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 @@ -
Skip to content

Configure the service ​

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_.

Listener and development settings ​

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.

VariableWhat it does
PORTListen port. Default 8787; invalid values outside the integer range 1–65,535 refuse startup.
CAPACITYLENS_HOSTListen host. Default 127.0.0.1; set 0.0.0.0 to expose the listener on the LAN or in a container.
CAPACITYLENS_ALLOW_RESETSet 1 to expose POST /api/test/reset for development and tests with sign-in off. Production refuses this setting.
CAPACITYLENS_OPTIMISTIC_CONCURRENCYEnabled by default. Set 0 only to allow stale writes to overwrite newer changes.
CAPACITYLENS_CREATE_ADMIN_ADMINDevelopment-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_PASSWORDRequired password for that development-only owner helper. For production, use the account setup token instead.

Sign-in mode ​

VariableWhat it does
SMALLSASS_ACCOUNT_MODEoff, 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_PROFILEAn optional named policy: self-hosted-password, self-hosted-mixed, self-hosted-sso-only or hosted-sso-only. Enforced at startup.
SMALLSASS_ACCOUNT_SECRETThe 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_URLThe exact browser-facing origin, for example https://capacity.example.com. Required for password or sso mode.
SMALLSASS_ACCOUNT_SETUP_TOKENThe 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_SIGNUPRe-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_PRODUCTIONDeliberately 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.

Passwords and multi-factor sign-in ​

VariableWhat it does
SMALLSASS_ACCOUNT_REQUIRE_MFASet 1 to require every password-mode teammate to enroll multi-factor sign-in before they can see company data.
SMALLSASS_ACCOUNT_PASSWORD_BREACH_CHECKOn by default: new passwords are checked against known breaches. Set off only for an isolated deployment that accepts the production warning.

Company login ​

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.

VariableWhat it does
SMALLSASS_ACCOUNT_GOOGLE_CLIENT_ID / SMALLSASS_ACCOUNT_GOOGLE_CLIENT_SECRETCredentials for a Google web application. Use an Internal audience restricted to your Workspace organisation.
SMALLSASS_ACCOUNT_MICROSOFT_CLIENT_ID / SMALLSASS_ACCOUNT_MICROSOFT_CLIENT_SECRETApplication ID and secret value from your Microsoft Entra app registration.
SMALLSASS_ACCOUNT_MICROSOFT_TENANT_IDRequired organisation tenant GUID. common, organizations, personal-account tenants and a missing value are refused.
SMALLSASS_ACCOUNT_PROVIDER_BOOTSTRAP_EMAILSComma-separated company email addresses allowed to create the first named-provider identity. Later new identities require an unused invitation addressed to them.
SMALLSASS_ACCOUNT_GITHUB_CLIENT_ID / SMALLSASS_ACCOUNT_GITHUB_CLIENT_SECRETOptional credentials for the existing experimental GitHub sign-in in mixed mode.
SMALLSASS_ACCOUNT_SSO_MFA_ENFORCEDOperator 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 verification email ​

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.

VariableWhat it does
SMALLSASS_ACCOUNT_MAIL_HOSTYour SMTP service hostname.
SMALLSASS_ACCOUNT_MAIL_PORTSMTP port. Port 465 uses implicit TLS; other ports require STARTTLS. Certificate verification remains enabled.
SMALLSASS_ACCOUNT_MAIL_USERSMTP authentication username.
SMALLSASS_ACCOUNT_MAIL_PASSWORDSMTP password or service credential. Keep it in the server's secret configuration.
SMALLSASS_ACCOUNT_MAIL_FROMA 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.

The database and backups ​

VariableWhat it does
CAPACITYLENS_DBPath 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_DIRDirectory for scheduled snapshots. On by default in Docker (/backups). Set it explicitly empty (CAPACITYLENS_BACKUP_DIR=) to turn scheduled backups off.
CAPACITYLENS_BACKUP_INTERVAL_MINMinutes between snapshots. Whole minutes, default 60; startup clamps over-maximum values to 35,000 with a warning.
CAPACITYLENS_BACKUP_KEEPHow 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_FILEPath 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_MBAudit 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.

Origin, CORS and proxy trust ​

VariableWhat it does
CAPACITYLENS_CORS_ORIGINComma-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_HTTPSSet 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_HEADERSTrusts 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.

VariableWhat it does
CAPACITYLENS_INTERNAL_TLS_CERTPEM certificate path for the internal reverse-proxy/API connection.
CAPACITYLENS_INTERNAL_TLS_KEYMatching 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_GENERATIONOptional SHA-256 marker for the exact loaded certificate.

See TLS and networking for the full picture.

Companies on this instance ​

VariableWhat it does
CAPACITYLENS_MULTI_ACCOUNTOff by default: one company per instance. Set 1 to allow more than one.
CAPACITYLENS_BOOTSTRAP_TOKENA 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_DEMOSeeds 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.

Health, logging and rate limiting ​

VariableWhat it does
CAPACITYLENS_LOGSet 1 for structured per-request JSON logs. Recommended for a real deployment.
CAPACITYLENS_HEALTH_DEEPSet 1 to make /api/health run a readiness query and report audit, backup and certificate status. Compose sets this by default.
CAPACITYLENS_RATE_LIMITRequests 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_STDOUTSet 1 to also write each audit record to stdout as JSON, for a container log collector. Compose defaults this on.
CAPACITYLENS_STORAGE_ENCRYPTEDSet 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_FORWARDINGAn 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 }.

See Monitoring and health checks for what to do with these.

What the web app is built with ​

VariableWhat it does
VITE_CAPACITYLENS_APIThe 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_DEMOSet 1 to build the in-memory demo instead of the real app. Wins over VITE_CAPACITYLENS_API if both are set.
VITE_CAPACITYLENS_BUILD_SHAOptional build identifier shown in Settings, typically the git commit.
VITE_CAPACITYLENS_FEEDBACK_MAILTOOptional 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.

Removed account variable names ​

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.

What's next ​

Secure the connection, then verify and hand over the installation.

CapacityLens is open source under AGPL-3.0.

+
Skip to content

Configure the service ​

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_.

Listener and development settings ​

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.

VariableWhat it does
PORTListen port. Default 8787; invalid values outside the integer range 1–65,535 refuse startup.
CAPACITYLENS_HOSTListen host. Default 127.0.0.1; set 0.0.0.0 to expose the listener on the LAN or in a container.
CAPACITYLENS_ALLOW_RESETSet 1 to expose POST /api/test/reset for development and tests with sign-in off. Production refuses this setting.
CAPACITYLENS_OPTIMISTIC_CONCURRENCYEnabled by default. Set 0 only to allow stale writes to overwrite newer changes.
CAPACITYLENS_CREATE_ADMIN_ADMINDevelopment-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_PASSWORDRequired password for that development-only owner helper. For production, use the account setup token instead.

Sign-in mode ​

VariableWhat it does
SMALLSASS_ACCOUNT_MODEoff, 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_PROFILEAn optional named policy: self-hosted-password, self-hosted-mixed, self-hosted-sso-only or hosted-sso-only. Enforced at startup.
SMALLSASS_ACCOUNT_SECRETThe 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_URLThe exact browser-facing origin, for example https://capacity.example.com. Required for password or sso mode.
SMALLSASS_ACCOUNT_SETUP_TOKENThe 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_SIGNUPRe-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_PRODUCTIONDeliberately 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.

Passwords and multi-factor sign-in ​

VariableWhat it does
SMALLSASS_ACCOUNT_REQUIRE_MFASet 1 to require every password-mode teammate to enroll multi-factor sign-in before they can see company data.
SMALLSASS_ACCOUNT_PASSWORD_BREACH_CHECKOn by default: new passwords are checked against known breaches. Set off only for an isolated deployment that accepts the production warning.

Company login ​

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.

VariableWhat it does
SMALLSASS_ACCOUNT_GOOGLE_CLIENT_ID / SMALLSASS_ACCOUNT_GOOGLE_CLIENT_SECRETCredentials for a Google web application. Use an Internal audience restricted to your Workspace organisation.
SMALLSASS_ACCOUNT_MICROSOFT_CLIENT_ID / SMALLSASS_ACCOUNT_MICROSOFT_CLIENT_SECRETApplication ID and secret value from your Microsoft Entra app registration.
SMALLSASS_ACCOUNT_MICROSOFT_TENANT_IDRequired organisation tenant GUID. common, organizations, personal-account tenants and a missing value are refused.
SMALLSASS_ACCOUNT_PROVIDER_BOOTSTRAP_EMAILSComma-separated company email addresses allowed to create the first named-provider identity. Later new identities require an unused invitation addressed to them.
SMALLSASS_ACCOUNT_GITHUB_CLIENT_ID / SMALLSASS_ACCOUNT_GITHUB_CLIENT_SECRETOptional credentials for the existing experimental GitHub sign-in in mixed mode.
SMALLSASS_ACCOUNT_SSO_MFA_ENFORCEDOperator 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 verification email ​

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.

VariableWhat it does
SMALLSASS_ACCOUNT_MAIL_HOSTYour SMTP service hostname.
SMALLSASS_ACCOUNT_MAIL_PORTSMTP port. Port 465 uses implicit TLS; other ports require STARTTLS. Certificate verification remains enabled.
SMALLSASS_ACCOUNT_MAIL_USERSMTP authentication username.
SMALLSASS_ACCOUNT_MAIL_PASSWORDSMTP password or service credential. Keep it in the server's secret configuration.
SMALLSASS_ACCOUNT_MAIL_FROMA 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.

The database and backups ​

VariableWhat it does
CAPACITYLENS_DBPath 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_DIRDirectory for scheduled snapshots. On by default in Docker (/backups). Set it explicitly empty (CAPACITYLENS_BACKUP_DIR=) to turn scheduled backups off.
CAPACITYLENS_BACKUP_INTERVAL_MINMinutes between snapshots. Whole minutes, default 60; startup clamps over-maximum values to 35,000 with a warning.
CAPACITYLENS_BACKUP_KEEPHow 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_FILEPath 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_MBAudit 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.

Origin, CORS and proxy trust ​

VariableWhat it does
CAPACITYLENS_CORS_ORIGINComma-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_HTTPSSet 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_HEADERSTrusts 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.

VariableWhat it does
CAPACITYLENS_INTERNAL_TLS_CERTPEM certificate path for the internal reverse-proxy/API connection.
CAPACITYLENS_INTERNAL_TLS_KEYMatching 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_GENERATIONOptional SHA-256 marker for the exact loaded certificate.

See TLS and networking for the full picture.

Companies on this instance ​

VariableWhat it does
CAPACITYLENS_MULTI_ACCOUNTOff by default: one company per instance. Set 1 to allow more than one.
CAPACITYLENS_BOOTSTRAP_TOKENA 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_DEMOSeeds 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.

Health, logging and rate limiting ​

VariableWhat it does
CAPACITYLENS_LOGSet 1 for structured per-request JSON logs. Recommended for a real deployment.
CAPACITYLENS_HEALTH_DEEPSet 1 to make /api/health run a readiness query and report audit, backup and certificate status. Compose sets this by default.
CAPACITYLENS_RATE_LIMITRequests 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_STDOUTSet 1 to also write each audit record to stdout as JSON, for a container log collector. Compose defaults this on.
CAPACITYLENS_STORAGE_ENCRYPTEDSet 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_FORWARDINGAn 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 }.

See Monitoring and health checks for what to do with these.

What the web app is built with ​

VariableWhat it does
VITE_CAPACITYLENS_APIThe 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_DEMOSet 1 to build the in-memory demo instead of the real app. Wins over VITE_CAPACITYLENS_API if both are set.
VITE_CAPACITYLENS_BUILD_SHAOptional build identifier shown in Settings, typically the git commit.
VITE_CAPACITYLENS_FEEDBACK_MAILTOOptional 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.

Removed account variable names ​

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.

What's next ​

Secure the connection, then verify and hand over the installation.

CapacityLens is open source under AGPL-3.0.

diff --git a/docs/open-source.html b/docs/open-source.html index afa85622b..b342dc37e 100644 --- a/docs/open-source.html +++ b/docs/open-source.html @@ -18,7 +18,7 @@ -
Skip to content

Open source and contributing ​

CapacityLens is open source under the GNU Affero General Public License v3.0. You can use, inspect, and change the software under that licence.

Read the source code and the complete licence.

Get help ​

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.

Report a problem or suggest an improvement ​

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.

Contribute a change ​

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.

Security reports ​

Do not publish a vulnerability or credential in a public issue. Follow the private reporting instructions in the repository's security policy.

Frequently asked questions ​

Do I need to be a developer to contribute? ​

No. Clear bug reports, documentation corrections, and descriptions of confusing tasks are useful contributions.

Can I run a modified version for my company? ​

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.

Where should I ask a usage question? ​

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.

What's next ​

Return to the documentation home or read the development guide.

CapacityLens is open source under AGPL-3.0.

+
Skip to content

Open source and contributing ​

CapacityLens is open source under the GNU Affero General Public License v3.0. You can use, inspect, and change the software under that licence.

Read the source code and the complete licence.

Get help ​

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.

Report a problem or suggest an improvement ​

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.

Contribute a change ​

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.

Security reports ​

Do not publish a vulnerability or credential in a public issue. Follow the private reporting instructions in the repository's security policy.

Frequently asked questions ​

Do I need to be a developer to contribute? ​

No. Clear bug reports, documentation corrections, and descriptions of confusing tasks are useful contributions.

Can I run a modified version for my company? ​

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.

Where should I ask a usage question? ​

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.

What's next ​

Return to the documentation home or read the development guide.

CapacityLens is open source under AGPL-3.0.

diff --git a/docs/reference/conventions.html b/docs/reference/conventions.html index d01c881ad..0d090ffa4 100644 --- a/docs/reference/conventions.html +++ b/docs/reference/conventions.html @@ -18,8 +18,8 @@ -
Skip to content

Name functions, variables and results ​

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.

The verb is the contract ​

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.

VerbPromiseExample in the tree
is, has, canReturns a boolean. No side effects.canArchive(entity) in shared/src/domain/lifecycle/transitions.ts
assertThrows when the condition fails. On success returns the value it established, or nothing.assertAccountAuthority in server/src/accounts/adminPort/authority.ts returns the Role
ensureMakes a state true if it is not already, returns nothing. Safe to call twice.ensureControlTables(db) in server/src/controlTables/retentionV24.ts
getLooks 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
listReturns an array of matches, empty when there are none.listMembersForAccount(db, accountId) in the same file
readReads from storage, the network or the environment.readApiError(res) in src/lib/readApiError.ts
parseTurns 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
validateChecks 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
normalizeReturns a repaired copy of the same type.normalizeAccountEmail(value) in shared/src/account/validation.ts
resolvePicks one concrete value from preferences or candidates.resolveBarColor(allocation, maps) in shared/src/lib/color.ts
buildDerives a new structure from its inputs. Pure.buildColumnGeometry in src/components/scheduler/columnGeometry.ts
applyReturns the input with a change applied. Pure unless the name says otherwise.applyOps(base, ops) in src/data/syncOps.ts
createConstructs something with identity or capabilities: an entity, a command set, a closure.createAllocationCommands(input) in src/components/scheduler/allocationSubmit.ts
makeTest fixtures only.makeResource(overrides) in src/test/fixtures.ts
useA 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.

Variables ​

  • 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.

Parameters ​

  • 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.

Results ​

  • 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.

What lint enforces ​

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.

CapacityLens is open source under AGPL-3.0.

+
Skip to content

Name functions, variables and results ​

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.

The verb is the contract ​

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.

VerbPromiseExample in the tree
is, has, canReturns a boolean. No side effects.canArchive(entity) in shared/src/domain/lifecycle/transitions.ts
assertThrows when the condition fails. On success returns the value it established, or nothing.assertAccountAuthority in server/src/accounts/adminPort/authority.ts returns the Role
ensureMakes a state true if it is not already, returns nothing. Safe to call twice.ensureControlTables(db) in server/src/controlTables/retentionV24.ts
getLooks 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
listReturns an array of matches, empty when there are none.listMembersForAccount(db, accountId) in the same file
readReads from storage, the network or the environment.readApiError(res) in src/lib/readApiError.ts
parseTurns 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
validateChecks 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
normalizeReturns a repaired copy of the same type.normalizeAccountEmail(value) in shared/src/account/validation.ts
resolvePicks one concrete value from preferences or candidates.resolveBarColor(allocation, maps) in shared/src/lib/color.ts
buildDerives a new structure from its inputs. Pure.buildColumnGeometry in src/components/scheduler/columnGeometry.ts
applyReturns the input with a change applied. Pure unless the name says otherwise.applyOps(base, ops) in src/data/syncOps.ts
createConstructs something with identity or capabilities: an entity, a command set, a closure.createAllocationCommands(input) in src/components/scheduler/allocationSubmit.ts
makeTest fixtures only.makeResource(overrides) in src/test/fixtures.ts
useA 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.

Variables ​

  • 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.

Parameters ​

  • 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.

Results ​

  • 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.

What lint enforces ​

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.

CapacityLens is open source under AGPL-3.0.

diff --git a/docs/reference/development.html b/docs/reference/development.html index fa1f412e3..5aa19f68a 100644 --- a/docs/reference/development.html +++ b/docs/reference/development.html @@ -18,9 +18,9 @@ -
Skip to content

Development guide ​

This page is for contributors, not users

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.

Prerequisites ​

  • Node 24, pinned in .nvmrc.
  • pnpm, through Corepack — the version is pinned in package.json's packageManager field.
  • Docker, only if you plan to run the Docker Compose smoke tests.

Set up the repository ​

bash
nvm use
+    
Skip to content

Development guide ​

This page is for contributors, not users

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.

Prerequisites ​

  • Node 24, pinned in .nvmrc.
  • pnpm, through Corepack — the version is pinned in package.json's packageManager field.
  • Docker, only if you plan to run the Docker Compose smoke tests.

Set up the repository ​

bash
nvm use
 corepack enable
-pnpm install

Run modes ​

bash
pnpm run dev        # SQLite API :8787 + web :5173, seeded development data
+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 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:

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
pnpm run dev        # SQLite API :8787 + web :5173, seeded development data
 pnpm run dev:demo   # web :5173, editable in-memory data, resets on reload
 pnpm 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.

Port lanes ​

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 which
 CAPACITYLENS_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 access lab ​

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.

  1. Start the complete lab:

    bash
    pnpm run dev:access
  2. 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:

    PersonaEmailRole
    Lucius Foxowner@capacitylens.devOwner
    Alfred Pennyworthalex.admin@capacitylens.devAdmin
    Barbara Gordonerin.editor@capacitylens.devEditor
    James Gordonvic.viewer@capacitylens.devViewer
  3. 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.

Server tests that pass but do not exit ​

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:

bash
pnpm --filter capacitylens-server test:unit-shard -- 3/4 --list
 pnpm --filter capacitylens-server test:unit-shard -- 3/4
 pnpm --filter capacitylens-server exec vitest run src/example.test.ts --pool=forks --no-file-parallelism

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.

When CI runs ​

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:

bash
gh pr merge <number> --merge --delete-branch

CI jobs ​

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.

Database migrations ​

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:

  1. Update shared types and full fixtures where the portable shape changed.
  2. Update TABLES and fresh-database DDL.
  3. 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.
  4. 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.
  5. Update import sanitisation independently of the physical migration.
  6. 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.
  7. Assert data preservation, fresh/migrated schema equivalence, idempotent reopen, transaction rollback/retry, quick_check, foreign_key_check, future-version refusal and auth convergence.
  8. 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.

Persistence diagnostics ​

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.

Test data and generated files ​

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/.

Ports ​

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.

CapacityLens is open source under AGPL-3.0.

+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:

bash
gh pr merge <number> --merge --delete-branch

CI jobs ​

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.

Database migrations ​

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:

  1. Update shared types and full fixtures where the portable shape changed.
  2. Update TABLES and fresh-database DDL.
  3. 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.
  4. 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.
  5. Update import sanitisation independently of the physical migration.
  6. 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.
  7. Assert data preservation, fresh/migrated schema equivalence, idempotent reopen, transaction rollback/retry, quick_check, foreign_key_check, future-version refusal and auth convergence.
  8. 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.

Persistence diagnostics ​

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.

Test data and generated files ​

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/.

Ports ​

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.

CapacityLens is open source under AGPL-3.0.

diff --git a/docs/reference/glossary.html b/docs/reference/glossary.html index 0fc00ed90..dbb636d2e 100644 --- a/docs/reference/glossary.html +++ b/docs/reference/glossary.html @@ -18,7 +18,7 @@ -
Skip to content

Glossary ​

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.

