From c87660b920f75a29ab0e0cfef822fc274fb78efa Mon Sep 17 00:00:00 2001 From: Kevinjohn Gallagher Date: Fri, 11 Sep 2026 00:02:53 +0100 Subject: [PATCH 1/2] Prepare experimental Node 26 compatibility coverage Signed-off-by: Kevinjohn Gallagher --- .github/actions/setup/action.yml | 30 ++- .github/workflows/node-compatibility.yml | 96 +++++++++ CHANGELOG.md | 3 + README.md | 1 + docs-src/reference/development.md | 59 ++++++ docs-src/self-hosting/configuration.md | 3 +- docs/reference/development.html | 2 +- docs/self-hosting/configuration.html | 2 +- package.json | 2 + scripts/smoke-packaged-server.mjs | 257 +++++++++++++++++++++++ scripts/smoke-packaged-server.test.mjs | 48 +++++ src/test/setup.ts | 53 +++-- src/test/storageCompatibility.test.ts | 36 ++++ 13 files changed, 571 insertions(+), 21 deletions(-) create mode 100644 .github/workflows/node-compatibility.yml create mode 100644 scripts/smoke-packaged-server.mjs create mode 100644 scripts/smoke-packaged-server.test.mjs create mode 100644 src/test/storageCompatibility.test.ts 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 49fca17dc..52e3e054e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,9 @@ 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). + - Open an individual's schedule from their avatar, with matching hover and keyboard-focus cues, and simplify drawer entries to a compact activity-first agenda (#753, #754). diff --git a/README.md b/README.md index ca2b999d1..318c1a549 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/reference/development.md b/docs-src/reference/development.md index 4a2d1dbda..c17ffe917 100644 --- a/docs-src/reference/development.md +++ b/docs-src/reference/development.md @@ -31,6 +31,65 @@ 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. +The upstream [callback-scope fix](https://github.com/nodejs/node/pull/65666) passes an +isolated source-build comparison, but acceptance against an official fixed release is +still pending. Follow [issue #710](https://github.com/Kevinjohn/capacitylens/issues/710) +for the current evidence. 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/self-hosting/configuration.md b/docs-src/self-hosting/configuration.md index 2eea6ef75..62b0dc332 100644 --- a/docs-src/self-hosting/configuration.md +++ b/docs-src/self-hosting/configuration.md @@ -17,7 +17,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/reference/development.html b/docs/reference/development.html index de5d1487d..d26b968e4 100644 --- a/docs/reference/development.html +++ b/docs/reference/development.html @@ -20,7 +20,7 @@
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 strict-OIDC end-to-end suite (e2e:oidc) or 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. The upstream callback-scope fix passes an isolated source-build comparison, but acceptance against an official fixed release is still pending. Follow issue #710 for the current evidence. 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

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 automated suite deliberately owns different ports and a separate database.

Useful automated counterparts are:

bash
pnpm exec playwright test --project=auth-backed \
   e2e/login.auth.spec.ts e2e/invite.auth.spec.ts \
diff --git a/docs/self-hosting/configuration.html b/docs/self-hosting/configuration.html
index 4eceaf732..9bec91fe0 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 or self-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 to create the first account. Required unless open signup or the bootstrap-admin escape hatch is enabled.
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.

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 ​

Read Set up company login first. These variables configure the strict OIDC provider CapacityLens supports.

VariableWhat it does
SMALLSASS_ACCOUNT_OIDC_CLIENT_ID / SMALLSASS_ACCOUNT_OIDC_CLIENT_SECRETThe client credentials from your company login provider. Both required for sso mode.
SMALLSASS_ACCOUNT_OIDC_ISSUERThe exact issuer URL your provider reports. Required.
SMALLSASS_ACCOUNT_OIDC_DISCOVERY_URLThe provider's .well-known/openid-configuration URL. Required — the authorisation, token, JWKS and user-info endpoints all come from discovery, not from manual overrides.
SMALLSASS_ACCOUNT_OIDC_BOOTSTRAP_EMAILSComma-separated verified emails allowed to create the first company-login identity. Every identity after that needs a pre-authorised invitation instead.
SMALLSASS_ACCOUNT_OIDC_PROVIDER_IDOptional id used in the sign-in route. Defaults to sso. Can't be a name CapacityLens already uses internally (credential, generic-oauth, two-factor, google, microsoft, github).
SMALLSASS_ACCOUNT_OIDC_LABELOptional button label. Defaults to "Single sign-on".
SMALLSASS_ACCOUNT_OIDC_SCOPESSpace-separated scopes. Defaults to openid profile email, all of which are required.
SMALLSASS_ACCOUNT_SSO_MFA_ENFORCEDAn attestation that your company login provider requires multi-factor sign-in for every admitted identity. Set it only after testing that policy.

Once the first successful startup has happened, the provider id and issuer are locked together — changing either refuses startup, to protect existing sign-in records. See Move to single sign-on for converting an existing password installation.

