diff --git a/.github/actions/setup/action.yml b/.github/actions/setup/action.yml
index ec451fa50..926cfc999 100644
--- a/.github/actions/setup/action.yml
+++ b/.github/actions/setup/action.yml
@@ -1,13 +1,41 @@
name: Set up Node and pnpm
description: Install the repository's pinned Node and pnpm toolchain and frozen dependencies.
+inputs:
+ node-version:
+ description: Optional Node major version override. The repository .nvmrc remains the default.
+ required: false
+ default: ""
+
runs:
using: composite
steps:
- uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
- - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
+ - name: Set up Node
+ uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
+ # setup-node gives node-version precedence when it is provided; otherwise it reads .nvmrc.
+ node-version: ${{ inputs.node-version }}
node-version-file: .nvmrc
cache: pnpm
+ - name: Verify the selected toolchain
+ env:
+ NODE_VERSION_OVERRIDE: ${{ inputs.node-version }}
+ run: |
+ expected_node="$NODE_VERSION_OVERRIDE"
+ if [ -z "$expected_node" ]; then
+ expected_node="$(tr -d '[:space:]' < .nvmrc)"
+ fi
+ case "$expected_node" in
+ *[!0-9]*) echo "node-version must be a numeric major, received: $expected_node" >&2; exit 1 ;;
+ esac
+ actual_major="$(node --print 'process.versions.node.split(".")[0]')"
+ test "$actual_major" = "$expected_node"
+
+ expected_pnpm="$(node --print 'JSON.parse(require("node:fs").readFileSync("package.json", "utf8")).packageManager.split("@").at(-1)')"
+ actual_pnpm="$(pnpm --version)"
+ test "$actual_pnpm" = "$expected_pnpm"
+ echo "Using Node $(node --version) and pnpm $actual_pnpm"
+ shell: bash
- run: pnpm install --frozen-lockfile
shell: bash
diff --git a/.github/workflows/node-compatibility.yml b/.github/workflows/node-compatibility.yml
new file mode 100644
index 000000000..eb9cdc910
--- /dev/null
+++ b/.github/workflows/node-compatibility.yml
@@ -0,0 +1,96 @@
+name: Node compatibility
+
+on:
+ workflow_dispatch:
+ schedule:
+ - cron: "23 5 * * 2"
+
+concurrency:
+ group: node-compatibility-${{ github.ref }}
+ cancel-in-progress: true
+
+permissions:
+ contents: read
+
+jobs:
+ application:
+ name: Node 26 application gate
+ runs-on: ubuntu-latest
+ timeout-minutes: 25
+ steps:
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
+ with:
+ persist-credentials: false
+ - uses: ./.github/actions/setup
+ with:
+ node-version: 26
+ - run: pnpm run gate
+
+ server:
+ name: Node 26 server gate and migration rehearsal
+ runs-on: ubuntu-latest
+ timeout-minutes: 35
+ steps:
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
+ with:
+ persist-credentials: false
+ - uses: ./.github/actions/setup
+ with:
+ node-version: 26
+ - run: pnpm run gate:server
+ - name: Rehearse released database migrations
+ run: timeout --signal=TERM --kill-after=30s 7m pnpm run rehearse:migrations
+
+ packaged-server:
+ name: Node 26 deployed server and import worker
+ runs-on: ubuntu-latest
+ timeout-minutes: 10
+ steps:
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
+ with:
+ persist-credentials: false
+ - uses: ./.github/actions/setup
+ with:
+ node-version: 26
+ - name: Test the packaged smoke harness
+ run: pnpm run policy:packaged-smoke:test
+ - name: Build deployed server bundles
+ run: pnpm --filter capacitylens-server build:runtime
+ - name: Assemble the production dependency tree
+ run: pnpm --filter capacitylens-server deploy --prod "$RUNNER_TEMP/capacitylens-server"
+ - name: Smoke test deployed server, import worker, and backups
+ env:
+ CAPACITYLENS_SMOKE_ARTIFACT_DIR: ${{ runner.temp }}/packaged-smoke-artifacts
+ run: timeout --signal=TERM --kill-after=30s 3m pnpm run smoke:packaged-server "$RUNNER_TEMP/capacitylens-server"
+ - name: Upload packaged smoke diagnostics
+ if: failure()
+ uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
+ with:
+ name: node-26-packaged-smoke-${{ github.run_id }}-${{ github.run_attempt }}
+ path: ${{ runner.temp }}/packaged-smoke-artifacts/
+ if-no-files-found: warn
+ retention-days: 7
+
+ chromium:
+ name: Node 26 Chromium E2E
+ runs-on: ubuntu-latest
+ timeout-minutes: 25
+ steps:
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
+ with:
+ persist-credentials: false
+ - uses: ./.github/actions/setup
+ with:
+ node-version: 26
+ - run: pnpm exec playwright install --with-deps chromium
+ - run: pnpm run e2e
+ - name: Upload browser diagnostics
+ if: failure()
+ uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
+ with:
+ name: node-26-chromium-${{ github.run_id }}-${{ github.run_attempt }}
+ path: |
+ playwright-report/
+ test-results/
+ if-no-files-found: warn
+ retention-days: 7
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 8cf381bfc..d728f68f5 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -15,6 +15,8 @@ new features and **patch** versions carry fixes.
### Changed
+- Prepare experimental Node 26 compatibility checks while retaining Node 24 as the default;
+ official fixed-runtime acceptance remains pending (#710).
- Present Google and Microsoft together above the password fallback. Provider-required
installations reject password and GitHub sign-in; mixed installations retain existing
password and GitHub behavior. Microsoft live-tenant validation remains pending (#1219).
diff --git a/README.md b/README.md
index fde92214e..71414655a 100644
--- a/README.md
+++ b/README.md
@@ -73,6 +73,7 @@ asked "can we take this on?" and need a shared, honest answer.
## Run it for real
Node 24 and pnpm are required; the pinned version is in `.nvmrc`.
+Node 26 compatibility is still under investigation; see [the compatibility notes](docs-src/reference/development.md#check-node-26-compatibility).
```bash
nvm use
diff --git a/docs-src/.vitepress/config.mts b/docs-src/.vitepress/config.mts
index 0efba94f7..4525cea9f 100644
--- a/docs-src/.vitepress/config.mts
+++ b/docs-src/.vitepress/config.mts
@@ -154,6 +154,7 @@ const referenceSidebar = [
{ text: "OpenSSF Baseline assessment", link: "/security/OpenSSF-best-practices-dev" },
{ text: "Control inventories", link: "/security/control-inventories" },
{ text: "Development guide", link: "/reference/development" },
+ { text: "Node 26 discovery", link: "/reference/node26-discovery" },
{ text: "Code conventions", link: "/reference/conventions" },
{ text: "Open source and contributing", link: "/open-source" },
],
diff --git a/docs-src/reference/development.md b/docs-src/reference/development.md
index cefc52863..ba3212cc7 100644
--- a/docs-src/reference/development.md
+++ b/docs-src/reference/development.md
@@ -30,6 +30,68 @@ corepack enable
pnpm install
```
+## Check Node 26 compatibility
+
+Node 24 remains the default development and deployment runtime. Node 26 coverage is
+experimental: Node 26.8.2 can delay SQLite backup completion until another timer fires.
+Official [Node 26.9.0](https://nodejs.org/en/blog/release/v26.9.0) includes the upstream
+[callback-scope fix](https://github.com/nodejs/node/pull/65666). Project acceptance
+against that release is still pending. Follow [issue #710](https://github.com/Kevinjohn/capacitylens/issues/710)
+for the current decision and the [consolidated discovery record](/reference/node26-discovery)
+for the evidence, corrected findings and remaining acceptance checks. Proposed minimums
+are 24.19.0 and, conditionally, 26.9.0; these are not yet enforced support ranges.
+Do not use a locally patched runtime for deployment.
+
+The `node-compatibility.yml` workflow prepares separate application, server and Chromium
+checks using Node 26. Its manual and weekly triggers take effect only after the workflow
+is merged; activation is held pending the compatibility decision in #710. Existing
+workflows continue selecting `.nvmrc`. The shared setup action accepts a `node-version`
+override containing a numeric major (for example, `26`) and verifies the repository's pnpm pin.
+
+For isolated compatibility testing on macOS or Linux, select the intended Node 26 binary
+in your shell without changing `.nvmrc`. Confirm its exact version:
+
+```bash
+node --version
+```
+
+Node 26 does not bundle Corepack. Install the pinned pnpm with the
+[standalone installer](https://pnpm.io/installation#using-a-standalone-script).
+From the repository root, download the installer:
+
+```bash
+curl --fail --show-error --location https://get.pnpm.io/install.sh --output /tmp/install-pnpm.sh
+```
+
+Install the version named by `packageManager`:
+
+```bash
+env PNPM_VERSION="$(node --input-type=module -e 'import { readFileSync } from "node:fs"; console.log(JSON.parse(readFileSync("package.json", "utf8")).packageManager.split("@")[1])')" sh /tmp/install-pnpm.sh
+```
+
+Follow the installer's printed shell setup instructions, then check the selected versions:
+
+```bash
+node --version
+```
+
+```bash
+pnpm --version
+```
+
+The pnpm version must match `package.json` (currently `11.4.0`). Install dependencies
+without changing the lockfile:
+
+```bash
+pnpm install --frozen-lockfile
+```
+
+Run the normal application and server gates, migration rehearsal and Chromium suite listed
+below. Also run the packaged server/import-worker smoke from the compatibility workflow:
+source-based browser tests alone do not execute those production bundles. Record the
+commit, exact runtime and platform with the results. A failed check remains a failed
+compatibility result; do not add timers or relax timeouts to conceal the backup defect.
+
## Run modes
```bash
diff --git a/docs-src/reference/node26-discovery.md b/docs-src/reference/node26-discovery.md
new file mode 100644
index 000000000..96eb32c40
--- /dev/null
+++ b/docs-src/reference/node26-discovery.md
@@ -0,0 +1,173 @@
+---
+title: Review the Node 26 discovery
+description: Evidence, proposed runtime minimums and remaining acceptance checks for Node 24 and Node 26 support.
+---
+
+# Review the Node 26 discovery
+
+This reference preserves the findings for contributors assessing [issue #710](https://github.com/Kevinjohn/capacitylens/issues/710). It records the investigation as of 11 September 2026 and the candidate in [draft PR #779](https://github.com/Kevinjohn/capacitylens/pull/779). It is evidence for a future support decision, not a release announcement.
+
+**Status update, 21 September 2026:** Official [Node 26.9.0](https://nodejs.org/en/blog/release/v26.9.0)
+shipped on 16 September with the upstream SQLite backup fix. The project has not completed
+its official-runtime acceptance checks, so Node 24 remains the supported default. The
+11 September observations below are preserved as recorded.
+
+## Decision and release boundary
+
+Node 24 remains the development, build and release baseline. The proposal is one codebase supporting Node 24 and Node 26 with separate minimum versions:
+
+| Major | Proposed minimum | Reason and acceptance condition |
+| --- | --- | --- |
+| 24 | 24.19.0 | Verified baseline. The investigation did not establish whether an earlier version is sufficient. |
+| 26 | 26.9.0, conditional | The official release must contain the SQLite callback-scope fix and pass native Linux/macOS probes and complete compatibility validation. Otherwise use the first later official release that does. |
+
+At the last release check, 26.8.2 was the latest published Node 26 release and still contained the defect. The [26.9.0 release proposal](https://github.com/nodejs/node/pull/65881) was open and draft. Its inspected head, `b37bb627f5d2b70096f6dc75d4c5ac9cfe3662fd`, contained the backport (`113180ad3f`); this is not evidence that an official fixed release has shipped. Both the inspected official 26.8.2 source and then-current v26.x source lacked the fix.
+
+The intended policy is to test the minimum and latest patch of each supported major, recommend current patches, and raise floors when security or correctness requires it. The existing `>=24` package engines and major-only server preflight do **not** enforce these proposed floors or restrict support to two majors. The candidate setup override accepts numeric majors only; exact-minimum selection and a minimum/latest matrix remain implementation work.
+
+Long-term support remains bounded by upstream maintenance. The [Node release schedule](https://github.com/nodejs/Release/blob/main/schedule.json), as checked during discovery, gives Node 24 an end date of 30 April 2028 and Node 26 an LTS start of 28 October 2026 and end date of 30 April 2029.
+
+PR #779 remains draft and unmerged. Its manual/weekly compatibility workflow is prepared but inactive. Discovery does not authorise merging, activating that schedule, deploying, releasing, changing the default runtime, or distributing an experimental patched Node binary.
+
+## Correct the initial diagnosis
+
+The first official Node 26.8.2 application run had 3,987 passing tests and eight failures. The first server run had 1,868 passing tests and 11 failures. These were different problems:
+
+| Finding | Final interpretation |
+| --- | --- |
+| Eight application failures | Test storage objects and prototypes disagreed under Node 26/jsdom, so spies missed quota/error injection. |
+| Nine backup-related server failures | Node SQLite completed native work but could delay JavaScript completion until another callback ran. |
+| One TLS failure | PATH selected macOS LibreSSL, which lacked `-copy_extensions`; OpenSSL 3.6.4 passed the 17 focused tests. No certificate-code fix was needed. |
+| One restore timeout | Passed in isolation; an independent restore defect was not established. |
+
+“Node 26 backups always hang” was an incorrect early generalisation. No-timer probes and normal packaged startup/periodic backups succeeded. The reproducible defect depends on the event-loop arrangement. No corrupted snapshots were observed; that does not prove every backup workload is safe.
+
+Initial frontend builds, 264 Chromium tests, account/credential/migration checks, a v7-to-v38 rehearsal, package deployment into scratch space and an import smoke also passed. Those successes did not make the unchanged application or server gate green.
+
+## Reproduce the native backup defect
+
+The same 18-case matrix ran on native Linux x64 and macOS arm64. Six source/destination combinations covered in-memory, rollback-journal and WAL sources, each with a new or empty pre-created destination. Children had an eight-second parent deadline and were terminated before cleanup. Completed snapshots were reopened for row and integrity checks.
+
+| Runtime, on each platform | No timer | Referenced 60-second timer | 10 ms interval |
+| --- | --- | --- | --- |
+| 24.19.0 | 6/6 | 6/6 | 6/6 |
+| 26.8.1 | 6/6 | 0/6 within deadline | 6/6 |
+| 26.8.2 | 6/6 | 0/6 within deadline | 6/6 |
+
+The [no-timer Linux run](https://github.com/Kevinjohn/capacitylens/actions/runs/34518249150) used commit `8c17a47cc0ab5565435f472b7dda6d5844c9359a`. The [expanded Linux run](https://github.com/Kevinjohn/capacitylens/actions/runs/34518443811) used `2491e34915e31c458a7416014e03bf486430ced2`; its two Node 26 jobs failed as expected. The [probe at that exact commit](https://github.com/Kevinjohn/capacitylens/blob/2491e34915e31c458a7416014e03bf486430ced2/investigation/sqlite-backup-probe.mjs) preserves the executable reproduction. The diagnostic branch is not a merge candidate.
+
+A further nine-case macOS matrix used 300 rows, positive `rate: 1`, fresh, empty pre-created and valid existing destinations, hashes and reopened integrity checks. It passed without the problematic timer on 24.19.0, 26.8.1 and 26.8.2. Timing probes with 100 ms, one-, two- and five-second referenced timers showed completion following the timer. An unreferenced future timer allowed prompt completion. Node 26.7.0 showed the same pattern; the first affected Node version was not bisected.
+
+Stock Node 24.19.0 and 26.8.2 both passed packaged startup and periodic backup checks: import a changed resource, leave the server without HTTP traffic for 65 seconds, verify a later snapshot contains the changed row and passes integrity, then shut down cleanly. The application's periodic timer is unreferenced. These results cover an ordinary operating path, not every arrangement of other timers, concurrent writes, sustained retention, recovery or shutdown during the reproduced delay.
+
+## Explain and verify the upstream fix
+
+`async_hooks` tracing separated native Promise resolution from its JavaScript continuation. On stock 26.8.2, resolution happened around 1.186 ms, but `await` resumed around 1,002.907 ms after the timer at 1,002.624 ms. Node 24 resolved around 0.876 ms and resumed around 0.896 ms. Rejection after a throwing progress callback showed the same delay.
+
+File-read and PBKDF2 controls, plus several caller arrangements, isolated the behaviour to this completion path. A standalone native `uv_queue_work` module without SQLite reproduced it: a bare Node 26 callback took about 502.760 ms to deliver the continuation with a 500 ms timer; adding `node::CallbackScope` reduced that to about 0.0905 ms. Corresponding Node 24 results were 0.336 ms and 0.139 ms. These timings are individual diagnostic observations, not performance benchmarks.
+
+[Node PR #65666](https://github.com/nodejs/node/pull/65666), merged on 2 September, fixes the missing scope in `BackupJob::AfterThreadPoolWork` in `src/node_sqlite.cc`. Exact commit: [`6e7818e4f6d2d0429a2ebd441868af19bf7333d2`](https://github.com/nodejs/node/commit/6e7818e4f6d2d0429a2ebd441868af19bf7333d2). It adds context and internal callback scopes around the completion function, covering both resolution and rejection. A duplicate upstream report is unnecessary; the earlier report draft is superseded.
+
+The upstream regression fixture makes backup the final active request: it copies a 1 MiB blob with rate one, clears its heartbeat from the progress callback, and asserts at `beforeExit` that completion ran. The fixture is `test/fixtures/sqlite/backup-last-request.mjs`; the enclosing suite is `test/parallel/test-sqlite-backup.mjs`.
+
+### Controlled source comparison
+
+The official 26.8.2 source was configured with `./configure --without-npm` and built with `make -j4`. The baseline binary was retained, then the exact three-file upstream patch was applied and rebuilt with the same source base and compiler. Nothing was installed as the machine default. The patched executable still identifies itself as 26.8.2; it is not an official 26.9.0 build.
+
+| Check | Baseline source build | Exact patched source build |
+| --- | --- | --- |
+| Upstream last-request fixture | Failed | Passed |
+| 18-case timer matrix | Six failures | 18/18 passed |
+| Success trace | Native 0.777 ms; continuation 1,003.239 ms | Native 0.8345 ms; continuation 0.8511 ms, before timer |
+| Rejection trace | 502.952 ms | 0.3862 ms, before timer |
+| Full upstream SQLite backup suite | Not used as a baseline acceptance claim | 18/18 passed |
+
+Preserved SHA-256 identifiers:
+
+| Artifact | SHA-256 |
+| --- | --- |
+| Official 26.8.2 source `.tar.xz` | `36b37bf5ee4d092b9d9dff2d1a90b1444f8b453eddf6ff96cabdebb97d32f41d` |
+| Baseline compiled executable | `725c171df50b188063f13acb8a23fc40425431fc95e4c98f6985eb7007c43030` |
+| Patched compiled executable | `a815a34351bf28c4655ebffa07c4a8eb3da984496dd2e0e2262cc566b606635e` |
+| Baseline `node_sqlite.cc` | `ef9821838ee1eed603cd84f63a04e5a88dbe5dca625173e6dad556349f92aaff` |
+| Patched `node_sqlite.cc` | `f844c3245159f7e64ffd7046dc12fd0ca27d79f60c3309300a61c090df814961` |
+| Saved upstream patch | `377a85407d95480eba399f11ae3e483b03d6f41986cda378df1bc6ff76f5842f` |
+| Official 26.8.2 macOS ARM64 archive | `974b6d5fb2fc7c33ff2354db0902b4e91c2de01ec8acc6de48e543c97e18c9e1` |
+
+On CapacityLens base `37ee79fe40754554c3d09275293539e185b56f0f`, the patched runtime passed the server gate (1,879 coverage tests, 56 account tests, three durability tests and three migration tests), packaged periodic smoke and v7-to-v38 rehearsal. The rehearsal covered 17 tables and 46 rows, value preservation, rollback, ENOSPC, termination and idempotence. Chromium had 262 first-attempt passes and two draw-mode setup failures that passed on retry; their cause remains unproven.
+
+## Fix the test storage mismatch
+
+Node 26's native storage globals interacted with jsdom and the existing fallback. The local store could be `MemoryStorage` while the session store used a different prototype from the intercepted `Storage.prototype`. Global/window object identity alone therefore did not establish compatible storage. Eight tests missed their intended quota/error injections.
+
+Passing `--localstorage-file` removed a warning but left all eight failures. Assigning the jsdom prototype to the fallback object caused `Illegal invocation`. A scratch setup with two independent memory stores and a matching constructor passed 82 focused tests on both runtimes and then all 3,995 application tests on Node 26; that scratch test run was not a full gate.
+
+The maintained candidate in `src/test/setup.ts` aligns the constructor, prototypes and window/global aliases, using independent stores when storage is missing, unusable or mismatched. Reading a storage getter suppresses only the expected `DOMException` named `SecurityError` for an opaque origin. The normal Node 24 path remains intact. `src/test/storageCompatibility.test.ts` checks aliases, independence and prototype-based failure injection. The focused maintained suite passed 83/83 on both runtimes; the complete maintained application suite passed 3,996/3,996. This is test-environment compatibility work and does not change product persistence.
+
+## Record the candidate and validation accurately
+
+The implementation milestone is [`c87660b920f75a29ab0e0cfef822fc274fb78efa`](https://github.com/Kevinjohn/capacitylens/commit/c87660b920f75a29ab0e0cfef822fc274fb78efa). Later documentation commits preserve this evidence without rerunning unrelated application suites.
+
+The candidate prepares four separate Linux compatibility jobs: application gate, server gate with rehearsal, packaged smoke and Chromium. It uses pinned actions, read-only permissions, timeouts, concurrency control and failure artifacts. Existing workflows still select `.nvmrc`. The setup action checks the selected major, pnpm 11.4.0 and frozen installation.
+
+The maintained packaged smoke checks the deployed server and import worker, startup and later periodic snapshots, changed contents, integrity and shutdown. It isolates inherited application settings, uses temporary fictional data and loopback networking, bounds polling and shutdown, and surfaces early failures. Two harness tests cover environment isolation and an actually failing entrypoint with prompt cleanup.
+
+| Runtime and scope | Recorded result at the implementation milestone |
+| --- | --- |
+| Official 24.19.0 application gate | Passed; 3,996 tests in 222 files, static checks, coverage, audit and build |
+| Official 26.8.2 application gate | Passed; same 3,996 tests using maintained storage setup |
+| Official 24.19.0 server gate | Passed; 1,879 coverage, 56 account, three durability and three migration tests |
+| Official 24.19.0 Chromium | 264 passed without retries |
+| Official 24.19.0 and experimental patched 26 packaged smoke | Startup, periodic content/integrity and shutdown passed |
+| Both of those runtimes, smoke harness tests | 2/2 passed |
+| Default 24 and override 26 setup version checks | Passed |
+| Fresh standalone pnpm 11.4.0 and frozen install on official 26 | Passed |
+| Workflow syntax | actionlint 1.7.12 passed |
+| Documentation and PR analysis | Documentation build/visual checks and CodeQL passed |
+| Existing Node 24 GitHub gate | [Run 34540727802](https://github.com/Kevinjohn/capacitylens/actions/runs/34540727802) passed on `c87660b9` |
+
+The GitHub result covers that gate workflow, not every available CI workflow. Its DCO job was intentionally skipped for manual dispatch; signed commits were checked separately. The new Node 26 workflow itself was not dispatched or activated. Complete server, browser and backup acceptance against an **official fixed Node 26 release remains outstanding**.
+
+### Preserve unsuccessful and invalid runs
+
+An early restricted Vitest run failed to create `.vite-temp`; `--configLoader runner` avoided that environment problem. A scratch setup placed outside the worktree failed discovery before tests; moving it into an ignored local cache allowed the experiment. Neither was an application regression.
+
+A supposed Node 24 server attempt was invalid because Homebrew PATH precedence selected Node 26.8.1. It was stopped and is not counted as Node 24 evidence. The corrected attempt found a documentation-fragment failure (1,878 passing tests); fixing the automatic heading slug passed five focused tests and the later complete gate. Use `node --version` after runtime activation and preserve the selected runtime's PATH precedence.
+
+Node 26 experimental storage warnings remained warnings. Earlier patched-runtime browser flakes remain recorded even though the final Node 24 browser run passed cleanly. Passing application tests on official 26.8.2 does not override the separate native backup failure.
+
+## Reject workarounds and bound the risk
+
+Do not declare 26.8.2 the supported minimum: it retains the demonstrated defect. Rolling back to 26.7 does not solve it. A heartbeat makes the reproduction complete but masks the missing callback scope; longer timeouts also conceal it. Replacing the production backup implementation would introduce separate durability and shutdown risks and was not justified by this discovery.
+
+The unrelated [zero-rate backup issue #64892](https://github.com/nodejs/node/issues/64892) and [fix #64893](https://github.com/nodejs/node/pull/64893) are not this diagnosis: these probes use default or positive rates. No production data, database schema, Node types, build target or default runtime changed. No Docker validation or changes were part of this work.
+
+The return is earlier runtime-regression detection and a tested path to the next maintained major without a second application implementation. Most candidate tooling already exists. The remaining effort depends on the official release and acceptance results; exact minimum enforcement and matrix coverage still need implementation. The main risk is prematurely calling a runtime supported on the strength of frontend checks or an experimental binary. Keeping adoption conditional limits that risk; recurring CI adds maintenance and execution cost.
+
+## Finish acceptance before adoption
+
+1. Inspect the actual official release source and release metadata for #65666; record its exact version and artifact hashes. If 26.9.0 lacks the fix, move the proposed floor forward.
+2. Repeat native Linux x64 and macOS ARM64 matrices, including resolution/rejection and the last-active-request regression, with that official binary.
+3. Run application and server gates, rehearsal, packaged startup/periodic backup checks and Chromium against the official release. Investigate any failures rather than importing experimental-build results as acceptance.
+4. Implement exact minimum/latest selection for both majors. Align package engines, runtime preflight, CI selection and operator documentation with the approved support range. Verify rejection below the floors and handling of other majors.
+5. Record results, residual limitations and the support decision in #710 and PR #779. Keep Node 24 as default unless a separate decision changes it.
+6. Obtain the explicit go-ahead to merge/activate or release. Documentation preservation does not remove the current hold.
+
+## Locate the preserved evidence
+
+The public record consists of this committed report, the issue/PR history, immutable source/probe links and the linked CI runs. The [milestone comment](https://github.com/Kevinjohn/capacitylens/issues/710#issuecomment-5626734080) and [minimum-version clarification](https://github.com/Kevinjohn/capacitylens/issues/710#issuecomment-5626874401) record the agreed boundaries.
+
+A private durable local archive retains 1,601 available evidence files, including raw logs, reproduction scripts, source archives, the exact patch and both controlled-build executables. Its manifest lists every file's size and SHA-256 and identifies excluded reproducible dependency caches and build trees. Every archived file and the archive digest were verified. The archive SHA-256 is `0250457237e3bdd3a9b1003248c3ffb97ea7f7a0423797996e5042b2912d571e` (457,036,240 bytes). Original scratch files were retained. This is a local copy, not an off-machine backup; raw records require privacy review before external publication.
+
+| Archived record | Purpose |
+| --- | --- |
+| `investigation.md`, `server-findings.md`, `storage-findings.md` | Initial observations, including subsequently corrected conclusions |
+| `phase2-report.md`, `phase2-linux/`, `phase2-macos/` | Native probes, platform matrices and snapshot checks |
+| `phase3/report.md` and phase-three traces | Completion mechanism, native controls and before/after evidence |
+| `phase3-build/runtime-build-metadata.json` | Source, patch and binary provenance |
+| `phase3-build/baseline-node`, patched `out/Release/node` | Actual controlled-build executables; research only |
+| `phase4/report.md` and phase-four logs | Maintained candidate checks and saved GitHub gate output |
+
+CI artifacts can expire. Their available local copies and this consolidated report preserve the findings independently of that retention window. Historical scripts contain scratch paths and historical reports contain superseded interpretations; use this report for conclusions and inspect scripts before rerunning them. Current issue/PR snapshots and the final report are stored alongside the archive as supplementary records.
+
+For maintained commands, see [Check Node 26 compatibility](/reference/development#check-node-26-compatibility).
diff --git a/docs-src/self-hosting/configuration.md b/docs-src/self-hosting/configuration.md
index 4386551f1..1d60598b8 100644
--- a/docs-src/self-hosting/configuration.md
+++ b/docs-src/self-hosting/configuration.md
@@ -19,7 +19,8 @@ everything specific to the app itself uses `CAPACITYLENS_`.
## Listener and development settings
-For a bare-metal run, use Node 24 or newer and run `pnpm --filter capacitylens-server start`.
+For a bare-metal run, use the default Node 24 runtime and run `pnpm --filter capacitylens-server start`.
+Newer versions allowed by the package engine range are not automatically validated for deployment.
The server binds to localhost by default. Set the host explicitly to expose it on a network.
| Variable | What it does |
diff --git a/docs/installation/configure-the-service.html b/docs/installation/configure-the-service.html
index 0490339c7..49b9dd4b2 100644
--- a/docs/installation/configure-the-service.html
+++ b/docs/installation/configure-the-service.html
@@ -18,7 +18,7 @@
This is the complete configuration reference within the technical installation track. Apply the settings for your chosen installation route, then restart or rebuild as each setting requires.
CapacityLens is configured entirely through environment variables, read from .env by Docker Compose or set directly for a bare-metal run. .env.example in the repository is the complete, authoritative register with defaults — this page groups the variables that matter for a self-hosted install by what you're trying to do. Server variables (CAPACITYLENS_*, SMALLSASS_ACCOUNT_*) take effect on restart. Client variables (VITE_CAPACITYLENS_*) are baked into the web app at build time, so changing one needs a rebuild. Two prefixes are deliberate: sign-in and accounts are built as a separable platform component, so their settings carry the SMALLSASS_ACCOUNT_ prefix, while everything specific to the app itself uses CAPACITYLENS_.
For a bare-metal run, use Node 24 or newer and run pnpm --filter capacitylens-server start. The server binds to localhost by default. Set the host explicitly to expose it on a network.
Variable
What it does
PORT
Listen port. Default 8787; invalid values outside the integer range 1–65,535 refuse startup.
CAPACITYLENS_HOST
Listen host. Default 127.0.0.1; set 0.0.0.0 to expose the listener on the LAN or in a container.
CAPACITYLENS_ALLOW_RESET
Set 1 to expose POST /api/test/reset for development and tests with sign-in off. Production refuses this setting.
CAPACITYLENS_OPTIMISTIC_CONCURRENCY
Enabled by default. Set 0 only to allow stale writes to overwrite newer changes.
CAPACITYLENS_CREATE_ADMIN_ADMIN
Development-only first-owner helper, also available as --create-owner-admin-admin. Creates admin@admin.admin only when the password user table is empty. Production refuses this setting.
CAPACITYLENS_BOOTSTRAP_ADMIN_PASSWORD
Required password for that development-only owner helper. For production, use the account setup token instead.
off, password or sso. off creates no sign-in at all; production refuses to boot with it unset unless you explicitly opt in (see below).
SMALLSASS_ACCOUNT_DEPLOYMENT_PROFILE
An optional named policy: self-hosted-password, self-hosted-mixed, self-hosted-sso-only or hosted-sso-only. Enforced at startup.
SMALLSASS_ACCOUNT_SECRET
The session-signing secret. Required for password or sso mode. Generate with openssl rand -base64 48 — anything 32 characters or longer is fine; the install guide's command produces 48.
SMALLSASS_ACCOUNT_PUBLIC_URL
The exact browser-facing origin, for example https://capacity.example.com. Required for password or sso mode.
SMALLSASS_ACCOUNT_SETUP_TOKEN
The one-time secret the first owner enters on a fresh password-mode instance. For Google/Microsoft setup, use SMALLSASS_ACCOUNT_PROVIDER_BOOTSTRAP_EMAILS for the first identity or a pre-authorised invitation after that.
SMALLSASS_ACCOUNT_ALLOW_OPEN_SIGNUP
Re-opens self-service sign-up. Closed by default — CapacityLens is invite-only unless you set this. Leave it unset in production.
CAPACITYLENS_ALLOW_OPEN_IN_PRODUCTION
Deliberately allows the auth-off (off) posture under production. Off by default; without it, a production instance with no sign-in refuses to start.
Treat SMALLSASS_ACCOUNT_SETUP_TOKEN as a short-lived bootstrap secret. Give the first owner the value through a secure channel; never paste it into chat, tickets, screenshots, command output or logs. The owner copies the value from the server .env file or installer into the matching field. Do not add surrounding quote characters or whitespace in the browser: the submitted value must match the configured secret exactly.
After the first owner account and company have been created, remove SMALLSASS_ACCOUNT_SETUP_TOKEN from the server environment and restart the server. First-owner signup already closes as soon as the first identity exists, but removing the secret invalidates the handoff material instead of leaving it available to operators or future processes.
Use Set up company login for the registration steps. Google and Microsoft are company providers. Configure either or both; a partial credential pair refuses startup. password mode retains the password form, while sso mode permits only configured company providers. GitHub remains experimental in mixed mode and cannot provide company-only access.
Application ID and secret value from your Microsoft Entra app registration.
SMALLSASS_ACCOUNT_MICROSOFT_TENANT_ID
Required organisation tenant GUID. common, organizations, personal-account tenants and a missing value are refused.
SMALLSASS_ACCOUNT_PROVIDER_BOOTSTRAP_EMAILS
Comma-separated company email addresses allowed to create the first named-provider identity. Later new identities require an unused invitation addressed to them.
Optional credentials for the existing experimental GitHub sign-in in mixed mode.
SMALLSASS_ACCOUNT_SSO_MFA_ENFORCED
Operator attestation that the company provider requires multi-factor sign-in. Set it only after checking the upstream policy.
Register https://your-capacitylens-address/api/auth/callback/google for Google and https://your-capacitylens-address/api/auth/callback/microsoft for Microsoft, using your actual HTTPS origin. These paths must match exactly. Restart after changing server settings.
Microsoft first connections may need a one-time email verification. All five SMTP settings are required whenever Microsoft is configured, even if a particular identity arrives with adequate verified-email claims. An SMTP failure blocks completion without granting access and allows retry. Ordinary returning Microsoft sign-in does not require another verification email.
Variable
What it does
SMALLSASS_ACCOUNT_MAIL_HOST
Your SMTP service hostname.
SMALLSASS_ACCOUNT_MAIL_PORT
SMTP port. Port 465 uses implicit TLS; other ports require STARTTLS. Certificate verification remains enabled.
SMALLSASS_ACCOUNT_MAIL_USER
SMTP authentication username.
SMALLSASS_ACCOUNT_MAIL_PASSWORD
SMTP password or service credential. Keep it in the server's secret configuration.
SMALLSASS_ACCOUNT_MAIL_FROM
A valid sender email address authorised by the SMTP service; use the address without a display name.
The verification link expires after 15 minutes and must be confirmed in the browser that started sign-in. See the company-login guide for resend, expiry and account-connection recovery.
hosted-sso-only is reserved for hosted deployments. It requires mode=sso and complete Google and/or tenant-specific Microsoft configuration; it rejects passwords, GitHub, open signup and incomplete provider settings. Self-hosted installations that require company sign-in use self-hosted-sso-only. See Require company sign-in.
The retired generic OIDC settings and hosted-oidc-only profile are rejected at startup. Remove those settings and configure Google and/or Microsoft explicitly; CapacityLens does not fall back to password or sign-in-off mode.
Path to the SQLite file. Docker Compose pins this to /data/capacitylens.db inside a named volume; only change it for a bare-metal run.
CAPACITYLENS_BACKUP_DIR
Directory for scheduled snapshots. On by default in Docker (/backups). Set it explicitly empty (CAPACITYLENS_BACKUP_DIR=) to turn scheduled backups off.
CAPACITYLENS_BACKUP_INTERVAL_MIN
Minutes between snapshots. Whole minutes, default 60; startup clamps over-maximum values to 35,000 with a warning.
CAPACITYLENS_BACKUP_KEEP
How many snapshots to retain. Default 48. Invalid and lower values use the safe default; over-maximum values clamp to 10,000 with a startup warning, so leave disk capacity for that many restore points.
CAPACITYLENS_AUDIT_FILE
Path to the audit log. Default capacitylens-audit.jsonl next to CAPACITYLENS_DB; Docker Compose pins it to /data/capacitylens-audit.jsonl. Only read when audit logging is on.
CAPACITYLENS_AUDIT_MAX_MB
Audit log rotation size cap in MB. Default 64 — once the file reaches this size it's rotated to <file>.1 (replacing any previous .1), bounding disk use to roughly twice the cap.
See Backups and restore for what these snapshots protect against and how to use them. Back up the rotated <file>.1 audit file alongside the current one — a restore that only picks up the live file can miss recent audit history still sitting in the rotated generation.
For a bare-metal run, the database defaults to ./capacitylens.db; :memory: is also accepted. Scheduled backups stay off unless CAPACITYLENS_BACKUP_DIR is set. Positive fractional retention counts are rounded down.
Audit logging is on by default. Set CAPACITYLENS_AUDIT=off only for development; production refuses disabled audit. Each mutation record contains ts, userId, accountId, action, entity, id and changedFields. Changed fields are names, never their values. A memory-only database uses a working-directory-relative audit file.
CAPACITYLENS_AUDIT_MAX_MB accepts integers from 1 to 1,048,576; missing or invalid values use 64 MiB. Rotation happens before a record would cross the cap. A single record larger than the cap is rejected and remains queued in the audit outbox. The size setting is only read when audit logging is enabled.
Comma-separated browser origins to allow, only needed if the web app and API are on different origins. Defaults to local development origins. Wildcards are rejected because browser requests use cookie credentials.
CAPACITYLENS_HTTPS
Set 1 when the public origin is genuinely HTTPS, to enable a two-year HSTS header. Leave unset if your proxy already emits HSTS.
CAPACITYLENS_TRUST_PROXY_HEADERS
Trusts X-Forwarded-For/X-Forwarded-Proto from a non-loopback listener. Docker Compose sets this to 1 because its API only accepts connections from the packaged nginx. Loopback listeners (127.0.0.1, localhost, ::1) trust their same-host proxy automatically without this flag.
The HTTPS setting enables HSTS including subdomains. Leave it off for plain HTTP. The other baseline security headers are always enabled.
Variable
What it does
CAPACITYLENS_INTERNAL_TLS_CERT
PEM certificate path for the internal reverse-proxy/API connection.
CAPACITYLENS_INTERNAL_TLS_KEY
Matching PEM private-key path. Omit both paths for HTTP on a trusted same-host loopback connection. A partial or unreadable identity refuses startup. Compose creates an identity per installation.
CAPACITYLENS_INTERNAL_TLS_GENERATION
Optional SHA-256 marker for the exact loaded certificate.
Off by default: one company per instance. Set 1 to allow more than one.
CAPACITYLENS_BOOTSTRAP_TOKEN
A shared secret for creating an additional company through the API when the caller isn't already an owner or admin of one. Only matters when CAPACITYLENS_MULTI_ACCOUNT=1.
CAPACITYLENS_SEED_DEMO
Seeds a two-company sample dataset on a never-initialised database. Only makes sense paired with CAPACITYLENS_MULTI_ACCOUNT=1; use it for a throwaway or demo instance, not a real one.
A fresh database starts empty unless demo seeding is explicitly enabled. The company limit applies in every sign-in mode, including off. The bootstrap token is sent in x-capacitylens-bootstrap-token to POST /api/orgs; an empty or unset token disables that path. Without it, company creation requires first-run setup or an existing owner or admin.
Set 1 for structured per-request JSON logs. Recommended for a real deployment.
CAPACITYLENS_HEALTH_DEEP
Set 1 to make /api/health run a readiness query and report audit, backup and certificate status. Compose sets this by default.
CAPACITYLENS_RATE_LIMIT
Requests per minute per IP across rate-limited routes. Accepts integers 1–1,000,000. Production refuses missing, zero or invalid values. /api/health is exempt.
CAPACITYLENS_AUDIT_STDOUT
Set 1 to also write each audit record to stdout as JSON, for a container log collector. Compose defaults this on.
CAPACITYLENS_STORAGE_ENCRYPTED
Set 1 only after you have verified that the database, audit log and backup storage are encrypted at rest. This is an operator attestation; it does not encrypt storage itself.
CAPACITYLENS_SECURITY_LOG_FORWARDING
An attestation that you're forwarding audit and security events to a separate collector. Doesn't create the collector itself.
Without structured logging, the server prints its startup line and reports server errors to stderr. Deep health checks are off by default: /api/health returns { ok: true }. With deep checks enabled, the endpoint runs SELECT 1, reports audit state and pending records, and includes internal certificate expiry when configured. Failed readiness returns HTTP 503 with { ok: false }.
The API origin the built app talks to. Leave empty for the normal same-origin build (the app calls a relative /api, which nginx proxies). Only set this to point the app at a different origin.
VITE_CAPACITYLENS_DEMO
Set 1 to build the in-memory demo instead of the real app. Wins over VITE_CAPACITYLENS_API if both are set.
VITE_CAPACITYLENS_BUILD_SHA
Optional build identifier shown in Settings, typically the git commit.
VITE_CAPACITYLENS_FEEDBACK_MAILTO
Optional email address for the in-app feedback link. Leave empty to hide the link.
Any of these needs a rebuild to take effect. Use docker compose build web for the packaged production stack, or pnpm run build for a direct Node installation, then redeploy the rebuilt web files. Setting them only in a running process does nothing.
CapacityLens accepts only the SMALLSASS_ACCOUNT_* account variables documented above. If you're upgrading an installation that predates this namespace, follow the account-variable rename procedure before starting the new release. Startup refuses a configured removed name and identifies its replacement.
This is the complete configuration reference within the technical installation track. Apply the settings for your chosen installation route, then restart or rebuild as each setting requires.
CapacityLens is configured entirely through environment variables, read from .env by Docker Compose or set directly for a bare-metal run. .env.example in the repository is the complete, authoritative register with defaults — this page groups the variables that matter for a self-hosted install by what you're trying to do. Server variables (CAPACITYLENS_*, SMALLSASS_ACCOUNT_*) take effect on restart. Client variables (VITE_CAPACITYLENS_*) are baked into the web app at build time, so changing one needs a rebuild. Two prefixes are deliberate: sign-in and accounts are built as a separable platform component, so their settings carry the SMALLSASS_ACCOUNT_ prefix, while everything specific to the app itself uses CAPACITYLENS_.
For a bare-metal run, use the default Node 24 runtime and run pnpm --filter capacitylens-server start. Newer versions allowed by the package engine range are not automatically validated for deployment. The server binds to localhost by default. Set the host explicitly to expose it on a network.
Variable
What it does
PORT
Listen port. Default 8787; invalid values outside the integer range 1–65,535 refuse startup.
CAPACITYLENS_HOST
Listen host. Default 127.0.0.1; set 0.0.0.0 to expose the listener on the LAN or in a container.
CAPACITYLENS_ALLOW_RESET
Set 1 to expose POST /api/test/reset for development and tests with sign-in off. Production refuses this setting.
CAPACITYLENS_OPTIMISTIC_CONCURRENCY
Enabled by default. Set 0 only to allow stale writes to overwrite newer changes.
CAPACITYLENS_CREATE_ADMIN_ADMIN
Development-only first-owner helper, also available as --create-owner-admin-admin. Creates admin@admin.admin only when the password user table is empty. Production refuses this setting.
CAPACITYLENS_BOOTSTRAP_ADMIN_PASSWORD
Required password for that development-only owner helper. For production, use the account setup token instead.
off, password or sso. off creates no sign-in at all; production refuses to boot with it unset unless you explicitly opt in (see below).
SMALLSASS_ACCOUNT_DEPLOYMENT_PROFILE
An optional named policy: self-hosted-password, self-hosted-mixed, self-hosted-sso-only or hosted-sso-only. Enforced at startup.
SMALLSASS_ACCOUNT_SECRET
The session-signing secret. Required for password or sso mode. Generate with openssl rand -base64 48 — anything 32 characters or longer is fine; the install guide's command produces 48.
SMALLSASS_ACCOUNT_PUBLIC_URL
The exact browser-facing origin, for example https://capacity.example.com. Required for password or sso mode.
SMALLSASS_ACCOUNT_SETUP_TOKEN
The one-time secret the first owner enters on a fresh password-mode instance. For Google/Microsoft setup, use SMALLSASS_ACCOUNT_PROVIDER_BOOTSTRAP_EMAILS for the first identity or a pre-authorised invitation after that.
SMALLSASS_ACCOUNT_ALLOW_OPEN_SIGNUP
Re-opens self-service sign-up. Closed by default — CapacityLens is invite-only unless you set this. Leave it unset in production.
CAPACITYLENS_ALLOW_OPEN_IN_PRODUCTION
Deliberately allows the auth-off (off) posture under production. Off by default; without it, a production instance with no sign-in refuses to start.
Treat SMALLSASS_ACCOUNT_SETUP_TOKEN as a short-lived bootstrap secret. Give the first owner the value through a secure channel; never paste it into chat, tickets, screenshots, command output or logs. The owner copies the value from the server .env file or installer into the matching field. Do not add surrounding quote characters or whitespace in the browser: the submitted value must match the configured secret exactly.
After the first owner account and company have been created, remove SMALLSASS_ACCOUNT_SETUP_TOKEN from the server environment and restart the server. First-owner signup already closes as soon as the first identity exists, but removing the secret invalidates the handoff material instead of leaving it available to operators or future processes.
Use Set up company login for the registration steps. Google and Microsoft are company providers. Configure either or both; a partial credential pair refuses startup. password mode retains the password form, while sso mode permits only configured company providers. GitHub remains experimental in mixed mode and cannot provide company-only access.
Application ID and secret value from your Microsoft Entra app registration.
SMALLSASS_ACCOUNT_MICROSOFT_TENANT_ID
Required organisation tenant GUID. common, organizations, personal-account tenants and a missing value are refused.
SMALLSASS_ACCOUNT_PROVIDER_BOOTSTRAP_EMAILS
Comma-separated company email addresses allowed to create the first named-provider identity. Later new identities require an unused invitation addressed to them.
Optional credentials for the existing experimental GitHub sign-in in mixed mode.
SMALLSASS_ACCOUNT_SSO_MFA_ENFORCED
Operator attestation that the company provider requires multi-factor sign-in. Set it only after checking the upstream policy.
Register https://your-capacitylens-address/api/auth/callback/google for Google and https://your-capacitylens-address/api/auth/callback/microsoft for Microsoft, using your actual HTTPS origin. These paths must match exactly. Restart after changing server settings.
Microsoft first connections may need a one-time email verification. All five SMTP settings are required whenever Microsoft is configured, even if a particular identity arrives with adequate verified-email claims. An SMTP failure blocks completion without granting access and allows retry. Ordinary returning Microsoft sign-in does not require another verification email.
Variable
What it does
SMALLSASS_ACCOUNT_MAIL_HOST
Your SMTP service hostname.
SMALLSASS_ACCOUNT_MAIL_PORT
SMTP port. Port 465 uses implicit TLS; other ports require STARTTLS. Certificate verification remains enabled.
SMALLSASS_ACCOUNT_MAIL_USER
SMTP authentication username.
SMALLSASS_ACCOUNT_MAIL_PASSWORD
SMTP password or service credential. Keep it in the server's secret configuration.
SMALLSASS_ACCOUNT_MAIL_FROM
A valid sender email address authorised by the SMTP service; use the address without a display name.
The verification link expires after 15 minutes and must be confirmed in the browser that started sign-in. See the company-login guide for resend, expiry and account-connection recovery.
hosted-sso-only is reserved for hosted deployments. It requires mode=sso and complete Google and/or tenant-specific Microsoft configuration; it rejects passwords, GitHub, open signup and incomplete provider settings. Self-hosted installations that require company sign-in use self-hosted-sso-only. See Require company sign-in.
The retired generic OIDC settings and hosted-oidc-only profile are rejected at startup. Remove those settings and configure Google and/or Microsoft explicitly; CapacityLens does not fall back to password or sign-in-off mode.
Path to the SQLite file. Docker Compose pins this to /data/capacitylens.db inside a named volume; only change it for a bare-metal run.
CAPACITYLENS_BACKUP_DIR
Directory for scheduled snapshots. On by default in Docker (/backups). Set it explicitly empty (CAPACITYLENS_BACKUP_DIR=) to turn scheduled backups off.
CAPACITYLENS_BACKUP_INTERVAL_MIN
Minutes between snapshots. Whole minutes, default 60; startup clamps over-maximum values to 35,000 with a warning.
CAPACITYLENS_BACKUP_KEEP
How many snapshots to retain. Default 48. Invalid and lower values use the safe default; over-maximum values clamp to 10,000 with a startup warning, so leave disk capacity for that many restore points.
CAPACITYLENS_AUDIT_FILE
Path to the audit log. Default capacitylens-audit.jsonl next to CAPACITYLENS_DB; Docker Compose pins it to /data/capacitylens-audit.jsonl. Only read when audit logging is on.
CAPACITYLENS_AUDIT_MAX_MB
Audit log rotation size cap in MB. Default 64 — once the file reaches this size it's rotated to <file>.1 (replacing any previous .1), bounding disk use to roughly twice the cap.
See Backups and restore for what these snapshots protect against and how to use them. Back up the rotated <file>.1 audit file alongside the current one — a restore that only picks up the live file can miss recent audit history still sitting in the rotated generation.
For a bare-metal run, the database defaults to ./capacitylens.db; :memory: is also accepted. Scheduled backups stay off unless CAPACITYLENS_BACKUP_DIR is set. Positive fractional retention counts are rounded down.
Audit logging is on by default. Set CAPACITYLENS_AUDIT=off only for development; production refuses disabled audit. Each mutation record contains ts, userId, accountId, action, entity, id and changedFields. Changed fields are names, never their values. A memory-only database uses a working-directory-relative audit file.
CAPACITYLENS_AUDIT_MAX_MB accepts integers from 1 to 1,048,576; missing or invalid values use 64 MiB. Rotation happens before a record would cross the cap. A single record larger than the cap is rejected and remains queued in the audit outbox. The size setting is only read when audit logging is enabled.
Comma-separated browser origins to allow, only needed if the web app and API are on different origins. Defaults to local development origins. Wildcards are rejected because browser requests use cookie credentials.
CAPACITYLENS_HTTPS
Set 1 when the public origin is genuinely HTTPS, to enable a two-year HSTS header. Leave unset if your proxy already emits HSTS.
CAPACITYLENS_TRUST_PROXY_HEADERS
Trusts X-Forwarded-For/X-Forwarded-Proto from a non-loopback listener. Docker Compose sets this to 1 because its API only accepts connections from the packaged nginx. Loopback listeners (127.0.0.1, localhost, ::1) trust their same-host proxy automatically without this flag.
The HTTPS setting enables HSTS including subdomains. Leave it off for plain HTTP. The other baseline security headers are always enabled.
Variable
What it does
CAPACITYLENS_INTERNAL_TLS_CERT
PEM certificate path for the internal reverse-proxy/API connection.
CAPACITYLENS_INTERNAL_TLS_KEY
Matching PEM private-key path. Omit both paths for HTTP on a trusted same-host loopback connection. A partial or unreadable identity refuses startup. Compose creates an identity per installation.
CAPACITYLENS_INTERNAL_TLS_GENERATION
Optional SHA-256 marker for the exact loaded certificate.
Off by default: one company per instance. Set 1 to allow more than one.
CAPACITYLENS_BOOTSTRAP_TOKEN
A shared secret for creating an additional company through the API when the caller isn't already an owner or admin of one. Only matters when CAPACITYLENS_MULTI_ACCOUNT=1.
CAPACITYLENS_SEED_DEMO
Seeds a two-company sample dataset on a never-initialised database. Only makes sense paired with CAPACITYLENS_MULTI_ACCOUNT=1; use it for a throwaway or demo instance, not a real one.
A fresh database starts empty unless demo seeding is explicitly enabled. The company limit applies in every sign-in mode, including off. The bootstrap token is sent in x-capacitylens-bootstrap-token to POST /api/orgs; an empty or unset token disables that path. Without it, company creation requires first-run setup or an existing owner or admin.
Set 1 for structured per-request JSON logs. Recommended for a real deployment.
CAPACITYLENS_HEALTH_DEEP
Set 1 to make /api/health run a readiness query and report audit, backup and certificate status. Compose sets this by default.
CAPACITYLENS_RATE_LIMIT
Requests per minute per IP across rate-limited routes. Accepts integers 1–1,000,000. Production refuses missing, zero or invalid values. /api/health is exempt.
CAPACITYLENS_AUDIT_STDOUT
Set 1 to also write each audit record to stdout as JSON, for a container log collector. Compose defaults this on.
CAPACITYLENS_STORAGE_ENCRYPTED
Set 1 only after you have verified that the database, audit log and backup storage are encrypted at rest. This is an operator attestation; it does not encrypt storage itself.
CAPACITYLENS_SECURITY_LOG_FORWARDING
An attestation that you're forwarding audit and security events to a separate collector. Doesn't create the collector itself.
Without structured logging, the server prints its startup line and reports server errors to stderr. Deep health checks are off by default: /api/health returns { ok: true }. With deep checks enabled, the endpoint runs SELECT 1, reports audit state and pending records, and includes internal certificate expiry when configured. Failed readiness returns HTTP 503 with { ok: false }.
The API origin the built app talks to. Leave empty for the normal same-origin build (the app calls a relative /api, which nginx proxies). Only set this to point the app at a different origin.
VITE_CAPACITYLENS_DEMO
Set 1 to build the in-memory demo instead of the real app. Wins over VITE_CAPACITYLENS_API if both are set.
VITE_CAPACITYLENS_BUILD_SHA
Optional build identifier shown in Settings, typically the git commit.
VITE_CAPACITYLENS_FEEDBACK_MAILTO
Optional email address for the in-app feedback link. Leave empty to hide the link.
Any of these needs a rebuild to take effect. Use docker compose build web for the packaged production stack, or pnpm run build for a direct Node installation, then redeploy the rebuilt web files. Setting them only in a running process does nothing.
CapacityLens accepts only the SMALLSASS_ACCOUNT_* account variables documented above. If you're upgrading an installation that predates this namespace, follow the account-variable rename procedure before starting the new release. Startup refuses a configured removed name and identifies its replacement.
Use the support guide when you need help using or operating CapacityLens. Include the release version, what you were trying to do, what happened, and any non-sensitive error message.
Open a GitHub issue. Search existing issues first, then describe one problem with the smallest reproducible example you can provide. Remove credentials, invitation links, real customer data, and other private information.
Documentation problems are welcome: name the page, quote the confusing instruction, and explain what you were trying to accomplish.
Read the contribution guide before starting. It explains the development setup, standards, tests, signed commits, and pull-request process.
For documentation changes, edit the Markdown sources under docs-src/, follow the documentation style guide, and rebuild the committed site before opening a pull request.
Yes, subject to the AGPL-3.0 licence. Read the licence itself for the terms that apply to copying, changing, and providing the software over a network.
Use the support route rather than opening a bug when nothing appears to be broken. A focused question with the release version and relevant guide page is easiest to answer.
Use the support guide when you need help using or operating CapacityLens. Include the release version, what you were trying to do, what happened, and any non-sensitive error message.
Open a GitHub issue. Search existing issues first, then describe one problem with the smallest reproducible example you can provide. Remove credentials, invitation links, real customer data, and other private information.
Documentation problems are welcome: name the page, quote the confusing instruction, and explain what you were trying to accomplish.
Read the contribution guide before starting. It explains the development setup, standards, tests, signed commits, and pull-request process.
For documentation changes, edit the Markdown sources under docs-src/, follow the documentation style guide, and rebuild the committed site before opening a pull request.
Yes, subject to the AGPL-3.0 licence. Read the licence itself for the terms that apply to copying, changing, and providing the software over a network.
Use the support route rather than opening a bug when nothing appears to be broken. A focused question with the release version and relevant guide page is easiest to answer.
This page is the standard for how code inside a module reads: what a function's name promises, how long a variable name should be, when parameters become an options object, and what shape a result takes. It is for contributors and reviewers. Filenames, exports, imports and module ownership are in the development guide; error handling is in DEFENSIVE-CODING.md at the repository root, and this page defers to it.
The rules apply to new code and to deliberate migrations. Existing differences are tracked debt, not licence to add more. Wire fields, SQL names, environment variables, ids, emails and test-ids are stable identifiers and never change under this page.
Read these two calls from server/src/controlTables/members.ts and server/src/accounts/adminPort/authority.ts:
ts
const role = getMemberRole(db, accountId, userId); // Role | null: absence is a value
-const actorRole = assertAccountAuthority(db, actor, workspaceId, "manage-invitations"); // throws if refused
The first name says "this may be absent, check it"; the second says "if this returns, you are authorised". Neither caller needs to open the function. That is the whole standard: a function name starts with a verb, the verb tells the caller what comes back and whether anything happens on the way, and the verb comes from this table.
Verb
Promise
Example in the tree
is, has, can
Returns a boolean. No side effects.
canArchive(entity) in shared/src/domain/lifecycle/transitions.ts
assert
Throws when the condition fails. On success returns the value it established, or nothing.
assertAccountAuthority in server/src/accounts/adminPort/authority.ts returns the Role
ensure
Makes a state true if it is not already, returns nothing. Safe to call twice.
ensureControlTables(db) in server/src/controlTables/retentionV24.ts
get
Looks one thing up by key in storage. Returns it, or null when absent. Never throws for absence.
getMemberRole(db, accountId, userId) in server/src/controlTables/members.ts
list
Returns an array of matches, empty when there are none.
listMembersForAccount(db, accountId) in the same file
read
Reads from storage, the network or the environment.
readApiError(res) in src/lib/readApiError.ts
parse
Turns untrusted input into a typed value and never returns garbage. A decoder returns null; a boundary parser throws a typed error; a configuration parser applies its documented default or refuses start-up.
parseISOTimestamp (null) in shared/src/lib/integrity.ts; parseData (throws) in shared/src/data/transfer.ts; parseRateLimit (default) in server/src/rateLimit.ts and parsePort (refuses) in server/src/boot/refusals.ts
validate
Checks a value that is already typed. Returns a ValidationResult or reports through a fail callback, and never throws, as DEFENSIVE-CODING.md section 2 sets out.
validateProjectClient(clientId) in shared/src/lib/integrity.ts
normalize
Returns a repaired copy of the same type.
normalizeAccountEmail(value) in shared/src/account/validation.ts
resolve
Picks one concrete value from preferences or candidates.
resolveBarColor(allocation, maps) in shared/src/lib/color.ts
build
Derives a new structure from its inputs. Pure.
buildColumnGeometry in src/components/scheduler/columnGeometry.ts
apply
Returns the input with a change applied. Pure unless the name says otherwise.
applyOps(base, ops) in src/data/syncOps.ts
create
Constructs something with identity or capabilities: an entity, a command set, a closure.
createAllocationCommands(input) in src/components/scheduler/allocationSubmit.ts
make
Test fixtures only.
makeResource(overrides) in src/test/fixtures.ts
use
A React hook.
useScopedData in src/store/useScopedData.ts
Two verbs the table deliberately leaves out: find, because get and the <thing>ById selectors already say "or nothing", and handle, because a callback is named for what it does. A component prop is onSubmit; the function passed to it is submit or saveDraft, not handleSubmit.
Counterexamples that are now tracked debt:
ensureInternalClients exists twice with different contracts: the shared one in shared/src/data/internalClient.ts returns a new AppData, the server one in server/src/db/repairs.ts returns nothing. Under this table the shared one is an apply.
validateAuthUser(value: unknown, requireEmail = false) returns AuthUser | null. It takes untrusted input and returns the typed value, so it is a parse, and its flag parameter is parameter debt too.
validate* functions return three shapes across the tree: ValidationResult, a boolean (validateHex) and the typed value or null. The last group are parses; the audit decides the rest. validateAllocationDraft reports its first problem through a fail callback and returns a boolean, matching the validation convention.
ensureBarColors(hex) returns a colour pair. It derives a value, so it is a resolve.
Length grows with distance. A loop index three lines from its declaration can be i. Anything exported, passed between files or read more than a screen away says its role in full: activeMemberIds, not ids.
Abbreviations are a closed list: id, db, tx (a transaction), op (a batch operation), req and res (HTTP), el (a DOM element), e in a catch clause or an event handler, i and j as indices, a and b inside a comparator. Anything else is written out.
Say what it holds, not what type it is.resources, not resourceList or resourceArray. A map is named by its key: resourcesById. A count ends in Count.
Collections are plural, one item is singular, and a loop reads for (const resource of resources).
Booleans read as a yes/no question: enabled, isOpen, hasChanges, canEdit. Never negate a name (notReady, hasNoRows); negate at the use site instead. Entity fields keep their shipped names (ignoreWeekends), and isNotNull in server/src/schema/introspection.ts is the SQL term, not a negated boolean.
At most three positional parameters, subject first: (db, accountId, userId), (resource, date). A fourth parameter, or a boolean flag in any position, turns the whole list into a single options object with a named type ending in Options or Input, so every call site reads as key and value. createAllocationCommands(input: CommandInput) is the pattern; isUnavailable(resource, date, timeOff, closures) in src/lib/capacity/availability.ts is the debt. An optional object, as in makeResource(overrides = {}), is already an options object.
Dependencies are parameters. A function receives the database, clock or fetch it uses, and only the capabilities it consumes. The development guide's ownership section says why.
Optional means absent, not null. Use ? on the option; reserve null for values that are looked up and missing.
A multi-outcome result is a discriminated union on kind, each variant carrying only its own data, named <Thing>Result or <Thing>Outcome. SessionListResult in src/account/sessionClient.ts and ReserveAccountCommandResult in server/src/accounts/state/commandLedgerWrites.ts are the pattern. Callers switch on kind; there is no boolean to check first. UI state unions such as ModalState in src/components/scheduler/schedulerGridModal.ts use the same kind discriminant without the suffix. Status in src/auth/authStatus.ts discriminates on kind but carries a lifecycle name, and OfflineCacheWriteResult in src/data/offline/types.ts now discriminates on kind; Status remains tracked debt because its kind carries a lifecycle name rather than an outcome name.
status is the lifecycle state of a thing, such as a membership or a command, never the outcome of a call. The status union on operation receipts in shared/src/account/ports.ts is an existing portable contract and stays as it is.
ok belongs to ValidationResult in shared/src/lib/integrity.ts and to wire shapes: HTTP responses such as server/src/routes/systemRoutes.ts and the import worker message protocol in server/src/importWorker.ts. It is not a general outcome shape.
Absence follows the source. In-memory lookups return undefined, as Array.find and Map.get do: resourceById in src/store/selectors.ts. Database lookups return null, as SQL does: getMemberRole. Do not convert one into the other at a boundary.
Tuples are for labelled positional pairs that are always destructured together, like rowKeyParts(key): [table: string, id: string] in src/data/sync/revisions.ts. An outcome is never a tuple.
Expected failures are values; enforcement throws. A missing record or an invalid draft comes back as data. A broken invariant, a corrupt row, a refused write or a refused authorisation throws, using the typed errors DEFENSIVE-CODING.md section 2 names (server authorisation throws AccountContractError carrying an AccountFailure from shared/src/account/errors.ts, which the routes map to an HTTP status), with cause attached whenever another error is being wrapped.
Async functions return Promise<T> with the same naming, no Async suffix. The lint rules for floating and misused promises hold the call sites to await or void.
The compiler checks indexed reads and optional properties in each TypeScript project with noUncheckedIndexedAccess and exactOptionalPropertyTypes. Check an indexed value before using it. Omit an absent optional property instead of passing undefined; reserve explicit undefined for contracts that include it as a value. Do not add assertions, placeholder defaults or wider types merely to satisfy either check.
The mechanical part of this page is enforced by pnpm run lint in the typed packages (src, shared/src, server/src, server/scripts). The structural rules also cover e2e; that separate Playwright project does not enable the typed project-service rules:
casing, through @typescript-eslint/naming-convention: camelCase for let variables, camelCase, UPPER_CASE or PascalCase for const, camelCase or PascalCase for functions and parameters (a component is a value too), PascalCase for types; properties, methods and imports are exempt because many mirror wire fields, SQL columns and library names
negated names, through the same rule: a variable or parameter starting with hasNo, not followed by a capital, or isNot (other than isNotNull) fails
max-params at three, so a fourth parameter fails
complexity at 12 and nesting depth at three
max-lines-per-function at 60 authored lines, excluding blank lines and comments
Existing violations, including the reviewed initial baseline for a newly adopted rule, are recorded as a count per file and rule in eslint-suppressions.json at the repository root. The #645 enforcement baseline and #647 structural baseline record each declaration and its disposition. A count that rises fails lint. A count that falls also fails, until the entry is pruned with
pnpm exec eslint . --prune-suppressions
so the baseline only shrinks. Replacing one violation with another in the same file at the same count is invisible to lint; review catches it.
Everything else on this page is checked in review. When a rule here and the tree disagree in code you are already changing, fix the code; when they disagree in code you are not changing, leave it and note it in the audit issue rather than widening the diff.
This page is the standard for how code inside a module reads: what a function's name promises, how long a variable name should be, when parameters become an options object, and what shape a result takes. It is for contributors and reviewers. Filenames, exports, imports and module ownership are in the development guide; error handling is in DEFENSIVE-CODING.md at the repository root, and this page defers to it.
The rules apply to new code and to deliberate migrations. Existing differences are tracked debt, not licence to add more. Wire fields, SQL names, environment variables, ids, emails and test-ids are stable identifiers and never change under this page.
Read these two calls from server/src/controlTables/members.ts and server/src/accounts/adminPort/authority.ts:
ts
const role = getMemberRole(db, accountId, userId); // Role | null: absence is a value
+const actorRole = assertAccountAuthority(db, actor, workspaceId, "manage-invitations"); // throws if refused
The first name says "this may be absent, check it"; the second says "if this returns, you are authorised". Neither caller needs to open the function. That is the whole standard: a function name starts with a verb, the verb tells the caller what comes back and whether anything happens on the way, and the verb comes from this table.
Verb
Promise
Example in the tree
is, has, can
Returns a boolean. No side effects.
canArchive(entity) in shared/src/domain/lifecycle/transitions.ts
assert
Throws when the condition fails. On success returns the value it established, or nothing.
assertAccountAuthority in server/src/accounts/adminPort/authority.ts returns the Role
ensure
Makes a state true if it is not already, returns nothing. Safe to call twice.
ensureControlTables(db) in server/src/controlTables/retentionV24.ts
get
Looks one thing up by key in storage. Returns it, or null when absent. Never throws for absence.
getMemberRole(db, accountId, userId) in server/src/controlTables/members.ts
list
Returns an array of matches, empty when there are none.
listMembersForAccount(db, accountId) in the same file
read
Reads from storage, the network or the environment.
readApiError(res) in src/lib/readApiError.ts
parse
Turns untrusted input into a typed value and never returns garbage. A decoder returns null; a boundary parser throws a typed error; a configuration parser applies its documented default or refuses start-up.
parseISOTimestamp (null) in shared/src/lib/integrity.ts; parseData (throws) in shared/src/data/transfer.ts; parseRateLimit (default) in server/src/rateLimit.ts and parsePort (refuses) in server/src/boot/refusals.ts
validate
Checks a value that is already typed. Returns a ValidationResult or reports through a fail callback, and never throws, as DEFENSIVE-CODING.md section 2 sets out.
validateProjectClient(clientId) in shared/src/lib/integrity.ts
normalize
Returns a repaired copy of the same type.
normalizeAccountEmail(value) in shared/src/account/validation.ts
resolve
Picks one concrete value from preferences or candidates.
resolveBarColor(allocation, maps) in shared/src/lib/color.ts
build
Derives a new structure from its inputs. Pure.
buildColumnGeometry in src/components/scheduler/columnGeometry.ts
apply
Returns the input with a change applied. Pure unless the name says otherwise.
applyOps(base, ops) in src/data/syncOps.ts
create
Constructs something with identity or capabilities: an entity, a command set, a closure.
createAllocationCommands(input) in src/components/scheduler/allocationSubmit.ts
make
Test fixtures only.
makeResource(overrides) in src/test/fixtures.ts
use
A React hook.
useScopedData in src/store/useScopedData.ts
Two verbs the table deliberately leaves out: find, because get and the <thing>ById selectors already say "or nothing", and handle, because a callback is named for what it does. A component prop is onSubmit; the function passed to it is submit or saveDraft, not handleSubmit.
Counterexamples that are now tracked debt:
ensureInternalClients exists twice with different contracts: the shared one in shared/src/data/internalClient.ts returns a new AppData, the server one in server/src/db/repairs.ts returns nothing. Under this table the shared one is an apply.
validateAuthUser(value: unknown, requireEmail = false) returns AuthUser | null. It takes untrusted input and returns the typed value, so it is a parse, and its flag parameter is parameter debt too.
validate* functions return three shapes across the tree: ValidationResult, a boolean (validateHex) and the typed value or null. The last group are parses; the audit decides the rest. validateAllocationDraft reports its first problem through a fail callback and returns a boolean, matching the validation convention.
ensureBarColors(hex) returns a colour pair. It derives a value, so it is a resolve.
Length grows with distance. A loop index three lines from its declaration can be i. Anything exported, passed between files or read more than a screen away says its role in full: activeMemberIds, not ids.
Abbreviations are a closed list: id, db, tx (a transaction), op (a batch operation), req and res (HTTP), el (a DOM element), e in a catch clause or an event handler, i and j as indices, a and b inside a comparator. Anything else is written out.
Say what it holds, not what type it is.resources, not resourceList or resourceArray. A map is named by its key: resourcesById. A count ends in Count.
Collections are plural, one item is singular, and a loop reads for (const resource of resources).
Booleans read as a yes/no question: enabled, isOpen, hasChanges, canEdit. Never negate a name (notReady, hasNoRows); negate at the use site instead. Entity fields keep their shipped names (ignoreWeekends), and isNotNull in server/src/schema/introspection.ts is the SQL term, not a negated boolean.
At most three positional parameters, subject first: (db, accountId, userId), (resource, date). A fourth parameter, or a boolean flag in any position, turns the whole list into a single options object with a named type ending in Options or Input, so every call site reads as key and value. createAllocationCommands(input: CommandInput) is the pattern; isUnavailable(resource, date, timeOff, closures) in src/lib/capacity/availability.ts is the debt. An optional object, as in makeResource(overrides = {}), is already an options object.
Dependencies are parameters. A function receives the database, clock or fetch it uses, and only the capabilities it consumes. The development guide's ownership section says why.
Optional means absent, not null. Use ? on the option; reserve null for values that are looked up and missing.
A multi-outcome result is a discriminated union on kind, each variant carrying only its own data, named <Thing>Result or <Thing>Outcome. SessionListResult in src/account/sessionClient.ts and ReserveAccountCommandResult in server/src/accounts/state/commandLedgerWrites.ts are the pattern. Callers switch on kind; there is no boolean to check first. UI state unions such as ModalState in src/components/scheduler/schedulerGridModal.ts use the same kind discriminant without the suffix. Status in src/auth/authStatus.ts discriminates on kind but carries a lifecycle name, and OfflineCacheWriteResult in src/data/offline/types.ts now discriminates on kind; Status remains tracked debt because its kind carries a lifecycle name rather than an outcome name.
status is the lifecycle state of a thing, such as a membership or a command, never the outcome of a call. The status union on operation receipts in shared/src/account/ports.ts is an existing portable contract and stays as it is.
ok belongs to ValidationResult in shared/src/lib/integrity.ts and to wire shapes: HTTP responses such as server/src/routes/systemRoutes.ts and the import worker message protocol in server/src/importWorker.ts. It is not a general outcome shape.
Absence follows the source. In-memory lookups return undefined, as Array.find and Map.get do: resourceById in src/store/selectors.ts. Database lookups return null, as SQL does: getMemberRole. Do not convert one into the other at a boundary.
Tuples are for labelled positional pairs that are always destructured together, like rowKeyParts(key): [table: string, id: string] in src/data/sync/revisions.ts. An outcome is never a tuple.
Expected failures are values; enforcement throws. A missing record or an invalid draft comes back as data. A broken invariant, a corrupt row, a refused write or a refused authorisation throws, using the typed errors DEFENSIVE-CODING.md section 2 names (server authorisation throws AccountContractError carrying an AccountFailure from shared/src/account/errors.ts, which the routes map to an HTTP status), with cause attached whenever another error is being wrapped.
Async functions return Promise<T> with the same naming, no Async suffix. The lint rules for floating and misused promises hold the call sites to await or void.
The compiler checks indexed reads and optional properties in each TypeScript project with noUncheckedIndexedAccess and exactOptionalPropertyTypes. Check an indexed value before using it. Omit an absent optional property instead of passing undefined; reserve explicit undefined for contracts that include it as a value. Do not add assertions, placeholder defaults or wider types merely to satisfy either check.
The mechanical part of this page is enforced by pnpm run lint in the typed packages (src, shared/src, server/src, server/scripts). The structural rules also cover e2e; that separate Playwright project does not enable the typed project-service rules:
casing, through @typescript-eslint/naming-convention: camelCase for let variables, camelCase, UPPER_CASE or PascalCase for const, camelCase or PascalCase for functions and parameters (a component is a value too), PascalCase for types; properties, methods and imports are exempt because many mirror wire fields, SQL columns and library names
negated names, through the same rule: a variable or parameter starting with hasNo, not followed by a capital, or isNot (other than isNotNull) fails
max-params at three, so a fourth parameter fails
complexity at 12 and nesting depth at three
max-lines-per-function at 60 authored lines, excluding blank lines and comments
Existing violations, including the reviewed initial baseline for a newly adopted rule, are recorded as a count per file and rule in eslint-suppressions.json at the repository root. The #645 enforcement baseline and #647 structural baseline record each declaration and its disposition. A count that rises fails lint. A count that falls also fails, until the entry is pruned with
pnpm exec eslint . --prune-suppressions
so the baseline only shrinks. Replacing one violation with another in the same file at the same count is invisible to lint; review catches it.
Everything else on this page is checked in review. When a rule here and the tree disagree in code you are already changing, fix the code; when they disagree in code you are not changing, leave it and note it in the audit issue rather than widening the diff.
It's for anyone changing the CapacityLens code. If you just run or use CapacityLens, you don't need it — see the glossary or the guide instead.
This page gets the CapacityLens source running on your machine, explains how the repository is laid out, and lists every check CI runs before a change merges. It's for anyone editing code, not just running the product. Getting a dev server up takes a few minutes; running the full check suite takes longer.
It's for anyone changing the CapacityLens code. If you just run or use CapacityLens, you don't need it — see the glossary or the guide instead.
This page gets the CapacityLens source running on your machine, explains how the repository is laid out, and lists every check CI runs before a change merges. It's for anyone editing code, not just running the product. Getting a dev server up takes a few minutes; running the full check suite takes longer.
Node 24 remains the default development and deployment runtime. Node 26 coverage is experimental: Node 26.8.2 can delay SQLite backup completion until another timer fires. Official Node 26.9.0 includes the upstream callback-scope fix. Project acceptance against that release is still pending. Follow issue #710 for the current decision and the consolidated discovery record for the evidence, corrected findings and remaining acceptance checks. Proposed minimums are 24.19.0 and, conditionally, 26.9.0; these are not yet enforced support ranges. Do not use a locally patched runtime for deployment.
The node-compatibility.yml workflow prepares separate application, server and Chromium checks using Node 26. Its manual and weekly triggers take effect only after the workflow is merged; activation is held pending the compatibility decision in #710. Existing workflows continue selecting .nvmrc. The shared setup action accepts a node-version override containing a numeric major (for example, 26) and verifies the repository's pnpm pin.
For isolated compatibility testing on macOS or Linux, select the intended Node 26 binary in your shell without changing .nvmrc. Confirm its exact version:
bash
node --version
Node 26 does not bundle Corepack. Install the pinned pnpm with the standalone installer. From the repository root, download the installer:
env PNPM_VERSION="$(node --input-type=module -e 'import { readFileSync } from "node:fs"; console.log(JSON.parse(readFileSync("package.json", "utf8")).packageManager.split("@")[1])')" sh /tmp/install-pnpm.sh
Follow the installer's printed shell setup instructions, then check the selected versions:
bash
node --version
bash
pnpm --version
The pnpm version must match package.json (currently 11.4.0). Install dependencies without changing the lockfile:
bash
pnpm install --frozen-lockfile
Run the normal application and server gates, migration rehearsal and Chromium suite listed below. Also run the packaged server/import-worker smoke from the compatibility workflow: source-based browser tests alone do not execute those production bundles. Record the commit, exact runtime and platform with the results. A failed check remains a failed compatibility result; do not add timers or relax timeouts to conceal the backup defect.
pnpm run dev # SQLite API :8787 + web :5173, seeded development datapnpm run dev:demo # web :5173, editable in-memory data, resets on reloadpnpm run dev:access # isolated password-auth role lab: API :8897 + web :5473
Those are the lane-0 ports — what a single checkout binds. See Port lanes for what happens when several checkouts run at once.
Every local server takes its port from a lane: an integer from 0 to 9 that one run holds for its duration. A port is base + lane, so lane 0 is the historical 5173/8787/4173 and ten concurrent worktrees never collide.
pnpm run dev, preview, test, gate, gate:server, gate:all, e2e and the documentation servers all run through scripts/with-lane.mjs, which claims a lane before the command starts and releases it after. Configuration files only read the resolved lane, so there is nothing to pass by hand:
bash
pnpm run e2e # claims the lowest free lane, prints whichCAPACITYLENS_PORT_LANE=4 pnpm run e2e # pin a lane (CI pins 0)
The claim also reserves a share of the machine's CPUs and passes it to Vitest and Playwright as a worker count, so ten concurrent runs divide the cores instead of each assuming it owns them. A run that arrives when the pool is empty still gets one worker — nothing queues.
Two deliberate exceptions:
pnpm run dev:access keeps fixed ports, so it is single-flight machine-wide.
Documentation screenshots are captured by hand on :5199, which no automated run binds.
If a lane's port is still held when a run claims it, the launcher clears the process only when it belongs to this worktree. Anything else is reported by pid and the run stops, rather than killing another checkout's server.
An empty VITE_CAPACITYLENS_API means same-origin server mode. A non-empty value must be an absolute HTTP(S) origin with no credentials, path, query or fragment; surrounding whitespace and a trailing slash are normalized. Only VITE_CAPACITYLENS_DEMO=1 selects the in-memory adapter. An invalid non-empty API or feedback-mailbox build value fails Vite configuration before bundling.
The in-memory demo has no real membership roles. To inspect the implemented Owner/Admin/Editor/Viewer flows against the password-auth server, use the isolated access lab described below. It documents the local-only credentials, the prebuilt Wayne Enterprises fixture, and the expected visibility matrix.
The lab is destructive only to the fixed local file server/.access-lab.db, which is recreated on every run. Its launcher and setup boundary remove inherited SMALLSASS_ACCOUNT_*, CAPACITYLENS_*, BETTER_AUTH_* and VITE_CAPACITYLENS_* configuration, then pin the API to 127.0.0.1, password auth, the lab database and the local Vite origin. The setup script also refuses every path except that exact non-symlink repository fixture, including a same-named database in another directory. Never use these fictional credentials on a real installation.
Start the complete lab:
bash
pnpm run dev:access
Open http://127.0.0.1:5473. Wayne Enterprises, a private client/project and a time-off note are already present. Sign in with any persona; every persona uses access-lab-password-2026:
Persona
Email
Role
Lucius Fox
owner@capacitylens.dev
Owner
Alfred Pennyworth
alex.admin@capacitylens.dev
Admin
Barbara Gordon
erin.editor@capacitylens.dev
Editor
James Gordon
vic.viewer@capacitylens.dev
Viewer
Compare the sidebar role badge, Team & access, edit affordances, private names and time-off note against the roles and permissions table. Stop the command with Ctrl-C before running auth-backed Playwright; the access lab is single-flight on fixed ports, outside the lane system, and keeps a separate database.
Useful automated counterparts are:
bash
pnpm exec playwright test --project=auth-backed \
@@ -38,7 +38,7 @@
gh workflow run e2e.yml --ref main
The gate workflow exposes independent jobs for workflow static analysis, DCO, application checks, server checks, account conformance, released-database migration rehearsal and the production dependency audit. The migration job also runs the process-heavy migration regression tests in an isolated single-worker pool; both migration phases and the ordinary server unit phase have bounded step runtimes so a leaked native handle can't consume the complete job timeout. Independent jobs run even when another category fails, so a red application test can't hide an account, migration or dependency result. Coverage uploads when the repository has a CODECOV_TOKEN secret.
Treat process exit as an assertion. The characteristic failure looks like a list of green test files or passing assertions followed by silence, until GNU timeout returns 124 (an inner child terminated by SIGTERM commonly reports 143). GitHub's orphan-process cleanup may additionally name MainThread, esbuild or another descendant. This is a lifecycle defect, not evidence that the suite needs a larger timeout.
The ordinary server runner discovers test files under server/src, removes the three deliberately isolated suites, sorts the remainder, distributes them across four shards and launches one fresh Vitest process per file. To inspect or reproduce the same boundary:
Start with the last announced filename and inspect every owned Fastify instance, SQLite connection, timer, process signal listener and spawned child. Use registerServerFixtureCleanup() for ordinary app/database fixtures. Subprocess tests must bound the child itself and avoid captured stdio pipes when a compiler or other descendant can inherit them — temporary-file capture is the established entrypoint-test pattern. A process-heavy test that intentionally exercises termination may get a dedicated required job, but passing assertions must never be manufactured with forced exits, weaker cleanup, thread-pool reuse or a larger outer timeout.
Static analysis and CodeQL analyze every pull request targeting main. The focused static-analysis workflow checks whole-repository formatting first, then compiles translations, type-checks the shared and application projects, and lints all authored sources. The heavier workflows run when the merge reaches main, plus their own weekly or monthly schedules. To see those gates green before merging, dispatch them against the branch:
bash
gh workflow run gate.yml --ref <branch>
-gh workflow run e2e.yml --ref <branch>
Opening a pull request and pushing to its branch previously fired gate, e2e, docker and security on every event — several full passes per change. Focused format/lint/type-check and CodeQL jobs now cover every proposed commit without repeating the full suites. Staged-file lint on commit, whole-repository lint on push and whole-repository formatting on pull requests provide early feedback; pnpm run gate:all and pnpm run e2e remain the complete local checks, and CI is the record.
Two jobs used to depend on pull-request context and now read the pushed commit range (github.event.before..github.sha) instead: DCO sign-off and dependency review. Both skip when that range doesn't exist — branch creation and force pushes. Feature commits carry their own Signed-off-by trailers. Creating a pull request means publishing it on GitHub and leaving it open for review; it never implies permission to merge. After the maintainer explicitly authorises the merge of that specific pull request, use a normal merge commit and delete the remote feature branch:
The e2e workflow runs cross-browser behavior. Each Playwright phase writes a distinct HTML report, JUnit result and trace directory; failed jobs retain those artifacts for seven days. Docker Compose smoke tests stay separate so the README badges report independent status.
CodeQL runs on pull requests targeting main, on main itself and on its weekly schedule. Its commit-specific concurrency key preserves analysis for each revision even when changes arrive quickly. OpenSSF Scorecard runs on main and weekly. The security workflow performs full-history secret scanning, dependency review, source SBOM generation, container vulnerability scanning and two OWASP ZAP baselines. A separate release-only workflow packages each published tag, generates its SBOM, creates GitHub build attestations and attaches the artifacts plus the recognized .intoto.jsonl provenance bundle to the GitHub Release. It is manually runnable with an existing release tag for deliberate rebuilds and backfills. The blocking ZAP scan boots the hardened posture — password authentication, required MFA, scheduled backups and operator attestations, with credentials minted and masked per run — so a finding there is a regression in the recommended configuration. A second, non-blocking job scans the out-of-the-box default posture weekly and uploads its report as an artifact. Reviewed secret-scan fixtures are allowlisted by value in .gitleaks.toml, which pnpm run security:gitleaks-config checks on every gate run. Because a scheduled or main run has no reviewer watching it, a failure there — or a cancellation that leaves the run with nothing to read — opens or comments on a security-scan-failure issue, and a later clean run closes it. A cancellation caused by a newer push is not reported, since that's cancel-in-progress working as intended. See docs-src/security/security-review-2026-07-14.md for assessment scope and residual controls.
main is protected against deletion and force pushes, and changes must arrive through a pull request. The Lint and type-check status is required before merge; no approving review is required while the project has one active maintainer. The heavier post-merge workflows still report complete suite results on main.
The coverage badge needs a Codecov project and a repository secret named CODECOV_TOKEN; uploads are deliberately skipped until that secret exists. Uploads are best-effort because the required local gate already enforces coverage thresholds and must not depend on Codecov availability. Scorecard needs publish_results: true and its OIDC permission, which are configured in .github/workflows/scorecard.yml.
Dependabot's monthly npm, GitHub Actions and Docker updates stay enabled; pnpm is updated from / because the root workspace owns the shared lockfile. Because its pull-request bodies quote registry metadata and a base-image digest carries none, .github/workflows/dependabot-summary.yml comments a plain-English summary of each update on the pull request. The comment is advisory and gates nothing.
The portable AppData/export format uses EXPORT_SCHEMA_VERSION in shared/. The physical SQLite file independently uses DB_SCHEMA_VERSION and PRAGMA user_version in server/src/db.ts. Never reuse one number for the other: an export-only change must not block an otherwise compatible server rollback, and a control/auth database change must not escape downgrade refusal.
Database v8 is the explicit-runner baseline. An immutable ordered migration advances one version inside one BEGIN IMMEDIATE transaction and stamps user_version plus the CapacityLens application_id in that same commit. The same transaction inserts a row into capacitylens_schema_migrations containing the version, name, SHA-256 definition checksum and application timestamp. Startup validates the complete ledger before planning writes and refuses a missing, reordered, renamed or checksummed-different migration. SCHEMA_SQL creates fresh databases; already-released files advance through migrations. Shape introspection remains a post-migration assertion and a v0-v7 baseline repair, not the mechanism for silently applying new fields. That assertion verifies the TABLES write contract (declared types, nullability and id primary keys) and rejects unknown required columns or constraints that could reject a valid entity write. Nullable or defaulted extension columns stay forward-compatible because every product write names its columns explicitly.
Database v21 indexes every scoped table by accountId; v23 separately indexes every non-account foreign-key child column so SQLite parent deletes and cascades don't scan whole child tables. Startup verifies the owner, column, uniqueness, collation and direction of both index sets.
For every persisted change:
Update shared types and full fixtures where the portable shape changed.
Update TABLES and fresh-database DDL.
Add the next immutable database migration and a complete checksum definition. Never edit or delete a migration that shipped — a changed definition is intentional startup incompatibility, not a repair mechanism. Restore the released migration and add a new version instead.
Make required fields additive first, backfill and validate, then rebuild to enforce NOT NULL. A rename/rebuild must preserve indexes, triggers, constraints and foreign keys explicitly.
Update import sanitisation independently of the physical migration.
Before changing migration code, generate a sanitised .db fixture with the released build. Keep one fixture per shipped top-level database version and auth shape under server/src/fixtures/databases/; tests copy it before opening and never migrate it in place. Intermediate migration steps that never appeared as a released build's user_version don't get synthetic fixtures — the next released fixture exercises those steps in their real sequence.
Assert data preservation, fresh/migrated schema equivalence, idempotent reopen, transaction rollback/retry, quick_check, foreign_key_check, future-version refusal and auth convergence.
Add operator-facing migration/rollback notes to CHANGELOG.md and the operator docs.
Before releasing any schema-bearing build, run the automated rehearsal. With no argument it uses the committed password-auth v7 fixture:
bash
pnpm run rehearse:migrations
Also run it against a representative long-lived installation. The command uses SQLite's online backup API and never opens the source for writes. It remaps ids, replaces names/notes/emails and credential/session/invite/MFA material, enables secure deletion and vacuums the temporary copy before testing it. Unknown tables fail closed until their sensitive columns are reviewed. Temporary artifacts are deleted by default:
bash
pnpm run rehearse:migrations -- --source /path/to/capacitylens.db
The rehearsal verifies the happy-path migration, pre-migration snapshot equivalence, row-count and integrity preservation, checksum-ledger convergence, idempotent reopen, rollback after an injected ENOSPC, and WAL recovery after killing a process with the real migration transaction open. Use --keep only in a protected development environment when the anonymised artifacts are needed for diagnosis; never commit an installation-derived database.
Schema v25 historically added the CapacityLens-owned federated-link observation/ceremony and SSO activation-state tables, an atomic observation trigger, and Better Auth UNIQUE(providerId, accountId) plus UNIQUE(userId, providerId) concurrency backstops. Its migration and released database fixtures are historical records; preserve their exact definitions and use the checked-in fixture ledger when rehearsing a later schema version.
App-owned control tables share the application migration stream. Better Auth stays pinned and owns its own tables; startup reruns its introspection migration and then verifies that no table or column work remains before accepting traffic. Every Better Auth upgrade needs a password-mode fixture containing synthetic users, credential accounts and sessions. A dependency/plugin upgrade that can change Better Auth's desired schema must also advance DB_SCHEMA_VERSION (a named marker migration is sufficient when no app-owned SQL is needed), so the previous server refuses the file before the library-owned DDL runs.
Pre-migration snapshot tests fault-inject permission, file-sync, rename and directory-sync failures and prove initialization stays uncalled. The normal migration rehearsal exercises the real Node 24 filesystem primitives, but destructive power-loss behavior still depends on the host filesystem, mount options and storage hardware, and needs an operator-level storage test where warranted.
Database v17 adds capacitylens_audit_outbox. Product routes enqueue their data-minimised audit record inside the mutation transaction, then synchronously drain in sequence order. The file sink fsyncs before the outbox delete and recognizes a stable auditId, so tests must cover rollback, restart recovery, append/delete replay and sink-failure retention whenever this pipeline changes.
Database v18 adds capacitylens_sync_sessions and capacitylens_sync_row_provenance. Browser sync batches carry one random per-page session id and a monotonic sequence. The server rejects a lower sequence that arrives late and uses the exact hashed row result of the preceding same-session batch to distinguish a safe successor from an intervening external edit. Ordered browser batches always enforce these stale preconditions, even in the explicit single-writer concurrency mode; direct API writes retain their configured optimistic-concurrency policy. Ordering rows expire after seven days. For an existing-row PUT or batch PUT, updatedAt is an exact server-revision precondition: omission, malformation, an older value or a caller-authored future value returns 409. A partial PATCH may omit the precondition for compatibility, but any supplied value must match exactly. PATCH is a merge: omitting a field preserves its stored value, explicit null clears an optional column, and explicit null for a required column is rejected with 400. Optional values repaired by the shared import sanitizer normalize to absence consistently before SQLite encoding. Row provenance carries its owning account explicitly and is removed as part of workspace erasure. Current servers return one server-owned revision for each PUT table/id and no others; a superseded ordered batch returns an empty revision list. During a rolling-version window the client also accepts a successful legacy receipt with missing or partial revision metadata, logs the skew and continues without the unavailable timestamp translations. The ok result stays mandatory, and a present applied count must still equal the submitted operation count.
Database v19 installs product-table triggers for every parent/child tenant relationship. They reject cross-company references on insert or update, make scoped accountId values immutable, and are verified (including their enforcement bodies) on every boot. The migration refuses a pre-existing cross-company edge rather than guessing which tenant label or reference to repair; use the verified pre-migration snapshot and an explicit operator repair before retrying.
Database v20 moves the app-owned capacitylens_bootstrap_claim table into the immutable migration ledger. It upgrades only the two definitions emitted by older CapacityLens builds, clears their unauthenticated five-minute claim lease, and preserves an already-current tokenized lease. Any other shape fails closed after the normal pre-migration snapshot instead of receiving speculative DDL. Runtime auth setup only verifies the exact table definition and expires stale leases.
Database v21 adds one non-unique accountId index to every tenant-scoped product table. Startup verifies each index's owning table, key column, direction, collation, uniqueness and partial-index flags. The query-plan regression requires both scoped reads and whole-slice deletes to use these indexes, so one company's synchronous work doesn't scale with unrelated companies' rows.
Database v22 repairs any built-in Internal client carrying an archive or deletion tombstone from the historical legacy-id replacement path. It clears both lifecycle fields and advances the row's revision so a repaired singleton is active and distinguishable from its pre-migration value. The write boundary independently rejects any future replacement that would promote an inactive row.
Production startup validates pure configuration, opens without application DDL, plans the upgrade, plans application-ledger and Better Auth schema work, and writes a verified capacitylens-pre-migration-vN-to-vM.db rollback snapshot before applying anything. Scheduled backups may stay disabled; this one-shot safety snapshot is mandatory for an existing on-disk database that needs any of those migrations. It is not retention-pruned automatically; repeated attempts for one version pair atomically refresh that one file.
CapacityLens supports coordinated restarts, not mixed-version writers. Do not add down migrations. Rollback uses the old image and its matching pre-migration snapshot while the API is stopped. If mixed-version/zero-downtime deployment is introduced later, schema changes must switch to an expand → backfill/dual-read-write → contract sequence across releases.
Server-mode Settings exposes process-local persistence counters for failed saves, retries, reconciliations, superseded reloads, rebases and discarded edits, plus the current write-suspension state. The counters intentionally contain no tenant values and reset whenever a fresh persistence lifecycle attaches. Use them with the build stamp when reproducing save or reload failures — they're diagnostic breadcrumbs, not durable telemetry or an operator health endpoint.
Sample organisations and people must be fictional. Never copy production names, notes, domains or ids into fixtures, screenshots or stories. Paraglide output, test reports, local databases and local agent configuration are ignored and must not be committed. The only committed database files are the sanitised released-schema artifacts under server/src/fixtures/databases/.
The complete E2E matrix also uses web/API ports 5273, 5373 and 8887. Stop an existing dev stack before E2E — Playwright intentionally refuses to reuse the demo/auth servers because persistence flavour matters. When a focused Playwright command explicitly names only ordinary core spec files (for example, pnpm exec playwright test e2e/timeoff.spec.ts), the harness starts only the demo Vite server. Unfiltered, directory-filtered and mixed selections retain the complete server set unless an explicit scope flag selects a narrower supported matrix.
The access lab reserves web/API 5473/8897 and is single-flight machine-wide.
Development/test environment controls are intentionally separate from production configuration. API_PORT belongs only to scripts/serve-dist.mjs; Playwright/package orchestration owns CAPACITYLENS_E2E_PHASE, CAPACITYLENS_WEBKIT, CAPACITYLENS_WEBKIT_ONLY, CAPACITYLENS_FIREFOX, CAPACITYLENS_FIREFOX_ONLY, CAPACITYLENS_VITE_ONLY. CAPACITYLENS_REHEARSAL_URL is the one operator-supplied test control: it points the rehearsal browser project at the staged upgraded deployment. CI pins ACTIONLINT_VERSION; update that pin alongside its download/checksum workflow review. CAPACITYLENS_E2E_PHASE must contain only letters, numbers, underscores and hyphens; unset or empty selects default. Invalid values fail configuration rather than aliasing two runs into one report directory.
+gh workflow run e2e.yml --ref <branch>
Opening a pull request and pushing to its branch previously fired gate, e2e, docker and security on every event — several full passes per change. Focused format/lint/type-check and CodeQL jobs now cover every proposed commit without repeating the full suites. Staged-file lint on commit, whole-repository lint on push and whole-repository formatting on pull requests provide early feedback; pnpm run gate:all and pnpm run e2e remain the complete local checks, and CI is the record.
Two jobs used to depend on pull-request context and now read the pushed commit range (github.event.before..github.sha) instead: DCO sign-off and dependency review. Both skip when that range doesn't exist — branch creation and force pushes. Feature commits carry their own Signed-off-by trailers. Creating a pull request means publishing it on GitHub and leaving it open for review; it never implies permission to merge. After the maintainer explicitly authorises the merge of that specific pull request, use a normal merge commit and delete the remote feature branch:
The e2e workflow runs cross-browser behavior. Each Playwright phase writes a distinct HTML report, JUnit result and trace directory; failed jobs retain those artifacts for seven days. Docker Compose smoke tests stay separate so the README badges report independent status.
CodeQL runs on pull requests targeting main, on main itself and on its weekly schedule. Its commit-specific concurrency key preserves analysis for each revision even when changes arrive quickly. OpenSSF Scorecard runs on main and weekly. The security workflow performs full-history secret scanning, dependency review, source SBOM generation, container vulnerability scanning and two OWASP ZAP baselines. A separate release-only workflow packages each published tag, generates its SBOM, creates GitHub build attestations and attaches the artifacts plus the recognized .intoto.jsonl provenance bundle to the GitHub Release. It is manually runnable with an existing release tag for deliberate rebuilds and backfills. The blocking ZAP scan boots the hardened posture — password authentication, required MFA, scheduled backups and operator attestations, with credentials minted and masked per run — so a finding there is a regression in the recommended configuration. A second, non-blocking job scans the out-of-the-box default posture weekly and uploads its report as an artifact. Reviewed secret-scan fixtures are allowlisted by value in .gitleaks.toml, which pnpm run security:gitleaks-config checks on every gate run. Because a scheduled or main run has no reviewer watching it, a failure there — or a cancellation that leaves the run with nothing to read — opens or comments on a security-scan-failure issue, and a later clean run closes it. A cancellation caused by a newer push is not reported, since that's cancel-in-progress working as intended. See docs-src/security/security-review-2026-07-14.md for assessment scope and residual controls.
main is protected against deletion and force pushes, and changes must arrive through a pull request. The Lint and type-check status is required before merge; no approving review is required while the project has one active maintainer. The heavier post-merge workflows still report complete suite results on main.
The coverage badge needs a Codecov project and a repository secret named CODECOV_TOKEN; uploads are deliberately skipped until that secret exists. Uploads are best-effort because the required local gate already enforces coverage thresholds and must not depend on Codecov availability. Scorecard needs publish_results: true and its OIDC permission, which are configured in .github/workflows/scorecard.yml.
Dependabot's monthly npm, GitHub Actions and Docker updates stay enabled; pnpm is updated from / because the root workspace owns the shared lockfile. Because its pull-request bodies quote registry metadata and a base-image digest carries none, .github/workflows/dependabot-summary.yml comments a plain-English summary of each update on the pull request. The comment is advisory and gates nothing.
The portable AppData/export format uses EXPORT_SCHEMA_VERSION in shared/. The physical SQLite file independently uses DB_SCHEMA_VERSION and PRAGMA user_version in server/src/db.ts. Never reuse one number for the other: an export-only change must not block an otherwise compatible server rollback, and a control/auth database change must not escape downgrade refusal.
Database v8 is the explicit-runner baseline. An immutable ordered migration advances one version inside one BEGIN IMMEDIATE transaction and stamps user_version plus the CapacityLens application_id in that same commit. The same transaction inserts a row into capacitylens_schema_migrations containing the version, name, SHA-256 definition checksum and application timestamp. Startup validates the complete ledger before planning writes and refuses a missing, reordered, renamed or checksummed-different migration. SCHEMA_SQL creates fresh databases; already-released files advance through migrations. Shape introspection remains a post-migration assertion and a v0-v7 baseline repair, not the mechanism for silently applying new fields. That assertion verifies the TABLES write contract (declared types, nullability and id primary keys) and rejects unknown required columns or constraints that could reject a valid entity write. Nullable or defaulted extension columns stay forward-compatible because every product write names its columns explicitly.
Database v21 indexes every scoped table by accountId; v23 separately indexes every non-account foreign-key child column so SQLite parent deletes and cascades don't scan whole child tables. Startup verifies the owner, column, uniqueness, collation and direction of both index sets.
For every persisted change:
Update shared types and full fixtures where the portable shape changed.
Update TABLES and fresh-database DDL.
Add the next immutable database migration and a complete checksum definition. Never edit or delete a migration that shipped — a changed definition is intentional startup incompatibility, not a repair mechanism. Restore the released migration and add a new version instead.
Make required fields additive first, backfill and validate, then rebuild to enforce NOT NULL. A rename/rebuild must preserve indexes, triggers, constraints and foreign keys explicitly.
Update import sanitisation independently of the physical migration.
Before changing migration code, generate a sanitised .db fixture with the released build. Keep one fixture per shipped top-level database version and auth shape under server/src/fixtures/databases/; tests copy it before opening and never migrate it in place. Intermediate migration steps that never appeared as a released build's user_version don't get synthetic fixtures — the next released fixture exercises those steps in their real sequence.
Assert data preservation, fresh/migrated schema equivalence, idempotent reopen, transaction rollback/retry, quick_check, foreign_key_check, future-version refusal and auth convergence.
Add operator-facing migration/rollback notes to CHANGELOG.md and the operator docs.
Before releasing any schema-bearing build, run the automated rehearsal. With no argument it uses the committed password-auth v7 fixture:
bash
pnpm run rehearse:migrations
Also run it against a representative long-lived installation. The command uses SQLite's online backup API and never opens the source for writes. It remaps ids, replaces names/notes/emails and credential/session/invite/MFA material, enables secure deletion and vacuums the temporary copy before testing it. Unknown tables fail closed until their sensitive columns are reviewed. Temporary artifacts are deleted by default:
bash
pnpm run rehearse:migrations -- --source /path/to/capacitylens.db
The rehearsal verifies the happy-path migration, pre-migration snapshot equivalence, row-count and integrity preservation, checksum-ledger convergence, idempotent reopen, rollback after an injected ENOSPC, and WAL recovery after killing a process with the real migration transaction open. Use --keep only in a protected development environment when the anonymised artifacts are needed for diagnosis; never commit an installation-derived database.
Schema v25 historically added the CapacityLens-owned federated-link observation/ceremony and SSO activation-state tables, an atomic observation trigger, and Better Auth UNIQUE(providerId, accountId) plus UNIQUE(userId, providerId) concurrency backstops. Its migration and released database fixtures are historical records; preserve their exact definitions and use the checked-in fixture ledger when rehearsing a later schema version.
App-owned control tables share the application migration stream. Better Auth stays pinned and owns its own tables; startup reruns its introspection migration and then verifies that no table or column work remains before accepting traffic. Every Better Auth upgrade needs a password-mode fixture containing synthetic users, credential accounts and sessions. A dependency/plugin upgrade that can change Better Auth's desired schema must also advance DB_SCHEMA_VERSION (a named marker migration is sufficient when no app-owned SQL is needed), so the previous server refuses the file before the library-owned DDL runs.
Pre-migration snapshot tests fault-inject permission, file-sync, rename and directory-sync failures and prove initialization stays uncalled. The normal migration rehearsal exercises the real Node 24 filesystem primitives, but destructive power-loss behavior still depends on the host filesystem, mount options and storage hardware, and needs an operator-level storage test where warranted.
Database v17 adds capacitylens_audit_outbox. Product routes enqueue their data-minimised audit record inside the mutation transaction, then synchronously drain in sequence order. The file sink fsyncs before the outbox delete and recognizes a stable auditId, so tests must cover rollback, restart recovery, append/delete replay and sink-failure retention whenever this pipeline changes.
Database v18 adds capacitylens_sync_sessions and capacitylens_sync_row_provenance. Browser sync batches carry one random per-page session id and a monotonic sequence. The server rejects a lower sequence that arrives late and uses the exact hashed row result of the preceding same-session batch to distinguish a safe successor from an intervening external edit. Ordered browser batches always enforce these stale preconditions, even in the explicit single-writer concurrency mode; direct API writes retain their configured optimistic-concurrency policy. Ordering rows expire after seven days. For an existing-row PUT or batch PUT, updatedAt is an exact server-revision precondition: omission, malformation, an older value or a caller-authored future value returns 409. A partial PATCH may omit the precondition for compatibility, but any supplied value must match exactly. PATCH is a merge: omitting a field preserves its stored value, explicit null clears an optional column, and explicit null for a required column is rejected with 400. Optional values repaired by the shared import sanitizer normalize to absence consistently before SQLite encoding. Row provenance carries its owning account explicitly and is removed as part of workspace erasure. Current servers return one server-owned revision for each PUT table/id and no others; a superseded ordered batch returns an empty revision list. During a rolling-version window the client also accepts a successful legacy receipt with missing or partial revision metadata, logs the skew and continues without the unavailable timestamp translations. The ok result stays mandatory, and a present applied count must still equal the submitted operation count.
Database v19 installs product-table triggers for every parent/child tenant relationship. They reject cross-company references on insert or update, make scoped accountId values immutable, and are verified (including their enforcement bodies) on every boot. The migration refuses a pre-existing cross-company edge rather than guessing which tenant label or reference to repair; use the verified pre-migration snapshot and an explicit operator repair before retrying.
Database v20 moves the app-owned capacitylens_bootstrap_claim table into the immutable migration ledger. It upgrades only the two definitions emitted by older CapacityLens builds, clears their unauthenticated five-minute claim lease, and preserves an already-current tokenized lease. Any other shape fails closed after the normal pre-migration snapshot instead of receiving speculative DDL. Runtime auth setup only verifies the exact table definition and expires stale leases.
Database v21 adds one non-unique accountId index to every tenant-scoped product table. Startup verifies each index's owning table, key column, direction, collation, uniqueness and partial-index flags. The query-plan regression requires both scoped reads and whole-slice deletes to use these indexes, so one company's synchronous work doesn't scale with unrelated companies' rows.
Database v22 repairs any built-in Internal client carrying an archive or deletion tombstone from the historical legacy-id replacement path. It clears both lifecycle fields and advances the row's revision so a repaired singleton is active and distinguishable from its pre-migration value. The write boundary independently rejects any future replacement that would promote an inactive row.
Production startup validates pure configuration, opens without application DDL, plans the upgrade, plans application-ledger and Better Auth schema work, and writes a verified capacitylens-pre-migration-vN-to-vM.db rollback snapshot before applying anything. Scheduled backups may stay disabled; this one-shot safety snapshot is mandatory for an existing on-disk database that needs any of those migrations. It is not retention-pruned automatically; repeated attempts for one version pair atomically refresh that one file.
CapacityLens supports coordinated restarts, not mixed-version writers. Do not add down migrations. Rollback uses the old image and its matching pre-migration snapshot while the API is stopped. If mixed-version/zero-downtime deployment is introduced later, schema changes must switch to an expand → backfill/dual-read-write → contract sequence across releases.
Server-mode Settings exposes process-local persistence counters for failed saves, retries, reconciliations, superseded reloads, rebases and discarded edits, plus the current write-suspension state. The counters intentionally contain no tenant values and reset whenever a fresh persistence lifecycle attaches. Use them with the build stamp when reproducing save or reload failures — they're diagnostic breadcrumbs, not durable telemetry or an operator health endpoint.
Sample organisations and people must be fictional. Never copy production names, notes, domains or ids into fixtures, screenshots or stories. Paraglide output, test reports, local databases and local agent configuration are ignored and must not be committed. The only committed database files are the sanitised released-schema artifacts under server/src/fixtures/databases/.
The complete E2E matrix also uses web/API ports 5273, 5373 and 8887. Stop an existing dev stack before E2E — Playwright intentionally refuses to reuse the demo/auth servers because persistence flavour matters. When a focused Playwright command explicitly names only ordinary core spec files (for example, pnpm exec playwright test e2e/timeoff.spec.ts), the harness starts only the demo Vite server. Unfiltered, directory-filtered and mixed selections retain the complete server set unless an explicit scope flag selects a narrower supported matrix.
The access lab reserves web/API 5473/8897 and is single-flight machine-wide.
Development/test environment controls are intentionally separate from production configuration. API_PORT belongs only to scripts/serve-dist.mjs; Playwright/package orchestration owns CAPACITYLENS_E2E_PHASE, CAPACITYLENS_WEBKIT, CAPACITYLENS_WEBKIT_ONLY, CAPACITYLENS_FIREFOX, CAPACITYLENS_FIREFOX_ONLY, CAPACITYLENS_VITE_ONLY. CAPACITYLENS_REHEARSAL_URL is the one operator-supplied test control: it points the rehearsal browser project at the staged upgraded deployment. CI pins ACTIONLINT_VERSION; update that pin alongside its download/checksum workflow review. CAPACITYLENS_E2E_PHASE must contain only letters, numbers, underscores and hyphens; unset or empty selects default. Invalid values fail configuration rather than aliasing two runs into one report directory.
Every jargon word in these docs links here the first time it appears. This table is the full list, in plain language, with a link to where each term is covered in depth.
Term
Meaning
account
In the app, Account means the personal page for your sign-in, password and sessions. Older technical code and records may use account for a company workspace; user-facing docs and screens call that a company. See Roles and permissions.
activity
An activity names the work placed on the schedule. It can belong to one project, be internal work with no project, or be reusable across all projects. See Projects and allocations.
admin
Admin is the second-highest of the four roles a member can hold in a company, below Owner and above Editor and Viewer. An Admin can invite and manage other members, see time-off notes, and restore or purge archived data — but cannot touch the Owner or transfer ownership. An Admin can be nominated as the next Owner, and only that Admin can agree to it. See Roles and permissions.
allocation
An allocation joins a person to an activity for a date range. It is the block of scheduled work shown across that person's row, with a tentative, confirmed or completed status. See Projects and allocations.
All projects
An All-projects activity is shared work that can be booked against any active project, such as account management or a common design task. Each booking may count towards one chosen active project whose client is also active, while No specific project leaves it unattributed. See Projects and allocations.
break-glass
Break-glass is the operator's recovery path when company sign-in is unavailable: restore password mode and restart so existing local-password accounts can sign in. It does not reset passwords or change provider connections. See Require company sign-in.
claims
Claims are the facts an identity provider reports about a person during company login — their email address, whether that email is verified, and a stable ID that doesn't change. CapacityLens relies on these to admit someone and keep their sign-in linked to the same person. See Set up your company login.
client
A client groups the projects your company does for one customer. Internal work does not need a client. See Projects and allocations.
client secret
A client secret is the private, random string an identity provider issues alongside a client ID, proving that sign-in requests really come from your CapacityLens server. It's usually shown once and should be treated like a root password. See Set up your company login.
company login
Company login means signing in through Google Workspace or Microsoft Entra ID instead of a CapacityLens-specific password. See How sign-in works.
company
A company is one separate workspace containing its own clients, projects, activities, people and schedule. One installation can host several companies when multi-company is enabled. Access and data never carry across automatically. See Roles and permissions.
demo mode
Demo mode is the in-memory, nothing-to-install version of CapacityLens: a fictional two-company sample dataset that resets on every reload and has no real logins, memberships or roles. It's what Try the demo runs.
discipline
A discipline is a category people are grouped by across the app — Design, Development, and so on — used for filtering and for per-discipline utilisation figures. Turned on by default. See Settings.
external party
An external party is a non-capacity-tracked resource on the schedule, such as a print shop or a subcontractor, that can be booked without counting toward anyone's utilisation. Off by default. See Settings.
identity provider
An identity provider (IdP) is the outside system that checks a person's password or company login and tells CapacityLens who they are — Google, Microsoft Entra, Okta or Keycloak, for example. These docs use the everyday name, company login, wherever possible; see How sign-in works.
invite
An invite is a single-use link an Owner or Admin creates for a specific role, and optionally a specific email address, so someone can join a company. CapacityLens shows the link once and never emails it — the person who creates it has to send it themselves. See Invite your team.
link (accounts)
Linking is the step where a signed-in person explicitly connects their existing CapacityLens account to a configured Google or Microsoft identity. Linking keeps the person's existing membership, role and scheduled work. See Require company sign-in.
member
A member is someone who can sign in to one company, with an Owner, Admin, Editor or Viewer role. Membership does not add a person to the schedule, and a scheduled person does not need to be a member. See Roles and permissions.
MFA / TOTP
MFA (multi-factor authentication) is a second proof of identity beyond a password. TOTP (time-based one-time password) is the six-digit authenticator-app code CapacityLens uses for it. An operator can require MFA for every password sign-in with one setting. See Security overview.
mixed mode
Mixed mode is the deployment setting where password sign-in and company login are both switched on at once, so people can connect their company login account before the password door closes. It's a supported way to run CapacityLens permanently, not just a migration stage. See Move from passwords to single sign-on.
owner
The Owner is the one person per company with full control: the only role that can delete the company, replace its whole dataset, or start an ownership transfer. Every company has exactly one Owner, and no Admin can reset an Owner's password or remove them. See Roles and permissions.
ownership transfer
The three-step ceremony that moves ownership of a company: the Owner nominates an Admin, that Admin agrees, and the same Owner confirms. Nothing changes until all three have happened, either side can stop it, and a request expires after seven days. See Roles and permissions.
password mode
Password mode is the setting where people sign in with an email and password stored by CapacityLens itself, rather than through a company login provider. It's the default for a new self-hosted install. See Configuration.
person
A person is a row on the schedule that can receive allocations and time off. A person does not need a sign-in: you can schedule a freelancer without inviting them as a member. See People and placeholders.
PKCE
PKCE is an extra check built into the sign-in handshake that stops a stolen sign-in code being reused by someone else. CapacityLens requires it from every identity provider it connects to. See Set up your company login.
placeholder
A placeholder is an unfilled slot you pencil into the schedule before a role is hired or assigned — a way to plan for work you know is coming without naming a real person yet. See People and placeholders.
project
A project groups the activities and allocations for one piece of client work. Every project belongs to a client; internal work does not need a project. See Projects and allocations.
redirect URI
A redirect URI (also called a callback URL) is the exact web address your identity provider sends someone back to once they've signed in. It has to match your CapacityLens address character for character, or the first sign-in click fails. See Set up your company login.
role
A role is what a member is allowed to do in a company: Viewer, Editor, Admin or Owner, each able to do everything the role below it can plus more. Roles are set per company, so the same person can be an Editor in one company and a Viewer in another. See Roles and permissions.
schedule
The schedule is the single week-by-week grid at the centre of CapacityLens, showing every person's allocations and time off, zoomable from one to eight weeks. See The schedule.
session
A session is the period between signing in and signing out, or timing out, tracked by CapacityLens on the server. A session lasts at most twelve hours and ends after thirty minutes of inactivity, whichever comes first. See Security overview.
single sign-on (SSO)
Single sign-on (SSO) lets people sign in to CapacityLens with the same account they use for the rest of their company's tools, instead of a separate password. It's the formal name for what these docs call company login. See How sign-in works.
snapshot (offline)
A snapshot is the read-only copy of a company's schedule CapacityLens stores on your device when offline access is turned on, so you can check it without a connection. Snapshots expire after seven days and never accept edits. See Offline access.
social sign-in
Social sign-in is the experimental "Sign in with Google" or "Continue with Microsoft/GitHub" button, separate from company login. After a company has moved to single sign-on, a social button can let an existing person sign in, but it can't create a new one. See How sign-in works.
SQLite / the database
SQLite is the single-file database CapacityLens stores everything in — every company, person, allocation and audit entry. Self-hosting keeps that one file, plus its backups, on persistent storage. See Backups and restore.
time off
Time off is a block on the schedule marking a person unavailable — holiday, sick leave or unpaid leave — drawn onto the same canvas as their work so capacity stays honest. An Everyone entry records a company-wide closure that covers every tracked person at once. See Time off.
user
A user is a login identity: the record that lets someone sign in at all, scoped to the whole installation rather than to one company. One user can be a member of several companies, each with a different role — and a user existing doesn't put anyone on the schedule; a person and a member are separate records for that. See Invite your team.
utilisation
Utilisation is how much of a person's available time is booked with allocations, shown as a percentage. CapacityLens can show total, per-discipline and personal utilisation figures, and some scheduling modes let work be booked without counting toward it. See Settings.
Every jargon word in these docs links here the first time it appears. This table is the full list, in plain language, with a link to where each term is covered in depth.
Term
Meaning
account
In the app, Account means the personal page for your sign-in, password and sessions. Older technical code and records may use account for a company workspace; user-facing docs and screens call that a company. See Roles and permissions.
activity
An activity names the work placed on the schedule. It can belong to one project, be internal work with no project, or be reusable across all projects. See Projects and allocations.
admin
Admin is the second-highest of the four roles a member can hold in a company, below Owner and above Editor and Viewer. An Admin can invite and manage other members, see time-off notes, and restore or purge archived data — but cannot touch the Owner or transfer ownership. An Admin can be nominated as the next Owner, and only that Admin can agree to it. See Roles and permissions.
allocation
An allocation joins a person to an activity for a date range. It is the block of scheduled work shown across that person's row, with a tentative, confirmed or completed status. See Projects and allocations.
All projects
An All-projects activity is shared work that can be booked against any active project, such as account management or a common design task. Each booking may count towards one chosen active project whose client is also active, while No specific project leaves it unattributed. See Projects and allocations.
break-glass
Break-glass is the operator's recovery path when company sign-in is unavailable: restore password mode and restart so existing local-password accounts can sign in. It does not reset passwords or change provider connections. See Require company sign-in.
claims
Claims are the facts an identity provider reports about a person during company login — their email address, whether that email is verified, and a stable ID that doesn't change. CapacityLens relies on these to admit someone and keep their sign-in linked to the same person. See Set up your company login.
client
A client groups the projects your company does for one customer. Internal work does not need a client. See Projects and allocations.
client secret
A client secret is the private, random string an identity provider issues alongside a client ID, proving that sign-in requests really come from your CapacityLens server. It's usually shown once and should be treated like a root password. See Set up your company login.
company login
Company login means signing in through Google Workspace or Microsoft Entra ID instead of a CapacityLens-specific password. See How sign-in works.
company
A company is one separate workspace containing its own clients, projects, activities, people and schedule. One installation can host several companies when multi-company is enabled. Access and data never carry across automatically. See Roles and permissions.
demo mode
Demo mode is the in-memory, nothing-to-install version of CapacityLens: a fictional two-company sample dataset that resets on every reload and has no real logins, memberships or roles. It's what Try the demo runs.
discipline
A discipline is a category people are grouped by across the app — Design, Development, and so on — used for filtering and for per-discipline utilisation figures. Turned on by default. See Settings.
external party
An external party is a non-capacity-tracked resource on the schedule, such as a print shop or a subcontractor, that can be booked without counting toward anyone's utilisation. Off by default. See Settings.
identity provider
An identity provider (IdP) is the outside system that checks a person's password or company login and tells CapacityLens who they are — Google, Microsoft Entra, Okta or Keycloak, for example. These docs use the everyday name, company login, wherever possible; see How sign-in works.
invite
An invite is a single-use link an Owner or Admin creates for a specific role, and optionally a specific email address, so someone can join a company. CapacityLens shows the link once and never emails it — the person who creates it has to send it themselves. See Invite your team.
link (accounts)
Linking is the step where a signed-in person explicitly connects their existing CapacityLens account to a configured Google or Microsoft identity. Linking keeps the person's existing membership, role and scheduled work. See Require company sign-in.
member
A member is someone who can sign in to one company, with an Owner, Admin, Editor or Viewer role. Membership does not add a person to the schedule, and a scheduled person does not need to be a member. See Roles and permissions.
MFA / TOTP
MFA (multi-factor authentication) is a second proof of identity beyond a password. TOTP (time-based one-time password) is the six-digit authenticator-app code CapacityLens uses for it. An operator can require MFA for every password sign-in with one setting. See Security overview.
mixed mode
Mixed mode is the deployment setting where password sign-in and company login are both switched on at once, so people can connect their company login account before the password door closes. It's a supported way to run CapacityLens permanently, not just a migration stage. See Move from passwords to single sign-on.
owner
The Owner is the one person per company with full control: the only role that can delete the company, replace its whole dataset, or start an ownership transfer. Every company has exactly one Owner, and no Admin can reset an Owner's password or remove them. See Roles and permissions.
ownership transfer
The three-step ceremony that moves ownership of a company: the Owner nominates an Admin, that Admin agrees, and the same Owner confirms. Nothing changes until all three have happened, either side can stop it, and a request expires after seven days. See Roles and permissions.
password mode
Password mode is the setting where people sign in with an email and password stored by CapacityLens itself, rather than through a company login provider. It's the default for a new self-hosted install. See Configuration.
person
A person is a row on the schedule that can receive allocations and time off. A person does not need a sign-in: you can schedule a freelancer without inviting them as a member. See People and placeholders.
PKCE
PKCE is an extra check built into the sign-in handshake that stops a stolen sign-in code being reused by someone else. CapacityLens requires it from every identity provider it connects to. See Set up your company login.
placeholder
A placeholder is an unfilled slot you pencil into the schedule before a role is hired or assigned — a way to plan for work you know is coming without naming a real person yet. See People and placeholders.
project
A project groups the activities and allocations for one piece of client work. Every project belongs to a client; internal work does not need a project. See Projects and allocations.
redirect URI
A redirect URI (also called a callback URL) is the exact web address your identity provider sends someone back to once they've signed in. It has to match your CapacityLens address character for character, or the first sign-in click fails. See Set up your company login.
role
A role is what a member is allowed to do in a company: Viewer, Editor, Admin or Owner, each able to do everything the role below it can plus more. Roles are set per company, so the same person can be an Editor in one company and a Viewer in another. See Roles and permissions.
schedule
The schedule is the single week-by-week grid at the centre of CapacityLens, showing every person's allocations and time off, zoomable from one to eight weeks. See The schedule.
session
A session is the period between signing in and signing out, or timing out, tracked by CapacityLens on the server. A session lasts at most twelve hours and ends after thirty minutes of inactivity, whichever comes first. See Security overview.
single sign-on (SSO)
Single sign-on (SSO) lets people sign in to CapacityLens with the same account they use for the rest of their company's tools, instead of a separate password. It's the formal name for what these docs call company login. See How sign-in works.
snapshot (offline)
A snapshot is the read-only copy of a company's schedule CapacityLens stores on your device when offline access is turned on, so you can check it without a connection. Snapshots expire after seven days and never accept edits. See Offline access.
social sign-in
Social sign-in is the experimental "Sign in with Google" or "Continue with Microsoft/GitHub" button, separate from company login. After a company has moved to single sign-on, a social button can let an existing person sign in, but it can't create a new one. See How sign-in works.
SQLite / the database
SQLite is the single-file database CapacityLens stores everything in — every company, person, allocation and audit entry. Self-hosting keeps that one file, plus its backups, on persistent storage. See Backups and restore.
time off
Time off is a block on the schedule marking a person unavailable — holiday, sick leave or unpaid leave — drawn onto the same canvas as their work so capacity stays honest. An Everyone entry records a company-wide closure that covers every tracked person at once. See Time off.
user
A user is a login identity: the record that lets someone sign in at all, scoped to the whole installation rather than to one company. One user can be a member of several companies, each with a different role — and a user existing doesn't put anyone on the schedule; a person and a member are separate records for that. See Invite your team.
utilisation
Utilisation is how much of a person's available time is booked with allocations, shown as a percentage. CapacityLens can show total, per-discipline and personal utilisation figures, and some scheduling modes let work be booked without counting toward it. See Settings.
This reference preserves the findings for contributors assessing issue #710. It records the investigation as of 11 September 2026 and the candidate in draft PR #779. It is evidence for a future support decision, not a release announcement.
Status update, 21 September 2026: Official Node 26.9.0 shipped on 16 September with the upstream SQLite backup fix. The project has not completed its official-runtime acceptance checks, so Node 24 remains the supported default. The 11 September observations below are preserved as recorded.
Node 24 remains the development, build and release baseline. The proposal is one codebase supporting Node 24 and Node 26 with separate minimum versions:
Major
Proposed minimum
Reason and acceptance condition
24
24.19.0
Verified baseline. The investigation did not establish whether an earlier version is sufficient.
26
26.9.0, conditional
The official release must contain the SQLite callback-scope fix and pass native Linux/macOS probes and complete compatibility validation. Otherwise use the first later official release that does.
At the last release check, 26.8.2 was the latest published Node 26 release and still contained the defect. The 26.9.0 release proposal was open and draft. Its inspected head, b37bb627f5d2b70096f6dc75d4c5ac9cfe3662fd, contained the backport (113180ad3f); this is not evidence that an official fixed release has shipped. Both the inspected official 26.8.2 source and then-current v26.x source lacked the fix.
The intended policy is to test the minimum and latest patch of each supported major, recommend current patches, and raise floors when security or correctness requires it. The existing >=24 package engines and major-only server preflight do not enforce these proposed floors or restrict support to two majors. The candidate setup override accepts numeric majors only; exact-minimum selection and a minimum/latest matrix remain implementation work.
Long-term support remains bounded by upstream maintenance. The Node release schedule, as checked during discovery, gives Node 24 an end date of 30 April 2028 and Node 26 an LTS start of 28 October 2026 and end date of 30 April 2029.
PR #779 remains draft and unmerged. Its manual/weekly compatibility workflow is prepared but inactive. Discovery does not authorise merging, activating that schedule, deploying, releasing, changing the default runtime, or distributing an experimental patched Node binary.
The first official Node 26.8.2 application run had 3,987 passing tests and eight failures. The first server run had 1,868 passing tests and 11 failures. These were different problems:
Finding
Final interpretation
Eight application failures
Test storage objects and prototypes disagreed under Node 26/jsdom, so spies missed quota/error injection.
Nine backup-related server failures
Node SQLite completed native work but could delay JavaScript completion until another callback ran.
One TLS failure
PATH selected macOS LibreSSL, which lacked -copy_extensions; OpenSSL 3.6.4 passed the 17 focused tests. No certificate-code fix was needed.
One restore timeout
Passed in isolation; an independent restore defect was not established.
“Node 26 backups always hang” was an incorrect early generalisation. No-timer probes and normal packaged startup/periodic backups succeeded. The reproducible defect depends on the event-loop arrangement. No corrupted snapshots were observed; that does not prove every backup workload is safe.
Initial frontend builds, 264 Chromium tests, account/credential/migration checks, a v7-to-v38 rehearsal, package deployment into scratch space and an import smoke also passed. Those successes did not make the unchanged application or server gate green.
The same 18-case matrix ran on native Linux x64 and macOS arm64. Six source/destination combinations covered in-memory, rollback-journal and WAL sources, each with a new or empty pre-created destination. Children had an eight-second parent deadline and were terminated before cleanup. Completed snapshots were reopened for row and integrity checks.
Runtime, on each platform
No timer
Referenced 60-second timer
10 ms interval
24.19.0
6/6
6/6
6/6
26.8.1
6/6
0/6 within deadline
6/6
26.8.2
6/6
0/6 within deadline
6/6
The no-timer Linux run used commit 8c17a47cc0ab5565435f472b7dda6d5844c9359a. The expanded Linux run used 2491e34915e31c458a7416014e03bf486430ced2; its two Node 26 jobs failed as expected. The probe at that exact commit preserves the executable reproduction. The diagnostic branch is not a merge candidate.
A further nine-case macOS matrix used 300 rows, positive rate: 1, fresh, empty pre-created and valid existing destinations, hashes and reopened integrity checks. It passed without the problematic timer on 24.19.0, 26.8.1 and 26.8.2. Timing probes with 100 ms, one-, two- and five-second referenced timers showed completion following the timer. An unreferenced future timer allowed prompt completion. Node 26.7.0 showed the same pattern; the first affected Node version was not bisected.
Stock Node 24.19.0 and 26.8.2 both passed packaged startup and periodic backup checks: import a changed resource, leave the server without HTTP traffic for 65 seconds, verify a later snapshot contains the changed row and passes integrity, then shut down cleanly. The application's periodic timer is unreferenced. These results cover an ordinary operating path, not every arrangement of other timers, concurrent writes, sustained retention, recovery or shutdown during the reproduced delay.
async_hooks tracing separated native Promise resolution from its JavaScript continuation. On stock 26.8.2, resolution happened around 1.186 ms, but await resumed around 1,002.907 ms after the timer at 1,002.624 ms. Node 24 resolved around 0.876 ms and resumed around 0.896 ms. Rejection after a throwing progress callback showed the same delay.
File-read and PBKDF2 controls, plus several caller arrangements, isolated the behaviour to this completion path. A standalone native uv_queue_work module without SQLite reproduced it: a bare Node 26 callback took about 502.760 ms to deliver the continuation with a 500 ms timer; adding node::CallbackScope reduced that to about 0.0905 ms. Corresponding Node 24 results were 0.336 ms and 0.139 ms. These timings are individual diagnostic observations, not performance benchmarks.
Node PR #65666, merged on 2 September, fixes the missing scope in BackupJob::AfterThreadPoolWork in src/node_sqlite.cc. Exact commit: 6e7818e4f6d2d0429a2ebd441868af19bf7333d2. It adds context and internal callback scopes around the completion function, covering both resolution and rejection. A duplicate upstream report is unnecessary; the earlier report draft is superseded.
The upstream regression fixture makes backup the final active request: it copies a 1 MiB blob with rate one, clears its heartbeat from the progress callback, and asserts at beforeExit that completion ran. The fixture is test/fixtures/sqlite/backup-last-request.mjs; the enclosing suite is test/parallel/test-sqlite-backup.mjs.
The official 26.8.2 source was configured with ./configure --without-npm and built with make -j4. The baseline binary was retained, then the exact three-file upstream patch was applied and rebuilt with the same source base and compiler. Nothing was installed as the machine default. The patched executable still identifies itself as 26.8.2; it is not an official 26.9.0 build.
Check
Baseline source build
Exact patched source build
Upstream last-request fixture
Failed
Passed
18-case timer matrix
Six failures
18/18 passed
Success trace
Native 0.777 ms; continuation 1,003.239 ms
Native 0.8345 ms; continuation 0.8511 ms, before timer
On CapacityLens base 37ee79fe40754554c3d09275293539e185b56f0f, the patched runtime passed the server gate (1,879 coverage tests, 56 account tests, three durability tests and three migration tests), packaged periodic smoke and v7-to-v38 rehearsal. The rehearsal covered 17 tables and 46 rows, value preservation, rollback, ENOSPC, termination and idempotence. Chromium had 262 first-attempt passes and two draw-mode setup failures that passed on retry; their cause remains unproven.
Node 26's native storage globals interacted with jsdom and the existing fallback. The local store could be MemoryStorage while the session store used a different prototype from the intercepted Storage.prototype. Global/window object identity alone therefore did not establish compatible storage. Eight tests missed their intended quota/error injections.
Passing --localstorage-file removed a warning but left all eight failures. Assigning the jsdom prototype to the fallback object caused Illegal invocation. A scratch setup with two independent memory stores and a matching constructor passed 82 focused tests on both runtimes and then all 3,995 application tests on Node 26; that scratch test run was not a full gate.
The maintained candidate in src/test/setup.ts aligns the constructor, prototypes and window/global aliases, using independent stores when storage is missing, unusable or mismatched. Reading a storage getter suppresses only the expected DOMException named SecurityError for an opaque origin. The normal Node 24 path remains intact. src/test/storageCompatibility.test.ts checks aliases, independence and prototype-based failure injection. The focused maintained suite passed 83/83 on both runtimes; the complete maintained application suite passed 3,996/3,996. This is test-environment compatibility work and does not change product persistence.
The implementation milestone is c87660b920f75a29ab0e0cfef822fc274fb78efa. Later documentation commits preserve this evidence without rerunning unrelated application suites.
The candidate prepares four separate Linux compatibility jobs: application gate, server gate with rehearsal, packaged smoke and Chromium. It uses pinned actions, read-only permissions, timeouts, concurrency control and failure artifacts. Existing workflows still select .nvmrc. The setup action checks the selected major, pnpm 11.4.0 and frozen installation.
The maintained packaged smoke checks the deployed server and import worker, startup and later periodic snapshots, changed contents, integrity and shutdown. It isolates inherited application settings, uses temporary fictional data and loopback networking, bounds polling and shutdown, and surfaces early failures. Two harness tests cover environment isolation and an actually failing entrypoint with prompt cleanup.
Runtime and scope
Recorded result at the implementation milestone
Official 24.19.0 application gate
Passed; 3,996 tests in 222 files, static checks, coverage, audit and build
Official 26.8.2 application gate
Passed; same 3,996 tests using maintained storage setup
Official 24.19.0 server gate
Passed; 1,879 coverage, 56 account, three durability and three migration tests
Official 24.19.0 Chromium
264 passed without retries
Official 24.19.0 and experimental patched 26 packaged smoke
Startup, periodic content/integrity and shutdown passed
Both of those runtimes, smoke harness tests
2/2 passed
Default 24 and override 26 setup version checks
Passed
Fresh standalone pnpm 11.4.0 and frozen install on official 26
Passed
Workflow syntax
actionlint 1.7.12 passed
Documentation and PR analysis
Documentation build/visual checks and CodeQL passed
The GitHub result covers that gate workflow, not every available CI workflow. Its DCO job was intentionally skipped for manual dispatch; signed commits were checked separately. The new Node 26 workflow itself was not dispatched or activated. Complete server, browser and backup acceptance against an official fixed Node 26 release remains outstanding.
An early restricted Vitest run failed to create .vite-temp; --configLoader runner avoided that environment problem. A scratch setup placed outside the worktree failed discovery before tests; moving it into an ignored local cache allowed the experiment. Neither was an application regression.
A supposed Node 24 server attempt was invalid because Homebrew PATH precedence selected Node 26.8.1. It was stopped and is not counted as Node 24 evidence. The corrected attempt found a documentation-fragment failure (1,878 passing tests); fixing the automatic heading slug passed five focused tests and the later complete gate. Use node --version after runtime activation and preserve the selected runtime's PATH precedence.
Node 26 experimental storage warnings remained warnings. Earlier patched-runtime browser flakes remain recorded even though the final Node 24 browser run passed cleanly. Passing application tests on official 26.8.2 does not override the separate native backup failure.
Do not declare 26.8.2 the supported minimum: it retains the demonstrated defect. Rolling back to 26.7 does not solve it. A heartbeat makes the reproduction complete but masks the missing callback scope; longer timeouts also conceal it. Replacing the production backup implementation would introduce separate durability and shutdown risks and was not justified by this discovery.
The unrelated zero-rate backup issue #64892 and fix #64893 are not this diagnosis: these probes use default or positive rates. No production data, database schema, Node types, build target or default runtime changed. No Docker validation or changes were part of this work.
The return is earlier runtime-regression detection and a tested path to the next maintained major without a second application implementation. Most candidate tooling already exists. The remaining effort depends on the official release and acceptance results; exact minimum enforcement and matrix coverage still need implementation. The main risk is prematurely calling a runtime supported on the strength of frontend checks or an experimental binary. Keeping adoption conditional limits that risk; recurring CI adds maintenance and execution cost.
Inspect the actual official release source and release metadata for #65666; record its exact version and artifact hashes. If 26.9.0 lacks the fix, move the proposed floor forward.
Repeat native Linux x64 and macOS ARM64 matrices, including resolution/rejection and the last-active-request regression, with that official binary.
Run application and server gates, rehearsal, packaged startup/periodic backup checks and Chromium against the official release. Investigate any failures rather than importing experimental-build results as acceptance.
Implement exact minimum/latest selection for both majors. Align package engines, runtime preflight, CI selection and operator documentation with the approved support range. Verify rejection below the floors and handling of other majors.
Record results, residual limitations and the support decision in #710 and PR #779. Keep Node 24 as default unless a separate decision changes it.
Obtain the explicit go-ahead to merge/activate or release. Documentation preservation does not remove the current hold.
The public record consists of this committed report, the issue/PR history, immutable source/probe links and the linked CI runs. The milestone comment and minimum-version clarification record the agreed boundaries.
A private durable local archive retains 1,601 available evidence files, including raw logs, reproduction scripts, source archives, the exact patch and both controlled-build executables. Its manifest lists every file's size and SHA-256 and identifies excluded reproducible dependency caches and build trees. Every archived file and the archive digest were verified. The archive SHA-256 is 0250457237e3bdd3a9b1003248c3ffb97ea7f7a0423797996e5042b2912d571e (457,036,240 bytes). Original scratch files were retained. This is a local copy, not an off-machine backup; raw records require privacy review before external publication.
Actual controlled-build executables; research only
phase4/report.md and phase-four logs
Maintained candidate checks and saved GitHub gate output
CI artifacts can expire. Their available local copies and this consolidated report preserve the findings independently of that retention window. Historical scripts contain scratch paths and historical reports contain superseded interpretations; use this report for conclusions and inspect scripts before rerunning them. Current issue/PR snapshots and the final report are stored alongside the archive as supplementary records.
This page records CapacityLens's answers to every control in OpenSSF Baseline Levels 1–3. It helps maintainers complete the public assessment and shows contributors where stronger controls are still needed. The public OpenSSF project record remains the authoritative badge status.
The assessment uses the v2026.02.19 criteria currently presented by BadgeApp and was reviewed against the repository on 9 September 2026. The upstream OSPS Baseline published v2026.08.28 on 28 August 2026, but BadgeApp has not yet adopted that version. Reconcile this ledger when its public questionnaire changes. Met means the repository or its GitHub configuration supplies evidence. Unmet identifies real work still required. N/A includes a reason. This document does not claim that an unsubmitted level has been awarded.
CapacityLens is a self-hosted, week-by-week agency capacity scheduler. It shows availability, allocations, time off and over-capacity warnings so agencies can make informed staffing decisions.
TypeScript, JavaScript, CSS and HTML. BadgeApp may group TypeScript under JavaScript.
What is the Common Platform Enumeration name?
None. CapacityLens has no assigned CPE.
Are there other relevant comments?
The security set includes a threat model, control inventories, an OWASP ASVS 5.0.0 ledger and dated reviews. CI includes CodeQL, dependency review, secret scanning, SBOM generation, container scanning and OWASP ZAP.
OSPS-GV-03.02 — Does the contributor guide define acceptable contributions?
Met
CONTRIBUTING.md defines scope, coding, testing, security, submission and DCO requirements.
OSPS-LE-01.01 — Must contributors assert legal authority for every commit?
Met
Contributor commits require DCO sign-off, documented in CONTRIBUTING.md and checked by CI.
OSPS-QA-03.01 — Must automated status checks pass or be explicitly bypassed before main accepts a commit?
Unmet
Current branch protection requires pull requests but deliberately does not require status checks while there is one active maintainer. Full workflows report after merge.
OSPS-QA-06.01 — Does CI run an automated test suite before every commit is accepted?
Unmet
Contributors run the complete local gates and CodeQL analyzes pull requests, but application and browser suites currently report after merge rather than blocking every merge.
OSPS-SA-01.01 — Does design documentation describe system actors and actions?
OSPS-SA-02.01 — Are the released software's external interfaces documented?
Met
Self-hosting, sign-in, account-boundary and development references describe browser, HTTP, configuration, identity-provider and persistence interfaces.
OSPS-SA-03.01 — Has the project performed a security assessment?
Met
The dated security review, ASVS ledger, threat model and control inventories identify likely and high-impact risks.
OSPS-VM-01.01 — Is there a coordinated-disclosure policy with a response timeframe?
Met
SECURITY.md requests private reports, targets acknowledgement within five working days and explains validation, repair and advisory publication.
OSPS-VM-03.01 — Can reporters contact the project privately about vulnerabilities?
Met
GitHub Private Vulnerability Reporting is the primary documented route.
OSPS-VM-04.01 — Will discovered vulnerabilities be published publicly?
Met
SECURITY.md commits to publishing an advisory after a patched release; release notes and the changelog record relevant fixes.
Level 2 is not currently claimable because OSPS-QA-03.01 and OSPS-QA-06.01 remain unmet.
OSPS-AC-04.02 — Does each CI job receive only the permissions it needs?
Met
Workflows declare minimal permissions; write, attestation and identity-token rights are limited to the release job that consumes them.
OSPS-BR-01.04 — Is trusted collaborator input sanitized before CI uses it?
Met
Manual release input is treated as an existing tag, passed as a quoted value and resolved by GitHub checkout rather than evaluated as shell code.
OSPS-BR-02.02 — Is every release asset associated with a unique release identifier?
Met
Assets attach to a uniquely tagged GitHub release and their provenance subjects contain cryptographic identities.
OSPS-BR-07.02 — Is there a policy for storing, accessing and rotating secrets?
Met
Security inventories, self-hosting configuration and incident guidance define storage boundaries, access expectations and rotation steps.
OSPS-DO-03.01 — Are there user instructions for verifying release integrity and authenticity?
Unmet
Attestations are generated, but the public documentation does not yet give consumers a complete verification command and expected result.
OSPS-DO-03.02 — Do verification instructions identify the expected release author or process?
Unmet
The release workflow has a GitHub identity, but consumer-facing verification documentation does not yet pin and explain the expected identity.
OSPS-DO-04.01 — Is each release's support scope and duration documented?
Met
SECURITY.md states that only the latest release and current main are supported; SUPPORT.md defines the available support routes.
OSPS-DO-05.01 — Does documentation say when releases stop receiving security updates?
Met
SECURITY.md states that older releases may not receive fixes once superseded.
OSPS-GV-04.01 — Must collaborators be reviewed before receiving escalated access?
Unmet
The governance document describes progression but does not define an enforceable vetting and approval policy for sensitive access.
OSPS-QA-02.02 — Are compiled release assets delivered with an SBOM?
Met
The release workflow publishes an SPDX JSON SBOM alongside the packaged web build and provenance bundle.
OSPS-QA-04.02 — Do all repositories in a multi-repository release enforce equivalent security requirements?
N/A
CapacityLens releases are built from one repository.
OSPS-QA-06.02 — Is it documented when and how tests run?
Met
CONTRIBUTING.md and the development guide describe local commands, CI jobs, coverage expectations and affected-risk checks.
OSPS-QA-06.03 — Must major changes add or update automated tests?
Met
CONTRIBUTING.md requires tests that fail without a behavior change and names extra threat-oriented suites for sensitive changes.
OSPS-QA-07.01 — Does every change require approval from a human other than its author?
Unmet
CapacityLens currently has one active maintainer and branch protection deliberately requires no non-author approval.
OSPS-SA-03.02 — Has the project performed threat modelling and attack-surface analysis?
Met
The threat model, attack inventory, ASVS ledger and dated reviews cover trust boundaries, critical paths, threats and mitigations.
OSPS-VM-04.02 — Are non-exploitable component findings accounted for in VEX documents?
Unmet
The project produces SBOMs and reviews dependency findings but does not publish a VEX feed.
OSPS-VM-05.01 — Is there a documented remediation threshold for dependency and licence findings?
Unmet
CI has audit and vulnerability thresholds, but one public policy does not yet define both vulnerability and licence remediation thresholds.
OSPS-VM-05.02 — Must applicable composition-analysis violations be resolved before release?
Unmet
Security scans run on main and on demand, but the release workflow does not enforce a documented SCA decision gate before publication.
OSPS-VM-05.03 — Is every change automatically checked against dependency policy and blocked on violations?
Unmet
Dependency checks are not currently a required pre-merge status on every change.
OSPS-VM-06.01 — Is there a documented remediation threshold for static-analysis findings?
Unmet
CodeQL runs on every pull request, but the public policy does not yet define severity and remediation-time thresholds.
OSPS-VM-06.02 — Is every change statically analyzed and blocked on unsuppressed security violations?
Unmet
CodeQL analyzes each pull request, but its result is not currently a required branch-protection check.
Level 3 is not currently claimable. The unmet controls above are the executable improvement queue; they should only be changed to Met after the policy, enforcement and consumer evidence land.
Review this page whenever branch protection, release publication, security policy or CI behavior changes. Update the public BadgeApp answers separately: repository documentation is evidence, not a substitute for the public self-certification.
The control summaries are derived from the OpenSSF Best Practices Badge Baseline criteria and are published here with attribution to David A. Wheeler and the OpenSSF Best Practices Badge contributors under the Community Data License Agreement – Permissive 2.0.
This page records CapacityLens's answers to every control in OpenSSF Baseline Levels 1–3. It helps maintainers complete the public assessment and shows contributors where stronger controls are still needed. The public OpenSSF project record remains the authoritative badge status.
The assessment uses the v2026.02.19 criteria currently presented by BadgeApp and was reviewed against the repository on 9 September 2026. The upstream OSPS Baseline published v2026.08.28 on 28 August 2026, but BadgeApp has not yet adopted that version. Reconcile this ledger when its public questionnaire changes. Met means the repository or its GitHub configuration supplies evidence. Unmet identifies real work still required. N/A includes a reason. This document does not claim that an unsubmitted level has been awarded.
CapacityLens is a self-hosted, week-by-week agency capacity scheduler. It shows availability, allocations, time off and over-capacity warnings so agencies can make informed staffing decisions.
TypeScript, JavaScript, CSS and HTML. BadgeApp may group TypeScript under JavaScript.
What is the Common Platform Enumeration name?
None. CapacityLens has no assigned CPE.
Are there other relevant comments?
The security set includes a threat model, control inventories, an OWASP ASVS 5.0.0 ledger and dated reviews. CI includes CodeQL, dependency review, secret scanning, SBOM generation, container scanning and OWASP ZAP.
OSPS-GV-03.02 — Does the contributor guide define acceptable contributions?
Met
CONTRIBUTING.md defines scope, coding, testing, security, submission and DCO requirements.
OSPS-LE-01.01 — Must contributors assert legal authority for every commit?
Met
Contributor commits require DCO sign-off, documented in CONTRIBUTING.md and checked by CI.
OSPS-QA-03.01 — Must automated status checks pass or be explicitly bypassed before main accepts a commit?
Unmet
Current branch protection requires pull requests but deliberately does not require status checks while there is one active maintainer. Full workflows report after merge.
OSPS-QA-06.01 — Does CI run an automated test suite before every commit is accepted?
Unmet
Contributors run the complete local gates and CodeQL analyzes pull requests, but application and browser suites currently report after merge rather than blocking every merge.
OSPS-SA-01.01 — Does design documentation describe system actors and actions?
OSPS-SA-02.01 — Are the released software's external interfaces documented?
Met
Self-hosting, sign-in, account-boundary and development references describe browser, HTTP, configuration, identity-provider and persistence interfaces.
OSPS-SA-03.01 — Has the project performed a security assessment?
Met
The dated security review, ASVS ledger, threat model and control inventories identify likely and high-impact risks.
OSPS-VM-01.01 — Is there a coordinated-disclosure policy with a response timeframe?
Met
SECURITY.md requests private reports, targets acknowledgement within five working days and explains validation, repair and advisory publication.
OSPS-VM-03.01 — Can reporters contact the project privately about vulnerabilities?
Met
GitHub Private Vulnerability Reporting is the primary documented route.
OSPS-VM-04.01 — Will discovered vulnerabilities be published publicly?
Met
SECURITY.md commits to publishing an advisory after a patched release; release notes and the changelog record relevant fixes.
Level 2 is not currently claimable because OSPS-QA-03.01 and OSPS-QA-06.01 remain unmet.
OSPS-AC-04.02 — Does each CI job receive only the permissions it needs?
Met
Workflows declare minimal permissions; write, attestation and identity-token rights are limited to the release job that consumes them.
OSPS-BR-01.04 — Is trusted collaborator input sanitized before CI uses it?
Met
Manual release input is treated as an existing tag, passed as a quoted value and resolved by GitHub checkout rather than evaluated as shell code.
OSPS-BR-02.02 — Is every release asset associated with a unique release identifier?
Met
Assets attach to a uniquely tagged GitHub release and their provenance subjects contain cryptographic identities.
OSPS-BR-07.02 — Is there a policy for storing, accessing and rotating secrets?
Met
Security inventories, self-hosting configuration and incident guidance define storage boundaries, access expectations and rotation steps.
OSPS-DO-03.01 — Are there user instructions for verifying release integrity and authenticity?
Unmet
Attestations are generated, but the public documentation does not yet give consumers a complete verification command and expected result.
OSPS-DO-03.02 — Do verification instructions identify the expected release author or process?
Unmet
The release workflow has a GitHub identity, but consumer-facing verification documentation does not yet pin and explain the expected identity.
OSPS-DO-04.01 — Is each release's support scope and duration documented?
Met
SECURITY.md states that only the latest release and current main are supported; SUPPORT.md defines the available support routes.
OSPS-DO-05.01 — Does documentation say when releases stop receiving security updates?
Met
SECURITY.md states that older releases may not receive fixes once superseded.
OSPS-GV-04.01 — Must collaborators be reviewed before receiving escalated access?
Unmet
The governance document describes progression but does not define an enforceable vetting and approval policy for sensitive access.
OSPS-QA-02.02 — Are compiled release assets delivered with an SBOM?
Met
The release workflow publishes an SPDX JSON SBOM alongside the packaged web build and provenance bundle.
OSPS-QA-04.02 — Do all repositories in a multi-repository release enforce equivalent security requirements?
N/A
CapacityLens releases are built from one repository.
OSPS-QA-06.02 — Is it documented when and how tests run?
Met
CONTRIBUTING.md and the development guide describe local commands, CI jobs, coverage expectations and affected-risk checks.
OSPS-QA-06.03 — Must major changes add or update automated tests?
Met
CONTRIBUTING.md requires tests that fail without a behavior change and names extra threat-oriented suites for sensitive changes.
OSPS-QA-07.01 — Does every change require approval from a human other than its author?
Unmet
CapacityLens currently has one active maintainer and branch protection deliberately requires no non-author approval.
OSPS-SA-03.02 — Has the project performed threat modelling and attack-surface analysis?
Met
The threat model, attack inventory, ASVS ledger and dated reviews cover trust boundaries, critical paths, threats and mitigations.
OSPS-VM-04.02 — Are non-exploitable component findings accounted for in VEX documents?
Unmet
The project produces SBOMs and reviews dependency findings but does not publish a VEX feed.
OSPS-VM-05.01 — Is there a documented remediation threshold for dependency and licence findings?
Unmet
CI has audit and vulnerability thresholds, but one public policy does not yet define both vulnerability and licence remediation thresholds.
OSPS-VM-05.02 — Must applicable composition-analysis violations be resolved before release?
Unmet
Security scans run on main and on demand, but the release workflow does not enforce a documented SCA decision gate before publication.
OSPS-VM-05.03 — Is every change automatically checked against dependency policy and blocked on violations?
Unmet
Dependency checks are not currently a required pre-merge status on every change.
OSPS-VM-06.01 — Is there a documented remediation threshold for static-analysis findings?
Unmet
CodeQL runs on every pull request, but the public policy does not yet define severity and remediation-time thresholds.
OSPS-VM-06.02 — Is every change statically analyzed and blocked on unsuppressed security violations?
Unmet
CodeQL analyzes each pull request, but its result is not currently a required branch-protection check.
Level 3 is not currently claimable. The unmet controls above are the executable improvement queue; they should only be changed to Met after the policy, enforcement and consumer evidence land.
Review this page whenever branch protection, release publication, security policy or CI behavior changes. Update the public BadgeApp answers separately: repository documentation is evidence, not a substitute for the public self-certification.
The control summaries are derived from the OpenSSF Best Practices Badge Baseline criteria and are published here with attribution to David A. Wheeler and the OpenSSF Best Practices Badge contributors under the Community Data License Agreement – Permissive 2.0.
Version: 2026-09-23. These inventories support ASVS architecture requirements; they are not a substitute for deployment-specific data classification, key inventory or log-retention policy.
no entity values, credentials or bearer tokens; deployment defines access and retention
Ownership transfer request
company, initiator and nominee ids, workflow state, deadline
SQLite over TLS
a live request expires seven days after it is made; a terminal request is retained for a year and is then swept the next time that company uses the ceremony (activity-driven, as invitation retention is); account erasure deletes them; no name or email is ever copied into the row
Device preference
theme, zoom and similar settings
localStorage
device-local, not account data/export; explicit device clear
Ownership-transfer revisions are strictly increasing concurrency guards. If a persisted revision is corrupted or has exhausted the safe range, transition and membership-invalidation writes fail closed without partially ending the request.
Every /api/* response receives Cache-Control: no-store and Pragma: no-cache. The SPA contains no advertising, analytics or crash-reporting integration. The only default outbound application call is the HIBP password range lookup during credential creation/change/reset. Enabled identity providers add their documented browser and server exchanges.
SHA-1 only as required by HIBP k-anonymity protocol
ephemeral candidate digest; five-character prefix sent
never used as a verifier or security hash; response padding enabled
Offline snapshot
Web Crypto AES-256-GCM, 96-bit random IV and AAD
non-extractable per-browser random device key
schema v2; corrupt/expired records and v1 plaintext deleted; device clear destroys key/data
Invite/reset lookup
SHA-256 token digest
CSPRNG token shown once
expiry, use/revocation and account deletion remove state
Session/auth/MFA
Better Auth/Node crypto
SMALLSASS_ACCOUNT_SECRET, session tokens, encrypted backup codes/TOTP state
32+ character operator secret; rotate to invalidate sessions; secret manager/operator rotation required
OAuth token storage
Better Auth application-secret encryption
provider access/refresh tokens and application secret
implicit linking disabled; existing plaintext tokens are encrypted when refreshed; operator secret rotation policy applies
Account command/session handles
Domain-separated SHA-256
bearer/password inputs or session token; application/operation context
comparison/index/audit handles only; raw credentials do not cross the account boundary or enter durable command/audit results
Sync successor provenance
SHA-256 over canonical non-secret row JSON
seven-day operational row/session metadata
exact same-session successor checks only; tenant deletion removes workspace provenance
Internal service TLS
OpenSSL P-256/SHA-256, Node HTTPS and nginx verification
per-install root-only CA key; API-only leaf key; public CA/leaf
automatic in Compose; optional for same-host bare metal; configured identities never fall back silently; coordinated renewal/recreation
Public TLS/provider assertion crypto
TLS proxy, Node trust store and Better Auth providers
public certificates/provider metadata
operator certificate lifecycle; HTTPS-only provider configuration; library updates through lockfile
No home-grown cipher, ECB, unauthenticated application encryption or client-extractable offline key is used. The operator must maintain a deployment key/certificate inventory covering TLS, storage encryption, secret manager, IdP credentials and backup encryption; this repository cannot observe it.
Review the inventory annually and after any cryptographic change. Rotate application/provider keys after suspected exposure or trust-boundary/staff changes and at the deployment's documented interval. Formats deliberately carry versions so new password/KDF and encrypted-cache profiles can coexist during migration. The project will follow maintained Node/Web Crypto/Better Auth primitives, track NIST and OWASP deprecations, and introduce approved post-quantum TLS/signature algorithms only after its platform dependencies provide interoperable production implementations; no custom hybrid cryptography will be added. Re-encryption of operator volumes/backups belongs in that platform's key rotation plan.
pnpm run security:crypto-inventory automatically discovers cryptographic implementation paths and fails when they differ from the reviewed machine-readable inventory. Both green gates run it, so a new primitive or TLS/key-handling path requires an explicit inventory review. SBOM, dependency review and CodeQL cover third-party implementation code that this source path check cannot inspect.
operator must classify, redact, restrict, retain and correlate in UTC
Production requires application audit to remain enabled. Forwarding security events to a separate monitored destination is recommended but optional; its absent attestation produces a warning. The local audit sink latches degradation into deep health. The runbook defines incident preservation and review, but the operator must document retention, access groups, time synchronization and alerts.
Runtime: Node.js 24, Better Auth, jose, Fastify/server packages, React/UI packages, SQLite in Node, nginx and the HIBP range service; Google and Microsoft company providers are supported, while GitHub remains experimental in mixed mode.
pnpm-lock.yaml pins the dependency graph. Reviewed overrides keep vulnerable transitive packages on compatible patched releases where their parents have not raised their own minimums. Docker base images are digest-pinned. GitHub actions are full-commit pinned. Dependabot covers npm, Actions and Docker.
pnpm's lifecycle-script policy is fail closed; allowBuilds permits only esbuild's reviewed platform-binary linker, so a newly introduced dependency install script requires an explicit repository change before it can execute in a clean install.
Workspace peers are explicit and production deployment uses a dedicated lock/graph. A lockfile-recorded Sonner patch disables only its runtime CSS injector; the identical published stylesheet is built as a self-hosted hashed asset so CSP can continue to forbid style elements.
Runtime images remove package managers and unused network clients. The Docker build rejects frontend/test packages in the API graph, and all three shipped images are scanned for high/critical CVEs.
CI performs dependency review, production audit, secret scan (reviewed fixture values allowlisted in .gitleaks.toml, whose scope is itself gated), CodeQL, SBOM generation, container vulnerability scanning, DAST and release provenance. Published releases attach their packaged build, SPDX SBOM and GitHub-issued .intoto.jsonl provenance bundle as release assets. DAST is two-tier: the blocking baseline validates the hardened posture — the configuration the deployment guide recommends — while the out-of-the-box default posture is scanned weekly as a non-blocking published report, documenting rather than asserting its residual surface. The public-repository workflows run automatically on their documented events and remain manually runnable for deliberate reruns.
Version: 2026-09-23. These inventories support ASVS architecture requirements; they are not a substitute for deployment-specific data classification, key inventory or log-retention policy.
no entity values, credentials or bearer tokens; deployment defines access and retention
Ownership transfer request
company, initiator and nominee ids, workflow state, deadline
SQLite over TLS
a live request expires seven days after it is made; a terminal request is retained for a year and is then swept the next time that company uses the ceremony (activity-driven, as invitation retention is); account erasure deletes them; no name or email is ever copied into the row
Device preference
theme, zoom and similar settings
localStorage
device-local, not account data/export; explicit device clear
Ownership-transfer revisions are strictly increasing concurrency guards. If a persisted revision is corrupted or has exhausted the safe range, transition and membership-invalidation writes fail closed without partially ending the request.
Every /api/* response receives Cache-Control: no-store and Pragma: no-cache. The SPA contains no advertising, analytics or crash-reporting integration. The only default outbound application call is the HIBP password range lookup during credential creation/change/reset. Enabled identity providers add their documented browser and server exchanges.
SHA-1 only as required by HIBP k-anonymity protocol
ephemeral candidate digest; five-character prefix sent
never used as a verifier or security hash; response padding enabled
Offline snapshot
Web Crypto AES-256-GCM, 96-bit random IV and AAD
non-extractable per-browser random device key
schema v2; corrupt/expired records and v1 plaintext deleted; device clear destroys key/data
Invite/reset lookup
SHA-256 token digest
CSPRNG token shown once
expiry, use/revocation and account deletion remove state
Session/auth/MFA
Better Auth/Node crypto
SMALLSASS_ACCOUNT_SECRET, session tokens, encrypted backup codes/TOTP state
32+ character operator secret; rotate to invalidate sessions; secret manager/operator rotation required
OAuth token storage
Better Auth application-secret encryption
provider access/refresh tokens and application secret
implicit linking disabled; existing plaintext tokens are encrypted when refreshed; operator secret rotation policy applies
Account command/session handles
Domain-separated SHA-256
bearer/password inputs or session token; application/operation context
comparison/index/audit handles only; raw credentials do not cross the account boundary or enter durable command/audit results
Sync successor provenance
SHA-256 over canonical non-secret row JSON
seven-day operational row/session metadata
exact same-session successor checks only; tenant deletion removes workspace provenance
Internal service TLS
OpenSSL P-256/SHA-256, Node HTTPS and nginx verification
per-install root-only CA key; API-only leaf key; public CA/leaf
automatic in Compose; optional for same-host bare metal; configured identities never fall back silently; coordinated renewal/recreation
Public TLS/provider assertion crypto
TLS proxy, Node trust store and Better Auth providers
public certificates/provider metadata
operator certificate lifecycle; HTTPS-only provider configuration; library updates through lockfile
No home-grown cipher, ECB, unauthenticated application encryption or client-extractable offline key is used. The operator must maintain a deployment key/certificate inventory covering TLS, storage encryption, secret manager, IdP credentials and backup encryption; this repository cannot observe it.
Review the inventory annually and after any cryptographic change. Rotate application/provider keys after suspected exposure or trust-boundary/staff changes and at the deployment's documented interval. Formats deliberately carry versions so new password/KDF and encrypted-cache profiles can coexist during migration. The project will follow maintained Node/Web Crypto/Better Auth primitives, track NIST and OWASP deprecations, and introduce approved post-quantum TLS/signature algorithms only after its platform dependencies provide interoperable production implementations; no custom hybrid cryptography will be added. Re-encryption of operator volumes/backups belongs in that platform's key rotation plan.
pnpm run security:crypto-inventory automatically discovers cryptographic implementation paths and fails when they differ from the reviewed machine-readable inventory. Both green gates run it, so a new primitive or TLS/key-handling path requires an explicit inventory review. SBOM, dependency review and CodeQL cover third-party implementation code that this source path check cannot inspect.
operator must classify, redact, restrict, retain and correlate in UTC
Production requires application audit to remain enabled. Forwarding security events to a separate monitored destination is recommended but optional; its absent attestation produces a warning. The local audit sink latches degradation into deep health. The runbook defines incident preservation and review, but the operator must document retention, access groups, time synchronization and alerts.
Runtime: Node.js 24, Better Auth, jose, Fastify/server packages, React/UI packages, SQLite in Node, nginx and the HIBP range service; Google and Microsoft company providers are supported, while GitHub remains experimental in mixed mode.
pnpm-lock.yaml pins the dependency graph. Reviewed overrides keep vulnerable transitive packages on compatible patched releases where their parents have not raised their own minimums. Docker base images are digest-pinned. GitHub actions are full-commit pinned. Dependabot covers npm, Actions and Docker.
pnpm's lifecycle-script policy is fail closed; allowBuilds permits only esbuild's reviewed platform-binary linker, so a newly introduced dependency install script requires an explicit repository change before it can execute in a clean install.
Workspace peers are explicit and production deployment uses a dedicated lock/graph. A lockfile-recorded Sonner patch disables only its runtime CSS injector; the identical published stylesheet is built as a self-hosted hashed asset so CSP can continue to forbid style elements.
Runtime images remove package managers and unused network clients. The Docker build rejects frontend/test packages in the API graph, and all three shipped images are scanned for high/critical CVEs.
CI performs dependency review, production audit, secret scan (reviewed fixture values allowlisted in .gitleaks.toml, whose scope is itself gated), CodeQL, SBOM generation, container vulnerability scanning, DAST and release provenance. Published releases attach their packaged build, SPDX SBOM and GitHub-issued .intoto.jsonl provenance bundle as release assets. DAST is two-tier: the blocking baseline validates the hardened posture — the configuration the deployment guide recommends — while the out-of-the-box default posture is scanned weekly as a non-blocking published report, documenting rather than asserting its residual surface. The public-repository workflows run automatically on their documented events and remain manually runnable for deliberate reruns.
This page is for anyone evaluating or operating CapacityLens who wants to know what protects their data without reading the source code. It summarises the defaults, points to the detailed evidence for anyone who needs it, and explains how to report a problem.
Sign-in. Password sign-in checks new and changed passwords against the Have I Been Pwned breached-password list before accepting them, and stores them with a modern, slow hashing algorithm. Optional company login (single sign-on through your identity provider) is available as a first-class alternative.
Multi-factor sign-in. An operator can require TOTP (a six-digit code from an authenticator app) for every password identity. It is opt-in, not on by default, and is turned on through server configuration.
Sessions. A signed-in session lasts twelve hours at most and expires after thirty minutes of inactivity. The session cookie is host-only, so it's confined to your exact CapacityLens address, and it's held back from cross-site background requests — it's only sent on the top-level return from your identity provider during company login. People can see and revoke their own sessions, and an administrator can revoke a teammate's.
Isolation between companies. Every request checks the signed-in person's membership and role in that specific company before it can read or change anything, so one company's data never leaks into another's.
No tracking. CapacityLens ships with no product analytics, advertising or crash-reporting service. See Privacy for exactly what is stored and where.
These are the defaults for the self-hosted, open-source application. An operator's own configuration, network setup and backup policy also matter — see Self-hosting for the deployment side of the picture.
The bullets above are a summary. The full, dated evidence — what was reviewed, what passed, what is only partial, and what is out of scope — lives in the compliance artifacts. Start at Reviews and compliance for an index of all of them, including the OWASP ASVS 5.0.0 ledger, the security review, the threat model, the control inventories and the mutation-testing reviews.
CapacityLens has a published security policy on GitHub. In short: use GitHub private vulnerability reporting rather than a public issue, discussion or pull request, and include the affected version/commit, prerequisites, reproduction steps and impact. The maintainer aims to acknowledge reports within five working days. Read the full policy for scope, response process and what is out of scope.
This page is for anyone evaluating or operating CapacityLens who wants to know what protects their data without reading the source code. It summarises the defaults, points to the detailed evidence for anyone who needs it, and explains how to report a problem.
Sign-in. Password sign-in checks new and changed passwords against the Have I Been Pwned breached-password list before accepting them, and stores them with a modern, slow hashing algorithm. Optional company login (single sign-on through your identity provider) is available as a first-class alternative.
Multi-factor sign-in. An operator can require TOTP (a six-digit code from an authenticator app) for every password identity. It is opt-in, not on by default, and is turned on through server configuration.
Sessions. A signed-in session lasts twelve hours at most and expires after thirty minutes of inactivity. The session cookie is host-only, so it's confined to your exact CapacityLens address, and it's held back from cross-site background requests — it's only sent on the top-level return from your identity provider during company login. People can see and revoke their own sessions, and an administrator can revoke a teammate's.
Isolation between companies. Every request checks the signed-in person's membership and role in that specific company before it can read or change anything, so one company's data never leaks into another's.
No tracking. CapacityLens ships with no product analytics, advertising or crash-reporting service. See Privacy for exactly what is stored and where.
These are the defaults for the self-hosted, open-source application. An operator's own configuration, network setup and backup policy also matter — see Self-hosting for the deployment side of the picture.
The bullets above are a summary. The full, dated evidence — what was reviewed, what passed, what is only partial, and what is out of scope — lives in the compliance artifacts. Start at Reviews and compliance for an index of all of them, including the OWASP ASVS 5.0.0 ledger, the security review, the threat model, the control inventories and the mutation-testing reviews.
CapacityLens has a published security policy on GitHub. In short: use GitHub private vulnerability reporting rather than a public issue, discussion or pull request, and include the affected version/commit, prerequisites, reproduction steps and impact. The maintainer aims to acknowledge reports within five working days. Read the full policy for scope, response process and what is out of scope.
The mutation suite clears its 85% release threshold, but the score is accepted only together with this survivor review. One genuine defence-in-depth tenant-integrity defect was found and fixed: a project-bound activity whose project was missing or belonged to another account could pass allocation validation because the unresolved project was treated like an activity with no project. The shared write boundary now fails closed, with missing, cross-account, inactive, reassigned and unchanged-reference cases pinned by tests. The generic scoped-reference boundary also directly proves that a new write cannot attach to an archived parent.
Additional assertions close material test gaps around private-name fallback, malformed working-day sets, import privacy/lifecycle repair, cascade revision stamps, strict ISO-date anchoring and untrusted API error bodies. No surviving mutant reviewed after that pass demonstrated another authorization, tenant-isolation, confidentiality or write-integrity bypass.
stryker.config.json mutates the pure shared domain/lib core, scheduler helpers, browser helpers and the reset failure mapper. It intentionally excludes React component code and the entire server/ implementation. Therefore this score supports the shared validation and browser-logic claims only; it is not evidence that Fastify, Better Auth, session, MFA, CSRF or SQLite route authorization is mutation-tested. Those controls rely on gate:server, focused integration tests, E2E and the manual ASVS review.
The generated interactive report is reports/mutation/mutation.html. It is a local generated artifact rather than a committed assurance record; this document preserves the reviewed outcome.
The final run improves the baseline by 17 assertion kills, 15 fewer survivors and two fewer uncovered mutants. The shared domain core scores 95.14%; mutations.ts scores 96.03% with no uncovered mutants. tenancy.ts, private-name projection, working-day validation and reset-failure mapping each score 100%. The result exceeds the configured 85% break threshold by 7.84 percentage points.
100% mutation score. The direct tenant predicates have no surviving or uncovered mutants.
Access rank guards
Surviving guard mutants are behaviorally equivalent: JavaScript rank comparison with an unknown value already returns false. Exhaustive role/action oracles and untyped-boundary cases still prove fail-closed behavior.
Allocation/reference validation
A real missing/cross-account project defect and inactive-reference assertion gaps were fixed. Missing, cross-account and archived parents now exercise fail-closed throw branches at both the specialized allocation validator and generic scoped write boundary.
Private-name projection
Missing/non-string/empty code names now assert the neutral "Confidential" projection and prove the real name is absent; quote normalization asserts repeated smart/straight outer marks.
Import and lifecycle repair
Adversarial privacy values, ordinary/built-in colour, blank/invalid timestamps and equal archive/delete boundaries are pinned. Remaining helper-condition survivors either converge on the same repaired output or guard an unreachable type-exhaustive default.
Referential cascades
Tests now prove that surviving rows retain identity and receive the caller's revision when a foreign key is cleared.
Date and working-day validation
Prefix/suffix date junk, duplicate/fractional/out-of-range weekdays and custom error-field routing are now explicit.
API error parsing
Direct tests cover unreadable JSON, null, primitives, arrays, empty/non-string error values and valid server text.
Scheduler/layout/timezone/tour helpers
Remaining survivors or uncovered mutants here are product-behavior/test-quality debt, not authorization or confidentiality controls. They remain visible in the HTML report and must not be described as security coverage.
Timed-out mutants
Stryker counts these as detected, but they are weaker diagnostic evidence than an assertion kill. Reviewed timeouts are in date/layout/fuzzy/virtualization logic, outside the security boundary.
The configured score must remain at or above the 85% break threshold.
Any survivor in tenancy, access, private-name projection, import/reference validation, auth failure mapping or destructive-action support code requires manual triage; the aggregate score alone is insufficient.
Changes to server/src/auth.ts, authorization hooks, password security, CSRF, session handling or production guards require focused server integration tests and manual security review. A separate server mutation profile may be added later, but is not implied by this report.
src/lib/tour.ts and low-scoring timezone/presentation helpers should receive product-focused mutation work independently; their current status does not lower a security control to Pass.
The mutation suite clears its 85% release threshold, but the score is accepted only together with this survivor review. One genuine defence-in-depth tenant-integrity defect was found and fixed: a project-bound activity whose project was missing or belonged to another account could pass allocation validation because the unresolved project was treated like an activity with no project. The shared write boundary now fails closed, with missing, cross-account, inactive, reassigned and unchanged-reference cases pinned by tests. The generic scoped-reference boundary also directly proves that a new write cannot attach to an archived parent.
Additional assertions close material test gaps around private-name fallback, malformed working-day sets, import privacy/lifecycle repair, cascade revision stamps, strict ISO-date anchoring and untrusted API error bodies. No surviving mutant reviewed after that pass demonstrated another authorization, tenant-isolation, confidentiality or write-integrity bypass.
stryker.config.json mutates the pure shared domain/lib core, scheduler helpers, browser helpers and the reset failure mapper. It intentionally excludes React component code and the entire server/ implementation. Therefore this score supports the shared validation and browser-logic claims only; it is not evidence that Fastify, Better Auth, session, MFA, CSRF or SQLite route authorization is mutation-tested. Those controls rely on gate:server, focused integration tests, E2E and the manual ASVS review.
The generated interactive report is reports/mutation/mutation.html. It is a local generated artifact rather than a committed assurance record; this document preserves the reviewed outcome.
The final run improves the baseline by 17 assertion kills, 15 fewer survivors and two fewer uncovered mutants. The shared domain core scores 95.14%; mutations.ts scores 96.03% with no uncovered mutants. tenancy.ts, private-name projection, working-day validation and reset-failure mapping each score 100%. The result exceeds the configured 85% break threshold by 7.84 percentage points.
100% mutation score. The direct tenant predicates have no surviving or uncovered mutants.
Access rank guards
Surviving guard mutants are behaviorally equivalent: JavaScript rank comparison with an unknown value already returns false. Exhaustive role/action oracles and untyped-boundary cases still prove fail-closed behavior.
Allocation/reference validation
A real missing/cross-account project defect and inactive-reference assertion gaps were fixed. Missing, cross-account and archived parents now exercise fail-closed throw branches at both the specialized allocation validator and generic scoped write boundary.
Private-name projection
Missing/non-string/empty code names now assert the neutral "Confidential" projection and prove the real name is absent; quote normalization asserts repeated smart/straight outer marks.
Import and lifecycle repair
Adversarial privacy values, ordinary/built-in colour, blank/invalid timestamps and equal archive/delete boundaries are pinned. Remaining helper-condition survivors either converge on the same repaired output or guard an unreachable type-exhaustive default.
Referential cascades
Tests now prove that surviving rows retain identity and receive the caller's revision when a foreign key is cleared.
Date and working-day validation
Prefix/suffix date junk, duplicate/fractional/out-of-range weekdays and custom error-field routing are now explicit.
API error parsing
Direct tests cover unreadable JSON, null, primitives, arrays, empty/non-string error values and valid server text.
Scheduler/layout/timezone/tour helpers
Remaining survivors or uncovered mutants here are product-behavior/test-quality debt, not authorization or confidentiality controls. They remain visible in the HTML report and must not be described as security coverage.
Timed-out mutants
Stryker counts these as detected, but they are weaker diagnostic evidence than an assertion kill. Reviewed timeouts are in date/layout/fuzzy/virtualization logic, outside the security boundary.
The configured score must remain at or above the 85% break threshold.
Any survivor in tenancy, access, private-name projection, import/reference validation, auth failure mapping or destructive-action support code requires manual triage; the aggregate score alone is insufficient.
Changes to server/src/auth.ts, authorization hooks, password security, CSRF, session handling or production guards require focused server integration tests and manual security review. A separate server mutation profile may be added later, but is not implied by this report.
src/lib/tour.ts and low-scoring timezone/presentation helpers should receive product-focused mutation work independently; their current status does not lower a security control to Pass.
The corrected pure-logic mutation profile clears its 85% release threshold at 92.37%, with no runner errors. The shared domain and library core scores 95.20%; tenant predicates, private-name projection, scheduling-day rules and password-reset failure mapping remain at 100%.
The first run exposed scope drift rather than a product defect. Two React hooks added under the scheduler directory were caught by the existing broad TypeScript glob even though the documented profile intentionally targets pure helpers. Those hooks contributed 507 lifecycle/event mutants, including 216 survivors, 81 uncovered mutants and two Vitest-runner serialization errors. They are now explicitly excluded by the scheduler hook naming convention. Their behavior remains covered by component tests and the Chromium, Firefox and WebKit E2E matrix.
stryker.config.json mutates the pure shared domain/library core, pure scheduler helpers, browser helpers and reset-page failure mapper. It excludes React hooks and component rendering, along with the entire server/ implementation. The score therefore supports shared validation and pure browser-logic claims; it is not evidence for Fastify, Better Auth, session, MFA, CSRF or SQLite route authorization. Those controls rely on the server gate, focused integration tests, E2E and manual security review.
The generated interactive report is reports/mutation/mutation.html. It remains a local ignored artifact; this document preserves the reviewed outcome.
The result exceeds the configured break threshold by 7.37 percentage points. All 1,720 tests in the mutation runner's initial per-test coverage pass succeeded before the 3,068 mutants ran.
tenancy.ts and privateNames.ts remain at 100%. No tenant-isolation or confidential-name mutant survives.
Access boundary
One guard mutant survives where an unknown canonical action already resolves to no minimum role; the original and mutant both fail closed. Exhaustive role/action and untyped-boundary tests remain in place.
Lifecycle and destructive actions
Surviving impact-preview selectors and timestamp guards either converge on the same conservative result or exercise invalid values already rejected by the write boundary. No archive, delete, restore or purge permission bypass was found.
Allocation/reference validation
mutations.ts remains at 96.03%. Surviving type/id-remap guards cover malformed import shapes that converge on the same sanitised result; missing, cross-account, inactive and reassigned references remain explicitly rejected.
Import and referential repair
Integrity and import survivors are defensive type guards, equivalent ISO-date checks or repair branches whose adversarial inputs converge on the asserted canonical output. No fail-open scoped reference was found.
Reset failure mapping
resetPasswordFailure.ts remains at 100%.
Pure scheduler helpers
The scheduler helper group scores 92.87% with no uncovered mutants. Drag math and week snapping remain at 100%; geometry/virtualisation timeouts are counted as detected.
Timezone and tour presentation
Low-scoring timezone label parsing and the dynamically imported product tour remain visible product-test debt. They carry no authorization, tenant-isolation or confidentiality claim.
Timed-out mutants
All 11 are in date, colour, fuzzy-search, geometry or virtualisation helpers. Stryker counts them as detected, but they remain weaker evidence than assertion kills.
The corrected profile has no compile/runtime errors and remains above both the 90% high-water mark and the 85% break threshold.
React hooks must keep behavior-focused component and cross-browser coverage; pure calculations extracted from a hook belong back in the mutation profile.
Any future survivor in tenancy, access, private-name projection, import/reference validation, reset failure mapping or destructive-action support code requires manual triage regardless of the aggregate score.
Timezone labeling and the product tour remain the clearest non-security mutation-testing debt.
The corrected pure-logic mutation profile clears its 85% release threshold at 92.37%, with no runner errors. The shared domain and library core scores 95.20%; tenant predicates, private-name projection, scheduling-day rules and password-reset failure mapping remain at 100%.
The first run exposed scope drift rather than a product defect. Two React hooks added under the scheduler directory were caught by the existing broad TypeScript glob even though the documented profile intentionally targets pure helpers. Those hooks contributed 507 lifecycle/event mutants, including 216 survivors, 81 uncovered mutants and two Vitest-runner serialization errors. They are now explicitly excluded by the scheduler hook naming convention. Their behavior remains covered by component tests and the Chromium, Firefox and WebKit E2E matrix.
stryker.config.json mutates the pure shared domain/library core, pure scheduler helpers, browser helpers and reset-page failure mapper. It excludes React hooks and component rendering, along with the entire server/ implementation. The score therefore supports shared validation and pure browser-logic claims; it is not evidence for Fastify, Better Auth, session, MFA, CSRF or SQLite route authorization. Those controls rely on the server gate, focused integration tests, E2E and manual security review.
The generated interactive report is reports/mutation/mutation.html. It remains a local ignored artifact; this document preserves the reviewed outcome.
The result exceeds the configured break threshold by 7.37 percentage points. All 1,720 tests in the mutation runner's initial per-test coverage pass succeeded before the 3,068 mutants ran.
tenancy.ts and privateNames.ts remain at 100%. No tenant-isolation or confidential-name mutant survives.
Access boundary
One guard mutant survives where an unknown canonical action already resolves to no minimum role; the original and mutant both fail closed. Exhaustive role/action and untyped-boundary tests remain in place.
Lifecycle and destructive actions
Surviving impact-preview selectors and timestamp guards either converge on the same conservative result or exercise invalid values already rejected by the write boundary. No archive, delete, restore or purge permission bypass was found.
Allocation/reference validation
mutations.ts remains at 96.03%. Surviving type/id-remap guards cover malformed import shapes that converge on the same sanitised result; missing, cross-account, inactive and reassigned references remain explicitly rejected.
Import and referential repair
Integrity and import survivors are defensive type guards, equivalent ISO-date checks or repair branches whose adversarial inputs converge on the asserted canonical output. No fail-open scoped reference was found.
Reset failure mapping
resetPasswordFailure.ts remains at 100%.
Pure scheduler helpers
The scheduler helper group scores 92.87% with no uncovered mutants. Drag math and week snapping remain at 100%; geometry/virtualisation timeouts are counted as detected.
Timezone and tour presentation
Low-scoring timezone label parsing and the dynamically imported product tour remain visible product-test debt. They carry no authorization, tenant-isolation or confidentiality claim.
Timed-out mutants
All 11 are in date, colour, fuzzy-search, geometry or virtualisation helpers. Stryker counts them as detected, but they remain weaker evidence than assertion kills.
The corrected profile has no compile/runtime errors and remains above both the 90% high-water mark and the 85% break threshold.
React hooks must keep behavior-focused component and cross-browser coverage; pure calculations extracted from a hook belong back in the mutation profile.
Any future survivor in tenancy, access, private-name projection, import/reference validation, reset failure mapping or destructive-action support code requires manual triage regardless of the aggregate score.
Timezone labeling and the product tour remain the clearest non-security mutation-testing debt.
Assessment date: 2026-08-18. Target: ASVS Level 2 when optional hardening is enabled, with every Level 1–3 requirement assessed. Baseline: OWASP Application Security Verification Standard 5.0.0 (May 2025), 345 requirements.
This ledger is an evidence-based source/configuration review, not an OWASP certification. It uses:
Pass — implemented or deliberately avoided, with repository evidence and tests where practical;
Partial — meaningful controls exist, but a clause, deployment proof or higher-assurance aspect is incomplete;
Gap — applicable requirement is not implemented;
N/A — the governed technology/function does not exist in CapacityLens.
An inherited library/framework control is only marked Pass where the application constrains its use and the behavior is covered by configuration/tests or the maintained library contract. External TLS, disks, collectors, secret stores and identity-provider policy cannot become Pass merely because an environment acknowledgement is set; those stay Partial where deployment evidence is required. Requirement descriptions are not reproduced here; use the official ASVS release alongside these IDs.
Point-in-time totals: 200 Pass, 48 Partial, 7 Gap and 90 N/A = 345. These counts include all levels; they are not a score or certification percentage.
Evergreen-browser cross-browser suite and security headers; no full incompatible-browser block
—
V3.1.1
—
—
V3.2 Rendering context
JSON MIME/nosniff/CORP plus React text rendering and TypeScript module scope
V3.2.1, V3.2.2, V3.2.3
—
—
—
V3.3 Cookies
HTTPS emits Secure, Path=/, domain-free __Host- cookies; HTTP loopback uses development names; SameSite=Lax, HttpOnly and bounded cookies
V3.3.1, V3.3.2, V3.3.3, V3.3.4, V3.3.5
—
—
—
V3.4 Browser headers
Two-year subdomain HSTS, exact CORS, CSP with inline style elements forbidden, nosniff, no-referrer, frame denial and COEP/COOP/CORP; bounded CSP reports project into the security stream
15–128, change/current-password flow, HIBP by default, no composition rule, paste/managers, exact bytes, no periodic expiry; breach checking can be disabled with a warning
API throttling/MFA lockout, no default account, opt-in required TOTP, consistent documented paths and generic failures; default password mode is single-factor and no phishing-resistant factor/user notifications exist
V6.3.1, V6.3.2, V6.3.4, V6.3.6, V6.3.8
V6.3.3
V6.3.5, V6.3.7
—
V6.4 Recovery
Production setup avoids initial passwords; reset preserves MFA/revokes sessions; stopped-server sole-Owner recovery uses the same single-use flow and exact eligibility; lost TOTP requires an enrollment-issued recovery code
V6.4.1, V6.4.2, V6.4.3, V6.4.4, V6.4.6
—
—
V6.4.5
V6.5 Factor properties
CSPRNG seeds/codes, protected recovery material, 30-second TOTP/server time, lockout and revocation; library does not evidence same-window TOTP replay storage
V6.5.2, V6.5.3, V6.5.4, V6.5.5, V6.5.6, V6.5.8
V6.5.1
—
V6.5.7
V6.6 Out-of-band/PSTN
No SMS, phone, email-code or push factor
—
—
—
V6.6.1, V6.6.2, V6.6.3, V6.6.4
V6.7 Cryptographic authenticator
No hardware cryptographic authenticator
—
—
—
V6.7.1, V6.7.2
V6.8 Federated identity
Provider+subject identity, asymmetric signature validation, verified-email admission and explicit linking; SSO MFA remains an operator assurance rather than claim-level enforcement
Function/data/field/action rules and only contextual control (session freshness) are documented
V8.1.1, V8.1.2, V8.1.3, V8.1.4
—
—
—
V8.2 Enforcement
Central role/action, account/object/parent-reference and field rules; project-bound writes fail closed when the parent cannot be resolved in-tenant; no adaptive environment/device engine
V8.2.1, V8.2.2, V8.2.3
—
V8.2.4
—
V8.3 Trusted layer/immediacy
Server-side DB membership on every operation; changes/revocations immediate; no privilege-bearing intermediary
V8.3.1, V8.3.2, V8.3.3
—
—
—
V8.4 Multi-tenancy/admin
Independent cross-tenant enforcement; admin always has freshness and may have required MFA, but no continuous device/risk assessment
Public TLS/version/ciphers are proxy/operator evidence; no mTLS client; OCSP/ECH not supplied by app
—
V12.1.1, V12.1.2
V12.1.4, V12.1.5
V12.1.3
V12.2 Public services
Documentation mandates public TLS/trusted certificates, but source review cannot verify a deployed endpoint
—
V12.2.1, V12.2.2
—
—
V12.3 Other connections
Outbound HTTPS validates certificates; packaged nginx verifies a per-install CA/service identity over TLS 1.2/1.3, while same-host bare-metal HTTP is permitted; public monitoring/operator protocols are external
UTC ISO metadata, documented JSON streams, correlation-ready data and credential/body redaction; clock synchronization is external
V16.2.1, V16.2.3, V16.2.4, V16.2.5
V16.2.2
—
—
V16.3 Security events
Auth, bypass/control failures, queue saturation, SSO cutover/repair, operator recovery and unexpected errors logged; not every successful L3 decision is recorded
V16.3.1, V16.3.3, V16.3.4
V16.3.2
—
—
V16.4 Log protection
JSON serialization prevents injection and local files have restrictive modes; external forwarding is optional and its ACL/immutability need operator evidence
V16.4.1
V16.4.2, V16.4.3
—
—
V16.5 Failure handling
Generic responses, fail-closed external/control failures and transaction rollback; a process-wide last-resort handler records the local error plus a sanitized security event, drains, exits non-zero and relies on supervisor restart rather than continuing potentially corrupt state
The application can be configured for the ASVS Level 2 risk band but the community defaults no longer force that posture: password MFA is optional and breach screening can be disabled. A password-only deployment therefore does not meet V6.3.3 L2. A Gap in a Level 3-only requirement still documents a conscious higher-assurance boundary rather than an L2 failure. Partial/Gap L1/L2 controls remain real limitations, particularly optional authentication hardening, federated-provider proof, URL bearer links and deployment public-TLS/secret/log/storage evidence.
Assessment date: 2026-08-18. Target: ASVS Level 2 when optional hardening is enabled, with every Level 1–3 requirement assessed. Baseline: OWASP Application Security Verification Standard 5.0.0 (May 2025), 345 requirements.
This ledger is an evidence-based source/configuration review, not an OWASP certification. It uses:
Pass — implemented or deliberately avoided, with repository evidence and tests where practical;
Partial — meaningful controls exist, but a clause, deployment proof or higher-assurance aspect is incomplete;
Gap — applicable requirement is not implemented;
N/A — the governed technology/function does not exist in CapacityLens.
An inherited library/framework control is only marked Pass where the application constrains its use and the behavior is covered by configuration/tests or the maintained library contract. External TLS, disks, collectors, secret stores and identity-provider policy cannot become Pass merely because an environment acknowledgement is set; those stay Partial where deployment evidence is required. Requirement descriptions are not reproduced here; use the official ASVS release alongside these IDs.
Point-in-time totals: 200 Pass, 48 Partial, 7 Gap and 90 N/A = 345. These counts include all levels; they are not a score or certification percentage.
Evergreen-browser cross-browser suite and security headers; no full incompatible-browser block
—
V3.1.1
—
—
V3.2 Rendering context
JSON MIME/nosniff/CORP plus React text rendering and TypeScript module scope
V3.2.1, V3.2.2, V3.2.3
—
—
—
V3.3 Cookies
HTTPS emits Secure, Path=/, domain-free __Host- cookies; HTTP loopback uses development names; SameSite=Lax, HttpOnly and bounded cookies
V3.3.1, V3.3.2, V3.3.3, V3.3.4, V3.3.5
—
—
—
V3.4 Browser headers
Two-year subdomain HSTS, exact CORS, CSP with inline style elements forbidden, nosniff, no-referrer, frame denial and COEP/COOP/CORP; bounded CSP reports project into the security stream
15–128, change/current-password flow, HIBP by default, no composition rule, paste/managers, exact bytes, no periodic expiry; breach checking can be disabled with a warning
API throttling/MFA lockout, no default account, opt-in required TOTP, consistent documented paths and generic failures; default password mode is single-factor and no phishing-resistant factor/user notifications exist
V6.3.1, V6.3.2, V6.3.4, V6.3.6, V6.3.8
V6.3.3
V6.3.5, V6.3.7
—
V6.4 Recovery
Production setup avoids initial passwords; reset preserves MFA/revokes sessions; stopped-server sole-Owner recovery uses the same single-use flow and exact eligibility; lost TOTP requires an enrollment-issued recovery code
V6.4.1, V6.4.2, V6.4.3, V6.4.4, V6.4.6
—
—
V6.4.5
V6.5 Factor properties
CSPRNG seeds/codes, protected recovery material, 30-second TOTP/server time, lockout and revocation; library does not evidence same-window TOTP replay storage
V6.5.2, V6.5.3, V6.5.4, V6.5.5, V6.5.6, V6.5.8
V6.5.1
—
V6.5.7
V6.6 Out-of-band/PSTN
No SMS, phone, email-code or push factor
—
—
—
V6.6.1, V6.6.2, V6.6.3, V6.6.4
V6.7 Cryptographic authenticator
No hardware cryptographic authenticator
—
—
—
V6.7.1, V6.7.2
V6.8 Federated identity
Provider+subject identity, asymmetric signature validation, verified-email admission and explicit linking; SSO MFA remains an operator assurance rather than claim-level enforcement
Function/data/field/action rules and only contextual control (session freshness) are documented
V8.1.1, V8.1.2, V8.1.3, V8.1.4
—
—
—
V8.2 Enforcement
Central role/action, account/object/parent-reference and field rules; project-bound writes fail closed when the parent cannot be resolved in-tenant; no adaptive environment/device engine
V8.2.1, V8.2.2, V8.2.3
—
V8.2.4
—
V8.3 Trusted layer/immediacy
Server-side DB membership on every operation; changes/revocations immediate; no privilege-bearing intermediary
V8.3.1, V8.3.2, V8.3.3
—
—
—
V8.4 Multi-tenancy/admin
Independent cross-tenant enforcement; admin always has freshness and may have required MFA, but no continuous device/risk assessment
Public TLS/version/ciphers are proxy/operator evidence; no mTLS client; OCSP/ECH not supplied by app
—
V12.1.1, V12.1.2
V12.1.4, V12.1.5
V12.1.3
V12.2 Public services
Documentation mandates public TLS/trusted certificates, but source review cannot verify a deployed endpoint
—
V12.2.1, V12.2.2
—
—
V12.3 Other connections
Outbound HTTPS validates certificates; packaged nginx verifies a per-install CA/service identity over TLS 1.2/1.3, while same-host bare-metal HTTP is permitted; public monitoring/operator protocols are external
UTC ISO metadata, documented JSON streams, correlation-ready data and credential/body redaction; clock synchronization is external
V16.2.1, V16.2.3, V16.2.4, V16.2.5
V16.2.2
—
—
V16.3 Security events
Auth, bypass/control failures, queue saturation, SSO cutover/repair, operator recovery and unexpected errors logged; not every successful L3 decision is recorded
V16.3.1, V16.3.3, V16.3.4
V16.3.2
—
—
V16.4 Log protection
JSON serialization prevents injection and local files have restrictive modes; external forwarding is optional and its ACL/immutability need operator evidence
V16.4.1
V16.4.2, V16.4.3
—
—
V16.5 Failure handling
Generic responses, fail-closed external/control failures and transaction rollback; a process-wide last-resort handler records the local error plus a sanitized security event, drains, exits non-zero and relies on supervisor restart rather than continuing potentially corrupt state
The application can be configured for the ASVS Level 2 risk band but the community defaults no longer force that posture: password MFA is optional and breach screening can be disabled. A password-only deployment therefore does not meet V6.3.3 L2. A Gap in a Level 3-only requirement still documents a conscious higher-assurance boundary rather than an L2 failure. Partial/Gap L1/L2 controls remain real limitations, particularly optional authentication hardening, federated-provider proof, URL bearer links and deployment public-TLS/secret/log/storage evidence.
This page describes the open-source application as shipped. A hosted service would need its own privacy notice, retention terms, subprocessors and data-processing agreements — this page is not that. It is technical documentation, not legal advice, and a commercial hosted service should get a professional privacy/security review before launch.
The SQLite database can contain company names, member names and email addresses, resource names, projects, activities, allocations, time off and free-text notes. The authentication tables contain identities, linked sign-in providers, sessions, invitations and password-reset state.
When Microsoft sign-in needs mailbox proof, CapacityLens temporarily stores the intended email, the Microsoft tenant and account identifiers, the attempt's status and expiry, and a hash of the email token. It also stores a hash of the source IP address for rate limiting. Return addresses for that attempt are encrypted in the database. The proof link expires after 15 minutes; a successful link is retained as the person's Microsoft sign-in identity so later sign-ins do not need another email proof.
An Owner can optionally record whether each company member successfully signs in. This setting is off by default. When it is on, CapacityLens stores one yes-or-no confirmation on each membership, not the sign-in time or any site activity. Turning the setting off removes every live confirmation. Changing a membership's access state, revoking a person's sessions or issuing a new password-reset link clears their confirmation until they sign in again. Like other database data, an older backup may still contain the value it held when that backup was made.
Used invitations are kept as bounded history: at most the newest 200 per company and no longer than 365 days. A live, unused invitation instead follows its own expiry and can be revoked at any time. Invitation links are stored only as digests and are never shown again once created.
The audit log records who changed which record and which fields, but not the values that changed. A short-lived queue holds that same metadata in SQLite until it is durably written to disk; it never holds the values either. Database snapshots (backups) contain the full database and must be protected the same way as the live production data.
Clients and projects can optionally have a private code name alongside the real name — this is an access-control feature, not encryption. The real name still lives in SQLite, in operator backups and in an owner's export. Only the Owner can read and manage the real names and the code-name setting through the API; everyone else (admins, editors, viewers) sees only the code name. A non-owner's own edits preserve the real fields they were never shown.
The public demo keeps its scheduling data in memory only — it resets on refresh and is never sent anywhere. Ordinary device preferences (like theme) use the browser's localStorage and are not part of a company export.
Offline access is optional. When turned on, it stores your last verified identity, your list of companies and a snapshot of each company's data in the browser's IndexedDB, for up to seven days. Signing out clears your own cached snapshots; using "Clear device data" clears every CapacityLens user's cache from that browser profile. The offline snapshot is read-only — it never queues changes to send later — and is encrypted with a key that lives only in that browser and cannot be extracted from it. That said, anyone who can use an unlocked browser profile signed in as you can still trigger that key, so protect the device the same way you would protect a signed-in session.
An offline snapshot contains whatever the server would normally show that person: a non-owner sees code names, while an Owner's snapshot may contain real, private names. Protect an owner's laptop or browser profile accordingly.
CapacityLens includes no product analytics, advertising or crash-reporting service, and telemetry from its authentication library is turned off. Scheduling requests normally use the application server on the same origin. External avatar URLs can cause the browser to request images from the configured image host. Provider sign-in also uses the external services described below.
If Microsoft sign-in is configured, the server uses the operator's SMTP service to send a one-time mailbox proof when Microsoft does not return a verified matching email address. That message goes to the intended CapacityLens email address and contains a link that expires after 15 minutes. The operator is responsible for the SMTP provider's processing and retention terms.
When password creation, change or reset is turned on, the server checks the candidate password against the Have I Been Pwned breached-password list by default. It sends only the first five characters of a scrambled (SHA-1) version of the password — never the password itself or the full scrambled value — and this check only happens while setting a password, not during normal sign-in. If that check is unavailable, the password change is refused rather than skipped. An isolated, non-production deployment can turn this check off; a production deployment that does so gets a startup warning. If you self-host, include this outbound check in your own network and privacy assessment.
If an operator turns on company login (Google or Microsoft sign-in), your browser is sent to that provider to sign in, and the server exchanges the result for a session. That provider becomes a processor of your identity data, so review its own privacy terms. The Microsoft integration requests the signed-in person's photo from Microsoft Graph on the server, even though its inline image format is not accepted as a CapacityLens avatar. See Permissions, consent and profile pictures for the exact requested permissions and current picture behaviour.
Deleting a resource (a person, client or project) immediately replaces its name with an anonymised label and clears notes from its allocations and time off.
Permanently deleting a company removes its scheduling data and erases any identity that no longer belongs to another company, including that identity's sessions and linked providers.
Audit files and backups are separate copies. Deleting a live record does not rewrite an existing backup — an operator needs a separate process to remove data from backups and off-host copies.
An identity used by more than one company is kept as long as any of those memberships needs it.
For a self-hosted install, the operator decides the purpose, the lawful basis, who has access, how long data is kept, backup policy and access control — in most privacy frameworks, that makes the operator the data controller. Protect the SQLite database, the audit log, backup snapshots, staff devices and identity-provider credentials accordingly. See Self-hosting for the deployment side of this responsibility.
This page describes the open-source application as shipped. A hosted service would need its own privacy notice, retention terms, subprocessors and data-processing agreements — this page is not that. It is technical documentation, not legal advice, and a commercial hosted service should get a professional privacy/security review before launch.
The SQLite database can contain company names, member names and email addresses, resource names, projects, activities, allocations, time off and free-text notes. The authentication tables contain identities, linked sign-in providers, sessions, invitations and password-reset state.
When Microsoft sign-in needs mailbox proof, CapacityLens temporarily stores the intended email, the Microsoft tenant and account identifiers, the attempt's status and expiry, and a hash of the email token. It also stores a hash of the source IP address for rate limiting. Return addresses for that attempt are encrypted in the database. The proof link expires after 15 minutes; a successful link is retained as the person's Microsoft sign-in identity so later sign-ins do not need another email proof.
An Owner can optionally record whether each company member successfully signs in. This setting is off by default. When it is on, CapacityLens stores one yes-or-no confirmation on each membership, not the sign-in time or any site activity. Turning the setting off removes every live confirmation. Changing a membership's access state, revoking a person's sessions or issuing a new password-reset link clears their confirmation until they sign in again. Like other database data, an older backup may still contain the value it held when that backup was made.
Used invitations are kept as bounded history: at most the newest 200 per company and no longer than 365 days. A live, unused invitation instead follows its own expiry and can be revoked at any time. Invitation links are stored only as digests and are never shown again once created.
The audit log records who changed which record and which fields, but not the values that changed. A short-lived queue holds that same metadata in SQLite until it is durably written to disk; it never holds the values either. Database snapshots (backups) contain the full database and must be protected the same way as the live production data.
Clients and projects can optionally have a private code name alongside the real name — this is an access-control feature, not encryption. The real name still lives in SQLite, in operator backups and in an owner's export. Only the Owner can read and manage the real names and the code-name setting through the API; everyone else (admins, editors, viewers) sees only the code name. A non-owner's own edits preserve the real fields they were never shown.
The public demo keeps its scheduling data in memory only — it resets on refresh and is never sent anywhere. Ordinary device preferences (like theme) use the browser's localStorage and are not part of a company export.
Offline access is optional. When turned on, it stores your last verified identity, your list of companies and a snapshot of each company's data in the browser's IndexedDB, for up to seven days. Signing out clears your own cached snapshots; using "Clear device data" clears every CapacityLens user's cache from that browser profile. The offline snapshot is read-only — it never queues changes to send later — and is encrypted with a key that lives only in that browser and cannot be extracted from it. That said, anyone who can use an unlocked browser profile signed in as you can still trigger that key, so protect the device the same way you would protect a signed-in session.
An offline snapshot contains whatever the server would normally show that person: a non-owner sees code names, while an Owner's snapshot may contain real, private names. Protect an owner's laptop or browser profile accordingly.
CapacityLens includes no product analytics, advertising or crash-reporting service, and telemetry from its authentication library is turned off. Scheduling requests normally use the application server on the same origin. External avatar URLs can cause the browser to request images from the configured image host. Provider sign-in also uses the external services described below.
If Microsoft sign-in is configured, the server uses the operator's SMTP service to send a one-time mailbox proof when Microsoft does not return a verified matching email address. That message goes to the intended CapacityLens email address and contains a link that expires after 15 minutes. The operator is responsible for the SMTP provider's processing and retention terms.
When password creation, change or reset is turned on, the server checks the candidate password against the Have I Been Pwned breached-password list by default. It sends only the first five characters of a scrambled (SHA-1) version of the password — never the password itself or the full scrambled value — and this check only happens while setting a password, not during normal sign-in. If that check is unavailable, the password change is refused rather than skipped. An isolated, non-production deployment can turn this check off; a production deployment that does so gets a startup warning. If you self-host, include this outbound check in your own network and privacy assessment.
If an operator turns on company login (Google or Microsoft sign-in), your browser is sent to that provider to sign in, and the server exchanges the result for a session. That provider becomes a processor of your identity data, so review its own privacy terms. The Microsoft integration requests the signed-in person's photo from Microsoft Graph on the server, even though its inline image format is not accepted as a CapacityLens avatar. See Permissions, consent and profile pictures for the exact requested permissions and current picture behaviour.
Deleting a resource (a person, client or project) immediately replaces its name with an anonymised label and clears notes from its allocations and time off.
Permanently deleting a company removes its scheduling data and erases any identity that no longer belongs to another company, including that identity's sessions and linked providers.
Audit files and backups are separate copies. Deleting a live record does not rewrite an existing backup — an operator needs a separate process to remove data from backups and off-host copies.
An identity used by more than one company is kept as long as any of those memberships needs it.
For a self-hosted install, the operator decides the purpose, the lawful basis, who has access, how long data is kept, backup policy and access control — in most privacy frameworks, that makes the operator the data controller. Protect the SQLite database, the audit log, backup snapshots, staff devices and identity-provider credentials accordingly. See Self-hosting for the deployment side of this responsibility.
CapacityLens keeps a set of dated, evidence-based security documents alongside the code, instead of a one-off claim of "secure by design". They are written for security reviewers, auditors and technical evaluators, not for a first read of the product — start at the Security overview if that is what you need. Each artifact below is reproduced as-is on its own page; this page just explains what each one is and when to reach for it.
Dated 2026-08-18 for alpha4. Every one of the 345 requirements in the OWASP Application Security Verification Standard 5.0.0 (covering Levels 1 to 3), assessed as Pass, Partial, Gap or Not Applicable, with the repository evidence behind each one. Read this when you need to check a specific control by ASVS requirement id, or want the complete picture rather than a summary.
Dated 2026-09-09. Answers every control in OpenSSF Baseline Levels 1–3 with repository evidence, including explicit unmet controls instead of treating configured tools as proof of enforcement. Read this when completing or checking the project's public OpenSSF badge entry.
The alpha4 reassessment covered the complete source at that time and the security-relevant delta since July. It records the fixed session-idle, strict-OIDC SSRF and provider-token findings, refreshed the threat model and inventories, and reconciled all 345 ASVS controls with the CI and ZAP evidence available then.
A point-in-time source-code review against the ASVS ledger above, plus the OWASP Top 10 and API Security Top 10. It explains the review's scope and method, lists findings and how they were treated, and states plainly what is a code guarantee, what is an inherited library guarantee, and what is left to the self-hosting operator. Read this for the narrative version of the ASVS ledger — what was found, fixed and accepted, and why.
Dated 2026-08-18. States CapacityLens's security objectives in plain terms, lists the assets and trust boundaries being protected, names the realistic attackers (a malicious teammate, a credential-stuffing bot, a compromised identity provider, a careless operator, and more), and maps each abuse case to the controls and tests that address it. Ends with the risks that are consciously accepted rather than fixed. Read this to understand why a control exists, not just that it does.
Dated 2026-08-18. The reference tables behind the threat model and ASVS ledger: every entry point and untrusted input, every class of sensitive data and how long it is kept, the full cryptographic inventory (what algorithm protects what, and its key lifecycle), service and rate limits, and the audit/security event log. Read this when you need the specific technical detail — for example, exactly what algorithm hashes a password, or exactly how long a session token lives.
The first review of CapacityLens's mutation-testing results (a technique that deliberately introduces small bugs into the code to check whether the test suite catches them) read for security meaning rather than raw score. It found and fixed one real defence-in-depth defect in tenant-data validation, and records which parts of the codebase the mutation score does — and does not — cover.
A follow-up review after a test-scope correction (two React hooks had been wrongly included in the mutation run). Confirms the corrected 92.37% score, with tenant isolation, private-name handling and password-reset failure mapping all still at 100%. Read the two mutation reviews together for the current state of that evidence and what it does not claim to cover.
CapacityLens keeps a set of dated, evidence-based security documents alongside the code, instead of a one-off claim of "secure by design". They are written for security reviewers, auditors and technical evaluators, not for a first read of the product — start at the Security overview if that is what you need. Each artifact below is reproduced as-is on its own page; this page just explains what each one is and when to reach for it.
Dated 2026-08-18 for alpha4. Every one of the 345 requirements in the OWASP Application Security Verification Standard 5.0.0 (covering Levels 1 to 3), assessed as Pass, Partial, Gap or Not Applicable, with the repository evidence behind each one. Read this when you need to check a specific control by ASVS requirement id, or want the complete picture rather than a summary.
Dated 2026-09-09. Answers every control in OpenSSF Baseline Levels 1–3 with repository evidence, including explicit unmet controls instead of treating configured tools as proof of enforcement. Read this when completing or checking the project's public OpenSSF badge entry.
The alpha4 reassessment covered the complete source at that time and the security-relevant delta since July. It records the fixed session-idle, strict-OIDC SSRF and provider-token findings, refreshed the threat model and inventories, and reconciled all 345 ASVS controls with the CI and ZAP evidence available then.
A point-in-time source-code review against the ASVS ledger above, plus the OWASP Top 10 and API Security Top 10. It explains the review's scope and method, lists findings and how they were treated, and states plainly what is a code guarantee, what is an inherited library guarantee, and what is left to the self-hosting operator. Read this for the narrative version of the ASVS ledger — what was found, fixed and accepted, and why.
Dated 2026-08-18. States CapacityLens's security objectives in plain terms, lists the assets and trust boundaries being protected, names the realistic attackers (a malicious teammate, a credential-stuffing bot, a compromised identity provider, a careless operator, and more), and maps each abuse case to the controls and tests that address it. Ends with the risks that are consciously accepted rather than fixed. Read this to understand why a control exists, not just that it does.
Dated 2026-08-18. The reference tables behind the threat model and ASVS ledger: every entry point and untrusted input, every class of sensitive data and how long it is kept, the full cryptographic inventory (what algorithm protects what, and its key lifecycle), service and rate limits, and the audit/security event log. Read this when you need the specific technical detail — for example, exactly what algorithm hashes a password, or exactly how long a session token lives.
The first review of CapacityLens's mutation-testing results (a technique that deliberately introduces small bugs into the code to check whether the test suite catches them) read for security meaning rather than raw score. It found and fixed one real defence-in-depth defect in tenant-data validation, and records which parts of the codebase the mutation score does — and does not — cover.
A follow-up review after a test-scope correction (two React hooks had been wrongly included in the mutation run). Confirms the corrected 92.37% score, with tenant isolation, private-name handling and password-reset failure mapping all still at 100%. Read the two mutation reviews together for the current state of that evidence and what it does not claim to cover.
CapacityLens is suitable for continued community review and public CI, but this review is not a claim of formal certification or that every self-hosted deployment is secure. The implementation mechanisms identified by the review are covered by tests. Community deployments now deliberately treat MFA, breach-check opt-out, storage/log-forwarding attestations and same-host internal TLS as optional hardening, so strict ASVS Level 2 depends on operator configuration rather than startup enforcement. Higher-assurance hardware authentication, adaptive device/location decisions, HSM/full-memory encryption and deployer-provided controls remain outside the application guarantee.
The target is OWASP ASVS 5.0 Level 2 when optional hardening is enabled, with Level 3 controls assessed rather than ignored. The companion complete ASVS ledger accounts for all 345 requirements as Pass, Partial, Gap or Not Applicable. Password-only deployments are below the strict L2 authentication target by explicit product-policy choice.
Manual data-flow, trust-boundary and authorization review against every ASVS 5.0.0 control.
Threat-model review using attacker, tenant, operator, browser, provider and supply-chain abuse cases.
Focused regression tests for each remediated control, followed by repository gates, cross-browser E2E, dependency/secret/container/DAST checks where locally available.
Mapping to OWASP Top 10 (2021), OWASP API Security Top 10 (2023), ASVS levels and OWASP SAMM.
Explicit separation of code guarantees, inherited framework/library guarantees and external operator controls.
This is a point-in-time source assessment, not a penetration test of a named production host. OIDC provider behavior, TLS, volume encryption, host/container policy and logging infrastructure require deployment-specific verification.
Production password mode could operate without a second factor
Critical
Required TOTP remains implemented and integration-tested but is now opt-in; password-only community deployments explicitly accept this risk and do not meet ASVS V6.3.3 L2
CL-02
Password policy/storage did not meet current full OWASP guidance
High
15–128 Unicode code points, no composition rule, context-word rejection, HIBP k-anonymity check, versioned scrypt N=2^17,r=8,p=1, exact-byte verification and constant-time comparison
CL-03
Session lifetime, visibility and containment were incomplete
High
Fixed 12-hour and 30-minute idle limits, 15-minute freshness for privileged actions, __Host- cookies, user session inventory/revocation, admin identity-global revocation with cross-account authority checks, reset invalidation
CL-04
CORS alone was treated as the browser cross-site boundary
High
Root hook now rejects unsafe disallowed Origin or cross-site Fetch Metadata requests; exact origin CORS remains additive defense
CL-05
Offline tenant snapshots were plaintext in browser storage
High
AES-256-GCM, non-extractable device key, random IV/AAD, tamper/expiry deletion, legacy plaintext wipe and viewer-only behavior
CL-06
SSO-only production could silently inherit unknown single-factor assurance
High
SMALLSASS_ACCOUNT_SSO_MFA_ENFORCED=1 records tested IdP assurance; its absence warns instead of refusing startup
CL-07
Provider/password-check URLs allowed unsafe configuration or redirect behavior
High
Provider endpoints require credential-free absolute HTTPS (loopback HTTP only in development); HIBP endpoint is fixed, time-bounded, no-redirect and fail-closed
CL-08
Security events were not a complete, separately forwardable stream
High
Typed security JSON plus mutation audit JSON exist; local audit remains required, while stdout/external forwarding is optional and absence is warned
CL-09
Production could claim data protection without encrypted persistent storage
High
The storage attestation is documented as external evidence only; its absence now warns so simple self-hosting can use ordinary host storage
Process 0077 umask; 0600 database/WAL/SHM/audit/snapshot files; 0700 backup directory; tested
CL-11
Deep health performed work proportional to tenant data and runtime integrity checks
Medium
Startup performs the full foreign-key check once; health uses constant SELECT 1, reports audit state and is rate-limit exempt so application traffic cannot starve the uptime probe
CL-12
Sensitive API responses and token-bearing SPA routes had incomplete cache protection
Medium
All API responses are no-store; invite/reset routes are no-store and not access-logged; nonexistent file-like paths return 404
CL-13
Baseline browser policy lacked stronger production directives
Medium
Two-year HSTS including subdomains, CSP/frame isolation, no-referrer, no-sniff, COEP/COOP/CORP and restrictive Permissions Policy; runtime-injected styles moved to a static asset so style elements remain forbidden
CL-14
Headless production bootstrap created a strong but non-expiring initial password
Medium
Headless/pinned bootstrap password paths are development-only; production must use setup-token signup where the owner chooses the final password
CL-15
Release security checks did not cover the full public supply-chain path
Production images carried unnecessary package-manager, frontend/test and network-client code, including newly vulnerable transitive/base packages
High
Dedicated pnpm deploy lock, explicit peer isolation and a Docker graph assertion reduce the API from 284 to 81 stored packages; npm/Corepack/Yarn and nginx curl/libcurl are removed; all three shipped images pass Trivy HIGH/CRITICAL scanning
CL-17
Allocation validation treated an unresolved project on a project-bound activity as though the activity had no project
Medium
Missing and cross-account projects now fail closed at the shared write boundary; inactive and unchanged-reference behavior is mutation-tested with adversarial cases
CL-18
The pinned package manager's dependency-audit client used registry endpoints that had been retired
Medium
pnpm 11 uses npm's supported bulk-advisory endpoint; the production-only audit is again fail-capable in local and hosted gates; clean installs explicitly allow only esbuild's reviewed lifecycle script
CL-19
The packaged nginx→API connection used plaintext HTTP
Medium
Compose creates/verifies a per-install P-256 CA/API identity over TLS 1.2/1.3; bare-metal same-host loopback HTTP is now supported, while configured partial/unreadable TLS identities still fail closed
CL-20
CSP violations had no reporting destination
Medium
Legacy/current CSP reporting directives feed a public rate-limited 64 KiB endpoint that projects at most 20 origin/directive-only events into the separately forwarded security stream
CL-21
Accepted sockets and memory-expensive password/security work lacked explicit process queues
High
API sockets are capped at 512; scrypt is capped at 2 active/16 queued and HIBP at 8 active/32 queued with timeout/fail-closed overflow; maxima and recovery behavior are inventoried
CL-22
Packaged same-origin writes could be rejected when the redundant cross-origin allow-list was empty
Medium
The CSRF boundary now derives the exact public origin from the trusted proxy's overwritten scheme plus Host while still rejecting cross-site Fetch Metadata/disallowed origins; direct and proxied cases are regression-tested
CL-23
Mutation tooling resolved a newly disclosed remotely triggerable qs.stringify denial of service
Medium
The workspace overrides Stryker's transitive dependency to patched qs@6.15.2; frozen install and a full 625-entry dependency audit pass with zero advisories
CL-24
Unhandled process exceptions/rejections could lose structured security diagnostics before supervisor recovery
Medium
A process-wide last-resort handler records full local diagnostics plus a sanitized security event, drains in-flight work, exits non-zero and is restarted by the deployment supervisor rather than continuing potentially corrupt state
These are not concealed as “accepted passes.” Owners should reassess them when the deployment's data sensitivity or user population changes.
Residual
ASVS impact
Current treatment
Recommended trigger/action
Required MFA is optional; TOTP is phishable
V6.3.3 L2/L3
Password-only is supported with a warning; required TOTP meets L2 when enabled but is not phishing-resistant
Enable required MFA for sensitive/public multi-user deployments; add WebAuthn/passkeys before high-assurance use
Breached-password screening can be disabled
V6.2.12 L2
HIBP k-anonymity checking remains default and fail-closed when enabled; off emits a warning
Disable only for an isolated/offline deployment with a documented alternative password-risk decision
Legacy Better Auth password hashes retain the old work factor until credential rotation
V11.2.2/V11.4.2
Verify-only compatibility; all new/reset/change hashes use scrypt-v1
Prompt or require password reset after upgrade if the old database may have been exposed
OIDC/social behavior is provider-dependent
V6.8.4, V7.1.3/V7.6.1
Strict OIDC validates issuer and every endpoint before redirect/secret use, bounds no-redirect provider fetches, verifies signed audience-bound ID tokens, JWKS rotation, user-info subject and invite admission; named social remains experimental
Capture IdP-specific staging/MFA evidence; locally revoke sessions during offboarding; revisit back-channel logout before hosted GA
No adaptive IP/device/location risk engine or anomalous-login user notification
Public TLS remains mandatory at the edge; Compose verifies internal TLS, while bare metal may use same-host loopback HTTP
Capture scanner/proxy evidence; enable internal TLS when host isolation is insufficient
No HSM, full-memory encryption or PQC implementation
V11 L3
Standard platform crypto, versioned formats and an automated crypto-discovery inventory
Revisit for regulated/high-assurance use or when NIST/platform guidance changes
Host encryption, secret manager, log collector, clocks, ACLs, retention and off-host backups are not observable by code
V11–V16
Optional attestations plus startup warnings and operator guidance
Add controls according to deployment sensitivity and verify them with infrastructure evidence
Single-process SQLite availability has a finite ceiling
V2.4/V13/V15/V16.5.4
512-socket ceiling, bounded scrypt/HIBP queues, throttling, bounded requests/imports, constant health, WAL/timeouts and fail-fast supervised recovery after an unhandled process fault
Add edge limits/monitoring; migrate architecture if measured load approaches limits
scrypt, AES-GCM, CSPRNG tokens and TLS-only external URLs; internal TLS may be loopback-only HTTP and disk/public-TLS/HSM remain operator controls
A03 Injection
Strong
React text rendering, no runtime eval/untrusted HTML, parameterized SQLite, explicit sanitisation/codecs, fixed/bounded regex and URLs
A04 Insecure Design
Strong
Threat model, standing invariants, closed signup, single-company default, fail-closed production guard, atomic import and explicit residual-risk ledger
A05 Security Misconfiguration
Strong defaults
Non-root/read-only/cap-drop containers, restrictive CSP/site-isolation/CORS/cache headers, hidden server version, production interlocks and no test reset; reverse proxy/TLS/collector still need correct deployment
A06 Vulnerable and Outdated Components
Automated
Minimal production graph, lockfile-recorded dependency patch, Dependabot, audit, dependency review, CodeQL, SBOM and Trivy; remediation timing is documented below
A07 Identification and Authentication Failures
Configurable; below strict L2 by default
HIBP defaults on and required TOTP is available, but both may be relaxed; scrypt, throttling, fixed/idle/revocable sessions and host-only cookies remain enforced
Account membership and object accountId are independently enforced on trusted server state; cross-account tests cover CRUD, import and session administration.
API2 Broken Authentication
Password/TOTP support, generic failure, throttling, host-only cookies, fixed/idle sessions and immediate revocation exist; required MFA and SSO assurance are optional deployment choices.
API3 Broken Object Property Level Authorization
Explicit schemas/column codecs, protected-name field projection and preservation, and output minimisation defend both mass assignment and field disclosure.
API4 Unrestricted Resource Consumption
Body/record/batch/numeric caps, 512-socket ceiling, bounded scrypt/HIBP queues, per-IP throttling, timeouts and constant health exist; edge/global quotas remain operator controls.
API5 Broken Function Level Authorization
Central action/role matrix and server authorization precede mutations; UI visibility is never the authority.
API6 Unrestricted Access to Sensitive Business Flows
Setup, invitation, reset, membership, import, purge and account operations are gated, rate limited, fresh-session protected where privileged and audited.
API7 Server Side Request Forgery
End users cannot supply fetch destinations; configured identity endpoints are HTTPS validated and the fixed HIBP request refuses redirects.
API8 Security Misconfiguration
Production refuses auth-off, invalid rate limits, disabled local audit and unsafe bootstrap; optional MFA/storage/forwarding/internal-TLS gaps warn; headers, CORS, cache and containers remain hardened.
API9 Improper Inventory Management
Entry-point, data, crypto, log and dependency inventories are versioned in docs/security; no undocumented versioned API exists.
API10 Unsafe Consumption of APIs
HIBP is time-bounded/no-redirect/fail-closed. Strict OIDC validates discovered endpoints before use, refuses server-side redirects, bounds JSON to 1 MiB/10 seconds and cryptographically verifies identity claims; named social providers remain experimental.
Add signed container publication and enforced branch protections when public
Verification
Unit/integration/mutation/cross-browser E2E, authorization regressions, restore drill, container scan and ZAP; survivor triage is recorded in the mutation review
Commission an independent authenticated penetration test against the release deployment
Operations
Production guard, typed forwarding, restrictive files/containers, backup and incident runbooks
Exercise incident/log/restore procedures with the real collector, IdP and encrypted backup destination
Critical actively exploitable runtime vulnerability: contain immediately; patch or disable the affected path within 24 hours.
High runtime vulnerability: patch within 7 days. Medium: 30 days. Low: 90 days or document why it is not reachable/impactful.
Supported runtime/dependency updates without a known vulnerability: review monthly; do not let a runtime or security-critical library leave upstream support.
Better Auth/provider protocol code, import/migration, cryptography, backup/restore, service worker, shell/process execution in development scripts and release workflows are “risky/dangerous” areas: require focused tests and security review when changed.
Rotate SMALLSASS_ACCOUNT_SECRET and provider credentials after suspected exposure, staff/access change or provider requirement, and at the operator's documented interval. Rotation of SMALLSASS_ACCOUNT_SECRET invalidates sessions. TLS/storage/backup keys follow the platform key policy.
Review this report, threat model, inventories, action/image pins and ASVS release at least annually and after a material auth, tenancy, deployment or data-classification change.
Pass: 2,988 mutants; 2,763 killed, 11 timed out, 177 survived, 37 uncovered and 0 errors; 92.84% total / 94.00% covered score against an 85% break threshold
pnpm audit
Pass: no known vulnerabilities across all 625 production, development and optional dependency entries; GHSA-q8mj-m7cp-5q26 is fixed at qs@6.15.2
CI=1 pnpm install --frozen-lockfile
Pass: clean install from the frozen lockfile; pnpm's fail-closed lifecycle policy permits only esbuild's reviewed install script
Gitleaks 8.30.1
Pass: all 16 Git commits and the final worktree; no leaks found
Docker production build/guard/smoke
Pass: dedicated 81-package API runtime graph, verified internal TLS with no plaintext fallback, production interlocks, loopback-only public edge, health/database/audit status, security headers, same-origin reporting and cross-site unsafe-request rejection
Trivy 0.72.0
Pass: exact final API, web and internal-TLS initializer image archives contain no fixed HIGH or CRITICAL vulnerabilities
Pass: all 345 official v5.0.0 IDs appear exactly once—199 Pass, 48 Partial, 7 Gap and 91 N/A
git diff --check
Pass
The four ZAP informational classes are suspicious strings in public/minified HTML, intentional non-storable shell responses, the expected modern-SPA classification and the deliberately retained legacy report-uri directive beside report-to for browser interoperability. They are downgraded to INFO—not ignored—in .zap/rules.tsv; every warning or failure remains build-failing.
Hosted CodeQL, dependency review, SBOM generation and tagged provenance are configured but cannot be claimed as executed by this local review. Their first public GitHub run is a release gate, and a real deployment still needs the operator evidence and independent testing identified above.
Two corrections to the DAST posture described above, made after the first public CI runs:
The INFO level in .zap/rules.tsv was never honored — ZAP's baseline rules file supports only IGNORE/WARN/FAIL, so the four reviewed informational classes were still failing the public workflow. They are now IGNORE, with the review rationale kept in the rules file; any new warning class still fails the blocking scan.
DAST is now two-tier, aligned with the optional-hardening posture model introduced in v0.20.0-alpha.3: the blocking baseline boots and scans the hardened posture (password authentication, required MFA, scheduled backups, the storage/log-forwarding attestations, and per-run minted, masked credentials) on every push and pull request, while the out-of-the-box default posture is scanned by a separate non-blocking weekly job whose report is published as an artifact. Findings there document the default's accepted residual surface rather than failing the build.
CapacityLens is suitable for continued community review and public CI, but this review is not a claim of formal certification or that every self-hosted deployment is secure. The implementation mechanisms identified by the review are covered by tests. Community deployments now deliberately treat MFA, breach-check opt-out, storage/log-forwarding attestations and same-host internal TLS as optional hardening, so strict ASVS Level 2 depends on operator configuration rather than startup enforcement. Higher-assurance hardware authentication, adaptive device/location decisions, HSM/full-memory encryption and deployer-provided controls remain outside the application guarantee.
The target is OWASP ASVS 5.0 Level 2 when optional hardening is enabled, with Level 3 controls assessed rather than ignored. The companion complete ASVS ledger accounts for all 345 requirements as Pass, Partial, Gap or Not Applicable. Password-only deployments are below the strict L2 authentication target by explicit product-policy choice.
Manual data-flow, trust-boundary and authorization review against every ASVS 5.0.0 control.
Threat-model review using attacker, tenant, operator, browser, provider and supply-chain abuse cases.
Focused regression tests for each remediated control, followed by repository gates, cross-browser E2E, dependency/secret/container/DAST checks where locally available.
Mapping to OWASP Top 10 (2021), OWASP API Security Top 10 (2023), ASVS levels and OWASP SAMM.
Explicit separation of code guarantees, inherited framework/library guarantees and external operator controls.
This is a point-in-time source assessment, not a penetration test of a named production host. OIDC provider behavior, TLS, volume encryption, host/container policy and logging infrastructure require deployment-specific verification.
Production password mode could operate without a second factor
Critical
Required TOTP remains implemented and integration-tested but is now opt-in; password-only community deployments explicitly accept this risk and do not meet ASVS V6.3.3 L2
CL-02
Password policy/storage did not meet current full OWASP guidance
High
15–128 Unicode code points, no composition rule, context-word rejection, HIBP k-anonymity check, versioned scrypt N=2^17,r=8,p=1, exact-byte verification and constant-time comparison
CL-03
Session lifetime, visibility and containment were incomplete
High
Fixed 12-hour and 30-minute idle limits, 15-minute freshness for privileged actions, __Host- cookies, user session inventory/revocation, admin identity-global revocation with cross-account authority checks, reset invalidation
CL-04
CORS alone was treated as the browser cross-site boundary
High
Root hook now rejects unsafe disallowed Origin or cross-site Fetch Metadata requests; exact origin CORS remains additive defense
CL-05
Offline tenant snapshots were plaintext in browser storage
High
AES-256-GCM, non-extractable device key, random IV/AAD, tamper/expiry deletion, legacy plaintext wipe and viewer-only behavior
CL-06
SSO-only production could silently inherit unknown single-factor assurance
High
SMALLSASS_ACCOUNT_SSO_MFA_ENFORCED=1 records tested IdP assurance; its absence warns instead of refusing startup
CL-07
Provider/password-check URLs allowed unsafe configuration or redirect behavior
High
Provider endpoints require credential-free absolute HTTPS (loopback HTTP only in development); HIBP endpoint is fixed, time-bounded, no-redirect and fail-closed
CL-08
Security events were not a complete, separately forwardable stream
High
Typed security JSON plus mutation audit JSON exist; local audit remains required, while stdout/external forwarding is optional and absence is warned
CL-09
Production could claim data protection without encrypted persistent storage
High
The storage attestation is documented as external evidence only; its absence now warns so simple self-hosting can use ordinary host storage
Process 0077 umask; 0600 database/WAL/SHM/audit/snapshot files; 0700 backup directory; tested
CL-11
Deep health performed work proportional to tenant data and runtime integrity checks
Medium
Startup performs the full foreign-key check once; health uses constant SELECT 1, reports audit state and is rate-limit exempt so application traffic cannot starve the uptime probe
CL-12
Sensitive API responses and token-bearing SPA routes had incomplete cache protection
Medium
All API responses are no-store; invite/reset routes are no-store and not access-logged; nonexistent file-like paths return 404
CL-13
Baseline browser policy lacked stronger production directives
Medium
Two-year HSTS including subdomains, CSP/frame isolation, no-referrer, no-sniff, COEP/COOP/CORP and restrictive Permissions Policy; runtime-injected styles moved to a static asset so style elements remain forbidden
CL-14
Headless production bootstrap created a strong but non-expiring initial password
Medium
Headless/pinned bootstrap password paths are development-only; production must use setup-token signup where the owner chooses the final password
CL-15
Release security checks did not cover the full public supply-chain path
Production images carried unnecessary package-manager, frontend/test and network-client code, including newly vulnerable transitive/base packages
High
Dedicated pnpm deploy lock, explicit peer isolation and a Docker graph assertion reduce the API from 284 to 81 stored packages; npm/Corepack/Yarn and nginx curl/libcurl are removed; all three shipped images pass Trivy HIGH/CRITICAL scanning
CL-17
Allocation validation treated an unresolved project on a project-bound activity as though the activity had no project
Medium
Missing and cross-account projects now fail closed at the shared write boundary; inactive and unchanged-reference behavior is mutation-tested with adversarial cases
CL-18
The pinned package manager's dependency-audit client used registry endpoints that had been retired
Medium
pnpm 11 uses npm's supported bulk-advisory endpoint; the production-only audit is again fail-capable in local and hosted gates; clean installs explicitly allow only esbuild's reviewed lifecycle script
CL-19
The packaged nginx→API connection used plaintext HTTP
Medium
Compose creates/verifies a per-install P-256 CA/API identity over TLS 1.2/1.3; bare-metal same-host loopback HTTP is now supported, while configured partial/unreadable TLS identities still fail closed
CL-20
CSP violations had no reporting destination
Medium
Legacy/current CSP reporting directives feed a public rate-limited 64 KiB endpoint that projects at most 20 origin/directive-only events into the separately forwarded security stream
CL-21
Accepted sockets and memory-expensive password/security work lacked explicit process queues
High
API sockets are capped at 512; scrypt is capped at 2 active/16 queued and HIBP at 8 active/32 queued with timeout/fail-closed overflow; maxima and recovery behavior are inventoried
CL-22
Packaged same-origin writes could be rejected when the redundant cross-origin allow-list was empty
Medium
The CSRF boundary now derives the exact public origin from the trusted proxy's overwritten scheme plus Host while still rejecting cross-site Fetch Metadata/disallowed origins; direct and proxied cases are regression-tested
CL-23
Mutation tooling resolved a newly disclosed remotely triggerable qs.stringify denial of service
Medium
The workspace overrides Stryker's transitive dependency to patched qs@6.15.2; frozen install and a full 625-entry dependency audit pass with zero advisories
CL-24
Unhandled process exceptions/rejections could lose structured security diagnostics before supervisor recovery
Medium
A process-wide last-resort handler records full local diagnostics plus a sanitized security event, drains in-flight work, exits non-zero and is restarted by the deployment supervisor rather than continuing potentially corrupt state
These are not concealed as “accepted passes.” Owners should reassess them when the deployment's data sensitivity or user population changes.
Residual
ASVS impact
Current treatment
Recommended trigger/action
Required MFA is optional; TOTP is phishable
V6.3.3 L2/L3
Password-only is supported with a warning; required TOTP meets L2 when enabled but is not phishing-resistant
Enable required MFA for sensitive/public multi-user deployments; add WebAuthn/passkeys before high-assurance use
Breached-password screening can be disabled
V6.2.12 L2
HIBP k-anonymity checking remains default and fail-closed when enabled; off emits a warning
Disable only for an isolated/offline deployment with a documented alternative password-risk decision
Legacy Better Auth password hashes retain the old work factor until credential rotation
V11.2.2/V11.4.2
Verify-only compatibility; all new/reset/change hashes use scrypt-v1
Prompt or require password reset after upgrade if the old database may have been exposed
OIDC/social behavior is provider-dependent
V6.8.4, V7.1.3/V7.6.1
Strict OIDC validates issuer and every endpoint before redirect/secret use, bounds no-redirect provider fetches, verifies signed audience-bound ID tokens, JWKS rotation, user-info subject and invite admission; named social remains experimental
Capture IdP-specific staging/MFA evidence; locally revoke sessions during offboarding; revisit back-channel logout before hosted GA
No adaptive IP/device/location risk engine or anomalous-login user notification
Public TLS remains mandatory at the edge; Compose verifies internal TLS, while bare metal may use same-host loopback HTTP
Capture scanner/proxy evidence; enable internal TLS when host isolation is insufficient
No HSM, full-memory encryption or PQC implementation
V11 L3
Standard platform crypto, versioned formats and an automated crypto-discovery inventory
Revisit for regulated/high-assurance use or when NIST/platform guidance changes
Host encryption, secret manager, log collector, clocks, ACLs, retention and off-host backups are not observable by code
V11–V16
Optional attestations plus startup warnings and operator guidance
Add controls according to deployment sensitivity and verify them with infrastructure evidence
Single-process SQLite availability has a finite ceiling
V2.4/V13/V15/V16.5.4
512-socket ceiling, bounded scrypt/HIBP queues, throttling, bounded requests/imports, constant health, WAL/timeouts and fail-fast supervised recovery after an unhandled process fault
Add edge limits/monitoring; migrate architecture if measured load approaches limits
scrypt, AES-GCM, CSPRNG tokens and TLS-only external URLs; internal TLS may be loopback-only HTTP and disk/public-TLS/HSM remain operator controls
A03 Injection
Strong
React text rendering, no runtime eval/untrusted HTML, parameterized SQLite, explicit sanitisation/codecs, fixed/bounded regex and URLs
A04 Insecure Design
Strong
Threat model, standing invariants, closed signup, single-company default, fail-closed production guard, atomic import and explicit residual-risk ledger
A05 Security Misconfiguration
Strong defaults
Non-root/read-only/cap-drop containers, restrictive CSP/site-isolation/CORS/cache headers, hidden server version, production interlocks and no test reset; reverse proxy/TLS/collector still need correct deployment
A06 Vulnerable and Outdated Components
Automated
Minimal production graph, lockfile-recorded dependency patch, Dependabot, audit, dependency review, CodeQL, SBOM and Trivy; remediation timing is documented below
A07 Identification and Authentication Failures
Configurable; below strict L2 by default
HIBP defaults on and required TOTP is available, but both may be relaxed; scrypt, throttling, fixed/idle/revocable sessions and host-only cookies remain enforced
Account membership and object accountId are independently enforced on trusted server state; cross-account tests cover CRUD, import and session administration.
API2 Broken Authentication
Password/TOTP support, generic failure, throttling, host-only cookies, fixed/idle sessions and immediate revocation exist; required MFA and SSO assurance are optional deployment choices.
API3 Broken Object Property Level Authorization
Explicit schemas/column codecs, protected-name field projection and preservation, and output minimisation defend both mass assignment and field disclosure.
API4 Unrestricted Resource Consumption
Body/record/batch/numeric caps, 512-socket ceiling, bounded scrypt/HIBP queues, per-IP throttling, timeouts and constant health exist; edge/global quotas remain operator controls.
API5 Broken Function Level Authorization
Central action/role matrix and server authorization precede mutations; UI visibility is never the authority.
API6 Unrestricted Access to Sensitive Business Flows
Setup, invitation, reset, membership, import, purge and account operations are gated, rate limited, fresh-session protected where privileged and audited.
API7 Server Side Request Forgery
End users cannot supply fetch destinations; configured identity endpoints are HTTPS validated and the fixed HIBP request refuses redirects.
API8 Security Misconfiguration
Production refuses auth-off, invalid rate limits, disabled local audit and unsafe bootstrap; optional MFA/storage/forwarding/internal-TLS gaps warn; headers, CORS, cache and containers remain hardened.
API9 Improper Inventory Management
Entry-point, data, crypto, log and dependency inventories are versioned in docs/security; no undocumented versioned API exists.
API10 Unsafe Consumption of APIs
HIBP is time-bounded/no-redirect/fail-closed. Strict OIDC validates discovered endpoints before use, refuses server-side redirects, bounds JSON to 1 MiB/10 seconds and cryptographically verifies identity claims; named social providers remain experimental.
Add signed container publication and enforced branch protections when public
Verification
Unit/integration/mutation/cross-browser E2E, authorization regressions, restore drill, container scan and ZAP; survivor triage is recorded in the mutation review
Commission an independent authenticated penetration test against the release deployment
Operations
Production guard, typed forwarding, restrictive files/containers, backup and incident runbooks
Exercise incident/log/restore procedures with the real collector, IdP and encrypted backup destination
Critical actively exploitable runtime vulnerability: contain immediately; patch or disable the affected path within 24 hours.
High runtime vulnerability: patch within 7 days. Medium: 30 days. Low: 90 days or document why it is not reachable/impactful.
Supported runtime/dependency updates without a known vulnerability: review monthly; do not let a runtime or security-critical library leave upstream support.
Better Auth/provider protocol code, import/migration, cryptography, backup/restore, service worker, shell/process execution in development scripts and release workflows are “risky/dangerous” areas: require focused tests and security review when changed.
Rotate SMALLSASS_ACCOUNT_SECRET and provider credentials after suspected exposure, staff/access change or provider requirement, and at the operator's documented interval. Rotation of SMALLSASS_ACCOUNT_SECRET invalidates sessions. TLS/storage/backup keys follow the platform key policy.
Review this report, threat model, inventories, action/image pins and ASVS release at least annually and after a material auth, tenancy, deployment or data-classification change.
Pass: 2,988 mutants; 2,763 killed, 11 timed out, 177 survived, 37 uncovered and 0 errors; 92.84% total / 94.00% covered score against an 85% break threshold
pnpm audit
Pass: no known vulnerabilities across all 625 production, development and optional dependency entries; GHSA-q8mj-m7cp-5q26 is fixed at qs@6.15.2
CI=1 pnpm install --frozen-lockfile
Pass: clean install from the frozen lockfile; pnpm's fail-closed lifecycle policy permits only esbuild's reviewed install script
Gitleaks 8.30.1
Pass: all 16 Git commits and the final worktree; no leaks found
Docker production build/guard/smoke
Pass: dedicated 81-package API runtime graph, verified internal TLS with no plaintext fallback, production interlocks, loopback-only public edge, health/database/audit status, security headers, same-origin reporting and cross-site unsafe-request rejection
Trivy 0.72.0
Pass: exact final API, web and internal-TLS initializer image archives contain no fixed HIGH or CRITICAL vulnerabilities
Pass: all 345 official v5.0.0 IDs appear exactly once—199 Pass, 48 Partial, 7 Gap and 91 N/A
git diff --check
Pass
The four ZAP informational classes are suspicious strings in public/minified HTML, intentional non-storable shell responses, the expected modern-SPA classification and the deliberately retained legacy report-uri directive beside report-to for browser interoperability. They are downgraded to INFO—not ignored—in .zap/rules.tsv; every warning or failure remains build-failing.
Hosted CodeQL, dependency review, SBOM generation and tagged provenance are configured but cannot be claimed as executed by this local review. Their first public GitHub run is a release gate, and a real deployment still needs the operator evidence and independent testing identified above.
Two corrections to the DAST posture described above, made after the first public CI runs:
The INFO level in .zap/rules.tsv was never honored — ZAP's baseline rules file supports only IGNORE/WARN/FAIL, so the four reviewed informational classes were still failing the public workflow. They are now IGNORE, with the review rationale kept in the rules file; any new warning class still fails the blocking scan.
DAST is now two-tier, aligned with the optional-hardening posture model introduced in v0.20.0-alpha.3: the blocking baseline boots and scans the hardened posture (password authentication, required MFA, scheduled backups, the storage/log-forwarding attestations, and per-run minted, masked credentials) on every push and pull request, while the out-of-the-box default posture is scanned by a separate non-blocking weekly job whose report is published as an artifact. Findings there document the default's accepted residual surface rather than failing the build.
CapacityLens 0.55.0-alpha.4 remains suitable for continued community review and public CI. The alpha4 delta review found no unresolved Critical, High or Medium application vulnerability. It did find that the dated assurance documents had fallen behind implemented controls, including the bounded import worker pool, stricter CSP-report limit, encrypted OAuth tokens, explicit federated identity linking and the SSO cutover/recovery paths. This review refreshes that evidence rather than turning documentation drift into an unsupported security claim.
The target remains OWASP ASVS 5.0 Level 2 when optional hardening is enabled. Password-only deployments remain below the strict Level 2 authentication target because required MFA is optional. Hardware-backed authentication, adaptive device/location decisions, HSM or full-memory encryption, and deployer-controlled TLS, storage and monitoring evidence remain outside the application guarantee. This is a source/configuration assessment, not a penetration test or certification.
This review reassessed the complete current application and the security-relevant delta since the 2026-07-14 review: React and offline browser state, the Fastify/SQLite API, shared validation, account and identity ports, password/MFA and strict OIDC, SSO cutover and stopped-server recovery, tenant authorization, imports and worker concurrency, audit durability, backups, nginx, Docker, workflows and the dependency graph.
The method was:
Reconcile the threat model and control inventories with the current source and configuration.
Review authentication, tenancy, destructive operations, concurrency, cryptography, deployment and supply-chain changes since the previous assessment.
Reconcile every ASVS 5.0.0 identifier and update evidence or status where the implementation changed.
Inspect the current Node 24 gate, account-boundary, migration, crash-durability, cross-browser, strict-OIDC, CodeQL, dependency, secret, container and OWASP ZAP evidence.
Keep application guarantees separate from library guarantees and operator controls.
The original CL-01–CL-24 findings and their treatments remain recorded in the previous review. The alpha4 reassessment adds these findings.
ID
Finding
Severity
Treatment
CL-25
The documented 30-minute inactivity limit had been ineffective because the session timestamp representation differed
High
Fixed before alpha4: the server normalizes the real stored representation, uses compare-and-swap touch/delete operations, expires at the exact boundary and retains the independent 12-hour absolute limit
CL-26
Strict-OIDC discovery could accept a provider-advertised server endpoint on a private or reserved network
High
Fixed before alpha4: discovered endpoints are resolved and classified before use; public issuers cannot redirect server-side token, JWKS or user-info traffic to private/reserved networks, and provider HTTP responses stay bounded
CL-27
OAuth access/refresh tokens were stored without application-layer encryption and implicit linking was enabled
High
Fixed before alpha4: Better Auth encrypts provider tokens with the application secret; implicit linking is disabled; explicit links require verified matching email and durable admission evidence
CL-28
The security documents omitted or misstated several implemented alpha4 limits and controls
Low
Resolved by this reassessment: the threat model, inventories and ASVS ledger now describe the import worker pool, CSP limit, SSO cutover/recovery, token encryption, current secret allowlist and concurrency status
No additional unresolved finding was identified in the reviewed scope.
The provider-neutral account boundary now owns account, identity, membership, invitation, session, recovery and erasure flows through explicit ports and policy checks. The server still independently authorizes each tenant operation; architecture and conformance tests prevent route code from bypassing the boundary.
Strict OIDC now verifies discovered endpoints before secret use, validates asymmetric ID tokens, requires verified email for admission/linking, disables implicit linking and records durable provider/subject observations. The SSO-only cutover refuses startup until every live membership is ready, then atomically revokes incompatible state and records activation.
Sole-Owner password recovery is a stopped-server operator ceremony. It requires an exclusive SQLite lock and a unique eligible identity, mints the normal single-use reset flow, revokes a partially issued ceremony on failure and records a token-free audit event.
Destructive imports remain Owner-only and atomic. CPU-heavy preparation uses a process-wide two-active/eight-queued worker bound with a five-second queue deadline and request cancellation; the transaction rechecks a fingerprint of the exact tenant slice so concurrent writes conflict instead of being overwritten.
Product writes commit a data-minimised audit event to the SQLite outbox with the mutation. Bounded recovery preserves malformed head rows for investigation, progressive draining avoids starving the event loop, and every delivery is flushed before deletion.
Offline snapshots remain encrypted, opt-in and read-only. The seven-day retention boundary is now swept on every cache connection as well as normal reads, preventing an unopened stale record from surviving indefinitely.
The refreshed ASVS 5.0.0 ledger accounts for all 345 requirements: 200 Pass, 48 Partial, 7 Gap and 90 Not Applicable. The only status movement is V15.4.4 from Not Applicable to Pass because alpha4 now has a bounded FIFO import-worker pool with queue deadlines, cancellation and starvation tests. Other changes strengthen evidence without changing status.
The OWASP Top 10 and API Security Top 10 mapping remains materially unchanged:
Broken access control and BOLA are constrained by server-side membership/action/field policy, tenant identifiers, SQLite constraints and cross-account tests.
Cryptographic failures are constrained by versioned scrypt, authenticated offline encryption, encrypted OAuth tokens, hashed bearer lookup, TLS verification and the gate-enforced inventory.
Injection, insecure design and unsafe API consumption are constrained by structured parsing, allowlisted fields, parameterized SQLite, explicit trust boundaries and bounded no-redirect provider calls.
Misconfiguration and vulnerable components are constrained by production startup checks, pinned build inputs, dependency review, CodeQL, SBOM, secret scanning, container scans and ZAP.
Logging and monitoring controls include transactional mutation audit, typed security events and unattended security-workflow failure reporting; collector retention and alerting remain operator responsibilities.
The previous review's residual risks remain. In particular:
Required TOTP is optional and phishable; passkeys or another phishing-resistant factor are still needed before high-assurance use.
Provider disablement and upstream logout do not revoke already-issued local sessions. Operators must use local revocation during an incident; back-channel logout remains absent.
SSO cutover deliberately leaves password credentials dormant for the documented mixed-mode rollback. Host/database compromise and operator misuse remain outside in-process containment.
The stopped-server Owner recovery command is intentional operator authority. Protecting host and database access is therefore part of the authentication boundary.
Public TLS, encrypted volumes, secret management, time synchronization, immutable/off-host logs, backup retention and alerting require deployment evidence.
An unlocked or compromised application origin can use its non-extractable offline key, and a single-process SQLite service retains a finite availability ceiling.
Verification applies to c49ff283951f5735b9239971dd005d42e78a0481, which contains 0.55.0-alpha.4 plus documentation-only installation-route separation.
Verification
Result
Application gate
Pass: typecheck, ESLint, 3,382 tests across 191 files, coverage thresholds and production build/bundle checks
Server gate
Pass: typecheck, ESLint, all four unit shards, 311 account-boundary conformance tests, released v7→v34 migration rehearsal and credential-onboarding crash durability
Cross-browser and identity E2E
Pass: 251 Chromium/database/auth, 227 Firefox, 227 WebKit and five strict-OIDC/Dex scenarios
CodeQL and dependency review
Pass on current main
Secret scan and SBOM
Pass: no full-history leaks; source SBOM generated
Container scans
Pass: zero fixed High or Critical findings in the API, web and internal-TLS initializer images
OWASP ZAP
Pass: hardened posture on the reviewed commit; default posture manually rerun on 5f07b7f, before only documentation changed—23 URLs, 63 checks, zero failures, zero warnings and four reviewed ignores per profile
ASVS reconciliation
Pass: every official ASVS 5.0.0 identifier appears exactly once; totals are 200 Pass, 48 Partial, 7 Gap and 90 N/A
The default-posture ZAP run is informational because optional hardening is deliberately not forced. The four ignored classes retain their reviewed rationale in .zap/rules.tsv; new warnings and failures remain visible, and the hardened profile remains blocking.
CapacityLens 0.55.0-alpha.4 remains suitable for continued community review and public CI. The alpha4 delta review found no unresolved Critical, High or Medium application vulnerability. It did find that the dated assurance documents had fallen behind implemented controls, including the bounded import worker pool, stricter CSP-report limit, encrypted OAuth tokens, explicit federated identity linking and the SSO cutover/recovery paths. This review refreshes that evidence rather than turning documentation drift into an unsupported security claim.
The target remains OWASP ASVS 5.0 Level 2 when optional hardening is enabled. Password-only deployments remain below the strict Level 2 authentication target because required MFA is optional. Hardware-backed authentication, adaptive device/location decisions, HSM or full-memory encryption, and deployer-controlled TLS, storage and monitoring evidence remain outside the application guarantee. This is a source/configuration assessment, not a penetration test or certification.
This review reassessed the complete current application and the security-relevant delta since the 2026-07-14 review: React and offline browser state, the Fastify/SQLite API, shared validation, account and identity ports, password/MFA and strict OIDC, SSO cutover and stopped-server recovery, tenant authorization, imports and worker concurrency, audit durability, backups, nginx, Docker, workflows and the dependency graph.
The method was:
Reconcile the threat model and control inventories with the current source and configuration.
Review authentication, tenancy, destructive operations, concurrency, cryptography, deployment and supply-chain changes since the previous assessment.
Reconcile every ASVS 5.0.0 identifier and update evidence or status where the implementation changed.
Inspect the current Node 24 gate, account-boundary, migration, crash-durability, cross-browser, strict-OIDC, CodeQL, dependency, secret, container and OWASP ZAP evidence.
Keep application guarantees separate from library guarantees and operator controls.
The original CL-01–CL-24 findings and their treatments remain recorded in the previous review. The alpha4 reassessment adds these findings.
ID
Finding
Severity
Treatment
CL-25
The documented 30-minute inactivity limit had been ineffective because the session timestamp representation differed
High
Fixed before alpha4: the server normalizes the real stored representation, uses compare-and-swap touch/delete operations, expires at the exact boundary and retains the independent 12-hour absolute limit
CL-26
Strict-OIDC discovery could accept a provider-advertised server endpoint on a private or reserved network
High
Fixed before alpha4: discovered endpoints are resolved and classified before use; public issuers cannot redirect server-side token, JWKS or user-info traffic to private/reserved networks, and provider HTTP responses stay bounded
CL-27
OAuth access/refresh tokens were stored without application-layer encryption and implicit linking was enabled
High
Fixed before alpha4: Better Auth encrypts provider tokens with the application secret; implicit linking is disabled; explicit links require verified matching email and durable admission evidence
CL-28
The security documents omitted or misstated several implemented alpha4 limits and controls
Low
Resolved by this reassessment: the threat model, inventories and ASVS ledger now describe the import worker pool, CSP limit, SSO cutover/recovery, token encryption, current secret allowlist and concurrency status
No additional unresolved finding was identified in the reviewed scope.
The provider-neutral account boundary now owns account, identity, membership, invitation, session, recovery and erasure flows through explicit ports and policy checks. The server still independently authorizes each tenant operation; architecture and conformance tests prevent route code from bypassing the boundary.
Strict OIDC now verifies discovered endpoints before secret use, validates asymmetric ID tokens, requires verified email for admission/linking, disables implicit linking and records durable provider/subject observations. The SSO-only cutover refuses startup until every live membership is ready, then atomically revokes incompatible state and records activation.
Sole-Owner password recovery is a stopped-server operator ceremony. It requires an exclusive SQLite lock and a unique eligible identity, mints the normal single-use reset flow, revokes a partially issued ceremony on failure and records a token-free audit event.
Destructive imports remain Owner-only and atomic. CPU-heavy preparation uses a process-wide two-active/eight-queued worker bound with a five-second queue deadline and request cancellation; the transaction rechecks a fingerprint of the exact tenant slice so concurrent writes conflict instead of being overwritten.
Product writes commit a data-minimised audit event to the SQLite outbox with the mutation. Bounded recovery preserves malformed head rows for investigation, progressive draining avoids starving the event loop, and every delivery is flushed before deletion.
Offline snapshots remain encrypted, opt-in and read-only. The seven-day retention boundary is now swept on every cache connection as well as normal reads, preventing an unopened stale record from surviving indefinitely.
The refreshed ASVS 5.0.0 ledger accounts for all 345 requirements: 200 Pass, 48 Partial, 7 Gap and 90 Not Applicable. The only status movement is V15.4.4 from Not Applicable to Pass because alpha4 now has a bounded FIFO import-worker pool with queue deadlines, cancellation and starvation tests. Other changes strengthen evidence without changing status.
The OWASP Top 10 and API Security Top 10 mapping remains materially unchanged:
Broken access control and BOLA are constrained by server-side membership/action/field policy, tenant identifiers, SQLite constraints and cross-account tests.
Cryptographic failures are constrained by versioned scrypt, authenticated offline encryption, encrypted OAuth tokens, hashed bearer lookup, TLS verification and the gate-enforced inventory.
Injection, insecure design and unsafe API consumption are constrained by structured parsing, allowlisted fields, parameterized SQLite, explicit trust boundaries and bounded no-redirect provider calls.
Misconfiguration and vulnerable components are constrained by production startup checks, pinned build inputs, dependency review, CodeQL, SBOM, secret scanning, container scans and ZAP.
Logging and monitoring controls include transactional mutation audit, typed security events and unattended security-workflow failure reporting; collector retention and alerting remain operator responsibilities.
The previous review's residual risks remain. In particular:
Required TOTP is optional and phishable; passkeys or another phishing-resistant factor are still needed before high-assurance use.
Provider disablement and upstream logout do not revoke already-issued local sessions. Operators must use local revocation during an incident; back-channel logout remains absent.
SSO cutover deliberately leaves password credentials dormant for the documented mixed-mode rollback. Host/database compromise and operator misuse remain outside in-process containment.
The stopped-server Owner recovery command is intentional operator authority. Protecting host and database access is therefore part of the authentication boundary.
Public TLS, encrypted volumes, secret management, time synchronization, immutable/off-host logs, backup retention and alerting require deployment evidence.
An unlocked or compromised application origin can use its non-extractable offline key, and a single-process SQLite service retains a finite availability ceiling.
Verification applies to c49ff283951f5735b9239971dd005d42e78a0481, which contains 0.55.0-alpha.4 plus documentation-only installation-route separation.
Verification
Result
Application gate
Pass: typecheck, ESLint, 3,382 tests across 191 files, coverage thresholds and production build/bundle checks
Server gate
Pass: typecheck, ESLint, all four unit shards, 311 account-boundary conformance tests, released v7→v34 migration rehearsal and credential-onboarding crash durability
Cross-browser and identity E2E
Pass: 251 Chromium/database/auth, 227 Firefox, 227 WebKit and five strict-OIDC/Dex scenarios
CodeQL and dependency review
Pass on current main
Secret scan and SBOM
Pass: no full-history leaks; source SBOM generated
Container scans
Pass: zero fixed High or Critical findings in the API, web and internal-TLS initializer images
OWASP ZAP
Pass: hardened posture on the reviewed commit; default posture manually rerun on 5f07b7f, before only documentation changed—23 URLs, 63 checks, zero failures, zero warnings and four reviewed ignores per profile
ASVS reconciliation
Pass: every official ASVS 5.0.0 identifier appears exactly once; totals are 200 Pass, 48 Partial, 7 Gap and 90 N/A
The default-posture ZAP run is informational because optional hardening is deliberately not forced. The four ignored classes retain their reviewed rationale in .zap/rules.tsv; new warnings and failures remain visible, and the hardened profile remains blocking.
A user can read or change only accounts, operations, records and protected fields allowed by their current server-side membership and role.
Passwords, session tokens, reset/invite tokens, MFA seeds/recovery codes and provider secrets are not disclosed or stored in recoverable form where a safer representation is possible.
Tenant writes remain valid, atomic and attributable; corrupted relational state prevents startup.
Browser-delivered code cannot silently turn an authenticated browser into a cross-site write primitive, and sensitive API responses are not cached.
Operators can contain a compromised identity, restore authoritative data and investigate typed security/audit events without relying on the compromised host alone.
CapacityLens is not a safety-critical, payments, classified-data, real-time media or anonymous public-upload system. Availability is important but confidentiality and tenant integrity take priority over keeping a misconfigured production process running.
Data-minimised JSON, local restrictive file plus optional separately forwarded stream
Process/log-collector boundary
Build and release inputs
Lockfile, pinned images/actions, dependency review, SBOM, scans and provenance
Contributor/CI/registry boundary
The packaged production flow is browser → public TLS proxy → unprivileged web container → verified per-install internal TLS → unprivileged HTTPS API container → local SQLite/backup volumes. A bare-metal deployment may instead use HTTP only over a same-host loopback hop. In both shapes the API must not be publicly reachable and the proxy must overwrite rather than append forwarding headers.
Viewer, editor, admin and owner, all potentially malicious within their legitimate account.
An authenticated user attempting cross-account or higher-role access.
An unauthenticated internet attacker, automated credential attacker or cross-site origin.
A compromised browser profile or device.
A malicious/compromised identity provider, dependency, build input or container base.
A self-hosting operator who makes an accidental or unsafe configuration choice.
A host operator misusing stopped-server SSO repair or sole-Owner recovery authority.
A host-level attacker. Host compromise is not fully preventable in-process; encrypted storage, secret management, isolation and off-host logs/backups limit consequences.
Membership fetched server-side for each operation; every scoped entity has accountId; row-addressed generic writes make absent and foreign ids response-indistinguishable; row/reference validation fails closed when a project-bound allocation cannot resolve its project in the same account; cross-account tests
app.authz, app.members, tenant-store, route and shared mutation tests
Function/field privilege escalation
Central action matrix; protected-name projection/preservation; owner-only import; fresh session for privileged actions
access, privacy and route tests
Credential stuffing/password cracking
Positive global/API throttling; five-attempt MFA lock; 15–128 Unicode code points; HIBP range check; scrypt N=2^17,r=8,p=1; no default password
password/auth/rate-limit tests
Password-only account takeover
Opt-in required TOTP wall before tenant data; otherwise long passwords, HIBP by default, scrypt, throttling and bounded/revocable sessions; one-time MFA recovery codes
real auth integration and UI tests
Session theft/fixation
Secure HttpOnly SameSite __Host- cookies; new token on auth; fixed 12-hour and 30-minute idle limits; revocation/reset invalidation; session inventory
auth and member revocation tests
CSRF and cross-origin data use
Unsafe-method Origin/Sec-Fetch-Site rejection; exact configured or trusted-proxy-derived same origin; SameSite cookie; safe HTTP methods
CSRF/CORS and packaged-proxy tests
Injection/XSS/mass assignment
React text rendering; no untrusted HTML; parameterized SQLite; explicit table/column codecs; sanitisation and structural limits; CSP
identity-port, onboarding and provider integration tests
Operator recovery misuse
Stopped server and exclusive SQLite lock; unique sole-Owner eligibility; ordinary expiring single-use reset; rollback on partial failure; token-free audit record
Owner-recovery CLI and audit tests
Resource exhaustion
512 accepted-socket ceiling; per-IP application/CSP rate limit with constant-work health exempt; bounded scrypt, HIBP and import queues; 5,000-operation batch and 200,000-record import caps; request/queue/provider timeouts
Google and Microsoft use Better Auth's provider OAuth flow for state, PKCE and cookies. CapacityLens checks provider-specific claims and account admission before creating a local identity. GitHub remains experimental in mixed mode and cannot meet provider-required sign-in. Provider configuration still needs installation-specific interoperability, MFA-policy and logout/session-lifetime testing.
IdP disablement does not revoke already-issued local sessions. The accepted maximum is the remaining twelve-hour absolute lifetime or thirty minutes inactivity; local revocation is an incident-response requirement and back-channel logout must be reconsidered before hosted GA.
Identity masquerade is a session-scoped, account-confined read projection for Owners and Admins. A root unsafe-method guard and a separate Better Auth proxy guard reject changes while it is active; actor-dependent reads use the target member's role and redaction. The process-local registry deliberately disappears on restart, which ends access rather than restoring uncertain state. Start and end events are audited, with the start event carrying the session expiry that bounds a record if the process stops before an end event can be written.
SSO-only mode leaves local password credentials available if the operator restores password mode. Protecting the host/database and using the stopped-server recovery procedures carefully remain operator responsibilities.
The sole-Owner recovery command is deliberate host-operator authority. It cannot be contained from an attacker who already controls the application database and process environment.
Required TOTP is optional. Password-only deployments do not meet ASVS 5.0 Level 2 requirement V6.3.3; when enabled, TOTP meets L2 but remains phishable and insufficient for L3.
Existing legacy Better Auth scrypt hashes use the former weaker profile until the user changes or resets the password. They are verify-only; new material never uses that format.
The application has no IP/device-risk engine, anomalous-login user notification, global administrator “revoke everyone” control or HSM/full-memory encryption.
Public TLS, encrypted host volumes, secret-manager/HSM use, clock synchronization, log retention and off-host collection are deployment controls. Internal service TLS is application-packaged for Compose and optional for a same-host bare-metal hop; startup warnings cannot verify external controls.
An unlocked or compromised application origin can invoke its non-extractable offline key. Device encryption, patching and profile access control remain necessary.
A single-process SQLite service can still be denied service by sufficient network or tenant-valid load. Edge rate limiting, connection limits and resource monitoring remain operator controls.
A user can read or change only accounts, operations, records and protected fields allowed by their current server-side membership and role.
Passwords, session tokens, reset/invite tokens, MFA seeds/recovery codes and provider secrets are not disclosed or stored in recoverable form where a safer representation is possible.
Tenant writes remain valid, atomic and attributable; corrupted relational state prevents startup.
Browser-delivered code cannot silently turn an authenticated browser into a cross-site write primitive, and sensitive API responses are not cached.
Operators can contain a compromised identity, restore authoritative data and investigate typed security/audit events without relying on the compromised host alone.
CapacityLens is not a safety-critical, payments, classified-data, real-time media or anonymous public-upload system. Availability is important but confidentiality and tenant integrity take priority over keeping a misconfigured production process running.
Data-minimised JSON, local restrictive file plus optional separately forwarded stream
Process/log-collector boundary
Build and release inputs
Lockfile, pinned images/actions, dependency review, SBOM, scans and provenance
Contributor/CI/registry boundary
The packaged production flow is browser → public TLS proxy → unprivileged web container → verified per-install internal TLS → unprivileged HTTPS API container → local SQLite/backup volumes. A bare-metal deployment may instead use HTTP only over a same-host loopback hop. In both shapes the API must not be publicly reachable and the proxy must overwrite rather than append forwarding headers.
Viewer, editor, admin and owner, all potentially malicious within their legitimate account.
An authenticated user attempting cross-account or higher-role access.
An unauthenticated internet attacker, automated credential attacker or cross-site origin.
A compromised browser profile or device.
A malicious/compromised identity provider, dependency, build input or container base.
A self-hosting operator who makes an accidental or unsafe configuration choice.
A host operator misusing stopped-server SSO repair or sole-Owner recovery authority.
A host-level attacker. Host compromise is not fully preventable in-process; encrypted storage, secret management, isolation and off-host logs/backups limit consequences.
Membership fetched server-side for each operation; every scoped entity has accountId; row-addressed generic writes make absent and foreign ids response-indistinguishable; row/reference validation fails closed when a project-bound allocation cannot resolve its project in the same account; cross-account tests
app.authz, app.members, tenant-store, route and shared mutation tests
Function/field privilege escalation
Central action matrix; protected-name projection/preservation; owner-only import; fresh session for privileged actions
access, privacy and route tests
Credential stuffing/password cracking
Positive global/API throttling; five-attempt MFA lock; 15–128 Unicode code points; HIBP range check; scrypt N=2^17,r=8,p=1; no default password
password/auth/rate-limit tests
Password-only account takeover
Opt-in required TOTP wall before tenant data; otherwise long passwords, HIBP by default, scrypt, throttling and bounded/revocable sessions; one-time MFA recovery codes
real auth integration and UI tests
Session theft/fixation
Secure HttpOnly SameSite __Host- cookies; new token on auth; fixed 12-hour and 30-minute idle limits; revocation/reset invalidation; session inventory
auth and member revocation tests
CSRF and cross-origin data use
Unsafe-method Origin/Sec-Fetch-Site rejection; exact configured or trusted-proxy-derived same origin; SameSite cookie; safe HTTP methods
CSRF/CORS and packaged-proxy tests
Injection/XSS/mass assignment
React text rendering; no untrusted HTML; parameterized SQLite; explicit table/column codecs; sanitisation and structural limits; CSP
identity-port, onboarding and provider integration tests
Operator recovery misuse
Stopped server and exclusive SQLite lock; unique sole-Owner eligibility; ordinary expiring single-use reset; rollback on partial failure; token-free audit record
Owner-recovery CLI and audit tests
Resource exhaustion
512 accepted-socket ceiling; per-IP application/CSP rate limit with constant-work health exempt; bounded scrypt, HIBP and import queues; 5,000-operation batch and 200,000-record import caps; request/queue/provider timeouts
Google and Microsoft use Better Auth's provider OAuth flow for state, PKCE and cookies. CapacityLens checks provider-specific claims and account admission before creating a local identity. GitHub remains experimental in mixed mode and cannot meet provider-required sign-in. Provider configuration still needs installation-specific interoperability, MFA-policy and logout/session-lifetime testing.
IdP disablement does not revoke already-issued local sessions. The accepted maximum is the remaining twelve-hour absolute lifetime or thirty minutes inactivity; local revocation is an incident-response requirement and back-channel logout must be reconsidered before hosted GA.
Identity masquerade is a session-scoped, account-confined read projection for Owners and Admins. A root unsafe-method guard and a separate Better Auth proxy guard reject changes while it is active; actor-dependent reads use the target member's role and redaction. The process-local registry deliberately disappears on restart, which ends access rather than restoring uncertain state. Start and end events are audited, with the start event carrying the session expiry that bounds a record if the process stops before an end event can be written.
SSO-only mode leaves local password credentials available if the operator restores password mode. Protecting the host/database and using the stopped-server recovery procedures carefully remain operator responsibilities.
The sole-Owner recovery command is deliberate host-operator authority. It cannot be contained from an attacker who already controls the application database and process environment.
Required TOTP is optional. Password-only deployments do not meet ASVS 5.0 Level 2 requirement V6.3.3; when enabled, TOTP meets L2 but remains phishable and insufficient for L3.
Existing legacy Better Auth scrypt hashes use the former weaker profile until the user changes or resets the password. They are verify-only; new material never uses that format.
The application has no IP/device-risk engine, anomalous-login user notification, global administrator “revoke everyone” control or HSM/full-memory encryption.
Public TLS, encrypted host volumes, secret-manager/HSM use, clock synchronization, log retention and off-host collection are deployment controls. Internal service TLS is application-packaged for Compose and optional for a same-host bare-metal hop; startup warnings cannot verify external controls.
An unlocked or compromised application origin can invoke its non-extractable offline key. Device encryption, patching and profile access control remain necessary.
A single-process SQLite service can still be denied service by sufficient network or tenant-valid load. Edge rate limiting, connection limits and resource monitoring remain operator controls.
CapacityLens is configured entirely through environment variables, read from .env by Docker Compose or set directly for a bare-metal run. .env.example in the repository is the complete, authoritative register with defaults — this page groups the variables that matter for a self-hosted install by what you're trying to do. Server variables (CAPACITYLENS_*, SMALLSASS_ACCOUNT_*) take effect on restart. Client variables (VITE_CAPACITYLENS_*) are baked into the web app at build time, so changing one needs a rebuild. Two prefixes are deliberate: sign-in and accounts are built as a separable platform component, so their settings carry the SMALLSASS_ACCOUNT_ prefix, while everything specific to the app itself uses CAPACITYLENS_.
For a bare-metal run, use Node 24 or newer and run pnpm --filter capacitylens-server start. The server binds to localhost by default. Set the host explicitly to expose it on a network.
Variable
What it does
PORT
Listen port. Default 8787; invalid values outside the integer range 1–65,535 refuse startup.
CAPACITYLENS_HOST
Listen host. Default 127.0.0.1; set 0.0.0.0 to expose the listener on the LAN or in a container.
CAPACITYLENS_ALLOW_RESET
Set 1 to expose POST /api/test/reset for development and tests with sign-in off. Production refuses this setting.
CAPACITYLENS_OPTIMISTIC_CONCURRENCY
Enabled by default. Set 0 only to allow stale writes to overwrite newer changes.
CAPACITYLENS_CREATE_ADMIN_ADMIN
Development-only first-owner helper, also available as --create-owner-admin-admin. Creates admin@admin.admin only when the password user table is empty. Production refuses this setting.
CAPACITYLENS_BOOTSTRAP_ADMIN_PASSWORD
Required password for that development-only owner helper. For production, use the account setup token instead.
off, password or sso. off creates no sign-in at all; production refuses to boot with it unset unless you explicitly opt in (see below).
SMALLSASS_ACCOUNT_DEPLOYMENT_PROFILE
An optional named policy: self-hosted-password, self-hosted-mixed, self-hosted-sso-only or hosted-sso-only. Enforced at startup.
SMALLSASS_ACCOUNT_SECRET
The session-signing secret. Required for password or sso mode. Generate with openssl rand -base64 48 — anything 32 characters or longer is fine; the install guide's command produces 48.
SMALLSASS_ACCOUNT_PUBLIC_URL
The exact browser-facing origin, for example https://capacity.example.com. Required for password or sso mode.
SMALLSASS_ACCOUNT_SETUP_TOKEN
The one-time secret the first owner enters on a fresh password-mode instance. For Google/Microsoft setup, use SMALLSASS_ACCOUNT_PROVIDER_BOOTSTRAP_EMAILS for the first identity or a pre-authorised invitation after that.
SMALLSASS_ACCOUNT_ALLOW_OPEN_SIGNUP
Re-opens self-service sign-up. Closed by default — CapacityLens is invite-only unless you set this. Leave it unset in production.
CAPACITYLENS_ALLOW_OPEN_IN_PRODUCTION
Deliberately allows the auth-off (off) posture under production. Off by default; without it, a production instance with no sign-in refuses to start.
Treat SMALLSASS_ACCOUNT_SETUP_TOKEN as a short-lived bootstrap secret. Give the first owner the value through a secure channel; never paste it into chat, tickets, screenshots, command output or logs. The owner copies the value from the server .env file or installer into the matching field. Do not add surrounding quote characters or whitespace in the browser: the submitted value must match the configured secret exactly.
After the first owner account and company have been created, remove SMALLSASS_ACCOUNT_SETUP_TOKEN from the server environment and restart the server. First-owner signup already closes as soon as the first identity exists, but removing the secret invalidates the handoff material instead of leaving it available to operators or future processes.
Use Set up company login for the registration steps. Google and Microsoft are company providers. Configure either or both; a partial credential pair refuses startup. password mode retains the password form, while sso mode permits only configured company providers. GitHub remains experimental in mixed mode and cannot provide company-only access.
Application ID and secret value from your Microsoft Entra app registration.
SMALLSASS_ACCOUNT_MICROSOFT_TENANT_ID
Required organisation tenant GUID. common, organizations, personal-account tenants and a missing value are refused.
SMALLSASS_ACCOUNT_PROVIDER_BOOTSTRAP_EMAILS
Comma-separated company email addresses allowed to create the first named-provider identity. Later new identities require an unused invitation addressed to them.
Optional credentials for the existing experimental GitHub sign-in in mixed mode.
SMALLSASS_ACCOUNT_SSO_MFA_ENFORCED
Operator attestation that the company provider requires multi-factor sign-in. Set it only after checking the upstream policy.
Register https://your-capacitylens-address/api/auth/callback/google for Google and https://your-capacitylens-address/api/auth/callback/microsoft for Microsoft, using your actual HTTPS origin. These paths must match exactly. Restart after changing server settings.
Microsoft first connections may need a one-time email verification. All five SMTP settings are required whenever Microsoft is configured, even if a particular identity arrives with adequate verified-email claims. An SMTP failure blocks completion without granting access and allows retry. Ordinary returning Microsoft sign-in does not require another verification email.
Variable
What it does
SMALLSASS_ACCOUNT_MAIL_HOST
Your SMTP service hostname.
SMALLSASS_ACCOUNT_MAIL_PORT
SMTP port. Port 465 uses implicit TLS; other ports require STARTTLS. Certificate verification remains enabled.
SMALLSASS_ACCOUNT_MAIL_USER
SMTP authentication username.
SMALLSASS_ACCOUNT_MAIL_PASSWORD
SMTP password or service credential. Keep it in the server's secret configuration.
SMALLSASS_ACCOUNT_MAIL_FROM
A valid sender email address authorised by the SMTP service; use the address without a display name.
The verification link expires after 15 minutes and must be confirmed in the browser that started sign-in. See the company-login guide for resend, expiry and account-connection recovery.
hosted-sso-only is reserved for hosted deployments. It requires mode=sso and complete Google and/or tenant-specific Microsoft configuration; it rejects passwords, GitHub, open signup and incomplete provider settings. Self-hosted installations that require company sign-in use self-hosted-sso-only. See Require company sign-in.
The retired generic OIDC settings and hosted-oidc-only profile are rejected at startup. Remove those settings and configure Google and/or Microsoft explicitly; CapacityLens does not fall back to password or sign-in-off mode.
Path to the SQLite file. Docker Compose pins this to /data/capacitylens.db inside a named volume; only change it for a bare-metal run.
CAPACITYLENS_BACKUP_DIR
Directory for scheduled snapshots. On by default in Docker (/backups). Set it explicitly empty (CAPACITYLENS_BACKUP_DIR=) to turn scheduled backups off.
CAPACITYLENS_BACKUP_INTERVAL_MIN
Minutes between snapshots. Whole minutes, default 60; startup clamps over-maximum values to 35,000 with a warning.
CAPACITYLENS_BACKUP_KEEP
How many snapshots to retain. Default 48. Invalid and lower values use the safe default; over-maximum values clamp to 10,000 with a startup warning, so leave disk capacity for that many restore points.
CAPACITYLENS_AUDIT_FILE
Path to the audit log. Default capacitylens-audit.jsonl next to CAPACITYLENS_DB; Docker Compose pins it to /data/capacitylens-audit.jsonl. Only read when audit logging is on.
CAPACITYLENS_AUDIT_MAX_MB
Audit log rotation size cap in MB. Default 64 — once the file reaches this size it's rotated to <file>.1 (replacing any previous .1), bounding disk use to roughly twice the cap.
See Backups and restore for what these snapshots protect against and how to use them. Back up the rotated <file>.1 audit file alongside the current one — a restore that only picks up the live file can miss recent audit history still sitting in the rotated generation.
For a bare-metal run, the database defaults to ./capacitylens.db; :memory: is also accepted. Scheduled backups stay off unless CAPACITYLENS_BACKUP_DIR is set. Positive fractional retention counts are rounded down.
Audit logging is on by default. Set CAPACITYLENS_AUDIT=off only for development; production refuses disabled audit. Each mutation record contains ts, userId, accountId, action, entity, id and changedFields. Changed fields are names, never their values. A memory-only database uses a working-directory-relative audit file.
CAPACITYLENS_AUDIT_MAX_MB accepts integers from 1 to 1,048,576; missing or invalid values use 64 MiB. Rotation happens before a record would cross the cap. A single record larger than the cap is rejected and remains queued in the audit outbox. The size setting is only read when audit logging is enabled.
Comma-separated browser origins to allow, only needed if the web app and API are on different origins. Defaults to local development origins. Wildcards are rejected because browser requests use cookie credentials.
CAPACITYLENS_HTTPS
Set 1 when the public origin is genuinely HTTPS, to enable a two-year HSTS header. Leave unset if your proxy already emits HSTS.
CAPACITYLENS_TRUST_PROXY_HEADERS
Trusts X-Forwarded-For/X-Forwarded-Proto from a non-loopback listener. Docker Compose sets this to 1 because its API only accepts connections from the packaged nginx. Loopback listeners (127.0.0.1, localhost, ::1) trust their same-host proxy automatically without this flag.
The HTTPS setting enables HSTS including subdomains. Leave it off for plain HTTP. The other baseline security headers are always enabled.
Variable
What it does
CAPACITYLENS_INTERNAL_TLS_CERT
PEM certificate path for the internal reverse-proxy/API connection.
CAPACITYLENS_INTERNAL_TLS_KEY
Matching PEM private-key path. Omit both paths for HTTP on a trusted same-host loopback connection. A partial or unreadable identity refuses startup. Compose creates an identity per installation.
CAPACITYLENS_INTERNAL_TLS_GENERATION
Optional SHA-256 marker for the exact loaded certificate.
Off by default: one company per instance. Set 1 to allow more than one.
CAPACITYLENS_BOOTSTRAP_TOKEN
A shared secret for creating an additional company through the API when the caller isn't already an owner or admin of one. Only matters when CAPACITYLENS_MULTI_ACCOUNT=1.
CAPACITYLENS_SEED_DEMO
Seeds a two-company sample dataset on a never-initialised database. Only makes sense paired with CAPACITYLENS_MULTI_ACCOUNT=1; use it for a throwaway or demo instance, not a real one.
A fresh database starts empty unless demo seeding is explicitly enabled. The company limit applies in every sign-in mode, including off. The bootstrap token is sent in x-capacitylens-bootstrap-token to POST /api/orgs; an empty or unset token disables that path. Without it, company creation requires first-run setup or an existing owner or admin.
Set 1 for structured per-request JSON logs. Recommended for a real deployment.
CAPACITYLENS_HEALTH_DEEP
Set 1 to make /api/health run a readiness query and report audit, backup and certificate status. Compose sets this by default.
CAPACITYLENS_RATE_LIMIT
Requests per minute per IP across rate-limited routes. Accepts integers 1–1,000,000. Production refuses missing, zero or invalid values. /api/health is exempt.
CAPACITYLENS_AUDIT_STDOUT
Set 1 to also write each audit record to stdout as JSON, for a container log collector. Compose defaults this on.
CAPACITYLENS_STORAGE_ENCRYPTED
Set 1 only after you have verified that the database, audit log and backup storage are encrypted at rest. This is an operator attestation; it does not encrypt storage itself.
CAPACITYLENS_SECURITY_LOG_FORWARDING
An attestation that you're forwarding audit and security events to a separate collector. Doesn't create the collector itself.
Without structured logging, the server prints its startup line and reports server errors to stderr. Deep health checks are off by default: /api/health returns { ok: true }. With deep checks enabled, the endpoint runs SELECT 1, reports audit state and pending records, and includes internal certificate expiry when configured. Failed readiness returns HTTP 503 with { ok: false }.
The API origin the built app talks to. Leave empty for the normal same-origin build (the app calls a relative /api, which nginx proxies). Only set this to point the app at a different origin.
VITE_CAPACITYLENS_DEMO
Set 1 to build the in-memory demo instead of the real app. Wins over VITE_CAPACITYLENS_API if both are set.
VITE_CAPACITYLENS_BUILD_SHA
Optional build identifier shown in Settings, typically the git commit.
VITE_CAPACITYLENS_FEEDBACK_MAILTO
Optional email address for the in-app feedback link. Leave empty to hide the link.
Any of these needs a rebuild to take effect. Use docker compose build web for the packaged production stack, or pnpm run build for a direct Node installation, then redeploy the rebuilt web files. Setting them only in a running process does nothing.
CapacityLens accepts only the SMALLSASS_ACCOUNT_* account variables documented above. If you're upgrading an installation that predates this namespace, follow the account-variable rename procedure before starting the new release. Startup refuses a configured removed name and identifies its replacement.
CapacityLens is configured entirely through environment variables, read from .env by Docker Compose or set directly for a bare-metal run. .env.example in the repository is the complete, authoritative register with defaults — this page groups the variables that matter for a self-hosted install by what you're trying to do. Server variables (CAPACITYLENS_*, SMALLSASS_ACCOUNT_*) take effect on restart. Client variables (VITE_CAPACITYLENS_*) are baked into the web app at build time, so changing one needs a rebuild. Two prefixes are deliberate: sign-in and accounts are built as a separable platform component, so their settings carry the SMALLSASS_ACCOUNT_ prefix, while everything specific to the app itself uses CAPACITYLENS_.
For a bare-metal run, use the default Node 24 runtime and run pnpm --filter capacitylens-server start. Newer versions allowed by the package engine range are not automatically validated for deployment. The server binds to localhost by default. Set the host explicitly to expose it on a network.
Variable
What it does
PORT
Listen port. Default 8787; invalid values outside the integer range 1–65,535 refuse startup.
CAPACITYLENS_HOST
Listen host. Default 127.0.0.1; set 0.0.0.0 to expose the listener on the LAN or in a container.
CAPACITYLENS_ALLOW_RESET
Set 1 to expose POST /api/test/reset for development and tests with sign-in off. Production refuses this setting.
CAPACITYLENS_OPTIMISTIC_CONCURRENCY
Enabled by default. Set 0 only to allow stale writes to overwrite newer changes.
CAPACITYLENS_CREATE_ADMIN_ADMIN
Development-only first-owner helper, also available as --create-owner-admin-admin. Creates admin@admin.admin only when the password user table is empty. Production refuses this setting.
CAPACITYLENS_BOOTSTRAP_ADMIN_PASSWORD
Required password for that development-only owner helper. For production, use the account setup token instead.
off, password or sso. off creates no sign-in at all; production refuses to boot with it unset unless you explicitly opt in (see below).
SMALLSASS_ACCOUNT_DEPLOYMENT_PROFILE
An optional named policy: self-hosted-password, self-hosted-mixed, self-hosted-sso-only or hosted-sso-only. Enforced at startup.
SMALLSASS_ACCOUNT_SECRET
The session-signing secret. Required for password or sso mode. Generate with openssl rand -base64 48 — anything 32 characters or longer is fine; the install guide's command produces 48.
SMALLSASS_ACCOUNT_PUBLIC_URL
The exact browser-facing origin, for example https://capacity.example.com. Required for password or sso mode.
SMALLSASS_ACCOUNT_SETUP_TOKEN
The one-time secret the first owner enters on a fresh password-mode instance. For Google/Microsoft setup, use SMALLSASS_ACCOUNT_PROVIDER_BOOTSTRAP_EMAILS for the first identity or a pre-authorised invitation after that.
SMALLSASS_ACCOUNT_ALLOW_OPEN_SIGNUP
Re-opens self-service sign-up. Closed by default — CapacityLens is invite-only unless you set this. Leave it unset in production.
CAPACITYLENS_ALLOW_OPEN_IN_PRODUCTION
Deliberately allows the auth-off (off) posture under production. Off by default; without it, a production instance with no sign-in refuses to start.
Treat SMALLSASS_ACCOUNT_SETUP_TOKEN as a short-lived bootstrap secret. Give the first owner the value through a secure channel; never paste it into chat, tickets, screenshots, command output or logs. The owner copies the value from the server .env file or installer into the matching field. Do not add surrounding quote characters or whitespace in the browser: the submitted value must match the configured secret exactly.
After the first owner account and company have been created, remove SMALLSASS_ACCOUNT_SETUP_TOKEN from the server environment and restart the server. First-owner signup already closes as soon as the first identity exists, but removing the secret invalidates the handoff material instead of leaving it available to operators or future processes.
Use Set up company login for the registration steps. Google and Microsoft are company providers. Configure either or both; a partial credential pair refuses startup. password mode retains the password form, while sso mode permits only configured company providers. GitHub remains experimental in mixed mode and cannot provide company-only access.
Application ID and secret value from your Microsoft Entra app registration.
SMALLSASS_ACCOUNT_MICROSOFT_TENANT_ID
Required organisation tenant GUID. common, organizations, personal-account tenants and a missing value are refused.
SMALLSASS_ACCOUNT_PROVIDER_BOOTSTRAP_EMAILS
Comma-separated company email addresses allowed to create the first named-provider identity. Later new identities require an unused invitation addressed to them.
Optional credentials for the existing experimental GitHub sign-in in mixed mode.
SMALLSASS_ACCOUNT_SSO_MFA_ENFORCED
Operator attestation that the company provider requires multi-factor sign-in. Set it only after checking the upstream policy.
Register https://your-capacitylens-address/api/auth/callback/google for Google and https://your-capacitylens-address/api/auth/callback/microsoft for Microsoft, using your actual HTTPS origin. These paths must match exactly. Restart after changing server settings.
Microsoft first connections may need a one-time email verification. All five SMTP settings are required whenever Microsoft is configured, even if a particular identity arrives with adequate verified-email claims. An SMTP failure blocks completion without granting access and allows retry. Ordinary returning Microsoft sign-in does not require another verification email.
Variable
What it does
SMALLSASS_ACCOUNT_MAIL_HOST
Your SMTP service hostname.
SMALLSASS_ACCOUNT_MAIL_PORT
SMTP port. Port 465 uses implicit TLS; other ports require STARTTLS. Certificate verification remains enabled.
SMALLSASS_ACCOUNT_MAIL_USER
SMTP authentication username.
SMALLSASS_ACCOUNT_MAIL_PASSWORD
SMTP password or service credential. Keep it in the server's secret configuration.
SMALLSASS_ACCOUNT_MAIL_FROM
A valid sender email address authorised by the SMTP service; use the address without a display name.
The verification link expires after 15 minutes and must be confirmed in the browser that started sign-in. See the company-login guide for resend, expiry and account-connection recovery.
hosted-sso-only is reserved for hosted deployments. It requires mode=sso and complete Google and/or tenant-specific Microsoft configuration; it rejects passwords, GitHub, open signup and incomplete provider settings. Self-hosted installations that require company sign-in use self-hosted-sso-only. See Require company sign-in.
The retired generic OIDC settings and hosted-oidc-only profile are rejected at startup. Remove those settings and configure Google and/or Microsoft explicitly; CapacityLens does not fall back to password or sign-in-off mode.
Path to the SQLite file. Docker Compose pins this to /data/capacitylens.db inside a named volume; only change it for a bare-metal run.
CAPACITYLENS_BACKUP_DIR
Directory for scheduled snapshots. On by default in Docker (/backups). Set it explicitly empty (CAPACITYLENS_BACKUP_DIR=) to turn scheduled backups off.
CAPACITYLENS_BACKUP_INTERVAL_MIN
Minutes between snapshots. Whole minutes, default 60; startup clamps over-maximum values to 35,000 with a warning.
CAPACITYLENS_BACKUP_KEEP
How many snapshots to retain. Default 48. Invalid and lower values use the safe default; over-maximum values clamp to 10,000 with a startup warning, so leave disk capacity for that many restore points.
CAPACITYLENS_AUDIT_FILE
Path to the audit log. Default capacitylens-audit.jsonl next to CAPACITYLENS_DB; Docker Compose pins it to /data/capacitylens-audit.jsonl. Only read when audit logging is on.
CAPACITYLENS_AUDIT_MAX_MB
Audit log rotation size cap in MB. Default 64 — once the file reaches this size it's rotated to <file>.1 (replacing any previous .1), bounding disk use to roughly twice the cap.
See Backups and restore for what these snapshots protect against and how to use them. Back up the rotated <file>.1 audit file alongside the current one — a restore that only picks up the live file can miss recent audit history still sitting in the rotated generation.
For a bare-metal run, the database defaults to ./capacitylens.db; :memory: is also accepted. Scheduled backups stay off unless CAPACITYLENS_BACKUP_DIR is set. Positive fractional retention counts are rounded down.
Audit logging is on by default. Set CAPACITYLENS_AUDIT=off only for development; production refuses disabled audit. Each mutation record contains ts, userId, accountId, action, entity, id and changedFields. Changed fields are names, never their values. A memory-only database uses a working-directory-relative audit file.
CAPACITYLENS_AUDIT_MAX_MB accepts integers from 1 to 1,048,576; missing or invalid values use 64 MiB. Rotation happens before a record would cross the cap. A single record larger than the cap is rejected and remains queued in the audit outbox. The size setting is only read when audit logging is enabled.
Comma-separated browser origins to allow, only needed if the web app and API are on different origins. Defaults to local development origins. Wildcards are rejected because browser requests use cookie credentials.
CAPACITYLENS_HTTPS
Set 1 when the public origin is genuinely HTTPS, to enable a two-year HSTS header. Leave unset if your proxy already emits HSTS.
CAPACITYLENS_TRUST_PROXY_HEADERS
Trusts X-Forwarded-For/X-Forwarded-Proto from a non-loopback listener. Docker Compose sets this to 1 because its API only accepts connections from the packaged nginx. Loopback listeners (127.0.0.1, localhost, ::1) trust their same-host proxy automatically without this flag.
The HTTPS setting enables HSTS including subdomains. Leave it off for plain HTTP. The other baseline security headers are always enabled.
Variable
What it does
CAPACITYLENS_INTERNAL_TLS_CERT
PEM certificate path for the internal reverse-proxy/API connection.
CAPACITYLENS_INTERNAL_TLS_KEY
Matching PEM private-key path. Omit both paths for HTTP on a trusted same-host loopback connection. A partial or unreadable identity refuses startup. Compose creates an identity per installation.
CAPACITYLENS_INTERNAL_TLS_GENERATION
Optional SHA-256 marker for the exact loaded certificate.
Off by default: one company per instance. Set 1 to allow more than one.
CAPACITYLENS_BOOTSTRAP_TOKEN
A shared secret for creating an additional company through the API when the caller isn't already an owner or admin of one. Only matters when CAPACITYLENS_MULTI_ACCOUNT=1.
CAPACITYLENS_SEED_DEMO
Seeds a two-company sample dataset on a never-initialised database. Only makes sense paired with CAPACITYLENS_MULTI_ACCOUNT=1; use it for a throwaway or demo instance, not a real one.
A fresh database starts empty unless demo seeding is explicitly enabled. The company limit applies in every sign-in mode, including off. The bootstrap token is sent in x-capacitylens-bootstrap-token to POST /api/orgs; an empty or unset token disables that path. Without it, company creation requires first-run setup or an existing owner or admin.
Set 1 for structured per-request JSON logs. Recommended for a real deployment.
CAPACITYLENS_HEALTH_DEEP
Set 1 to make /api/health run a readiness query and report audit, backup and certificate status. Compose sets this by default.
CAPACITYLENS_RATE_LIMIT
Requests per minute per IP across rate-limited routes. Accepts integers 1–1,000,000. Production refuses missing, zero or invalid values. /api/health is exempt.
CAPACITYLENS_AUDIT_STDOUT
Set 1 to also write each audit record to stdout as JSON, for a container log collector. Compose defaults this on.
CAPACITYLENS_STORAGE_ENCRYPTED
Set 1 only after you have verified that the database, audit log and backup storage are encrypted at rest. This is an operator attestation; it does not encrypt storage itself.
CAPACITYLENS_SECURITY_LOG_FORWARDING
An attestation that you're forwarding audit and security events to a separate collector. Doesn't create the collector itself.
Without structured logging, the server prints its startup line and reports server errors to stderr. Deep health checks are off by default: /api/health returns { ok: true }. With deep checks enabled, the endpoint runs SELECT 1, reports audit state and pending records, and includes internal certificate expiry when configured. Failed readiness returns HTTP 503 with { ok: false }.
The API origin the built app talks to. Leave empty for the normal same-origin build (the app calls a relative /api, which nginx proxies). Only set this to point the app at a different origin.
VITE_CAPACITYLENS_DEMO
Set 1 to build the in-memory demo instead of the real app. Wins over VITE_CAPACITYLENS_API if both are set.
VITE_CAPACITYLENS_BUILD_SHA
Optional build identifier shown in Settings, typically the git commit.
VITE_CAPACITYLENS_FEEDBACK_MAILTO
Optional email address for the in-app feedback link. Leave empty to hide the link.
Any of these needs a rebuild to take effect. Use docker compose build web for the packaged production stack, or pnpm run build for a direct Node installation, then redeploy the rebuilt web files. Setting them only in a running process does nothing.
CapacityLens accepts only the SMALLSASS_ACCOUNT_* account variables documented above. If you're upgrading an installation that predates this namespace, follow the account-variable rename procedure before starting the new release. Startup refuses a configured removed name and identifies its replacement.