mirror of
https://github.com/J3vb/OwnCord.git
synced 2026-09-03 03:50:00 +03:00
dev
49
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
63c87df487 |
refactor(b3-8): settings/audit family behind SettingsService (S-09, family 1) (#1477)
* feat(service): settings family — SettingsService over the Store seam The B3-8 settings/audit family's service: List, Patch (whitelist, boolean normalization, the require_2fa preconditions incl. the TOTP census and the unrelated-key guard, atomic apply, one audit row per changed key) and Setting (the read the hub and the backup scheduler consume; wraps db.ErrNotFound as the store reports it). db gains ApplySettings — the handler's raw upsert loop as one hand-written transactional wrapper where raw SQL belongs — and Store carries it. parseSettingsPatchBool duplicates auth.go's parseBooleanSettingValue with the admin surface's own pinned error wording; both messages are test-pinned, so the twins stay separate. Service-level characterization in settings_test.go mirrors the admin/api_test.go PATCH rows and adds the service-only contracts (ErrNotFound wrap, audit rows, multi-key apply). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01B8dwVLEihnGZYtH9X631F4 * refactor(admin): settings handlers thin over SettingsService; scheduler reads via it handleGetSettings/handlePatchSettings become adapters (decode, delegate, map ErrBadRequest to 400 with the service's prefix-free message); the whitelist and every precondition now live only in the service, so admin/types.go's copy is gone. MaintainBackups reads backup_schedule and backup_retention through the service — its backup mechanics keep the handle — and the maintenance chain threads Settings from the runtime the hub stage built. NewHandler/NewAdminAPI gain the settings parameter; all 207 construction sites wired via the newTestSettingsService helper. Behavior parity pinned by the existing TestAdminAPI_*Settings* rows (all green); the only unpinned change is the PATCH 500 path collapsing its four stage-specific internal messages into one. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01B8dwVLEihnGZYtH9X631F4 * refactor(ws): hub settings cache reads through a SettingsReader The hub's server_name/motd cache consumes a consumer-side SettingsReader interface (service.SettingsService satisfies it; HubOptions.Settings is required and validated like DB and Limiter — the RequiredCollaborators pin gains the refusal case). hub_settings.go no longer touches db at all, so the import pin from the B3-5 finisher goes, and its allowlist row goes with it; the thinned admin settings handler's row is deleted too — two allowlist rows down, the settings family's persistence now lives only in db/ and service/. Test helpers (both ws package namespaces) default the reader over the test database; newBareHub wires it explicitly; production passes Services.Settings from StartRuntime. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01B8dwVLEihnGZYtH9X631F4 * docs(boundaries,b3): settings/audit family re-measure and evidence The backup pair takes its forecast boundary disposition; the family's two deleted rows and the disposition counts (28/18/15 -> 24/18/17) re-derived from the tool. Family evidence block appended to the B3-8 section; README B3 row records B3-5 complete and the family opened. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01B8dwVLEihnGZYtH9X631F4 * fix(service): prefix-free ErrBadRequest wraps for the pinned admin bodies The %.0w rework was meant to ride the service commit but was left unstaged: with the plain %w wrap the PATCH error bodies carry a 'bad request: ' prefix the admin pins reject. Zero-width wrapping keeps errors.Is(ErrBadRequest) while err.Error() stays exactly the pinned message. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01B8dwVLEihnGZYtH9X631F4 * test(app): lifecycle hub fixtures wire the required Settings reader The two direct ws.NewHub sites in lifecycle_test predate Settings becoming required; race across internal/app is green again. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01B8dwVLEihnGZYtH9X631F4 * test(db): cover ApplySettings — the db coverage floor caught the gap CI's coverage floor failed db at 78.9% against 79.3%: ApplySettings was exercised only from service tests, which do not count toward db's own figure. Four db-side rows cover the apply, the empty no-op, the in-transaction failure rollback and the begin failure, using the package's full-migration opener. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01B8dwVLEihnGZYtH9X631F4 * chore(coverage): raise the service floor to the branch's measured 69.2 The settings family's tested service code raised the Linux figure from the 67.8 floor to 69.2; the ratchet raises the floor in the same PR (service is not in the run-varying set). db stays at 79.3 — this PR restores its figure (79.5 with the ApplySettings tests), it did not set out to raise it. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01B8dwVLEihnGZYtH9X631F4 --------- Co-authored-by: Claude <noreply@anthropic.com> |
||
|
|
528ae260ee |
refactor(b3-5): hub.go under 400 — construction, settings cache and replay accessor move out (S-08, finisher) (#1476)
* refactor(ws): move construction, settings cache and replay accessor out of hub.go Pure moves closing hub.go's B3-5 size target: HubOptions and NewHub -> new hub_options.go; getCachedSettings and refreshSettingsLocked -> new hub_settings.go; maxColdReplayLimit -> replay.go beside the family that reads it. Normalized-diff residue: the new files' scaffolding plus duplicates of five imports hub.go retains (auth, db, permissions, plugin, service); errors, fmt and os moved outright. No identifier changed. hub.go now holds the Hub state, lifecycle (Run/Stop/graceful) and stats accessors: 361 lines, under the 400-line exit target. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01B8dwVLEihnGZYtH9X631F4 * docs(boundaries,b3): re-measure after the hub.go finishers; record the B3-5 exit hub.go and hub_options.go become type-only boundary rows; hub_settings.go reads through the h.db field and imports nothing, so it carries no row (the header records that wrinkle). Disposition counts re-derived from the tool's summary — boundary had been stale at 12 since the seed-profile row. Finisher evidence block and the B3-5 exit summary (five squash merges, all seven responsibilities, exit size table with all three targets met) appended to the plan; README B3 row advanced. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01B8dwVLEihnGZYtH9X631F4 --------- Co-authored-by: Claude <noreply@anthropic.com> |
||
|
|
df717e431c |
refactor(b3-5): voice leftovers join voice_broadcast.go; presence coalescer to hub_presence.go (S-08, PR 4) (#1475)
* refactor(ws): move voice broadcast leftovers into voice_broadcast.go Pure move of broadcastVoiceEvent and broadcastVoiceEventWithLeaver from hub_broadcast.go and VoiceSessionCount from hub.go into the existing voice_broadcast.go, beside the voice rate-limit and quality tables. Normalized-diff residue: one context import added to the destination; the sources keep theirs. No identifier changed. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01B8dwVLEihnGZYtH9X631F4 * refactor(ws): move the presence coalescer into hub_presence.go Pure move of pendingPresence, QueuePresence, dropQueuedPresenceAndBroadcast, presenceCoalesceWindow, presenceFlushRaceHook, flushPresenceQueue and BroadcastPresence from hub_broadcast.go into the new hub_presence.go, in source order. Normalized-diff residue: the new file's scaffolding plus duplicates of two imports the source keeps (time, db). No identifier changed. hub_broadcast.go now holds only broadcast delivery and backpressure — 424 lines, under its 500-line B3-5 exit target. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01B8dwVLEihnGZYtH9X631F4 * chore(invariants): hub_presence.go adapter row; hub_broadcast reason trimmed The presence coalescer makes no persistence call — db.BroadcastStatus is the doc's own pure-helper example — so the new file is an adapter row; hub_broadcast.go's reason drops the presence half it no longer does. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01B8dwVLEihnGZYtH9X631F4 * docs(boundaries,b3): re-measure after the voice and presence moves Table regenerated (hub_presence.go adapter row, 17 -> 18; hub_broadcast reason trimmed), header dated, split-PR-4 evidence block appended, README B3 row advanced. hub_broadcast.go records its exit figure met (424 < 500). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01B8dwVLEihnGZYtH9X631F4 --------- Co-authored-by: Claude <noreply@anthropic.com> |
||
|
|
70875a36f6 |
refactor(b3-5): gather ws visibility and permission refresh into hub_visibility.go (S-08, PR 3) (#1474)
* refactor(ws): gather visibility and permission refresh into hub_visibility.go Pure move of the visibility responsibility from the three files it was spread across: the channelReadAudience family, RefreshChannelVisibility, refreshChannelVisibilityCanSend, RefreshAllChannelVisibility and revokeUnreadableChannels from hub_broadcast.go; computeAllowedChannels from serve.go; bumpVisibilityWatermark and MarkVisibilityChanged from hub.go. Normalized-diff residue: the new file's scaffolding plus duplicates of five imports the sources keep (context, fmt, db, log/slog, sync/atomic); the permissions import moved outright (out of hub_broadcast.go and serve.go, into hub_visibility.go). No identifier changed. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01B8dwVLEihnGZYtH9X631F4 * docs(boundaries,b3): re-measure after the visibility gather hub_visibility.go gains its DBImportAllow row; hub_broadcast.go's row shrinks to the member/presence payload reads. Table regenerated, counts and ratchet wording brought current (27 -> 28 move rows, zero new calls), re-measurement header dated. Split-PR-3 evidence block appended to the B3 plan; README B3 row advanced. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01B8dwVLEihnGZYtH9X631F4 --------- Co-authored-by: Claude <noreply@anthropic.com> |
||
|
|
f9258efc6e |
refactor(b3-5): split ws — replay family and connection registry move to their files (S-08, PR 2) (#1473)
* refactor(ws): move reconnect replay selection and delivery into replay.go Pure move of handleReconnect, reconnectPrecheck, reconnectSelectReplay, reconnectVetColdTail, reconnectRegister, reconnectWriteReplay and liveVoiceEventsSince — plus the replay-only maxColdReplay const, cut from serve.go's shared const block — into the new replay.go. Normalized-diff residue: the new file's scaffolding (package, import and const openers) and duplicates of four imports serve.go retains (context, log/slog, db, websocket); sync/atomic and telemetry moved outright. No identifier changed. serve.go keeps ServeWS, the shared connect-time helpers (refreshUserSnapshot, applyConnectStatus, announceConnectPresence) and computeAllowedChannels (visibility, moves with that responsibility). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01B8dwVLEihnGZYtH9X631F4 * refactor(ws): move the connection registry and supersession out of hub.go Pure move of Register, Unregister, registerNow, unregisterNow and shouldMarkOffline into the new hub_registry.go. Normalized-diff residue: the new file's package line and its log/slog import (hub.go keeps its copy for the remaining code). No identifier changed. The plan named registry.go as the destination, but that file holds the message-type HandlerRegistry — a different registry; growing it would conflate the two. hub_registry.go keeps them apart; deviation recorded in the plan's evidence block. clientEvent stays in hub.go: Run's dispatch loop owns the channel the queued events flow through. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01B8dwVLEihnGZYtH9X631F4 * docs(boundaries,b3): re-measure after the replay and registry moves replay.go gains its DBImportAllow row (a split of serve.go's row, type-only — the family's calls stayed with the shared helpers); hub_registry.go needs none (no db use). Table regenerated, the connection-rows note updated, the ratchet sentence now names the db surface rather than the row count (26 -> 27 move rows, zero new calls), disposition count and re-measurement header brought current. Split-PR-2 evidence block appended to the B3 plan; README B3 row advanced. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01B8dwVLEihnGZYtH9X631F4 --------- Co-authored-by: Claude <noreply@anthropic.com> |
||
|
|
1d1804ce5b |
refactor(b3-5): move handshake auth and fresh-connect out of ws serve.go (S-08, split 1) (#1472)
Pure moves into serve_auth.go and serve_ready.go with the boundaries inventory re-measured; evidence in the B3 plan's split-PR-1 block. |
||
|
|
e13adaf8b1 |
refactor(b3-4): hub constructor options — required collaborators validated at construction (S-11) (#1470)
ws.NewHub(opts HubOptions) (*Hub, error). The dispositions came from what each setter's own implementation said, not the plan's guesses: - The four rejectIfRunning-guarded knobs were construction wiring pretending to be mutable state and became validated options with their setters deleted: SetLiveKit, SetLiveKitProcess, SetPluginRegistry, ConfigureReplay. rejectIfRunning died with its last caller. The plan left SetLiveKitProcess "depends on whether the supervised process can restart" — it cannot: the restart path relaunches the whole app, so a process without a client is now refused at construction. - The genuinely runtime-mutable stay setters, each with its why: SetEventPersister / SetEventStore / SetPluginEventSink are atomic hot-swaps internal/app wires one lifecycle stage after Run (the persister cannot exist before the hub; the sink consumes the built hub's broadcaster), and SetPendingVoiceModFlags is per-user state. - DB and Limiter are required — before this change ws.NewHub(nil, nil, nil) succeeded (api's LiveKit-proxy tests built exactly that hub) and a missing collaborator surfaced as a later panic. Services stays optional: nil is the degraded fixture half the ws suite builds, and forcing a real service layer would change which handler paths the frozen tests take. internal/app.StartRuntime builds the LiveKit client/process first (buildVoice), passes everything through HubOptions, and starts the supervised process only once the hub holds it (OC-0019 ordering kept); construction failure is a startHub boot error now. Test migration: newTestHub / newTestHubDeps / newTestHubWith keep the old call shape at ~76 sites across both ws package namespaces; the eighteen former setter sites construct with options; TestNewHub_RequiredCollaborators and TestHub_LiveKitProcessRequiresClient pin the refusals. server-boundaries.md gains the after-B3-4 setter table; the plan's B3-4 section gains the dated evidence block. Claude-Session: https://claude.ai/code/session_01B8dwVLEihnGZYtH9X631F4 Co-authored-by: Claude <noreply@anthropic.com> |
||
|
|
bd397d2bbc |
feat(b3-7): alpha-shaped dataset — deterministic seed profile and the v1.2.0-alpha.4 snapshot (#1469)
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. Claude-Session: https://claude.ai/code/session_01B8dwVLEihnGZYtH9X631F4 Co-authored-by: Claude <noreply@anthropic.com> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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 = |
||
|
|
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. |
||
|
|
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
|
||
|
|
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> |
||
|
|
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 @ |
||
|
|
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>
|
||
|
|
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> |
||
|
|
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> |
||
|
|
5202e3fe1e |
fix: correctness fixes from the 2026-08-20 bug hunt (#1398)
* fix(identity): 2 defect(s) (OC-0192, OC-0197)
OC-0192: bound raw display_name/about/avatar bytes before the quadratic
fixpoint sanitizer runs, in both the REST handler and UserService.UpdateProfile.
OC-0197: sanitize display_name before validateDisplayName so an
HTML-entity-encoded bidi override (e.g. "‮") can no longer pass
validation as ASCII and be decoded into the real character on the way to
storage.
* fix(ws): 1 defect(s) (OC-0196)
A transient DB error during WebSocket auth (session or user lookup) was
collapsed into the terminal auth_error frame, which the client treats as
non-recoverable: it stops reconnecting and clears stored credentials. A
sub-second SQLite hiccup therefore force-logged-out every reconnecting
client with a perfectly valid session. Send a non-terminal INTERNAL error
frame instead so normal backoff/reconnect retries.
* fix(api): 1 defect(s) (OC-0198)
* fix(ws): 1 defect(s) (OC-0200)
normalizeHostForCertCompare now unwraps a bracketed IPv6 literal after the
trailing-":443" strip and before lowercasing, matching tofu::cert_store_key's
normalization order. Without the unwrap, every cert-tofu host equality guard
took the "unrelated host" branch for bracketed-IPv6 servers.
* fix(api): 1 defect(s) (OC-0202)
* fix(admin): 1 defect(s) (OC-0203)
Channel permission override handlers applied requireGrantableOverride only
to the bits being written, so an all-zero PUT or a DELETE could clear a
deny bit the actor's own role does not hold — EffectivePerms =
(rolePerm &^ deny) | allow makes removing a deny an escalation. Both the
role-layer and per-user handlers now check the guard against the bits
already on the row.
* fix(client): 1 defect(s) (OC-0205)
* fix(client): 3 defect(s) (OC-0207, OC-0227, OC-0235)
* fix(client): 1 defect(s) (OC-0208)
* fix(voice): 3 defect(s) (OC-0209, OC-0212, OC-0213)
OC-0209: reject a replayed retired-key announce before verifyPeerAnnounce
runs, so the replay cannot overwrite the peer's displayed verification
status/session fingerprint with the retired key's before being rejected.
OC-0212: buffer an announce blocked as a TOFU pin mismatch and replay it
after a successful rePinPeerIdentity, so re-pinning actually restores the
peer for the live call instead of clearing the badge and leaving them
un-keyed (a mid-call peer never re-announces on its own).
OC-0213: skip retiring a departing peer's key when the local voice roster
still lists them as present — a rejoin announce published straight into
the send queue can overtake the buffered, stale voice_leave, and retiring
a still-live key would reject every later genuine re-announce as a replay.
* fix(ws): 1 defect(s) (OC-0211)
* fix(identity): 1 defect(s) (OC-0214)
The delete-account admin guard counted remaining admins with a raw
`banned = 0` filter, so an admin whose temporary ban had already lapsed
was treated as unusable. Use the shared notBannedClause, appended outside
the Sprintf format string because its strftime verbs (%Y, %H) would
otherwise be parsed as fmt directives.
* fix(client): 1 defect(s) (OC-0215)
* fix(voice): 1 defect(s) (OC-0216)
* fix(client): 1 defect(s) (OC-0217)
* fix(voice): 1 defect(s) (OC-0219)
rollbackVoiceJoin cleared the client's in-memory voiceChID but left its
VoiceTopic subscription in place, so a socket whose join failed after
voiceJoinComplete's Subscribe kept receiving that room's E2EE relays for
the rest of the connection. Use clearVoiceAndUnsubscribe instead, matching
every other path that takes a client out of voice while its WS stays up.
* fix(client): 2 defect(s) (OC-0220, OC-0224)
dmDisplayName: a group DM whose other members have all left keeps a live
is_group row, but the server leaves `recipient` zero-valued, so the empty
username fell through as a blank label. Fall back to a non-empty placeholder.
updateDmLastMessage: a queued chat_message redelivered for an id already
reflected in the `ready` snapshot double-counted the unread badge. Only
increment when the message id advances past lastMessageId.
* fix(client): 1 defect(s) (OC-0221)
Cap queued attachments at the server's 10-attachment limit in the message
composer. Past that the server rejects the whole chat_send frame as a
generic parse error, orphaning already-uploaded attachments; refusing
before the upload starts keeps composer state and the send in sync.
* fix(ws): 1 defect(s) (OC-0222)
handleReconnect built the resume auth_ok before applyConnectStatus settled
c.user.Status, so a resumed client was told its disconnect-time status
(routinely "offline") instead of the status it was coming online as.
Move applyConnectStatus ahead of reconnectWriteReplay, matching
handleFreshConnect's ordering.
* fix(mentions): 1 defect(s) (OC-0223)
* fix(admin): 1 defect(s) (OC-0225)
* fix(client): 1 defect(s) (OC-0226)
* fix(client): 1 defect(s) (OC-0228)
* fix(client): 1 defect(s) (OC-0230)
Route the Logs tab entry counter through renderLogEntries so every render path (filter change, Clear, Refresh, live entry) keeps the count in sync with the list.
* fix(voice): 1 defect(s) (OC-0231)
* fix(client): 1 defect(s) (OC-0232)
Reduce Motion toggle wrote the reduced-motion class directly, fighting the
OS-sync media-query listener that owns it when Sync with OS is on. Route the
side effect through syncOsMotionListener so whichever source owns the class
re-derives it.
* fix(client): 1 defect(s) (OC-0233)
notifyIncomingMessage titled the desktop notification with the raw
payload username, so the popup named the sender differently from the
message row it points at. Resolve the author the same way the message
list does (resolveAuthor over the live membersStore, then
resolveDisplayName).
* fix(client): 1 defect(s) (OC-0234)
* fix(client): 1 defect(s) (OC-0236)
* fix(ws): 1 defect(s) (OC-0237)
* fix(client): 4 defect(s) (OC-0193, OC-0201, OC-0204, OC-0218)
* fix(identity): 1 defect(s) (OC-0195)
Bound free-text profile fields by raw byte length before cleanText's
quadratic sanitizeToFixpoint pass runs, generalizing OC-0192's guard into
cleanTextBounded and applying it to HandlePresenceUpdate's custom_status,
SetCustomStatus, and group DM names.
* fix(dm): 1 defect(s) (OC-0199)
handleCreateDM now broadcasts dm_channel_open to the recipient when a 1:1 DM is newly created, matching handleCreateGroupDM. GetOrCreateDMChannel pre-seeds dm_open_state for both users, so the recipient's later OpenDM reported opened=false and nothing ever told them the DM existed.
* fix(voice): 1 defect(s) (OC-0206)
vad-worklet.js gate timing constants were copied from the setTimeout
fallback's ~16ms poll cadence, but AudioWorkletProcessor.process() runs
once per 128-sample render quantum (~2.667ms at the 48kHz AudioContext).
The mic gate therefore closed ~6x faster than intended (~32ms of silence
instead of ~200ms), with the startup grace and RMS post interval off by
the same factor. Scale the frame counts to render quanta.
* fix(client): 1 defect(s) (OC-0229)
* test(client): assert the real TOFU re-pin outcome and make the pin mock faithful
The e2e journey test asserted that "Trust New Key" makes the peer's verify
badge disappear. That is the behaviour OC-0212 identifies as the defect: a
mid-call peer never re-announces, so clearing the badge left the peer
un-keyed for the rest of the call with nothing on screen. Re-pinning now
replays the announce that was blocked as a mismatch and re-verifies it
against the pin just stored, so assert the peer actually lands verified.
The mock's store_identity_pin was a no-op recorder while get_identity_pin
served a static seed map, so the replayed announce re-read the stale pin and
re-failed — a mismatch the real keyring never produces. Back the pins with a
mutable map so a write is visible to the next read. The unreadable-store
(DC-08) and reject-keeps-blocked paths are unchanged and still pass.
* fix(dm): 1 defect(s) (OC-0194)
Add regression tests pinning the raw-byte bound on group DM names, for
both CreateGroupDM and RenameGroupDM.
The Server/service/dm.go source fix for OC-0194 already landed in
|
||
|
|
f5faf82a60 |
infra: observability, backups, guardrails, and deployment hardening (#1376)
* docs: add infrastructure roadmap plan Records the verified recommendations from an infrastructure review in three tracks: raising the single-instance ceiling, cheap seams for a possible multi-instance future, and ops hygiene. Includes explicit anti-recommendations and sequencing. Security-sensitive detail is intentionally excluded per docs/security.md. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017RtDNHSYWwPKArL8MsRdbj * feat(server): real health checks and saturation metrics /api/v1/metrics now exposes signals that were already computed in memory but never surfaced: reconnect replay tier hits, event-persister counters, SQLite writer-pool wait stats, aggregate per-client backpressure counters (including previously invisible low-priority drops), and permission-cache hit/miss. /health now returns a real verdict: hub dispatch-loop liveness, a bounded database ping, and a free-disk check, returning 503 with a subsystem reason when degraded. Checks are cached so the unauthenticated endpoint cannot amplify load. The hub's panic breaker now exits the process so a supervisor can restart it, instead of leaving broadcast delivery silently dead while clients still appear online. OTel instruments that were declared but never recorded are now wired (ws_active_connections, ws_broadcast_latency_seconds, ws_messages_total, ws_events_dropped_total, voice gauges) or removed (db_query_duration_seconds). Also corrects the docs/api.md description of broadcast_drops, which counts hub-queue overflow, not client send-queue overflow. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017RtDNHSYWwPKArL8MsRdbj * feat(server): implement scheduled backups, retention, and backup verification The backup_schedule and backup_retention settings have existed in the admin panel and API since the initial schema but were never read by any code. The 15-minute maintenance loop now enforces them: a scheduled backup is taken when the newest backup on disk is older than the schedule interval (manual backups reset the clock), and retention prunes backups older than the configured days while always keeping the newest one. Backups are now verified with PRAGMA integrity_check immediately after VACUUM INTO (a failed backup is removed rather than listed as restorable) and again before a restore may overwrite the live database. A failed VACUUM INTO also cleans up its partial output file — but never a pre-existing one. The backup directory is configurable via a new backup.dir key (default data/backups) so operators can point backups at another disk or an off-host mount, mirroring the SetDatabasePath plumb. Restore-handler tests now use real SQLite fixtures (the integrity gate correctly refuses text files) with the mid-copy failure injected through a test-only copy hook. Also adds audited gosec suppressions to the Windows disk-free syscall added in the previous commit, which the Windows lint leg flagged. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017RtDNHSYWwPKArL8MsRdbj * feat(server): capacity and failure-mode guardrails - server.max_ws_connections: optional cap on concurrent WebSocket clients, checked before the upgrade with a 503 + Retry-After; rejections are counted and exposed as ws_conn_rejects in /api/v1/metrics. - Single-process database lock: an OS-level advisory lock (flock / exclusive handle) beside the SQLite file makes a second server process fail fast with a clear message instead of silently fighting the first over process-local state. A bounded retry covers the self-update/restore restart handoff, and the lock mechanism failing (e.g. network filesystems) only warns. - Disk-space awareness: boot-time warnings for the data and backup volumes, plus a disk_free_mb metrics field, via a small cross-platform diskutil package (already used by /health). - Upload storage failures: storage.Save now marks server-side filesystem failures with a sentinel (storage.ErrIO); handlers return 507 for those instead of blaming the client with a 400, and the emoji route stops echoing raw storage errors (which embed absolute paths) into responses. - Unknown config keys now warn at startup — a typo like admin_alowed_cidrs previously kept the default silently while the operator believed the setting changed. Never fatal: newer servers tolerate older configs. - Admin settings honesty: the three stored-but-inert settings (server_icon, max_upload_bytes, voice_quality) are shown read-only with a note pointing at the real config.yaml keys, instead of pretending to apply. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017RtDNHSYWwPKArL8MsRdbj * perf(db): write-path efficiency and capacity knobs - channel_focus/mark_read now skip the read-state UPSERT when the stored row already matches (same last_message_id, no mentions) — refocus events fire at up to 10/s/user and every no-op write still occupied the single SQLite writer connection. The extra existence check runs on the reader pool, which doesn't serialize. Same shape as the session-touch throttle. - DeleteExpiredSessions is now sargable: migration 031 normalizes legacy expiry formats to the RFC3339-Z layout the server writes and indexes expires_at, replacing the strftime full-table scan that ran on the writer every 15 minutes. - Boot-time ANALYZE runs only when a migration actually applied; unchanged schemas get the cheap PRAGMA optimize instead (which also covers crash-restarts that never reached the shutdown optimize). - The read/write SQL router gets a table-driven test with explicit expected values (INSERT ... RETURNING must hit the writer despite being :one). - New knobs, all defaulting to current behavior: database.max_readers, security.auth_rate_limit_multiplier (for shared-NAT communities), event_persistence.replay_ring_size and replay_cold_limit. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017RtDNHSYWwPKArL8MsRdbj * fix(server): shutdown lifecycle ordering - The event pruner and maintenance loop are now joined (bounded) before the database closes: bgCtx cancellation used to run AFTER database.Close via LIFO defers, contradicting its own comment, and neither goroutine was ever waited on — a mid-tick scheduled backup or prune could still hold the writer while the pool tore down. StartEventPruner returns a done channel with the same join contract EventPersister.Stop already had. - srv.Shutdown now runs before hub.GracefulStop, so in-flight HTTP handlers' broadcasts still reach a live hub and the event persister instead of vanishing from the replay/event store across a restart. Shutdown does not wait on hijacked WebSocket connections, so the swap adds no delay. - GracefulStopContext threads the 30s shutdown budget into the hub: the 5s client-notice window (matching the countdown clients are shown) ends early when the budget expires, and is skipped entirely when nobody is connected — early-return startup paths and idle servers no longer sleep 5s for an audience of zero. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017RtDNHSYWwPKArL8MsRdbj * build(deploy): systemd unit, compose hardening, boot-smoked releases, CI polish - deploy/owncord.service: hardened systemd unit template with the two verified caveats encoded (install dir stays writable for self-update under ProtectSystem=strict; CAP_NET_BIND_SERVICE for ACME's :80), plus a 'Linux (systemd)' deployment docs section — the Linux service story was previously 'Docker or nothing'. - New 'Reverse Proxy Topology' docs section with a working nginx snippet and the correct signaling-vs-media distinction: /livekit/* is already proxied by the server, only WebRTC media ports must be directly reachable. - docker-compose: log rotation, commented resource limits, and a healthcheck backed by a new 'chatserver healthcheck' subcommand (the distroless image has no shell) that probes /health without config side effects. - release.yml: a concurrency group (queue, never cancel), and boot-smoke gates — the freshly built server binaries and the Docker image are cold booted and probed healthy BEFORE anything is signed or pushed. The release feed drives signed self-updates, so a binary that compiles but dies on boot previously would have shipped itself to every auto-updating instance. - ci.yml: client-check/client-tests move to ubuntu with the reasoning recorded (no win32 code paths, LF enforced repo-wide); admin-e2e gets a written graduation criterion instead of an open-ended non-blocking status. - docs: Tailscale guide notes the CGNAT range vs the default admin CIDRs; architecture overview records presence/voice state as the fifth single-instance blocker and the macOS client scope decision. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017RtDNHSYWwPKArL8MsRdbj * perf(server): measured load tooling, narrowed invalidation, presence coalescing, storage and CIDR seams - Fix scripts/k6/ws-load.js against the real wire protocol: envelope-wrapped frames, correct message types (typing_start, presence_update), the correct /api/v1/ws path, and thresholds that fail a run where nobody authenticated or went ready — the script had drifted to pre-envelope framing and reported 100% green while every auth failed on the first frame. A new workflow_dispatch-only load-baseline workflow boots a real server, seeds users through the setup/invite APIs, runs the script, and uploads the k6 summary plus a metrics snapshot for before/after comparison. - Role-scoped channel-override changes now evict only the affected role's members from the permission cache (fail-safe: unreadable member list still flushes everything). InvalidateAll here repopulated every connected user — two reads each — synchronously inside the admin request via RefreshChannelVisibility, a stampede that scaled with total population rather than the role's size. Same pattern the per-user override endpoints already used. - Connect/disconnect presence broadcasts now pass through a 300ms latest-wins coalescer (QueuePresence): each un-coalesced presence change is a sequenced global broadcast (an O(clients) fan-out under seqMu), so a reconnect storm fired O(users) of them from the connect critical path. A flap inside the window collapses to its final state; the wire format, seq ordering, and replay behaviour are unchanged, and the delivery path (BroadcastPresence) is untouched. - Storage seam: api handlers now consume a FileStore interface (consumer-side, same pattern as service.Store) with Open returning a seekable storage.File — writing down the contract (range-request seeks included) an alternative backend would have to meet, without building one. - The metrics surfaces and the LiveKit webhook/health endpoints get their own allowlist keys (metrics_allowed_cidrs, livekit_webhook_allowed_cidrs, both defaulting to admin_allowed_cidrs), so a central Prometheus scraper or an externally-hosted LiveKit no longer requires widening the admin panel's perimeter. Startup now also warns when admin_allowed_cidrs is customized while trusted_proxies is empty — behind a proxy or container network the check would otherwise compare the proxy's private address, not the client's. - The container healthcheck probe now PINS the server's own certificate from disk (VerifyConnection, exact-match) instead of skipping TLS verification, addressing the CodeQL finding on the previous commit; WebPKI verification is used when no local cert exists (ACME). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017RtDNHSYWwPKArL8MsRdbj * fix(server): address self-review findings on the hardening branch Seven fixes from a high-effort review of the full branch diff: - healthcheck CLI now works under tls.mode acme: it overrides ServerName with the configured domain for WebPKI verification instead of pinning a cert that doesn't exist (or is stale) in that mode. Previously an ACME deployment's container healthcheck failed forever. - /health pings the READER pool (new db.PingRead): the writer ping queued behind a scheduled backup's VACUUM INTO and reported the server degraded for the whole backup — which an autoheal watchdog would turn into a nightly mid-backup restart. - /health runs its cached checks under context.WithoutCancel so a probe that disconnects mid-request cannot poison the shared cache with a false degraded verdict for the next 5 seconds. - The token CLI uses a new db.OpenShared that skips the single-process lock: minting a token against a running server is safe under WAL and was a documented workflow the lock had broken. - The per-user TOTP failure cap is no longer scaled by security.auth_rate_limit_multiplier — that knob exists for per-IP limits; scaling the only cross-IP brute-force defence multiplied an attacker's distributed guess budget. Mirrors the unscaled per-user login threshold. - A direct presence_update now drops the user's queued entry in the connect/disconnect coalescer, so a stale connect-time presence can no longer flush 300ms later over the user's fresher chosen status. - The scheduled-backup filename collision loop breaks on any stat error and bounds its suffix probing, instead of spinning the maintenance goroutine forever on a persistent EACCES. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017RtDNHSYWwPKArL8MsRdbj * test(admin): real SQLite fixture for the merged Close-failure restore test TestHandleRestoreBackup_RestartsWhenCloseFails arrived from main (#1375) with a plain-text backup fixture; this branch's restore handler verifies backups with integrity_check before touching the live database, so the text fixture was (correctly) refused with 400 before the Close-failure branch under test was reached. Use a real backup via BackupToSafe, matching the other restore tests. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017RtDNHSYWwPKArL8MsRdbj --------- Co-authored-by: Claude <noreply@anthropic.com> |
||
|
|
4ff199e14f |
fix: resolve 107 verified defects across ws hub, voice/E2EE, db, and client (#1331)
* fix(client): style the user profile popup
The popup rendered unstyled: it appeared at the bottom of the page and
pushed the rest of the app up, with the avatar drawn as a full-width bar.
app.css carried a complete Discord-shaped card under `.user-popup` /
`.up-*`, but nothing in the codebase renders those classes — the
component emits `.upp-*`. The component had been rewritten with a new
prefix and the stylesheet was left pointing at a DOM that no longer
existed. With no rule matching, the card stayed `position: static`, so
the left/top it computes were discarded and both it and its overlay laid
out as ordinary blocks at the end of <body>.
Replace the orphaned block with rules for the classes actually rendered,
following the same anatomy: banner strip, avatar straddling the
banner/body seam inside a ring punched from the card background, panel
sections, action row. Everything routes through existing tokens, so the
card follows the theme contract.
Two latent bugs fixed while there:
- Placement guessed a 300px card height and clamped only the top edge,
so a member clicked low in the list opened a card that ran off the
bottom of the window. Measure the card and clamp both edges.
- The avatar has to hang off the body's top edge, but the body scrolls,
and `overflow-y: auto` clips horizontally too. Make it a child of the
card rather than the body.
The fade+scale moves from inline styles into CSS so a
`prefers-reduced-motion` override can drop it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(client): stop vite watching src-tauri
`npm run tauri dev` died on Windows partway through the cargo build:
Error: EBUSY: resource busy or locked, watch
'src-tauri\target\debug\deps\owncord_client_lib.dll'
Error The "beforeDevCommand" terminated with a non-zero status code.
Vite's watcher recursed into `src-tauri/target/`, and the moment cargo
wrote the output DLL, node's FSWatcher raised EBUSY as an unhandled
error event and killed the vite process. Vite is tauri's
`beforeDevCommand`, so its death aborted the whole dev session.
The config matched the upstream Tauri vite template in every respect
except the `server.watch.ignored` block that template ships with. Add
it. Tauri already watches `src-tauri` itself for rebuilds, so nothing
is lost.
Windows-specific — EBUSY on an open handle is a Windows filesystem
semantic, and CI only ever runs `tauri build`, never `tauri dev`, so
neither caught it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* docs(security): add 2026-08-04 whole-codebase security review (#1326)
Read-only security review of the full tree (Go server, admin panel, WASM
plugin host, LiveKit voice, Tauri client). No code changes.
Three findings, all the same defect class — a security predicate enforced
at some members of a handler family but not all:
- A-2026-08-01 (HIGH) handleDeleteChannelPermission omits the hierarchy and
grantability guards its PUT twin carries, so a MANAGE_CHANNELS holder can
clear their own role's channel deny and read private channels.
- A-2026-08-02 (HIGH) the admin channel list/patch/delete handlers omit the
type == "dm" guard their sibling getPermChannel carries, so the same role
can enumerate and irreversibly cascade-delete arbitrary DMs and group DMs.
- A-2026-08-03 (MEDIUM) DMService.RingTargets omits the block check the five
other DM interaction sinks perform, so a blocked user can ring the person
who blocked them.
Also records one non-vulnerability observation (backup restore writes to a
hardcoded database path, silently no-opping when database.path is
customised), the candidates rejected during verification, the areas verified
clean, and the areas not examined.
Claude-Session: https://claude.ai/code/session_01Q7GUJtdsHHHGs4pSiLn6LJ
Co-authored-by: Claude <noreply@anthropic.com>
* Full audit: docs/spec refresh + remediation (security fixes, dead-code removal, test & CI gaps) (#1327)
* docs: fix server reference docs (api, protocol, server-configuration, deployment)
api.md:
- Correct the login rate limit: 5/min per IP (was documented as 60/min);
document the per-username lockout and lockout persistence
(Server/api/constants.go, Server/api/auth_handler.go)
- Complete the middleware list to the real 9-entry chain incl. the
opt-in Coraza WAF (Server/api/router.go)
- Add voice_sessions and broadcast_drops to the metrics sample
(Server/api/metrics_handler.go) and document the otel-only
Prometheus /metrics mount
- Add reference sections for the previously undocumented /admin/api
endpoints: setup, stats, users, audit-log, settings, tokens,
backups, updates, and the SSE log stream (Server/admin/api.go)
protocol.md:
- Fix type counts (client->server 26, server->client 37) and add the
missing rows: call_ring, call_decline, emoji_update, call_incoming,
call_declined
- Correct rate limits: voice join/leave 5/1s (was "None"), E2EE offer
64/1s (was 5/1s), and add the call-ring limit (1/3s)
- Document the plugin command wire types (chat_command, command_reply,
plugin_broadcast) and flag that they sit outside protocol-schema.json
server-configuration.md:
- Add missing keys: server.waf_* (3), database.type,
telemetry.otlp_insecure, and the whole logging section +
OWNCORD_LOGGING_LEVEL
- Correct plugin-disabled status code to 503 (was 501)
deployment.md:
- Drop the removed "version" field from the /health sample; add
broadcast_drops to the metrics sample; note the distroless non-root
image; refresh build version strings
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KDaeRAN79nVdgtNX2zJVrx
* docs(architecture): rewrite stale architecture pages against
|
||
|
|
086979b7e8 |
Release v1.2.0-alpha.1 -> main (#1309)
* fix(admin): accept same-origin first-run setup requests A freshly generated config.yaml leaves allowed_origins commented out, so the list is empty. The setup handler's CSRF guard assumed "no Origin header means same-origin", but browsers send Origin on same-origin POSTs too — Chrome and Edge always, Firefox since 70. The admin panel's own setup call is one of those POSTs, so every new install hit "cross-origin setup request blocked" and could never create an owner account. The guard now accepts a request whose Origin names the same host:port as the request's own Host header, falling back to the allowlist otherwise. That is what the original comment intended. CSRF protection is unaffected: a cross-site attacker cannot set Origin, the browser does, and a foreign origin still needs an explicit allowlist entry. Scheme is not compared. Nothing in this server derives the external scheme (no r.TLS or X-Forwarded-Proto handling exists anywhere), so a scheme check would reject legitimate requests behind a TLS-terminating proxy. Tests: isSameOrigin table covering port/host/suffix/schemeless/opaque-origin cases, plus two handler-level tests pinning both halves — same-origin succeeds against an empty allowlist, a foreign origin still 403s and creates no user. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * feat(identity): implement identity keypair caching and error handling * fix(client): use the real OS credential store, not keyring's mock (#1281) The `keyring` crate declares no `default` feature. Every platform arm in its lib.rs selects a backend only when that platform's feature is on and otherwise falls through to `pub use mock as default`, so the client's bare `keyring = "3"` compiled the in-memory mock store on Windows, macOS and Linux alike. The mock keeps its secret in the `Entry` object itself, and each command built its own `Entry`: save_identity_key -> Entry::new(..) -> set_password -> Ok(()) load_identity_key -> Entry::new(..) -> get_password -> NoEntry So a save reported success, the very next read in the same process returned nothing, `NoEntry` was mapped to `Ok(None)` so neither side logged anything, and no entry was ever written to Credential Manager on any machine. Downstream, the voice-E2EE identity keypair was regenerated on reconnect, the published identity key stopped matching the key that signed the announce, and peers correctly rejected it as a possible MITM. Name the platform backends explicitly, and stop trusting a store that reports a write it did not keep: - secret_store: read every write back and compare before reporting success. If the store returns a value we did not write, purge it so it cannot shadow the fallback on the next read. - On Windows only, fall back to a DPAPI-protected file in the app data dir, engaged solely after a proven round-trip failure and cleared as soon as the real store works again. The account name is mixed into the DPAPI entropy so a blob cannot be moved between entries and decrypt. macOS/Linux report an error instead of writing secrets to plaintext. - Log the compiled backend at startup and add `probe_credential_store` so an affected machine can be diagnosed from its own log file. - Guard the regression: `compiled_keyring_backend_is_persistent` fails the build if the features are ever dropped again. Verified to fail against `keyring = "3"`. The E2EE fail-closed posture is unchanged: a peer whose announce signature does not verify is still rejected. Linux builds now need `libdbus-1-dev` for the Secret Service backend. Claude-Session: https://claude.ai/code/session_016oUHtEUWWxC79eB88GvX58 Co-authored-by: Claude <noreply@anthropic.com> * fix(client, admin): make the settings panel, client, and admin panel do what they say (#1282) * fix(client): make the settings panel do what it says Functional review of every control in the settings overlay. Each fix below closes a gap between what a control promised and what it did. - Appearance: picking a theme no longer drops a saved accent colour. applyThemeByName strips every inline custom property from <body>, which includes the accent override; under neon-glow (whose body class sets --accent) the user's colour silently reverted until restart. - Overlay: reopening the panel rebuilds the active tab. The Voice & Audio mic meter and camera preview are torn down on close, so a reopened panel showed a dead meter and a black preview; tabs also now re-read prefs. The Logs tab's live listener is released when you switch away from it. - Status: the UserBar picker always started at "online" and never persisted, while the Account tab read a pref nobody else wrote — the two surfaces disagreed. Both now go through lib/userStatus, sync live via the pref-change event, and the saved status is re-asserted on connect. - Notifications: Do Not Disturb now suppresses the desktop notification and the chime, as its description in the panel claims. The taskbar flash, a passive cue, stays. - Keybinds: Ctrl+F, Ctrl+M, Ctrl+D, Ctrl+Shift+V and Ctrl+U were listed but unimplemented. They are wired now (voice ones only while in voice, all of them suspended while the settings panel is open). "Mark as Read" had no feature behind it at all and is replaced by the Escape behaviour that actually exists. - Account: backup codes now carry a "you won't see them again" warning and a copy button; the change-password form requires the current password before spending a server attempt and disables itself while in flight. - Advanced: removed the Hardware Acceleration toggle. Nothing read the preference it wrote — the webview decides GPU compositing before any JS runs, so honouring it needs a Rust startup change. - The settings sidebar name/avatar follow a rename instead of going stale, and settings/helpers no longer keeps a drifted copy of lib/preferences (the copy lacked the write guard, so a failed save could throw). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019TDE7ZPi38jLfEKhj7kvmm * fix(client): close silent-failure gaps in the inline admin surface Continuation of the settings-panel review into the rest of the client. - Member context menu had no styling at all: AdminActions renders BEM class names (context-menu__item and friends) that appear nowhere in the CSS, so the menu had no hover, no danger colour, and the "Change Role" submenu pushed the menu open instead of flying out. Added the missing rules. - The submenu offered a hardcoded admin/moderator/member list. On a server with custom roles those roles were unreachable, and picking a name that didn't resolve to a role id silently did nothing. Roles now come from the server's ready payload (owner excluded), and an unresolvable role reports an error instead of dead-ending. - Kick / ban / delete-channel now show an in-flight state, and the two-click confirm disarms after a few seconds so a menu left open can't turn a stray click into a ban (docs/architecture/ux/settings-and-admin.md §3). - Ban collects a reason, which the server already stores and displays (adminBanMember has always accepted one; the menu never passed it). - Copying an invite code was silent: no confirmation, and a clipboard rejection looked identical to success. It now toasts either way. - Creating an invite double-click-minted two of them, and revoking — which kills a live link — had neither a confirm nor an in-flight guard. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019TDE7ZPi38jLfEKhj7kvmm * fix(client): restore moderator message deletion and formatting - The delete affordance was offered only on your own messages, so a moderator could not moderate anything from the client. It now also appears when the signed-in user's role carries MANAGE_MESSAGES, derived from the role bitmasks the server already sends in `ready` (this is what docs/architecture/ux/messaging.md §4 specifies as "Delete (own / moderator)"). lib/permissions.ts existed for exactly this and had no callers at all. - Developer-mode "Copy ID" was silent on success and swallowed clipboard failures; it toasts either way now. - prettier --write on AdminActions.ts (Client Static Checks). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019TDE7ZPi38jLfEKhj7kvmm * fix(admin): stop the panel reporting success it didn't have Functional review of the server admin web panel. - An expired admin session left the panel on screen toasting "invalid or expired session" for every action, with no way back to the login form — only the log-stream code handled it. api() now handles 401 centrally: clear the token, return to login, and say why. - Deleting a backup called fetch() without looking at the response, so a failed delete reported "Backup deleted" and left the file in place. It now goes through api(), and — like every other destructive action here — asks for confirmation first. - A failed update check rendered as "Up to date. You're running the latest version", which is a lie that hides a broken update path. It now says the check failed and why. A failed apply no longer leaves the button stuck on "Applying...". - The Edit Channel modal could only rename. PATCH /channels/{id} accepts topic, slow_mode, position and archived, and the channel table has an Archived column — which was read-only state with no control behind it. All four are editable now. - Banned users showed "Yes" with no reason, even though the ban reason is collected on ban and returned by the API. It's now displayed. - Login and first-run setup had no in-flight guard, so a double-click spent two attempts against the login lockout / setup rate limit. Settings' Save stayed enabled after a successful save, implying unsaved changes. - Clipboard copies (invite code, new API token) had no rejection path: a refused clipboard looked exactly like a successful copy. - Backup names in inline onclick handlers go through jsq() like every other interpolated string. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019TDE7ZPi38jLfEKhj7kvmm * feat(admin): add the plugin management UI the backend already had /api/v1/admin/plugins has exposed list/install/enable/disable/uninstall since Phase C Step 9 — its own header says it "exposes plugin lifecycle operations to the admin panel", and docs/architecture/ux/settings-and-admin.md tells operators plugin management lives in the web panel. The panel had no Plugins section at all, so installing a plugin meant hand-crafting a multipart POST. Panel: - Plugins section: installed table (name, manifest description and requested permissions, version, enabled state, install date), zip upload with the 16 MB server cap stated up front, enable/disable, and uninstall behind a confirm. One lifecycle call at a time. - The lifecycle API sits under a different prefix than the rest of the panel and answers errors as plain text (http.Error), not JSON, so it gets its own fetch helper — sharing api() would have surfaced "unexpected token" instead of the server's reason. 401 still routes back to login. Server: - PluginRow had no JSON tags, so the list marshalled Go field names and every column would have rendered empty. Now snake_case like the rest of the API. - GET /plugins returns X-Plugin-Runtime: enabled|disabled. An empty list means "nothing installed" on a live runtime and "you can't install anything" on a disabled one; the body can't tell them apart, so the panel's empty state had no way to be honest about it. The plugin-store test helper now hands back the database the registry writes to — the existing happy-path test wired a *different* in-memory DB into the handler, which is why nothing noticed the list was always empty. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019TDE7ZPi38jLfEKhj7kvmm * feat(client): gate the composer on slow mode instead of failing the send Verified the optimistic message lifecycle against docs/architecture/ux — pending → chat_send_ok → sent, failed rows with mapped reasons, retry and delete-draft all behave as documented. One thing did not: slow mode. The UX spec (§5) says slow mode should "disable send with a live countdown in the composer; do not drop the drafted message". In practice the composer knew nothing about it: you typed, sent, and got a red failed row back — the exact enabled-then-rejected pattern §6.2 forbids. The client never even received the channel's slow_mode value. - Server: channel payloads (ready, channel_create, channel_update) now carry slow_mode alongside can_send, for the same reason can_send is there — the client can express the limit as affordance. The server still enforces. - Client: after an accepted send the composer disables itself for the channel's cooldown with a per-second countdown, and a SLOW_MODE refusal restarts the full window (the server's limiter is the authority on when the next send is allowed). The draft stays in the textarea. Moderators, who bypass slow mode server-side, are not gated. - The MANAGE_MESSAGES lookup added for moderator deletes moves into lib/permissions as currentUserPermissions/currentUserHasPermission/ canManageMessages, so the composer and the message renderer share one definition instead of two. - WsErrorCode listed 9 of the server's 16 codes: SLOW_MODE, CONFLICT, BAD_REQUEST, INVALID_JSON, UNKNOWN_TYPE, BAD_PAYLOAD, NOT_KEY_HOLDER and ALREADY_JOINED were missing, so code switching on it could not name cases the server actually sends. Now mirrors Server/ws/errors.go. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019TDE7ZPi38jLfEKhj7kvmm * fix(admin): make backup restore actually restart, and fail closed without a safety copy Verification pass over the remaining review items. Two real defects in restore, one duplicate resolved; cert TOFU and the replay path checked out as-is. Restore: - The handler closed the database, swapped the file underneath it, told the admin "database restored — server restarting", broadcast a 5-second restart countdown to every client... and then kept running. Nothing restarted it, so the server answered every subsequent request against a closed DB until an operator noticed. It now respawns for real, reusing the update-apply pattern (SpawnDetached → SIGTERM → os.Exit backstop) behind a test seam. - A failed pre-restore backup was a warning, and the irreversible overwrite went ahead anyway — removing the safety net the panel explicitly promises ("A pre-restore backup will be created"), precisely when it matters. It now aborts with the database untouched. - The safety copy was written to a cwd-relative "data/backups" while every other backup handler uses the absolute backupBaseDir, so a server started from another directory filed it somewhere the operator would never find. Both new tests were confirmed to fail against the previous behaviour. Client: - SidebarArea kept a private 140-line copy of the member-list wiring that SidebarMemberSection already provides (the extracted, tested one was never imported). Fixing the silent role-change failure earlier meant patching both; now there is one copy. Verified without changes: the optimistic send lifecycle (pending → chat_send_ok → sent, failed rows with mapped reasons, retry, delete-draft), reconnect replay (monotonic last_seq, dedup on reconnect, replay suppression of unread/notifications), and cert TOFU (first-use and mismatch modals, accept re-pins and reconnects, reject disconnects back to connect). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019TDE7ZPi38jLfEKhj7kvmm * fix(admin): remove the data race in the restart test hook CI (-race) failed identically on ubuntu and windows: TestHandleRestoreBackup_ Success polled a plain bool that the restore handler's goroutine wrote, and swapped the restartSelf package var from the test goroutine while that handler read it. The hook is now behind a mutex with an atomic flag in StubRestart. Production behaviour is unchanged — the race was entirely in the test seam I added. Verified with `go test -race -count=2 ./admin/`. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019TDE7ZPi38jLfEKhj7kvmm --------- Co-authored-by: Claude <noreply@anthropic.com> * refactor + perf: split largest source files into modules; optimize hot paths (#1283) * refactor(updater): split updater.go into cohesive files Split the 1070-line updater.go into four files within the same package: updater.go (core types, release checking), download.go (download and tarball extraction), verify.go (signatures, checksums, staged binary), and assets.go (client assets, text-asset cache, HTTP fetching). Pure mechanical move — no behavior or API changes. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BJM9kF4JBhRHEqtcv3sasu * refactor(ws): split hub.go into cohesive files Split the 1289-line hub.go into five files within the same package: hub.go (Hub struct, lifecycle, register/unregister), hub_broadcast.go (broadcast fan-out and per-user sends), hub_events.go (sequencing, replay, persistence), hub_sweep.go (stale client/session/voice sweepers), and hub_livekit.go (LiveKit accessors). Also optimizes wrapWithSeq on the hot broadcast path: build the seq prefix with a single preallocated append + strconv.AppendUint instead of fmt.Sprintf, halving allocations per broadcast message. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BJM9kF4JBhRHEqtcv3sasu * refactor(client): extract E2EEManager from livekitSession Move all client-side E2EE key-exchange logic (~550 lines) out of LiveKitSession into a new E2EEManager class in livekitE2EE.ts: ECDH keypair management, identity signing and TOFU pin verification, announce/offer handling, key-holder election, membership rekeying, and periodic key rotation. Dependencies are injected following the existing roomEventHandlers pattern. LiveKitSession keeps thin public delegates (handleE2EEAnnounce, handleE2EEOffer, handleParticipantLeft, rePinPeerIdentity) so the module-level bound exports and the public API are unchanged. livekitSession.ts shrinks from 1955 to 1409 lines. Adds focused unit tests for E2EEManager (key-holder setup, pending announce queue, offer resolution, clearState, rotation). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BJM9kF4JBhRHEqtcv3sasu * perf(server): hot-path and query optimizations Logging (biggest win): rewrite the admin log RingBuffer as a true ring (fixed array + head/count) instead of allocating a fresh 2000-entry slice + full copy per log line; gate the ring handler on a configurable level instead of unconditional DEBUG capture; move the broadcast debug log out of the seqMu critical section; drop the per-message slog.With clone in the WS handler. Database: new migration 019 adds idx_attachments_message (message pages no longer scan the attachments table), a covering role-leading index on channel_overrides (replacing a duplicate of the UNIQUE auto-index), a partial index for pinned messages, and narrows the FTS trigger to content changes only; ANALYZE runs after migrations. Rewrite GetChannelUnreadCounts and GetUserDMChannels to correlated subqueries that range-scan idx_messages_channel — O(unread) instead of O(all messages) per WS connect. New GetUserDMChannelIDs replaces the full DM query where only IDs are needed. CreateMessage/EditMessageContent use RETURNING, removing the re-read after every send/edit. Write-path contention: TouchSession throttled to once per minute per session (was one UPDATE per authenticated request); EventPersister flushes its batch in a single transaction with per-row fallback; revoked-session and stale-voice sweeps run off the hub dispatch goroutine with an in-flight guard, and session checks are batched into one IN query; the rate limiter is sharded into 32 buckets with allocation-free strconv key building (auth.Key). WS structural: voice E2EE channel fan-out goes through the existing pubsub voice topic instead of scanning every connected client under h.mu; channelReadAudience memoizes role lookups per call; hasChannelAccess drops its redundant duplicate permission check; voice_join batches SPEAK/VIDEO/SCREENSHARE checks via HasChannelPermBatch. Also: pubsub topic builders and NewAppMetrics stop allocating via Sprintf/global mutex. Verified with go test -race across all packages, go vet, gofmt, and sqlc generate idempotency. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BJM9kF4JBhRHEqtcv3sasu * perf(client): render-path, logging, and bundle optimizations Logging: the logger no longer runs permanently at debug — level is set from the environment at startup (debug in dev, info in prod), so every hot-path debug entry stops being serialized, buffered, consoled, and persisted to disk; per-URL debug logs in embed rendering removed. Render path: MessageList's store selector is scoped to the mounted channel, so messages in other channels no longer trigger re-renders, and a new incremental tail-append fast path appends rows instead of tearing down the whole window; Intl.DateTimeFormat instances are cached at module level; parseTimestamp memoizes epoch millis; media prefs (showEmbeds/inlineMedia/showLinkPreviews/animateGifs) are cached with pref-change invalidation; members store gains a roleRevision counter so MessageList stops rebuilding a role map on every presence/typing event. MemberList patches presence changes in place (status dot + offline class) via a row map instead of rebuilding every row, with single-pass role grouping. ChannelSidebar splits its voice subscription into a structural selector (excluding speaking) and a speaking-only patcher using a cached element map instead of per-user querySelector on every speaker event. Memory: GIF/media elements are unobserved before the message window discards them, fixing unbounded IntersectionObserver retention of detached DOM (including frozen-frame data URLs). Bundle: livekit-client (1.3 MB) moves to its own chunk via dynamic imports and manualChunks; the READY handler's stale-voice check reads the voice store instead of requiring the module synchronously. Adds 11 focused tests (different-channel no-rerender, append fast path, media release, presence patch, speaking patch). Full unit suite: 3606/3606 passing; typecheck, lint, and production build clean. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BJM9kF4JBhRHEqtcv3sasu --------- Co-authored-by: Claude <noreply@anthropic.com> * fix(ci): skip alloc test under deadlock tag; cut bcrypt cost in tests (#1284) The deadlock-tag CI pass failed on TestRingBuffer_WriteDoesNotAllocate: under -tags deadlock, syncutil.Mutex is the go-deadlock mutex whose Lock allocates, so the steady-state ring write measures 1 alloc/call. Extend the build constraint to !race && !deadlock — the test's guarantee is about the ring buffer itself, which the -race-less default pass covers. Make bcryptCost a var with an exported SetCostForTesting hook that also resets the dummy timing pad, and call it with bcrypt.MinCost from the api, auth, and admin TestMains. Password hashing at production cost 12 dominated those suites (~264 hashes): with the race detector the api package alone took ~860s; it now runs in ~33s. Nothing under test depends on hash strength, and no test asserts the cost. Hygiene in the same pass: migration 020 drops idx_sessions_token and idx_invites_code (exact duplicates of their UNIQUE auto-indexes, pure write overhead) with updated db_test assertions; remove the dead tar.TypeRegA comparison in the updater (stdlib normalises it to TypeReg since Go 1.11); gofmt storage/storage.go comment alignment. Claude-Session: https://claude.ai/code/session_01BJM9kF4JBhRHEqtcv3sasu Co-authored-by: Claude <noreply@anthropic.com> * perf(ws): route hot-path permission checks through the cached PermissionService (#1285) The ws package was the only major subsystem still doing live per-check permission queries (GetRoleForUser + GetChannelPermissions per check): a V2 voice join cost 9+ DB reads across its four gates, and every channel broadcast resolved one role query per connected client. Hub now holds svc.Permissions and the voice deps carry it (nil-safe: bare test fixtures fall back to the existing live path, fail-closed semantics preserved everywhere). Converted sites: the voice join and token-refresh permission gates, USE_VIDEO/SHARE_SCREEN controls, requireChannelAccess, channelReadAudience, and RefreshChannelVisibility. Caching these is revocation-correct: every permission-changing mutation already invalidates synchronously before hub fan-out (InvalidateUser on role change, InvalidateAll on override change), the 30s TTL is only a backstop, and the service's gen-counter guard prevents a populate that races an invalidation from caching stale data — the audience-resolution comments now document that invariant. The stale-voice sweeper's check deliberately stays live: it is the last-line backstop for revocations that might bypass an invalidation hook, runs once a minute for only in-voice clients, and its eviction test pins exactly that guarantee. requirePerm keeps its INTERNAL-vs-FORBIDDEN distinction by using the cache only for positive verdicts and falling through to the live path on denial. Adds perm_cache_test.go: role-change invalidation is immediate (no TTL wait), and a counting-store test proving the second check is served from cache. All pinning tests (authz, voice_perm_stale, channel visibility agreement, sweep eviction) pass unmodified. Claude-Session: https://claude.ai/code/session_01BJM9kF4JBhRHEqtcv3sasu Co-authored-by: Claude <noreply@anthropic.com> * perf + refactor: SQLite reader pool, async audits, real lazy-livekit, test splits, eslint 10 (#1286) * perf(db): batch audit writes through an async writer Audit inserts ran synchronously on the request path — including one INSERT per WebSocket connect — each an implicit transaction on the single SQLite connection. WriteAudit keeps its exact signature and D8 policy (never fail the caller, never silently discard): it now upgrades to an async path when the passed Auditor also implements AsyncAuditor. *DB implements that via an atomic pointer that main.go populates at server startup with an AuditWriter modeled on the event persister (bounded queue, batched single-transaction flush with per-row fallback, drain-on-stop, atomic counters, non-blocking enqueue that error-logs drops without leaking the detail field). The token CLI and tests never install a writer, so they keep today's synchronous behavior with zero call-site changes. The writer's Stop defer registers after database.Close's so the LIFO unwind drains the queue before the DB shuts. Adds audit_writer_test.go: batch flush, D8 drop logging, drain-on-stop, flush-failure accounting, poison-row fallback, concurrent enqueue, and seam tests pinning sync-without-writer vs async-with-writer behavior. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BJM9kF4JBhRHEqtcv3sasu * perf(client): actually defer livekit-client; honor saved log level at startup The manualChunks split was cosmetic: index.html modulepreloaded the 531 kB livekit chunk and the entry statically imported it. All four import chains from startup are now cut — auth.store's logout leaveVoice and ptt's setMuted go through dynamic imports, applyStoredAppearance moved to lib/appearance.ts so main.ts and ConnectPage stop pulling the settings tree (whose overlay now loads on first open), and MainPage itself is a dynamic import in renderPage, guarded against the destroy-before-mount race by a navigation-generation helper and pre-warmed once the socket connects. Entry chunk drops 387 kB -> 114 kB (gzip 36 kB); index.html has no modulepreload links; livekit/MainPage/SettingsOverlay/livekitSession load as lazy chunks. The logger now honors the Logs tab's saved minimum level at startup (applyStoredLogLevel with the legacy-key migration moved into lib/preferences.ts) and re-applies it live on pref changes. Dead code: remove unreachable VoiceChannel.ts (superseded by ChannelSidebar's renderer) and its test, plus all knip-flagged unused re-exports in message-list/renderers.ts and ConnectPage's unused form types — knip is now clean apart from pre-existing config hints. Tests: +12 (navigation guard incl. stale-mount discard; logger startup pref, migration, and live re-apply); ptt/stored-appearance updated for dynamic-import plumbing only. Full suite 3593 passing; typecheck, lint, and production build clean. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BJM9kF4JBhRHEqtcv3sasu * perf(db): split SQLite into single-writer + multi-reader connection pools The entire server serialized on one SQLite connection: every read queued behind every other read and every write, throwing away WAL's concurrent-reader capability. File-backed databases now open two pools from a DSN that carries all seven PRAGMAs as per-connection _pragma parameters (an Exec'd PRAGMA only configures one arbitrary pooled connection — moving them into the DSN is what makes >1 connection safe, foreign_keys included): a single-connection writer with _txlock=immediate, and a reader pool sized max(4, NumCPU). In-memory databases keep the exact historical single-connection behavior, which preserves every :memory: test site and the connection-scoped PRAGMA-toggle tests untouched. Routing lives in a dbtx router implementing sqlc's DBTX: statements go to the reader only when provably read-only (leading SELECT/PRAGMA after skipping comments — necessary because sqlc routes INSERT/UPDATE/DELETE ... RETURNING through QueryRowContext/QueryContext, which must stay on the writer); Exec, transactions, migrations, ANALYZE, VACUUM INTO, and the SQLDb() escape hatch all pin to the writer. Every former sqlDB reference across the package was re-pointed deliberately. New pool_test.go pins the properties the split must preserve on a file-backed DB: foreign_keys=1 across many reader connections, WAL journal mode, FK enforcement through both write paths, 8x8 concurrent reader/writer hammering with exact row counts, and a read completing against the pre-tx snapshot while a write transaction is open — the property this change exists to unlock. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BJM9kF4JBhRHEqtcv3sasu * test(client)+chore: split the two largest test files; eslint 10; audit clean Split tests/unit/ws.test.ts (3340 lines) into ws-cert / ws-reconnect / ws-messaging / ws-lifecycle plus a shared helpers/ws-mocks.ts module, and tests/unit/audio-pipeline.test.ts (2547 lines) into core / gain / vad-worklet / vad-fallback files. Test bodies moved verbatim; the suite count is unchanged at 3593 passing. Upgrade eslint 9 -> 10 (with @eslint/js 10; typescript-eslint's peer range already covers v10, flat config unchanged, zero new findings) and pin test-exclude ^8 via the existing overrides block so the coverage chain picks up patched glob/minimatch/brace-expansion. npm audit: 8 high -> 0 vulnerabilities. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BJM9kF4JBhRHEqtcv3sasu * refactor(server): split remaining large files; dependency hygiene notes Split ws/coverage_boost_test.go (2856 lines) into coverage_helpers / chat / voice / voice_lifecycle / misc test files — bodies verbatim, 746 passing tests before and after. Split service/message.go (781) into message_crud / message_reactions / message_query / message_perms with types and the constructor staying put, and ws/serve.go (754) into serve / serve_pumps / serve_auth / serve_ready. Dependency findings (no changes needed): coraza-coreruleset's stale Feb-2024 pseudo-version is unreachable from our code — it enters the module graph only through coraza's own internal tests, and our WAF uses inline directives, never the CRS (fresher rules would require adopting the /v4 module and rewiring the WAF config — deliberate follow-up, not hygiene); gogo/protobuf is likewise graph-only via the livekit SDK and never built into our binaries. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BJM9kF4JBhRHEqtcv3sasu * style: satisfy golangci-lint modernize/staticcheck in new pool and audit code CI's golangci-lint pass (not run locally until now) flagged the Phase 3/4 additions: range-over-int loops, interface{} -> any on the dbtx router, WaitGroup.Go in the pool tests, and a De Morgan simplification in isReadOnlySQL's identifier-boundary check. Pure style — verified against the same golangci-lint v2.11.3 binary CI uses (0 issues) and re-ran db/ws race + deadlock suites green. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BJM9kF4JBhRHEqtcv3sasu --------- Co-authored-by: Claude <noreply@anthropic.com> * feat(waf) + fix(deps) + test(ws): OWASP CRS, Dependabot fixes, sleep-free ws tests (#1287) * fix(deps): clear quick-xml RUSTSEC advisories in Tauri lockfile cargo-audit identified the two Dependabot alerts on the default branch: quick-xml 0.37.5 and 0.38.4 both carry RUSTSEC-2026-0194 (quadratic runtime on duplicate-attribute checks) and RUSTSEC-2026-0195 (unbounded namespace allocation DoS), fixed in >=0.41. Both were transitive: plist 1.8.0 (via tauri) and tauri-winrt-notification 0.7.2 (via notify-rust). Semver-compatible updates fix both — plist 1.10.0 moves to quick-xml 0.41, and tauri-winrt-notification 0.7.3 drops quick-xml entirely. cargo-audit is now clean of vulnerabilities; the remaining 20 informational notices are the unmaintained GTK3-binding crates inherent to Tauri v2 on Linux. Verified plist compiles against quick-xml 0.41 (full Tauri build needs the GTK/WebKit system libs CI installs). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BJM9kF4JBhRHEqtcv3sasu * feat(waf): layer the maintained OWASP Core Rule Set onto the WAF The WAF previously ran six inline directives only — the CRS never loaded (the old coreruleset dep was a stale graph-only pseudo-version). A second Coraza engine now loads the embedded CRS from coraza-coreruleset/v4 (v4.25.0), layered on top of the inline rules, which stay byte-identical and keep blocking exactly as before. CRS ships in a new server.waf_crs_mode knob (off|detect|block), defaulting to detect: chat traffic is CRS-false-positive-prone (a new test pins that block mode rejects benign SQL-ish chat prose at the default threshold), so operators get rule-match visibility via structured logs first and opt into blocking after tuning. Setup mirrors the official connector: Host/Transfer-Encoding restored to the transaction (else 920280 fires on everything), phase 2 always runs so query-string attacks are scored, PUT/PATCH/DELETE added to the CRS method policy for this REST API, body limits matched to the app's 1 MiB cap with uploads excluded from body access and the content-type policy. Also fixes a latent middleware bug: the body was previously swapped for the buffered reader even when nothing was buffered, which would have handed body-access-off routes an empty body; now pinned by a test across all modes. Adds waf_crs_test.go (load, mode wiring, XSS/traversal detection without blocking, block-mode blocking + benign passthrough, upload body preservation); waf_test.go passes unmodified. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BJM9kF4JBhRHEqtcv3sasu * test(ws): replace fixed sleeps with condition-based waits The ws suite paced async hub effects with 537 fixed time.Sleep calls — slow at best, flaky under load at worst. They are now condition-based: a small waitFor/waitRegistered/waitClientCount/waitMsgOfType helper set (waitRegistered exploits the hub's in-order client-event processing), plus blocking decode-scans for the DM tests. The bulk deletion is grounded in verified production facts, unchanged by this commit: sendMsg is a synchronous buffered send (error replies are already buffered when the handler returns), the voice control / rollback / cleanup / sweep paths are synchronous, and serve.go registers the client before writing the ready frame. Absence assertions were deliberately NOT inverted into polling — they keep bounded windows, each commented. 20 sleeps remain, all justified in place: poll intervals inside condition loops, absence windows, clock-granularity pacing, and the event-pruner's inherently time-based no-prune-after-cancel assertion. Suite: 746 tests before and after; 62.6s -> 46.1s (30s of the remainder is GracefulStop's hard-coded production 5s drain, out of scope here); race flake check passes 3 consecutive iterations; deadlock pass and golangci-lint clean. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BJM9kF4JBhRHEqtcv3sasu --------- Co-authored-by: Claude <noreply@anthropic.com> * fix: audit-driven fixes — client leaks/lazy-load, WAF detect logging, audit shutdown race (#1288) * fix(waf,db): aggregate CRS detect-mode logging; make audit Stop await goroutine exit WAF detect mode wired logCRSMatch as the engine-level error callback, which fires one slog.Warn per matched rule on the request goroutine. In the default detect mode ordinary chat prose trips several CRS SQLi/XSS rules plus anomaly scoring, so each request logged a burst of Warn lines in the hot path. Aggregate per request from per-transaction state instead of the shared global callback: in the default detect path leave the engine error callback nil and, in the existing crsTx defer, emit at most one Warn per request that had matches (count + highest-severity rule), demoting the full rule-id list to Debug. Block mode keeps per-rule logging (blocked requests are rare and their detail is wanted), and a caller-supplied onCRSMatch callback keeps per-rule delivery so existing tests stay unmodified. Detection, interruption, and body handling are unchanged — only the detect-path logging shape. The audit writer's Stop selected between <-done and <-ctx.Done(); on a slow flush the 5s ctx could win, returning while run() was still flushing. main.go's LIFO defers then closed the DB pool under a live flusher, losing audits. Stop now always waits on done (the goroutine has stopped touching the store) while ctx bounds only the drain inside run() via a published stopCtxDone channel, so a slow store delays shutdown by at most one in-flight flush and the pool is never closed under a live writer. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BJM9kF4JBhRHEqtcv3sasu * fix(client): plug listener leaks, guard lazy livekit load, honor saved log level Follow-up audit of the recently-landed lazy-livekit and session wiring found three real issues: - clearAuth unconditionally dynamic-imported livekitSession to call leaveVoice on every logout, pulling the ~531 kB livekit chunk into the logout path even when no voice session was ever active. Guard the import on an active voice session (currentChannelId set and status not idle) and add a .catch so a failed teardown import can't reject unhandled. - The onStateChange handler unsubscribed session listeners only on the ready transition, not on disconnected; user_update and ready listeners registered per session were never collected for cleanup. Collect them into a sessionUnsubs array cleaned up on both ready and disconnected, preventing duplicate handlers accumulating across reconnects. - The Logs tab min-level select ignored the persisted log level when no explicit dropdown preference was saved. Add logger.getLogLevel() and default the select to it so the UI reflects the level actually in effect. Also add .catch to the ptt setMuted dynamic import. New unit tests cover the clearAuth guard, getLogLevel, and the LogsTab default. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BJM9kF4JBhRHEqtcv3sasu --------- Co-authored-by: Claude <noreply@anthropic.com> * fix(waf): load embedded OWASP CRS ruleset correctly on Windows (#1289) The CRS WAF engine failed to initialize on Windows, taking the whole api package's test suite red there. coraza's seclang parser resolves Include globs through path/filepath: for every match of `Include @owasp_crs/*.conf` it calls filepath.Join(currentDir, match), which on Windows rewrites the forward slashes to backslashes. It then feeds names like `@owasp_crs\REQUEST-901-INITIALIZATION.conf` back into the root fs.FS. That FS is the ruleset's embed.FS, which is always forward-slash and rejects a backslash name, so newCRSWAF returned "file does not exist" and no CRS rule under a subdirectory was ever loaded. Wrap coreruleset.FS in a small slash-normalizing fs.FS (Open/ReadFile/ReadDir/ Glob) that converts backslashes to forward slashes before delegating. This fixes CRS loading on Windows without patching coraza or the ruleset module and is a no-op where the separator is already "/". The Linux-only local verification for the CRS work missed this because coraza never emits backslashes there. The new test reproduces the failure mode on any OS by constructing the exact backslash name coraza produces on Windows: the raw ruleset FS fails to read it, the wrapper resolves it, and a forward-slash path still works. Claude-Session: https://claude.ai/code/session_01BJM9kF4JBhRHEqtcv3sasu Co-authored-by: Claude <noreply@anthropic.com> * fix(e2e): repair the Playwright suite so the CI job stops timing out (#1291) The Client E2E CI job never completed: every run hit its 25-minute cap and was cancelled. ~229 of the 255 web tests were failing, all cascading from the shared login helper, and 255 tests x 3 attempts x 20-45s of timeout burn on 1 worker deterministically exceeds the cap. Root cause: the e2e Tauri mock predates the Rust HTTP TOFU proxy. api.ts now awaits invoke("start_http_proxy") and builds REST URLs as http://127.0.0.1:{port}/api/v1/..., but the mock's invoke returned null for the unstubbed command, so every URL got a literal "null" port and Request construction threw before the mocked plugin:http transport was consulted. Login rejected, [data-testid='app-layout'] never mounted, and every logged-in test burned its full timeout. Stubbing start_http_proxy with any numeric port fixes the cascade because route matching is substring-based. The tail of failures after that fix were tests asserting behavior the app intentionally changed: - The ready payload can no longer pre-connect the local user to voice: the dispatcher treats "self in ready.voice_states while idle" as stale state from a reload and immediately leaves. MOCK_VOICE_STATE now seeds remote users only (2, 3), and widget tests join through the real click path via a new joinVoiceChannelByName helper. - The mock's voice_join reply no longer includes a voice_token: a token starts a real LiveKit session that deterministically self-destructs in the browser mock (E2EE key exchange timeout ~15s / connect-refused retries), tearing the widget down mid-test. These web tests validate the WS/UI layer only; real LiveKit is covered by the native suite. The reply also gained the full VoiceStatePayload shape — the sidebar renders user.username directly, and the omitted field broke the whole voice-user list render. - Message-load failure now renders an inline region error + Retry instead of a toast (UX spec 2), so the toast specs assert the inline UI and get their auto-dismiss vehicle from the delete-confirmation toast. CI hardening so a future systemic breakage can never burn the full cap again: maxFailures 20 and a 20-minute globalTimeout in CI (Playwright now self-terminates with a usable report instead of being SIGKILLed), with the workflow's timeout-minutes 25 as the outer backstop. The job stays continue-on-error until it has proven stably green across a few pushes; the ci.yml comment documents that flip trigger. Full suite: 255/255 passing locally (~7.5 min at 1 worker, ~4 min at 2). Unit tests (3598), typecheck, and prettier all clean. Claude-Session: https://claude.ai/code/session_01BJM9kF4JBhRHEqtcv3sasu Co-authored-by: Claude <noreply@anthropic.com> * feat(admin): first-run setup wizard with config.yaml write-back + LiveKit auto-download (#1290) * feat(admin): first-run setup wizard with config.yaml write-back Turn the single-screen owner-account setup into a guided multi-step wizard so non-technical operators never have to hand-edit YAML: - config: new comment-preserving config.Save (yaml.Node round-trip, atomic temp+rename write, verified loadable before replacing the file) plus a shared config.DefaultPath. Persists the runtime-generated LiveKit credentials so voice tokens survive restarts. - admin: POST /admin/api/setup accepts an optional "wizard" object (server name, MOTD, registration, port, TLS mode/domain, upload limit, voice quality). Values are validated before the account is created; DB settings and config.yaml are written after; failures downgrade to warnings so the created owner is never orphaned behind a 5xx. When a startup-only value changed the server restarts itself (reusing the backup/update restart machinery) and returns the new admin URL. - admin: GET /admin/api/setup/status now returns secret-free prefill defaults while setup is pending. - admin panel: six-step wizard UI (welcome, account, server basics, uploads & voice, access, review) with plain-language explanations, a restart/reconnect screen, and a "skip" path that keeps the legacy account-only flow byte-for-byte. - legacy payload {username,password} and all existing call sites keep working (SetupOptions is a trailing variadic parameter). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018iHyK5WtjSQgjubTegSrUB * fix(lint): satisfy modernize — any over interface{}, new(expr) over ptr helper Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018iHyK5WtjSQgjubTegSrUB * feat(voice): auto-download the LiveKit server binary Voice now works with zero manual setup: when voice.auto_download_livekit is enabled and no voice.livekit_binary is configured, the server fetches the pinned livekit-server release (v1.13.5, overridable via voice.livekit_version) from the official LiveKit GitHub releases in the background at startup, verifies it against the release's checksums.txt, extracts it into data/livekit/, and manages it as the existing companion process (crash recovery, health checks, graceful shutdown). - ws: new livekit_download.go — pinned version, per-platform asset mapping (linux/windows × amd64/arm64/armv7, matching LiveKit's goreleaser config), size-capped downloads, hash verification and extraction through one open handle (TOCTOU-safe), O_EXCL staging, atomic rename, stale-version cleanup. LiveKitProcess.Start resolves the binary asynchronously with retries so boot is never blocked. - config: voice.auto_download_livekit + voice.livekit_version; enabled in the generated default config so fresh installs get working voice out of the box, while the compiled-in default stays off for existing configs. config.Load now loads the default file it just wrote, so the first boot runs with exactly the configuration the file documents. - wizard: "Voice chat" toggle (on by default) in the Uploads & voice step; the choice is written to config.yaml and factored into the restart decision. - docs: livekit-setup, server-configuration, deployment, README. Verified end-to-end against the real v1.13.5 release: download, checksum match, extraction, and process spawn all succeed. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018iHyK5WtjSQgjubTegSrUB * chore: remove stray server.log, ignore local run logs Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018iHyK5WtjSQgjubTegSrUB --------- Co-authored-by: Claude <noreply@anthropic.com> * fix(voice): desktop-client origins on LiveKit proxy + client version bump to alpha.5 + release version guard (#1293) * fix(voice): accept the desktop client's webview origins on the LiveKit proxy The desktop client's chat connection goes through its Rust proxy, which sends no Origin header, so the safe-default empty allowed_origins never blocked it. The LiveKit JS SDK's signal requests and validate probes, however, are issued directly from the webview and carry its fixed origin (http(s)://tauri.localhost on WebView2, tauri://localhost on WKWebView/WebKitGTK). isOriginAllowed treated those as cross-origin and returned 403, so on every default install voice failed for any desktop client that wasn't on the server machine — chat worked, voice didn't, with /livekit/rtc/v1 403s in the server log. Treat these fixed first-party origins as always allowed. This is the same trust already extended to absent-Origin requests: web content can never present them (browsers resolve *.localhost to loopback and cannot reach the tauri:// scheme), so the CSRF surface is unchanged. Exact, case-insensitive matching only — lookalikes (tauri.localhost.evil.com, tauri.localhost:8080) still require an explicit allowlist entry. Operators no longer need to hand-add these origins to server.allowed_origins for voice to work; that list is now only for web/browser clients. Docs and the generated config comment updated. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018iHyK5WtjSQgjubTegSrUB * chore(client): bump version to 1.1.0-alpha.5 The v1.1.0-alpha.4 release shipped client artifacts still versioned 1.1.0-alpha.3 because the client manifests were never bumped — deployed desktop clients therefore consider themselves up to date and never auto-update. Bump package.json, package-lock.json, tauri.conf.json, Cargo.toml and Cargo.lock to 1.1.0-alpha.5 so the next release's clients update normally. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018iHyK5WtjSQgjubTegSrUB * ci(release): fail the release when client version does not match the tag Guards against the v1.1.0-alpha.4 mistake recurring: a new verify-versions job compares the pushed tag against tauri.conf.json, package.json and Cargo.toml and fails before any build starts; every build job now depends on it. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018iHyK5WtjSQgjubTegSrUB --------- Co-authored-by: Claude <noreply@anthropic.com> * fix(admin): allow API-token principals to use the SSE log stream (#1294) The log stream was session-only: POST /admin/api/logs/ticket required a *db.Session in the request context (deliberately nil for API-token principals), and the stream handler re-validated the ticket hash against the sessions table alone. API tokens could reach every other /admin/api/* route but not the log stream, breaking the mcp-introspect server_logs tool that docs/mcp-introspect.md documents as working. Bind tickets to the hash of whichever bearer credential authenticated the request, and resolve it in the stream handler via auth.ResolveTokenHash — the same session-first, API-token-fallback path the admin middleware uses. Ban, role demotion, and mid-stream revocation of either credential kind cut the stream exactly as before. Co-authored-by: Claude Fable 5 <noreply@anthropic.com> * fix(voice): treat the server's own origin as same-origin on the LiveKit proxy (#1295) A page served by the server itself (e.g. a browser client at https://<server>:8443) chats fine but cannot join voice: browsers attach the page origin to every WebSocket handshake, and the LiveKit proxy's hand-rolled isOriginAllowed only recognized "no Origin" as same-origin, so the RTC upgrade 403'd while same-origin fetches (which omit Origin) succeeded — /livekit/rtc/v1 403s with validate flipping 403/200 in the server log. Allow an Origin whose host equals the request Host, mirroring websocket.Accept's default same-origin policy that the chat WS endpoint already applies — which is exactly why chat worked and voice didn't. Web content on another origin can never present this origin (the browser pins it), so the CSRF surface is unchanged. Same host on a different port remains cross-origin and denied. Also log rejected origins on the 403 path (origin, path, remote) — this failure was previously undiagnosable from the server log, which recorded the 403 but not the offending origin. Existing allowlist tests used origins colliding with httptest's default request host (example.com), which the new semantics correctly treat as same-origin; their fixtures now use distinct hosts so they keep exercising the allowlist path. Co-authored-by: Claude Fable 5 <noreply@anthropic.com> * fix(release): strip bundled libwayland from Linux AppImages (white screen on Arch) (#1297) linuxdeploy bundles the Ubuntu 22.04 runner's libwayland-{client,cursor, egl,server} into the AppImage, and AppRun forces them onto LD_LIBRARY_PATH. On hosts with newer Mesa (Arch, Fedora), EGL init dlopens libwayland-client, hits the stale bundled copy, and fails with "Could not create default EGL display: EGL_BAD_PARAMETER. Aborting..." - WebKit's web process dies and the window stays white. Reproduced in an Arch container with the published alpha.5 aarch64 AppImage (identical stderr to the field report); the same image renders normally on Ubuntu 24.04, and removing the four bundled libwayland libs makes it render on both. WEBKIT_DISABLE_COMPOSITING_MODE=1 does NOT help (tested). Add scripts/strip-appimage-bundled-libs.sh and run it in both Linux release jobs after the Tauri build: strip the libs, repack with appimagetool, regenerate the updater tar.gz, and re-sign both artifacts with the Tauri updater key. Every supported distro ships libwayland at or above the 1.20 the client links against, so the host copy is always the right one. Co-authored-by: Claude Fable 5 <noreply@anthropic.com> * fix(client): send the session bearer token when fetching attachments (#1298) Uploaded images rendered only as loading placeholders: the server's /api/v1/files/{id} endpoint requires a Bearer token (it enforces per-channel ACLs), but the client's attachment image fetch and file download never attached one, so every request came back 401 and the placeholder was never replaced. Server-hosted attachment fetches now go through fetchServerFile, which routes through the cert-pinned TOFU proxy with the session token from the auth store. The token is only ever sent to the configured server host — external image URLs keep a plain, credential-free fetch. Claude-Session: https://claude.ai/code/session_018tt1rh32f75EAtad6qLraa Co-authored-by: Claude <noreply@anthropic.com> * fix(client): enable microphone/camera detection on Linux (WebKitGTK) (#1299) On Linux no audio or video devices were ever detected: WebKitGTK ships with enable-media-stream and enable-webrtc off, and wry installs no permission-request handler on its webkitgtk backend (unlike macOS, where it auto-grants media capture), so WebKit's default denies every getUserMedia/enumerateDevices request. Add a Linux-only setup hook that turns both settings on for the main window's webview and grants WebKitUserMediaPermissionRequest and WebKitDeviceInfoPermissionRequest. All other permission request types still fall through to WebKit's default deny. The webkit2gtk crate becomes a direct dependency, pinned to the exact version wry already links (=2.0.2, v2_38 for enable-webrtc), so the binary's native library footprint is unchanged — the AppImage bundle set stays identical and the libwayland strip step from #1297 is unaffected. Claude-Session: https://claude.ai/code/session_018tt1rh32f75EAtad6qLraa Co-authored-by: Claude <noreply@anthropic.com> * feat(client): kick to login and reset call state on server shutdown (#1300) When the server shut down, connected clients stayed on the main page in an endless "Reconnecting..." loop, and a live call's webcam/screenshare toggles kept whatever state they had. The server already broadcasts server_restart with reason "shutdown" from hub.GracefulStop before closing connections — the client just ignored the reason. The dispatcher now treats reason "shutdown" as terminal: it signs the user out (clearAuth), which navigates back to the login screen, leaves the voice session — stopping any live camera/screenshare tracks — and resets all call settings (camera, screenshare, mute, deafen, channel) to their normal state. Other restart reasons (update, setup, backup_restore) keep the existing countdown-banner + auto-reconnect behavior. clearAuth gains a LogoutReason so the logout wiring can tell a server-initiated kick from a user logout or invalid-token path: on "server_shutdown" the saved credential is kept (the token is still valid), so profiles with auto-login reconnect on their own once the server comes back, instead of losing their stored login on every server restart. The main page also skips the restart countdown banner for shutdown notices since the page unmounts immediately. Claude-Session: https://claude.ai/code/session_018tt1rh32f75EAtad6qLraa Co-authored-by: Claude <noreply@anthropic.com> * fix(client): credential fallback store on every OS, not just Windows (#1301) Credential saves still failed outright on machines where the OS keychain does not round-trip — most commonly a Linux desktop with no Secret Service provider (no gnome-keyring / KWallet, e.g. a bare window manager) and a locked macOS Keychain. The verified-write fallback introduced for the 2026-07 keyring regression existed on Windows only; on macOS and Linux secret_store::set returned an error and nothing was persisted, so logins and the voice-E2EE identity key vanished on every restart. The fallback now engages on every desktop platform, under the same rule as before: only after a keychain write has provably failed to round-trip, with the OS credential store taking over again the moment it recovers. Windows keeps DPAPI. macOS/Linux entries are sealed with ChaCha20-Poly1305 (via ring, already in the tree) under a per-install random key file written owner-only (0600) to the app data dir; the account name is bound in as AEAD associated data, mirroring the DPAPI entropy, so a blob cannot be moved between entries. Secrets at rest are never plaintext, and a copied fallback store is useless without the key file beside it. The shared set/get fallback path is now platform-neutral with only the sealing primitive per-OS, Backend gains an EncryptedFile variant, and fallback_crypto ships round-trip, AAD-mismatch, tamper, nonce uniqueness, and key-file permission tests that run in CI. Claude-Session: https://claude.ai/code/session_018tt1rh32f75EAtad6qLraa Co-authored-by: Claude <noreply@anthropic.com> * fix(voice): keep stream audio playing when the user mutes/deafens (#1302) Muting yourself in a call (which the deafen control also engages — deafen forces mute) silenced the audio of any screen-share stream being watched: the deafen path unsubscribed every remote audio publication, including ScreenShareAudio tracks, and the subscribe-time guard blocked new stream-audio tracks the same way. Muting/deafening yourself gates voices, not the content someone is streaming. Both paths now exempt ScreenShareAudio: the stream's audio keeps playing while the user is muted or deafened, and remains controllable through its own per-tile mute button and volume slider. Microphone (voice) audio is still fully unsubscribed on deafen exactly as before. The mic-mute path itself never touched incoming stream audio (verified against livekit-client: setMicrophoneEnabled, RemoteParticipant.setVolume and the audio pipeline are all scoped to the Microphone source) — the coupling was only ever the deafen subscription sweep. Claude-Session: https://claude.ai/code/session_018tt1rh32f75EAtad6qLraa Co-authored-by: Claude <noreply@anthropic.com> * feat: Discord-parity quick wins (blocks UI, topics, role colors, profile popup, temp bans, archived filtering) (#1303) * feat: Discord-parity quick wins — blocks UI, topics, role colors, profile popup, temp bans, archived filtering Adds docs/plans/discord-parity.md (full gap analysis vs Discord free/Nitro, phased plan) and lands phase 1 — the six features where one side already existed and the other was never finished: - Block/unblock from the client: PUT/DELETE /blocks/{userId} were server-only; the member context menu now offers Block (with confirm) / Unblock to every user, admin actions stay role-gated. New setUserBlockedByMe store helper. - Channel topics end-to-end: topic now ships in the WS ready payload (protocol.md updated), renders live in the chat header, and is editable in the client's Edit Channel modal (PATCH already supported it). - Role colors from server data: member list groups and message username colors now use roles.color from ready (with theme-var fallbacks) instead of a hardcoded 4-name switch; custom roles render their own groups, and members with an unknown role render in a gray group instead of vanishing. - Profile popup mounted: left-clicking a member opens the existing UserProfilePopup (previously dead code); its Message button starts a DM. Action buttons without handlers are no longer rendered. - Temp bans: PATCH /admin/api/users/{id} accepts ban_duration_hours (1..8760) feeding the existing BanUser expiry plumbing; ban menu gains a duration selector (Forever/1h/1d/7d/30d). - Archived channels actually hide: VisibleChannelIDs now skips archived refs, so REST list, ready payload, and replay filtering all exclude them; archiving live-syncs connected clients via RefreshChannelVisibility. The admin panel still lists archived channels for unarchiving. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BGozcKwpbG5GHU8e4cJYmR * fix(lint): rewrite visibility if-else chain as switch (gocritic ifElseChain) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BGozcKwpbG5GHU8e4cJYmR --------- Co-authored-by: Claude <noreply@anthropic.com> * feat: Discord-parity phases 2–6 (moderation, mentions, markdown, roles, social) (#1304) * feat: parity phase 2 — moderation depth (live permission bits, voice moderation, purge) - Admin perimeter now admits any role holding a moderation-capable bit (AdminPerimeter mask); each route group re-checks its own bit: channels/overrides -> MANAGE_CHANNELS, audit log -> VIEW_AUDIT_LOG, settings -> MANAGE_SERVER, force-logout -> KICK_MEMBERS. Ban and role assignment authorize inside ModerationService (BAN_MEMBERS / MANAGE_ROLES). New GET /admin/api/me lets the panel hide tabs and row actions the caller cannot use; the desktop member-list menu gates on permission bits from the ready role list instead of role names. - Hierarchy beyond ban: ChangeUserRole requires the actor to strictly outrank the target and refuses to assign a role at or above the actor's own position (closes "any admin can promote anyone to Owner"); ForceLogout enforces the same rule. - Voice moderation on MUTE_MEMBERS: voice_mod_mute/deafen/move/kick WS commands (bit + strict outrank, 5/s rate limit, audit-logged). voice_states gains server_muted/server_deafened, carried on voice_state; server mute is enforced at the SFU via LiveKit MutePublishedTrack and the target's own unmute attempts are refused with SERVER_MUTED/SERVER_DEAFENED. Move/kick run the hub voice-leave routine then send voice_moved (client rejoins through the normal join path) or voice_disconnected. Client voice-row menu grows a moderation section gated on the bit. - Bulk delete: POST /api/v1/channels/{id}/messages/purge {limit 1-100, before?} gated on READ|MANAGE_MESSAGES, soft-deletes preserving tombstones, one message_purge audit row, fans out a single chat_bulk_deleted broadcast. Channel context menu gains "Purge Messages…" for holders of MANAGE_MESSAGES. - Honest kick semantics: the session-revoking "Kick" action is renamed Force Logout in the client and admin panel (endpoint unchanged). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BGozcKwpbG5GHU8e4cJYmR * fix(lint): use slices.Contains in voice moderation tests (modernize) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BGozcKwpbG5GHU8e4cJYmR * fix(test): widen the occupied pre_restore window in the abort-restore test The test blocked the safety backup by occupying pre_restore_<ts>.db names for the next 4 seconds; on slow Windows CI runners the request outlived the window and the restore succeeded, failing the 500 assertion. Occupy two minutes of candidates instead. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BGozcKwpbG5GHU8e4cJYmR * feat: parity phase 3 — real mentions (server resolution, badges, notifications, autocomplete) - Mentions resolve server-side at send time: whole-word @username parsing (address-shaped text rejected), case-insensitive against unique usernames, 20-mention cap; stored in message_mentions in the same writer transaction as the message. chat_message/chat_edited and REST history/pinned/search carry mentions + mentions_everyone. - New MENTION_EVERYONE permission (bit 21, seeded to Owner/Admin/Moderator) gates @everyone/@here; never honored in DMs. @here skips offline users. Fan-out respects per-channel read permissions and skips users who blocked the author. - read_states.mention_count is live: incremented on insert (never on edit), zeroed by channel_focus, shipped per channel in ready. - Client: mentions highlight only when they resolve; mentioning the current user accents the whole row; #channel-name renders a navigating chip; channels show a red mention badge that outranks the unread badge; notifications say "X mentioned you in #channel" and the suppress-@everyone pref now suppresses only honored everyone-mentions; the composer gets an @-autocomplete popup (prefix-ranked, keyboard-driven, @everyone/@here offered only with the permission). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BGozcKwpbG5GHU8e4cJYmR * feat: parity phase 4 — markdown rendering, message navigation, reactions/media/read-state polish - Discord-flavored markdown via a tokenizer (message-list/markdown.ts): bold/italic/underline/strike/spoiler with nesting, escaping and a word-boundary rule keeping snake_case literal; line-start quotes, headings, lists; masked links restricted to absolute http(s) + isSafeUrl (rejects render as literal source); language-tagged code fences with a hand-rolled highlighter (no new dependency); markdown is inert inside code. Renderer stays a strict DOM builder — no innerHTML. Composer gains Ctrl+B/I/U wrapping. - Message navigation: GET /channels/{id}/messages/around/{messageId} (half-before/half-after window, has-more flags via over-fetch); detached-window support in the messages store with a "Jump to Present" pill; search/pin jumps fetch the window when the target isn't loaded; reply previews are clickable; "Copy Message Link" + owncord://message/{channel}/{message} deep-link route; pasted message links render as jump chips. - Who-reacted: GET .../reactions/{emoji}/users (100 cap) + hover tooltip with per-message+emoji cache invalidated on reaction_update. - Inline media: video/audio attachments render native players from MIME allowlists (unknown containers keep the download chip); SVG stays out. - Read-state polish: NEW-messages divider, explicit Mark as Read / Mark All as Read, DM unread count badges (real counts shipped in ready instead of a dot; DM mention counts survive reconnect). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BGozcKwpbG5GHU8e4cJYmR * feat: parity phase 5 — role CRUD, per-user overrides, override matrix, client channel management - Roles are real entities: /admin/api/roles CRUD + reorder behind MANAGE_ROLES, with all rules in a new RoleService measured against the actor's position (only strictly-below roles may be touched; never grant a bit your own role lacks; seeded Owner immutable; default role undeletable — deletion reassigns members, drops its overrides, and invalidates exactly the moved members' cached perms in one writer transaction). Case-insensitive unique names (migration 023), normalized colors, roles_update broadcast keeps clients current, and both admin surfaces stopped hardcoding the four seeded roles. A new ASCII guard test protects sqlc-generated SQL from a byte/rune offset bug that silently splices queries when comments contain non-ASCII. - Per-user channel overrides (migration 024): resolution is now base -> role override -> user override with one implementation (EffectiveChannelPerms); both layers load in two batch queries behind every visibility/permission site, per-role visibility memoization removed (two members of one role can now differ), and the @everyone fan-out honors user-layer allow and deny. Admin REST + full tri-state override matrix UI (role or user per channel) replace the single "Can access" checkbox; the visibility-agreement test grew a same-role different-overrides case. - Categories stopped being magic strings: any channel type under any free-text category (server + client validation removed), category editable everywhere with datalist suggestions, voice channels group under their real category. - Desktop channel management: Edit Channel gains slowmode presets, NSFW toggle, and voice user/video limits (bounds-checked server-side, broadcast on channel_create/update via one shared constructor); NSFW channels show a per-session age-gate overlay; VIEW_AUDIT_LOG holders get an Audit Log entry point opening the admin panel at #audit. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BGozcKwpbG5GHU8e4cJYmR * feat: parity phase 6 — custom emoji, profiles & presence, group DMs, DM calls, channel mutes - Custom emoji end-to-end: the dormant emoji table gains a mime column and real routes (list/upload/delete + authenticated image serving, MANAGE_SERVER-gated, 512KiB / 128px caps validated against sniffed bytes, SVG refused, 200-emoji cap, audited, emoji_update broadcast). :shortcode: renders inline (jumbo when emoji-only, never in code), the picker gains a Server category, the composer a :-autocomplete, reactions accept and render custom emoji, and the admin panel gets an Emoji section. - Profiles: avatar upload (sniffed, capped, served authenticated) with one shared client avatar helper replacing letter-initials everywhere; display_name (heading with @username handle preserved for mentions), about, and custom_status columns with sanitized bounds; user_update broadcast keeps clients current. - Presence: invisible is a real stored status collapsed to offline for every other viewer at every serialization site (owner sees truth); connect no longer force-stamps online (idle/dnd/invisible survive reconnect — the flash-online bug is gone); auto-idle after 10 minutes of inactivity that never overrides a manual status. The @here fan-out now collapses status first so invisible users are not pinged. - Group DMs: channels.is_group discriminator; create (2-8 others, bidirectional block checks), rename (participants only), leave (channel deleted with the last participant); per-viewer dm_channel_open payloads; stacked-avatar rows, multi-select member picker, participant headers; 1:1-only composer block gating. - DM calls: call_ring/call_decline signaling over existing DM voice (no new call state), Call button in DM headers, incoming-call banner with accept/decline/30s timeout and chime. - Per-channel mutes (client prefs): muted channels/DMs stay silent for non-mention noise (badge dims, mentions still notify), managed from context menus and the Notifications tab. The dead Friends nav item is removed as the plan prescribed. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BGozcKwpbG5GHU8e4cJYmR --------- Co-authored-by: Claude <noreply@anthropic.com> * Pre-release review fixes + v1.2.0-alpha.1 prep (#1305) * fix(review): pre-release security & performance fixes for the parity work Security: - Channel-override endpoints (role + per-user) now enforce grantability: a MANAGE_CHANNELS holder can no longer grant itself or a user a permission bit its own role lacks, and the role-layer endpoint refuses targeting a role at or above the actor's position (Administrator bypasses). Closes a privilege-escalation path opened when the override routes were downgraded from ADMINISTRATOR-only. - DM voice events no longer leak: channelReadAudience resolves a DM channel's audience from its participants (intersected with connected clients) instead of the role scan, which passed every user with base READ_MESSAGES since DMs carry no overrides. A private DM call's voice_state/voice_leave now reaches only its participants. - Invisible users no longer flash online on connect: member_join carries a viewer-safe status (db.BroadcastStatus) and the client defaults a missing status to offline instead of hardcoding online. - Voice moderation can no longer reach a private DM call: voiceModTarget refuses a DM-channel target unless the actor is a participant, with the same shape as "not in voice" so nothing about the call leaks. Correctness: - Un-deafening a member now also clears the deafen-implied server mute, so the target regains the ability to unmute themselves instead of staying silenced at the SFU until a separate unmute. Performance: - IncrementMentionCounts batches its upserts into chunked multi-row statements instead of one exec per recipient, so an @everyone mention holds the SQLite writer for one exec per 500 readers instead of N. - applyMentionCounts resolves mentions against a set built once from the readers instead of a nested O(mentions x readers) scan. - The markdown parser's bracket/paren matching is computed once per line instead of rescanned at every opener, removing the O(n^2) worst case on pathological input. - Video/audio attachment blob URLs are now LRU-capped and revoked, and the attachment caches are cleared on logout, fixing an unbounded per-session Blob leak. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BGozcKwpbG5GHU8e4cJYmR * chore(release): prep v1.2.0-alpha.1 Bump the client manifests (package.json, package-lock.json, tauri.conf.json, Cargo.toml, Cargo.lock) from 1.1.0-alpha.5 to 1.2.0-alpha.1 so the release workflow's verify-versions guard passes for tag v1.2.0-alpha.1. The server version is injected via ldflags at build time and needs no bump. Add a curated CHANGELOG section for v1.2.0-alpha.1 documenting the Discord-parity feature drop (mentions, markdown, custom emoji, message navigation, role management, per-user overrides, voice moderation, profiles, group DMs, DM calls, channel mutes) and the pre-release security/performance review, plus an operator note covering the nine new migrations and the new WebSocket message types. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BGozcKwpbG5GHU8e4cJYmR * perf(mentions): apply mention counts off the send path SendMessage resolved every reader and wrote the mention/@everyone badge counts synchronously after the commit but before returning, so a mention in a large channel delayed delivering the message to everyone else by the full reader-resolution chain plus the batched increment. Move that bookkeeping onto a background goroutine via an injectable dispatcher field (bg, defaulting to `go fn()`). The write already ran on a cancellation-detached context and swallowed its errors, so detaching it from the request is safe; the count is advisory, so the tiny window where a reader's channel_focus clears it just before the increment lands is harmless (matching Discord's eventual consistency). Tests read the counts synchronously right after a send, so the shared mention fixture and the ws mentions test opt into an inline runner (RunBackgroundInlineForTest / the hub's RunMentionCountsInlineForTest seam); a new test exercises the real async path by polling. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BGozcKwpbG5GHU8e4cJYmR * refactor(client): extract shared inline-autocomplete factory MentionAutocomplete and EmojiAutocomplete duplicated ~90 lines of identical listbox scaffolding (AbortController cleanup, suggestions/ activeIndex state, the root listbox + .ma-list, mousedown-to-choose rows, and a byte-identical arrow/Enter/Tab/Escape keydown switch), so a fix to one silently diverged from the other. Factor that into createInlineAutocomplete<T>, parameterized by the four things that actually differ: the filter, the selected value, the per-row children, and the row/root test ids + class (emoji keeps the shared mention-autocomplete base class plus its own, and only mentions prime the list on create). Both components become thin adapters that keep their existing exports — createMention/EmojiAutocomplete, the pure filter functions, and the MIN/MAX constants — unchanged, so MessageInput and every test are untouched and still pass. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BGozcKwpbG5GHU8e4cJYmR * fix(lint): drop now-unused appendChildren import in MentionAutocomplete The row rendering moved into the shared inline-autocomplete factory, so the import is no longer referenced; oxlint fails the Client Static Checks job on the unused identifier. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BGozcKwpbG5GHU8e4cJYmR --------- Co-authored-by: Claude <noreply@anthropic.com> * fix(review): full-project review — hierarchy, role positions, search, clarity (#1306) From a full-codebase review (Opus security + Sonnet server/client + Haiku consistency): - Per-user channel overrides now enforce the same role-hierarchy guard the role-layer endpoint already has: a non-admin MANAGE_CHANNELS holder can no longer write or clear a per-user override against a member ranked at or above their own. Without it, because the per-user layer is last in the resolution order, a Moderator could deny a higher-ranked member the channel access their role grants. Applied to both PUT and DELETE. - CreateRole no longer places two default-positioned roles at the same position: it steps to the highest free slot below the actor and rejects an explicit position that is already taken. Colliding positions read as equal rank in every hierarchy check, so two such roles could never manage each other's members. The rank guard still takes precedence over the collision message for an at/above-rank position. - Search overlay no longer silently drops a query that arrives inside the 500ms rate-limit window (which sits above the 300ms debounce): it reschedules the search for when the window opens instead of leaving the previous query's results on screen. - Corrected a misleading TODO on chat_send attachments: they are upload UUIDs resolved by ownership at link time, not URLs, so a javascript:/data: string is never stored or rendered — a scheme check would wrongly reject valid ids. The comment now states this and the loop variable/error name say "id". Claude-Session: https://claude.ai/code/session_01BGozcKwpbG5GHU8e4cJYmR Co-authored-by: Claude <noreply@anthropic.com> * Test hardening: fuzzing, contract/upgrade, load, e2e (#1307) * fix(image): reject zero-dimension images in header decode FuzzImageDimensions found two inputs the emoji/image size guard accepted as valid with a nil error despite having no real dimensions: - a GIF whose logical screen descriptor decodes to height=0 via Go's own image.DecodeConfig, and - a VP8 keyframe whose size field is all zeros (VP8, unlike VP8L/VP8X, stores the size directly, so 0x0 is a validly-shaped header). Both callers compare the returned size straight against their pixel cap, so a degenerate 0-dimension header slipped through as a "small" image. Reject non-positive dimensions centrally in imageDimensions and reject zero VP8 dimensions in webpDimensions, so the invariant holds even for a caller that forgets its own bounds check. The two crashers are checked in as the fuzz regression corpus. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BGozcKwpbG5GHU8e4cJYmR * test(fuzz): add Go fuzzers and TS property tests for parsers/validators Adds coverage on the parsers and validators most exposed to hostile input, each with a tricky seed corpus and invariant assertions: Server (Go native fuzzing): - FuzzParseMentionTokens: never panics; resolved count within cap. - FuzzSanitizeFTSQuery: output never errors against real SQLite FTS5. - FuzzValidateShortcode: accepted shortcodes match the documented charset/length. - FuzzEffectivePerms / FuzzEffectiveChannelPerms: ADMINISTRATOR implies all bits, user-deny beats role-allow, result is a subset of AllPerms. Client (fast-check property tests): - markdown tokenizer never throws and emits no script/on*/javascript: sinks, bounded time on pathological input. - mention/emoji content parsing never throws. - filterMentionSuggestions/filterEmojiSuggestions never throw and respect the caps and the MIN_EMOJI_QUERY/permission gates. The image-header fuzzer that found the zero-dimension bug landed with its fix in the preceding commit. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BGozcKwpbG5GHU8e4cJYmR * test(migration): add full-chain and upgrade round-trip tests Applies every embedded migration to a fresh DB and asserts the resulting schema is coherent, then applies the full chain on top of a pre-parity (migration 019) snapshot and asserts it upgrades without error and preserves seeded rows. Protects existing operators on the v1.2.0 upgrade (9 new migrations, 020 through 028). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BGozcKwpbG5GHU8e4cJYmR * test(protocol): assert protocol schema matches generated Go constants Asserts every wire constant in docs/protocol-schema.json has a matching generated Go constant and vice-versa, with a small explicit exception list for intentionally-undocumented internal constants. Catches the chat_command-style drift the review flagged before it reaches the wire. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BGozcKwpbG5GHU8e4cJYmR * test(load): add hub load/soak harness with goleak verification Adds a long test (skipped under -short, run under -race in CI) that concurrently registers and unregisters 200 WS clients across churn rounds while six broadcaster goroutines fan out to the hub, then asserts via go.uber.org/goleak that no goroutines leak and no deadlock or panic occurs. Exercises the client registry, broadcast audience resolution, and the background mention goroutine under contention -- the class of bug the race detector only reveals at scale. Adds a BroadcastVoiceEventForTest seam to export_test.go for the broadcaster loop. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BGozcKwpbG5GHU8e4cJYmR * test(e2e): add blocking parity-feature Playwright specs Adds end-to-end coverage for the v1.2.0 parity features that had none, all tagged "@parity" and driven through the existing mocked-Tauri harness (tests/e2e/helpers.ts) — 15 tests across three files: - gating-badges.parity.spec.ts: NSFW age-gate mount/continue, mention red badge (ready-payload render + live incoming-mention bump), per-channel mute toggle + localStorage persistence. - social.parity.spec.ts: group-DM create via the member picker (asserts the POST /dms/group request), group render + leave (DELETE), and Change Role via the member context menu (asserts the PATCH /admin/api/users/{id}). - emoji-voicemod.parity.spec.ts: custom-emoji ":shortcode" autocomplete + message-list <img> render, and the voice-moderation menu — both the admin-can path (asserts voice_mod_mute / voice_mod_kick ws_send) and the gated path (menu absent without MUTE_MEMBERS). The specs assert the exact outgoing HTTP/WS request where the flow is request-driven, not just DOM side effects. No product bugs were found. Adds a dedicated CI job "Client E2E (parity subset, blocking)" that runs only the @parity specs (playwright --grep "@parity") WITHOUT continue-on-error, so a regression in these features fails CI. The pre-existing full e2e job stays non-blocking, per the maintainer note that it needs a few green pushes before graduating. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BGozcKwpbG5GHU8e4cJYmR --------- Co-authored-by: Claude <noreply@anthropic.com> * More hardening: fuzz the input surface + fix mis-written tests (#1308) * fix(upload): keep sanitizeUploadFilename output a safe, valid basename FuzzSanitizeUploadFilename found two inputs the upload-filename sanitizer returned unchanged in violation of its own contract: - "/" survived verbatim: filepath.Base("/") returns "/" (root is its own basename), and the final reserved-name check only special-cased "", ".", and "..", so a path separator reached the served download name and the client's save-dialog prefill. - a name longer than the 255-byte cap was truncated with a byte slice (name[:max]), which can land mid-rune and yield invalid UTF-8 — which then misbehaves in JSON encoding, on disk, and in download-name handling. Now any residual '/' is dropped in the character filter, and truncation trims back to the last full rune so the result is always valid UTF-8. The two crashers are checked in as the fuzz regression corpus. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BGozcKwpbG5GHU8e4cJYmR * test(fuzz): fuzz the file/path and content/identity input surface Adds Go native fuzzers on the untrusted-input parsers/validators the first fuzzing pass didn't reach, each with a tricky seed corpus and both a never-panics and a semantic/security invariant: - storage.sanitizeFilename + resolvedPath composition (a name that passes sanitize must resolve inside the storage dir — no traversal), and storage.ValidateFileType (error iff a blocked magic prefix matches, for any header length). - plugin.validateRelativePath (accepted paths are non-absolute, separator- and traversal-free). - service.sanitizeContent: output carries no surviving <script/js:/on* sink, is length-bounded, and is idempotent (the bluemonday StrictPolicy contract). Two documented regression seeds pin the "inert plain text that merely contains the word javascript:/onclick=" non-bug. - auth.ValidateUsername / ValidatePasswordStrength — accept implies the documented charset/length. - api.validateAvatarURL (never accepts a non-https / javascript: / data: URL) and api.validateDisplayName. - ws.parseParticipantIdentity / parseRoomChannelID — never panic on adversarial LiveKit webhook strings. Each target survived active fuzzing (hundreds of thousands to millions of execs) with no crash; the one real bug found (sanitizeUploadFilename) landed with its fix in the preceding commit. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BGozcKwpbG5GHU8e4cJYmR * test: make mis-written tests actually assert their claimed behavior A test-quality audit found tests that ran an action but asserted nothing (or asserted a tautology), so they would pass even if the code under test were deleted. Each is now wired to the real observable effect it names — no product code changed, no assertion weakened: Client (vitest): - notifications.test.ts: 19 notifyIncomingMessage tests had zero expect() calls; each now asserts the sendNotification / requestUserAttention / oscillator mock per its name (suppress vs fire, truncation, fallback title), with mockClear() so a stale call can't make it trivially green. Three catch-path tests now assert the debug log fired. One test whose title contradicted its body (and the code's guard) was renamed to match verified behavior. - livekit-session.test.ts: token-refresh test asserts the stored token and the rearmed refresh timer; the two "no active room" device-switch tests assert Room.switchActiveDevice is not called. - connection-stats.test.ts: the "start is idempotent" test now advances timers and asserts the poll callback fires once per tick (no double interval). - voice-audio-tab.test.ts: the cleanup test now actually starts a camera preview (it previously couldn't reach the camera-stop path) and asserts both mic and camera tracks are stopped. - dispatcher.test.ts: replaced an expect(true).toBe(true) with assertions on the voice-store speaking state the handler writes, incl. a control. - sidebar-area.test.ts: performs the back-navigation the test described and asserts the pre-DM text channel (not the DM) is restored. - profiles.test.ts: asserts no profile is created/mutated for a missing id. - log-persistence.test.ts: activeFlush tests assert flush sequencing, and the cleanup error test asserts the logged error. Server (Go): - db/coverage_boost_test.go: TestCreateAttachment_WithDimensions now links the attachment to a message and verifies the persisted width/height via GetAttachmentsByMessageIDs, instead of only checking a row exists. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BGozcKwpbG5GHU8e4cJYmR * style(fuzz): satisfy golangci-lint on the new fuzz seed corpora - Escape the raw bidi/zero-width Unicode format characters embedded in the seed strings as \u escape sequences (staticcheck ST1018) — same runes, now greppable and lint-clean. - Range over strings.SplitSeq instead of strings.Split in the relative-path fuzzer's traversal check (modernize). No change to what any seed exercises. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BGozcKwpbG5GHU8e4cJYmR --------- Co-authored-by: Claude <noreply@anthropic.com> * docs(changelog): note pre-release test hardening and the two bugs it found #1307 and #1308 landed fuzzing, migration/protocol/load tests, a blocking @parity e2e job, and a test-quality audit. Two of those were real product fixes (zero-dimension image headers, sanitizeUploadFilename) that belong in the release notes, not just the test log. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * docs(changelog): restore the alpha.5 behavioural notes dropped in the rewrite The v1.2.0-alpha.1 section replaced the v1.1.0-alpha.5 one wholesale, taking the LiveKit-proxy origin-gate and log-stream API-token bullets with it. Both fixes are in this release's code (#1293, #1294, #1295) — only their operator notes went missing, and an operator upgrading from alpha.3 would never have seen them. Restored verbatim from main. This is the sole content main had that dev lacked. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(client): gate CREDENTIAL_FALLBACK_KEY_FILE to non-Windows `cargo clippy -- -D warnings` failed the Windows Tauri build with "constant CREDENTIAL_FALLBACK_KEY_FILE is never used". Its only consumer, `fallback_crypto`, is `#[cfg(not(windows))]` (lib.rs:6) because Windows seals fallback entries with DPAPI instead — so on Windows the constant is genuinely dead and -D warnings promotes that to an error. Gated the constant to match its consumer rather than silencing it with #[allow(dead_code)], so it still trips if it ever goes dead on the platforms that do use it. Latent on dev, not introduced here: Tauri Full Build is gated on base_ref == 'main', and the fast suite only compiles Rust on ubuntu (rust-tests runs on ubuntu-22.04), where fallback_crypto *is* compiled. Nothing built the Rust lib for Windows until this dev -> main PR. Verified locally on Windows: `cargo clippy -- -D warnings` and `cargo clippy --all-targets -- -D warnings` both exit 0. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(voice): stop writing a credential byte to the log on bad LiveKit config CodeQL go/clear-text-logging (high, alert #13): the YAML-safety check in generateConfig rejected a bad credential with fmt.Errorf("LiveKit credential contains unsafe YAML character %q", ch) where ch is a byte taken from LiveKitAPIKey or LiveKitAPISecret. Start() wraps that error and api/router.go logs it, so a byte of the API key or secret reached the server log in clear text. The check now uses strings.ContainsAny and names the offending config field instead of echoing the byte — strictly more useful to an operator, who previously got a character with no indication of which credential it came from. Same rejection set, so behaviour is otherwise unchanged. Adds a regression test asserting the error names the field and contains no part of either credential. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(plugin): resolve UI asset paths at construction, not per request CodeQL go/path-injection (high, alerts #11 and #12): AssetHandler built the on-disk path from req.URL.Path on every request, then validated it with filepath.Rel. The validation was sound — traversal was already blocked by the manifest allowlist, the Rel check, and the serve-time Lstat — but a path was still being constructed from user input, which is the pattern the rule flags and the one that goes wrong when someone later edits the ordering. Each declared asset is now resolved and traversal-checked once, when the handler is built, into an asset-name -> absolute-path map. At serve time the request path is only ever a map key, so no filesystem path is derived from user input at all. An asset that fails validation is absent from the map and 404s, as an undeclared file already did. Also moves filepath.Abs/Join/Rel off the per-request path. The serve-time Lstat symlink and IsRegular checks stay exactly as they were — they close the post-install TOCTOU window and are still needed per request. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * test(plugin): constrain default-build registry tests to !wazero registry_test.go opens "Registry lifecycle tests for the default (non-wazero) build" and asserts activation fails with ErrRuntimeUnavailable, but carried no build constraint. Under -tags wazero a real runtime is linked in, so TestRegistry_Activate_ WithoutRuntime and TestRegistry_EnablePlugin_RollsBackWhenActivationFails both failed. Nothing caught it: CI builds all three tag variants but only runs tests untagged, so these have been red under -tags wazero without surfacing. Adds the //go:build !wazero the file always implied, matching the sandbox_default.go / sandbox_wazero.go split already used here. Its helpers are used by no other file, so nothing else loses coverage; the wazero build keeps its own activation tests in sandbox_wazero_test.go. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
9a0ae0dd2a |
feat(release): fold distribution back into the source repo
The separate J3vb/OwnCord-releases repo existed only because this repo was private: it carried the AGPL source snapshot and provided a publicly-readable update feed. Once this repo is public both roles collapse into its own Releases page, so the mirror is pure redundancy. - Server/config/config.go: github.repo default OwnCord-releases -> OwnCord. This one default drives both the server self-update and the client auto-update chain (tauri.conf.json updater.endpoints is empty, so the client resolves through the server). No test pinned the old value. - release.yml: drop the mirror step and its RELEASES_REPO_TOKEN guard, whose AGPL/private-repo premise no longer holds. The existing Create GitHub Release step is now the sole publish target. All 31 SHA pins verified intact. - Repoint the README badge/download link, both SECURITY.md links, the server-configuration table and sample, the system-overview diagram node and the CHANGELOG note. SECURITY.md's advisory link is the load-bearing one: left alone it would 404 once the mirror repo is deleted. - README: Go 1.25+ -> 1.26+ (badge and prerequisite) to match the toolchain actually required. Deleting the mirror repo loses nothing: both repos' v1.1.0-alpha.2 carry byte-identical asset sets, signatures and update manifest included. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
e392939c52 |
chore(deps): batch-apply all open dependabot bumps (2026-07-23)
Applies all 22 open dependabot PRs in one pass (CI on those PRs never ran — Actions minutes exhausted). Verified locally via the ci-check mirror: builds (all tag variants), vet, golangci-lint, sqlc/protocol verify, vitest 3304/3304, npm audit clean, cargo check. Server (Go): wazero 1.12.0, x/mod 0.38.0, chi 5.3.1, otel 1.44.0, otel/trace 1.44.0, otel prometheus exporter 0.66.0, modernc sqlite 1.54.0, livekit/protocol 1.50.2, koanf/v2 2.3.5, x/sync 0.22.0. Also x/text 0.39.0 (fixes GO-2026-5970, flagged by govulncheck). livekit/protocol requires Go 1.26 → go.mod, CI pins, and docs bumped. Client (npm, lockfile-only): playwright/test 1.61.1, oxlint 1.75.0, eslint 9.39.5, knip 6.29.0, plugin-http 2.5.9, plugin-fs 2.5.1, plugin-opener 2.5.4, tauri-apps/api 2.11.1 + npm audit fix (brace-expansion, fast-uri transitive highs). Client (cargo, lockfile-only): futures-util 0.3.33, env_logger 0.11.11, serde 1.0.229, tauri-typegen 0.5.2. Closes #1204 #1205 #1206 #1207 #1209 #1210 #1211 #1212 #1213 #1214 Closes #1215 #1216 #1219 #1220 #1221 #1222 #1223 #1225 #1226 #1227 #1228 #1224 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
83d924c10a |
docs(audit): record D13 closure of A-2026-07-16
Design note (permission-middleware-consolidation.md, status implemented), closure-table + §3 rows for A-2026-07-16, the A-2026-07-07 amendment recording the missed fifth site, the D13 decision row, and the settled two-scope authorization contract in architecture/server.md. Backlog row 12 stays untouched: the auth-route sweep is deferred to a future D14. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
52659e4a39 |
Merge pull request #1200 from J3vb/feat/narrow-tauri-capabilities
feat(client): narrow Tauri HTTP capability scope |
||
|
|
3cf3e7beaf |
Merge pull request #1198 from J3vb/feat/gif-server-proxy
feat(gif): move Klipy integration behind a server proxy so no API key ships in the client bundle |
||
|
|
59ec405595 |
docs: record final Tauri capability posture and residual risk
Update the security doc's capability section with the deny list, the fact that `http:allow-fetch` is the only URL-scoped HTTP identifier, and why the https wildcard cannot be removed without moving the link-preview fetch into Rust. Note under Known Limitations that narrowing the plugin scope alone does not bound exfiltration while CSP `connect-src` allows `https:` to any host. Add a capability row to the client architecture doc and mark the design note implemented. |
||
|
|
825a1d0eb2 |
docs: document the GIF proxy endpoints, gif.api_key, and degradation contract
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
2ec53863c2 |
docs: reconcile audit backlog table with closure status
Rows 1, 2, 4, 6, 7, 8, 9 were all closed in the closure table but never struck in the section 6 backlog, making the remaining work look ~4x larger than it is. Only rows 10 (partial) and 12 are still open. Also drops the stale "V1/V2 dispatch" blurb from the architecture index, which contradicted websocket.md after the V1 registry was deleted in #1196. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
a56d131add | Merge branch 'main' into feat/audit-backlog-3-11 | ||
|
|
868306a06d |
Merge pull request #1194 from J3vb/feat/dm-block-gating-updater-progress
feat(client): DM block composer gating + updater download progress |
||
|
|
723c1e591b |
Merge pull request #1193 from J3vb/feat/voice-e2ee-status
feat(voice): surface voice-session + E2EE status and freeze controls on WS reconnect |
||
|
|
b28fad5d4e |
docs: record Node 22+ jsdom localStorage trap and sweep remaining KNOWN RED claims
The suite was never red: on Node 22+ native Web Storage shadows jsdom's localStorage, failing ~478 unrelated tests locally. Documents the workaround where a future session will hit it, and corrects the stale KNOWN RED assertions in docs/architecture/client.md and the ci-check skill that contradicted the new green-and-must-stay-green rule. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
3e42c4a4f0 |
docs: close audit items 3 and 11 (channel-visibility + V2 dispatch)
- audit-2026-07-19.md: A-2026-07-07 and A-2026-07-09 → RESOLVED 2026-07-20 in the findings tables; §6 backlog rows 3 and 11 struck through as DONE (D9/D10) - plans/audit-2026-07-19-decisions.md: D9/D10 status → Implemented - plans/channel-visibility-unification.md, v2-dispatch-migration.md: status → implemented; v2 note records the applier-trigger shape the voice handlers actually landed with - architecture/server.md: WS box "V1+V2 dispatch" → "typed command dispatch" - architecture/websocket.md: intro + §D4c redrawn as the single typed path (no V1 fallback) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
ca91d28561 |
feat(updater): surface download progress in the update banner
The Rust download callback was a no-op, so "Downloading update…" looked hung
for large binaries (settings-and-admin.md §5). download_and_install_update now
accumulates received bytes and emits an `update-progress` event
({ received, total }) to the webview. downloadAndInstallUpdate(serverUrl,
onProgress) listens for it and UpdateNotifier renders a percentage when the
total is known, falling back to bytes (MB) until Content-Length arrives.
Rust change is minimal and CI-gated only (not built locally per policy). Adds
TS tests for the formatter and the banner wiring.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
||
|
|
a546af2d4f |
feat(dm): gate DM composer on block state with spec reasons
Wire DM block state into the existing disabled-with-reason composer mode (channels-members-dms.md §3.2). A new blocks.store holds two directions: - blockedByMe (from GET /blocks on every ready) -> "You've blocked this user. Unblock to send messages." - blockedByThem (inferred from a refused DM send: ErrBlocked -> FORBIDDEN, cleared on the next ready) -> neutral "You can't message this user right now.", never revealing the block explicitly. ChannelController reads dmComposerBlockReason(recipientId) and subscribes to blocks.store so an unblock (shrunken GET /blocks) re-enables the composer live; blockedByMe takes precedence when both apply. Adds api.listBlocks(), threads an optional api into wireDispatcher, and covers both directions plus un-gating in blocks-store / channel-controller / dispatcher tests. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
544360642e |
fix(voice): freeze sidebar voice join/leave row on WS reconnect
The WS-reconnect control freeze (spec item 6) only reached the VoiceWidget's in-call controls. The actual join affordance — clicking a voice-channel row in ChannelSidebar — stayed a plain clickable div with no disabled state, so a click while the socket was reconnecting/disconnected was a silent no-op (only the VoiceCallbacks socketLive() backstop stopped the send). Gate renderVoiceChannelItem on ui.store.connectionStatus using the same disabled-with-reason pattern as VoiceWidget: apply a .disabled class, aria-disabled, and a "Reconnecting…" / "Not connected" title while not connected, and make the click a no-op. Subscribe the sidebar to connectionStatus so the row freezes/unfreezes reactively (mirrors the existing collapsedCategories selector). Docs: README.md §3 callout now notes the sidebar join affordance takes the disabled-with-reason state too; voice-and-e2ee.md lists ChannelSidebar.ts as a source of truth for the freeze. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
23bf34bbae |
docs(ux): close the voice/E2EE status gap callouts
Mark the voice-and-e2ee.md §1 (store-backed voiceStatus) and §2 (visible E2EE securing/secured indicator) gaps as implemented, noting the client runs the ECDH key exchange before the media connect. Close the remaining voice column of the README.md §3 connection-status table: voice controls now freeze on WS reconnect. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
80903a4ccb |
fix(client): revoke server session on user-initiated logout
api.logout() (POST /auth/logout) existed but was never called, leaving the bearer token valid server-side after a client-local logout. Add a small logout() helper that fires the revocation best-effort — fire-and-forget with its rejection swallowed — then runs clearAuth() synchronously, so a slow, offline, or rejecting server can never block or delay the local logout. Wire it into the settings Log Out button. Tests pin both paths: logout is called, and local logout still completes when the request rejects or never settles. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
93850689a2 |
fix(client): unify role lookups onto the dispatcher-updated store
SidebarMemberSection read role name->id from a parallel roles.store that nothing ever wrote to — only channels.store.setRoles is updated by the dispatcher on `ready`. Repoint the reader at channels.store and delete the dead roles.store (its setRoles/getRoleIdByName coverage already lives in channels.store.test.ts). Adds a regression test pinning that the member UI resolves role ids from the store the dispatcher writes. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
45e51cca1c |
fix(client): review follow-ups for the connection-status batch
Fixes from an adversarial review of the previous commit:
- MainPage banner: sync the banner with the current store status at mount.
The selector subscription baselines on the current value and only fires
on change, so a MainPage mounted mid-outage (status already
"reconnecting") would never show the banner — the whole retry cycle maps
to the same 3-state value. The status→banner dispatch is extracted to
ServerBanner.applyConnectionStatus and unit-tested.
- History-fetch failure is no longer silent when the channel already has
rows (live broadcasts / optimistic sends): the inline error region only
renders in an empty channel, so loadMessages now also raises a toast in
that case.
- Composer disable reason distinguishes "Reconnecting…" from
"Not connected" per the spec §3 table (it previously showed
"Reconnecting…" while disconnected, contradicting the banner).
- The single-writer wiring is extracted to
dispatcher.wireConnectionStatus(ws) and pinned by a test (it was
previously an untestable main.ts module-scope line — deleting it would
have failed zero tests).
- Docs honesty: messaging.md's transport-drop diagram arm now shows both
codes (channel full → NETWORK, closed/not-open → OFFLINE) instead of
claiming NETWORK for both; README §3's callout now explicitly lists the
voice column ("frozen" during reconnect) as a remaining gap instead of
implying the section is fully closed; the composer table documents both
offline reasons.
- New pinning tests: SidebarArea passes ws to UserBar (the production-bug
fix was previously unasserted), ServerBanner.showDisconnected,
applyConnectionStatus mapping, ChannelController onRetryLoad /
onRetry-resend / onDeleteDraft, composer reason per status, and the
history-failure toast fallback.
Verified: tsc + full client unit suite (3234 tests) + oxlint/eslint +
prettier all green.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
||
|
|
fc5a94cb50 |
feat(client): connection-status store + no-silent-failure batch
Implements the next four gaps from the client UX spec (docs/architecture/ux).
Connection status as single source of truth (spec §3):
- main.ts registers the one writer: ws.onStateChange → toConnectionStatus
(new 5→3 state mapper exported from ws.ts) → ui.store.connectionStatus.
- Consumers now subscribe to the store instead of ad-hoc ws wirings: the
MainPage reconnect banner (which also gains a "Disconnected" state via
ServerBanner.showDisconnected instead of going stale), ChannelController
composer gating + per-click send guard, and the UserBar presence picker.
- Fixes a latent production bug: SidebarArea never passed ws to UserBar, so
the status picker was permanently disabled and its presence_update path
dead. It now gates on the store and receives the ws send path.
- The one-shot connected-overlay wiring stays on ws.onStateChange by design
(it needs the exact internal transition); LiveKit voice reconnection stays
independent ("retrying underneath").
Transport backpressure surfaced (spec §5):
- ws.ts sendRaw no longer drops local send failures silently: send() passes
the envelope id, and failures notify a new onSendFailure(id, code)
listener — channel full → NETWORK, closed/not-open → OFFLINE (deferred a
microtask on the not-open path so the optimistic row registers first).
- The dispatcher fails the matching pending row via markSendFailed, exactly
like a server error reply; id-less sends (heartbeat) and fire-and-forget
sends (typing, presence) stay silent by design. MessageList renders the
new NETWORK reason ("Connection problem — message not sent").
uploadFile honors global 401 handling (spec §5):
- api.uploadFile now calls onUnauthorized and throws ApiClientError(401)
like every other REST call; main.ts sets the "Your session expired — sign
in again." transient error so the connect page shows the reason.
History fetch loading/error states (messaging.md §1):
- messages.store gains per-channel historyLoadState (loading/error, absent
= idle) with setChannelLoading/setChannelLoadError; setMessages and
clearChannelMessages clear it.
- MessageController.loadMessages sets loading synchronously before the
fetch and marks error inline instead of a toast; MessageList renders an
in-region spinner placeholder or an inline error + Retry (onRetryLoad
re-invokes loadMessages via ChannelController).
Also fixes two pre-existing eslint errors in api.ts (redundant assertions).
Docs: the corresponding gap callouts in docs/architecture/ux are updated
(README §3/§5, messaging.md §1/§3/§6, channels-members-dms.md block-gating
note no longer claims the composer lacks a read-only mode).
Verified: tsc + full client unit suite (3225 tests) + oxlint/eslint +
prettier all green.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
||
|
|
e0ab0744ee |
feat(client): optimistic message send + composer permission gating
Implements the two highest-impact gaps from the client UX spec. Optimistic send: - messages.store gains addOptimisticMessage / markSendFailed / removeOptimistic, and confirmSend now stamps the real id + "sent" on the ack. addMessage reconciles the broadcast by real id (idempotent, replay-safe) with a defensive author match, so an echo never duplicates. Message gains status/correlationId/errorCode. - ChannelController.performSend renders a pending row immediately and supports retry / delete-draft (retry preserves attachments). - MessageList renders pending (dimmed) and failed (reason + Retry / Delete) rows; the hover action bar is limited to confirmed rows. - Failures are precise: the server echoes the request id on error replies (buildErrorMsgWithID), so the dispatcher maps SLOW_MODE / FORBIDDEN / RATE_LIMITED / BAD_REQUEST to the exact row instead of dropping the code. An offline send is shown failed, not silently lost. Composer permission + connection gating: - The server computes an authoritative per-channel can_send in the ready payload (channelCanSend mirrors MessageService.checkSendPermission: READ|SEND, MANAGE_MESSAGES for announcement, admin bypass, channel overrides). channels.store carries it as Channel.canSend. - MessageInput gains a disabled-with-reason mode; ChannelController derives the reason from can_send + channel type + connection status and disables the composer reactively (announcement read-only, no-permission, reconnecting) rather than accepting a click and failing. Older servers that omit can_send default permissive. Docs: the corresponding "Current gap" callouts in docs/architecture/ux are updated to reflect the implementation. Verified: full server suite + client tsc + 3204 unit tests + lint + gofmt. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UA17KPvqGBX3XbXYnMf1rA |
||
|
|
0c86264b8c |
docs: add client UX specification (target-state flows + per-view states)
Adds docs/architecture/ux/ — a prescriptive (to-be) behavior spec for the
Tauri client, complementing the as-built module map in
docs/architecture/client.md. Covers every view and how it should react to
server events, permission state, and failure:
- README.md — view-state vocabulary, feedback primitives,
connection-status contract, the global
event->reaction map, and the error/permission
reaction matrix
- connection-and-auth — boot, profiles/health, login, TOTP,
register-by-invite, connected handshake,
reconnect, cert-TOFU trust
- messaging — composer permission/connection gating, optimistic
send lifecycle, edit/delete, reactions,
attachments, pins, search, read/unread, slow-mode
- channels-members-dms — channel list/switch/categories, member list +
presence + typing, DM open/close, blocking
- voice-and-e2ee — join/leave, mute/deafen/camera/screenshare, PTT,
active-speaker, and the E2EE securing/secured
indicators
- settings-and-admin — settings tabs, profile/password/2FA/delete,
theming, inline admin (ban/kick/roles, channel
CRUD, invites), updater
Each flow carries dated "Current gap" callouts where today's code diverges
from the target (grounded in file:line references), so the set doubles as a
UX improvement backlog. Notable gaps captured: non-optimistic send with a
dead pending-send path, no composer read-only/permission gating (incl.
announcement channels), silently-dropped WS error codes, no E2EE "securing"
indicator, no updater download progress, client-local logout that never
revokes the server session, and a duplicated role store.
All 12 Mermaid diagrams validated; intra-repo links checked. Indexed from
docs/architecture/README.md and the top-level Docs Index.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UA17KPvqGBX3XbXYnMf1rA
|
||
|
|
5f1d6fc287 |
refactor(server): remove the store abstraction layer (D3)
Deletes Server/store (SQLiteStore, MemStore, the composed Store interface) and collapses to a single sqlc-backed db package, executing the prior audit's P4 "single data layer" direction (finding #6). SQLiteStore was a pure pass-through to *db.DB, so consumers now depend on narrow interfaces that *db.DB satisfies directly: - service.Store (service/datastore.go, renamed from store/store.go) - ws.EventStore (ws/eventstore.go) - plugin.PluginStore (plugin/pluginstore.go) The event- and plugin-KV methods that lived in the store's SQLite implementation move into the db package (db/event_queries.go, db/plugin_queries.go), keeping their raw-SQL form. Tests: the MemStore-based unit tests now run against a real in-memory SQLite db opened per-test with migrations applied, via package-local seed helpers. Fault-injection tests embed a real *db.DB and override the single method under test, preserving error-path coverage. Full server suite and sqlc-verify are green. Docs: audit finding #6 and A-2026-07-06 marked resolved; decisions D3 updated; architecture server.md / data-model.md diagrams and prose updated to the api -> service -> db layering. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UA17KPvqGBX3XbXYnMf1rA |
||
|
|
071426c0d8 |
feat(server,client): announcement channels (D1, closes A-2026-07-01)
Make 'announcement' a real channel type, resolving the contradiction where it was documented and offered by the admin API but hard-rejected by the migration-013 DB triggers. Model: announcement channels are readable like text channels (same READ_MESSAGES visibility), but posting is restricted to users with MANAGE_MESSAGES — no new permission bit, migration, or client permission plumbing needed. Server: - migrations/016: recreate the channel-type triggers to allow text/voice/announcement/dm. - service/message.go: checkSendPermission now takes the channel type and rejects posts to announcement channels from users lacking MANAGE_MESSAGES (SendMessage + CanPost paths). Added a service test. - Unread counts: ready-payload builder (ws/serve.go) and GetChannelUnreadCounts (db) now include announcement channels alongside text, so they track unread/last-message like text channels. Client: - ChannelSidebar renders announcement channels with a megaphone icon (added to the icon set) instead of the '#' text prefix; they otherwise behave like text channels (already typed in ChannelType). Specs + trackers (api.md, protocol.md, schema.md incl. migration 016, architecture/data-model.md, audit A-2026-07-01, decisions D1) updated. Verified: go build ./...; go test ./service ./db ./ws ./api ./admin; sqlc-verify; client tsc + oxlint + prettier clean. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UA17KPvqGBX3XbXYnMf1rA |
||
|
|
ca3b0fee3e |
docs: refresh api.md, protocol.md, schema.md against current code (D7)
One-PR spec refresh per decision D7, using the 2026-07-19 audit's
conformance matrix as the checklist:
api.md
- Remove the deleted version field from /health and /api/v1/info
(anti-fingerprinting C-2) and document the removal.
- Document the profile surface (PATCH /users/me, PUT /users/me/password,
GET/DELETE /users/me/sessions), the user-blocks surface
(GET/PUT/DELETE /api/v1/blocks), and the plugin admin surface
(/api/v1/admin/plugins).
- Correct GET /api/v1/files/{id}: auth is required and caching is
'private, no-cache' (was documented as public/immutable, unauthenticated).
- Add search (30/min) and upload (10/min) rate limits; note announcement
channel type as planned-only.
protocol.md
- Document auth_ok.replay_source and the 3-tier reconnect replay
(ring buffer -> events table -> full resync) with the visibility
watermark.
- Add the Voice End-to-End Encryption section (voice_e2ee_announce/offer
in both directions, key-holder semantics, rate limits) and
voice_token.is_key_holder.
- Document user_update; mark voice_speakers and member_leave as defined
but not currently emitted; extend voice_config fields.
- Update the reference tables (19 client->server / 30 server->client)
and point them at protocol-schema.json as the generated inventory.
schema.md
- Correct the migration history to the real 001-015 numbering.
- Document the previously missing tables: login_attempts, settings,
emoji, sounds (dead schema), rate_lockouts, user_blocks, events,
plugins, plugin_kv; add attachments.uploader_id and new indexes.
- Fix the channel-type list to text/voice/dm (013 triggers) and correct
the permission formula to (base & ~deny) | allow to match
permissions.EffectivePerms.
- Note the sqlc/dbgen layer and link the architecture data-model doc.
Also correct the audit_log_v6 claim (transient rename inside migration
003, not a coexisting table) in the audit and data-model blueprint, and
update the audit/decisions trackers (A-2026-07-03 closed).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UA17KPvqGBX3XbXYnMf1rA
|
||
|
|
cf8e5aae24 |
docs: add architecture blueprints and 2026-07-19 audit
Add docs/architecture/ — a curated blueprint set with 10 Mermaid diagrams covering system context, deployment topology, server package map, REST request lifecycle, WebSocket auth/replay/dispatch, the full data model (migrations 001-015), voice/E2EE flow, and the client module map. Add docs/audit-2026-07-19.md — successor to audit-2026-04-07.md: re-verifies carried-over findings, catalogues spec-vs-code drift in api.md/protocol.md/schema.md (incl. the announcement channel-type contradiction and the undocumented voice-E2EE protocol surface), records server/client/CI findings with file:line evidence, and closes with a 12-item prioritized improvement backlog. Link both from the README docs index. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UA17KPvqGBX3XbXYnMf1rA |