Google, Microsoft and GitHub sign-in buttons are available and experimental through SMALLSASS_ACCOUNT_GOOGLE_CLIENT_ID/SMALLSASS_ACCOUNT_GOOGLE_CLIENT_SECRET and the equivalent Microsoft and GitHub pairs (Microsoft also takes an optional SMALLSASS_ACCOUNT_MICROSOFT_TENANT_ID, defaulting to common). Each provider needs both its id and secret set, or it's off. Strict OIDC above is the supported, provider-neutral path.

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_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 (docker compose build web, or build web-client for the client-only image) to take effect — setting them in a running container's environment does nothing.

Older variable names ​

Earlier releases used CAPACITYLENS_AUTH, BETTER_AUTH_*, CAPACITYLENS_SSO_* and named-social-provider variables with different names than the SMALLSASS_ACCOUNT_* ones above. Those older names still work as aliases, but they're deprecated: CapacityLens warns once, without logging the value, when it sees only the old name, and refuses to start if an old and a new name are both set but disagree. Move to the SMALLSASS_ACCOUNT_* names above when you next touch your configuration — the aliases won't be removed until at least two stable minor releases and 90 days have passed since the canonical names first shipped, so there's no rush, but new deployments should use the current names from the start.

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 or self-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 to create the first account. Required unless open signup or the bootstrap-admin escape hatch is enabled.
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.

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 ​

Read Set up company login first. These variables configure the strict OIDC provider CapacityLens supports.

VariableWhat it does
SMALLSASS_ACCOUNT_OIDC_CLIENT_ID / SMALLSASS_ACCOUNT_OIDC_CLIENT_SECRETThe client credentials from your company login provider. Both required for sso mode.
SMALLSASS_ACCOUNT_OIDC_ISSUERThe exact issuer URL your provider reports. Required.
SMALLSASS_ACCOUNT_OIDC_DISCOVERY_URLThe provider's .well-known/openid-configuration URL. Required — the authorisation, token, JWKS and user-info endpoints all come from discovery, not from manual overrides.
SMALLSASS_ACCOUNT_OIDC_BOOTSTRAP_EMAILSComma-separated verified emails allowed to create the first company-login identity. Every identity after that needs a pre-authorised invitation instead.
SMALLSASS_ACCOUNT_OIDC_PROVIDER_IDOptional id used in the sign-in route. Defaults to sso. Can't be a name CapacityLens already uses internally (credential, generic-oauth, two-factor, google, microsoft, github).
SMALLSASS_ACCOUNT_OIDC_LABELOptional button label. Defaults to "Single sign-on".
SMALLSASS_ACCOUNT_OIDC_SCOPESSpace-separated scopes. Defaults to openid profile email, all of which are required.
SMALLSASS_ACCOUNT_SSO_MFA_ENFORCEDAn attestation that your company login provider requires multi-factor sign-in for every admitted identity. Set it only after testing that policy.

Once the first successful startup has happened, the provider id and issuer are locked together — changing either refuses startup, to protect existing sign-in records. See Move to single sign-on for converting an existing password installation.

Google, Microsoft and GitHub sign-in buttons are available and experimental through SMALLSASS_ACCOUNT_GOOGLE_CLIENT_ID/SMALLSASS_ACCOUNT_GOOGLE_CLIENT_SECRET and the equivalent Microsoft and GitHub pairs (Microsoft also takes an optional SMALLSASS_ACCOUNT_MICROSOFT_TENANT_ID, defaulting to common). Each provider needs both its id and secret set, or it's off. Strict OIDC above is the supported, provider-neutral path.

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_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 (docker compose build web, or build web-client for the client-only image) to take effect — setting them in a running container's environment does nothing.

Older variable names ​

Earlier releases used CAPACITYLENS_AUTH, BETTER_AUTH_*, CAPACITYLENS_SSO_* and named-social-provider variables with different names than the SMALLSASS_ACCOUNT_* ones above. Those older names still work as aliases, but they're deprecated: CapacityLens warns once, without logging the value, when it sees only the old name, and refuses to start if an old and a new name are both set but disagree. Move to the SMALLSASS_ACCOUNT_* names above when you next touch your configuration — the aliases won't be removed until at least two stable minor releases and 90 days have passed since the canonical names first shipped, so there's no rush, but new deployments should use the current names from the start.

What's next ​

CapacityLens is open source under AGPL-3.0.

