Extracts Playwright harness tables and Dark Factory testing standard to
docs/testing/, and dated incident writeups to docs/lessons/, bringing
AGENTS.md under the 32KiB context budget agent harnesses eagerly load.
Co-Authored-By: Claude Fable 5 <[email protected]>
package.json had no "test" script, so `npm test` failed with
"Missing script" and the obvious fallback -- `node --test tests/*.test.mjs`
-- reports ~10 bogus failures. Those files are not broken: the runner
sets NODE_ENV=test (so nostrToolsBootstrap uses the mock toolkit) and
imports tests/test-helpers/setup-localstorage.mjs, and without that
harness they fail on missing globals. tests/modal-accessibility.test.mjs,
for example, is 8/8 under the runner and 0/1 under a bare node --test.
Aliasing test -> run-unit-tests.mjs means the obvious command is also the
correct one, so nobody re-derives that false failure list. CI is
unaffected (it calls test:unit and the shards, which are unchanged).
Co-Authored-By: Claude Fable 5 <[email protected]>
Picks up BitLogin's login-surface expansion (NIP-07 extension, NIP-46
remote signer with nostrconnect QR, passkey sign-in), three rounds of
adversarial-audit fixes, and the mandatory recovery-phrase ceremony for
passkey-created accounts. Vendored via scripts/build-bitlogin-widget.mjs,
which pins the source commit in vendor/bitlogin.integrity.json; the
shared-chunk hashes changed, so three old chunks are removed and three
new ones added.
CSP fix, required for sign-in to work at all: BitLogin's crypto worker
compiles an Argon2id WASM module, and index.html's script-src had no
'wasm-unsafe-eval', so WebAssembly.compile would be blocked and every
BitLogin password flow would fail -- silently, because the page still
loads. This exact omission broke sign-in on bitlogin.network for four
days. The keyword permits WASM compilation only; JS eval stays blocked,
and the CSP comment's stale "no bundle uses wasm" claim is corrected.
tests/csp-policy.test.mjs now pins the keyword for index.html so it
cannot be dropped again. Baseline verified unchanged: npm run test:unit
green, npm run lint clean, and the raw tests/*.test.mjs glob fails the
same 10 pre-existing files before and after this change.
test_integrity_note:
change_type: ["new_tests"]
scenarios:
- id: SCN-csp-allows-wasm-for-bitlogin
given: "index.html mounts <bitlogin-auth>, whose worker compiles an Argon2id WASM module"
when: "the page's Content-Security-Policy script-src is evaluated"
then: "'wasm-unsafe-eval' is present, so WebAssembly.compile is permitted while 'unsafe-eval' remains absent"
observable_outcomes:
- "script-src directive parsed from the index.html CSP meta tag contains 'wasm-unsafe-eval'"
- "existing assertions still forbid 'unsafe-inline' and 'unsafe-eval'"
determinism_controls:
- "pure static parse of a committed HTML file; no clock, network, or browser"
anti_cheat_rationale:
prevents:
- "silently relaxing script-src to make sign-in work"
- "dropping the keyword and shipping a login page that cannot run Argon2id"
relaxation:
did_relax_any_assertion: false
if_true_explain_spec_basis: ""
Updates the vendored widget to BitLogin 205b067, which includes the
second-audit hardening the previous bytes predated -- phrase-gated sudo key,
deterministic migration, the pinned derivation chain -- plus the entropy
work: the closed nonce/IV surface, fail-closed RNG tests, and the
package-wide Math.random ban.
Also ports the provenance recording from BitUnlock's vendoring script. The
manifest now carries repository, commit, commit date and toolchain, and the
script refuses to vendor from a BitLogin checkout with uncommitted changes,
resolved before vendorDir is deleted so the refusal leaves the committed
files intact. Hashes prove the bytes have not changed since they were
committed; only the commit says which BitLogin produced them, and version
"0.1.0" has covered many substantive changes.
EXISTING SIGN-INS ARE UNAFFECTED, which matters most here since this is the
one consumer with real users. normalize.ts, argon2id.ts, hkdf.ts and
scalarExpand.ts are byte-identical between the previously vendored build and
205b067, so login name + password derive the same locator, signing scalar
and capsule key as before. Accounts created with a hand-typed password still
sign in: the manual-password removal took away the creation, recovery and
rotation CONTROLS, not the acceptance, and nothing on the login path
inspects the password's shape. BitLogin's legacyPassword.e2e.test.ts pins
that, including the migration path of signing in with an old typed password
and rotating onto a generated one.
What does change for users: creating a new account, recovering, or rotating
now hands out a generated six-word passphrase instead of accepting a typed
one.
2354 unit tests pass, including the 10 bitlogin-specific ones in
auth-providers-bitlogin and bitlogin-modal-integration.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01HtNMsKzzFTPURemX7tT19u
Option A chosen. Noting explicitly that Option B should not ship as an interim
step -- the two are not stackable, and a bounded max-age shipped now is the
thing that never gets revisited.
Co-Authored-By: Claude Fable 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01CMr9Yt8SYmZcfgrg3CV2G1
/css/* and /js/* have no header rule at all, so every stylesheet and script
revalidates on every page load. The obvious fix is a trap: asset URLs are not
content-hashed, so a long max-age with immutable would pin returning users to
stale CSS/JS after each deploy with no way to bust it. Records both real
options and the measured live headers.
Also corrects an earlier hand-off note claiming ~10 failing tests here; all
three suites pass on unstable as of today.
Co-Authored-By: Claude Fable 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01CMr9Yt8SYmZcfgrg3CV2G1
Vestigial. Verified dead before removing: the live headers on
bitvid.network come from vercel.json, not from this file -- /js/* is
served with Vercel's default `max-age=0, must-revalidate` rather than
the `immutable, max-age=31536000` netlify.toml asks for, which is proof
the file is not consulted by anything deploying this site. It cost real
time today by making the deploy target look like Netlify.
Two rules it carried that vercel.json does NOT, both deliberately left
out rather than silently dropped:
- `Content-Security-Policy: frame-ancestors *` on /*. No behaviour
change: absence is equally permissive, and the embed page relies on
being framable, not on the header.
- Long-lived immutable caching for /css/* and /js/*. This one is a real
(small) performance regression that predates this commit -- the live
site has been serving those with max-age=0 for as long as it has been
on Vercel. Worth porting into vercel.json separately; deleting a file
nothing reads is not the change that should carry it.
Co-Authored-By: Claude Fable 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01CMr9Yt8SYmZcfgrg3CV2G1
Was on the 2026-07-25 build. Carries an adversarial audit's fixes, the
ones that matter most for a site that hands the widget real identities:
- Origin binding is enforced INSIDE the crypto worker and derived from
self.location instead of a caller-supplied field, so a page cannot
read or adopt a wallet connection bound to a different site.
- Phrase recovery no longer trips the rollback alarm on every later
login (the mark is keyed by the everyday pubkey, which recovery
preserves, while the recovered capsule restarts at generation 0) --
users who had ever rotated a password previously saw "possibly
revoked credentials" forever, with no way to clear it.
- quorumMet counts relays that ANSWERED rather than ones that merely
accepted a socket, and relay responses are now checked against the
address requested, so a single junk event can no longer block
registration, rotation, and recovery.
- Password rotation checks the new capsule reached quorum BEFORE
publishing the tombstone that destroys the old one; a failed rotation
used to be able to leave neither password working.
- Derived secrets (Argon2id output, both PRKs, the BIP-39 seed) are now
wiped at their derivation site, as the spec always claimed.
- The worker RPC validates message shapes, times out, and rejects
pending calls when the worker dies, so a torn-down worker surfaces as
an error instead of a permanent hang.
- Synthetic (script-dispatched) clicks no longer drive consent UI.
Verified: the 10 pre-existing test failures in this suite are unchanged
by this update (checked by stashing the vendor directory and re-running
-- 1330 pass / 10 fail either way), and the BitLogin auth-provider
tests pass. dist/ is gitignored build output and regenerates.
Co-Authored-By: Claude Fable 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01CMr9Yt8SYmZcfgrg3CV2G1
The 2026-06-25 audit blamed "logout logs out everyone" / broken profile
switching / unknown-user-after-refresh on the single shared nip46Client and
single-slot session storage. Re-auditing the current code shows every named
suspect has since been fixed piecemeal: per-account v2 session map with v1
last-connected default, keepStored logout, scoped no-arg clear (v1 slot only),
reuseStored+expectPubkey switch wired end-to-end into
useStoredRemoteSigner({pubkey}), and a validator-enforced boot restore.
None of that was pinned at the manager level, so any layer could regress
silently. tests/nip46-signer-lifecycle.test.mjs now exercises the real
SignerManager against the real signer registry and real (polyfilled) storage:
- logging out the active NIP-46 account destroys its live client exactly once
while BOTH accounts' stored sessions and the other account's registry signer
survive
- a failed targeted restore (passphrase-locked or missing session) fails typed
and deletes nothing
- disconnectRemoteSigner with and without keepStored never touches the
per-account map
Mutation-checked: reinjecting the historical bugs (logout without keepStored;
no-arg clear wiping the v2 map) fails 2/6; real code passes 6/6.
TODO #33 updated: remaining work is the on-phone Amber verification the item
already gates on — and if that still misbehaves, the failure is now known to
live ABOVE this contract (UI/state reconciliation on restore failure), which
narrows the search considerably.
Co-Authored-By: Claude Fable 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01LHH3wPBsoruwCaJo8Rpf6a
- jsdom entry: resolved long ago (28.1.0 installed, in devDeps) — was still
marked Active with an install remediation.
- webseed-playbook.spec.ts:31: resolved by f48150ed; documents the actual fix
(record attached srcs instead of asserting the final cleared src).
- uploadModal-reset: not reproducible across 2026-07-28..30 sweeps; left on
file with instructions if the ordering-dependent hang recurs.
- login-flows.spec.ts:149: corrected — it is NOT a passes-in-isolation flake;
it failed in isolation on 2026-07-28 (local + CI e2e-headless) while passing
in the e2e job. Notes the reverted fix attempt and links it to pre-launch
TODO #33, which is the same subsystem.
Co-Authored-By: Claude Fable 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01LHH3wPBsoruwCaJo8Rpf6a
The multi-line git commit message inside the run block dropped below the
literal block's indentation, ending the block mid-command and failing the
whole workflow file at parse time (0s failure, no jobs). Use two -m flags on
one shell line instead. YAML now validated locally before push.
Token included so this push performs the regeneration.
Co-Authored-By: Claude Fable 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01LHH3wPBsoruwCaJo8Rpf6a
workflow_dispatch only works for workflows on the default branch, which this
file will not reach until the next promotion. Gate the job on an explicit
[update-visual-baselines] commit-message token instead so it is dispatchable
from unstable now; the workflow's own commit omits the token, so it cannot
retrigger itself.
This commit carries the token, so pushing it performs the first regeneration.
Co-Authored-By: Claude Fable 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01LHH3wPBsoruwCaJo8Rpf6a
The visual-regression job pixel-compares tests/visual/baselines.json on
ubuntu-latest, but the committed baselines were captured on developer machines.
Chromium builds and font fallbacks differ between environments — the token
stack's "Inter"/"Fira Code" are not shipped or installed anywhere, so every
machine renders its own system fallback — and the kitchen-sink default-theme
diff drifted past MAX_DIFF_RATIO on CI while passing 13/13 locally. ci.yml
documents exactly this mismatch as the reason the job is quarantined from
blocking deploys.
The CI diff artifact confirms it: baseline and actual render identically except
for ghosted/offset text in the high-contrast lower half — font-metric drift,
not a design change. (The dark upper half diffs too little to trip the 0.2
pixelmatch threshold, which is why only the default theme fails.)
Fix the class of problem instead of the instance: capture baselines in the SAME
runner image, browser install and font stack that the comparison job uses.
workflow_dispatch only — regenerating baselines is an explicit decision made
when a visual change is intentional, never a side effect of a push. The
workflow re-runs the comparison against its own output before committing, so a
bad capture cannot land.
Co-Authored-By: Claude Fable 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01LHH3wPBsoruwCaJo8Rpf6a
CI has been failing on unstable for weeks, across four unit files and a set of
e2e specs. None of it was a product regression: every failure is a test that
encodes behavior a later commit deliberately changed. Each is corrected against
the spec that superseded it, not adjusted until it passed.
Unit:
- video-modal-moderation: the fixture set flat `blurThumbnail`/`trustedMuteCount`,
but normalizeVideoModerationContext derives shouldShow from
`original.blurThumbnail`/`trustedCount`, so the assertion ran against a
context whose early return had just deleted the blur it was checking. 4525c1c6
also made a lone trusted mute a non-blocking ranking signal (its sibling test
was renamed to say exactly that; this one was missed). Fixture now mirrors
what moderationDecorator emits.
- trustBootstrap: asserted a subscription to onWhitelistChange that 18e08bf2
deliberately removed -- buildTrustedSeeds derives seeds from the super admin,
editors and fallback seeds, so a whitelist change cannot alter them. The
removal is now pinned so it is not reinstated by reflex.
- moderation-service: 18e08bf2 removed `status.whitelisted ||` from
isTrustedReporter (being allowed to publish is not a statement that your
reports carry weight). A whitelisted reporter now counts toward the report
total but not toward trusted.
- playbackService_forcedSource: forced P2P now falls back to a hosted URL when
the torrent definitively fails -- playbackService documents the change and its
rollback. The old assertion did not merely fail, it HUNG: forced sources skip
the start timeout, so the now-reachable hosted attempt waited forever on a
<video> that never emits.
E2E:
- ui-modals: asserted exactly one `.bv-modal` per component page, but
profile-modal.html also bundles #storageCorsModal (d7ad1516). Requires at
least one and drives the primary, which is what the test's own injected
querySelector already used.
- coverage-utility-modules: used `author:abc123`, which 9afbc0cb correctly began
rejecting as undecodable. Uses a real pubkey for the success path and pins the
rejection separately.
- webseed-playback: asserted the FINAL src equals the CDN URL while also
tolerating "No playable source found" in its status check -- contradictory,
since a failed attempt calls resetVideoElement() and clears src. Headless CI
cannot decode the archive.org H.264 sample. Now records src attachments and
asserts the URL was attached, which is the behavior under test.
- nostr-runtime-coverage: asserted a legacy plaintext session-actor entry reads
back as null. Since 8692dac6 added per-account storage, readV1Entry PURGES the
plaintext entry and the reader falls back to the sole remaining encrypted
account -- so null no longer describes the security property. Asserts the
property directly: no plaintext key survives, in the entry or in raw storage.
Also drops the syncAuthUiState share-gate wiring added in d141c316. It was
redundant: Application.updateShareNostrAuthState() already fires on init, login,
logout, pubkey-change and signer-change and forwards to
videoModal.setShareNostrAuthState(). That call short-circuited on its own
`if (!this.videoModal?.setShareNostrAuthState) return;` guard purely because the
method did not exist -- adding it (d141c316) was the whole fix. The tests now
pin that contract instead of a second, competing code path.
All 416 unit files, all three CI shards, both DM suites and lint are green.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>