From 6ffc27ea75bc9d3b06aafc9389d66966482c9f08 Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Tue, 25 Aug 2026 02:43:44 -0700 Subject: [PATCH 1/4] docs(testing): the Node 26 import failure, named at its throw site MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This trap has bitten repeatedly and lived only in one operator's private memory, which the fleet cannot read — so every agent on Node 26 rediscovers it and some blame the PR under test. @sprint-review's sharpening is what makes it actionable: the failing package is `buffer-equal-constant-time`, not `jsonwebtoken`. jwt is the common importer (jws → jwa → buffer-equal-constant-time) and only the caller frame. Reproduced here rather than quoted: on Node v26.0.0 both `require('jsonwebtoken')` and `require('buffer-equal-constant-time')` throw at the identical frame, `index.js:37:35`. One detail worth recording — `:37` is `var origSlowBufEqual = SlowBuffer.prototype.equal;`, a module-scope READ. The write at `:31` sits inside `bufferEq.install` and never runs, so the obvious suspect is not the culprit. Also states what the check must be: whether that leaf is in the require graph, not whether the suite mentions `jsonwebtoken` — a string grep misses every transitive importer. And the remedy is Node 22 (what tests.yml pins), not a moduleNameMapper stub, which silently breaks suites that really sign tokens. Docs-only. Co-Authored-By: Claude Opus 5 --- backend/TESTING.md | 41 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/backend/TESTING.md b/backend/TESTING.md index c27529f51d..987e22aca1 100644 --- a/backend/TESTING.md +++ b/backend/TESTING.md @@ -96,6 +96,47 @@ Frontend testing is documented separately at `frontend/TESTING.md`. Contracts te ./dev.sh test:integration # Tier 1 against Docker Compose services (./dev.sh up required) ``` +## Node 26 kills any suite whose require graph reaches `buffer-equal-constant-time` + +Node 26 removed `SlowBuffer`. `buffer-equal-constant-time/index.js:37` reads +`SlowBuffer.prototype.equal` at **module scope**, so it throws the moment it is +required — before any test runs: + +``` +TypeError: Cannot read properties of undefined (reading 'prototype') + at Object. (backend/node_modules/buffer-equal-constant-time/index.js:37:35) +``` + +The line one might expect to be at fault, `Buffer.prototype.equal = +SlowBuffer.prototype.equal = …` at `:31`, sits inside `bufferEq.install` and +never runs. The unconditional **read** at `:37` is the one that fires. + +**`jsonwebtoken` is the common importer, not the failing package.** The chain is +`jsonwebtoken` → `jws` → `jwa` → `buffer-equal-constant-time`, and both +`require('jsonwebtoken')` and `require('buffer-equal-constant-time')` throw at +the identical frame. So a "will this suite die on 26?" check must ask whether +that leaf is in the require graph — grepping a suite for the string +`jsonwebtoken` misses every suite that reaches it transitively, and blames the +wrong package when it hits. + +It fails **loudly**: `Tests: 0 total` and a non-zero exit, never a silent skip. +A red suite here is an environment artifact and not a defect in the change under +test — do not "fix" a PR against it. + +Fix: run on the version CI uses. `tests.yml` pins **Node 22**. + +```bash +PATH=/opt/homebrew/opt/node@22/bin:$PATH npx jest +``` + +Prefer that over `--moduleNameMapper` stubbing of `jsonwebtoken`: a stub works +for suites that only import it transitively and silently breaks any suite that +actually signs or verifies a token, which is most of the runtime-token service +suites. + +Suites with no jwt in their graph are unaffected — `mongoose` and +`mongodb-memory-server` both load clean on 26. + ## CI `.github/workflows/tests.yml` defines both tiers: From 20a7e716ae178680946efac4e53e1fe0f89ff016 Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Tue, 25 Aug 2026 02:44:58 -0700 Subject: [PATCH 2/4] =?UTF-8?q?docs(testing):=20upgrading=20escapes=20noth?= =?UTF-8?q?ing=20=E2=80=94=20the=20leaf=20is=20abandoned=20at=20the=20brea?= =?UTF-8?q?king=20version?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit @sprint-review's follow-up, and it changes the remedy rather than just sharpening the pointer: the first thing a reader will try is `npm update jsonwebtoken`, and it cannot work. Verified against the registry rather than reasoned about: jsonwebtoken 9.0.3 -> jws ^4 jws 4.0.1 -> jwa ^2.0.1 jwa 2.0.1 -> buffer-equal-constant-time ^1.0.1 buffer-equal-constant-time 1.0.1 (latest, zero deps) The leaf is at its own latest AND that version is the breaking one, so a fully upgraded chain still resolves onto it. There is no fixed release to move to. That leaves Node 22 as the remedy for real, not as the convenient one. Docs-only. Co-Authored-By: Claude Opus 5 --- backend/TESTING.md | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/backend/TESTING.md b/backend/TESTING.md index 987e22aca1..b071ca7d25 100644 --- a/backend/TESTING.md +++ b/backend/TESTING.md @@ -123,6 +123,21 @@ It fails **loudly**: `Tests: 0 total` and a non-zero exit, never a silent skip. A red suite here is an environment artifact and not a defect in the change under test — do not "fix" a PR against it. +**Upgrading escapes nothing** (@sprint-review), which is the first thing anyone +tries. Measured against the registry 2026-08-25: + +| package | latest | what it pins | +|---|---|---| +| `jsonwebtoken` | 9.0.3 | `jws ^4` | +| `jws` | 4.0.1 | `jwa ^2.0.1` | +| `jwa` | 2.0.1 | `buffer-equal-constant-time ^1.0.1` | +| `buffer-equal-constant-time` | **1.0.1** | nothing — zero deps | + +The leaf is at its latest published version *and* that version is the one that +breaks, so the whole chain resolves onto it even fully upgraded. There is no +fixed release to move to; the package is simply abandoned at the breaking +version. Don't spend an afternoon on `npm update`. + Fix: run on the version CI uses. `tests.yml` pins **Node 22**. ```bash From bdcbe261ab08e76f45097e78134027f1d5614f75 Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Tue, 25 Aug 2026 04:46:02 -0700 Subject: [PATCH 3/4] =?UTF-8?q?docs(testing):=20abandoned=20is=20not=20unf?= =?UTF-8?q?ixable=20=E2=80=94=20name=20the=20two-line=20remedy?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "No fixed release exists" reads as a dead end. It isn't one: the two offending lines are unreachable from our call path (jwa imports the package at index.js:1 and calls only the plain comparison at :141; .install()/.restore() are called nowhere in jwa, jws, or jsonwebtoken), so a patch-package diff, an overrides pin, or a leaf-level moduleNameMapper stub is a faithful replacement rather than a shim. Records that it must intercept the require — the lines are dead by purpose and live by execution — and that the leaf is the tree's only casualty: the six other runtime packages naming SlowBuffer all require clean on 26. Node 22 stays the answer while 26 is optional; this is the move if 26 ever becomes mandatory. Co-Authored-By: Claude Opus 5 --- backend/TESTING.md | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/backend/TESTING.md b/backend/TESTING.md index b071ca7d25..35d4a250c7 100644 --- a/backend/TESTING.md +++ b/backend/TESTING.md @@ -138,6 +138,32 @@ breaks, so the whole chain resolves onto it even fully upgraded. There is no fixed release to move to; the package is simply abandoned at the breaking version. Don't spend an afternoon on `npm update`. +**Abandoned is not unfixable, though** (@sprint-review). "No fixed release +exists" reads as a dead end and this one isn't: the two offending lines are +unreachable from our call path, so they can be replaced wholesale. `jwa` +imports the package at `index.js:1` and uses it in exactly one place — +`index.js:141`, the plain `bufferEqual(a, b)` comparison — and `.install()` / +`.restore()`, the only things lines 36-37 exist to support, are called nowhere +in `jwa`, `jws`, or `jsonwebtoken`. So a two-line `patch-package` diff, an npm +`overrides` pin, or a jest `moduleNameMapper` stub that supplies only the +comparison function is a faithful replacement rather than a test-only shim. +Note that is a stub of the *leaf*, not of `jsonwebtoken` — the caution below +about stubbing `jsonwebtoken` doesn't apply, because real signing and verifying +still run against a real comparison. +That is the move **if Node 26 ever becomes mandatory** — not now, while 26 is +optional and Node 22 is the right answer. + +Note the remedy has to intercept the `require`. Lines 36-37 are dead by +*purpose* and live by *execution*: they sit at module top level and run +unconditionally, so declining to call `install()` does not avoid them. + +The leaf is also the tree's only casualty. Sixteen packages under +`backend/node_modules` mention `SlowBuffer`; the six that could plausibly touch +it at runtime — `safe-buffer`, `safer-buffer`, `object-hash`, `iconv-lite`, +`string_decoder`, `readable-stream` — all `require` clean on 26 (they guard the +reference). The rest are type declarations, browser bundles, and changelogs. +One patch covers the whole backend. + Fix: run on the version CI uses. `tests.yml` pins **Node 22**. ```bash From 1264cc293b74cfbea2f9579051f680adf8ea3a99 Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Tue, 25 Aug 2026 04:49:49 -0700 Subject: [PATCH 4/4] docs(testing): audit SlowBuffer by deref site, not by require-time survival MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit @sprint-review: "all six require clean on 26" passes iconv-lite for the wrong reason. Its deref is function-scoped inside extendNodeEncodings, so it would load clean whether or not it were safe — a require-time probe cannot see any deref behind a function boundary, which is the commoner shape and the worse failure (throws at call time, not at boot). Replaces the probe with the grep, re-derived here: exactly two packages dereference SlowBuffer. buffer-equal-constant-time at module top level (breaks); iconv-lite at extend-node.js:37, guarded by the :19 early return on !supportsNodeEncodingsExtension (false on any Node with Buffer.from, confirmed) and uncalled anywhere in this repo. Co-Authored-By: Claude Opus 5 --- backend/TESTING.md | 25 +++++++++++++++++++------ 1 file changed, 19 insertions(+), 6 deletions(-) diff --git a/backend/TESTING.md b/backend/TESTING.md index 35d4a250c7..a5fca5a5f1 100644 --- a/backend/TESTING.md +++ b/backend/TESTING.md @@ -157,12 +157,25 @@ Note the remedy has to intercept the `require`. Lines 36-37 are dead by *purpose* and live by *execution*: they sit at module top level and run unconditionally, so declining to call `install()` does not avoid them. -The leaf is also the tree's only casualty. Sixteen packages under -`backend/node_modules` mention `SlowBuffer`; the six that could plausibly touch -it at runtime — `safe-buffer`, `safer-buffer`, `object-hash`, `iconv-lite`, -`string_decoder`, `readable-stream` — all `require` clean on 26 (they guard the -reference). The rest are type declarations, browser bundles, and changelogs. -One patch covers the whole backend. +The leaf is also the tree's only casualty — but check that by **where the +dereference sits, not by whether the module loads** (@sprint-review). Sixteen +packages under `backend/node_modules` mention `SlowBuffer`; requiring each one +and watching it survive proves nothing, because a require-time probe is blind +to every deref behind a function boundary, which is both the commoner shape and +the worse failure — it throws at call time rather than at boot. The cheap check +is the grep: + +```bash +grep -rE 'SlowBuffer[[:space:]]*\.' backend/node_modules +``` + +Two hits, and reading them is the whole audit. `buffer-equal-constant-time` +derefs at module top level, so it breaks. `iconv-lite/lib/extend-node.js:37` +derefs inside `extendNodeEncodings()`, which early-returns at `:19` on +`!supportsNodeEncodingsExtension` — `false` on any Node with `Buffer.from` — +and nothing in this repo calls it. Everything else names `SlowBuffer` in prose, +type declarations, browser bundles, or changelogs. So one patch covers the +whole backend. Fix: run on the version CI uses. `tests.yml` pins **Node 22**.