Author SHA1 Message Date
J3vbandClaude Fable 5 fb04a579c4 fix(docker): ship /app owned by the runtime uid; run the boot-smoke in CI too (#1378)
* fix(release): give the Docker boot-smoke a writable /app, and run it in CI

The v1.2.0-alpha.3 release run died at "Boot-smoke Docker image": a bare
`docker run` of the distroless image has nowhere the uid-65532 server can
write — /app is root-owned, and the VOLUME /app/data anonymous volume is
created root-owned too — so config.Load failed on "writing default config:
open config.yaml: permission denied" and the container exited. Real
deployments bind-mount config.yaml and data/, which is why the image itself
is fine.

Move the smoke into Server/scripts/docker-smoke.sh, run the container with
`--tmpfs /app --tmpfs /app/data` (Docker's tmpfs default mode is 1777, so
the non-root server can write both), and call the same script from ci.yml's
docker-build job — loading the image it already builds — so the smoke is
exercised on every PR to main instead of for the first time at tag time.

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

* fix(docker): ship /app and /app/data owned by the runtime uid so a bare run boots

The tmpfs approach did not survive CI: runc re-applies the underlying
directory's mode to a tmpfs mounted over an existing path, so /app stayed
root:755 and the write still failed. Fix the image instead of the harness:
stage /app/data in the builder, chown it to 65532, COPY --chown it into the
distroless stage before WORKDIR. Docker seeds the VOLUME's anonymous volume
from that image dir, ownership included, so `docker run <image>` with no
mounts now boots and answers /health — which is also the contract the smoke
should be testing, so it goes back to a bare `docker run`.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-15 22:17:21 +02:00
J3vbandClaude Fable 5 fb4329dd94 release: v1.2.0-alpha.3 (#1377)
Bump the client version in package.json (+lock), tauri.conf.json and
Cargo.toml (+lock) so release.yml's verify-versions gate passes and
deployed clients see the update; refresh the literal version in the
README and docs build examples; add the curated CHANGELOG entry covering
the 199 verified defects fixed since v1.2.0-alpha.2 (#1366-#1375), the
observability/backup/deployment hardening in #1376, migration 031, and
the new config keys.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-15 21:37:20 +02:00
J3vbandClaude Fable 5 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>
2026-08-15 20:50:47 +02:00
J3vbandClaude Opus 5 ea0430c5b0 fix: batch of correctness fixes across server and client (#1375)
* fix(client): 1 defect(s) (OC-0201)

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

HandleTyping built the per-user-per-channel rate-limit key before resolving the channel or checking read permission, so forged channel ids could pin unbounded dead entries in the shared process-wide RateLimiter.

* fix(client): 2 defect(s) (OC-0203, OC-0224)

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

* fix(ws): 2 defect(s) (OC-0205, OC-0211)

* fix(admin): 2 defect(s) (OC-0209, OC-0212)

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

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

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

Route handler-driven PresenceEvent through BroadcastToAll instead of BroadcastToAllLow so every source of a user's presence shares one ordered per-client FIFO.

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

PATCH /users/{id} combining banned + role_id committed and broadcast the ban before authorizing the role change, so a refused role change returned an error while leaving the target banned. Authorize the role change up front via the new ModerationService.AuthorizeRoleChange.

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

LinkAttachmentsToMessage no longer claims an attachment that is a user's live avatar (users.avatar points at it). Once message_id is set, handleServeFile's avatar branch (gated on ChannelID == nil) is unreachable and the file falls under the message's channel ACL / soft-delete state, permanently disagreeing with users.avatar about who may read it.

* fix(emoji): 1 defect(s) (OC-0217)

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

The data-copy phase of an HTTP proxy tunnel was unbounded. Steps 1-2 of
handle_connection (header read, TCP connect, TLS handshake) each run under
a 10s guard, but step 3 called io::copy_bidirectional with no deadline. A
remote that completes the TLS handshake and then neither responds nor
closes parks the spawned connection task, the loopback socket and the
remote TLS session indefinitely: copy_bidirectional only resolves once
BOTH directions finish, so closing the local side alone does not free it.

Wrap the copy in copy_with_deadline, a generic helper bounded by
DATA_PHASE_TIMEOUT (600s). The bound is deliberately far looser than the
10s setup guards because this phase carries the REST body, including
attachment and avatar uploads, so it must reclaim only genuinely stuck
connections rather than merely slow ones. The helper is generic over the
stream types so it can be exercised without a live TLS connection.

Regression test drives two in-memory duplex pairs whose far ends stay
alive, so neither half ever observes EOF and raw copy_bidirectional would
block forever; the test asserts the call resolves on its own deadline with
ErrorKind::TimedOut.

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

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

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

UpdateNotifier scheduled its deferred update check with a setTimeout whose
handle was never retained, so destroy() could not cancel it. A component torn
down inside the 3s window (page swap / logout) still fired performCheck() and
issued a network update check against the old server URL. Retain the timer
handle and clear it in destroy().

* fix(dm): 1 defect(s) (OC-0222)

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

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

The Grant-Microphone retry's .finally hardcoded grantMicBtn.disabled = false, undoing updateFrozen()'s socket-down freeze when the WS socket dropped while the mic permission request was in flight. Delegate the state back to render().

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

handleApplyUpdate broadcasts a 'restarting in 5s' notice before the on-disk
swap. Every failure path in the swap returned silently, leaving clients
counting down to a restart that never happened. Extract the swap into
applyStagedUpdate and send a corrective 'update_aborted' broadcast from a
deferred guard on every path that does not reach the respawn.

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

PATCH /channels/{id} accepted a blank or whitespace-only name, leaving the
channel unidentifiable in clients. updateChannelRequest.validate() now
rejects it the way handleCreateChannel already did.

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

* fix(admin): run deferred cleanup before the update restart exits

The fix batch left three golangci-lint findings and two prettier findings
that CI gates on.

applyStagedUpdate called os.Exit(0) in the same function that defers both
staged.Close() and the corrective "update_aborted" broadcast, so neither
ran (gocritic exitAfterDefer). Return a bool instead and let the caller
exit once those defers have run — on Windows, releasing the staged binary's
file handle is the reason the restart exists at all, so this is a real fix
rather than a lint appeasement. The exported test hook calls the function as
a statement, so the added result does not affect it.

Also modernize a bulk-insert loop to range-over-int, compare backup bytes
with bytes.Equal, and reflow two test files to prettier's output.

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

* test(ws): pin the live presence path against the invisible custom-status leak

OC-0207 and OC-0211 are the same defect at two emitters: hub_broadcast.go's
BroadcastPresence (connect/reconnect) and event.go's presenceEvents (live
presence_update). The fix for OC-0211 closed both sites in one change, but
only the hub_broadcast side got a regression test.

This pins the event.go sibling: an invisible user's real custom status must
be blanked on the PresenceOthersEvent frame while the owner's own
PresenceSelfEvent still carries it. Without it, a later change could reopen
the live path while the committed test kept passing.

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

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

* test(ws): silence a contextcheck false positive in the reconnect race test

RefreshChannelVisibility takes no context by design — it is reached through
the admin HubBroadcaster interface, which carries none, so it builds its own
internally. contextcheck flags the call only because the test closure around
it holds a ctx for its override write, so there is nothing to propagate.
Suppress at the call site rather than widen a production interface (and its
mocks) to satisfy a lint in a test.

golangci-lint v2.11.3 (the version ci.yml pins) now reports 0 issues.

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-08-15 16:30:05 +02:00
J3vbandClaude b8b7a2a1f9 fix: correctness fixes across LiveKit voice, client session and transport paths (#1374)
* fix: enhance bugfix workflow documentation with detailed clustering and staging instructions

* fix(voice): 6 defect(s) (OC-0001, OC-0006, OC-0009, OC-0010, OC-0015, OC-0029)

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

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

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

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

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

* fix(client): 3 defect(s) (OC-0014, OC-0024, OC-0031)

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

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

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

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

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

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

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

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

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

* fix(client): 2 defect(s) (OC-0002, OC-0020)

OC-0002: chain offer handling behind the announce chain so an offer that
arrives immediately behind its sender's announce is not dropped as an
unknown peer.

OC-0020: retire a departing peer's ECDH key on participant-left so a
replayed pre-leave announce cannot overwrite the fresh key they rejoined
with.

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

handleVoiceJoin handed the client its LiveKit token before checking whether
the join had been superseded by a concurrent eviction (moderator kick/move,
the CONNECT_VOICE revocation sweep, CleanupVoiceForChannel). Those evictors
delete the voice_states row, clear the client's in-memory state, and call
RemoveParticipant — which no-ops because the join has not reached the SFU
yet. The client was left holding a live 5-minute RoomJoin credential for a
membership the server had just torn down.

Re-check the client's voice state immediately after GenerateToken and
withhold the credential if the join was superseded, with a best-effort
RemoveParticipant to match every other eviction path.

* fix(ws): 2 defect(s) (OC-0017, OC-0022)

OC-0017: sweepStaleVoiceStates re-checks the live client immediately before
deleting a snapshotted-stale voice_states row. voice_join commits the row
before calling c.setVoiceState, so a join that lands inside that window was
snapshotted as a ghost and had its just-committed row deleted, leaving the
client in voice in memory with no DB row.

OC-0022: CleanupVoiceForChannel resolves its voice_leave audience with a
variant of channelReadAudience that skips the archived short-circuit. Both
production callers archive the channel before evicting, so the plain
resolver always returned an empty audience and only the evicted
participants learned the call ended.

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

Camera and screenshare now draw from the same per-channel voice_max_video
budget. handleVoiceScreenshareV2 performed no cap check at all, and the
camera gate's slot-count subquery counted only `camera = 1` rows, so a
screensharing occupant was invisible to it. Both gates now count
`camera = 1 OR screenshare = 1` via a shared enableVideoSlot helper.

* fix(client): 2 defect(s) (OC-0032, OC-0033)

OC-0033: voice_disconnected staleness guard swallowed the kick toast when
the sibling voice_leave had already cleared currentChannelId. Treat a
cleared store as not-stale.

OC-0032: VIDEO_LIMIT rollback assumed the camera, tearing down a working
camera and leaving refused screen tracks published. Correlate by envelope
id and roll back the kind that was actually refused.

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

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

A superseded video-enable id makes rollbackPendingVideo return undefined.
The dispatcher's ternary treated undefined as "not screen" and called
disableCamera(), tearing down a working camera the user never touched.
Return early instead: undefined means there is nothing to roll back.

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-08-15 12:57:51 +02:00
J3vbandClaude Opus 5 079f59d06d fix(workflows): drop hardcoded absolute repo path from bughunt prompts (#1373)
* fix: enhance bugfix workflow documentation with detailed clustering and staging instructions

* fix(workflows): drop hardcoded absolute repo path from bughunt prompts

The bughunt and bughunt-fix agent prompts told every finder, verifier, fix
and prove agent that the repo lives at a specific absolute path from one
contributor's machine. Anywhere else - a cloud session, CI, another
checkout - that path does not exist, and the churn recon agent ran
`git -C <that path> log ...` outright, so the most-churned-files inventory
came back empty and every finder prompt lost its churn context.

Point the prompts at the agent's working directory instead, which is the
repo root on every platform.

Both harnesses pass (bughunt.harness.mjs, bughunt-fix.harness.mjs).

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-08-15 09:34:50 +02:00
J3vbandClaude Fable 5 8787b9066d fix: batch of 34 correctness fixes across server and client (#1372)
* fix(client): 3 defect(s) (OC-0037, OC-0063, OC-0116)

Route the tray Status submenu through saveUserStatus() (mapping the legacy
"offline" to "invisible") so notifications, autoIdle, and reconnect presence
restore all agree with the tray's choice; build the connected overlay from
the auth_ok payload instead of a pre-dispatch authStore snapshot; keep the
TOTP overlay open across a rejected verify (totpPending latch) and retain
the partial token for the retry instead of clearing it in finally.

Hand-applied combined cluster preserved from the previous fix run's
overlap-guard block (both clusters edit main.ts).

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

* fix(voice): 2 defect(s) (OC-0010, OC-0011)

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

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

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

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

* fix(auth): 1 defect(s) (OC-0061)

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

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

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

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

* fix(service): 2 defect(s) (OC-0075, OC-0120)

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

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

* fix(client): 2 defect(s) (OC-0085, OC-0094)

Scope collapsed-category persistence to the connected host instead of the
server display name, and stop the DM back button from jumping to the first
text channel when DM mode was entered without recording channelBeforeDm.

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

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

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

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

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

* fix(dm): 1 defect(s) (OC-0119)

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

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

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

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

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

* fix(updater): 1 defect(s) (OC-0146)

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

* fix(mentions): 1 defect(s) (OC-0131)

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-14 18:48:10 +02:00
J3vbandClaude Fable 5 7be9ccd2f9 fix: batch of 22 correctness fixes across server and client (#1371)
* fix(voice): 4 defect(s) (OC-0008, OC-0009, OC-0042, OC-0080)

Guard LiveKit session state against supersession: bump the camera/screen
generation in leaveVoice and teardownForReconnect so an in-flight enable
discards its track, bail out of restoreLocalVoiceState when a newer room
claimed _room mid-await, and recheck isStateConnected in the auto-reconnect
tail.

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

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

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

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

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

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

* fix(service): 2 defect(s) (OC-0036, OC-0128)

* fix(voice): 2 defect(s) (OC-0038, OC-0065)

OC-0038: the LiveKit participant_left webhook cleared the leaver's own
client voice state before broadcasting voice_leave, so the broadcast
audience (READ_MESSAGES holders union still-in-the-room participants)
could no longer see them. Voice membership is gated on CONNECT_VOICE
alone, so a participant without READ_MESSAGES never learned the server
had torn down their call. Extracted finishVoiceLeave's audience logic
into broadcastVoiceEventWithLeaver and used it on the webhook path.

OC-0065: handleWebhookParticipantJoined OR'd a GetVoiceState read error
into the same branch as "no matching row", so a transient DB failure
ejected a legitimate participant from the SFU mid-call. Now the read
error is logged and the check skipped, matching sweepStaleVoiceStates.

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

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

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

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

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

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

* fix(client): 2 defect(s) (OC-0111, OC-0143)

OC-0111: retry a presence_update dropped by the 1-per-10s limiter once the
window reopens, so auto-idle's return-to-online does not leave the server
and every other client stuck on idle.

OC-0143: pass apiConfig.host to the DM profile sidebar so per-user notes
are scoped per server, matching channel mutes, the NSFW gate and volume.

* test(ws): align aborted-switch test with OC-0034 no-resurrect behavior

The fix agent rewrote this pre-existing test (it locked the buggy restore
path) but the prove agent left it out of c67d25ed; committed state alone
failed go test ./ws/ without it.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-14 16:15:32 +02:00
J3vbandClaude Fable 5 8579cb5d91 fix: batch of 25 correctness fixes across server and client (#1370)
* chore(workflows): raise subagent effort tiers (sonnet/haiku to xhigh, prove opus to high)

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

* fix(voice): 6 defect(s) (OC-0098, OC-0004, OC-0005, OC-0006, OC-0007, OC-0020)

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

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

* fix(auth): 2 defect(s) (OC-0099, OC-0021)

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

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

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

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

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

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

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

* test(admin): compare restore bytes with bytes.Equal

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

* fix(voice): 2 defect(s) (OC-0095, OC-0014)

OC-0095: createRoom never called setE2EEEnabled(true), so the full ECDH/HKDF/AES-GCM key exchange completed but frames still reached the SFU in plaintext.

OC-0014: token refresh timer was 23h while the server mints LiveKit tokens with a 5-minute TTL, so any reconnect after minute 5 presented an expired token.

* fix(profile): 2 defect(s) (OC-0100, OC-0102)

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

Archived channels were only read-only for SendMessage/DeleteMessage. Edit, reaction, pin and purge sinks bypassed the check. Route every write sink through a shared requireChannelWritable gate.

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

* chore(workflows): correct stale model labels in bughunt-fix phase details

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

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

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

* test: fix two CI-only failures in the batch-4 test suite

The delete-account broadcast test now observes member_ban on a second
client's socket: the hub broadcasts and then force-disconnects the target,
so on a slow runner the close could beat the target's own copy of the
frame. The observer is also the party the event exists for.

The voice e2e mock now echoes the real joined channel id on voice_leave
(it hardcoded channel_id 0, which the dispatcher's channel-matched
self-leave teardown correctly ignores), and the rejoin test waits for the
mock's delayed echoes to settle before clicking the row again — clicking
inside the echo window toggled a leave instead of a join.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-14 14:49:27 +02:00
J3vb db0275a290 fix: batch of 29 correctness fixes across server and client (#1369)
* fix(ws): 2 defect(s) (OC-0013, OC-0140)

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

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

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

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

* fix(server): 7 defect(s) (OC-0033, OC-0066, OC-0067, OC-0068, OC-0074, OC-0077, OC-0106)

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

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

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

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

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

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

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

* fix(plugin): 1 defect(s) (OC-0088)

* fix(plugin): 4 defect(s) (OC-0104, OC-0126, OC-0127, OC-0133)

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

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

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

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

* test(server): adapt existing tests to updated OpenDM and IncrementMentionCounts signatures

* style(plugin): modernize loops and goroutine spawns in race test

* fix(ws): mirror the focus admission gate in the post-subscribe revalidation

* fix(service): detach DM post-commit side effects from the request ctx, fail delete closed, add empty-fan-out fallback

* fix(plugin): preserve enabled intent when upgrade reactivation hits a runtime-less build

* chore(skills): harden bughunt-fix workflow and fold review lessons into bughunt-run/db-change

* Add comprehensive documentation for task-observer skill

- Introduced environments.md to outline activation setup, compaction behavior, and handoff-doc mode.
- Created skill-authoring.md detailing taxonomy, licensing, confidentiality, and editing rules for skill creation.
- Added weekly-review.md for a structured review process of OPEN observations, including scheduled and in-session fallback modes.

* chore(go): pin toolchain go1.26.6 (stdlib CVE fixes flagged by govulncheck)
2026-08-14 10:05:40 +02:00
J3vbandClaude c3837fa32c fix(client): batch of 15 client correctness fixes (#1367)
* fix(client): 1 defect(s) (OC-0078)

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

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

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

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

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

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

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

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

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

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

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

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

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

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-08-14 08:11:16 +02:00
J3vbandClaude Fable 5 b1fb56511d fix(client): batch of 15 client correctness fixes (#1366)
* fix(message-list): rebuild virtual window when scroll leaves the rendered range

Scroll-driven renderWindow calls previously never rebuilt the DOM, so
scrolling past the overscan showed only spacer blank space until an
unrelated data change forced a full re-render. The window now rebuilds
whenever the computed visible range is not fully contained in the
rendered one, keeping the no-op (and the existing rebuild rate limiter)
for ranges that are already rendered.

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

* fix(messages): refetch channel tail when revisiting a previously loaded channel

The server only delivers live message broadcasts for the focused channel,
so a channel's loaded window stops updating once the user switches away.
Switching channels now drops the left channel's loaded flag so the next
visit refetches the live tail, while keeping the old rows rendered until
the refetch merges in (pending/failed rows are preserved).

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

* fix(channel-sidebar): assign distinct slots when reordering channels with tied positions

Categories whose channels share a position value (the server does not
enforce uniqueness, and new channels default to position 0) previously
produced an empty or partial reorder on drop, leaving the final order
ambiguous. Tied slots are now nudged into a strictly increasing sequence
before being reassigned, while already-distinct groups keep their range.

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

* fix(embeds): keep the link-preview abort timer armed until the body is read

The link-preview fetch previously cleared its 5 s abort timer as soon as
response headers arrived, so reading the response body was unbounded in
time and size. The timer is now cleared in a finally after the body read,
so the timeout covers the whole request and the 50 KB parse cap applies
as documented.

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

* fix(message-list): anchor scroll-to-bottom and jump-to-present controls outside the scroller

The two floating controls were appended inside the overflow scroller, so
they were part of its scrollable overflow and translated away with the
content whenever the user scrolled up — precisely when they become
visible. They now anchor to a position:relative frame that wraps the
scroller, keeping them pinned to the viewport edge.

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

* fix(voice): reset the pinned input device before cycling the mic to default

Selecting the default microphone (or losing the selected one to a hot
unplug) only muted and unmuted the existing track, which kept capturing
from the previously pinned device. The shared cycle now resets the
capture device to the system default first so both paths actually reach
it.

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

* fix(voice): re-check supersession after camera publish before announcing camera on

A camera disable that completes while the enable's publishTrack call is
still in flight now causes the enable to unpublish and stop its track and
skip the enabled announcement, so the server's last word matches the local
state instead of reporting a stopped camera as on.

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

* fix(voice): re-check supersession across the screenshare publish loop

A screenshare disable that completes while a publish in the enable loop is
still in flight now stops the loop before the remaining tracks are
published; the enable attempt unpublishes and stops all of its tracks and
skips the enabled announcement, so tracks held only by that attempt are
released and the server's last word matches the local state.

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

* fix(sidebar): rethrow channel modal API failures so modals can recover

The create/edit/delete channel callbacks caught API errors and only
showed a toast, so the awaiting modal never saw the failure and left its
submit button disabled with the in-flight label. The callbacks now
rethrow after toasting, letting each modal re-enable its button and
render its inline error so the user can retry without losing the form.

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

* fix(messages): carry pending and failed rows across the prepend trim

When a scroll-up page pushed a channel past the per-channel cap,
prependMessages trimmed the tail wholesale, deleting pending/failed
optimistic rows that hold the only copy of the user's composed text.
The trim now carries those rows across, matching the other
window-replacing writers.

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

* fix(channel-sidebar): re-resolve the drop container when the sidebar re-renders mid-drag

A store-driven sidebar re-render while a drag is in flight rebuilds the
channel rows, detaching the container captured at mousedown; detached
rows report all-zero rects, so the drop and the hover indicator could
never resolve. The global handlers now re-target the live row, its
container, and the store's current group snapshot before hit-testing.

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

* fix(delete-channel-modal): re-arm the confirm button when a delete fails

The confirm button was only restored from the catch block, so a caller
that handled the failure itself and resolved left the button disabled on
'Deleting...' with no way to retry. Restoration now runs in a finally
block whenever the modal is still open, regardless of how the callback
settled.

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

* fix(dm-sidebar): keep the presence dot when an avatar image loads

The avatar swap cleared the whole circle before inserting the fetched
image, which also removed the online/idle/dnd/offline dot on 1:1 rows.
The initial now lives in its own node and only that node is replaced,
so the presence dot survives the image arriving.

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

* fix(formatting): compute the yesterday boundary from the calendar date

The relative-day fence post was derived by subtracting a fixed 24 hours
from local midnight, which lands inside the wrong calendar day when a
DST transition makes the local day 23 or 25 hours long. It is now built
from the calendar date directly, so hover and expanded message
timestamps keep the correct Yesterday label around transitions.

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

* fix(message-input): keep the empty-edit guard active while attachments are queued

Edits are text-only, so a queued attachment no longer bypasses the
empty-content guard while editing. Submitting an edit whose text was
cleared is now refused with edit mode intact, matching the behavior
when nothing is attached.

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-08-14 05:55:00 +02:00
J3vbandClaude Fable 5 fa50d85413 fix(bughunt): opus verifiers, args-armed budget, and single-finder floor retune (#1365)
First-live-run fallout (2026-08-13, base 34f2e41):

- Verify moves fable -> opus. An Anthropic outage (Mythos/Fable/Sonnet
  elevated errors) mass-nulled the fable verify agents mid-run; opus
  verifiers then carried the whole 8-round hunt with 0 nulls / 0 unverified.
- args.budgetTotal fallback. The +25M turn directive left budget.total null
  in every probe this session, silently disarming the cost ceiling. The
  ceiling now arms from args.budgetTotal when the directive doesn't, computed
  from budget.spent(); budget.remaining() stays authoritative when it does.
- ROUND_BUDGET_FLOOR 2M -> 600k. The 2M value was a dual-finder-era anchor
  (~2.6M/round); the single opus finder costs ~100-260k/round, so 2M would
  zero-out any hunt launched with a budget under 2M - a foot-gun now that
  budgetTotal is a first-class arg.

Harness: +s8c (args-armed ceiling announces budget=10M and floor-stops);
s8b/s9 retuned to the 600k floor. 33/33 offline scenarios pass.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-13 20:58:06 +02:00
J3vbandClaude Opus 4.8 34f2e41207 feat(bughunt): single-finder hunt with graph-fed targeting, telemetry, and defect fixes (#1364)
* chore: ignore graphify-out

* fix(bughunt): assemble the report in-script - the report agent dropped findings

* fix(bughunt): retry only unverified candidates and catch garbage-verdict batches

* fix(bughunt): retune the round budget floor and require a budget directive

* feat(bughunt): per-round telemetry and a runStats aggregate

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* docs(bughunt-run): record run telemetry and validate coordinates after each hunt

* feat(bughunt): drop the sonnet finder slot

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat(bughunt): rebuild adaptive targeting - directory clusters, cooldown, graph-fed explore lenses

* fix(bughunt): rewind a dead finder's explore draw so unread files are never marked clean

* docs(bughunt-run): pre-hunt graph ranking checklist and offline test roster

* fix(bughunt): rewind thrown-stage explore draws and correct log/doc wording

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(bughunt): disambiguate absorb() drop log and retire dead sonnet label alternation

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

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-13 16:52:23 +02:00
J3vbandClaude Opus 5 3af3489f71 feat(bughunt): add a fix-run circuit breaker and panel finder attribution (#1362)
* feat(bughunt-fix): add a circuit breaker for systematically failing runs

A fix run had no abort condition. If something was systematically wrong - the
operator on the wrong branch, a broken test runner, ledger coordinates gone
stale after a rebase - it worked through every cluster, spending a high-effort
agent on each, and only reported the wreckage at the end.

Two trip points, because there are two distinct failure signals:

- after the fix stage, a high blocked rate means the fixing itself is failing.
  Proving each of those costs a serial agent per cluster and cannot succeed, so
  phase 3 is skipped entirely.
- inside the prove loop, a high revert-proof failure rate means the proving is
  failing. Break rather than attempt the rest.

`declined` never counts as a failure - it is a judgement the fix prompt
explicitly invites, and a run where several findings are correctly declined is a
good run. Both points require a minimum number of attempts first, because "50%
of two" is noise. Clusters never reached are marked blocked with a rationale
naming the breaker, so nothing is left reported as fixed with no commit behind
it, and the gate still runs over whatever committed before the trip.

proveAttempts is incremented before the ok check so successes land in the
denominator; inside the failure branch the ratio would be failures-over-failures
and trip on the first failed cluster at any threshold.

Verified with 6 new harness scenarios (21 -> 27, all green, bughunt.harness.mjs
untouched at 21). The guard was also proved load-bearing: with the threshold
temporarily raised to an unreachable 1.1, f16 runs all four clusters instead of
stopping at three and f20 produces no breaker report - both fail for the reason
the guard exists.

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

* feat(bughunt): attribute confirmed findings to the finder that produced them

The dual-model panel unions its two finders rather than voting between them, so
the second model's entire value is what it finds alone - and the union threw
that away, leaving no way to tell whether sonnet earns its cost.

Tag each finding with its panel slot. Because dedupe keeps the first occurrence
and opus is slot 0, a confirmed finding tagged sonnet is one opus missed, which
is exactly the number that decides the question. The run logs the split.

Three details worth naming:

- the tag is taken from the panel slot, not from the position in the surviving
  list. Filtering the nulls out before reading the index shifts sonnet into slot
  0 whenever opus dies and mislabels its finds as opus - precisely when the
  attribution matters most.
- the tag is stripped in verifyPrompt, not at its two call sites, so every
  caller routes through the guard. The verifier prompt says "another model" on
  purpose; naming it is an authority cue that erodes refute-by-default.
- dropping to a single finder would also weaken convergence, since a round only
  counts as dry when the full panel reported. The skill records this next to the
  count so the decision is made with both halves in view.

Verified with 4 new harness scenarios (21 -> 25). The dead-opus case is the
load-bearing one: it fails against the naive filter-then-index form.

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

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-11 21:03:15 +02:00
dependabot[bot] 74af0a56b2 chore(deps): bump the go-dependencies group in /Server with 3 updates (#1358)
Bumps the go-dependencies group in /Server with 3 updates: [github.com/corazawaf/coraza/v3](https://github.com/corazawaf/coraza), [github.com/knadh/koanf/providers/structs](https://github.com/knadh/koanf) and [go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc](https://github.com/open-telemetry/opentelemetry-go).


Updates `github.com/corazawaf/coraza/v3` from 3.6.0 to 3.7.0
- [Release notes](https://github.com/corazawaf/coraza/releases)
- [Changelog](https://github.com/corazawaf/coraza/blob/main/CHANGELOG.md)
- [Commits](https://github.com/corazawaf/coraza/compare/v3.6.0...v3.7.0)

Updates `github.com/knadh/koanf/providers/structs` from 1.0.0 to 1.0.1
- [Release notes](https://github.com/knadh/koanf/releases)
- [Commits](https://github.com/knadh/koanf/compare/v1.0.0...parsers/hcl/v1.0.1)

Updates `go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc` from 1.44.0 to 1.45.0
- [Release notes](https://github.com/open-telemetry/opentelemetry-go/releases)
- [Changelog](https://github.com/open-telemetry/opentelemetry-go/blob/main/CHANGELOG.md)
- [Commits](https://github.com/open-telemetry/opentelemetry-go/compare/v1.44.0...v1.45.0)

---
updated-dependencies:
- dependency-name: github.com/corazawaf/coraza/v3
  dependency-version: 3.7.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: go-dependencies
- dependency-name: github.com/knadh/koanf/providers/structs
  dependency-version: 1.0.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: go-dependencies
- dependency-name: go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc
  dependency-version: 1.45.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: go-dependencies
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-11 20:33:56 +02:00
dependabot[bot] b9d5d40e45 ci(deps): bump the actions-dependencies group with 2 updates (#1359)
Bumps the actions-dependencies group with 2 updates: [golangci/golangci-lint-action](https://github.com/golangci/golangci-lint-action) and [anthropics/claude-code-action](https://github.com/anthropics/claude-code-action).


Updates `golangci/golangci-lint-action` from 9.2.0 to 9.3.0
- [Release notes](https://github.com/golangci/golangci-lint-action/releases)
- [Commits](https://github.com/golangci/golangci-lint-action/compare/1e7e51e771db61008b38414a730f564565cf7c20...ba0d7d2ec06a0ea1cb5fa41b2e4a3ab91d21278a)

Updates `anthropics/claude-code-action` from 1.0.187 to 1.0.189
- [Release notes](https://github.com/anthropics/claude-code-action/releases)
- [Commits](https://github.com/anthropics/claude-code-action/compare/1623c36729ac1cd5895198cded705a287de7db79...6b082c41935b4c8a3b8b0ef85ba4ba4d9eeb8975)

---
updated-dependencies:
- dependency-name: golangci/golangci-lint-action
  dependency-version: 9.3.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: actions-dependencies
- dependency-name: anthropics/claude-code-action
  dependency-version: 1.0.189
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: actions-dependencies
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-11 20:33:46 +02:00
dependabot[bot] 32e2a93f3b chore(deps): bump tauri-plugin-updater (#1360)
Bumps the cargo-dependencies group in /Client/tauri-client/src-tauri with 1 update: [tauri-plugin-updater](https://github.com/tauri-apps/plugins-workspace).


Updates `tauri-plugin-updater` from 2.10.0 to 2.10.1
- [Release notes](https://github.com/tauri-apps/plugins-workspace/releases)
- [Commits](https://github.com/tauri-apps/plugins-workspace/compare/updater-v2.10.0...updater-v2.10.1)

---
updated-dependencies:
- dependency-name: tauri-plugin-updater
  dependency-version: 2.10.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: cargo-dependencies
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-11 20:33:32 +02:00
J3vb 6d964164b5 feat(bughunt): add the fix pipeline, a findings ledger, and cross-run memory (#1361)
* feat(bughunt): seed dedupe from the findings ledger via args.known

* feat(bughunt): allow a scoped hunt via args.lenses

* feat(bughunt): carry finder why/repro/evidence into confirmed records

* feat(bughunt-fix): add workflow skeleton with per-file clustering

* feat(bughunt-fix): add parallel per-file fix agents

* fix(bughunt-fix): dedupe ids in the test stub, not in the merge loop

* fix(bughunt-fix): drop foreign result ids loudly and assert the fix-prompt rules

* feat(bughunt-fix): add serial revert-proof and per-cluster commits

* fix(bughunt-fix): require real test output in the prove report

* feat(bughunt-fix): add the ci-check gate and finalise the return shape

* fix(bughunt-fix): harden the gate call and cover generated-code drift

* docs(bughunt): add the bughunt-run operator skill

* fix(bughunt-fix): guard cross-cluster edits, branch, and ledger handoff
2026-08-11 20:33:19 +02:00
J3vbandClaude Opus 5 a39cd8e23c ci(deps): group Dependabot updates into one PR per ecosystem (#1357)
The 2026-08-10 refresh opened 17 PRs: ten gomod, four npm, three actions.
Each one rewrites its ecosystem's lockfile, so merging any single PR
invalidates every sibling, which then rebases and re-runs the full ~15
minute CI matrix. Clearing the batch sequentially costs 17 CI cycles for
one weekly dependency refresh.

A catch-all group per ecosystem makes that 4 PRs at most. It also keeps
release trains intact -- the seven OpenTelemetry modules in that batch are
one coordinated release and belong in one PR.

The stryker and vitest groups are removed because the npm catch-all
subsumes them; their reason for existing (exact peer pins across a family
break under a partial merge) is now the rationale for the whole scheme and
is recorded at the top of the file.

Majors are already ignored for every ecosystem, so each group only ever
carries patch and minor updates. A bad member goes on the ignore list
rather than ungrouping the rest.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-11 19:57:22 +02:00
J3vbandClaude Opus 5 0594a130cb fix(ws): give the LiveKit health check its own HTTP transport (#1356)
NewLiveKitProcess built its health-check http.Client without a Transport,
so it fell back to the process-wide http.DefaultTransport.

httptest.Server.Close calls CloseIdleConnections on http.DefaultTransport
by design ("assume most users of httptest.Server will be using the standard
transport, so help them out"), and ws is full of t.Parallel tests that each
defer srv.Close(). Any one of them finishing while a health check held a
pooled connection severed that request:

  livekit_test.go:978: HealthCheck: livekit health check failed:
    Get "http://127.0.0.1:41343": net/http: HTTP/1.x transport connection
    broken: http: CloseIdleConnections called

That surfaced as an unrelated-looking CI failure on a TypeScript lint bump
(#1341). It is not purely a test artifact: in production the health check
also shared one connection pool with every other DefaultTransport user in
the server process.

Cloning DefaultTransport keeps its tuned defaults (proxy, dial and TLS
timeouts, HTTP/2) while giving the client a private pool.

Locked by TestHealthCheckClientOwnsItsTransport, which fails on the
unfixed constructor.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-11 19:56:54 +02:00
J3vbanddependabot[bot] a29e28d018 ci(deps): bump actions/checkout, swatinem/rust-cache, and claude-code-action (#1355)
* ci(deps): bump actions/checkout from 4.2.2 to 4.4.0

Bumps [actions/checkout](https://github.com/actions/checkout) from 4.2.2 to 4.4.0.
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/11bd71901bbe5b1630ceea73d27597364c9af683...11d5960a326750d5838078e36cf38b85af677262)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-version: 4.4.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>

* ci(deps): bump swatinem/rust-cache from 2.9.1 to 2.9.2

Bumps [swatinem/rust-cache](https://github.com/swatinem/rust-cache) from 2.9.1 to 2.9.2.
- [Release notes](https://github.com/swatinem/rust-cache/releases)
- [Changelog](https://github.com/Swatinem/rust-cache/blob/master/CHANGELOG.md)
- [Commits](https://github.com/swatinem/rust-cache/compare/c19371144df3bb44fab255c43d04cbc2ab54d1c4...6323deb102c322ba6fcbdcafc7e3dddab59af2b6)

---
updated-dependencies:
- dependency-name: swatinem/rust-cache
  dependency-version: 2.9.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>

* ci(deps): bump anthropics/claude-code-action from 1.0.185 to 1.0.187

Bumps [anthropics/claude-code-action](https://github.com/anthropics/claude-code-action) from 1.0.185 to 1.0.187.
- [Release notes](https://github.com/anthropics/claude-code-action/releases)
- [Commits](https://github.com/anthropics/claude-code-action/compare/9db594c7a0e82298c121c18b7f08aa1579ce7341...1623c36729ac1cd5895198cded705a287de7db79)

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

Signed-off-by: dependabot[bot] <support@github.com>

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-11 19:56:42 +02:00
J3vbandClaude Opus 5 9009fbc584 chore(deps): bump eslint, oxlint, knip, and typescript-eslint (#1354)
Batches the four open Dependabot npm PRs into one change so
package-lock.json is rewritten once instead of four times:

  eslint             10.8.0 -> 10.8.1
  oxlint              1.76.0 -> 1.77.0
  knip                6.31.0 -> 6.32.0
  typescript-eslint   8.65.0 -> 8.66.0

All four are devDependencies; no runtime dependency moves.

Supersedes #1341, #1345, #1349, and #1352.

Verified per the ci-check skill: 4822 unit tests across 171 files, tsc
--noEmit, npm run lint, and prettier --check all pass. The
no-underscore-dangle warnings oxlint prints on livekitSession.ts are
pre-existing -- oxlint 1.76.0 emits the identical set -- and are warnings,
not errors.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-11 19:47:13 +02:00
J3vbandClaude Opus 5 ba4e689b25 chore(deps): bump otel to 1.45.0, koanf, and sqlite in /Server (#1353)
Batches the ten open Dependabot gomod PRs into one change so the OTel
release train lands together and go.sum is rewritten once instead of ten
times:

  go.opentelemetry.io/otel                       1.44.0 -> 1.45.0
  go.opentelemetry.io/otel/sdk                   1.44.0 -> 1.45.0
  go.opentelemetry.io/otel/metric                1.44.0 -> 1.45.0
  go.opentelemetry.io/otel/trace                 1.44.0 -> 1.45.0
  go.opentelemetry.io/otel/sdk/metric            1.44.0 -> 1.45.0
  go.opentelemetry.io/otel/exporters/prometheus  0.66.0 -> 0.67.0
  contrib/instrumentation/net/http/otelhttp       0.69.0 -> 0.70.0
  github.com/knadh/koanf/v2                       2.3.5 -> 2.3.6
  github.com/knadh/koanf/parsers/yaml             1.1.0 -> 1.1.1
  modernc.org/sqlite                             1.55.0 -> 1.56.0

go mod tidy also carried the transitive bumps each of those PRs would have
pulled on its own (httpsnoop, logr, go-isatty, libc).

Supersedes #1338, #1340, #1342, #1343, #1344, #1346, #1347, #1348, #1350,
and #1351.

Verified per the ci-check skill: all four build-tag variants, go vet,
go test -race ./... , the -tags deadlock pass over ws, and golangci-lint
(0 issues).

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-11 19:46:59 +02:00
J3vbandClaude Opus 5 ad0448df4d ci: stop the lint gate fetching its schema over the network (#1335)
golangci-lint-action verifies .golangci.yml against a JSONSchema it pulls
from https://golangci-lint.run before it lints anything. On d352696 that
fetch hit its client timeout and took the required ubuntu leg of Server
Build & Test red -- with zero linters run and nothing wrong with the code.

Turn the pass off. `golangci-lint run` already rejects a malformed config
on its own, so the only thing lost is a nicer error message for a config
typo, and the thing gained is a required gate that no longer depends on a
third-party website being reachable.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-09 12:24:57 +02:00
J3vbandClaude d3526968bb release: v1.2.0-alpha.2 (#1333)
* docs: add bug-detection improvements plan

Plan for mechanical bug detection alongside the agentic hunt: activate the 14
unused Go fuzz harnesses, the configured-but-never-run Stryker setup, and
browser-mode vitest; encode recurring bug classes as semgrep rules; add
model-based and fault-injected ordering tests; add a persistent seen-ledger
and sibling-sweep lens to the hunt.

All local-only and on demand - fuzz crashers are working reproducers, and this
repo is public.

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

* build: add make fuzz target and ignore mutation-test output

`go test ./...` runs each Fuzz* function against its committed seed corpus
only - one pass per seed, zero generated inputs - so the 17 fuzz harnesses in
Server/ have never actually fuzzed. `make fuzz` enumerates every target and
runs each with a time budget (Go fuzzes one target per package per
invocation, hence the loop). Local-only by design: a crasher is a working
reproducer and this repo is public.

Also gitignore Client/tauri-client/.stryker-tmp/ and reports/ - a Stryker run
left 200+ untracked files, and a surviving-mutant report maps exactly which
behaviour nothing tests.

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

* test(client): pin reconnect auth-frame and replay-dedup arming

Stryker found 14 surviving mutants across ws.ts:413/422/428 - the auth frame
built on reconnect. Every condition there could be flipped with all 4777
tests still green: the replay-dedup arming guard, the resume-vs-fresh-connect
ternary, and the conditional active_channel_id spread.

Seven tests through the public send/isReplaying surface, no new exports. Two
isolate each half of the `reconnectAttempt > 0 && lastSeq > 0` AND condition -
the combination no existing test reached, and the one an && -> || mutant
walked straight through.

Verified by flipping the line 413 guard to `if (true)`: 3 of 7 fail, revert
restores green.

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

* docs: record two fuzz corpus traps

Interrupting a fuzz run manufactures a false crasher: Go cannot distinguish a
worker that crashed on an input from one killed externally, so it saves the
in-flight input to testdata/fuzz/ as a suspect. It looks exactly like a real
security finding. Replay before believing it.

And committed seed corpus shares the testdata/fuzz/<Target>/ directory with
any false crasher, so clearing one by removing the directory deletes the
seeds too.

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

* feat(client): enforce three prose invariants as ESLint rules

CLAUDE.md documents the voice-supersession, E2EE staleness and dispatcher
invariants in English. English fails no build, and bug hunts keep rediscovering
the same classes. Five rules encode them as an inline flat-config plugin - no
new dependency, and `npx eslint src/` is already a blocking CI gate.

- no-leave-voice-when-superseded: a global leaveVoice() inside a branch that
  already confirmed supersession tears down the newer live session
- e2ee-epoch-needs-keypair-check: a non-key-holder never bumps the epoch, so
  an epoch-only staleness guard cannot see a restarted session
- e2ee-verified-status-literal: keeps "verified" tied to a hand-written call
  site that earned it, never a computed status
- no-identity-scope-fallback: a `?? 0` placeholder scope mints a keypair under
  the wrong account
- no-store-write-in-ws-on: page-local ws.on handlers may read stores, not
  write them

Each rule proven to fire by reintroducing the historical bug shape and
reverting; RuleTester cases cover both the real shapes that must stay clean
and the bug shapes that must not.

A fourth candidate - await-then-stale-snapshot - was declined as not
AST-expressible: whether an await needs a guard, and whether the guard is
sufficient, is intent rather than shape, and the rule would flag most of the
already-correct guard code in livekitSession.ts.

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

* docs: correct dispatcher invariant, record Tier 2 as shipped

The client CLAUDE.md claimed ws.on(...) appears only in dispatcher.ts. Eight
handlers across main.ts, MainPage.ts and ChannelController.ts say otherwise -
page-local UI (ringing, overlays, slow-mode timers) legitimately subscribes.
The real invariant is narrower: dispatcher is the single path by which server
events WRITE to domain stores. That is what local/no-store-write-in-ws-on
enforces, and the doc now matches the code.

Also record that Tier 2 shipped as ESLint rules rather than semgrep, and why
the fourth candidate was declined.

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

* fix(client): move the status-picker dot onto the avatar corner

The corner dot on the user bar avatar was a static hardcoded-green div —
never reflected real status and did nothing on click. Removed it and
relocated the actual StatusPicker trigger dot (real color, opens the
status dropdown) to that same corner instead of its own row. The
"Online"/"Idle"/... text label under the username is unchanged.

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

* fix(client): return the saved password over IPC again

The remember-password box saved a password the client could never read
back. Hardening had put #[serde(skip)] on CredentialData::password, so
load_credential returned a record whose password was always absent and
the login form could not prefill it — the box appeared to work and
silently did nothing.

Drop the skip and carry the field through the TS wrapper, which now maps
a non-string password to undefined rather than trusting the payload.

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

* feat(client): add an auto-connect checkbox to the login form

Auto-connect already existed end to end — ServerProfile.autoConnect,
setAutoLogin(), and the boot auto-login block with its cancel overlay —
but was only reachable through the zap button on a server card. This
surfaces the same state as a checkbox under Remember password, where
users look for it.

Ticking it forces Remember password on and disables it: boot auto-login
replays the stored token, which saveCredential only writes when the
password is remembered, so the two cannot be set independently without
producing a setting that silently does nothing.

Unticking is guarded. setAutoLogin(null) clears autoConnect on every
profile, so a bare toggle-off would wipe another server's setting; the
clear now only fires when this profile is the current holder. The guard
lives in ensureProfileExists, which all four auth paths already route
through.

Also consume the password restored in the previous commit, so selecting
a saved server prefills it instead of leaving the field blank.

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

* chore(release): bump client to 1.2.0-alpha.2

The client version is not derived from the tag — release.yml's
verify-versions job compares the tag against package.json and
tauri.conf.json and fails the release if they drift, so all five
manifests (both lockfiles included) move together.

Also refreshes the literal version in the README and docs build
examples, and closes the Unreleased changelog section as v1.2.0-alpha.2.

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

* docs(changelog): record the three bug-hunt sweeps in v1.2.0-alpha.2

PRs #1328, #1331 and #1332 merged to main after v1.2.0-alpha.1 was tagged
and closed 233 verified defects between them, but none of the three left
an entry in the curated changelog — the generated list covers commits,
this file covers behaviour, and nothing bridged the two.

Verified unreleased by ancestry rather than by date (none of the three
merge commits is an ancestor of v1.2.0-alpha.1), so all of it ships for
the first time in alpha.2.

Nine entries grouped by subsystem, leading with the changes an operator
or user would actually notice: the 24h-retention desync, the avatar-
deleting orphan sweep, the zero-byte restore truncation, the six hot-mic
paths, and the TOFU re-pin that would have warned every install at once.

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

* test(client): drop the e2e assertion for the removed user-bar status dot (#1334)

26b46cc removed the hardcoded-green `.status-dot` div from the user bar
avatar and relocated the real StatusPicker trigger dot into that corner,
adding "status picker dot sits on the avatar" to cover the new element.
The old "user bar has status dot" test was left behind and now fails on
an element that no longer exists by design.

The replacement test already asserts the corner dot is present and
visible, so removing the stale one loses no coverage.


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

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

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-09 11:50:12 +02:00
J3vbandClaude Opus 5 82be103794 fix(client): resolve 94 verified defects across voice, identity, transport and UI (#1332)
* fix(client): gate every mic re-enable path on the user's mute state

Six separate paths republished the microphone without consulting whether
the user had muted themselves: the audio-device fallback, selecting the
"Default" input, un-deafening, retryMicPermission, a stale PTT ownership
latch, and auto-reconnect's restoreLocalVoiceState. Each one produced a
hot mic while every remote UI still showed the user as muted.

These were six findings but one missing guard. Adds isMicPolicyGated()
(localMuted || localDeafened || localServerMuted || pttGated) and routes
the device-switch cycle, applyMicMuteState's unmute branch and
retryMicPermission through it, which also covers setDeafened(false) --
a call site no finding named.

Also extracts reconnectSuperseded() so all five supersession checkpoints
in the auto-reconnect loop carry the state-type check that only the
give-up path had, and clears the PTT gate on stopPtt and on ptt-error.

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

* fix(client): stop camera/screenshare publishing after the user turns it off

enableCamera and enableScreenshare set the store flag before awaiting
getUserMedia/getDisplayMedia, so clicking off during the OS picker left
the track publishing to the SFU while the UI showed it off, with no stop
affordance. Adds one shared generation guard: disable bumps, enable
captures before the await and discards the track if it changed.

Also in this area:
- a server refusal of voice_screenshare (or a non-VIDEO_LIMIT refusal of
  voice_camera) never rolled back the published track; the dispatcher now
  correlates the error by envelope id rather than blanket-rolling-back.
- a full-ready resync left every loaded channel with a permanent hole in
  its history, because that tier never replays chat_message frames.
  Loaded windows are now invalidated on a resync (pending and failed rows
  carry through) and the active channel refetched.
- CHANNEL_FULL while joining left voiceStatus stuck; DM mirror rows kept
  phantom entries and stale unread counts across a resync; addMessage and
  setAroundMessages dropped offline/failed optimistic rows.

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

* fix(client): preserve a mid-setup key-holder promotion, and route the
audio graph through the noise suppressor

setupKeyExchange unconditionally wrote the server's key-holder value
captured at join, clobbering a handleParticipantLeft promotion that
landed during its pre-publish awaits. The joiner then waited for an offer
only it could send, timed out, and was ejected from voice. The write now
preserves an existing promotion; it sits after the existing
session-generation check, and clearState bumps that generation and resets
the flag synchronously, so stale state cannot survive a teardown.

Enhanced Noise Suppression silently disabled the input-volume slider and
the VAD gate: livekit-client's setProcessor() does its own internal
replaceTrack(processedTrack) after awaiting addModule and a fetch, so it
landed after ours and wired the sender straight to the raw mic. The
pipeline now sources from the processed track and re-runs after
attaching, so our replaceTrack wins.

Also scopes the voice identity keypair by host AND user id so two
accounts sharing one OS profile stop sharing an identity keypair, guards
peer-key and TOFU writes against a clearState during their IPC awaits,
and seeds VideoGrid tiles from the persisted per-user volume.

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

* fix(client): drop the previous server's bearer token on a host switch

api.setConfig spread the new config over the old, so switching hosts
carried the previous server's session token forward and the login request
to the next server went out holding a live credential for the first one.
The token is now dropped in the shared setConfig when host changes
without an accompanying token, covering login, register and auto-connect
at once.

Also fixes a packaged-build-only failure: the CSP omitted blob: from
img-src, so avatar upload validation (which measures the image via
URL.createObjectURL) always failed in release and never in dev.

Smaller connection and IPC fixes: ws_disconnect now bumps the connection
generation instead of nulling the sender slot, so an in-flight handshake
cannot install after a disconnect; a dead LiveKit proxy listener
deregisters itself instead of being reused forever; httpProxy no longer
caches an origin the Rust side may have torn down; logPersistence stopped
looping on its own flush-failure logs; ConnectPage subscribes to
transientError instead of reading it once; cert-mismatch accept/reject
only act when the event host matches the live session.

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

* fix(client): guard the quick-switcher against a double-open

openQuickSwitch assigned its instance only after awaiting the profile
load, so a second click during that window mounted a second overlay and
orphaned the first. Every close affordance destroys only the tracked
instance, leaving a body-mounted position:fixed backdrop that blocks all
input until the app is reloaded. Adds the same `opening` flag the sibling
overlay controllers already use; audited every other opener in these
files and found no second instance of the race.

Also: loadOlderMessages and loadMessages now discard a response whose
window was replaced mid-fetch by a same-channel jump; the ArrowUp
edit-last-message scan skips unsent rows, matching the visual affordance;
unpinning from the pinned panel writes the store row; the pinned panel
forwards the channel it captured at open time rather than reading the
active one at click time; the reaction picker closes on channel teardown;
a non-voice channel switch dismisses the video grid; and destroy() closes
the settings overlay.

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

* fix(client): repair the status-picker stylesheet and a dozen UI defects

The .status-picker rules targeted a root element the component never
toggles, leaving the popup's own chrome unstyled and the root
display:none. Repointed at .status-picker-dropdown and dropped the dead
rules.

Component and store fixes, all test-first: the upload preview bar never
became visible so upload errors were invisible; replying while editing
left the edit text in the textarea; MessageList's load-older latch keyed
off a raw count so a live tail append refired the fetch; drag-reorder
renumbered channels into a 0..n-1 range instead of reusing the group's
own position slots; DM avatars bypassed the authenticated fetch path;
the member-list moderation gate read a mount-time role snapshot; mention
autocomplete offered usernames the mention grammar cannot express;
notifications titled DMs as "#channel"; the update-notifier catch
dereferenced a null banner; and the channel context menu leaked its node
on teardown.

Also resets authStore in member-list.test.ts's shared reset helper: one
test was leaving role="admin" set for every test after it, unnoticed
because no gate read authStore for role until now.

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

* fix(client): adopt the legacy identity key instead of re-minting one

Scoping the identity keypair by host and user id changed the keyring
account name, so every existing install would have found nothing at the
new account and generated a fresh identity key. Every peer who had
already pinned the old one would then see a TOFU mismatch, which raises
the re-pin modal telling the user to verify the safety number
out-of-band -- a MITM alarm fired at the whole alpha population at once,
which teaches people to click through the one warning meant to matter.

When the scoped account is empty, the legacy host-only account is now
adopted: saved under the scoped name, then the legacy account deleted.
Save happens before delete so a partial failure leaves the legacy key in
place for the next launch rather than stranding the user with neither.
A corrupt legacy blob falls through to fresh generation without throwing.

A second account on the same host still mints its own distinct keypair,
which was the point of the scoping fix.

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

* fix(client): surface server errors that had no dedicated handler

The WebSocket error handler bannered only RATE_LIMITED and FORBIDDEN, so
every other code that reached the fallthrough was dropped in silence --
a rejected chat_edit reported nothing at all while the optimistic
"Message edited" toast still fired. Every specific branch above already
returns, so the fallthrough sees only genuinely unhandled codes; it now
banners all of them.

Also:
- reattachToPresent cleared the detached flag eagerly, so a failed tail
  refetch let a live broadcast splice onto the stale around-window with a
  silent gap. The flag now survives until setMessages lands the tail.
- a mixed-case host and its lowercase-normalized URL form resolved to
  different cert-store pin keys; tofu::cert_store_key and ws.ts's
  normalizeHostForCertCompare both lowercase now. attachments.ts already
  did the right thing and is unchanged.
- clearAuth left the channels store populated for the next login.
- capabilities/default.json was missing
  core:window:allow-request-user-attention.

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

* fix(client): tear down video tiles, focus and the lightbox on leave

Four defects an earlier pass could not finish because each spanned two
files:

- closeVideoGrid only hid the grid, so remote video tiles survived a
  channel leave and reappeared on the next join. VideoGrid grew a
  clearStreams(), called from the real-leave branch of checkVideoMode
  (not the reconnect branch).
- the grid kept its focused-tile state across a close; setFocusedTile now
  accepts null and closeVideoGrid clears it.
- the per-user volume preference key had no host component, so volumes
  set on one server applied to a different user with the same id on
  another. Scoped via setAudioVolumeHost, mirroring channel-mutes.
- the media lightbox stayed mounted after MainPage.destroy().

Also repairs tests/unit/audio-elements.test.ts, which was missing an
afterEach import and failing to compile.

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

* fix(client): close eight defects a review found in this batch

Three of these are incomplete fixes from earlier commits on this branch --
the diagnosis landed, the cure stopped short.

- main.ts kept a hand-copied normalizeHostForCertCompare that never got the
  .toLowerCase() its ws.ts original and tofu::cert_store_key both have. Since
  the Rust side always emits the lowercased host and a profile stores it
  verbatim, any uppercase in the hostname broke all three guards -- worst of
  them the mismatch modal's onReject, which then skipped disconnect/clearAuth
  and left the user connected to the server whose certificate they had just
  refused. ws.ts now exports the one implementation and the copy is gone.
- the status-picker stylesheet repair repointed the root and deleted the old
  .status-option rules without adding replacements under the names the
  component emits, so the trigger dot -- a bare div whose only style is an
  inline background -- stayed 0x0, invisible and unclickable. The picker still
  could not be opened.
- ungateMic's re-open branch was unreachable in the one scenario its comment
  described: a PTT release routes through setMuted(true), so localMuted is
  always true there. It now takes the pttOwnsMute latch read *before* each
  call site resets it; reading the module flag from inside would always see
  false and move the bug rather than fix it.

The rest:

- dispatcher.ts statically imported @lib/screenShare, which has value imports
  from livekit-client -- dragging ~1.3 MB into the entry chunk that the file's
  own comment says is deliberately kept out of it. Now lazy, like every other
  voice call site here.
- replay detection compared payload.timestamp (server clock) against
  Date.now() (client clock). A self-hosted server without NTP made every live
  message after a reconnect look like a replay, silently killing notifications
  for the whole drift window. Both sides are now in server time via an
  observed skew estimate; latency biases it toward treat-as-live, which is the
  side that costs a duplicate rather than a dropped notification.
- identity.ts and livekitE2EE.ts each derived the keyring scope with `?? 0`.
  A missing user id would have adopted-and-deleted the real legacy key into a
  bogus host:0 account, then minted a second keypair under host:<realId> --
  published key and signing key permanently disagreeing, which is a false MITM
  warning for every peer. Unreachable today, irreversible if reached.
- per-user volumes were scoped by host with a legacy fallback that only fired
  when currentHost was null, which MainPage never leaves it as -- so every
  saved volume silently read as the default on upgrade. Reads now fall through
  to the unscoped key once and persist under the scoped one.
- a post-resync invalidate ran unconditionally while its refetch was guarded,
  so a missing getMessages left every window dropped with nothing to reload it.

A ninth finding -- that the DM reconcile could strand activeChannelId -- was
checked and rejected: the block 40 lines above already clears it whenever the
id is absent from both channels and dm_channels.

Two test-suite notes: livekit-session's announce-signing test was joining
voice with no authenticated user, which production does not permit, so it now
sets one (below PEER_ID, leaving key-holder election unchanged) and clears it
after. status-picker-userbar reads app.css from disk rather than `?raw`, which
vitest stubs to an empty string for stylesheets.

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

* fix(client): repair the e2e cert test and four defects found verifying it

The e2e suite caught one behavioural divergence from this branch, and
hand-verifying the hunt's flagged-but-unchecked items turned up four more
defects.

E2E:

- cert-tofu's "disconnect on mismatch returns to the connect page" emitted the
  mismatch for myserver.example:8443 while the session was authenticated
  against localhost:8443, so it asserted the pre-fix behaviour: a certificate
  rotating on ANY unrelated saved profile logs you out of the server you are
  using. That is the bug 8917c28 deliberately fixed. The test now emits for the
  live host, and a new sibling pins the guard itself -- a mismatch for another
  host must leave the session alone. Verified by defeating the guard: only the
  new test goes red, which is why the old one never noticed the change.

Defects found verifying the ledger's open items:

- logging out fired delete_credential fire-and-forget and then navigated to the
  connect page, whose auto-login immediately read the same account back. Since
  B4-3 moved the credential commands to #[tauri::command(async)] they no longer
  serialize on the IPC thread, so a read that wins that race signs the user
  straight back into the server they just left. Two fixes, because the race and
  the intent are separate problems: a CREDENTIAL_LOCK mutex restores the
  one-operation-at-a-time property that also keeps secret_store::set's
  read-modify-write atomic, and the connect page now skips auto-login once
  after a logout that removed the credential -- mirroring the quick-switch
  sessionStorage idiom already in that file. A server_shutdown logout keeps its
  credential and deliberately does not set the flag, so restart auto-login
  still works. e2e-pinned: with the suppression defeated, the user is visibly
  back in the app after clicking Log Out.

- a post-resync history refetch that REJECTED left the active channel's window
  already invalidated but never marked errored, so MessageList fell into its
  "no messages yet" welcome branch -- rendering a failed reload as a genuinely
  empty channel, with no Retry, until the user navigated away and back. Now
  calls setChannelLoadError, reusing MessageController's existing plumbing.

- an invite deep link arriving during the connected overlay's 800ms ready
  countdown hit a gate that assumed isAuthenticated implies the router is on
  "main". It is not: clearAuth() ran without the teardown that only the
  authStore subscriber performs (and only while on "main"), so the overlay's
  timer then mounted MainPage over a nulled-out auth state, and the invite was
  dropped. Gated on the real invariant and the in-flight session is now torn
  down explicitly.

- channel mutes carried the same dead legacy-preference fallback that per-user
  volumes had -- guarded on currentHost === null, which MainPage never leaves
  it as -- so every saved mute was silently discarded on upgrade. Mutes are a
  list, where an empty saved value is real data, so this needed a presence
  probe rather than the volume fix's sentinel.

Also extends the e2e Tauri mock with storedSettings/storedCredential seeds so
auto-login paths are exercisable at all.

Verified clean: 4800 vitest, 293 Playwright, 97 cargo, tsc, tsc -p e2e, eslint,
prettier, clippy -D warnings.

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

* fix(client): stop Tab escaping every modal, and a duplicate row after a resync

Two bugs left open by the previous round.

The "flaky" a11y focus-trap test was not flaky -- it was a real accessibility
defect surfacing nondeterministically. FOCUSABLE_SELECTOR is structural and
says nothing about visibility, but this codebase hides controls with inline
`style.display = "none"` (MemberPickerModal's group-name field and confirm
button both start hidden). So focusDialog() picked a display:none input as the
dialog's first focusable and called .focus() on it -- which browsers silently
refuse -- and focus never entered the dialog at all. trapFocus() then computed
first/last as those same hidden elements, so neither Tab branch ever matched
document.activeElement, preventDefault() never fired, and Tab fell through to
the browser's native order and walked straight out of the dialog. Whether the
test noticed depended on how much async sidebar content happened to be
focusable at that moment, which is what made it look intermittent.

Fixed in the shared helper rather than in the one modal that exposed it: about
forty call sites hide controls the same way, so every factory modal had the
same hole. trapFocus and focusDialog now filter out inline-hidden elements.
Reproduced first at 3/10 failures under --repeat-each; 10/10 after, and 20/20
at --workers=4. Note the check reads inline styles only -- an element hidden by
a CSS class would still slip through, which no current call site does.

Second: a message the server persisted but whose chat_send_ok ack was lost to
the same disconnect that forced a resync was displayed twice. The optimistic
row keeps id 0 until confirmSend stamps it, so setMessages' id-based carry-over
could never collide it with the real row, while addMessage had solved exactly
this for the live path by matching on content. Extracted that predicate as
isUnreconciledEcho and used it in both, so the two cannot drift apart.

The dangerous direction here is over-merging, not under-merging: collapsing two
genuinely distinct sends of the same text loses a real message. Three things
bound it -- only rows still awaiting reconciliation qualify (pending, or failed
for OFFLINE specifically, since a SLOW_MODE rejection is never broadcast and
eating that row would kill a live retry draft), author and content must both
match, and each snapshot row is consumed at most once, so N identical pending
sends pair off against N identical real rows instead of collapsing onto one.
Both directions are tested.

Verified: 4804 vitest, 293 Playwright with zero flaky, tsc, tsc -p e2e, eslint,
prettier.

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

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-08 17:56:43 +02:00
J3vbandClaude Opus 5 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 5630aa1

All six pages carried "Verified against ddc49f0 (2026-07-19)" stamps and had
drifted:

- websocket.md: delete the false claim that docs/protocol-schema.json does
  not exist — it is the codegen source of truth (Server/scripts/genprotocol,
  CI-gated by make protocol-verify); note the hand-declared plugin command
  family as the one exception; refresh LOC
- server.md: fix the websocket dependency (github.com/coder/websocket, not
  nhooyr.io), refresh LOC (42k/71k), migrations 016 -> 028
- data-model.md: migrations 001-028, 23 -> 26 tables, add api_tokens and
  channel_user_overrides to the ER diagram, channels.type now includes
  announcement, note 017/024/027/028 columns; drop the claim that schema.md
  is 6 migrations behind (it is current)
- voice-e2ee.md: drop the stale claim that the E2EE flow is absent from
  protocol.md (it has a full section); document livekitE2EE.ts/identity.ts
  and identity-key pinning
- client.md: rewrite — Solid beachhead is gone; the HTTP path is now
  TOFU-pinned through http_proxy.rs (the doc claimed the opposite); shared
  tofu.rs core with explicit-consent pinning; 9 stores (roles store deleted,
  blocks + emoji added); refreshed LOC and tooling figures
- README.md: 26 tables/001-028; client-architecture.md described as the
  redirect stub it is; companion-audit links refreshed

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

* docs(architecture/ux): align UI flow specs with current client behavior

- Cert first-use is a blocking trust modal on status "first_use" — the Rust
  proxy rejects the first connection until the user confirms (main.ts:146-176,
  tofu.rs); the specs described the pre-F4/F8 behavior (an 8s banner on
  "trusted_first_use"). Fixed in README.md and connection-and-auth.md.
- Dispatcher event table: add the five missing types (chat_bulk_deleted,
  roles_update, emoji_update, voice_moved, voice_disconnected) and note that
  call_incoming/call_declined are page-scoped listeners in MainPage.ts.
- channels-members-dms.md: the "no in-client block button" gap is closed
  (AdminActions.ts context-menu item -> SidebarMemberSection.ts:177-186);
  document group DMs (MemberPickerModal, 10-participant cap, rename/leave)
  and per-channel notification mutes (lib/channel-mutes.ts); refresh stale
  line anchors.
- voice-and-e2ee.md: document the actual E2EE verification surface (roster
  shield badge -> identity-mismatch modal -> rePinPeerIdentity with TOCTOU-
  safe key capture), noise suppression + fallback, device hot-swap, stream
  preview, and DM ring/incoming-call flow; drop the nonexistent
  VoiceChannel.ts reference.
- settings-and-admin.md: the "ban should collect a reason" gap is closed
  (appendBanFlow with reason + duration); document the admin-panel deep-link
  (lib/admin-panel.ts) and the tray status menu.
- messaging.md: correct the pinned-messages empty-state copy and drop the
  nonexistent components/message-input/ directory reference.
- Re-stamp all six specs "Verified against 5630aa1 (2026-08-04)".

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

* docs: refresh client-facing and top-level docs

- security.md: drop the stale "hardcoded Tenor API key" limitation (the GIF
  provider is Klipy, proxied server-side with an operator-supplied key —
  nothing ships in the client bundle); describe credential storage accurately
  (OS keyring primary, verified writes, DPAPI/ChaCha20 file fallback);
  complete the audit-log action list against the actual WriteAudit call sites
  and note that backup restore is not audit-logged; fix the firewall
  checklist to include the LiveKit media ports (7880-7881/TCP,
  50000-60000/UDP) and ACME port 80
- credential-storage.md: probe_credential_store sample now shows the real
  serialized backend value ("Keyring") and the full variant union
- quick-start.md + README.md: refresh build version strings to
  1.2.0-alpha.1; README "audits" section now points at the current audit
  documents
- contributing.md: sqlc rows no longer claim a PostgreSQL engine/pgdbgen
  (removed with the store layer); add the protocol-generate/verify targets

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

* docs(plans): add verified status headers and fix stale references

Every plan under docs/plans/ now carries a dated status verified against
5630aa1:

- Shipped: audit-2026-07-19-decisions (all 13 rows), channel-visibility-
  unification, http-tofu-proxy, permission-middleware-consolidation (the
  disclosed ws.channelCanSend copy is still open, now at serve_ready.go:119),
  security-hardening-remediation, sqlc-adoption, v2-dispatch-migration,
  tauri-capability-narrowing (DNS-rebinding follow-up still open)
- Shipped with corrections: discord-parity — Phase 1's gap table was never
  re-marked; all six rows have since shipped, including archived channels,
  which are filtered by permissions.VisibleChannelIDs (checker.go:116-121);
  named leftovers (role hoist/mentionable, @RoleName mentions, categories as
  entities, dead-code list) stay open. security-scan-2026-07-22 — all 8
  findings closed; two of the four F3 follow-ups have since shipped (safety
  number rendered in the roster badge; rePinPeerIdentity wired to the
  identity-mismatch modal), getIdentityPin fail-open remains open; noted the
  scan artifact directory is not in the repo
- Design-only: slash-commands — added staleness notes (migration number 016
  now taken, Server/store/ deleted, src/state/ never existed)

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

* docs(audits): reconcile prior audit statuses with current CI and code

- audit-test-coverage-2026-07-25: T-2026-07-25-21 (HIGH, 229/255 web e2e
  failing) was fixed by the mock repair but the audit was never updated —
  now RESOLVED, re-verified by a local 270/270 run at 5630aa1; the CI gate
  table row updated to match
- audit-2026-07-19: carried-over item 11 ("no Playwright job in ci.yml") is
  resolved — client-e2e (non-blocking, every PR) and the blocking
  client-e2e-parity job both exist; backlog item 10 marked DONE
  (client-tests is blocking, Playwright wired)

Only status/closure cells were edited; original finding text is untouched.

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

* docs(changelog): add Unreleased section for post-v1.2.0-alpha.1 fixes

Three fixes landed after the release with no changelog home (the file had no
Unreleased section at all): the profile-popup styling fix (a308f81), the
vite/src-tauri watch fix (cdcfc03), and the AppImage env-key signing fix
(9d75890). Also corrects the Deferred-work note that still described the
Solid.js removal in the present progressive — it completed 2026-07-19.

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

* docs(tests): rewrite the e2e issues log against a real suite run

The old file was dated 2026-03-18, claimed 209/209 passing (the suite is now
270 tests), and pointed at a plan document that does not exist in the repo.
Rewritten from an actual run at 5630aa1: 270/270 web tests green (8.6 min),
15/15 @parity subset green (the blocking CI job), with the suite inventory,
CI wiring, the two real open issues (three native specs matched by no
playwright.config.native.ts project; client-e2e still non-blocking), and
dispositions for every claim the old file carried.

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

* docs(audit): add 2026-08-04 docs-and-coverage audit report

Companion to the same-day security review (disjoint scope). Contains: the
verified architecture summary; real test-run results for every runnable
suite at 5630aa1 (Go race+deadlock, 4394 client unit tests at 94.66% stmt
coverage, 83 Rust tests + clippy, 270/270 web e2e, 15/15 parity, browser
smoke — with env-blocked suites named and their compensating CI evidence
cited); a 52-row UI/UX flow coverage matrix (30 covered / 21 partial /
1 untested / 0 broken, headline gaps: TOFU flow, E2EE verification, admin
panel, updater — all unit-only); per-doc drift findings with the commit that
fixed each; reconciliation of all four prior audits and eleven plans
(including the orphaned 2026-04-07 #8 resurfaced as DC-11); the dead-code
and TODO inventories; and a prioritized DC-01..DC-15 gap list with ordered
next steps.

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

* fix(admin): add hierarchy guard to channel role-override delete (A-2026-08-01)

Deleting an override is a permission mutation: removing a deny row restores
exactly the access the PUT path refuses to grant, so a MANAGE_CHANNELS holder
could unlock a private channel their own role was locked out of. Gate DELETE
identically to handlePutChannelPermission: resolve the role (404 when
missing), fail closed without an actor role, and refuse targets at or above
the actor's position.

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

* fix(admin): exclude DM channels from the admin channel surface (A-2026-08-02)

DMs and group DMs share the channels table and id space with guild channels,
but they belong to their participants, not to MANAGE_CHANNELS holders:
listing exposed ids and group names of every private conversation, PATCH
could silently rename one, and DELETE cascade-destroyed one irreversibly.
List now filters type=dm; PATCH and DELETE resolve through getAdminChannel,
which answers 404 for DM ids so the surface does not confirm which ids are
private conversations.

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

* fix(service): enforce blocks on DM call rings (A-2026-08-03)

RingTargets checked participation but not blocks, so a blocked user could
still make the blocker's client ring. Route rings through
requireDMNotBlocked like every other DM sink; group DMs stay exempt inside
it, matching the send path (blocks are enforced at group creation instead).

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

* chore(client): delete dead modules (DC audit remediation)

All verified unreferenced by any import before deletion:
- ServerStrip.ts: removed from the layout when the unified sidebar header
  landed (SidebarArea.ts); only its own orphaned unit test still used it.
  The e2e spec already asserted .unified-sidebar-header, so it is renamed
  to sidebar-header.spec.ts and retitled honestly.
- FileUpload.ts: uploads go through api.uploadFile from MessageInput.
- lib/reconcile.ts: nothing imports it; the messages store carries its own
  pending-send reconciliation.
- public/rnnoise-worklet.ts: unreferenced duplicate of the .js worklet the
  runtime actually loads, and public/ ships verbatim into the bundle.
- api.getSounds/deleteSound + SoundResponse: the server has no /sounds
  routes; these called endpoints that do not exist (pairs with the
  sounds-table drop on the server side).
- dm.store incrementDmMention: zero callers; DM mention counts flow from
  the server mention_count via the dispatcher. This was the one live knip
  error the CI '|| true' was masking.

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

* chore(client): retire the tauri-typegen ritual (DC-05)

src/generated/ was tauri-typegen output frozen on 2026-04-03: it covered 21
of the 29 IPC commands lib.rs registers, nothing ever imported it (0%
coverage), and CI carried a bespoke patch step solely to keep the unused
file lint-clean. Delete the directory and every part of the pipeline that
existed to feed it: the client-check patch step, the tauri-build
generate/patch steps, the tauri.conf.json plugin block, and the inert
Cargo.toml build-dependency (build.rs is bare tauri_build::build(); no Rust
source references the crate). Cargo.lock shrinks by exactly the typegen
subtree — no other resolution changes.

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

* ci: make knip blocking (DC-06 follow-through)

Pre-verified green locally after the dead-module deletions; the config
hints knip still prints do not affect its exit code.

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

* chore(server): drop the dead sounds table (A-2026-07-13)

The table shipped in 001 for a soundboard that was never built: no query,
model, sqlc definition, route or handler ever referenced it. Migration 029
drops it; the sqlc model regenerates without the Sound struct (sqlc emits a
struct per schema table even with zero queries). schema.md, the data-model
blueprint, and the 2026-07-19 audit closure table are updated in the same
change per the docs maintenance rule.

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

* chore(server): remove dead WAF wrapper, use protocol constants, fix stale comments

- NewWAFMiddleware had no production caller (the router mounts the CRS
  variant); its doc text folds into NewWAFMiddlewareCRS and the tests call
  the survivor directly.
- serve_auth compares against MsgTypeAuth and the DM-close REST path builds
  its WS notification from MsgTypeDMChannelClose instead of restating the
  wire strings, so the generated constants are load-bearing again.
- Comment fixes: DatabaseConfig no longer claims Postgres scaffolding that
  main.go removed; host_ui.go no longer advertises a route that is not
  mounted (DC-09's sibling); buildReady cites docs/protocol.md, the file
  PROTOCOL.md was renamed to (DC-09).

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

* fix(protocol): add the plugin command family to protocol-schema.json (DC-01)

chat_command, command_reply and plugin_broadcast were the only wire types
outside the schema: the first declared by hand in handlers_command.go, the
other two raw string literals, all bypassing the protocol-verify codegen
gate. Add the three schema entries (27 c2s / 39 s2c), regenerate both
constant files, and swap the hand-rolled declarations for the generated
constants. The ws protocol-contract test's exception list is empty now —
and stays that way.

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

* test(client): wire orphaned native specs and typecheck the Playwright layer

- dm-system, reconnection and theme-persistence (14 tests) matched no
  project's testMatch in playwright.config.native.ts, so they had never
  executed (E2E-ISSUES open issue #1 / DC-03). All three use the persistent
  fixture + ensureLoggedIn, so they join native-authenticated.
- tests/e2e was excluded from tsconfig, leaving 47 spec files with no
  typechecking anywhere. New tsconfig.e2e.json project (+@types/node for
  the node-API fixtures), a typecheck:e2e script, and a CI step. The one
  real error it surfaced is fixed: mockTotpFailure omitted the required
  simulateWsFlow flag.

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

* test(client): cover createPromptModal and the external-abort close path

modalFactory.ts was the least-covered file in the repo (57.6%):
createPromptModal had no tests at all and createModal's external-abort
branch never ran with an onClose. Now 100% statements/branches/functions,
including the trimmed-submit, legitimate-empty-submit, Enter-preventDefault
and no-double-close contracts.

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

* test(client): e2e-cover the TOFU certificate ceremony (DC-04 slice)

The first-use confirmation and mismatch warning are the client's core
security ceremony and had no e2e coverage. Six tests drive them through
the mocked Tauri event layer: first-use modal content, trust, cancel,
modal non-stacking, mismatch fingerprint rows, and disconnect-to-connect-
page. The mock now exposes its listener registry so tests can wait for
the async cert-tofu registration instead of racing it (validated with
--repeat-each=3).

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

* docs: fix the inaccuracies the 2026-08-04 refresh missed

- contributing.md: drop the '-tags postgres' build row (no such tag exists
  anywhere in Server/), add the four Make targets the 07-25 audit created
  (test/test-deadlock/cover/cover-all), align the coverage statement with
  the real gates (client 70%, no Go floor by T-2026-07-25-19), point TS
  style at architecture/client.md instead of the tombstone, and describe
  the real dev-branch PR flow.
- docs/security.md: reporting section now defers to root SECURITY.md as
  the canonical policy (it said 48h where SECURITY.md promises 7 days, and
  described the maintainer's advisory path rather than the reporter's);
  fixed the updater-key link that resolved to docs/Server/... on GitHub.
- audit-2026-04-07.md closure table: #10 and #11 were long-resolved (#10
  verified in db/audit.go, #11 exceeded by per-PR e2e jobs), #6 written in
  future tense for work done 2026-07-19, #7 citing a 113-file count from
  months ago.
- README: Contributing section matched neither ci.yml nor contributing.md
  (branch from dev, not main); Docs Index gains the six missing live docs;
  the plugin system joins the feature list; the security row no longer
  anchors to an aging version string.
- server-configuration.md: the env-var table is explicitly a subset — the
  OWNCORD_<SECTION>_<KEY> scheme covers every key.
- mcp-introspect.md: index.mjs is 266 lines, not ~230.
- types.ts header cited PROTOCOL.md/API.md/SCHEMA.md, filenames that no
  longer exist.

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

* ci: pin claude.yml actions by SHA; add docs checkbox to the PR template

claude.yml was the only workflow with unpinned third-party actions —
checkout now uses the same v4.2.2 SHA the other workflows pin, and
claude-code-action pins the commit the v1 tag resolves to (Dependabot's
github-actions ecosystem keeps both fresh).

The PR template gains the docs checkbox A-2026-07-03 recommended: the
architecture/UX maintenance rule ('a PR changing a diagram's source-of-
truth files updates the diagram in the same PR') existed only as prose no
process step ever surfaced.

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

* style(client): prettier-format the cert-tofu spec

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

* docs(audit): record the remediation pass and close finding statuses

- Security review: A-2026-08-01/02/03 -> RESOLVED with their pinning tests
  named.
- Docs-and-coverage audit: DC statuses updated in place (01/02/03/05
  resolved, 04/09 partial with the remainder named, 14's keep-decision
  recorded) and a remediation addendum added: what shipped, the decisions
  taken (plugin host API kept, reserved protocol entries kept, e2e soak not
  shortcut, the 404-on-missing-role semantics note), and the full
  verification table from real runs — Go race + deadlock suites green,
  4 tag builds, client 4360/4360 units at 95.35% coverage, Playwright
  276/276 in 8.9 min, parity 15/15.
- CHANGELOG Unreleased: security fixes, migration 029, protocol additions,
  dead-code retirement, CI gates.
- E2E-ISSUES: rewritten against the remediation HEAD (276/276), native
  orphan issue moved to resolved, mock listener-registry note.

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

* fix(admin): index the channel slice in the DM filter (gocritic rangeValCopy)

golangci-lint (CI-only gate) flags the range-value copy of the 152-byte
db.Channel struct in the admin list filter.

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

* chore(client): add .nvmrc pinning Node 20 to match CI (DC-10)

Also re-triggers CI: the previous run's windows server job died to a Go
runtime unwinder fatal ('traceback did not unwind completely') with no
test failure — toolchain flake, and the integration lacks permission to
rerun failed jobs.

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

---------

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

* fix: 26 bug-hunt findings across WS hub, voice/E2EE, admin, roles, and client (runs 1-3) (#1328)

* fix(ws): keep pubsub subscriptions when a replaced client is unsubscribed

Both pubsub indexes are keyed by userID, but a reconnect registers a new
*Client under that same userID. UnsubscribeAll and Unsubscribe deleted by
userID alone, so a kick of the already-replaced connection stripped the live
one's topics. The live client stays in h.clients and keeps answering
ping/pong, so it never reconnects -- it just silently stops receiving every
global, user, and channel broadcast.

Guard the forward-index delete in unsubscribeLocked with an identity check and
route UnsubscribeAll through it, so the four Unsubscribe call sites
(voice_leave, hub_broadcast x2, handlers) and the three UnsubscribeAll ones
(kickClient, unregisterNow, registerNow) all share one rule.

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

* fix(ws): mark kicked clients offline instead of reporting them replaced

Every kick path deletes the hub entry via kickClient, so the readPump defer's
unregisterNow finds nothing and fell through to "return true", conflating
absent with replaced. serve_pumps.go then skipped MarkUserDisconnected, the
offline presence broadcast, and handleVoiceLeave -- already-connected peers
rendered every kicked user as online until that user reconnected and
disconnected cleanly.

Return exists instead: a different client in the slot is a genuine
replacement, an absent entry is a real disconnect. Only serve_pumps.go reads
the return value; the five serve.go/hub.go call sites discard it.

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

* fix(ws): force a full ready when cold-tier replay hits the row cap

GetEventsSinceForChannels is "ORDER BY seq ASC LIMIT n", so a reconnect gap
larger than maxColdReplay returned the oldest 5000 rows and dropped the
newest. handleReconnect accepted any non-empty result as a successful resume,
and the client only tracks max(seq) with no gap detection -- so it accepted
the next live event and silently lost the range in between, including state
events (channel/role/member changes) that REST history fetches never repair.

Treat a result at the cap as overflow and fall through to the full ready
re-sync. An exactly-cap-remaining gap pays one unnecessary full ready.

maxColdReplay is hoisted to the package const block so the test can seed
exactly enough events to hit it.

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

* fix(ws): re-elect the voice E2EE key holder on the two paths that skipped it

updateKeyHolder had only two callers (voice_join, voice_leave), so two paths
that remove a participant from voice left voiceKeyHolders naming someone who
is gone. IsVoiceKeyHolder then rejects the real lowest-uid participant's rekey
offers with NOT_KEY_HOLDER -- which the client does not handle -- after it has
already applied its rotated key locally, splitting keys across the room.

1. The LiveKit participant_left webhook (media-only loss, WS stays up) cleared
   voice state and broadcast voice_leave with no re-election.
2. registerNow's fresh-connect replacement (F5 reload) drops the old
   connection's voice state without transferring it. handleVoiceLeave never
   runs there: readPump skips it when replaced, and it early-returns on
   already-cleared state.

Both call updateKeyHolder outside h.mu, since it takes keyHolderMu then
h.mu.RLock. The recompute reads live client voice state, so it is idempotent
and stays correct when a network reconnect transfers voice state -- locked by
TestRegisterNow_KeepsKeyHolderWhenVoiceStateTransfers.

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

* fix(client): stand down as E2EE key holder on offer, keep peer keys on reconnect

Two independent key-holder desync bugs in E2EEManager:

1. _isKeyHolder had no demotion path -- set at join, promoted on participant
   leave, cleared only on voice leave. The server re-elects the lowest userID
   on every join, so a lower-ID joiner left the incumbent still believing it
   held the key with an armed 5-minute timer. Its rotations applied the new
   key locally before the server rejected the offers with NOT_KEY_HOLDER (which
   the client does not handle), so it went deaf and mute every rotation cycle.
   Accepting an offer proves the sender is the server-authoritative holder, so
   treat it as the demotion signal and clear the timer.

2. reannounceForReconnect cleared _peerPublicKeys and peer verifications with
   nothing able to refill them: handleAnnounce replies with an offer rather
   than a counter-announce, and the server relays stored peer keys only on
   voice_join, which an SFU-level reconnect never runs. handleOffer's
   unknown-peer guard then dropped every later rotation, stranding the
   reconnector on the pre-reconnect key. The clear was also unnecessary --
   peers' keys stay valid when we regenerate our own pair.

vitest 4396/4396; typecheck and prettier clean.

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

* fix(tauri): give the ws sender slot connection ownership, bound livekit TLS

ws_proxy: state.tx is one slot shared by every ws_connect, and both the
install and the teardown clear were unconditional while the mutex was only
held in short scoped blocks. A handshake pends up to CONNECT_TIMEOUT and a
profile switch starts a second ws_connect without awaiting or cancelling the
first, so a stale connect could complete after a newer one was live, emit an
untagged "open", and install its sender over the live one -- routing the next
auth send to the previously-trusted host, then tearing down the live socket
and emitting "closed" while JS believed it was connected.

Add a generation counter claimed at ws_connect entry and checked under the
slot lock before install, plus same_channel ownership on the teardown clear,
mirroring the Arc::ptr_eq guard ptt.rs already uses for ATOMICRACE-001.

livekit_proxy: the outbound TcpStream::connect and TLS handshake were bare
awaits, while the sibling http_proxy.rs bounds both at 10s. TCP connect is
OS-bounded, but a peer that accepts TCP and never answers the ClientHello
blocked the task forever. The task holds `local` without polling it, so the
SDK closing its side never cancels it, and the detached per-connection tasks
survive stop_livekit_proxy -- so they leaked on every SDK retry.

cargo test 80 passed; clippy --all-targets -D warnings clean.

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

* fix(ws): keep voice E2EE alive across WS resume and unify voice teardown

A network reconnect transferred voiceChID/joinToken to the new connection
but left it unsubscribed from voice:<id> (the only transport for
voice_e2ee_announce relays) and wiped the announced ECDH key, so a
resumed key holder could never offer the room key to later joiners and
voice_join replayed nothing for the resumed user. registerNow now
transfers the announced key with the voice state and re-subscribes
VoiceTopic unconditionally (it is CONNECT_VOICE-gated at join; only the
message-stream ChannelTopic needs the READ gate).

The LiveKit participant_left webhook and CleanupVoiceForChannel cleared
voice state without dropping the voice-topic subscription, leaving the
socket receiving another room's announces (which carry no channel_id to
filter on) for its lifetime. All take-out-of-voice paths now go through
one clearVoiceAndUnsubscribe helper.

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

* fix(ws): route sequenced DMs through the normal FIFO

writePump drains sendHigh to exhaustion before send, so a seq-stamped DM
on the high queue reached the socket ahead of lower-seq events still
queued behind a slow write. The client acks max(seq) and replay is
strictly seq > last_seq, so a disconnect in that window silently and
permanently lost the overtaken events while auth_ok reported a clean
resume. Sequenced frames now share the one per-client FIFO; the high
queue remains for unsequenced targeted messages (DM opens, voice tokens).

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

* fix(storage): remove the partial file when Save fails after create

The io.Copy and f.Sync error paths returned without deleting the file
created for the upload, and the orphan sweep is DB-row-driven, so a
write-side failure (ENOSPC, disk I/O error) permanently leaked a partial
storage/<uuid> with no DB row. One success-flag deferred cleanup now
covers every failure path (the oversize branch folds into it), fixing
all three callers.

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

* fix(client): close three voice-E2EE ordering gaps and wire the DM mention badge

- setupKeyExchange generated the room key AFTER draining queued
  announces, so a key holder joining an ongoing call sent drained peers
  no offer — they waited on the 5-minute rotation timer. Keygen now
  precedes the drain and every drained peer gets its offer immediately.
- A key-holder re-election arriving while the elected client was still
  connecting was dropped (getCurrentChannelId is null for the whole
  key-exchange wait), stranding the client until timeout ejection. The
  manager now remembers its channel from setupKeyExchange, and the
  become-holder rotation resolves a pending room-key wait.
- Offers applied concurrently could finish out of order (no epoch on the
  receiver side), leaving the older key active. handleOffer now chains
  applications so offers apply strictly in WS delivery order.
- incrementDmMention had zero callers: the DM @mention badge (dmStore's
  mentionCount, the mute-immune signal DmSidebar renders) never fired
  live, only after a reconnect restored the server count. The dispatcher
  now bumps it under the same guards as the DM unread count.

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

* fix(tauri): restart the LiveKit proxy when the TOFU pin changes

After the user accepted a rotated cert, two stale caches kept every
voice rejoin tunneling into the old pin until logout: the Rust reuse
branch returned the running listener (which bakes its fingerprint in at
spawn) without re-reading certs.json, and ensureLiveKitProxy's port
cache never invoked Rust again at all. start_livekit_proxy now loads the
stored fingerprint before the reuse check and tears down on host OR pin
change, and the TS side invokes it on every join — the reuse branch
dedups the unchanged case.

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

* chore(tauri): ignore two unreachable cargo-audit advisories

RUSTSEC-2024-0429 (glib 0.18, Linux-only, Variant::array_iter_str never
called; no semver-compatible fix exists) and RUSTSEC-2026-0097 (rand 0.7
as a phf_generator build-dep with a fixed seed and no log feature; the
pre-release kuchikiki pin blocks the upgrade path). Both entries document
their drop condition inline.

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

* fix(ws): transfer the focused channel on WS resume so the message stream survives

registerNow's replaced-client branch moved voice state and the E2EE key to
the resumed connection but not the focused channel; newClient always starts
with channelID == 0 and the client never re-sends channel_focus on a resume,
so the ChannelTopic re-subscribe was a no-op and the user silently stopped
receiving chat_message until manually switching channels. Transfer the old
connection's focused channel, READ-gated and fail-closed like every other
ChannelTopic subscription.

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

* fix(ws): always include a voice room's participants in its voice-event audience

broadcastVoiceEvent filtered recipients on READ_MESSAGES while voice
membership is gated on CONNECT_VOICE alone, so a participant in the gap
(e.g. READ revoked mid-call by a channel override) never received the
room's voice_state/voice_leave. The client's E2EE key-holder election and
forward-secrecy rotation run only off the voice_leave WS event, so a
departing key holder was never replaced and new joiners hung until the
e2ee_timeout eject. Union the READ audience with the room's current
participants; what outsiders may observe is unchanged.

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

* fix(client): tear down the failed reconnect attempt's room instead of leaking it

The catch block read this._room, whose typed accessor returns null in the
"reconnecting" state — so the failed attempt's freshly created Room was
never disconnected and kept all its listeners. livekit-client emits
Disconnected synchronously on a failed connect, and in "reconnecting"
state the token/channel/url getters all return values, so each leaked room
spawned an additional concurrent reconnect loop whose AbortController was
discarded and unreachable from leaveVoice. Alias the attempt's room outside
the try and clean it up in the catch, mirroring cleanupAbortedReconnect.

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

* fix(admin): evict voice participants before deleting a channel

CleanupVoiceForChannel was doc-commented 'Called when a channel is
deleted' but had zero production callers, and the voice_states FK cascade
wipes the rows it reads — so deleting a voice channel stranded its
participants with live client voice state, a voice-topic subscription, and
a LiveKit session, and the stale sweeper could never recover them (a
nonexistent channel resolves base-role permission bits). Wire the cleanup
into handleDeleteChannel BEFORE the row delete, via HubBroadcaster.

Also harden the cleanup itself: the row delete and client-state clear are
now conditional on the participant still being in the deleted channel, so
a user who moved rooms mid-cleanup is untouched, and the evicted
participants are always included in their own voice_leave audience (their
client state is already cleared, so the participant union in
broadcastVoiceEvent cannot see them).

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

* fix(client): stop leaking an E2EE worker and SetKey listener per voice join

createRoom spun up a fresh E2EE Worker per Room while the key provider
lives for the whole process; livekit's per-room E2EEManager registers a
SetKey listener on the provider with no matching removal and never
terminates the worker. Every join, channel switch, or failed reconnect
attempt therefore permanently added one running worker plus one listener,
and every later setKey posted the new room key into every orphaned worker
— key material outliving its session. Track the worker on the session:
clear provider listeners and terminate the stale worker before each Room,
and terminate it in leaveVoice so the last key does not stay resident.

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

* fix(service): reject role position collisions on update, matching create

UpdateRole's position branch ran only validatePosition and let an explicit
position land on a slot another role holds — while CreateRole refuses
exactly that, with a comment explaining why: every hierarchy comparison
uses >=/<=, so tied positions read as equal rank and the two roles can no
longer manage each other's members. Refuse a position held by a different
role with the same ErrBadRequest; re-stating the role's own position stays
allowed.

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

* fix(ws): close the window where a dying connection re-takes a pubsub topic

Subscribe had no counterpart to unsubscribeLocked's identity guard: an old
connection's in-flight handler (a channel_focus mid DB round-trip shares no
lock with registerNow) could Subscribe after UnsubscribeAll(old) had run,
stealing the topic from its replacement — whose own unsubscribes then skip
the entry while publishes go to the closed connection. Subscribe now
refuses a client whose send is closed (checked under ps.mu), and
registerNow closes the old client's send BEFORE stripping it, so a late
Subscribe either sees the closed send and is refused or slipped in earlier
and is removed by the subsequent UnsubscribeAll.

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

* refactor(ws): rewrite cold-replay if-else chain as switch (gocritic)

Fixes the ifElseChain lint failure on CI for both platforms.

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

* fix(client): restore incrementDmMention deleted as dead code on dev

The audit PR (#1327) removed it from dm.store.ts because its only caller
lives on this branch (the DM mention badge wiring), which was not merged
yet. The rebase was textually clean but left dispatcher.ts calling a
function that no longer existed.

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

---------

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

* Audit closure pass: DC-04/06/08/09/12/13/15 (E2EE + updater e2e, fail-closed pin lookup, a11y pass, UX polish) (#1329)

* ci(server): run the tag-gated wazero/otel Go tests (DC-06)

The build-tag matrix only compiled the otel/wazero variants; the tests
behind those tags (plugin/sandbox_wazero_test.go 462 lines,
telemetry/telemetry_otel_test.go 214 lines) ran nowhere since they were
written (T-2026-07-25-16). Scoped to the two packages that carry tagged
files; verified green locally before wiring:
go test -tags wazero ./plugin/... and -tags otel ./telemetry/... both pass.

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

* fix(client): fail closed when the identity pin store is unreadable (DC-08)

getIdentityPin collapsed a keyring read error into "no pin stored", so a
transient failure sent a pinned peer down the TOFU first-sight path:
verifyPeerAnnounce verified against the server-delivered key and then
RE-PINNED it — a fail-open a malicious server could exploit by inducing
store errors (F3 follow-up 3, plans/security-scan-2026-07-22).

getIdentityPin now returns a three-state IdentityPinLookup
(pinned/unpinned/unavailable), mirroring how tofu.rs keeps Err distinct
from Ok(None) first-use. verifyPeerAnnounce rejects the announce on
"unavailable" without any pin write, records the new "unknown"
PeerVerification status, and the roster badge renders it as an amber
shield-question ("could not check") distinct from the legacy
"unverified" state.

Pinned by unit tests: pin present, no pin, store error (identity.ts),
the fail-closed rejection path (livekit-session), and the badge
presentation (channel-sidebar).

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

* feat(admin): write a backup_restore audit row that survives the restore (DC-09)

Backup restore was the one admin mutation with no audit_log row —
docs/security.md documented the gap as inherent ("the database is closed
as part of the restore"). The row IS writable durably: written
synchronously (LogAudit, deliberately not the async WriteAudit fast path)
before BackupTo takes the pre-restore safety copy, it is captured inside
pre_restore_*.db and survives the file swap forensically.

The extended restore test opens the pre-restore backup as a database and
asserts the backup_restore row is inside it — proving both the write and
its ordering. docs/security.md now documents where the row lives instead
of the gap.

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

* feat(client): channel-delete toast + optimistic reaction toggle (DC-12)

Two messaging-surface gaps the UX specs carried as open:

- channel_delete on the active channel now toasts "This channel was
  deleted" alongside the existing redirect (ux/channels-members-dms
  §1.2) — the redirect alone read as the app spontaneously changing
  channels. Non-active deletions stay silent.

- Reactions toggle optimistically (ux/messaging §5): the pill flips on
  the click, registered under the send's WS envelope id — the same
  correlation scheme as the optimistic message rows. updateReaction
  consumes the matching self-echo instead of re-applying it (the
  delta-based arithmetic would double-count), other users' echoes apply
  normally, and an error reply or local transport failure rolls back
  exactly that toggle via rollbackReaction in the dispatcher's error and
  send-failure handlers. The pill reverting is the failure feedback.

Both spec gap notes flipped to implemented; the stale §2 note claiming
the ready payload lacks slow_mode fell in the same edit.

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

* fix(client): guard the role-change submenu against double-fire (DC-12)

Every other destructive admin action already carried an in-flight guard
(withConfirmation, unblockRunning, banRunning, purge) — the role-change
submenu was the residual: currentRole only updates when the
member_update echoes, so a double-click (or a second option clicked
while the first PATCH was in flight) fired onChangeRole twice. One
shared guard now inerts the whole submenu while a change is running,
with the pending class on the clicked option.

The settings-and-admin spec's in-flight gap note flips to implemented,
and the stale messaging §8 slow-mode note is corrected in the same
docs sweep (the countdown shipped with the ready payload's slow_mode —
verified against ChannelController.startSlowMode and its tests).

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

* fix(client): drag-reorder document listeners leak — own them per sidebar signal (DC-12)

The shared document mousemove/mouseup pair was reference-counted per
attached channel row: a sidebar with N channels took N refs (and N more
per re-render) while its single destroy returned exactly one, so the
count never reached zero and the listeners plus their activeDrag
closure lived for the rest of the process — the KNOWN BUG the
drag-reorder test pinned since the 2026-07-25 audit.

Ownership is now a Set of AbortSignals (the sidebar's lifetime
controller — the @lib/disposable teardown idiom): acquisition is
idempotent per signal no matter how many rows attach, release is the
signal's abort, and an owner aborted mid-drag clears the in-flight
visual state (which the old containerEl comparison never actually
matched in production — it compared the category container against
channelList). releaseGlobalDragListeners is gone; ChannelSidebar's
destroy releases via its existing ac.abort().

The pinning test changes with the fix, as its own comment instructed;
the lifecycle block now pins the fixed contract, including
re-registration after full teardown.

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

* test(e2e): E2EE identity-verification and updater journeys (DC-04)

The two remaining client-side headline coverage gaps from the 2026-08-04
audit's flow matrix (rows 38 and 49), both driven through the web mock
harness:

voice-e2ee-verify.spec.ts (6 tests, ux/voice-and-e2ee §7): the peer's
announce is real crypto — an ECDSA P-256 identity key signing an ECDH
ephemeral key exactly as e2eeCrypto does — so the badge states come out
of the production verification path. Covers the verified badge with
safety number (+ TOFU pin on first sight), the legacy unverified badge,
the mismatch block, the mismatch modal's reject path (peer stays
blocked, nothing pinned) and Trust New Key (re-pins the displayed key),
and the DC-08 fail-closed 'could not check' badge when the pin store is
unreadable. The harness gap that kept this untestable is closed by a
voice_join handler that grants a key-holder voice_token plus a WebSocket
shim that parks LiveKit's room.connect forever, holding the session
stably in 'securing'.

updater.spec.ts (4 tests, ux/settings-and-admin §5): no-update silence,
banner + Later dismissal, the full banner → download progress (% and MB
fallback via real update-progress events) → automatic relaunch journey,
and the failure state with Dismiss. There is no restart prompt by design
— the applied state IS the relaunch, asserted via the recorded
plugin:process|restart invoke.

Harness: buildTauriMockScript gains per-test identity-pin config
(identityPins / identityPinError) and a window.__invokeLog recording
every IPC call so tests can assert side effects with no DOM footprint.

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

* style(client): prettier-format the drag-reorder module and new e2e specs

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

* feat(client): accessibility pass over the modal/overlay stack (DC-13)

The repo had exactly one focus-trapped dialog (UserProfilePopup), one
aria-live region, and no role="dialog" anywhere else. This pass
generalizes that one good implementation into lib/a11y.ts
(applyDialogSemantics, trapFocus, focusDialog, setRovingTabindex,
enableRovingNavigation) and applies it across the stack — all additive:
no DOM classes, testids, structure, or visible text changed.

- modalFactory: every factory modal now carries role=dialog + aria-modal,
  moves focus in on open, restores it on every close path, and Tab-cycles
  inside; createPromptModal is labelled by its title.
- Hand-rolled modals (CertMismatch/CertFirstUse/IdentityMismatch,
  Create/Edit/DeleteChannel, InviteManager): dialog semantics labelled by
  their existing headings, focus trap + restore, aria-label on icon-only
  close buttons, and Escape mapped to each modal's SAFE action (reject on
  the trust prompts — dismissal must never grant trust; cancel on the
  channel modals — never the destructive/submit callback).
- SettingsOverlay: dialog on the panel, focus in on open/restore on
  close; the sidebar is a vertical role=tablist with roving tabindex and
  ArrowUp/Down/Home/End activate-on-focus; the content pane is a
  tabpanel labelled by the active tab.
- QuickSwitcher: dialog + combobox/listbox/option wiring with
  aria-activedescendant tracking the active row. QuickSwitchOverlay:
  dialog + keyboard-operable rows (the inert current-server row stays
  unfocusable on purpose).
- EmojiPicker/GifPicker: listbox/option cells with a roving tabindex
  (Arrow/Home/End move the single Tab stop, Enter/Space activate through
  the click path). inline-autocomplete: option ids + combobox attrs and
  aria-activedescendant on the composer textarea — deliberately NOT
  roving tabindex, since moving DOM focus out of the textarea would
  break typing (the combobox pattern).
- Toast and TypingIndicator are polite live regions (role=status).

Tests: +71 unit cases across 18 files (4474 total, all green) pinning
roles, traps, restores, Escape safety, and roving behavior; plus an
axe-style e2e smoke (a11y-smoke.spec.ts, 5 tests) proving the wiring in
the running app — settings tablist + focus restore to the opener, quick
switcher combobox, member-picker Tab containment, live regions, and the
cert first-use dialog where Escape rejects without trusting.

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

* docs(ux): replace file:line anchors with symbol references (DC-15)

The UX specs cited code as file:line anchors, and a three-week-old
snapshot already had 15 of 58 pointing at entirely wrong code (the audit
measured 200-700 lines of drift). All 55 remaining anchors across the
six spec files now reference the owning symbol instead
("validateForm() in pages/connect-page/LoginForm.ts"), each target
verified to exist before rewriting; the stale ones were re-aimed at the
correct symbol, not just de-numbered. Zero file:line references remain
under docs/architecture/ux/.

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

* docs(audit): record the 2026-08-05 closure pass; stamp DC statuses in place

- audit-2026-08-04-docs-and-coverage.md: DC-06/08/12/13/15 marked
  resolved, DC-04 and DC-09 further-resolved (admin-panel journey and the
  handleApplyUpdate TODO are the remainders), matrix rows 38/49 flipped
  from headline gaps to covered, the §4 UX-problem bullets closed, and a
  §12 closure addendum records what shipped and the verification runs.
- CHANGELOG Unreleased: operator-facing entries for the DC-08 fail-closed
  fix, the a11y pass, the UX polish, the backup_restore audit row, the
  tag-gated CI tests, and the new e2e journeys.
- E2E-ISSUES.md: fresh full-suite run recorded at this HEAD — 291/291
  passed in 9.2 min with zero flaky retries (276 baseline + 6 E2EE + 4
  updater + 5 a11y smoke), @parity 15/15; suite inventory now 40 files.

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

* test(client): fix install-settle race in the updater e2e spec

The install settle handles are created only when the app's
download_and_install_update invoke reaches the mock wrapper, but the spec
called them right after asserting the banner text — which flips
synchronously on click, before the invoke's microtask runs. Local runners
won that race; CI lost it three attempts in a row
(window.__rejectInstall is not a function). Both settle sites now wait
for the handles, same pattern as the listener waits the file already uses.

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

---------

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

* Final audit closure: admin-panel e2e, container-safe updates, blocking e2e gate, dependency policy (#1330)

* ci(server): run the tag-gated wazero/otel Go tests (DC-06)

The build-tag matrix only compiled the otel/wazero variants; the tests
behind those tags (plugin/sandbox_wazero_test.go 462 lines,
telemetry/telemetry_otel_test.go 214 lines) ran nowhere since they were
written (T-2026-07-25-16). Scoped to the two packages that carry tagged
files; verified green locally before wiring:
go test -tags wazero ./plugin/... and -tags otel ./telemetry/... both pass.

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

* fix(client): fail closed when the identity pin store is unreadable (DC-08)

getIdentityPin collapsed a keyring read error into "no pin stored", so a
transient failure sent a pinned peer down the TOFU first-sight path:
verifyPeerAnnounce verified against the server-delivered key and then
RE-PINNED it — a fail-open a malicious server could exploit by inducing
store errors (F3 follow-up 3, plans/security-scan-2026-07-22).

getIdentityPin now returns a three-state IdentityPinLookup
(pinned/unpinned/unavailable), mirroring how tofu.rs keeps Err distinct
from Ok(None) first-use. verifyPeerAnnounce rejects the announce on
"unavailable" without any pin write, records the new "unknown"
PeerVerification status, and the roster badge renders it as an amber
shield-question ("could not check") distinct from the legacy
"unverified" state.

Pinned by unit tests: pin present, no pin, store error (identity.ts),
the fail-closed rejection path (livekit-session), and the badge
presentation (channel-sidebar).

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

* feat(admin): write a backup_restore audit row that survives the restore (DC-09)

Backup restore was the one admin mutation with no audit_log row —
docs/security.md documented the gap as inherent ("the database is closed
as part of the restore"). The row IS writable durably: written
synchronously (LogAudit, deliberately not the async WriteAudit fast path)
before BackupTo takes the pre-restore safety copy, it is captured inside
pre_restore_*.db and survives the file swap forensically.

The extended restore test opens the pre-restore backup as a database and
asserts the backup_restore row is inside it — proving both the write and
its ordering. docs/security.md now documents where the row lives instead
of the gap.

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

* feat(client): channel-delete toast + optimistic reaction toggle (DC-12)

Two messaging-surface gaps the UX specs carried as open:

- channel_delete on the active channel now toasts "This channel was
  deleted" alongside the existing redirect (ux/channels-members-dms
  §1.2) — the redirect alone read as the app spontaneously changing
  channels. Non-active deletions stay silent.

- Reactions toggle optimistically (ux/messaging §5): the pill flips on
  the click, registered under the send's WS envelope id — the same
  correlation scheme as the optimistic message rows. updateReaction
  consumes the matching self-echo instead of re-applying it (the
  delta-based arithmetic would double-count), other users' echoes apply
  normally, and an error reply or local transport failure rolls back
  exactly that toggle via rollbackReaction in the dispatcher's error and
  send-failure handlers. The pill reverting is the failure feedback.

Both spec gap notes flipped to implemented; the stale §2 note claiming
the ready payload lacks slow_mode fell in the same edit.

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

* fix(client): guard the role-change submenu against double-fire (DC-12)

Every other destructive admin action already carried an in-flight guard
(withConfirmation, unblockRunning, banRunning, purge) — the role-change
submenu was the residual: currentRole only updates when the
member_update echoes, so a double-click (or a second option clicked
while the first PATCH was in flight) fired onChangeRole twice. One
shared guard now inerts the whole submenu while a change is running,
with the pending class on the clicked option.

The settings-and-admin spec's in-flight gap note flips to implemented,
and the stale messaging §8 slow-mode note is corrected in the same
docs sweep (the countdown shipped with the ready payload's slow_mode —
verified against ChannelController.startSlowMode and its tests).

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

* fix(client): drag-reorder document listeners leak — own them per sidebar signal (DC-12)

The shared document mousemove/mouseup pair was reference-counted per
attached channel row: a sidebar with N channels took N refs (and N more
per re-render) while its single destroy returned exactly one, so the
count never reached zero and the listeners plus their activeDrag
closure lived for the rest of the process — the KNOWN BUG the
drag-reorder test pinned since the 2026-07-25 audit.

Ownership is now a Set of AbortSignals (the sidebar's lifetime
controller — the @lib/disposable teardown idiom): acquisition is
idempotent per signal no matter how many rows attach, release is the
signal's abort, and an owner aborted mid-drag clears the in-flight
visual state (which the old containerEl comparison never actually
matched in production — it compared the category container against
channelList). releaseGlobalDragListeners is gone; ChannelSidebar's
destroy releases via its existing ac.abort().

The pinning test changes with the fix, as its own comment instructed;
the lifecycle block now pins the fixed contract, including
re-registration after full teardown.

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

* test(e2e): E2EE identity-verification and updater journeys (DC-04)

The two remaining client-side headline coverage gaps from the 2026-08-04
audit's flow matrix (rows 38 and 49), both driven through the web mock
harness:

voice-e2ee-verify.spec.ts (6 tests, ux/voice-and-e2ee §7): the peer's
announce is real crypto — an ECDSA P-256 identity key signing an ECDH
ephemeral key exactly as e2eeCrypto does — so the badge states come out
of the production verification path. Covers the verified badge with
safety number (+ TOFU pin on first sight), the legacy unverified badge,
the mismatch block, the mismatch modal's reject path (peer stays
blocked, nothing pinned) and Trust New Key (re-pins the displayed key),
and the DC-08 fail-closed 'could not check' badge when the pin store is
unreadable. The harness gap that kept this untestable is closed by a
voice_join handler that grants a key-holder voice_token plus a WebSocket
shim that parks LiveKit's room.connect forever, holding the session
stably in 'securing'.

updater.spec.ts (4 tests, ux/settings-and-admin §5): no-update silence,
banner + Later dismissal, the full banner → download progress (% and MB
fallback via real update-progress events) → automatic relaunch journey,
and the failure state with Dismiss. There is no restart prompt by design
— the applied state IS the relaunch, asserted via the recorded
plugin:process|restart invoke.

Harness: buildTauriMockScript gains per-test identity-pin config
(identityPins / identityPinError) and a window.__invokeLog recording
every IPC call so tests can assert side effects with no DOM footprint.

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

* style(client): prettier-format the drag-reorder module and new e2e specs

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

* feat(client): accessibility pass over the modal/overlay stack (DC-13)

The repo had exactly one focus-trapped dialog (UserProfilePopup), one
aria-live region, and no role="dialog" anywhere else. This pass
generalizes that one good implementation into lib/a11y.ts
(applyDialogSemantics, trapFocus, focusDialog, setRovingTabindex,
enableRovingNavigation) and applies it across the stack — all additive:
no DOM classes, testids, structure, or visible text changed.

- modalFactory: every factory modal now carries role=dialog + aria-modal,
  moves focus in on open, restores it on every close path, and Tab-cycles
  inside; createPromptModal is labelled by its title.
- Hand-rolled modals (CertMismatch/CertFirstUse/IdentityMismatch,
  Create/Edit/DeleteChannel, InviteManager): dialog semantics labelled by
  their existing headings, focus trap + restore, aria-label on icon-only
  close buttons, and Escape mapped to each modal's SAFE action (reject on
  the trust prompts — dismissal must never grant trust; cancel on the
  channel modals — never the destructive/submit callback).
- SettingsOverlay: dialog on the panel, focus in on open/restore on
  close; the sidebar is a vertical role=tablist with roving tabindex and
  ArrowUp/Down/Home/End activate-on-focus; the content pane is a
  tabpanel labelled by the active tab.
- QuickSwitcher: dialog + combobox/listbox/option wiring with
  aria-activedescendant tracking the active row. QuickSwitchOverlay:
  dialog + keyboard-operable rows (the inert current-server row stays
  unfocusable on purpose).
- EmojiPicker/GifPicker: listbox/option cells with a roving tabindex
  (Arrow/Home/End move the single Tab stop, Enter/Space activate through
  the click path). inline-autocomplete: option ids + combobox attrs and
  aria-activedescendant on the composer textarea — deliberately NOT
  roving tabindex, since moving DOM focus out of the textarea would
  break typing (the combobox pattern).
- Toast and TypingIndicator are polite live regions (role=status).

Tests: +71 unit cases across 18 files (4474 total, all green) pinning
roles, traps, restores, Escape safety, and roving behavior; plus an
axe-style e2e smoke (a11y-smoke.spec.ts, 5 tests) proving the wiring in
the running app — settings tablist + focus restore to the opener, quick
switcher combobox, member-picker Tab containment, live regions, and the
cert first-use dialog where Escape rejects without trusting.

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

* docs(ux): replace file:line anchors with symbol references (DC-15)

The UX specs cited code as file:line anchors, and a three-week-old
snapshot already had 15 of 58 pointing at entirely wrong code (the audit
measured 200-700 lines of drift). All 55 remaining anchors across the
six spec files now reference the owning symbol instead
("validateForm() in pages/connect-page/LoginForm.ts"), each target
verified to exist before rewriting; the stale ones were re-aimed at the
correct symbol, not just de-numbered. Zero file:line references remain
under docs/architecture/ux/.

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

* docs(audit): record the 2026-08-05 closure pass; stamp DC statuses in place

- audit-2026-08-04-docs-and-coverage.md: DC-06/08/12/13/15 marked
  resolved, DC-04 and DC-09 further-resolved (admin-panel journey and the
  handleApplyUpdate TODO are the remainders), matrix rows 38/49 flipped
  from headline gaps to covered, the §4 UX-problem bullets closed, and a
  §12 closure addendum records what shipped and the verification runs.
- CHANGELOG Unreleased: operator-facing entries for the DC-08 fail-closed
  fix, the a11y pass, the UX polish, the backup_restore audit row, the
  tag-gated CI tests, and the new e2e journeys.
- E2E-ISSUES.md: fresh full-suite run recorded at this HEAD — 291/291
  passed in 9.2 min with zero flaky retries (276 baseline + 6 E2EE + 4
  updater + 5 a11y smoke), @parity 15/15; suite inventory now 40 files.

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

* test(client): fix install-settle race in the updater e2e spec

The install settle handles are created only when the app's
download_and_install_update invoke reaches the mock wrapper, but the spec
called them right after asserting the banner text — which flips
synchronously on click, before the invoke's microtask runs. Local runners
won that race; CI lost it three attempts in a row
(window.__rejectInstall is not a function). Both settle sites now wait
for the handles, same pattern as the listener waits the file already uses.

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

* ci: promote client-e2e to blocking (DC-07)

The soak is decided: green full-suite runs at 270, 276 and 291 tests across
the audit branches, and the one hard failure in the window was a real spec
bug a non-blocking job would have let rot.

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

* feat(server): refuse in-place self-update in container deployments

Resolves the long-standing handleApplyUpdate TODO. In a container the
running binary is image content: the staged replacement dies with the
container and the restart comes back as the old image. RunningInContainer
(OWNCORD_CONTAINER authoritative both ways — the shipped Dockerfile sets 1,
bind-mount operators can set 0 — with /.dockerenv//run/.containerenv as
fallback) now gates POST /admin/api/updates/apply with 503
CONTAINER_DEPLOYMENT before any updater logic, GET /admin/api/updates gains
can_apply, and the admin SPA swaps the apply button for an image-upgrade
note when it is false.

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

* docs: adopt the dependency pinning/review policy (DC-11, 2026-04-07 #8)

Writes down the policy the lockfiles already enforce: lockfiles
authoritative with npm ci-only installs, weekly Dependabot with majors
adopted deliberately, per-PR security gates (npm audit on shipped deps,
govulncheck, cargo audit, knip), and toolchain-level version pins.

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

* test(admin): add the admin-panel e2e journey against a real server (DC-04)

The admin SPA was the one surface no suite could reach: it is served by the
Go server and mocked nowhere. start-server.sh builds and boots a real
server (fresh temp data dir, TLS off, loopback) and the journey drives the
SPA end to end — first-run wizard creating the owner, dashboard stats,
channel create/rename, audit-log rows for both mutations, and sign-out/
sign-in. One shared page keeps the localStorage session across the serial
steps, mirroring the native suite's persistent fixture and staying under
the 5-logins/min limiter; on a Playwright retry the wizard branch downgrades
to login since setup is one-shot server-side. New non-blocking admin-e2e CI
job on the same graduation convention client-e2e followed.

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

* docs(audit): final closure — every DC finding resolved or deliberately reserved

Records the owner-directed closure pass (§13): DC-04 fully (admin journey
was the last row), DC-07 (client-e2e blocking), DC-09 fully (container-
aware update refusal), DC-11 + 2026-04-07 #8 (dependency policy).
Remaining open items are all deliberate: DC-14 reserved protocol entries,
the admin-e2e soak graduation, and the accepted/tracked 2026-04 carryovers.

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

---------

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

* fix(attachments): stop the orphan sweep destroying every avatar and the grace period

The 15-minute maintenance sweep deleted attachment rows and their files for
any attachment with message_id IS NULL. Avatars are exactly that by design:
users.avatar points at the attachment by URL and nothing ever links it to a
message (migration 027). Every avatar in the instance was therefore destroyed
on the first tick past the grace period, permanently 404ing every profile
picture. The query now excludes attachments a user's avatar still points at.

Independently, the cutoff was formatted RFC3339 while uploaded_at is written
by SQLite as 'YYYY-MM-DD HH:MM:SS'. TEXT comparison is bytewise and ' ' sorts
before 'T', so every unlinked upload sharing the cutoff's UTC date was swept
regardless of time -- the one-hour grace collapsed to 'immediately'. Rather
than fix the format at the one call site, DeleteOrphanedAttachments now takes
a time.Time and formats it internally, so no caller can reintroduce the class.

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

* fix(client): send old_password so changing a password can succeed

The client posted {current_password, new_password} while the server decodes
json:"old_password" (Server/api/profile_handler.go:43). Go's encoding/json
does no alias matching, so OldPassword was always empty and every password
change returned 400 INVALID_INPUT -- the feature could never work for anyone.
docs/api.md and every server test already document old_password.

The existing unit test asserted the client's own broken payload, so it passed
while the feature was dead; it now asserts the documented server contract.

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

* fix(admin): roll back and restart when a backup restore fails mid-copy

copyFile truncates the destination with os.Create before it can know whether
the read will succeed. On the restore path the destination is the live
database, already closed, so a failure in io.Copy or Sync left a zero-byte
chatserver.db, no rollback, and -- because the old code returned before the
restart -- a process still answering requests against a closed DB while the
response and the server_restart broadcast both claimed a restart was underway.

The failure branch now puts the pre-restore safety copy back (saying so
honestly in the error, including when the rollback itself fails) and requests
the restart the success path already did.

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

* fix(voice): refuse client-side unmute while server-muted by a moderator

Push-to-talk called LiveKitSession.setMuted directly, which had no
server-mute guard -- only the voice widget's own handler checked. Unmuting
re-publishes a fresh mic track, and since MuteParticipantAudio only mutes the
track SIDs that exist at mute time while the LiveKit grant still carries the
microphone publish source, the SFU accepted it: holding PTT lifted a
moderator's mute and never told the server.

The guard now lives in setMuted itself, the one entry point every caller
shares, so PTT and any future caller are covered. Muting stays allowed.

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

* fix(ws): force a full ready when a client's seq is ahead of the ring buffer

EventsSince/EventsSinceFiltered guarded only the lower bound, so a client
asking for events newer than anything the buffer ever held got a non-nil empty
slice -- which handleReconnect reads as a successful, complete replay. It then
registers the client, sends auth_ok with replay_source=buffer and skips ready
entirely, leaving stale members, channels and read state until the counter
climbs back past the client's remembered value.

That disagreement is reachable in normal operation: the hub seeds its counter
from GetMaxEventSeq, which is 0 once the 24h pruner has emptied the table, so
a restart can reseed seq below a lastSeq clients preserve across reconnects.

Both functions now return nil (the existing 'cannot guarantee coverage'
signal) when afterSeq exceeds the newest buffered seq, so the caller falls
through to the cold tier and the intended full ready. afterSeq == newestSeq
remains the legitimate caught-up case and still replays empty.

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

* fix(client): re-send channel_focus on auth_ok so reconnects keep receiving messages

channel_focus was sent only by mountChannel, which early-returns when the
channel id is unchanged, so a reconnect into the same channel never re-sent it.
The server transfers the focused channel from the old connection, but only
while that connection is still registered -- readPump's defer unregisters it
and drops every topic subscription the moment the server observes the close,
about a second before the client's first retry. Any server-observed close
(restart, proxy close, network reset) therefore resumed with no ChannelTopic
subscription: server channel messages, edits and reactions are delivered
exclusively over that topic, so the message stream went silently dead while
global events kept arriving and made the connection look healthy.

auth_ok fires on every connection including resumes and the full-ready
fallback, and it also covers the server-restart case where there is no old
state to transfer from. The server's handler is idempotent, so the extra focus
on a fresh connect is harmless.

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

* fix(messages): stop persisting HTML-escaped text, safely

bluemonday writes text tokens through html.EscapeString, so sanitizeContent
persisted and broadcast the escaped form: every apostrophe, quote, ampersand
and angle bracket reached other users as a literal entity, and because stored
quote lines began with '&gt;' the client's blockquote regex could never fire.
cleanText (display names, about, custom status, DM names) had the same bug.

Unescaping bluemonday's output alone would be a sanitizer bypass: surviving
text tokens can recombine into live markup -- '<<script>script>alert(1)<'
+ '</script>/script>' reassembles a real end tag. Instead the whole
unescape -> Sanitize -> unescape cycle now runs to a fixpoint, so the stored
result is by construction stable under re-sanitizing: any '<' that the
tokenizer would read as a tag start is stripped rather than re-encoded, and
only inert punctuation survives. The loop is bounded by the input length and
each pass is non-increasing; measured worst case over pathological tag/entity
soup at the 16 KiB input ceiling is under a millisecond.

The fuzz sinks are tightened to match the new contract rather than loosened:
they now require a tag-like start ('<' + letter or '/') because a bare '<'
followed by punctuation is inert plain text under every client render path.
The <script substring and idempotency checks are unchanged. Verified with
4.2M fuzz executions, zero crashers.

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

* fix(client): close the WS task and TLS socket on disconnect via generation-owned teardown

ws_disconnect dropped the slot's sender expecting the write task to end, but
the monitor task held my_tx — a Sender clone kept only to prove teardown
ownership — so rx.recv() could never yield None: the monitor waits on the
writer via join_next() while the writer waits on the monitor's clone
dropping. Every intentional disconnect or profile-switch reconnect leaked
the writer, the reader, and the TLS socket, and with no server-side read
deadline the connection stayed registered — the user remained presence-online
after logout, and the stale Rust reader kept injecting the old server's
events into the new session's stores.

Ownership is now proven by the connection generation that already guards
install: the monitor captures my_generation plus the generation Arc and
clears/announces only if the generation is still current, checked under the
slot lock (generation only advances inside begin_connection while that lock
is held, so check-and-clear is atomic against new attempts). install_sender
receives the only Sender, so dropping the slot's sender really closes the
channel: writer exits, join_next returns, abort_all reaps the reader, and
the socket drops.

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

* fix(voice): send voice_leave when the E2EE key exchange times out

connectAndSetup's key-exchange failure branch called leaveVoice(false) — no
voice_leave frame, no leaveVoiceChannel(). The timeout fires BEFORE
room.connect(), so no SFU participant ever exists and no LiveKit webhook can
clean up, while the server already registered the join when it sent
voice_token. The orphaned voice_states row matches the connected client's
channel, so sweepStaleVoiceStates never reaps it; once the ghost has the
lowest uid it wins key-holder election with a cleared E2EE state, every
later joiner's exchange times out and ghosts too, and rejoining the same
channel bounces off ALREADY_JOINED.

Mirror the reconnect-exhausted give-up path: leaveVoice(true) +
leaveVoiceChannel(), so the server drops the row and the local store
converges. The supersession checkpoints keep leaveVoice(false) — there a
newer attempt owns the server-side state and a voice_leave would destroy it.

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

* fix(ws): keep voice state when the replay-failure fallback will transfer it

handleFreshConnect's stale-voice cleanup ran unconditionally, but the
replay-failure fallback (lastSeq > 0, e.g. after a restart reset the seq
counter) reaches it while the old connection is still registered — and
registerNow then transfers that connection's live voice state into the new
client. The cleanup had already deleted the DB row, broadcast voice_leave,
and removed the live LiveKit participant (using the very JoinedAt token
being transferred), so the user ended up "in voice" on the hub only:
voice_join bounced off ALREADY_JOINED and sweepStaleVoiceStates never
reaps in-memory state without a row.

Skip the cleanup when lastSeq > 0 and the still-registered old client's
voiceChID matches the row — exactly the case registerNow transfers. All
other cases (F5 fresh connects, no old client, mismatched channel) keep
the existing cleanup, and if the old client unregisters in the window
before registerNow, the untransferred row is reaped by the next sweep.

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

* fix(settings): stop the mic meter stream when it resolves after teardown

The mic-meter IIFE had no post-await guard: a getUserMedia resolving after
SettingsOverlay.hide() ran cleanup() (or after the tab's signal aborted)
opened the microphone anyway, started the rAF meter loop, and registerMic
re-armed state that cleanupMic() had already cleared — the mic stayed hot
for the rest of the session with nobody left to stop it.

Mirror the camera preview's request-id guard: cleanupMic()'s invalidation
callback now bumps a micRequestId alongside cameraRequestId, the IIFE
captures the id before the await, and a stale or aborted request stops the
just-acquired tracks and bails before touching the AudioContext.

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

* fix(api,db): close the API-token and stale-ban holes in 2FA and account deletion

Four related gaps around sessionless (API-token) principals and account
teardown, all found by the bughunt harvest:

- 2FA enable/disable skipped the BUG-108 "revoke other sessions" step
  entirely when the caller authenticated with an API token (nil session).
  Both handlers now use the change-password pattern: keep=0 matches no
  row, so every login session is revoked.
- verify-totp issued a session to a user banned after the password step;
  it now runs the same IsEffectivelyBanned refusal as login.
- DeleteAccount left API tokens active (they authenticate independently
  of the purged sessions) and left a stale lapsed ban_expires in place,
  which makes banned=1 read as NOT banned — together a previously
  temp-banned self-deleted account stayed fully usable through any
  owner-minted token. Tokens are now revoked in the purge and
  anonymiseUser sets ban_expires = NULL.
- The last-admin guard resolved admin-class roles by display name
  ('Owner','Admin'), so renaming the seeded Admin role silently disabled
  self-deletion protection for its holders. It now keys on the canonical
  OwnerRoleID/AdminRoleID plus any role holding the Administrator bit.
  (The harvest's suggested criterion — Owner ID or Administrator bit
  alone — would have DROPPED seeded Admins, whose 0x3FFFFFFF permissions
  lack bit 30; the ID-based form preserves existing guard semantics.)
- DeleteAccount also now applies LeaveGroupDM's invariant: DM channels
  left with zero participants are removed instead of becoming
  unreachable, undeletable rows.

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

* fix(api,ws): rate-limit gaps — bucket isolation, focus/mark_read, call_decline, reaper horizon

- Every empty-prefix RateLimitMiddleware mount shared one bare-IP bucket,
  and the limiter records a timestamp per call regardless of the limit
  passed — so unrelated endpoints capped each other at the minimum limit
  (five ordinary profile edits 429'd the password endpoint; NAT'd logins
  blocked register). The prefix is now a required parameter and every
  mount names its own bucket, mirroring the existing client_update:/
  livekit_proxy:/gif: pattern. The sessions-list handler also stops
  401ing API-token principals (nil session only ever fed IsCurrent).
- channel_focus and mark_read were the only user-facing V2 handlers with
  no rate limit, and each drives an unmetered SQLite write plus pubsub
  churn; they now share a 5/s per-user budget (same underlying service
  call), silently dropping over-budget frames like their siblings.
- call_decline gets the same limiter as its sibling call_ring — the
  identical participant-lookup-plus-fan-out cost shape.
- The rate-limiter reaper pruned any entry idle past 15 minutes, but slow
  mode passes windows up to the 6 h admin cap, so long slow modes were
  silently reset; the cleanup horizon now covers the largest real window.

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

* fix(server): unreachable route envelopes, dead env override, admin paging, plugin/updater defects

Seven harvest findings across router, config, admin, plugin and updater:

- The global 1 MiB body cap shadowed every route with a larger documented
  envelope: the 16 MiB plugin install 400'd at ~1 MiB and an at-limit
  avatar could never fit its multipart framing. The exemption list is now
  a named var covering uploads, plugin install, and avatar — each of
  which enforces its own cap at the route/handler level.
- queryInt clamped offset with the limit's 500 cap, so the admin audit
  log and user list could never page past row 550; the cap is now an
  explicit per-call bound (offset callers pass MaxInt32).
- OWNCORD_EVENT_PERSISTENCE_* env overrides were documented but dead:
  envKeyToKoanf cut at the first underscore, producing the unknown path
  event.persistence_* that koanf silently drops.
- InstallPlugin trusted LastInsertId, which SQLite does not update on the
  upsert's DO UPDATE branch — on the shared writer connection a reinstall
  returned the rowid of some unrelated prior INSERT, so EnablePlugin
  no-opped and plugin_kv wrote to a nonexistent plugin id. RETURNING id
  is correct on both branches.
- Every wazero plugin re-activation compiled the module again and leaked
  the previous CompiledModule; the handle is now retained on the instance
  and closed in deactivate, the lost-activation race, and the
  closed-module release path.
- Linux server self-update was gated on the Windows-only
  chatserver.exe.sig asset it never uses; the required-asset check and
  the signature fetch are now GOOS-aware.

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

* fix(ws): batch S4 — seq-gap shed, replay tail merge, drain-on-close, handshake teardown

Eight harvest findings in the hub/replay/pump paths, each locked by a
watched-red test in harvest_s4_internal_test.go / reconnect_db_test.go:

- kickClient closes the send channel BEFORE UnsubscribeAll so a racing
  Subscribe can never leave a dead client holding a topic.
- deliverBroadcast consults the topic limiter BEFORE allocating a seq: a
  shed frame no longer burns a sequence number that sits in the replay
  buffer forever unpublished.
- onStaleTick prunes idle topic-limiter buckets (Cleanup had no caller).
- dm_channel_open bumps the visibility watermark so a client resuming
  from an older seq takes the full-ready path instead of silently losing
  the targeted, unsequenced open.
- computeAllowedChannels treats a DM-lookup failure as fatal (full ready)
  instead of replaying with every DM event silently stripped.
- Cold-tier replay merges the ring-buffer tail past the newest persisted
  row; if the buffer cannot vouch for the flush gap it forces full ready.
- writePump drains queued frames (e.g. the BANNED kick reason) after
  closeSend instead of dropping them on the first closed channel.
- A failed post-registerNow handshake runs the offline teardown when no
  replacement connection holds the slot — no more users stuck online.

Declined by design: hoisting registerNow above the replay snapshot
(report L390) — every fallback path would re-register the same client
and registerNow self-kicks the slot holder; the µs dedup window does not
justify that risk in the hottest path.

The kickClient ordering test is a 300-iteration stress whose race window
is too narrow to hit reliably; it documents the invariant rather than
having been watched red.

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

* fix(client): batch C1 — store merges, ready-badge resync, pending-send teardown

Ten harvest findings in the stores/dispatcher layer, each locked by a
watched-red vitest test:

- setMessages merges instead of clobbering: live broadcasts and
  pending/failed optimistic rows that landed while the history GET was
  in flight survive the snapshot.
- addChannel is idempotent — the re-sent channel_create on role edits no
  longer wipes unread/mention counts, lastMessageId, or canSend.
- setChannels carries client-synthesized DM rows across the rebuild.
- READY marks the focused channel read after the store repopulation so
  stale server read_states cannot resurrect badges on the channel the
  user is actively reading (skipped on first connect).
- setVoiceStates maps the ready payload's camera/screenshare flags
  instead of blanking live streams on a mid-call resync.
- The dm_channels length guard is gone: an empty array is authoritative
  and clears ghost DMs.
- addMessage's defensive pending-row reconcile requires content equality
  so another session's replayed message cannot consume the pending row.
- performSend into a detached history window reattaches to present
  first, mirroring onJumpToPresent.
- prependMessages at the cap keeps the fetched older page and detaches
  the window instead of silently discarding the fetch (which refetched
  the same page forever); hasMore is the server's value again.
- A connection leaving "connected" fails every pending optimistic send
  (retry affordance) instead of letting rows spin forever.

One existing assertion updated to the corrected semantics: trimming on
prepend now drops rows below the window, so hasMore stays the server's
value and the test asserts the detach instead.

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

* fix(server): batch S5 — voice eviction scoping, fail-closed camera cap, role-service races

Seven harvest findings in the voice/service layer, each locked by a
watched-red test:

- The CONNECT_VOICE revocation sweep evicts via a channel-conditional
  clear (the in-memory analogue of LeaveVoiceChannelIfMatch): a
  voice_join to a permitted channel that commits while the DB-backed
  permission check runs can no longer be torn down. The report's
  suggested pre-check guard was rejected — it leaves the same race open
  between guard and clear, proven by the interleaving test.
- A failed channel switch's abort branch re-subscribes the restored
  session to its VoiceTopic and re-elects the key holder; without them
  the session silently missed every voice_e2ee relay.
- voice_camera fails closed when the VoiceMaxVideo lookup errors instead
  of skipping the cap check and enabling unconditionally.
- LiveKitProcess starts the child inside the p.mu critical section that
  publishes p.cmd (Wait stays outside), removing the data race between
  Start's cmd.Process write and IsRunning/Stop reads.
- AffectedUserIDs reports lookup success; handlePatchRole falls back to
  a blanket permission-cache invalidation when the member list was
  unreadable, instead of evicting nobody and leaving revoked grants live.
- RoleService serializes its read-check-write mutations (position
  uniqueness and the role cap are snapshot-enforced, not DB-enforced);
  concurrent creates can no longer land on the same position and tie
  every hierarchy comparison.
- channel_focus writes the read state even when the channel has no
  undeleted messages — the upsert is what zeroes mention_count, so
  emptied channels finally clear their badge.

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

* fix(client): batch C2 — voice eviction teardown, supersession scoping, VAD generation

Seven harvest findings in the voice/session layer, each locked by a
watched-red vitest test:

- A server-initiated voice_leave for self tears down the LiveKit session
  (mic publish + E2EE key material), guarded on channel match so a
  late-arriving leave cannot kill a newer join.
- VIDEO_LIMIT refusal rolls back with disableCamera() — max_video has no
  SFU-level enforcement, so the already-published track kept streaming.
- teardownForReconnect sends voice_camera/voice_screenshare OFF frames
  before stopping local tracks, freeing the server-side max_video slot a
  reconnect otherwise occupies forever.
- Supersession checkpoints 3/4/5 disconnect only their own local room
  (mirroring checkpoint 2) instead of calling the global leaveVoice,
  which by then tears down the newer attempt's live session.
- retryMicPermission honors a moderator's server-mute like it honors
  deafen — granting mic while listen-only no longer hands the channel an
  unmuted track.
- handleDisconnected defers to the active reconnect loop (livekit-client
  fires Disconnected synchronously inside the loop's own connect call),
  preventing a second uncancellable retry loop.
- stopVadPolling invalidates an in-flight startVadPolling addModule via
  a VAD-scoped generation counter, so VAD cannot resurrect itself with a
  stale threshold.

Deliberately skipped: the report's optional RATE_LIMITED camera rollback
— that error code is shared by unrelated actions and the payload cannot
attribute it to a camera toggle, so a blind rollback would be wrong.

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

* fix(client): batch C3 — E2EE rotation races, pin-write tri-state, stale-offer guards

Six harvest findings in the voice-E2EE key-exchange layer, each locked
by a watched-red vitest test; the security-invariant sweep (re-pin
TOCTOU, forward-secrecy rekey, concurrent-rotation, blind-repin — 239
E2EE-adjacent tests) stays green:

- Re-election as key holder during an in-flight rotation defers (sets
  _isKeyHolder + _rotationPending, mirroring the sibling branch) instead
  of dropping the election and stranding the room without a holder.
- storeIdentityPin returns tri-state stored/no-store/failed; a FAILED
  pin write now marks the peer unverified instead of displaying
  "verified" with no pin persisted — an unpinned peer could never trip
  mismatch detection, the exact MITM window the pin exists to close.
- handleOfferInner discards a stale offer when the session keypair
  changed, not just the epoch — a non-key-holder never bumps epoch, so
  an offer surviving clearState() into the next session passed the
  epoch-only check.
- handleAnnounce's wrap-and-offer path gets the same epoch guard as the
  receive path, so a rotation landing mid-wrap cannot ship a dead key.
- The key-exchange retry races a FRESH promise (the first rejection had
  permanently settled the old one, making the retry window zero), and
  aborts cleanly when clearState() tore the session down mid-exchange.
- setupKeyExchange publishes _ecdhKeyPair only after _isKeyHolder and
  _roomKey are ready, so a concurrent announce is queued and drained
  through the offer-sending path instead of being consumed offer-less.

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

* fix(client): batch R1 — secret store must not report a broken keyring as empty

Three harvest findings in the Tauri credential store, each locked by a
watched-red test:

- secret_store::get treated a keyring read error as "nothing stored"
  whenever the fallback file was also empty, which is indistinguishable
  from first login. loadOrGenerateIdentityKeyPair reads exactly that
  signal, so an unreadable keychain made the client mint and publish a
  fresh identity key over the existing one, invalidating every peer's
  TOFU pin. It now prefers a fallback copy and otherwise propagates the
  error; loadIdentityKey rethrows instead of swallowing to null.
- A failed keyring write left any older entry in place while the fresh
  secret went to the fallback file — and get() reads the keyring first,
  so the stale value shadowed the new one forever. The write-failure arm
  now purges the entry, mirroring the read-back-mismatch arm beside it.
- fallback_crypto deleted nothing when the key file's write or sync
  failed, leaving a short file that every later load rejects; since the
  key file is never rewritten once it exists, one ENOSPC poisoned the
  fallback store permanently.

Both Rust fixes needed a small injectable seam (get_with/set_with,
finish_new_key_file) because the keyring error branches are otherwise
unreachable without a live OS credential store.

The saved-login path is unaffected in behavior: loadCredential still
catches and degrades to "no saved credential" rather than surfacing the
new error. The persistence re-read in loadOrGenerateIdentityKeyPair
deliberately does not rethrow — the keypair already exists in memory by
then, so a transient failure keeps the existing "did not persist" warning.

fallback_crypto is cfg(not(windows)), so its test ran only under a
temporary local gate lift (reverted, verified no residual diff); it
executes for real on the Linux and macOS CI runners.

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

* docs: rewrite CLAUDE.md per Claude 5 context guidance; commit skills + hunt workflow

The CLAUDE.md files were a manual: build commands, code style, layout Claude
can read off the filesystem. Rewritten so they are short and spend their
tokens on gotchas instead — the things that are invisible until they cost an
afternoon.

Moved out of CLAUDE.md into skills (progressive disclosure), which also fixes
three references to skills that never existed:
- ci-check: the full local CI mirror, including the four Go build-tag variants
  and the deadlock pass a plain build/test misses, and the windows-latest
  runtime.scanstack GC fault that should be rerun rather than investigated.
- db-change: the sqlc workflow plus three silent traps — non-ASCII query files
  truncating the NEXT query's emitted SQL, semicolons in migration comments
  orphaning statements, and LIMIT 1 mis-emitting on a :one query.
- protocol-change: regenerate both constant files and commit the pair.

Dropped: command lists duplicated from the Makefile and package.json, prettier
style rules the formatter already enforces, and layout facts a directory
listing answers. Added the subsystem invariants that keep getting rediscovered
the hard way — the ws seq/FIFO contract, voice-session supersession scoping,
E2EE staleness guards, and the Node 22 webstorage failure mode.

.claude/ is no longer ignored wholesale: skills and workflows are tracked so a
cloud session, which sees only tracked files, starts with instructions rather
than nothing. Machine-local settings and locks stay ignored. Deleted
bughunting.js, a superseded copy declaring the same workflow name as
bughunt.js, which left the registry ambiguous.

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

* fix(client): batch C4 — cert-latch scoping, stale active channel, credential opt-out

Thirteen harvest findings across the client UI and wiring, each locked by a
watched-red vitest test:

- The TLS cert-mismatch latch fired on any host's event, so an unrelated
  saved profile's rotated certificate permanently killed this socket's
  reconnect loop. It now latches only for the connected host, cancels any
  pending reconnect (a timer armed before the mismatch would otherwise fire
  connect(), clearing the latch and resuming against that host), and resets
  on a fresh connect.
- ready never cleared activeChannelId when the channel vanished from the
  snapshot, and MainPage's subscriber had no else branch — the message list
  and composer stayed mounted and enabled against a channel the server no
  longer recognizes. Both sides fixed; the mark-read from batch C1 is
  suppressed when the clear happens.
- user_update re-saved the session token unconditionally, bypassing the
  remember-password opt-out, and dropped the stored password while doing it.
- A failed older-page fetch latched loadingOlder, permanently killing
  infinite scroll for that view; it now clears in a finally.
- Concurrent message jumps raced, letting the older response overwrite the
  newer window. Guarded by a generation counter.
- A FORBIDDEN send in a group DM flagged participants[0] as blocking, which
  disabled the unrelated 1:1 composer with that person; block gating is
  1:1-only.
- streamPreview added an abort listener per call instead of per signal.
- dm_channel_close had no fallback when the closed DM was being viewed;
  both call sites now share one closeDmLocally helper.
- The GIF picker routed through the textarea and discarded the draft.
- QuickSwitcher listed DM rows that the DM section already shows.
- Accepting a rotated certificate reconnected into a page with nothing left
  listening, stranding the user on the connect screen.
- Logout read voiceStore after clearAuth had already reset it, so the
  voice_leave was never sent; clearAuth now snapshots logoutWasInVoice.
- disconnect() left reconnectAttempt set, carrying a stale backoff ceiling
  into the next login.

Also fixes two lint errors this branch introduced earlier and that only a
full `npm run lint` catches: a useless spread in the C1 pending-send sweep
(now Array.from, which states the snapshot intent), and two floating
promises in C2's voice_leave handler, where converting an implicit-return
arrow to a block body stopped chaining them.

main.ts and MainPage.ts have no unit-test seam, so three focused pieces were
extracted to make the fixes testable: createUserUpdateCredentialSaver,
reconnectAfterCertAccept, and the logoutWasInVoice snapshot.

One existing assertion corrected: a dispatcher test claimed ready must keep
an active channel that was absent from the payload, which locked the bug.
It now keeps a channel that is present, with a sibling test for the absent
case.

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

* fix: resolve 107 verified defects across ws hub, voice/E2EE, db, and client

Findings came from a multi-round hunt, each verified against the tree by an
independent adversarial pass before any code changed, then fixed and re-reviewed.
Every behavioural fix carries a regression test that was confirmed to fail
against the pre-fix code.

Server (Go)
- Reconnect/replay: force a full ready when retention pruning has removed the
  events after a client's last_seq, rather than accepting the surviving suffix
  as a complete resume; close the snapshot-to-registration window under seqMu;
  restore the focused-channel subscription during the handshake via a new
  READ-gated active_channel_id auth field; supplement replay with the client's
  own voice room; tear down transferred voice sessions on a failed handshake.
- Hub: ratchet visibilityChangeSeq upward only (all three writers); make the
  stale-voice sweep error-aware so a transient DB failure no longer evicts
  every participant; re-elect the E2EE key holder on sweep and cleanup paths.
- Voice: preserve moderator mute/deafen across channel switches; deliver
  voice_leave to evicted users; gate camera/screenshare permission checks on
  the enabling direction only; reject joins to non-voice and archived channels.
- Permissions: archived channels are now read-only and unjoinable, and
  can_send is recomputed per client on role/override changes.
- Data: stop cascaded message deletes from stranding uploaded files
  (migration 030 unlinks instead); clear personal data on account deletion;
  exclude banned users from owner lookup; drop the silent 1000-member cap;
  advance the author's own read state on send.

Client (TypeScript / Rust)
- Voice: make joinGeneration monotonic so a superseded attempt can no longer
  pass supersession checks; scope aborted-path cleanup to the attempt's own
  room; send voice_leave on connect failure; stop push-to-talk from writing the
  user's explicit mute flag; gate join-time PTT muting on a new backend
  capability probe so platforms that cannot report key state are unaffected.
- E2EE: act on the tri-state pin-write result instead of reporting an
  unverified peer as verified; use keypair ownership rather than null checks.
- State: reset the message cache on logout; clear NSFW acknowledgements on
  logout; scope channel mutes, NSFW acks and DM notes per server host.
- UI: make the attachment remove button and the failed-send Retry/Discard
  buttons work; fix drag-reorder's phantom-drag latch and its permission gate.

Docs: protocol.md now documents can_send, active_channel_id, the archive
read-only contract, and the sequenced/unsequenced presence split.

Verified: all four Go build tag variants, go vet, go test -race, the ws
deadlock detector, sqlc and protocol generation, tsc, eslint, prettier, and
the full client suite (169 files, 4664 tests).

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

* fix(ci): satisfy golangci-lint, knip, and the host-scoped mute key in e2e

Three CI failures on the previous commit, all genuine fallout from it.

golangci-lint (v2.11.3) — 6 issues in tests added by that commit:
- contextcheck: the temp-ban subtest captured an outer ctx while calling
  seedTokenUser, which builds its own; declare ctx inside the subtest instead.
- modernize: use WaitGroup.Go and range-over-int in three tests.

knip — SessionResponse in lib/types.ts became unused. The getSessions fix
replaced it with SessionInfo in lib/api.ts, which documents why the old
declaration was wrong (it named ip_address/expires_at, which the server never
sends, and omitted ip/is_current, which it always does). Delete the dead type
rather than re-export it, and fold that reasoning into the surviving comment.

Client E2E — the per-channel-mute parity test asserted the pre-scoping
localStorage key. Channel mutes are now keyed mutedChannels:<host>, because
channel ids are per-server autoincrement integers sharing one webview origin;
verified in a browser that the app writes
owncord:settings:mutedChannels:localhost:8443. The test now resolves whichever
scoped key exists instead of pinning the test server's host, so it still
asserts the same thing: the id persists on mute and is gone on unmute.

Verified with the CI linter version built against Go 1.26 (0 issues), all four
build tag variants, go vet, go test -race, the ws deadlock detector, knip,
tsc for both tsconfigs, prettier, the full client unit suite, and the
previously-failing parity specs run in a real browser.

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

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-07 21:20:48 +02:00
dependabot[bot] b77c2790b0 chore(deps): bump the npm_and_yarn group across 1 directory with 2 updates (#1324)
Bumps the npm_and_yarn group with 2 updates in the /tools/mcp-introspect directory: [fast-uri](https://github.com/fastify/fast-uri) and [hono](https://github.com/honojs/hono).


Updates `fast-uri` from 3.1.4 to 3.1.5
- [Release notes](https://github.com/fastify/fast-uri/releases)
- [Commits](https://github.com/fastify/fast-uri/compare/v3.1.4...v3.1.5)

Updates `hono` from 4.12.32 to 4.13.0
- [Release notes](https://github.com/honojs/hono/releases)
- [Commits](https://github.com/honojs/hono/compare/v4.12.32...v4.13.0)

---
updated-dependencies:
- dependency-name: fast-uri
  dependency-version: 3.1.5
  dependency-type: indirect
- dependency-name: hono
  dependency-version: 4.13.0
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-04 15:42:26 +00:00
dependabot[bot] 931a2fd27e chore(deps): bump postcss from 8.5.19 to 8.5.25 in /Client/tauri-client (#1322)
Bumps [postcss](https://github.com/postcss/postcss) from 8.5.19 to 8.5.25.
- [Release notes](https://github.com/postcss/postcss/releases)
- [Changelog](https://github.com/postcss/postcss/blob/main/CHANGELOG.md)
- [Commits](https://github.com/postcss/postcss/compare/8.5.19...8.5.25)

---
updated-dependencies:
- dependency-name: postcss
  dependency-version: 8.5.25
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-04 15:22:06 +00:00
J3vbandClaude Opus 5 1486078265 ci: skip the Tauri full build on Dependabot PRs (#1325)
Dependabot PRs run under the separate `dependabot` secrets scope, so
TAURI_SIGNING_PRIVATE_KEY arrives empty and `npm run tauri build` always
aborted with "failed to decode secret key" while signing the updater
artifact -- after the compile and the NSIS/AppImage/deb bundle had both
already succeeded. Every dependency PR therefore burned ~50 min of runner
time across three platforms to produce a red check carrying no signal,
and the permanent red masked whether the job would have caught a real
break.

Granting Dependabot the signing secret would clear the symptom but hands
a release signing key to workflows triggered by third-party dependency
updates, so the job is skipped for that actor instead.

Coverage is preserved where it matters: `rust-tests` is a required check,
runs on every event, and compiles the crate via `cargo clippy
--all-targets` and `cargo test --lib`, so a dependency bump that breaks
the Rust build is still caught. Given up on Dependabot PRs only:
bundling, Windows/ARM-specific compilation, and the `cargo audit` step --
which overlaps with Dependabot's own cargo scanning.

`Tauri Full Build` is not among the required status checks on main
(Server Build & Test x2, Client Static Checks, Client Unit Tests, Rust
Unit Tests), so skipping it cannot leave a PR waiting on a status.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-04 15:19:33 +00:00
dependabot[bot] 3f8a26c904 chore(deps): bump fast-uri from 3.1.4 to 3.1.5 in /Client/tauri-client (#1323)
Bumps [fast-uri](https://github.com/fastify/fast-uri) from 3.1.4 to 3.1.5.
- [Release notes](https://github.com/fastify/fast-uri/releases)
- [Commits](https://github.com/fastify/fast-uri/compare/v3.1.4...v3.1.5)

---
updated-dependencies:
- dependency-name: fast-uri
  dependency-version: 3.1.5
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-04 17:08:21 +02:00
dependabot[bot] 4a3b4e0cff ci(deps): bump docker/build-push-action from 6.16.0 to 6.19.2 (#1316)
Bumps [docker/build-push-action](https://github.com/docker/build-push-action) from 6.16.0 to 6.19.2.
- [Release notes](https://github.com/docker/build-push-action/releases)
- [Commits](https://github.com/docker/build-push-action/compare/14487ce63c7a62a4a324b0bfb37086795e31c6c1...10e90e3645eae34f1e60eeb005ba3a3d33f178e8)

---
updated-dependencies:
- dependency-name: docker/build-push-action
  dependency-version: 6.19.2
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-04 17:05:12 +02:00
dependabot[bot] 15db18a3ac chore(deps): bump modernc.org/sqlite from 1.54.0 to 1.55.0 in /Server (#1315)
Bumps [modernc.org/sqlite](https://gitlab.com/cznic/sqlite) from 1.54.0 to 1.55.0.
- [Changelog](https://gitlab.com/cznic/sqlite/blob/master/CHANGELOG.md)
- [Commits](https://gitlab.com/cznic/sqlite/compare/v1.54.0...v1.55.0)

---
updated-dependencies:
- dependency-name: modernc.org/sqlite
  dependency-version: 1.55.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-04 16:59:42 +02:00
dependabot[bot] 4582a601b3 chore(deps): bump knip from 6.29.0 to 6.31.0 in /Client/tauri-client (#1314)
Bumps [knip](https://github.com/webpro-nl/knip/tree/HEAD/packages/knip) from 6.29.0 to 6.31.0.
- [Release notes](https://github.com/webpro-nl/knip/releases)
- [Commits](https://github.com/webpro-nl/knip/commits/knip@6.31.0/packages/knip)

---
updated-dependencies:
- dependency-name: knip
  dependency-version: 6.31.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-04 16:59:37 +02:00
dependabot[bot] a3d03620e5 chore(deps): bump undici from 7.28.0 to 7.29.0 in /Client/tauri-client (#1321)
Bumps [undici](https://github.com/nodejs/undici) from 7.28.0 to 7.29.0.
- [Release notes](https://github.com/nodejs/undici/releases)
- [Commits](https://github.com/nodejs/undici/compare/v7.28.0...v7.29.0)

---
updated-dependencies:
- dependency-name: undici
  dependency-version: 7.29.0
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-04 16:49:55 +02:00
dependabot[bot] 5df58bb9c4 chore(deps): bump ip-address (#1320)
Bumps the npm_and_yarn group with 1 update in the /tools/mcp-introspect directory: [ip-address](https://github.com/beaugunderson/ip-address).


Updates `ip-address` from 10.2.0 to 10.4.0
- [Release notes](https://github.com/beaugunderson/ip-address/releases)
- [Commits](https://github.com/beaugunderson/ip-address/compare/v10.2.0...v10.4.0)

---
updated-dependencies:
- dependency-name: ip-address
  dependency-version: 10.4.0
  dependency-type: indirect
  dependency-group: npm_and_yarn
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-04 16:49:51 +02:00
dependabot[bot] 4bda86d0cb chore(deps): bump zeroize in /Client/tauri-client/src-tauri (#1319)
Bumps [zeroize](https://github.com/RustCrypto/utils) from 1.8.2 to 1.9.0.
- [Commits](https://github.com/RustCrypto/utils/compare/zeroize-v1.8.2...zeroize-v1.9.0)

---
updated-dependencies:
- dependency-name: zeroize
  dependency-version: 1.9.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-04 16:49:47 +02:00
dependabot[bot] 6e841cc75f chore(deps): bump @playwright/test in /Client/tauri-client (#1318)
Bumps [@playwright/test](https://github.com/microsoft/playwright) from 1.62.0 to 1.62.1.
- [Release notes](https://github.com/microsoft/playwright/releases)
- [Commits](https://github.com/microsoft/playwright/compare/v1.62.0...v1.62.1)

---
updated-dependencies:
- dependency-name: "@playwright/test"
  dependency-version: 1.62.1
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-04 16:49:43 +02:00
dependabot[bot] d9c6094b5f chore(deps): bump rustls in /Client/tauri-client/src-tauri (#1317)
Bumps [rustls](https://github.com/rustls/rustls) from 0.23.42 to 0.23.43.
- [Release notes](https://github.com/rustls/rustls/releases)
- [Changelog](https://github.com/rustls/rustls/blob/main/CHANGELOG.md)
- [Commits](https://github.com/rustls/rustls/compare/v/0.23.42...v/0.23.43)

---
updated-dependencies:
- dependency-name: rustls
  dependency-version: 0.23.43
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-04 16:49:39 +02:00
dependabot[bot] 4fa8f6f84d ci(deps): bump actions/setup-go from 5.5.0 to 5.6.0 (#1313)
Bumps [actions/setup-go](https://github.com/actions/setup-go) from 5.5.0 to 5.6.0.
- [Release notes](https://github.com/actions/setup-go/releases)
- [Commits](https://github.com/actions/setup-go/compare/d35c59abb061a4a6fb18e82ac0862c26744d6ab5...40f1582b2485089dde7abd97c1529aa768e1baff)

---
updated-dependencies:
- dependency-name: actions/setup-go
  dependency-version: 5.6.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-04 16:49:36 +02:00
dependabot[bot] b854f5a986 ci(deps): bump docker/setup-buildx-action from 3.10.0 to 3.12.0 (#1312)
Bumps [docker/setup-buildx-action](https://github.com/docker/setup-buildx-action) from 3.10.0 to 3.12.0.
- [Release notes](https://github.com/docker/setup-buildx-action/releases)
- [Commits](https://github.com/docker/setup-buildx-action/compare/b5ca514318bd6ebac0fb2aedd5d36ec1b5c232a2...8d2750c68a42422c14e847fe6c8ac0403b4cbd6f)

---
updated-dependencies:
- dependency-name: docker/setup-buildx-action
  dependency-version: 3.12.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-04 16:49:32 +02:00
dependabot[bot] 3c78bcd2b2 ci(deps): bump swatinem/rust-cache from 2.7.8 to 2.9.1 (#1311)
Bumps [swatinem/rust-cache](https://github.com/swatinem/rust-cache) from 2.7.8 to 2.9.1.
- [Release notes](https://github.com/swatinem/rust-cache/releases)
- [Changelog](https://github.com/Swatinem/rust-cache/blob/master/CHANGELOG.md)
- [Commits](https://github.com/swatinem/rust-cache/compare/9d47c6ad4b02e050fd481d890b2ea34778fd09d6...c19371144df3bb44fab255c43d04cbc2ab54d1c4)

---
updated-dependencies:
- dependency-name: swatinem/rust-cache
  dependency-version: 2.9.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-04 16:49:28 +02:00
609 changed files with 55380 additions and 5753 deletions
+306
View File
@@ -0,0 +1,306 @@
---
name: bughunt-run
description: Run a bug hunt and turn its findings into committed fixes. Use when starting a hunt, resuming one, or fixing findings already in the ledger. Covers the ledger handoff between the bughunt and bughunt-fix workflows.
---
# Running the bughunt pipeline
Two workflows with a human gate between them. The ledger at
`.superpowers/findings-ledger.json` is the interface. Both workflows are pure
functions of their `args`**the session does all file I/O**, because workflow
scripts have no filesystem access.
`.superpowers/` is gitignored. This repo is public and unfixed defects must never
reach a commit, an issue, or a PR body.
## 1. Hunt
**Launch the hunt from a turn that carries a token-budget directive** (recommended:
`+25M`, comfortably above a full 8-round run). The workflow's cost ceiling is gated
on `budget.total`, which is null without a directive — a directive-less run has **no
ceiling at all**. The workflow's first log line echoes the state: `budget=25M` means
armed; `budget=NONE - cost ceiling disarmed` means stop the run and relaunch with a
directive.
Before launching, in order:
1. **Rebuild the graph** (stale coordinates aim the explore lens at moved code):
`graphify update . --no-cluster` — local tree-sitter, zero LLM cost, ~10.7k nodes.
2. **Rank explore targets**: `node .superpowers/rank-explore.mjs` — writes
`.superpowers/explore-ranking.json`, deprioritizing files recorded clean in
`.superpowers/explored-clean.json` and dropping files that no longer exist.
3. Read the ledger and pass every record in as `known`, so the hunt does not
re-derive anything already found, fixed, declined, or refuted.
```
Workflow({
name: "bughunt",
args: {
known: <every record from findings-ledger.json, as {file, line, title, status}>,
graph: <the rows of .superpowers/explore-ranking.json>,
lenses: [ {key, prompt}, ... ], // optional: scope the hunt to one subsystem
maxRounds: 8,
dryThreshold: 2,
},
})
```
If `graph` is omitted or empty the hunt logs
`explore: args.graph absent/empty - falling back to churn-based fresh eyes` and
still runs — degraded targeting, never a smaller lens family.
Omit `lenses` for a general hunt across the rotating families.
Each lens object is `{key, prompt}`. `key` must match `^[a-z0-9-]+$`
lowercase letters, digits, and hyphens only. Keys get interpolated into agent
labels of the form `r<N>:hunt:<key>:<model>`, and a key containing uppercase,
dots, or spaces breaks label parsing. A lens missing `key` or `prompt` is not
validated — it reaches the finder prompt as the literal string `undefined`,
silently degrading that lens instead of failing loudly. Check your lens
objects before passing them.
When it returns, first save the raw result verbatim to
`.superpowers/hunts/<YYYY-MM-DD>-raw.json`, then:
```bash
node .superpowers/render-run-stats.mjs .superpowers/hunts/<YYYY-MM-DD>-raw.json <hunt-name>
```
This validates the result shape, appends the run's telemetry to
`.superpowers/run-history.json`, updates `.superpowers/explored-clean.json`, and
checks **every** confirmed finding's coordinates against the working tree (file
exists, line within length — the report agent that used to spot-check two findings
is gone). Resolve any `COORD` warnings before appending to the ledger: stale
coordinates poison `bughunt-fix`.
Then append each entry of `result.confirmed` to the ledger with
`status: "open"`, an id from `nextId`, and today's date. Bump `nextId`. The
incoming record carries a prose `fix` field (bughunt's suggested remedy) —
rename it to `suggestedFix` when appending, so the ledger's `fix` field starts
as `null` and is free for `bughunt-fix` to fill in with `{commit, test,
revertProof}` once something is actually fixed. Then:
```bash
node .superpowers/render-ledger.mjs
```
Each confirmed record carries `finder: "opus"`. The dual-model finder panel was
retired 2026-08-12: attribution over the only measured run priced sonnet's unique
yield (1 high, 4 medium, 9 low) at roughly a third of the run's agents. The known
cost: with one finder, a lazy-but-non-null finder round can read as "clean" where
the panel required both models to agree it was. Watch `runStats` — per-lens
candidate counts make an anomalously empty lens visible after the fact.
## 2. Gate (human)
Read `.superpowers/FINDINGS.md`. Mark anything you do not want fixed as
`declined` with a rationale — declined findings are fed back into the next hunt's
prompts and never re-reported.
## 3. Fix
```
Workflow({
name: "bughunt-fix",
args: {
findings: <records with status "open" from findings-ledger.json>,
branch: "fix/bughunt-YYYY-MM-DD",
only: ["OC-0042"], // optional
maxSeverity: "medium", // optional
circuitBreaker: { threshold: 0.5, minAttempts: 3 }, // optional; false to disable
},
})
```
Create and check out the branch first — the workflow commits to whatever branch
is current and does not create one.
### Composing the batch
The workflow clusters findings by the file the BUG is in, but its same-run
overlap guard fires on the files the FIX touches. Compose the batch so the two
can never disagree:
- **Close over the file relation.** Pull in every open finding that shares a
file with anything already selected, regardless of severity — a same-file
finding left behind is a future cross-cluster block.
- **Re-check coordinates against the working tree** (file exists, line within
length) before launching. render-run-stats checked them at hunt time only;
merges since then can stale them.
- **Scan fix-touchpoints, then read only the hits.** Token-scan each finding's
`suggestedFix`/`why` for path-like tokens owned by another cluster, then read
just the flagged records to separate evidence citations from actual fix
edits — the scan over-predicts (a measured run: 6 flagged, 1 real). True
collisions go into **sequential waves**: launch the later wave after the
earlier wave's commits land, and the same-run guard never fires. Batch 4
skipped this and self-blocked 20/27 findings; batch 6 ran it and blocked
zero.
### Security findings
Fixed security findings ship in normal PRs at this project's stage (alpha,
~zero external deployments): the fix and its disclosure land atomically.
Commit subjects and PR text describe the fix, never the exploit — no
severity labels, repro steps, or attack narratives in public text — and a
release should follow soon after merge. The GHSA advisory route is reserved
for coordinated disclosure once there is a real deployed user base. Never
decline a finding merely for routing.
## When a run trips the breaker
The run stops early if more than `threshold` of attempted findings fail, once at
least `minAttempts` have been tried. `declined` never counts as a failure — a run
where several findings are correctly declined is a good run. There are two trip
points: the fix stage (before any prove agent runs) and inside the prove loop.
**A tripped run means stop and investigate, do not re-run.** The usual causes are
being on the wrong branch, a broken test runner, or ledger coordinates gone stale
after a rebase. Re-running without fixing the cause just spends the budget again.
Findings from clusters the run never reached come back `blocked` with a rationale
naming the breaker. Set those back to `open` once the underlying problem is fixed
— they were never attempted. Their edits are sitting uncommitted in the working
tree, so the debris warning above applies to them too.
Whatever committed before the trip still goes through the gate, so `result.gate`
tells you whether those commits are green.
When it returns, for each entry in `result.results`:
- `fixed` → status `fixed`, `fix: {commit, test, revertProof: "self-reported"}`
using the matching `result.commits` entry — the prove agent's own report, not
yet independently checked (see step 4)
- `declined` → status `declined`, copy the rationale
- `blocked` → status `blocked` (not `open`), record the rationale; these failed
their revert-proof, tripped the cross-cluster overlap guard, or their agent
died, and want a human. Do NOT leave them `open``bughunt-fix` only picks up
`open` findings, so `open` would silently re-enter one of these into the next
fix run, exactly the retry loop the design deliberately excludes ("one human
look beats three agent attempts").
Ledger writes select records by id or `fix.commit` — never by date fields,
which collide when two batches reconcile on the same day — and every bulk
mutation asserts its expected match count before writing (a same-day sibling
batch once inflated a 29-record update to 44 matches; only the count
assertion caught it).
Check `result.gate`. A failed gate leaves the commits in place on the branch —
fix it yourself, do not re-run the workflow over it.
A fix that tightens a guard and fails ONLY e2e/CI while unit tests and local
gates stay green is usually a test-infrastructure defect, not a bad fix:
triage the mock/harness first. Check that mock echoes carry the same fields
the real server sends (real channel ids, not sentinels), and never assert
broadcast delivery on a socket the same operation force-closes. Mocks
calibrated against lenient code silently decay into lies — every
guard-tightening fix is also a fidelity audit of the mocks that exercise it.
The Playwright trace's console stream (grep the .trace file for the app's log
lines) locates the mechanism in minutes.
## 4. Verify the fixes independently — REQUIRED
The workflow's prove agent *self-reports* that each test went RED with the fix
reverted. Nothing inside the workflow can verify that: workflow scripts have no
filesystem access. You do. Run the independent proof over every commit the
workflow made:
```bash
node .superpowers/verify-fixes.mjs <sha> <sha> ...
```
It reverse-applies each commit's own source diff onto the current tree, runs
that commit's tests, and requires them to FAIL — then restores to HEAD and
requires them to PASS. This is the only check in the pipeline no agent can
fabricate. The reverse-apply/restore-to-HEAD shape is load-bearing for
**stacked waves**: sequential waves pile commits onto shared files, and an
older per-commit-snapshot restore silently corrupts every later verification
(a mid-branch snapshot left in the tree once made a whole package
uncompilable for five subsequent runs). Rust commits with in-file
`#[cfg(test)]` tests fail the file-level source/test split entirely — prove
those by hand at hunk level, splitting at the `mod tests` boundary.
While it runs it checkouts and restores source files, so the working tree is
not a stable read surface: anything reading concurrently — review agents,
scanners, hooks — must read committed objects (`git show HEAD:<path>`) or
pre-taken snapshots, and any live-tree scanner finding from that window needs
re-verification against HEAD before it is believed.
Any `FAIL ... VACUOUS TEST` means the fix was committed behind a test that
proves nothing. Revert that commit and set its findings back to `open`; do not
talk yourself into keeping it because the code change looks right.
Two classes are exempt from the insta-revert, both artifacts of the verifier
choosing the wrong detector rather than of a vacuous test:
- **Race/deadlock-class fixes** whose test only fails under its detector.
verify-fixes escalates to `-race` before declaring vacuity; if an older copy
reports VACUOUS on such a fix, re-prove red/green by hand under the class's
detector (`go test -race ./<pkg>/`) before reverting anything.
- **Cross-stack commits** whose test files belong to a different stack than
their source files (e.g. a vitest file pinning a `src-tauri/` config). The
script now keys the runner off the TEST files; an older copy keyed it off
the sources and ran the wrong stack's suite, which never executes the proof.
On any VACUOUS verdict for a cross-stack commit, re-prove by hand with the
test file's own runner before reverting.
For every commit `verify-fixes.mjs` reports `PASS`, upgrade that commit's
findings' `fix.revertProof` from `"self-reported"` to `"pass"` — this
independent run is the only check in the pipeline no agent can fabricate, and
it is what earns the upgrade. A `FAIL` commit needs no further edit here — it
was already reverted and its findings set back to `open` above.
Re-render. Before you review the branch and open the PR, inspect the working
tree: a blocked or declined cluster can leave its edits and any new failing
test it wrote sitting uncommitted. The gate's "N uncommitted modifications at
gate time" note is the trigger list. **Classify before discarding** — an
uncommitted modification to a TRACKED test file may be a required companion to
a committed fix, not debris. Two known mechanisms: the old test locked the old
buggy behavior, and the fix widened an interface that a fake/mock in a test
file the cluster never named must now implement (without it the committed
package does not even compile). The decisive test: `git stash push -- <file>`,
then compile/test the committed state alone — if it fails, the modification is
a companion; fold it into the causing commit (fixup + autosquash keeps the
history coherent for verify-fixes) or commit it with attribution. Only then
discard true debris — a reflexive `git add -A` would commit tests that
describe unfixed defects into a public repo.
**Capture before destroying, as separate verified steps.** Preserve blocked or
declined edits by copying the files aside (or `git stash push -u` after
confirming it actually saved something) BEFORE any rm/checkout, and never
chain the capture and the destructive step into one command — a capture
failure then becomes data loss (`git diff /dev/null <file>` fails outright on
Windows git and has deleted debris before it was saved). Never blind
`git stash pop`: a paired push on a clean tree saves nothing, and the pop then
grabs whatever foreign stash sits on top (a preserved debris stash, in the
measured case — ~40 conflicted paths). Pop only a ref you verified your own
push created; for temporary file comparisons, skip stash entirely and read old
versions from the object store (`git show <ref>:<path>`). Audit what got COMMITTED, too: parallel fix agents share the tree, so a
prove agent can commit a sibling cluster's content that happened to sit in a
shared test file or in regenerated output. Grep the committed tests for
finding ids outside the run's fixed set, and re-run the generated-code
verifies (sqlc/protocol) after the debris discard — a mismatch means a commit
carries foreign regen content. Anything pinning or describing an UNFIXED
finding must be excised from history (amend + rebase onto the amended
commit), not merely removed by a follow-up commit.
Then review the branch against the merge-base — `git diff
origin/main...HEAD` (three-dot), never two-dot: a concurrent merge plus a
background fetch can move origin/main mid-run and turn the two-dot diff into
phantom deletions. If origin moved, confirm zero file overlap and a clean
`git merge-tree --write-tree origin/main HEAD` before opening the PR by
hand. The workflow never
pushes and never opens a PR.
## Testing the workflows themselves
```bash
node .claude/workflows/bughunt.harness.mjs
node .claude/workflows/bughunt-fix.harness.mjs
node .superpowers/render-ledger.mjs --selftest
node .superpowers/verify-fixes.mjs --selftest
node .superpowers/rank-explore.mjs --selftest
node .superpowers/render-run-stats.mjs --selftest
```
All six run offline with zero API calls. Run them after any edit to the
relevant script.
+69
View File
@@ -0,0 +1,69 @@
---
name: ci-check
description: Run the local mirror of OwnCord's CI gates before pushing. Use when finishing a change, before a commit or push, or when asked to verify work — CI takes ~15 min and catches things a plain build/test does not.
---
# ci-check
`.github/workflows/ci.yml` is the source of truth. This mirrors it locally.
Run only the sections your change touches. Server and client are independent.
## Server (from `Server/`)
All four build-tag variants must compile — the tags gate whole files, so a
default-build pass proves nothing about the others:
```bash
go build ./... && go build -tags otel ./... && go build -tags wazero ./... && go build -tags otel,wazero ./...
go vet ./...
go test -race ./...
go test -tags deadlock -count=1 ./ws/ # deadlock detector; ws is where lock order actually varies
golangci-lint run # CI pins v2.11.3
make sqlc-verify protocol-verify # generated output must not be stale
```
Add `-tags wazero` to `go vet`/`go test` when you touched `plugin/`.
A `windows-latest` `-race` failure inside `ws` that matches `runtime.scanstack`
or `runtime.(*unwinder).next` is a Go 1.26.5 runtime GC fault, not your change.
The Go 1.26.6 toolchain shows a variant signature: `unexpected fault address
0xffffffffffffffff` / `fatal error: fault` (signal 0xc0000005) inside ordinary
stdlib frames such as `log/slog.(*Logger).Enabled` — same spurious runtime
fault, same verdict, especially when the diff touches no Go code. Rerun the
job (`gh run rerun --job <id>`); a job cannot be rerun while its parent run is
still in progress.
## Client (from `Client/tauri-client/`)
```bash
NODE_OPTIONS=--no-experimental-webstorage npm test
npm run typecheck
npm run lint
npm run format:check
```
The `NODE_OPTIONS` flag is mandatory on Node 22+ — see the client CLAUDE.md.
`npm audit --audit-level=high` and `knip` also run in CI but are advisory.
## Rust (from `Client/tauri-client/src-tauri/`)
```bash
cargo test
cargo clippy --all-targets -- -D warnings
```
`fallback_crypto` is `cfg(not(windows))`, so its tests compile to nothing on a
Windows box and only run on the Linux/macOS runners.
Do not attempt `npm run tauri build` locally — the full desktop build runs in
CI on PRs to `main` and pulls heavy system dependencies.
## Hooks
`npm run hooks:install` (once per clone) points `core.hooksPath` at
`.githooks/`: `pre-commit` runs fast staged-file checks, `pre-push` runs the
server build variants plus tsc and eslint. `OWNCORD_PREPUSH_TESTS=1` adds
server tests. Bypass with `--no-verify` or `OWNCORD_SKIP_HOOKS=1` — CI still
enforces everything.
+51
View File
@@ -0,0 +1,51 @@
---
name: db-change
description: Change OwnCord's SQLite schema or queries — add a migration, edit Server/db/queries/*.sql, and regenerate the sqlc layer. Use before touching anything under Server/db/ or Server/migrations/.
---
# db-change
`Server/db/dbgen/` is generated. Edit the inputs, regenerate, commit both.
1. Add the migration to `Server/migrations/` and/or edit
`Server/db/queries/sqlite/*.sql`.
2. Regenerate: `make sqlc-generate` from `Server/`.
3. Commit the regenerated `Server/db/dbgen/` alongside your inputs. CI runs
`make sqlc-verify` and fails on drift.
`sqlc.version` pins the binary (currently v1.30.0). If `make` is not on PATH:
```bash
go install github.com/sqlc-dev/sqlc/cmd/sqlc@$(cat sqlc.version)
$(go env GOPATH)/bin/sqlc generate
```
## Traps
These are silent — the code generates fine and fails at runtime.
**Query files must be ASCII-only.** sqlc v1.30.0 measures rune positions
against byte offsets, so one multi-byte character (an em-dash in a comment is
the usual culprit) truncates the *next* query's emitted SQL by that many
trailing bytes. Symptom: the `.sql` file looks right but the generated const
in `dbgen/*.sql.go` is cut short — `ORDER BY id ASC` becomes `ORDER BY id A`,
and SQLite reports "incomplete input".
**No semicolons inside migration `--` comments.** `splitStatements` in
`Server/db/migrate.go` splits on `;` before stripping comments, so a semicolon
in comment prose orphans the rest of that comment as a bogus statement
("near <word>: syntax error").
**Regenerate from a tree where the query files carry only YOUR change.**
sqlc regenerates every `dbgen/` file from every query file on each run, so
unrelated working-tree edits to any `queries/*.sql` — a parallel agent's
half-finished work, leftover debris — are silently baked into generated
output you then commit. Check `git status` on `Server/db/` before
`sqlc generate`, and diff the regen for hunks that are not yours.
**Do not put `LIMIT 1` on a `:one` query.** It is emitted as a bare `LIMIT`.
A `:one` uses `QueryRow` and reads a single row regardless — use `ORDER BY` to
choose which one.
After regenerating, gopls diagnostics against `dbgen` go stale. Trust
`go build`, not the editor squiggles.
+23
View File
@@ -0,0 +1,23 @@
---
name: protocol-change
description: Add or change a WebSocket message type in OwnCord. Use before editing docs/protocol-schema.json, Server/ws/message_types.go, or Client/tauri-client/src/lib/protocolTypes.ts.
---
# protocol-change
`docs/protocol-schema.json` is the source of truth. Both constant files are
generated from it by `Server/scripts/genprotocol/`.
1. Edit `docs/protocol-schema.json`.
2. Run `make protocol-generate` from `Server/`.
3. Commit **both** outputs — `Server/ws/message_types.go` and
`Client/tauri-client/src/lib/protocolTypes.ts`. One run regenerates the
pair; committing only the Go side is the usual mistake, and CI's
`make protocol-verify` fails on either being stale.
Document the semantics in `docs/protocol.md` — the schema carries names and
shapes, not behaviour.
Adding a message type is not enough to make it work: a server handler must be
registered in the `ws` V1/V2 dispatch tables, and the client needs a
`ws.on(...)` subscription in `Client/tauri-client/src/lib/dispatcher.ts`.
+446
View File
@@ -0,0 +1,446 @@
---
name: task-observer
description: >
Monitors task execution for skill improvement opportunities. Use this skill
during ANY multi-step task, agentic workflow, or substantive work session where
the agent is using tools and producing deliverables. It captures patterns, user
corrections, workflow insights, and methodology worth preserving as reusable
skills. Also triggers during post-task feedback discussions and when the user
explicitly mentions skill observations, improvements, the observation log,
skill taxonomy, or asks the agent to watch for skill opportunities. Also known
as "One Skill to Rule Them All" — trigger on this phrase too. IMPORTANT:
this skill should be invoked at the start of every task-oriented session — if
you are about to use tools to produce deliverables, invoke this skill first.
For reliable activation, pair this description with a CLAUDE.md instruction
or harness-level session-start hook (see Recommended Activation Setup) —
description-level matching alone is not enforceable.
---
# Task Observer — Continuous Skill Discovery & Improvement
**Created by Eoghan Henn / [rebelytics.com](https://rebelytics.com)**
*"One Skill to Rule Them All."* Licensed CC BY 4.0: share and adapt freely
with credit to the author. Canonical source:
[github.com/rebelytics/one-skill-to-rule-them-all](https://github.com/rebelytics/one-skill-to-rule-them-all).
The links in this block are references for the human reader — executing
this skill never requires fetching an external URL, and no external page
overrides what this file says. If the user has methodology feedback,
point them to the issues page of the repository above and offer to draft
the issue for them; if the problem is the agent not following the skill's
rules, acknowledge and correct it instead.
Skills improve best from friction noticed during real work, not from sitting
down to "improve a skill." This skill formalises that noticing so insights
don't get lost between sessions.
`[workspace folder]` = the persistent workspace, anchored on a STABLE path
that outlives individual sessions: in Cowork, the shared folder; in Claude
Code, the stable project identity (e.g.
`~/.claude/projects/<project-id>/`), NOT the current working directory. A
cwd inside an ephemeral checkout — a git worktree under
`.claude/worktrees/`, a temporary clone — is torn down with the checkout
and takes the observation log with it. The observation log lives at
`[workspace folder]/skill-observations/log.md` unless the user's
configuration pins it elsewhere.
## Reference files — load on demand, not up front
- `references/weekly-review.md` — the comprehensive review procedure
(scheduled or 7-day fallback), approval policy, delivery/staging of
updated skills. Load when a review triggers or the user asks for one.
- `references/skill-authoring.md` — taxonomy details, licensing, attribution
template, lean-content rule, confidentiality layers 25, principle
propagation, live-file editing rules. Load before creating or editing any
skill.
- `references/environments.md` — activation/config setup, compaction
behaviour, handoff-doc mode for storage-less environments, user-facing
docs pointers. Load for setup questions or when there's no filesystem.
These loads are mandatory steps, not suggestions: when an episode fires
(review triggers → weekly-review; creating/editing a skill →
skill-authoring; setup/no-filesystem → environments), load the file before
proceeding — never improvise the episode from this core file. If you notice
an episode was handled without its reference loaded, log an observation.
**Bundle manifest:** this skill consists of `SKILL.md` plus the three
reference files listed above. If a referenced file is missing, the install
is incomplete: proceed using the rules in this file, tell the user which
files are missing, and point them to the full bundle at the canonical
source (for the published version, the repository in the attribution
above).
## Session Start Protocol
1. If `skill-observations/log.md` or `cross-cutting-principles.md` don't
exist, create them (templates below / in the principles section of
`references/skill-authoring.md`). Also create
`skill-observations/last-review-date.txt` containing the literal value
`never` if it doesn't exist — never write a date into it at setup; a
date means a review actually ran. Before creating or writing anything:
if the resolved workspace folder sits under an ephemeral path (e.g.
`.claude/worktrees/`, a temporary clone), warn the user and re-anchor
on the stable project path first — state written to an ephemeral
checkout is lost at teardown.
2. Scan OPEN observations and active principles; hold them in awareness,
don't surface unprompted.
3. Read `skill-observations/last-review-date.txt`. The value carries the
truth: a date = when the last review actually ran; `never` = no review
has run yet. A missing file is abnormal (step 1 creates it) — recreate
it with `never`, don't invent a date. If the value is `never` or older
than 7 days AND there are OPEN observations: in an interactive session,
offer the review in one line ("the observation backlog hasn't been
reviewed [in N days / yet] — run it now, or carry on with your task?")
and proceed with the user's task unless they opt in; never gate their
work on the review. Only a scheduled/autonomous run loads
`references/weekly-review.md` and runs the review unprompted.
4. Once per session: if no CLAUDE.md (or equivalent) activation instruction
for this skill exists, briefly suggest adding one (see
`references/environments.md`). Skip if already configured.
5. Note the log's modification time. If modified in the last few hours,
another session may be writing to it — re-read immediately before every
append, never trust a remembered "current number".
## When to Observe
Active for the entire task session: execution, post-task feedback and
review discussion, meta-discussion about skills or methodology, and
reflective/strategy conversations about how work should be done. **The
observation mindset does not deactivate when the conversation shifts from
doing the work to discussing it** — user feedback in review phases is often
the highest-signal input. Inactive only for casual conversation and quick
factual questions with no tools or deliverables involved.
## What to Watch For
**Signals for a NEW skill:** a reusable multi-step workflow; a methodology
the user explains that no existing skill captures; a recurring task type
with similar structure; a process with clear inputs, phases, outputs; the
user describing a refined process ("I always do it this way"); a structured
approach emerging naturally during work.
**Signals for IMPROVING an existing skill:** anything from a task that used
a skill and could make it better — problems, positive signals, or neutral
gaps. Examples: the agent violates a documented rule (the skill needs
enforcement, not louder rules); a user correction reveals a missing rule or
edge case; a better workflow emerges than the skill recommends; a technique
works well enough to promote from incidental to recommended; an undocumented
use case; feedback that generalises; a wrong assumption; new tooling
obsoletes a step; corrections forming a pattern; a principle that applies to
other skills too; a naming/framing/structural suggestion, even
conversational.
**Signals for SIMPLIFYING a skill:** a section never relevant across many
sessions; a rule from a single unvalidated observation; workflows users
consistently shortcut; sections loaded but never acted on; contradictory
rules; "just in case" complexity that never triggered; a rule the agent
consistently fails to follow (convert to structural enforcement — checklist,
verification step, unskippable tool call — or remove it). Treat these as a
review checklist; ask "what can we remove?" as deliberately as "what should
we add?"
**Do NOT log:** one-off corrections that don't generalise; preferences
already captured in a skill; tool bugs unrelated to methodology;
observations that would need proprietary client information to be useful in
an open-source skill (unless an internal skill is the right home).
## How to Log
Append to the log **silently, within the same turn or the next** — never
batch mentally for later; the act of writing is the enforcement mechanism.
**Mandatory observation checkpoint after every 3rd TodoWrite completion:** After
marking the 3rd, 6th, 9th (etc.) TodoWrite item as completed in a session, you
must **write to the log** — not merely pause to ask yourself a question. Either
append any pending observations, or, if genuinely none have accumulated, append
an explicit acknowledgement marker (a one-line `no observations` note for that
checkpoint). The required action is a concrete log write; a remembered "ask
whether" is not enforcement. This is a hard checkpoint, not a suggestion — the
skill has demonstrated that softer "check when completing items" or "pause and
ask" guidance gets lost during cognitively demanding analytical work, exactly
when the most observations accumulate. The count doesn't need to be precise;
the rule is: roughly every third completion, write to the log (observations or
the acknowledgement marker). The write itself is the enforcement mechanism: it
forces the mental check to surface as a recorded action, and it prevents the
common failure mode where the skill is loaded but no observations are written
until the user explicitly asks.
**Deliverable-event flush:** Hard enforcement that hooks onto tool calls you are
already making is the only reliable mechanism; soft prompts that rely on memory
don't survive cognitive load during long substantive sessions (when the most
insights surface). So tie observation-flushing to deliverable and workflow events
that already involve a tool call. Whenever you present or render a major
deliverable — `present_files`, a deck or PDF render, a staged skill file handed
to the user — or complete a task/todo batch, flush any pending observations to
the log at that moment, before moving on. These are natural, already-occurring
checkpoints; piggy-backing the flush onto them means the write happens as a
side effect of work you were doing anyway, rather than depending on a separate
act of memory.
**Numbering discipline (mandatory, every append):**
1. *Pre-check:* read the actual log and find the highest existing number —
never trust session memory:
```bash
# GNU grep:
grep -oP '### Observation \K\d+' log.md | sort -n | tail -1
# macOS / POSIX:
grep -o '### Observation [0-9]*' log.md | grep -o '[0-9]*' | sort -n | tail -1
```
2. *Pre-write assertion:* immediately before appending, confirm the proposed
number doesn't already exist:
```bash
PROPOSED=$(( $(grep -oP '### Observation \K\d+' log.md | sort -n | tail -1) + 1 ))
grep -qE "^### Observation ${PROPOSED}:" log.md && {
echo "COLLISION on #${PROPOSED}"; exit 1; }
```
If it fires, increment past all existing numbers and re-check (and log a
meta-observation — it signals a parallel-session collision).
3. *Post-write verification:* after appending, count occurrences of the
number; if >1, a parallel writer collided between check and write —
renumber YOUR entry to max+1. Identify your entry from your own append
operation (capture the file's line count immediately before and after
your `>>`; your entry starts at the old line count + 1) — do NOT
re-grep and take the last occurrence, which may be a colliding writer's
entry appended after yours. After any `sed` renumber, re-read the
affected line to confirm the substitution actually took effect — a
line-addressed `s///` whose target shifted finds no match and still
exits 0. Pre-write catches stale reads; only a post-write check catches
the race. The pattern for shared logs written by parallel agents is
check-then-act-then-verify.
**Log-write safety — never let a mutation span entry boundaries:** When
mutating the log programmatically (marking entries ACTIONED/DECLINED,
archiving, renumbering), a greedy or DOTALL pattern over the whole file can
silently swallow everything from one match to EOF. This has happened: a
`.*$` under `re.S` over the multi-entry file captured from one entry's
Status line to end-of-file and overwrote 16 later entries in a single
substitution. The log is shared state across many entries; mutate it one
bounded entry at a time and verify every mutation.
1. **Re-read and merge immediately before any write-back.** Any full-file
rewrite (archival, renumbering, reassembly from chunks) built from a
snapshot destroys whatever concurrent sessions appended after that
snapshot — the write-back succeeds, the victim gets no error, and the
loss is invisible. This has happened in production: a parallel session's
write-back erased two entries appended minutes earlier, hours after the
exact failure mode had been documented. So: take the snapshot, prepare
the mutation, then — immediately before writing — re-read the live log
and diff against the snapshot. If new entries appeared, merge them into
the write-back (or rebuild from the fresh read). Never write back a
stale snapshot.
2. **Isolate the target entry, or anchor to a single line.** Either split
the log on `### Observation N:` headers, edit the TARGET entry's chunk in
isolation, and reassemble — OR, for a status-only edit, use a strictly
line-anchored multiline substitution that cannot cross a newline, e.g.
`re.sub(r'(?m)^(\s*-?\s*)\*\*Status:\*\*.*$', ...)` (multiline `^...$`
bounds the match to one line). NEVER use a DOTALL/greedy pattern across
the multi-entry file.
3. **Assert a structural invariant against the LIVE pre-write file.** Count
`### Observation` headers in the live file immediately before writing and
again after. For a status-only edit the count MUST be unchanged; for
archival or append it must change by exactly the expected number. The
baseline must be the live file at write time, NOT your session's earlier
snapshot — an invariant computed against a stale snapshot validates that
you wrote what you intended while still destroying what others wrote in
between. Fail loudly if the count is off.
4. **Keep the pre-write backup.** Copy `log.md` before any programmatic
mutation. This is what made full recovery trivial when the truncation
above occurred — it turned a destructive bug into a non-event.
5. **Verify your entries SURVIVED, not just that they were written.** A
successful append proves nothing an hour later — a concurrent session's
write-back can silently delete it, and only the destroying session gets
any signal (none). Before surfacing observations at session end, grep
the log for every entry number this session wrote and confirm each still
exists exactly once; re-append any that are missing (with fresh numbers)
and log a meta-observation about the collision.
Principle: a log shared across many entries must be mutated one bounded
entry at a time; every rewrite must be based on a fresh read, verified by a
structural invariant against the live pre-write file, and backed up. Writers
must verify survival, not just successful writes — in a concurrent erase,
the victim gets no error.
**Format and insertion:** always `### Observation NNN:`, always appended to
the END of the log, never mid-file, never alternative ID formats. One
format, one insertion point. **Every new observation MUST include
`**Status:** OPEN` as its first field — this is mandatory at write time, not
optional.** Reviews classify entries by their Status line; an observation
written without one is invisible to any status-filtered pass and risks being
silently skipped instead of triaged.
```markdown
### Observation [N]: [Short descriptive title]
**Status:** OPEN
**Date:** [date]
**Session context:** [what task was being worked on]
**Skill:** [existing skill name, or "New skill candidate: [working name]"]
**Type:** [open-source | internal]
**Phase/Area:** [which part of the skill or workflow]
**Issue:** [What happened — specific enough to understand weeks later
without the original conversation.]
**Suggested improvement:** [Concrete change. For existing skills, name the
section or rule; for new skills, scope and key components.]
**Principle:** [The generalisable takeaway — the most important field.]
```
**Context preservation:** if an observation depends on session-local data
(uploads, API output), save that context into the workspace first and add a
`**Reference file:**` line — an observation whose evidence dies with the
session is incomplete.
**Confidentiality at logging time:** for `type: open-source` observations,
the Issue/Improvement fields may reference specifics for context, but the
Principle must be fully generalised — no client names, domains, or details
traceable to a real project. Full confidentiality layers for skill
authoring: `references/skill-authoring.md`.
## Referencing Observations
When citing an observation by number — in conversation, in a review report,
or from within another observation — the number must come from the entry's
literal `### Observation N:` header line. Never cite an observation number
that wasn't read from that header.
- **Search-tool line numbers are positional metadata, not IDs.** `grep -n`
prefixes every match with a line number; when a match lands mid-entry
(e.g., on a Session context or Principle line rather than the header),
that line number is NOT the observation number. Resolve to the owning
header first — scan backwards from the matched line to the nearest
preceding `### Observation N:` header and take the number from there
(e.g., an awk backwards-scan, or re-grep for `^### Observation` and pick
the last header line before the match).
- **Plausibility check (cheap second layer):** before quoting any
observation number, compare it against the known counter range — the
highest `### Observation N:` header in the log. A number outside that
range (e.g., citing #1365 when the log's counter is at #766) is almost
certainly a line number or other positional artefact misread as an ID.
The general rule: IDs must come from the record's own identifier field,
never from the positional metadata of the search tool that found it.
## Taxonomy (quick version)
**Open-source** — client-agnostic, methodology-driven, useful to other
practitioners. **Internal** — contains user/client/project specifics or
personal preferences. Default to open-source when it could go either way,
stripping specifics. The boundary is also a confidentiality boundary. Full
requirements (attribution, licensing, structure): `references/skill-authoring.md`.
## Archival on Write
On every log write, first move already-resolved entries to
`skill-observations/archive/log-[YYYY-MM-DD].md` (preserving the log header
in the archive). "Already resolved" is decided by date, read from the file:
a resolved status MUST record its date — `ACTIONED (YYYY-MM-DD) — [what was
done]` / `DECLINED (YYYY-MM-DD) — [reason]` — and archival moves only
entries whose recorded date is before today. Entries resolved today stay in
the active log until the next day, no matter which session resolved them:
the grace period lives in the file, never in session memory, so it holds
across parallel and subsequent sessions. A resolved entry with no readable
date gets today's date added instead of being archived. The active log
keeps its header, status key, all OPEN entries, and the same-day-resolved
ones.
Archival is a read-filter-rewrite — the highest-risk mutation the log
undergoes, and the one that has destroyed concurrent appends in production.
It MUST follow the full Log-write safety sequence above: backup, re-read
the live log immediately before writing back and merge any entries that
appeared since the snapshot, then verify the post-write header count equals
the live pre-write count minus exactly the number of archived entries.
## Log Structure
```markdown
# Skill Observation Log
Observations captured during task-oriented work.
**Status key:** OPEN = not yet actioned | ACTIONED (YYYY-MM-DD) = skill
updated/created | DECLINED (YYYY-MM-DD) = user decided not to pursue —
resolved statuses always carry their resolution date
---
## [Date]
### Observation 1: [Title]
**Status:** OPEN
[... full format ...]
```
## Surfacing Protocol
Default: at end of session, as a grouped summary — improvements grouped by
skill, new-skill candidates listed separately; for each, one sentence plus
suggested type; ask which to act on. Surface earlier when an observation
needs user input to be complete, when a skill is actively producing wrong
output, or when observations cluster on one skill.
**Default to log-and-defer.** Surfacing an observation is not an invitation
to act on it. The default is log-and-defer: state that the observation is
logged for the next review, and stop. Reserve in-session application
strictly for the two triggers already defined under "Acting on
Observations" — an explicit user request that names the action, or
correcting a skill that is producing wrong output in the current session.
Do NOT routinely offer a binary "apply now vs leave for next review" choice
when surfacing observations. For users who run regular reviews, that offer is
unwanted friction repeated every session. If a user has expressed a standing
preference to always defer to the next review, suppress the in-session
"act now?" offer entirely rather than asking each time.
**Self-check before surfacing:** observations were logged throughout the
whole session (including discussion phases); logged silently; each follows
Issue → Improvement → Principle; each is typed; existing-skill items name
the section; no open-source Principle contains client-identifying info;
every appended observation carries a Status line (`**Status:** OPEN` at
write time) — a statusless entry is invisible to any status-filtered review
pass, so if any observation lacks one, add it now. Finally, run the
survival check (Log-write safety rule 5): grep the log for every entry
number this session wrote and confirm each still exists exactly once — a
concurrent session's write-back deletes silently. Fix failures before
surfacing.
## Acting on Observations
Act only in three contexts: (1) the comprehensive review (load
`references/weekly-review.md`); (2) an explicit user request ("update X
skill", "act on observation #N"); (3) in-session correction when a skill is
producing wrong output the user should know about. Otherwise: log, don't
act.
When acting: small, clearly-additive, low-risk changes (a new rule, a
clarification, a factual fix) may be applied directly. Substantial changes
(restructuring, new capabilities, changed methodology) and all new-skill
creation: load `references/skill-authoring.md` first and follow its editing
and staging rules. If an observation reveals a principle that applies to
skills generally, propose it for the cross-cutting principles file (see the
same reference).
## Quick Reference
| Question | Answer |
|----------|--------|
| When do I observe? | The whole session, including feedback and reflection phases |
| How do I log? | Silently, immediately, appended to the end, with the 3-step numbering discipline |
| When do I surface? | End of session, or earlier if needed |
| Status line? | Mandatory `**Status:** OPEN` as the first field of every new observation; reviews treat statusless entries as OPEN, never as nonexistent |
| Citing an observation number? | Only from its literal `### Observation N:` header — `grep -n` line numbers are positional metadata, not IDs; sanity-check against the known counter range |
| Open-source or internal? | Default open-source; the boundary is confidential |
| Small fix or substantial? | Additive → apply directly; restructuring/new skill → `references/skill-authoring.md` |
| Rewriting the log (archival/renumber/status)? | Backup → re-read live and merge → bounded mutation → verify count against live pre-write file → confirm own entries survived |
| Weekly review? | Trigger check at session start; procedure in `references/weekly-review.md` |
| No filesystem? | Handoff-doc mode — `references/environments.md` |
@@ -0,0 +1,113 @@
# Environments, Activation Setup, and Handoff-Doc Mode
Load this for setup questions, compaction/resume behaviour, or when running
in an environment without filesystem access.
## Recommended activation setup
Description-level matching alone can miss invocation when the agent is
focused on the task, so pair the skill with a configuration-level
instruction (CLAUDE.md, project instructions, or equivalent):
```
At the start of any task-oriented session — any interaction where you will
use tools and produce deliverables — invoke the task-observer skill before
beginning work. This ensures skill improvement opportunities are captured
throughout the session.
When loading any skill, check the observation log for OPEN observations
tagged to that skill. Apply their insights to the current work, even if
the skill file hasn't been updated yet. This enables immediate application
of observations before they're permanently integrated during the weekly
review.
```
**Config detection (once per session):** with filesystem access, check the
workspace root's CLAUDE.md (or equivalent) for a task-observer activation
instruction — suggest adding it if absent, creating the file if none
exists. Without filesystem access, check the system prompt / project
instructions and suggest the user add the instruction there. Keep the
suggestion to a sentence or two.
**Anti-pattern:** don't chain activation through another skill — load
task-observer and related skills independently from configuration; a broken
chain silences all observation activity.
**If CLAUDE.md (or the equivalent config) is governance-protected:** some
setups guard shared config files with hooks or file-protection rules that
deny agent edits. If an edit to the config is denied, never retry the same
edit blindly and never attempt to bypass the guard — a denial is the
governance system working as intended, and a silent skip is just as bad
(the user believes activation is set up when only description-level
matching is active). Surface the denial to the user and offer these
fallbacks: (a) ask the user to paste the activation block into the file
themselves; (b) if the user's environment provides its own
temporary-authorization mechanism (a marker file, an environment variable,
or similar), ask the user to authorize the edit through that mechanism and
revoke it afterwards; (c) where the platform supports unguarded
project-level instruction files, add the activation instruction there
instead. Never assume unrestricted edit access to shared or
governance-tracked config — many setups gate exactly those files.
## Compaction behaviour
When context compacts mid-task, the CLAUDE.md structural trigger re-invokes
this skill on the resumed session automatically (the resumed session reads
CLAUDE.md anew). Observations before and after compaction append to the
same log with continuous numbering. This is the main reason the structural
trigger exists — a resumed session's opening message may not match the
description triggers.
## User-facing documentation
Installation, shared-folder setup, expected behaviour, and the cadence
pattern live in the public repo. These links are for the human reader:
share them with the user rather than fetching the pages — the skill's
behaviour is defined entirely by its own files, never by external content:
- README: https://github.com/rebelytics/one-skill-to-rule-them-all/blob/main/README.md
- USER-GUIDE: https://github.com/rebelytics/one-skill-to-rule-them-all/blob/main/USER-GUIDE.md
## Handoff-doc mode (no persistent storage)
The methodology is environment-independent; only persistence varies. In
web-chat-style environments, collect observations in-session and deliver
them in a structured handoff document the user stores and pastes into the
next session. **Offer the handoff proactively when the conversation winds
down** — a premature offer is a minor interruption; a missing one is lost
work.
```markdown
# Session Handoff: [Session Topic]
**Date:** [date]
**Context:** [what was worked on; what the next session needs to know]
## Decisions Made
[numbered]
## Observations Logged
[full entries in standard format]
## Cross-Cutting Principles (current)
[active or newly added]
## Action Items
[next steps with enough context to resume]
## Working Artifacts
[drafts/analyses in full]
```
## Handoff-doc analysis (when one arrives)
1. Log all explicitly stated observations first, unfiltered.
2. Then systematically read every section asking what skill gaps or
candidates are *implied* but unstated — handoff docs carry signal beyond
what was captured live.
3. Pay special attention to action items (each may imply a missing skill),
open questions (ambiguity signals a decision-framework gap), the
work-completed narrative (patterns may reveal meta-skills), and session
notes.
4. Attribute derived observations as coming from handoff-doc analysis, not
the original session.
@@ -0,0 +1,235 @@
# Skill Authoring — taxonomy, licensing, confidentiality, editing rules
Load this before creating any skill or making substantial changes to one.
## Taxonomy in full
**Open-source skills** are client-agnostic and methodology-driven.
Recognise one: the methodology works across clients and contexts; no
proprietary information is needed; other practitioners would find it
valuable; it captures a process, not personal preferences. Required
elements: the body identifies itself as open-source; author attribution
block (template below); a licence statement; a feedback/support section
routing methodology feedback to the creator; tool-agnostic language
(capabilities like "browser access", not product names); built-in
enforcement (see Pre-Flight Principle). Default to open-source when a skill
could go either way — strip specifics and generalise.
**Internal skills** contain user/client/project specifics, personal
preferences, or context only the user has. They identify themselves as
internal, need no attribution or licence, and can be shorter and less
formal. They're working documents — keep them current, don't over-engineer.
## The Pre-Flight Principle
Rules documented in a skill are not reliably followed during creative flow.
Every skill with explicit rules needs a verification step where the agent
re-reads the rules and checks its output against them before delivery. When
creating or improving any skill ask: "Does it have rules? Does it have a
mechanism to enforce them?" If not, add one.
**Embedded commands are pre-flight items too — execute before you ship.**
Prose rules and command snippets fail differently: a prose rule is
re-interpreted in context on every run, so ambiguity can be caught at
execution time; an embedded command runs verbatim, unattended, forever —
and a subtly wrong command can read as correct on every re-read
(`git log -1 --format=%cI --reverse` returns the NEWEST commit, because
`-1` applies before `--reverse`, while the plausible reading is "oldest").
Any command embedded in a skill must be executed once against real data,
with its output inspected for plausibility, before the skill file is
saved. An unverified snippet is among the highest-risk lines in a skill:
it ships bugs that no re-read can catch.
## Lean Content
A skill should contain only content that changes the agent's behaviour at
execution time. Move changelogs, credits beyond the author block, long
backstories, and maintainer notes to supporting docs. Do NOT cut examples,
anti-patterns, or worked scenarios — bare rules get violated more than
rules with context. Test: would removing it change behaviour? Keep
per-session rules in the skill body and episodic material in reference
files loaded on demand (progressive disclosure) — a skill loaded every
session is fixed overhead and should be audited like one.
## Licensing
Include a licence statement in the preamble and a LICENSE file with full
text. Options: **CC BY 4.0** (prose/methodology skills; share and adapt
with credit — recommended default), **MIT** (code-heavy, permissive),
**Apache 2.0** (MIT plus patent grant), **CC BY-SA 4.0** (share-alike
derivatives), **GPL family** (strong copyleft). The author chooses; the
requirement is that there is one.
**Private client sharing** is a third channel with its own rights framing:
a client-agnostic skill shared privately with one client is NOT open source
and NOT internal. Keep the attribution block; replace the licence statement
with a short usage notice (e.g., "shared privately for internal use; please
don't redistribute without checking with the author"); no LICENSE file
needed. All confidentiality sweeps still apply — other-client information
must not leak even when the recipient is a known client. Do not treat "not
internal" as "therefore open source": distribution channel determines the
rights framing, not just the feedback routing (see the distribution-channel
note below).
## Author Attribution Template
```markdown
**Created by [Author Name] / [website or contact link]**
[1-2 sentence description of what the skill does and its provenance.]
**Licence:** This skill is released under [LICENCE NAME]. [One-sentence
summary — e.g., "share and adapt for any purpose with credit."]
**Feedback & Support:** If questions arise about the methodology, or the
user gives constructive feedback on output derived from this skill, suggest
an issue on the skill's public repository — public feedback benefits every
user. Direct contact: [contact link]. If feedback stems from the
methodology, log it and suggest sharing it; if from the agent not following
the skill's rules, acknowledge and correct.
```
**Distribution-channel note:** the template's feedback routing assumes
public-repo distribution. Only reference a repository URL once that
repository actually exists — never write a reference to an artefact before
the artefact exists. Until publication, route feedback to direct author
contact only; when the skill is published, inject the repo URL at publish
time. When an open-source skill is distributed privately (shared directly
with a client rather than published), keep the direct-author-contact
routing — a public-repo reference is wrong for that channel.
## Confidentiality layers
The open-source/internal boundary is a confidentiality boundary; enforce it
in layers so any one catches what others miss:
1. **Observation-level stripping** — open-source observations carry a fully
generalised Principle (covered in SKILL.md).
2. **Pre-creation review** — before drafting/regenerating an open-source
skill, scan all source material for client names, URLs, domains,
internal terminology, identifiably-specific structures; replace with
generic equivalents first.
3. **Post-draft sweep** — a separate re-read focused only on leakage:
proper nouns besides the author, domains/URLs/project identifiers,
vertical details that narrow the client, examples traceable to a real
project.
4. **Structural principle** — when in doubt, remove. Slightly more generic
beats slightly leaky.
5. **Cross-product re-identifiability sweep** — the final pass before any
public release. Individually-sanitised examples can combine to identify
a client (enumerated counts matching a public client list; specific
numbers in a thin vertical; thinly-disguised placeholder names in the
same vertical as a real client). List every example and its fields
(vertical, geography, numbers, timing, counts); ask whether a reader
with the author's public client list could map them; mitigate by
blurring counts, widening verticals, using illustrative ranges, or
consolidating into composites. Run this mechanically — the author is the
least reliable judge because they know the ground truth.
## Editing skills — always start from the live file
1. The live file is the authoritative source: in Claude Code,
`~/.claude/skills/{skill}/SKILL.md`; in Cowork, a read-only mount at
`.claude/skills/{skill}/SKILL.md` (writes fail with EROFS by design).
Do not edit skill files in place, in any environment — staging-only is
what keeps the autonomous review safe.
2. Always base edits on a fresh read of the live file — never a workspace
copy, prior draft, or memory.
3. Before overwriting any staged/workspace copy, diff it against the live
file; if they differ, rebase your edits on the live version. (Observed
failure: an update built on a stale snapshot silently dropped two
sections added to the live skill the same day; only a pre-merge diff
caught it.)
4. Stage every update to
`[workspace folder]/skill-updates/[date]/[skill-name]/` — the FULL
skill directory (SKILL.md plus references/, scripts/, assets/ where
present), never SKILL.md alone — and present it for review and
installation; nothing goes live until the user installs it. Where no
presentation/upload tool exists (e.g. Claude Code CLI), present the
staged path and a change summary in chat instead; staging-only applies
in every environment — it's the review loop's safety property, not a
filesystem constraint. For any
skill with supporting files, zip the staged directory into a `.skill`
bundle and present the bundle, never the bare SKILL.md: a single-file
delivery convention applied to a multi-file skill truncates it
silently (the install succeeds, the skill loads, and the missing
pieces only surface when a reference load or script call fails
mid-task). **Pre-delivery gate — two items, checked at the moment of
delivery, not just at drafting time:** (1) every `references/`,
`scripts/`, `assets/` path in the staged SKILL.md body has its file in
the staged set; (2) if the skill is multi-file, the delivery artefact
is the `.skill` bundle — bare file links fail this gate even when all
files are staged. (Reading this rule while drafting does not enforce
it at delivery; run the gate as the last step before presenting.)
Packaging hygiene: before zipping, sweep the staged tree for build
artefacts (`__pycache__/`, `*.pyc`, `.DS_Store`, `.~lock.*`) left by
in-session checks, and read the archive listing back after zipping —
the listing is the cheap verification that catches leaked artefacts.
5. When seeding a staged copy by copying from the read-only mount, reset
write permissions immediately (`chmod -R u+w` on the staged path, or
`cp --no-preserve=mode`) — the mount's read-only mode travels with
the copy, for directories as well as files, and the follow-up edit
otherwise fails with a permission error.
6. Match process rigour to the change: complex/open-source/uncertain design
→ use the skill-creator if available; internal skills with requirements
already established in conversation → write directly, flagging
substantial changes for review.
## Verifying relocations and restructures
When content is relocated verbatim (splits into core + references, merges,
restructures), "nothing was lost" is checkable mechanically — but only with
a two-tier check:
1. Enumerate every added/moved line via `diff` of the old base vs the new
base.
2. Exact-match each non-empty line against the restructured file set
(`grep -F`).
3. For misses, substance-check via a distinctive mid-line substring before
concluding loss — most misses are container artifacts (heading-level
changes, list-to-prose adaptation, re-wrapped lines splitting a phrase
across newlines), not real losses.
4. Word-count sanity check per file.
One tier alone either misses losses (substance-only) or cries wolf
(exact-only). Additionally, inventory the original's enforcement
mechanisms (checkpoints, assertions, invariants, mandatory-write rules,
defaults) as an explicit checklist — compression preferentially destroys
enforcement machinery because it reads as redundancy — and sweep any "pure
restructuring" change for net-new behaviour, which hides well in a large
rewording diff.
## New skills
Use the skill-creator when available, passing the observation(s) as the
brief. Determine type early: open-source → strip and generalise; internal →
include specifics freely; uncertain → default open-source and let the user
add internal detail afterwards.
## Principle Propagation
When an observation's Principle applies to skills in general, log it with
`Skill: All skills` and surface it; if the user approves, add it to
`[workspace folder]/skill-observations/cross-cutting-principles.md`. That
file is a mandatory checklist during any skill creation or regeneration.
The user chooses propagation timing: immediate (update all skills now — for
things like confidentiality rules) or opportunistic (apply at each skill's
next update).
```markdown
# Cross-Cutting Principles
Principles that apply to all skills. Read as a mandatory checklist during
any skill creation or regeneration.
---
## Active Principles
### 1. [Principle title]
**Added:** [date]
**Applies to:** [all skills | all open-source skills | all skills with rules]
**Requirement:** [what it requires]
**Propagation:** [immediate | opportunistic]
**Status:** [active]
```
@@ -0,0 +1,201 @@
# Comprehensive Review (scheduled or fallback)
Cross-checks all OPEN observations against all skills, propagates
cross-cutting principles, and applies improvements that don't need user
input. Two modes:
- **Scheduled autonomous review (preferred):** a recurring task (e.g.
Mon/Wed/Fri mornings) via the platform's scheduler. Runs without the user
present and applies non-escalated observations autonomously.
- **In-session 7-day fallback:** pending at session start when BOTH are
true: no scheduled review is registered (or none succeeded in 7+ days),
AND `skill-observations/last-review-date.txt` contains `never` or a date
more than 7 days old (a missing file is recreated with `never` — see
Session Start steps 1 and 3; the file's value is authoritative, a date
means a review actually ran). In an interactive session a pending
fallback surfaces as a one-line offer and runs only if the user opts in
(SKILL.md, Session Start step 3) — it never gates the user's task.
**Reachability — where does scheduled work actually run?** Scheduled mode
requires the scheduling agent's execution environment to read and write
the workspace folder. Persistence and execution context are independent
axes: knowing where the state lives is not enough — check whether the
scheduler runs somewhere that can reach it. Three regimes:
1. **Shared filesystem** (e.g. Cowork's mounted folder): scheduled mode
works as described.
2. **Local-only filesystem with a cloud scheduler** (e.g. remote routines
that run on hosted infrastructure): scheduled mode is physically broken
— the remote agent cannot read `skill-observations/` or stage updates
to `skill-updates/`. Do not register a routine. Recommend a recurring
calendar reminder plus a manual "run the skill review" trigger in a
local session, or syncing the observation log to storage the scheduler
can reach (e.g. a git repository it can clone).
3. **Local-only filesystem with a local scheduler** (cron, Task Scheduler,
a terminal-resident loop): works, but the user must keep the local
agent runnable.
## Approval policy
**Interactive (user present):** always present observations grouped by
skill (number, title, one-sentence summary), flag judgment calls as "needs
your input", and wait for blanket or selective approval before applying.
**Scheduled autonomous (user absent):** apply non-escalated observations by
default — safety comes from the staging-plus-review pattern (nothing is
live until the user installs it). **Escalate without applying** when: (1)
the observation proposes a NEW skill (naming/scope/type/licence need the
user); (2) it removes or substantially restructures existing content; (3)
it self-flags uncertainty ("not sure if…", "worth discussing…"); (4) two
observations conflict. A scheduled run should still apply every
non-escalated item — a review that applies nothing is just a report
generator.
## Steps
**Step 0 — recommend scheduled setup (fallback mode only).** Ordering
guard: run Step 1's no-observations short-circuit FIRST — if there are no
OPEN observations and no outstanding principles, skip Step 0 entirely and
just update the timestamp. A brand-new install must never get a setup
prompt before it has done any work. Otherwise: check
`skill-observations/scheduled-review-decline.txt`: if under 30 days old and
the fallback isn't firing repeatedly, skip. Check for a registered
scheduled task (scheduler presence or
`skill-observations/scheduler-registered.txt`); if found, skip. Before
offering, check reachability (see the regimes above): if the platform's
scheduler runs where it cannot reach the workspace folder (regime 2), do
NOT offer registration — recommend the calendar-reminder-plus-manual-
trigger pattern instead, and skip the rest of this step. Otherwise
offer to set one up. Yes → register via the platform scheduler (Cowork:
`create-shortcut` / `set_scheduled_task`; terminal: cron), name it
`weekly-skill-review`, use the draft prompt at
`skill-observations/scheduled-task-draft.md` if present, then verify the
registration actually succeeded (the scheduler lists the task, or the
platform confirmed creation) BEFORE writing today's date to
`scheduler-registered.txt`. If registration fails or can't be verified, do
NOT write the marker — the marker would permanently suppress the fallback
while no review ever runs. Tell the user registration failed and leave the
fallback active. No → write today's date to
`scheduled-review-decline.txt` (suppresses for 30 days; repeated fallback
firings within the window re-surface the offer). No scheduler available in
this environment → skip silently.
**Step 1 — load.** Archive entries resolved in *previous* sessions (see
Archival on Write in SKILL.md). Read the observation log.
Build the work queue from the structural identifiers, not from a status
filter. The OPEN set is defined as: **status is literally OPEN, OR the
observation has no Status line at all.** Concretely:
1. Enumerate all `### Observation N:` headers first — this is the
authoritative list of entries in the log.
2. For each header, classify the entry's status by looking for a
`**Status:**` line within its body. Treat a missing, blank, or any
non-ACTIONED / non-DECLINED status as OPEN.
3. Never derive the work queue from a `grep '**Status:** OPEN'` alone.
Derive it from the header list minus the resolved (ACTIONED /
DECLINED) entries. A grep on an optional field silently drops every
entry missing that field — the review then confidently reports a
clean log while a backlog of untriaged observations is skipped.
**Reconciliation guard:** before proceeding, assert that
`count(### Observation headers) == count(status-classified entries)`.
If the counts differ, the delta is statusless entries — surface and
triage them (as OPEN) rather than proceeding as if the log were clean.
Also read all active cross-cutting principles. If there are no OPEN
observations and no outstanding principles: report "no open observations
or outstanding principles", update the timestamp, and stop.
**Step 2 — inventory skills.** List all skills (system prompt
`<available_skills>` or the skills directory). Only user-owned custom
skills can be updated. Known read-only system skills: docx, pdf, xlsx,
pptx, skill-creator, schedule (grow this list when an update fails for
permissions). Observations targeting a system skill are NOT skipped — route
them to a complementary user-owned `{system-skill}-extras` skill containing
only the delta, creating it if needed and noting the pairing in
configuration.
**Step 3 — cross-check observations.** Evaluate every OPEN observation
against every skill — not just the skill named in its header; Principles
often generalise. Build skill → [relevant observations]. Interactive:
present all of it and await approval. Autonomous: apply the approval policy
above and continue.
**Step 4 — cross-check principles.** Flag every skill that doesn't yet
comply with each active cross-cutting principle.
**Step 5 — apply.** For each skill with approved/non-escalated items,
produce an updated SKILL.md: integrate insights into the sections where
they belong (never append an observations list at the bottom); preserve
structure, voice, and attribution; place new rules where they logically
live. Follow the editing rules in `references/skill-authoring.md` (live
file as base, staging, diff-before-overwrite).
**Step 6 — mark ACTIONED.** Update each applied observation's status:
`ACTIONED (YYYY-MM-DD) — Applied to [skill-name] (weekly review)`. The
date immediately after the status word is load-bearing: archival is gated
on it (entries archive only when it's before today), so a dateless mark
breaks the cross-session grace period. Do NOT archive same-session — the
next log write on a later day archives them.
**Step 7 — timestamp.** Write today's date to
`skill-observations/last-review-date.txt`.
**Step 8 — deliver and summarise.** Stage updated skills (see Delivery
below), then present:
```
## Weekly Skill Review Complete — [date]
Updated skills ([N] observations, [N] principles applied):
**[skill-name]** — [1-sentence change summary]; observations #[N], #[N]
### Observations Actioned
[numbers and titles]
### Skipped (needs manual review)
[items with reasons]
```
Wait for the user to acknowledge before other work.
## Constraints
- Don't modify observation entries beyond their status field.
- Don't create new skills in a review — note candidates for the user to
action via the skill-creator.
- Unsure how to integrate an observation → skip it and say so in the
summary.
- Treat internal observations with the same rigour as open-source.
## Delivering updated skills
Save each updated skill to
`[workspace folder]/skill-updates/[date]/[skill-name]/` — the FULL skill
directory (SKILL.md plus references/, scripts/, assets/ where present),
never SKILL.md alone — and present it for review and installation. In
Cowork: via `present_files` and its upload button. In environments without
a presentation tool (e.g. Claude Code CLI): report the staged path and a
change summary in chat and let the user review and install from there.
Never write to the live skill directly, even where the skills directory is
writable — staging-only is a deliberate safety property of the review loop
(nothing goes live without the user's sign-off), not a filesystem
constraint. For any skill with
supporting files, zip the staged directory into a `.skill` bundle and
present the bundle; a bare SKILL.md install silently truncates a
multi-file skill. Pre-delivery gate (two items, run as the last step
before presenting): (1) grep the staged SKILL.md body for `references/`,
`scripts/`, `assets/` paths and fail the delivery if any referenced file
is missing from the staged set; (2) for multi-file skills, fail the
delivery if the artefact being presented is bare file links rather than
the `.skill` bundle. Sweep build artefacts (`__pycache__/`, `*.pyc`,
`.DS_Store`, `.~lock.*`) before zipping and read the archive listing back
after. When seeding staged
copies from the read-only mount, `chmod -R u+w` the staged path first —
the mount's read-only mode travels with the copy, for directories as
well as files. Do not edit skill files in place — nothing goes live
until the user installs it. **Keep-two rule:** for any skill, keep only
the two most recent date directories under `skill-updates/`; delete
older ones.
+816
View File
@@ -0,0 +1,816 @@
// Offline harness for bughunt-fix.js - mirrors bughunt.harness.mjs: wraps the script body in an
// AsyncFunction with stubbed agent/parallel/pipeline/phase/log/args/budget.
// Run: node .claude/workflows/bughunt-fix.harness.mjs [nameFilter]
import { readFileSync } from 'node:fs'
import { dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
import assert from 'node:assert/strict'
const here = dirname(fileURLToPath(import.meta.url))
const AsyncFunction = Object.getPrototypeOf(async function () {}).constructor
export async function run({ agentStub, args = undefined, budget = undefined }) {
const src = readFileSync(join(here, 'bughunt-fix.js'), 'utf8')
const body = src.replace('export const meta', 'const meta')
const calls = []
const logs = []
const agent = async (prompt, opts = {}) => {
calls.push({ prompt, opts })
return agentStub(prompt, opts)
}
const parallel = (thunks) =>
Promise.all(thunks.map((t) => Promise.resolve().then(t).catch(() => null)))
const pipeline = (items, ...stages) =>
Promise.all(
items.map(async (item, i) => {
let v = item
for (const stage of stages) {
try {
v = await stage(v, item, i)
} catch {
return null
}
}
return v
}),
)
const log = (m) => logs.push(String(m))
const phase = () => {}
const budgetImpl = budget || { total: null, spent: () => 0, remaining: () => Infinity }
const fn = new AsyncFunction('agent', 'parallel', 'pipeline', 'phase', 'log', 'args', 'budget', body)
const result = await fn(agent, parallel, pipeline, phase, log, args, budgetImpl)
return { result, calls, logs }
}
// ---------- fixtures ----------
export const rec = (id, over = {}) => ({
id,
title: `bug ${id}`,
file: 'Client/tauri-client/src/lib/livekitE2EE.ts',
line: 100,
severity: 'high',
why: 'w',
repro: 'r',
evidence: 'e',
status: 'open',
found: '2026-08-09',
hunt: 'h',
lens: 'l',
fix: null,
...over,
})
const scenarios = {}
// F1: clustering groups by file; cluster count equals distinct file count.
scenarios.f1_clusters_by_file = async () => {
const findings = [
rec('OC-0001'),
rec('OC-0002', { line: 800 }),
rec('OC-0003', { file: 'Server/ws/hub_sweep.go' }),
]
const { result } = await run({
args: { findings, branch: 'fix/test' },
agentStub: () => {
throw new Error('no agent should run in phase 1 with the later phases unimplemented')
},
})
assert.equal(result.branch, 'fix/test')
assert.equal(result.clusters.length, 2)
const byFile = Object.fromEntries(result.clusters.map((c) => [c.file, c.ids]))
assert.deepEqual(byFile['Client/tauri-client/src/lib/livekitE2EE.ts'], ['OC-0001', 'OC-0002'])
assert.deepEqual(byFile['Server/ws/hub_sweep.go'], ['OC-0003'])
}
// F1b: a backslash/case-only variant of the same path must not split into a second cluster -
// the same disjointness invariant the cross-cluster guard protects, at the grouping step.
scenarios.f1b_clustering_normalizes_backslashes = async () => {
const findings = [
rec('OC-0001', { file: 'Server/ws/hub_sweep.go' }),
rec('OC-0002', { file: 'Server\\ws\\hub_sweep.go' }),
]
const { result } = await run({
args: { findings },
agentStub: () => {
throw new Error('no agent should run in phase 1 with the later phases unimplemented')
},
})
assert.equal(result.clusters.length, 1, 'a backslash variant of the same path must merge into one cluster')
assert.deepEqual(result.clusters[0].ids.sort(), ['OC-0001', 'OC-0002'])
assert.equal(result.clusters[0].file, 'Server/ws/hub_sweep.go')
}
// F2: only / maxSeverity / non-open status all exclude, and every exclusion is logged by id.
scenarios.f2_exclusions_are_announced = async () => {
const findings = [
rec('OC-0001'),
rec('OC-0002', { severity: 'low' }),
rec('OC-0003', { status: 'fixed' }),
rec('OC-0004'),
]
const { result, logs } = await run({
args: { findings, only: ['OC-0001', 'OC-0002', 'OC-0003'], maxSeverity: 'medium' },
agentStub: () => {
throw new Error('no agent expected')
},
})
const reasons = Object.fromEntries(result.excluded.map((e) => [e.id, e.reason]))
assert.equal(reasons['OC-0002'], 'below maxSeverity')
assert.equal(reasons['OC-0003'], 'status is fixed, not open')
assert.equal(reasons['OC-0004'], 'not in only')
assert.equal(result.clusters.length, 1)
assert.deepEqual(result.clusters[0].ids, ['OC-0001'])
const joined = logs.join('\n')
for (const id of ['OC-0002', 'OC-0003', 'OC-0004']) {
assert.match(joined, new RegExp(id), `exclusion of ${id} must be logged, not silent`)
}
}
// F3: one sonnet/xhigh agent per cluster, and the prompt carries every finding in that file.
scenarios.f3_one_xhigh_agent_per_cluster = async () => {
const findings = [
rec('OC-0001'),
rec('OC-0002', { line: 800 }),
rec('OC-0003', { file: 'Server/ws/hub_sweep.go' }),
]
const { result, calls } = await run({
args: { findings },
agentStub: (prompt, opts) => {
if (!String(opts.label).startsWith('fix:')) throw new Error(`unexpected label ${opts.label}`)
const ids = [...new Set([...prompt.matchAll(/OC-\d{4}/g)].map((m) => m[0]))]
return { results: ids.map((id) => ({ id, outcome: 'fixed', testPath: `t/${id}.test.ts`, rationale: '' })), touchedPaths: [] }
},
})
const fixCalls = calls.filter((c) => String(c.opts.label).startsWith('fix:'))
assert.equal(fixCalls.length, 2, 'one agent per file cluster')
for (const c of fixCalls) {
assert.equal(c.opts.model, 'sonnet')
assert.equal(c.opts.effort, 'xhigh')
assert.equal(c.opts.phase, 'Fix')
}
const e2eeCall = fixCalls.find((c) => c.opts.label.includes('livekitE2EE'))
assert.match(e2eeCall.prompt, /OC-0001/)
assert.match(e2eeCall.prompt, /OC-0002/)
assert.ok(!e2eeCall.prompt.includes('OC-0003'), 'a cluster prompt must not leak another file\'s findings')
assert.match(e2eeCall.prompt, /write a test that fails/i, 'rule 1: test-first')
assert.match(e2eeCall.prompt, /weakening an assertion/i, 'rule 2: never weaken an assertion')
assert.match(e2eeCall.prompt, /grep every caller/i, 'rule 3: root cause, grep callers')
assert.match(e2eeCall.prompt, /touchedPaths/i, 'rule 4: shared-file edits must be listed in touchedPaths')
assert.match(e2eeCall.prompt, /one change that closes more than one/i, 'rule 5: one change closing several findings')
assert.match(e2eeCall.prompt, /do not run any git command/i, 'rule 6: no git')
assert.match(e2eeCall.prompt, /do not invent a fix/i, 'rule 7: declined with a rationale')
assert.match(e2eeCall.prompt, /mechanical reason/i, 'rule 8: blocked with a rationale')
assert.equal(result.results.length, 3)
}
// F4: a dead fix agent marks only its own cluster; siblings still report.
scenarios.f4_dead_agent_does_not_poison_siblings = async () => {
const findings = [rec('OC-0001'), rec('OC-0003', { file: 'Server/ws/hub_sweep.go' })]
const { result } = await run({
args: { findings },
agentStub: (prompt, opts) => {
if (opts.label.includes('hub_sweep')) throw new Error('agent died')
if (String(opts.label).startsWith('prove:'))
return {
committed: true,
sha: 'aaa0000',
redObserved: true,
greenObserved: true,
redOutput: '$ npx vitest run t.ts\nFAIL t.ts > OC-0001 (reverted)',
greenOutput: '$ npx vitest run t.ts\nPASS t.ts > OC-0001 (fixed)',
note: '',
}
return { results: [{ id: 'OC-0001', outcome: 'fixed', testPath: 't.ts', rationale: '' }], touchedPaths: [] }
},
})
const byId = Object.fromEntries(result.results.map((r) => [r.id, r.outcome]))
assert.equal(byId['OC-0001'], 'fixed')
assert.equal(byId['OC-0003'], 'blocked')
const reason = result.results.find((r) => r.id === 'OC-0003').rationale
assert.match(reason, /agent/i)
}
// F5: a declined finding keeps its rationale and is not treated as fixed.
scenarios.f5_decline_propagates = async () => {
const findings = [rec('OC-0001')]
const { result } = await run({
args: { findings },
agentStub: () => ({
results: [{ id: 'OC-0001', outcome: 'declined', testPath: '', rationale: 'intended behaviour, locked by test X' }],
touchedPaths: [],
}),
})
assert.equal(result.results[0].outcome, 'declined')
assert.equal(result.results[0].rationale, 'intended behaviour, locked by test X')
}
// F5b: a foreign id (hallucinated, or copy-pasted from a different cluster) is dropped, not merged,
// and its id is announced in the logs rather than disappearing silently.
scenarios.f5b_foreign_id_is_dropped_and_announced = async () => {
const findings = [rec('OC-0001')]
const { result, logs } = await run({
args: { findings },
agentStub: () => ({
results: [
{ id: 'OC-0001', outcome: 'fixed', testPath: 't.ts', rationale: '' },
{ id: 'OC-9999', outcome: 'fixed', testPath: 't2.ts', rationale: '' },
],
touchedPaths: [],
}),
})
assert.deepEqual(result.results.map((r) => r.id), ['OC-0001'])
assert.match(logs.join('\n'), /OC-9999/, 'the dropped foreign id must be announced in the logs')
}
// F6: prove agents run serially (never overlapping) and only for clusters that produced a fix.
scenarios.f6_prove_is_serial = async () => {
const findings = [rec('OC-0001'), rec('OC-0003', { file: 'Server/ws/hub_sweep.go' })]
let inFlight = 0
let maxInFlight = 0
const { result, calls } = await run({
args: { findings },
agentStub: async (prompt, opts) => {
if (String(opts.label).startsWith('fix:')) {
const ids = [...new Set([...prompt.matchAll(/OC-\d{4}/g)].map((m) => m[0]))]
return { results: ids.map((id) => ({ id, outcome: 'fixed', testPath: `t/${id}.ts`, rationale: '' })), touchedPaths: [] }
}
inFlight++
maxInFlight = Math.max(maxInFlight, inFlight)
await new Promise((r) => setTimeout(r, 5))
inFlight--
return {
committed: true,
sha: 'abc1234',
redObserved: true,
greenObserved: true,
redOutput: '$ go test ./ws/ -run TestSweep\nFAIL: reverted source',
greenOutput: '$ go test ./ws/ -run TestSweep\nPASS: fix restored',
note: '',
}
},
})
assert.equal(maxInFlight, 1, 'prove/commit must be serial - git index contention')
const proveCalls = calls.filter((c) => String(c.opts.label).startsWith('prove:'))
assert.equal(proveCalls.length, 2)
for (const c of proveCalls) {
assert.equal(c.opts.model, 'opus')
assert.equal(c.opts.effort, 'high')
}
assert.equal(result.commits.length, 2)
assert.deepEqual(result.commits[0], { sha: 'abc1234', file: findings[0].file, ids: ['OC-0001'] })
}
// F7: a vacuous test (revert-proof does not go RED) is NOT committed and its findings go blocked.
scenarios.f7_vacuous_test_is_not_committed = async () => {
const findings = [rec('OC-0001')]
const { result } = await run({
args: { findings },
agentStub: (prompt, opts) => {
if (String(opts.label).startsWith('fix:'))
return { results: [{ id: 'OC-0001', outcome: 'fixed', testPath: 't.ts', rationale: '' }], touchedPaths: [] }
return {
committed: false,
sha: '',
redObserved: false,
greenObserved: true,
redOutput: '$ npx vitest run t.ts\nPASS t.ts > OC-0001 (reverted, should have failed)',
greenOutput: '$ npx vitest run t.ts\nPASS t.ts > OC-0001 (fixed)',
note: 'test passed with the fix reverted',
}
},
})
assert.equal(result.commits.length, 0, 'a cluster that failed its revert-proof must not be committed')
assert.equal(result.results[0].outcome, 'blocked')
assert.match(result.results[0].rationale, /revert-proof/i)
}
// F7b: RED was observed but the restored fix does not go GREEN - also not committed.
scenarios.f7b_restored_fix_must_be_green = async () => {
const findings = [rec('OC-0001')]
const { result } = await run({
args: { findings },
agentStub: (prompt, opts) => {
if (String(opts.label).startsWith('fix:'))
return { results: [{ id: 'OC-0001', outcome: 'fixed', testPath: 't.ts', rationale: '' }], touchedPaths: [] }
return {
committed: false,
sha: '',
redObserved: true,
greenObserved: false,
redOutput: '$ npx vitest run t.ts\nFAIL t.ts > OC-0001 (reverted)',
greenOutput: '$ npx vitest run t.ts\nFAIL t.ts > OC-0001 (still failing after restore)',
note: 'still failing after restore',
}
},
})
assert.equal(result.commits.length, 0)
assert.equal(result.results[0].outcome, 'blocked')
assert.match(result.results[0].rationale, /after restoring the fix/i)
}
// F8: a cluster with only declines is never sent to prove, and produces no commit.
scenarios.f8_declined_cluster_skips_prove = async () => {
const findings = [rec('OC-0001')]
const { result, calls } = await run({
args: { findings },
agentStub: (prompt, opts) => {
if (String(opts.label).startsWith('fix:'))
return { results: [{ id: 'OC-0001', outcome: 'declined', testPath: '', rationale: 'by design' }], touchedPaths: [] }
throw new Error('prove must not run for a cluster with no fixes')
},
})
assert.ok(!calls.some((c) => String(c.opts.label).startsWith('prove:')))
assert.equal(result.commits.length, 0)
assert.equal(result.results[0].outcome, 'declined')
}
// F9: one failing cluster does not stop its siblings from committing.
scenarios.f9_blocked_cluster_does_not_block_siblings = async () => {
const findings = [rec('OC-0001'), rec('OC-0003', { file: 'Server/ws/hub_sweep.go' })]
const { result } = await run({
args: { findings },
agentStub: (prompt, opts) => {
if (String(opts.label).startsWith('fix:')) {
const ids = [...new Set([...prompt.matchAll(/OC-\d{4}/g)].map((m) => m[0]))]
return { results: ids.map((id) => ({ id, outcome: 'fixed', testPath: 't.ts', rationale: '' })), touchedPaths: [] }
}
if (opts.label.includes('livekitE2EE'))
return {
committed: false,
sha: '',
redObserved: false,
greenObserved: true,
redOutput: '$ npx vitest run t.ts\nPASS t.ts > OC-0001 (reverted, should have failed)',
greenOutput: '$ npx vitest run t.ts\nPASS t.ts > OC-0001 (fixed)',
note: 'vacuous',
}
return {
committed: true,
sha: 'def5678',
redObserved: true,
greenObserved: true,
redOutput: '$ go test ./ws/ -run TestSweep\nFAIL: reverted source',
greenOutput: '$ go test ./ws/ -run TestSweep\nPASS: fix restored',
note: '',
}
},
})
assert.equal(result.commits.length, 1)
assert.equal(result.commits[0].sha, 'def5678')
const byId = Object.fromEntries(result.results.map((r) => [r.id, r.outcome]))
assert.equal(byId['OC-0001'], 'blocked')
assert.equal(byId['OC-0003'], 'fixed')
}
// F9b: a mixed cluster (one fixed + one declined) whose prove fails demotes only the fixed
// finding to blocked; the declined finding and its original rationale are left untouched.
scenarios.f9b_declined_survives_a_failed_prove = async () => {
const declinedRationale = 'intentional: rate limit is a product decision, not a bug'
const findings = [rec('OC-0001'), rec('OC-0002', { line: 800 })]
const { result } = await run({
args: { findings },
agentStub: (prompt, opts) => {
if (String(opts.label).startsWith('fix:')) {
return {
results: [
{ id: 'OC-0001', outcome: 'fixed', testPath: 't/OC-0001.test.ts', rationale: '' },
{ id: 'OC-0002', outcome: 'declined', testPath: '', rationale: declinedRationale },
],
touchedPaths: [],
}
}
return {
committed: false,
sha: '',
redObserved: false,
greenObserved: true,
redOutput: '$ npx vitest run t/OC-0001.test.ts\nPASS (reverted, should have failed)',
greenOutput: '$ npx vitest run t/OC-0001.test.ts\nPASS (fixed)',
note: 'test passed with the fix reverted',
}
},
})
const byId = Object.fromEntries(result.results.map((r) => [r.id, r]))
assert.equal(byId['OC-0001'].outcome, 'blocked')
assert.match(byId['OC-0001'].rationale, /revert-proof/i)
assert.equal(byId['OC-0002'].outcome, 'declined')
assert.equal(byId['OC-0002'].rationale, declinedRationale)
}
// F10: the gate runs once, and only for the stacks the commits actually touched.
scenarios.f10_gate_targets_touched_stacks = async () => {
const findings = [rec('OC-0001'), rec('OC-0003', { file: 'Server/ws/hub_sweep.go' })]
let gatePrompt = ''
const { result, calls } = await run({
args: { findings },
agentStub: (prompt, opts) => {
if (String(opts.label).startsWith('fix:')) {
const ids = [...new Set([...prompt.matchAll(/OC-\d{4}/g)].map((m) => m[0]))]
return { results: ids.map((id) => ({ id, outcome: 'fixed', testPath: 't.ts', rationale: '' })), touchedPaths: [] }
}
if (String(opts.label).startsWith('prove:'))
return {
committed: true,
sha: 'aaa1111',
redObserved: true,
greenObserved: true,
redOutput: '$ npx vitest run t.ts\nFAIL t.ts (reverted)',
greenOutput: '$ npx vitest run t.ts\nPASS t.ts (fixed)',
note: '',
}
gatePrompt = prompt
return { passed: true, stacks: ['client', 'server'], output: 'ok' }
},
})
const gateCalls = calls.filter((c) => c.opts.label === 'gate')
assert.equal(gateCalls.length, 1, 'ci-check runs once, not per fix')
assert.equal(gateCalls[0].opts.model, 'sonnet')
assert.equal(gateCalls[0].opts.effort, 'xhigh')
assert.match(gatePrompt, /no-experimental-webstorage/, 'client gate command must be spelled out')
assert.match(gatePrompt, /go build -tags otel/, 'server gate must cover the tagged build variants')
assert.equal(result.gate.passed, true)
}
// F11: nothing committed means nothing to gate - skip it rather than burn 15 minutes.
scenarios.f11_no_commits_skips_gate = async () => {
const findings = [rec('OC-0001')]
const { result, calls } = await run({
args: { findings },
agentStub: (prompt, opts) => {
if (String(opts.label).startsWith('fix:'))
return { results: [{ id: 'OC-0001', outcome: 'declined', testPath: '', rationale: 'by design' }], touchedPaths: [] }
throw new Error(`no agent expected for label ${opts.label}`)
},
})
assert.ok(!calls.some((c) => c.opts.label === 'gate'))
assert.equal(result.gate, null)
}
// F12: a red gate does not rewrite history - commits stand, the failure is reported.
scenarios.f12_failing_gate_keeps_commits = async () => {
const findings = [rec('OC-0001')]
const { result } = await run({
args: { findings },
agentStub: (prompt, opts) => {
if (String(opts.label).startsWith('fix:'))
return { results: [{ id: 'OC-0001', outcome: 'fixed', testPath: 't.ts', rationale: '' }], touchedPaths: [] }
if (String(opts.label).startsWith('prove:'))
return {
committed: true,
sha: 'bbb2222',
redObserved: true,
greenObserved: true,
redOutput: '$ npx vitest run t.ts\nFAIL t.ts (reverted)',
greenOutput: '$ npx vitest run t.ts\nPASS t.ts (fixed)',
note: '',
}
return { passed: false, stacks: ['client'], output: 'tsc: 3 errors' }
},
})
assert.equal(result.commits.length, 1, 'a failing gate must not revert commits')
assert.equal(result.results[0].outcome, 'fixed')
assert.equal(result.gate.passed, false)
assert.match(result.gate.output, /tsc: 3 errors/)
}
// F12b: a truthy but wrongly-shaped gate response (no boolean `passed`, no `stacks` array) must
// still be treated as a failed gate - falling back to the computed stack list, but keeping the
// agent's own `output` string rather than overwriting it with the generic default message.
scenarios.f12b_malformed_gate_response_is_a_failed_gate = async () => {
const findings = [rec('OC-0001')]
const { result } = await run({
args: { findings },
agentStub: (prompt, opts) => {
if (String(opts.label).startsWith('fix:'))
return { results: [{ id: 'OC-0001', outcome: 'fixed', testPath: 't.ts', rationale: '' }], touchedPaths: [] }
if (String(opts.label).startsWith('prove:'))
return {
committed: true,
sha: 'ccc3333',
redObserved: true,
greenObserved: true,
redOutput: '$ npx vitest run t.ts\nFAIL t.ts (reverted)',
greenOutput: '$ npx vitest run t.ts\nPASS t.ts (fixed)',
note: '',
}
// wrongly shaped: no boolean `passed`, no `stacks` array - just a stray `output` string.
return { ok: true, output: 'ran partway: lint crashed before finishing' }
},
})
assert.equal(result.commits.length, 1, 'a malformed gate response must not lose an already-made commit')
assert.equal(result.gate.passed, false)
assert.ok(Array.isArray(result.gate.stacks), 'stacks must fall back to the computed list, not stay undefined')
assert.deepEqual(result.gate.stacks, ['client'])
assert.equal(
result.gate.output,
'ran partway: lint crashed before finishing',
"the agent's own output must be preserved, not replaced by the default message",
)
}
// F12c: the gate agent call itself throws. The .catch(() => null) guard must keep the rejection
// from escaping the workflow - same as Phase 3's prove agent - so commits already made survive
// and the gate is reported as failed rather than the run crashing before its final return.
scenarios.f12c_thrown_gate_agent_does_not_lose_commits = async () => {
const findings = [rec('OC-0001')]
const { result } = await run({
args: { findings },
agentStub: (prompt, opts) => {
if (String(opts.label).startsWith('fix:'))
return { results: [{ id: 'OC-0001', outcome: 'fixed', testPath: 't.ts', rationale: '' }], touchedPaths: [] }
if (String(opts.label).startsWith('prove:'))
return {
committed: true,
sha: 'ddd4444',
redObserved: true,
greenObserved: true,
redOutput: '$ npx vitest run t.ts\nFAIL t.ts (reverted)',
greenOutput: '$ npx vitest run t.ts\nPASS t.ts (fixed)',
note: '',
}
throw new Error('gate agent died')
},
})
assert.equal(result.commits.length, 1, 'a thrown gate agent must not lose an already-made commit')
assert.equal(result.commits[0].sha, 'ddd4444')
assert.equal(result.gate.passed, false)
assert.equal(result.results[0].outcome, 'fixed', 'a gate failure must not demote an already-committed result')
}
// F13: two clusters whose touchedPaths intersect (a shared root-cause file edited by both
// agents) must both be blocked before Phase 3 - neither may reach prove/commit, and the log
// must name both cluster files and the shared path.
scenarios.f13_intersecting_touched_paths_blocks_both_clusters = async () => {
const findings = [rec('OC-0001'), rec('OC-0003', { file: 'Server/ws/hub_sweep.go' })]
const { result, logs, calls } = await run({
args: { findings },
agentStub: (prompt, opts) => {
if (!String(opts.label).startsWith('fix:')) throw new Error(`only fix agents should run, got ${opts.label}`)
if (opts.label.includes('livekitE2EE'))
return {
results: [{ id: 'OC-0001', outcome: 'fixed', testPath: 't/OC-0001.test.ts', rationale: '' }],
touchedPaths: ['Server/ws/shared_helper.go'],
}
return {
results: [{ id: 'OC-0003', outcome: 'fixed', testPath: 'Server/ws/hub_sweep_test.go', rationale: '' }],
touchedPaths: ['Server/ws/shared_helper.go'],
}
},
})
const byId = Object.fromEntries(result.results.map((r) => [r.id, r]))
assert.equal(byId['OC-0001'].outcome, 'blocked')
assert.equal(byId['OC-0003'].outcome, 'blocked')
assert.match(byId['OC-0001'].rationale, /Server\/ws\/shared_helper\.go/)
assert.match(byId['OC-0003'].rationale, /Server\/ws\/shared_helper\.go/)
assert.equal(result.commits.length, 0, 'neither cluster may commit once blocked by the overlap guard')
const joined = logs.join('\n')
assert.match(joined, /livekitE2EE\.ts/, 'log must name the first cluster file')
assert.match(joined, /hub_sweep\.go/, 'log must name the second cluster file')
assert.match(joined, /shared_helper\.go/, 'log must name the shared path')
assert.ok(!calls.some((c) => String(c.opts.label).startsWith('prove:')), 'blocked clusters must never reach prove')
}
// F14: two clusters with disjoint touchedPaths are unaffected by the guard and both commit.
scenarios.f14_disjoint_touched_paths_both_commit = async () => {
const findings = [rec('OC-0001'), rec('OC-0003', { file: 'Server/ws/hub_sweep.go' })]
const { result } = await run({
args: { findings },
agentStub: (prompt, opts) => {
if (String(opts.label).startsWith('fix:')) {
if (opts.label.includes('livekitE2EE'))
return {
results: [{ id: 'OC-0001', outcome: 'fixed', testPath: 't/OC-0001.test.ts', rationale: '' }],
touchedPaths: ['Client/tauri-client/src/lib/otherHelper.ts'],
}
return {
results: [{ id: 'OC-0003', outcome: 'fixed', testPath: 'Server/ws/hub_sweep_test.go', rationale: '' }],
touchedPaths: ['Server/ws/other_helper.go'],
}
}
return {
committed: true,
sha: opts.label.includes('livekitE2EE') ? 'e2ee1111' : 'sweep222',
redObserved: true,
greenObserved: true,
redOutput: 'FAIL (reverted)',
greenOutput: 'PASS (fixed)',
note: '',
}
},
})
assert.equal(result.commits.length, 2, 'disjoint touchedPaths must not trip the overlap guard')
const byId = Object.fromEntries(result.results.map((r) => [r.id, r.outcome]))
assert.equal(byId['OC-0001'], 'fixed')
assert.equal(byId['OC-0003'], 'fixed')
}
// F15: the prove prompt for a cluster whose agent reported extra touchedPaths names every one
// of those paths in both the revert (checkout) instruction and the staging (add) instruction -
// not just cluster.file.
scenarios.f15_prove_prompt_names_every_touched_path = async () => {
const findings = [rec('OC-0001')]
let provePromptText = ''
const { result } = await run({
args: { findings, branch: 'fix/test-touched' },
agentStub: (prompt, opts) => {
if (String(opts.label).startsWith('fix:'))
return {
results: [{ id: 'OC-0001', outcome: 'fixed', testPath: 't/OC-0001.test.ts', rationale: '' }],
touchedPaths: ['Client/tauri-client/src/lib/sharedCrypto.ts'],
}
if (String(opts.label).startsWith('prove:')) {
provePromptText = prompt
return {
committed: true,
sha: 'aaa9999',
redObserved: true,
greenObserved: true,
redOutput: 'FAIL (reverted)',
greenOutput: 'PASS (fixed)',
note: '',
}
}
return { passed: true, stacks: ['client'], output: 'ok' }
},
})
assert.equal(result.commits.length, 1)
const checkoutLine = provePromptText.split('\n').find((l) => l.includes('git checkout HEAD --'))
assert.ok(checkoutLine, 'prove prompt must contain the checkout instruction')
assert.match(checkoutLine, /livekitE2EE\.ts/, 'checkout instruction must name the cluster file')
assert.match(checkoutLine, /sharedCrypto\.ts/, 'checkout instruction must also name the extra touched path')
const addLine = provePromptText.split('\n').find((l) => l.includes('git add'))
assert.ok(addLine, 'prove prompt must contain the staging instruction')
assert.match(addLine, /livekitE2EE\.ts/, 'add instruction must name the cluster file')
assert.match(addLine, /sharedCrypto\.ts/, 'add instruction must also name the extra touched path')
assert.match(provePromptText, /rev-parse --abbrev-ref HEAD/, 'prove prompt must guard the current branch')
assert.match(provePromptText, /fix\/test-touched/, 'branch guard must name the expected branch')
}
// ---------- circuit breaker ----------
// Four findings, one per file, so each becomes its own cluster.
const FOUR_FILES = [
rec('OC-0001'),
rec('OC-0002', { file: 'Server/ws/hub_sweep.go' }),
rec('OC-0003', { file: 'Client/tauri-client/src/lib/livekitSession.ts' }),
rec('OC-0004', { file: 'Client/tauri-client/src/components/VoiceWidget.ts' }),
]
// A fix stub that reports every id in its prompt as fixed. Ids go through a Set because rec()
// puts each id in both `id` and `title`, so the raw matchAll yields every id twice.
const fixAll = (prompt) => {
const ids = [...new Set([...prompt.matchAll(/OC-\d{4}/g)].map((m) => m[0]))]
return { results: ids.map((id) => ({ id, outcome: 'fixed', testPath: `t/${id}.ts`, rationale: '' })), touchedPaths: [] }
}
const PROVE_FAIL = {
committed: false,
sha: '',
redObserved: false,
greenObserved: true,
redOutput: '$ npx vitest run t.ts\nPASS t.ts (still passing with the fix reverted)',
greenOutput: '$ npx vitest run t.ts\nPASS t.ts',
note: 'test did not exercise the bug',
}
const proveOk = (sha) => ({
committed: true,
sha,
redObserved: true,
greenObserved: true,
redOutput: '$ npx vitest run t.ts\nFAIL t.ts (reverted)',
greenOutput: '$ npx vitest run t.ts\nPASS t.ts (fixed)',
note: '',
})
// F16: three failed revert-proofs out of three attempts trips the breaker, and the fourth cluster
// is never handed to an agent. Load-bearing: this is the one that proves the loop actually stops.
scenarios.f16_breaker_trips_after_majority_prove_failures = async () => {
const { result, calls, logs } = await run({
args: { findings: FOUR_FILES },
agentStub: (prompt, opts) => {
if (String(opts.label).startsWith('fix:')) return fixAll(prompt)
if (String(opts.label).startsWith('prove:')) return PROVE_FAIL
return { passed: true, stacks: [], output: '' }
},
})
const proveCalls = calls.filter((c) => String(c.opts.label).startsWith('prove:'))
assert.equal(proveCalls.length, 3, 'breaker must stop the loop after minAttempts, not run all four')
assert.ok(result.breaker, 'breaker report must be present on the result')
assert.equal(result.breaker.trippedAt, 'prove')
assert.equal(result.breaker.attempted, 3)
assert.equal(result.breaker.failed, 3)
assert.equal(result.commits.length, 0)
// Every finding ends blocked, but the unreached one must say WHY it was never tried.
assert.ok(result.results.every((r) => r.outcome === 'blocked'), 'nothing may be left reported as fixed')
const unreached = result.results.filter((r) => /circuit breaker/i.test(r.rationale))
assert.equal(unreached.length, 1, 'exactly the one unreached finding carries the breaker rationale')
assert.match(unreached[0].rationale, /uncommitted/i, 'operator must be told the edits are still in the tree')
assert.ok(logs.some((l) => /CIRCUIT BREAKER/.test(l)), 'a trip must be announced in the log')
}
// F17: two failures out of two is 100%, but below minAttempts it is noise, not a signal.
scenarios.f17_breaker_holds_below_min_attempts = async () => {
const { result, calls } = await run({
args: { findings: FOUR_FILES.slice(0, 2) },
agentStub: (prompt, opts) => {
if (String(opts.label).startsWith('fix:')) return fixAll(prompt)
if (String(opts.label).startsWith('prove:')) return PROVE_FAIL
return { passed: true, stacks: [], output: '' }
},
})
assert.equal(calls.filter((c) => String(c.opts.label).startsWith('prove:')).length, 2, 'both clusters must be attempted')
assert.equal(result.breaker, null)
for (const r of result.results) {
assert.equal(r.outcome, 'blocked')
assert.match(r.rationale, /revert-proof failed/, 'rationale must be the real reason, not the breaker')
}
}
// F18: one failure in four is a bad cluster, not a bad run.
scenarios.f18_breaker_holds_under_threshold = async () => {
let n = 0
const { result } = await run({
args: { findings: FOUR_FILES },
agentStub: (prompt, opts) => {
if (String(opts.label).startsWith('fix:')) return fixAll(prompt)
if (String(opts.label).startsWith('prove:')) return ++n === 1 ? PROVE_FAIL : proveOk(`sha${n}`)
return { passed: true, stacks: [], output: '' }
},
})
assert.equal(result.breaker, null)
assert.equal(result.commits.length, 3, 'the three good clusters must still land')
}
// F19: the operator can turn the guard off entirely.
scenarios.f19_breaker_disabled_by_args = async () => {
const { result, calls } = await run({
args: { findings: FOUR_FILES, circuitBreaker: false },
agentStub: (prompt, opts) => {
if (String(opts.label).startsWith('fix:')) return fixAll(prompt)
if (String(opts.label).startsWith('prove:')) return PROVE_FAIL
return { passed: true, stacks: [], output: '' }
},
})
assert.equal(calls.filter((c) => String(c.opts.label).startsWith('prove:')).length, 4, 'all four must be attempted when disabled')
assert.equal(result.breaker, null)
}
// F20: when the FIX stage is what is failing, proving each of those costs a serial agent per
// cluster and cannot succeed. Load-bearing: this is the cheap early exit.
scenarios.f20_fix_stage_trip_skips_prove_entirely = async () => {
const { result, calls } = await run({
args: { findings: FOUR_FILES },
agentStub: (prompt, opts) => {
if (String(opts.label).startsWith('fix:')) {
const ids = [...new Set([...prompt.matchAll(/OC-\d{4}/g)].map((m) => m[0]))]
return {
results: ids.map((id) =>
id === 'OC-0001'
? { id, outcome: 'fixed', testPath: `t/${id}.ts`, rationale: '' }
: { id, outcome: 'blocked', testPath: '', rationale: 'could not run the test suite' },
),
touchedPaths: [],
}
}
return { passed: true, stacks: [], output: '' }
},
})
assert.ok(result.breaker, 'breaker report must be present')
assert.equal(result.breaker.trippedAt, 'fix')
assert.equal(
calls.filter((c) => String(c.opts.label).startsWith('prove:')).length,
0,
'a fix-stage trip must spend nothing on prove agents',
)
assert.equal(result.commits.length, 0)
assert.equal(result.gate, null, 'no commits means no gate')
}
// F21: a trip does not orphan whatever already landed - the operator needs to know if it is green.
scenarios.f21_trip_still_gates_existing_commits = async () => {
let n = 0
const { result, calls } = await run({
args: { findings: FOUR_FILES },
agentStub: (prompt, opts) => {
if (String(opts.label).startsWith('fix:')) return fixAll(prompt)
if (String(opts.label).startsWith('prove:')) return ++n === 1 ? proveOk('aaa1111') : PROVE_FAIL
return { passed: true, stacks: ['client'], output: 'all green' }
},
})
assert.ok(result.breaker, 'breaker report must be present')
assert.equal(result.breaker.trippedAt, 'prove')
assert.equal(result.commits.length, 1, 'the one proven cluster must survive the trip')
assert.equal(calls.filter((c) => c.opts.label === 'gate').length, 1, 'the gate must still run over what landed')
assert.equal(result.gate.passed, true)
}
// ---------- runner ----------
const only = process.argv[2]
for (const [name, fn] of Object.entries(scenarios)) {
if (only && !name.includes(only)) continue
try {
await fn()
} catch (e) {
console.error(`FAIL ${name}`)
throw e
}
console.log(`PASS ${name}`)
}
console.log('all scenarios pass')
+493
View File
@@ -0,0 +1,493 @@
export const meta = {
name: 'bughunt-fix',
description: 'Fix open ledger findings test-first: per-file agents, mechanical revert-proof, serial commits, one ci-check gate',
whenToUse: 'After a bughunt run has been written to the findings ledger and a human has skimmed it. Consumes open findings, produces commits on a branch. Never opens a PR.',
phases: [
{ title: 'Plan', detail: 'cluster open findings by file' },
{ title: 'Fix', detail: 'sonnet/xhigh: one agent per file, test-first, no git' },
{ title: 'Prove', detail: 'opus/high: serial revert-proof then commit per cluster' },
{ title: 'Gate', detail: 'sonnet/xhigh: ci-check for the touched stacks, once' },
],
}
// args has been observed arriving JSON-stringified; coerce it the same way bughunt.js does.
const ARGS = (() => {
if (typeof args === 'string') {
try {
return JSON.parse(args) || {}
} catch {
return {}
}
}
return args || {}
})()
const SEV_RANK = { critical: 0, high: 1, medium: 2, low: 3 }
const BRANCH = ARGS.branch || 'fix/bughunt'
const ONLY = Array.isArray(ARGS.only) && ARGS.only.length ? new Set(ARGS.only) : null
const MAX_SEVERITY = ARGS.maxSeverity || 'low'
const ALL = Array.isArray(ARGS.findings) ? ARGS.findings : []
// Circuit breaker: stop a run that is going systematically wrong instead of spending a
// high-effort agent on every remaining cluster. `declined` is not a failure - it is a
// judgement the fix prompt explicitly invites - so only `blocked` counts.
// ?? not || so an explicit threshold of 0 is honoured.
const BREAKER =
ARGS.circuitBreaker === false
? null
: {
threshold: ARGS.circuitBreaker?.threshold ?? 0.5,
minAttempts: ARGS.circuitBreaker?.minAttempts ?? 3,
}
let breaker = null // set to a report object if it trips
// ---------- phase 1: plan ----------
phase('Plan')
const excluded = []
const selected = []
for (const f of ALL) {
if (f.status && f.status !== 'open') {
excluded.push({ id: f.id, reason: `status is ${f.status}, not open` })
} else if (ONLY && !ONLY.has(f.id)) {
excluded.push({ id: f.id, reason: 'not in only' })
} else if ((SEV_RANK[f.severity] ?? 3) > (SEV_RANK[MAX_SEVERITY] ?? 3)) {
excluded.push({ id: f.id, reason: 'below maxSeverity' })
} else {
selected.push(f)
}
}
// Group by file. One agent per file is what removes merge conflicts (clusters are disjoint)
// and what makes a root-cause fix possible - the agent sees every defect in the file at once.
// Normalize the grouping key (backslashes -> forward slashes) so a path reported with the
// "wrong" separator does not silently split one real file into two clusters.
const byFile = new Map()
for (const f of selected) {
const key = String(f.file).replace(/\\/g, '/')
if (!byFile.has(key)) byFile.set(key, [])
byFile.get(key).push(f)
}
const clusters = [...byFile.entries()].map(([file, findings]) => ({
file,
ids: findings.map((f) => f.id),
findings,
}))
log(`plan: ${selected.length} finding(s) in ${clusters.length} file cluster(s) on ${BRANCH}` +
(BREAKER
? ` (breaker: stop above ${Math.round(BREAKER.threshold * 100)}% failures after ${BREAKER.minAttempts} attempts)`
: ' (breaker disabled)'))
for (const c of clusters) log(` ${c.file}: ${c.ids.join(', ')}`)
// Announce every exclusion by id. Silent truncation reads as "covered everything" when it did not.
for (const e of excluded) log(` excluded ${e.id}: ${e.reason}`)
const publicClusters = clusters.map((c) => ({ file: c.file, ids: c.ids }))
// ---------- schemas ----------
const FIX_RESULTS = {
type: 'object',
required: ['results', 'touchedPaths'],
properties: {
results: {
type: 'array',
items: {
type: 'object',
required: ['id', 'outcome', 'testPath', 'rationale'],
properties: {
id: { type: 'string', description: 'the ledger id, e.g. OC-0042' },
outcome: { type: 'string', enum: ['fixed', 'declined', 'blocked'] },
testPath: { type: 'string', description: 'repo-relative path of the test that pins this finding; empty if not fixed' },
rationale: { type: 'string', description: 'required for declined and blocked; empty for fixed' },
},
},
},
touchedPaths: {
type: 'array',
items: { type: 'string' },
description:
'every non-test SOURCE file this agent modified while working this cluster, repo-relative, forward ' +
'slashes - including cluster.file itself if it was touched, and any shared file outside the cluster ' +
'the root-cause fix required. Test files belong in testPath (per finding), not here.',
},
},
}
// ---------- phase 2: fix ----------
phase('Fix')
function fixPrompt(cluster) {
return (
`You are fixing confirmed bugs in ONE file of the OwnCord repo (checked out at your current working ` +
`directory - do not assume any absolute path; use repo-relative paths).\n` +
`Your file: ${cluster.file}\n\n` +
`You own this CLUSTER for this run: no other agent has this file as its cluster, so fix ALL of the ` +
`findings below together rather than one at a time. But cluster fixes may share a file (rule 4 cuts ` +
`both ways), so another cluster's root-cause edit can still land in this file while you work. ` +
`Re-read the exact region you are about to edit immediately before each edit rather than trusting a ` +
`full-file read from the start of your turn, and treat any editor warning that the file changed ` +
`since you last read it as a signal to re-read and re-target, never to force the edit through.\n\n` +
`RULES\n` +
` 1. Test first. For each finding, write a test that FAILS against the current code before you ` +
`change anything, and run it to watch it fail. A test that passes before the fix does not pin the ` +
`bug and will be rejected mechanically later.\n` +
` 2. Never make a failing test pass by weakening an assertion. The existing suite is green and must ` +
`stay green on its current assertions.\n` +
` 3. Fix the ROOT CAUSE. Grep every caller of the function you are about to change. One guard in a ` +
`shared function beats a guard in every caller, and patching only the path a finding names leaves its ` +
`siblings broken.\n` +
` 4. You may edit a shared file outside your own cluster (${cluster.file}) when that is where the ` +
`root cause lives. If you do, you MUST list every SOURCE file you modified - including this cluster's ` +
`own file - in touchedPaths, repo-relative with forward slashes. Test files belong in testPath, not ` +
`touchedPaths. If you regenerate shared generated output (sqlc, protocol), list EVERY generated ` +
`file the regeneration changed - check with git status --porcelain (read-only, allowed despite ` +
`rule 6), do not guess: an unlisted generated file defeats the cross-cluster overlap guard.\n` +
` 5. Because several findings share this file, look for one change that closes more than one of them ` +
`before writing separate patches.\n` +
` 6. DO NOT run any git command. No add, no commit, no stash, no checkout. Other agents are working ` +
`in this same working tree and git operations collide on the index lock. Leave your changes in the ` +
`working tree; a later serial phase commits them.\n` +
` 7. If a finding is wrong, or the correct fix is a deliberate product decision you should not make ` +
`alone, return outcome "declined" with a rationale. Do not invent a fix you do not believe in.\n` +
` 8. If you cannot fix it for a mechanical reason (missing fixture, unclear repro), return "blocked" ` +
`with a rationale.\n\n` +
`Client tests run from Client/tauri-client with:\n` +
` NODE_OPTIONS=--no-experimental-webstorage npx vitest run <testfile>\n` +
`Server tests run from Server with:\n` +
` go test ./<pkg>/ -run <TestName>\n\n` +
`Return one result per finding id, all ${cluster.ids.length} of them, plus touchedPaths.\n\n` +
`--- FINDINGS IN ${cluster.file} ---\n${JSON.stringify(cluster.findings, null, 2)}`
)
}
const fixOutcomes = await parallel(
clusters.map((cluster) => () =>
agent(fixPrompt(cluster), {
label: `fix:${cluster.file}`,
phase: 'Fix',
model: 'sonnet',
effort: 'xhigh',
schema: FIX_RESULTS,
}).then((r) => ({
cluster,
results: (r && r.results) || [],
touchedPaths: r && Array.isArray(r.touchedPaths) ? r.touchedPaths.filter((p) => typeof p === 'string' && p) : [],
})),
),
)
// A null slot means the agent died or threw. Its findings are blocked, its siblings are unaffected.
const fixed = []
for (let i = 0; i < clusters.length; i++) {
const cluster = clusters[i]
const outcome = fixOutcomes[i]
if (!outcome) {
log(`fix ${cluster.file}: agent failed - ${cluster.ids.length} finding(s) blocked`)
fixed.push({
cluster,
results: cluster.ids.map((id) => ({ id, outcome: 'blocked', testPath: '', rationale: 'fix agent failed or returned nothing' })),
touchedPaths: [],
union: [cluster.file],
})
continue
}
// A hallucinated id, or one copy-pasted from a different cluster, must not merge in silently.
const ownIds = new Set(cluster.ids)
const ownResults = outcome.results.filter((r) => ownIds.has(r.id))
const foreignResults = outcome.results.filter((r) => !ownIds.has(r.id))
if (foreignResults.length) {
log(`fix ${cluster.file}: dropped ${foreignResults.length} result(s) for id(s) not in this cluster - ${foreignResults.map((r) => r.id).join(', ')}`)
}
// An agent that skipped a finding entirely leaves it blocked rather than silently dropped.
const reported = new Set(ownResults.map((r) => r.id))
const missing = cluster.ids
.filter((id) => !reported.has(id))
.map((id) => ({ id, outcome: 'blocked', testPath: '', rationale: 'fix agent returned no result for this finding' }))
if (missing.length) log(`fix ${cluster.file}: ${missing.length} finding(s) unreported by the agent - blocked`)
fixed.push({
cluster,
results: [...ownResults, ...missing],
touchedPaths: outcome.touchedPaths,
union: [...new Set([cluster.file, ...outcome.touchedPaths])],
})
}
// ---------- phase 2.5: cross-cluster overlap guard ----------
// Rule 4 above lets an agent fix a shared root cause outside its own file - the per-file
// disjointness the whole design leans on (no merge conflicts, a clean revert per cluster) no
// longer holds automatically once that happens. If two clusters' agents both touched the same
// path, Phase 3 cannot safely revert/stage per-cluster: one cluster's revert could silently
// undo the other's real fix (a misattributed VACUOUS TEST) or a real change could never get
// staged at all. Block both clusters rather than guess which one "owns" the shared file.
for (let i = 0; i < fixed.length; i++) {
for (let j = i + 1; j < fixed.length; j++) {
const a = fixed[i]
const b = fixed[j]
const shared = a.union.filter((p) => b.union.includes(p))
if (!shared.length) continue
log(`blocked: ${a.cluster.file} and ${b.cluster.file} both touch ${shared.join(', ')} - both clusters blocked`)
for (const [entry, other] of [[a, b], [b, a]]) {
for (const r of entry.results) {
if (r.outcome === 'fixed') {
r.outcome = 'blocked'
r.rationale = `cross-cluster edit: shares ${shared.join(', ')} with ${other.cluster.file} - needs a human`
}
}
}
}
}
const allResults = fixed.flatMap((f) => f.results)
log(`fix: ${allResults.filter((r) => r.outcome === 'fixed').length} fixed, ` +
`${allResults.filter((r) => r.outcome === 'declined').length} declined, ` +
`${allResults.filter((r) => r.outcome === 'blocked').length} blocked`)
// ---------- phase 2.6: circuit breaker (fix stage) ----------
// A high blocked rate here means the fixing itself is failing - bad ledger coordinates, a
// broken test runner, agents that cannot run the suite. Proving each of those costs a
// serial agent per cluster and cannot succeed, so stop before spending it.
if (BREAKER) {
const attempted = allResults.filter((r) => r.outcome !== 'declined').length
const failed = allResults.filter((r) => r.outcome === 'blocked').length
if (attempted >= BREAKER.minAttempts && failed / attempted > BREAKER.threshold) {
breaker = {
trippedAt: 'fix',
attempted,
failed,
threshold: BREAKER.threshold,
reason: `${failed}/${attempted} finding(s) could not be fixed - skipping prove and commit entirely`,
}
log(`CIRCUIT BREAKER: ${breaker.reason}`)
}
}
// ---------- phase 3: prove + commit ----------
phase('Prove')
const PROVE_RESULT = {
type: 'object',
required: ['committed', 'sha', 'redObserved', 'greenObserved', 'redOutput', 'greenOutput', 'note'],
properties: {
committed: { type: 'boolean' },
sha: { type: 'string', description: 'short sha of the commit, empty when not committed' },
redObserved: { type: 'boolean', description: 'did the tests FAIL with the source reverted' },
greenObserved: { type: 'boolean', description: 'did the tests PASS with the fix restored' },
redOutput: {
type: 'string',
description:
'the ACTUAL output of the test run performed with the source reverted (step 4), including the ' +
'command that was run. This run must FAIL. Paste the real captured output verbatim - not a ' +
'summary, not a paraphrase.',
},
greenOutput: {
type: 'string',
description:
'the ACTUAL output of the test run performed after the fix was restored (step 6), including the ' +
'command that was run. This run must PASS. Paste the real captured output verbatim - not a ' +
'summary, not a paraphrase.',
},
note: { type: 'string', description: 'why it was not committed, empty on success' },
},
}
function provePrompt(cluster, fixedIds, testPaths, sourcePaths) {
return (
`You are proving and committing ONE cluster of fixes in the OwnCord repo ` +
`(checked out at your current working directory - do not assume any absolute path), on branch ${BRANCH}.\n\n` +
`Source file(s): ${sourcePaths.join(', ')}\n` +
`Findings fixed here: ${fixedIds.join(', ')}\n` +
`Test files written: ${testPaths.join(', ') || '(none reported)'}\n\n` +
`You are running SERIALLY. No other agent is touching git right now, so you may use git freely.\n\n` +
`Do exactly this, in order:\n` +
` 1. Run: git rev-parse --abbrev-ref HEAD\n` +
` It MUST print exactly "${BRANCH}". If it does not, DO NOT touch git any further: set ` +
`committed=false, explain in note which branch you actually found, and STOP. Committing to the wrong ` +
`branch (e.g. main, because the operator forgot to create/checkout ${BRANCH} first) is not recoverable ` +
`by this agent.\n` +
` 2. Copy the current (fixed) contents of ALL source file(s) listed above to a scratch location ` +
`outside the repo.\n` +
` 3. Run: git checkout HEAD -- ${sourcePaths.join(' ')}\n` +
` Revert every source path listed above, and nothing else. Do NOT revert or delete the test ` +
`files - a brand-new test file is untracked and this leaves it alone, and a new case in an existing ` +
`test file is a modification to a path you did not name, so it survives too. Either way the new ` +
`assertions are present while the fix is gone.\n` +
` 4. Run the tests listed above. They MUST fail. Set redObserved accordingly. Capture the ACTUAL ` +
`output of this run, including the command you ran, and return it verbatim in redOutput - not a ` +
`summary, not a paraphrase.\n` +
` If they PASS, the tests do not pin the bug - they are vacuous. Restore the fixed source from ` +
`scratch, set committed=false, explain in note, and STOP. Do not commit. Do not try to repair the ` +
`test yourself.\n` +
` 5. Restore the fixed source file(s) from your scratch copy.\n` +
` 6. Run the tests again. They MUST pass. Set greenObserved accordingly. Capture the ACTUAL output ` +
`of this run, including the command you ran, and return it verbatim in greenOutput - not a summary, ` +
`not a paraphrase. If they do not pass, set committed=false, explain in note, and STOP.\n` +
` 7. Before staging, diff every file you are about to commit and check the content belongs to ` +
`THIS cluster: other agents' uncommitted work shares this tree, and shared test files or ` +
`regenerated output can carry their hunks. A test function or comment citing a finding id not ` +
`listed above, or a hunk in a generated/shared file unrelated to your findings, must NOT be ` +
`committed - set committed=false, name the foreign content in note, and STOP.\n` +
` 8. Stage ALL source file(s) listed above (git add ${sourcePaths.join(' ')}) AND the test files. ` +
`Then check git status --porcelain for OTHER modified tracked test files in the same package(s)/` +
`directory(ies) as your source files: a fix in this cluster may have rewritten a pre-existing test ` +
`that locked the old behavior, or widened an interface that a fake/mock in a sibling test file must ` +
`now implement - leaving such a companion uncommitted makes the committed branch fail or not compile ` +
`on its own. If the modification's content belongs to THIS cluster's fix (per the step-7 check), ` +
`stage it too; if it cites another cluster's findings, leave it. Commit with subject:\n` +
` fix(<area>): ${fixedIds.length} defect(s) (${fixedIds.join(', ')})\n` +
` Use a conventional-commit area matching the file (voice, ws, client, identity...). Do not add a ` +
`Co-Authored-By trailer.\n` +
` 9. Return the short sha.\n\n` +
`Client tests run from Client/tauri-client with:\n` +
` NODE_OPTIONS=--no-experimental-webstorage npx vitest run <testfile>\n` +
`Server tests run from Server with:\n` +
` go test ./<pkg>/ -run <TestName>`
)
}
const commits = []
let proveAttempts = 0
let proveFailures = 0
// Serial on purpose: parallel git commands collide on .git/index.lock.
for (const { cluster, results, union } of fixed) {
if (breaker) {
// Tripped either before the loop (fix stage) or on an earlier iteration. Everything
// from here on was never attempted; say so rather than leaving it reported as fixed,
// which would put a `fixed` status in the ledger with no commit behind it.
for (const r of results) {
if (r.outcome === 'fixed') {
r.outcome = 'blocked'
r.rationale = `circuit breaker tripped before this cluster was attempted (${breaker.reason}); edits are uncommitted in the working tree`
}
}
continue
}
const fixedHere = results.filter((r) => r.outcome === 'fixed')
if (!fixedHere.length) {
log(`prove ${cluster.file}: no fixes to prove - skipped`)
continue
}
const ids = fixedHere.map((r) => r.id)
const testPaths = [...new Set(fixedHere.map((r) => r.testPath).filter(Boolean))]
// A dead/thrown prove agent must not take down the sibling clusters still waiting in this
// serial loop - same "one blocked cluster does not poison the rest" rule Phase 2 gets from
// parallel()'s catch. Fold it into a null result so the ok/why logic below handles it uniformly.
// Opus on purpose: prove is the last eyes before content reaches a public commit. The
// 2026-08-14 run's sonnet prove agents staged a sibling cluster's test function and a
// regenerated-file hunk from another cluster's uncommitted work without noticing either.
const p = await agent(provePrompt(cluster, ids, testPaths, union), {
label: `prove:${cluster.file}`,
phase: 'Prove',
model: 'opus',
effort: 'high',
schema: PROVE_RESULT,
}).catch(() => null)
// Counted before the ok check on purpose: successes belong in the denominator. Increment
// this inside the failure branch instead and the ratio is failures-over-failures, which is
// always 1.0 - the breaker would trip on the first failed cluster at any threshold.
proveAttempts++
const ok = p && p.committed && p.redObserved && p.greenObserved && p.sha
if (!ok) {
const why = !p
? 'prove agent failed'
: !p.redObserved
? `revert-proof failed: tests still passed with the fix reverted (${p.note || 'no note'})`
: !p.greenObserved
? `tests did not pass after restoring the fix (${p.note || 'no note'})`
: `not committed (${p.note || 'no note'})`
log(`prove ${cluster.file}: ${why} - ${ids.length} finding(s) blocked`)
for (const r of results) {
if (r.outcome === 'fixed') {
r.outcome = 'blocked'
r.rationale = why
}
}
proveFailures++
if (BREAKER && proveAttempts >= BREAKER.minAttempts && proveFailures / proveAttempts > BREAKER.threshold) {
breaker = {
trippedAt: 'prove',
attempted: proveAttempts,
failed: proveFailures,
threshold: BREAKER.threshold,
reason: `${proveFailures}/${proveAttempts} cluster(s) failed their revert-proof - stopping before the rest`,
}
log(`CIRCUIT BREAKER: ${breaker.reason}`)
}
continue
}
commits.push({ sha: p.sha, file: cluster.file, ids })
log(`prove ${cluster.file}: committed ${p.sha} (${ids.join(', ')})`)
}
// ---------- phase 4: gate ----------
const GATE_RESULT = {
type: 'object',
required: ['passed', 'stacks', 'output'],
properties: {
passed: { type: 'boolean' },
stacks: { type: 'array', items: { type: 'string' } },
output: { type: 'string', description: 'the failing command and its output, or a short ok summary' },
},
}
function stacksFor(files) {
const s = new Set()
for (const f of files) {
if (f.startsWith('Server/')) s.add('server')
else if (f.startsWith('Client/tauri-client/src-tauri/')) s.add('rust')
else if (f.startsWith('Client/')) s.add('client')
}
return [...s]
}
const GATE_COMMANDS = {
client:
`From Client/tauri-client:\n` +
` NODE_OPTIONS=--no-experimental-webstorage npm test\n` +
` npm run typecheck\n` +
` npm run lint\n` +
` npm run format:check`,
server:
`From Server:\n` +
` go build ./... && go build -tags otel ./... && go build -tags wazero ./... && go build -tags otel,wazero ./...\n` +
` go vet ./...\n` +
` go test -race ./...\n` +
` go test -tags deadlock -count=1 ./ws/\n` +
` golangci-lint run\n` +
` make sqlc-verify protocol-verify # generated output must not be stale. If make is not on PATH, ` +
`run the equivalent commands directly instead: ` +
`"sqlc generate && git diff --exit-code db/dbgen" and ` +
`"go run ./scripts/genprotocol && git diff --exit-code ws/message_types.go ../Client/tauri-client/src/lib/protocolTypes.ts" ` +
`- a non-empty diff in either means generated code is stale and the gate fails`,
rust:
`From Client/tauri-client/src-tauri:\n` +
` cargo test\n` +
` cargo clippy --all-targets -- -D warnings`,
}
let gate = null
if (commits.length) {
phase('Gate')
const stacks = stacksFor(commits.map((c) => c.file))
gate = await agent(
`Run the OwnCord CI gates locally for the stacks touched by this fix run, on branch ${BRANCH}.\n\n` +
`This runs ONCE for the whole run - a full gate per fix would take longer than the fixing did.\n\n` +
`Touched stacks: ${stacks.join(', ')}\n\n` +
stacks.map((s) => GATE_COMMANDS[s]).join('\n\n') +
`\n\nRun every command for every touched stack. Report passed=false if ANY of them fails, and put ` +
`the failing command plus the relevant output in "output". Do NOT fix anything, do NOT amend or ` +
`revert any commit, and do NOT push. Reporting the failure accurately is the whole job.\n\n` +
`Known false alarm: a windows -race failure inside ws whose stack mentions runtime.scanstack or ` +
`runtime.(*unwinder).next is a Go 1.26.5 runtime GC fault, not a real failure - rerun that package ` +
`once before reporting it.`,
{ label: 'gate', phase: 'Gate', model: 'sonnet', effort: 'xhigh', schema: GATE_RESULT },
).catch(() => null)
// A malformed/missing report (dead agent, or a schema the caller didn't honor) is treated as a
// failed gate, same as the null-check pattern in phases 2 and 3 - never crash on shape here.
if (!gate || typeof gate.passed !== 'boolean' || !Array.isArray(gate.stacks))
gate = { passed: false, stacks, output: (gate && gate.output) || 'gate agent failed to report' }
log(`gate: ${gate.passed ? 'PASS' : 'FAIL'} (${gate.stacks.join(', ')})`)
} else {
log('gate: nothing committed - skipped')
}
return { branch: BRANCH, clusters: publicClusters, excluded, commits, results: allResults, gate, breaker }
+860
View File
@@ -0,0 +1,860 @@
// Offline harness for bughunt.js - mimics the workflow runtime: wraps the script
// body in an AsyncFunction with stubbed agent/parallel/pipeline/phase/log/args/budget.
// Run: node .claude/workflows/bughunt.harness.mjs [nameFilter]
import { readFileSync } from 'node:fs'
import { dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
import assert from 'node:assert/strict'
const here = dirname(fileURLToPath(import.meta.url))
const AsyncFunction = Object.getPrototypeOf(async function () {}).constructor
export async function run({ agentStub, args = undefined, budget = undefined }) {
const src = readFileSync(join(here, 'bughunt.js'), 'utf8')
const body = src.replace('export const meta', 'const meta')
const calls = []
const logs = []
const agent = async (prompt, opts = {}) => {
calls.push({ prompt, opts })
return agentStub(prompt, opts)
}
const parallel = (thunks) =>
Promise.all(thunks.map((t) => Promise.resolve().then(t).catch(() => null)))
const pipeline = (items, ...stages) =>
Promise.all(
items.map(async (item, i) => {
let v = item
for (const stage of stages) {
try {
v = await stage(v, item, i)
} catch {
return null
}
}
return v
}),
)
const log = (m) => logs.push(String(m))
const phase = () => {}
const budgetImpl = budget || { total: null, spent: () => 0, remaining: () => Infinity }
const fn = new AsyncFunction('agent', 'parallel', 'pipeline', 'phase', 'log', 'args', 'budget', body)
const result = await fn(agent, parallel, pipeline, phase, log, args, budgetImpl)
return { result, calls, logs }
}
// ---------- stub kit (used from Task 2 onward; harmless now) ----------
export function makeStub({ hunt, verify, recon = defaultRecon }) {
return (prompt, opts) => {
const label = opts.label || ''
if (label.startsWith('recon:')) return recon(label)
let m = /^r(\d+):hunt:([a-z0-9-]+):opus$/.exec(label)
if (m) return hunt(Number(m[1]), m[2], 'opus', prompt)
m = /^r(\d+):verify:([a-z0-9-]+?)(:retry)?$/.exec(label)
if (m) {
const candidates = JSON.parse(prompt.split('--- CANDIDATES ---')[1])
return verify(Number(m[1]), m[2], candidates, Boolean(m[3]), prompt)
}
throw new Error(`unexpected agent label: ${label}`)
}
}
export function defaultRecon() {
return 'Server/ws/hub.go 12\nServer/api/user.go 9\nClient/tauri-client/src/lib/dispatcher.ts 8'
}
export const none = { findings: [] }
export const finding = (n, over = {}) => ({
title: `distinct bug alpha${n} omega${n}`,
file: 'Server/ws/hub.go',
line: 100 + n * 40,
severity: 'high',
why: 'w',
repro: 'r',
evidence: 'e',
...over,
})
export const graphRows = (n) =>
Array.from({ length: n }, (_, i) => ({ file: `Server/gen/g${i}.go`, score: 1 - i / (n + 1), degree: 10, cited: 5 }))
export const confirmAll = (cands) => ({
verdicts: cands.map((c) => ({
title: c.title, file: c.file, line: c.line,
refuted: false, reason: 'confirmed', confidence: 'high',
severity: c.severity || 'high', fix: 'fix',
})),
})
export const refuteAll = (cands) => ({
verdicts: cands.map((c) => ({
title: c.title, file: c.file, line: c.line,
refuted: true, reason: 'refuted', confidence: 'high',
severity: c.severity || 'high',
})),
})
// ---------- scenarios ----------
const scenarios = {}
// S1: happy convergence - one bug in round 1, rounds 2-3 dry -> converged.
scenarios.s1_convergence = async () => {
const { result, calls, logs } = await run({
agentStub: makeStub({
hunt: (round, key, model) =>
round === 1 && key === 'ws-hub' && model === 'opus' ? { findings: [finding(1)] } : none,
verify: (round, key, cands) => confirmAll(cands),
}),
})
for (const k of ['converged', 'stoppedOnBudget', 'rounds', 'confirmed', 'unverified', 'report'])
assert.ok(k in result, `missing key ${k}`)
assert.equal(result.converged, true)
assert.equal(result.rounds.length, 3)
assert.deepEqual(result.rounds.map((r) => r.dryAfter), [0, 1, 2])
assert.deepEqual(result.rounds.map((r) => r.family), ['surfaces', 'bug-classes', 'flows'])
assert.equal(result.confirmed.length, 1)
assert.ok(!calls.some((c) => (c.opts.label || '').startsWith('r4:')), 'no round 4 after convergence')
assert.ok(!calls.some((c) => c.opts.label === 'report'), 'the report is built in-script')
assert.match(result.report, /CONVERGED after 3 round\(s\)/)
assert.match(result.report, /### high - distinct bug alpha1 omega1/)
assert.match(result.report, /\| 1 \| surfaces \|/)
assert.ok(logs.some((l) => /budget=NONE - cost ceiling disarmed/.test(l)), 'a directive-less run must announce the dead ceiling')
}
// S2: near-duplicate findings from a single finder collapse - one candidate, one verify call.
scenarios.s2_finder_dedupe = async () => {
const verifyBatches = []
const { result, calls } = await run({
agentStub: makeStub({
hunt: (round, key) =>
round === 1 && key === 'ws-hub'
? { findings: [finding(1, { line: 100 }), finding(1, { line: 105, title: 'distinct bug alpha1 omega1 variant' })] }
: none,
verify: (round, key, cands) => {
verifyBatches.push(cands)
return confirmAll(cands)
},
}),
})
assert.equal(verifyBatches.length, 1)
assert.equal(verifyBatches[0].length, 1)
assert.equal(result.confirmed.length, 1)
const hunts = calls.filter((c) => /^r1:hunt:ws-hub:/.test(c.opts.label || ''))
assert.equal(hunts.length, 1, 'exactly one finder call per lens - the sonnet slot is gone')
assert.match(hunts[0].opts.label, /:opus$/, 'the label keeps the :opus suffix the harness parses')
}
// S3: refuted findings stay dead - re-reported next round, never re-verified; refutes count toward dry.
scenarios.s3_refuted_permanence = async () => {
const { result, calls } = await run({
agentStub: makeStub({
hunt: (round, key, model) => {
if (round === 1 && key === 'ws-hub' && model === 'opus') return { findings: [finding(2)] }
if (round === 2 && key === 'state-desync' && model === 'opus') return { findings: [finding(2)] }
return none
},
verify: (round, key, cands) => refuteAll(cands),
}),
})
const verifyRounds = calls
.map((c) => /^r(\d+):verify:/.exec(c.opts.label || ''))
.filter(Boolean)
.map((m) => Number(m[1]))
assert.deepEqual(verifyRounds, [1], 'refuted candidate must not be re-verified in round 2')
assert.equal(result.rounds[0].refuted, 1)
assert.equal(result.confirmed.length, 0)
assert.equal(result.rounds.length, 2) // refute-only r1 is dry -> converged after r2
assert.equal(result.converged, true)
assert.ok(!calls.some((c) => c.opts.label === 'report'), 'zero confirmed -> code-built report')
assert.match(result.report, /Converged/i)
}
// S4: backstop - fresh confirmed bug every round with maxRounds=3 -> stops, NOT converged.
scenarios.s4_backstop = async () => {
const firstLens = { 1: 'ws-hub', 2: 'concurrency', 3: 'flow-reconnect' }
const { result } = await run({
args: { maxRounds: 3 },
agentStub: makeStub({
hunt: (round, key, model) =>
model === 'opus' && key === firstLens[round]
? { findings: [finding(round, { file: `Server/ws/f${round}.go` })] }
: none,
verify: (round, key, cands) => confirmAll(cands),
}),
})
assert.equal(result.rounds.length, 3)
assert.equal(result.converged, false)
assert.equal(result.stoppedOnBudget, false)
assert.equal(result.confirmed.length, 3)
assert.deepEqual(result.rounds.map((r) => r.dryAfter), [0, 0, 0])
}
// S5: failed finder -> round dry-ineligible; dry counter neither increments nor resets.
scenarios.s5_finder_failure_ineligible = async () => {
const { result } = await run({
args: { maxRounds: 3 },
agentStub: makeStub({
hunt: (round, key, model) => {
if (round === 1 && key === 'ws-hub' && model === 'opus') return { findings: [finding(1)] }
if (round === 2 && key === 'concurrency' && model === 'opus') return null // dead finder
return none
},
verify: (round, key, cands) => confirmAll(cands),
}),
})
assert.equal(result.rounds[1].dryEligible, false)
assert.deepEqual(result.rounds.map((r) => r.dryAfter), [0, 0, 1])
assert.equal(result.converged, false)
}
// S6: failed verifier retried once, retry succeeds.
scenarios.s6_verifier_retry = async () => {
const { result, calls } = await run({
agentStub: makeStub({
hunt: (round, key, model) =>
round === 1 && key === 'ws-hub' && model === 'opus' ? { findings: [finding(1)] } : none,
verify: (round, key, cands, isRetry) => (isRetry ? confirmAll(cands) : null),
}),
})
assert.ok(calls.some((c) => (c.opts.label || '').endsWith(':retry')))
assert.equal(result.confirmed.length, 1)
assert.equal(result.converged, true)
}
// S6b: verifier fails twice -> candidate dropped unconfirmed, round ineligible;
// re-reported later, verified then, and scrubbed from the unverified list.
scenarios.s6b_verifier_double_failure = async () => {
const { result } = await run({
args: { maxRounds: 3 },
agentStub: makeStub({
hunt: (round, key, model) => {
if (round === 1 && key === 'ws-hub' && model === 'opus') return { findings: [finding(3)] }
if (round === 2 && key === 'state-desync' && model === 'opus') return { findings: [finding(3)] }
return none
},
verify: (round, key, cands) => (round === 1 ? null : confirmAll(cands)),
}),
})
assert.equal(result.rounds[0].dryEligible, false)
assert.equal(result.rounds[0].confirmed, 0)
assert.equal(result.confirmed.length, 1)
assert.equal(result.confirmed[0].round, 2)
assert.equal(result.unverified.length, 0, 'later-confirmed candidate must leave the unverified list')
}
// N1 (spec #1): the top-ranked hotspot cluster sits out exactly the next round, then returns.
// The producing cluster keeps running when eligible (the old s7b lock, restated under cooldown).
scenarios.s_cluster_cooldown = async () => {
const { calls } = await run({
args: { maxRounds: 6, dryThreshold: 9, graph: graphRows(60) },
agentStub: makeStub({
hunt: (round, key) => {
if (round === 1 && key === 'ws-hub')
return { findings: [
finding(1, { file: 'Server/ws/hub.go', title: 'ws bug alpha one' }),
finding(2, { file: 'Server/ws/pubsub.go', line: 300, title: 'ws bug beta two' }),
] }
if (round === 2 && key === 'concurrency')
return { findings: [
finding(3, { file: 'Server/ws/emit.go', title: 'ws bug gamma three' }),
finding(4, { file: 'Server/api/user.go', title: 'api bug delta four' }),
] }
if (round === 4 && key === 'hotspot-server-ws')
return { findings: [finding(9, { file: 'Server/ws/late.go', title: 'late ws bug nine' })] }
return none
},
verify: (round, key, cands) => confirmAll(cands),
}),
})
const hunted = (rnd, key) => calls.some((c) => (c.opts.label || '') === `r${rnd}:hunt:${key}:opus`)
assert.ok(hunted(4, 'hotspot-server-ws'), 'top cluster hunts in r4')
assert.ok(!hunted(5, 'hotspot-server-ws'), 'the r4 top cluster must sit out r5 even though it produced')
assert.ok(hunted(5, 'hotspot-server-api'), 'the next cluster takes the top slot in r5')
assert.ok(hunted(6, 'hotspot-server-ws'), 'cooldown lasts exactly one round')
}
// N2 (spec #2): a cooldown gap FREEZES cleanStreak - neither increments nor resets - so
// demotion still means two consecutive clean APPEARANCES. If the gap incremented, ws would
// be demoted before r6; if demotion broke, ws would still run in r8.
scenarios.s_cooldown_freezes_streak = async () => {
const { result, calls } = await run({
args: { maxRounds: 8, dryThreshold: 9, graph: graphRows(100) },
agentStub: makeStub({
hunt: (round, key) => {
if (round === 1 && key === 'ws-hub')
return { findings: [
finding(1, { file: 'Server/ws/hub.go', title: 'ws bug alpha one' }),
finding(2, { file: 'Server/ws/pubsub.go', line: 300, title: 'ws bug beta two' }),
] }
if (round === 2 && key === 'concurrency')
return { findings: [finding(3, { file: 'Server/api/user.go', title: 'api bug delta three' })] }
return none
},
verify: (round, key, cands) => confirmAll(cands),
}),
})
const hunted = (rnd, key) => calls.some((c) => (c.opts.label || '') === `r${rnd}:hunt:${key}:opus`)
assert.ok(hunted(4, 'hotspot-server-ws'), 'clean appearance #1 in r4')
assert.ok(!hunted(5, 'hotspot-server-ws'), 'cooldown in r5')
assert.ok(hunted(6, 'hotspot-server-ws'), 'the gap must freeze the streak at 1, not increment it')
assert.equal(result.rounds.length, 8, 'the run must reach r8 for the demotion assert to mean anything')
assert.ok(!hunted(8, 'hotspot-server-ws'), 'two clean appearances (r4, r6) demote the lens')
}
// N3 (spec #3): cooldown+demotion emptying the hotspot pool must backfill from explore and
// log it - silent family shrinkage is the exact freshEyesLens() defect this rebuild removes.
scenarios.s_hotspot_backfill = async () => {
const { calls, logs } = await run({
args: { maxRounds: 5, dryThreshold: 9, graph: graphRows(100) },
agentStub: makeStub({
hunt: (round, key) =>
round === 1 && key === 'ws-hub'
? { findings: [finding(1, { file: 'Server/ws/hub.go', title: 'lone ws bug one' })] }
: none,
verify: (round, key, cands) => confirmAll(cands),
}),
})
const r5 = calls.filter((c) => /^r5:hunt:/.test(c.opts.label || '')).map((c) => c.opts.label.split(':')[2])
assert.ok(!r5.some((k) => k.startsWith('hotspot-')), 'the sole cluster is on cooldown in r5')
assert.deepEqual([...r5].sort(), ['explore-1', 'explore-2', 'explore-3', 'explore-4'], 'the family backfills to full size from explore')
assert.ok(logs.some((l) => /hotspot pool short/.test(l)), 'backfill must be logged, never silent')
}
// N4 (spec #4): within-run consumption - later rounds draw the NEXT chunk of the ranking,
// never re-offering files already handed to an explore lens this run.
scenarios.s_explore_consumption = async () => {
const { calls } = await run({
args: { maxRounds: 5, dryThreshold: 9, graph: graphRows(80) },
agentStub: makeStub({
hunt: (round, key) =>
round === 1 && key === 'ws-hub'
? { findings: [finding(1, { file: 'Server/ws/hub.go', title: 'seed bug one' })] }
: none,
verify: (round, key, cands) => confirmAll(cands),
}),
})
const promptOf = (rnd, key) => (calls.find((c) => (c.opts.label || '') === `r${rnd}:hunt:${key}:opus`) || {}).prompt || ''
assert.match(promptOf(4, 'explore-1'), /Server\/gen\/g0\.go/)
assert.match(promptOf(4, 'explore-2'), /Server\/gen\/g10\.go/)
assert.match(promptOf(4, 'explore-3'), /Server\/gen\/g20\.go/, 'r4 backfills a third explore lens (single cluster)')
assert.match(promptOf(5, 'explore-1'), /Server\/gen\/g30\.go/, 'r5 draws the next chunk')
assert.doesNotMatch(promptOf(5, 'explore-1'), /Server\/gen\/g0\.go/, 'r5 must not re-offer r4 files')
}
// N6 (spec #6): args.graph absent -> churn-based fresh-eyes fallback, logged, family intact.
scenarios.s_graph_missing_fallback = async () => {
const { calls, logs } = await run({
args: { maxRounds: 4, dryThreshold: 9 },
agentStub: makeStub({
hunt: (round, key) =>
round === 1 && key === 'ws-hub'
? { findings: [finding(1, { file: 'Server/ws/hub.go', title: 'seed bug one' })] }
: none,
verify: (round, key, cands) => confirmAll(cands),
}),
})
assert.ok(logs.some((l) => /falling back to churn/.test(l)), 'the fallback must be logged')
const e1 = calls.find((c) => (c.opts.label || '') === 'r4:hunt:explore-1:opus')
assert.ok(e1, 'an explore lens must still run from churn')
assert.match(e1.prompt, /Server\/api\/user\.go/, 'churned file with no findings feeds the fallback')
}
// S7: rounds 1-3 each confirm a bug -> round 4 runs adaptive lenses: directory-granularity
// hotspots plus explore (churn fallback here - no args.graph is passed).
scenarios.s7_adaptive_lenses = async () => {
const A = finding(1, { file: 'Server/ws/hub.go', line: 120, title: 'alpha race window one' })
const B = finding(2, { file: 'Server/ws/pubsub.go', line: 60, title: 'beta subscription leak two' })
const C = finding(3, { file: 'Client/tauri-client/src/lib/livekitE2EE.ts', line: 200, title: 'gamma epoch desync three' })
const { result, calls } = await run({
agentStub: makeStub({
hunt: (round, key) => {
if (round === 1 && key === 'ws-hub') return { findings: [A] }
if (round === 2 && key === 'concurrency') return { findings: [B] }
if (round === 3 && key === 'flow-voice') return { findings: [C] }
return none
},
verify: (round, key, cands) => confirmAll(cands),
}),
})
assert.equal(result.converged, true)
assert.equal(result.rounds.length, 5) // r4, r5 adaptive + dry
assert.equal(result.rounds[3].family, 'adaptive')
const r4Keys = [...new Set(calls.filter((c) => /^r4:hunt:/.test(c.opts.label || '')).map((c) => c.opts.label.split(':')[2]))]
assert.ok(r4Keys.includes('hotspot-server-ws'), `r4 keys: ${r4Keys}`)
assert.ok(r4Keys.includes('hotspot-client-tauri-client-src-lib'), `r4 keys: ${r4Keys}`)
assert.ok(r4Keys.includes('explore-1'), `r4 keys: ${r4Keys}`)
const hotspot = calls.find((c) => (c.opts.label || '').includes('hotspot-server-ws'))
assert.match(hotspot.prompt, /Server\/ws\/hub\.go/)
assert.match(hotspot.prompt, /alpha race window one/)
const explore = calls.find((c) => (c.opts.label || '') === 'r4:hunt:explore-1:opus')
assert.match(explore.prompt, /Server\/api\/user\.go/) // churned, never a finding
assert.equal(result.confirmed.length, 3)
}
// S7c: a lens whose VERIFIER died is not demoted (its cluster returns after cooldown);
// a zero-candidate explore lens still accrues streak and demotes.
scenarios.s7c_verifier_failure_not_demoted = async () => {
const early = { 1: 'ws-hub', 2: 'concurrency', 3: 'flow-reconnect' }
const { result, calls } = await run({
args: { maxRounds: 6, dryThreshold: 9, graph: graphRows(100) },
agentStub: makeStub({
hunt: (round, key) => {
if (round <= 3 && key === early[round])
return { findings: [finding(round, { file: `Server/ws/a${round}.go`, title: `early bug item${round} kappa${round}` })] }
if (round >= 4 && key === 'hotspot-server-ws')
return { findings: [finding(round + 10, { file: `Server/ws/b${round}.go`, title: `late bug item${round} sigma${round}` })] }
return none
},
verify: (round, key, cands) => (round <= 3 ? confirmAll(cands) : null),
}),
})
const labels = calls.map((c) => c.opts.label || '')
assert.ok(labels.some((l) => /^r4:hunt:hotspot-server-ws:/.test(l)))
assert.ok(!labels.some((l) => /^r5:hunt:hotspot-server-ws:/.test(l)), 'cooldown after topping r4')
assert.ok(labels.some((l) => /^r6:hunt:hotspot-server-ws:/.test(l)), 'a verifier-dead lens must NOT be demoted')
assert.ok(!labels.some((l) => /^r6:hunt:explore-1:/.test(l)), 'a zero-candidate explore lens still demotes')
assert.equal(result.confirmed.length, 3)
assert.equal(result.unverified.length, 2) // b4 and b6, each denied a verdict twice
assert.equal(result.converged, false)
assert.equal(result.rounds[3].dryEligible, false)
assert.equal(result.rounds[5].dryEligible, false)
}
// S12: empty adaptive family (no confirms, no churn) must break honestly, not count dry rounds.
scenarios.s12_empty_adaptive_family = async () => {
const { result } = await run({
agentStub: makeStub({
recon: () => 'no parseable churn output',
hunt: (round, key, model) => {
if (round <= 2 && key === (round === 1 ? 'ws-hub' : 'concurrency') && model === 'opus')
return { findings: [finding(round, { file: `Server/ws/c${round}.go`, title: `verifierless bug delta${round} theta${round}` })] }
return none
},
verify: () => null,
}),
})
assert.equal(result.rounds.length, 3)
assert.equal(result.converged, false)
assert.equal(result.confirmed.length, 0)
assert.equal(result.unverified.length, 2)
}
// S8: budget below the round floor before round 1 -> zero rounds, honest non-convergence.
scenarios.s8_budget_floor = async () => {
const { result, calls } = await run({
budget: { total: 1000000, spent: () => 900000, remaining: () => 100000 },
agentStub: makeStub({ hunt: () => none, verify: (r, k, c) => confirmAll(c) }),
})
assert.equal(result.rounds.length, 0)
assert.equal(result.stoppedOnBudget, true)
assert.equal(result.converged, false)
assert.ok(!calls.some((c) => /:hunt:/.test(c.opts.label || '')))
assert.match(result.report, /budget/i)
}
// S8c: budget.total null (directive failed to arm) but args.budgetTotal supplied ->
// ceiling armed from args, announced in the config log, computed from spent().
scenarios.s8c_budget_args_fallback = async () => {
const { result, logs } = await run({
args: { budgetTotal: 10000000 },
budget: { total: null, spent: () => 9500000, remaining: () => Infinity },
agentStub: makeStub({ hunt: () => none, verify: (r, k, c) => confirmAll(c) }),
})
assert.equal(result.rounds.length, 0)
assert.equal(result.stoppedOnBudget, true)
assert.ok(logs.some((l) => /budget=10M/.test(l)), 'args-armed ceiling must announce 10M, not NONE')
assert.equal(result.runStats.config.budgetTotal, 10000000)
}
// S8b: budget runs low mid-hunt -> finishes the round it started, stops before the next.
scenarios.s8b_budget_midrun = async () => {
let n = 0
const { result } = await run({
budget: { total: 10000000, spent: () => 0, remaining: () => (n++ === 0 ? 3000000 : 400000) },
agentStub: makeStub({
hunt: (round, key, model) =>
round === 1 && key === 'ws-hub' && model === 'opus' ? { findings: [finding(1)] } : none,
verify: (round, key, cands) => confirmAll(cands),
}),
})
assert.equal(result.rounds.length, 1)
assert.equal(result.stoppedOnBudget, true)
assert.equal(result.converged, false)
assert.equal(result.confirmed.length, 1)
}
// S14: the title-word dedupe branch applies only near the prior's location.
// Dedupe is permanent, so merging two distinct same-file bugs that happen to
// share half their title words loses the second one forever.
scenarios.s14_title_dedupe_window = async () => {
const near = { file: 'Server/ws/hub.go', line: 140, title: 'hub client map race on register path' }
const far = { file: 'Server/ws/hub.go', line: 900, title: 'hub client map race on unregister' }
const verifyBatches = []
const { result } = await run({
agentStub: makeStub({
hunt: (round, key, model) => {
if (model !== 'opus') return none
if (round === 1 && key === 'ws-hub')
return { findings: [finding(1, { file: 'Server/ws/hub.go', line: 100, title: 'hub client map race on register' })] }
if (round === 2 && key === 'concurrency')
return { findings: [finding(2, far), finding(3, near)] }
return none
},
verify: (round, key, cands) => {
verifyBatches.push(cands.map((c) => c.line))
return confirmAll(cands)
},
}),
})
assert.deepEqual(verifyBatches, [[100], [900]], 'near-duplicate dropped, distant same-file bug kept')
assert.equal(result.confirmed.length, 2)
assert.ok(result.confirmed.some((c) => c.line === 900), 'the distant bug must survive dedupe')
}
// S13: JSON-stringified args must behave identically to object args (observed live: the
// runtime can deliver args as a string; maxRounds:1 silently fell back to 8 before the coercion).
scenarios.s13_string_args = async () => {
const { result, calls } = await run({
args: '{"maxRounds": 1}',
agentStub: makeStub({
hunt: (round, key, model) =>
round === 1 && key === 'ws-hub' && model === 'opus' ? { findings: [finding(1)] } : none,
verify: (round, key, cands) => confirmAll(cands),
}),
})
assert.equal(result.rounds.length, 1, 'string maxRounds:1 must cap the loop at one round')
assert.equal(result.converged, false)
assert.equal(result.confirmed.length, 1)
assert.ok(!calls.some((c) => (c.opts.label || '').startsWith('r2:')), 'no round 2 under the cap')
}
// S10: verifier returns truncated (empty) verdict lists on both attempts ->
// candidates land in unverified, round ineligible, dry counter untouched.
scenarios.s10_truncated_verdicts = async () => {
const { result, calls } = await run({
args: { maxRounds: 2 },
agentStub: makeStub({
hunt: (round, key, model) =>
round === 1 && key === 'ws-hub' && model === 'opus' ? { findings: [finding(1)] } : none,
verify: () => ({ verdicts: [] }),
}),
})
assert.ok(calls.some((c) => (c.opts.label || '').endsWith(':retry')), 'short verdict list must trigger the retry')
assert.equal(result.rounds[0].dryEligible, false)
assert.deepEqual(result.rounds.map((r) => r.dryAfter), [0, 1])
assert.equal(result.confirmed.length, 0)
assert.equal(result.unverified.length, 1)
assert.equal(result.converged, false)
}
// S11: verdict coordinates drift from the candidate's -> still pairs, confirms once,
// nothing listed unverified, and a round-2 re-report of the ORIGINAL coords is deduped.
scenarios.s11_drifted_verdict = async () => {
const orig = finding(4) // file Server/ws/hub.go, line 260
const { result, calls } = await run({
agentStub: makeStub({
hunt: (round, key, model) => {
if (round === 1 && key === 'ws-hub' && model === 'opus') return { findings: [orig] }
if (round === 2 && key === 'state-desync' && model === 'opus') return { findings: [orig] }
return none
},
verify: (round, key, cands) => ({
verdicts: cands.map((c) => ({
title: c.title, file: c.file, line: c.line + 5,
refuted: false, reason: 'confirmed', confidence: 'high', severity: 'high', fix: 'fix',
})),
}),
}),
})
const verifyRounds = calls
.map((c) => /^r(\d+):verify:/.exec(c.opts.label || ''))
.filter(Boolean)
.map((m) => Number(m[1]))
assert.deepEqual(verifyRounds, [1], 'drifted-but-paired verdict must still suppress the original coords')
assert.equal(result.confirmed.length, 1)
assert.equal(result.unverified.length, 0)
assert.equal(result.converged, true)
}
// S-known: a finding already in the ledger is suppressed - never verified, never re-confirmed,
// and its text appears in the finder prompt so the model does not spend effort re-deriving it.
scenarios.s_known_ledger_suppresses = async () => {
const known = [
{ file: 'Server/ws/hub.go', line: 140, title: 'distinct bug alpha1 omega1', status: 'declined' },
]
const { result, calls } = await run({
args: { known, maxRounds: 1, dryThreshold: 9 },
agentStub: makeStub({
hunt: (round, key, model) =>
round === 1 && key === 'ws-hub' && model === 'opus' ? { findings: [finding(1)] } : none,
verify: (round, key, cands) => confirmAll(cands),
}),
})
const huntPrompts = calls.filter((c) => /:hunt:/.test(c.opts.label || '')).map((c) => c.prompt)
assert.ok(huntPrompts.length > 0, 'expected at least one finder call')
assert.match(huntPrompts[0], /KNOWN FINDINGS/, 'ledger entries must reach the finder prompt')
assert.match(huntPrompts[0], /\[declined\] distinct bug alpha1 omega1/)
assert.ok(
!calls.some((c) => /:verify:/.test(c.opts.label || '')),
'a ledger-known candidate must not reach verification',
)
assert.equal(result.confirmed.length, 0)
}
// S-lenses: args.lenses replaces the round-1 family entirely, and the round label reflects it.
scenarios.s_custom_lenses = async () => {
const lenses = [
{ key: 'voice-e2ee-keyholder', prompt: 'Hunt the key-holder election.' },
{ key: 'voice-e2ee-rotation', prompt: 'Hunt the rotation paths.' },
]
const { result, calls } = await run({
args: { lenses, maxRounds: 1, dryThreshold: 9 },
agentStub: makeStub({ hunt: () => none, verify: (r, k, c) => confirmAll(c) }),
})
const keys = calls
.map((c) => /^r1:hunt:([a-z0-9-]+):opus$/.exec(c.opts.label || ''))
.filter(Boolean)
.map((m) => m[1])
assert.deepEqual([...new Set(keys)].sort(), ['voice-e2ee-keyholder', 'voice-e2ee-rotation'])
assert.ok(!keys.includes('ws-hub'), 'the default surface family must not run when lenses are supplied')
assert.equal(result.rounds[0].family, 'custom')
assert.equal(result.rounds[0].lenses, 2)
}
// S-lenses-default: omitting args.lenses leaves the rotation untouched.
scenarios.s_custom_lenses_absent = async () => {
const { result } = await run({
args: { maxRounds: 1, dryThreshold: 9 },
agentStub: makeStub({ hunt: () => none, verify: (r, k, c) => confirmAll(c) }),
})
assert.equal(result.rounds[0].family, 'surfaces')
}
// S-ledger-fields: a confirmed record must carry finder detail (why/repro/evidence) as well as
// verifier detail (severity/fix), because the ledger needs both.
scenarios.s_confirmed_carries_finder_detail = async () => {
const cand = {
title: 'distinct bug alpha1 omega1',
file: 'Server/ws/hub.go',
line: 140,
severity: 'low',
why: 'WHY_TEXT',
repro: 'REPRO_TEXT',
evidence: 'EVIDENCE_TEXT',
}
const { result } = await run({
args: { maxRounds: 1, dryThreshold: 9 },
agentStub: makeStub({
hunt: (round, key, model) =>
round === 1 && key === 'ws-hub' && model === 'opus' ? { findings: [cand] } : none,
verify: (round, key, cands) => ({
verdicts: cands.map((c) => ({
title: c.title, file: c.file, line: c.line,
refuted: false, reason: 'confirmed', confidence: 'high',
severity: 'high', fix: 'FIX_TEXT',
})),
}),
}),
})
assert.equal(result.confirmed.length, 1)
const r = result.confirmed[0]
assert.equal(r.why, 'WHY_TEXT')
assert.equal(r.repro, 'REPRO_TEXT')
assert.equal(r.evidence, 'EVIDENCE_TEXT')
assert.equal(r.severity, 'high', 'verifier severity must win over the finder rating')
assert.equal(r.fix, 'FIX_TEXT')
assert.equal(r.lens, 'ws-hub')
assert.equal(r.round, 1)
assert.equal(r.finder, 'opus', 'the finder tag is constant now but the ledger still expects it')
}
// S_VERIFIER_IS_NOT_TOLD_THE_FINDER: the verifier prompt deliberately says "another model" and
// never names it. Leaking the attribution tag would tell a refute-by-default verifier that opus
// found something, which is exactly the kind of authority cue that erodes refute-by-default.
scenarios.s_verifier_is_not_told_the_finder = async () => {
let verifyPromptText = ''
await run({
args: { maxRounds: 1, dryThreshold: 9 },
agentStub: makeStub({
hunt: (round, key, model) =>
round === 1 && key === 'ws-hub' && model === 'opus' ? { findings: [finding(1)] } : none,
verify: (round, key, cands, retry, prompt) => {
verifyPromptText = prompt
return confirmAll(cands)
},
}),
})
assert.ok(verifyPromptText, 'the verifier must have been called')
assert.doesNotMatch(verifyPromptText, /finder/i, 'the verifier must not be told which model found the candidate')
// Guard against over-stripping: the fields the verifier actually needs must survive.
for (const field of ['title', 'file', 'line', 'why', 'repro', 'evidence']) {
assert.match(verifyPromptText, new RegExp(`"${field}"`), `candidates must still carry ${field}`)
}
}
// New (spec Testing #8): the report is built in-script. Section count must equal the confirmed
// count at 82 (the agent version emitted 79 for 82), and the unverified section must survive
// the agent's removal - it used to exist only inside the report agent's prompt.
scenarios.s_report_deterministic = async () => {
const many = Array.from({ length: 82 }, (_, i) =>
finding(i, { file: `Server/ws/f${i}.go`, line: 10, title: `unique bug row${i} tag${i}` }))
const stuck = finding(999, { file: 'Server/api/stuck.go', line: 40, title: 'stuck bug never verified' })
const { result, calls } = await run({
args: { maxRounds: 1, dryThreshold: 9 },
agentStub: makeStub({
hunt: (round, key, model) =>
round === 1 && key === 'ws-hub' && model === 'opus' ? { findings: [...many, stuck] } : none,
verify: (round, key, cands) => confirmAll(cands.filter((c) => c.file !== 'Server/api/stuck.go')),
}),
})
assert.equal(result.confirmed.length, 82)
assert.equal(result.unverified.length, 1)
assert.ok(!calls.some((c) => c.opts.label === 'report'), 'no report agent may run')
const sections = (result.report.match(/^### /gm) || []).length
assert.equal(sections, 82, 'one section per confirmed finding, none dropped')
assert.match(result.report, /## Unverified - re-run/)
assert.match(result.report, /stuck bug never verified/)
assert.match(result.report, /## Convergence/)
}
// New (spec Testing #7): the retry re-sends ONLY unmatched candidates, and N garbage verdicts
// (count == candidate count, zero of them matching) must still trigger it - the hole S10 misses
// because S10's verdict list is empty rather than full of junk.
scenarios.s_targeted_retry = async () => {
const a = finding(1, { file: 'Server/ws/a.go', title: 'alpha bug one paired' })
const b = finding(2, { file: 'Server/api/b.go', title: 'beta bug two orphaned' })
const retryBatches = []
const { result } = await run({
args: { maxRounds: 1, dryThreshold: 9 },
agentStub: makeStub({
hunt: (round, key, model) =>
round === 1 && key === 'ws-hub' && model === 'opus' ? { findings: [a, b] } : none,
verify: (round, key, cands, isRetry) => {
if (isRetry) {
retryBatches.push(cands)
return confirmAll(cands)
}
// one real verdict for a, one garbage verdict pointing nowhere: count matches, content doesn't
return {
verdicts: [
{ title: a.title, file: a.file, line: a.line, refuted: false, reason: 'ok', confidence: 'high', severity: 'high', fix: 'f' },
{ title: 'hallucinated', file: 'Server/nowhere.go', line: 1, refuted: false, reason: 'x', confidence: 'low', severity: 'low' },
],
}
},
}),
})
assert.equal(retryBatches.length, 1, 'retry must fire despite verdict count == candidate count')
assert.deepEqual(retryBatches[0].map((c) => c.file), ['Server/api/b.go'], 'only the unmatched candidate is re-sent')
assert.equal(result.confirmed.length, 2)
assert.equal(result.unverified.length, 0)
}
// New (spec Testing #9): the retuned floor must stop a run the old 150k floor let through.
// A single opus finder round costs ~100-260k (2026-08-13 run); 400k remaining is under the
// 600k floor, so starting another round could overshoot the ceiling - stop instead.
scenarios.s9_budget_ceiling_retuned = async () => {
const { result, logs } = await run({
budget: { total: 10000000, spent: () => 9600000, remaining: () => 400000 },
agentStub: makeStub({ hunt: () => none, verify: (r, k, c) => confirmAll(c) }),
})
assert.equal(result.rounds.length, 0, '400k remaining must not start a round under the 600k floor')
assert.equal(result.stoppedOnBudget, true)
assert.ok(logs.some((l) => /Budget floor/.test(l)))
}
// New: telemetry. Per-round suppression split (ledger vs same-run), spend sampling, file
// coverage, severity mix, per-lens precision, and the top-level runStats aggregate. Without
// this every cost figure from a run is eyewitness-only - the 2026-08-12 problem.
scenarios.s_telemetry = async () => {
let spent = 0
const known = [{ file: 'Server/ws/hub.go', line: 100, title: 'known bug from ledger prior', status: 'fixed' }]
const { result } = await run({
args: { maxRounds: 1, dryThreshold: 9, known },
budget: { total: 50000000, spent: () => (spent += 500000), remaining: () => 40000000 },
agentStub: makeStub({
hunt: (round, key, model) => {
if (round !== 1 || key !== 'ws-hub' || model !== 'opus') return none
return { findings: [
finding(1, { file: 'Server/ws/hub.go', line: 102, title: 'known bug from ledger prior' }),
finding(2, { file: 'Server/api/fresh.go', severity: 'medium', title: 'fresh bug beta gamma' }),
] }
},
verify: (round, key, cands) => confirmAll(cands),
}),
})
const r1 = result.rounds[0]
assert.equal(r1.suppressedLedger, 1, 'the ledger-known duplicate must be counted as ledger suppression')
assert.equal(r1.suppressedRun, 0)
assert.ok(r1.spentAfter > r1.spentBefore, 'per-round spend must be sampled')
assert.equal(r1.filesTouched, 1)
assert.equal(r1.filesNew, 1)
assert.deepEqual(r1.severity, { critical: 0, high: 0, medium: 1, low: 0 })
assert.equal(r1.perLens['ws-hub'].confirmed, 1)
assert.equal(r1.perLens['ws-hub'].fresh, 1)
assert.ok(result.runStats, 'runStats missing from the result')
assert.equal(result.runStats.confirmed, 1)
assert.equal(result.runStats.suppressedLedger, 1)
assert.equal(result.runStats.config.maxRounds, 1)
assert.match(result.report, /## Run stats/)
}
// N7 (Task 9 review finding): a dead finder on an explore lens read nothing - its draw is
// rewound so the files never reach exploredFiles, where the session would record them clean
// and deprioritize them in every future hunt. maxRounds caps at 4 on purpose: a live round-5
// lens would legitimately re-read the rewound files and they would CORRECTLY re-enter
// exploredFiles - the poison-prevention property is only assertable when the run ends here.
// Re-offering in later rounds follows from the same exploreConsumed state drawExploreFiles
// filters on, so this one scenario locks the mechanism.
scenarios.s_explore_rewind_on_dead_finder = async () => {
const { result, calls } = await run({
args: { maxRounds: 4, dryThreshold: 9, graph: graphRows(80) },
agentStub: makeStub({
hunt: (round, key) => {
if (round === 1 && key === 'ws-hub')
return { findings: [finding(1, { file: 'Server/ws/hub.go', title: 'seed bug one' })] }
if (round === 4 && key === 'explore-1') return null // dead finder: read nothing
return none
},
verify: (round, key, cands) => confirmAll(cands),
}),
})
const promptOf = (rnd, key) => (calls.find((c) => (c.opts.label || '') === `r${rnd}:hunt:${key}:opus`) || {}).prompt || ''
assert.match(promptOf(4, 'explore-1'), /Server\/gen\/g0\.go/, 'r4 explore-1 drew the head of the ranking')
for (let i = 0; i < 10; i++)
assert.ok(!result.exploredFiles.includes(`Server/gen/g${i}.go`), `g${i} was never read - must not be reported explored`)
assert.ok(result.exploredFiles.includes('Server/gen/g10.go'), 'files a LIVE lens drew stay reported')
assert.ok(result.exploredFiles.includes('Server/gen/g20.go'), 'backfilled live lens files stay reported too')
}
// N8 (final-review finding): a THROWN stage nulls the whole lens result - the second
// finder-failure mode the code documents. Its explore draw must rewind exactly like the
// null-finder case, or never-read files reach exploredFiles and poison explored-clean.
scenarios.s_explore_rewind_on_thrown_stage = async () => {
const { result, calls } = await run({
args: { maxRounds: 4, dryThreshold: 9, graph: graphRows(80) },
agentStub: makeStub({
hunt: (round, key) => {
if (round === 1 && key === 'ws-hub')
return { findings: [finding(1, { file: 'Server/ws/hub.go', title: 'seed bug one' })] }
if (round === 4 && key === 'explore-1') throw new Error('finder infrastructure blew up')
return none
},
verify: (round, key, cands) => confirmAll(cands),
}),
})
const promptOf = (rnd, key) => (calls.find((c) => (c.opts.label || '') === `r${rnd}:hunt:${key}:opus`) || {}).prompt || ''
assert.match(promptOf(4, 'explore-1'), /Server\/gen\/g0\.go/, 'r4 explore-1 drew the head of the ranking')
for (let i = 0; i < 10; i++)
assert.ok(!result.exploredFiles.includes(`Server/gen/g${i}.go`), `g${i} was never read - must not be reported explored`)
assert.ok(result.exploredFiles.includes('Server/gen/g10.go'), 'files a LIVE lens drew stay reported')
assert.equal(result.rounds[3].dryEligible, false, 'a nulled lens result still makes the round ineligible')
}
// ---------- runner ----------
const only = process.argv[2]
for (const [name, fn] of Object.entries(scenarios)) {
if (only && !name.includes(only)) continue
try {
await fn()
} catch (e) {
console.error(`FAIL ${name}`)
throw e
}
console.log(`PASS ${name}`)
}
console.log('all scenarios pass')
+732
View File
@@ -0,0 +1,732 @@
export const meta = {
name: 'bughunt',
description: 'Converging multi-round bug hunt: rotating lens families, single opus finder, opus refute-by-default verification, dry-threshold stop',
whenToUse: 'Hunting real bugs across the Go server, Tauri Rust backend, and TS client until consecutive rounds go dry. Not a security-only scan.',
phases: [
{ title: 'Recon', detail: 'haiku: churn + concurrency-surface inventory' },
],
}
// ---------- config ----------
// args may arrive JSON-stringified (observed in run wf_9199e623-b83: maxRounds:1 never took) - coerce
const ARGS = (() => {
if (typeof args === 'string') {
try { return JSON.parse(args) || {} } catch { return {} }
}
return args || {}
})()
const MAX_ROUNDS = ARGS.maxRounds || 8
const DRY_THRESHOLD = ARGS.dryThreshold || 2
// A scoped hunt (args.lenses) replaces the round-1 family outright; later rounds still go
// adaptive, so hotspot and explore coverage - and therefore convergence - still work.
const CUSTOM_LENSES = Array.isArray(ARGS.lenses) && ARGS.lenses.length ? ARGS.lenses : null
// Floor for one round. The single opus finder (sonnet retired 2026-08-12) costs ~100-260k per
// round, measured across the 8-round 2026-08-13 run. The old 2M floor was a dual-finder-era
// anchor (~2.6M/round) that would zero-out any hunt launched with a budget under 2M - now that
// budgetTotal is a first-class arg, that cliff is a foot-gun. 600k is ~3x a measured round.
const ROUND_BUDGET_FLOOR = 600000
// The turn directive failed to arm budget.total on the 2026-08-13 live run (+25M present,
// total still null), so args.budgetTotal is the deterministic fallback. budget.spent()
// works even when total is null; budget.remaining() stays authoritative when the
// directive DID arm, because stubs (and the runtime) may track it statefully.
const BUDGET_TOTAL = budget.total || Number(ARGS.budgetTotal) || null
const remainingBudget = () => (budget.total ? budget.remaining() : BUDGET_TOTAL ? Math.max(0, BUDGET_TOTAL - budget.spent()) : Infinity)
// The args channel has already been observed delivering something the script
// could not read; an unnoticed fallback here is an 8x cost surprise, so say out
// loud what the run is actually going to do.
log(`config: maxRounds=${MAX_ROUNDS} dryThreshold=${DRY_THRESHOLD}${CUSTOM_LENSES ? ` lenses=custom(${CUSTOM_LENSES.length})` : ''} budget=${BUDGET_TOTAL ? Math.round(BUDGET_TOTAL / 1e6) + 'M' : 'NONE - cost ceiling disarmed'}`)
// ---------- schemas: copied VERBATIM from the current bughunt.js ----------
const FINDINGS = {
type: 'object',
required: ['findings'],
properties: {
findings: {
type: 'array',
items: {
type: 'object',
required: ['title', 'file', 'line', 'severity', 'why', 'repro'],
properties: {
title: { type: 'string' },
file: { type: 'string', description: 'repo-relative path' },
line: { type: 'integer' },
severity: { type: 'string', enum: ['critical', 'high', 'medium', 'low'] },
why: { type: 'string', description: 'the defect, one or two sentences' },
repro: { type: 'string', description: 'concrete inputs/interleaving -> wrong behavior' },
evidence: { type: 'string', description: 'the code lines that prove it' },
},
},
},
},
}
const VERDICTS = {
type: 'object',
required: ['verdicts'],
properties: {
verdicts: {
type: 'array',
items: {
type: 'object',
required: ['title', 'file', 'line', 'refuted', 'reason', 'confidence', 'severity'],
properties: {
title: { type: 'string' },
file: { type: 'string' },
line: { type: 'integer' },
refuted: { type: 'boolean' },
reason: { type: 'string', description: 'what refutes it, or what confirms it in the code' },
confidence: { type: 'string', enum: ['high', 'medium', 'low'] },
severity: { type: 'string', enum: ['critical', 'high', 'medium', 'low'] },
fix: { type: 'string', description: 'smallest correct fix, if confirmed' },
},
},
},
},
}
// ---------- rules ----------
const RULES = `
Repo: OwnCord, checked out at your current working directory (the repo root - do not assume any absolute
path; run every command from there and use repo-relative paths). Go 1.26 server in Server/, Tauri v2 client in Client/tauri-client/
(Rust in src-tauri/src/, TypeScript in src/lib/ and src/stores/).
You are hunting REAL BUGS: wrong behavior, not style. In scope:
- logic errors, off-by-one, wrong operator, inverted condition, wrong default
- concurrency: data races, deadlocks, lock-order inversion, missed wakeups, goroutine leaks, TOCTOU
- lifecycle: use-after-close, double-close, nil deref on error paths, leaked resources/listeners/timers
- state machines that can reach an unintended state, or desync between two sources of truth
- error paths that silently swallow, lose data, or leave partial writes
- auth/authz checks reading stale state, or missing on one path while present on siblings
Out of scope, do not report: naming, formatting, missing tests, "consider adding", speculative hardening,
performance that is not a hang, anything you cannot point at specific lines for.
Method:
1. Read the actual files. Never report from a filename, a grep hit, or a graph edge alone - a
graphify edge is structural evidence of coupling, not of a bug; open the cited file and confirm.
2. For every candidate, grep for ALL callers before judging - a guard may already live upstream.
3. Check whether an existing test already locks the behavior you think is wrong. If a test asserts it,
it is intended behavior, not a bug. Test files are *_test.go and tests/unit/*.test.ts.
4. Report EVERY finding you can prove - there is no cap. The quality bar stays: zero findings is a
valid, respectable answer, and each finding needs file, line, and a concrete repro.
You may run read-only shell commands (grep, git log, go doc, graphify path, graphify explain).
Do not modify any file. Do not run the test suite.
`
// ---------- lens catalog ----------
// keys must match /^[a-z0-9-]+$/ - they are embedded in agent labels the harness parses.
const SURFACE_LENSES = [
{
key: 'ws-hub',
prompt:
`Surface: the WebSocket hub and its client lifecycle. Files: Server/ws/*.go (skip *_test.go) - start with ` +
`client.go, hub*.go, emit.go, event.go, event_persister.go, event_pruner.go, handlers*.go, command.go.\n\n` +
`Hunt specifically for: send on closed channel; write to a client after unregister; hub map mutated without ` +
`the right lock held; lock ordering between hub and client; a goroutine that outlives its client; ` +
`read-pump/write-pump shutdown races; events emitted to a client mid-unregister; event ordering that can ` +
`invert under concurrent publish; pruner racing the persister over the same rows.\n` +
`Trace at least one full connect -> subscribe -> emit -> disconnect path end to end before reporting anything.`,
},
{
key: 'voice-e2ee',
prompt:
`Surface: voice/video E2EE key lifecycle, spanning three languages. Files: Server/ws/handler_v2_voice*.go and ` +
`any Server/ws/*voice*.go or *e2ee*.go; Client/tauri-client/src/lib/e2eeCrypto.ts, livekitE2EE.ts, ` +
`livekitSession.ts, identity.ts; Client/tauri-client/src-tauri/src/tofu.rs, secret_store.rs, fallback_crypto.rs, dpapi.rs.\n\n` +
`Hunt specifically for: a key-rotation window where a participant can decrypt after they should be excluded; ` +
`TOFU pin re-check that reads state captured before a rotation (time-of-check/time-of-use); a participant ` +
`joining mid-rotation getting the wrong epoch key; key material outliving the session; an error path that ` +
`falls back to unencrypted or to a zeroed/default key; sender/receiver epoch disagreement after reconnect.\n` +
`This area was hardened before - check git log for the relevant commits and do NOT re-report anything already fixed.`,
},
{
key: 'api-authz',
prompt:
`Surface: REST API auth and authorization. Files: Server/api/*.go (skip *_test.go), Server/auth/*.go, ` +
`Server/permissions/*.go.\n\n` +
`Hunt specifically for: a permission checked against a snapshot that can go stale before it is used; ` +
`a handler that checks channel access but not server/guild access (or vice versa); an ID taken from the ` +
`request body when it should come from the session; sibling handlers where one path has a guard and a ` +
`near-identical one does not; rate limiter keyed on something the caller controls; role/override resolution ` +
`that returns allow on error instead of deny.\n` +
`Compare handlers against each other - the strongest signal here is inconsistency between siblings.`,
},
{
key: 'db-storage',
prompt:
`Surface: persistence. Files: Server/db/*.go (NOT db/dbgen/, that is generated), Server/db/queries/*.sql, ` +
`Server/migrations/*.sql, Server/storage/*.go, Server/service/*.go.\n\n` +
`Hunt specifically for: a multi-statement operation that is not in one transaction and can leave partial state; ` +
`a tx that can be committed twice or leaked without rollback on an early return; sql.ErrNoRows treated as a ` +
`real error or swallowed as success; a query whose SQL semantics disagree with what the caller assumes ` +
`(LIMIT, ordering, NULL handling, JOIN dropping rows); a migration that is not idempotent or that breaks ` +
`an older row shape; unbounded result sets read fully into memory.\n` +
`Read the .sql alongside its Go caller - the bug is usually the gap between them.`,
},
{
key: 'tauri-rust',
prompt:
`Surface: the Tauri Rust backend. Files: Client/tauri-client/src-tauri/src/*.rs.\n\n` +
`Hunt specifically for: a panic reachable from a Tauri command (unwrap/expect on attacker- or ` +
`environment-controlled input) - a panic here can take down the app; a lock held across .await; ` +
`state in tauri::State mutated from two commands without coordination; the http_proxy / livekit_proxy / ` +
`ws_proxy forwarding a header, URL, or origin it should filter; credentials/secret_store material logged, ` +
`left in memory, or written unencrypted on a fallback path; ptt.rs global hook not released on shutdown.\n` +
`For each panic you find, state exactly which input reaches it.`,
},
{
key: 'client-state',
prompt:
`Surface: TypeScript client state and event handling. Files: Client/tauri-client/src/lib/*.ts and ` +
`src/stores/*.ts - prioritize dispatcher.ts, reconcile.ts, read-state.ts, router.ts, roomEventHandlers.ts, ` +
`navigation-guard.ts, rate-limiter.ts, channel-navigation.ts, and whatever the churn recon flagged.\n\n` +
`Hunt specifically for: a listener/interval/observer registered without a matching teardown (check ` +
`disposable.ts for the intended pattern and find who bypasses it); reconcile logic that drops or duplicates ` +
`an entity when events arrive out of order; read-state that can mark unread messages read, or lose an unread ` +
`count, across a reconnect; an async handler whose await lets stale state be written after a newer update ` +
`(last-write-wins race); a route guard bypassable by a rapid navigation sequence.\n` +
`Check tests/unit/ before reporting - much of this behavior is already test-locked.`,
},
]
const BUGCLASS_LENSES = [
{
key: 'concurrency',
prompt:
`Bug class: concurrency and interleaving - sweep the whole repo for THIS CLASS ONLY.\n` +
`Go (Server/): data races on maps/slices/fields shared between goroutines; lock-order inversion; ` +
`missed wakeups; TOCTOU between a check and its use; goroutines racing shutdown; send on closed channel.\n` +
`Rust (src-tauri/src/): a lock held across .await; tauri::State mutated from two commands without ` +
`coordination; Arc<Mutex<_>> cloned into tasks that outlive their owner.\n` +
`TS (src/lib/, src/stores/): two async handlers interleaving on the same store (last-write-wins after ` +
`an await); a stale closure writing state after a newer update already landed.\n` +
`Use the recon concurrency-surface inventory to pick files. For every candidate, name the exact interleaving.`,
},
{
key: 'lifecycle',
prompt:
`Bug class: lifecycle and teardown - sweep the whole repo for THIS CLASS ONLY.\n` +
`Every acquire must have a matching release on EVERY exit path: goroutines outliving their owner; ` +
`timers/intervals/listeners/workers registered without removal (client disposable.ts is the intended ` +
`pattern - find who bypasses it); double-close and use-after-close; teardown-order mistakes; ` +
`Rust Drop not running (mem::forget, leaked handles, the ptt.rs global hook); ` +
`partial teardown when an error interrupts the happy path halfway.`,
},
{
key: 'state-desync',
prompt:
`Bug class: two sources of truth drifting - sweep the whole repo for THIS CLASS ONLY.\n` +
`Pairs to audit: hub client maps vs pubsub registrations; server voice state vs LiveKit vs client ` +
`stores; client read-state vs server acked sequence numbers; DB rows vs in-memory caches; ` +
`any two structures updated by different code paths. Find the path that updates one and not the ` +
`other - reconnect, replacement, and error paths are where they diverge.`,
},
{
key: 'error-paths',
prompt:
`Bug class: error-path data loss - sweep the whole repo for THIS CLASS ONLY.\n` +
`Swallowed errors (err assigned and ignored, empty catch, unwrap_or(default) hiding failure); ` +
`partial writes left behind on early return; fallbacks that silently degrade to wrong behavior; ` +
`an error mapped to success upstream; cleanup skipped when the happy path is interrupted mid-way. ` +
`Read every 'if err != nil', catch block, and .catch in the hot files from recon.`,
},
{
key: 'ordering-boundary',
prompt:
`Bug class: ordering and boundaries - sweep the whole repo for THIS CLASS ONLY.\n` +
`Off-by-one and fence-post errors; LIMIT/pagination silently truncating; sequence-number gaps, ` +
`duplication, or inversion between assignment and delivery; sort-stability and tie assumptions; ` +
`first/last/empty-collection special cases; inclusive-vs-exclusive range disagreements between a ` +
`caller and its callee (read the SQL alongside its Go caller).`,
},
]
const FLOW_LENSES = [
{
key: 'flow-reconnect',
prompt:
`Flow: WebSocket drop -> reconnect -> resume. Trace it END TO END across all three languages before ` +
`reporting anything. Server: the serve handshake/resume path, hub client replacement and state ` +
`transfer (this transfer has needed four separate fixes: unsubscribe identity, VoiceTopic+E2EE key ` +
`transfer, focused-channel transfer, closeSend ordering - hunt for what it STILL misses), topic ` +
`re-subscription, cold/warm replay tiers. Client: the reconnect loop, seq ack tracking, store ` +
`reconcile after resume. Report any state that exists on the old connection and does not provably ` +
`reach the new one.`,
},
{
key: 'flow-voice',
prompt:
`Flow: voice join -> E2EE key announce/offer -> key-holder election -> rotation -> participant ` +
`leave -> LiveKit webhook -> cleanup. Trace it END TO END: Server/ws/*voice*, livekit_webhook.go, ` +
`client livekitE2EE.ts and livekitSession.ts, Rust livekit_proxy.rs. Hunt for: a participant who can ` +
`still decrypt after they should be excluded; holder-election stalls; epoch/key disagreement after ` +
`reconnect; the three take-out-of-voice paths (webhook, sweep, voice_leave) diverging.`,
},
{
key: 'flow-message',
prompt:
`Flow: message send -> permission gate -> persist -> sequence assign -> fan-out -> replay tiers -> ` +
`client store -> read-state/unread counts. Trace it END TO END and hunt the gaps BETWEEN layers: ` +
`persisted but never fanned out; delivered but sequence-skipped; acked via max(seq) while a lower ` +
`seq was dropped; unread counts drifting from actual unread messages across reconnect or channel switch.`,
},
{
key: 'flow-session',
prompt:
`Flow: login -> session/token issue -> per-connection auth -> revocation/sweep -> kick -> API-token ` +
`paths. Trace it END TO END and hunt stale-authorization windows: state checked at connect but not ` +
`re-checked at use; revocation that kicks the WS but leaves another surface authorized; the sweep ` +
`racing an in-flight request; API tokens diverging from session-token semantics on any path.`,
},
]
function lensesForRound(round) {
if (CUSTOM_LENSES) return round === 1 ? CUSTOM_LENSES : buildAdaptiveLenses(round)
if (round === 1) return SURFACE_LENSES
if (round === 2) return BUGCLASS_LENSES
if (round === 3) return FLOW_LENSES
return buildAdaptiveLenses(round)
}
function familyName(round) {
if (CUSTOM_LENSES) return round === 1 ? 'custom' : 'adaptive'
return ['surfaces', 'bug-classes', 'flows'][round - 1] || 'adaptive'
}
// Directory granularity: the old two/three-segment cluster collapsed the whole TS client into
// one bucket (35 of 82 findings), so the "top cluster" never changed for five straight rounds.
function clusterOf(file) {
const parts = String(file).split('/')
return parts.length > 1 ? parts.slice(0, -1).join('/') : parts[0]
}
// ---------- explore targeting ----------
// args.graph: session-computed coupling ranking (rank-explore.mjs). The workflow only reads
// .file - scoring already happened outside, where the filesystem is.
const GRAPH_ROWS = (Array.isArray(ARGS.graph) ? ARGS.graph : []).filter((r) => r && typeof r.file === 'string')
const EXPLORE_FILES_PER_LENS = 10
const exploreConsumed = new Set() // within-run consumption: never re-offer a file to a later round
let exploreFallbackLogged = false
function drawExploreFiles() {
let pool
if (GRAPH_ROWS.length) pool = GRAPH_ROWS.map((r) => r.file)
else {
if (!exploreFallbackLogged) {
log('explore: args.graph absent/empty - falling back to churn-based fresh eyes')
exploreFallbackLogged = true
}
pool = churnFiles
}
const files = pool
.filter((f) => !exploreConsumed.has(f) && !seen.some((s) => s.file === f))
.slice(0, EXPLORE_FILES_PER_LENS)
for (const f of files) exploreConsumed.add(f)
return files
}
function exploreLens(i) {
const files = drawExploreFiles()
if (!files.length) return null
const src = GRAPH_ROWS.length
? `These files are heavily coupled (per the code graph) to files where confirmed bugs live, yet no ` +
`hunt has confirmed or refuted a single finding in them - either they are clean or every lens so ` +
`far walked past them.`
: `These files churned heavily in the last 8 weeks, yet no hunt round has confirmed or refuted a ` +
`single finding in them - either they are clean or every lens so far walked past them.`
return {
key: `explore-${i}`,
prompt: `${src} Read each one IN FULL with fresh eyes and hunt for real bugs of any class:\n` +
files.map((f) => ` - ${f}`).join('\n'),
files,
}
}
let cooldownCluster = null // the top-ranked cluster hunted in round N sits out round N+1
function buildAdaptiveLenses(round) {
const byCluster = {}
for (const c of confirmedAll) {
const cl = clusterOf(c.file)
if (!byCluster[cl]) byCluster[cl] = []
byCluster[cl].push(c)
}
// Explore-heavy schedule: measured hotspot yield flattened to 0.25 high+med/agent by round 6.
const hotspotQuota = round <= 5 ? 2 : 1
const exploreQuota = round <= 5 ? 2 : 3
const hotKey = (cl) => ('hotspot ' + cl).toLowerCase().replace(/[^a-z0-9]+/g, '-')
const picked = Object.entries(byCluster)
.sort((a, b) => b[1].length - a[1].length)
.filter(([cl]) => cl !== cooldownCluster)
.filter(([cl]) => (cleanStreak[hotKey(cl)] || 0) < 2) // pre-filter so backfill sees the real shortfall
.slice(0, hotspotQuota)
cooldownCluster = picked.length ? picked[0][0] : null
const hotspots = picked.map(([cluster, items]) => ({
key: hotKey(cluster),
prompt:
`Bugs cluster. Confirmed findings so far in ${cluster}:\n` +
items.map((i) => ` - ${i.file}:${i.line} ${i.title}`).join('\n') +
`\nHunt ADJACENT to these: the same functions' siblings, every caller, the counterpart operations ` +
`(subscribe/unsubscribe, open/close, register/transfer, acquire/release), and the paths a past fix ` +
`here did NOT cover. Do not re-report the findings listed above - they are already known.`,
}))
const shortfall = hotspotQuota - hotspots.length
if (shortfall > 0) log(`adaptive: hotspot pool short by ${shortfall} - trying explore backfill`)
const explores = []
for (let i = 1; i <= exploreQuota + shortfall; i++) {
if ((cleanStreak[`explore-${i}`] || 0) >= 2) continue // demoted slot: no substitution, that IS demotion
const lens = exploreLens(i)
if (!lens) {
log(`adaptive: explore pool exhausted after ${explores.length} lens(es)`)
break
}
explores.push(lens)
}
return [...hotspots, ...explores]
}
// ---------- dedupe + ledger helpers ----------
function normTitle(t) {
return String(t || '').toLowerCase().replace(/[^a-z0-9 ]+/g, ' ').split(/\s+/).filter((w) => w.length > 2)
}
// Dedupe is permanent: a candidate merged into an existing entry never comes
// back, so an over-eager match silently loses a real bug rather than deferring
// it. The title-word branch therefore only applies near the prior's location -
// two distinct bugs in one file often share half their title words ("hub client
// map race on register" vs "...on unregister"), and without a window the second
// one is suppressed forever, sometimes by a merely REFUTED namesake.
const TITLE_MATCH_WINDOW = 60
function isDup(a, b) {
if (a.file !== b.file) return false
const delta = Math.abs((a.line || 0) - (b.line || 0))
if (delta <= 10) return true
if (delta > TITLE_MATCH_WINDOW) return false
const aw = normTitle(a.title)
if (!aw.length) return false
const bw = new Set(normTitle(b.title))
const hits = aw.filter((w) => bw.has(w)).length
return hits * 2 >= aw.length
}
function dedupe(cands, priors, counts) {
const kept = []
for (const c of cands) {
const prior = priors.find((p) => isDup(c, p))
if (prior) {
if (counts) counts[prior.fromLedger ? 'suppressedLedger' : 'suppressedRun']++
continue
}
if (kept.some((k) => isDup(c, k))) {
if (counts) counts.suppressedRun++
continue
}
kept.push(c)
}
return kept
}
function seenBlock(seen) {
if (!seen.length) return ''
const lines = seen.map((s) => ` - ${s.file}:${s.line} [${s.status}] ${s.title}`)
return `\n--- KNOWN FINDINGS (already investigated - do NOT re-report; refuted means examined and rejected) ---\n${lines.join('\n')}\n`
}
function convergenceTable(stats, converged, stoppedOnBudget) {
const verdict = converged
? `CONVERGED after ${stats.length} round(s).`
: stoppedOnBudget
? 'NOT converged - stopped on budget.'
: 'NOT converged - hit the round backstop.'
const rows = stats.map(
(s) =>
`| ${s.round} | ${s.family} | ${s.lenses} | ${s.candidates} | ${s.fresh} | ${s.confirmed} | ${s.refuted} | ${s.dryEligible ? 'yes' : 'NO'} | ${s.dryAfter} |`,
)
return [
'## Convergence',
'',
verdict,
'',
'| round | family | lenses | candidates | fresh | confirmed | refuted | dry-eligible | dry after |',
'|---|---|---|---|---|---|---|---|---|',
...rows,
].join('\n')
}
// ---------- recon (verbatim from the current script, including both prompts) ----------
phase('Recon')
const recon = await parallel([
() =>
agent(
`${RULES}\n\nRECON TASK (mechanical, do not hunt bugs yourself):\n` +
`Run, from the repo root: git log --since="8 weeks ago" --name-only --pretty=format: -- Server Client\n` +
`Count how often each non-test source file changed. Return the 25 most-churned files with their counts, ` +
`plus any file that changed in more than 6 distinct commits. High churn = where bugs concentrate.\n` +
`Return plain text: one "path count" per line, most-churned first. No commentary.`,
{ label: 'recon:churn', phase: 'Recon', model: 'haiku', effort: 'xhigh' },
),
() =>
agent(
`${RULES}\n\nRECON TASK (mechanical, do not hunt bugs yourself):\n` +
`Inventory the concurrency and lifecycle surface so the finders know where to look. Report:\n` +
` (a) every Server/ non-test .go file containing "go func", "sync.", "chan ", "select {", or "context.WithCancel"\n` +
` (b) every Client/tauri-client/src/**/*.ts (non-test) containing "addEventListener", "setInterval", "setTimeout", or "new AbortController"\n` +
` (c) every Client/tauri-client/src-tauri/src/*.rs containing "unsafe", "Mutex", "RwLock", "spawn", or "unwrap()"\n` +
`For each file give the path and a rough hit count. Return plain text grouped under (a)/(b)/(c). No commentary, no analysis.`,
{ label: 'recon:surface', phase: 'Recon', model: 'haiku', effort: 'xhigh' },
),
])
const CONTEXT = `\n\n--- RECON: most-churned files (last 8 weeks) ---\n${recon[0] || 'unavailable'}\n\n--- RECON: concurrency & lifecycle surface ---\n${recon[1] || 'unavailable'}\n`
const churnFiles = String(recon[0] || '')
.split('\n')
.map((l) => l.trim().split(/\s+/)[0])
.filter((p) => p.includes('/'))
log('Recon complete - starting converging rounds')
// ---------- round loop ----------
// Cross-run memory: the calling session passes the findings ledger in as args.known.
// Seeding `seen` is all it takes - finderPrompt() already interpolates seenBlock(seen),
// and each round already dedupes fresh candidates against it, so one assignment buys both
// prompt-level suppression ("do not re-derive this") and mechanical dedupe.
const seen = (ARGS.known || []).map((k) => ({
file: k.file,
line: k.line,
title: k.title,
status: k.status || 'known',
fromLedger: true, // telemetry: distinguishes ledger suppression from same-run suppression
}))
const confirmedAll = []
const unverified = []
const roundStats = []
const cleanStreak = {}
let dry = 0
let round = 0
let stoppedOnBudget = false
function finderPrompt(lens, rnd) {
return (
`${RULES}${CONTEXT}${seenBlock(seen)}\n\nThis is round ${rnd} of a converging hunt. Everything under ` +
`KNOWN FINDINGS has already been investigated - spend zero effort re-deriving those; hunt for what is ` +
`NOT on that list.\n\n${lens.prompt}`
)
}
function verifyPrompt(lensKey, candidates) {
return (
`${RULES}\n\nYou are an ADVERSARIAL VERIFIER. Another model hunted the "${lensKey}" lens of this repo and ` +
`produced the candidate findings below. Your job is to REFUTE them, not to agree with them.\n\n` +
`For each candidate, independently: open the cited file, read the surrounding function in full, grep every ` +
`caller, and look for an existing test that locks the current behavior. Then ask, in order:\n` +
` 1. Does the cited code actually say what the finding claims? (Misread code is the most common failure.)\n` +
` 2. Is the bad state actually reachable, or does an upstream guard/type/lock make it impossible?\n` +
` 3. Is the described repro real - can you name the concrete inputs or the exact interleaving?\n` +
` 4. Is this intended behavior that a test already asserts?\n\n` +
`Set refuted=true if ANY of those kills it. DEFAULT TO refuted=true when you are uncertain - a false ` +
`positive costs more than a miss here. Only set refuted=false when you can point at the specific lines ` +
`that prove the bug and describe how it fires.\n` +
`Re-rate severity yourself; do not inherit the hunter's rating. For each survivor, give the smallest ` +
`correct fix - one guard in the shared function beats a guard in every caller.\n\n` +
`Return one verdict per candidate, keeping title/file/line so they can be matched up.\n\n` +
// Strip the panel attribution here rather than at the call sites: the prompt above says
// "another model" on purpose, and naming it is an authority cue that erodes refute-by-default.
`--- CANDIDATES ---\n${JSON.stringify(candidates.map(({ finder, ...c }) => c), null, 2)}`
)
}
while (dry < DRY_THRESHOLD && round < MAX_ROUNDS) {
if (BUDGET_TOTAL && remainingBudget() < ROUND_BUDGET_FLOOR) {
stoppedOnBudget = true
log(`Budget floor reached (${Math.round(remainingBudget() / 1000)}k left) - stopping before round ${round + 1}`)
break
}
const family = lensesForRound(round + 1)
if (!family || !family.length) break // nothing to hunt != everything demoted
round++
const spentBefore = budget.spent()
const counts = { suppressedLedger: 0, suppressedRun: 0, finderNull: 0, finderEmpty: 0, verifierNull: 0 }
const lenses = family.filter((l) => (cleanStreak[l.key] || 0) < 2)
if (!lenses.length) {
dry++
roundStats.push({ round, family: familyName(round), lenses: 0, candidates: 0, fresh: 0, confirmed: 0, refuted: 0, dryEligible: true, dryAfter: dry, severity: { critical: 0, high: 0, medium: 0, low: 0 }, perLens: {}, filesTouched: 0, filesNew: 0, ...counts, spentBefore, spentAfter: budget.spent() })
log(`Round ${round}: every lens demoted - counts as a dry round (dry=${dry})`)
continue
}
const rnd = round
const seenAtStart = seen.slice()
const lensResults = await pipeline(
lenses,
(lens) =>
agent(finderPrompt(lens, rnd), { label: `r${rnd}:hunt:${lens.key}:opus`, phase: `Round ${rnd}`, model: 'opus', effort: 'high', schema: FINDINGS })
.then((res) => ({ lens, res })),
async (r) => {
const { lens, res } = r
// agent() returns null on failure; a thrown stage instead nulls the whole lens result,
// which the eligibility check catches separately. Both checks are needed.
const finderFailed = res === null
if (finderFailed) counts.finderNull++
else if (!(res.findings || []).length) counts.finderEmpty++
// finder is constant now; kept on the record for ledger continuity across hunts
const union = res ? (res.findings || []).map((f) => ({ ...f, finder: 'opus' })) : []
const fresh = dedupe(union, seenAtStart, counts)
if (!fresh.length) return { lens, finderFailed, unionCount: union.length, fresh: [], matched: [], unmatched: [] }
log(`r${rnd} ${lens.key}: ${fresh.length} fresh candidate(s) -> verification`)
// opus, not fable: fable verify agents hit usage limits and nulled out en masse on
// the 2026-08-13 live run (and were the dominant cost even when they worked)
const vopts = { phase: `Round ${rnd}`, model: 'opus', effort: 'high', schema: VERDICTS }
// Pair verdicts to candidates as they arrive, then retry ONLY what got no usable verdict.
// Retrying the whole batch re-burned every verdict on a partial return, and the old
// count-based trigger let N unmatched garbage verdicts skip the retry entirely.
const matched = []
const unmatched = fresh.slice()
const absorb = (vs) => {
for (const v of vs || []) {
const vRec = { file: v.file, line: v.line, title: v.title }
const idx = unmatched.findIndex((f) => isDup(vRec, f) || isDup(f, vRec))
if (idx === -1) {
const claimed = matched.some(({ cand }) => isDup(vRec, cand) || isDup(cand, vRec))
log(`r${rnd} ${lens.key}: verifier verdict "${v.title}" (${v.file}:${v.line}) ${claimed ? 'duplicates an already-claimed candidate' : 'matched no candidate'} - dropped`)
continue
}
const [cand] = unmatched.splice(idx, 1)
matched.push({ v, cand })
}
}
const v1 = await agent(verifyPrompt(lens.key, fresh), { ...vopts, label: `r${rnd}:verify:${lens.key}` })
absorb(v1 && v1.verdicts)
if (!v1) counts.verifierNull++
if (unmatched.length) {
const v2 = await agent(verifyPrompt(lens.key, unmatched.slice()), { ...vopts, label: `r${rnd}:verify:${lens.key}:retry` })
absorb(v2 && v2.verdicts)
if (!v2) counts.verifierNull++
}
return { lens, finderFailed, unionCount: union.length, fresh, matched, unmatched }
},
)
// a thrown stage nulls the whole lens result - rewind its explore draw too, or the
// session records never-read files as explored-clean (the same poison as a null finder)
lensResults.forEach((r, i) => {
if (!r && lenses[i].files) for (const f of lenses[i].files) exploreConsumed.delete(f)
})
let eligible = !lensResults.some((r) => !r)
let newConfirmed = 0
let newRefuted = 0
let candCount = 0
let freshCount = 0
const perLens = {}
const sevMix = { critical: 0, high: 0, medium: 0, low: 0 }
const filesTouched = new Set()
const filesNew = new Set()
for (const r of lensResults.filter(Boolean)) {
candCount += r.unionCount
freshCount += r.fresh.length
if (r.finderFailed) eligible = false
if (r.finderFailed && r.lens.files) {
// a dead finder read nothing: un-consume its draw so later rounds can re-offer the
// files and the session does not record never-examined files as explored-clean
for (const f of r.lens.files) exploreConsumed.delete(f)
}
let lensConfirmed = 0
let lensRefuted = 0
for (const { v, cand } of r.matched) {
// Keep the matched candidate: the verdict schema has no why/repro/evidence, and the
// ledger needs them. Verdict fields are spread last so the verifier's re-rated severity
// and its corrected title/file/line win over the finder's.
const rec = { file: v.file, line: v.line, title: v.title, status: v.refuted ? 'refuted' : 'confirmed' }
if (seen.some((p) => isDup(rec, p))) { counts.suppressedRun++; continue } // cross-lens same-round duplicate
seen.push(rec)
if (v.refuted) { newRefuted++; lensRefuted++ }
else {
newConfirmed++
lensConfirmed++
sevMix[v.severity] = (sevMix[v.severity] || 0) + 1
confirmedAll.push({ ...cand, ...v, lens: r.lens.key, round })
}
}
if (r.unmatched.length) {
eligible = false // partial verifier failure: some candidates got no verdict at all
for (const f of r.unmatched) unverified.push({ ...f, lens: r.lens.key, round })
}
// a lens hunted at partial panel strength, or whose candidates never got a verdict, is not evidence of cleanliness
if (!r.finderFailed && !r.unmatched.length) cleanStreak[r.lens.key] = lensConfirmed > 0 ? 0 : (cleanStreak[r.lens.key] || 0) + 1
perLens[r.lens.key] = { candidates: r.unionCount, fresh: r.fresh.length, confirmed: lensConfirmed, refuted: lensRefuted, unverified: r.unmatched.length }
// coverage proxy: files that produced fresh candidates this round (finder reading is unobservable)
for (const f of r.fresh) {
filesTouched.add(f.file)
if (!seenAtStart.some((s) => s.file === f.file)) filesNew.add(f.file)
}
}
if (newConfirmed > 0) dry = 0
else if (eligible) dry++
// ineligible zero-confirm round: dry unchanged - "we didn't fully look" is not "it's clean"
roundStats.push({ round, family: familyName(round), lenses: lenses.length, candidates: candCount, fresh: freshCount, confirmed: newConfirmed, refuted: newRefuted, dryEligible: eligible, dryAfter: dry, severity: sevMix, perLens, filesTouched: filesTouched.size, filesNew: filesNew.size, ...counts, spentBefore, spentAfter: budget.spent() })
log(`Round ${round} (${familyName(round)}): ${newConfirmed} confirmed, ${newRefuted} refuted, dry=${dry}${eligible ? '' : ' (ineligible)'}`)
}
const converged = dry >= DRY_THRESHOLD
// ---------- report (deterministic) ----------
// A report agent silently dropped findings (79 sections for 82 confirmed on 2026-08-12), so the
// markdown is assembled in-script from confirmedSorted. Coordinate spot-checking moved to the
// calling session, which validates EVERY finding's file/line after return (see bughunt-run).
const RANK = { critical: 0, high: 1, medium: 2, low: 3 }
const confirmedSorted = confirmedAll.slice().sort((a, b) => RANK[a.severity] - RANK[b.severity])
const unverifiedFinal = unverified.filter((u) => !seen.some((p) => isDup(u, p)))
const table = convergenceTable(roundStats, converged, stoppedOnBudget)
const sum = (k) => roundStats.reduce((n, r) => n + (r[k] || 0), 0)
const runStats = {
config: { maxRounds: MAX_ROUNDS, dryThreshold: DRY_THRESHOLD, customLenses: !!CUSTOM_LENSES, knownCount: (ARGS.known || []).length, graphRows: GRAPH_ROWS.length, budgetTotal: BUDGET_TOTAL },
spentTotal: budget.spent(),
rounds: roundStats.length,
converged,
stoppedOnBudget,
confirmed: confirmedSorted.length,
refuted: sum('refuted'),
unverified: unverifiedFinal.length,
suppressedLedger: sum('suppressedLedger'),
suppressedRun: sum('suppressedRun'),
finderNull: sum('finderNull'),
finderEmpty: sum('finderEmpty'),
verifierNull: sum('verifierNull'),
}
function buildReport() {
const outcome = converged
? `CONVERGED after ${round} round(s).`
: stoppedOnBudget
? `NOT converged - stopped on budget after ${round} round(s).`
: `NOT converged - hit the round backstop after ${round} round(s).`
const sev = { critical: 0, high: 0, medium: 0, low: 0 }
for (const f of confirmedSorted) sev[f.severity] = (sev[f.severity] || 0) + 1
const lines = ['# Bug hunt report', '']
lines.push(
`${confirmedSorted.length} confirmed finding(s) - ${sev.critical} critical, ${sev.high} high, ` +
`${sev.medium} medium, ${sev.low} low. ${outcome}` +
(confirmedSorted.length
? ` Fix first: ${confirmedSorted[0].title} (\`${confirmedSorted[0].file}:${confirmedSorted[0].line}\`).`
: ''),
'',
)
for (const f of confirmedSorted) {
lines.push(`### ${f.severity} - ${f.title}`, '')
lines.push(`\`${f.file}:${f.line}\` - lens \`${f.lens}\`, round ${f.round}, confidence ${f.confidence}`, '')
if (f.why) lines.push(f.why, '')
if (f.repro) lines.push(`**Repro:** ${f.repro}`, '')
if (f.evidence) lines.push(`**Evidence:** ${f.evidence}`, '')
if (f.fix) lines.push(`**Fix:** ${f.fix}`, '')
}
if (unverifiedFinal.length) {
lines.push('## Unverified - re-run', '')
for (const u of unverifiedFinal) lines.push(`- \`${u.file}:${u.line}\` ${u.title} (lens \`${u.lens}\`, round ${u.round})`)
lines.push('')
}
lines.push(table)
lines.push('', '## Run stats', '')
lines.push(
`Total spent: ${runStats.spentTotal} output tokens across ${runStats.rounds} round(s). ` +
`Suppressed by dedupe: ${runStats.suppressedLedger} ledger-known, ${runStats.suppressedRun} same-run. ` +
`Agent failures: ${runStats.finderNull} finder null, ${runStats.finderEmpty} finder empty, ${runStats.verifierNull} verifier null.`,
'',
)
lines.push('| round | spent | files (new) | suppressed ledger/run | finder null/empty | verifier null |')
lines.push('|---|---|---|---|---|---|')
for (const s of roundStats)
lines.push(`| ${s.round} | ${s.spentAfter - s.spentBefore} | ${s.filesTouched} (${s.filesNew}) | ${s.suppressedLedger}/${s.suppressedRun} | ${s.finderNull}/${s.finderEmpty} | ${s.verifierNull} |`)
return lines.join('\n')
}
const report = buildReport()
return { converged, stoppedOnBudget, rounds: roundStats, confirmed: confirmedSorted, unverified: unverifiedFinal, runStats, exploredFiles: [...exploreConsumed], report }
+4
View File
@@ -17,6 +17,10 @@
- [ ] Unit tests pass (`npm test` / `go test ./...`)
- [ ] TypeScript check passes (`npx tsc --noEmit`)
- [ ] Manual testing done (describe below)
- [ ] Docs updated — anything under `docs/architecture/` (incl. `ux/`) whose
"Source of truth" files this PR touches is updated in the same PR
(their maintenance rule), and reference docs (`api.md`, `protocol.md`,
`schema.md`, `server-configuration.md`) reflect any surface changes
## Screenshots
+29 -10
View File
@@ -1,5 +1,20 @@
version: 2
# Every ecosystem groups its updates into a single PR. Splitting per package
# means each ecosystem's lockfile (go.sum, package-lock.json, Cargo.lock) is
# rewritten once per PR, so merging any one of them invalidates all the rest —
# every sibling then rebases and re-runs the full ~15 minute CI matrix. The
# 2026-08-10 batch opened 17 PRs for one weekly refresh.
#
# Grouping also keeps release trains together. The OpenTelemetry modules move
# in lockstep, and npm families version-lock their own packages with exact peer
# pins (typescript-checker@9.6.0 requires core@9.6.0, not ^9.6.0), so a partial
# merge is an ERESOLVE failure waiting to happen.
#
# Majors are ignored everywhere below, so each group only ever carries patch and
# minor updates. If one member of a group is bad, add it to that ecosystem's
# ignore list rather than ungrouping the rest.
updates:
# Go server dependencies
- package-ecosystem: gomod
@@ -13,6 +28,10 @@ updates:
- dependencies
- go
open-pull-requests-limit: 10
groups:
go-dependencies:
patterns:
- "*"
ignore:
- dependency-name: "*"
update-types: ["version-update:semver-major"]
@@ -29,18 +48,10 @@ updates:
- dependencies
- npm
open-pull-requests-limit: 10
# These families version-lock their own packages with exact peer pins
# (e.g. typescript-checker@9.6.0 requires core@9.6.0, not ^9.6.0), so a
# PR-per-package split guarantees an ERESOLVE failure whenever only some
# of them are merged. Group each family into a single PR.
groups:
stryker:
npm-dependencies:
patterns:
- "@stryker-mutator/*"
vitest:
patterns:
- "vitest"
- "@vitest/*"
- "*"
ignore:
- dependency-name: "*"
update-types: ["version-update:semver-major"]
@@ -57,6 +68,10 @@ updates:
- dependencies
- rust
open-pull-requests-limit: 5
groups:
cargo-dependencies:
patterns:
- "*"
ignore:
- dependency-name: "*"
update-types: ["version-update:semver-major"]
@@ -73,6 +88,10 @@ updates:
- dependencies
- ci
open-pull-requests-limit: 5
groups:
actions-dependencies:
patterns:
- "*"
ignore:
- dependency-name: "*"
update-types: ["version-update:semver-major"]
+145 -74
View File
@@ -35,9 +35,9 @@ jobs:
run:
working-directory: Server/
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
- uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 # v5.5.0
- uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5.6.0
with:
go-version: "1.26"
cache-dependency-path: Server/go.sum
@@ -75,6 +75,19 @@ jobs:
- name: Run tests with deadlock detection
run: go test -tags deadlock -count=1 ./...
# Tag-gated tests (DC-06 / T-2026-07-25-16). The build-tag matrix above
# only COMPILES the otel/wazero variants; the tests behind those tags
# (plugin/sandbox_wazero_test.go, telemetry/telemetry_otel_test.go) ran
# nowhere until this step. Scoped to the two packages that carry tagged
# files — every other package is tag-invariant and already covered by the
# race run above. One leg is enough; no -race (the runtime under the tag
# is the concern, not new concurrency).
- name: Run tag-gated tests (-tags wazero, -tags otel)
if: matrix.os == 'ubuntu-latest'
run: |
go test -tags wazero -count=1 ./plugin/...
go test -tags otel -count=1 ./telemetry/...
- name: Upload Go coverage
if: always()
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
@@ -83,20 +96,32 @@ jobs:
path: Server/coverage.out
retention-days: 7
# verify: false — the action's default `config verify` pass fetches
# golangci-lint.run's JSONSchema over HTTPS before linting anything, so a
# timeout on that host fails a required job having run zero linters (it
# took main red on d352696). `golangci-lint run` rejects a bad config on
# its own; the schema pass only bought a prettier error message, priced
# at a third-party site inside the gate.
- name: Lint
uses: golangci/golangci-lint-action@1e7e51e771db61008b38414a730f564565cf7c20 # v9.2.0
uses: golangci/golangci-lint-action@ba0d7d2ec06a0ea1cb5fa41b2e4a3ab91d21278a # v9.3.0
with:
version: v2.11.3
working-directory: Server/
verify: false
# ubuntu-latest deliberately: the client TS code has zero win32-conditional
# paths (no process.platform / path.sep branches in src or the unit suites),
# prettier pins endOfLine: lf and .gitattributes forces eol=lf, so a Windows
# runner adds queue time without adding coverage. Windows-specific behavior
# is covered where it exists: rust-tests and the tauri-build matrix.
client-check:
name: Client Static Checks
runs-on: windows-latest
runs-on: ubuntu-latest
defaults:
run:
working-directory: Client/tauri-client/
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
@@ -107,25 +132,6 @@ jobs:
- name: Install npm dependencies
run: npm ci
- name: Patch auto-generated Tauri TypeScript bindings
working-directory: Client/tauri-client/
# tauri-typegen generates an Event type that is intentionally unused in app code.
# Rename it to _Event so @typescript-eslint/no-unused-vars does not fail.
run: |
node -e "
const fs = require('fs');
const p = 'src/generated/events.ts';
if (fs.existsSync(p)) {
let c = fs.readFileSync(p, 'utf8');
c = c.replace(/^type Event\b/gm, 'type _Event').replace(/^interface Event\b/gm, 'interface _Event');
c = c.replace(/,\s*type Event\s*(?=\})/g, ' '); // unused named import from @tauri-apps/api/event
fs.writeFileSync(p, c);
console.log('Patched: renamed Event -> _Event in generated/events.ts');
} else {
console.log('src/generated/events.ts not found, skipping patch.');
}
"
# Scoped to shipped dependencies. The remaining high findings are all one
# advisory, brace-expansion <=5.0.7, reaching us only through dev tooling
# (eslint, @vitest/coverage-v8, stryker). Those are already on their
@@ -144,6 +150,12 @@ jobs:
- name: TypeScript check
run: npx tsc --noEmit
- name: TypeScript check (Playwright specs)
# The main tsconfig excludes tests/e2e from the app graph; this
# project typechecks the 47 spec files + fixtures + the three
# playwright configs so type rot cannot hide there.
run: npx tsc -p tsconfig.e2e.json --noEmit
- name: ESLint (type-aware rules)
run: npx eslint src/
@@ -151,19 +163,23 @@ jobs:
run: npx prettier --check "src/**/*.ts" "tests/**/*.ts"
- name: Knip (unused code & deps)
run: npx knip || true
# Blocking since the 2026-08-04 remediation: the '|| true' era let a
# real unused-export finding sit invisible in every green run.
run: npx knip
# Unit tests live in their own job so a suite failure is visible as exactly one
# failing check instead of masking the static gates above. The suite is GREEN
# and must stay green — never "fix" a failing test by editing its assertions.
# ubuntu-latest for the same reason as client-check above: jsdom-only vitest
# with no platform-conditional code under test.
client-tests:
name: Client Unit Tests
runs-on: windows-latest
runs-on: ubuntu-latest
defaults:
run:
working-directory: Client/tauri-client/
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
@@ -197,7 +213,7 @@ jobs:
run:
working-directory: Client/tauri-client/src-tauri/
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
- name: Install Linux system dependencies
run: |
@@ -218,7 +234,7 @@ jobs:
components: clippy
- name: Rust cache
uses: swatinem/rust-cache@9d47c6ad4b02e050fd481d890b2ea34778fd09d6 # v2.7.8
uses: swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
with:
workspaces: Client/tauri-client/src-tauri
@@ -228,30 +244,29 @@ jobs:
- name: Rust unit tests
run: cargo test --lib
# Playwright e2e against the mocked-Tauri dev server. The suite is green
# since the mock repair (start_http_proxy stub + voice-premise rewrite):
# a full 255-test run passes locally in ~7.5 min at 1 worker. Runaway
# protection lives in playwright.config.ts (maxFailures: 20 aborts a
# systemic cascade early; globalTimeout: 20 min self-terminates with a
# usable report) with timeout-minutes below as the outer backstop.
# Playwright e2e against the mocked-Tauri dev server. Runaway protection
# lives in playwright.config.ts (maxFailures: 20 aborts a systemic cascade
# early; globalTimeout: 20 min self-terminates with a usable report) with
# timeout-minutes below as the outer backstop.
#
# Still continue-on-error for now: a newly-revived 255-test browser suite
# may harbor rare flakes (retries: 2 covers them, but confidence needs a
# few green pushes first). Flip this job to blocking once it has been
# stably green across several pushes.
# BLOCKING since 2026-08-05 (DC-07): the post-repair soak recorded green
# full-suite runs at 270, 276 and 291 tests across the 08-04/08-05 audit
# branches, and the one hard CI failure in that window was a real spec bug
# (updater install-settle race), which a non-blocking job would have let
# rot. retries: 2 absorbs the known rare flake class (see E2E-ISSUES.md's
# flake accounting).
# See docs/audit-test-coverage-2026-07-25.md T-2026-07-25-21.
# The native config (playwright.config.native.ts) is deliberately not wired
# up — it needs a real server and a built desktop binary.
client-e2e:
name: Client E2E (Playwright, non-blocking)
name: Client E2E (Playwright)
runs-on: ubuntu-latest
continue-on-error: true
timeout-minutes: 25
defaults:
run:
working-directory: Client/tauri-client/
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
@@ -278,6 +293,59 @@ jobs:
Client/tauri-client/test-results/
retention-days: 7
# Admin-panel journey against a REAL server (no mocks): start-server.sh
# builds the Go binary and boots it with a fresh temp data dir, and the
# suite drives the embedded SPA through the first-run wizard, dashboard,
# channel CRUD, audit log and re-login — the one DC-04 surface the mocked
# suites cannot reach. Non-blocking while it earns its soak, same
# graduation convention client-e2e followed.
# GRADUATION CRITERION (recorded 2026-08-15): flip continue-on-error to
# false once the job has ~30 consecutive green runs on main with no
# infra-flake reruns — the same evidence bar client-e2e cleared (270+ green
# runs cited in docs/audit-2026-08-04-docs-and-coverage.md) scaled to this
# job's lower traffic. Check with: gh run list -w CI -b main --json
# conclusion | jq '[.[] | .conclusion] | index("failure")'.
admin-e2e:
name: Admin Panel E2E (real server, non-blocking)
runs-on: ubuntu-latest
continue-on-error: true
timeout-minutes: 20
defaults:
run:
working-directory: Client/tauri-client/
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
- uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5.6.0
with:
go-version: "1.26"
cache-dependency-path: Server/go.sum
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version: 20
cache: npm
cache-dependency-path: Client/tauri-client/package-lock.json
- name: Install npm dependencies
run: npm ci
- name: Install Playwright browser
run: npx playwright install --with-deps chromium
- name: Run admin-panel journey
run: npx playwright test --config=playwright.config.admin.ts
- name: Upload Playwright report
if: always()
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: admin-e2e-report
path: |
Client/tauri-client/playwright-report/
Client/tauri-client/test-results/
retention-days: 7
# Blocking e2e subset: the parity-feature specs (tagged "@parity"), covering
# the wire paths added in v1.2.0 (mentions/badges, per-channel mute, NSFW
# gate, group DMs, role change, custom-emoji autocomplete, voice moderation).
@@ -293,7 +361,7 @@ jobs:
run:
working-directory: Client/tauri-client/
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
@@ -327,25 +395,52 @@ jobs:
if: github.ref_name == 'main' || github.base_ref == 'main'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@b5ca514318bd6ebac0fb2aedd5d36ec1b5c232a2 # v3.10.0
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
- name: Build image (no push)
uses: docker/build-push-action@14487ce63c7a62a4a324b0bfb37086795e31c6c1 # v6.16.0
uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2
with:
context: Server/
push: false
load: true
tags: owncord-smoke:candidate
build-args: VERSION=ci
cache-from: type=gha
cache-to: type=gha,mode=max
# Full Tauri build only on PRs to main (expensive: ~15 min x2 multiplier)
# Same script release.yml runs before it signs or pushes anything. A
# boot regression — or a bug in the smoke harness itself, as happened on
# the first v1.2.0-alpha.3 release run — must fail here, not at tag time.
- name: Boot-smoke Docker image
run: bash Server/scripts/docker-smoke.sh owncord-smoke:candidate
# Full Tauri build only on PRs to main (expensive: ~15 min x2 multiplier).
#
# Skipped for Dependabot: its PRs run under the separate `dependabot` secrets
# scope, so TAURI_SIGNING_PRIVATE_KEY arrives empty and `npm run tauri build`
# always aborts with "failed to decode secret key" while signing the updater
# artifact — after a successful compile and bundle. That burned ~50 min of
# runner time per dependency PR to produce a red check that never carried any
# signal. Granting Dependabot the signing key would fix the symptom but hands
# a release key to workflows triggered by third-party dependency updates.
#
# What still covers Dependabot PRs: the required `rust-tests` job compiles the
# crate (cargo clippy --all-targets + cargo test --lib), so a dependency bump
# that breaks the Rust build is still caught.
# What this gives up on those PRs: bundling (NSIS/AppImage/deb), Windows and
# ARM-specific compilation, and the `cargo audit` step below — that last one
# overlaps with Dependabot's own cargo scanning, which is what opens these PRs
# in the first place.
tauri-build:
name: Tauri Full Build (${{ matrix.os }})
needs: client-check
if: github.event_name == 'pull_request' && github.base_ref == 'main'
if: >-
github.event_name == 'pull_request'
&& github.base_ref == 'main'
&& github.actor != 'dependabot[bot]'
strategy:
fail-fast: false
matrix:
@@ -358,7 +453,7 @@ jobs:
run:
working-directory: Client/tauri-client/
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
@@ -388,37 +483,13 @@ jobs:
components: clippy
- name: Rust cache
uses: swatinem/rust-cache@9d47c6ad4b02e050fd481d890b2ea34778fd09d6 # v2.7.8
uses: swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
with:
workspaces: Client/tauri-client/src-tauri
- name: Install npm dependencies
run: npm ci
- name: Install tauri-typegen
run: cargo install tauri-typegen@0.5.0 --quiet
- name: Generate TypeScript IPC bindings
working-directory: Client/tauri-client/
run: cargo tauri-typegen generate
- name: Fix generated TypeScript bindings (tauri-typegen 0.5.0 workaround)
working-directory: Client/tauri-client/
# tauri-typegen 0.5.0 cannot map serde_json::Value to a TS type — patch post-generation.
# Duplicate events are avoided at source by using one emit() call site per event name.
run: |
node -e "
const fs = require('fs');
const tp = fs.readFileSync('src/generated/types.ts', 'utf8');
if (!tp.includes('export type Value')) {
fs.writeFileSync('src/generated/types.ts', tp.replace(
'export interface CredentialData',
'export type Value = unknown;\n\nexport interface CredentialData'
));
}
console.log('Generated bindings patched.');
"
- name: Clippy lint (Rust)
working-directory: Client/tauri-client/src-tauri/
run: cargo clippy -- -D warnings
+2 -2
View File
@@ -26,13 +26,13 @@ jobs:
actions: read # Required for Claude to read CI results on PRs
steps:
- name: Checkout repository
uses: actions/checkout@v4
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
with:
fetch-depth: 1
- name: Run Claude Code
id: claude
uses: anthropics/claude-code-action@v1
uses: anthropics/claude-code-action@6b082c41935b4c8a3b8b0ef85ba4ba4d9eeb8975 # v1
with:
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
+131
View File
@@ -0,0 +1,131 @@
# Manual WebSocket load baseline against a locally booted server.
#
# workflow_dispatch ONLY, and deliberately not part of the blocking CI matrix:
# a perf run on shared runners is a flake source and a wall-clock tax the
# 10-job/~15-min pipeline doesn't need. Run it before/after changes to the
# hub, the write path, or the replay budget, and compare the uploaded
# k6-summary.json + metrics snapshot between runs. Runner-grade hardware is
# NOT a capacity promise for real deployments — treat results as relative
# (before vs after), not absolute.
name: Load Baseline
on:
workflow_dispatch:
inputs:
users:
description: "Load-test users to register (max VUs in the script is 100)"
required: false
default: "100"
permissions:
contents: read
jobs:
k6-baseline:
name: k6 WebSocket baseline
runs-on: ubuntu-latest
timeout-minutes: 25
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
- uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5.6.0
with:
go-version: "1.26"
cache-dependency-path: Server/go.sum
- name: Build server
working-directory: Server
env:
CGO_ENABLED: "0"
run: go build -o chatserver .
- name: Boot server
working-directory: Server
env:
# Registration/login are per-IP rate limited (3/min and 5/min by
# default) and every VU logs in from 127.0.0.1 — scale the auth
# limits up with the knob that exists for shared-IP scenarios.
OWNCORD_SECURITY_AUTH_RATE_LIMIT_MULTIPLIER: "100"
run: |
mkdir -p "$RUNNER_TEMP/loadtest"
cp chatserver "$RUNNER_TEMP/loadtest/"
cd "$RUNNER_TEMP/loadtest"
./chatserver > server.log 2>&1 &
echo $! > server.pid
for _ in $(seq 1 30); do
sleep 1
if ./chatserver healthcheck; then exit 0; fi
done
echo "::error::server never became healthy"
tail -50 server.log
exit 1
- name: Seed owner, channel, and load-test users
working-directory: Server
run: |
BASE=https://127.0.0.1:8443
TOKEN=$(curl -sk -X POST "$BASE/admin/api/setup" \
-H 'Content-Type: application/json' \
-d '{"username":"loadadmin","password":"LoadTest123!Admin"}' | jq -r .token)
[ -n "$TOKEN" ] && [ "$TOKEN" != "null" ] || { echo "::error::setup failed"; exit 1; }
CHANNEL_ID=$(curl -sk -X POST "$BASE/admin/api/channels" \
-H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
-d '{"name":"loadtest","type":"text"}' | jq -r .id)
[ -n "$CHANNEL_ID" ] && [ "$CHANNEL_ID" != "null" ] || { echo "::error::channel create failed"; exit 1; }
echo "CHANNEL_ID=$CHANNEL_ID" >> "$GITHUB_ENV"
echo "ADMIN_TOKEN=$TOKEN" >> "$GITHUB_ENV"
INVITE=$(curl -sk -X POST "$BASE/api/v1/invites" \
-H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
-d '{"max_uses":0}' | jq -r .code)
[ -n "$INVITE" ] && [ "$INVITE" != "null" ] || { echo "::error::invite create failed"; exit 1; }
USERS="${{ inputs.users }}"
for i in $(seq 1 "${USERS:-100}"); do
code=$(curl -sk -o /dev/null -w '%{http_code}' -X POST "$BASE/api/v1/auth/register" \
-H 'Content-Type: application/json' \
-d "{\"username\":\"loadtest$i\",\"password\":\"LoadTest123!\",\"invite_code\":\"$INVITE\"}")
if [ "$code" != "200" ] && [ "$code" != "201" ]; then
echo "::error::registering loadtest$i failed with $code"
exit 1
fi
done
- name: Install k6
run: |
curl -fsSL https://dl.k6.io/key.gpg | sudo gpg --dearmor -o /usr/share/keyrings/k6-archive-keyring.gpg
echo "deb [signed-by=/usr/share/keyrings/k6-archive-keyring.gpg] https://dl.k6.io/deb stable main" | sudo tee /etc/apt/sources.list.d/k6.list
sudo apt-get update -qq && sudo apt-get install -y k6
- name: Run k6 baseline
working-directory: Server/scripts/k6
env:
K6_WS_URL: wss://127.0.0.1:8443/api/v1/ws
K6_HTTP_URL: https://127.0.0.1:8443
K6_CHANNEL_ID: ${{ env.CHANNEL_ID }}
run: |
mkdir -p reports
k6 run --insecure-skip-tls-verify ws-load.js
- name: Snapshot server metrics
if: always()
run: |
curl -sk https://127.0.0.1:8443/api/v1/metrics | tee "$RUNNER_TEMP/loadtest/metrics-after.json" || true
- name: Stop server
if: always()
run: |
kill "$(cat "$RUNNER_TEMP/loadtest/server.pid")" 2>/dev/null || true
sleep 3
- name: Upload results
if: always()
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: k6-baseline
path: |
Server/scripts/k6/reports/k6-summary.json
${{ runner.temp }}/loadtest/metrics-after.json
${{ runner.temp }}/loadtest/server.log
retention-days: 30
+76 -13
View File
@@ -5,6 +5,15 @@ on:
tags:
- "v*"
# A deleted-and-re-pushed tag (it has happened — see the checksum note in the
# publish job) must not race two publish runs: `gh release create` fails
# loudly on the second run, but the ghcr :latest push does not, and which run
# wins it would be arbitrary. Queue, never cancel — a half-cancelled release
# is worse than a slow one.
concurrency:
group: release-${{ github.ref_name }}
cancel-in-progress: false
jobs:
# The v1.1.0-alpha.4 release shipped clients still versioned 1.1.0-alpha.3
# because the client manifests weren't bumped before tagging — deployed
@@ -16,7 +25,7 @@ jobs:
permissions:
contents: read
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
- name: Compare tag with client manifests
shell: bash
run: |
@@ -41,7 +50,7 @@ jobs:
permissions:
contents: read
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
@@ -53,7 +62,7 @@ jobs:
uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable
- name: Rust cache
uses: swatinem/rust-cache@9d47c6ad4b02e050fd481d890b2ea34778fd09d6 # v2.7.8
uses: swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
with:
workspaces: Client/tauri-client/src-tauri
@@ -93,7 +102,7 @@ jobs:
permissions:
contents: read
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
@@ -120,7 +129,7 @@ jobs:
uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable
- name: Rust cache
uses: swatinem/rust-cache@9d47c6ad4b02e050fd481d890b2ea34778fd09d6 # v2.7.8
uses: swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
with:
workspaces: Client/tauri-client/src-tauri
@@ -199,9 +208,9 @@ jobs:
permissions:
contents: read
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
- uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 # v5.5.0
- uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5.6.0
with:
go-version: "1.26"
@@ -223,6 +232,40 @@ jobs:
CGO_ENABLED: "0"
run: go build -o chatserver -ldflags "-s -w -X main.version=$VERSION" .
# Boot-smoke the EXACT artifact that ships: this feed drives the signed
# self-update, so a binary that compiles but dies on boot would deploy
# itself to every auto-updating instance. CI's tests exercise the same
# commit but never this build (release ldflags, CGO_ENABLED=0) and never
# execute the produced binary. First run writes config.yaml, generates a
# self-signed cert, migrates a fresh SQLite DB — a real cold boot.
- name: Boot-smoke server binary
shell: bash
working-directory: Server
run: |
SMOKE_DIR="$RUNNER_TEMP/owncord-smoke"
mkdir -p "$SMOKE_DIR"
cd "$SMOKE_DIR"
BIN="$GITHUB_WORKSPACE/Server/chatserver"
[ -f "$GITHUB_WORKSPACE/Server/chatserver.exe" ] && BIN="$GITHUB_WORKSPACE/Server/chatserver.exe"
"$BIN" &
SERVER_PID=$!
ok=0
for _ in $(seq 1 30); do
sleep 1
if ! kill -0 "$SERVER_PID" 2>/dev/null; then
echo "::error::server process exited during boot smoke"
exit 1
fi
if "$BIN" healthcheck; then ok=1; break; fi
done
kill "$SERVER_PID" 2>/dev/null || true
wait "$SERVER_PID" 2>/dev/null || true
if [ "$ok" != "1" ]; then
echo "::error::server never reported healthy within 30s"
exit 1
fi
echo "boot smoke passed"
- name: Create tar.gz (Linux)
if: matrix.os == 'ubuntu-latest'
working-directory: Server
@@ -249,7 +292,7 @@ jobs:
permissions:
contents: read
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
@@ -276,7 +319,7 @@ jobs:
uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable
- name: Rust cache
uses: swatinem/rust-cache@9d47c6ad4b02e050fd481d890b2ea34778fd09d6 # v2.7.8
uses: swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
with:
workspaces: Client/tauri-client/src-tauri
@@ -350,7 +393,7 @@ jobs:
contents: read
packages: write
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
- name: Extract version from tag
shell: bash
@@ -359,7 +402,7 @@ jobs:
echo "VERSION=$VERSION" >> "$GITHUB_ENV"
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@b5ca514318bd6ebac0fb2aedd5d36ec1b5c232a2 # v3.10.0
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
- name: Log in to GitHub Container Registry
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
@@ -378,8 +421,28 @@ jobs:
type=semver,pattern={{major}}.{{minor}}
type=raw,value=latest
# Build locally first so the image can be boot-smoked BEFORE anything
# is pushed — a pushed :latest that dies on boot deploys itself to every
# `docker compose pull` upgrade.
- name: Build image (local, for smoke test)
uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2
with:
context: Server/
load: true
build-args: VERSION=${{ env.VERSION }}
tags: owncord-smoke:candidate
cache-from: type=gha
cache-to: type=gha,mode=max
# Shared with ci.yml's docker-build job so the smoke itself is exercised
# on every PR to main — the first alpha.3 release run died here on a
# smoke-harness bug (bare `docker run`, nowhere writable for the
# default config) that no pre-merge check had ever run.
- name: Boot-smoke Docker image
run: bash Server/scripts/docker-smoke.sh owncord-smoke:candidate
- name: Build and push
uses: docker/build-push-action@14487ce63c7a62a4a324b0bfb37086795e31c6c1 # v6.16.0
uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2
with:
context: Server/
push: true
@@ -396,7 +459,7 @@ jobs:
permissions:
contents: write
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
+13 -5
View File
@@ -2,11 +2,13 @@
.env
Server/.env
# Claude Code — local-only, never committed. A slashless pattern matches at any
# depth, so these also cover Server/CLAUDE.md, Client/**/CLAUDE.md and nested
# .claude/ dirs.
.claude/
CLAUDE.md
# Claude Code — local-only by default. The exceptions are committed on purpose:
# a cloud session clones this repo and sees ONLY tracked files, so the CLAUDE.md
# files, skills and workflows have to be here or it starts with no instructions.
# Machine-local state (settings.local.json, locks) stays ignored.
.claude/*
!.claude/skills/
!.claude/workflows/
CLAUDE.local.md
.mcp.json
@@ -25,6 +27,11 @@ docs/research/
docs/superpowers/
/skills/
# Mutation-testing output (npm run test:mutate). Local-only by design: a
# surviving-mutant report maps exactly which behaviour nothing tests.
Client/tauri-client/.stryker-tmp/
Client/tauri-client/reports/
# Server runtime artifacts
Server/chatserver.exe
Server/chatserver.exe~
@@ -91,3 +98,4 @@ Client/tauri-client/.env
# local server run logs
server.log
graphify-out/
+429 -3
View File
@@ -5,6 +5,432 @@ tooling (`npm run changelog`) auto-generates entries from commit messages
on each release; this file is the curated counterpart that calls out
behavioural changes operators must know about.
## v1.2.0-alpha.3
- **fix:** eight bug-hunt batches closed **199 verified defects** since
`v1.2.0-alpha.2` — 30 in #1366/#1367, 110 in #1369#1372, 34 in #1374 and
25 in #1375 — each fixed test-first with the failing assertion watched red
against the unpatched code. The behavioural consequences worth knowing
about are listed below; the rest are one-line correctness fixes with no
operator-visible change.
- **security(client): voice E2EE was never actually enabled** (#1370). The
full ECDH/HKDF/AES-GCM key exchange completed, the room key was set, and
the UI showed 🔒 Secured — but `createRoom` never called
`room.setE2EEEnabled(true)`, so every audio and video frame reached the
SFU in plaintext. It is enabled now, and a dead E2EE worker is no longer
invisible to the Secured badge. Related voice-crypto fixes: a joining key
holder sent its room-key offers *before* its own announce, so existing
participants dropped them as "unknown peer" (#1370, #1374); rotation
offers exceeded the server rate limit in large channels and permanently
starved the same peers; both rotation paths and the reconnect-to-Secured
path now carry session-generation guards; a departing peer's ephemeral
key is retired on leave so a replayed pre-leave announce cannot overwrite
the fresh key they rejoined with (#1372, #1374). The client also refreshed
its LiveKit token every 23 hours while the server mints it with a 5-minute
TTL, so auto-reconnect failed for any voice session older than five
minutes (#1370).
- **security(server):** access-control holes (#1369#1372, #1374, #1375) —
`voice_join` into a 1:1 DM had no block gate, so a blocked user could
enter the blocker's DM voice room; the attachment-serve admin bypass let
an ADMINISTRATOR download files from private DMs they were not in; the
archived-channel read-only gate covered `SendMessage` only, so edit,
reaction, pin, purge, delete and `channel_focus` still mutated or
subscribed to archived channels (every write sink now routes through one
`requireChannelWritable` gate); `EditMessage` and `handleReaction` DM
detection failed *open* on a `GetChannel` error, skipping the block gate;
group-DM creation only block-checked the creator, letting a third party
force two users who blocked each other into a shared room; an invisible
user's real custom status leaked on both presence emitters; `PATCH
/users/{id}` with `banned` + `role_id` committed and broadcast the ban
before authorizing the role change; admin API-token creation accepted a
negative `expires_hours` and minted a token that never expires; upload
rejections echoed raw storage errors (absolute server paths) to any
authenticated user; the GIF proxy's log redaction missed the
percent-encoded API key; `chat_command` was the only client message type
without a rate limiter while each frame ran a WASM plugin invocation; and
the login and typing rate limiters built their keys from *unvalidated*
input, letting an unauthenticated caller pin unbounded heap for six hours.
- **fix(auth):** accounts whose username contains `'`, `"` or `&` were
permanently unloggable — registration HTML-escaped the name but login did
not — and a profile rename to such a name locked the user out (#1370). If
you had users hit this, they can log in again with no action on your side.
Also: message search returned 500 for any query containing a hyphen (the
one FTS5 operator the sanitizer allowlisted); usernames with an uppercase
non-ASCII letter could never be @mentioned; registration recorded the
reverse-proxy address as the session IP.
- **server:** WS hub, reconnect and replay (#1369, #1371, #1372, #1374,
#1375) — REST DM events never bumped the visibility watermark, while
*every* ordinary DM message re-emitted `dm_channel_open` and bumped the
global watermark, forcing every other client's next reconnect into a full
resync; the client's `lastSeq` was never reset by a full-ready resync and
desynced permanently; cold-tier replay had no interior-gap detection, so
events the persister dropped were skipped and presented as a complete
resume; `buildReady` swallowed three DB errors and shipped an
authoritative-looking empty snapshot (the client wiped its DM list, member
list and unread badges) and dropped the user's own live voice room when
not READ-visible; `channel_focus` could re-subscribe after a concurrent
visibility revoke, and role demotion's live-subscription revocation was
gated on a cosmetic role re-read; a failed reconnect handshake ran the
full disconnect teardown twice; presence events from every source now
share one ordered per-client FIFO.
- **server:** voice lifecycle (#1369, #1371, #1374) — a stale join's
rollback deleted `voice_states` by user id alone, destroying a concurrent
newer membership; deleting a voice channel raced a concurrent `voice_join`
into a permanent hub/SFU ghost no sweep could heal; the stale-state sweep
could delete a just-committed join's row, leaving the client in voice with
no DB row; `handleVoiceJoin` handed out a live 5-minute LiveKit credential
*after* a concurrent kick/move/revocation had already torn the membership
down (the token is now withheld); the `participant_left` webhook never
told the leaver, and a transient DB read error on `participant_joined`
ejected a legitimate participant mid-call; `voice_mod_move` lacked the
archived-channel gate; `CleanupVoiceForChannel` resolved an empty
`voice_leave` audience because both callers archive first. Camera and
screenshare now draw from the same per-channel `voice_max_video` budget —
screenshare had no cap check at all, and the camera gate did not count
screensharing occupants.
- **server:** DM and message fan-out (#1369, #1371, #1372, #1375) — a DM
send, edit, delete or reaction survived a transient participant lookup
failure by silently dropping live fan-out to everyone including the
sender; emoji create/delete and group-DM creation tied their broadcasts to
the request context, so an aborted request committed the mutation and
skipped the event; slow mode consumed its cooldown token before content
validation, so a rejected send locked the composer for the full window; an
attachment-metadata read failure broadcast the message with no
attachments; `GET /channels/{id}/pins` had no LIMIT and failed permanently
past ~32k pins; pinning a soft-deleted message returned 500;
`LinkAttachmentsToMessage` no longer claims a user's live avatar as a
message attachment; `PATCH /channels/{id}` now rejects a blank name.
- **server:** admin and plugins (#1369, #1370, #1372, #1375) — "Restore
backup" wrote to a hardcoded `data/chatserver.db`, so it silently no-oped
on any server with a configured `database.path`; the WAF inline engine
rejected every request body ≥ 1 MiB, breaking plugin install and large
avatar uploads when `waf_enabled` was on; self-account-deletion emitted no
`member_ban`, so every other client kept the deleted user; the admin live
log stream blanked every error attribute to `{}`; `CheckForUpdate` had no
in-flight dedupe and stampeded GitHub on cache expiry; a failed self-update
swap left every client counting down to a restart that never came (a
corrective `update_aborted` is now broadcast, and deferred cleanup runs
before the restart exits — on Windows that file-handle release is the
reason the restart exists). Plugin enable/re-install left
`plugins.enabled = 1` while the runtime instance was deactivated, and
uninstall reported success while the on-disk directory survived and
resurrected the plugin on the next start.
- **fix(client):** voice reliability (#1366, #1367, #1370#1372, #1374,
#1375) — a failed voice channel-switch left the user live in the call
(mic hot, audio flowing) with the voice UI hidden and no way to leave;
selecting the "Default" microphone (or losing the pinned one to a hot
unplug) never changed the capture device; a camera or screenshare disable
that completed while the enable's `publishTrack` was in flight left the
server and every peer believing it was on (`leaveVoice` and reconnect
teardown now bump the same generation guard); a `VIDEO_LIMIT` rollback
assumed the camera and tore down a working camera while leaving refused
screen tracks published — it now correlates by envelope id; auto-idle's
return-to-online `presence_update` was always swallowed by the 1-per-10s
limiter, so every user showed Idle to everyone else after their first idle
period; connection-quality degradation was never reported; a group-DM
decline silenced every other participant's ring and never reached the
caller.
- **fix(client):** messaging and stores (#1366, #1367, #1369, #1372) —
re-opening a channel visited earlier in the session rendered a permanently
stale window (live broadcasts only cover the focused channel; the tail is
now refetched); the virtual scroll window never followed the scroll
position, so rows past the initial overscan rendered as blank space; a
scroll-up page past the 500-row cap deleted the user's pending/failed
rows, the only copy of their composed text; the scroll-to-bottom button
and "Jump to Present" pill scrolled out of view exactly when they became
visible; a user named exactly "System" had every message rendered as a
server notice with no moderation controls; DM permalinks failed until the
DM had been opened once; the reaction picker dropped the server's custom
emoji; Ctrl+K was dead with CapsLock on; the composer's slow-mode cooldown
was applied to whichever channel was mounted, not the one that sent.
- **fix(client):** settings, session and platform (#1367, #1370#1372,
#1375) — the built-in light theme overrode only 4 of ~45 tokens (composer
and inputs near-invisible), the Font Size slider and High Contrast toggle
were no-ops, and the tray Status menu bypassed the client's own status
state so a tray-set Do Not Disturb silenced nothing; a failed TOTP verify
tore down the overlay so the code could not be re-entered; channel
create/edit/delete modals locked up permanently on an API failure; login
to an IPv6-literal host was impossible; a host stored with an explicit
`:443` lost its bearer token and cert-pinned proxy on attachment fetches;
one malformed stored server profile discarded *all* saved profiles; a
banned/revoked token reconnected forever if the session ended before
MainPage mounted; a previous server's block list, collapsed categories and
DM notes bled into the next server; the Rust HTTP proxy tunnel's data
phase had no deadline, so a remote that completed TLS then went silent
parked the connection forever (bounded at 600s — loose on purpose, this
path carries uploads); the autostart toggle raced its own write.
- **infra:** observability, backups, guardrails and deployment hardening
(#1376). **`/health` now returns a real verdict** — hub dispatch-loop
liveness, a bounded DB ping and a free-disk check, answering **503 with a
subsystem reason** (`hub`, `database`, `disk`) when degraded; results are
cached so the unauthenticated endpoint cannot amplify load. Point uptime
monitors at it and treat any 503 as actionable. **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 — if
you run the bare binary without a supervisor, use the new hardened
`deploy/owncord.service` systemd unit (see "Running as a Linux Service").
**Backups now actually run:** `backup_schedule` and `backup_retention`
had existed in the admin panel since the initial schema but were never
read by any code; the 15-minute maintenance loop now enforces them,
verifies each backup with `PRAGMA integrity_check` (and again before a
restore may overwrite the live DB), and prunes by age keeping the newest.
Expect backup files to start appearing and pruning for the first time.
`/api/v1/metrics` gains reconnect-tier, backpressure, DB-writer-wait,
permission-cache, `ws_conn_rejects` and `disk_free_mb` signals, and the
declared-but-never-recorded OTel instruments are wired. Upload storage
failures return **507** instead of blaming the client with a 400. A
single-process lock beside the SQLite file makes a second server process
fail fast instead of silently fighting the first. **Unknown config keys
now warn at startup** (a typo previously kept the default silently), and
startup warns when `admin_allowed_cidrs` is customized while
`trusted_proxies` is empty. Shutdown now joins the pruner and maintenance
loop before the DB closes, drains HTTP handlers into a live hub, and skips
the 5s client-notice window when nobody is connected. Write-path work:
no-op read-state UPSERTs are skipped, boot-time `ANALYZE` runs only when a
migration applied, role-scoped override changes evict only that role's
members from the permission cache, and connect/disconnect presence passes
through a 300ms latest-wins coalescer (wire format and seq ordering
unchanged).
- **config:** new keys, all defaulting to current behaviour (#1376) —
`server.max_ws_connections` (0 = unlimited; over the cap answers 503 +
Retry-After), `server.metrics_allowed_cidrs` and
`server.livekit_webhook_allowed_cidrs` (both fall back to
`admin_allowed_cidrs`, so a central Prometheus scraper or an
externally-hosted LiveKit no longer requires widening the admin
perimeter), `database.max_readers` (0 = auto), `backup.dir`
(`data/backups`), `security.auth_rate_limit_multiplier` (1.0; raise for
shared-NAT communities), `event_persistence.replay_ring_size` (1000) and
`event_persistence.replay_cold_limit` (5000 — watch `reconnect_tier_full`
before raising). Three stored-but-inert admin settings (`server_icon`,
`max_upload_bytes`, `voice_quality`) are now shown read-only with a
pointer at the real `config.yaml` keys instead of pretending to apply.
Documented in `docs/server-configuration.md`.
- **deploy:** new `chatserver healthcheck` subcommand probes `/health`
pinning the server's own certificate from disk (WebPKI when none exists,
i.e. ACME) and is now the docker-compose healthcheck — the distroless
image has no shell; plain `docker compose` only *surfaces* unhealthy, pair
it with a watchdog for auto-restart. Compose gains json-file log rotation
(`10m` × 3) on both services. `release.yml` now cold-boots the freshly
built server binaries and Docker image and probes them healthy **before
anything is signed or pushed** — the release feed drives signed
self-updates, so a binary that compiled but died on boot would previously
have shipped itself to every auto-updating instance. New "Reverse Proxy
Topology" docs section (nginx snippet; only WebRTC media ports need to be
directly reachable, `/livekit/*` is already proxied). Release binaries
are built with Go 1.26.6 (stdlib CVE fixes flagged by govulncheck).
- **migrations:** **031** normalizes legacy `sessions.expires_at` values to
RFC3339-UTC and adds `idx_sessions_expires_at`, so the 15-minute expired-
session sweep is an index lookup instead of a full-table scan on the
writer. Applies automatically on first start; no operator action needed.
- **protocol:** no wire changes — `docs/protocol-schema.json`,
`message_types.go` and `protocolTypes.ts` are byte-identical to
`v1.2.0-alpha.2`. Older clients and servers interoperate unchanged.
- **fix(ws):** the LiveKit health check shared the process-wide
`http.DefaultTransport` pool with every other user in the server; it now
owns a private transport (#1356).
- **chore:** bug-hunt tooling under `.claude/` (fix pipeline, findings
ledger, circuit breaker, single-finder hunt with graph-fed targeting —
#1361#1365, #1373); dependency bumps (OTel 1.45.0, koanf, sqlite,
eslint/oxlint/knip/typescript-eslint, tauri-plugin-updater, GitHub
Actions; #1353#1360). No runtime impact.
## v1.2.0-alpha.2
- **feat(client):** the login form has an **Auto connect** checkbox under
Remember password. Ticking it makes that server connect automatically on
launch — the same setting as the auto-login button on a server card, so
the two stay in sync, and as before only one server can be auto-connect
at a time.
Ticking it also forces Remember password on and locks it: auto-connect
replays the stored token, which is only written when the password is
remembered, so the two cannot be set independently without producing a
setting that silently does nothing.
- **fix(client):** Remember password works again. The password was saved to
the OS keyring but never returned to the client over IPC, so the login
form could not prefill it — the box appeared to work and did nothing.
- **fix:** three bug-hunt sweeps closed **233 verified defects** since
`v1.2.0-alpha.1` — 26 in #1328, 107 in #1331, 100 in #1332 — each fixed
test-first, with the failing assertion watched red against the unpatched
code before the patch landed. The behavioural consequences worth knowing
about are listed in the nine entries below.
- **server:** WS hub reconnect and replay hardening (#1328, #1331).
Cold-tier replay used to truncate silently instead of forcing a full
ready, and a retention-pruned event log was accepted outright as a
complete resume — the highest-impact fix in #1331, since any client whose
reconnect gap crossed the 24h retention default was permanently desynced.
Resume also silently dropped the focused channel's topic subscription,
stopping message delivery until the user manually switched channels; it
is now restored during the handshake. `visibilityChangeSeq` can now only
move forward across its three writers — it previously could regress and
skip a required resync.
- **server:** voice/E2EE key-holder election and audience gating (#1328,
#1331) — three key-holder desync bugs (no client demotion path, peer keys
cleared on reconnect, missing re-election on the webhook and
fresh-reconnect paths), plus re-election wired into the sweep and
channel-cleanup paths. Voice events were READ-filtered while membership
is CONNECT-only, so participants in that gap silently missed
`voice_leave`, stalling key-holder election and forward-secrecy rotation.
Deleting a channel now evicts its voice participants first — the cleanup
function existed but had zero production callers, so the FK cascade used
to strand them silently. Moderator mute/deafen now survives a
voice-channel switch; joins to non-voice channels are rejected; archived
channels are read-only and unjoinable.
- **security(server):** roles/permissions (#1328, #1331) — `UpdateRole`
allowed position collisions that `CreateRole` already rejected, so tied
positions could read as equal rank in every hierarchy comparison; it now
matches `CreateRole`'s validation. `can_send` is now recomputed per client
on every role/override change, so a permission change takes effect for
connected clients immediately rather than waiting on a reconnect.
- **server:** attachments and admin data-safety (#1331) — migration **030**
unlinks attachments on message delete instead of cascading, so a cascaded
channel/DM delete no longer strands uploaded files on disk with no
reclamation path. The 15-minute orphan-attachment sweep was deleting every
avatar in the instance (avatars are, by design, attachments with no
message link) on its first tick past the grace period, permanently 404ing
every profile picture; a second bug in the same sweep collapsed the
one-hour grace period to effectively zero, from a TEXT-comparison mismatch
between an RFC3339 cutoff and SQLite's own timestamp format. A failed
backup restore used to truncate the live database to zero bytes with no
rollback, while the server kept answering requests against the now-closed
DB and falsely claimed a restart was underway — it now restores the
pre-restore safety copy on failure and requests the restart honestly.
Also fixed: personal data is cleared on account deletion, banned users are
excluded from owner lookup, the silent 1000-member roster cap is gone, and
a sender's own read state now advances on send. Migration applies
automatically on first start; no operator action needed.
- **protocol:** a new READ-gated `active_channel_id` auth field (#1331)
restores the focused-channel subscription during the reconnect handshake
itself, closing the window before the post-`auth_ok` `channel_focus` round
trip lands. `protocol.md` also corrects the presence table, which had
incorrectly documented all presence events as sequenced. Older
clients/servers are unaffected — it is a new, ignorable field.
- **security(client):** identity/TOFU and transport (#1332) — an in-flight
change to scope the identity keypair by host *and* user id would have
re-minted a fresh key on every existing install, firing the TOFU "verify
out-of-band" re-pin warning at the entire alpha population simultaneously,
exactly the pattern that teaches users to click through the one warning
meant to matter. The legacy host-only key is now adopted into the scoped
name instead, saving before deleting so a partial failure cannot strand a
user with neither key. Switching hosts carried the previous server's
bearer token forward into the next login request; `api.setConfig` now
drops it when the host changes without a replacement. A hand-copied,
un-lowercased host normalizer in `main.ts` meant an uppercase hostname's
cert-mismatch *reject* path skipped `disconnect()`/`clearAuth()`, leaving
a user who refused a changed certificate still connected to that server —
the single lowercased implementation in `ws.ts` is now shared everywhere.
- **fix(client):** voice mic/camera reliability (#1331, #1332) — six
separate paths could republish the microphone without checking the user's
mute state (the audio-device fallback, selecting "Default" input,
un-deafening, `retryMicPermission`, a stale PTT ownership latch, and
auto-reconnect's `restoreLocalVoiceState`), each producing a hot mic while
every remote UI still showed the user muted; all now route through
`isMicPolicyGated()`. Camera and screenshare kept publishing to the SFU
after the user turned them off during the OS device picker. Enhanced Noise
Suppression silently disabled the input-volume slider and VAD gate because
`livekit-client`'s own `replaceTrack` call landed after ours. A key-holder
promotion arriving mid voice-setup was clobbered, ejecting the joiner
after a timeout only it could have resolved.
- **fix(client):** messaging and store reliability (#1328, #1331, #1332) —
sequenced DMs could jump the FIFO ahead of `sendHigh`, permanently losing
an event dropped before flush. A full-ready resync left every loaded
channel with a permanent hole in its history, because that tier never
replays `chat_message` frames; loaded windows are now invalidated and the
active channel refetched. The WS error handler only bannered
`RATE_LIMITED` and `FORBIDDEN`, so every other server error code — for
example a rejected `chat_edit` — was dropped in silence while the
optimistic "Message edited" toast still fired. A message whose
`chat_send_ok` was lost to the same disconnect that forced a resync could
render twice; the optimistic row's id-based dedup now shares the
content-based match predicate `addMessage` already used. Replay detection
compared the server's `created_at` against the client's own clock, so a
self-hosted server without NTP made every live message after a reconnect
look like a replay and silently killed its notification; both sides now
use an estimated server-time skew.
- **fix(client):** UI defects (#1331, #1332) — the quick-switcher could
mount a second overlay, orphaning a body-mounted backdrop that blocked all
input until reload. The status-picker stylesheet targeted a root element
the component never toggles; a same-branch repair then left the status dot
itself 0×0 and unclickable, now fixed together with a test pinning the
stylesheet to the classes the component actually emits. The attachment
remove button and the failed-send Retry/Discard buttons did nothing;
drag-reorder's phantom-drag latch and permission gate are fixed; keyboard
Tab could escape every modal because hidden (`display: none`) controls
were still counted as focusable.
- **fix(client):** the user profile popup is styled correctly again
(`a308f81`).
- **fix(client):** Vite no longer watches `src-tauri/`, so a running dev
server does not rebuild the frontend when Rust sources or build artifacts
change (`cdcfc03`).
- **fix(release):** the stripped Linux AppImage is signed from the
environment-provided key instead of a temporary key file (`9d75890`) —
release-pipeline only, no operator action needed.
- **docs:** full documentation audit against `5630aa1` — reference docs,
architecture pages, and UX specs corrected; plans and prior audits given
verified statuses; see `docs/audit-2026-08-04-docs-and-coverage.md`.
- **security(server):** closed the three 2026-08-04 review findings — the
channel role-override **DELETE** now enforces the same hierarchy guard as
PUT (A-2026-08-01); the admin channel list/edit/delete surface no longer
sees DM channels, answering 404 for their ids (A-2026-08-02); DM call
rings respect blocks like every other DM interaction (A-2026-08-03).
Behavioural note: deleting a channel override for a *nonexistent* role now
returns 404 (was 204), matching PUT.
- **server:** migration **029** drops the never-used `sounds` table (dead
since the initial schema; A-2026-07-13). Applies automatically on first
start; no operator action.
- **protocol:** the plugin command family (`chat_command`, `command_reply`,
`plugin_broadcast`) is now part of `protocol-schema.json` and the
generated constants (27 client→server / 39 server→client). Wire strings
are unchanged — no client or plugin impact.
- **chore(client):** dead modules deleted (`ServerStrip`, `FileUpload`,
`reconcile`, a stray worklet copy, orphan sounds API methods) and the
unused tauri-typegen pipeline retired (`src/generated/**`, its CI steps,
config block, and build-dependency).
- **ci:** knip is now blocking; Playwright specs are typechecked
(`typecheck:e2e`); three orphaned native e2e specs run again;
`claude.yml` actions are SHA-pinned; the PR template asks for docs
updates per the architecture maintenance rule.
- **tests(client):** the TOFU certificate ceremony has e2e coverage
(first-use + mismatch journeys), and `modalFactory` is fully covered.
- **security(client):** the voice-E2EE identity pin lookup fails **closed**
on keyring errors (DC-08): a transient store failure used to read as
"never pinned", silently sending a pinned peer down the first-sight path
and re-pinning whatever key the server delivered. An unreadable pin store
now rejects the peer's announce, writes nothing, and shows a distinct
amber "could not check" badge until the store recovers.
- **feat(client):** accessibility pass over the modal/overlay stack
(DC-13): every modal is a labelled `role="dialog"` with a focus trap and
focus restore, Escape maps to each dialog's safe action, the settings
sidebar is a keyboard-navigable tablist, the quick switcher and composer
autocompletes are wired as combobox/listbox, the emoji/GIF pickers are
keyboard-operable, and toasts/typing announce via polite live regions.
- **feat(client):** UX polish (DC-12): deleting the active channel now
says so in a toast; reactions toggle optimistically with rollback on
failure; the role-change menu can no longer double-fire; a document-level
listener leak in channel drag-reorder is fixed.
- **feat(admin):** restoring a backup now writes a `backup_restore`
audit-log row (DC-09). The row is written before the pre-restore safety
copy, so it lives inside the `pre_restore_*.db` backup — the restored
database itself cannot carry it (the restore replaces the file).
- **ci:** the `-tags wazero` / `-tags otel` Go tests now actually run in CI
(DC-06) — previously those variants were only compiled, leaving ~600
lines of plugin/telemetry tests permanently dark.
- **tests(client):** e2e journeys for voice-E2EE identity verification
(badge states + mismatch modal, driven through the real crypto path) and
the updater (banner → progress → auto-relaunch), plus an accessibility
smoke; full web suite now 291 tests.
- **server/admin:** in-place self-update is refused in container
deployments (503 `CONTAINER_DEPLOYMENT`; the shipped image sets
`OWNCORD_CONTAINER=1`, bind-mount operators can set `0` to opt back in).
Container upgrades are image pulls; `GET /admin/api/updates` now reports
`can_apply` and the admin panel says so instead of offering the button.
- **ci:** the full client e2e suite now blocks merges (DC-07); a new
non-blocking `admin-e2e` job drives the admin panel against a real server
(first-run wizard, channel CRUD, audit log, re-login).
- **docs:** the dependency pinning/review policy is written down in
`docs/contributing.md`, closing the last 2026-04 audit carryover that was
still undecided.
## v1.2.0-alpha.1 — Discord feature parity
> **Project reset note:** OwnCord has re-entered alpha. The `v1.0.0` release is
@@ -256,6 +682,6 @@ claimed behaviour — no product code changed and no assertion weakened.
The project is under a feature freeze until the beta reset completes.
Explicitly deferred (not abandoned unless noted): real OpenTelemetry SDK
wiring, the Postgres backend (scaffolding removed pending real demand),
the slash-command dispatcher (`docs/plans/slash-commands.md`), and the
Solid.js migration (abandoned — the experiment is being removed in favor
of the established vanilla component pattern).
and the slash-command dispatcher (`docs/plans/slash-commands.md`). The
Solid.js migration was abandoned and its experiment fully removed
(2026-07-19) in favor of the established vanilla component pattern.
+30
View File
@@ -0,0 +1,30 @@
# OwnCord
Self-hosted chat platform (alpha). `Server/` is a Go 1.26 REST + WebSocket
server over SQLite with LiveKit voice/video; `Client/tauri-client/` is a Tauri
v2 desktop app (TypeScript frontend, thin Rust backend). Per-component detail
lives in `Server/CLAUDE.md` and `Client/tauri-client/CLAUDE.md`; the protocol
and schema are documented in `docs/protocol.md`, `docs/schema.md`, and
`docs/architecture/README.md`.
## Generated code — never hand-edit
CI fails on drift, and the next generator run silently discards your edit.
| Generated | Source of truth | Workflow |
| --- | --- | --- |
| `Server/db/dbgen/` | `Server/db/queries/*.sql`, `Server/migrations/` | `db-change` skill |
| `Server/ws/message_types.go` **and** `Client/tauri-client/src/lib/protocolTypes.ts` | `docs/protocol-schema.json` | `protocol-change` skill |
| `Client/tauri-client/src/generated/` | `tauri-typegen` | CI patches known typegen bugs — see `.github/workflows/ci.yml` |
## Gotchas
- **Verify with the `ci-check` skill**, not with an ad-hoc `go build && go test`.
CI compiles four Go build-tag variants and runs a deadlock-detection pass;
the default build proves nothing about the tagged ones.
- **The client unit suite is green and must stay green.** Never make a failing
test pass by weakening its assertions.
- Security issues go through GitHub Security Advisories, never public issues
(`docs/security.md`). This repo is public — unfixed defects do not belong in
commits, issues, or PR descriptions.
- Branch from `main`, PR to `main`, squash merge, conventional commit subjects.
+1
View File
@@ -0,0 +1 @@
20
+38
View File
@@ -0,0 +1,38 @@
# OwnCord Client (Tauri v2)
TypeScript frontend (Vite, vanilla TS — no React/Vue) plus a deliberately thin
Rust backend in `src-tauri/` for native APIs only. LiveKit handles voice/video.
## Layout
- `src/stores/` observable stores · `src/lib/` protocol, WS, voice, E2EE ·
`src/pages/`, `src/components/` UI
- `src/lib/protocolTypes.ts` and `src/generated/` are generated — see the root
CLAUDE.md
- `tests/unit`, `tests/integration` (vitest, jsdom) · `tests/e2e` (Playwright) ·
`tests/browser` (vitest browser mode)
## Gotchas
- **On Node 22+ you must run `NODE_OPTIONS=--no-experimental-webstorage npm test`.**
Native Web Storage shadows jsdom's `localStorage` and fails ~478 tests that
have nothing to do with your change. That is a local toolchain artifact, not
a regression — do not "fix" those failures. CI pins Node 20.
- `src/lib/dispatcher.ts` is the single WS-event entry point **into the
stores**: server events reach domain stores only through a `ws.on(...)`
subscription registered there. Other modules do register their own
`ws.on(...)` handlers for page-local UI (`main.ts`, `MainPage.ts`,
`ChannelController.ts` — ringing, overlays, slow-mode timers); that is fine
as long as they only *read* store state. Writing a store from one of those
handlers is the violation, and `local/no-store-write-in-ws-on` now fails the
build on it.
- Voice sessions are superseded, not cancelled. `LiveKitSession` re-entry
points check whether a newer attempt owns the shared state before tearing
anything down, so cleanup in an aborted path must be scoped to that attempt's
own room — a global `leaveVoice()` there kills the live session.
- Voice E2EE is key-holder based with TOFU identity pinning. Anything touching
`livekitE2EE.ts` or `identity.ts` must preserve the epoch/keypair staleness
guards and must never report an unverified peer as verified.
- Do not run `npm run tauri build` locally; the desktop build is CI-only.
- Formatting is prettier-enforced; match the surrounding code rather than
reasoning about style.
+408
View File
@@ -0,0 +1,408 @@
// Custom ESLint rules that turn three of the invariants documented in prose in
// CLAUDE.md into enforced, test-covered lint rules. Each rule is scoped (via
// `files:` in eslint.config.js) to only the module(s) its invariant governs —
// see the per-rule `meta.docs.description` for the invariant it encodes and
// tests/unit/eslint-rules.test.ts for the real-code shapes it was proven
// against (both the shapes that must stay clean and the historical bug shapes
// it must catch).
//
// Plain JS, ESM, no build step — eslint.config.js imports this directly.
/** True when `node` is a `this.<methodName>(...)` call. */
function isThisMethodCall(node, methodName) {
return (
node !== null &&
node.type === "CallExpression" &&
node.callee.type === "MemberExpression" &&
node.callee.object.type === "ThisExpression" &&
!node.callee.computed &&
node.callee.property.type === "Identifier" &&
node.callee.property.name === methodName
);
}
/** True when `node` is a `this.<propertyName>` member access. */
function isThisMember(node, propertyName) {
return (
node !== null &&
node.type === "MemberExpression" &&
node.object.type === "ThisExpression" &&
!node.computed &&
node.property.type === "Identifier" &&
node.property.name === propertyName
);
}
function isFunctionNode(node) {
return (
node.type === "FunctionDeclaration" ||
node.type === "FunctionExpression" ||
node.type === "ArrowFunctionExpression"
);
}
// ─────────────────────────────────────────────────────────────────────────
// Rule: no-leave-voice-when-superseded
//
// Invariant (CLAUDE.md): "Voice sessions are superseded, not cancelled.
// LiveKitSession re-entry points check whether a newer attempt owns the
// shared state before tearing anything down, so cleanup in an aborted path
// must be scoped to that attempt's own room — a global leaveVoice() there
// kills the live session."
//
// livekitSession.ts encodes "this attempt was superseded" with exactly two
// guard predicates, always used the same way: `this.reconnectSuperseded(...)`
// (true = superseded) and `!this.isStateConnected(...)` (negated = true when
// superseded). Once either guard has confirmed supersession, the historical
// bug (see the fix that introduced disconnectSupersededLocalRoom /
// generation-guarded leaveVoice calls) was calling the global
// `this.leaveVoice()` inside that same branch, tearing down whichever session
// currently owns the shared state — which, once superseded, is a newer
// attempt's live session, not this one.
// ─────────────────────────────────────────────────────────────────────────
/** True when `test` (walking through &&/||) asserts "this attempt IS
* superseded" via one of the two named guards used throughout the file. */
function testSignalsSuperseded(test) {
if (test === null) return false;
if (test.type === "LogicalExpression") {
return testSignalsSuperseded(test.left) || testSignalsSuperseded(test.right);
}
if (isThisMethodCall(test, "reconnectSuperseded")) return true;
if (test.type === "UnaryExpression" && test.operator === "!") {
return isThisMethodCall(test.argument, "isStateConnected");
}
return false;
}
const noLeaveVoiceWhenSuperseded = {
meta: {
type: "problem",
docs: {
description:
"Disallow this.leaveVoice() inside a branch that already confirmed this connect/reconnect " +
"attempt was superseded. Voice sessions are superseded, not cancelled — once reconnectSuperseded() " +
"or !isStateConnected() is true, `_state` may already belong to a newer, live attempt, and " +
"leaveVoice() there tears that live session down instead of the aborted one.",
},
schema: [],
messages: {
unsafeLeaveVoice:
"this.leaveVoice() must not run once this attempt is known to be superseded — it acts on " +
"whichever session currently owns `_state`, which may now be a newer, live attempt. Disconnect " +
"only this attempt's own room instead (e.g. disconnectSupersededLocalRoom(localRoom) / " +
"localRoom.disconnect()), or simply return without calling it.",
},
},
create(context) {
return {
CallExpression(node) {
if (!isThisMethodCall(node, "leaveVoice")) return;
let child = node;
let parent = node.parent;
while (parent) {
if (isFunctionNode(parent)) return; // left the enclosing method — stop
if (
parent.type === "IfStatement" &&
child === parent.consequent &&
testSignalsSuperseded(parent.test)
) {
context.report({ node, messageId: "unsafeLeaveVoice" });
return;
}
child = parent;
parent = parent.parent;
}
},
};
},
};
// ─────────────────────────────────────────────────────────────────────────
// Rule: e2ee-epoch-needs-keypair-check
//
// Invariant (CLAUDE.md): "Anything touching livekitE2EE.ts ... must preserve
// the epoch/keypair staleness guards."
//
// Every async E2EE operation that resumes after an await re-checks it is
// still the current attempt before writing shared state. The historical bug
// (see the fix for handleOfferInner / handleAnnounceInner) compared only
// `this._e2eeEpoch !== epochBefore` — insufficient, because a non-key-holder
// never bumps the epoch, so a torn-down-then-restarted session can resume
// with the epoch unchanged in both the old and new session. The fix requires
// ALSO comparing keypair identity (`this._ecdhKeyPair !== keypair`). This
// rule requires both checks to appear together in the same guard.
// ─────────────────────────────────────────────────────────────────────────
/** True when `test` (walking through &&/||) contains `this.<prop> !== X`
* (in either operand order). */
function containsStrictInequality(test, prop) {
if (test === null) return false;
if (test.type === "LogicalExpression") {
return containsStrictInequality(test.left, prop) || containsStrictInequality(test.right, prop);
}
if (test.type === "BinaryExpression" && test.operator === "!==") {
return isThisMember(test.left, prop) || isThisMember(test.right, prop);
}
return false;
}
const e2eeEpochNeedsKeypairCheck = {
meta: {
type: "problem",
docs: {
description:
"Require this._ecdhKeyPair identity checks alongside this._e2eeEpoch staleness checks. A " +
"non-key-holder session never bumps the epoch, so an epoch-only comparison cannot detect a " +
"torn-down-then-restarted session resuming after an await — only the keypair identity can.",
},
schema: [],
messages: {
missingKeypairCheck:
"This staleness check compares this._e2eeEpoch but not this._ecdhKeyPair. A non-key-holder " +
"session never advances the epoch, so this guard alone cannot detect a torn-down-then-restarted " +
"session — add `|| this._ecdhKeyPair !== <the keypair captured before the await>` to the condition.",
},
},
create(context) {
return {
IfStatement(node) {
if (
containsStrictInequality(node.test, "_e2eeEpoch") &&
!containsStrictInequality(node.test, "_ecdhKeyPair")
) {
context.report({ node: node.test, messageId: "missingKeypairCheck" });
}
},
};
},
};
// ─────────────────────────────────────────────────────────────────────────
// Rule: e2ee-verified-status-literal
//
// Invariant (CLAUDE.md): "Anything touching livekitE2EE.ts ... must never
// report an unverified peer as verified."
//
// verifyPeerAnnounce's every write of peer-verification state goes through
// setPeerVerification/setPeerVerificationIfCurrent, and "verified" is reached
// exactly once, only after a real signature check. This rule keeps that
// structurally true: the `status` field at every call site must be a literal
// the author typed by hand at that call site, never a variable/expression —
// which would let a status be computed (and potentially manipulated) instead
// of asserted at the one audited call site that earned it.
// ─────────────────────────────────────────────────────────────────────────
function getCalleeName(node) {
if (node.callee.type === "Identifier") return node.callee.name;
if (
node.callee.type === "MemberExpression" &&
!node.callee.computed &&
node.callee.property.type === "Identifier"
) {
return node.callee.property.name;
}
return null;
}
const VERIFICATION_SETTERS = new Set(["setPeerVerification", "setPeerVerificationIfCurrent"]);
const e2eeVerifiedStatusLiteral = {
meta: {
type: "problem",
docs: {
description:
"Require the `status` field passed to setPeerVerification/setPeerVerificationIfCurrent to be a " +
"string literal. A peer must never be reported verified via a computed/derived status — each " +
"verification outcome is a distinct, hand-written call site that earned its status inline.",
},
schema: [],
messages: {
dynamicStatus:
"The `status` passed here must be a string literal ('verified' | 'unverified' | 'mismatch' | " +
"'unknown'), not a computed expression. Add a new literal call site for this outcome instead of " +
"deriving the status dynamically — that is what keeps 'verified' provably tied to a real signature check.",
},
},
create(context) {
return {
CallExpression(node) {
const name = getCalleeName(node);
if (name === null || !VERIFICATION_SETTERS.has(name)) return;
const objArg = node.arguments[node.arguments.length - 1];
if (objArg === undefined || objArg.type !== "ObjectExpression") return;
const statusProp = objArg.properties.find(
(p) =>
p.type === "Property" &&
!p.computed &&
p.key.type === "Identifier" &&
p.key.name === "status",
);
if (statusProp === undefined) return;
const value = statusProp.value;
if (value.type !== "Literal" || typeof value.value !== "string") {
context.report({ node: statusProp, messageId: "dynamicStatus" });
}
},
};
},
};
// ─────────────────────────────────────────────────────────────────────────
// Rule: no-identity-scope-fallback
//
// Invariant (CLAUDE.md): "Anything touching livekitE2EE.ts or identity.ts
// must preserve the epoch/keypair staleness guards." (Identity-scoping
// analogue: a documented, previously-real bug — see identity.ts's
// `identityKeyPairCache` comment — where a missing user id fell back to a
// placeholder scope like `?? 0`, silently minting/adopting a keypair under
// the wrong account and permanently desyncing the published key from the
// announce-signing key for every peer.)
//
// getOrCreateIdentityKeyPair's userId argument must come from a value that
// was already checked for `undefined` (the pattern both call sites use), not
// a `??`/`||` fallback that would substitute a placeholder id.
// ─────────────────────────────────────────────────────────────────────────
const noIdentityScopeFallback = {
meta: {
type: "problem",
docs: {
description:
"Disallow a ??/|| placeholder fallback as the userId argument to getOrCreateIdentityKeyPair. A " +
"missing user id must abort (see the `userId === undefined` guards at both call sites), never " +
"substitute a placeholder scope — that mints or adopts a keypair under the wrong account and " +
"permanently desyncs the published key from the announce-signing key.",
},
schema: [],
messages: {
placeholderFallback:
"Do not fall back with ??/|| when passing the user id to getOrCreateIdentityKeyPair — a missing " +
"id must abort instead (check `=== undefined` and return, as both existing call sites do). A " +
"placeholder id mints/adopts a keypair under the wrong account and desyncs it from the signing key.",
},
},
create(context) {
return {
CallExpression(node) {
if (
node.callee.type !== "Identifier" ||
node.callee.name !== "getOrCreateIdentityKeyPair"
) {
return;
}
const userIdArg = node.arguments[1];
if (userIdArg === undefined) return;
if (
userIdArg.type === "LogicalExpression" &&
(userIdArg.operator === "??" || userIdArg.operator === "||")
) {
context.report({ node: userIdArg, messageId: "placeholderFallback" });
}
},
};
},
};
// ─────────────────────────────────────────────────────────────────────────
// Rule: no-store-write-in-ws-on
//
// Invariant (CLAUDE.md): "src/lib/dispatcher.ts is the single WS-event entry
// point: server events reach the stores only through a ws.on(...)
// subscription registered there."
//
// Other modules DO register their own ws.on(...) handlers (page-local UI:
// slow-mode timers, the connected overlay, incoming-call ringing) — that
// itself is not the violation. What must never happen outside dispatcher.ts
// is one of those handlers writing to a domain store directly, bypassing the
// dispatcher. Store *reads* (`fooStore.getState()`) are unaffected; this only
// flags calls to an imported store-mutator function (set/add/update/... from
// a `*/stores/*` module) reached from inside a `ws.on(...)` callback.
// ─────────────────────────────────────────────────────────────────────────
const STORE_MUTATOR_PREFIX =
/^(set|add|remove|update|increment|clear|toggle|open|close|join|leave|mark|confirm|bulk|rollback|reset|prepend|reattach|invalidate|load)[A-Z_]/;
function isStoreModuleSource(source) {
// Matches both the "@stores/..." alias and relative "../stores/..." paths.
return typeof source === "string" && /(?:^|\/)@?stores\//.test(source);
}
function isWsOnCall(node) {
return (
node !== null &&
node !== undefined &&
node.type === "CallExpression" &&
node.callee.type === "MemberExpression" &&
!node.callee.computed &&
node.callee.object.type === "Identifier" &&
node.callee.object.name === "ws" &&
node.callee.property.type === "Identifier" &&
node.callee.property.name === "on" &&
node.arguments.length >= 2
);
}
const noStoreWriteInWsOn = {
meta: {
type: "problem",
docs: {
description:
"Disallow calling an imported store-mutator (set*/add*/update*/... from a stores/ module) from " +
"inside a ws.on(...) callback outside dispatcher.ts. dispatcher.ts is the single place server " +
"events are allowed to write into domain stores; a page-local ws.on(...) handler may read store " +
"state and drive its own local UI, but must not mutate a domain store itself.",
},
schema: [],
messages: {
storeWriteOutsideDispatcher:
"'{{name}}' is a store mutator called from a ws.on(...) handler outside dispatcher.ts. " +
"dispatcher.ts is the single WS-event entry point that may write to stores — move this update " +
"into a dispatcher.ts handler for this message type, or have this handler read the store instead " +
"of writing it.",
},
},
create(context) {
const storeMutatorImports = new Set();
return {
ImportDeclaration(node) {
if (!isStoreModuleSource(node.source.value)) return;
for (const spec of node.specifiers) {
if (spec.type === "ImportSpecifier" && STORE_MUTATOR_PREFIX.test(spec.local.name)) {
storeMutatorImports.add(spec.local.name);
}
}
},
CallExpression(node) {
if (node.callee.type !== "Identifier" || !storeMutatorImports.has(node.callee.name)) return;
let parent = node.parent;
while (parent) {
if (
isFunctionNode(parent) &&
isWsOnCall(parent.parent) &&
parent.parent.arguments[1] === parent
) {
context.report({
node,
messageId: "storeWriteOutsideDispatcher",
data: { name: node.callee.name },
});
return;
}
parent = parent.parent;
}
},
};
},
};
export default {
rules: {
"no-leave-voice-when-superseded": noLeaveVoiceWhenSuperseded,
"e2ee-epoch-needs-keypair-check": e2eeEpochNeedsKeypairCheck,
"e2ee-verified-status-literal": e2eeVerifiedStatusLiteral,
"no-identity-scope-fallback": noIdentityScopeFallback,
"no-store-write-in-ws-on": noStoreWriteInWsOn,
},
};
+39 -12
View File
@@ -1,5 +1,6 @@
import eslint from "@eslint/js";
import tseslint from "typescript-eslint";
import localRules from "./eslint-rules.js";
export default tseslint.config(
eslint.configs.recommended,
@@ -32,10 +33,7 @@ export default tseslint.config(
// Empty functions are used for no-op callbacks
"@typescript-eslint/no-empty-function": "off",
// Project uses void for fire-and-forget promises intentionally
"@typescript-eslint/no-misused-promises": [
"error",
{ checksVoidReturn: false },
],
"@typescript-eslint/no-misused-promises": ["error", { checksVoidReturn: false }],
// Allow require() in config files
"@typescript-eslint/no-require-imports": "off",
// Unbound methods used in singleton export pattern (bind at export)
@@ -67,14 +65,43 @@ export default tseslint.config(
"consistent-return": "off",
},
},
// --- Local rules: three CLAUDE.md invariants enforced as lint rules ---
// See eslint-rules.js for each rule's rationale and the historical bug
// shape it catches. Each is scoped to only the module(s) its invariant
// governs.
{
ignores: [
"dist/",
"src-tauri/",
"node_modules/",
"public/",
"*.js",
"*.cjs",
],
files: ["src/lib/livekitSession.ts"],
plugins: { local: localRules },
rules: {
"local/no-leave-voice-when-superseded": "error",
},
},
{
files: ["src/lib/livekitE2EE.ts"],
plugins: { local: localRules },
rules: {
"local/e2ee-epoch-needs-keypair-check": "error",
"local/e2ee-verified-status-literal": "error",
"local/no-identity-scope-fallback": "error",
},
},
{
files: ["src/lib/identity.ts"],
plugins: { local: localRules },
rules: {
"local/no-identity-scope-fallback": "error",
},
},
{
// dispatcher.ts IS the allowed entry point, so it is exempt from its own rule.
files: ["src/**/*.ts"],
ignores: ["src/lib/dispatcher.ts"],
plugins: { local: localRules },
rules: {
"local/no-store-write-in-ws-on": "error",
},
},
{
ignores: ["dist/", "src-tauri/", "node_modules/", "public/", "*.js", "*.cjs"],
},
);
+315 -273
View File
File diff suppressed because it is too large Load Diff
+8 -5
View File
@@ -1,7 +1,7 @@
{
"name": "owncord-client",
"private": true,
"version": "1.2.0-alpha.1",
"version": "1.2.0-alpha.3",
"type": "module",
"scripts": {
"dev": "vite",
@@ -14,12 +14,14 @@
"test:e2e": "playwright test",
"test:e2e:prod": "npm run build && playwright test --config playwright.config.prod.ts",
"test:e2e:native": "playwright test --config playwright.config.native.ts",
"test:e2e:admin": "playwright test --config playwright.config.admin.ts",
"test:e2e:ui": "playwright test --ui",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage",
"test:browser": "vitest run --config vitest.config.browser.ts",
"typecheck": "tsc --noEmit",
"typecheck:build": "tsc -p tsconfig.build.json --noEmit",
"typecheck:e2e": "tsc -p tsconfig.e2e.json --noEmit",
"lint": "oxlint src/ && eslint src/",
"lint:fix": "eslint src/ --fix",
"lint:ox": "oxlint src/",
@@ -37,16 +39,17 @@
"@stryker-mutator/typescript-checker": "^9.6.1",
"@stryker-mutator/vitest-runner": "^9.6.1",
"@tauri-apps/cli": "^2",
"@types/node": "^20.19.43",
"@vitest/browser": "^3.2.4",
"@vitest/coverage-v8": "^3",
"eslint": "^10.8.0",
"eslint": "^10.8.1",
"fast-check": "^4.9.0",
"jsdom": "^29.1.1",
"knip": "^6.1.1",
"oxlint": "^1.76.0",
"knip": "^6.32.0",
"oxlint": "^1.77.0",
"prettier": "^3.9.6",
"typescript": "^5.7",
"typescript-eslint": "^8.65.0",
"typescript-eslint": "^8.66.0",
"vite": "^6",
"vitest": "^3"
},
@@ -0,0 +1,45 @@
import { defineConfig } from "@playwright/test";
/**
* Playwright config for the ADMIN PANEL e2e suite — the server-embedded SPA
* (Server/admin/static/index.html), driven against a REAL server started by
* tests/e2e/admin/start-server.sh (fresh temp data dir, TLS off, loopback).
*
* Unlike the mocked-Tauri web suite this exercises the true stack: chi
* router, admin middleware/gates, SQLite, and the SPA itself. The journey is
* stateful by design (first-run wizard creates the owner the later tests log
* in as), so it runs serially in one worker against one server instance.
*
* Usage: npm run test:e2e:admin (requires the Go toolchain)
*/
const PORT = process.env.OWNCORD_ADMIN_E2E_PORT ?? "18446";
export default defineConfig({
testDir: "./tests/e2e/admin",
timeout: 30_000,
expect: { timeout: 5_000 },
fullyParallel: false,
workers: 1,
retries: process.env.CI ? 2 : 1,
reporter: process.env.CI
? [
["html", { open: "never" }],
["junit", { outputFile: "test-results/admin-junit.xml" }],
]
: "html",
use: {
baseURL: `http://127.0.0.1:${PORT}`,
screenshot: "only-on-failure",
trace: "on-first-retry",
contextOptions: { reducedMotion: "reduce" },
},
webServer: {
command: "bash tests/e2e/admin/start-server.sh",
url: `http://127.0.0.1:${PORT}/health`,
reuseExistingServer: !process.env.CI,
// First run compiles the Go server; CI cold caches need the headroom.
timeout: 240_000,
},
});
@@ -60,7 +60,10 @@ export default defineConfig({
"app-layout.spec.ts",
"channel-navigation.spec.ts",
"chat-operations.spec.ts",
"dm-system.spec.ts",
"reconnection.spec.ts",
"settings-overlay.spec.ts",
"theme-persistence.spec.ts",
"voice-controls.spec.ts",
"overlays.spec.ts",
],
@@ -9,7 +9,7 @@ import { defineConfig, devices } from "@playwright/test";
*/
export default defineConfig({
testDir: "./tests/e2e",
testIgnore: ["**/native/**"],
testIgnore: ["**/native/**", "**/admin/**"],
timeout: 30_000,
expect: {
timeout: 5_000,
+1 -1
View File
@@ -2,7 +2,7 @@ import { defineConfig, devices } from "@playwright/test";
export default defineConfig({
testDir: "./tests/e2e",
testIgnore: ["**/native/**"],
testIgnore: ["**/native/**", "**/admin/**"],
timeout: 30_000,
expect: {
timeout: 5_000,
@@ -1,291 +0,0 @@
// =============================================================================
// RNNoise AudioWorklet Processor
//
// Runs on the audio rendering thread. Receives WASM module bytes from the
// main thread, initializes RNNoise, and processes 480-sample frames at 48kHz.
// =============================================================================
const FRAME_SIZE = 480;
const WASM_MEMORY_INITIAL_PAGES = 256;
const OUTPUT_RING_CAPACITY = 50;
const RN_NOISE_INT16_SCALE = 32768;
declare abstract class AudioWorkletProcessor {
readonly port: MessagePort;
}
declare function registerProcessor(
name: string,
processorCtor: typeof RNNoiseProcessor,
): void;
interface RNNoiseWasmExports extends WebAssembly.Exports {
rnnoise_create(): number;
rnnoise_destroy(state: number): void;
rnnoise_process_frame(state: number, outputPtr: number, inputPtr: number): void;
malloc(size: number): number;
free(ptr: number): void;
}
interface RNNoiseWasmInstance extends WebAssembly.Instance {
exports: RNNoiseWasmExports;
}
class RNNoiseProcessor extends AudioWorkletProcessor {
private _instance: RNNoiseWasmInstance | null = null;
private _state: number = 0;
private _inputPtr: number = 0;
private _outputPtr: number = 0;
private _heapF32: Float32Array | null = null;
private _ready: boolean = false;
private _destroyed: boolean = false;
// Ring buffer to accumulate 480-sample frames
private _inputRing: Float32Array;
private _inputRingOffset: number = 0;
// Output ring buffer (contiguous for efficiency)
private _outBuffer: Float32Array;
private _outWritePos: number = 0;
private _outReadPos: number = 0;
private _outAvailable: number = 0;
private _outSampleOffset: number = 0;
constructor() {
super();
this._inputRing = new Float32Array(FRAME_SIZE);
this._outBuffer = new Float32Array(OUTPUT_RING_CAPACITY * FRAME_SIZE);
this.port.onmessage = (event: MessageEvent) => {
if (event.data.type === "init") {
this._initWasm(event.data.wasmBytes);
} else if (event.data.type === "destroy") {
this._cleanup();
}
};
}
/**
* Reports an error to the main thread and logs it.
* @param message - Error message
* @param error - Optional error object
* @private
*/
private _reportError(message: string, error?: unknown): void {
console.error(`RNNoise Processor: ${message}`, error);
this.port.postMessage({ type: "error", message });
}
/**
* Initializes the WASM module and RNNoise state.
* @param wasmBytes - Raw WASM module bytes
* @private
*/
private async _initWasm(wasmBytes: ArrayBuffer): Promise<void> {
let allocated = false;
try {
// Basic validation: check for expected exports
const module = await WebAssembly.compile(wasmBytes);
const expectedExports = ['rnnoise_create', 'rnnoise_destroy', 'rnnoise_process_frame', 'malloc', 'free'];
const availableExports = WebAssembly.Module.exports(module).map(exp => exp.name);
const hasRequiredExports = expectedExports.every(exp => availableExports.includes(exp));
if (!hasRequiredExports) {
throw new Error('WASM module missing required RNNoise exports');
}
const memory = new WebAssembly.Memory({ initial: WASM_MEMORY_INITIAL_PAGES });
const importObject = {
env: {
memory,
emscripten_notify_memory_growth: () => {
this._heapF32 = new Float32Array(memory.buffer);
},
},
wasi_snapshot_preview1: {
proc_exit: () => {},
fd_close: () => 0,
fd_write: () => 0,
fd_seek: () => 0,
},
};
// Try instantiating with the raw WASM bytes
const { instance } = await WebAssembly.instantiate(wasmBytes, importObject);
this._instance = instance as RNNoiseWasmInstance;
this._heapF32 = new Float32Array(memory.buffer);
// Call RNNoise C API
const exports = instance.exports as unknown as RNNoiseWasmExports;
this._state = exports.rnnoise_create();
this._inputPtr = exports.malloc(FRAME_SIZE * 4);
this._outputPtr = exports.malloc(FRAME_SIZE * 4);
allocated = true;
this._ready = true;
this.port.postMessage({ type: "ready" });
} catch (err) {
// Cleanup allocated memory on failure
if (allocated && this._instance) {
try {
const exports = this._instance.exports;
if (this._inputPtr) exports.free(this._inputPtr);
if (this._outputPtr) exports.free(this._outputPtr);
if (this._state) exports.rnnoise_destroy(this._state);
} catch (cleanupErr) {
// Log cleanup errors but don't override original error
console.warn('Failed to cleanup WASM memory:', cleanupErr);
}
}
this._reportError(`WASM initialization failed: ${err instanceof Error ? err.message : String(err)}`, err);
}
}
/**
* Processes a complete 480-sample frame through RNNoise.
* Copies input ring buffer to WASM memory, runs noise suppression,
* and stores the result in the output ring buffer.
* @private
*/
private _processFrame(): void {
if (!this._instance || !this._heapF32) return;
const exports = this._instance.exports;
const inOff = this._inputPtr / 4;
const outOff = this._outputPtr / 4;
// CRITICAL: Bounds check before accessing heap
if (inOff + FRAME_SIZE > this._heapF32.length ||
outOff + FRAME_SIZE > this._heapF32.length) {
console.error('WASM heap bounds exceeded');
return;
}
for (let i = 0; i < FRAME_SIZE; i++) {
this._heapF32[inOff + i] = (this._inputRing[i] ?? 0) * RN_NOISE_INT16_SCALE;
}
exports.rnnoise_process_frame(this._state, this._outputPtr, this._inputPtr);
// Write to contiguous buffer
const writeStart = this._outWritePos * FRAME_SIZE;
for (let i = 0; i < FRAME_SIZE; i++) {
this._outBuffer[writeStart + i] = (this._heapF32[outOff + i] ?? 0) / RN_NOISE_INT16_SCALE;
}
this._outWritePos = (this._outWritePos + 1) % OUTPUT_RING_CAPACITY;
if (this._outAvailable < OUTPUT_RING_CAPACITY) {
this._outAvailable++;
} else {
// Overwrite oldest
this._outReadPos = (this._outReadPos + 1) % OUTPUT_RING_CAPACITY;
this._outSampleOffset = 0;
}
}
/**
* Cleans up WASM resources and marks the processor as destroyed.
* Safe to call multiple times.
* @private
*/
private _cleanup(): void {
if (this._instance && this._state) {
try {
const exports = this._instance.exports;
exports.rnnoise_destroy(this._state);
exports.free(this._inputPtr);
exports.free(this._outputPtr);
} catch (err) {
console.warn('RNNoise cleanup failed:', err);
// Continue cleanup even if individual steps fail
}
}
this._ready = false;
this._destroyed = true;
this._state = 0;
}
/**
* Processes input audio data into the ring buffer and triggers frame processing.
* @param inData - Input audio samples
* @private
*/
private _processInputRingBuffer(inData: Float32Array): void {
let inIdx = 0;
while (inIdx < inData.length) {
const needed = FRAME_SIZE - this._inputRingOffset;
const toCopy = Math.min(needed, inData.length - inIdx);
this._inputRing.set(inData.subarray(inIdx, inIdx + toCopy), this._inputRingOffset);
this._inputRingOffset += toCopy;
inIdx += toCopy;
if (this._inputRingOffset >= FRAME_SIZE) {
this._processFrame();
this._inputRingOffset = 0;
}
}
}
/**
* Fills output buffer from the processed frames ring buffer.
* @param outData - Output audio buffer to fill
* @private
*/
private _fillOutputFromRingBuffer(outData: Float32Array): void {
let outIdx = 0;
while (outIdx < outData.length && this._outAvailable > 0) {
const readStart = this._outReadPos * FRAME_SIZE;
const available = FRAME_SIZE - this._outSampleOffset;
const toWrite = Math.min(available, outData.length - outIdx);
outData.set(this._outBuffer.subarray(readStart + this._outSampleOffset, readStart + this._outSampleOffset + toWrite), outIdx);
outIdx += toWrite;
this._outSampleOffset += toWrite;
if (this._outSampleOffset >= FRAME_SIZE) {
this._outReadPos = (this._outReadPos + 1) % OUTPUT_RING_CAPACITY;
this._outAvailable--;
this._outSampleOffset = 0;
}
}
// Fill remaining with silence
if (outIdx < outData.length) {
outData.fill(0, outIdx);
}
}
process(inputs: Float32Array[][], outputs: Float32Array[][]): boolean {
if (this._destroyed) return false;
// Validate input/output structure
if (!inputs || !inputs[0] || !inputs[0][0] ||
!outputs || !outputs[0] || !outputs[0][0]) {
return true; // Pass through silence or existing data
}
const input = inputs[0];
const output = outputs[0];
const inData = input[0]!;
const outData = output[0]!;
// Validate buffer lengths
if (inData.length === 0 || outData.length === 0) {
return true;
}
if (!this._ready) {
// Pass through until WASM is ready
const copyLength = Math.min(inData.length, outData.length);
outData.set(inData.subarray(0, copyLength));
if (copyLength < outData.length) {
outData.fill(0, copyLength);
}
return true;
}
this._processInputRingBuffer(inData);
this._fillOutputFromRingBuffer(outData);
return true;
}
}
registerProcessor("rnnoise-processor", RNNoiseProcessor);
@@ -8,4 +8,28 @@ ignore = [
# Drop both entries when the notification chain moves to quick-xml >= 0.41.
"RUSTSEC-2026-0194",
"RUSTSEC-2026-0195",
# glib 0.18.5 is pinned by the whole Linux GTK stack: wry (even 0.56)
# requires `webkit2gtk =2.0.2`, which requires `glib ^0.18.0`. The fix
# landed in glib 0.20.0 and was never backported (0.18.5 is the last 0.18
# release; 0.19.x is still in range), so no semver-compatible route exists.
# The unsoundness is only reachable through `Variant::array_iter_str()` —
# nothing in the dependency tree or in src-tauri/src/ calls it, and the
# crate is Linux-only here (see the cfg(target_os = "linux") block in
# Cargo.toml). Drop this entry when webkit2gtk moves to gtk-rs 0.20.
"RUSTSEC-2024-0429",
# rand 0.7.3 arrives only as a BUILD dependency, three levels down:
# tauri-utils -> kuchikiki 0.8.8-speedreader -> selectors 0.24.0, whose
# build.rs uses phf_codegen -> phf_generator 0.8.0 (which requires
# rand ^0.7). It runs at codegen time and never links into a shipped
# binary. The advisory needs `ThreadRng` reseeding under a custom logger
# with rand's `log` feature on; phf_generator instead uses a fixed-seed
# `SmallRng::seed_from_u64(1234567890)` and never enables `log` — and no
# other crate here depends on rand 0.7, so feature unification cannot
# turn it on. Not upgradable: kuchikiki 0.8.9-speedreader would drop this
# chain, but cargo will not match a pre-release across patch versions
# (`^0.8.8-speedreader` rejects 0.8.9-speedreader) and tauri-utils 2.9.3
# is the latest release. Drop this entry when tauri-utils bumps kuchikiki.
"RUSTSEC-2026-0097",
]
+8 -403
View File
@@ -69,56 +69,6 @@ dependencies = [
"libc",
]
[[package]]
name = "anstream"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d"
dependencies = [
"anstyle",
"anstyle-parse",
"anstyle-query",
"anstyle-wincon",
"colorchoice",
"is_terminal_polyfill",
"utf8parse",
]
[[package]]
name = "anstyle"
version = "1.0.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000"
[[package]]
name = "anstyle-parse"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e"
dependencies = [
"utf8parse",
]
[[package]]
name = "anstyle-query"
version = "1.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"
dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "anstyle-wincon"
version = "3.0.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d"
dependencies = [
"anstyle",
"once_cell_polyfill",
"windows-sys 0.61.2",
]
[[package]]
name = "anyhow"
version = "1.0.102"
@@ -423,16 +373,6 @@ dependencies = [
"tinyvec",
]
[[package]]
name = "bstr"
version = "1.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "63044e1ae8e69f3b5a92c736ca6269b8d12fa7efe39bf34ddb06d102cf0e2cab"
dependencies = [
"memchr",
"serde",
]
[[package]]
name = "bumpalo"
version = "3.20.2"
@@ -603,35 +543,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0"
dependencies = [
"iana-time-zone",
"js-sys",
"num-traits",
"serde",
"wasm-bindgen",
"windows-link 0.2.1",
]
[[package]]
name = "chrono-tz"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "93698b29de5e97ad0ae26447b344c482a7284c737d9ddc5f9e52b74a336671bb"
dependencies = [
"chrono",
"chrono-tz-build",
"phf 0.11.3",
]
[[package]]
name = "chrono-tz-build"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0c088aee841df9c3041febbb73934cfc39708749bf96dc827e3359cd39ef11b1"
dependencies = [
"parse-zoneinfo",
"phf 0.11.3",
"phf_codegen 0.11.3",
]
[[package]]
name = "cipher"
version = "0.4.4"
@@ -642,52 +558,6 @@ dependencies = [
"inout",
]
[[package]]
name = "clap"
version = "4.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b193af5b67834b676abd72466a96c1024e6a6ad978a1f484bd90b85c94041351"
dependencies = [
"clap_builder",
"clap_derive",
]
[[package]]
name = "clap_builder"
version = "4.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f"
dependencies = [
"anstream",
"anstyle",
"clap_lex",
"strsim",
]
[[package]]
name = "clap_derive"
version = "4.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1110bd8a634a1ab8cb04345d8d878267d57c3cf1b38d91b71af6686408bbca6a"
dependencies = [
"heck 0.5.0",
"proc-macro2",
"quote",
"syn 2.0.117",
]
[[package]]
name = "clap_lex"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9"
[[package]]
name = "colorchoice"
version = "1.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570"
[[package]]
name = "combine"
version = "4.6.7"
@@ -707,18 +577,6 @@ dependencies = [
"crossbeam-utils",
]
[[package]]
name = "console"
version = "0.16.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4fe5f465a4f6fee88fad41b85d990f84c835335e85b5d9e6e63e0d06d28cba7c"
dependencies = [
"encode_unicode",
"libc",
"unicode-width",
"windows-sys 0.61.2",
]
[[package]]
name = "const-random"
version = "0.1.18"
@@ -860,25 +718,6 @@ dependencies = [
"crossbeam-utils",
]
[[package]]
name = "crossbeam-deque"
version = "0.8.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51"
dependencies = [
"crossbeam-epoch",
"crossbeam-utils",
]
[[package]]
name = "crossbeam-epoch"
version = "0.9.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2d6914041f254d6e9176c01941b21115dcfb7089e55135a35411081bd106ef3f"
dependencies = [
"crossbeam-utils",
]
[[package]]
name = "crossbeam-utils"
version = "0.8.21"
@@ -1087,12 +926,6 @@ dependencies = [
"syn 2.0.117",
]
[[package]]
name = "deunicode"
version = "1.6.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "abd57806937c9cc163efc8ea3910e00a62e2aeb0b8119f1793a978088f8f6b04"
[[package]]
name = "device_query"
version = "2.1.0"
@@ -1310,12 +1143,6 @@ version = "1.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4ef6b89e5b37196644d8796de5268852ff179b44e96276cf4290264843743bb7"
[[package]]
name = "encode_unicode"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0"
[[package]]
name = "encoding_rs"
version = "0.8.35"
@@ -1874,30 +1701,6 @@ version = "0.3.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280"
[[package]]
name = "globset"
version = "0.4.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "52dfc19153a48bde0cbd630453615c8151bce3a5adfac7a0aebfbf0a1e1f57e3"
dependencies = [
"aho-corasick",
"bstr",
"log",
"regex-automata",
"regex-syntax",
]
[[package]]
name = "globwalk"
version = "0.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0bf760ebf69878d9fd8f110c89703d90ce35095324d1f1edcb595c63945ee757"
dependencies = [
"bitflags 2.11.0",
"ignore",
"walkdir",
]
[[package]]
name = "gobject-sys"
version = "0.18.0"
@@ -2110,15 +1913,6 @@ version = "1.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87"
[[package]]
name = "humansize"
version = "2.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6cb51c9a029ddc91b07a787f1d86b53ccfa49b0e86688c946ebe8d3555685dd7"
dependencies = [
"libm",
]
[[package]]
name = "hyper"
version = "1.8.1"
@@ -2195,7 +1989,7 @@ dependencies = [
"js-sys",
"log",
"wasm-bindgen",
"windows-core 0.58.0",
"windows-core 0.61.2",
]
[[package]]
@@ -2331,22 +2125,6 @@ dependencies = [
"icu_properties",
]
[[package]]
name = "ignore"
version = "0.4.25"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d3d782a365a015e0f5c04902246139249abf769125006fbe7649e2ee88169b4a"
dependencies = [
"crossbeam-deque",
"globset",
"log",
"memchr",
"regex-automata",
"same-file",
"walkdir",
"winapi-util",
]
[[package]]
name = "indexmap"
version = "1.9.3"
@@ -2370,19 +2148,6 @@ dependencies = [
"serde_core",
]
[[package]]
name = "indicatif"
version = "0.18.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9433806cd6b4ec1aba79c021c7e4c58fb4c3b9977c085062e611ac929998fb0c"
dependencies = [
"console",
"portable-atomic",
"unicode-width",
"unit-prefix",
"web-time",
]
[[package]]
name = "infer"
version = "0.19.0"
@@ -2437,12 +2202,6 @@ dependencies = [
"once_cell",
]
[[package]]
name = "is_terminal_polyfill"
version = "1.70.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695"
[[package]]
name = "itoa"
version = "1.0.18"
@@ -2626,12 +2385,6 @@ dependencies = [
"winapi",
]
[[package]]
name = "libm"
version = "0.2.16"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981"
[[package]]
name = "libredox"
version = "0.1.14"
@@ -3208,12 +2961,6 @@ version = "1.21.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
[[package]]
name = "once_cell_polyfill"
version = "1.70.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
[[package]]
name = "open"
version = "5.3.3"
@@ -3274,7 +3021,7 @@ dependencies = [
[[package]]
name = "owncord-client"
version = "1.2.0-alpha.1"
version = "1.2.0-alpha.3"
dependencies = [
"base64 0.22.1",
"device_query",
@@ -3301,7 +3048,6 @@ dependencies = [
"tauri-plugin-store",
"tauri-plugin-updater",
"tauri-plugin-window-state",
"tauri-typegen",
"tokio",
"tokio-rustls",
"tokio-tungstenite",
@@ -3367,15 +3113,6 @@ dependencies = [
"windows-link 0.2.1",
]
[[package]]
name = "parse-zoneinfo"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1f2a05b18d44e2957b88f96ba460715e295bc1d7510468a2f3d3b44535d26c24"
dependencies = [
"regex",
]
[[package]]
name = "pathdiff"
version = "0.2.3"
@@ -3388,49 +3125,6 @@ version = "2.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
[[package]]
name = "pest"
version = "2.8.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e0848c601009d37dfa3430c4666e147e49cdcf1b92ecd3e63657d8a5f19da662"
dependencies = [
"memchr",
"ucd-trie",
]
[[package]]
name = "pest_derive"
version = "2.8.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "11f486f1ea21e6c10ed15d5a7c77165d0ee443402f0780849d1768e7d9d6fe77"
dependencies = [
"pest",
"pest_generator",
]
[[package]]
name = "pest_generator"
version = "2.8.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8040c4647b13b210a963c1ed407c1ff4fdfa01c31d6d2a098218702e6664f94f"
dependencies = [
"pest",
"pest_meta",
"proc-macro2",
"quote",
"syn 2.0.117",
]
[[package]]
name = "pest_meta"
version = "2.8.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "89815c69d36021a140146f26659a81d6c2afa33d216d736dd4be5381a7362220"
dependencies = [
"pest",
"sha2",
]
[[package]]
name = "phf"
version = "0.8.0"
@@ -3692,12 +3386,6 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "portable-atomic"
version = "1.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49"
[[package]]
name = "potential_utf"
version = "0.1.4"
@@ -4311,9 +3999,9 @@ dependencies = [
[[package]]
name = "rustls"
version = "0.23.42"
version = "0.23.43"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3c54fcab019b409d04215d3a17cb438fd7fbf192ee61461f20f4fe18704bc138"
checksum = "0283386ce02abc0151e1761d08802dfe86c173b0b494af5cbc086574e453da06"
dependencies = [
"once_cell",
"ring",
@@ -4582,12 +4270,6 @@ dependencies = [
"serde_derive",
]
[[package]]
name = "serde-rename-rule"
version = "0.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8a059d895f1a31dd928f40abbea4e7177e3d8ff3aa4152fdb7a396ae1ef63a3"
[[package]]
name = "serde-untagged"
version = "0.1.9"
@@ -4820,16 +4502,6 @@ version = "0.4.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"
[[package]]
name = "slug"
version = "0.1.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "882a80f72ee45de3cc9a5afeb2da0331d58df69e4e7d8eeb5d3c7784ae67e724"
dependencies = [
"deunicode",
"wasm-bindgen",
]
[[package]]
name = "smallvec"
version = "1.15.1"
@@ -5471,9 +5143,9 @@ dependencies = [
[[package]]
name = "tauri-plugin-updater"
version = "2.10.0"
version = "2.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3fe8e9bebd88fc222938ffdfbdcfa0307081423bd01e3252fc337d8bde81fc61"
checksum = "806d9dac662c2e4594ff03c647a552f2c9bd544e7d0f683ec58f872f952ce4af"
dependencies = [
"base64 0.22.1",
"dirs 6.0.0",
@@ -5568,27 +5240,6 @@ dependencies = [
"wry",
]
[[package]]
name = "tauri-typegen"
version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3e761d97bf6c90f13894383493485008d0c51f67ff7b12c0f95dc34b8a9e6e73"
dependencies = [
"chrono",
"clap",
"indicatif",
"proc-macro2",
"quote",
"regex",
"serde",
"serde-rename-rule",
"serde_json",
"syn 2.0.117",
"tera",
"thiserror 2.0.18",
"walkdir",
]
[[package]]
name = "tauri-utils"
version = "2.9.3"
@@ -5685,28 +5336,6 @@ dependencies = [
"utf-8",
]
[[package]]
name = "tera"
version = "1.20.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e8004bca281f2d32df3bacd59bc67b312cb4c70cea46cbd79dbe8ac5ed206722"
dependencies = [
"chrono",
"chrono-tz",
"globwalk",
"humansize",
"lazy_static",
"percent-encoding",
"pest",
"pest_derive",
"rand 0.8.7",
"regex",
"serde",
"serde_json",
"slug",
"unicode-segmentation",
]
[[package]]
name = "thiserror"
version = "1.0.69"
@@ -6134,12 +5763,6 @@ version = "1.19.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb"
[[package]]
name = "ucd-trie"
version = "0.1.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971"
[[package]]
name = "uds_windows"
version = "1.2.1"
@@ -6204,24 +5827,12 @@ version = "1.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493"
[[package]]
name = "unicode-width"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254"
[[package]]
name = "unicode-xid"
version = "0.2.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"
[[package]]
name = "unit-prefix"
version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "81e544489bf3d8ef66c953931f56617f423cd4b5494be343d9b9d3dda037b9a3"
[[package]]
name = "untrusted"
version = "0.9.0"
@@ -6265,12 +5876,6 @@ version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
[[package]]
name = "utf8parse"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
[[package]]
name = "uuid"
version = "1.22.0"
@@ -7561,9 +7166,9 @@ dependencies = [
[[package]]
name = "zeroize"
version = "1.8.2"
version = "1.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0"
checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e"
dependencies = [
"zeroize_derive",
]
+1 -2
View File
@@ -1,6 +1,6 @@
[package]
name = "owncord-client"
version = "1.2.0-alpha.1"
version = "1.2.0-alpha.3"
edition = "2021"
# Effective minimum: tauri 2.11 declares rust-version = "1.77.2", so the crate
# cannot build below it. Declaring it here enables Cargo's MSRV-aware resolver
@@ -21,7 +21,6 @@ crate-type = ["lib", "cdylib", "staticlib"]
[build-dependencies]
tauri-build = { version = "2", features = [] }
tauri-typegen = "0.5"
[features]
default = []
@@ -21,6 +21,7 @@
"core:window:allow-outer-size",
"core:window:allow-available-monitors",
"core:window:allow-center",
"core:window:allow-request-user-attention",
"notification:default",
"notification:allow-notify",
"notification:allow-request-permission",
+175 -78
View File
@@ -1,4 +1,5 @@
use serde::Serialize;
use std::sync::Mutex;
use tauri::AppHandle;
use crate::secret_store::{self, Backend};
@@ -8,9 +9,9 @@ use crate::secret_store::{self, Backend};
pub struct CredentialData {
pub username: String,
pub token: String,
// Password is stored in the credential blob for re-authentication but
// is never serialized back to the frontend over IPC to limit exposure.
#[serde(skip)]
// Password is stored in the credential blob for re-authentication and is
// serialized back to the frontend over IPC so the login form can prefill
// it when the user ticked "Remember password".
pub password: Option<String>,
}
@@ -53,6 +54,42 @@ fn require_non_empty(value: &str, field: &str) -> Result<(), String> {
Ok(())
}
// ---------------------------------------------------------------------------
// Cross-command serialization
// ---------------------------------------------------------------------------
//
// B4-3 moved every command below to `#[tauri::command(async)]` so the
// blocking keyring/DPAPI I/O runs off Tauri's IPC main thread instead of
// freezing the UI on it. Before that, Tauri ran all (sync) commands one at a
// time on that thread, so two overlapping invocations were always fully
// serialized in arrival order. `async` dispatches each invocation onto the
// async runtime's thread pool instead, so two overlapping calls can now
// genuinely run concurrently and interleave their OS credential-store
// operations.
//
// That is reachable, not hypothetical: `identity.ts`'s legacy-key migration
// does a save-then-delete pair for two different accounts, and logging out
// fires a fire-and-forget `delete_credential` for a host whose connect-page
// auto-login can immediately issue `load_credential` for the very same host.
// Nothing upstream awaits the delete before the read can start.
//
// This mutex restores the "only one credential-store operation in flight at
// a time" property that made ordering safe pre-`async`, without giving back
// the perf win: it guards the whole command body (not just the raw OS call),
// so the fallback file's read-modify-write in `secret_store::set_with` is
// still atomic with respect to a concurrent read or delete for the same or a
// different account.
static CREDENTIAL_LOCK: Mutex<()> = Mutex::new(());
/// Run `f` with every other credential-store command excluded. Poisoning is
/// recovered from (the guarded value is `()`, so there is nothing to
/// distrust) rather than propagated, so a panic inside one command cannot
/// permanently wedge every credential operation for the rest of the process.
fn with_credential_lock<T>(f: impl FnOnce() -> T) -> T {
let _guard = CREDENTIAL_LOCK.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
f()
}
// ---------------------------------------------------------------------------
// Tauri commands
// ---------------------------------------------------------------------------
@@ -68,7 +105,7 @@ fn require_non_empty(value: &str, field: &str) -> Result<(), String> {
/// On macOS it is stored in the system Keychain. The write is read back before
/// this returns — see [`crate::secret_store`] for what happens when it does not
/// come back.
#[tauri::command]
#[tauri::command(async)]
pub fn save_credential(
app: AppHandle,
host: String,
@@ -76,37 +113,41 @@ pub fn save_credential(
token: String,
password: Option<String>,
) -> Result<(), String> {
require_non_empty(&host, "host")?;
require_non_empty(&token, "token")?;
require_non_empty(&username, "username")?;
with_credential_lock(|| {
require_non_empty(&host, "host")?;
require_non_empty(&token, "token")?;
require_non_empty(&username, "username")?;
let mut payload = serde_json::json!({
"username": username,
"token": token,
});
if let Some(ref pw) = password {
payload["password"] = serde_json::Value::String(pw.clone());
}
let mut payload = serde_json::json!({
"username": username,
"token": token,
});
if let Some(ref pw) = password {
payload["password"] = serde_json::Value::String(pw.clone());
}
secret_store::set(&app, &login_account(&host), &payload.to_string())
.map_err(|e| format!("save_credential failed: {e}"))?;
Ok(())
secret_store::set(&app, &login_account(&host), &payload.to_string())
.map_err(|e| format!("save_credential failed: {e}"))?;
Ok(())
})
}
/// Load a credential from the system credential store.
///
/// Returns `None` when no credential exists for the given host.
#[tauri::command]
#[tauri::command(async)]
pub fn load_credential(app: AppHandle, host: String) -> Result<Option<CredentialData>, String> {
require_non_empty(&host, "host")?;
with_credential_lock(|| {
require_non_empty(&host, "host")?;
let Some(json_str) = secret_store::get(&app, &login_account(&host))
.map_err(|e| format!("load_credential failed: {e}"))?
else {
return Ok(None);
};
let Some(json_str) = secret_store::get(&app, &login_account(&host))
.map_err(|e| format!("load_credential failed: {e}"))?
else {
return Ok(None);
};
parse_credential_blob(&json_str).map(Some)
parse_credential_blob(&json_str).map(Some)
})
}
/// Parse the stored credential JSON blob.
@@ -142,11 +183,13 @@ fn parse_credential_blob(json_str: &str) -> Result<CredentialData, String> {
/// Delete a credential from the system credential store.
///
/// Deleting a non-existent credential is not treated as an error.
#[tauri::command]
#[tauri::command(async)]
pub fn delete_credential(app: AppHandle, host: String) -> Result<(), String> {
require_non_empty(&host, "host")?;
secret_store::delete(&app, &login_account(&host))
.map_err(|e| format!("delete_credential failed: {e}"))
with_credential_lock(|| {
require_non_empty(&host, "host")?;
secret_store::delete(&app, &login_account(&host))
.map_err(|e| format!("delete_credential failed: {e}"))
})
}
// ---------------------------------------------------------------------------
@@ -165,34 +208,40 @@ pub fn delete_credential(app: AppHandle, host: String) -> Result<(), String> {
/// file (DPAPI on Windows, sealed per-install key elsewhere); if that is also
/// unavailable this returns an error rather than reporting a success that would
/// leave peers rejecting the user's voice announce after a restart.
#[tauri::command]
#[tauri::command(async)]
pub fn save_identity_key(app: AppHandle, host: String, key: String) -> Result<(), String> {
require_non_empty(&host, "host")?;
require_non_empty(&key, "key")?;
with_credential_lock(|| {
require_non_empty(&host, "host")?;
require_non_empty(&key, "key")?;
secret_store::set(&app, &identity_account(&host), &key)
.map_err(|e| format!("save_identity_key failed: {e}"))?;
Ok(())
secret_store::set(&app, &identity_account(&host), &key)
.map_err(|e| format!("save_identity_key failed: {e}"))?;
Ok(())
})
}
/// Load the identity private key for `host`.
///
/// Returns `None` when no identity key exists for the given host.
#[tauri::command]
#[tauri::command(async)]
pub fn load_identity_key(app: AppHandle, host: String) -> Result<Option<String>, String> {
require_non_empty(&host, "host")?;
secret_store::get(&app, &identity_account(&host))
.map_err(|e| format!("load_identity_key failed: {e}"))
with_credential_lock(|| {
require_non_empty(&host, "host")?;
secret_store::get(&app, &identity_account(&host))
.map_err(|e| format!("load_identity_key failed: {e}"))
})
}
/// Delete the identity private key for `host`.
///
/// Deleting a non-existent key is not treated as an error.
#[tauri::command]
#[tauri::command(async)]
pub fn delete_identity_key(app: AppHandle, host: String) -> Result<(), String> {
require_non_empty(&host, "host")?;
secret_store::delete(&app, &identity_account(&host))
.map_err(|e| format!("delete_identity_key failed: {e}"))
with_credential_lock(|| {
require_non_empty(&host, "host")?;
secret_store::delete(&app, &identity_account(&host))
.map_err(|e| format!("delete_identity_key failed: {e}"))
})
}
// ---------------------------------------------------------------------------
@@ -217,44 +266,46 @@ pub struct CredentialStoreProbe {
/// announce: it distinguishes "the credential store is fine" from "writes are
/// accepted and dropped" without touching any real credential. The probe
/// account is removed again whatever the outcome.
#[tauri::command]
#[tauri::command(async)]
pub fn probe_credential_store(app: AppHandle) -> CredentialStoreProbe {
// Underscores are not legal in DNS hostnames, so this cannot collide with a
// real `{host}` or `identity:{host}` account.
const PROBE_ACCOUNT: &str = "__diagnostic_probe__";
const PROBE_SECRET: &str = "owncord-credential-store-probe";
with_credential_lock(|| {
// Underscores are not legal in DNS hostnames, so this cannot collide
// with a real `{host}` or `identity:{host}` account.
const PROBE_ACCOUNT: &str = "__diagnostic_probe__";
const PROBE_SECRET: &str = "owncord-credential-store-probe";
let result = secret_store::set(&app, PROBE_ACCOUNT, PROBE_SECRET).and_then(|backend| {
match secret_store::get(&app, PROBE_ACCOUNT)? {
Some(ref got) if got == PROBE_SECRET => Ok(backend),
Some(_) => Err("read back a different value than was written".into()),
None => Err("the store reported a successful write but returned no entry".into()),
let result = secret_store::set(&app, PROBE_ACCOUNT, PROBE_SECRET).and_then(|backend| {
match secret_store::get(&app, PROBE_ACCOUNT)? {
Some(ref got) if got == PROBE_SECRET => Ok(backend),
Some(_) => Err("read back a different value than was written".into()),
None => Err("the store reported a successful write but returned no entry".into()),
}
});
// Always clean up, including when the probe failed part-way through.
if let Err(e) = secret_store::delete(&app, PROBE_ACCOUNT) {
log::warn!("failed to remove credential store probe entry: {e}");
}
});
// Always clean up, including when the probe failed part-way through.
if let Err(e) = secret_store::delete(&app, PROBE_ACCOUNT) {
log::warn!("failed to remove credential store probe entry: {e}");
}
match result {
Ok(backend) => {
log::info!("credential store probe succeeded (backend: {backend:?})");
CredentialStoreProbe {
ok: true,
backend: Some(backend),
error: None,
match result {
Ok(backend) => {
log::info!("credential store probe succeeded (backend: {backend:?})");
CredentialStoreProbe {
ok: true,
backend: Some(backend),
error: None,
}
}
Err(e) => {
log::error!("credential store probe failed: {e}");
CredentialStoreProbe {
ok: false,
backend: None,
error: Some(e),
}
}
}
Err(e) => {
log::error!("credential store probe failed: {e}");
CredentialStoreProbe {
ok: false,
backend: None,
error: Some(e),
}
}
}
})
}
// ---------------------------------------------------------------------------
@@ -347,14 +398,60 @@ mod tests {
}
#[test]
fn credential_data_skips_password_in_json() {
fn credential_data_serializes_password_for_prefill() {
let data = CredentialData {
username: "alice".into(),
token: "tok".into(),
password: Some("pw".into()),
};
let json = serde_json::to_string(&data).unwrap();
assert!(!json.contains("password"));
assert!(!json.contains("pw"));
assert!(json.contains("password"));
assert!(json.contains("pw"));
}
/// B4-3 follow-up: all 7 commands moved to `#[tauri::command(async)]`,
/// which runs each invocation on the async runtime's thread pool instead
/// of Tauri's single IPC main thread. Two overlapping invocations (e.g.
/// `identity.ts`'s save-then-delete legacy-key migration, or a logout's
/// `delete_credential` racing a connect-page auto-login's
/// `load_credential` for the same host) can now genuinely run
/// concurrently. `with_credential_lock` must serialize them: this proves
/// no two holders of the lock ever run their critical section at the
/// same time, regardless of which OS thread the runtime schedules them
/// on.
#[test]
fn credential_lock_serializes_overlapping_commands() {
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::Arc;
use std::thread;
use std::time::Duration;
let concurrent = Arc::new(AtomicUsize::new(0));
let max_concurrent = Arc::new(AtomicUsize::new(0));
let handles: Vec<_> = (0..8)
.map(|_| {
let concurrent = Arc::clone(&concurrent);
let max_concurrent = Arc::clone(&max_concurrent);
thread::spawn(move || {
with_credential_lock(|| {
let now = concurrent.fetch_add(1, Ordering::SeqCst) + 1;
max_concurrent.fetch_max(now, Ordering::SeqCst);
thread::sleep(Duration::from_millis(5));
concurrent.fetch_sub(1, Ordering::SeqCst);
});
})
})
.collect();
for h in handles {
h.join().unwrap();
}
assert_eq!(
max_concurrent.load(Ordering::SeqCst),
1,
"two credential-store commands ran their critical section concurrently"
);
}
}
@@ -67,12 +67,9 @@ pub fn load_or_create_key(dir: &Path) -> Result<[u8; KEY_LEN], String> {
options.mode(0o600);
}
match options.open(&path) {
Ok(mut file) => {
file.write_all(&key)
.and_then(|()| file.sync_all())
.map_err(|e| format!("failed to write credential fallback key: {e}"))?;
Ok(key)
}
Ok(mut file) => finish_new_key_file(&path, key, || {
file.write_all(&key).and_then(|()| file.sync_all())
}),
// Lost the create race to another thread — use the winner's key.
Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {
let bytes = fs::read(&path)
@@ -86,6 +83,28 @@ pub fn load_or_create_key(dir: &Path) -> Result<[u8; KEY_LEN], String> {
}
}
/// Finish writing a just-created (empty) key file: run `write_and_sync` — the
/// real write_all + sync_all in production, injected here so the failure
/// path is testable without forcing a genuine disk-full/IO error — and
/// delete the file again if it fails.
///
/// `create_new` above already created `path` with zero bytes in it. Left
/// behind, a write/sync failure leaves a short file that every future
/// `load_or_create_key` call reads back and rejects forever (see this
/// function's doc comment: "never rewritten once it exists") — silently
/// poisoning the fallback store on the first ENOSPC/IO hiccup.
fn finish_new_key_file(
path: &Path,
key: [u8; KEY_LEN],
write_and_sync: impl FnOnce() -> std::io::Result<()>,
) -> Result<[u8; KEY_LEN], String> {
if let Err(e) = write_and_sync() {
let _ = fs::remove_file(path);
return Err(format!("failed to write credential fallback key: {e}"));
}
Ok(key)
}
/// Seal `plaintext` under `key`, binding `aad` (the service + account name).
///
/// Output layout: `nonce (12 bytes) || ciphertext || tag`. The nonce is random
@@ -217,6 +236,34 @@ mod tests {
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn removes_the_partial_file_when_the_write_fails() {
// A crash / ENOSPC mid-write must not leave a short file behind:
// load_or_create_key's doc comment says the key file is "never
// rewritten once it exists", so a poisoned short file is permanent —
// every future load fails the length check forever.
let dir = std::env::temp_dir().join(format!(
"owncord-fallback-partial-write-test-{}",
std::process::id()
));
let _ = fs::remove_dir_all(&dir);
fs::create_dir_all(&dir).unwrap();
let path = dir.join(CREDENTIAL_FALLBACK_KEY_FILE);
// `create_new` in load_or_create_key already created this empty file
// before the write step (which is what's under test) runs.
fs::write(&path, b"").unwrap();
let err = finish_new_key_file(&path, [7u8; KEY_LEN], || {
Err(std::io::Error::other("disk full"))
})
.unwrap_err();
assert!(err.contains("failed to write"), "unexpected error: {err}");
assert!(!path.exists(), "a failed write must not leave a partial key file behind");
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn rejects_a_corrupt_key_file() {
let dir = std::env::temp_dir().join(format!(
+216 -16
View File
@@ -33,7 +33,7 @@ use std::collections::HashMap;
use std::net::IpAddr;
use std::sync::Arc;
use rustls::pki_types::ServerName;
use tauri::{AppHandle, Runtime};
use tauri::{AppHandle, Manager, Runtime};
use tokio::io::{self, AsyncReadExt, AsyncWriteExt};
use tokio::net::{TcpListener, TcpStream};
use tokio::sync::Mutex;
@@ -57,6 +57,18 @@ impl HttpProxyState {
inner: Mutex::new(HashMap::new()),
}
}
/// Remove the `remote_host` entry, but only if it still points at `port`.
/// Used by `run_proxy_loop`'s accept-error exit path to deregister a dead
/// tunnel without racing a newer tunnel that may have already replaced it
/// (e.g. `stop_http_proxy` + a fresh `start_http_proxy` while this loop
/// was mid-shutdown).
async fn remove_if_port_matches(&self, remote_host: &str, port: u16) {
let mut inner = self.inner.lock().await;
if inner.get(remote_host).is_some_and(|entry| entry.port == port) {
inner.remove(remote_host);
}
}
}
/// Validate a remote host string before it is used in header rewriting and
@@ -111,6 +123,7 @@ pub async fn start_http_proxy<R: Runtime>(
app.clone(),
listener,
remote_host.clone(),
port,
shutdown_rx,
));
// Watch the loop so a panic is logged instead of vanishing silently (which
@@ -161,6 +174,7 @@ async fn run_proxy_loop<R: Runtime>(
app: AppHandle<R>,
listener: TcpListener,
remote_host: String,
port: u16,
mut shutdown_rx: tokio::sync::oneshot::Receiver<()>,
) {
let mut consecutive_errors: u32 = 0;
@@ -191,6 +205,21 @@ async fn run_proxy_loop<R: Runtime>(
"[http_proxy] {} consecutive accept errors, stopping proxy loop",
MAX_CONSECUTIVE_ACCEPT_ERRORS
);
// Deregister the dead tunnel BEFORE the break drops
// `listener`, so a future start_http_proxy rebinds a
// fresh port instead of handing back this closed one
// forever. Doing it here rather than after the loop
// returns matters: the listener still holds the port,
// so no newer tunnel can have been handed the same
// number and the port guard cannot misfire.
if let Some(state) = app.try_state::<HttpProxyState>() {
state.remove_if_port_matches(&remote_host, port).await;
} else {
warn!(
"[http_proxy] state unmanaged; cannot deregister dead tunnel for {}",
remote_host
);
}
break;
}
}
@@ -243,6 +272,51 @@ fn rewrite_request_headers(raw: &[u8], remote_host: &str) -> String {
modified
}
/// Bracket-aware split of a `remote_host` string into (hostname, port).
/// Defaults to port 443 (standard HTTPS) when none is specified.
///
/// A leading `[` consumes up to the matching `]` as the hostname, so a
/// bracketed IPv6 literal parses correctly whether or not it carries an
/// explicit port (`[::1]`, `[::1]:8443`). Without brackets, a single
/// trailing colon is a `host:port` split — but a *bare* (unbracketed) IPv6
/// literal contains more than one colon, and RFC 3986 gives it no way to
/// carry a port without brackets, so that case is returned whole with the
/// default port instead of being mis-split on its last colon.
fn split_host_port(remote_host: &str) -> Result<(&str, &str), String> {
if let Some(rest) = remote_host.strip_prefix('[') {
let (host, tail) = rest
.split_once(']')
.ok_or_else(|| format!("unterminated '[' in remote_host '{remote_host}'"))?;
let port = tail.strip_prefix(':').unwrap_or("443");
Ok((host, port))
} else {
match remote_host.rsplit_once(':') {
Some((host, port)) if !host.contains(':') => Ok((host, port)),
_ => Ok((remote_host, "443")),
}
}
}
/// Derive the TLS `ServerName` (SNI) and the TCP dial target from a
/// `remote_host` string. Mirrors `livekit_proxy::parse_server_name`'s
/// bracket handling.
fn resolve_remote_target(remote_host: &str) -> Result<(ServerName<'static>, String), String> {
let (hostname, port) = split_host_port(remote_host)?;
let server_name = if let Ok(ip) = hostname.parse::<IpAddr>() {
ServerName::IpAddress(ip.into())
} else {
ServerName::try_from(hostname.to_string())
.map_err(|e| format!("invalid server name '{hostname}': {e}"))?
};
let dial_target = if hostname.contains(':') {
format!("[{hostname}]:{port}")
} else {
format!("{hostname}:{port}")
};
Ok((server_name, dial_target))
}
/// Handle one proxied connection:
/// 1. Read the request headers from the loopback side
/// 2. TLS-connect to the remote and run the TOFU check (store/emit/reject)
@@ -292,20 +366,7 @@ async fn handle_connection<R: Runtime>(
.with_no_client_auth();
let connector = tokio_rustls::TlsConnector::from(Arc::new(tls_config));
let (raw_hostname, _port) = remote_host.rsplit_once(':').unwrap_or((remote_host, "443"));
let hostname = raw_hostname.trim_start_matches('[').trim_end_matches(']');
let server_name = if let Ok(ip) = hostname.parse::<IpAddr>() {
ServerName::IpAddress(ip.into())
} else {
ServerName::try_from(hostname.to_string())
.map_err(|e| format!("invalid server name '{hostname}': {e}"))?
};
let dial_target = if remote_host.contains(':') {
remote_host.to_string()
} else {
format!("{remote_host}:443")
};
let (server_name, dial_target) = resolve_remote_target(remote_host)?;
let tcp = timeout(Duration::from_secs(10), TcpStream::connect(&dial_target))
.await
.map_err(|_| Box::<dyn std::error::Error + Send + Sync>::from("TCP connect timed out"))??;
@@ -386,7 +447,7 @@ async fn handle_connection<R: Runtime>(
// ── 3. Forward request + bidirectional copy ──────────────────────────
tls.write_all(modified.as_bytes()).await?;
match io::copy_bidirectional(&mut local, &mut tls).await {
match copy_with_deadline(&mut local, &mut tls, DATA_PHASE_TIMEOUT).await {
Ok((to_remote, from_remote)) => {
debug!(
"[http_proxy] connection closed: {}B sent, {}B received",
@@ -400,6 +461,40 @@ async fn handle_connection<R: Runtime>(
Ok(())
}
/// Bound for the data-copy phase of a tunneled connection (step 3 above).
/// The header read, TCP connect, and TLS handshake phases all use a tight
/// 10s guard, but this phase carries the actual REST body — including
/// attachment/avatar uploads — so it needs a much more generous bound. 600s
/// only reclaims a connection that is genuinely stuck (e.g. a remote that
/// completes the TLS handshake and then neither responds nor closes), not
/// one that is merely slow.
const DATA_PHASE_TIMEOUT: Duration = Duration::from_secs(600);
/// Run `io::copy_bidirectional` under a deadline. Without this, a remote
/// that completes the TLS handshake and then stalls forever (neither
/// responding nor closing) parks the spawned connection task — and both the
/// loopback socket and the remote TLS session — indefinitely; closing the
/// local side alone does not free it, since `copy_bidirectional` only
/// resolves once BOTH directions finish. Generic over the stream types so it
/// can be unit-tested without a live TLS connection.
async fn copy_with_deadline<A, B>(
local: &mut A,
remote: &mut B,
dur: Duration,
) -> io::Result<(u64, u64)>
where
A: io::AsyncRead + io::AsyncWrite + Unpin + ?Sized,
B: io::AsyncRead + io::AsyncWrite + Unpin + ?Sized,
{
match timeout(dur, io::copy_bidirectional(local, remote)).await {
Ok(result) => result,
Err(_) => Err(io::Error::new(
io::ErrorKind::TimedOut,
"data phase timed out",
)),
}
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
@@ -408,6 +503,45 @@ async fn handle_connection<R: Runtime>(
mod tests {
use super::*;
// Regression: the accept-error exit path in run_proxy_loop must be able to
// deregister its own dead entry, but must NOT clobber a newer tunnel that
// has since replaced it under the same remote_host key.
#[tokio::test]
async fn remove_if_port_matches_removes_only_matching_entry() {
let state = HttpProxyState::new();
{
let (tx, _rx) = tokio::sync::oneshot::channel::<()>();
let mut inner = state.inner.lock().await;
inner.insert(
"example.com:8443".to_string(),
ProxyEntry {
port: 4242,
shutdown_tx: tx,
},
);
}
// A stale loop reporting a port that no longer matches the live
// entry must leave the current entry alone.
state
.remove_if_port_matches("example.com:8443", 9999)
.await;
assert_eq!(
state.inner.lock().await.get("example.com:8443").map(|e| e.port),
Some(4242),
"mismatched port must not remove a newer tunnel's entry"
);
// A loop reporting its own still-current port must remove it.
state
.remove_if_port_matches("example.com:8443", 4242)
.await;
assert!(
state.inner.lock().await.get("example.com:8443").is_none(),
"matching port must deregister the dead tunnel"
);
}
#[test]
fn validate_rejects_crlf_and_null() {
assert!(validate_remote_host("evil\r\nhost").is_err());
@@ -428,6 +562,41 @@ mod tests {
assert!(validate_remote_host("[::1]:8443").is_ok());
}
// OC-0021: IPv6 hosts that are not in the exact `[addr]:port` shape must
// still resolve to a valid ServerName and a dialable host:port target.
#[test]
fn resolve_remote_target_handles_bracketed_ipv6_without_port() {
let (server_name, dial_target) = resolve_remote_target("[2001:db8::1]")
.expect("bracketed IPv6 without a port must parse");
assert!(matches!(server_name, ServerName::IpAddress(_)));
assert_eq!(dial_target, "[2001:db8::1]:443");
}
#[test]
fn resolve_remote_target_handles_bare_ipv6_without_port() {
let (server_name, dial_target) =
resolve_remote_target("2001:db8::1").expect("bare IPv6 without a port must parse");
assert!(matches!(server_name, ServerName::IpAddress(_)));
assert_eq!(dial_target, "[2001:db8::1]:443");
}
#[test]
fn resolve_remote_target_still_handles_bracketed_ipv6_with_port() {
let (server_name, dial_target) = resolve_remote_target("[2001:db8::1]:8443")
.expect("bracketed IPv6 with a port must parse");
assert!(matches!(server_name, ServerName::IpAddress(_)));
assert_eq!(dial_target, "[2001:db8::1]:8443");
}
#[test]
fn resolve_remote_target_still_handles_plain_hostname_and_port() {
let (server_name, dial_target) =
resolve_remote_target("example.com:8443").expect("hostname:port must parse");
assert!(matches!(server_name, ServerName::DnsName(_)));
assert_eq!(dial_target, "example.com:8443");
}
#[test]
fn rewrite_replaces_host_and_forces_close() {
let raw = b"GET /api/v1/health HTTP/1.1\r\nHost: 127.0.0.1:5000\r\nAccept: */*\r\n\r\n";
@@ -457,4 +626,35 @@ mod tests {
assert!(out.contains("Content-Length: 2\r\n"));
assert!(out.ends_with("\r\n\r\n"));
}
// OC-0218: the data phase of a tunneled request (step 3 in
// `handle_connection`) must not be able to hang forever. A remote that
// completes the TLS handshake and then neither responds nor closes must
// eventually be reclaimed, the same way the header-read/connect/handshake
// phases already are (10s guards above). Simulate that stall with two
// in-memory duplex pairs where neither peer ever writes or disconnects,
// so raw `io::copy_bidirectional` would block forever.
#[tokio::test]
async fn copy_with_deadline_reclaims_a_stalled_connection() {
// Keep both "far" ends alive (bound, not `_`) so neither duplex half
// observes EOF — this is what makes the connection "stalled" rather
// than "closed".
let (mut local_near, _local_far) = tokio::io::duplex(64);
let (mut remote_near, _remote_far) = tokio::io::duplex(64);
// An outer safety bound: if `copy_with_deadline` does not honor its
// own deadline, fail fast instead of hanging the test suite forever.
let outcome = tokio::time::timeout(
Duration::from_secs(5),
copy_with_deadline(&mut local_near, &mut remote_near, Duration::from_millis(50)),
)
.await
.expect(
"copy_with_deadline must resolve on its own deadline; the data phase must not hang \
indefinitely on a stalled remote (OC-0218)",
);
let err = outcome.expect_err("a stalled remote must surface as a timeout error, not Ok");
assert_eq!(err.kind(), io::ErrorKind::TimedOut);
}
}
+1
View File
@@ -125,6 +125,7 @@ pub fn run() {
ptt::ptt_stop,
ptt::ptt_set_key,
ptt::ptt_get_key,
ptt::ptt_polling_supported,
ptt::ptt_listen_for_key,
livekit_proxy::start_livekit_proxy,
livekit_proxy::stop_livekit_proxy,
@@ -18,7 +18,9 @@
// - Certificate validation uses the TOFU-pinned fingerprint from ws_proxy.
// The WebSocket proxy must connect first to establish trust; the LiveKit
// proxy then pins to that same certificate. If the cert changes between
// WS and LiveKit connections, the LiveKit handshake will fail.
// WS and LiveKit connections, the LiveKit handshake will fail (fail
// closed) until the user accepts the new cert — each start call reloads
// the stored pin and restarts the listener when it changed.
// - Only one proxy instance runs at a time (per remote host). Connecting to
// a different server replaces the proxy. Stale proxy ports are not reused.
// - If the TcpListener errors (extremely unlikely on loopback), the cached
@@ -29,7 +31,7 @@ use log::{debug, error, info, warn};
use std::net::IpAddr;
use std::sync::Arc;
use rustls::pki_types::ServerName;
use tauri::Runtime;
use tauri::{Manager, Runtime};
use tokio::io::{self, AsyncReadExt, AsyncWriteExt};
use tokio::net::{TcpListener, TcpStream};
use tokio::sync::Mutex;
@@ -45,6 +47,9 @@ struct ProxyInner {
port: Option<u16>,
/// The remote host:port we're proxying to.
remote_host: String,
/// The TOFU fingerprint the running listener pins. Baked into the proxy
/// loop at spawn, so a re-pin in the cert store requires a restart.
pinned_fingerprint: String,
/// Shutdown signal sender.
shutdown_tx: Option<tokio::sync::oneshot::Sender<()>>,
}
@@ -55,10 +60,26 @@ impl LiveKitProxyState {
inner: Mutex::new(ProxyInner {
port: None,
remote_host: String::new(),
pinned_fingerprint: String::new(),
shutdown_tx: None,
}),
}
}
/// Clear the running-proxy state, but only if it still points at `port`.
/// Mirrors HttpProxyState::remove_if_port_matches; used by run_proxy_loop's
/// accept-error exit path so a dead listener doesn't keep being handed
/// back by start_livekit_proxy's reuse branch, and doesn't race a newer
/// proxy that may have already replaced it.
async fn clear_if_port_matches(&self, port: u16) {
let mut inner = self.inner.lock().await;
if inner.port == Some(port) {
inner.port = None;
inner.remote_host.clear();
inner.pinned_fingerprint.clear();
inner.shutdown_tx = None;
}
}
}
// ---------------------------------------------------------------------------
@@ -121,6 +142,21 @@ pub(crate) fn rewrite_proxy_headers(request: &str, remote_host: &str) -> String
modified
}
/// Decide whether an already-running proxy can serve a new start request:
/// only when both the remote host AND the TOFU-pinned fingerprint are
/// unchanged. The listener bakes its fingerprint in at spawn, so after the
/// user accepts a rotated cert (which rewrites the store), reusing the old
/// listener would fail every TLS handshake against the stale pin until
/// logout — the caller must tear down and restart instead.
pub(crate) fn can_reuse_proxy(
running_host: &str,
running_fingerprint: &str,
requested_host: &str,
stored_fingerprint: &str,
) -> bool {
running_host == requested_host && running_fingerprint == stored_fingerprint
}
/// Extract the TLS server name from a `host[:port]` string.
///
/// IPv6 literals arrive bracketed (`[::1]:8443`); the brackets are stripped and
@@ -162,24 +198,12 @@ pub async fn start_livekit_proxy<R: Runtime>(
info!("[livekit_proxy] start requested for {}", remote_host);
// Reuse existing proxy for same host.
if let Some(port) = inner.port {
if inner.remote_host == remote_host {
debug!("[livekit_proxy] reusing existing proxy on port {} for {}", port, remote_host);
return Ok(port);
}
// Different host — tear down old proxy.
info!("[livekit_proxy] stopping old proxy for {} (switching to {})", inner.remote_host, remote_host);
if let Some(tx) = inner.shutdown_tx.take() {
let _ = tx.send(());
}
inner.port = None;
}
// Load the TOFU-pinned fingerprint from the cert store. The ws_proxy must
// have connected first (establishing the TOFU trust), so the fingerprint
// should already be stored. If not, reject — we refuse to connect without
// a pinned cert.
// Load the TOFU-pinned fingerprint from the cert store BEFORE the reuse
// check — a running listener bakes its pin in at spawn, so a re-pin
// (user accepted a rotated cert) must force a restart, not a reuse. The
// ws_proxy must have connected first (establishing the TOFU trust), so
// the fingerprint should already be stored. If not, reject — we refuse
// to connect without a pinned cert.
let store_key = tofu::cert_store_key(&remote_host);
let fingerprint = tofu::load_stored_fingerprint(&app, &store_key)?
.ok_or_else(|| format!(
@@ -187,6 +211,23 @@ pub async fn start_livekit_proxy<R: Runtime>(
Connect via WebSocket first to establish TOFU trust."
))?;
// Reuse the existing proxy only when host AND pin are unchanged.
if let Some(port) = inner.port {
if can_reuse_proxy(&inner.remote_host, &inner.pinned_fingerprint, &remote_host, &fingerprint) {
debug!("[livekit_proxy] reusing existing proxy on port {} for {}", port, remote_host);
return Ok(port);
}
// Different host or re-pinned cert — tear down the old proxy.
info!(
"[livekit_proxy] stopping old proxy for {} (restarting for {})",
inner.remote_host, remote_host
);
if let Some(tx) = inner.shutdown_tx.take() {
let _ = tx.send(());
}
inner.port = None;
}
let listener = TcpListener::bind("127.0.0.1:0")
.await
.map_err(|e| format!("livekit proxy bind failed: {e}"))?;
@@ -198,7 +239,14 @@ pub async fn start_livekit_proxy<R: Runtime>(
let (shutdown_tx, shutdown_rx) = tokio::sync::oneshot::channel::<()>();
let host = remote_host.clone();
let loop_handle = tokio::spawn(run_proxy_loop(listener, host, fingerprint, shutdown_rx));
let loop_handle = tokio::spawn(run_proxy_loop(
app.clone(),
listener,
host,
port,
fingerprint.clone(),
shutdown_rx,
));
// Watch the loop so a panic is logged instead of vanishing silently.
tokio::spawn(async move {
match loop_handle.await {
@@ -212,6 +260,7 @@ pub async fn start_livekit_proxy<R: Runtime>(
inner.port = Some(port);
inner.remote_host = remote_host;
inner.pinned_fingerprint = fingerprint;
inner.shutdown_tx = Some(shutdown_tx);
Ok(port)
@@ -228,6 +277,7 @@ pub async fn stop_livekit_proxy(
}
inner.port = None;
inner.remote_host.clear();
inner.pinned_fingerprint.clear();
Ok(())
}
@@ -238,9 +288,11 @@ pub async fn stop_livekit_proxy(
/// Maximum consecutive accept errors before the proxy loop exits.
const MAX_CONSECUTIVE_ACCEPT_ERRORS: u32 = 5;
async fn run_proxy_loop(
async fn run_proxy_loop<R: Runtime>(
app: tauri::AppHandle<R>,
listener: TcpListener,
remote_host: String,
port: u16,
pinned_fingerprint: String,
mut shutdown_rx: tokio::sync::oneshot::Receiver<()>,
) {
@@ -272,6 +324,20 @@ async fn run_proxy_loop(
"[livekit_proxy] {} consecutive accept errors, stopping proxy loop",
MAX_CONSECUTIVE_ACCEPT_ERRORS
);
// Deregister the dead proxy BEFORE the break drops
// `listener`, so a future start_livekit_proxy
// rebinds a fresh port instead of handing back
// this closed one forever (the reuse branch keys
// only on host+pin, not liveness). Mirrors
// http_proxy.rs's identical fix.
if let Some(state) = app.try_state::<LiveKitProxyState>() {
state.clear_if_port_matches(port).await;
} else {
warn!(
"[livekit_proxy] state unmanaged; cannot deregister dead proxy for {}",
remote_host
);
}
break;
}
}
@@ -282,6 +348,35 @@ async fn run_proxy_loop(
}
}
/// Bound on the outbound dial and TLS handshake, matching http_proxy.rs.
const PROXY_CONNECT_TIMEOUT: Duration = Duration::from_secs(10);
/// Dial `remote_host` and complete the TLS handshake, bounding each step by
/// `limit`.
///
/// Both steps must be bounded. A peer that accepts the TCP connection and then
/// never answers the ClientHello blocks the handshake forever, and the calling
/// task holds `local` without polling it — so the LiveKit SDK closing its side
/// never cancels it. Those tasks and their sockets accumulate on every SDK
/// retry and survive stop_livekit_proxy, whose shutdown oneshot only stops the
/// accept loop; the per-connection tasks are detached.
async fn connect_tls(
connector: &tokio_rustls::TlsConnector,
server_name: ServerName<'static>,
remote_host: &str,
limit: Duration,
) -> Result<tokio_rustls::client::TlsStream<TcpStream>, Box<dyn std::error::Error + Send + Sync>> {
debug!("[livekit_proxy] connecting TCP to {}", remote_host);
let tcp = timeout(limit, TcpStream::connect(remote_host))
.await
.map_err(|_| Box::<dyn std::error::Error + Send + Sync>::from("TCP connect timed out"))??;
debug!("[livekit_proxy] starting TLS handshake with {}", remote_host);
let tls = timeout(limit, connector.connect(server_name, tcp))
.await
.map_err(|_| Box::<dyn std::error::Error + Send + Sync>::from("TLS handshake timed out"))??;
Ok(tls)
}
/// Handle a single proxied connection:
/// 1. Read the HTTP request headers from the local (plain) side
/// 2. Rewrite Host/Origin so the remote server accepts the connection
@@ -344,10 +439,7 @@ async fn handle_connection(
let server_name = parse_server_name(remote_host)?;
debug!("[livekit_proxy] connecting TCP to {}", remote_host);
let tcp = TcpStream::connect(remote_host).await?;
debug!("[livekit_proxy] starting TLS handshake with {}", remote_host);
let mut tls = connector.connect(server_name, tcp).await?;
let mut tls = connect_tls(&connector, server_name, remote_host, PROXY_CONNECT_TIMEOUT).await?;
debug!("[livekit_proxy] TLS handshake complete, forwarding traffic");
// ── 4. Forward request + bidirectional copy ──────────────────────────
@@ -431,6 +523,27 @@ mod tests {
assert!(validate_remote_host("").is_ok());
}
// ── can_reuse_proxy ─────────────────────────────────────────────────────
#[test]
fn reuses_proxy_only_when_host_and_pin_are_unchanged() {
assert!(can_reuse_proxy("example.com:443", "aa:bb", "example.com:443", "aa:bb"));
}
#[test]
fn restarts_proxy_when_host_changes() {
assert!(!can_reuse_proxy("old.example:443", "aa:bb", "new.example:443", "aa:bb"));
}
#[test]
fn restarts_proxy_when_pin_changes() {
// The user accepted a rotated cert (accept_cert_fingerprint rewrote the
// store). The running listener still pins the old fingerprint, so every
// connection through it would fail the TLS handshake — reuse must be
// refused so the caller tears down and restarts with the new pin.
assert!(!can_reuse_proxy("example.com:443", "aa:bb", "example.com:443", "cc:dd"));
}
// ── rewrite_proxy_headers ───────────────────────────────────────────────
#[test]
@@ -558,4 +671,86 @@ mod tests {
fn rejects_an_invalid_dns_name() {
assert!(parse_server_name("not a hostname").is_err());
}
// A peer that accepts the TCP connection and then answers nothing must not
// hang the connection task forever — see connect_tls.
#[tokio::test]
async fn tls_handshake_is_bounded_by_its_timeout() {
let listener = TcpListener::bind("127.0.0.1:0").await.expect("bind");
let addr = listener.local_addr().expect("local_addr");
tokio::spawn(async move {
let _accepted = listener.accept().await.expect("accept");
// Hold the connection open, answering nothing.
std::future::pending::<()>().await;
});
let tls_config = rustls::ClientConfig::builder()
.dangerous()
.with_custom_certificate_verifier(Arc::new(tofu::PinnedVerifier::new(
"aa:bb:cc".to_string(),
)))
.with_no_client_auth();
let connector = tokio_rustls::TlsConnector::from(Arc::new(tls_config));
let server_name = ServerName::try_from("localhost").expect("server name");
// The outer bound exists only so a regression fails fast instead of
// hanging the suite; the assertion is that the inner limit fired.
let outcome = timeout(
Duration::from_secs(5),
connect_tls(
&connector,
server_name,
&addr.to_string(),
Duration::from_millis(100),
),
)
.await;
assert!(
outcome.is_ok(),
"connect_tls hung: the TLS handshake is not bounded by its own timeout"
);
assert!(
outcome.expect("bounded").is_err(),
"a silent peer must produce an error, not a usable TLS stream"
);
}
// ── LiveKitProxyState::clear_if_port_matches ────────────────────────────
//
// B4_conn_ipc-7: run_proxy_loop's accept-error exit path drops the
// listener without deregistering it, so ProxyInner.port stays set and
// start_livekit_proxy's reuse branch (unchanged host+pin) hands the dead
// port back forever. Mirrors http_proxy.rs's
// remove_if_port_matches_removes_only_matching_entry test.
#[tokio::test]
async fn clear_if_port_matches_clears_only_a_matching_entry() {
let state = LiveKitProxyState::new();
{
let (tx, _rx) = tokio::sync::oneshot::channel::<()>();
let mut inner = state.inner.lock().await;
inner.port = Some(4242);
inner.remote_host = "example.com:8443".to_string();
inner.pinned_fingerprint = "aa:bb".to_string();
inner.shutdown_tx = Some(tx);
}
// A stale loop reporting a port that no longer matches the live
// listener must leave the current entry alone.
state.clear_if_port_matches(9999).await;
assert_eq!(
state.inner.lock().await.port,
Some(4242),
"mismatched port must not clear a newer proxy's state"
);
// A loop reporting its own still-current port must clear it so the
// next start_livekit_proxy rebinds instead of reusing the dead listener.
state.clear_if_port_matches(4242).await;
let inner = state.inner.lock().await;
assert_eq!(inner.port, None, "matching port must deregister the dead proxy");
assert!(inner.remote_host.is_empty());
assert!(inner.pinned_fingerprint.is_empty());
}
}
+78 -6
View File
@@ -298,10 +298,55 @@ mod linux {
}
}
/// Decide whether the polling loop must emit a `ptt-state` event this tick.
///
/// Returns `Some(new_state)` on a press/release edge, `None` when nothing
/// changed.
///
/// The `vk == 0` (unbound) case is folded into `pressed` here rather than
/// guarding the whole tick: clearing the binding while the key is physically
/// held must still produce the `true -> false` falling edge. With the guard
/// outside, `was_pressed` freezes at `true`, no final `ptt-state=false` is
/// ever emitted, and the microphone stays published.
fn ptt_transition(vk: i32, key_down: bool, was_pressed: bool) -> Option<bool> {
let pressed = vk != 0 && key_down;
(pressed != was_pressed).then_some(pressed)
}
// ---------------------------------------------------------------------------
// Tauri commands
// ---------------------------------------------------------------------------
/// Whether this platform can actually observe global key state, i.e. whether
/// the polling loop can ever emit a `ptt-state` event.
///
/// `ptt_start` spawns its thread unconditionally, so a live thread is NOT
/// evidence that PTT works: on macOS `is_key_down` is a compile-time stub that
/// always returns false, and on a pure-Wayland Linux session
/// `DeviceState::checked_new()` returns None. The frontend gates its join-time
/// PTT mute on this, because muting at join where no event can ever arrive
/// would close the microphone for the whole session with no way to reopen it.
#[tauri::command]
pub fn ptt_polling_supported() -> bool {
#[cfg(windows)]
{
true
}
#[cfg(target_os = "linux")]
{
use device_query::DeviceState;
// Mirrors the availability check inside `is_key_down`: no reachable
// X11/XWayland display means key state is never observable.
DeviceState::checked_new().is_some()
}
#[cfg(not(any(windows, target_os = "linux")))]
{
false
}
}
/// Start the PTT polling loop. Emits `ptt-state` (bool) events.
///
/// Uses `PTT_THREAD`'s Mutex as the critical section to prevent duplicate
@@ -329,12 +374,14 @@ pub fn ptt_start<R: Runtime>(app: AppHandle<R>) {
while !thread_shutdown.load(Ordering::SeqCst) {
let vk = PTT_VKEY.load(Ordering::SeqCst);
if vk != 0 {
let pressed = is_key_down(vk);
if pressed != was_pressed {
was_pressed = pressed;
let _ = app.emit("ptt-state", pressed);
}
// Evaluated on every tick, including vk == 0: clearing the PTT
// key while it is physically held must still produce a falling
// edge, otherwise was_pressed freezes at true and the mic never
// gets its final `ptt-state=false`. `is_key_down` short-circuits
// to false for vk == 0 on every platform, so this costs nothing.
if let Some(pressed) = ptt_transition(vk, is_key_down(vk), was_pressed) {
was_pressed = pressed;
let _ = app.emit("ptt-state", pressed);
}
std::thread::sleep(Duration::from_millis(20));
}
@@ -523,6 +570,31 @@ mod tests {
assert!(g.is_none(), "slot must stay empty when nothing was running");
}
// The loop must emit only on edges, never on every tick — a repeat emit
// would re-run the mute logic (and its user-mute guard) 50x/second.
#[test]
fn ptt_transition_reports_edges_only() {
assert_eq!(ptt_transition(0x41, true, false), Some(true), "rising edge");
assert_eq!(ptt_transition(0x41, true, true), None, "still held");
assert_eq!(ptt_transition(0x41, false, true), Some(false), "falling edge");
assert_eq!(ptt_transition(0x41, false, false), None, "still idle");
}
// Regression for the "hot mic after Clear while the PTT key is held" bug:
// clearing the binding (vk -> 0) with the key still physically down must
// still yield the falling edge that emits the final ptt-state=false. The
// old loop wrapped the whole comparison in `if vk != 0`, so this case
// produced no transition at all and the mic stayed published.
#[test]
fn ptt_transition_emits_release_when_binding_cleared_while_key_held() {
assert_eq!(ptt_transition(0, true, true), Some(false));
// The release is reported once, then the unbound key stays quiet — an
// unbound key must never read as pressed no matter what the raw
// key-down probe says.
assert_eq!(ptt_transition(0, true, false), None);
assert_eq!(ptt_transition(0, false, false), None);
}
#[test]
fn allowed_capture_vk_accepts_safe_non_text_keys() {
assert!(is_allowed_ptt_capture_vk(0x70)); // F1
+221 -16
View File
@@ -92,6 +92,33 @@ const FALLBACK_BACKEND: Backend = Backend::EncryptedFile;
/// secret — the caller's in-memory copy is all that is left, so the current
/// session still works but nothing survives a restart.
pub fn set(app: &AppHandle, account: &str, secret: &str) -> Result<Backend, String> {
set_with(
account,
secret,
keyring_set,
keyring_get,
keyring_delete,
|acct, sec| set_fallback(app, acct, sec),
// Best-effort here: the keyring copy just proved it round-trips, so
// it is authoritative regardless of whether the stale fallback copy
// actually got flushed off disk.
|acct| {
let _ = clear_fallback(app, acct);
},
)
}
/// Core decision logic for [`set`], with the keyring and fallback operations
/// injected so the branching is testable without a live OS credential store.
fn set_with(
account: &str,
secret: &str,
keyring_set: impl Fn(&str, &str) -> Result<(), String>,
keyring_get: impl Fn(&str) -> Result<Option<String>, String>,
keyring_delete: impl Fn(&str) -> Result<(), String>,
fallback_set: impl FnOnce(&str, &str) -> Result<(), String>,
fallback_clear: impl FnOnce(&str),
) -> Result<Backend, String> {
match keyring_set(account, secret) {
Ok(()) => match keyring_get(account) {
// The normal path: written and read back byte-for-byte.
@@ -99,7 +126,7 @@ pub fn set(app: &AppHandle, account: &str, secret: &str) -> Result<Backend, Stri
// A machine that was previously degraded and has since been
// fixed must not keep a stale ciphertext shadowing the real
// store on the next read.
clear_fallback(app, account);
fallback_clear(account);
return Ok(Backend::Keyring);
}
Ok(Some(_)) => {
@@ -127,10 +154,23 @@ pub fn set(app: &AppHandle, account: &str, secret: &str) -> Result<Backend, Stri
but the read-back failed: {e} — falling back"
),
},
Err(e) => log::error!("{SERVICE}: credential store write failed for '{account}': {e}"),
Err(e) => {
log::error!("{SERVICE}: credential store write failed for '{account}': {e}");
// An older secret may already sit in the keyring from a prior
// successful write. get() reads the keyring first, so leaving
// that stale entry in place would shadow the fresh secret parked
// in the fallback below — mirrors the read-back-mismatch arm
// above, which purges for the same reason.
if let Err(de) = keyring_delete(account) {
log::warn!(
"{SERVICE}: could not remove a stale keyring entry for '{account}' after a \
failed write: {de}"
);
}
}
}
set_fallback(app, account, secret)?;
fallback_set(account, secret)?;
log::warn!(
"{SERVICE}: account '{account}' is stored in the encrypted fallback file, not the OS \
credential store. See docs/credential-storage.md"
@@ -143,12 +183,34 @@ pub fn set(app: &AppHandle, account: &str, secret: &str) -> Result<Backend, Stri
/// The OS credential store wins over the fallback file, so a machine that
/// recovers goes back to the real store without any migration step.
pub fn get(app: &AppHandle, account: &str) -> Result<Option<String>, String> {
get_with(account, keyring_get, |acct| get_fallback(app, acct))
}
/// Core decision logic for [`get`], with the keyring and fallback lookups
/// injected so the branching is testable without a live OS credential store.
fn get_with(
account: &str,
keyring_get: impl Fn(&str) -> Result<Option<String>, String>,
get_fallback: impl Fn(&str) -> Option<String>,
) -> Result<Option<String>, String> {
match keyring_get(account) {
Ok(Some(secret)) => return Ok(Some(secret)),
Ok(None) => {}
Err(e) => log::warn!("{SERVICE}: credential store read failed for '{account}': {e}"),
Ok(Some(secret)) => Ok(Some(secret)),
Ok(None) => Ok(get_fallback(account)),
Err(e) => {
log::warn!("{SERVICE}: credential store read failed for '{account}': {e}");
// A read error must not collapse to "nothing stored": on a
// healthy machine set() clears the fallback on every successful
// write, so an empty fallback here is indistinguishable from
// "never stored". Prefer a fallback copy if one exists; only
// report "nothing" when both stores genuinely have nothing, and
// otherwise propagate the error so the caller can tell a broken
// store apart from first login.
match get_fallback(account) {
Some(secret) => Ok(Some(secret)),
None => Err(e),
}
}
}
Ok(get_fallback(app, account))
}
/// Remove `account` from every store. Absent entries are not an error.
@@ -156,9 +218,23 @@ pub fn get(app: &AppHandle, account: &str) -> Result<Option<String>, String> {
/// Both stores are cleared even if one errors: a delete that left the fallback
/// copy behind would resurrect a "deleted" secret on the next read.
pub fn delete(app: &AppHandle, account: &str) -> Result<(), String> {
delete_with(account, keyring_delete, |acct| clear_fallback(app, acct))
}
/// Core decision logic for [`delete`], with the keyring and fallback removals
/// injected so the branching is testable without a live OS credential store.
fn delete_with(
account: &str,
keyring_delete: impl Fn(&str) -> Result<(), String>,
fallback_clear: impl FnOnce(&str) -> Result<(), String>,
) -> Result<(), String> {
let keyring_result = keyring_delete(account);
clear_fallback(app, account);
keyring_result
// `Result::and`'s argument is evaluated eagerly, so `fallback_clear` runs
// regardless of whether the keyring delete succeeded — both stores are
// still cleared even if one errors. Whichever side failed is what gets
// reported: a delete must not read as Ok(()) while either store still
// holds the "deleted" secret.
keyring_result.and(fallback_clear(account))
}
// ---------------------------------------------------------------------------
@@ -338,19 +414,27 @@ fn get_fallback(app: &AppHandle, account: &str) -> Option<String> {
.ok()
}
/// Drop any fallback copy of `account`. Best-effort: a failure here is logged,
/// never propagated, because it must not mask the outcome of the real store.
fn clear_fallback(app: &AppHandle, account: &str) {
let Ok(store) = app.store(CREDENTIAL_FALLBACK_STORE) else {
return;
};
/// Drop any fallback copy of `account`, flushing the removal to disk.
///
/// Returns the flush error to the caller instead of only logging it: a
/// `delete()` that reported success while this failed to flush would leave
/// the sealed secret on disk to resurrect the "deleted" credential on the
/// next read. Callers where the keyring copy is authoritative (a `set()`
/// recovering from a stale fallback) may still discard the `Err` themselves.
fn clear_fallback(app: &AppHandle, account: &str) -> Result<(), String> {
let store = app
.store(CREDENTIAL_FALLBACK_STORE)
.map_err(|e| format!("failed to open credential fallback store: {e}"))?;
// `delete` reports whether a key was present; only flush when one was, so
// the common healthy path does not rewrite the file on every save.
if store.delete(account) {
if let Err(e) = store.save() {
log::warn!("failed to flush credential fallback removal for '{account}': {e}");
return Err(format!(
"failed to flush credential fallback removal for '{account}': {e}"
));
}
}
Ok(())
}
// ---------------------------------------------------------------------------
@@ -413,6 +497,127 @@ mod tests {
assert_eq!(fallback_aad("host.example"), fallback_aad("host.example"));
}
// -- get_with: finding "a keyring read error must not read as 'not stored'" --
#[test]
fn get_with_falls_back_when_the_keyring_errors_but_the_fallback_has_a_copy() {
let result = get_with(
"identity:chat.example",
|_| Err("keychain locked".to_string()),
|_| Some("fallback-secret".to_string()),
);
assert_eq!(result, Ok(Some("fallback-secret".to_string())));
}
#[test]
fn get_with_propagates_the_keyring_error_when_the_fallback_is_also_empty() {
// The bug: a keyring read failure must never be reported as "nothing
// stored" (Ok(None)) when the fallback is empty too — that is
// indistinguishable from first login, and the E2EE identity keypair
// loader mints and publishes a brand-new identity key on exactly that
// signal, invalidating every peer's TOFU pin.
let result = get_with("identity:chat.example", |_| Err("keychain locked".to_string()), |_| None);
assert_eq!(result, Err("keychain locked".to_string()));
}
#[test]
fn get_with_prefers_the_live_keyring_value_over_the_fallback() {
let result = get_with("acct", |_| Ok(Some("live".to_string())), |_| Some("stale".to_string()));
assert_eq!(result, Ok(Some("live".to_string())));
}
#[test]
fn get_with_uses_the_fallback_when_the_keyring_has_nothing_stored() {
let result = get_with("acct", |_| Ok(None), |_| Some("fallback".to_string()));
assert_eq!(result, Ok(Some("fallback".to_string())));
}
// -- set_with: finding "a failed keyring write must not leave a stale entry" --
#[test]
fn set_with_deletes_any_stale_keyring_entry_when_the_write_fails() {
// The bug: a write failure with an older secret already sitting in
// the keyring from a prior successful write must not leave that
// stale entry in place — get() reads the keyring first, so it would
// shadow the fresh secret parked in the fallback below forever.
use std::cell::Cell;
let delete_called = Cell::new(false);
let result = set_with(
"acct",
"new-secret",
|_, _| Err("write failed".to_string()),
|_| panic!("keyring_get must not run after a failed write"),
|_| {
delete_called.set(true);
Ok(())
},
|_, _| Ok(()),
|_| {},
);
assert_eq!(result, Ok(FALLBACK_BACKEND));
assert!(
delete_called.get(),
"a failed keyring write must delete any stale prior entry before falling back"
);
}
#[test]
fn set_with_returns_keyring_backend_when_the_write_round_trips() {
use std::cell::Cell;
let cleared = Cell::new(false);
let result = set_with(
"acct",
"secret",
|_, s| {
assert_eq!(s, "secret");
Ok(())
},
|_| Ok(Some("secret".to_string())),
|_| panic!("must not delete a keyring entry that round-tripped"),
|_, _| panic!("must not touch the fallback on a successful round trip"),
|_| cleared.set(true),
);
assert_eq!(result, Ok(Backend::Keyring));
assert!(cleared.get(), "a recovered machine must clear any stale fallback copy");
}
// -- delete_with: finding "delete must not report success while the
// fallback copy survives on disk to resurrect a deleted secret" --
#[test]
fn delete_with_propagates_a_fallback_flush_failure() {
// The bug: a delete that removed the keyring entry but failed to
// flush the fallback file's removal must not report Ok(()) — the
// sealed secret is still on disk and comes back on the next launch.
let result = delete_with("acct", |_| Ok(()), |_| Err("disk full".to_string()));
assert_eq!(result, Err("disk full".to_string()));
}
#[test]
fn delete_with_clears_the_fallback_even_when_the_keyring_delete_fails() {
use std::cell::Cell;
let fallback_cleared = Cell::new(false);
let result = delete_with(
"acct",
|_| Err("keyring delete failed".to_string()),
|_| {
fallback_cleared.set(true);
Ok(())
},
);
assert_eq!(result, Err("keyring delete failed".to_string()));
assert!(
fallback_cleared.get(),
"delete must still clear the fallback even when the keyring delete errors"
);
}
#[test]
fn delete_with_succeeds_when_both_stores_clear() {
let result = delete_with("acct", |_| Ok(()), |_| Ok(()));
assert_eq!(result, Ok(()));
}
#[cfg(windows)]
#[test]
fn dpapi_round_trips_and_rejects_foreign_entropy() {
+20 -1
View File
@@ -283,8 +283,13 @@ impl rustls::client::danger::ServerCertVerifier for HostScopedVerifier {
/// Cert-store key for a host. Strips a default `:443` so the ws proxy (which
/// keys off `wss://host` with no explicit 443) and the http/livekit proxies
/// (which see `host:443`) resolve the SAME pin. Non-default ports are kept.
/// Case-folded (DNS names are case-insensitive): the host reaches this from
/// several places (a profile-entered host verbatim, a `wss://` URL, a URL
/// parsed on the TS side, which lowercases) — without folding case here, two
/// callers with the same server in different case would pin/read different
/// entries, opening a second, unpinned proxy tunnel.
pub(crate) fn cert_store_key(host: &str) -> String {
host.strip_suffix(":443").unwrap_or(host).to_string()
host.strip_suffix(":443").unwrap_or(host).to_ascii_lowercase()
}
/// Extract the host (with any non-default port) from a `wss://` URL.
@@ -390,6 +395,20 @@ mod tests {
assert_eq!(cert_store_key("example.com:8443"), "example.com:8443");
}
// DNS names are case-insensitive, but a raw host string (a profile-entered
// host, or one taken verbatim from a wss:// URL) is not normalized before
// reaching here. Two call sites can derive the SAME host in different
// case (e.g. login uses the host as typed, an attachment fetch resolves
// it through URL parsing, which lowercases) — without folding case here,
// they pin/read two different cert-store entries for the same server,
// opening a second, unpinned proxy tunnel.
#[test]
fn cert_store_key_folds_case() {
assert_eq!(cert_store_key("Example.COM"), "example.com");
assert_eq!(cert_store_key("MyServer.LAN:8443"), "myserver.lan:8443");
assert_eq!(cert_store_key("Example.COM:443"), "example.com");
}
#[test]
fn extract_host_variants() {
assert_eq!(extract_host("wss://example.com/chat"), "example.com");
+220 -27
View File
@@ -13,6 +13,7 @@
use futures_util::{SinkExt, StreamExt};
use log::{debug, error, info, warn};
use serde_json::Value;
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::Arc;
use std::time::Duration;
use tauri::{AppHandle, Emitter, Runtime};
@@ -32,14 +33,68 @@ use crate::tofu::{self, TofuOutcome};
/// into its closure and clear the sender even after a worker task panic.
pub struct WsState {
tx: Arc<Mutex<Option<mpsc::Sender<String>>>>,
/// Bumped once per `ws_connect` attempt. The handshake can pend for up to
/// CONNECT_TIMEOUT, and callers (profile switch) start a second connect
/// without awaiting the first, so an attempt must prove it is still the
/// current generation before it may touch the shared sender slot.
generation: Arc<AtomicU64>,
}
impl WsState {
pub fn new() -> Self {
Self {
tx: Arc::new(Mutex::new(None)),
generation: Arc::new(AtomicU64::new(0)),
}
}
/// Claim a generation for a new connection attempt, dropping any existing
/// sender. Every later step of that attempt is conditional on this value
/// still being current.
async fn begin_connection(&self) -> u64 {
let mut tx_lock = self.tx.lock().await;
if tx_lock.is_some() {
debug!("[ws_proxy] dropping existing connection");
}
*tx_lock = None;
self.generation.fetch_add(1, Ordering::SeqCst) + 1
}
/// Install `tx` as the live sender if `generation` is still current.
/// Returns false when a newer `ws_connect` superseded this attempt.
async fn install_sender(&self, generation: u64, tx: mpsc::Sender<String>) -> bool {
// Checked under the slot lock so the decision and the write cannot be
// split by a concurrent attempt.
let mut tx_lock = self.tx.lock().await;
if self.generation.load(Ordering::SeqCst) != generation {
return false;
}
*tx_lock = Some(tx);
true
}
}
/// Clear the live sender slot, but only if `my_generation` is still the
/// current connection generation. Returns false when a newer `ws_connect`
/// superseded this connection — that teardown must not clear the slot or
/// announce a close. Ownership is proven by generation, NOT by holding a
/// Sender clone: a clone kept alive in the monitor task would keep the
/// outbound channel open, so the write task could never observe closure
/// after `ws_disconnect` (circular wait — task, socket, and TLS session
/// would all leak). `generation` only advances inside `begin_connection`
/// while the slot lock is held, so checking it under the same lock makes
/// the check-and-clear atomic with respect to new attempts.
async fn clear_sender_if_current(
slot: &Mutex<Option<mpsc::Sender<String>>>,
generation: &AtomicU64,
my_generation: u64,
) -> bool {
let mut tx_lock = slot.lock().await;
if generation.load(Ordering::SeqCst) != my_generation {
return false;
}
*tx_lock = None;
true
}
/// Single call site for ws-state events — keeps tauri-typegen from generating duplicates.
@@ -69,14 +124,8 @@ pub async fn ws_connect<R: Runtime>(
) -> Result<(), String> {
info!("[ws_proxy] connecting to {}", url);
// Drop any existing connection
{
let mut tx_lock = state.tx.lock().await;
if tx_lock.is_some() {
debug!("[ws_proxy] dropping existing connection");
}
*tx_lock = None;
}
// Drop any existing connection and claim this attempt's generation.
let my_generation = state.begin_connection().await;
// Only allow secure WebSocket connections
if !url.starts_with("wss://") {
@@ -169,23 +218,26 @@ pub async fn ws_connect<R: Runtime>(
}
// ── End TOFU check ───────────────────────────────────────────────────
let (mut sink, mut stream) = ws_stream.split();
// Channel for JS → server messages (bounded for backpressure). The slot
// gets the ONLY Sender: teardown ownership is proven by generation, so no
// clone may outlive the slot — one would keep rx.recv() pending forever.
let (tx, mut rx) = mpsc::channel::<String>(256);
if !state.install_sender(my_generation, tx).await {
info!("[ws_proxy] handshake superseded by a newer connect; dropping stale socket");
return Err("superseded by a newer connection".into());
}
info!("[ws_proxy] connected to {}", host);
emit_ws_state(&app, "open");
let (mut sink, mut stream) = ws_stream.split();
// Channel for JS → server messages (bounded for backpressure)
let (tx, mut rx) = mpsc::channel::<String>(256);
{
let mut tx_lock = state.tx.lock().await;
*tx_lock = Some(tx);
}
let app_read = app.clone();
let app_state = app.clone();
// Clone the Arc so the monitoring closure can clear tx on any exit path,
// Clone the Arcs so the monitoring closure can clear tx on any exit path,
// including worker task panics, without needing tauri::State.
let tx_arc = Arc::clone(&state.tx);
let generation_arc = Arc::clone(&state.generation);
// Single outer task owns a JoinSet containing read and write workers.
// join_next() blocks until the first worker finishes (normally or via panic),
@@ -242,14 +294,16 @@ pub async fn ws_connect<R: Runtime>(
}
// Clear the sender so ws_send returns "not connected". This runs on
// every exit path — normal close, graceful disconnect, and panic.
{
let mut tx_lock = tx_arc.lock().await;
*tx_lock = None;
// every exit path — normal close, graceful disconnect, and panic — but
// only when this connection still owns the slot. Clearing
// unconditionally would kill a newer connection's sender and tell JS
// that the live connection had closed.
if clear_sender_if_current(&tx_arc, &generation_arc, my_generation).await {
// Always emit closed, even after a panic.
emit_ws_state(&app_state, "closed");
} else {
debug!("[ws_proxy] superseded connection torn down; leaving live sender in place");
}
// Always emit closed, even after a panic.
emit_ws_state(&app_state, "closed");
});
Ok(())
@@ -281,8 +335,13 @@ pub async fn ws_send(
/// Disconnect the proxy WebSocket.
#[tauri::command]
pub async fn ws_disconnect(state: tauri::State<'_, WsState>) -> Result<(), String> {
let mut tx_lock = state.tx.lock().await;
*tx_lock = None; // dropping the sender closes the channel → write task ends
// begin_connection() both clears the sender slot (dropping it closes the
// channel so the write task ends) AND bumps the generation counter, so a
// handshake still pending from before this disconnect fails install_sender
// instead of installing itself afterward — reusing the same invalidation
// path a superseding connect() already has. The returned generation is
// unused: nothing will ever install under it.
state.begin_connection().await;
Ok(())
}
@@ -427,4 +486,138 @@ mod tests {
let bad = format!("é{}", &VALID[..93]);
assert!(!is_valid_cert_fingerprint(&bad));
}
// ── Connection-generation ownership of the shared sender slot ───────────
//
// A handshake pends up to CONNECT_TIMEOUT, and a profile switch starts a
// second ws_connect without awaiting or cancelling the first, so two
// attempts can be in flight over one slot. Mirrors the ptt.rs
// ATOMICRACE-001 guard.
#[tokio::test]
async fn superseded_connect_does_not_take_the_sender_slot() {
let state = WsState::new();
// Connection A starts its handshake, then a profile switch starts B
// while A is still pending.
let gen_a = state.begin_connection().await;
let gen_b = state.begin_connection().await;
assert_ne!(gen_a, gen_b);
let (tx_b, _rx_b) = mpsc::channel::<String>(4);
assert!(
state.install_sender(gen_b, tx_b.clone()).await,
"the current generation must be able to install"
);
// A's handshake finally completes. Installing now would route the next
// auth send to the stale host and drop B's sender, ending B's write task.
let (tx_a, _rx_a) = mpsc::channel::<String>(4);
assert!(
!state.install_sender(gen_a, tx_a).await,
"a superseded attempt must not take the slot"
);
let slot = state.tx.lock().await;
assert!(
slot.as_ref().is_some_and(|t| t.same_channel(&tx_b)),
"the live connection's sender must still be installed"
);
}
// ── Generation-owned teardown ───────────────────────────────────────────
//
// Teardown ownership must be provable WITHOUT holding a Sender clone: any
// clone kept alive by the monitor task keeps the outbound channel open, so
// after ws_disconnect drops the slot's sender the write task never sees
// rx.recv() == None — writer, reader, and TLS socket all leak in a
// circular wait (monitor waits on writer, writer waits on the monitor's
// clone dropping).
#[tokio::test]
async fn owning_teardown_clears_the_slot_by_generation() {
let state = WsState::new();
let my_generation = state.begin_connection().await;
let (tx, _rx) = mpsc::channel::<String>(4);
state.install_sender(my_generation, tx).await;
assert!(
clear_sender_if_current(&state.tx, &state.generation, my_generation).await,
"the owning connection must clear its slot without a Sender clone"
);
assert!(
state.tx.lock().await.is_none(),
"ws_send must report not-connected after a real close"
);
}
#[tokio::test]
async fn superseded_teardown_by_generation_leaves_the_live_sender() {
let state = WsState::new();
let gen_a = state.begin_connection().await;
let (tx_a, _rx_a) = mpsc::channel::<String>(4);
state.install_sender(gen_a, tx_a).await;
let gen_b = state.begin_connection().await;
let (tx_b, _rx_b) = mpsc::channel::<String>(4);
assert!(state.install_sender(gen_b, tx_b.clone()).await);
// A's monitor task tears down after B is live. Clearing here would
// kill B's sender and emit "closed" while JS believes B is connected.
assert!(
!clear_sender_if_current(&state.tx, &state.generation, gen_a).await,
"a superseded teardown must not clear the slot or announce a close"
);
let slot = state.tx.lock().await;
assert!(
slot.as_ref().is_some_and(|t| t.same_channel(&tx_b)),
"the live connection's sender must survive a superseded teardown"
);
}
#[tokio::test]
async fn disconnect_closes_the_outbound_channel() {
// ws_disconnect's contract (the comment at its *tx_lock = None):
// dropping the slot's sender closes the channel so the write task
// ends. That holds only while install_sender receives the ONLY
// Sender — no teardown-ownership clone may exist.
let state = WsState::new();
let generation = state.begin_connection().await;
let (tx, mut rx) = mpsc::channel::<String>(4);
state.install_sender(generation, tx).await;
*state.tx.lock().await = None; // ws_disconnect
let got = tokio::time::timeout(Duration::from_secs(1), rx.recv())
.await
.expect("write task would hang forever: channel still open after disconnect");
assert_eq!(got, None, "rx.recv() must yield None so the write task exits");
}
// B4_conn_ipc-9: ws_disconnect must invalidate an in-flight ws_connect
// attempt, not just null the sender slot. A handshake can pend for up to
// CONNECT_TIMEOUT (10s) past a disconnect (JS calls connect fire-and-
// forget — logout during "connecting" is a real interleaving), and
// install_sender checks generation alone, so a manual `*tx_lock = None`
// leaves a "cancelled" connection free to install itself afterward and
// spawn its worker tasks against a socket JS believes closed.
#[tokio::test]
async fn disconnect_invalidates_an_in_flight_connect_attempt() {
let state = WsState::new();
// A's handshake is in flight: generation claimed, sender not yet
// installed (mirrors the pending window before install_sender runs).
let gen_a = state.begin_connection().await;
// ws_disconnect fires while A is still mid-handshake — this is
// ws_disconnect's real body (state.begin_connection().await).
state.begin_connection().await;
// A's handshake finally completes and tries to install its sender.
// It must be rejected: JS already believes the connection is closed.
let (tx_a, _rx_a) = mpsc::channel::<String>(4);
assert!(
!state.install_sender(gen_a, tx_a).await,
"a handshake pending during disconnect must not be able to install after it"
);
}
}
@@ -1,6 +1,6 @@
{
"productName": "OwnCord",
"version": "1.2.0-alpha.1",
"version": "1.2.0-alpha.3",
"identifier": "com.owncord.client",
"build": {
"frontendDist": "../dist",
@@ -19,12 +19,12 @@
"decorations": true,
"resizable": true,
"center": true,
"additionalBrowserArgs": "--autoplay-policy=no-user-gesture-required --use-fake-ui-for-media-stream"
"additionalBrowserArgs": "--disable-features=msWebOOUI,msPdfOOUI,msSmartScreenProtection --autoplay-policy=no-user-gesture-required --use-fake-ui-for-media-stream"
}
],
"withGlobalTauri": false,
"security": {
"csp": "default-src 'self'; script-src 'self' 'wasm-unsafe-eval'; style-src 'self' 'unsafe-inline'; connect-src 'self' http://ipc.localhost https: wss: http://localhost:* ws://localhost:* http://127.0.0.1:* ws://127.0.0.1:*; img-src 'self' https: data:; media-src 'self' blob:; font-src 'self'; object-src 'none'; base-uri 'self'; frame-src https://www.youtube.com https://youtube.com; worker-src 'self' blob:"
"csp": "default-src 'self'; script-src 'self' 'wasm-unsafe-eval'; style-src 'self' 'unsafe-inline'; connect-src 'self' http://ipc.localhost https: wss: http://localhost:* ws://localhost:* http://127.0.0.1:* ws://127.0.0.1:*; img-src 'self' blob: https: data:; media-src 'self' blob:; font-src 'self'; object-src 'none'; base-uri 'self'; frame-src https://www.youtube.com https://youtube.com; worker-src 'self' blob:"
}
},
"bundle": {
@@ -65,12 +65,6 @@
}
},
"plugins": {
"tauri-typegen": {
"project_path": ".",
"output_path": "../src/generated",
"validation_library": "none",
"verbose": false
},
"updater": {
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IEJCQkQ0NzM1MjkxRTlGQTIKUldTaW54NHBOVWU5dTRqYW0yalI5VTJBd0NXOUZwM2UrcDM4YkhCSmlMZWJKWWVXaGJWdHBaSHgK",
"endpoints": [],
@@ -231,6 +231,10 @@ export function createMemberContextMenu(options: MemberContextMenuOptions): Cont
);
const roleSub = createElement("div", { class: "context-menu__submenu" });
// One guard across every option: `currentRole` only updates when the
// member_update echoes, so without it a double-click (or a second option
// clicked while the first PATCH is in flight) fires twice.
let roleChangeRunning = false;
for (const role of options.availableRoles) {
const cls =
role === options.currentRole
@@ -240,9 +244,14 @@ export function createMemberContextMenu(options: MemberContextMenuOptions): Cont
role,
cls,
() => {
if (role !== options.currentRole) {
void options.onChangeRole(role);
}
if (roleChangeRunning || role === options.currentRole) return;
roleChangeRunning = true;
roleOption.classList.add("context-menu__item--pending");
const done = (): void => {
roleChangeRunning = false;
roleOption.classList.remove("context-menu__item--pending");
};
options.onChangeRole(role).then(done, done);
},
ac.signal,
);
@@ -8,6 +8,7 @@
import { createElement, setText, appendChildren } from "@lib/dom";
import { createIcon } from "@lib/icons";
import { applyDialogSemantics, focusDialog, trapFocus } from "@lib/a11y";
import type { MountableComponent } from "@lib/safe-render";
export interface CertMismatchModalOptions {
@@ -21,17 +22,27 @@ export interface CertMismatchModalOptions {
export function createCertMismatchModal(options: CertMismatchModalOptions): MountableComponent {
const { host, storedFingerprint, newFingerprint, onAccept, onReject } = options;
let overlay: HTMLDivElement | null = null;
let restoreFocus: (() => void) | null = null;
const ac = new AbortController();
function mount(container: Element): void {
overlay = createElement("div", { class: "modal-overlay visible" });
const modal = createElement("div", { class: "modal" });
// Ids are unique per factory, not per instance — these three trust prompts
// never stack with each other in practice.
applyDialogSemantics(modal, { labelledBy: "cert-mismatch-title" });
trapFocus(modal, ac.signal);
// Header
const header = createElement("div", { class: "modal-header" });
const title = createElement("h3", {}, "Certificate Warning");
const closeBtn = createElement("button", { class: "modal-close", type: "button" });
const title = createElement("h3", { id: "cert-mismatch-title" }, "Certificate Warning");
const closeBtn = createElement("button", {
class: "modal-close",
type: "button",
// Icon-only control — the aria-label is its entire accessible name.
"aria-label": "Close",
});
closeBtn.textContent = "";
closeBtn.appendChild(createIcon("x", 14));
closeBtn.addEventListener("click", onReject, { signal: ac.signal });
@@ -94,7 +105,18 @@ export function createCertMismatchModal(options: CertMismatchModalOptions): Moun
{ signal: ac.signal },
);
// Escape maps to reject because that is the fail-closed safe default
// (Disconnect) — dismissing a trust prompt must never grant trust.
document.addEventListener(
"keydown",
(e: KeyboardEvent) => {
if (e.key === "Escape" && overlay?.isConnected === true) onReject();
},
{ signal: ac.signal },
);
container.appendChild(overlay);
restoreFocus = focusDialog(modal);
}
function destroy(): void {
@@ -103,6 +125,8 @@ export function createCertMismatchModal(options: CertMismatchModalOptions): Moun
overlay.remove();
overlay = null;
}
restoreFocus?.();
restoreFocus = null;
}
return { mount, destroy };
@@ -124,15 +148,24 @@ export interface CertFirstUseModalOptions {
export function createCertFirstUseModal(options: CertFirstUseModalOptions): MountableComponent {
const { host, fingerprint, onAccept, onReject } = options;
let overlay: HTMLDivElement | null = null;
let restoreFocus: (() => void) | null = null;
const ac = new AbortController();
function mount(container: Element): void {
overlay = createElement("div", { class: "modal-overlay visible" });
const modal = createElement("div", { class: "modal" });
// Unique per factory, not per instance — the three trust prompts never
// stack with each other in practice.
applyDialogSemantics(modal, { labelledBy: "cert-first-use-title" });
trapFocus(modal, ac.signal);
const header = createElement("div", { class: "modal-header" });
const title = createElement("h3", {}, "New Server Certificate");
const closeBtn = createElement("button", { class: "modal-close", type: "button" });
const title = createElement("h3", { id: "cert-first-use-title" }, "New Server Certificate");
const closeBtn = createElement("button", {
class: "modal-close",
type: "button",
"aria-label": "Close",
});
closeBtn.textContent = "";
closeBtn.appendChild(createIcon("x", 14));
closeBtn.addEventListener("click", onReject, { signal: ac.signal });
@@ -187,7 +220,18 @@ export function createCertFirstUseModal(options: CertFirstUseModalOptions): Moun
{ signal: ac.signal },
);
// Escape rejects (Cancel) — the fail-closed default: never trust a
// certificate because the prompt was dismissed.
document.addEventListener(
"keydown",
(e: KeyboardEvent) => {
if (e.key === "Escape" && overlay?.isConnected === true) onReject();
},
{ signal: ac.signal },
);
container.appendChild(overlay);
restoreFocus = focusDialog(modal);
}
function destroy(): void {
@@ -196,6 +240,8 @@ export function createCertFirstUseModal(options: CertFirstUseModalOptions): Moun
overlay.remove();
overlay = null;
}
restoreFocus?.();
restoreFocus = null;
}
return { mount, destroy };
@@ -223,15 +269,24 @@ export function createIdentityMismatchModal(
): MountableComponent {
const { username, fingerprint, onAccept, onReject } = options;
let overlay: HTMLDivElement | null = null;
let restoreFocus: (() => void) | null = null;
const ac = new AbortController();
function mount(container: Element): void {
overlay = createElement("div", { class: "modal-overlay visible" });
const modal = createElement("div", { class: "modal" });
// Unique per factory, not per instance — the three trust prompts never
// stack with each other in practice.
applyDialogSemantics(modal, { labelledBy: "identity-mismatch-title" });
trapFocus(modal, ac.signal);
const header = createElement("div", { class: "modal-header" });
const title = createElement("h3", {}, "Identity Warning");
const closeBtn = createElement("button", { class: "modal-close", type: "button" });
const title = createElement("h3", { id: "identity-mismatch-title" }, "Identity Warning");
const closeBtn = createElement("button", {
class: "modal-close",
type: "button",
"aria-label": "Close",
});
closeBtn.textContent = "";
closeBtn.appendChild(createIcon("x", 14));
closeBtn.addEventListener("click", onReject, { signal: ac.signal });
@@ -287,7 +342,18 @@ export function createIdentityMismatchModal(
{ signal: ac.signal },
);
// Escape rejects (Cancel) — the fail-closed default: dismissing the
// prompt must never re-pin the new identity key.
document.addEventListener(
"keydown",
(e: KeyboardEvent) => {
if (e.key === "Escape" && overlay?.isConnected === true) onReject();
},
{ signal: ac.signal },
);
container.appendChild(overlay);
restoreFocus = focusDialog(modal);
}
function destroy(): void {
@@ -296,6 +362,8 @@ export function createIdentityMismatchModal(
overlay.remove();
overlay = null;
}
restoreFocus?.();
restoreFocus = null;
}
return { mount, destroy };
@@ -22,7 +22,7 @@ import { attachStreamPreview, attachScrollCollapse } from "@lib/streamPreview";
import { showUserVolumeMenu } from "./channel-sidebar/volume-menu";
import type { VoiceModMenuOptions } from "./channel-sidebar/volume-menu";
import { attachChannelContextMenu, CHANNEL_MUTE_CHANGED } from "./channel-sidebar/context-menu";
import { attachDragHandlers, releaseGlobalDragListeners } from "./channel-sidebar/drag-reorder";
import { attachDragHandlers } from "./channel-sidebar/drag-reorder";
import { rePinPeerIdentity } from "@lib/livekitSession";
import { createIdentityMismatchModal } from "./CertMismatchModal";
import { createLogger } from "@lib/logger";
@@ -34,10 +34,11 @@ import { importIdentityPublicKey, computeKeyFingerprint } from "@lib/e2eeCrypto"
const log = createLogger("ChannelSidebar");
/** Icon, color, and tooltip for a peer's E2EE identity verification badge
* (F3 TOFU). The three states mirror the voice store's PeerVerification:
* (F3 TOFU). The states mirror the voice store's PeerVerification:
* a green shield-check when the announce signature verified against the pinned
* key, a muted shield when the peer published no key (legacy), and a red
* shield-alert when the delivered key differs from the pinned one. */
* key, a muted shield when the peer published no key (legacy), a red
* shield-alert when the delivered key differs from the pinned one, and an
* amber shield-question when the local pin store could not be read (DC-08). */
function verifyPresentation(v: PeerVerification): {
icon: IconName;
color: string;
@@ -60,6 +61,15 @@ function verifyPresentation(v: PeerVerification): {
title: "Identity key changed — click to review and re-pin",
};
}
if (v.status === "unknown") {
return {
icon: "shield-question",
color: "var(--yellow, #f0b232)",
title:
"Could not check this participant's identity — key storage is unavailable, " +
"so they are blocked for E2EE until it recovers",
};
}
// "unverified" — the remaining status: peer published no identity key (legacy).
return {
icon: "shield",
@@ -495,6 +505,11 @@ function renderVoiceChannelItem(
// Don't trigger if the right-click menu is open
if (e.button !== 0) return;
e.stopPropagation();
// Watching a stream needs a live LiveKit room -- join first, same
// as the hover/focus preview's placeholder click below.
if (voiceStore.getState().currentChannelId !== channel.id) {
onVoiceJoin(channel.id);
}
const tileId = user.screenshare
? user.userId + SCREENSHARE_TILE_ID_OFFSET
: user.userId;
@@ -851,6 +866,24 @@ export function createChannelSidebar(options: ChannelSidebarOptions): MountableC
);
unsubscribers.push(unsubAuth);
// canManageChannels()/canModerateVoice() read authStore.user.role and
// channelsStore.roles at render time, but nothing above re-renders when
// either changes on its own — a MEMBER_UPDATE for the signed-in user
// (dispatcher.ts's self-branch) or a ROLES_UPDATE permission-mask edit
// would otherwise leave the category "+", the channel context menu and
// the voice-moderation menu stale until an unrelated channel/voice event
// happened to fire renderChannels() (OC-0142).
const unsubRole = authStore.subscribeSelector(
(s) => s.user?.role ?? "",
() => renderChannels(),
);
unsubscribers.push(unsubRole);
const unsubRoles = channelsStore.subscribeSelector(
(s) => s.roles,
() => renderChannels(),
);
unsubscribers.push(unsubRoles);
// Subscribe to UI store for category collapse changes
const unsubUi = uiStore.subscribeSelector(
(s) => s.collapsedCategories,
@@ -907,8 +940,9 @@ export function createChannelSidebar(options: ChannelSidebarOptions): MountableC
}
function destroy(): void {
// ac.abort() also releases this sidebar's hold on the shared document-level
// drag listeners (drag-reorder.ts tracks owners by signal).
ac.abort();
releaseGlobalDragListeners(channelList ?? undefined);
for (const unsub of unsubscribers) {
unsub();
}
@@ -11,6 +11,7 @@
* under every category — the server agrees (it validates the type alone).
*/
import { applyDialogSemantics, focusDialog, trapFocus } from "@lib/a11y";
import { createElement, setText, appendChildren } from "@lib/dom";
import { createIcon } from "@lib/icons";
import type { MountableComponent } from "@lib/safe-render";
@@ -44,6 +45,7 @@ export function createCreateChannelModal(options: CreateChannelModalOptions): Mo
const { category, onCreate, onClose } = options;
const ac = new AbortController();
let overlay: HTMLDivElement | null = null;
let restoreFocus: (() => void) | null = null;
function mount(container: Element): void {
overlay = createElement("div", {
@@ -52,13 +54,17 @@ export function createCreateChannelModal(options: CreateChannelModalOptions): Mo
});
const modal = createElement("div", { class: "modal" });
applyDialogSemantics(modal, { labelledBy: "create-channel-title" });
trapFocus(modal, ac.signal);
// Header
const header = createElement("div", { class: "modal-header" });
const title = createElement("h3", {}, "Create Channel");
const title = createElement("h3", { id: "create-channel-title" }, "Create Channel");
// Icon-only button: without a label a screen reader announces just "button".
const closeBtn = createElement("button", {
class: "modal-close",
type: "button",
"aria-label": "Close",
});
closeBtn.textContent = "";
closeBtn.appendChild(createIcon("x", 14));
@@ -188,8 +194,25 @@ export function createCreateChannelModal(options: CreateChannelModalOptions): Mo
{ signal: ac.signal },
);
// Escape cancels — never creates. Document-level so it works wherever
// focus sits; guarded on the overlay still being attached because the
// listener lives until destroy() aborts it.
document.addEventListener(
"keydown",
(e: KeyboardEvent) => {
if (e.key === "Escape" && overlay?.isConnected === true) {
onClose();
}
},
{ signal: ac.signal },
);
container.appendChild(overlay);
// Capture where focus came from before anything inside the dialog takes
// it, so destroy() can hand it back to the opener.
restoreFocus = focusDialog(modal);
// Focus the name input
nameInput.focus();
}
@@ -200,6 +223,11 @@ export function createCreateChannelModal(options: CreateChannelModalOptions): Mo
overlay.remove();
overlay = null;
}
// Every close path (X, Cancel, backdrop, Escape) funnels through the
// caller's onClose, which calls destroy() — the single place focus
// returns to the opener.
restoreFocus?.();
restoreFocus = null;
}
return { mount, destroy };
@@ -3,6 +3,7 @@
* Shows channel name and requires explicit confirmation.
*/
import { applyDialogSemantics, focusDialog, trapFocus } from "@lib/a11y";
import { createElement, setText, appendChildren } from "@lib/dom";
import { createIcon } from "@lib/icons";
import type { MountableComponent } from "@lib/safe-render";
@@ -18,6 +19,7 @@ export function createDeleteChannelModal(options: DeleteChannelModalOptions): Mo
const { channelName, onConfirm, onClose } = options;
const ac = new AbortController();
let overlay: HTMLDivElement | null = null;
let restoreFocus: (() => void) | null = null;
function mount(container: Element): void {
overlay = createElement("div", {
@@ -26,13 +28,17 @@ export function createDeleteChannelModal(options: DeleteChannelModalOptions): Mo
});
const modal = createElement("div", { class: "modal" });
applyDialogSemantics(modal, { labelledBy: "delete-channel-title" });
trapFocus(modal, ac.signal);
// Header
const header = createElement("div", { class: "modal-header" });
const title = createElement("h3", {}, "Delete Channel");
const title = createElement("h3", { id: "delete-channel-title" }, "Delete Channel");
// Icon-only button: without a label a screen reader announces just "button".
const closeBtn = createElement("button", {
class: "modal-close",
type: "button",
"aria-label": "Close",
});
closeBtn.textContent = "";
closeBtn.appendChild(createIcon("x", 14));
@@ -87,8 +93,14 @@ export function createDeleteChannelModal(options: DeleteChannelModalOptions): Mo
} catch (err) {
errorEl.style.display = "block";
setText(errorEl, err instanceof Error ? err.message : "Failed to delete channel");
deleteBtn.removeAttribute("disabled");
setText(deleteBtn, "Delete Channel");
} finally {
// Re-arm the button whether the caller rejected or handled the
// failure itself and resolved. A successful delete destroys the
// modal inside onConfirm, so the overlay is gone and this no-ops.
if (overlay?.isConnected === true) {
deleteBtn.removeAttribute("disabled");
setText(deleteBtn, "Delete Channel");
}
}
},
{ signal: ac.signal },
@@ -109,7 +121,24 @@ export function createDeleteChannelModal(options: DeleteChannelModalOptions): Mo
{ signal: ac.signal },
);
// Escape cancels — it must never stand in for the destructive confirm.
// Document-level so it works wherever focus sits; guarded on the overlay
// still being attached because the listener lives until destroy() aborts it.
document.addEventListener(
"keydown",
(e: KeyboardEvent) => {
if (e.key === "Escape" && overlay?.isConnected === true) {
onClose();
}
},
{ signal: ac.signal },
);
container.appendChild(overlay);
// Move focus in (lands on the header's close button, safely away from the
// destructive confirm) and remember the opener for destroy() to restore.
restoreFocus = focusDialog(modal);
}
function destroy(): void {
@@ -118,6 +147,11 @@ export function createDeleteChannelModal(options: DeleteChannelModalOptions): Mo
overlay.remove();
overlay = null;
}
// Every close path (X, Cancel, backdrop, Escape) funnels through the
// caller's onClose, which calls destroy() — the single place focus
// returns to the opener.
restoreFocus?.();
restoreFocus = null;
}
return { mount, destroy };
@@ -13,7 +13,8 @@
import { createElement, appendChildren, setText } from "@lib/dom";
import type { MountableComponent } from "@lib/safe-render";
import type { UserStatus } from "@lib/types";
import { isSafeUrl } from "./message-list/attachments";
import { isRenderableAvatar } from "@lib/avatar";
import { fetchImageAsDataUrl, resolveServerUrl } from "./message-list/attachments";
// ---------------------------------------------------------------------------
// Types
@@ -31,6 +32,16 @@ export interface DmProfileData {
export interface DmProfileSidebarOptions {
readonly user: DmProfileData;
readonly onClose: () => void;
/**
* The connected server's host, used to scope the note's localStorage key.
* User ids are per-server, so without this a note about user 5 on one
* server is shown for, and overwritten by, the unrelated user 5 on
* another — real in the multi-profile client (see profiles.ts). Optional,
* and falls back to the legacy unscoped key, so a caller that has not
* been updated to pass it yet keeps today's single-profile behavior
* exactly (including any note already saved under the old key).
*/
readonly host?: string;
}
export type DmProfileSidebarComponent = MountableComponent & {
@@ -67,17 +78,34 @@ const STATUS_LABELS: Readonly<Record<UserStatus, string>> = {
// Helpers
// ---------------------------------------------------------------------------
function loadNote(userId: number): string {
/** The legacy unscoped key, from before per-server notes (or when the caller
* has not yet been updated to pass a host). */
function legacyNoteKey(userId: number): string {
return NOTE_STORAGE_PREFIX + String(userId);
}
function scopedNoteKey(userId: number, host: string): string {
return `${NOTE_STORAGE_PREFIX}${host}:${userId}`;
}
function loadNote(userId: number, host: string): string {
try {
return localStorage.getItem(NOTE_STORAGE_PREFIX + String(userId)) ?? "";
if (host !== "") {
const scoped = localStorage.getItem(scopedNoteKey(userId, host));
if (scoped !== null) return scoped;
}
// Fall back to the legacy key so a note saved before per-server scoping
// (or while the host was unknown) is not silently lost.
return localStorage.getItem(legacyNoteKey(userId)) ?? "";
} catch {
return "";
}
}
function saveNote(userId: number, text: string): void {
function saveNote(userId: number, host: string, text: string): void {
try {
localStorage.setItem(NOTE_STORAGE_PREFIX + String(userId), text);
const key = host !== "" ? scopedNoteKey(userId, host) : legacyNoteKey(userId);
localStorage.setItem(key, text);
} catch {
// localStorage may be unavailable or full -- silently ignore
}
@@ -92,7 +120,7 @@ export function createDmProfileSidebar(
): DmProfileSidebarComponent {
const ac = new AbortController();
const { signal } = ac;
const { user, onClose } = options;
const { user, onClose, host = "" } = options;
let panel: HTMLDivElement | null = null;
let open = false;
@@ -119,22 +147,31 @@ export function createDmProfileSidebar(
wrapper.style.position = "relative";
wrapper.style.flexShrink = "0";
if (user.avatar !== null && user.avatar.length > 0 && isSafeUrl(user.avatar)) {
wrapper.style.background = "transparent";
const img = createElement("img", {
src: user.avatar,
alt: user.username,
class: "dps-avatar-img",
// The letter draws immediately; the picture (if any) is fetched through
// the same cert-pinned, bearer-token path attachments use and swapped in
// once the bytes arrive. `<img src>` cannot carry the auth header an
// `/api/v1/files/{id}` avatar needs, so the URL is never assigned raw.
wrapper.style.background = "var(--accent, #5865f2)";
const initial = user.username.charAt(0).toUpperCase() || "?";
const letter = createElement("span", {}, initial);
wrapper.appendChild(letter);
if (isRenderableAvatar(user.avatar)) {
const resolved = resolveServerUrl(user.avatar);
void fetchImageAsDataUrl(resolved).then((dataUrl) => {
if (dataUrl === null || !wrapper.isConnected) return;
const img = createElement("img", {
src: dataUrl,
alt: user.username,
class: "dps-avatar-img",
});
img.style.width = "80px";
img.style.height = "80px";
img.style.borderRadius = "50%";
letter.remove();
wrapper.style.background = "transparent";
wrapper.insertBefore(img, wrapper.firstChild);
});
img.style.width = "80px";
img.style.height = "80px";
img.style.borderRadius = "50%";
wrapper.appendChild(img);
} else {
wrapper.style.background = "var(--accent, #5865f2)";
const initial = user.username.charAt(0).toUpperCase() || "?";
const text = createElement("span", {}, initial);
wrapper.appendChild(text);
}
// Status dot overlay
@@ -328,12 +365,12 @@ export function createDmProfileSidebar(
noteInput.style.fontSize = "13px";
noteInput.style.padding = "8px";
noteInput.style.fontFamily = "inherit";
noteInput.value = loadNote(user.id);
noteInput.value = loadNote(user.id, host);
noteInput.addEventListener(
"input",
() => {
saveNote(user.id, noteInput.value);
saveNote(user.id, host, noteInput.value);
},
{ signal },
);
@@ -16,7 +16,8 @@ import { createElement, setText, appendChildren } from "@lib/dom";
import { createIcon } from "@lib/icons";
import { showContextMenu } from "@lib/context-menu";
import type { MountableComponent } from "@lib/safe-render";
import { isSafeUrl } from "./message-list/attachments";
import { isRenderableAvatar } from "@lib/avatar";
import { fetchImageAsDataUrl, resolveServerUrl } from "./message-list/attachments";
/** One member of a group DM, as far as the sidebar needs to draw them. */
export interface DmParticipant {
@@ -74,17 +75,29 @@ const STATUS_COLORS: Record<string, string> = {
offline: "var(--text-micro)",
};
/** Fill one avatar circle: the picture if it is safe to load, else the letter. */
/**
* Fill one avatar circle: the letter immediately, the picture swapped in once
* fetched. `<img src>` cannot carry the bearer token an authenticated
* `/api/v1/files/{id}` avatar needs, so the URL is always fetched through the
* same cert-pinned path attachments and custom emoji use rather than assigned
* directly.
*/
function paintAvatar(el: HTMLElement, avatar: string | null, label: string): void {
if (avatar !== null && isSafeUrl(avatar)) {
const img = createElement("img", { src: avatar, alt: label });
// The letter lives in its own node so the swap below can remove just it —
// anything else in the circle (the 1:1 presence dot) must survive the image.
const letter = document.createTextNode(label.charAt(0).toUpperCase());
el.appendChild(letter);
if (!isRenderableAvatar(avatar)) return;
const resolved = resolveServerUrl(avatar);
void fetchImageAsDataUrl(resolved).then((dataUrl) => {
if (dataUrl === null || !el.isConnected) return;
const img = createElement("img", { src: dataUrl, alt: label });
img.style.width = "100%";
img.style.height = "100%";
img.style.borderRadius = "50%";
el.appendChild(img);
return;
}
setText(el, label.charAt(0).toUpperCase());
letter.remove();
el.insertBefore(img, el.firstChild);
});
}
/**
@@ -318,6 +331,18 @@ export function createDmSidebar(options: DmSidebarOptions): MountableComponent {
const items = sorted.map((convo) => renderDmItem(convo, options, ac.signal));
searchInput.addEventListener(
"input",
() => {
const q = searchInput.value.trim().toLowerCase();
items.forEach((el, i) => {
const match = q === "" || sorted[i]!.username.toLowerCase().includes(q);
el.style.display = match ? "" : "none";
});
},
{ signal: ac.signal },
);
appendChildren(root, header, sectionLabel, ...items);
container.appendChild(root);
}
@@ -16,6 +16,7 @@
* shown as its own option rather than being silently rounded to a neighbour.
*/
import { applyDialogSemantics, focusDialog, trapFocus } from "@lib/a11y";
import { createElement, setText, appendChildren } from "@lib/dom";
import { createIcon } from "@lib/icons";
import type { MountableComponent } from "@lib/safe-render";
@@ -159,6 +160,7 @@ export function createEditChannelModal(options: EditChannelModalOptions): Mounta
const isVoice = channelType === "voice";
const ac = new AbortController();
let overlay: HTMLDivElement | null = null;
let restoreFocus: (() => void) | null = null;
function mount(container: Element): void {
overlay = createElement("div", {
@@ -167,13 +169,17 @@ export function createEditChannelModal(options: EditChannelModalOptions): Mounta
});
const modal = createElement("div", { class: "modal" });
applyDialogSemantics(modal, { labelledBy: "edit-channel-title" });
trapFocus(modal, ac.signal);
// Header
const header = createElement("div", { class: "modal-header" });
const title = createElement("h3", {}, "Edit Channel");
const title = createElement("h3", { id: "edit-channel-title" }, "Edit Channel");
// Icon-only button: without a label a screen reader announces just "button".
const closeBtn = createElement("button", {
class: "modal-close",
type: "button",
"aria-label": "Close",
});
closeBtn.textContent = "";
closeBtn.appendChild(createIcon("x", 14));
@@ -395,7 +401,25 @@ export function createEditChannelModal(options: EditChannelModalOptions): Mounta
{ signal: ac.signal },
);
// Escape cancels — never saves. Document-level so it works wherever focus
// sits; guarded on the overlay still being attached because the listener
// lives until destroy() aborts it.
document.addEventListener(
"keydown",
(e: KeyboardEvent) => {
if (e.key === "Escape" && overlay?.isConnected === true) {
onClose();
}
},
{ signal: ac.signal },
);
container.appendChild(overlay);
// Capture where focus came from before anything inside the dialog takes
// it, so destroy() can hand it back to the opener.
restoreFocus = focusDialog(modal);
nameInput.focus();
nameInput.select();
}
@@ -406,6 +430,11 @@ export function createEditChannelModal(options: EditChannelModalOptions): Mounta
overlay.remove();
overlay = null;
}
// Every close path (X, Cancel, backdrop, Escape) funnels through the
// caller's onClose, which calls destroy() — the single place focus
// returns to the opener.
restoreFocus?.();
restoreFocus = null;
}
return { mount, destroy };
@@ -50,6 +50,11 @@ export interface EmojiAutocompleteOptions {
/** Called with the text to insert (`:wave:` or a unicode character). */
readonly onSelect: (insert: string) => void;
readonly onClose: () => void;
/**
* Composer textarea the popup completes for; carries combobox semantics and
* aria-activedescendant while the popup is open (see inline-autocomplete).
*/
readonly comboboxInput?: HTMLElement;
}
/** Same shape as the shared inline-autocomplete widget. */
@@ -153,5 +158,6 @@ export function createEmojiAutocomplete(
// MIN_EMOJI_QUERY, so there is nothing to prime on create.
onSelect: options.onSelect,
onClose: options.onClose,
comboboxInput: options.comboboxInput,
});
}
@@ -2,6 +2,7 @@
// Uses @lib/dom helpers exclusively. Never sets innerHTML with user content.
import { createElement, setText, clearChildren } from "@lib/dom";
import { enableRovingNavigation, setRovingTabindex } from "@lib/a11y";
import { buildCustomEmojiNode } from "@components/message-list/custom-emoji";
// ---------------------------------------------------------------------------
@@ -559,11 +560,16 @@ export function createEmojiPicker(options: EmojiPickerOptions): {
header.appendChild(searchInput);
root.appendChild(header);
// Scrollable content area (holds category labels + grids)
// Scrollable content area (holds category labels + grids). Announced as a
// single flat listbox — the category grids are visual grouping only, and
// roving tabindex (DC-13) treats every .ep-emoji cell as one list.
const scrollArea = createElement("div", {
style: "overflow-y: auto; max-height: 320px;",
role: "listbox",
"aria-label": "Emoji",
});
root.appendChild(scrollArea);
enableRovingNavigation(scrollArea, ".ep-emoji", signal);
// Build categories with recent + custom
function getAllCategories(): readonly EmojiCategory[] {
@@ -596,6 +602,10 @@ export function createEmojiPicker(options: EmojiPickerOptions): {
const span = createElement("span", {
class: "ep-emoji",
title: emoji,
role: "option",
// Mirrors the title (the character or :shortcode: token) — e2e specs
// select cells by title, so the accessible name must never diverge.
"aria-label": emoji,
});
// A `:shortcode:` entry shows its image; everything else is the character
// itself. An unresolvable shortcode falls back to the text, which is what
@@ -652,6 +662,10 @@ export function createEmojiPicker(options: EmojiPickerOptions): {
);
scrollArea.appendChild(empty);
}
// Every render rebuilds the cell set, so the single Tab stop must be
// re-established or filtering would leave zero tabbable cells.
setRovingTabindex(scrollArea, ".ep-emoji");
}
// Initial render
@@ -1,231 +0,0 @@
// Step 8.59 — File upload component with drag-and-drop, preview, and progress.
// Uses @lib/dom helpers exclusively. Never sets innerHTML with user content.
import { createElement, setText, appendChildren } from "@lib/dom";
import { createIcon } from "@lib/icons";
import type { MountableComponent } from "@lib/safe-render";
/** Default allowed MIME types for file uploads. */
const DEFAULT_ALLOWED_TYPES = [
"image/jpeg",
"image/png",
"image/gif",
"image/webp",
"image/avif",
"video/mp4",
"video/webm",
"audio/mpeg",
"audio/ogg",
"audio/wav",
"application/pdf",
"text/plain",
];
export interface FileUploadOptions {
readonly onUpload: (file: File) => Promise<void>;
readonly maxSizeMb?: number;
readonly allowedMimeTypes?: readonly string[];
}
const DEFAULT_MAX_SIZE_MB = 10;
export type FileUploadComponent = MountableComponent & { openPicker(): void };
export function createFileUpload(options: FileUploadOptions): FileUploadComponent {
const maxBytes = (options.maxSizeMb ?? DEFAULT_MAX_SIZE_MB) * 1024 * 1024;
const ac = new AbortController();
const signal = ac.signal;
let root: HTMLDivElement | null = null;
let dropzone: HTMLDivElement;
let fileInput: HTMLInputElement;
let preview: HTMLDivElement;
let thumb: HTMLImageElement;
let nameSpan: HTMLSpanElement;
let sizeSpan: HTMLSpanElement;
let progressBar: HTMLDivElement;
let cancelBtn: HTMLButtonElement;
let errorDiv: HTMLDivElement;
let uploadAbort: AbortController | null = null;
// oxlint-disable-next-line consistent-function-scoping -- co-located with its sole caller for readability
function formatSize(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}
function showError(message: string): void {
setText(errorDiv, message);
errorDiv.classList.remove("file-upload__error--hidden");
preview.classList.add("file-upload__preview--hidden");
}
function resetPreview(): void {
preview.classList.add("file-upload__preview--hidden");
thumb.src = "";
thumb.style.display = "none";
setText(nameSpan, "");
setText(sizeSpan, "");
progressBar.style.width = "0%";
uploadAbort = null;
errorDiv.classList.add("file-upload__error--hidden");
}
function showPreview(file: File): void {
resetPreview();
setText(nameSpan, file.name);
setText(sizeSpan, formatSize(file.size));
if (file.type.startsWith("image/")) {
const url = URL.createObjectURL(file);
thumb.src = url;
thumb.style.display = "block";
thumb.addEventListener("load", () => URL.revokeObjectURL(url));
}
preview.classList.remove("file-upload__preview--hidden");
}
async function handleFile(file: File): Promise<void> {
errorDiv.classList.add("file-upload__error--hidden");
const allowed = options.allowedMimeTypes ?? DEFAULT_ALLOWED_TYPES;
if (file.type && !allowed.includes(file.type)) {
showError(`File type "${file.type}" is not allowed.`);
return;
}
if (file.size > maxBytes) {
showError(
`File too large (${formatSize(file.size)}). Max ${options.maxSizeMb ?? DEFAULT_MAX_SIZE_MB} MB.`,
);
return;
}
showPreview(file);
uploadAbort = new AbortController();
try {
progressBar.style.width = "50%";
await options.onUpload(file);
progressBar.style.width = "100%";
setTimeout(() => resetPreview(), 1500);
} catch (err) {
if (uploadAbort?.signal.aborted) return;
showError(err instanceof Error ? err.message : "Upload failed");
resetPreview();
}
}
function buildDom(): void {
root = createElement("div", { class: "file-upload" });
dropzone = createElement("div", {
class: "file-upload__dropzone file-upload__dropzone--hidden",
});
appendChildren(
dropzone,
createElement("span", { class: "file-upload__droptext" }, "Drop files here"),
);
const allowed = options.allowedMimeTypes ?? DEFAULT_ALLOWED_TYPES;
fileInput = createElement("input", {
class: "file-upload__input",
type: "file",
accept: allowed.join(","),
});
fileInput.style.display = "none";
preview = createElement("div", { class: "file-upload__preview file-upload__preview--hidden" });
thumb = createElement("img", { class: "file-upload__thumb" });
thumb.style.display = "none";
thumb.alt = "";
nameSpan = createElement("span", { class: "file-upload__name" });
sizeSpan = createElement("span", { class: "file-upload__size" });
const progressContainer = createElement("div", { class: "file-upload__progress" });
progressBar = createElement("div", { class: "file-upload__progress-bar" });
progressBar.style.width = "0%";
appendChildren(progressContainer, progressBar);
cancelBtn = createElement("button", { class: "file-upload__cancel", type: "button" });
cancelBtn.appendChild(createIcon("x", 14));
appendChildren(preview, thumb, nameSpan, sizeSpan, progressContainer, cancelBtn);
errorDiv = createElement("div", { class: "file-upload__error file-upload__error--hidden" });
appendChildren(root, dropzone, fileInput, preview, errorDiv);
}
function attachListeners(): void {
fileInput.addEventListener(
"change",
() => {
const file = fileInput.files?.[0];
if (file) {
void handleFile(file);
fileInput.value = "";
}
},
{ signal },
);
cancelBtn.addEventListener(
"click",
() => {
if (uploadAbort !== null) uploadAbort.abort();
resetPreview();
},
{ signal },
);
let dragCounter = 0;
root!.addEventListener(
"dragenter",
(e) => {
e.preventDefault();
dragCounter++;
dropzone.classList.remove("file-upload__dropzone--hidden");
},
{ signal },
);
root!.addEventListener(
"dragleave",
(e) => {
e.preventDefault();
dragCounter--;
if (dragCounter <= 0) {
dragCounter = 0;
dropzone.classList.add("file-upload__dropzone--hidden");
}
},
{ signal },
);
root!.addEventListener("dragover", (e) => e.preventDefault(), { signal });
root!.addEventListener(
"drop",
(e) => {
e.preventDefault();
dragCounter = 0;
dropzone.classList.add("file-upload__dropzone--hidden");
const file = e.dataTransfer?.files[0];
if (file) void handleFile(file);
},
{ signal },
);
}
function mount(container: Element): void {
buildDom();
attachListeners();
container.appendChild(root!);
}
function destroy(): void {
ac.abort();
if (uploadAbort !== null) uploadAbort.abort();
root?.remove();
root = null;
}
function openPicker(): void {
fileInput.click();
}
return { mount, destroy, openPicker };
}
@@ -3,6 +3,7 @@
// innerHTML with user content.
import { createElement, setText, clearChildren } from "@lib/dom";
import { enableRovingNavigation, setRovingTabindex } from "@lib/a11y";
import { ApiClientError } from "@lib/api";
import { searchGifs, getTrendingGifs } from "@lib/gifProvider";
import type { GifApi, GifResult } from "@lib/gifProvider";
@@ -67,9 +68,15 @@ export function createGifPicker(options: GifPickerOptions): {
root.appendChild(header);
// Grid area (scrollable)
const gridArea = createElement("div", { class: "gp-grid-area" });
// Grid area (scrollable). Announced as a flat listbox of GIF options with
// roving tabindex (DC-13); the inner .gp-grid is layout only.
const gridArea = createElement("div", {
class: "gp-grid-area",
role: "listbox",
"aria-label": "GIFs",
});
root.appendChild(gridArea);
enableRovingNavigation(gridArea, ".gp-item", signal);
// Loading indicator
const loadingEl = createElement("div", { class: "gp-loading" });
@@ -92,7 +99,13 @@ export function createGifPicker(options: GifPickerOptions): {
const grid = createElement("div", { class: "gp-grid" });
for (const gif of gifs) {
const item = createElement("div", { class: "gp-item" });
const item = createElement("div", {
class: "gp-item",
role: "option",
// Same fallback as the img alt below — an untitled GIF still needs a
// pronounceable accessible name.
"aria-label": gif.title || "GIF",
});
const img = createElement("img", {
class: "gp-img",
src: gif.url,
@@ -114,6 +127,9 @@ export function createGifPicker(options: GifPickerOptions): {
}
gridArea.appendChild(grid);
// Each render replaces the cell set, so re-establish the single Tab stop.
setRovingTabindex(gridArea, ".gp-item");
}
function showLoading(): void {
@@ -3,6 +3,7 @@
* Create, copy, and revoke invite codes.
*/
import { applyDialogSemantics, focusDialog, trapFocus } from "@lib/a11y";
import { createElement, appendChildren, clearChildren } from "@lib/dom";
import { createIcon } from "@lib/icons";
import type { MountableComponent } from "@lib/safe-render";
@@ -56,6 +57,7 @@ export function createInviteManager(options: InviteManagerOptions): MountableCom
let root: HTMLDivElement | null = null;
let listEl: HTMLDivElement | null = null;
let emptyEl: HTMLDivElement | null = null;
let restoreFocus: (() => void) | null = null;
let invites: readonly InviteItem[] = options.invites;
function renderList(): void {
@@ -161,11 +163,14 @@ export function createInviteManager(options: InviteManagerOptions): MountableCom
const modal = createElement("div", {
class: "modal",
});
applyDialogSemantics(modal, { labelledBy: "invite-manager-title" });
trapFocus(modal, ac.signal);
// Header
const header = createElement("div", { class: "modal-header" });
const title = createElement("h3", {}, "Server Invites");
const closeBtn = createElement("button", { class: "modal-close" });
const title = createElement("h3", { id: "invite-manager-title" }, "Server Invites");
// Icon-only button: without a label a screen reader announces just "button".
const closeBtn = createElement("button", { class: "modal-close", "aria-label": "Close" });
closeBtn.appendChild(createIcon("x", 14));
closeBtn.addEventListener("click", () => options.onClose(), { signal: ac.signal });
appendChildren(header, title, closeBtn);
@@ -236,6 +241,10 @@ export function createInviteManager(options: InviteManagerOptions): MountableCom
renderList();
container.appendChild(root);
// Capture where focus came from before anything inside the dialog takes
// it, so destroy() can hand it back to the opener.
restoreFocus = focusDialog(modal);
}
function destroy(): void {
@@ -246,6 +255,10 @@ export function createInviteManager(options: InviteManagerOptions): MountableCom
}
listEl = null;
emptyEl = null;
// Every close path (X, backdrop, Escape) funnels through the caller's
// onClose, which calls destroy() — the single place focus returns.
restoreFocus?.();
restoreFocus = null;
}
return { mount, destroy };
@@ -272,7 +272,11 @@ function createMemberItem(
// Moderation actions are permission-gated per item (a role name told us
// nothing about what its bits allow); block/unblock is open to everyone.
const gates = moderationGates(opts.currentUserRole);
// The role name is read live from authStore, not the opts snapshot
// taken once at mount -- dispatcher.ts keeps authStore.user.role
// current on every self MEMBER_UPDATE precisely so gates like this one
// see a promotion/demotion without waiting for the sidebar to rebuild.
const gates = moderationGates(authStore.getState().user?.role ?? opts.currentUserRole);
const showAdminActions = gates.canKick || gates.canBan || gates.canManageRoles;
closeActiveMenu();
@@ -32,6 +32,11 @@ export interface MentionAutocompleteOptions {
/** Called with the token to insert (without the leading "@"). */
readonly onSelect: (token: string) => void;
readonly onClose: () => void;
/**
* Composer textarea the popup completes for; carries combobox semantics and
* aria-activedescendant while the popup is open (see inline-autocomplete).
*/
readonly comboboxInput?: HTMLElement;
}
/** Same shape as the shared inline-autocomplete widget. */
@@ -55,6 +60,10 @@ export function filterMentionSuggestions(query: string): MentionSuggestion[] {
const substring: MentionSuggestion[] = [];
for (const member of membersStore.getState().members.values()) {
// Skip usernames the mention grammar cannot express (a space, an "@",
// etc. truncate the token on insert) -- picking one would insert a dead
// token that resolves to no mention and notifies nobody.
if (!/^[\p{L}\p{N}_.-]{1,64}$/u.test(member.username)) continue;
const lower = member.username.toLowerCase();
if (q !== "" && !lower.includes(q)) continue;
const entry: MentionSuggestion = {
@@ -121,5 +130,6 @@ export function createMentionAutocomplete(
primeOnCreate: true,
onSelect: options.onSelect,
onClose: options.onClose,
comboboxInput: options.comboboxInput,
});
}
@@ -90,7 +90,16 @@ export function wrapWithMarker(
const len = marker.length;
// Already wrapped — pressing the shortcut again takes the markers back off.
if (selected.length > 2 * len && selected.startsWith(marker) && selected.endsWith(marker)) {
// The interior must not itself contain the marker: otherwise a selection
// that merely starts and ends with it (e.g. multiple already-wrapped spans,
// or a longer marker like "**" matching the outer edge of "*x*") would be
// mistaken for a single wrapped span and have its interior markers stripped.
if (
selected.length > 2 * len &&
selected.startsWith(marker) &&
selected.endsWith(marker) &&
!selected.slice(len, selected.length - len).includes(marker)
) {
const inner = selected.slice(len, selected.length - len);
return {
value: value.slice(0, start) + inner + value.slice(end),
@@ -128,6 +137,22 @@ const ALLOWED_TYPES = [
"application/json",
];
/**
* Keys that move the caret without an open autocomplete popup claiming them,
* so the popup has to be resynced against the new caret on keyup. The popup's
* own keys are deliberately absent: it consumes ArrowUp/ArrowDown/Enter/Tab
* (so the caret does not move) and Escape closes it, and resyncing after any
* of those would reset the highlighted row or reopen what Escape dismissed.
*/
const CARET_MOVE_KEYS: ReadonlySet<string> = new Set([
"ArrowLeft",
"ArrowRight",
"Home",
"End",
"PageUp",
"PageDown",
]);
/** Disable the GIF button and say why, instead of silently doing nothing. */
function markGifUnavailable(gifBtn: HTMLButtonElement, reason: string): void {
gifBtn.setAttribute("disabled", "true");
@@ -198,7 +223,13 @@ export function createMessageInput(options: MessageInputOptions): MessageInputCo
/** Replace the token under the caret with "@token ". */
function insertMention(token: string): void {
if (textarea === null || mentionStart < 0) {
// The popup can outlive the token it was opened over: a caret move the
// composer never observed (Ctrl+A, a programmatic selection) leaves
// mentionStart pointing at an offset the caret no longer follows, and
// splicing there garbles the draft instead of completing it. Re-derive
// the token and only commit while it still starts where the popup thinks.
const active = activeMentionToken();
if (textarea === null || mentionStart < 0 || active === null || active.start !== mentionStart) {
closeMentionPopup();
return;
}
@@ -242,7 +273,10 @@ export function createMessageInput(options: MessageInputOptions): MessageInputCo
/** Replace the `:token` under the caret with the chosen emoji, plus a space. */
function insertEmoji(insert: string): void {
if (textarea === null || emojiStart < 0) {
// Same staleness guard as insertMention: never splice at an anchor the
// caret has since moved away from.
const active = activeEmojiToken();
if (textarea === null || emojiStart < 0 || active === null || active.start !== emojiStart) {
closeEmojiPopup();
return;
}
@@ -270,6 +304,9 @@ export function createMessageInput(options: MessageInputOptions): MessageInputCo
emojiPopup = createEmojiAutocomplete({
onSelect: insertEmoji,
onClose: closeEmojiPopup,
// The popup manages combobox/aria-activedescendant state on the
// textarea for as long as it is open.
comboboxInput: textarea ?? undefined,
});
root?.appendChild(emojiPopup.element);
}
@@ -306,6 +343,9 @@ export function createMessageInput(options: MessageInputOptions): MessageInputCo
mentionPopup = createMentionAutocomplete({
onSelect: insertMention,
onClose: closeMentionPopup,
// The popup manages combobox/aria-activedescendant state on the
// textarea for as long as it is open.
comboboxInput: textarea ?? undefined,
});
root?.appendChild(mentionPopup.element);
}
@@ -378,10 +418,21 @@ export function createMessageInput(options: MessageInputOptions): MessageInputCo
},
message,
);
// app.css only shows the preview bar via .visible -- without this an
// error with no attachments already queued renders into a display:none
// container and is never seen.
attachmentPreviewBar.classList.add("visible");
attachmentPreviewBar.appendChild(errEl);
const t = setTimeout(() => {
activeTimers.delete(t);
errEl.remove();
if (
attachmentPreviewBar !== null &&
pendingAttachments.length === 0 &&
attachmentPreviewBar.childElementCount === 0
) {
attachmentPreviewBar.classList.remove("visible");
}
}, 4000);
activeTimers.add(t);
}
@@ -418,7 +469,10 @@ export function createMessageInput(options: MessageInputOptions): MessageInputCo
if (textarea === null) return;
const content = textarea.value.trim();
const hasAttachments = pendingAttachments.length > 0;
if (content.length === 0 && !hasAttachments) return;
// Edits are text-only, so a queued attachment must not unlock submitting
// an edit whose text was cleared -- that would tear down edit mode for a
// send the host refuses anyway.
if (content.length === 0 && (state.editing !== null || !hasAttachments)) return;
// Block send while uploads are still in flight
if (pendingUploadCount > 0) {
@@ -452,8 +506,8 @@ export function createMessageInput(options: MessageInputOptions): MessageInputCo
/** Unique counter for preview items (before upload completes and we have a server ID). */
let previewCounter = 0;
function removePreviewItem(tempId: string): void {
const idx = pendingAttachments.findIndex((a) => a.id === tempId);
function removePreviewItem(el: HTMLDivElement): void {
const idx = pendingAttachments.findIndex((a) => a.previewEl === el);
const att = idx !== -1 ? pendingAttachments[idx] : undefined;
if (att !== undefined) {
const img = att.previewEl.querySelector("img");
@@ -482,6 +536,14 @@ export function createMessageInput(options: MessageInputOptions): MessageInputCo
async function handlePasteFile(file: File): Promise<void> {
if (options.onUploadFile === undefined || attachmentPreviewBar === null) return;
// Attachments queued during an edit are neither sent (the edit branch
// never reads pendingAttachments) nor cleared -- they'd silently ride
// along with the next ordinary message. Refuse at the single entry point.
if (state.editing !== null) {
showUploadError("Can't attach files while editing a message");
return;
}
// Validate file size
if (file.size > MAX_FILE_SIZE) {
showUploadError(`File too large: ${file.name} exceeds 100 MB limit`);
@@ -540,7 +602,7 @@ export function createMessageInput(options: MessageInputOptions): MessageInputCo
"click",
(e) => {
e.stopPropagation();
removePreviewItem(tempId);
removePreviewItem(item);
},
{ signal },
);
@@ -566,7 +628,7 @@ export function createMessageInput(options: MessageInputOptions): MessageInputCo
}
} catch (err) {
// Upload failed — remove preview and show error
removePreviewItem(tempId);
removePreviewItem(item);
const errMsg = err instanceof Error ? err.message : "Upload failed";
showUploadError(`Upload failed: ${errMsg}`);
} finally {
@@ -575,7 +637,9 @@ export function createMessageInput(options: MessageInputOptions): MessageInputCo
}
function setReplyTo(messageId: number, username: string): void {
if (state.editing !== null) hideEditBar();
// cancelEdit also clears the textarea -- without it the stale edit text
// survives into reply mode and Enter reposts it as a duplicate.
if (state.editing !== null) cancelEdit();
state = { replyTo: { messageId, username }, editing: null };
showReplyBar(username);
textarea?.focus();
@@ -643,7 +707,7 @@ export function createMessageInput(options: MessageInputOptions): MessageInputCo
const fileInput = createElement("input", {
type: "file",
style: "display: none;",
accept: "image/*,video/*,audio/*,.pdf,.txt,.zip,.rar,.7z",
accept: "image/*,video/*,audio/*,.pdf,.txt,.zip",
});
fileInput.addEventListener(
"change",
@@ -764,8 +828,17 @@ export function createMessageInput(options: MessageInputOptions): MessageInputCo
{ signal },
);
// Caret moves that aren't typing (click, blur) also decide the popup's fate.
// Caret moves that aren't typing (click, arrow/Home/End keys, blur) also
// decide the popup's fate — without this, completing a mention/emoji
// after moving the caret away with the keyboard splices at a stale offset.
textarea.addEventListener("click", syncAutocomplete, { signal });
textarea.addEventListener(
"keyup",
(e: KeyboardEvent) => {
if (CARET_MOVE_KEYS.has(e.key)) syncAutocomplete();
},
{ signal },
);
textarea.addEventListener(
"blur",
() => {
@@ -881,9 +954,20 @@ export function createMessageInput(options: MessageInputOptions): MessageInputCo
markGifUnavailable(gifBtn, reason);
},
onSelect: (gifUrl: string) => {
if (textarea !== null) {
textarea.value = gifUrl;
handleSend();
// Send the GIF directly instead of routing it through the textarea
// (handleSend's read of textarea.value): that overwrote — and
// discarded — whatever draft the user had typed, and on slow
// mode / mid-upload / debounced sends left the raw GIF URL sitting
// in the composer instead of the draft. Guarded by the same
// disabledReason/debounce checks as a normal send; an in-progress
// edit and any typed draft are left untouched.
if (disabledReason === null) {
const now = Date.now();
if (now - lastSendTime >= SEND_DEBOUNCE_MS) {
lastSendTime = now;
options.onSend(gifUrl, state.replyTo?.messageId ?? null, []);
clearReply();
}
}
closeGifPicker();
},
+124 -28
View File
@@ -36,7 +36,9 @@ export interface MessageListOptions {
readonly channelName: string;
readonly channelType?: string;
readonly currentUserId: number;
readonly onScrollTop: () => void;
/** May return a promise (e.g. the underlying fetch); MessageList clears its
* loadingOlder latch once it settles, success or failure. */
readonly onScrollTop: () => void | Promise<void>;
readonly onReplyClick: (messageId: number) => void;
readonly onEditClick: (messageId: number) => void;
readonly onDeleteClick: (messageId: number) => void;
@@ -156,7 +158,11 @@ function buildVirtualItems(
// A message directly under the NEW line starts a fresh block: rendering it
// as a grouped continuation of a message from before the line hides both
// its author and the fact that the line is there.
const isGrouped = !isFirstUnread && prevMsg !== null && shouldGroup(prevMsg, msg);
const isGrouped =
!isFirstUnread &&
prevMsg !== null &&
isSameDay(prevMsg.timestamp, msg.timestamp) &&
shouldGroup(prevMsg, msg);
items.push({ kind: "message", message: msg, isGrouped });
lastTimestamp = msg.timestamp;
prevMsg = msg;
@@ -237,6 +243,11 @@ export type MessageListComponent = MountableComponent & {
export function createMessageList(options: MessageListOptions): MessageListComponent {
const ac = new AbortController();
const unsubscribers: Array<() => void> = [];
/** Non-scrolling frame around the scroller; what is actually appended to
* the parent. The floating controls anchor to this box — an absolutely
* positioned box whose containing block is the scroller itself sits in
* its scrollable overflow and translates with the content. */
let region: HTMLDivElement | null = null;
let root: HTMLDivElement | null = null;
let wasAtBottom = true;
@@ -266,6 +277,36 @@ export function createMessageList(options: MessageListOptions): MessageListCompo
*/
const unreadOnOpen = isWindowDetached(options.channelId) ? 0 : getUnreadOnOpen(options.channelId);
/**
* Message id the NEW divider is anchored to, once one has been picked.
* `firstUnreadIndex` returns a count-from-the-end offset, which drifts
* whenever the loaded window grows (new messages arrive) between one full
* rebuild and the next — the exact thing unreadOnOpen's doc comment above
* promises won't happen. Latching onto the message id the first valid index
* pointed at keeps the divider glued to that message for the rest of the
* visit regardless of how the window grows around it.
*/
let newDividerAnchorId: number | null = null;
/**
* Resolve the NEW divider's position for this rebuild. Prefers the latched
* anchor id (stable across window growth); falls back to the count formula
* only until an anchor exists, then latches it — skipping id 0 (an
* unconfirmed optimistic row) since that id is not unique across pending
* sends and would anchor to the wrong message once reconciled.
*/
function resolveNewDividerIndex(messages: readonly Message[]): number {
if (newDividerAnchorId !== null) {
return messages.findIndex((m) => m.id === newDividerAnchorId);
}
const idx = firstUnreadIndex(messages, unreadOnOpen);
const anchor = idx !== -1 ? messages[idx] : undefined;
if (anchor !== undefined && anchor.id !== 0) {
newDividerAnchorId = anchor.id;
}
return idx;
}
// ---------------------------------------------------------------------------
// Height estimation (Fenwick tree backed)
// ---------------------------------------------------------------------------
@@ -282,6 +323,21 @@ export function createMessageList(options: MessageListOptions): MessageListCompo
if (item === undefined) return `idx-${index}`;
if (item.kind === "divider") return `div-${item.timestamp}`;
if (item.kind === "new-divider") return "new-divider";
// Every unconfirmed optimistic row (addOptimisticMessage) carries
// id: 0 until confirmSend stamps the real id, so keying purely on
// message.id would collide two or more pending rows onto the same
// "msg-0" cache entry — measureRendered would overwrite one row's
// measured height with another's, and the next Fenwick rebuild
// (rebuildItems / tryAppendMessages) would seed both rows' tree slots
// from that single, wrong value. correlationId is unique per pending
// send and stable across the row's lifetime, so key on that instead
// while id is still the 0 sentinel; fall back to the row's own index
// in the vanishingly unlikely case correlationId is also absent.
if (item.message.id === 0) {
return item.message.correlationId !== null
? `msg-c-${item.message.correlationId}`
: `idx-${index}`;
}
return `msg-${item.message.id}`;
}
@@ -456,12 +512,15 @@ export function createMessageList(options: MessageListOptions): MessageListCompo
const start = Math.max(0, firstVisible - OVERSCAN);
const end = Math.min(virtualItems.length, lastVisible + OVERSCAN + 1);
// Only rebuild DOM if explicitly requested by renderAll (which sets
// renderedStart to -1). Scroll-driven renderWindow calls only update
// spacers — never rebuild content. This prevents the height oscillation
// loop where images loading → height change → range recalculation →
// DOM rebuild → images reload → repeat forever.
if (renderedStart < 0) {
// Rebuild the DOM when explicitly requested by renderAll (which sets
// renderedStart to -1) or when the target range has left the rendered
// window — scrolling past the overscan must materialize the rows the
// spacers are standing in for. When the range is already fully rendered
// this is a no-op, which (together with the rebuild rate limiter below)
// prevents the height oscillation loop where images loading → height
// change → range recalculation → DOM rebuild → images reload → repeat.
const rangeAlreadyRendered = renderedStart >= 0 && start >= renderedStart && end <= renderedEnd;
if (!rangeAlreadyRendered) {
// Rate-limit DOM rebuilds only (expensive path).
// Scroll-driven spacer updates are cheap and don't need limiting.
renderWindowCount++;
@@ -476,7 +535,8 @@ export function createMessageList(options: MessageListOptions): MessageListCompo
}, 2000);
}
// Full rebuild requested by renderAll
// Full rebuild: requested by renderAll, or the window is following a
// scroll into a region that is not rendered yet.
log.debug("renderWindow REBUILD", { start, end });
// Measure current elements before replacing.
@@ -498,9 +558,10 @@ export function createMessageList(options: MessageListOptions): MessageListCompo
measureRendered();
updateSpacers();
} else {
// Scroll-driven: no-op. The ResizeObserver handles measurement and
// spacer updates when element sizes change. Calling measureRendered +
// updateSpacers here creates an infinite feedback loop:
// Target range already fully rendered: no-op. The ResizeObserver
// handles measurement and spacer updates when element sizes change.
// Calling measureRendered + updateSpacers here creates an infinite
// feedback loop:
// spacer change → scrollHeight change → scroll event → renderWindow
// → spacer change → ...
}
@@ -512,12 +573,7 @@ export function createMessageList(options: MessageListOptions): MessageListCompo
function rebuildItems(): void {
allMessages = getChannelMessages(options.channelId);
virtualItems = buildVirtualItems(
allMessages,
null,
null,
firstUnreadIndex(allMessages, unreadOnOpen),
);
virtualItems = buildVirtualItems(allMessages, null, null, resolveNewDividerIndex(allMessages));
// Build Fenwick tree initialized with smart estimates / cached heights
tree = new FenwickTree(virtualItems.length);
@@ -616,6 +672,13 @@ export function createMessageList(options: MessageListOptions): MessageListCompo
let renderAllRunning = false;
let renderAllCount = 0;
let renderAllResetTimer = 0;
// Set when the rapid-fire breaker below drops a renderAll() call on the
// floor. The store change that triggered the dropped call is still live —
// without this, the DOM is left showing pre-burst state until some later,
// unrelated store event happens to call renderAll() again. The 2s reset
// timeout checks this flag and issues one final renderAll() so the burst's
// last state always makes it to the screen.
let renderAllSuppressed = false;
function renderAll(): void {
if (root === null) return;
@@ -626,12 +689,18 @@ export function createMessageList(options: MessageListOptions): MessageListCompo
renderAllCount++;
if (renderAllCount > 20) {
log.error("[MessageList] renderAll called >20 times in 2s — breaking loop");
renderAllSuppressed = true;
return;
}
if (renderAllResetTimer === 0) {
renderAllResetTimer = window.setTimeout(() => {
renderAllCount = 0;
renderAllResetTimer = 0;
if (renderAllSuppressed) {
// Render the burst's final state once, now that it's over.
renderAllSuppressed = false;
renderAll();
}
}, 2000);
}
@@ -683,14 +752,22 @@ export function createMessageList(options: MessageListOptions): MessageListCompo
// ---------------------------------------------------------------------------
let loadingOlder = false;
let prevMessageCount = 0;
// The oldest loaded message's id, not the count: a live tail append also
// changes the count while a history fetch is still in flight, and
// resetting the latch on that lets the next scroll refire loadOlderMessages
// with the same unchanged cursor -- the same page then lands twice. Only a
// prepend moves messages[0]. Seeded from the current state (not left at a
// placeholder) so the first change observed after construction is compared
// against reality, not an arbitrary initial value.
let prevOldestId: number | null = getChannelMessages(options.channelId)[0]?.id ?? null;
const unsubLoadingReset = messagesStore.subscribeSelector(
(s) => s.messagesByChannel,
() => {
const msgs = getChannelMessages(options.channelId);
if (msgs.length !== prevMessageCount) {
prevMessageCount = msgs.length;
const oldestId = msgs.length > 0 ? msgs[0]!.id : null;
if (oldestId !== prevOldestId) {
prevOldestId = oldestId;
loadingOlder = false;
}
},
@@ -710,7 +787,14 @@ export function createMessageList(options: MessageListOptions): MessageListCompo
hasMoreMessages(options.channelId)
) {
loadingOlder = true;
options.onScrollTop();
// A failed fetch never changes the message count, so the subscriber
// below (which only reacts to a count change) would leave loadingOlder
// latched forever. Clear it once the load settles either way — the
// subscriber's reset still applies to the success path but is now just
// belt-and-braces.
void Promise.resolve(options.onScrollTop()).finally(() => {
loadingOlder = false;
});
}
// Update floating scroll-to-bottom button visibility
@@ -730,6 +814,7 @@ export function createMessageList(options: MessageListOptions): MessageListCompo
// ---------------------------------------------------------------------------
function mount(parentContainer: Element): void {
region = createElement("div", { class: "messages-region" });
root = createElement("div", { class: "messages-container" });
topSpacer = createElement("div", { class: "virtual-spacer-top" });
@@ -761,8 +846,9 @@ export function createMessageList(options: MessageListOptions): MessageListCompo
root.appendChild(contentContainer);
root.appendChild(bottomSpacer);
root.appendChild(scrollAnchor);
root.appendChild(scrollToBottomBtn);
root.appendChild(jumpToPresentPill);
region.appendChild(root);
region.appendChild(scrollToBottomBtn);
region.appendChild(jumpToPresentPill);
root.addEventListener("scroll", handleScroll, {
signal: ac.signal,
@@ -801,7 +887,7 @@ export function createMessageList(options: MessageListOptions): MessageListCompo
});
resizeObserver.observe(contentContainer);
parentContainer.appendChild(root);
parentContainer.appendChild(region);
renderAll();
updateJumpToPresentPill();
@@ -887,10 +973,11 @@ export function createMessageList(options: MessageListOptions): MessageListCompo
heightCache.clear();
tree = null;
releaseTrackedMedia();
if (root !== null) {
root.remove();
root = null;
if (region !== null) {
region.remove();
region = null;
}
root = null;
contentContainer = null;
topSpacer = null;
bottomSpacer = null;
@@ -912,6 +999,15 @@ export function createMessageList(options: MessageListOptions): MessageListCompo
renderedStart = -1;
renderWindow();
// renderWindow's own rapid-rebuild breaker can return before reassigning
// renderedStart (it stays -1, the value forced above) when too many
// rebuilds have fired in the last 2s. When that happens the DOM was never
// rebuilt for this target — report a failed jump rather than computing a
// localIdx against a sentinel and flashing/reporting success for a row
// that never rendered. This lets callers (e.g. MessageJump) fall back to
// fetching the around-window instead of treating this as a landed jump.
if (renderedStart < 0) return false;
// Briefly highlight the target message element
if (contentContainer !== null) {
const localIdx = idx - renderedStart;
@@ -61,11 +61,17 @@ function renderPinnedItem(
// Hover actions
const actions = createElement("div", { class: "pinned-msg__actions" });
const jumpBtn = createElement("button", { title: "Jump to message" });
// Icon-only buttons: title= only tooltips for mouse users, so mirror it as
// an aria-label for screen readers.
const jumpBtn = createElement("button", {
title: "Jump to message",
"aria-label": "Jump to message",
});
jumpBtn.appendChild(createIcon("external-link", 14));
const unpinBtn = createElement("button", {
class: "pinned-msg__unpin",
title: "Unpin message",
"aria-label": "Unpin message",
});
unpinBtn.appendChild(createIcon("x", 14));
@@ -94,7 +100,13 @@ export function createPinnedMessages(options: PinnedMessagesOptions): MountableC
let root: HTMLDivElement | null = null;
function mount(container: Element): void {
root = createElement("div", { class: "pinned-panel" });
// A side panel, not a modal: complementary landmark (no aria-modal, no
// focus trap), matching DmProfileSidebar.
root = createElement("div", {
class: "pinned-panel",
role: "complementary",
"aria-label": "Pinned messages",
});
// Header
const header = createElement("div", { class: "pinned-panel__header" });
@@ -106,7 +118,10 @@ export function createPinnedMessages(options: PinnedMessagesOptions): MountableC
const count = createElement("span", { class: "pinned-panel__count" });
count.textContent = String(options.pinnedMessages.length);
const closeBtn = createElement("button", { class: "pinned-panel__close" });
const closeBtn = createElement("button", {
class: "pinned-panel__close",
"aria-label": "Close pinned messages",
});
closeBtn.appendChild(createIcon("x", 16));
closeBtn.addEventListener("click", () => options.onClose(), { signal: ac.signal });
@@ -4,6 +4,7 @@
* Uses @lib/dom helpers exclusively. Never sets innerHTML with user content.
*/
import { applyDialogSemantics, focusDialog, trapFocus } from "@lib/a11y";
import { createElement, appendChildren, setText } from "@lib/dom";
import type { MountableComponent } from "@lib/safe-render";
@@ -31,6 +32,7 @@ export interface QuickSwitchOverlayOptions {
export function createQuickSwitchOverlay(options: QuickSwitchOverlayOptions): MountableComponent {
const ac = new AbortController();
let root: HTMLDivElement | null = null;
let restoreFocus: (() => void) | null = null;
function mount(container: Element): void {
root = createElement("div", {
@@ -48,6 +50,8 @@ export function createQuickSwitchOverlay(options: QuickSwitchOverlayOptions): Mo
);
const modal = createElement("div", { class: "quick-switch-modal" });
applyDialogSemantics(modal, { label: "Switch server" });
trapFocus(modal, ac.signal);
// Header
const header = createElement("div", { class: "quick-switch-header" });
@@ -64,11 +68,18 @@ export function createQuickSwitchOverlay(options: QuickSwitchOverlayOptions): Mo
for (const profile of options.profiles) {
const isCurrent = profile.host === options.currentHost;
const item = createElement("div", {
const attrs: Record<string, string> = {
class: `quick-switch-item${isCurrent ? " current" : ""}`,
"data-testid": "server-item",
"data-host": profile.host,
});
};
// Only actionable rows get button semantics — the connected row has no
// click handler, and a "button" that does nothing lies to screen readers.
if (!isCurrent) {
attrs["role"] = "button";
attrs["tabindex"] = "0";
}
const item = createElement("div", attrs);
const icon = createElement("div", { class: "quick-switch-icon" });
setText(icon, profile.name.charAt(0).toUpperCase());
@@ -94,6 +105,18 @@ export function createQuickSwitchOverlay(options: QuickSwitchOverlayOptions): Mo
},
{ signal: ac.signal },
);
// Divs get no native key activation; Enter/Space mirrors the click
// so the row honors the button role it advertises.
item.addEventListener(
"keydown",
(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
options.onSwitch(profile.host, profile.name);
}
},
{ signal: ac.signal },
);
}
list.appendChild(item);
@@ -103,6 +126,8 @@ export function createQuickSwitchOverlay(options: QuickSwitchOverlayOptions): Mo
const addItem = createElement("div", {
class: "quick-switch-item add-new",
"data-testid": "add-server-btn",
role: "button",
tabindex: "0",
});
const addIcon = createElement("div", { class: "quick-switch-icon add" }, "+");
const addInfo = createElement("div", { class: "quick-switch-info" });
@@ -115,6 +140,16 @@ export function createQuickSwitchOverlay(options: QuickSwitchOverlayOptions): Mo
appendChildren(addInfo, addName, addHost);
appendChildren(addItem, addIcon, addInfo);
addItem.addEventListener("click", () => options.onAddServer(), { signal: ac.signal });
addItem.addEventListener(
"keydown",
(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
options.onAddServer();
}
},
{ signal: ac.signal },
);
list.appendChild(addItem);
// Footer
@@ -124,6 +159,10 @@ export function createQuickSwitchOverlay(options: QuickSwitchOverlayOptions): Mo
root.appendChild(modal);
container.appendChild(root);
// Move focus onto the first actionable row (or the modal itself) and
// remember the opener — the UserBar switch button — for destroy().
restoreFocus = focusDialog(modal);
// Escape key closes overlay
document.addEventListener(
"keydown",
@@ -140,6 +179,10 @@ export function createQuickSwitchOverlay(options: QuickSwitchOverlayOptions): Mo
root.remove();
root = null;
}
// Restore after removal so focus cannot land on a node inside the
// just-detached overlay.
restoreFocus?.();
restoreFocus = null;
}
return { mount, destroy };
@@ -1,6 +1,7 @@
// Step 8.60 — Quick switcher modal (Ctrl+K) for fast channel navigation.
// Uses @lib/dom helpers exclusively. Never sets innerHTML with user content.
import { applyDialogSemantics, focusDialog, trapFocus } from "@lib/a11y";
import { createElement, setText, appendChildren, clearChildren } from "@lib/dom";
import { createIcon } from "@lib/icons";
import { channelsStore } from "@stores/channels.store";
@@ -22,6 +23,7 @@ export function createQuickSwitcher(options: QuickSwitcherOptions): MountableCom
let activeIndex = 0;
let filteredChannels: readonly Channel[] = [];
let unsubscribe: (() => void) | null = null;
let restoreFocus: (() => void) | null = null;
function getChannelIcon(ch: Channel): SVGSVGElement {
return ch.type === "voice" ? createIcon("volume-2", 14) : createIcon("hash", 14);
@@ -29,7 +31,11 @@ export function createQuickSwitcher(options: QuickSwitcherOptions): MountableCom
function getFilteredChannels(query: string): readonly Channel[] {
const state = channelsStore.getState();
const all = Array.from(state.channels.values());
// DM rows are synthesized into channelsStore once opened, but they have
// their own sidebar path (full clearDmUnread/setSidebarMode handling) —
// listing them here too would select via a bare setActiveChannel and
// leave their unread/mention badge lit forever.
const all = Array.from(state.channels.values()).filter((ch) => ch.type !== "dm");
const sorted = [...all].toSorted((a, b) => a.position - b.position);
if (query.length === 0) return sorted;
@@ -51,6 +57,12 @@ export function createQuickSwitcher(options: QuickSwitcherOptions): MountableCom
? "quick-switcher__item quick-switcher__item--active"
: "quick-switcher__item",
"data-channelid": String(ch.id),
// Combobox option wiring: the id feeds aria-activedescendant so a
// screen reader tracks the roving --active highlight without the
// input ever losing DOM focus.
id: `qs-option-${i}`,
role: "option",
"aria-selected": isActive ? "true" : "false",
});
const icon = createElement("span", { class: "quick-switcher__icon" });
@@ -79,6 +91,16 @@ export function createQuickSwitcher(options: QuickSwitcherOptions): MountableCom
resultsDiv.appendChild(item);
}
// Re-point aria-activedescendant on every render — arrow keys, filtering
// and store refreshes all funnel through here, so it can never go stale.
// An empty result set clears it; pointing at a missing id is worse than
// pointing at nothing.
if (filteredChannels.length > 0) {
input.setAttribute("aria-activedescendant", `qs-option-${activeIndex}`);
} else {
input.removeAttribute("aria-activedescendant");
}
}
function handleInput(): void {
@@ -130,11 +152,14 @@ export function createQuickSwitcher(options: QuickSwitcherOptions): MountableCom
}
function handleGlobalKeydown(e: KeyboardEvent): void {
if ((e.ctrlKey || e.metaKey) && e.key === "k") {
e.preventDefault();
if (root !== null && root.parentNode !== null) {
options.onClose();
}
// Same case-insensitive, altKey-excluding match as
// OverlayManagers.ts's open handler (OC-0150) — otherwise this close
// path goes dead under CapsLock and AltGr swallows a keystroke for
// nothing.
if (!(e.ctrlKey || e.metaKey) || e.altKey || e.key.toLowerCase() !== "k") return;
e.preventDefault();
if (root !== null && root.parentNode !== null) {
options.onClose();
}
}
@@ -154,21 +179,39 @@ export function createQuickSwitcher(options: QuickSwitcherOptions): MountableCom
// Modal container
const modal = createElement("div", { class: "quick-switcher" });
applyDialogSemantics(modal, { label: "Quick switcher" });
trapFocus(modal, signal);
// Search input
// Search input — combobox over the results listbox: the input keeps DOM
// focus while aria-activedescendant (set in renderResults) names the row
// the arrow keys have highlighted. The list is always rendered, so
// aria-expanded is statically true.
input = createElement("input", {
class: "quick-switcher__input",
type: "text",
placeholder: "Where do you want to go?",
role: "combobox",
"aria-expanded": "true",
"aria-autocomplete": "list",
"aria-controls": "quick-switcher-results",
});
// Results list
resultsDiv = createElement("div", { class: "quick-switcher__results" });
resultsDiv = createElement("div", {
class: "quick-switcher__results",
id: "quick-switcher-results",
role: "listbox",
});
appendChildren(modal, input, resultsDiv);
root.appendChild(modal);
container.appendChild(root);
// Capture the opener before anything inside grabs focus — Ctrl+K comes
// from the composer, and a keyboard user needs destroy() to land them
// back there, not at the top of the document.
restoreFocus = focusDialog(modal);
// Initial render
filteredChannels = getFilteredChannels("");
renderResults();
@@ -194,6 +237,10 @@ export function createQuickSwitcher(options: QuickSwitcherOptions): MountableCom
}
root?.remove();
root = null;
// Restore after the overlay is gone, so focus cannot land on a node the
// removal is about to detach.
restoreFocus?.();
restoreFocus = null;
}
return { mount, destroy };
@@ -93,15 +93,6 @@ export function createSearchOverlay(options: SearchOverlayOptions): MountableCom
appendChildren(item, header, content);
item.addEventListener(
"click",
() => {
options.onSelectResult(r);
options.onClose();
},
{ signal },
);
resultsDiv.appendChild(item);
}
}
@@ -200,6 +191,26 @@ export function createSearchOverlay(options: SearchOverlayOptions): MountableCom
}
}
// Single delegated listener for the results container, registered once at
// mount time. renderResults() re-creates row elements on every search and
// on every arrow-key navigation, so binding a listener directly to each row
// would re-register (and never release) one abort algorithm per discarded
// row for the lifetime of the overlay.
function handleResultsClick(e: MouseEvent): void {
const target = e.target;
if (!(target instanceof Element)) return;
const row = target.closest(".search-result-item");
if (!row) return;
const testId = row.getAttribute("data-testid");
if (!testId) return;
const idx = Number(testId.slice("search-result-".length));
const r = results[idx];
if (r !== undefined) {
options.onSelectResult(r);
options.onClose();
}
}
function mount(container: Element): void {
root = createElement("div", {
class: "search-overlay open",
@@ -234,6 +245,7 @@ export function createSearchOverlay(options: SearchOverlayOptions): MountableCom
input.addEventListener("input", handleInput, { signal });
input.addEventListener("keydown", handleKeydown, { signal });
root.addEventListener("click", handleBackdropClick, { signal });
resultsDiv.addEventListener("click", handleResultsClick, { signal });
requestAnimationFrame(() => input.focus());
}
@@ -1,48 +0,0 @@
/**
* ServerStrip component — vertical strip on the far left showing server icons.
* Single-server for now: Home button, separator, add server button.
*/
import { createElement, appendChildren } from "@lib/dom";
import type { MountableComponent } from "@lib/safe-render";
export function createServerStrip(): MountableComponent {
const ac = new AbortController();
let root: HTMLDivElement | null = null;
function mount(container: Element): void {
root = createElement("div", { class: "server-strip", "data-testid": "server-strip" });
const homeIcon = createElement(
"div",
{ class: "server-icon active", style: "background: var(--accent)" },
"O",
);
const separator = createElement("div", { class: "server-separator" });
const addIcon = createElement("div", { class: "server-icon add" }, "+");
// Add server button click — placeholder for future multi-server support
addIcon.addEventListener(
"click",
() => {
// No-op for single-server mode
},
{ signal: ac.signal },
);
appendChildren(root, homeIcon, separator, addIcon);
container.appendChild(root);
}
function destroy(): void {
ac.abort();
if (root !== null) {
root.remove();
root = null;
}
}
return { mount, destroy };
}
@@ -4,6 +4,7 @@
* Subscribes to uiStore for settingsOpen state.
*/
import { applyDialogSemantics, focusDialog, trapFocus } from "@lib/a11y";
import { createElement, appendChildren, clearChildren } from "@lib/dom";
import { createIcon } from "@lib/icons";
import type { IconName } from "@lib/icons";
@@ -73,6 +74,11 @@ const TAB_ICONS: Record<TabName, IconName> = {
Logs: "scroll-text",
};
/** Stable DOM id for a tab button (aria-labelledby target), e.g. "settings-tab-text-images". */
function tabId(name: TabName): string {
return `settings-tab-${name.toLowerCase().replace(/[^a-z0-9]+/g, "-")}`;
}
// ---------------------------------------------------------------------------
// Factory
// ---------------------------------------------------------------------------
@@ -83,11 +89,14 @@ export function createSettingsOverlay(
const ac = new AbortController();
const authenticated = options.isAuthenticated !== false;
let root: HTMLDivElement | null = null;
let panel: HTMLDivElement | null = null;
let contentArea: HTMLDivElement | null = null;
let pageTitle: HTMLHeadingElement | null = null;
let activeTab: TabName = authenticated ? "Account" : "Appearance";
/** False once the active tab's content has been torn down by `hide()`. */
let contentLive = false;
/** Puts focus back on whatever opened the panel; null while closed. */
let restoreFocus: (() => void) | null = null;
const tabButtons = new Map<TabName, HTMLButtonElement>();
let unsubUi: (() => void) | null = null;
let unsubAuth: (() => void) | null = null;
@@ -139,16 +148,25 @@ export function createSettingsOverlay(
for (const [name, btn] of tabButtons) {
btn.classList.toggle("active", name === tab);
btn.setAttribute("aria-selected", name === tab ? "true" : "false");
// Roving tabindex: only the active tab sits in the page Tab order.
btn.setAttribute("tabindex", name === tab ? "0" : "-1");
}
contentArea?.setAttribute("aria-labelledby", tabId(tab));
renderActiveTab();
}
function show(): void {
const wasOpen = root?.classList.contains("open") ?? false;
root?.classList.add("open");
// Closing tore down the live parts of the active tab (mic meter, camera
// preview, log listener). Rebuild it so a reopened panel shows live state
// instead of a frozen snapshot — and so every tab re-reads current prefs.
if (!contentLive) renderActiveTab();
// Move focus in only on the closed→open transition — a repeated show()
// would otherwise capture an element inside the panel as the "opener".
if (!wasOpen && panel !== null) {
restoreFocus = focusDialog(panel);
}
}
function hide(): void {
@@ -156,6 +174,9 @@ export function createSettingsOverlay(
// Stop camera preview, mic meter, and the log listener when the overlay closes
cleanupActiveTab();
contentLive = false;
// Hand focus back to whatever opened the panel.
restoreFocus?.();
restoreFocus = null;
}
// ---- MountableComponent ---------------------------------------------------
@@ -163,8 +184,42 @@ export function createSettingsOverlay(
function mount(container: Element): void {
root = createElement("div", { class: "settings-overlay", "data-testid": "settings-overlay" });
// Sidebar
const sidebar = createElement("div", { class: "settings-sidebar" });
// Sidebar. It doubles as the tablist: the profile block, category headings
// ("User Settings" / "App Settings") and the Log Out button also live in
// here, and a tablist should own only tabs — but moving them out would
// change the structure the e2e selectors pin down, so we accept that the
// non-tab children are presentational noise inside the tablist (DC-13).
const sidebar = createElement("div", {
class: "settings-sidebar",
role: "tablist",
"aria-orientation": "vertical",
"aria-label": "Settings sections",
});
// Arrow-key navigation between tabs, activate-on-focus (the simpler
// conformant flavor of the WAI-ARIA tabs pattern). Vertical list, so only
// Up/Down move; Home/End jump to the edges; both directions wrap.
sidebar.addEventListener(
"keydown",
(e: KeyboardEvent) => {
if (e.key !== "ArrowDown" && e.key !== "ArrowUp" && e.key !== "Home" && e.key !== "End") {
return;
}
const order = [...tabButtons.keys()];
const current = order.findIndex((name) => tabButtons.get(name) === e.target);
if (current === -1) return; // e.g. the Log Out button — not a tab
e.preventDefault();
let next: number;
if (e.key === "ArrowDown") next = (current + 1) % order.length;
else if (e.key === "ArrowUp") next = (current - 1 + order.length) % order.length;
else if (e.key === "Home") next = 0;
else next = order.length - 1;
const name = order[next]!;
setActiveTab(name);
tabButtons.get(name)?.focus();
},
{ signal: ac.signal },
);
// User profile section at top of sidebar
const user = authStore.getState().user;
@@ -213,8 +268,10 @@ export function createSettingsOverlay(
const accountBtn = createElement("button", {
class: `settings-nav-item${activeTab === "Account" ? " active" : ""}`,
id: tabId("Account"),
role: "tab",
"aria-selected": activeTab === "Account" ? "true" : "false",
tabindex: activeTab === "Account" ? "0" : "-1",
});
accountBtn.prepend(createIcon(TAB_ICONS["Account"], 18));
accountBtn.appendChild(document.createTextNode("Account"));
@@ -240,8 +297,10 @@ export function createSettingsOverlay(
for (const name of appTabs) {
const btn = createElement("button", {
class: `settings-nav-item${name === activeTab ? " active" : ""}`,
id: tabId(name),
role: "tab",
"aria-selected": name === activeTab ? "true" : "false",
tabindex: name === activeTab ? "0" : "-1",
});
btn.prepend(createIcon(TAB_ICONS[name], 18));
btn.appendChild(document.createTextNode(name));
@@ -263,8 +322,12 @@ export function createSettingsOverlay(
// Page title (h1) at top of content area — created here, inserted in renderActiveTab
pageTitle = createElement("h1", {}, activeTab);
// Content
contentArea = createElement("div", { class: "settings-content" });
// Content — the single tabpanel, renamed per switch via aria-labelledby
contentArea = createElement("div", {
class: "settings-content",
role: "tabpanel",
"aria-labelledby": tabId(activeTab),
});
// Close button wrapped with ESC label
const closeWrap = createElement("div", { class: "settings-close-wrap" });
@@ -292,7 +355,11 @@ export function createSettingsOverlay(
);
// Inner panel (Discord-style centered card)
const panel = createElement("div", { class: "settings-panel" });
panel = createElement("div", { class: "settings-panel" });
applyDialogSemantics(panel, { label: "Settings" });
// Arming the trap while hidden is safe: Tab can't land inside a
// display:none panel, so the handler only fires while the overlay is open.
trapFocus(panel, ac.signal);
appendChildren(panel, sidebar, contentArea, closeWrap);
// Click backdrop (outside panel) to close
@@ -343,10 +410,14 @@ export function createSettingsOverlay(
logsTab.cleanup();
voiceTab.cleanup();
tabButtons.clear();
// Tearing down while open still hands focus back to the opener.
restoreFocus?.();
restoreFocus = null;
if (root !== null) {
root.remove();
root = null;
}
panel = null;
contentArea = null;
pageTitle = null;
}
+10 -1
View File
@@ -100,7 +100,16 @@ export function createToastContainer(): ToastContainer {
}
function mount(container: Element): void {
root = createElement("div", { class: "toast-container", "data-testid": "toast-container" });
// One polite live region for all toasts (DC-13): screen readers announce
// each toast as it is appended without interrupting current speech.
// aria-atomic="false" so only the newly added toast is read, not the stack.
root = createElement("div", {
class: "toast-container",
"data-testid": "toast-container",
role: "status",
"aria-live": "polite",
"aria-atomic": "false",
});
container.appendChild(root);
}
@@ -56,7 +56,13 @@ export function createTypingIndicator(options: TypingIndicatorOptions): Mountabl
}
function mount(container: Element): void {
root = createElement("div", { class: "typing-bar" });
// Polite live region (DC-13): "X is typing" changes are announced without
// interrupting whatever the screen reader is currently speaking.
root = createElement("div", {
class: "typing-bar",
role: "status",
"aria-live": "polite",
});
updateFromState();
@@ -31,6 +31,7 @@ export function createUpdateNotifier(options: UpdateNotifierOptions): MountableC
let container: Element | null = null;
let banner: HTMLDivElement | null = null;
let dismissed = false;
let checkTimer: ReturnType<typeof setTimeout> | null = null;
async function performCheck(): Promise<void> {
if (dismissed) return;
@@ -90,6 +91,10 @@ export function createUpdateNotifier(options: UpdateNotifierOptions): MountableC
// App will relaunch — this code won't execute after relaunch()
} catch (err) {
log.error("Update install failed", { error: String(err) });
// The component may have been destroyed while the download was in
// flight (page swap / logout) -- the banner it wanted to repaint is
// already gone, so there is nothing left to do.
if (banner === null) return;
while (banner.firstChild) banner.removeChild(banner.firstChild);
const errorText = createElement(
"span",
@@ -119,12 +124,17 @@ export function createUpdateNotifier(options: UpdateNotifierOptions): MountableC
function mount(target: Element): void {
container = target;
// Delay the check slightly so the main UI renders first
setTimeout(() => {
checkTimer = setTimeout(() => {
checkTimer = null;
void performCheck();
}, 3000);
}
function destroy(): void {
if (checkTimer !== null) {
clearTimeout(checkTimer);
checkTimer = null;
}
removeBanner();
container = null;
}
+37 -21
View File
@@ -21,10 +21,21 @@ import {
import { avatarInitial, isRenderableAvatar, resolveDisplayName } from "@lib/avatar";
import { fetchImageAsDataUrl, resolveServerUrl } from "@components/message-list/attachments";
import type { WsClient } from "@lib/ws";
import type { PresenceSender } from "@lib/presence";
export interface UserBarOptions {
readonly onDisconnect?: () => void;
readonly ws?: WsClient | null;
/**
* The session's single shared presence sender (MainPage owns the instance
* and threads it to every producer — auto-idle, the settings Account tab,
* and this picker). Sending straight through `ws` instead would bypass the
* presence rate limiter's client-side token *and* its retry, so a frame
* the server drops (1 update / 10s, keyed by user id — service/
* channel.go) is lost for the rest of the session instead of retried
* (OC-0210). Required, alongside `ws`, for the picker to be enabled.
*/
readonly presenceSender?: PresenceSender | null;
}
/** Status labels for the line under the username. */
@@ -120,30 +131,36 @@ export function createUserBar(options?: UserBarOptions): MountableComponent {
});
avatarTextEl = createElement("span", {});
avatarEl.appendChild(avatarTextEl);
const statusDot = createElement("div", {
class: "status-dot",
style:
"background: var(--green); width: 10px; height: 10px; border-radius: 50%; position: absolute; bottom: 0; right: 0;",
});
avatarEl.appendChild(statusDot);
const info = createElement("div", { class: "ub-info" });
nameEl = createElement("span", { class: "ub-name", "data-testid": "user-bar-name" });
statusEl = createElement("span", { class: "ub-status" });
appendChildren(info, nameEl, statusEl);
// Status picker — anchored below username, opens upward
// Status picker — the dot itself lives in the avatar's corner (same spot
// the old plain status indicator occupied) so it doubles as the status
// display and its click target; the dropdown still opens upward from there.
const statusPickerWrap = createElement("div", {
class: "ub-status-picker-wrap",
"data-testid": "status-picker-wrap",
});
// The picker is usable only when the socket is live (store-backed status,
// docs/architecture/ux §3) AND a ws client was provided to send through —
// without a send path, selecting a status would be a silent no-op.
// docs/architecture/ux §3) AND a presence sender was provided to send
// through — without one, selecting a status would either be a silent
// no-op or (worse) bypass the shared presence rate limiter and its retry
// (OC-0210). `ws` is checked too since a sender without a live socket
// behind it is not meaningfully usable either.
const canSetStatus = (): boolean => {
const ws = options?.ws;
return ws !== undefined && ws !== null && uiStore.getState().connectionStatus === "connected";
const sender = options?.presenceSender;
return (
ws !== undefined &&
ws !== null &&
sender !== undefined &&
sender !== null &&
uiStore.getState().connectionStatus === "connected"
);
};
statusPicker = createStatusPicker({
@@ -154,21 +171,20 @@ export function createUserBar(options?: UserBarOptions): MountableComponent {
onStatusChange: (status: UserStatus) => {
saveUserStatus(status);
updateFromState();
const ws = options?.ws;
if (ws !== null && ws !== undefined && canSetStatus()) {
const sender = options?.presenceSender;
if (sender !== null && sender !== undefined && canSetStatus()) {
// No custom_status field: a plain status change must leave whatever
// text the user set standing.
ws.send({ type: "presence_update", payload: { status } } as never);
// text the user set standing. Routed through the shared sender
// (not ws.send directly) so a frame the presence limiter's window
// rejects is retried instead of lost — see @lib/presence.
sender.send(status);
}
},
onCustomStatusChange: (text: string) => {
saveCustomStatus(text);
const ws = options?.ws;
if (ws !== null && ws !== undefined && canSetStatus()) {
ws.send({
type: "presence_update",
payload: { status: loadUserStatus(), custom_status: text },
} as never);
const sender = options?.presenceSender;
if (sender !== null && sender !== undefined && canSetStatus()) {
sender.send(loadUserStatus(), text);
}
},
});
@@ -203,7 +219,7 @@ export function createUserBar(options?: UserBarOptions): MountableComponent {
() => updatePickerDisabled(),
);
info.appendChild(statusPickerWrap);
avatarEl.appendChild(statusPickerWrap);
const buttons = createElement("div", { class: "ub-controls" });
@@ -3,8 +3,9 @@
* in the chat or member list. Shows avatar, username, role badge, status dot,
* about section, join date, and Message/Call action buttons.
*
* Position: anchored to click point, flips if <100px from viewport edge.
* Animation: fade+scale 100ms.
* Position: anchored to the click point, flipped to the other side and clamped
* against the measured card height so it always lands fully on screen.
* Animation: fade+scale, defined in CSS so reduced-motion can drop it.
* Close: outside click or Escape.
* A11y: role="dialog", aria-label, focus trap, return focus on close.
*/
@@ -57,8 +58,10 @@ export type UserProfilePopupComponent = MountableComponent & {
// ---------------------------------------------------------------------------
const POPUP_WIDTH = 300;
const EDGE_THRESHOLD = 100;
const ANIMATION_DURATION_MS = 100;
/** Keeps the card clear of the window edges on both axes. */
const VIEWPORT_MARGIN = 8;
/** Breathing room between the click point and the card. */
const ANCHOR_GAP = 8;
const STATUS_COLORS: Record<UserStatus, string> = {
online: "#3ba55d",
@@ -110,28 +113,37 @@ export function createUserProfilePopup(
}
}
function computePosition(anchorX: number, anchorY: number): { left: number; top: number } {
/**
* Place the card beside the anchor, flipping and clamping so it always lands
* fully on screen — Discord opens its popout away from whichever edge the
* clicked row is nearest.
*
* The height is measured rather than assumed. The previous version guessed
* 300px and only clamped the top edge, so a member clicked low in the list
* opened a card that ran off the bottom of the window.
*/
function position(el: HTMLElement, anchorX: number, anchorY: number): void {
const vw = window.innerWidth;
const vh = window.innerHeight;
const height = el.offsetHeight;
let left = anchorX;
// Prefer the right of the anchor and flip left when there is no room. The
// member list sits against the right edge, so flipping is the usual case.
let left = anchorX + ANCHOR_GAP;
if (left + POPUP_WIDTH > vw - VIEWPORT_MARGIN) {
left = anchorX - POPUP_WIDTH - ANCHOR_GAP;
}
left = Math.max(VIEWPORT_MARGIN, Math.min(left, vw - POPUP_WIDTH - VIEWPORT_MARGIN));
// Align the top with the click, then lift the card just enough to fit.
let top = anchorY;
// Flip horizontally if too close to right edge
if (vw - anchorX < EDGE_THRESHOLD) {
left = anchorX - POPUP_WIDTH;
if (top + height > vh - VIEWPORT_MARGIN) {
top = vh - height - VIEWPORT_MARGIN;
}
top = Math.max(VIEWPORT_MARGIN, top);
// Flip vertically if too close to bottom edge
if (vh - anchorY < EDGE_THRESHOLD) {
top = anchorY - 300; // approximate popup height
}
// Clamp to viewport
left = Math.max(8, Math.min(left, vw - POPUP_WIDTH - 8));
top = Math.max(8, top);
return { left, top };
el.style.left = `${left}px`;
el.style.top = `${top}px`;
}
function buildAvatar(user: UserProfileData): HTMLDivElement {
@@ -182,17 +194,8 @@ export function createUserProfilePopup(
"data-testid": "user-profile-popup",
});
// Position the popup
const pos = computePosition(options.anchorX, options.anchorY);
popup.style.left = `${pos.left}px`;
popup.style.top = `${pos.top}px`;
popup.style.width = `${POPUP_WIDTH}px`;
// Animation: fade + scale
popup.style.opacity = "0";
popup.style.transform = "scale(0.95)";
popup.style.transition = `opacity ${ANIMATION_DURATION_MS}ms ease, transform ${ANIMATION_DURATION_MS}ms ease`;
// --- Content ---
// Avatar
@@ -298,10 +301,12 @@ export function createUserProfilePopup(
actions.appendChild(callBtn);
}
// Assemble popup
// Assemble the card: a banner strip and a body, with the avatar straddling
// the seam between them the way Discord's popout does.
const banner = createElement("div", { class: "upp-banner" });
const body = createElement("div", { class: "upp-body" });
appendChildren(
popup,
avatar,
body,
nameEl,
handleEl,
customStatusEl,
@@ -311,17 +316,24 @@ export function createUserProfilePopup(
joinSection,
);
if (actions.childElementCount > 0) {
appendChildren(popup, divider, actions);
appendChildren(body, divider, actions);
}
// The avatar hangs off the body's top edge, so it is a child of the card
// rather than the body — the body scrolls, and a scroll container clips.
// Appending it last puts it over the banner without needing a z-index.
appendChildren(popup, banner, body, avatar);
overlay.appendChild(popup);
container.appendChild(overlay);
// Trigger animation
// Measure, then place: the card has to be in the document before it has a
// height to clamp against.
position(popup, options.anchorX, options.anchorY);
// The fade+scale itself lives in CSS so `prefers-reduced-motion` can drop it.
requestAnimationFrame(() => {
if (popup !== null) {
popup.style.opacity = "1";
popup.style.transform = "scale(1)";
popup.classList.add("open");
}
});
@@ -8,6 +8,7 @@ import { createIcon } from "@lib/icons";
import {
getScreenshareAudioMuted,
getScreenshareAudioVolume,
getUserVolume,
muteScreenshareAudio,
setScreenshareAudioVolume,
setUserVolume,
@@ -26,8 +27,11 @@ export interface TileConfig {
export interface VideoGridComponent extends MountableComponent {
addStream(userId: number, username: string, stream: MediaStream, config?: TileConfig): void;
removeStream(userId: number): void;
/** Remove every tile — used on a real voice leave so stale remote tiles
* from the previous session don't survive into the next join. */
clearStreams(): void;
hasStreams(): boolean;
setFocusedTile(tileId: number): void;
setFocusedTile(tileId: number | null): void;
getFocusedTileId(): number | null;
}
@@ -224,7 +228,7 @@ export function createVideoGrid(): VideoGridComponent {
}
}
function setFocusedTile(tileId: number): void {
function setFocusedTile(tileId: number | null): void {
focusedTileId = tileId;
rebuildFocusLayout();
}
@@ -306,13 +310,18 @@ export function createVideoGrid(): VideoGridComponent {
// Add audio control overlay for remote tiles
if (config !== undefined && !config.isSelf) {
// Screenshare audio state survives tile rebuilds — initialize from it.
// Screenshare sliders are 0-100 (HTMLAudioElement.volume caps at 1.0);
// mic sliders keep 0-200 (LiveKit setVolume supports boost up to 2.0).
let muted = config.isScreenshare ? getScreenshareAudioMuted(config.audioUserId) : false;
let currentVolume = config.isScreenshare
// Mic and screenshare audio state both survive tile rebuilds —
// initialize from the same persisted values the sidebar volume menu
// reads, instead of hardcoding "unmuted at 100%" (B3-5). Screenshare
// sliders are 0-100 (HTMLAudioElement.volume caps at 1.0); mic sliders
// keep 0-200 (LiveKit setVolume supports boost up to 2.0).
const savedVolume = config.isScreenshare
? Math.round(getScreenshareAudioVolume(config.audioUserId) * 100)
: 100;
: getUserVolume(config.audioUserId);
let currentVolume = savedVolume;
let muted = config.isScreenshare
? getScreenshareAudioMuted(config.audioUserId)
: savedVolume === 0;
const overlay = createElement("div", { class: "video-tile-overlay" });
@@ -421,6 +430,15 @@ export function createVideoGrid(): VideoGridComponent {
}
}
/** Remove every tile (trackCleanup + srcObject=null via removeStream).
* Deleting the current key mid-iteration is well-defined for Map — no
* entries are skipped — so this needs no snapshot copy of the keys. */
function clearStreams(): void {
for (const userId of cells.keys()) {
removeStream(userId);
}
}
function hasStreams(): boolean {
return cells.size > 0;
}
@@ -470,6 +488,7 @@ export function createVideoGrid(): VideoGridComponent {
destroy,
addStream,
removeStream,
clearStreams,
hasStreams,
setFocusedTile,
getFocusedTileId: getFocusedTileIdFn,
@@ -185,15 +185,31 @@ export function createVoiceWidget(options: VoiceWidgetOptions): MountableCompone
}
/** Header E2EE status: dynamic label + a persistent "secured" lock once the
* room key is ready (docs/architecture/ux/voice-and-e2ee.md §2). */
function updateStatus(status: VoiceStatus): void {
* room key is ready (docs/architecture/ux/voice-and-e2ee.md §2).
* `encryptionDegraded` (OC-0002) is the SDK's own signal — via
* RoomEvent.EncryptionError, wired in livekitSession.ts's createRoom() —
* that the E2EE worker died after the key exchange already succeeded.
* voiceStatus alone reaches "connected" in that case, so the badge must
* never claim "Secured" from voiceStatus in isolation: it renders a
* distinct, still-visible not-secured warning instead of just hiding. */
function updateStatus(status: VoiceStatus, encryptionDegraded: boolean): void {
if (statusLabel !== null) {
setText(statusLabel, STATUS_LABELS[status]);
statusLabel.classList.toggle("vw-securing", status === "securing");
statusLabel.classList.toggle("vw-reconnecting", status === "reconnecting");
}
if (securedBadge !== null) {
securedBadge.style.display = status === "connected" ? "inline-flex" : "none";
const connected = status === "connected";
const degraded = connected && encryptionDegraded;
securedBadge.classList.toggle("vw-secured--degraded", degraded);
if (degraded) {
setText(securedBadge, "⚠️ Unsecured");
securedBadge.title = "End-to-end encryption failed — this call may not be protected";
} else {
setText(securedBadge, "🔒 Secured");
securedBadge.title = "End-to-end encrypted";
}
securedBadge.style.display = connected ? "inline-flex" : "none";
}
}
@@ -228,7 +244,7 @@ export function createVoiceWidget(options: VoiceWidgetOptions): MountableCompone
root.classList.add("visible");
startStatsPoller();
startElapsedTimer();
updateStatus(voice.voiceStatus);
updateStatus(voice.voiceStatus, voice.encryptionDegraded === true);
updateFrozen(uiStore.getState().connectionStatus);
// Channel name. A DM call resolves through the DM store rather than the
@@ -440,8 +456,14 @@ export function createVoiceWidget(options: VoiceWidgetOptions): MountableCompone
}
void retryMicPermission().finally(() => {
if (grantMicBtn) {
grantMicBtn.disabled = false;
setText(grantMicBtn, "Grant Microphone");
// Delegate the disabled/title state back to render(), which
// re-runs updateFrozen() — the single authority for the
// socket-down freeze. Hardcoding `disabled = false` here would
// silently re-enable this button (and drop its stale title)
// even while the WS socket is still down and every sibling
// control remains frozen.
render();
}
});
},
@@ -464,6 +486,7 @@ export function createVoiceWidget(options: VoiceWidgetOptions): MountableCompone
screenshare: s.localScreenshare,
listenOnly: s.listenOnly,
voiceStatus: s.voiceStatus,
encryptionDegraded: s.encryptionDegraded === true,
}),
() => render(),
(a, b) =>
@@ -475,7 +498,8 @@ export function createVoiceWidget(options: VoiceWidgetOptions): MountableCompone
a.camera === b.camera &&
a.screenshare === b.screenshare &&
a.listenOnly === b.listenOnly &&
a.voiceStatus === b.voiceStatus,
a.voiceStatus === b.voiceStatus &&
a.encryptionDegraded === b.encryptionDegraded,
),
);
// Freeze controls reactively when the WS socket drops (§3 connection status).
@@ -182,7 +182,10 @@ export function attachChannelContextMenu(
menu.remove();
menuAc.abort();
};
signal.addEventListener("abort", () => menuAc.abort());
// Tie this bridge listener's own lifetime to menuAc so it does not
// outlive the menu it belongs to — closeMenu (which aborts menuAc)
// already fires far more often than the sidebar's own teardown.
signal.addEventListener("abort", closeMenu, { signal: menuAc.signal });
// Defer so this click event doesn't immediately close it
setTimeout(() => {
if (menuAc.signal.aborted) return;
@@ -1,13 +1,13 @@
/**
* Channel drag-reorder — mouse-based drag-and-drop for channel reordering.
* Uses mousedown/mousemove/mouseup (avoids WebView2 HTML5 DnD issues).
* Admin/owner only.
* Gated on MANAGE_CHANNELS, like every other channel-management affordance.
*/
import { getCurrentUser } from "@stores/auth.store";
import { updateChannelPosition } from "@stores/channels.store";
import { channelsStore, updateChannelPosition } from "@stores/channels.store";
import type { Channel } from "@stores/channels.store";
import type { ChannelReorderData } from "../ChannelSidebar";
import { canManageChannels } from "@lib/permissions";
// ── Drag state (mouse-based, avoids WebView2 HTML5 DnD issues) ──
interface DragState {
@@ -16,17 +16,82 @@ interface DragState {
containerEl: HTMLElement;
channels: readonly Channel[];
onReorder: (reorders: readonly ChannelReorderData[]) => void;
/** The signal of the sidebar that started this drag, so its teardown can
* clear the in-flight visual state without touching another sidebar's. */
owner: AbortSignal;
}
let activeDrag: DragState | null = null;
/** Global mousemove/mouseup handlers for drag reordering. Registered once.
* Reference-counted so multiple sidebar instances share the same listeners
* and only the last destroy tears them down. */
/** Global mousemove/mouseup handlers for drag reordering, shared by every
* sidebar instance. Ownership is tracked per AbortSignal — the sidebar's
* lifetime controller — not per attached channel row: attachDragHandlers runs
* once per row per render, and the per-row ref-count this replaced meant a
* sidebar took N references its single destroy could never return, so the two
* document listeners lived for the rest of the process (the KNOWN BUG
* drag-reorder.test.ts pinned until this fix). An owner's release is its
* signal's abort — the same AbortController teardown idiom as
* {@link ../../lib/disposable} — so there is no separate release call to
* forget or miscount. */
const listenerOwners = new Set<AbortSignal>();
let globalDragAc: AbortController | null = null;
let globalDragRefCount = 0;
export function ensureGlobalDragListeners(): void {
globalDragRefCount++;
function releaseOwner(owner: AbortSignal): void {
listenerOwners.delete(owner);
// A sidebar destroyed mid-drag must not leave the row stuck in the dragging
// state or the body stuck in reorder mode.
if (activeDrag !== null && activeDrag.owner === owner) {
activeDrag.sourceEl.classList.remove("dragging");
document.body.classList.remove("channel-reordering");
activeDrag.containerEl.querySelectorAll(".channel-drop-indicator").forEach((x) => {
x.classList.remove("channel-drop-indicator");
});
activeDrag = null;
}
if (listenerOwners.size === 0 && globalDragAc !== null) {
globalDragAc.abort();
globalDragAc = null;
}
}
/** A sidebar re-render replaces the channel rows mid-drag
* (ChannelSidebar.renderChannels() clears the list and rebuilds every
* group), leaving the captured container detached — and detached rows
* report all-zero rects, so no hit-test against them can succeed. Re-point
* the drag at the dragged channel's live row (found by its stamped id), its
* live container, and the store's current snapshot of that group, so the
* drop still resolves. Returns false when no live row exists (e.g. the
* channel was deleted or its category collapsed mid-drag). */
function retargetDetachedDrag(drag: DragState): boolean {
if (drag.containerEl.isConnected) {
return true;
}
const row = document.querySelector<HTMLElement>(`[data-drag-channel-id="${drag.channelId}"]`);
const container = row?.closest<HTMLElement>(".category-channels-container") ?? null;
if (row === null || container === null) {
return false;
}
const byId = channelsStore.getState().channels;
const channels: Channel[] = [];
for (const item of container.querySelectorAll<HTMLElement>("[data-drag-channel-id]")) {
const ch = byId.get(Number(item.dataset.dragChannelId));
if (ch !== undefined) {
channels.push(ch);
}
}
drag.sourceEl.classList.remove("dragging");
drag.sourceEl = row;
drag.sourceEl.classList.add("dragging");
drag.containerEl = container;
drag.channels = channels;
return true;
}
export function ensureGlobalDragListeners(owner: AbortSignal): void {
if (owner.aborted || listenerOwners.has(owner)) {
return;
}
listenerOwners.add(owner);
owner.addEventListener("abort", () => releaseOwner(owner), { once: true });
if (globalDragAc !== null) {
return;
}
@@ -38,6 +103,9 @@ export function ensureGlobalDragListeners(): void {
if (activeDrag === null) {
return;
}
if (!retargetDetachedDrag(activeDrag)) {
return;
}
// Clear old indicators
activeDrag.containerEl.querySelectorAll(".channel-drop-indicator").forEach((x) => {
x.classList.remove("channel-drop-indicator");
@@ -68,6 +136,10 @@ export function ensureGlobalDragListeners(): void {
const drag = activeDrag;
activeDrag = null;
// Re-target before cleanup so the classes are cleared from the live
// rows, not a detached subtree.
const retargeted = retargetDetachedDrag(drag);
// Clean up visual state
drag.sourceEl.classList.remove("dragging");
document.body.classList.remove("channel-reordering");
@@ -75,6 +147,10 @@ export function ensureGlobalDragListeners(): void {
x.classList.remove("channel-drop-indicator");
});
if (!retargeted) {
return;
}
// Find drop target
const items = drag.containerEl.querySelectorAll("[data-drag-channel-id]");
let dropTargetId: number | null = null;
@@ -111,17 +187,34 @@ export function ensureGlobalDragListeners(): void {
...withoutDrag.slice(insertIdx),
];
// Build reorder data and update store immediately
// Build reorder data and update store immediately. Reassign the
// group's own existing position slots, not a 0..n-1 range: the
// server's position space is global, so a category can sit at
// non-contiguous positions (interleaved with other categories), and
// renumbering from 0 would stomp another category's slots.
const slots = drag.channels.map((c) => c.position).sort((a, b) => a - b);
// The server does not enforce unique positions (newly created channels
// commonly all sit at 0), and zipping tied slots onto the new order
// would drop some or all of the moves. Nudge ties upward so every slot
// is distinct; already-distinct groups keep their exact range.
for (let i = 1; i < slots.length; i++) {
const prev = slots[i - 1];
const cur = slots[i];
if (prev !== undefined && cur !== undefined && cur <= prev) {
slots[i] = prev + 1;
}
}
const reorders: ChannelReorderData[] = [];
for (let i = 0; i < reorderedIds.length; i++) {
const id = reorderedIds[i];
if (id === undefined) {
const newPosition = slots[i];
if (id === undefined || newPosition === undefined) {
continue;
}
const ch = drag.channels.find((c) => c.id === id);
if (ch !== undefined && ch.position !== i) {
reorders.push({ channelId: id, newPosition: i });
updateChannelPosition(id, i);
if (ch !== undefined && ch.position !== newPosition) {
reorders.push({ channelId: id, newPosition });
updateChannelPosition(id, newPosition);
}
}
@@ -133,7 +226,7 @@ export function ensureGlobalDragListeners(): void {
);
}
/** Make a channel element draggable via mousedown (admin/owner only). */
/** Make a channel element draggable via mousedown (MANAGE_CHANNELS only). */
export function attachDragHandlers(
el: HTMLElement,
channel: Channel,
@@ -145,13 +238,15 @@ export function attachDragHandlers(
if (onReorderChannel === undefined) {
return;
}
const user = getCurrentUser();
const role = user?.role?.toLowerCase() ?? "";
if (role !== "owner" && role !== "admin") {
// The one derivation for every channel-management affordance (create, edit,
// delete, reorder) — a custom role holding the bit gets the same rows the
// Edit/Delete menu already offers it, and a role merely *named* "admin"
// without the bit does not get a drag the server will 403.
if (!canManageChannels()) {
return;
}
ensureGlobalDragListeners();
ensureGlobalDragListeners(signal);
el.classList.add("channel-draggable");
el.dataset.dragChannelId = String(channel.id);
@@ -173,6 +268,15 @@ export function attachDragHandlers(
el.addEventListener(
"mousemove",
(e) => {
// Defuse a stale latch: pendingDrag is cleared only by a mouseup on
// this same row (see the listener below), so releasing the button
// anywhere else — off this row entirely, or via a fast flick — leaves
// it armed. A later button-free hover would otherwise promote it into
// a real drag on the next `if` below.
if (e.buttons === 0) {
pendingDrag = null;
return;
}
if (pendingDrag === null || activeDrag !== null) {
return;
}
@@ -189,6 +293,7 @@ export function attachDragHandlers(
containerEl,
channels,
onReorder: onReorderChannel,
owner: signal,
};
el.classList.add("dragging");
document.body.classList.add("channel-reordering");
@@ -204,18 +309,3 @@ export function attachDragHandlers(
{ signal },
);
}
/** Decrement global drag listener ref-count; tear down when no more sidebars. */
export function releaseGlobalDragListeners(containerEl?: HTMLElement): void {
// Clear stale drag state if the destroyed sidebar owns the active drag
if (containerEl !== undefined && activeDrag?.containerEl === containerEl) {
activeDrag.sourceEl.classList.remove("dragging");
document.body.classList.remove("channel-reordering");
activeDrag = null;
}
globalDragRefCount = Math.max(0, globalDragRefCount - 1);
if (globalDragRefCount === 0 && globalDragAc !== null) {
globalDragAc.abort();
globalDragAc = null;
}
}
@@ -128,11 +128,19 @@ export function showUserVolumeMenu(
);
}, 0);
// Also clean up if the parent component is destroyed
signal.addEventListener("abort", () => {
menu.remove();
dismissAc.abort();
});
// Also clean up if the parent component is destroyed. Tied to dismissAc's
// own signal (mirrors context-menu.ts's menuAc pattern) so this bridge
// listener is torn down with the menu itself — otherwise it never runs
// (the parent signal is long-lived) and every right-click permanently
// accumulates one closure retaining a detached .user-vol-menu subtree.
signal.addEventListener(
"abort",
() => {
menu.remove();
dismissAc.abort();
},
{ signal: dismissAc.signal },
);
}
/** Builds the moderation rows. close() runs after any action so the menu does
@@ -7,6 +7,13 @@
* navigation, Enter/Tab/Escape handling, AbortController cleanup — lives here
* once instead of being duplicated in each popup.
*
* Accessibility-wise this is a WAI-ARIA combobox, not a menu: DOM focus stays
* in the composer textarea the whole time (moving it into the list would stop
* keystrokes from reaching the textarea, so the rows deliberately get no
* roving tabindex) and the "focused" row is conveyed purely through
* aria-activedescendant on the textarea, pointing at per-row ids stamped on
* every render.
*
* Uses @lib/dom helpers exclusively. Never sets innerHTML with user content.
*/
@@ -40,6 +47,15 @@ export interface InlineAutocompleteConfig<T> {
readonly onSelect: (value: string) => void;
/** Called when the user dismisses the popup (Escape). */
readonly onClose: () => void;
/**
* The composer control this popup completes for (the textarea). While the
* popup exists it carries combobox semantics — role="combobox",
* aria-autocomplete="list", aria-expanded="true", aria-controls={list id} —
* plus aria-activedescendant tracking the active row; destroy() removes
* them all again. DOM focus never moves here: it must stay in the textarea
* so typing keeps working, which is why the rows have no tabindex.
*/
readonly comboboxInput?: HTMLElement;
}
export interface InlineAutocompleteComponent {
@@ -54,6 +70,15 @@ export interface InlineAutocompleteComponent {
destroy(): void;
}
/** The combobox state a popup stamps on its input, removed again on destroy. */
const COMBOBOX_ATTRS = [
"role",
"aria-autocomplete",
"aria-expanded",
"aria-controls",
"aria-activedescendant",
] as const;
export function createInlineAutocomplete<T>(
cfg: InlineAutocompleteConfig<T>,
): InlineAutocompleteComponent {
@@ -63,14 +88,29 @@ export function createInlineAutocomplete<T>(
let suggestions: T[] = [];
let activeIndex = 0;
// The testid is already unique per widget, so it doubles as a stable DOM id
// for aria-controls / aria-activedescendant to point at.
const rootId = cfg.rootTestId;
const root = createElement("div", {
class: cfg.rootClass,
id: rootId,
role: "listbox",
"data-testid": cfg.rootTestId,
});
const list = createElement("div", { class: "ma-list" });
root.appendChild(list);
const input = cfg.comboboxInput ?? null;
if (input !== null) {
input.setAttribute("role", "combobox");
input.setAttribute("aria-autocomplete", "list");
// The popup only exists while it is open (the composer destroys it to
// close), so "expanded" holds for this component's whole lifetime.
input.setAttribute("aria-expanded", "true");
input.setAttribute("aria-controls", rootId);
}
function choose(index: number): void {
const picked = suggestions[index];
if (picked === undefined) return;
@@ -83,6 +123,7 @@ export function createInlineAutocomplete<T>(
const s = suggestions[i]!;
const row = createElement("div", {
class: i === activeIndex ? "ma-item ma-item--active" : "ma-item",
id: `${rootId}-option-${i}`,
role: "option",
"aria-selected": i === activeIndex ? "true" : "false",
"data-testid": cfg.rowTestId(s),
@@ -100,6 +141,15 @@ export function createInlineAutocomplete<T>(
);
list.appendChild(row);
}
// Rows are rebuilt with index-based ids, so the pointer must be re-aimed
// on every render, not just when activeIndex moves.
if (input !== null) {
if (suggestions.length > 0) {
input.setAttribute("aria-activedescendant", `${rootId}-option-${activeIndex}`);
} else {
input.removeAttribute("aria-activedescendant");
}
}
}
function setQuery(query: string): boolean {
@@ -138,6 +188,12 @@ export function createInlineAutocomplete<T>(
function destroy(): void {
ac.abort();
// Another popup may have claimed the input between this one's open and
// close (the composer opens the mention popup before closing the emoji
// one), so only strip the combobox state while it still points here.
if (input !== null && input.getAttribute("aria-controls") === rootId) {
for (const attr of COMBOBOX_ATTRS) input.removeAttribute(attr);
}
root.remove();
}
@@ -32,9 +32,15 @@ window.addEventListener("owncord:pref-change", ((e: CustomEvent<{ key: string }>
/** Module-level server host for resolving relative attachment URLs. */
let _serverHost: string | null = null;
/** Set the server host (called once from MainPage on connect). */
/** Set the server host (called once from MainPage on connect).
* Strips a trailing default-HTTPS ":443" and lowercases, mirroring
* normalizeHostForCertCompare in lib/ws.ts and cert_store_key in
* src-tauri/src/tofu.rs — config hosts are stored verbatim (e.g.
* "Example.COM:443") but WHATWG URL drops the default port for https:,
* so isServerUrl's host comparison must normalize the same way or a
* ":443"-suffixed host never matches its own resolved URLs. */
export function setServerHost(host: string): void {
_serverHost = host.toLowerCase();
_serverHost = host.replace(/:443$/, "").toLowerCase();
}
/** Resolve a potentially relative URL to a full URL using the server host. */
@@ -168,7 +168,15 @@ export function renderMentions(text: string, info?: MentionInfo): DocumentFragme
}
// Strip trailing punctuation that is likely sentence-level, not part of the URL
const rawUrl = match[0];
const stripped = rawUrl.replace(/[.,;:!?)]+$/, "");
let stripped = rawUrl.replace(/[.,;:!?)]+$/, "");
// Give back one trailing ")" if it balances an unmatched "(" earlier in
// the URL — e.g. https://en.wikipedia.org/wiki/Rust_(programming_language)
// is a real address, not prose wrapped in parens.
if (rawUrl.length > stripped.length && rawUrl[stripped.length] === ")") {
const opens = (stripped.match(/\(/g) ?? []).length;
const closes = (stripped.match(/\)/g) ?? []).length;
if (opens > closes) stripped = stripped + ")";
}
const trailing = rawUrl.slice(stripped.length);
const url = stripped || rawUrl; // fallback if stripping emptied it
if (isSafeUrl(url)) {
@@ -165,9 +165,12 @@ function fetchOgMeta(url: string): Promise<OgMeta> {
log.debug("fetchOgMeta START", url.slice(0, 100));
const promise = (async (): Promise<OgMeta> => {
// The abort timer stays armed until the body is fully read (cleared in the
// finally below), so the 5 s timeout bounds the body download as well as
// the header phase — an unbounded stream is aborted, not buffered.
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), 5000);
try {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), 5000);
const fetchOpts: RequestInit = {
signal: controller.signal,
headers: {
@@ -179,7 +182,6 @@ function fetchOgMeta(url: string): Promise<OgMeta> {
// Self-signed servers are handled by the Rust TLS proxy for WebSocket;
// OG preview fetches should respect standard certificate validation.
const res = await tauriFetch(url, fetchOpts);
clearTimeout(timer);
if (!res.ok) {
if (generation !== embedCacheGeneration) {
@@ -213,6 +215,8 @@ function fetchOgMeta(url: string): Promise<OgMeta> {
}
ogCache.set(url, EMPTY_OG);
return EMPTY_OG;
} finally {
clearTimeout(timer);
}
})();
@@ -78,7 +78,9 @@ export function formatMessageTimestamp(iso: string): string {
const timeStr = CLOCK_TIME_FORMAT.format(date);
const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate());
const yesterdayStart = new Date(todayStart.getTime() - 86_400_000);
// Built from the calendar date, not todayStart - 24h: a DST-transition day
// is 23 or 25 hours long, and Date normalizes day 0 / negative days.
const yesterdayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate() - 1);
if (date >= todayStart) {
return `Today at ${timeStr}`;
@@ -346,6 +346,13 @@ export function renderInlineImage(url: string): HTMLDivElement {
// properly remove document-level listeners from the previous instance.
let activeLightboxClose: (() => void) | null = null;
/** Close the active lightbox, if any. Called on page teardown (logout, page
* swap) so an open overlay doesn't survive onto the next page with live
* document listeners and a revoked blob URL. */
export function closeActiveLightbox(): void {
activeLightboxClose?.();
}
/** Open a full-screen lightbox overlay with zoom and pan. */
export function openImageLightbox(src: string, alt: string): void {
// Close any existing lightbox (including its document listeners)
@@ -221,6 +221,33 @@ interface HoverState {
const hoverStates = new WeakMap<HTMLElement, HoverState>();
/**
* Chips currently mid-hover (debounce timer running or tooltip showing),
* keyed by the message list's AbortSignal. A single abort listener per signal
* hides whatever is in the set instead of registering a bare, never-removed
* `abort` listener per chip on every render — the latter permanently pinned
* every past chip (and, via parentNode, its whole detached row) in memory for
* the rest of the channel visit. start()/stop() add/remove the chip, so the
* set only ever holds the handful of chips actually being hovered.
*/
const hoveringChips = new WeakMap<AbortSignal, Set<HTMLElement>>();
function chipSetFor(signal: AbortSignal): Set<HTMLElement> {
const existing = hoveringChips.get(signal);
if (existing !== undefined) return existing;
const set = new Set<HTMLElement>();
hoveringChips.set(signal, set);
signal.addEventListener(
"abort",
() => {
for (const chip of set) hide(chip);
set.clear();
},
{ once: true },
);
return set;
}
function removeTooltip(chip: HTMLElement): void {
chip.querySelector(".reaction-tooltip")?.remove();
}
@@ -261,21 +288,25 @@ export function attachReactionTooltip(
});
};
const chips = chipSetFor(signal);
const start = (): void => {
hide(chip);
const existing = hoverStates.get(chip);
const generation = existing === undefined ? 0 : existing.generation;
const timer = window.setTimeout(show, REACTION_TOOLTIP_DEBOUNCE_MS);
hoverStates.set(chip, { timer, generation });
chips.add(chip);
};
const stop = (): void => hide(chip);
const stop = (): void => {
chips.delete(chip);
hide(chip);
};
chip.addEventListener("mouseenter", start, { signal });
chip.addEventListener("mouseleave", stop, { signal });
// Keyboard accessibility: focus mirrors hover.
chip.addEventListener("focusin", start, { signal });
chip.addEventListener("focusout", stop, { signal });
signal.addEventListener("abort", () => hide(chip));
}
@@ -179,7 +179,13 @@ export function renderMessage(
opts: MessageListOptions,
signal: AbortSignal,
): HTMLDivElement {
if (msg.user.username === "System") {
// id 0 is the reserved sentinel for server-synthesized system rows (DB
// user ids are AUTOINCREMENT starting at 1, so no real account can ever
// hold it). Dispatching on the username alone let any account that
// registered the display name "System" render with no author, no role
// colour and no moderation controls — indistinguishable from a genuine
// server notice.
if (msg.user.id === 0 && msg.user.username === "System") {
return renderSystemMessage(msg);
}
@@ -238,9 +238,15 @@ function buildAutostartRow(signal: AbortSignal): HTMLDivElement {
// Starts off; corrected to the real OS state once the plugin answers.
let enabled = false;
// Set as soon as the user interacts with the toggle. Guards the init
// read-back below so a slow `isEnabled()` resolving after the user has
// already flipped the switch can't clobber their change with a stale
// value (see OC-0141).
let touched = false;
const toggle = createToggle(false, {
signal,
onChange: (nowOn) => {
touched = true;
void (async () => {
try {
const { enable, disable } = await import("@tauri-apps/plugin-autostart");
@@ -261,7 +267,12 @@ function buildAutostartRow(signal: AbortSignal): HTMLDivElement {
void (async () => {
try {
const { isEnabled } = await import("@tauri-apps/plugin-autostart");
enabled = await isEnabled();
const initialEnabled = await isEnabled();
// If the user already toggled this before the read-back resolved,
// their change (and whatever it settles to) wins — don't overwrite it
// with the value read before that change was applied.
if (touched) return;
enabled = initialEnabled;
toggle.classList.toggle("on", enabled);
toggle.setAttribute("aria-checked", String(enabled));
} catch {
@@ -179,9 +179,16 @@ function buildVoiceAudioTabInner(
const onUp = (): void => {
meterThreshold.removeEventListener("pointermove", onMove);
meterThreshold.removeEventListener("pointerup", onUp);
meterThreshold.removeEventListener("pointercancel", onUp);
};
meterThreshold.addEventListener("pointermove", onMove, { signal });
meterThreshold.addEventListener("pointerup", onUp, { signal });
// A touch/pen drag that the OS claims as a pan (or any other
// mid-drag pointer loss) fires pointercancel instead of pointerup.
// Without this, onMove stays attached for the tab's lifetime and
// every later hover over the handle silently rewrites and persists
// voiceSensitivity with no button held (v097).
meterThreshold.addEventListener("pointercancel", onUp, { signal });
},
{ signal },
);
@@ -411,10 +418,14 @@ function buildVoiceAudioTabInner(
{ signal },
);
// Race guard: prevent stale getUserMedia results from overwriting a newer request
// Race guard: prevent stale getUserMedia results from overwriting a newer
// request. cleanupMic() invalidates both counters, so a stream resolving
// after teardown is stopped instead of re-arming state nobody cleans up.
let cameraRequestId = 0;
let micRequestId = 0;
registerCameraInvalidation(() => {
cameraRequestId += 1;
micRequestId += 1;
});
function stopCameraPreview(): void {
@@ -476,12 +487,15 @@ function buildVoiceAudioTabInner(
startCameraPreview(savedVideoDevice);
}
signal.addEventListener("abort", () => {
stopCameraPreview();
});
// Camera teardown on overlay close is already covered by the factory's
// single signal.addEventListener("abort", cleanupMic) — registering here
// too would add one more permanent listener (and retain this build's DOM
// subtree via closure) every time the tab is rebuilt, since `signal` is
// shared for the whole overlay lifetime, not per-build.
// Start mic level monitoring for visual feedback
void (async () => {
const thisRequest = ++micRequestId;
try {
const savedDevice = loadPref<string>("audioInputDevice", "");
const constraints: MediaStreamConstraints = {
@@ -489,6 +503,13 @@ function buildVoiceAudioTabInner(
video: false,
};
const stream = await navigator.mediaDevices.getUserMedia(constraints);
// Race guard: teardown (cleanup or abort) may have run while we awaited
// — opening the mic now would leave it hot with nobody left to stop it,
// and registerMic would re-arm state cleanupMic() already cleared.
if (signal.aborted || thisRequest !== micRequestId) {
for (const track of stream.getTracks()) track.stop();
return;
}
const audioCtx = new AudioContext();
const analyser = audioCtx.createAnalyser();
analyser.fftSize = 256;
@@ -39,11 +39,43 @@ export const THEMES = {
"--bg-secondary": "#f2f3f5",
"--bg-tertiary": "#e3e5e8",
"--text-normal": "#313338",
// OC-0043: the 4 keys above are all this theme used to set. Every other
// surface/text/border/interactive token then fell through to tokens.css's
// dark defaults, so widgets painting --text-normal (now dark) on top of
// e.g. --bg-input (still dark) rendered as unreadable dark-on-dark.
"--bg-input": "#ebedef",
"--bg-hover": "#e8e9ed",
"--bg-active": "#dcdfe4",
"--bg-modifier-hover": "rgba(0, 0, 0, 0.06)",
"--bg-modifier-active": "rgba(0, 0, 0, 0.08)",
"--bg-modifier-selected": "rgba(0, 0, 0, 0.1)",
"--text-muted": "#5c5e66",
"--text-faint": "#747f8d",
"--text-micro": "#949ba4",
"--header-primary": "#060607",
"--header-secondary": "#4e5058",
"--interactive-normal": "#4e5058",
"--interactive-hover": "#23272a",
"--interactive-active": "#000000",
"--interactive-muted": "#c7ccd1",
"--channel-icon": "#6d6f78",
"--border": "#e3e5e8",
"--border-strong": "#cbccd1",
"--scrollbar-thin-thumb": "#cdcfd4",
"--scrollbar-auto-thumb": "#cdcfd4",
},
} as const;
export type ThemeName = keyof typeof THEMES;
// Union of every CSS custom property any built-in theme sets. Used by
// applyTheme to clear a previous theme's tokens before applying a new one,
// without touching inline properties owned by other code (e.g. --accent,
// --font-size).
const THEME_KEYS: ReadonlySet<string> = new Set(
Object.values(THEMES).flatMap((theme) => Object.keys(theme)),
);
// ---------------------------------------------------------------------------
// Accessible toggle creation
// ---------------------------------------------------------------------------
@@ -90,9 +122,17 @@ export function createToggle(
// ---------------------------------------------------------------------------
export function applyTheme(name: ThemeName): void {
// Apply CSS variables for the theme (keeps existing behavior for inline var overrides)
const theme = THEMES[name];
const root = document.documentElement;
// Clear every key any built-in theme owns first, so switching to a theme
// that sets fewer keys (e.g. light -> dark) doesn't leave the previous
// theme's tokens stuck on <html>, outranking tokens.css's :root defaults
// via inline-style specificity. Keys owned by other code (--accent,
// --font-size) are not in THEME_KEYS and are left untouched.
for (const key of THEME_KEYS) {
root.style.removeProperty(key);
}
// Apply CSS variables for the theme (keeps existing behavior for inline var overrides)
for (const [key, value] of Object.entries(theme)) {
root.style.setProperty(key, value);
}
@@ -1,7 +0,0 @@
{
"version": 1,
"commands_hash": "ca3b770e3d69abf7",
"structs_hash": "2c0574a96a92e42f",
"config_hash": "c72a07caa5bc6ed4",
"combined_hash": "6a107ade235e2401"
}
@@ -1,107 +0,0 @@
/**
* Auto-generated TypeScript bindings for Tauri commands
* Generated by tauri-typegen v0.5.0
* Generated at: 2026-04-03T09:09:31.628896400+00:00
* Generator: none
*
* Do not edit manually - regenerate using: cargo tauri-typegen generate
*/
import { invoke } from "@tauri-apps/api/core";
import * as types from "./types";
export async function startLivekitProxy(params: types.StartLivekitProxyParams): Promise<number> {
return invoke("start_livekit_proxy", params);
}
export async function stopLivekitProxy(): Promise<void> {
return invoke("stop_livekit_proxy");
}
export async function checkClientUpdate(
params: types.CheckClientUpdateParams,
): Promise<types.UpdateCheckResult> {
return invoke("check_client_update", params);
}
export async function downloadAndInstallUpdate(
params: types.DownloadAndInstallUpdateParams,
): Promise<void> {
return invoke("download_and_install_update", params);
}
export async function pttStart(): Promise<void> {
return invoke("ptt_start");
}
export async function pttStop(): Promise<void> {
return invoke("ptt_stop");
}
export async function pttSetKey(params: types.PttSetKeyParams): Promise<void> {
return invoke("ptt_set_key", params);
}
export async function pttGetKey(): Promise<number> {
return invoke("ptt_get_key");
}
export async function pttListenForKey(): Promise<number> {
return invoke("ptt_listen_for_key");
}
export async function saveCredential(params: types.SaveCredentialParams): Promise<void> {
return invoke("save_credential", params);
}
export async function loadCredential(
params: types.LoadCredentialParams,
): Promise<types.CredentialData | null> {
return invoke("load_credential", params);
}
export async function deleteCredential(params: types.DeleteCredentialParams): Promise<void> {
return invoke("delete_credential", params);
}
export async function wsConnect(params: types.WsConnectParams): Promise<void> {
return invoke("ws_connect", params);
}
export async function wsSend(params: types.WsSendParams): Promise<void> {
return invoke("ws_send", params);
}
export async function wsDisconnect(): Promise<void> {
return invoke("ws_disconnect");
}
export async function acceptCertFingerprint(
params: types.AcceptCertFingerprintParams,
): Promise<void> {
return invoke("accept_cert_fingerprint", params);
}
export async function getSettings(): Promise<types.Value> {
return invoke("get_settings");
}
export async function saveSettings(params: types.SaveSettingsParams): Promise<void> {
return invoke("save_settings", params);
}
export async function storeCertFingerprint(
params: types.StoreCertFingerprintParams,
): Promise<void> {
return invoke("store_cert_fingerprint", params);
}
export async function getCertFingerprint(
params: types.GetCertFingerprintParams,
): Promise<string | null> {
return invoke("get_cert_fingerprint", params);
}
export async function openDevtools(): Promise<void> {
return invoke("open_devtools");
}
@@ -1,48 +0,0 @@
/**
* Auto-generated TypeScript bindings for Tauri commands
* Generated by tauri-typegen v0.5.0
* Generated at: 2026-04-03T09:09:31.629251800+00:00
* Generator: none
*
* Do not edit manually - regenerate using: cargo tauri-typegen generate
*/
/**
* Event Listeners
* Type-safe event listener helpers for Tauri events
*/
import { listen, type UnlistenFn } from "@tauri-apps/api/event";
import * as types from "./types";
/**
* Listen for 'status-change' events
* @param handler - Callback function to handle the event
* @returns Promise that resolves to an unlisten function
*/
export async function onStatusChange(handler: (payload: string) => void): Promise<UnlistenFn> {
return listen<string>("status-change", (event) => {
handler(event.payload);
});
}
/**
* Listen for 'ws-state' events
* @param handler - Callback function to handle the event
* @returns Promise that resolves to an unlisten function
*/
export async function onWsState(handler: (payload: string) => void): Promise<UnlistenFn> {
return listen<string>("ws-state", (event) => {
handler(event.payload);
});
}
/**
* Listen for 'cert-tofu' events
* @param handler - Callback function to handle the event
* @returns Promise that resolves to an unlisten function
*/
export async function onCertTofu(handler: (payload: types.Value) => void): Promise<UnlistenFn> {
return listen<types.Value>("cert-tofu", (event) => {
handler(event.payload);
});
}
@@ -1,12 +0,0 @@
/**
* Auto-generated TypeScript bindings for Tauri commands
* Generated by tauri-typegen v0.5.0
* Generated at: 2026-04-03T09:09:31.629428700+00:00
* Generator: none
*
* Do not edit manually - regenerate using: cargo tauri-typegen generate
*/
export * from "./types";
export * from "./commands";
export * from "./events";
@@ -1,92 +0,0 @@
/**
* Auto-generated TypeScript bindings for Tauri commands
* Generated by tauri-typegen v0.5.0
* Generated at: 2026-04-03T09:09:31.628377200+00:00
* Generator: none
*
* Do not edit manually - regenerate using: cargo tauri-typegen generate
*/
export interface UpdateCheckResult {
available: boolean;
version?: string | null;
body?: string | null;
}
export type Value = unknown;
export interface CredentialData {
username: string;
token: string;
}
export interface StartLivekitProxyParams {
remoteHost: string;
[key: string]: unknown;
}
export interface CheckClientUpdateParams {
serverUrl: string;
[key: string]: unknown;
}
export interface DownloadAndInstallUpdateParams {
serverUrl: string;
[key: string]: unknown;
}
export interface PttSetKeyParams {
vkCode: number;
[key: string]: unknown;
}
export interface SaveCredentialParams {
host: string;
username: string;
token: string;
password?: string | null;
[key: string]: unknown;
}
export interface LoadCredentialParams {
host: string;
[key: string]: unknown;
}
export interface DeleteCredentialParams {
host: string;
[key: string]: unknown;
}
export interface WsConnectParams {
url: string;
[key: string]: unknown;
}
export interface WsSendParams {
message: string;
[key: string]: unknown;
}
export interface AcceptCertFingerprintParams {
host: string;
fingerprint: string;
[key: string]: unknown;
}
export interface SaveSettingsParams {
key: string;
value: Value;
[key: string]: unknown;
}
export interface StoreCertFingerprintParams {
host: string;
fingerprint: string;
[key: string]: unknown;
}
export interface GetCertFingerprintParams {
host: string;
[key: string]: unknown;
}
+173
View File
@@ -0,0 +1,173 @@
/**
* Shared dialog accessibility helpers (DC-13).
*
* Generalizes the pattern UserProfilePopup pioneered — dialog semantics, a
* Tab-cycling focus trap, and focus save/restore — so every modal applies the
* same behavior instead of re-implementing (or forgetting) it. All listeners
* register against the caller's AbortSignal, matching the component teardown
* idiom used across the codebase.
*/
/** The elements a dialog's Tab cycle visits. */
const FOCUSABLE_SELECTOR =
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])';
/**
* Elements the app hides via inline `style.display = "none"` (the codebase's
* standard show/hide idiom — e.g. a group-name field revealed only once a
* second member is picked) still match FOCUSABLE_SELECTOR: the selector is
* structural, not a visibility check. A browser silently refuses to move
* focus onto a display:none element, so treating one as the dialog's "first"
* or "last" focusable leaves .focus() a no-op and the Tab trap comparing
* against an edge focus never actually reached — Tab then falls through to
* the browser's native order and can walk out of the dialog entirely.
*/
function isFocusable(el: HTMLElement): boolean {
return el.style.display !== "none" && el.style.visibility !== "hidden";
}
function queryFocusable(container: HTMLElement): HTMLElement[] {
return Array.from(container.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTOR)).filter(
isFocusable,
);
}
export interface DialogSemanticsOptions {
/** Accessible name for the dialog (aria-label). */
readonly label?: string;
/** Id of the element naming the dialog (aria-labelledby); wins over label. */
readonly labelledBy?: string;
}
/**
* Stamp WAI-ARIA dialog semantics on a modal container: role="dialog",
* aria-modal="true", and tabindex="-1" so the container itself can take
* initial focus when it holds no focusable control.
*/
export function applyDialogSemantics(el: HTMLElement, opts: DialogSemanticsOptions = {}): void {
el.setAttribute("role", "dialog");
el.setAttribute("aria-modal", "true");
el.setAttribute("tabindex", "-1");
if (opts.labelledBy !== undefined) {
el.setAttribute("aria-labelledby", opts.labelledBy);
} else if (opts.label !== undefined) {
el.setAttribute("aria-label", opts.label);
}
}
/**
* Trap Tab/Shift+Tab inside `container` for as long as `signal` lives:
* tabbing past the last focusable wraps to the first and vice versa. The
* focusable set is queried per keystroke, so contents may change freely.
*/
export function trapFocus(container: HTMLElement, signal: AbortSignal): void {
container.addEventListener(
"keydown",
(e: KeyboardEvent) => {
if (e.key !== "Tab") return;
const focusable = queryFocusable(container);
if (focusable.length === 0) {
// Nothing tabbable inside — keep focus on the container itself.
e.preventDefault();
return;
}
const first = focusable[0]!;
const last = focusable[focusable.length - 1]!;
// Focus outside the set (e.g. on the container) also wraps to an edge.
const active = document.activeElement;
if (e.shiftKey && (active === first || active === container)) {
e.preventDefault();
last.focus();
} else if (!e.shiftKey && (active === last || active === container)) {
e.preventDefault();
first.focus();
}
},
{ signal },
);
}
/**
* Make exactly one cell in `container` tabbable (the first) and the rest
* focusable only programmatically. Call after every render that replaces the
* cell set — search results swap the cells out from under the tabindex, and a
* grid with zero (or many) Tab stops breaks the "Tab enters the grid once"
* contract.
*/
export function setRovingTabindex(container: HTMLElement, cellSelector: string): void {
const cells = container.querySelectorAll<HTMLElement>(cellSelector);
cells.forEach((cell, i) => {
cell.setAttribute("tabindex", i === 0 ? "0" : "-1");
});
}
/**
* Roving-tabindex keyboard support for a flat list of option cells:
* ArrowLeft/ArrowRight step, Home/End jump to the edges, and Enter/Space
* activate the focused cell through its own click handler so keyboard and
* mouse take the identical code path. The grid is deliberately treated as a
* flat list — row-aware Up/Down would need layout knowledge the DOM doesn't
* expose reliably.
*
* The listener lives on the container (which survives re-renders) and the
* cell set is queried per keystroke, so callers may rebuild cells freely as
* long as they re-run setRovingTabindex afterwards.
*/
export function enableRovingNavigation(
container: HTMLElement,
cellSelector: string,
signal: AbortSignal,
): void {
container.addEventListener(
"keydown",
(e: KeyboardEvent) => {
// Only keystrokes originating on a cell rove; the search input above
// the grid keeps its native caret behavior for arrows and Home/End.
const origin =
e.target instanceof HTMLElement ? e.target.closest<HTMLElement>(cellSelector) : null;
if (origin === null) return;
const cells = Array.from(container.querySelectorAll<HTMLElement>(cellSelector));
const from = cells.indexOf(origin);
if (from === -1) return;
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
origin.click();
return;
}
let to: number;
if (e.key === "ArrowRight") to = Math.min(from + 1, cells.length - 1);
else if (e.key === "ArrowLeft") to = Math.max(from - 1, 0);
else if (e.key === "Home") to = 0;
else if (e.key === "End") to = cells.length - 1;
else return;
e.preventDefault();
// Move the single Tab stop along with focus so tabbing away and back
// returns to the last visited cell, not the first.
origin.setAttribute("tabindex", "-1");
const target = cells[to]!;
target.setAttribute("tabindex", "0");
target.focus();
},
{ signal },
);
}
/**
* Move initial focus into a just-opened dialog (its first focusable control,
* else the container itself) and return a restorer that puts focus back on
* whatever held it before — call the restorer on close. Capturing happens NOW,
* so call this before anything inside the dialog grabs focus.
*/
export function focusDialog(container: HTMLElement): () => void {
const previous = document.activeElement;
const firstFocusable = queryFocusable(container)[0];
(firstFocusable ?? container).focus();
return () => {
if (previous instanceof HTMLElement && previous.isConnected) {
previous.focus();
}
};
}
+55 -17
View File
@@ -17,9 +17,7 @@ import type {
ChannelType,
ChannelResponse,
EmojiResponse,
SoundResponse,
InviteResponse,
SessionResponse,
UploadResponse,
VoiceCredentialsResponse,
MemberResponse,
@@ -51,13 +49,48 @@ export class ApiClientError extends Error {
export type OnUnauthorized = () => void;
/**
* Single session object from GET /users/me/sessions, matching the server's
* wire shape (Server/api/profile_handler.go's sessionResponse, wrapped in a
* `{sessions: [...]}` envelope — docs/api.md). Defined here, next to its only
* consumer, rather than in `./types`: the declaration that used to live there
* had drifted from the actual contract (it declared `ip_address`/`expires_at`,
* which the server never sends, and omitted `ip`/`is_current`, which it always
* does), and nothing else needs this shape.
*/
export interface SessionInfo {
readonly id: number;
/** Never null: the server's fields are plain Go strings, so an unknown
* device or address arrives as "" rather than being omitted. */
readonly device: string;
readonly ip: string;
readonly created_at: string;
readonly last_used: string;
readonly is_current: boolean;
}
interface SessionsListResponse {
readonly sessions: SessionInfo[];
}
const log = createLogger("api");
/** Create the REST API client. */
export function createApiClient(initialConfig: ApiClientConfig, onUnauthorized?: OnUnauthorized) {
// oxlint-disable-next-line consistent-function-scoping -- co-located with createApiClient for encapsulation
function isValidHost(host: string): boolean {
return /^[\w.-]+(:\d+)?$/.test(host) && host.length <= 253;
if (host.length > 253) return false;
// Bracketed IPv6 literal ("[::1]" or "[::1]:8443") — same convention as
// livekitSession.ts's ensureLiveKitProxy and http_proxy.rs /
// livekit_proxy.rs's validate_remote_host + parse_server_name.
if (/^\[[0-9A-Fa-f:.]+\](:\d+)?$/.test(host)) return true;
// Bare (unbracketed) IPv6 literal, e.g. "2001:db8::1" or "::1". More than
// one colon means the whole string is the address — a single colon is
// reserved for the host:port separator below, matching how
// ensureLiveKitProxy tells "[::1]:port" apart from "host:port".
if ((host.match(/:/g) ?? []).length > 1 && /^[0-9A-Fa-f:.]+$/.test(host)) return true;
// DNS name or IPv4 literal, optionally with a port.
return /^[\w.-]+(:\d+)?$/.test(host);
}
let config = { ...initialConfig };
@@ -189,7 +222,20 @@ export function createApiClient(initialConfig: ApiClientConfig, onUnauthorized?:
log.error("setConfig rejected invalid host", { host: newConfig.host });
throw new Error("Invalid host format");
}
config = { ...config, ...newConfig };
// Switching to a different host without an accompanying new token must
// not carry the previous host's bearer token forward — otherwise the
// login/register request to the new host rides a still-live session
// token for the old one. Callers that only rotate the token (post-auth)
// never pass `host`, so this never touches a same-host token refresh.
if (
newConfig.host !== undefined &&
newConfig.host !== config.host &&
newConfig.token === undefined
) {
config = { ...config, ...newConfig, token: undefined };
} else {
config = { ...config, ...newConfig };
}
},
/** Get current config (for debugging). Token is redacted. */
@@ -334,7 +380,7 @@ export function createApiClient(initialConfig: ApiClientConfig, onUnauthorized?:
return request<void>(
"PUT",
"/users/me/password",
{ current_password: currentPassword, new_password: newPassword },
{ old_password: currentPassword, new_password: newPassword },
signal,
);
},
@@ -354,8 +400,10 @@ export function createApiClient(initialConfig: ApiClientConfig, onUnauthorized?:
return request<void>("DELETE", "/users/me/totp", { password }, signal);
},
getSessions(signal?: AbortSignal): Promise<SessionResponse[]> {
return request<SessionResponse[]>("GET", "/users/me/sessions", undefined, signal);
getSessions(signal?: AbortSignal): Promise<SessionInfo[]> {
return request<SessionsListResponse>("GET", "/users/me/sessions", undefined, signal).then(
(r) => r.sessions,
);
},
revokeSession(sessionId: number, signal?: AbortSignal): Promise<void> {
@@ -589,16 +637,6 @@ export function createApiClient(initialConfig: ApiClientConfig, onUnauthorized?:
return request<void>("DELETE", `/emoji/${emojiId}`, undefined, signal);
},
// ── Sounds ────────────────────────────────────────────
getSounds(signal?: AbortSignal): Promise<SoundResponse[]> {
return request<SoundResponse[]>("GET", "/sounds", undefined, signal);
},
deleteSound(soundId: number, signal?: AbortSignal): Promise<void> {
return request<void>("DELETE", `/sounds/${soundId}`, undefined, signal);
},
// ── Direct Messages ─────────────────────────────────────
/** List user's open DM channels. */
+52 -5
View File
@@ -17,9 +17,49 @@ import { voiceStore } from "@stores/voice.store";
const log = createLogger("audioElements");
/** Get saved per-user volume (0-200 range, default 100). Applied via LiveKit's GainNode-backed setVolume(). */
/**
* Server host the per-user volume prefs below belong to. Mirrors
* channel-mutes.ts's currentHost — the client is multi-server (one webview
* origin means one localStorage) and userId is only unique per server, so
* without a host component a volume set for user 7 on one server would
* silence user 7 on every other server too. `setAudioVolumeHost` is always
* called with a real host before any volume is read (see MainPage.ts), so
* the `null` startup default is not what protects a pre-scoping install's
* saved volumes — `getSavedUserVolume` does that below by reading through to
* the original unscoped key on a miss at the scoped one.
*/
let currentHost: string | null = null;
/** Point per-user volume reads/writes at a specific server. Call on connect
* and on server switch — mirroring channel-mutes.ts's setChannelMutesHost. */
export function setAudioVolumeHost(host: string | null): void {
currentHost = host;
}
function userVolumeKey(userId: number): string {
return currentHost === null ? `userVolume_${userId}` : `userVolume_${userId}:${currentHost}`;
}
// setUserVolume always clamps to 0-200, so -1 is safe as a "nothing saved" sentinel.
const VOLUME_NOT_SET = -1;
/** Get saved per-user volume (0-200 range, default 100). Applied via LiveKit's
* GainNode-backed setVolume(). On a miss at the host-scoped key, reads
* through to the pre-scoping unscoped key once and persists the result
* under the scoped key so the read-through isn't repeated. */
function getSavedUserVolume(userId: number): number {
return loadPref<number>(`userVolume_${userId}`, 100);
const scopedKey = userVolumeKey(userId);
if (currentHost === null) return loadPref<number>(scopedKey, 100);
const scoped = loadPref<number>(scopedKey, VOLUME_NOT_SET);
if (scoped !== VOLUME_NOT_SET) return scoped;
const legacy = loadPref<number>(`userVolume_${userId}`, VOLUME_NOT_SET);
if (legacy !== VOLUME_NOT_SET) {
savePref(scopedKey, legacy);
return legacy;
}
return loadPref<number>(scopedKey, 100);
}
export class AudioElements {
@@ -89,13 +129,20 @@ export class AudioElements {
const userId = parseUserId(participant.identity);
if (publication.source === Track.Source.ScreenShareAudio) {
// Screenshare audio: manage via HTMLAudioElement volume (not participant.setVolume)
for (const el of track.detach()) el.remove();
// Look up the tracking set before detaching so a fast re-subscribe
// (new TrackSubscribed before the old TrackUnsubscribed lands) drops
// the stale element from the set instead of leaking it forever — same
// hygiene as handleTrackUnsubscribedAudio below.
let audioEls = this.screenshareAudioElements.get(userId);
for (const el of track.detach()) {
el.remove();
audioEls?.delete(el);
}
const audioEl = track.attach();
audioEl.style.display = "none";
document.body.appendChild(audioEl);
audioEl.volume = this.getEffectiveScreenshareVolume(userId);
audioEl.muted = this.screenshareAudioMutedByUser.get(userId) ?? false;
let audioEls = this.screenshareAudioElements.get(userId);
if (audioEls === undefined) {
audioEls = new Set();
this.screenshareAudioElements.set(userId, audioEls);
@@ -185,7 +232,7 @@ export class AudioElements {
setUserVolume(userId: number, volume: number): void {
const clamped = Math.max(0, Math.min(200, volume));
savePref(`userVolume_${userId}`, clamped);
savePref(userVolumeKey(userId), clamped);
if (this.room !== null) {
for (const participant of this.room.remoteParticipants.values()) {
if (parseUserId(participant.identity) === userId) {
+30 -2
View File
@@ -21,6 +21,11 @@ export class AudioPipeline {
/** Monotonic counter incremented on teardown — used to discard stale async results. */
private _pipelineGeneration = 0;
/** Monotonic counter incremented on stopVadPolling — narrower than
* _pipelineGeneration (which only bumps on a full pipeline teardown), so it
* also invalidates an in-flight startVadPolling()'s addModule when VAD is
* stopped without tearing down the pipeline (e.g. setVoiceSensitivity(100)). */
private _vadGeneration = 0;
// Pipeline nodes
private audioPipelineCtx: AudioContext | null = null;
@@ -74,6 +79,10 @@ export class AudioPipeline {
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- LocalTrack.setProcessor uses wide generic, but AudioProcessorOptions is guaranteed at runtime with webAudioMix
await micPub.track.setProcessor(processor as any);
log.info("RNNoise processor attached to mic track");
// Rebuild so the gain/VAD chain sources from the processor's output and
// its own sender.replaceTrack runs last, winning over setProcessor's
// internal replaceTrack to the raw processed track (B3-1).
this.setupAudioPipeline();
}
/** Remove RNNoise processor from the local mic track. Safe to call if none attached. */
@@ -84,6 +93,9 @@ export class AudioPipeline {
if (micPub.track.getProcessor() === undefined) return;
await micPub.track.stopProcessor();
log.info("RNNoise processor removed from mic track");
// Rebuild so the sender ends back on the gain/VAD chain over the raw mic,
// not whatever track stopProcessor's own internals left wired (B3-1).
this.setupAudioPipeline();
}
// --- Pipeline setup/teardown ---
@@ -96,7 +108,15 @@ export class AudioPipeline {
if (micPub?.track === undefined) return;
try {
const mediaTrack = micPub.track.mediaStreamTrack;
// Source from the NS processor's output when one is attached, not the
// raw mic track — livekit-client's LocalTrack.setProcessor() does its
// own (internal, unawaited) sender.replaceTrack(processedTrack) once
// the worklet loads, and that call lands AFTER this one (it awaits
// addModule+fetch first). Sourcing from mediaStreamTrack unconditionally
// meant that call always won, silently rewiring the sender straight to
// the raw mic and bypassing this pipeline's gain/VAD entirely (B3-1).
const mediaTrack =
micPub.track.getProcessor()?.processedTrack ?? micPub.track.mediaStreamTrack;
const ctx = new AudioContext({ sampleRate: 48000 });
void ctx.resume(); // Ensure not suspended (WebView2 autoplay policy)
@@ -163,7 +183,11 @@ export class AudioPipeline {
if (this.room !== null) {
const micPub = this.room.localParticipant.getTrackPublication(Track.Source.Microphone);
if (micPub?.track?.sender !== undefined) {
const originalTrack = micPub.track.mediaStreamTrack;
// Restore to the NS processor's output when one is still attached, not
// the raw mic — otherwise tearing down just the gain/VAD wrapper (e.g.
// muting) would also silently bypass an active noise suppressor (B3-1).
const originalTrack =
micPub.track.getProcessor()?.processedTrack ?? micPub.track.mediaStreamTrack;
void micPub.track.sender
.replaceTrack(originalTrack)
.then(() => {
@@ -270,15 +294,18 @@ export class AudioPipeline {
// Try AudioWorklet first
const gen = this._pipelineGeneration;
const vadGen = this._vadGeneration;
this.audioPipelineCtx.audioWorklet
.addModule("/vad-worklet.js")
.then(() => {
if (gen !== this._pipelineGeneration) return; // Torn down while loading
if (vadGen !== this._vadGeneration) return; // stopVadPolling() while loading
if (this.audioPipelineCtx === null) return;
this.startVadWorklet(threshold);
})
.catch((err) => {
if (gen !== this._pipelineGeneration) return;
if (vadGen !== this._vadGeneration) return;
log.warn("AudioWorklet unavailable, falling back to setTimeout VAD", err);
this.startVadFallback(threshold);
});
@@ -389,6 +416,7 @@ export class AudioPipeline {
/** Stop VAD (both worklet and fallback). Pipeline stays intact. */
stopVadPolling(): void {
this._vadGeneration++;
// Stop setTimeout fallback
if (this.vadTimer !== null) {
clearTimeout(this.vadTimer);
+5 -2
View File
@@ -86,8 +86,11 @@ export function startAutoIdle(options: AutoIdleOptions): AutoIdleController {
let destroyed = false;
/** True while the timer is the reason the status is idle. Kept in memory so
* the hot path (one mousemove per pixel) is a boolean check rather than a
* preference read. */
let idleByTimer = false;
* preference read. Seeded from the persisted status/origin so a session
* that starts already auto-idle (app restart, MainPage remount) can still
* be un-idled by activity — otherwise the latch starts false and apply(false)
* is unreachable until the user manually reselects a status. */
let idleByTimer = loadUserStatus() === "idle" && loadUserStatusOrigin() === "auto";
function apply(idle: boolean): void {
const next = nextAutoStatus(loadUserStatus(), loadUserStatusOrigin(), idle);

Some files were not shown because too many files have changed in this diff Show More