TermMeaning
accountIn 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.
activityAn 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.
adminAdmin 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.
allocationAn 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 projectsAn 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-glassBreak-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.
claimsClaims 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.
clientA client groups the projects your company does for one customer. Internal work does not need a client. See Projects and allocations.
client secretA 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 loginCompany login means signing in through Google Workspace or Microsoft Entra ID instead of a CapacityLens-specific password. See How sign-in works.
companyA 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 modeDemo 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.
disciplineA 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 partyAn 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 providerAn 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.
inviteAn 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.
memberA 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 / TOTPMFA (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 modeMixed 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.
ownerThe 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 transferThe 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 modePassword 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.
personA 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.
PKCEPKCE 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.
placeholderA 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.
projectA 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 URIA 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.
roleA 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.
scheduleThe 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.
sessionA 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-inSocial 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 databaseSQLite 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 offTime 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.
userA 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.
utilisationUtilisation 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.

CapacityLens is open source under AGPL-3.0.

+
Skip to content

Glossary ​

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.

TermMeaning
accountIn 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.
activityAn 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.
adminAdmin 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.
allocationAn 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 projectsAn 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-glassBreak-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.
claimsClaims 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.
clientA client groups the projects your company does for one customer. Internal work does not need a client. See Projects and allocations.
client secretA 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 loginCompany login means signing in through Google Workspace or Microsoft Entra ID instead of a CapacityLens-specific password. See How sign-in works.
companyA 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 modeDemo 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.
disciplineA 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 partyAn 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 providerAn 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.
inviteAn 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.
memberA 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 / TOTPMFA (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 modeMixed 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.
ownerThe 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 transferThe 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 modePassword 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.
personA 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.
PKCEPKCE 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.
placeholderA 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.
projectA 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 URIA 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.
roleA 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.
scheduleThe 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.
sessionA 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-inSocial 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 databaseSQLite 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 offTime 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.
userA 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.
utilisationUtilisation 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.

CapacityLens is open source under AGPL-3.0.

diff --git a/docs/reference/node26-discovery.html b/docs/reference/node26-discovery.html new file mode 100644 index 000000000..5df30b946 --- /dev/null +++ b/docs/reference/node26-discovery.html @@ -0,0 +1,25 @@ + + + + + + Review the Node 26 discovery | CapacityLens + + + + + + + + + + + + + + +
Skip to content

Review the Node 26 discovery ​

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.

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:

MajorProposed minimumReason and acceptance condition
2424.19.0Verified baseline. The investigation did not establish whether an earlier version is sufficient.
2626.9.0, conditionalThe 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.

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:

FindingFinal interpretation
Eight application failuresTest storage objects and prototypes disagreed under Node 26/jsdom, so spies missed quota/error injection.
Nine backup-related server failuresNode SQLite completed native work but could delay JavaScript completion until another callback ran.
One TLS failurePATH selected macOS LibreSSL, which lacked -copy_extensions; OpenSSL 3.6.4 passed the 17 focused tests. No certificate-code fix was needed.
One restore timeoutPassed 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 platformNo timerReferenced 60-second timer10 ms interval
24.19.06/66/66/6
26.8.16/60/6 within deadline6/6
26.8.26/60/6 within deadline6/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.

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, 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.

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.

CheckBaseline source buildExact patched source build
Upstream last-request fixtureFailedPassed
18-case timer matrixSix failures18/18 passed
Success traceNative 0.777 ms; continuation 1,003.239 msNative 0.8345 ms; continuation 0.8511 ms, before timer
Rejection trace502.952 ms0.3862 ms, before timer
Full upstream SQLite backup suiteNot used as a baseline acceptance claim18/18 passed

Preserved SHA-256 identifiers:

ArtifactSHA-256
Official 26.8.2 source .tar.xz36b37bf5ee4d092b9d9dff2d1a90b1444f8b453eddf6ff96cabdebb97d32f41d
Baseline compiled executable725c171df50b188063f13acb8a23fc40425431fc95e4c98f6985eb7007c43030
Patched compiled executablea815a34351bf28c4655ebffa07c4a8eb3da984496dd2e0e2262cc566b606635e
Baseline node_sqlite.ccef9821838ee1eed603cd84f63a04e5a88dbe5dca625173e6dad556349f92aaff
Patched node_sqlite.ccf844c3245159f7e64ffd7046dc12fd0ca27d79f60c3309300a61c090df814961
Saved upstream patch377a85407d95480eba399f11ae3e483b03d6f41986cda378df1bc6ff76f5842f
Official 26.8.2 macOS ARM64 archive974b6d5fb2fc7c33ff2354db0902b4e91c2de01ec8acc6de48e543c97e18c9e1

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. 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 scopeRecorded result at the implementation milestone
Official 24.19.0 application gatePassed; 3,996 tests in 222 files, static checks, coverage, audit and build
Official 26.8.2 application gatePassed; same 3,996 tests using maintained storage setup
Official 24.19.0 server gatePassed; 1,879 coverage, 56 account, three durability and three migration tests
Official 24.19.0 Chromium264 passed without retries
Official 24.19.0 and experimental patched 26 packaged smokeStartup, periodic content/integrity and shutdown passed
Both of those runtimes, smoke harness tests2/2 passed
Default 24 and override 26 setup version checksPassed
Fresh standalone pnpm 11.4.0 and frozen install on official 26Passed
Workflow syntaxactionlint 1.7.12 passed
Documentation and PR analysisDocumentation build/visual checks and CodeQL passed
Existing Node 24 GitHub gateRun 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 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.

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 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.

Archived recordPurpose
investigation.md, server-findings.md, storage-findings.mdInitial 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 tracesCompletion mechanism, native controls and before/after evidence
phase3-build/runtime-build-metadata.jsonSource, patch and binary provenance
phase3-build/baseline-node, patched out/Release/nodeActual controlled-build executables; research only
phase4/report.md and phase-four logsMaintained 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.

CapacityLens is open source under AGPL-3.0.

+ + + + \ No newline at end of file diff --git a/docs/security/OpenSSF-best-practices-dev.html b/docs/security/OpenSSF-best-practices-dev.html index a3401346a..44c6f262a 100644 --- a/docs/security/OpenSSF-best-practices-dev.html +++ b/docs/security/OpenSSF-best-practices-dev.html @@ -18,7 +18,7 @@ -
Skip to content

Review the OpenSSF Baseline self-assessment ​

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.

Project information ​

QuestionAnswer
What is the project's human-readable name?CapacityLens
What does the project do?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.
What is the project URL?github.com/Kevinjohn/capacitylens
What is the source repository URL?github.com/Kevinjohn/capacitylens
Which licence covers the project?AGPL-3.0-only; see LICENSE.
Which implementation languages are used?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.

Baseline Level 1 ​

Control and questionAnswerEvidence or reason
OSPS-AC-01.01 — Does access to sensitive repository resources require multi-factor sign-in?MetGitHub requires two-factor sign-in for contributors and protects sensitive settings and credentials. Public source remains intentionally readable.
OSPS-AC-02.01 — Are new collaborators assigned the lowest privileges by default or granted access manually?MetExternal contributors have no direct rights and propose changes through forks and pull requests. Repository access is granted explicitly.
OSPS-AC-03.01 — Are direct commits to the primary branch prevented?MetThe protected main branch accepts changes through pull requests.
OSPS-AC-03.02 — Is deletion of the primary branch protected by an explicit control?MetGitHub protection prevents deletion and force pushes to main.
OSPS-BR-01.01 — Is untrusted CI metadata validated or safely handled before use?MetWorkflows do not evaluate contributor-controlled titles, messages or metadata as commands. Shell variables are quoted and permissions are constrained.
OSPS-BR-01.03 — Is untrusted code kept away from privileged CI credentials and assets?MetPull-request analysis uses pull_request, not pull_request_target; workflows declare permissions and checkouts disable persisted credentials.
OSPS-BR-03.01 — Do official project channels use encrypted transport?MetRepository, documentation, support and security links use HTTPS.
OSPS-BR-03.02 — Are distribution channels protected against adversary-in-the-middle attacks?MetGitHub distributes source and releases over HTTPS; release assets receive GitHub build attestations.
OSPS-BR-07.01 — Does the project prevent accidental storage of unencrypted secrets in version control?Met.gitignore, contributor policy, full-history Gitleaks scanning and a validated Gitleaks configuration provide layered controls.
OSPS-DO-01.01 — Do releases have guides for basic functionality?MetUser and operator guides cover setup, use, sign-in, self-hosting and recovery.
OSPS-DO-02.01 — Is defect reporting documented?MetCONTRIBUTING.md directs ordinary reports to GitHub issues; SECURITY.md covers vulnerabilities.
OSPS-GV-02.01 — Is there a public mechanism to discuss changes and usage problems?MetIssues and pull requests are public, searchable and linkable.
OSPS-GV-03.01 — Is the contribution process documented?MetCONTRIBUTING.md covers setup, checks, standards, pull requests and sign-off.
OSPS-LE-02.01 — Does the source licence meet the OSI or FSF definition?MetAGPL-3.0-only is an OSI-approved free-software licence.
OSPS-LE-02.02 — Do released software assets use an OSI- or FSF-compliant licence?MetReleased software uses AGPL-3.0-only; product names and logos have a separate trademark policy.
OSPS-LE-03.01 — Is the source licence stored in a standard repository location?MetThe complete licence is in the root LICENSE file.
OSPS-LE-03.02 — Is the licence included with released source or alongside release assets?MetGitHub source releases contain the root LICENSE file.
OSPS-QA-01.01 — Is the authoritative repository publicly readable at a stable URL?MetThe GitHub repository is public and authoritative.
OSPS-QA-01.02 — Does version control publicly record changes, authors and dates?MetThe public Git history records content, authorship and timestamps.
OSPS-QA-02.01 — Does the repository list its direct language dependencies?MetWorkspace package.json files declare direct dependencies and pnpm-lock.yaml resolves the complete graph.
OSPS-QA-04.01 — Are all constituent repositories documented when the project uses several?N/ACapacityLens is a single repository containing the application, shared package, server, documentation and deployment configuration.
OSPS-QA-05.01 — Does version control exclude generated executable artifacts?MetBuilds and executable application artifacts are generated by CI. Committed docs/ files are reviewable static documentation, not executable binaries.
OSPS-QA-05.02 — Does version control exclude unreviewable binary artifacts?MetNo compiled libraries or application executables are committed. Images are documentation and interface assets, which this control excludes.
OSPS-VM-02.01 — Does the documentation identify a security reporting contact or route?MetSECURITY.md links directly to GitHub Private Vulnerability Reporting and defines a safe fallback.

Baseline Level 2 ​

Control and questionAnswerEvidence or reason
OSPS-AC-04.01 — Do CI jobs without explicit permissions receive least privilege by default?MetWorkflows set top-level read-only permissions and elevate individual jobs only where required.
OSPS-BR-02.01 — Does every official release have a unique identifier?MetReleases use Semantic Versioning tags, including explicit prerelease identifiers.
OSPS-BR-04.01 — Does every release describe functional and security changes?MetGitHub release notes and CHANGELOG.md provide human-readable changes and security entries.
OSPS-BR-05.01 — Does the build use standard tooling to obtain dependencies?Metpnpm reads the workspace manifests and exact lockfile.
OSPS-BR-06.01 — Are release assets signed or represented in a signed manifest containing hashes?MetThe release workflow creates GitHub build attestations and publishes an in-toto provenance bundle for the packaged build and SPDX SBOM.
OSPS-DO-06.01 — Is dependency selection, retrieval and tracking documented?MetThe development guide documents dependency policy, pnpm, the lockfile, Dependabot and validation.
OSPS-DO-07.01 — Are build instructions and prerequisites documented?MetThe README and CONTRIBUTING.md specify Node 24, pnpm, installation, development and complete validation commands.
OSPS-GV-01.01 — Are members with access to sensitive resources listed?MetGOVERNANCE.md records the maintainer-led model; the repository owner is publicly identified through GitHub and package metadata.
OSPS-GV-01.02 — Are project roles and responsibilities documented?MetGOVERNANCE.md describes product direction, review, release, security response and contributor progression.
OSPS-GV-03.02 — Does the contributor guide define acceptable contributions?MetCONTRIBUTING.md defines scope, coding, testing, security, submission and DCO requirements.
OSPS-LE-01.01 — Must contributors assert legal authority for every commit?MetContributor 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?UnmetCurrent 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?UnmetContributors 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?MetThe account-boundary design, threat model and development reference describe people, trust boundaries, components and operations.
OSPS-SA-02.01 — Are the released software's external interfaces documented?MetSelf-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?MetThe 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?MetSECURITY.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?MetGitHub Private Vulnerability Reporting is the primary documented route.
OSPS-VM-04.01 — Will discovered vulnerabilities be published publicly?MetSECURITY.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.

Baseline Level 3 ​

Control and questionAnswerEvidence or reason
OSPS-AC-04.02 — Does each CI job receive only the permissions it needs?MetWorkflows 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?MetManual 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?MetAssets 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?MetSecurity 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?UnmetAttestations 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?UnmetThe 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?MetSECURITY.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?MetSECURITY.md states that older releases may not receive fixes once superseded.
OSPS-GV-04.01 — Must collaborators be reviewed before receiving escalated access?UnmetThe 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?MetThe 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/ACapacityLens releases are built from one repository.
OSPS-QA-06.02 — Is it documented when and how tests run?MetCONTRIBUTING.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?MetCONTRIBUTING.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?UnmetCapacityLens 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?MetThe 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?UnmetThe 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?UnmetCI 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?UnmetSecurity 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?UnmetDependency 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?UnmetCodeQL 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?UnmetCodeQL 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.

Keep the record current ​

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.

CapacityLens is open source under AGPL-3.0.

+
Skip to content

Review the OpenSSF Baseline self-assessment ​

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.

Project information ​

QuestionAnswer
What is the project's human-readable name?CapacityLens
What does the project do?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.
What is the project URL?github.com/Kevinjohn/capacitylens
What is the source repository URL?github.com/Kevinjohn/capacitylens
Which licence covers the project?AGPL-3.0-only; see LICENSE.
Which implementation languages are used?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.

Baseline Level 1 ​

Control and questionAnswerEvidence or reason
OSPS-AC-01.01 — Does access to sensitive repository resources require multi-factor sign-in?MetGitHub requires two-factor sign-in for contributors and protects sensitive settings and credentials. Public source remains intentionally readable.
OSPS-AC-02.01 — Are new collaborators assigned the lowest privileges by default or granted access manually?MetExternal contributors have no direct rights and propose changes through forks and pull requests. Repository access is granted explicitly.
OSPS-AC-03.01 — Are direct commits to the primary branch prevented?MetThe protected main branch accepts changes through pull requests.
OSPS-AC-03.02 — Is deletion of the primary branch protected by an explicit control?MetGitHub protection prevents deletion and force pushes to main.
OSPS-BR-01.01 — Is untrusted CI metadata validated or safely handled before use?MetWorkflows do not evaluate contributor-controlled titles, messages or metadata as commands. Shell variables are quoted and permissions are constrained.
OSPS-BR-01.03 — Is untrusted code kept away from privileged CI credentials and assets?MetPull-request analysis uses pull_request, not pull_request_target; workflows declare permissions and checkouts disable persisted credentials.
OSPS-BR-03.01 — Do official project channels use encrypted transport?MetRepository, documentation, support and security links use HTTPS.
OSPS-BR-03.02 — Are distribution channels protected against adversary-in-the-middle attacks?MetGitHub distributes source and releases over HTTPS; release assets receive GitHub build attestations.
OSPS-BR-07.01 — Does the project prevent accidental storage of unencrypted secrets in version control?Met.gitignore, contributor policy, full-history Gitleaks scanning and a validated Gitleaks configuration provide layered controls.
OSPS-DO-01.01 — Do releases have guides for basic functionality?MetUser and operator guides cover setup, use, sign-in, self-hosting and recovery.
OSPS-DO-02.01 — Is defect reporting documented?MetCONTRIBUTING.md directs ordinary reports to GitHub issues; SECURITY.md covers vulnerabilities.
OSPS-GV-02.01 — Is there a public mechanism to discuss changes and usage problems?MetIssues and pull requests are public, searchable and linkable.
OSPS-GV-03.01 — Is the contribution process documented?MetCONTRIBUTING.md covers setup, checks, standards, pull requests and sign-off.
OSPS-LE-02.01 — Does the source licence meet the OSI or FSF definition?MetAGPL-3.0-only is an OSI-approved free-software licence.
OSPS-LE-02.02 — Do released software assets use an OSI- or FSF-compliant licence?MetReleased software uses AGPL-3.0-only; product names and logos have a separate trademark policy.
OSPS-LE-03.01 — Is the source licence stored in a standard repository location?MetThe complete licence is in the root LICENSE file.
OSPS-LE-03.02 — Is the licence included with released source or alongside release assets?MetGitHub source releases contain the root LICENSE file.
OSPS-QA-01.01 — Is the authoritative repository publicly readable at a stable URL?MetThe GitHub repository is public and authoritative.
OSPS-QA-01.02 — Does version control publicly record changes, authors and dates?MetThe public Git history records content, authorship and timestamps.
OSPS-QA-02.01 — Does the repository list its direct language dependencies?MetWorkspace package.json files declare direct dependencies and pnpm-lock.yaml resolves the complete graph.
OSPS-QA-04.01 — Are all constituent repositories documented when the project uses several?N/ACapacityLens is a single repository containing the application, shared package, server, documentation and deployment configuration.
OSPS-QA-05.01 — Does version control exclude generated executable artifacts?MetBuilds and executable application artifacts are generated by CI. Committed docs/ files are reviewable static documentation, not executable binaries.
OSPS-QA-05.02 — Does version control exclude unreviewable binary artifacts?MetNo compiled libraries or application executables are committed. Images are documentation and interface assets, which this control excludes.
OSPS-VM-02.01 — Does the documentation identify a security reporting contact or route?MetSECURITY.md links directly to GitHub Private Vulnerability Reporting and defines a safe fallback.

Baseline Level 2 ​

Control and questionAnswerEvidence or reason
OSPS-AC-04.01 — Do CI jobs without explicit permissions receive least privilege by default?MetWorkflows set top-level read-only permissions and elevate individual jobs only where required.
OSPS-BR-02.01 — Does every official release have a unique identifier?MetReleases use Semantic Versioning tags, including explicit prerelease identifiers.
OSPS-BR-04.01 — Does every release describe functional and security changes?MetGitHub release notes and CHANGELOG.md provide human-readable changes and security entries.
OSPS-BR-05.01 — Does the build use standard tooling to obtain dependencies?Metpnpm reads the workspace manifests and exact lockfile.
OSPS-BR-06.01 — Are release assets signed or represented in a signed manifest containing hashes?MetThe release workflow creates GitHub build attestations and publishes an in-toto provenance bundle for the packaged build and SPDX SBOM.
OSPS-DO-06.01 — Is dependency selection, retrieval and tracking documented?MetThe development guide documents dependency policy, pnpm, the lockfile, Dependabot and validation.
OSPS-DO-07.01 — Are build instructions and prerequisites documented?MetThe README and CONTRIBUTING.md specify Node 24, pnpm, installation, development and complete validation commands.
OSPS-GV-01.01 — Are members with access to sensitive resources listed?MetGOVERNANCE.md records the maintainer-led model; the repository owner is publicly identified through GitHub and package metadata.
OSPS-GV-01.02 — Are project roles and responsibilities documented?MetGOVERNANCE.md describes product direction, review, release, security response and contributor progression.
OSPS-GV-03.02 — Does the contributor guide define acceptable contributions?MetCONTRIBUTING.md defines scope, coding, testing, security, submission and DCO requirements.
OSPS-LE-01.01 — Must contributors assert legal authority for every commit?MetContributor 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?UnmetCurrent 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?UnmetContributors 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?MetThe account-boundary design, threat model and development reference describe people, trust boundaries, components and operations.
OSPS-SA-02.01 — Are the released software's external interfaces documented?MetSelf-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?MetThe 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?MetSECURITY.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?MetGitHub Private Vulnerability Reporting is the primary documented route.
OSPS-VM-04.01 — Will discovered vulnerabilities be published publicly?MetSECURITY.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.

Baseline Level 3 ​

Control and questionAnswerEvidence or reason
OSPS-AC-04.02 — Does each CI job receive only the permissions it needs?MetWorkflows 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?MetManual 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?MetAssets 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?MetSecurity 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?UnmetAttestations 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?UnmetThe 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?MetSECURITY.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?MetSECURITY.md states that older releases may not receive fixes once superseded.
OSPS-GV-04.01 — Must collaborators be reviewed before receiving escalated access?UnmetThe 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?MetThe 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/ACapacityLens releases are built from one repository.
OSPS-QA-06.02 — Is it documented when and how tests run?MetCONTRIBUTING.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?MetCONTRIBUTING.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?UnmetCapacityLens 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?MetThe 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?UnmetThe 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?UnmetCI 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?UnmetSecurity 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?UnmetDependency 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?UnmetCodeQL 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?UnmetCodeQL 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.

Keep the record current ​

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.

CapacityLens is open source under AGPL-3.0.

diff --git a/docs/security/control-inventories.html b/docs/security/control-inventories.html index 78e370d99..a773b3f55 100644 --- a/docs/security/control-inventories.html +++ b/docs/security/control-inventories.html @@ -18,7 +18,7 @@ -
Skip to content

Security control inventories ​

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.

Entry points and untrusted input ​

InputFormat and limitTrusted enforcementSecurity treatment
Tenant API route/body/query/pathJSON and bounded strings/arrays; explicit route schemas/codecsFastify API and shared domainauthentication, per-operation membership/action check, allowlisted tables/fields, sanitisation, relational validation
Whole-account importJSON; 5 MiB and 200,000 recordsOwner-only API + SQLitebounded worker preparation, version migration, known tables, tenant remap, repair/reference checks, exact-snapshot recheck and atomic replacement
Password15–128 Unicode code pointsAuth callbackscontext-word and HIBP check, exact-byte versioned scrypt hashing, generic failure paths; HIBP response is time/size bounded and redirects are refused
TOTP/recovery codeBetter Auth bounded formatsAuth plugintimed TOTP, lockout, encrypted recovery material, one-time use
Sign-up/setup/invite/reset valuesBounded JSON/path/header valuesAuth/APIfirst-owner or invite gate, token expiry/hash/revocation, generic lookup behavior
Federated callback/linkProvider claims and bounded callback stateAuth/account boundaryprovider-specific claim checks, verified-email admission, explicit linking, unique subject/provider rows and provider-required access checks
Stopped-server recovery/repairOperator CLI arguments and current SQLite stateExclusive SQLite transactionsole-Owner reset eligibility; exact named-provider identity, ownerless-company or empty-company targets; audit; partial-operation rollback
Provider configurationEnvironment and provider responsesStartup/provider integrationcomplete named-provider credentials; exact Microsoft tenant; partial or retired configuration fails closed
CORS/origin/forwarding headersHTTP headersRoot API hook/proxyexact origin allow-list, unsafe cross-site rejection, forwarded IP trusted only in packaged single-proxy shape
CSP violation reportbounded CSP/Reporting API JSONPublic rate-limited API route64 KiB, maximum 1 projected event/request, origin/directive only; URL paths, queries and fragments discarded
Offline snapshotPreviously authorized API responseBrowser cache layeraccount/user/origin scoped, schema validation, AES-GCM integrity, seven-day expiry, read-only projection
Environment and numeric settingsProcess environmentStartup parsers/guardbounded integers, explicit boolean grammar, fail-closed production invariants

There is no runtime XML, LDAP, XPath, GraphQL, WebSocket, email, Markdown/WYSIWYG, LaTeX, archive, image, arbitrary file-upload, CSV/spreadsheet, template, shell-command, JNDI, memcache, WebRTC or media processing entry point.

Sensitive data and retention ​

ClassExamplesStorage/transportRetention and disclosure
Authentication secretpassword verifier, MFA recovery, session/reset/invite values, OAuth tokens/provider secretsSQLite/env over TLS; provider tokens encrypted with the app secret; restrictive host filessessions fixed at 12h/30m idle; reset/invite bounded and revocable; identity erasure removes eligible verifier/token state; never log values
Identity dataname, email, memberships, provider subject, verified-link observation, last sign-in confirmationSQLite over TLSuntil identity no longer belongs to an account; account erasure removes eligible identity/control rows; used invites bounded to 200/365 days
Tenant confidential dataschedule, notes, real private namesSQLite, encrypted operator storage, role-filtered APIoperator policy; owner export; account erasure; old backups/audits follow operator retention/legal hold
Offline tenant datalast verified identity/account/snapshotAES-GCM IndexedDBopt-in; seven-day expiry is physically swept before the next cache access; sign-out/opt-out/device clear/schema upgrade/tamper removes records
Audit/security metadatatimestamp, actor/account/action/entity/field names, security outcome/IPlocal JSONL and separately forwarded JSONno entity values, credentials or bearer tokens; deployment defines access and retention
Ownership transfer requestcompany, initiator and nominee ids, workflow state, deadlineSQLite over TLSa 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 preferencetheme, zoom and similar settingslocalStoragedevice-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.

Cryptographic inventory ​

PurposePrimitive/libraryKey/materialLifecycle and migration
New password storageNode crypto.scrypt, N=2^17,r=8,p=1, 16-byte salt, 64-byte resultpassword-derived; random saltself-describing scrypt-v1 record; parameters can version; legacy Better Auth scrypt verify-only
Password comparisonNode timingSafeEqualstored/derived resultconstant-time after fixed-length derivation
Breach lookupSHA-1 only as required by HIBP k-anonymity protocolephemeral candidate digest; five-character prefix sentnever used as a verifier or security hash; response padding enabled
Offline snapshotWeb Crypto AES-256-GCM, 96-bit random IV and AADnon-extractable per-browser random device keyschema v2; corrupt/expired records and v1 plaintext deleted; device clear destroys key/data
Invite/reset lookupSHA-256 token digestCSPRNG token shown onceexpiry, use/revocation and account deletion remove state
Session/auth/MFABetter Auth/Node cryptoSMALLSASS_ACCOUNT_SECRET, session tokens, encrypted backup codes/TOTP state32+ character operator secret; rotate to invalidate sessions; secret manager/operator rotation required
OAuth token storageBetter Auth application-secret encryptionprovider access/refresh tokens and application secretimplicit linking disabled; existing plaintext tokens are encrypted when refreshed; operator secret rotation policy applies
Account command/session handlesDomain-separated SHA-256bearer/password inputs or session token; application/operation contextcomparison/index/audit handles only; raw credentials do not cross the account boundary or enter durable command/audit results
Sync successor provenanceSHA-256 over canonical non-secret row JSONseven-day operational row/session metadataexact same-session successor checks only; tenant deletion removes workspace provenance
Internal service TLSOpenSSL P-256/SHA-256, Node HTTPS and nginx verificationper-install root-only CA key; API-only leaf key; public CA/leafautomatic in Compose; optional for same-host bare metal; configured identities never fall back silently; coordinated renewal/recreation
Public TLS/provider assertion cryptoTLS proxy, Node trust store and Better Auth providerspublic certificates/provider metadataoperator 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.

Service connection and work limits ​

Service/resourceMaximumLimit behavior and recovery
API accepted sockets512 per processnew sockets refused; nginx surfaces upstream failure; client retries must be bounded
API request/incomplete connection30 secondsFastify terminates timed-out work; proxy has bounded headroom, never an infinite read timeout
SQLiteone synchronous connection/process; one API process/filewaits five seconds on a held lock, then surfaces failure; restart/repair rather than spawning writers
Password scrypt2 active + 16 queued per processidentity-global requests share the queue; overflow fails closed; queue releases after success or failure
HIBP range service8 active + 32 queued per process; 5-second callidentity-global requests share the queue; overflow, timeout, redirect or outage fails password mutation closed
Import worker threads2 active + 8 queued per process; 5-second waitFIFO queue; overflow/wait timeout returns retryable 503; disconnected requests cancel queued/active work; slots release after exit
Batch mutation5,000 operations/requestone SQLite transaction; authorization/validation per operation; stale conflict rolls the complete batch back
CSP report ingestion64 KiB and 1 emitted event/requestmalformed/oversize rejected; excess array entries discarded; normal IP rate limit applies
Google and Microsoft providersProvider-controlled OAuth round tripProvider-owned flow; Microsoft issuer, audience, time, tenant, identity and nonce claims are checked before identity use
Backup operationone in flightscheduler skips overlap; shutdown waits for completion before closing SQLite

Security and audit event inventory ​

LayerEventsFormat/destinationSensitive-data rule
API request logmethod, route, status, latency and request metadataPino JSON stdout when enabledno request/response bodies, cookie or authorization values
Security logauth outcomes, MFA/fresh-session rejection, authorization/CSRF denial, CSP reports, queue saturation, 429/500, process failure and session revocationcapacitylens.security JSON stdoutids/outcomes and bounded source metadata only; never credential/bearer values, exception details or CSP URL paths/queries
Mutation auditactor, account, action, entity, id and changed fields; company-provider cutover/link/repair and operator Owner-recovery outcomestransactional SQLite outbox → fsynced mode-0600 JSONL plus optional capacitylens.audit JSON stdoutfield names and non-secret ceremony digests only; never field values or bearer tokens; stable auditId supports deduplication
Proxy/IdP/platformTLS/access/WAF/container/identity/collector eventsdeployment-defined separate systemsoperator 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.

Third parties and build inputs ​

  • 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.
  • Build/test: pnpm registry packages, GitHub Actions, CodeQL, Playwright browsers, Vitest, ESLint, Stryker, Gitleaks, Syft/Anchore, Trivy and OWASP ZAP.
  • 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.

CapacityLens is open source under AGPL-3.0.

+
Skip to content

Security control inventories ​

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.

Entry points and untrusted input ​

InputFormat and limitTrusted enforcementSecurity treatment
Tenant API route/body/query/pathJSON and bounded strings/arrays; explicit route schemas/codecsFastify API and shared domainauthentication, per-operation membership/action check, allowlisted tables/fields, sanitisation, relational validation
Whole-account importJSON; 5 MiB and 200,000 recordsOwner-only API + SQLitebounded worker preparation, version migration, known tables, tenant remap, repair/reference checks, exact-snapshot recheck and atomic replacement
Password15–128 Unicode code pointsAuth callbackscontext-word and HIBP check, exact-byte versioned scrypt hashing, generic failure paths; HIBP response is time/size bounded and redirects are refused
TOTP/recovery codeBetter Auth bounded formatsAuth plugintimed TOTP, lockout, encrypted recovery material, one-time use
Sign-up/setup/invite/reset valuesBounded JSON/path/header valuesAuth/APIfirst-owner or invite gate, token expiry/hash/revocation, generic lookup behavior
Federated callback/linkProvider claims and bounded callback stateAuth/account boundaryprovider-specific claim checks, verified-email admission, explicit linking, unique subject/provider rows and provider-required access checks
Stopped-server recovery/repairOperator CLI arguments and current SQLite stateExclusive SQLite transactionsole-Owner reset eligibility; exact named-provider identity, ownerless-company or empty-company targets; audit; partial-operation rollback
Provider configurationEnvironment and provider responsesStartup/provider integrationcomplete named-provider credentials; exact Microsoft tenant; partial or retired configuration fails closed
CORS/origin/forwarding headersHTTP headersRoot API hook/proxyexact origin allow-list, unsafe cross-site rejection, forwarded IP trusted only in packaged single-proxy shape
CSP violation reportbounded CSP/Reporting API JSONPublic rate-limited API route64 KiB, maximum 1 projected event/request, origin/directive only; URL paths, queries and fragments discarded
Offline snapshotPreviously authorized API responseBrowser cache layeraccount/user/origin scoped, schema validation, AES-GCM integrity, seven-day expiry, read-only projection
Environment and numeric settingsProcess environmentStartup parsers/guardbounded integers, explicit boolean grammar, fail-closed production invariants

There is no runtime XML, LDAP, XPath, GraphQL, WebSocket, email, Markdown/WYSIWYG, LaTeX, archive, image, arbitrary file-upload, CSV/spreadsheet, template, shell-command, JNDI, memcache, WebRTC or media processing entry point.

Sensitive data and retention ​

ClassExamplesStorage/transportRetention and disclosure
Authentication secretpassword verifier, MFA recovery, session/reset/invite values, OAuth tokens/provider secretsSQLite/env over TLS; provider tokens encrypted with the app secret; restrictive host filessessions fixed at 12h/30m idle; reset/invite bounded and revocable; identity erasure removes eligible verifier/token state; never log values
Identity dataname, email, memberships, provider subject, verified-link observation, last sign-in confirmationSQLite over TLSuntil identity no longer belongs to an account; account erasure removes eligible identity/control rows; used invites bounded to 200/365 days
Tenant confidential dataschedule, notes, real private namesSQLite, encrypted operator storage, role-filtered APIoperator policy; owner export; account erasure; old backups/audits follow operator retention/legal hold
Offline tenant datalast verified identity/account/snapshotAES-GCM IndexedDBopt-in; seven-day expiry is physically swept before the next cache access; sign-out/opt-out/device clear/schema upgrade/tamper removes records
Audit/security metadatatimestamp, actor/account/action/entity/field names, security outcome/IPlocal JSONL and separately forwarded JSONno entity values, credentials or bearer tokens; deployment defines access and retention
Ownership transfer requestcompany, initiator and nominee ids, workflow state, deadlineSQLite over TLSa 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 preferencetheme, zoom and similar settingslocalStoragedevice-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.

Cryptographic inventory ​

PurposePrimitive/libraryKey/materialLifecycle and migration
New password storageNode crypto.scrypt, N=2^17,r=8,p=1, 16-byte salt, 64-byte resultpassword-derived; random saltself-describing scrypt-v1 record; parameters can version; legacy Better Auth scrypt verify-only
Password comparisonNode timingSafeEqualstored/derived resultconstant-time after fixed-length derivation
Breach lookupSHA-1 only as required by HIBP k-anonymity protocolephemeral candidate digest; five-character prefix sentnever used as a verifier or security hash; response padding enabled
Offline snapshotWeb Crypto AES-256-GCM, 96-bit random IV and AADnon-extractable per-browser random device keyschema v2; corrupt/expired records and v1 plaintext deleted; device clear destroys key/data
Invite/reset lookupSHA-256 token digestCSPRNG token shown onceexpiry, use/revocation and account deletion remove state
Session/auth/MFABetter Auth/Node cryptoSMALLSASS_ACCOUNT_SECRET, session tokens, encrypted backup codes/TOTP state32+ character operator secret; rotate to invalidate sessions; secret manager/operator rotation required
OAuth token storageBetter Auth application-secret encryptionprovider access/refresh tokens and application secretimplicit linking disabled; existing plaintext tokens are encrypted when refreshed; operator secret rotation policy applies
Account command/session handlesDomain-separated SHA-256bearer/password inputs or session token; application/operation contextcomparison/index/audit handles only; raw credentials do not cross the account boundary or enter durable command/audit results
Sync successor provenanceSHA-256 over canonical non-secret row JSONseven-day operational row/session metadataexact same-session successor checks only; tenant deletion removes workspace provenance
Internal service TLSOpenSSL P-256/SHA-256, Node HTTPS and nginx verificationper-install root-only CA key; API-only leaf key; public CA/leafautomatic in Compose; optional for same-host bare metal; configured identities never fall back silently; coordinated renewal/recreation
Public TLS/provider assertion cryptoTLS proxy, Node trust store and Better Auth providerspublic certificates/provider metadataoperator 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.

Service connection and work limits ​

Service/resourceMaximumLimit behavior and recovery
API accepted sockets512 per processnew sockets refused; nginx surfaces upstream failure; client retries must be bounded
API request/incomplete connection30 secondsFastify terminates timed-out work; proxy has bounded headroom, never an infinite read timeout
SQLiteone synchronous connection/process; one API process/filewaits five seconds on a held lock, then surfaces failure; restart/repair rather than spawning writers
Password scrypt2 active + 16 queued per processidentity-global requests share the queue; overflow fails closed; queue releases after success or failure
HIBP range service8 active + 32 queued per process; 5-second callidentity-global requests share the queue; overflow, timeout, redirect or outage fails password mutation closed
Import worker threads2 active + 8 queued per process; 5-second waitFIFO queue; overflow/wait timeout returns retryable 503; disconnected requests cancel queued/active work; slots release after exit
Batch mutation5,000 operations/requestone SQLite transaction; authorization/validation per operation; stale conflict rolls the complete batch back
CSP report ingestion64 KiB and 1 emitted event/requestmalformed/oversize rejected; excess array entries discarded; normal IP rate limit applies
Google and Microsoft providersProvider-controlled OAuth round tripProvider-owned flow; Microsoft issuer, audience, time, tenant, identity and nonce claims are checked before identity use
Backup operationone in flightscheduler skips overlap; shutdown waits for completion before closing SQLite

Security and audit event inventory ​

LayerEventsFormat/destinationSensitive-data rule
API request logmethod, route, status, latency and request metadataPino JSON stdout when enabledno request/response bodies, cookie or authorization values
Security logauth outcomes, MFA/fresh-session rejection, authorization/CSRF denial, CSP reports, queue saturation, 429/500, process failure and session revocationcapacitylens.security JSON stdoutids/outcomes and bounded source metadata only; never credential/bearer values, exception details or CSP URL paths/queries
Mutation auditactor, account, action, entity, id and changed fields; company-provider cutover/link/repair and operator Owner-recovery outcomestransactional SQLite outbox → fsynced mode-0600 JSONL plus optional capacitylens.audit JSON stdoutfield names and non-secret ceremony digests only; never field values or bearer tokens; stable auditId supports deduplication
Proxy/IdP/platformTLS/access/WAF/container/identity/collector eventsdeployment-defined separate systemsoperator 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.

Third parties and build inputs ​

  • 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.
  • Build/test: pnpm registry packages, GitHub Actions, CodeQL, Playwright browsers, Vitest, ESLint, Stryker, Gitleaks, Syft/Anchore, Trivy and OWASP ZAP.
  • 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.

CapacityLens is open source under AGPL-3.0.

diff --git a/docs/security/index.html b/docs/security/index.html index 9b6af782e..8446ba6f0 100644 --- a/docs/security/index.html +++ b/docs/security/index.html @@ -18,7 +18,7 @@ -
Skip to content

Security overview ​

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.

What's on by default ​

  • 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.

Where the detailed evidence lives ​

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.

Report a vulnerability ​

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.

What's next ​

Read Privacy for what data CapacityLens stores and what stays in the browser, or go straight to Reviews and compliance for the detailed evidence.

CapacityLens is open source under AGPL-3.0.

+
Skip to content

Security overview ​

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.

What's on by default ​

  • 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.

Where the detailed evidence lives ​

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.

Report a vulnerability ​

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.

What's next ​

Read Privacy for what data CapacityLens stores and what stays in the browser, or go straight to Reviews and compliance for the detailed evidence.

CapacityLens is open source under AGPL-3.0.

diff --git a/docs/security/mutation-review-2026-07-15.html b/docs/security/mutation-review-2026-07-15.html index 6d2e0c81b..561404be8 100644 --- a/docs/security/mutation-review-2026-07-15.html +++ b/docs/security/mutation-review-2026-07-15.html @@ -18,7 +18,7 @@ -
Skip to content

Mutation-test security review — 2026-07-15 ​

Conclusion ​

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.

Scope ​

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.

Result ​

MetricFirst post-fix baselineFinal reviewed run
Mutants2,9882,988
Killed by assertions2,7462,763
Timed out1111
Survived192177
No coverage3937
Errors00
Total mutation score92.27%92.84%
Covered-code score93.49%94.00%

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.

Survivor triage ​

AreaReview outcome
shared/src/domain/tenancy.ts100% mutation score. The direct tenant predicates have no surviving or uncovered mutants.
Access rank guardsSurviving 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 validationA 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 projectionMissing/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 repairAdversarial 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 cascadesTests now prove that surviving rows retain identity and receive the caller's revision when a foreign key is cleared.
Date and working-day validationPrefix/suffix date junk, duplicate/fractional/out-of-range weekdays and custom error-field routing are now explicit.
API error parsingDirect tests cover unreadable JSON, null, primitives, arrays, empty/non-string error values and valid server text.
Scheduler/layout/timezone/tour helpersRemaining 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 mutantsStryker 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.

Acceptance and follow-up ​

  • 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.

CapacityLens is open source under AGPL-3.0.

+
Skip to content

Mutation-test security review — 2026-07-15 ​

Conclusion ​

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.

Scope ​

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.

Result ​

MetricFirst post-fix baselineFinal reviewed run
Mutants2,9882,988
Killed by assertions2,7462,763
Timed out1111
Survived192177
No coverage3937
Errors00
Total mutation score92.27%92.84%
Covered-code score93.49%94.00%

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.

Survivor triage ​

AreaReview outcome
shared/src/domain/tenancy.ts100% mutation score. The direct tenant predicates have no surviving or uncovered mutants.
Access rank guardsSurviving 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 validationA 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 projectionMissing/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 repairAdversarial 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 cascadesTests now prove that surviving rows retain identity and receive the caller's revision when a foreign key is cleared.
Date and working-day validationPrefix/suffix date junk, duplicate/fractional/out-of-range weekdays and custom error-field routing are now explicit.
API error parsingDirect tests cover unreadable JSON, null, primitives, arrays, empty/non-string error values and valid server text.
Scheduler/layout/timezone/tour helpersRemaining 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 mutantsStryker 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.

Acceptance and follow-up ​

  • 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.

CapacityLens is open source under AGPL-3.0.

diff --git a/docs/security/mutation-review-2026-07-18.html b/docs/security/mutation-review-2026-07-18.html index 74f333978..47f409011 100644 --- a/docs/security/mutation-review-2026-07-18.html +++ b/docs/security/mutation-review-2026-07-18.html @@ -18,7 +18,7 @@ -
Skip to content

Mutation-test review — 2026-07-18 ​

Conclusion ​

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.

Scope ​

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.

Result ​

Metric2026-07-15 reviewed run2026-07-18 reviewed run
Mutants2,9883,068
Killed by assertions2,7632,823
Timed out1111
Survived177190
No coverage3744
Errors00
Total mutation score92.84%92.37%
Covered-code score94.00%93.72%

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.

Survivor triage ​

AreaReview outcome
Tenant and private-data predicatestenancy.ts and privateNames.ts remain at 100%. No tenant-isolation or confidential-name mutant survives.
Access boundaryOne 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 actionsSurviving 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 validationmutations.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 repairIntegrity 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 mappingresetPasswordFailure.ts remains at 100%.
Pure scheduler helpersThe 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 presentationLow-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 mutantsAll 11 are in date, colour, fuzzy-search, geometry or virtualisation helpers. Stryker counts them as detected, but they remain weaker evidence than assertion kills.

Acceptance and follow-up ​

  • 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.

CapacityLens is open source under AGPL-3.0.

+
Skip to content

Mutation-test review — 2026-07-18 ​

Conclusion ​

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.

Scope ​

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.

Result ​

Metric2026-07-15 reviewed run2026-07-18 reviewed run
Mutants2,9883,068
Killed by assertions2,7632,823
Timed out1111
Survived177190
No coverage3744
Errors00
Total mutation score92.84%92.37%
Covered-code score94.00%93.72%

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.

Survivor triage ​

AreaReview outcome
Tenant and private-data predicatestenancy.ts and privateNames.ts remain at 100%. No tenant-isolation or confidential-name mutant survives.
Access boundaryOne 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 actionsSurviving 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 validationmutations.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 repairIntegrity 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 mappingresetPasswordFailure.ts remains at 100%.
Pure scheduler helpersThe 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 presentationLow-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 mutantsAll 11 are in date, colour, fuzzy-search, geometry or virtualisation helpers. Stryker counts them as detected, but they remain weaker evidence than assertion kills.

Acceptance and follow-up ​

  • 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.

CapacityLens is open source under AGPL-3.0.

diff --git a/docs/security/owasp-asvs-5.0.0.html b/docs/security/owasp-asvs-5.0.0.html index ad2cf570a..f114af514 100644 --- a/docs/security/owasp-asvs-5.0.0.html +++ b/docs/security/owasp-asvs-5.0.0.html @@ -18,7 +18,7 @@ -
Skip to content

OWASP ASVS 5.0.0 complete control ledger ​

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.

V1 Encoding and sanitization ​

SectionEvidence summaryPassPartialGapN/A
V1.1 ArchitectureFastify parses once; shared sanitisation precedes domain use; React/JSON perform contextual output encodingV1.1.2V1.1.1——
V1.2 Injection preventionReact text nodes, encoded URL components, structured JSON, parameterized SQLite, fixed/bounded regexV1.2.1, V1.2.2, V1.2.3, V1.2.4, V1.2.9——V1.2.5, V1.2.6, V1.2.7, V1.2.8, V1.2.10
V1.3 SanitizationNo eval; context-specific codecs/lengths; operator-only HTTPS URL allow-list; bounded fixed regexV1.3.2, V1.3.3, V1.3.6, V1.3.12——V1.3.1, V1.3.4, V1.3.5, V1.3.7, V1.3.8, V1.3.9, V1.3.10, V1.3.11
V1.4 Memory/numeric safetyMemory-safe JS/TS runtime, bounded integer parsers and explicit shutdown/resource releaseV1.4.1, V1.4.2, V1.4.3———
V1.5 Safe parsingTyped JSON/object allow-listing; Node URL parser; no XMLV1.5.2V1.5.3—V1.5.1

V2 Validation and business logic ​

SectionEvidence summaryPassPartialGapN/A
V2.1 DocumentationAGENTS.md, DEFENSIVE-CODING.md, domain invariants and control inventory define shape/context/limitsV2.1.1, V2.1.2, V2.1.3———
V2.2 EnforcementServer/domain validation is authoritative; related entity/account/date/activity rules checkedV2.2.1, V2.2.2, V2.2.3———
V2.3 Flows/transactionsSetup/invite/MFA/link/cutover order, SQLite transactions, sync provenance, stale-import checks and atomic replacementV2.3.1, V2.3.2, V2.3.3——V2.3.4, V2.3.5
V2.4 Anti-automationAPI/health throttling and request/import/batch boundsV2.4.1——V2.4.2

V3 Web frontend security ​

SectionEvidence summaryPassPartialGapN/A
V3.1 Browser feature modelEvergreen-browser cross-browser suite and security headers; no full incompatible-browser block—V3.1.1——
V3.2 Rendering contextJSON MIME/nosniff/CORP plus React text rendering and TypeScript module scopeV3.2.1, V3.2.2, V3.2.3———
V3.3 CookiesHTTPS emits Secure, Path=/, domain-free __Host- cookies; HTTP loopback uses development names; SameSite=Lax, HttpOnly and bounded cookiesV3.3.1, V3.3.2, V3.3.3, V3.3.4, V3.3.5———
V3.4 Browser headersTwo-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 streamV3.4.1, V3.4.2, V3.4.4, V3.4.5, V3.4.6, V3.4.7, V3.4.8V3.4.3——
V3.5 Cross-origin controlsUnsafe Origin/Fetch-Metadata rejection, correct methods, no JSONP/script data, same-origin CORPV3.5.1, V3.5.2, V3.5.3, V3.5.6, V3.5.7, V3.5.8——V3.5.4, V3.5.5
V3.6 External assetsRuntime JS/CSS/fonts are self-hosted; no CDN runtime dependencyV3.6.1———
V3.7 Client behaviorSupported web platform only; external provider navigation is explicit/user-selected; preload/incompatible-browser behavior is deployment-dependentV3.7.1, V3.7.2, V3.7.3V3.7.4, V3.7.5——

V4 API and web service ​

SectionEvidence summaryPassPartialGapN/A
V4.1 HTTP useCorrect content types, TLS at public proxy, explicit methods; trusted forwarding depends on packaged/operator proxyV4.1.1, V4.1.2, V4.1.4V4.1.3—V4.1.5
V4.2 Message framingCurrent nginx/Fastify/Node framing; auth proxy strips length/transfer headers; provider output boundedV4.2.5V4.2.1, V4.2.2, V4.2.3, V4.2.4——
V4.3 GraphQLNo GraphQL endpoint———V4.3.1, V4.3.2
V4.4 WebSocketNo WebSocket endpoint———V4.4.1, V4.4.2, V4.4.3, V4.4.4

V5 File handling ​

SectionEvidence summaryPassPartialGapN/A
V5.1 DocumentationJSON import is the sole file-like input; type, 5 MiB and record limits documented/testedV5.1.1———
V5.2 Uploaded contentJSON content parsed/validated with body/record caps; no archives or images are accepted/storedV5.2.1, V5.2.2——V5.2.3, V5.2.4, V5.2.5, V5.2.6
V5.3 Storage/pathServer data/audit/backup paths are operator configuration, not user filenames; no public uploaded code/archiveV5.3.2——V5.3.1, V5.3.3
V5.4 DownloadsExport filename is internally generated and safe; no untrusted served filesV5.4.1, V5.4.2——V5.4.3

V6 Authentication ​

SectionEvidence summaryPassPartialGapN/A
V6.1 DocumentationAuth pathways, throttling/lockout, context words, password/MFA/SSO strength documentedV6.1.1, V6.1.2, V6.1.3———
V6.2 Passwords15–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 warningV6.2.1, V6.2.2, V6.2.3, V6.2.4, V6.2.5, V6.2.6, V6.2.7, V6.2.8, V6.2.9, V6.2.10, V6.2.11V6.2.12——
V6.3 Authentication controlsAPI 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 existV6.3.1, V6.3.2, V6.3.4, V6.3.6, V6.3.8V6.3.3V6.3.5, V6.3.7—
V6.4 RecoveryProduction 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 codeV6.4.1, V6.4.2, V6.4.3, V6.4.4, V6.4.6——V6.4.5
V6.5 Factor propertiesCSPRNG seeds/codes, protected recovery material, 30-second TOTP/server time, lockout and revocation; library does not evidence same-window TOTP replay storageV6.5.2, V6.5.3, V6.5.4, V6.5.5, V6.5.6, V6.5.8V6.5.1—V6.5.7
V6.6 Out-of-band/PSTNNo SMS, phone, email-code or push factor———V6.6.1, V6.6.2, V6.6.3, V6.6.4
V6.7 Cryptographic authenticatorNo hardware cryptographic authenticator———V6.7.1, V6.7.2
V6.8 Federated identityProvider+subject identity, asymmetric signature validation, verified-email admission and explicit linking; SSO MFA remains an operator assurance rather than claim-level enforcementV6.8.1, V6.8.2V6.8.4—V6.8.3

V7 Session management ​

SectionEvidence summaryPassPartialGapN/A
V7.1 DocumentationAbsolute/freshness/concurrency policy documented; provider session coordination remains experimentalV7.1.1, V7.1.2V7.1.3——
V7.2 Token creation/verificationBackend stateful CSPRNG reference sessions; new token on authenticationV7.2.1, V7.2.2, V7.2.3, V7.2.4———
V7.3 TimeoutsFixed 12-hour absolute limit, 30-minute server-enforced inactivity expiry and no sliding absolute refreshV7.3.1, V7.3.2———
V7.4 TerminationLogout/expiry/deletion/reset/revocation are immediate; self/admin controls and visible logoutV7.4.1, V7.4.2, V7.4.3, V7.4.4, V7.4.5———
V7.5 ReauthenticationCurrent password/MFA verification and fresh privileged actions; session termination uses freshness rather than an always-new promptV7.5.1, V7.5.3V7.5.2——
V7.6 FederationSession creation is user-initiated; provider logout/lifetime coordination needs provider testingV7.6.2V7.6.1——

V8 Authorization ​

SectionEvidence summaryPassPartialGapN/A
V8.1 DocumentationFunction/data/field/action rules and only contextual control (session freshness) are documentedV8.1.1, V8.1.2, V8.1.3, V8.1.4———
V8.2 EnforcementCentral 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 engineV8.2.1, V8.2.2, V8.2.3—V8.2.4—
V8.3 Trusted layer/immediacyServer-side DB membership on every operation; changes/revocations immediate; no privilege-bearing intermediaryV8.3.1, V8.3.2, V8.3.3———
V8.4 Multi-tenancy/adminIndependent cross-tenant enforcement; admin always has freshness and may have required MFA, but no continuous device/risk assessmentV8.4.1V8.4.2——

V9 Self-contained tokens ​

SectionEvidence summaryPassPartialGapN/A
V9.1 IntegrityApplication sessions are stateful; configured OIDC assertions use maintained issuer/signature/algorithm/key validationV9.1.1, V9.1.2, V9.1.3———
V9.2 ClaimsProvider tokens are checked for validity, type and audience by the protocol library; CapacityLens is not a token issuerV9.2.1, V9.2.2, V9.2.3——V9.2.4

V10 OAuth and OIDC ​

SectionEvidence summaryPassPartialGapN/A
V10.1 Token/client bindingProvider tokens stay server-side and are encrypted at rest; maintained clients provide state/nonce/transaction bindingV10.1.1, V10.1.2———
V10.2 Client flowsLibrary state/PKCE/mix-up defenses; least default scopesV10.2.1, V10.2.2, V10.2.3———
V10.3 Resource serverCapacityLens does not accept OAuth access tokens as an API resource server———V10.3.1, V10.3.2, V10.3.3, V10.3.4, V10.3.5
V10.4 Authorization serverCapacityLens is not an OAuth authorization server———V10.4.1, V10.4.2, V10.4.3, V10.4.4, V10.4.5, V10.4.6, V10.4.7, V10.4.8, V10.4.9, V10.4.10, V10.4.11, V10.4.12, V10.4.13, V10.4.14, V10.4.15, V10.4.16
V10.5 OIDC relying partyMaintained nonce/subject/issuer/audience validation; no back-channel logoutV10.5.1, V10.5.2, V10.5.3, V10.5.4——V10.5.5
V10.6 OpenID ProviderCapacityLens is not an OpenID Provider———V10.6.1, V10.6.2
V10.7 ConsentCapacityLens is not an authorization server managing third-party grants———V10.7.1, V10.7.2, V10.7.3

V11 Cryptography ​

SectionEvidence summaryPassPartialGapN/A
V11.1 Inventory/lifecycleRepository crypto inventory plus a gate-enforced automated implementation-path discovery check; deployment key rotation/PQC migration remain operator/planning workV11.1.2, V11.1.3V11.1.1, V11.1.4——
V11.2 Design/implementationNode/Web Crypto/Better Auth, ≥128-bit primitives and fail-closed errors; versioned formats but legacy hashes and library timing remainV11.2.1, V11.2.3, V11.2.5V11.2.2, V11.2.4——
V11.3 Symmetric encryptionAuthenticated offline AES-256-GCM plus Better Auth provider-token encryption; no separate cipher+MAC constructionV11.3.1, V11.3.2, V11.3.3, V11.3.4——V11.3.5
V11.4 Hash/KDFSHA-256 token digests, versioned OWASP scrypt and appropriate derived lengths; SHA-1 only for non-verifier HIBP protocolV11.4.1, V11.4.2, V11.4.3, V11.4.4———
V11.5 RandomnessPlatform CSPRNG with ≥128-bit security for tokens/keys and OS heavy-demand behaviorV11.5.1, V11.5.2———
V11.6 Key generation/exchangePlatform-approved generation and TLS exchange primitivesV11.6.1, V11.6.2———
V11.7 In-use dataData minimisation/short-lived values exist; no full-memory encryption and necessary plaintext exists while processing—V11.7.2V11.7.1—

V12 Secure communication ​

SectionEvidence summaryPassPartialGapN/A
V12.1 TLS configurationPublic TLS/version/ciphers are proxy/operator evidence; no mTLS client; OCSP/ECH not supplied by app—V12.1.1, V12.1.2V12.1.4, V12.1.5V12.1.3
V12.2 Public servicesDocumentation mandates public TLS/trusted certificates, but source review cannot verify a deployed endpoint—V12.2.1, V12.2.2——
V12.3 Other connectionsOutbound 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 externalV12.3.2V12.3.1, V12.3.3—V12.3.4, V12.3.5

V13 Configuration ​

SectionEvidence summaryPassPartialGapN/A
V13.1 Communication/resourcesCommunication inventory defines socket/service/work maxima, queue/timeout/refusal behavior and recovery; deployment certificate/credential rotation remains operator-specificV13.1.1, V13.1.2V13.1.3, V13.1.4——
V13.2 Backend communicationUnprivileged components, no defaults, fixed/configured outbound endpoints; network egress and connection policy require deployment controlsV13.2.2, V13.2.3V13.2.4, V13.2.5, V13.2.6—V13.2.1
V13.3 Secret managementDocs require secret manager/least privilege/rotation; env delivery is supported, not a vault/HSM or enforced expiry—V13.3.1, V13.3.2, V13.3.4V13.3.3—
V13.4 Production exposure.dockerignore, production-only dependencies, no debug/reset, no listing/TRACE, intentional health, no detailed backend versions, exact static-file handlingV13.4.1, V13.4.2, V13.4.3, V13.4.4, V13.4.5, V13.4.6, V13.4.7———

V14 Data protection ​

SectionEvidence summaryPassPartialGapN/A
V14.1 ClassificationPrivacy/control inventories identify data classes and application/operator protectionsV14.1.1, V14.1.2———
V14.2 Server-side protectionAPI no-store, no trackers, projection/minimisation and 404 file behavior; link tokens, deployment storage and retention remain partialV14.2.2, V14.2.3, V14.2.5, V14.2.6V14.2.1, V14.2.4, V14.2.7—V14.2.8
V14.3 Browser dataAPI no-store; logout clears offline data, but no universal Clear-Site-Data; encrypted opt-in tenant snapshots still reside in IndexedDBV14.3.2V14.3.1, V14.3.3——

V15 Secure coding and architecture ​

SectionEvidence summaryPassPartialGapN/A
V15.1 Documentation/inventoryRemediation policy, SBOM/third parties, expensive/risky/dangerous function inventoryV15.1.1, V15.1.2, V15.1.3, V15.1.4, V15.1.5———
V15.2 Components/resourcesAudits/scans, bounded heavy paths, minimal production graph with leak assertion, lockfile-recorded patch, non-root read-only containersV15.2.1, V15.2.2, V15.2.3, V15.2.4, V15.2.5———
V15.3 Defensive implementationOutput projection, no-redirect outbound call, allowlisted fields, trusted proxy, strict TS/types/prototype/parameter handlingV15.3.1, V15.3.2, V15.3.3, V15.3.4, V15.3.5, V15.3.6, V15.3.7———
V15.4 ConcurrencySQLite atomic checks; import workers receive structured clones, use bounded FIFO slots/deadlines/cancellation, and recheck tenant state before commitV15.4.2, V15.4.4——V15.4.1, V15.4.3

V16 Security logging and error handling ​

SectionEvidence summaryPassPartialGapN/A
V16.1 InventoryLayer/event/format/destination/sensitivity inventory; operator supplies exact retention/accessV16.1.1———
V16.2 Log contentUTC ISO metadata, documented JSON streams, correlation-ready data and credential/body redaction; clock synchronization is externalV16.2.1, V16.2.3, V16.2.4, V16.2.5V16.2.2——
V16.3 Security eventsAuth, bypass/control failures, queue saturation, SSO cutover/repair, operator recovery and unexpected errors logged; not every successful L3 decision is recordedV16.3.1, V16.3.3, V16.3.4V16.3.2——
V16.4 Log protectionJSON serialization prevents injection and local files have restrictive modes; external forwarding is optional and its ACL/immutability need operator evidenceV16.4.1V16.4.2, V16.4.3——
V16.5 Failure handlingGeneric 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 stateV16.5.1, V16.5.2, V16.5.3V16.5.4——

V17 WebRTC ​

SectionEvidence summaryPassPartialGapN/A
V17.1 TURNNo WebRTC/TURN———V17.1.1, V17.1.2
V17.2 MediaNo DTLS/SRTP/media server or recording———V17.2.1, V17.2.2, V17.2.3, V17.2.4, V17.2.5, V17.2.6, V17.2.7, V17.2.8
V17.3 SignalingNo WebRTC signaling server———V17.3.1, V17.3.2

Interpretation ​

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.

CapacityLens is open source under AGPL-3.0.

+
Skip to content

OWASP ASVS 5.0.0 complete control ledger ​

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.

V1 Encoding and sanitization ​

SectionEvidence summaryPassPartialGapN/A
V1.1 ArchitectureFastify parses once; shared sanitisation precedes domain use; React/JSON perform contextual output encodingV1.1.2V1.1.1——
V1.2 Injection preventionReact text nodes, encoded URL components, structured JSON, parameterized SQLite, fixed/bounded regexV1.2.1, V1.2.2, V1.2.3, V1.2.4, V1.2.9——V1.2.5, V1.2.6, V1.2.7, V1.2.8, V1.2.10
V1.3 SanitizationNo eval; context-specific codecs/lengths; operator-only HTTPS URL allow-list; bounded fixed regexV1.3.2, V1.3.3, V1.3.6, V1.3.12——V1.3.1, V1.3.4, V1.3.5, V1.3.7, V1.3.8, V1.3.9, V1.3.10, V1.3.11
V1.4 Memory/numeric safetyMemory-safe JS/TS runtime, bounded integer parsers and explicit shutdown/resource releaseV1.4.1, V1.4.2, V1.4.3———
V1.5 Safe parsingTyped JSON/object allow-listing; Node URL parser; no XMLV1.5.2V1.5.3—V1.5.1

V2 Validation and business logic ​

SectionEvidence summaryPassPartialGapN/A
V2.1 DocumentationAGENTS.md, DEFENSIVE-CODING.md, domain invariants and control inventory define shape/context/limitsV2.1.1, V2.1.2, V2.1.3———
V2.2 EnforcementServer/domain validation is authoritative; related entity/account/date/activity rules checkedV2.2.1, V2.2.2, V2.2.3———
V2.3 Flows/transactionsSetup/invite/MFA/link/cutover order, SQLite transactions, sync provenance, stale-import checks and atomic replacementV2.3.1, V2.3.2, V2.3.3——V2.3.4, V2.3.5
V2.4 Anti-automationAPI/health throttling and request/import/batch boundsV2.4.1——V2.4.2

V3 Web frontend security ​

SectionEvidence summaryPassPartialGapN/A
V3.1 Browser feature modelEvergreen-browser cross-browser suite and security headers; no full incompatible-browser block—V3.1.1——
V3.2 Rendering contextJSON MIME/nosniff/CORP plus React text rendering and TypeScript module scopeV3.2.1, V3.2.2, V3.2.3———
V3.3 CookiesHTTPS emits Secure, Path=/, domain-free __Host- cookies; HTTP loopback uses development names; SameSite=Lax, HttpOnly and bounded cookiesV3.3.1, V3.3.2, V3.3.3, V3.3.4, V3.3.5———
V3.4 Browser headersTwo-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 streamV3.4.1, V3.4.2, V3.4.4, V3.4.5, V3.4.6, V3.4.7, V3.4.8V3.4.3——
V3.5 Cross-origin controlsUnsafe Origin/Fetch-Metadata rejection, correct methods, no JSONP/script data, same-origin CORPV3.5.1, V3.5.2, V3.5.3, V3.5.6, V3.5.7, V3.5.8——V3.5.4, V3.5.5
V3.6 External assetsRuntime JS/CSS/fonts are self-hosted; no CDN runtime dependencyV3.6.1———
V3.7 Client behaviorSupported web platform only; external provider navigation is explicit/user-selected; preload/incompatible-browser behavior is deployment-dependentV3.7.1, V3.7.2, V3.7.3V3.7.4, V3.7.5——

V4 API and web service ​

SectionEvidence summaryPassPartialGapN/A
V4.1 HTTP useCorrect content types, TLS at public proxy, explicit methods; trusted forwarding depends on packaged/operator proxyV4.1.1, V4.1.2, V4.1.4V4.1.3—V4.1.5
V4.2 Message framingCurrent nginx/Fastify/Node framing; auth proxy strips length/transfer headers; provider output boundedV4.2.5V4.2.1, V4.2.2, V4.2.3, V4.2.4——
V4.3 GraphQLNo GraphQL endpoint———V4.3.1, V4.3.2
V4.4 WebSocketNo WebSocket endpoint———V4.4.1, V4.4.2, V4.4.3, V4.4.4

V5 File handling ​

SectionEvidence summaryPassPartialGapN/A
V5.1 DocumentationJSON import is the sole file-like input; type, 5 MiB and record limits documented/testedV5.1.1———
V5.2 Uploaded contentJSON content parsed/validated with body/record caps; no archives or images are accepted/storedV5.2.1, V5.2.2——V5.2.3, V5.2.4, V5.2.5, V5.2.6
V5.3 Storage/pathServer data/audit/backup paths are operator configuration, not user filenames; no public uploaded code/archiveV5.3.2——V5.3.1, V5.3.3
V5.4 DownloadsExport filename is internally generated and safe; no untrusted served filesV5.4.1, V5.4.2——V5.4.3

V6 Authentication ​

SectionEvidence summaryPassPartialGapN/A
V6.1 DocumentationAuth pathways, throttling/lockout, context words, password/MFA/SSO strength documentedV6.1.1, V6.1.2, V6.1.3———
V6.2 Passwords15–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 warningV6.2.1, V6.2.2, V6.2.3, V6.2.4, V6.2.5, V6.2.6, V6.2.7, V6.2.8, V6.2.9, V6.2.10, V6.2.11V6.2.12——
V6.3 Authentication controlsAPI 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 existV6.3.1, V6.3.2, V6.3.4, V6.3.6, V6.3.8V6.3.3V6.3.5, V6.3.7—
V6.4 RecoveryProduction 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 codeV6.4.1, V6.4.2, V6.4.3, V6.4.4, V6.4.6——V6.4.5
V6.5 Factor propertiesCSPRNG seeds/codes, protected recovery material, 30-second TOTP/server time, lockout and revocation; library does not evidence same-window TOTP replay storageV6.5.2, V6.5.3, V6.5.4, V6.5.5, V6.5.6, V6.5.8V6.5.1—V6.5.7
V6.6 Out-of-band/PSTNNo SMS, phone, email-code or push factor———V6.6.1, V6.6.2, V6.6.3, V6.6.4
V6.7 Cryptographic authenticatorNo hardware cryptographic authenticator———V6.7.1, V6.7.2
V6.8 Federated identityProvider+subject identity, asymmetric signature validation, verified-email admission and explicit linking; SSO MFA remains an operator assurance rather than claim-level enforcementV6.8.1, V6.8.2V6.8.4—V6.8.3

V7 Session management ​

SectionEvidence summaryPassPartialGapN/A
V7.1 DocumentationAbsolute/freshness/concurrency policy documented; provider session coordination remains experimentalV7.1.1, V7.1.2V7.1.3——
V7.2 Token creation/verificationBackend stateful CSPRNG reference sessions; new token on authenticationV7.2.1, V7.2.2, V7.2.3, V7.2.4———
V7.3 TimeoutsFixed 12-hour absolute limit, 30-minute server-enforced inactivity expiry and no sliding absolute refreshV7.3.1, V7.3.2———
V7.4 TerminationLogout/expiry/deletion/reset/revocation are immediate; self/admin controls and visible logoutV7.4.1, V7.4.2, V7.4.3, V7.4.4, V7.4.5———
V7.5 ReauthenticationCurrent password/MFA verification and fresh privileged actions; session termination uses freshness rather than an always-new promptV7.5.1, V7.5.3V7.5.2——
V7.6 FederationSession creation is user-initiated; provider logout/lifetime coordination needs provider testingV7.6.2V7.6.1——

V8 Authorization ​

SectionEvidence summaryPassPartialGapN/A
V8.1 DocumentationFunction/data/field/action rules and only contextual control (session freshness) are documentedV8.1.1, V8.1.2, V8.1.3, V8.1.4———
V8.2 EnforcementCentral 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 engineV8.2.1, V8.2.2, V8.2.3—V8.2.4—
V8.3 Trusted layer/immediacyServer-side DB membership on every operation; changes/revocations immediate; no privilege-bearing intermediaryV8.3.1, V8.3.2, V8.3.3———
V8.4 Multi-tenancy/adminIndependent cross-tenant enforcement; admin always has freshness and may have required MFA, but no continuous device/risk assessmentV8.4.1V8.4.2——

V9 Self-contained tokens ​

SectionEvidence summaryPassPartialGapN/A
V9.1 IntegrityApplication sessions are stateful; configured OIDC assertions use maintained issuer/signature/algorithm/key validationV9.1.1, V9.1.2, V9.1.3———
V9.2 ClaimsProvider tokens are checked for validity, type and audience by the protocol library; CapacityLens is not a token issuerV9.2.1, V9.2.2, V9.2.3——V9.2.4

V10 OAuth and OIDC ​

SectionEvidence summaryPassPartialGapN/A
V10.1 Token/client bindingProvider tokens stay server-side and are encrypted at rest; maintained clients provide state/nonce/transaction bindingV10.1.1, V10.1.2———
V10.2 Client flowsLibrary state/PKCE/mix-up defenses; least default scopesV10.2.1, V10.2.2, V10.2.3———
V10.3 Resource serverCapacityLens does not accept OAuth access tokens as an API resource server———V10.3.1, V10.3.2, V10.3.3, V10.3.4, V10.3.5
V10.4 Authorization serverCapacityLens is not an OAuth authorization server———V10.4.1, V10.4.2, V10.4.3, V10.4.4, V10.4.5, V10.4.6, V10.4.7, V10.4.8, V10.4.9, V10.4.10, V10.4.11, V10.4.12, V10.4.13, V10.4.14, V10.4.15, V10.4.16
V10.5 OIDC relying partyMaintained nonce/subject/issuer/audience validation; no back-channel logoutV10.5.1, V10.5.2, V10.5.3, V10.5.4——V10.5.5
V10.6 OpenID ProviderCapacityLens is not an OpenID Provider———V10.6.1, V10.6.2
V10.7 ConsentCapacityLens is not an authorization server managing third-party grants———V10.7.1, V10.7.2, V10.7.3

V11 Cryptography ​

SectionEvidence summaryPassPartialGapN/A
V11.1 Inventory/lifecycleRepository crypto inventory plus a gate-enforced automated implementation-path discovery check; deployment key rotation/PQC migration remain operator/planning workV11.1.2, V11.1.3V11.1.1, V11.1.4——
V11.2 Design/implementationNode/Web Crypto/Better Auth, ≥128-bit primitives and fail-closed errors; versioned formats but legacy hashes and library timing remainV11.2.1, V11.2.3, V11.2.5V11.2.2, V11.2.4——
V11.3 Symmetric encryptionAuthenticated offline AES-256-GCM plus Better Auth provider-token encryption; no separate cipher+MAC constructionV11.3.1, V11.3.2, V11.3.3, V11.3.4——V11.3.5
V11.4 Hash/KDFSHA-256 token digests, versioned OWASP scrypt and appropriate derived lengths; SHA-1 only for non-verifier HIBP protocolV11.4.1, V11.4.2, V11.4.3, V11.4.4———
V11.5 RandomnessPlatform CSPRNG with ≥128-bit security for tokens/keys and OS heavy-demand behaviorV11.5.1, V11.5.2———
V11.6 Key generation/exchangePlatform-approved generation and TLS exchange primitivesV11.6.1, V11.6.2———
V11.7 In-use dataData minimisation/short-lived values exist; no full-memory encryption and necessary plaintext exists while processing—V11.7.2V11.7.1—

V12 Secure communication ​

SectionEvidence summaryPassPartialGapN/A
V12.1 TLS configurationPublic TLS/version/ciphers are proxy/operator evidence; no mTLS client; OCSP/ECH not supplied by app—V12.1.1, V12.1.2V12.1.4, V12.1.5V12.1.3
V12.2 Public servicesDocumentation mandates public TLS/trusted certificates, but source review cannot verify a deployed endpoint—V12.2.1, V12.2.2——
V12.3 Other connectionsOutbound 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 externalV12.3.2V12.3.1, V12.3.3—V12.3.4, V12.3.5

V13 Configuration ​

SectionEvidence summaryPassPartialGapN/A
V13.1 Communication/resourcesCommunication inventory defines socket/service/work maxima, queue/timeout/refusal behavior and recovery; deployment certificate/credential rotation remains operator-specificV13.1.1, V13.1.2V13.1.3, V13.1.4——
V13.2 Backend communicationUnprivileged components, no defaults, fixed/configured outbound endpoints; network egress and connection policy require deployment controlsV13.2.2, V13.2.3V13.2.4, V13.2.5, V13.2.6—V13.2.1
V13.3 Secret managementDocs require secret manager/least privilege/rotation; env delivery is supported, not a vault/HSM or enforced expiry—V13.3.1, V13.3.2, V13.3.4V13.3.3—
V13.4 Production exposure.dockerignore, production-only dependencies, no debug/reset, no listing/TRACE, intentional health, no detailed backend versions, exact static-file handlingV13.4.1, V13.4.2, V13.4.3, V13.4.4, V13.4.5, V13.4.6, V13.4.7———

V14 Data protection ​

SectionEvidence summaryPassPartialGapN/A
V14.1 ClassificationPrivacy/control inventories identify data classes and application/operator protectionsV14.1.1, V14.1.2———
V14.2 Server-side protectionAPI no-store, no trackers, projection/minimisation and 404 file behavior; link tokens, deployment storage and retention remain partialV14.2.2, V14.2.3, V14.2.5, V14.2.6V14.2.1, V14.2.4, V14.2.7—V14.2.8
V14.3 Browser dataAPI no-store; logout clears offline data, but no universal Clear-Site-Data; encrypted opt-in tenant snapshots still reside in IndexedDBV14.3.2V14.3.1, V14.3.3——

V15 Secure coding and architecture ​

SectionEvidence summaryPassPartialGapN/A
V15.1 Documentation/inventoryRemediation policy, SBOM/third parties, expensive/risky/dangerous function inventoryV15.1.1, V15.1.2, V15.1.3, V15.1.4, V15.1.5———
V15.2 Components/resourcesAudits/scans, bounded heavy paths, minimal production graph with leak assertion, lockfile-recorded patch, non-root read-only containersV15.2.1, V15.2.2, V15.2.3, V15.2.4, V15.2.5———
V15.3 Defensive implementationOutput projection, no-redirect outbound call, allowlisted fields, trusted proxy, strict TS/types/prototype/parameter handlingV15.3.1, V15.3.2, V15.3.3, V15.3.4, V15.3.5, V15.3.6, V15.3.7———
V15.4 ConcurrencySQLite atomic checks; import workers receive structured clones, use bounded FIFO slots/deadlines/cancellation, and recheck tenant state before commitV15.4.2, V15.4.4——V15.4.1, V15.4.3

V16 Security logging and error handling ​

SectionEvidence summaryPassPartialGapN/A
V16.1 InventoryLayer/event/format/destination/sensitivity inventory; operator supplies exact retention/accessV16.1.1———
V16.2 Log contentUTC ISO metadata, documented JSON streams, correlation-ready data and credential/body redaction; clock synchronization is externalV16.2.1, V16.2.3, V16.2.4, V16.2.5V16.2.2——
V16.3 Security eventsAuth, bypass/control failures, queue saturation, SSO cutover/repair, operator recovery and unexpected errors logged; not every successful L3 decision is recordedV16.3.1, V16.3.3, V16.3.4V16.3.2——
V16.4 Log protectionJSON serialization prevents injection and local files have restrictive modes; external forwarding is optional and its ACL/immutability need operator evidenceV16.4.1V16.4.2, V16.4.3——
V16.5 Failure handlingGeneric 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 stateV16.5.1, V16.5.2, V16.5.3V16.5.4——

V17 WebRTC ​

SectionEvidence summaryPassPartialGapN/A
V17.1 TURNNo WebRTC/TURN———V17.1.1, V17.1.2
V17.2 MediaNo DTLS/SRTP/media server or recording———V17.2.1, V17.2.2, V17.2.3, V17.2.4, V17.2.5, V17.2.6, V17.2.7, V17.2.8
V17.3 SignalingNo WebRTC signaling server———V17.3.1, V17.3.2

Interpretation ​

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.

CapacityLens is open source under AGPL-3.0.

diff --git a/docs/security/privacy.html b/docs/security/privacy.html index 4dafb0094..ed80c9f6e 100644 --- a/docs/security/privacy.html +++ b/docs/security/privacy.html @@ -18,7 +18,7 @@ -
Skip to content

Privacy ​

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.

Data the application stores ​

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.

What stays in the browser ​

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.

Network behaviour ​

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.

Keeping and deleting data ​

  • 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.

Who is responsible for what ​

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.

What's next ​

Read the Security overview for the security defaults, or go to Reviews and compliance for the detailed evidence behind these claims.

CapacityLens is open source under AGPL-3.0.

+
Skip to content

Privacy ​

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.

Data the application stores ​

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.

What stays in the browser ​

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.

Network behaviour ​

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.

Keeping and deleting data ​

  • 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.

Who is responsible for what ​

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.

What's next ​

Read the Security overview for the security defaults, or go to Reviews and compliance for the detailed evidence behind these claims.

CapacityLens is open source under AGPL-3.0.

diff --git a/docs/security/reviews.html b/docs/security/reviews.html index e3309bf49..d842ec5d7 100644 --- a/docs/security/reviews.html +++ b/docs/security/reviews.html @@ -18,7 +18,7 @@ -
Skip to content

Reviews and compliance ​

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.

OWASP ASVS 5.0.0 ledger ​

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.

OpenSSF Baseline self-assessment ​

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.

Security review — 2026-08-18 ​

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.

Security review — 2026-07-14 ​

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.

Threat model ​

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.

Control inventories ​

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.

Mutation-test review — 2026-07-15 ​

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.

Mutation-test review — 2026-07-18 ​

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.

What's next ​

Report a vulnerability through the process in the security policy, or go back to the Security overview for the plain-language summary.

CapacityLens is open source under AGPL-3.0.

+
Skip to content

Reviews and compliance ​

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.

OWASP ASVS 5.0.0 ledger ​

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.

OpenSSF Baseline self-assessment ​

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.

Security review — 2026-08-18 ​

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.

Security review — 2026-07-14 ​

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.

Threat model ​

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.

Control inventories ​

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.

Mutation-test review — 2026-07-15 ​

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.

Mutation-test review — 2026-07-18 ​

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.

What's next ​

Report a vulnerability through the process in the security policy, or go back to the Security overview for the plain-language summary.

CapacityLens is open source under AGPL-3.0.

diff --git a/docs/security/security-review-2026-07-14.html b/docs/security/security-review-2026-07-14.html index 1858dfb3b..ff6f51492 100644 --- a/docs/security/security-review-2026-07-14.html +++ b/docs/security/security-review-2026-07-14.html @@ -18,7 +18,7 @@ -
Skip to content

Security review — 2026-07-14 (deployment posture updated 2026-07-15) ​

Executive conclusion ​

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.

Scope and method ​

Reviewed surfaces: React SPA, browser persistence/service worker, Fastify API, shared domain core, Better Auth password/MFA/social/OIDC integration, membership/tenant enforcement, SQLite schema and migrations, import/export, audit/logging, backups/restore, nginx, Docker/Compose, GitHub workflows, dependency graph and repository history.

Method:

  1. Manual data-flow, trust-boundary and authorization review against every ASVS 5.0.0 control.
  2. Threat-model review using attacker, tenant, operator, browser, provider and supply-chain abuse cases.
  3. Focused regression tests for each remediated control, followed by repository gates, cross-browser E2E, dependency/secret/container/DAST checks where locally available.
  4. Mapping to OWASP Top 10 (2021), OWASP API Security Top 10 (2023), ASVS levels and OWASP SAMM.
  5. 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.

Findings and current treatment ​

IDOriginal riskSeverityResolution
CL-01Production password mode could operate without a second factorCriticalRequired 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-02Password policy/storage did not meet current full OWASP guidanceHigh15–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-03Session lifetime, visibility and containment were incompleteHighFixed 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-04CORS alone was treated as the browser cross-site boundaryHighRoot hook now rejects unsafe disallowed Origin or cross-site Fetch Metadata requests; exact origin CORS remains additive defense
CL-05Offline tenant snapshots were plaintext in browser storageHighAES-256-GCM, non-extractable device key, random IV/AAD, tamper/expiry deletion, legacy plaintext wipe and viewer-only behavior
CL-06SSO-only production could silently inherit unknown single-factor assuranceHighSMALLSASS_ACCOUNT_SSO_MFA_ENFORCED=1 records tested IdP assurance; its absence warns instead of refusing startup
CL-07Provider/password-check URLs allowed unsafe configuration or redirect behaviorHighProvider endpoints require credential-free absolute HTTPS (loopback HTTP only in development); HIBP endpoint is fixed, time-bounded, no-redirect and fail-closed
CL-08Security events were not a complete, separately forwardable streamHighTyped security JSON plus mutation audit JSON exist; local audit remains required, while stdout/external forwarding is optional and absence is warned
CL-09Production could claim data protection without encrypted persistent storageHighThe storage attestation is documented as external evidence only; its absence now warns so simple self-hosting can use ordinary host storage
CL-10Database/audit/backup file modes inherited ambient host defaultsMediumProcess 0077 umask; 0600 database/WAL/SHM/audit/snapshot files; 0700 backup directory; tested
CL-11Deep health performed work proportional to tenant data and runtime integrity checksMediumStartup 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-12Sensitive API responses and token-bearing SPA routes had incomplete cache protectionMediumAll API responses are no-store; invite/reset routes are no-store and not access-logged; nonexistent file-like paths return 404
CL-13Baseline browser policy lacked stronger production directivesMediumTwo-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-14Headless production bootstrap created a strong but non-expiring initial passwordMediumHeadless/pinned bootstrap password paths are development-only; production must use setup-token signup where the owner chooses the final password
CL-15Release security checks did not cover the full public supply-chain pathHighCross-browser E2E, full-history secret scan, dependency review, SBOM, CodeQL, Trivy, ZAP, pinned actions/images and tagged provenance
CL-16Production images carried unnecessary package-manager, frontend/test and network-client code, including newly vulnerable transitive/base packagesHighDedicated 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-17Allocation validation treated an unresolved project on a project-bound activity as though the activity had no projectMediumMissing and cross-account projects now fail closed at the shared write boundary; inactive and unchanged-reference behavior is mutation-tested with adversarial cases
CL-18The pinned package manager's dependency-audit client used registry endpoints that had been retiredMediumpnpm 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-19The packaged nginx→API connection used plaintext HTTPMediumCompose 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-20CSP violations had no reporting destinationMediumLegacy/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-21Accepted sockets and memory-expensive password/security work lacked explicit process queuesHighAPI 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-22Packaged same-origin writes could be rejected when the redundant cross-origin allow-list was emptyMediumThe 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-23Mutation tooling resolved a newly disclosed remotely triggerable qs.stringify denial of serviceMediumThe 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-24Unhandled process exceptions/rejections could lose structured security diagnostics before supervisor recoveryMediumA 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

Residual risks and unmet controls ​

These are not concealed as “accepted passes.” Owners should reassess them when the deployment's data sensitivity or user population changes.

ResidualASVS impactCurrent treatmentRecommended trigger/action
Required MFA is optional; TOTP is phishableV6.3.3 L2/L3Password-only is supported with a warning; required TOTP meets L2 when enabled but is not phishing-resistantEnable required MFA for sensitive/public multi-user deployments; add WebAuthn/passkeys before high-assurance use
Breached-password screening can be disabledV6.2.12 L2HIBP k-anonymity checking remains default and fail-closed when enabled; off emits a warningDisable 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 rotationV11.2.2/V11.4.2Verify-only compatibility; all new/reset/change hashes use scrypt-v1Prompt or require password reset after upgrade if the old database may have been exposed
OIDC/social behavior is provider-dependentV6.8.4, V7.1.3/V7.6.1Strict 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 experimentalCapture 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 notificationV6.3.5/V6.3.7, V8.2.4/V8.4.2Opt-in required MFA, throttling, typed events, fresh privileged actions and operator alertsAdd risk engine/notifications for a public multi-organization SaaS footprint
Reset/invite bearer values appear in one-time link pathsV14.2.1No-referrer, no-store, access-log suppression, short expiry, hashing/use/revocationMove to a separate out-of-band code exchange if URL exposure is unacceptable
Browser offline data remains usable by compromised same-origin codeV14.3.3Opt-in, encrypted, non-extractable key, seven-day expiry, role filtered and read-onlyDisable offline mode for high-sensitivity tenants; enforce managed-device controls
Public/internal TLS evidence remains deployment-dependentV12.1/V12.2/V12.3.3Public TLS remains mandatory at the edge; Compose verifies internal TLS, while bare metal may use same-host loopback HTTPCapture scanner/proxy evidence; enable internal TLS when host isolation is insufficient
No HSM, full-memory encryption or PQC implementationV11 L3Standard platform crypto, versioned formats and an automated crypto-discovery inventoryRevisit 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 codeV11–V16Optional attestations plus startup warnings and operator guidanceAdd controls according to deployment sensitivity and verify them with infrastructure evidence
Single-process SQLite availability has a finite ceilingV2.4/V13/V15/V16.5.4512-socket ceiling, bounded scrypt/HIBP queues, throttling, bounded requests/imports, constant health, WAL/timeouts and fail-fast supervised recovery after an unhandled process faultAdd edge limits/monitoring; migrate architecture if measured load approaches limits

OWASP Top 10 (2021) ​

CategoryAssessmentPrincipal evidence or remaining issue
A01 Broken Access ControlStrongServer-side membership/action checks, field projection, fresh privileged actions, cross-tenant tests; adaptive contextual authorization remains out of scope
A02 Cryptographic FailuresStrong application controls / deployment-dependentscrypt, 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 InjectionStrongReact text rendering, no runtime eval/untrusted HTML, parameterized SQLite, explicit sanitisation/codecs, fixed/bounded regex and URLs
A04 Insecure DesignStrongThreat model, standing invariants, closed signup, single-company default, fail-closed production guard, atomic import and explicit residual-risk ledger
A05 Security MisconfigurationStrong defaultsNon-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 ComponentsAutomatedMinimal production graph, lockfile-recorded dependency patch, Dependabot, audit, dependency review, CodeQL, SBOM and Trivy; remediation timing is documented below
A07 Identification and Authentication FailuresConfigurable; below strict L2 by defaultHIBP defaults on and required TOTP is available, but both may be relaxed; scrypt, throttling, fixed/idle/revocable sessions and host-only cookies remain enforced
A08 Software and Data Integrity FailuresStrongLockfile, pinned actions/base images, atomic database operations, authenticated offline encryption, SBOM/provenance and full-history secret scan
A09 Security Logging and Monitoring FailuresStrong app / operator-dependentTyped auth/authz/CSRF/rate/error events and audit stream; collector alerts/retention must be verified externally
A10 Server-Side Request ForgeryStrongNo end-user URL fetch; fixed no-redirect HIBP URL; provider endpoints are operator-configured HTTPS URLs without credentials

OWASP API Security Top 10 (2023) ​

CategoryAssessment
API1 Broken Object Level AuthorizationAccount membership and object accountId are independently enforced on trusted server state; cross-account tests cover CRUD, import and session administration.
API2 Broken AuthenticationPassword/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 AuthorizationExplicit schemas/column codecs, protected-name field projection and preservation, and output minimisation defend both mass assignment and field disclosure.
API4 Unrestricted Resource ConsumptionBody/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 AuthorizationCentral action/role matrix and server authorization precede mutations; UI visibility is never the authority.
API6 Unrestricted Access to Sensitive Business FlowsSetup, invitation, reset, membership, import, purge and account operations are gated, rate limited, fresh-session protected where privileged and audited.
API7 Server Side Request ForgeryEnd users cannot supply fetch destinations; configured identity endpoints are HTTPS validated and the fixed HIBP request refuses redirects.
API8 Security MisconfigurationProduction 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 ManagementEntry-point, data, crypto, log and dependency inventories are versioned in docs/security; no undocumented versioned API exists.
API10 Unsafe Consumption of APIsHIBP 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.

OWASP SAMM view ​

Business functionCurrent maturity evidenceNext maturity step
GovernanceSecurity policy, full ASVS ledger, data/crypto/log/third-party inventory, public disclosure channelDefine deployment-specific risk owner, metrics and annual policy/exception review
DesignThreat model, architecture/tenant invariants, privacy and defensive-coding standardsAdd automated abuse-case review to major architecture changes and provider-specific assurance profiles
ImplementationShared validation core, code review gates, lockfile, secret scan, SAST, dependency review, SBOM/provenanceAdd signed container publication and enforced branch protections when public
VerificationUnit/integration/mutation/cross-browser E2E, authorization regressions, restore drill, container scan and ZAP; survivor triage is recorded in the mutation reviewCommission an independent authenticated penetration test against the release deployment
OperationsProduction guard, typed forwarding, restrictive files/containers, backup and incident runbooksExercise incident/log/restore procedures with the real collector, IdP and encrypted backup destination

Maintenance and remediation policy ​

  • 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.

Verification record ​

Verification completed on 2026-07-15 with the repository's pinned Node 24 runtime:

VerificationResult
pnpm run gate:serverPass: TypeScript, ESLint and 572 server tests across 37 files
pnpm run gatePass: 1,617 tests across 101 files; 84.13% statements, 78.22% branches, 86.33% functions and 86.42% lines; production build and bundle budget pass
pnpm run e2e:allPass: 525 tests—185 Chromium/database/auth, 170 isolated WebKit/Safari and 170 isolated Firefox/Gecko
pnpm run mutationPass: 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 auditPass: 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-lockfilePass: clean install from the frozen lockfile; pnpm's fail-closed lifecycle policy permits only esbuild's reviewed install script
Gitleaks 8.30.1Pass: all 16 Git commits and the final worktree; no leaks found
Docker production build/guard/smokePass: 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.0Pass: exact final API, web and internal-TLS initializer image archives contain no fixed HIGH or CRITICAL vulnerabilities
OWASP ZAP pinned baselinePass: 0 failures, 0 warnings, 4 reviewed informational classes and 63 passing checks
actionlint 1.7.12Pass: all GitHub workflow files
ASVS ledger reconciliationPass: all 345 official v5.0.0 IDs appear exactly once—199 Pass, 48 Partial, 7 Gap and 91 N/A
git diff --checkPass

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.

Addendum — 2026-07-17 ​

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 open source under AGPL-3.0.

+
Skip to content

Security review — 2026-07-14 (deployment posture updated 2026-07-15) ​

Executive conclusion ​

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.

Scope and method ​

Reviewed surfaces: React SPA, browser persistence/service worker, Fastify API, shared domain core, Better Auth password/MFA/social/OIDC integration, membership/tenant enforcement, SQLite schema and migrations, import/export, audit/logging, backups/restore, nginx, Docker/Compose, GitHub workflows, dependency graph and repository history.

Method:

  1. Manual data-flow, trust-boundary and authorization review against every ASVS 5.0.0 control.
  2. Threat-model review using attacker, tenant, operator, browser, provider and supply-chain abuse cases.
  3. Focused regression tests for each remediated control, followed by repository gates, cross-browser E2E, dependency/secret/container/DAST checks where locally available.
  4. Mapping to OWASP Top 10 (2021), OWASP API Security Top 10 (2023), ASVS levels and OWASP SAMM.
  5. 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.

Findings and current treatment ​

IDOriginal riskSeverityResolution
CL-01Production password mode could operate without a second factorCriticalRequired 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-02Password policy/storage did not meet current full OWASP guidanceHigh15–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-03Session lifetime, visibility and containment were incompleteHighFixed 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-04CORS alone was treated as the browser cross-site boundaryHighRoot hook now rejects unsafe disallowed Origin or cross-site Fetch Metadata requests; exact origin CORS remains additive defense
CL-05Offline tenant snapshots were plaintext in browser storageHighAES-256-GCM, non-extractable device key, random IV/AAD, tamper/expiry deletion, legacy plaintext wipe and viewer-only behavior
CL-06SSO-only production could silently inherit unknown single-factor assuranceHighSMALLSASS_ACCOUNT_SSO_MFA_ENFORCED=1 records tested IdP assurance; its absence warns instead of refusing startup
CL-07Provider/password-check URLs allowed unsafe configuration or redirect behaviorHighProvider endpoints require credential-free absolute HTTPS (loopback HTTP only in development); HIBP endpoint is fixed, time-bounded, no-redirect and fail-closed
CL-08Security events were not a complete, separately forwardable streamHighTyped security JSON plus mutation audit JSON exist; local audit remains required, while stdout/external forwarding is optional and absence is warned
CL-09Production could claim data protection without encrypted persistent storageHighThe storage attestation is documented as external evidence only; its absence now warns so simple self-hosting can use ordinary host storage
CL-10Database/audit/backup file modes inherited ambient host defaultsMediumProcess 0077 umask; 0600 database/WAL/SHM/audit/snapshot files; 0700 backup directory; tested
CL-11Deep health performed work proportional to tenant data and runtime integrity checksMediumStartup 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-12Sensitive API responses and token-bearing SPA routes had incomplete cache protectionMediumAll API responses are no-store; invite/reset routes are no-store and not access-logged; nonexistent file-like paths return 404
CL-13Baseline browser policy lacked stronger production directivesMediumTwo-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-14Headless production bootstrap created a strong but non-expiring initial passwordMediumHeadless/pinned bootstrap password paths are development-only; production must use setup-token signup where the owner chooses the final password
CL-15Release security checks did not cover the full public supply-chain pathHighCross-browser E2E, full-history secret scan, dependency review, SBOM, CodeQL, Trivy, ZAP, pinned actions/images and tagged provenance
CL-16Production images carried unnecessary package-manager, frontend/test and network-client code, including newly vulnerable transitive/base packagesHighDedicated 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-17Allocation validation treated an unresolved project on a project-bound activity as though the activity had no projectMediumMissing and cross-account projects now fail closed at the shared write boundary; inactive and unchanged-reference behavior is mutation-tested with adversarial cases
CL-18The pinned package manager's dependency-audit client used registry endpoints that had been retiredMediumpnpm 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-19The packaged nginx→API connection used plaintext HTTPMediumCompose 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-20CSP violations had no reporting destinationMediumLegacy/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-21Accepted sockets and memory-expensive password/security work lacked explicit process queuesHighAPI 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-22Packaged same-origin writes could be rejected when the redundant cross-origin allow-list was emptyMediumThe 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-23Mutation tooling resolved a newly disclosed remotely triggerable qs.stringify denial of serviceMediumThe 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-24Unhandled process exceptions/rejections could lose structured security diagnostics before supervisor recoveryMediumA 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

Residual risks and unmet controls ​

These are not concealed as “accepted passes.” Owners should reassess them when the deployment's data sensitivity or user population changes.

ResidualASVS impactCurrent treatmentRecommended trigger/action
Required MFA is optional; TOTP is phishableV6.3.3 L2/L3Password-only is supported with a warning; required TOTP meets L2 when enabled but is not phishing-resistantEnable required MFA for sensitive/public multi-user deployments; add WebAuthn/passkeys before high-assurance use
Breached-password screening can be disabledV6.2.12 L2HIBP k-anonymity checking remains default and fail-closed when enabled; off emits a warningDisable 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 rotationV11.2.2/V11.4.2Verify-only compatibility; all new/reset/change hashes use scrypt-v1Prompt or require password reset after upgrade if the old database may have been exposed
OIDC/social behavior is provider-dependentV6.8.4, V7.1.3/V7.6.1Strict 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 experimentalCapture 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 notificationV6.3.5/V6.3.7, V8.2.4/V8.4.2Opt-in required MFA, throttling, typed events, fresh privileged actions and operator alertsAdd risk engine/notifications for a public multi-organization SaaS footprint
Reset/invite bearer values appear in one-time link pathsV14.2.1No-referrer, no-store, access-log suppression, short expiry, hashing/use/revocationMove to a separate out-of-band code exchange if URL exposure is unacceptable
Browser offline data remains usable by compromised same-origin codeV14.3.3Opt-in, encrypted, non-extractable key, seven-day expiry, role filtered and read-onlyDisable offline mode for high-sensitivity tenants; enforce managed-device controls
Public/internal TLS evidence remains deployment-dependentV12.1/V12.2/V12.3.3Public TLS remains mandatory at the edge; Compose verifies internal TLS, while bare metal may use same-host loopback HTTPCapture scanner/proxy evidence; enable internal TLS when host isolation is insufficient
No HSM, full-memory encryption or PQC implementationV11 L3Standard platform crypto, versioned formats and an automated crypto-discovery inventoryRevisit 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 codeV11–V16Optional attestations plus startup warnings and operator guidanceAdd controls according to deployment sensitivity and verify them with infrastructure evidence
Single-process SQLite availability has a finite ceilingV2.4/V13/V15/V16.5.4512-socket ceiling, bounded scrypt/HIBP queues, throttling, bounded requests/imports, constant health, WAL/timeouts and fail-fast supervised recovery after an unhandled process faultAdd edge limits/monitoring; migrate architecture if measured load approaches limits

OWASP Top 10 (2021) ​

CategoryAssessmentPrincipal evidence or remaining issue
A01 Broken Access ControlStrongServer-side membership/action checks, field projection, fresh privileged actions, cross-tenant tests; adaptive contextual authorization remains out of scope
A02 Cryptographic FailuresStrong application controls / deployment-dependentscrypt, 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 InjectionStrongReact text rendering, no runtime eval/untrusted HTML, parameterized SQLite, explicit sanitisation/codecs, fixed/bounded regex and URLs
A04 Insecure DesignStrongThreat model, standing invariants, closed signup, single-company default, fail-closed production guard, atomic import and explicit residual-risk ledger
A05 Security MisconfigurationStrong defaultsNon-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 ComponentsAutomatedMinimal production graph, lockfile-recorded dependency patch, Dependabot, audit, dependency review, CodeQL, SBOM and Trivy; remediation timing is documented below
A07 Identification and Authentication FailuresConfigurable; below strict L2 by defaultHIBP defaults on and required TOTP is available, but both may be relaxed; scrypt, throttling, fixed/idle/revocable sessions and host-only cookies remain enforced
A08 Software and Data Integrity FailuresStrongLockfile, pinned actions/base images, atomic database operations, authenticated offline encryption, SBOM/provenance and full-history secret scan
A09 Security Logging and Monitoring FailuresStrong app / operator-dependentTyped auth/authz/CSRF/rate/error events and audit stream; collector alerts/retention must be verified externally
A10 Server-Side Request ForgeryStrongNo end-user URL fetch; fixed no-redirect HIBP URL; provider endpoints are operator-configured HTTPS URLs without credentials

OWASP API Security Top 10 (2023) ​

CategoryAssessment
API1 Broken Object Level AuthorizationAccount membership and object accountId are independently enforced on trusted server state; cross-account tests cover CRUD, import and session administration.
API2 Broken AuthenticationPassword/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 AuthorizationExplicit schemas/column codecs, protected-name field projection and preservation, and output minimisation defend both mass assignment and field disclosure.
API4 Unrestricted Resource ConsumptionBody/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 AuthorizationCentral action/role matrix and server authorization precede mutations; UI visibility is never the authority.
API6 Unrestricted Access to Sensitive Business FlowsSetup, invitation, reset, membership, import, purge and account operations are gated, rate limited, fresh-session protected where privileged and audited.
API7 Server Side Request ForgeryEnd users cannot supply fetch destinations; configured identity endpoints are HTTPS validated and the fixed HIBP request refuses redirects.
API8 Security MisconfigurationProduction 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 ManagementEntry-point, data, crypto, log and dependency inventories are versioned in docs/security; no undocumented versioned API exists.
API10 Unsafe Consumption of APIsHIBP 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.

OWASP SAMM view ​

Business functionCurrent maturity evidenceNext maturity step
GovernanceSecurity policy, full ASVS ledger, data/crypto/log/third-party inventory, public disclosure channelDefine deployment-specific risk owner, metrics and annual policy/exception review
DesignThreat model, architecture/tenant invariants, privacy and defensive-coding standardsAdd automated abuse-case review to major architecture changes and provider-specific assurance profiles
ImplementationShared validation core, code review gates, lockfile, secret scan, SAST, dependency review, SBOM/provenanceAdd signed container publication and enforced branch protections when public
VerificationUnit/integration/mutation/cross-browser E2E, authorization regressions, restore drill, container scan and ZAP; survivor triage is recorded in the mutation reviewCommission an independent authenticated penetration test against the release deployment
OperationsProduction guard, typed forwarding, restrictive files/containers, backup and incident runbooksExercise incident/log/restore procedures with the real collector, IdP and encrypted backup destination

Maintenance and remediation policy ​

  • 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.

Verification record ​

Verification completed on 2026-07-15 with the repository's pinned Node 24 runtime:

VerificationResult
pnpm run gate:serverPass: TypeScript, ESLint and 572 server tests across 37 files
pnpm run gatePass: 1,617 tests across 101 files; 84.13% statements, 78.22% branches, 86.33% functions and 86.42% lines; production build and bundle budget pass
pnpm run e2e:allPass: 525 tests—185 Chromium/database/auth, 170 isolated WebKit/Safari and 170 isolated Firefox/Gecko
pnpm run mutationPass: 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 auditPass: 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-lockfilePass: clean install from the frozen lockfile; pnpm's fail-closed lifecycle policy permits only esbuild's reviewed install script
Gitleaks 8.30.1Pass: all 16 Git commits and the final worktree; no leaks found
Docker production build/guard/smokePass: 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.0Pass: exact final API, web and internal-TLS initializer image archives contain no fixed HIGH or CRITICAL vulnerabilities
OWASP ZAP pinned baselinePass: 0 failures, 0 warnings, 4 reviewed informational classes and 63 passing checks
actionlint 1.7.12Pass: all GitHub workflow files
ASVS ledger reconciliationPass: all 345 official v5.0.0 IDs appear exactly once—199 Pass, 48 Partial, 7 Gap and 91 N/A
git diff --checkPass

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.

Addendum — 2026-07-17 ​

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 open source under AGPL-3.0.

diff --git a/docs/security/security-review-2026-08-18.html b/docs/security/security-review-2026-08-18.html index 0fde3c889..d24add2b1 100644 --- a/docs/security/security-review-2026-08-18.html +++ b/docs/security/security-review-2026-08-18.html @@ -18,7 +18,7 @@ -
Skip to content

Security review — 2026-08-18 ​

Executive conclusion ​

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.

Scope and method ​

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:

  1. Reconcile the threat model and control inventories with the current source and configuration.
  2. Review authentication, tenancy, destructive operations, concurrency, cryptography, deployment and supply-chain changes since the previous assessment.
  3. Reconcile every ASVS 5.0.0 identifier and update evidence or status where the implementation changed.
  4. Inspect the current Node 24 gate, account-boundary, migration, crash-durability, cross-browser, strict-OIDC, CodeQL, dependency, secret, container and OWASP ZAP evidence.
  5. Keep application guarantees separate from library guarantees and operator controls.

Delta findings and treatment ​

The original CL-01–CL-24 findings and their treatments remain recorded in the previous review. The alpha4 reassessment adds these findings.

IDFindingSeverityTreatment
CL-25The documented 30-minute inactivity limit had been ineffective because the session timestamp representation differedHighFixed 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-26Strict-OIDC discovery could accept a provider-advertised server endpoint on a private or reserved networkHighFixed 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-27OAuth access/refresh tokens were stored without application-layer encryption and implicit linking was enabledHighFixed 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-28The security documents omitted or misstated several implemented alpha4 limits and controlsLowResolved 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.

Material changes revalidated ​

  • 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.

OWASP mapping ​

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.

Residual risks ​

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 record ​

Verification applies to c49ff283951f5735b9239971dd005d42e78a0481, which contains 0.55.0-alpha.4 plus documentation-only installation-route separation.

VerificationResult
Application gatePass: typecheck, ESLint, 3,382 tests across 191 files, coverage thresholds and production build/bundle checks
Server gatePass: 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 E2EPass: 251 Chromium/database/auth, 227 Firefox, 227 WebKit and five strict-OIDC/Dex scenarios
CodeQL and dependency reviewPass on current main
Secret scan and SBOMPass: no full-history leaks; source SBOM generated
Container scansPass: zero fixed High or Critical findings in the API, web and internal-TLS initializer images
OWASP ZAPPass: 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 reconciliationPass: 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 is open source under AGPL-3.0.

+
Skip to content

Security review — 2026-08-18 ​

Executive conclusion ​

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.

Scope and method ​

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:

  1. Reconcile the threat model and control inventories with the current source and configuration.
  2. Review authentication, tenancy, destructive operations, concurrency, cryptography, deployment and supply-chain changes since the previous assessment.
  3. Reconcile every ASVS 5.0.0 identifier and update evidence or status where the implementation changed.
  4. Inspect the current Node 24 gate, account-boundary, migration, crash-durability, cross-browser, strict-OIDC, CodeQL, dependency, secret, container and OWASP ZAP evidence.
  5. Keep application guarantees separate from library guarantees and operator controls.

Delta findings and treatment ​

The original CL-01–CL-24 findings and their treatments remain recorded in the previous review. The alpha4 reassessment adds these findings.

IDFindingSeverityTreatment
CL-25The documented 30-minute inactivity limit had been ineffective because the session timestamp representation differedHighFixed 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-26Strict-OIDC discovery could accept a provider-advertised server endpoint on a private or reserved networkHighFixed 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-27OAuth access/refresh tokens were stored without application-layer encryption and implicit linking was enabledHighFixed 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-28The security documents omitted or misstated several implemented alpha4 limits and controlsLowResolved 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.

Material changes revalidated ​

  • 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.

OWASP mapping ​

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.

Residual risks ​

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 record ​

Verification applies to c49ff283951f5735b9239971dd005d42e78a0481, which contains 0.55.0-alpha.4 plus documentation-only installation-route separation.

VerificationResult
Application gatePass: typecheck, ESLint, 3,382 tests across 191 files, coverage thresholds and production build/bundle checks
Server gatePass: 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 E2EPass: 251 Chromium/database/auth, 227 Firefox, 227 WebKit and five strict-OIDC/Dex scenarios
CodeQL and dependency reviewPass on current main
Secret scan and SBOMPass: no full-history leaks; source SBOM generated
Container scansPass: zero fixed High or Critical findings in the API, web and internal-TLS initializer images
OWASP ZAPPass: 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 reconciliationPass: 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 is open source under AGPL-3.0.

diff --git a/docs/security/threat-model.html b/docs/security/threat-model.html index 1d7bf808a..ac8bb2792 100644 --- a/docs/security/threat-model.html +++ b/docs/security/threat-model.html @@ -18,7 +18,7 @@ -
Skip to content

CapacityLens threat model ​

Version: 2026-09-23. Review this model after changes to authentication, tenancy, imports, offline storage, deployment topology or external services.

Security objectives ​

  1. A user can read or change only accounts, operations, records and protected fields allowed by their current server-side membership and role.
  2. 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.
  3. Tenant writes remain valid, atomic and attributable; corrupted relational state prevents startup.
  4. Browser-delivered code cannot silently turn an authenticated browser into a cross-site write primitive, and sensitive API responses are not cached.
  5. 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.

Assets and trust boundaries ​

AssetPrimary protectionBoundary
Account schedule and private client/project namesServer membership, action/field authorization, SQLite constraintsBrowser/API and tenant boundary
Identity, password, MFA and provider-link stateBetter Auth, versioned scrypt, encrypted recovery/tokens, explicit verified linkingAuth/provider/API and database boundary
Session, reset and invite bearer valuesHttpOnly cookies or one-time values; hashes where supported; expiry/revocationBrowser/API and operator delivery boundary
Offline snapshotOpt-in, role-filtered, AES-256-GCM, seven-day expiry, viewer-onlyBrowser-origin/device boundary
Database, WAL, audit and snapshots0600 files, 0700 backup directory, optional encrypted-volume attestationProcess/host boundary
Audit and security eventsData-minimised JSON, local restrictive file plus optional separately forwarded streamProcess/log-collector boundary
Build and release inputsLockfile, pinned images/actions, dependency review, SBOM, scans and provenanceContributor/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.

Actors ​

  • 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.

Abuse cases and controls ​

ThreatPrincipal controlsVerification
BOLA/IDOR or cross-tenant mutationMembership 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 testsapp.authz, app.members, tenant-store, route and shared mutation tests
Function/field privilege escalationCentral action matrix; protected-name projection/preservation; owner-only import; fresh session for privileged actionsaccess, privacy and route tests
Credential stuffing/password crackingPositive 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 passwordpassword/auth/rate-limit tests
Password-only account takeoverOpt-in required TOTP wall before tenant data; otherwise long passwords, HIBP by default, scrypt, throttling and bounded/revocable sessions; one-time MFA recovery codesreal auth integration and UI tests
Session theft/fixationSecure HttpOnly SameSite __Host- cookies; new token on auth; fixed 12-hour and 30-minute idle limits; revocation/reset invalidation; session inventoryauth and member revocation tests
CSRF and cross-origin data useUnsafe-method Origin/Sec-Fetch-Site rejection; exact configured or trusted-proxy-derived same origin; SameSite cookie; safe HTTP methodsCSRF/CORS and packaged-proxy tests
Injection/XSS/mass assignmentReact text rendering; no untrusted HTML; parameterized SQLite; explicit table/column codecs; sanitisation and structural limits; CSPserver/shared/CSP tests
Malicious, stale or oversized importJSON-only, 5 MiB/200,000-record caps; Owner-only access; bounded/cancellable worker preparation; schema migration/sanitisation; tenant remap; reference validation; exact-snapshot recheck and atomic replacementimport, worker, transaction and mutation tests
Offline cache disclosure/tamperingRole-filtered input; non-extractable device key; AES-GCM with random IV/AAD; tamper/expiry deletion; viewer-onlyoffline cache tests
Database corruption/partial writeStartup foreign-key check; WAL; transactions; optimistic concurrency; sync-session ordering/provenance; atomic imports/backupsmigration, ordering, transaction and restore-drill tests
Log erasure/injection or invisible attackStructured serialization, no values/credentials, restrictive modes, health degradation latch and optional separate JSON forwardingaudit/log/production-guard tests
Provider substitution or invalid claimsGoogle/Microsoft provider-specific OAuth flow; Microsoft tenant, issuer, audience, expiry, identity and nonce checks; no email-based account mergingprovider callback and account-admission tests
Federated identity takeoverVerified matching email for explicit links; implicit linking disabled; provider/subject uniqueness; invitation/bootstrap admission; provider-required access checksidentity-port, onboarding and provider integration tests
Operator recovery misuseStopped server and exclusive SQLite lock; unique sole-Owner eligibility; ordinary expiring single-use reset; rollback on partial failure; token-free audit recordOwner-recovery CLI and audit tests
Resource exhaustion512 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 timeoutsresource-queue/rate-limit/health/import/CSP tests
Supply-chain compromiseExact lockfile, pinned action/base-image commits/digests, Dependabot, CodeQL, Gitleaks, dependency review, SBOM, Trivy, ZAP and tagged provenancelocal gates and public/manual workflows

Residual and accepted risks ​

  • 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 open source under AGPL-3.0.

+
Skip to content

CapacityLens threat model ​

Version: 2026-09-23. Review this model after changes to authentication, tenancy, imports, offline storage, deployment topology or external services.

Security objectives ​

  1. A user can read or change only accounts, operations, records and protected fields allowed by their current server-side membership and role.
  2. 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.
  3. Tenant writes remain valid, atomic and attributable; corrupted relational state prevents startup.
  4. Browser-delivered code cannot silently turn an authenticated browser into a cross-site write primitive, and sensitive API responses are not cached.
  5. 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.

Assets and trust boundaries ​

AssetPrimary protectionBoundary
Account schedule and private client/project namesServer membership, action/field authorization, SQLite constraintsBrowser/API and tenant boundary
Identity, password, MFA and provider-link stateBetter Auth, versioned scrypt, encrypted recovery/tokens, explicit verified linkingAuth/provider/API and database boundary
Session, reset and invite bearer valuesHttpOnly cookies or one-time values; hashes where supported; expiry/revocationBrowser/API and operator delivery boundary
Offline snapshotOpt-in, role-filtered, AES-256-GCM, seven-day expiry, viewer-onlyBrowser-origin/device boundary
Database, WAL, audit and snapshots0600 files, 0700 backup directory, optional encrypted-volume attestationProcess/host boundary
Audit and security eventsData-minimised JSON, local restrictive file plus optional separately forwarded streamProcess/log-collector boundary
Build and release inputsLockfile, pinned images/actions, dependency review, SBOM, scans and provenanceContributor/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.

Actors ​

  • 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.

Abuse cases and controls ​

ThreatPrincipal controlsVerification
BOLA/IDOR or cross-tenant mutationMembership 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 testsapp.authz, app.members, tenant-store, route and shared mutation tests
Function/field privilege escalationCentral action matrix; protected-name projection/preservation; owner-only import; fresh session for privileged actionsaccess, privacy and route tests
Credential stuffing/password crackingPositive 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 passwordpassword/auth/rate-limit tests
Password-only account takeoverOpt-in required TOTP wall before tenant data; otherwise long passwords, HIBP by default, scrypt, throttling and bounded/revocable sessions; one-time MFA recovery codesreal auth integration and UI tests
Session theft/fixationSecure HttpOnly SameSite __Host- cookies; new token on auth; fixed 12-hour and 30-minute idle limits; revocation/reset invalidation; session inventoryauth and member revocation tests
CSRF and cross-origin data useUnsafe-method Origin/Sec-Fetch-Site rejection; exact configured or trusted-proxy-derived same origin; SameSite cookie; safe HTTP methodsCSRF/CORS and packaged-proxy tests
Injection/XSS/mass assignmentReact text rendering; no untrusted HTML; parameterized SQLite; explicit table/column codecs; sanitisation and structural limits; CSPserver/shared/CSP tests
Malicious, stale or oversized importJSON-only, 5 MiB/200,000-record caps; Owner-only access; bounded/cancellable worker preparation; schema migration/sanitisation; tenant remap; reference validation; exact-snapshot recheck and atomic replacementimport, worker, transaction and mutation tests
Offline cache disclosure/tamperingRole-filtered input; non-extractable device key; AES-GCM with random IV/AAD; tamper/expiry deletion; viewer-onlyoffline cache tests
Database corruption/partial writeStartup foreign-key check; WAL; transactions; optimistic concurrency; sync-session ordering/provenance; atomic imports/backupsmigration, ordering, transaction and restore-drill tests
Log erasure/injection or invisible attackStructured serialization, no values/credentials, restrictive modes, health degradation latch and optional separate JSON forwardingaudit/log/production-guard tests
Provider substitution or invalid claimsGoogle/Microsoft provider-specific OAuth flow; Microsoft tenant, issuer, audience, expiry, identity and nonce checks; no email-based account mergingprovider callback and account-admission tests
Federated identity takeoverVerified matching email for explicit links; implicit linking disabled; provider/subject uniqueness; invitation/bootstrap admission; provider-required access checksidentity-port, onboarding and provider integration tests
Operator recovery misuseStopped server and exclusive SQLite lock; unique sole-Owner eligibility; ordinary expiring single-use reset; rollback on partial failure; token-free audit recordOwner-recovery CLI and audit tests
Resource exhaustion512 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 timeoutsresource-queue/rate-limit/health/import/CSP tests
Supply-chain compromiseExact lockfile, pinned action/base-image commits/digests, Dependabot, CodeQL, Gitleaks, dependency review, SBOM, Trivy, ZAP and tagged provenancelocal gates and public/manual workflows

Residual and accepted risks ​

  • 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 open source under AGPL-3.0.

diff --git a/docs/self-hosting/configuration.html b/docs/self-hosting/configuration.html index a0c514d30..d48f12863 100644 --- a/docs/self-hosting/configuration.html +++ b/docs/self-hosting/configuration.html @@ -18,7 +18,7 @@ -
Skip to content

Configuration ​

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_.

Listener and development settings ​

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.

VariableWhat it does
PORTListen port. Default 8787; invalid values outside the integer range 1–65,535 refuse startup.
CAPACITYLENS_HOSTListen host. Default 127.0.0.1; set 0.0.0.0 to expose the listener on the LAN or in a container.
CAPACITYLENS_ALLOW_RESETSet 1 to expose POST /api/test/reset for development and tests with sign-in off. Production refuses this setting.
CAPACITYLENS_OPTIMISTIC_CONCURRENCYEnabled by default. Set 0 only to allow stale writes to overwrite newer changes.
CAPACITYLENS_CREATE_ADMIN_ADMINDevelopment-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_PASSWORDRequired password for that development-only owner helper. For production, use the account setup token instead.

Sign-in mode ​

VariableWhat it does
SMALLSASS_ACCOUNT_MODEoff, 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_PROFILEAn optional named policy: self-hosted-password, self-hosted-mixed, self-hosted-sso-only or hosted-sso-only. Enforced at startup.
SMALLSASS_ACCOUNT_SECRETThe 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_URLThe exact browser-facing origin, for example https://capacity.example.com. Required for password or sso mode.
SMALLSASS_ACCOUNT_SETUP_TOKENThe 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_SIGNUPRe-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_PRODUCTIONDeliberately 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.

Passwords and multi-factor sign-in ​

VariableWhat it does
SMALLSASS_ACCOUNT_REQUIRE_MFASet 1 to require every password-mode teammate to enroll multi-factor sign-in before they can see company data.
SMALLSASS_ACCOUNT_PASSWORD_BREACH_CHECKOn by default: new passwords are checked against known breaches. Set off only for an isolated deployment that accepts the production warning.

Company login ​

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.

VariableWhat it does
SMALLSASS_ACCOUNT_GOOGLE_CLIENT_ID / SMALLSASS_ACCOUNT_GOOGLE_CLIENT_SECRETCredentials for a Google web application. Use an Internal audience restricted to your Workspace organisation.
SMALLSASS_ACCOUNT_MICROSOFT_CLIENT_ID / SMALLSASS_ACCOUNT_MICROSOFT_CLIENT_SECRETApplication ID and secret value from your Microsoft Entra app registration.
SMALLSASS_ACCOUNT_MICROSOFT_TENANT_IDRequired organisation tenant GUID. common, organizations, personal-account tenants and a missing value are refused.
SMALLSASS_ACCOUNT_PROVIDER_BOOTSTRAP_EMAILSComma-separated company email addresses allowed to create the first named-provider identity. Later new identities require an unused invitation addressed to them.
SMALLSASS_ACCOUNT_GITHUB_CLIENT_ID / SMALLSASS_ACCOUNT_GITHUB_CLIENT_SECRETOptional credentials for the existing experimental GitHub sign-in in mixed mode.
SMALLSASS_ACCOUNT_SSO_MFA_ENFORCEDOperator 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 verification email ​

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.

VariableWhat it does
SMALLSASS_ACCOUNT_MAIL_HOSTYour SMTP service hostname.
SMALLSASS_ACCOUNT_MAIL_PORTSMTP port. Port 465 uses implicit TLS; other ports require STARTTLS. Certificate verification remains enabled.
SMALLSASS_ACCOUNT_MAIL_USERSMTP authentication username.
SMALLSASS_ACCOUNT_MAIL_PASSWORDSMTP password or service credential. Keep it in the server's secret configuration.
SMALLSASS_ACCOUNT_MAIL_FROMA 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.

The database and backups ​

VariableWhat it does
CAPACITYLENS_DBPath 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_DIRDirectory for scheduled snapshots. On by default in Docker (/backups). Set it explicitly empty (CAPACITYLENS_BACKUP_DIR=) to turn scheduled backups off.
CAPACITYLENS_BACKUP_INTERVAL_MINMinutes between snapshots. Whole minutes, default 60; startup clamps over-maximum values to 35,000 with a warning.
CAPACITYLENS_BACKUP_KEEPHow 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_FILEPath 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_MBAudit 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.

Origin, CORS and proxy trust ​

VariableWhat it does
CAPACITYLENS_CORS_ORIGINComma-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_HTTPSSet 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_HEADERSTrusts 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.

VariableWhat it does
CAPACITYLENS_INTERNAL_TLS_CERTPEM certificate path for the internal reverse-proxy/API connection.
CAPACITYLENS_INTERNAL_TLS_KEYMatching 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_GENERATIONOptional SHA-256 marker for the exact loaded certificate.

See TLS and networking for the full picture.

Companies on this instance ​

VariableWhat it does
CAPACITYLENS_MULTI_ACCOUNTOff by default: one company per instance. Set 1 to allow more than one.
CAPACITYLENS_BOOTSTRAP_TOKENA 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_DEMOSeeds 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.

Health, logging and rate limiting ​

VariableWhat it does
CAPACITYLENS_LOGSet 1 for structured per-request JSON logs. Recommended for a real deployment.
CAPACITYLENS_HEALTH_DEEPSet 1 to make /api/health run a readiness query and report audit, backup and certificate status. Compose sets this by default.
CAPACITYLENS_RATE_LIMITRequests 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_STDOUTSet 1 to also write each audit record to stdout as JSON, for a container log collector. Compose defaults this on.
CAPACITYLENS_STORAGE_ENCRYPTEDSet 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_FORWARDINGAn 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 }.

See Monitoring and health checks for what to do with these.

What the web app is built with ​

VariableWhat it does
VITE_CAPACITYLENS_APIThe 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_DEMOSet 1 to build the in-memory demo instead of the real app. Wins over VITE_CAPACITYLENS_API if both are set.
VITE_CAPACITYLENS_BUILD_SHAOptional build identifier shown in Settings, typically the git commit.
VITE_CAPACITYLENS_FEEDBACK_MAILTOOptional 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.

Removed account variable names ​

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.

What's next ​

CapacityLens is open source under AGPL-3.0.

+
Skip to content

Configuration ​

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_.

Listener and development settings ​

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.

VariableWhat it does
PORTListen port. Default 8787; invalid values outside the integer range 1–65,535 refuse startup.
CAPACITYLENS_HOSTListen host. Default 127.0.0.1; set 0.0.0.0 to expose the listener on the LAN or in a container.
CAPACITYLENS_ALLOW_RESETSet 1 to expose POST /api/test/reset for development and tests with sign-in off. Production refuses this setting.
CAPACITYLENS_OPTIMISTIC_CONCURRENCYEnabled by default. Set 0 only to allow stale writes to overwrite newer changes.
CAPACITYLENS_CREATE_ADMIN_ADMINDevelopment-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_PASSWORDRequired password for that development-only owner helper. For production, use the account setup token instead.

Sign-in mode ​

VariableWhat it does
SMALLSASS_ACCOUNT_MODEoff, 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_PROFILEAn optional named policy: self-hosted-password, self-hosted-mixed, self-hosted-sso-only or hosted-sso-only. Enforced at startup.
SMALLSASS_ACCOUNT_SECRETThe 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_URLThe exact browser-facing origin, for example https://capacity.example.com. Required for password or sso mode.
SMALLSASS_ACCOUNT_SETUP_TOKENThe 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_SIGNUPRe-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_PRODUCTIONDeliberately 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.

Passwords and multi-factor sign-in ​

VariableWhat it does
SMALLSASS_ACCOUNT_REQUIRE_MFASet 1 to require every password-mode teammate to enroll multi-factor sign-in before they can see company data.
SMALLSASS_ACCOUNT_PASSWORD_BREACH_CHECKOn by default: new passwords are checked against known breaches. Set off only for an isolated deployment that accepts the production warning.

Company login ​

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.

VariableWhat it does
SMALLSASS_ACCOUNT_GOOGLE_CLIENT_ID / SMALLSASS_ACCOUNT_GOOGLE_CLIENT_SECRETCredentials for a Google web application. Use an Internal audience restricted to your Workspace organisation.
SMALLSASS_ACCOUNT_MICROSOFT_CLIENT_ID / SMALLSASS_ACCOUNT_MICROSOFT_CLIENT_SECRETApplication ID and secret value from your Microsoft Entra app registration.
SMALLSASS_ACCOUNT_MICROSOFT_TENANT_IDRequired organisation tenant GUID. common, organizations, personal-account tenants and a missing value are refused.
SMALLSASS_ACCOUNT_PROVIDER_BOOTSTRAP_EMAILSComma-separated company email addresses allowed to create the first named-provider identity. Later new identities require an unused invitation addressed to them.
SMALLSASS_ACCOUNT_GITHUB_CLIENT_ID / SMALLSASS_ACCOUNT_GITHUB_CLIENT_SECRETOptional credentials for the existing experimental GitHub sign-in in mixed mode.
SMALLSASS_ACCOUNT_SSO_MFA_ENFORCEDOperator 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 verification email ​

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.

VariableWhat it does
SMALLSASS_ACCOUNT_MAIL_HOSTYour SMTP service hostname.
SMALLSASS_ACCOUNT_MAIL_PORTSMTP port. Port 465 uses implicit TLS; other ports require STARTTLS. Certificate verification remains enabled.
SMALLSASS_ACCOUNT_MAIL_USERSMTP authentication username.
SMALLSASS_ACCOUNT_MAIL_PASSWORDSMTP password or service credential. Keep it in the server's secret configuration.
SMALLSASS_ACCOUNT_MAIL_FROMA 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.

The database and backups ​

VariableWhat it does
CAPACITYLENS_DBPath 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_DIRDirectory for scheduled snapshots. On by default in Docker (/backups). Set it explicitly empty (CAPACITYLENS_BACKUP_DIR=) to turn scheduled backups off.
CAPACITYLENS_BACKUP_INTERVAL_MINMinutes between snapshots. Whole minutes, default 60; startup clamps over-maximum values to 35,000 with a warning.
CAPACITYLENS_BACKUP_KEEPHow 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_FILEPath 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_MBAudit 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.

Origin, CORS and proxy trust ​

VariableWhat it does
CAPACITYLENS_CORS_ORIGINComma-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_HTTPSSet 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_HEADERSTrusts 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.

VariableWhat it does
CAPACITYLENS_INTERNAL_TLS_CERTPEM certificate path for the internal reverse-proxy/API connection.
CAPACITYLENS_INTERNAL_TLS_KEYMatching 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_GENERATIONOptional SHA-256 marker for the exact loaded certificate.

See TLS and networking for the full picture.

Companies on this instance ​

VariableWhat it does
CAPACITYLENS_MULTI_ACCOUNTOff by default: one company per instance. Set 1 to allow more than one.
CAPACITYLENS_BOOTSTRAP_TOKENA 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_DEMOSeeds 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.

Health, logging and rate limiting ​

VariableWhat it does
CAPACITYLENS_LOGSet 1 for structured per-request JSON logs. Recommended for a real deployment.
CAPACITYLENS_HEALTH_DEEPSet 1 to make /api/health run a readiness query and report audit, backup and certificate status. Compose sets this by default.
CAPACITYLENS_RATE_LIMITRequests 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_STDOUTSet 1 to also write each audit record to stdout as JSON, for a container log collector. Compose defaults this on.
CAPACITYLENS_STORAGE_ENCRYPTEDSet 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_FORWARDINGAn 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 }.