diff --git a/package.json b/package.json index 964163876..0afeca117 100644 --- a/package.json +++ b/package.json @@ -70,6 +70,7 @@ "gate": "node scripts/run-gate.mjs app", "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/run-gate.mjs server", "policy:sonner-csp:test": "node --test scripts/check-sonner-csp.test.mjs", @@ -79,6 +80,7 @@ "policy:server-script-lint:test": "node --test scripts/check-server-script-lint.test.mjs", "policy:shared-environment:test": "node --test scripts/check-shared-environment.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" }, "dependencies": { diff --git a/scripts/smoke-packaged-server.mjs b/scripts/smoke-packaged-server.mjs new file mode 100644 index 000000000..64d654fa8 --- /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", + CAPACITYLENS_AUTH: "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..3732aa1d4 --- /dev/null +++ b/scripts/smoke-packaged-server.test.mjs @@ -0,0 +1,48 @@ +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.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); + }); +}); From 1eac627d2a216ed0e8fda1490047f7465e5deae1 Mon Sep 17 00:00:00 2001 From: Kevinjohn Gallagher Date: Fri, 11 Sep 2026 00:44:12 +0100 Subject: [PATCH 2/2] docs: preserve Node 26 discovery evidence and acceptance plan Signed-off-by: Kevinjohn Gallagher --- docs-src/.vitepress/config.mts | 1 + docs-src/reference/development.md | 5 +- docs-src/reference/node26-discovery.md | 168 ++++++++++++++++++ docs/company-login/index.html | 2 +- .../company-login/move-to-single-sign-on.html | 2 +- docs/company-login/set-up-company-login.html | 2 +- docs/getting-started/first-steps.html | 2 +- docs/getting-started/install.html | 2 +- docs/getting-started/invite-your-team.html | 2 +- .../roles-and-permissions.html | 2 +- docs/getting-started/try-the-demo.html | 2 +- .../getting-started/what-is-capacitylens.html | 2 +- docs/guide/offline-access.html | 2 +- docs/guide/people-and-placeholders.html | 2 +- docs/guide/projects-and-allocations.html | 2 +- docs/guide/settings.html | 2 +- docs/guide/the-schedule.html | 2 +- docs/guide/time-off.html | 2 +- docs/index.html | 2 +- docs/reference/conventions.html | 4 +- docs/reference/development.html | 6 +- docs/reference/glossary.html | 2 +- docs/reference/node26-discovery.html | 25 +++ docs/security/OpenSSF-best-practices-dev.html | 2 +- docs/security/control-inventories.html | 2 +- docs/security/index.html | 2 +- docs/security/mutation-review-2026-07-15.html | 2 +- docs/security/mutation-review-2026-07-18.html | 2 +- docs/security/owasp-asvs-5.0.0.html | 2 +- docs/security/privacy.html | 2 +- docs/security/reviews.html | 2 +- docs/security/security-review-2026-07-14.html | 2 +- docs/security/security-review-2026-08-18.html | 2 +- docs/security/threat-model.html | 2 +- docs/self-hosting/backups-and-restore.html | 2 +- docs/self-hosting/configuration.html | 2 +- docs/self-hosting/incidents.html | 2 +- docs/self-hosting/index.html | 2 +- docs/self-hosting/install-with-docker.html | 2 +- docs/self-hosting/install-without-docker.html | 2 +- docs/self-hosting/monitoring.html | 2 +- docs/self-hosting/tls-and-networking.html | 2 +- docs/self-hosting/upgrades.html | 2 +- 43 files changed, 240 insertions(+), 43 deletions(-) create mode 100644 docs-src/reference/node26-discovery.md create mode 100644 docs/reference/node26-discovery.html diff --git a/docs-src/.vitepress/config.mts b/docs-src/.vitepress/config.mts index 5d0ff186b..9c7ef4110 100644 --- a/docs-src/.vitepress/config.mts +++ b/docs-src/.vitepress/config.mts @@ -148,6 +148,7 @@ export default defineConfig({ items: [ { text: "Glossary", link: "/reference/glossary" }, { text: "Development guide", link: "/reference/development" }, + { text: "Node 26 discovery", link: "/reference/node26-discovery" }, { text: "Code conventions", link: "/reference/conventions" }, ], }, diff --git a/docs-src/reference/development.md b/docs-src/reference/development.md index c17ffe917..2737e75c7 100644 --- a/docs-src/reference/development.md +++ b/docs-src/reference/development.md @@ -38,7 +38,10 @@ experimental: Node 26.8.2 can delay SQLite backup completion until another timer The upstream [callback-scope fix](https://github.com/nodejs/node/pull/65666) passes an isolated source-build comparison, but acceptance against an official fixed release is still pending. Follow [issue #710](https://github.com/Kevinjohn/capacitylens/issues/710) -for the current evidence. Do not use a locally patched runtime for deployment. +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 diff --git a/docs-src/reference/node26-discovery.md b/docs-src/reference/node26-discovery.md new file mode 100644 index 000000000..e582b06c2 --- /dev/null +++ b/docs-src/reference/node26-discovery.md @@ -0,0 +1,168 @@ +--- +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. + +## 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/company-login/index.html b/docs/company-login/index.html index 1575fd562..b04990f55 100644 --- a/docs/company-login/index.html +++ b/docs/company-login/index.html @@ -19,7 +19,7 @@ -
Skip to content

CapacityLens is open source under AGPL-3.0.

+
Skip to content

CapacityLens is open source under AGPL-3.0.

diff --git a/docs/company-login/move-to-single-sign-on.html b/docs/company-login/move-to-single-sign-on.html index f9a9adc5a..c9f27ff95 100644 --- a/docs/company-login/move-to-single-sign-on.html +++ b/docs/company-login/move-to-single-sign-on.html @@ -19,7 +19,7 @@ -
Skip to content