Author SHA1 Message Date
Claude 2f83d13297 chore(b3-7): satisfy the lint gate — split the message generator, reasoned nolints, modernize idioms
golangci flagged eleven issues, all in profile_alpha.go. funlen/cyclop:
alphaChannelFor, alphaAuthorFor and the sqlBatch chunk machinery move out
of alphaInsertMessages — pure code motion, proven by the regenerated
snapshot being byte-identical to the committed one. gosec gets reasons,
not exemptions by silence: the public dev-profile hash and math/rand are
this profile's documented contract, and both concatenations build only
"(?,…)" placeholder groups. strings.SplitSeq and range-over-int adopted;
alphaVoiceSessions now surfaces in the summary output, where its
no-at-rest-rows interpretation is worth stating at runtime anyway.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B8dwVLEihnGZYtH9X631F4
2026-08-31 05:59:12 +00:00
Claude a88d1554be fix(b3-7): inventory row for the profile's db import; join-aware, chronological, uploader-attributed dataset
The Server Build & Test failure was the db-import-boundary rule:
cmd/seed/profile_alpha.go had no DBImportAllow row. Added (boundary — the
profile writes through the handle main.go owns) and the dbinventory table
in server-boundaries.md repasted from the tool.

Codex findings on the dataset, all verified and fixed with the snapshot
regenerated (byte-identity holds; size unchanged at 3.2MB):
- authors before join: alphaJoinTimes is computed once and monotonic in
  the user id, so eligibility at time t is the prefix 1..N; channel
  authors draw from it and DM pairs carry traffic only from their ready
  time (first twelve pairs are all-pre-window so DM traffic exists from
  the first hour; DM channels are created at ready, the
  create-on-first-contact shape).
- non-chronological ids: per-bucket second offsets are sorted before ids
  are assigned; ascending ids now follow ascending timestamps.
- NULL uploader: attachments record uploader_id = the message's author.
- scrub over-claim (P1): scrub.sql and the README now state precisely
  that message content and channel names are deliberately out of scope —
  synthetic here, a judgement call on a real database — so a donated
  production database is not shareable after this script alone.

The canary pins all four as invariants a live database cannot violate:
zero authored-before-join rows, zero adjacent-id timestamp inversions,
zero uploaderless attachments, zero DM messages predating a member.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B8dwVLEihnGZYtH9X631F4
2026-08-31 05:41:13 +00:00
J3vb 057cb19eda Merge branch 'dev' into feat/b3-7-alpha-dataset 2026-08-31 07:26:12 +02:00
J3vbandClaude e524f28521 docs: amend BPR-032 to the slim epoch policy; identical-tree B1 evidence (#1466)
Owner decisions 2026-08-31, formalizing what B2-2 (2026-08-29, "shipped
slim") and HP-2 (condition 1 accepted at that scope) already record:

- BPR-032 states the single-current-epoch beta policy with a dated
  amendment; the N/N-1/N-2 window returns by decision when a real epoch
  bump needs one. Traceability row matched, with the slim-scope evidence
  (TestAuth_ProtocolEpoch, TestEpoch1Fixtures, TestReleaseProtocolEpoch).
- Roadmap B6 ws16 / B8 ws12+14 / B10 item 5 no longer imply B2-2 shipped
  GET /api/v1/server-info or a three-epoch matrix: B6 adds the endpoint,
  B10 re-runs the accepted epochs. The B2 exit bullet gets a dated note
  instead of a rewrite (closed-phase history stays legible).
- B1 exit gate reworded: integration evidence is the required matrix on
  the PR head plus strict:true tree identity with the squash commit
  (scripts/verify-integration-tree.sh; the strict selftest pin rides the
  companion CI PR).
- BPR-051's non-developer comprehension read becomes B10 qualification
  item 15 (an R-08 scorecard row); the traceability row points at it.
- plans README: BPR/roadmap rows carry the amendment notes; the stale
  sweep sentence is dated and scoped to the records then open; the
  roadmap row no longer claims B3 has not started.


Claude-Session: https://claude.ai/code/session_01B8dwVLEihnGZYtH9X631F4

Co-authored-by: Claude <noreply@anthropic.com>
2026-08-31 05:24:22 +00:00
Claude 0dd3e86bc0 feat(b3-7): alpha-shaped dataset — deterministic seed profile and the v1.2.0-alpha.4 snapshot
go run ./cmd/seed -confirm-dev -profile alpha fills an empty database with
the plan's dataset: 100 users (1/2/5/92 across the four roles), 12 channels
(10 text + 2 voice; 3 role-override, 2 user-override, 1 archived), 20,000
messages over 30 simulated days on a diurnal curve (exactly 15% in DMs
across 40 pairs), 300 attachment rows (60/10/10/20%, 10KB-5MB), 500
reactions, 30 invites (10 revoked), one disabled plugin row. Deterministic
by construction — fixed seed, fixed clock, constant bcrypt hash, explicit
ids and timestamps, VACUUM INTO as the canonical bytes — and
TestAlphaProfileByteIdentical holds the property (two full runs compared
byte for byte; a schema_versions wall-clock leak was the one leak found,
now pinned by the scrub). Two constants deliberately leave no rows and say
why in the package comment: voice sessions are LiveKit-ephemeral, and the
replay log is empty exactly as on a server restarted for an upgrade.

The committed snapshot (3.2MB, under the 5MB LFS line) is the scrubbed
VACUUM of that profile at the alpha.4 migration set - the schema has not
moved since the tag, so it is a true alpha.4 artifact. scrub.sql beside it
also anonymises a real donated database. db/alpha_snapshot_test.go is the
standing canary: provenance (31 applied migrations), HEAD migrations apply
cleanly, and every promised row count checks out, FTS included. Consumers
(B4 HP-4, B6 upgrade rehearsal, B10 in-place upgrade) are named in the
snapshot README and docs/deployment.md.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B8dwVLEihnGZYtH9X631F4
2026-08-31 05:14:13 +00:00
J3vbandClaude 59518c4767 ci: run otel-tagged api tests; pin strict for identical-tree evidence (#1465)
The tag-gated step ran -tags otel only for ./telemetry/..., so
api/recoverer_otel_test.go — B3-9's OC-0346 panic-log test — executed
nowhere in CI. Widen the scope to ./api/... and correct the comment that
lists the tagged files. Both packages pass locally under the tag.

G-03 as amended: integration evidence for a dev squash commit is the full
required matrix on its PR head plus tree identity between the two, which
required_status_checks.strict guarantees by construction. Make that
checkable: verify-gate-evidence.mjs --selftest now fails if the protection
script ever loses "strict": true, and scripts/verify-integration-tree.sh
asserts squash-tree == PR-head-tree for any squash SHA (the three newest
dev commits PASS), for phase-exit and hold-point evidence blocks.


Claude-Session: https://claude.ai/code/session_01B8dwVLEihnGZYtH9X631F4

Co-authored-by: Claude <noreply@anthropic.com>
2026-08-31 04:58:39 +00:00
7abdd941fd refactor(b3-3): lifecycle extraction into Server/internal/app with one composite close (#1464)
* docs(b3-3): mark B3-3 in progress and record HP-3's merge SHA

HP-3 (#1461) merged as `52601114`; B3-3 (lifecycle extraction into
`Server/internal/app/`) starts on `feat/b3-3-lifecycle`. Status line only —
no step-table or scorecard edits.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011EvaP8XtuTJD86eSeuJrce

* refactor(b3-3): move the process lifecycle into Server/internal/app (pure move)

Every run* block, the healthcheck CLI, the banner and disk helpers, the seq
seeding, the bind-retry listener and the restart coordinator move out of
`Server/main.go` into a new `Server/internal/app` package, verbatim. `main.go`
keeps the CLI dispatch, the log sinks, the `version` symbol `-ldflags` names
and the restart handoff, and calls `app.Run`.

Behaviour-neutral. The only substitutions are the package clause, `run` ->
`Run` and `runHealthcheckCLI` -> `RunHealthcheckCLI` (the two entry points
main() calls), the five restart-coordinator identifiers main() still names
(`RestartCoordinator`, `NewRestartCoordinator`, `RestartBackstopDelay`,
`PerformRestartHandoff`, `Disarm`), and `version` becoming a parameter of
`Run`/`runServeAndWait` instead of a package-level var — it has to stay in
package main because `-X main.version` is what the Makefile, `release.yml`
and the Dockerfile inject.

Normalised-diff proof (HP-1's shape): undoing those substitutions over the
whole Server diff and running `sort | uniq -u` leaves 45 unpaired lines, all
of them comment prose or the new import — no code line is unpaired.

`DBImportAllow` swaps its `main.go` row for the four `internal/app` files that
now own the handle (all `boundary`); `docs/architecture/server-boundaries.md`
is regenerated from it (50 -> 53 importers, boundary 7 -> 10; the summary
table's stale 6 is corrected to match the generated line).

Full gate green: four tag variants, vet, `go test -race ./...` with coverage
(aggregate 80.1%, unchanged), coverage floor, `-tags deadlock ./ws/`,
golangci-lint v2.11.3 (0 issues), genprotocol/sqlc/gendocs drift, check:docs,
check:hygiene. `TestAuthCharacterization` green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011EvaP8XtuTJD86eSeuJrce

* refactor(b3-3): one App with one composite close, replacing run()'s defer stack

`type App` threads the dependencies through fields instead of run() locals.
Every stage registers exactly one close step as it comes up, in start order,
and `App.Close(ctx)` walks them backwards — so there is a single teardown
path, taken on a failed start, a serve error and a clean shutdown alike,
where run() had a LIFO `defer` stack and an early return that skipped
whatever it had not reached.

`main.go` is 1,019 -> 99 lines: the CLI dispatch, the log sinks, the restart
handoff, and `cfg := app.LoadConfig(...); a := app.New(cfg, ...); a.Run(ctx)`.

The three ordering facts the inventory records are preserved, and are now
what the reverse walk is FOR rather than emergent from where a `defer`
happened to sit: the audit writer and event persistence both stop before
`database.Close`, and the hub's GracefulStop runs on every return from Run so
a supervised LiveKit process is never orphaned (OC-0027).

Test-first. Three RED rows, each with a negative control on this branch:

| Property                                              | Mutation that must fail it            | Result |
| ----------------------------------------------------- | -------------------------------------- | ------ |
| close order is the reverse of start order             | walk the closers forward               | FAIL   |
| first error returned, every later close still runs    | return on the first error              | FAIL   |
| hub stops when a stage after the router fails         | skip teardown on a failed start        | FAIL   |

Deliberate, documented changes that come with the contract:

* `Run(ctx)` is real: bgCtx and the serve context both descend from it, so
  cancelling the caller's context stops the server the way a signal or a
  restart request does (`context.AfterFunc` joins the coordinator's context
  to it).
* the four stop steps that used to build a fresh `context.Background()` with
  their own 5s cap now take Close's budget as their parent, so a wedged step
  cannot push teardown past the 30s the operator was told about;
* `database.Close`'s error is reported instead of discarded;
* the ACME start moves one stage later, after the maintenance loop, which is
  what makes "reverse of start" equal the order run()'s explicit shutdown
  call used to impose by hand (drain in-flight HTTP handlers first);
* `internal/app/app.go` gains a `DBImportAllow` row; the inventory doc is
  regenerated (54 importers, boundary 11).

Full gate green, aggregate coverage 80.2% (floor 79.8%).
`TestAuthCharacterization` green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011EvaP8XtuTJD86eSeuJrce

* refactor(b3-3): the hub has one owner — construction moves to internal/app

`api.NewRouter` gains an `api.Runtime` parameter and stops returning a hub.
`app.StartRuntime` (`Server/internal/app/hub.go`) now builds the rate limiter,
the service layer and the hub, applies every pre-Run setter and starts the
dispatch goroutine — the `ws.NewHub` call that was at `router.go:106` and the
plugin and LiveKit setters that were at `:325-360`.

Before this, the hub had two owners: the router built and wired it, and
`main.go` set the event persister and the event store after `NewRouter`
returned. Both now sit inside `internal/app`, which is what gives B3-4 one
place to turn the required setters into validated `HubOptions`.

The limiter and the service layer move with the hub because it needs the SAME
instances — the limiter persists auth lockouts and the services hold the
permission cache the hub invalidates, so a second copy of either would
silently split that state. `Runtime` carries them plus `VoiceEnabled`, which
is the `lkErr == nil` guard the voice routes were already mounted behind;
`routerVoiceRoutes` keeps only the mounting half, `routerPluginWiring` becomes
`app.wirePlugins`, and the LiveKit client and companion process are built by
`app.startVoice` with its OC-0019 fail-closed ordering unchanged.

The hub is its own lifecycle stage now, started before the router, so
`App.Close` stops it through the "hub" step exactly as before.

Call sites updated at the call site only, wiring with no assertion changes:
six `api_test` files and `cmd/gendocs`. `gendocs` produces a byte-identical
route index (its drift check is part of the gate).

Full gate green: four tag variants, vet, `go test -race ./...` with coverage
(aggregate 80.2%), coverage floor, `-tags deadlock ./ws/`, golangci-lint
v2.11.3 (0 issues), genprotocol/sqlc/gendocs drift, check:docs, check:hygiene.
`TestAuthCharacterization` green. `internal/app/hub.go` gains a
`DBImportAllow` row; the inventory doc is regenerated (55 importers).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011EvaP8XtuTJD86eSeuJrce

* test(b3-3): fail every lifecycle stage in turn and assert what teardown releases

`Server/internal/app/lifecycle_failure_test.go` is the failure-injection
report the B3 exit gate asks for. Each of the fourteen stages `App.start`
brings up is made to fail in turn, and every row asserts the same four
properties: the returned error names the stage, no goroutine is left running
(`goleak`), the database handle is closed so the SQLite process lock is
released for the successor a restart handoff is about to start, and the
listener is not left bound so that successor can take the port.

The table is generated from `App.stages()` rather than written out, so a
stage added later is covered the day it is added.

Two rows are not injected. A real out-of-range port drives the genuine
listener-bind failure (the OC-0027 path). And a run that is cancelled while
actually serving is the control: the same four properties on the path where
nothing fails, with a nil error — so the injected rows are not passing merely
because something went wrong.

Negative controls on this branch:

| Assertion under test              | Mutation applied                | Result         |
| --------------------------------- | ------------------------------- | -------------- |
| the database handle is closed     | drop the `database` close step  | FAIL, 11 rows  |
| the hub's dispatch loop is stopped| drop the `hub` close step       | FAIL, 12 rows  |

Green under `go test -race ./internal/app/`. Full gate green, aggregate
coverage 80.2% (floor 79.8%), `internal/app` 66.1%.
`TestAuthCharacterization` green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011EvaP8XtuTJD86eSeuJrce

* docs(b3-3): evidence block, after-state lifecycle rows, status line and step row

Plan §B3-3 gains its evidence block: pre-squash SHA per numbered item with
the gate and `TestAuthCharacterization` result for each, `main.go`'s 1,019 →
99 lines, the normalised-diff proof for the pure move (the exact command and
its 45 unpaired lines, all comment prose or the new import, broken down per
file), the composite-close negative controls, the failure-injection table
(sixteen rows × four assertions), the hub-ownership before/after, the
build-and-packaging check, the gate list and the coverage figures.

`docs/architecture/server-boundaries.md`'s hub lifecycle inventory keeps its
before-state tables and gains the after-state: where each of the seven
setters is called now, and the fifteen-stage start list with the close step
each registers, plus why the resulting close order is what makes the three
ordering facts hold by rule rather than by `defer` placement.

One correction the check turned up and the evidence records: a plain `go
build .` from `Server/` produces a binary named `Server`, not `chatserver` —
that comes from the module path and is unchanged by B3-3. Every packaging
path (`Makefile`, `Dockerfile`, `release.yml`, all untouched) passes `-o`
explicitly, and `-X main.version` still resolves.

Status line, step-table row and the `docs/plans/README.md` B3 row point at
PR #1464; the squash SHA lands here at merge.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011EvaP8XtuTJD86eSeuJrce

* fix(b3-3): keep the background workers alive through the HTTP drain

Codex (P2) caught a defect in B3-3's own new `Run(ctx)` contract: `bgCtx` was
derived from the caller's context, so cancelling that context stopped the
event persister, the audit writer and the maintenance loop immediately —
before `Close` ran its HTTP-first drain. That drain exists precisely so
in-flight handlers' broadcasts still reach a live hub and event persister and
their audit records still reach a live writer; with the consumers already
gone, both are dropped. It also made caller-context shutdown behave unlike
the SIGTERM and restart paths, which cancel only the serve context.

`run()` had this right for free by rooting `bgCtx` at `context.Background()`.
`main.go` passes `context.Background()`, so no released build was affected —
the defect is in the new contract this PR introduces.

`context.WithoutCancel(ctx)`: `bgCtx` inherits the caller's values but not its
cancellation. Cancelling ctx still stops serving, because the serve context
descends from it in `startSignals`; when the background work stops stays
`Close`'s decision, which is what the ordering rule promises.

Test-first. `TestAppRun_CallerCancel_KeepsBackgroundWorkersAliveThroughTheDrain`
records `bgCtx.Err()` as each close step runs — a new test-only `onCloseStep`
seam makes the teardown walk observable — and requires bgCtx still live at
`signals`, `http`, `maintenance` and `audit-writer`, and already cancelled by
`database` (the `event-persistence` step is what cancels it and joins the
pruner). RED on all four rows before the fix; the negative control, restoring
`context.WithCancel(ctx)`, fails it again.

Full gate green on the merged tree, including `dev`'s new `errorlint`,
`exhaustive` and `durationcheck` linters: golangci-lint v2.11.3, 0 issues.
Aggregate coverage 80.2% (floor 79.8%). `TestAuthCharacterization` green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011EvaP8XtuTJD86eSeuJrce

---------

Co-authored-by: J3vb <dragon613gaming@gmail.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-31 05:47:33 +02:00
J3vbandClaude Fable 5 6432e65c56 chore(claude): session-start + pre-bash hooks, deny .env reads; durationcheck + noImplicitOverride (#1463)
* chore(claude): session-start and pre-bash hooks, deny .env reads

scripts/claude-hook.mjs, wired in .claude/settings.json:
- SessionStart warns when core.hooksPath is not .githooks, so a clone or a
  new machine cannot silently run without the repo git hooks.
- PreToolUse on Bash refuses a top-level cd: the tool's shell is persistent,
  so a cd leaks into every later command and a gate can report green from
  the wrong directory. Subshells, git -C and root-relative paths pass.

permissions.deny gains Read(**/.env): the gitignored env files never enter
the model's context. Server/.env.example stays readable.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jwaz4CHGAz85Rpypjvto5a

* chore(lint): durationcheck on the server, noImplicitOverride on the client

Both measured at zero hits on dev, so they cost nothing today and only
block regressions: a Duration multiplied by a Duration-typed value, and an
override left behind when its base method is renamed.

rowserrcheck and sqlclosecheck were measured too and rejected: their six
production hits are all correct code (rows.Err is checked inside
scanEventRows behind the rowsScanner interface; the three Close sites close
on every path by hand).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jwaz4CHGAz85Rpypjvto5a

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-30 18:50:12 +00:00
J3vbandClaude Fable 5 ead64cdc20 chore(lint): errorlint + exhaustive + switch-exhaustiveness-check, and permissions.deny for generated files (#1462)
* chore(claude): deny hand-edits to generated files via permissions.deny

CLAUDE.md already says the sqlc, protocol and tauri-typegen outputs are
never hand-edited; this turns the sentence into a permission rule so the
Edit/Write tools refuse those paths outright.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jwaz4CHGAz85Rpypjvto5a

* chore(lint): switch-exhaustiveness-check on the client, default branch counts as exhaustive

A switch over a string union that misses a member is a silent drop, not a
type error. Every existing default-less switch already covers its union, so
this adds no exceptions; the four switches with a default keep it as the
deliberate catch-all.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jwaz4CHGAz85Rpypjvto5a

* chore(lint): enable errorlint and exhaustive in golangci and fix the 110 hits

errorlint: 68 fmt.Errorf sites wrapped the inner error with %v, which hid it
from errors.Is/As upstream — now %w; 6 == / != comparisons on sentinel
errors become errors.Is (the recover() branch in the router asserts the
recovered value is an error first); 36 ClientError type assertions become
errors.As, so a wrapped ClientError still reaches the client with its code.
Three test assertions the autofixer inverted (!ok || code mismatch) are
restored by hand.

exhaustive (default-signifies-exhaustive): one hit, the hub simulation's
FaultStatus switch — FaultOK moves from an if-guard into the switch.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jwaz4CHGAz85Rpypjvto5a

* chore(claude): path-scoped rules for the three generated-code workflows

.claude/rules/{db-change,protocol-change,gendocs}.md load only when Claude
reads a matching source-of-truth file, so the db-change / protocol-change
skills and the gendocs regeneration step surface at the moment they apply
instead of relying on the CLAUDE.md table being remembered. .gitignore
whitelists .claude/rules/ next to skills/, workflows/ and settings.json.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jwaz4CHGAz85Rpypjvto5a

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-30 20:22:55 +02:00
J3vbandClaude Fable 5 526011141f docs(hp-3): accepted 2026-08-30 by the owner; B3-9 squash SHA recorded (#1461)
hp-3-scorecard: decision line and signature filled as drafted (B3-9 closed
the three pinned defects after the measurement; nothing else changes).
Plan: status line, HP-3 step-table row, B3-9 evidence line carry
PR #1454 = 123c0899; docs/plans/README.md rows for the plan and the
scorecard updated. B3-3 is next.


Claude-Session: https://claude.ai/code/session_01A17Uq3d2C36rN82Jitf3wo

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-30 17:07:26 +00:00
J3vbandClaude Fable 5 e01061208d test(api): logbounds — a marker the random request id cannot contain (#1460)
TestBoundRequestID_ControlBytesRejected asserted that "abc" never reaches
the log record, but the server-generated fallback id is a short random base64
run and contained "abc" by chance in CI run 33308823281
(req_id=runnervmgx7h7/qj9LabcvlI-000002), failing an unrelated PR. The marker
is now a long distinctive token, and the test also asserts a request id was
logged at all, as its sibling does.


Claude-Session: https://claude.ai/code/session_01KmiqjgTuov1stBTB6uGkvo

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-30 16:06:55 +00:00
J3vbandClaude Fable 5 f92452124d test(b3-6): benchmarks and a recorded bench baseline — six Benchmark* on the B6 gate paths, bench-baseline.sh with a missing-name guard (#1459)
* test(b3-6): client connection model test — fc.commands over the real stack

B3-6 item 4 (Tier 3a of docs/plans/bug-detection-improvements.md). Property
tests find bad functions; this repo's recurring bugs are bad orderings, and
nothing generated orderings.

Client/tests/unit/connection.model.test.ts drives the real connection stack —
createWsClient() + wireDispatcher() + the real stores — through seven
fc.commands (Connect, Disconnect, RegisterNow, Receive(id, seq), Supersede,
Resync, Logout) against a minimal reference model, checking four invariants
after every command: no duplicate message ids, a monotonic seq watermark
(observed at the auth frame, reset only at the modelled epoch resets), a
verified peer that never flips to unverified, and a superseded attempt's
teardown that never kills the newer session.

Only the boundaries are mocked: the Tauri IPC wire (the shared ws-mocks
helper) and the LiveKit / notification / toast / identity leaves, as in
dispatcher.test.ts. Seeded (OWNCORD_MODEL_SEED, default fixed) so a failure
replays exactly; 150 runs of up to 30 commands, ~0.9 s for the file. A second
test asserts every invariant family was actually reached, so a family that
stops being reachable fails instead of silently passing.

Test only — no Client/src/ change, so B7's rule holds.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KmiqjgTuov1stBTB6uGkvo

* docs(b3-6): evidence block for item 4 (client connection model test)

Records the branch and commit, the seven commands and four invariants, the
RED counterexample for each invariant family with its restored control, the
GREEN runs, and the numbers (seed 20260830, numRuns 150, maxCommands 30,
1083 invariant checks, 119 ms of test time).

Also notes the two spec details resolved against HEAD: RegisterNow has no
client-side symbol (it is the server's hub registration, observed here as the
ready-snapshot/queued-frame redelivery), and the design's aborted voice
attempt is reachable from the connection layer through the dispatcher's stale
voice_leave guard rather than through LiveKitSession's join generations.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KmiqjgTuov1stBTB6uGkvo

* test(b3-6): connection model — drop the tautological seq check, make coverage counters count the non-trivial case, guard the seed

Review findings on B3-6 item 4.

The invariant-2 assertion in checkInvariants compared the model to itself and
could not fail, while reading as though the seq watermark were checked after
every command. Deleted; the header comment now says where the real assertion
lives (connectCmd, against that connect's own auth frame).

Both coverage counters were counting their no-op case: exercised.seq counted
the initial connect declaring last_seq 0, and exercised.verified counted the
check that runs immediately after Supersede seeded the verifications itself.
They now count only a resume (last_seq > 0) and a verification check that
survived some other command, so "reached every invariant family" fails if only
the trivial form remains. Both still hold at the default seed and at 99.

A malformed OWNCORD_MODEL_SEED now throws instead of handing fast-check the
NaN (or the 0 an empty variable coerces to) and running a different suite than
the one that was asked for.

The evidence block's "+0.4 s on the full client suite" was never measured —
both full-suite runs included this file. Replaced with the file's own measured
cost and the observed suite spread, which is larger than that cost.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KmiqjgTuov1stBTB6uGkvo

* test(b3-6): connection model — a buffer resume replays events after auth_ok; ready only on the fresh/fallback path (Codex P2 on #1455)

Verified against the server before changing anything. reconnectWriteReplay
(Server/ws/serve.go:593) writes auth_ok with the replay tier and then the
missed events, and never a ready; only reconnectPrecheck falling through to
handleFreshConnect produces auth_ok(none) + ready. The epoch-1 fixtures record
exactly that split: fresh-connect.json is auth_ok(none) -> ready -> ...,
resume-replay.json is auth_ok(buffer) -> presence -> chat_message -> presence,
with no ready anywhere. Codex is right.

Connect now drives whichever shape the model's watermark implies: last_seq 0
takes the fresh path unchanged, last_seq > 0 takes the resume path — auth_ok
with the tier, then one replayed chat_message carrying the next seq, and no
ready. The replayed frame is a message that committed while we were away, or,
once the id pool is exhausted, a redelivery of one already held, which is the
other real replay shape. An assertion after the handshake requires that frame
to be in the store: on this path the replay burst is the only thing that
repairs client state, so nothing else can cover for it.

RegisterNow had the same defect one step smaller — a bare ready, which the
server never writes either. It now sends the full auth_ok(none) + ready
handshake before the queued redelivery, so every ready in the file follows the
auth_ok that precedes it on the wire, and the redelivered frame carries the
server's restarted counter (OC-0032).

exercised.resumeReplay joins the coverage counters, so the resume path cannot
quietly stop being generated. Reverting the resume branch to the pre-fix shape
fails on [Connect,Receive(id=1,seq=1),Disconnect,Connect] with
"expected [ 1 ] to include 2" and on the family counter. Merely adding a ready
alongside the replay still passes — recorded in the report as the honest
result: that shape does not break an invariant, it just lets a snapshot do the
repair the replay burst is supposed to do.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KmiqjgTuov1stBTB6uGkvo

* feat(b3-6): contract drift — generated route, table and config-key indexes

B3-6 item 9 (workstream 10). `check:server` already diffs the two
generators; this adds a third for the three server contracts that only
prose described until now.

`Server/cmd/gendocs` rewrites one marked block per document:

- `docs/api.md` "Route index (generated)" — 111 rows from `chi.Walk` over
  the production router built with uploads, voice and the GIF proxy on,
  the same scaffolding `api/absence_contract_test.go` uses. Carries that
  test's vacuity guards: fewer than 100 routes, or no `/admin/` route,
  fails the run.
- `docs/schema.md` "Table index (generated)" — 34 rows from `sqlite_master`
  and `pragma_table_info` on an in-memory database with the migrations
  applied. sqlc exposes no catalog, so the migrated schema is the catalog.
- `docs/server-configuration.md` "Key index (generated)" — 56 keys from the
  koanf struct tags, each mapped to the `###` section of the hand-written
  reference that names it. A key documented nowhere fails the run by name.

Output is padded exactly the way Prettier formats a table, so the drift
check and the hygiene gate agree instead of undoing each other.

Wiring, copied from protocol-verify: `make docs-generate` / `make
docs-verify`, a `DOCS_VERIFY` step in `check:server` and the generator in
`generate` (`scripts/run.mjs`), a CI step on the ubuntu leg of
`server-build-test`, and a `.githooks/pre-commit` block on router, handler,
migration, config and generator paths.

Everything hand-written in the three documents is untouched. The new
`cmd/gendocs` file imports `db` for the catalog, so it takes a boundary row
in the B3-0 inventory and `server-boundaries.md` is regenerated with it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KmiqjgTuov1stBTB6uGkvo

* docs(b3-6): evidence block for item 9 (machine-readable contract drift)

Records the three RED controls and their restore, the counts (111 routes,
34 tables, 56 config keys, 0 undocumented), and two corrections to the item's
spec: the configuration reference table lives in docs/server-configuration.md,
not docs/deployment.md, and sqlc exposes no catalog — the migrated in-memory
schema is the catalog.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KmiqjgTuov1stBTB6uGkvo

* fix(b3-6): gendocs — exclude ANALYZE artifacts, honest hook message, admin routes trigger the hook, generate order, width ceiling

Review findings on item 9.

1. The table index dropped `sqlite_stat1` / `sqlite_stat4`. `db.Migrate` runs
   ANALYZE after applying migrations, so those hold planner statistics, not
   schema — and `sqlite_stat4` exists only because the current
   modernc.org/sqlite build has STAT4, so a driver bump would have failed the
   docs drift check on an unrelated dependency PR. Filtered with GLOB (LIKE's
   `_` is a wildcard), block regenerated, header line's justification
   corrected: 34 -> 32 tables.
2. The pre-commit message now covers both failure modes — stale blocks are
   regenerated and staged, a key the tool named as undocumented is documented
   in docs/server-configuration.md.
3. `Server/admin/.*\.go` added to the hook's trigger: the 34 `/admin/api/*`
   routes are registered there, not in api/router.go, so a new admin route
   could commit stale docs locally.
4. `run.mjs` `generate` runs gendocs after `sqlc generate` — gendocs compiles
   the api package, which imports db/dbgen.
5. The vacuity guard now requires a traversed `/admin/api/` subroute rather
   than any `/admin/` path, which the per-method mount catch-alls satisfied on
   their own, so its message is true. `writeTable` gained a comment naming its
   ceiling: padding counts runes, Prettier counts display width, so a
   full-width cell would diverge — none exists in the generated content.

Evidence block updated for the new table count.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KmiqjgTuov1stBTB6uGkvo

* fix(b3-6): gendocs — generate the route index from the full-tag build with telemetry on; the hook triggers on every api/ and admin/ Go file (Codex P2s on #1456)

1. `/metrics` was missing from the route index. It mounts only when
   `telemetry.PrometheusHandler()` returns non-nil (api/router.go:431-437),
   which needs the otel build tag AND telemetry enabled at runtime; the
   generator ran in the default build with telemetry unset, so the index
   omitted a production route.

   The route index is now the superset build. The scaffold config enables
   telemetry with the Prometheus exporter and the tool calls telemetry.Init
   the way main.go does, and every invocation passes -tags otel,wazero:
   Makefile docs-generate/docs-verify, scripts/run.mjs (DOCS_VERIFY and
   generate), .githooks/pre-commit, the regenCmd quoted into all three block
   header lines, and the CLAUDE.md row. ci.yml inherits it through
   `make docs-verify`. The route block's header line now says which build it
   came from and what is enabled.

   Rather than a build-tag constant, the tool checks the condition that
   actually gates the route: if telemetry.Init leaves no Prometheus handler
   it exits non-zero naming the tags, so the default build cannot quietly
   generate a short index.

   Nothing under Server/api or Server/admin carries a build constraint, so
   wazero adds and removes no route; it rides along so one build serves the
   whole repository. Route count 111 -> 121 (ten per-method rows for the
   /metrics mount, the same shape chi gives /admin and /livekit).

2. The pre-commit trigger named individual api/ files and missed
   client_update.go, whose MountClientUpdateRoute registers a route directly.
   It is now the whole of Server/api/ and Server/admin/ — naming files
   individually is how a trigger goes stale — plus the existing migrations/,
   config/config.go and cmd/gendocs/ patterns.

Evidence block updated: route count and the tagged-build decision.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KmiqjgTuov1stBTB6uGkvo

* test(b3-6): fuzz seeds — epoch-1 corpora for every target, protocol + predicate-parity fuzz targets

Workstream 3. Every Fuzz* target `make fuzz` loops over now has a committed
corpus, so a plain `go test ./...` replays the real wire and not only the
hand-written f.Add shapes. 17 -> 20 targets, 3 -> 20 with a corpus, 98 corpus
files added.

Two new targets:

- ws/protocol_fuzz_test.go — FuzzHandleMessageDecode drives the inbound
  envelope decoder (handlers.go) through a headless NewHubForTest +
  NewTestClient; FuzzCommandPayloads drives all 24 payload decoders in
  commandConstructors, which are pure funcs of (userID, reqID, raw) and so
  need no hub at all. Between them they pin: a rejected frame yields no log
  fields and one invalid-count tick, an accepted frame yields the 64-byte
  capped fields and re-encodes to an equal envelope, a rejected payload never
  returns a command alongside its error, and a decoded command always carries
  the authenticated sender rather than a user id lifted from the payload.
- permissions/predicates_fuzz_test.go — FuzzPredicateParity continues the
  B2-5 parity tables by machine: each predicate against the two-layer
  override formula written out longhand, sentinel included (so "an
  unauthorized caller never learns a channel is archived" is pinned), plus
  CanAdmitSession == CanViewChannel and CanType == CanSendMessage.

Corpus entries are generated from protocol/fixtures/epoch-1 — every distinct
c2s frame of the 11 journeys for the two ws targets, and the role permission
values, channel types, message bodies, usernames, avatar URL and channel ids
those journeys carry for the rest. Replay costs <= 0.02s per target.

Test-only: no production file changes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KmiqjgTuov1stBTB6uGkvo

* docs(b3-6): evidence block for item 5 (fuzz seeds)

Seed counts per target, the two RED negative controls with their failing
excerpts, the replay wall clock, and — as the shared rules require — what was
found stale at HEAD for each of the item's four pointers and what was done
instead: the inbound decoders live in handlers.go/command.go not messages.go,
permissions.Subject has no wire form so parity replaces "round-trips", there
is no pure upload-admission function to fuzz, and there is no recovery-token
parser at all.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KmiqjgTuov1stBTB6uGkvo

* test(b3-6): FuzzParseMentionTokens compares with db.LowerASCII, the OC-0131 rule — make fuzz green again

The target still asserted the Unicode fold (strings.ToLower) that OC-0131
removed from parseMentionTokens: usernames.username is COLLATE NOCASE, which
folds ASCII A-Z only, so the parser folds with db.LowerASCII to stay in step
with GetUserIDsByUsernames' equally ASCII-folded map key. Any mention of a
name starting with an uppercase non-ASCII letter (@Ǥ0, @Ł) therefore failed
the assertion, and `make fuzz` found one within four seconds.

The assertion now uses the same fold the code under test does. Nothing else
in the file changes, and no production behaviour is involved — the fold was
already correct; only the check disagreed with it.

30s of fuzzing on a cleared cache: PASS at 1,159,227 execs (it failed at
66,255 before).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KmiqjgTuov1stBTB6uGkvo

* test(b3-6): fuzz seeds — every command constructor seeded, the auth payload decoder gets its own target, evidence corrected

commandConstructors registers 26 decoders, not the 24 the evidence block
claimed (the count missed the two E2EE keys), and only 16 had any input: ten
commands appear in no epoch-1 journey, so presence_update, call_ring,
call_decline, voice_token_refresh, voice_mute, voice_deafen, voice_camera,
voice_screenshare, voice_mod_deafen and voice_mod_kick were reachable only if
the fuzzer guessed the type string. Each now has a corpus entry carrying a
minimal valid payload taken from its own decoder struct, with the fixture
channel and user ids where they apply.

TestCommandPayloadSeedsCoverEveryConstructor is the guardrail that keeps that
true: it unions the hand-written seed list with the committed corpus and fails
when a registered command has neither, or when a seed names a command nothing
registers. Removing one corpus entry fails it by name.

auth was decoded by neither target. It is not in the constructor table —
authenticateConn reads it before the hub knows the client — so its two corpus
entries were inert under FuzzCommandPayloads. They move to FuzzAuthPayload,
which pins the property that matters in a handshake a stranger controls: no
numeric field takes a value its Go type cannot hold, and the token that will
be hashed is the string the JSON carried. The production decode is inline
behind a live socket read and a session lookup, so the target mirrors the
struct and the comment says why rather than reshaping production to expose it.

Corpus entries now credit the journey that owns the frame: the ping frame to
ping.json, the auth frame to fresh-connect.json.

Test-only: no production file changes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KmiqjgTuov1stBTB6uGkvo

* test(b3-6): fuzz seeds — auth target gates on the real epoch constants; corpus reader fails on a malformed entry

FuzzAuthPayload was proving encoding/json behaviour against a copy of the
handshake struct and nothing more. It now mirrors the two rejections
authenticateConn actually makes — the decode error and the empty token as one
(serve_auth.go:58), then the epoch window (:62) — using minClientEpoch and
ProtocolEpoch themselves, so moving either constant or that gate turns the
target red instead of leaving it quietly stale. The load-bearing case is the
absent epoch: every client up to v1.2.0-alpha.4 predates the field and relies
on the zero value being inside the window, so raising minClientEpoch above 0
now fails here rather than in the field. Setting it to 1 locally fails both
fixture-derived corpus entries and two seeds.

Deciding "absent" needed care, and fuzzing found that out in three seconds:
encoding/json falls back to a case-INSENSITIVE tag match, so "epoCh" populates
Epoch while an exact key lookup calls the field missing. The probe now decodes
into a *int, which is the same matching the server does, and three seeds pin
the rule.

corpusFirstString skipped a corpus file with no string(...) argument, which
would have let a malformed entry masquerade as a seeded command while the
coverage test still passed. It is now a failure naming the file.

The struct comment cited serve_auth.go:44; the struct starts at :45.

Test-only: no production file changes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KmiqjgTuov1stBTB6uGkvo

* test(b3-6): fuzz seeds — token expectation uses struct decoding semantics; parity oracle states the zero-permission ordering (Codex P2s on #1457)

FuzzAuthPayload derived the expected token from an exact key lookup, so
{"token":"a","TOKEN":"b"} failed the target: encoding/json resolves both keys
to the tagged field and the last one wins, leaving the handshake holding "b"
while the lookup expected "a". The expectation now comes from a probe struct
carrying the same json:"token" tag, so it follows the decoder's field
resolution rather than the raw key set — the same correction the epoch probe
already needed. A corpus entry pins it; reverting the probe fails on that
entry by name.

rawHas mirrors Subject.Has, which applies the Administrator bypass before the
zero-permission refusal, so an administrator holds an empty mask where
HasPerm(_, 0) is false. Parity with production is this target's purpose, so
the ordering stays; what changes is that the oracle's contract comment now
states it instead of claiming the tidier rule, and
TestSubjectHasZeroPermIsAdminBypassed records the divergence as observed
behaviour with a message that says to move both together if it is ever
changed deliberately.

The evidence block gains the call-site survey behind that: every leaf caller
of Subject.Has names a permissions.* constant, the variable-forwarding
wrappers are all reached with named constants, and the one table-driven site
has two rows.

No production code changed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KmiqjgTuov1stBTB6uGkvo

* feat(b3-6): nightly docker smoke on dev — its own workflow, plus a timeout on ci.yml's verify job

dev is not a push trigger, so an image regression on dev is only found when a
dev -> main PR opens. A nightly at 03:00 UTC closes that window.

Not a schedule on ci.yml, which is what the plan proposed: a scheduled run
attaches its check runs to the default branch's tip, so the jobs skipped to
scope the nightly to the smoke would land on main's tip as `skipped` under
seven of the twelve required contexts. verify-gate-evidence.mjs:45-61 keeps
the latest attempt per name and does not count `skipped` as success, and
release.yml's gate-evidence job gates every build and publish job on it — so
a tag cut from a main tip that had sat through one nightly would be refused.
A separate file writes one check run, under a name that is no required
context, and leaves ci.yml's job selection untouched.

The nightly checks out dev explicitly, since a schedule always reads the
workflow from the default branch. Its build and smoke steps are the
server-docker-build ones verbatim — same pinned actions, same commands, same
Server/scripts/docker-smoke.sh that release.yml runs — with a keep-in-sync
comment on both jobs.

ci.yml's only change is `timeout-minutes: 20` on server-docker-build. The
plan asserted that B1-7's guard check already enforced a timeout there; it
does not (check-workflow-guards.mjs audits only the workflows in METERED,
which is claude.yml alone), and the job had none, so it inherited GitHub's
360-minute default.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KmiqjgTuov1stBTB6uGkvo

* docs(b3-6): evidence block for item 8 — the deviation, the gate conflict behind it, and the proof command

Records why the nightly is its own workflow when the item says it is not:
a skipped job still writes a check run (observed on main's tip, where Tauri
Full Build reports `skipped`), a scheduled run attaches to the default
branch's tip, and verify-gate-evidence.mjs:45-61 would then read seven of the
twelve required contexts as skipped on the commit a release is tagged from.

Also: that a schedule only runs from the default branch, so the nightly does
not start until this file reaches main at the next release merge; the
contents of the new workflow against the job it mirrors; the controller's
proof command with the observed-SHA placeholder; and the false premise in the
item's "B1-7's guard check enforces both", which is what the one-line ci.yml
timeout answers.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KmiqjgTuov1stBTB6uGkvo

* docs(b3-6): item 8 — proof recorded (run 33301623322), cache-scope note, comment count

Run 33301623322 fired from the temporary push trigger, now dropped: "Print
checked-out revision" logged event=push on the branch ref, and
git rev-parse HEAD printed 75d64dd412 — dev's
tip at the time, not the branch's, which is what `ref: dev` exists to do.
Build and boot-smoke green.

Two facts the evidence block was missing. A scheduled run has
github.ref = refs/heads/main, so the buildx type=gha cache is scoped to the
default branch while the layers come from dev's tree — the only behavioural
difference from the PR job, and harmless because the cache is
content-addressed. And a red nightly reaches the repository owner, by
GitHub's scheduled-workflow failure email.

ci.yml's release-gate comment said docker-smoke.sh is called "from both
workflows"; it is three now.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KmiqjgTuov1stBTB6uGkvo

* test(b3-6): seeded hub simulation and fault-injected transport (items 2 and 3)

Server/ws/hub_sim_test.go drives a PCG-seeded interleaving of subscribe,
broadcast (global, channel, recipients-scoped, sequenced DM), ack, disconnect
and reconnect-transfer over a real Hub with eight headless clients, and a
model client checks the per-client FIFO/seq oracle from Server/CLAUDE.md
after every step: strictly increasing seq per connection, exact audience
delivery (nothing lost, extra or twice), a resume replayed exactly from the
watermark to the seq at which registerNow ran, h.seq advancing only for a
frame that reached the ring, an evicted watermark refused a replay, and a
replaced socket's late teardown reporting replaced=true. The resume step runs
reconnectRegister as-is (snapshot and registerNow under one seqMu section)
on a goroutine while up to three broadcasts race it; the model recovers the
snapshot point from the replay burst, so any interleaving is checkable.

OWNCORD_SIM_SEED replays one seed, OWNCORD_SIM_SEEDS (default 20) and
OWNCORD_SIM_STEPS (default 200) size a run, and a failure prints the seed,
the step, a ready-to-paste replay line and the last steps. The default runs
in about 2.3 s under -race; `make sim` runs 10,000 steps per seed.

Server/ws/faultconn_test.go is the seeded, deterministic frame transport the
simulation reads through: drop, tail cut, duplicate, bounded reorder and an
order-preserving lag from its own PCG stream, exported to ws_test through
export_test.go as NewFaultConnForTest. The simulation's default wire is a lag
plus tail cuts, the one fault a TCP-backed WebSocket really has; the silent
drop is the negative control that proves the oracle notices a lost replay.

BenchmarkReconnectStorm resumes 50 live clients per op through the same
path. newTestHub and its three seed helpers take testing.TB so the benchmark
can share them. No production code changes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KmiqjgTuov1stBTB6uGkvo

* docs(b3-6): evidence block for items 2 and 3 (hub simulation, fault transport)

Oracle, the RED/GREEN excerpts (inverted assertion, seed replay, drop-all
wire, unsynchronized registerNow), wall-clock and benchmark figures, gate
results and the epoch-harness decision, under B3-6 in the plan.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KmiqjgTuov1stBTB6uGkvo

* fix(b3-6): hub sim — deterministic topic limiter for exact replay, a floor on the step mix, auth-frame-wins under transfer, wire-seed mixing

Review fixes for items 2 and 3.

Exact replay. TopicRateLimiter keys its window on time.Now(), so at 10,000
steps the shed boundary was a timing-dependent step and every later seq
differed between runs; the printed OWNCORD_SIM_SEED line could not reproduce
a failure. FreezeTopicLimiterForTest (export_test.go) swaps the hub's limiter
for one whose window never rolls over inside a run, so the shed is a
per-channel count. Three more leaks of the scheduler's interleaving into the
trajectory surfaced once that was fixed, and are closed the same way — by
taking the decision away from the race or making both outcomes read the
same: racing frames are pulled into the wire at attach time (queue fill no
longer depends on which side of the snapshot they fell), the racing burst is
aimed at the resuming client's own audience (a replay-superset frame was
read iff it landed before the snapshot), and a resume within the burst's
reach of the ring's eviction boundary is not raced (the allocations could
evict the watermark before or after the snapshot and pick replay or
fallback). Three runs of one seed now print byte-identical stats; what still
varies — how many racing seqs land in the replay burst — is printed on its
own line and stated in the doc comment.

Floor. TestHubSimulation aggregates the per-seed stats and requires every
load-bearing transition (the four broadcast kinds, resume, fallback, fresh,
cut, kicked, racing-in-replay) at least once across the default run, so a
constant change cannot turn the simulation into no-ops with CI green. Its
first run found that the overflow kick had become unreachable at 200 steps;
the sim's queue is 12 now (production stays 256).

Also: the resume step draws active_channel_id as none / the open channel /
another channel whether or not the old socket is registered, so registerNow's
auth-frame-wins branch runs under the transfer; the wire's PCG takes the seed
and (idx<<32|conns) as its two words instead of an arithmetic mix that
collided past 131 connections; seedTestUser takes testing.TB like its
siblings; the evidence block lists what the simulation does not cover and the
-timeout 60m the ten-pass deadlock gate needs, with the same line under
Traps carried forward.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KmiqjgTuov1stBTB6uGkvo

* test(b3-6): hub sim — floor on raced resumes instead of the scheduler-decided key; log when the floor is skipped

racing-in-replay was the one floor key the scheduler decides, so a correct hub could in principle fail the floor on a run where no racing seq landed inside a burst. The floor now keys on raced resumes — a resume that got a replay while a burst ran (burst > 0 && ok), which the seed determines — and racing-in-replay stays a printed count. The floor also says so when it is skipped for OWNCORD_SIM_SEED or a shorter seed list instead of returning silently.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

Claude-Session: https://claude.ai/code/session_01KmiqjgTuov1stBTB6uGkvo

* test(b3-6): hub sim — raced counts a broadcast allocated while registration was in progress, not the requested burst

Codex P2 on #1458: raced incremented on burst > 0, which counted a resume whose goroutine had returned before the first broadcast ran and one whose every channel broadcast the limiter shed, so the floor could pass with no broadcast overlapping a registration. raced now counts a resume where a racing broadcast allocated a seq while the reconnect goroutine had not yet been observed to return (the driver's done handshake, checked after each allocation). That is the scheduler's call, so raced moves off the deterministic stats line and is floored only in aggregate across the 20 default seeds — 179 bursts per run, 178–179 observed overlapping in three measured runs, odds named in the comment and the evidence block. The requested burst stays a printed, seed-determined count (bursts) and is floored as before; the floor logs its totals.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

Claude-Session: https://claude.ai/code/session_01KmiqjgTuov1stBTB6uGkvo

* test(b3-6): benchmarks and the bench-baseline script (item 6)

Five new Benchmark* beside the reconnect storm the hub-simulation item
delivered, one file per package touched:

- BenchmarkPermissionInvalidation (ws) — RefreshChannelVisibility over 50
  registered clients, the fan-out an override or role edit triggers.
- BenchmarkBroadcastFanout (ws) — one sequenced global broadcast through
  deliverBroadcast to 100 headless clients, queues drained per iteration.
- BenchmarkReplaySelection (ws) — EventsSince over a ring filled to the
  hub's own capacity, from a watermark halfway back.
- BenchmarkReadStateWrite (service) — one mark_read/channel_focus through
  ChannelService against an in-memory SQLite; a distinct user per
  iteration so the no-op write-skip never short-circuits the write.
- BenchmarkUploadAdmission (api) — sanitizeUploadFilename then
  storage.ValidateFileType on a fixed fixture.

scripts/bench-baseline.sh runs the six through benchstat (pinned, run with
go run, not a go.mod dependency) into docs/plans/b3-bench-baseline-<date>.md.
It fails when an expected benchmark name is absent from the run, so a rename
cannot silently shorten the baseline; `make bench-baseline` wires it in, and
nothing else does. Baselines are recorded, not gated.

quietLogs points the default logger at io.Discard while a hub benchmark
runs: go test prints a benchmark's name before running it, so the hub's
per-registration INFO lines land inside the result line and benchstat drops
the benchmark from the table. Three service seed helpers now take
testing.TB, as the ws helpers already do.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KmiqjgTuov1stBTB6uGkvo

* docs(b3-6): recorded bench baseline 2026-08-30 and the item 6 evidence block

The baseline `make bench-baseline` produced at c7917fcc (go1.26.7
windows/amd64, Ryzen 9 7950X3D, -count=6, 65 s): benchstat medians for the
six B3-6 benchmarks, with a provenance block and a "reading these numbers"
note. Recorded, not gated — no CI step reads it and no workflow runs the
script; the performance gate is B6's.

Plus the plan index row and the evidence block under B3-6, carrying the RED
excerpt (a renamed benchmark makes the script exit 1 naming it, writing no
baseline), the -benchtime=1x smoke showing all six ran, and the headline
figures.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KmiqjgTuov1stBTB6uGkvo

* fix(b3-6): bench-baseline — render to a temp file and move on success, guard the benchstat table too, dump unfiltered output on failure

The `{ … } >"$out"` group truncated the committed baseline at group start, so
under `set -euo pipefail` a benchstat failure — first-run module fetch, proxy
outage, a bad pin — left a 13-byte stub where the baseline had been. The
document now renders into the temp directory and is moved onto its path only
once everything has succeeded, making "no baseline written on failure" true
for every path, not just the early ones.

The expected-name guard grepped the raw output, and a result line corrupted by
output written from inside the benchmark still starts with the benchmark's
name — so benchstat drops that row, exits 0, and the guard sees nothing wrong.
The same loop now runs a second time over the rendered table, where benchstat
prints the name without its Benchmark prefix; a name missing there fails the
run naming it.

Also: tee the unfiltered stream so a failure dump shows toolchain and module
errors rather than the grep-filtered view; reject a BENCH_COUNT that is not a
positive integer; drop the redundant `|| exit 1` after `cd` under `set -e`.

The baseline document gains three lines under "Reading these numbers" — that
`go test ./...` runs the three packages' benchmarks concurrently, that
PermissionInvalidation measures the uncached bare-hub path, and how
regeneration works — and the plan index row says only the newest baseline is
kept. Regenerated on the rebased tree.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KmiqjgTuov1stBTB6uGkvo

* docs(b3-6): bench baseline regenerated on the rebased tree

The document's header names the commit it was measured on; the rebase onto
the chained hub-sim tip replaced that SHA, so the six benchmarks were re-run
(-count=6) on the rebased tree and the table re-recorded.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KmiqjgTuov1stBTB6uGkvo

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-30 15:24:53 +00:00
J3vbandClaude Fable 5 fb5b058ee4 test(b3-6): seeded hub simulation with a six-part FIFO/seq oracle, fault-injected transport, exact seed replay (Tier 3b/3c) (#1458)
* test(b3-6): seeded hub simulation and fault-injected transport (items 2 and 3)

Server/ws/hub_sim_test.go drives a PCG-seeded interleaving of subscribe,
broadcast (global, channel, recipients-scoped, sequenced DM), ack, disconnect
and reconnect-transfer over a real Hub with eight headless clients, and a
model client checks the per-client FIFO/seq oracle from Server/CLAUDE.md
after every step: strictly increasing seq per connection, exact audience
delivery (nothing lost, extra or twice), a resume replayed exactly from the
watermark to the seq at which registerNow ran, h.seq advancing only for a
frame that reached the ring, an evicted watermark refused a replay, and a
replaced socket's late teardown reporting replaced=true. The resume step runs
reconnectRegister as-is (snapshot and registerNow under one seqMu section)
on a goroutine while up to three broadcasts race it; the model recovers the
snapshot point from the replay burst, so any interleaving is checkable.

OWNCORD_SIM_SEED replays one seed, OWNCORD_SIM_SEEDS (default 20) and
OWNCORD_SIM_STEPS (default 200) size a run, and a failure prints the seed,
the step, a ready-to-paste replay line and the last steps. The default runs
in about 2.3 s under -race; `make sim` runs 10,000 steps per seed.

Server/ws/faultconn_test.go is the seeded, deterministic frame transport the
simulation reads through: drop, tail cut, duplicate, bounded reorder and an
order-preserving lag from its own PCG stream, exported to ws_test through
export_test.go as NewFaultConnForTest. The simulation's default wire is a lag
plus tail cuts, the one fault a TCP-backed WebSocket really has; the silent
drop is the negative control that proves the oracle notices a lost replay.

BenchmarkReconnectStorm resumes 50 live clients per op through the same
path. newTestHub and its three seed helpers take testing.TB so the benchmark
can share them. No production code changes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KmiqjgTuov1stBTB6uGkvo

* docs(b3-6): evidence block for items 2 and 3 (hub simulation, fault transport)

Oracle, the RED/GREEN excerpts (inverted assertion, seed replay, drop-all
wire, unsynchronized registerNow), wall-clock and benchmark figures, gate
results and the epoch-harness decision, under B3-6 in the plan.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KmiqjgTuov1stBTB6uGkvo

* fix(b3-6): hub sim — deterministic topic limiter for exact replay, a floor on the step mix, auth-frame-wins under transfer, wire-seed mixing

Review fixes for items 2 and 3.

Exact replay. TopicRateLimiter keys its window on time.Now(), so at 10,000
steps the shed boundary was a timing-dependent step and every later seq
differed between runs; the printed OWNCORD_SIM_SEED line could not reproduce
a failure. FreezeTopicLimiterForTest (export_test.go) swaps the hub's limiter
for one whose window never rolls over inside a run, so the shed is a
per-channel count. Three more leaks of the scheduler's interleaving into the
trajectory surfaced once that was fixed, and are closed the same way — by
taking the decision away from the race or making both outcomes read the
same: racing frames are pulled into the wire at attach time (queue fill no
longer depends on which side of the snapshot they fell), the racing burst is
aimed at the resuming client's own audience (a replay-superset frame was
read iff it landed before the snapshot), and a resume within the burst's
reach of the ring's eviction boundary is not raced (the allocations could
evict the watermark before or after the snapshot and pick replay or
fallback). Three runs of one seed now print byte-identical stats; what still
varies — how many racing seqs land in the replay burst — is printed on its
own line and stated in the doc comment.

Floor. TestHubSimulation aggregates the per-seed stats and requires every
load-bearing transition (the four broadcast kinds, resume, fallback, fresh,
cut, kicked, racing-in-replay) at least once across the default run, so a
constant change cannot turn the simulation into no-ops with CI green. Its
first run found that the overflow kick had become unreachable at 200 steps;
the sim's queue is 12 now (production stays 256).

Also: the resume step draws active_channel_id as none / the open channel /
another channel whether or not the old socket is registered, so registerNow's
auth-frame-wins branch runs under the transfer; the wire's PCG takes the seed
and (idx<<32|conns) as its two words instead of an arithmetic mix that
collided past 131 connections; seedTestUser takes testing.TB like its
siblings; the evidence block lists what the simulation does not cover and the
-timeout 60m the ten-pass deadlock gate needs, with the same line under
Traps carried forward.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KmiqjgTuov1stBTB6uGkvo

* test(b3-6): hub sim — floor on raced resumes instead of the scheduler-decided key; log when the floor is skipped

racing-in-replay was the one floor key the scheduler decides, so a correct hub could in principle fail the floor on a run where no racing seq landed inside a burst. The floor now keys on raced resumes — a resume that got a replay while a burst ran (burst > 0 && ok), which the seed determines — and racing-in-replay stays a printed count. The floor also says so when it is skipped for OWNCORD_SIM_SEED or a shorter seed list instead of returning silently.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

Claude-Session: https://claude.ai/code/session_01KmiqjgTuov1stBTB6uGkvo

* test(b3-6): hub sim — raced counts a broadcast allocated while registration was in progress, not the requested burst

Codex P2 on #1458: raced incremented on burst > 0, which counted a resume whose goroutine had returned before the first broadcast ran and one whose every channel broadcast the limiter shed, so the floor could pass with no broadcast overlapping a registration. raced now counts a resume where a racing broadcast allocated a seq while the reconnect goroutine had not yet been observed to return (the driver's done handshake, checked after each allocation). That is the scheduler's call, so raced moves off the deterministic stats line and is floored only in aggregate across the 20 default seeds — 179 bursts per run, 178–179 observed overlapping in three measured runs, odds named in the comment and the evidence block. The requested burst stays a printed, seed-determined count (bursts) and is floored as before; the floor logs its totals.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

Claude-Session: https://claude.ai/code/session_01KmiqjgTuov1stBTB6uGkvo

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-30 14:57:34 +00:00
J3vbandClaude Fable 5 746f60f789 feat(b3-6): nightly Docker smoke of dev in its own workflow, plus a timeout on the CI verify job (#1452)
* feat(b3-6): nightly docker smoke on dev — its own workflow, plus a timeout on ci.yml's verify job

dev is not a push trigger, so an image regression on dev is only found when a
dev -> main PR opens. A nightly at 03:00 UTC closes that window.

Not a schedule on ci.yml, which is what the plan proposed: a scheduled run
attaches its check runs to the default branch's tip, so the jobs skipped to
scope the nightly to the smoke would land on main's tip as `skipped` under
seven of the twelve required contexts. verify-gate-evidence.mjs:45-61 keeps
the latest attempt per name and does not count `skipped` as success, and
release.yml's gate-evidence job gates every build and publish job on it — so
a tag cut from a main tip that had sat through one nightly would be refused.
A separate file writes one check run, under a name that is no required
context, and leaves ci.yml's job selection untouched.

The nightly checks out dev explicitly, since a schedule always reads the
workflow from the default branch. Its build and smoke steps are the
server-docker-build ones verbatim — same pinned actions, same commands, same
Server/scripts/docker-smoke.sh that release.yml runs — with a keep-in-sync
comment on both jobs.

ci.yml's only change is `timeout-minutes: 20` on server-docker-build. The
plan asserted that B1-7's guard check already enforced a timeout there; it
does not (check-workflow-guards.mjs audits only the workflows in METERED,
which is claude.yml alone), and the job had none, so it inherited GitHub's
360-minute default.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KmiqjgTuov1stBTB6uGkvo

* docs(b3-6): evidence block for item 8 — the deviation, the gate conflict behind it, and the proof command

Records why the nightly is its own workflow when the item says it is not:
a skipped job still writes a check run (observed on main's tip, where Tauri
Full Build reports `skipped`), a scheduled run attaches to the default
branch's tip, and verify-gate-evidence.mjs:45-61 would then read seven of the
twelve required contexts as skipped on the commit a release is tagged from.

Also: that a schedule only runs from the default branch, so the nightly does
not start until this file reaches main at the next release merge; the
contents of the new workflow against the job it mirrors; the controller's
proof command with the observed-SHA placeholder; and the false premise in the
item's "B1-7's guard check enforces both", which is what the one-line ci.yml
timeout answers.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KmiqjgTuov1stBTB6uGkvo

* docs(b3-6): item 8 — proof recorded (run 33301623322), cache-scope note, comment count

Run 33301623322 fired from the temporary push trigger, now dropped: "Print
checked-out revision" logged event=push on the branch ref, and
git rev-parse HEAD printed 75d64dd412 — dev's
tip at the time, not the branch's, which is what `ref: dev` exists to do.
Build and boot-smoke green.

Two facts the evidence block was missing. A scheduled run has
github.ref = refs/heads/main, so the buildx type=gha cache is scoped to the
default branch while the layers come from dev's tree — the only behavioural
difference from the PR job, and harmless because the cache is
content-addressed. And a red nightly reaches the repository owner, by
GitHub's scheduled-workflow failure email.

ci.yml's release-gate comment said docker-smoke.sh is called "from both
workflows"; it is three now.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KmiqjgTuov1stBTB6uGkvo

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-30 14:43:03 +00:00
J3vbandClaude Fable 5 1e9ac9a842 test(b3-6): fuzz seeds — epoch-1 corpora for every target, protocol/auth/predicate-parity fuzz targets, make fuzz green (#1457)
* test(b3-6): fuzz seeds — epoch-1 corpora for every target, protocol + predicate-parity fuzz targets

Workstream 3. Every Fuzz* target `make fuzz` loops over now has a committed
corpus, so a plain `go test ./...` replays the real wire and not only the
hand-written f.Add shapes. 17 -> 20 targets, 3 -> 20 with a corpus, 98 corpus
files added.

Two new targets:

- ws/protocol_fuzz_test.go — FuzzHandleMessageDecode drives the inbound
  envelope decoder (handlers.go) through a headless NewHubForTest +
  NewTestClient; FuzzCommandPayloads drives all 24 payload decoders in
  commandConstructors, which are pure funcs of (userID, reqID, raw) and so
  need no hub at all. Between them they pin: a rejected frame yields no log
  fields and one invalid-count tick, an accepted frame yields the 64-byte
  capped fields and re-encodes to an equal envelope, a rejected payload never
  returns a command alongside its error, and a decoded command always carries
  the authenticated sender rather than a user id lifted from the payload.
- permissions/predicates_fuzz_test.go — FuzzPredicateParity continues the
  B2-5 parity tables by machine: each predicate against the two-layer
  override formula written out longhand, sentinel included (so "an
  unauthorized caller never learns a channel is archived" is pinned), plus
  CanAdmitSession == CanViewChannel and CanType == CanSendMessage.

Corpus entries are generated from protocol/fixtures/epoch-1 — every distinct
c2s frame of the 11 journeys for the two ws targets, and the role permission
values, channel types, message bodies, usernames, avatar URL and channel ids
those journeys carry for the rest. Replay costs <= 0.02s per target.

Test-only: no production file changes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KmiqjgTuov1stBTB6uGkvo

* docs(b3-6): evidence block for item 5 (fuzz seeds)

Seed counts per target, the two RED negative controls with their failing
excerpts, the replay wall clock, and — as the shared rules require — what was
found stale at HEAD for each of the item's four pointers and what was done
instead: the inbound decoders live in handlers.go/command.go not messages.go,
permissions.Subject has no wire form so parity replaces "round-trips", there
is no pure upload-admission function to fuzz, and there is no recovery-token
parser at all.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KmiqjgTuov1stBTB6uGkvo

* test(b3-6): FuzzParseMentionTokens compares with db.LowerASCII, the OC-0131 rule — make fuzz green again

The target still asserted the Unicode fold (strings.ToLower) that OC-0131
removed from parseMentionTokens: usernames.username is COLLATE NOCASE, which
folds ASCII A-Z only, so the parser folds with db.LowerASCII to stay in step
with GetUserIDsByUsernames' equally ASCII-folded map key. Any mention of a
name starting with an uppercase non-ASCII letter (@Ǥ0, @Ł) therefore failed
the assertion, and `make fuzz` found one within four seconds.

The assertion now uses the same fold the code under test does. Nothing else
in the file changes, and no production behaviour is involved — the fold was
already correct; only the check disagreed with it.

30s of fuzzing on a cleared cache: PASS at 1,159,227 execs (it failed at
66,255 before).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KmiqjgTuov1stBTB6uGkvo

* test(b3-6): fuzz seeds — every command constructor seeded, the auth payload decoder gets its own target, evidence corrected

commandConstructors registers 26 decoders, not the 24 the evidence block
claimed (the count missed the two E2EE keys), and only 16 had any input: ten
commands appear in no epoch-1 journey, so presence_update, call_ring,
call_decline, voice_token_refresh, voice_mute, voice_deafen, voice_camera,
voice_screenshare, voice_mod_deafen and voice_mod_kick were reachable only if
the fuzzer guessed the type string. Each now has a corpus entry carrying a
minimal valid payload taken from its own decoder struct, with the fixture
channel and user ids where they apply.

TestCommandPayloadSeedsCoverEveryConstructor is the guardrail that keeps that
true: it unions the hand-written seed list with the committed corpus and fails
when a registered command has neither, or when a seed names a command nothing
registers. Removing one corpus entry fails it by name.

auth was decoded by neither target. It is not in the constructor table —
authenticateConn reads it before the hub knows the client — so its two corpus
entries were inert under FuzzCommandPayloads. They move to FuzzAuthPayload,
which pins the property that matters in a handshake a stranger controls: no
numeric field takes a value its Go type cannot hold, and the token that will
be hashed is the string the JSON carried. The production decode is inline
behind a live socket read and a session lookup, so the target mirrors the
struct and the comment says why rather than reshaping production to expose it.

Corpus entries now credit the journey that owns the frame: the ping frame to
ping.json, the auth frame to fresh-connect.json.

Test-only: no production file changes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KmiqjgTuov1stBTB6uGkvo

* test(b3-6): fuzz seeds — auth target gates on the real epoch constants; corpus reader fails on a malformed entry

FuzzAuthPayload was proving encoding/json behaviour against a copy of the
handshake struct and nothing more. It now mirrors the two rejections
authenticateConn actually makes — the decode error and the empty token as one
(serve_auth.go:58), then the epoch window (:62) — using minClientEpoch and
ProtocolEpoch themselves, so moving either constant or that gate turns the
target red instead of leaving it quietly stale. The load-bearing case is the
absent epoch: every client up to v1.2.0-alpha.4 predates the field and relies
on the zero value being inside the window, so raising minClientEpoch above 0
now fails here rather than in the field. Setting it to 1 locally fails both
fixture-derived corpus entries and two seeds.

Deciding "absent" needed care, and fuzzing found that out in three seconds:
encoding/json falls back to a case-INSENSITIVE tag match, so "epoCh" populates
Epoch while an exact key lookup calls the field missing. The probe now decodes
into a *int, which is the same matching the server does, and three seeds pin
the rule.

corpusFirstString skipped a corpus file with no string(...) argument, which
would have let a malformed entry masquerade as a seeded command while the
coverage test still passed. It is now a failure naming the file.

The struct comment cited serve_auth.go:44; the struct starts at :45.

Test-only: no production file changes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KmiqjgTuov1stBTB6uGkvo

* test(b3-6): fuzz seeds — token expectation uses struct decoding semantics; parity oracle states the zero-permission ordering (Codex P2s on #1457)

FuzzAuthPayload derived the expected token from an exact key lookup, so
{"token":"a","TOKEN":"b"} failed the target: encoding/json resolves both keys
to the tagged field and the last one wins, leaving the handshake holding "b"
while the lookup expected "a". The expectation now comes from a probe struct
carrying the same json:"token" tag, so it follows the decoder's field
resolution rather than the raw key set — the same correction the epoch probe
already needed. A corpus entry pins it; reverting the probe fails on that
entry by name.

rawHas mirrors Subject.Has, which applies the Administrator bypass before the
zero-permission refusal, so an administrator holds an empty mask where
HasPerm(_, 0) is false. Parity with production is this target's purpose, so
the ordering stays; what changes is that the oracle's contract comment now
states it instead of claiming the tidier rule, and
TestSubjectHasZeroPermIsAdminBypassed records the divergence as observed
behaviour with a message that says to move both together if it is ever
changed deliberately.

The evidence block gains the call-site survey behind that: every leaf caller
of Subject.Has names a permissions.* constant, the variable-forwarding
wrappers are all reached with named constants, and the one table-driven site
has two rows.

No production code changed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KmiqjgTuov1stBTB6uGkvo

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-30 14:30:53 +00:00
J3vbandClaude Fable 5 8cb0ec9e35 feat(b3-6): contract drift — generated route, table and config-key indexes with a CI drift check (#1456)
* feat(b3-6): contract drift — generated route, table and config-key indexes

B3-6 item 9 (workstream 10). `check:server` already diffs the two
generators; this adds a third for the three server contracts that only
prose described until now.

`Server/cmd/gendocs` rewrites one marked block per document:

- `docs/api.md` "Route index (generated)" — 111 rows from `chi.Walk` over
  the production router built with uploads, voice and the GIF proxy on,
  the same scaffolding `api/absence_contract_test.go` uses. Carries that
  test's vacuity guards: fewer than 100 routes, or no `/admin/` route,
  fails the run.
- `docs/schema.md` "Table index (generated)" — 34 rows from `sqlite_master`
  and `pragma_table_info` on an in-memory database with the migrations
  applied. sqlc exposes no catalog, so the migrated schema is the catalog.
- `docs/server-configuration.md` "Key index (generated)" — 56 keys from the
  koanf struct tags, each mapped to the `###` section of the hand-written
  reference that names it. A key documented nowhere fails the run by name.

Output is padded exactly the way Prettier formats a table, so the drift
check and the hygiene gate agree instead of undoing each other.

Wiring, copied from protocol-verify: `make docs-generate` / `make
docs-verify`, a `DOCS_VERIFY` step in `check:server` and the generator in
`generate` (`scripts/run.mjs`), a CI step on the ubuntu leg of
`server-build-test`, and a `.githooks/pre-commit` block on router, handler,
migration, config and generator paths.

Everything hand-written in the three documents is untouched. The new
`cmd/gendocs` file imports `db` for the catalog, so it takes a boundary row
in the B3-0 inventory and `server-boundaries.md` is regenerated with it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KmiqjgTuov1stBTB6uGkvo

* docs(b3-6): evidence block for item 9 (machine-readable contract drift)

Records the three RED controls and their restore, the counts (111 routes,
34 tables, 56 config keys, 0 undocumented), and two corrections to the item's
spec: the configuration reference table lives in docs/server-configuration.md,
not docs/deployment.md, and sqlc exposes no catalog — the migrated in-memory
schema is the catalog.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KmiqjgTuov1stBTB6uGkvo

* fix(b3-6): gendocs — exclude ANALYZE artifacts, honest hook message, admin routes trigger the hook, generate order, width ceiling

Review findings on item 9.

1. The table index dropped `sqlite_stat1` / `sqlite_stat4`. `db.Migrate` runs
   ANALYZE after applying migrations, so those hold planner statistics, not
   schema — and `sqlite_stat4` exists only because the current
   modernc.org/sqlite build has STAT4, so a driver bump would have failed the
   docs drift check on an unrelated dependency PR. Filtered with GLOB (LIKE's
   `_` is a wildcard), block regenerated, header line's justification
   corrected: 34 -> 32 tables.
2. The pre-commit message now covers both failure modes — stale blocks are
   regenerated and staged, a key the tool named as undocumented is documented
   in docs/server-configuration.md.
3. `Server/admin/.*\.go` added to the hook's trigger: the 34 `/admin/api/*`
   routes are registered there, not in api/router.go, so a new admin route
   could commit stale docs locally.
4. `run.mjs` `generate` runs gendocs after `sqlc generate` — gendocs compiles
   the api package, which imports db/dbgen.
5. The vacuity guard now requires a traversed `/admin/api/` subroute rather
   than any `/admin/` path, which the per-method mount catch-alls satisfied on
   their own, so its message is true. `writeTable` gained a comment naming its
   ceiling: padding counts runes, Prettier counts display width, so a
   full-width cell would diverge — none exists in the generated content.

Evidence block updated for the new table count.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KmiqjgTuov1stBTB6uGkvo

* fix(b3-6): gendocs — generate the route index from the full-tag build with telemetry on; the hook triggers on every api/ and admin/ Go file (Codex P2s on #1456)

1. `/metrics` was missing from the route index. It mounts only when
   `telemetry.PrometheusHandler()` returns non-nil (api/router.go:431-437),
   which needs the otel build tag AND telemetry enabled at runtime; the
   generator ran in the default build with telemetry unset, so the index
   omitted a production route.

   The route index is now the superset build. The scaffold config enables
   telemetry with the Prometheus exporter and the tool calls telemetry.Init
   the way main.go does, and every invocation passes -tags otel,wazero:
   Makefile docs-generate/docs-verify, scripts/run.mjs (DOCS_VERIFY and
   generate), .githooks/pre-commit, the regenCmd quoted into all three block
   header lines, and the CLAUDE.md row. ci.yml inherits it through
   `make docs-verify`. The route block's header line now says which build it
   came from and what is enabled.

   Rather than a build-tag constant, the tool checks the condition that
   actually gates the route: if telemetry.Init leaves no Prometheus handler
   it exits non-zero naming the tags, so the default build cannot quietly
   generate a short index.

   Nothing under Server/api or Server/admin carries a build constraint, so
   wazero adds and removes no route; it rides along so one build serves the
   whole repository. Route count 111 -> 121 (ten per-method rows for the
   /metrics mount, the same shape chi gives /admin and /livekit).

2. The pre-commit trigger named individual api/ files and missed
   client_update.go, whose MountClientUpdateRoute registers a route directly.
   It is now the whole of Server/api/ and Server/admin/ — naming files
   individually is how a trigger goes stale — plus the existing migrations/,
   config/config.go and cmd/gendocs/ patterns.

Evidence block updated: route count and the tagged-build decision.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KmiqjgTuov1stBTB6uGkvo

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-30 14:18:54 +00:00
J3vbandClaude Fable 5 e0f9848aed test(b3-6): client connection model test — fc.commands over the real ws client, dispatcher and stores (Tier 3a) (#1455)
* test(b3-6): client connection model test — fc.commands over the real stack

B3-6 item 4 (Tier 3a of docs/plans/bug-detection-improvements.md). Property
tests find bad functions; this repo's recurring bugs are bad orderings, and
nothing generated orderings.

Client/tests/unit/connection.model.test.ts drives the real connection stack —
createWsClient() + wireDispatcher() + the real stores — through seven
fc.commands (Connect, Disconnect, RegisterNow, Receive(id, seq), Supersede,
Resync, Logout) against a minimal reference model, checking four invariants
after every command: no duplicate message ids, a monotonic seq watermark
(observed at the auth frame, reset only at the modelled epoch resets), a
verified peer that never flips to unverified, and a superseded attempt's
teardown that never kills the newer session.

Only the boundaries are mocked: the Tauri IPC wire (the shared ws-mocks
helper) and the LiveKit / notification / toast / identity leaves, as in
dispatcher.test.ts. Seeded (OWNCORD_MODEL_SEED, default fixed) so a failure
replays exactly; 150 runs of up to 30 commands, ~0.9 s for the file. A second
test asserts every invariant family was actually reached, so a family that
stops being reachable fails instead of silently passing.

Test only — no Client/src/ change, so B7's rule holds.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KmiqjgTuov1stBTB6uGkvo

* docs(b3-6): evidence block for item 4 (client connection model test)

Records the branch and commit, the seven commands and four invariants, the
RED counterexample for each invariant family with its restored control, the
GREEN runs, and the numbers (seed 20260830, numRuns 150, maxCommands 30,
1083 invariant checks, 119 ms of test time).

Also notes the two spec details resolved against HEAD: RegisterNow has no
client-side symbol (it is the server's hub registration, observed here as the
ready-snapshot/queued-frame redelivery), and the design's aborted voice
attempt is reachable from the connection layer through the dispatcher's stale
voice_leave guard rather than through LiveKitSession's join generations.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KmiqjgTuov1stBTB6uGkvo

* test(b3-6): connection model — drop the tautological seq check, make coverage counters count the non-trivial case, guard the seed

Review findings on B3-6 item 4.

The invariant-2 assertion in checkInvariants compared the model to itself and
could not fail, while reading as though the seq watermark were checked after
every command. Deleted; the header comment now says where the real assertion
lives (connectCmd, against that connect's own auth frame).

Both coverage counters were counting their no-op case: exercised.seq counted
the initial connect declaring last_seq 0, and exercised.verified counted the
check that runs immediately after Supersede seeded the verifications itself.
They now count only a resume (last_seq > 0) and a verification check that
survived some other command, so "reached every invariant family" fails if only
the trivial form remains. Both still hold at the default seed and at 99.

A malformed OWNCORD_MODEL_SEED now throws instead of handing fast-check the
NaN (or the 0 an empty variable coerces to) and running a different suite than
the one that was asked for.

The evidence block's "+0.4 s on the full client suite" was never measured —
both full-suite runs included this file. Replaced with the file's own measured
cost and the observed suite spread, which is larger than that cost.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KmiqjgTuov1stBTB6uGkvo

* test(b3-6): connection model — a buffer resume replays events after auth_ok; ready only on the fresh/fallback path (Codex P2 on #1455)

Verified against the server before changing anything. reconnectWriteReplay
(Server/ws/serve.go:593) writes auth_ok with the replay tier and then the
missed events, and never a ready; only reconnectPrecheck falling through to
handleFreshConnect produces auth_ok(none) + ready. The epoch-1 fixtures record
exactly that split: fresh-connect.json is auth_ok(none) -> ready -> ...,
resume-replay.json is auth_ok(buffer) -> presence -> chat_message -> presence,
with no ready anywhere. Codex is right.

Connect now drives whichever shape the model's watermark implies: last_seq 0
takes the fresh path unchanged, last_seq > 0 takes the resume path — auth_ok
with the tier, then one replayed chat_message carrying the next seq, and no
ready. The replayed frame is a message that committed while we were away, or,
once the id pool is exhausted, a redelivery of one already held, which is the
other real replay shape. An assertion after the handshake requires that frame
to be in the store: on this path the replay burst is the only thing that
repairs client state, so nothing else can cover for it.

RegisterNow had the same defect one step smaller — a bare ready, which the
server never writes either. It now sends the full auth_ok(none) + ready
handshake before the queued redelivery, so every ready in the file follows the
auth_ok that precedes it on the wire, and the redelivered frame carries the
server's restarted counter (OC-0032).

exercised.resumeReplay joins the coverage counters, so the resume path cannot
quietly stop being generated. Reverting the resume branch to the pre-fix shape
fails on [Connect,Receive(id=1,seq=1),Disconnect,Connect] with
"expected [ 1 ] to include 2" and on the family counter. Merely adding a ready
alongside the replay still passes — recorded in the report as the honest
result: that shape does not break an invariant, it just lets a snapshot do the
repair the replay burst is supposed to do.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KmiqjgTuov1stBTB6uGkvo

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-30 12:13:17 +00:00
J3vbandClaude Fable 5 7da2a2b9ad feat(b3-6): coverage floor — script, floors and CI gate on the ubuntu leg (S-06) (#1453)
* feat(b3-6): coverage floor — script, floors and CI step (S-06)

scripts/coverage-floor.sh reads a Go coverage profile and fails when the
aggregate, or one of the core packages (ws, service, permissions, auth, db),
is below the figure recorded in coverage-floor.json. Statement counts come
from the profile's block lines, not `go tool cover -func`, so the per-package
figures are exact; percentages are truncated to one decimal and compared in
tenths. Exclusions (generated db/dbgen, cmd/) live in the floor file.

Measured with the CI command on this branch's base (origin/dev 75d64dd4):
aggregate 79.1 (11241/14194), auth 90.8, db 79.4, permissions 100.0,
service 67.8, ws 84.5. The plan's starting aggregate was 74.6 (B0 baseline at
an older SHA); the ratchet applies to this PR too, so the floor lands at the
measured figure.

Wired into ci.yml right after the race/coverage test step, on the Linux leg
only: OS-tagged files swap in and out of the build and several tests skip on
Windows, so one leg keeps the figure deterministic. The ratchet rule is in
Server/CLAUDE.md.

--floor <file> and OWNCORD_COVERAGE_FLOOR override the committed floors, so
the negative control needs no edit of the tracked JSON.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KmiqjgTuov1stBTB6uGkvo

* docs(b3-6): evidence block for item 1 (coverage floor)

Numbers, RED/GREEN commands and the Linux-leg decision, appended to the
B3-6 section per the item's exit criteria.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KmiqjgTuov1stBTB6uGkvo

* fix(b3-6): coverage floor — fail closed on JSON shape, honour --floor anywhere, LC_ALL=C, gate after the last test step

Review findings on item 1.

The floor-file parser now fails closed instead of silently reading less than
the file says. A Prettier-wrapped "exclude" array used to yield zero
exclusions and misreport as a coverage drop; an "exclude" key with no parsed
entries is now exit 2 naming the one-line-array rule. A package entry sharing
a line with the closing brace ("ws": 99.0 }) used to be dropped, so that floor
stopped being enforced; the block now closes after the line is parsed, and an
entry on the "packages": { line is parsed too. Two entries on one line are
exit 2 rather than one silently ignored. All three rules are in the header
comment.

--floor is parsed in any argument position, so `coverage-floor.sh coverage.out
--floor red.json` no longer silently uses the committed floors; an unknown
flag or a second positional argument is a usage message and exit 2.

LC_ALL=C is exported for mawk's locale-dependent decimal handling.

The ci.yml step moves after the deadlock and tag-gated test steps — it still
reads the profile the race step wrote, and still runs only on the ubuntu leg,
but a floor miss no longer hides those steps' results.

Server/CLAUDE.md gains the no-trailing-slash rule for exclusions.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KmiqjgTuov1stBTB6uGkvo

* fix(b3-6): coverage floor — every package entry must parse, and the five core packages must all be present (Codex P2 on #1453)

A package entry whose value did not match the numeric regex — valid JSON such
as "auth": "90.8" — was silently ignored, and the only completeness check was
"at least one package parsed", so a malformed value quietly removed a floor.
That contradicts the script's own fail-closed contract.

Inside the "packages" block, any line that is not a well-formed
"name": <number> entry (or the block's closing brace) is now exit 2, naming the
line number, the line, and the shape rule. The entry match is anchored, so it
also subsumes the previous "two entries on one line" heuristic.

The five core packages — ws, service, permissions, auth, db — must each have a
parsed floor; a missing one is exit 2 naming it. Extra packages beyond the core
set stay allowed.

Header comment and the plan's evidence block record both rules.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KmiqjgTuov1stBTB6uGkvo

* fix(b3-6): coverage floor — floors set from the Linux leg (CI run 33302062524)

The floors were first measured locally on Windows; the gate runs on the ubuntu
leg, and the first CI run of #1453 showed the two legs differ. Each floor is
now the Linux value truncated to one decimal, measured by the gate itself on
the leg that enforces it: aggregate 79.9 (11344/14191), auth 90.8 (418/460),
db 79.3 (1738/2189), permissions 100.0 (94/94), service 67.8 (1204/1775),
ws 86.9 (3271/3763).

The deltas are the ones the pre-merge analysis predicted. ws is higher on Linux
because three EnsureLiveKitBinary tests and one harvest_s5 case skip on Windows,
and that lifts the aggregate with it; db is one statement larger on Linux
(lockfile_unix.go has 11 statements where lockfile_windows.go has 10), which
costs it a tenth. auth, permissions and service carry no OS-conditional code and
are identical on both legs.

The evidence block now carries both columns, cites the run id, and keeps the
74.6 note. Running the new floors against the local Windows profile fails on
aggregate and ws by exactly those deltas, which is expected and is why the gate
is Linux-only.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KmiqjgTuov1stBTB6uGkvo

* fix(b3-6): coverage floor — a tenth of headroom where the Linux leg varied run-to-run (ws, aggregate; runs 33302062524 and 33302732286)

Run 33302062524 measured ws at 3271/3763 (86.9); run 33302732286, same commit,
measured 3267/3763 (86.8) and failed the floor set from the first. Four
statements of run-to-run variance in ws under -race — timing-dependent branches
— which moves the aggregate with it (11344 -> 11340). The reviewer's
zero-headroom concern is now evidence rather than a prediction.

Floors are therefore the lowest observed Linux figure, truncated to 0.1, minus
0.1 where the package varied between runs: ws 86.9 -> 86.7, aggregate
79.9 -> 79.8. auth 90.8, db 79.3, permissions 100.0 and service 67.8 are
unchanged — their statement counts are identical in both runs, so they take no
headroom.

Server/CLAUDE.md states that rule next to the ratchet, and notes that a Windows
run reports aggregate and ws under floor by design. The evidence block carries
both runs' covered/total per package, which varied, and the resulting floors.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KmiqjgTuov1stBTB6uGkvo

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-30 11:48:02 +00:00
J3vbandClaude Fable 5 0eb603cefc feat(b3-6): authz-chokepoint invariant rule — raw permission checks route through the B2-5 predicates (#1451)
* feat(b3-6): authz-chokepoint rule — raw permission checks route through the B2-5 predicates

B2-5 gave every channel-scoped security property exactly one predicate. This
is the guardrail that keeps the next call site from re-deriving one by hand,
which is how the thirteen hand-rolled decision sites B2-5 collapsed came to
exist.

The rule fails any production file outside Server/permissions that names one
of the six raw bit helpers (HasPerm, HasAnyPerm, HasServerPerm, HasAdmin,
EffectivePerms, EffectiveChannelPerms) — the whole exported surface of
permissions.go except Name — unless the enclosing symbol has a residue row.
It matches the selector rather than the call, so taking a helper as a value
does not evade it, and reports a dot-import of the package separately, since
that would let the helpers be spelled bare.

AuthzResidueAllow is HP-2 question 5's residue table: 19 symbols, 21 call
sites, re-measured at dev 75d64dd4 and unchanged in count. Rows are keyed by
directory plus enclosing function or method, never file:line — the table's
line numbers had already moved under B3-2. Each row carries one of question
5's five classes and a reason, so B3-8 can retire a class at a time.
TestAuthzResidueAllowIsLive fails any row whose symbol stopped calling a raw
helper, so the list can only shrink honestly.

importNames and the walker's rule-set parameter move to invariants.go: the
syncutil rule already needed the first, and the liveness test needs the
second. No production behaviour changes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KmiqjgTuov1stBTB6uGkvo

* docs(b3-6): authz-chokepoint evidence block — allowlist size, RED and GREEN runs

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KmiqjgTuov1stBTB6uGkvo

* fix(b3-6): authz-chokepoint — tighten the exclusion guard, reject file-scope rows, document the class set

Four review Minors, none behaviour-changing for the 19 real rows.

The permissions/ early return is gone rather than tested. It could never fire
for the package itself — a file there cannot import itself, so it binds no
"permissions" identifier and matches nothing — while its HasPrefix arm would
have silently exempted a future permissions/<sub>, which is a different
package that does import permissions. Dropping it is the strict choice. A new
fixture at permissions/policy/x.go pins that: re-adding the exclusion fails it.

An allowlist row keyed <dir>.<file-scope> would have blanket-exempted every
package-scope raw call and every dot-import in that directory at once.
TestAuthzResidueAllowIsLive now rejects such a row, and TestFileScopeRowsAreRejected
covers the predicate directly.

The class set is documented as closed: a row cannot invent a class, including
the "unclassified" escape valve the brief sketched, so new residue needs a
constant added as a deliberate edit.

The violation message said the call "decides authorization", which is wrong for
EffectivePerms and EffectiveChannelPerms — they compute the mask a decision
reads. It now says "resolves permission bits", distinguishes the two groups,
and names the five legal classes from authzClassList, a constant folded from
the class constants so it cannot drift.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KmiqjgTuov1stBTB6uGkvo

* fix(b3-6): authz-chokepoint — bind residue rows to helper and multiplicity (Codex P2 on #1451)

The symbol-only lookup exempted the whole function. A second raw call added
inside any of the 19 allowlisted symbols, or a switch to a different helper
there, passed silently — and the liveness test only asked for at least one hit,
so the 21-call residue could grow without review. Correct by construction.

Each row now carries Calls, a helper-name to count multiset filled from what
the tree actually contains: 18 rows bind one call, mentionReaders binds three
(EffectivePerms 1, HasAdmin 2). By helper: HasAdmin 13, HasServerPerm 6,
HasAnyPerm 1, EffectivePerms 1 — 21 in all, unchanged.

The rule counts hits per symbol as it walks a file and flags the call that
takes a helper past its bound count, so an extra call, one more of the same
helper, and a helper the row never listed (bound count 0) all fail at the
offending line, with the helper and the expected-versus-found counts in the
message. A dot-import is now flagged inside an allowlisted symbol too — it
binds no call to count, and a row is no excuse for one. Fewer calls than the
row binds is left to TestAuthzResidueAllowIsLive, which compares the multiset
exactly (maps.Equal) instead of asking for at least one hit, so an over-counted
row cannot leave headroom either.

Symbol keying is unchanged: the plan mandates file:line independence.

RED, then restored: a second permissions.HasAdmin in api.serveFileAuthorize
fails naming "binds 1 call(s) of HasAdmin here, found 2"; swapping it to
HasPerm fails naming "binds 0 call(s) of HasPerm here, found 1"; setting that
row to HasAdmin: 2 fails the liveness test with the multiset diff. All three
have unit fixtures, alongside one that a shrinking residue is not the rule's
business.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KmiqjgTuov1stBTB6uGkvo

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-30 11:07:37 +00:00
J3vbandClaude Fable 5 123c0899e5 fix(b3-9): close the B3-tagged findings — OC-0345, OC-0346, OC-0376, OC-0377, OC-0378 (#1454)
* docs(b3-9): record B3-2's merge (#1450 = 75d64dd4); B3-9 in progress

Plan status line, B3-2 step-table row (DONE) and evidence block carry the
squash SHA; docs/plans/README.md B3 row updated.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A17Uq3d2C36rN82Jitf3wo

* fix(api): panic log carries trace_id — tracing ahead of recoverer (OC-0346)

recoverer snapshots telemetry.TraceIDFromContext before dispatch, so it
needs the otelhttp span to exist already; it was mounted two slots ahead
of telemetry.HTTPMiddleware and the trace_id attribute was always dropped.
Move the tracing middleware above it; request-id binding, security headers
and the body cap keep their relative positions.

Test (otel build only — the default build hard-wires TraceIDFromContext to
""): go test -tags otel -run TestRecoverer_PanicLogCarriesTraceID ./api/

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A17Uq3d2C36rN82Jitf3wo

* fix(admin): owner gate answers 503 on a role read fault, not 403 (OC-0345)

ownerOnlyMiddleware collapsed `err != nil || role == nil` into 403 "role not
found", so a transient GetRoleByID failure told the Owner they lack the
Owner role. Split the outcomes: a store error logs and answers 503
SERVICE_UNAVAILABLE (the perimeter's contract); a genuinely missing role
still answers 403. The existing whitebox tests, which inject only the user
into the context, are unchanged.

Test: TestOwnerOnlyMiddleware_RoleLookupFailureIs503 (roles table renamed,
whitebox — through the full stack the perimeter would answer its own 503).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A17Uq3d2C36rN82Jitf3wo

* fix(service): verify-totp reports a store fault as 500, uncounted (OC-0377)

challengeSecret folded a GetUserByID error into the same 401 "invalid or
expired two-factor challenge" an expired challenge earns, and the attempt
had already been charged to the per-user totp_fail cap. Split the outcome:
a store error logs and returns the new service.ErrTOTPUnavailable
(ErrInternal, "two-factor verification temporarily unavailable"); an unknown
user or a missing secret still answers 401. The limiter reservation moves
after the store read — the rule authenticate already applies — and still
precedes the code compare, so the check-then-act it closes stays closed.

Characterization row flipped in the same commit: `VerifyTOTPFailurePaths/
user lookup fails -> 500, challenge kept, attempt not counted` — after the
fault ten wrong codes still answer 401 (the tenth would be 429 had the
fault counted), then the eleventh is refused. `per-user failure cap spans
challenges` unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A17Uq3d2C36rN82Jitf3wo

* fix(service): verify-totp keeps the verified second factor when the session insert fails (OC-0378)

VerifyTOTP consumed the partial challenge before issueSession, so a store
fault on the session insert discarded a verified second factor and sent the
user back to the password step; the code was also marked used, so an
immediate retry would have been refused as a replay.

The claim stays atomic and first (two concurrent verifies can never both
reach issueSession). On issueSession failure the challenge is restored under
the same partial token — the client still holds it — and the accepted code
is released, so the retry completes the login without another password
step. auth gains PartialAuthStore.Restore and UsedTOTPCodeStore.Unmark, each
tested in the leaf package.

Characterization row flipped in the same commit: `VerifyTOTPFailurePaths/
session insert fails -> 500, the challenge and the code survive` — once the
trigger is dropped the same token and the same code answer 200 with a token.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A17Uq3d2C36rN82Jitf3wo

* fix(service): registration commits the account, the invite use and the first session together (OC-0376)

CreateUserWithInvite committed the user and burned the invite; the session
insert ran outside that transaction, so a store fault there answered 500
with a half-registered account — a retry got "invalid invite or
credentials" while a login with the same password worked. Option B from
the ledger: the session token is generated first and the session row is
inserted inside the same transaction (db.insertSession through
dbgen.Queries.WithTx; no query or migration change, so no sqlc regen). A
fault at any step rolls the whole registration back and the caller simply
retries. The H-6 cap needs no eviction for a user with no sessions.

Characterization row flipped in the same commit: `RegisterPolicyAndFailurePaths/
session insert fails -> 500, nothing committed` — user row absent, invite
use_count 0, message "registration failed — please try again". db tests
pass the three new arguments; the happy-path test asserts the session row.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A17Uq3d2C36rN82Jitf3wo

* refactor(service): one session-token path for Register and issueSession

OC-0376 gave Register its own auth.GenerateToken failure branch — a
duplicate of the unreachable one issueSession already carried — and the
auth slice's statement coverage dipped from 91.8% to 91.7% on that one
statement. newSessionToken generates the token and hands its hash to a
persist callback: CreateSession for login and verify-totp, the
CreateUserWithInvite transaction for registration. Behaviour identical
(the characterization file is green before and after); slice coverage
402/437 = 92.0%, service/auth.go 250/263 = 95.1%.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A17Uq3d2C36rN82Jitf3wo

* chore(ledger): close the five B3-tagged findings; counts re-derived (PR #1454)

OC-0346 → 775eba50, OC-0345 → fb1afb8a, OC-0377 → f7015809,
OC-0378 → be37d7ee, OC-0376 → 85d86dc7: status fixed, fix.test,
revertProof pass (hand reverse-apply per commit + verify-fixes.mjs).
Ledger 315 fixed / 59 open → 320 / 54 (3 declined, 1 duplicate, 378).

The four count-carrying documents are re-derived around every number,
not just the totals (obs #100): docs/plans/README.md, hp-0-scorecard
(54 open = 1 high / 12 medium / 41 low; three hunts; 53 of 54 resolve;
Client 33 / Server 21), repo-health-issue-register (table, "eleven of
which", OC-0345/OC-0346 rows marked fixed with this PR), b0-baseline.
OC-0323 stays open — it rides B3-8's message/read-state family.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A17Uq3d2C36rN82Jitf3wo

* fix(service): verify-totp — cap check before the store read; release the code on a lost claim (Codex P2s, #1454)

Two P2s from Codex on PR #1454, both verified against the code and fixed
test-first:

1. An exhausted totp_fail window is refused by the read-only limiter.Check
   before challengeSecret, so rotating source IPs cannot drive user reads
   and secret decryptions past the per-user cap. The atomic Allow that
   records the attempt still runs after the store read (OC-0377: an outage
   charges nothing); the cap boundary is unchanged.
   Test: api/totp_cap_before_store_test.go — budget filled through the
   limiter, users table hidden, expects 429 (RED: 500 "temporarily
   unavailable", the store was read first).

2. A verify whose claim loses at Consume releases the code it marked, so a
   winner mid-recovery (Consume → issueSession failed → Restore) is not
   left with a live token behind a dead code until the authenticator rolls
   over.
   Test: service/auth_lost_claim_test.go — forces the interleaving through
   the store's GetUserByID (RED: "the losing claim left its code marked as
   used").

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A17Uq3d2C36rN82Jitf3wo

* docs(b3-9): evidence block, status line, step-table row; README B3 row (PR #1454)

Per finding: pre-squash SHA, RED and GREEN lines, revert-proof, the two
negative controls, what changed; the ledger diff and the re-derived count
paragraphs; auth-slice coverage 402/437 = 92.0% (floor 392/427 = 91.8%),
service/auth.go 250/263 = 95.1%; the otel-tagged run. OC-0323 recorded as
riding B3-8. hp-3-scorecard untouched — the owner signs it as drafted.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A17Uq3d2C36rN82Jitf3wo

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-30 12:56:04 +02:00
J3vbandClaude Fable 5 75d64dd412 refactor(b3-2): auth vertical slice — service.AuthService behind a consumer-owned interface (S-10) + HP-3 draft (#1450)
* docs(b3-1): record PR #1449 = 71d867cb in the status line, step table and evidence block

Pre-squash SHAs completed with the coverage commit a0356ee1 and the three
Codex rounds (head 8614603b).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A17Uq3d2C36rN82Jitf3wo

* refactor(b3-2): auth_deps.go — the consumer-owned AuthService interface

Eight methods beside the handlers that need them: Register, Login,
VerifyTOTP, Logout, DeleteAccount, EnableTOTP, ConfirmTOTP, DisableTOTP —
fewer than the ten *db.DB methods the two handlers call today. The input
and result types they name (Principal, RegisterInput, LoginInput,
AuthResult, TOTPChangeResult) and the AuthBroadcaster the delete path needs
live in service/auth.go. Nothing implements or calls the interface yet.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A17Uq3d2C36rN82Jitf3wo

* refactor(b3-2): service.AuthService — the auth orchestration, moved verbatim

Register, Login, VerifyTOTP, Logout, DeleteAccount, EnableTOTP, ConfirmTOTP,
DisableTOTP and the RegistrationPolicy gate two characterization rows pin
ahead of the body read. The enumeration guard, the F3 reserve-before-compare,
the audit writes, the best-effort custom-status clear and the 200+warning
partial-success contract move line for line; persistence stays in db behind
Store. Each refusal is a named service.Err* whose Error() is the exact
public message the handler wrote and whose category (ErrUnauthorized and
ErrInvalidInput join the message.go set) the transport maps to a status.
The auth rate multiplier moves to auth/ratescale.go so the route mounts
and the login failure accounting read one value; api keeps its wrappers.
Nothing calls the service yet — the handlers still own their copies.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A17Uq3d2C36rN82Jitf3wo

* refactor(b3-2): thin auth handlers — decode, call AuthService, encode

*db.DB leaves every handler signature in auth_handler.go and
totp_handler.go; MountAuthRoutes takes the interface and the
AuthMiddleware the caller builds, and router.go constructs the service
after the hub. Each refusal is encoded by one writeAuthError switch on the
service's error categories. The principal helper in middleware.go hands
the handlers the caller as service.Principal, and userResponse moves next
to the profile handler, so neither auth file names db any more: their two
DBImportAllow rows go in this commit (TestDBImportAllowIsLive proves the
rows could not outlive the import) and the boundary fixture points at
middleware.go instead. The auth-slice limits leave api/constants.go with
the code that reads them; profile_handler.go reads the shared pw_confirm
budget from the service. Test files change only where they mount the
routes (four helper lines + two direct mounts); no assertion or row moves.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A17Uq3d2C36rN82Jitf3wo

* docs(b3-2): after-state boundary inventory — api db importers 12 → 10

Regenerated table (49 files; move 28 → 26), the auth slice's after-state
dependency rows, and the honest reading of the plan's "neither db nor
service" target: met for db, not for service — the handlers import service
for the interface's types and Err* categories.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A17Uq3d2C36rN82Jitf3wo

* docs(b3-2): evidence block — pre-squash SHAs, graph deltas, gates, coverage

Characterization green at each SHA in a detached worktree with the frozen
files byte-identical to 71d867cb; nine-method interface vs ten db methods;
api db importers 12 → 10; slice coverage 392/433 = 90.5% → 392/427 = 91.8%;
the five behaviour notes (decode-before-gate corner cases, shared
AuthMiddleware, folded confirmation block, moved limits, moved converter).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A17Uq3d2C36rN82Jitf3wo

* docs(hp-3): scorecard draft and the D4 vertical-slice pattern in server.md

Five questions answered with commands and outputs at fe1d11b8/3f0d24ec;
owner sign-off line left blank. server.md gains D4 — the eight-step
interface/service/handler rule for B3-8 with the awkward step
(gate-before-decode) named — and its D3 deviation note drops the auth
routes. Plans README indexes the scorecard.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A17Uq3d2C36rN82Jitf3wo

* docs(b3-2): record PR #1450 in the evidence block and the HP-3 fetch line

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A17Uq3d2C36rN82Jitf3wo

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-30 09:49:05 +02:00
J3vbandClaude Fable 5 71d867cbdb test(b3-1): auth characterization tests — freeze the slice before B3-2 (#1449)
* docs(b3-0): record PR #1448 = d383d8c7 in the evidence block

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Rg9QQWVN3E5UUgBD2dydtu

* docs(b3-1): inventory of what the 85 auth tests already pin, per route and property

Route x property table in the B3-1 evidence block: the existing test for
each row, or GAP and the characterization row that fills it (next commit).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Rg9QQWVN3E5UUgBD2dydtu

* test(b3-1): auth characterization — fill the inventory gaps against today's behaviour

12 tests / 44 table rows over the mounted auth router, no mocks: read
faults via ALTER TABLE RENAME, write faults via RAISE(FAIL) triggers.
Three rows pin defects as-is with `// known:` and ledger entries
OC-0376 (register 500 after the account commit), OC-0377 (verify-totp
maps a DB error to 401), OC-0378 (challenge consumed before the session
insert); fixed in B3-9, not here. Mutation spot-check: 401->500 in
totpChallengeSecret, 500->401 in loginAuthenticate, 503->401 in
AuthMiddleware each turned the rows that name them RED.

The nine watched ledger-count claims move 56 open / 375 -> 59 / 378.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Rg9QQWVN3E5UUgBD2dydtu

* docs(b3-1): coverage before/after, row count and pre-squash SHAs in the evidence block

auth_handler.go 78.0% -> 90.2%, totp_handler.go 78.8% -> 91.1% (statements).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Rg9QQWVN3E5UUgBD2dydtu

* fix(b3-1): Codex P2s — guaranteed-invalid TOTP code, B3-9 scope, hp-0 breakdown

- wrongTOTPCode derives a code outside the three accepted steps instead of
  assuming "000000" is invalid (1-in-333k flake in the per-user cap row)
- OC-0376/0377/0378 added to B3-9 in the step table and its section
- hp-0 open-record breakdown recomputed for the 59-open ledger

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Rg9QQWVN3E5UUgBD2dydtu

* docs(b3-1): Codex round 2 — OC-0378 fix must keep the challenge claim atomic; coverage command syntax

- OC-0378 suggestedFix: Consume first, restore/re-issue on session failure;
  issuing before Consume lets two concurrent verifies both create sessions
- evidence block: go test -coverprofile=cover.out ./api/ (flag needs a file)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Rg9QQWVN3E5UUgBD2dydtu

* fix(b3-1): Codex round 3 — exclude the +2 TOTP step, sequence auth B3-9 after B3-2, keep the replay claim in OC-0378's remedy

- wrongTOTPCode excludes {-1,0,+1,+2}: the verifier samples the clock after
  the helper, so a step boundary in between shifts its window to {0,+1,+2}
- B3-9 "Parallel with": OC-0345/0346 any; OC-0323 with B3-8; OC-0376..0378
  after B3-2 (matches the safe-parallelism rule and the section text)
- OC-0378 suggestedFix: a restored challenge must carry the accepted
  verification or roll back the MarkUsed claim, or the retry is a replay

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Rg9QQWVN3E5UUgBD2dydtu

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-29 22:33:05 +02:00
J3vb d383d8c7e8 feat(b3-0): boundary inventory — dbinventory tool, db-import-boundary rule, server-boundaries.md (#1448)
* docs(b3): Codex round 1 — keep the main-PR Docker term, checkout dev on schedule, profile numbers, hub built in api.NewRouter

P1: the Docker verify condition keeps ref_name/base_ref main and adds the
schedule term. P2: scheduled runs check out dev explicitly (the workflow file
comes from main). P2: the alpha profile's dimensions are defined in the plan,
not borrowed from load-baseline.yml (which has only users=100). P2: ws.NewHub
is called in api/router.go:106 with setters split across router.go and
main.go, so B3-4 follows B3-3, which moves construction into internal/app.
Also: plan-index row and roadmap slice line for B3.

* feat(b3-0): boundary inventory — dbinventory tool, db-import-boundary rule, server-boundaries.md

51 production files outside db/ and service/ import db (ws 17, admin 16,
api 12, auth 2, root 2, cmd/seed 1, plugin 1); 14 are type-only. Each has a
disposition (move 28 / adapter 17 / boundary 6) and, for moves, a target
family, held in invariants.DBImportAllow so the generated document and the
gate cannot drift. The rule fails any new importer without a row; the live
test fails any stale row. Hub lifecycle (setters, locks, defer stack) and the
auth before-graph are inventoried for B3-2/B3-3/B3-4. Closes the B3 entry
gate's third item.

* fix(b3-0): dbinventory exempts only top-level db/ and service/ (Codex P2)

Skipping by directory name let a nested api/service/ escape the inventory
while the rule would still catch it; the walker now exempts by root-relative
path, with a test over a synthetic tree.
2026-08-29 20:07:15 +02:00
J3vb ad4defc27b docs(b3): execution plan — server architecture and permanent guardrails (#1447)
Ten steps (B3-0 inventory through B3-9 findings) with HP-3 mid-phase, mapping
all 17 roadmap workstreams and every B3-tagged register row. Entry gate 2 of 3
met; every roadmap and layout-refactor claim re-verified at bf7b886d (api has
12 db importers, not 11; workstream 7 already done by B2-5; the
seq-enqueue-paired rule never merged).
2026-08-29 14:46:00 +00:00
J3vb 972064f91b docs(hp-2): accepted 2026-08-29 by the owner; B2 complete, B3 next (#1446) 2026-08-29 16:32:11 +02:00
J3vb bf7b886df8 fix(hp-2): anchor checker drops the extension allowlist (Codex P2 on #1444) (#1445)
* fix(hp-2): anchor checker drops the extension allowlist (Codex P2)

The list skipped the .sh anchor and the extensionless Server/Dockerfile:13.
Any path with a slash, or a basename with an alphabetic extension, now counts:
117 -> 119 checked, 0 unresolvable. Scorecard and plan counts updated.

* docs(b2-9,hp-2): record the #1444 squash SHA; Codex fix lands in the follow-up
2026-08-29 16:06:14 +02:00
J3vb 2bfc5e30d6 docs(b2-9,hp-2): security owners closed, HP-2 sign-off scorecard (#1444)
* docs(b2-7): record the #1443 squash SHA in the evidence block

* docs(b2-9): SEC-03 sized and re-tagged to B5, verdict recorded

* test(e2ee): HP-2 adversarial membership and key-change cases

Three cases from docs/trust-model.md that had no dedicated test: a modified
server adding an unknown member at first contact (pinned as today's behaviour,
a known gap with its RED recorded in the HP-2 scorecard), a second device's key
overwriting the one-per-account pin so the first device mismatches, and a peer
resumed across a rotation being re-keyed with the rotated key (OC-0316, holder
side). The last two were proven able to fail by temporary code mutation.

* docs(b2-9): close the owner table; advisory placeholders for SEC-01/SEC-04

* docs(hp-2): protocol and threat-model sign-off scorecard

Seven questions answered with commands and their output; B2 exit gate walked
(nine conditions). Adds the trust-model anchor checker beside the scorecard,
the HP-2 evidence block, and the plan index / roadmap slice updates. Owner
lines (reader, review date, decision, signature) are left blank on purpose.
2026-08-29 12:54:30 +00:00
J3vbandClaude Fable 5 88c7a8249a docs(b2-7): trust model, absence proofs, plugin boundary (#1443)
* docs(b2-7): trust model — who can read what (BPR-050/051, C-09 contract)

One document states the operator trust model in plain language and traces
every claim to a code line or test: server-readable text and files and why,
E2EE voice/video/screen with the key-holder and TOFU rules, transport per TLS
mode with desktop pinning and the browser rule, the C-09 preview destination
contract B7 implements, at-rest storage, operator can/cannot, multi-device
sessions, and what beta does not claim. Linked from security.md,
deployment.md, quick-start.md and docs/README.md.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Rg9QQWVN3E5UUgBD2dydtu

* test(b2-7): absence proofs — no federation/directory routes, outbound host table

TestAbsenceContract_NoFederationDirectoryOrListingRoutes builds the production
router with uploads, voice and GIF on, walks the whole mounted tree with
chi.Walk and fails on any route matching federat|directory|discover|listing.
A floor on the route count and a check that the admin subtree was traversed
keep it from passing vacuously. trust-model.md gains "What OwnCord does not
have" (BPR-040/082/083) and the outbound-host table B6's network capture
checks against.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Rg9QQWVN3E5UUgBD2dydtu

* docs(b2-7): plugin boundary — off twice, compiled out of releases, no API promise

docs/architecture/plugins.md records the experimental WASM boundary (BPR-080/
081, BG-17): disabled by build tag and by config, absent from release.yml and
Dockerfile builds, the HP-2 configuration audit (fresh, upgraded, Docker,
standalone), the beta release-notes wording, what exists today with its limits
and tests, the post-beta plugin candidates that stay in core during beta, and
the core concerns that never move. Linked from architecture/README.md,
architecture/server.md and docs/README.md.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Rg9QQWVN3E5UUgBD2dydtu

* docs(b2-7): L-08 re-tagged to B10 with the reason; B2-7 evidence block

L-08's remaining gate ("deterministic source build passes") cannot pass in
principle — TinyGo embeds host paths and has no -trimpath — and a compile-only
drift check would need a second Go SDK, TinyGo and Binaryen on every PR for a
subsystem release builds compile out. Re-tagged to B10, which runs the compile
once against the release candidate or closes on the provenance record; the
"no API promise" half is closed by docs/architecture/plugins.md. The plan's
B2-7 evidence block records the four pre-squash SHAs, the absence test's RED
output, the release-build finding, the decision, and the BPR-051 reader
placeholder. CHANGELOG gains a Documentation block.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Rg9QQWVN3E5UUgBD2dydtu

* fix(b2-7): Codex review — TOFU windows disclosed, legacy TOTP, absence test at three boundaries

Two P1 and three P2 from the Codex review of 56f23a36, all verified against
the code. trust-model.md now states the desktop's first-connection TLS TOFU
window and the out-of-band fingerprint check; scopes identity-key pinning to
changes after the first pin, not first contact; discloses that databases from
before TOTP encryption may still hold plaintext secrets and how re-enrolment
fixes that. The absence contract gains two sibling tests — WebSocket wire
types from protocol/schema.json and every koanf key of config.Config (one
allowlisted on-disk path whose presence the test asserts) — and the document
states what the three tests bound. plugins.md corrected: unknown config keys
are warned about and ignored, not rejected. Evidence block records PR #1443
and the review outcome.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Rg9QQWVN3E5UUgBD2dydtu

* fix(b2-7): Codex re-review — desktop first-use window in every TLS mode; tls off is plaintext

The desktop pins the fingerprint it sees on first connection in every
tls.mode (CaptureVerifier, no web-PKI validation on the server connection),
so a public-CA certificate closes the first-use window only for a browser;
the short answer and the pinning list now say so. tls.mode off served
directly is plaintext HTTP with nothing enforcing a proxy; the transport
table row states it. Evidence block records the round-2 outcome.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Rg9QQWVN3E5UUgBD2dydtu

* fix(b2-7): Codex round 3 — E2EE does not survive a hostile operator on first contact; memory cap is server-wide

Identity keys are trusted on first use, so a modified server can deliver an
unpinned peer's first announce with keys the operator holds and the key
holder wraps the room key to it. trust-model.md now scopes E2EE to an
operator who reads, and to a modified server only for peers pinned and
compared out of band beforehand, in every place the stronger claim stood.
plugins.md: the wazero runtime is sized from plugins.max_memory_mb alone;
a manifest's memory value is validated but not applied. Evidence block
records the round.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Rg9QQWVN3E5UUgBD2dydtu

* fix(b2-7): Codex round 4 — transport intro excludes tls off; ipAllowed is not the full C-09 deny-set

The transport section opened with "everything is TLS"; it now excludes
tls.mode off. The C-09 contract cited the server's ipAllowed as the complete
address deny-set; it rejects loopback, private, link-local, unspecified,
multicast and CGN only, so the clause now lists the documentation and
benchmarking ranges the native broker must add and records that widening
ipAllowed is a separate server change.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Rg9QQWVN3E5UUgBD2dydtu

* fix(b2-7): Codex round 5 — backups exclude uploads; release-time appimagetool fetch listed

The built-in backup is VACUUM INTO of the SQLite file only; the trust model
and deployment.md now say uploaded files are not in it and upload.storage_dir
needs its own backup. The outbound-host section scopes "no script fetches an
external host" to scripts the server runs and lists the release workflow's
build-time appimagetool download.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Rg9QQWVN3E5UUgBD2dydtu

* fix(b2-7): Codex round 6 — one pin per account, LiveKit media in the host table, quick-start wording

Identity pins are keyed by host and user id, so a peer's second device
overwrites the pin and the first device then mismatches; the document no
longer claims per-device pinning. The outbound-host table gains the
supervised LiveKit subprocess's WebRTC media and scopes the capture contract
to traffic the server initiates. The quick-start cross-link no longer says
voice and video are unreadable by the operator.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Rg9QQWVN3E5UUgBD2dydtu

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-29 11:49:24 +00:00
J3vbandClaude Opus 5 3159f976c9 chore(bughunt): record the 2026-08-29 hunt in the findings ledger (#1442)
* chore(bughunt): record the 2026-08-29 hunt in the findings ledger

Appends OC-0350..OC-0375 as `open` and bumps nextId to 376.

Run shape: 12 rounds, 72 agents, single opus finder with opus
refute-by-default verification. 61 candidates reached verification;
26 were confirmed and 35 refuted. A further 24 candidates were
suppressed as already-known from the ledger and 5 as same-run
duplicates.

The run did NOT converge: it stopped on the maxRounds=12 backstop
rather than on consecutive dry rounds, and round 12 still confirmed
new findings, so the sweep is incomplete and a follow-up hunt has
more to find. Cost ceiling was not the constraint (2.11M of a 5M
budget).

No code changes: this commit only records findings. Nothing is fixed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WTe8ePDma9H2ZeqbdP7EAW

* docs: follow the ledger's count claims after the 2026-08-29 hunt

check-doc-counts.mjs gates the watched planning documents against the
ledger, and appending OC-0350..OC-0375 moved open 30 -> 56 and total
349 -> 375. Updates the four count claims it flagged, the same
numbers-only edit the previous ledger commit made.

The path-resolution row is re-measured, not merely renumbered: all 375
records still resolve to a live file:line (0 dead paths, 0 lines past
end of file).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WTe8ePDma9H2ZeqbdP7EAW

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-08-29 10:27:36 +00:00
J3vbandClaude Fable 5 2b2d58abc1 feat(b2-6): safe audit coverage (S-02) (#1441)
* docs(b2-6): enumerate the security-sensitive mutations and their audit coverage

Step 1 of B2-6: the mutation inventory crossed with the 43 non-test
Audit( call sites at 67fdd18d, recorded in the plan's evidence block.
Invite create/revoke (S-02) and plugin install/uninstall have no audit
row; no timeout mutation exists (kick is force_logout / voice_mod_kick).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Rg9QQWVN3E5UUgBD2dydtu

* added new doc

* docs(audit): update changelog and security documentation to include admin panel actions in audit log

* feat(b2-6): audit every security-sensitive mutation, invite and plugin rows first (S-02)

Step 2 of B2-6. TestAuditCoverage_* in service, api and admin drive each
mutation from the plan's inventory against a fake db.AuditStore
(Server/db/audittest) and assert the expected action arrives. Red before
this commit on exactly four rows: invite_create, invite_revoke,
plugin_install, plugin_uninstall.

- InviteService writes invite_create / invite_revoke naming the invite by
  id, never by code; RevokeInvite now takes the actor, threaded from the
  handler. A failed revoke writes nothing (test).
- The plugin admin handler takes a db.Auditor and writes plugin_install /
  plugin_uninstall against the RequireAdminAuth principal
  (admin.ActorIDFromContext, exported for that).
- docs/security.md lists the four new actions; CHANGELOG under Unreleased.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Rg9QQWVN3E5UUgBD2dydtu

* test(b2-6): denylist over the recorded audit detail corpus

Step 3 of B2-6. Each TestAuditCoverage_* table now ends with a subtest
that runs audittest.AssertSafeDetails over every entry its rows recorded:
a shape denylist (bcrypt/argon2 hashes, password=/token=/secret=/
recovery-code key-value leaks, otpauth URIs, Bearer credentials) plus the
fixture's own secrets (raw tokens, passwords and hashes, TOTP secrets and
codes, invite codes, message bodies). audittest_test.go proves each class
bites and that ordinary details pass. Zero hits on the corpus at HEAD, so
no call site needed changing.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Rg9QQWVN3E5UUgBD2dydtu

* docs(b2-6): record pre-squash SHAs, red/green and denylist evidence

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Rg9QQWVN3E5UUgBD2dydtu

* fix(b2-6): audit committed invites on a canceled request; 404 unknown plugin uninstall

Codex P2s on #1441, both test-first:
- CreateInvite read the invite back on the request context, so a cancel
  after the insert committed returned an error and skipped invite_create.
  The read-back and audit now run on context.WithoutCancel, like the
  password-change tail. TestCreateInvite_AuditSurvivesCanceledLookup.
- Registry.UninstallPlugin is idempotent on an unknown id, so the handler
  wrote plugin_uninstall for plugins that never existed. It now checks the
  row first: 404 and no audit. TestPluginsHandlerUninstallUnknownID.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Rg9QQWVN3E5UUgBD2dydtu

* docs(b2-6): record the Codex review outcome on #1441

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Rg9QQWVN3E5UUgBD2dydtu

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-29 11:11:37 +02:00
J3vbandClaude Fable 5 67fdd18d7e feat(b2-5): one permission predicate per security property (#1440)
* feat(b2-5): canonical permission predicates

One value-taking predicate per security property in Server/permissions:
CanViewChannel, CanAdmitSession (= view), CanSendMessage, CanType (= send),
CanJoinVoice, CanModerateVoice, all over a Subject the caller resolves
(role bits, both override layers, channel flags, DM state). Checker now
resolves a Subject and asks it, so HasChannelPerm, HasChannelPermBatch and
VisibleChannelIDs are the same rule rather than three copies.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Rg9QQWVN3E5UUgBD2dydtu

* fix(b2-5): send sites delegate to CanSendMessage (S-01)

checkSendPermission, HandleTyping, the ready payload's can_send and the
composer refresh all ask permissions.CanSendMessage over a resolved Subject
(PermissionService.Subject / ws subjectFor). Typing now follows the post
policy: a read-only member, an announcement reader without MANAGE_MESSAGES,
an archived channel, a blocked or non-participant DM user emit nothing.
Parity tables run each site against the predicate over the same fixture, in
both the cached-service and bare-hub branches.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Rg9QQWVN3E5UUgBD2dydtu

* refactor(b2-5): view sites delegate to CanViewChannel/CanAdmitSession (S-12)

HandleChannelFocus and the post-Subscribe revalidation (applySetChannelID)
ask permissions.CanAdmitSession; channelReadAudience and
RefreshChannelVisibility ask CanViewChannel — all over a Subject resolved by
subjectFor in either the cached-service or bare-hub branch, so no ws path
mirrors the visibility rule by hand any more. hasPermChecked is gone with
its last caller. Parity tables per site, both branches, every override layer
plus an archived channel.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Rg9QQWVN3E5UUgBD2dydtu

* refactor(b2-5): voice join sites delegate to CanJoinVoice

voice_join, voice_token_refresh, the destination of a moderator move and the
stale-voice sweep all ask permissions.CanJoinVoice over the subject the new
ws channelSubject resolves (role bits, both override layers, channel flags,
DM membership and block state); joinDenial maps a refusal to the frame each
reason always produced. hasChannelAccess, hasChannelAccessLive and
Hub.requireChannelAccess are gone with their last callers. The sweep now
re-runs the whole join rule (a deleted or archived channel, a lost DM
membership or a new block evict too, not only a lost CONNECT_VOICE bit), and
the token refresh refuses a deleted channel. Parity tables cover the shared
resolver, the join gate and the sweep in both branches.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Rg9QQWVN3E5UUgBD2dydtu

* fix(b2-5): voice moderation delegates to CanModerateVoice (SEC-02 server half)

voiceModTarget decides with permissions.CanModerateVoice over the actor's
subject in the target's channel: effective MUTE_MEMBERS there (a role-layer
or user-layer deny now holds), READ_MESSAGES so a hidden room cannot be
moderated, and DM membership for a DM call. The base-bit check stays as an
early rejection only, keeping FORBIDDEN ahead of the voice-state lookup.
Locked by a table over both override layers, a hidden channel and the
Administrator bypass, through the real voice_mod_mute path; the deafen-race
fixtures gain the Checker the gate now needs.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Rg9QQWVN3E5UUgBD2dydtu

* docs(b2-5): evidence block, inventory and closed rows

Record the B2-5 evidence (pre-squash SHAs, before/after inventory, the
SEC-02 READ decision, the residue that leaves the authz-chokepoint rule with
B3 item 15) in the plan, mark the step done, and flip S-01, S-12 and the
server half of SEC-02 to resolved/superseded in the issue register.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Rg9QQWVN3E5UUgBD2dydtu

* fix(b2-5): CanJoinVoice refuses an archived DM call too

The old voice_join gate refused every archived channel regardless of type,
and the admin PATCH accepts archived for a DM; the predicate's DM branch
returned before consulting the flag, so join, token refresh and the sweep
would have let an evicted participant back into an archived call. Archive
is now checked after membership and block for both channel kinds (Codex P2
on #1440), pinned in the predicate table.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Rg9QQWVN3E5UUgBD2dydtu

* docs(b2-5): record the Codex P2 fix in the evidence block

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Rg9QQWVN3E5UUgBD2dydtu

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-29 07:07:04 +00:00
J3vbandClaude Fable 5 2a21a22ecb docs(plans): bind the layout-refactor supplement into B3/B7/B9 and record B2-2 done (#1439)
Adds docs/plans/developer-experience-layout-refactor-2026-08-29.md and wires
it into the roadmap as dated workstream lines (B3 #17, B7 #16, B9 #11) rather
than a new phase. Updates the current implementation slice and README rows
for B2-2 (PR #1438, B2-3/B2-4 folded in; B2-5 next) and fixes the new plan's
stale pending-merge header.


Claude-Session: https://claude.ai/code/session_01Rg9QQWVN3E5UUgBD2dydtu

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-29 05:51:24 +00:00
J3vb 9c9b8be669 feat(b2-2): protocol epoch and negotiation (slim) (#1438)
* feat(b2-2): declare protocol_epoch in the schema and generate both constants

protocol/schema.json gains protocol_epoch (1). genprotocol emits
ws.ProtocolEpoch and PROTOCOL_EPOCH from it; the contract test pins the Go
constant to the schema so a stale regeneration fails the required check.

* feat(b2-2): check the client's protocol epoch in the auth handshake

The auth payload gains epoch (absent = 0). Outside [minClientEpoch,
ProtocolEpoch] the server answers one auth_error with code
protocol_epoch_unsupported, the client/server/min epochs, and a message
naming which side to update, then closes 1008 like every other handshake
failure. minClientEpoch is 0 for epoch 1 only so alpha.4 clients keep
connecting; the epoch-1 fixtures are unchanged.

* feat(b2-2): send the protocol epoch and offer the update on a refused connect

ws.ts sends epoch: PROTOCOL_EPOCH in the auth frame (contract test extended
on purpose). On auth_error code protocol_epoch_unsupported with a newer
server the dispatcher records the host in ui.store.updateRequiredHost and
main.ts mounts the UpdateNotifier on the connect page, so a refused client
gets the same Update Now banner it would have had on the main page.

* feat(b2-2): withhold client releases newer than the server's protocol epoch

The signed server-update manifest gains protocol_epoch (release.yml reads it
from protocol/schema.json). Updater.ReleaseProtocolEpoch verifies the
manifest and reads it; the client-update endpoint answers 204 when the
release's epoch is newer than ws.ProtocolEpoch or the manifest does not
verify. Releases without a manifest are epoch 0 and advertised as before.
Docs: protocol.md Compatibility section, api.md, deployment.md, protocol
README, CHANGELOG Unreleased.

* docs(b2-2): record the slim B2-2 decision and evidence; fold B2-3/B2-4 into it

* ci: prove the protocol_epoch manifest read on every PR, not only at tag time

* fix(b2-2): offer the update on an already-mounted connect page and keep the credential on a protocol refusal

Codex P1: on a first login or startup auto-login no overlay exists before
auth_ok, so a refusal never re-rendered the connect page and the one-time
read of updateRequiredHost missed it. The connect page now subscribes to
it, and a later refusal replaces the banner.

Codex P2: a refusal on reconnect went through the generic logout and
deleted the stored credential although the token is still valid.
clearAuth gets a protocol_epoch reason; main.ts keeps the credential on it
(the skip-auto-login flag is still set and, being sessionStorage, does not
survive the relaunch the update triggers).
2026-08-29 07:23:06 +02:00
e6c6bf12bf chore: sync dev with main after v1.2.0-alpha.4 (#1437)
* ci(deps): bump anthropics/claude-code-action (#1404)

Bumps the actions-dependencies group with 1 update: [anthropics/claude-code-action](https://github.com/anthropics/claude-code-action).


Updates `anthropics/claude-code-action` from 1.0.193 to 1.0.199
- [Release notes](https://github.com/anthropics/claude-code-action/releases)
- [Commits](https://github.com/anthropics/claude-code-action/compare/9d7150bc8a3dae8149739a88019d192b579ad90c...dcb57747bfceeaa1fa72638cae52295d1d853d4a)

---
updated-dependencies:
- dependency-name: anthropics/claude-code-action
  dependency-version: 1.0.199
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: actions-dependencies
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* build(deps): pin rfd to tauri-plugin-dialog's major to unblock the cargo group (#1406)

The cargo-dependencies group PR (#1405) fails Rust Unit Tests on Linux:

    error: failed to run custom build command for `rfd v0.17.2`
    You need to choose at least one backend: `gtk3` or `xdg-portal`
    features for x86_64-linux

rfd is not really ours. It arrives in the tree via tauri-plugin-dialog,
which pins ^0.16; we declare it directly only for the fatal-startup
message box in lib.rs, where the Tauri app never finished building and
the plugin has no AppHandle to run a dialog through.

Cargo unifies features only within a semver-compatible version group, so
while both wanted ^0.16 there was a single rfd in the graph and the
plugin's backend features covered our `default-features = false`
declaration too. Bumping our direct dep to 0.17 forks rfd into two
crates: the plugin keeps 0.16.0 with its features, ours resolves to
0.17.2 with none, and rfd 0.17 added a build.rs assertion that aborts
the Linux build when no backend feature is set. Confirmed in the PR's
lockfile, which carries both 0.16.0 and 0.17.2.

Adding a Linux backend feature would be the wrong fix: it would paper
over the fork and still build rfd twice on every platform for one error
dialog. Our version has to track the plugin's instead, so ignore
semver-minor rfd updates (0.16 -> 0.17 for a 0.x crate) until
tauri-plugin-dialog moves. Patch updates inside 0.16.x still flow.

The remaining five crates in the group are unaffected; `windows` in fact
consolidates 3 versions down to 2.

Cargo.toml is comment-only here - no dependency, feature, or lockfile
change - so the build is untouched.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>

* chore(deps): bump log (#1407)

Bumps the cargo-dependencies group with 1 update in the /Client/tauri-client/src-tauri directory: [log](https://github.com/rust-lang/log).


Updates `log` from 0.4.33 to 0.4.34
- [Release notes](https://github.com/rust-lang/log/releases)
- [Changelog](https://github.com/rust-lang/log/blob/master/CHANGELOG.md)
- [Commits](https://github.com/rust-lang/log/compare/0.4.33...0.4.34)

---
updated-dependencies:
- dependency-name: log
  dependency-version: 0.4.34
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: cargo-dependencies
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* ci(deps): bump anthropics/claude-code-action (#1408)

Bumps the actions-dependencies group with 1 update: [anthropics/claude-code-action](https://github.com/anthropics/claude-code-action).


Updates `anthropics/claude-code-action` from 1.0.199 to 1.0.200
- [Release notes](https://github.com/anthropics/claude-code-action/releases)
- [Commits](https://github.com/anthropics/claude-code-action/compare/dcb57747bfceeaa1fa72638cae52295d1d853d4a...24dcd50c0568f0fc9e9211213a4fd2d9eb15c4e0)

---
updated-dependencies:
- dependency-name: anthropics/claude-code-action
  dependency-version: 1.0.200
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: actions-dependencies
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* release: v1.2.0-alpha.4 — 62 fixes plus the B0/B1 repository foundation (#1426)

* Fix 27 findings from 2026-08-21 bug hunt (#1400)

* chore(findings): record 2026-08-21 bug hunt (38 findings)

* fix(api): 1 defect(s) (OC-0240)

* fix(client): 1 defect(s) (OC-0241)

* fix(plugin): 2 defect(s) (OC-0243, OC-0265)

* fix(client): 3 defect(s) (OC-0244, OC-0256, OC-0259)

* fix(client): 1 defect(s) (OC-0247)

* fix(client): 2 defect(s) (OC-0248, OC-0258)

* fix(identity): 1 defect(s) (OC-0250)

* fix(ws): 3 defect(s) (OC-0252, OC-0269, OC-0272)

* fix(admin): 1 defect(s) (OC-0253)

* fix(client): 1 defect(s) (OC-0254)

* fix(voice): 1 defect(s) (OC-0255)

* fix(ws): 1 defect(s) (OC-0260)

* fix(client): 1 defect(s) (OC-0261)

* fix(client): 1 defect(s) (OC-0262)

* fix(client): 1 defect(s) (OC-0263)

* fix(client): 1 defect(s) (OC-0264)

* fix(client): 1 defect(s) (OC-0268)

* fix(ws): 1 defect(s) (OC-0273)

* fix(service): 1 defect(s) (OC-0275)

* style: satisfy golangci-lint and prettier on 2026-08-21 fix commits

- drop ineffectual backupDir reset before return (registry.go, OC-0265)
- reflow long boolean expression (attachments.ts, OC-0241)

* fix(client): 4 defect(s) (OC-0242, OC-0246, OC-0249, OC-0251)

* fix(voice): 1 defect(s) (OC-0267)

* fix(admin): 1 defect(s) (OC-0274)

* fix(voice): 1 defect(s) (OC-0245)

* fix(ws): 1 defect(s) (OC-0271)

* fix(voice): 2 defect(s) (OC-0239, OC-0257)

* fix(ws): 1 defect(s) (OC-0266)

* fix(voice): 1 defect(s) (OC-0270)

* style: clear golangci-lint modernize and prettier nits from 2026-08-21 fixes

- range-over-int and slices.Contains modernizations in new Go test files
- prettier reflow in dispatcher.ts

* chore(findings): mark 2026-08-21 hunt findings fixed/declined

37 fixed across the fix waves, OC-0238 declined (LiveKit webhook TLS
requires a product decision, not a mechanical patch).

---------

Co-authored-by: Claude <noreply@anthropic.com>

* fix: 35 findings from the 2026-08-22 bug hunt (#1402)

* fix(voice): 1 defect(s) (OC-0277)

* fix(voice): 1 defect(s) (OC-0278)

* fix(client): 1 defect(s) (OC-0280)

refreshDmSidebar() rebuilds the entire DM sidebar subtree on every
dmStore.channels change - which includes presence flips and new
messages, not just DM list changes. The "Find a conversation" filter
text and input focus live only in that destroyed subtree, so they were
silently wiped mid-typing. Capture and restore both across the
destroy+recreate cycle.

* fix(ws): 1 defect(s) (OC-0285)

* fix(client): 1 defect(s) (OC-0286)

* fix(client): 1 defect(s) (OC-0288)

Consume the legacy unscoped mute key after migrating it onto the first
host, so a brand-new host with no scoped key of its own no longer reads
through to the same legacy list and inherits another server's mutes.

* fix(voice): 1 defect(s) (OC-0290)

* fix(db): 1 defect(s) (OC-0293)

DecrementMentionCounts reversed mention_count bumps that were never
applied: message_mentions stores every resolved mention id including the
author's blockers, while applyMentionCounts excludes blockers before
incrementing. Deleting a blocked author's message therefore wiped an
unrelated, genuine mention badge on the same read_states row. Mirror the
block exclusion in the decrement UPDATE.

* fix(db): 1 defect(s) (OC-0294)

DeleteAccount soft-deletes the departing user's messages but never reversed the read_states.mention_count bumps those messages made, leaving phantom mention badges. Reverse them inline in the existing transaction, mirroring DecrementMentionCounts' guards.

* fix(client): 1 defect(s) (OC-0295)

MemberList rebuilt every row on any non-presence-only membersStore change
and on every roles_update, but registered each row's click/contextmenu
listeners on the component-lifetime disposable.signal, which only aborts
at destroy(). Discarded rows therefore stayed reachable (and their
listeners live) for the component's whole lifetime. Route per-row
listeners through a per-render AbortController that is aborted and
replaced at the top of every render, and aborted again in destroy().

* fix(identity): 1 defect(s) (OC-0297)

UpdateProfile's post-commit re-read of the user row could fail for reasons
unrelated to context cancellation (SQLITE_BUSY, I/O error, pool exhaustion)
and was reported as ErrInternal even though UpdateUserProfile had already
committed. Callers that treat any UpdateProfile error as proof the write
never landed — handleUploadAvatar deletes the file it just stored — would
delete a file the committed avatar column now points at, permanently
breaking the avatar with no user_update broadcast.

Since UpdateUserProfile only writes username/avatar/display_name/about,
merge those four onto the pre-write snapshot to reconstruct the committed
row without needing the re-read to succeed, and log the read failure.

* fix(ws): 2 defect(s) (OC-0298, OC-0299)

- OC-0298: applyConnectStatus stamped c.user.Status even when the
  UpdateUserStatus write failed, so auth_ok and the presence broadcast
  claimed a status users.status disagreed with, and buildReady's
  ListMembers read never self-corrected for the session.
- OC-0299: refreshUserSnapshot silently fell back to roleName "member"
  when the new role lookup failed, pinning the session to a fabricated
  role on the wire. It now fails closed like the sibling lookups in
  upgradeAndAuth and handleFreshConnect.

* fix(client): 1 defect(s) (OC-0300)

* fix(client): 1 defect(s) (OC-0301)

* fix(ws): 1 defect(s) (OC-0302)

* fix(api): 1 defect(s) (OC-0305)

handleDiagnosticsConnectivity used clientIP(r), ignoring cfg.Server.TrustedProxies, so behind a configured trusted reverse proxy the endpoint reported the proxy hop instead of the real client address. Use clientIPWithProxies with the parsed trusted-proxy nets, matching RateLimitMiddleware on the same route.

* fix(client): 2 defect(s) (OC-0306, OC-0308)

* fix(client): 1 defect(s) (OC-0307)

QuickSwitcher registered a per-row click listener against the
overlay-lifetime AbortSignal, but renderResults() rebuilds every row on
each keystroke, arrow key, and store refresh. Discarded rows kept their
listeners alive until the overlay closed. Replaced with one delegated
click listener on the stable results container, keyed off the
data-channelid each row already carries.

* fix(client): 1 defect(s) (OC-0310)

* fix(server): 3 defect(s) (OC-0279, OC-0291, OC-0292)

Reap a soft-deleted message's attachment files, count lapsed temporary
bans as active users in the require_2fa enrollment gate, and only apply
the 2FA-enrollment precondition when require_2fa itself is being enabled.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SdkJRbjCtrG76jEnrhKbYo

* test(api): sync apiTestSchema with the user_blocks migration

DeleteAccount's mention-count reversal joins user_blocks; the api
package's hand-rolled schema fixture predates migration 012.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SdkJRbjCtrG76jEnrhKbYo

* fix(client): 3 defect(s) (OC-0281, OC-0282, OC-0296)

Decouple the E2EE identity-mismatch modal and right-click popovers from
the sidebar's per-render abort signal, and let global drag listeners
survive a mid-drag re-render.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SdkJRbjCtrG76jEnrhKbYo

* fix(voice): 2 defect(s) (OC-0283, OC-0287)

Retire a departed peer's E2EE key unconditionally on leave, and surface
a failed microphone unmute instead of reporting an unmuted state the
room never saw.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SdkJRbjCtrG76jEnrhKbYo

* fix(client): 3 defect(s) (OC-0289, OC-0303, OC-0309)

Guard the DM call button against redialing the channel already joined,
resolve the incoming-call banner's caller through the nickname-aware
display name, and keep the DM profile sidebar subscribed to live
member/status updates.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SdkJRbjCtrG76jEnrhKbYo

* style(client): prettier-format the dm-store test

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SdkJRbjCtrG76jEnrhKbYo

* fix(server): 1 defect(s) (OC-0284)

Make message soft-delete a compare-and-set so a repeated chat_delete
cannot reverse mention counts twice.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SdkJRbjCtrG76jEnrhKbYo

* fix(server): 2 defect(s) (OC-0276, OC-0304)

Re-sync a resumed connection's voice E2EE peer keys in registerNow
(announce frames are unsequenced and cannot be replayed), and apply the
live-connection presence rule to every DM payload DMService builds.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SdkJRbjCtrG76jEnrhKbYo

* chore(ledger): record the 2026-08-21 hunt findings as fixed

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SdkJRbjCtrG76jEnrhKbYo

* chore(ledger): independent revert-proof pass for OC-0276..OC-0310

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SdkJRbjCtrG76jEnrhKbYo

* refactor(service): extract DeleteMessage authorization into a helper

Keeps DeleteMessage under the cyclop complexity ceiling after the
OC-0284 guard.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SdkJRbjCtrG76jEnrhKbYo

---------

Co-authored-by: Claude <noreply@anthropic.com>

* chore(ledger): record the 38 open findings from the 2026-08-22 hunt (#1403)

Claude-Session: https://claude.ai/code/session_01SdkJRbjCtrG76jEnrhKbYo

Co-authored-by: Claude <noreply@anthropic.com>

* chore(graphify): refresh knowledge graph

* fix: close the three B0 P0 gates and record a measured baseline (#1409)

* chore(security): stop tracking the private security-finding reports

docs/security-findings/ holds detailed reports for defects that are not yet
fixed. The directory was untracked but not ignored, so any 'git add .' would
have published seven unfixed vulnerability traces to a public repository.

Findings are coordinated through private GitHub Security Advisories
(docs/security.md); only opaque identifiers and safe status belong in tracked
plans.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(client): repair the two red P0 unit contracts (G-01, G-02)

G-02: noise-suppression-restart stubbed MediaStream with
vi.fn().mockImplementation(arrow), which is not constructible. Vitest 4 threw
'is not a constructor' at the new MediaStream([inputTrack]) call in
noise-suppression.ts before reaching any assertion. Replaced with a real
class; the OC-0277 assertions are unchanged.

G-01: message-list's OC-0217 guard was inverted, not merely stale. It spied on
AbortSignal.prototype.addEventListener and asserted zero abort registrations,
but the leak it names registered row listeners via
element.addEventListener(..., { signal }) — a path that never calls that
prototype method. Measured: the leak produces 0 registrations (test passes),
while the OC-0286 fix rotates a per-window AbortSignal.any and produces 5
across 5 distinct signals (test fails). The guard passed on the bug and failed
on the fix.

It now captures the signal each window's row listeners register against and
asserts the invariant its name always claimed: one signal per rendered window,
a fresh signal per jump, and every superseded window already aborted with
exactly one live. Verified both directions — green on the fix, and
'expected 1 to be 5' with beginRowRender() reverted to rowSignal = ac.signal.

Client suite: 5257 passed, 0 failed (was 5255 passed, 2 failed).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(client): make the Playwright suite terminate

The runner finished every test and then never exited, printing no summary — so
the failure read as 'tests never finish' when it was 'process never exits'.
getActiveResourcesInfo() at hang time showed a live ProcessWrap plus several
PipeWrap: the Vite dev server was still running. Playwright's webServer
teardown does not kill it here.

Measured, full suite each time:

  npm run dev                        hangs, tests pass
  node node_modules/vite/bin/vite.js hangs, tests pass
  reuseExistingServer: false         hangs, tests pass
  gracefulShutdown SIGTERM/3s        hangs, tests pass
  npx vite                           exits, 290 of 293 FAIL
  no webServer (pre-started)         exits, 293 pass in 33s

npx only appears to fix it: npx exits once Vite is up, Playwright reads that as
the server dying and tears the group down mid-run, so later tests get
ERR_CONNECTION_REFUSED.

globalTeardown now kills the process listening on the dev port, releasing the
runner's handle. The webServer command spawns Vite's entry point directly so
the listening process is Playwright's own child — via 'npm run dev' the npm
process would still hold the handle open. It also reaps servers orphaned by an
interrupted run, which reuseExistingServer would otherwise silently adopt.

An earlier revision used netstat, which is not on PATH in every shell here; the
swallowed ENOENT made the fix look applied while the hang persisted. It now
uses PowerShell on Windows and lsof elsewhere, and warns on failure rather than
failing silently.

npm run test:e2e: exit 0, 293 passed, 37s, reproducible, no orphan listener.
playwright.config.prod.ts carried the same npm-wrapper shape.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* chore(client): align .nvmrc with the Node version CI uses

Three versions were in play, not two: .nvmrc said 20, CI pins 24, and the
machine the audit was measured on runs 26. A baseline measured against .nvmrc
is not the baseline CI produces, which defeats the point of B0.

Scoped to .nvmrc only. The full single-source-of-truth work — package engines,
contributor docs, release — stays in B1 (RL-17 / C-01).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs(plans): add the beta audit set and the B0 baseline

The 2026-08-23 audit set has been sitting untracked: repository-health and
repository-layout audits, beta product requirements, requirement traceability,
the issue register, and the B0-B10 roadmap. They are the plan of record for
beta and belong in the repository.

Adds b0-baseline-2026-08-25.md, which supersedes the roadmap's 'current
evidence snapshot'. Every row is marked measured or carried, so nothing is
inherited silently. It also records three audit claims that did not survive
verification:

  - G-01 was an inverted guard, not a stale assertion — it passed on the bug
    and failed on the fix.
  - The Playwright hang matched none of the three hypotheses; the runner could
    not kill its own dev server.
  - The golangci-lint toolchain failure is refuted: 19 linters run, 0 issues,
    verified with -v to rule out the known zero-linters false-green.

Adds b0-dev-branch-protection.sh, which records the applied dev branch
protection and the reasoning behind each setting.

Security detail stays private: the register carries only opaque SEC-* families
and safe closure criteria, per the roadmap's public/private handling policy.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* chore(graphify): refresh the knowledge graph

Own commit, per CLAUDE.md — the graph payload does not belong in the diff of
the changes that triggered it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs(plans): add the active-plan index and fix a stale status header (G-04)

Planning documents had no recorded state, so a reader could not tell current
guidance from shipped history. docs/plans/README.md now indexes every plan as
active, partially implemented, design-only, or shipped, and names the source of
truth for each concern so a defect count is never read out of a plan.

Status is recorded in the index rather than by moving or rewriting the
historical plans, so links from audits and commit messages keep resolving.

One real stale claim found and fixed: audit-2026-08-19-remediation.md still
read 'in progress 2026-08-19' while its own phase table showed phases 1-6 done
2026-08-20 (merged 03fcb7d5, PR #1396) with only phase 7 pending. The header
had drifted because the table was updated in place and the header was not.
No plan was found claiming '0 open findings'.

Also records the Step 8 staleness pass in the B0 baseline: all 38 open OC
records still resolve to a live file:line at this commit, so none is superseded
by later work. Adjudicating them individually is bughunt-fix work, not B0.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Update graph output files and manifest with new metadata

- Updated graph.html and graph.json with new binary data.
- Modified manifest.json to reflect changes in file modification times and AST hashes for several documents.
- Added new entry for README.md in the manifest with its corresponding metadata.

* docs(plans): close the Docker and coverage leftovers in the B0 baseline

Docker smoke: measured and passing. Image builds at 50.1 MB and boots on :8443
with TLS; docker-smoke.sh exits 0.

Server coverage: re-measured at 74.6% aggregate, confirming the figure carried
from the audit rather than continuing to inherit it.

Two findings from doing it:

ENV-03 — docker-smoke.sh cannot be run from Git Bash on Windows. MSYS path
conversion rewrites the container-internal /chatserver into
'C:/Program Files/Git/chatserver', so docker exec fails 127 and the script
reports 'container never reported healthy within 30s' — indistinguishable from
a real boot regression. MSYS_NO_PATHCONV=1 makes the same script pass. CI is
Linux and unaffected, but Windows is an official contributor platform (RL-20).

The CI Docker job is gated on main, so it is skipped for any PR targeting dev
— a dev-targeted change cannot get Docker evidence from CI at all.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* chore(graphify): refresh the knowledge graph

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs(plans): B1 execution plan, and accept HP-0 (#1410)

* docs(plans): add the B1 repository-foundation execution plan

B1 is the isolated layout/contributor phase. This records the execution
order, the proof for each step, and what is out of scope.

Two findings worth surfacing before any B1 work starts:

- HP-0 was never formally accepted. The roadmap's B1 entry gate requires
  it; no scorecard artifact exists, no commit or document records an
  acceptance, and the B0 baseline still lists "Step 10: HP-0 sign-off"
  under "Not yet done in B0". The plan lists the five gaps that closing
  it requires, including pinning required status checks on dev -- which
  are still unset, so a dev PR can currently merge red.

- Several layout-audit claims do not survive verification against HEAD,
  matching the B0 pattern. RL-09's "no single command verifies both
  protocol consumers" is false (make protocol-verify does, and is
  enforced in CI, the pre-commit hook, and a contract test). RL-10's
  test-discovery side effect never fires (no _test.go in Server/scripts).
  RL-06's regeneration concern is refuted locally. RL-08 grows a
  toolchain constraint instead. RL-05, RL-07, RL-20 and RL-21 are each
  worse than written -- RL-20 includes a live bug where a missing `make`
  is reported as stale protocol constants.

The riskiest item, RL-01 (flatten Client/tauri-client into Client), gets
a full reference inventory and a mechanical proof for both commits: tree-
object equality for the pure move, and scripted-substitution replay for
the path rewrite. Release asset names and updater contracts are verified
independent of the directory name, so the move cannot rename an artifact.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs(plans): correct the B1 status-check pin list from a live dev PR

The list was derived from ci.yml. Observing PR #1410's actual checks
found three that exist in no workflow file -- Analyze (go),
Analyze (javascript-typescript), Analyze (actions) -- because CodeQL
runs from GitHub default setup, configured in repository settings.
Reading .github/ alone misses them.

Also confirms the two negative predictions against a real dev-targeted
PR: Server Docker Build (verify) reports as "skipping", and Tauri Full
Build never appears in the check list at all. Neither may be pinned.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs(plans): accept HP-0 and pin the dev required status checks

Closes B1's entry gate. All five B1-0 items are done.

The scorecard is the artifact the hold point asks for: one place that
answers its four questions, records what was accepted as a stated
limitation rather than claimed green, and part-closes R-08.

Required status checks are now pinned on dev -- ten of them. That was
B0's one outstanding step. Two things came out of doing it:

- The names cannot be inferred from ci.yml. Three of the ten (the
  Analyze jobs) exist in no workflow file, because CodeQL runs from
  GitHub default setup configured in repository settings. They were read
  off a live dev-targeted PR with `gh pr checks`.
- Server Docker Build, Tauri Full Build and the CodeQL aggregate are
  deliberately excluded. The first two report "skipping" on a dev PR --
  Tauri Full Build under its unexpanded matrix name, since the job is
  skipped before matrix expansion. Admin Panel E2E is excluded because
  continue-on-error makes it report success unconditionally.

Two prior claims are corrected rather than left to propagate:

- b0-dev-branch-protection.sh was written assuming repository-settings
  writes are blocked from the agent sandbox. They are not; the PUT
  succeeded. The script stays as the record of intent and the way to
  re-apply or undo.
- An earlier revision of the B1 plan said Tauri Full Build does not
  appear in a dev PR's check list at all. It does, as skipping.

Evidence closed out:

- Rust is no longer a carried row. Re-measured: 115 passed, cargo clippy
  --all-targets -- -D warnings at exit 0, confirming the carried figure.
- The 38 open ledger records are accepted as counted, non-stale and
  assigned: 11 medium / 27 low, zero high or critical, zero dead paths
  across all 348 re-verified at this commit, and none assigned to B1.
- The private security review is reconciled: 7 findings, 7 of 7 mapped
  to existing public rows, 0 unmapped. Summary is content-free; the
  detail stays in the untracked private reports.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>

* refactor: flatten Client/tauri-client into Client (B1-1) (#1411)

* refactor: move Client/tauri-client to Client (pure move, no content change)

* refactor: re-point paths after the Client flatten (mechanical, no behaviour change)

---------

Co-authored-by: Claude <noreply@anthropic.com>

* B1-2: truth, entry points, and contributor path (#1412)

* fix(hooks): guard on the command the hook actually runs

pre-commit probed one binary and invoked another. The protocol block
guarded on `command -v go` and then ran `make protocol-verify`; the sqlc
block guarded on `command -v sqlc` and ran `make sqlc-verify`. `make` is
not on PATH on a stock Windows box, so a contributor with Go installed
but no make had their commit rejected with

    pre-commit: FAIL: protocol constants are stale — run 'make
    protocol-generate' in Server/ and stage the result

when nothing had been generated and nothing compared. The real cause was
`make: not found`, and the advice the message gives fails the same way.

Rather than add a `command -v make` guard, inline what the two Makefile
targets reduce to — `sqlc generate` / `go run ./scripts/genprotocol`
followed by `git diff --exit-code`. Same semantics, one less prerequisite,
and it doubles as the make-free equivalent B1-2 asks for. The Makefile
targets stay for anyone who prefers them.

Also: the protocol block was the only one with no `else`, so a
contributor without Go got no check and no notice. It now warns like its
two siblings. And gofmt is a separate binary from go, so it is probed
separately.

Verified both directions with the hook body replayed verbatim:
- Go present, make absent -> passes, no staleness claimed.
- schema edited without regenerating -> fails, as it must.

Refs RL-20 / L-14.

* docs: state one branch and PR model

Active documents contradicted each other head-on. README.md and
docs/contributing.md said branch from `dev` and target `dev`; CLAUDE.md
said branch from `main`, PR to `main`. That is R-02, and the 2026-08-19
audit had already recorded it as D-06 without it being resolved.

`dev` is the answer, and the repository already behaves that way: B0 made
`dev` PR-only with ten required checks enforced on admins, and #1409,
#1410 and #1411 all landed there. `main` carries releases.

docs/contributing.md becomes the single source of truth. It now states the
model, what protection is actually applied, and the two consequences a
contributor meets on their first PR — that a self-mergeable PR still cannot
merge red, and that Docker and Tauri Full Build report as skipped against
`dev` rather than failing. Everywhere else summarises and links here.

- CLAUDE.md: corrected, with a link rather than a second copy.
- CONTRIBUTING.md: new. GitHub's contributing-guidelines affordance only
  resolves the root, .github/ or docs/ — `docs/contributing.md` is not a
  path it finds, so the link never appeared on issues or PRs.
- PULL_REQUEST_TEMPLATE.md: names the base branch, which it did not.
- bughunt-run skill: reviewed the branch against `origin/main`, which is
  the wrong base once every PR targets `dev`.

README.md already said `dev` and is left as the short summary it should be.
Dated audits and the historical remediation plan keep their `main`-era
wording — they are records.

Refs R-02.

* fix(hooks): pick the pre-push base from the nearest integration branch

pre-push decided which side's gates to run from
`git diff --name-only origin/main...HEAD`. That was right when everything
targeted `main`. Once `dev` became the integration branch it stopped being
right: a branch cut from `dev` diffed against `main` counts everything on
`dev` and not yet on `main` as "changed".

Measured on this branch: the old base reported 609 changed files, the new
one reports 6. So in practice the hook was running the full server build
matrix and the client typecheck plus eslint on every push, whatever the
change touched — the file-based narrowing it exists for never engaged.

Now it picks whichever of origin/dev, origin/main is nearest, by commits
between merge-base and HEAD, skipping a candidate that scores 0. Verified:
a branch cut from dev picks origin/dev (2 ahead); dev itself scores 0
against dev and picks origin/main (8 ahead), which is what a dev -> main
release PR wants. With no candidate resolvable it falls back to the
existing `__all__`, so an unfetched or shallow clone still runs everything.

Refs RL-20 / L-14, R-02.

* chore(node): one Node source of truth

`.nvmrc` and all ten `actions/setup-node` pins said 24; five active
documents and the repo's only `engines` block still said 20. A contributor
following the docs installed a version CI does not run.

Node 24 wins — it is what CI already runs. Every manifest now declares
`engines`, and `engine-strict=true` turns a wrong major into a failed
install rather than an `EBADENGINE` warning nobody reads. `>=24` rather
than `^24` so a Node 26 box keeps working; `Client/.nvmrc` stays the
human-facing pin and the docs point at it instead of restating a number.

The `.npmrc` is per package root, not one at the top. npm reads the
project `.npmrc` from the package directory and does not walk parents —
verified with a throwaway package requiring node >=99: with only a parent
`.npmrc` npm warned and exited 0; with one in the package directory it
failed `notsup`. A single root file would have left `Client/`, the package
that matters most, on warnings.

Five docs, not the four previously identified — `docs/mcp-introspect.md`
also said 20. And `docs/contributing.md` claimed "`.nvmrc` + CI both say
Node 20", which was wrong about both.

Verified both directions in all three package roots: Node 22 fails
`notsup`; Node 24 installs clean and `npm ci` passes in Client/.

Refs RL-17 / C-01, ENV-01.

* docs: add the documentation landing page

`docs/` had 24 top-level files and no index. The root README carried a
flat list of 22 links that had drifted: six documents were reachable from
nowhere at all — including both 2026-08-23 audits and the test audit — and
two entries were labelled "latest" while newer unlinked audits existed.

docs/README.md is the index RL-12 asked for. It groups by what a document
*is*, because that is what decides whether to trust it: guidance tells you
how to do something, reference describes a contract the code implements,
audits are dated snapshots nobody updates, plans record intent. Every
tracked file under docs/ now appears exactly once, and the audit table says
plainly that audit-2026-08-19.md still claims "0 open findings" when the
ledger has 38.

The root README keeps a short curated list and defers to the index, rather
than maintaining a second copy that drifts again. Two fixes while there:
`docs/plans/` was linked as a bare directory, unlike its two sibling
directory entries, and was annotated "each carries a verified status
header" — which docs/plans/README.md:7-9 explicitly contradicts, since a
plan's header is exactly the thing that drifts and the index is the
authority.

Verified: 78 relative links across the new and edited files resolve, and
no tracked docs/ file is unreachable from the index.

Refs RL-12 / R-06.

* feat(scripts): root command facade

Entry points existed only inside Server/ (a Makefile) and Client/ (npm
scripts). Nothing at the root told a new contributor where to start, and
the root package.json had three scripts, none of which built or tested
anything.

`npm run check` from the root now runs what CI gates on, and
check:server / check:client / check:rust run one stack. scripts/run.mjs
is dependency-free Node — the shape render-ledger.mjs already uses — so
`npm run check` works before `npm install` has.

Cross-platform by construction: every step is spawned with an explicit cwd
and no shell, so there is nothing to quote and no `cd &&` to behave
differently on Windows. npm and npx get their .cmd suffix there. No step
shells out to make.

The facade orchestrates; it is not a new required path. Each step prints
the command and the directory before running it, and those are exactly the
commands documented per-stack — so a server contributor can read the output
and type them instead, and still never needs Node. Tools CI installs but a
contributor may not have (golangci-lint, which has no wrapper in this repo
at all; sqlc, pinned by Server/sqlc.version) are skipped with a printed
reason rather than failing.

Three corrections to the ci-check skill while aligning it:

- `make sqlc-verify protocol-verify` replaced by what those targets reduce
  to, so the documented path does not require make either.
- `cargo test` -> `cargo test --lib`, which is what ci.yml actually runs.
- "NODE_OPTIONS=--no-experimental-webstorage is mandatory on Node 22+" was
  false. tests/setup.ts installs the shim, CI runs Node 24 without the
  flag, and the suite was measured passing without it — 192 files / 5257
  tests, identical to the flagged run.

Also documents the third RL-20 problem, which needed no code: core.hooksPath
is exclusive, so `npm run hooks:install` silently disables any
.git/hooks/post-commit — including the one `graphify hook install` writes,
which CLAUDE.md tells agents to install. Nothing warned about that.

Verified: check:client 5257/192 green, check:rust 123 tests + clippy green,
--list prints every command, and the optional-tool skip path reports rather
than fails.

Refs RL-04 / L-04, RL-20 / L-14.

* feat(ci): fail on a document that contradicts the findings ledger

G-04's remaining half. The ledger is the source of truth for defect counts,
but nothing stopped a planning document from stating a different number and
nothing noticed when one did. `render-ledger.mjs --check` cannot help: it
validates the JSON schema and returns before rendering, so it never reads
FINDINGS.md and cannot see drift at all — and no workflow ran it anyway.

scripts/check-doc-counts.mjs counts ledger statuses and compares them to
what an allow-list of active documents claims, failing with file, line,
claimed value and actual. Wired into ci.yml as a job with no npm ci, since
the script imports nothing outside node:, and into the facade as
`npm run check:docs` — first in `check`, so a contradicted count does not
wait behind ten minutes of -race.

The patterns are narrow on purpose. A first attempt matched any
"<number> <status>" and flagged nineteen things, all false: "the 45 open P1
rows" (issue-register rows, not ledger findings), "All 8 findings F1-F8"
(a different register), "G-05 **refuted**" (an identifier), `">=20"` and
`CGO_ENABLED=0` (not counts at all). A check that cries wolf gets ignored,
which is the failure G-04 already describes. So a number is only read as a
claim in three shapes that cannot mean anything else: an enumeration of two
or more "<n> <status>" pairs, a status table row in a table that totals
itself, and "<n> records/findings" where the ledger is named within three
lines. Fifteen selftest assertions pin both directions, and the job runs
them before it runs the check.

It reads findings-ledger.json directly rather than importing
render-ledger.mjs for `validate`/`render`: that module ends in a bare
top-level `await main()` with no import.meta.main guard, so importing it
rewrites FINDINGS.md as a side effect.

Dated docs/audit-*.md are reported, never failed — they are snapshots
nobody maintains. audit-2026-08-19.md does claim zero open findings against
38 open, so b0-baseline's "No plan was found claiming '0 open findings'"
holds for docs/plans/ but not for docs/.

Not included: a real FINDINGS.md render-drift check. That is RL-07 and
belongs with the generated-artifact work, not here.

Verified: 27 claims across 9 documents agree; corrupting one count in
docs/plans/README.md fails the check naming that line, for both the status
and the total.

Refs G-04.

---------

Co-authored-by: Claude <noreply@anthropic.com>

* chore: remove graphify knowledge graph tooling (#1413)

The committed knowledge graph and its PreToolUse hooks were steering every
codebase question through `graphify query` before any other tool could run.
Serena (gopls + rust-analyzer + tsserver over MCP) answers the same questions
from real language servers rather than a generated snapshot that goes stale
between rebuilds, so the graph no longer earns the ~20 MB it costs the tree.

Removed:
- `graphify-out/` untracked (7 files, ~20 MB) and now gitignored
- both `graphify hook-guard` PreToolUse hooks from `.claude/settings.json`
- the "Knowledge graph (graphify)" section of `CLAUDE.md`
- the `graphify-out/**` block from `.gitattributes` and `.gitignore`
- the graph-rebuild step from the `bughunt-run` skill, and the graph-edge
  guidance from the bughunt workflow prompt
- the graphify-specific `core.hooksPath` example in `ci-check` and
  `docs/contributing.md`, keeping the underlying warning in generic form

Also deletes the locally installed `post-commit` / `post-checkout` rebuild
hooks (untracked, not part of this diff).

This does not shrink clone size: the graph blobs stay in published history,
which `docs/plans/b1-repository-foundation-2026-08-25.md` explicitly rules out
rewriting. It does stop future refreshes from adding more.

Dated audit and plan documents keep their graphify references as a historical
record of the state they described.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>

* B1-3: repository hygiene gates (RL-19 / L-13, S-05) (#1414)

* chore(format): one Prettier config at the repository root

Every formatting rule in this repository lived under Client/ and covered
exactly two globs: Client/src/**/*.ts and Client/tests/**/*.ts. Root Markdown,
all of docs/, every YAML and JSON, all CSS, the root scripts and
tools/mcp-introspect were formatted by nothing. There was no .editorconfig.

The obvious fix -- a second Prettier config at the root for "everything else"
-- gives two configs and two ignore files that can silently disagree about the
same file. So the root takes ownership instead: config, ignore file and gate
move up, and Client/ folds in. Client's inline "prettier" block, its
.prettierignore, its format/format:check scripts and its now-unused prettier
devDependency are all deleted; knip would have failed client-check on that last
one.

The .prettierrc.json values are lifted byte-for-byte from Client/package.json,
which is what keeps the reformat commit free of client TypeScript churn: 87
tracked files need reformatting and not one of them is under Client/src or
Client/tests.

.prettierignore carries only what .gitignore does not. Prettier 3 reads the
root .gitignore by default, so node_modules/, dist/, coverage/,
Client/src/generated/ and docs/security-findings/ need no entry. It does NOT
read nested .gitignore files, which is why .remember/ is listed explicitly --
38 untracked per-machine scratch files were otherwise able to turn a shared
gate red. graphify-out/ is listed because its seven files are tracked and
.graphify_labels.json is signed byte-for-byte by its .sig, so formatting it
would silently invalidate the signature.

check:hygiene is registered in scripts/run.mjs and folded into check and
release:preflight. It deliberately contains no `gofmt -l` step: gofmt -l prints
offenders and still exits 0, so it cannot fail a build. Go formatting is
enforced separately.

shellcheck and actionlint take their file lists from `git ls-files`, never a
filesystem glob -- .claude/worktrees/ holds a gitignored pre-flatten copy of
the tree with three .sh files a glob would happily lint.

This commit leaves the tree non-conformant on purpose. The reformat is the next
commit, so the 87-file diff is reviewable separately from the rule that caused
it.

Not included: editorconfig-checker. .editorconfig is the editor baseline the
audit asked for; Prettier, gofmt and rustfmt already fail CI on the same
indentation and newline rules, so a fourth tool checking them again is a gate
with no failure mode of its own.

Verified: `npx prettier --check .` names 87 tracked files and zero untracked
ones; the same command listed 38 .remember/ scratch files before the ignore
entry and none after. `node scripts/run.mjs --list` resolves check:hygiene to 8
shell targets and 4 workflow targets. Both package.json files parse.

Refs RL-19 / L-13, S-05.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* chore(format): reformat the tree to the repository Prettier rules

Mechanical. This commit is `npx prettier --write .` and nothing else -- the
rule that caused it landed in the previous commit so this diff can be reviewed
as a transformation rather than as 84 files of hunks.

84 tracked files: 54 Markdown, 7 .mjs, 7 JSON, 6 YAML, 4 .js, 3 CSS, 2
TypeScript (the two Playwright configs at Client's root, which the old
Client/src + Client/tests globs never covered). No file under Client/src or
Client/tests moves, because .prettierrc.json carries Client's former inline
values byte-for-byte.

Prettier rewrote 87 files, not 84. The three in .github/ISSUE_TEMPLATE/ had
CRLF on disk and differ only in line endings, which .gitattributes
(`* text=auto eol=lf`) already normalises, so their committed blobs are
unchanged. Worth knowing before someone reconciles the two numbers.

The largest single diff is .superpowers/findings-ledger.json at 7976 lines
rewritten. That is safe to format: nothing writes the ledger programmatically
-- render-ledger.mjs reads it and writes only FINDINGS.md -- so no tool will
fight Prettier over its style on the next hunt. FINDINGS.md itself is ignored
as generated.

Verified: `npx prettier --check .` reports "All matched files use Prettier code
style", so the pass is both complete and idempotent. All 7 reformatted JSON
files were parsed before and after and compared as values: semantically
identical, zero content changes. `node .superpowers/render-ledger.mjs --check`
still reports 348 valid findings and leaves FINDINGS.md untouched.
`node scripts/check-doc-counts.mjs` still passes its selftest and still agrees
on 27 claims across 9 watched documents -- table realignment did not break the
patterns it matches on. `node scripts/run.mjs --list` still parses.

Refs RL-19 / L-13, S-05.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* chore(lint): enforce Go formatting in the Server linter

S-05: repository-wide Go formatting was not a required gate. The only gofmt
enforcement anywhere was .githooks/pre-commit, which is opt-in per clone
(`npm run hooks:install`), only sees staged files, and warns-and-skips when
gofmt is off PATH.

The obvious fix -- a `gofmt -l` step in CI -- does not work: `gofmt -l` prints
its offenders and still exits 0, so the step passes no matter what it finds.
scripts/run.mjs has the same problem, which is why check:hygiene has no Go step
either.

So gofmt goes where it can actually fail something: Server/.golangci.yml. The
file was already `version: "2"` but had no `formatters:` block at all, so the
19 enabled linters ran with zero formatters. In v2 gofmt/gofumpt/goimports
moved out of `linters.enable` into their own section with its own exclusions.
Adding it there means the gate reports through the Lint step of "Server Build &
Test", which is already pinned as required on dev -- no new job and no new pin.
Every tracked .go file is under Server/ (551 of them, one go.mod), so
Server-scoped is repository-wide here.

One file was genuinely misformatted: a one-space struct field alignment in
Server/admin/handlers_users_broadcast_test.go, fixed in the same commit because
a single line does not need its own reformat commit.

Trap worth recording: `gofmt -l .` on a Windows working tree lists every file
that has CRLF on disk, because gofmt normalises line endings. That reported 18
offenders here, 17 of them ghosts. The blobs are all LF -- .gitattributes
forces `eol=lf` -- so CI never saw them, and the honest test is to run gofmt
over `git show HEAD:<file>` rather than the working copy. Doing that across all
551 tracked Go files found exactly the one real offender above.

Verified both directions with golangci-lint v2 locally: `golangci-lint run
./...` reports 0 issues on the formatted tree; appending a misformatted
function to Server/auth/constants.go produces 2 gofmt findings; appending the
same misformatted function to Server/db/dbgen/admin.sql.go produces 0, so the
exclusion holds. Both files restored and verified clean afterwards.

Refs RL-19 / L-13, S-05.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(scripts): escape the NUL separator instead of embedding one

The `tracked()` helper added earlier in this branch splits `git ls-files -z`
output on NUL. The separator was written as a literal NUL byte rather than the
two-character JavaScript escape, so scripts/run.mjs became a binary file: `git
diff` refused to show it, `grep` reported "Binary file matches" instead of the
line, and `* text=auto` in .gitattributes stops normalising line endings for a
blob it detects as binary.

The code worked -- splitting on a raw NUL and splitting on "\0" are the same
operation -- which is exactly why this is worth fixing before it is inherited.
A source file that tooling classifies as binary is a file nobody can review.

Verified: zero NUL bytes remain, `grep -n "split("` now prints line 50 instead
of "Binary file matches", `node scripts/run.mjs --list` still resolves the same
8 shell and 4 workflow targets, and prettier still reports the file clean.

* chore(lint): enforce Rust formatting

Rust had no formatting gate of any kind: no rustfmt.toml, no `cargo fmt`
anywhere in CI, in scripts/run.mjs, in the Makefile or in the git hooks. Clippy
was the only Rust gate, and clippy does not check layout.

`cargo fmt --all -- --check` now runs in the rust-tests job, ahead of clippy: a
formatting failure is cheap to produce and cheap to fix, and there is no reason
to spend a clippy pass to surface one. The stable toolchain in that job
requested `components: clippy` only, so rustfmt is added there.

Only that job. ci.yml has a second, byte-identical `Install Rust` block in
tauri-build; it stays clippy-only, because a full desktop build is the wrong
place to discover a misplaced brace.

No rustfmt.toml. The default profile is the point of a baseline -- a config
file here would be a second opinion about style with nothing to say.
Client/src-tauri is a single `[package]`, not a workspace, so `--all` is a
safeguard against a future member rather than a fan-out today.

Verified: `node scripts/run.mjs --list` resolves check:rust to three steps with
`cargo fmt --all -- --check` first, `npm run format` now also runs `cargo fmt
--all`, and prettier reports ci.yml, run.mjs and the ci-check skill clean.
`cargo fmt --all -- --check` currently fails on 13 files -- that is the
reformat, and it is the next commit.

Refs RL-19 / L-13.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* chore(format): reformat the Rust crate to rustfmt defaults

Mechanical. This commit is `cargo fmt --all` and nothing else; the gate that
demands it landed in the previous commit so this diff is reviewable on its own.

13 of the 17 tracked .rs files, +343/-164. The crate had never been formatted,
so the changes are the usual first-run set: aligned trailing comments collapsed
to single spaces, single-element slice literals folded onto one line, long
method chains broken across lines, closure bodies expanded into blocks.

Verified: `cargo fmt --all -- --check` is clean, so the pass is complete and
idempotent. `cargo clippy --all-targets -- -D warnings` finishes with no
warnings, and `cargo test --lib` reports 115 passed / 0 failed -- identical to
before the reformat, which is what "mechanical" has to mean for a commit that
touches this much of the crate.

Refs RL-19 / L-13.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(scripts): make the root facade actually run on Windows

Adding the first gate that a contributor would run from the repository root
exposed two bugs in the facade, both of which made it silently wrong on the
platform this project is developed on.

1. Every npm and npx step failed. bin() appends `.cmd` on Windows, but Node
   refuses to spawn a .cmd or .bat with shell:false -- the CVE-2024-27980
   mitigation -- and fails with EINVAL and a *null* exit status. run.mjs only
   special-cased ENOENT, so the result was `FAILED: npx prettier --check .
   exited null` with nothing to explain it. check:client has three npm steps and
   has never been able to run here.

   Fixed by spawning only the npm shims through a shell. They are concatenated
   into a single command string rather than passed as an args array, because
   shell:true plus a separate array is deprecated (DEP0190) and prints a warning
   on every invocation; no argument in this file contains a space.

2. Every optional() step was skipped, always. onPath() shelled out to
   `where` on Windows, but where.exe lives in C:\WINDOWS\System32, which a Git
   Bash PATH does not necessarily contain -- on this machine PATH carries
   System32\Wbem, System32\WindowsPowerShell\v1.0 and System32\OpenSSH but not
   System32 itself. The probe could not start, `probe.status === 0` was false,
   and golangci-lint and sqlc reported as "not installed" while installed.

   Fixed by resolving against PATH and PATHEXT directly. No subprocess, and no
   dependency on which directories happen to be on PATH.

A spawn error other than ENOENT now reports its code instead of surfacing as a
null exit status.

Verified: before, `node scripts/run.mjs check:hygiene` died with "exited null"
and both optional steps printed SKIP with the tools present on PATH. After, the
same command runs prettier, shellcheck and actionlint and prints
"check:hygiene: passed", with no deprecation warning. `golangci-lint` is
detected by the new onPath where the old one missed it.

Refs RL-20 / L-14.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* chore(format): ignore build output that nested gitignores hide

Prettier honours the root .gitignore and no other. Every build and scratch
directory in this repository is ignored by a *nested* one -- Client/.gitignore,
.serena/.gitignore, .superpowers/sdd/.gitignore -- so none of them were
excluded from the new repository-wide gate.

The effect is not subtle. Running `cargo test` once drops roughly 850
formattable files into Client/src-tauri/target/, and the hygiene gate goes from
clean to "Code style issues found in 939 files". CI never sees it, because a
fresh checkout has no build output; every contributor sees it on their second
command.

Mirrors the three nested files rather than inventing a list: dist, coverage,
playwright-report, test-results, .vite, src-tauri/target and src-tauri/gen from
Client/.gitignore, plus .serena/ and .superpowers/sdd/. node_modules needs no
entry -- Prettier ignores it by default.

Verified: `npx prettier --check .` reports "All matched files use Prettier code
style" with a fully populated Client/src-tauri/target/ present on disk, and
still names README.md when a misformatted table is appended to it.

Refs RL-19 / L-13.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* chore(ci): shellcheck, actionlint, and a repository hygiene job

The last two gates RL-19 asks for. Neither existed: the shell scripts were
never linted, the workflows were never syntax-checked, and .githooks/pre-commit
carried hand-written `# shellcheck disable=` directives that nothing had ever
read.

New `hygiene` job, ubuntu-only and root-scoped, modelled on docs-consistency
for the same reason: every gate in it is platform-independent text analysis,
and .gitattributes pins eol=lf so a second OS would only re-prove line endings.
It runs `npm run check:hygiene` -- the same entry point a contributor runs, not
a parallel copy of the commands.

shellcheck ships in the runner image. actionlint does not, so it is pinned by
version and verified by sha256: an installer script piped from a branch would
be the one unverified download in a workflow file that pins every action by
commit SHA.

Prettier's step moves here from client-check, where it no longer belongs.

Both linters found real defects.

shellcheck, 3 findings in 8 scripts. Two are SC1125 errors in
.githooks/pre-commit: `# shellcheck disable=SC2086 - repo paths contain no
spaces` is not a valid directive. Trailing prose makes shellcheck discard the
rest of the line, so neither suppression was ever in effect -- and one of the
two was written earlier in this same branch, which is a fair demonstration of
why the gate is worth having. The prose moves to its own line above. The third
is SC2015 in start-server.sh, rewritten as an explicit if.

actionlint, 5 findings, all inside `run:` blocks it shellchecks once shellcheck
is on PATH. Three SC2015 in load-baseline.yml, rewritten as explicit ifs. Two
SC2035 in release.yml, where `sha256sum *` should not become `sha256sum ./*`:
the comment four lines above records that ParseChecksumFile exact-matches the
last field, so a "./" prefix would strand every deployed server exactly as a
"windows/" prefix would. `sha256sum -- *` satisfies the linter and leaves the
output bytes identical.

Verified all three gates in both directions with shellcheck 0.10.0 and
actionlint 1.7.7 on PATH. Passing: `node scripts/run.mjs check:hygiene` prints
"check:hygiene: passed" with all three steps run, not skipped. Failing:
appending `bait_fn() { cat $1; }` to Server/scripts/voice-test.sh fails on
SC2086; changing a runs-on to `ubunt-latest` fails on runner-label; appending a
misformatted table to README.md fails prettier. All three files restored and
confirmed clean afterwards. actionlint validates the new job in ci.yml itself.

Refs RL-19 / L-13, S-05.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs(plans): record B1 progress through B1-3

The header still read "B1-0 is complete; B1-1 is the next step" three merged
phases later. A plan that misstates where it is costs a reader the same
confusion whether it is stale by one phase or three.

B1-0 (#1410), B1-1 (#1411), B1-2 (#1412) and B1-3 (this branch) are done; B1-4,
dependency automation, is next.

Verified: `node scripts/check-doc-counts.mjs` still agrees on 27 claims across
9 watched documents -- this file is one of them -- and prettier reports it
clean.

* chore(ci): pin Repository Hygiene as a required check on dev

The second half of S-05. Its acceptance criterion is "tree is formatted AND a
fast required gate fails future drift" -- a check that runs but is not pinned
lets a formatting regression merge, so the gate is not a gate until this lands.

The name was read off PR #1414 with `gh pr checks` after the job reported
`pass` in 26s, not copied out of ci.yml. That order matters: the B0 script
records that three pinned names exist in no workflow file at all, and that a
required check which never reports blocks every PR forever.

Extends the existing script rather than adding a second one, per the B1 plan.

Also records, in the "deliberately NOT pinned" list, that Docs & Ledger
Consistency reports and passes on a dev PR yet is unpinned. That reads as an
oversight from the 2026-08-25 pass rather than a decision, but it belongs to
G-04, so it is documented here and not changed.

NOT APPLIED YET. Running this script now would pin a check that PR #1413 cannot
report -- its branch predates the hygiene job, so the job does not exist in its
workflow file and the check would never arrive. Run it after #1414 merges;
#1413 needs a rebase onto dev regardless.

Verified: shellcheck clean, the embedded JSON parses, and `check:hygiene`
passes with prettier, shellcheck and actionlint all running.

Refs S-05, RL-14 / G-03.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>

* B1-4: dependency automation (RL-05 / L-05, RL-18) (#1415)

* chore(deps): cover the root and mcp-introspect npm roots

The repository has three npm package roots — `/` (changelogen, prettier),
`/Client`, and `/tools/mcp-introspect` (@modelcontextprotocol/sdk, zod) —
each with its own package-lock.json, and `npm run bootstrap` runs `npm ci`
in all three. Dependabot watched exactly one of them. The root's prettier is
what the Repository Hygiene gate runs, so the formatting gate's own toolchain
was drifting unwatched.

The obvious fix is to collapse the three roots into an npm workspace and
watch one lockfile. Measured on npm 11.17 / Node 26 rather than assumed, that
trade is bad, and it is bad for different reasons than expected. Workspaces do
not break the things you would predict: `npm ci` inside `Client/` still exits
0, `npm run <script>` still resolves the hoisted binaries because npm prepends
every ancestor `node_modules/.bin` to PATH, and `engine-strict` still fails
the install on a wrong Node major. What they cost is ten CI steps keyed on
`cache-dependency-path: Client/package-lock.json` (six in ci.yml, four in the
tag-only, CI-ungated release.yml) pointing at a file that stops existing; the
Repository Hygiene job's deliberate root-only install growing 970 ms to
6172 ms and 39 to 318 packages unless every call site remembers
`--workspaces=false`; and one shared lockfile putting all three npm Dependabot
groups back into the same file, which is precisely the rebase storm the
grouping comment at the top of dependabot.yml exists to prevent. The measured
benefit is one 298 KB lockfile instead of three (17 KB / 253 KB / 42 KB) and
614 resolved packages deduped to 582 — 32 packages, 5.2% — with client install
time unchanged at 5642 ms against 5667 ms.

So the roots stay separate and each gets its own block, matching the four that
already exist: grouped to one PR, majors ignored, weekly on Monday. A single
block with `directories:` was rejected for the same reason as workspaces —
grouping only works while a group rewrites exactly one lockfile. The decision
and its numbers are recorded in docs/contributing.md under Dependency Policy,
so the next person to propose workspaces reads the measurement instead of
repeating it.

Verified: a coverage checker cross-references every `package-ecosystem` /
`directory` pair in dependabot.yml against every manifest in `git ls-files`,
in both directions. Against dev at 2a37f386 it reports `UNWATCHED npm
package.json` and `UNWATCHED npm tools/mcp-introspect/package.json` (plus
Server/Dockerfile, which the next commit covers). Against this commit both npm
rows read `ok`, and the forward direction confirms each newly declared
directory really holds a package.json. `npx prettier --check` reports both
edited files unchanged, so the B1-3 formatting gate stays green.

Not included: the docker ecosystem (next commit, RL-18); immutable image
digests for release/runtime containers, which is R-04's remaining half and
belongs to B6; and turning the coverage checker into a permanent
`check:hygiene` gate — a new manifest root can still drift unwatched, which is
how this gap arose, but that is new gate machinery rather than the coverage
this item asks for.

Refs RL-05 / L-05

* chore(deps): watch the server container base images

Server/Dockerfile pulls `golang:1.26-bookworm` to build and
`gcr.io/distroless/static-debian12` to run, and nothing watched either. Every
other dependency root in the repository is on a weekly Dependabot schedule, so
the one artefact that ships to users as a whole filesystem was the only one
whose upstream moved silently — including its CA certificates, which the
Dockerfile comment specifically calls out as the reason distroless was chosen
over scratch.

The obvious fix is to pin both images by digest and be done. That is the wrong
move here for two reasons. A digest pin with no automation behind it is worse
than a tag: it freezes the base image at whatever was current the day someone
typed it, and a frozen distroless base is a frozen CA bundle. And digest
refresh for release and runtime images is R-04's other half, scoped to B6
alongside the smoke tests that have to gate it — landing half of it here would
leave the digests pinned and the refresh unowned.

So this adds the `docker` ecosystem for /Server on the same terms as the five
blocks around it: grouped to one PR, majors ignored, weekly on Monday. Be
precise about what that actually buys, because it is less than the block
implies. Of the two images only `golang:1.26-bookworm` carries a comparable
version, so it is the only one Dependabot can act on today;
`gcr.io/distroless/static-debian12` has no version tag, and an untagged image
is not something a version update can move — it needs the digest pinning that
B6 owns. The comment above the block records that the Go builder tag tracks
Server/go.mod and every `actions/setup-go` in CI, so a `1.27-bookworm` PR is a
prompt to move all three together rather than a standalone merge.

Verified: the coverage checker cross-references every `package-ecosystem` /
`directory` pair against every manifest in `git ls-files`, in both directions.
Against dev at 2a37f386 the reverse direction reports `UNWATCHED docker
Server/Dockerfile`; against this commit every row reads `ok` and it exits 0 —
`ALL ROOTS WATCHED`, six blocks covering six manifest roots, with the forward
direction confirming /Server really holds a Dockerfile. `npx prettier --check`
passes on the edited file.

Not included: Server/docker-compose.yml and Server/docker-compose.otel.yml.
Their four images are `ghcr.io/j3vb/owncord-server:latest` (this repository's
own published image), `livekit/livekit-server:v1` (a floating major tag, and
majors are ignored everywhere), `jaegertracing/all-in-one:latest` and
`prom/prometheus:latest` — none of which a version update can move, so a
compose block would be configuration that provably produces nothing. Also not
included: immutable digests plus digest-refresh PRs with smoke tests for the
release and runtime images, which is the remainder of R-04 and belongs to B6.

Refs RL-18

* docs: apply skill-review findings to ci-check and the project skills (#1416)

The observation log had accumulated 46 open entries against a last review of
2026-08-14. Seven of them target skills tracked in this repository and were
verified still-unapplied against the current files.

`ci-check` gains four things it was missing. It never mentioned `cargo audit`,
which CI runs pinned at 0.22.1 in `tauri-build` — the one gate that turns red
with zero local changes, because an upstream advisory breaks a branch that was
clean yesterday, and the one a hand-written mirror silently drops because no
edit provokes it. It never mentioned that `release.yml` is tag-triggered and
PR-ungated, so a smoke/sign/strip step added only there first executes on the
release; #1376 shipped a smoke harness whose own bug then blocked a release,
and #1378 fixed it structurally by extracting `Server/scripts/docker-smoke.sh`
for both workflows. And it had no guidance for reading a red check at all: a
new section adds causality-before-forensics triage (diff the changed-file set
against the failing job's input surface before opening a log — a workflow-only
diff cannot cause a Go goroutine leak), the lockfile-fork diagnosis for
dependency bumps (a 1 → 2 entry-count transition means the update forked the
dependency and revoked the features it was borrowing, so aligning versions is
the fix, not setting the feature the new copy demands), and the known-flake
table promoted to a signature-to-recovery index, now including the apt-mirror
hang that cancels `tauri-build` by timeout.

The baseline rule that came with the triage section needed adjusting rather
than transcribing. Its source observation recorded `golangci-lint`'s known-red
complexity baseline as 23 cyclop / 6 dupl / 21 funlen / 12 nestif; #1389
cleared that to zero, so quoting those numbers would have taught the reader to
excuse a failure that is now genuinely theirs. The rule is recorded without
them, stating that the repo currently carries no known-red gate and what to do
if one is ever reintroduced.

`protocol-change` claimed the schema is the source of truth without saying what
it covers. It holds message-type names only, so a payload-field change touches
the Go command/message files, the client types and `docs/protocol.md` and never
the schema — routing one through the regenerate cycle is wasted work. A table
splits the three cases, with the relay-handler caveat: a server that
re-serialises drops unknown fields, so a forwarded field is not backward
compatible with older servers.

`task-observer`'s numbering discipline treated collisions as a parallel-human
accident. They are structural in fan-out workflows, because a dispatched
subagent has the skill active in its own context and writes to the same log.

`bughunt-run` covered findings blocked by a circuit breaker but not findings
that went stale: a later hunt routinely fixes a blocked finding as a side
effect of an overlapping sibling, and a saved debris patch stops applying once
a refactor rewrites its files. Of 6 findings blocked on 2026-08-14, 2 were
already fixed 5 days later.

`docs/contributing.md` gains the commit-body convention that was being followed
without being written down anywhere — reasoning over diff-restatement, a
`Verified:` paragraph proving both directions, and an explicit `Not included:`
line. That last one is what keeps adjacent scope from becoming either silent
drift or an unnecessary blocking question.

Verified: each edit was checked against the live file before applying, which
changed two outcomes. Observation 50 (make the hunt's stop rule measure
coverage, not just quietness) is already implemented — `bughunt-run` documents
`coverage + dry is the real stop`, `stalledCoverage` and
`coverage.uncoveredAtStop`, landed by #1399 — so it is marked actioned rather
than re-applied. Observation 42 looked covered by the same grep and was not:
the existing text handles breaker-blocked findings, a different case from a
finding a sibling fix already closed. Confirmed absent before editing:
`cargo audit` and `release.yml` in ci-check, `payload` in protocol-change,
`subagent` in task-observer. `npm run check:hygiene` passes (prettier clean on
all five files); `npm run check:docs` passes.

Not included: the 21 open observations targeting `superpowers:*` plugin skills,
which live in a versioned plugin cache and…

* chore(deps): bump the npm-dependencies group across 1 directory with 3 updates (#1428)

Bumps the npm-dependencies group with 3 updates in the /Client directory: [eslint](https://github.com/eslint/eslint), [oxlint](https://github.com/oxc-project/oxc/tree/HEAD/npm/oxlint) and [typescript-eslint](https://github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/typescript-eslint).


Updates `eslint` from 10.9.0 to 10.9.1
- [Release notes](https://github.com/eslint/eslint/releases)
- [Commits](https://github.com/eslint/eslint/compare/v10.9.0...v10.9.1)

Updates `oxlint` from 1.79.0 to 1.80.0
- [Release notes](https://github.com/oxc-project/oxc/releases)
- [Changelog](https://github.com/oxc-project/oxc/blob/main/npm/oxlint/CHANGELOG.md)
- [Commits](https://github.com/oxc-project/oxc/commits/oxlint_v1.80.0/npm/oxlint)

Updates `typescript-eslint` from 8.67.0 to 8.68.0
- [Release notes](https://github.com/typescript-eslint/typescript-eslint/releases)
- [Changelog](https://github.com/typescript-eslint/typescript-eslint/blob/main/packages/typescript-eslint/CHANGELOG.md)
- [Commits](https://github.com/typescript-eslint/typescript-eslint/commits/v8.68.0/packages/typescript-eslint)

---
updated-dependencies:
- dependency-name: eslint
  dependency-version: 10.9.1
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: npm-dependencies
- dependency-name: oxlint
  dependency-version: 1.80.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: npm-dependencies
- dependency-name: typescript-eslint
  dependency-version: 8.68.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: npm-dependencies
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* ci(deps): bump anthropics/claude-code-action (#1429)

Bumps the actions-dependencies group with 1 update in the / directory: [anthropics/claude-code-action](https://github.com/anthropics/claude-code-action).


Updates `anthropics/claude-code-action` from 1.0.200 to 1.0.206
- [Release notes](https://github.com/anthropics/claude-code-action/releases)
- [Commits](https://github.com/anthropics/claude-code-action/compare/24dcd50c0568f0fc9e9211213a4fd2d9eb15c4e0...1f291e1cfe0f5fc21db2aef19af844591600ade7)

---
updated-dependencies:
- dependency-name: anthropics/claude-code-action
  dependency-version: 1.0.206
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: actions-dependencies
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* chore(deps): bump golang from 1.26-bookworm to 1.27-bookworm in /Server in the docker-dependencies group across 1 directory (#1427)

* ci(deps): bump anthropics/claude-code-action (#1404)

Bumps the actions-dependencies group with 1 update: [anthropics/claude-code-action](https://github.com/anthropics/claude-code-action).


Updates `anthropics/claude-code-action` from 1.0.193 to 1.0.199
- [Release notes](https://github.com/anthropics/claude-code-action/releases)
- [Commits](https://github.com/anthropics/claude-code-action/compare/9d7150bc8a3dae8149739a88019d192b579ad90c...dcb57747bfceeaa1fa72638cae52295d1d853d4a)

---
updated-dependencies:
- dependency-name: anthropics/claude-code-action
  dependency-version: 1.0.199
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: actions-dependencies
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* build(deps): pin rfd to tauri-plugin-dialog's major to unblock the cargo group (#1406)

The cargo-dependencies group PR (#1405) fails Rust Unit Tests on Linux:

    error: failed to run custom build command for `rfd v0.17.2`
    You need to choose at least one backend: `gtk3` or `xdg-portal`
    features for x86_64-linux

rfd is not really ours. It arrives in the tree via tauri-plugin-dialog,
which pins ^0.16; we declare it directly only for the fatal-startup
message box in lib.rs, where the Tauri app never finished building and
the plugin has no AppHandle to run a dialog through.

Cargo unifies features only within a semver-compatible version group, so
while both wanted ^0.16 there was a single rfd in the graph and the
plugin's backend features covered our `default-features = false`
declaration too. Bumping our direct dep to 0.17 forks rfd into two
crates: the plugin keeps 0.16.0 with its features, ours resolves to
0.17.2 with none, and rfd 0.17 added a build.rs assertion that aborts
the Linux build when no backend feature is set. Confirmed in the PR's
lockfile, which carries both 0.16.0 and 0.17.2.

Adding a Linux backend feature would be the wrong fix: it would paper
over the fork and still build rfd twice on every platform for one error
dialog. Our version has to track the plugin's instead, so ignore
semver-minor rfd updates (0.16 -> 0.17 for a 0.x crate) until
tauri-plugin-dialog moves. Patch updates inside 0.16.x still flow.

The remaining five crates in the group are unaffected; `windows` in fact
consolidates 3 versions down to 2.

Cargo.toml is comment-only here - no dependency, feature, or lockfile
change - so the build is untouched.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>

* chore(deps): bump log (#1407)

Bumps the cargo-dependencies group with 1 update in the /Client/tauri-client/src-tauri directory: [log](https://github.com/rust-lang/log).


Updates `log` from 0.4.33 to 0.4.34
- [Release notes](https://github.com/rust-lang/log/releases)
- [Changelog](https://github.com/rust-lang/log/blob/master/CHANGELOG.md)
- [Commits](https://github.com/rust-lang/log/compare/0.4.33...0.4.34)

---
updated-dependencies:
- dependency-name: log
  dependency-version: 0.4.34
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: cargo-dependencies
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* ci(deps): bump anthropics/claude-code-action (#1408)

Bumps the actions-dependencies group with 1 update: [anthropics/claude-code-action](https://github.com/anthropics/claude-code-action).


Updates `anthropics/claude-code-action` from 1.0.199 to 1.0.200
- [Release notes](https://github.com/anthropics/claude-code-action/releases)
- [Commits](https://github.com/anthropics/claude-code-action/compare/dcb57747bfceeaa1fa72638cae52295d1d853d4a...24dcd50c0568f0fc9e9211213a4fd2d9eb15c4e0)

---
updated-dependencies:
- dependency-name: anthropics/claude-code-action
  dependency-version: 1.0.200
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: actions-dependencies
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* release: v1.2.0-alpha.4 — 62 fixes plus the B0/B1 repository foundation (#1426)

* Fix 27 findings from 2026-08-21 bug hunt (#1400)

* chore(findings): record 2026-08-21 bug hunt (38 findings)

* fix(api): 1 defect(s) (OC-0240)

* fix(client): 1 defect(s) (OC-0241)

* fix(plugin): 2 defect(s) (OC-0243, OC-0265)

* fix(client): 3 defect(s) (OC-0244, OC-0256, OC-0259)

* fix(client): 1 defect(s) (OC-0247)

* fix(client): 2 defect(s) (OC-0248, OC-0258)

* fix(identity): 1 defect(s) (OC-0250)

* fix(ws): 3 defect(s) (OC-0252, OC-0269, OC-0272)

* fix(admin): 1 defect(s) (OC-0253)

* fix(client): 1 defect(s) (OC-0254)

* fix(voice): 1 defect(s) (OC-0255)

* fix(ws): 1 defect(s) (OC-0260)

* fix(client): 1 defect(s) (OC-0261)

* fix(client): 1 defect(s) (OC-0262)

* fix(client): 1 defect(s) (OC-0263)

* fix(client): 1 defect(s) (OC-0264)

* fix(client): 1 defect(s) (OC-0268)

* fix(ws): 1 defect(s) (OC-0273)

* fix(service): 1 defect(s) (OC-0275)

* style: satisfy golangci-lint and prettier on 2026-08-21 fix commits

- drop ineffectual backupDir reset before return (registry.go, OC-0265)
- reflow long boolean expression (attachments.ts, OC-0241)

* fix(client): 4 defect(s) (OC-0242, OC-0246, OC-0249, OC-0251)

* fix(voice): 1 defect(s) (OC-0267)

* fix(admin): 1 defect(s) (OC-0274)

* fix(voice): 1 defect(s) (OC-0245)

* fix(ws): 1 defect(s) (OC-0271)

* fix(voice): 2 defect(s) (OC-0239, OC-0257)

* fix(ws): 1 defect(s) (OC-0266)

* fix(voice): 1 defect(s) (OC-0270)

* style: clear golangci-lint modernize and prettier nits from 2026-08-21 fixes

- range-over-int and slices.Contains modernizations in new Go test files
- prettier reflow in dispatcher.ts

* chore(findings): mark 2026-08-21 hunt findings fixed/declined

37 fixed across the fix waves, OC-0238 declined (LiveKit webhook TLS
requires a product decision, not a mechanical patch).

---------

Co-authored-by: Claude <noreply@anthropic.com>

* fix: 35 findings from the 2026-08-22 bug hunt (#1402)

* fix(voice): 1 defect(s) (OC-0277)

* fix(voice): 1 defect(s) (OC-0278)

* fix(client): 1 defect(s) (OC-0280)

refreshDmSidebar() rebuilds the entire DM sidebar subtree on every
dmStore.channels change - which includes presence flips and new
messages, not just DM list changes. The "Find a conversation" filter
text and input focus live only in that destroyed subtree, so they were
silently wiped mid-typing. Capture and restore both across the
destroy+recreate cycle.

* fix(ws): 1 defect(s) (OC-0285)

* fix(client): 1 defect(s) (OC-0286)

* fix(client): 1 defect(s) (OC-0288)

Consume the legacy unscoped mute key after migrating it onto the first
host, so a brand-new host with no scoped key of its own no longer reads
through to the same legacy list and inherits another server's mutes.

* fix(voice): 1 defect(s) (OC-0290)

* fix(db): 1 defect(s) (OC-0293)

DecrementMentionCounts reversed mention_count bumps that were never
applied: message_mentions stores every resolved mention id including the
author's blockers, while applyMentionCounts excludes blockers before
incrementing. Deleting a blocked author's message therefore wiped an
unrelated, genuine mention badge on the same read_states row. Mirror the
block exclusion in the decrement UPDATE.

* fix(db): 1 defect(s) (OC-0294)

DeleteAccount soft-deletes the departing user's messages but never reversed the read_states.mention_count bumps those messages made, leaving phantom mention badges. Reverse them inline in the existing transaction, mirroring DecrementMentionCounts' guards.

* fix(client): 1 defect(s) (OC-0295)

MemberList rebuilt every row on any non-presence-only membersStore change
and on every roles_update, but registered each row's click/contextmenu
listeners on the component-lifetime disposable.signal, which only aborts
at destroy(). Discarded rows therefore stayed reachable (and their
listeners live) for the component's whole lifetime. Route per-row
listeners through a per-render AbortController that is aborted and
replaced at the top of every render, and aborted again in destroy().

* fix(identity): 1 defect(s) (OC-0297)

UpdateProfile's post-commit re-read of the user row could fail for reasons
unrelated to context cancellation (SQLITE_BUSY, I/O error, pool exhaustion)
and was reported as ErrInternal even though UpdateUserProfile had already
committed. Callers that treat any UpdateProfile error as proof the write
never landed — handleUploadAvatar deletes the file it just stored — would
delete a file the committed avatar column now points at, permanently
breaking the avatar with no user_update broadcast.

Since UpdateUserProfile only writes username/avatar/display_name/about,
merge those four onto the pre-write snapshot to reconstruct the committed
row without needing the re-read to succeed, and log the read failure.

* fix(ws): 2 defect(s) (OC-0298, OC-0299)

- OC-0298: applyConnectStatus stamped c.user.Status even when the
  UpdateUserStatus write failed, so auth_ok and the presence broadcast
  claimed a status users.status disagreed with, and buildReady's
  ListMembers read never self-corrected for the session.
- OC-0299: refreshUserSnapshot silently fell back to roleName "member"
  when the new role lookup failed, pinning the session to a fabricated
  role on the wire. It now fails closed like the sibling lookups in
  upgradeAndAuth and handleFreshConnect.

* fix(client): 1 defect(s) (OC-0300)

* fix(client): 1 defect(s) (OC-0301)

* fix(ws): 1 defect(s) (OC-0302)

* fix(api): 1 defect(s) (OC-0305)

handleDiagnosticsConnectivity used clientIP(r), ignoring cfg.Server.TrustedProxies, so behind a configured trusted reverse proxy the endpoint reported the proxy hop instead of the real client address. Use clientIPWithProxies with the parsed trusted-proxy nets, matching RateLimitMiddleware on the same route.

* fix(client): 2 defect(s) (OC-0306, OC-0308)

* fix(client): 1 defect(s) (OC-0307)

QuickSwitcher registered a per-row click listener against the
overlay-lifetime AbortSignal, but renderResults() rebuilds every row on
each keystroke, arrow key, and store refresh. Discarded rows kept their
listeners alive until the overlay closed. Replaced with one delegated
click listener on the stable results container, keyed off the
data-channelid each row already carries.

* fix(client): 1 defect(s) (OC-0310)

* fix(server): 3 defect(s) (OC-0279, OC-0291, OC-0292)

Reap a soft-deleted message's attachment files, count lapsed temporary
bans as active users in the require_2fa enrollment gate, and only apply
the 2FA-enrollment precondition when require_2fa itself is being enabled.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SdkJRbjCtrG76jEnrhKbYo

* test(api): sync apiTestSchema with the user_blocks migration

DeleteAccount's mention-count reversal joins user_blocks; the api
package's hand-rolled schema fixture predates migration 012.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SdkJRbjCtrG76jEnrhKbYo

* fix(client): 3 defect(s) (OC-0281, OC-0282, OC-0296)

Decouple the E2EE identity-mismatch modal and right-click popovers from
the sidebar's per-render abort signal, and let global drag listeners
survive a mid-drag re-render.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SdkJRbjCtrG76jEnrhKbYo

* fix(voice): 2 defect(s) (OC-0283, OC-0287)

Retire a departed peer's E2EE key unconditionally on leave, and surface
a failed microphone unmute instead of reporting an unmuted state the
room never saw.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SdkJRbjCtrG76jEnrhKbYo

* fix(client): 3 defect(s) (OC-0289, OC-0303, OC-0309)

Guard the DM call button against redialing the channel already joined,
resolve the incoming-call banner's caller through the nickname-aware
display name, and keep the DM profile sidebar subscribed to live
member/status updates.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SdkJRbjCtrG76jEnrhKbYo

* style(client): prettier-format the dm-store test

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SdkJRbjCtrG76jEnrhKbYo

* fix(server): 1 defect(s) (OC-0284)

Make message soft-delete a compare-and-set so a repeated chat_delete
cannot reverse mention counts twice.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SdkJRbjCtrG76jEnrhKbYo

* fix(server): 2 defect(s) (OC-0276, OC-0304)

Re-sync a resumed connection's voice E2EE peer keys in registerNow
(announce frames are unsequenced and cannot be replayed), and apply the
live-connection presence rule to every DM payload DMService builds.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SdkJRbjCtrG76jEnrhKbYo

* chore(ledger): record the 2026-08-21 hunt findings as fixed

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SdkJRbjCtrG76jEnrhKbYo

* chore(ledger): independent revert-proof pass for OC-0276..OC-0310

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SdkJRbjCtrG76jEnrhKbYo

* refactor(service): extract DeleteMessage authorization into a helper

Keeps DeleteMessage under the cyclop complexity ceiling after the
OC-0284 guard.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SdkJRbjCtrG76jEnrhKbYo

---------

Co-authored-by: Claude <noreply@anthropic.com>

* chore(ledger): record the 38 open findings from the 2026-08-22 hunt (#1403)

Claude-Session: https://claude.ai/code/session_01SdkJRbjCtrG76jEnrhKbYo

Co-authored-by: Claude <noreply@anthropic.com>

* chore(graphify): refresh knowledge graph

* fix: close the three B0 P0 gates and record a measured baseline (#1409)

* chore(security): stop tracking the private security-finding reports

docs/security-findings/ holds detailed reports for defects that are not yet
fixed. The directory was untracked but not ignored, so any 'git add .' would
have published seven unfixed vulnerability traces to a public repository.

Findings are coordinated through private GitHub Security Advisories
(docs/security.md); only opaque identifiers and safe status belong in tracked
plans.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(client): repair the two red P0 unit contracts (G-01, G-02)

G-02: noise-suppression-restart stubbed MediaStream with
vi.fn().mockImplementation(arrow), which is not constructible. Vitest 4 threw
'is not a constructor' at the new MediaStream([inputTrack]) call in
noise-suppression.ts before reaching any assertion. Replaced with a real
class; the OC-0277 assertions are unchanged.

G-01: message-list's OC-0217 guard was inverted, not merely stale. It spied on
AbortSignal.prototype.addEventListener and asserted zero abort registrations,
but the leak it names registered row listeners via
element.addEventListener(..., { signal }) — a path that never calls that
prototype method. Measured: the leak produces 0 registrations (test passes),
while the OC-0286 fix rotates a per-window AbortSignal.any and produces 5
across 5 distinct signals (test fails). The guard passed on the bug and failed
on the fix.

It now captures the signal each window's row listeners register against and
asserts the invariant its name always claimed: one signal per rendered window,
a fresh signal per jump, and every superseded window already aborted with
exactly one live. Verified both directions — green on the fix, and
'expected 1 to be 5' with beginRowRender() reverted to rowSignal = ac.signal.

Client suite: 5257 passed, 0 failed (was 5255 passed, 2 failed).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(client): make the Playwright suite terminate

The runner finished every test and then never exited, printing no summary — so
the failure read as 'tests never finish' when it was 'process never exits'.
getActiveResourcesInfo() at hang time showed a live ProcessWrap plus several
PipeWrap: the Vite dev server was still running. Playwright's webServer
teardown does not kill it here.

Measured, full suite each time:

  npm run dev                        hangs, tests pass
  node node_modules/vite/bin/vite.js hangs, tests pass
  reuseExistingServer: false         hangs, tests pass
  gracefulShutdown SIGTERM/3s        hangs, tests pass
  npx vite                           exits, 290 of 293 FAIL
  no webServer (pre-started)         exits, 293 pass in 33s

npx only appears to fix it: npx exits once Vite is up, Playwright reads that as
the server dying and tears the group down mid-run, so later tests get
ERR_CONNECTION_REFUSED.

globalTeardown now kills the process listening on the dev port, releasing the
runner's handle. The webServer command spawns Vite's entry point directly so
the listening process is Playwright's own child — via 'npm run dev' the npm
process would still hold the handle open. It also reaps servers orphaned by an
interrupted run, which reuseExistingServer would otherwise silently adopt.

An earlier revision used netstat, which is not on PATH in every shell here; the
swallowed ENOENT made the fix look applied while the hang persisted. It now
uses PowerShell on Windows and lsof elsewhere, and warns on failure rather than
failing silently.

npm run test:e2e: exit 0, 293 passed, 37s, reproducible, no orphan listener.
playwright.config.prod.ts carried the same npm-wrapper shape.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* chore(client): align .nvmrc with the Node version CI uses

Three versions were in play, not two: .nvmrc said 20, CI pins 24, and the
machine the audit was measured on runs 26. A baseline measured against .nvmrc
is not the baseline CI produces, which defeats the point of B0.

Scoped to .nvmrc only. The full single-source-of-truth work — package engines,
contributor docs, release — stays in B1 (RL-17 / C-01).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs(plans): add the beta audit set and the B0 baseline

The 2026-08-23 audit set has been sitting untracked: repository-health and
repository-layout audits, beta product requirements, requirement traceability,
the issue register, and the B0-B10 roadmap. They are the plan of record for
beta and belong in the repository.

Adds b0-baseline-2026-08-25.md, which supersedes the roadmap's 'current
evidence snapshot'. Every row is marked measured or carried, so nothing is
inherited silently. It also records three audit claims that did not survive
verification:

  - G-01 was an inverted guard, not a stale assertion — it passed on the bug
    and failed on the fix.
  - The Playwright hang matched none of the three hypotheses; the runner could
    not kill its own dev server.
  - The golangci-lint toolchain failure is refuted: 19 linters run, 0 issues,
    verified with -v to rule out the known zero-linters false-green.

Adds b0-dev-branch-protection.sh, which records the applied dev branch
protection and the reasoning behind each setting.

Security detail stays private: the register carries only opaque SEC-* families
and safe closure criteria, per the roadmap's public/private handling policy.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* chore(graphify): refresh the knowledge graph

Own commit, per CLAUDE.md — the graph payload does not belong in the diff of
the changes that triggered it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs(plans): add the active-plan index and fix a stale status header (G-04)

Planning documents had no recorded state, so a reader could not tell current
guidance from shipped history. docs/plans/README.md now indexes every plan as
active, partially implemented, design-only, or shipped, and names the source of
truth for each concern so a defect count is never read out of a plan.

Status is recorded in the index rather than by moving or rewriting the
historical plans, so links from audits and commit messages keep resolving.

One real stale claim found and fixed: audit-2026-08-19-remediation.md still
read 'in progress 2026-08-19' while its own phase table showed phases 1-6 done
2026-08-20 (merged 03fcb7d5, PR #1396) with only phase 7 pending. The header
had drifted because the table was updated in place and the header was not.
No plan was found claiming '0 open findings'.

Also records the Step 8 staleness pass in the B0 baseline: all 38 open OC
records still resolve to a live file:line at this commit, so none is superseded
by later work. Adjudicating them individually is bughunt-fix work, not B0.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Update graph output files and manifest with new metadata

- Updated graph.html and graph.json with new binary data.
- Modified manifest.json to reflect changes in file modification times and AST hashes for several documents.
- Added new entry for README.md in the manifest with its corresponding metadata.

* docs(plans): close the Docker and coverage leftovers in the B0 baseline

Docker smoke: measured and passing. Image builds at 50.1 MB and boots on :8443
with TLS; docker-smoke.sh exits 0.

Server coverage: re-measured at 74.6% aggregate, confirming the figure carried
from the audit rather than continuing to inherit it.

Two findings from doing it:

ENV-03 — docker-smoke.sh cannot be run from Git Bash on Windows. MSYS path
conversion rewrites the container-internal /chatserver into
'C:/Program Files/Git/chatserver', so docker exec fails 127 and the script
reports 'container never reported healthy within 30s' — indistinguishable from
a real boot regression. MSYS_NO_PATHCONV=1 makes the same script pass. CI is
Linux and unaffected, but Windows is an official contributor platform (RL-20).

The CI Docker job is gated on main, so it is skipped for any PR targeting dev
— a dev-targeted change cannot get Docker evidence from CI at all.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* chore(graphify): refresh the knowledge graph

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs(plans): B1 execution plan, and accept HP-0 (#1410)

* docs(plans): add the B1 repository-foundation execution plan

B1 is the isolated layout/contributor phase. This records the execution
order, the proof for each step, and what is out of scope.

Two findings worth surfacing before any B1 work starts:

- HP-0 was never formally accepted. The roadmap's B1 entry gate requires
  it; no scorecard artifact exists, no commit or document records an
  acceptance, and the B0 baseline still lists "Step 10: HP-0 sign-off"
  under "Not yet done in B0". The plan lists the five gaps that closing
  it requires, including pinning required status checks on dev -- which
  are still unset, so a dev PR can currently merge red.

- Several layout-audit claims do not survive verification against HEAD,
  matching the B0 pattern. RL-09's "no single command verifies both
  protocol consumers" is false (make protocol-verify does, and is
  enforced in CI, the pre-commit hook, and a contract test). RL-10's
  test-discovery side effect never fires (no _test.go in Server/scripts).
  RL-06's regeneration concern is refuted locally. RL-08 grows a
  toolchain constraint instead. RL-05, RL-07, RL-20 and RL-21 are each
  worse than written -- RL-20 includes a live bug where a missing `make`
  is reported as stale protocol constants.

The riskiest item, RL-01 (flatten Client/tauri-client into Client), gets
a full reference inventory and a mechanical proof for both commits: tree-
object equality for the pure move, and scripted-substitution replay for
the path rewrite. Release asset names and updater contracts are verified
independent of the directory name, so the move cannot rename an artifact.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs(plans): correct the B1 status-check pin list from a live dev PR

The list was derived from ci.yml. Observing PR #1410's actual checks
found three that exist in no workflow file -- Analyze (go),
Analyze (javascript-typescript), Analyze (actions) -- because CodeQL
runs from GitHub default setup, configured in repository settings.
Reading .github/ alone misses them.

Also confirms the two negative predictions against a real dev-targeted
PR: Server Docker Build (verify) reports as "skipping", and Tauri Full
Build never appears in the check list at all. Neither may be pinned.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs(plans): accept HP-0 and pin the dev required status checks

Closes B1's entry gate. All five B1-0 items are done.

The scorecard is the artifact the hold point asks for: one place that
answers its four questions, records what was accepted as a stated
limitation rather than claimed green, and part-closes R-08.

Required status checks are now pinned on dev -- ten of them. That was
B0's one outstanding step. Two things came out of doing it:

- The names cannot be inferred from ci.yml. Three of the ten (the
  Analyze jobs) exist in no workflow file, because CodeQL runs from
  GitHub default setup configured in repository settings. They were read
  off a live dev-targeted PR with `gh pr checks`.
- Server Docker Build, Tauri Full Build and the CodeQL aggregate are
  deliberately excluded. The first two report "skipping" on a dev PR --
  Tauri Full Build under its unexpanded matrix name, since the job is
  skipped before matrix expansion. Admin Panel E2E is excluded because
  continue-on-error makes it report success unconditionally.

Two prior claims are corrected rather than left to propagate:

- b0-dev-branch-protection.sh was written assuming repository-settings
  writes are blocked from the agent sandbox. They are not; the PUT
  succeeded. The script stays as the record of intent and the way to
  re-apply or undo.
- An earlier revision of the B1 plan said Tauri Full Build does not
  appear in a dev PR's check list at all. It does, as skipping.

Evidence closed out:

- Rust is no longer a carried row. Re-measured: 115 passed, cargo clippy
  --all-targets -- -D warnings at exit 0, confirming the carried figure.
- The 38 open ledger records are accepted as counted, non-stale and
  assigned: 11 medium / 27 low, zero high or critical, zero dead paths
  across all 348 re-verified at this commit, and none assigned to B1.
- The private security review is reconciled: 7 findings, 7 of 7 mapped
  to existing public rows, 0 unmapped. Summary is content-free; the
  detail stays in the untracked private reports.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>

* refactor: flatten Client/tauri-client into Client (B1-1) (#1411)

* refactor: move Client/tauri-client to Client (pure move, no content change)

* refactor: re-point paths after the Client flatten (mechanical, no behaviour change)

---------

Co-authored-by: Claude <noreply@anthropic.com>

* B1-2: truth, entry points, and contributor path (#1412)

* fix(hooks): guard on the command the hook actually runs

pre-commit probed one binary and invoked another. The protocol block
guarded on `command -v go` and then ran `make protocol-verify`; the sqlc
block guarded on `command -v sqlc` and ran `make sqlc-verify`. `make` is
not on PATH on a stock Windows box, so a contributor with Go installed
but no make had their commit rejected with

    pre-commit: FAIL: protocol constants are stale — run 'make
    protocol-generate' in Server/ and stage the result

when nothing had been generated and nothing compared. The real cause was
`make: not found`, and the advice the message gives fails the same way.

Rather than add a `command -v make` guard, inline what the two Makefile
targets reduce to — `sqlc generate` / `go run ./scripts/genprotocol`
followed by `git diff --exit-code`. Same semantics, one less prerequisite,
and it doubles as the make-free equivalent B1-2 asks for. The Makefile
targets stay for anyone who prefers them.

Also: the protocol block was the only one with no `else`, so a
contributor without Go got no check and no notice. It now warns like its
two siblings. And gofmt is a separate binary from go, so it is probed
separately.

Verified both directions with the hook body replayed verbatim:
- Go present, make absent -> passes, no staleness claimed.
- schema edited without regenerating -> fails, as it must.

Refs RL-20 / L-14.

* docs: state one branch and PR model

Active documents contradicted each other head-on. README.md and
docs/contributing.md said branch from `dev` and target `dev`; CLAUDE.md
said branch from `main`, PR to `main`. That is R-02, and the 2026-08-19
audit had already recorded it as D-06 without it being resolved.

`dev` is the answer, and the repository already behaves that way: B0 made
`dev` PR-only with ten required checks enforced on admins, and #1409,
#1410 and #1411 all landed there. `main` carries releases.

docs/contributing.md becomes the single source of truth. It now states the
model, what protection is actually applied, and the two consequences a
contributor meets on their first PR — that a self-mergeable PR still cannot
merge red, and that Docker and Tauri Full Build report as skipped against
`dev` rather than failing. Everywhere else summarises and links here.

- CLAUDE.md: corrected, with a link rather than a second copy.
- CONTRIBUTING.md: new. GitHub's contributing-guidelines affordance only
  resolves the root, .github/ or docs/ — `docs/contributing.md` is not a
  path it finds, so the link never appeared on issues or PRs.
- PULL_REQUEST_TEMPLATE.md: names the base branch, which it did not.
- bughunt-run skill: reviewed the branch against `origin/main`, which is
  the wrong base once every PR targets `dev`.

README.md already said `dev` and is left as the short summary it should be.
Dated audits and the historical remediation plan keep their `main`-era
wording — they are records.

Refs R-02.

* fix(hooks): pick the pre-push base from the nearest integration branch

pre-push decided which side's gates to run from
`git diff --name-only origin/main...HEAD`. That was right when everything
targeted `main`. Once `dev` became the integration branch it stopped being
right: a branch cut from `dev` diffed against `main` counts everything on
`dev` and not yet on `main` as "changed".

Measured on this branch: the old base reported 609 changed files, the new
one reports 6. So in practice the hook was running the full server build
matrix and the client typecheck plus eslint on every push, whatever the
change touched — the file-based narrowing it exists for never engaged.

Now it picks whichever of origin/dev, origin/main is nearest, by commits
between merge-base and HEAD, skipping a candidate that scores 0. Verified:
a branch cut from dev picks origin/dev (2 ahead); dev itself scores 0
against dev and picks origin/main (8 ahead), which is what a dev -> main
release PR wants. With no candidate resolvable it falls back to the
existing `__all__`, so an unfetched or shallow clone still runs everything.

Refs RL-20 / L-14, R-02.

* chore(node): one Node source of truth

`.nvmrc` and all ten `actions/setup-node` pins said 24; five active
documents and the repo's only `engines` block still said 20. A contributor
following the docs installed a version CI does not run.

Node 24 wins — it is what CI already runs. Every manifest now declares
`engines`, and `engine-strict=true` turns a wrong major into a failed
install rather than an `EBADENGINE` warning nobody reads. `>=24` rather
than `^24` so a Node 26 box keeps working; `Client/.nvmrc` stays the
human-facing pin and the docs point at it instead of restating a number.

The `.npmrc` is per package root, not one at the top. npm reads the
project `.npmrc` from the package directory and does not walk parents —
verified with a throwaway package requiring node >=99: with only a parent
`.npmrc` npm warned and exited 0; with one in the package directory it
failed `notsup`. A single root file would have left `Client/`, the package
that matters most, on warnings.

Five docs, not the four previously identified — `docs/mcp-introspect.md`
also said 20. And `docs/contributing.md` claimed "`.nvmrc` + CI both say
Node 20", which was wrong about both.

Verified both directions in all three package roots: Node 22 fails
`notsup`; Node 24 installs clean and `npm ci` passes in Client/.

Refs RL-17 / C-01, ENV-01.

* docs: add the documentation landing page

`docs/` had 24 top-level files and no index. The root README carried a
flat list of 22 links that had drifted: six documents were reachable from
nowhere at all — including both 2026-08-23 audits and the test audit — and
two entries were labelled "latest" while newer unlinked audits existed.

docs/README.md is the index RL-12 asked for. It groups by what a document
*is*, because that is what decides whether to trust it: guidance tells you
how to do something, reference describes a contract the code implements,
audits are dated snapshots nobody updates, plans record intent. Every
tracked file under docs/ now appears exactly once, and the audit table says
plainly that audit-2026-08-19.md still claims "0 open findings" when the
ledger has 38.

The root README keeps a short curated list and defers to the index, rather
than maintaining a second copy that drifts again. Two fixes while there:
`docs/plans/` was linked as a bare directory, unlike its two sibling
directory entries, and was annotated "each carries a verified status
header" — which docs/plans/README.md:7-9 explicitly contradicts, since a
plan's header is exactly the thing that drifts and the index is the
authority.

Verified: 78 relative links across the new and edited files resolve, and
no tracked docs/ file is unreachable from the index.

Refs RL-12 / R-06.

* feat(scripts): root command facade

Entry points existed only inside Server/ (a Makefile) and Client/ (npm
scripts). Nothing at the root told a new contributor where to start, and
the root package.json had three scripts, none of which built or tested
anything.

`npm run check` from the root now runs what CI gates on, and
check:server / check:client / check:rust run one stack. scripts/run.mjs
is dependency-free Node — the shape render-ledger.mjs already uses — so
`npm run check` works before `npm install` has.

Cross-platform by construction: every step is spawned with an explicit cwd
and no shell, so there is nothing to quote and no `cd &&` to behave
differently on Windows. npm and npx get their .cmd suffix there. No step
shells out to make.

The facade orchestrates; it is not a new required path. Each step prints
the command and the directory before running it, and those are exactly the
commands documented per-stack — so a server contributor can read the output
and type them instead, and still never needs Node. Tools CI installs but a
contributor may not have (golangci-lint, which has no wrapper in this repo
at all; sqlc, pinned by Server/sqlc.version) are skipped with a printed
reason rather than failing.

Three corrections to the ci-check skill while aligning it:

- `make sqlc-verify protocol-verify` replaced by what those targets reduce
  to, so the documented path does not require make either.
- `cargo test` -> `cargo test --lib`, which is what ci.yml actually runs.
- "NODE_OPTIONS=--no-experimental-webstorage is mandatory on Node 22+" was
  false. tests/setup.ts installs the shim, CI runs Node 24 without the
  flag, and the suite was measured passing without it — 192 files / 5257
  tests, identical to the flagged run.

Also documents the third RL-20 problem, which needed no code: core.hooksPath
is exclusive, so `npm run hooks:install` silently disables any
.git/hooks/post-commit — including the one `graphify hook install` writes,
which CLAUDE.md tells agents to install. Nothing warned about that.

Verified: check:client 5257/192 green, check:rust 123 tests + clippy green,
--list prints every command, and the optional-tool skip path reports rather
than fails.

Refs RL-04 / L-04, RL-20 / L-14.

* feat(ci): fail on a document that contradicts the findings ledger

G-04's remaining half. The ledger is the source of truth for defect counts,
but nothing stopped a planning document from stating a different number and
nothing noticed when one did. `render-ledger.mjs --check` cannot help: it
validates the JSON schema and returns before rendering, so it never reads
FINDINGS.md and cannot see drift at all — and no workflow ran it anyway.

scripts/check-doc-counts.mjs counts ledger statuses and compares them to
what an allow-list of active documents claims, failing with file, line,
claimed value and actual. Wired into ci.yml as a job with no npm ci, since
the script imports nothing outside node:, and into the facade as
`npm run check:docs` — first in `check`, so a contradicted count does not
wait behind ten minutes of -race.

The patterns are narrow on purpose. A first attempt matched any
"<number> <status>" and flagged nineteen things, all false: "the 45 open P1
rows" (issue-register rows, not ledger findings), "All 8 findings F1-F8"
(a different register), "G-05 **refuted**" (an identifier), `">=20"` and
`CGO_ENABLED=0` (not counts at all). A check that cries wolf gets ignored,
which is the failure G-04 already describes. So a number is only read as a
claim in three shapes that cannot mean anything else: an enumeration of two
or more "<n> <status>" pairs, a status table row in a table that totals
itself, and "<n> records/findings" where the ledger is named within three
lines. Fifteen selftest assertions pin both directions, and the job runs
them before it runs the check.

It reads findings-ledger.json directly rather than importing
render-ledger.mjs for `validate`/`render`: that module ends in a bare
top-level `await main()` with no import.meta.main guard, so importing it
rewrites FINDINGS.md as a side effect.

Dated docs/audit-*.md are reported, never failed — they are snapshots
nobody maintains. audit-2026-08-19.md does claim zero open findings against
38 open, so b0-baseline's "No plan was found claiming '0 open findings'"
holds for docs/plans/ but not for docs/.

Not included: a real FINDINGS.md render-drift check. That is RL-07 and
belongs with the generated-artifact work, not here.

Verified: 27 claims across 9 documents agree; corrupting one count in
docs/plans/README.md fails the check naming that line, for both the status
and the total.

Refs G-04.

---------

Co-authored-by: Claude <noreply@anthropic.com>

* chore: remove graphify knowledge graph tooling (#1413)

The committed knowledge graph and its PreToolUse hooks were steering every
codebase question through `graphify query` before any other tool could run.
Serena (gopls + rust-analyzer + tsserver over MCP) answers the same questions
from real language servers rather than a generated snapshot that goes stale
between rebuilds, so the graph no longer earns the ~20 MB it costs the tree.

Removed:
- `graphify-out/` untracked (7 files, ~20 MB) and now gitignored
- both `graphify hook-guard` PreToolUse hooks from `.claude/settings.json`
- the "Knowledge graph (graphify)" section of `CLAUDE.md`
- the `graphify-out/**` block from `.gitattributes` and `.gitignore`
- the graph-rebuild step from the `bughunt-run` skill, and the graph-edge
  guidance from the bughunt workflow prompt
- the graphify-specific `core.hooksPath` example in `ci-check` and
  `docs/contributing.md`, keeping the underlying warning in generic form

Also deletes the locally installed `post-commit` / `post-checkout` rebuild
hooks (untracked, not part of this diff).

This does not shrink clone size: the graph blobs stay in published history,
which `docs/plans/b1-repository-foundation-2026-08-25.md` explicitly rules out
rewriting. It does stop future refreshes from adding more.

Dated audit and plan documents keep their graphify references as a historical
record of the state they described.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>

* B1-3: repository hygiene gates (RL-19 / L-13, S-05) (#1414)

* chore(format): one Prettier config at the repository root

Every formatting rule in this repository lived under Client/ and covered
exactly two globs: Client/src/**/*.ts and Client/tests/**/*.ts. Root Markdown,
all of docs/, every YAML and JSON, all CSS, the root scripts and
tools/mcp-introspect were formatted by nothing. There was no .editorconfig.

The obvious fix -- a second Prettier config at the root for "everything else"
-- gives two configs and two ignore files that can silently disagree about the
same file. So the root takes ownership instead: config, ignore file and gate
move up, and Client/ folds in. Client's inline "prettier" block, its
.prettierignore, its format/format:check scripts and its now-unused prettier
devDependency are all deleted; knip would have failed client-check on that last
one.

The .prettierrc.json values are lifted byte-for-byte from Client/package.json,
which is what keeps the reformat commit free of client TypeScript churn: 87
tracked files need reformatting and not one of them is under Client/src or
Client/tests.

.prettierignore carries only what .gitignore does not. Prettier 3 reads the
root .gitignore by default, so node_modules/, dist/, coverage/,
Client/src/generated/ and docs/security-findings/ need no entry. It does NOT
read nested .gitignore files, which is why .remember/ is listed explicitly --
38 untracked per-machine scratch files were otherwise able to turn a shared
gate red. graphify-out/ is listed because its seven files are tracked and
.graphify_labels.json is signed byte-for-byte by its .sig, so formatting it
would silently invalidate the signature.

check:hygiene is registered in scripts/run.mjs and folded into check and
release:preflight. It deliberately contains no `gofmt -l` step: gofmt -l prints
offenders and still exits 0, so it cannot fail a build. Go formatting is
enforced separately.

shellcheck and actionlint take their file lists from `git ls-files`, never a
filesystem glob -- .claude/worktrees/ holds a gitignored pre-flatten copy of
the tree with three .sh files a glob would happily lint.

This commit leaves the tree non-conformant on purpose. The reformat is the next
commit, so the 87-file diff is reviewable separately from the rule that caused
it.

Not included: editorconfig-checker. .editorconfig is the editor baseline the
audit asked for; Prettier, gofmt and rustfmt already fail CI on the same
indentation and newline rules, so a fourth tool checking them again is a gate
with no failure mode of its own.

Verified: `npx prettier --check .` names 87 tracked files and zero untracked
ones; the same command listed 38 .remember/ scratch files before the ignore
entry and none after. `node scripts/run.mjs --list` resolves check:hygiene to 8
shell targets and 4 workflow targets. Both package.json files parse.

Refs RL-19 / L-13, S-05.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* chore(format): reformat the tree to the repository Prettier rules

Mechanical. This commit is `npx prettier --write .` and nothing else -- the
rule that caused it landed in the previous commit so this diff can be reviewed
as a transformation rather than as 84 files of hunks.

84 tracked files: 54 Markdown, 7 .mjs, 7 JSON, 6 YAML, 4 .js, 3 CSS, 2
TypeScript (the two Playwright configs at Client's root, which the old
Client/src + Client/tests globs never covered). No file under Client/src or
Client/tests moves, because .prettierrc.json carries Client's former inline
values byte-for-byte.

Prettier rewrote 87 files, not 84. The three in .github/ISSUE_TEMPLATE/ had
CRLF on disk and differ only in line endings, which .gitattributes
(`* text=auto eol=lf`) already normalises, so their committed blobs are
unchanged. Worth knowing before someone reconciles the two numbers.

The largest single diff is .superpowers/findings-ledger.json at 7976 lines
rewritten. That is safe to format: nothing writes the ledger programmatically
-- render-ledger.mjs reads it and writes only FINDINGS.md -- so no tool will
fight Prettier over its style on the next hunt. FINDINGS.md itself is ignored
as generated.

Verified: `npx prettier --check .` reports "All matched files use Prettier code
style", so the pass is both complete and idempotent. All 7 reformatted JSON
files were parsed before and after and compared as values: semantically
identical, zero content changes. `node .superpowers/render-ledger.mjs --check`
still reports 348 valid findings and leaves FINDINGS.md untouched.
`node scripts/check-doc-counts.mjs` still passes its selftest and still agrees
on 27 claims across 9 watched documents -- table realignment did not break the
patterns it matches on. `node scripts/run.mjs --list` still parses.

Refs RL-19 / L-13, S-05.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* chore(lint): enforce Go formatting in the Server linter

S-05: repository-wide Go formatting was not a required gate. The only gofmt
enforcement anywhere was .githooks/pre-commit, which is opt-in per clone
(`npm run hooks:install`), only sees staged files, and warns-and-skips when
gofmt is off PATH.

The obvious fix -- a `gofmt -l` step in CI -- does not work: `gofmt -l` prints
its offenders and still exits 0, so the step passes no matter what it finds.
scripts/run.mjs has the same problem, which is why check:hygiene has no Go step
either.

So gofmt goes where it can actually fail something: Server/.golangci.yml. The
file was already `version: "2"` but had no `formatters:` block at all, so the
19 enabled linters ran with zero formatters. In v2 gofmt/gofumpt/goimports
moved out of `linters.enable` into their own section with its own exclusions.
Adding it there means the gate reports through the Lint step of "Server Build &
Test", which is already pinned as required on dev -- no new job and no new pin.
Every tracked .go file is under Server/ (551 of them, one go.mod), so
Server-scoped is repository-wide here.

One file was genuinely misformatted: a one-space struct field alignment in
Server/admin/handlers_users_broadcast_test.go, fixed in the same commit because
a single line does not need its own reformat commit.

Trap worth recording: `gofmt -l .` on a Windows working tree lists every file
that has CRLF on disk, because gofmt normalises line endings. That reported 18
offenders here, 17 of them ghosts. The blobs are all LF -- .gitattributes
forces `eol=lf` -- so CI never saw them, and the honest test is to run gofmt
over `git show HEAD:<file>` rather than the working copy. Doing that across all
551 tracked Go files found exactly the one real offender above.

Verified both directions with golangci-lint v2 locally: `golangci-lint run
./...` reports 0 issues on the formatted tree; appending a misformatted
function to Server/auth/constants.go produces 2 gofmt findings; appending the
same misformatted function to Server/db/dbgen/admin.sql.go produces 0, so the
exclusion holds. Both files restored and verified clean afterwards.

Refs RL-19 / L-13, S-05.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(scripts): escape the NUL separator instead of embedding one

The `tracked()` helper added earlier in this branch splits `git ls-files -z`
output on NUL. The separator was written as a literal NUL byte rather than the
two-character JavaScript escape, so scripts/run.mjs became a binary file: `git
diff` refused to show it, `grep` reported "Binary file matches" instead of the
line, and `* text=auto` in .gitattributes stops normalising line endings for a
blob it detects as binary.

The code worked -- splitting on a raw NUL and splitting on "\0" are the same
operation -- which is exactly why this is worth fixing before it is inherited.
A source file that tooling classifies as binary is a file nobody can review.

Verified: zero NUL bytes remain, `grep -n "split("` now prints line 50 instead
of "Binary file matches", `node scripts/run.mjs --list` still resolves the same
8 shell and 4 workflow targets, and prettier still reports the file clean.

* chore(lint): enforce Rust formatting

Rust had no formatting gate of any kind: no rustfmt.toml, no `cargo fmt`
anywhere in CI, in scripts/run.mjs, in the Makefile or in the git hooks. Clippy
was the only Rust gate, and clippy does not check layout.

`cargo fmt --all -- --check` now runs in the rust-tests job, ahead of clippy: a
formatting failure is cheap to produce and cheap to fix, and there is no reason
to spend a clippy pass to surface one. The stable toolchain in that job
requested `components: clippy` only, so rustfmt is added there.

Only that job. ci.yml has a second, byte-identical `Install Rust` block in
tauri-build; it stays clippy-only, because a full desktop build is the wrong
place to discover a misplaced brace.

No rustfmt.toml. The default profile is the point of a baseline -- a config
file here would be a second opinion about style with nothing to say.
Client/src-tauri is a single `[package]`, not a workspace, so `--all` is a
safeguard against a future member rather than a fan-out today.

Verified: `node scripts/run.mjs --list` resolves check:rust to three steps with
`cargo fmt --all -- --check` first, `npm run format` now also runs `cargo fmt
--all`, and prettier reports ci.yml, run.mjs and the ci-check skill clean.
`cargo fmt --all -- --check` currently fails on 13 files -- that is the
reformat, and it is the next commit.

Refs RL-19 / L-13.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* chore(format): reformat the Rust crate to rustfmt defaults

Mechanical. This commit is `cargo fmt --all` and nothing else; the gate that
demands it landed in the previous commit so this diff is reviewable on its own.

13 of the 17 tracked .rs files, +343/-164. The crate had never been formatted,
so the changes are the usual first-run set: aligned trailing comments collapsed
to single spaces, single-element slice literals folded onto one line, long
method chains broken across lines, closure bodies expanded into blocks.

Verified: `cargo fmt --all -- --check` is clean, so the pass is complete and
idempotent. `cargo clippy --all-targets -- -D warnings` finishes with no
warnings, and `cargo test --lib` reports 115 passed / 0 failed -- identical to
before the reformat, which is what "mechanical" has to mean for a commit that
touches this much of the crate.

Refs RL-19 / L-13.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(scripts): make the root facade actually run on Windows

Adding the first gate that a contributor would run from the repository root
exposed two bugs in the facade, both of which made it silently wrong on the
platform this project is developed on.

1. Every npm and npx step failed. bin() appends `.cmd` on Windows, but Node
   refuses to spawn a .cmd or .bat with shell:false -- the CVE-2024-27980
   mitigation -- and fails with EINVAL and a *null* exit status. run.mjs only
   special-cased ENOENT, so the result was `FAILED: npx prettier --check .
   exited null` with nothing to explain it. check:client has three npm steps and
   has never been able to run here.

   Fixed by spawning only the npm shims through a shell. They are concatenated
   into a single command string rather than passed as an args array, because
   shell:true plus a separate array is deprecated (DEP0190) and prints a warning
   on every invocation; no argument in this file contains a space.

2. Every optional() step was skipped, always. onPath() shelled out to
   `where` on Windows, but where.exe lives in C:\WINDOWS\System32, which a Git
   Bash PATH does not necessarily contain -- on this machine PATH carries
   System32\Wbem, System32\WindowsPowerShell\v1.0 and System32\OpenSSH but not
   System32 itself. The probe could not start, `probe.status === 0` was false,
   and golangci-lint and sqlc reported as "not installed" while installed.

   Fixed by resolving against PATH and PATHEXT directly. No subprocess, and no
   dependency on which directories happen to be on PATH.

A spawn error other than ENOENT now reports its code instead of surfacing as a
null exit status.

Verified: before, `node scripts/run.mjs check:hygiene` died with "exited null"
and both optional steps printed SKIP with the tools present on PATH. After, the
same command runs prettier, shellcheck and actionlint and prints
"check:hygiene: passed", with no deprecation warning. `golangci-lint` is
detected by the new onPath where the old one missed it.

Refs RL-20 / L-14.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* chore(format): ignore build output that nested gitignores hide

Prettier honours the root .gitignore and no other. Every build and scratch
directory in this repository is ignored by a *nested* one -- Client/.gitignore,
.serena/.gitignore, .superpowers/sdd/.gitignore -- so none of them were
excluded from the new repository-wide gate.

The effect is not subtle. Running `cargo test` once drops roughly 850
formattable files into Client/src-tauri/target/, and the hygiene gate goes from
clean to "Code style issues found in 939 files". CI never sees it, because a
fresh checkout has no build output; every contributor sees it on their second
command.

Mirrors the three nested files rather than inventing a list: dist, coverage,
playwright-report, test-results, .vite, src-tauri/target and src-tauri/gen from
Client/.gitignore, plus .serena/ and .superpowers/sdd/. node_modules needs no
entry -- Prettier ignores it by default.

Verified: `npx prettier --check .` reports "All matched files use Prettier code
style" with a fully populated Client/src-tauri/target/ present on disk, and
still names README.md when a misformatted table is appended to it.

Refs RL-19 / L-13.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* chore(ci): shellcheck, actionlint, and a repository hygiene job

The last two gates RL-19 asks for. Neither existed: the shell scripts were
never linted, the workflows were never syntax-checked, and .githooks/pre-commit
carried hand-written `# shellcheck disable=` directives that nothing had ever
read.

New `hygiene` job, ubuntu-only and root-scoped, modelled on docs-consistency
for the same reason: every gate in it is platform-independent text analysis,
and .gitattributes pins eol=lf so a second OS would only re-prove line endings.
It runs `npm run check:hygiene` -- the same entry point a contributor runs, not
a parallel copy of the commands.

shellcheck ships in the runner image. actionlint does not, so it is pinned by
version and verified by sha256: an installer script piped from a branch would
be the one unverified download in a workflow file that pins every action by
commit SHA.

Prettier's step moves here from client-check, where it no longer belongs.

Both linters found real defects.

shellcheck, 3 findings in 8 scripts. Two are SC1125 errors in
.githooks/pre-commit: `# shellcheck disable=SC2086 - repo paths contain no
spaces` is not a valid directive. Trailing prose makes shellcheck discard the
rest of the line, so neither suppression was ever in effect -- and one of the
two was written earlier in this same branch, which is a fair demonstration of
why the gate is worth having. The prose moves to its own line above. The third
is SC2015 in start-server.sh, rewritten as an explicit if.

actionlint, 5 findings, all inside `run:` blocks it shellchecks once shellcheck
is on PATH. Three SC2015 in load-baseline.yml, rewritten as explicit ifs. Two
SC2035 in release.yml, where `sha256sum *` should not become `sha256sum ./*`:
the comment four lines above records that ParseChecksumFile exact-matches the
last field, so a "./" prefix would strand every deployed server exactly as a
"windows/" prefix would. `sha256sum -- *` satisfies the linter and leaves the
output bytes identical.

Verified all three gates in both directions with shellcheck 0.10.0 and
actionlint 1.7.7 on PATH. Passing: `node scripts/run.mjs check:hygiene` prints
"check:hygiene: passed" with all three steps run, not skipped. Failing:
appending `bait_fn() { cat $1; }` to Server/scripts/voice-test.sh fails on
SC2086; changing a runs-on to `ubunt-latest` fails on runner-label; appending a
misformatted table to README.md fails prettier. All three files restored and
confirmed clean afterwards. actionlint validates the new job in ci.yml itself.

Refs RL-19 / L-13, S-05.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs(plans): record B1 progress through B1-3

The header still read "B1-0 is complete; B1-1 is the next step" three merged
phases later. A plan that misstates where it is costs a reader the same
confusion whether it is stale by one phase or three.

B1-0 (#1410), B1-1 (#1411), B1-2 (#1412) and B1-3 (this branch) are done; B1-4,
dependency automation, is next.

Verified: `node scripts/check-doc-counts.mjs` still agrees on 27 claims across
9 watched documents -- this file is one of them -- and prettier reports it
clean.

* chore(ci): pin Repository Hygiene as a required check on dev

The second half of S-05. Its acceptance criterion is "tree is formatted AND a
fast required gate fails future drift" -- a check that runs but is not pinned
lets a formatting regression merge, so the gate is not a gate until this lands.

The name was read off PR #1414 with `gh pr checks` after the job reported
`pass` in 26s, not copied out of ci.yml. That order matters: the B0 script
records that three pinned names exist in no workflow file at all, and that a
required check which never reports blocks every PR forever.

Extends the existing script rather than adding a second one, per the B1 plan.

Also records, in the "deliberately NOT pinned" list, that Docs & Ledger
Consistency reports and passes on a dev PR yet is unpinned. That reads as an
oversight from the 2026-08-25 pass rather than a decision, but it belongs to
G-04, so it is documented here and not changed.

NOT APPLIED YET. Running this script now would pin a check that PR #1413 cannot
report -- its branch predates the hygiene job, so the job does not exist in its
workflow file and the check would never arrive. Run it after #1414 merges;
#1413 needs a rebase onto dev regardless.

Verified: shellcheck clean, the embedded JSON parses, and `check:hygiene`
passes with prettier, shellcheck and actionlint all running.

Refs S-05, RL-14 / G-03.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>

* B1-4: dependency automation (RL-05 / L-05, RL-18) (#1415)

* chore(deps): cover the root and mcp-introspect npm roots

The repository has three npm package roots — `/` (changelogen, prettier),
`/Client`, and `/tools/mcp-introspect` (@modelcontextprotocol/sdk, zod) —
each with its own package-lock.json, and `npm run bootstrap` runs `npm ci`
in all three. Dependabot watched exactly one of them. The root's prettier is
what the Repository Hygiene gate runs, so the formatting gate's own toolchain
was drifting unwatched.

The obvious fix is to collapse the three roots into an npm workspace and
watch one lockfile. Measured on npm 11.17 / Node 26 rather than assumed, that
trade is bad, and it is bad for different reasons than expected. Workspaces do
not break the things you would predict: `npm ci` inside `Client/` still exits
0, `npm run <script>` still resolves the hoisted binaries because npm prepends
every ancestor `node_modules/.bin` to PATH, and `engine-strict` still fails
the install on a wrong Node major. What they cost is ten CI steps keyed on
`cache-dependency-path: Client/package-lock.json` (six in ci.yml, four in the
tag-only, CI-ungated release.yml) pointing at a file that stops existing; the
Repository Hygiene job's deliberate root-only install growing 970 ms to
6172 ms and 39 to 318 packages unless every call site remembers
`--workspaces=false`; and one shared lockfile putting all three npm Dependabot
groups back into the same file, which is precisely the rebase storm the
grouping comment at the top of dependabot.yml exists to prevent. The measured
benefit is one 298 KB lockfile instead of three (17 KB / 253 KB / 42 KB) and
614 resolved packages deduped to 582 — 32 packages, 5.2% — with client install
time unchanged at 5642 ms against 5667 ms.

So the roots stay separate and each gets its own block, matching the four that
already exist: grouped to one PR, majors ignored, weekly on Monday. A single
block with `directories:` was rejected for the same reason as workspaces —
grouping only works while a group rewrites exactly one lockfile. The decision
and its numbers are recorded in docs/contributing.md under Dependency Policy,
so the next person to propose workspaces reads the measurement instead of
repeating it.

Verified: a coverage checker cross-references every `package-ecosystem` /
`directory` pair in dependabot.yml against every manifest in `git ls-files`,
in both directions. Against dev at 2a37f386 it reports `UNWATCHED npm
package.json` and `UNWATCHED npm tools/mcp-introspect/package.json` (plus
Server/Dockerfile, which the next commit covers). Against this commit both npm
rows read `ok`, and the forward direction confirms each newly declared
directory really holds a package.json. `npx prettier --check` reports both
edited files unchanged, so the B1-3 formatting gate stays green.

Not included: the docker ecosystem (next commit, RL-18); immutable image
digests for release/runtime containers, which is R-04's remaining half and
belongs to B6; and turning the coverage checker into a permanent
`check:hygiene` gate — a new manifest root can still drift unwatched, which is
how this gap arose, but that is new gate machinery rather than the coverage
this item asks for.

Refs RL-05 / L-05

* chore(deps): watch the server container base images

Server/Dockerfile pulls `golang:1.26-bookworm` to build and
`gcr.io/distroless/static-debian12` to run, and nothing watched either. Every
other dependency root in the repository is on a weekly Dependabot schedule, so
the one artefact that ships to users as a whole filesystem was the only one
whose upstream moved silently — including its CA certificates, which the
Dockerfile comment specifically calls out as the reason distroless was chosen
over scratch.

The obvious fix is to pin both images by digest and be done. That is the wrong
move here for two reasons. A digest pin with no automation behind it is worse
than a tag: it freezes the base image at whatever was current the day someone
typed it, and a frozen distroless base is a frozen CA bundle. And digest
refresh for release and runtime images is R-04's other half, scoped to B6
alongside the smoke tests that have to gate it — landing half of it here would
leave the digests pinned and the refresh unowned.

So this adds the `docker` ecosystem for /Server on the same terms as the five
blocks around it: grouped to one PR, majors ignored, weekly on Monday. Be
precise about what that actually buys, because it is less than the block
implies. Of the two images only `golang:1.26-bookworm` carries a comparable
version, so it is the only one Dependabot can act on today;
`gcr.io/distroless/static-debian12` has no version tag, and an untagged image
is not something a version update can move — it needs the digest pinning that
B6 owns. The comment above the block records that the Go builder tag tracks
Server/go.mod and every `actions/setup-go` in CI, so a `1.27-bookworm` PR is a
prompt to move all three together rather than a standalone merge.

Verified: the coverage checker cross-references every `package-ecosystem` /
`directory` pair against every manifest in `git ls-files`, in both directions.
Against dev at 2a37f386 the reverse direction reports `UNWATCHED docker
Server/Dockerfile`; against this commit every row reads `ok` and it exits 0 —
`ALL ROOTS WATCHED`, six blocks covering six manifest roots, with the forward
direction confirming /Server really holds a Dockerfile. `npx prettier --check`
passes on the edited file.

Not included: Server/docker-compose.yml and Server/docker-compose.otel.yml.
Their four images are `ghcr.io/j3vb/owncord-server:latest` (this repository's
own published image), `livekit/livekit-server:v1` (a floating major tag, and
majors are ignored everywhere), `jaegertracing/all-in-one:latest` and
`prom/prometheus:latest` — none of which a version update can move, so a
compose block would be configuration that provably produces nothing. Also not
included: immutable digests plus digest-refresh PRs with smoke tests for the
release and runtime images, which is the remainder of R-04 and belongs to B6.

Refs RL-18

* docs: apply skill-review findings to ci-check …

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-28 17:31:02 +02:00
J3vb fbb85b4d76 fix(b2-8): close the nine B2-tagged ledger findings (#1436)
* docs(b2-8): record the B2-1 pre-squash head and ledger the voice_join ordering hazard

- B2-1 evidence block: PR #1435 pre-squash head 069412db (refs/pull/1435/head), squash 1fe3df79
- Ledger OC-0349 (open, low): the joiner's own voice_state takes the hub queue while the
  rest of the join burst is written directly, so its position on the joiner's socket is
  not ordered (documented in docs/protocol.md during B2-1, not yet fixed)

* fix(client): 2 defect(s) (OC-0311, OC-0315)

OC-0311: scope the voice_leave E2EE participant-left notification to this client's own voice channel — the frame is broadcast to the whole channel read audience, so a peer leaving a channel we merely read could delete their key, clear their verification, and trigger a room-key rotation in our live session.

OC-0315: parse server timestamps with parseTimestamp instead of Date.parse in the reconnect replay gate and the clock-skew sample — the wire form is naive UTC with no 'Z', which Date.parse reads as local time, so an east-of-UTC viewer silently swallowed genuinely live messages.

* fix(voice): 1 defect(s) (OC-0316)

* fix(client): 1 defect(s) (OC-0317)

* fix(plugin): 1 defect(s) (OC-0318)

@
Route every directory-manifest resolution through loadManifestFromDir so
InstallFromZip and scanPluginDirectory apply identical plugin.toml over
plugin.json precedence. A TOML-only zip now installs, and a zip carrying
both manifests is rejected rather than validating one while the loader
later obeys the other.
@

* fix(client): 1 defect(s) (OC-0322)

* fix(ws): 1 defect(s) (OC-0337)

liveVoiceEventsSince's cold-tier fallback handed a cap-truncated window to
resuming clients as if it were complete. The query is oldest-first with a
LIMIT, so a full result means the NEWEST rows were dropped - for a voice
room, quite possibly a peer's voice_leave. Degrade to nil (the documented
best-effort miss) on a cap hit, matching reconnectSelectReplay's guard.

* fix(plugin): 1 defect(s) (OC-0338)

* fix(client): 1 defect(s) (OC-0328)

* docs(b2-8): ledger records, plan evidence and count claims for the nine fixes

- Ledger: OC-0311/0315/0316/0317/0318/0322/0328/0337/0338 -> fixed, each with
  commit, pinning test and revertProof: pass (verify-fixes.mjs 8/8, plus a hand
  RED/GREEN of the wazero-tagged OC-0318 parity test)
- Plan: B2-8 evidence block and status line (B2-0, B2-1, B2-8 landed; B2-2 next)
- Count claims in README, b0-baseline, hp-0-scorecard and the issue register
  follow the ledger (315 fixed / 30 open / 3 declined / 1 duplicate = 349)

* fix(ws): replay a complete cap-sized voice window instead of skipping it (OC-0337 follow-up)

Codex review on #1436: liveVoiceEventsSince decided truncation by
len(persisted) >= coldCap, so a complete window of exactly coldCap rows was
treated as truncated and the supplement returned nil. Fetch coldCap+1 rows
and discard only when the extra row exists. Test-first: the exact-cap case
fails before the change and passes after; the over-cap case still degrades
to nil.
2026-08-28 15:13:49 +00:00
J3vbandClaude Fable 5 1fe3df7962 test(b2-1): capture the epoch-1 protocol fixtures and retire S-15 (#1435)
* refactor(protocol): retire reserved voice_speakers and member_leave (S-15)

Neither type was ever emitted by the server; B2-1 clears them from the
schema before the epoch-1 wire fixtures are captured, so the frozen epoch
does not carry two dead message types.

Client: dropped the dead `ws.on(MEMBER_LEAVE)` / `ws.on(VOICE_SPEAKERS)`
dispatcher handlers, the `MemberLeavePayload` type and both `ServerMessage`
union members, and the tests that only exercised those WS paths.
`removeMember` (member_ban) and `setSpeakers` (LiveKit ActiveSpeakers) stay
live and keep their direct unit tests.

* test(ws): capture the epoch-1 wire fixtures

alpha.4 is the last client on the pre-epoch wire and B2-2 adds a protocol
epoch to the auth handshake next, so record what epoch 1 actually looks
like while it is still observable.

TestEpoch1Fixtures drives eleven journeys through the ws package's
in-process hub harness (full migrations, real hub, httptest WebSocket
server) and compares each journey's per-connection frame sequence with a
transcript under protocol/fixtures/epoch-1/:

- fresh-connect, auth-failure, ping
- chat-send-fanout, chat-edit-delete, reaction-add-remove
- typing, mark-read, dm-send
- resume-replay (last_seq + buffer-tier replay burst)
- voice-join-e2ee-leave (join, both voice_state forms, announce and
  offer relay, leave)

Volatile values are replaced by typed placeholders before both writing
and comparison -- any key that is id/seq/last_seq, ends in _id (except
channel_id and role_id) or _at, is timestamp/ts/last_seen, or contains
token, becomes "<class:json-type>" so a field that changes type is still
a diff, while everything else is compared verbatim.

Regenerate with: go test ./ws -run TestEpoch1Fixtures -update

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test(client): pin the epoch-1 auth frame contract

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test(updater): pin the epoch-1 manifest and client-update shapes

* test(ws): harden the epoch-1 fixtures against trailing frames and absent optionals

Every journey now ends with the ping/pong barrier on each recorded
connection, so a frame the server emits after the last read fails as
`expected "pong", got "X"` instead of going unrecorded; auth-failure
asserts the StatusPolicyViolation close instead, its socket being gone.

alice carries a display name, avatar, about text, custom status, identity
public key and an announce signature (bob carries none), so every optional
field is frozen in its present form as well as its absent one — a rename or
a retype of display_name or identity_public_key now moves a fixture.

The typing journey focuses the channel on "a" before typing: without the
subscription registerNow only makes for a focused client, its ping/pong
proved nothing about excludeUserID.

Comment fixes: the escaped placeholder form MarshalIndent would write, the
real (headroom) reason for the raised read limit, a note that bare id and
active_channel_id are normalised by design, and a .prettierignore line
saying these fixtures are verified by the Go comparison, not by git diff.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs(protocol): document the epoch-1 fixtures

* docs(protocol): match the epoch-1 wire where the fixtures contradicted the doc

- voice_state: note the unsequenced relay-to-joiner form (table row + section)
- auth_error: correct the example message and list the real rejection messages
- member_join/presence connect example: add the seq both frames actually carry
- chat_message.user: document display_name
- voice_join reply order: state that the joiner's own voice_state broadcast is not ordered against the other three frames

* test(ws): freeze the null forms of auth_ok and member_join user fields

buildAuthOK emits display_name/about/custom_status/avatar as
always-present nulls, but only alice — who has all four set — ever
authenticated on a recorded connection, so the fixtures froze those
fields in their populated form alone. A rename, a retype or a dropped
null would have moved nothing, on the very frame B2-2 edits.

fresh-connect now records bob's handshake too, on a second connection:
his auth_ok carries the four nulls, his member_join carries avatar null
with display_name and identity_public_key omitted, and alice — idle by
then, so her reads stay in hub order — records the same pair as an
already-connected observer sees it. In voice-join-e2ee-leave bob answers
alice's signed announce with a legacy unsigned one, which freezes the
absent form of signature next to her present one. The auth frames the
test writes now carry the correlation id the real client stamps on every
frame (ws.ts send()); normalisation renders it <id:string>.

expectClosed also asserts the close reason ("authentication failed"),
not just code 1008 — HP-2 asks for both.

Comment precision, no behaviour change: the barrier guarantee now states
that pong may overtake a pending LOW-priority frame (writePump) and that
no journey is affected because every barrier is sent on an idle
connection; the ping-budget ceiling is six connections, not four; the
typing journey cites handleChannelFocusV2 rather than registerNow as the
subscribe site; resume-replay's b barrier explains why moving it past
the resume would be a flake, not a fix; and the header notes that
normalisation hides that chat_send_ok.id echoes the request id.

The client contract test's cited range for ws.ts's send() call is
441-453, not 441-454.

Regenerate with: go test ./ws -run TestEpoch1Fixtures -update

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs(protocol): shape-not-value fixture rule, voice_max_video default, auth close code

The fixture rule read "a fixture may only change with an epoch bump",
which is false for the seeded values the transcripts record verbatim:
role permission masks and colours, motd, server_name, voice_max_video,
the voice_config preset. A migration that changes a default mask diffs
fresh-connect.json, and the README told the author to revert a change
that never touched the wire.

Split the rule along shape versus value. A key set, a JSON type, a key
appearing or disappearing, or per-connection frame order is a protocol
change and earns fixtures/epoch-<n+1>/. A seeded default value is a seed
change: regenerate in the same PR and read the diff frame by frame.
Normalising those values is explicitly not the answer — a placeholder
over a mask or over an enum such as voice_config.threshold_mode would
hide the drift the fixtures exist to catch.

Two wire facts corrected against the fixtures:

- voice_max_video on an unconfigured channel is 25, not 0 (migration
  004 is DEFAULT 25); the doc listed it among the zero values.
- auth_error is followed by a close with code 1008 (policy violation)
  and reason "authentication failed" (serve.go:128), which the doc left
  as "closes the connection".

Also: voice_speakers moves from discord-parity's "still dead" list to
"came off the list" — it was retired earlier on this branch. And Kick
says sessions are revoked and sockets drop on the next sweep, which is
what ForceLogout does (moderation.go:236); it does not cut sockets.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs(protocol): fixture rule covers enum vocabularies; header comment precision

Re-check minors from the whole-branch review: a fixed-vocabulary value the client switches on is shape, not a seeded value; drop the false 'new default channel' clause (the seed asserts channel ids); the low-priority frame is presence_update, not connect presence.

* docs(plans): record B2-1 evidence for HP-2 (PR #1435, fixture commit SHAs)

* docs(protocol): additive changes stay within an epoch; bump only for what old clients cannot process

Codex review on #1435: B2-2 keeps protocol_epoch = 1 while adding auth/ready/auth_error fields, which the previous wording would have called a break to revert. An epoch is a compatibility boundary, not a snapshot: additive keys regenerate in the same PR and are documented; removals, renames, retypes, dropped frames and reordering bump the epoch. The plan's B2-1 evidence records the refinement and hands B2-2/B2-4 the open questions (additive-tolerant replay of the epoch-1 transcript; epoch 0 vs 1 naming).

* test(client): compare auth-frame key sets order-independently

Codex review on #1435: Object.keys preserves insertion order, so a harmless property reorder in ws.ts would fail the pin. Key order has no wire meaning; the Go fixtures already compare with sorted keys. Sort both sides.

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-28 15:43:30 +02:00
J3vbandClaude Fable 5 fb6b51a062 chore(b2-0): release hygiene (#1434)
- dev branch protection: strict: true — script updated and applied;
  API read-back true, the 12 required checks unchanged
- release.yml: environment: release on the publish job, so the
  environment's required reviewer actually gates publishing
- docker-smoke.sh: export MSYS_NO_PATHCONV=1 (ENV-03) so Git Bash
  callers no longer have to set it; old script exits 1, new exits 0
- B2 plan: B2-0 marked done with the evidence HP-2 questions 1 and 7 cite

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-28 10:16:27 +02:00
J3vbandClaude Fable 5 b3f39419e9 docs: B2 execution plan and the 2026-08-28 roadmap amendments (#1433)
* docs(roadmap): record B0/B1 complete and point B2 at its execution plan

* docs(roadmap): keep the hard break after the status header

* docs(roadmap): write down the phase execution pattern B0 and B1 proved

* docs(roadmap): add the 2026-08-28 amendments to B3-B10

* docs(plans): add the B2 execution plan

* docs(plans): index the B2 plan and the roadmap amendment

* docs(plans): record that the owner already synced dev after alpha.4 (#1432)

* docs(plans): apply the final review — verify three claims, serialize B2-5, add the step table

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs(plans): do not point readers at a public repro from the B2-9 table

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-28 07:36:40 +00:00
dd7ed091c0 chore: merge main into dev after the v1.2.0-alpha.4 release (#1432)
* ci(deps): bump anthropics/claude-code-action (#1404)

Bumps the actions-dependencies group with 1 update: [anthropics/claude-code-action](https://github.com/anthropics/claude-code-action).


Updates `anthropics/claude-code-action` from 1.0.193 to 1.0.199
- [Release notes](https://github.com/anthropics/claude-code-action/releases)
- [Commits](https://github.com/anthropics/claude-code-action/compare/9d7150bc8a3dae8149739a88019d192b579ad90c...dcb57747bfceeaa1fa72638cae52295d1d853d4a)

---
updated-dependencies:
- dependency-name: anthropics/claude-code-action
  dependency-version: 1.0.199
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: actions-dependencies
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* build(deps): pin rfd to tauri-plugin-dialog's major to unblock the cargo group (#1406)

The cargo-dependencies group PR (#1405) fails Rust Unit Tests on Linux:

    error: failed to run custom build command for `rfd v0.17.2`
    You need to choose at least one backend: `gtk3` or `xdg-portal`
    features for x86_64-linux

rfd is not really ours. It arrives in the tree via tauri-plugin-dialog,
which pins ^0.16; we declare it directly only for the fatal-startup
message box in lib.rs, where the Tauri app never finished building and
the plugin has no AppHandle to run a dialog through.

Cargo unifies features only within a semver-compatible version group, so
while both wanted ^0.16 there was a single rfd in the graph and the
plugin's backend features covered our `default-features = false`
declaration too. Bumping our direct dep to 0.17 forks rfd into two
crates: the plugin keeps 0.16.0 with its features, ours resolves to
0.17.2 with none, and rfd 0.17 added a build.rs assertion that aborts
the Linux build when no backend feature is set. Confirmed in the PR's
lockfile, which carries both 0.16.0 and 0.17.2.

Adding a Linux backend feature would be the wrong fix: it would paper
over the fork and still build rfd twice on every platform for one error
dialog. Our version has to track the plugin's instead, so ignore
semver-minor rfd updates (0.16 -> 0.17 for a 0.x crate) until
tauri-plugin-dialog moves. Patch updates inside 0.16.x still flow.

The remaining five crates in the group are unaffected; `windows` in fact
consolidates 3 versions down to 2.

Cargo.toml is comment-only here - no dependency, feature, or lockfile
change - so the build is untouched.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>

* chore(deps): bump log (#1407)

Bumps the cargo-dependencies group with 1 update in the /Client/tauri-client/src-tauri directory: [log](https://github.com/rust-lang/log).


Updates `log` from 0.4.33 to 0.4.34
- [Release notes](https://github.com/rust-lang/log/releases)
- [Changelog](https://github.com/rust-lang/log/blob/master/CHANGELOG.md)
- [Commits](https://github.com/rust-lang/log/compare/0.4.33...0.4.34)

---
updated-dependencies:
- dependency-name: log
  dependency-version: 0.4.34
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: cargo-dependencies
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* ci(deps): bump anthropics/claude-code-action (#1408)

Bumps the actions-dependencies group with 1 update: [anthropics/claude-code-action](https://github.com/anthropics/claude-code-action).


Updates `anthropics/claude-code-action` from 1.0.199 to 1.0.200
- [Release notes](https://github.com/anthropics/claude-code-action/releases)
- [Commits](https://github.com/anthropics/claude-code-action/compare/dcb57747bfceeaa1fa72638cae52295d1d853d4a...24dcd50c0568f0fc9e9211213a4fd2d9eb15c4e0)

---
updated-dependencies:
- dependency-name: anthropics/claude-code-action
  dependency-version: 1.0.200
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: actions-dependencies
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* release: v1.2.0-alpha.4 — 62 fixes plus the B0/B1 repository foundation (#1426)

* Fix 27 findings from 2026-08-21 bug hunt (#1400)

* chore(findings): record 2026-08-21 bug hunt (38 findings)

* fix(api): 1 defect(s) (OC-0240)

* fix(client): 1 defect(s) (OC-0241)

* fix(plugin): 2 defect(s) (OC-0243, OC-0265)

* fix(client): 3 defect(s) (OC-0244, OC-0256, OC-0259)

* fix(client): 1 defect(s) (OC-0247)

* fix(client): 2 defect(s) (OC-0248, OC-0258)

* fix(identity): 1 defect(s) (OC-0250)

* fix(ws): 3 defect(s) (OC-0252, OC-0269, OC-0272)

* fix(admin): 1 defect(s) (OC-0253)

* fix(client): 1 defect(s) (OC-0254)

* fix(voice): 1 defect(s) (OC-0255)

* fix(ws): 1 defect(s) (OC-0260)

* fix(client): 1 defect(s) (OC-0261)

* fix(client): 1 defect(s) (OC-0262)

* fix(client): 1 defect(s) (OC-0263)

* fix(client): 1 defect(s) (OC-0264)

* fix(client): 1 defect(s) (OC-0268)

* fix(ws): 1 defect(s) (OC-0273)

* fix(service): 1 defect(s) (OC-0275)

* style: satisfy golangci-lint and prettier on 2026-08-21 fix commits

- drop ineffectual backupDir reset before return (registry.go, OC-0265)
- reflow long boolean expression (attachments.ts, OC-0241)

* fix(client): 4 defect(s) (OC-0242, OC-0246, OC-0249, OC-0251)

* fix(voice): 1 defect(s) (OC-0267)

* fix(admin): 1 defect(s) (OC-0274)

* fix(voice): 1 defect(s) (OC-0245)

* fix(ws): 1 defect(s) (OC-0271)

* fix(voice): 2 defect(s) (OC-0239, OC-0257)

* fix(ws): 1 defect(s) (OC-0266)

* fix(voice): 1 defect(s) (OC-0270)

* style: clear golangci-lint modernize and prettier nits from 2026-08-21 fixes

- range-over-int and slices.Contains modernizations in new Go test files
- prettier reflow in dispatcher.ts

* chore(findings): mark 2026-08-21 hunt findings fixed/declined

37 fixed across the fix waves, OC-0238 declined (LiveKit webhook TLS
requires a product decision, not a mechanical patch).

---------

Co-authored-by: Claude <noreply@anthropic.com>

* fix: 35 findings from the 2026-08-22 bug hunt (#1402)

* fix(voice): 1 defect(s) (OC-0277)

* fix(voice): 1 defect(s) (OC-0278)

* fix(client): 1 defect(s) (OC-0280)

refreshDmSidebar() rebuilds the entire DM sidebar subtree on every
dmStore.channels change - which includes presence flips and new
messages, not just DM list changes. The "Find a conversation" filter
text and input focus live only in that destroyed subtree, so they were
silently wiped mid-typing. Capture and restore both across the
destroy+recreate cycle.

* fix(ws): 1 defect(s) (OC-0285)

* fix(client): 1 defect(s) (OC-0286)

* fix(client): 1 defect(s) (OC-0288)

Consume the legacy unscoped mute key after migrating it onto the first
host, so a brand-new host with no scoped key of its own no longer reads
through to the same legacy list and inherits another server's mutes.

* fix(voice): 1 defect(s) (OC-0290)

* fix(db): 1 defect(s) (OC-0293)

DecrementMentionCounts reversed mention_count bumps that were never
applied: message_mentions stores every resolved mention id including the
author's blockers, while applyMentionCounts excludes blockers before
incrementing. Deleting a blocked author's message therefore wiped an
unrelated, genuine mention badge on the same read_states row. Mirror the
block exclusion in the decrement UPDATE.

* fix(db): 1 defect(s) (OC-0294)

DeleteAccount soft-deletes the departing user's messages but never reversed the read_states.mention_count bumps those messages made, leaving phantom mention badges. Reverse them inline in the existing transaction, mirroring DecrementMentionCounts' guards.

* fix(client): 1 defect(s) (OC-0295)

MemberList rebuilt every row on any non-presence-only membersStore change
and on every roles_update, but registered each row's click/contextmenu
listeners on the component-lifetime disposable.signal, which only aborts
at destroy(). Discarded rows therefore stayed reachable (and their
listeners live) for the component's whole lifetime. Route per-row
listeners through a per-render AbortController that is aborted and
replaced at the top of every render, and aborted again in destroy().

* fix(identity): 1 defect(s) (OC-0297)

UpdateProfile's post-commit re-read of the user row could fail for reasons
unrelated to context cancellation (SQLITE_BUSY, I/O error, pool exhaustion)
and was reported as ErrInternal even though UpdateUserProfile had already
committed. Callers that treat any UpdateProfile error as proof the write
never landed — handleUploadAvatar deletes the file it just stored — would
delete a file the committed avatar column now points at, permanently
breaking the avatar with no user_update broadcast.

Since UpdateUserProfile only writes username/avatar/display_name/about,
merge those four onto the pre-write snapshot to reconstruct the committed
row without needing the re-read to succeed, and log the read failure.

* fix(ws): 2 defect(s) (OC-0298, OC-0299)

- OC-0298: applyConnectStatus stamped c.user.Status even when the
  UpdateUserStatus write failed, so auth_ok and the presence broadcast
  claimed a status users.status disagreed with, and buildReady's
  ListMembers read never self-corrected for the session.
- OC-0299: refreshUserSnapshot silently fell back to roleName "member"
  when the new role lookup failed, pinning the session to a fabricated
  role on the wire. It now fails closed like the sibling lookups in
  upgradeAndAuth and handleFreshConnect.

* fix(client): 1 defect(s) (OC-0300)

* fix(client): 1 defect(s) (OC-0301)

* fix(ws): 1 defect(s) (OC-0302)

* fix(api): 1 defect(s) (OC-0305)

handleDiagnosticsConnectivity used clientIP(r), ignoring cfg.Server.TrustedProxies, so behind a configured trusted reverse proxy the endpoint reported the proxy hop instead of the real client address. Use clientIPWithProxies with the parsed trusted-proxy nets, matching RateLimitMiddleware on the same route.

* fix(client): 2 defect(s) (OC-0306, OC-0308)

* fix(client): 1 defect(s) (OC-0307)

QuickSwitcher registered a per-row click listener against the
overlay-lifetime AbortSignal, but renderResults() rebuilds every row on
each keystroke, arrow key, and store refresh. Discarded rows kept their
listeners alive until the overlay closed. Replaced with one delegated
click listener on the stable results container, keyed off the
data-channelid each row already carries.

* fix(client): 1 defect(s) (OC-0310)

* fix(server): 3 defect(s) (OC-0279, OC-0291, OC-0292)

Reap a soft-deleted message's attachment files, count lapsed temporary
bans as active users in the require_2fa enrollment gate, and only apply
the 2FA-enrollment precondition when require_2fa itself is being enabled.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SdkJRbjCtrG76jEnrhKbYo

* test(api): sync apiTestSchema with the user_blocks migration

DeleteAccount's mention-count reversal joins user_blocks; the api
package's hand-rolled schema fixture predates migration 012.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SdkJRbjCtrG76jEnrhKbYo

* fix(client): 3 defect(s) (OC-0281, OC-0282, OC-0296)

Decouple the E2EE identity-mismatch modal and right-click popovers from
the sidebar's per-render abort signal, and let global drag listeners
survive a mid-drag re-render.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SdkJRbjCtrG76jEnrhKbYo

* fix(voice): 2 defect(s) (OC-0283, OC-0287)

Retire a departed peer's E2EE key unconditionally on leave, and surface
a failed microphone unmute instead of reporting an unmuted state the
room never saw.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SdkJRbjCtrG76jEnrhKbYo

* fix(client): 3 defect(s) (OC-0289, OC-0303, OC-0309)

Guard the DM call button against redialing the channel already joined,
resolve the incoming-call banner's caller through the nickname-aware
display name, and keep the DM profile sidebar subscribed to live
member/status updates.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SdkJRbjCtrG76jEnrhKbYo

* style(client): prettier-format the dm-store test

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SdkJRbjCtrG76jEnrhKbYo

* fix(server): 1 defect(s) (OC-0284)

Make message soft-delete a compare-and-set so a repeated chat_delete
cannot reverse mention counts twice.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SdkJRbjCtrG76jEnrhKbYo

* fix(server): 2 defect(s) (OC-0276, OC-0304)

Re-sync a resumed connection's voice E2EE peer keys in registerNow
(announce frames are unsequenced and cannot be replayed), and apply the
live-connection presence rule to every DM payload DMService builds.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SdkJRbjCtrG76jEnrhKbYo

* chore(ledger): record the 2026-08-21 hunt findings as fixed

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SdkJRbjCtrG76jEnrhKbYo

* chore(ledger): independent revert-proof pass for OC-0276..OC-0310

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SdkJRbjCtrG76jEnrhKbYo

* refactor(service): extract DeleteMessage authorization into a helper

Keeps DeleteMessage under the cyclop complexity ceiling after the
OC-0284 guard.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SdkJRbjCtrG76jEnrhKbYo

---------

Co-authored-by: Claude <noreply@anthropic.com>

* chore(ledger): record the 38 open findings from the 2026-08-22 hunt (#1403)

Claude-Session: https://claude.ai/code/session_01SdkJRbjCtrG76jEnrhKbYo

Co-authored-by: Claude <noreply@anthropic.com>

* chore(graphify): refresh knowledge graph

* fix: close the three B0 P0 gates and record a measured baseline (#1409)

* chore(security): stop tracking the private security-finding reports

docs/security-findings/ holds detailed reports for defects that are not yet
fixed. The directory was untracked but not ignored, so any 'git add .' would
have published seven unfixed vulnerability traces to a public repository.

Findings are coordinated through private GitHub Security Advisories
(docs/security.md); only opaque identifiers and safe status belong in tracked
plans.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(client): repair the two red P0 unit contracts (G-01, G-02)

G-02: noise-suppression-restart stubbed MediaStream with
vi.fn().mockImplementation(arrow), which is not constructible. Vitest 4 threw
'is not a constructor' at the new MediaStream([inputTrack]) call in
noise-suppression.ts before reaching any assertion. Replaced with a real
class; the OC-0277 assertions are unchanged.

G-01: message-list's OC-0217 guard was inverted, not merely stale. It spied on
AbortSignal.prototype.addEventListener and asserted zero abort registrations,
but the leak it names registered row listeners via
element.addEventListener(..., { signal }) — a path that never calls that
prototype method. Measured: the leak produces 0 registrations (test passes),
while the OC-0286 fix rotates a per-window AbortSignal.any and produces 5
across 5 distinct signals (test fails). The guard passed on the bug and failed
on the fix.

It now captures the signal each window's row listeners register against and
asserts the invariant its name always claimed: one signal per rendered window,
a fresh signal per jump, and every superseded window already aborted with
exactly one live. Verified both directions — green on the fix, and
'expected 1 to be 5' with beginRowRender() reverted to rowSignal = ac.signal.

Client suite: 5257 passed, 0 failed (was 5255 passed, 2 failed).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(client): make the Playwright suite terminate

The runner finished every test and then never exited, printing no summary — so
the failure read as 'tests never finish' when it was 'process never exits'.
getActiveResourcesInfo() at hang time showed a live ProcessWrap plus several
PipeWrap: the Vite dev server was still running. Playwright's webServer
teardown does not kill it here.

Measured, full suite each time:

  npm run dev                        hangs, tests pass
  node node_modules/vite/bin/vite.js hangs, tests pass
  reuseExistingServer: false         hangs, tests pass
  gracefulShutdown SIGTERM/3s        hangs, tests pass
  npx vite                           exits, 290 of 293 FAIL
  no webServer (pre-started)         exits, 293 pass in 33s

npx only appears to fix it: npx exits once Vite is up, Playwright reads that as
the server dying and tears the group down mid-run, so later tests get
ERR_CONNECTION_REFUSED.

globalTeardown now kills the process listening on the dev port, releasing the
runner's handle. The webServer command spawns Vite's entry point directly so
the listening process is Playwright's own child — via 'npm run dev' the npm
process would still hold the handle open. It also reaps servers orphaned by an
interrupted run, which reuseExistingServer would otherwise silently adopt.

An earlier revision used netstat, which is not on PATH in every shell here; the
swallowed ENOENT made the fix look applied while the hang persisted. It now
uses PowerShell on Windows and lsof elsewhere, and warns on failure rather than
failing silently.

npm run test:e2e: exit 0, 293 passed, 37s, reproducible, no orphan listener.
playwright.config.prod.ts carried the same npm-wrapper shape.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* chore(client): align .nvmrc with the Node version CI uses

Three versions were in play, not two: .nvmrc said 20, CI pins 24, and the
machine the audit was measured on runs 26. A baseline measured against .nvmrc
is not the baseline CI produces, which defeats the point of B0.

Scoped to .nvmrc only. The full single-source-of-truth work — package engines,
contributor docs, release — stays in B1 (RL-17 / C-01).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs(plans): add the beta audit set and the B0 baseline

The 2026-08-23 audit set has been sitting untracked: repository-health and
repository-layout audits, beta product requirements, requirement traceability,
the issue register, and the B0-B10 roadmap. They are the plan of record for
beta and belong in the repository.

Adds b0-baseline-2026-08-25.md, which supersedes the roadmap's 'current
evidence snapshot'. Every row is marked measured or carried, so nothing is
inherited silently. It also records three audit claims that did not survive
verification:

  - G-01 was an inverted guard, not a stale assertion — it passed on the bug
    and failed on the fix.
  - The Playwright hang matched none of the three hypotheses; the runner could
    not kill its own dev server.
  - The golangci-lint toolchain failure is refuted: 19 linters run, 0 issues,
    verified with -v to rule out the known zero-linters false-green.

Adds b0-dev-branch-protection.sh, which records the applied dev branch
protection and the reasoning behind each setting.

Security detail stays private: the register carries only opaque SEC-* families
and safe closure criteria, per the roadmap's public/private handling policy.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* chore(graphify): refresh the knowledge graph

Own commit, per CLAUDE.md — the graph payload does not belong in the diff of
the changes that triggered it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs(plans): add the active-plan index and fix a stale status header (G-04)

Planning documents had no recorded state, so a reader could not tell current
guidance from shipped history. docs/plans/README.md now indexes every plan as
active, partially implemented, design-only, or shipped, and names the source of
truth for each concern so a defect count is never read out of a plan.

Status is recorded in the index rather than by moving or rewriting the
historical plans, so links from audits and commit messages keep resolving.

One real stale claim found and fixed: audit-2026-08-19-remediation.md still
read 'in progress 2026-08-19' while its own phase table showed phases 1-6 done
2026-08-20 (merged 03fcb7d5, PR #1396) with only phase 7 pending. The header
had drifted because the table was updated in place and the header was not.
No plan was found claiming '0 open findings'.

Also records the Step 8 staleness pass in the B0 baseline: all 38 open OC
records still resolve to a live file:line at this commit, so none is superseded
by later work. Adjudicating them individually is bughunt-fix work, not B0.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Update graph output files and manifest with new metadata

- Updated graph.html and graph.json with new binary data.
- Modified manifest.json to reflect changes in file modification times and AST hashes for several documents.
- Added new entry for README.md in the manifest with its corresponding metadata.

* docs(plans): close the Docker and coverage leftovers in the B0 baseline

Docker smoke: measured and passing. Image builds at 50.1 MB and boots on :8443
with TLS; docker-smoke.sh exits 0.

Server coverage: re-measured at 74.6% aggregate, confirming the figure carried
from the audit rather than continuing to inherit it.

Two findings from doing it:

ENV-03 — docker-smoke.sh cannot be run from Git Bash on Windows. MSYS path
conversion rewrites the container-internal /chatserver into
'C:/Program Files/Git/chatserver', so docker exec fails 127 and the script
reports 'container never reported healthy within 30s' — indistinguishable from
a real boot regression. MSYS_NO_PATHCONV=1 makes the same script pass. CI is
Linux and unaffected, but Windows is an official contributor platform (RL-20).

The CI Docker job is gated on main, so it is skipped for any PR targeting dev
— a dev-targeted change cannot get Docker evidence from CI at all.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* chore(graphify): refresh the knowledge graph

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs(plans): B1 execution plan, and accept HP-0 (#1410)

* docs(plans): add the B1 repository-foundation execution plan

B1 is the isolated layout/contributor phase. This records the execution
order, the proof for each step, and what is out of scope.

Two findings worth surfacing before any B1 work starts:

- HP-0 was never formally accepted. The roadmap's B1 entry gate requires
  it; no scorecard artifact exists, no commit or document records an
  acceptance, and the B0 baseline still lists "Step 10: HP-0 sign-off"
  under "Not yet done in B0". The plan lists the five gaps that closing
  it requires, including pinning required status checks on dev -- which
  are still unset, so a dev PR can currently merge red.

- Several layout-audit claims do not survive verification against HEAD,
  matching the B0 pattern. RL-09's "no single command verifies both
  protocol consumers" is false (make protocol-verify does, and is
  enforced in CI, the pre-commit hook, and a contract test). RL-10's
  test-discovery side effect never fires (no _test.go in Server/scripts).
  RL-06's regeneration concern is refuted locally. RL-08 grows a
  toolchain constraint instead. RL-05, RL-07, RL-20 and RL-21 are each
  worse than written -- RL-20 includes a live bug where a missing `make`
  is reported as stale protocol constants.

The riskiest item, RL-01 (flatten Client/tauri-client into Client), gets
a full reference inventory and a mechanical proof for both commits: tree-
object equality for the pure move, and scripted-substitution replay for
the path rewrite. Release asset names and updater contracts are verified
independent of the directory name, so the move cannot rename an artifact.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs(plans): correct the B1 status-check pin list from a live dev PR

The list was derived from ci.yml. Observing PR #1410's actual checks
found three that exist in no workflow file -- Analyze (go),
Analyze (javascript-typescript), Analyze (actions) -- because CodeQL
runs from GitHub default setup, configured in repository settings.
Reading .github/ alone misses them.

Also confirms the two negative predictions against a real dev-targeted
PR: Server Docker Build (verify) reports as "skipping", and Tauri Full
Build never appears in the check list at all. Neither may be pinned.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs(plans): accept HP-0 and pin the dev required status checks

Closes B1's entry gate. All five B1-0 items are done.

The scorecard is the artifact the hold point asks for: one place that
answers its four questions, records what was accepted as a stated
limitation rather than claimed green, and part-closes R-08.

Required status checks are now pinned on dev -- ten of them. That was
B0's one outstanding step. Two things came out of doing it:

- The names cannot be inferred from ci.yml. Three of the ten (the
  Analyze jobs) exist in no workflow file, because CodeQL runs from
  GitHub default setup configured in repository settings. They were read
  off a live dev-targeted PR with `gh pr checks`.
- Server Docker Build, Tauri Full Build and the CodeQL aggregate are
  deliberately excluded. The first two report "skipping" on a dev PR --
  Tauri Full Build under its unexpanded matrix name, since the job is
  skipped before matrix expansion. Admin Panel E2E is excluded because
  continue-on-error makes it report success unconditionally.

Two prior claims are corrected rather than left to propagate:

- b0-dev-branch-protection.sh was written assuming repository-settings
  writes are blocked from the agent sandbox. They are not; the PUT
  succeeded. The script stays as the record of intent and the way to
  re-apply or undo.
- An earlier revision of the B1 plan said Tauri Full Build does not
  appear in a dev PR's check list at all. It does, as skipping.

Evidence closed out:

- Rust is no longer a carried row. Re-measured: 115 passed, cargo clippy
  --all-targets -- -D warnings at exit 0, confirming the carried figure.
- The 38 open ledger records are accepted as counted, non-stale and
  assigned: 11 medium / 27 low, zero high or critical, zero dead paths
  across all 348 re-verified at this commit, and none assigned to B1.
- The private security review is reconciled: 7 findings, 7 of 7 mapped
  to existing public rows, 0 unmapped. Summary is content-free; the
  detail stays in the untracked private reports.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>

* refactor: flatten Client/tauri-client into Client (B1-1) (#1411)

* refactor: move Client/tauri-client to Client (pure move, no content change)

* refactor: re-point paths after the Client flatten (mechanical, no behaviour change)

---------

Co-authored-by: Claude <noreply@anthropic.com>

* B1-2: truth, entry points, and contributor path (#1412)

* fix(hooks): guard on the command the hook actually runs

pre-commit probed one binary and invoked another. The protocol block
guarded on `command -v go` and then ran `make protocol-verify`; the sqlc
block guarded on `command -v sqlc` and ran `make sqlc-verify`. `make` is
not on PATH on a stock Windows box, so a contributor with Go installed
but no make had their commit rejected with

    pre-commit: FAIL: protocol constants are stale — run 'make
    protocol-generate' in Server/ and stage the result

when nothing had been generated and nothing compared. The real cause was
`make: not found`, and the advice the message gives fails the same way.

Rather than add a `command -v make` guard, inline what the two Makefile
targets reduce to — `sqlc generate` / `go run ./scripts/genprotocol`
followed by `git diff --exit-code`. Same semantics, one less prerequisite,
and it doubles as the make-free equivalent B1-2 asks for. The Makefile
targets stay for anyone who prefers them.

Also: the protocol block was the only one with no `else`, so a
contributor without Go got no check and no notice. It now warns like its
two siblings. And gofmt is a separate binary from go, so it is probed
separately.

Verified both directions with the hook body replayed verbatim:
- Go present, make absent -> passes, no staleness claimed.
- schema edited without regenerating -> fails, as it must.

Refs RL-20 / L-14.

* docs: state one branch and PR model

Active documents contradicted each other head-on. README.md and
docs/contributing.md said branch from `dev` and target `dev`; CLAUDE.md
said branch from `main`, PR to `main`. That is R-02, and the 2026-08-19
audit had already recorded it as D-06 without it being resolved.

`dev` is the answer, and the repository already behaves that way: B0 made
`dev` PR-only with ten required checks enforced on admins, and #1409,
#1410 and #1411 all landed there. `main` carries releases.

docs/contributing.md becomes the single source of truth. It now states the
model, what protection is actually applied, and the two consequences a
contributor meets on their first PR — that a self-mergeable PR still cannot
merge red, and that Docker and Tauri Full Build report as skipped against
`dev` rather than failing. Everywhere else summarises and links here.

- CLAUDE.md: corrected, with a link rather than a second copy.
- CONTRIBUTING.md: new. GitHub's contributing-guidelines affordance only
  resolves the root, .github/ or docs/ — `docs/contributing.md` is not a
  path it finds, so the link never appeared on issues or PRs.
- PULL_REQUEST_TEMPLATE.md: names the base branch, which it did not.
- bughunt-run skill: reviewed the branch against `origin/main`, which is
  the wrong base once every PR targets `dev`.

README.md already said `dev` and is left as the short summary it should be.
Dated audits and the historical remediation plan keep their `main`-era
wording — they are records.

Refs R-02.

* fix(hooks): pick the pre-push base from the nearest integration branch

pre-push decided which side's gates to run from
`git diff --name-only origin/main...HEAD`. That was right when everything
targeted `main`. Once `dev` became the integration branch it stopped being
right: a branch cut from `dev` diffed against `main` counts everything on
`dev` and not yet on `main` as "changed".

Measured on this branch: the old base reported 609 changed files, the new
one reports 6. So in practice the hook was running the full server build
matrix and the client typecheck plus eslint on every push, whatever the
change touched — the file-based narrowing it exists for never engaged.

Now it picks whichever of origin/dev, origin/main is nearest, by commits
between merge-base and HEAD, skipping a candidate that scores 0. Verified:
a branch cut from dev picks origin/dev (2 ahead); dev itself scores 0
against dev and picks origin/main (8 ahead), which is what a dev -> main
release PR wants. With no candidate resolvable it falls back to the
existing `__all__`, so an unfetched or shallow clone still runs everything.

Refs RL-20 / L-14, R-02.

* chore(node): one Node source of truth

`.nvmrc` and all ten `actions/setup-node` pins said 24; five active
documents and the repo's only `engines` block still said 20. A contributor
following the docs installed a version CI does not run.

Node 24 wins — it is what CI already runs. Every manifest now declares
`engines`, and `engine-strict=true` turns a wrong major into a failed
install rather than an `EBADENGINE` warning nobody reads. `>=24` rather
than `^24` so a Node 26 box keeps working; `Client/.nvmrc` stays the
human-facing pin and the docs point at it instead of restating a number.

The `.npmrc` is per package root, not one at the top. npm reads the
project `.npmrc` from the package directory and does not walk parents —
verified with a throwaway package requiring node >=99: with only a parent
`.npmrc` npm warned and exited 0; with one in the package directory it
failed `notsup`. A single root file would have left `Client/`, the package
that matters most, on warnings.

Five docs, not the four previously identified — `docs/mcp-introspect.md`
also said 20. And `docs/contributing.md` claimed "`.nvmrc` + CI both say
Node 20", which was wrong about both.

Verified both directions in all three package roots: Node 22 fails
`notsup`; Node 24 installs clean and `npm ci` passes in Client/.

Refs RL-17 / C-01, ENV-01.

* docs: add the documentation landing page

`docs/` had 24 top-level files and no index. The root README carried a
flat list of 22 links that had drifted: six documents were reachable from
nowhere at all — including both 2026-08-23 audits and the test audit — and
two entries were labelled "latest" while newer unlinked audits existed.

docs/README.md is the index RL-12 asked for. It groups by what a document
*is*, because that is what decides whether to trust it: guidance tells you
how to do something, reference describes a contract the code implements,
audits are dated snapshots nobody updates, plans record intent. Every
tracked file under docs/ now appears exactly once, and the audit table says
plainly that audit-2026-08-19.md still claims "0 open findings" when the
ledger has 38.

The root README keeps a short curated list and defers to the index, rather
than maintaining a second copy that drifts again. Two fixes while there:
`docs/plans/` was linked as a bare directory, unlike its two sibling
directory entries, and was annotated "each carries a verified status
header" — which docs/plans/README.md:7-9 explicitly contradicts, since a
plan's header is exactly the thing that drifts and the index is the
authority.

Verified: 78 relative links across the new and edited files resolve, and
no tracked docs/ file is unreachable from the index.

Refs RL-12 / R-06.

* feat(scripts): root command facade

Entry points existed only inside Server/ (a Makefile) and Client/ (npm
scripts). Nothing at the root told a new contributor where to start, and
the root package.json had three scripts, none of which built or tested
anything.

`npm run check` from the root now runs what CI gates on, and
check:server / check:client / check:rust run one stack. scripts/run.mjs
is dependency-free Node — the shape render-ledger.mjs already uses — so
`npm run check` works before `npm install` has.

Cross-platform by construction: every step is spawned with an explicit cwd
and no shell, so there is nothing to quote and no `cd &&` to behave
differently on Windows. npm and npx get their .cmd suffix there. No step
shells out to make.

The facade orchestrates; it is not a new required path. Each step prints
the command and the directory before running it, and those are exactly the
commands documented per-stack — so a server contributor can read the output
and type them instead, and still never needs Node. Tools CI installs but a
contributor may not have (golangci-lint, which has no wrapper in this repo
at all; sqlc, pinned by Server/sqlc.version) are skipped with a printed
reason rather than failing.

Three corrections to the ci-check skill while aligning it:

- `make sqlc-verify protocol-verify` replaced by what those targets reduce
  to, so the documented path does not require make either.
- `cargo test` -> `cargo test --lib`, which is what ci.yml actually runs.
- "NODE_OPTIONS=--no-experimental-webstorage is mandatory on Node 22+" was
  false. tests/setup.ts installs the shim, CI runs Node 24 without the
  flag, and the suite was measured passing without it — 192 files / 5257
  tests, identical to the flagged run.

Also documents the third RL-20 problem, which needed no code: core.hooksPath
is exclusive, so `npm run hooks:install` silently disables any
.git/hooks/post-commit — including the one `graphify hook install` writes,
which CLAUDE.md tells agents to install. Nothing warned about that.

Verified: check:client 5257/192 green, check:rust 123 tests + clippy green,
--list prints every command, and the optional-tool skip path reports rather
than fails.

Refs RL-04 / L-04, RL-20 / L-14.

* feat(ci): fail on a document that contradicts the findings ledger

G-04's remaining half. The ledger is the source of truth for defect counts,
but nothing stopped a planning document from stating a different number and
nothing noticed when one did. `render-ledger.mjs --check` cannot help: it
validates the JSON schema and returns before rendering, so it never reads
FINDINGS.md and cannot see drift at all — and no workflow ran it anyway.

scripts/check-doc-counts.mjs counts ledger statuses and compares them to
what an allow-list of active documents claims, failing with file, line,
claimed value and actual. Wired into ci.yml as a job with no npm ci, since
the script imports nothing outside node:, and into the facade as
`npm run check:docs` — first in `check`, so a contradicted count does not
wait behind ten minutes of -race.

The patterns are narrow on purpose. A first attempt matched any
"<number> <status>" and flagged nineteen things, all false: "the 45 open P1
rows" (issue-register rows, not ledger findings), "All 8 findings F1-F8"
(a different register), "G-05 **refuted**" (an identifier), `">=20"` and
`CGO_ENABLED=0` (not counts at all). A check that cries wolf gets ignored,
which is the failure G-04 already describes. So a number is only read as a
claim in three shapes that cannot mean anything else: an enumeration of two
or more "<n> <status>" pairs, a status table row in a table that totals
itself, and "<n> records/findings" where the ledger is named within three
lines. Fifteen selftest assertions pin both directions, and the job runs
them before it runs the check.

It reads findings-ledger.json directly rather than importing
render-ledger.mjs for `validate`/`render`: that module ends in a bare
top-level `await main()` with no import.meta.main guard, so importing it
rewrites FINDINGS.md as a side effect.

Dated docs/audit-*.md are reported, never failed — they are snapshots
nobody maintains. audit-2026-08-19.md does claim zero open findings against
38 open, so b0-baseline's "No plan was found claiming '0 open findings'"
holds for docs/plans/ but not for docs/.

Not included: a real FINDINGS.md render-drift check. That is RL-07 and
belongs with the generated-artifact work, not here.

Verified: 27 claims across 9 documents agree; corrupting one count in
docs/plans/README.md fails the check naming that line, for both the status
and the total.

Refs G-04.

---------

Co-authored-by: Claude <noreply@anthropic.com>

* chore: remove graphify knowledge graph tooling (#1413)

The committed knowledge graph and its PreToolUse hooks were steering every
codebase question through `graphify query` before any other tool could run.
Serena (gopls + rust-analyzer + tsserver over MCP) answers the same questions
from real language servers rather than a generated snapshot that goes stale
between rebuilds, so the graph no longer earns the ~20 MB it costs the tree.

Removed:
- `graphify-out/` untracked (7 files, ~20 MB) and now gitignored
- both `graphify hook-guard` PreToolUse hooks from `.claude/settings.json`
- the "Knowledge graph (graphify)" section of `CLAUDE.md`
- the `graphify-out/**` block from `.gitattributes` and `.gitignore`
- the graph-rebuild step from the `bughunt-run` skill, and the graph-edge
  guidance from the bughunt workflow prompt
- the graphify-specific `core.hooksPath` example in `ci-check` and
  `docs/contributing.md`, keeping the underlying warning in generic form

Also deletes the locally installed `post-commit` / `post-checkout` rebuild
hooks (untracked, not part of this diff).

This does not shrink clone size: the graph blobs stay in published history,
which `docs/plans/b1-repository-foundation-2026-08-25.md` explicitly rules out
rewriting. It does stop future refreshes from adding more.

Dated audit and plan documents keep their graphify references as a historical
record of the state they described.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>

* B1-3: repository hygiene gates (RL-19 / L-13, S-05) (#1414)

* chore(format): one Prettier config at the repository root

Every formatting rule in this repository lived under Client/ and covered
exactly two globs: Client/src/**/*.ts and Client/tests/**/*.ts. Root Markdown,
all of docs/, every YAML and JSON, all CSS, the root scripts and
tools/mcp-introspect were formatted by nothing. There was no .editorconfig.

The obvious fix -- a second Prettier config at the root for "everything else"
-- gives two configs and two ignore files that can silently disagree about the
same file. So the root takes ownership instead: config, ignore file and gate
move up, and Client/ folds in. Client's inline "prettier" block, its
.prettierignore, its format/format:check scripts and its now-unused prettier
devDependency are all deleted; knip would have failed client-check on that last
one.

The .prettierrc.json values are lifted byte-for-byte from Client/package.json,
which is what keeps the reformat commit free of client TypeScript churn: 87
tracked files need reformatting and not one of them is under Client/src or
Client/tests.

.prettierignore carries only what .gitignore does not. Prettier 3 reads the
root .gitignore by default, so node_modules/, dist/, coverage/,
Client/src/generated/ and docs/security-findings/ need no entry. It does NOT
read nested .gitignore files, which is why .remember/ is listed explicitly --
38 untracked per-machine scratch files were otherwise able to turn a shared
gate red. graphify-out/ is listed because its seven files are tracked and
.graphify_labels.json is signed byte-for-byte by its .sig, so formatting it
would silently invalidate the signature.

check:hygiene is registered in scripts/run.mjs and folded into check and
release:preflight. It deliberately contains no `gofmt -l` step: gofmt -l prints
offenders and still exits 0, so it cannot fail a build. Go formatting is
enforced separately.

shellcheck and actionlint take their file lists from `git ls-files`, never a
filesystem glob -- .claude/worktrees/ holds a gitignored pre-flatten copy of
the tree with three .sh files a glob would happily lint.

This commit leaves the tree non-conformant on purpose. The reformat is the next
commit, so the 87-file diff is reviewable separately from the rule that caused
it.

Not included: editorconfig-checker. .editorconfig is the editor baseline the
audit asked for; Prettier, gofmt and rustfmt already fail CI on the same
indentation and newline rules, so a fourth tool checking them again is a gate
with no failure mode of its own.

Verified: `npx prettier --check .` names 87 tracked files and zero untracked
ones; the same command listed 38 .remember/ scratch files before the ignore
entry and none after. `node scripts/run.mjs --list` resolves check:hygiene to 8
shell targets and 4 workflow targets. Both package.json files parse.

Refs RL-19 / L-13, S-05.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* chore(format): reformat the tree to the repository Prettier rules

Mechanical. This commit is `npx prettier --write .` and nothing else -- the
rule that caused it landed in the previous commit so this diff can be reviewed
as a transformation rather than as 84 files of hunks.

84 tracked files: 54 Markdown, 7 .mjs, 7 JSON, 6 YAML, 4 .js, 3 CSS, 2
TypeScript (the two Playwright configs at Client's root, which the old
Client/src + Client/tests globs never covered). No file under Client/src or
Client/tests moves, because .prettierrc.json carries Client's former inline
values byte-for-byte.

Prettier rewrote 87 files, not 84. The three in .github/ISSUE_TEMPLATE/ had
CRLF on disk and differ only in line endings, which .gitattributes
(`* text=auto eol=lf`) already normalises, so their committed blobs are
unchanged. Worth knowing before someone reconciles the two numbers.

The largest single diff is .superpowers/findings-ledger.json at 7976 lines
rewritten. That is safe to format: nothing writes the ledger programmatically
-- render-ledger.mjs reads it and writes only FINDINGS.md -- so no tool will
fight Prettier over its style on the next hunt. FINDINGS.md itself is ignored
as generated.

Verified: `npx prettier --check .` reports "All matched files use Prettier code
style", so the pass is both complete and idempotent. All 7 reformatted JSON
files were parsed before and after and compared as values: semantically
identical, zero content changes. `node .superpowers/render-ledger.mjs --check`
still reports 348 valid findings and leaves FINDINGS.md untouched.
`node scripts/check-doc-counts.mjs` still passes its selftest and still agrees
on 27 claims across 9 watched documents -- table realignment did not break the
patterns it matches on. `node scripts/run.mjs --list` still parses.

Refs RL-19 / L-13, S-05.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* chore(lint): enforce Go formatting in the Server linter

S-05: repository-wide Go formatting was not a required gate. The only gofmt
enforcement anywhere was .githooks/pre-commit, which is opt-in per clone
(`npm run hooks:install`), only sees staged files, and warns-and-skips when
gofmt is off PATH.

The obvious fix -- a `gofmt -l` step in CI -- does not work: `gofmt -l` prints
its offenders and still exits 0, so the step passes no matter what it finds.
scripts/run.mjs has the same problem, which is why check:hygiene has no Go step
either.

So gofmt goes where it can actually fail something: Server/.golangci.yml. The
file was already `version: "2"` but had no `formatters:` block at all, so the
19 enabled linters ran with zero formatters. In v2 gofmt/gofumpt/goimports
moved out of `linters.enable` into their own section with its own exclusions.
Adding it there means the gate reports through the Lint step of "Server Build &
Test", which is already pinned as required on dev -- no new job and no new pin.
Every tracked .go file is under Server/ (551 of them, one go.mod), so
Server-scoped is repository-wide here.

One file was genuinely misformatted: a one-space struct field alignment in
Server/admin/handlers_users_broadcast_test.go, fixed in the same commit because
a single line does not need its own reformat commit.

Trap worth recording: `gofmt -l .` on a Windows working tree lists every file
that has CRLF on disk, because gofmt normalises line endings. That reported 18
offenders here, 17 of them ghosts. The blobs are all LF -- .gitattributes
forces `eol=lf` -- so CI never saw them, and the honest test is to run gofmt
over `git show HEAD:<file>` rather than the working copy. Doing that across all
551 tracked Go files found exactly the one real offender above.

Verified both directions with golangci-lint v2 locally: `golangci-lint run
./...` reports 0 issues on the formatted tree; appending a misformatted
function to Server/auth/constants.go produces 2 gofmt findings; appending the
same misformatted function to Server/db/dbgen/admin.sql.go produces 0, so the
exclusion holds. Both files restored and verified clean afterwards.

Refs RL-19 / L-13, S-05.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(scripts): escape the NUL separator instead of embedding one

The `tracked()` helper added earlier in this branch splits `git ls-files -z`
output on NUL. The separator was written as a literal NUL byte rather than the
two-character JavaScript escape, so scripts/run.mjs became a binary file: `git
diff` refused to show it, `grep` reported "Binary file matches" instead of the
line, and `* text=auto` in .gitattributes stops normalising line endings for a
blob it detects as binary.

The code worked -- splitting on a raw NUL and splitting on "\0" are the same
operation -- which is exactly why this is worth fixing before it is inherited.
A source file that tooling classifies as binary is a file nobody can review.

Verified: zero NUL bytes remain, `grep -n "split("` now prints line 50 instead
of "Binary file matches", `node scripts/run.mjs --list` still resolves the same
8 shell and 4 workflow targets, and prettier still reports the file clean.

* chore(lint): enforce Rust formatting

Rust had no formatting gate of any kind: no rustfmt.toml, no `cargo fmt`
anywhere in CI, in scripts/run.mjs, in the Makefile or in the git hooks. Clippy
was the only Rust gate, and clippy does not check layout.

`cargo fmt --all -- --check` now runs in the rust-tests job, ahead of clippy: a
formatting failure is cheap to produce and cheap to fix, and there is no reason
to spend a clippy pass to surface one. The stable toolchain in that job
requested `components: clippy` only, so rustfmt is added there.

Only that job. ci.yml has a second, byte-identical `Install Rust` block in
tauri-build; it stays clippy-only, because a full desktop build is the wrong
place to discover a misplaced brace.

No rustfmt.toml. The default profile is the point of a baseline -- a config
file here would be a second opinion about style with nothing to say.
Client/src-tauri is a single `[package]`, not a workspace, so `--all` is a
safeguard against a future member rather than a fan-out today.

Verified: `node scripts/run.mjs --list` resolves check:rust to three steps with
`cargo fmt --all -- --check` first, `npm run format` now also runs `cargo fmt
--all`, and prettier reports ci.yml, run.mjs and the ci-check skill clean.
`cargo fmt --all -- --check` currently fails on 13 files -- that is the
reformat, and it is the next commit.

Refs RL-19 / L-13.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* chore(format): reformat the Rust crate to rustfmt defaults

Mechanical. This commit is `cargo fmt --all` and nothing else; the gate that
demands it landed in the previous commit so this diff is reviewable on its own.

13 of the 17 tracked .rs files, +343/-164. The crate had never been formatted,
so the changes are the usual first-run set: aligned trailing comments collapsed
to single spaces, single-element slice literals folded onto one line, long
method chains broken across lines, closure bodies expanded into blocks.

Verified: `cargo fmt --all -- --check` is clean, so the pass is complete and
idempotent. `cargo clippy --all-targets -- -D warnings` finishes with no
warnings, and `cargo test --lib` reports 115 passed / 0 failed -- identical to
before the reformat, which is what "mechanical" has to mean for a commit that
touches this much of the crate.

Refs RL-19 / L-13.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(scripts): make the root facade actually run on Windows

Adding the first gate that a contributor would run from the repository root
exposed two bugs in the facade, both of which made it silently wrong on the
platform this project is developed on.

1. Every npm and npx step failed. bin() appends `.cmd` on Windows, but Node
   refuses to spawn a .cmd or .bat with shell:false -- the CVE-2024-27980
   mitigation -- and fails with EINVAL and a *null* exit status. run.mjs only
   special-cased ENOENT, so the result was `FAILED: npx prettier --check .
   exited null` with nothing to explain it. check:client has three npm steps and
   has never been able to run here.

   Fixed by spawning only the npm shims through a shell. They are concatenated
   into a single command string rather than passed as an args array, because
   shell:true plus a separate array is deprecated (DEP0190) and prints a warning
   on every invocation; no argument in this file contains a space.

2. Every optional() step was skipped, always. onPath() shelled out to
   `where` on Windows, but where.exe lives in C:\WINDOWS\System32, which a Git
   Bash PATH does not necessarily contain -- on this machine PATH carries
   System32\Wbem, System32\WindowsPowerShell\v1.0 and System32\OpenSSH but not
   System32 itself. The probe could not start, `probe.status === 0` was false,
   and golangci-lint and sqlc reported as "not installed" while installed.

   Fixed by resolving against PATH and PATHEXT directly. No subprocess, and no
   dependency on which directories happen to be on PATH.

A spawn error other than ENOENT now reports its code instead of surfacing as a
null exit status.

Verified: before, `node scripts/run.mjs check:hygiene` died with "exited null"
and both optional steps printed SKIP with the tools present on PATH. After, the
same command runs prettier, shellcheck and actionlint and prints
"check:hygiene: passed", with no deprecation warning. `golangci-lint` is
detected by the new onPath where the old one missed it.

Refs RL-20 / L-14.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* chore(format): ignore build output that nested gitignores hide

Prettier honours the root .gitignore and no other. Every build and scratch
directory in this repository is ignored by a *nested* one -- Client/.gitignore,
.serena/.gitignore, .superpowers/sdd/.gitignore -- so none of them were
excluded from the new repository-wide gate.

The effect is not subtle. Running `cargo test` once drops roughly 850
formattable files into Client/src-tauri/target/, and the hygiene gate goes from
clean to "Code style issues found in 939 files". CI never sees it, because a
fresh checkout has no build output; every contributor sees it on their second
command.

Mirrors the three nested files rather than inventing a list: dist, coverage,
playwright-report, test-results, .vite, src-tauri/target and src-tauri/gen from
Client/.gitignore, plus .serena/ and .superpowers/sdd/. node_modules needs no
entry -- Prettier ignores it by default.

Verified: `npx prettier --check .` reports "All matched files use Prettier code
style" with a fully populated Client/src-tauri/target/ present on disk, and
still names README.md when a misformatted table is appended to it.

Refs RL-19 / L-13.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* chore(ci): shellcheck, actionlint, and a repository hygiene job

The last two gates RL-19 asks for. Neither existed: the shell scripts were
never linted, the workflows were never syntax-checked, and .githooks/pre-commit
carried hand-written `# shellcheck disable=` directives that nothing had ever
read.

New `hygiene` job, ubuntu-only and root-scoped, modelled on docs-consistency
for the same reason: every gate in it is platform-independent text analysis,
and .gitattributes pins eol=lf so a second OS would only re-prove line endings.
It runs `npm run check:hygiene` -- the same entry point a contributor runs, not
a parallel copy of the commands.

shellcheck ships in the runner image. actionlint does not, so it is pinned by
version and verified by sha256: an installer script piped from a branch would
be the one unverified download in a workflow file that pins every action by
commit SHA.

Prettier's step moves here from client-check, where it no longer belongs.

Both linters found real defects.

shellcheck, 3 findings in 8 scripts. Two are SC1125 errors in
.githooks/pre-commit: `# shellcheck disable=SC2086 - repo paths contain no
spaces` is not a valid directive. Trailing prose makes shellcheck discard the
rest of the line, so neither suppression was ever in effect -- and one of the
two was written earlier in this same branch, which is a fair demonstration of
why the gate is worth having. The prose moves to its own line above. The third
is SC2015 in start-server.sh, rewritten as an explicit if.

actionlint, 5 findings, all inside `run:` blocks it shellchecks once shellcheck
is on PATH. Three SC2015 in load-baseline.yml, rewritten as explicit ifs. Two
SC2035 in release.yml, where `sha256sum *` should not become `sha256sum ./*`:
the comment four lines above records that ParseChecksumFile exact-matches the
last field, so a "./" prefix would strand every deployed server exactly as a
"windows/" prefix would. `sha256sum -- *` satisfies the linter and leaves the
output bytes identical.

Verified all three gates in both directions with shellcheck 0.10.0 and
actionlint 1.7.7 on PATH. Passing: `node scripts/run.mjs check:hygiene` prints
"check:hygiene: passed" with all three steps run, not skipped. Failing:
appending `bait_fn() { cat $1; }` to Server/scripts/voice-test.sh fails on
SC2086; changing a runs-on to `ubunt-latest` fails on runner-label; appending a
misformatted table to README.md fails prettier. All three files restored and
confirmed clean afterwards. actionlint validates the new job in ci.yml itself.

Refs RL-19 / L-13, S-05.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs(plans): record B1 progress through B1-3

The header still read "B1-0 is complete; B1-1 is the next step" three merged
phases later. A plan that misstates where it is costs a reader the same
confusion whether it is stale by one phase or three.

B1-0 (#1410), B1-1 (#1411), B1-2 (#1412) and B1-3 (this branch) are done; B1-4,
dependency automation, is next.

Verified: `node scripts/check-doc-counts.mjs` still agrees on 27 claims across
9 watched documents -- this file is one of them -- and prettier reports it
clean.

* chore(ci): pin Repository Hygiene as a required check on dev

The second half of S-05. Its acceptance criterion is "tree is formatted AND a
fast required gate fails future drift" -- a check that runs but is not pinned
lets a formatting regression merge, so the gate is not a gate until this lands.

The name was read off PR #1414 with `gh pr checks` after the job reported
`pass` in 26s, not copied out of ci.yml. That order matters: the B0 script
records that three pinned names exist in no workflow file at all, and that a
required check which never reports blocks every PR forever.

Extends the existing script rather than adding a second one, per the B1 plan.

Also records, in the "deliberately NOT pinned" list, that Docs & Ledger
Consistency reports and passes on a dev PR yet is unpinned. That reads as an
oversight from the 2026-08-25 pass rather than a decision, but it belongs to
G-04, so it is documented here and not changed.

NOT APPLIED YET. Running this script now would pin a check that PR #1413 cannot
report -- its branch predates the hygiene job, so the job does not exist in its
workflow file and the check would never arrive. Run it after #1414 merges;
#1413 needs a rebase onto dev regardless.

Verified: shellcheck clean, the embedded JSON parses, and `check:hygiene`
passes with prettier, shellcheck and actionlint all running.

Refs S-05, RL-14 / G-03.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>

* B1-4: dependency automation (RL-05 / L-05, RL-18) (#1415)

* chore(deps): cover the root and mcp-introspect npm roots

The repository has three npm package roots — `/` (changelogen, prettier),
`/Client`, and `/tools/mcp-introspect` (@modelcontextprotocol/sdk, zod) —
each with its own package-lock.json, and `npm run bootstrap` runs `npm ci`
in all three. Dependabot watched exactly one of them. The root's prettier is
what the Repository Hygiene gate runs, so the formatting gate's own toolchain
was drifting unwatched.

The obvious fix is to collapse the three roots into an npm workspace and
watch one lockfile. Measured on npm 11.17 / Node 26 rather than assumed, that
trade is bad, and it is bad for different reasons than expected. Workspaces do
not break the things you would predict: `npm ci` inside `Client/` still exits
0, `npm run <script>` still resolves the hoisted binaries because npm prepends
every ancestor `node_modules/.bin` to PATH, and `engine-strict` still fails
the install on a wrong Node major. What they cost is ten CI steps keyed on
`cache-dependency-path: Client/package-lock.json` (six in ci.yml, four in the
tag-only, CI-ungated release.yml) pointing at a file that stops existing; the
Repository Hygiene job's deliberate root-only install growing 970 ms to
6172 ms and 39 to 318 packages unless every call site remembers
`--workspaces=false`; and one shared lockfile putting all three npm Dependabot
groups back into the same file, which is precisely the rebase storm the
grouping comment at the top of dependabot.yml exists to prevent. The measured
benefit is one 298 KB lockfile instead of three (17 KB / 253 KB / 42 KB) and
614 resolved packages deduped to 582 — 32 packages, 5.2% — with client install
time unchanged at 5642 ms against 5667 ms.

So the roots stay separate and each gets its own block, matching the four that
already exist: grouped to one PR, majors ignored, weekly on Monday. A single
block with `directories:` was rejected for the same reason as workspaces —
grouping only works while a group rewrites exactly one lockfile. The decision
and its numbers are recorded in docs/contributing.md under Dependency Policy,
so the next person to propose workspaces reads the measurement instead of
repeating it.

Verified: a coverage checker cross-references every `package-ecosystem` /
`directory` pair in dependabot.yml against every manifest in `git ls-files`,
in both directions. Against dev at 2a37f386 it reports `UNWATCHED npm
package.json` and `UNWATCHED npm tools/mcp-introspect/package.json` (plus
Server/Dockerfile, which the next commit covers). Against this commit both npm
rows read `ok`, and the forward direction confirms each newly declared
directory really holds a package.json. `npx prettier --check` reports both
edited files unchanged, so the B1-3 formatting gate stays green.

Not included: the docker ecosystem (next commit, RL-18); immutable image
digests for release/runtime containers, which is R-04's remaining half and
belongs to B6; and turning the coverage checker into a permanent
`check:hygiene` gate — a new manifest root can still drift unwatched, which is
how this gap arose, but that is new gate machinery rather than the coverage
this item asks for.

Refs RL-05 / L-05

* chore(deps): watch the server container base images

Server/Dockerfile pulls `golang:1.26-bookworm` to build and
`gcr.io/distroless/static-debian12` to run, and nothing watched either. Every
other dependency root in the repository is on a weekly Dependabot schedule, so
the one artefact that ships to users as a whole filesystem was the only one
whose upstream moved silently — including its CA certificates, which the
Dockerfile comment specifically calls out as the reason distroless was chosen
over scratch.

The obvious fix is to pin both images by digest and be done. That is the wrong
move here for two reasons. A digest pin with no automation behind it is worse
than a tag: it freezes the base image at whatever was current the day someone
typed it, and a frozen distroless base is a frozen CA bundle. And digest
refresh for release and runtime images is R-04's other half, scoped to B6
alongside the smoke tests that have to gate it — landing half of it here would
leave the digests pinned and the refresh unowned.

So this adds the `docker` ecosystem for /Server on the same terms as the five
blocks around it: grouped to one PR, majors ignored, weekly on Monday. Be
precise about what that actually buys, because it is less than the block
implies. Of the two images only `golang:1.26-bookworm` carries a comparable
version, so it is the only one Dependabot can act on today;
`gcr.io/distroless/static-debian12` has no version tag, and an untagged image
is not something a version update can move — it needs the digest pinning that
B6 owns. The comment above the block records that the Go builder tag tracks
Server/go.mod and every `actions/setup-go` in CI, so a `1.27-bookworm` PR is a
prompt to move all three together rather than a standalone merge.

Verified: the coverage checker cross-references every `package-ecosystem` /
`directory` pair against every manifest in `git ls-files`, in both directions.
Against dev at 2a37f386 the reverse direction reports `UNWATCHED docker
Server/Dockerfile`; against this commit every row reads `ok` and it exits 0 —
`ALL ROOTS WATCHED`, six blocks covering six manifest roots, with the forward
direction confirming /Server really holds a Dockerfile. `npx prettier --check`
passes on the edited file.

Not included: Server/docker-compose.yml and Server/docker-compose.otel.yml.
Their four images are `ghcr.io/j3vb/owncord-server:latest` (this repository's
own published image), `livekit/livekit-server:v1` (a floating major tag, and
majors are ignored everywhere), `jaegertracing/all-in-one:latest` and
`prom/prometheus:latest` — none of which a version update can move, so a
compose block would be configuration that provably produces nothing. Also not
included: immutable digests plus digest-refresh PRs with smoke tests for the
release and runtime images, which is the remainder of R-04 and belongs to B6.

Refs RL-18

* docs: apply skill-review findings to ci-check and the project skills (#1416)

The observation log had accumulated 46 open entries against a last review of
2026-08-14. Seven of them target skills tracked in this repository and were
verified still-unapplied against the current files.

`ci-check` gains four things it was missing. It never mentioned `cargo audit`,
which CI runs pinned at 0.22.1 in `tauri-build` — the one gate that turns red
with zero local changes, because an upstream advisory breaks a branch that was
clean yesterday, and the one a hand-written mirror silently drops because no
edit provokes it. It never mentioned that `release.yml` is tag-triggered and
PR-ungated, so a smoke/sign/strip step added only there first executes on the
release; #1376 shipped a smoke harness whose own bug then blocked a release,
and #1378 fixed it structurally by extracting `Server/scripts/docker-smoke.sh`
for both workflows. And it had no guidance for reading a red check at all: a
new section adds causality-before-forensics triage (diff the changed-file set
against the failing job's input surface before opening a log — a workflow-only
diff cannot cause a Go goroutine leak), the lockfile-fork diagnosis for
dependency bumps (a 1 → 2 entry-count transition means the update forked the
dependency and revoked the features it was borrowing, so aligning versions is
the fix, not setting the feature the new copy demands), and the known-flake
table promoted to a signature-to-recovery index, now including the apt-mirror
hang that cancels `tauri-build` by timeout.

The baseline rule that came with the triage section needed adjusting rather
than transcribing. Its source observation recorded `golangci-lint`'s known-red
complexity baseline as 23 cyclop / 6 dupl / 21 funlen / 12 nestif; #1389
cleared that to zero, so quoting those numbers would have taught the reader to
excuse a failure that is now genuinely theirs. The rule is recorded without
them, stating that the repo currently carries no known-red gate and what to do
if one is ever reintroduced.

`protocol-change` claimed the schema is the source of truth without saying what
it covers. It holds message-type names only, so a payload-field change touches
the Go command/message files, the client types and `docs/protocol.md` and never
the schema — routing one through the regenerate cycle is wasted work. A table
splits the three cases, with the relay-handler caveat: a server that
re-serialises drops unknown fields, so a forwarded field is not backward
compatible with older servers.

`task-observer`'s numbering discipline treated collisions as a parallel-human
accident. They are structural in fan-out workflows, because a dispatched
subagent has the skill active in its own context and writes to the same log.

`bughunt-run` covered findings blocked by a circuit breaker but not findings
that went stale: a later hunt routinely fixes a blocked finding as a side
effect of an overlapping sibling, and a saved debris patch stops applying once
a refactor rewrites its files. Of 6 findings blocked on 2026-08-14, 2 were
already fixed 5 days later.

`docs/contributing.md` gains the commit-body convention that was being followed
without being written down anywhere — reasoning over diff-restatement, a
`Verified:` paragraph proving both directions, and an explicit `Not included:`
line. That last one is what keeps adjacent scope from becoming either silent
drift or an unnecessary blocking question.

Verified: each edit was checked against the live file before applying, which
changed two outcomes. Observation 50 (make the hunt's stop rule measure
coverage, not just quietness) is already implemented — `bughunt-run` documents
`coverage + dry is the real stop`, `stalledCoverage` and
`coverage.uncoveredAtStop`, landed by #1399 — so it is marked actioned rather
than re-applied. Observation 42 looked covered by the same grep and was not:
the existing text handles breaker-blocked findings, a different case from a
finding a sibling fix already closed. Confirmed absent before editing:
`cargo audit` and `release.yml` in ci-check, `payload` in protocol-change,
`subagent` in task-observer. `npm run check:hygiene` passes (prettier clean on
all five files); `npm run check:docs` passes.

Not included: the 21 open observations targeting `superpowers:*` plugin skills,
which live in a versioned plugin cache and…

* chore(deps): bump the npm-dependencies group across 1 directory with 3 updates (#1428)

Bumps the npm-dependencies group with 3 updates in the /Client directory: [eslint](https://github.com/eslint/eslint), [oxlint](https://github.com/oxc-project/oxc/tree/HEAD/npm/oxlint) and [typescript-eslint](https://github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/typescript-eslint).


Updates `eslint` from 10.9.0 to 10.9.1
- [Release notes](https://github.com/eslint/eslint/releases)
- [Commits](https://github.com/eslint/eslint/compare/v10.9.0...v10.9.1)

Updates `oxlint` from 1.79.0 to 1.80.0
- [Release notes](https://github.com/oxc-project/oxc/releases)
- [Changelog](https://github.com/oxc-project/oxc/blob/main/npm/oxlint/CHANGELOG.md)
- [Commits](https://github.com/oxc-project/oxc/commits/oxlint_v1.80.0/npm/oxlint)

Updates `typescript-eslint` from 8.67.0 to 8.68.0
- [Release notes](https://github.com/typescript-eslint/typescript-eslint/releases)
- [Changelog](https://github.com/typescript-eslint/typescript-eslint/blob/main/packages/typescript-eslint/CHANGELOG.md)
- [Commits](https://github.com/typescript-eslint/typescript-eslint/commits/v8.68.0/packages/typescript-eslint)

---
updated-dependencies:
- dependency-name: eslint
  dependency-version: 10.9.1
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: npm-dependencies
- dependency-name: oxlint
  dependency-version: 1.80.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: npm-dependencies
- dependency-name: typescript-eslint
  dependency-version: 8.68.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: npm-dependencies
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* ci(deps): bump anthropics/claude-code-action (#1429)

Bumps the actions-dependencies group with 1 update in the / directory: [anthropics/claude-code-action](https://github.com/anthropics/claude-code-action).


Updates `anthropics/claude-code-action` from 1.0.200 to 1.0.206
- [Release notes](https://github.com/anthropics/claude-code-action/releases)
- [Commits](https://github.com/anthropics/claude-code-action/compare/24dcd50c0568f0fc9e9211213a4fd2d9eb15c4e0...1f291e1cfe0f5fc21db2aef19af844591600ade7)

---
updated-dependencies:
- dependency-name: anthropics/claude-code-action
  dependency-version: 1.0.206
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: actions-dependencies
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* chore(deps): bump golang from 1.26-bookworm to 1.27-bookworm in /Server in the docker-dependencies group across 1 directory (#1427)

* ci(deps): bump anthropics/claude-code-action (#1404)

Bumps the actions-dependencies group with 1 update: [anthropics/claude-code-action](https://github.com/anthropics/claude-code-action).


Updates `anthropics/claude-code-action` from 1.0.193 to 1.0.199
- [Release notes](https://github.com/anthropics/claude-code-action/releases)
- [Commits](https://github.com/anthropics/claude-code-action/compare/9d7150bc8a3dae8149739a88019d192b579ad90c...dcb57747bfceeaa1fa72638cae52295d1d853d4a)

---
updated-dependencies:
- dependency-name: anthropics/claude-code-action
  dependency-version: 1.0.199
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: actions-dependencies
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* build(deps): pin rfd to tauri-plugin-dialog's major to unblock the cargo group (#1406)

The cargo-dependencies group PR (#1405) fails Rust Unit Tests on Linux:

    error: failed to run custom build command for `rfd v0.17.2`
    You need to choose at least one backend: `gtk3` or `xdg-portal`
    features for x86_64-linux

rfd is not really ours. It arrives in the tree via tauri-plugin-dialog,
which pins ^0.16; we declare it directly only for the fatal-startup
message box in lib.rs, where the Tauri app never finished building and
the plugin has no AppHandle to run a dialog through.

Cargo unifies features only within a semver-compatible version group, so
while both wanted ^0.16 there was a single rfd in the graph and the
plugin's backend features covered our `default-features = false`
declaration too. Bumping our direct dep to 0.17 forks rfd into two
crates: the plugin keeps 0.16.0 with its features, ours resolves to
0.17.2 with none, and rfd 0.17 added a build.rs assertion that aborts
the Linux build when no backend feature is set. Confirmed in the PR's
lockfile, which carries both 0.16.0 and 0.17.2.

Adding a Linux backend feature would be the wrong fix: it would paper
over the fork and still build rfd twice on every platform for one error
dialog. Our version has to track the plugin's instead, so ignore
semver-minor rfd updates (0.16 -> 0.17 for a 0.x crate) until
tauri-plugin-dialog moves. Patch updates inside 0.16.x still flow.

The remaining five crates in the group are unaffected; `windows` in fact
consolidates 3 versions down to 2.

Cargo.toml is comment-only here - no dependency, feature, or lockfile
change - so the build is untouched.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>

* chore(deps): bump log (#1407)

Bumps the cargo-dependencies group with 1 update in the /Client/tauri-client/src-tauri directory: [log](https://github.com/rust-lang/log).


Updates `log` from 0.4.33 to 0.4.34
- [Release notes](https://github.com/rust-lang/log/releases)
- [Changelog](https://github.com/rust-lang/log/blob/master/CHANGELOG.md)
- [Commits](https://github.com/rust-lang/log/compare/0.4.33...0.4.34)

---
updated-dependencies:
- dependency-name: log
  dependency-version: 0.4.34
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: cargo-dependencies
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* ci(deps): bump anthropics/claude-code-action (#1408)

Bumps the actions-dependencies group with 1 update: [anthropics/claude-code-action](https://github.com/anthropics/claude-code-action).


Updates `anthropics/claude-code-action` from 1.0.199 to 1.0.200
- [Release notes](https://github.com/anthropics/claude-code-action/releases)
- [Commits](https://github.com/anthropics/claude-code-action/compare/dcb57747bfceeaa1fa72638cae52295d1d853d4a...24dcd50c0568f0fc9e9211213a4fd2d9eb15c4e0)

---
updated-dependencies:
- dependency-name: anthropics/claude-code-action
  dependency-version: 1.0.200
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: actions-dependencies
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* release: v1.2.0-alpha.4 — 62 fixes plus the B0/B1 repository foundation (#1426)

* Fix 27 findings from 2026-08-21 bug hunt (#1400)

* chore(findings): record 2026-08-21 bug hunt (38 findings)

* fix(api): 1 defect(s) (OC-0240)

* fix(client): 1 defect(s) (OC-0241)

* fix(plugin): 2 defect(s) (OC-0243, OC-0265)

* fix(client): 3 defect(s) (OC-0244, OC-0256, OC-0259)

* fix(client): 1 defect(s) (OC-0247)

* fix(client): 2 defect(s) (OC-0248, OC-0258)

* fix(identity): 1 defect(s) (OC-0250)

* fix(ws): 3 defect(s) (OC-0252, OC-0269, OC-0272)

* fix(admin): 1 defect(s) (OC-0253)

* fix(client): 1 defect(s) (OC-0254)

* fix(voice): 1 defect(s) (OC-0255)

* fix(ws): 1 defect(s) (OC-0260)

* fix(client): 1 defect(s) (OC-0261)

* fix(client): 1 defect(s) (OC-0262)

* fix(client): 1 defect(s) (OC-0263)

* fix(client): 1 defect(s) (OC-0264)

* fix(client): 1 defect(s) (OC-0268)

* fix(ws): 1 defect(s) (OC-0273)

* fix(service): 1 defect(s) (OC-0275)

* style: satisfy golangci-lint and prettier on 2026-08-21 fix commits

- drop ineffectual backupDir reset before return (registry.go, OC-0265)
- reflow long boolean expression (attachments.ts, OC-0241)

* fix(client): 4 defect(s) (OC-0242, OC-0246, OC-0249, OC-0251)

* fix(voice): 1 defect(s) (OC-0267)

* fix(admin): 1 defect(s) (OC-0274)

* fix(voice): 1 defect(s) (OC-0245)

* fix(ws): 1 defect(s) (OC-0271)

* fix(voice): 2 defect(s) (OC-0239, OC-0257)

* fix(ws): 1 defect(s) (OC-0266)

* fix(voice): 1 defect(s) (OC-0270)

* style: clear golangci-lint modernize and prettier nits from 2026-08-21 fixes

- range-over-int and slices.Contains modernizations in new Go test files
- prettier reflow in dispatcher.ts

* chore(findings): mark 2026-08-21 hunt findings fixed/declined

37 fixed across the fix waves, OC-0238 declined (LiveKit webhook TLS
requires a product decision, not a mechanical patch).

---------

Co-authored-by: Claude <noreply@anthropic.com>

* fix: 35 findings from the 2026-08-22 bug hunt (#1402)

* fix(voice): 1 defect(s) (OC-0277)

* fix(voice): 1 defect(s) (OC-0278)

* fix(client): 1 defect(s) (OC-0280)

refreshDmSidebar() rebuilds the entire DM sidebar subtree on every
dmStore.channels change - which includes presence flips and new
messages, not just DM list changes. The "Find a conversation" filter
text and input focus live only in that destroyed subtree, so they were
silently wiped mid-typing. Capture and restore both across the
destroy+recreate cycle.

* fix(ws): 1 defect(s) (OC-0285)

* fix(client): 1 defect(s) (OC-0286)

* fix(client): 1 defect(s) (OC-0288)

Consume the legacy unscoped mute key after migrating it onto the first
host, so a brand-new host with no scoped key of its own no longer reads
through to the same legacy list and inherits another server's mutes.

* fix(voice): 1 defect(s) (OC-0290)

* fix(db): 1 defect(s) (OC-0293)

DecrementMentionCounts reversed mention_count bumps that were never
applied: message_mentions stores every resolved mention id including the
author's blockers, while applyMentionCounts excludes blockers before
incrementing. Deleting a blocked author's message therefore wiped an
unrelated, genuine mention badge on the same read_states row. Mirror the
block exclusion in the decrement UPDATE.

* fix(db): 1 defect(s) (OC-0294)

DeleteAccount soft-deletes the departing user's messages but never reversed the read_states.mention_count bumps those messages made, leaving phantom mention badges. Reverse them inline in the existing transaction, mirroring DecrementMentionCounts' guards.

* fix(client): 1 defect(s) (OC-0295)

MemberList rebuilt every row on any non-presence-only membersStore change
and on every roles_update, but registered each row's click/contextmenu
listeners on the component-lifetime disposable.signal, which only aborts
at destroy(). Discarded rows therefore stayed reachable (and their
listeners live) for the component's whole lifetime. Route per-row
listeners through a per-render AbortController that is aborted and
replaced at the top of every render, and aborted again in destroy().

* fix(identity): 1 defect(s) (OC-0297)

UpdateProfile's post-commit re-read of the user row could fail for reasons
unrelated to context cancellation (SQLITE_BUSY, I/O error, pool exhaustion)
and was reported as ErrInternal even though UpdateUserProfile had already
committed. Callers that treat any UpdateProfile error as proof the write
never landed — handleUploadAvatar deletes the file it just stored — would
delete a file the committed avatar column now points at, permanently
breaking the avatar with no user_update broadcast.

Since UpdateUserProfile only writes username/avatar/display_name/about,
merge those four onto the pre-write snapshot to reconstruct the committed
row without needing the re-read to succeed, and log the read failure.

* fix(ws): 2 defect(s) (OC-0298, OC-0299)

- OC-0298: applyConnectStatus stamped c.user.Status even when the
  UpdateUserStatus write failed, so auth_ok and the presence broadcast
  claimed a status users.status disagreed with, and buildReady's
  ListMembers read never self-corrected for the session.
- OC-0299: refreshUserSnapshot silently fell back to roleName "member"
  when the new role lookup failed, pinning the session to a fabricated
  role on the wire. It now fails closed like the sibling lookups in
  upgradeAndAuth and handleFreshConnect.

* fix(client): 1 defect(s) (OC-0300)

* fix(client): 1 defect(s) (OC-0301)

* fix(ws): 1 defect(s) (OC-0302)

* fix(api): 1 defect(s) (OC-0305)

handleDiagnosticsConnectivity used clientIP(r), ignoring cfg.Server.TrustedProxies, so behind a configured trusted reverse proxy the endpoint reported the proxy hop instead of the real client address. Use clientIPWithProxies with the parsed trusted-proxy nets, matching RateLimitMiddleware on the same route.

* fix(client): 2 defect(s) (OC-0306, OC-0308)

* fix(client): 1 defect(s) (OC-0307)

QuickSwitcher registered a per-row click listener against the
overlay-lifetime AbortSignal, but renderResults() rebuilds every row on
each keystroke, arrow key, and store refresh. Discarded rows kept their
listeners alive until the overlay closed. Replaced with one delegated
click listener on the stable results container, keyed off the
data-channelid each row already carries.

* fix(client): 1 defect(s) (OC-0310)

* fix(server): 3 defect(s) (OC-0279, OC-0291, OC-0292)

Reap a soft-deleted message's attachment files, count lapsed temporary
bans as active users in the require_2fa enrollment gate, and only apply
the 2FA-enrollment precondition when require_2fa itself is being enabled.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SdkJRbjCtrG76jEnrhKbYo

* test(api): sync apiTestSchema with the user_blocks migration

DeleteAccount's mention-count reversal joins user_blocks; the api
package's hand-rolled schema fixture predates migration 012.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SdkJRbjCtrG76jEnrhKbYo

* fix(client): 3 defect(s) (OC-0281, OC-0282, OC-0296)

Decouple the E2EE identity-mismatch modal and right-click popovers from
the sidebar's per-render abort signal, and let global drag listeners
survive a mid-drag re-render.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SdkJRbjCtrG76jEnrhKbYo

* fix(voice): 2 defect(s) (OC-0283, OC-0287)

Retire a departed peer's E2EE key unconditionally on leave, and surface
a failed microphone unmute instead of reporting an unmuted state the
room never saw.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SdkJRbjCtrG76jEnrhKbYo

* fix(client): 3 defect(s) (OC-0289, OC-0303, OC-0309)

Guard the DM call button against redialing the channel already joined,
resolve the incoming-call banner's caller through the nickname-aware
display name, and keep the DM profile sidebar subscribed to live
member/status updates.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SdkJRbjCtrG76jEnrhKbYo

* style(client): prettier-format the dm-store test

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SdkJRbjCtrG76jEnrhKbYo

* fix(server): 1 defect(s) (OC-0284)

Make message soft-delete a compare-and-set so a repeated chat_delete
cannot reverse mention counts twice.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SdkJRbjCtrG76jEnrhKbYo

* fix(server): 2 defect(s) (OC-0276, OC-0304)

Re-sync a resumed connection's voice E2EE peer keys in registerNow
(announce frames are unsequenced and cannot be replayed), and apply the
live-connection presence rule to every DM payload DMService builds.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SdkJRbjCtrG76jEnrhKbYo

* chore(ledger): record the 2026-08-21 hunt findings as fixed

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SdkJRbjCtrG76jEnrhKbYo

* chore(ledger): independent revert-proof pass for OC-0276..OC-0310

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SdkJRbjCtrG76jEnrhKbYo

* refactor(service): extract DeleteMessage authorization into a helper

Keeps DeleteMessage under the cyclop complexity ceiling after the
OC-0284 guard.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SdkJRbjCtrG76jEnrhKbYo

---------

Co-authored-by: Claude <noreply@anthropic.com>

* chore(ledger): record the 38 open findings from the 2026-08-22 hunt (#1403)

Claude-Session: https://claude.ai/code/session_01SdkJRbjCtrG76jEnrhKbYo

Co-authored-by: Claude <noreply@anthropic.com>

* chore(graphify): refresh knowledge graph

* fix: close the three B0 P0 gates and record a measured baseline (#1409)

* chore(security): stop tracking the private security-finding reports

docs/security-findings/ holds detailed reports for defects that are not yet
fixed. The directory was untracked but not ignored, so any 'git add .' would
have published seven unfixed vulnerability traces to a public repository.

Findings are coordinated through private GitHub Security Advisories
(docs/security.md); only opaque identifiers and safe status belong in tracked
plans.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(client): repair the two red P0 unit contracts (G-01, G-02)

G-02: noise-suppression-restart stubbed MediaStream with
vi.fn().mockImplementation(arrow), which is not constructible. Vitest 4 threw
'is not a constructor' at the new MediaStream([inputTrack]) call in
noise-suppression.ts before reaching any assertion. Replaced with a real
class; the OC-0277 assertions are unchanged.

G-01: message-list's OC-0217 guard was inverted, not merely stale. It spied on
AbortSignal.prototype.addEventListener and asserted zero abort registrations,
but the leak it names registered row listeners via
element.addEventListener(..., { signal }) — a path that never calls that
prototype method. Measured: the leak produces 0 registrations (test passes),
while the OC-0286 fix rotates a per-window AbortSignal.any and produces 5
across 5 distinct signals (test fails). The guard passed on the bug and failed
on the fix.

It now captures the signal each window's row listeners register against and
asserts the invariant its name always claimed: one signal per rendered window,
a fresh signal per jump, and every superseded window already aborted with
exactly one live. Verified both directions — green on the fix, and
'expected 1 to be 5' with beginRowRender() reverted to rowSignal = ac.signal.

Client suite: 5257 passed, 0 failed (was 5255 passed, 2 failed).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(client): make the Playwright suite terminate

The runner finished every test and then never exited, printing no summary — so
the failure read as 'tests never finish' when it was 'process never exits'.
getActiveResourcesInfo() at hang time showed a live ProcessWrap plus several
PipeWrap: the Vite dev server was still running. Playwright's webServer
teardown does not kill it here.

Measured, full suite each time:

  npm run dev                        hangs, tests pass
  node node_modules/vite/bin/vite.js hangs, tests pass
  reuseExistingServer: false         hangs, tests pass
  gracefulShutdown SIGTERM/3s        hangs, tests pass
  npx vite                           exits, 290 of 293 FAIL
  no webServer (pre-started)         exits, 293 pass in 33s

npx only appears to fix it: npx exits once Vite is up, Playwright reads that as
the server dying and tears the group down mid-run, so later tests get
ERR_CONNECTION_REFUSED.

globalTeardown now kills the process listening on the dev port, releasing the
runner's handle. The webServer command spawns Vite's entry point directly so
the listening process is Playwright's own child — via 'npm run dev' the npm
process would still hold the handle open. It also reaps servers orphaned by an
interrupted run, which reuseExistingServer would otherwise silently adopt.

An earlier revision used netstat, which is not on PATH in every shell here; the
swallowed ENOENT made the fix look applied while the hang persisted. It now
uses PowerShell on Windows and lsof elsewhere, and warns on failure rather than
failing silently.

npm run test:e2e: exit 0, 293 passed, 37s, reproducible, no orphan listener.
playwright.config.prod.ts carried the same npm-wrapper shape.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* chore(client): align .nvmrc with the Node version CI uses

Three versions were in play, not two: .nvmrc said 20, CI pins 24, and the
machine the audit was measured on runs 26. A baseline measured against .nvmrc
is not the baseline CI produces, which defeats the point of B0.

Scoped to .nvmrc only. The full single-source-of-truth work — package engines,
contributor docs, release — stays in B1 (RL-17 / C-01).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs(plans): add the beta audit set and the B0 baseline

The 2026-08-23 audit set has been sitting untracked: repository-health and
repository-layout audits, beta product requirements, requirement traceability,
the issue register, and the B0-B10 roadmap. They are the plan of record for
beta and belong in the repository.

Adds b0-baseline-2026-08-25.md, which supersedes the roadmap's 'current
evidence snapshot'. Every row is marked measured or carried, so nothing is
inherited silently. It also records three audit claims that did not survive
verification:

  - G-01 was an inverted guard, not a stale assertion — it passed on the bug
    and failed on the fix.
  - The Playwright hang matched none of the three hypotheses; the runner could
    not kill its own dev server.
  - The golangci-lint toolchain failure is refuted: 19 linters run, 0 issues,
    verified with -v to rule out the known zero-linters false-green.

Adds b0-dev-branch-protection.sh, which records the applied dev branch
protection and the reasoning behind each setting.

Security detail stays private: the register carries only opaque SEC-* families
and safe closure criteria, per the roadmap's public/private handling policy.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* chore(graphify): refresh the knowledge graph

Own commit, per CLAUDE.md — the graph payload does not belong in the diff of
the changes that triggered it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs(plans): add the active-plan index and fix a stale status header (G-04)

Planning documents had no recorded state, so a reader could not tell current
guidance from shipped history. docs/plans/README.md now indexes every plan as
active, partially implemented, design-only, or shipped, and names the source of
truth for each concern so a defect count is never read out of a plan.

Status is recorded in the index rather than by moving or rewriting the
historical plans, so links from audits and commit messages keep resolving.

One real stale claim found and fixed: audit-2026-08-19-remediation.md still
read 'in progress 2026-08-19' while its own phase table showed phases 1-6 done
2026-08-20 (merged 03fcb7d5, PR #1396) with only phase 7 pending. The header
had drifted because the table was updated in place and the header was not.
No plan was found claiming '0 open findings'.

Also records the Step 8 staleness pass in the B0 baseline: all 38 open OC
records still resolve to a live file:line at this commit, so none is superseded
by later work. Adjudicating them individually is bughunt-fix work, not B0.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Update graph output files and manifest with new metadata

- Updated graph.html and graph.json with new binary data.
- Modified manifest.json to reflect changes in file modification times and AST hashes for several documents.
- Added new entry for README.md in the manifest with its corresponding metadata.

* docs(plans): close the Docker and coverage leftovers in the B0 baseline

Docker smoke: measured and passing. Image builds at 50.1 MB and boots on :8443
with TLS; docker-smoke.sh exits 0.

Server coverage: re-measured at 74.6% aggregate, confirming the figure carried
from the audit rather than continuing to inherit it.

Two findings from doing it:

ENV-03 — docker-smoke.sh cannot be run from Git Bash on Windows. MSYS path
conversion rewrites the container-internal /chatserver into
'C:/Program Files/Git/chatserver', so docker exec fails 127 and the script
reports 'container never reported healthy within 30s' — indistinguishable from
a real boot regression. MSYS_NO_PATHCONV=1 makes the same script pass. CI is
Linux and unaffected, but Windows is an official contributor platform (RL-20).

The CI Docker job is gated on main, so it is skipped for any PR targeting dev
— a dev-targeted change cannot get Docker evidence from CI at all.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* chore(graphify): refresh the knowledge graph

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs(plans): B1 execution plan, and accept HP-0 (#1410)

* docs(plans): add the B1 repository-foundation execution plan

B1 is the isolated layout/contributor phase. This records the execution
order, the proof for each step, and what is out of scope.

Two findings worth surfacing before any B1 work starts:

- HP-0 was never formally accepted. The roadmap's B1 entry gate requires
  it; no scorecard artifact exists, no commit or document records an
  acceptance, and the B0 baseline still lists "Step 10: HP-0 sign-off"
  under "Not yet done in B0". The plan lists the five gaps that closing
  it requires, including pinning required status checks on dev -- which
  are still unset, so a dev PR can currently merge red.

- Several layout-audit claims do not survive verification against HEAD,
  matching the B0 pattern. RL-09's "no single command verifies both
  protocol consumers" is false (make protocol-verify does, and is
  enforced in CI, the pre-commit hook, and a contract test). RL-10's
  test-discovery side effect never fires (no _test.go in Server/scripts).
  RL-06's regeneration concern is refuted locally. RL-08 grows a
  toolchain constraint instead. RL-05, RL-07, RL-20 and RL-21 are each
  worse than written -- RL-20 includes a live bug where a missing `make`
  is reported as stale protocol constants.

The riskiest item, RL-01 (flatten Client/tauri-client into Client), gets
a full reference inventory and a mechanical proof for both commits: tree-
object equality for the pure move, and scripted-substitution replay for
the path rewrite. Release asset names and updater contracts are verified
independent of the directory name, so the move cannot rename an artifact.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs(plans): correct the B1 status-check pin list from a live dev PR

The list was derived from ci.yml. Observing PR #1410's actual checks
found three that exist in no workflow file -- Analyze (go),
Analyze (javascript-typescript), Analyze (actions) -- because CodeQL
runs from GitHub default setup, configured in repository settings.
Reading .github/ alone misses them.

Also confirms the two negative predictions against a real dev-targeted
PR: Server Docker Build (verify) reports as "skipping", and Tauri Full
Build never appears in the check list at all. Neither may be pinned.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs(plans): accept HP-0 and pin the dev required status checks

Closes B1's entry gate. All five B1-0 items are done.

The scorecard is the artifact the hold point asks for: one place that
answers its four questions, records what was accepted as a stated
limitation rather than claimed green, and part-closes R-08.

Required status checks are now pinned on dev -- ten of them. That was
B0's one outstanding step. Two things came out of doing it:

- The names cannot be inferred from ci.yml. Three of the ten (the
  Analyze jobs) exist in no workflow file, because CodeQL runs from
  GitHub default setup configured in repository settings. They were read
  off a live dev-targeted PR with `gh pr checks`.
- Server Docker Build, Tauri Full Build and the CodeQL aggregate are
  deliberately excluded. The first two report "skipping" on a dev PR --
  Tauri Full Build under its unexpanded matrix name, since the job is
  skipped before matrix expansion. Admin Panel E2E is excluded because
  continue-on-error makes it report success unconditionally.

Two prior claims are corrected rather than left to propagate:

- b0-dev-branch-protection.sh was written assuming repository-settings
  writes are blocked from the agent sandbox. They are not; the PUT
  succeeded. The script stays as the record of intent and the way to
  re-apply or undo.
- An earlier revision of the B1 plan said Tauri Full Build does not
  appear in a dev PR's check list at all. It does, as skipping.

Evidence closed out:

- Rust is no longer a carried row. Re-measured: 115 passed, cargo clippy
  --all-targets -- -D warnings at exit 0, confirming the carried figure.
- The 38 open ledger records are accepted as counted, non-stale and
  assigned: 11 medium / 27 low, zero high or critical, zero dead paths
  across all 348 re-verified at this commit, and none assigned to B1.
- The private security review is reconciled: 7 findings, 7 of 7 mapped
  to existing public rows, 0 unmapped. Summary is content-free; the
  detail stays in the untracked private reports.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>

* refactor: flatten Client/tauri-client into Client (B1-1) (#1411)

* refactor: move Client/tauri-client to Client (pure move, no content change)

* refactor: re-point paths after the Client flatten (mechanical, no behaviour change)

---------

Co-authored-by: Claude <noreply@anthropic.com>

* B1-2: truth, entry points, and contributor path (#1412)

* fix(hooks): guard on the command the hook actually runs

pre-commit probed one binary and invoked another. The protocol block
guarded on `command -v go` and then ran `make protocol-verify`; the sqlc
block guarded on `command -v sqlc` and ran `make sqlc-verify`. `make` is
not on PATH on a stock Windows box, so a contributor with Go installed
but no make had their commit rejected with

    pre-commit: FAIL: protocol constants are stale — run 'make
    protocol-generate' in Server/ and stage the result

when nothing had been generated and nothing compared. The real cause was
`make: not found`, and the advice the message gives fails the same way.

Rather than add a `command -v make` guard, inline what the two Makefile
targets reduce to — `sqlc generate` / `go run ./scripts/genprotocol`
followed by `git diff --exit-code`. Same semantics, one less prerequisite,
and it doubles as the make-free equivalent B1-2 asks for. The Makefile
targets stay for anyone who prefers them.

Also: the protocol block was the only one with no `else`, so a
contributor without Go got no check and no notice. It now warns like its
two siblings. And gofmt is a separate binary from go, so it is probed
separately.

Verified both directions with the hook body replayed verbatim:
- Go present, make absent -> passes, no staleness claimed.
- schema edited without regenerating -> fails, as it must.

Refs RL-20 / L-14.

* docs: state one branch and PR model

Active documents contradicted each other head-on. README.md and
docs/contributing.md said branch from `dev` and target `dev`; CLAUDE.md
said branch from `main`, PR to `main`. That is R-02, and the 2026-08-19
audit had already recorded it as D-06 without it being resolved.

`dev` is the answer, and the repository already behaves that way: B0 made
`dev` PR-only with ten required checks enforced on admins, and #1409,
#1410 and #1411 all landed there. `main` carries releases.

docs/contributing.md becomes the single source of truth. It now states the
model, what protection is actually applied, and the two consequences a
contributor meets on their first PR — that a self-mergeable PR still cannot
merge red, and that Docker and Tauri Full Build report as skipped against
`dev` rather than failing. Everywhere else summarises and links here.

- CLAUDE.md: corrected, with a link rather than a second copy.
- CONTRIBUTING.md: new. GitHub's contributing-guidelines affordance only
  resolves the root, .github/ or docs/ — `docs/contributing.md` is not a
  path it finds, so the link never appeared on issues or PRs.
- PULL_REQUEST_TEMPLATE.md: names the base branch, which it did not.
- bughunt-run skill: reviewed the branch against `origin/main`, which is
  the wrong base once every PR targets `dev`.

README.md already said `dev` and is left as the short summary it should be.
Dated audits and the historical remediation plan keep their `main`-era
wording — they are records.

Refs R-02.

* fix(hooks): pick the pre-push base from the nearest integration branch

pre-push decided which side's gates to run from
`git diff --name-only origin/main...HEAD`. That was right when everything
targeted `main`. Once `dev` became the integration branch it stopped being
right: a branch cut from `dev` diffed against `main` counts everything on
`dev` and not yet on `main` as "changed".

Measured on this branch: the old base reported 609 changed files, the new
one reports 6. So in practice the hook was running the full server build
matrix and the client typecheck plus eslint on every push, whatever the
change touched — the file-based narrowing it exists for never engaged.

Now it picks whichever of origin/dev, origin/main is nearest, by commits
between merge-base and HEAD, skipping a candidate that scores 0. Verified:
a branch cut from dev picks origin/dev (2 ahead); dev itself scores 0
against dev and picks origin/main (8 ahead), which is what a dev -> main
release PR wants. With no candidate resolvable it falls back to the
existing `__all__`, so an unfetched or shallow clone still runs everything.

Refs RL-20 / L-14, R-02.

* chore(node): one Node source of truth

`.nvmrc` and all ten `actions/setup-node` pins said 24; five active
documents and the repo's only `engines` block still said 20. A contributor
following the docs installed a version CI does not run.

Node 24 wins — it is what CI already runs. Every manifest now declares
`engines`, and `engine-strict=true` turns a wrong major into a failed
install rather than an `EBADENGINE` warning nobody reads. `>=24` rather
than `^24` so a Node 26 box keeps working; `Client/.nvmrc` stays the
human-facing pin and the docs point at it instead of restating a number.

The `.npmrc` is per package root, not one at the top. npm reads the
project `.npmrc` from the package directory and does not walk parents —
verified with a throwaway package requiring node >=99: with only a parent
`.npmrc` npm warned and exited 0; with one in the package directory it
failed `notsup`. A single root file would have left `Client/`, the package
that matters most, on warnings.

Five docs, not the four previously identified — `docs/mcp-introspect.md`
also said 20. And `docs/contributing.md` claimed "`.nvmrc` + CI both say
Node 20", which was wrong about both.

Verified both directions in all three package roots: Node 22 fails
`notsup`; Node 24 installs clean and `npm ci` passes in Client/.

Refs RL-17 / C-01, ENV-01.

* docs: add the documentation landing page

`docs/` had 24 top-level files and no index. The root README carried a
flat list of 22 links that had drifted: six documents were reachable from
nowhere at all — including both 2026-08-23 audits and the test audit — and
two entries were labelled "latest" while newer unlinked audits existed.

docs/README.md is the index RL-12 asked for. It groups by what a document
*is*, because that is what decides whether to trust it: guidance tells you
how to do something, reference describes a contract the code implements,
audits are dated snapshots nobody updates, plans record intent. Every
tracked file under docs/ now appears exactly once, and the audit table says
plainly that audit-2026-08-19.md still claims "0 open findings" when the
ledger has 38.

The root README keeps a short curated list and defers to the index, rather
than maintaining a second copy that drifts again. Two fixes while there:
`docs/plans/` was linked as a bare directory, unlike its two sibling
directory entries, and was annotated "each carries a verified status
header" — which docs/plans/README.md:7-9 explicitly contradicts, since a
plan's header is exactly the thing that drifts and the index is the
authority.

Verified: 78 relative links across the new and edited files resolve, and
no tracked docs/ file is unreachable from the index.

Refs RL-12 / R-06.

* feat(scripts): root command facade

Entry points existed only inside Server/ (a Makefile) and Client/ (npm
scripts). Nothing at the root told a new contributor where to start, and
the root package.json had three scripts, none of which built or tested
anything.

`npm run check` from the root now runs what CI gates on, and
check:server / check:client / check:rust run one stack. scripts/run.mjs
is dependency-free Node — the shape render-ledger.mjs already uses — so
`npm run check` works before `npm install` has.

Cross-platform by construction: every step is spawned with an explicit cwd
and no shell, so there is nothing to quote and no `cd &&` to behave
differently on Windows. npm and npx get their .cmd suffix there. No step
shells out to make.

The facade orchestrates; it is not a new required path. Each step prints
the command and the directory before running it, and those are exactly the
commands documented per-stack — so a server contributor can read the output
and type them instead, and still never needs Node. Tools CI installs but a
contributor may not have (golangci-lint, which has no wrapper in this repo
at all; sqlc, pinned by Server/sqlc.version) are skipped with a printed
reason rather than failing.

Three corrections to the ci-check skill while aligning it:

- `make sqlc-verify protocol-verify` replaced by what those targets reduce
  to, so the documented path does not require make either.
- `cargo test` -> `cargo test --lib`, which is what ci.yml actually runs.
- "NODE_OPTIONS=--no-experimental-webstorage is mandatory on Node 22+" was
  false. tests/setup.ts installs the shim, CI runs Node 24 without the
  flag, and the suite was measured passing without it — 192 files / 5257
  tests, identical to the flagged run.

Also documents the third RL-20 problem, which needed no code: core.hooksPath
is exclusive, so `npm run hooks:install` silently disables any
.git/hooks/post-commit — including the one `graphify hook install` writes,
which CLAUDE.md tells agents to install. Nothing warned about that.

Verified: check:client 5257/192 green, check:rust 123 tests + clippy green,
--list prints every command, and the optional-tool skip path reports rather
than fails.

Refs RL-04 / L-04, RL-20 / L-14.

* feat(ci): fail on a document that contradicts the findings ledger

G-04's remaining half. The ledger is the source of truth for defect counts,
but nothing stopped a planning document from stating a different number and
nothing noticed when one did. `render-ledger.mjs --check` cannot help: it
validates the JSON schema and returns before rendering, so it never reads
FINDINGS.md and cannot see drift at all — and no workflow ran it anyway.

scripts/check-doc-counts.mjs counts ledger statuses and compares them to
what an allow-list of active documents claims, failing with file, line,
claimed value and actual. Wired into ci.yml as a job with no npm ci, since
the script imports nothing outside node:, and into the facade as
`npm run check:docs` — first in `check`, so a contradicted count does not
wait behind ten minutes of -race.

The patterns are narrow on purpose. A first attempt matched any
"<number> <status>" and flagged nineteen things, all false: "the 45 open P1
rows" (issue-register rows, not ledger findings), "All 8 findings F1-F8"
(a different register), "G-05 **refuted**" (an identifier), `">=20"` and
`CGO_ENABLED=0` (not counts at all). A check that cries wolf gets ignored,
which is the failure G-04 already describes. So a number is only read as a
claim in three shapes that cannot mean anything else: an enumeration of two
or more "<n> <status>" pairs, a status table row in a table that totals
itself, and "<n> records/findings" where the ledger is named within three
lines. Fifteen selftest assertions pin both directions, and the job runs
them before it runs the check.

It reads findings-ledger.json directly rather than importing
render-ledger.mjs for `validate`/`render`: that module ends in a bare
top-level `await main()` with no import.meta.main guard, so importing it
rewrites FINDINGS.md as a side effect.

Dated docs/audit-*.md are reported, never failed — they are snapshots
nobody maintains. audit-2026-08-19.md does claim zero open findings against
38 open, so b0-baseline's "No plan was found claiming '0 open findings'"
holds for docs/plans/ but not for docs/.

Not included: a real FINDINGS.md render-drift check. That is RL-07 and
belongs with the generated-artifact work, not here.

Verified: 27 claims across 9 documents agree; corrupting one count in
docs/plans/README.md fails the check naming that line, for both the status
and the total.

Refs G-04.

---------

Co-authored-by: Claude <noreply@anthropic.com>

* chore: remove graphify knowledge graph tooling (#1413)

The committed knowledge graph and its PreToolUse hooks were steering every
codebase question through `graphify query` before any other tool could run.
Serena (gopls + rust-analyzer + tsserver over MCP) answers the same questions
from real language servers rather than a generated snapshot that goes stale
between rebuilds, so the graph no longer earns the ~20 MB it costs the tree.

Removed:
- `graphify-out/` untracked (7 files, ~20 MB) and now gitignored
- both `graphify hook-guard` PreToolUse hooks from `.claude/settings.json`
- the "Knowledge graph (graphify)" section of `CLAUDE.md`
- the `graphify-out/**` block from `.gitattributes` and `.gitignore`
- the graph-rebuild step from the `bughunt-run` skill, and the graph-edge
  guidance from the bughunt workflow prompt
- the graphify-specific `core.hooksPath` example in `ci-check` and
  `docs/contributing.md`, keeping the underlying warning in generic form

Also deletes the locally installed `post-commit` / `post-checkout` rebuild
hooks (untracked, not part of this diff).

This does not shrink clone size: the graph blobs stay in published history,
which `docs/plans/b1-repository-foundation-2026-08-25.md` explicitly rules out
rewriting. It does stop future refreshes from adding more.

Dated audit and plan documents keep their graphify references as a historical
record of the state they described.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>

* B1-3: repository hygiene gates (RL-19 / L-13, S-05) (#1414)

* chore(format): one Prettier config at the repository root

Every formatting rule in this repository lived under Client/ and covered
exactly two globs: Client/src/**/*.ts and Client/tests/**/*.ts. Root Markdown,
all of docs/, every YAML and JSON, all CSS, the root scripts and
tools/mcp-introspect were formatted by nothing. There was no .editorconfig.

The obvious fix -- a second Prettier config at the root for "everything else"
-- gives two configs and two ignore files that can silently disagree about the
same file. So the root takes ownership instead: config, ignore file and gate
move up, and Client/ folds in. Client's inline "prettier" block, its
.prettierignore, its format/format:check scripts and its now-unused prettier
devDependency are all deleted; knip would have failed client-check on that last
one.

The .prettierrc.json values are lifted byte-for-byte from Client/package.json,
which is what keeps the reformat commit free of client TypeScript churn: 87
tracked files need reformatting and not one of them is under Client/src or
Client/tests.

.prettierignore carries only what .gitignore does not. Prettier 3 reads the
root .gitignore by default, so node_modules/, dist/, coverage/,
Client/src/generated/ and docs/security-findings/ need no entry. It does NOT
read nested .gitignore files, which is why .remember/ is listed explicitly --
38 untracked per-machine scratch files were otherwise able to turn a shared
gate red. graphify-out/ is listed because its seven files are tracked and
.graphify_labels.json is signed byte-for-byte by its .sig, so formatting it
would silently invalidate the signature.

check:hygiene is registered in scripts/run.mjs and folded into check and
release:preflight. It deliberately contains no `gofmt -l` step: gofmt -l prints
offenders and still exits 0, so it cannot fail a build. Go formatting is
enforced separately.

shellcheck and actionlint take their file lists from `git ls-files`, never a
filesystem glob -- .claude/worktrees/ holds a gitignored pre-flatten copy of
the tree with three .sh files a glob would happily lint.

This commit leaves the tree non-conformant on purpose. The reformat is the next
commit, so the 87-file diff is reviewable separately from the rule that caused
it.

Not included: editorconfig-checker. .editorconfig is the editor baseline the
audit asked for; Prettier, gofmt and rustfmt already fail CI on the same
indentation and newline rules, so a fourth tool checking them again is a gate
with no failure mode of its own.

Verified: `npx prettier --check .` names 87 tracked files and zero untracked
ones; the same command listed 38 .remember/ scratch files before the ignore
entry and none after. `node scripts/run.mjs --list` resolves check:hygiene to 8
shell targets and 4 workflow targets. Both package.json files parse.

Refs RL-19 / L-13, S-05.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* chore(format): reformat the tree to the repository Prettier rules

Mechanical. This commit is `npx prettier --write .` and nothing else -- the
rule that caused it landed in the previous commit so this diff can be reviewed
as a transformation rather than as 84 files of hunks.

84 tracked files: 54 Markdown, 7 .mjs, 7 JSON, 6 YAML, 4 .js, 3 CSS, 2
TypeScript (the two Playwright configs at Client's root, which the old
Client/src + Client/tests globs never covered). No file under Client/src or
Client/tests moves, because .prettierrc.json carries Client's former inline
values byte-for-byte.

Prettier rewrote 87 files, not 84. The three in .github/ISSUE_TEMPLATE/ had
CRLF on disk and differ only in line endings, which .gitattributes
(`* text=auto eol=lf`) already normalises, so their committed blobs are
unchanged. Worth knowing before someone reconciles the two numbers.

The largest single diff is .superpowers/findings-ledger.json at 7976 lines
rewritten. That is safe to format: nothing writes the ledger programmatically
-- render-ledger.mjs reads it and writes only FINDINGS.md -- so no tool will
fight Prettier over its style on the next hunt. FINDINGS.md itself is ignored
as generated.

Verified: `npx prettier --check .` reports "All matched files use Prettier code
style", so the pass is both complete and idempotent. All 7 reformatted JSON
files were parsed before and after and compared as values: semantically
identical, zero content changes. `node .superpowers/render-ledger.mjs --check`
still reports 348 valid findings and leaves FINDINGS.md untouched.
`node scripts/check-doc-counts.mjs` still passes its selftest and still agrees
on 27 claims across 9 watched documents -- table realignment did not break the
patterns it matches on. `node scripts/run.mjs --list` still parses.

Refs RL-19 / L-13, S-05.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* chore(lint): enforce Go formatting in the Server linter

S-05: repository-wide Go formatting was not a required gate. The only gofmt
enforcement anywhere was .githooks/pre-commit, which is opt-in per clone
(`npm run hooks:install`), only sees staged files, and warns-and-skips when
gofmt is off PATH.

The obvious fix -- a `gofmt -l` step in CI -- does not work: `gofmt -l` prints
its offenders and still exits 0, so the step passes no matter what it finds.
scripts/run.mjs has the same problem, which is why check:hygiene has no Go step
either.

So gofmt goes where it can actually fail something: Server/.golangci.yml. The
file was already `version: "2"` but had no `formatters:` block at all, so the
19 enabled linters ran with zero formatters. In v2 gofmt/gofumpt/goimports
moved out of `linters.enable` into their own section with its own exclusions.
Adding it there means the gate reports through the Lint step of "Server Build &
Test", which is already pinned as required on dev -- no new job and no new pin.
Every tracked .go file is under Server/ (551 of them, one go.mod), so
Server-scoped is repository-wide here.

One file was genuinely misformatted: a one-space struct field alignment in
Server/admin/handlers_users_broadcast_test.go, fixed in the same commit because
a single line does not need its own reformat commit.

Trap worth recording: `gofmt -l .` on a Windows working tree lists every file
that has CRLF on disk, because gofmt normalises line endings. That reported 18
offenders here, 17 of them ghosts. The blobs are all LF -- .gitattributes
forces `eol=lf` -- so CI never saw them, and the honest test is to run gofmt
over `git show HEAD:<file>` rather than the working copy. Doing that across all
551 tracked Go files found exactly the one real offender above.

Verified both directions with golangci-lint v2 locally: `golangci-lint run
./...` reports 0 issues on the formatted tree; appending a misformatted
function to Server/auth/constants.go produces 2 gofmt findings; appending the
same misformatted function to Server/db/dbgen/admin.sql.go produces 0, so the
exclusion holds. Both files restored and verified clean afterwards.

Refs RL-19 / L-13, S-05.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(scripts): escape the NUL separator instead of embedding one

The `tracked()` helper added earlier in this branch splits `git ls-files -z`
output on NUL. The separator was written as a literal NUL byte rather than the
two-character JavaScript escape, so scripts/run.mjs became a binary file: `git
diff` refused to show it, `grep` reported "Binary file matches" instead of the
line, and `* text=auto` in .gitattributes stops normalising line endings for a
blob it detects as binary.

The code worked -- splitting on a raw NUL and splitting on "\0" are the same
operation -- which is exactly why this is worth fixing before it is inherited.
A source file that tooling classifies as binary is a file nobody can review.

Verified: zero NUL bytes remain, `grep -n "split("` now prints line 50 instead
of "Binary file matches", `node scripts/run.mjs --list` still resolves the same
8 shell and 4 workflow targets, and prettier still reports the file clean.

* chore(lint): enforce Rust formatting

Rust had no formatting gate of any kind: no rustfmt.toml, no `cargo fmt`
anywhere in CI, in scripts/run.mjs, in the Makefile or in the git hooks. Clippy
was the only Rust gate, and clippy does not check layout.

`cargo fmt --all -- --check` now runs in the rust-tests job, ahead of clippy: a
formatting failure is cheap to produce and cheap to fix, and there is no reason
to spend a clippy pass to surface one. The stable toolchain in that job
requested `components: clippy` only, so rustfmt is added there.

Only that job. ci.yml has a second, byte-identical `Install Rust` block in
tauri-build; it stays clippy-only, because a full desktop build is the wrong
place to discover a misplaced brace.

No rustfmt.toml. The default profile is the point of a baseline -- a config
file here would be a second opinion about style with nothing to say.
Client/src-tauri is a single `[package]`, not a workspace, so `--all` is a
safeguard against a future member rather than a fan-out today.

Verified: `node scripts/run.mjs --list` resolves check:rust to three steps with
`cargo fmt --all -- --check` first, `npm run format` now also runs `cargo fmt
--all`, and prettier reports ci.yml, run.mjs and the ci-check skill clean.
`cargo fmt --all -- --check` currently fails on 13 files -- that is the
reformat, and it is the next commit.

Refs RL-19 / L-13.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* chore(format): reformat the Rust crate to rustfmt defaults

Mechanical. This commit is `cargo fmt --all` and nothing else; the gate that
demands it landed in the previous commit so this diff is reviewable on its own.

13 of the 17 tracked .rs files, +343/-164. The crate had never been formatted,
so the changes are the usual first-run set: aligned trailing comments collapsed
to single spaces, single-element slice literals folded onto one line, long
method chains broken across lines, closure bodies expanded into blocks.

Verified: `cargo fmt --all -- --check` is clean, so the pass is complete and
idempotent. `cargo clippy --all-targets -- -D warnings` finishes with no
warnings, and `cargo test --lib` reports 115 passed / 0 failed -- identical to
before the reformat, which is what "mechanical" has to mean for a commit that
touches this much of the crate.

Refs RL-19 / L-13.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(scripts): make the root facade actually run on Windows

Adding the first gate that a contributor would run from the repository root
exposed two bugs in the facade, both of which made it silently wrong on the
platform this project is developed on.

1. Every npm and npx step failed. bin() appends `.cmd` on Windows, but Node
   refuses to spawn a .cmd or .bat with shell:false -- the CVE-2024-27980
   mitigation -- and fails with EINVAL and a *null* exit status. run.mjs only
   special-cased ENOENT, so the result was `FAILED: npx prettier --check .
   exited null` with nothing to explain it. check:client has three npm steps and
   has never been able to run here.

   Fixed by spawning only the npm shims through a shell. They are concatenated
   into a single command string rather than passed as an args array, because
   shell:true plus a separate array is deprecated (DEP0190) and prints a warning
   on every invocation; no argument in this file contains a space.

2. Every optional() step was skipped, always. onPath() shelled out to
   `where` on Windows, but where.exe lives in C:\WINDOWS\System32, which a Git
   Bash PATH does not necessarily contain -- on this machine PATH carries
   System32\Wbem, System32\WindowsPowerShell\v1.0 and System32\OpenSSH but not
   System32 itself. The probe could not start, `probe.status === 0` was false,
   and golangci-lint and sqlc reported as "not installed" while installed.

   Fixed by resolving against PATH and PATHEXT directly. No subprocess, and no
   dependency on which directories happen to be on PATH.

A spawn error other than ENOENT now reports its code instead of surfacing as a
null exit status.

Verified: before, `node scripts/run.mjs check:hygiene` died with "exited null"
and both optional steps printed SKIP with the tools present on PATH. After, the
same command runs prettier, shellcheck and actionlint and prints
"check:hygiene: passed", with no deprecation warning. `golangci-lint` is
detected by the new onPath where the old one missed it.

Refs RL-20 / L-14.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* chore(format): ignore build output that nested gitignores hide

Prettier honours the root .gitignore and no other. Every build and scratch
directory in this repository is ignored by a *nested* one -- Client/.gitignore,
.serena/.gitignore, .superpowers/sdd/.gitignore -- so none of them were
excluded from the new repository-wide gate.

The effect is not subtle. Running `cargo test` once drops roughly 850
formattable files into Client/src-tauri/target/, and the hygiene gate goes from
clean to "Code style issues found in 939 files". CI never sees it, because a
fresh checkout has no build output; every contributor sees it on their second
command.

Mirrors the three nested files rather than inventing a list: dist, coverage,
playwright-report, test-results, .vite, src-tauri/target and src-tauri/gen from
Client/.gitignore, plus .serena/ and .superpowers/sdd/. node_modules needs no
entry -- Prettier ignores it by default.

Verified: `npx prettier --check .` reports "All matched files use Prettier code
style" with a fully populated Client/src-tauri/target/ present on disk, and
still names README.md when a misformatted table is appended to it.

Refs RL-19 / L-13.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* chore(ci): shellcheck, actionlint, and a repository hygiene job

The last two gates RL-19 asks for. Neither existed: the shell scripts were
never linted, the workflows were never syntax-checked, and .githooks/pre-commit
carried hand-written `# shellcheck disable=` directives that nothing had ever
read.

New `hygiene` job, ubuntu-only and root-scoped, modelled on docs-consistency
for the same reason: every gate in it is platform-independent text analysis,
and .gitattributes pins eol=lf so a second OS would only re-prove line endings.
It runs `npm run check:hygiene` -- the same entry point a contributor runs, not
a parallel copy of the commands.

shellcheck ships in the runner image. actionlint does not, so it is pinned by
version and verified by sha256: an installer script piped from a branch would
be the one unverified download in a workflow file that pins every action by
commit SHA.

Prettier's step moves here from client-check, where it no longer belongs.

Both linters found real defects.

shellcheck, 3 findings in 8 scripts. Two are SC1125 errors in
.githooks/pre-commit: `# shellcheck disable=SC2086 - repo paths contain no
spaces` is not a valid directive. Trailing prose makes shellcheck discard the
rest of the line, so neither suppression was ever in effect -- and one of the
two was written earlier in this same branch, which is a fair demonstration of
why the gate is worth having. The prose moves to its own line above. The third
is SC2015 in start-server.sh, rewritten as an explicit if.

actionlint, 5 findings, all inside `run:` blocks it shellchecks once shellcheck
is on PATH. Three SC2015 in load-baseline.yml, rewritten as explicit ifs. Two
SC2035 in release.yml, where `sha256sum *` should not become `sha256sum ./*`:
the comment four lines above records that ParseChecksumFile exact-matches the
last field, so a "./" prefix would strand every deployed server exactly as a
"windows/" prefix would. `sha256sum -- *` satisfies the linter and leaves the
output bytes identical.

Verified all three gates in both directions with shellcheck 0.10.0 and
actionlint 1.7.7 on PATH. Passing: `node scripts/run.mjs check:hygiene` prints
"check:hygiene: passed" with all three steps run, not skipped. Failing:
appending `bait_fn() { cat $1; }` to Server/scripts/voice-test.sh fails on
SC2086; changing a runs-on to `ubunt-latest` fails on runner-label; appending a
misformatted table to README.md fails prettier. All three files restored and
confirmed clean afterwards. actionlint validates the new job in ci.yml itself.

Refs RL-19 / L-13, S-05.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs(plans): record B1 progress through B1-3

The header still read "B1-0 is complete; B1-1 is the next step" three merged
phases later. A plan that misstates where it is costs a reader the same
confusion whether it is stale by one phase or three.

B1-0 (#1410), B1-1 (#1411), B1-2 (#1412) and B1-3 (this branch) are done; B1-4,
dependency automation, is next.

Verified: `node scripts/check-doc-counts.mjs` still agrees on 27 claims across
9 watched documents -- this file is one of them -- and prettier reports it
clean.

* chore(ci): pin Repository Hygiene as a required check on dev

The second half of S-05. Its acceptance criterion is "tree is formatted AND a
fast required gate fails future drift" -- a check that runs but is not pinned
lets a formatting regression merge, so the gate is not a gate until this lands.

The name was read off PR #1414 with `gh pr checks` after the job reported
`pass` in 26s, not copied out of ci.yml. That order matters: the B0 script
records that three pinned names exist in no workflow file at all, and that a
required check which never reports blocks every PR forever.

Extends the existing script rather than adding a second one, per the B1 plan.

Also records, in the "deliberately NOT pinned" list, that Docs & Ledger
Consistency reports and passes on a dev PR yet is unpinned. That reads as an
oversight from the 2026-08-25 pass rather than a decision, but it belongs to
G-04, so it is documented here and not changed.

NOT APPLIED YET. Running this script now would pin a check that PR #1413 cannot
report -- its branch predates the hygiene job, so the job does not exist in its
workflow file and the check would never arrive. Run it after #1414 merges;
#1413 needs a rebase onto dev regardless.

Verified: shellcheck clean, the embedded JSON parses, and `check:hygiene`
passes with prettier, shellcheck and actionlint all running.

Refs S-05, RL-14 / G-03.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>

* B1-4: dependency automation (RL-05 / L-05, RL-18) (#1415)

* chore(deps): cover the root and mcp-introspect npm roots

The repository has three npm package roots — `/` (changelogen, prettier),
`/Client`, and `/tools/mcp-introspect` (@modelcontextprotocol/sdk, zod) —
each with its own package-lock.json, and `npm run bootstrap` runs `npm ci`
in all three. Dependabot watched exactly one of them. The root's prettier is
what the Repository Hygiene gate runs, so the formatting gate's own toolchain
was drifting unwatched.

The obvious fix is to collapse the three roots into an npm workspace and
watch one lockfile. Measured on npm 11.17 / Node 26 rather than assumed, that
trade is bad, and it is bad for different reasons than expected. Workspaces do
not break the things you would predict: `npm ci` inside `Client/` still exits
0, `npm run <script>` still resolves the hoisted binaries because npm prepends
every ancestor `node_modules/.bin` to PATH, and `engine-strict` still fails
the install on a wrong Node major. What they cost is ten CI steps keyed on
`cache-dependency-path: Client/package-lock.json` (six in ci.yml, four in the
tag-only, CI-ungated release.yml) pointing at a file that stops existing; the
Repository Hygiene job's deliberate root-only install growing 970 ms to
6172 ms and 39 to 318 packages unless every call site remembers
`--workspaces=false`; and one shared lockfile putting all three npm Dependabot
groups back into the same file, which is precisely the rebase storm the
grouping comment at the top of dependabot.yml exists to prevent. The measured
benefit is one 298 KB lockfile instead of three (17 KB / 253 KB / 42 KB) and
614 resolved packages deduped to 582 — 32 packages, 5.2% — with client install
time unchanged at 5642 ms against 5667 ms.

So the roots stay separate and each gets its own block, matching the four that
already exist: grouped to one PR, majors ignored, weekly on Monday. A single
block with `directories:` was rejected for the same reason as workspaces —
grouping only works while a group rewrites exactly one lockfile. The decision
and its numbers are recorded in docs/contributing.md under Dependency Policy,
so the next person to propose workspaces reads the measurement instead of
repeating it.

Verified: a coverage checker cross-references every `package-ecosystem` /
`directory` pair in dependabot.yml against every manifest in `git ls-files`,
in both directions. Against dev at 2a37f386 it reports `UNWATCHED npm
package.json` and `UNWATCHED npm tools/mcp-introspect/package.json` (plus
Server/Dockerfile, which the next commit covers). Against this commit both npm
rows read `ok`, and the forward direction confirms each newly declared
directory really holds a package.json. `npx prettier --check` reports both
edited files unchanged, so the B1-3 formatting gate stays green.

Not included: the docker ecosystem (next commit, RL-18); immutable image
digests for release/runtime containers, which is R-04's remaining half and
belongs to B6; and turning the coverage checker into a permanent
`check:hygiene` gate — a new manifest root can still drift unwatched, which is
how this gap arose, but that is new gate machinery rather than the coverage
this item asks for.

Refs RL-05 / L-05

* chore(deps): watch the server container base images

Server/Dockerfile pulls `golang:1.26-bookworm` to build and
`gcr.io/distroless/static-debian12` to run, and nothing watched either. Every
other dependency root in the repository is on a weekly Dependabot schedule, so
the one artefact that ships to users as a whole filesystem was the only one
whose upstream moved silently — including its CA certificates, which the
Dockerfile comment specifically calls out as the reason distroless was chosen
over scratch.

The obvious fix is to pin both images by digest and be done. That is the wrong
move here for two reasons. A digest pin with no automation behind it is worse
than a tag: it freezes the base image at whatever was current the day someone
typed it, and a frozen distroless base is a frozen CA bundle. And digest
refresh for release and runtime images is R-04's other half, scoped to B6
alongside the smoke tests that have to gate it — landing half of it here would
leave the digests pinned and the refresh unowned.

So this adds the `docker` ecosystem for /Server on the same terms as the five
blocks around it: grouped to one PR, majors ignored, weekly on Monday. Be
precise about what that actually buys, because it is less than the block
implies. Of the two images only `golang:1.26-bookworm` carries a comparable
version, so it is the only one Dependabot can act on today;
`gcr.io/distroless/static-debian12` has no version tag, and an untagged image
is not something a version update can move — it needs the digest pinning that
B6 owns. The comment above the block records that the Go builder tag tracks
Server/go.mod and every `actions/setup-go` in CI, so a `1.27-bookworm` PR is a
prompt to move all three together rather than a standalone merge.

Verified: the coverage checker cross-references every `package-ecosystem` /
`directory` pair against every manifest in `git ls-files`, in both directions.
Against dev at 2a37f386 the reverse direction reports `UNWATCHED docker
Server/Dockerfile`; against this commit every row reads `ok` and it exits 0 —
`ALL ROOTS WATCHED`, six blocks covering six manifest roots, with the forward
direction confirming /Server really holds a Dockerfile. `npx prettier --check`
passes on the edited file.

Not included: Server/docker-compose.yml and Server/docker-compose.otel.yml.
Their four images are `ghcr.io/j3vb/owncord-server:latest` (this repository's
own published image), `livekit/livekit-server:v1` (a floating major tag, and
majors are ignored everywhere), `jaegertracing/all-in-one:latest` and
`prom/prometheus:latest` — none of which a version update can move, so a
compose block would be configuration that provably produces nothing. Also not
included: immutable digests plus digest-refresh PRs with smoke tests for the
release and runtime images, which is the remainder of R-04 and belongs to B6.

Refs RL-18

* docs: apply skill-review findings to ci-check …

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-28 08:18:24 +02:00
J3vbandClaude Opus 5 e58df6802e fix(release): publish only this tag's changelog section as the release body (#1431)
`gh release create --notes-file CHANGELOG.md` hands GitHub the entire
file. v1.2.0-alpha.4 shipped with all 795 lines as its description: every
past release back to alpha.1, plus the "How to write an entry" style
guide, which is addressed to contributors and has no business on a
download page. A reader looking for what changed had to scroll past
three prior releases to find it.

Slice the section for the tag being published instead, and rewrite its
relative `docs/` links to absolute ones — they resolve against the
repository, so on a release page they 404 for every reader.

Fail closed when the section is missing. A published release with an
empty description has already been fetched by the time anyone notices;
a failed run can be re-run once the entry is written.

Also drop the `changelogen --output CHANGELOG.md` step and the `npm ci`
that fed it. It ran after the tag existed, so its from-tag and to-tag
were the same commit: it appended an empty `## <tag>...<tag>` heading
whose compare link pointed at itself. Nothing else consumed its output.
`npm run changelog` still exists for drafting an entry locally, before
tagging, which is where generating one is actually useful.

Verified against the committed CHANGELOG.md: alpha.4 yields the 73-line
curated section (3115 bytes, down from 49775), alpha.1 matches through
its titled heading, a bare `v1.2.0` correctly matches nothing rather
than swallowing alpha.4, and a missing section exits 1.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-28 07:53:40 +02:00
J3vbandClaude Opus 5 dbdb287651 chore(deps): point Dependabot at dev instead of the default branch (#1430)
Every block omitted `target-branch`, so Dependabot defaulted to `main` and
opened all seven ecosystems against the release branch. That contradicts the
branch model in CLAUDE.md and docs/contributing.md, where `dev` is the
integration branch and the only branch that takes PRs.

Retargeting by hand does not hold: `@dependabot rebase` recreates the PR
against the *configured* target, silently reverting the base back to `main`.
The config is the only durable place to fix it.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-28 05:36:05 +00:00
64d2e1087b chore: merge main into dev to unblock the alpha.4 release PR (#1425)
* ci(deps): bump anthropics/claude-code-action (#1404)

Bumps the actions-dependencies group with 1 update: [anthropics/claude-code-action](https://github.com/anthropics/claude-code-action).


Updates `anthropics/claude-code-action` from 1.0.193 to 1.0.199
- [Release notes](https://github.com/anthropics/claude-code-action/releases)
- [Commits](https://github.com/anthropics/claude-code-action/compare/9d7150bc8a3dae8149739a88019d192b579ad90c...dcb57747bfceeaa1fa72638cae52295d1d853d4a)

---
updated-dependencies:
- dependency-name: anthropics/claude-code-action
  dependency-version: 1.0.199
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: actions-dependencies
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* build(deps): pin rfd to tauri-plugin-dialog's major to unblock the cargo group (#1406)

The cargo-dependencies group PR (#1405) fails Rust Unit Tests on Linux:

    error: failed to run custom build command for `rfd v0.17.2`
    You need to choose at least one backend: `gtk3` or `xdg-portal`
    features for x86_64-linux

rfd is not really ours. It arrives in the tree via tauri-plugin-dialog,
which pins ^0.16; we declare it directly only for the fatal-startup
message box in lib.rs, where the Tauri app never finished building and
the plugin has no AppHandle to run a dialog through.

Cargo unifies features only within a semver-compatible version group, so
while both wanted ^0.16 there was a single rfd in the graph and the
plugin's backend features covered our `default-features = false`
declaration too. Bumping our direct dep to 0.17 forks rfd into two
crates: the plugin keeps 0.16.0 with its features, ours resolves to
0.17.2 with none, and rfd 0.17 added a build.rs assertion that aborts
the Linux build when no backend feature is set. Confirmed in the PR's
lockfile, which carries both 0.16.0 and 0.17.2.

Adding a Linux backend feature would be the wrong fix: it would paper
over the fork and still build rfd twice on every platform for one error
dialog. Our version has to track the plugin's instead, so ignore
semver-minor rfd updates (0.16 -> 0.17 for a 0.x crate) until
tauri-plugin-dialog moves. Patch updates inside 0.16.x still flow.

The remaining five crates in the group are unaffected; `windows` in fact
consolidates 3 versions down to 2.

Cargo.toml is comment-only here - no dependency, feature, or lockfile
change - so the build is untouched.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>

* chore(deps): bump log (#1407)

Bumps the cargo-dependencies group with 1 update in the /Client/tauri-client/src-tauri directory: [log](https://github.com/rust-lang/log).


Updates `log` from 0.4.33 to 0.4.34
- [Release notes](https://github.com/rust-lang/log/releases)
- [Changelog](https://github.com/rust-lang/log/blob/master/CHANGELOG.md)
- [Commits](https://github.com/rust-lang/log/compare/0.4.33...0.4.34)

---
updated-dependencies:
- dependency-name: log
  dependency-version: 0.4.34
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: cargo-dependencies
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* ci(deps): bump anthropics/claude-code-action (#1408)

Bumps the actions-dependencies group with 1 update: [anthropics/claude-code-action](https://github.com/anthropics/claude-code-action).


Updates `anthropics/claude-code-action` from 1.0.199 to 1.0.200
- [Release notes](https://github.com/anthropics/claude-code-action/releases)
- [Commits](https://github.com/anthropics/claude-code-action/compare/dcb57747bfceeaa1fa72638cae52295d1d853d4a...24dcd50c0568f0fc9e9211213a4fd2d9eb15c4e0)

---
updated-dependencies:
- dependency-name: anthropics/claude-code-action
  dependency-version: 1.0.200
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: actions-dependencies
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* fix(client): strip tags to a fixpoint inside sanitizePassApprox

CodeQL alert 17 (js/incomplete-multi-character-sanitization, high) fires on
the single-pass `input.replace(/<[^>]*>/g, "")`: a lone replace can in
principle splice a fresh `<...>` out of the text either side of what it
removed. echoNormalize already loops sanitizePassApprox to a fixpoint, so
that was absorbed one level up and the output is unchanged -- but the
repetition is now where a reader (and the query) can see it.

sanitizePassApprox is a comparison normalizer, never rendered output: its
only consumer is the `===` echo match in isUnreconciledEcho. Not a
sanitization boundary, so this is a legibility fix, not a security one.

Client suite 5257/5257, tsc, lint, hygiene all green.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(client): put the strip-tags replace inside the loop body

The previous form hoisted the `replace` into the `for` header's init
expression, so CodeQL still reported it (alert 18, line 217 col 21) --
js/incomplete-multi-character-sanitization only credits a repeated
replacement when the call sits in the loop *body*, which is also the shape
the rule's own guidance shows.

Same fixpoint, same output; `while (out.includes("<"))` gives the loop a
real condition instead of `for (;;)`.

Client suite 5257/5257, tsc, lint, prettier green.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-27 20:12:52 +00:00
J3vbandClaude Opus 5 4e7e4b12ac release: v1.2.0-alpha.4 (#1423)
Bumps the client version across every pin verify-versions enforces
(package.json, tauri.conf.json, Cargo.toml) plus the two lockfiles that carry
it, and the user-facing build examples in README.md, docs/deployment.md,
docs/quick-start.md, docs/api.md and the issue-form placeholders.

Deliberately NOT bumped: the v1.2.0-alpha.3 references in ci.yml,
release.yml and docker-smoke.sh, which record the release that published from
a red commit and are the reason the gate-evidence job exists; and the string in
scripts/check-doc-counts.mjs, which is a selftest fixture asserting a version
number is not read as a ledger claim. Rewriting either would falsify a record.

CHANGELOG's Unreleased section becomes v1.2.0-alpha.4.

Verified rather than assumed:
- npm ci exits 0, so package-lock.json still matches package.json.
- cargo metadata --locked exits 0, so Cargo.lock needs no regeneration.
- The verify-versions comparison was run locally against tag v1.2.0-alpha.4:
  all three sources agree, so the tag will not be rejected.
- npm run check passes end to end, exit 0.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-27 21:37:40 +02:00
J3vbandClaude Opus 5 9d46161cca docs: make the changelog a scannable list, and write down the rule (#1422)
The changelog had drifted into walls of text — v1.2.0-alpha.3's entry is a
handful of paragraphs where a single bullet runs eleven lines and names the
function that owned the bug. An operator cannot tell in ten seconds whether any
of it bit them, which is the only job this file has.

Adds a "How to write an entry" section to CHANGELOG.md as the rule: lead with
what is user-visible and what is not, group by an area a user recognises rather
than by subsystem or PR, one line per fix, say what was broken then what it does
now, plain language over symbol names, no OC-* ids or file paths, counts in a
summary line rather than on every bullet. Repository work that changes nothing
observable gets at most a short block at the end. Shipped entries are left
alone as history; the rule starts from the next release.

Rewrites Unreleased to follow it, which also closes a real gap: that section
documented B0/B1 repository plumbing and omitted all 62 operator-visible bug
fixes from #1400 and #1402. Exactly backwards — the invisible half was written
up and the half users would notice was not. A release cut from dev today would
have shipped a changelog that mentioned a directory rename and not "banned users
could still connect".

docs/contributing.md's PR process now points at the rule, since that is where a
contributor decides whether their change needs an entry.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-27 18:47:16 +00:00
J3vbandClaude Opus 5 e9724d6f5f docs: accept HP-1 — B1 is complete (#1421)
HP-1 accepted 2026-08-27 by J3vb (repository owner). Recorded the same way HP-0
was: a decision line on the scorecard, and a dated acceptance section appended
to the baseline document.

Condition 6 is accepted as a STATED LIMITATION, not as met. dev carries
strict:false, so a PR whose checks went green before dev advanced can still
merge without re-testing, and the squash commit that lands was never itself
tested as it stands. Closing it forces a rebase on every open PR whenever
another lands, and enforce_admins:true leaves no exemption. Taken knowingly;
not a B2 blocker. Recording it as accepted-with-limitation rather than met is
the point — a scorecard that rounds a partial up to a pass is worth nothing.

Also corrects a stale claim the plan index itself is supposed to police: it
still read "No phase complete" for the roadmap, which stopped being true when
HP-0 was accepted on 2026-08-25. That is the G-04 drift class this index exists
to close, so it should not be the document carrying it.

B2's entry gate condition "B1 is complete and protocol source has one owner" is
now met. Its other two conditions remain B2 entry work, not B1 debt.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-27 20:42:44 +02:00
J3vbandClaude Opus 5 5a70f7ae0f B1-8: platform contract map, HP-1 structural review, and the B1 exit gate (RL-02 / L-02) (#1420)
* docs: record the desktop/browser platform contract map (B1-8, RL-02/L-02)

Client/src/platform/ does not exist — no commits, no files, zero importers.
RL-02 asked for the boundary to be *recorded* in B1 so that B7 executes a
decided plan rather than rediscovering the surface. This is that record, and
nothing more: no directory, no interface, no code.

Measured against dev @ eb873fe7, not estimated: 20 files under Client/src/
import @tauri-apps, using 26 distinct invoke command names against 30
#[tauri::command] handlers, with zero dangling calls and zero uses of the
window.__TAURI__ global. Every native dependency is an import, so a static
check can find all of them — which is what BPR-025 will eventually enforce.

The count is 26 and not 22 because Client/src/lib/ws.ts binds core.invoke to a
local tauriInvoke before calling it; a regex matching only invoke("…") misses
ws_connect, ws_send, ws_disconnect and accept_cert_fingerprint. Any future
lint rule enforcing the seam has to match the binding, not the call site.

The 20 files collapse into 13 capability clusters, three of which have no
browser equivalent and are flagged as product decisions rather than shims:
certificate TOFU in ws.ts, the OS keychain behind credentials.ts/identity.ts,
and out-of-focus push-to-talk in ptt.ts.

Ownership is recorded by phase (B7/B8/B2). No human owners exist for these
folders anywhere in the repository; the document says so rather than leaving
the absence to read as an oversight.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs: perform the HP-1 structural review and measure the B1 exit gate

HP-1 asks whether B1's migrations were mechanical. It had never been run, and
it cannot be run against dev: dev is squash-merge only, so #1411 landed as one
commit and the pure-move/path-rewrite separation the hold point exists to
review survives only on refs/pull/1411/head. The scorecard records the
pre-squash SHAs so the review is reproducible.

Four proofs, all passing:

- Pure move (4befe699): 473 renames, all R100, zero non-rename entries, zero
  line changes, and every renamed blob byte-identical. The blob-OID comparison
  is what actually covers the six binaries — --numstat prints "-" for them, so
  the obvious line-count filter reports false positives.
- Path rewrite (38ddca73): 983 added / 983 removed, and after normalising the
  substitution, six unpaired pairs remain — all relative-path depth arithmetic
  from losing one directory level. Each was resolved against HEAD. The release
  signer is among them and runs only on a tag, so no CI run on any branch
  executes it; it is correct (working-directory: Client, artifacts at the root)
  and guarded by a downstream verify step that fails closed.
- Go module rename (7a4e5dc3): 350 files, 728/728, zero unpaired lines. The
  largest change in B1 is provably a pure substitution.
- Active path inventory: 11 files still name tauri-client, all historical —
  ledger lens labels, dated audits, and plans that describe the move. Zero in
  code, workflows, scripts, hooks or the Dockerfile.

The seed move (93ee14d5) does change behaviour — init() deleted, os.MkdirAll
moved into main(). That was authorised by the plan and is isolated in its own
commit, which is what HP-1 asks for.

Exit gate: seven of eight conditions evidenced. Condition 6 is recorded as
PARTIALLY MET and is a real gap — dev has 11 required checks pinned but
strict:false, so when dev advances after a PR goes green that PR can still
merge without re-testing, and the squash commit that lands was never itself
tested. Deliberately not changed here: flipping strict forces a rebase on every
open PR whenever another lands, and enforce_admins is on. Owner's call.

ENV-01 is closed. Every B0 number was measured on Node 26 while CI pins 24. The
client suite now re-runs on Node 24 from a fresh clone in a node:24 container:
192 files, 5257 tests — identical to B0, and the clone doubles as the exit
gate's Linux setup smoke. ENV-02 also reproduces at 50.1 MB booting on :8443.

Corrects the plan's stale Docker command along the way: the script moved to
Server/scripts/ and now takes the image as an argument, and the build context
is Server/ rather than the repository root — building from the root streams the
whole working tree and then fails on the missing go.mod.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs: record the applied repository settings in the HP-1 scorecard

Both checked-in settings scripts were run on 2026-08-27 — they had landed in
#1418 and #1419 but were deliberately never executed, because repo-settings
writes need a person.

b0-dev-branch-protection.sh pinned the twelfth required check on dev,
"Docs & Ledger Consistency". Until that run the FINDINGS.md drift gate reported
but could not block a merge. Condition 6 now reads 12 pinned checks; it stays
PARTIALLY MET because strict is still false, which the script itself encodes as
a deliberate choice.

b1-release-tag-protection.sh created the "Release tags" ruleset (active, target
tag, refs/tags/v*, blocks update and deletion, zero bypass actors) and the
release environment with one required reviewer. Checked for a pre-existing
ruleset of that name first — the POST half is not idempotent and a second run
would have created a duplicate. Three rulesets existed, all targeting branches,
none named "Release tags".

Condition 7 closes: B1-7 merged, and the Discussions slugs its issue-template
config hardcodes — q-a and ideas — both exist, so the contact links resolve
rather than silently dropping the user on the category picker.

Two things the read-back surfaced, both recorded as open, neither blocking:

- The release environment has can_admins_bypass: true, GitHub's default. The
  ruleset has zero bypass actors, but the reviewer gate does not. Moot while
  the sole admin is also the sole reviewer.
- claude.yml passes secrets.CLAUDE_CODE_OAUTH_TOKEN and the repository has no
  such secret. Nothing is failing, because all five issue_comment runs are
  skipped at the B1-7 guard before the missing secret would matter — but the
  paid-automation surface RL-22 hardens is inert today.

environment: release is still absent from release.yml, deliberately. The
environment now exists, so that is a separate two-line change.

Gate re-run after rebasing onto c0c87366 so condition 8 is measured over the
final tree, B1-7 included: green, 5257 client tests, exit 0. B1-7's
check-workflow-guards.mjs runs locally; its sibling verify-gate-evidence.mjs
does not — CI runs the selftest, and the assert form needs a token and a real
SHA.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-27 18:11:47 +00:00
J3vbandClaude c0c8736674 B1-7: community intake and automation authorization (RL-21 / L-15, RL-22 / L-16, RL-16 / R-09) (#1419)
* ci(claude): constrain automation triggers and bound run cost (L-16)

The Claude Code workflow consumes a metered credential, and the repository
stated nothing about who may spend it or for how long. Whatever downstream
behaviour happens to hold, an invariant this repository depends on should be
asserted and tested here, not inherited from a pinned dependency that a routine
version bump can re-derive.

Three controls, in the one workflow that spends:

- **Authorization.** The job condition now requires the actor to be on an
  explicit maintainer allowlist as well as the trigger text to mention the bot.
  An allowlist rather than an association check: this repository has exactly one
  collaborator, the term is unambiguous to read and to review, and it matches
  the actor-term pattern `ci.yml` already uses to exclude Dependabot. Adding a
  login is a one-line edit, which is the honest cost.
- **Duration.** `timeout-minutes: 30`, in the band every other long-running job
  here uses. Without it the job inherits GitHub's 360-minute default — the wrong
  ceiling for metered work, and the only job in the repository that lacked one.
- **Fan-out.** A `concurrency` group keyed on the issue or pull request number
  with `cancel-in-progress: true`, so repeated triggers on one thread collapse
  into a single run instead of running in parallel. Exactly one of
  `github.event.issue.number` and `github.event.pull_request.number` is present
  per triggering event, so the key is stable across all four.

The `permissions:` block and the checkout are deliberately untouched. The
permissions are already minimal and the checkout takes no `ref:`, so it reads
the base branch rather than proposed code — both correct, and rewriting either
would be churn.

`scripts/check-workflow-guards.mjs` keeps all three from silently regressing.
Modelled on `scripts/check-doc-counts.mjs`: same `--selftest`-then-assert shape,
same dependency-free approach. It is text-level rather than YAML-parsed on
purpose — the root has no YAML parser, and adding a dependency to assert that a
file contains a `timeout-minutes` key would be a poor trade. That limit is
stated in the file: these are presence-and-shape checks, not semantics.

It runs from `CHECK_HYGIENE` in `scripts/run.mjs`, so it is reachable as
`npm run check:hygiene` locally and executes inside `Repository Hygiene`, which
is already a pinned required check on `dev`. No new CI job and no new pin — the
guard is blocking from the moment it lands.

`actionlint` cannot do this job. It validates expression syntax, action inputs
and runner labels; a job condition is valid input to it whatever the condition
admits, and it has no notion of cost at all. The two tools are complementary
and both now run.

Also corrects the record: `docs/plans/b1-repository-foundation-2026-08-25.md`
claimed impact was bounded by read-only content permissions. The workflow's own
token block is least-privilege, but that is not the only identity a run can
hold, so the claim was narrower than the truth and is now stated accurately.

And `docs/security.md` gains the private-coordination section that two planning
documents already cite it for. The citation pointed at a policy that was not
written down; it now says what stays private, that the rule covers the
repository's own automation and settings rather than only product code, and
that a commit message on a public repository is a disclosure channel.

Verified: both directions, per guard. `node scripts/check-workflow-guards.mjs`
exits 0 on the current tree and reports four guards present. Deleting the
`timeout-minutes` line makes it exit 1 naming that guard and the invariant to
restore; replacing the actor term with `true` makes it exit 1 naming that one;
restoring each returns exit 0. `--selftest` passes eight assertions covering
every guard's absence, a commented-out guard (which must not count), and the two
shapes that must not trip it — any positive timeout value, and any concurrency
key. `npm run check:hygiene` passes with prettier, shellcheck, actionlint and
both new steps running for real; actionlint accepts the edited workflow.

Not included: the workflow's `permissions:` block and checkout step, per above.
No change to the action version or its inputs. `ci.yml`, `release.yml` and
`load-baseline.yml` are outside this item — none is reachable the same way, and
each already carries per-job least-privilege permissions, and where relevant a
timeout and a concurrency group. `METERED` in the new script lists one workflow
because one workflow spends; a second entry is a one-line change when that
changes.

Refs RL-22, L-16

* feat(intake): structured bug form, and route ideas to Discussions (RL-21)

Both issue templates were Markdown with front matter, so nothing they collected
was structured, required, or validated. A reporter could submit the form
untouched. The Environment block was three bullets with `Windows 11` prefilled
as the OS — the single most common answer, pre-filled, on a project that ships
Windows and Linux builds and an ARM64 client.

And `feature_request.md` existed at all, which is the direct violation: BPR-100
says Issues is the bug tracker and Discussions hosts support, ideas and
community feedback. A feature-request template routes ideas into Issues by
construction.

Done:
- `bug_report.md` → `bug_report.yml`, a real issue form. Six fields are
  `validations: required` — what happened, steps to reproduce, component, OS,
  architecture, deployment mode — because those six are what turns a report into
  something reproducible. The rest are optional on purpose; a form that demands
  everything gets abandoned.
- `feature_request.md` deleted. Nothing in the tree referenced either template
  by filename, so this breaks no link, script, or workflow.
- `config.yml` gains three routed destinations and keeps `blank_issues_enabled:
  false` — which is what makes the routing hold, since a blank issue bypasses
  every form and every warning on one.

The new environment fields are drawn from what this project actually ships, not
from a generic template:
- **Architecture** x64 / ARM64, with the note that ARM64 is the Linux desktop
  client today and there is no ARM64 server release.
- **Deployment mode** covering the six paths `docs/deployment.md` documents —
  prebuilt binary on either OS, from source, Docker/Compose, systemd, Windows
  service.
- **TLS mode** matching `tls.mode`'s four values exactly, `off` quoted so YAML
  does not read it as boolean false.
- **Network topology** — direct, port forward, reverse proxy, Tailscale — because
  voice bugs in particular bifurcate hard on this, and the reverse-proxy path
  cannot carry the WebRTC UDP range at all.
- **Separate client and server versions.** They are obtained differently and can
  legitimately differ. The server field says where to look — admin panel or the
  startup banner — and explicitly tolerates "unknown", because the version is
  deliberately absent from the unauthenticated `/health` endpoint as
  anti-fingerprinting hardening, so a non-admin reporter genuinely cannot get it.
- **Client webview**, WebView2 or WebKitGTK. No "PWA" option: no PWA exists, B1
  excludes browser and PWA work, and BPR-092 forbids presenting unavailable
  behaviour as functional. The field is diagnostic today regardless — the desktop
  client renders through the OS webview, and that already drives real bug classes.

Every public template now carries the disclosure warning BPR-101 asks for, and
the security contact link is first in the chooser, above the Discussions links.

Four files, 189 insertions, 58 deletions.

Verified: both files parse as YAML, and the form was checked against the issue
form schema rather than only for parseability — 13 body elements, 12 unique ids
with no collisions, every non-markdown element carrying an id and a label, every
dropdown carrying options, and the markdown block carrying neither an id nor
validations (both of which GitHub rejects). `config.yml` has
`blank_issues_enabled: false` and four contact links each with exactly
name/url/about. `npm run check:hygiene` passes.

The gap that verification leaves, stated plainly: nothing in this repository
validates issue-form schema. Prettier confirms the YAML parses and actionlint
does not read `.github/ISSUE_TEMPLATE/` at all, so a file that is valid YAML but
an invalid form disappears from the "New issue" chooser silently. The checks
above are a local stand-in, not the real gate. The live chooser needs a look
after merge — which BPR-100's closure evidence ("dry-run submissions reach the
intended destination") requires in any case.

Not included: the Discussions `?category=` slugs are written as `q-a` and
`ideas`, GitHub's defaults. If this repository's categories were renamed, a
wrong slug drops the user on the category picker rather than erroring — confirm
against the live Discussions tab before relying on them. No PR-template or
documentation changes here; those are the next commit. L-15 is not closed by
this commit alone: BPR-100 names six surfaces and three of them are docs.

Refs RL-21, L-15

* docs(intake): route contributors, and state the security path (RL-21)

The previous commit fixed the forms. This is the half BPR-100 and BPR-102
actually ask for and the B1 plan's bullet does not mention: their closure
evidence names repository navigation, support links and contribution docs
alongside the issue forms, so a `.github/`-only change cannot satisfy either.

Three gaps, each verified rather than assumed:

**Discussions was invisible.** The only link to it anywhere in the tree was
inside `.github/ISSUE_TEMPLATE/config.yml` — the new-issue chooser. So "route
ideas and feedback to Discussions" worked for exactly one audience: people who
had already decided to file an issue. `README.md` and `docs/README.md` now each
carry the routing, so it is reachable from the two pages a newcomer actually
lands on.

**`docs/contributing.md` never mentioned security reporting.** Five files
carry the "never a public issue" rule — the root `README.md`, `CONTRIBUTING.md`,
`SECURITY.md`, `docs/security.md`, `CLAUDE.md` — and every one of them delegates
the full process to `docs/contributing.md`, which is also the document BPR-102's
evidence row sends a fresh contributor to. It said nothing about it. It now has
a routing table and a security section that says the thing that actually matters
on a public repository: the PR description, the commits and the branch name are
disclosure channels, so a fix for a vulnerability describes the control it adds
and nothing else.

**The README contradicted the issue chooser.** The banner said "there's no
support" while the chooser offered a link named "Community Support". Both were
defensible in isolation and together they told a user two different things
before they had read anything else. The banner now says the honest version — no
support *commitment* — and a "Getting Help and Reporting Problems" table names
the right destination for each kind of message without promising a response.

Also in the PR template, which the audit's remedy names as "PR guidance":
- The Test Plan asked for `npm test` / `go test ./...` / `npx tsc --noEmit`.
  Those predate B1-4's root facade; `npm run check` is the entry point CI gates
  on and the one `CONTRIBUTING.md` and `README.md` now tell people to run.
- A generated-files checkbox naming all five, since CI fails on drift and a
  hand-edited generated file is the failure that wastes a cycle.
- A `Not included:` prompt, because `docs/contributing.md` makes a written
  deferral a required commit element and the template asked for it nowhere.
- The disclosure warning BPR-101 wants on public templates.

Two stale claims fixed while in these files: `docs/contributing.md` said "ten
status checks are required" three lines from a section that says twelve, and
`docs/plans/README.md` still read "B1-0 done, B1-1 next" six phases later — in
the index that declares itself the authority over plan headers.

Five files, 70 insertions, 12 deletions.

Verified: `git grep "ten status checks"` returns nothing.
`node scripts/check-doc-counts.mjs` still agrees on 21 claims across 8 watched
documents — `docs/plans/README.md` and `README.md` are both watched, so a
count claim broken by these edits would have failed here.
`npm run check:hygiene` passes with prettier, shellcheck, actionlint and the
workflow-guard check all running.

One nearby claim checked and deliberately left: `docs/contributing.md` also says
"four of the ten" a hundred lines later. That is four of ten *CI steps keying on
a cache-dependency-path*, not required checks — correct in context, and changing
it would have been a wrong fix to a right-looking grep hit.

Not included: L-15 is **not** closed. BPR-100's closure evidence requires
dry-run submissions that reach the intended destination, and BPR-102's requires
a fresh Windows and Linux contributor to follow these docs and land a passing
sample change. Neither is a file edit. BPR-101 additionally wants a tabletop
report proving private receipt, triage, advisory and coordinated disclosure —
no such artifact exists in the tree, and this commit does not create one.
`CODE_OF_CONDUCT.md` and `GOVERNANCE.md` do not exist in this repository; adding
them is community-health scope, not RL-21's, and neither is named by the audit
row or the register row.

Refs RL-21, L-15

* ci(release): require exact-SHA gate evidence before publishing (RL-16)

A tag push starts `release.yml` and nothing else — `ci.yml` has no `tags:`
trigger. And `release.yml` re-runs none of the required checks: it verifies the
version, builds, boot-smokes and signs, which is a different question from
"did the gate pass on this commit". So a tag could publish from a commit whose
CI was red, and nothing would notice.

It already has. `v1.2.0-alpha.3` published from `fb04a579`, whose CI run
concluded **failure** — `Server Build & Test (windows-latest)`, the race and
coverage step. The Release run on the same commit went green and shipped. That
is R-09 demonstrated rather than hypothesised, and it is the fixture this commit
is verified against.

The obvious fix — re-run the test suite inside `release.yml` — is the wrong one.
It would double the tag-time cost, still not cover the checks that run in other
workflows (CodeQL's three `Analyze` jobs exist in no workflow file at all), and
answer a weaker question: "does it pass now" rather than "did the gate pass on
this commit". The evidence already exists; nothing was reading it.

Done:
- `scripts/verify-gate-evidence.mjs` resolves the tagged SHA's check runs and
  asserts every required context is present and `success`. `skipped` and
  `neutral` are not success — a required check that skipped on the tagged commit
  proves nothing about it — and a still-`in_progress` check is called out as
  unfinished rather than treated as absent. Where a context reported more than
  once, the latest attempt decides, in both directions.
- The required set is **parsed out of `b0-dev-branch-protection.sh`**, not
  restated. Pinning a thirteenth check cannot leave this gate behind, and a
  change to that file's shape fails the self-test rather than silently
  weakening the gate.
- A `gate-evidence` job in `release.yml` that `verify-versions` needs. Every
  build job already needs `verify-versions` and both publishers need those, so
  one edge gates the whole graph — including the GHCR push, which today can
  mutate `:latest` before `publish` has run at all.
- `permissions: checks: read` and nothing else.

It is a script rather than a `run:` block because of the rule in the `ci-check`
skill: a step that exists only in `release.yml` first executes at tag time, so
its own bugs surface on the release. `Server/scripts/docker-smoke.sh` is the
worked example — one script, two call sites. Here the second call site is
`--selftest`, run by `ci.yml`'s docs-consistency job on every pull request.

`docs/plans/b1-release-tag-protection.sh` covers the half a workflow file
cannot express: a ruleset on `refs/tags/v*` blocking update and deletion, and a
`release` environment with a required reviewer. **NOT APPLIED** — both are
repository-settings writes this session cannot make. Run
`bash docs/plans/b1-release-tag-protection.sh` when you want them.

Deliberately **no `environment: release` key** in `release.yml` yet. The key is
PR-landable, but naming an environment that does not exist stalls the next
release; the script says to add it after creating the environment, and says why.

Verified: both directions, on real data rather than only fixtures. Feeding the
actual check runs from `fb04a579` — the commit alpha.3 shipped from — through
`evaluate` returns **NOT RELEASABLE**, naming `Server Build & Test
(windows-latest): failure` first. Feeding PR #1418's real check runs on
`8875238` returns **RELEASABLE**, and correctly ignores the red
`github-advanced-security` result because it is not a pinned context — the gate
tracks the required set, not "everything is green". `--selftest` passes 12
assertions covering a missing check, a failure, an unfinished run, `skipped`,
`neutral`, both re-run orderings, an unrequired extra, and a commit with no
checks at all. `bash -n` and `shellcheck` are clean on the new script and both
its heredocs parse as JSON. `npm run check:hygiene` passes with actionlint over
both edited workflows.

The module gained a direct-invocation guard so it can be imported and tested
without reaching the network — compared against `argv[1]` rather than
`import.meta.main`, which needs Node 24.2 against an engines floor of `>=24`
and would silently no-op on 24.0.

Not included: the network path itself is exercised only at tag time. The
self-test covers the decision logic and the required-set parsing, which is where
the bugs live; a live API call needs a token this environment does not have.
R-09's "protected release approval" limb stays open until the settings script is
run — the register phases R-09 **B1/B10**, so that half is B10's. `release.yml`'s
version stamping, both signing keys, the fail-closed minisign verify,
`checksums.sha256`'s bare filenames, both cold-boot smokes and the `git archive`
source snapshot are untouched; the remedy says to retain them and this commit
only adds an edge in front of them.

Refs RL-16, R-09

* docs(plans): record B1 progress through B1-7

B1-6 (#1418) merged and B1-7 is this branch, so the header and the plan index
both move on. B1-8 — the platform contract map — is next, and it is documentation
only: it records the browser-neutral contract folders and their owners, and moves
no native behaviour. Adapter extraction stays B7.

Verified: `node scripts/check-doc-counts.mjs` still agrees on 21 claims across 8
watched documents, both edited files among them; prettier clean.

Refs R-08

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-08-27 17:25:22 +00:00
J3vbandClaude eb873fe7b2 B1-6: generated artifacts (RL-06 / L-06, RL-07 / L-07, RL-08 / L-08) (#1418)
* ci: verify FINDINGS.md against the ledger it renders from (RL-07)

`.superpowers/FINDINGS.md` is generated from `findings-ledger.json`, and
`CLAUDE.md` forbids hand-editing it — but nothing checked. The one automated
consumer, `render-ledger.mjs --check`, validates the ledger's JSON schema and
`return`s at line 116, *before* the only `render()` call at line 118, and never
opens `FINDINGS.md` at all. A stale 1.09 MB rendering passed it cleanly.

The audit says "no workflow runs it". That was true when it was written and is
not now: B1-2 (#1412) wired `--check` into the `Docs & Ledger Consistency` job.
So the gate exists, reports, and is blind to the thing its name suggests it
watches — which is worse than absent, because it reads as covered.

The obvious fix — render to a temp file and diff, as the B1 plan suggests — is
not what this repository does. It has three implementations of one idea
(`Server/Makefile` sqlc-verify and protocol-verify, `.githooks/pre-commit`,
`scripts/run.mjs`), and all three regenerate **in place** and let `git diff
--exit-code` be the differ. That needs no temp path, no cleanup, and inherits
`.gitattributes`' line-ending normalisation for free. A fourth shape would cost
a reader something for nothing.

Done:
- The gate, in all three places the existing two gates live: the
  `docs-consistency` CI job, `scripts/run.mjs`'s `CHECK_DOCS`, and a new
  `.githooks/pre-commit` block gated on the ledger, the rendering, or the
  renderer being staged. `npm run check` never ran the renderer at all before
  this, which contradicted `run.mjs`'s own stated purpose.
- `validate()` now requires `severity`. This is not a nice-to-have riding
  along: `render()` sorts the open section by `SEV_RANK`, and an unranked
  severity makes the comparator return `NaN`, which leaves the sort order
  implementation-defined. A gate whose expected output is implementation-defined
  can go red across a Node upgrade for a reason that is not drift. The
  validation is what makes the gate's premise — that the rendering is a pure
  function of the ledger — true rather than merely true today.
- `--stat` on the diff. Deliberate deviation from the three precedents: a fully
  drifted rendering is a ~40,000-line CI log, and the exit code is what gates.

Eight files, 119 insertions, 24 deletions. The gate is one render (67-170 ms)
plus one `git diff`. Rendering subsumes `--check`, because `main()` validates
and exits 1 before it writes — so the CI job keeps both steps only so the checks
UI names which fix is needed.

Verified: both directions, and the naive test would have lied. Appending to
`FINDINGS.md` proves nothing — the renderer overwrites it, so the perturbation
vanishes and the diff comes back clean. `git diff <path>` compares the worktree
against the **index**, so the drift has to live in the index. Changing one
finding's title in the ledger and staging it *without* re-rendering — exactly
the mistake the gate exists to catch — makes `git diff --exit-code --stat` exit
1 with a one-line stat, and `.githooks/pre-commit` fail with `FINDINGS.md is
stale`. Restoring the ledger and re-rendering returns both to exit 0, and `git
status --porcelain` is clean afterwards. Severity validation both ways: setting
one finding to `moderate` makes `--check` print `INVALID OC-0001: bad severity
moderate` and exit 1; `git checkout` of the ledger makes it valid again. The
hook's grep pattern was exercised against five paths — the three
`.superpowers/` targets match, `.superpowers/sdd/notes.md` and
`scripts/check-doc-counts.mjs` do not. `node scripts/run.mjs --list` resolves
`check:docs` to three steps rather than one; `npm run check:docs` and
`npm run check:hygiene` pass, the latter with prettier, shellcheck (the new hook
block) and actionlint (the new CI step) all running for real.

Not included: untracking `FINDINGS.md` — that is the next commit, and the order
matters. L-07 requires the drift check to exist *before* the removal, because
the check is what proves the tracked copy was current at the moment it was
deleted. No `import.meta.main` guard on the renderer: no caller imports it, and
`import.meta.main` landed in Node 24.2 against an `engines` floor of `>=24`, so
it would silently no-op on 24.0/24.1 — `scripts/check-doc-counts.mjs` documents
the workaround and stays accurate. No `existsSync` guard for a missing ledger:
the unhandled rejection already exits non-zero, so CI already rejects it and
only the message is ugly, which is not drift. The `docs-consistency` job is not
converted to `npm run check:docs`; it is deliberately `npm ci`-free with direct
`node` calls in every step, and half-converting it would be worse than being
internally consistent. No `Server/Makefile` target — the ledger is
root-scoped, and `make` is not on PATH on a stock Windows box (RL-20).

Refs RL-07, L-07

* chore: stop tracking the rendered FINDINGS.md (RL-07)

The previous commit built the drift check RL-07 asked for. This is the second
half: with the check in place proving the committed rendering was current, the
rendering itself comes out of the index.

Untracking is strictly stronger than checking. A drift check watches for a
rendering that has fallen behind its source; not tracking it removes the
possibility. `findings-ledger.json` stays the only tracked copy and remains
canonical — `CLAUDE.md` tells contributors to open a PR against it — and the
1.09 MB view of it is regenerated in 67-170 ms by a command that was already
documented.

Why the drift check still had to land first, in its own commit: it is what
proved the tracked copy was current at the moment it was deleted. Deleting a
generated file you have never verified against its source is how you discover,
later, that the source was wrong. L-07 sequences it the same way — "remove the
tracked duplicate human rendering *after* deterministic on-demand/CI rendering
and a drift check exist" — and this commit is the "after".

The gate transforms rather than disappears. `git diff --exit-code` cannot watch
an untracked file, so what remains of L-07's "CI rejects generation failure or
drift" is the generation half, plus its separate "a downloadable rendering is
reproducible" clause. CI now renders **twice and compares** — which tests both:
the render must succeed (it validates and exits 1 before writing) and it must be
a pure function of the ledger. The severity rule added in the previous commit is
what makes that second property true rather than merely true today. The
rendering is then uploaded as the `findings-ledger-rendering` artifact with
`if: always()`, so a reviewer reads it without a Node run — and can read it
precisely when the job failed.

Six coordinated edits, and the fourth is not optional:
- `.gitignore` — drop the `!` negation; the `.superpowers/*` blanket takes over.
- `.gitattributes` — drop `linguist-generated=true`, now dead.
- `.prettierignore` — drop the entry; Prettier 3 reads the root `.gitignore`.
- `scripts/check-doc-counts.mjs` — drop it from `WATCHED`. A missing watched
  file is pushed to `failures` and exits 1 by design, with a message telling you
  to fix the list. Forgetting this line reds `Docs & Ledger Consistency` and
  `npm run check` on every subsequent run.
- `CLAUDE.md` — the command stays, the "tracked artifact" framing goes.
- `.claude/skills/bughunt-run/SKILL.md` — the human gate between hunt and fix
  reads this file, so it now says to generate it first. That reader is already
  at a terminal that ran the renderer seconds earlier.

13 files, 103 insertions, 9,278 deletions. The check-doc-counts gate goes from
27 claims across 9 documents to 21 across 8; the six it loses were rendered
*from* the ledger they were checked against, so they were self-consistent by
construction and could only ever have failed on a stale rendering — which is the
thing that can no longer exist.

Verified: both directions. `git ls-files .superpowers/` returns exactly two
files; `git check-ignore -v .superpowers/FINDINGS.md` names `.gitignore:87`
while the ledger itself is not ignored (exit 1), so the blanket rule did not
overreach. Deleting the rendering outright and running
`node scripts/check-doc-counts.mjs` prints `21 claim(s) across 8 watched
document(s)` and exits **0** — the proof that the `WATCHED` line was dropped,
because leaving it would have failed here. `npm run check:docs` then regenerates
the file (1,087,051 bytes) and passes. Rendering twice and `cmp`-ing the results
reports byte-identical output. The pre-commit hook was exercised both ways with
the ledger staged: a severity of `moderate` fails with `findings-ledger.json is
invalid`, and a valid tree passes with exit 0. `npm run check:hygiene` passes
with prettier, shellcheck and actionlint all running for real.

Not included: `findings-ledger.json` is untouched by this commit — it is the
canonical copy and it stays tracked, at 1,205,085 bytes, which is *larger* than
the rendering just removed. Anyone reaching for the size argument should know
that untracking the rendering removes 47% of the pair and leaves the bigger,
less readable half; the reason to do it is that the rendering is 100% derived
and would otherwise write a fresh ~1.06 MB blob into permanent history on every
hunt, not that it is the heavy one. No history rewrite — the blobs already
committed stay where they are, per the B1 non-goal. `Server/Makefile` gains no
ledger target: root-scoped, and `make` is not on PATH on a stock Windows box.

Refs RL-07, L-07

* chore: stop tracking the prebuilt hello.wasm plugin example (RL-08)

`Server/plugin/examples/hello/hello.wasm` was 946,410 bytes of committed build
output — 84% of that directory — for a plugin subsystem that is disabled twice
over: it compiles only under `-tags wazero`, and `plugins.enabled` defaults to
`false`. Nothing verified it matched the `main.go` beside it.

The remedy the audit names is a compile-and-compare gate. It cannot be built,
and not for cost reasons: TinyGo embeds absolute host paths from the building
machine's Go SDK and module cache into its output and offers no `-trimpath`
equivalent, so two machines compiling identical source produce different bytes.
A byte-identity gate cannot pass in principle. What is left is a compile-only
check, and that needs three pinned downloads — TinyGo, a *second* Go SDK at
1.25.x because TinyGo 0.40.1 rejects the Go 1.26 this module pins, and Binaryen
129 — on every PR, to prove something weaker than advertised about a subsystem
that ships in zero release artifacts.

So the artifact goes and its provenance is written down instead. BPR-080 asks
that the example WASM be "reproducible **or** provenance-verified" — disjunctive
— and the second branch is the one that is actually reachable here.

The repository had already made this call for itself. `sandbox_wazero_test.go`
uses a 41-byte inline WASM literal, with the comment "Using a literal here
avoids dragging a binary asset into the repo." This extends that from the tests
to the example.

Done:
- `git rm --cached` the artifact; a narrow `.gitignore` entry naming the exact
  path. Deliberately **not** a blanket `*.wasm`: `Client/public/rnnoise.wasm` is
  a vendored npm artifact this repository does not build and the client fetches
  at runtime, so ignoring it would break noise suppression. The rule that
  separates them — untrack build output whose source we own and whose absence
  breaks nothing; keep vendored third-party artifacts required at runtime — is
  written into the ignore comment.
- `Server/.dockerignore` gains `plugin/examples/`. `Dockerfile` does `COPY . .`
  and the file already excluded `scripts/` and `cmd/` but not this, so a
  developer who still has the untracked artifact on disk was shipping it into
  the build context. Same omission B1-5 fixed for `cmd/`.
- The README carried two false statements, both now removed: it claimed the
  plugin is "used by `Server/plugin/plugin_test.go`" and that that test
  "exercises the manifest parser and the loader against this directory".
  Neither is true — `plugin_test.go` builds every fixture in `t.TempDir()`.
- A Provenance section: TinyGo 0.40.1 + Go 1.25.3 + Binaryen 129, why the output
  is not byte-reproducible, and why the compile gate is deferred rather than
  merely absent.
- The ABI-stability sentence L-08 requires, which existed nowhere in the
  repository: the ABI is experimental with no compatibility promise, and both
  halves of "disabled" are named with the files that prove them. Verbatim
  identical in the example README and `docs/contributing.md`.
- The TinyGo/Go/Binaryen table existed in two hand-maintained copies that had
  already drifted in wording. It now lives in the example README only;
  `docs/contributing.md` links to it, which is the pattern that page already
  used two lines above for the ABI itself.

Five files, 87 insertions, 20 deletions, plus the 946,410-byte deletion.

Verified: both directions. The inertness proof is the load-bearing one, and it
is the inverse of B1-5's remove-and-watch-it-fail, because here passing is the
point: with `hello.wasm` moved out of the tree entirely, `go build ./...`,
`go build -tags wazero ./...`, `go vet ./...`, `go test ./plugin/...`,
`go test -tags wazero -count=1 ./plugin/...` and `go test ./api/...` all pass.
`go list ./plugin/...` returns a single package with and without the tag, so
`//go:build tinygo` keeps the example out of the module's build graph. The
narrowness proof is one pair: `git check-ignore -v` matches
`Server/plugin/examples/hello/hello.wasm` at `.gitignore:59` and exits 0, and
exits 1 on `Client/public/rnnoise.wasm`, which `git ls-files` confirms is still
tracked. `git ls-files Server/plugin/examples/` now returns exactly the three
source files. `npm run check:hygiene` and `npm run check:docs` pass.

Not included: no CI compile-and-compare job, per the reasoning above — deferred
to B2, which the issue register already names as L-08's second phase. **L-08 is
not claimed closed**: its closure evidence reads "Deterministic source build
passes", and that is precisely what TinyGo cannot deliver here; the register's
B1/B2 span is what makes deferring it in-scope rather than a slip. No
`tinygo.version` pin file — `Server/sqlc.version` earns its existence through
four mechanical consumers, and nothing would read this one; the gap in
`docs/contributing.md`'s toolchain-pinning policy is closed by recording TinyGo
and Binaryen as a documented exception instead. `main.go`, `plugin.json` and the
README stay tracked — L-08 says keep the source, and this commit keeps all of
it. `.gitattributes` keeps `*.wasm binary`, which still covers the client's
vendored module. No history rewrite: the artifact's existing blobs stay where
they are, per the B1 non-goal.

Refs RL-08, L-08

* docs(plans): retire the removed graphify tooling from the B1 plan (RL-06)

RL-06 asked for a 20.41 MB tracked `graphify-out/` payload to stop being
tracked, after a portable regeneration command and a CI artifact existed.
None of that happened. Instead `a5f7d95` (#1413) deleted the tool outright,
taking all 7 tracked files with it — 20,408,656 bytes, `graph.json` at
19,463,420 — before B1-6 opened. `git ls-files` matches nothing graphify-related
today.

So the outcome RL-06 wanted holds (no large tracked payload, history intact) and
the method it prescribed was bypassed. There is nothing left to do in the
repository. What was left is a documentation problem, and a live one: this plan
is an active document, and it still told a reader to run a tool that does not
exist.

The obvious response — delete every graphify mention — is wrong twice over.
The `.gitignore` rule has to stay: the local directory reached ~208 MB with
cache and dated snapshots on the machine that ran the tool, and dropping the
rule would flood that contributor's `git status` with untracked noise. And the
"do not rewrite history to shrink graphify-out" non-goal has to stay too: the
files are gone from the tree but four `graph.json` revisions remain in the pack
(~71 MiB logical, ~3.2 MiB packed of 13.28 MiB), so the line is still operative.
It is what keeps "closed" honest rather than overclaiming.

Done — nine edits, each a dead instruction rather than a stale mention:
- **B1-2 Step 7, the worst of them.** It told a human to `unset
  GRAPHIFY_SKIP_HOOK`, run `graphify update .`, and `git commit -am` a refresh.
  The tool is gone, and `git commit -am` with nothing to commit exits non-zero
  while reading like a no-op success. Replaced with a retirement note; Step 7 is
  the last step, so nothing renumbers.
- **The "Traps carried forward" entry.** A live instruction, in a list of traps,
  aimed at exactly the multi-commit sequence this phase is. Deleted.
- B1-1 Step 1's `export GRAPHIFY_SKIP_HOOK=1` and its four-line hook rationale,
  collapsed to one sentence of history. The "close any editor, cargo, vite"
  paragraph beside it is still true and stays.
- The RL-06 verdict row, the B1-6 bullet, the flatten's "leave alone" list, the
  `post-commit` parenthetical, B1-3's exclusion list, and the non-goal line.
- `.gitignore`'s stale "delete the dir when convenient" TODO becomes a recorded
  decision citing the commit that caused it.

Verified: `git grep -i graphify` outside the dated audit and the issue register
returns exactly five hits, and every one is intended — the `.gitignore` rule and
four plan lines that are explicitly retirement or history notes ("once began",
"Retired", "closed by deletion", and the non-goal). `git grep
GRAPHIFY_SKIP_HOOK` returns one hit, the sentence recording that it used to be
required. `node scripts/check-doc-counts.mjs` still passes — this file is one of
the documents it watches — and `npx prettier --check` is clean after the
verdict-row rewrite reflowed the table.

Not included: `docs/audit-2026-08-23-repository-layout.md` keeps its RL-06 row —
dated point-in-time snapshot, and `check-doc-counts.mjs` already classifies
`docs/audit-*` as report-only. `docs/plans/repo-health-issue-register-2026-08-23.md`
keeps L-06 and the R-03 row that routes to it, and the reason is *not* that it
is dated: it is in the watched set, i.e. this repository treats it as active. It
is that no B1 phase has updated its closure column, so L-01, L-04, L-05 and
L-09 through L-13 are all closed in fact and open on paper. Changing that
convention in the phase with the least to say about it would leave the register
half-updated, which is worse than uniformly stale. That sweep belongs to `R-06`,
or to one pass at B1's end. No history rewrite, per the non-goal this commit
deliberately keeps.

Refs RL-06, L-06

* docs(plans): record B1 progress through B1-6

The header still read "B1-3 are complete; B1-4 is the next step" three merged
phases later — B1-3 (#1414), B1-4 (#1415) and B1-5 (#1417) have all landed, and
B1-6 is this branch.

B1-3 set this convention with its own `docs(plans): record B1 progress through
B1-3` commit, and then B1-4 and B1-5 both skipped it. A plan that misstates
where it is costs a reader the same confusion whether it is one phase stale or
three; three is just harder to notice, because the header looks deliberate.

Verified: `node scripts/check-doc-counts.mjs` still agrees on 21 claims across 8
watched documents — this file is one of them — and prettier reports it clean.

Refs RL-06 (the phase this records), R-08

* chore(ci): pin Docs & Ledger Consistency as a required check on dev

The previous commits gave `Docs & Ledger Consistency` a gate that can actually
fail: it now rejects a ledger that will not render, on top of the schema check
it already ran. But the job is not among `dev`'s required contexts, so it
reports and cannot block. L-07's closure evidence reads "CI **rejects**
generation failure or drift" — reporting is not rejecting, and the item is not
closed until this lands.

The script's own header already diagnosed the omission: it listed
`Docs & Ledger Consistency` under "deliberately NOT pinned" with the note that
it "looks like an oversight from the 2026-08-25 pass rather than a decision".
That entry is now wrong in the other direction, so it moves out of the
not-pinned list and into a dated note beside `Repository Hygiene`'s.

The name was read off **PR #1418's live check runs** after the job reported
`success` — not copied out of `ci.yml`. That order is B1-3's rule and it is not
pedantry: the B0 script records that three of the pinned names exist in no
workflow file at all, because CodeQL runs from GitHub default setup.

Two count claims move with it. `docs/contributing.md` said "All ten required
checks" and the HP-0 scorecard's table said **10**, both stale since B1-3 added
`Repository Hygiene` and now doubly so. B1-5 spotted the first and deferred it
to "the branch-protection item's to fix"; this is that item, and it is also the
commit that changes the number, so leaving them stale here would make this
commit the proximate cause of a documented inconsistency. The scorecard is in
`check-doc-counts.mjs`'s watched set — the repository classifies it as active,
not as a frozen snapshot — so the don't-edit-dated-docs rule does not shield it.
Its pinned block gains both names and a line recording when each was added.

NOT APPLIED YET. Running this script is `gh api -X PUT
repos/J3vb/OwnCord/branches/dev/protection`, a repository-settings write this
session cannot perform. Run `bash docs/plans/b0-dev-branch-protection.sh` after
this PR merges.

The pre-flight is clear, stated positively rather than assumed: a required check
that never reports blocks every PR forever, which is the hazard B1-3's own
NOT-APPLIED note was about. It does not apply here. `Docs & Ledger Consistency`
has existed in `dev`'s `ci.yml` since #1412, so no in-flight branch predates the
job, and it reported `success` on this PR in 11 seconds.

Verified: `bash -n` and `shellcheck` are clean. Extracting the heredoc and
parsing it with `node` reports **12** contexts including
`Docs & Ledger Consistency`, spelled exactly as the live check reports it — the
JSON is machine-checked rather than eyeballed, because a typo here is a branch
that cannot merge. `node scripts/check-doc-counts.mjs` still agrees on 21 claims
across 8 watched documents, the scorecard among them, and
`npm run check:hygiene` passes with prettier, shellcheck and actionlint all
running.

Not included: the script is not run — that is the owner's step, above. No other
context is added or removed; the four remaining "deliberately NOT pinned"
entries keep their recorded reasons, including `Admin Panel E2E`, whose
`continue-on-error: true` still makes requiring it theatre until `R-01`
graduates it.

Refs RL-07, L-07, RL-14, G-03

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-08-27 11:08:07 +02:00
J3vbandClaude 9eba6969d2 B1-5: ownership moves (RL-09 / L-09, RL-10 / L-10, RL-11 / L-11, RL-13 / L-12) (#1417)
* refactor: move the protocol schema to protocol/schema.json (RL-09)

The WebSocket message-type schema is the one artifact in this repository that
neither component owns: `Server/ws/message_types.go` and
`Client/src/lib/protocolTypes.ts` are both generated from it, and neither may
be hand-edited. It nonetheless lived at `docs/protocol-schema.json` — filed
under the directory for prose, whose own README calls it "Reference" material
— and its generator lived at `Server/scripts/genprotocol/`, i.e. inside one of
the two consumers. Ownership was legible from neither location.

The obvious fix — move the generator to the repository root alongside the
schema, so the whole tool is at the cross-component boundary — is wrong here.
The generator is a Go `package main`, and Go modules are directory-rooted:
`Server/go.mod` roots at `Server/`, so a root-level Go program needs a second
module or a `go.work`. That second module would sit outside every path filter
this repository already has — `golangci-lint` runs with `working-directory:
Server/` (ci.yml), `go vet ./...` runs from `Server/` (scripts/run.mjs,
.githooks/pre-commit), `.githooks/pre-commit` selects Go files with
`^Server/.*\.go$`, `.githooks/pre-push` sets `server_changed` on `^Server/`,
setup-go caches on `Server/go.sum`, and dependabot has one gomod block for
`/Server`. Six gates would silently stop covering the generator, each failing
open. The schema is data and moves freely; the generator is Go and stays where
the Go toolchain already runs.

Done instead:
- `docs/protocol-schema.json` -> `protocol/schema.json`. A new top-level
  `protocol/` is the cross-component boundary, with a `README.md` naming the
  two generated consumers, the one command, and the four gates.
- `Server/scripts/genprotocol/` -> `Server/cmd/genprotocol/`, the module's
  conventional home for an executable. This also empties `Server/scripts/` of
  Go entry points except `seed.go`, which RL-10 moves next.
- `Server/cmd/` added to `Server/.dockerignore` and `Server/.air.toml`, which
  both already excluded `Server/scripts/`. Without this the move would have
  silently widened the Docker build context and the air watch set.

27 files, 115 insertions, 76 deletions. Two runtime path resolvers re-pointed
(`cmd/genprotocol/main.go:41` `-schema` default, `ws/protocol_contract_test.go:67`
`filepath.Join`); two git-hook grep patterns (`pre-commit:53`, `pre-push:57`);
eight generator call sites across five files (Makefile x2, scripts/run.mjs x2,
pre-commit x2, ci-check skill, bughunt-fix.js); two broken relative markdown
links (docs/README.md:47, docs/protocol.md:1497); two generated files
regenerated, header lines only, zero constants changed; two ledger prose hits
plus a `render-ledger.mjs` re-render. No new verify was written: the
regenerate-and-diff check is already enforced three times (CI `make
protocol-verify`, `.githooks/pre-commit`, `npm run check:server`) and
`ws/protocol_contract_test.go` independently checks the schema against the
constants a fourth time.

Verified: both directions, for both resolvers. With `protocol/schema.json`
removed, `go test ./ws/ -run TestProtocol` fails with `reading protocol schema
at /home/user/OwnCord/protocol/schema.json: no such file or directory` (two
tests) and `go run ./cmd/genprotocol` exits 1 with `read schema: open
../protocol/schema.json: no such file or directory`; with the file restored
both pass. So the new path is genuinely resolved, not merely spelled in a
comment. The hook patterns were exercised directly: the pre-commit pattern
matches `protocol/schema.json` and `Server/cmd/genprotocol/main.go` and no
longer matches `docs/protocol-schema.json`; the pre-push pattern matches
`protocol/schema.json`. `go run ./cmd/genprotocol` twice in a row leaves
`git diff --exit-code ws/message_types.go ../Client/src/lib/protocolTypes.ts`
clean, so the committed outputs are exactly what the generator emits.
`go build ./...` and `go vet ./...` pass; `npx prettier --check .`,
`npm run typecheck` and `npm run lint` pass; `node .superpowers/render-ledger.mjs
--check` reports 348 findings valid.

Not included: the four dated `docs/audit-*.md` files, the older
`docs/plans/*`, and `CHANGELOG.md` keep the old path — they are point-in-time
records, and `.prettierignore` and `scripts/check-doc-counts.mjs` already
treat them as deliberately unmaintained. The B1 plan itself keeps its own
wording, since it states intent rather than current state. `Server/scripts/`
is not deleted: it still holds `seed.go` (RL-10), `k6/`, `toxiproxy/` and two
shell scripts. `Server/telemetry/metrics.go:19` declares a scope for a
`Server/voice` package that does not exist — spotted here, unrelated to this
move, left for RL-13's sweep to carry forward verbatim rather than fixed
inside a relocation. No `seed:` Make target was added.

Refs RL-09, L-09

* refactor: move the seed tool under Server/cmd/seed (RL-10)

`Server/scripts/seed.go` was a `package main` sitting directly in
`Server/scripts/`, which made `Server/scripts` itself one of the module's
three main packages — a developer tool in the module's build graph under a
directory name that says "loose scripts". It also did filesystem work in
`func init()`: `os.MkdirAll("data", 0o750)` ran before `flag.Parse()`, so the
directory appeared even when the tool immediately refused to run.

The audit row (RL-10) claims that `init()` fires "during test discovery". It
does not, and the obvious fix aimed at that claim would be aimed at nothing:
`Server/scripts/` contains zero `_test.go` files, so Go never builds a test
binary there and `go test ./...` never runs the `init()`. The residual defect
is narrower and real — an untagged `package main` in the build graph, plus a
side effect on a path (`go run ./cmd/seed -h`) that has nothing to do with
tests.

Done:
- `Server/scripts/seed.go` -> `Server/cmd/seed/main.go`, joining
  `cmd/genprotocol/` from RL-09. `Server/scripts/` now holds shell and JS
  tooling only (docker-smoke.sh, k6/, toxiproxy/, voice-test.sh) and no Go
  entry point at all.
- The `os.MkdirAll` moved out of `init()` to immediately before `db.Open` in
  `main()` — the one call that needs the directory, since `db.Open` ->
  `OpenWithMaxReaders` -> `openFile` creates no intermediate directories.
- The package doc comment's usage lines were wrong in two ways, not one: they
  named `go run scripts/seed.go`, which no longer exists, and they omitted
  the mandatory `-confirm-dev`, so neither documented command could ever have
  run. Both corrected, and `seed.go is a standalone tool` became the
  conventional `Command seed populates ...`.
- `Server/CLAUDE.md`'s Layout list now names `cmd/` and states that no Go
  entry point lives in `scripts/`.

Two files, 20 insertions, 17 deletions. `go list` main packages go from
`{server, server/cmd/genprotocol, server/scripts}` to `{server,
server/cmd/genprotocol, server/cmd/seed}` — the count is unchanged at three,
which is the honest framing: this relocates a main package to a conventional
path, it does not remove one from the build graph.

Verified: both directions, by building the pre-change file and the
post-change file and running each in a fresh empty directory. Before, `seed`
with no flags exits 1 *and leaves a `data/` directory behind*; `seed -h`
exits 0 and also leaves `data/` behind. After, both exit the same way and
create nothing — `data/ exists=NO` in each case. The happy path is unchanged:
`seed -confirm-dev` in an empty directory creates `data/` at mode 0750,
writes `data/chatserver.db`, and reports 4 users / 5 channels / 31 messages;
a second run reports 0 new rows, so idempotence survives. The old documented
invocation now fails loudly (`go run scripts/seed.go` -> `stat
scripts/seed.go: no such file or directory`) and the new one is what the
comment says. All four build-tag variants compile, `go vet ./...` passes,
`gofmt -l` is clean outside `db/dbgen`, and `npx prettier --check .` passes.

Behaviour delta, called out rather than left silent: the two cases above
(`-h`, and a missing `-confirm-dev`) no longer create `./data`. That is a
change, not a pure relocation. It is the change RL-10 asks for — the remedy
text is "remove import/test-time filesystem side effects" — and the
alternative that preserves the old behaviour exactly, making the `MkdirAll`
the first statement of `main()` before `flag.Parse()`, would keep precisely
the side effect the item exists to remove.

Not included: `Server/scripts/genprotocol` was moved to `Server/cmd/` by the
RL-09 commit rather than here, so the "executable tooling under conventional
command ownership" class is closed across the two commits, not this one
alone. `filepath.Dir(*dbPath)` was evaluated for the `MkdirAll` and rejected:
it would fix a real gap (`-db /elsewhere/x.db` still creates a useless
`./data` and does not create `/elsewhere`) but it means creating an arbitrary
directory from CLI input, and that is a behaviour change past "shift it out
of `init()`" — worth its own item. No `make seed` target was added, and the
dated `docs/audit-*.md` rows naming `Server/scripts/seed.go` keep the old
path. The findings ledger has zero references to this file, so no re-render
was needed.

Refs RL-10, L-10

* test: give the cross-stack contracts a named tier (RL-11)

`Client/tests/unit/admin-static-channel-perms.test.ts` reads and executes
`Server/admin/static/index.html`. Filed under `tests/unit`, nothing about its
location or name said it locks a server-owned artifact, so a Go developer
editing the admin SPA got a red check called "Client Unit Tests" with no clue
why.

The register describes this as one file. It is not, and the measured set does
not match the description in either direction:
- Client -> Server: exactly ONE test crosses by filesystem read, not two.
  `main-page.test.ts` was named in the plan but only carries a prose comment
  citing `Server/admin/update_handlers.go:181` at line 1046 — no read, no
  import, nothing to move.
- Server -> Client: the four tests the plan named do not cross.
  `waf_test.go`/`waf_crs_test.go` set a `User-Agent: OwnCordClient/1.0`
  literal that appears nowhere under `Client/`; `ws_integration_test.go:289`
  and `sanitize_content_fuzz_test.go:46` are comments. The real crossing is
  one the register never named: `Server/updater/updater_test.go:630` does
  `os.ReadFile` on `Client/src-tauri/tauri.conf.json`.

The obvious fixes are both wrong. Moving the invariant "to the owning server
test" cannot work: `Server/go.mod` carries no JavaScript engine (no goja,
otto, v8go, quickjs, rogchap, duktape), so a Go port could only assert at the
text level like `admin/perm_grid_test.go` does — and that is not a
substitute. Flipping the guard at `admin/static/index.html:1182` to
`targetIsTouchedRole=false` reintroduces OC-0154 in full while leaving every
greppable identifier intact, so a text-level test passes on a broken file.
Relocating it to the e2e admin journey is worse: that job is
`continue-on-error: true` and deliberately unpinned ("requiring it is
theatre" — `docs/plans/b0-dev-branch-protection.sh`), so it would convert a
blocking, pinned gate into one that is green regardless. And the journey does
not cover the invariant today: `grep -Eic "perm|access|role|override|matrix"`
over its 142 lines returns 0, so the "if e2e already covers it, delete"
branch never fires.

Done — one tier, applied to the whole set, defined by artifact coupling and
placed by runtime capability:
- New `Client/tests/contract/`, holding
  `server-admin-static-channel-perms.test.ts`. Same directory depth, so
  `../../../Server/...` still resolves; the body is byte-identical apart from
  a header naming the owner and the runner.
- `Server/updater/tauri_key_contract_test.go` splits the one cross-component
  Go test out of `updater_test.go` verbatim, same `package updater`. It stays
  in Go — placement follows capability, and Go parses JSON fine — so only the
  file name has to declare the crossing. Without this the item would have
  been "moved one file and declared the class closed".
- `npm run test:contract`, and the tier, the membership rule and a
  blocking/non-blocking table in `docs/contributing.md#testing`, which
  previously described no tiers at all.
- `Client/CLAUDE.md`'s tier list was missing `tests/e2e/admin` and
  `tests/e2e/native` before this; it now lists all seven and states the rule.
  `Server/CLAUDE.md` records why the SPA's execution-level invariant is
  locked from the client tree, so nobody "fixes" it into a regex.
- Ledger `OC-0154.fix.test` re-pointed and `FINDINGS.md` re-rendered;
  `.claude/workflows/bughunt.js` — the workflow that produced OC-0154 — no
  longer describes the TS test surface as `tests/unit/*.test.ts` only.
- Three stale cross-stack pointers of exactly the class this item is about:
  `tests/e2e/helpers.ts:348,351` and `tests/unit/types.test.ts:13` named
  `docs/brain/06-Specs/PROTOCOL.md`, which does not exist (`docs/brain/` is a
  gitignored path); all now name `docs/protocol.md`.

15 files, 125 insertions, 33 deletions. No CI job, workflow, vitest,
tsconfig, eslint, knip or stryker change, and no new pinned check —
`ci.yml`'s `npx vitest run --coverage` has no path filter and
`vitest.config.ts` includes `tests/**/*.test.ts`, so enforcement after the
move is bit-identical to enforcement before it. That is deliberate: `dev`
pins 11 contexts and a 12th is a branch-protection API write, not something a
PR can do, so any new job would be advisory until someone separately changed
repository settings — strictly less protection than today.

Verified: both directions, and the assertion was not weakened. Flipping
`admin/static/index.html:1182` to `const targetIsTouchedRole=false;` makes
the moved test fail (`AssertionError: expected 'DELETE' not to be 'DELETE'`);
`git checkout` of that file makes it pass again — so the invariant survived
the move intact rather than becoming a test that passes anywhere. The split
Go test's cross-boundary read is live too: with
`Client/src-tauri/tauri.conf.json` moved away, `go test ./updater/` fails
with `ReadFile(../../Client/src-tauri/tauri.conf.json): no such file or
directory` from `tauri_key_contract_test.go:20`, and passes once restored.
The full client suite is 192 files / 5257 tests passing, identical to the
count before the move; `npm run typecheck` passes, which proves
`tests/contract/` is inside the tsconfig graph and that `tests/types/jsdom.d.ts`
still resolves the moved test's `import { JSDOM }`. `npm run lint`,
`npx prettier --check .`, `go vet ./...` and `go test ./updater/` all pass.
`git grep "tests/unit/admin-static-channel-perms"` finds no survivor outside
the B1 plan itself.

Not included: nothing was deleted, because no e2e sibling covers OC-0154.
`Client/tests/types/jsdom.d.ts` was neither moved nor deleted — it is still
the only type source for the moved test's `jsdom` import. `capabilities-scope.test.ts`
and `tauri-conf-webview2-args.test.ts` read `src-tauri/` and stay in
`tests/unit`: `src-tauri` is inside the `Client` component, so they are not
contract tests, and the rule earns that rather than hand-waving it — moving
them would have forced repoints of ledger entry OC-0089 and
`docs/security.md:64` for no gain. Each gained a one-line header saying why.
`Server/admin/perm_grid_test.go` and `emoji_section_test.go` read their own
package's embedded asset and are unchanged; they are the text-level
complement to the execution-level test, not duplicates. No JS engine was
added to `go.mod`, no npm root was created under `Server/`, and no root-level
`tests/` tier was created — there is no runner for one and no way to make it
blocking from a PR. Separately noticed and NOT fixed here:
`docs/contributing.md:221` still says "All ten required checks" while
`docs/plans/b0-dev-branch-protection.sh` pins eleven since B1-3 added
`Repository Hygiene`, and `docs/plans/hp-0-scorecard-2026-08-25.md:109` is
stale the same way — that is the branch-protection item's to fix, not this
one's, and one register item per commit.

Refs RL-11, L-11

* refactor: rename the Go module to github.com/J3vb/OwnCord/Server (RL-13)

`Server/go.mod` declared `github.com/owncord/server` while the public
repository is `github.com/J3vb/OwnCord`. Nothing resolves that path — there is
no `owncord` GitHub org and no vanity-import host serving go-import metadata
for it — so every import line in the tree named a location that does not
exist. It compiles because a main module's own path is never fetched, which is
exactly why it went unnoticed.

The obvious fix — an AST-aware import rewriter (`gomvpkg`, `go mod edit`) —
is wrong here, and provably so. Six of the 722 occurrences are not imports at
all: `api/main_test.go:20` (a goleak `IgnoreTopFunction` pattern),
`telemetry/metrics.go:17-19` (three OTel instrumentation-scope names),
`invariants/syncutil_locks.go:73` (a diagnostic message), and
`invariants/syncutil_locks_test.go:56` (an import line inside a raw-string Go
fixture). An import rewriter touches none of them, and the compiler cannot
see any of them either.

Done as one scripted substitution over `git ls-files`, anchored on the full
`github.com/owncord/server` string. The anchor matters: `owncord-server` is a
different identifier — the OTel `service.name` (`config/config.go`,
`telemetry/telemetry_otel.go`) and the GHCR image name
(`.github/workflows/release.yml`, `docker-compose.yml`) — and a looser pattern
would have moved it. It is untouched: 10 occurrences across 9 files, before
and after.

350 files, 728 insertions, 728 deletions. 722 occurrences in 344 Go files,
plus `go.mod:1`, the `sed` at `Makefile:67`, `Server/CLAUDE.md:3`,
`docs/architecture/server.md:5`, and the ledger pair
(`findings-ledger.json:3758` plus a `render-ledger.mjs` re-render of
`FINDINGS.md`). Zero in any workflow, zero in the Dockerfile, zero in
`Server/.golangci.yml` (no `local-prefixes`, `gci`, `importas` or `depguard`
rule keys on the module path, so import grouping is not configured anywhere).

The plan's blast-radius estimate missed one thing, and it is the one that
would have gone red: **gofmt**. `J` (0x4A) sorts before every lowercase
letter, so in the 36 files where a module-local import shares a contiguous
group with a third-party one, the module's imports must move above
`github.com/go-chi/...`. `gofmt -l` was clean before the substitution and
listed exactly 36 files after it; `gofmt -w` on those 36 restores it to
clean. `gofmt` is an enforced gate — the `formatters` block in
`Server/.golangci.yml`, which is S-05 — so a substitution-only commit fails
Lint.

Verified: both directions, and the line accounting is exact. Every added line
in this diff contains the new module path (728) and every removed line
contains the old one (728); the count of changed lines containing neither is
**zero**, so the gofmt re-sort moved module-path lines only and touched no
third-party import. The residual check
(`git ls-files -z | xargs -0 grep -n 'github\.com/owncord/server'`) returns
exactly two hits, both deliberately out of scope: the RL-13 row in
`docs/audit-2026-08-23-repository-layout.md` and the measurement row in this
phase's own plan. The compiler-invisible half was proven by reverting *only*
`api/main_test.go:20` to the old path on the otherwise-renamed tree:
`go build ./...` and `go vet ./api/` both still pass — they see nothing wrong
— while `go test ./api/` FAILS, because the runtime function name now carries
the new path and goleak stops ignoring `ws.(*Hub).Run.func1`. Restoring the
line makes it pass. `go.sum` is byte-identical (no `go mod tidy` was run and
none was needed). All four build-tag variants compile; `go vet ./...`,
`go vet -tags otel,wazero ./...` and `go vet -tags deadlock ./...` pass;
`go test -race ./...` is 16/16 packages green; `go test -tags deadlock ./...`
passes; the tag-gated `./plugin/...` (wazero) and `./telemetry/...` (otel)
runs pass. `golangci-lint` v2.11.3 — the pinned CI version, rebuilt locally
against Go 1.26 because the packaged binary cannot load a 1.26 config —
reports **0 issues**. `go run ./cmd/genprotocol` leaves
`git diff --exit-code ws/message_types.go ../Client/src/lib/protocolTypes.ts`
clean, so the rename does not reach the generated protocol constants.
`npx prettier --check .` and `node .superpowers/render-ledger.mjs --check`
pass.

Not included: `docs/audit-2026-08-23-repository-layout.md` and
`docs/plans/b1-repository-foundation-2026-08-25.md` keep the old path — they
are the audit row and the measurement that motivated this change, and
rewriting them would erase the record of what was measured. They are why the
residual check needs a two-path allowance rather than being empty; that
allowance is stated above rather than hidden in a pathspec.
`telemetry/metrics.go:19` declares `scopeVoice` for a `Server/voice` package
that does not exist; the substitution carried the dead path forward verbatim
as `github.com/J3vb/OwnCord/Server/voice` rather than fixing it, because
correcting a real observability bug inside a mechanical rename would hide it
in a 350-file diff. It needs its own item. No `go.work`, no second module,
and no vanity-import host was set up — the new path resolves against the real
repository, but nothing imports this module as a library, so `go get`
reachability was not exercised either way.

Refs RL-13, L-12

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-08-27 07:11:18 +02:00
J3vb 9ee306310f docs: apply skill-review findings to ci-check and the project skills (#1416)
The observation log had accumulated 46 open entries against a last review of
2026-08-14. Seven of them target skills tracked in this repository and were
verified still-unapplied against the current files.

`ci-check` gains four things it was missing. It never mentioned `cargo audit`,
which CI runs pinned at 0.22.1 in `tauri-build` — the one gate that turns red
with zero local changes, because an upstream advisory breaks a branch that was
clean yesterday, and the one a hand-written mirror silently drops because no
edit provokes it. It never mentioned that `release.yml` is tag-triggered and
PR-ungated, so a smoke/sign/strip step added only there first executes on the
release; #1376 shipped a smoke harness whose own bug then blocked a release,
and #1378 fixed it structurally by extracting `Server/scripts/docker-smoke.sh`
for both workflows. And it had no guidance for reading a red check at all: a
new section adds causality-before-forensics triage (diff the changed-file set
against the failing job's input surface before opening a log — a workflow-only
diff cannot cause a Go goroutine leak), the lockfile-fork diagnosis for
dependency bumps (a 1 → 2 entry-count transition means the update forked the
dependency and revoked the features it was borrowing, so aligning versions is
the fix, not setting the feature the new copy demands), and the known-flake
table promoted to a signature-to-recovery index, now including the apt-mirror
hang that cancels `tauri-build` by timeout.

The baseline rule that came with the triage section needed adjusting rather
than transcribing. Its source observation recorded `golangci-lint`'s known-red
complexity baseline as 23 cyclop / 6 dupl / 21 funlen / 12 nestif; #1389
cleared that to zero, so quoting those numbers would have taught the reader to
excuse a failure that is now genuinely theirs. The rule is recorded without
them, stating that the repo currently carries no known-red gate and what to do
if one is ever reintroduced.

`protocol-change` claimed the schema is the source of truth without saying what
it covers. It holds message-type names only, so a payload-field change touches
the Go command/message files, the client types and `docs/protocol.md` and never
the schema — routing one through the regenerate cycle is wasted work. A table
splits the three cases, with the relay-handler caveat: a server that
re-serialises drops unknown fields, so a forwarded field is not backward
compatible with older servers.

`task-observer`'s numbering discipline treated collisions as a parallel-human
accident. They are structural in fan-out workflows, because a dispatched
subagent has the skill active in its own context and writes to the same log.

`bughunt-run` covered findings blocked by a circuit breaker but not findings
that went stale: a later hunt routinely fixes a blocked finding as a side
effect of an overlapping sibling, and a saved debris patch stops applying once
a refactor rewrites its files. Of 6 findings blocked on 2026-08-14, 2 were
already fixed 5 days later.

`docs/contributing.md` gains the commit-body convention that was being followed
without being written down anywhere — reasoning over diff-restatement, a
`Verified:` paragraph proving both directions, and an explicit `Not included:`
line. That last one is what keeps adjacent scope from becoming either silent
drift or an unnecessary blocking question.

Verified: each edit was checked against the live file before applying, which
changed two outcomes. Observation 50 (make the hunt's stop rule measure
coverage, not just quietness) is already implemented — `bughunt-run` documents
`coverage + dry is the real stop`, `stalledCoverage` and
`coverage.uncoveredAtStop`, landed by #1399 — so it is marked actioned rather
than re-applied. Observation 42 looked covered by the same grep and was not:
the existing text handles breaker-blocked findings, a different case from a
finding a sibling fix already closed. Confirmed absent before editing:
`cargo audit` and `release.yml` in ci-check, `payload` in protocol-change,
`subagent` in task-observer. `npm run check:hygiene` passes (prettier clean on
all five files); `npm run check:docs` passes.

Not included: the 21 open observations targeting `superpowers:*` plugin skills,
which live in a versioned plugin cache and are overwritten on update — they are
being routed to a separate user-owned extras skill outside this repository. The
6 targeting `graphify` are deferred pending a decision on whether that skill is
still in use here now that #1413 removed its repository integration. The 5
new-skill candidates are noted only; a review is not permitted to create skills.

Refs skill-observations #25, #35, #39, #41, #42, #43, #45, #58, #59, #63
2026-08-26 19:01:40 +00:00
J3vb ece06f6d01 B1-4: dependency automation (RL-05 / L-05, RL-18) (#1415)
* chore(deps): cover the root and mcp-introspect npm roots

The repository has three npm package roots — `/` (changelogen, prettier),
`/Client`, and `/tools/mcp-introspect` (@modelcontextprotocol/sdk, zod) —
each with its own package-lock.json, and `npm run bootstrap` runs `npm ci`
in all three. Dependabot watched exactly one of them. The root's prettier is
what the Repository Hygiene gate runs, so the formatting gate's own toolchain
was drifting unwatched.

The obvious fix is to collapse the three roots into an npm workspace and
watch one lockfile. Measured on npm 11.17 / Node 26 rather than assumed, that
trade is bad, and it is bad for different reasons than expected. Workspaces do
not break the things you would predict: `npm ci` inside `Client/` still exits
0, `npm run <script>` still resolves the hoisted binaries because npm prepends
every ancestor `node_modules/.bin` to PATH, and `engine-strict` still fails
the install on a wrong Node major. What they cost is ten CI steps keyed on
`cache-dependency-path: Client/package-lock.json` (six in ci.yml, four in the
tag-only, CI-ungated release.yml) pointing at a file that stops existing; the
Repository Hygiene job's deliberate root-only install growing 970 ms to
6172 ms and 39 to 318 packages unless every call site remembers
`--workspaces=false`; and one shared lockfile putting all three npm Dependabot
groups back into the same file, which is precisely the rebase storm the
grouping comment at the top of dependabot.yml exists to prevent. The measured
benefit is one 298 KB lockfile instead of three (17 KB / 253 KB / 42 KB) and
614 resolved packages deduped to 582 — 32 packages, 5.2% — with client install
time unchanged at 5642 ms against 5667 ms.

So the roots stay separate and each gets its own block, matching the four that
already exist: grouped to one PR, majors ignored, weekly on Monday. A single
block with `directories:` was rejected for the same reason as workspaces —
grouping only works while a group rewrites exactly one lockfile. The decision
and its numbers are recorded in docs/contributing.md under Dependency Policy,
so the next person to propose workspaces reads the measurement instead of
repeating it.

Verified: a coverage checker cross-references every `package-ecosystem` /
`directory` pair in dependabot.yml against every manifest in `git ls-files`,
in both directions. Against dev at 2a37f386 it reports `UNWATCHED npm
package.json` and `UNWATCHED npm tools/mcp-introspect/package.json` (plus
Server/Dockerfile, which the next commit covers). Against this commit both npm
rows read `ok`, and the forward direction confirms each newly declared
directory really holds a package.json. `npx prettier --check` reports both
edited files unchanged, so the B1-3 formatting gate stays green.

Not included: the docker ecosystem (next commit, RL-18); immutable image
digests for release/runtime containers, which is R-04's remaining half and
belongs to B6; and turning the coverage checker into a permanent
`check:hygiene` gate — a new manifest root can still drift unwatched, which is
how this gap arose, but that is new gate machinery rather than the coverage
this item asks for.

Refs RL-05 / L-05

* chore(deps): watch the server container base images

Server/Dockerfile pulls `golang:1.26-bookworm` to build and
`gcr.io/distroless/static-debian12` to run, and nothing watched either. Every
other dependency root in the repository is on a weekly Dependabot schedule, so
the one artefact that ships to users as a whole filesystem was the only one
whose upstream moved silently — including its CA certificates, which the
Dockerfile comment specifically calls out as the reason distroless was chosen
over scratch.

The obvious fix is to pin both images by digest and be done. That is the wrong
move here for two reasons. A digest pin with no automation behind it is worse
than a tag: it freezes the base image at whatever was current the day someone
typed it, and a frozen distroless base is a frozen CA bundle. And digest
refresh for release and runtime images is R-04's other half, scoped to B6
alongside the smoke tests that have to gate it — landing half of it here would
leave the digests pinned and the refresh unowned.

So this adds the `docker` ecosystem for /Server on the same terms as the five
blocks around it: grouped to one PR, majors ignored, weekly on Monday. Be
precise about what that actually buys, because it is less than the block
implies. Of the two images only `golang:1.26-bookworm` carries a comparable
version, so it is the only one Dependabot can act on today;
`gcr.io/distroless/static-debian12` has no version tag, and an untagged image
is not something a version update can move — it needs the digest pinning that
B6 owns. The comment above the block records that the Go builder tag tracks
Server/go.mod and every `actions/setup-go` in CI, so a `1.27-bookworm` PR is a
prompt to move all three together rather than a standalone merge.

Verified: the coverage checker cross-references every `package-ecosystem` /
`directory` pair against every manifest in `git ls-files`, in both directions.
Against dev at 2a37f386 the reverse direction reports `UNWATCHED docker
Server/Dockerfile`; against this commit every row reads `ok` and it exits 0 —
`ALL ROOTS WATCHED`, six blocks covering six manifest roots, with the forward
direction confirming /Server really holds a Dockerfile. `npx prettier --check`
passes on the edited file.

Not included: Server/docker-compose.yml and Server/docker-compose.otel.yml.
Their four images are `ghcr.io/j3vb/owncord-server:latest` (this repository's
own published image), `livekit/livekit-server:v1` (a floating major tag, and
majors are ignored everywhere), `jaegertracing/all-in-one:latest` and
`prom/prometheus:latest` — none of which a version update can move, so a
compose block would be configuration that provably produces nothing. Also not
included: immutable digests plus digest-refresh PRs with smoke tests for the
release and runtime images, which is the remainder of R-04 and belongs to B6.

Refs RL-18
2026-08-26 18:33:55 +00:00
J3vbandClaude Opus 5 2a37f386f9 B1-3: repository hygiene gates (RL-19 / L-13, S-05) (#1414)
* chore(format): one Prettier config at the repository root

Every formatting rule in this repository lived under Client/ and covered
exactly two globs: Client/src/**/*.ts and Client/tests/**/*.ts. Root Markdown,
all of docs/, every YAML and JSON, all CSS, the root scripts and
tools/mcp-introspect were formatted by nothing. There was no .editorconfig.

The obvious fix -- a second Prettier config at the root for "everything else"
-- gives two configs and two ignore files that can silently disagree about the
same file. So the root takes ownership instead: config, ignore file and gate
move up, and Client/ folds in. Client's inline "prettier" block, its
.prettierignore, its format/format:check scripts and its now-unused prettier
devDependency are all deleted; knip would have failed client-check on that last
one.

The .prettierrc.json values are lifted byte-for-byte from Client/package.json,
which is what keeps the reformat commit free of client TypeScript churn: 87
tracked files need reformatting and not one of them is under Client/src or
Client/tests.

.prettierignore carries only what .gitignore does not. Prettier 3 reads the
root .gitignore by default, so node_modules/, dist/, coverage/,
Client/src/generated/ and docs/security-findings/ need no entry. It does NOT
read nested .gitignore files, which is why .remember/ is listed explicitly --
38 untracked per-machine scratch files were otherwise able to turn a shared
gate red. graphify-out/ is listed because its seven files are tracked and
.graphify_labels.json is signed byte-for-byte by its .sig, so formatting it
would silently invalidate the signature.

check:hygiene is registered in scripts/run.mjs and folded into check and
release:preflight. It deliberately contains no `gofmt -l` step: gofmt -l prints
offenders and still exits 0, so it cannot fail a build. Go formatting is
enforced separately.

shellcheck and actionlint take their file lists from `git ls-files`, never a
filesystem glob -- .claude/worktrees/ holds a gitignored pre-flatten copy of
the tree with three .sh files a glob would happily lint.

This commit leaves the tree non-conformant on purpose. The reformat is the next
commit, so the 87-file diff is reviewable separately from the rule that caused
it.

Not included: editorconfig-checker. .editorconfig is the editor baseline the
audit asked for; Prettier, gofmt and rustfmt already fail CI on the same
indentation and newline rules, so a fourth tool checking them again is a gate
with no failure mode of its own.

Verified: `npx prettier --check .` names 87 tracked files and zero untracked
ones; the same command listed 38 .remember/ scratch files before the ignore
entry and none after. `node scripts/run.mjs --list` resolves check:hygiene to 8
shell targets and 4 workflow targets. Both package.json files parse.

Refs RL-19 / L-13, S-05.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* chore(format): reformat the tree to the repository Prettier rules

Mechanical. This commit is `npx prettier --write .` and nothing else -- the
rule that caused it landed in the previous commit so this diff can be reviewed
as a transformation rather than as 84 files of hunks.

84 tracked files: 54 Markdown, 7 .mjs, 7 JSON, 6 YAML, 4 .js, 3 CSS, 2
TypeScript (the two Playwright configs at Client's root, which the old
Client/src + Client/tests globs never covered). No file under Client/src or
Client/tests moves, because .prettierrc.json carries Client's former inline
values byte-for-byte.

Prettier rewrote 87 files, not 84. The three in .github/ISSUE_TEMPLATE/ had
CRLF on disk and differ only in line endings, which .gitattributes
(`* text=auto eol=lf`) already normalises, so their committed blobs are
unchanged. Worth knowing before someone reconciles the two numbers.

The largest single diff is .superpowers/findings-ledger.json at 7976 lines
rewritten. That is safe to format: nothing writes the ledger programmatically
-- render-ledger.mjs reads it and writes only FINDINGS.md -- so no tool will
fight Prettier over its style on the next hunt. FINDINGS.md itself is ignored
as generated.

Verified: `npx prettier --check .` reports "All matched files use Prettier code
style", so the pass is both complete and idempotent. All 7 reformatted JSON
files were parsed before and after and compared as values: semantically
identical, zero content changes. `node .superpowers/render-ledger.mjs --check`
still reports 348 valid findings and leaves FINDINGS.md untouched.
`node scripts/check-doc-counts.mjs` still passes its selftest and still agrees
on 27 claims across 9 watched documents -- table realignment did not break the
patterns it matches on. `node scripts/run.mjs --list` still parses.

Refs RL-19 / L-13, S-05.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* chore(lint): enforce Go formatting in the Server linter

S-05: repository-wide Go formatting was not a required gate. The only gofmt
enforcement anywhere was .githooks/pre-commit, which is opt-in per clone
(`npm run hooks:install`), only sees staged files, and warns-and-skips when
gofmt is off PATH.

The obvious fix -- a `gofmt -l` step in CI -- does not work: `gofmt -l` prints
its offenders and still exits 0, so the step passes no matter what it finds.
scripts/run.mjs has the same problem, which is why check:hygiene has no Go step
either.

So gofmt goes where it can actually fail something: Server/.golangci.yml. The
file was already `version: "2"` but had no `formatters:` block at all, so the
19 enabled linters ran with zero formatters. In v2 gofmt/gofumpt/goimports
moved out of `linters.enable` into their own section with its own exclusions.
Adding it there means the gate reports through the Lint step of "Server Build &
Test", which is already pinned as required on dev -- no new job and no new pin.
Every tracked .go file is under Server/ (551 of them, one go.mod), so
Server-scoped is repository-wide here.

One file was genuinely misformatted: a one-space struct field alignment in
Server/admin/handlers_users_broadcast_test.go, fixed in the same commit because
a single line does not need its own reformat commit.

Trap worth recording: `gofmt -l .` on a Windows working tree lists every file
that has CRLF on disk, because gofmt normalises line endings. That reported 18
offenders here, 17 of them ghosts. The blobs are all LF -- .gitattributes
forces `eol=lf` -- so CI never saw them, and the honest test is to run gofmt
over `git show HEAD:<file>` rather than the working copy. Doing that across all
551 tracked Go files found exactly the one real offender above.

Verified both directions with golangci-lint v2 locally: `golangci-lint run
./...` reports 0 issues on the formatted tree; appending a misformatted
function to Server/auth/constants.go produces 2 gofmt findings; appending the
same misformatted function to Server/db/dbgen/admin.sql.go produces 0, so the
exclusion holds. Both files restored and verified clean afterwards.

Refs RL-19 / L-13, S-05.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(scripts): escape the NUL separator instead of embedding one

The `tracked()` helper added earlier in this branch splits `git ls-files -z`
output on NUL. The separator was written as a literal NUL byte rather than the
two-character JavaScript escape, so scripts/run.mjs became a binary file: `git
diff` refused to show it, `grep` reported "Binary file matches" instead of the
line, and `* text=auto` in .gitattributes stops normalising line endings for a
blob it detects as binary.

The code worked -- splitting on a raw NUL and splitting on "\0" are the same
operation -- which is exactly why this is worth fixing before it is inherited.
A source file that tooling classifies as binary is a file nobody can review.

Verified: zero NUL bytes remain, `grep -n "split("` now prints line 50 instead
of "Binary file matches", `node scripts/run.mjs --list` still resolves the same
8 shell and 4 workflow targets, and prettier still reports the file clean.

* chore(lint): enforce Rust formatting

Rust had no formatting gate of any kind: no rustfmt.toml, no `cargo fmt`
anywhere in CI, in scripts/run.mjs, in the Makefile or in the git hooks. Clippy
was the only Rust gate, and clippy does not check layout.

`cargo fmt --all -- --check` now runs in the rust-tests job, ahead of clippy: a
formatting failure is cheap to produce and cheap to fix, and there is no reason
to spend a clippy pass to surface one. The stable toolchain in that job
requested `components: clippy` only, so rustfmt is added there.

Only that job. ci.yml has a second, byte-identical `Install Rust` block in
tauri-build; it stays clippy-only, because a full desktop build is the wrong
place to discover a misplaced brace.

No rustfmt.toml. The default profile is the point of a baseline -- a config
file here would be a second opinion about style with nothing to say.
Client/src-tauri is a single `[package]`, not a workspace, so `--all` is a
safeguard against a future member rather than a fan-out today.

Verified: `node scripts/run.mjs --list` resolves check:rust to three steps with
`cargo fmt --all -- --check` first, `npm run format` now also runs `cargo fmt
--all`, and prettier reports ci.yml, run.mjs and the ci-check skill clean.
`cargo fmt --all -- --check` currently fails on 13 files -- that is the
reformat, and it is the next commit.

Refs RL-19 / L-13.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* chore(format): reformat the Rust crate to rustfmt defaults

Mechanical. This commit is `cargo fmt --all` and nothing else; the gate that
demands it landed in the previous commit so this diff is reviewable on its own.

13 of the 17 tracked .rs files, +343/-164. The crate had never been formatted,
so the changes are the usual first-run set: aligned trailing comments collapsed
to single spaces, single-element slice literals folded onto one line, long
method chains broken across lines, closure bodies expanded into blocks.

Verified: `cargo fmt --all -- --check` is clean, so the pass is complete and
idempotent. `cargo clippy --all-targets -- -D warnings` finishes with no
warnings, and `cargo test --lib` reports 115 passed / 0 failed -- identical to
before the reformat, which is what "mechanical" has to mean for a commit that
touches this much of the crate.

Refs RL-19 / L-13.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(scripts): make the root facade actually run on Windows

Adding the first gate that a contributor would run from the repository root
exposed two bugs in the facade, both of which made it silently wrong on the
platform this project is developed on.

1. Every npm and npx step failed. bin() appends `.cmd` on Windows, but Node
   refuses to spawn a .cmd or .bat with shell:false -- the CVE-2024-27980
   mitigation -- and fails with EINVAL and a *null* exit status. run.mjs only
   special-cased ENOENT, so the result was `FAILED: npx prettier --check .
   exited null` with nothing to explain it. check:client has three npm steps and
   has never been able to run here.

   Fixed by spawning only the npm shims through a shell. They are concatenated
   into a single command string rather than passed as an args array, because
   shell:true plus a separate array is deprecated (DEP0190) and prints a warning
   on every invocation; no argument in this file contains a space.

2. Every optional() step was skipped, always. onPath() shelled out to
   `where` on Windows, but where.exe lives in C:\WINDOWS\System32, which a Git
   Bash PATH does not necessarily contain -- on this machine PATH carries
   System32\Wbem, System32\WindowsPowerShell\v1.0 and System32\OpenSSH but not
   System32 itself. The probe could not start, `probe.status === 0` was false,
   and golangci-lint and sqlc reported as "not installed" while installed.

   Fixed by resolving against PATH and PATHEXT directly. No subprocess, and no
   dependency on which directories happen to be on PATH.

A spawn error other than ENOENT now reports its code instead of surfacing as a
null exit status.

Verified: before, `node scripts/run.mjs check:hygiene` died with "exited null"
and both optional steps printed SKIP with the tools present on PATH. After, the
same command runs prettier, shellcheck and actionlint and prints
"check:hygiene: passed", with no deprecation warning. `golangci-lint` is
detected by the new onPath where the old one missed it.

Refs RL-20 / L-14.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* chore(format): ignore build output that nested gitignores hide

Prettier honours the root .gitignore and no other. Every build and scratch
directory in this repository is ignored by a *nested* one -- Client/.gitignore,
.serena/.gitignore, .superpowers/sdd/.gitignore -- so none of them were
excluded from the new repository-wide gate.

The effect is not subtle. Running `cargo test` once drops roughly 850
formattable files into Client/src-tauri/target/, and the hygiene gate goes from
clean to "Code style issues found in 939 files". CI never sees it, because a
fresh checkout has no build output; every contributor sees it on their second
command.

Mirrors the three nested files rather than inventing a list: dist, coverage,
playwright-report, test-results, .vite, src-tauri/target and src-tauri/gen from
Client/.gitignore, plus .serena/ and .superpowers/sdd/. node_modules needs no
entry -- Prettier ignores it by default.

Verified: `npx prettier --check .` reports "All matched files use Prettier code
style" with a fully populated Client/src-tauri/target/ present on disk, and
still names README.md when a misformatted table is appended to it.

Refs RL-19 / L-13.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* chore(ci): shellcheck, actionlint, and a repository hygiene job

The last two gates RL-19 asks for. Neither existed: the shell scripts were
never linted, the workflows were never syntax-checked, and .githooks/pre-commit
carried hand-written `# shellcheck disable=` directives that nothing had ever
read.

New `hygiene` job, ubuntu-only and root-scoped, modelled on docs-consistency
for the same reason: every gate in it is platform-independent text analysis,
and .gitattributes pins eol=lf so a second OS would only re-prove line endings.
It runs `npm run check:hygiene` -- the same entry point a contributor runs, not
a parallel copy of the commands.

shellcheck ships in the runner image. actionlint does not, so it is pinned by
version and verified by sha256: an installer script piped from a branch would
be the one unverified download in a workflow file that pins every action by
commit SHA.

Prettier's step moves here from client-check, where it no longer belongs.

Both linters found real defects.

shellcheck, 3 findings in 8 scripts. Two are SC1125 errors in
.githooks/pre-commit: `# shellcheck disable=SC2086 - repo paths contain no
spaces` is not a valid directive. Trailing prose makes shellcheck discard the
rest of the line, so neither suppression was ever in effect -- and one of the
two was written earlier in this same branch, which is a fair demonstration of
why the gate is worth having. The prose moves to its own line above. The third
is SC2015 in start-server.sh, rewritten as an explicit if.

actionlint, 5 findings, all inside `run:` blocks it shellchecks once shellcheck
is on PATH. Three SC2015 in load-baseline.yml, rewritten as explicit ifs. Two
SC2035 in release.yml, where `sha256sum *` should not become `sha256sum ./*`:
the comment four lines above records that ParseChecksumFile exact-matches the
last field, so a "./" prefix would strand every deployed server exactly as a
"windows/" prefix would. `sha256sum -- *` satisfies the linter and leaves the
output bytes identical.

Verified all three gates in both directions with shellcheck 0.10.0 and
actionlint 1.7.7 on PATH. Passing: `node scripts/run.mjs check:hygiene` prints
"check:hygiene: passed" with all three steps run, not skipped. Failing:
appending `bait_fn() { cat $1; }` to Server/scripts/voice-test.sh fails on
SC2086; changing a runs-on to `ubunt-latest` fails on runner-label; appending a
misformatted table to README.md fails prettier. All three files restored and
confirmed clean afterwards. actionlint validates the new job in ci.yml itself.

Refs RL-19 / L-13, S-05.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs(plans): record B1 progress through B1-3

The header still read "B1-0 is complete; B1-1 is the next step" three merged
phases later. A plan that misstates where it is costs a reader the same
confusion whether it is stale by one phase or three.

B1-0 (#1410), B1-1 (#1411), B1-2 (#1412) and B1-3 (this branch) are done; B1-4,
dependency automation, is next.

Verified: `node scripts/check-doc-counts.mjs` still agrees on 27 claims across
9 watched documents -- this file is one of them -- and prettier reports it
clean.

* chore(ci): pin Repository Hygiene as a required check on dev

The second half of S-05. Its acceptance criterion is "tree is formatted AND a
fast required gate fails future drift" -- a check that runs but is not pinned
lets a formatting regression merge, so the gate is not a gate until this lands.

The name was read off PR #1414 with `gh pr checks` after the job reported
`pass` in 26s, not copied out of ci.yml. That order matters: the B0 script
records that three pinned names exist in no workflow file at all, and that a
required check which never reports blocks every PR forever.

Extends the existing script rather than adding a second one, per the B1 plan.

Also records, in the "deliberately NOT pinned" list, that Docs & Ledger
Consistency reports and passes on a dev PR yet is unpinned. That reads as an
oversight from the 2026-08-25 pass rather than a decision, but it belongs to
G-04, so it is documented here and not changed.

NOT APPLIED YET. Running this script now would pin a check that PR #1413 cannot
report -- its branch predates the hygiene job, so the job does not exist in its
workflow file and the check would never arrive. Run it after #1414 merges;
#1413 needs a rebase onto dev regardless.

Verified: shellcheck clean, the embedded JSON parses, and `check:hygiene`
passes with prettier, shellcheck and actionlint all running.

Refs S-05, RL-14 / G-03.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-26 18:00:26 +00:00
J3vbandClaude Opus 5 a5f7d954d2 chore: remove graphify knowledge graph tooling (#1413)
The committed knowledge graph and its PreToolUse hooks were steering every
codebase question through `graphify query` before any other tool could run.
Serena (gopls + rust-analyzer + tsserver over MCP) answers the same questions
from real language servers rather than a generated snapshot that goes stale
between rebuilds, so the graph no longer earns the ~20 MB it costs the tree.

Removed:
- `graphify-out/` untracked (7 files, ~20 MB) and now gitignored
- both `graphify hook-guard` PreToolUse hooks from `.claude/settings.json`
- the "Knowledge graph (graphify)" section of `CLAUDE.md`
- the `graphify-out/**` block from `.gitattributes` and `.gitignore`
- the graph-rebuild step from the `bughunt-run` skill, and the graph-edge
  guidance from the bughunt workflow prompt
- the graphify-specific `core.hooksPath` example in `ci-check` and
  `docs/contributing.md`, keeping the underlying warning in generic form

Also deletes the locally installed `post-commit` / `post-checkout` rebuild
hooks (untracked, not part of this diff).

This does not shrink clone size: the graph blobs stay in published history,
which `docs/plans/b1-repository-foundation-2026-08-25.md` explicitly rules out
rewriting. It does stop future refreshes from adding more.

Dated audit and plan documents keep their graphify references as a historical
record of the state they described.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-26 17:45:10 +00:00
J3vbandClaude 70473e8e10 B1-2: truth, entry points, and contributor path (#1412)
* fix(hooks): guard on the command the hook actually runs

pre-commit probed one binary and invoked another. The protocol block
guarded on `command -v go` and then ran `make protocol-verify`; the sqlc
block guarded on `command -v sqlc` and ran `make sqlc-verify`. `make` is
not on PATH on a stock Windows box, so a contributor with Go installed
but no make had their commit rejected with

    pre-commit: FAIL: protocol constants are stale — run 'make
    protocol-generate' in Server/ and stage the result

when nothing had been generated and nothing compared. The real cause was
`make: not found`, and the advice the message gives fails the same way.

Rather than add a `command -v make` guard, inline what the two Makefile
targets reduce to — `sqlc generate` / `go run ./scripts/genprotocol`
followed by `git diff --exit-code`. Same semantics, one less prerequisite,
and it doubles as the make-free equivalent B1-2 asks for. The Makefile
targets stay for anyone who prefers them.

Also: the protocol block was the only one with no `else`, so a
contributor without Go got no check and no notice. It now warns like its
two siblings. And gofmt is a separate binary from go, so it is probed
separately.

Verified both directions with the hook body replayed verbatim:
- Go present, make absent -> passes, no staleness claimed.
- schema edited without regenerating -> fails, as it must.

Refs RL-20 / L-14.

* docs: state one branch and PR model

Active documents contradicted each other head-on. README.md and
docs/contributing.md said branch from `dev` and target `dev`; CLAUDE.md
said branch from `main`, PR to `main`. That is R-02, and the 2026-08-19
audit had already recorded it as D-06 without it being resolved.

`dev` is the answer, and the repository already behaves that way: B0 made
`dev` PR-only with ten required checks enforced on admins, and #1409,
#1410 and #1411 all landed there. `main` carries releases.

docs/contributing.md becomes the single source of truth. It now states the
model, what protection is actually applied, and the two consequences a
contributor meets on their first PR — that a self-mergeable PR still cannot
merge red, and that Docker and Tauri Full Build report as skipped against
`dev` rather than failing. Everywhere else summarises and links here.

- CLAUDE.md: corrected, with a link rather than a second copy.
- CONTRIBUTING.md: new. GitHub's contributing-guidelines affordance only
  resolves the root, .github/ or docs/ — `docs/contributing.md` is not a
  path it finds, so the link never appeared on issues or PRs.
- PULL_REQUEST_TEMPLATE.md: names the base branch, which it did not.
- bughunt-run skill: reviewed the branch against `origin/main`, which is
  the wrong base once every PR targets `dev`.

README.md already said `dev` and is left as the short summary it should be.
Dated audits and the historical remediation plan keep their `main`-era
wording — they are records.

Refs R-02.

* fix(hooks): pick the pre-push base from the nearest integration branch

pre-push decided which side's gates to run from
`git diff --name-only origin/main...HEAD`. That was right when everything
targeted `main`. Once `dev` became the integration branch it stopped being
right: a branch cut from `dev` diffed against `main` counts everything on
`dev` and not yet on `main` as "changed".

Measured on this branch: the old base reported 609 changed files, the new
one reports 6. So in practice the hook was running the full server build
matrix and the client typecheck plus eslint on every push, whatever the
change touched — the file-based narrowing it exists for never engaged.

Now it picks whichever of origin/dev, origin/main is nearest, by commits
between merge-base and HEAD, skipping a candidate that scores 0. Verified:
a branch cut from dev picks origin/dev (2 ahead); dev itself scores 0
against dev and picks origin/main (8 ahead), which is what a dev -> main
release PR wants. With no candidate resolvable it falls back to the
existing `__all__`, so an unfetched or shallow clone still runs everything.

Refs RL-20 / L-14, R-02.

* chore(node): one Node source of truth

`.nvmrc` and all ten `actions/setup-node` pins said 24; five active
documents and the repo's only `engines` block still said 20. A contributor
following the docs installed a version CI does not run.

Node 24 wins — it is what CI already runs. Every manifest now declares
`engines`, and `engine-strict=true` turns a wrong major into a failed
install rather than an `EBADENGINE` warning nobody reads. `>=24` rather
than `^24` so a Node 26 box keeps working; `Client/.nvmrc` stays the
human-facing pin and the docs point at it instead of restating a number.

The `.npmrc` is per package root, not one at the top. npm reads the
project `.npmrc` from the package directory and does not walk parents —
verified with a throwaway package requiring node >=99: with only a parent
`.npmrc` npm warned and exited 0; with one in the package directory it
failed `notsup`. A single root file would have left `Client/`, the package
that matters most, on warnings.

Five docs, not the four previously identified — `docs/mcp-introspect.md`
also said 20. And `docs/contributing.md` claimed "`.nvmrc` + CI both say
Node 20", which was wrong about both.

Verified both directions in all three package roots: Node 22 fails
`notsup`; Node 24 installs clean and `npm ci` passes in Client/.

Refs RL-17 / C-01, ENV-01.

* docs: add the documentation landing page

`docs/` had 24 top-level files and no index. The root README carried a
flat list of 22 links that had drifted: six documents were reachable from
nowhere at all — including both 2026-08-23 audits and the test audit — and
two entries were labelled "latest" while newer unlinked audits existed.

docs/README.md is the index RL-12 asked for. It groups by what a document
*is*, because that is what decides whether to trust it: guidance tells you
how to do something, reference describes a contract the code implements,
audits are dated snapshots nobody updates, plans record intent. Every
tracked file under docs/ now appears exactly once, and the audit table says
plainly that audit-2026-08-19.md still claims "0 open findings" when the
ledger has 38.

The root README keeps a short curated list and defers to the index, rather
than maintaining a second copy that drifts again. Two fixes while there:
`docs/plans/` was linked as a bare directory, unlike its two sibling
directory entries, and was annotated "each carries a verified status
header" — which docs/plans/README.md:7-9 explicitly contradicts, since a
plan's header is exactly the thing that drifts and the index is the
authority.

Verified: 78 relative links across the new and edited files resolve, and
no tracked docs/ file is unreachable from the index.

Refs RL-12 / R-06.

* feat(scripts): root command facade

Entry points existed only inside Server/ (a Makefile) and Client/ (npm
scripts). Nothing at the root told a new contributor where to start, and
the root package.json had three scripts, none of which built or tested
anything.

`npm run check` from the root now runs what CI gates on, and
check:server / check:client / check:rust run one stack. scripts/run.mjs
is dependency-free Node — the shape render-ledger.mjs already uses — so
`npm run check` works before `npm install` has.

Cross-platform by construction: every step is spawned with an explicit cwd
and no shell, so there is nothing to quote and no `cd &&` to behave
differently on Windows. npm and npx get their .cmd suffix there. No step
shells out to make.

The facade orchestrates; it is not a new required path. Each step prints
the command and the directory before running it, and those are exactly the
commands documented per-stack — so a server contributor can read the output
and type them instead, and still never needs Node. Tools CI installs but a
contributor may not have (golangci-lint, which has no wrapper in this repo
at all; sqlc, pinned by Server/sqlc.version) are skipped with a printed
reason rather than failing.

Three corrections to the ci-check skill while aligning it:

- `make sqlc-verify protocol-verify` replaced by what those targets reduce
  to, so the documented path does not require make either.
- `cargo test` -> `cargo test --lib`, which is what ci.yml actually runs.
- "NODE_OPTIONS=--no-experimental-webstorage is mandatory on Node 22+" was
  false. tests/setup.ts installs the shim, CI runs Node 24 without the
  flag, and the suite was measured passing without it — 192 files / 5257
  tests, identical to the flagged run.

Also documents the third RL-20 problem, which needed no code: core.hooksPath
is exclusive, so `npm run hooks:install` silently disables any
.git/hooks/post-commit — including the one `graphify hook install` writes,
which CLAUDE.md tells agents to install. Nothing warned about that.

Verified: check:client 5257/192 green, check:rust 123 tests + clippy green,
--list prints every command, and the optional-tool skip path reports rather
than fails.

Refs RL-04 / L-04, RL-20 / L-14.

* feat(ci): fail on a document that contradicts the findings ledger

G-04's remaining half. The ledger is the source of truth for defect counts,
but nothing stopped a planning document from stating a different number and
nothing noticed when one did. `render-ledger.mjs --check` cannot help: it
validates the JSON schema and returns before rendering, so it never reads
FINDINGS.md and cannot see drift at all — and no workflow ran it anyway.

scripts/check-doc-counts.mjs counts ledger statuses and compares them to
what an allow-list of active documents claims, failing with file, line,
claimed value and actual. Wired into ci.yml as a job with no npm ci, since
the script imports nothing outside node:, and into the facade as
`npm run check:docs` — first in `check`, so a contradicted count does not
wait behind ten minutes of -race.

The patterns are narrow on purpose. A first attempt matched any
"<number> <status>" and flagged nineteen things, all false: "the 45 open P1
rows" (issue-register rows, not ledger findings), "All 8 findings F1-F8"
(a different register), "G-05 **refuted**" (an identifier), `">=20"` and
`CGO_ENABLED=0` (not counts at all). A check that cries wolf gets ignored,
which is the failure G-04 already describes. So a number is only read as a
claim in three shapes that cannot mean anything else: an enumeration of two
or more "<n> <status>" pairs, a status table row in a table that totals
itself, and "<n> records/findings" where the ledger is named within three
lines. Fifteen selftest assertions pin both directions, and the job runs
them before it runs the check.

It reads findings-ledger.json directly rather than importing
render-ledger.mjs for `validate`/`render`: that module ends in a bare
top-level `await main()` with no import.meta.main guard, so importing it
rewrites FINDINGS.md as a side effect.

Dated docs/audit-*.md are reported, never failed — they are snapshots
nobody maintains. audit-2026-08-19.md does claim zero open findings against
38 open, so b0-baseline's "No plan was found claiming '0 open findings'"
holds for docs/plans/ but not for docs/.

Not included: a real FINDINGS.md render-drift check. That is RL-07 and
belongs with the generated-artifact work, not here.

Verified: 27 claims across 9 documents agree; corrupting one count in
docs/plans/README.md fails the check naming that line, for both the status
and the total.

Refs G-04.

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-08-26 10:39:24 +02:00
J3vbandClaude 7365a31b45 refactor: flatten Client/tauri-client into Client (B1-1) (#1411)
* refactor: move Client/tauri-client to Client (pure move, no content change)

* refactor: re-point paths after the Client flatten (mechanical, no behaviour change)

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-08-26 08:55:06 +02:00
J3vbandClaude Opus 5 7c286abed2 docs(plans): B1 execution plan, and accept HP-0 (#1410)
* docs(plans): add the B1 repository-foundation execution plan

B1 is the isolated layout/contributor phase. This records the execution
order, the proof for each step, and what is out of scope.

Two findings worth surfacing before any B1 work starts:

- HP-0 was never formally accepted. The roadmap's B1 entry gate requires
  it; no scorecard artifact exists, no commit or document records an
  acceptance, and the B0 baseline still lists "Step 10: HP-0 sign-off"
  under "Not yet done in B0". The plan lists the five gaps that closing
  it requires, including pinning required status checks on dev -- which
  are still unset, so a dev PR can currently merge red.

- Several layout-audit claims do not survive verification against HEAD,
  matching the B0 pattern. RL-09's "no single command verifies both
  protocol consumers" is false (make protocol-verify does, and is
  enforced in CI, the pre-commit hook, and a contract test). RL-10's
  test-discovery side effect never fires (no _test.go in Server/scripts).
  RL-06's regeneration concern is refuted locally. RL-08 grows a
  toolchain constraint instead. RL-05, RL-07, RL-20 and RL-21 are each
  worse than written -- RL-20 includes a live bug where a missing `make`
  is reported as stale protocol constants.

The riskiest item, RL-01 (flatten Client/tauri-client into Client), gets
a full reference inventory and a mechanical proof for both commits: tree-
object equality for the pure move, and scripted-substitution replay for
the path rewrite. Release asset names and updater contracts are verified
independent of the directory name, so the move cannot rename an artifact.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs(plans): correct the B1 status-check pin list from a live dev PR

The list was derived from ci.yml. Observing PR #1410's actual checks
found three that exist in no workflow file -- Analyze (go),
Analyze (javascript-typescript), Analyze (actions) -- because CodeQL
runs from GitHub default setup, configured in repository settings.
Reading .github/ alone misses them.

Also confirms the two negative predictions against a real dev-targeted
PR: Server Docker Build (verify) reports as "skipping", and Tauri Full
Build never appears in the check list at all. Neither may be pinned.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs(plans): accept HP-0 and pin the dev required status checks

Closes B1's entry gate. All five B1-0 items are done.

The scorecard is the artifact the hold point asks for: one place that
answers its four questions, records what was accepted as a stated
limitation rather than claimed green, and part-closes R-08.

Required status checks are now pinned on dev -- ten of them. That was
B0's one outstanding step. Two things came out of doing it:

- The names cannot be inferred from ci.yml. Three of the ten (the
  Analyze jobs) exist in no workflow file, because CodeQL runs from
  GitHub default setup configured in repository settings. They were read
  off a live dev-targeted PR with `gh pr checks`.
- Server Docker Build, Tauri Full Build and the CodeQL aggregate are
  deliberately excluded. The first two report "skipping" on a dev PR --
  Tauri Full Build under its unexpanded matrix name, since the job is
  skipped before matrix expansion. Admin Panel E2E is excluded because
  continue-on-error makes it report success unconditionally.

Two prior claims are corrected rather than left to propagate:

- b0-dev-branch-protection.sh was written assuming repository-settings
  writes are blocked from the agent sandbox. They are not; the PUT
  succeeded. The script stays as the record of intent and the way to
  re-apply or undo.
- An earlier revision of the B1 plan said Tauri Full Build does not
  appear in a dev PR's check list at all. It does, as skipping.

Evidence closed out:

- Rust is no longer a carried row. Re-measured: 115 passed, cargo clippy
  --all-targets -- -D warnings at exit 0, confirming the carried figure.
- The 38 open ledger records are accepted as counted, non-stale and
  assigned: 11 medium / 27 low, zero high or critical, zero dead paths
  across all 348 re-verified at this commit, and none assigned to B1.
- The private security review is reconciled: 7 findings, 7 of 7 mapped
  to existing public rows, 0 unmapped. Summary is content-free; the
  detail stays in the untracked private reports.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-25 19:29:31 +00:00
J3vbandClaude Opus 5 6a1561fa7d fix: close the three B0 P0 gates and record a measured baseline (#1409)
* chore(security): stop tracking the private security-finding reports

docs/security-findings/ holds detailed reports for defects that are not yet
fixed. The directory was untracked but not ignored, so any 'git add .' would
have published seven unfixed vulnerability traces to a public repository.

Findings are coordinated through private GitHub Security Advisories
(docs/security.md); only opaque identifiers and safe status belong in tracked
plans.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(client): repair the two red P0 unit contracts (G-01, G-02)

G-02: noise-suppression-restart stubbed MediaStream with
vi.fn().mockImplementation(arrow), which is not constructible. Vitest 4 threw
'is not a constructor' at the new MediaStream([inputTrack]) call in
noise-suppression.ts before reaching any assertion. Replaced with a real
class; the OC-0277 assertions are unchanged.

G-01: message-list's OC-0217 guard was inverted, not merely stale. It spied on
AbortSignal.prototype.addEventListener and asserted zero abort registrations,
but the leak it names registered row listeners via
element.addEventListener(..., { signal }) — a path that never calls that
prototype method. Measured: the leak produces 0 registrations (test passes),
while the OC-0286 fix rotates a per-window AbortSignal.any and produces 5
across 5 distinct signals (test fails). The guard passed on the bug and failed
on the fix.

It now captures the signal each window's row listeners register against and
asserts the invariant its name always claimed: one signal per rendered window,
a fresh signal per jump, and every superseded window already aborted with
exactly one live. Verified both directions — green on the fix, and
'expected 1 to be 5' with beginRowRender() reverted to rowSignal = ac.signal.

Client suite: 5257 passed, 0 failed (was 5255 passed, 2 failed).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(client): make the Playwright suite terminate

The runner finished every test and then never exited, printing no summary — so
the failure read as 'tests never finish' when it was 'process never exits'.
getActiveResourcesInfo() at hang time showed a live ProcessWrap plus several
PipeWrap: the Vite dev server was still running. Playwright's webServer
teardown does not kill it here.

Measured, full suite each time:

  npm run dev                        hangs, tests pass
  node node_modules/vite/bin/vite.js hangs, tests pass
  reuseExistingServer: false         hangs, tests pass
  gracefulShutdown SIGTERM/3s        hangs, tests pass
  npx vite                           exits, 290 of 293 FAIL
  no webServer (pre-started)         exits, 293 pass in 33s

npx only appears to fix it: npx exits once Vite is up, Playwright reads that as
the server dying and tears the group down mid-run, so later tests get
ERR_CONNECTION_REFUSED.

globalTeardown now kills the process listening on the dev port, releasing the
runner's handle. The webServer command spawns Vite's entry point directly so
the listening process is Playwright's own child — via 'npm run dev' the npm
process would still hold the handle open. It also reaps servers orphaned by an
interrupted run, which reuseExistingServer would otherwise silently adopt.

An earlier revision used netstat, which is not on PATH in every shell here; the
swallowed ENOENT made the fix look applied while the hang persisted. It now
uses PowerShell on Windows and lsof elsewhere, and warns on failure rather than
failing silently.

npm run test:e2e: exit 0, 293 passed, 37s, reproducible, no orphan listener.
playwright.config.prod.ts carried the same npm-wrapper shape.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* chore(client): align .nvmrc with the Node version CI uses

Three versions were in play, not two: .nvmrc said 20, CI pins 24, and the
machine the audit was measured on runs 26. A baseline measured against .nvmrc
is not the baseline CI produces, which defeats the point of B0.

Scoped to .nvmrc only. The full single-source-of-truth work — package engines,
contributor docs, release — stays in B1 (RL-17 / C-01).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs(plans): add the beta audit set and the B0 baseline

The 2026-08-23 audit set has been sitting untracked: repository-health and
repository-layout audits, beta product requirements, requirement traceability,
the issue register, and the B0-B10 roadmap. They are the plan of record for
beta and belong in the repository.

Adds b0-baseline-2026-08-25.md, which supersedes the roadmap's 'current
evidence snapshot'. Every row is marked measured or carried, so nothing is
inherited silently. It also records three audit claims that did not survive
verification:

  - G-01 was an inverted guard, not a stale assertion — it passed on the bug
    and failed on the fix.
  - The Playwright hang matched none of the three hypotheses; the runner could
    not kill its own dev server.
  - The golangci-lint toolchain failure is refuted: 19 linters run, 0 issues,
    verified with -v to rule out the known zero-linters false-green.

Adds b0-dev-branch-protection.sh, which records the applied dev branch
protection and the reasoning behind each setting.

Security detail stays private: the register carries only opaque SEC-* families
and safe closure criteria, per the roadmap's public/private handling policy.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* chore(graphify): refresh the knowledge graph

Own commit, per CLAUDE.md — the graph payload does not belong in the diff of
the changes that triggered it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs(plans): add the active-plan index and fix a stale status header (G-04)

Planning documents had no recorded state, so a reader could not tell current
guidance from shipped history. docs/plans/README.md now indexes every plan as
active, partially implemented, design-only, or shipped, and names the source of
truth for each concern so a defect count is never read out of a plan.

Status is recorded in the index rather than by moving or rewriting the
historical plans, so links from audits and commit messages keep resolving.

One real stale claim found and fixed: audit-2026-08-19-remediation.md still
read 'in progress 2026-08-19' while its own phase table showed phases 1-6 done
2026-08-20 (merged 03fcb7d5, PR #1396) with only phase 7 pending. The header
had drifted because the table was updated in place and the header was not.
No plan was found claiming '0 open findings'.

Also records the Step 8 staleness pass in the B0 baseline: all 38 open OC
records still resolve to a live file:line at this commit, so none is superseded
by later work. Adjudicating them individually is bughunt-fix work, not B0.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Update graph output files and manifest with new metadata

- Updated graph.html and graph.json with new binary data.
- Modified manifest.json to reflect changes in file modification times and AST hashes for several documents.
- Added new entry for README.md in the manifest with its corresponding metadata.

* docs(plans): close the Docker and coverage leftovers in the B0 baseline

Docker smoke: measured and passing. Image builds at 50.1 MB and boots on :8443
with TLS; docker-smoke.sh exits 0.

Server coverage: re-measured at 74.6% aggregate, confirming the figure carried
from the audit rather than continuing to inherit it.

Two findings from doing it:

ENV-03 — docker-smoke.sh cannot be run from Git Bash on Windows. MSYS path
conversion rewrites the container-internal /chatserver into
'C:/Program Files/Git/chatserver', so docker exec fails 127 and the script
reports 'container never reported healthy within 30s' — indistinguishable from
a real boot regression. MSYS_NO_PATHCONV=1 makes the same script pass. CI is
Linux and unaffected, but Windows is an official contributor platform (RL-20).

The CI Docker job is gated on main, so it is skipped for any PR targeting dev
— a dev-targeted change cannot get Docker evidence from CI at all.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* chore(graphify): refresh the knowledge graph

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-25 20:40:25 +02:00
J3vb 5cc0888964 chore(graphify): refresh knowledge graph 2026-08-23 10:54:00 +02:00
J3vbandClaude 35bc7aed11 chore(ledger): record the 38 open findings from the 2026-08-22 hunt (#1403)
Claude-Session: https://claude.ai/code/session_01SdkJRbjCtrG76jEnrhKbYo

Co-authored-by: Claude <noreply@anthropic.com>
2026-08-22 13:27:25 +02:00
J3vbandClaude Fable 5 3fc9fa1adc Merge main into dev (toolchain upgrades #1401)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-22 06:50:36 +02:00
J3vbandClaude Fable 5 f1a673e87e fix: 35 findings from the 2026-08-22 bug hunt (#1402)
* fix(voice): 1 defect(s) (OC-0277)

* fix(voice): 1 defect(s) (OC-0278)

* fix(client): 1 defect(s) (OC-0280)

refreshDmSidebar() rebuilds the entire DM sidebar subtree on every
dmStore.channels change - which includes presence flips and new
messages, not just DM list changes. The "Find a conversation" filter
text and input focus live only in that destroyed subtree, so they were
silently wiped mid-typing. Capture and restore both across the
destroy+recreate cycle.

* fix(ws): 1 defect(s) (OC-0285)

* fix(client): 1 defect(s) (OC-0286)

* fix(client): 1 defect(s) (OC-0288)

Consume the legacy unscoped mute key after migrating it onto the first
host, so a brand-new host with no scoped key of its own no longer reads
through to the same legacy list and inherits another server's mutes.

* fix(voice): 1 defect(s) (OC-0290)

* fix(db): 1 defect(s) (OC-0293)

DecrementMentionCounts reversed mention_count bumps that were never
applied: message_mentions stores every resolved mention id including the
author's blockers, while applyMentionCounts excludes blockers before
incrementing. Deleting a blocked author's message therefore wiped an
unrelated, genuine mention badge on the same read_states row. Mirror the
block exclusion in the decrement UPDATE.

* fix(db): 1 defect(s) (OC-0294)

DeleteAccount soft-deletes the departing user's messages but never reversed the read_states.mention_count bumps those messages made, leaving phantom mention badges. Reverse them inline in the existing transaction, mirroring DecrementMentionCounts' guards.

* fix(client): 1 defect(s) (OC-0295)

MemberList rebuilt every row on any non-presence-only membersStore change
and on every roles_update, but registered each row's click/contextmenu
listeners on the component-lifetime disposable.signal, which only aborts
at destroy(). Discarded rows therefore stayed reachable (and their
listeners live) for the component's whole lifetime. Route per-row
listeners through a per-render AbortController that is aborted and
replaced at the top of every render, and aborted again in destroy().

* fix(identity): 1 defect(s) (OC-0297)

UpdateProfile's post-commit re-read of the user row could fail for reasons
unrelated to context cancellation (SQLITE_BUSY, I/O error, pool exhaustion)
and was reported as ErrInternal even though UpdateUserProfile had already
committed. Callers that treat any UpdateProfile error as proof the write
never landed — handleUploadAvatar deletes the file it just stored — would
delete a file the committed avatar column now points at, permanently
breaking the avatar with no user_update broadcast.

Since UpdateUserProfile only writes username/avatar/display_name/about,
merge those four onto the pre-write snapshot to reconstruct the committed
row without needing the re-read to succeed, and log the read failure.

* fix(ws): 2 defect(s) (OC-0298, OC-0299)

- OC-0298: applyConnectStatus stamped c.user.Status even when the
  UpdateUserStatus write failed, so auth_ok and the presence broadcast
  claimed a status users.status disagreed with, and buildReady's
  ListMembers read never self-corrected for the session.
- OC-0299: refreshUserSnapshot silently fell back to roleName "member"
  when the new role lookup failed, pinning the session to a fabricated
  role on the wire. It now fails closed like the sibling lookups in
  upgradeAndAuth and handleFreshConnect.

* fix(client): 1 defect(s) (OC-0300)

* fix(client): 1 defect(s) (OC-0301)

* fix(ws): 1 defect(s) (OC-0302)

* fix(api): 1 defect(s) (OC-0305)

handleDiagnosticsConnectivity used clientIP(r), ignoring cfg.Server.TrustedProxies, so behind a configured trusted reverse proxy the endpoint reported the proxy hop instead of the real client address. Use clientIPWithProxies with the parsed trusted-proxy nets, matching RateLimitMiddleware on the same route.

* fix(client): 2 defect(s) (OC-0306, OC-0308)

* fix(client): 1 defect(s) (OC-0307)

QuickSwitcher registered a per-row click listener against the
overlay-lifetime AbortSignal, but renderResults() rebuilds every row on
each keystroke, arrow key, and store refresh. Discarded rows kept their
listeners alive until the overlay closed. Replaced with one delegated
click listener on the stable results container, keyed off the
data-channelid each row already carries.

* fix(client): 1 defect(s) (OC-0310)

* fix(server): 3 defect(s) (OC-0279, OC-0291, OC-0292)

Reap a soft-deleted message's attachment files, count lapsed temporary
bans as active users in the require_2fa enrollment gate, and only apply
the 2FA-enrollment precondition when require_2fa itself is being enabled.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SdkJRbjCtrG76jEnrhKbYo

* test(api): sync apiTestSchema with the user_blocks migration

DeleteAccount's mention-count reversal joins user_blocks; the api
package's hand-rolled schema fixture predates migration 012.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SdkJRbjCtrG76jEnrhKbYo

* fix(client): 3 defect(s) (OC-0281, OC-0282, OC-0296)

Decouple the E2EE identity-mismatch modal and right-click popovers from
the sidebar's per-render abort signal, and let global drag listeners
survive a mid-drag re-render.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SdkJRbjCtrG76jEnrhKbYo

* fix(voice): 2 defect(s) (OC-0283, OC-0287)

Retire a departed peer's E2EE key unconditionally on leave, and surface
a failed microphone unmute instead of reporting an unmuted state the
room never saw.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SdkJRbjCtrG76jEnrhKbYo

* fix(client): 3 defect(s) (OC-0289, OC-0303, OC-0309)

Guard the DM call button against redialing the channel already joined,
resolve the incoming-call banner's caller through the nickname-aware
display name, and keep the DM profile sidebar subscribed to live
member/status updates.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SdkJRbjCtrG76jEnrhKbYo

* style(client): prettier-format the dm-store test

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SdkJRbjCtrG76jEnrhKbYo

* fix(server): 1 defect(s) (OC-0284)

Make message soft-delete a compare-and-set so a repeated chat_delete
cannot reverse mention counts twice.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SdkJRbjCtrG76jEnrhKbYo

* fix(server): 2 defect(s) (OC-0276, OC-0304)

Re-sync a resumed connection's voice E2EE peer keys in registerNow
(announce frames are unsequenced and cannot be replayed), and apply the
live-connection presence rule to every DM payload DMService builds.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SdkJRbjCtrG76jEnrhKbYo

* chore(ledger): record the 2026-08-21 hunt findings as fixed

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SdkJRbjCtrG76jEnrhKbYo

* chore(ledger): independent revert-proof pass for OC-0276..OC-0310

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SdkJRbjCtrG76jEnrhKbYo

* refactor(service): extract DeleteMessage authorization into a helper

Keeps DeleteMessage under the cyclop complexity ceiling after the
OC-0284 guard.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SdkJRbjCtrG76jEnrhKbYo

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-08-22 06:34:58 +02:00
J3vbandClaude 463f1d546f Fix 27 findings from 2026-08-21 bug hunt (#1400)
* chore(findings): record 2026-08-21 bug hunt (38 findings)

* fix(api): 1 defect(s) (OC-0240)

* fix(client): 1 defect(s) (OC-0241)

* fix(plugin): 2 defect(s) (OC-0243, OC-0265)

* fix(client): 3 defect(s) (OC-0244, OC-0256, OC-0259)

* fix(client): 1 defect(s) (OC-0247)

* fix(client): 2 defect(s) (OC-0248, OC-0258)

* fix(identity): 1 defect(s) (OC-0250)

* fix(ws): 3 defect(s) (OC-0252, OC-0269, OC-0272)

* fix(admin): 1 defect(s) (OC-0253)

* fix(client): 1 defect(s) (OC-0254)

* fix(voice): 1 defect(s) (OC-0255)

* fix(ws): 1 defect(s) (OC-0260)

* fix(client): 1 defect(s) (OC-0261)

* fix(client): 1 defect(s) (OC-0262)

* fix(client): 1 defect(s) (OC-0263)

* fix(client): 1 defect(s) (OC-0264)

* fix(client): 1 defect(s) (OC-0268)

* fix(ws): 1 defect(s) (OC-0273)

* fix(service): 1 defect(s) (OC-0275)

* style: satisfy golangci-lint and prettier on 2026-08-21 fix commits

- drop ineffectual backupDir reset before return (registry.go, OC-0265)
- reflow long boolean expression (attachments.ts, OC-0241)

* fix(client): 4 defect(s) (OC-0242, OC-0246, OC-0249, OC-0251)

* fix(voice): 1 defect(s) (OC-0267)

* fix(admin): 1 defect(s) (OC-0274)

* fix(voice): 1 defect(s) (OC-0245)

* fix(ws): 1 defect(s) (OC-0271)

* fix(voice): 2 defect(s) (OC-0239, OC-0257)

* fix(ws): 1 defect(s) (OC-0266)

* fix(voice): 1 defect(s) (OC-0270)

* style: clear golangci-lint modernize and prettier nits from 2026-08-21 fixes

- range-over-int and slices.Contains modernizations in new Go test files
- prettier reflow in dispatcher.ts

* chore(findings): mark 2026-08-21 hunt findings fixed/declined

37 fixed across the fix waves, OC-0238 declined (LiveKit webhook TLS
requires a product decision, not a mechanical patch).

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-08-21 12:41:56 +02:00
375 changed files with 25626 additions and 3397 deletions
+20
View File
@@ -0,0 +1,20 @@
---
paths:
- "Server/db/queries/**"
- "Server/migrations/**"
- "Server/sqlc.yaml"
---
# Schema and query edits
These files are the sqlc source of truth. Invoke the `db-change` skill before
changing anything here.
`Server/db/dbgen/` is generated from them and is denied to Edit/Write in
`.claude/settings.json`. After a change, regenerate and stage the result:
```
cd Server && sqlc generate
```
The pre-commit hook and CI (`make sqlc-verify`) both fail on drift.
+19
View File
@@ -0,0 +1,19 @@
---
paths:
- "Server/api/router.go"
- "Server/config/config.go"
- "Server/migrations/**"
---
# Generated documentation blocks
The `gendocs:*` blocks in `docs/api.md`, `docs/schema.md` and
`docs/server-configuration.md` are generated from these files. Never edit
inside a `gendocs:*` block by hand. After changing routes, config fields or
migrations, regenerate and stage the docs:
```
cd Server && go run -tags otel,wazero ./cmd/gendocs
```
CI (`make docs-verify`) fails on drift.
+20
View File
@@ -0,0 +1,20 @@
---
paths:
- "protocol/schema.json"
- "Server/cmd/genprotocol/**"
---
# Protocol message types
`protocol/schema.json` is the source of truth for the WebSocket message types.
Invoke the `protocol-change` skill before changing it.
`Server/ws/message_types.go` and `Client/src/lib/protocolTypes.ts` are generated
from it and are denied to Edit/Write in `.claude/settings.json`. After a change,
regenerate and stage both files:
```
cd Server && go run ./cmd/genprotocol
```
The pre-commit hook and CI (`make protocol-verify`) both fail on drift.
+38 -1
View File
@@ -1 +1,38 @@
{}
{
"permissions": {
"deny": [
"Edit(Server/db/dbgen/**)",
"Write(Server/db/dbgen/**)",
"Edit(Server/ws/message_types.go)",
"Write(Server/ws/message_types.go)",
"Edit(Client/src/lib/protocolTypes.ts)",
"Write(Client/src/lib/protocolTypes.ts)",
"Edit(Client/src/generated/**)",
"Write(Client/src/generated/**)",
"Read(**/.env)"
]
},
"hooks": {
"SessionStart": [
{
"hooks": [
{
"type": "command",
"command": "node \"${CLAUDE_PROJECT_DIR}/scripts/claude-hook.mjs\" session-start"
}
]
}
],
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "node \"${CLAUDE_PROJECT_DIR}/scripts/claude-hook.mjs\" pre-bash"
}
]
}
]
}
}
+20
View File
@@ -60,6 +60,26 @@ if printf '%s\n' "$staged" | grep -qE '^(protocol/schema\.json|Server/cmd/genpro
fi
fi
# Any api/ or admin/ Go file, the migrations, the config or the generator
# changed -> the regenerated docs index blocks must be part of the same commit.
# The route trigger is deliberately the whole of api/ and admin/: routes are
# registered in router.go, in the *_handler.go files, in client_update.go's
# MountClientUpdateRoute, and in the admin package's own mux — naming files
# individually is how this goes stale.
#
# Inlined like the two blocks above, and for the same reason: make is not on
# PATH on a stock Windows box. -tags otel,wazero is the build the route index
# is generated from; the tool refuses to run without it.
if printf '%s\n' "$staged" | grep -qE '^Server/(api|admin)/.*\.go$|^Server/(migrations/|config/config\.go|cmd/gendocs/)'; then
if command -v go >/dev/null 2>&1; then
(cd Server && go run -tags otel,wazero ./cmd/gendocs \
&& git diff --exit-code ../docs/api.md ../docs/schema.md ../docs/server-configuration.md) \
|| fail "generated docs blocks are stale, or a config key is undocumented — if gendocs named keys above, document them in docs/server-configuration.md; otherwise run 'go run -tags otel,wazero ./cmd/gendocs' in Server/ and stage the result"
else
printf 'pre-commit: WARNING: go not installed; skipping the generated-docs check. CI will run it.\n' >&2
fi
fi
# Findings ledger changed -> it must still be valid. Unlike the two blocks
# above there is nothing to diff: FINDINGS.md is not tracked (RL-07), so a
# stale rendering cannot be committed. --check is the whole gate here, and it
+12
View File
@@ -22,10 +22,16 @@ version: 2
# reason — see docs/contributing.md#dependency-policy for the measured decision
# against adopting npm workspaces.
# Every block targets `dev`, not the default branch. `dev` is the integration
# branch and the only branch that takes PRs; `main` carries releases. Without
# this, Dependabot opens against `main`, and retargeting by hand does not stick
# — `@dependabot rebase` recreates the PR against the configured target.
updates:
# Go server dependencies
- package-ecosystem: gomod
directory: /Server
target-branch: dev
schedule:
interval: weekly
day: monday
@@ -49,6 +55,7 @@ updates:
# together — not a standalone merge.
- package-ecosystem: docker
directory: /Server
target-branch: dev
schedule:
interval: weekly
day: monday
@@ -69,6 +76,7 @@ updates:
# Tauri client npm dependencies
- package-ecosystem: npm
directory: /Client
target-branch: dev
schedule:
interval: weekly
day: monday
@@ -89,6 +97,7 @@ updates:
# Root tooling npm dependencies (changelogen, prettier)
- package-ecosystem: npm
directory: /
target-branch: dev
schedule:
interval: weekly
day: monday
@@ -109,6 +118,7 @@ updates:
# tools/mcp-introspect npm dependencies (local dev MCP server)
- package-ecosystem: npm
directory: /tools/mcp-introspect
target-branch: dev
schedule:
interval: weekly
day: monday
@@ -129,6 +139,7 @@ updates:
# Tauri Rust/Cargo dependencies
- package-ecosystem: cargo
directory: /Client/src-tauri
target-branch: dev
schedule:
interval: weekly
day: monday
@@ -161,6 +172,7 @@ updates:
# GitHub Actions
- package-ecosystem: github-actions
directory: /
target-branch: dev
schedule:
interval: weekly
day: monday
+36 -7
View File
@@ -69,6 +69,13 @@ jobs:
if: matrix.os == 'ubuntu-latest'
run: make protocol-verify
# The route, table and config-key index blocks in docs/ must never drift
# from the mounted router, the migrated schema and config.Config's koanf
# tags. Same one-leg rule as the two checks above.
- name: Verify generated docs (make docs-verify)
if: matrix.os == 'ubuntu-latest'
run: make docs-verify
- name: Run tests with race detection and coverage
run: go test -race -timeout 20m ./... -coverprofile=coverage.out -cover
@@ -77,16 +84,28 @@ jobs:
# Tag-gated tests (DC-06 / T-2026-07-25-16). The build-tag matrix above
# only COMPILES the otel/wazero variants; the tests behind those tags
# (plugin/sandbox_wazero_test.go, telemetry/telemetry_otel_test.go) ran
# nowhere until this step. Scoped to the two packages that carry tagged
# files — every other package is tag-invariant and already covered by the
# race run above. One leg is enough; no -race (the runtime under the tag
# is the concern, not new concurrency).
# (plugin/sandbox_wazero_test.go, telemetry/telemetry_otel_test.go,
# api/recoverer_otel_test.go — the OC-0346 panic-log test, which this
# step never executed until ./api/... was added) ran nowhere until this
# step. Scoped to the packages that carry tagged files — every other
# package is tag-invariant and already covered by the race run above.
# One leg is enough; no -race (the runtime under the tag is the concern,
# not new concurrency).
- name: Run tag-gated tests (-tags wazero, -tags otel)
if: matrix.os == 'ubuntu-latest'
run: |
go test -tags wazero -count=1 ./plugin/...
go test -tags otel -count=1 ./telemetry/...
go test -tags otel -count=1 ./telemetry/... ./api/...
# Coverage ratchet (B3-6 item 1). Reads the profile the race step wrote,
# but placed after the other test steps so a floor miss does not hide
# their results. Linux leg only: the profile is not the same on both legs
# — OS-tagged files swap in and out and several tests skip on Windows —
# so the floors are pinned to one leg and the figure stays deterministic.
# Ratchet rule in Server/CLAUDE.md.
- name: Check coverage floor
if: matrix.os == 'ubuntu-latest'
run: bash scripts/coverage-floor.sh coverage.out
- name: Upload Go coverage
if: always()
@@ -193,10 +212,17 @@ jobs:
- name: Ledger schema is valid
run: node .superpowers/render-ledger.mjs --check
# release.yml writes protocol_epoch into the signed server-update
# manifest with exactly this command, and release.yml only runs at tag
# time — so the read is proven here, on every pull request.
- name: protocol_epoch is readable the way release.yml reads it
run: test "$(jq -e '.protocol_epoch' protocol/schema.json)" -ge 1
# R-09 / RL-16. The release gate itself is only invoked for real at tag
# time, which is the wrong place to find a bug in it — so its decision
# logic is exercised here, on every pull request, against fixtures. Same
# reason Server/scripts/docker-smoke.sh is called from both workflows.
# reason Server/scripts/docker-smoke.sh is called from all three
# workflows (ci.yml, release.yml, nightly-docker-smoke.yml).
# This also parses the required-check list out of
# b0-dev-branch-protection.sh, so a change to that list's shape fails here
# rather than silently weakening the gate.
@@ -509,10 +535,13 @@ jobs:
# Image build is verification only, so it is skipped on dev to keep day-to-day
# work on the fast check suite. Runs for main pushes and PRs targeting main.
#
# Keep in sync with nightly-docker-smoke.yml's nightly-docker-smoke job.
server-docker-build:
name: Server Docker Build (verify)
if: github.ref_name == 'main' || github.base_ref == 'main'
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
+51 -8
View File
@@ -498,6 +498,10 @@ jobs:
release-server-docker,
]
runs-on: ubuntu-latest
# The `release` environment carries a required reviewer; naming it here
# is what makes that approval gate fire before anything is signed or
# published. Without this line the environment exists but never applies.
environment: release
permissions:
contents: write
steps:
@@ -570,8 +574,13 @@ jobs:
run: |
WIN_HASH=$(sha256sum windows/chatserver.exe | awk '{print $1}')
LINUX_HASH=$(sha256sum linux/chatserver-linux-amd64.tar.gz | awk '{print $1}')
printf '{"version":"v%s","asset":"chatserver.exe","sha256":"%s","assets":[{"asset":"chatserver.exe","sha256":"%s"},{"asset":"chatserver-linux-amd64.tar.gz","sha256":"%s"}]}' \
"$VERSION" "$WIN_HASH" "$WIN_HASH" "$LINUX_HASH" > windows/server-update-manifest.json
# protocol_epoch is read from the schema, never typed here, so the
# manifest cannot drift from the constants the binaries were built with.
# The server's client-update endpoint withholds any release whose epoch
# is newer than its own (Server/api/client_update.go).
EPOCH=$(jq -e '.protocol_epoch' protocol/schema.json)
printf '{"version":"v%s","asset":"chatserver.exe","sha256":"%s","assets":[{"asset":"chatserver.exe","sha256":"%s"},{"asset":"chatserver-linux-amd64.tar.gz","sha256":"%s"}],"protocol_epoch":%s}' \
"$VERSION" "$WIN_HASH" "$WIN_HASH" "$LINUX_HASH" "$EPOCH" > windows/server-update-manifest.json
- name: Sign server update assets
working-directory: Client
@@ -601,12 +610,46 @@ jobs:
minisign -Vm "$f" -x "$RUNNER_TEMP/asset.minisig" -p "$RUNNER_TEMP/server_update.pub"
done
- name: Install root dependencies (changelogen)
run: npm ci
- name: Generate changelog
# The release body is the curated section for THIS tag, never the whole
# file. v1.2.0-alpha.4 published all 795 lines of CHANGELOG.md — every
# past release, plus the "How to write an entry" style guide aimed at
# contributors — because `--notes-file CHANGELOG.md` hands GitHub the
# entire file and nothing ever narrowed it.
#
# The `changelogen --output CHANGELOG.md` step that used to run here is
# gone. It ran after the tag existed, so its from-tag and to-tag were the
# same commit: it appended an empty `## <tag>...<tag>` heading whose
# compare link pointed at itself, and no step consumed the result.
# `npm run changelog` still exists for drafting an entry locally, which
# is the point in time where generating one is useful.
#
# Fail closed. Empty notes on a public download page are worse than a
# failed run: the run can be re-run once the entry is written, but a
# published release with no description has already been fetched.
- name: Extract this tag's release notes
shell: bash
run: npx changelogen --output CHANGELOG.md
env:
TAG: ${{ github.ref_name }}
DOC_BASE: ${{ github.server_url }}/${{ github.repository }}/blob/${{ github.ref_name }}
run: |
# Headings carry an optional title ("## v1.2.0-alpha.1 — Discord
# feature parity"), so match the tag as a whole word, not a prefix:
# a bare prefix match would let `v1.2.0` swallow `v1.2.0-alpha.4`.
awk -v tag="$TAG" '
$0 == "## " tag || index($0, "## " tag " ") == 1 { found = 1; next }
found && /^## / { exit }
found
' CHANGELOG.md > release-notes.md
if [ ! -s release-notes.md ]; then
echo "::error::CHANGELOG.md has no '## $TAG' section. Write the entry, then re-run this release."
exit 1
fi
# Relative links resolve against the repository, not against a
# release page, so every one of them 404s for a release reader.
sed -i "s#](docs/#]($DOC_BASE/docs/#g" release-notes.md
echo "Release notes: $(wc -l < release-notes.md) lines, $(wc -c < release-notes.md) bytes"
# Sole publish target. This repo is public, so its own Releases page both
# satisfies AGPL source availability (via the owncord-src snapshot below)
@@ -620,5 +663,5 @@ jobs:
mapfile -t assets < <(find windows linux -type f)
assets+=(checksums.sha256 owncord-src-*.tar.gz)
gh release create "${{ github.ref_name }}" \
--notes-file CHANGELOG.md \
--notes-file release-notes.md \
"${assets[@]}"
+1
View File
@@ -9,6 +9,7 @@ Server/.env
.claude/*
!.claude/skills/
!.claude/workflows/
!.claude/rules/
!.claude/settings.json
CLAUDE.local.md
.mcp.json
+4
View File
@@ -6,6 +6,10 @@
# Generated, verified by `git diff --exit-code` after regeneration.
Server/db/dbgen/
Client/src/lib/protocolTypes.ts
# Frozen wire records, written by `go test ./ws -run TestEpoch1Fixtures -update`.
# Unlike the two above, drift is caught by that test's own frame comparison, not
# by `git diff --exit-code`.
protocol/fixtures/
# Dated point-in-time snapshots. scripts/check-doc-counts.mjs already treats
+617 -23
View File
@@ -1,5 +1,5 @@
{
"nextId": 349,
"nextId": 379,
"findings": [
{
"id": "OC-0001",
@@ -7335,13 +7335,19 @@
"why": "The VOICE_LEAVE handler has payload.channel_id in hand (it uses it two lines above for shouldTeardownSession) but calls handleParticipantLeft(payload.user_id) with no channel. E2EEManager.handleParticipantLeft then unconditionally deletes that user from _peerPublicKeys/_peerOfferEpochs, clears their verification badge, retires their key, and re-runs the key-holder election against the client's OWN voice channel — even though the leave was for a different channel entirely. voice_leave is broadcast to channelReadAudience(thatChannel), i.e. every client with READ_MESSAGES on it, not just the room's participants.",
"repro": "I am in voice channel A and hold the room key; I also have READ_MESSAGES on voice channel B. Peer P (in B) switches to A. Server order: voice_leave(B,P) and voice_state(A,P) are enqueued on the buffered h.broadcast queue by P's voice_join; P's voice_token is sent directly, so P connects and its voice_e2ee_announce is relayed to me via pubsub.Publish. With the broadcast goroutine backed up, my socket sees: (1) announce(P,K) -> I verify P, store K, offer P the current room key; (2) voice_leave(B,P) -> handleParticipantLeft(P) with no channel filter: hadPeerKey=true, P deleted from _peerPublicKeys, clearPeerVerification(P) wipes the verified badge, and because A's roster does not list P yet, retirePeerKey(P,K) retires P's LIVE key; then the `wasKeyHolder && hadPeerKey` branch rotates the room key excluding P; (3) voice_state(A,P) -> P appears in my voice widget. Result: P is visibly in my call but holds a superseded key — nothing I send decrypts for them and nothing they send decrypts for me. Nothing heals it: mid-call peers never re-announce, my 5-minute rotation iterates _peerPublicKeys (P is gone), and any later replay of P's stored key (e.g. sendVoicePeerKeys on my WS reconnect, hub.go:664-666) is rejected by the retirement guard at livekitE2EE.ts:782. Passing payload.channel_id and ignoring leaves for other channels fixes it; the ready-resync call site at dispatcher.ts:374-384 already scopes by channel.",
"evidence": "dispatcher.ts:1071-1079:\n const shouldTeardownSession =\n isSelf && voiceStore.getState().currentChannelId === payload.channel_id;\n void livekitSession().then(({ handleParticipantLeft, leaveVoice }) => {\n void handleParticipantLeft(payload.user_id); // <- payload.channel_id dropped\n\nlivekitE2EE.ts:1246-1253 (no channel parameter; acts on this._channelId):\n async handleParticipantLeft(userId: number): Promise<void> {\n const departingKey = this._peerPublicKeys.get(userId);\n const hadPeerKey = departingKey !== undefined;\n this._peerPublicKeys.delete(userId);\n this._peerOfferEpochs.delete(userId);\n clearPeerVerification(userId);\n const channelId = this._channelId ?? this.deps.getCurrentChannelId();\n\nAudience proof, hub_broadcast.go:126-143: broadcastVoiceEventWithLeaver resolves h.channelReadAudience(ctx, channelID) — everyone with READ on the channel, regardless of voice membership.\n\nReordering proof (already documented in-tree): voice_leave goes through the async hub queue (hub_broadcast.go:160-168 `h.broadcast <- bm`), while voice_e2ee_announce is published straight into the recipient's send queue from the announcer's read pump (voice_e2ee.go:270-272 `h.pubsub.Publish(VoiceTopic(channelID), ...)`) — the same hazard livekitE2EE.ts:1268-1272 cites for OC-0213.",
"status": "open",
"status": "fixed",
"found": "2026-08-22",
"hunt": "general-2026-08-22-b",
"lens": "voice-e2ee",
"suggestedFix": "Scope the E2EE notification to this client's own voice channel, mirroring removeVoiceUser and the shouldTeardownSession comparison already computed in the same handler. In dispatcher.ts, reuse the pre-leaveVoiceChannel store read: `const sameChannel = voiceStore.getState().currentChannelId === payload.channel_id;` (the value shouldTeardownSession already derives at :1071-1072), then at :1077 call it only when it matches — `if (sameChannel) void handleParticipantLeft(payload.user_id);` — leaving `if (shouldTeardownSession) void leaveVoice(false);` untouched. This keeps the one-argument call shape that dispatcher.test.ts:2728 asserts, and needs no change in livekitE2EE.ts. (Threading payload.channel_id into handleParticipantLeft and early-returning on mismatch against `this._channelId ?? this.deps.getCurrentChannelId()` is the alternative single-guard form, but it breaks that arity assertion and would require updating it to toHaveBeenCalledWith(7, 3).) The other caller, the ready-resync at dispatcher.ts:374-384, is already channel-scoped and unaffected.",
"confidence": "medium",
"finder": "opus"
"finder": "opus",
"fixed": "2026-08-28",
"fix": {
"commit": "a231108f",
"test": "Client/tests/unit/dispatcher.test.ts",
"revertProof": "pass"
}
},
{
"id": "OC-0312",
@@ -7403,13 +7409,19 @@
"why": "`payload.timestamp` is the raw SQLite `datetime('now')` string (\"2026-08-22 12:00:01\" — UTC, no zone designator; Server/migrations/001_initial_schema.sql:81, passed through verbatim by service/message_crud.go:79). `Date.parse` treats it as LOCAL time, so the parsed epoch is off by the viewer's UTC offset. The codebase already has `parseTimestamp()` (components/message-list/formatting.ts:25-33) that exists solely to append the missing \"Z\"; this comparison bypasses it. The bias cancels once `serverClockSkewMs` has been sampled (line 765 uses the same biased parse), but it is 0 until the first accepted live message — so the very first reconnect of a session is decided by the viewer's timezone instead of by the timestamp.",
"repro": "Cold-skew case (serverClockSkewMs still 0 — no chat_message received since login, i.e. a quiet channel).\nEast of UTC, e.g. viewer at UTC+2: socket blips and reconnects at wall time H; 1 s later a peer posts a genuinely LIVE message. Date.parse(ts) = T - 2h, so `T - 2h < H - 0` is true → isReplayFrame = true → notifyIncomingMessage is skipped (no desktop notification, no sound, no taskbar flash). Worse, line 700-702 computes `isMention = ... && !(mentions_here && isReplayFrame)`, so a live `@here` that names the viewer raises no mention badge at all — and the reconnect tier sends no follow-up `ready` to correct it (OC-0271), so the badge is lost permanently.\nWest of UTC, e.g. viewer at UTC-5: Date.parse(ts) = T + 5h, so the test is false for every frame → the entire replayed burst is classified live and fires one desktop notification + sound per already-seen message, which is exactly what the gate was added to prevent.\nNot caught by tests: every timestamp in tests/unit/dispatcher.test.ts (lines 515, 541, 597, 622, 672, 779, 810…) is an ISO `Z` string, a form the server never emits.",
"evidence": "685: const isReplayFrame =\n686: lastReconnectHandshakeAt !== null &&\n687: Date.now() - lastReconnectHandshakeAt < REPLAY_GATE_WINDOW_MS &&\n688: Date.parse(payload.timestamp) < lastReconnectHandshakeAt - serverClockSkewMs;\n...\n765: serverClockSkewMs = Date.now() - Date.parse(payload.timestamp);\n\n(the helper that exists for exactly this, formatting.ts:31-34:)\n const date = !raw.endsWith(\"Z\") && !raw.includes(\"+\") && !/T\\d{2}:\\d{2}:\\d{2}[+-]/.test(raw)\n ? new Date(raw.replace(\" \", \"T\") + \"Z\") : new Date(raw);",
"status": "open",
"status": "fixed",
"found": "2026-08-22",
"hunt": "general-2026-08-22-b",
"lens": "ordering-boundary",
"suggestedFix": "Route both parses through the existing UTC-normalizing helper instead of bare Date.parse — e.g. import { parseTimestamp } from \"@components/message-list/formatting\" (or lift it into @lib) and use `parseTimestamp(payload.timestamp).getTime()` at dispatcher.ts:688 and :765. One shared helper at both sites, no per-caller guards.",
"confidence": "high",
"finder": "opus"
"finder": "opus",
"fixed": "2026-08-28",
"fix": {
"commit": "a231108f",
"test": "Client/tests/unit/dispatcher.test.ts",
"revertProof": "pass"
}
},
{
"id": "OC-0316",
@@ -7420,13 +7432,19 @@
"why": "registerNow's resume-time E2EE resync (OC-0276) only pushes other participants' stored ECDH public keys *to* the resuming client. The room key itself travels the other way, as a targeted unsequenced voice_e2ee_offer, and one sent while the socket was down is dropped outright. Nothing on either side re-runs the exchange after the resume: the server never re-offers, and the client's only re-announce paths (setupKeyExchange, reannounceForReconnect) are both driven by the LiveKit room, not by the WebSocket, so a pure WS blip leaves a non-key-holder holding the pre-rotation key with no signal and no retry.",
"repro": "Users A (lower user id, key holder) and B are in a voice call; both LiveKit sessions are healthy. B's WebSocket drops (WiFi blip / proxy restart) but its LiveKit room stays up — nothing tears voice down on a socket drop alone (dispatcher.ts READY comment, livekitSession.ts). The server has not yet observed B's TCP close, so B's old *Client is still in h.clients. While B is offline a third participant C leaves (or A's 5-minute KEY_ROTATION_INTERVAL_MS timer fires, livekitE2EE.ts:109): A rotates the room key and sends a voice_e2ee_offer for B. sendToUserIfInVoiceChannel queues it onto B's dead client and it is lost. B reconnects with last_seq > 0; handleReconnect replays the sequenced voice_state/voice_leave frames, registerNow transfers B's voice state and calls sendVoicePeerKeys — so B's roster and peer-key map are correct — but B's keyProvider still holds the pre-rotation key. From that moment A and B cannot decrypt each other's frames: both hear silence while VoiceWidget still shows \"Secured\", and the only recovery is A's next 5-minute periodic rotation.",
"evidence": "Server/ws/hub.go:664-666 (registerNow tail):\n if voiceChID := c.getVoiceChID(); voiceChID != 0 {\n h.sendVoicePeerKeys(c, voiceChID)\n }\nsendVoicePeerKeys (Server/ws/voice_e2ee.go:344-350) only sends buildVoiceE2EEAnnounce(uid, pubKey, sig) for every *other* participant — no room-key material.\n\nThe offer path drops silently while the socket is down (Server/ws/voice_e2ee.go:239-259):\n target, ok := h.clients[targetUserID]\n if !ok { slog.Debug(\"e2ee: key offer dropped, target not connected\", ...); return }\n ...\n target.sendMsg(msg)\n(and while the dead old *Client is still registered, sendMsg queues into a send buffer that registerNow's old.closeSend() then discards).\n\nClient side, the only two re-announce entry points are LiveKit-driven:\n Client/src/lib/livekitE2EE.ts:159 setupKeyExchange <- called only from livekitSession.ts:1103 (connectAndSetup)\n Client/src/lib/livekitE2EE.ts:346 reannounceForReconnect <- called only from livekitSession.ts:564 (attemptAutoReconnect)\nNeither is reachable from a WS resume: dispatcher.ts's AUTH_OK handler (lines 270-290) does exactly setAuth() + one channel_focus send, and the READY handler's E2EE work (OC-0201, dispatcher.ts:360-384) runs only on the full-resync tier, which a successful replay resume never takes.\n\nreannounceForReconnect's own comment states the assumption that is unmet here: \"the key holder will send a fresh offer if the key was rotated during our absence\" (livekitE2EE.ts:343-344) — true only because that path re-announces; the WS-resume path does not.",
"status": "open",
"status": "fixed",
"found": "2026-08-22",
"hunt": "general-2026-08-22-b",
"lens": "flow-reconnect",
"suggestedFix": "One server-side addition at registerNow's resync call site (Server/ws/hub.go:664-666) — do not put it inside sendVoicePeerKeys, since voice_join.go:531 shares that function and the joiner's own announce already comes from its client there:\n\n if voiceChID := c.getVoiceChID(); voiceChID != 0 {\n h.sendVoicePeerKeys(c, voiceChID)\n // Re-relay THIS client's own stored key back onto VoiceTopic so the\n // key holder's duplicate-announce branch re-wraps the CURRENT room\n // key for us — a rotation offer sent while this socket was down was\n // dropped and no replay tier can recover it.\n if key, sig := c.getE2EEPubKey(); key != \"\" {\n h.sendToVoiceChannelExcept(voiceChID, c.userID,\n buildVoiceE2EEAnnounce(c.userID, key, sig))\n }\n }\n\nThis needs no client change: handleAnnounceInner's dedup branch (livekitE2EE.ts:~812, \"duplicate announce — will re-send offer if key holder\") deliberately falls through to the wrap-and-offer branch on an identical key, so the holder re-offers the live room key. The announce is not blocked by _retiredPeerKeys (that set holds only keys a peer has been moved OFF of, never the live one) and re-runs verifyPeerAnnounce exactly as reannounceForReconnect's announce already does. Guard it on c.lastSeq > 0 if you want it strictly on the resume path.",
"confidence": "high",
"finder": "opus"
"finder": "opus",
"fixed": "2026-08-28",
"fix": {
"commit": "cd4cc850",
"test": "Server/ws/oc_0316_voice_e2ee_resume_rotation_test.go",
"revertProof": "pass"
}
},
{
"id": "OC-0317",
@@ -7437,13 +7455,19 @@
"why": "The replay branch suppresses the unread/mention increment but still writes `lastMessageId: messageId` unconditionally. When the redelivered id is *lower* than the stored watermark, the watermark is rolled backwards, so the very next frame in the same replay burst no longer looks like a replay and is counted as new. Its sibling `updateDmLastMessagePreview` (lines 186-190) documents this exact hazard (OC-0301) and returns `prev` instead — `updateDmLastMessage` never got the same treatment.",
"repro": "DM channel 5. Two messages (ids 101 then 102) are delivered by the server in the registerNow→buildReady window, so `ready` lands with unreadCount=2 / lastMessageId=102, and both frames are then drained from the queue as `chat_message` (dispatcher.ts:740 calls updateDmLastMessage for each, since the DM is neither own-message nor active).\n1. frame 101: isReplay = (101 <= 102) = true → unreadCount stays 2, but lastMessageId is overwritten with 101.\n2. frame 102: isReplay = (102 <= 101) = false → unreadCount = 3, and mentionCount = +1 if the message mentioned the reader.\nThe DM sidebar badge shows 3 unread (and a phantom mention) for 2 messages, and it survives until the next full `ready`. Nothing in tests/unit/dm-store.test.ts asserts lastMessageId after a stale call, so the behavior is not locked.",
"evidence": "const isReplay = updated.lastMessageId !== null && messageId <= updated.lastMessageId;\nreturn { channels: [ { ...updated,\n lastMessageId: messageId, // <-- regresses the watermark on a replay\n lastMessage: content,\n lastMessageAt: timestamp,\n unreadCount: isReplay ? updated.unreadCount : updated.unreadCount + 1,\n mentionCount: isMention && !isReplay ? updated.mentionCount + 1 : updated.mentionCount,\n}, ...rest ] };",
"status": "open",
"status": "fixed",
"found": "2026-08-22",
"hunt": "general-2026-08-22-b",
"lens": "hotspot-client-tauri-client-src-components",
"suggestedFix": "Mirror the sibling: in updateDmLastMessage's setState, replace the isReplay ternaries with an early `if (isReplay) return prev;` right after the isReplay computation (dm.store.ts:149). One guard in the shared function; the equal-id case is the same message ready already previewed, so nothing visible is lost.",
"confidence": "high",
"finder": "opus"
"finder": "opus",
"fixed": "2026-08-28",
"fix": {
"commit": "7c159c11",
"test": "Client/tests/unit/dm-store.test.ts",
"revertProof": "pass"
}
},
{
"id": "OC-0318",
@@ -7454,13 +7478,19 @@
"why": "Two sources of truth for one plugin directory, read with opposite precedence by the two paths that consume it. `installZipStagedManifest` reads *only* `plugin.json` from the staged zip, and that is the manifest that is validated, shown to the admin, persisted to `plugins.manifest_json`, and used to activate the instance. `scanPluginDirectory` — the path that runs on every server start — prefers `plugin.toml` and only falls back to `plugin.json` when the TOML file is absent. A zip may contain both files (installZipExtract rejects only symlinks and path escapes, not extra regular files), so the manifest that governs the plugin after a restart is one that was never examined at install time. The manifest is the per-plugin ACL (manifest.go:64-67, errors.go:18-22: \"the manifest — not the guest module — is the authority ... so an admin can see the full command surface before enabling the plugin\"), so this defeats exactly the review it exists for.",
"repro": "Server built with `-tags wazero` (the build where plugins actually execute and where TOML is parsed). Upload a zip through POST /api/v1/admin/plugins/install containing plugin.json with `\"permissions\": [\"commands\"]`, `\"commands\": [{\"name\":\"hello\"}]`, `\"entrypoint\":\"hello.wasm\"` — plus a plugin.toml at the same root declaring `permissions = [\"commands\",\"http\",\"storage\",\"ui\"]`, extra `[[commands]]` entries, and `entrypoint = \"other.wasm\"`. Install succeeds; installZipStagedManifest parses only the JSON, so the admin list, the stored manifest_json, and the immediately-activated instance all show the narrow JSON surface. Restart the server: LoadAll → scanPluginDirectory (loader.go:64) picks plugin.toml, installFromDisk upserts *that* manifest, and activateAll brings the plugin up with the broader capability set, the undeclared-at-review commands, and a different .wasm entrypoint — with no new admin action and no log line noting that the effective manifest changed. The same mechanism bites non-maliciously: an author who ships both files and later edits only plugin.json sees the stale TOML silently win after every restart while the freshly installed process used the JSON.",
"evidence": "registry.go:421-427 (install path)\n\tmanifestPath := filepath.Join(stageAbs, \"plugin.json\")\n\traw, err := os.ReadFile(manifestPath)\n\tif err != nil { return nil, fmt.Errorf(\"plugin zip: missing plugin.json at root: %w\", err) }\n\tmanifest, err := ParseManifest(raw)\n\nloader.go:63-86 (load path)\n\t// Prefer plugin.toml (wazero build) over plugin.json.\n\tmanifest, ok, tomlErr := tryLoadPluginTOML(pluginDir)\n\t...\n\tif !ok { /* only now read plugin.json */ }\n\nregistry.go:286-296 — the staged tree (including any plugin.toml) is promoted verbatim into finalDir and registered with the JSON manifest.\n`grep -rn \"plugin.toml\" Server/ --include=*.go` matches only manifest_toml.go and the loader comment: nothing in the install path ever looks at it.",
"status": "open",
"status": "fixed",
"found": "2026-08-22",
"hunt": "general-2026-08-22-b",
"lens": "explore-2",
"suggestedFix": "Make both paths resolve the manifest through one function instead of guarding each caller. Extract loader.go:62-86's precedence into a shared helper and call it from the install path too:\n\n\t// loader.go\n\tfunc loadManifestFromDir(dir string) (*Manifest, error) {\n\t\tif m, ok, err := tryLoadPluginTOML(dir); err != nil {\n\t\t\treturn nil, err\n\t\t} else if ok {\n\t\t\treturn m, nil\n\t\t}\n\t\traw, err := os.ReadFile(filepath.Join(dir, \"plugin.json\"))\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\treturn ParseManifest(raw)\n\t}\n\nThen replace registry.go:422-427 with `manifest, err := loadManifestFromDir(stageAbs)` (keeping the existing \"missing plugin.json at root\" wrapping for os.IsNotExist) and have scanPluginDirectory call the same helper. The manifest the admin's install validates is then byte-for-byte the one the next restart loads, in both build tags. If keeping JSON-only at install is preferred, the equally small alternative is to reject the ambiguity at the single install site — after extraction, `if _, err := os.Stat(filepath.Join(stageAbs, \"plugin.toml\")); err == nil { return nil, fmt.Errorf(\"plugin zip: must not contain both plugin.json and plugin.toml\") }` — but the shared-helper version also fixes the plain on-disk case where an author edits only one of the two files.",
"confidence": "high",
"finder": "opus"
"finder": "opus",
"fixed": "2026-08-28",
"fix": {
"commit": "bbbaeed4",
"test": "Server/plugin/registry_test.go, Server/plugin/registry_zip_toml_wazero_test.go",
"revertProof": "pass"
}
},
{
"id": "OC-0319",
@@ -7522,13 +7552,19 @@
"why": "The DNS-name branch uses `[\\w.-]+`, and JS `\\w` is `[A-Za-z0-9_]` — so a host containing `_` passes. Both `http_proxy::validate_remote_host` and `livekit_proxy::validate_remote_host` allow only `is_ascii_alphanumeric() || '.' | '-' | ':' | '[' | ']'` and reject `_`. Since every REST call routes through `ensureHttpProxy` (api.ts:88), an underscore host is accepted by the Add Server modal and by `api.setConfig`, then fails 100% of REST traffic. The file's own header comment and ServerPanel.ts:310-313 both state the invariant that this validator mirrors the Rust one (\"an address accepted here is also accepted by the actual connection path, and vice versa\").",
"repro": "Connect page -> \"Add Server\" -> address `my_server.lan:8443`. ServerPanel.ts:314 `isValidHost(addr)` returns true (JS `\\w` matches `_`), so the profile is saved. The connect page then health-checks it: `api.getHealth(\"my_server.lan:8443\")` -> `ensureHttpProxy(host)` -> `invoke(\"start_http_proxy\", {remoteHost})` -> http_proxy.rs:101 `validate_remote_host` -> Err(\"remote_host contains unexpected characters\"). Every REST call fails identically, so login is impossible and the profile shows permanently unreachable; `start_livekit_proxy` rejects the same host, so voice is dead too. The WS proxy has no charset check, so `wss://my_server.lan/api/v1/ws` would have connected — the client accepts an address that only one of its three transports can use.",
"evidence": "hostValidation.ts:33 return /^[\\w.-]+(:\\d+)?$/.test(host); // \\w includes '_'\n\nhttp_proxy.rs:83-88\n if !remote_host\n .chars()\n .all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | ':' | '[' | ']'))\n {\n return Err(\"remote_host contains unexpected characters\".into());\n }\n\nlivekit_proxy.rs:110-116 (identical charset, same rejection)\n\napi.ts:87-89\n async function baseUrl(): Promise<string> {\n return `${await ensureHttpProxy(config.host)}/api/v1`;\n }\n\ncommands.rs tests pin the Rust side as deliberate:\n (\"underscore\", \"chat_example.com\".into()), // expected to be rejected\n\ntests/unit/host-validation.test.ts has no underscore case, so nothing locks the TS behavior.",
"status": "open",
"status": "fixed",
"found": "2026-08-22",
"hunt": "general-2026-08-22-b",
"lens": "tauri-rust",
"suggestedFix": "One character-class change in the shared validator, hostValidation.ts:33: replace `\\w` with an explicit ASCII class so the DNS branch matches the Rust charset — `return /^[A-Za-z0-9.-]+(:\\d+)?$/.test(host);`. Add an underscore rejection case to tests/unit/host-validation.test.ts mirroring commands.rs:364 so the two validators stay pinned together.",
"confidence": "high",
"finder": "opus"
"finder": "opus",
"fixed": "2026-08-28",
"fix": {
"commit": "e95c57a4",
"test": "Client/tests/unit/host-validation.test.ts",
"revertProof": "pass"
}
},
{
"id": "OC-0323",
@@ -7624,13 +7660,19 @@
"why": "incrementUnread/incrementMention bump unconditionally. `Channel.lastMessageId` is declared (line 30) and filled from `ready`'s `last_message_id` (line 100), but no call site anywhere in the client reads it — the identical registerNow->buildReady double-delivery window that OC-0242 fixed for DMs (dm.store.ts) is unguarded for server channels.",
"repro": "A message is broadcast into channel #general between registerNow (Server/ws/serve.go:853, which subscribes the socket) and buildReady (serve.go:884) on a fresh connect or a full resync. The server counts it in read_states.unread_count, so `ready` carries unread_count = 1 and last_message_id = <that id>; `ready` is written straight to the connection by handshakeWrite while the broadcast waits in the client's send queue. setChannels applies unreadCount = 1, then writePump drains the queued chat_message and dispatcher.ts:711 calls incrementUnread -> the sidebar shows 2 unread for 1 message, and an @mention in it shows a mention count of 2. dm.store.ts:148 guards this exact case for DMs with `messageId <= updated.lastMessageId`; the channel path has no equivalent.",
"evidence": "channels.store.ts:346-363\n export function incrementUnread(channelId: number, evenIfActive = false): void {\n channelsStore.setState((prev) => {\n if (prev.activeChannelId === channelId && !evenIfActive) return prev;\n const existing = prev.channels.get(channelId);\n if (existing === undefined) return prev;\n const updated: Channel = { ...existing, unreadCount: existing.unreadCount + 1 };\n ...\n\nchannels.store.ts:30 / :100 — the watermark is stored and never consulted\n readonly lastMessageId: number | null;\n lastMessageId: ch.last_message_id ?? null,\n\n`grep -rn lastMessageId` over src/ shows the only readers are dm.store.ts and SidebarDmHelpers.ts — nothing reads Channel.lastMessageId.\n\ncaller: dispatcher.ts:706-715\n if ((payload.channel_id !== activeId || isDetached) && !isOwnMessage) {\n incrementUnread(payload.channel_id, isDetached);\n if (isMention) incrementMention(payload.channel_id, isDetached);\n }",
"status": "open",
"status": "fixed",
"found": "2026-08-22",
"hunt": "general-2026-08-22-b",
"lens": "hotspot-client-tauri-client-src-lib",
"suggestedFix": "Mirror the DM shape in the one shared store function rather than at the call site: give channels.store a single guarded entry point, e.g. `noteChannelMessage(channelId, messageId, isMention, evenIfActive)`, whose setState computes `const isReplay = existing.lastMessageId !== null && messageId <= existing.lastMessageId;` and writes `unreadCount: isReplay ? existing.unreadCount : existing.unreadCount + 1`, `mentionCount: isMention && !isReplay ? existing.mentionCount + 1 : existing.mentionCount`, and `lastMessageId: Math.max(messageId, existing.lastMessageId ?? 0)` — both counters behind ONE watermark read, exactly as OC-0242 required for updateDmLastMessage (a guard split across the two functions cannot work: the first call would already have advanced the watermark). Then replace the pair at dispatcher.ts:710/713 with the single call; it is the only production caller of incrementUnread/incrementMention, so the existing exports can stay for the tests.",
"confidence": "medium",
"finder": "opus"
"finder": "opus",
"fixed": "2026-08-28",
"fix": {
"commit": "3e74c968",
"test": "Client/tests/unit/channels.store.test.ts, Client/tests/unit/dispatcher.test.ts",
"revertProof": "pass"
}
},
{
"id": "OC-0329",
@@ -7777,13 +7819,19 @@
"why": "The cold-tier query is `ORDER BY seq ASC LIMIT n`, so when the range exceeds the cap it is the newest rows that are discarded. The main replay path 220 lines above (reconnectSelectReplay, serve.go:422 and serve.go:442) explicitly detects both of that query's failure modes — `len(persisted) >= coldCap` (cap hit, newest dropped) and a retention-pruned prefix (oldest-seq probe) — and forces a full ready. liveVoiceEventsSince calls the identical db.GetEventsSinceForChannels with the identical cap and has neither guard, so a truncated window is handed to the client as if it were the complete voice history for that room. Worse, the cap is spent on UNFILTERED rows: the query is `channel_id = 0 OR channel_id IN (chID)`, so global broadcasts and the DM's ordinary chat messages consume the budget, and the voice_state/voice_leave filter at serve.go:655 only runs on whatever survived. A peer's voice_leave that falls in the dropped tail is never delivered and never re-sent (the client tracks only max(seq)), so the resumed client renders a participant who has left; symmetrically, a dropped voice_state hides a peer who is really in the call, which also starves that peer of the E2EE announce/offer exchange keyed on the roster.",
"repro": "Config: event_persistence.enabled = true, event_persistence.replay_cold_limit = 50 (a legal value; ConfigureReplay accepts any positive int, Server/ws/hub.go:751). Alice and Bob are both in a voice call inside a 1:1 DM that Alice has since closed, so the DM id is outside Alice's allowedChannelIDs (computeAllowedChannels sources DM ids from dm_open_state) and handleReconnect takes the liveVoiceChID supplement branch at serve.go:286. Alice's socket drops. While she is offline: (1) Bob posts 60 messages into that DM — each is a persisted event on that channel_id — and then (2) Bob leaves voice, emitting voice_leave. Alice's readable channels stay quiet, so the main cold-tier replay at serve.go:417 returns well under 50 rows and succeeds (tier \"db\"), and the ring buffer no longer covers her last_seq. liveVoiceEventsSince then runs GetEventsSinceForChannels(lastSeq, [dmID], 50), which returns the OLDEST 50 rows — the first 50 chat messages — and drops the remaining 10 rows including Bob's voice_leave. Alice's client resumes with Bob still listed in the voice roster and never receives a correction; the same window would equally have swallowed a voice_state for a peer who joined late, leaving that peer invisible to her for the rest of the call.",
"evidence": "// Server/ws/serve.go:637-649 (liveVoiceEventsSince)\nif buf := h.ReplayBuffer().EventsSinceFiltered(afterSeq, only); buf != nil {\n\traw = buf\n} else if esp := h.eventStore.Load(); esp != nil {\n\tes := *esp\n\tpersisted, err := es.GetEventsSinceForChannels(ctx, int64(afterSeq), []int64{chID}, h.maxColdReplayLimit())\n\tif err != nil {\n\t\treturn nil\n\t}\n\traw = make([][]byte, 0, len(persisted))\n\tfor _, p := range persisted {\n\t\traw = append(raw, p.Payload)\n\t}\n}\n// no `len(persisted) >= coldCap` check, no oldest-seq retention probe — compare\n// Server/ws/serve.go:422-453, which has both for the same query:\n// case len(persisted) >= coldCap: \"...the NEWEST events were dropped...forcing full ready\"\n// case len(oldest) == 0 || uint64(oldest[0].Seq) > lastSeq+1: \"retention pruning left a gap...forcing full ready\"\n//\n// Server/db/event_queries.go:136-144 — the cap is applied before any type filter:\n// WHERE seq > ? AND (channel_id = 0 OR channel_id IN (...)) ORDER BY seq ASC LIMIT ?\n// Server/ws/serve.go:653-659 — voice filtering happens only on the truncated result.",
"status": "open",
"status": "fixed",
"found": "2026-08-22",
"hunt": "general-2026-08-22-b",
"lens": "explore-1",
"suggestedFix": "Stop spending the cap on non-voice rows and stop replaying a truncated window. Smallest change: give this call its own store method that applies the type filter in SQL — `WHERE seq > ? AND channel_id = ? AND event_type IN ('voice_state','voice_leave') ORDER BY seq ASC LIMIT ?` — so chat and global broadcasts can no longer evict voice events from the budget, and in liveVoiceEventsSince add the sibling's cap check: `if len(persisted) >= cap { slog.Warn(\"live voice supplement hit the row cap, skipping truncated window\"); return nil }`. Returning nil is the correct degradation here (a full ready is no longer available — registerNow already ran at serve.go:268 before the supplement at serve.go:287), and it restores the documented best-effort miss instead of installing a join whose matching leave was discarded. Do not simply raise the limit: that leaves the same silent-truncation hole one order of magnitude further out.",
"confidence": "medium",
"finder": "opus"
"finder": "opus",
"fixed": "2026-08-28",
"fix": {
"commit": "7aeab0ed",
"test": "Server/ws/reconnect_voice_supplement_coldtier_test.go",
"revertProof": "pass"
}
},
{
"id": "OC-0338",
@@ -7794,13 +7842,19 @@
"why": "`toml.Decode` resolves a TOML key to a struct field via the `toml` struct tag, or, when absent, the Go field name matched with `strings.EqualFold`. `Manifest`/`Resources` declare only `json` tags, so `max_memory_mb` and `cpu_budget_ms` never match `MaxMemoryMB` / `CPUBudgetMs` (underscores break EqualFold) and are left undecoded with no error. Every other manifest key happens to be a single word (`name`, `version`, `entrypoint`, `permissions`, `commands`, `ui`, `asset`, …) and case-folds fine, which is why the breakage is invisible — only the two snake_case resource keys are silently discarded, and `Validate()` only checks `>= 0`, so zero passes.",
"repro": "Build with `-tags wazero` (the only build where plugin.toml is parsed at all — manifest_nottoml.go:9 stubs it out). Ship `plugins/foo/plugin.toml`:\n\n name = \"foo\"\n version = \"1.0.0\"\n entrypoint = \"foo.wasm\"\n permissions = [\"commands\"]\n [[commands]]\n name = \"foo\"\n [resources]\n cpu_budget_ms = 2000\n max_memory_mb = 128\n\nscanPluginDirectory (loader.go:64) loads it via tryLoadPluginTOML; `Manifest.Resources` is `{0, 0}`. Invoke `/foo`: sandbox_wazero.go:317 falls through to `r.cfg.CPUBudgetMs` (config default 100), so a command the author budgeted 2000 ms for is killed at 100 ms with \"command exceeded CPU budget of 100ms\". The byte-identical plugin.json (`\"resources\": {\"cpu_budget_ms\": 2000}`) behaves correctly, so the same plugin works as JSON and misbehaves as TOML. installFromDisk then serializes the zeroed Resources back into `plugins.manifest_json` (loader.go:132-138, registry.go:192-196), so the admin plugin list also reports a budget the author never wrote. No test covers TOML decoding (`grep -rn toml Server/plugin/*_test.go` is empty), so nothing locks this in as intended.",
"evidence": "manifest_toml.go:31-38\n\tvar m Manifest\n\tif _, err := toml.Decode(string(raw), &m); err != nil { ... }\n\tif err := m.Validate(); err != nil { ... }\n\nmanifest.go:77-80\ntype Resources struct {\n\tMaxMemoryMB int `json:\"max_memory_mb\"`\n\tCPUBudgetMs int `json:\"cpu_budget_ms\"`\n}\n\ntoml@v1.6.0/decode.go:311-318 — `if ff.name == key { ... }` else `if f == nil && strings.EqualFold(ff.name, key) { f = ff }`\ntoml@v1.6.0/type_fields.go:108-113 — `name := opts.name; if name == \"\" { name = sf.Name }`, where `opts` comes from `tag.Get(\"toml\")` (encode.go:647-648).\n\nConsumer: sandbox_wazero.go:317-323\n\tbudgetMs := inst.Manifest.Resources.CPUBudgetMs\n\tif budgetMs <= 0 { budgetMs = r.cfg.CPUBudgetMs }\n\tif budgetMs <= 0 { budgetMs = 100 }",
"status": "open",
"status": "fixed",
"found": "2026-08-22",
"hunt": "general-2026-08-22-b",
"lens": "explore-2",
"suggestedFix": "Add toml tags to the two snake_case fields in Server/plugin/manifest.go:77-80:\n\ttype Resources struct {\n\t\tMaxMemoryMB int `json:\"max_memory_mb\" toml:\"max_memory_mb\"`\n\t\tCPUBudgetMs int `json:\"cpu_budget_ms\" toml:\"cpu_budget_ms\"`\n\t}\nThat is the minimal fix and is safe for the JSON path (encoding/json ignores the toml tag). Optionally harden the shared decode site instead of every future field: in tryLoadPluginTOML (manifest_toml.go:31) keep the MetaData and reject leftovers — `md, err := toml.Decode(...)`; `if u := md.Undecoded(); len(u) > 0 { return nil, false, fmt.Errorf(\"plugin.toml: unknown keys %v\", u) }` — which turns any future tag/name mismatch or manifest typo into a loud load error rather than a silent zero.",
"confidence": "high",
"finder": "opus"
"finder": "opus",
"fixed": "2026-08-28",
"fix": {
"commit": "073e8799",
"test": "Server/plugin/manifest_test.go",
"revertProof": "pass"
}
},
{
"id": "OC-0339",
@@ -7913,13 +7967,19 @@
"why": "`adminAuthMiddleware` already resolved the principal's `*db.Role` via `auth.ResolveTokenHash` and stored it in the request context under `adminRoleKey` (line 90). `ownerOnlyMiddleware` ignores that value, issues a second `GetRoleByID` on the request context, and collapses `err != nil` into the same 403 \"role not found\" it uses for a genuinely missing role. This is the exact fail-closed-as-authorization-denial collapse that the perimeter branch 60 lines above was explicitly fixed for (it now answers 503 SERVICE_UNAVAILABLE and logs, precisely so a DB outage is not reported as a bad credential), and that `api/middleware.go:117` was fixed for. The function's own doc comment (lines 120-121) claims it \"reads the user from context ... rather than re-authenticating, avoiding redundant DB queries\" — the redundant query it claims to avoid is the one that introduces the fault.",
"repro": "Owner is signed into the admin panel. Any transient read failure on the `roles` lookup (SQLITE_BUSY / \"database is locked\" while a scheduled backup's `VACUUM INTO` runs, a disk I/O error, or a context deadline on the reader pool) hits `GetRoleByID` during a request to one of the nine owner-only routes registered in Server/admin/api.go:148-176 — `GET /admin/api/updates`, `POST /admin/api/updates/apply`, `POST /admin/api/backup`, `GET /admin/api/backups`, `DELETE /admin/api/backups/{name}`, `POST /admin/api/backups/{name}/restore`, `GET|POST /admin/api/tokens`, `DELETE /admin/api/tokens/{id}`. The perimeter middleware immediately before it already succeeded and put the correct, non-nil Owner role in the context, so the request is fully authenticated and authorized. The Owner nevertheless receives HTTP 403 `FORBIDDEN {\"code\":\"FORBIDDEN\",\"message\":\"role not found\"}` — the admin panel renders a permission-denied error telling the server Owner they lack the Owner role — and nothing is logged, unlike the perimeter path which logs the underlying error. Using the already-resolved `adminRoleKey` value (or mirroring the perimeter's 503 + slog on `err != nil`) makes the outcome correct.",
"evidence": "// middleware.go:89-93 (perimeter already stores the role)\nctx := context.WithValue(r.Context(), adminUserKey, user)\nctx = context.WithValue(ctx, adminRoleKey, role)\n\n// middleware.go:59-69 (perimeter, after the OC fix: DB error != bad token)\ndefault:\n slog.ErrorContext(r.Context(), \"admin: token resolution failed\", \"error\", err)\n writeErr(w, http.StatusServiceUnavailable, \"SERVICE_UNAVAILABLE\", \"authentication service temporarily unavailable\")\n\n// middleware.go:130-134 (ownerOnlyMiddleware, unfixed sibling)\nrole, err := database.GetRoleByID(r.Context(), user.RoleID)\nif err != nil || role == nil {\n writeErr(w, http.StatusForbidden, \"FORBIDDEN\", \"role not found\")\n return\n}",
"status": "open",
"status": "fixed",
"found": "2026-08-22",
"hunt": "general-2026-08-22-b",
"lens": "explore-1",
"suggestedFix": "Split the two outcomes in ownerOnlyMiddleware rather than switching to the context role. Reading adminRoleKey would be the cleaner design but it breaks both TestOwnerOnlyMiddleware_RoleNotFound and TestOwnerOnlyMiddleware_OwnerPassesThrough, which inject only adminUserKey. Smallest change that preserves every locked behavior, at Server/admin/middleware.go:130-134:\n\n role, err := database.GetRoleByID(r.Context(), user.RoleID)\n if err != nil {\n slog.ErrorContext(r.Context(), \"admin: owner role lookup failed\", \"error\", err)\n writeErr(w, http.StatusServiceUnavailable, \"SERVICE_UNAVAILABLE\", \"authorization service temporarily unavailable\")\n return\n }\n if role == nil {\n writeErr(w, http.StatusForbidden, \"FORBIDDEN\", \"role not found\")\n return\n }\n\nrole==nil still yields 403 (test at middleware_and_spawn_test.go:211 unaffected), the owner path still yields 200, and the DB fault now matches the perimeter's 503 + slog contract.",
"confidence": "medium",
"finder": "opus"
"finder": "opus",
"fixed": "2026-08-30",
"fix": {
"commit": "fb1afb8a",
"test": "Server/admin/middleware_and_spawn_test.go",
"revertProof": "pass"
}
},
{
"id": "OC-0346",
@@ -7930,13 +7990,19 @@
"why": "`recoverer` is registered at router.go:286, two slots ahead of `telemetry.HTTPMiddleware()` at router.go:291, and it snapshots `telemetry.TraceIDFromContext(r.Context())` *before* calling `next.ServeHTTP`. At that moment no span exists in the request context (otelhttp is downstream), so `TraceIDFromContext` returns \"\" on every request and the `trace_id` attribute the recovery closure promises is always dropped by the `if traceID != \"\"` guard at line 666. The panic record — the one log line where trace correlation matters most — is the only one that silently loses it, while in-handler logs via logctx.go:38 get it correctly because they run inside the span.",
"repro": "Build with `-tags otel`, set telemetry.enabled=true and exporter=\"otlp\" (or \"prometheus\"), then issue a request to any REST route whose handler panics (e.g. force a nil deref in a handler). The recovered-panic slog record contains method/path/panic/stack/req_id but never a trace_id attribute, even though otelhttp created a live span for that exact request and the trace is exported. Moving `r.Use(telemetry.HTTPMiddleware())` above `r.Use(recoverer)` (or reading the trace ID inside the deferred closure instead of before dispatch) makes the same request log the real trace ID.",
"evidence": "router.go:286-291\n\tr.Use(recoverer) // slog-routing panic recovery ...\n\tr.Use(requestLogger)\n\tr.Use(telemetry.HTTPMiddleware())\n\nrouter.go:646-667\n\t// Capture correlation IDs before dispatch ... while the\n\t// panic log still carries req_id/trace_id.\n\treqID := middleware.GetReqID(r.Context())\n\ttraceID := telemetry.TraceIDFromContext(r.Context()) // <- no span yet: always \"\"\n\tdefer func() {\n\t\tif rec := recover(); rec != nil {\n\t\t\t...\n\t\t\tif traceID != \"\" {\n\t\t\t\tattrs = append(attrs, \"trace_id\", traceID)\n\t\t\t}\n\t\t\tslog.Error(\"http handler panic recovered\", attrs...)\n\nBuild-tag dependency: telemetry_otel.go's TraceIDFromContext is the only implementation that can ever return non-empty (telemetry_default.go:26 hardcodes \"\"), and it reads trace.SpanContextFromContext(ctx), which is populated by otelhttp.NewHandler in (*otelProvider).HTTPMiddleware — mounted after recoverer.",
"status": "open",
"status": "fixed",
"found": "2026-08-22",
"hunt": "general-2026-08-22-b",
"lens": "explore-2",
"suggestedFix": "In routerMiddleware (Server/api/router.go:278-292) move `r.Use(telemetry.HTTPMiddleware())` above `r.Use(recoverer)`. With otelhttp outermost, recoverer's r.Context() already carries the live span, so the existing line 650 capture yields the real trace ID, and recoverer still recovers handler panics because it remains outside every route handler. This is one line in the shared stack rather than a change in recoverer, and it keeps the deferred closure free of context calls (the contextcheck constraint the comment cites). It does not disturb the ordering the file comment calls a security property — request-id binding, security headers and the body cap keep their relative positions.",
"confidence": "high",
"finder": "opus"
"finder": "opus",
"fixed": "2026-08-30",
"fix": {
"commit": "775eba50",
"test": "Server/api/recoverer_otel_test.go",
"revertProof": "pass"
}
},
{
"id": "OC-0347",
@@ -7971,6 +8037,534 @@
"suggestedFix": "Tighten the single shared selector rather than its caller — in members.store.ts getOnlineMembers, change the predicate to `if (member.status !== \"offline\" && member.status !== \"invisible\")`, matching MemberList's isAwayStatus.",
"confidence": "high",
"finder": "opus"
},
{
"id": "OC-0349",
"title": "voice_join relays the joiners own voice_state through the asynchronous hub queue while the rest of the join burst is written directly, so its position on the joiners socket is unordered",
"file": "Server/ws/voice_join.go",
"line": 498,
"severity": "low",
"why": "voiceJoinComplete writes voice_token (voice_join.go:445), each existing participants voice_state (:523), the peers voice_e2ee_announce relays (sendVoicePeerKeys, :531) and voice_config (:546) straight into the joiners send queue with c.sendMsg, but the joiners OWN voice_state goes through h.broadcastVoiceEvent (:498), which hub_broadcast.go:95-168 enqueues on the buffered h.broadcast channel for the broadcast goroutine to fan out later. The joiner is part of that audience (channelReadAudience), so nothing orders its own sequenced voice_state against the four direct frames: it can land before voice_token, between the existing participants states, or after voice_config, depending on how backed up the broadcast goroutine is. The B2-1 fixture capture could not pin the join burst and had to document the order as unspecified (docs/protocol.md:927-929) and exclude that one frame from the epoch-1 transcripts ordered comparison (protocol_epoch1_contract_test.go:69-75, 636-648). A client that treats the reply as an ordered burst — e.g. takes its own voice_state as the signal that the roster before it is complete, or that the join finished before voice_config — reads a state that is right most of the time and wrong under load.",
"repro": "cd Server && go test -tags deadlock ./ws -run TestEpoch1Fixtures -count=30 at 1fe3df79 with the voice-join journeys own-voice_state exclusion removed (protocol_epoch1_contract_test.go:636-648): roughly 1 run in 30 records the joiners own voice_state after the existing participants states or after voice_config instead of directly after voice_token; the default build reorders less often but is not immune. Equivalently, keep the broadcast goroutine busy (a burst of chat_send into another channel from a second client) while a client sends voice_join and watch the joiners own voice_state trail voice_config on its socket.",
"evidence": "Server/ws/voice_join.go:445 c.sendMsg(buildVoiceToken(channelID, token, \"/livekit\", h.livekit.URL(), isKeyHolder)) // direct\nServer/ws/voice_join.go:498 h.broadcastVoiceEvent(ctx, channelID, buildVoiceState(*state)) // hub queue, joiner in audience\nServer/ws/voice_join.go:523 c.sendMsg(buildVoiceState(vs)) // direct, per existing participant\nServer/ws/voice_join.go:531 h.sendVoicePeerKeys(c, channelID) // direct\nServer/ws/voice_join.go:546 c.sendMsg(buildVoiceConfig(channelID, quality, bitrate, maxUsers)) // direct\nServer/ws/hub_broadcast.go:95-168 broadcastVoiceEvent -> h.broadcast <- bm (buffered channel; deliverBroadcast fans out on the hub goroutine)\ndocs/protocol.md:927-929 \"Items 1, 3 and 4 are written directly and keep that relative order; item 2 travels through the hubs broadcast queue, so its position relative to the other three on the joiners own socket is not guaranteed.\"\nServer/ws/protocol_epoch1_contract_test.go:69-75, 636-648 the epoch-1 transcript deliberately records the joiners own voice_state out of the ordered comparison because under -tags deadlock it was observed arriving after the direct frames.",
"status": "open",
"found": "2026-08-28",
"hunt": "b2-1-fixture-capture-2026-08-28",
"lens": "ordering-boundary",
"suggestedFix": "Write the joiners own voice_state to the joiner directly (c.sendMsg, in program order at :498, so the whole burst on the joiners socket is one goroutines program order) and broadcast it to everyone except the joiner — broadcastVoiceEventWithLeaver already carries an exclude-user path in hub_broadcast.go, so the fix is one call-site change plus a helper, not a new fan-out. Check the seq contract first: deliverBroadcast is where the sequenced copy is stamped and appended to the replay buffer, so the joiners direct copy must carry the same seq (or be documented as the unsequenced form like the relayed existing states) rather than double-stamping. Behaviour change within epoch 1 (same frame set, deterministic position): regenerate the epoch-1 fixture in the same PR (`go test ./ws -run TestEpoch1Fixtures -update`), drop the contract tests exclusion so the order is asserted, and tighten docs/protocol.md:927-929.",
"confidence": "high",
"finder": "fable"
},
{
"id": "OC-0350",
"title": "Admin panel login has no 2FA branch — any admin with TOTP enabled is permanently locked out of /admin",
"file": "Server/admin/static/index.html",
"line": 659,
"severity": "high",
"why": "POST /api/v1/auth/login answers a TOTP-enabled account with HTTP 200 and `{\"partial_token\":…,\"requires_2fa\":true}` and NO `token`. The admin panel's sign-in handler only checks `res.ok`, then assigns `d.token` (undefined) and proceeds. There is no `requires_2fa` branch and no call to `/auth/verify-totp` anywhere in the file, so the panel can never complete a 2FA login.",
"repro": "1. Owner account enables 2FA from the desktop client (POST /users/me/totp/enable + confirm).\n2. Open https://server/admin and sign in with the correct username + password.\n3. Server returns 200 `{\"partial_token\":\"…\",\"requires_2fa\":true}`. The handler sets `state.token = undefined` and writes the literal string \"undefined\" into localStorage['admin_token'].\n4. `enterApp()` → `api('GET','/me')` sends `Authorization: Bearer undefined` → adminAuthMiddleware 401 → `handleSessionExpired()` clears the token and shows the login overlay with \"Your session expired — sign in again.\"\n5. Every retry repeats this. The admin panel is unreachable for that account, with no error explaining why.\n\nAggravating case: docs/security.md:41 advertises the `require_2fa` server setting, and Server/admin/handlers_settings.go:148 refuses to enable it until *every* user has TOTP enrolled — so following the documented hardening path locks every principal out of the admin panel at once.",
"evidence": "Server/admin/static/index.html:650-661\n document.getElementById('loginBtn').onclick=async()=>{\n ...\n try{const r=await fetch('/api/v1/auth/login',{...});const d=await r.json();if(!r.ok)throw new Error(d.message||'Login failed');\n state.token=d.token;localStorage.setItem('admin_token',state.token);await enterApp();\n }catch(e){err.textContent=e.message}\n\nServer/api/auth_handler.go:363-377\n if user.TOTPSecret != nil {\n partialToken, err := partialStore.Issue(...)\n ...\n writeJSON(w, http.StatusOK, authSuccessResponse{\n PartialToken: partialToken,\n Requires2FA: true,\n })\n return\n }\n\nServer/api/auth_handler.go:67-72 Token is `json:\"token,omitempty\"` — omitted entirely on the 2FA branch.\n\n`grep -n \"verify-totp|partial_token|requires_2fa\" Server/admin/static/index.html` → no matches.",
"status": "open",
"found": "2026-08-29",
"hunt": "general-2026-08-29",
"lens": "hotspot-server-admin-static",
"suggestedFix": "Add the missing 2FA branch at the single shared login handler (Server/admin/static/index.html:659). After parsing `d`, before assigning the token: if `d.requires_2fa && d.partial_token`, show a code prompt and complete the login with `POST /api/v1/auth/verify-totp` sending `Authorization: Bearer <d.partial_token>` and body `{code}` (the contract handleVerifyTOTP expects, totp_handler.go:41-66), then use the `token` from that 200 response. As a one-line stopgap for the same spot, guard `if(!d.token) throw new Error(d.requires_2fa?'This account has two-factor authentication enabled; the admin panel cannot complete 2FA sign-in yet.':'Login failed')` so the panel stops writing the literal string \"undefined\" into localStorage and gives a truthful error instead of a false \"session expired\".",
"confidence": "high",
"finder": "opus"
},
{
"id": "OC-0351",
"title": "voice_join tears the user out of their current call before the destination's capacity is ever checked, so clicking a full voice channel ends the call for nothing",
"file": "Server/ws/voice_join.go",
"line": 222,
"severity": "medium",
"why": "handleVoiceJoin's pre-flight (voiceJoinPrecheck, lines 84-172) refuses every condition that would make the join bounce — rate limit, bad channel_id, CONNECT_VOICE, missing channel, wrong channel type, DM block, archived, nil user, LiveKit down — but it never checks voice_max_users. voiceJoinLeaveCurrent then unconditionally runs the destructive half of the switch (h.handleVoiceLeave at line 223: DB row deleted, voice_leave broadcast to the whole READ audience, LiveKit participant removed, key-holder re-elected), and only afterwards does voiceJoinPersist (line 269) run the atomic capacity check and answer CHANNEL_FULL. The user is already out of their old channel when the refusal is sent. The sibling server-driven path knows this is wrong and guards it: handleVoiceModMoveV2 runs an explicit advisory CountChannelVoiceUsers pre-flight (Server/ws/voice_moderation.go:426-437) with the comment \"this one keeps the common case from dropping the target into a channel that is already full\", and the whole handler is introduced as \"the pre-flight: they refuse a move the re-join would only bounce, so the target is never dropped from voice for nothing.\" The self-switch path has no equivalent.",
"repro": "Voice channel B has voice_max_users = 2 and two participants. User U is live in voice channel A. U clicks B (the client deliberately never pre-blocks the click — Client/src/lib/dispatcher.ts:1291-1296: \"The server owns the limits ... the client never pre-blocks the click\"), sending voice_join{channel_id: B}. voiceJoinPrecheck passes (B exists, is a voice channel, is not archived, U holds CONNECT_VOICE). voiceJoinLeaveCurrent then calls handleVoiceLeave: U's voice_states row for A is deleted, voice_leave(A, U) is broadcast to everyone who can see A, U's LiveKit participant in A is removed, and A's E2EE key holder is re-elected. voiceJoinPersist then returns db.ErrChannelFull and the server replies CHANNEL_FULL. Client-side, dispatcher.ts:1287-1291 sees voiceStatus === \"joining\" and calls leaveVoice(true). End state: U is in no voice channel at all, their call in A is over, and rejoining A can now itself fail if A filled up meanwhile. Deterministic — no race required. Neither TestVoice_Join_ChannelFull (Server/ws/voice_handlers_test.go:996) nor TestHandleVoiceJoin_ChannelFull (Server/ws/coverage_voice_test.go:395) covers the switch case: in both, the refused joiner starts with no current voice channel, so the destructive leave never runs.",
"evidence": "// voice_join.go:221-223 — destructive leave, no capacity pre-flight above it\n\t// If user is already in a different voice channel, leave it first.\n\tif currentChID > 0 {\n\t\th.handleVoiceLeave(ctx, c)\n\n// voice_join.go:266-273 — the capacity check runs only AFTER that leave\n\t// Check channel capacity and persist to DB atomically.\n\tmaxUsers := ch.VoiceMaxUsers\n\tif maxUsers > 0 {\n\t\tif err := h.db.JoinVoiceChannelIfCapacity(ctx, c.userID, channelID, maxUsers); err != nil {\n\t\t\tif errors.Is(err, db.ErrChannelFull) {\n\t\t\t\tc.sendMsg(buildErrorMsg(ErrCodeChannelFull, \"voice channel is full\"))\n\t\t\t\treturn nil, false\n\n// voice_moderation.go:426-437 — the sibling path DOES pre-check\n\tif dest.VoiceMaxUsers > 0 {\n\t\tcount, cErr := d.DB.CountChannelVoiceUsers(ctx, c.ToChannelID())\n\t\t...\n\t\tif count >= dest.VoiceMaxUsers {\n\t\t\treturn Result{Error: ClientError{Code: ErrCodeChannelFull, Message: \"voice channel is full\"}}\n\t\t}\n\t}",
"status": "open",
"found": "2026-08-29",
"hunt": "general-2026-08-29",
"lens": "ws-hub",
"suggestedFix": "Add the same advisory capacity pre-flight the sibling path already has, in voiceJoinPrecheck where `ch` is already in hand — after the Archived check and before any state mutation — scoped to the switch case so the same-channel re-join keeps answering ALREADY_JOINED: `if cur := c.getVoiceChID(); cur > 0 && cur != channelID && ch.VoiceMaxUsers > 0 { count, cErr := h.db.CountChannelVoiceUsers(ctx, channelID); if cErr != nil { c.sendMsg(buildErrorMsg(ErrCodeInternal, \"failed to check channel capacity\")); return 0, nil, false }; if count >= ch.VoiceMaxUsers { c.sendMsg(buildErrorMsg(ErrCodeChannelFull, \"voice channel is full\")); return 0, nil, false } }`. CountChannelVoiceUsers already exists (Server/db/voice_queries.go:329). This is advisory only — the atomic JoinVoiceChannelIfCapacity check in voiceJoinPersist stays as the authority for the racing case, exactly as in handleVoiceModMoveV2 — and the joiner's own row lives on the old channel, so it cannot be miscounted against the destination.",
"confidence": "high",
"finder": "opus"
},
{
"id": "OC-0352",
"title": "A key holder's reconnect re-announce is fire-and-forget with no confirmation retry, so one dropped WS frame permanently breaks room-key delivery for every participant",
"file": "Client/src/lib/livekitE2EE.ts",
"line": 401,
"severity": "medium",
"why": "reannounceForReconnect() mints a brand-new ephemeral ECDH keypair on every LiveKit auto-reconnect attempt and publishes it with a single unacknowledged `getWs()?.send(...)` (line 384). WsClient.sendRaw silently drops the frame when the WS proxy is not open (ws.ts:603-609 — `if (tauriInvoke === null || !proxyOpen) { log.warn(\"Cannot send, WebSocket not open\"); ... return; }`), and nothing queues or retries it. The one bounded retry that exists — the RECONNECT_CONFIRM_MS timer — is armed only for non-key-holders (`if (!this._isKeyHolder && roomKeyAtReconnect !== null)`), so a key holder whose announce is dropped has zero recovery: no retry, no log, no signal. Its published ECDH key is now permanently the pre-reconnect (dead) one, while it wraps every future room key under the new private half. No other code path ever re-announces mid-call: setupKeyExchange only runs on a fresh join, and the server's OC-0316 resume relay (Server/ws/hub.go:672) re-publishes the client's *stale stored* key, cementing the divergence.",
"repro": "1) Client H is the elected key holder (lowest uid) in a voice channel with peers P1, P2. 2) The user's network drops. livekit-client gives up on the SFU signal socket and the session enters attemptAutoReconnect (livekitSession.ts:498); ws.ts is meanwhile in exponential backoff (getReconnectDelay, ws.ts:206 — 1s/2s/4s/8s/16s/30s, capped at 30s), so after a ~60s outage its next retry is ~30s away. 3) Network returns. attemptAutoReconnect's 3s-delayed attempt calls `await this._e2ee.reannounceForReconnect()` (livekitSession.ts:564) BEFORE `newRoom.connect()`: a fresh pair K_new is generated, `this._ecdhKeyPair = pair` is published locally, and the announce carrying K_new is passed to `getWs()?.send(...)` while `proxyOpen === false` → dropped. 4) `newRoom.connect()` succeeds (the SFU is reachable again), so the room is live with K_new. Because `this._isKeyHolder` is true, the block at line 401 never arms the confirm timer, so the announce is never repeated. 5) The WS resumes ~30s later; the server transfers and re-relays H's OLD stored key K_old to VoiceTopic (hub.go:672, `if key, sig := c.getE2EEPubKey(); key != \"\"`). P1/P2 see a duplicate announce for K_old and keep K_old in _peerPublicKeys. 6) H's next rotation (5-minute timer, or any voice_leave) calls distributeRoomKey → wrapRoomKey(K_new.privateKey, peerPub, ...). P1/P2 unwrap with unwrapRoomKey(theirPriv, K_old.publicKey, ...): the two ECDH shared secrets differ, AES-GCM authentication fails, handleOfferInner's catch logs \"failed to handle offer\" and returns. Every peer stays on the superseded room key while H encrypts with the new one — the whole call is deaf and mute in both directions, with the UI still showing \"🔒 Secured\". 7) It never heals: a *new* joiner is handed H's stale K_old by sendVoicePeerKeys (Server/ws/voice_e2ee.go:329), so its setupKeyExchange also cannot unwrap H's offer and it is ejected with e2ee_timeout after 15s. The existing test at Client/tests/unit/livekit-e2ee.test.ts:1196 deliberately stands the manager down to a non-holder (`await mgr.handleOffer(PEER_ID, \"enc\", \"iv\"); // stands us down — now a non-holder`) before asserting the retry, so the holder case is neither covered nor intended behaviour.",
"evidence": "livekitE2EE.ts:384 this.deps.getWs()?.send({ type: \"voice_e2ee_announce\", payload: reconnectAnnounce });\nlivekitE2EE.ts:394-395 // periodic rotation (OC-0007). Holders don't need this: their own key\\n // IS the current one.\nlivekitE2EE.ts:401 if (!this._isKeyHolder && roomKeyAtReconnect !== null) {\nws.ts:603-609 if (tauriInvoke === null || !proxyOpen) { log.warn(\"Cannot send, WebSocket not open\"); queueMicrotask(() => notifySendFailure(id, \"OFFLINE\")); return; }",
"status": "open",
"found": "2026-08-29",
"hunt": "general-2026-08-29",
"lens": "voice-e2ee",
"suggestedFix": "One guard in reannounceForReconnect (Client/src/lib/livekitE2EE.ts, around :368): do not adopt the freshly generated pair when the announce cannot actually be delivered. Widen the E2EE ws dep to expose the connection state (WsClient already has getState(), ws.ts:755) and, when it is not \"connected\", skip the keypair swap entirely — keep the existing `_ecdhKeyPair`, still `keyProvider.setKey(...)` the retained room key, and return. Losing per-reconnect forward secrecy on that one attempt is strictly better than publishing a key half nobody holds; the published key and the local private half then can never diverge, for holders and non-holders alike. (Equivalent, slightly larger: re-send the stored `reconnectAnnounce` from an ws.onStateChange(\"connected\") hook until it is accepted.)",
"confidence": "medium",
"finder": "opus"
},
{
"id": "OC-0353",
"title": "LiveKit proxy rewrites Origin to a non-canonical \"https://host:443\", which the server's own origin allowlist can never match behind the documented reverse proxy — every remote voice join 403s",
"file": "Client/src-tauri/src/livekit_proxy.rs",
"line": 136,
"severity": "medium",
"why": "rewrite_proxy_headers replaces the webview's Origin with `https://` + remote_host, and remote_host always carries an explicit port (livekitSession.ts's ensureLiveKitProxy appends `:443` when the profile host has none). The server gates every `/livekit/*` request — including the signal WS upgrade — with isOriginAllowed, which accepts only (a) an absent Origin, (b) an Origin whose host string EqualFolds r.Host, (c) the three fixed `tauri.localhost` origins, or (d) an exact allowed_origins entry. The synthesized `https://chat.example.com:443` is not a canonical origin serialization (RFC 6454 omits the default port), so it matches none of the first-party entries, and it only matches r.Host when nothing in front of the Go server normalizes the Host header. The project's own documented nginx recipe uses `proxy_set_header Host $host`, and nginx's `$host` strips the port — so r.Host becomes `chat.example.com` while Origin stays `https://chat.example.com:443`.",
"repro": "Deploy the server behind the documented nginx snippet (docs/deployment.md:285-303) on chat.example.com with the default `server.allowed_origins: []`. Save the profile as host \"chat.example.com\" in the desktop client and join a voice channel from a machine that is not the server. ensureLiveKitProxy computes remote_host=\"chat.example.com:443\"; the Rust proxy sends `Host: chat.example.com:443` and `Origin: https://chat.example.com:443`; nginx forwards `Host: chat.example.com`; isOriginAllowed compares u.Host \"chat.example.com:443\" against r.Host \"chat.example.com\", falls through the first-party list and the empty allowlist, and returns false. The LiveKit signal WS gets 403 FORBIDDEN and voice never connects, while the chat WebSocket keeps working (ws_proxy uses tokio-tungstenite, which sends no Origin header at all, so it takes the `origin == \"\"` early-accept). Note livekit_proxy.rs:598's unit test pins the rewrite's shape but nothing tests that the resulting origin is one the server accepts.",
"evidence": "Client/src-tauri/src/livekit_proxy.rs:135-137\n } else if lower.starts_with(\"origin:\") {\n modified.push_str(\"Origin: https://\");\n modified.push_str(remote_host);\n\nClient/src/lib/livekitSession.ts:766 (ensureLiveKitProxy)\n hostWithPort = this.serverHost.includes(\":\") ? this.serverHost : `${this.serverHost}:443`;\n\nServer/api/livekit_proxy.go:147-149 (isOriginAllowed)\n\tif u, err := url.Parse(origin); err == nil && u.Host != \"\" && strings.EqualFold(u.Host, r.Host) {\n\t\treturn true\n\t}\n\nServer/api/livekit_proxy.go:76-84 — the check gates every /livekit/* request, WS upgrades included.\n\ndocs/deployment.md:297\n proxy_set_header Host $host;\n\nCorroborating: Server/api/livekit_proxy_test.go:127 calls the Rust layer the client's \"Origin-stripping Rust proxy\" — the server-side allowance was written against a premise (Origin removed) that livekit_proxy.rs does not implement (Origin rewritten).",
"status": "open",
"found": "2026-08-29",
"hunt": "general-2026-08-29",
"lens": "tauri-rust",
"suggestedFix": "Normalize the scheme-default port on both sides of the same-origin comparison in the shared server-side guard, Server/api/livekit_proxy.go:145 — e.g. compare `stripDefaultPort(u.Scheme, u.Host)` against `stripDefaultPort(schemeOf(r), r.Host)`, dropping only \":443\" for https and \":80\" for http. One guard in isOriginAllowed covers both the port-stripping and port-preserving proxies, and keeps TestIsOriginAllowed_SameHostDifferentPortDenied (:9999 vs :8443) failing as it should. Fixing it client-side by dropping :443 from the Origin alone would break the direct-on-443 case, where the rewritten Host still carries the port.",
"confidence": "medium",
"finder": "opus"
},
{
"id": "OC-0354",
"title": "authStore.user.totp_enabled is never populated from the server, so 2FA can never be disabled from Settings and every auth_ok wipes it",
"file": "Client/src/components/settings/AccountTab.ts",
"line": 786,
"severity": "medium",
"why": "The 2FA panel's entire state is derived from `authStore.getState().user?.totp_enabled`, but nothing on any production code path ever sets that field from the server. Its only two writers are the local post-action calls in MainPage (`updateUser({ totp_enabled: true })` at :559 and `false` at :570), and the dispatcher's AUTH_OK handler replaces `authStore.user` wholesale on *every* auth_ok — fresh connect and every WS resume alike — with the `auth_ok` payload user, which `buildAuthOK` (Server/ws/serve_ready.go:27-51) does not include `totp_enabled` in. So the flag is undefined at login and is reset to undefined by the first reconnect after an in-session enable.",
"repro": "Enable 2FA on an account, then restart the desktop app (or just let the WebSocket drop and resume once). Open Settings → Account → Two-Factor Authentication. The badge reads \"Disabled\" and `buildTotpEnrollForm` is rendered; `buildTotpDisableView` is unreachable, so there is no way to turn 2FA off from the UI. Clicking Enable posts /users/me/totp/enable and the server answers 409 \"disable 2FA before re-enabling\" — an instruction the UI makes impossible to follow. The badge also actively lies about the state of a security control. Within a single session the same wipe happens on reconnect: enable 2FA (panel flips to \"Enabled\"), drop the socket, resume — the next render reads undefined again.",
"evidence": "AccountTab.ts:786 `const enabled = authStore.getState().user?.totp_enabled === true;`\nAccountTab.ts:802-806 `if (enabled) { contentArea.appendChild(buildTotpDisableView(...)); } else { contentArea.appendChild(buildTotpEnrollForm(...)); }`\ndispatcher.ts:276 `setAuth(authStore.getState().token ?? \"\", payload.user, payload.server_name, payload.motd);`\nauth.store.ts:57-65 setAuth does `authStore.setState(() => ({ token, user, serverName, motd, isAuthenticated: true }))` — a wholesale replace, no merge.\nServer/ws/serve_ready.go:29-46 auth_ok payload user = {id, username, avatar, role, display_name, about, custom_status, status} — no totp_enabled.\nClient/src/lib/types.ts:823-828 `export interface AuthResponse { token?, partial_token?, requires_2fa }` — the login response's real `totp_enabled` (Server/api/auth_handler.go:62 and :721) is not even declared client-side, and main.ts's onLogin/onRegister/onTotpSubmit only read `result.token` / `result.requires_2fa` / `result.partial_token`.\n`api.getMe()` (api.ts:321) is never called anywhere in Client/src, and its `MemberResponse` (types.ts:916-925) carries no totp_enabled either.\nServer/api/totp_handler.go:194-200 `if user.TOTPSecret != nil && *user.TOTPSecret != \"\" { 409 TOTP_ALREADY_ENABLED \"disable 2FA before re-enabling\" }`",
"status": "open",
"found": "2026-08-29",
"hunt": "general-2026-08-29",
"lens": "flow-reconnect",
"suggestedFix": "Fix it in the one payload both the fresh-connect and resume paths share rather than in each client writer: add `\"totp_enabled\": user.TOTPSecret != nil` to the `user` map in buildAuthOK (Server/ws/serve_ready.go:29-46), declaring the field on the auth_ok user in protocol/schema.json and regenerating Server/ws/message_types.go + Client/src/lib/protocolTypes.ts via the protocol-change skill. That makes authStore.user.totp_enabled authoritative on every connect, so the existing wholesale setAuth stays correct and no client-side merge is needed.",
"confidence": "high",
"finder": "opus"
},
{
"id": "OC-0355",
"title": "The \"/\" quick-search hotkey swallows the keystroke inside the very inputs it focuses, so \"/\" can never be typed into the audit or log filter",
"file": "Server/admin/static/index.html",
"line": 2002,
"severity": "medium",
"why": "The global keydown handler calls e.preventDefault() for \"/\" whenever any .filter-search element exists, with no check that the event target is already a text field. On the Audit Log and Server Logs pages the search box IS that element, so the character is dropped instead of inserted. The hidden admin shell keeps its markup after logout, so the login form inherits the same swallow.",
"repro": "Open Server Logs, click the \"Filter logs...\" box and type `api/v1`. The field shows `apiv1` — every \"/\" is preventDefault-ed, so no log line can be filtered by path. Same in the Audit Log search box. Secondary: after visiting Audit Log or Server Logs, let the session expire (or sign out) — the login overlay is shown while #content still holds a .filter-search, so typing a password containing \"/\" silently drops that character and focus() targets a display:none input.",
"evidence": "index.html:2002 `if(e.key==='/'&&!document.querySelector('.modal-overlay.visible')){const s=document.querySelector('.filter-search');if(s){e.preventDefault();s.focus()}}`\nindex.html:1494 the Server Logs toolbar renders `<input class=\"filter-search\" placeholder=\"Filter logs...\" ... oninput=\"state.logSearch=this.value;renderLogLines()\">`\nindex.html:218 `.hidden{display:none!important}` — hideAll() only hides #adminShell, it never clears #content.",
"status": "open",
"found": "2026-08-29",
"hunt": "general-2026-08-29",
"lens": "hotspot-server-admin-static",
"suggestedFix": "Add one target guard at index.html:2002 before preventDefault: `if(e.key==='/'&&!document.querySelector('.modal-overlay.visible')){const t=e.target;if(t&&(t.isContentEditable||/^(input|textarea|select)$/i.test(t.tagName)))return;const s=document.querySelector('.filter-search');if(s){e.preventDefault();s.focus()}}` — this single guard fixes both the filter fields and the login form.",
"confidence": "high",
"finder": "opus"
},
{
"id": "OC-0356",
"title": "The Ctrl+K quick switcher is entirely unstyled — none of its five classes exist in any stylesheet, so the arrow-key selection is invisible and the result list has no scroller",
"file": "Client/src/components/QuickSwitcher.ts",
"line": 172,
"severity": "medium",
"why": "`createQuickSwitcher` builds its whole UI out of `quick-switcher`, `quick-switcher__input`, `quick-switcher__results`, `quick-switcher__item` and `quick-switcher__item--active`. `grep -n \"switcher\" Client/src/styles/*.css` returns **zero** matches across all five loaded stylesheets (tokens/base/login/app/theme-neon-glow, the complete set imported by `main.ts:3-7`), and only the backdrop (`quick-switcher-overlay`, line 166) carries an inline style. The `base.css` reset then applies: the modal is a transparent block with no background/width/padding on a 60%-black backdrop, `.quick-switcher__results` gets no `max-height`/`overflow` so a long channel list runs off the bottom of the viewport with no scroller, and — the functional break — `quick-switcher__item--active`, the class `renderResults()` moves on every ArrowUp/ArrowDown (lines 56-58, 110-126), paints nothing at all, so the keyboard highlight the widget's entire navigation model depends on is invisible.",
"repro": "Press Ctrl+K anywhere in MainPage (OverlayManagers.createQuickSwitcherManager → createQuickSwitcher). The overlay opens as unstyled text on a dark backdrop; press ArrowDown repeatedly — `activeIndex` advances and `aria-activedescendant` moves, but no row is visually highlighted, so Enter (line 129-136) navigates to a channel the user had no way to see was selected. On a server with ~30+ channels the results list also extends past the viewport bottom with no scrollbar, making the lower entries unreachable by mouse.",
"evidence": "QuickSwitcher.ts:56 class: isActive ? \"quick-switcher__item quick-switcher__item--active\" : \"quick-switcher__item\",\nQuickSwitcher.ts:172 const modal = createElement(\"div\", { class: \"quick-switcher\" });\nQuickSwitcher.ts:181 class: \"quick-switcher__input\",\nQuickSwitcher.ts:192 class: \"quick-switcher__results\",\n$ grep -n \"switcher\" Client/src/styles/*.css -> (no output)\n(compare: `.quick-switch*` — the *other* overlay — has 18 rules in app.css, and SearchOverlay's `.search-result-item--active` is defined)",
"status": "open",
"found": "2026-08-29",
"hunt": "general-2026-08-29",
"lens": "hotspot-client-src-components",
"suggestedFix": "Add the missing block to Client/src/styles/app.css (next to the existing .quick-switch-* rules) rather than to each call site — at minimum `.quick-switcher__item--active { background: var(--bg-modifier-selected); color: var(--text-normal); }` so the roving highlight paints, plus `.quick-switcher { width: 480px; max-width: 90vw; background: var(--bg-primary); border-radius: 8px; padding: 12px; }`, `.quick-switcher__input { width: 100%; padding: 8px; background: var(--bg-tertiary); color: var(--text-normal); border-radius: 4px; }`, `.quick-switcher__results { max-height: 50vh; overflow-y: auto; }` and `.quick-switcher__item { display: flex; gap: 8px; align-items: center; padding: 6px 8px; border-radius: 4px; cursor: pointer; }`.",
"confidence": "high",
"finder": "opus"
},
{
"id": "OC-0357",
"title": "Message search silently returns nothing for any query containing punctuation — sanitizeFTSQuery deletes separators instead of folding them to a space",
"file": "Server/db/message_queries.go",
"line": 50,
"severity": "low",
"why": "messages_fts uses FTS5's default unicode61 tokenizer, where every non-alphanumeric rune ('_', '.', '\\'', '/', ':', '@', …) is a token separator, so \"user_id\" is indexed as the two tokens `user` and `id`. sanitizeFTSQuery keeps letters/digits/space, folds only '-' to a space, and silently DROPS every other rune — which concatenates the neighbouring tokens into one term that exists nowhere in the index. The '-' arm exists for exactly this reason (its own comment: \"Folding to a space (rather than dropping it) still matches the indexed tokens\"), but the rule was never applied to the other separators.",
"repro": "Post a message \"don't touch user_id in docs/protocol.md\". FTS indexes the tokens don, t, touch, user, id, in, docs, protocol, md. Now search (GET /api/v1/messages/search?q=user_id, or the in-app search overlay): sanitizeFTSQuery returns \"userid\", the MATCH finds no such token, and SearchMessages returns zero hits with HTTP 200. Same for \"don't\" -> \"dont\", \"docs/protocol\" -> \"docsprotocol\", \"example.com\" -> \"examplecom\". Searching \"user id\" (with a space) matches, proving the content is indexed and reachable — only the punctuated spelling the user actually copied out of the message is silently unmatchable. Expected: fold each separator to a space, exactly as '-' already is, so the query becomes the same token sequence the tokenizer produced.",
"evidence": "\tfor _, r := range q {\n\t\tswitch {\n\t\tcase unicode.IsLetter(r) || unicode.IsDigit(r) || r == ' ':\n\t\t\tsb.WriteRune(r)\n\t\tcase r == '-':\n\t\t\tsb.WriteRune(' ')\n\t\t}\n\t}\n// (no default: every other rune is dropped, joining adjacent tokens)",
"status": "open",
"found": "2026-08-29",
"hunt": "general-2026-08-29",
"lens": "db-storage",
"suggestedFix": "In sanitizeFTSQuery, replace the special-cased `case r == '-': sb.WriteRune(' ')` with a `default: sb.WriteRune(' ')` arm, so every non-alphanumeric rune folds to a space exactly as '-' already does. Output stays within the documented charset (letters, digits, spaces), so the fuzz/unit contracts still hold, and the query becomes the same token sequence unicode61 produced when indexing.",
"confidence": "high",
"finder": "opus"
},
{
"id": "OC-0358",
"title": "EditMessageContent lacks the `AND deleted = 0` guard its SoftDeleteMessage / SetMessagePinned siblings have, so an edit racing a delete rewrites a tombstone and broadcasts chat_edited for a deleted message",
"file": "Server/db/message_queries.go",
"line": 179,
"severity": "low",
"why": "MessageService.EditMessage checks msg.Deleted from a read taken before the write, and db.EditMessage re-reads the row but still issues an UPDATE keyed on id alone. OC-0284 added `AND deleted = 0` to SoftDeleteMessage and SetMessagePinned (and both check RowsAffected) precisely so a message that races the writer surfaces as ErrNotFound instead of silently succeeding; the edit path was not given the same guard, so it commits content/edited_at onto an already soft-deleted row and then reports success.",
"repro": "Alice has message 100 open in the composer for editing. Interleave: (a) EditMessage reads message 100 via GetMessage — Deleted=false, passes the msg.Deleted guard and editMessageCheckAccess; (b) a moderator's chat_delete for message 100 commits (deleted=1, DecrementMentionCounts reverses its badges); (c) Alice's EditMessageContent UPDATE runs — no deleted predicate, so it rewrites the tombstone's content and stamps edited_at, then ReplaceMessageMentions re-inserts message_mentions rows for the now-deleted message that nothing will ever reverse; (d) the service returns success and the ws layer fans out chat_edited for a message every client has already tombstoned. Expected: `AND deleted = 0` plus a RowsAffected check mapping to ErrDeletedMessage, exactly as DeleteMessage and SetMessagePinned already do.",
"evidence": "messages.sql:19 -- name: EditMessageContent :one\n UPDATE messages SET content = ?, edited_at = datetime('now') WHERE id = ?\nmessages.sql:24 UPDATE messages SET deleted = 1 WHERE id = ? AND deleted = 0;\nmessages.sql:27 UPDATE messages SET pinned = ? WHERE id = ? AND deleted = 0;\nmessage_queries.go:179 updated, err := d.q.EditMessageContent(ctx, dbgen.EditMessageContentParams{Content: content, ID: id})",
"status": "open",
"found": "2026-08-29",
"hunt": "general-2026-08-29",
"lens": "db-storage",
"suggestedFix": "Add `AND deleted = 0` to the EditMessageContent statement in Server/db/queries/sqlite/messages.sql (via the db-change skill so dbgen regenerates) and, in db.EditMessage, map the resulting sql.ErrNoRows to ErrNotFound/a deleted-message error rather than a bare wrap, so MessageService.EditMessage surfaces ErrDeletedMessage — one guard in the shared query, exactly as OC-0284 did for SoftDeleteMessage.",
"confidence": "high",
"finder": "opus"
},
{
"id": "OC-0359",
"title": "secret_store's read-back-mismatch branch deletes the keyring entry before the fallback write commits — the exact eager-purge its sibling branch was fixed to defer",
"file": "Client/src-tauri/src/secret_store.rs",
"line": 149,
"severity": "low",
"why": "set_with's write-failure arm (line 164) was fixed to defer its keyring_delete until after fallback_set has actually committed, with a comment saying an eager delete \"risks erasing the last good copy of the secret if the fallback write fails too.\" The read-back-mismatch arm 15 lines above does exactly that eager delete, then falls through to `fallback_set(account, secret)?` — whose `?` returns before any replacement copy exists. If the fallback write fails, the credential/identity key that was in the keyring is gone and nothing was written anywhere.",
"repro": "A machine whose OS credential store accepts writes but serves a stale value. User re-logs in: save_credential -> secret_store::set -> keyring_set returns Ok(()); keyring_get returns the PREVIOUS (still-valid) credential blob, which != the new secret, so the Ok(Some(_)) arm at line 139 runs keyring_delete and removes it. Execution reaches line 179; fallback_set fails (app.path().app_data_dir() unresolvable, credential_fallback.key cannot be created, or store.save() hits ENOSPC) and returns Err. Both stores are now empty: the next load_credential returns Ok(None), indistinguishable from first login, and the user's saved credential — or, via save_identity_key, the voice-E2EE identity private key every peer has TOFU-pinned — is permanently destroyed. The existing test at secret_store.rs:648 only covers this arm with a SUCCEEDING fallback, so nothing locks the current behavior; the mirror-image test for the sibling arm (line 594, set_with_keeps_the_stale_keyring_entry_when_the_write_and_fallback_both_fail) has no counterpart here.",
"evidence": "Client/src-tauri/src/secret_store.rs:139-153\n Ok(Some(_)) => {\n log::error!(... \"returned a different secret than was written\" ...);\n if let Err(e) = keyring_delete(account) {\n log::warn!(\"{SERVICE}: could not remove the mismatched entry for '{account}': {e}\");\n }\n }\n...\nline 179: fallback_set(account, secret)?;\n\nContrast, same function, line 164-175:\n Err(e) => {\n ...\n // But the purge must wait until fallback_set below has actually\n // committed the replacement: deleting now, before that write is\n // known to succeed, risks erasing the last good copy of the secret\n // if the fallback write fails too.\n purge_stale_keyring_after_fallback_commits = true;\n }",
"status": "open",
"found": "2026-08-29",
"hunt": "general-2026-08-29",
"lens": "tauri-rust",
"suggestedFix": "In the Ok(Some(_)) arm (Client/src-tauri/src/secret_store.rs:139-153), drop the inline keyring_delete and reuse the existing deferral flag: `purge_stale_keyring_after_fallback_commits = true;`. The shared purge block at lines 181-188 then runs it only after fallback_set has committed, making both degraded arms obey the same ordering invariant with no new code path and no test change.",
"confidence": "medium",
"finder": "opus"
},
{
"id": "OC-0360",
"title": "LiveKitSession keeps a second copy of the master output-volume multiplier that setOutputVolume never updates, so the diagnostics dump always reports the app-startup value",
"file": "Client/src/lib/livekitSession.ts",
"line": 153,
"severity": "low",
"why": "The same value lives in two places: AudioElements.outputVolumeMultiplier (the one that is actually applied to participants) and LiveKitSession.outputVolumeMultiplier. setOutputVolume writes the pref and the AudioElements field only; the LiveKitSession field is initialised once from localStorage at module construction and never written again, yet it is what buildSessionDebugInfo publishes.",
"repro": "1. Launch the client with the stored `owncord:settings:outputVolume` at 100. LiveKitSession.outputVolumeMultiplier = 1.\n2. Settings -> Voice & Audio -> drag Output Volume to 40%. VoiceAudioTab calls setOutputVolume(40) -> AudioElements.outputVolumeMultiplier = 0.4 and audio really is at 40%.\n3. Settings -> Logs -> copy diagnostics (LogsTab.ts:288 getSessionDebugInfo()), or run `__owncord.lkDebug()`. The dump reports outputVolumeMultiplier: 1 while every participant is actually being played at 0.4 — the field is stale for the whole app session and only re-reads the pref on the next launch.",
"evidence": "livekitSession.ts:153 — `private outputVolumeMultiplier = loadPref<number>(\"outputVolume\", 100) / 100;` (no other assignment to this field exists in the file)\nlivekitSession.ts:1769-1774 — `return buildSessionDebugInfo({ room: this._room, currentChannelId: this._currentChannelId, outputVolumeMultiplier: this.outputVolumeMultiplier, ... })`\naudioElements.ts:248-252 —\n setOutputVolume(volume: number): void {\n const clamped = Math.max(0, Math.min(200, volume));\n savePref(\"outputVolume\", clamped);\n this.outputVolumeMultiplier = clamped / 100;\nlivekitDiagnostics.ts:171 — `outputVolumeMultiplier,` is emitted verbatim into the debug object.",
"status": "open",
"found": "2026-08-29",
"hunt": "general-2026-08-29",
"lens": "state-desync",
"suggestedFix": "Delete the duplicate field at livekitSession.ts:153 and read the single source of truth in getSessionDebugInfo: change line 1772 to `outputVolumeMultiplier: this._audioElements.getOutputVolumeMultiplier(),` (that getter already exists at audioElements.ts:90-92). One line, no caller or signature changes — buildSessionDebugInfo's deps shape is unchanged.",
"confidence": "high",
"finder": "opus"
},
{
"id": "OC-0361",
"title": "Admin Users / Audit Log pagination offers a phantom empty next page at exact multiples of PAGE_SIZE",
"file": "Server/admin/static/index.html",
"line": 832,
"severity": "low",
"why": "The \"has more\" decision is `users.length < PAGE_SIZE` on a page fetched with `limit=PAGE_SIZE`, so a full page is indistinguishable from \"a full page and nothing after it\". The server API supports over-fetching (and this repo already uses the fetch-one-extra pattern correctly in `MessageService.GetMessages`, which asks for `limit+1` and derives `hasMore := len(msgs) > limit`), so the admin panel is the odd one out. Same defect at line 1433 for the audit log.",
"repro": "Server with exactly 50 users (PAGE_SIZE = 50). Open Admin -> Users: `GET /users?limit=50&offset=0` returns 50 rows, so `users.length < PAGE_SIZE` is false and the \">\" button stays enabled. Clicking it sets usersPage=2 -> `GET /users?limit=50&offset=50` returns [] -> the table renders \"No users found\" with \"Page 2\" showing. The same sequence reproduces on Admin -> Audit Log (index.html:1433) with an entry count that is an exact multiple of 50.",
"evidence": "const offset=(state.usersPage-1)*PAGE_SIZE;\nusers=await api('GET','/users?limit='+PAGE_SIZE+'&offset='+offset)\n...\nhtml+='<button class=\"page-btn\" '+(users.length<PAGE_SIZE?'disabled':'')+' onclick=\"state.usersPage++;renderContent()\">&gt;</button>';\n// audit log sibling, line 1433:\nhtml+='<button class=\"page-btn\" '+(!entries||entries.length<PAGE_SIZE?'disabled':'')+' onclick=\"state.auditPage++;renderContent()\">&gt;</button>';",
"status": "open",
"found": "2026-08-29",
"hunt": "general-2026-08-29",
"lens": "ordering-boundary",
"suggestedFix": "Fetch one row past the page and derive hasMore from the overflow, matching MessageService.GetMessages. In renderUsers: `const rows=await api('GET','/users?limit='+(PAGE_SIZE+1)+'&offset='+offset); const hasMore=rows.length>PAGE_SIZE; users=rows.slice(0,PAGE_SIZE);` then line 832 becomes `(!hasMore?'disabled':'')`. Apply the identical change in renderAudit (lines 1396/1433), assigning the sliced array to state.auditCache so the \"N entries on this page\" label stays accurate. limit=51 is inside the server's 1..500 clamp, so no server change is needed.",
"confidence": "high",
"finder": "opus"
},
{
"id": "OC-0362",
"title": "clearCustomEmoji resets the emoji generation counter to 0, so a previous server's in-flight GET /emoji is accepted by the next server's session — and that server's own reply is then rejected as stale",
"file": "Client/src/stores/emoji.store.ts",
"line": 98,
"severity": "low",
"why": "The OC-0251 staleness guard in setCustomEmoji compares the caller's snapshotted `rev` against the store's current `rev`, but clearCustomEmoji (called from MainPage.destroy on logout/server switch) either resets `rev` to 0 via INITIAL or, when the set is already empty, early-returns and never touches `rev` at all. Either way the counter is back at 0 for the next session, which snapshots `emojiRevAtFetch = 0` too — so a reply belonging to the previous server still matches and is applied, which is precisely what clearCustomEmoji exists to prevent, and it then bumps rev to 1 so the new server's own reply is discarded as \"stale\".",
"repro": "1) Connect to server A. dispatcher's ready handler snapshots emojiRevAtFetch = 0 and issues GET /emoji to A. 2) Before that reply lands (slow/remote A), switch profiles to server B. MainPage.destroy() calls clearCustomEmoji(); the store is still empty, so line 98 early-returns and rev stays 0. 3) B's ready handler snapshots emojiRevAtFetch = 0 and issues its own GET /emoji. 4) A's late reply arrives first: setCustomEmoji(A_list, 0) sees 0 === 0, applies A's emoji and bumps rev to 1. 5) B's reply arrives: setCustomEmoji(B_list, 0) sees 0 !== 1 and is skipped. Result: for the whole B session, resolveEmoji answers from A's set — B's own shortcodes render as literal `:name:` text, and any A shortcode present in a B message renders `<img src=\"/api/v1/emoji/<A-id>/image\">` resolved against B's host, i.e. an unrelated emoji of B's. Nothing recovers until an emoji_update broadcast happens on B.",
"evidence": "emoji.store.ts:47-51 const INITIAL: EmojiState = { emoji: [], byShortcode: new Map(), rev: 0 };\nemoji.store.ts:90-93 emojiStore.setState((prev) => { if (rev !== undefined && rev !== (prev.rev ?? 0)) return prev; return { emoji: next, byShortcode, rev: (prev.rev ?? 0) + 1 }; });\nemoji.store.ts:97-99 export function clearCustomEmoji(): void { emojiStore.setState((prev) => (prev.emoji.length === 0 ? prev : INITIAL)); }\ndispatcher.ts:571-574 const emojiRevAtFetch = emojiStore.getState().rev ?? 0; api.listEmoji().then((list) => setCustomEmoji(list, emojiRevAtFetch))\nMainPage.ts:916-920 // Custom emoji belong to the server this page was connected to. ... clearCustomEmoji();",
"status": "open",
"found": "2026-08-29",
"hunt": "general-2026-08-29",
"lens": "hotspot-server-db",
"suggestedFix": "Make clearCustomEmoji invalidate in-flight fetches instead of resetting the counter: `emojiStore.setState((prev) => ({ emoji: [], byShortcode: new Map(), rev: (prev.rev ?? 0) + 1 }));` — unconditional (drop the `emoji.length === 0` early return), so any snapshotted rev from the previous session can never match again. One change in the shared store; no caller edits needed.",
"confidence": "medium",
"finder": "opus"
},
{
"id": "OC-0363",
"title": "Clicking any emoji permanently deletes every custom-emoji entry from the shared recent-emoji list whenever the current server's emoji set is not loaded",
"file": "Client/src/components/EmojiPicker.ts",
"line": 540,
"severity": "low",
"why": "getRecentEmoji applies a display-time filter that drops `:shortcode:` entries which do not resolve against the *current* server's emojiStore. addRecentEmoji feeds that filtered list straight back into localStorage.setItem, so a read-side presentation filter becomes a destructive write against the single, host-unscoped `owncord:recent-emoji` key.",
"repro": "On server A (which defines custom emoji :blob:), use :blob: — recents become [\":blob:\", \"😀\"]. Switch to server B, which has no :blob:. Open the picker and click any plain unicode emoji. addRecentEmoji calls getRecentEmoji(), whose line-531 filter drops \":blob:\" because resolveEmoji returns null on B, and line 543 writes the shortened list back. Returning to server A, :blob: is gone from Recent forever. The same wipe happens on server A itself if the picker is used before the ready-time GET /emoji resolves, or after it fails (dispatcher.ts:574's .catch only logs \"Failed to load custom emoji\"), since emojiStore is empty and every stored shortcode fails to resolve. The existing test (tests/unit/emoji-picker.test.ts:330) only pins the display filter, not the write-back.",
"evidence": "EmojiPicker.ts:509 const RECENT_KEY = \"owncord:recent-emoji\";\nEmojiPicker.ts:531 .filter((e) => !(e.startsWith(\":\") && e.endsWith(\":\")) || resolveEmoji(e) !== null)\nEmojiPicker.ts:539-543\nfunction addRecentEmoji(emoji: string): void {\n const recent = getRecentEmoji().filter((e) => e !== emoji);\n recent.unshift(emoji);\n try {\n localStorage.setItem(RECENT_KEY, JSON.stringify(recent.slice(0, MAX_RECENT)));\nemoji.store.ts:111 return emojiStore.getState().byShortcode.get(name) ?? null; // current server only",
"status": "open",
"found": "2026-08-29",
"hunt": "general-2026-08-29",
"lens": "hotspot-server-db",
"suggestedFix": "Split the read paths in Client/src/components/EmojiPicker.ts: add a `readStoredRecent()` that only parses/validates the JSON string array (no resolveEmoji filter), have `addRecentEmoji` build its list from that, and keep the `resolveEmoji` filter inside `getRecentEmoji()` for display only.",
"confidence": "high",
"finder": "opus"
},
{
"id": "OC-0364",
"title": "Admin Users page reports a lapsed temporary ban as \"Banned: Yes\" forever — every other surface treats the user as active",
"file": "Server/admin/static/index.html",
"line": 805,
"severity": "low",
"why": "The Users table derives its Banned column and Status dot from the raw `users.banned` column alone; nothing in the panel ever reads `ban_expires` (zero hits for ban_expires/BanExpires in that file), even though Server/db/admin_queries.go:87 maps it and Server/admin/types.go:101 ships it as `ban_expires`. Nothing on the server clears `users.banned` when a temporary ban lapses — expiry is decided lazily by auth.IsEffectivelyBanned and by the `replace(ban_expires,' ','T') <= strftime(...)` arm in ListMembers / CountUsersWithoutTOTP / notBannedClause / GetOwnerUser. So the row keeps banned=1 with a past ban_expires for life while the account is fully active (logs in, passes AuthMiddleware and the WS handshake, appears in the roster, counts for the last-admin and require_2fa guards), and the admin panel — the only surface where an operator reviews ban state — is the one place that still calls them banned.",
"repro": "1. From the desktop client, right-click a member -> Ban and pick a finite duration from the ban-duration dropdown (Client/src/components/AdminActions.ts:345; sent as ban_duration_hours by Client/src/lib/api.ts:794). The server stores banned=1 with ban_expires = now+N hours (Server/admin/handlers_users.go:169-176 -> db.BanUser).\n2. Let the ban lapse, or seed it directly: UPDATE users SET banned=1, ban_expires='2020-01-01T00:00:00Z' WHERE id=?.\n3. Log in as that user: login succeeds, they appear in every client's member list, they can be @mentioned, and they count toward the last-admin / require_2fa guards.\n4. Open the admin panel's Users page: the same account renders with a red \"Banned: Yes\" badge, the stale ban reason under it, a \"banned\" status dot instead of Online, and only an Unban action — the Ban button is hidden, so re-banning them takes two steps (Unban, then Ban).",
"evidence": "Server/admin/static/index.html:805-806,823:\n const status=u.Status||u.status||'offline';const banned=u.Banned||u.banned||false;\n const statusDot=banned?'banned':status;const statusLabel=banned?'Banned':status==='online'?'Online':'Offline';\n if(banned)html+='<button class=\"act-btn\" title=\"Unban\" onclick=\"unbanUser('+uid+')\">'+I.check+'</button>';\n else html+='<button class=\"act-btn danger\" title=\"Ban\" ...>'+I.ban+'</button>';\n\nServer/admin/types.go:99-101 (data the panel is given but ignores):\n\tBanned bool `json:\"banned\"`\n\tBanReason *string `json:\"ban_reason,omitempty\"`\n\tBanExpires *string `json:\"ban_expires,omitempty\"`\n\nServer/auth/helpers.go:73-91 (the rule every other surface applies):\n\tif u == nil || !u.Banned { return false }\n\tif u.BanExpires == nil { return true }\n\t... return time.Now().UTC().Before(t.UTC())",
"status": "open",
"found": "2026-08-29",
"hunt": "general-2026-08-29",
"lens": "explore-1",
"suggestedFix": "Derive the effective flag once in renderUsers (Server/admin/static/index.html:805) instead of reading the raw column: `const rawBan=u.Banned||u.banned||false; const exp=u.ban_expires||u.BanExpires||null; const banned=rawBan&&(!exp||new Date(String(exp).replace(' ','T').replace(/Z?$/,'Z')).getTime()>Date.now());` — the replace() normalises SQLite's space-separated form the same way db.notBannedClause does. That one expression fixes the badge, the status dot and the Ban/Unban button choice together. Do not move the computation into toAdminUserResponse: the API's raw `banned` is what the panel would still need to offer an Unban that clears the stale row.",
"confidence": "high",
"finder": "opus"
},
{
"id": "OC-0365",
"title": "GifPicker re-registers every result cell's click listener on the picker-lifetime AbortSignal on each search, retaining every discarded set of 20 GIF thumbnails",
"file": "Client/src/components/GifPicker.ts",
"line": 117,
"severity": "low",
"why": "`renderGifs` replaces the whole cell set (`clearChildren(gridArea)` then a fresh `.gp-grid`) on every load, but binds each new item's click listener to `signal` — the AbortController created once at `createGifPicker` and only aborted in `destroy()`. Per the DOM spec, `addEventListener` with a signal installs an abort algorithm on that signal holding a strong reference to the EventTarget, so every detached `.gp-item` (and its `<img>` with a decoded Klipy GIF) stays reachable until the picker closes. This is the identical mechanism already accepted and fixed for EmojiPicker.ts:620, SearchOverlay.ts:96, QuickSwitcher.ts:83, MemberList.ts:469 and MessageList.ts:332 — GifPicker is the one picker in that family that was not covered, and its cells are animated images rather than text.",
"repro": "Open the composer's GIF picker (MessageInput.ts:989). `loadGifs(\"\")` renders 20 trending cells, each with a click listener on `signal`. Type a query: the 300 ms debounce (GifPicker.ts:181) fires `loadGifs(\"cats\")`, `renderGifs` calls `clearChildren(gridArea)` and builds 20 brand-new cells with 20 more listeners on the same signal. Refine the search five times and the picker is holding 6 × 20 = 120 detached `<div class=\"gp-item\">` nodes plus their `<img>` GIF payloads, all rooted in `abortController.signal`'s abort-algorithm list, until `destroy()` runs when the picker is closed (MessageInput.ts:964). Take a heap snapshot after the fifth search: the detached-node count grows by 20 per search and never drops.",
"evidence": "Client/src/components/GifPicker.ts:46-47\n const abortController = new AbortController();\n const signal = abortController.signal;\n\nClient/src/components/GifPicker.ts:91-127\n function renderGifs(gifs: readonly GifResult[]): void {\n clearChildren(gridArea); // detaches the previous cell set...\n ...\n for (const gif of gifs) {\n const item = createElement(\"div\", { class: \"gp-item\", ... });\n ...\n item.addEventListener(\n \"click\",\n () => { options.onSelect(gif.fullUrl); options.onClose(); },\n { signal }, // ...but the picker-lifetime signal keeps holding it\n );\n\nClient/src/components/GifPicker.ts:206-211\n function destroy(): void {\n if (debounceTimer !== null) clearTimeout(debounceTimer);\n abortController.abort(); // the only release point",
"status": "open",
"found": "2026-08-29",
"hunt": "general-2026-08-29",
"lens": "explore-3",
"suggestedFix": "Register one delegated click listener on gridArea at mount time (alongside enableRovingNavigation at line 78), and drop the per-item addEventListener at line 117 — mirroring EmojiPicker's data-emoji delegation: store the URL on the cell as a data attribute (e.g. createElement(\"div\", { class: \"gp-item\", ..., \"data-full-url\": gif.fullUrl })) and have the delegated handler do const cell = (e.target as Element).closest<HTMLElement>(\".gp-item\"); if (cell?.dataset.fullUrl) { options.onSelect(cell.dataset.fullUrl); options.onClose(); }.",
"confidence": "high",
"finder": "opus"
},
{
"id": "OC-0366",
"title": "resetBlocksStore() rewinds blockedByMeRev to 0, so a previous session's in-flight GET /blocks is accepted by the next session (and the next session's own reply is not protected)",
"file": "Client/src/stores/blocks.store.ts",
"line": 96,
"severity": "low",
"why": "`resetBlocksStore()` writes the module-level `INITIAL`, which carries `blockedByMeRev: 0`. The OC-0218 staleness guard in `setBlockedByMe` compares the caller's snapshotted revision against the store's current revision — resetting that counter to its starting value makes a token snapshotted in the *previous* server/account session match again in the *next* one, so the stale reply is applied instead of skipped. This is exactly the defect already confirmed on the twin counter in `emoji.store.ts` (`clearCustomEmoji` resetting `rev` to 0), and it defeats the cross-server isolation `clearAuth` calls `resetBlocksStore` to guarantee (auth.store.ts:77-82, 105).",
"repro": "1. User A signs into server S1. `ready` fires; dispatcher.ts:551 snapshots `blockedByMeRevAtFetch = 0` (fresh session) and issues `GET /blocks` with no AbortSignal (api.ts:686 — dispatcher passes none). A has blocked user id 7 on S1.\n2. Before the HTTP reply lands (slow link / large server), A logs out (or the session is terminated). `clearAuth()` runs `resetBlocksStore()` → state is `INITIAL`, so `blockedByMeRev` goes back to 0 and `blockedByMe` is empty.\n3. User B signs in (another server, or the same server as a different account). Its `ready` snapshots `blockedByMeRev = 0` and issues its own `GET /blocks`.\n4. S1's earlier reply now resolves: `setBlockedByMe([7], 0)`. The guard at line 58 compares 0 === 0, passes, and writes A's blocked-user ids into B's store.\n5. Result: in B's session, `dmComposerBlockReason` (line 103-106) disables the DM composer for whoever user id 7 is on this server with \"You've blocked this user. Unblock to send messages.\", and MemberList's context menu (MemberList.ts:294) offers \"Unblock\" for a user B never blocked. Because `setBlockedByMe` does not bump the revision, B's own correct reply is *also* accepted at rev 0 — so whichever of the two HTTP responses lands last wins, and if the stale one loses the race the wrong list persists until the next `ready` or block toggle.",
"evidence": "blocks.store.ts:38-42 const INITIAL: BlocksState = { blockedByMe: new Set(), blockedByThem: new Set(), blockedByMeRev: 0 };\nblocks.store.ts:56-61 export function setBlockedByMe(userIds, rev?) { blocksStore.setState((prev) => { if (rev !== undefined && rev !== (prev.blockedByMeRev ?? 0)) return prev; return { ...prev, blockedByMe: new Set(userIds) }; }); }\nblocks.store.ts:95-97 export function resetBlocksStore(): void { blocksStore.setState(() => INITIAL); }\nauth.store.ts:105 resetBlocksStore();\ndispatcher.ts:551-554 const blockedByMeRevAtFetch = blocksStore.getState().blockedByMeRev ?? 0;\n api.listBlocks().then((r) => setBlockedByMe(r.blocked_user_ids, blockedByMeRevAtFetch))\napi.ts:686 listBlocks(signal?: AbortSignal) // dispatcher passes no signal, so the fetch is never aborted on logout",
"status": "open",
"found": "2026-08-29",
"hunt": "general-2026-08-29",
"lens": "explore-3",
"suggestedFix": "Make the revision monotonic across resets instead of rewinding it — one guard in the shared function: `export function resetBlocksStore(): void { blocksStore.setState((prev) => ({ ...INITIAL, blockedByMeRev: (prev.blockedByMeRev ?? 0) + 1 })); }`. Every snapshot taken before the reset then fails the equality at line 58, so no pre-logout reply can be applied, while a post-reset ready-time snapshot still matches.",
"confidence": "medium",
"finder": "opus"
},
{
"id": "OC-0367",
"title": "Create Role prefills a position that is already taken, so every role created after the first is refused",
"file": "Server/admin/static/index.html",
"line": 1310,
"severity": "low",
"why": "The create modal always prefills position = myPosition()-1 and saveRole always sends the field, so the server's auto-placement path (which walks down to the highest free slot) is unreachable. CreateRole rejects an explicitly requested position that is occupied, and the slot directly below the actor is occupied by the previous role the admin created.",
"repro": "Sign in as Owner (position 100). Roles → Create Role → name \"Helper\" → Save: created at position 99. Roles → Create Role again → name \"Greeter\": the Position field is prefilled 99 again, Save returns 400 \"position 99 is already used by another role\". Every subsequent create fails on its own default until the admin manually types a free number; the server's free-slot fallback never runs because the panel never omits position.",
"evidence": "index.html:1310 `const position=role?role.position:Math.max(0,myPosition()-1);`\nindex.html:1360 `position:parseInt(document.getElementById('rolePos').value,10)||0,`\nServer/service/role.go (CreateRole): `if in.Position != nil { ... if taken[position] { return nil, fmt.Errorf(\"%w: position %d is already used by another role\", ErrBadRequest, position) } } else { for position > 0 && taken[position] { position-- } ... }`",
"status": "open",
"found": "2026-08-29",
"hunt": "general-2026-08-29",
"lens": "hotspot-server-admin-static",
"suggestedFix": "Mirror the server's walk-down in the prefill at index.html:1310: `let position;if(role)position=role.position;else{const taken={};(state.roleList||[]).forEach(r=>{taken[r.position]=true});position=Math.max(0,myPosition()-1);while(position>0&&taken[position])position--;}` (equivalently, omit `position` from the create body so role.go's auto-placement branch runs).",
"confidence": "high",
"finder": "opus"
},
{
"id": "OC-0368",
"title": "UserProfilePopup's hand-rolled focus trap lets Tab/Shift+Tab walk out of the aria-modal dialog",
"file": "Client/src/components/UserProfilePopup.ts",
"line": 378,
"severity": "low",
"why": "The popup re-implements the focus trap instead of using @lib/a11y's trapFocus, and its copy is missing the two guards that helper documents as required: it never treats `document.activeElement === popup` (the dialog container, which is exactly what mount() focuses at line 341) as an edge of the cycle, and it returns without `e.preventDefault()` when the dialog holds no focusable control (line 373). Either case falls through to the browser's native tab order, so focus leaves a dialog that declares `aria-modal=\"true\"` and lands on controls hidden behind a full-screen `.upp-overlay` (position:fixed; inset:0; z-index:200).",
"repro": "Case A (any member): click a row in the member list; the popup mounts and focus sits on the popup container. Press Shift+Tab as the first keystroke. activeElement is the popup, which is neither `first` nor `last`, so no branch fires, nothing is prevented, and the browser moves focus backwards to the last tabbable element of the page *behind* the overlay (composer, sidebar buttons) — visually covered and unclickable, with no way to tab back in. Case B (own row): clicking your own member row builds a popup with zero buttons (MemberList.ts:257-260 omits onMessage when isSelf, and never passes onCall), so `focusable.length === 0` and plain Tab escapes the dialog the same way.",
"evidence": "line 341: popup.focus(); // activeElement is now the popup container itself\nline 373: if (focusable.length === 0) return; // no preventDefault -> native Tab escapes\nline 378: if (e.shiftKey && document.activeElement === first) {\nline 381: } else if (!e.shiftKey && document.activeElement === last) {\n// neither branch matches while activeElement === popup\n// contrast Client/src/lib/a11y.ts:74-88, which preventDefaults the empty case and\n// wraps when `active === container`.",
"status": "open",
"found": "2026-08-29",
"hunt": "general-2026-08-29",
"lens": "explore-3",
"suggestedFix": "Delete the hand-rolled listener at UserProfilePopup.ts:363-384 and call the shared helper instead: `import { trapFocus } from \"@lib/a11y\";` then `trapFocus(popup, signal);` — it preventDefaults the empty-dialog case and treats `active === container` as an edge that wraps.",
"confidence": "high",
"finder": "opus"
},
{
"id": "OC-0369",
"title": "Any self role/profile update wipes the custom-status text the user is typing in the user-bar status picker, and silently discards it",
"file": "Client/src/components/UserBar.ts",
"line": 285,
"severity": "low",
"why": "The `authStore` → `s.user` subscriber unconditionally pushes the server's stored custom status back into the picker's input with no \"is the user editing it\" guard. `StatusPicker.setCustomStatus` both overwrites `customInputEl.value` and resets `lastCommittedCustom`, so the in-progress text is not just visually replaced — the later blur/Enter commit compares against the freshly-reset watermark, short-circuits, and never sends what was typed.",
"repro": "Open the user bar's status dropdown and type \"on vacation\" into the custom-status input; do not press Enter yet. Have an admin change your role — dispatcher.ts:892 runs `updateUser({ role: payload.role })` on a self MEMBER_UPDATE, which replaces `authStore.user` with a new object (auth.store.ts:127-130). The selector `(s) => s.user` fires, `statusPicker.setCustomStatus(serverCustomStatus() ?? \"\")` runs, the input now reads the old server value and `lastCommittedCustom` equals it. Click away: `commit()` in StatusPicker.ts:178-183 computes `text === lastCommittedCustom` and returns early, so \"on vacation\" is never saved or sent. The identical clobber happens on a self USER_UPDATE (dispatcher.ts:947 `setAuth({...currentUser, ...})`).",
"evidence": " disposable.onStoreChange(\n authStore,\n (s) => s.user,\n () => {\n updateFromState();\n statusPicker?.setCustomStatus(serverCustomStatus() ?? \"\"); // no focus/dirty guard\n },\n );\n\n// StatusPicker.ts\n function setCustomStatus(text: string): void {\n lastCommittedCustom = text;\n if (customInputEl !== null) customInputEl.value = text;\n }\n const commit = (): void => {\n const text = input.value.trim().slice(0, MAX_CUSTOM_STATUS_LEN);\n if (text === lastCommittedCustom) return; // <-- typed text now silently dropped",
"status": "open",
"found": "2026-08-29",
"hunt": "general-2026-08-29",
"lens": "hotspot-client-src-components",
"suggestedFix": "Guard the push in the UserBar subscriber (one place, not per caller): skip the overwrite when the user is mid-edit, e.g. expose the input's focus state (or accept a `force` flag) and in UserBar.ts:283-286 call `statusPicker?.setCustomStatus(...)` only when the custom-status input is not `document.activeElement`. Equivalently, inside `StatusPicker.setCustomStatus` return early if `customInputEl !== null && document.activeElement === customInputEl`, leaving `lastCommittedCustom` untouched so the pending commit still fires on blur.",
"confidence": "medium",
"finder": "opus"
},
{
"id": "OC-0370",
"title": "Arrow-key navigation in the mention/emoji autocomplete never scrolls the active row into view, so rows past the 8th are highlighted off-screen",
"file": "Client/src/components/inline-autocomplete.ts",
"line": 167,
"severity": "low",
"why": "handleKeydown moves `activeIndex` and calls render(), which only repaints the `ma-item--active` class and re-aims `aria-activedescendant`. Nothing ever calls `scrollIntoView` on the active row, but the list is a fixed-height scroller (`.mention-autocomplete .ma-list { max-height: 240px; overflow-y: auto; }`, app.css:2838) while the widget renders up to 10 rows of ~29-30px each (~290-300px). aria-activedescendant does not scroll the container, so the highlighted row can sit entirely outside the visible box and Enter inserts something the user cannot see.",
"repro": "In a server with ≥10 members whose names share a prefix, type `@user` in the composer. filterMentionSuggestions returns MAX_MENTION_SUGGESTIONS = 10 rows; `.ma-list` clips at 240px so only ~8 are visible. Press ArrowDown nine times: activeIndex reaches 8 then 9, render() marks those rows `ma-item--active`, but the list scrollTop is still 0, so no row appears highlighted anywhere on screen. Pressing Enter inserts the 10th suggestion the user never saw. The same happens immediately on a single ArrowUp from index 0, which wraps to index 9 (line 172) — the popup looks like nothing is selected. Identical for the emoji popup (10 rows at 30px each with `.ea-preview` 18px + 12px padding).",
"evidence": " case \"ArrowDown\":\n e.preventDefault();\n activeIndex = (activeIndex + 1) % suggestions.length;\n render(); // render() has no scrollIntoView / scrollTop write\n return true;\n case \"ArrowUp\":\n activeIndex = (activeIndex - 1 + suggestions.length) % suggestions.length;\n/* app.css:2838 */ .mention-autocomplete .ma-list { max-height: 240px; overflow-y: auto; }",
"status": "open",
"found": "2026-08-29",
"hunt": "general-2026-08-29",
"lens": "explore-3",
"suggestedFix": "One change in the shared widget: at the end of `render()` in Client/src/components/inline-autocomplete.ts, after the aria-activedescendant block, add `if (suggestions.length > 0) (list.children[activeIndex] as HTMLElement | undefined)?.scrollIntoView({ block: \"nearest\" });`. That covers both the mention and emoji popups and both arrow directions.",
"confidence": "high",
"finder": "opus"
},
{
"id": "OC-0371",
"title": "`btn-modal-cancel` is defined in no stylesheet — every modal's Cancel button (and the NSFW gate's \"Go Back\") renders as bare unstyled text beside a styled Save pill",
"file": "Client/src/components/CreateChannelModal.ts",
"line": 134,
"severity": "low",
"why": "Four components give their secondary action the class `btn-modal-cancel`, but no CSS file in the repo defines `.btn-modal-cancel`. The styled sibling that clearly belongs to it, `.btn-cancel` (login.css:989 — padding, muted colour, hover underline), has zero users. With base.css:34's global `button { border:none; background:none; color:inherit; }` reset the Cancel control ends up with no padding, no background, no border and no hover state, flush against the `.btn-modal-save` accent pill in the same `.modal-footer` flex row.",
"repro": "Open the channel context menu → \"Create Channel\". The footer holds `<button class=\"btn-modal-cancel\">Cancel</button>` next to `<button class=\"btn-modal-save\">Create Channel</button>`. `grep -rn 'btn-modal-cancel' --include=*.css .` returns nothing, so only base.css's `button` reset applies: Cancel paints as inherit-coloured text with zero padding (a click target the height of one text line), while Save is a padded accent-filled button. Same on EditChannelModal.ts:332, DeleteChannelModal.ts:70 and NsfwGate.ts:74 (\"Go Back\" next to the \"Continue\" save button). The intended rule exists but is spelled `.btn-cancel`, which nothing uses.",
"evidence": "CreateChannelModal.ts:132-136\n const cancelBtn = createElement(\n \"button\",\n { class: \"btn-modal-cancel\", type: \"button\" },\n \"Cancel\",\n );\nlogin.css:989 .btn-cancel { padding: 8px 16px; ... } /* zero users */\nlogin.css:999 .btn-modal-save { padding: 8px 20px; background: var(--accent); ... }",
"status": "open",
"found": "2026-08-29",
"hunt": "general-2026-08-29",
"lens": "explore-3",
"suggestedFix": "Rename the orphaned selector: change `.btn-cancel` / `.btn-cancel:hover` at Client/src/styles/login.css:987 and :995 to `.btn-modal-cancel` / `.btn-modal-cancel:hover`. One CSS edit fixes all four call sites; do not touch the components.",
"confidence": "high",
"finder": "opus"
},
{
"id": "OC-0372",
"title": "Quick-switch server overlay has no max-height and no scroller, so with enough saved profiles rows and the \"Add new server\" button are clipped off-screen and unreachable",
"file": "Client/src/components/QuickSwitchOverlay.ts",
"line": 52,
"severity": "low",
"why": "The overlay's modal is `.quick-switch-modal`, which sets `overflow: hidden` with no `max-height`, and `.quick-switch-list` sets neither. Its parent `.quick-switch-backdrop` is `position: fixed; inset: 0; display: flex; align-items: center`, so once the row list makes the modal taller than the viewport the box overflows the fixed backdrop symmetrically above and below the fold. Nothing scrolls — a fixed-position backdrop creates no scrollable overflow and the modal clips its own children — so those rows can never be reached. Every other modal in the app gets this right (`.modal { max-height: 80vh; overflow-y: auto; }`, login.css:945).",
"repro": "Save 14 server profiles, then click the disconnect/switch button in the UserBar. createQuickSwitchOverlay renders one `.quick-switch-item` per profile plus the `add-new` row; each is 36px icon + 10px×2 padding + 1px×2 border = 58px, on top of a ~70px header and ~40px footer. On a 800px-tall window the modal is ~940px and, being flex-centred in a viewport-sized fixed backdrop, extends ~70px above y=0 and ~70px below the bottom. The first profile row and the \"Add new server\" row are outside the viewport, the modal's `overflow:hidden` shows no scrollbar, and the page does not scroll — so the user cannot add a server or switch to the clipped profiles without deleting profiles first.",
"evidence": "QuickSwitchOverlay.ts:52 const modal = createElement(\"div\", { class: \"quick-switch-modal\" });\napp.css:5253 .quick-switch-backdrop { position: fixed; inset: 0; display: flex; align-items: center; justify-content: center; }\napp.css:5262 .quick-switch-modal { width: 380px; max-width: 90vw; overflow: hidden; } /* no max-height */\napp.css:5285 .quick-switch-list { padding: 0 12px 8px; } /* no overflow-y */\nlogin.css:945 .modal { width: 440px; max-height: 80vh; overflow-y: auto; } /* every other modal */",
"status": "open",
"found": "2026-08-29",
"hunt": "general-2026-08-29",
"lens": "explore-3",
"suggestedFix": "In Client/src/styles/app.css, give `.quick-switch-modal` (line 5262) `max-height: 85vh; display: flex; flex-direction: column;` and give `.quick-switch-list` (line 5285) `overflow-y: auto;` (plus `min-height: 0`), so the row list scrolls while the header and footer stay pinned. No TS change needed.",
"confidence": "medium",
"finder": "opus"
},
{
"id": "OC-0373",
"title": "Audit action filter cannot be cleared once its option leaves the page: dropdown reads \"All Actions\" while the filter is still applied",
"file": "Server/admin/static/index.html",
"line": 1417,
"severity": "low",
"why": "The <select> options are rebuilt from the actions present on the *currently fetched page*, but state.auditActionFilter is global and survives paging. When the filtered action does not occur on the new page, no option carries `selected`, so the browser falls back to the first option (\"All Actions\") while the filter is still in force — and because the element's DOM value is already \"all\", picking \"All Actions\" fires no change event, so the filter can no longer be cleared from the control.",
"repro": "1. Audit Log, page 1 (50 rows) contains at least one `channel_delete`. Select \"channel_delete\" in the dropdown.\n2. Click \">\" to page 2, whose 50 rows contain no `channel_delete`.\n3. renderAudit emits only the \"All Actions\" option (no `selected` anywhere), so the dropdown displays \"All Actions\" — but state.auditActionFilter is still 'channel_delete', so `filtered` is empty and the table reads \"No matching entries\" beside 50 fetched rows.\n4. Open the dropdown and choose \"All Actions\": the element's value is already \"all\", so no `change` event fires and state.auditActionFilter is never reset. The page stays blank until the admin selects some other action first and then re-selects \"All Actions\".",
"evidence": "Server/admin/static/index.html:1400\n const actionTypes=[...new Set(state.auditCache.map(e=>e.action).filter(Boolean))].sort(); // current page only\nServer/admin/static/index.html:1415-1418\n html+='<select class=\"filter-select\" onchange=\"state.auditActionFilter=this.value;refilterAudit()\">';\n html+='<option value=\"all\" '+(state.auditActionFilter==='all'?'selected':'')+'>All Actions</option>';\n actionTypes.forEach(t=>{html+='<option value=\"'+esc(t)+'\" '+(state.auditActionFilter===t?'selected':'')+'>'+esc(t)+'</option>'});\nServer/admin/static/index.html:1403-1405 — the row filter still reads the stale state:\n if(state.auditActionFilter!=='all'&&e.action!==state.auditActionFilter)return false;\nstate.auditActionFilter is declared once at line 315 and is never reset by paging (`state.auditPage++;renderContent()` at line 1432).",
"status": "open",
"found": "2026-08-29",
"hunt": "general-2026-08-29",
"lens": "hotspot-server-admin-static",
"suggestedFix": "Make the option set always contain the active filter so the control and the state cannot diverge, in the one place that builds it (index.html:1400): `const actionTypes=[...new Set(state.auditCache.map(e=>e.action).filter(Boolean).concat(state.auditActionFilter!=='all'?[state.auditActionFilter]:[]))].sort();`. The stale filter then still renders as a `selected` option, the dropdown tells the truth, and choosing \"All Actions\" is a real value change that fires `change`.",
"confidence": "high",
"finder": "opus"
},
{
"id": "OC-0374",
"title": "ReorderRoles compacts positions to a gapless N..1 block, permanently breaking role creation for every non-owner role manager",
"file": "Server/service/role.go",
"line": 459,
"severity": "low",
"why": "ReorderRoles normalizes the manageable roles to positions N, N-1, … 1 with no gaps and never 0, while CreateRole's default placement (role.go:249-254) only accepts a strictly-positive unoccupied slot below the actor. After a single reorder there is no such slot for any actor below the owner, so role creation fails permanently — and the error it returns tells the admin to \"reorder existing roles first\", which re-compacts to the same dense block and cannot help.",
"repro": "Default install: Owner 100, Admin 80, Moderator 60, Member 40. (1) As the Owner, click one reorder arrow on the admin Roles page (Server/admin/static/index.html:1291 moveRole → PATCH /roles/reorder). ReorderRoles writes Admin=3, Moderator=2, Member=1; Owner stays at 100. (2) Sign in as the Admin (now position 3, holds MANAGE_ROLES) and POST /roles with no `position` field. taken={100,3,2,1}; the loop at role.go:249 steps 2 → 1 → 0 and the call returns ErrBadRequest \"no free position below your rank — reorder existing roles first\". (3) Reorder as the Admin: its one manageable role goes back to position 1, so step 2 fails identically — forever. The Moderator at position 2 is in the same state. Through the panel it is worse: the Create Role modal prefills Math.max(0, myPosition()-1) (index.html:1310), which post-compaction is always an occupied slot, so the explicit-position path returns \"position N is already used by another role\" and the omitted-position path returns the message above; UpdateRole's position change (role.go:321-331) is dead for the same reason.",
"evidence": "role.go:457-460\n\tpositions := make(map[int64]int, len(orderedIDs))\n\tfor i, id := range orderedIDs {\n\t\tpositions[id] = len(orderedIDs) - i\n\t}\n\nrole.go:248-254\n\t} else {\n\t\tfor position > 0 && taken[position] {\n\t\t\tposition--\n\t\t}\n\t\tif position <= 0 {\n\t\t\treturn nil, fmt.Errorf(\"%w: no free position below your rank — reorder existing roles first\", ErrBadRequest)\n\t\t}\n\nServer/service/role_test.go:505 TestReorderRoles_NormalizesPositions already locks the compaction (Admin→4, Helper→3, Moderator→2, Member→1); no test covers CreateRole after a reorder.",
"status": "open",
"found": "2026-08-29",
"hunt": "general-2026-08-29",
"lens": "explore-3",
"suggestedFix": "Preserve gaps in the single shared normalizer instead of guarding each caller: in ReorderRoles (Server/service/role.go:457-460) space the slots under the actor, e.g. stride := actor.Position / (len(orderedIDs) + 1) (>=1) and positions[id] = (len(orderedIDs) - i) * stride. That keeps every position unique, strictly below actor.Position, and in the same order, while leaving free slots for CreateRole's default placement. TestReorderRoles_NormalizesPositions must be updated to assert ordering/uniqueness/below-actor rather than the literal 4,3,2,1 values.",
"confidence": "high",
"finder": "opus"
},
{
"id": "OC-0375",
"title": "The server's own voice_state echo strips \"(You)\" off your local self-view tile in the video grid",
"file": "Client/src/pages/MainPage.ts",
"line": 815,
"severity": "low",
"why": "The mid-call relabel loop added for OC-0227 iterates the whole voice roster, which includes the current user, and calls `videoGrid.setLabel(uid, remoteTileLabel(uid, false))`. The local self-view tile is registered under `tileId === currentUserId` (VideoModeController.ts:184) with the label `` `${myName} (You)` ``, and `remoteTileLabel` never appends \"(You)\". So the loop overwrites the self tile's label with the bare display name the moment the server echoes back `voice_state` with `camera: true`. Two writers of one tile label disagree, and the last writer is the wrong one — your own tile becomes indistinguishable from a remote participant's.",
"repro": "1. Join a voice channel and turn your camera on. `setLocalCamera(true)` (screenShare.ts:228) bumps the voice-store signature; the subscriber runs with `voiceUsers[channel][self].camera` still false (no server echo yet), so the relabel loop skips self and `checkVideoMode()` adds the self tile labelled \"Alice (You)\". 2. The client sends `voice_camera`; the server broadcasts `voice_state` for you with `camera: true`. 3. `updateVoiceState` writes `camera: true` onto the self entry in `voiceUsers`, the subscriber re-runs, and the loop now takes the `if (u.camera)` branch for `uid === currentUserId`: `prevTileLabels.get(self)` is undefined ≠ \"Alice\", so it calls `videoGrid.setLabel(self, \"Alice\")`. 4. Your own self-view tile now reads plain \"Alice\" for the rest of the call, with nothing marking it as yours. (Same path turns the local screenshare tile's \"Your Screen\" fallback into \"User <id> (Screen)\" when no member record supplies a display name.)",
"evidence": "// MainPage.ts:808-821 — no `uid === currentUserId` skip\nfor (const [uid, u] of users) {\n ...\n if (u.camera) {\n const label = remoteTileLabel(uid, false); // returns `name`, never `name (You)`\n if (prevTileLabels.get(uid) !== label) {\n prevTileLabels.set(uid, label);\n videoGrid?.setLabel(uid, label); // uid === currentUserId hits the SELF tile\n }\n }\n\n// VideoModeController.ts:180-190 — the self tile is keyed by currentUserId\nif (voice.localCamera) {\n if (!localTileAdded) {\n const localStream = getLocalCameraStream();\n if (localStream !== null) {\n videoGrid.addStream(currentUserId, myName ? `${myName} (You)` : \"You\", localStream, { isSelf: true, ... });\n localTileAdded = true;\n\n// voice.store.ts:231-241 — updateVoiceState puts SELF into voiceUsers with camera from the payload\nnextUsers.set(payload.user_id, { userId: payload.user_id, username: payload.username, ..., camera: payload.camera, ... });",
"status": "open",
"found": "2026-08-29",
"hunt": "general-2026-08-29",
"lens": "explore-3",
"suggestedFix": "In the loop at Client/src/pages/MainPage.ts:808-829, leave the signature accumulation alone but skip the local user for relabeling: hoist `const selfId = getCurrentUserId();` and wrap both setLabel blocks in `if (uid !== selfId) { ... }` — VideoModeController is the sole writer of the self tiles' \"(You)\" / \"Your Screen\" labels.",
"confidence": "high",
"finder": "opus"
},
{
"id": "OC-0376",
"title": "Register commits the account and burns the invite, then answers 500 when the session insert fails",
"file": "Server/api/auth_handler.go",
"line": 201,
"severity": "low",
"why": "handleRegister hashes the password first so a hashing failure cannot burn an invite (the comment at line 153 states that intent), and CreateUserWithInvite consumes the invite and creates the user in one transaction. The session insert that follows is outside that transaction: when CreateSession fails the handler returns 500 \"failed to create session\", but the user row and the invite use are already committed. The caller sees a failed registration; retrying gets 400 \"invalid invite or credentials\" (username taken, invite exhausted) while a login with the same password succeeds. Same shape on the verify-totp path (OC-0378).",
"repro": "cd Server && go test -count=1 -run 'TestAuthCharacterization_RegisterPolicyAndFailurePaths/session_insert_fails' ./api/ — the row installs a BEFORE INSERT ON sessions trigger, registers with a valid invite, and pins today's outcome: 500, user row present, invite use_count 1.",
"evidence": "Server/api/auth_handler.go:166 uid, err := database.CreateUserWithInvite(...) // commits user + invite use\nServer/api/auth_handler.go:201 if _, err := database.CreateSession(...); err != nil { // 500 after the commit\nServer/api/auth_handler.go:153 // Hash password before consuming the invite so that a hashing failure\n // does not burn a valid invite code.",
"suggestedFix": "Either answer 201 without a token when the session insert fails after the account commit (the account exists; the client logs in), or move the session insert into the CreateUserWithInvite transaction so registration is atomic. Belongs to the AuthService in B3-2/B3-9, not to the handler.",
"status": "fixed",
"found": "2026-08-29",
"hunt": "b3-1-auth-characterization-2026-08-29",
"lens": "characterization",
"confidence": "high",
"finder": "claude",
"fixed": "2026-08-30",
"fix": {
"commit": "85d86dc7",
"test": "Server/api/auth_characterization_test.go, Server/db/coverage_boost_test.go",
"revertProof": "pass"
}
},
{
"id": "OC-0377",
"title": "verify-totp maps a database error while loading the challenged user to 401, indistinguishable from an expired challenge",
"file": "Server/api/totp_handler.go",
"line": 141,
"severity": "low",
"why": "totpChallengeSecret folds `err != nil` from GetUserByID into the same 401 \"invalid or expired two-factor challenge\" that a missing user or missing secret gets. A transient database fault during the second factor therefore reads as a bad challenge: the client drops the partial token and asks the user to log in again, and the attempt has already been recorded against the per-user totp_fail cap by the limiter.Allow call above it. Every sibling path in the slice maps a non-sentinel database error to 5xx (login 500, AuthMiddleware 503) precisely so an outage is not mistaken for a credential failure.",
"repro": "cd Server && go test -count=1 -run 'TestAuthCharacterization_VerifyTOTPFailurePaths/user_lookup_fails' ./api/ — the row renames the users table after the challenge is issued and pins today's 401.",
"evidence": "Server/api/totp_handler.go:140 user, err := database.GetUserByID(r.Context(), challengeUserID)\nServer/api/totp_handler.go:141 if err != nil || user == nil || user.TOTPSecret == nil {\nServer/api/totp_handler.go:142 writeJSON(w, http.StatusUnauthorized, errorResponse{ ... \"invalid or expired two-factor challenge\" })\nServer/api/auth_handler.go:488-497 the login sibling: a non-nil error is a genuine DB failure -> 500 \"login temporarily unavailable\"",
"suggestedFix": "Split the condition: `err != nil` -> 500 INTERNAL_ERROR (\"two-factor verification temporarily unavailable\") without RegisterFailure and with the limiter reservation undone or not made; keep 401 for `user == nil || user.TOTPSecret == nil`. Fix in B3-9 after B3-2 lands, and flip the characterization row with it.",
"status": "fixed",
"found": "2026-08-29",
"hunt": "b3-1-auth-characterization-2026-08-29",
"lens": "characterization",
"confidence": "high",
"finder": "claude",
"fixed": "2026-08-30",
"fix": {
"commit": "f7015809",
"test": "Server/api/auth_characterization_test.go",
"revertProof": "pass"
}
},
{
"id": "OC-0378",
"title": "verify-totp consumes the partial challenge before the session insert, so a store failure forces the user back to the password step",
"file": "Server/api/totp_handler.go",
"line": 107,
"severity": "low",
"why": "handleVerifyTOTP calls partialStore.Consume before issueSession. When CreateSession fails the handler answers 500 \"failed to create session\", but the challenge is already gone (and the code is marked used by VerifyTOTPCodeOnce), so the only way forward is a fresh POST /login with the password. A verified second factor is discarded because of a persistence hiccup that has nothing to do with the credential. Same shape as OC-0376 on the register path.",
"repro": "cd Server && go test -count=1 -run 'TestAuthCharacterization_VerifyTOTPFailurePaths/session_insert_fails' ./api/ — the row installs a BEFORE INSERT ON sessions trigger, verifies a valid code, pins the 500, drops the trigger and pins that the same partial token is now refused with 401.",
"evidence": "Server/api/totp_handler.go:107 if _, ok := partialStore.Consume(partialToken); !ok { // challenge gone here\nServer/api/totp_handler.go:115 token, err := issueSession(r.Context(), database, user.ID, challenge.Device, challenge.IP) // fails after it",
"suggestedFix": "Keep the claim atomic and first: Consume the challenge before issuing the session (as today), then on CreateSession failure re-issue or restore the challenge for the same user/device/IP so the verified second factor is not discarded. The restore must also keep the accepted verification usable: VerifyTOTPCodeOnce has already recorded (user, code) in UsedTOTPCodeStore for 90 s, so an immediate retry with the authenticator's still-current code would be refused as a replay - either carry the verified state on the restored challenge (retry issues the session without a new code) or roll back that MarkUsed claim together with the challenge. Do NOT issue the session before Consume: two concurrent requests holding the same partial token can pass Lookup with different valid codes from the +/-1 step window (the used-code store keys on (user, code), not the token), both would create sessions, and the losing Consume would leave an unreturned bearer session in the database; if the order must change, the loser has to revoke the session it created. Belongs to the AuthService in B3-2/B3-9.",
"status": "fixed",
"found": "2026-08-29",
"hunt": "b3-1-auth-characterization-2026-08-29",
"lens": "characterization",
"confidence": "high",
"finder": "claude",
"fixed": "2026-08-30",
"fix": {
"commit": "be37d7ee",
"test": "Server/api/auth_characterization_test.go, Server/auth/totp_test.go",
"revertProof": "pass"
}
}
]
}
+40
View File
@@ -39,6 +39,46 @@ ownership, dependency automation — gets **at most a short block at the end**,
and only when it changes something a contributor or fork holder must do
(a moved directory, a renamed module, a new required command).
## Unreleased
User-visible: one change to how updates roll out, and two new documents.
Not user-visible: the protocol now carries a version number.
### Login & connection
- The client and server now agree on a protocol version ("epoch") when
connecting. This release is epoch 1; clients from v1.2.0-alpha.4 and earlier
still connect.
- A client too old for its server is told "update the client" on the connect
screen, with the usual Update Now button — instead of failing in confusing
ways. The saved login is kept, so the updated client signs back in by
itself.
- **Upgrade the server before the clients.** The server only offers client
releases that speak its own protocol epoch, so a protocol-changing release
reaches clients once the server runs it. Releases that do not change the
protocol are offered as before.
### Admin panel
- Creating or revoking an invite, and installing or uninstalling a plugin, now
show up in the audit log. Invite entries name the invite by id, never by
code.
### Documentation
- `docs/trust-model.md` answers "who can read my messages?": the server
operator can read text and files; voice, video and screen share are
end-to-end encrypted; what beta does not claim. Every claim cites the code
or test behind it.
- `docs/architecture/plugins.md`: plugins are experimental, off by default,
compiled out of release binaries, and carry no API promise.
### Repository
- `protocol/schema.json` declares `protocol_epoch`; `npm run generate` emits it
as `ws.ProtocolEpoch` and `PROTOCOL_EPOCH`. Rules for bumping it:
`docs/protocol.md`, Compatibility.
## v1.2.0-alpha.4
**62 bug fixes**, all user-visible, plus repository work that changes nothing an
+6 -5
View File
@@ -11,11 +11,12 @@ and schema are documented in `docs/protocol.md`, `docs/schema.md`, and
CI fails on drift, and the next generator run silently discards your edit.
| Generated | Source of truth | Workflow |
| ---------------------------------------------------------------------- | ----------------------------------------------- | -------------------------------------------------------------- |
| `Server/db/dbgen/` | `Server/db/queries/*.sql`, `Server/migrations/` | `db-change` skill |
| `Server/ws/message_types.go` **and** `Client/src/lib/protocolTypes.ts` | `protocol/schema.json` | `protocol-change` skill |
| `Client/src/generated/` | `tauri-typegen` | CI patches known typegen bugs — see `.github/workflows/ci.yml` |
| Generated | Source of truth | Workflow |
| ------------------------------------------------------------------------------------- | ----------------------------------------------------------------------- | -------------------------------------------------------------- |
| `Server/db/dbgen/` | `Server/db/queries/*.sql`, `Server/migrations/` | `db-change` skill |
| `Server/ws/message_types.go` **and** `Client/src/lib/protocolTypes.ts` | `protocol/schema.json` | `protocol-change` skill |
| `gendocs:*` blocks in `docs/api.md`, `docs/schema.md`, `docs/server-configuration.md` | `Server/api/router.go`, `Server/migrations/`, `Server/config/config.go` | `cd Server && go run -tags otel,wazero ./cmd/gendocs` |
| `Client/src/generated/` | `tauri-typegen` | CI patches known typegen bugs — see `.github/workflows/ci.yml` |
## Bug-hunt ledger
+5
View File
@@ -15,6 +15,11 @@ export default tseslint.config(
rules: {
// --- Key rules from T-191 ---
"@typescript-eslint/no-floating-promises": "error",
// A switch over a union that misses a member is a silent drop, not a type error.
"@typescript-eslint/switch-exhaustiveness-check": [
"error",
{ considerDefaultExhaustiveForUnions: true },
],
"@typescript-eslint/no-unused-vars": [
"error",
{
+37 -37
View File
@@ -5,7 +5,7 @@
import type { WsClient } from "./ws";
import { toConnectionStatus, setActiveChannelProvider } from "./ws";
import { authStore, setAuth, clearAuth, updateUser } from "@stores/auth.store";
import { setTransientError, setConnectionStatus } from "@stores/ui.store";
import { setTransientError, setConnectionStatus, setUpdateRequiredHost } from "@stores/ui.store";
import {
setChannels,
setRoles,
@@ -13,8 +13,7 @@ import {
addChannel,
updateChannel,
removeChannel,
incrementUnread,
incrementMention,
noteChannelMessage,
} from "@stores/channels.store";
import { channelsStore } from "@stores/channels.store";
import {
@@ -49,7 +48,6 @@ import {
updateVoiceUserProfile,
removeVoiceUser,
setVoiceConfig,
setSpeakers,
joinVoiceChannel,
leaveVoiceChannel,
} from "@stores/voice.store";
@@ -76,13 +74,14 @@ import type { DmChannelPayload } from "./types";
import { isTextLikeChannel } from "./types";
import type { ApiClient } from "./api";
import { invalidateReactionUsers } from "@components/message-list/reaction-tooltip";
import { parseTimestamp } from "@components/message-list/formatting";
import { notifyIncomingMessage } from "./notifications";
import { mentionsCurrentUser } from "./mentions";
import { ensureIdentityKeyPublished } from "@lib/identity";
import { markChannelRead } from "./read-state";
import { createLogger } from "./logger";
import { showToast } from "./toast";
import { ServerMessageType as S } from "./protocolTypes";
import { ServerMessageType as S, PROTOCOL_EPOCH } from "./protocolTypes";
// SidebarDmHelpers is page-level, but addDmToChannelsStore is the only
// place the DM->channelsStore mirror row is synthesized (selectDmConversation
// on open); the dm_channel_close fallback below needs the same synthesis for
@@ -293,7 +292,15 @@ export function wireDispatcher(
ws.on(S.AUTH_ERROR, (payload) => {
log.error("Auth failed", { message: payload.message });
setTransientError(payload.message);
clearAuth();
const epochRefusal = payload.code === "protocol_epoch_unsupported";
// The server speaks a newer protocol than this build: hand the host to
// the connect page so it can offer the client update right there.
if (epochRefusal && (payload.server_epoch ?? 0) > PROTOCOL_EPOCH) {
setUpdateRequiredHost(api?.getConfig?.().host ?? null);
}
// A protocol refusal is not a bad token: say so, so main.ts keeps the
// stored credential for the relaunch after the update.
clearAuth(epochRefusal ? "protocol_epoch" : "user");
}),
);
@@ -502,8 +509,8 @@ export function wireDispatcher(
// offline keeps a phantom row here (closeDmLocally fixes this exact
// shape for the live dm_channel_close path; this is its ready-time
// equivalent), and a DM read elsewhere keeps a stale unread/mention
// count (incrementUnread/incrementMention bump the mirror in parallel
// with dmStore once it exists, but only dmStore is restated above).
// count (noteChannelMessage bumps the mirror in parallel with dmStore
// once it exists, but only dmStore is restated above).
// Reconcile every dm-typed row against the just-restated payload.
channelsStore.setState((prev) => {
const dmById = new Map(dmPayloads.map((d) => [d.channel_id, d]));
@@ -676,7 +683,7 @@ export function wireDispatcher(
// ones — the burst is exactly the messages missed while away (a
// full-ready resume sends no burst at all; ready's unread_count values
// are authoritative there). DM channel IDs are not in channelsStore
// (they use dmStore), so incrementUnread is a no-op for DMs, but the
// (they use dmStore), so noteChannelMessage is a no-op for DMs, but the
// own-message guard is applied here for defence-in-depth.
//
// isReplayFrame is computed here (rather than only below, where the
@@ -685,7 +692,7 @@ export function wireDispatcher(
const isReplayFrame =
lastReconnectHandshakeAt !== null &&
Date.now() - lastReconnectHandshakeAt < REPLAY_GATE_WINDOW_MS &&
Date.parse(payload.timestamp) < lastReconnectHandshakeAt - serverClockSkewMs;
parseTimestamp(payload.timestamp).getTime() < lastReconnectHandshakeAt - serverClockSkewMs;
// highlightsCurrentUser (mentions.ts) treats @everyone and @here as one
// bit, because the wire carries only one: mentions_everyone. But the
// server's applyMentionCounts (mentions.go) narrows an @here fan-out to
@@ -703,15 +710,15 @@ export function wireDispatcher(
const isDetached = isWindowDetached(payload.channel_id);
if ((payload.channel_id !== activeId || isDetached) && !isOwnMessage) {
// incrementUnread/incrementMention skip the active channel by
// default — evenIfActive (isDetached here) is a no-op for a
// genuinely non-active channel, since their internal guard only
// fires when channelId IS the active one.
incrementUnread(payload.channel_id, isDetached);
// A mention is an unread too — the mention badge just outranks it.
if (isMention) {
incrementMention(payload.channel_id, isDetached);
}
// noteChannelMessage skips the active channel by default —
// evenIfActive (isDetached here) is a no-op for a genuinely
// non-active channel, since its internal guard only fires when
// channelId IS the active one. It also guards both counters behind
// payload.id vs. the channel's lastMessageId watermark (OC-0328), so
// a message already reflected in a `ready` snapshot (delivered
// between the server's registerNow and buildReady, then redelivered
// as a queued chat_message) does not double-count.
noteChannelMessage(payload.channel_id, payload.id, isMention, isDetached);
}
// Update DM store last message if this message belongs to a DM channel.
@@ -730,7 +737,7 @@ export function wireDispatcher(
);
} else {
// The DM badge reads dmStore's mentionCount (mute-immune, rendered
// by DmSidebar) — incrementMention above no-ops for DM ids, which
// by DmSidebar) — noteChannelMessage above no-ops for DM ids, which
// are absent from channelsStore. isMention is passed through so the
// mention bump sits behind updateDmLastMessage's own message-id
// guard (OC-0242) — a separate unconditional increment here would
@@ -762,7 +769,7 @@ export function wireDispatcher(
notifyIncomingMessage(payload);
// Refresh the skew estimate from this accepted-as-live frame so it
// stays current for the next reconnect.
serverClockSkewMs = Date.now() - Date.parse(payload.timestamp);
serverClockSkewMs = Date.now() - parseTimestamp(payload.timestamp).getTime();
}
}),
);
@@ -871,13 +878,6 @@ export function wireDispatcher(
}),
);
unsubs.push(
ws.on(S.MEMBER_LEAVE, (payload) => {
log.info("Member left", { userId: payload.user_id });
removeMember(payload.user_id);
}),
);
unsubs.push(
ws.on(S.MEMBER_BAN, (payload) => {
log.info("Member banned", { userId: payload.user_id });
@@ -1068,13 +1068,19 @@ export function wireDispatcher(
// late-arriving voice_leave for a channel we already left (and rejoined
// elsewhere) must not kill a newer join. Read the store before
// leaveVoiceChannel() below clears currentChannelId.
const shouldTeardownSession =
isSelf && voiceStore.getState().currentChannelId === payload.channel_id;
const sameChannel = voiceStore.getState().currentChannelId === payload.channel_id;
const shouldTeardownSession = isSelf && sameChannel;
// Notify E2EE state machine so key holder can rotate the room key, and
// (when applicable) tear down the media session — both through one lazy
// import so the two effects cannot land in different ticks.
// OC-0311: voice_leave is broadcast to the whole channelReadAudience,
// i.e. everyone with READ_MESSAGES on THAT channel — not just its
// voice participants. Scope the E2EE notification to this client's own
// voice channel so a peer leaving a channel we merely read (and never
// shared a call with) cannot delete their key, clear their
// verification, or trigger a room-key rotation in our live session.
void livekitSession().then(({ handleParticipantLeft, leaveVoice }) => {
void handleParticipantLeft(payload.user_id);
if (sameChannel) void handleParticipantLeft(payload.user_id);
if (shouldTeardownSession) void leaveVoice(false);
});
// Clear local voice state only for the same channel-match case as the
@@ -1098,12 +1104,6 @@ export function wireDispatcher(
}),
);
unsubs.push(
ws.on(S.VOICE_SPEAKERS, (payload) => {
setSpeakers(payload);
}),
);
unsubs.push(
ws.on(S.VOICE_TOKEN, (payload) => {
void livekitSession().then(({ handleVoiceToken }) =>
+4 -2
View File
@@ -29,6 +29,8 @@ export function isValidHost(host: string): boolean {
// one colon means the whole string is the address — a single colon is
// reserved for the host:port separator below.
if ((host.match(/:/g) ?? []).length > 1 && /^[0-9A-Fa-f:.]+$/.test(host)) return true;
// DNS name or IPv4 literal, optionally with a port.
return /^[\w.-]+(:\d+)?$/.test(host);
// DNS name or IPv4 literal, optionally with a port. Explicit ASCII class
// (not `\w`, which wrongly includes '_') to match the Rust proxies'
// charset exactly.
return /^[A-Za-z0-9.-]+(:\d+)?$/.test(host);
}
+4 -2
View File
@@ -7,6 +7,10 @@
// Usage: import { MessageType } from "@lib/protocolTypes";
// ws.send({ type: MessageType.CHAT_SEND, payload: { ... } });
// The wire epoch this client speaks; sent in the auth frame and checked by
// the server. See docs/protocol.md, Compatibility.
export const PROTOCOL_EPOCH = 1;
// ---------------------------------------------------------------------------
// Server → Client message types
// ---------------------------------------------------------------------------
@@ -29,12 +33,10 @@ export const ServerMessageType = {
VOICE_STATE: "voice_state",
VOICE_CONFIG: "voice_config",
VOICE_TOKEN: "voice_token",
VOICE_SPEAKERS: "voice_speakers",
VOICE_LEAVE: "voice_leave", // broadcast (same string as client msg)
VOICE_MOVED: "voice_moved",
VOICE_DISCONNECTED: "voice_disconnected",
MEMBER_JOIN: "member_join",
MEMBER_LEAVE: "member_leave",
MEMBER_UPDATE: "member_update",
USER_UPDATE: "user_update",
MEMBER_BAN: "member_ban",
+14 -7
View File
@@ -282,6 +282,15 @@ export interface AuthOkPayload {
export interface AuthErrorPayload {
readonly message: string;
/**
* Set only when the server refused this client's protocol epoch
* (`"protocol_epoch_unsupported"`); the epochs say which side is older.
* Absent on every other refusal.
*/
readonly code?: "protocol_epoch_unsupported";
readonly client_epoch?: number;
readonly server_epoch?: number;
readonly min_epoch?: number;
}
export interface ReadyPayload {
@@ -464,7 +473,9 @@ export interface VoiceConfigPayload {
readonly max_users: number;
}
/** CRITICAL: uses threshold_mode, NOT mode. */
/** Argument shape for `voice.store.setSpeakers` — fed by LiveKit's
* ActiveSpeakersChanged, not by a wire message.
* CRITICAL: uses threshold_mode, NOT mode. */
export interface VoiceSpeakersPayload {
readonly channel_id: number;
readonly speakers: readonly number[];
@@ -508,10 +519,6 @@ export interface MemberJoinPayload {
readonly status?: UserStatus;
}
export interface MemberLeavePayload {
readonly user_id: number;
}
/**
* Full role list after any role mutation. The server sends the whole list
* rather than a delta, so the store is replaced wholesale — a dropped
@@ -633,6 +640,8 @@ export interface ErrorPayload {
export interface AuthPayload {
readonly token: string;
readonly last_seq?: number;
/** The wire epoch this client speaks — always `PROTOCOL_EPOCH`. */
readonly epoch: number;
/**
* The channel this client had open when it disconnected, sent only on a
* resume (`last_seq > 0`).
@@ -765,14 +774,12 @@ export type ServerMessage =
| (WsEnvelope<VoiceStatePayload> & { readonly type: "voice_state" })
| (WsEnvelope<VoiceLeavePayload> & { readonly type: "voice_leave" })
| (WsEnvelope<VoiceConfigPayload> & { readonly type: "voice_config" })
| (WsEnvelope<VoiceSpeakersPayload> & { readonly type: "voice_speakers" })
| (WsEnvelope<VoiceTokenPayload> & { readonly type: "voice_token" })
| (WsEnvelope<VoiceMovedPayload> & { readonly type: "voice_moved" })
| (WsEnvelope<VoiceDisconnectedPayload> & { readonly type: "voice_disconnected" })
| (WsEnvelope<VoiceE2EEAnnouncePayload> & { readonly type: "voice_e2ee_announce" })
| (WsEnvelope<VoiceE2EEOfferPayload> & { readonly type: "voice_e2ee_offer" })
| (WsEnvelope<MemberJoinPayload> & { readonly type: "member_join" })
| (WsEnvelope<MemberLeavePayload> & { readonly type: "member_leave" })
| (WsEnvelope<MemberUpdatePayload> & { readonly type: "member_update" })
| (WsEnvelope<UserUpdatePayload> & { readonly type: "user_update" })
| (WsEnvelope<MemberBanPayload> & { readonly type: "member_ban" })
+2
View File
@@ -4,6 +4,7 @@
import type { ServerMessage, ClientMessage } from "./types";
import { createLogger } from "./logger";
import { PROTOCOL_EPOCH } from "./protocolTypes";
const log = createLogger("ws");
@@ -448,6 +449,7 @@ export function createWsClient() {
payload: {
token: config.token,
last_seq: lastSeq,
epoch: PROTOCOL_EPOCH,
...(activeChannelId !== null ? { active_channel_id: activeChannelId } : {}),
},
});
+37 -7
View File
@@ -12,7 +12,7 @@ import { createApiClient } from "@lib/api";
import { createWsClient, normalizeHostForCertCompare } from "@lib/ws";
import { wireDispatcher, wireConnectionStatus } from "@lib/dispatcher";
import { authStore, clearAuth } from "@stores/auth.store";
import { setTransientError } from "@stores/ui.store";
import { setTransientError, uiStore, setUpdateRequiredHost } from "@stores/ui.store";
import { voiceStore, leaveVoiceChannel } from "@stores/voice.store";
import { createConnectPage } from "@pages/ConnectPage";
import { applyStoredAppearance } from "@lib/appearance";
@@ -20,6 +20,8 @@ import { restoreTheme } from "@lib/themes";
import { initPtt } from "@lib/ptt";
import { createNavigationGuard } from "@lib/navigation-guard";
import { createConnectedOverlay } from "@components/ConnectedOverlay";
import { createUpdateNotifier } from "@components/UpdateNotifier";
import type { MountableComponent } from "@lib/safe-render";
import type { ConnectedOverlayControl } from "@components/ConnectedOverlay";
import { createLogger, applyStoredLogLevel } from "@lib/logger";
import { initLogPersistence, flushLogs } from "@lib/logPersistence";
@@ -626,6 +628,27 @@ async function renderPage(pageId: "connect" | "main"): Promise<void> {
safeMount(connectPage, appEl!);
// A server refused this client's protocol epoch as too old: offer the
// update on the connect page itself. The main page's notifier never
// mounts on a refusal, so without this the user would have to fetch the
// installer by hand. Subscribed, not read once: on a first login or a
// startup auto-login this page is already mounted when the refusal
// arrives and nothing re-renders it (no overlay exists before auth_ok, so
// the isAuthenticated subscriber below does not navigate).
let updateNotifier: MountableComponent | null = null;
const offerUpdate = (host: string | null): void => {
if (!host) return;
setUpdateRequiredHost(null);
// A later refusal (another server tried from this same page) replaces
// the banner rather than being ignored.
updateNotifier?.destroy?.();
const notifier = createUpdateNotifier({ serverUrl: `https://${host}` });
notifier.mount(appEl!);
updateNotifier = notifier;
};
const unsubUpdateRequired = uiStore.subscribeSelector((s) => s.updateRequiredHost, offerUpdate);
offerUpdate(uiStore.getState().updateRequiredHost);
// Periodic health check — re-run every 15s so offline servers update when they come back
const healthCheckInterval = setInterval(() => {
runHealthChecks(connectPage, getProfileList());
@@ -635,6 +658,8 @@ async function renderPage(pageId: "connect" | "main"): Promise<void> {
currentPage = {
destroy() {
clearInterval(healthCheckInterval);
unsubUpdateRequired();
updateNotifier?.destroy?.();
connectPage.destroy?.();
},
};
@@ -805,12 +830,17 @@ authStore.subscribeSelector(
// kicked us by shutting down: the token is still valid, and deleting
// the credential would break auto-login every time the server restarts.
const host = api.getConfig().host;
if (host && authStore.getState().logoutReason !== "server_shutdown") {
void deleteCredential(host);
// Same condition on purpose: whenever the credential is being removed,
// the connect page must not turn around and auto-login with it. A
// server_shutdown keeps the credential precisely so auto-login still
// works on restart, so it deliberately does not set this.
const reason = authStore.getState().logoutReason;
if (host && reason !== "server_shutdown") {
// A protocol-epoch refusal keeps the credential too: the token is
// still valid, and the update the connect page offers relaunches
// straight into auto-login with it (sessionStorage — and so the
// skip flag below — does not survive that relaunch).
if (reason !== "protocol_epoch") void deleteCredential(host);
// Whenever this session must not turn around and auto-login with the
// credential (removed, or just refused), say so. A server_shutdown
// keeps the credential precisely so auto-login still works on
// restart, so it deliberately does not set this.
sessionStorage.setItem("owncord:skip-auto-login", "1");
}
router.navigate("connect");
+6 -1
View File
@@ -21,7 +21,12 @@ const log = createLogger("auth.store");
* server-initiated kick whose token is still valid — the logout wiring keeps
* the saved credential in that case so auto-login works when the server
* comes back. */
export type LogoutReason = "user" | "server_shutdown";
/**
* Why the session ended. "protocol_epoch": the server refused this client's
* wire epoch — the token is still valid, so main.ts keeps the stored
* credential and the update it offers relaunches into auto-login.
*/
export type LogoutReason = "user" | "server_shutdown" | "protocol_epoch";
export interface AuthState {
readonly token: string | null;
+45
View File
@@ -388,6 +388,51 @@ export function incrementMention(channelId: number, evenIfActive = false): void
});
}
/**
* Record an incoming channel message: bumps unread (and mention, when
* `isMention`) unless `messageId` is already reflected in the channel's
* watermark — mirrors dm.store's `updateDmLastMessage` (OC-0242).
*
* OC-0328: a message delivered between the server's registerNow and
* buildReady is both counted in `ready`'s snapshot (unread_count/
* last_message_id already advanced) AND redelivered as a queued
* chat_message once the socket drains. Both counters must sit behind the
* SAME watermark read in one setState — splitting the guard across
* incrementUnread/incrementMention can't work, since the first call would
* already have advanced lastMessageId before the second one checked it.
*
* `evenIfActive` mirrors incrementUnread's escape hatch — see its doc.
*/
export function noteChannelMessage(
channelId: number,
messageId: number,
isMention: boolean,
evenIfActive = false,
): void {
channelsStore.setState((prev) => {
if (prev.activeChannelId === channelId && !evenIfActive) {
return prev;
}
const existing = prev.channels.get(channelId);
if (existing === undefined) {
return prev;
}
const isReplay = existing.lastMessageId !== null && messageId <= existing.lastMessageId;
if (isReplay) {
return prev;
}
const updated: Channel = {
...existing,
unreadCount: existing.unreadCount + 1,
mentionCount: isMention ? existing.mentionCount + 1 : existing.mentionCount,
lastMessageId: messageId,
};
const next = new Map(prev.channels);
next.set(channelId, updated);
return { ...prev, channels: next };
});
}
/** Clear the unread and mention counts for a channel — they clear together. */
export function clearUnread(channelId: number): void {
channelsStore.setState((prev) => {
+3 -2
View File
@@ -146,6 +146,7 @@ export function updateDmLastMessage(
if (updated === undefined) return prev;
const rest = prev.channels.filter((c) => c.channelId !== channelId);
const isReplay = updated.lastMessageId !== null && messageId <= updated.lastMessageId;
if (isReplay) return prev;
return {
channels: [
{
@@ -153,8 +154,8 @@ export function updateDmLastMessage(
lastMessageId: messageId,
lastMessage: content,
lastMessageAt: timestamp,
unreadCount: isReplay ? updated.unreadCount : updated.unreadCount + 1,
mentionCount: isMention && !isReplay ? updated.mentionCount + 1 : updated.mentionCount,
unreadCount: updated.unreadCount + 1,
mentionCount: isMention ? updated.mentionCount + 1 : updated.mentionCount,
},
...rest,
],
+1 -1
View File
@@ -105,7 +105,7 @@ export function addMember(payload: MemberJoinPayload): void {
});
}
/** Remove a member from a member_leave event. */
/** Remove a member from a member_ban event. */
export function removeMember(userId: number): void {
membersStore.setState((prev) => {
const next = new Map(prev.members);
+11
View File
@@ -14,6 +14,12 @@ export interface UiState {
readonly connectionStatus: "connected" | "reconnecting" | "disconnected";
readonly transientError: string | null;
readonly persistentError: string | null;
/**
* Host of a server that refused this client's protocol epoch as too old.
* main.ts consumes it when the connect page mounts, to offer the update
* there — the main page's own notifier never mounts on a refusal.
*/
readonly updateRequiredHost: string | null;
readonly collapsedCategories: ReadonlySet<string>;
readonly sidebarMode: "channels" | "dms";
readonly activeDmUserId: number | null;
@@ -28,6 +34,7 @@ const INITIAL_STATE: UiState = {
connectionStatus: "disconnected",
transientError: null,
persistentError: null,
updateRequiredHost: null,
collapsedCategories: new Set(),
sidebarMode: "channels",
activeDmUserId: null,
@@ -108,6 +115,10 @@ export function setTransientError(msg: string | null): void {
}
/** Set a persistent error message that requires user action. */
export function setUpdateRequiredHost(host: string | null): void {
uiStore.setState((prev) => ({ ...prev, updateRequiredHost: host }));
}
export function setPersistentError(msg: string | null): void {
uiStore.setState((prev) => ({
...prev,
+3 -3
View File
@@ -477,9 +477,9 @@ export function setVoiceConfig(payload: VoiceConfigPayload): void {
});
}
/** Update speaking state for users from a voice_speakers event or
* LiveKit's ActiveSpeakersChanged. Updates ALL users including local
* (LiveKit is now the sole authority for speaking detection). */
/** Update speaking state for users from LiveKit's ActiveSpeakersChanged.
* Updates ALL users including local (LiveKit is the sole authority for
* speaking detection). */
export function setSpeakers(payload: VoiceSpeakersPayload): void {
voiceStore.setState((prev) => {
const existingChannel = prev.voiceUsers.get(payload.channel_id);
+151
View File
@@ -0,0 +1,151 @@
// CONTRACT TEST. Pins the exact key set of the `auth` frame that
// Client/src/lib/ws.ts sends as the first message after the WebSocket opens
// (ws.ts:441-453) -- the client side of the same wire contract a sibling Go
// test freezes for the server. B2-2 added the `epoch` field (the wire epoch
// this client speaks, PROTOCOL_EPOCH from protocolTypes.ts); the key sets
// below include it deliberately. Any further field MUST fail here until it is
// added on purpose. Extend this file, do not replace or delete it.
//
// Assertions compare exact key sets (sorted Object.keys -- key order has no
// wire meaning), never toHaveProperty, so an unexpected added key fails just
// as loudly as a missing one.
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
// vi.mock is hoisted per file; the factories resolve to the shared handles
// exported from ../unit/helpers/ws-mocks (see that module's doc comment --
// it is shared across all ws-*.test.ts files, this one included).
vi.mock("@tauri-apps/api/core", async () => ({
invoke: (await import("../unit/helpers/ws-mocks")).mockInvoke,
}));
vi.mock("@tauri-apps/api/event", async () => ({
listen: (await import("../unit/helpers/ws-mocks")).mockListen,
}));
import { mockInvoke, mockListen, eventHandlers, emitTauriEvent } from "../unit/helpers/ws-mocks";
import { createWsClient, setActiveChannelProvider } from "../../src/lib/ws";
import { PROTOCOL_EPOCH } from "../../src/lib/protocolTypes";
/** Parses the most recently sent `auth` frame (envelope + payload) from ws_send. */
function getAuthFrame(): { type: string; payload: Record<string, unknown> } {
const authCall = mockInvoke.mock.calls.find(
(c) =>
c[0] === "ws_send" &&
typeof c[1]?.message === "string" &&
(c[1].message as string).includes('"type":"auth"'),
);
expect(authCall).toBeDefined();
return JSON.parse((authCall![1] as { message: string }).message) as {
type: string;
payload: Record<string, unknown>;
};
}
describe("contract: auth frame key set (epoch 1)", () => {
let client: ReturnType<typeof createWsClient>;
beforeEach(() => {
vi.useFakeTimers();
mockInvoke.mockReset();
mockInvoke.mockResolvedValue(undefined);
mockListen.mockClear();
eventHandlers.clear();
// activeChannelProvider is a module-level singleton (registered once at
// app bootstrap in dispatcher.ts) -- reset it so state doesn't leak
// across tests/files.
setActiveChannelProvider(null);
client = createWsClient();
});
afterEach(() => {
client.disconnect();
setActiveChannelProvider(null);
vi.useRealTimers();
});
it("fresh connect: envelope keys are exactly [type, payload, id], payload keys exactly [token, last_seq, epoch]", async () => {
client.connect({ host: "localhost:8443", token: "t" });
await vi.advanceTimersByTimeAsync(10);
emitTauriEvent("ws-state", "open");
const frame = getAuthFrame();
// send() (ws.ts:631-637) wraps every outgoing message with a correlation
// `id` via `{ ...msg, id }` -- that's a generic per-send addition, not
// part of the auth-specific payload contract, but it IS part of what
// actually goes over the wire, so the envelope pin has three keys, not
// the two the auth message literal at ws.ts:446-453 has on its own.
expect(Object.keys(frame).sort()).toEqual(["id", "payload", "type"]);
expect(frame.type).toBe("auth");
expect(Object.keys(frame.payload).sort()).toEqual(["epoch", "last_seq", "token"]);
expect(frame.payload.token).toBe("t");
expect(frame.payload.last_seq).toBe(0);
expect(frame.payload.epoch).toBe(PROTOCOL_EPOCH);
expect(PROTOCOL_EPOCH).toBe(1);
});
it("resume with a registered active-channel provider: payload keys exactly [token, last_seq, active_channel_id, epoch]", async () => {
client.connect({ host: "localhost:8443", token: "t" });
await vi.advanceTimersByTimeAsync(10);
emitTauriEvent("ws-state", "open");
emitTauriEvent(
"ws-message",
JSON.stringify({
type: "auth_ok",
seq: 7,
payload: {
user: { id: 1, username: "a", avatar: null, role: "admin" },
server_name: "S",
motd: "",
},
}),
);
setActiveChannelProvider(() => 42);
emitTauriEvent("ws-state", "closed");
mockInvoke.mockClear();
await vi.advanceTimersByTimeAsync(1100);
emitTauriEvent("ws-state", "open");
const frame = getAuthFrame();
expect(Object.keys(frame.payload).sort()).toEqual([
"active_channel_id",
"epoch",
"last_seq",
"token",
]);
expect(frame.payload.last_seq).toBe(7);
expect(frame.payload.active_channel_id).toBe(42);
});
it("resume without a provider registered: payload keys stay exactly [token, last_seq, epoch]", async () => {
client.connect({ host: "localhost:8443", token: "t" });
await vi.advanceTimersByTimeAsync(10);
emitTauriEvent("ws-state", "open");
emitTauriEvent(
"ws-message",
JSON.stringify({
type: "auth_ok",
seq: 3,
payload: {
user: { id: 1, username: "a", avatar: null, role: "admin" },
server_name: "S",
motd: "",
},
}),
);
// No setActiveChannelProvider call -- stays null from beforeEach reset.
emitTauriEvent("ws-state", "closed");
mockInvoke.mockClear();
await vi.advanceTimersByTimeAsync(1100);
emitTauriEvent("ws-state", "open");
const frame = getAuthFrame();
expect(Object.keys(frame.payload).sort()).toEqual(["epoch", "last_seq", "token"]);
expect(frame.payload.last_seq).toBe(3);
});
});
+7 -55
View File
@@ -4,7 +4,7 @@
* These tests use the existing Tauri mock infrastructure to simulate:
* - WebSocket voice_state and voice_leave events
* - Voice channel UI (sidebar voice users, voice widget)
* - Speaker indicators, connection quality, listen-only mode
* - Connection quality, listen-only mode
*
* NOTE: These tests do NOT exercise real LiveKit/WebRTC connections.
* Real voice E2E requires the native test infrastructure (Tauri exe + LiveKit binary).
@@ -68,42 +68,6 @@ test.describe("Voice lifecycle", () => {
// Should now have 1 user
await expect(page.locator(".voice-user-item")).toHaveCount(1, { timeout: 5000 });
});
test("speaker indicator updates on voice_speakers event", async ({ page }) => {
// Wait for voice users to render
await expect(page.locator(".voice-user-item")).toHaveCount(2, { timeout: 5000 });
// Emit speakers event — user 3 (in channel 10 per the ready payload) speaks
await emitWsMessage(page, {
type: "voice_speakers",
payload: {
channel_id: 10,
speakers: [3],
},
});
// The speaking user's avatar should have the speaking class
const speakingAvatar = page.locator(".voice-user-item.speaking");
await expect(speakingAvatar).toBeVisible({ timeout: 5000 });
});
test("speaker indicator clears when user stops speaking", async ({ page }) => {
await expect(page.locator(".voice-user-item")).toHaveCount(2, { timeout: 5000 });
// User starts speaking
await emitWsMessage(page, {
type: "voice_speakers",
payload: { channel_id: 10, speakers: [3] },
});
await expect(page.locator(".voice-user-item.speaking")).toBeVisible({ timeout: 5000 });
// User stops speaking (empty speakers list)
await emitWsMessage(page, {
type: "voice_speakers",
payload: { channel_id: 10, speakers: [] },
});
await expect(page.locator(".voice-user-item.speaking")).toHaveCount(0, { timeout: 5000 });
});
});
test.describe("Voice widget", () => {
@@ -204,19 +168,7 @@ test.describe("Voice WS flow", () => {
await expect(widget).not.toHaveClass(/visible/, { timeout: 5_000 });
});
// 3. Speaker indicator animation — voice_speakers event adds .speaking class.
test("voice_speakers event adds speaking class to voice user", async ({ page }) => {
await expect(page.locator(".voice-user-item")).toHaveCount(2, { timeout: 5000 });
await emitWsMessage(page, {
type: "voice_speakers",
payload: { channel_id: 10, speakers: [3] },
});
await expect(page.locator(".voice-user-item.speaking")).toBeVisible({ timeout: 5000 });
});
// 4. Permission recovery button — grant mic button appears when
// 3. Permission recovery button — grant mic button appears when
// listenOnly is true (display toggled via voice store subscription).
test("grant mic button appears in listen-only mode", async ({ page }) => {
await joinVoiceChannelByName(page);
@@ -233,7 +185,7 @@ test.describe("Voice WS flow", () => {
await expect(grantMicBtn).toBeVisible({ timeout: 5000 });
});
// 5. Device hot-swap toast — simulate a toast notification for device change.
// 4. Device hot-swap toast — simulate a toast notification for device change.
test("device change shows toast notification", async ({ page }) => {
// Toast container is mounted by MainPage — inject a toast element.
await page.evaluate(() => {
@@ -251,7 +203,7 @@ test.describe("Voice WS flow", () => {
await expect(toast).toBeVisible({ timeout: 5000 });
});
// 6. Connection quality warning — stats pane auto-expands on quality degradation.
// 5. Connection quality warning — stats pane auto-expands on quality degradation.
test("quality degradation auto-expands stats pane", async ({ page }) => {
await joinVoiceChannelByName(page);
const widget = page.locator("[data-testid='voice-widget']");
@@ -269,7 +221,7 @@ test.describe("Voice WS flow", () => {
await expect(statsPane).toHaveClass(/visible/, { timeout: 5000 });
});
// 7. Mute/deafen toggle — buttons use aria-pressed and .active-ctrl class.
// 6. Mute/deafen toggle — buttons use aria-pressed and .active-ctrl class.
test("mute and deafen buttons toggle state", async ({ page }) => {
await joinVoiceChannelByName(page);
const widget = page.locator("[data-testid='voice-widget']");
@@ -287,7 +239,7 @@ test.describe("Voice WS flow", () => {
await expect(deafenBtn).toHaveClass(/active-ctrl/);
});
// 8. Voice timer — joinedAt is set by joinVoiceChannel() on click.
// 7. Voice timer — joinedAt is set by joinVoiceChannel() on click.
test("voice timer shows elapsed time", async ({ page }) => {
await joinVoiceChannelByName(page);
const widget = page.locator("[data-testid='voice-widget']");
@@ -297,7 +249,7 @@ test.describe("Voice WS flow", () => {
await expect(timer).toHaveText(/\d{2}:\d{2}/, { timeout: 5000 });
});
// 9. Token refresh — emitting a new voice_token doesn't disconnect.
// 8. Token refresh — emitting a new voice_token doesn't disconnect.
test("token refresh does not disconnect session", async ({ page }) => {
await joinVoiceChannelByName(page);
const widget = page.locator("[data-testid='voice-widget']");
+1
View File
@@ -72,6 +72,7 @@ const UI_INITIAL: UiState = {
connectionStatus: "disconnected",
transientError: null,
persistentError: null,
updateRequiredHost: null,
collapsedCategories: new Set(),
sidebarMode: "channels",
activeDmUserId: null,
+2 -71
View File
@@ -530,10 +530,10 @@ describe("Store integration via dispatcher", () => {
});
// ────────────────────────────────────────────────────────────────
// 8. Voice config and speakers
// 8. Voice config
// ────────────────────────────────────────────────────────────────
describe("voice config and speakers", () => {
describe("voice config", () => {
it("stores voice config from voice_config event", () => {
ws.simulate("voice_config", {
channel_id: 3,
@@ -554,65 +554,6 @@ describe("Store integration via dispatcher", () => {
expect(config!.top_speakers).toBe(3);
expect(config!.max_users).toBe(25);
});
it("updates speaking states from voice_speakers event", () => {
// First seed voice users in channel 3
ws.simulate("ready", {
channels: [],
members: [],
voice_states: [
{ channel_id: 3, user_id: 1, muted: false, deafened: false },
{ channel_id: 3, user_id: 2, muted: false, deafened: false },
{ channel_id: 3, user_id: 3, muted: false, deafened: false },
],
roles: [],
});
// User 1 and 3 are speaking
ws.simulate("voice_speakers", {
channel_id: 3,
speakers: [1, 3],
threshold_mode: "selective",
});
const channelUsers = voiceStore.getState().voiceUsers.get(3);
expect(channelUsers).toBeDefined();
expect(channelUsers!.get(1)?.speaking).toBe(true);
expect(channelUsers!.get(2)?.speaking).toBe(false);
expect(channelUsers!.get(3)?.speaking).toBe(true);
});
it("clears speaking when user is no longer in speakers list", () => {
// Seed voice users
ws.simulate("ready", {
channels: [],
members: [],
voice_states: [
{ channel_id: 3, user_id: 1, muted: false, deafened: false },
{ channel_id: 3, user_id: 2, muted: false, deafened: false },
],
roles: [],
});
// User 1 speaking
ws.simulate("voice_speakers", {
channel_id: 3,
speakers: [1],
threshold_mode: "forwarding",
});
expect(voiceStore.getState().voiceUsers.get(3)!.get(1)?.speaking).toBe(true);
// Now nobody speaking
ws.simulate("voice_speakers", {
channel_id: 3,
speakers: [],
threshold_mode: "forwarding",
});
expect(voiceStore.getState().voiceUsers.get(3)!.get(1)?.speaking).toBe(false);
expect(voiceStore.getState().voiceUsers.get(3)!.get(2)?.speaking).toBe(false);
});
});
// ────────────────────────────────────────────────────────────────
@@ -678,16 +619,6 @@ describe("Store integration via dispatcher", () => {
expect(members.get(50)!.username).toBe("new-user");
});
it("removes member on member_leave event", () => {
ws.simulate("member_join", {
user: { id: 51, username: "leaving-user", avatar: null, role: "member", status: "online" },
});
expect(membersStore.getState().members.has(51)).toBe(true);
ws.simulate("member_leave", { user_id: 51 });
expect(membersStore.getState().members.has(51)).toBe(false);
});
it("updates member role on member_update event", () => {
ws.simulate("member_join", {
user: { id: 52, username: "role-user", avatar: null, role: "member", status: "online" },
@@ -71,6 +71,7 @@ function resetStores(): void {
connectionStatus: "connected" as const,
transientError: null,
persistentError: null,
updateRequiredHost: null,
collapsedCategories: new Set<string>(),
sidebarMode: "channels" as const,
activeDmUserId: null,
+62
View File
@@ -16,6 +16,7 @@ import {
UNCATEGORIZED_VOICE_CATEGORY,
incrementUnread,
incrementMention,
noteChannelMessage,
clearUnread,
getUnreadOnOpen,
resetChannelsStore,
@@ -625,6 +626,67 @@ describe("channels store", () => {
});
});
// OC-0328: a message delivered between the server's registerNow and
// buildReady is both counted in `ready` (unread_count/last_message_id
// already advanced) and redelivered as a queued chat_message — mirrors
// dm.store's updateDmLastMessage guard (OC-0242).
describe("noteChannelMessage", () => {
it("increments unread and advances the watermark for a new message", () => {
setChannels([{ ...readyChannels[0]!, unread_count: 1, last_message_id: 100 }]);
noteChannelMessage(1, 101, false);
const ch = channelsStore.getState().channels.get(1);
expect(ch?.unreadCount).toBe(2);
expect(ch?.lastMessageId).toBe(101);
});
it("also increments mention when isMention is set", () => {
setChannels([{ ...readyChannels[0]!, unread_count: 1, last_message_id: 100 }]);
noteChannelMessage(1, 101, true);
expect(channelsStore.getState().channels.get(1)?.mentionCount).toBe(1);
});
it("does not bump either counter for a message id already reflected in lastMessageId", () => {
setChannels([{ ...readyChannels[0]!, unread_count: 1, last_message_id: 100 }]);
noteChannelMessage(1, 100, true); // same id ready already counted
const ch = channelsStore.getState().channels.get(1);
expect(ch?.unreadCount).toBe(1); // unchanged
expect(ch?.mentionCount).toBe(0); // unchanged
});
it("skips increment for the active channel", () => {
setChannels([{ ...readyChannels[0]!, unread_count: 1, last_message_id: 100 }]);
setActiveChannel(1);
noteChannelMessage(1, 101, false);
expect(channelsStore.getState().channels.get(1)?.unreadCount).toBe(0);
});
it("bumps the active channel when evenIfActive is set", () => {
setChannels([{ ...readyChannels[0]!, unread_count: 1, last_message_id: 100 }]);
setActiveChannel(1);
noteChannelMessage(1, 101, false, true);
expect(channelsStore.getState().channels.get(1)?.unreadCount).toBe(1);
});
it("is a no-op for an unknown channel id", () => {
setChannels(readyChannels);
const before = channelsStore.getState();
noteChannelMessage(999, 1, false);
expect(channelsStore.getState()).toBe(before);
});
});
describe("clearUnread", () => {
it("resets unread count to 0", () => {
setChannels(readyChannels);
+603
View File
@@ -0,0 +1,603 @@
/**
* Model-based test for the client connection stack (B3-6 item 4, Tier 3a of
* docs/plans/bug-detection-improvements.md).
*
* Property tests find bad functions; every recurring bug in this codebase's
* history is a bad *ordering* — reconnect transfer, superseded voice sessions,
* duplicate-message reconciliation, resync corruption, the logout/auto-login
* race. `fc.commands` generates those orderings and shrinks a long failure to
* its minimal reproducer.
*
* System under test — the REAL modules, wired together the way main.ts wires
* them: `createWsClient()` (src/lib/ws.ts) driving `wireDispatcher()`
* (src/lib/dispatcher.ts) into the real stores. Only the boundaries are
* mocked:
* - the Tauri IPC surface (`invoke`/`listen`), via the shared ws-mocks
* helper the ws-*.test.ts files already use — this is the wire, and
* driving it is the only way to exercise ws.ts's own state machine;
* - the LiveKit media layer, notifications, toasts and identity publishing,
* exactly as dispatcher.test.ts mocks them. `handleParticipantLeft` keeps
* its one store-visible effect (clearing the departed peer's E2EE
* verification) so the verification invariant stays two-sided.
* Nothing that the invariants describe is mocked: ws.ts's seq watermark,
* messages.store's id reconciliation, voice.store's session and verification
* state are all the production implementations.
*
* Reproducing a failure: fast-check prints the seed, the counterexample and a
* `replayPath`. Re-run with `OWNCORD_MODEL_SEED=<seed>` to replay exactly.
*/
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import fc from "fast-check";
// vi.mock is hoisted per file; these factories resolve to the shared handles
// exported from ./helpers/ws-mocks (see that module's doc comment).
vi.mock("@tauri-apps/api/core", async () => ({
invoke: (await import("./helpers/ws-mocks")).mockInvoke,
}));
vi.mock("@tauri-apps/api/event", async () => ({
listen: (await import("./helpers/ws-mocks")).mockListen,
}));
vi.mock("@lib/notifications", () => ({
notifyIncomingMessage: vi.fn(),
cleanupNotificationAudio: vi.fn(),
}));
vi.mock("@lib/screenShare", () => ({
rollbackPendingVideo: vi.fn(() => undefined),
}));
vi.mock("@lib/toast", () => ({
showToast: vi.fn(),
}));
vi.mock("@lib/identity", () => ({
ensureIdentityKeyPublished: vi.fn(async () => true),
}));
// The media layer is out of scope for a connection model (it needs a real
// LiveKit room and WebCrypto), but handleParticipantLeft's store-visible
// effect is not: livekitE2EE.ts drops a departed peer's verification, and the
// verification invariant below has to be able to observe that happening.
vi.mock("@lib/livekitSession", async () => {
const { clearPeerVerification, clearPeerVerifications } = await import("@stores/voice.store");
return {
handleVoiceToken: vi.fn(async () => {}),
handleParticipantLeft: vi.fn(async (userId: number) => {
clearPeerVerification(userId);
}),
handleE2EEAnnounce: vi.fn(async () => {}),
handleE2EEOffer: vi.fn(async () => {}),
leaveVoice: vi.fn(() => {
clearPeerVerifications();
}),
cleanupAll: vi.fn(),
isVoiceConnected: vi.fn(() => false),
isVoiceSessionActive: vi.fn(() => false),
setMuted: vi.fn(),
setDeafened: vi.fn(),
disableCamera: vi.fn(async () => {}),
disableScreenshare: vi.fn(async () => {}),
};
});
import { mockInvoke, mockListen, eventHandlers, emitTauriEvent } from "./helpers/ws-mocks";
import { createWsClient, type WsClient } from "../../src/lib/ws";
import { wireDispatcher, type DispatcherCleanup } from "../../src/lib/dispatcher";
import { clearAuth } from "../../src/stores/auth.store";
import { getChannelMessages } from "../../src/stores/messages.store";
import { voiceStore, setPeerVerification } from "../../src/stores/voice.store";
import type {
AuthOkPayload,
ChatMessagePayload,
ReadyPayload,
ReadyVoiceState,
VoiceStatePayload,
} from "../../src/lib/types";
const HOST = "localhost:8443";
const TOKEN = "test-token";
const TEXT_CHANNEL = 1;
/** The two voice channels a Supersede can move between. */
const VOICE_CHANNELS = [10, 11] as const;
const SELF_ID = 1;
const PEER_IDS = [2, 3] as const;
/** Small id pool so redelivery collisions happen by construction. */
const MESSAGE_IDS = { min: 1, max: 6 } as const;
/**
* Minimal reference implementation of the expected state — deliberately NOT a
* second copy of the real logic: a handful of scalars the commands maintain by
* hand, which is what makes a disagreement meaningful.
*/
interface Model {
/** Socket open AND authenticated (auth_ok seen). */
connected: boolean;
/** The seq watermark ws.ts must declare in the next auth frame. */
seq: number;
/** Message ids the store must hold for TEXT_CHANNEL, in arrival order. */
ids: number[];
/** The voice channel the newest join owns, or null. */
voiceChannel: number | null;
/**
* Peers in that voice channel whose E2EE identity is verified. In this model
* a peer in our call is exactly a verified peer — the announce/TOFU crypto
* itself belongs to livekitE2EE's own tests, not to a connection model.
*/
verifiedPeers: number[];
}
interface Real {
readonly client: WsClient;
readonly cleanup: DispatcherCleanup;
/** Every envelope handed to the Tauri `ws_send` command, newest last. */
readonly sent: Array<{ type?: string; payload?: Record<string, unknown> }>;
}
/**
* Which invariant families the generated sequences actually reached. Asserted
* at the end of the file: a family that stops being reachable (a `check()`
* that can no longer fire, a payload the dispatcher starts ignoring) is a
* silent hole, not a pass.
*/
const exercised = { ids: 0, seq: 0, verified: 0, staleTeardown: 0, resumeReplay: 0 };
/** Let the ws client's awaits and the dispatcher's lazy imports settle. */
async function settle(): Promise<void> {
await vi.advanceTimersByTimeAsync(1);
}
function emit(type: string, payload: unknown, seq?: number): void {
emitTauriEvent(
"ws-message",
JSON.stringify(seq === undefined ? { type, payload } : { type, payload, seq }),
);
}
function member(id: number, username: string) {
return { id, username, avatar: null, role: "member", status: "online" as const };
}
function authOkPayload(replaySource: "none" | "buffer"): AuthOkPayload {
return {
user: { id: SELF_ID, username: "me", avatar: null, role: "member" },
server_name: "test",
motd: "",
replay_source: replaySource,
};
}
/**
* The message a resume replays: one that committed while we were away, or —
* once the id pool is exhausted — a redelivery of one we already hold, which
* is the other real shape a replay burst takes.
*/
function replayedMessageId(m: Model): number {
for (let id = MESSAGE_IDS.min; id <= MESSAGE_IDS.max; id++) {
if (!m.ids.includes(id)) return id;
}
return m.ids[m.ids.length - 1] as number;
}
function chatPayload(id: number): ChatMessagePayload {
return {
id,
channel_id: TEXT_CHANNEL,
user: { id: PEER_IDS[0], username: "peer2", avatar: null },
content: `message ${id}`,
reply_to: null,
attachments: [],
timestamp: "2026-01-01T00:00:00Z",
};
}
function voiceState(channelId: number, userId: number): VoiceStatePayload {
return {
channel_id: channelId,
user_id: userId,
username: userId === SELF_ID ? "me" : `peer${userId}`,
muted: false,
deafened: false,
speaking: false,
camera: false,
screenshare: false,
};
}
/** The `ready` snapshot a server would build for this model state. */
function readyPayload(m: Model, dropPeers: readonly number[] = []): ReadyPayload {
const voiceChannel = m.voiceChannel;
const roster: ReadyVoiceState[] =
voiceChannel === null
? []
: [SELF_ID, ...m.verifiedPeers.filter((uid) => !dropPeers.includes(uid))].map((uid) => ({
channel_id: voiceChannel,
user_id: uid,
muted: false,
deafened: false,
}));
return {
channels: [
{
id: TEXT_CHANNEL,
name: "general",
type: "text",
category: null,
position: 0,
last_message_id: m.ids.length > 0 ? Math.max(...m.ids) : 0,
},
],
members: [member(SELF_ID, "me"), ...PEER_IDS.map((id) => member(id, `peer${id}`))],
voice_states: roster,
roles: [],
dm_channels: [],
};
}
/** The peers the real store currently reports as verified, sorted. */
function realVerifiedPeers(): number[] {
const verifications = voiceStore.getState().peerVerifications;
return [...(verifications?.values() ?? [])]
.filter((v) => v.status === "verified")
.map((v) => v.userId)
.toSorted((a, b) => a - b);
}
/**
* The four invariants from the design, checked after every command so
* shrinking lands on the first step that broke one.
*/
function checkInvariants(m: Model, r: Real, afterSeeding = false): void {
// 1. Message ids never duplicate, and the store holds exactly the ids the
// connection delivered — a redelivery reconciles in place, it does not
// add a row, and a resync does not lose one.
const ids = getChannelMessages(TEXT_CHANNEL).map((row) => row.id);
expect(new Set(ids).size, `duplicate message ids: ${ids.join(",")}`).toBe(ids.length);
expect(ids).toEqual(m.ids);
if (m.ids.length > 0) exercised.ids++;
// 2. Per-client seq is monotonic. Not checked here: the watermark is only
// observable when ws.ts puts it on the wire, so the assertion lives in
// `connectCmd` against that connect's own auth frame. Anything asserted
// here would only be the model against itself.
// 3. A verified peer never flips to unverified (and back). The model is the
// arbiter: a peer stays verified until it genuinely leaves our call, and
// a re-join re-verifies — anything else is a flip. `afterSeeding` marks
// the one call that runs immediately after Supersede wrote the
// verifications itself: it still asserts, but it must not count as
// coverage, or the family would look reached even if every check that
// survives a later command disappeared.
expect(realVerifiedPeers()).toEqual(m.verifiedPeers.toSorted((a, b) => a - b));
if (m.verifiedPeers.length > 0 && !afterSeeding) exercised.verified++;
// 4. An aborted attempt never tears down a live session owned by a newer
// attempt: the store's voice session always belongs to the newest join.
expect(voiceStore.getState().currentChannelId).toBe(m.voiceChannel);
// The transport must never be left in a state nobody asked for.
expect(["disconnected", "connecting", "authenticating", "connected", "reconnecting"]).toContain(
r.client.getState(),
);
}
type Cmd = fc.AsyncCommand<Model, Real>;
function cmd(
name: string,
check: (m: Model) => boolean,
run: (m: Model, r: Real) => Promise<void>,
): Cmd {
return { check, run, toString: () => name };
}
/**
* Connect: open the socket, hand over the auth frame, complete the handshake.
*
* The handshake has two shapes on the wire, and they are not interchangeable
* (`Server/ws/serve.go`; the epoch-1 fixtures record both):
* - fresh / full-resync fallback (`last_seq` 0, or replay refused):
* `handleFreshConnect` writes `auth_ok` with `replay_source: "none"`
* followed by `ready` — `fresh-connect.json` records exactly that.
* - resume (`last_seq > 0`, replay accepted): `reconnectWriteReplay` writes
* `auth_ok` with the tier, then the missed events, and **no `ready` at
* all** — `resume-replay.json` records `auth_ok(buffer)` → `presence` →
* `chat_message` → `presence`.
* Modelling a resume as `auth_ok` + `ready` (as this did before) meant the
* dispatcher never saw the `auth_ok → replayed events` ordering, and the
* post-resume state was repaired by a snapshot that never arrives.
*/
const connectCmd = cmd(
"Connect",
(m) => !m.connected,
async (m, r) => {
// Only frames sent by THIS attempt count — an earlier attempt's auth frame
// must never be mistaken for one this connect produced.
const before = r.sent.length;
r.client.connect({ host: HOST, token: TOKEN });
await settle();
emitTauriEvent("ws-state", "open");
await settle();
// Invariant 2's only real assertion point: the auth frame is where the seq
// watermark becomes observable. A fresh connect declares 0 and proves
// nothing, so only a resume (`m.seq > 0`, i.e. frames survived a
// Disconnect without a resync or logout in between) counts as coverage.
const resume = m.seq > 0;
const auth = r.sent.slice(before).find((e) => e.type === "auth");
expect(auth, "connect sent no auth frame").toBeDefined();
expect(auth?.payload?.last_seq).toBe(m.seq);
if (resume) exercised.seq++;
emit("auth_ok", authOkPayload(resume ? "buffer" : "none"));
const replayId = resume ? replayedMessageId(m) : null;
if (replayId === null) {
emit("ready", readyPayload(m));
} else {
emit("chat_message", chatPayload(replayId), m.seq + 1);
}
await settle();
m.connected = true;
if (replayId !== null) {
m.seq += 1;
if (!m.ids.includes(replayId)) m.ids.push(replayId);
// The replay burst is the only thing that repairs this client's state —
// no `ready` follows it — so the frame has to be in the store already.
// Re-adding a `ready` here would let a snapshot do that repair instead,
// which is the shape this test must not silently accept.
expect(getChannelMessages(TEXT_CHANNEL).map((row) => row.id)).toContain(replayId);
exercised.resumeReplay++;
}
expect(r.client.getState()).toBe("connected");
checkInvariants(m, r);
},
);
/** Disconnect: the proxy drops the socket. Automatic reconnect keeps lastSeq. */
const disconnectCmd = cmd(
"Disconnect",
(m) => m.connected,
async (m, r) => {
emitTauriEvent("ws-state", "closed");
await settle();
m.connected = false;
expect(r.client.getState()).toBe("reconnecting");
checkInvariants(m, r);
},
);
/**
* RegisterNow: the server registered this connection and then built `ready`.
* A message that committed in between is in the snapshot AND is redelivered as
* a queued frame — the redelivery must reconcile, not duplicate.
*
* `ready` never travels alone: `handleFreshConnect` is its only writer and it
* always follows that path's `auth_ok`, so this drives the full handshake.
* `replay_source: "none"` resets ws.ts's watermark, and the redelivered frame
* then carries the server's restarted counter (OC-0032).
*/
const registerNowCmd = cmd(
"RegisterNow",
(m) => m.connected && m.ids.length > 0,
async (m, r) => {
emit("auth_ok", authOkPayload("none"));
emit("ready", readyPayload(m));
emit("chat_message", chatPayload(m.ids[m.ids.length - 1] as number), 1);
await settle();
m.seq = 1;
checkInvariants(m, r);
},
);
/** Receive(id, seq): a sequenced frame off the wire. */
function receiveCmd(id: number, seq: number): Cmd {
return cmd(
`Receive(id=${id},seq=${seq})`,
(m) => m.connected,
async (m, r) => {
emit("chat_message", chatPayload(id), seq);
await settle();
if (seq > m.seq) m.seq = seq;
if (!m.ids.includes(id)) m.ids.push(id);
checkInvariants(m, r);
},
);
}
/**
* Supersede(next, stale): a newer voice join takes over the session, then a
* teardown frame for this client's own voice membership lands.
* - stale: it names the channel the *older*, already-superseded attempt
* owned. It must not tear down the newer session (the server broadcasts a
* voice_leave to the leaver on every channel switch, and it can arrive
* after the new join has already been granted).
* - live: it names the current channel, so this really is our departure —
* the session ends and the E2EE state goes with it.
*/
function supersedeCmd(next: number, stale: boolean): Cmd {
const channel = VOICE_CHANNELS[next] as number;
const other = VOICE_CHANNELS[1 - next] as number;
return cmd(
`Supersede(channel=${channel},${stale ? "stale" : "live"})`,
(m) => m.connected,
async (m, r) => {
emit("voice_state", voiceState(channel, SELF_ID));
for (const uid of PEER_IDS) emit("voice_state", voiceState(channel, uid));
await settle();
// The newer session verifies its peers (livekitE2EE does this from each
// peer's announce; the crypto is out of scope here).
for (const uid of PEER_IDS) {
setPeerVerification({
userId: uid,
status: "verified",
safetyNumber: `sn-${uid}`,
sessionFingerprint: `fp-${uid}`,
});
}
m.voiceChannel = channel;
m.verifiedPeers = [...PEER_IDS];
checkInvariants(m, r, true);
// …and now the teardown frame arrives.
emit("voice_leave", { channel_id: stale ? other : channel, user_id: SELF_ID });
await settle();
if (stale) {
exercised.staleTeardown++;
} else {
m.voiceChannel = null;
m.verifiedPeers = [];
}
checkInvariants(m, r);
},
);
}
/**
* Resync(dropPeer): a full re-sync — the server rebuilt this client's state
* from scratch, so its own seq counter may have restarted below our stale
* watermark and the watermark must reset with it (ws.ts's replay_source
* "none" branch). `dropPeer` models the peer who left our call during the
* outage: a full resync never replays that voice_leave, so the ready-time
* reconciliation is the only thing that can drop their key and verification.
*/
function resyncCmd(dropPeer: boolean): Cmd {
return cmd(
`Resync(dropPeer=${dropPeer})`,
(m) => m.connected,
async (m, r) => {
const dropped = dropPeer && m.verifiedPeers.length > 0 ? [m.verifiedPeers[0] as number] : [];
emit("auth_ok", authOkPayload("none"));
emit("ready", readyPayload(m, dropped));
await settle();
m.seq = 0;
m.verifiedPeers = m.verifiedPeers.filter((uid) => !dropped.includes(uid));
checkInvariants(m, r);
},
);
}
/** Logout: the intentional teardown — transport and every domain store. */
const logoutCmd = cmd(
"Logout",
(m) => m.connected || m.ids.length > 0 || m.voiceChannel !== null,
async (m, r) => {
r.client.disconnect();
clearAuth();
await settle();
m.connected = false;
m.seq = 0;
m.ids = [];
m.voiceChannel = null;
m.verifiedPeers = [];
expect(r.client.getState()).toBe("disconnected");
checkInvariants(m, r);
},
);
const commandArbs = [
// Listed twice on purpose: every other command needs a live connection, so
// an under-weighted Connect leaves most generated sequences doing nothing.
fc.constant(connectCmd),
fc.constant(connectCmd),
fc.constant(disconnectCmd),
fc.constant(registerNowCmd),
fc
.tuple(fc.integer(MESSAGE_IDS), fc.integer({ min: 0, max: 8 }))
.map(([id, seq]) => receiveCmd(id, seq)),
fc.tuple(fc.integer({ min: 0, max: 1 }), fc.boolean()).map(([n, s]) => supersedeCmd(n, s)),
fc.boolean().map((drop) => resyncCmd(drop)),
fc.constant(logoutCmd),
];
/**
* Fixed by default so CI is reproducible run to run; override to replay a
* reported counterexample (`OWNCORD_MODEL_SEED=<seed> npm test`). A malformed
* override throws rather than silently handing fast-check `NaN` (or the `0`
* an empty variable coerces to) and running a different suite than the one
* that was asked for.
*/
function modelSeed(): number {
const raw = process.env.OWNCORD_MODEL_SEED;
if (raw === undefined) return 20260830;
// Number("") is 0 and Number("abc") is NaN — both would run a different
// suite than the one that was asked for, so neither gets a fallback.
const seed = Number(raw);
if (raw.trim() === "" || !Number.isInteger(seed)) {
throw new Error(`OWNCORD_MODEL_SEED must be an integer, got ${JSON.stringify(raw)}`);
}
return seed;
}
const SEED = modelSeed();
const NUM_RUNS = 150;
const MAX_COMMANDS = 30;
describe("connection model (fc.commands over the real ws client + dispatcher)", () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.clearAllTimers();
vi.useRealTimers();
});
it("holds every connection invariant under generated orderings", async () => {
const live: { real: Real | null } = { real: null };
await fc.assert(
fc.asyncProperty(
fc.commands(commandArbs, { maxCommands: MAX_COMMANDS, size: "large" }),
async (cmds) => {
try {
await fc.asyncModelRun(() => {
// Fresh transport, dispatcher and stores per run — a run must
// never inherit the previous one's timers or listeners.
vi.clearAllTimers();
mockInvoke.mockReset();
mockListen.mockClear();
eventHandlers.clear();
clearAuth();
const sent: Real["sent"] = [];
mockInvoke.mockImplementation(
async (command: string, args?: { message?: string }) => {
if (command === "ws_send" && typeof args?.message === "string") {
sent.push(JSON.parse(args.message) as Real["sent"][number]);
}
return undefined;
},
);
const client = createWsClient();
const real: Real = { client, cleanup: wireDispatcher(client), sent };
live.real = real;
const model: Model = {
connected: false,
seq: 0,
ids: [],
voiceChannel: null,
verifiedPeers: [],
};
return { model, real };
}, cmds);
} finally {
live.real?.cleanup();
live.real?.client.disconnect();
live.real = null;
}
},
),
{ numRuns: NUM_RUNS, seed: SEED },
);
});
it("reached every invariant family (a family that stops firing is a hole)", () => {
for (const [family, count] of Object.entries(exercised)) {
expect(count, `invariant family "${family}" was never exercised`).toBeGreaterThan(0);
}
});
});
+205 -50
View File
@@ -28,7 +28,8 @@ import {
listCustomEmoji,
resolveEmoji,
} from "../../src/stores/emoji.store";
import { uiStore } from "../../src/stores/ui.store";
import { uiStore, setUpdateRequiredHost } from "../../src/stores/ui.store";
import { PROTOCOL_EPOCH } from "../../src/lib/protocolTypes";
import {
clearReactionUsersCache,
getCachedReactionUsers,
@@ -284,6 +285,52 @@ describe("WS Dispatcher", () => {
expect(uiStore.getState().transientError).toBe("Invalid token");
});
it("marks the server host as needing a client update when auth_error refuses this client's epoch as too old", () => {
cleanup();
setUpdateRequiredHost(null);
const getConfig = vi.fn(() => ({ host: "chat.example:8443", token: "t" }));
cleanup = wireDispatcher(mock.ws, { listBlocks: vi.fn().mockResolvedValue([]), getConfig });
mock.dispatch("auth_error", {
message: "update the client",
code: "protocol_epoch_unsupported",
client_epoch: PROTOCOL_EPOCH,
server_epoch: PROTOCOL_EPOCH + 1,
min_epoch: PROTOCOL_EPOCH + 1,
});
expect(uiStore.getState().updateRequiredHost).toBe("chat.example:8443");
expect(uiStore.getState().transientError).toBe("update the client");
expect(authStore.getState().isAuthenticated).toBe(false);
// The token is still valid — main.ts keeps the stored credential on this
// reason so the update relaunches straight into auto-login (Codex P2).
expect(authStore.getState().logoutReason).toBe("protocol_epoch");
});
it("does not offer a client update when the SERVER is the older side, or on an ordinary auth_error", () => {
cleanup();
setUpdateRequiredHost(null);
const getConfig = vi.fn(() => ({ host: "chat.example:8443", token: "t" }));
cleanup = wireDispatcher(mock.ws, { listBlocks: vi.fn().mockResolvedValue([]), getConfig });
mock.dispatch("auth_error", {
message: "update the server",
code: "protocol_epoch_unsupported",
client_epoch: PROTOCOL_EPOCH,
server_epoch: PROTOCOL_EPOCH - 1,
min_epoch: PROTOCOL_EPOCH - 1,
});
expect(uiStore.getState().updateRequiredHost).toBeNull();
// Server older than the client: still a protocol refusal, still a valid
// token — the credential must survive this one too.
expect(authStore.getState().logoutReason).toBe("protocol_epoch");
mock.dispatch("auth_error", { message: "Invalid token" });
expect(uiStore.getState().updateRequiredHost).toBeNull();
expect(authStore.getState().logoutReason).toBe("user");
});
it("wires ready to channels, members, and voice stores", () => {
mock.dispatch("ready", {
channels: [
@@ -434,6 +481,54 @@ describe("WS Dispatcher", () => {
expect(ch?.unreadCount).toBe(1);
});
// OC-0328: mirrors the DM-side "does not double-count" test below. A
// channel message delivered between the server's registerNow and
// buildReady is both counted in `ready`'s snapshot (lastMessageId already
// advanced to its id) AND redelivered as a queued chat_message once the
// socket drains — the channel path had no replay guard at all.
it("does not double-count a channel unread/mention whose id is already reflected in lastMessageId", () => {
authStore.setState((prev) => ({
...prev,
user: { id: 5, username: "me", avatar: null, role: "member" },
}));
channelsStore.setState((prev) => {
const ch = new Map(prev.channels);
ch.set(5, {
id: 5,
name: "off-topic",
type: "text" as const,
category: null,
position: 0,
unreadCount: 1,
mentionCount: 1,
lastMessageId: 200, // already reflects message 200 via `ready`
canSend: true,
topic: "",
slowMode: 0,
nsfw: false,
voiceMaxUsers: 0,
voiceMaxVideo: 0,
});
return { ...prev, channels: ch, activeChannelId: 1 }; // active is channel 1
});
// The same message redelivered as a queued chat_message.
mock.dispatch("chat_message", {
id: 200,
channel_id: 5,
user: { id: 2, username: "bob", avatar: null },
content: "hey @me",
mentions: [5],
reply_to: null,
attachments: [],
timestamp: "2026-03-15T10:00:00Z",
});
const ch = channelsStore.getState().channels.get(5);
expect(ch?.unreadCount).toBe(1);
expect(ch?.mentionCount).toBe(1);
});
// OC-0204: "active channel" normally means "the user is watching the live
// tail", so skipping the unread bump there is correct — until a jump to an
// old permalink/reply/search hit leaves the SAME active channel showing a
@@ -679,6 +774,84 @@ describe("WS Dispatcher", () => {
});
});
// OC-0315: payload.timestamp is the raw SQLite datetime('now') string —
// naive UTC, no 'Z' suffix (the server never emits one). Date.parse (used
// by the replay-gate comparison, unlike the parseTimestamp helper built
// for exactly this) interprets that as LOCAL time. On a viewer whose zone
// is east of UTC, the parsed epoch reads *earlier* than the true instant,
// so a genuinely live message can look like it predates the reconnect
// handshake and gets silently swallowed by the replay gate — worst when
// serverClockSkewMs is still 0 (nothing has been sampled yet this
// session), since nothing else offsets the bias. Pin a real east-of-UTC
// zone to observe it; skip where the pin isn't honored (see the probe in
// renderers.test.ts's DST block for why a worker-thread pool can't).
const oc0315OriginalTZ = process.env.TZ;
process.env.TZ = "Asia/Tokyo";
const oc0315PinHonored = new Date(2026, 0, 15).getTimezoneOffset() === -540;
if (oc0315OriginalTZ === undefined) {
delete process.env.TZ;
} else {
process.env.TZ = oc0315OriginalTZ;
}
describe.skipIf(!oc0315PinHonored)(
"[OC-0315] naive-UTC server timestamps vs a non-UTC viewer clock",
() => {
const originalTZ = process.env.TZ;
beforeEach(() => {
process.env.TZ = "Asia/Tokyo";
vi.mocked(mockNotifyIncomingMessage).mockClear();
});
afterEach(() => {
if (originalTZ === undefined) {
delete process.env.TZ;
} else {
process.env.TZ = originalTZ;
}
});
it("does not misclassify a live message as a replay when serverClockSkewMs is still 0 (cold, never sampled)", () => {
// Sanity: really pinned east of UTC (Tokyo has no DST, so this is
// stable year-round, unlike the America/New_York probe elsewhere).
expect(new Date(2026, 0, 15).getTimezoneOffset()).toBe(-540);
mock.dispatch("auth_ok", {
user: { id: 1, username: "alex", avatar: null, role: "admin" },
server_name: "TestServer",
motd: "",
});
const handshakeAt = Date.now();
// Second auth_ok in the same dispatcher lifetime = a reconnect.
mock.dispatch("auth_ok", {
user: { id: 1, username: "alex", avatar: null, role: "admin" },
server_name: "TestServer",
motd: "",
});
// A genuinely live message, 1s after the handshake, stamped by the
// server in its real wire form: naive UTC, no 'Z'.
vi.setSystemTime(handshakeAt + 1000);
const naiveUtcTimestamp = new Date(Date.now())
.toISOString()
.replace("T", " ")
.replace(/\.\d{3}Z$/, "");
mock.dispatch("chat_message", {
id: 1,
channel_id: 1,
user: { id: 2, username: "bob", avatar: null },
content: "live now, naive-UTC timestamp",
reply_to: null,
attachments: [],
timestamp: naiveUtcTimestamp,
});
expect(mockNotifyIncomingMessage).toHaveBeenCalledTimes(1);
});
},
);
describe("mention counts", () => {
function seedChannel(): void {
channelsStore.setState((prev) => {
@@ -1101,23 +1274,6 @@ describe("WS Dispatcher", () => {
expect(membersStore.getState().members.has(77)).toBe(false);
});
it("wires member_leave to members store", () => {
membersStore.setState((prev) => {
const m = new Map(prev.members);
m.set(99, {
id: 99,
username: "bye",
avatar: null,
role: "member",
status: "online" as const,
});
return { ...prev, members: m };
});
mock.dispatch("member_leave", { user_id: 99 });
expect(membersStore.getState().members.has(99)).toBe(false);
});
it("wires voice_state to voice store", () => {
mock.dispatch("voice_state", {
channel_id: 2,
@@ -2729,6 +2885,37 @@ describe("WS Dispatcher", () => {
expect(mockHandleParticipantLeft).toHaveBeenCalledWith(7);
});
// OC-0311: voice_leave is broadcast to channelReadAudience(channel), i.e.
// everyone with READ_MESSAGES on THAT channel — not just its voice
// participants. A client can read channel B (and so receive B's
// voice_leave frames) while its own live voice session is in channel A.
// Without a channel guard, a peer leaving a channel this client merely
// reads mutates this client's own E2EE peer state (deletes the peer's key,
// clears their verification badge, retires their key, and can trigger a
// room-key rotation) for a call that peer was never part of.
it("[OC-0311] does not touch E2EE peer state for a voice_leave from a channel this client is not in", async () => {
vi.mocked(mockHandleParticipantLeft).mockClear();
authStore.setState((prev) => ({
...prev,
user: { id: 5, username: "me", avatar: null, role: "member" },
}));
// This client's live voice session is channel 3.
voiceStore.setState((prev) => ({
...prev,
currentChannelId: 3,
}));
// A peer leaves channel 99, which this client can merely read (hence
// seeing the broadcast) but is not the client's own voice channel.
mock.dispatch("voice_leave", {
channel_id: 99,
user_id: 7,
});
await vi.runAllTimersAsync();
expect(mockHandleParticipantLeft).not.toHaveBeenCalled();
});
it("mirrors a moderator mute/deafen into the local flags and honors it", async () => {
authStore.setState((prev) => ({
...prev,
@@ -2977,38 +3164,6 @@ describe("WS Dispatcher", () => {
expect(configs.get(3)).toBeDefined();
});
it("wires voice_speakers to voice store", () => {
voiceStore.setState((prev) => {
const users = new Map(
[1, 2, 4].map((userId) => [
userId,
{
userId,
username: `user${userId}`,
muted: false,
deafened: false,
speaking: false,
camera: false,
screenshare: false,
},
]),
);
const voiceUsers = new Map(prev.voiceUsers);
voiceUsers.set(3, users);
return { ...prev, voiceUsers };
});
mock.dispatch("voice_speakers", {
channel_id: 3,
speakers: [1, 2, 3],
});
const users = voiceStore.getState().voiceUsers.get(3);
expect(users?.get(1)?.speaking).toBe(true);
expect(users?.get(2)?.speaking).toBe(true);
expect(users?.get(4)?.speaking).toBe(false);
});
it("wires voice_token to handleVoiceToken", async () => {
const { handleVoiceToken } = await import("@lib/livekitSession");
+30
View File
@@ -357,6 +357,36 @@ describe("dmStore", () => {
const ch = dmStore.getState().channels[0]!;
expect(ch.unreadCount).toBe(2);
});
// OC-0317: a replayed/stale id must not regress the lastMessageId
// watermark either — its sibling updateDmLastMessagePreview already
// returns `prev` untouched on replay (OC-0301); this function must do
// the same so a later, genuinely-new frame in the same burst is still
// correctly recognized as new instead of looking like another replay.
it("does not regress lastMessageId, lastMessage, or lastMessageAt on a replay", () => {
setDmChannels([
makeDm({
channelId: 5,
unreadCount: 1,
lastMessageId: 102,
lastMessage: "second",
lastMessageAt: "2026-03-28T12:00:02Z",
}),
]);
updateDmLastMessage(5, 101, "stale-replay", "2026-03-28T12:00:01Z");
const ch = dmStore.getState().channels[0]!;
expect(ch.lastMessageId).toBe(102);
expect(ch.lastMessage).toBe("second");
expect(ch.lastMessageAt).toBe("2026-03-28T12:00:02Z");
expect(ch.unreadCount).toBe(1);
// The next genuinely-new frame must still be counted as new, not
// treated as a second replay because the watermark got rolled back.
updateDmLastMessage(5, 103, "third", "2026-03-28T12:00:03Z");
const after = dmStore.getState().channels[0]!;
expect(after.lastMessageId).toBe(103);
expect(after.unreadCount).toBe(2);
});
});
// ── updateDmLastMessagePreview ──────────────────────────
@@ -27,6 +27,15 @@ describe("isValidHost", () => {
expect(isValidHost("chat.example.com:8443")).toBe(true);
});
it("rejects a host containing an underscore (OC-0322: Rust proxies reject it)", () => {
// http_proxy::validate_remote_host and livekit_proxy::validate_remote_host
// only allow is_ascii_alphanumeric() || '.' | '-' | ':' | '[' | ']' -- JS
// `\w` wrongly includes '_', which would let the client save/accept a
// host neither Rust proxy can ever connect to.
expect(isValidHost("chat_example.com")).toBe(false);
expect(isValidHost("my_server.lan:8443")).toBe(false);
});
it("accepts an IPv4 literal, optionally with a port", () => {
expect(isValidHost("192.168.1.1")).toBe(true);
expect(isValidHost("192.168.1.1:8443")).toBe(true);
+146
View File
@@ -1842,3 +1842,149 @@ describe("E2EEManager", () => {
}
});
});
// ── HP-2 question 4: adversarial membership and key-change rules ───────────
// Each test pins one rule from docs/trust-model.md §"What is end-to-end
// encrypted" that had no dedicated test before HP-2, or records a known gap
// so the fix has a RED waiting for it.
describe("E2EEManager — HP-2 adversarial membership and key-change rules", () => {
beforeEach(() => {
vi.clearAllMocks();
mockMembers.clear();
mockMembers.set(PEER_ID, { identityPublicKey: "peer-identity-b64" });
mockVoiceState.voiceUsers.clear();
vi.mocked(getIdentityPin).mockResolvedValue({ status: "unpinned" });
vi.mocked(storeIdentityPin).mockResolvedValue("stored");
});
it("[HP-2 known gap] a modified server that adds an unknown member at first contact gets the room key wrapped to it", async () => {
// Membership is server-controlled and the client accepts any first-sight
// identity (verifyPeerAnnounce). A server that inserts a member row it
// holds the identity key for, then relays a well-signed announce for it,
// is keyed by the holder like any real peer. The client has no
// independent membership evidence — the voice roster is server state
// too, and here it does not even list the newcomer.
//
// This pins TODAY's behaviour. When authenticated membership (or
// "refuse unrecognised participants") lands, this test goes RED and the
// expectations below invert. docs/trust-model.md §"What beta does not
// claim" names the gap.
const INTRUDER = 99;
mockMembers.set(INTRUDER, { identityPublicKey: "server-supplied-identity-b64" });
expect(mockVoiceState.voiceUsers.size).toBe(0);
const ws = { send: vi.fn() };
const mgr = createManager(ws);
await mgr.setupKeyExchange(true, 1);
ws.send.mockClear();
await mgr.handleAnnounce(INTRUDER, "aW50cnVkZXI=", "sig-the-server-can-make");
// Today: keyed, pinned, verified — the RED of the desired rule (no offer,
// no pin) is recorded in docs/plans/hp-2-scorecard-2026-08-29.md Q4.
const offers = sendsOfType(ws, "voice_e2ee_offer");
expect(offers).toHaveLength(1);
expect((offers[0] as any).payload.target_user_id).toBe(INTRUDER);
expect(mgr.peerPublicKeys.has(INTRUDER)).toBe(true);
expect(storeIdentityPin).toHaveBeenCalledWith(
"localhost:7880",
String(INTRUDER),
"server-supplied-identity-b64",
);
expect(setPeerVerification).toHaveBeenCalledWith(
expect.objectContaining({ userId: INTRUDER, status: "verified" }),
);
});
it("[HP-2] a second device's key, once trusted, overwrites the account pin — the first device then mismatches", async () => {
// Pins are one per account ({host}:{userId}) while identity keys are per
// install (identity.ts; migration 017 holds one identity_public_key per
// user). Trusting device 2 therefore evicts device 1's pin, and device
// 1's next announce is blocked as a mismatch. docs/trust-model.md
// §"What is end-to-end encrypted" states the flip-flop; this pins it.
const DEVICE1 = "device1-identity-b64";
const DEVICE2 = "device2-identity-b64";
vi.mocked(getIdentityPin).mockResolvedValue({ status: "pinned", pin: DEVICE1 });
mockMembers.set(PEER_ID, { identityPublicKey: DEVICE2 }); // server row: last announcer wins
const ws = { send: vi.fn() };
const mgr = createManager(ws);
await mgr.setupKeyExchange(true, 1);
ws.send.mockClear();
// Device 2 announces: pinned key differs → blocked, nothing wrapped.
await mgr.handleAnnounce(PEER_ID, "ZGV2aWNlMg==", "sig2");
expect(setPeerVerification).toHaveBeenLastCalledWith(
expect.objectContaining({ userId: PEER_ID, status: "mismatch" }),
);
expect(sendsOfType(ws, "voice_e2ee_offer")).toHaveLength(0);
// The human clicks "Trust new key": the ONE slot is overwritten and the
// buffered announce replays against the new pin, so device 2 is keyed.
vi.mocked(getIdentityPin).mockResolvedValue({ status: "pinned", pin: DEVICE2 });
expect(await mgr.rePinPeerIdentity(PEER_ID, DEVICE2)).toBe(true);
expect(storeIdentityPin).toHaveBeenCalledTimes(1);
expect(storeIdentityPin).toHaveBeenCalledWith("localhost:7880", String(PEER_ID), DEVICE2);
expect(sendsOfType(ws, "voice_e2ee_offer")).toHaveLength(1);
const importsBefore = vi.mocked(importPublicKey).mock.calls.length;
// Device 1 comes back (the server row carries its key again) and is the
// one that mismatches now — no offer, no key imported, no second pin.
mockMembers.set(PEER_ID, { identityPublicKey: DEVICE1 });
await mgr.handleAnnounce(PEER_ID, "ZGV2aWNlMQ==", "sig1");
expect(setPeerVerification).toHaveBeenLastCalledWith(
expect.objectContaining({ userId: PEER_ID, status: "mismatch" }),
);
expect(sendsOfType(ws, "voice_e2ee_offer")).toHaveLength(1);
expect(vi.mocked(importPublicKey).mock.calls.length).toBe(importsBefore);
expect(storeIdentityPin).toHaveBeenCalledTimes(1);
});
it("[HP-2 / OC-0316] a peer whose socket dropped across a rotation is re-keyed with the rotated key when the server replays its announce", async () => {
// Server side: hub.go re-relays the resumed client's stored announce to
// the channel (TestRegisterNow_ReannouncesOwnKeyOnResume). Holder side,
// pinned here: that replay is a duplicate announce, and the offer it
// triggers must carry the CURRENT room key and epoch — not the key the
// peer held before its outage.
// Round-trip import/export so the replayed announce is recognised as the
// SAME ephemeral key (the duplicate path), not a changed one.
vi.mocked(importPublicKey).mockImplementation(
async (b64: string) => ({ type: `peer-key-${b64}` }) as unknown as CryptoKey,
);
vi.mocked(exportPublicKey).mockImplementation(async (key: CryptoKey) =>
(key as unknown as { type: string }).type.replace("peer-key-", ""),
);
try {
const ws = { send: vi.fn() };
const mgr = createManager(ws);
await mgr.setupKeyExchange(true, 1);
await mgr.handleAnnounce(PEER_ID, "cGVlcg==", "sig");
expect(mgr.epoch).toBe(1);
// Peer's WebSocket drops (media stays up, so no participant-left); the
// periodic rotation fires meanwhile.
await mgr.rotateKeyPeriodically();
expect(mgr.epoch).toBe(2);
const rotatedKey = (mgr as any)._roomKey as Uint8Array;
ws.send.mockClear();
vi.mocked(wrapRoomKey).mockClear();
vi.mocked(importPublicKey).mockClear();
// Peer resumes; the server replays its unchanged announce to us.
await mgr.handleAnnounce(PEER_ID, "cGVlcg==", "sig");
expect(importPublicKey).not.toHaveBeenCalled(); // duplicate, not a new key
const offers = sendsOfType(ws, "voice_e2ee_offer");
expect(offers).toHaveLength(1);
expect((offers[0] as any).payload.target_user_id).toBe(PEER_ID);
expect(wrapRoomKey).toHaveBeenCalledTimes(1);
const [, , wrappedKey, wrappedEpoch] = vi.mocked(wrapRoomKey).mock.calls[0]!;
expect(wrappedKey).toBe(rotatedKey);
expect(wrappedEpoch).toBe(2);
} finally {
vi.mocked(importPublicKey).mockImplementation(
async () => ({ type: "public" }) as unknown as CryptoKey,
);
vi.mocked(exportPublicKey).mockImplementation(async () => "bW9ja2VwaGVtZXJhbA==");
}
});
});
+94
View File
@@ -81,6 +81,14 @@ vi.mock("@lib/profiles", () => ({
// `api.getConfig().host` read (main.ts:776) after a login sets it via
// `api.setConfig({ host })` (main.ts:515).
const mockLogin = vi.fn();
// UpdateNotifier (mounted on the connect page after a protocol-epoch refusal)
// calls checkForUpdate; stub the Tauri-backed updater so the test observes the
// call instead of an invoke() into nothing.
const mockCheckForUpdate = vi.fn();
vi.mock("@lib/updater", () => ({
checkForUpdate: (...args: unknown[]) => mockCheckForUpdate(...args),
downloadAndInstallUpdate: vi.fn(),
}));
const mockApiState = { host: "" };
vi.mock("@lib/api", () => ({
createApiClient: vi.fn(() => ({
@@ -151,6 +159,8 @@ vi.mock("@lib/dispatcher", async () => {
import { mockInvoke, eventHandlers, emitTauriEvent } from "./helpers/ws-mocks";
import { clearAuth } from "@stores/auth.store";
import { deleteCredential } from "@lib/credentials";
import { uiStore, setUpdateRequiredHost } from "@stores/ui.store";
import { loadUserStatus, loadUserStatusOrigin } from "@lib/userStatus";
import { createMainPage } from "@pages/MainPage";
import { setActivePresenceSender, type PresenceSender } from "@lib/presence";
@@ -379,3 +389,87 @@ describe("main.ts connect-page skip-auto-login flag (OC-0028)", () => {
expect(sessionStorage.getItem("owncord:skip-auto-login")).toBeNull();
});
});
describe("main.ts connect page after a protocol-epoch refusal (B2-2)", () => {
it("mounts the update notifier on the connect page so a refused client can update in place", async () => {
await loginAndReachAuthOk("server-a.example:8443", "alex", {
user: { id: 1, username: "alex", avatar: null, role: "member" },
server_name: "Server A",
motd: "",
});
emitTauriEvent("ws-message", JSON.stringify({ type: "ready", payload: {} }));
await vi.advanceTimersByTimeAsync(800);
// The real dispatcher's auth_error handler records the host when the
// server says this client's epoch is too old (dispatcher.test.ts covers
// that); the dispatcher is stubbed here, so set what it would have set,
// then end the session the way auth_error does.
mockCheckForUpdate.mockResolvedValue({ available: false, version: null, body: null });
setUpdateRequiredHost("server-a.example:8443");
clearAuth();
await Promise.resolve();
await Promise.resolve();
await Promise.resolve();
await Promise.resolve();
// The notifier checks 3 s after mount (UpdateNotifier.ts mount()).
await vi.advanceTimersByTimeAsync(3000);
expect(mockCheckForUpdate).toHaveBeenCalledWith("https://server-a.example:8443");
// Consumed on mount: the next connect page must not re-check.
expect(uiStore.getState().updateRequiredHost).toBeNull();
});
it("offers the update when the refusal lands on an already-mounted connect page (first login / startup auto-login)", async () => {
// No session, no overlay: the connect page rendered at startup is the
// one the refusal arrives on, and nothing re-renders it (Codex P1). The
// dispatcher is stubbed here; set what its auth_error handler sets.
mockCheckForUpdate.mockClear();
mockCheckForUpdate.mockResolvedValue({ available: false, version: null, body: null });
setUpdateRequiredHost("server-c.example:8443");
await Promise.resolve();
await Promise.resolve();
await vi.advanceTimersByTimeAsync(3000);
expect(mockCheckForUpdate).toHaveBeenCalledWith("https://server-c.example:8443");
expect(uiStore.getState().updateRequiredHost).toBeNull();
});
it("keeps the stored credential on a protocol-epoch refusal, unlike an ordinary auth_error", async () => {
await loginAndReachAuthOk("server-d.example:8443", "alex", {
user: { id: 1, username: "alex", avatar: null, role: "member" },
server_name: "Server D",
motd: "",
});
emitTauriEvent("ws-message", JSON.stringify({ type: "ready", payload: {} }));
await vi.advanceTimersByTimeAsync(800);
vi.mocked(deleteCredential).mockClear();
// What the dispatcher does on protocol_epoch_unsupported (Codex P2).
clearAuth("protocol_epoch");
await Promise.resolve();
await Promise.resolve();
await Promise.resolve();
await Promise.resolve();
// The token is still valid: the credential stays so the update can
// relaunch into auto-login. (The skip-auto-login flag is set on the same
// path, but the connect page consumes it on mount, so it cannot be read
// back here — the quick-switch test above covers that consumption.)
expect(deleteCredential).not.toHaveBeenCalled();
// Contrast: the same logout for an ordinary reason removes it.
await loginAndReachAuthOk("server-d.example:8443", "alex", {
user: { id: 1, username: "alex", avatar: null, role: "member" },
server_name: "Server D",
motd: "",
});
emitTauriEvent("ws-message", JSON.stringify({ type: "ready", payload: {} }));
await vi.advanceTimersByTimeAsync(800);
clearAuth("user");
await Promise.resolve();
await Promise.resolve();
await Promise.resolve();
await Promise.resolve();
expect(deleteCredential).toHaveBeenCalledWith("server-d.example:8443");
});
});
+1
View File
@@ -204,6 +204,7 @@ function resetStores(): void {
connectionStatus: "disconnected" as const,
transientError: null,
persistentError: null,
updateRequiredHost: null,
collapsedCategories: new Set<string>(),
sidebarMode: "channels" as const,
activeDmUserId: null,
@@ -36,6 +36,7 @@ function resetStores(): void {
connectionStatus: "disconnected" as const,
transientError: null,
persistentError: null,
updateRequiredHost: null,
collapsedCategories: new Set<string>(),
sidebarMode: "channels" as const,
activeDmUserId: null,
@@ -23,6 +23,7 @@ function resetStores(): void {
connectionStatus: "disconnected" as const,
transientError: null,
persistentError: null,
updateRequiredHost: null,
collapsedCategories: new Set<string>(),
sidebarMode: "channels" as const,
activeDmUserId: null,
+7 -17
View File
@@ -88,13 +88,12 @@ const sampleVoiceConfig = {
},
};
const sampleVoiceSpeakers = {
type: "voice_speakers" as const,
payload: {
channel_id: 10,
speakers: [1, 5, 12],
threshold_mode: "forwarding" as const,
},
// Not a wire message — VoiceSpeakersPayload is the argument shape for
// voice.store's setSpeakers, fed by LiveKit's ActiveSpeakersChanged.
const sampleVoiceSpeakers: VoiceSpeakersPayload = {
channel_id: 10,
speakers: [1, 5, 12],
threshold_mode: "forwarding" as const,
};
describe("ServerMessage discriminated union", () => {
@@ -142,7 +141,7 @@ describe("AUDIT Critical: threshold_mode (CRIT-2, CRIT-3)", () => {
});
it("VoiceSpeakersPayload uses threshold_mode NOT mode", () => {
const speakers: VoiceSpeakersPayload = sampleVoiceSpeakers.payload;
const speakers: VoiceSpeakersPayload = sampleVoiceSpeakers;
expect(speakers.threshold_mode).toBe("forwarding");
// @ts-expect-error — mode is not a valid field
expect(speakers.mode).toBeUndefined();
@@ -155,13 +154,6 @@ describe("AUDIT Critical: threshold_mode (CRIT-2, CRIT-3)", () => {
expect(["forwarding", "selective"]).toContain(msg.payload.threshold_mode);
}
});
it("voice_speakers ServerMessage carries threshold_mode", () => {
const msg: ServerMessage = sampleVoiceSpeakers;
if (msg.type === "voice_speakers") {
expect(msg.payload.threshold_mode).toBeDefined();
}
});
});
describe("AUDIT Critical: no channel_focus message type", () => {
@@ -185,12 +177,10 @@ describe("AUDIT Critical: no channel_focus message type", () => {
"voice_state",
"voice_leave",
"voice_config",
"voice_speakers",
"voice_offer",
"voice_answer",
"voice_ice",
"member_join",
"member_leave",
"member_update",
"member_ban",
"server_restart",
+1
View File
@@ -28,6 +28,7 @@ function resetStore(): void {
connectionStatus: "disconnected" as const,
transientError: null,
persistentError: null,
updateRequiredHost: null,
collapsedCategories: new Set<string>(),
sidebarMode: "channels" as const,
activeDmUserId: null,
@@ -57,6 +57,7 @@ function resetStores(): void {
connectionStatus: "disconnected" as const,
transientError: null,
persistentError: null,
updateRequiredHost: null,
collapsedCategories: new Set<string>(),
sidebarMode: "channels" as const,
activeDmUserId: null,
+3 -3
View File
@@ -664,12 +664,12 @@ describe("voice store", () => {
expect(voiceStore.getState().voiceUsers.get(10)?.get(1)?.speaking).toBe(false);
});
it("updates remote users' speaking state from server", () => {
// Server says user 2 is speaking
it("updates remote users' speaking state from LiveKit", () => {
// LiveKit says user 2 is speaking
setSpeakers({ channel_id: 10, speakers: [2], threshold_mode: "forwarding" });
expect(voiceStore.getState().voiceUsers.get(10)?.get(2)?.speaking).toBe(true);
// Server says nobody is speaking — remote user updated, local unchanged
// LiveKit says nobody is speaking — remote user updated, local unchanged
setSpeakers({ channel_id: 10, speakers: [], threshold_mode: "forwarding" });
expect(voiceStore.getState().voiceUsers.get(10)?.get(2)?.speaking).toBe(false);
});
+1
View File
@@ -5,6 +5,7 @@
"moduleResolution": "bundler",
"strict": true,
"noUncheckedIndexedAccess": true,
"noImplicitOverride": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
+9
View File
@@ -25,7 +25,16 @@ linters:
- nestif # deeply nested if-blocks
- dupl # verbatim duplicated blocks
# Silent-drop and sentinel-error classes (added 2026-08-30).
- exhaustive # a switch over an enum-like type with no default that misses a member
- errorlint # errors.Is/As instead of == and type assertions on wrapped errors; %w in Errorf
- durationcheck # time.Duration multiplied by a Duration-typed value, e.g. d*time.Second where d is already a Duration
settings:
exhaustive:
# An explicit default branch is a deliberate catch-all; only switches without
# one, which fall through in silence, must list every member.
default-signifies-exhaustive: true
staticcheck:
checks:
- "all"
+47 -1
View File
@@ -11,7 +11,15 @@ prometheus.
- `db/` hand-written query wrappers; `db/dbgen/` is generated (see `db-change`)
- `cmd/` executable tooling, one `package main` per subdirectory —
`cmd/genprotocol/` regenerates the protocol constants from `protocol/schema.json`,
`cmd/seed/` fills a dev database (`go run ./cmd/seed -confirm-dev`).
`cmd/seed/` fills a dev database (`go run ./cmd/seed -confirm-dev`; add
`-profile alpha` for the deterministic B3-7 dataset behind
`Server/testdata/snapshots/v1.2.0-alpha.4.sqlite` — regenerate that snapshot
only deliberately, per its README),
`cmd/dbinventory/` prints the `db`-importer table for
`docs/architecture/server-boundaries.md` (exits 1 on an unlisted importer).
`cmd/gendocs/` rewrites the route, table and config-key index blocks in
`docs/` and must be run as `go run -tags otel,wazero ./cmd/gendocs`
(`make docs-verify` fails on drift).
`scripts/` holds shell/JS tooling only; no Go entry point lives there
- `admin/` web admin panel · `updater/` self-update + signature verification ·
`plugin/` WASM plugin runtime (`-tags wazero`) · `telemetry/` OTel (`-tags otel`)
@@ -34,3 +42,41 @@ prometheus.
- Prefer the standard library. `syncutil` exists so lock usage is uniform and
detectable; do not hand-roll around it. `Server/invariants/` enforces this
at `go test` time; exceptions are greppable via `grep -rn "invariant:allow" Server/`.
- Only `db/` and `service/` import `db` freely. Any other production file that
imports it needs a row in `invariants/db_import_boundary.go` (`DBImportAllow`)
with a disposition and reason — the B3 inventory, which only shrinks. New
persistence goes behind a service, not into a handler.
- Only `permissions/` calls the raw permission bit helpers (`HasPerm`,
`HasAnyPerm`, `HasServerPerm`, `HasAdmin`, `EffectivePerms`,
`EffectiveChannelPerms`). Everywhere else resolves a `permissions.Subject`
and asks the predicate that owns the property (`CanViewChannel`,
`CanAdmitSession`, `CanSendMessage`, `CanType`, `CanJoinVoice`,
`CanModerateVoice`) — one predicate per security property, so a call site
cannot re-derive half a rule. The residue that predates B2-5 is listed by
symbol in `invariants/authz_chokepoint.go` (`AuthzResidueAllow`) with a
class, a reason, and the exact helper calls it is frozen at — a row is an
inventory, not a licence for the function, so a second raw call inside a
listed one still fails. That list only shrinks too.
## Coverage floor
`coverage-floor.json` holds the aggregate floor and one floor per core package
(`ws`, `service`, `permissions`, `auth`, `db`); `db/dbgen` and `cmd/` are
excluded there because they are generated or entry points, and an exclusion is
spelled without a trailing slash (`cmd`, not `cmd/`). CI checks it on the Linux
leg, after the test steps that share the job. Locally, from `Server/`:
```bash
go test -race ./... -coverprofile=coverage.out -cover
bash scripts/coverage-floor.sh coverage.out
```
**Ratchet.** A floor is the **lowest Linux figure observed** for that package,
truncated to 0.1, **minus 0.1 where the package varied between runs**`ws`
and the aggregate do vary, because a few `-race` branches in `ws` are
timing-dependent and move four or so statements per run. A PR that raises a
figure raises that floor in the same PR; the number in the file is what the
branch measured, not a stale one. Nobody lowers a floor without a hold-point
(HP) entry recording why. Coverage also differs between the Linux and Windows
legs, so the floors track the Linux figure and the check runs only there — on
Windows the script will report `aggregate` and `ws` under floor, by design.
+45 -2
View File
@@ -4,6 +4,10 @@
# test-deadlock Run the deadlock-detection pass CI also runs.
# fuzz Actually fuzz. CI (and plain `go test`) only replays the
# committed seed corpus; this generates new inputs.
# sim Run the seeded hub simulation long: 10,000 steps per seed.
# CI runs its 200 x 20 default through `go test -race ./...`.
# bench-baseline Record a benchmark baseline into docs/plans/. Recorded, not
# gated: no CI step reads it (that gate is B6's).
# cover Per-package coverage (what CI uploads) + a function summary.
# cover-all Cross-package coverage — the honest number. See below.
# sqlc-generate Regenerate type-safe Go from sqlc.yaml (db/dbgen).
@@ -11,13 +15,15 @@
# sqlc-install Install the pinned sqlc version into $GOBIN.
# protocol-generate Regenerate WS message-type constants (Go + TS) from ../protocol/schema.json.
# protocol-verify Fail if the committed protocol constants are stale (used by CI).
# docs-generate Regenerate the route/table/config index blocks in ../docs.
# docs-verify Fail if those generated blocks are stale (used by CI).
# otel-up Start Jaeger + Prometheus for local tracing development.
# otel-down Stop and remove the OTel dev containers.
SQLC_VERSION := $(shell cat sqlc.version)
.PHONY: test test-deadlock fuzz cover cover-all sqlc-install sqlc-generate sqlc-verify \
protocol-generate protocol-verify otel-up otel-down
.PHONY: test test-deadlock fuzz sim bench-baseline cover cover-all sqlc-install sqlc-generate sqlc-verify \
protocol-generate protocol-verify docs-generate docs-verify otel-up otel-down
test:
go test -race -timeout 20m ./...
@@ -48,6 +54,26 @@ fuzz:
done; \
done
# The seeded hub simulation (ws/hub_sim_test.go), long form: the default 20
# seeds at 10,000 steps each instead of the 200 CI runs. A failure prints a
# ready-to-paste OWNCORD_SIM_SEED=... OWNCORD_SIM_STEPS=... replay line.
#
# No make on Windows? OWNCORD_SIM_STEPS=10000 go test -race -count=1 -run '^TestHubSimulation$' ./ws/
SIMSTEPS ?= 10000
sim:
OWNCORD_SIM_STEPS=$(SIMSTEPS) go test -race -count=1 -run '^TestHubSimulation$$' ./ws/
# The six Benchmark* the baseline is made of, six repeats each, through
# benchstat into docs/plans/b3-bench-baseline-<date>.md. Deliberately local and
# deliberately in no workflow: baselines are recorded, not gated (B6 owns the
# gate). The script fails if any expected benchmark name is missing from the
# run, so a rename cannot silently shorten the table.
#
# No make on Windows? ./scripts/bench-baseline.sh from Server/ in Git Bash.
BENCH_COUNT ?= 6
bench-baseline:
BENCH_COUNT=$(BENCH_COUNT) ./scripts/bench-baseline.sh
# Matches the CI invocation. Note that `go test ./... -coverprofile` instruments
# each package only for itself, so a package whose code is mostly exercised
# through another package's tests reports far lower than its real coverage
@@ -91,6 +117,23 @@ protocol-verify:
exit 1 ; \
)
# Route, table and config-key indexes in ../docs. Same shape as the two
# generator checks above: regenerate, then fail on any diff. The tool also
# exits non-zero on its own when a config key is documented nowhere.
#
# -tags otel,wazero is not optional: /metrics mounts only when the otel build
# supplies a Prometheus handler, so the default build would generate an index
# missing a production route. The tool refuses to run without it.
docs-generate:
go run -tags otel,wazero ./cmd/gendocs
docs-verify:
go run -tags otel,wazero ./cmd/gendocs
@git diff --exit-code ../docs/api.md ../docs/schema.md ../docs/server-configuration.md || ( \
echo "ERROR: generated documentation blocks are stale. Run 'make docs-generate' and commit the result." ; \
exit 1 ; \
)
# Phase B Step 8 — local OTel development stack.
# Starts Jaeger (traces) and Prometheus (metrics) in Docker.
# Jaeger UI: http://localhost:16686
+171
View File
@@ -0,0 +1,171 @@
package admin_test
import (
"context"
"encoding/json"
"net/http"
"path/filepath"
"testing"
"github.com/J3vb/OwnCord/Server/admin"
"github.com/J3vb/OwnCord/Server/db"
"github.com/J3vb/OwnCord/Server/db/audittest"
"github.com/J3vb/OwnCord/Server/permissions"
)
// TestAuditCoverage_AdminMutations is the B2-6 audit table for the
// admin-owned security-sensitive mutations: channel permission edits (role
// and user layer), API-token create/revoke, settings changes and the setup
// wizard's config write. The closing subtest runs the detail denylist over
// the recorded corpus (plan docs/plans/b2-protocol-trust-compat-2026-08-28.md
// § B2-6).
func TestAuditCoverage_AdminMutations(t *testing.T) {
// fixture returns a handler, an owner token and a channel id, with the
// recorder installed after seeding.
fixture := func(t *testing.T) (http.Handler, *db.DB, string, int64) {
t.Helper()
database := openAdminTestDB(t)
handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, nil, nil, &mockPermInvalidator{},
newTestModService(database), newTestRoleService(database))
token := createAdminUser(t, database)
chID, err := database.CreateChannel(context.Background(), "secret", "text", "", "", 0)
if err != nil {
t.Fatalf("CreateChannel: %v", err)
}
return handler, database, token, chID
}
rows := []struct {
name string
action string
run func(t *testing.T) (*audittest.Recorder, []string)
}{
{"channel role perms set", "channel_perms_update", func(t *testing.T) (*audittest.Recorder, []string) {
handler, database, token, chID := fixture(t)
rec := audittest.Install(t, database)
w := doRequest(t, handler, http.MethodPut, "/channels/"+itoa(chID)+"/permissions/3", token,
map[string]any{"allow": 0, "deny": permissions.ReadMessages})
if w.Code != http.StatusOK {
t.Fatalf("status = %d; body = %s", w.Code, w.Body.String())
}
return rec, nil
}},
{"channel role perms clear", "channel_perms_clear", func(t *testing.T) (*audittest.Recorder, []string) {
handler, database, token, chID := fixture(t)
if w := doRequest(t, handler, http.MethodPut, "/channels/"+itoa(chID)+"/permissions/3", token,
map[string]any{"allow": 0, "deny": permissions.ReadMessages}); w.Code != http.StatusOK {
t.Fatalf("seed override: status = %d; body = %s", w.Code, w.Body.String())
}
rec := audittest.Install(t, database)
w := doRequest(t, handler, http.MethodDelete, "/channels/"+itoa(chID)+"/permissions/3", token, nil)
if w.Code != http.StatusNoContent && w.Code != http.StatusOK {
t.Fatalf("status = %d; body = %s", w.Code, w.Body.String())
}
return rec, nil
}},
{"channel user perms set", "channel_user_perms_update", func(t *testing.T) (*audittest.Recorder, []string) {
handler, database, token, chID := fixture(t)
target := seedOverrideTarget(t, database, "override-target")
rec := audittest.Install(t, database)
w := doRequest(t, handler, http.MethodPut, "/channels/"+itoa(chID)+"/user-permissions/"+itoa(target), token,
map[string]any{"allow": permissions.ReadMessages, "deny": permissions.SendMessages})
if w.Code != http.StatusOK {
t.Fatalf("status = %d; body = %s", w.Code, w.Body.String())
}
return rec, nil
}},
{"channel user perms clear", "channel_user_perms_clear", func(t *testing.T) (*audittest.Recorder, []string) {
handler, database, token, chID := fixture(t)
target := seedOverrideTarget(t, database, "override-target")
if w := doRequest(t, handler, http.MethodPut, "/channels/"+itoa(chID)+"/user-permissions/"+itoa(target), token,
map[string]any{"allow": permissions.ReadMessages, "deny": permissions.SendMessages}); w.Code != http.StatusOK {
t.Fatalf("seed override: status = %d; body = %s", w.Code, w.Body.String())
}
rec := audittest.Install(t, database)
w := doRequest(t, handler, http.MethodDelete, "/channels/"+itoa(chID)+"/user-permissions/"+itoa(target), token, nil)
if w.Code != http.StatusNoContent && w.Code != http.StatusOK {
t.Fatalf("status = %d; body = %s", w.Code, w.Body.String())
}
return rec, nil
}},
{"api token create", "api_token_create", func(t *testing.T) (*audittest.Recorder, []string) {
handler, database, token, _ := fixture(t)
rec := audittest.Install(t, database)
w := doRequest(t, handler, http.MethodPost, "/tokens", token,
map[string]any{"label": "ci bot", "username": "adminuser"})
if w.Code != http.StatusCreated {
t.Fatalf("status = %d; body = %s", w.Code, w.Body.String())
}
var resp struct {
Token string `json:"token"`
}
_ = json.Unmarshal(w.Body.Bytes(), &resp)
return rec, []string{resp.Token, token}
}},
{"api token revoke", "api_token_revoke", func(t *testing.T) (*audittest.Recorder, []string) {
handler, database, token, _ := fixture(t)
w := doRequest(t, handler, http.MethodPost, "/tokens", token,
map[string]any{"label": "ci bot", "username": "adminuser"})
if w.Code != http.StatusCreated {
t.Fatalf("seed token: status = %d; body = %s", w.Code, w.Body.String())
}
var resp struct {
ID int64 `json:"id"`
Token string `json:"token"`
}
_ = json.Unmarshal(w.Body.Bytes(), &resp)
rec := audittest.Install(t, database)
if w := doRequest(t, handler, http.MethodDelete, "/tokens/"+itoa(resp.ID), token, nil); w.Code != http.StatusNoContent {
t.Fatalf("status = %d; body = %s", w.Code, w.Body.String())
}
return rec, []string{resp.Token, token}
}},
{"setting change", "setting_change", func(t *testing.T) (*audittest.Recorder, []string) {
handler, database, token, _ := fixture(t)
rec := audittest.Install(t, database)
w := doRequest(t, handler, http.MethodPatch, "/settings", token,
map[string]string{"motd": "welcome 4d0d1405"})
if w.Code != http.StatusOK {
t.Fatalf("status = %d; body = %s", w.Code, w.Body.String())
}
return rec, []string{"welcome 4d0d1405", token}
}},
{"config write (setup wizard)", "config_write", func(t *testing.T) (*audittest.Recorder, []string) {
database := openAdminTestDB(t)
cfgPath := filepath.Join(t.TempDir(), "config.yaml")
handler := wizardHandler(t, database, cfgPath, make(chan string, 1))
rec := audittest.Install(t, database)
const password = "SecurePass123!"
w := doRequest(t, handler, http.MethodPost, "/setup", "", map[string]any{
"username": "owner",
"password": password,
"wizard": map[string]any{"server_name": "Audit Server"},
})
if w.Code != http.StatusCreated {
t.Fatalf("status = %d; body = %s", w.Code, w.Body.String())
}
var resp struct {
Token string `json:"token"`
}
_ = json.Unmarshal(w.Body.Bytes(), &resp)
return rec, []string{password, resp.Token}
}},
}
var corpus []db.AuditEntry
var secrets []string
for _, row := range rows {
t.Run(row.name, func(t *testing.T) {
rec, s := row.run(t)
rec.Wait(t, row.action)
corpus = append(corpus, rec.Entries()...)
secrets = append(secrets, s...)
})
}
t.Run("detail denylist", func(t *testing.T) {
if len(corpus) == 0 {
t.Fatal("no audit entries recorded")
}
audittest.AssertSafeDetails(t, corpus, secrets...)
})
}
+10 -1
View File
@@ -1,6 +1,7 @@
package admin
import (
"context"
"encoding/json"
"log/slog"
"net/http"
@@ -58,7 +59,15 @@ func queryInt(r *http.Request, key string, defaultVal, minVal, maxVal int) int {
// context by adminAuthMiddleware. Returns 0 if called outside that middleware
// (should not happen in production).
func actorFromContext(r *http.Request) int64 {
user, ok := r.Context().Value(adminUserKey).(*db.User)
return ActorIDFromContext(r.Context())
}
// ActorIDFromContext returns the admin principal's user ID that
// RequireAdminAuth stored in ctx, or 0 outside that middleware. Exported for
// handlers mounted behind RequireAdminAuth from other packages (the plugin
// admin surface in api) so their audit rows name the real actor.
func ActorIDFromContext(ctx context.Context) int64 {
user, ok := ctx.Value(adminUserKey).(*db.User)
if !ok || user == nil {
return 0
}
+9 -1
View File
@@ -128,7 +128,15 @@ func ownerOnlyMiddleware(database *db.DB, next http.Handler) http.Handler {
}
role, err := database.GetRoleByID(r.Context(), user.RoleID)
if err != nil || role == nil {
if err != nil {
// A read fault is an outage, not a missing role: answering 403
// would tell the Owner they lack the Owner role. Mirror the
// perimeter's contract above — log it, report 503 (OC-0345).
slog.ErrorContext(r.Context(), "admin: owner role lookup failed", "error", err)
writeErr(w, http.StatusServiceUnavailable, "SERVICE_UNAVAILABLE", "authorization service temporarily unavailable")
return
}
if role == nil {
writeErr(w, http.StatusForbidden, "FORBIDDEN", "role not found")
return
}
+49
View File
@@ -522,3 +522,52 @@ func TestSpawnDetached_CommandConstruction(t *testing.T) {
t.Error("cmd.Stderr should not be nil")
}
}
// TestOwnerOnlyMiddleware_RoleLookupFailureIs503 pins OC-0345: a database
// fault on the owner gate's role read is an outage, not a missing role, so the
// Owner must get 503 SERVICE_UNAVAILABLE — never the 403 "role not found" a
// genuinely absent role earns. Whitebox on purpose: through the full stack
// adminAuthMiddleware reads the role first and would answer its own 503, so
// the branch under test would never run.
func TestOwnerOnlyMiddleware_RoleLookupFailureIs503(t *testing.T) {
database := openWhiteboxTestDB(t)
uid, err := database.CreateUser(context.Background(), "ownerfault", "$2a$12$x", 1)
if err != nil {
t.Fatalf("CreateUser: %v", err)
}
user, err := database.GetUserByID(context.Background(), uid)
if err != nil || user == nil {
t.Fatalf("GetUserByID: %v", err)
}
// Every query against roles now fails with a non-sentinel error.
if _, err := database.ExecContext(context.Background(), `ALTER TABLE roles RENAME TO roles_gone`); err != nil {
t.Fatalf("hide roles: %v", err)
}
reached := false
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
reached = true
w.WriteHeader(http.StatusOK)
})
handler := ownerOnlyMiddleware(database, next)
ctx := context.WithValue(context.Background(), adminUserKey, user)
req := httptest.NewRequest(http.MethodPost, "/backup", nil).WithContext(ctx)
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
if reached {
t.Error("next handler was reached although the role could not be read")
}
if w.Code != http.StatusServiceUnavailable {
t.Errorf("status = %d, want 503 (a role read fault is not a missing role)", w.Code)
}
var resp map[string]string
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if resp["error"] != "SERVICE_UNAVAILABLE" {
t.Errorf("error = %q, want SERVICE_UNAVAILABLE", resp["error"])
}
}
+217
View File
@@ -0,0 +1,217 @@
package api_test
import (
"encoding/json"
"net/http"
"os"
"path/filepath"
"reflect"
"regexp"
"slices"
"strings"
"testing"
"github.com/J3vb/OwnCord/Server/api"
"github.com/J3vb/OwnCord/Server/config"
"github.com/J3vb/OwnCord/Server/db"
"github.com/J3vb/OwnCord/Server/internal/app"
"github.com/go-chi/chi/v5"
)
// absentPattern names the feature families OwnCord promises not to have:
// no federation between servers, no server directory or discovery, no
// public listing. docs/trust-model.md states the promise; the tests in this
// file are the proof at the three boundaries a new feature has to cross —
// an HTTP route, a WebSocket message type, a configuration key. They pin
// vocabulary, not semantics: a feature smuggled under a neutral name passes,
// which is why trust-model.md also carries the outbound-host table B6's
// network capture checks. A hit here is a design change that needs that
// document updated first, not a silent addition.
var absentPattern = regexp.MustCompile(`(?i)federat|directory|discover|listing`)
// fullRouter builds the production router with every optional route family
// switched on (uploads, voice, GIF proxy) so the walk below sees the whole
// tree, not the bare-config subset setupRouter mounts.
func fullRouter(t *testing.T) http.Handler {
t.Helper()
database, err := db.Open(":memory:")
if err != nil {
t.Fatalf("db.Open error: %v", err)
}
if err := db.Migrate(database); err != nil {
t.Fatalf("db.Migrate error: %v", err)
}
t.Cleanup(func() { _ = database.Close() })
dir := t.TempDir()
cfg := &config.Config{
Server: config.ServerConfig{Name: "Test Server", Port: 8443, DataDir: dir},
Upload: config.UploadConfig{MaxSizeMB: 1, StorageDir: filepath.Join(dir, "uploads")},
Voice: config.VoiceConfig{
LiveKitAPIKey: "absence-test-key",
LiveKitAPISecret: "absence-test-secret-at-least-32-chars-long",
LiveKitURL: "ws://127.0.0.1:7880",
},
GIF: config.GIFConfig{APIKey: "absence-test"},
}
rt := app.StartRuntime(cfg, database, nil)
handler, cleanup := api.NewRouter(cfg, database, "test", nil, nil, rt)
t.Cleanup(cleanup)
return handler
}
// TestAbsenceContract_NoFederationDirectoryOrListingRoutes walks every route
// the production router mounts (admin and plugin subrouters included) and
// fails on the first one whose path names federation, a directory, discovery
// or a listing. BPR-040/082/083.
func TestAbsenceContract_NoFederationDirectoryOrListingRoutes(t *testing.T) {
handler := fullRouter(t)
routes, ok := handler.(chi.Routes)
if !ok {
t.Fatalf("NewRouter returned %T, want a chi.Routes so the mounted tree can be walked", handler)
}
var total, adminRoutes int
var hits []string
walk := func(method, route string, _ http.Handler, _ ...func(http.Handler) http.Handler) error {
total++
if strings.HasPrefix(route, "/admin/") {
adminRoutes++
}
if absentPattern.MatchString(route) {
hits = append(hits, method+" "+route)
}
return nil
}
if err := chi.Walk(routes, walk); err != nil {
t.Fatalf("chi.Walk: %v", err)
}
// Guard against a vacuous pass: the walk must have seen the real tree,
// including the mounted admin subrouter, not an empty or wrapped mux.
if total < 100 {
t.Fatalf("walked only %d routes; expected the full production router (>= 100)", total)
}
if adminRoutes == 0 {
t.Fatal("walk saw no /admin/ routes; the mounted admin subrouter was not traversed")
}
if len(hits) > 0 {
t.Fatalf("routes matching %q must not exist (see docs/trust-model.md, \"What OwnCord does not have\"):\n %s",
absentPattern, strings.Join(hits, "\n "))
}
}
// TestAbsenceContract_NoFederationDirectoryOrListingWireTypes reads the
// protocol schema (the source of truth ws/message_types.go is generated from)
// and fails on any WebSocket message type in either direction whose wire name
// matches the pattern. A federation or directory feature carried entirely by
// new frames would otherwise pass the route test above.
func TestAbsenceContract_NoFederationDirectoryOrListingWireTypes(t *testing.T) {
raw, err := os.ReadFile(filepath.Join("..", "..", "protocol", "schema.json"))
if err != nil {
t.Fatalf("read protocol/schema.json: %v", err)
}
var schema struct {
ClientToServer []struct {
Wire string `json:"wire"`
} `json:"client_to_server"`
ServerToClient []struct {
Wire string `json:"wire"`
} `json:"server_to_client"`
}
if err := json.Unmarshal(raw, &schema); err != nil {
t.Fatalf("parse protocol/schema.json: %v", err)
}
var total int
var hits []string
for _, dir := range [][]struct {
Wire string `json:"wire"`
}{schema.ClientToServer, schema.ServerToClient} {
for _, m := range dir {
total++
if absentPattern.MatchString(m.Wire) {
hits = append(hits, m.Wire)
}
}
}
if total < 40 {
t.Fatalf("read only %d wire types from the schema; expected the full protocol (>= 40)", total)
}
if len(hits) > 0 {
t.Fatalf("WebSocket message types matching %q must not exist (see docs/trust-model.md, \"What OwnCord does not have\"):\n %s",
absentPattern, strings.Join(hits, "\n "))
}
}
// TestAbsenceContract_NoFederationDirectoryOrListingConfigKeys walks the
// koanf tags of config.Config and fails on any dotted key matching the
// pattern. A feature that needs a peer list, a directory URL or a discovery
// toggle has to surface here, so this is the third boundary.
func TestAbsenceContract_NoFederationDirectoryOrListingConfigKeys(t *testing.T) {
// On-disk paths that happen to contain a pattern word. Each entry names
// a filesystem location, never a network one; adding to this list needs
// the same justification as a route hit.
allowed := map[string]string{
"plugins.directory": "the on-disk plugin directory (Server/config/config.go PluginsConfig.Directory)",
}
keys := koanfKeys(reflect.TypeFor[config.Config](), "")
if len(keys) < 30 {
t.Fatalf("collected only %d config keys; expected the full config surface (>= 30)", len(keys))
}
var hits []string
for _, k := range keys {
if !absentPattern.MatchString(k) {
continue
}
if _, ok := allowed[k]; ok {
continue
}
hits = append(hits, k)
}
if len(hits) > 0 {
t.Fatalf("config keys matching %q must not exist (see docs/trust-model.md, \"What OwnCord does not have\"):\n %s",
absentPattern, strings.Join(hits, "\n "))
}
for k := range allowed {
if !slices.Contains(keys, k) {
t.Errorf("allowlisted config key %q no longer exists; drop it from the allowlist", k)
}
}
}
// koanfKeys returns every dotted koanf key reachable from t, recursing into
// nested structs the same way koanf unmarshals them.
func koanfKeys(t reflect.Type, prefix string) []string {
for t.Kind() == reflect.Ptr {
t = t.Elem()
}
if t.Kind() != reflect.Struct {
return nil
}
var keys []string
for f := range t.Fields() {
tag, ok := f.Tag.Lookup("koanf")
if !ok || tag == "" || tag == "-" {
continue
}
key := tag
if prefix != "" {
key = prefix + "." + tag
}
ft := f.Type
for ft.Kind() == reflect.Ptr {
ft = ft.Elem()
}
if ft.Kind() == reflect.Struct {
keys = append(keys, koanfKeys(ft, key)...)
continue
}
keys = append(keys, key)
}
return keys
}
+101
View File
@@ -0,0 +1,101 @@
package api
import (
"context"
"net/http"
"net/http/httptest"
"strconv"
"testing"
"time"
"github.com/J3vb/OwnCord/Server/db"
"github.com/J3vb/OwnCord/Server/db/audittest"
)
// TestAuditCoverage_PluginLifecycle is the plugin half of the B2-6 audit
// table: install and uninstall each emit an audit entry, and neither detail
// carries anything from the archive beyond the plugin name.
func TestAuditCoverage_PluginLifecycle(t *testing.T) {
install := func(t *testing.T) (http.Handler, *db.DB, int64) {
t.Helper()
reg, mem := newTestPluginRegistryWithStore(t)
h := NewPluginAdminHandler(reg, mem, mem)
body, contentType := buildZipUpload(t, validPluginZip(t))
req := httptest.NewRequest("POST", "/install", body)
req.Header.Set("Content-Type", contentType)
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusCreated {
t.Fatalf("install: status = %d; body = %s", rec.Code, rec.Body.String())
}
row, err := mem.GetPluginByName(context.Background(), "hello")
if err != nil || row == nil {
t.Fatalf("GetPluginByName: %v", err)
}
return h, mem, row.ID
}
rows := []struct {
name string
action string
run func(t *testing.T) *audittest.Recorder
}{
{"plugin install", "plugin_install", func(t *testing.T) *audittest.Recorder {
reg, mem := newTestPluginRegistryWithStore(t)
h := NewPluginAdminHandler(reg, mem, mem)
rec := audittest.Install(t, mem)
body, contentType := buildZipUpload(t, validPluginZip(t))
req := httptest.NewRequest("POST", "/install", body)
req.Header.Set("Content-Type", contentType)
w := httptest.NewRecorder()
h.ServeHTTP(w, req)
if w.Code != http.StatusCreated {
t.Fatalf("install: status = %d; body = %s", w.Code, w.Body.String())
}
return rec
}},
{"plugin uninstall", "plugin_uninstall", func(t *testing.T) *audittest.Recorder {
h, mem, id := install(t)
rec := audittest.Install(t, mem)
w := httptest.NewRecorder()
h.ServeHTTP(w, httptest.NewRequest("DELETE", "/"+strconv.FormatInt(id, 10), nil))
if w.Code != http.StatusNoContent {
t.Fatalf("uninstall: status = %d; body = %s", w.Code, w.Body.String())
}
return rec
}},
}
var corpus []db.AuditEntry
for _, row := range rows {
t.Run(row.name, func(t *testing.T) {
rec := row.run(t)
rec.Wait(t, row.action)
corpus = append(corpus, rec.Entries()...)
})
}
t.Run("detail denylist", func(t *testing.T) {
if len(corpus) == 0 {
t.Fatal("no audit entries recorded")
}
audittest.AssertSafeDetails(t, corpus)
})
}
// TestPluginsHandlerUninstallUnknownID pins Codex's P2 on #1441: the registry
// treats an unknown id as an idempotent no-op, so the handler must answer 404
// and write no plugin_uninstall row for a plugin that never existed.
func TestPluginsHandlerUninstallUnknownID(t *testing.T) {
reg, mem := newTestPluginRegistryWithStore(t)
h := NewPluginAdminHandler(reg, mem, mem)
rec := audittest.Install(t, mem)
w := httptest.NewRecorder()
h.ServeHTTP(w, httptest.NewRequest("DELETE", "/999", nil))
if w.Code != http.StatusNotFound {
t.Fatalf("status = %d, want 404; body = %s", w.Code, w.Body.String())
}
time.Sleep(20 * time.Millisecond)
if got := rec.Entries(); len(got) != 0 {
t.Fatalf("unknown plugin must not audit; got %v", got)
}
}
+104
View File
@@ -0,0 +1,104 @@
package api_test
import (
"context"
"encoding/json"
"net/http"
"testing"
"time"
"github.com/J3vb/OwnCord/Server/auth"
"github.com/J3vb/OwnCord/Server/db"
"github.com/J3vb/OwnCord/Server/db/audittest"
)
// TestAuditCoverage_APIMutations is the B2-6 audit table for the
// api-owned security-sensitive mutations (TOTP enrolment and removal,
// account self-deletion). The plugin lifecycle rows live in
// audit_coverage_plugin_test.go because their fixtures are package-internal.
// The closing subtest runs the detail denylist over the recorded corpus
// (plan docs/plans/b2-protocol-trust-compat-2026-08-28.md § B2-6).
func TestAuditCoverage_APIMutations(t *testing.T) {
const password = "Password1!"
// enrolTOTP runs enable+confirm for token and returns the TOTP secret
// and the confirmation code, both fixture secrets for the denylist.
enrolTOTP := func(t *testing.T, router http.Handler, token string) (secret, code string) {
t.Helper()
rr := postJSONWithToken(t, router, "/api/v1/users/me/totp/enable", token,
map[string]string{"password": password})
if rr.Code != http.StatusOK {
t.Fatalf("enable: status = %d; body = %s", rr.Code, rr.Body.String())
}
var enableResp map[string]any
_ = json.NewDecoder(rr.Body).Decode(&enableResp)
secret = extractSecretFromURI(t, enableResp["qr_uri"].(string))
code, _ = auth.GenerateTOTPCode(secret, time.Now().UTC())
rr = postJSONWithToken(t, router, "/api/v1/users/me/totp/confirm", token,
map[string]string{"password": password, "code": code})
if rr.Code != http.StatusNoContent {
t.Fatalf("confirm: status = %d; body = %s", rr.Code, rr.Body.String())
}
return secret, code
}
rows := []struct {
name string
action string
run func(t *testing.T) (*audittest.Recorder, []string)
}{
{"totp enable", "totp_enabled", func(t *testing.T) (*audittest.Recorder, []string) {
database := newAuthTestDB(t)
router := buildAuthRouter(database, auth.NewRateLimiter())
token := loginAndGetToken(t, router, database, "totpenable", 4)
rec := audittest.Install(t, database)
secret, code := enrolTOTP(t, router, token)
return rec, []string{password, token, secret, code}
}},
{"totp disable", "totp_disabled", func(t *testing.T) (*audittest.Recorder, []string) {
database := newAuthTestDB(t)
router := buildAuthRouter(database, auth.NewRateLimiter())
token := loginAndGetToken(t, router, database, "totpdisable", 4)
secret, code := enrolTOTP(t, router, token)
rec := audittest.Install(t, database)
rr := deleteWithToken(t, router, "/api/v1/users/me/totp", token,
map[string]string{"password": password})
if rr.Code != http.StatusNoContent {
t.Fatalf("disable: status = %d; body = %s", rr.Code, rr.Body.String())
}
return rec, []string{password, token, secret, code}
}},
{"account delete", "account_deleted", func(t *testing.T) (*audittest.Recorder, []string) {
database := newAuthTestDB(t)
router := buildAuthRouter(database, auth.NewRateLimiter())
hash, _ := auth.HashPassword(password)
uid, _ := database.CreateUser(context.Background(), "selfdelete", hash, 4)
token, _ := auth.GenerateToken()
_, _ = database.CreateSession(context.Background(), uid, auth.HashToken(token), "test", "127.0.0.1")
rec := audittest.Install(t, database)
rr := deleteJSONWithToken(t, router, "/api/v1/auth/account", token,
map[string]string{"password": password})
if rr.Code != http.StatusNoContent {
t.Fatalf("delete account: status = %d; body = %s", rr.Code, rr.Body.String())
}
return rec, []string{password, hash, token}
}},
}
var corpus []db.AuditEntry
var secrets []string
for _, row := range rows {
t.Run(row.name, func(t *testing.T) {
rec, s := row.run(t)
rec.Wait(t, row.action)
corpus = append(corpus, rec.Entries()...)
secrets = append(secrets, s...)
})
}
t.Run("detail denylist", func(t *testing.T) {
if len(corpus) == 0 {
t.Fatal("no audit entries recorded")
}
audittest.AssertSafeDetails(t, corpus, secrets...)
})
}
File diff suppressed because it is too large Load Diff
+53
View File
@@ -0,0 +1,53 @@
package api
import (
"context"
"github.com/J3vb/OwnCord/Server/service"
)
// AuthService is the consumer-owned interface behind the auth routes: every
// call auth_handler.go and totp_handler.go make below the transport layer, and
// nothing more (layout-refactor supplement, "interface beside the consumer").
// service.AuthService implements it. A handler decodes and validates the
// request, calls one method, and encodes either the result or the returned
// service.Err* value; every lockout, password compare, sentinel mapping,
// audit write and broadcast lives behind these nine methods.
//
// Nine methods stand in for the ten *db.DB methods, two db functions and two
// db sentinels the two handlers called directly at 71d867cb
// (docs/architecture/server-boundaries.md, "Auth slice").
type AuthService interface {
// RegistrationPolicy reports whether registration is permitted right now.
// It is the one gate that runs before the body is read: two
// characterization rows pin a closed server's 403 ahead of any
// credential, malformed body included.
RegistrationPolicy(ctx context.Context) error
// Register consumes the invite, creates the account and issues a session.
// in is already validated (see service.RegisterInput).
Register(ctx context.Context, in service.RegisterInput) (*service.AuthResult, error)
// Login runs the lockout gates and the constant-time password check, then
// issues a session or, for an enrolled account, starts a two-factor
// challenge.
Login(ctx context.Context, in service.LoginInput) (*service.AuthResult, error)
// VerifyTOTP completes a challenge Login started and issues the session,
// bound to the login request's device and IP rather than this one's.
VerifyTOTP(ctx context.Context, partialToken, code string) (*service.AuthResult, error)
// Logout revokes p.Session server-side and clears the custom status.
Logout(ctx context.Context, p service.Principal) error
// DeleteAccount confirms the password, anonymises and bans the account and
// broadcasts member_ban. ip is only logged and audited.
DeleteAccount(ctx context.Context, p service.Principal, password, ip string) error
// EnableTOTP confirms the password and stages a pending secret; qrURI is
// the enrolment payload for the authenticator app.
EnableTOTP(ctx context.Context, p service.Principal, password string) (qrURI string, err error)
// ConfirmTOTP verifies code against the pending secret, persists it and
// revokes the caller's other sessions.
ConfirmTOTP(ctx context.Context, p service.Principal, password, code string) (*service.TOTPChangeResult, error)
// DisableTOTP confirms the password, refuses while the server requires
// 2FA, clears the secret and revokes the caller's other sessions.
DisableTOTP(ctx context.Context, p service.Principal, password string) (*service.TOTPChangeResult, error)
}
// The production implementation satisfies the interface it was extracted for.
var _ AuthService = (*service.AuthService)(nil)
+130 -540
View File
@@ -4,7 +4,6 @@ import (
"context"
"encoding/json"
"errors"
"fmt"
"log/slog"
"net/http"
"strings"
@@ -12,8 +11,6 @@ import (
"unicode/utf8"
"github.com/J3vb/OwnCord/Server/auth"
"github.com/J3vb/OwnCord/Server/db"
"github.com/J3vb/OwnCord/Server/permissions"
"github.com/J3vb/OwnCord/Server/service"
"github.com/go-chi/chi/v5"
)
@@ -21,16 +18,9 @@ import (
// maxLoginUsernameLen bounds the username accepted by handleLogin, mirroring
// auth.ValidateUsername's 32-rune cap on registered usernames. Enforced
// before the value is ever used to build a RateLimiter map key — see the
// check in handleLogin for why.
// check in loginReadRequest for why.
const maxLoginUsernameLen = 32
// genericAuthError is returned for all login/register failures to avoid
// revealing whether a username exists.
var genericAuthError = errorResponse{
Error: "INVALID_CREDENTIALS",
Message: "invalid invite or credentials",
}
// registerRequest is the JSON body for POST /api/v1/auth/register.
type registerRequest struct {
Username string `json:"username"`
@@ -44,25 +34,6 @@ type loginRequest struct {
Password string `json:"password"`
}
// userResponse is the user shape included in auth responses.
type userResponse struct {
ID int64 `json:"id"`
Username string `json:"username"`
Avatar string `json:"avatar,omitempty"`
// DisplayName and About are always present (null = unset) so the settings
// form can tell "cleared" from "the server does not know this field".
DisplayName *string `json:"display_name"`
About *string `json:"about"`
// CustomStatus is the user's own free-text status line.
CustomStatus *string `json:"custom_status"`
// Status is the user's own true status, invisible included. This response
// only ever describes the caller, so there is nothing to hide from them.
Status string `json:"status"`
RoleID int64 `json:"role_id"`
TOTPEnabled bool `json:"totp_enabled"`
CreatedAt string `json:"created_at"`
}
// authSuccessResponse is returned on successful login/register.
type authSuccessResponse struct {
Token string `json:"token,omitempty"`
@@ -71,77 +42,56 @@ type authSuccessResponse struct {
User *userResponse `json:"user,omitempty"`
}
// AuthBroadcaster is the interface handleDeleteAccount uses to notify
// connected WebSocket clients that an account is gone. Satisfied by *ws.Hub
// (which already implements BroadcastMemberBan for the admin ban path this
// mirrors).
type AuthBroadcaster interface {
BroadcastMemberBan(userID int64)
}
// MountAuthRoutes registers all auth endpoints on the given router.
// Rate limiters are applied per-endpoint as specified. trustedProxies is the
// list of CIDRs whose X-Forwarded-For / X-Real-IP headers are honoured for
// rate-limiting IP resolution. totpKey is the AES-256 key used to encrypt
// TOTP secrets at rest (M1 security hardening).
//
// broadcaster is variadic and optional: MountAuthRoutes is called before the
// hub exists (router.go mounts auth routes first, and the hub needs the
// router to register its own webhook route), so a caller that cannot supply
// one yet may omit it entirely and self-deletion simply sends no event,
// exactly like today. A caller mounted after hub creation should pass it so
// DELETE /api/v1/auth/account can broadcast the same member_ban event the
// admin ban path already sends for the identical anonymise-and-ban DB state.
func MountAuthRoutes(r chi.Router, database *db.DB, limiter *auth.RateLimiter, trustedProxies []string, totpKey []byte, broadcaster ...AuthBroadcaster) {
var ab AuthBroadcaster
if len(broadcaster) > 0 {
ab = broadcaster[0]
}
registerLimiter := limiter
loginLimiter := limiter
partialStore := auth.NewPartialAuthStore(partialAuthStoreTTL)
pendingTOTPStore := auth.NewPendingTOTPStore(pendingTOTPStoreTTL)
usedTOTPCodes := auth.NewUsedTOTPCodeStore()
// MountAuthRoutes registers all auth endpoints on the given router. svc owns
// every decision below the transport (service.AuthService in production, see
// AuthService); requireAuth is the AuthMiddleware the authenticated routes
// mount, built by the caller because it needs the database handle this file
// no longer sees. Rate limiters are applied per-endpoint as specified;
// trustedProxies is the list of CIDRs whose X-Forwarded-For / X-Real-IP
// headers are honoured for rate-limiting IP resolution.
func MountAuthRoutes(r chi.Router, svc AuthService, requireAuth func(http.Handler) http.Handler, limiter *auth.RateLimiter, trustedProxies []string) {
r.Route("/api/v1/auth", func(r chi.Router) {
r.With(RateLimitMiddleware(registerLimiter, "register:", scaledAuthLimit(registerRateLimitPerMinute), time.Minute, trustedProxies)).
Post("/register", handleRegister(database, trustedProxies))
r.With(RateLimitMiddleware(limiter, "register:", scaledAuthLimit(registerRateLimitPerMinute), time.Minute, trustedProxies)).
Post("/register", handleRegister(svc, trustedProxies))
r.With(RateLimitMiddleware(loginLimiter, "login:", scaledAuthLimit(loginRateLimitPerMinute), time.Minute, trustedProxies)).
Post("/login", handleLogin(database, limiter, partialStore, trustedProxies))
r.With(RateLimitMiddleware(limiter, "login:", scaledAuthLimit(loginRateLimitPerMinute), time.Minute, trustedProxies)).
Post("/login", handleLogin(svc, trustedProxies))
r.With(RateLimitMiddleware(limiter, "totp_verify:", scaledAuthLimit(verifyTOTPRateLimitPerMinute), time.Minute, trustedProxies)).
Post("/verify-totp", handleVerifyTOTP(database, partialStore, limiter, usedTOTPCodes, totpKey))
Post("/verify-totp", handleVerifyTOTP(svc))
r.With(AuthMiddleware(database)).
Post("/logout", handleLogout(database))
r.With(requireAuth).
Post("/logout", handleLogout(svc))
r.With(AuthMiddleware(database)).
r.With(requireAuth).
Get("/me", handleMe())
r.With(AuthMiddleware(database),
r.With(requireAuth,
RateLimitMiddleware(limiter, "del_account:", scaledAuthLimit(sensitiveEndpointRateLimitPerMinute), time.Minute, trustedProxies)).
Delete("/account", handleDeleteAccount(database, limiter, ab))
Delete("/account", handleDeleteAccount(svc))
})
r.With(AuthMiddleware(database),
r.With(requireAuth,
RateLimitMiddleware(limiter, "totp:", scaledAuthLimit(sensitiveEndpointRateLimitPerMinute), time.Minute, trustedProxies)).
Post("/api/v1/users/me/totp/enable", handleEnableTOTP(pendingTOTPStore, limiter))
Post("/api/v1/users/me/totp/enable", handleEnableTOTP(svc))
r.With(AuthMiddleware(database),
r.With(requireAuth,
RateLimitMiddleware(limiter, "totp:", scaledAuthLimit(sensitiveEndpointRateLimitPerMinute), time.Minute, trustedProxies)).
Post("/api/v1/users/me/totp/confirm", handleConfirmTOTP(database, pendingTOTPStore, usedTOTPCodes, limiter, totpKey))
Post("/api/v1/users/me/totp/confirm", handleConfirmTOTP(svc))
r.With(AuthMiddleware(database),
r.With(requireAuth,
RateLimitMiddleware(limiter, "totp:", scaledAuthLimit(sensitiveEndpointRateLimitPerMinute), time.Minute, trustedProxies)).
Delete("/api/v1/users/me/totp", handleDisableTOTP(database, pendingTOTPStore, limiter))
Delete("/api/v1/users/me/totp", handleDisableTOTP(svc))
}
// handleRegister processes POST /api/v1/auth/register.
func handleRegister(database *db.DB, trustedProxies []string) http.HandlerFunc {
func handleRegister(svc AuthService, trustedProxies []string) http.HandlerFunc {
proxyNets := parseCIDRList(trustedProxies) // W3-3a: parse once at construction
return func(w http.ResponseWriter, r *http.Request) {
if !registerPolicyGate(w, r, database) {
// The policy gate runs before any credential is read: a closed
// server refuses even a malformed body with the policy's 403.
if err := svc.RegistrationPolicy(r.Context()); err != nil {
writeAuthError(r.Context(), w, err)
return
}
@@ -150,116 +100,21 @@ func handleRegister(database *db.DB, trustedProxies []string) http.HandlerFunc {
return
}
// Hash password before consuming the invite so that a hashing failure
// does not burn a valid invite code.
hash, err := auth.HashPassword(req.Password)
res, err := svc.Register(r.Context(), service.RegisterInput{
Username: req.Username,
Password: req.Password,
InviteCode: req.InviteCode,
Device: truncateDevice(r.Header.Get("User-Agent")),
IP: clientIPWithProxies(r, proxyNets),
})
if err != nil {
writeJSON(w, http.StatusInternalServerError, errorResponse{
Error: "INTERNAL_ERROR",
Message: "failed to process registration",
})
writeAuthError(r.Context(), w, err)
return
}
// Atomically consume the invite and create the user so failed
// registrations do not burn a valid invite code.
uid, err := database.CreateUserWithInvite(r.Context(), req.Username, hash, int(permissions.MemberRoleID), req.InviteCode)
if err != nil {
// UNIQUE constraint violation → duplicate username → 400.
// Any other DB error → 500.
switch {
case db.IsUniqueConstraintError(err):
writeJSON(w, http.StatusBadRequest, genericAuthError)
case errors.Is(err, db.ErrNotFound):
writeJSON(w, http.StatusBadRequest, genericAuthError)
default:
slog.Error("CreateUserWithInvite failed", "err", err, "username", req.Username)
writeJSON(w, http.StatusInternalServerError, errorResponse{
Error: "INTERNAL_ERROR",
Message: "registration failed — please try again",
})
}
return
}
ip := clientIPWithProxies(r, proxyNets)
slog.Info("user registered", "username", req.Username, "user_id", uid, "ip", ip)
db.WriteAudit(context.WithoutCancel(r.Context()), database, uid, "user_register", "user", uid,
"new account created via invite")
// Issue session.
token, err := auth.GenerateToken()
if err != nil {
writeJSON(w, http.StatusInternalServerError, errorResponse{
Error: "INTERNAL_ERROR",
Message: "failed to create session",
})
return
}
device := truncateDevice(r.Header.Get("User-Agent"))
if _, err := database.CreateSession(r.Context(), uid, auth.HashToken(token), device, ip); err != nil {
writeJSON(w, http.StatusInternalServerError, errorResponse{
Error: "INTERNAL_ERROR",
Message: "failed to create session",
})
return
}
user, err := database.GetUserByID(r.Context(), uid)
if err != nil || user == nil {
slog.Error("failed to fetch user after registration", "user_id", uid, "error", err)
writeJSON(w, http.StatusInternalServerError, errorResponse{
Error: "INTERNAL_ERROR",
Message: "registration succeeded but user fetch failed",
})
return
}
writeJSON(w, http.StatusCreated, authSuccessResponse{
Token: token,
Requires2FA: false,
User: toUserResponse(user),
})
writeJSON(w, http.StatusCreated, authResponse(res))
}
}
// registerPolicyGate reports whether registration is currently permitted,
// writing the refusal response itself when it is not.
func registerPolicyGate(w http.ResponseWriter, r *http.Request, database *db.DB) bool {
registrationOpen, err := isRegistrationOpen(r.Context(), database)
if err != nil {
writeJSON(w, http.StatusInternalServerError, errorResponse{
Error: "INTERNAL_ERROR",
Message: "failed to load registration policy",
})
return false
}
if !registrationOpen {
writeJSON(w, http.StatusForbidden, errorResponse{
Error: "FORBIDDEN",
Message: "registration is currently closed",
})
return false
}
require2FA, err := isRequire2FAEnabled(r.Context(), database)
if err != nil {
writeJSON(w, http.StatusInternalServerError, errorResponse{
Error: "INTERNAL_ERROR",
Message: "failed to load registration policy",
})
return false
}
if require2FA {
writeJSON(w, http.StatusForbidden, errorResponse{
Error: "FORBIDDEN",
Message: "registration is unavailable while two-factor authentication is required",
})
return false
}
return true
}
// registerReadRequest decodes and validates the registration body, writing the
// rejection response itself when the input cannot be used.
func registerReadRequest(w http.ResponseWriter, r *http.Request) (registerRequest, bool) {
@@ -327,7 +182,7 @@ func registerReadRequest(w http.ResponseWriter, r *http.Request) (registerReques
}
// handleLogin processes POST /api/v1/auth/login.
func handleLogin(database *db.DB, limiter *auth.RateLimiter, partialStore *auth.PartialAuthStore, trustedProxies []string) http.HandlerFunc {
func handleLogin(svc AuthService, trustedProxies []string) http.HandlerFunc {
proxyNets := parseCIDRList(trustedProxies) // W3-3a: parse once at construction
return func(w http.ResponseWriter, r *http.Request) {
req, ok := loginReadRequest(w, r)
@@ -335,77 +190,17 @@ func handleLogin(database *db.DB, limiter *auth.RateLimiter, partialStore *auth.
return
}
ip := clientIPWithProxies(r, proxyNets)
user, ok := loginAuthenticate(w, r, database, limiter, req, ip)
if !ok {
return
}
if auth.IsEffectivelyBanned(user) {
slog.Warn("banned user login attempt", "username", user.Username, "user_id", user.ID, "ip", ip)
db.WriteAudit(context.WithoutCancel(r.Context()), database, user.ID, "login_blocked_banned", "user", user.ID,
"banned user attempted login from "+ip)
writeJSON(w, http.StatusForbidden, errorResponse{
Error: "FORBIDDEN",
Message: "your account has been suspended",
})
return
}
require2FA, err := isRequire2FAEnabled(r.Context(), database)
if err != nil {
writeJSON(w, http.StatusInternalServerError, errorResponse{
Error: "INTERNAL_ERROR",
Message: "failed to load authentication policy",
})
return
}
if user.TOTPSecret != nil {
partialToken, err := partialStore.Issue(user.ID, truncateDevice(r.Header.Get("User-Agent")), ip)
if err != nil {
writeJSON(w, http.StatusInternalServerError, errorResponse{
Error: "INTERNAL_ERROR",
Message: "failed to start two-factor challenge",
})
return
}
writeJSON(w, http.StatusOK, authSuccessResponse{
PartialToken: partialToken,
Requires2FA: true,
})
return
}
if require2FA {
writeJSON(w, http.StatusForbidden, errorResponse{
Error: "FORBIDDEN",
Message: "two-factor authentication must be enabled on this account before login",
})
return
}
// Issue session.
token, err := issueSession(r.Context(), database, user.ID, truncateDevice(r.Header.Get("User-Agent")), ip)
if err != nil {
writeJSON(w, http.StatusInternalServerError, errorResponse{
Error: "INTERNAL_ERROR",
Message: "failed to create session",
})
return
}
// Don't set status to "online" here — the WebSocket connection in
// serve.go does that when the user actually connects. Setting it here
// would leave the user permanently "online" if they never open a WS
// connection or if the client crashes before connecting.
slog.Info("user logged in", "username", user.Username, "user_id", user.ID, "ip", ip)
db.WriteAudit(context.WithoutCancel(r.Context()), database, user.ID, "user_login", "user", user.ID,
"logged in from "+ip)
writeJSON(w, http.StatusOK, authSuccessResponse{
Token: token,
Requires2FA: false,
User: toUserResponse(user),
res, err := svc.Login(r.Context(), service.LoginInput{
Username: req.Username,
Password: req.Password,
Device: truncateDevice(r.Header.Get("User-Agent")),
IP: clientIPWithProxies(r, proxyNets),
})
if err != nil {
writeAuthError(r.Context(), w, err)
return
}
writeJSON(w, http.StatusOK, authResponse(res))
}
}
@@ -434,12 +229,13 @@ func loginReadRequest(w http.ResponseWriter, r *http.Request) (loginRequest, boo
}
// F: reject an over-long username before it is ever used to build a
// RateLimiter map key below (unameKey, failKey, userFailKey, lockout
// keys). Unlike registration, login has no account to validate
// against yet, so nothing else bounds this value — an unauthenticated
// caller could otherwise pin an arbitrarily large, body-sized string
// as a retained key (Cleanup only evicts it after hours). Mirrors the
// same 32-rune cap auth.ValidateUsername enforces at registration.
// RateLimiter map key (unameKey, failKey, userFailKey, lockout keys in
// service.AuthService). Unlike registration, login has no account to
// validate against yet, so nothing else bounds this value — an
// unauthenticated caller could otherwise pin an arbitrarily large,
// body-sized string as a retained key (Cleanup only evicts it after
// hours). Mirrors the same 32-rune cap auth.ValidateUsername enforces at
// registration.
if utf8.RuneCountInString(req.Username) > maxLoginUsernameLen {
writeJSON(w, http.StatusBadRequest, errorResponse{
Error: "INVALID_INPUT",
@@ -450,144 +246,18 @@ func loginReadRequest(w http.ResponseWriter, r *http.Request) (loginRequest, boo
return req, true
}
// loginAuthenticate runs the lockout gates, the constant-time password compare
// and the failure accounting for one login attempt. It returns the
// authenticated user, or false after writing the rejection response itself.
func loginAuthenticate(w http.ResponseWriter, r *http.Request, database *db.DB, limiter *auth.RateLimiter, req loginRequest, ip string) (*db.User, bool) {
// Check per-IP lockout first.
lockKey := "login_lock:" + ip
if limiter.IsLockedOut(lockKey) {
writeJSON(w, http.StatusTooManyRequests, errorResponse{
Error: "RATE_LIMITED",
Message: "account temporarily locked due to too many failed attempts",
})
return nil, false
}
// BUG-110: Also check per-username lockout to prevent distributed brute force.
// F1: canonicalize the username the same way GetUserByUsername does (COLLATE
// NOCASE) before keying the lockout, so case variants of one account
// (admin/Admin/ADMIN) share a single bucket instead of each getting its own.
unameKey := strings.ToLower(req.Username)
userLockKey := "login_user_lock:" + unameKey
if limiter.IsLockedOut(userLockKey) {
writeJSON(w, http.StatusTooManyRequests, errorResponse{
Error: "RATE_LIMITED",
Message: "account temporarily locked due to too many failed attempts",
})
return nil, false
}
// Constant-time lookup: always attempt bcrypt compare even when user
// does not exist to prevent timing-based username enumeration.
user, err := database.GetUserByUsername(r.Context(), req.Username)
// Distinguish DB errors from authentication failures. DB errors
// should NOT increment the rate limiter — otherwise a transient
// DB outage would lock out legitimate users.
if err != nil && user == nil {
// Could be a real DB error or simply "user not found".
// GetUserByUsername returns (nil, nil) for not-found, so a
// non-nil error here is a genuine DB failure.
slog.Error("login: GetUserByUsername failed", "err", err, "ip", ip)
writeJSON(w, http.StatusInternalServerError, errorResponse{
Error: "INTERNAL_ERROR",
Message: "login temporarily unavailable",
})
return nil, false
}
failKey := "login_fail:" + ip
userFailKey := "login_user_fail:" + unameKey
// F3: atomically reserve this attempt BEFORE the bcrypt compare. The
// read-only IsLockedOut gates above are check-then-act: N concurrent
// requests all pass them before any failure is recorded below, so the
// per-username cap — the only cross-IP brute-force defence — bound
// only sequential attackers. Allow records the attempt under the
// limiter's lock, capping a concurrent burst at the same budget a
// sequential attacker gets. Sized at threshold+1 so the sequential
// accepted-input set is unchanged: failures 110 still land, the 10th
// still trips the lockout (via the Check below), and a correct
// password on attempt 10 still succeeds — successful logins reset
// both counters. The reservation sits after the DB-error return above
// so a transient DB outage still does not consume attempts.
if !limiter.Allow(failKey, scaledAuthLimit(loginFailureThreshold)+1, loginFailureWindow) ||
!limiter.Allow(userFailKey, loginUserFailureThreshold+1, loginUserFailureWindow) {
writeJSON(w, http.StatusTooManyRequests, errorResponse{
Error: "RATE_LIMITED",
Message: "account temporarily locked due to too many failed attempts",
})
return nil, false
}
// Always run the password check — with an empty hash when the user does
// not exist. auth.CheckPassword performs a dummy bcrypt comparison for an
// empty hash, so bcrypt executes on every path and response time stays
// constant, preventing timing-based username enumeration. (A `user == nil
// || CheckPassword(...)` short-circuit would skip bcrypt entirely for
// unknown usernames, reintroducing the timing side-channel.)
storedHash := ""
if user != nil {
storedHash = user.PasswordHash
}
if !auth.CheckPassword(storedHash, req.Password) {
// The attempt was already recorded atomically up-front (F3); here
// only decide the lockouts, at the same boundary as before: the
// 10th in-window failure locks the key. Check is read-only, so
// the reservation is not double-counted.
if !limiter.Check(failKey, scaledAuthLimit(loginFailureThreshold)+1, loginFailureWindow) {
limiter.Lockout(r.Context(), lockKey, loginLockoutDuration)
}
// BUG-110: per-username lockout on threshold.
if !limiter.Check(userFailKey, loginUserFailureThreshold+1, loginUserFailureWindow) {
limiter.Lockout(r.Context(), userLockKey, loginUserLockoutDuration)
}
slog.Info("login failed", "ip", ip, "username_len", len(req.Username))
writeJSON(w, http.StatusUnauthorized, errorResponse{
Error: "UNAUTHORIZED",
Message: "invalid credentials",
})
return nil, false
}
// Reset failure counters on success.
limiter.Reset(r.Context(), failKey)
limiter.Reset(r.Context(), userFailKey)
return user, true
}
// handleLogout processes POST /api/v1/auth/logout.
func handleLogout(database *db.DB) http.HandlerFunc {
func handleLogout(svc AuthService) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
sess, ok := r.Context().Value(SessionKey).(*db.Session)
if !ok || sess == nil {
writeJSON(w, http.StatusUnauthorized, errorResponse{
Error: "UNAUTHORIZED",
Message: "not authenticated",
})
p, ok := principal(r)
if !ok || p.Session == nil {
writeNotAuthenticated(w)
return
}
// The client clears its token optimistically — once logout reaches the
// server, the revocation must not die with a dropped connection.
if err := database.DeleteSession(context.WithoutCancel(r.Context()), sess.TokenHash); err != nil {
writeJSON(w, http.StatusInternalServerError, errorResponse{
Error: "INTERNAL_ERROR",
Message: "failed to logout",
})
if err := svc.Logout(r.Context(), p); err != nil {
writeAuthError(r.Context(), w, err)
return
}
// A custom status is a "what I am doing right now" note. Leaving it
// standing after the user signed out states something about them that
// is no longer true, so logout clears it — unlike the chosen presence
// status, which is a preference and deliberately survives.
if err := database.UpdateUserCustomStatus(context.WithoutCancel(r.Context()), sess.UserID, nil); err != nil {
slog.Warn("failed to clear custom status on logout", "user_id", sess.UserID, "err", err)
}
slog.Info("user logged out", "user_id", sess.UserID)
db.WriteAudit(context.WithoutCancel(r.Context()), database, sess.UserID, "user_logout", "user", sess.UserID, "")
w.WriteHeader(http.StatusNoContent)
}
}
@@ -595,15 +265,12 @@ func handleLogout(database *db.DB) http.HandlerFunc {
// handleMe processes GET /api/v1/auth/me.
func handleMe() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
user, ok := r.Context().Value(UserKey).(*db.User)
if !ok || user == nil {
writeJSON(w, http.StatusUnauthorized, errorResponse{
Error: "UNAUTHORIZED",
Message: "not authenticated",
})
p, ok := principal(r)
if !ok {
writeNotAuthenticated(w)
return
}
writeJSON(w, http.StatusOK, toUserResponse(user))
writeJSON(w, http.StatusOK, toUserResponse(p.User))
}
}
@@ -612,30 +279,14 @@ type deleteAccountRequest struct {
Password string `json:"password"`
}
// handleDeleteAccount processes DELETE /api/v1/auth/account.
// The caller must supply their current password for confirmation.
// Progressive lockout mirrors the login handler: 3 failures → 15-min lock.
// broadcaster may be nil, in which case no event is sent and other connected
// clients converge on their next reconnect instead (same fallback every
// other broadcaster-optional handler in this package uses).
func handleDeleteAccount(database *db.DB, limiter *auth.RateLimiter, broadcaster AuthBroadcaster) http.HandlerFunc {
// handleDeleteAccount processes DELETE /api/v1/auth/account. The caller must
// supply their current password for confirmation; the lockout, the compare
// and the member_ban broadcast are the service's.
func handleDeleteAccount(svc AuthService) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
user, ok := r.Context().Value(UserKey).(*db.User)
if !ok || user == nil {
writeJSON(w, http.StatusUnauthorized, errorResponse{
Error: "UNAUTHORIZED",
Message: "not authenticated",
})
return
}
// Per-user lockout to prevent password brute-force on this destructive endpoint.
lockKey := auth.Key("delete_lock", user.ID)
if limiter.IsLockedOut(lockKey) {
writeJSON(w, http.StatusTooManyRequests, errorResponse{
Error: "RATE_LIMITED",
Message: "too many failed attempts, try again later",
})
p, ok := principal(r)
if !ok {
writeNotAuthenticated(w)
return
}
@@ -648,80 +299,70 @@ func handleDeleteAccount(database *db.DB, limiter *auth.RateLimiter, broadcaster
return
}
if req.Password == "" {
writeJSON(w, http.StatusBadRequest, errorResponse{
Error: "INVALID_INPUT",
Message: "password is required",
})
if err := svc.DeleteAccount(r.Context(), p, req.Password, clientIP(r)); err != nil {
writeAuthError(r.Context(), w, err)
return
}
// Verify the supplied password matches the stored hash.
failKey := auth.Key("delete_fail", user.ID)
if !auth.CheckPassword(user.PasswordHash, req.Password) {
if !limiter.Allow(failKey, deleteAccountFailureThreshold, deleteAccountFailureWindow) {
limiter.Lockout(r.Context(), lockKey, deleteAccountLockoutDuration)
}
writeJSON(w, http.StatusBadRequest, errorResponse{
Error: "INVALID_INPUT",
Message: "incorrect password",
})
return
}
limiter.Reset(r.Context(), failKey)
if err := database.DeleteAccount(r.Context(), user.ID); err != nil {
if errors.Is(err, db.ErrLastAdmin) {
writeJSON(w, http.StatusForbidden, errorResponse{
Error: "FORBIDDEN",
Message: "cannot delete the last admin account",
})
return
}
slog.Error("DeleteAccount failed", "err", err, "user_id", user.ID)
writeJSON(w, http.StatusInternalServerError, errorResponse{
Error: "INTERNAL_ERROR",
Message: "failed to delete account",
})
return
}
ip := clientIP(r)
slog.Info("account deleted", "username", user.Username, "user_id", user.ID, "ip", ip)
db.WriteAudit(context.WithoutCancel(r.Context()), database, user.ID, "account_deleted", "user", user.ID,
"account self-deleted from "+ip)
// DeleteAccount left the row in exactly the state an admin ban does
// (anonymised, banned, sessions revoked) — broadcast the same event so
// every other connected client drops the deleted user immediately
// instead of keeping their pre-deletion username until it reconnects.
if broadcaster != nil {
broadcaster.BroadcastMemberBan(user.ID)
}
w.WriteHeader(http.StatusNoContent)
}
}
// toUserResponse converts a db.User to the API response shape.
func toUserResponse(u *db.User) *userResponse {
avatar := ""
if u.Avatar != nil {
avatar = *u.Avatar
// authResponse encodes a service result: a session, or the two-factor
// challenge Login started instead of one.
func authResponse(res *service.AuthResult) authSuccessResponse {
if res.Requires2FA {
return authSuccessResponse{
PartialToken: res.PartialToken,
Requires2FA: true,
}
}
resp := &userResponse{
ID: u.ID,
Username: u.Username,
Avatar: avatar,
DisplayName: u.DisplayName,
About: u.About,
CustomStatus: u.CustomStatus,
Status: u.Status,
RoleID: u.RoleID,
TOTPEnabled: u.TOTPSecret != nil,
CreatedAt: u.CreatedAt,
return authSuccessResponse{
Token: res.Token,
Requires2FA: false,
User: toUserResponse(res.User),
}
return resp
}
// writeNotAuthenticated is the refusal for a route mounted behind
// AuthMiddleware that still finds no usable principal on the request.
func writeNotAuthenticated(w http.ResponseWriter) {
writeJSON(w, http.StatusUnauthorized, errorResponse{
Error: "UNAUTHORIZED",
Message: "not authenticated",
})
}
// writeAuthError encodes a service.Err* refusal from the auth slice. Each
// named value's Error() is the public message; its category picks the
// status and code, and two values carry a code of their own. Anything that
// is not an auth refusal is a contract bug in the service, logged and
// answered as a generic 500 so no cause leaks to the client.
func writeAuthError(ctx context.Context, w http.ResponseWriter, err error) {
var status int
var code string
switch {
case errors.Is(err, service.ErrRegistrationRejected):
status, code = http.StatusBadRequest, "INVALID_CREDENTIALS"
case errors.Is(err, service.ErrTOTPAlreadyEnabled):
status, code = http.StatusConflict, "TOTP_ALREADY_ENABLED"
case errors.Is(err, service.ErrRateLimited):
status, code = http.StatusTooManyRequests, "RATE_LIMITED"
case errors.Is(err, service.ErrUnauthorized):
status, code = http.StatusUnauthorized, "UNAUTHORIZED"
case errors.Is(err, service.ErrForbidden):
status, code = http.StatusForbidden, "FORBIDDEN"
case errors.Is(err, service.ErrInvalidInput):
status, code = http.StatusBadRequest, "INVALID_INPUT"
case errors.Is(err, service.ErrBadRequest):
status, code = http.StatusBadRequest, "BAD_REQUEST"
case errors.Is(err, service.ErrInternal):
status, code = http.StatusInternalServerError, "INTERNAL_ERROR"
default:
slog.ErrorContext(ctx, "auth service returned a non-refusal error", "error", err)
writeJSON(w, http.StatusInternalServerError, errorResponse{Error: "INTERNAL_ERROR", Message: "internal error"})
return
}
writeJSON(w, status, errorResponse{Error: code, Message: err.Error()})
}
// truncateDevice truncates the User-Agent to prevent oversized session records.
@@ -733,54 +374,3 @@ func truncateDevice(ua string) string {
}
return ua
}
func issueSession(ctx context.Context, database *db.DB, userID int64, device, ip string) (string, error) {
token, err := auth.GenerateToken()
if err != nil {
return "", err
}
if _, err := database.CreateSession(ctx, userID, auth.HashToken(token), device, ip); err != nil {
return "", err
}
return token, nil
}
func isRequire2FAEnabled(ctx context.Context, database *db.DB) (bool, error) {
return getBooleanSetting(ctx, database, "require_2fa", false)
}
func isRegistrationOpen(ctx context.Context, database *db.DB) (bool, error) {
return getBooleanSetting(ctx, database, "registration_open", true)
}
func getBooleanSetting(ctx context.Context, database *db.DB, key string, defaultValue bool) (bool, error) {
value, err := database.GetSetting(ctx, key)
if err != nil {
if errors.Is(err, db.ErrNotFound) {
return defaultValue, nil
}
return false, err
}
return parseBooleanSettingValue(value)
}
func parseBooleanSettingValue(value string) (bool, error) {
switch strings.ToLower(strings.TrimSpace(value)) {
case "1", "true":
return true, nil
case "0", "false":
return false, nil
default:
return false, fmt.Errorf("invalid boolean setting value %q", value)
}
}
func requirePasswordConfirmation(user *db.User, password string) error {
if password == "" {
return fmt.Errorf("password is required")
}
if !auth.CheckPassword(user.PasswordHash, password) {
return fmt.Errorf("password confirmation failed")
}
return nil
}
@@ -7,6 +7,7 @@ import (
"github.com/J3vb/OwnCord/Server/api"
"github.com/J3vb/OwnCord/Server/auth"
"github.com/J3vb/OwnCord/Server/service"
"github.com/go-chi/chi/v5"
)
@@ -32,7 +33,7 @@ func TestDeleteAccount_BroadcastsMemberBan(t *testing.T) {
broadcaster := &recordingAuthBroadcaster{}
r := chi.NewRouter()
api.MountAuthRoutes(r, database, limiter, nil, testTOTPKey, broadcaster)
api.MountAuthRoutes(r, service.NewAuthService(database, limiter, testTOTPKey, broadcaster), api.AuthMiddleware(database), limiter, nil)
hash, _ := auth.HashPassword("correctPass1")
uid, _ := database.CreateUser(context.Background(), "deletebroadcast", hash, 4)
@@ -51,14 +52,14 @@ func TestDeleteAccount_BroadcastsMemberBan(t *testing.T) {
}
}
// Omitting the broadcaster (the shape every existing MountAuthRoutes call
// site uses today) must keep working exactly as before: no event, no panic.
// A nil broadcaster (the shape every test mount uses) must keep working
// exactly as before: no event, no panic.
func TestDeleteAccount_NoBroadcasterOmitted(t *testing.T) {
database := newAuthTestDB(t)
limiter := auth.NewRateLimiter()
r := chi.NewRouter()
api.MountAuthRoutes(r, database, limiter, nil, testTOTPKey)
api.MountAuthRoutes(r, service.NewAuthService(database, limiter, testTOTPKey, nil), api.AuthMiddleware(database), limiter, nil)
hash, _ := auth.HashPassword("correctPass1")
uid, _ := database.CreateUser(context.Background(), "deletenobroadcast", hash, 4)
+2 -1
View File
@@ -17,6 +17,7 @@ import (
"github.com/J3vb/OwnCord/Server/api"
"github.com/J3vb/OwnCord/Server/auth"
"github.com/J3vb/OwnCord/Server/db"
"github.com/J3vb/OwnCord/Server/service"
"github.com/go-chi/chi/v5"
)
@@ -45,7 +46,7 @@ func buildAuthRouter(database *db.DB, limiter *auth.RateLimiter) http.Handler {
func buildAuthRouterWithProxies(database *db.DB, limiter *auth.RateLimiter, trustedProxies []string) http.Handler {
r := chi.NewRouter()
api.MountAuthRoutes(r, database, limiter, trustedProxies, testTOTPKey)
api.MountAuthRoutes(r, service.NewAuthService(database, limiter, testTOTPKey, nil), api.AuthMiddleware(database), limiter, trustedProxies)
return r
}
+11
View File
@@ -9,6 +9,7 @@ import (
"strings"
"github.com/J3vb/OwnCord/Server/updater"
"github.com/J3vb/OwnCord/Server/ws"
"github.com/go-chi/chi/v5"
"golang.org/x/mod/semver"
)
@@ -58,6 +59,16 @@ func handleClientUpdate(u *updater.Updater) http.HandlerFunc {
return
}
// Server first, clients second: never advertise a client that speaks
// a newer wire epoch than this server — it would auto-update straight
// into a refused handshake. The epoch comes from the release's signed
// manifest; a manifest that does not verify is withheld the same way.
epoch, err := u.ReleaseProtocolEpoch(r.Context(), info)
if err != nil || epoch > ws.ProtocolEpoch {
w.WriteHeader(http.StatusNoContent)
return
}
// Find the updater artifact and its signature for the requested
// target ("{os}-{arch}-{installer}", e.g. "windows-x86_64-nsis").
// Targets without a published updater artifact get 204 — never a
+73
View File
@@ -0,0 +1,73 @@
package api_test
// client_update_epoch_test.go — the client-update endpoint never advertises a
// release whose signed manifest declares a protocol epoch newer than this
// server's (B2-2): a client that auto-updated onto it would be refused at the
// next handshake. A manifest that does not verify is treated the same way —
// fail closed, 204 — since its epoch cannot be trusted. A release with no
// manifest at all predates the epoch and is advertised as before
// (client_update_test.go covers that path throughout).
import (
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/J3vb/OwnCord/Server/updater"
)
// fakeGitHubReleaseWithManifest is fakeGitHubRelease plus a server-update
// manifest and signature, served with the given bytes.
func fakeGitHubReleaseWithManifest(t *testing.T, tag string, manifest, sig []byte) *httptest.Server {
t.Helper()
var srv *httptest.Server
mux := http.NewServeMux()
assetNames := []string{
"OwnCord_1.0.0_x64-setup.nsis.zip",
"OwnCord_1.0.0_x64-setup.nsis.zip.sig",
"server-update-manifest.json",
"server-update-manifest.json.sig",
}
mux.HandleFunc("/repos/test/repo/releases/latest", func(w http.ResponseWriter, _ *http.Request) {
assets := make([]map[string]any, 0, len(assetNames))
for _, name := range assetNames {
assets = append(assets, map[string]any{"name": name, "browser_download_url": srv.URL + "/download/" + name})
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{"tag_name": tag, "body": "notes", "html_url": "x", "assets": assets})
})
mux.HandleFunc("/download/", func(w http.ResponseWriter, r *http.Request) {
switch {
case strings.HasSuffix(r.URL.Path, "server-update-manifest.json"):
_, _ = w.Write(manifest)
case strings.HasSuffix(r.URL.Path, "server-update-manifest.json.sig"):
_, _ = w.Write(sig)
case strings.HasSuffix(r.URL.Path, ".sig"):
_, _ = w.Write([]byte("dW50cnVzdGVkIGNvbW1lbnQ="))
default:
http.NotFound(w, r)
}
})
srv = httptest.NewServer(mux)
t.Cleanup(srv.Close)
return srv
}
func TestClientUpdate_UnverifiableManifestIsNotAdvertised(t *testing.T) {
// The manifest claims epoch 1 (compatible) but its signature is garbage:
// the claim is untrusted, so the release is withheld.
manifest := []byte(`{"version":"v2.0.0","asset":"chatserver.exe","sha256":"00","protocol_epoch":1}`)
srv := fakeGitHubReleaseWithManifest(t, "v2.0.0", manifest, []byte("not a signature"))
u := updater.NewUpdater("1.0.0", "", "test", "repo")
u.SetBaseURL(srv.URL)
req := httptest.NewRequest(http.MethodGet, "/api/v1/client-update/windows-x86_64-nsis/1.0.0", nil)
rr := httptest.NewRecorder()
buildClientUpdateRouter(u).ServeHTTP(rr, req)
if rr.Code != http.StatusNoContent {
t.Fatalf("status = %d, want 204 (unverifiable manifest must not be advertised); body: %s", rr.Code, rr.Body.String())
}
}
+70
View File
@@ -4,6 +4,7 @@ import (
"encoding/json"
"net/http"
"net/http/httptest"
"reflect"
"strings"
"testing"
@@ -273,3 +274,72 @@ func TestClientUpdate_GitHubError(t *testing.T) {
t.Errorf("status = %d, want 502; body: %s", rr.Code, rr.Body.String())
}
}
// TestClientUpdate_Epoch1ResponseShape pins the exact JSON shape of the
// client-update 200 and 204 responses as of protocol epoch 1. B2-3 adds a
// protocol-epoch field to this response; when it does, this test WILL fail
// until it is extended on purpose to include the new field in the expected
// shape below.
//
// fakeGitHubRelease always publishes non-empty release notes and has no
// parameter for an empty body, so this covers the non-empty-notes case
// (top-level keys exactly {version, notes, platforms}) plus an explicit
// assertion that "pub_date" — omitempty, and never set by the handler —
// stays absent.
func TestClientUpdate_Epoch1ResponseShape(t *testing.T) {
srv := fakeGitHubRelease(t, "v2.0.0")
u := updater.NewUpdater("1.0.0", "", "test", "repo")
u.SetBaseURL(srv.URL)
router := buildClientUpdateRouter(u)
req := httptest.NewRequest(http.MethodGet, "/api/v1/client-update/windows-x86_64-nsis/1.0.0", nil)
rr := httptest.NewRecorder()
router.ServeHTTP(rr, req)
if rr.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body: %s", rr.Code, rr.Body.String())
}
if ct := rr.Header().Get("Content-Type"); ct != "application/json; charset=utf-8" {
t.Errorf("Content-Type = %q, want %q", ct, "application/json; charset=utf-8")
}
var resp map[string]any
if err := json.NewDecoder(rr.Body).Decode(&resp); err != nil {
t.Fatalf("decode: %v", err)
}
want := map[string]any{
"version": "2.0.0",
"notes": "Release notes here",
"platforms": map[string]any{
"windows-x86_64-nsis": map[string]any{
"signature": "dW50cnVzdGVkIGNvbW1lbnQ=",
"url": srv.URL + "/download/OwnCord_1.0.0_x64-setup.nsis.zip",
},
},
}
if !reflect.DeepEqual(resp, want) {
t.Errorf("200 response = %#v, want %#v — a new field (e.g. protocol epoch) must be added here deliberately", resp, want)
}
if _, ok := resp["pub_date"]; ok {
t.Errorf("response has a \"pub_date\" key = %v, want absent", resp["pub_date"])
}
// 204 (already latest) has an empty body, not "{}" or any other JSON.
srv204 := fakeGitHubRelease(t, "v1.0.0")
u204 := updater.NewUpdater("1.0.0", "", "test", "repo")
u204.SetBaseURL(srv204.URL)
router204 := buildClientUpdateRouter(u204)
req204 := httptest.NewRequest(http.MethodGet, "/api/v1/client-update/windows-x86_64-nsis/1.0.0", nil)
rr204 := httptest.NewRecorder()
router204.ServeHTTP(rr204, req204)
if rr204.Code != http.StatusNoContent {
t.Fatalf("status = %d, want 204; body: %s", rr204.Code, rr204.Body.String())
}
if rr204.Body.Len() != 0 {
t.Errorf("204 body length = %d, want 0 (body: %q)", rr204.Body.Len(), rr204.Body.String())
}
}
+7 -89
View File
@@ -1,10 +1,9 @@
package api
import (
"math"
"sync/atomic"
"time"
"github.com/J3vb/OwnCord/Server/auth"
"github.com/J3vb/OwnCord/Server/config"
)
@@ -13,36 +12,18 @@ import (
// Each constant defines either a request cap or a sliding-window duration used
// by the per-endpoint rate limiters.
// authRateScaleBits holds the security.auth_rate_limit_multiplier as float
// bits. It scales the per-IP auth request caps and failure thresholds for
// deployments where many users share one IP (office/school NAT) — the
// compiled-in constants below assume roughly one person per address. Atomic
// because tests construct multiple routers concurrently. Set via
// setAuthRateScale in NewRouter; reads happen at mount time and on the login
// failure-count path.
var authRateScaleBits atomic.Uint64
func init() { authRateScaleBits.Store(math.Float64bits(1.0)) }
// The auth rate multiplier (security.auth_rate_limit_multiplier) lives in
// auth (auth/ratescale.go) since B3-2, so the route mounts here and the login
// failure accounting in service.AuthService read one value. These wrappers
// keep the mount sites and api/constants_test.go unchanged.
// setAuthRateScale clamps and installs the auth rate multiplier. Zero or
// negative (unset config) means 1.0.
func setAuthRateScale(m float64) {
if m <= 0 {
m = 1.0
}
m = math.Min(math.Max(m, 0.1), 100)
authRateScaleBits.Store(math.Float64bits(m))
}
func setAuthRateScale(m float64) { auth.SetRateScale(m) }
// scaledAuthLimit applies the auth rate multiplier to a compiled-in limit,
// never returning less than 1.
func scaledAuthLimit(n int) int {
scaled := int(math.Round(float64(n) * math.Float64frombits(authRateScaleBits.Load())))
if scaled < 1 {
return 1
}
return scaled
}
func scaledAuthLimit(n int) int { return auth.ScaledLimit(n) }
const (
// registerRateLimitPerMinute is the maximum registration attempts per IP per minute.
@@ -72,40 +53,6 @@ const (
// stays under this; it exists to bound abuse of the operator's Klipy quota.
gifRateLimitPerMinute = 30
// loginFailureThreshold is the number of failed login attempts (within
// loginFailureWindow) before the IP is locked out.
loginFailureThreshold = 9
// loginFailureWindow is the sliding window for counting login failures.
loginFailureWindow = 15 * time.Minute
// loginLockoutDuration is how long an IP is locked out after exceeding
// loginFailureThreshold.
loginLockoutDuration = 15 * time.Minute
// deleteAccountFailureThreshold is the number of wrong-password attempts
// before the per-user lockout kicks in.
deleteAccountFailureThreshold = 3
// deleteAccountFailureWindow is the sliding window for counting
// delete-account password failures.
deleteAccountFailureWindow = 15 * time.Minute
// deleteAccountLockoutDuration is how long the account-deletion endpoint
// is locked after exceeding deleteAccountFailureThreshold.
deleteAccountLockoutDuration = 15 * time.Minute
// totpFailureRateLimit is the maximum TOTP verification failures per user
// within totpFailureWindow before the user is rate-limited.
totpFailureRateLimit = 10
// totpFailureWindow is the sliding window for counting per-user TOTP failures.
totpFailureWindow = 15 * time.Minute
// partialAuthMaxFailures is the number of failed TOTP attempts on a single
// partial-auth challenge before it is revoked.
partialAuthMaxFailures = 5
// profilePasswordRateLimitPerMinute is the maximum password change attempts
// per IP per minute.
profilePasswordRateLimitPerMinute = 5
@@ -114,29 +61,6 @@ const (
// per user per minute.
profileUpdateRateLimitPerMinute = 10
// loginUserFailureThreshold is the number of failed login attempts for a
// specific username (regardless of source IP) before the account is locked.
loginUserFailureThreshold = 9
// loginUserFailureWindow is the sliding window for per-username login failures.
loginUserFailureWindow = 15 * time.Minute
// loginUserLockoutDuration is how long a username is locked after exceeding
// loginUserFailureThreshold.
loginUserLockoutDuration = 15 * time.Minute
// pwConfirmFailureThreshold is the number of wrong-password attempts on
// password-confirmation endpoints before per-user lockout kicks in.
pwConfirmFailureThreshold = 3
// pwConfirmFailureWindow is the sliding window for per-user password
// confirmation failures.
pwConfirmFailureWindow = 15 * time.Minute
// pwConfirmLockoutDuration is how long password-confirmation endpoints are
// locked after exceeding pwConfirmFailureThreshold.
pwConfirmLockoutDuration = 15 * time.Minute
// uploadRateLimitPerMinute is the maximum file uploads per user per minute.
uploadRateLimitPerMinute = 10
@@ -149,12 +73,6 @@ const (
// ─── Timeouts & TTLs ────────────────────────────────────────────────────────
const (
// partialAuthStoreTTL is the lifetime of a partial-auth (2FA) challenge token.
partialAuthStoreTTL = 10 * time.Minute
// pendingTOTPStoreTTL is the lifetime of a pending TOTP enrollment secret.
pendingTOTPStoreTTL = 10 * time.Minute
// rateLimiterCleanupInterval is how often stale rate-limiter entries are reaped.
rateLimiterCleanupInterval = 5 * time.Minute
+11 -11
View File
@@ -27,8 +27,9 @@ func TestRateLimiterCleanupHorizon_CoversMaxSlowMode(t *testing.T) {
}
// setAuthRateScale/scaledAuthLimit gate every per-IP auth limit
// (auth_handler.go:107-136) and the per-IP login failure threshold that arms
// the lockout (auth_handler.go:514,537). The multiplier is operator-supplied
// (auth_handler.go MountAuthRoutes) and, through auth.ScaledLimit, the per-IP
// login failure threshold that arms the lockout (service/auth.go
// authenticate). The multiplier is operator-supplied
// via security.auth_rate_limit_multiplier and config validates nothing, so
// this clamp is all that stands between a typo and brute-force protection
// disappearing.
@@ -49,7 +50,7 @@ func TestSetAuthRateScale_ClampsMultiplier(t *testing.T) {
{"below the floor clamps to 0.1x", 1e-9, verifyTOTPRateLimitPerMinute, 1},
{"at the floor is 0.1x", 0.1, verifyTOTPRateLimitPerMinute, 1},
{"in range scales and rounds", 0.5, loginRateLimitPerMinute, 3},
{"in range scales the failure threshold", 2, loginFailureThreshold, 18},
{"in range scales the failure threshold", 2, 9, 18}, // 9 = service/auth.go loginFailureThreshold
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
@@ -63,7 +64,7 @@ func TestSetAuthRateScale_ClampsMultiplier(t *testing.T) {
}
// A limit of 0 lets nothing through: on the login failure threshold
// (auth_handler.go:514) that locks every IP out on its first attempt. The
// (service/auth.go authenticate) that locks every IP out on its first attempt. The
// smallest allowed multiplier must still leave every scaled limit usable.
func TestScaledAuthLimit_NeverBelowOne(t *testing.T) {
t.Cleanup(func() { setAuthRateScale(1.0) })
@@ -75,7 +76,7 @@ func TestScaledAuthLimit_NeverBelowOne(t *testing.T) {
loginRateLimitPerMinute,
verifyTOTPRateLimitPerMinute,
sensitiveEndpointRateLimitPerMinute,
loginFailureThreshold,
9, // service/auth.go loginFailureThreshold
} {
if got := scaledAuthLimit(n); got < 1 {
t.Errorf("scaledAuthLimit(%d) = %d at the 0.1x floor, want >= 1", n, got)
@@ -85,20 +86,19 @@ func TestScaledAuthLimit_NeverBelowOne(t *testing.T) {
// The multiplier exists for shared-NAT *per-IP* limits. The per-user caps are
// the only cross-IP brute-force defence, so scaling them would hand a
// distributed attacker up to 100x the guesses (totp_handler.go:76-80). Those
// caps are only observable through a limiter key inside the handler, so this
// pins the call site instead.
// distributed attacker up to 100x the guesses (service/auth.go VerifyTOTP).
// Those caps are only observable through a limiter key inside the service, so
// this pins the call site instead.
func TestPerUserFailureCapsStayUnscaled(t *testing.T) {
for file, constants := range map[string][]string{
"totp_handler.go": {"totpFailureRateLimit"},
"auth_handler.go": {"loginUserFailureThreshold"},
"../service/auth.go": {"totpFailureRateLimit", "loginUserFailureThreshold"},
} {
src, err := os.ReadFile(file)
if err != nil {
t.Fatalf("read %s: %v", file, err)
}
for _, c := range constants {
if strings.Contains(string(src), "scaledAuthLimit("+c) {
if strings.Contains(string(src), "ScaledLimit("+c) || strings.Contains(string(src), "scaledAuthLimit("+c) {
t.Errorf("%s scales %s with the per-IP auth multiplier; per-user caps must stay unscaled",
file, c)
}
+1 -1
View File
@@ -750,7 +750,7 @@ func buildCombinedRouter(t *testing.T) (http.Handler, *auth.RateLimiter, string)
r := chi.NewRouter()
svc := service.New(database, limiter)
api.MountAuthRoutes(r, database, limiter, nil, testTOTPKey)
api.MountAuthRoutes(r, service.NewAuthService(database, limiter, testTOTPKey, nil), api.AuthMiddleware(database), limiter, nil)
api.MountProfileRoutes(r, database, svc, nil, limiter, nil, nil)
api.MountInviteRoutes(r, database, svc)
+5 -2
View File
@@ -11,6 +11,7 @@ import (
"github.com/J3vb/OwnCord/Server/auth"
"github.com/J3vb/OwnCord/Server/config"
"github.com/J3vb/OwnCord/Server/db"
"github.com/J3vb/OwnCord/Server/internal/app"
"github.com/J3vb/OwnCord/Server/permissions"
)
@@ -35,7 +36,8 @@ func setupDiagnosticsRouter(t *testing.T) (http.Handler, string, *db.DB) {
},
}
handler, _, cleanup := api.NewRouter(cfg, database, "1.0.0-test", nil, nil)
rt := app.StartRuntime(cfg, database, nil)
handler, cleanup := api.NewRouter(cfg, database, "1.0.0-test", nil, nil, rt)
t.Cleanup(cleanup)
// Create a user and session for authenticated requests.
@@ -106,7 +108,8 @@ func TestDiagnosticsConnectivity_HonoursTrustedProxies(t *testing.T) {
},
}
handler, _, cleanup := api.NewRouter(cfg, database, "1.0.0-test", nil, nil)
rt := app.StartRuntime(cfg, database, nil)
handler, cleanup := api.NewRouter(cfg, database, "1.0.0-test", nil, nil, rt)
t.Cleanup(cleanup)
uid, _ := database.CreateUser(context.Background(), "diagproxyuser", "$2a$12$fake", 1)
+8 -1
View File
@@ -102,8 +102,15 @@ func handleListInvites(svc *service.Services) http.HandlerFunc {
// handleRevokeInvite processes DELETE /api/v1/invites/:code.
func handleRevokeInvite(svc *service.Services) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
user, ok := r.Context().Value(UserKey).(*db.User)
if !ok || user == nil {
writeJSON(w, http.StatusUnauthorized, errorResponse{
Error: "UNAUTHORIZED", Message: "not authenticated",
})
return
}
code := chi.URLParam(r, "code")
if err := svc.Invites.RevokeInvite(r.Context(), code); err != nil {
if err := svc.Invites.RevokeInvite(r.Context(), user.ID, code); err != nil {
writeServiceError(r.Context(), w, err)
return
}
+1 -1
View File
@@ -19,7 +19,7 @@ import (
func buildInviteRouter(database *db.DB, limiter *auth.RateLimiter) http.Handler {
r := chi.NewRouter()
svc := service.New(database, limiter)
api.MountAuthRoutes(r, database, limiter, nil, testTOTPKey)
api.MountAuthRoutes(r, service.NewAuthService(database, limiter, testTOTPKey, nil), api.AuthMiddleware(database), limiter, nil)
api.MountInviteRoutes(r, database, svc)
return r
}
+10 -3
View File
@@ -60,16 +60,23 @@ func TestBoundRequestID_OverLongHeaderNeverReachesLog(t *testing.T) {
}
// TestBoundRequestID_ControlBytesRejected covers the charset half of the bound:
// a short id carrying control bytes is dropped too.
// a short id carrying control bytes is dropped too. The marker is long and
// distinctive on purpose: the server-generated fallback id is a short random
// base64 run, and a three-letter marker ("abc") once matched inside it by
// chance (CI run 33308823281, req_id=…/qj9LabcvlI-000002).
func TestBoundRequestID_ControlBytesRejected(t *testing.T) {
const marker = "ILLFORMEDREQUESTID"
req := httptest.NewRequest(http.MethodGet, "/api/v1/health", nil)
req.Header.Set("X-Request-Id", "abc\x00def\tghi")
req.Header.Set("X-Request-Id", marker+"\x00def\tghi")
out, _ := loggedRequest(t, req)
if strings.Contains(out, "abc") {
if strings.Contains(out, marker) {
t.Errorf("ill-formed X-Request-Id reached the log record: %q", out)
}
if !strings.Contains(out, "req_id=") {
t.Errorf("no request id was logged at all — correlation lost: %q", out)
}
}
// TestBoundRequestID_NormalIDPreserved proves the bound does not break the
+13
View File
@@ -14,6 +14,7 @@ import (
"github.com/J3vb/OwnCord/Server/auth"
"github.com/J3vb/OwnCord/Server/db"
"github.com/J3vb/OwnCord/Server/permissions"
"github.com/J3vb/OwnCord/Server/service"
)
// contextKey is an unexported type for context keys in this package.
@@ -67,6 +68,18 @@ func (t *touchThrottle) shouldTouch(hash string, now time.Time) bool {
return true
}
// principal returns the caller AuthMiddleware resolved for r as the shape
// the service layer takes. ok is false when the request carries no
// authenticated user; Session is nil for an API-token principal.
func principal(r *http.Request) (service.Principal, bool) {
user, ok := r.Context().Value(UserKey).(*db.User)
if !ok || user == nil {
return service.Principal{}, false
}
sess, _ := r.Context().Value(SessionKey).(*db.Session)
return service.Principal{User: user, Session: sess}, true
}
// AuthMiddleware reads the "Authorization: Bearer <token>" header, validates
// the session, and injects the user and session into the request context.
// Returns 401 if the token is missing, invalid, or the session is expired.
+48 -2
View File
@@ -7,12 +7,17 @@
package api
import (
"context"
"database/sql"
"errors"
"io"
"log/slog"
"net/http"
"strconv"
"strings"
"github.com/J3vb/OwnCord/Server/admin"
"github.com/J3vb/OwnCord/Server/db"
"github.com/J3vb/OwnCord/Server/plugin"
"github.com/go-chi/chi/v5"
)
@@ -26,13 +31,14 @@ const maxPluginUploadBytes = 16 * 1024 * 1024
type PluginAdminHandler struct {
registry *plugin.Registry
store plugin.PluginStore
audit db.Auditor // nil disables audit writes (unit tests)
}
// NewPluginAdminHandler builds an http.Handler that the router can mount.
// Pass a nil registry when plugin support is disabled — the handler then
// reports an empty list and 503 on lifecycle calls.
func NewPluginAdminHandler(registry *plugin.Registry, st plugin.PluginStore) http.Handler {
h := &PluginAdminHandler{registry: registry, store: st}
func NewPluginAdminHandler(registry *plugin.Registry, st plugin.PluginStore, audit db.Auditor) http.Handler {
h := &PluginAdminHandler{registry: registry, store: st, audit: audit}
r := chi.NewRouter()
r.Get("/", h.list)
r.Post("/install", h.install)
@@ -104,6 +110,7 @@ func (h *PluginAdminHandler) install(w http.ResponseWriter, r *http.Request) {
})
return
}
h.writeAudit(r, "plugin_install", h.installedID(r.Context(), name), name)
writeJSON(w, http.StatusCreated, map[string]any{"name": name})
}
@@ -170,14 +177,53 @@ func (h *PluginAdminHandler) uninstall(w http.ResponseWriter, r *http.Request) {
http.Error(w, "plugin runtime disabled", http.StatusServiceUnavailable)
return
}
// Registry.UninstallPlugin is idempotent on an unknown id, so check the
// row here: a stale or repeated delete must answer 404 and must not
// record a plugin_uninstall that never happened.
if h.store != nil {
if _, err := h.store.GetPlugin(r.Context(), id); err != nil {
if errors.Is(err, sql.ErrNoRows) {
http.Error(w, "plugin not found", http.StatusNotFound)
return
}
slog.Error("plugin lookup failed", "id", id, "error", err)
http.Error(w, "internal server error", http.StatusInternalServerError)
return
}
}
if err := h.registry.UninstallPlugin(r.Context(), id); err != nil {
slog.Error("plugin uninstall failed", "id", id, "error", err)
http.Error(w, "internal server error", http.StatusInternalServerError)
return
}
h.writeAudit(r, "plugin_uninstall", id, "")
w.WriteHeader(http.StatusNoContent)
}
// writeAudit records a plugin lifecycle mutation (B2-6) against the admin
// principal RequireAdminAuth put on the request. A nil auditor (unit tests
// that only exercise the HTTP surface) records nothing.
func (h *PluginAdminHandler) writeAudit(r *http.Request, action string, pluginID int64, detail string) {
if h.audit == nil {
return
}
db.WriteAudit(context.WithoutCancel(r.Context()), h.audit, admin.ActorIDFromContext(r.Context()),
action, "plugin", pluginID, detail)
}
// installedID resolves a freshly installed plugin's row id for its audit
// entry; 0 when the store cannot answer (the name in detail still identifies it).
func (h *PluginAdminHandler) installedID(ctx context.Context, name string) int64 {
if h.store == nil {
return 0
}
row, err := h.store.GetPluginByName(ctx, name)
if err != nil || row == nil {
return 0
}
return row.ID
}
// pluginRuntimeState reports whether lifecycle calls will work, for the
// X-Plugin-Runtime response header. A nil registry means plugin support is
// compiled/configured off and every lifecycle endpoint answers 503.
+10 -10
View File
@@ -40,7 +40,7 @@ func openPluginTestDB(t *testing.T) *db.DB {
}
func TestPluginsHandlerListEmptyWhenRegistryNil(t *testing.T) {
h := NewPluginAdminHandler(nil, nil)
h := NewPluginAdminHandler(nil, nil, nil)
req := httptest.NewRequest("GET", "/", nil)
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
@@ -53,7 +53,7 @@ func TestPluginsHandlerListEmptyWhenRegistryNil(t *testing.T) {
}
func TestPluginsHandlerInstallRejectsWhenRegistryNil(t *testing.T) {
h := NewPluginAdminHandler(nil, nil)
h := NewPluginAdminHandler(nil, nil, nil)
body, contentType := buildZipUpload(t, validPluginZip(t))
req := httptest.NewRequest("POST", "/install", body)
req.Header.Set("Content-Type", contentType)
@@ -66,7 +66,7 @@ func TestPluginsHandlerInstallRejectsWhenRegistryNil(t *testing.T) {
func TestPluginsHandlerInstallRejectsNonZipContentType(t *testing.T) {
reg := newTestPluginRegistry(t)
h := NewPluginAdminHandler(reg, nil)
h := NewPluginAdminHandler(reg, nil, nil)
// Build a multipart body whose file part is labelled as text/plain.
var buf bytes.Buffer
@@ -94,7 +94,7 @@ func TestPluginsHandlerInstallRejectsNonZipContentType(t *testing.T) {
func TestPluginsHandlerInstallRejectsNonZipMagic(t *testing.T) {
reg := newTestPluginRegistry(t)
h := NewPluginAdminHandler(reg, nil)
h := NewPluginAdminHandler(reg, nil, nil)
body, contentType := buildZipUpload(t, []byte("this is definitely not a zip"))
req := httptest.NewRequest("POST", "/install", body)
@@ -111,7 +111,7 @@ func TestPluginsHandlerInstallHappyPath(t *testing.T) {
mem := openPluginTestDB(t)
// Wire the store into the handler so /list can show the new row. The
// registry already writes via its own PluginStore.
h := NewPluginAdminHandler(reg, mem)
h := NewPluginAdminHandler(reg, mem, nil)
body, contentType := buildZipUpload(t, validPluginZip(t))
req := httptest.NewRequest("POST", "/install", body)
req.Header.Set("Content-Type", contentType)
@@ -128,14 +128,14 @@ func TestPluginsHandlerInstallHappyPath(t *testing.T) {
// The admin panel's empty state distinguishes "no plugins installed" from
// "the runtime is off", which it can only do from this header.
func TestPluginsHandlerListReportsRuntimeState(t *testing.T) {
off := NewPluginAdminHandler(nil, nil)
off := NewPluginAdminHandler(nil, nil, nil)
rec := httptest.NewRecorder()
off.ServeHTTP(rec, httptest.NewRequest("GET", "/", nil))
if got := rec.Header().Get("X-Plugin-Runtime"); got != "disabled" {
t.Fatalf("nil registry: X-Plugin-Runtime = %q, want %q", got, "disabled")
}
on := NewPluginAdminHandler(newTestPluginRegistry(t), openPluginTestDB(t))
on := NewPluginAdminHandler(newTestPluginRegistry(t), openPluginTestDB(t), nil)
rec = httptest.NewRecorder()
on.ServeHTTP(rec, httptest.NewRequest("GET", "/", nil))
if got := rec.Header().Get("X-Plugin-Runtime"); got != "enabled" {
@@ -147,7 +147,7 @@ func TestPluginsHandlerListReportsRuntimeState(t *testing.T) {
// Go field names and every column renders empty.
func TestPluginsHandlerListUsesSnakeCaseJSON(t *testing.T) {
reg, mem := newTestPluginRegistryWithStore(t)
h := NewPluginAdminHandler(reg, mem)
h := NewPluginAdminHandler(reg, mem, nil)
body, contentType := buildZipUpload(t, validPluginZip(t))
req := httptest.NewRequest("POST", "/install", body)
@@ -178,7 +178,7 @@ func TestPluginsHandlerListUsesSnakeCaseJSON(t *testing.T) {
}
func TestPluginsHandlerEnableDisableUninstallReturn503WhenRegistryNil(t *testing.T) {
h := NewPluginAdminHandler(nil, nil)
h := NewPluginAdminHandler(nil, nil, nil)
for _, tc := range []struct{ method, path string }{
{"POST", "/1/enable"},
{"POST", "/1/disable"},
@@ -195,7 +195,7 @@ func TestPluginsHandlerEnableDisableUninstallReturn503WhenRegistryNil(t *testing
func TestPluginsHandlerLifecycleInvalidID(t *testing.T) {
reg := newTestPluginRegistry(t)
h := NewPluginAdminHandler(reg, nil)
h := NewPluginAdminHandler(reg, nil, nil)
req := httptest.NewRequest("POST", "/not-an-int/enable", nil)
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
+44 -2
View File
@@ -23,6 +23,48 @@ import (
// ─── Request / Response types ────────────────────────────────────────────────
// userResponse is the caller's own user record, the shape auth responses and
// PATCH /users/me return. It lives beside the profile handler because this is
// the file that still sees db.User; the auth handlers get it through
// toUserResponse without naming db (B3-2).
type userResponse struct {
ID int64 `json:"id"`
Username string `json:"username"`
Avatar string `json:"avatar,omitempty"`
// DisplayName and About are always present (null = unset) so the settings
// form can tell "cleared" from "the server does not know this field".
DisplayName *string `json:"display_name"`
About *string `json:"about"`
// CustomStatus is the user's own free-text status line.
CustomStatus *string `json:"custom_status"`
// Status is the user's own true status, invisible included. This response
// only ever describes the caller, so there is nothing to hide from them.
Status string `json:"status"`
RoleID int64 `json:"role_id"`
TOTPEnabled bool `json:"totp_enabled"`
CreatedAt string `json:"created_at"`
}
// toUserResponse converts a db.User to the API response shape.
func toUserResponse(u *db.User) *userResponse {
avatar := ""
if u.Avatar != nil {
avatar = *u.Avatar
}
return &userResponse{
ID: u.ID,
Username: u.Username,
Avatar: avatar,
DisplayName: u.DisplayName,
About: u.About,
CustomStatus: u.CustomStatus,
Status: u.Status,
RoleID: u.RoleID,
TOTPEnabled: u.TOTPSecret != nil,
CreatedAt: u.CreatedAt,
}
}
// updateProfileRequest is the JSON body for PATCH /api/v1/users/me.
// identity_public_key, when present, publishes the client's long-term E2EE
// identity public key (F3 voice E2EE TOFU); omitted = leave unchanged.
@@ -392,8 +434,8 @@ func handleChangePassword(svc *service.Services, limiter *auth.RateLimiter) http
// Verify old password using constant-time bcrypt comparison.
failKey := auth.Key("pw_confirm_fail", user.ID)
if !auth.CheckPassword(user.PasswordHash, req.OldPassword) {
if !limiter.Allow(failKey, pwConfirmFailureThreshold, pwConfirmFailureWindow) {
limiter.Lockout(r.Context(), lockKey, pwConfirmLockoutDuration)
if !limiter.Allow(failKey, service.PwConfirmFailureThreshold, service.PwConfirmFailureWindow) {
limiter.Lockout(r.Context(), lockKey, service.PwConfirmLockoutDuration)
}
writeJSON(w, http.StatusForbidden, errorResponse{
Error: "FORBIDDEN", Message: "incorrect password",
+75
View File
@@ -0,0 +1,75 @@
//go:build otel
package api
// OC-0346: the recovered-panic log record must carry the request's trace_id.
// Only the otel build can produce one (telemetry_default.go's
// TraceIDFromContext is hard-wired to ""), so this file is tagged and CI's
// untagged test run does not see it. Run it with
//
// go test -tags otel -count=1 -run TestRecoverer_PanicLogCarriesTraceID ./api/
import (
"bytes"
"context"
"encoding/json"
"log/slog"
"net/http"
"net/http/httptest"
"regexp"
"strings"
"testing"
"github.com/go-chi/chi/v5"
"github.com/J3vb/OwnCord/Server/config"
"github.com/J3vb/OwnCord/Server/telemetry"
)
// TestRecoverer_PanicLogCarriesTraceID drives a panicking handler through the
// real routerMiddleware stack with tracing on and asserts the panic record
// carries the span's trace id. Before the fix recoverer was mounted ahead of
// telemetry.HTTPMiddleware, so it captured the trace id from a context that
// had no span yet and the attribute was always dropped.
func TestRecoverer_PanicLogCarriesTraceID(t *testing.T) {
shutdown, err := telemetry.Init(context.Background(), config.TelemetryConfig{
Enabled: true,
Exporter: "prometheus", // a real tracer provider, no network exporter
ServiceName: "recoverer-test",
})
if err != nil {
t.Fatalf("telemetry.Init: %v", err)
}
t.Cleanup(func() { _ = shutdown(context.Background()) })
var logs bytes.Buffer
prev := slog.Default()
slog.SetDefault(slog.New(slog.NewJSONHandler(&logs, nil)))
t.Cleanup(func() { slog.SetDefault(prev) })
r := chi.NewRouter()
routerMiddleware(r, &config.Config{})
r.Get("/boom", func(http.ResponseWriter, *http.Request) { panic("boom") })
rr := httptest.NewRecorder()
r.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/boom", nil))
if rr.Code != http.StatusInternalServerError {
t.Fatalf("status = %d, want 500 (panic not recovered)", rr.Code)
}
var rec map[string]any
for _, line := range strings.Split(strings.TrimSpace(logs.String()), "\n") {
var m map[string]any
if json.Unmarshal([]byte(line), &m) == nil && m["msg"] == "http handler panic recovered" {
rec = m
break
}
}
if rec == nil {
t.Fatalf("no recovered-panic record in logs:\n%s", logs.String())
}
traceID, _ := rec["trace_id"].(string)
if !regexp.MustCompile(`^[0-9a-f]{32}$`).MatchString(traceID) {
t.Fatalf("panic record trace_id = %q, want the span's 32-hex trace id; record = %v", traceID, rec)
}
}
+83 -111
View File
@@ -5,10 +5,10 @@ import (
"context"
"database/sql"
"encoding/json"
"errors"
"fmt"
"log/slog"
"net/http"
"net/url"
"slices"
"time"
@@ -30,13 +30,37 @@ import (
"github.com/go-chi/chi/v5/middleware"
)
// NewRouter builds and returns the fully configured HTTP handler, the
// WebSocket hub (so the caller can call hub.GracefulStop on shutdown), and a
// cleanup function that stops background goroutines (e.g. rate-limiter cleanup).
// Runtime holds the process-level collaborators NewRouter mounts its routes
// over. Until B3-3 NewRouter built all of them itself and returned the hub,
// while main.go set the hub's event persister and event store after it
// returned — two owners of one hub. internal/app builds them now
// (app.StartRuntime), applies every pre-Run setter from that one place, and
// hands the result in here; B3-4 turns the required setters into validated
// constructor options at the same single call site.
type Runtime struct {
// Hub is already wired and running: StartRuntime starts its dispatch
// goroutine after the last pre-Run setter, exactly where NewRouter used
// to. Stopping it is the caller's job (App.Close's "hub" step).
Hub *ws.Hub
// Limiter backs both the hub and every rate-limited route. One instance:
// it persists auth lockouts, so a second copy would split that state.
Limiter *auth.RateLimiter
// Services is the shared service layer — the same instance the hub holds,
// so the permission cache the hub invalidates is the one the handlers read.
Services *service.Services
// VoiceEnabled is whether StartRuntime's LiveKit client was built. The
// webhook, LiveKit health and signalling-proxy routes are mounted only
// then — the `lkErr == nil` guard that used to live in this package.
VoiceEnabled bool
}
// NewRouter builds and returns the fully configured HTTP handler and a
// cleanup function that stops background goroutines (e.g. rate-limiter
// cleanup).
//
// pluginRegistry may be nil — in that case the plugin admin endpoints respond
// with 503 on lifecycle calls and an empty list on read.
func NewRouter(cfg *config.Config, database *db.DB, ver string, logBuf *admin.RingBuffer, pluginRegistry *plugin.Registry) (http.Handler, *ws.Hub, func()) {
func NewRouter(cfg *config.Config, database *db.DB, ver string, logBuf *admin.RingBuffer, pluginRegistry *plugin.Registry, rt Runtime) (http.Handler, func()) {
// Install the auth rate multiplier before any route mounts read it.
setAuthRateScale(cfg.Security.AuthRateLimitMultiplier)
@@ -59,9 +83,10 @@ func NewRouter(cfg *config.Config, database *db.DB, ver string, logBuf *admin.Ri
healthHandler := handleHealth(routerHealthDeps(cfg, database, &getOnlineUsers, &hubAlive))
r.Get("/health", healthHandler)
// Shared rate limiter for auth endpoints. Lockouts are persisted to the
// database so they survive server restarts (M2 security hardening).
limiter := auth.NewPersistentRateLimiter(database)
// Shared rate limiter for auth endpoints, built by internal/app so the
// hub and these routes share one instance (its lockouts are persisted to
// the database and survive restarts — M2 security hardening).
limiter := rt.Limiter
// Start background cleanup of stale rate-limiter entries to prevent
// unbounded memory growth. The goroutine exits when stopCh is closed.
@@ -75,9 +100,8 @@ func NewRouter(cfg *config.Config, database *db.DB, ver string, logBuf *admin.Ri
})
// Service layer — centralizes business logic for REST and WS handlers.
// *db.DB satisfies service.Store directly (the store abstraction was
// removed in D3).
svc := service.New(database, limiter)
// Built by internal/app alongside the hub, which holds the same instance.
svc := rt.Services
// Auth routes are mounted after hub creation (below) so self-service
// account deletion can broadcast member_ban like the admin ban path does.
@@ -102,24 +126,22 @@ func NewRouter(cfg *config.Config, database *db.DB, ver string, logBuf *admin.Ri
// File upload and serving routes.
store, storeErr := routerUploadRoutes(r, database, limiter, cfg, svc.Permissions)
// WebSocket hub — WS does its own in-band auth, so no AuthMiddleware here.
hub := ws.NewHub(database, limiter, svc)
// Replay budget knobs must land before hub.Run starts (below).
hub.ConfigureReplay(cfg.EventPersistence.ReplayRingSize, cfg.EventPersistence.ReplayColdLimit)
// WebSocket hub — built, wired and started by internal/app; WS does its
// own in-band auth, so no AuthMiddleware here.
hub := rt.Hub
getOnlineUsers = func() int { return hub.ClientCount() }
hubAlive = func() bool { return hub.DispatchAlive() }
// Auth routes: register, login, logout, me. Mounted with the hub as the
// AuthBroadcaster so DELETE /api/v1/auth/account (self-service account
// deletion) fans out member_ban and force-disconnects the deleted user's
// own socket, exactly like the admin ban path does for the same
// anonymise-and-ban DB state.
MountAuthRoutes(r, database, limiter, cfg.Server.TrustedProxies, totpKey, hub)
// Auth routes. The service is built after the hub, with the hub as its
// AuthBroadcaster, so DELETE /api/v1/auth/account fans out member_ban and
// force-disconnects the deleted user's own socket exactly like the admin
// ban path does for the same DB state.
MountAuthRoutes(r, service.NewAuthService(database, limiter, totpKey, hub), AuthMiddleware(database), limiter, cfg.Server.TrustedProxies)
routerPluginWiring(hub, pluginRegistry)
// Voice: LiveKit client, optional companion process, webhook and proxy routes.
routerVoiceRoutes(r, cfg, limiter, hub)
// Voice: webhook, LiveKit health and signalling-proxy routes. The client
// and the companion process are built by internal/app, which reports
// through rt.VoiceEnabled whether there is anything to mount.
routerVoiceRoutes(r, cfg, limiter, hub, rt.VoiceEnabled)
// Profile routes: update profile, change password, session management.
// Mounted after hub creation so the hub can broadcast user_update events.
@@ -158,7 +180,6 @@ func NewRouter(cfg *config.Config, database *db.DB, ver string, logBuf *admin.Ri
Get("/api/v1/diagnostics/connectivity",
handleDiagnosticsConnectivity(cfg, ver, hub))
go hub.Run()
r.Get("/api/v1/ws", ws.ServeWS(hub, database, cfg.Server.AllowedOrigins, cfg.Server.MaxWSConnections))
routerMetricsRoutes(r, cfg, database, svc, hub)
@@ -181,7 +202,7 @@ func NewRouter(cfg *config.Config, database *db.DB, ver string, logBuf *admin.Ri
// which case lifecycle calls return 503 and list returns []).
r.Group(func(r chi.Router) {
r.Use(admin.RequireAdminAuth(database))
r.Mount("/api/v1/admin/plugins", NewPluginAdminHandler(pluginRegistry, database))
r.Mount("/api/v1/admin/plugins", NewPluginAdminHandler(pluginRegistry, database, database))
})
})
@@ -205,7 +226,7 @@ func NewRouter(cfg *config.Config, database *db.DB, ver string, logBuf *admin.Ri
close(limiterStopCh)
}
return r, hub, cleanup
return r, cleanup
}
// routerTOTPKey loads (or auto-generates) the AES-256 key NewRouter hands to the
@@ -272,7 +293,8 @@ func routerHealthDeps(cfg *config.Config, database *db.DB, getOnlineUsers *func(
}
// routerMiddleware installs NewRouter's global middleware stack. The order is a
// security property (request-id binding before the logger reads it, security
// security property (request-id binding before the logger reads it, tracing
// before panic recovery so the panic log carries the trace id, security
// headers and the body cap before any handler runs) — keep it exactly as
// written.
func routerMiddleware(r chi.Router, cfg *config.Config) {
@@ -283,12 +305,14 @@ func routerMiddleware(r chi.Router, cfg *config.Config) {
// NOTE: middleware.RealIP is intentionally omitted — trusting X-Real-IP from
// any source allows IP spoofing for rate-limit bypass. IP header trust is now
// handled explicitly in clientIPWithProxies using the trusted_proxies config.
r.Use(recoverer) // slog-routing panic recovery (replaces chi's stderr-only Recoverer)
r.Use(requestLogger) // structured request/response logging
// Phase B Step 8 — OpenTelemetry HTTP tracing. No-op when telemetry is
// disabled or the otel build tag is not set, so this is safe to mount
// unconditionally.
// unconditionally. Mounted ahead of recoverer, which snapshots the trace
// id before dispatch: the span must already exist for the panic record to
// carry trace_id (OC-0346).
r.Use(telemetry.HTTPMiddleware())
r.Use(recoverer) // slog-routing panic recovery (replaces chi's stderr-only Recoverer)
r.Use(requestLogger) // structured request/response logging
r.Use(SecurityHeadersWithTLS(cfg.TLS.Mode))
r.Use(MaxBodySizeUnless(defaultMaxBodySize, bodyCapExemptPrefixes...))
@@ -317,64 +341,14 @@ func routerUploadRoutes(r chi.Router, database *db.DB, limiter *auth.RateLimiter
return store, storeErr
}
// routerPluginWiring wires the plugin registry and its event sink into the hub.
func routerPluginWiring(hub *ws.Hub, pluginRegistry *plugin.Registry) {
// Phase C Step 9 — wire plugin registry and event sink into the hub.
// nil pluginRegistry means plugins are disabled; the hub no-ops cleanly.
if pluginRegistry != nil {
hub.SetPluginRegistry(pluginRegistry)
sink := pluginRegistry.Sink()
sink.SetBroadcaster(hub.BroadcastToChannel)
hub.SetPluginEventSink(sink)
}
}
// routerVoiceRoutes creates the LiveKit client, optionally starts the companion
// LiveKit process, and mounts the webhook, LiveKit health and signaling-proxy
// routes. Voice is disabled — and none of those routes are mounted — when the
// client fails to build.
func routerVoiceRoutes(r chi.Router, cfg *config.Config, limiter *auth.RateLimiter, hub *ws.Hub) {
// Create LiveKit client if voice config is present; voice is disabled on failure.
lk, lkErr := ws.NewLiveKitClient(&cfg.Voice)
if lkErr != nil {
slog.Warn("failed to create LiveKit client, voice disabled", "error", lkErr)
} else {
hub.SetLiveKit(lk)
// Optionally start a companion LiveKit process — either from a
// configured binary or via checksum-verified auto-download (the
// download happens in the background inside Start).
if cfg.Voice.LiveKitBinaryPath != "" || cfg.Voice.AutoDownloadLiveKit {
proc := ws.NewLiveKitProcess(&cfg.Voice, &cfg.TLS, cfg.Server.DataDir)
// Register the process with the hub BEFORE calling Start(), and
// keep it registered even if Start() fails (OC-0019). The only
// consumer of h.lkProcess is the voice_join guard
// (`h.lkProcess != nil && !h.lkProcess.IsRunning()`), which reads
// a nil process as "LiveKit is externally managed, don't check".
// That is the wrong reading here: OwnCord was told to manage
// LiveKit and failed to launch it, so joins must fail closed via
// IsRunning() == false, not be waved through with no SFU
// running. IsRunning() is false for a proc whose Start() never
// got as far as spawning cmd, and Hub.Stop's lkProcess.Stop() is
// safe to call on a never-started proc.
hub.SetLiveKitProcess(proc)
if startErr := proc.Start(); startErr != nil {
slog.Error("failed to start LiveKit process", "error", startErr)
}
}
}
// Warn if LiveKit is externally managed and webhook may be blocked by admin CIDRs.
if lkErr == nil && cfg.Voice.LiveKitBinaryPath == "" && !cfg.Voice.AutoDownloadLiveKit {
lkHost := ""
if u, parseErr := url.Parse(cfg.Voice.LiveKitURL); parseErr == nil {
lkHost = u.Hostname()
}
if lkHost != "" && lkHost != "localhost" && lkHost != "127.0.0.1" && lkHost != "::1" {
slog.Warn("LiveKit is externally managed but webhook endpoint is admin-IP-restricted — "+
"add the LiveKit server's IP to livekit_webhook_allowed_cidrs or webhooks will be silently dropped",
"livekit_host", lkHost)
}
// routerVoiceRoutes mounts the LiveKit webhook, health and signalling-proxy
// routes. voiceEnabled is internal/app's report that the LiveKit client was
// built (StartRuntime); voice is disabled — and none of these routes are
// mounted — when it was not. Until B3-3 this function also created the client
// and the companion process, which is what gave the hub a second owner.
func routerVoiceRoutes(r chi.Router, cfg *config.Config, limiter *auth.RateLimiter, hub *ws.Hub, voiceEnabled bool) {
if !voiceEnabled {
return
}
// LiveKit webhook endpoint (no auth middleware — uses LiveKit JWT
@@ -383,28 +357,26 @@ func routerVoiceRoutes(r chi.Router, cfg *config.Config, limiter *auth.RateLimit
// externally-hosted LiveKit can be admitted WITHOUT widening the admin
// panel's perimeter to the SFU's network. Falls back to
// admin_allowed_cidrs when unset.
if lkErr == nil {
webhookCIDRs := cfg.Server.LiveKitWebhookCIDRs()
r.With(AdminIPRestrict(webhookCIDRs, cfg.Server.TrustedProxies)).
Post("/api/v1/livekit/webhook",
ws.MountWebhookRoute(hub, cfg.Voice.LiveKitAPIKey, cfg.Voice.LiveKitAPISecret))
webhookCIDRs := cfg.Server.LiveKitWebhookCIDRs()
r.With(AdminIPRestrict(webhookCIDRs, cfg.Server.TrustedProxies)).
Post("/api/v1/livekit/webhook",
ws.MountWebhookRoute(hub, cfg.Voice.LiveKitAPIKey, cfg.Voice.LiveKitAPISecret))
// LiveKit health check — same perimeter as the webhook.
r.With(AdminIPRestrict(webhookCIDRs, cfg.Server.TrustedProxies)).
Get("/api/v1/livekit/health", handleLiveKitHealth(hub))
// LiveKit health check — same perimeter as the webhook.
r.With(AdminIPRestrict(webhookCIDRs, cfg.Server.TrustedProxies)).
Get("/api/v1/livekit/health", handleLiveKitHealth(hub))
// Reverse proxy LiveKit signaling through OwnCord's HTTPS server.
// This avoids mixed-content blocks (secure page → insecure WS).
// Client connects to wss://server:8443/livekit/* → ws://localhost:7880/*
//
// NOTE: AuthMiddleware is intentionally omitted. The LiveKit JS SDK's
// signal requests don't carry OwnCord session tokens — authentication
// is handled by the LiveKit JWT (access_token query param) which the
// LiveKit server validates. Users can only obtain a valid JWT through
// the authenticated voice_join WS flow. Rate limiting prevents abuse.
r.With(rateLimitMiddlewareWithPrefix(limiter, "livekit_proxy:", livekitProxyRateLimitPerMinute, time.Minute, cfg.Server.TrustedProxies)).
Handle("/livekit/*", http.StripPrefix("/livekit", NewLiveKitProxy(cfg.Voice.LiveKitURL, cfg.Server.AllowedOrigins)))
}
// Reverse proxy LiveKit signaling through OwnCord's HTTPS server.
// This avoids mixed-content blocks (secure page → insecure WS).
// Client connects to wss://server:8443/livekit/* → ws://localhost:7880/*
//
// NOTE: AuthMiddleware is intentionally omitted. The LiveKit JS SDK's
// signal requests don't carry OwnCord session tokens — authentication
// is handled by the LiveKit JWT (access_token query param) which the
// LiveKit server validates. Users can only obtain a valid JWT through
// the authenticated voice_join WS flow. Rate limiting prevents abuse.
r.With(rateLimitMiddlewareWithPrefix(limiter, "livekit_proxy:", livekitProxyRateLimitPerMinute, time.Minute, cfg.Server.TrustedProxies)).
Handle("/livekit/*", http.StripPrefix("/livekit", NewLiveKitProxy(cfg.Voice.LiveKitURL, cfg.Server.AllowedOrigins)))
}
// routerMetricsRoutes mounts the JSON metrics endpoint and, when an OTel
@@ -651,7 +623,7 @@ func recoverer(next http.Handler) http.Handler {
defer func() {
if rec := recover(); rec != nil {
// Preserve chi's behaviour of not swallowing the abort sentinel.
if rec == http.ErrAbortHandler {
if err, ok := rec.(error); ok && errors.Is(err, http.ErrAbortHandler) {
panic(rec)
}
attrs := []any{
@@ -1,11 +1,11 @@
package api_test
// router_delete_account_broadcast_test.go pins the production wiring for
// OC-0048: NewRouter (router.go) must pass the WS hub to MountAuthRoutes as
// its optional AuthBroadcaster so self-service account deletion fans out
// member_ban exactly like the admin ban path does. MountAuthRoutes is called
// before the hub exists in router.go, so the only production call site used
// to omit the broadcaster entirely — handleDeleteAccount's
// OC-0048: NewRouter (router.go) must hand the WS hub to
// service.NewAuthService as its AuthBroadcaster so self-service account
// deletion fans out member_ban exactly like the admin ban path does. Auth
// routes were once mounted before the hub existed in router.go, so the only
// production call site used to omit the broadcaster entirely — handleDeleteAccount's
// `if broadcaster != nil` guard was never taken outside tests that construct
// their own fake broadcaster (see auth_handler_delete_broadcast_test.go,
// which only proves the handler itself works when a broadcaster IS passed).
@@ -35,6 +35,7 @@ import (
"github.com/J3vb/OwnCord/Server/auth"
"github.com/J3vb/OwnCord/Server/config"
"github.com/J3vb/OwnCord/Server/db"
"github.com/J3vb/OwnCord/Server/internal/app"
)
// dialAndAuthWS opens a WS connection against srv and completes the auth
@@ -97,7 +98,8 @@ func TestNewRouter_DeleteAccount_BroadcastsMemberBanOverWS(t *testing.T) {
},
}
handler, _, cleanup := api.NewRouter(cfg, database, "test", nil, nil)
rt := app.StartRuntime(cfg, database, nil)
handler, cleanup := api.NewRouter(cfg, database, "test", nil, nil, rt)
t.Cleanup(cleanup)
newUserSession := func(username string) (int64, string) {
@@ -169,7 +171,7 @@ func TestNewRouter_DeleteAccount_BroadcastsMemberBanOverWS(t *testing.T) {
if !sawMemberBan {
t.Fatal("no member_ban WS broadcast for the deleted user observed on a second client — " +
"router.go's MountAuthRoutes call must pass the hub as the optional " +
"AuthBroadcaster (mount it after ws.NewHub, not before)")
"router.go must build service.NewAuthService with the hub as its " +
"AuthBroadcaster (after ws.NewHub, not before)")
}
}
+3 -1
View File
@@ -30,6 +30,7 @@ import (
"github.com/J3vb/OwnCord/Server/auth"
"github.com/J3vb/OwnCord/Server/config"
"github.com/J3vb/OwnCord/Server/db"
"github.com/J3vb/OwnCord/Server/internal/app"
)
// voiceJoinWSMsg builds a raw voice_join WebSocket frame for the given channel.
@@ -72,7 +73,8 @@ func TestNewRouter_LiveKitProcessStartFailure_VoiceJoinFailsClosed(t *testing.T)
},
}
handler, _, cleanup := api.NewRouter(cfg, database, "test", nil, nil)
rt := app.StartRuntime(cfg, database, nil)
handler, cleanup := api.NewRouter(cfg, database, "test", nil, nil, rt)
t.Cleanup(cleanup)
// role_id=1 -> Owner, so CONNECT_VOICE is granted and the test isolates
+3 -1
View File
@@ -10,6 +10,7 @@ import (
"github.com/J3vb/OwnCord/Server/api"
"github.com/J3vb/OwnCord/Server/config"
"github.com/J3vb/OwnCord/Server/db"
"github.com/J3vb/OwnCord/Server/internal/app"
)
// setupRouter creates a test router with an in-memory database.
@@ -32,7 +33,8 @@ func setupRouter(t *testing.T) http.Handler {
},
}
handler, _, cleanup := api.NewRouter(cfg, database, "test", nil, nil)
rt := app.StartRuntime(cfg, database, nil)
handler, cleanup := api.NewRouter(cfg, database, "test", nil, nil, rt)
t.Cleanup(cleanup)
return handler
}
+2 -1
View File
@@ -6,6 +6,7 @@ import (
"github.com/J3vb/OwnCord/Server/api"
"github.com/J3vb/OwnCord/Server/config"
"github.com/J3vb/OwnCord/Server/db"
"github.com/J3vb/OwnCord/Server/internal/app"
)
// TestNewRouterRefusesToStartWithMalformedTOTPKey pins OC-0228: a malformed
@@ -49,7 +50,7 @@ func TestNewRouterRefusesToStartWithMalformedTOTPKey(t *testing.T) {
panicked = true
}
}()
api.NewRouter(cfg, database, "test", nil, nil)
api.NewRouter(cfg, database, "test", nil, nil, app.StartRuntime(cfg, database, nil))
}()
if !panicked {
@@ -0,0 +1,2 @@
go test fuzz v1
string("報告書.pdf")
@@ -0,0 +1,2 @@
go test fuzz v1
string("emoji.bin")
@@ -0,0 +1,2 @@
go test fuzz v1
string("alice-avatar.png")
@@ -0,0 +1,2 @@
go test fuzz v1
string("Ünïcode Ärchive.tar.gz")
@@ -0,0 +1,2 @@
go test fuzz v1
string("https://cdn.example.com/avatars/1/abc.webp?v=2")
@@ -0,0 +1,2 @@
go test fuzz v1
string("https://example.com:8443/a.png#f")
@@ -0,0 +1,2 @@
go test fuzz v1
string("https://fixtures.invalid/alice-avatar.png")
@@ -0,0 +1,2 @@
go test fuzz v1
string("fixture profile text")
@@ -0,0 +1,2 @@
go test fuzz v1
string("Alice Fixture")
@@ -0,0 +1,2 @@
go test fuzz v1
string("fixture custom status")
+37
View File
@@ -0,0 +1,37 @@
package api_test
// Codex P2 on PR #1454 (B3-9, OC-0377): once a user's totp_fail cap is
// exhausted, verify-totp must refuse before it loads the user and decrypts
// the secret — otherwise rotating source IPs (the per-user cap is the only
// cross-IP defence) could drive store reads and decryptions without bound.
// The read-only Check runs ahead of the store read; the atomic Allow that
// records the attempt still sits after it, so an outage charges nothing.
import (
"net/http"
"testing"
"time"
"github.com/J3vb/OwnCord/Server/auth"
)
func TestVerifyTOTP_ExhaustedCapRefusesBeforeStoreRead(t *testing.T) {
database := newAuthTestDB(t)
limiter := auth.NewRateLimiter()
router := buildAuthRouter(database, limiter)
uid := seedUser(t, database, "capped", "correctPass1", 4)
secret := enrolTOTP(t, database, uid)
pt := loginPartial(t, router, "capped", "correctPass1", "", "")
// Exhaust the per-user budget (service: totpFailureRateLimit = 10 in
// totpFailureWindow = 15 min) without a single HTTP failure.
for range 10 {
limiter.Allow(auth.Key("totp_fail", uid), 10, 15*time.Minute)
}
// Every user read now fails; an attempt that reaches the store answers
// 500 "two-factor verification temporarily unavailable" (OC-0377).
hideTable(t, database, "users")
rr := send(t, router, http.MethodPost, "/api/v1/auth/verify-totp", pt, "203.0.113.50", "", map[string]string{"code": totpCode(t, secret)})
wantErr(t, rr, http.StatusTooManyRequests, "RATE_LIMITED", "too many failed attempts, try again later")
}
+50 -360
View File
@@ -1,17 +1,13 @@
package api
import (
"context"
"encoding/json"
"errors"
"io"
"log/slog"
"net/http"
"strings"
"time"
"github.com/J3vb/OwnCord/Server/auth"
"github.com/J3vb/OwnCord/Server/db"
"github.com/J3vb/OwnCord/Server/service"
)
// ─── TOTP request/response types ─────────────────────────────────────────────
@@ -36,7 +32,9 @@ type totpEnableResponse struct {
// ─── Handlers ────────────────────────────────────────────────────────────────
func handleVerifyTOTP(database *db.DB, partialStore *auth.PartialAuthStore, limiter *auth.RateLimiter, usedTOTPCodes *auth.UsedTOTPCodeStore, totpKey []byte) http.HandlerFunc {
// handleVerifyTOTP processes POST /api/v1/auth/verify-totp: the bearer token
// is the partial-login challenge Login issued.
func handleVerifyTOTP(svc AuthService) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
partialToken, ok := auth.ExtractBearerToken(r)
if !ok {
@@ -47,15 +45,6 @@ func handleVerifyTOTP(database *db.DB, partialStore *auth.PartialAuthStore, limi
return
}
challenge, ok := partialStore.Lookup(partialToken)
if !ok {
writeJSON(w, http.StatusUnauthorized, errorResponse{
Error: "UNAUTHORIZED",
Message: "invalid or expired two-factor challenge",
})
return
}
var req verifyTotpRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeJSON(w, http.StatusBadRequest, errorResponse{
@@ -65,137 +54,21 @@ func handleVerifyTOTP(database *db.DB, partialStore *auth.PartialAuthStore, limi
return
}
totpRateLimitKey := auth.Key("totp_fail", challenge.UserID)
// Atomically record this attempt and reject once the per-user failure cap
// is reached. Recording up-front — rather than a read-only Check now and
// Allow only on failure — closes a TOCTOU where many concurrent requests
// reusing one valid partial token all pass the read-only check before any
// failure is recorded, defeating the per-user brute-force cap (the only
// cross-IP defence). A successful verification resets the counter below,
// so legitimate retries are not penalised.
// Deliberately NOT scaledAuthLimit: this cap is keyed per USER, and it
// is the only cross-IP brute-force defence on TOTP codes. The
// multiplier exists for shared-NAT per-IP limits; scaling a per-user
// threshold with it would hand a distributed attacker more guesses.
// Mirrors loginUserFailureThreshold staying unscaled in auth_handler.
if !limiter.Allow(totpRateLimitKey, totpFailureRateLimit, totpFailureWindow) {
writeJSON(w, http.StatusTooManyRequests, errorResponse{
Error: "RATE_LIMITED",
Message: "too many failed attempts, try again later",
})
return
}
user, secret, ok := totpChallengeSecret(w, r, database, totpKey, challenge.UserID)
if !ok {
return
}
if !auth.VerifyTOTPCodeOnce(secret, strings.TrimSpace(req.Code), time.Now().UTC(), user.ID, usedTOTPCodes) {
// The attempt was already recorded atomically up-front via
// limiter.Allow; only the per-partial-token counter is advanced here.
partialStore.RegisterFailure(partialToken, partialAuthMaxFailures)
writeJSON(w, http.StatusUnauthorized, errorResponse{
Error: "UNAUTHORIZED",
Message: "invalid two-factor code",
})
return
}
limiter.Reset(r.Context(), totpRateLimitKey)
if _, ok := partialStore.Consume(partialToken); !ok {
writeJSON(w, http.StatusUnauthorized, errorResponse{
Error: "UNAUTHORIZED",
Message: "invalid or expired two-factor challenge",
})
return
}
token, err := issueSession(r.Context(), database, user.ID, challenge.Device, challenge.IP)
res, err := svc.VerifyTOTP(r.Context(), partialToken, req.Code)
if err != nil {
writeJSON(w, http.StatusInternalServerError, errorResponse{
Error: "INTERNAL_ERROR",
Message: "failed to create session",
})
writeAuthError(r.Context(), w, err)
return
}
slog.Info("totp verified", "user_id", user.ID, "ip", challenge.IP)
db.WriteAudit(context.WithoutCancel(r.Context()), database, user.ID, "totp_verified", "user", user.ID,
"two-factor verification completed from "+challenge.IP)
writeJSON(w, http.StatusOK, authSuccessResponse{
Token: token,
Requires2FA: false,
User: toUserResponse(user),
})
writeJSON(w, http.StatusOK, authResponse(res))
}
}
// totpChallengeSecret resolves the user behind a partial-auth challenge and
// returns their decrypted TOTP secret. It writes its own refusal, so a false
// third result means the response is already complete.
func totpChallengeSecret(w http.ResponseWriter, r *http.Request, database *db.DB, totpKey []byte, challengeUserID int64) (*db.User, string, bool) {
user, err := database.GetUserByID(r.Context(), challengeUserID)
if err != nil || user == nil || user.TOTPSecret == nil {
writeJSON(w, http.StatusUnauthorized, errorResponse{
Error: "UNAUTHORIZED",
Message: "invalid or expired two-factor challenge",
})
return nil, "", false
}
// A ban can land inside the partial-token window; the login path
// refuses banned users right after the password compare, so the
// second factor must refuse them too.
if auth.IsEffectivelyBanned(user) {
writeJSON(w, http.StatusForbidden, errorResponse{
Error: "FORBIDDEN",
Message: "your account has been suspended",
})
return nil, "", false
}
secret, decErr := auth.DecryptTOTPSecret(totpKey, *user.TOTPSecret)
if decErr != nil {
slog.Error("failed to decrypt TOTP secret", "user_id", user.ID, "error", decErr)
writeJSON(w, http.StatusInternalServerError, errorResponse{
Error: "INTERNAL_ERROR",
Message: "failed to verify two-factor code",
})
return nil, "", false
}
return user, secret, true
}
func handleEnableTOTP(pendingStore *auth.PendingTOTPStore, limiter *auth.RateLimiter) http.HandlerFunc {
// handleEnableTOTP processes POST /api/v1/users/me/totp/enable.
func handleEnableTOTP(svc AuthService) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
user, ok := r.Context().Value(UserKey).(*db.User)
if !ok || user == nil {
writeJSON(w, http.StatusUnauthorized, errorResponse{
Error: "UNAUTHORIZED",
Message: "not authenticated",
})
return
}
// BUG-111: Per-user lockout for password confirmation.
lockKey := auth.Key("pw_confirm_lock", user.ID)
if limiter.IsLockedOut(lockKey) {
writeJSON(w, http.StatusTooManyRequests, errorResponse{
Error: "RATE_LIMITED",
Message: "too many failed attempts, try again later",
})
return
}
if user.TOTPSecret != nil && *user.TOTPSecret != "" {
writeJSON(w, http.StatusConflict, errorResponse{
Error: "TOTP_ALREADY_ENABLED",
Message: "disable 2FA before re-enabling",
})
p, ok := principal(r)
if !ok {
writeNotAuthenticated(w)
return
}
@@ -207,78 +80,25 @@ func handleEnableTOTP(pendingStore *auth.PendingTOTPStore, limiter *auth.RateLim
})
return
}
failKey := auth.Key("pw_confirm_fail", user.ID)
if err := requirePasswordConfirmation(user, req.Password); err != nil {
if !limiter.Allow(failKey, pwConfirmFailureThreshold, pwConfirmFailureWindow) {
limiter.Lockout(r.Context(), lockKey, pwConfirmLockoutDuration)
}
writeJSON(w, http.StatusBadRequest, errorResponse{
Error: "INVALID_INPUT",
Message: err.Error(),
})
return
}
limiter.Reset(r.Context(), failKey)
secret, err := auth.GenerateTOTPSecret()
qrURI, err := svc.EnableTOTP(r.Context(), p, req.Password)
if err != nil {
writeJSON(w, http.StatusInternalServerError, errorResponse{
Error: "INTERNAL_ERROR",
Message: "failed to generate two-factor secret",
})
writeAuthError(r.Context(), w, err)
return
}
pendingStore.Put(user.ID, secret)
writeJSON(w, http.StatusOK, totpEnableResponse{
QRURI: auth.BuildTOTPURI(user.Username, secret, "OwnCord"),
QRURI: qrURI,
BackupCodes: []string{},
})
}
}
// revokeOtherSessionsAfterAuthChange revokes every session for userID except
// keepSessionID as the security tail of a committed 2FA state change. It
// mirrors UserService.ChangePassword (service/user.go:262-274): a failure is
// logged and retried once (bounded compensating retry for transient write
// contention); if the retry also fails, revoked reports what did succeed and
// failed is true so the caller can report a partial success instead of
// silently claiming the other sessions were revoked when they were not.
func revokeOtherSessionsAfterAuthChange(ctx context.Context, database *db.DB, userID, keepSessionID int64, action string) (revoked int64, failed bool) {
revoked, err := database.DeleteOtherSessions(ctx, userID, keepSessionID)
if err != nil {
slog.Error("DeleteOtherSessions after "+action, "err", err, "user_id", userID)
revokedRetry, retryErr := database.DeleteOtherSessions(ctx, userID, keepSessionID)
if retryErr != nil {
slog.Error("DeleteOtherSessions retry after "+action, "err", retryErr, "user_id", userID)
return revoked, true
}
revoked += revokedRetry
}
if revoked > 0 {
slog.Info("revoked other sessions after "+action, "user_id", userID, "revoked", revoked)
}
return revoked, false
}
func handleConfirmTOTP(database *db.DB, pendingStore *auth.PendingTOTPStore, usedTOTPCodes *auth.UsedTOTPCodeStore, limiter *auth.RateLimiter, totpKey []byte) http.HandlerFunc {
// handleConfirmTOTP processes POST /api/v1/users/me/totp/confirm.
func handleConfirmTOTP(svc AuthService) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
user, ok := r.Context().Value(UserKey).(*db.User)
if !ok || user == nil {
writeJSON(w, http.StatusUnauthorized, errorResponse{
Error: "UNAUTHORIZED",
Message: "not authenticated",
})
return
}
// BUG-111: Per-user lockout for password confirmation.
lockKey := auth.Key("pw_confirm_lock", user.ID)
if limiter.IsLockedOut(lockKey) {
writeJSON(w, http.StatusTooManyRequests, errorResponse{
Error: "RATE_LIMITED",
Message: "too many failed attempts, try again later",
})
p, ok := principal(r)
if !ok {
writeNotAuthenticated(w)
return
}
@@ -290,107 +110,24 @@ func handleConfirmTOTP(database *db.DB, pendingStore *auth.PendingTOTPStore, use
})
return
}
failKey := auth.Key("pw_confirm_fail", user.ID)
if err := requirePasswordConfirmation(user, req.Password); err != nil {
if !limiter.Allow(failKey, pwConfirmFailureThreshold, pwConfirmFailureWindow) {
limiter.Lockout(r.Context(), lockKey, pwConfirmLockoutDuration)
}
writeJSON(w, http.StatusBadRequest, errorResponse{
Error: "INVALID_INPUT",
Message: err.Error(),
})
res, err := svc.ConfirmTOTP(r.Context(), p, req.Password, req.Code)
if err != nil {
writeAuthError(r.Context(), w, err)
return
}
limiter.Reset(r.Context(), failKey)
secret, ok := pendingStore.Lookup(user.ID)
if !ok {
writeJSON(w, http.StatusBadRequest, errorResponse{
Error: "BAD_REQUEST",
Message: "no pending two-factor enrollment found",
})
return
}
if !auth.VerifyTOTPCodeOnce(secret, strings.TrimSpace(req.Code), time.Now().UTC(), user.ID, usedTOTPCodes) {
writeJSON(w, http.StatusUnauthorized, errorResponse{
Error: "UNAUTHORIZED",
Message: "invalid two-factor code",
})
return
}
encryptedSecret, encErr := auth.EncryptTOTPSecret(totpKey, secret)
if encErr != nil {
slog.Error("failed to encrypt TOTP secret", "user_id", user.ID, "error", encErr)
writeJSON(w, http.StatusInternalServerError, errorResponse{
Error: "INTERNAL_ERROR",
Message: "failed to enable two-factor authentication",
})
return
}
if err := database.UpdateUserTOTPSecret(r.Context(), user.ID, &encryptedSecret); err != nil {
writeJSON(w, http.StatusInternalServerError, errorResponse{
Error: "INTERNAL_ERROR",
Message: "failed to enable two-factor authentication",
})
return
}
pendingStore.Delete(user.ID)
// BUG-108: Revoke all other sessions after 2FA state change. An
// API-token principal has a nil session; keep=0 matches no row, so
// every login session is revoked — same semantics as change-password.
sess, _ := r.Context().Value(SessionKey).(*db.Session)
keepSessionID := int64(0)
if sess != nil {
keepSessionID = sess.ID
}
// Security tail of the 2FA change: once the secret update committed,
// revoking the other sessions must not be aborted by a dead request.
tailCtx := context.WithoutCancel(r.Context())
revoked, revokeFailed := revokeOtherSessionsAfterAuthChange(tailCtx, database, user.ID, keepSessionID, "totp enable")
slog.Info("totp enabled", "user_id", user.ID)
db.WriteAudit(tailCtx, database, user.ID, "totp_enabled", "user", user.ID,
"two-factor authentication enrolled")
if revokeFailed {
// Partial success: 2FA IS enabled; only revoking the other
// sessions failed. A 5xx here would be a lie — the state change
// already committed — so mirror the ChangePassword contract
// (api/profile_handler.go) and report 200 with an explicit warning
// instead of a silent, unqualified 204.
writeJSON(w, http.StatusOK, map[string]any{
"warning": "two-factor authentication enabled, but other sessions could not be revoked; revoke them from the sessions list",
"sessions_revoked": revoked,
})
return
}
w.WriteHeader(http.StatusNoContent)
writeTOTPChange(w, res)
}
}
func handleDisableTOTP(database *db.DB, pendingStore *auth.PendingTOTPStore, limiter *auth.RateLimiter) http.HandlerFunc {
// handleDisableTOTP processes DELETE /api/v1/users/me/totp. An empty body is
// accepted (and then refused by the service as a missing password); only a
// body that is present and not JSON is malformed.
func handleDisableTOTP(svc AuthService) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
user, ok := r.Context().Value(UserKey).(*db.User)
if !ok || user == nil {
writeJSON(w, http.StatusUnauthorized, errorResponse{
Error: "UNAUTHORIZED",
Message: "not authenticated",
})
return
}
// BUG-111: Per-user lockout for password confirmation.
lockKey := auth.Key("pw_confirm_lock", user.ID)
if limiter.IsLockedOut(lockKey) {
writeJSON(w, http.StatusTooManyRequests, errorResponse{
Error: "RATE_LIMITED",
Message: "too many failed attempts, try again later",
})
p, ok := principal(r)
if !ok {
writeNotAuthenticated(w)
return
}
@@ -402,74 +139,27 @@ func handleDisableTOTP(database *db.DB, pendingStore *auth.PendingTOTPStore, lim
})
return
}
failKey := auth.Key("pw_confirm_fail", user.ID)
if err := requirePasswordConfirmation(user, req.Password); err != nil {
if !limiter.Allow(failKey, pwConfirmFailureThreshold, pwConfirmFailureWindow) {
limiter.Lockout(r.Context(), lockKey, pwConfirmLockoutDuration)
}
writeJSON(w, http.StatusBadRequest, errorResponse{
Error: "INVALID_INPUT",
Message: err.Error(),
})
return
}
limiter.Reset(r.Context(), failKey)
require2FA, err := isRequire2FAEnabled(r.Context(), database)
res, err := svc.DisableTOTP(r.Context(), p, req.Password)
if err != nil {
writeJSON(w, http.StatusInternalServerError, errorResponse{
Error: "INTERNAL_ERROR",
Message: "failed to load authentication policy",
})
writeAuthError(r.Context(), w, err)
return
}
if require2FA {
writeJSON(w, http.StatusForbidden, errorResponse{
Error: "FORBIDDEN",
Message: "two-factor authentication is required for this server",
})
return
}
pendingStore.Delete(user.ID)
if err := database.UpdateUserTOTPSecret(r.Context(), user.ID, nil); err != nil {
writeJSON(w, http.StatusInternalServerError, errorResponse{
Error: "INTERNAL_ERROR",
Message: "failed to disable two-factor authentication",
})
return
}
// BUG-108: Revoke all other sessions after 2FA state change. An
// API-token principal has a nil session; keep=0 matches no row, so
// every login session is revoked — same semantics as change-password.
sess, _ := r.Context().Value(SessionKey).(*db.Session)
keepSessionID := int64(0)
if sess != nil {
keepSessionID = sess.ID
}
// Security tail of the 2FA change: once the secret update committed,
// revoking the other sessions must not be aborted by a dead request.
tailCtx := context.WithoutCancel(r.Context())
revoked, revokeFailed := revokeOtherSessionsAfterAuthChange(tailCtx, database, user.ID, keepSessionID, "totp disable")
slog.Info("totp disabled", "user_id", user.ID)
db.WriteAudit(tailCtx, database, user.ID, "totp_disabled", "user", user.ID,
"two-factor authentication disabled")
if revokeFailed {
// Partial success: 2FA IS disabled; only revoking the other
// sessions failed. A 5xx here would be a lie — the state change
// already committed — so mirror the ChangePassword contract
// (api/profile_handler.go) and report 200 with an explicit warning
// instead of a silent, unqualified 204.
writeJSON(w, http.StatusOK, map[string]any{
"warning": "two-factor authentication disabled, but other sessions could not be revoked; revoke them from the sessions list",
"sessions_revoked": revoked,
})
return
}
w.WriteHeader(http.StatusNoContent)
writeTOTPChange(w, res)
}
}
// writeTOTPChange answers a committed 2FA change: 204, or 200 with the
// warning when the caller's other sessions could not be revoked — a partial
// success the service reports instead of a 5xx, because the change is
// already durable.
func writeTOTPChange(w http.ResponseWriter, res *service.TOTPChangeResult) {
if res.Warning != "" {
writeJSON(w, http.StatusOK, map[string]any{
"warning": res.Warning,
"sessions_revoked": res.SessionsRevoked,
})
return
}
w.WriteHeader(http.StatusNoContent)
}
+32
View File
@@ -0,0 +1,32 @@
package api
import (
"testing"
"github.com/J3vb/OwnCord/Server/storage"
)
// BenchmarkUploadAdmission runs the two upload validators in the order
// uploadHandler runs them, over one fixed fixture: sanitizeUploadFilename
// (basename, backslash strip, the control/Cf rune scan, the byte cap) and then
// storage.ValidateFileType over the sniff header. The fixture is a hostile
// filename and an accepted PDF header — accept is the worst case for
// ValidateFileType, which returns on the first blocked signature it matches and
// only walks the whole blockedMagic table when nothing matches.
func BenchmarkUploadAdmission(b *testing.B) {
// U+202E is a bidi override: the invisible rune class sanitizeUploadFilename
// strips, so a name cannot render one extension while carrying another.
const name = "../../etc\\pa\u202egnp.exe\x00 quarterly report.pdf"
header := []byte("%PDF-1.7\n%\xe2\xe3\xcf\xd3\n1 0 obj\n<< /Type /Catalog >>")
b.ReportAllocs()
b.ResetTimer()
for range b.N {
if got := sanitizeUploadFilename(name); got == "" {
b.Fatal("sanitizeUploadFilename returned an empty name")
}
if err := storage.ValidateFileType(header); err != nil {
b.Fatalf("ValidateFileType rejected the fixture: %v", err)
}
}
}
+39
View File
@@ -0,0 +1,39 @@
package auth
import (
"math"
"sync/atomic"
)
// rateScaleBits holds security.auth_rate_limit_multiplier as float bits. It
// scales the per-IP auth request caps and failure thresholds for deployments
// where many users share one IP (office/school NAT) — the compiled-in limits
// assume roughly one person per address. Atomic because tests construct
// multiple routers concurrently. Installed by api.NewRouter via SetRateScale;
// read at route-mount time and on the login failure-count path
// (service.AuthService).
var rateScaleBits atomic.Uint64
func init() { rateScaleBits.Store(math.Float64bits(1.0)) }
// SetRateScale clamps and installs the auth rate multiplier. Zero or
// negative (unset config) means 1.0.
func SetRateScale(m float64) {
if m <= 0 {
m = 1.0
}
m = math.Min(math.Max(m, 0.1), 100)
rateScaleBits.Store(math.Float64bits(m))
}
// ScaledLimit applies the auth rate multiplier to a compiled-in limit, never
// returning less than 1. Per-user caps must not go through it: they are the
// only cross-IP brute-force defence, and the multiplier exists for shared-NAT
// per-IP limits.
func ScaledLimit(n int) int {
scaled := int(math.Round(float64(n) * math.Float64frombits(rateScaleBits.Load())))
if scaled < 1 {
return 1
}
return scaled
}
@@ -0,0 +1,2 @@
go test fuzz v1
string("Tr0ub4dor&3-owncord")
@@ -0,0 +1,2 @@
go test fuzz v1
string("correct horse battery staple")
@@ -0,0 +1,2 @@
go test fuzz v1
string("alice")
@@ -0,0 +1,2 @@
go test fuzz v1
string("bob")
@@ -0,0 +1,2 @@
go test fuzz v1
string("Alice Fixture")

Some files were not shown because too many files have changed in this diff Show More