See Monitoring and health checks for what to do with these.

What the web app is built with ​

VariableWhat it does
VITE_CAPACITYLENS_APIThe 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_DEMOSet 1 to build the in-memory demo instead of the real app. Wins over VITE_CAPACITYLENS_API if both are set.
VITE_CAPACITYLENS_BUILD_SHAOptional build identifier shown in Settings, typically the git commit.
VITE_CAPACITYLENS_FEEDBACK_MAILTOOptional 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.

Removed account variable names ​

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.

What's next ​

CapacityLens is open source under AGPL-3.0.

diff --git a/package.json b/package.json index 92a16fb37..35b837972 100644 --- a/package.json +++ b/package.json @@ -79,6 +79,7 @@ "gate:all": "node scripts/with-lane.mjs node scripts/run-gate.mjs all", "mutation": "pnpm run paraglide:compile && stryker run", "rehearse:migrations": "pnpm --filter capacitylens-server rehearse:migrations", + "smoke:packaged-server": "node scripts/smoke-packaged-server.mjs", "test:server": "pnpm --filter capacitylens-server test", "gate:server": "node scripts/with-lane.mjs node scripts/run-gate.mjs server", "policy:sonner-csp:test": "node --test scripts/check-sonner-csp.test.mjs", @@ -90,6 +91,7 @@ "policy:typecheck-graph:test": "node --test scripts/check-typecheck-graph.test.mjs", "policy:build-tsconfig:test": "node --test scripts/check-build-tsconfig.test.mjs", "policy:script-environments:test": "node --test scripts/check-script-environments.test.mjs", + "policy:packaged-smoke:test": "node --test scripts/smoke-packaged-server.test.mjs", "policy:lint-coverage:test": "node --test scripts/check-lint-coverage.test.mjs" }, "lint-staged": { diff --git a/scripts/smoke-packaged-server.mjs b/scripts/smoke-packaged-server.mjs new file mode 100644 index 000000000..688626b95 --- /dev/null +++ b/scripts/smoke-packaged-server.mjs @@ -0,0 +1,257 @@ +import assert from "node:assert/strict"; +import { spawn } from "node:child_process"; +import { once } from "node:events"; +import { cp, mkdir, mkdtemp, readdir, rm, stat, writeFile } from "node:fs/promises"; +import { createServer } from "node:net"; +import { tmpdir } from "node:os"; +import { fileURLToPath } from "node:url"; +import { join, resolve } from "node:path"; +import { DatabaseSync } from "node:sqlite"; +import { setTimeout as delay } from "node:timers/promises"; + +const STARTUP_TIMEOUT_MS = 20_000; +const REQUEST_TIMEOUT_MS = 10_000; +const PERIODIC_TIMEOUT_MS = 80_000; +const SHUTDOWN_TIMEOUT_MS = 10_000; +const POLL_INTERVAL_MS = 250; +const PERIODIC_RESOURCE_NAME = "Bruce Wayne Periodic Backup Check"; + +function errorMessage(error) { + return error instanceof Error ? error.message : String(error); +} + +async function reservePort() { + const server = createServer(); + server.listen(0, "127.0.0.1"); + await once(server, "listening"); + const address = server.address(); + assert.ok(address && typeof address !== "string", "Could not reserve a loopback port."); + await new Promise((resolveClose, rejectClose) => + server.close((error) => (error ? rejectClose(error) : resolveClose())), + ); + return address.port; +} + +async function waitFor({ description, timeoutMs, check, childOutcome }) { + const deadline = Date.now() + timeoutMs; + let lastError; + while (Date.now() < deadline) { + const outcome = childOutcome(); + if (outcome) { + throw new Error( + outcome.error + ? `The packaged server could not start: ${errorMessage(outcome.error)}` + : `The packaged server exited early (${outcome.signal ?? `code ${outcome.code}`}).`, + ); + } + try { + const result = await check(); + if (result) return result; + } catch (error) { + lastError = error; + } + await delay(POLL_INTERVAL_MS); + } + const detail = lastError ? ` Last error: ${errorMessage(lastError)}` : ""; + throw new Error(`Timed out waiting for ${description}.${detail}`); +} + +export function buildSmokeEnvironment(inherited, { backupDir, databasePath, port }) { + const environment = { ...inherited }; + for (const key of Object.keys(environment)) { + if ( + key.startsWith("SMALLSASS_ACCOUNT_") || + key.startsWith("CAPACITYLENS_") || + key.startsWith("BETTER_AUTH_") || + key.startsWith("VITE_CAPACITYLENS_") + ) { + delete environment[key]; + } + } + return Object.assign(environment, { + NODE_ENV: "test", + SMALLSASS_ACCOUNT_MODE: "off", + CAPACITYLENS_AUDIT: "off", + CAPACITYLENS_SEED_DEMO: "1", + CAPACITYLENS_HEALTH_DEEP: "1", + CAPACITYLENS_HTTPS: "0", + CAPACITYLENS_DB: databasePath, + CAPACITYLENS_BACKUP_DIR: backupDir, + CAPACITYLENS_BACKUP_INTERVAL_MIN: "1", + CAPACITYLENS_HOST: "127.0.0.1", + PORT: String(port), + }); +} + +async function fetchJson(url, options = {}) { + const response = await fetch(url, { ...options, signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS) }); + const body = await response.text(); + assert.ok(response.ok, `${options.method ?? "GET"} ${url} returned ${response.status}: ${body}`); + return JSON.parse(body); +} + +async function snapshotFiles(backupDir) { + try { + return (await readdir(backupDir)).filter((name) => name.endsWith(".db")).sort(); + } catch (error) { + if (error && typeof error === "object" && "code" in error && error.code === "ENOENT") return []; + throw error; + } +} + +function inspectSnapshot(path, expectedResourceName) { + const database = new DatabaseSync(path, { readOnly: true }); + try { + assert.equal(database.prepare("PRAGMA integrity_check").get().integrity_check, "ok"); + const resources = database.prepare("SELECT COUNT(*) AS count FROM resources").get().count; + assert.ok(resources > 0, "The packaged snapshot contains no seeded resources."); + if (expectedResourceName) { + const matching = database + .prepare("SELECT COUNT(*) AS count FROM resources WHERE name = ?") + .get(expectedResourceName).count; + assert.equal(matching, 1, `The periodic snapshot does not contain ${expectedResourceName}.`); + } + return resources; + } finally { + database.close(); + } +} + +async function terminate(child, exitPromise) { + if (child.exitCode !== null || child.signalCode !== null) return exitPromise; + child.kill("SIGTERM"); + let timer; + const timeout = new Promise((resolveTimeout) => { + timer = setTimeout(() => resolveTimeout(null), SHUTDOWN_TIMEOUT_MS); + timer.unref(); + }); + const graceful = await Promise.race([exitPromise, timeout]); + clearTimeout(timer); + if (graceful) return graceful; + child.kill("SIGKILL"); + await exitPromise; + throw new Error("The packaged server did not stop within 10 seconds of SIGTERM."); +} + +async function preserveFailureArtifacts(sourceDir, log, artifactDir) { + if (!artifactDir) return; + await mkdir(artifactDir, { recursive: true }); + await writeFile(join(artifactDir, "packaged-server.log"), log); + await cp(sourceDir, join(artifactDir, "packaged-server-state"), { recursive: true, force: true }); +} + +async function main() { + const deploymentDir = process.argv.slice(2).find((argument) => argument !== "--"); + if (!deploymentDir) throw new Error("Usage: pnpm run smoke:packaged-server "); + const entrypoint = resolve(deploymentDir, "dist/index.mjs"); + await stat(entrypoint); + const port = await reservePort(); + const workDir = await mkdtemp(join(tmpdir(), "capacitylens-packaged-smoke-")); + const backupDir = join(workDir, "backups"); + let log = ""; + let passed = false; + + const child = spawn(process.execPath, [entrypoint], { + cwd: deploymentDir, + env: buildSmokeEnvironment(process.env, { + backupDir, + databasePath: join(workDir, "capacitylens.db"), + port, + }), + stdio: ["ignore", "pipe", "pipe"], + }); + child.stdout.setEncoding("utf8"); + child.stderr.setEncoding("utf8"); + child.stdout.on("data", (chunk) => (log += chunk)); + child.stderr.on("data", (chunk) => (log += chunk)); + let childOutcome = null; + const exitPromise = new Promise((resolveExit) => { + child.once("error", (error) => { + childOutcome = { code: null, signal: null, error }; + resolveExit(childOutcome); + }); + child.once("exit", (code, signal) => { + childOutcome = { code, signal, error: null }; + resolveExit(childOutcome); + }); + }); + const baseUrl = `http://127.0.0.1:${port}`; + + try { + await waitFor({ + description: "the packaged server health endpoint", + timeoutMs: STARTUP_TIMEOUT_MS, + check: async () => { + const response = await fetch(`${baseUrl}/api/health`, { signal: AbortSignal.timeout(1_000) }); + return response.ok; + }, + childOutcome: () => childOutcome, + }); + const startupFiles = await waitFor({ + description: "the startup snapshot", + timeoutMs: STARTUP_TIMEOUT_MS, + check: async () => { + const files = await snapshotFiles(backupDir); + return files.length > 0 ? files : null; + }, + childOutcome: () => childOutcome, + }); + const seededResources = inspectSnapshot(join(backupDir, startupFiles.at(-1))); + + const state = await fetchJson(`${baseUrl}/api/state?accountId=a-studio`); + assert.ok(Array.isArray(state.resources) && state.resources.length > 0, "Seed data has no resources to import."); + state.resources[0].name = PERIODIC_RESOURCE_NAME; + const importResult = await fetchJson(`${baseUrl}/api/import`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ accountId: "a-studio", data: state }), + }); + assert.ok(importResult.imported > 0, "The deployed import worker imported no rows."); + + const periodicFiles = await waitFor({ + description: "a periodic snapshot after the import", + timeoutMs: PERIODIC_TIMEOUT_MS, + check: async () => { + const files = await snapshotFiles(backupDir); + return files.length > startupFiles.length ? files : null; + }, + childOutcome: () => childOutcome, + }); + inspectSnapshot(join(backupDir, periodicFiles.at(-1)), PERIODIC_RESOURCE_NAME); + + const result = await terminate(child, exitPromise); + assert.deepEqual( + result, + { code: 0, signal: null, error: null }, + `Unexpected packaged server exit: ${JSON.stringify(result)}`, + ); + passed = true; + console.log( + JSON.stringify({ + node: process.version, + importWorker: "passed", + startupSnapshot: "passed", + periodicSnapshot: "passed", + seededResources: Number(seededResources), + shutdown: "clean", + }), + ); + } finally { + try { + if (child.exitCode === null && child.signalCode === null) await terminate(child, exitPromise); + } catch (error) { + log += `\nSmoke cleanup error: ${errorMessage(error)}\n`; + } + if (!passed) { + console.error(`Packaged server output:\n${log || "(no output)"}`); + try { + await preserveFailureArtifacts(workDir, log, process.env.CAPACITYLENS_SMOKE_ARTIFACT_DIR); + } catch (error) { + console.error(`Could not preserve packaged smoke artifacts: ${errorMessage(error)}`); + } + } + await rm(workDir, { recursive: true, force: true }); + } +} + +if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) await main(); diff --git a/scripts/smoke-packaged-server.test.mjs b/scripts/smoke-packaged-server.test.mjs new file mode 100644 index 000000000..05501b5f9 --- /dev/null +++ b/scripts/smoke-packaged-server.test.mjs @@ -0,0 +1,49 @@ +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import test from "node:test"; +import { fileURLToPath } from "node:url"; +import { buildSmokeEnvironment } from "./smoke-packaged-server.mjs"; + +test("packaged smoke strips deployment settings before applying its loopback fixture", () => { + const inherited = { + PATH: "/tools", + CAPACITYLENS_HOST: "0.0.0.0", + CAPACITYLENS_INTERNAL_TLS_KEY: "/private/key", + SMALLSASS_ACCOUNT_OIDC_DISCOVERY_URL: "https://identity.example.test", + BETTER_AUTH_SECRET: "inherited", + VITE_CAPACITYLENS_API: "https://api.example.test", + }; + const environment = buildSmokeEnvironment(inherited, { + backupDir: "/tmp/backups", + databasePath: "/tmp/state.db", + port: 43210, + }); + + assert.equal(environment.PATH, "/tools"); + assert.equal(environment.CAPACITYLENS_HOST, "127.0.0.1"); + assert.equal(environment.SMALLSASS_ACCOUNT_MODE, "off"); + assert.equal(environment.CAPACITYLENS_INTERNAL_TLS_KEY, undefined); + assert.equal(environment.SMALLSASS_ACCOUNT_OIDC_DISCOVERY_URL, undefined); + assert.equal(environment.BETTER_AUTH_SECRET, undefined); + assert.equal(environment.VITE_CAPACITYLENS_API, undefined); +}); + +test("packaged smoke reports an early deployed-server exit without leaving its startup poll alive", () => { + const deploymentDir = mkdtempSync(join(tmpdir(), "capacitylens-broken-deployment-")); + mkdirSync(join(deploymentDir, "dist")); + writeFileSync(join(deploymentDir, "dist/index.mjs"), 'throw new Error("fixture startup failure");\n'); + const script = fileURLToPath(new URL("./smoke-packaged-server.mjs", import.meta.url)); + const startedAt = Date.now(); + try { + const result = spawnSync(process.execPath, [script, deploymentDir], { encoding: "utf8", timeout: 5_000 }); + assert.notEqual(result.status, 0); + assert.match(result.stderr, /exited early/); + assert.match(result.stderr, /fixture startup failure/); + assert.ok(Date.now() - startedAt < 2_000, "Early server exit left the startup poll alive."); + } finally { + rmSync(deploymentDir, { recursive: true, force: true }); + } +}); diff --git a/src/test/setup.ts b/src/test/setup.ts index 676f0e1d3..1fc56bdf5 100644 --- a/src/test/setup.ts +++ b/src/test/setup.ts @@ -27,24 +27,43 @@ class MemoryStorage implements Storage { // Node >=25 exposes an experimental global localStorage accessor that resolves to undefined unless // the process receives --localstorage-file, and under a jsdom opaque origin `window.localStorage` // itself comes through as undefined — so the value captured here must never be trusted blindly. -// Fall back to an in-memory Storage whenever the environment's own storage is missing or unusable, -// pinning the globals so tests that intercept Storage.prototype still exercise their -// quota/SecurityError paths when a real storage exists. -function usableStorage(candidate: unknown): Storage { - return candidate && typeof (candidate as Storage).getItem === "function" - ? (candidate as Storage) - : new MemoryStorage(); +function readBrowserStorage(getter: () => Storage): Storage | undefined { + try { + return getter(); + } catch (error) { + if (error instanceof DOMException && error.name === "SecurityError") return undefined; + throw error; + } +} + +// Node 26 can expose Storage globals whose instances do not share Storage.prototype with jsdom. +// Use independent memory stores in that case so prototype spies observe both browser boundaries. +const storagePrototype = typeof Storage === "function" ? Storage.prototype : undefined; +const candidateLocalStorage = typeof window === "undefined" ? undefined : readBrowserStorage(() => window.localStorage); +const candidateSessionStorage = + typeof window === "undefined" ? undefined : readBrowserStorage(() => window.sessionStorage); +const useMemoryStorage = [candidateLocalStorage, candidateSessionStorage].some( + (candidate) => + !candidate || typeof candidate.getItem !== "function" || Object.getPrototypeOf(candidate) !== storagePrototype, +); +if (useMemoryStorage) { + const memoryStorageDescriptor = { configurable: true, value: MemoryStorage }; + Object.defineProperty(globalThis, "Storage", memoryStorageDescriptor); + if (typeof window !== "undefined" && window !== globalThis) { + Object.defineProperty(window, "Storage", memoryStorageDescriptor); + } +} +const localStorageValue = useMemoryStorage ? new MemoryStorage() : candidateLocalStorage; +const sessionStorageValue = useMemoryStorage ? new MemoryStorage() : candidateSessionStorage; +const storageDescriptors = { + localStorage: { configurable: true, value: localStorageValue }, + sessionStorage: { configurable: true, value: sessionStorageValue }, +}; + +Object.defineProperties(globalThis, storageDescriptors); +if (useMemoryStorage && typeof window !== "undefined" && window !== globalThis) { + Object.defineProperties(window, storageDescriptors); } -Object.defineProperties(globalThis, { - localStorage: { - configurable: true, - value: usableStorage(typeof window === "undefined" ? undefined : window.localStorage), - }, - sessionStorage: { - configurable: true, - value: usableStorage(typeof window === "undefined" ? undefined : window.sessionStorage), - }, -}); // jsdom ships neither of these browser APIs, but cmdk (the command-palette engine) hard-depends on // both: CommandList observes its size via ResizeObserver, and the active item is scrolled into view. diff --git a/src/test/storageCompatibility.test.ts b/src/test/storageCompatibility.test.ts new file mode 100644 index 000000000..71015a1cd --- /dev/null +++ b/src/test/storageCompatibility.test.ts @@ -0,0 +1,36 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +describe("browser storage test harness", () => { + afterEach(() => { + localStorage.clear(); + sessionStorage.clear(); + vi.restoreAllMocks(); + }); + + it("keeps local and session storage independent while sharing Storage.prototype", () => { + const localSetItem = vi.spyOn(Storage.prototype, "setItem"); + const sessionGetItem = vi.spyOn(Storage.prototype, "getItem"); + + expect(window.localStorage).toBe(localStorage); + expect(window.sessionStorage).toBe(sessionStorage); + expect(window.Storage).toBe(Storage); + + localStorage.setItem("storage-harness-local", "local"); + sessionStorage.setItem("storage-harness-session", "session"); + + expect(localStorage.getItem("storage-harness-session")).toBeNull(); + expect(sessionStorage.getItem("storage-harness-local")).toBeNull(); + expect(localStorage.getItem("storage-harness-local")).toBe("local"); + expect(sessionStorage.getItem("storage-harness-session")).toBe("session"); + expect(localSetItem).toHaveBeenCalledTimes(2); + expect(sessionGetItem).toHaveBeenCalled(); + const storageError = new Error("window storage blocked"); + localSetItem.mockImplementationOnce(() => { + throw storageError; + }); + expect(() => window.localStorage.setItem("storage-harness-window", "blocked")).toThrow(storageError); + expect(localSetItem).toHaveBeenCalledTimes(3); + expect(Object.getPrototypeOf(localStorage)).toBe(Storage.prototype); + expect(Object.getPrototypeOf(sessionStorage)).toBe(Storage.prototype); + }); +});