release: v1.2.0-alpha.4 — 62 fixes plus the B0/B1 repository foundation (#1426)
* Fix 27 findings from 2026-08-21 bug hunt (#1400) * chore(findings): record 2026-08-21 bug hunt (38 findings) * fix(api): 1 defect(s) (OC-0240) * fix(client): 1 defect(s) (OC-0241) * fix(plugin): 2 defect(s) (OC-0243, OC-0265) * fix(client): 3 defect(s) (OC-0244, OC-0256, OC-0259) * fix(client): 1 defect(s) (OC-0247) * fix(client): 2 defect(s) (OC-0248, OC-0258) * fix(identity): 1 defect(s) (OC-0250) * fix(ws): 3 defect(s) (OC-0252, OC-0269, OC-0272) * fix(admin): 1 defect(s) (OC-0253) * fix(client): 1 defect(s) (OC-0254) * fix(voice): 1 defect(s) (OC-0255) * fix(ws): 1 defect(s) (OC-0260) * fix(client): 1 defect(s) (OC-0261) * fix(client): 1 defect(s) (OC-0262) * fix(client): 1 defect(s) (OC-0263) * fix(client): 1 defect(s) (OC-0264) * fix(client): 1 defect(s) (OC-0268) * fix(ws): 1 defect(s) (OC-0273) * fix(service): 1 defect(s) (OC-0275) * style: satisfy golangci-lint and prettier on 2026-08-21 fix commits - drop ineffectual backupDir reset before return (registry.go, OC-0265) - reflow long boolean expression (attachments.ts, OC-0241) * fix(client): 4 defect(s) (OC-0242, OC-0246, OC-0249, OC-0251) * fix(voice): 1 defect(s) (OC-0267) * fix(admin): 1 defect(s) (OC-0274) * fix(voice): 1 defect(s) (OC-0245) * fix(ws): 1 defect(s) (OC-0271) * fix(voice): 2 defect(s) (OC-0239, OC-0257) * fix(ws): 1 defect(s) (OC-0266) * fix(voice): 1 defect(s) (OC-0270) * style: clear golangci-lint modernize and prettier nits from 2026-08-21 fixes - range-over-int and slices.Contains modernizations in new Go test files - prettier reflow in dispatcher.ts * chore(findings): mark 2026-08-21 hunt findings fixed/declined 37 fixed across the fix waves, OC-0238 declined (LiveKit webhook TLS requires a product decision, not a mechanical patch). --------- Co-authored-by: Claude <noreply@anthropic.com> * fix: 35 findings from the 2026-08-22 bug hunt (#1402) * fix(voice): 1 defect(s) (OC-0277) * fix(voice): 1 defect(s) (OC-0278) * fix(client): 1 defect(s) (OC-0280) refreshDmSidebar() rebuilds the entire DM sidebar subtree on every dmStore.channels change - which includes presence flips and new messages, not just DM list changes. The "Find a conversation" filter text and input focus live only in that destroyed subtree, so they were silently wiped mid-typing. Capture and restore both across the destroy+recreate cycle. * fix(ws): 1 defect(s) (OC-0285) * fix(client): 1 defect(s) (OC-0286) * fix(client): 1 defect(s) (OC-0288) Consume the legacy unscoped mute key after migrating it onto the first host, so a brand-new host with no scoped key of its own no longer reads through to the same legacy list and inherits another server's mutes. * fix(voice): 1 defect(s) (OC-0290) * fix(db): 1 defect(s) (OC-0293) DecrementMentionCounts reversed mention_count bumps that were never applied: message_mentions stores every resolved mention id including the author's blockers, while applyMentionCounts excludes blockers before incrementing. Deleting a blocked author's message therefore wiped an unrelated, genuine mention badge on the same read_states row. Mirror the block exclusion in the decrement UPDATE. * fix(db): 1 defect(s) (OC-0294) DeleteAccount soft-deletes the departing user's messages but never reversed the read_states.mention_count bumps those messages made, leaving phantom mention badges. Reverse them inline in the existing transaction, mirroring DecrementMentionCounts' guards. * fix(client): 1 defect(s) (OC-0295) MemberList rebuilt every row on any non-presence-only membersStore change and on every roles_update, but registered each row's click/contextmenu listeners on the component-lifetime disposable.signal, which only aborts at destroy(). Discarded rows therefore stayed reachable (and their listeners live) for the component's whole lifetime. Route per-row listeners through a per-render AbortController that is aborted and replaced at the top of every render, and aborted again in destroy(). * fix(identity): 1 defect(s) (OC-0297) UpdateProfile's post-commit re-read of the user row could fail for reasons unrelated to context cancellation (SQLITE_BUSY, I/O error, pool exhaustion) and was reported as ErrInternal even though UpdateUserProfile had already committed. Callers that treat any UpdateProfile error as proof the write never landed — handleUploadAvatar deletes the file it just stored — would delete a file the committed avatar column now points at, permanently breaking the avatar with no user_update broadcast. Since UpdateUserProfile only writes username/avatar/display_name/about, merge those four onto the pre-write snapshot to reconstruct the committed row without needing the re-read to succeed, and log the read failure. * fix(ws): 2 defect(s) (OC-0298, OC-0299) - OC-0298: applyConnectStatus stamped c.user.Status even when the UpdateUserStatus write failed, so auth_ok and the presence broadcast claimed a status users.status disagreed with, and buildReady's ListMembers read never self-corrected for the session. - OC-0299: refreshUserSnapshot silently fell back to roleName "member" when the new role lookup failed, pinning the session to a fabricated role on the wire. It now fails closed like the sibling lookups in upgradeAndAuth and handleFreshConnect. * fix(client): 1 defect(s) (OC-0300) * fix(client): 1 defect(s) (OC-0301) * fix(ws): 1 defect(s) (OC-0302) * fix(api): 1 defect(s) (OC-0305) handleDiagnosticsConnectivity used clientIP(r), ignoring cfg.Server.TrustedProxies, so behind a configured trusted reverse proxy the endpoint reported the proxy hop instead of the real client address. Use clientIPWithProxies with the parsed trusted-proxy nets, matching RateLimitMiddleware on the same route. * fix(client): 2 defect(s) (OC-0306, OC-0308) * fix(client): 1 defect(s) (OC-0307) QuickSwitcher registered a per-row click listener against the overlay-lifetime AbortSignal, but renderResults() rebuilds every row on each keystroke, arrow key, and store refresh. Discarded rows kept their listeners alive until the overlay closed. Replaced with one delegated click listener on the stable results container, keyed off the data-channelid each row already carries. * fix(client): 1 defect(s) (OC-0310) * fix(server): 3 defect(s) (OC-0279, OC-0291, OC-0292) Reap a soft-deleted message's attachment files, count lapsed temporary bans as active users in the require_2fa enrollment gate, and only apply the 2FA-enrollment precondition when require_2fa itself is being enabled. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SdkJRbjCtrG76jEnrhKbYo * test(api): sync apiTestSchema with the user_blocks migration DeleteAccount's mention-count reversal joins user_blocks; the api package's hand-rolled schema fixture predates migration 012. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SdkJRbjCtrG76jEnrhKbYo * fix(client): 3 defect(s) (OC-0281, OC-0282, OC-0296) Decouple the E2EE identity-mismatch modal and right-click popovers from the sidebar's per-render abort signal, and let global drag listeners survive a mid-drag re-render. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SdkJRbjCtrG76jEnrhKbYo * fix(voice): 2 defect(s) (OC-0283, OC-0287) Retire a departed peer's E2EE key unconditionally on leave, and surface a failed microphone unmute instead of reporting an unmuted state the room never saw. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SdkJRbjCtrG76jEnrhKbYo * fix(client): 3 defect(s) (OC-0289, OC-0303, OC-0309) Guard the DM call button against redialing the channel already joined, resolve the incoming-call banner's caller through the nickname-aware display name, and keep the DM profile sidebar subscribed to live member/status updates. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SdkJRbjCtrG76jEnrhKbYo * style(client): prettier-format the dm-store test Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SdkJRbjCtrG76jEnrhKbYo * fix(server): 1 defect(s) (OC-0284) Make message soft-delete a compare-and-set so a repeated chat_delete cannot reverse mention counts twice. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SdkJRbjCtrG76jEnrhKbYo * fix(server): 2 defect(s) (OC-0276, OC-0304) Re-sync a resumed connection's voice E2EE peer keys in registerNow (announce frames are unsequenced and cannot be replayed), and apply the live-connection presence rule to every DM payload DMService builds. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SdkJRbjCtrG76jEnrhKbYo * chore(ledger): record the 2026-08-21 hunt findings as fixed Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SdkJRbjCtrG76jEnrhKbYo * chore(ledger): independent revert-proof pass for OC-0276..OC-0310 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SdkJRbjCtrG76jEnrhKbYo * refactor(service): extract DeleteMessage authorization into a helper Keeps DeleteMessage under the cyclop complexity ceiling after the OC-0284 guard. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SdkJRbjCtrG76jEnrhKbYo --------- Co-authored-by: Claude <noreply@anthropic.com> * chore(ledger): record the 38 open findings from the 2026-08-22 hunt (#1403) Claude-Session: https://claude.ai/code/session_01SdkJRbjCtrG76jEnrhKbYo Co-authored-by: Claude <noreply@anthropic.com> * chore(graphify): refresh knowledge graph * fix: close the three B0 P0 gates and record a measured baseline (#1409) * chore(security): stop tracking the private security-finding reports docs/security-findings/ holds detailed reports for defects that are not yet fixed. The directory was untracked but not ignored, so any 'git add .' would have published seven unfixed vulnerability traces to a public repository. Findings are coordinated through private GitHub Security Advisories (docs/security.md); only opaque identifiers and safe status belong in tracked plans. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(client): repair the two red P0 unit contracts (G-01, G-02) G-02: noise-suppression-restart stubbed MediaStream with vi.fn().mockImplementation(arrow), which is not constructible. Vitest 4 threw 'is not a constructor' at the new MediaStream([inputTrack]) call in noise-suppression.ts before reaching any assertion. Replaced with a real class; the OC-0277 assertions are unchanged. G-01: message-list's OC-0217 guard was inverted, not merely stale. It spied on AbortSignal.prototype.addEventListener and asserted zero abort registrations, but the leak it names registered row listeners via element.addEventListener(..., { signal }) — a path that never calls that prototype method. Measured: the leak produces 0 registrations (test passes), while the OC-0286 fix rotates a per-window AbortSignal.any and produces 5 across 5 distinct signals (test fails). The guard passed on the bug and failed on the fix. It now captures the signal each window's row listeners register against and asserts the invariant its name always claimed: one signal per rendered window, a fresh signal per jump, and every superseded window already aborted with exactly one live. Verified both directions — green on the fix, and 'expected 1 to be 5' with beginRowRender() reverted to rowSignal = ac.signal. Client suite: 5257 passed, 0 failed (was 5255 passed, 2 failed). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(client): make the Playwright suite terminate The runner finished every test and then never exited, printing no summary — so the failure read as 'tests never finish' when it was 'process never exits'. getActiveResourcesInfo() at hang time showed a live ProcessWrap plus several PipeWrap: the Vite dev server was still running. Playwright's webServer teardown does not kill it here. Measured, full suite each time: npm run dev hangs, tests pass node node_modules/vite/bin/vite.js hangs, tests pass reuseExistingServer: false hangs, tests pass gracefulShutdown SIGTERM/3s hangs, tests pass npx vite exits, 290 of 293 FAIL no webServer (pre-started) exits, 293 pass in 33s npx only appears to fix it: npx exits once Vite is up, Playwright reads that as the server dying and tears the group down mid-run, so later tests get ERR_CONNECTION_REFUSED. globalTeardown now kills the process listening on the dev port, releasing the runner's handle. The webServer command spawns Vite's entry point directly so the listening process is Playwright's own child — via 'npm run dev' the npm process would still hold the handle open. It also reaps servers orphaned by an interrupted run, which reuseExistingServer would otherwise silently adopt. An earlier revision used netstat, which is not on PATH in every shell here; the swallowed ENOENT made the fix look applied while the hang persisted. It now uses PowerShell on Windows and lsof elsewhere, and warns on failure rather than failing silently. npm run test:e2e: exit 0, 293 passed, 37s, reproducible, no orphan listener. playwright.config.prod.ts carried the same npm-wrapper shape. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * chore(client): align .nvmrc with the Node version CI uses Three versions were in play, not two: .nvmrc said 20, CI pins 24, and the machine the audit was measured on runs 26. A baseline measured against .nvmrc is not the baseline CI produces, which defeats the point of B0. Scoped to .nvmrc only. The full single-source-of-truth work — package engines, contributor docs, release — stays in B1 (RL-17 / C-01). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * docs(plans): add the beta audit set and the B0 baseline The 2026-08-23 audit set has been sitting untracked: repository-health and repository-layout audits, beta product requirements, requirement traceability, the issue register, and the B0-B10 roadmap. They are the plan of record for beta and belong in the repository. Adds b0-baseline-2026-08-25.md, which supersedes the roadmap's 'current evidence snapshot'. Every row is marked measured or carried, so nothing is inherited silently. It also records three audit claims that did not survive verification: - G-01 was an inverted guard, not a stale assertion — it passed on the bug and failed on the fix. - The Playwright hang matched none of the three hypotheses; the runner could not kill its own dev server. - The golangci-lint toolchain failure is refuted: 19 linters run, 0 issues, verified with -v to rule out the known zero-linters false-green. Adds b0-dev-branch-protection.sh, which records the applied dev branch protection and the reasoning behind each setting. Security detail stays private: the register carries only opaque SEC-* families and safe closure criteria, per the roadmap's public/private handling policy. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * chore(graphify): refresh the knowledge graph Own commit, per CLAUDE.md — the graph payload does not belong in the diff of the changes that triggered it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * docs(plans): add the active-plan index and fix a stale status header (G-04) Planning documents had no recorded state, so a reader could not tell current guidance from shipped history. docs/plans/README.md now indexes every plan as active, partially implemented, design-only, or shipped, and names the source of truth for each concern so a defect count is never read out of a plan. Status is recorded in the index rather than by moving or rewriting the historical plans, so links from audits and commit messages keep resolving. One real stale claim found and fixed: audit-2026-08-19-remediation.md still read 'in progress 2026-08-19' while its own phase table showed phases 1-6 done 2026-08-20 (merged03fcb7d5, PR #1396) with only phase 7 pending. The header had drifted because the table was updated in place and the header was not. No plan was found claiming '0 open findings'. Also records the Step 8 staleness pass in the B0 baseline: all 38 open OC records still resolve to a live file:line at this commit, so none is superseded by later work. Adjudicating them individually is bughunt-fix work, not B0. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * Update graph output files and manifest with new metadata - Updated graph.html and graph.json with new binary data. - Modified manifest.json to reflect changes in file modification times and AST hashes for several documents. - Added new entry for README.md in the manifest with its corresponding metadata. * docs(plans): close the Docker and coverage leftovers in the B0 baseline Docker smoke: measured and passing. Image builds at 50.1 MB and boots on :8443 with TLS; docker-smoke.sh exits 0. Server coverage: re-measured at 74.6% aggregate, confirming the figure carried from the audit rather than continuing to inherit it. Two findings from doing it: ENV-03 — docker-smoke.sh cannot be run from Git Bash on Windows. MSYS path conversion rewrites the container-internal /chatserver into 'C:/Program Files/Git/chatserver', so docker exec fails 127 and the script reports 'container never reported healthy within 30s' — indistinguishable from a real boot regression. MSYS_NO_PATHCONV=1 makes the same script pass. CI is Linux and unaffected, but Windows is an official contributor platform (RL-20). The CI Docker job is gated on main, so it is skipped for any PR targeting dev — a dev-targeted change cannot get Docker evidence from CI at all. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * chore(graphify): refresh the knowledge graph Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> * docs(plans): B1 execution plan, and accept HP-0 (#1410) * docs(plans): add the B1 repository-foundation execution plan B1 is the isolated layout/contributor phase. This records the execution order, the proof for each step, and what is out of scope. Two findings worth surfacing before any B1 work starts: - HP-0 was never formally accepted. The roadmap's B1 entry gate requires it; no scorecard artifact exists, no commit or document records an acceptance, and the B0 baseline still lists "Step 10: HP-0 sign-off" under "Not yet done in B0". The plan lists the five gaps that closing it requires, including pinning required status checks on dev -- which are still unset, so a dev PR can currently merge red. - Several layout-audit claims do not survive verification against HEAD, matching the B0 pattern. RL-09's "no single command verifies both protocol consumers" is false (make protocol-verify does, and is enforced in CI, the pre-commit hook, and a contract test). RL-10's test-discovery side effect never fires (no _test.go in Server/scripts). RL-06's regeneration concern is refuted locally. RL-08 grows a toolchain constraint instead. RL-05, RL-07, RL-20 and RL-21 are each worse than written -- RL-20 includes a live bug where a missing `make` is reported as stale protocol constants. The riskiest item, RL-01 (flatten Client/tauri-client into Client), gets a full reference inventory and a mechanical proof for both commits: tree- object equality for the pure move, and scripted-substitution replay for the path rewrite. Release asset names and updater contracts are verified independent of the directory name, so the move cannot rename an artifact. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * docs(plans): correct the B1 status-check pin list from a live dev PR The list was derived from ci.yml. Observing PR #1410's actual checks found three that exist in no workflow file -- Analyze (go), Analyze (javascript-typescript), Analyze (actions) -- because CodeQL runs from GitHub default setup, configured in repository settings. Reading .github/ alone misses them. Also confirms the two negative predictions against a real dev-targeted PR: Server Docker Build (verify) reports as "skipping", and Tauri Full Build never appears in the check list at all. Neither may be pinned. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * docs(plans): accept HP-0 and pin the dev required status checks Closes B1's entry gate. All five B1-0 items are done. The scorecard is the artifact the hold point asks for: one place that answers its four questions, records what was accepted as a stated limitation rather than claimed green, and part-closes R-08. Required status checks are now pinned on dev -- ten of them. That was B0's one outstanding step. Two things came out of doing it: - The names cannot be inferred from ci.yml. Three of the ten (the Analyze jobs) exist in no workflow file, because CodeQL runs from GitHub default setup configured in repository settings. They were read off a live dev-targeted PR with `gh pr checks`. - Server Docker Build, Tauri Full Build and the CodeQL aggregate are deliberately excluded. The first two report "skipping" on a dev PR -- Tauri Full Build under its unexpanded matrix name, since the job is skipped before matrix expansion. Admin Panel E2E is excluded because continue-on-error makes it report success unconditionally. Two prior claims are corrected rather than left to propagate: - b0-dev-branch-protection.sh was written assuming repository-settings writes are blocked from the agent sandbox. They are not; the PUT succeeded. The script stays as the record of intent and the way to re-apply or undo. - An earlier revision of the B1 plan said Tauri Full Build does not appear in a dev PR's check list at all. It does, as skipping. Evidence closed out: - Rust is no longer a carried row. Re-measured: 115 passed, cargo clippy --all-targets -- -D warnings at exit 0, confirming the carried figure. - The 38 open ledger records are accepted as counted, non-stale and assigned: 11 medium / 27 low, zero high or critical, zero dead paths across all 348 re-verified at this commit, and none assigned to B1. - The private security review is reconciled: 7 findings, 7 of 7 mapped to existing public rows, 0 unmapped. Summary is content-free; the detail stays in the untracked private reports. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> * refactor: flatten Client/tauri-client into Client (B1-1) (#1411) * refactor: move Client/tauri-client to Client (pure move, no content change) * refactor: re-point paths after the Client flatten (mechanical, no behaviour change) --------- Co-authored-by: Claude <noreply@anthropic.com> * B1-2: truth, entry points, and contributor path (#1412) * fix(hooks): guard on the command the hook actually runs pre-commit probed one binary and invoked another. The protocol block guarded on `command -v go` and then ran `make protocol-verify`; the sqlc block guarded on `command -v sqlc` and ran `make sqlc-verify`. `make` is not on PATH on a stock Windows box, so a contributor with Go installed but no make had their commit rejected with pre-commit: FAIL: protocol constants are stale — run 'make protocol-generate' in Server/ and stage the result when nothing had been generated and nothing compared. The real cause was `make: not found`, and the advice the message gives fails the same way. Rather than add a `command -v make` guard, inline what the two Makefile targets reduce to — `sqlc generate` / `go run ./scripts/genprotocol` followed by `git diff --exit-code`. Same semantics, one less prerequisite, and it doubles as the make-free equivalent B1-2 asks for. The Makefile targets stay for anyone who prefers them. Also: the protocol block was the only one with no `else`, so a contributor without Go got no check and no notice. It now warns like its two siblings. And gofmt is a separate binary from go, so it is probed separately. Verified both directions with the hook body replayed verbatim: - Go present, make absent -> passes, no staleness claimed. - schema edited without regenerating -> fails, as it must. Refs RL-20 / L-14. * docs: state one branch and PR model Active documents contradicted each other head-on. README.md and docs/contributing.md said branch from `dev` and target `dev`; CLAUDE.md said branch from `main`, PR to `main`. That is R-02, and the 2026-08-19 audit had already recorded it as D-06 without it being resolved. `dev` is the answer, and the repository already behaves that way: B0 made `dev` PR-only with ten required checks enforced on admins, and #1409, #1410 and #1411 all landed there. `main` carries releases. docs/contributing.md becomes the single source of truth. It now states the model, what protection is actually applied, and the two consequences a contributor meets on their first PR — that a self-mergeable PR still cannot merge red, and that Docker and Tauri Full Build report as skipped against `dev` rather than failing. Everywhere else summarises and links here. - CLAUDE.md: corrected, with a link rather than a second copy. - CONTRIBUTING.md: new. GitHub's contributing-guidelines affordance only resolves the root, .github/ or docs/ — `docs/contributing.md` is not a path it finds, so the link never appeared on issues or PRs. - PULL_REQUEST_TEMPLATE.md: names the base branch, which it did not. - bughunt-run skill: reviewed the branch against `origin/main`, which is the wrong base once every PR targets `dev`. README.md already said `dev` and is left as the short summary it should be. Dated audits and the historical remediation plan keep their `main`-era wording — they are records. Refs R-02. * fix(hooks): pick the pre-push base from the nearest integration branch pre-push decided which side's gates to run from `git diff --name-only origin/main...HEAD`. That was right when everything targeted `main`. Once `dev` became the integration branch it stopped being right: a branch cut from `dev` diffed against `main` counts everything on `dev` and not yet on `main` as "changed". Measured on this branch: the old base reported 609 changed files, the new one reports 6. So in practice the hook was running the full server build matrix and the client typecheck plus eslint on every push, whatever the change touched — the file-based narrowing it exists for never engaged. Now it picks whichever of origin/dev, origin/main is nearest, by commits between merge-base and HEAD, skipping a candidate that scores 0. Verified: a branch cut from dev picks origin/dev (2 ahead); dev itself scores 0 against dev and picks origin/main (8 ahead), which is what a dev -> main release PR wants. With no candidate resolvable it falls back to the existing `__all__`, so an unfetched or shallow clone still runs everything. Refs RL-20 / L-14, R-02. * chore(node): one Node source of truth `.nvmrc` and all ten `actions/setup-node` pins said 24; five active documents and the repo's only `engines` block still said 20. A contributor following the docs installed a version CI does not run. Node 24 wins — it is what CI already runs. Every manifest now declares `engines`, and `engine-strict=true` turns a wrong major into a failed install rather than an `EBADENGINE` warning nobody reads. `>=24` rather than `^24` so a Node 26 box keeps working; `Client/.nvmrc` stays the human-facing pin and the docs point at it instead of restating a number. The `.npmrc` is per package root, not one at the top. npm reads the project `.npmrc` from the package directory and does not walk parents — verified with a throwaway package requiring node >=99: with only a parent `.npmrc` npm warned and exited 0; with one in the package directory it failed `notsup`. A single root file would have left `Client/`, the package that matters most, on warnings. Five docs, not the four previously identified — `docs/mcp-introspect.md` also said 20. And `docs/contributing.md` claimed "`.nvmrc` + CI both say Node 20", which was wrong about both. Verified both directions in all three package roots: Node 22 fails `notsup`; Node 24 installs clean and `npm ci` passes in Client/. Refs RL-17 / C-01, ENV-01. * docs: add the documentation landing page `docs/` had 24 top-level files and no index. The root README carried a flat list of 22 links that had drifted: six documents were reachable from nowhere at all — including both 2026-08-23 audits and the test audit — and two entries were labelled "latest" while newer unlinked audits existed. docs/README.md is the index RL-12 asked for. It groups by what a document *is*, because that is what decides whether to trust it: guidance tells you how to do something, reference describes a contract the code implements, audits are dated snapshots nobody updates, plans record intent. Every tracked file under docs/ now appears exactly once, and the audit table says plainly that audit-2026-08-19.md still claims "0 open findings" when the ledger has 38. The root README keeps a short curated list and defers to the index, rather than maintaining a second copy that drifts again. Two fixes while there: `docs/plans/` was linked as a bare directory, unlike its two sibling directory entries, and was annotated "each carries a verified status header" — which docs/plans/README.md:7-9 explicitly contradicts, since a plan's header is exactly the thing that drifts and the index is the authority. Verified: 78 relative links across the new and edited files resolve, and no tracked docs/ file is unreachable from the index. Refs RL-12 / R-06. * feat(scripts): root command facade Entry points existed only inside Server/ (a Makefile) and Client/ (npm scripts). Nothing at the root told a new contributor where to start, and the root package.json had three scripts, none of which built or tested anything. `npm run check` from the root now runs what CI gates on, and check:server / check:client / check:rust run one stack. scripts/run.mjs is dependency-free Node — the shape render-ledger.mjs already uses — so `npm run check` works before `npm install` has. Cross-platform by construction: every step is spawned with an explicit cwd and no shell, so there is nothing to quote and no `cd &&` to behave differently on Windows. npm and npx get their .cmd suffix there. No step shells out to make. The facade orchestrates; it is not a new required path. Each step prints the command and the directory before running it, and those are exactly the commands documented per-stack — so a server contributor can read the output and type them instead, and still never needs Node. Tools CI installs but a contributor may not have (golangci-lint, which has no wrapper in this repo at all; sqlc, pinned by Server/sqlc.version) are skipped with a printed reason rather than failing. Three corrections to the ci-check skill while aligning it: - `make sqlc-verify protocol-verify` replaced by what those targets reduce to, so the documented path does not require make either. - `cargo test` -> `cargo test --lib`, which is what ci.yml actually runs. - "NODE_OPTIONS=--no-experimental-webstorage is mandatory on Node 22+" was false. tests/setup.ts installs the shim, CI runs Node 24 without the flag, and the suite was measured passing without it — 192 files / 5257 tests, identical to the flagged run. Also documents the third RL-20 problem, which needed no code: core.hooksPath is exclusive, so `npm run hooks:install` silently disables any .git/hooks/post-commit — including the one `graphify hook install` writes, which CLAUDE.md tells agents to install. Nothing warned about that. Verified: check:client 5257/192 green, check:rust 123 tests + clippy green, --list prints every command, and the optional-tool skip path reports rather than fails. Refs RL-04 / L-04, RL-20 / L-14. * feat(ci): fail on a document that contradicts the findings ledger G-04's remaining half. The ledger is the source of truth for defect counts, but nothing stopped a planning document from stating a different number and nothing noticed when one did. `render-ledger.mjs --check` cannot help: it validates the JSON schema and returns before rendering, so it never reads FINDINGS.md and cannot see drift at all — and no workflow ran it anyway. scripts/check-doc-counts.mjs counts ledger statuses and compares them to what an allow-list of active documents claims, failing with file, line, claimed value and actual. Wired into ci.yml as a job with no npm ci, since the script imports nothing outside node:, and into the facade as `npm run check:docs` — first in `check`, so a contradicted count does not wait behind ten minutes of -race. The patterns are narrow on purpose. A first attempt matched any "<number> <status>" and flagged nineteen things, all false: "the 45 open P1 rows" (issue-register rows, not ledger findings), "All 8 findings F1-F8" (a different register), "G-05 **refuted**" (an identifier), `">=20"` and `CGO_ENABLED=0` (not counts at all). A check that cries wolf gets ignored, which is the failure G-04 already describes. So a number is only read as a claim in three shapes that cannot mean anything else: an enumeration of two or more "<n> <status>" pairs, a status table row in a table that totals itself, and "<n> records/findings" where the ledger is named within three lines. Fifteen selftest assertions pin both directions, and the job runs them before it runs the check. It reads findings-ledger.json directly rather than importing render-ledger.mjs for `validate`/`render`: that module ends in a bare top-level `await main()` with no import.meta.main guard, so importing it rewrites FINDINGS.md as a side effect. Dated docs/audit-*.md are reported, never failed — they are snapshots nobody maintains. audit-2026-08-19.md does claim zero open findings against 38 open, so b0-baseline's "No plan was found claiming '0 open findings'" holds for docs/plans/ but not for docs/. Not included: a real FINDINGS.md render-drift check. That is RL-07 and belongs with the generated-artifact work, not here. Verified: 27 claims across 9 documents agree; corrupting one count in docs/plans/README.md fails the check naming that line, for both the status and the total. Refs G-04. --------- Co-authored-by: Claude <noreply@anthropic.com> * chore: remove graphify knowledge graph tooling (#1413) The committed knowledge graph and its PreToolUse hooks were steering every codebase question through `graphify query` before any other tool could run. Serena (gopls + rust-analyzer + tsserver over MCP) answers the same questions from real language servers rather than a generated snapshot that goes stale between rebuilds, so the graph no longer earns the ~20 MB it costs the tree. Removed: - `graphify-out/` untracked (7 files, ~20 MB) and now gitignored - both `graphify hook-guard` PreToolUse hooks from `.claude/settings.json` - the "Knowledge graph (graphify)" section of `CLAUDE.md` - the `graphify-out/**` block from `.gitattributes` and `.gitignore` - the graph-rebuild step from the `bughunt-run` skill, and the graph-edge guidance from the bughunt workflow prompt - the graphify-specific `core.hooksPath` example in `ci-check` and `docs/contributing.md`, keeping the underlying warning in generic form Also deletes the locally installed `post-commit` / `post-checkout` rebuild hooks (untracked, not part of this diff). This does not shrink clone size: the graph blobs stay in published history, which `docs/plans/b1-repository-foundation-2026-08-25.md` explicitly rules out rewriting. It does stop future refreshes from adding more. Dated audit and plan documents keep their graphify references as a historical record of the state they described. Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> * B1-3: repository hygiene gates (RL-19 / L-13, S-05) (#1414) * chore(format): one Prettier config at the repository root Every formatting rule in this repository lived under Client/ and covered exactly two globs: Client/src/**/*.ts and Client/tests/**/*.ts. Root Markdown, all of docs/, every YAML and JSON, all CSS, the root scripts and tools/mcp-introspect were formatted by nothing. There was no .editorconfig. The obvious fix -- a second Prettier config at the root for "everything else" -- gives two configs and two ignore files that can silently disagree about the same file. So the root takes ownership instead: config, ignore file and gate move up, and Client/ folds in. Client's inline "prettier" block, its .prettierignore, its format/format:check scripts and its now-unused prettier devDependency are all deleted; knip would have failed client-check on that last one. The .prettierrc.json values are lifted byte-for-byte from Client/package.json, which is what keeps the reformat commit free of client TypeScript churn: 87 tracked files need reformatting and not one of them is under Client/src or Client/tests. .prettierignore carries only what .gitignore does not. Prettier 3 reads the root .gitignore by default, so node_modules/, dist/, coverage/, Client/src/generated/ and docs/security-findings/ need no entry. It does NOT read nested .gitignore files, which is why .remember/ is listed explicitly -- 38 untracked per-machine scratch files were otherwise able to turn a shared gate red. graphify-out/ is listed because its seven files are tracked and .graphify_labels.json is signed byte-for-byte by its .sig, so formatting it would silently invalidate the signature. check:hygiene is registered in scripts/run.mjs and folded into check and release:preflight. It deliberately contains no `gofmt -l` step: gofmt -l prints offenders and still exits 0, so it cannot fail a build. Go formatting is enforced separately. shellcheck and actionlint take their file lists from `git ls-files`, never a filesystem glob -- .claude/worktrees/ holds a gitignored pre-flatten copy of the tree with three .sh files a glob would happily lint. This commit leaves the tree non-conformant on purpose. The reformat is the next commit, so the 87-file diff is reviewable separately from the rule that caused it. Not included: editorconfig-checker. .editorconfig is the editor baseline the audit asked for; Prettier, gofmt and rustfmt already fail CI on the same indentation and newline rules, so a fourth tool checking them again is a gate with no failure mode of its own. Verified: `npx prettier --check .` names 87 tracked files and zero untracked ones; the same command listed 38 .remember/ scratch files before the ignore entry and none after. `node scripts/run.mjs --list` resolves check:hygiene to 8 shell targets and 4 workflow targets. Both package.json files parse. Refs RL-19 / L-13, S-05. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * chore(format): reformat the tree to the repository Prettier rules Mechanical. This commit is `npx prettier --write .` and nothing else -- the rule that caused it landed in the previous commit so this diff can be reviewed as a transformation rather than as 84 files of hunks. 84 tracked files: 54 Markdown, 7 .mjs, 7 JSON, 6 YAML, 4 .js, 3 CSS, 2 TypeScript (the two Playwright configs at Client's root, which the old Client/src + Client/tests globs never covered). No file under Client/src or Client/tests moves, because .prettierrc.json carries Client's former inline values byte-for-byte. Prettier rewrote 87 files, not 84. The three in .github/ISSUE_TEMPLATE/ had CRLF on disk and differ only in line endings, which .gitattributes (`* text=auto eol=lf`) already normalises, so their committed blobs are unchanged. Worth knowing before someone reconciles the two numbers. The largest single diff is .superpowers/findings-ledger.json at 7976 lines rewritten. That is safe to format: nothing writes the ledger programmatically -- render-ledger.mjs reads it and writes only FINDINGS.md -- so no tool will fight Prettier over its style on the next hunt. FINDINGS.md itself is ignored as generated. Verified: `npx prettier --check .` reports "All matched files use Prettier code style", so the pass is both complete and idempotent. All 7 reformatted JSON files were parsed before and after and compared as values: semantically identical, zero content changes. `node .superpowers/render-ledger.mjs --check` still reports 348 valid findings and leaves FINDINGS.md untouched. `node scripts/check-doc-counts.mjs` still passes its selftest and still agrees on 27 claims across 9 watched documents -- table realignment did not break the patterns it matches on. `node scripts/run.mjs --list` still parses. Refs RL-19 / L-13, S-05. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * chore(lint): enforce Go formatting in the Server linter S-05: repository-wide Go formatting was not a required gate. The only gofmt enforcement anywhere was .githooks/pre-commit, which is opt-in per clone (`npm run hooks:install`), only sees staged files, and warns-and-skips when gofmt is off PATH. The obvious fix -- a `gofmt -l` step in CI -- does not work: `gofmt -l` prints its offenders and still exits 0, so the step passes no matter what it finds. scripts/run.mjs has the same problem, which is why check:hygiene has no Go step either. So gofmt goes where it can actually fail something: Server/.golangci.yml. The file was already `version: "2"` but had no `formatters:` block at all, so the 19 enabled linters ran with zero formatters. In v2 gofmt/gofumpt/goimports moved out of `linters.enable` into their own section with its own exclusions. Adding it there means the gate reports through the Lint step of "Server Build & Test", which is already pinned as required on dev -- no new job and no new pin. Every tracked .go file is under Server/ (551 of them, one go.mod), so Server-scoped is repository-wide here. One file was genuinely misformatted: a one-space struct field alignment in Server/admin/handlers_users_broadcast_test.go, fixed in the same commit because a single line does not need its own reformat commit. Trap worth recording: `gofmt -l .` on a Windows working tree lists every file that has CRLF on disk, because gofmt normalises line endings. That reported 18 offenders here, 17 of them ghosts. The blobs are all LF -- .gitattributes forces `eol=lf` -- so CI never saw them, and the honest test is to run gofmt over `git show HEAD:<file>` rather than the working copy. Doing that across all 551 tracked Go files found exactly the one real offender above. Verified both directions with golangci-lint v2 locally: `golangci-lint run ./...` reports 0 issues on the formatted tree; appending a misformatted function to Server/auth/constants.go produces 2 gofmt findings; appending the same misformatted function to Server/db/dbgen/admin.sql.go produces 0, so the exclusion holds. Both files restored and verified clean afterwards. Refs RL-19 / L-13, S-05. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(scripts): escape the NUL separator instead of embedding one The `tracked()` helper added earlier in this branch splits `git ls-files -z` output on NUL. The separator was written as a literal NUL byte rather than the two-character JavaScript escape, so scripts/run.mjs became a binary file: `git diff` refused to show it, `grep` reported "Binary file matches" instead of the line, and `* text=auto` in .gitattributes stops normalising line endings for a blob it detects as binary. The code worked -- splitting on a raw NUL and splitting on "\0" are the same operation -- which is exactly why this is worth fixing before it is inherited. A source file that tooling classifies as binary is a file nobody can review. Verified: zero NUL bytes remain, `grep -n "split("` now prints line 50 instead of "Binary file matches", `node scripts/run.mjs --list` still resolves the same 8 shell and 4 workflow targets, and prettier still reports the file clean. * chore(lint): enforce Rust formatting Rust had no formatting gate of any kind: no rustfmt.toml, no `cargo fmt` anywhere in CI, in scripts/run.mjs, in the Makefile or in the git hooks. Clippy was the only Rust gate, and clippy does not check layout. `cargo fmt --all -- --check` now runs in the rust-tests job, ahead of clippy: a formatting failure is cheap to produce and cheap to fix, and there is no reason to spend a clippy pass to surface one. The stable toolchain in that job requested `components: clippy` only, so rustfmt is added there. Only that job. ci.yml has a second, byte-identical `Install Rust` block in tauri-build; it stays clippy-only, because a full desktop build is the wrong place to discover a misplaced brace. No rustfmt.toml. The default profile is the point of a baseline -- a config file here would be a second opinion about style with nothing to say. Client/src-tauri is a single `[package]`, not a workspace, so `--all` is a safeguard against a future member rather than a fan-out today. Verified: `node scripts/run.mjs --list` resolves check:rust to three steps with `cargo fmt --all -- --check` first, `npm run format` now also runs `cargo fmt --all`, and prettier reports ci.yml, run.mjs and the ci-check skill clean. `cargo fmt --all -- --check` currently fails on 13 files -- that is the reformat, and it is the next commit. Refs RL-19 / L-13. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * chore(format): reformat the Rust crate to rustfmt defaults Mechanical. This commit is `cargo fmt --all` and nothing else; the gate that demands it landed in the previous commit so this diff is reviewable on its own. 13 of the 17 tracked .rs files, +343/-164. The crate had never been formatted, so the changes are the usual first-run set: aligned trailing comments collapsed to single spaces, single-element slice literals folded onto one line, long method chains broken across lines, closure bodies expanded into blocks. Verified: `cargo fmt --all -- --check` is clean, so the pass is complete and idempotent. `cargo clippy --all-targets -- -D warnings` finishes with no warnings, and `cargo test --lib` reports 115 passed / 0 failed -- identical to before the reformat, which is what "mechanical" has to mean for a commit that touches this much of the crate. Refs RL-19 / L-13. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(scripts): make the root facade actually run on Windows Adding the first gate that a contributor would run from the repository root exposed two bugs in the facade, both of which made it silently wrong on the platform this project is developed on. 1. Every npm and npx step failed. bin() appends `.cmd` on Windows, but Node refuses to spawn a .cmd or .bat with shell:false -- the CVE-2024-27980 mitigation -- and fails with EINVAL and a *null* exit status. run.mjs only special-cased ENOENT, so the result was `FAILED: npx prettier --check . exited null` with nothing to explain it. check:client has three npm steps and has never been able to run here. Fixed by spawning only the npm shims through a shell. They are concatenated into a single command string rather than passed as an args array, because shell:true plus a separate array is deprecated (DEP0190) and prints a warning on every invocation; no argument in this file contains a space. 2. Every optional() step was skipped, always. onPath() shelled out to `where` on Windows, but where.exe lives in C:\WINDOWS\System32, which a Git Bash PATH does not necessarily contain -- on this machine PATH carries System32\Wbem, System32\WindowsPowerShell\v1.0 and System32\OpenSSH but not System32 itself. The probe could not start, `probe.status === 0` was false, and golangci-lint and sqlc reported as "not installed" while installed. Fixed by resolving against PATH and PATHEXT directly. No subprocess, and no dependency on which directories happen to be on PATH. A spawn error other than ENOENT now reports its code instead of surfacing as a null exit status. Verified: before, `node scripts/run.mjs check:hygiene` died with "exited null" and both optional steps printed SKIP with the tools present on PATH. After, the same command runs prettier, shellcheck and actionlint and prints "check:hygiene: passed", with no deprecation warning. `golangci-lint` is detected by the new onPath where the old one missed it. Refs RL-20 / L-14. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * chore(format): ignore build output that nested gitignores hide Prettier honours the root .gitignore and no other. Every build and scratch directory in this repository is ignored by a *nested* one -- Client/.gitignore, .serena/.gitignore, .superpowers/sdd/.gitignore -- so none of them were excluded from the new repository-wide gate. The effect is not subtle. Running `cargo test` once drops roughly 850 formattable files into Client/src-tauri/target/, and the hygiene gate goes from clean to "Code style issues found in 939 files". CI never sees it, because a fresh checkout has no build output; every contributor sees it on their second command. Mirrors the three nested files rather than inventing a list: dist, coverage, playwright-report, test-results, .vite, src-tauri/target and src-tauri/gen from Client/.gitignore, plus .serena/ and .superpowers/sdd/. node_modules needs no entry -- Prettier ignores it by default. Verified: `npx prettier --check .` reports "All matched files use Prettier code style" with a fully populated Client/src-tauri/target/ present on disk, and still names README.md when a misformatted table is appended to it. Refs RL-19 / L-13. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * chore(ci): shellcheck, actionlint, and a repository hygiene job The last two gates RL-19 asks for. Neither existed: the shell scripts were never linted, the workflows were never syntax-checked, and .githooks/pre-commit carried hand-written `# shellcheck disable=` directives that nothing had ever read. New `hygiene` job, ubuntu-only and root-scoped, modelled on docs-consistency for the same reason: every gate in it is platform-independent text analysis, and .gitattributes pins eol=lf so a second OS would only re-prove line endings. It runs `npm run check:hygiene` -- the same entry point a contributor runs, not a parallel copy of the commands. shellcheck ships in the runner image. actionlint does not, so it is pinned by version and verified by sha256: an installer script piped from a branch would be the one unverified download in a workflow file that pins every action by commit SHA. Prettier's step moves here from client-check, where it no longer belongs. Both linters found real defects. shellcheck, 3 findings in 8 scripts. Two are SC1125 errors in .githooks/pre-commit: `# shellcheck disable=SC2086 - repo paths contain no spaces` is not a valid directive. Trailing prose makes shellcheck discard the rest of the line, so neither suppression was ever in effect -- and one of the two was written earlier in this same branch, which is a fair demonstration of why the gate is worth having. The prose moves to its own line above. The third is SC2015 in start-server.sh, rewritten as an explicit if. actionlint, 5 findings, all inside `run:` blocks it shellchecks once shellcheck is on PATH. Three SC2015 in load-baseline.yml, rewritten as explicit ifs. Two SC2035 in release.yml, where `sha256sum *` should not become `sha256sum ./*`: the comment four lines above records that ParseChecksumFile exact-matches the last field, so a "./" prefix would strand every deployed server exactly as a "windows/" prefix would. `sha256sum -- *` satisfies the linter and leaves the output bytes identical. Verified all three gates in both directions with shellcheck 0.10.0 and actionlint 1.7.7 on PATH. Passing: `node scripts/run.mjs check:hygiene` prints "check:hygiene: passed" with all three steps run, not skipped. Failing: appending `bait_fn() { cat $1; }` to Server/scripts/voice-test.sh fails on SC2086; changing a runs-on to `ubunt-latest` fails on runner-label; appending a misformatted table to README.md fails prettier. All three files restored and confirmed clean afterwards. actionlint validates the new job in ci.yml itself. Refs RL-19 / L-13, S-05. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * docs(plans): record B1 progress through B1-3 The header still read "B1-0 is complete; B1-1 is the next step" three merged phases later. A plan that misstates where it is costs a reader the same confusion whether it is stale by one phase or three. B1-0 (#1410), B1-1 (#1411), B1-2 (#1412) and B1-3 (this branch) are done; B1-4, dependency automation, is next. Verified: `node scripts/check-doc-counts.mjs` still agrees on 27 claims across 9 watched documents -- this file is one of them -- and prettier reports it clean. * chore(ci): pin Repository Hygiene as a required check on dev The second half of S-05. Its acceptance criterion is "tree is formatted AND a fast required gate fails future drift" -- a check that runs but is not pinned lets a formatting regression merge, so the gate is not a gate until this lands. The name was read off PR #1414 with `gh pr checks` after the job reported `pass` in 26s, not copied out of ci.yml. That order matters: the B0 script records that three pinned names exist in no workflow file at all, and that a required check which never reports blocks every PR forever. Extends the existing script rather than adding a second one, per the B1 plan. Also records, in the "deliberately NOT pinned" list, that Docs & Ledger Consistency reports and passes on a dev PR yet is unpinned. That reads as an oversight from the 2026-08-25 pass rather than a decision, but it belongs to G-04, so it is documented here and not changed. NOT APPLIED YET. Running this script now would pin a check that PR #1413 cannot report -- its branch predates the hygiene job, so the job does not exist in its workflow file and the check would never arrive. Run it after #1414 merges; #1413 needs a rebase onto dev regardless. Verified: shellcheck clean, the embedded JSON parses, and `check:hygiene` passes with prettier, shellcheck and actionlint all running. Refs S-05, RL-14 / G-03. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> * B1-4: dependency automation (RL-05 / L-05, RL-18) (#1415) * chore(deps): cover the root and mcp-introspect npm roots The repository has three npm package roots — `/` (changelogen, prettier), `/Client`, and `/tools/mcp-introspect` (@modelcontextprotocol/sdk, zod) — each with its own package-lock.json, and `npm run bootstrap` runs `npm ci` in all three. Dependabot watched exactly one of them. The root's prettier is what the Repository Hygiene gate runs, so the formatting gate's own toolchain was drifting unwatched. The obvious fix is to collapse the three roots into an npm workspace and watch one lockfile. Measured on npm 11.17 / Node 26 rather than assumed, that trade is bad, and it is bad for different reasons than expected. Workspaces do not break the things you would predict: `npm ci` inside `Client/` still exits 0, `npm run <script>` still resolves the hoisted binaries because npm prepends every ancestor `node_modules/.bin` to PATH, and `engine-strict` still fails the install on a wrong Node major. What they cost is ten CI steps keyed on `cache-dependency-path: Client/package-lock.json` (six in ci.yml, four in the tag-only, CI-ungated release.yml) pointing at a file that stops existing; the Repository Hygiene job's deliberate root-only install growing 970 ms to 6172 ms and 39 to 318 packages unless every call site remembers `--workspaces=false`; and one shared lockfile putting all three npm Dependabot groups back into the same file, which is precisely the rebase storm the grouping comment at the top of dependabot.yml exists to prevent. The measured benefit is one 298 KB lockfile instead of three (17 KB / 253 KB / 42 KB) and 614 resolved packages deduped to 582 — 32 packages, 5.2% — with client install time unchanged at 5642 ms against 5667 ms. So the roots stay separate and each gets its own block, matching the four that already exist: grouped to one PR, majors ignored, weekly on Monday. A single block with `directories:` was rejected for the same reason as workspaces — grouping only works while a group rewrites exactly one lockfile. The decision and its numbers are recorded in docs/contributing.md under Dependency Policy, so the next person to propose workspaces reads the measurement instead of repeating it. Verified: a coverage checker cross-references every `package-ecosystem` / `directory` pair in dependabot.yml against every manifest in `git ls-files`, in both directions. Against dev at2a37f386it reports `UNWATCHED npm package.json` and `UNWATCHED npm tools/mcp-introspect/package.json` (plus Server/Dockerfile, which the next commit covers). Against this commit both npm rows read `ok`, and the forward direction confirms each newly declared directory really holds a package.json. `npx prettier --check` reports both edited files unchanged, so the B1-3 formatting gate stays green. Not included: the docker ecosystem (next commit, RL-18); immutable image digests for release/runtime containers, which is R-04's remaining half and belongs to B6; and turning the coverage checker into a permanent `check:hygiene` gate — a new manifest root can still drift unwatched, which is how this gap arose, but that is new gate machinery rather than the coverage this item asks for. Refs RL-05 / L-05 * chore(deps): watch the server container base images Server/Dockerfile pulls `golang:1.26-bookworm` to build and `gcr.io/distroless/static-debian12` to run, and nothing watched either. Every other dependency root in the repository is on a weekly Dependabot schedule, so the one artefact that ships to users as a whole filesystem was the only one whose upstream moved silently — including its CA certificates, which the Dockerfile comment specifically calls out as the reason distroless was chosen over scratch. The obvious fix is to pin both images by digest and be done. That is the wrong move here for two reasons. A digest pin with no automation behind it is worse than a tag: it freezes the base image at whatever was current the day someone typed it, and a frozen distroless base is a frozen CA bundle. And digest refresh for release and runtime images is R-04's other half, scoped to B6 alongside the smoke tests that have to gate it — landing half of it here would leave the digests pinned and the refresh unowned. So this adds the `docker` ecosystem for /Server on the same terms as the five blocks around it: grouped to one PR, majors ignored, weekly on Monday. Be precise about what that actually buys, because it is less than the block implies. Of the two images only `golang:1.26-bookworm` carries a comparable version, so it is the only one Dependabot can act on today; `gcr.io/distroless/static-debian12` has no version tag, and an untagged image is not something a version update can move — it needs the digest pinning that B6 owns. The comment above the block records that the Go builder tag tracks Server/go.mod and every `actions/setup-go` in CI, so a `1.27-bookworm` PR is a prompt to move all three together rather than a standalone merge. Verified: the coverage checker cross-references every `package-ecosystem` / `directory` pair against every manifest in `git ls-files`, in both directions. Against dev at2a37f386the reverse direction reports `UNWATCHED docker Server/Dockerfile`; against this commit every row reads `ok` and it exits 0 — `ALL ROOTS WATCHED`, six blocks covering six manifest roots, with the forward direction confirming /Server really holds a Dockerfile. `npx prettier --check` passes on the edited file. Not included: Server/docker-compose.yml and Server/docker-compose.otel.yml. Their four images are `ghcr.io/j3vb/owncord-server:latest` (this repository's own published image), `livekit/livekit-server:v1` (a floating major tag, and majors are ignored everywhere), `jaegertracing/all-in-one:latest` and `prom/prometheus:latest` — none of which a version update can move, so a compose block would be configuration that provably produces nothing. Also not included: immutable digests plus digest-refresh PRs with smoke tests for the release and runtime images, which is the remainder of R-04 and belongs to B6. Refs RL-18 * docs: apply skill-review findings to ci-check and the project skills (#1416) The observation log had accumulated 46 open entries against a last review of 2026-08-14. Seven of them target skills tracked in this repository and were verified still-unapplied against the current files. `ci-check` gains four things it was missing. It never mentioned `cargo audit`, which CI runs pinned at 0.22.1 in `tauri-build` — the one gate that turns red with zero local changes, because an upstream advisory breaks a branch that was clean yesterday, and the one a hand-written mirror silently drops because no edit provokes it. It never mentioned that `release.yml` is tag-triggered and PR-ungated, so a smoke/sign/strip step added only there first executes on the release; #1376 shipped a smoke harness whose own bug then blocked a release, and #1378 fixed it structurally by extracting `Server/scripts/docker-smoke.sh` for both workflows. And it had no guidance for reading a red check at all: a new section adds causality-before-forensics triage (diff the changed-file set against the failing job's input surface before opening a log — a workflow-only diff cannot cause a Go goroutine leak), the lockfile-fork diagnosis for dependency bumps (a 1 → 2 entry-count transition means the update forked the dependency and revoked the features it was borrowing, so aligning versions is the fix, not setting the feature the new copy demands), and the known-flake table promoted to a signature-to-recovery index, now including the apt-mirror hang that cancels `tauri-build` by timeout. The baseline rule that came with the triage section needed adjusting rather than transcribing. Its source observation recorded `golangci-lint`'s known-red complexity baseline as 23 cyclop / 6 dupl / 21 funlen / 12 nestif; #1389 cleared that to zero, so quoting those numbers would have taught the reader to excuse a failure that is now genuinely theirs. The rule is recorded without them, stating that the repo currently carries no known-red gate and what to do if one is ever reintroduced. `protocol-change` claimed the schema is the source of truth without saying what it covers. It holds message-type names only, so a payload-field change touches the Go command/message files, the client types and `docs/protocol.md` and never the schema — routing one through the regenerate cycle is wasted work. A table splits the three cases, with the relay-handler caveat: a server that re-serialises drops unknown fields, so a forwarded field is not backward compatible with older servers. `task-observer`'s numbering discipline treated collisions as a parallel-human accident. They are structural in fan-out workflows, because a dispatched subagent has the skill active in its own context and writes to the same log. `bughunt-run` covered findings blocked by a circuit breaker but not findings that went stale: a later hunt routinely fixes a blocked finding as a side effect of an overlapping sibling, and a saved debris patch stops applying once a refactor rewrites its files. Of 6 findings blocked on 2026-08-14, 2 were already fixed 5 days later. `docs/contributing.md` gains the commit-body convention that was being followed without being written down anywhere — reasoning over diff-restatement, a `Verified:` paragraph proving both directions, and an explicit `Not included:` line. That last one is what keeps adjacent scope from becoming either silent drift or an unnecessary blocking question. Verified: each edit was checked against the live file before applying, which changed two outcomes. Observation 50 (make the hunt's stop rule measure coverage, not just quietness) is already implemented — `bughunt-run` documents `coverage + dry is the real stop`, `stalledCoverage` and `coverage.uncoveredAtStop`, landed by #1399 — so it is marked actioned rather than re-applied. Observation 42 looked covered by the same grep and was not: the existing text handles breaker-blocked findings, a different case from a finding a sibling fix already closed. Confirmed absent before editing: `cargo audit` and `release.yml` in ci-check, `payload` in protocol-change, `subagent` in task-observer. `npm run check:hygiene` passes (prettier clean on all five files); `npm run check:docs` passes. Not included: the 21 open observations targeting `superpowers:*` plugin skills, which live in a versioned plugin cache and are overwritten on update — they are being routed to a separate user-owned extras skill outside this repository. The 6 targeting `graphify` are deferred pending a decision on whether that skill is still in use here now that #1413 removed its repository integration. The 5 new-skill candidates are noted only; a review is not permitted to create skills. Refs skill-observations #25, #35, #39, #41, #42, #43, #45, #58, #59, #63 * B1-5: ownership moves (RL-09 / L-09, RL-10 / L-10, RL-11 / L-11, RL-13 / L-12) (#1417) * refactor: move the protocol schema to protocol/schema.json (RL-09) The WebSocket message-type schema is the one artifact in this repository that neither component owns: `Server/ws/message_types.go` and `Client/src/lib/protocolTypes.ts` are both generated from it, and neither may be hand-edited. It nonetheless lived at `docs/protocol-schema.json` — filed under the directory for prose, whose own README calls it "Reference" material — and its generator lived at `Server/scripts/genprotocol/`, i.e. inside one of the two consumers. Ownership was legible from neither location. The obvious fix — move the generator to the repository root alongside the schema, so the whole tool is at the cross-component boundary — is wrong here. The generator is a Go `package main`, and Go modules are directory-rooted: `Server/go.mod` roots at `Server/`, so a root-level Go program needs a second module or a `go.work`. That second module would sit outside every path filter this repository already has — `golangci-lint` runs with `working-directory: Server/` (ci.yml), `go vet ./...` runs from `Server/` (scripts/run.mjs, .githooks/pre-commit), `.githooks/pre-commit` selects Go files with `^Server/.*\.go$`, `.githooks/pre-push` sets `server_changed` on `^Server/`, setup-go caches on `Server/go.sum`, and dependabot has one gomod block for `/Server`. Six gates would silently stop covering the generator, each failing open. The schema is data and moves freely; the generator is Go and stays where the Go toolchain already runs. Done instead: - `docs/protocol-schema.json` -> `protocol/schema.json`. A new top-level `protocol/` is the cross-component boundary, with a `README.md` naming the two generated consumers, the one command, and the four gates. - `Server/scripts/genprotocol/` -> `Server/cmd/genprotocol/`, the module's conventional home for an executable. This also empties `Server/scripts/` of Go entry points except `seed.go`, which RL-10 moves next. - `Server/cmd/` added to `Server/.dockerignore` and `Server/.air.toml`, which both already excluded `Server/scripts/`. Without this the move would have silently widened the Docker build context and the air watch set. 27 files, 115 insertions, 76 deletions. Two runtime path resolvers re-pointed (`cmd/genprotocol/main.go:41` `-schema` default, `ws/protocol_contract_test.go:67` `filepath.Join`); two git-hook grep patterns (`pre-commit:53`, `pre-push:57`); eight generator call sites across five files (Makefile x2, scripts/run.mjs x2, pre-commit x2, ci-check skill, bughunt-fix.js); two broken relative markdown links (docs/README.md:47, docs/protocol.md:1497); two generated files regenerated, header lines only, zero constants changed; two ledger prose hits plus a `render-ledger.mjs` re-render. No new verify was written: the regenerate-and-diff check is already enforced three times (CI `make protocol-verify`, `.githooks/pre-commit`, `npm run check:server`) and `ws/protocol_contract_test.go` independently checks the schema against the constants a fourth time. Verified: both directions, for both resolvers. With `protocol/schema.json` removed, `go test ./ws/ -run TestProtocol` fails with `reading protocol schema at /home/user/OwnCord/protocol/schema.json: no such file or directory` (two tests) and `go run ./cmd/genprotocol` exits 1 with `read schema: open ../protocol/schema.json: no such file or directory`; with the file restored both pass. So the new path is genuinely resolved, not merely spelled in a comment. The hook patterns were exercised directly: the pre-commit pattern matches `protocol/schema.json` and `Server/cmd/genprotocol/main.go` and no longer matches `docs/protocol-schema.json`; the pre-push pattern matches `protocol/schema.json`. `go run ./cmd/genprotocol` twice in a row leaves `git diff --exit-code ws/message_types.go ../Client/src/lib/protocolTypes.ts` clean, so the committed outputs are exactly what the generator emits. `go build ./...` and `go vet ./...` pass; `npx prettier --check .`, `npm run typecheck` and `npm run lint` pass; `node .superpowers/render-ledger.mjs --check` reports 348 findings valid. Not included: the four dated `docs/audit-*.md` files, the older `docs/plans/*`, and `CHANGELOG.md` keep the old path — they are point-in-time records, and `.prettierignore` and `scripts/check-doc-counts.mjs` already treat them as deliberately unmaintained. The B1 plan itself keeps its own wording, since it states intent rather than current state. `Server/scripts/` is not deleted: it still holds `seed.go` (RL-10), `k6/`, `toxiproxy/` and two shell scripts. `Server/telemetry/metrics.go:19` declares a scope for a `Server/voice` package that does not exist — spotted here, unrelated to this move, left for RL-13's sweep to carry forward verbatim rather than fixed inside a relocation. No `seed:` Make target was added. Refs RL-09, L-09 * refactor: move the seed tool under Server/cmd/seed (RL-10) `Server/scripts/seed.go` was a `package main` sitting directly in `Server/scripts/`, which made `Server/scripts` itself one of the module's three main packages — a developer tool in the module's build graph under a directory name that says "loose scripts". It also did filesystem work in `func init()`: `os.MkdirAll("data", 0o750)` ran before `flag.Parse()`, so the directory appeared even when the tool immediately refused to run. The audit row (RL-10) claims that `init()` fires "during test discovery". It does not, and the obvious fix aimed at that claim would be aimed at nothing: `Server/scripts/` contains zero `_test.go` files, so Go never builds a test binary there and `go test ./...` never runs the `init()`. The residual defect is narrower and real — an untagged `package main` in the build graph, plus a side effect on a path (`go run ./cmd/seed -h`) that has nothing to do with tests. Done: - `Server/scripts/seed.go` -> `Server/cmd/seed/main.go`, joining `cmd/genprotocol/` from RL-09. `Server/scripts/` now holds shell and JS tooling only (docker-smoke.sh, k6/, toxiproxy/, voice-test.sh) and no Go entry point at all. - The `os.MkdirAll` moved out of `init()` to immediately before `db.Open` in `main()` — the one call that needs the directory, since `db.Open` -> `OpenWithMaxReaders` -> `openFile` creates no intermediate directories. - The package doc comment's usage lines were wrong in two ways, not one: they named `go run scripts/seed.go`, which no longer exists, and they omitted the mandatory `-confirm-dev`, so neither documented command could ever have run. Both corrected, and `seed.go is a standalone tool` became the conventional `Command seed populates ...`. - `Server/CLAUDE.md`'s Layout list now names `cmd/` and states that no Go entry point lives in `scripts/`. Two files, 20 insertions, 17 deletions. `go list` main packages go from `{server, server/cmd/genprotocol, server/scripts}` to `{server, server/cmd/genprotocol, server/cmd/seed}` — the count is unchanged at three, which is the honest framing: this relocates a main package to a conventional path, it does not remove one from the build graph. Verified: both directions, by building the pre-change file and the post-change file and running each in a fresh empty directory. Before, `seed` with no flags exits 1 *and leaves a `data/` directory behind*; `seed -h` exits 0 and also leaves `data/` behind. After, both exit the same way and create nothing — `data/ exists=NO` in each case. The happy path is unchanged: `seed -confirm-dev` in an empty directory creates `data/` at mode 0750, writes `data/chatserver.db`, and reports 4 users / 5 channels / 31 messages; a second run reports 0 new rows, so idempotence survives. The old documented invocation now fails loudly (`go run scripts/seed.go` -> `stat scripts/seed.go: no such file or directory`) and the new one is what the comment says. All four build-tag variants compile, `go vet ./...` passes, `gofmt -l` is clean outside `db/dbgen`, and `npx prettier --check .` passes. Behaviour delta, called out rather than left silent: the two cases above (`-h`, and a missing `-confirm-dev`) no longer create `./data`. That is a change, not a pure relocation. It is the change RL-10 asks for — the remedy text is "remove import/test-time filesystem side effects" — and the alternative that preserves the old behaviour exactly, making the `MkdirAll` the first statement of `main()` before `flag.Parse()`, would keep precisely the side effect the item exists to remove. Not included: `Server/scripts/genprotocol` was moved to `Server/cmd/` by the RL-09 commit rather than here, so the "executable tooling under conventional command ownership" class is closed across the two commits, not this one alone. `filepath.Dir(*dbPath)` was evaluated for the `MkdirAll` and rejected: it would fix a real gap (`-db /elsewhere/x.db` still creates a useless `./data` and does not create `/elsewhere`) but it means creating an arbitrary directory from CLI input, and that is a behaviour change past "shift it out of `init()`" — worth its own item. No `make seed` target was added, and the dated `docs/audit-*.md` rows naming `Server/scripts/seed.go` keep the old path. The findings ledger has zero references to this file, so no re-render was needed. Refs RL-10, L-10 * test: give the cross-stack contracts a named tier (RL-11) `Client/tests/unit/admin-static-channel-perms.test.ts` reads and executes `Server/admin/static/index.html`. Filed under `tests/unit`, nothing about its location or name said it locks a server-owned artifact, so a Go developer editing the admin SPA got a red check called "Client Unit Tests" with no clue why. The register describes this as one file. It is not, and the measured set does not match the description in either direction: - Client -> Server: exactly ONE test crosses by filesystem read, not two. `main-page.test.ts` was named in the plan but only carries a prose comment citing `Server/admin/update_handlers.go:181` at line 1046 — no read, no import, nothing to move. - Server -> Client: the four tests the plan named do not cross. `waf_test.go`/`waf_crs_test.go` set a `User-Agent: OwnCordClient/1.0` literal that appears nowhere under `Client/`; `ws_integration_test.go:289` and `sanitize_content_fuzz_test.go:46` are comments. The real crossing is one the register never named: `Server/updater/updater_test.go:630` does `os.ReadFile` on `Client/src-tauri/tauri.conf.json`. The obvious fixes are both wrong. Moving the invariant "to the owning server test" cannot work: `Server/go.mod` carries no JavaScript engine (no goja, otto, v8go, quickjs, rogchap, duktape), so a Go port could only assert at the text level like `admin/perm_grid_test.go` does — and that is not a substitute. Flipping the guard at `admin/static/index.html:1182` to `targetIsTouchedRole=false` reintroduces OC-0154 in full while leaving every greppable identifier intact, so a text-level test passes on a broken file. Relocating it to the e2e admin journey is worse: that job is `continue-on-error: true` and deliberately unpinned ("requiring it is theatre" — `docs/plans/b0-dev-branch-protection.sh`), so it would convert a blocking, pinned gate into one that is green regardless. And the journey does not cover the invariant today: `grep -Eic "perm|access|role|override|matrix"` over its 142 lines returns 0, so the "if e2e already covers it, delete" branch never fires. Done — one tier, applied to the whole set, defined by artifact coupling and placed by runtime capability: - New `Client/tests/contract/`, holding `server-admin-static-channel-perms.test.ts`. Same directory depth, so `../../../Server/...` still resolves; the body is byte-identical apart from a header naming the owner and the runner. - `Server/updater/tauri_key_contract_test.go` splits the one cross-component Go test out of `updater_test.go` verbatim, same `package updater`. It stays in Go — placement follows capability, and Go parses JSON fine — so only the file name has to declare the crossing. Without this the item would have been "moved one file and declared the class closed". - `npm run test:contract`, and the tier, the membership rule and a blocking/non-blocking table in `docs/contributing.md#testing`, which previously described no tiers at all. - `Client/CLAUDE.md`'s tier list was missing `tests/e2e/admin` and `tests/e2e/native` before this; it now lists all seven and states the rule. `Server/CLAUDE.md` records why the SPA's execution-level invariant is locked from the client tree, so nobody "fixes" it into a regex. - Ledger `OC-0154.fix.test` re-pointed and `FINDINGS.md` re-rendered; `.claude/workflows/bughunt.js` — the workflow that produced OC-0154 — no longer describes the TS test surface as `tests/unit/*.test.ts` only. - Three stale cross-stack pointers of exactly the class this item is about: `tests/e2e/helpers.ts:348,351` and `tests/unit/types.test.ts:13` named `docs/brain/06-Specs/PROTOCOL.md`, which does not exist (`docs/brain/` is a gitignored path); all now name `docs/protocol.md`. 15 files, 125 insertions, 33 deletions. No CI job, workflow, vitest, tsconfig, eslint, knip or stryker change, and no new pinned check — `ci.yml`'s `npx vitest run --coverage` has no path filter and `vitest.config.ts` includes `tests/**/*.test.ts`, so enforcement after the move is bit-identical to enforcement before it. That is deliberate: `dev` pins 11 contexts and a 12th is a branch-protection API write, not something a PR can do, so any new job would be advisory until someone separately changed repository settings — strictly less protection than today. Verified: both directions, and the assertion was not weakened. Flipping `admin/static/index.html:1182` to `const targetIsTouchedRole=false;` makes the moved test fail (`AssertionError: expected 'DELETE' not to be 'DELETE'`); `git checkout` of that file makes it pass again — so the invariant survived the move intact rather than becoming a test that passes anywhere. The split Go test's cross-boundary read is live too: with `Client/src-tauri/tauri.conf.json` moved away, `go test ./updater/` fails with `ReadFile(../../Client/src-tauri/tauri.conf.json): no such file or directory` from `tauri_key_contract_test.go:20`, and passes once restored. The full client suite is 192 files / 5257 tests passing, identical to the count before the move; `npm run typecheck` passes, which proves `tests/contract/` is inside the tsconfig graph and that `tests/types/jsdom.d.ts` still resolves the moved test's `import { JSDOM }`. `npm run lint`, `npx prettier --check .`, `go vet ./...` and `go test ./updater/` all pass. `git grep "tests/unit/admin-static-channel-perms"` finds no survivor outside the B1 plan itself. Not included: nothing was deleted, because no e2e sibling covers OC-0154. `Client/tests/types/jsdom.d.ts` was neither moved nor deleted — it is still the only type source for the moved test's `jsdom` import. `capabilities-scope.test.ts` and `tauri-conf-webview2-args.test.ts` read `src-tauri/` and stay in `tests/unit`: `src-tauri` is inside the `Client` component, so they are not contract tests, and the rule earns that rather than hand-waving it — moving them would have forced repoints of ledger entry OC-0089 and `docs/security.md:64` for no gain. Each gained a one-line header saying why. `Server/admin/perm_grid_test.go` and `emoji_section_test.go` read their own package's embedded asset and are unchanged; they are the text-level complement to the execution-level test, not duplicates. No JS engine was added to `go.mod`, no npm root was created under `Server/`, and no root-level `tests/` tier was created — there is no runner for one and no way to make it blocking from a PR. Separately noticed and NOT fixed here: `docs/contributing.md:221` still says "All ten required checks" while `docs/plans/b0-dev-branch-protection.sh` pins eleven since B1-3 added `Repository Hygiene`, and `docs/plans/hp-0-scorecard-2026-08-25.md:109` is stale the same way — that is the branch-protection item's to fix, not this one's, and one register item per commit. Refs RL-11, L-11 * refactor: rename the Go module to github.com/J3vb/OwnCord/Server (RL-13) `Server/go.mod` declared `github.com/owncord/server` while the public repository is `github.com/J3vb/OwnCord`. Nothing resolves that path — there is no `owncord` GitHub org and no vanity-import host serving go-import metadata for it — so every import line in the tree named a location that does not exist. It compiles because a main module's own path is never fetched, which is exactly why it went unnoticed. The obvious fix — an AST-aware import rewriter (`gomvpkg`, `go mod edit`) — is wrong here, and provably so. Six of the 722 occurrences are not imports at all: `api/main_test.go:20` (a goleak `IgnoreTopFunction` pattern), `telemetry/metrics.go:17-19` (three OTel instrumentation-scope names), `invariants/syncutil_locks.go:73` (a diagnostic message), and `invariants/syncutil_locks_test.go:56` (an import line inside a raw-string Go fixture). An import rewriter touches none of them, and the compiler cannot see any of them either. Done as one scripted substitution over `git ls-files`, anchored on the full `github.com/owncord/server` string. The anchor matters: `owncord-server` is a different identifier — the OTel `service.name` (`config/config.go`, `telemetry/telemetry_otel.go`) and the GHCR image name (`.github/workflows/release.yml`, `docker-compose.yml`) — and a looser pattern would have moved it. It is untouched: 10 occurrences across 9 files, before and after. 350 files, 728 insertions, 728 deletions. 722 occurrences in 344 Go files, plus `go.mod:1`, the `sed` at `Makefile:67`, `Server/CLAUDE.md:3`, `docs/architecture/server.md:5`, and the ledger pair (`findings-ledger.json:3758` plus a `render-ledger.mjs` re-render of `FINDINGS.md`). Zero in any workflow, zero in the Dockerfile, zero in `Server/.golangci.yml` (no `local-prefixes`, `gci`, `importas` or `depguard` rule keys on the module path, so import grouping is not configured anywhere). The plan's blast-radius estimate missed one thing, and it is the one that would have gone red: **gofmt**. `J` (0x4A) sorts before every lowercase letter, so in the 36 files where a module-local import shares a contiguous group with a third-party one, the module's imports must move above `github.com/go-chi/...`. `gofmt -l` was clean before the substitution and listed exactly 36 files after it; `gofmt -w` on those 36 restores it to clean. `gofmt` is an enforced gate — the `formatters` block in `Server/.golangci.yml`, which is S-05 — so a substitution-only commit fails Lint. Verified: both directions, and the line accounting is exact. Every added line in this diff contains the new module path (728) and every removed line contains the old one (728); the count of changed lines containing neither is **zero**, so the gofmt re-sort moved module-path lines only and touched no third-party import. The residual check (`git ls-files -z | xargs -0 grep -n 'github\.com/owncord/server'`) returns exactly two hits, both deliberately out of scope: the RL-13 row in `docs/audit-2026-08-23-repository-layout.md` and the measurement row in this phase's own plan. The compiler-invisible half was proven by reverting *only* `api/main_test.go:20` to the old path on the otherwise-renamed tree: `go build ./...` and `go vet ./api/` both still pass — they see nothing wrong — while `go test ./api/` FAILS, because the runtime function name now carries the new path and goleak stops ignoring `ws.(*Hub).Run.func1`. Restoring the line makes it pass. `go.sum` is byte-identical (no `go mod tidy` was run and none was needed). All four build-tag variants compile; `go vet ./...`, `go vet -tags otel,wazero ./...` and `go vet -tags deadlock ./...` pass; `go test -race ./...` is 16/16 packages green; `go test -tags deadlock ./...` passes; the tag-gated `./plugin/...` (wazero) and `./telemetry/...` (otel) runs pass. `golangci-lint` v2.11.3 — the pinned CI version, rebuilt locally against Go 1.26 because the packaged binary cannot load a 1.26 config — reports **0 issues**. `go run ./cmd/genprotocol` leaves `git diff --exit-code ws/message_types.go ../Client/src/lib/protocolTypes.ts` clean, so the rename does not reach the generated protocol constants. `npx prettier --check .` and `node .superpowers/render-ledger.mjs --check` pass. Not included: `docs/audit-2026-08-23-repository-layout.md` and `docs/plans/b1-repository-foundation-2026-08-25.md` keep the old path — they are the audit row and the measurement that motivated this change, and rewriting them would erase the record of what was measured. They are why the residual check needs a two-path allowance rather than being empty; that allowance is stated above rather than hidden in a pathspec. `telemetry/metrics.go:19` declares `scopeVoice` for a `Server/voice` package that does not exist; the substitution carried the dead path forward verbatim as `github.com/J3vb/OwnCord/Server/voice` rather than fixing it, because correcting a real observability bug inside a mechanical rename would hide it in a 350-file diff. It needs its own item. No `go.work`, no second module, and no vanity-import host was set up — the new path resolves against the real repository, but nothing imports this module as a library, so `go get` reachability was not exercised either way. Refs RL-13, L-12 --------- Co-authored-by: Claude <noreply@anthropic.com> * B1-6: generated artifacts (RL-06 / L-06, RL-07 / L-07, RL-08 / L-08) (#1418) * ci: verify FINDINGS.md against the ledger it renders from (RL-07) `.superpowers/FINDINGS.md` is generated from `findings-ledger.json`, and `CLAUDE.md` forbids hand-editing it — but nothing checked. The one automated consumer, `render-ledger.mjs --check`, validates the ledger's JSON schema and `return`s at line 116, *before* the only `render()` call at line 118, and never opens `FINDINGS.md` at all. A stale 1.09 MB rendering passed it cleanly. The audit says "no workflow runs it". That was true when it was written and is not now: B1-2 (#1412) wired `--check` into the `Docs & Ledger Consistency` job. So the gate exists, reports, and is blind to the thing its name suggests it watches — which is worse than absent, because it reads as covered. The obvious fix — render to a temp file and diff, as the B1 plan suggests — is not what this repository does. It has three implementations of one idea (`Server/Makefile` sqlc-verify and protocol-verify, `.githooks/pre-commit`, `scripts/run.mjs`), and all three regenerate **in place** and let `git diff --exit-code` be the differ. That needs no temp path, no cleanup, and inherits `.gitattributes`' line-ending normalisation for free. A fourth shape would cost a reader something for nothing. Done: - The gate, in all three places the existing two gates live: the `docs-consistency` CI job, `scripts/run.mjs`'s `CHECK_DOCS`, and a new `.githooks/pre-commit` block gated on the ledger, the rendering, or the renderer being staged. `npm run check` never ran the renderer at all before this, which contradicted `run.mjs`'s own stated purpose. - `validate()` now requires `severity`. This is not a nice-to-have riding along: `render()` sorts the open section by `SEV_RANK`, and an unranked severity makes the comparator return `NaN`, which leaves the sort order implementation-defined. A gate whose expected output is implementation-defined can go red across a Node upgrade for a reason that is not drift. The validation is what makes the gate's premise — that the rendering is a pure function of the ledger — true rather than merely true today. - `--stat` on the diff. Deliberate deviation from the three precedents: a fully drifted rendering is a ~40,000-line CI log, and the exit code is what gates. Eight files, 119 insertions, 24 deletions. The gate is one render (67-170 ms) plus one `git diff`. Rendering subsumes `--check`, because `main()` validates and exits 1 before it writes — so the CI job keeps both steps only so the checks UI names which fix is needed. Verified: both directions, and the naive test would have lied. Appending to `FINDINGS.md` proves nothing — the renderer overwrites it, so the perturbation vanishes and the diff comes back clean. `git diff <path>` compares the worktree against the **index**, so the drift has to live in the index. Changing one finding's title in the ledger and staging it *without* re-rendering — exactly the mistake the gate exists to catch — makes `git diff --exit-code --stat` exit 1 with a one-line stat, and `.githooks/pre-commit` fail with `FINDINGS.md is stale`. Restoring the ledger and re-rendering returns both to exit 0, and `git status --porcelain` is clean afterwards. Severity validation both ways: setting one finding to `moderate` makes `--check` print `INVALID OC-0001: bad severity moderate` and exit 1; `git checkout` of the ledger makes it valid again. The hook's grep pattern was exercised against five paths — the three `.superpowers/` targets match, `.superpowers/sdd/notes.md` and `scripts/check-doc-counts.mjs` do not. `node scripts/run.mjs --list` resolves `check:docs` to three steps rather than one; `npm run check:docs` and `npm run check:hygiene` pass, the latter with prettier, shellcheck (the new hook block) and actionlint (the new CI step) all running for real. Not included: untracking `FINDINGS.md` — that is the next commit, and the order matters. L-07 requires the drift check to exist *before* the removal, because the check is what proves the tracked copy was current at the moment it was deleted. No `import.meta.main` guard on the renderer: no caller imports it, and `import.meta.main` landed in Node 24.2 against an `engines` floor of `>=24`, so it would silently no-op on 24.0/24.1 — `scripts/check-doc-counts.mjs` documents the workaround and stays accurate. No `existsSync` guard for a missing ledger: the unhandled rejection already exits non-zero, so CI already rejects it and only the message is ugly, which is not drift. The `docs-consistency` job is not converted to `npm run check:docs`; it is deliberately `npm ci`-free with direct `node` calls in every step, and half-converting it would be worse than being internally consistent. No `Server/Makefile` target — the ledger is root-scoped, and `make` is not on PATH on a stock Windows box (RL-20). Refs RL-07, L-07 * chore: stop tracking the rendered FINDINGS.md (RL-07) The previous commit built the drift check RL-07 asked for. This is the second half: with the check in place proving the committed rendering was current, the rendering itself comes out of the index. Untracking is strictly stronger than checking. A drift check watches for a rendering that has fallen behind its source; not tracking it removes the possibility. `findings-ledger.json` stays the only tracked copy and remains canonical — `CLAUDE.md` tells contributors to open a PR against it — and the 1.09 MB view of it is regenerated in 67-170 ms by a command that was already documented. Why the drift check still had to land first, in its own commit: it is what proved the tracked copy was current at the moment it was deleted. Deleting a generated file you have never verified against its source is how you discover, later, that the source was wrong. L-07 sequences it the same way — "remove the tracked duplicate human rendering *after* deterministic on-demand/CI rendering and a drift check exist" — and this commit is the "after". The gate transforms rather than disappears. `git diff --exit-code` cannot watch an untracked file, so what remains of L-07's "CI rejects generation failure or drift" is the generation half, plus its separate "a downloadable rendering is reproducible" clause. CI now renders **twice and compares** — which tests both: the render must succeed (it validates and exits 1 before writing) and it must be a pure function of the ledger. The severity rule added in the previous commit is what makes that second property true rather than merely true today. The rendering is then uploaded as the `findings-ledger-rendering` artifact with `if: always()`, so a reviewer reads it without a Node run — and can read it precisely when the job failed. Six coordinated edits, and the fourth is not optional: - `.gitignore` — drop the `!` negation; the `.superpowers/*` blanket takes over. - `.gitattributes` — drop `linguist-generated=true`, now dead. - `.prettierignore` — drop the entry; Prettier 3 reads the root `.gitignore`. - `scripts/check-doc-counts.mjs` — drop it from `WATCHED`. A missing watched file is pushed to `failures` and exits 1 by design, with a message telling you to fix the list. Forgetting this line reds `Docs & Ledger Consistency` and `npm run check` on every subsequent run. - `CLAUDE.md` — the command stays, the "tracked artifact" framing goes. - `.claude/skills/bughunt-run/SKILL.md` — the human gate between hunt and fix reads this file, so it now says to generate it first. That reader is already at a terminal that ran the renderer seconds earlier. 13 files, 103 insertions, 9,278 deletions. The check-doc-counts gate goes from 27 claims across 9 documents to 21 across 8; the six it loses were rendered *from* the ledger they were checked against, so they were self-consistent by construction and could only ever have failed on a stale rendering — which is the thing that can no longer exist. Verified: both directions. `git ls-files .superpowers/` returns exactly two files; `git check-ignore -v .superpowers/FINDINGS.md` names `.gitignore:87` while the ledger itself is not ignored (exit 1), so the blanket rule did not overreach. Deleting the rendering outright and running `node scripts/check-doc-counts.mjs` prints `21 claim(s) across 8 watched document(s)` and exits **0** — the proof that the `WATCHED` line was dropped, because leaving it would have failed here. `npm run check:docs` then regenerates the file (1,087,051 bytes) and passes. Rendering twice and `cmp`-ing the results reports byte-identical output. The pre-commit hook was exercised both ways with the ledger staged: a severity of `moderate` fails with `findings-ledger.json is invalid`, and a valid tree passes with exit 0. `npm run check:hygiene` passes with prettier, shellcheck and actionlint all running for real. Not included: `findings-ledger.json` is untouched by this commit — it is the canonical copy and it stays tracked, at 1,205,085 bytes, which is *larger* than the rendering just removed. Anyone reaching for the size argument should know that untracking the rendering removes 47% of the pair and leaves the bigger, less readable half; the reason to do it is that the rendering is 100% derived and would otherwise write a fresh ~1.06 MB blob into permanent history on every hunt, not that it is the heavy one. No history rewrite — the blobs already committed stay where they are, per the B1 non-goal. `Server/Makefile` gains no ledger target: root-scoped, and `make` is not on PATH on a stock Windows box. Refs RL-07, L-07 * chore: stop tracking the prebuilt hello.wasm plugin example (RL-08) `Server/plugin/examples/hello/hello.wasm` was 946,410 bytes of committed build output — 84% of that directory — for a plugin subsystem that is disabled twice over: it compiles only under `-tags wazero`, and `plugins.enabled` defaults to `false`. Nothing verified it matched the `main.go` beside it. The remedy the audit names is a compile-and-compare gate. It cannot be built, and not for cost reasons: TinyGo embeds absolute host paths from the building machine's Go SDK and module cache into its output and offers no `-trimpath` equivalent, so two machines compiling identical source produce different bytes. A byte-identity gate cannot pass in principle. What is left is a compile-only check, and that needs three pinned downloads — TinyGo, a *second* Go SDK at 1.25.x because TinyGo 0.40.1 rejects the Go 1.26 this module pins, and Binaryen 129 — on every PR, to prove something weaker than advertised about a subsystem that ships in zero release artifacts. So the artifact goes and its provenance is written down instead. BPR-080 asks that the example WASM be "reproducible **or** provenance-verified" — disjunctive — and the second branch is the one that is actually reachable here. The repository had already made this call for itself. `sandbox_wazero_test.go` uses a 41-byte inline WASM literal, with the comment "Using a literal here avoids dragging a binary asset into the repo." This extends that from the tests to the example. Done: - `git rm --cached` the artifact; a narrow `.gitignore` entry naming the exact path. Deliberately **not** a blanket `*.wasm`: `Client/public/rnnoise.wasm` is a vendored npm artifact this repository does not build and the client fetches at runtime, so ignoring it would break noise suppression. The rule that separates them — untrack build output whose source we own and whose absence breaks nothing; keep vendored third-party artifacts required at runtime — is written into the ignore comment. - `Server/.dockerignore` gains `plugin/examples/`. `Dockerfile` does `COPY . .` and the file already excluded `scripts/` and `cmd/` but not this, so a developer who still has the untracked artifact on disk was shipping it into the build context. Same omission B1-5 fixed for `cmd/`. - The README carried two false statements, both now removed: it claimed the plugin is "used by `Server/plugin/plugin_test.go`" and that that test "exercises the manifest parser and the loader against this directory". Neither is true — `plugin_test.go` builds every fixture in `t.TempDir()`. - A Provenance section: TinyGo 0.40.1 + Go 1.25.3 + Binaryen 129, why the output is not byte-reproducible, and why the compile gate is deferred rather than merely absent. - The ABI-stability sentence L-08 requires, which existed nowhere in the repository: the ABI is experimental with no compatibility promise, and both halves of "disabled" are named with the files that prove them. Verbatim identical in the example README and `docs/contributing.md`. - The TinyGo/Go/Binaryen table existed in two hand-maintained copies that had already drifted in wording. It now lives in the example README only; `docs/contributing.md` links to it, which is the pattern that page already used two lines above for the ABI itself. Five files, 87 insertions, 20 deletions, plus the 946,410-byte deletion. Verified: both directions. The inertness proof is the load-bearing one, and it is the inverse of B1-5's remove-and-watch-it-fail, because here passing is the point: with `hello.wasm` moved out of the tree entirely, `go build ./...`, `go build -tags wazero ./...`, `go vet ./...`, `go test ./plugin/...`, `go test -tags wazero -count=1 ./plugin/...` and `go test ./api/...` all pass. `go list ./plugin/...` returns a single package with and without the tag, so `//go:build tinygo` keeps the example out of the module's build graph. The narrowness proof is one pair: `git check-ignore -v` matches `Server/plugin/examples/hello/hello.wasm` at `.gitignore:59` and exits 0, and exits 1 on `Client/public/rnnoise.wasm`, which `git ls-files` confirms is still tracked. `git ls-files Server/plugin/examples/` now returns exactly the three source files. `npm run check:hygiene` and `npm run check:docs` pass. Not included: no CI compile-and-compare job, per the reasoning above — deferred to B2, which the issue register already names as L-08's second phase. **L-08 is not claimed closed**: its closure evidence reads "Deterministic source build passes", and that is precisely what TinyGo cannot deliver here; the register's B1/B2 span is what makes deferring it in-scope rather than a slip. No `tinygo.version` pin file — `Server/sqlc.version` earns its existence through four mechanical consumers, and nothing would read this one; the gap in `docs/contributing.md`'s toolchain-pinning policy is closed by recording TinyGo and Binaryen as a documented exception instead. `main.go`, `plugin.json` and the README stay tracked — L-08 says keep the source, and this commit keeps all of it. `.gitattributes` keeps `*.wasm binary`, which still covers the client's vendored module. No history rewrite: the artifact's existing blobs stay where they are, per the B1 non-goal. Refs RL-08, L-08 * docs(plans): retire the removed graphify tooling from the B1 plan (RL-06) RL-06 asked for a 20.41 MB tracked `graphify-out/` payload to stop being tracked, after a portable regeneration command and a CI artifact existed. None of that happened. Instead `a5f7d95` (#1413) deleted the tool outright, taking all 7 tracked files with it — 20,408,656 bytes, `graph.json` at 19,463,420 — before B1-6 opened. `git ls-files` matches nothing graphify-related today. So the outcome RL-06 wanted holds (no large tracked payload, history intact) and the method it prescribed was bypassed. There is nothing left to do in the repository. What was left is a documentation problem, and a live one: this plan is an active document, and it still told a reader to run a tool that does not exist. The obvious response — delete every graphify mention — is wrong twice over. The `.gitignore` rule has to stay: the local directory reached ~208 MB with cache and dated snapshots on the machine that ran the tool, and dropping the rule would flood that contributor's `git status` with untracked noise. And the "do not rewrite history to shrink graphify-out" non-goal has to stay too: the files are gone from the tree but four `graph.json` revisions remain in the pack (~71 MiB logical, ~3.2 MiB packed of 13.28 MiB), so the line is still operative. It is what keeps "closed" honest rather than overclaiming. Done — nine edits, each a dead instruction rather than a stale mention: - **B1-2 Step 7, the worst of them.** It told a human to `unset GRAPHIFY_SKIP_HOOK`, run `graphify update .`, and `git commit -am` a refresh. The tool is gone, and `git commit -am` with nothing to commit exits non-zero while reading like a no-op success. Replaced with a retirement note; Step 7 is the last step, so nothing renumbers. - **The "Traps carried forward" entry.** A live instruction, in a list of traps, aimed at exactly the multi-commit sequence this phase is. Deleted. - B1-1 Step 1's `export GRAPHIFY_SKIP_HOOK=1` and its four-line hook rationale, collapsed to one sentence of history. The "close any editor, cargo, vite" paragraph beside it is still true and stays. - The RL-06 verdict row, the B1-6 bullet, the flatten's "leave alone" list, the `post-commit` parenthetical, B1-3's exclusion list, and the non-goal line. - `.gitignore`'s stale "delete the dir when convenient" TODO becomes a recorded decision citing the commit that caused it. Verified: `git grep -i graphify` outside the dated audit and the issue register returns exactly five hits, and every one is intended — the `.gitignore` rule and four plan lines that are explicitly retirement or history notes ("once began", "Retired", "closed by deletion", and the non-goal). `git grep GRAPHIFY_SKIP_HOOK` returns one hit, the sentence recording that it used to be required. `node scripts/check-doc-counts.mjs` still passes — this file is one of the documents it watches — and `npx prettier --check` is clean after the verdict-row rewrite reflowed the table. Not included: `docs/audit-2026-08-23-repository-layout.md` keeps its RL-06 row — dated point-in-time snapshot, and `check-doc-counts.mjs` already classifies `docs/audit-*` as report-only. `docs/plans/repo-health-issue-register-2026-08-23.md` keeps L-06 and the R-03 row that routes to it, and the reason is *not* that it is dated: it is in the watched set, i.e. this repository treats it as active. It is that no B1 phase has updated its closure column, so L-01, L-04, L-05 and L-09 through L-13 are all closed in fact and open on paper. Changing that convention in the phase with the least to say about it would leave the register half-updated, which is worse than uniformly stale. That sweep belongs to `R-06`, or to one pass at B1's end. No history rewrite, per the non-goal this commit deliberately keeps. Refs RL-06, L-06 * docs(plans): record B1 progress through B1-6 The header still read "B1-3 are complete; B1-4 is the next step" three merged phases later — B1-3 (#1414), B1-4 (#1415) and B1-5 (#1417) have all landed, and B1-6 is this branch. B1-3 set this convention with its own `docs(plans): record B1 progress through B1-3` commit, and then B1-4 and B1-5 both skipped it. A plan that misstates where it is costs a reader the same confusion whether it is one phase stale or three; three is just harder to notice, because the header looks deliberate. Verified: `node scripts/check-doc-counts.mjs` still agrees on 21 claims across 8 watched documents — this file is one of them — and prettier reports it clean. Refs RL-06 (the phase this records), R-08 * chore(ci): pin Docs & Ledger Consistency as a required check on dev The previous commits gave `Docs & Ledger Consistency` a gate that can actually fail: it now rejects a ledger that will not render, on top of the schema check it already ran. But the job is not among `dev`'s required contexts, so it reports and cannot block. L-07's closure evidence reads "CI **rejects** generation failure or drift" — reporting is not rejecting, and the item is not closed until this lands. The script's own header already diagnosed the omission: it listed `Docs & Ledger Consistency` under "deliberately NOT pinned" with the note that it "looks like an oversight from the 2026-08-25 pass rather than a decision". That entry is now wrong in the other direction, so it moves out of the not-pinned list and into a dated note beside `Repository Hygiene`'s. The name was read off **PR #1418's live check runs** after the job reported `success` — not copied out of `ci.yml`. That order is B1-3's rule and it is not pedantry: the B0 script records that three of the pinned names exist in no workflow file at all, because CodeQL runs from GitHub default setup. Two count claims move with it. `docs/contributing.md` said "All ten required checks" and the HP-0 scorecard's table said **10**, both stale since B1-3 added `Repository Hygiene` and now doubly so. B1-5 spotted the first and deferred it to "the branch-protection item's to fix"; this is that item, and it is also the commit that changes the number, so leaving them stale here would make this commit the proximate cause of a documented inconsistency. The scorecard is in `check-doc-counts.mjs`'s watched set — the repository classifies it as active, not as a frozen snapshot — so the don't-edit-dated-docs rule does not shield it. Its pinned block gains both names and a line recording when each was added. NOT APPLIED YET. Running this script is `gh api -X PUT repos/J3vb/OwnCord/branches/dev/protection`, a repository-settings write this session cannot perform. Run `bash docs/plans/b0-dev-branch-protection.sh` after this PR merges. The pre-flight is clear, stated positively rather than assumed: a required check that never reports blocks every PR forever, which is the hazard B1-3's own NOT-APPLIED note was about. It does not apply here. `Docs & Ledger Consistency` has existed in `dev`'s `ci.yml` since #1412, so no in-flight branch predates the job, and it reported `success` on this PR in 11 seconds. Verified: `bash -n` and `shellcheck` are clean. Extracting the heredoc and parsing it with `node` reports **12** contexts including `Docs & Ledger Consistency`, spelled exactly as the live check reports it — the JSON is machine-checked rather than eyeballed, because a typo here is a branch that cannot merge. `node scripts/check-doc-counts.mjs` still agrees on 21 claims across 8 watched documents, the scorecard among them, and `npm run check:hygiene` passes with prettier, shellcheck and actionlint all running. Not included: the script is not run — that is the owner's step, above. No other context is added or removed; the four remaining "deliberately NOT pinned" entries keep their recorded reasons, including `Admin Panel E2E`, whose `continue-on-error: true` still makes requiring it theatre until `R-01` graduates it. Refs RL-07, L-07, RL-14, G-03 --------- Co-authored-by: Claude <noreply@anthropic.com> * B1-7: community intake and automation authorization (RL-21 / L-15, RL-22 / L-16, RL-16 / R-09) (#1419) * ci(claude): constrain automation triggers and bound run cost (L-16) The Claude Code workflow consumes a metered credential, and the repository stated nothing about who may spend it or for how long. Whatever downstream behaviour happens to hold, an invariant this repository depends on should be asserted and tested here, not inherited from a pinned dependency that a routine version bump can re-derive. Three controls, in the one workflow that spends: - **Authorization.** The job condition now requires the actor to be on an explicit maintainer allowlist as well as the trigger text to mention the bot. An allowlist rather than an association check: this repository has exactly one collaborator, the term is unambiguous to read and to review, and it matches the actor-term pattern `ci.yml` already uses to exclude Dependabot. Adding a login is a one-line edit, which is the honest cost. - **Duration.** `timeout-minutes: 30`, in the band every other long-running job here uses. Without it the job inherits GitHub's 360-minute default — the wrong ceiling for metered work, and the only job in the repository that lacked one. - **Fan-out.** A `concurrency` group keyed on the issue or pull request number with `cancel-in-progress: true`, so repeated triggers on one thread collapse into a single run instead of running in parallel. Exactly one of `github.event.issue.number` and `github.event.pull_request.number` is present per triggering event, so the key is stable across all four. The `permissions:` block and the checkout are deliberately untouched. The permissions are already minimal and the checkout takes no `ref:`, so it reads the base branch rather than proposed code — both correct, and rewriting either would be churn. `scripts/check-workflow-guards.mjs` keeps all three from silently regressing. Modelled on `scripts/check-doc-counts.mjs`: same `--selftest`-then-assert shape, same dependency-free approach. It is text-level rather than YAML-parsed on purpose — the root has no YAML parser, and adding a dependency to assert that a file contains a `timeout-minutes` key would be a poor trade. That limit is stated in the file: these are presence-and-shape checks, not semantics. It runs from `CHECK_HYGIENE` in `scripts/run.mjs`, so it is reachable as `npm run check:hygiene` locally and executes inside `Repository Hygiene`, which is already a pinned required check on `dev`. No new CI job and no new pin — the guard is blocking from the moment it lands. `actionlint` cannot do this job. It validates expression syntax, action inputs and runner labels; a job condition is valid input to it whatever the condition admits, and it has no notion of cost at all. The two tools are complementary and both now run. Also corrects the record: `docs/plans/b1-repository-foundation-2026-08-25.md` claimed impact was bounded by read-only content permissions. The workflow's own token block is least-privilege, but that is not the only identity a run can hold, so the claim was narrower than the truth and is now stated accurately. And `docs/security.md` gains the private-coordination section that two planning documents already cite it for. The citation pointed at a policy that was not written down; it now says what stays private, that the rule covers the repository's own automation and settings rather than only product code, and that a commit message on a public repository is a disclosure channel. Verified: both directions, per guard. `node scripts/check-workflow-guards.mjs` exits 0 on the current tree and reports four guards present. Deleting the `timeout-minutes` line makes it exit 1 naming that guard and the invariant to restore; replacing the actor term with `true` makes it exit 1 naming that one; restoring each returns exit 0. `--selftest` passes eight assertions covering every guard's absence, a commented-out guard (which must not count), and the two shapes that must not trip it — any positive timeout value, and any concurrency key. `npm run check:hygiene` passes with prettier, shellcheck, actionlint and both new steps running for real; actionlint accepts the edited workflow. Not included: the workflow's `permissions:` block and checkout step, per above. No change to the action version or its inputs. `ci.yml`, `release.yml` and `load-baseline.yml` are outside this item — none is reachable the same way, and each already carries per-job least-privilege permissions, and where relevant a timeout and a concurrency group. `METERED` in the new script lists one workflow because one workflow spends; a second entry is a one-line change when that changes. Refs RL-22, L-16 * feat(intake): structured bug form, and route ideas to Discussions (RL-21) Both issue templates were Markdown with front matter, so nothing they collected was structured, required, or validated. A reporter could submit the form untouched. The Environment block was three bullets with `Windows 11` prefilled as the OS — the single most common answer, pre-filled, on a project that ships Windows and Linux builds and an ARM64 client. And `feature_request.md` existed at all, which is the direct violation: BPR-100 says Issues is the bug tracker and Discussions hosts support, ideas and community feedback. A feature-request template routes ideas into Issues by construction. Done: - `bug_report.md` → `bug_report.yml`, a real issue form. Six fields are `validations: required` — what happened, steps to reproduce, component, OS, architecture, deployment mode — because those six are what turns a report into something reproducible. The rest are optional on purpose; a form that demands everything gets abandoned. - `feature_request.md` deleted. Nothing in the tree referenced either template by filename, so this breaks no link, script, or workflow. - `config.yml` gains three routed destinations and keeps `blank_issues_enabled: false` — which is what makes the routing hold, since a blank issue bypasses every form and every warning on one. The new environment fields are drawn from what this project actually ships, not from a generic template: - **Architecture** x64 / ARM64, with the note that ARM64 is the Linux desktop client today and there is no ARM64 server release. - **Deployment mode** covering the six paths `docs/deployment.md` documents — prebuilt binary on either OS, from source, Docker/Compose, systemd, Windows service. - **TLS mode** matching `tls.mode`'s four values exactly, `off` quoted so YAML does not read it as boolean false. - **Network topology** — direct, port forward, reverse proxy, Tailscale — because voice bugs in particular bifurcate hard on this, and the reverse-proxy path cannot carry the WebRTC UDP range at all. - **Separate client and server versions.** They are obtained differently and can legitimately differ. The server field says where to look — admin panel or the startup banner — and explicitly tolerates "unknown", because the version is deliberately absent from the unauthenticated `/health` endpoint as anti-fingerprinting hardening, so a non-admin reporter genuinely cannot get it. - **Client webview**, WebView2 or WebKitGTK. No "PWA" option: no PWA exists, B1 excludes browser and PWA work, and BPR-092 forbids presenting unavailable behaviour as functional. The field is diagnostic today regardless — the desktop client renders through the OS webview, and that already drives real bug classes. Every public template now carries the disclosure warning BPR-101 asks for, and the security contact link is first in the chooser, above the Discussions links. Four files, 189 insertions, 58 deletions. Verified: both files parse as YAML, and the form was checked against the issue form schema rather than only for parseability — 13 body elements, 12 unique ids with no collisions, every non-markdown element carrying an id and a label, every dropdown carrying options, and the markdown block carrying neither an id nor validations (both of which GitHub rejects). `config.yml` has `blank_issues_enabled: false` and four contact links each with exactly name/url/about. `npm run check:hygiene` passes. The gap that verification leaves, stated plainly: nothing in this repository validates issue-form schema. Prettier confirms the YAML parses and actionlint does not read `.github/ISSUE_TEMPLATE/` at all, so a file that is valid YAML but an invalid form disappears from the "New issue" chooser silently. The checks above are a local stand-in, not the real gate. The live chooser needs a look after merge — which BPR-100's closure evidence ("dry-run submissions reach the intended destination") requires in any case. Not included: the Discussions `?category=` slugs are written as `q-a` and `ideas`, GitHub's defaults. If this repository's categories were renamed, a wrong slug drops the user on the category picker rather than erroring — confirm against the live Discussions tab before relying on them. No PR-template or documentation changes here; those are the next commit. L-15 is not closed by this commit alone: BPR-100 names six surfaces and three of them are docs. Refs RL-21, L-15 * docs(intake): route contributors, and state the security path (RL-21) The previous commit fixed the forms. This is the half BPR-100 and BPR-102 actually ask for and the B1 plan's bullet does not mention: their closure evidence names repository navigation, support links and contribution docs alongside the issue forms, so a `.github/`-only change cannot satisfy either. Three gaps, each verified rather than assumed: **Discussions was invisible.** The only link to it anywhere in the tree was inside `.github/ISSUE_TEMPLATE/config.yml` — the new-issue chooser. So "route ideas and feedback to Discussions" worked for exactly one audience: people who had already decided to file an issue. `README.md` and `docs/README.md` now each carry the routing, so it is reachable from the two pages a newcomer actually lands on. **`docs/contributing.md` never mentioned security reporting.** Five files carry the "never a public issue" rule — the root `README.md`, `CONTRIBUTING.md`, `SECURITY.md`, `docs/security.md`, `CLAUDE.md` — and every one of them delegates the full process to `docs/contributing.md`, which is also the document BPR-102's evidence row sends a fresh contributor to. It said nothing about it. It now has a routing table and a security section that says the thing that actually matters on a public repository: the PR description, the commits and the branch name are disclosure channels, so a fix for a vulnerability describes the control it adds and nothing else. **The README contradicted the issue chooser.** The banner said "there's no support" while the chooser offered a link named "Community Support". Both were defensible in isolation and together they told a user two different things before they had read anything else. The banner now says the honest version — no support *commitment* — and a "Getting Help and Reporting Problems" table names the right destination for each kind of message without promising a response. Also in the PR template, which the audit's remedy names as "PR guidance": - The Test Plan asked for `npm test` / `go test ./...` / `npx tsc --noEmit`. Those predate B1-4's root facade; `npm run check` is the entry point CI gates on and the one `CONTRIBUTING.md` and `README.md` now tell people to run. - A generated-files checkbox naming all five, since CI fails on drift and a hand-edited generated file is the failure that wastes a cycle. - A `Not included:` prompt, because `docs/contributing.md` makes a written deferral a required commit element and the template asked for it nowhere. - The disclosure warning BPR-101 wants on public templates. Two stale claims fixed while in these files: `docs/contributing.md` said "ten status checks are required" three lines from a section that says twelve, and `docs/plans/README.md` still read "B1-0 done, B1-1 next" six phases later — in the index that declares itself the authority over plan headers. Five files, 70 insertions, 12 deletions. Verified: `git grep "ten status checks"` returns nothing. `node scripts/check-doc-counts.mjs` still agrees on 21 claims across 8 watched documents — `docs/plans/README.md` and `README.md` are both watched, so a count claim broken by these edits would have failed here. `npm run check:hygiene` passes with prettier, shellcheck, actionlint and the workflow-guard check all running. One nearby claim checked and deliberately left: `docs/contributing.md` also says "four of the ten" a hundred lines later. That is four of ten *CI steps keying on a cache-dependency-path*, not required checks — correct in context, and changing it would have been a wrong fix to a right-looking grep hit. Not included: L-15 is **not** closed. BPR-100's closure evidence requires dry-run submissions that reach the intended destination, and BPR-102's requires a fresh Windows and Linux contributor to follow these docs and land a passing sample change. Neither is a file edit. BPR-101 additionally wants a tabletop report proving private receipt, triage, advisory and coordinated disclosure — no such artifact exists in the tree, and this commit does not create one. `CODE_OF_CONDUCT.md` and `GOVERNANCE.md` do not exist in this repository; adding them is community-health scope, not RL-21's, and neither is named by the audit row or the register row. Refs RL-21, L-15 * ci(release): require exact-SHA gate evidence before publishing (RL-16) A tag push starts `release.yml` and nothing else — `ci.yml` has no `tags:` trigger. And `release.yml` re-runs none of the required checks: it verifies the version, builds, boot-smokes and signs, which is a different question from "did the gate pass on this commit". So a tag could publish from a commit whose CI was red, and nothing would notice. It already has. `v1.2.0-alpha.3` published from `fb04a579`, whose CI run concluded **failure** — `Server Build & Test (windows-latest)`, the race and coverage step. The Release run on the same commit went green and shipped. That is R-09 demonstrated rather than hypothesised, and it is the fixture this commit is verified against. The obvious fix — re-run the test suite inside `release.yml` — is the wrong one. It would double the tag-time cost, still not cover the checks that run in other workflows (CodeQL's three `Analyze` jobs exist in no workflow file at all), and answer a weaker question: "does it pass now" rather than "did the gate pass on this commit". The evidence already exists; nothing was reading it. Done: - `scripts/verify-gate-evidence.mjs` resolves the tagged SHA's check runs and asserts every required context is present and `success`. `skipped` and `neutral` are not success — a required check that skipped on the tagged commit proves nothing about it — and a still-`in_progress` check is called out as unfinished rather than treated as absent. Where a context reported more than once, the latest attempt decides, in both directions. - The required set is **parsed out of `b0-dev-branch-protection.sh`**, not restated. Pinning a thirteenth check cannot leave this gate behind, and a change to that file's shape fails the self-test rather than silently weakening the gate. - A `gate-evidence` job in `release.yml` that `verify-versions` needs. Every build job already needs `verify-versions` and both publishers need those, so one edge gates the whole graph — including the GHCR push, which today can mutate `:latest` before `publish` has run at all. - `permissions: checks: read` and nothing else. It is a script rather than a `run:` block because of the rule in the `ci-check` skill: a step that exists only in `release.yml` first executes at tag time, so its own bugs surface on the release. `Server/scripts/docker-smoke.sh` is the worked example — one script, two call sites. Here the second call site is `--selftest`, run by `ci.yml`'s docs-consistency job on every pull request. `docs/plans/b1-release-tag-protection.sh` covers the half a workflow file cannot express: a ruleset on `refs/tags/v*` blocking update and deletion, and a `release` environment with a required reviewer. **NOT APPLIED** — both are repository-settings writes this session cannot make. Run `bash docs/plans/b1-release-tag-protection.sh` when you want them. Deliberately **no `environment: release` key** in `release.yml` yet. The key is PR-landable, but naming an environment that does not exist stalls the next release; the script says to add it after creating the environment, and says why. Verified: both directions, on real data rather than only fixtures. Feeding the actual check runs from `fb04a579` — the commit alpha.3 shipped from — through `evaluate` returns **NOT RELEASABLE**, naming `Server Build & Test (windows-latest): failure` first. Feeding PR #1418's real check runs on `8875238` returns **RELEASABLE**, and correctly ignores the red `github-advanced-security` result because it is not a pinned context — the gate tracks the required set, not "everything is green". `--selftest` passes 12 assertions covering a missing check, a failure, an unfinished run, `skipped`, `neutral`, both re-run orderings, an unrequired extra, and a commit with no checks at all. `bash -n` and `shellcheck` are clean on the new script and both its heredocs parse as JSON. `npm run check:hygiene` passes with actionlint over both edited workflows. The module gained a direct-invocation guard so it can be imported and tested without reaching the network — compared against `argv[1]` rather than `import.meta.main`, which needs Node 24.2 against an engines floor of `>=24` and would silently no-op on 24.0. Not included: the network path itself is exercised only at tag time. The self-test covers the decision logic and the required-set parsing, which is where the bugs live; a live API call needs a token this environment does not have. R-09's "protected release approval" limb stays open until the settings script is run — the register phases R-09 **B1/B10**, so that half is B10's. `release.yml`'s version stamping, both signing keys, the fail-closed minisign verify, `checksums.sha256`'s bare filenames, both cold-boot smokes and the `git archive` source snapshot are untouched; the remedy says to retain them and this commit only adds an edge in front of them. Refs RL-16, R-09 * docs(plans): record B1 progress through B1-7 B1-6 (#1418) merged and B1-7 is this branch, so the header and the plan index both move on. B1-8 — the platform contract map — is next, and it is documentation only: it records the browser-neutral contract folders and their owners, and moves no native behaviour. Adapter extraction stays B7. Verified: `node scripts/check-doc-counts.mjs` still agrees on 21 claims across 8 watched documents, both edited files among them; prettier clean. Refs R-08 --------- Co-authored-by: Claude <noreply@anthropic.com> * B1-8: platform contract map, HP-1 structural review, and the B1 exit gate (RL-02 / L-02) (#1420) * docs: record the desktop/browser platform contract map (B1-8, RL-02/L-02) Client/src/platform/ does not exist — no commits, no files, zero importers. RL-02 asked for the boundary to be *recorded* in B1 so that B7 executes a decided plan rather than rediscovering the surface. This is that record, and nothing more: no directory, no interface, no code. Measured against dev @eb873fe7, not estimated: 20 files under Client/src/ import @tauri-apps, using 26 distinct invoke command names against 30 #[tauri::command] handlers, with zero dangling calls and zero uses of the window.__TAURI__ global. Every native dependency is an import, so a static check can find all of them — which is what BPR-025 will eventually enforce. The count is 26 and not 22 because Client/src/lib/ws.ts binds core.invoke to a local tauriInvoke before calling it; a regex matching only invoke("…") misses ws_connect, ws_send, ws_disconnect and accept_cert_fingerprint. Any future lint rule enforcing the seam has to match the binding, not the call site. The 20 files collapse into 13 capability clusters, three of which have no browser equivalent and are flagged as product decisions rather than shims: certificate TOFU in ws.ts, the OS keychain behind credentials.ts/identity.ts, and out-of-focus push-to-talk in ptt.ts. Ownership is recorded by phase (B7/B8/B2). No human owners exist for these folders anywhere in the repository; the document says so rather than leaving the absence to read as an oversight. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * docs: perform the HP-1 structural review and measure the B1 exit gate HP-1 asks whether B1's migrations were mechanical. It had never been run, and it cannot be run against dev: dev is squash-merge only, so #1411 landed as one commit and the pure-move/path-rewrite separation the hold point exists to review survives only on refs/pull/1411/head. The scorecard records the pre-squash SHAs so the review is reproducible. Four proofs, all passing: - Pure move (4befe699): 473 renames, all R100, zero non-rename entries, zero line changes, and every renamed blob byte-identical. The blob-OID comparison is what actually covers the six binaries — --numstat prints "-" for them, so the obvious line-count filter reports false positives. - Path rewrite (38ddca73): 983 added / 983 removed, and after normalising the substitution, six unpaired pairs remain — all relative-path depth arithmetic from losing one directory level. Each was resolved against HEAD. The release signer is among them and runs only on a tag, so no CI run on any branch executes it; it is correct (working-directory: Client, artifacts at the root) and guarded by a downstream verify step that fails closed. - Go module rename (7a4e5dc3): 350 files, 728/728, zero unpaired lines. The largest change in B1 is provably a pure substitution. - Active path inventory: 11 files still name tauri-client, all historical — ledger lens labels, dated audits, and plans that describe the move. Zero in code, workflows, scripts, hooks or the Dockerfile. The seed move (93ee14d5) does change behaviour — init() deleted, os.MkdirAll moved into main(). That was authorised by the plan and is isolated in its own commit, which is what HP-1 asks for. Exit gate: seven of eight conditions evidenced. Condition 6 is recorded as PARTIALLY MET and is a real gap — dev has 11 required checks pinned but strict:false, so when dev advances after a PR goes green that PR can still merge without re-testing, and the squash commit that lands was never itself tested. Deliberately not changed here: flipping strict forces a rebase on every open PR whenever another lands, and enforce_admins is on. Owner's call. ENV-01 is closed. Every B0 number was measured on Node 26 while CI pins 24. The client suite now re-runs on Node 24 from a fresh clone in a node:24 container: 192 files, 5257 tests — identical to B0, and the clone doubles as the exit gate's Linux setup smoke. ENV-02 also reproduces at 50.1 MB booting on :8443. Corrects the plan's stale Docker command along the way: the script moved to Server/scripts/ and now takes the image as an argument, and the build context is Server/ rather than the repository root — building from the root streams the whole working tree and then fails on the missing go.mod. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * docs: record the applied repository settings in the HP-1 scorecard Both checked-in settings scripts were run on 2026-08-27 — they had landed in #1418 and #1419 but were deliberately never executed, because repo-settings writes need a person. b0-dev-branch-protection.sh pinned the twelfth required check on dev, "Docs & Ledger Consistency". Until that run the FINDINGS.md drift gate reported but could not block a merge. Condition 6 now reads 12 pinned checks; it stays PARTIALLY MET because strict is still false, which the script itself encodes as a deliberate choice. b1-release-tag-protection.sh created the "Release tags" ruleset (active, target tag, refs/tags/v*, blocks update and deletion, zero bypass actors) and the release environment with one required reviewer. Checked for a pre-existing ruleset of that name first — the POST half is not idempotent and a second run would have created a duplicate. Three rulesets existed, all targeting branches, none named "Release tags". Condition 7 closes: B1-7 merged, and the Discussions slugs its issue-template config hardcodes — q-a and ideas — both exist, so the contact links resolve rather than silently dropping the user on the category picker. Two things the read-back surfaced, both recorded as open, neither blocking: - The release environment has can_admins_bypass: true, GitHub's default. The ruleset has zero bypass actors, but the reviewer gate does not. Moot while the sole admin is also the sole reviewer. - claude.yml passes secrets.CLAUDE_CODE_OAUTH_TOKEN and the repository has no such secret. Nothing is failing, because all five issue_comment runs are skipped at the B1-7 guard before the missing secret would matter — but the paid-automation surface RL-22 hardens is inert today. environment: release is still absent from release.yml, deliberately. The environment now exists, so that is a separate two-line change. Gate re-run after rebasing ontoc0c87366so condition 8 is measured over the final tree, B1-7 included: green, 5257 client tests, exit 0. B1-7's check-workflow-guards.mjs runs locally; its sibling verify-gate-evidence.mjs does not — CI runs the selftest, and the assert form needs a token and a real SHA. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> * docs: accept HP-1 — B1 is complete (#1421) HP-1 accepted 2026-08-27 by J3vb (repository owner). Recorded the same way HP-0 was: a decision line on the scorecard, and a dated acceptance section appended to the baseline document. Condition 6 is accepted as a STATED LIMITATION, not as met. dev carries strict:false, so a PR whose checks went green before dev advanced can still merge without re-testing, and the squash commit that lands was never itself tested as it stands. Closing it forces a rebase on every open PR whenever another lands, and enforce_admins:true leaves no exemption. Taken knowingly; not a B2 blocker. Recording it as accepted-with-limitation rather than met is the point — a scorecard that rounds a partial up to a pass is worth nothing. Also corrects a stale claim the plan index itself is supposed to police: it still read "No phase complete" for the roadmap, which stopped being true when HP-0 was accepted on 2026-08-25. That is the G-04 drift class this index exists to close, so it should not be the document carrying it. B2's entry gate condition "B1 is complete and protocol source has one owner" is now met. Its other two conditions remain B2 entry work, not B1 debt. Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> * docs: make the changelog a scannable list, and write down the rule (#1422) The changelog had drifted into walls of text — v1.2.0-alpha.3's entry is a handful of paragraphs where a single bullet runs eleven lines and names the function that owned the bug. An operator cannot tell in ten seconds whether any of it bit them, which is the only job this file has. Adds a "How to write an entry" section to CHANGELOG.md as the rule: lead with what is user-visible and what is not, group by an area a user recognises rather than by subsystem or PR, one line per fix, say what was broken then what it does now, plain language over symbol names, no OC-* ids or file paths, counts in a summary line rather than on every bullet. Repository work that changes nothing observable gets at most a short block at the end. Shipped entries are left alone as history; the rule starts from the next release. Rewrites Unreleased to follow it, which also closes a real gap: that section documented B0/B1 repository plumbing and omitted all 62 operator-visible bug fixes from #1400 and #1402. Exactly backwards — the invisible half was written up and the half users would notice was not. A release cut from dev today would have shipped a changelog that mentioned a directory rename and not "banned users could still connect". docs/contributing.md's PR process now points at the rule, since that is where a contributor decides whether their change needs an entry. Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> * release: v1.2.0-alpha.4 (#1423) Bumps the client version across every pin verify-versions enforces (package.json, tauri.conf.json, Cargo.toml) plus the two lockfiles that carry it, and the user-facing build examples in README.md, docs/deployment.md, docs/quick-start.md, docs/api.md and the issue-form placeholders. Deliberately NOT bumped: the v1.2.0-alpha.3 references in ci.yml, release.yml and docker-smoke.sh, which record the release that published from a red commit and are the reason the gate-evidence job exists; and the string in scripts/check-doc-counts.mjs, which is a selftest fixture asserting a version number is not read as a ledger claim. Rewriting either would falsify a record. CHANGELOG's Unreleased section becomes v1.2.0-alpha.4. Verified rather than assumed: - npm ci exits 0, so package-lock.json still matches package.json. - cargo metadata --locked exits 0, so Cargo.lock needs no regeneration. - The verify-versions comparison was run locally against tag v1.2.0-alpha.4: all three sources agree, so the tag will not be rejected. - npm run check passes end to end, exit 0. Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> * chore: merge main into dev to unblock the alpha.4 release PR (#1425) * ci(deps): bump anthropics/claude-code-action (#1404) Bumps the actions-dependencies group with 1 update: [anthropics/claude-code-action](https://github.com/anthropics/claude-code-action). Updates `anthropics/claude-code-action` from 1.0.193 to 1.0.199 - [Release notes](https://github.com/anthropics/claude-code-action/releases) - [Commits](https://github.com/anthropics/claude-code-action/compare/9d7150bc8a3dae8149739a88019d192b579ad90c...dcb57747bfceeaa1fa72638cae52295d1d853d4a) --- updated-dependencies: - dependency-name: anthropics/claude-code-action dependency-version: 1.0.199 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: actions-dependencies ... Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * build(deps): pin rfd to tauri-plugin-dialog's major to unblock the cargo group (#1406) The cargo-dependencies group PR (#1405) fails Rust Unit Tests on Linux: error: failed to run custom build command for `rfd v0.17.2` You need to choose at least one backend: `gtk3` or `xdg-portal` features for x86_64-linux rfd is not really ours. It arrives in the tree via tauri-plugin-dialog, which pins ^0.16; we declare it directly only for the fatal-startup message box in lib.rs, where the Tauri app never finished building and the plugin has no AppHandle to run a dialog through. Cargo unifies features only within a semver-compatible version group, so while both wanted ^0.16 there was a single rfd in the graph and the plugin's backend features covered our `default-features = false` declaration too. Bumping our direct dep to 0.17 forks rfd into two crates: the plugin keeps 0.16.0 with its features, ours resolves to 0.17.2 with none, and rfd 0.17 added a build.rs assertion that aborts the Linux build when no backend feature is set. Confirmed in the PR's lockfile, which carries both 0.16.0 and 0.17.2. Adding a Linux backend feature would be the wrong fix: it would paper over the fork and still build rfd twice on every platform for one error dialog. Our version has to track the plugin's instead, so ignore semver-minor rfd updates (0.16 -> 0.17 for a 0.x crate) until tauri-plugin-dialog moves. Patch updates inside 0.16.x still flow. The remaining five crates in the group are unaffected; `windows` in fact consolidates 3 versions down to 2. Cargo.toml is comment-only here - no dependency, feature, or lockfile change - so the build is untouched. Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> * chore(deps): bump log (#1407) Bumps the cargo-dependencies group with 1 update in the /Client/tauri-client/src-tauri directory: [log](https://github.com/rust-lang/log). Updates `log` from 0.4.33 to 0.4.34 - [Release notes](https://github.com/rust-lang/log/releases) - [Changelog](https://github.com/rust-lang/log/blob/master/CHANGELOG.md) - [Commits](https://github.com/rust-lang/log/compare/0.4.33...0.4.34) --- updated-dependencies: - dependency-name: log dependency-version: 0.4.34 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: cargo-dependencies ... Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * ci(deps): bump anthropics/claude-code-action (#1408) Bumps the actions-dependencies group with 1 update: [anthropics/claude-code-action](https://github.com/anthropics/claude-code-action). Updates `anthropics/claude-code-action` from 1.0.199 to 1.0.200 - [Release notes](https://github.com/anthropics/claude-code-action/releases) - [Commits](https://github.com/anthropics/claude-code-action/compare/dcb57747bfceeaa1fa72638cae52295d1d853d4a...24dcd50c0568f0fc9e9211213a4fd2d9eb15c4e0) --- updated-dependencies: - dependency-name: anthropics/claude-code-action dependency-version: 1.0.200 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: actions-dependencies ... Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * fix(client): strip tags to a fixpoint inside sanitizePassApprox CodeQL alert 17 (js/incomplete-multi-character-sanitization, high) fires on the single-pass `input.replace(/<[^>]*>/g, "")`: a lone replace can in principle splice a fresh `<...>` out of the text either side of what it removed. echoNormalize already loops sanitizePassApprox to a fixpoint, so that was absorbed one level up and the output is unchanged -- but the repetition is now where a reader (and the query) can see it. sanitizePassApprox is a comparison normalizer, never rendered output: its only consumer is the `===` echo match in isUnreconciledEcho. Not a sanitization boundary, so this is a legibility fix, not a security one. Client suite 5257/5257, tsc, lint, hygiene all green. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(client): put the strip-tags replace inside the loop body The previous form hoisted the `replace` into the `for` header's init expression, so CodeQL still reported it (alert 18, line 217 col 21) -- js/incomplete-multi-character-sanitization only credits a repeated replacement when the call sits in the loop *body*, which is also the shape the rule's own guidance shows. Same fixpoint, same output; `while (out.includes("<"))` gives the loop a real condition instead of `for (;;)`. Client suite 5257/5257, tsc, lint, prettier green. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: Claude <noreply@anthropic.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
@@ -1,8 +1 @@
|
||||
{
|
||||
"hooks": {
|
||||
"PreToolUse": [
|
||||
{ "matcher": "Bash|Grep", "hooks": [{ "type": "command", "command": "graphify hook-guard search || exit 0" }] },
|
||||
{ "matcher": "Read|Glob", "hooks": [{ "type": "command", "command": "graphify hook-guard read || exit 0" }] }
|
||||
]
|
||||
}
|
||||
}
|
||||
{}
|
||||
|
||||
@@ -24,9 +24,7 @@ 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. **Build the inventory**: `node .superpowers/rank-explore.mjs` — writes
|
||||
1. **Build the inventory**: `node .superpowers/rank-explore.mjs` — writes
|
||||
`.superpowers/explore-ranking.json`: EVERY non-test source file (~419 rows), each with
|
||||
`examined` (already carries a ledger finding or a LIVE explored-clean record → the hunt
|
||||
pre-seeds its covered set), `risky` (top coupling ∪ past-bug clusters ∪ top churn,
|
||||
@@ -39,7 +37,7 @@ Before launching, in order:
|
||||
must be `examined` in the inventory — a `known` file the inventory does not mark
|
||||
examined can never be drawn (the seen-filter blocks it) nor covered, which would
|
||||
strand `uncoveredCount()` above zero and block convergence.
|
||||
3. Read the ledger and pass every record in as `known`, so the hunt does not
|
||||
2. Read the ledger and pass every record in as `known`, so the hunt does not
|
||||
re-derive anything already found, fixed, declined, or refuted.
|
||||
|
||||
```
|
||||
@@ -123,9 +121,16 @@ 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.
|
||||
Generate the readable rendering, then read it — it is gitignored, so a fresh
|
||||
clone has no copy until you make one:
|
||||
|
||||
```bash
|
||||
node .superpowers/render-ledger.mjs # writes .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. Edit
|
||||
`findings-ledger.json` to do that, not the rendering.
|
||||
|
||||
## 3. Fix
|
||||
|
||||
@@ -187,6 +192,15 @@ points: the fix stage (before any prove agent runs) and inside the prove loop.
|
||||
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.
|
||||
|
||||
**Re-verify a blocked finding against HEAD before fixing it.** A deferred item
|
||||
ages against a moving codebase: later hunts routinely fix a blocked finding as a
|
||||
side effect of an overlapping sibling, and a saved debris patch stops applying
|
||||
once a refactor rewrites the files it touched. Check the _mechanism_ still exists
|
||||
at HEAD, not just the line coordinates. If it is already covered, mark it fixed
|
||||
with a pointer to the covering commit instead of re-fixing it. Of 6 findings
|
||||
blocked on 2026-08-14, 2 were already fixed 5 days later and the debris patch no
|
||||
longer applied at all.
|
||||
|
||||
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
|
||||
@@ -229,7 +243,7 @@ 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
|
||||
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:
|
||||
@@ -314,10 +328,12 @@ 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
|
||||
origin/dev...HEAD` (three-dot), never two-dot: a concurrent merge plus a
|
||||
background fetch can move the base mid-run and turn the two-dot diff into
|
||||
phantom deletions. `dev` is the integration branch every PR targets
|
||||
(docs/contributing.md#branch-and-pr-model); use `origin/main` only for a
|
||||
release PR cut from `dev`. If origin moved, confirm zero file overlap and a
|
||||
clean `git merge-tree --write-tree origin/dev HEAD` before opening the PR by
|
||||
hand. The workflow never
|
||||
pushes and never opens a PR.
|
||||
|
||||
|
||||
@@ -9,6 +9,19 @@ description: Run the local mirror of OwnCord's CI gates before pushing. Use when
|
||||
|
||||
Run only the sections your change touches. Server and client are independent.
|
||||
|
||||
**A step added only to `release.yml` first runs at tag time.** `release.yml` is
|
||||
tag-triggered and never gated by a PR, so a smoke/sign/strip step added there is
|
||||
untested code on the critical path — its own bugs surface on the release, not on
|
||||
a PR. Extract it to a script `ci.yml` also runs (`Server/scripts/docker-smoke.sh`
|
||||
is the worked example) or duplicate it into `ci.yml` before merge.
|
||||
|
||||
From the repository root, `npm run check` runs all of it, and
|
||||
`check:server` / `check:client` / `check:rust` / `check:hygiene` run one stack.
|
||||
`node scripts/run.mjs --list` prints the exact command each step runs and the
|
||||
directory it runs in — the per-stack commands below are those commands, and
|
||||
staying with them is fine. Nothing here needs `make`, and server work needs no
|
||||
Node.
|
||||
|
||||
## Server (from `Server/`)
|
||||
|
||||
All four build-tag variants must compile — the tags gate whole files, so a
|
||||
@@ -20,7 +33,11 @@ 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
|
||||
|
||||
# Generated output must not be stale. These are what `make sqlc-verify` and
|
||||
# `make protocol-verify` reduce to — make is not on PATH on a stock Windows box.
|
||||
sqlc generate && git diff --exit-code db/dbgen
|
||||
go run ./cmd/genprotocol && git diff --exit-code ws/message_types.go ../Client/src/lib/protocolTypes.ts
|
||||
```
|
||||
|
||||
Add `-tags wazero` to `go vet`/`go test` when you touched `plugin/`.
|
||||
@@ -34,32 +51,138 @@ 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/`)
|
||||
## Client (from `Client/`)
|
||||
|
||||
```bash
|
||||
NODE_OPTIONS=--no-experimental-webstorage npm test
|
||||
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.
|
||||
Formatting is no longer a client gate — Prettier is configured once at the
|
||||
repository root and checked by `check:hygiene` below.
|
||||
|
||||
`NODE_OPTIONS=--no-experimental-webstorage` used to be required here. It is not
|
||||
any more: `tests/setup.ts` installs an in-memory `localStorage` shim, CI runs
|
||||
Node 24 without the flag (`ci.yml`), and the full suite was measured passing
|
||||
without it — 192 files / 5257 tests, identical to the flagged run.
|
||||
|
||||
`npm audit --audit-level=high` and `knip` also run in CI but are advisory.
|
||||
|
||||
## Rust (from `Client/tauri-client/src-tauri/`)
|
||||
## Docs and ledger (from the repository root)
|
||||
|
||||
```bash
|
||||
cargo test
|
||||
cargo clippy --all-targets -- -D warnings
|
||||
npm run check:docs
|
||||
```
|
||||
|
||||
Which is `scripts/check-doc-counts.mjs` plus, since B1-6, an actual render of
|
||||
the findings ledger:
|
||||
|
||||
```bash
|
||||
node .superpowers/render-ledger.mjs
|
||||
```
|
||||
|
||||
`.superpowers/FINDINGS.md` is **not tracked** — it is generated on demand and
|
||||
gitignored, so there is no committed rendering to go stale. The gate is that
|
||||
generation succeeds. Rendering subsumes `--check`: the renderer validates and
|
||||
exits 1 before it writes, so a schema break (including an unranked `severity`)
|
||||
fails here.
|
||||
|
||||
CI does one thing more, in `Docs & Ledger Consistency` — it renders **twice**
|
||||
and compares, proving the output is a pure function of the ledger, then uploads
|
||||
the rendering as the `findings-ledger-rendering` artifact so a reviewer can read
|
||||
it without running Node.
|
||||
|
||||
## Hygiene (from the repository root)
|
||||
|
||||
```bash
|
||||
npm run check:hygiene
|
||||
```
|
||||
|
||||
Which is:
|
||||
|
||||
```bash
|
||||
npx prettier --check . # every material tracked source, not just client TS
|
||||
shellcheck <tracked *.sh + .githooks/pre-commit + .githooks/pre-push>
|
||||
actionlint .github/workflows/*.yml
|
||||
```
|
||||
|
||||
`shellcheck` and `actionlint` have no clean Windows install, so `run.mjs` marks
|
||||
them optional and prints `--- SKIP` instead of failing; CI runs them for real.
|
||||
Prettier is not optional and runs everywhere.
|
||||
|
||||
The file lists come from `git ls-files`, never a filesystem glob:
|
||||
`.claude/worktrees/` holds gitignored copies of the tree that a glob would
|
||||
happily lint.
|
||||
|
||||
Go formatting is not here. `gofmt -l` prints offenders and still exits 0, so it
|
||||
cannot fail a build; the `formatters` block in `Server/.golangci.yml` enforces
|
||||
it inside `golangci-lint run`, and `.githooks/pre-commit` catches staged files.
|
||||
|
||||
## Rust (from `Client/src-tauri/`)
|
||||
|
||||
```bash
|
||||
cargo fmt --all -- --check # runs ahead of clippy in CI
|
||||
cargo test --lib # CI runs --lib; plain `cargo test` also builds the bin target
|
||||
cargo clippy --all-targets -- -D warnings
|
||||
cargo install cargo-audit@0.22.1 --quiet && cargo audit # CI runs this in tauri-build
|
||||
```
|
||||
|
||||
`cargo audit` is the one gate here that turns red with **zero** local changes —
|
||||
an advisory published upstream breaks a branch that was clean yesterday. Check the
|
||||
advisory date before hunting your diff. It is skipped on Dependabot PRs by design
|
||||
(it overlaps the scanning that opened them), so a clean Dependabot run does not
|
||||
mean the advisory set is clean. The client equivalents, `npm audit --omit=dev
|
||||
--audit-level=high` and `knip`, are advisory in CI.
|
||||
|
||||
`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.
|
||||
|
||||
## Reading a red check
|
||||
|
||||
**Causality before forensics.** Before opening a failing job's log, diff the
|
||||
PR's changed-file set against that job's input surface and ask whether the change
|
||||
could reach it. A diff touching only `.github/workflows/*.yml` cannot cause a Go
|
||||
goroutine leak — that failure is pre-existing or flaky by construction. Re-run
|
||||
first, and check `dev`/`main` is green to tell "flaky" from "already red". Only
|
||||
start log-reading once the change plausibly reaches the job.
|
||||
|
||||
**Compare against the baseline, never against zero.** For any gate a repo
|
||||
knowingly runs red, the unit of verification is the _delta_ from a recorded
|
||||
baseline, not pass/fail — absolute pass/fail only means something when the
|
||||
intended state is zero. Get the delta with `git stash && <gate> > /tmp/base &&
|
||||
git stash pop && <gate> | diff /tmp/base -`. This repo currently carries **no**
|
||||
known-red gate: `golangci-lint`'s complexity backlog was cleared to zero, so a
|
||||
red `golangci-lint` is now genuinely yours. If a budget is ever retuned upward,
|
||||
record the new baseline here next to the command or the gate reports nothing.
|
||||
|
||||
**A dependency bump that breaks the build may be a fork, not a version.** When an
|
||||
updated dependency suddenly demands configuration it never needed, suspect it was
|
||||
inheriting that configuration from a shared resolution with another dependent.
|
||||
Diff the lockfile _entry count_ for that dependency between base and PR: a 1 → 2
|
||||
transition means the update forked it into two semver-incompatible copies, feature
|
||||
unification stopped crossing the boundary, and the fix is to restore version
|
||||
alignment with whatever else requires it — not to set the feature the new copy
|
||||
asks for.
|
||||
|
||||
### Known infra flakes
|
||||
|
||||
Not your change. Match the signature, then recover.
|
||||
|
||||
| Signature | Verdict / recovery |
|
||||
| ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| `windows-latest` `-race` fault in `ws`: `runtime.scanstack`, `runtime.(*unwinder).next`, or `unexpected fault address 0xffffffffffffffff` / `fatal error: fault` inside ordinary stdlib frames | Go runtime GC fault, not your code — see the Server section. `gh run rerun --job <id>` |
|
||||
| `##[error]The operation was canceled.` + `Terminate orphan process: ... playwright install --with-deps` + a wall of `Ign:N http://azure.archive.ubuntu.com/...` and no Playwright summary line | Runner apt-mirror outage during "Install Linux system dependencies". The job was **canceled by timeout**, not failed. `gh run cancel` then `gh run rerun --failed` |
|
||||
| Red `Lint` step with zero linters actually run | `golangci-lint`'s network schema fetch failed. Re-run |
|
||||
|
||||
`gh run view --log` refuses while a run is in progress; `gh api
|
||||
repos/<owner>/<repo>/actions/jobs/<id>/logs` works. A job cannot be rerun while
|
||||
its parent run is still in progress. `tauri-build` has no `timeout-minutes`, so a
|
||||
hung apt step can hold a run open for the 6 h default — cancel it rather than wait.
|
||||
|
||||
## Hooks
|
||||
|
||||
`npm run hooks:install` (once per clone) points `core.hooksPath` at
|
||||
@@ -67,3 +190,11 @@ CI on PRs to `main` and pulls heavy system dependencies.
|
||||
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.
|
||||
|
||||
**`core.hooksPath` is exclusive, not additive.** Once set, Git resolves every
|
||||
hook against `.githooks/` and stops consulting `.git/hooks/` entirely.
|
||||
`.githooks/` holds only `pre-commit` and `pre-push`, so running
|
||||
`hooks:install` **silently disables any locally installed hook** of any other
|
||||
name (`post-commit`, `post-checkout`, ...). Nothing warns you. If you need one,
|
||||
re-install it under `.githooks/` (untracked, and it stays yours), or skip
|
||||
`hooks:install` and run the checks through `npm run check` instead.
|
||||
|
||||
@@ -26,7 +26,7 @@ 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
|
||||
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".
|
||||
|
||||
@@ -1,17 +1,32 @@
|
||||
---
|
||||
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.
|
||||
description: Add or change a WebSocket message type in OwnCord. Use before editing protocol/schema.json, Server/ws/message_types.go, or 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/`.
|
||||
`protocol/schema.json` is the source of truth. Both constant files are
|
||||
generated from it by `Server/cmd/genprotocol/`.
|
||||
|
||||
1. Edit `docs/protocol-schema.json`.
|
||||
**The schema holds message-type NAMES only.** Route by what you are changing —
|
||||
most payload work never touches it, and sending a field change through the
|
||||
regenerate cycle below is wasted work:
|
||||
|
||||
| Change | What to edit |
|
||||
| -------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------- |
|
||||
| New message type | schema + regenerate (steps below) |
|
||||
| New or changed payload **field** on an existing type | `Server/ws/command.go`/`messages.go`, `Client/src/lib/protocolTypes.ts`, `docs/protocol.md` — no schema, no regenerate |
|
||||
| Content inside an opaque blob the server relays verbatim | `docs/protocol.md` only; often zero Go change |
|
||||
|
||||
Before assuming a field needs server work, read the relay handler: if the server
|
||||
forwards the message raw, there is nothing to add. If it **re-serialises**, an
|
||||
older server drops unknown JSON fields — so a field the server must forward is
|
||||
NOT backward compatible with older servers.
|
||||
|
||||
1. Edit `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
|
||||
`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.
|
||||
|
||||
@@ -20,4 +35,4 @@ 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`.
|
||||
`ws.on(...)` subscription in `Client/src/lib/dispatcher.ts`.
|
||||
|
||||
@@ -19,7 +19,7 @@ description: >
|
||||
# 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
|
||||
_"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
|
||||
@@ -176,9 +176,17 @@ 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.
|
||||
|
||||
**Your own delegates are concurrent writers.** A subagent dispatched into the
|
||||
same project has this skill active in its own context and appends to the same
|
||||
log, so it consumes numbers between your read and your write. Collisions are
|
||||
structural in any fan-out workflow, not a rare parallel-human accident — which
|
||||
is exactly why the pre-write assertion below matters most in the workflows that
|
||||
spawn helpers. When dispatching, say who owns logging for the session, or two
|
||||
writers record the same incident from different angles under different numbers.
|
||||
|
||||
**Numbering discipline (mandatory, every append):**
|
||||
|
||||
1. *Pre-check:* read the actual log and find the highest existing number —
|
||||
1. _Pre-check:_ read the actual log and find the highest existing number —
|
||||
never trust session memory:
|
||||
|
||||
```bash
|
||||
@@ -188,7 +196,7 @@ act of memory.
|
||||
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
|
||||
2. _Pre-write assertion:_ immediately before appending, confirm the proposed
|
||||
number doesn't already exist:
|
||||
|
||||
```bash
|
||||
@@ -200,7 +208,7 @@ act of memory.
|
||||
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
|
||||
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
|
||||
@@ -377,6 +385,7 @@ resolved statuses always carry their resolution date
|
||||
## [Date]
|
||||
|
||||
### Observation 1: [Title]
|
||||
|
||||
**Status:** OPEN
|
||||
[... full format ...]
|
||||
```
|
||||
@@ -432,15 +441,15 @@ 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` |
|
||||
| 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` |
|
||||
|
||||
@@ -84,18 +84,23 @@ work.
|
||||
**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]
|
||||
```
|
||||
|
||||
@@ -103,7 +108,7 @@ work.
|
||||
|
||||
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
|
||||
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
|
||||
|
||||
@@ -227,6 +227,7 @@ 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]
|
||||
|
||||
@@ -80,7 +80,7 @@ fallback active. No → write today's date to
|
||||
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
|
||||
**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
|
||||
|
||||
@@ -1,32 +1,34 @@
|
||||
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.',
|
||||
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' },
|
||||
{ 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') {
|
||||
if (typeof args === "string") {
|
||||
try {
|
||||
return JSON.parse(args) || {}
|
||||
return JSON.parse(args) || {};
|
||||
} catch {
|
||||
return {}
|
||||
return {};
|
||||
}
|
||||
}
|
||||
return args || {}
|
||||
})()
|
||||
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 : []
|
||||
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.
|
||||
@@ -37,23 +39,23 @@ const BREAKER =
|
||||
: {
|
||||
threshold: ARGS.circuitBreaker?.threshold ?? 0.5,
|
||||
minAttempts: ARGS.circuitBreaker?.minAttempts ?? 3,
|
||||
}
|
||||
let breaker = null // set to a report object if it trips
|
||||
};
|
||||
let breaker = null; // set to a report object if it trips
|
||||
|
||||
// ---------- phase 1: plan ----------
|
||||
phase('Plan')
|
||||
phase("Plan");
|
||||
|
||||
const excluded = []
|
||||
const selected = []
|
||||
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` })
|
||||
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' })
|
||||
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' })
|
||||
excluded.push({ id: f.id, reason: "below maxSeverity" });
|
||||
} else {
|
||||
selected.push(f)
|
||||
selected.push(f);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -61,59 +63,68 @@ for (const f of ALL) {
|
||||
// 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()
|
||||
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 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(', ')}`)
|
||||
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}`)
|
||||
for (const e of excluded) log(` excluded ${e.id}: ${e.reason}`);
|
||||
|
||||
const publicClusters = clusters.map((c) => ({ file: c.file, ids: c.ids }))
|
||||
const publicClusters = clusters.map((c) => ({ file: c.file, ids: c.ids }));
|
||||
|
||||
// ---------- schemas ----------
|
||||
const FIX_RESULTS = {
|
||||
type: 'object',
|
||||
required: ['results', 'touchedPaths'],
|
||||
type: "object",
|
||||
required: ["results", "touchedPaths"],
|
||||
properties: {
|
||||
results: {
|
||||
type: 'array',
|
||||
type: "array",
|
||||
items: {
|
||||
type: 'object',
|
||||
required: ['id', 'outcome', 'testPath', 'rationale'],
|
||||
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' },
|
||||
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' },
|
||||
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.',
|
||||
"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')
|
||||
phase("Fix");
|
||||
|
||||
function fixPrompt(cluster) {
|
||||
return (
|
||||
@@ -150,65 +161,82 @@ function fixPrompt(cluster) {
|
||||
`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` +
|
||||
`Client tests run from 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) : [],
|
||||
})),
|
||||
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 = []
|
||||
const fixed = [];
|
||||
for (let i = 0; i < clusters.length; i++) {
|
||||
const cluster = clusters[i]
|
||||
const outcome = fixOutcomes[i]
|
||||
const cluster = clusters[i];
|
||||
const outcome = fixOutcomes[i];
|
||||
if (!outcome) {
|
||||
log(`fix ${cluster.file}: agent failed - ${cluster.ids.length} finding(s) blocked`)
|
||||
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' })),
|
||||
results: cluster.ids.map((id) => ({
|
||||
id,
|
||||
outcome: "blocked",
|
||||
testPath: "",
|
||||
rationale: "fix agent failed or returned nothing",
|
||||
})),
|
||||
touchedPaths: [],
|
||||
union: [cluster.file],
|
||||
})
|
||||
continue
|
||||
});
|
||||
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))
|
||||
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(', ')}`)
|
||||
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 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`)
|
||||
.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 ----------
|
||||
@@ -220,82 +248,97 @@ for (let i = 0; i < clusters.length; i++) {
|
||||
// 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]]) {
|
||||
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`
|
||||
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`)
|
||||
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
|
||||
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',
|
||||
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}`)
|
||||
};
|
||||
log(`CIRCUIT BREAKER: ${breaker.reason}`);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- phase 3: prove + commit ----------
|
||||
phase('Prove')
|
||||
phase("Prove");
|
||||
|
||||
const PROVE_RESULT = {
|
||||
type: 'object',
|
||||
required: ['committed', 'sha', 'redObserved', 'greenObserved', 'redOutput', 'greenOutput', 'note'],
|
||||
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' },
|
||||
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',
|
||||
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.',
|
||||
"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',
|
||||
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.',
|
||||
"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' },
|
||||
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` +
|
||||
`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` +
|
||||
@@ -305,7 +348,7 @@ function provePrompt(cluster, fixedIds, testPaths, sourcePaths) {
|
||||
`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` +
|
||||
` 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 ` +
|
||||
@@ -325,27 +368,27 @@ function provePrompt(cluster, fixedIds, testPaths, sourcePaths) {
|
||||
`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. ` +
|
||||
` 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` +
|
||||
` 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` +
|
||||
`Client tests run from 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
|
||||
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) {
|
||||
@@ -353,20 +396,20 @@ for (const { cluster, results, union } of fixed) {
|
||||
// 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`
|
||||
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
|
||||
continue;
|
||||
}
|
||||
const fixedHere = results.filter((r) => r.outcome === 'fixed')
|
||||
const fixedHere = results.filter((r) => r.outcome === "fixed");
|
||||
if (!fixedHere.length) {
|
||||
log(`prove ${cluster.file}: no fixes to prove - skipped`)
|
||||
continue
|
||||
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))]
|
||||
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.
|
||||
@@ -375,73 +418,80 @@ for (const { cluster, results, union } of fixed) {
|
||||
// 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',
|
||||
phase: "Prove",
|
||||
model: "opus",
|
||||
effort: "high",
|
||||
schema: PROVE_RESULT,
|
||||
}).catch(() => null)
|
||||
}).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
|
||||
proveAttempts++;
|
||||
const ok = p && p.committed && p.redObserved && p.greenObserved && p.sha;
|
||||
if (!ok) {
|
||||
const why = !p
|
||||
? 'prove agent failed'
|
||||
? "prove agent failed"
|
||||
: !p.redObserved
|
||||
? `revert-proof failed: tests still passed with the fix reverted (${p.note || 'no note'})`
|
||||
? `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`)
|
||||
? `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
|
||||
if (r.outcome === "fixed") {
|
||||
r.outcome = "blocked";
|
||||
r.rationale = why;
|
||||
}
|
||||
}
|
||||
proveFailures++
|
||||
if (BREAKER && proveAttempts >= BREAKER.minAttempts && proveFailures / proveAttempts > BREAKER.threshold) {
|
||||
proveFailures++;
|
||||
if (
|
||||
BREAKER &&
|
||||
proveAttempts >= BREAKER.minAttempts &&
|
||||
proveFailures / proveAttempts > BREAKER.threshold
|
||||
) {
|
||||
breaker = {
|
||||
trippedAt: 'prove',
|
||||
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}`)
|
||||
};
|
||||
log(`CIRCUIT BREAKER: ${breaker.reason}`);
|
||||
}
|
||||
continue
|
||||
continue;
|
||||
}
|
||||
commits.push({ sha: p.sha, file: cluster.file, ids })
|
||||
log(`prove ${cluster.file}: committed ${p.sha} (${ids.join(', ')})`)
|
||||
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'],
|
||||
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' },
|
||||
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()
|
||||
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')
|
||||
if (f.startsWith("Server/")) s.add("server");
|
||||
else if (f.startsWith("Client/src-tauri/")) s.add("rust");
|
||||
else if (f.startsWith("Client/")) s.add("client");
|
||||
}
|
||||
return [...s]
|
||||
return [...s];
|
||||
}
|
||||
|
||||
const GATE_COMMANDS = {
|
||||
client:
|
||||
`From Client/tauri-client:\n` +
|
||||
`From Client:\n` +
|
||||
` NODE_OPTIONS=--no-experimental-webstorage npm test\n` +
|
||||
` npm run typecheck\n` +
|
||||
` npm run lint\n` +
|
||||
@@ -456,38 +506,48 @@ const GATE_COMMANDS = {
|
||||
` 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" ` +
|
||||
`"go run ./cmd/genprotocol && git diff --exit-code ws/message_types.go ../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`,
|
||||
}
|
||||
`From Client/src-tauri:\n` + ` cargo test\n` + ` cargo clippy --all-targets -- -D warnings`,
|
||||
};
|
||||
|
||||
let gate = null
|
||||
let gate = null;
|
||||
if (commits.length) {
|
||||
phase('Gate')
|
||||
const stacks = stacksFor(commits.map((c) => c.file))
|
||||
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') +
|
||||
`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)
|
||||
{ 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(', ')})`)
|
||||
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')
|
||||
log("gate: nothing committed - skipped");
|
||||
}
|
||||
|
||||
return { branch: BRANCH, clusters: publicClusters, excluded, commits, results: allResults, gate, breaker }
|
||||
return {
|
||||
branch: BRANCH,
|
||||
clusters: publicClusters,
|
||||
excluded,
|
||||
commits,
|
||||
results: allResults,
|
||||
gate,
|
||||
breaker,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
# Editor baseline for OwnCord. Pairs with .gitattributes (`* text=auto eol=lf`)
|
||||
# and the repository Prettier config — all three agree on LF and trailing
|
||||
# newlines, so an editor that honours this file produces bytes CI accepts.
|
||||
#
|
||||
# This is a baseline, not a gate. Prettier, gofmt and rustfmt are what actually
|
||||
# fail the build; nothing lints this file.
|
||||
root = true
|
||||
|
||||
[*]
|
||||
charset = utf-8
|
||||
end_of_line = lf
|
||||
insert_final_newline = true
|
||||
trim_trailing_whitespace = true
|
||||
indent_style = space
|
||||
indent_size = 2
|
||||
|
||||
# gofmt emits tabs and is the authority for Go.
|
||||
[*.go]
|
||||
indent_style = tab
|
||||
|
||||
[{go.mod,go.sum}]
|
||||
indent_style = tab
|
||||
|
||||
# rustfmt default profile.
|
||||
[*.rs]
|
||||
indent_size = 4
|
||||
|
||||
# Recipe lines are tab-significant to make(1).
|
||||
[Makefile]
|
||||
indent_style = tab
|
||||
@@ -8,14 +8,3 @@
|
||||
*.wasm binary
|
||||
*.exe binary
|
||||
|
||||
# Knowledge graph (graphify) — generated, committed so a fresh clone can query
|
||||
# it without rebuilding. `* text=auto eol=lf` above would rewrite line endings
|
||||
# inside these on checkout, and .graphify_labels.json.sig signs the labels
|
||||
# byte-for-byte, so normalization would invalidate the signature. -text opts the
|
||||
# whole tree out; -diff keeps a 17 MB graph out of textual diffs.
|
||||
graphify-out/** -text linguist-generated=true
|
||||
graphify-out/graph.json -diff
|
||||
graphify-out/graph.html -diff
|
||||
|
||||
# Rendered from findings-ledger.json by render-ledger.mjs — never hand-edit.
|
||||
.superpowers/FINDINGS.md linguist-generated=true
|
||||
|
||||
@@ -22,46 +22,80 @@ fail() {
|
||||
# ---------- Server (Go) ----------
|
||||
go_staged=$(printf '%s\n' "$staged" | grep '^Server/.*\.go$' | grep -v '^Server/db/dbgen/')
|
||||
if [ -n "$go_staged" ]; then
|
||||
if command -v go >/dev/null 2>&1; then
|
||||
# shellcheck disable=SC2086 — repo paths contain no spaces
|
||||
if command -v go >/dev/null 2>&1 && command -v gofmt >/dev/null 2>&1; then
|
||||
# Word splitting is intended: repo paths contain no spaces.
|
||||
# shellcheck disable=SC2086
|
||||
unformatted=$(gofmt -l $go_staged)
|
||||
[ -n "$unformatted" ] && fail "gofmt needed (run gofmt -w on): $unformatted"
|
||||
(cd Server && go vet ./...) || fail "go vet"
|
||||
else
|
||||
printf 'pre-commit: WARNING: go not installed; skipping Go checks.\n' >&2
|
||||
printf 'pre-commit: WARNING: go/gofmt not installed; skipping Go checks.\n' >&2
|
||||
fi
|
||||
fi
|
||||
|
||||
# These two blocks inline what `make sqlc-verify` / `make protocol-verify` reduce
|
||||
# to (Server/Makefile), rather than shelling out to make. `make` is not on PATH on
|
||||
# a stock Windows box, and a guard on `go`/`sqlc` does not imply it: the old code
|
||||
# probed one command and invoked another, so a contributor with Go but no make was
|
||||
# told "protocol constants are stale" when nothing had been generated or compared.
|
||||
|
||||
# sqlc inputs changed -> regenerated db/dbgen must be part of the same commit.
|
||||
if printf '%s\n' "$staged" | grep -qE '^Server/(db/queries/|migrations/|sqlc\.yaml|sqlc\.version)'; then
|
||||
if command -v sqlc >/dev/null 2>&1; then
|
||||
(cd Server && make sqlc-verify) \
|
||||
|| fail "db/dbgen is stale — run 'make sqlc-generate' in Server/ and stage the result"
|
||||
(cd Server && sqlc generate && git diff --exit-code db/dbgen) \
|
||||
|| fail "db/dbgen is stale — run 'sqlc generate' in Server/ and stage the result"
|
||||
else
|
||||
printf 'pre-commit: WARNING: sqlc not installed (make sqlc-install); CI will run sqlc-verify.\n' >&2
|
||||
printf 'pre-commit: WARNING: sqlc not installed; skipping the db/dbgen check. Install the version pinned in Server/sqlc.version. CI will run it.\n' >&2
|
||||
fi
|
||||
fi
|
||||
|
||||
# Protocol schema changed -> regenerated Go + TS constants must be in the same commit.
|
||||
if printf '%s\n' "$staged" | grep -qE '^(docs/protocol-schema\.json|Server/scripts/genprotocol/)'; then
|
||||
if printf '%s\n' "$staged" | grep -qE '^(protocol/schema\.json|Server/cmd/genprotocol/)'; then
|
||||
if command -v go >/dev/null 2>&1; then
|
||||
(cd Server && make protocol-verify) \
|
||||
|| fail "protocol constants are stale — run 'make protocol-generate' in Server/ and stage the result"
|
||||
(cd Server && go run ./cmd/genprotocol \
|
||||
&& git diff --exit-code ws/message_types.go ../Client/src/lib/protocolTypes.ts) \
|
||||
|| fail "protocol constants are stale — run 'go run ./cmd/genprotocol' in Server/ and stage the result"
|
||||
else
|
||||
printf 'pre-commit: WARNING: go not installed; skipping the protocol-constants check. CI will run it.\n' >&2
|
||||
fi
|
||||
fi
|
||||
|
||||
# ---------- Client (TypeScript) ----------
|
||||
ts_staged=$(printf '%s\n' "$staged" | grep -E '^Client/tauri-client/(src|tests)/.*\.ts$' | grep -v '/generated/')
|
||||
if [ -n "$ts_staged" ]; then
|
||||
if [ ! -d Client/tauri-client/node_modules ]; then
|
||||
printf 'pre-commit: WARNING: node_modules missing in Client/tauri-client; skipping client checks (run npm install there).\n' >&2
|
||||
# Findings ledger changed -> it must still be valid. Unlike the two blocks
|
||||
# above there is nothing to diff: FINDINGS.md is not tracked (RL-07), so a
|
||||
# stale rendering cannot be committed. --check is the whole gate here, and it
|
||||
# writes nothing. render-ledger.mjs is Node-stdlib-only, so `node` alone is the
|
||||
# probe — no node_modules guard, unlike the prettier block below.
|
||||
if printf '%s\n' "$staged" | grep -qE '^\.superpowers/(findings-ledger\.json|render-ledger\.mjs)$'; then
|
||||
if command -v node >/dev/null 2>&1; then
|
||||
node .superpowers/render-ledger.mjs --check \
|
||||
|| fail "findings-ledger.json is invalid — see the INVALID lines above"
|
||||
else
|
||||
rel=$(printf '%s\n' "$ts_staged" | sed 's|^Client/tauri-client/||')
|
||||
cd Client/tauri-client || exit 1
|
||||
printf 'pre-commit: WARNING: node not installed; skipping the ledger check. CI will run it.\n' >&2
|
||||
fi
|
||||
fi
|
||||
|
||||
# ---------- Formatting (repository-wide) ----------
|
||||
# Prettier is configured once at the repository root (.prettierrc.json) and
|
||||
# covers every material tracked source, not just client TypeScript.
|
||||
# --ignore-unknown drops the Go/Rust/binary paths it has no parser for.
|
||||
if [ -d node_modules ]; then
|
||||
# Word splitting is intended: repo paths contain no spaces.
|
||||
# shellcheck disable=SC2086
|
||||
npx prettier --check --ignore-unknown $staged || fail "prettier (run: npm run format)"
|
||||
else
|
||||
printf 'pre-commit: WARNING: node_modules missing at the repository root; skipping prettier.\n' >&2
|
||||
fi
|
||||
|
||||
# ---------- Client (TypeScript) ----------
|
||||
ts_staged=$(printf '%s\n' "$staged" | grep -E '^Client/(src|tests)/.*\.ts$' | grep -v '/generated/')
|
||||
if [ -n "$ts_staged" ]; then
|
||||
if [ ! -d Client/node_modules ]; then
|
||||
printf 'pre-commit: WARNING: node_modules missing in Client; skipping client checks (run npm install there).\n' >&2
|
||||
else
|
||||
rel=$(printf '%s\n' "$ts_staged" | sed 's|^Client/||')
|
||||
cd Client || exit 1
|
||||
# shellcheck disable=SC2086
|
||||
npx oxlint $rel || fail "oxlint"
|
||||
# shellcheck disable=SC2086
|
||||
npx prettier --check $rel || fail "prettier (run: npm run format)"
|
||||
npm run -s typecheck || fail "tsc --noEmit"
|
||||
cd "$repo_root" || exit 1
|
||||
fi
|
||||
|
||||
@@ -15,9 +15,34 @@ fail() {
|
||||
exit 1
|
||||
}
|
||||
|
||||
# What changed relative to origin/main decides which side's gates run.
|
||||
# What changed relative to this branch's base decides which side's gates run.
|
||||
#
|
||||
# The base is whichever of origin/dev, origin/main is NEAREST — the one with the
|
||||
# fewest commits between its merge-base and HEAD. A feature branch cut from dev
|
||||
# picks dev; dev itself scores 0 against dev (nothing to compare) and so picks
|
||||
# main, which is right for a dev -> main release PR. Hardcoding origin/main got
|
||||
# the first case wrong once dev became the integration branch: everything on dev
|
||||
# and not yet on main counted as "changed", so both sides' gates ran every time.
|
||||
#
|
||||
# Markdown/docs changes never trigger builds.
|
||||
changed=$(git diff --name-only origin/main...HEAD 2>/dev/null) || changed="__all__"
|
||||
base=""
|
||||
best=""
|
||||
for cand in origin/dev origin/main; do
|
||||
git rev-parse --verify -q "$cand" >/dev/null 2>&1 || continue
|
||||
mb=$(git merge-base "$cand" HEAD 2>/dev/null) || continue
|
||||
n=$(git rev-list --count "$mb..HEAD" 2>/dev/null) || continue
|
||||
[ "$n" -eq 0 ] && continue
|
||||
if [ -z "$best" ] || [ "$n" -lt "$best" ]; then
|
||||
base=$cand
|
||||
best=$n
|
||||
fi
|
||||
done
|
||||
|
||||
if [ -n "$base" ]; then
|
||||
changed=$(git diff --name-only "$base...HEAD" 2>/dev/null) || changed="__all__"
|
||||
else
|
||||
changed="__all__"
|
||||
fi
|
||||
[ "$changed" = "__all__" ] || changed=$(printf '%s\n' "$changed" | grep -v '\.md$')
|
||||
[ -z "$changed" ] && exit 0
|
||||
|
||||
@@ -28,8 +53,8 @@ if [ "$changed" = "__all__" ]; then
|
||||
client_changed=1
|
||||
else
|
||||
if printf '%s\n' "$changed" | grep -q '^Server/'; then server_changed=1; fi
|
||||
if printf '%s\n' "$changed" | grep -q '^Client/tauri-client/'; then client_changed=1; fi
|
||||
if printf '%s\n' "$changed" | grep -q '^docs/protocol-schema\.json'; then
|
||||
if printf '%s\n' "$changed" | grep -q '^Client/'; then client_changed=1; fi
|
||||
if printf '%s\n' "$changed" | grep -q '^protocol/schema\.json'; then
|
||||
server_changed=1
|
||||
client_changed=1
|
||||
fi
|
||||
@@ -49,12 +74,12 @@ if [ "$server_changed" = 1 ] && command -v go >/dev/null 2>&1; then
|
||||
fi
|
||||
|
||||
if [ "$client_changed" = 1 ]; then
|
||||
if [ -d Client/tauri-client/node_modules ]; then
|
||||
if [ -d Client/node_modules ]; then
|
||||
echo "pre-push: client typecheck + eslint..."
|
||||
(cd Client/tauri-client && npm run -s typecheck) || fail "tsc --noEmit"
|
||||
(cd Client/tauri-client && npx eslint src/) || fail "eslint"
|
||||
(cd Client && npm run -s typecheck) || fail "tsc --noEmit"
|
||||
(cd Client && npx eslint src/) || fail "eslint"
|
||||
else
|
||||
printf 'pre-push: WARNING: node_modules missing in Client/tauri-client; skipping client checks.\n' >&2
|
||||
printf 'pre-push: WARNING: node_modules missing in Client; skipping client checks.\n' >&2
|
||||
fi
|
||||
fi
|
||||
|
||||
|
||||
@@ -1,34 +0,0 @@
|
||||
---
|
||||
name: Bug Report
|
||||
about: Report a bug in OwnCord
|
||||
title: "bug: "
|
||||
labels: bug
|
||||
---
|
||||
|
||||
## Description
|
||||
|
||||
<!-- Clear description of the bug -->
|
||||
|
||||
## Steps to Reproduce
|
||||
|
||||
1.
|
||||
2.
|
||||
3.
|
||||
|
||||
## Expected Behavior
|
||||
|
||||
<!-- What should happen -->
|
||||
|
||||
## Actual Behavior
|
||||
|
||||
<!-- What actually happens -->
|
||||
|
||||
## Environment
|
||||
|
||||
- **OS**: Windows 11 (version)
|
||||
- **OwnCord Version**:
|
||||
- **Component**: Server / Client / Both
|
||||
|
||||
## Screenshots / Logs
|
||||
|
||||
<!-- Paste relevant logs or screenshots -->
|
||||
@@ -0,0 +1,169 @@
|
||||
# A YAML issue form, not a Markdown template: only this format can mark a field
|
||||
# required, so the environment detail a maintainer needs to reproduce a bug
|
||||
# arrives with the report instead of after a round trip.
|
||||
#
|
||||
# Nothing in this repository validates this file's schema — prettier checks it
|
||||
# parses as YAML and actionlint does not read it. A form that is valid YAML but
|
||||
# an invalid issue form silently stops appearing in the chooser, so changes here
|
||||
# want a look at the live "New issue" page afterwards.
|
||||
name: Bug report
|
||||
description: Something in the server, desktop client, or admin panel is broken.
|
||||
title: "bug: "
|
||||
labels: ["bug"]
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
**Do not report security vulnerabilities here.** Use
|
||||
[private security reporting](https://github.com/J3vb/OwnCord/security/advisories/new)
|
||||
instead — a public issue discloses the problem before there is a fix.
|
||||
|
||||
Questions, ideas and feedback belong in
|
||||
[Discussions](https://github.com/J3vb/OwnCord/discussions), not here.
|
||||
|
||||
- type: textarea
|
||||
id: what-happened
|
||||
attributes:
|
||||
label: What happened
|
||||
description: What went wrong, and what you expected instead.
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: repro
|
||||
attributes:
|
||||
label: Steps to reproduce
|
||||
description: Numbered steps from a known starting state. A bug nobody can reproduce cannot be fixed.
|
||||
placeholder: |
|
||||
1. Start the server with …
|
||||
2. In the client, open …
|
||||
3. …
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: dropdown
|
||||
id: component
|
||||
attributes:
|
||||
label: Component
|
||||
options:
|
||||
- Server
|
||||
- Desktop client
|
||||
- Admin panel
|
||||
- Both server and client
|
||||
- Not sure
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: input
|
||||
id: server-version
|
||||
attributes:
|
||||
label: Server version
|
||||
description: >-
|
||||
Admin panel → Updates, or the banner the server prints at startup. It is
|
||||
deliberately not exposed on the unauthenticated /health endpoint, so
|
||||
"unknown" is a fine answer if you are not the operator. A server built
|
||||
from source reports "dev".
|
||||
placeholder: "1.2.0-alpha.4 / dev / unknown"
|
||||
validations:
|
||||
required: false
|
||||
|
||||
- type: input
|
||||
id: client-version
|
||||
attributes:
|
||||
label: Client version
|
||||
description: Settings → Logs shows it. Leave blank for a server-only bug.
|
||||
placeholder: "1.2.0-alpha.4"
|
||||
validations:
|
||||
required: false
|
||||
|
||||
- type: dropdown
|
||||
id: os
|
||||
attributes:
|
||||
label: Operating system
|
||||
options:
|
||||
- Windows 10
|
||||
- Windows 11
|
||||
- Linux
|
||||
- Other
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: dropdown
|
||||
id: arch
|
||||
attributes:
|
||||
label: CPU architecture
|
||||
description: ARM64 currently applies to the Linux desktop client; there is no ARM64 server release yet.
|
||||
options:
|
||||
- x64
|
||||
- ARM64 (aarch64)
|
||||
- Not sure
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: dropdown
|
||||
id: deployment
|
||||
attributes:
|
||||
label: How is the server deployed
|
||||
options:
|
||||
- Prebuilt binary (Windows)
|
||||
- Prebuilt binary (Linux)
|
||||
- Built from source
|
||||
- Docker / Compose
|
||||
- Linux systemd service
|
||||
- Windows service (NSSM or Task Scheduler)
|
||||
- Not applicable — client-only bug
|
||||
- Not sure
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: dropdown
|
||||
id: tls-mode
|
||||
attributes:
|
||||
label: TLS mode
|
||||
description: The `tls.mode` setting in config.yaml.
|
||||
options:
|
||||
- self_signed
|
||||
- acme
|
||||
- manual
|
||||
- "off"
|
||||
- Not applicable / not sure
|
||||
validations:
|
||||
required: false
|
||||
|
||||
- type: dropdown
|
||||
id: topology
|
||||
attributes:
|
||||
label: How do clients reach the server
|
||||
options:
|
||||
- Same machine or LAN, direct
|
||||
- Port forwarding to a public IP
|
||||
- Behind a reverse proxy
|
||||
- Tailscale
|
||||
- Not sure
|
||||
validations:
|
||||
required: false
|
||||
|
||||
- type: dropdown
|
||||
id: webview
|
||||
attributes:
|
||||
label: Client webview
|
||||
description: >-
|
||||
The desktop client renders through the OS webview — WebView2 on Windows,
|
||||
WebKitGTK on Linux — so rendering and networking bugs often depend on it.
|
||||
Skip this for a server-only bug.
|
||||
options:
|
||||
- WebView2 (Windows)
|
||||
- WebKitGTK (Linux)
|
||||
- Not applicable / not sure
|
||||
validations:
|
||||
required: false
|
||||
|
||||
- type: textarea
|
||||
id: logs
|
||||
attributes:
|
||||
label: Logs, screenshots, or anything else
|
||||
description: >-
|
||||
Server console output or Settings → Logs from the client. Redact tokens,
|
||||
invite codes and anything else you would not post publicly.
|
||||
validations:
|
||||
required: false
|
||||
@@ -1,5 +1,23 @@
|
||||
# Issues are the bug tracker only. Ideas, questions and feedback go to
|
||||
# Discussions; vulnerabilities go to private security reporting. Keeping
|
||||
# blank_issues_enabled false is what makes that routing hold — a blank issue
|
||||
# bypasses every form and every warning on it.
|
||||
#
|
||||
# The ?category= slugs must match this repository's actual Discussions
|
||||
# categories. A slug that does not exist silently drops the user on the category
|
||||
# picker rather than erroring, so check the live Discussions tab after changing
|
||||
# one.
|
||||
blank_issues_enabled: false
|
||||
contact_links:
|
||||
- name: Community Support
|
||||
- name: Report a security vulnerability
|
||||
url: https://github.com/J3vb/OwnCord/security/advisories/new
|
||||
about: Private disclosure. Never open a public issue for a security bug.
|
||||
- name: Ask a question
|
||||
url: https://github.com/J3vb/OwnCord/discussions/categories/q-a
|
||||
about: Setup, deployment and usage questions.
|
||||
- name: Suggest an idea
|
||||
url: https://github.com/J3vb/OwnCord/discussions/categories/ideas
|
||||
about: Feature requests and design suggestions start here, not as issues.
|
||||
- name: General discussion and feedback
|
||||
url: https://github.com/J3vb/OwnCord/discussions
|
||||
about: Ask questions and get help from the community
|
||||
about: Anything that is not a reproducible bug.
|
||||
|
||||
@@ -1,22 +0,0 @@
|
||||
---
|
||||
name: Feature Request
|
||||
about: Suggest a new feature for OwnCord
|
||||
title: "feat: "
|
||||
labels: enhancement
|
||||
---
|
||||
|
||||
## Problem
|
||||
|
||||
<!-- What problem does this solve? -->
|
||||
|
||||
## Proposed Solution
|
||||
|
||||
<!-- How should it work? -->
|
||||
|
||||
## Alternatives Considered
|
||||
|
||||
<!-- Other approaches you thought about -->
|
||||
|
||||
## Additional Context
|
||||
|
||||
<!-- Mockups, links, or related issues -->
|
||||
@@ -1,5 +1,10 @@
|
||||
# Pull Request
|
||||
|
||||
<!-- Base branch: PRs target `dev`, not `main`. `main` carries releases only.
|
||||
See docs/contributing.md#branch-and-pr-model. The Docker and Tauri Full
|
||||
Build jobs are gated on `main` and report as skipped here — that is
|
||||
expected. -->
|
||||
|
||||
## Summary
|
||||
|
||||
<!-- What does this PR do? 1-3 bullet points -->
|
||||
@@ -14,14 +19,31 @@
|
||||
|
||||
## Test Plan
|
||||
|
||||
- [ ] Unit tests pass (`npm test` / `go test ./...`)
|
||||
- [ ] TypeScript check passes (`npx tsc --noEmit`)
|
||||
- [ ] `npm run check` passes from the repository root — the one entry point that
|
||||
runs what CI gates on. `check:server` / `check:client` / `check:rust` /
|
||||
`check:hygiene` / `check:docs` run a single stack if that is all you touched
|
||||
- [ ] Manual testing done (describe below)
|
||||
- [ ] Generated files were regenerated, not hand-edited — `Server/db/dbgen/`,
|
||||
`Server/ws/message_types.go`, `Client/src/lib/protocolTypes.ts`,
|
||||
`Client/src/generated/`, `.superpowers/FINDINGS.md`. CI fails on drift
|
||||
- [ ] 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
|
||||
|
||||
## Scope
|
||||
|
||||
<!-- What adjacent work did you deliberately leave out, and why? A written
|
||||
deferral is a deliverable — see docs/contributing.md#commit-format. -->
|
||||
|
||||
Not included:
|
||||
|
||||
> **No security detail in this PR.** This repository is public, so the
|
||||
> description, the commits and the branch name are all disclosure channels. If
|
||||
> this change repairs a vulnerability, report it through
|
||||
> [private security reporting](https://github.com/J3vb/OwnCord/security/advisories/new)
|
||||
> first and describe only the control this PR adds.
|
||||
|
||||
## Screenshots
|
||||
|
||||
<!-- If UI changes, add before/after screenshots -->
|
||||
|
||||
@@ -14,6 +14,13 @@ version: 2
|
||||
# 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.
|
||||
#
|
||||
# Each package root gets its own block rather than one block with `directories:`.
|
||||
# Grouping only works because a group rewrites exactly one lockfile; a block
|
||||
# spanning roots would put several lockfiles in one PR and reintroduce the very
|
||||
# conflict the grouping prevents. The three npm roots stay separate for the same
|
||||
# reason — see docs/contributing.md#dependency-policy for the measured decision
|
||||
# against adopting npm workspaces.
|
||||
|
||||
updates:
|
||||
# Go server dependencies
|
||||
@@ -36,9 +43,32 @@ updates:
|
||||
- dependency-name: "*"
|
||||
update-types: ["version-update:semver-major"]
|
||||
|
||||
# Server container base images (Server/Dockerfile). The builder's
|
||||
# `golang:1.26-bookworm` tracks the toolchain in Server/go.mod and every
|
||||
# `actions/setup-go` in CI, so a minor bump here is a signal to move all three
|
||||
# together — not a standalone merge.
|
||||
- package-ecosystem: docker
|
||||
directory: /Server
|
||||
schedule:
|
||||
interval: weekly
|
||||
day: monday
|
||||
commit-message:
|
||||
prefix: "chore(deps):"
|
||||
labels:
|
||||
- dependencies
|
||||
- docker
|
||||
open-pull-requests-limit: 5
|
||||
groups:
|
||||
docker-dependencies:
|
||||
patterns:
|
||||
- "*"
|
||||
ignore:
|
||||
- dependency-name: "*"
|
||||
update-types: ["version-update:semver-major"]
|
||||
|
||||
# Tauri client npm dependencies
|
||||
- package-ecosystem: npm
|
||||
directory: /Client/tauri-client
|
||||
directory: /Client
|
||||
schedule:
|
||||
interval: weekly
|
||||
day: monday
|
||||
@@ -56,9 +86,49 @@ updates:
|
||||
- dependency-name: "*"
|
||||
update-types: ["version-update:semver-major"]
|
||||
|
||||
# Root tooling npm dependencies (changelogen, prettier)
|
||||
- package-ecosystem: npm
|
||||
directory: /
|
||||
schedule:
|
||||
interval: weekly
|
||||
day: monday
|
||||
commit-message:
|
||||
prefix: "chore(deps):"
|
||||
labels:
|
||||
- dependencies
|
||||
- npm
|
||||
open-pull-requests-limit: 5
|
||||
groups:
|
||||
root-npm-dependencies:
|
||||
patterns:
|
||||
- "*"
|
||||
ignore:
|
||||
- dependency-name: "*"
|
||||
update-types: ["version-update:semver-major"]
|
||||
|
||||
# tools/mcp-introspect npm dependencies (local dev MCP server)
|
||||
- package-ecosystem: npm
|
||||
directory: /tools/mcp-introspect
|
||||
schedule:
|
||||
interval: weekly
|
||||
day: monday
|
||||
commit-message:
|
||||
prefix: "chore(deps):"
|
||||
labels:
|
||||
- dependencies
|
||||
- npm
|
||||
open-pull-requests-limit: 5
|
||||
groups:
|
||||
mcp-introspect-dependencies:
|
||||
patterns:
|
||||
- "*"
|
||||
ignore:
|
||||
- dependency-name: "*"
|
||||
update-types: ["version-update:semver-major"]
|
||||
|
||||
# Tauri Rust/Cargo dependencies
|
||||
- package-ecosystem: cargo
|
||||
directory: /Client/tauri-client/src-tauri
|
||||
directory: /Client/src-tauri
|
||||
schedule:
|
||||
interval: weekly
|
||||
day: monday
|
||||
|
||||
@@ -64,7 +64,7 @@ jobs:
|
||||
run: make sqlc-install sqlc-verify
|
||||
|
||||
# Protocol message-type constants (Go + TS) must never drift from
|
||||
# docs/protocol-schema.json — the single source of truth.
|
||||
# protocol/schema.json — the single source of truth.
|
||||
- name: Verify generated protocol constants (make protocol-verify)
|
||||
if: matrix.os == 'ubuntu-latest'
|
||||
run: make protocol-verify
|
||||
@@ -119,7 +119,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
defaults:
|
||||
run:
|
||||
working-directory: Client/tauri-client/
|
||||
working-directory: Client/
|
||||
steps:
|
||||
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
|
||||
|
||||
@@ -127,7 +127,7 @@ jobs:
|
||||
with:
|
||||
node-version: 24
|
||||
cache: npm
|
||||
cache-dependency-path: Client/tauri-client/package-lock.json
|
||||
cache-dependency-path: Client/package-lock.json
|
||||
|
||||
- name: Install npm dependencies
|
||||
run: npm ci
|
||||
@@ -159,9 +159,6 @@ jobs:
|
||||
- name: ESLint (type-aware rules)
|
||||
run: npx eslint src/
|
||||
|
||||
- name: Prettier format check
|
||||
run: npx prettier --check "src/**/*.ts" "tests/**/*.ts"
|
||||
|
||||
- name: Knip (unused code & deps)
|
||||
# Blocking since the 2026-08-04 remediation: the '|| true' era let a
|
||||
# real unused-export finding sit invisible in every green run.
|
||||
@@ -172,12 +169,123 @@ jobs:
|
||||
# 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.
|
||||
# The automated half of G-04: a planning document that states a finding count
|
||||
# the ledger contradicts fails here instead of quietly misleading a reader.
|
||||
# Deliberately tiny — no npm ci, because the script imports nothing outside
|
||||
# node:. It also runs its own selftest, since the whole check rests on
|
||||
# patterns narrow enough not to cry wolf.
|
||||
docs-consistency:
|
||||
name: Docs & Ledger Consistency
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
|
||||
|
||||
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||
with:
|
||||
node-version: 24
|
||||
|
||||
- name: Self-test the count matcher
|
||||
run: node scripts/check-doc-counts.mjs --selftest
|
||||
|
||||
- name: Documents must agree with the findings ledger
|
||||
run: node scripts/check-doc-counts.mjs
|
||||
|
||||
- name: Ledger schema is valid
|
||||
run: node .superpowers/render-ledger.mjs --check
|
||||
|
||||
# R-09 / RL-16. The release gate itself is only invoked for real at tag
|
||||
# time, which is the wrong place to find a bug in it — so its decision
|
||||
# logic is exercised here, on every pull request, against fixtures. Same
|
||||
# reason Server/scripts/docker-smoke.sh is called from both workflows.
|
||||
# This also parses the required-check list out of
|
||||
# b0-dev-branch-protection.sh, so a change to that list's shape fails here
|
||||
# rather than silently weakening the gate.
|
||||
- name: Self-test the release gate
|
||||
run: node scripts/verify-gate-evidence.mjs --selftest
|
||||
|
||||
# RL-07. FINDINGS.md is not tracked, so it cannot drift -- but L-07 also
|
||||
# asks that the rendering be reproducible and that CI reject a generation
|
||||
# failure. Rendering twice and comparing tests both: the render must
|
||||
# succeed (it validates and exits 1 before writing), and it must be a pure
|
||||
# function of the ledger. The severity rule in validate() is what makes
|
||||
# the second half true -- an unranked severity would make render()'s sort
|
||||
# implementation-defined.
|
||||
- name: FINDINGS.md renders, and renders identically twice
|
||||
run: |
|
||||
node .superpowers/render-ledger.mjs
|
||||
cp .superpowers/FINDINGS.md "$RUNNER_TEMP/FINDINGS.first.md"
|
||||
node .superpowers/render-ledger.mjs
|
||||
cmp "$RUNNER_TEMP/FINDINGS.first.md" .superpowers/FINDINGS.md || {
|
||||
echo "ERROR: rendering the ledger twice produced different output."
|
||||
echo "render() must be a pure function of findings-ledger.json."
|
||||
exit 1
|
||||
}
|
||||
|
||||
# The rendering is the human-readable view and is deliberately untracked,
|
||||
# so this artifact is how a reviewer reads it without a Node run.
|
||||
# if: always() -- you want it downloadable precisely when the job failed.
|
||||
- name: Upload the rendered findings ledger
|
||||
if: always()
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||
with:
|
||||
name: findings-ledger-rendering
|
||||
path: .superpowers/FINDINGS.md
|
||||
retention-days: 7
|
||||
|
||||
# Repository-wide formatting, script lint and workflow lint (RL-19 / L-13, S-05).
|
||||
#
|
||||
# Root-scoped and ubuntu-only for the same reason as docs-consistency above:
|
||||
# every gate here is platform-independent text analysis, and .gitattributes
|
||||
# pins eol=lf so a second OS would only re-prove line endings.
|
||||
#
|
||||
# Prettier lives here rather than in client-check because it is no longer a
|
||||
# client gate -- one config at the repository root covers Markdown, YAML,
|
||||
# JSON, CSS and the root scripts as well as client TypeScript.
|
||||
hygiene:
|
||||
name: Repository Hygiene
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
|
||||
|
||||
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||
with:
|
||||
node-version: 24
|
||||
|
||||
# Root install only -- prettier is the sole dependency this job needs, and
|
||||
# the client's install is client-check's job.
|
||||
- name: Install root dependencies
|
||||
run: npm ci
|
||||
|
||||
# shellcheck ships in the ubuntu runner image. actionlint does not, so it
|
||||
# is pinned by version and checked by digest: an unpinned installer script
|
||||
# would be the one unverified download in a workflow file that pins every
|
||||
# action by commit SHA.
|
||||
- name: Install actionlint
|
||||
env:
|
||||
ACTIONLINT_VERSION: 1.7.7
|
||||
ACTIONLINT_SHA256: 023070a287cd8cccd71515fedc843f1985bf96c436b7effaecce67290e7e0757
|
||||
run: |
|
||||
set -euo pipefail
|
||||
url="https://github.com/rhysd/actionlint/releases/download/v${ACTIONLINT_VERSION}/actionlint_${ACTIONLINT_VERSION}_linux_amd64.tar.gz"
|
||||
curl -sSfL --retry 3 -o "$RUNNER_TEMP/actionlint.tar.gz" "$url"
|
||||
echo "$ACTIONLINT_SHA256 $RUNNER_TEMP/actionlint.tar.gz" | sha256sum -c -
|
||||
tar -xzf "$RUNNER_TEMP/actionlint.tar.gz" -C "$RUNNER_TEMP" actionlint
|
||||
echo "$RUNNER_TEMP" >> "$GITHUB_PATH"
|
||||
|
||||
- name: Report tool versions
|
||||
run: shellcheck --version && actionlint --version
|
||||
|
||||
# The same entry point a contributor runs. run.mjs takes its shellcheck and
|
||||
# actionlint file lists from `git ls-files`, never a filesystem glob.
|
||||
- name: Formatting, shell and workflow gates
|
||||
run: npm run check:hygiene
|
||||
|
||||
client-tests:
|
||||
name: Client Unit Tests
|
||||
runs-on: ubuntu-latest
|
||||
defaults:
|
||||
run:
|
||||
working-directory: Client/tauri-client/
|
||||
working-directory: Client/
|
||||
steps:
|
||||
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
|
||||
|
||||
@@ -185,7 +293,7 @@ jobs:
|
||||
with:
|
||||
node-version: 24
|
||||
cache: npm
|
||||
cache-dependency-path: Client/tauri-client/package-lock.json
|
||||
cache-dependency-path: Client/package-lock.json
|
||||
|
||||
- name: Install npm dependencies
|
||||
run: npm ci
|
||||
@@ -198,7 +306,7 @@ jobs:
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||
with:
|
||||
name: client-coverage
|
||||
path: Client/tauri-client/coverage/
|
||||
path: Client/coverage/
|
||||
retention-days: 7
|
||||
|
||||
# Rust unit tests used to live inside tauri-build, which only runs on PRs to
|
||||
@@ -211,7 +319,7 @@ jobs:
|
||||
timeout-minutes: 30
|
||||
defaults:
|
||||
run:
|
||||
working-directory: Client/tauri-client/src-tauri/
|
||||
working-directory: Client/src-tauri/
|
||||
steps:
|
||||
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
|
||||
|
||||
@@ -231,12 +339,17 @@ jobs:
|
||||
- name: Install Rust
|
||||
uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable
|
||||
with:
|
||||
components: clippy
|
||||
components: clippy, rustfmt
|
||||
|
||||
- name: Rust cache
|
||||
uses: swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
|
||||
with:
|
||||
workspaces: Client/tauri-client/src-tauri
|
||||
workspaces: Client/src-tauri
|
||||
|
||||
# Ahead of clippy: a formatting failure is cheap to produce and cheap to
|
||||
# fix, and there is no reason to spend a clippy pass to surface one.
|
||||
- name: Rustfmt check
|
||||
run: cargo fmt --all -- --check
|
||||
|
||||
- name: Clippy lint (including test targets)
|
||||
run: cargo clippy --all-targets -- -D warnings
|
||||
@@ -264,7 +377,7 @@ jobs:
|
||||
timeout-minutes: 25
|
||||
defaults:
|
||||
run:
|
||||
working-directory: Client/tauri-client/
|
||||
working-directory: Client/
|
||||
steps:
|
||||
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
|
||||
|
||||
@@ -272,7 +385,7 @@ jobs:
|
||||
with:
|
||||
node-version: 24
|
||||
cache: npm
|
||||
cache-dependency-path: Client/tauri-client/package-lock.json
|
||||
cache-dependency-path: Client/package-lock.json
|
||||
|
||||
- name: Install npm dependencies
|
||||
run: npm ci
|
||||
@@ -295,8 +408,8 @@ jobs:
|
||||
with:
|
||||
name: playwright-report
|
||||
path: |
|
||||
Client/tauri-client/playwright-report/
|
||||
Client/tauri-client/test-results/
|
||||
Client/playwright-report/
|
||||
Client/test-results/
|
||||
retention-days: 7
|
||||
|
||||
# Admin-panel journey against a REAL server (no mocks): start-server.sh
|
||||
@@ -318,7 +431,7 @@ jobs:
|
||||
timeout-minutes: 20
|
||||
defaults:
|
||||
run:
|
||||
working-directory: Client/tauri-client/
|
||||
working-directory: Client/
|
||||
steps:
|
||||
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
|
||||
|
||||
@@ -331,7 +444,7 @@ jobs:
|
||||
with:
|
||||
node-version: 24
|
||||
cache: npm
|
||||
cache-dependency-path: Client/tauri-client/package-lock.json
|
||||
cache-dependency-path: Client/package-lock.json
|
||||
|
||||
- name: Install npm dependencies
|
||||
run: npm ci
|
||||
@@ -348,8 +461,8 @@ jobs:
|
||||
with:
|
||||
name: admin-e2e-report
|
||||
path: |
|
||||
Client/tauri-client/playwright-report/
|
||||
Client/tauri-client/test-results/
|
||||
Client/playwright-report/
|
||||
Client/test-results/
|
||||
retention-days: 7
|
||||
|
||||
# Blocking e2e subset: the parity-feature specs (tagged "@parity"), covering
|
||||
@@ -365,7 +478,7 @@ jobs:
|
||||
timeout-minutes: 15
|
||||
defaults:
|
||||
run:
|
||||
working-directory: Client/tauri-client/
|
||||
working-directory: Client/
|
||||
steps:
|
||||
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
|
||||
|
||||
@@ -373,7 +486,7 @@ jobs:
|
||||
with:
|
||||
node-version: 24
|
||||
cache: npm
|
||||
cache-dependency-path: Client/tauri-client/package-lock.json
|
||||
cache-dependency-path: Client/package-lock.json
|
||||
|
||||
- name: Install npm dependencies
|
||||
run: npm ci
|
||||
@@ -390,8 +503,8 @@ jobs:
|
||||
with:
|
||||
name: playwright-report-parity
|
||||
path: |
|
||||
Client/tauri-client/playwright-report/
|
||||
Client/tauri-client/test-results/
|
||||
Client/playwright-report/
|
||||
Client/test-results/
|
||||
retention-days: 7
|
||||
|
||||
# Image build is verification only, so it is skipped on dev to keep day-to-day
|
||||
@@ -457,7 +570,7 @@ jobs:
|
||||
runs-on: ${{ matrix.os }}
|
||||
defaults:
|
||||
run:
|
||||
working-directory: Client/tauri-client/
|
||||
working-directory: Client/
|
||||
steps:
|
||||
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
|
||||
|
||||
@@ -465,7 +578,7 @@ jobs:
|
||||
with:
|
||||
node-version: 24
|
||||
cache: npm
|
||||
cache-dependency-path: Client/tauri-client/package-lock.json
|
||||
cache-dependency-path: Client/package-lock.json
|
||||
|
||||
- name: Install Linux system dependencies
|
||||
if: startsWith(matrix.os, 'ubuntu')
|
||||
@@ -491,20 +604,20 @@ jobs:
|
||||
- name: Rust cache
|
||||
uses: swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
|
||||
with:
|
||||
workspaces: Client/tauri-client/src-tauri
|
||||
workspaces: Client/src-tauri
|
||||
|
||||
- name: Install npm dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Clippy lint (Rust)
|
||||
working-directory: Client/tauri-client/src-tauri/
|
||||
working-directory: Client/src-tauri/
|
||||
run: cargo clippy -- -D warnings
|
||||
|
||||
# Rust unit tests moved to the standalone `rust-tests` job so they run on
|
||||
# every event, not just PRs to main.
|
||||
|
||||
- name: Security audit (Rust dependencies)
|
||||
working-directory: Client/tauri-client/src-tauri/
|
||||
working-directory: Client/src-tauri/
|
||||
run: |
|
||||
cargo install cargo-audit@0.22.1 --quiet
|
||||
cargo audit
|
||||
|
||||
@@ -10,14 +10,41 @@ on:
|
||||
pull_request_review:
|
||||
types: [submitted]
|
||||
|
||||
# Repeated triggers on one issue or pull request collapse into a single run
|
||||
# rather than fanning out. `github.event.issue.number` is present on the issues
|
||||
# and issue_comment events; `github.event.pull_request.number` on the two review
|
||||
# events. Exactly one of the two is non-empty per event, so the group is stable.
|
||||
concurrency:
|
||||
group: claude-${{ github.event.issue.number || github.event.pull_request.number }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
claude:
|
||||
# Two independent conditions, both required.
|
||||
#
|
||||
# 1. The actor is on the maintainer allowlist. This workflow consumes a
|
||||
# metered API credential, so the repository states its own trust boundary
|
||||
# here rather than relying on any downstream check. Add a login to this
|
||||
# list to grant access; there is no other way in.
|
||||
# 2. The trigger text mentions @claude.
|
||||
#
|
||||
# scripts/check-workflow-guards.mjs asserts that both this actor term and the
|
||||
# cost bounds below survive; actionlint checks expression syntax and cannot
|
||||
# see authorization intent.
|
||||
if: |
|
||||
(github.event_name == 'issue_comment' && contains(github.event.comment.body, '@claude')) ||
|
||||
(github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '@claude')) ||
|
||||
(github.event_name == 'pull_request_review' && contains(github.event.review.body, '@claude')) ||
|
||||
(github.event_name == 'issues' && (contains(github.event.issue.body, '@claude') || contains(github.event.issue.title, '@claude')))
|
||||
contains(fromJSON('["J3vb"]'), github.actor) &&
|
||||
(
|
||||
(github.event_name == 'issue_comment' && contains(github.event.comment.body, '@claude')) ||
|
||||
(github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '@claude')) ||
|
||||
(github.event_name == 'pull_request_review' && contains(github.event.review.body, '@claude')) ||
|
||||
(github.event_name == 'issues' && (contains(github.event.issue.body, '@claude') || contains(github.event.issue.title, '@claude')))
|
||||
)
|
||||
runs-on: ubuntu-latest
|
||||
# Every other long-running job in this repository declares a cap
|
||||
# (ci.yml rust-tests, client-e2e, admin-e2e, client-e2e-parity;
|
||||
# load-baseline). Without one the job inherits GitHub's 360-minute default,
|
||||
# which is the wrong ceiling for metered work.
|
||||
timeout-minutes: 30
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
@@ -47,4 +74,3 @@ jobs:
|
||||
# See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md
|
||||
# or https://code.claude.com/docs/en/cli-reference for available options
|
||||
# claude_args: '--allowed-tools Bash(gh pr:*)'
|
||||
|
||||
|
||||
@@ -67,19 +67,28 @@ jobs:
|
||||
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; }
|
||||
if [ -z "$TOKEN" ] || [ "$TOKEN" = "null" ]; then
|
||||
echo "::error::setup failed"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
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; }
|
||||
if [ -z "$CHANNEL_ID" ] || [ "$CHANNEL_ID" = "null" ]; then
|
||||
echo "::error::channel create failed"
|
||||
exit 1
|
||||
fi
|
||||
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; }
|
||||
if [ -z "$INVITE" ] || [ "$INVITE" = "null" ]; then
|
||||
echo "::error::invite create failed"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
USERS="${{ inputs.users }}"
|
||||
for i in $(seq 1 "${USERS:-100}"); do
|
||||
|
||||
@@ -15,12 +15,47 @@ concurrency:
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
# R-09 / RL-16. ci.yml has no `tags:` trigger, so a tag push starts this
|
||||
# workflow and nothing else — and this workflow re-runs none of the required
|
||||
# checks. It builds, smokes and signs, which is a different question from
|
||||
# "did the gate pass on this commit".
|
||||
#
|
||||
# It did not, at least once: v1.2.0-alpha.3 published from a commit whose
|
||||
# `Server Build & Test (windows-latest)` had concluded failure. Nothing
|
||||
# noticed, because nothing looked.
|
||||
#
|
||||
# The required set is read out of b0-dev-branch-protection.sh rather than
|
||||
# restated here, so pinning a new check cannot leave this gate behind. The
|
||||
# logic lives in a script with a --selftest that ci.yml runs on every PR:
|
||||
# a step that exists only in this file first executes at tag time, which is
|
||||
# the wrong place to discover its bugs.
|
||||
gate-evidence:
|
||||
name: Verify exact-SHA gate evidence
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
checks: read
|
||||
steps:
|
||||
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
|
||||
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||
with:
|
||||
node-version: 24
|
||||
- name: Required checks must be green on the tagged commit
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
GITHUB_REPOSITORY: ${{ github.repository }}
|
||||
run: node scripts/verify-gate-evidence.mjs "${{ github.sha }}"
|
||||
|
||||
# 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
|
||||
# clients then never saw the update. Fail fast on that mismatch, before any
|
||||
# expensive build starts.
|
||||
verify-versions:
|
||||
name: Verify client version matches tag
|
||||
# Every build job needs verify-versions, and both publishers need those, so
|
||||
# one edge here gates the whole graph — nothing builds, pushes to GHCR, or
|
||||
# creates a Release on a commit that did not pass.
|
||||
needs: gate-evidence
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -30,9 +65,9 @@ jobs:
|
||||
shell: bash
|
||||
run: |
|
||||
TAG_VERSION="${GITHUB_REF_NAME#v}"
|
||||
TAURI_VERSION=$(node -p "require('./Client/tauri-client/src-tauri/tauri.conf.json').version")
|
||||
NPM_VERSION=$(node -p "require('./Client/tauri-client/package.json').version")
|
||||
CARGO_VERSION=$(sed -n 's/^version = "\(.*\)"$/\1/p' Client/tauri-client/src-tauri/Cargo.toml | head -1)
|
||||
TAURI_VERSION=$(node -p "require('./Client/src-tauri/tauri.conf.json').version")
|
||||
NPM_VERSION=$(node -p "require('./Client/package.json').version")
|
||||
CARGO_VERSION=$(sed -n 's/^version = "\(.*\)"$/\1/p' Client/src-tauri/Cargo.toml | head -1)
|
||||
fail=0
|
||||
for pair in "tauri.conf.json:$TAURI_VERSION" "package.json:$NPM_VERSION" "Cargo.toml:$CARGO_VERSION"; do
|
||||
file="${pair%%:*}"; ver="${pair#*:}"
|
||||
@@ -56,7 +91,7 @@ jobs:
|
||||
with:
|
||||
node-version: 24
|
||||
cache: npm
|
||||
cache-dependency-path: Client/tauri-client/package-lock.json
|
||||
cache-dependency-path: Client/package-lock.json
|
||||
|
||||
- name: Install Rust
|
||||
uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable
|
||||
@@ -64,14 +99,14 @@ jobs:
|
||||
- name: Rust cache
|
||||
uses: swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
|
||||
with:
|
||||
workspaces: Client/tauri-client/src-tauri
|
||||
workspaces: Client/src-tauri
|
||||
|
||||
- name: Install npm dependencies
|
||||
working-directory: Client/tauri-client
|
||||
working-directory: Client
|
||||
run: npm ci
|
||||
|
||||
- name: Build Tauri app
|
||||
working-directory: Client/tauri-client
|
||||
working-directory: Client
|
||||
env:
|
||||
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
|
||||
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
|
||||
@@ -81,7 +116,7 @@ jobs:
|
||||
shell: bash
|
||||
run: |
|
||||
mkdir -p release-staging
|
||||
NSIS_DIR="Client/tauri-client/src-tauri/target/release/bundle/nsis"
|
||||
NSIS_DIR="Client/src-tauri/target/release/bundle/nsis"
|
||||
INSTALLER=$(find "$NSIS_DIR" -name "*.exe" | head -1)
|
||||
cp "$INSTALLER" release-staging/
|
||||
NSIS_ZIP=$(find "$NSIS_DIR" -name "*_x64-setup.nsis.zip" ! -name "*.sig" | head -1)
|
||||
@@ -108,7 +143,7 @@ jobs:
|
||||
with:
|
||||
node-version: 24
|
||||
cache: npm
|
||||
cache-dependency-path: Client/tauri-client/package-lock.json
|
||||
cache-dependency-path: Client/package-lock.json
|
||||
|
||||
- name: Install Linux system dependencies
|
||||
run: |
|
||||
@@ -131,14 +166,14 @@ jobs:
|
||||
- name: Rust cache
|
||||
uses: swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
|
||||
with:
|
||||
workspaces: Client/tauri-client/src-tauri
|
||||
workspaces: Client/src-tauri
|
||||
|
||||
- name: Install npm dependencies
|
||||
working-directory: Client/tauri-client
|
||||
working-directory: Client
|
||||
run: npm ci
|
||||
|
||||
- name: Build Tauri app (AppImage + deb)
|
||||
working-directory: Client/tauri-client
|
||||
working-directory: Client
|
||||
env:
|
||||
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
|
||||
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
|
||||
@@ -149,7 +184,7 @@ jobs:
|
||||
# EGL_BAD_PARAMETER). Strip them and regenerate the updater artifact +
|
||||
# signatures for the patched image.
|
||||
- name: Strip host-incompatible libs from AppImage and re-sign
|
||||
working-directory: Client/tauri-client
|
||||
working-directory: Client
|
||||
shell: bash
|
||||
env:
|
||||
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
|
||||
@@ -173,7 +208,7 @@ jobs:
|
||||
shell: bash
|
||||
run: |
|
||||
mkdir -p linux-staging
|
||||
BUNDLE_DIR="Client/tauri-client/src-tauri/target/release/bundle"
|
||||
BUNDLE_DIR="Client/src-tauri/target/release/bundle"
|
||||
# AppImage
|
||||
APPIMAGE=$(find "$BUNDLE_DIR/appimage" -name "*.AppImage" ! -name "*.sig" | head -1)
|
||||
if [ -n "$APPIMAGE" ] && [ -f "$APPIMAGE" ]; then cp "$APPIMAGE" linux-staging/; fi
|
||||
@@ -298,7 +333,7 @@ jobs:
|
||||
with:
|
||||
node-version: 24
|
||||
cache: npm
|
||||
cache-dependency-path: Client/tauri-client/package-lock.json
|
||||
cache-dependency-path: Client/package-lock.json
|
||||
|
||||
- name: Install Linux system dependencies
|
||||
run: |
|
||||
@@ -321,14 +356,14 @@ jobs:
|
||||
- name: Rust cache
|
||||
uses: swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
|
||||
with:
|
||||
workspaces: Client/tauri-client/src-tauri
|
||||
workspaces: Client/src-tauri
|
||||
|
||||
- name: Install npm dependencies
|
||||
working-directory: Client/tauri-client
|
||||
working-directory: Client
|
||||
run: npm ci
|
||||
|
||||
- name: Build Tauri app (AppImage + deb)
|
||||
working-directory: Client/tauri-client
|
||||
working-directory: Client
|
||||
env:
|
||||
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
|
||||
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
|
||||
@@ -336,7 +371,7 @@ jobs:
|
||||
|
||||
# Same strip + re-sign as the x86_64 job — see the comment there.
|
||||
- name: Strip host-incompatible libs from AppImage and re-sign
|
||||
working-directory: Client/tauri-client
|
||||
working-directory: Client
|
||||
shell: bash
|
||||
env:
|
||||
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
|
||||
@@ -360,7 +395,7 @@ jobs:
|
||||
shell: bash
|
||||
run: |
|
||||
mkdir -p linux-arm64-staging
|
||||
BUNDLE_DIR="Client/tauri-client/src-tauri/target/release/bundle"
|
||||
BUNDLE_DIR="Client/src-tauri/target/release/bundle"
|
||||
# AppImage + updater artifact (.tar.gz) + signatures. Every filename
|
||||
# must carry the arch: FindClientAssets matches on the
|
||||
# _aarch64.AppImage.tar.gz suffix, and arch-less names would collide
|
||||
@@ -454,7 +489,14 @@ jobs:
|
||||
|
||||
publish:
|
||||
name: Publish GitHub Release
|
||||
needs: [release-client-windows, release-client-linux, release-client-linux-arm64, release-server, release-server-docker]
|
||||
needs:
|
||||
[
|
||||
release-client-windows,
|
||||
release-client-linux,
|
||||
release-client-linux-arm64,
|
||||
release-server,
|
||||
release-server-docker,
|
||||
]
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
@@ -465,7 +507,7 @@ jobs:
|
||||
with:
|
||||
node-version: 24
|
||||
cache: npm
|
||||
cache-dependency-path: Client/tauri-client/package-lock.json
|
||||
cache-dependency-path: Client/package-lock.json
|
||||
|
||||
- name: Download Windows client assets
|
||||
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
|
||||
@@ -515,8 +557,8 @@ jobs:
|
||||
- name: Generate SHA256 checksums
|
||||
shell: bash
|
||||
run: |
|
||||
(cd windows && sha256sum *) > checksums.sha256
|
||||
(cd linux && sha256sum *) >> checksums.sha256
|
||||
(cd windows && sha256sum -- *) > checksums.sha256
|
||||
(cd linux && sha256sum -- *) >> checksums.sha256
|
||||
sha256sum owncord-src-*.tar.gz >> checksums.sha256
|
||||
|
||||
# The legacy top-level asset/sha256 pair stays bound to the Windows
|
||||
@@ -532,7 +574,7 @@ jobs:
|
||||
"$VERSION" "$WIN_HASH" "$WIN_HASH" "$LINUX_HASH" > windows/server-update-manifest.json
|
||||
|
||||
- name: Sign server update assets
|
||||
working-directory: Client/tauri-client
|
||||
working-directory: Client
|
||||
shell: bash
|
||||
env:
|
||||
SERVER_UPDATE_SIGNING_PRIVATE_KEY: ${{ secrets.SERVER_UPDATE_SIGNING_PRIVATE_KEY }}
|
||||
@@ -542,8 +584,8 @@ jobs:
|
||||
printf '%s' "$SERVER_UPDATE_SIGNING_PRIVATE_KEY" > "$KEY_PATH"
|
||||
trap 'rm -f "$KEY_PATH"' EXIT
|
||||
npm ci
|
||||
npx tauri signer sign -f "$KEY_PATH" -p "$SERVER_UPDATE_SIGNING_PRIVATE_KEY_PASSWORD" ../../windows/chatserver.exe
|
||||
npx tauri signer sign -f "$KEY_PATH" -p "$SERVER_UPDATE_SIGNING_PRIVATE_KEY_PASSWORD" ../../windows/server-update-manifest.json
|
||||
npx tauri signer sign -f "$KEY_PATH" -p "$SERVER_UPDATE_SIGNING_PRIVATE_KEY_PASSWORD" ../windows/chatserver.exe
|
||||
npx tauri signer sign -f "$KEY_PATH" -p "$SERVER_UPDATE_SIGNING_PRIVATE_KEY_PASSWORD" ../windows/server-update-manifest.json
|
||||
|
||||
# Fail closed before publishing: prove the freshly signed assets verify
|
||||
# against the pinned public key that ships inside the server binary.
|
||||
|
||||
@@ -28,10 +28,16 @@ docs/research/
|
||||
docs/superpowers/
|
||||
/skills/
|
||||
|
||||
# Detailed security reports for findings that are not yet fixed. This repo is
|
||||
# public (docs/security.md): reproduction traces for a live defect must never
|
||||
# be committed. Findings are coordinated through private GitHub Security
|
||||
# Advisories; only opaque identifiers and safe status go in tracked plans.
|
||||
docs/security-findings/
|
||||
|
||||
# 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/
|
||||
Client/.stryker-tmp/
|
||||
Client/reports/
|
||||
|
||||
# Server runtime artifacts
|
||||
Server/chatserver.exe
|
||||
@@ -41,6 +47,17 @@ Server/server.exe
|
||||
Server/config.yaml
|
||||
Server/data/
|
||||
|
||||
# Prebuilt plugin example (RL-08). Built from the main.go beside it with the
|
||||
# TinyGo toolchain that directory's README pins. Read by nothing in the build
|
||||
# or test graph, and not byte-reproducible on another machine: TinyGo embeds
|
||||
# absolute host paths from the building machine's Go SDK and module cache, and
|
||||
# has no -trimpath equivalent.
|
||||
#
|
||||
# Deliberately NOT a blanket *.wasm rule. Client/public/rnnoise.wasm is a
|
||||
# vendored npm artifact this repository does not build and the client fetches
|
||||
# at runtime; ignoring it would break voice noise suppression.
|
||||
Server/plugin/examples/hello/hello.wasm
|
||||
|
||||
# Test coverage artifacts
|
||||
*.out
|
||||
Server/cov.out
|
||||
@@ -59,7 +76,7 @@ Client/login-mockup.html
|
||||
Client/ui-mockup.html
|
||||
|
||||
# Tauri typegen (auto-generated IPC bindings)
|
||||
Client/tauri-client/src/generated/
|
||||
Client/src/generated/
|
||||
.typecache
|
||||
|
||||
# Node modules
|
||||
@@ -70,12 +87,16 @@ node_modules/
|
||||
.claude-flow/
|
||||
.rust-review-results/
|
||||
|
||||
# Bug-hunt ledger: shared so contributors can add findings. Only the ledger,
|
||||
# its render and its validator are tracked; hunt transcripts, .bak snapshots
|
||||
# and debris patches are per-session scratch and stay local.
|
||||
# Bug-hunt ledger: shared so contributors can add findings. Only the ledger and
|
||||
# its renderer are tracked; hunt transcripts, .bak snapshots and debris patches
|
||||
# are per-session scratch and stay local.
|
||||
#
|
||||
# FINDINGS.md is deliberately NOT tracked (RL-07): it is 100% derived from
|
||||
# findings-ledger.json, and every hunt would otherwise write a fresh ~1.06 MB
|
||||
# blob into permanent history for a file a reader can regenerate in under a
|
||||
# second with `node .superpowers/render-ledger.mjs`.
|
||||
.superpowers/*
|
||||
!.superpowers/findings-ledger.json
|
||||
!.superpowers/FINDINGS.md
|
||||
!.superpowers/render-ledger.mjs
|
||||
|
||||
.claude/worktrees/
|
||||
@@ -97,7 +118,7 @@ Client/CLIENT-REVIEW.md
|
||||
.serena/
|
||||
|
||||
# Client env (holds API keys - never commit)
|
||||
Client/tauri-client/.env
|
||||
Client/.env
|
||||
|
||||
# Rust review output
|
||||
.rust-review-results/
|
||||
@@ -108,12 +129,8 @@ Client/tauri-client/.env
|
||||
# local server run logs
|
||||
server.log
|
||||
|
||||
# Knowledge graph (graphify). The top-level built graph is tracked so a fresh
|
||||
# clone can query it without a rebuild. Every subdirectory stays local: cache/
|
||||
# is a per-machine AST build cache, and graphify parks the PREVIOUS graph in a
|
||||
# dated YYYY-MM-DD/ backup on each rebuild — 18 MB of stale duplicate that is a
|
||||
# local rollback aid, not shared state.
|
||||
graphify-out/*/
|
||||
|
||||
# Transient graphify rebuild-state file.
|
||||
graphify-out/.pending_changes
|
||||
# Knowledge-graph output. The tool and its 20.41 MB tracked payload were removed
|
||||
# in a5f7d95 (#1413, RL-06). The rule stays so a machine that still has the local
|
||||
# directory — it reached ~208 MB with cache and dated snapshots — does not see it
|
||||
# as untracked noise.
|
||||
graphify-out/
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
# engine-strict makes the engines block in package.json a hard error rather
|
||||
# than an npm warning. npm reads the project .npmrc from the package
|
||||
# directory and does not walk parent directories, so this file has to exist
|
||||
# in every package root or the gate silently downgrades to a warning there.
|
||||
engine-strict=true
|
||||
@@ -0,0 +1,40 @@
|
||||
# Prettier 3 reads .gitignore by default, so everything ignored there —
|
||||
# node_modules/, dist/, coverage/, Client/src/generated/, docs/security-findings/ —
|
||||
# is already excluded. Only tracked files need entries here.
|
||||
|
||||
|
||||
# Generated, verified by `git diff --exit-code` after regeneration.
|
||||
Server/db/dbgen/
|
||||
Client/src/lib/protocolTypes.ts
|
||||
|
||||
|
||||
# Dated point-in-time snapshots. scripts/check-doc-counts.mjs already treats
|
||||
# these as deliberately unmaintained and out of scope to edit; reformatting
|
||||
# them would churn frozen records for no reader.
|
||||
docs/audit-*.md
|
||||
|
||||
# Carried forward from the client's own ignore file — a deliberate exclusion,
|
||||
# not an oversight.
|
||||
*.html
|
||||
|
||||
# Session scratch from the remember plugin. Gitignored by a nested
|
||||
# .remember/.gitignore, which Prettier does not read — it honours only the root
|
||||
# .gitignore. Untracked and per-machine: a contributor's scratch directory must
|
||||
# never be able to turn a shared gate red.
|
||||
.remember/
|
||||
**/.remember/
|
||||
|
||||
# Build output and per-tool scratch. Every path below is gitignored -- but by a
|
||||
# NESTED .gitignore, and Prettier honours only the root one. Without these
|
||||
# entries the gate goes red the moment a contributor runs a build: `cargo test`
|
||||
# alone drops ~850 formattable files into src-tauri/target/.
|
||||
# Mirrors Client/.gitignore, .serena/.gitignore and .superpowers/sdd/.gitignore.
|
||||
Client/dist/
|
||||
Client/coverage/
|
||||
Client/playwright-report/
|
||||
Client/test-results/
|
||||
Client/.vite/
|
||||
Client/src-tauri/target/
|
||||
Client/src-tauri/gen/
|
||||
.serena/
|
||||
.superpowers/sdd/
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"singleQuote": false,
|
||||
"semi": true,
|
||||
"trailingComma": "all",
|
||||
"printWidth": 100,
|
||||
"tabWidth": 2,
|
||||
"arrowParens": "always",
|
||||
"endOfLine": "lf"
|
||||
}
|
||||
@@ -2,102 +2,151 @@
|
||||
// Run: node .superpowers/render-ledger.mjs # write FINDINGS.md
|
||||
// node .superpowers/render-ledger.mjs --check # validate only
|
||||
// node .superpowers/render-ledger.mjs --selftest # run built-in tests
|
||||
import assert from 'node:assert/strict'
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
const VALID_STATUS = ['open', 'fixed', 'declined', 'refuted', 'duplicate', 'blocked']
|
||||
const VALID_STATUS = ["open", "fixed", "declined", "refuted", "duplicate", "blocked"];
|
||||
|
||||
// Must stay in lockstep with SEV_RANK below. render() sorts the open section by
|
||||
// SEV_RANK, and an unranked severity makes the comparator return NaN — which
|
||||
// leaves the sort order implementation-defined, so the rendering would stop
|
||||
// being a pure function of the ledger. The drift gate compares the rendering
|
||||
// against the ledger, so its whole premise rests on this being enforced.
|
||||
const VALID_SEVERITY = ["critical", "high", "medium", "low"];
|
||||
|
||||
export function validate(ledger) {
|
||||
const problems = []
|
||||
const ids = new Set()
|
||||
const problems = [];
|
||||
const ids = new Set();
|
||||
for (const r of ledger.findings) {
|
||||
if (ids.has(r.id)) problems.push(`duplicate id ${r.id}`)
|
||||
ids.add(r.id)
|
||||
if (!/^OC-\d{4}$/.test(r.id)) problems.push(`${r.id}: malformed id`)
|
||||
if (!VALID_STATUS.includes(r.status)) problems.push(`${r.id}: bad status ${r.status}`)
|
||||
if (r.status === 'fixed' && (!r.fix || !r.fix.commit)) problems.push(`${r.id}: fixed without a commit`)
|
||||
if (r.status === 'declined' && !r.rationale) problems.push(`${r.id}: declined without a rationale`)
|
||||
if (r.status === 'duplicate' && !r.duplicateOf) problems.push(`${r.id}: duplicate without duplicateOf`)
|
||||
if (ids.has(r.id)) problems.push(`duplicate id ${r.id}`);
|
||||
ids.add(r.id);
|
||||
if (!/^OC-\d{4}$/.test(r.id)) problems.push(`${r.id}: malformed id`);
|
||||
if (!VALID_STATUS.includes(r.status)) problems.push(`${r.id}: bad status ${r.status}`);
|
||||
if (!VALID_SEVERITY.includes(r.severity)) problems.push(`${r.id}: bad severity ${r.severity}`);
|
||||
if (r.status === "fixed" && (!r.fix || !r.fix.commit))
|
||||
problems.push(`${r.id}: fixed without a commit`);
|
||||
if (r.status === "declined" && !r.rationale)
|
||||
problems.push(`${r.id}: declined without a rationale`);
|
||||
if (r.status === "duplicate" && !r.duplicateOf)
|
||||
problems.push(`${r.id}: duplicate without duplicateOf`);
|
||||
}
|
||||
return problems
|
||||
return problems;
|
||||
}
|
||||
|
||||
function selftest() {
|
||||
assert.deepEqual(validate({ findings: [] }), [])
|
||||
// Every fixture carries a severity: validate() now requires one, so omitting
|
||||
// it would make each case report two problems and assert against the wrong one.
|
||||
assert.deepEqual(validate({ findings: [] }), []);
|
||||
assert.deepEqual(
|
||||
validate({ findings: [{ id: 'OC-0001', status: 'fixed', fix: null }] }),
|
||||
['OC-0001: fixed without a commit'],
|
||||
)
|
||||
assert.deepEqual(validate({ findings: [{ id: 'bad', status: 'open' }] }), ['bad: malformed id'])
|
||||
validate({ findings: [{ id: "OC-0001", severity: "low", status: "fixed", fix: null }] }),
|
||||
["OC-0001: fixed without a commit"],
|
||||
);
|
||||
assert.deepEqual(validate({ findings: [{ id: "bad", severity: "low", status: "open" }] }), [
|
||||
"bad: malformed id",
|
||||
]);
|
||||
assert.deepEqual(
|
||||
validate({ findings: [{ id: 'OC-0001', status: 'open' }, { id: 'OC-0001', status: 'open' }] }),
|
||||
['duplicate id OC-0001'],
|
||||
)
|
||||
assert.deepEqual(validate({ findings: [{ id: 'OC-0002', status: 'declined' }] }), ['OC-0002: declined without a rationale'])
|
||||
console.log('selftest: all assertions pass')
|
||||
validate({
|
||||
findings: [
|
||||
{ id: "OC-0001", severity: "low", status: "open" },
|
||||
{ id: "OC-0001", severity: "low", status: "open" },
|
||||
],
|
||||
}),
|
||||
["duplicate id OC-0001"],
|
||||
);
|
||||
assert.deepEqual(
|
||||
validate({ findings: [{ id: "OC-0002", severity: "low", status: "declined" }] }),
|
||||
["OC-0002: declined without a rationale"],
|
||||
);
|
||||
|
||||
// An unranked severity is what makes render()'s sort implementation-defined.
|
||||
assert.deepEqual(
|
||||
validate({ findings: [{ id: "OC-0003", severity: "moderate", status: "open" }] }),
|
||||
["OC-0003: bad severity moderate"],
|
||||
);
|
||||
assert.deepEqual(validate({ findings: [{ id: "OC-0004", status: "open" }] }), [
|
||||
"OC-0004: bad severity undefined",
|
||||
]);
|
||||
for (const sev of VALID_SEVERITY) {
|
||||
assert.deepEqual(
|
||||
validate({ findings: [{ id: "OC-0005", severity: sev, status: "open" }] }),
|
||||
[],
|
||||
);
|
||||
}
|
||||
console.log("selftest: all assertions pass");
|
||||
}
|
||||
|
||||
const SEV_RANK = { critical: 0, high: 1, medium: 2, low: 3 }
|
||||
const SEV_RANK = { critical: 0, high: 1, medium: 2, low: 3 };
|
||||
|
||||
export function render(ledger) {
|
||||
const by = (s) => ledger.findings.filter((f) => f.status === s)
|
||||
const open = by('open').sort((a, b) => SEV_RANK[a.severity] - SEV_RANK[b.severity])
|
||||
const blocked = by('blocked')
|
||||
const fixed = by('fixed')
|
||||
const declined = by('declined')
|
||||
const refuted = by('refuted')
|
||||
const dup = by('duplicate')
|
||||
const by = (s) => ledger.findings.filter((f) => f.status === s);
|
||||
const open = by("open").sort((a, b) => SEV_RANK[a.severity] - SEV_RANK[b.severity]);
|
||||
const blocked = by("blocked");
|
||||
const fixed = by("fixed");
|
||||
const declined = by("declined");
|
||||
const refuted = by("refuted");
|
||||
const dup = by("duplicate");
|
||||
|
||||
const lines = []
|
||||
lines.push('# OwnCord Findings Ledger', '')
|
||||
lines.push('Generated by `render-ledger.mjs`. Do not hand-edit — edit `findings-ledger.json`.', '')
|
||||
const lines = [];
|
||||
lines.push("# OwnCord Findings Ledger", "");
|
||||
lines.push(
|
||||
"Generated by `render-ledger.mjs`. Do not hand-edit — edit `findings-ledger.json`.",
|
||||
"",
|
||||
);
|
||||
lines.push(
|
||||
`**${open.length} open** · ${blocked.length} blocked · ${fixed.length} fixed · ` +
|
||||
`${declined.length} declined · ${refuted.length} refuted · ${dup.length} duplicate`,
|
||||
'',
|
||||
)
|
||||
"",
|
||||
);
|
||||
|
||||
const section = (title, rows, extra) => {
|
||||
if (!rows.length) return
|
||||
lines.push(`## ${title}`, '')
|
||||
if (!rows.length) return;
|
||||
lines.push(`## ${title}`, "");
|
||||
for (const r of rows) {
|
||||
lines.push(`### ${r.id} — ${r.severity} — ${r.title}`, '')
|
||||
lines.push(`\`${r.file}:${r.line}\` · found ${r.found} · hunt \`${r.hunt}\` · lens \`${r.lens}\``, '')
|
||||
if (r.why) lines.push(r.why, '')
|
||||
if (r.repro) lines.push(`**Repro:** ${r.repro}`, '')
|
||||
if (r.evidence) lines.push(`**Evidence:** ${r.evidence}`, '')
|
||||
if (r.suggestedFix) lines.push(`**Suggested fix:** ${r.suggestedFix}`, '')
|
||||
const e = extra && extra(r)
|
||||
if (e) lines.push(e, '')
|
||||
lines.push(`### ${r.id} — ${r.severity} — ${r.title}`, "");
|
||||
lines.push(
|
||||
`\`${r.file}:${r.line}\` · found ${r.found} · hunt \`${r.hunt}\` · lens \`${r.lens}\``,
|
||||
"",
|
||||
);
|
||||
if (r.why) lines.push(r.why, "");
|
||||
if (r.repro) lines.push(`**Repro:** ${r.repro}`, "");
|
||||
if (r.evidence) lines.push(`**Evidence:** ${r.evidence}`, "");
|
||||
if (r.suggestedFix) lines.push(`**Suggested fix:** ${r.suggestedFix}`, "");
|
||||
const e = extra && extra(r);
|
||||
if (e) lines.push(e, "");
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
section('Open', open)
|
||||
section('Blocked — fix attempted, revert-proof failed', blocked)
|
||||
section('Fixed', fixed, (r) => `**Fixed:** \`${r.fix.commit}\` · test \`${r.fix.test}\` · revert-proof ${r.fix.revertProof}`)
|
||||
section('Declined', declined, (r) => `**Declined:** ${r.rationale}`)
|
||||
section('Refuted', refuted)
|
||||
section('Duplicate', dup, (r) => `**Duplicate of** ${r.duplicateOf}`)
|
||||
return lines.join('\n')
|
||||
section("Open", open);
|
||||
section("Blocked — fix attempted, revert-proof failed", blocked);
|
||||
section(
|
||||
"Fixed",
|
||||
fixed,
|
||||
(r) =>
|
||||
`**Fixed:** \`${r.fix.commit}\` · test \`${r.fix.test}\` · revert-proof ${r.fix.revertProof}`,
|
||||
);
|
||||
section("Declined", declined, (r) => `**Declined:** ${r.rationale}`);
|
||||
section("Refuted", refuted);
|
||||
section("Duplicate", dup, (r) => `**Duplicate of** ${r.duplicateOf}`);
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const { readFileSync, writeFileSync } = await import('node:fs')
|
||||
const { dirname, join } = await import('node:path')
|
||||
const { fileURLToPath } = await import('node:url')
|
||||
const here = dirname(fileURLToPath(import.meta.url))
|
||||
const ledger = JSON.parse(readFileSync(join(here, 'findings-ledger.json'), 'utf8'))
|
||||
const problems = validate(ledger)
|
||||
const { readFileSync, writeFileSync } = await import("node:fs");
|
||||
const { dirname, join } = await import("node:path");
|
||||
const { fileURLToPath } = await import("node:url");
|
||||
const here = dirname(fileURLToPath(import.meta.url));
|
||||
const ledger = JSON.parse(readFileSync(join(here, "findings-ledger.json"), "utf8"));
|
||||
const problems = validate(ledger);
|
||||
if (problems.length) {
|
||||
for (const p of problems) console.error(`INVALID ${p}`)
|
||||
process.exit(1)
|
||||
for (const p of problems) console.error(`INVALID ${p}`);
|
||||
process.exit(1);
|
||||
}
|
||||
if (process.argv.includes('--check')) {
|
||||
console.log(`ledger valid: ${ledger.findings.length} finding(s)`)
|
||||
return
|
||||
if (process.argv.includes("--check")) {
|
||||
console.log(`ledger valid: ${ledger.findings.length} finding(s)`);
|
||||
return;
|
||||
}
|
||||
writeFileSync(join(here, 'FINDINGS.md'), render(ledger) + '\n')
|
||||
console.log(`wrote FINDINGS.md (${ledger.findings.length} finding(s))`)
|
||||
writeFileSync(join(here, "FINDINGS.md"), render(ledger) + "\n");
|
||||
console.log(`wrote FINDINGS.md (${ledger.findings.length} finding(s))`);
|
||||
}
|
||||
|
||||
if (process.argv.includes('--selftest')) selftest()
|
||||
else await main()
|
||||
if (process.argv.includes("--selftest")) selftest();
|
||||
else await main();
|
||||
|
||||
@@ -5,6 +5,114 @@ 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.
|
||||
|
||||
## How to write an entry
|
||||
|
||||
**Scannable lists, never walls of text.** A reader should be able to find what
|
||||
affects them in about ten seconds, without reading a paragraph they do not care
|
||||
about. Entries below `v1.2.0-alpha.3` do not follow this and are left as
|
||||
shipped history; everything from the next release forward does.
|
||||
|
||||
The rules:
|
||||
|
||||
1. **Open with what is user-visible and what is not.** Most releases carry a
|
||||
mixture. Say which is which up front, so nobody reads twenty lines of
|
||||
repository plumbing looking for a fix.
|
||||
2. **Group by the area a user recognises** — Login & connection, Voice,
|
||||
Mentions, Messages & files, Accounts & admin, Desktop UI. Not by subsystem,
|
||||
package, or which PR it came from.
|
||||
3. **One line per fix.** If it needs two lines, it needs two entries or it does
|
||||
not belong here.
|
||||
4. **Say what was broken, then what it does now.** "Banned users could still
|
||||
connect — ban is re-checked on connect." A reader must be able to tell
|
||||
whether it bit them, without opening the PR.
|
||||
5. **Plain language.** Name the thing a user sees, not the function that owned
|
||||
the bug. `voiceJoinLeaveCurrent` means nothing to an operator; "moderator
|
||||
mute survives a channel move" does.
|
||||
6. **No `OC-*` ids, no file paths, no PR-body prose.** The ledger and the pull
|
||||
request already carry those, and this file is the one place that does not
|
||||
need them. A PR number is fine where it genuinely helps someone dig.
|
||||
7. **Counts belong in a summary line, not per item.** "62 fixes" once at the
|
||||
top beats a number attached to every bullet.
|
||||
|
||||
Anything a user cannot observe — repository layout, CI gates, generated-code
|
||||
ownership, dependency automation — gets **at most a short block at the end**,
|
||||
and only when it changes something a contributor or fork holder must do
|
||||
(a moved directory, a renamed module, a new required command).
|
||||
|
||||
## v1.2.0-alpha.4
|
||||
|
||||
**62 bug fixes**, all user-visible, plus repository work that changes nothing an
|
||||
operator can see. Fixes first; the repository half is the short block at the end.
|
||||
|
||||
### Login & connection
|
||||
|
||||
- Connecting with a failed role lookup silently made you a plain **member** — it
|
||||
now fails closed instead of guessing.
|
||||
- **Banned users could still connect.** Ban status is re-checked on connect.
|
||||
- Reconnecting left a **phantom voice E2EE key holder** and a stale voice-channel
|
||||
marker behind.
|
||||
- Typing indicators in DMs could **disconnect you** under load.
|
||||
|
||||
### Voice
|
||||
|
||||
- Moderator mute and deafen are **preserved across a channel move** — they were
|
||||
silently dropped.
|
||||
- Voice E2EE keys **re-sync on reconnect**, and a departed peer's key is always
|
||||
retired so a replayed announce cannot overwrite a fresh one.
|
||||
- A kicked client no longer receives frames.
|
||||
- A rolled-back join now reaches everyone present, including people without
|
||||
permission to read the channel.
|
||||
- A **failed microphone unmute now shows as failed** instead of quietly
|
||||
reporting you as unmuted.
|
||||
- Noise suppression rebuilds correctly after a microphone restart.
|
||||
|
||||
### Mentions
|
||||
|
||||
- **`@here` no longer behaves like `@everyone`** — the two are distinguished.
|
||||
- Mention badges are reversed on delete, purge and account deletion, and can no
|
||||
longer be reversed twice.
|
||||
|
||||
### Messages & files
|
||||
|
||||
- Deleting a message now **actually deletes its attachment files**.
|
||||
- A failed avatar upload no longer deletes a committed file's reference.
|
||||
|
||||
### Accounts & admin
|
||||
|
||||
- The `require_2fa` enrollment gate misfired after a temporary ban lapsed, and
|
||||
applied its precondition to unrelated settings.
|
||||
- A DM partner with no live connection now shows **offline everywhere** — it was
|
||||
inconsistent between views.
|
||||
- Plugin installation rolls back properly when it fails.
|
||||
- The diagnostics endpoint honours trusted proxies.
|
||||
|
||||
### Desktop app
|
||||
|
||||
- Fixed event-listener leaks in the message list, member list, emoji picker,
|
||||
quick switcher, sidebar popovers and drag-reorder.
|
||||
- Recent emoji, channel mutes and custom status are now **per-server** instead of
|
||||
bleeding between servers.
|
||||
- The DM sidebar filter survives updates, the call button cannot redial, the
|
||||
incoming-call banner uses nicknames, and Ctrl+I unwraps correctly on bold text.
|
||||
|
||||
### Repository — no runtime effect
|
||||
|
||||
Phases B0 and B1 of the
|
||||
[repository-health roadmap](docs/plans/repo-health-roadmap-2026-08-23.md).
|
||||
Desktop behaviour, release asset names and the update contract are unchanged by
|
||||
design. Three items affect anyone holding a working copy or a fork:
|
||||
|
||||
- **`Client/tauri-client/` is now `Client/`** (#1411). Rebase an in-flight
|
||||
branch rather than merging across the move.
|
||||
- **The Go module is now `github.com/J3vb/OwnCord/Server`** (#1417), was
|
||||
`github.com/owncord/server`.
|
||||
- **The protocol schema is now `protocol/schema.json`** (#1417), was
|
||||
`docs/protocol-schema.json`.
|
||||
|
||||
One command runs what CI gates on, Windows and Linux, no `make` needed:
|
||||
`npm run bootstrap`, then `npm run check`. Go-only contributors still do not
|
||||
need Node.
|
||||
|
||||
## v1.2.0-alpha.3
|
||||
|
||||
- **fix:** eight bug-hunt batches closed **199 verified defects** since
|
||||
@@ -19,7 +127,7 @@ behavioural changes operators must know about.
|
||||
`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
|
||||
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
|
||||
@@ -37,18 +145,18 @@ behavioural changes operators must know about.
|
||||
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;
|
||||
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
|
||||
/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*
|
||||
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
|
||||
@@ -60,7 +168,7 @@ behavioural changes operators must know about.
|
||||
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
|
||||
_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
|
||||
@@ -79,7 +187,7 @@ behavioural changes operators must know about.
|
||||
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
|
||||
_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
|
||||
@@ -152,7 +260,7 @@ behavioural changes operators must know about.
|
||||
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
|
||||
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
|
||||
@@ -208,7 +316,7 @@ behavioural changes operators must know about.
|
||||
- **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
|
||||
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
|
||||
@@ -305,7 +413,7 @@ behavioural changes operators must know about.
|
||||
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
|
||||
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
|
||||
@@ -315,7 +423,7 @@ behavioural changes operators must know about.
|
||||
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
|
||||
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
|
||||
@@ -372,7 +480,7 @@ behavioural changes operators must know about.
|
||||
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
|
||||
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
|
||||
@@ -627,14 +735,14 @@ claimed behaviour — no product code changed and no assertion weakened.
|
||||
logged (`livekit proxy: origin rejected`) so the next such failure is
|
||||
diagnosable from the server log.
|
||||
- **API tokens can use the admin log stream.** `POST
|
||||
/admin/api/logs/ticket` required a browser login session, so headless
|
||||
/admin/api/logs/ticket` required a browser login session, so headless
|
||||
clients (the `mcp-introspect` dev tool, bots) could reach every other
|
||||
`/admin/api/*` route but not `server_logs`. Tickets are now bound to
|
||||
whichever credential authenticated the request; revoking a token cuts
|
||||
an in-flight stream, exactly as session revocation always has.
|
||||
- **The desktop client now actually uses the OS credential store.** The
|
||||
`keyring` crate declares no `default` feature, so the previous
|
||||
`keyring = "3"` dependency compiled its in-memory *mock* store on
|
||||
`keyring = "3"` dependency compiled its in-memory _mock_ store on
|
||||
Windows, macOS and Linux alike: saves reported success and the next
|
||||
read in the same process returned nothing, and no credential was ever
|
||||
written to Credential Manager / Keychain / Secret Service. The visible
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
# 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
|
||||
server over SQLite with LiveKit voice/video; `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
|
||||
lives in `Server/CLAUDE.md` and `Client/CLAUDE.md`; the protocol
|
||||
and schema are documented in `docs/protocol.md`, `docs/schema.md`, and
|
||||
`docs/architecture/README.md`.
|
||||
|
||||
@@ -11,44 +11,29 @@ and schema are documented in `docs/protocol.md`, `docs/schema.md`, and
|
||||
|
||||
CI fails on drift, and the next generator run silently discards your edit.
|
||||
|
||||
| Generated | Source of truth | Workflow |
|
||||
| --- | --- | --- |
|
||||
| `Server/db/dbgen/` | `Server/db/queries/*.sql`, `Server/migrations/` | `db-change` skill |
|
||||
| `Server/ws/message_types.go` **and** `Client/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` |
|
||||
|
||||
## Knowledge graph (graphify)
|
||||
|
||||
`graphify-out/` holds a committed knowledge graph of this repo — god nodes,
|
||||
communities, cross-file edges. It is checked in so a fresh clone can query it
|
||||
without a rebuild.
|
||||
|
||||
- For codebase questions, run `graphify query "<question>"` before grepping.
|
||||
`graphify path "<A>" "<B>"` for relationships, `graphify explain "<concept>"`
|
||||
for one concept. These return a scoped subgraph — far smaller than
|
||||
`GRAPH_REPORT.md` or raw grep output.
|
||||
- Read `graphify-out/GRAPH_REPORT.md` only for broad architecture review.
|
||||
- After changing code, `graphify update .` refreshes it (AST-only, no API cost).
|
||||
`graphify hook install` wires that to a post-commit hook — `.git/hooks/` is not
|
||||
tracked, so each clone installs it once.
|
||||
- `graphify-out/cache/` is a per-machine AST cache and stays ignored.
|
||||
|
||||
The graph is generated: never hand-edit it, and refresh it in its own commit
|
||||
rather than folding an 18 MB blob into an unrelated diff.
|
||||
| Generated | Source of truth | Workflow |
|
||||
| ---------------------------------------------------------------------- | ----------------------------------------------- | -------------------------------------------------------------- |
|
||||
| `Server/db/dbgen/` | `Server/db/queries/*.sql`, `Server/migrations/` | `db-change` skill |
|
||||
| `Server/ws/message_types.go` **and** `Client/src/lib/protocolTypes.ts` | `protocol/schema.json` | `protocol-change` skill |
|
||||
| `Client/src/generated/` | `tauri-typegen` | CI patches known typegen bugs — see `.github/workflows/ci.yml` |
|
||||
|
||||
## Bug-hunt ledger
|
||||
|
||||
`.superpowers/findings-ledger.json` is the shared ledger of hunt findings —
|
||||
open a PR against it to add one. `FINDINGS.md` is rendered from it:
|
||||
`.superpowers/findings-ledger.json` is the shared ledger of hunt findings and
|
||||
the only tracked copy — open a PR against it to add one. The readable
|
||||
`FINDINGS.md` is **not tracked**: generate it whenever you want to read one
|
||||
(gitignored, under a second, and CI uploads it as a build artifact):
|
||||
|
||||
```
|
||||
node .superpowers/render-ledger.mjs # rewrite FINDINGS.md
|
||||
node .superpowers/render-ledger.mjs # write a local FINDINGS.md
|
||||
node .superpowers/render-ledger.mjs --check # validate the ledger only
|
||||
```
|
||||
|
||||
Statuses: `open`, `fixed`, `declined`, `refuted`, `duplicate`, `blocked`.
|
||||
Never edit `FINDINGS.md` by hand — edit the ledger and re-render. Everything
|
||||
else under `.superpowers/` is per-session scratch and stays local.
|
||||
Statuses: `open`, `fixed`, `declined`, `refuted`, `duplicate`, `blocked`;
|
||||
`severity` must be `critical`, `high`, `medium` or `low`. Edit the ledger, never
|
||||
the rendering — a hand-edited `FINDINGS.md` is overwritten by the next render
|
||||
and committed by nothing. Everything else under `.superpowers/` is per-session
|
||||
scratch and stays local.
|
||||
|
||||
## Gotchas
|
||||
|
||||
@@ -60,4 +45,6 @@ else under `.superpowers/` is per-session scratch and stays local.
|
||||
- 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.
|
||||
- Branch from `dev` and PR to `dev` — `dev` is the integration branch and is
|
||||
PR-only; `main` carries releases. Squash merge, conventional commit subjects.
|
||||
Full model: [docs/contributing.md](docs/contributing.md#branch-and-pr-model).
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
# Contributing to OwnCord
|
||||
|
||||
The full guide lives in **[docs/contributing.md](docs/contributing.md)** —
|
||||
environment setup, the branch model, coding standards, and how to run the
|
||||
checks CI runs.
|
||||
|
||||
This file exists so GitHub can find it: the contributing-guidelines link that
|
||||
appears on new issues and pull requests only resolves `CONTRIBUTING.md` at the
|
||||
repository root, in `.github/`, or in `docs/`.
|
||||
|
||||
Three things worth knowing before you open a pull request:
|
||||
|
||||
- **Branch from `dev` and target `dev`.** `main` carries releases only. See
|
||||
[Branch and PR model](docs/contributing.md#branch-and-pr-model).
|
||||
- **Run the checks first.** `npm run check` from the repository root, or the
|
||||
per-stack commands in [docs/contributing.md](docs/contributing.md). CI takes
|
||||
about 15 minutes and enforces more than a plain build and test.
|
||||
- **Report security issues privately**, through GitHub Security Advisories —
|
||||
never a public issue or pull request. See [SECURITY.md](SECURITY.md) and
|
||||
[docs/security.md](docs/security.md).
|
||||
|
||||
New to the codebase? [docs/README.md](docs/README.md) indexes everything, and
|
||||
[docs/architecture/](docs/architecture/README.md) explains how the server and
|
||||
client fit together.
|
||||
@@ -0,0 +1,5 @@
|
||||
# engine-strict makes the engines block in package.json a hard error rather
|
||||
# than an npm warning. npm reads the project .npmrc from the package
|
||||
# directory and does not walk parent directories, so this file has to exist
|
||||
# in every package root or the gate silently downgrades to a warning there.
|
||||
engine-strict=true
|
||||
@@ -0,0 +1 @@
|
||||
24
|
||||
@@ -9,8 +9,18 @@ Rust backend in `src-tauri/` for native APIs only. LiveKit handles voice/video.
|
||||
`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/unit`, `tests/integration`, `tests/contract` (vitest, jsdom) ·
|
||||
`tests/e2e`, `tests/e2e/admin`, `tests/e2e/native` (Playwright) ·
|
||||
`tests/browser` (vitest browser mode)
|
||||
- A test whose assertions read, import or execute a **`Server/`-owned**
|
||||
artifact belongs in `tests/contract`, not `tests/unit` — `src-tauri/` is
|
||||
part of this component, so reading it is an ordinary unit test. The rule
|
||||
is in [docs/contributing.md](../docs/contributing.md#testing)
|
||||
- `src/platform/` does **not** exist yet. Where the desktop/browser seam will
|
||||
go, and which 20 files hold the native imports that must move behind it, is
|
||||
recorded in
|
||||
[docs/architecture/platform-contracts.md](../docs/architecture/platform-contracts.md).
|
||||
Building it is B7 — do not start it as a side effect of another change.
|
||||
|
||||
## Gotchas
|
||||
|
||||
@@ -23,7 +33,7 @@ Rust backend in `src-tauri/` for native APIs only. LiveKit handles voice/video.
|
||||
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
|
||||
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
|
||||
@@ -2,13 +2,7 @@
|
||||
"$schema": "https://unpkg.com/knip@6/schema.json",
|
||||
"entry": ["src/main.ts"],
|
||||
"project": ["src/**/*.ts"],
|
||||
"ignore": [
|
||||
"public/**",
|
||||
"src-tauri/**",
|
||||
"src/lib/protocolTypes.ts"
|
||||
],
|
||||
"ignoreDependencies": [
|
||||
"@tauri-apps/cli"
|
||||
],
|
||||
"ignore": ["public/**", "src-tauri/**", "src/lib/protocolTypes.ts"],
|
||||
"ignoreDependencies": ["@tauri-apps/cli"],
|
||||
"ignoreExportsUsedInFile": true
|
||||
}
|
||||
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "owncord-client",
|
||||
"version": "1.2.0-alpha.3",
|
||||
"version": "1.2.0-alpha.4",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "owncord-client",
|
||||
"version": "1.2.0-alpha.3",
|
||||
"version": "1.2.0-alpha.4",
|
||||
"dependencies": {
|
||||
"@jitsi/rnnoise-wasm": "^0.2.1",
|
||||
"@tauri-apps/api": "^2.10.1",
|
||||
@@ -36,11 +36,14 @@
|
||||
"jsdom": "^30.0.1",
|
||||
"knip": "^6.32.2",
|
||||
"oxlint": "^1.79.0",
|
||||
"prettier": "^3.9.6",
|
||||
"typescript": "^6.0.3",
|
||||
"typescript-eslint": "^8.67.0",
|
||||
"vite": "^8.2.2",
|
||||
"vitest": "^4.1.11"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=24",
|
||||
"npm": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/@asamuzakjp/css-color": {
|
||||
@@ -6114,22 +6117,6 @@
|
||||
"node": ">= 0.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/prettier": {
|
||||
"version": "3.9.6",
|
||||
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.9.6.tgz",
|
||||
"integrity": "sha512-OpN0zzVdiaiAhxpuuj5efpIS4sY9j7bY6uR5mnj5yPzGkdkjNKSJeUThPb60Jw29QuAZgA4o+/iB49kFiaBX6g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"prettier": "bin/prettier.cjs"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/prettier/prettier?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/pretty-ms": {
|
||||
"version": "9.3.0",
|
||||
"resolved": "https://registry.npmjs.org/pretty-ms/-/pretty-ms-9.3.0.tgz",
|
||||
@@ -1,8 +1,12 @@
|
||||
{
|
||||
"name": "owncord-client",
|
||||
"private": true,
|
||||
"version": "1.2.0-alpha.3",
|
||||
"version": "1.2.0-alpha.4",
|
||||
"type": "module",
|
||||
"engines": {
|
||||
"node": ">=24",
|
||||
"npm": ">=10"
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -p tsconfig.build.json && vite build",
|
||||
@@ -11,6 +15,7 @@
|
||||
"test": "vitest run",
|
||||
"test:unit": "vitest run tests/unit",
|
||||
"test:integration": "vitest run tests/integration",
|
||||
"test:contract": "vitest run tests/contract",
|
||||
"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",
|
||||
@@ -25,8 +30,6 @@
|
||||
"lint": "oxlint src/ && eslint src/",
|
||||
"lint:fix": "eslint src/ --fix",
|
||||
"lint:ox": "oxlint src/",
|
||||
"format": "prettier --write \"src/**/*.ts\" \"tests/**/*.ts\"",
|
||||
"format:check": "prettier --check \"src/**/*.ts\" \"tests/**/*.ts\"",
|
||||
"knip": "knip",
|
||||
"test:mutate": "stryker run",
|
||||
"test:mutate:dry": "stryker run --dryRunOnly"
|
||||
@@ -47,21 +50,11 @@
|
||||
"jsdom": "^30.0.1",
|
||||
"knip": "^6.32.2",
|
||||
"oxlint": "^1.79.0",
|
||||
"prettier": "^3.9.6",
|
||||
"typescript": "^6.0.3",
|
||||
"typescript-eslint": "^8.67.0",
|
||||
"vite": "^8.2.2",
|
||||
"vitest": "^4.1.11"
|
||||
},
|
||||
"prettier": {
|
||||
"singleQuote": false,
|
||||
"semi": true,
|
||||
"trailingComma": "all",
|
||||
"printWidth": 100,
|
||||
"tabWidth": 2,
|
||||
"arrowParens": "always",
|
||||
"endOfLine": "lf"
|
||||
},
|
||||
"dependencies": {
|
||||
"@jitsi/rnnoise-wasm": "^0.2.1",
|
||||
"@tauri-apps/api": "^2.10.1",
|
||||
@@ -36,7 +36,10 @@ export default defineConfig({
|
||||
workers: 1,
|
||||
retries: 2,
|
||||
reporter: process.env.CI
|
||||
? [["html", { open: "never" }], ["junit", { outputFile: "test-results/native-junit.xml" }]]
|
||||
? [
|
||||
["html", { open: "never" }],
|
||||
["junit", { outputFile: "test-results/native-junit.xml" }],
|
||||
]
|
||||
: "html",
|
||||
|
||||
use: {
|
||||
@@ -19,7 +19,10 @@ export default defineConfig({
|
||||
retries: process.env.CI ? 2 : 1,
|
||||
workers: process.env.CI ? 1 : undefined,
|
||||
reporter: process.env.CI
|
||||
? [["html", { open: "never" }], ["junit", { outputFile: "test-results/junit.xml" }]]
|
||||
? [
|
||||
["html", { open: "never" }],
|
||||
["junit", { outputFile: "test-results/junit.xml" }],
|
||||
]
|
||||
: "html",
|
||||
|
||||
use: {
|
||||
@@ -40,7 +43,10 @@ export default defineConfig({
|
||||
],
|
||||
|
||||
webServer: {
|
||||
command: "npm run preview",
|
||||
// Spawn Vite directly rather than through npm — see the note in
|
||||
// playwright.config.ts: an `npm run` wrapper leaves vite alive as an
|
||||
// orphaned grandchild on teardown and the runner never exits.
|
||||
command: "npx vite preview",
|
||||
url: "http://localhost:4173",
|
||||
reuseExistingServer: !process.env.CI,
|
||||
timeout: 60_000,
|
||||
@@ -43,8 +43,16 @@ export default defineConfig({
|
||||
},
|
||||
],
|
||||
|
||||
// Kills the dev server the runner cannot kill itself; without it the suite
|
||||
// passes and then hangs forever. See tests/e2e/global-teardown.ts.
|
||||
globalTeardown: "./tests/e2e/global-teardown.ts",
|
||||
|
||||
webServer: {
|
||||
command: "npm run dev",
|
||||
// Run Vite's entry point directly so the listening process IS Playwright's
|
||||
// child — globalTeardown kills the listener, which only releases the
|
||||
// runner's ChildProcess handle if that listener is the child itself. Going
|
||||
// through `npm run dev` would leave the npm process holding it open.
|
||||
command: "node node_modules/vite/bin/vite.js",
|
||||
url: "http://localhost:1420",
|
||||
reuseExistingServer: !process.env.CI,
|
||||
timeout: 60_000,
|
||||
@@ -70,12 +70,18 @@ class RNNoiseProcessor extends AudioWorkletProcessor {
|
||||
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));
|
||||
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');
|
||||
throw new Error("WASM module missing required RNNoise exports");
|
||||
}
|
||||
|
||||
const memory = new WebAssembly.Memory({ initial: WASM_MEMORY_INITIAL_PAGES });
|
||||
@@ -118,10 +124,13 @@ class RNNoiseProcessor extends AudioWorkletProcessor {
|
||||
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);
|
||||
console.warn("Failed to cleanup WASM memory:", cleanupErr);
|
||||
}
|
||||
}
|
||||
this._reportError(`WASM initialization failed: ${err instanceof Error ? err.message : String(err)}`, err);
|
||||
this._reportError(
|
||||
`WASM initialization failed: ${err instanceof Error ? err.message : String(err)}`,
|
||||
err,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -137,11 +146,10 @@ class RNNoiseProcessor extends AudioWorkletProcessor {
|
||||
|
||||
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');
|
||||
if (inOff + FRAME_SIZE > this._heapF32.length || outOff + FRAME_SIZE > this._heapF32.length) {
|
||||
console.error("WASM heap bounds exceeded");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -179,7 +187,7 @@ class RNNoiseProcessor extends AudioWorkletProcessor {
|
||||
exports.free(this._inputPtr);
|
||||
exports.free(this._outputPtr);
|
||||
} catch (err) {
|
||||
console.warn('RNNoise cleanup failed:', err);
|
||||
console.warn("RNNoise cleanup failed:", err);
|
||||
// Continue cleanup even if individual steps fail
|
||||
}
|
||||
}
|
||||
@@ -220,7 +228,13 @@ class RNNoiseProcessor extends AudioWorkletProcessor {
|
||||
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);
|
||||
outData.set(
|
||||
this._outBuffer.subarray(
|
||||
readStart + this._outSampleOffset,
|
||||
readStart + this._outSampleOffset + toWrite,
|
||||
),
|
||||
outIdx,
|
||||
);
|
||||
outIdx += toWrite;
|
||||
this._outSampleOffset += toWrite;
|
||||
if (this._outSampleOffset >= FRAME_SIZE) {
|
||||
@@ -243,10 +257,9 @@ class RNNoiseProcessor extends AudioWorkletProcessor {
|
||||
*/
|
||||
process(inputs, outputs) {
|
||||
if (this._destroyed) return false;
|
||||
|
||||
|
||||
// Validate input/output structure
|
||||
if (!inputs || !inputs[0] || !inputs[0][0] ||
|
||||
!outputs || !outputs[0] || !outputs[0][0]) {
|
||||
if (!inputs || !inputs[0] || !inputs[0][0] || !outputs || !outputs[0] || !outputs[0][0]) {
|
||||
return true; // Pass through silence or existing data
|
||||
}
|
||||
|
||||
@@ -21,15 +21,15 @@ class VadProcessor extends AudioWorkletProcessor {
|
||||
// once per ~16ms poll like the setTimeout fallback. These frame counts
|
||||
// are therefore ~6x the fallback's, so both paths gate on the same
|
||||
// wall-clock timing.
|
||||
this._gateOnFrames = 75; // ~200ms of silence before gating
|
||||
this._gateOffFrames = 12; // ~32ms of speech before ungating
|
||||
this._gateOnFrames = 75; // ~200ms of silence before gating
|
||||
this._gateOffFrames = 12; // ~32ms of speech before ungating
|
||||
this._silentFrames = 0;
|
||||
this._speechFrames = 0;
|
||||
this._gated = false;
|
||||
this._active = true;
|
||||
this._startupFrames = 0;
|
||||
this._startupGrace = 188; // ~500ms grace period
|
||||
this._frameCounter = 0; // for throttled RMS updates
|
||||
this._startupGrace = 188; // ~500ms grace period
|
||||
this._frameCounter = 0; // for throttled RMS updates
|
||||
|
||||
this.port.onmessage = (event) => {
|
||||
if (event.data.type === "config") {
|
||||
@@ -3025,7 +3025,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "owncord-client"
|
||||
version = "1.2.0-alpha.3"
|
||||
version = "1.2.0-alpha.4"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"device_query",
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "owncord-client"
|
||||
version = "1.2.0-alpha.3"
|
||||
version = "1.2.0-alpha.4"
|
||||
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
|
||||
@@ -1,9 +1,7 @@
|
||||
{
|
||||
"identifier": "default",
|
||||
"description": "Default capability granting core permissions to the main window. NOTE: http:allow-fetch is the ONLY URL-scoped HTTP identifier — tauri-plugin-http validates the URL once, in the `fetch` command; `fetch_send` and `fetch_read_body` take an already-validated ResourceId and never consult a scope, so allow/deny blocks on those identifiers are inert. Do not re-add them.",
|
||||
"windows": [
|
||||
"main"
|
||||
],
|
||||
"windows": ["main"],
|
||||
"permissions": [
|
||||
"core:default",
|
||||
"core:event:default",
|
||||
|
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 35 KiB After Width: | Height: | Size: 35 KiB |
|
Before Width: | Height: | Size: 1.5 KiB After Width: | Height: | Size: 1.5 KiB |
|
Before Width: | Height: | Size: 57 KiB After Width: | Height: | Size: 57 KiB |
|
Before Width: | Height: | Size: 35 KiB After Width: | Height: | Size: 35 KiB |
|
Before Width: | Height: | Size: 1004 B After Width: | Height: | Size: 1004 B |
@@ -10,13 +10,11 @@ const MAX_SETTINGS_KEY_LEN: usize = 128;
|
||||
/// Allowed key prefixes and exact keys for the settings store.
|
||||
/// Keys must either match an exact entry or start with an allowed prefix.
|
||||
const ALLOWED_SETTINGS_PREFIXES: &[&str] = &[
|
||||
"owncord:", // owncord:profiles, owncord:settings:*, owncord:recent-emoji
|
||||
"userVolume_", // per-user volume: userVolume_{userId}
|
||||
"owncord:", // owncord:profiles, owncord:settings:*, owncord:recent-emoji
|
||||
"userVolume_", // per-user volume: userVolume_{userId}
|
||||
];
|
||||
|
||||
const ALLOWED_SETTINGS_EXACT: &[&str] = &[
|
||||
"windowState",
|
||||
];
|
||||
const ALLOWED_SETTINGS_EXACT: &[&str] = &["windowState"];
|
||||
|
||||
fn is_settings_key_allowed(key: &str) -> bool {
|
||||
if key.len() > MAX_SETTINGS_KEY_LEN || key.is_empty() {
|
||||
@@ -25,7 +23,9 @@ fn is_settings_key_allowed(key: &str) -> bool {
|
||||
if ALLOWED_SETTINGS_EXACT.contains(&key) {
|
||||
return true;
|
||||
}
|
||||
ALLOWED_SETTINGS_PREFIXES.iter().any(|prefix| key.starts_with(prefix))
|
||||
ALLOWED_SETTINGS_PREFIXES
|
||||
.iter()
|
||||
.any(|prefix| key.starts_with(prefix))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -61,9 +61,12 @@ pub fn save_settings(app: tauri::AppHandle, key: String, value: Value) -> Result
|
||||
return Err(format!("unknown settings key: {key}"));
|
||||
}
|
||||
|
||||
let store = app
|
||||
.store(SETTINGS_STORE)
|
||||
.map_err(|e| log_cmd_err("save_settings", format!("failed to open settings store: {e}")))?;
|
||||
let store = app.store(SETTINGS_STORE).map_err(|e| {
|
||||
log_cmd_err(
|
||||
"save_settings",
|
||||
format!("failed to open settings store: {e}"),
|
||||
)
|
||||
})?;
|
||||
|
||||
store.set(&key, value);
|
||||
store
|
||||
@@ -87,7 +90,10 @@ fn validate_cert_pin(host: &str, fingerprint: &str) -> Result<(), String> {
|
||||
return Err("host must be 1-253 characters".into());
|
||||
}
|
||||
// Validate host format: alphanumeric, dots, hyphens, colons (port), brackets (IPv6)
|
||||
if !host.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | ':' | '[' | ']')) {
|
||||
if !host
|
||||
.chars()
|
||||
.all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | ':' | '[' | ']'))
|
||||
{
|
||||
return Err("host contains invalid characters".into());
|
||||
}
|
||||
if fingerprint.is_empty() {
|
||||
@@ -112,7 +118,10 @@ pub fn store_cert_fingerprint(
|
||||
validate_cert_pin(&host, &fingerprint)?;
|
||||
|
||||
let store = app.store(CERTS_STORE).map_err(|e| {
|
||||
log_cmd_err("store_cert_fingerprint", format!("failed to open certs store: {e}"))
|
||||
log_cmd_err(
|
||||
"store_cert_fingerprint",
|
||||
format!("failed to open certs store: {e}"),
|
||||
)
|
||||
})?;
|
||||
|
||||
// Capture old value before mutating so we can restore it if save fails.
|
||||
@@ -123,8 +132,12 @@ pub fn store_cert_fingerprint(
|
||||
// existed, or delete if there was none. Without this, a failed save
|
||||
// during cert rotation would silently lose the previously trusted cert.
|
||||
match old_value {
|
||||
Some(v) => { store.set(&host, v); }
|
||||
None => { let _ = store.delete(&host); }
|
||||
Some(v) => {
|
||||
store.set(&host, v);
|
||||
}
|
||||
None => {
|
||||
let _ = store.delete(&host);
|
||||
}
|
||||
}
|
||||
return Err(log_cmd_err(
|
||||
"store_cert_fingerprint",
|
||||
@@ -135,10 +148,7 @@ pub fn store_cert_fingerprint(
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn get_cert_fingerprint(
|
||||
app: tauri::AppHandle,
|
||||
host: String,
|
||||
) -> Result<Option<String>, String> {
|
||||
pub fn get_cert_fingerprint(app: tauri::AppHandle, host: String) -> Result<Option<String>, String> {
|
||||
if host.is_empty() {
|
||||
return Err("host must not be empty".into());
|
||||
}
|
||||
@@ -188,13 +198,19 @@ pub fn store_identity_pin(
|
||||
return Err("host must be 1-253 characters".into());
|
||||
}
|
||||
// Validate host format: alphanumeric, dots, hyphens, colons (port), brackets (IPv6)
|
||||
if !host.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | ':' | '[' | ']')) {
|
||||
if !host
|
||||
.chars()
|
||||
.all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | ':' | '[' | ']'))
|
||||
{
|
||||
return Err("host contains invalid characters".into());
|
||||
}
|
||||
if user_id.is_empty() || user_id.len() > 64 {
|
||||
return Err("user_id must be 1-64 characters".into());
|
||||
}
|
||||
if !user_id.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_')) {
|
||||
if !user_id
|
||||
.chars()
|
||||
.all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_'))
|
||||
{
|
||||
return Err("user_id contains invalid characters".into());
|
||||
}
|
||||
if pin.is_empty() || pin.len() > MAX_IDENTITY_PIN_LEN {
|
||||
@@ -202,7 +218,10 @@ pub fn store_identity_pin(
|
||||
}
|
||||
// Base64 charset (standard + url-safe + padding). Guards against garbage/DoS;
|
||||
// the actual key parsing/verification happens on the JS side.
|
||||
if !pin.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '+' | '/' | '=' | '-' | '_')) {
|
||||
if !pin
|
||||
.chars()
|
||||
.all(|c| c.is_ascii_alphanumeric() || matches!(c, '+' | '/' | '=' | '-' | '_'))
|
||||
{
|
||||
return Err("pin contains invalid characters".into());
|
||||
}
|
||||
|
||||
@@ -218,8 +237,12 @@ pub fn store_identity_pin(
|
||||
// Restore previous in-memory state so a failed save during a re-pin
|
||||
// doesn't silently drop the previously trusted identity key.
|
||||
match old_value {
|
||||
Some(v) => { store.set(&store_key, v); }
|
||||
None => { let _ = store.delete(&store_key); }
|
||||
Some(v) => {
|
||||
store.set(&store_key, v);
|
||||
}
|
||||
None => {
|
||||
let _ = store.delete(&store_key);
|
||||
}
|
||||
}
|
||||
return Err(format!("failed to persist identity pin: {e}"));
|
||||
}
|
||||
@@ -374,7 +397,13 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn identity_pin_key_combines_host_and_user() {
|
||||
assert_eq!(identity_pin_key("chat.example.com", "42"), "chat.example.com:42");
|
||||
assert_eq!(identity_pin_key("192.168.1.10:8443", "u_7"), "192.168.1.10:8443:u_7");
|
||||
assert_eq!(
|
||||
identity_pin_key("chat.example.com", "42"),
|
||||
"chat.example.com:42"
|
||||
);
|
||||
assert_eq!(
|
||||
identity_pin_key("192.168.1.10:8443", "u_7"),
|
||||
"192.168.1.10:8443:u_7"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -86,7 +86,9 @@ static CREDENTIAL_LOCK: Mutex<()> = Mutex::new(());
|
||||
/// 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());
|
||||
let _guard = CREDENTIAL_LOCK
|
||||
.lock()
|
||||
.unwrap_or_else(|poisoned| poisoned.into_inner());
|
||||
f()
|
||||
}
|
||||
|
||||
@@ -353,8 +355,14 @@ mod tests {
|
||||
fn account_names_keep_the_port_that_distinguishes_hosts() {
|
||||
// Two servers on one machine differ only by port; dropping it would
|
||||
// make them share an identity key.
|
||||
assert_ne!(login_account("localhost:8443"), login_account("localhost:9443"));
|
||||
assert_eq!(identity_account("localhost:8443"), "identity:localhost:8443");
|
||||
assert_ne!(
|
||||
login_account("localhost:8443"),
|
||||
login_account("localhost:9443")
|
||||
);
|
||||
assert_eq!(
|
||||
identity_account("localhost:8443"),
|
||||
"identity:localhost:8443"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -374,7 +382,9 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn parse_credential_blob_rejects_malformed_input() {
|
||||
assert!(parse_credential_blob("not json").unwrap_err().contains("not valid JSON"));
|
||||
assert!(parse_credential_blob("not json")
|
||||
.unwrap_err()
|
||||
.contains("not valid JSON"));
|
||||
assert!(parse_credential_blob(r#"{"token":"tok"}"#)
|
||||
.unwrap_err()
|
||||
.contains("missing 'username'"));
|
||||
@@ -47,7 +47,8 @@ impl Drop for OutBlob {
|
||||
// Scrub first: on the unprotect path this buffer holds the plaintext
|
||||
// identity key, and LocalFree does not zero what it releases.
|
||||
// SAFETY: as in `to_vec`, plus the range is ours alone to write.
|
||||
let bytes = unsafe { std::slice::from_raw_parts_mut(self.0.pbData, self.0.cbData as usize) };
|
||||
let bytes =
|
||||
unsafe { std::slice::from_raw_parts_mut(self.0.pbData, self.0.cbData as usize) };
|
||||
bytes.zeroize();
|
||||
// SAFETY: pbData came from DPAPI's LocalAlloc, and `Drop` runs at most
|
||||
// once, so it is freed exactly once.
|
||||
@@ -200,7 +200,10 @@ mod tests {
|
||||
tampered[last] ^= 0x01;
|
||||
assert!(unprotect(&key, &tampered, b"aad").is_err());
|
||||
|
||||
assert!(unprotect(&key, &blob[..NONCE_LEN], b"aad").is_err(), "truncated blob");
|
||||
assert!(
|
||||
unprotect(&key, &blob[..NONCE_LEN], b"aad").is_err(),
|
||||
"truncated blob"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -213,10 +216,8 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn creates_and_reuses_the_key_file() {
|
||||
let dir = std::env::temp_dir().join(format!(
|
||||
"owncord-fallback-key-test-{}",
|
||||
std::process::id()
|
||||
));
|
||||
let dir =
|
||||
std::env::temp_dir().join(format!("owncord-fallback-key-test-{}", std::process::id()));
|
||||
let _ = fs::remove_dir_all(&dir);
|
||||
|
||||
let first = load_or_create_key(&dir).unwrap();
|
||||
@@ -259,7 +260,10 @@ mod tests {
|
||||
.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");
|
||||
assert!(
|
||||
!path.exists(),
|
||||
"a failed write must not leave a partial key file behind"
|
||||
);
|
||||
|
||||
let _ = fs::remove_dir_all(&dir);
|
||||
}
|
||||
@@ -29,10 +29,10 @@
|
||||
// - The accept loop exits after 5 consecutive errors to prevent CPU spin.
|
||||
|
||||
use log::{debug, error, info, warn};
|
||||
use rustls::pki_types::ServerName;
|
||||
use std::collections::HashMap;
|
||||
use std::net::IpAddr;
|
||||
use std::sync::Arc;
|
||||
use rustls::pki_types::ServerName;
|
||||
use tauri::{AppHandle, Manager, Runtime};
|
||||
use tokio::io::{self, AsyncReadExt, AsyncWriteExt};
|
||||
use tokio::net::{TcpListener, TcpStream};
|
||||
@@ -65,7 +65,10 @@ impl HttpProxyState {
|
||||
/// 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) {
|
||||
if inner
|
||||
.get(remote_host)
|
||||
.is_some_and(|entry| entry.port == port)
|
||||
{
|
||||
inner.remove(remote_host);
|
||||
}
|
||||
}
|
||||
@@ -372,7 +375,9 @@ async fn handle_connection<R: Runtime>(
|
||||
.map_err(|_| Box::<dyn std::error::Error + Send + Sync>::from("TCP connect timed out"))??;
|
||||
let mut tls = timeout(Duration::from_secs(10), connector.connect(server_name, tcp))
|
||||
.await
|
||||
.map_err(|_| Box::<dyn std::error::Error + Send + Sync>::from("TLS handshake timed out"))??;
|
||||
.map_err(|_| {
|
||||
Box::<dyn std::error::Error + Send + Sync>::from("TLS handshake timed out")
|
||||
})??;
|
||||
|
||||
let fingerprint = captured_fp
|
||||
.lock()
|
||||
@@ -400,7 +405,10 @@ async fn handle_connection<R: Runtime>(
|
||||
// it (accept_cert_fingerprint) before any credential-bearing request is
|
||||
// sent. The connect page's health check triggers this before login.
|
||||
TofuOutcome::FirstUse => {
|
||||
info!("[http_proxy] first-use cert for {} — awaiting user confirmation", store_key);
|
||||
info!(
|
||||
"[http_proxy] first-use cert for {} — awaiting user confirmation",
|
||||
store_key
|
||||
);
|
||||
crate::ws_proxy::emit_cert_tofu(
|
||||
&app,
|
||||
serde_json::json!({
|
||||
@@ -523,19 +531,20 @@ mod tests {
|
||||
|
||||
// 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;
|
||||
state.remove_if_port_matches("example.com:8443", 9999).await;
|
||||
assert_eq!(
|
||||
state.inner.lock().await.get("example.com:8443").map(|e| e.port),
|
||||
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;
|
||||
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"
|
||||
@@ -609,13 +618,15 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn rewrite_overrides_existing_keepalive() {
|
||||
let raw =
|
||||
b"POST /x HTTP/1.1\r\nHost: 127.0.0.1:5000\r\nConnection: keep-alive\r\n\r\n";
|
||||
let raw = b"POST /x HTTP/1.1\r\nHost: 127.0.0.1:5000\r\nConnection: keep-alive\r\n\r\n";
|
||||
let out = rewrite_request_headers(raw, "example.com:8443");
|
||||
assert!(out.contains("Connection: close\r\n"));
|
||||
assert!(!out.to_ascii_lowercase().contains("keep-alive"));
|
||||
// Exactly one Connection header.
|
||||
assert_eq!(out.to_ascii_lowercase().matches("\r\nconnection:").count(), 1);
|
||||
assert_eq!(
|
||||
out.to_ascii_lowercase().matches("\r\nconnection:").count(),
|
||||
1
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -38,8 +38,12 @@ pub fn enable_media_capture(app: &AppHandle) {
|
||||
webview.connect_permission_request(|_, request| {
|
||||
// UserMediaPermissionRequest covers getUserMedia (mic/camera);
|
||||
// DeviceInfoPermissionRequest covers enumerateDevices labels.
|
||||
let is_media = request.downcast_ref::<UserMediaPermissionRequest>().is_some()
|
||||
|| request.downcast_ref::<DeviceInfoPermissionRequest>().is_some();
|
||||
let is_media = request
|
||||
.downcast_ref::<UserMediaPermissionRequest>()
|
||||
.is_some()
|
||||
|| request
|
||||
.downcast_ref::<DeviceInfoPermissionRequest>()
|
||||
.is_some();
|
||||
if is_media {
|
||||
request.allow();
|
||||
return true;
|
||||
@@ -28,9 +28,9 @@
|
||||
// - The accept loop exits after 5 consecutive errors to prevent CPU spin.
|
||||
|
||||
use log::{debug, error, info, warn};
|
||||
use rustls::pki_types::ServerName;
|
||||
use std::net::IpAddr;
|
||||
use std::sync::Arc;
|
||||
use rustls::pki_types::ServerName;
|
||||
use tauri::{Manager, Runtime};
|
||||
use tokio::io::{self, AsyncReadExt, AsyncWriteExt};
|
||||
use tokio::net::{TcpListener, TcpStream};
|
||||
@@ -205,16 +205,25 @@ pub async fn start_livekit_proxy<R: Runtime>(
|
||||
// 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!(
|
||||
let fingerprint = tofu::load_stored_fingerprint(&app, &store_key)?.ok_or_else(|| {
|
||||
format!(
|
||||
"no trusted certificate fingerprint for {remote_host}. \
|
||||
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);
|
||||
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.
|
||||
@@ -256,7 +265,10 @@ pub async fn start_livekit_proxy<R: Runtime>(
|
||||
}
|
||||
});
|
||||
|
||||
info!("[livekit_proxy] proxy started on 127.0.0.1:{} → {}", port, remote_host);
|
||||
info!(
|
||||
"[livekit_proxy] proxy started on 127.0.0.1:{} → {}",
|
||||
port, remote_host
|
||||
);
|
||||
|
||||
inner.port = Some(port);
|
||||
inner.remote_host = remote_host;
|
||||
@@ -268,9 +280,7 @@ pub async fn start_livekit_proxy<R: Runtime>(
|
||||
|
||||
/// Stop the LiveKit TLS proxy if running.
|
||||
#[tauri::command]
|
||||
pub async fn stop_livekit_proxy(
|
||||
state: tauri::State<'_, LiveKitProxyState>,
|
||||
) -> Result<(), String> {
|
||||
pub async fn stop_livekit_proxy(state: tauri::State<'_, LiveKitProxyState>) -> Result<(), String> {
|
||||
let mut inner = state.inner.lock().await;
|
||||
if let Some(tx) = inner.shutdown_tx.take() {
|
||||
let _ = tx.send(());
|
||||
@@ -370,10 +380,15 @@ async fn connect_tls(
|
||||
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);
|
||||
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"))??;
|
||||
.map_err(|_| {
|
||||
Box::<dyn std::error::Error + Send + Sync>::from("TLS handshake timed out")
|
||||
})??;
|
||||
Ok(tls)
|
||||
}
|
||||
|
||||
@@ -413,9 +428,9 @@ async fn handle_connection(
|
||||
Ok::<(), Box<dyn std::error::Error + Send + Sync>>(())
|
||||
})
|
||||
.await
|
||||
.map_err(|_| Box::<dyn std::error::Error + Send + Sync>::from(
|
||||
"upstream header read timed out",
|
||||
))??;
|
||||
.map_err(|_| {
|
||||
Box::<dyn std::error::Error + Send + Sync>::from("upstream header read timed out")
|
||||
})??;
|
||||
|
||||
// Reject CRLF in remote_host before header insertion (defense-in-depth;
|
||||
// primary validation is in start_livekit_proxy).
|
||||
@@ -430,9 +445,9 @@ async fn handle_connection(
|
||||
// ── 3. Connect to remote over TLS ────────────────────────────────────
|
||||
let tls_config = rustls::ClientConfig::builder()
|
||||
.dangerous()
|
||||
.with_custom_certificate_verifier(Arc::new(
|
||||
tofu::PinnedVerifier::new(pinned_fingerprint.to_string()),
|
||||
))
|
||||
.with_custom_certificate_verifier(Arc::new(tofu::PinnedVerifier::new(
|
||||
pinned_fingerprint.to_string(),
|
||||
)))
|
||||
.with_no_client_auth();
|
||||
|
||||
let connector = tokio_rustls::TlsConnector::from(Arc::new(tls_config));
|
||||
@@ -447,7 +462,10 @@ async fn handle_connection(
|
||||
let result = io::copy_bidirectional(&mut local, &mut tls).await;
|
||||
match result {
|
||||
Ok((to_remote, from_remote)) => {
|
||||
debug!("[livekit_proxy] connection closed: {}B sent, {}B received", to_remote, from_remote);
|
||||
debug!(
|
||||
"[livekit_proxy] connection closed: {}B sent, {}B received",
|
||||
to_remote, from_remote
|
||||
);
|
||||
}
|
||||
Err(e) => {
|
||||
debug!("[livekit_proxy] bidirectional copy ended: {}", e);
|
||||
@@ -493,7 +511,10 @@ mod tests {
|
||||
"example.com\nX-Injected: 1",
|
||||
"example.com\r",
|
||||
] {
|
||||
assert!(validate_remote_host(host).is_err(), "should reject {host:?}");
|
||||
assert!(
|
||||
validate_remote_host(host).is_err(),
|
||||
"should reject {host:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -512,7 +533,10 @@ mod tests {
|
||||
"exa mple.com:443",
|
||||
"example.com;evil",
|
||||
] {
|
||||
assert!(validate_remote_host(host).is_err(), "should reject {host:?}");
|
||||
assert!(
|
||||
validate_remote_host(host).is_err(),
|
||||
"should reject {host:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -527,12 +551,22 @@ mod tests {
|
||||
|
||||
#[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"));
|
||||
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"));
|
||||
assert!(!can_reuse_proxy(
|
||||
"old.example:443",
|
||||
"aa:bb",
|
||||
"new.example:443",
|
||||
"aa:bb"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -541,7 +575,12 @@ mod tests {
|
||||
// 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"));
|
||||
assert!(!can_reuse_proxy(
|
||||
"example.com:443",
|
||||
"aa:bb",
|
||||
"example.com:443",
|
||||
"cc:dd"
|
||||
));
|
||||
}
|
||||
|
||||
// ── rewrite_proxy_headers ───────────────────────────────────────────────
|
||||
@@ -749,7 +788,10 @@ mod tests {
|
||||
// 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_eq!(
|
||||
inner.port, None,
|
||||
"matching port must deregister the dead proxy"
|
||||
);
|
||||
assert!(inner.remote_host.is_empty());
|
||||
assert!(inner.pinned_fingerprint.is_empty());
|
||||
}
|
||||
@@ -24,8 +24,7 @@ static PTT_VKEY: AtomicI32 = AtomicI32::new(0);
|
||||
/// therefore never reset the stop signal that an earlier thread's `join()` is
|
||||
/// still waiting on — the lost-signal race (ATOMICRACE-001) that a single
|
||||
/// shared flag allowed.
|
||||
static PTT_THREAD: Mutex<Option<(Arc<AtomicBool>, std::thread::JoinHandle<()>)>> =
|
||||
Mutex::new(None);
|
||||
static PTT_THREAD: Mutex<Option<(Arc<AtomicBool>, std::thread::JoinHandle<()>)>> = Mutex::new(None);
|
||||
|
||||
/// Returns true if a VK code is allowed for global capture in ptt_listen_for_key.
|
||||
///
|
||||
@@ -58,7 +57,7 @@ fn is_allowed_ptt_capture_vk(vk: i32) -> bool {
|
||||
0x2D | // Insert
|
||||
0x2E | // Delete
|
||||
0x05 | // Mouse X1
|
||||
0x06 // Mouse X2
|
||||
0x06 // Mouse X2
|
||||
)
|
||||
}
|
||||
|
||||
@@ -73,8 +72,7 @@ fn is_key_down(vk: i32) -> bool {
|
||||
return false;
|
||||
}
|
||||
// SAFETY: GetAsyncKeyState is safe to call with valid VK codes 1-254
|
||||
let state =
|
||||
unsafe { windows::Win32::UI::Input::KeyboardAndMouse::GetAsyncKeyState(vk) };
|
||||
let state = unsafe { windows::Win32::UI::Input::KeyboardAndMouse::GetAsyncKeyState(vk) };
|
||||
// High-order bit set (negative when interpreted as i16) = key is down
|
||||
(state as i16) < 0
|
||||
}
|
||||
@@ -101,7 +99,10 @@ fn is_key_down(vk: i32) -> bool {
|
||||
let Some(keycode) = linux::vk_to_keycode(vk) else {
|
||||
return false;
|
||||
};
|
||||
DEVICE_STATE.with(|ds| ds.as_ref().is_some_and(|ds| ds.get_keys().contains(&keycode)))
|
||||
DEVICE_STATE.with(|ds| {
|
||||
ds.as_ref()
|
||||
.is_some_and(|ds| ds.get_keys().contains(&keycode))
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(not(any(windows, target_os = "linux")))]
|
||||
@@ -444,7 +445,9 @@ pub fn ptt_stop_internal() {
|
||||
#[tauri::command]
|
||||
pub fn ptt_set_key(vk_code: i32) -> Result<(), String> {
|
||||
if vk_code != 0 && !(1..=254).contains(&vk_code) {
|
||||
return Err(format!("invalid virtual key code: {vk_code} (must be 0 or 1-254)"));
|
||||
return Err(format!(
|
||||
"invalid virtual key code: {vk_code} (must be 0 or 1-254)"
|
||||
));
|
||||
}
|
||||
PTT_VKEY.store(vk_code, Ordering::SeqCst);
|
||||
Ok(())
|
||||
@@ -478,8 +481,7 @@ pub async fn ptt_listen_for_key() -> i32 {
|
||||
continue;
|
||||
}
|
||||
// Wait for key release (with its own timeout)
|
||||
let release_deadline =
|
||||
std::time::Instant::now() + Duration::from_secs(5);
|
||||
let release_deadline = std::time::Instant::now() + Duration::from_secs(5);
|
||||
while device_state.get_keys().contains(&key)
|
||||
&& std::time::Instant::now() < release_deadline
|
||||
{
|
||||
@@ -503,8 +505,7 @@ pub async fn ptt_listen_for_key() -> i32 {
|
||||
continue;
|
||||
}
|
||||
if is_key_down(vk) {
|
||||
let release_deadline =
|
||||
std::time::Instant::now() + Duration::from_secs(5);
|
||||
let release_deadline = std::time::Instant::now() + Duration::from_secs(5);
|
||||
while is_key_down(vk) && std::time::Instant::now() < release_deadline {
|
||||
std::thread::sleep(Duration::from_millis(20));
|
||||
}
|
||||
@@ -576,7 +577,11 @@ mod tests {
|
||||
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, true),
|
||||
Some(false),
|
||||
"falling edge"
|
||||
);
|
||||
assert_eq!(ptt_transition(0x41, false, false), None, "still idle");
|
||||
}
|
||||
|
||||
@@ -635,7 +640,11 @@ mod tests {
|
||||
];
|
||||
|
||||
for (keycode, vk) in cases {
|
||||
assert_eq!(keycode_to_vk(&keycode), vk, "keycode_to_vk failed for {keycode:?}");
|
||||
assert_eq!(
|
||||
keycode_to_vk(&keycode),
|
||||
vk,
|
||||
"keycode_to_vk failed for {keycode:?}"
|
||||
);
|
||||
assert_eq!(
|
||||
vk_to_keycode(vk),
|
||||
Some(keycode),
|
||||
@@ -272,9 +272,10 @@ fn compiled_backend_persistence() -> (bool, &'static str) {
|
||||
CredentialPersistence::UntilDelete => (true, "persists until deleted (on disk)"),
|
||||
CredentialPersistence::UntilReboot => (false, "vanishes on reboot (kernel memory)"),
|
||||
CredentialPersistence::ProcessOnly => (false, "vanishes when the process exits"),
|
||||
CredentialPersistence::EntryOnly => {
|
||||
(false, "vanishes with the entry object (the in-memory mock store)")
|
||||
}
|
||||
CredentialPersistence::EntryOnly => (
|
||||
false,
|
||||
"vanishes with the entry object (the in-memory mock store)",
|
||||
),
|
||||
_ => (false, "unrecognized persistence class"),
|
||||
}
|
||||
}
|
||||
@@ -509,7 +510,10 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn fallback_aad_is_account_specific() {
|
||||
assert_ne!(fallback_aad("host.example"), fallback_aad("identity:host.example"));
|
||||
assert_ne!(
|
||||
fallback_aad("host.example"),
|
||||
fallback_aad("identity:host.example")
|
||||
);
|
||||
assert_eq!(fallback_aad("host.example"), fallback_aad("host.example"));
|
||||
}
|
||||
|
||||
@@ -532,13 +536,21 @@ mod tests {
|
||||
// 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);
|
||||
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()));
|
||||
let result = get_with(
|
||||
"acct",
|
||||
|_| Ok(Some("live".to_string())),
|
||||
|_| Some("stale".to_string()),
|
||||
);
|
||||
assert_eq!(result, Ok(Some("live".to_string())));
|
||||
}
|
||||
|
||||
@@ -625,7 +637,10 @@ mod tests {
|
||||
|_| cleared.set(true),
|
||||
);
|
||||
assert_eq!(result, Ok(Backend::Keyring));
|
||||
assert!(cleared.get(), "a recovered machine must clear any stale fallback copy");
|
||||
assert!(
|
||||
cleared.get(),
|
||||
"a recovered machine must clear any stale fallback copy"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -657,7 +672,10 @@ mod tests {
|
||||
deleted.get(),
|
||||
"a mismatched keyring entry must be purged, not left to shadow the fallback"
|
||||
);
|
||||
assert!(fallback_written.get(), "the secret must still land in the fallback");
|
||||
assert!(
|
||||
fallback_written.get(),
|
||||
"the secret must still land in the fallback"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -729,7 +747,11 @@ mod tests {
|
||||
fn dpapi_round_trips_and_rejects_foreign_entropy() {
|
||||
let secret = b"eyJrdHkiOiJFQyIsImNydiI6IlAtMjU2In0";
|
||||
let blob = crate::dpapi::protect(secret, &fallback_aad("identity:a.example")).unwrap();
|
||||
assert_ne!(blob.as_slice(), secret.as_slice(), "blob must not be plaintext");
|
||||
assert_ne!(
|
||||
blob.as_slice(),
|
||||
secret.as_slice(),
|
||||
"blob must not be plaintext"
|
||||
);
|
||||
|
||||
let back = crate::dpapi::unprotect(&blob, &fallback_aad("identity:a.example")).unwrap();
|
||||
assert_eq!(back, secret);
|
||||
@@ -80,7 +80,12 @@ pub(crate) struct CaptureVerifier {
|
||||
impl CaptureVerifier {
|
||||
pub(crate) fn new() -> (Self, CapturedFingerprint) {
|
||||
let fp = Arc::new(std::sync::Mutex::new(None));
|
||||
(Self { captured: fp.clone() }, fp)
|
||||
(
|
||||
Self {
|
||||
captured: fp.clone(),
|
||||
},
|
||||
fp,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -134,7 +139,9 @@ pub(crate) struct PinnedVerifier {
|
||||
|
||||
impl PinnedVerifier {
|
||||
pub(crate) fn new(expected_fingerprint: String) -> Self {
|
||||
Self { expected_fingerprint }
|
||||
Self {
|
||||
expected_fingerprint,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -203,7 +210,11 @@ impl HostScopedVerifier {
|
||||
)
|
||||
.build()
|
||||
.map_err(|e| format!("failed to build web-PKI verifier: {e}"))?;
|
||||
Ok(Self::with_default(pinned_host, expected_fingerprint, default))
|
||||
Ok(Self::with_default(
|
||||
pinned_host,
|
||||
expected_fingerprint,
|
||||
default,
|
||||
))
|
||||
}
|
||||
|
||||
/// Seam for tests: inject the verifier used for non-pinned hosts.
|
||||
@@ -247,11 +258,21 @@ impl rustls::client::danger::ServerCertVerifier for HostScopedVerifier {
|
||||
now: rustls::pki_types::UnixTime,
|
||||
) -> Result<rustls::client::danger::ServerCertVerified, rustls::Error> {
|
||||
if self.is_pinned_host(server_name) {
|
||||
self.pinned
|
||||
.verify_server_cert(end_entity, intermediates, server_name, ocsp_response, now)
|
||||
self.pinned.verify_server_cert(
|
||||
end_entity,
|
||||
intermediates,
|
||||
server_name,
|
||||
ocsp_response,
|
||||
now,
|
||||
)
|
||||
} else {
|
||||
self.default
|
||||
.verify_server_cert(end_entity, intermediates, server_name, ocsp_response, now)
|
||||
self.default.verify_server_cert(
|
||||
end_entity,
|
||||
intermediates,
|
||||
server_name,
|
||||
ocsp_response,
|
||||
now,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -410,7 +431,9 @@ mod tests {
|
||||
fn decide_mismatch_when_pin_differs() {
|
||||
assert_eq!(
|
||||
decide(Some("aa:bb".into()), "cc:dd"),
|
||||
TofuOutcome::Mismatch { stored: "aa:bb".into() }
|
||||
TofuOutcome::Mismatch {
|
||||
stored: "aa:bb".into()
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
@@ -430,7 +453,10 @@ mod tests {
|
||||
// own distinct key, matching the un-bracketed "host:port" behavior above.
|
||||
#[test]
|
||||
fn cert_store_key_treats_bracketed_and_bare_ipv6_as_the_same_host() {
|
||||
assert_eq!(cert_store_key("[2001:db8::1]"), cert_store_key("2001:db8::1"));
|
||||
assert_eq!(
|
||||
cert_store_key("[2001:db8::1]"),
|
||||
cert_store_key("2001:db8::1")
|
||||
);
|
||||
assert_eq!(cert_store_key("2001:db8::1"), "2001:db8::1");
|
||||
assert_eq!(cert_store_key("[2001:db8::1]"), "2001:db8::1");
|
||||
// The default-port livekit form ("[host]:443") also collapses to the
|
||||
@@ -478,7 +504,10 @@ mod tests {
|
||||
#[test]
|
||||
fn extract_host_variants() {
|
||||
assert_eq!(extract_host("wss://example.com/chat"), "example.com");
|
||||
assert_eq!(extract_host("wss://example.com:8443/chat"), "example.com:8443");
|
||||
assert_eq!(
|
||||
extract_host("wss://example.com:8443/chat"),
|
||||
"example.com:8443"
|
||||
);
|
||||
assert_eq!(extract_host("wss://example.com:443/chat"), "example.com");
|
||||
assert_eq!(extract_host("wss://example.com"), "example.com");
|
||||
assert_eq!(extract_host("example.com/path"), "example.com");
|
||||
@@ -577,7 +606,9 @@ mod tests {
|
||||
HostScopedVerifier::with_default(
|
||||
pinned_host.to_string(),
|
||||
fingerprint_hex(cert_bytes),
|
||||
Arc::new(StubVerifier { accept: stub_accepts }),
|
||||
Arc::new(StubVerifier {
|
||||
accept: stub_accepts,
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -14,23 +14,16 @@ const QUIT_ID: &str = "quit";
|
||||
pub fn create_tray<R: Runtime>(app: &tauri::AppHandle<R>) -> Result<(), tauri::Error> {
|
||||
let show_hide = MenuItem::with_id(app, SHOW_HIDE_ID, "Show/Hide", true, None::<&str>)?;
|
||||
|
||||
let status_online =
|
||||
MenuItem::with_id(app, STATUS_ONLINE_ID, "Online", true, None::<&str>)?;
|
||||
let status_online = MenuItem::with_id(app, STATUS_ONLINE_ID, "Online", true, None::<&str>)?;
|
||||
let status_idle = MenuItem::with_id(app, STATUS_IDLE_ID, "Idle", true, None::<&str>)?;
|
||||
let status_dnd = MenuItem::with_id(app, STATUS_DND_ID, "Do Not Disturb", true, None::<&str>)?;
|
||||
let status_offline =
|
||||
MenuItem::with_id(app, STATUS_OFFLINE_ID, "Offline", true, None::<&str>)?;
|
||||
let status_offline = MenuItem::with_id(app, STATUS_OFFLINE_ID, "Offline", true, None::<&str>)?;
|
||||
|
||||
let status_submenu = Submenu::with_items(
|
||||
app,
|
||||
"Status",
|
||||
true,
|
||||
&[
|
||||
&status_online,
|
||||
&status_idle,
|
||||
&status_dnd,
|
||||
&status_offline,
|
||||
],
|
||||
&[&status_online, &status_idle, &status_dnd, &status_offline],
|
||||
)?;
|
||||
|
||||
let quit = MenuItem::with_id(app, QUIT_ID, "Quit", true, None::<&str>)?;
|
||||
@@ -41,7 +34,11 @@ pub fn create_tray<R: Runtime>(app: &tauri::AppHandle<R>) -> Result<(), tauri::E
|
||||
let app_handle_menu = app.clone();
|
||||
|
||||
TrayIconBuilder::new()
|
||||
.icon(app.default_window_icon().cloned().unwrap_or_else(|| tauri::image::Image::new(&[], 1, 1)))
|
||||
.icon(
|
||||
app.default_window_icon()
|
||||
.cloned()
|
||||
.unwrap_or_else(|| tauri::image::Image::new(&[], 1, 1)),
|
||||
)
|
||||
.menu(&menu)
|
||||
.tooltip("OwnCord")
|
||||
.on_tray_icon_event(move |_tray, event| {
|
||||
@@ -1,5 +1,5 @@
|
||||
use std::sync::Arc;
|
||||
use serde::Serialize;
|
||||
use std::sync::Arc;
|
||||
use tauri::{AppHandle, Emitter};
|
||||
use tauri_plugin_updater::UpdaterExt;
|
||||
|
||||
@@ -23,9 +23,10 @@ struct DownloadProgress {
|
||||
|
||||
/// Extract the host (with port if non-443) from an https:// URL for cert store lookup.
|
||||
fn extract_host_for_cert_store(server_url: &str) -> Result<String, String> {
|
||||
let parsed = url::Url::parse(server_url)
|
||||
.map_err(|e| format!("failed to parse server URL: {e}"))?;
|
||||
let host = parsed.host_str()
|
||||
let parsed =
|
||||
url::Url::parse(server_url).map_err(|e| format!("failed to parse server URL: {e}"))?;
|
||||
let host = parsed
|
||||
.host_str()
|
||||
.ok_or_else(|| "server URL has no host".to_string())?;
|
||||
let port = parsed.port().unwrap_or(443);
|
||||
let raw = if port == 443 {
|
||||
@@ -41,7 +42,10 @@ fn extract_host_for_cert_store(server_url: &str) -> Result<String, String> {
|
||||
/// HTTP client also downloads the installer from GitHub, whose certificate
|
||||
/// must pass normal web-PKI validation instead (a client-wide pin would
|
||||
/// reject it and every install would fail).
|
||||
fn build_tls_config(app: &AppHandle, server_url: &str) -> Result<Option<rustls::ClientConfig>, String> {
|
||||
fn build_tls_config(
|
||||
app: &AppHandle,
|
||||
server_url: &str,
|
||||
) -> Result<Option<rustls::ClientConfig>, String> {
|
||||
let store_key = extract_host_for_cert_store(server_url)?;
|
||||
let fingerprint = load_stored_fingerprint(app, &store_key)?;
|
||||
match fingerprint {
|
||||
@@ -164,10 +168,7 @@ pub async fn check_client_update(
|
||||
/// The frontend should call `relaunch()` from @tauri-apps/plugin-process
|
||||
/// after this completes.
|
||||
#[tauri::command]
|
||||
pub async fn download_and_install_update(
|
||||
app: AppHandle,
|
||||
server_url: String,
|
||||
) -> Result<(), String> {
|
||||
pub async fn download_and_install_update(app: AppHandle, server_url: String) -> Result<(), String> {
|
||||
let updater = build_updater(&app, &server_url)?;
|
||||
|
||||
let update = updater
|
||||
@@ -144,20 +144,19 @@ pub async fn ws_connect<R: Runtime>(
|
||||
.with_custom_certificate_verifier(Arc::new(verifier))
|
||||
.with_no_client_auth();
|
||||
|
||||
let connector =
|
||||
tokio_tungstenite::Connector::Rustls(Arc::new(tls_config));
|
||||
let connector = tokio_tungstenite::Connector::Rustls(Arc::new(tls_config));
|
||||
|
||||
let connect_future = tokio_tungstenite::connect_async_tls_with_config(
|
||||
&url,
|
||||
None,
|
||||
false,
|
||||
Some(connector),
|
||||
);
|
||||
let connect_future =
|
||||
tokio_tungstenite::connect_async_tls_with_config(&url, None, false, Some(connector));
|
||||
|
||||
let (ws_stream, _response) = tokio::time::timeout(CONNECT_TIMEOUT, connect_future)
|
||||
.await
|
||||
.map_err(|_| {
|
||||
error!("[ws_proxy] connect timed out after {}s to {}", CONNECT_TIMEOUT.as_secs(), url);
|
||||
error!(
|
||||
"[ws_proxy] connect timed out after {}s to {}",
|
||||
CONNECT_TIMEOUT.as_secs(),
|
||||
url
|
||||
);
|
||||
format!("ws connect timed out after {}s", CONNECT_TIMEOUT.as_secs())
|
||||
})?
|
||||
.map_err(|e| {
|
||||
@@ -182,19 +181,28 @@ pub async fn ws_connect<R: Runtime>(
|
||||
match tofu::evaluate(&app, &host, &fingerprint)? {
|
||||
TofuOutcome::Trusted => {
|
||||
info!("[ws_proxy] TOFU check passed for {}", host);
|
||||
emit_cert_tofu(&app, serde_json::json!({
|
||||
"host": host,
|
||||
"fingerprint": fingerprint,
|
||||
"status": "trusted",
|
||||
}));
|
||||
emit_cert_tofu(
|
||||
&app,
|
||||
serde_json::json!({
|
||||
"host": host,
|
||||
"fingerprint": fingerprint,
|
||||
"status": "trusted",
|
||||
}),
|
||||
);
|
||||
}
|
||||
TofuOutcome::FirstUse => {
|
||||
info!("[ws_proxy] first-use cert for {} — awaiting user confirmation", host);
|
||||
emit_cert_tofu(&app, serde_json::json!({
|
||||
"host": host,
|
||||
"fingerprint": fingerprint,
|
||||
"status": "first_use",
|
||||
}));
|
||||
info!(
|
||||
"[ws_proxy] first-use cert for {} — awaiting user confirmation",
|
||||
host
|
||||
);
|
||||
emit_cert_tofu(
|
||||
&app,
|
||||
serde_json::json!({
|
||||
"host": host,
|
||||
"fingerprint": fingerprint,
|
||||
"status": "first_use",
|
||||
}),
|
||||
);
|
||||
// Do not open the socket: the user must confirm the fingerprint
|
||||
// (accept_cert_fingerprint) before anything is sent over it.
|
||||
return Err(format!(
|
||||
@@ -203,15 +211,21 @@ pub async fn ws_connect<R: Runtime>(
|
||||
}
|
||||
TofuOutcome::Mismatch { stored } => {
|
||||
let msg = tofu::mismatch_message(&host, &stored, &fingerprint);
|
||||
warn!("[ws_proxy] TOFU check FAILED for {} — certificate fingerprint mismatch", host);
|
||||
warn!(
|
||||
"[ws_proxy] TOFU check FAILED for {} — certificate fingerprint mismatch",
|
||||
host
|
||||
);
|
||||
debug!("[ws_proxy] TOFU detail: {}", msg);
|
||||
emit_cert_tofu(&app, serde_json::json!({
|
||||
"host": host,
|
||||
"fingerprint": fingerprint,
|
||||
"status": "mismatch",
|
||||
"message": msg,
|
||||
"storedFingerprint": stored,
|
||||
}));
|
||||
emit_cert_tofu(
|
||||
&app,
|
||||
serde_json::json!({
|
||||
"host": host,
|
||||
"fingerprint": fingerprint,
|
||||
"status": "mismatch",
|
||||
"message": msg,
|
||||
"storedFingerprint": stored,
|
||||
}),
|
||||
);
|
||||
// Reject the connection — do not proceed.
|
||||
return Err(msg);
|
||||
}
|
||||
@@ -311,10 +325,7 @@ pub async fn ws_connect<R: Runtime>(
|
||||
|
||||
/// Send a text message through the proxy WebSocket.
|
||||
#[tauri::command]
|
||||
pub async fn ws_send(
|
||||
state: tauri::State<'_, WsState>,
|
||||
message: String,
|
||||
) -> Result<(), String> {
|
||||
pub async fn ws_send(state: tauri::State<'_, WsState>, message: String) -> Result<(), String> {
|
||||
let tx_lock = state.tx.lock().await;
|
||||
if let Some(tx) = tx_lock.as_ref() {
|
||||
match tx.try_send(message) {
|
||||
@@ -395,8 +406,12 @@ pub fn accept_cert_fingerprint<R: Runtime>(
|
||||
// fingerprint would be trusted in-process even though it was never
|
||||
// persisted to certs.json.
|
||||
match old_value {
|
||||
Some(v) => { store.set(&host, v); }
|
||||
None => { let _ = store.delete(&host); }
|
||||
Some(v) => {
|
||||
store.set(&host, v);
|
||||
}
|
||||
None => {
|
||||
let _ = store.delete(&host);
|
||||
}
|
||||
}
|
||||
log::warn!("[ws_proxy] accept_cert_fingerprint: failed to persist pin for {host}: {e}");
|
||||
return Err(format!("failed to persist cert fingerprint: {e}"));
|
||||
@@ -591,7 +606,10 @@ mod tests {
|
||||
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");
|
||||
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
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"productName": "OwnCord",
|
||||
"version": "1.2.0-alpha.3",
|
||||
"version": "1.2.0-alpha.4",
|
||||
"identifier": "com.owncord.client",
|
||||
"build": {
|
||||
"frontendDist": "../dist",
|
||||
@@ -30,17 +30,8 @@
|
||||
"bundle": {
|
||||
"active": true,
|
||||
"createUpdaterArtifacts": "v1Compatible",
|
||||
"targets": [
|
||||
"nsis",
|
||||
"appimage",
|
||||
"deb"
|
||||
],
|
||||
"icon": [
|
||||
"icons/32x32.png",
|
||||
"icons/128x128.png",
|
||||
"icons/128x128@2x.png",
|
||||
"icons/icon.ico"
|
||||
],
|
||||
"targets": ["nsis", "appimage", "deb"],
|
||||
"icon": ["icons/32x32.png", "icons/128x128.png", "icons/128x128@2x.png", "icons/icon.ico"],
|
||||
"linux": {
|
||||
"deb": {
|
||||
"depends": [
|
||||
|
Before Width: | Height: | Size: 5.8 KiB After Width: | Height: | Size: 5.8 KiB |
@@ -100,7 +100,7 @@ function closeIdentityModal(): void {
|
||||
async function openIdentityMismatchModal(
|
||||
userId: number,
|
||||
username: string,
|
||||
signal: AbortSignal,
|
||||
lifetimeSignal: AbortSignal,
|
||||
): Promise<void> {
|
||||
closeIdentityModal();
|
||||
// Compute the newly-delivered key's fingerprint so the user can verify it
|
||||
@@ -117,8 +117,14 @@ async function openIdentityMismatchModal(
|
||||
log.warn("E2EE: could not compute changed-key fingerprint for re-pin modal", err);
|
||||
}
|
||||
}
|
||||
// The sidebar (or a newer open) may have superseded us during the async compute.
|
||||
if (signal.aborted) return;
|
||||
// The SIDEBAR (or a newer open) may have superseded us during the async
|
||||
// compute — but NOT a mere re-render: `lifetimeSignal` is the sidebar's own
|
||||
// factory-lifetime signal (aborted only in destroy()), not the per-render
|
||||
// one that renderChannels() replaces on every redraw (OC-0281). Binding this
|
||||
// check to the render signal made an unrelated re-render landing mid-compute
|
||||
// (a message in another channel, a peer toggling mute) turn the click into a
|
||||
// silent no-op.
|
||||
if (lifetimeSignal.aborted) return;
|
||||
closeIdentityModal();
|
||||
const modal = createIdentityMismatchModal({
|
||||
username,
|
||||
@@ -148,8 +154,10 @@ async function openIdentityMismatchModal(
|
||||
});
|
||||
modal.mount(document.body);
|
||||
activeIdentityModal = modal;
|
||||
// Close if the owning sidebar is destroyed while the modal is still open.
|
||||
signal.addEventListener("abort", closeIdentityModal, { once: true });
|
||||
// Close if the owning sidebar is destroyed while the modal is still open —
|
||||
// NOT on a re-render, which is why this is `lifetimeSignal` and not the
|
||||
// render-scoped signal (OC-0281).
|
||||
lifetimeSignal.addEventListener("abort", closeIdentityModal, { once: true });
|
||||
}
|
||||
|
||||
export interface ChannelReorderData {
|
||||
@@ -332,6 +340,7 @@ function buildVoiceModOptions(
|
||||
function renderVoiceChannelItem(
|
||||
channel: Channel,
|
||||
signal: AbortSignal,
|
||||
lifetimeSignal: AbortSignal,
|
||||
onVoiceJoin: (channelId: number) => void,
|
||||
onVoiceLeave: () => void,
|
||||
onWatchStream?: (userId: number) => void,
|
||||
@@ -494,7 +503,16 @@ function renderVoiceChannelItem(
|
||||
"click",
|
||||
(e) => {
|
||||
e.stopPropagation();
|
||||
void openIdentityMismatchModal(user.userId, user.username || "Unknown", signal);
|
||||
// lifetimeSignal (not the per-render `signal`): the modal must
|
||||
// survive an unrelated re-render, and must not be silently
|
||||
// skipped by one landing during the async fingerprint compute
|
||||
// (OC-0281). The click listener itself stays on the per-render
|
||||
// `signal` so it dies with this row (OC-0229).
|
||||
void openIdentityMismatchModal(
|
||||
user.userId,
|
||||
user.username || "Unknown",
|
||||
lifetimeSignal,
|
||||
);
|
||||
},
|
||||
{ signal },
|
||||
);
|
||||
@@ -514,7 +532,10 @@ function renderVoiceChannelItem(
|
||||
user.username || "Unknown",
|
||||
e.clientX,
|
||||
e.clientY,
|
||||
signal,
|
||||
// lifetimeSignal (not the per-render `signal`): the menu is
|
||||
// mounted on document.body, independent of this row's render,
|
||||
// and must not be torn down by an unrelated re-render (OC-0282).
|
||||
lifetimeSignal,
|
||||
buildVoiceModOptions(channel.id, user, onVoiceModerate),
|
||||
);
|
||||
},
|
||||
@@ -583,6 +604,7 @@ function renderChannelItem(
|
||||
channel: Channel,
|
||||
isActive: boolean,
|
||||
signal: AbortSignal,
|
||||
lifetimeSignal: AbortSignal,
|
||||
onVoiceJoin: (channelId: number) => void,
|
||||
onVoiceLeave: () => void,
|
||||
onEditChannel?: (channel: Channel) => void,
|
||||
@@ -599,6 +621,7 @@ function renderChannelItem(
|
||||
el = renderVoiceChannelItem(
|
||||
channel,
|
||||
signal,
|
||||
lifetimeSignal,
|
||||
onVoiceJoin,
|
||||
onVoiceLeave,
|
||||
onWatchStream,
|
||||
@@ -607,9 +630,25 @@ function renderChannelItem(
|
||||
} else {
|
||||
el = renderTextChannelItem(channel, isActive, signal);
|
||||
}
|
||||
attachChannelContextMenu(el, channel, signal, onEditChannel, onDeleteChannel, onPurgeChannel);
|
||||
attachChannelContextMenu(
|
||||
el,
|
||||
channel,
|
||||
signal,
|
||||
lifetimeSignal,
|
||||
onEditChannel,
|
||||
onDeleteChannel,
|
||||
onPurgeChannel,
|
||||
);
|
||||
if (containerEl !== undefined && channels !== undefined) {
|
||||
attachDragHandlers(el, channel, containerEl, channels, signal, onReorderChannel);
|
||||
attachDragHandlers(
|
||||
el,
|
||||
channel,
|
||||
containerEl,
|
||||
channels,
|
||||
signal,
|
||||
lifetimeSignal,
|
||||
onReorderChannel,
|
||||
);
|
||||
}
|
||||
return el;
|
||||
}
|
||||
@@ -619,6 +658,7 @@ function renderCategoryGroup(
|
||||
channels: readonly Channel[],
|
||||
activeChannelId: number | null,
|
||||
signal: AbortSignal,
|
||||
lifetimeSignal: AbortSignal,
|
||||
onVoiceJoin: (channelId: number) => void,
|
||||
onVoiceLeave: () => void,
|
||||
onCreateChannel?: (category: string) => void,
|
||||
@@ -689,6 +729,7 @@ function renderCategoryGroup(
|
||||
ch,
|
||||
ch.id === activeChannelId,
|
||||
signal,
|
||||
lifetimeSignal,
|
||||
onVoiceJoin,
|
||||
onVoiceLeave,
|
||||
onEditChannel,
|
||||
@@ -713,6 +754,7 @@ function renderCategoryGroup(
|
||||
ch,
|
||||
ch.id === activeChannelId,
|
||||
signal,
|
||||
lifetimeSignal,
|
||||
onVoiceJoin,
|
||||
onVoiceLeave,
|
||||
onEditChannel,
|
||||
@@ -821,6 +863,11 @@ export function createChannelSidebar(options: ChannelSidebarOptions): MountableC
|
||||
channels,
|
||||
state.activeChannelId,
|
||||
currentRenderAc.signal,
|
||||
// Sidebar-lifetime signal (aborted only in destroy()) for anything
|
||||
// that owns DOM mounted outside this render's rows -- a menu or
|
||||
// modal on document.body must not be torn down by an unrelated
|
||||
// re-render (OC-0281, OC-0282).
|
||||
ac.signal,
|
||||
onVoiceJoin,
|
||||
onVoiceLeave,
|
||||
onCreateChannel,
|
||||
@@ -13,7 +13,7 @@
|
||||
import { createElement, appendChildren, setText } from "@lib/dom";
|
||||
import type { MountableComponent } from "@lib/safe-render";
|
||||
import type { UserStatus } from "@lib/types";
|
||||
import { isRenderableAvatar } from "@lib/avatar";
|
||||
import { avatarInitial, isRenderableAvatar, resolveDisplayName } from "@lib/avatar";
|
||||
import { fetchImageAsDataUrl, resolveServerUrl } from "./message-list/attachments";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -23,6 +23,10 @@ import { fetchImageAsDataUrl, resolveServerUrl } from "./message-list/attachment
|
||||
export interface DmProfileData {
|
||||
readonly id: number;
|
||||
readonly username: string;
|
||||
/** Nickname, when set. The DM header this panel opens from renders through
|
||||
* `dmDisplayName`, which prefers this over `username` -- without it here
|
||||
* the panel would show a different identity from the header just clicked. */
|
||||
readonly displayName?: string | null;
|
||||
readonly avatar: string | null;
|
||||
readonly status: UserStatus;
|
||||
readonly about?: string | null;
|
||||
@@ -46,6 +50,18 @@ export interface DmProfileSidebarOptions {
|
||||
|
||||
export type DmProfileSidebarComponent = MountableComponent & {
|
||||
readonly isOpen: () => boolean;
|
||||
/**
|
||||
* Repaint the name, avatar initial and status (dot + label, both the
|
||||
* avatar-corner one and the inline one) from a fresher `DmProfileData`,
|
||||
* in place -- without rebuilding the panel and losing the note textarea's
|
||||
* focus/selection. The panel itself has no subscription to any store (it
|
||||
* is intentionally presentational); the owner is expected to call this
|
||||
* when the underlying user's presence or identity changes while the panel
|
||||
* stays open, mirroring how ChannelController keeps the DM chat header
|
||||
* live across the same events (see ChannelController.ts's refreshDmHeader).
|
||||
* A no-op before mount() or after destroy().
|
||||
*/
|
||||
readonly update: (user: DmProfileData) => void;
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -120,11 +136,21 @@ export function createDmProfileSidebar(
|
||||
): DmProfileSidebarComponent {
|
||||
const ac = new AbortController();
|
||||
const { signal } = ac;
|
||||
const { user, onClose, host = "" } = options;
|
||||
const { onClose, host = "" } = options;
|
||||
let user = options.user;
|
||||
|
||||
let panel: HTMLDivElement | null = null;
|
||||
let open = false;
|
||||
|
||||
// Live-updatable node refs, populated on mount() and cleared on destroy()
|
||||
// -- see the `update()` doc comment on DmProfileSidebarComponent for why
|
||||
// these are repainted in place instead of the whole panel being rebuilt.
|
||||
let nameNode: HTMLDivElement | null = null;
|
||||
let avatarLetterNode: HTMLSpanElement | null = null;
|
||||
let statusDotNode: HTMLDivElement | null = null;
|
||||
let statusDotInlineNode: HTMLSpanElement | null = null;
|
||||
let statusTextNode: HTMLSpanElement | null = null;
|
||||
|
||||
function isOpen(): boolean {
|
||||
return open;
|
||||
}
|
||||
@@ -152,8 +178,9 @@ export function createDmProfileSidebar(
|
||||
// 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 initial = avatarInitial(user);
|
||||
const letter = createElement("span", {}, initial);
|
||||
avatarLetterNode = letter;
|
||||
wrapper.appendChild(letter);
|
||||
|
||||
if (isRenderableAvatar(user.avatar)) {
|
||||
@@ -162,7 +189,7 @@ export function createDmProfileSidebar(
|
||||
if (dataUrl === null || !wrapper.isConnected) return;
|
||||
const img = createElement("img", {
|
||||
src: dataUrl,
|
||||
alt: user.username,
|
||||
alt: resolveDisplayName(user),
|
||||
class: "dps-avatar-img",
|
||||
});
|
||||
img.style.width = "80px";
|
||||
@@ -185,6 +212,7 @@ export function createDmProfileSidebar(
|
||||
statusDot.style.border = "3px solid var(--bg-secondary, #111214)";
|
||||
statusDot.style.background = STATUS_COLORS[user.status] ?? STATUS_COLORS.offline;
|
||||
statusDot.title = STATUS_LABELS[user.status] ?? "Offline";
|
||||
statusDotNode = statusDot;
|
||||
wrapper.appendChild(statusDot);
|
||||
|
||||
return wrapper;
|
||||
@@ -261,7 +289,8 @@ export function createDmProfileSidebar(
|
||||
nameEl.style.fontWeight = "600";
|
||||
nameEl.style.color = "var(--text-primary, #f2f3f5)";
|
||||
nameEl.style.marginBottom = "4px";
|
||||
setText(nameEl, user.username);
|
||||
setText(nameEl, resolveDisplayName(user));
|
||||
nameNode = nameEl;
|
||||
|
||||
// Status line
|
||||
const statusLine = createElement("div", {
|
||||
@@ -282,8 +311,10 @@ export function createDmProfileSidebar(
|
||||
statusDotInline.style.borderRadius = "50%";
|
||||
statusDotInline.style.display = "inline-block";
|
||||
statusDotInline.style.background = STATUS_COLORS[user.status] ?? STATUS_COLORS.offline;
|
||||
statusDotInlineNode = statusDotInline;
|
||||
|
||||
const statusText = createElement("span", {}, STATUS_LABELS[user.status] ?? "Offline");
|
||||
statusTextNode = statusText;
|
||||
appendChildren(statusLine, statusDotInline, statusText);
|
||||
|
||||
appendChildren(content, nameEl, statusLine);
|
||||
@@ -409,7 +440,41 @@ export function createDmProfileSidebar(
|
||||
panel.remove();
|
||||
panel = null;
|
||||
}
|
||||
nameNode = null;
|
||||
avatarLetterNode = null;
|
||||
statusDotNode = null;
|
||||
statusDotInlineNode = null;
|
||||
statusTextNode = null;
|
||||
}
|
||||
|
||||
return { mount, destroy, isOpen };
|
||||
function update(nextUser: DmProfileData): void {
|
||||
user = nextUser;
|
||||
// Not mounted (or already torn down) -- nothing to repaint. mount() will
|
||||
// paint the fresh `user` from scratch if it is called afterwards.
|
||||
if (panel === null) return;
|
||||
|
||||
if (nameNode !== null) setText(nameNode, resolveDisplayName(user));
|
||||
|
||||
const color = STATUS_COLORS[user.status] ?? STATUS_COLORS.offline;
|
||||
const label = STATUS_LABELS[user.status] ?? "Offline";
|
||||
|
||||
if (statusDotNode !== null) {
|
||||
statusDotNode.style.background = color;
|
||||
statusDotNode.title = label;
|
||||
}
|
||||
if (statusDotInlineNode !== null) {
|
||||
statusDotInlineNode.style.background = color;
|
||||
}
|
||||
if (statusTextNode !== null) setText(statusTextNode, label);
|
||||
|
||||
// Only repaint the fallback letter if it is still showing -- once the
|
||||
// fetched avatar image swaps in, buildAvatar() removes the letter node
|
||||
// from the DOM (see above), and a stale identity's initial no longer
|
||||
// matters (or exists) to update.
|
||||
if (avatarLetterNode !== null && avatarLetterNode.isConnected) {
|
||||
setText(avatarLetterNode, avatarInitial(user));
|
||||
}
|
||||
}
|
||||
|
||||
return { mount, destroy, isOpen, update };
|
||||
}
|
||||
@@ -4,6 +4,7 @@
|
||||
import { createElement, setText, clearChildren } from "@lib/dom";
|
||||
import { enableRovingNavigation, setRovingTabindex } from "@lib/a11y";
|
||||
import { buildCustomEmojiNode } from "@components/message-list/custom-emoji";
|
||||
import { resolveEmoji } from "@stores/emoji.store";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
@@ -517,7 +518,19 @@ function getRecentEmoji(): string[] {
|
||||
if (!raw) return [];
|
||||
const parsed: unknown = JSON.parse(raw);
|
||||
if (!Array.isArray(parsed)) return [];
|
||||
return parsed.filter((e): e is string => typeof e === "string").slice(0, MAX_RECENT);
|
||||
return (
|
||||
parsed
|
||||
.filter((e): e is string => typeof e === "string")
|
||||
// A `:shortcode:`-shaped entry is only meaningful when it still
|
||||
// resolves on *this* server — the recent list is global (unscoped by
|
||||
// host), so a custom emoji clicked on one server would otherwise leak
|
||||
// as dead literal text into every other server's picker, and a
|
||||
// deleted emoji would do the same on its own server forever after.
|
||||
// Plain unicode entries (no colons) are never shortcode-shaped and
|
||||
// pass through untouched.
|
||||
.filter((e) => !(e.startsWith(":") && e.endsWith(":")) || resolveEmoji(e) !== null)
|
||||
.slice(0, MAX_RECENT)
|
||||
);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
@@ -571,6 +584,27 @@ export function createEmojiPicker(options: EmojiPickerOptions): {
|
||||
root.appendChild(scrollArea);
|
||||
enableRovingNavigation(scrollArea, ".ep-emoji", signal);
|
||||
|
||||
// Single delegated listener for the whole grid, registered once at mount
|
||||
// time. renderAllCategories() discards and rebuilds every cell on each
|
||||
// search keystroke (~250 cells per render); a listener bound directly to
|
||||
// each cell would register (and, since it lives on the picker-lifetime
|
||||
// `signal`, never release) one abort algorithm per discarded cell for the
|
||||
// rest of the picker's life — the same pattern SearchOverlay.ts's
|
||||
// handleResultsClick already fixes for its rows.
|
||||
scrollArea.addEventListener(
|
||||
"click",
|
||||
(e) => {
|
||||
const target = e.target;
|
||||
if (!(target instanceof Element)) return;
|
||||
const cell = target.closest<HTMLElement>(".ep-emoji");
|
||||
if (cell === null) return;
|
||||
const emoji = cell.dataset.emoji;
|
||||
if (emoji === undefined) return;
|
||||
handleEmojiClick(emoji);
|
||||
},
|
||||
{ signal },
|
||||
);
|
||||
|
||||
// Build categories with recent + custom
|
||||
function getAllCategories(): readonly EmojiCategory[] {
|
||||
const recent = getRecentEmoji();
|
||||
@@ -606,6 +640,9 @@ export function createEmojiPicker(options: EmojiPickerOptions): {
|
||||
// Mirrors the title (the character or :shortcode: token) — e2e specs
|
||||
// select cells by title, so the accessible name must never diverge.
|
||||
"aria-label": emoji,
|
||||
// Read by the delegated click handler on scrollArea (see mount-time
|
||||
// listener above) instead of a per-cell listener.
|
||||
"data-emoji": emoji,
|
||||
});
|
||||
// A `:shortcode:` entry shows its image; everything else is the character
|
||||
// itself. An unresolvable shortcode falls back to the text, which is what
|
||||
@@ -617,7 +654,6 @@ export function createEmojiPicker(options: EmojiPickerOptions): {
|
||||
} else {
|
||||
setText(span, emoji);
|
||||
}
|
||||
span.addEventListener("click", () => handleEmojiClick(emoji), { signal });
|
||||
return span;
|
||||
}
|
||||
|
||||
@@ -462,11 +462,30 @@ export function createMemberList(opts: MemberListOptions): MountableComponent {
|
||||
/** Rendered rows by user id \u2014 lets presence-only updates patch in place. */
|
||||
const rowsByUserId = new Map<number, HTMLDivElement>();
|
||||
let prevMembers: ReadonlyMap<number, Member> = new Map();
|
||||
// renderList() rebuilds every row from scratch on every non-presence-only
|
||||
// membersStore change and on every roles_update. Per-row listeners (click,
|
||||
// contextmenu) must NOT be registered on the component-lifetime
|
||||
// `disposable.signal`, which only aborts once, at destroy() --
|
||||
// addEventListener({ signal }) keeps a detached row alive via that signal's
|
||||
// own retained "abort" listener list until it fires, so every rebuild would
|
||||
// otherwise leak one full set of detached rows (OC-0295), exactly the
|
||||
// defect already fixed in ChannelSidebar (renderAc, OC-0229) and
|
||||
// MessageList (OC-0286). renderAc is aborted and replaced at the top of
|
||||
// every render, so only the CURRENT render's rows stay reachable.
|
||||
let renderAc: AbortController | null = null;
|
||||
|
||||
function render(): void {
|
||||
if (root === null) return;
|
||||
renderAc?.abort();
|
||||
const currentRenderAc = new AbortController();
|
||||
renderAc = currentRenderAc;
|
||||
renderList(root, opts, currentRenderAc.signal, rowsByUserId);
|
||||
}
|
||||
|
||||
function mount(container: Element): void {
|
||||
root = createElement("div", { class: "member-list", "data-testid": "member-list" });
|
||||
prevMembers = membersStore.getState().members;
|
||||
renderList(root, opts, disposable.signal, rowsByUserId);
|
||||
render();
|
||||
|
||||
disposable.onStoreChange<MembersState, ReadonlyMap<number, Member>>(
|
||||
membersStore,
|
||||
@@ -476,7 +495,7 @@ export function createMemberList(opts: MemberListOptions): MountableComponent {
|
||||
if (isPresenceOnlyChange(prevMembers, members)) {
|
||||
patchPresence(prevMembers, members, rowsByUserId);
|
||||
} else {
|
||||
renderList(root, opts, disposable.signal, rowsByUserId);
|
||||
render();
|
||||
}
|
||||
}
|
||||
prevMembers = members;
|
||||
@@ -491,9 +510,7 @@ export function createMemberList(opts: MemberListOptions): MountableComponent {
|
||||
channelsStore,
|
||||
(s) => s.roles,
|
||||
() => {
|
||||
if (root !== null) {
|
||||
renderList(root, opts, disposable.signal, rowsByUserId);
|
||||
}
|
||||
render();
|
||||
},
|
||||
);
|
||||
|
||||
@@ -505,6 +522,8 @@ export function createMemberList(opts: MemberListOptions): MountableComponent {
|
||||
closeActivePopup();
|
||||
document.removeEventListener("mousedown", handleOutsideClick);
|
||||
disposable.destroy();
|
||||
renderAc?.abort();
|
||||
renderAc = null;
|
||||
rowsByUserId.clear();
|
||||
if (root !== null) {
|
||||
root.remove();
|
||||