mirror of
https://github.com/J3vb/OwnCord.git
synced 2026-09-03 03:50:00 +03:00
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>
This commit is contained in:
co-authored by
Claude Opus 5
dependabot[bot]
parent
259225ac61
commit
b7d388a39c
+105
@@ -0,0 +1,105 @@
|
||||
# Documentation index
|
||||
|
||||
Every document in `docs/` is listed here. If it is not on this page it is not
|
||||
current guidance.
|
||||
|
||||
Docs fall into four kinds, and the difference matters when you are deciding
|
||||
whether to trust one: **guidance** tells you how to do something,
|
||||
**reference** describes a contract the code actually implements, **audits** are
|
||||
dated snapshots that were true when written and were never updated, and
|
||||
**plans** record intent. Read an audit as history, not as status.
|
||||
|
||||
## Start here
|
||||
|
||||
| I want to… | Read |
|
||||
| ----------------------- | ----------------------------------------------------------- |
|
||||
| Run a server | [quick-start.md](quick-start.md) |
|
||||
| Deploy for real | [deployment.md](deployment.md) |
|
||||
| Contribute a change | [contributing.md](contributing.md) |
|
||||
| Understand the system | [architecture/](architecture/README.md) |
|
||||
| Report a bug | [Issues](https://github.com/J3vb/OwnCord/issues/new/choose) |
|
||||
| Ask, or suggest an idea | [Discussions](https://github.com/J3vb/OwnCord/discussions) |
|
||||
| Report a vulnerability | [security.md](security.md) |
|
||||
|
||||
## Guidance
|
||||
|
||||
| Document | Covers |
|
||||
| ---------------------------------------- | ------------------------------------------------------------------------------------------------ |
|
||||
| [quick-start.md](quick-start.md) | Getting a server running with the fewest steps. |
|
||||
| [deployment.md](deployment.md) | Production deployment on Windows and Linux. |
|
||||
| [contributing.md](contributing.md) | Environment setup, **the branch and PR model**, coding standards, how to run the checks CI runs. |
|
||||
| [security.md](security.md) | How to report a vulnerability, and how findings are handled in public vs private. |
|
||||
| [livekit-setup.md](livekit-setup.md) | Standing up the LiveKit SFU for voice and video. |
|
||||
| [port-forwarding.md](port-forwarding.md) | Making a server reachable from outside the LAN. |
|
||||
| [tailscale.md](tailscale.md) | Remote access without port forwarding. |
|
||||
| [mcp-introspect.md](mcp-introspect.md) | Dev-only MCP server for introspecting a running instance. |
|
||||
|
||||
## Reference
|
||||
|
||||
These describe contracts the code implements. If one disagrees with the code,
|
||||
the code is right and the document is a bug.
|
||||
|
||||
| Document | Covers |
|
||||
| -------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| [api.md](api.md) | REST API under `/api/v1`. |
|
||||
| [protocol.md](protocol.md) | WebSocket protocol — frames, sequencing, reconnect. |
|
||||
| [schema.md](schema.md) | SQLite schema and migrations. |
|
||||
| [server-configuration.md](server-configuration.md) | Every server configuration option. |
|
||||
| [credential-storage.md](credential-storage.md) | What the desktop client persists, and where. |
|
||||
| [../protocol/schema.json](../protocol/schema.json) | **Generated-code source of truth**, at the repository root because it is owned by neither side. `Server/ws/message_types.go` and `Client/src/lib/protocolTypes.ts` are generated from it — never hand-edit either. See [../protocol/README.md](../protocol/README.md). |
|
||||
|
||||
## Architecture
|
||||
|
||||
[architecture/README.md](architecture/README.md) indexes the blueprints and
|
||||
carries the maintenance rule: each blueprint names its source-of-truth files,
|
||||
and a PR touching those updates the blueprint in the same change.
|
||||
|
||||
- [system-overview.md](architecture/system-overview.md), [server.md](architecture/server.md), [client.md](architecture/client.md)
|
||||
- [data-model.md](architecture/data-model.md), [websocket.md](architecture/websocket.md), [voice-e2ee.md](architecture/voice-e2ee.md)
|
||||
- [ux/](architecture/ux/README.md) — target-state UX spec, per-view states and event→reaction maps
|
||||
- [platform-contracts.md](architecture/platform-contracts.md) — target-state desktop/browser seam: what has to move behind a contract before the client can run in a browser (B7)
|
||||
|
||||
[client-architecture.md](client-architecture.md) is a redirect stub; the live
|
||||
document is [architecture/client.md](architecture/client.md).
|
||||
|
||||
## Audits — dated, not maintained
|
||||
|
||||
Point-in-time snapshots. They are **not** updated as the code moves, and they
|
||||
are deliberately left alone when paths change, so links from commit messages
|
||||
keep resolving. Anything here may be stale; the ledger and the plan index carry
|
||||
current status.
|
||||
|
||||
| Audit | Scope |
|
||||
| ------------------------------------------------------------------------------ | ------------------------------------------------------------------------- |
|
||||
| [audit-2026-08-23-repository-layout.md](audit-2026-08-23-repository-layout.md) | Repository layout and contributor experience (`RL-01`…`RL-22`). |
|
||||
| [audit-2026-08-23-repository-health.md](audit-2026-08-23-repository-health.md) | Full repository health. |
|
||||
| [audit-2026-08-19.md](audit-2026-08-19.md) | Repo health. **States "0 open findings" — untrue since; see the ledger.** |
|
||||
| [audit-test-coverage-2026-08-19.md](audit-test-coverage-2026-08-19.md) | Test audit (`T-*`, a separate register from the `OC-*` ledger). |
|
||||
| [audit-2026-08-04-docs-and-coverage.md](audit-2026-08-04-docs-and-coverage.md) | Documentation accuracy and UI/UX test coverage. |
|
||||
| [audit-2026-08-04.md](audit-2026-08-04.md) | Security review. |
|
||||
| [audit-test-coverage-2026-07-25.md](audit-test-coverage-2026-07-25.md) | Test-coverage audit. |
|
||||
| [audit-2026-07-19.md](audit-2026-07-19.md) | Architecture and spec-conformance review. |
|
||||
| [audit-2026-04-07.md](audit-2026-04-07.md) | First comprehensive audit. |
|
||||
|
||||
## Plans
|
||||
|
||||
[plans/README.md](plans/README.md) indexes every plan with a recorded state —
|
||||
active, partially implemented, design-only, or shipped — and **is the authority
|
||||
over a plan's own header**, which can drift.
|
||||
|
||||
## Where status actually lives
|
||||
|
||||
Do not read a defect count, or a "what works" claim, out of a document on this
|
||||
page. Status has owners:
|
||||
|
||||
| Concern | Source of truth |
|
||||
| -------------------------- | ---------------------------------------------------------------------------------- |
|
||||
| Defect status | `.superpowers/findings-ledger.json` (`FINDINGS.md` is rendered from it) |
|
||||
| Security-sensitive defects | Private GitHub Security Advisories |
|
||||
| Phase order and gates | [plans/repo-health-roadmap-2026-08-23.md](plans/repo-health-roadmap-2026-08-23.md) |
|
||||
| Current measured baseline | [plans/b0-baseline-2026-08-25.md](plans/b0-baseline-2026-08-25.md) |
|
||||
| Generated-code contracts | `CLAUDE.md`, "Generated code — never hand-edit" |
|
||||
|
||||
A CI job checks that documents on this page do not contradict the ledger's
|
||||
counts. Adding a count to a document means adding it to that check's allow-list
|
||||
in `scripts/check-doc-counts.mjs`.
|
||||
+244
-216
@@ -46,19 +46,19 @@ endpoints return plain-text errors — see their section):
|
||||
|
||||
### Error Codes
|
||||
|
||||
| Code | HTTP Status | When It Occurs |
|
||||
| ---- | ----------- | -------------- |
|
||||
| `UNAUTHORIZED` | 401 | Missing/invalid/expired session token |
|
||||
| `INVALID_CREDENTIALS` | 401 | Login/register with bad username/password/invite (generic to prevent enumeration) |
|
||||
| `FORBIDDEN` | 403 | Insufficient permissions, banned account, or admin IP restriction |
|
||||
| `NOT_FOUND` | 404 | Resource (channel, message, user, invite, file, backup) not found |
|
||||
| `RATE_LIMITED` | 429 | Too many requests; response includes `Retry-After` header (seconds) |
|
||||
| `INVALID_INPUT` / `BAD_REQUEST` | 400 | Malformed body, missing required fields, invalid query params, or an upload exceeding the size limit (oversize uploads are rejected 400, not 413; the only 413 in the API is the plugin-install endpoint's plain-text "plugin upload too large") |
|
||||
| `CONFLICT` | 409 | Duplicate username on register, or server already up-to-date on update |
|
||||
| `INTERNAL_ERROR` | 500 | Internal server error |
|
||||
| `STORAGE_ERROR` | 507 | Upload could not be persisted (storage backend write failure) |
|
||||
| `BAD_GATEWAY` | 502 | Upstream failure (GitHub API, LiveKit, GIF provider, asset download) |
|
||||
| `GIF_DISABLED` | 503 | GIF proxy is not configured on this server (no `gif.api_key`) |
|
||||
| Code | HTTP Status | When It Occurs |
|
||||
| ------------------------------- | ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| `UNAUTHORIZED` | 401 | Missing/invalid/expired session token |
|
||||
| `INVALID_CREDENTIALS` | 401 | Login/register with bad username/password/invite (generic to prevent enumeration) |
|
||||
| `FORBIDDEN` | 403 | Insufficient permissions, banned account, or admin IP restriction |
|
||||
| `NOT_FOUND` | 404 | Resource (channel, message, user, invite, file, backup) not found |
|
||||
| `RATE_LIMITED` | 429 | Too many requests; response includes `Retry-After` header (seconds) |
|
||||
| `INVALID_INPUT` / `BAD_REQUEST` | 400 | Malformed body, missing required fields, invalid query params, or an upload exceeding the size limit (oversize uploads are rejected 400, not 413; the only 413 in the API is the plugin-install endpoint's plain-text "plugin upload too large") |
|
||||
| `CONFLICT` | 409 | Duplicate username on register, or server already up-to-date on update |
|
||||
| `INTERNAL_ERROR` | 500 | Internal server error |
|
||||
| `STORAGE_ERROR` | 507 | Upload could not be persisted (storage backend write failure) |
|
||||
| `BAD_GATEWAY` | 502 | Upstream failure (GitHub API, LiveKit, GIF provider, asset download) |
|
||||
| `GIF_DISABLED` | 503 | GIF proxy is not configured on this server (no `gif.api_key`) |
|
||||
|
||||
---
|
||||
|
||||
@@ -81,11 +81,11 @@ Create a new account using an invite code. The first user is created via `/admin
|
||||
}
|
||||
```
|
||||
|
||||
| Field | Type | Required | Notes |
|
||||
| ----- | ---- | -------- | ----- |
|
||||
| `username` | string | Yes | HTML-stripped, trimmed. Must be non-empty. |
|
||||
| `password` | string | Yes | Validated for strength (min length, complexity). |
|
||||
| `invite_code` | string | Yes | Must be a valid, non-expired, non-revoked invite with remaining uses. |
|
||||
| Field | Type | Required | Notes |
|
||||
| ------------- | ------ | -------- | --------------------------------------------------------------------- |
|
||||
| `username` | string | Yes | HTML-stripped, trimmed. Must be non-empty. |
|
||||
| `password` | string | Yes | Validated for strength (min length, complexity). |
|
||||
| `invite_code` | string | Yes | Must be a valid, non-expired, non-revoked invite with remaining uses. |
|
||||
|
||||
#### Response 201 Created
|
||||
|
||||
@@ -111,13 +111,13 @@ See [GET /api/v1/auth/me](#get-apiv1authme) for the full user-object field table
|
||||
|
||||
#### Errors
|
||||
|
||||
| Status | Code | Cause |
|
||||
| ------ | ---- | ----- |
|
||||
| 400 | `INVALID_INPUT` | Missing username/password/invite_code, or weak password |
|
||||
| 400 | `INVALID_CREDENTIALS` | Bad invite code, expired/revoked invite, or duplicate username |
|
||||
| 403 | `FORBIDDEN` | Registration is closed or unavailable while server-wide 2FA is required |
|
||||
| 429 | `RATE_LIMITED` | Exceeded 3 registrations/minute from this IP |
|
||||
| 500 | `INTERNAL_ERROR` | Hashing failure, session creation failure, or DB error |
|
||||
| Status | Code | Cause |
|
||||
| ------ | --------------------- | ----------------------------------------------------------------------- |
|
||||
| 400 | `INVALID_INPUT` | Missing username/password/invite_code, or weak password |
|
||||
| 400 | `INVALID_CREDENTIALS` | Bad invite code, expired/revoked invite, or duplicate username |
|
||||
| 403 | `FORBIDDEN` | Registration is closed or unavailable while server-wide 2FA is required |
|
||||
| 429 | `RATE_LIMITED` | Exceeded 3 registrations/minute from this IP |
|
||||
| 500 | `INTERNAL_ERROR` | Hashing failure, session creation failure, or DB error |
|
||||
|
||||
---
|
||||
|
||||
@@ -173,13 +173,13 @@ If the account has TOTP enabled:
|
||||
|
||||
#### Errors
|
||||
|
||||
| Status | Code | Cause |
|
||||
| ------ | ---- | ----- |
|
||||
| 400 | `INVALID_INPUT` | Missing username or password |
|
||||
| 401 | `UNAUTHORIZED` | Wrong username or password |
|
||||
| 403 | `FORBIDDEN` | Account is banned/suspended |
|
||||
| 429 | `RATE_LIMITED` | IP locked out after 10 consecutive failures (15 min cooldown) |
|
||||
| 500 | `INTERNAL_ERROR` | Session creation failure |
|
||||
| Status | Code | Cause |
|
||||
| ------ | ---------------- | ------------------------------------------------------------- |
|
||||
| 400 | `INVALID_INPUT` | Missing username or password |
|
||||
| 401 | `UNAUTHORIZED` | Wrong username or password |
|
||||
| 403 | `FORBIDDEN` | Account is banned/suspended |
|
||||
| 429 | `RATE_LIMITED` | IP locked out after 10 consecutive failures (15 min cooldown) |
|
||||
| 500 | `INTERNAL_ERROR` | Session creation failure |
|
||||
|
||||
---
|
||||
|
||||
@@ -223,11 +223,11 @@ See [GET /api/v1/auth/me](#get-apiv1authme) for the full user-object field table
|
||||
|
||||
#### Errors
|
||||
|
||||
| Status | Code | Cause |
|
||||
| ------ | ---- | ----- |
|
||||
| 400 | `INVALID_INPUT` | Malformed request body |
|
||||
| 401 | `UNAUTHORIZED` | Missing/expired challenge, invalid TOTP code, or challenge consumed |
|
||||
| 500 | `INTERNAL_ERROR` | Session creation failure |
|
||||
| Status | Code | Cause |
|
||||
| ------ | ---------------- | ------------------------------------------------------------------- |
|
||||
| 400 | `INVALID_INPUT` | Malformed request body |
|
||||
| 401 | `UNAUTHORIZED` | Missing/expired challenge, invalid TOTP code, or challenge consumed |
|
||||
| 500 | `INTERNAL_ERROR` | Session creation failure |
|
||||
|
||||
---
|
||||
|
||||
@@ -257,18 +257,18 @@ Get the current authenticated user's profile.
|
||||
This is the canonical **user object**, also returned as `user` by register,
|
||||
login and the TOTP challenge.
|
||||
|
||||
| Field | Type | Description |
|
||||
| ----- | ---- | ----------- |
|
||||
| `id` | int64 | User ID |
|
||||
| `username` | string | Unique handle; the name `@mentions` resolve against |
|
||||
| `avatar` | string | Avatar URL (`/api/v1/files/{id}` after an upload, or an `https://` URL), or empty string |
|
||||
| `display_name` | string\|null | Nickname rendered instead of `username`; null when unset |
|
||||
| `about` | string\|null | Profile bio, max 300 characters; null when unset |
|
||||
| `custom_status` | string\|null | Free-text status line, max 128 characters; null when unset. Set over WebSocket (`presence_update`), not over REST |
|
||||
| `status` | string | One of: `online`, `idle`, `dnd`, `invisible`, `offline`. **This is the caller's own true status**, so `invisible` appears here; every payload describing this user to *anyone else* reports `offline` instead |
|
||||
| `role_id` | int64 | Numeric role ID (1=Owner, 2=Admin, 3=Moderator, 4=Member) |
|
||||
| `totp_enabled` | bool | Whether the user has a confirmed TOTP secret |
|
||||
| `created_at` | string | ISO 8601 timestamp |
|
||||
| Field | Type | Description |
|
||||
| --------------- | ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `id` | int64 | User ID |
|
||||
| `username` | string | Unique handle; the name `@mentions` resolve against |
|
||||
| `avatar` | string | Avatar URL (`/api/v1/files/{id}` after an upload, or an `https://` URL), or empty string |
|
||||
| `display_name` | string\|null | Nickname rendered instead of `username`; null when unset |
|
||||
| `about` | string\|null | Profile bio, max 300 characters; null when unset |
|
||||
| `custom_status` | string\|null | Free-text status line, max 128 characters; null when unset. Set over WebSocket (`presence_update`), not over REST |
|
||||
| `status` | string | One of: `online`, `idle`, `dnd`, `invisible`, `offline`. **This is the caller's own true status**, so `invisible` appears here; every payload describing this user to _anyone else_ reports `offline` instead |
|
||||
| `role_id` | int64 | Numeric role ID (1=Owner, 2=Admin, 3=Moderator, 4=Member) |
|
||||
| `totp_enabled` | bool | Whether the user has a confirmed TOTP secret |
|
||||
| `created_at` | string | ISO 8601 timestamp |
|
||||
|
||||
---
|
||||
|
||||
@@ -303,12 +303,12 @@ Account deleted successfully. All sessions, messages (soft-deleted), and associa
|
||||
|
||||
#### Errors
|
||||
|
||||
| Status | Code | Cause |
|
||||
| ------ | ---- | ----- |
|
||||
| 400 | `INVALID_INPUT` | Missing or incorrect password |
|
||||
| 403 | `FORBIDDEN` | Cannot delete the last admin account |
|
||||
| 429 | `RATE_LIMITED` | Locked out after 3 failed password attempts (15 min cooldown) |
|
||||
| 500 | `INTERNAL_ERROR` | Database error during deletion |
|
||||
| Status | Code | Cause |
|
||||
| ------ | ---------------- | ------------------------------------------------------------- |
|
||||
| 400 | `INVALID_INPUT` | Missing or incorrect password |
|
||||
| 403 | `FORBIDDEN` | Cannot delete the last admin account |
|
||||
| 429 | `RATE_LIMITED` | Locked out after 3 failed password attempts (15 min cooldown) |
|
||||
| 500 | `INTERNAL_ERROR` | Database error during deletion |
|
||||
|
||||
---
|
||||
|
||||
@@ -399,13 +399,13 @@ event replaces the client's copy rather than patching it).
|
||||
}
|
||||
```
|
||||
|
||||
| Field | Rules |
|
||||
| -------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| `username` | Required. The unique handle; `@mentions` resolve against it. |
|
||||
| `avatar` | Optional. Must be an `https://` URL (max 512 chars) or `""` to clear. Upload a file instead with `POST /api/v1/users/me/avatar`. |
|
||||
| `display_name` | Optional, 1–32 characters. Shown instead of `username` everywhere; `""` clears it and falls back to the username. Rejected if it contains control or invisible (bidi-override) characters. |
|
||||
| `about` | Optional, max 300 characters. `""` clears it. |
|
||||
| `identity_public_key` | Optional, base64, max 128 characters. Publishes the client's long-term E2EE identity public key for voice TOFU pinning (see [protocol.md](protocol.md), Voice End-to-End Encryption). |
|
||||
| Field | Rules |
|
||||
| --------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| `username` | Required. The unique handle; `@mentions` resolve against it. |
|
||||
| `avatar` | Optional. Must be an `https://` URL (max 512 chars) or `""` to clear. Upload a file instead with `POST /api/v1/users/me/avatar`. |
|
||||
| `display_name` | Optional, 1–32 characters. Shown instead of `username` everywhere; `""` clears it and falls back to the username. Rejected if it contains control or invisible (bidi-override) characters. |
|
||||
| `about` | Optional, max 300 characters. `""` clears it. |
|
||||
| `identity_public_key` | Optional, base64, max 128 characters. Publishes the client's long-term E2EE identity public key for voice TOFU pinning (see [protocol.md](protocol.md), Voice End-to-End Encryption). |
|
||||
|
||||
Omitting a field leaves it unchanged; sending `""` clears the nullable ones.
|
||||
`display_name` and `about` are HTML-sanitized and trimmed server-side, and the
|
||||
@@ -476,7 +476,7 @@ client is expected to downscale and square-crop before uploading.
|
||||
### PUT /api/v1/users/me/password
|
||||
|
||||
Change the authenticated user's password. Verifies the old password, enforces
|
||||
password strength, and revokes all *other* sessions on success.
|
||||
password strength, and revokes all _other_ sessions on success.
|
||||
|
||||
**Auth:** Required
|
||||
**Rate limit:** 5 requests/minute, plus a failed-confirmation lockout on
|
||||
@@ -500,11 +500,11 @@ old one).
|
||||
|
||||
#### Errors
|
||||
|
||||
| Status | Code | Cause |
|
||||
| ------ | ---- | ----- |
|
||||
| 400 | `INVALID_INPUT` | Weak new password, or new password equals old |
|
||||
| 403 | `FORBIDDEN` | Incorrect old password |
|
||||
| 429 | `RATE_LIMITED` | Too many attempts / lockout |
|
||||
| Status | Code | Cause |
|
||||
| ------ | --------------- | --------------------------------------------- |
|
||||
| 400 | `INVALID_INPUT` | Weak new password, or new password equals old |
|
||||
| 403 | `FORBIDDEN` | Incorrect old password |
|
||||
| 429 | `RATE_LIMITED` | Too many attempts / lockout |
|
||||
|
||||
---
|
||||
|
||||
@@ -571,19 +571,19 @@ List all channels the authenticated user has `READ_MESSAGES` permission for. DM
|
||||
]
|
||||
```
|
||||
|
||||
| Field | Type | Description |
|
||||
| ----- | ---- | ----------- |
|
||||
| `id` | int64 | Channel ID |
|
||||
| `name` | string | Channel name |
|
||||
| `type` | string | `text`, `voice`, or `announcement` (announcement channels are read like text but only `MANAGE_MESSAGES` holders can post) |
|
||||
| `topic` | string | Channel topic/description |
|
||||
| `category` | string | Category grouping |
|
||||
| `position` | int | Sort order within category |
|
||||
| `slow_mode` | int | Slow-mode delay in seconds (0 = disabled) |
|
||||
| `archived` | bool | Whether the channel is archived |
|
||||
| `nsfw` | bool | Age-restriction label. **Stored and shipped only** — the server applies no content behaviour to a flagged channel (see below) |
|
||||
| `voice_max_users` | int | Voice capacity, 0 = unlimited. Enforced on join (`CHANNEL_FULL`) |
|
||||
| `voice_max_video` | int | Simultaneous cameras/screen shares, 0 = unlimited. Enforced on publish (`VIDEO_LIMIT`) |
|
||||
| Field | Type | Description |
|
||||
| ----------------- | ------ | ----------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `id` | int64 | Channel ID |
|
||||
| `name` | string | Channel name |
|
||||
| `type` | string | `text`, `voice`, or `announcement` (announcement channels are read like text but only `MANAGE_MESSAGES` holders can post) |
|
||||
| `topic` | string | Channel topic/description |
|
||||
| `category` | string | Category grouping |
|
||||
| `position` | int | Sort order within category |
|
||||
| `slow_mode` | int | Slow-mode delay in seconds (0 = disabled) |
|
||||
| `archived` | bool | Whether the channel is archived |
|
||||
| `nsfw` | bool | Age-restriction label. **Stored and shipped only** — the server applies no content behaviour to a flagged channel (see below) |
|
||||
| `voice_max_users` | int | Voice capacity, 0 = unlimited. Enforced on join (`CHANNEL_FULL`) |
|
||||
| `voice_max_video` | int | Simultaneous cameras/screen shares, 0 = unlimited. Enforced on publish (`VIDEO_LIMIT`) |
|
||||
|
||||
#### The `nsfw` flag
|
||||
|
||||
@@ -608,10 +608,10 @@ Paginated message history for a channel.
|
||||
|
||||
#### Query Parameters
|
||||
|
||||
| Param | Type | Default | Range | Description |
|
||||
| ----- | ---- | ------- | ----- | ----------- |
|
||||
| `before` | int64 | 0 (latest) | >= 0 | Cursor: return messages with ID less than this value |
|
||||
| `limit` | int | 50 | 1-100 | Number of messages to return |
|
||||
| Param | Type | Default | Range | Description |
|
||||
| -------- | ----- | ---------- | ----- | ---------------------------------------------------- |
|
||||
| `before` | int64 | 0 (latest) | >= 0 | Cursor: return messages with ID less than this value |
|
||||
| `limit` | int | 50 | 1-100 | Number of messages to return |
|
||||
|
||||
#### Response 200 OK
|
||||
|
||||
@@ -689,9 +689,9 @@ reference, or an `owncord://message/{channelId}/{messageId}` permalink.
|
||||
|
||||
#### Query Parameters
|
||||
|
||||
| Param | Type | Default | Range | Description |
|
||||
| ----- | ---- | ------- | ----- | ----------- |
|
||||
| `limit` | int | 50 | 1-100 | Total window size, centre included |
|
||||
| Param | Type | Default | Range | Description |
|
||||
| ------- | ---- | ------- | ----- | ---------------------------------- |
|
||||
| `limit` | int | 50 | 1-100 | Total window size, centre included |
|
||||
|
||||
Half the window sits before the centre and the remainder after it: `limit=50`
|
||||
returns up to 25 older messages, the centre, and up to 24 newer ones. Near the
|
||||
@@ -714,18 +714,18 @@ reactions with the `me` flag, `mentions`, `mentions_everyone`), but is ordered
|
||||
|
||||
`has_more_before` / `has_more_after` report whether the channel holds further
|
||||
live history on each side of the returned window. A client that renders an
|
||||
around-window is *detached* from the live tail while `has_more_after` is true:
|
||||
around-window is _detached_ from the live tail while `has_more_after` is true:
|
||||
newly broadcast messages belong below the window and are not part of it, so the
|
||||
client should offer a "jump to present" affordance that refetches the normal
|
||||
`GET /messages` tail.
|
||||
|
||||
#### Errors
|
||||
|
||||
| Status | Code | When |
|
||||
|--------|------|------|
|
||||
| 400 | `BAD_REQUEST` | `id` or `messageId` is not a positive integer, or `limit` is not a positive integer |
|
||||
| 403 | `FORBIDDEN` | The channel exists but `READ_MESSAGES` is denied |
|
||||
| 404 | `NOT_FOUND` | The channel does not exist, the caller is not a participant of the DM, or the message does not live in this channel |
|
||||
| Status | Code | When |
|
||||
| ------ | ------------- | ------------------------------------------------------------------------------------------------------------------- |
|
||||
| 400 | `BAD_REQUEST` | `id` or `messageId` is not a positive integer, or `limit` is not a positive integer |
|
||||
| 403 | `FORBIDDEN` | The channel exists but `READ_MESSAGES` is denied |
|
||||
| 404 | `NOT_FOUND` | The channel does not exist, the caller is not a participant of the DM, or the message does not live in this channel |
|
||||
|
||||
Soft-deleted messages are 404 here, not an empty window: history omits deleted
|
||||
rows, so there is no row to centre on. Deleted messages are also excluded from
|
||||
@@ -752,10 +752,10 @@ requests are rejected with 403.
|
||||
}
|
||||
```
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
|-------|------|----------|-------------|
|
||||
| `limit` | integer | Yes | How many messages to delete, 1--100. Values above 100 are clamped; 0 or negative is a 400. |
|
||||
| `before` | integer | No | Only delete messages with an id below this one. Omit or `0` to start from the newest. |
|
||||
| Field | Type | Required | Description |
|
||||
| -------- | ------- | -------- | ------------------------------------------------------------------------------------------ |
|
||||
| `limit` | integer | Yes | How many messages to delete, 1--100. Values above 100 are clamped; 0 or negative is a 400. |
|
||||
| `before` | integer | No | Only delete messages with an id below this one. Omit or `0` to start from the newest. |
|
||||
|
||||
#### Response 200 OK
|
||||
|
||||
@@ -810,11 +810,11 @@ tooltip, not an audit. `users` is always an array (`[]` when nobody used that
|
||||
emoji, which is also the answer for an emoji that does not exist). `avatar` is
|
||||
`""` when the user has none.
|
||||
|
||||
| Status | Error | When |
|
||||
|--------|-------|------|
|
||||
| 400 | `BAD_REQUEST` | Non-positive `id`/`messageId`, or an empty / over-32-rune / control-character emoji |
|
||||
| 403 | `FORBIDDEN` | No `READ_MESSAGES` on the channel |
|
||||
| 404 | `NOT_FOUND` | Channel or message not found, the message lives in another channel, or a DM the caller is not in |
|
||||
| Status | Error | When |
|
||||
| ------ | ------------- | ------------------------------------------------------------------------------------------------ |
|
||||
| 400 | `BAD_REQUEST` | Non-positive `id`/`messageId`, or an empty / over-32-rune / control-character emoji |
|
||||
| 403 | `FORBIDDEN` | No `READ_MESSAGES` on the channel |
|
||||
| 404 | `NOT_FOUND` | Channel or message not found, the message lives in another channel, or a DM the caller is not in |
|
||||
|
||||
---
|
||||
|
||||
@@ -864,11 +864,11 @@ Full-text search across messages in channels the user can read. Uses SQLite FTS5
|
||||
|
||||
#### Query Parameters
|
||||
|
||||
| Param | Type | Default | Range | Description |
|
||||
| ----- | ---- | ------- | ----- | ----------- |
|
||||
| `q` | string | (required) | non-empty | Search query (FTS5 syntax) |
|
||||
| `channel_id` | int64 | (all channels) | > 0 | Restrict search to a single channel |
|
||||
| `limit` | int | 50 | 1-100 | Maximum results to return |
|
||||
| Param | Type | Default | Range | Description |
|
||||
| ------------ | ------ | -------------- | --------- | ----------------------------------- |
|
||||
| `q` | string | (required) | non-empty | Search query (FTS5 syntax) |
|
||||
| `channel_id` | int64 | (all channels) | > 0 | Restrict search to a single channel |
|
||||
| `limit` | int | 50 | 1-100 | Maximum results to return |
|
||||
|
||||
#### Response 200 OK
|
||||
|
||||
@@ -915,10 +915,10 @@ them against its `klipy.com` CDN allowlist before rendering.
|
||||
|
||||
#### Query Parameters
|
||||
|
||||
| Param | Type | Default | Range | Description |
|
||||
| ----- | ---- | ------- | ----- | ----------- |
|
||||
| `q` | string | (required) | 1-100 chars | Search term |
|
||||
| `limit` | int | 20 | 1-50 | Maximum results to return |
|
||||
| Param | Type | Default | Range | Description |
|
||||
| ------- | ------ | ---------- | ----------- | ------------------------- |
|
||||
| `q` | string | (required) | 1-100 chars | Search term |
|
||||
| `limit` | int | 20 | 1-50 | Maximum results to return |
|
||||
|
||||
#### Response 200 OK
|
||||
|
||||
@@ -943,22 +943,22 @@ could not leak it to clients. Results missing either format are omitted.
|
||||
|
||||
#### Errors
|
||||
|
||||
| Status | Code | When |
|
||||
| ------ | ---- | ---- |
|
||||
| 400 | `INVALID_INPUT` | Missing/blank `q`, `q` over 100 chars, or `limit` outside 1-50 |
|
||||
| 401 | `UNAUTHORIZED` | No valid session (checked before the disabled check) |
|
||||
| 429 | `RATE_LIMITED` | Over 30 requests/minute |
|
||||
| 502 | `BAD_GATEWAY` | Upstream error, timeout, or unparseable response |
|
||||
| 503 | `GIF_DISABLED` | `gif.api_key` is not configured |
|
||||
| Status | Code | When |
|
||||
| ------ | --------------- | -------------------------------------------------------------- |
|
||||
| 400 | `INVALID_INPUT` | Missing/blank `q`, `q` over 100 chars, or `limit` outside 1-50 |
|
||||
| 401 | `UNAUTHORIZED` | No valid session (checked before the disabled check) |
|
||||
| 429 | `RATE_LIMITED` | Over 30 requests/minute |
|
||||
| 502 | `BAD_GATEWAY` | Upstream error, timeout, or unparseable response |
|
||||
| 503 | `GIF_DISABLED` | `gif.api_key` is not configured |
|
||||
|
||||
### GET /api/v1/gif/trending
|
||||
|
||||
Same auth, rate limit, response shape, and error codes as
|
||||
`/api/v1/gif/search`, minus the `q` parameter.
|
||||
|
||||
| Param | Type | Default | Range | Description |
|
||||
| ----- | ---- | ------- | ----- | ----------- |
|
||||
| `limit` | int | 20 | 1-50 | Maximum results to return |
|
||||
| Param | Type | Default | Range | Description |
|
||||
| ------- | ---- | ------- | ----- | ------------------------- |
|
||||
| `limit` | int | 20 | 1-50 | Maximum results to return |
|
||||
|
||||
---
|
||||
|
||||
@@ -1562,23 +1562,23 @@ Authorization is two-layered:
|
||||
users are rejected here even while their session is still valid.
|
||||
2. **Per-route bit.** Route groups then require the specific permission below.
|
||||
`ADMINISTRATOR` bypasses every one of them; owner-only routes gate on role
|
||||
*position* (`>= 100`) instead of on a bit, so not even `ADMINISTRATOR`
|
||||
_position_ (`>= 100`) instead of on a bit, so not even `ADMINISTRATOR`
|
||||
substitutes for being the owner.
|
||||
|
||||
| Route | Requires |
|
||||
| ----- | -------- |
|
||||
| `GET /admin/api/me` | perimeter only |
|
||||
| `GET /admin/api/stats` | perimeter only |
|
||||
| `GET /admin/api/users` | perimeter only |
|
||||
| `PATCH /admin/api/users/{id}` | perimeter; `BAN_MEMBERS` for `banned`, `MANAGE_ROLES` for `role_id` (checked in the service) |
|
||||
| `DELETE /admin/api/users/{id}/sessions` | `KICK_MEMBERS` |
|
||||
| `GET/POST/PATCH/DELETE /admin/api/channels…` (incl. `/permissions` and `/user-permissions`) | `MANAGE_CHANNELS` |
|
||||
| `GET/POST/PATCH/DELETE /admin/api/roles…` (incl. `/roles/reorder`) | `MANAGE_ROLES` |
|
||||
| `GET /admin/api/audit-log` | `VIEW_AUDIT_LOG` |
|
||||
| `GET/PATCH /admin/api/settings` | `MANAGE_SERVER` |
|
||||
| `POST /admin/api/logs/ticket`, `GET /admin/api/logs/stream` | `ADMINISTRATOR` |
|
||||
| `/api/v1/admin/plugins…` | `ADMINISTRATOR` |
|
||||
| `/admin/api/tokens…`, `/admin/api/backup(s)…`, `/admin/api/updates…` | Owner role (position 100) |
|
||||
| Route | Requires |
|
||||
| ------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------- |
|
||||
| `GET /admin/api/me` | perimeter only |
|
||||
| `GET /admin/api/stats` | perimeter only |
|
||||
| `GET /admin/api/users` | perimeter only |
|
||||
| `PATCH /admin/api/users/{id}` | perimeter; `BAN_MEMBERS` for `banned`, `MANAGE_ROLES` for `role_id` (checked in the service) |
|
||||
| `DELETE /admin/api/users/{id}/sessions` | `KICK_MEMBERS` |
|
||||
| `GET/POST/PATCH/DELETE /admin/api/channels…` (incl. `/permissions` and `/user-permissions`) | `MANAGE_CHANNELS` |
|
||||
| `GET/POST/PATCH/DELETE /admin/api/roles…` (incl. `/roles/reorder`) | `MANAGE_ROLES` |
|
||||
| `GET /admin/api/audit-log` | `VIEW_AUDIT_LOG` |
|
||||
| `GET/PATCH /admin/api/settings` | `MANAGE_SERVER` |
|
||||
| `POST /admin/api/logs/ticket`, `GET /admin/api/logs/stream` | `ADMINISTRATOR` |
|
||||
| `/api/v1/admin/plugins…` | `ADMINISTRATOR` |
|
||||
| `/admin/api/tokens…`, `/admin/api/backup(s)…`, `/admin/api/updates…` | Owner role (position 100) |
|
||||
|
||||
Moderation routes additionally enforce the **role hierarchy**: the actor must
|
||||
strictly outrank the target (`actor.position > target.position`), and a role
|
||||
@@ -1729,18 +1729,18 @@ List all users with role and ban state.
|
||||
|
||||
Array of:
|
||||
|
||||
| Field | Type | Notes |
|
||||
| ----- | ---- | ----- |
|
||||
| `id` | int | |
|
||||
| `username` | string | |
|
||||
| `avatar` | string? | omitted when unset |
|
||||
| `role_id` | int | |
|
||||
| `role_name` | string | |
|
||||
| `status` | string | presence status |
|
||||
| `created_at` | string | |
|
||||
| `last_seen` | string? | omitted when never seen |
|
||||
| `banned` | bool | |
|
||||
| `ban_reason` | string? | omitted when unset |
|
||||
| Field | Type | Notes |
|
||||
| ------------- | ------- | -------------------------- |
|
||||
| `id` | int | |
|
||||
| `username` | string | |
|
||||
| `avatar` | string? | omitted when unset |
|
||||
| `role_id` | int | |
|
||||
| `role_name` | string | |
|
||||
| `status` | string | presence status |
|
||||
| `created_at` | string | |
|
||||
| `last_seen` | string? | omitted when never seen |
|
||||
| `banned` | bool | |
|
||||
| `ban_reason` | string? | omitted when unset |
|
||||
| `ban_expires` | string? | omitted for permanent bans |
|
||||
|
||||
Password hashes and TOTP secrets are never included.
|
||||
@@ -1774,11 +1774,11 @@ omitted or `0` = permanent) and is only meaningful with `banned: true`.
|
||||
|
||||
#### Errors
|
||||
|
||||
| Status | Code | Cause |
|
||||
| ------ | ---- | ----- |
|
||||
| 400 | `BAD_REQUEST` | Invalid id/body, `ban_duration_hours` out of range, or attempting to modify your own account |
|
||||
| 403 | `FORBIDDEN` | Missing bit, or the actor does not outrank the target |
|
||||
| 404 | `NOT_FOUND` | User not found |
|
||||
| Status | Code | Cause |
|
||||
| ------ | ------------- | -------------------------------------------------------------------------------------------- |
|
||||
| 400 | `BAD_REQUEST` | Invalid id/body, `ban_duration_hours` out of range, or attempting to modify your own account |
|
||||
| 403 | `FORBIDDEN` | Missing bit, or the actor does not outrank the target |
|
||||
| 404 | `NOT_FOUND` | User not found |
|
||||
|
||||
---
|
||||
|
||||
@@ -1873,9 +1873,9 @@ user has TOTP enabled.
|
||||
|
||||
#### Errors
|
||||
|
||||
| Status | Code | Cause |
|
||||
| ------ | ---- | ----- |
|
||||
| 400 | `BAD_REQUEST` | Unknown key, invalid boolean, or `require_2fa` preconditions not met |
|
||||
| Status | Code | Cause |
|
||||
| ------ | ------------- | -------------------------------------------------------------------- |
|
||||
| 400 | `BAD_REQUEST` | Unknown key, invalid boolean, or `require_2fa` preconditions not met |
|
||||
|
||||
---
|
||||
|
||||
@@ -1949,7 +1949,7 @@ The raw token is shown exactly once and is never recoverable.
|
||||
|
||||
#### Response 204 No Content
|
||||
|
||||
`404 NOT_FOUND` if there is no *active* token with that id.
|
||||
`404 NOT_FOUND` if there is no _active_ token with that id.
|
||||
|
||||
---
|
||||
|
||||
@@ -2032,7 +2032,7 @@ Owner-only self-update from GitHub Releases (minisign/Ed25519-verified; see
|
||||
|
||||
```json
|
||||
{
|
||||
"current": "v1.2.0-alpha.3",
|
||||
"current": "v1.2.0-alpha.4",
|
||||
"latest": "v1.2.0",
|
||||
"update_available": true,
|
||||
"required_assets_present": true,
|
||||
@@ -2054,10 +2054,10 @@ admin SPA replaces the apply button with an image-upgrade note.
|
||||
|
||||
#### Errors
|
||||
|
||||
| Status | Code | Cause |
|
||||
| ------ | ---- | ----- |
|
||||
| 503 | `UPDATE_UNAVAILABLE` | Update checking is not configured |
|
||||
| 502 | `UPDATE_CHECK_FAILED` | GitHub API failure |
|
||||
| Status | Code | Cause |
|
||||
| ------ | --------------------- | --------------------------------- |
|
||||
| 503 | `UPDATE_UNAVAILABLE` | Update checking is not configured |
|
||||
| 502 | `UPDATE_CHECK_FAILED` | GitHub API failure |
|
||||
|
||||
---
|
||||
|
||||
@@ -2077,14 +2077,14 @@ re-verification against TOCTOU swaps), spawns the new process and shuts down.
|
||||
|
||||
#### Errors
|
||||
|
||||
| Status | Code | Cause |
|
||||
| ------ | ---- | ----- |
|
||||
| 503 | `CONTAINER_DEPLOYMENT` | Container deployment — the binary is image content; upgrade by pulling the new image (opt back in with `OWNCORD_CONTAINER=0` if the binary is bind-mounted) |
|
||||
| 503 | `UPDATE_UNAVAILABLE` | Update checking is not configured |
|
||||
| 409 | `RESTART_PENDING` | A restart from an earlier apply/restore is already pending |
|
||||
| 409 | `UPDATE_IN_PROGRESS` | Another restart-sensitive operation (update apply or backup restore) is running |
|
||||
| 409 | `NO_UPDATE` | Already up to date |
|
||||
| 502 | `UPDATE_CHECK_FAILED` / `MISSING_ASSETS` / `DOWNLOAD_FAILED` | Check, asset or download/verification failure |
|
||||
| Status | Code | Cause |
|
||||
| ------ | ------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| 503 | `CONTAINER_DEPLOYMENT` | Container deployment — the binary is image content; upgrade by pulling the new image (opt back in with `OWNCORD_CONTAINER=0` if the binary is bind-mounted) |
|
||||
| 503 | `UPDATE_UNAVAILABLE` | Update checking is not configured |
|
||||
| 409 | `RESTART_PENDING` | A restart from an earlier apply/restore is already pending |
|
||||
| 409 | `UPDATE_IN_PROGRESS` | Another restart-sensitive operation (update apply or backup restore) is running |
|
||||
| 409 | `NO_UPDATE` | Already up to date |
|
||||
| 502 | `UPDATE_CHECK_FAILED` / `MISSING_ASSETS` / `DOWNLOAD_FAILED` | Check, asset or download/verification failure |
|
||||
|
||||
---
|
||||
|
||||
@@ -2131,7 +2131,7 @@ Create, edit, delete and reorder roles. The whole group requires
|
||||
`MANAGE_ROLES`; `RoleService` then enforces the hierarchy rules below, so a
|
||||
principal that clears the bit still cannot escalate through it.
|
||||
|
||||
**Rules, all measured against the *actor's* role position:**
|
||||
**Rules, all measured against the _actor's_ role position:**
|
||||
|
||||
- You may only create, edit, delete or reorder roles positioned **strictly
|
||||
below** your own. Equal rank is refused too, so a role cannot rewrite itself.
|
||||
@@ -2162,8 +2162,24 @@ Roles ordered by position descending, each with its member count.
|
||||
|
||||
```json
|
||||
[
|
||||
{ "id": 1, "name": "Owner", "color": "#E74C3C", "permissions": 2147483647, "position": 100, "is_default": false, "member_count": 1 },
|
||||
{ "id": 4, "name": "Member", "color": null, "permissions": 1635, "position": 40, "is_default": true, "member_count": 12 }
|
||||
{
|
||||
"id": 1,
|
||||
"name": "Owner",
|
||||
"color": "#E74C3C",
|
||||
"permissions": 2147483647,
|
||||
"position": 100,
|
||||
"is_default": false,
|
||||
"member_count": 1
|
||||
},
|
||||
{
|
||||
"id": 4,
|
||||
"name": "Member",
|
||||
"color": null,
|
||||
"permissions": 1635,
|
||||
"position": 40,
|
||||
"is_default": true,
|
||||
"member_count": 12
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
@@ -2180,12 +2196,12 @@ Roles ordered by position descending, each with its member count.
|
||||
}
|
||||
```
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
|-------|------|----------|-------------|
|
||||
| `name` | string | Yes | 1–32 characters, unique case-insensitively |
|
||||
| `color` | string | No | `#rgb`/`#rrggbb`, or `""` for none |
|
||||
| `permissions` | integer | No | Bitfield; defaults to `0` |
|
||||
| `position` | integer | No | Defaults to one below the actor's own position |
|
||||
| Field | Type | Required | Description |
|
||||
| ------------- | ------- | -------- | ---------------------------------------------- |
|
||||
| `name` | string | Yes | 1–32 characters, unique case-insensitively |
|
||||
| `color` | string | No | `#rgb`/`#rrggbb`, or `""` for none |
|
||||
| `permissions` | integer | No | Bitfield; defaults to `0` |
|
||||
| `position` | integer | No | Defaults to one below the actor's own position |
|
||||
|
||||
#### Response 201 Created
|
||||
|
||||
@@ -2194,10 +2210,10 @@ The created role (`id`, `name`, `color`, `permissions`, `position`,
|
||||
|
||||
#### Errors
|
||||
|
||||
| Status | Code | When |
|
||||
|--------|------|------|
|
||||
| 400 | `BAD_REQUEST` | Missing/blank/over-long name, duplicate name, bad color, negative position |
|
||||
| 403 | `FORBIDDEN` | Missing `MANAGE_ROLES`, position at or above your own, or a permission bit you lack |
|
||||
| Status | Code | When |
|
||||
| ------ | ------------- | ----------------------------------------------------------------------------------- |
|
||||
| 400 | `BAD_REQUEST` | Missing/blank/over-long name, duplicate name, bad color, negative position |
|
||||
| 403 | `FORBIDDEN` | Missing `MANAGE_ROLES`, position at or above your own, or a permission bit you lack |
|
||||
|
||||
### PATCH /admin/api/roles/{id}
|
||||
|
||||
@@ -2220,11 +2236,11 @@ The updated role.
|
||||
|
||||
#### Errors
|
||||
|
||||
| Status | Code | When |
|
||||
|--------|------|------|
|
||||
| 400 | `BAD_REQUEST` | The role is the default role, or is the seeded Owner role |
|
||||
| 403 | `FORBIDDEN` | Missing `MANAGE_ROLES`, or the role is at or above your own position |
|
||||
| 404 | `NOT_FOUND` | No such role |
|
||||
| Status | Code | When |
|
||||
| ------ | ------------- | -------------------------------------------------------------------- |
|
||||
| 400 | `BAD_REQUEST` | The role is the default role, or is the seeded Owner role |
|
||||
| 403 | `FORBIDDEN` | Missing `MANAGE_ROLES`, or the role is at or above your own position |
|
||||
| 404 | `NOT_FOUND` | No such role |
|
||||
|
||||
### PATCH /admin/api/roles/reorder
|
||||
|
||||
@@ -2246,10 +2262,10 @@ The full role list after the reorder, position descending.
|
||||
|
||||
#### Errors
|
||||
|
||||
| Status | Code | When |
|
||||
|--------|------|------|
|
||||
| 400 | `BAD_REQUEST` | Wrong number of ids, or a duplicate id |
|
||||
| 403 | `FORBIDDEN` | Missing `MANAGE_ROLES`, or an id that is unknown or not below your rank |
|
||||
| Status | Code | When |
|
||||
| ------ | ------------- | ----------------------------------------------------------------------- |
|
||||
| 400 | `BAD_REQUEST` | Wrong number of ids, or a duplicate id |
|
||||
| 403 | `FORBIDDEN` | Missing `MANAGE_ROLES`, or an id that is unknown or not below your rank |
|
||||
|
||||
---
|
||||
|
||||
@@ -2265,11 +2281,11 @@ out-of-range value is refused with `400 INVALID_INPUT` rather than clamped —
|
||||
a caller that sent `-1` meant something, and storing `0` would hide it. A
|
||||
refused body writes nothing at all:
|
||||
|
||||
| Field | Range | Meaning |
|
||||
|-------|-------|---------|
|
||||
| `slow_mode` | 0…21600 | Cooldown in seconds; 0 = off (6-hour ceiling, as Discord) |
|
||||
| `voice_max_users` | 0…99 | Voice capacity; 0 = unlimited |
|
||||
| `voice_max_video` | 0…99 | Simultaneous cameras/screen shares; 0 = unlimited |
|
||||
| Field | Range | Meaning |
|
||||
| ----------------- | ------- | --------------------------------------------------------- |
|
||||
| `slow_mode` | 0…21600 | Cooldown in seconds; 0 = off (6-hour ceiling, as Discord) |
|
||||
| `voice_max_users` | 0…99 | Voice capacity; 0 = unlimited |
|
||||
| `voice_max_video` | 0…99 | Simultaneous cameras/screen shares; 0 = unlimited |
|
||||
|
||||
`nsfw` is a bool and is stored, broadcast and audited only — the server applies
|
||||
no content behaviour to a flagged channel (see `GET /api/v1/channels`). The
|
||||
@@ -2330,12 +2346,24 @@ carries no override) so the panel can render a complete grid; `users` lists
|
||||
{
|
||||
"channel_id": 4,
|
||||
"roles": [
|
||||
{ "role_id": 1, "role_name": "Owner", "position": 100, "permissions": 2147483647, "allow": 0, "deny": 0 },
|
||||
{ "role_id": 4, "role_name": "Member", "position": 40, "permissions": 1635, "allow": 0, "deny": 514 }
|
||||
{
|
||||
"role_id": 1,
|
||||
"role_name": "Owner",
|
||||
"position": 100,
|
||||
"permissions": 2147483647,
|
||||
"allow": 0,
|
||||
"deny": 0
|
||||
},
|
||||
{
|
||||
"role_id": 4,
|
||||
"role_name": "Member",
|
||||
"position": 40,
|
||||
"permissions": 1635,
|
||||
"allow": 0,
|
||||
"deny": 514
|
||||
}
|
||||
],
|
||||
"users": [
|
||||
{ "user_id": 12, "username": "alice", "role_id": 4, "allow": 2, "deny": 0 }
|
||||
]
|
||||
"users": [{ "user_id": 12, "username": "alice", "role_id": 4, "allow": 2, "deny": 0 }]
|
||||
}
|
||||
```
|
||||
|
||||
@@ -2349,10 +2377,10 @@ Write one override row. Same body for both layers:
|
||||
{ "allow": 2, "deny": 1 }
|
||||
```
|
||||
|
||||
| Field | Type | Description |
|
||||
|-------|------|-------------|
|
||||
| Field | Type | Description |
|
||||
| ------- | ------- | ---------------------------- |
|
||||
| `allow` | integer | Bits granted in this channel |
|
||||
| `deny` | integer | Bits refused in this channel |
|
||||
| `deny` | integer | Bits refused in this channel |
|
||||
|
||||
Bits outside `permissions.AllPerms` are masked off rather than rejected, so an
|
||||
unknown bit can never be persisted. A row with both masks `0` is meaningless —
|
||||
@@ -2378,12 +2406,12 @@ the role layer, `{user_id, username, role_id, allow, deny}` for the user layer.
|
||||
|
||||
#### Errors
|
||||
|
||||
| Status | Code | When |
|
||||
|--------|------|------|
|
||||
| 400 | `BAD_REQUEST` | Unparseable id or body |
|
||||
| 400 | `INVALID_INPUT` | The channel is a DM |
|
||||
| 403 | `FORBIDDEN` | Missing `MANAGE_CHANNELS` |
|
||||
| 404 | `NOT_FOUND` | Unknown channel, role or user |
|
||||
| Status | Code | When |
|
||||
| ------ | --------------- | ----------------------------- |
|
||||
| 400 | `BAD_REQUEST` | Unparseable id or body |
|
||||
| 400 | `INVALID_INPUT` | The channel is a DM |
|
||||
| 403 | `FORBIDDEN` | Missing `MANAGE_CHANNELS` |
|
||||
| 404 | `NOT_FOUND` | Unknown channel, role or user |
|
||||
|
||||
### DELETE /admin/api/channels/{id}/permissions/{roleId}
|
||||
|
||||
@@ -2532,10 +2560,10 @@ Tauri-compatible update endpoint. The desktop client checks this to see if a new
|
||||
|
||||
#### Path Parameters
|
||||
|
||||
| Param | Type | Description |
|
||||
| ----- | ---- | ----------- |
|
||||
| `target` | string | Tauri updater target `{os}-{arch}-{installer}` (e.g., `windows-x86_64-nsis`, `linux-x86_64-appimage`, `linux-aarch64-appimage`). Selects the platform's updater artifact and is echoed back as the `platforms` key. Targets without a published updater artifact (e.g., `linux-x86_64-deb`) get 204. |
|
||||
| `current_version` | string | Client's current semver version (e.g., `1.0.0`) |
|
||||
| Param | Type | Description |
|
||||
| ----------------- | ------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `target` | string | Tauri updater target `{os}-{arch}-{installer}` (e.g., `windows-x86_64-nsis`, `linux-x86_64-appimage`, `linux-aarch64-appimage`). Selects the platform's updater artifact and is echoed back as the `platforms` key. Targets without a published updater artifact (e.g., `linux-x86_64-deb`) get 204. |
|
||||
| `current_version` | string | Client's current semver version (e.g., `1.0.0`) |
|
||||
|
||||
#### Response 200 OK (update available)
|
||||
|
||||
|
||||
+20
-14
@@ -11,29 +11,35 @@ natively) followed by a prose explanation and a **Source of truth** file list.
|
||||
|
||||
## Index
|
||||
|
||||
| Doc | Diagrams | Covers |
|
||||
|-----|----------|--------|
|
||||
| [system-overview.md](system-overview.md) | D1 System context, D8 Deployment topology | All processes, trust boundaries, ports, single-instance constraints |
|
||||
| [server.md](server.md) | D2 Server package map, D3 REST request lifecycle | Go package structure, DB-access styles, middleware chain |
|
||||
| [websocket.md](websocket.md) | D4 WS connect / replay / dispatch | Real-time engine: auth handshake, 3-tier reconnect replay, backpressure, typed dispatch |
|
||||
| [data-model.md](data-model.md) | D5 Entity-relationship overview | All 26 tables from migrations 001–028, grouped by domain |
|
||||
| [voice-e2ee.md](voice-e2ee.md) | D6 Voice + E2EE flow | LiveKit token flow, loopback TLS tunnel, ECDH key-holder relay |
|
||||
| [client.md](client.md) | D7 Client module map | Tauri client: bootstrap, dispatcher, stores, Rust sidecars (structure, as-built) |
|
||||
| [ux/](ux/README.md) | UX flow + state diagrams | Client **behavior** spec (target state): what every view does and how it reacts to events, permissions, and failure |
|
||||
| Doc | Diagrams | Covers |
|
||||
| ---------------------------------------------- | ------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| [system-overview.md](system-overview.md) | D1 System context, D8 Deployment topology | All processes, trust boundaries, ports, single-instance constraints |
|
||||
| [server.md](server.md) | D2 Server package map, D3 REST request lifecycle | Go package structure, DB-access styles, middleware chain |
|
||||
| [websocket.md](websocket.md) | D4 WS connect / replay / dispatch | Real-time engine: auth handshake, 3-tier reconnect replay, backpressure, typed dispatch |
|
||||
| [data-model.md](data-model.md) | D5 Entity-relationship overview | All 26 tables from migrations 001–028, grouped by domain |
|
||||
| [voice-e2ee.md](voice-e2ee.md) | D6 Voice + E2EE flow | LiveKit token flow, loopback TLS tunnel, ECDH key-holder relay |
|
||||
| [client.md](client.md) | D7 Client module map | Tauri client: bootstrap, dispatcher, stores, Rust sidecars (structure, as-built) |
|
||||
| [ux/](ux/README.md) | UX flow + state diagrams | Client **behavior** spec (target state): what every view does and how it reacts to events, permissions, and failure |
|
||||
| [platform-contracts.md](platform-contracts.md) | — | Desktop/browser **seam** (target state): where native dependencies will be isolated, and the three that have no browser equivalent |
|
||||
|
||||
### Structure vs. behavior
|
||||
|
||||
[client.md](client.md) maps the client *as-built* (modules, stores, wiring). The
|
||||
[ux/](ux/README.md) set is the complementary *behavior* spec — prescriptive
|
||||
[client.md](client.md) maps the client _as-built_ (modules, stores, wiring). The
|
||||
[ux/](ux/README.md) set is the complementary _behavior_ spec — prescriptive
|
||||
(to-be) flows for every view, with per-view state matrices and event→reaction
|
||||
maps. Where today's code diverges from the target, the UX docs carry dated
|
||||
**⚠ Current gap** callouts, so the set doubles as a UX improvement backlog.
|
||||
|
||||
[platform-contracts.md](platform-contracts.md) is a third kind again: a _target
|
||||
seam_ map. It records where the desktop/browser boundary will be drawn and what
|
||||
crosses it, measured against today's code. The seam does not exist yet — B7
|
||||
builds it — so read that document as a decision record, not as structure.
|
||||
|
||||
## Maintenance rule
|
||||
|
||||
These documents are **curated, not generated**. The rule that keeps them honest:
|
||||
|
||||
> If a PR changes the *structure* of anything listed in a diagram's
|
||||
> If a PR changes the _structure_ of anything listed in a diagram's
|
||||
> **Source of truth** list (new package, new table, new message type, changed
|
||||
> flow), that PR updates the corresponding diagram in the same change.
|
||||
|
||||
@@ -43,9 +49,9 @@ in the dated audit reports, which are point-in-time snapshots by design.
|
||||
|
||||
## Relationship to other docs
|
||||
|
||||
- `docs/api.md`, `docs/protocol.md`, `docs/schema.md` are the *reference specs*
|
||||
- `docs/api.md`, `docs/protocol.md`, `docs/schema.md` are the _reference specs_
|
||||
(request/response shapes, wire formats, DDL). These blueprints describe
|
||||
*structure and flow*, not payload shapes. Known drift between the specs and
|
||||
_structure and flow_, not payload shapes. Known drift between the specs and
|
||||
the code is catalogued in the dated audit reports (latest:
|
||||
[audit-2026-08-04-docs-and-coverage.md](../audit-2026-08-04-docs-and-coverage.md)).
|
||||
- `docs/client-architecture.md` is a redirect stub kept for old links;
|
||||
|
||||
+13
-13
@@ -92,7 +92,7 @@ re-render. All three network paths — WebSocket, REST, and LiveKit — terminat
|
||||
TLS inside Rust proxies that share one TOFU core (`tofu.rs`): the WS and HTTP
|
||||
proxies use a capture-then-decide verifier, and the LiveKit proxy refuses to
|
||||
start without an existing pin. Deciding never writes a pin — a first
|
||||
connection is *rejected* and surfaced to the user as a blocking trust prompt
|
||||
connection is _rejected_ and surfaced to the user as a blocking trust prompt
|
||||
before any pin is stored (the former auto-pin-on-first-use behavior was
|
||||
removed in the 2026-07-22 security remediation). The remaining dashed edges
|
||||
mark cross-store coupling (auth→voice→members) — known structural debt, not
|
||||
@@ -100,18 +100,18 @@ yet scheduled.
|
||||
|
||||
### Key mechanisms
|
||||
|
||||
| Concern | Where | How |
|
||||
|---------|-------|-----|
|
||||
| Reconnect | `src/lib/ws.ts` | Exponential backoff (cap 30s), heartbeat 30s, `last_seq` replay + bounded dedup set, generation counter invalidates stale listeners |
|
||||
| Cert trust | `src-tauri/src/tofu.rs` (shared by `ws_proxy.rs`, `http_proxy.rs`, `livekit_proxy.rs`) | TOFU with explicit consent: fingerprints stored per host in `certs.json`, but *deciding never writes a pin* — first use and mismatch both reject the connection and emit a `cert-tofu` event; the TS side shows a blocking modal (`CertMismatchModal.ts`) and only an explicit Accept stores/updates the pin. The updater uses a fourth, host-scoped verifier (pin for the OwnCord host, WebPKI for GitHub). |
|
||||
| Voice E2EE identity | `src/lib/identity.ts` + `src-tauri/src/commands.rs` | Long-term ECDSA identity key in the OS keyring (`identity:{host}`); peer identity keys pinned in `identity_pins.json`; changed peer key → blocking identity-mismatch modal with safety-number comparison |
|
||||
| Credentials | `src-tauri/src/credentials.rs` | OS keychain per host; password field `serde(skip)` so it never crosses IPC back to JS |
|
||||
| Multi-server | `src/lib/profiles.ts` | Server profiles w/ 15s health polling and auto-connect; one active connection, quick-switch replaces WS + tunnels |
|
||||
| HTTP capability | `src-tauri/capabilities/default.json` | `http:allow-fetch` is the only URL-scoped identifier (the other two `fetch_*` commands take a validated `ResourceId`); allows `https://*` + `http://127.0.0.1:*`, denies https loopback. Wildcard is required by link previews — see [docs/plans/tauri-capability-narrowing.md](../plans/tauri-capability-narrowing.md) |
|
||||
| Updates | `src/lib/updater.ts` + `update_commands.rs` | Endpoint derived from the connected server URL, https-only, TLS pinned to TOFU fingerprint, minisign-verified |
|
||||
| Settings | `commands.rs` + `src/lib/preferences.ts` | Split persistence: Rust store (`settings.json`, key-allowlisted) *and* raw `localStorage` for UI prefs/themes |
|
||||
| Theming | `src/lib/themes.ts` + `styles/tokens.css` | CSS custom properties; 4 built-in themes + custom overrides |
|
||||
| GIF picker | `src/lib/gifProvider.ts` + `components/GifPicker.ts` | Calls the user's own server (`/api/v1/gif/*`) through `api.ts` — no provider API key in the bundle. Server answers `503 GIF_DISABLED` when unconfigured: the picker shows "GIFs are not enabled on this server" and `onUnavailable` disables the composer's GIF button (with a `title`/`aria-label` reason) instead of failing silently. Returned media URLs are still pinned to the `klipy.com` CDN. |
|
||||
| Concern | Where | How |
|
||||
| ------------------- | -------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| Reconnect | `src/lib/ws.ts` | Exponential backoff (cap 30s), heartbeat 30s, `last_seq` replay + bounded dedup set, generation counter invalidates stale listeners |
|
||||
| Cert trust | `src-tauri/src/tofu.rs` (shared by `ws_proxy.rs`, `http_proxy.rs`, `livekit_proxy.rs`) | TOFU with explicit consent: fingerprints stored per host in `certs.json`, but _deciding never writes a pin_ — first use and mismatch both reject the connection and emit a `cert-tofu` event; the TS side shows a blocking modal (`CertMismatchModal.ts`) and only an explicit Accept stores/updates the pin. The updater uses a fourth, host-scoped verifier (pin for the OwnCord host, WebPKI for GitHub). |
|
||||
| Voice E2EE identity | `src/lib/identity.ts` + `src-tauri/src/commands.rs` | Long-term ECDSA identity key in the OS keyring (`identity:{host}`); peer identity keys pinned in `identity_pins.json`; changed peer key → blocking identity-mismatch modal with safety-number comparison |
|
||||
| Credentials | `src-tauri/src/credentials.rs` | OS keychain per host; password field `serde(skip)` so it never crosses IPC back to JS |
|
||||
| Multi-server | `src/lib/profiles.ts` | Server profiles w/ 15s health polling and auto-connect; one active connection, quick-switch replaces WS + tunnels |
|
||||
| HTTP capability | `src-tauri/capabilities/default.json` | `http:allow-fetch` is the only URL-scoped identifier (the other two `fetch_*` commands take a validated `ResourceId`); allows `https://*` + `http://127.0.0.1:*`, denies https loopback. Wildcard is required by link previews — see [docs/plans/tauri-capability-narrowing.md](../plans/tauri-capability-narrowing.md) |
|
||||
| Updates | `src/lib/updater.ts` + `update_commands.rs` | Endpoint derived from the connected server URL, https-only, TLS pinned to TOFU fingerprint, minisign-verified |
|
||||
| Settings | `commands.rs` + `src/lib/preferences.ts` | Split persistence: Rust store (`settings.json`, key-allowlisted) _and_ raw `localStorage` for UI prefs/themes |
|
||||
| Theming | `src/lib/themes.ts` + `styles/tokens.css` | CSS custom properties; 4 built-in themes + custom overrides |
|
||||
| GIF picker | `src/lib/gifProvider.ts` + `components/GifPicker.ts` | Calls the user's own server (`/api/v1/gif/*`) through `api.ts` — no provider API key in the bundle. Server answers `503 GIF_DISABLED` when unconfigured: the picker shows "GIFs are not enabled on this server" and `onUnavailable` disables the composer's GIF button (with a `title`/`aria-label` reason) instead of failing silently. Returned media URLs are still pinned to the `klipy.com` CDN. |
|
||||
|
||||
### Quality tooling
|
||||
|
||||
|
||||
@@ -100,15 +100,15 @@ erDiagram
|
||||
|
||||
### Domain notes
|
||||
|
||||
| Domain | Tables | Notes |
|
||||
|--------|--------|-------|
|
||||
| Domain | Tables | Notes |
|
||||
| ----------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Identity & access | `roles`, `users`, `sessions`, `api_tokens`, `channel_overrides`, `channel_user_overrides`, `user_blocks`, `invites`, `login_attempts`, `rate_lockouts` | Sessions store only SHA-256 token hashes. `api_tokens` (018) are long-lived bearer credentials (owner-minted, hash-stored) that deliberately live outside the session table. Permissions are a bitfield on `roles.permissions`; channel overrides use Discord semantics `(role &^ deny) \| allow`, with `channel_user_overrides` (024) as a per-user final layer on top of the role layer. `users` gained `identity_public_key` (017) for voice E2EE identity pinning and `display_name`/`about`/`custom_status` (027). `rate_lockouts` (011) persists rate-limiter lockouts across restarts. |
|
||||
| Messaging | `channels`, `messages`, `attachments`, `reactions`, `read_states`, `message_mentions`, `emoji` | `message_mentions` (022) stores server-resolved `@username` mentions per message; `messages.mentions_everyone` flags an authorized `@everyone`/`@here`, and `read_states.mention_count` is the per-user unread badge those two drive. `channels.type` is constrained to `text \| voice \| announcement \| dm` by INSERT/UPDATE triggers (migration 013, extended by 016 to allow `announcement`). Announcement channels read like text but require `MANAGE_MESSAGES` to post. `attachments.uploader_id` (010) backs upload-ownership checks. |
|
||||
| Direct messages | `dm_participants`, `dm_open_state` | DMs are `channels` rows with `type='dm'`; these tables track membership and per-user open/closed UI state (009). `channels.is_group` (028) marks a group DM so group-ness survives participants leaving. |
|
||||
| Voice | `voice_states` | One row per user (`user_id` is the PK) — a user occupies at most one voice channel. |
|
||||
| Real-time replay | `events` | Cold tier of the 3-tier reconnect replay ([websocket.md](websocket.md)); written by the async `EventPersister`, pruned by retention. Hub seq counter is seeded from `MAX(events.seq)` at startup so seqs stay monotonic across restarts. |
|
||||
| Plugins | `plugins`, `plugin_kv` | 015. `plugin_kv` is per-plugin namespaced KV via composite PK `(plugin_id, key)`. |
|
||||
| Ops | `settings`, `audit_log` | `settings` is a generic KV read by admin and the WS hub (via `db.GetSetting`). Migration 003 rebuilds `audit_log` through a transient `audit_log_v6` rename — only `audit_log` exists at runtime. (The dead `sounds` table was dropped by migration 029, closing A-2026-07-13.) |
|
||||
| Messaging | `channels`, `messages`, `attachments`, `reactions`, `read_states`, `message_mentions`, `emoji` | `message_mentions` (022) stores server-resolved `@username` mentions per message; `messages.mentions_everyone` flags an authorized `@everyone`/`@here`, and `read_states.mention_count` is the per-user unread badge those two drive. `channels.type` is constrained to `text \| voice \| announcement \| dm` by INSERT/UPDATE triggers (migration 013, extended by 016 to allow `announcement`). Announcement channels read like text but require `MANAGE_MESSAGES` to post. `attachments.uploader_id` (010) backs upload-ownership checks. |
|
||||
| Direct messages | `dm_participants`, `dm_open_state` | DMs are `channels` rows with `type='dm'`; these tables track membership and per-user open/closed UI state (009). `channels.is_group` (028) marks a group DM so group-ness survives participants leaving. |
|
||||
| Voice | `voice_states` | One row per user (`user_id` is the PK) — a user occupies at most one voice channel. |
|
||||
| Real-time replay | `events` | Cold tier of the 3-tier reconnect replay ([websocket.md](websocket.md)); written by the async `EventPersister`, pruned by retention. Hub seq counter is seeded from `MAX(events.seq)` at startup so seqs stay monotonic across restarts. |
|
||||
| Plugins | `plugins`, `plugin_kv` | 015. `plugin_kv` is per-plugin namespaced KV via composite PK `(plugin_id, key)`. |
|
||||
| Ops | `settings`, `audit_log` | `settings` is a generic KV read by admin and the WS hub (via `db.GetSetting`). Migration 003 rebuilds `audit_log` through a transient `audit_log_v6` rename — only `audit_log` exists at runtime. (The dead `sounds` table was dropped by migration 029, closing A-2026-07-13.) |
|
||||
|
||||
### How the schema is accessed
|
||||
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
# Platform contract map — desktop and browser
|
||||
|
||||
**Kind:** target-state map. **Status:** design record only — the seam described
|
||||
here **does not exist in the code yet**.
|
||||
**Measured against:** `dev` @ `eb873fe7`, 2026-08-27.
|
||||
**Closes:** `RL-02` / `L-02` (B1-8). **Executed by:** B7.
|
||||
|
||||
OwnCord is a Tauri desktop app whose frontend talks to native APIs directly.
|
||||
Beta requires the same frontend to also run in a browser. This document records
|
||||
**where the seam between "shared app" and "native host" will go**, and what has
|
||||
to move across it — so that B7 executes a decided plan instead of rediscovering
|
||||
the surface.
|
||||
|
||||
> **Nothing here is implemented.** B1 was an explicitly non-functional phase:
|
||||
> _"No native behaviour moves in B1. Adapter extraction is B7 and must not be
|
||||
> smuggled in."_ This file adds no directory, no interface, and no code.
|
||||
|
||||
## Target layout
|
||||
|
||||
```
|
||||
Client/src/platform/
|
||||
├── contracts/ # TypeScript interfaces only. No imports from @tauri-apps.
|
||||
├── desktop/ # Tauri implementations. The ONLY place @tauri-apps may appear.
|
||||
└── browser/ # Web-standard implementations, or an explicit refusal.
|
||||
```
|
||||
|
||||
The rule this eventually enforces is `BPR-025`
|
||||
([traceability](../plans/beta-requirements-traceability-2026-08-23.md)):
|
||||
|
||||
> Static checks keep native imports inside desktop ownership; the same
|
||||
> domain/store/protocol suites run against desktop and browser adapters.
|
||||
|
||||
Two consequences worth stating now, because they shape the interface design:
|
||||
|
||||
- **Contracts must be async everywhere.** Some operations are synchronous in a
|
||||
browser and IPC round-trips on desktop. A contract that exposes a sync method
|
||||
cannot be implemented by the desktop side.
|
||||
- **A browser adapter is allowed to refuse.** Three capabilities below have no
|
||||
web equivalent. The contract must let an adapter say "unsupported" and let the
|
||||
app degrade, rather than force a fake implementation that fails at runtime.
|
||||
|
||||
## What exists today
|
||||
|
||||
Measured with `git grep`, not estimated:
|
||||
|
||||
| Measure | Value |
|
||||
| ---------------------------------------------------------- | ----- |
|
||||
| Files under `Client/src/` importing `@tauri-apps/*` | 20 |
|
||||
| Distinct `invoke` command names called from `Client/src/` | 26 |
|
||||
| `#[tauri::command]` handlers in `Client/src-tauri/` | 30 |
|
||||
| TS calls with no matching Rust handler | 0 |
|
||||
| Uses of the `window.__TAURI__` global | 0 |
|
||||
| Environment-detection helper (`isDesktop()` or equivalent) | none |
|
||||
| Files under `Client/src/platform/` | 0 |
|
||||
|
||||
Reproduce:
|
||||
|
||||
```bash
|
||||
git grep -l "@tauri-apps" -- 'Client/src/**' | wc -l
|
||||
git grep -hoE '(tauriInvoke|invoke)(<[^>]*>)?\(\s*"[a-z_]+"' -- 'Client/src/**' \
|
||||
| grep -oE '[a-z_]+"$' | tr -d '"' | sort -u | wc -l
|
||||
```
|
||||
|
||||
Note the alias: `Client/src/lib/ws.ts` binds `core.invoke` to a local
|
||||
`tauriInvoke` before calling it, so a regex that only matches `invoke("…")`
|
||||
undercounts by four (`ws_connect`, `ws_send`, `ws_disconnect`,
|
||||
`accept_cert_fingerprint`). Any future lint rule enforcing the seam must match
|
||||
the binding, not the call site.
|
||||
|
||||
Four Rust handlers are registered but never invoked from `Client/src/`:
|
||||
`get_cert_fingerprint` and `store_cert_fingerprint` (used by
|
||||
`Client/tests/e2e/helpers.ts`), and `probe_credential_store` and `ptt_get_key`
|
||||
(no caller anywhere). The latter two are dead-surface candidates — B7's call,
|
||||
not B1's.
|
||||
|
||||
There is no `window.__TAURI__` access and no environment branching, which is
|
||||
good news: every native dependency is a static or dynamic **import**, so a
|
||||
static check can find all of them. The only `typeof window` guards in
|
||||
`Client/src/lib/` are in `channel-mutes.ts` and `logger.ts`, and are unrelated
|
||||
to desktop/browser branching.
|
||||
|
||||
## Proposed contracts
|
||||
|
||||
Thirteen capability clusters. Each becomes one file under `contracts/`, with
|
||||
matching implementations under `desktop/` and `browser/`.
|
||||
|
||||
| Contract | Files today | Native surface | Browser outlook |
|
||||
| ----------------- | ----------------------------------------------------------------------------- | --------------------------------------------------- | --------------------------------------------------------- |
|
||||
| HTTP fetch | `lib/api.ts`, `lib/profiles.ts`, `message-list/{attachments,embeds,media}.ts` | `plugin-http` | native `fetch` — but CORS becomes a server concern |
|
||||
| WebSocket | `lib/ws.ts` | `api/core`, `api/event`; 4 invokes, 4 event listens | ⚠ see hard cases |
|
||||
| Secret storage | `lib/credentials.ts`, `lib/identity.ts` | `api/core`; 8 invokes | ⚠ see hard cases |
|
||||
| Settings | `lib/profiles.ts` | `api/core` (`save_settings`, `get_settings`) | `localStorage` / IndexedDB |
|
||||
| Native proxies | `lib/livekitSession.ts`, `lib/httpProxy.ts` | `api/core`; 4 invokes | not needed — the proxies exist to work around desktop TLS |
|
||||
| Notifications | `lib/notifications.ts` | `plugin-notification`, `api/window` | Notification API + Page Visibility |
|
||||
| Filesystem / logs | `lib/logPersistence.ts`, `settings/AdvancedTab.ts`, `settings/LogsTab.ts` | `api/path`, `plugin-fs` | in-memory ring buffer + download |
|
||||
| Window | `lib/window-state.ts`, `lib/notifications.ts` | `api/window` | mostly unsupported; degrade |
|
||||
| Updater / process | `lib/updater.ts`, `settings/AdvancedTab.ts` | `api/core`, `plugin-process`, `plugin-autostart` | unsupported — the page reloads instead |
|
||||
| Shell / opener | `lib/admin-panel.ts`, `main.ts` | `plugin-opener` | `window.open` |
|
||||
| File save / pick | `message-list/attachments.ts` | `plugin-dialog`, `plugin-fs` | `<a download>` / File System Access API |
|
||||
| Input / PTT | `lib/ptt.ts` | `api/core`, `api/event`; 5 invokes | ⚠ see hard cases |
|
||||
| Deep links | `lib/deep-link.ts` | `plugin-deep-link` | URL routing |
|
||||
| App metadata | `settings/LogsTab.ts` | `api/app` | build-time constant |
|
||||
|
||||
Two files appear under more than one contract (`lib/profiles.ts` does HTTP and
|
||||
settings; `settings/AdvancedTab.ts` spans four). That is expected — the clusters
|
||||
are capabilities, not a file partition, and those files split during extraction.
|
||||
|
||||
## Hard cases — where a browser adapter cannot be a shim
|
||||
|
||||
These three are not implementation details. Each is a product decision that B7
|
||||
must take deliberately, and each changes what the browser build **is**.
|
||||
|
||||
**`lib/ws.ts` — certificate TOFU.** The desktop client tunnels its WebSocket
|
||||
through Rust specifically so it can pin a self-signed certificate on first use
|
||||
(`accept_cert_fingerprint`, the `cert-tofu` event). A browser cannot inspect or
|
||||
pin a certificate; the user agent decides, and a self-signed server is simply
|
||||
refused. The browser adapter must **degrade honestly** — require a
|
||||
publicly-trusted certificate and say so — not emulate the flow. This trust path
|
||||
has been hardened twice already (identity TOFU, then a re-pin TOCTOU); do not
|
||||
let a browser adapter quietly reopen it.
|
||||
|
||||
**`lib/credentials.ts` / `lib/identity.ts` — OS keychain.** Secrets live in
|
||||
Windows Credential Manager / GNOME Keyring / macOS Keychain, and
|
||||
`Client/src-tauri/src/secret_store.rs` reads each write back before returning.
|
||||
The browser has no peer for this. Whatever the browser adapter stores, it is
|
||||
strictly weaker, and the E2EE identity key is among the secrets involved. This
|
||||
is a security-posture decision, not a storage swap.
|
||||
|
||||
**`lib/ptt.ts` — push-to-talk.** PTT is deliberately hand-rolled rather than
|
||||
using `plugin-global-shortcut`, because it must observe a key held down while
|
||||
OwnCord is unfocused. A browser cannot see keys outside its tab. The browser
|
||||
adapter can offer in-tab PTT or voice-activity detection, but not the desktop
|
||||
behaviour.
|
||||
|
||||
## Ownership
|
||||
|
||||
**No human owners are recorded for these folders, here or anywhere in the
|
||||
repository.** That is a real gap, not an omission in this document — assigning
|
||||
them is unstarted work. Until then, ownership is by phase, matching the
|
||||
convention already used in the
|
||||
[issue register](../plans/repo-health-issue-register-2026-08-23.md):
|
||||
|
||||
| Area | Phase |
|
||||
| --------------------------------------------- | ------ |
|
||||
| `contracts/`, `desktop/`, `browser/` | **B7** |
|
||||
| The static check enforcing the seam (BPR-025) | **B7** |
|
||||
| Browser build target and PWA packaging | **B8** |
|
||||
| Protocol contract both adapters speak | **B2** |
|
||||
|
||||
## Source of truth
|
||||
|
||||
- `Client/src/lib/`, `Client/src/components/` — the 20 files listed above
|
||||
- `Client/src-tauri/src/lib.rs` — the `generate_handler!` registration list
|
||||
- [`docs/audit-2026-08-23-repository-layout.md`](../audit-2026-08-23-repository-layout.md) — `RL-02`, and the target tree
|
||||
- [`docs/plans/beta-requirements-traceability-2026-08-23.md`](../plans/beta-requirements-traceability-2026-08-23.md) — `BPR-025`
|
||||
- [`docs/architecture/client.md`](client.md) — the client as-built
|
||||
|
||||
Per this directory's maintenance rule: a PR that adds a new `@tauri-apps` import
|
||||
to `Client/src/`, or a new `#[tauri::command]`, updates the counts and the
|
||||
cluster table here in the same change.
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
**Verified against:** commit `5630aa1`, 2026-08-04
|
||||
|
||||
Single Go binary (`github.com/owncord/server`, Go 1.26). Pure-Go SQLite
|
||||
Single Go binary (`github.com/J3vb/OwnCord/Server`, Go 1.26). Pure-Go SQLite
|
||||
(`modernc.org/sqlite`, no CGO), chi router, `github.com/coder/websocket`,
|
||||
LiveKit for voice, Wazero for plugins (build-tag gated), optional OpenTelemetry
|
||||
(`-tags otel`). Roughly 42k LOC of production code and 71k LOC of tests.
|
||||
|
||||
@@ -107,4 +107,4 @@ Revisit when that commitment is on the table.
|
||||
|
||||
**Source of truth:** `Server/main.go`, `Server/config/config.go`,
|
||||
`Server/docker-compose.yml`, `docs/deployment.md`, `docs/server-configuration.md`,
|
||||
`Client/tauri-client/src-tauri/src/lib.rs`.
|
||||
`Client/src-tauri/src/lib.rs`.
|
||||
|
||||
@@ -17,13 +17,13 @@ this set doubles as a UX improvement backlog. Gaps are grounded in real
|
||||
|
||||
## Documents
|
||||
|
||||
| Doc | Covers |
|
||||
|-----|--------|
|
||||
| [connection-and-auth.md](connection-and-auth.md) | App boot, server profiles, connect/health, login, TOTP, register-by-invite, the connected handshake, reconnect, and cert-TOFU trust prompts |
|
||||
| [messaging.md](messaging.md) | Composer + send (optimistic), edit/delete, reactions, attachments, replies, pins, search, read/unread, slow-mode, announcement read-only gating |
|
||||
| [channels-members-dms.md](channels-members-dms.md) | Channel list/switch/categories, member list + presence + typing, roles, DM open/close, blocking |
|
||||
| [voice-and-e2ee.md](voice-and-e2ee.md) | Voice join/leave, mute/deafen/camera/screenshare, push-to-talk, active-speaker, and the E2EE securing/key-ready indicators |
|
||||
| [settings-and-admin.md](settings-and-admin.md) | Settings tabs, profile/password/2FA/delete-account, appearance/theming, the inline admin surface (ban/kick/roles, channel CRUD, invites), and the updater |
|
||||
| Doc | Covers |
|
||||
| -------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| [connection-and-auth.md](connection-and-auth.md) | App boot, server profiles, connect/health, login, TOTP, register-by-invite, the connected handshake, reconnect, and cert-TOFU trust prompts |
|
||||
| [messaging.md](messaging.md) | Composer + send (optimistic), edit/delete, reactions, attachments, replies, pins, search, read/unread, slow-mode, announcement read-only gating |
|
||||
| [channels-members-dms.md](channels-members-dms.md) | Channel list/switch/categories, member list + presence + typing, roles, DM open/close, blocking |
|
||||
| [voice-and-e2ee.md](voice-and-e2ee.md) | Voice join/leave, mute/deafen/camera/screenshare, push-to-talk, active-speaker, and the E2EE securing/key-ready indicators |
|
||||
| [settings-and-admin.md](settings-and-admin.md) | Settings tabs, profile/password/2FA/delete-account, appearance/theming, the inline admin surface (ban/kick/roles, channel CRUD, invites), and the updater |
|
||||
|
||||
The cross-cutting vocabulary and global reaction matrices below apply to **every**
|
||||
document; the per-flow docs reference them rather than repeating them.
|
||||
@@ -37,18 +37,18 @@ choose a defined presentation for each (a view may legitimately collapse some
|
||||
e.g. a view that can never be empty — but that must be a decision, not an
|
||||
omission):
|
||||
|
||||
| State | Meaning | Default presentation |
|
||||
|-------|---------|----------------------|
|
||||
| `loading` | A fetch/subscription is in flight and no cached data is shown yet | Skeleton or inline spinner in the view's own region — **never** a full-screen blocker except the initial connected handshake |
|
||||
| `ready` | Data present and current | The normal view |
|
||||
| `empty` | Fetch succeeded, zero items | A labelled empty state with a one-line "what goes here / what to do next" hint |
|
||||
| `error` | Fetch/action failed | Inline error with a **Retry** affordance for recoverable errors; a toast only for fire-and-forget actions |
|
||||
| `stale` | Data shown but known out of date (e.g. during reconnect) | The normal view plus a non-blocking status hint (connection banner); interactions that require a live socket are disabled with a reason |
|
||||
| `permission-denied` | The user may see the view but not act | The view renders read-only; the disallowed control is **disabled with a visible reason**, never hidden silently and never enabled-then-rejected |
|
||||
| `offline` | No live socket | Live-only controls disabled with the connection status surfaced |
|
||||
| State | Meaning | Default presentation |
|
||||
| ------------------- | ----------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `loading` | A fetch/subscription is in flight and no cached data is shown yet | Skeleton or inline spinner in the view's own region — **never** a full-screen blocker except the initial connected handshake |
|
||||
| `ready` | Data present and current | The normal view |
|
||||
| `empty` | Fetch succeeded, zero items | A labelled empty state with a one-line "what goes here / what to do next" hint |
|
||||
| `error` | Fetch/action failed | Inline error with a **Retry** affordance for recoverable errors; a toast only for fire-and-forget actions |
|
||||
| `stale` | Data shown but known out of date (e.g. during reconnect) | The normal view plus a non-blocking status hint (connection banner); interactions that require a live socket are disabled with a reason |
|
||||
| `permission-denied` | The user may see the view but not act | The view renders read-only; the disallowed control is **disabled with a visible reason**, never hidden silently and never enabled-then-rejected |
|
||||
| `offline` | No live socket | Live-only controls disabled with the connection status surfaced |
|
||||
|
||||
**Principle — no silent states.** Every terminal outcome (success, empty,
|
||||
failure, denial) produces *some* observable feedback. A control that will be
|
||||
failure, denial) produces _some_ observable feedback. A control that will be
|
||||
rejected by the server must be pre-disabled with a reason; an action that
|
||||
succeeds without a visible result must emit a confirmation.
|
||||
|
||||
@@ -59,16 +59,16 @@ succeeds without a visible result must emit a confirmation.
|
||||
The client has a fixed set of feedback surfaces. Each has one job; pick by the
|
||||
decision table, don't improvise.
|
||||
|
||||
| Primitive | Source | Use for | Do **not** use for |
|
||||
|-----------|--------|---------|--------------------|
|
||||
| **Toast** (`info`/`success`/`error`, 5 s auto-dismiss, max 5) | `lib/toast.ts` → `components/Toast.ts` | Transient results of an explicit user action (sent, copied, saved, "couldn't reach server") | Anything the user must act on; anything that must survive navigation |
|
||||
| **Inline field error** | per-form | Validation and per-field server rejections (bad password, weak input) | Global/connection state |
|
||||
| **Inline section error + Retry** | per-view | A failed load of a view's own data (messages, invites, pins) | One-shot actions (use a toast) |
|
||||
| **Persistent banner** | `components/ServerBanner.ts` (reconnect/restart), ad-hoc cert banner | Connection status: reconnecting, server-restart countdown, first-trust cert notice | Per-action results |
|
||||
| **Blocking modal** | `lib/modalFactory.ts` (+ `CertMismatchModal`) | Decisions that must be made before proceeding: cert mismatch, destructive confirm | Routine feedback; anything dismissable-by-ignoring |
|
||||
| **Two-click / inline confirm** | `AdminActions.ts` `withConfirmation`, `PendingDeleteManager` | Reversible-ish destructive actions in dense menus (kick, ban, delete channel, delete message) | Irreversible account-level actions (use a modal with typed confirm) |
|
||||
| **Disabled control + reason** | per-control | Actions not currently permitted (offline, no permission, slow-mode cooldown, upload in flight) | Errors that already happened |
|
||||
| **Transient-error store** (`ui.store.setTransientError`) | survives navigation | A message that must appear on the *connect* page after a forced disconnect (banned, kicked, restart) | In-session messaging (use a toast) |
|
||||
| Primitive | Source | Use for | Do **not** use for |
|
||||
| ------------------------------------------------------------- | -------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------- |
|
||||
| **Toast** (`info`/`success`/`error`, 5 s auto-dismiss, max 5) | `lib/toast.ts` → `components/Toast.ts` | Transient results of an explicit user action (sent, copied, saved, "couldn't reach server") | Anything the user must act on; anything that must survive navigation |
|
||||
| **Inline field error** | per-form | Validation and per-field server rejections (bad password, weak input) | Global/connection state |
|
||||
| **Inline section error + Retry** | per-view | A failed load of a view's own data (messages, invites, pins) | One-shot actions (use a toast) |
|
||||
| **Persistent banner** | `components/ServerBanner.ts` (reconnect/restart), ad-hoc cert banner | Connection status: reconnecting, server-restart countdown, first-trust cert notice | Per-action results |
|
||||
| **Blocking modal** | `lib/modalFactory.ts` (+ `CertMismatchModal`) | Decisions that must be made before proceeding: cert mismatch, destructive confirm | Routine feedback; anything dismissable-by-ignoring |
|
||||
| **Two-click / inline confirm** | `AdminActions.ts` `withConfirmation`, `PendingDeleteManager` | Reversible-ish destructive actions in dense menus (kick, ban, delete channel, delete message) | Irreversible account-level actions (use a modal with typed confirm) |
|
||||
| **Disabled control + reason** | per-control | Actions not currently permitted (offline, no permission, slow-mode cooldown, upload in flight) | Errors that already happened |
|
||||
| **Transient-error store** (`ui.store.setTransientError`) | survives navigation | A message that must appear on the _connect_ page after a forced disconnect (banned, kicked, restart) | In-session messaging (use a toast) |
|
||||
|
||||
---
|
||||
|
||||
@@ -104,11 +104,11 @@ source of truth in `ui.store.connectionStatus`
|
||||
> LiveKit's own reconnection keeps retrying underneath — only the UI is gated,
|
||||
> never LiveKit's machinery.
|
||||
|
||||
| Status | Composer / send | Voice controls | Presence picker | Reconnect banner |
|
||||
|--------|-----------------|----------------|-----------------|------------------|
|
||||
| `connected` | enabled | enabled | enabled | hidden |
|
||||
| `reconnecting` | disabled, "Reconnecting…" | frozen, retrying underneath | disabled | visible, spinner |
|
||||
| `disconnected` | disabled | torn down | disabled | visible or → connect page on fatal |
|
||||
| Status | Composer / send | Voice controls | Presence picker | Reconnect banner |
|
||||
| -------------- | ------------------------- | --------------------------- | --------------- | ---------------------------------- |
|
||||
| `connected` | enabled | enabled | enabled | hidden |
|
||||
| `reconnecting` | disabled, "Reconnecting…" | frozen, retrying underneath | disabled | visible, spinner |
|
||||
| `disconnected` | disabled | torn down | disabled | visible or → connect page on fatal |
|
||||
|
||||
---
|
||||
|
||||
@@ -116,33 +116,33 @@ source of truth in `ui.store.connectionStatus`
|
||||
|
||||
The dispatcher (`src/lib/dispatcher.ts`) is the single fan-in from the socket to
|
||||
the stores. Target: **every** inbound message type produces a defined store
|
||||
mutation *and*, where user-visible, a defined UI reaction. The per-flow docs
|
||||
mutation _and_, where user-visible, a defined UI reaction. The per-flow docs
|
||||
detail each; this is the index.
|
||||
|
||||
| Inbound event | Store effect | Target UI reaction |
|
||||
|---------------|--------------|--------------------|
|
||||
| `auth_ok` | `auth.setAuth` | Advance handshake → ready overlay |
|
||||
| `auth_error` | `ui.setTransientError` + `auth.clearAuth` | Return to connect page with the reason shown |
|
||||
| `ready` | bulk-load channels/roles/members/voice/dm | Render main view; resolve the connected overlay |
|
||||
| `chat_message` | `messages.addMessage` (+ unread/DM/notify) | Append; reconcile a pending optimistic row if it's our echo |
|
||||
| `chat_send_ok` | `messages.confirmSend` | Mark the optimistic row **sent** (see gap in [messaging.md](messaging.md)) |
|
||||
| `chat_edited` / `chat_deleted` | `messages.editMessage` / `deleteMessage` | In-place edit / tombstone |
|
||||
| `chat_bulk_deleted` | `messages.bulkDeleteMessages` | Remove every purged row in one pass |
|
||||
| `reaction_update` | `messages.updateReaction` | Toggle the pill + count, reflect `me` |
|
||||
| `typing` | `members.setTyping` (5 s auto-clear) | Typing indicator |
|
||||
| `presence` / `member_update` / `user_update` | `members.*` | Live member-list update |
|
||||
| `member_join` / `member_leave` / `member_ban` | `members.add/remove` | Member-list add/remove |
|
||||
| `channel_create` / `channel_update` / `channel_delete` | `channels.*` | Sidebar update; redirect if the active channel was deleted |
|
||||
| `roles_update` | `channels.setRoles` | Refresh name colors + permission-gated affordances |
|
||||
| `emoji_update` | `emoji.setCustomEmoji` | Refresh picker, autocomplete, and rendered custom emoji |
|
||||
| `voice_state` / `voice_leave` / `voice_config` / `voice_speakers` | `voice.*` | Voice roster + speaking rings |
|
||||
| `voice_moved` / `voice_disconnected` | `voice.*` + `livekitSession` | Follow a mod move by rejoining the new channel / tear down after a mod kick with an error toast naming the reason |
|
||||
| `voice_token` / `voice_e2ee_*` | `livekitSession.*` | Drive the voice-join + securing indicators |
|
||||
| `dm_channel_open` / `dm_channel_close` | `dm.*` | DM list add/remove |
|
||||
| `server_restart` | `ui.setTransientError` | Restart banner with countdown |
|
||||
| `error` | `ui.setTransientError` (+ `clearAuth` on `BANNED`) | Map the code → the reaction in §5 |
|
||||
| Inbound event | Store effect | Target UI reaction |
|
||||
| ----------------------------------------------------------------- | -------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------- |
|
||||
| `auth_ok` | `auth.setAuth` | Advance handshake → ready overlay |
|
||||
| `auth_error` | `ui.setTransientError` + `auth.clearAuth` | Return to connect page with the reason shown |
|
||||
| `ready` | bulk-load channels/roles/members/voice/dm | Render main view; resolve the connected overlay |
|
||||
| `chat_message` | `messages.addMessage` (+ unread/DM/notify) | Append; reconcile a pending optimistic row if it's our echo |
|
||||
| `chat_send_ok` | `messages.confirmSend` | Mark the optimistic row **sent** (see gap in [messaging.md](messaging.md)) |
|
||||
| `chat_edited` / `chat_deleted` | `messages.editMessage` / `deleteMessage` | In-place edit / tombstone |
|
||||
| `chat_bulk_deleted` | `messages.bulkDeleteMessages` | Remove every purged row in one pass |
|
||||
| `reaction_update` | `messages.updateReaction` | Toggle the pill + count, reflect `me` |
|
||||
| `typing` | `members.setTyping` (5 s auto-clear) | Typing indicator |
|
||||
| `presence` / `member_update` / `user_update` | `members.*` | Live member-list update |
|
||||
| `member_join` / `member_leave` / `member_ban` | `members.add/remove` | Member-list add/remove |
|
||||
| `channel_create` / `channel_update` / `channel_delete` | `channels.*` | Sidebar update; redirect if the active channel was deleted |
|
||||
| `roles_update` | `channels.setRoles` | Refresh name colors + permission-gated affordances |
|
||||
| `emoji_update` | `emoji.setCustomEmoji` | Refresh picker, autocomplete, and rendered custom emoji |
|
||||
| `voice_state` / `voice_leave` / `voice_config` / `voice_speakers` | `voice.*` | Voice roster + speaking rings |
|
||||
| `voice_moved` / `voice_disconnected` | `voice.*` + `livekitSession` | Follow a mod move by rejoining the new channel / tear down after a mod kick with an error toast naming the reason |
|
||||
| `voice_token` / `voice_e2ee_*` | `livekitSession.*` | Drive the voice-join + securing indicators |
|
||||
| `dm_channel_open` / `dm_channel_close` | `dm.*` | DM list add/remove |
|
||||
| `server_restart` | `ui.setTransientError` | Restart banner with countdown |
|
||||
| `error` | `ui.setTransientError` (+ `clearAuth` on `BANNED`) | Map the code → the reaction in §5 |
|
||||
|
||||
`call_incoming` / `call_declined` are deliberately *not* routed through the
|
||||
`call_incoming` / `call_declined` are deliberately _not_ routed through the
|
||||
dispatcher: `MainPage.ts` subscribes to them directly (page-scoped listeners)
|
||||
and drives the ring state machine in `lib/call-ring.ts` +
|
||||
`components/IncomingCallBanner.ts`.
|
||||
@@ -162,26 +162,26 @@ One canonical reaction per failure class, applied everywhere. Today error
|
||||
handling is per-call-site with no shared mapper (`doFetch()` in `lib/api.ts` centralizes only
|
||||
401); this matrix is the target contract.
|
||||
|
||||
| Class | Source | Target reaction |
|
||||
|-------|--------|-----------------|
|
||||
| **401 Unauthorized** | any REST call | Global: `clearAuth()` → disconnect → connect page, with "Your session expired — sign in again." (centralized in `api.ts` + `main.ts`; since 2026-07 `uploadFile` honors it too, and the connect page shows the session-expired reason) |
|
||||
| **403 Forbidden** (action) | REST/WS | Toast "You don't have permission to do that." **and** pre-disable the control so it can't be attempted again in that context |
|
||||
| **403 Suspended/Banned** | login REST / WS `BANNED` | Transient-error store → connect page: "Your account has been suspended." Force logout, no reconnect |
|
||||
| **429 Rate-limited** | REST/WS `RATE_LIMITED` | Non-destructive toast "You're doing that too fast — try again in a moment." Keep the user's input; re-enable the control after a short cooldown |
|
||||
| **Slow-mode** | WS `SLOW_MODE` | Disable send with a live countdown in the composer; do not drop the drafted message |
|
||||
| **Validation (400)** | REST | Inline field error with the server message (capped to a safe length — the login form caps at 200 chars in the `handleFormSubmit()` catch block, `pages/connect-page/LoginForm.ts`; apply everywhere) |
|
||||
| **Conflict/Not-found (404/409)** | REST/WS | Contextual inline message + refresh the affected view (the target moved/vanished) |
|
||||
| **5xx / network** | REST | Inline section error + **Retry**; for one-shot actions, a toast "Couldn't reach the server." Never a silent drop |
|
||||
| **Transport backpressure** | WS `ws_send` "channel full" | Mark the optimistic row failed with Retry (✓ since 2026-07: `ws.onSendFailure` → dispatcher → `markSendFailed` with `NETWORK`/`OFFLINE`; id-less sends like heartbeats stay silent) |
|
||||
| **Cert first-use** | Rust `cert-tofu: first_use` | **Blocking trust modal** (`createCertFirstUseModal`): the Rust proxy *rejects* the first connection rather than auto-pinning; Accept stores the pin and retries, Cancel leaves the server untrusted (already: the `ws.onCertFirstUse(...)` handler in `main.ts`) |
|
||||
| **Cert mismatch** | Rust `cert-tofu: mismatch` | Blocking `CertMismatchModal`; Accept re-pins + reconnects, Reject disconnects + returns to connect (already: the `ws.onCertMismatch(...)` handler in `main.ts`) |
|
||||
| Class | Source | Target reaction |
|
||||
| -------------------------------- | --------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| **401 Unauthorized** | any REST call | Global: `clearAuth()` → disconnect → connect page, with "Your session expired — sign in again." (centralized in `api.ts` + `main.ts`; since 2026-07 `uploadFile` honors it too, and the connect page shows the session-expired reason) |
|
||||
| **403 Forbidden** (action) | REST/WS | Toast "You don't have permission to do that." **and** pre-disable the control so it can't be attempted again in that context |
|
||||
| **403 Suspended/Banned** | login REST / WS `BANNED` | Transient-error store → connect page: "Your account has been suspended." Force logout, no reconnect |
|
||||
| **429 Rate-limited** | REST/WS `RATE_LIMITED` | Non-destructive toast "You're doing that too fast — try again in a moment." Keep the user's input; re-enable the control after a short cooldown |
|
||||
| **Slow-mode** | WS `SLOW_MODE` | Disable send with a live countdown in the composer; do not drop the drafted message |
|
||||
| **Validation (400)** | REST | Inline field error with the server message (capped to a safe length — the login form caps at 200 chars in the `handleFormSubmit()` catch block, `pages/connect-page/LoginForm.ts`; apply everywhere) |
|
||||
| **Conflict/Not-found (404/409)** | REST/WS | Contextual inline message + refresh the affected view (the target moved/vanished) |
|
||||
| **5xx / network** | REST | Inline section error + **Retry**; for one-shot actions, a toast "Couldn't reach the server." Never a silent drop |
|
||||
| **Transport backpressure** | WS `ws_send` "channel full" | Mark the optimistic row failed with Retry (✓ since 2026-07: `ws.onSendFailure` → dispatcher → `markSendFailed` with `NETWORK`/`OFFLINE`; id-less sends like heartbeats stay silent) |
|
||||
| **Cert first-use** | Rust `cert-tofu: first_use` | **Blocking trust modal** (`createCertFirstUseModal`): the Rust proxy _rejects_ the first connection rather than auto-pinning; Accept stores the pin and retries, Cancel leaves the server untrusted (already: the `ws.onCertFirstUse(...)` handler in `main.ts`) |
|
||||
| **Cert mismatch** | Rust `cert-tofu: mismatch` | Blocking `CertMismatchModal`; Accept re-pins + reconnects, Reject disconnects + returns to connect (already: the `ws.onCertMismatch(...)` handler in `main.ts`) |
|
||||
|
||||
---
|
||||
|
||||
## 6. Cross-cutting principles
|
||||
|
||||
1. **Optimistic where the user acts, authoritative where the server decides.**
|
||||
Local actions (send, react, mute) reflect immediately with a *pending* marker,
|
||||
Local actions (send, react, mute) reflect immediately with a _pending_ marker,
|
||||
then reconcile against the server echo; on failure they roll back visibly with
|
||||
a retry — never silently.
|
||||
2. **Permission is expressed as affordance, not as rejection.** If the server
|
||||
|
||||
@@ -15,30 +15,30 @@ Renders from `channels.store` (`channels` map, `activeChannelId`), grouped by
|
||||
category, sorted by position. The sidebar has two modes (`ui.store.sidebarMode`):
|
||||
`channels` and `dms`.
|
||||
|
||||
| State | Trigger | Target reaction |
|
||||
|-------|---------|-----------------|
|
||||
| `ready` | Channels loaded from `ready` | Grouped, collapsible category list |
|
||||
| `empty` | Zero channels | "No channels yet" + hint (already the empty-state branch of `renderChannels()`, `components/ChannelSidebar.ts`) |
|
||||
| category collapsed | User toggles | Persisted per-server in localStorage (`ui.toggleCategory`); chevron reflects state |
|
||||
| active channel | `setActiveChannel` | Highlighted; unread cleared |
|
||||
| unread | `chat_message` in a non-active channel | Unread pill; badge on the channel |
|
||||
| State | Trigger | Target reaction |
|
||||
| ------------------ | -------------------------------------- | --------------------------------------------------------------------------------------------------------------- |
|
||||
| `ready` | Channels loaded from `ready` | Grouped, collapsible category list |
|
||||
| `empty` | Zero channels | "No channels yet" + hint (already the empty-state branch of `renderChannels()`, `components/ChannelSidebar.ts`) |
|
||||
| category collapsed | User toggles | Persisted per-server in localStorage (`ui.toggleCategory`); chevron reflects state |
|
||||
| active channel | `setActiveChannel` | Highlighted; unread cleared |
|
||||
| unread | `chat_message` in a non-active channel | Unread pill; badge on the channel |
|
||||
|
||||
### 1.1 Channel type affordances
|
||||
|
||||
Each channel type gets a distinct icon and interaction:
|
||||
|
||||
| Type | Icon | Click behavior |
|
||||
|------|------|----------------|
|
||||
| `text` | hash | Focus → load messages |
|
||||
| Type | Icon | Click behavior |
|
||||
| -------------- | -------------- | ---------------------------------------------------------------------------------------------------------- |
|
||||
| `text` | hash | Focus → load messages |
|
||||
| `announcement` | megaphone (D1) | Focus → load messages; **composer read-only unless MANAGE_MESSAGES** (see [messaging.md §2](messaging.md)) |
|
||||
| `voice` | speaker | Join voice (see [voice-and-e2ee.md](voice-and-e2ee.md)); shows the participant roster inline |
|
||||
| `dm` | — | Not in the channel list; lives in DM mode |
|
||||
| `voice` | speaker | Join voice (see [voice-and-e2ee.md](voice-and-e2ee.md)); shows the participant roster inline |
|
||||
| `dm` | — | Not in the channel list; lives in DM mode |
|
||||
|
||||
### 1.1a Per-channel notification mutes
|
||||
|
||||
The channel context menu offers "Mute Channel" / "Unmute Channel"
|
||||
(the Mute Channel item in `attachChannelContextMenu()`, `components/channel-sidebar/context-menu.ts`, backed by `lib/channel-mutes.ts`).
|
||||
Discord semantics, deliberately: a mute silences the channel's *noise* — no
|
||||
Discord semantics, deliberately: a mute silences the channel's _noise_ — no
|
||||
desktop notification, no chime — while the unread badge still counts but
|
||||
renders dimmed, and a message that mentions you still notifies and shows the
|
||||
red mention badge. It is a client-side preference on purpose (stored in
|
||||
@@ -63,6 +63,7 @@ sequenceDiagram
|
||||
```
|
||||
|
||||
**Target rules:**
|
||||
|
||||
- Switching is instantaneous from cache; the message area shows its own loading
|
||||
state for uncached history ([messaging.md §1](messaging.md)), never a global block.
|
||||
- If the active channel is **deleted** server-side (`channel_delete`), redirect to
|
||||
@@ -85,14 +86,14 @@ back on failure.
|
||||
Renders from `members.store` (`members` map + `typingUsers`). Shows presence and
|
||||
role grouping.
|
||||
|
||||
| State | Trigger | Target reaction |
|
||||
|-------|---------|-----------------|
|
||||
| `ready` | `ready.members` | Grouped by role, sorted; presence dot per member |
|
||||
| `empty` | No online members | "No members online" (already the empty-state branch of `renderList()`, `components/MemberList.ts`) |
|
||||
| presence change | `presence` event | Live dot update; offline members styled distinctly |
|
||||
| role change | `member_update` | Re-group live |
|
||||
| profile change | `user_update` | Name/avatar update; if it's us, also patch `auth.store` (already the `user_update` handler in `wireDispatcher()`, `lib/dispatcher.ts`) |
|
||||
| join/leave/ban | `member_join`/`member_leave`/`member_ban` | Add/remove with no reflow flash |
|
||||
| State | Trigger | Target reaction |
|
||||
| --------------- | ----------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `ready` | `ready.members` | Grouped by role, sorted; presence dot per member |
|
||||
| `empty` | No online members | "No members online" (already the empty-state branch of `renderList()`, `components/MemberList.ts`) |
|
||||
| presence change | `presence` event | Live dot update; offline members styled distinctly |
|
||||
| role change | `member_update` | Re-group live |
|
||||
| profile change | `user_update` | Name/avatar update; if it's us, also patch `auth.store` (already the `user_update` handler in `wireDispatcher()`, `lib/dispatcher.ts`) |
|
||||
| join/leave/ban | `member_join`/`member_leave`/`member_ban` | Add/remove with no reflow flash |
|
||||
|
||||
### 2.1 Typing indicator
|
||||
|
||||
@@ -122,14 +123,14 @@ role), consistent with the affordance principle.
|
||||
DM mode (`sidebarMode: "dms"`) renders from `dm.store` (`channels` list, each with
|
||||
recipient, last-message preview, unread).
|
||||
|
||||
| State | Trigger | Target reaction |
|
||||
|-------|---------|-----------------|
|
||||
| `ready` | `ready.dm_channels` | DM list sorted by recency |
|
||||
| `empty` | No DMs | "No direct messages yet" + "Start one from a member's profile" |
|
||||
| open DM | `dm_channel_open` | Prepend/move-to-top, dedup (already `addDmChannel()`, `stores/dm.store.ts`) |
|
||||
| close DM | `dm_channel_close` | Remove from list |
|
||||
| new DM message | `chat_message` in a DM | `updateDmLastMessage` (unread bump + reorder) if not focused; `updateDmLastMessagePreview` (no bump) if own/active |
|
||||
| last-message empty | Never messaged | "No messages yet" fallback (already the `lastMessage` fallback in `buildDmConversations()`, `pages/main-page/SidebarDmHelpers.ts`) |
|
||||
| State | Trigger | Target reaction |
|
||||
| ------------------ | ---------------------- | ---------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `ready` | `ready.dm_channels` | DM list sorted by recency |
|
||||
| `empty` | No DMs | "No direct messages yet" + "Start one from a member's profile" |
|
||||
| open DM | `dm_channel_open` | Prepend/move-to-top, dedup (already `addDmChannel()`, `stores/dm.store.ts`) |
|
||||
| close DM | `dm_channel_close` | Remove from list |
|
||||
| new DM message | `chat_message` in a DM | `updateDmLastMessage` (unread bump + reorder) if not focused; `updateDmLastMessagePreview` (no bump) if own/active |
|
||||
| last-message empty | Never messaged | "No messages yet" fallback (already the `lastMessage` fallback in `buildDmConversations()`, `pages/main-page/SidebarDmHelpers.ts`) |
|
||||
|
||||
### 3.1 Opening a DM
|
||||
|
||||
@@ -166,11 +167,11 @@ other participant).
|
||||
Blocking gates DM delivery server-side (a blocked user can't post into the DM,
|
||||
and `IsEitherBlocked` is bidirectional). **Target UX:**
|
||||
|
||||
| Action | Reaction |
|
||||
|--------|----------|
|
||||
| Block user | Confirm → block; DM composer becomes read-only with "You've blocked this user. Unblock to send messages." |
|
||||
| Action | Reaction |
|
||||
| ------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| Block user | Confirm → block; DM composer becomes read-only with "You've blocked this user. Unblock to send messages." |
|
||||
| Being blocked | Composer read-only with a neutral "You can't message this user right now." (do not reveal the block state explicitly — the server returns a generic refusal) |
|
||||
| Unblock | Composer re-enables |
|
||||
| Unblock | Composer re-enables |
|
||||
|
||||
> **✅ Wired (composer gating).** DM block state now drives the same
|
||||
> disabled-with-reason composer mode (see [messaging.md §2](messaging.md)) via
|
||||
|
||||
@@ -43,13 +43,13 @@ status area. Settings are reachable unauthenticated (for appearance/advanced).
|
||||
|
||||
### 2.1 Server profiles & health
|
||||
|
||||
| State | Trigger | Target reaction |
|
||||
|-------|---------|-----------------|
|
||||
| `loading` | Profile list resolving from the Rust store (`owncord:profiles`) | Skeleton rows; no flash of "no servers" |
|
||||
| `ready` | Profiles loaded | List with per-profile health dot |
|
||||
| `empty` | No saved profiles | "Add a server to get started" with an inline add affordance |
|
||||
| health: reachable | `GET /api/v1/health` ok within 3 s | Green dot + server name/MOTD preview |
|
||||
| health: unreachable | timeout/opaque error | Amber "unreachable" dot; **do not** block selecting it (user may still try) |
|
||||
| State | Trigger | Target reaction |
|
||||
| ------------------- | --------------------------------------------------------------- | --------------------------------------------------------------------------- |
|
||||
| `loading` | Profile list resolving from the Rust store (`owncord:profiles`) | Skeleton rows; no flash of "no servers" |
|
||||
| `ready` | Profiles loaded | List with per-profile health dot |
|
||||
| `empty` | No saved profiles | "Add a server to get started" with an inline add affordance |
|
||||
| health: reachable | `GET /api/v1/health` ok within 3 s | Green dot + server name/MOTD preview |
|
||||
| health: unreachable | timeout/opaque error | Amber "unreachable" dot; **do not** block selecting it (user may still try) |
|
||||
|
||||
Health polls every 15 s (interval wired in `main.ts`, profile data via
|
||||
`profiles.ts`); auto-connect, if enabled for the active profile, drives the
|
||||
@@ -61,14 +61,14 @@ The form is an explicit FSM: `idle | loading | totp | connecting | error |
|
||||
auto-connecting` (the `FormState` type in `pages/connect-page/LoginForm.ts`). This is the model other views should
|
||||
follow.
|
||||
|
||||
| State | Presentation | Exit |
|
||||
|-------|--------------|------|
|
||||
| `idle` | Enabled fields; Login/Register toggle | submit → validate |
|
||||
| `loading` | Submit shows spinner, fields disabled (`updateSubmitButton()` + `updateFormInputsDisabled()` in `LoginForm.ts`) | `auth.login` resolves |
|
||||
| `totp` | 6-digit overlay, Verify/Cancel | code → `verifyTotp` |
|
||||
| `connecting` | "Connecting…" while WS handshakes | ws `connected` |
|
||||
| `auto-connecting` | Dedicated spinner card for saved-profile auto-login | any key/click cancels to `idle` |
|
||||
| `error` | Shake-animated banner, server message capped 200 chars (the `handleFormSubmit()` catch + `updateErrorBanner()` in `LoginForm.ts`) | user edits → `idle` |
|
||||
| State | Presentation | Exit |
|
||||
| ----------------- | --------------------------------------------------------------------------------------------------------------------------------- | ------------------------------- |
|
||||
| `idle` | Enabled fields; Login/Register toggle | submit → validate |
|
||||
| `loading` | Submit shows spinner, fields disabled (`updateSubmitButton()` + `updateFormInputsDisabled()` in `LoginForm.ts`) | `auth.login` resolves |
|
||||
| `totp` | 6-digit overlay, Verify/Cancel | code → `verifyTotp` |
|
||||
| `connecting` | "Connecting…" while WS handshakes | ws `connected` |
|
||||
| `auto-connecting` | Dedicated spinner card for saved-profile auto-login | any key/click cancels to `idle` |
|
||||
| `error` | Shake-animated banner, server message capped 200 chars (the `handleFormSubmit()` catch + `updateErrorBanner()` in `LoginForm.ts`) | user edits → `idle` |
|
||||
|
||||
**Client-side validation before any request** (`validateForm()` in `LoginForm.ts`): host,
|
||||
username, password required; password ≥ 8; register mode also requires the invite
|
||||
@@ -105,14 +105,14 @@ sequenceDiagram
|
||||
|
||||
**Auth branches → reaction** (server `auth_handler.go`):
|
||||
|
||||
| Server result | Target reaction |
|
||||
|---------------|-----------------|
|
||||
| `200 {token, user}` | Proceed to WS connect |
|
||||
| Server result | Target reaction |
|
||||
| ----------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `200 {token, user}` | Proceed to WS connect |
|
||||
| `200 {partial_token, requires_2fa}` | TOTP overlay; on cancel, clear the partial token (already cleared: the `onTotpSubmit` handler's `finally` in `main.ts` resets `pendingTotpPartialToken`) |
|
||||
| `403` banned/suspended | Error banner with the server message; remain on the form |
|
||||
| `403` require-2FA-but-none-set | Error banner directing the user to set up 2FA on the web panel |
|
||||
| `400` invalid input | Inline field error |
|
||||
| `429` rate-limited | "Too many attempts — wait a moment." Keep entered username; re-enable after cooldown |
|
||||
| `403` banned/suspended | Error banner with the server message; remain on the form |
|
||||
| `403` require-2FA-but-none-set | Error banner directing the user to set up 2FA on the web panel |
|
||||
| `400` invalid input | Inline field error |
|
||||
| `429` rate-limited | "Too many attempts — wait a moment." Keep entered username; re-enable after cooldown |
|
||||
|
||||
### 2.4 Register-by-invite
|
||||
|
||||
@@ -145,7 +145,7 @@ sequenceDiagram
|
||||
OVL->>OVL: onReady → router.navigate("main")
|
||||
```
|
||||
|
||||
**Target rule:** the ready overlay is the *only* full-screen blocker in the app.
|
||||
**Target rule:** the ready overlay is the _only_ full-screen blocker in the app.
|
||||
It exists specifically so Main never renders mid-populate. Everything else
|
||||
(message load, member load) uses in-region loading, not a global block.
|
||||
|
||||
@@ -168,19 +168,19 @@ stateDiagram-v2
|
||||
Restarting --> Reconnecting: server drops us
|
||||
```
|
||||
|
||||
| Phase | Target reaction |
|
||||
|-------|-----------------|
|
||||
| `reconnecting` | `ServerBanner.showReconnecting()` (already `applyConnectionStatus()`, `components/ServerBanner.ts`, invoked from MainPage's connectionStatus subscription); **live-only controls disable** via connection status (§3 of README); drafted input preserved |
|
||||
| replay resync | Silent when the ring buffer covers `last_seq`; deduped so no double-render (the replay-dedup block inside `handleMessage()`, `lib/ws.ts`); unread suppressed during replay (the `chat_message` handler's `!ws.isReplaying()` guard in `wireDispatcher()`, `lib/dispatcher.ts`) |
|
||||
| full resync | If `last_seq` predates buffer coverage, server replays from the events table or forces a full `ready`; the UI simply re-populates — no user action |
|
||||
| `server_restart` | `ServerBanner.showRestart(delay_seconds)` with a live countdown (`showRestart()`, `components/ServerBanner.ts`) |
|
||||
| fatal (`auth_error`) | `intentionalClose`, transient-error store → connect page |
|
||||
| Phase | Target reaction |
|
||||
| -------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| `reconnecting` | `ServerBanner.showReconnecting()` (already `applyConnectionStatus()`, `components/ServerBanner.ts`, invoked from MainPage's connectionStatus subscription); **live-only controls disable** via connection status (§3 of README); drafted input preserved |
|
||||
| replay resync | Silent when the ring buffer covers `last_seq`; deduped so no double-render (the replay-dedup block inside `handleMessage()`, `lib/ws.ts`); unread suppressed during replay (the `chat_message` handler's `!ws.isReplaying()` guard in `wireDispatcher()`, `lib/dispatcher.ts`) |
|
||||
| full resync | If `last_seq` predates buffer coverage, server replays from the events table or forces a full `ready`; the UI simply re-populates — no user action |
|
||||
| `server_restart` | `ServerBanner.showRestart(delay_seconds)` with a live countdown (`showRestart()`, `components/ServerBanner.ts`) |
|
||||
| fatal (`auth_error`) | `intentionalClose`, transient-error store → connect page |
|
||||
|
||||
**Target rule:** reconnection is invisible on the happy path and honest on the
|
||||
sad path. The user should never wonder whether the app is live — the banner and
|
||||
the disabled live-controls answer it. This is where consolidating connection
|
||||
status onto `ui.store` (README §3) pays off: the composer, voice controls, and
|
||||
presence picker all disable *reactively* while reconnecting, instead of accepting
|
||||
presence picker all disable _reactively_ while reconnecting, instead of accepting
|
||||
a click and failing.
|
||||
|
||||
---
|
||||
@@ -189,16 +189,16 @@ a click and failing.
|
||||
|
||||
The Rust proxies validate the server cert against the per-host pin store and
|
||||
emit `cert-tofu` events. **Deciding never writes a pin** (`tofu.rs`): an
|
||||
unknown host's first connection is *rejected* until the user confirms the
|
||||
unknown host's first connection is _rejected_ until the user confirms the
|
||||
fingerprint, so no credential is ever sent to an unconfirmed host. The HTTP
|
||||
proxy usually sees the host first (the connect page's health check precedes
|
||||
login and WS).
|
||||
|
||||
| Event | Target reaction | Current |
|
||||
|-------|-----------------|---------|
|
||||
| Event | Target reaction | Current |
|
||||
| ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `first_use` | **Blocking trust modal** (`createCertFirstUseModal`) showing host + fingerprint; **Accept** stores the pin (`accept_cert_fingerprint`), re-runs the connect-page health check and resumes a pending connect; **Cancel** leaves the host untrusted (health stays "unreachable") | Implemented in the `ws.onCertFirstUse(...)` handler in `main.ts`; shares a `certModalActive` guard with the mismatch modal so the two never stack |
|
||||
| `trusted` | No UI (silent, expected) | — |
|
||||
| `mismatch` | **Blocking** `CertMismatchModal`: explain the fingerprint changed; **Accept** re-pins (`accept_cert_fingerprint`) + reconnects; **Reject** disconnects, `clearAuth()`, → connect page | Implemented in the `ws.onCertMismatch(...)` handler in `main.ts`; reconnect blocked until resolved (`certMismatchBlock`) |
|
||||
| `trusted` | No UI (silent, expected) | — |
|
||||
| `mismatch` | **Blocking** `CertMismatchModal`: explain the fingerprint changed; **Accept** re-pins (`accept_cert_fingerprint`) + reconnects; **Reject** disconnects, `clearAuth()`, → connect page | Implemented in the `ws.onCertMismatch(...)` handler in `main.ts`; reconnect blocked until resolved (`certMismatchBlock`) |
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
@@ -219,7 +219,7 @@ sequenceDiagram
|
||||
end
|
||||
```
|
||||
|
||||
**Target rule:** a cert mismatch is the one moment the client must *stop and ask*
|
||||
**Target rule:** a cert mismatch is the one moment the client must _stop and ask_
|
||||
— never auto-accept, never silently reconnect. This is correct today; the spec
|
||||
locks it.
|
||||
|
||||
@@ -227,12 +227,12 @@ locks it.
|
||||
|
||||
## 6. Logout & session lifecycle
|
||||
|
||||
| Trigger | Target behavior |
|
||||
|---------|-----------------|
|
||||
| User logout | best-effort `POST /auth/logout` (fire-and-forget) → `clearAuth()` → leave voice, disconnect WS, delete stored credential for the host, → connect page |
|
||||
| 401 anywhere | Same as logout, with "Your session expired — sign in again." |
|
||||
| WS `BANNED` | Transient-error → connect page, no reconnect |
|
||||
| Cert reject | Disconnect → connect page |
|
||||
| Trigger | Target behavior |
|
||||
| ------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| User logout | best-effort `POST /auth/logout` (fire-and-forget) → `clearAuth()` → leave voice, disconnect WS, delete stored credential for the host, → connect page |
|
||||
| 401 anywhere | Same as logout, with "Your session expired — sign in again." |
|
||||
| WS `BANNED` | Transient-error → connect page, no reconnect |
|
||||
| Cert reject | Disconnect → connect page |
|
||||
|
||||
> **✓ Resolved 2026-07-20 — server session revoked on logout.** User-initiated
|
||||
> logout now calls `api.logout()` (`POST /auth/logout`) via the `logout()` helper
|
||||
|
||||
@@ -14,13 +14,13 @@ slow-mode, and announcement read-only gating.
|
||||
|
||||
The list renders from `messages.store` (`messagesByChannel`, capped 500/channel).
|
||||
|
||||
| State | Trigger | Target reaction |
|
||||
|-------|---------|-----------------|
|
||||
| `loading` | Channel opened, history fetch in flight, nothing cached | **In-region loading placeholder** in the message area |
|
||||
| `ready` | Messages present | Virtualized list |
|
||||
| `empty` | Loaded, zero messages | "This is the beginning of #channel." welcome state (already `renderEmptyState()`, `components/MessageList.ts`) |
|
||||
| `loading older` | Scroll-to-top with `hasMore` | Top spinner while `prependMessages` resolves (already the scroll-top `hasMore` branch of `handleScroll()`, `components/MessageList.ts`) |
|
||||
| `error` | History fetch failed | **Inline section error + Retry** in the message area |
|
||||
| State | Trigger | Target reaction |
|
||||
| --------------- | ------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `loading` | Channel opened, history fetch in flight, nothing cached | **In-region loading placeholder** in the message area |
|
||||
| `ready` | Messages present | Virtualized list |
|
||||
| `empty` | Loaded, zero messages | "This is the beginning of #channel." welcome state (already `renderEmptyState()`, `components/MessageList.ts`) |
|
||||
| `loading older` | Scroll-to-top with `hasMore` | Top spinner while `prependMessages` resolves (already the scroll-top `hasMore` branch of `handleScroll()`, `components/MessageList.ts`) |
|
||||
| `error` | History fetch failed | **Inline section error + Retry** in the message area |
|
||||
|
||||
> **✓ Implemented (2026-07).** `messages.store` tracks a per-channel
|
||||
> `historyLoadState` (`loading` / `error`, absent = idle); `loadMessages` sets it
|
||||
@@ -35,7 +35,7 @@ The list renders from `messages.store` (`messagesByChannel`, capped 500/channel)
|
||||
## 2. Composer — permission & connection gating
|
||||
|
||||
This is the spec's canonical example of **permission-as-affordance**. The
|
||||
composer must reflect, *before the user types or sends*, whether posting is
|
||||
composer must reflect, _before the user types or sends_, whether posting is
|
||||
possible.
|
||||
|
||||
```mermaid
|
||||
@@ -54,14 +54,14 @@ stateDiagram-v2
|
||||
SlowMode --> Enabled: cooldown elapsed
|
||||
```
|
||||
|
||||
| Composer state | Presentation | Reason shown |
|
||||
|----------------|--------------|--------------|
|
||||
| `enabled` | Editable textarea, attach + pickers active | — |
|
||||
| `read-only` (announcement, no MANAGE_MESSAGES) | Textarea replaced by a disabled bar | "Only moderators can post in announcement channels." |
|
||||
| `no-permission` | Disabled bar | "You don't have permission to send messages here." |
|
||||
| `offline` | Disabled — "Reconnecting…" while retrying, "Not connected" when disconnected | connection status (README §3) |
|
||||
| `slow-mode` | Disabled with a live countdown | "Slow mode: wait Ns." |
|
||||
| `uploading` | Send disabled until uploads settle (already the `pendingUploadCount` guard in `handleSend()`, `components/MessageInput.ts`) | per-attachment spinner |
|
||||
| Composer state | Presentation | Reason shown |
|
||||
| ---------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------- |
|
||||
| `enabled` | Editable textarea, attach + pickers active | — |
|
||||
| `read-only` (announcement, no MANAGE_MESSAGES) | Textarea replaced by a disabled bar | "Only moderators can post in announcement channels." |
|
||||
| `no-permission` | Disabled bar | "You don't have permission to send messages here." |
|
||||
| `offline` | Disabled — "Reconnecting…" while retrying, "Not connected" when disconnected | connection status (README §3) |
|
||||
| `slow-mode` | Disabled with a live countdown | "Slow mode: wait Ns." |
|
||||
| `uploading` | Send disabled until uploads settle (already the `pendingUploadCount` guard in `handleSend()`, `components/MessageInput.ts`) | per-attachment spinner |
|
||||
|
||||
> **✓ Implemented (2026-07).** The server sends an authoritative per-channel
|
||||
> `can_send` in the ready payload (`ws/serve.go` `channelCanSend`, mirroring
|
||||
@@ -105,11 +105,11 @@ sequenceDiagram
|
||||
end
|
||||
```
|
||||
|
||||
| Optimistic state | Presentation | Transition |
|
||||
|------------------|--------------|------------|
|
||||
| `pending` | Row shown dimmed with a subtle "sending" affordance | `chat_send_ok` → `sent`; error → `failed` |
|
||||
| `sent` | Normal row; the subsequent `chat_message` broadcast reconciles (same `id`), never duplicates | — |
|
||||
| `failed` | Row marked failed with **Retry** and **Delete draft**; content preserved | Retry re-sends with a new correlation id |
|
||||
| Optimistic state | Presentation | Transition |
|
||||
| ---------------- | -------------------------------------------------------------------------------------------- | ----------------------------------------- |
|
||||
| `pending` | Row shown dimmed with a subtle "sending" affordance | `chat_send_ok` → `sent`; error → `failed` |
|
||||
| `sent` | Normal row; the subsequent `chat_message` broadcast reconciles (same `id`), never duplicates | — |
|
||||
| `failed` | Row marked failed with **Retry** and **Delete draft**; content preserved | Retry re-sends with a new correlation id |
|
||||
|
||||
**Reconciliation contract:** the correlation id (`ws.ts` per-send UUID, echoed as
|
||||
`chat_send_ok.id`) is the join key. `addMessage` from the broadcast must detect an
|
||||
@@ -134,11 +134,11 @@ existing pending/sent row for that id and replace-in-place rather than append.
|
||||
|
||||
## 4. Edit / delete
|
||||
|
||||
| Action | Target UX |
|
||||
|--------|-----------|
|
||||
| Edit (own message) | Inline edit in the composer (`startEdit`, `MessageInput.ts`); optimistic content swap; `chat_edited` reconciles + stamps "edited"; failure rolls back with a toast |
|
||||
| Action | Target UX |
|
||||
| ------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| Edit (own message) | Inline edit in the composer (`startEdit`, `MessageInput.ts`); optimistic content swap; `chat_edited` reconciles + stamps "edited"; failure rolls back with a toast |
|
||||
| Delete (own / moderator) | **Two-click confirm** on the row (`createPendingDeleteManager()`, `pages/main-page/MessageController.ts`); optimistic tombstone; `chat_deleted` confirms; failure restores the row + toast |
|
||||
| Delete (no permission) | The delete affordance is not offered on others' messages unless the user has MANAGE_MESSAGES |
|
||||
| Delete (no permission) | The delete affordance is not offered on others' messages unless the user has MANAGE_MESSAGES |
|
||||
|
||||
Deleted messages are soft-deleted (kept as a tombstone in the array, `deleted:true`)
|
||||
so surrounding context and reply references stay intact.
|
||||
@@ -147,10 +147,10 @@ so surrounding context and reply references stay intact.
|
||||
|
||||
## 5. Reactions
|
||||
|
||||
| Action | Target UX |
|
||||
|--------|-----------|
|
||||
| Action | Target UX |
|
||||
| ------------------- | -------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Add/remove reaction | Optimistic pill toggle + count adjustment, reflecting `me`; `reaction_update` echo reconciles; failure rolls the pill back |
|
||||
| Emoji picker | `EmojiPicker` with recent-emoji memory (`owncord:recent-emoji`) |
|
||||
| Emoji picker | `EmojiPicker` with recent-emoji memory (`owncord:recent-emoji`) |
|
||||
|
||||
> **✓ Implemented (2026-08).** The pill toggles on the click:
|
||||
> `ReactionController.sendReaction` applies the toggle locally
|
||||
@@ -165,7 +165,7 @@ so surrounding context and reply references stay intact.
|
||||
|
||||
**Who reacted (✓ implemented 2026-08):** hovering (or focusing) a reaction pill
|
||||
for 300 ms fetches the reactor list and shows a tooltip reading
|
||||
*"alice, bob, carol and 4 others reacted with 👍"*. The debounce mirrors
|
||||
_"alice, bob, carol and 4 others reacted with 👍"_. The debounce mirrors
|
||||
`lib/streamPreview.ts` so a pointer crossing a row of pills fires no requests.
|
||||
The list comes from `GET /channels/{id}/messages/{messageId}/reactions/{emoji}/users`
|
||||
(oldest first, capped at 100 server-side) and is cached per message+emoji in
|
||||
@@ -180,13 +180,13 @@ Usernames are inserted as text nodes — never markup.
|
||||
The composer supports file attach with client-side validation and per-item
|
||||
upload state (already thorough — `MessageInput.ts`).
|
||||
|
||||
| State | Presentation |
|
||||
|-------|--------------|
|
||||
| selected | Thumbnail/chip per file |
|
||||
| validating | Reject oversize/disallowed type inline via `showUploadError` (the `MAX_FILE_SIZE`/`ALLOWED_TYPES` validation in `handlePasteFile()`, `components/MessageInput.ts`) |
|
||||
| uploading | Per-item spinner; **send disabled** until all settle (the per-item uploading preview in `handlePasteFile()` + the `handleSend()` upload guard, `components/MessageInput.ts`) |
|
||||
| uploaded | Chip ready; ids attached to the `chat_send` payload |
|
||||
| failed | Inline error on the chip with remove/retry |
|
||||
| State | Presentation |
|
||||
| ---------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| selected | Thumbnail/chip per file |
|
||||
| validating | Reject oversize/disallowed type inline via `showUploadError` (the `MAX_FILE_SIZE`/`ALLOWED_TYPES` validation in `handlePasteFile()`, `components/MessageInput.ts`) |
|
||||
| uploading | Per-item spinner; **send disabled** until all settle (the per-item uploading preview in `handlePasteFile()` + the `handleSend()` upload guard, `components/MessageInput.ts`) |
|
||||
| uploaded | Chip ready; ids attached to the `chat_send` payload |
|
||||
| failed | Inline error on the chip with remove/retry |
|
||||
|
||||
Upload goes through `POST /uploads` (multipart). **✓ Implemented (2026-07):**
|
||||
`uploadFile` now honors the global 401 handler like every other call — a 401
|
||||
@@ -196,12 +196,12 @@ sign in again.") and throws `ApiClientError(401)`.
|
||||
**Inline players (✓ implemented 2026-08):** a received attachment renders by MIME
|
||||
family, not as a download chip for everything but images:
|
||||
|
||||
| MIME | Rendering |
|
||||
|------|-----------|
|
||||
| `image/*` except `image/svg+xml` | Inline `<img>` (existing) |
|
||||
| `video/mp4`, `video/webm`, `video/ogg` | Inline `<video controls preload="metadata">` in the same max box as an image, with the download button on hover |
|
||||
| `audio/mpeg`/`mp3`, `audio/ogg`, `audio/opus`, `audio/wav`, `audio/webm` | Inline `<audio controls preload="metadata">` row with filename, size and download |
|
||||
| anything else, including `image/svg+xml` | Download chip |
|
||||
| MIME | Rendering |
|
||||
| ------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------- |
|
||||
| `image/*` except `image/svg+xml` | Inline `<img>` (existing) |
|
||||
| `video/mp4`, `video/webm`, `video/ogg` | Inline `<video controls preload="metadata">` in the same max box as an image, with the download button on hover |
|
||||
| `audio/mpeg`/`mp3`, `audio/ogg`, `audio/opus`, `audio/wav`, `audio/webm` | Inline `<audio controls preload="metadata">` row with filename, size and download |
|
||||
| anything else, including `image/svg+xml` | Download chip |
|
||||
|
||||
Both player families are allowlists, not `video/`/`audio/` prefix tests: an
|
||||
unknown container gets a chip rather than a player that fails to decode. SVG is
|
||||
@@ -217,11 +217,11 @@ string and park it in the LRU + IndexedDB caches.
|
||||
|
||||
## 7. Replies, pins, search, read/unread
|
||||
|
||||
| Feature | Target UX |
|
||||
|---------|-----------|
|
||||
| Reply | Reply target chip above the composer (`setReplyTo`/`clearReply`); `reply_to` sent; rendered as a quoted preview |
|
||||
| Pin/unpin | Optimistic (`setMessagePinned()`, already optimistic in `stores/messages.store.ts`); pinned panel lists them, empty state "This channel doesn't have any pinned messages… yet!" (already `renderEmptyState()`, `components/PinnedMessages.ts`) |
|
||||
| Search | Overlay with a status line cycling *type-N-chars → searching → results → no results → failed* (already thorough: `doSearch()`/`setStatus()` in `components/SearchOverlay.ts`); abort in-flight on new query |
|
||||
| Feature | Target UX |
|
||||
| ----------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Reply | Reply target chip above the composer (`setReplyTo`/`clearReply`); `reply_to` sent; rendered as a quoted preview |
|
||||
| Pin/unpin | Optimistic (`setMessagePinned()`, already optimistic in `stores/messages.store.ts`); pinned panel lists them, empty state "This channel doesn't have any pinned messages… yet!" (already `renderEmptyState()`, `components/PinnedMessages.ts`) |
|
||||
| Search | Overlay with a status line cycling _type-N-chars → searching → results → no results → failed_ (already thorough: `doSearch()`/`setStatus()` in `components/SearchOverlay.ts`); abort in-flight on new query |
|
||||
| Read/unread | Unread badge per channel; cleared on focus (`setActiveChannel`); incremented only for non-active, non-own, non-replay messages (the `chat_message` handler in `wireDispatcher()`, `lib/dispatcher.ts`); focus emits `channel_focus` for server read-state |
|
||||
|
||||
**Read-state target rule:** unread counts must be suppressed during reconnect
|
||||
@@ -233,7 +233,7 @@ unread messages renders a red **NEW** line above the first one. Opening the
|
||||
channel clears the badge, which destroys the only record of where the reader had
|
||||
got to, so `setActiveChannel` snapshots the count first
|
||||
(`channels.store.getUnreadOnOpen`); MessageList reads it once at mount and places
|
||||
the line above the last *N* loaded messages. Consequences of that derivation: the
|
||||
the line above the last _N_ loaded messages. Consequences of that derivation: the
|
||||
line is suppressed while the message window is detached (a slice around some old
|
||||
message is not the tail), and it clears on the next visit, when the snapshot is 0.
|
||||
The message under the line never renders as a grouped continuation of the one
|
||||
@@ -262,24 +262,24 @@ reply bar above a reply, an `owncord://message/…` permalink pasted into chat o
|
||||
opened from the OS — goes through one path (`lib/message-navigation.ts`
|
||||
registry → `main-page/MessageJump.ts`), so they behave identically.
|
||||
|
||||
| Step | Target UX |
|
||||
|------|-----------|
|
||||
| Target loaded | Scroll to the row and flash it (`.highlight-flash`, 1.5s) |
|
||||
| Target not loaded | Fetch `GET /channels/{id}/messages/around/{messageId}`, replace the channel's window with it, then scroll + flash |
|
||||
| Target in another channel | Open that channel first, then the above — the jumper owns the switch so the fetch is sequenced after it, not racing it |
|
||||
| Channel not visible / message deleted | Toast and stay put; never blank the chat area on an unresolvable link |
|
||||
| Step | Target UX |
|
||||
| ------------------------------------- | ---------------------------------------------------------------------------------------------------------------------- |
|
||||
| Target loaded | Scroll to the row and flash it (`.highlight-flash`, 1.5s) |
|
||||
| Target not loaded | Fetch `GET /channels/{id}/messages/around/{messageId}`, replace the channel's window with it, then scroll + flash |
|
||||
| Target in another channel | Open that channel first, then the above — the jumper owns the switch so the fetch is sequenced after it, not racing it |
|
||||
| Channel not visible / message deleted | Toast and stay put; never blank the chat area on an unresolvable link |
|
||||
|
||||
**Detached windows.** An around-window whose `has_more_after` is true is
|
||||
*detached*: the bottom of the list is history, not "now". While detached the
|
||||
_detached_: the bottom of the list is history, not "now". While detached the
|
||||
store refuses to append live broadcasts (they belong below a gap, and splicing
|
||||
them on would be a lie about ordering) and the message list shows a **Jump to
|
||||
Present** pill. Clicking it reattaches and refetches the live tail. Scrolling
|
||||
further up (`prependMessages`) keeps the window detached; only a fresh tail
|
||||
fetch reattaches.
|
||||
|
||||
**Permalinks.** The hover action bar's *Copy Message Link* yields
|
||||
**Permalinks.** The hover action bar's _Copy Message Link_ yields
|
||||
`owncord://message/{channelId}/{messageId}`. Pasted back into chat, that link
|
||||
renders as a compact chip (channel name + *Jump*) rather than a bare URL; a
|
||||
renders as a compact chip (channel name + _Jump_) rather than a bare URL; a
|
||||
link to a channel the reader cannot see stays plain text.
|
||||
|
||||
---
|
||||
@@ -292,15 +292,15 @@ per-channel `mention_count` in `ready`. The client treats those fields as
|
||||
authoritative and only falls back to parsing `@tokens` locally when an older
|
||||
server omits them.
|
||||
|
||||
| Surface | Target UX |
|
||||
|---------|-----------|
|
||||
| `@username` | Highlighted **only** when it resolves — against the server's `mentions` list or `membersStore` (case-insensitive). An unresolvable `@nobody`, an email local part (`mail@example`) or an address-shaped `@bob@example.com` stays plain text |
|
||||
| `@everyone` / `@here` | Highlighted only when `mentions_everyone` is true; a sender without `MENTION_EVERYONE` produces ordinary text with no mention semantics anywhere in the client |
|
||||
| Mention of *you* | The `@token` gets `.mention-self` **and** the whole row gets `.mentioned` (left accent + tinted background) |
|
||||
| `#channel-name` | Rendered as a clickable chip when the name resolves in `channelsStore` (DM channels excluded); click / Enter routes through `navigateToChannel`, the same activation path the sidebar and quick switcher use |
|
||||
| Channel badge | `mentionCount` per channel, seeded from `ready`, incremented on an incoming `chat_message` that mentions you, cleared on activation alongside unread. The red `.mention-badge` replaces the plain unread badge — never both on one row |
|
||||
| Notification | "*X* mentioned you in #channel" for a direct mention or an honoured `@everyone`. The **Suppress @everyone** preference drops only `mentions_everyone`-driven notifications; a message that also names you still notifies. DND still silences the popup and the chime |
|
||||
| Composer | Typing `@` opens `MentionAutocomplete` (up/down/enter/tab/escape), filtered by username; `@everyone`/`@here` appear only when your role holds `MENTION_EVERYONE`. Selection inserts `@username ` and the popup owns Enter so a half-typed mention never sends |
|
||||
| Surface | Target UX |
|
||||
| --------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `@username` | Highlighted **only** when it resolves — against the server's `mentions` list or `membersStore` (case-insensitive). An unresolvable `@nobody`, an email local part (`mail@example`) or an address-shaped `@bob@example.com` stays plain text |
|
||||
| `@everyone` / `@here` | Highlighted only when `mentions_everyone` is true; a sender without `MENTION_EVERYONE` produces ordinary text with no mention semantics anywhere in the client |
|
||||
| Mention of _you_ | The `@token` gets `.mention-self` **and** the whole row gets `.mentioned` (left accent + tinted background) |
|
||||
| `#channel-name` | Rendered as a clickable chip when the name resolves in `channelsStore` (DM channels excluded); click / Enter routes through `navigateToChannel`, the same activation path the sidebar and quick switcher use |
|
||||
| Channel badge | `mentionCount` per channel, seeded from `ready`, incremented on an incoming `chat_message` that mentions you, cleared on activation alongside unread. The red `.mention-badge` replaces the plain unread badge — never both on one row |
|
||||
| Notification | "_X_ mentioned you in #channel" for a direct mention or an honoured `@everyone`. The **Suppress @everyone** preference drops only `mentions_everyone`-driven notifications; a message that also names you still notifies. DND still silences the popup and the chime |
|
||||
| Composer | Typing `@` opens `MentionAutocomplete` (up/down/enter/tab/escape), filtered by username; `@everyone`/`@here` appear only when your role holds `MENTION_EVERYONE`. Selection inserts `@username ` and the popup owns Enter so a half-typed mention never sends |
|
||||
|
||||
**Editing rule:** an edit re-resolves mentions (the row's highlight follows the
|
||||
new text) but never re-notifies and never re-increments a badge — that is
|
||||
@@ -315,17 +315,17 @@ client-side — the server stores and ships the raw text — and the renderer
|
||||
builds DOM nodes only: `innerHTML` is never used with message content, and
|
||||
every `href` passes `isSafeUrl` first.
|
||||
|
||||
| Construct | Syntax | Notes |
|
||||
|-----------|--------|-------|
|
||||
| Bold / italic / underline / strike | `**b**`, `*i*` or `_i_`, `__u__`, `~~s~~` | Nest freely (`**bold *and italic***`). `_` only opens on a word boundary, so `snake_case_names` stay literal |
|
||||
| Spoiler | `\|\|hidden\|\|` | Obscured `role="button"` span with `aria-pressed`; revealing is per-span and one-way, and the revealing click is swallowed so a link underneath cannot open with it |
|
||||
| Escape | `\*literal\*` | A backslash neutralises any markdown punctuation; escapes are inert inside code |
|
||||
| Block quote | `> line` at line start | Contiguous `>` lines merge into one quote; `>>>` quotes the rest of the message. Quotes may contain other blocks, one level deep |
|
||||
| Heading | `# `, `## `, `### ` at line start | h1–h3; the space is required, so `#nospace` and `#channel` are untouched |
|
||||
| Lists | `- ` / `* ` bullets, `1. ` ordered | Contiguous items form one list; two leading spaces nest a single level; an ordered list keeps its starting number |
|
||||
| Masked link | `[text](url)` | `http(s)` absolute URLs only — `javascript:`, `data:` and relative URLs render as literal source text. Shows `title=url`, and produces **no** link embed (an author who hid the address does not get it previewed back) |
|
||||
| Inline code | `` `code` ``, ``` ``code`` ``` | Markdown, mentions and autolinking are all dead inside |
|
||||
| Code fence | ` ```lang ` … ` ``` ` | The tag renders as a label and selects a lightweight highlighter (js/ts, go, python, rust, json, bash, css, html — anything else falls back to plain). Copy button unchanged |
|
||||
| Construct | Syntax | Notes |
|
||||
| ---------------------------------- | ----------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Bold / italic / underline / strike | `**b**`, `*i*` or `_i_`, `__u__`, `~~s~~` | Nest freely (`**bold *and italic***`). `_` only opens on a word boundary, so `snake_case_names` stay literal |
|
||||
| Spoiler | `\|\|hidden\|\|` | Obscured `role="button"` span with `aria-pressed`; revealing is per-span and one-way, and the revealing click is swallowed so a link underneath cannot open with it |
|
||||
| Escape | `\*literal\*` | A backslash neutralises any markdown punctuation; escapes are inert inside code |
|
||||
| Block quote | `> line` at line start | Contiguous `>` lines merge into one quote; `>>>` quotes the rest of the message. Quotes may contain other blocks, one level deep |
|
||||
| Heading | `# `, `## `, `### ` at line start | h1–h3; the space is required, so `#nospace` and `#channel` are untouched |
|
||||
| Lists | `- ` / `* ` bullets, `1. ` ordered | Contiguous items form one list; two leading spaces nest a single level; an ordered list keeps its starting number |
|
||||
| Masked link | `[text](url)` | `http(s)` absolute URLs only — `javascript:`, `data:` and relative URLs render as literal source text. Shows `title=url`, and produces **no** link embed (an author who hid the address does not get it previewed back) |
|
||||
| Inline code | `` `code` ``, ` ``code`` ` | Markdown, mentions and autolinking are all dead inside |
|
||||
| Code fence | ` ```lang ` … ` ``` ` | The tag renders as a label and selects a lightweight highlighter (js/ts, go, python, rust, json, bash, css, html — anything else falls back to plain). Copy button unchanged |
|
||||
|
||||
Bare URLs are still autolinked, and mention/`#channel` chips render inside
|
||||
styled spans — a URL's own `_` and `*` are treated as address, not markup.
|
||||
|
||||
@@ -18,6 +18,7 @@ Appearance, Notifications, Text & Images, Accessibility, Voice & Audio, Keybinds
|
||||
Advanced, Logs.
|
||||
|
||||
**Target rules:**
|
||||
|
||||
- Every save is confirmed: a toast on success, an inline error on failure. No
|
||||
silent saves.
|
||||
- Preference writes are immediate and local (localStorage `owncord:settings:*`),
|
||||
@@ -36,10 +37,10 @@ password for sensitive changes and are rate-limited server-side.
|
||||
|
||||
### 2.1 Profile edit
|
||||
|
||||
| Step | Reaction |
|
||||
|------|----------|
|
||||
| Step | Reaction |
|
||||
| -------------------- | ------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Edit username/avatar | `PATCH /users/me`; optimistic `auth.updateUser`; server broadcasts `user_update` so the member list + own bar update live |
|
||||
| Failure | Inline field error + rollback |
|
||||
| Failure | Inline field error + rollback |
|
||||
|
||||
### 2.2 Change password (with session revocation)
|
||||
|
||||
@@ -75,9 +76,9 @@ success message with a soft note, never a red error. (Server contract:
|
||||
|
||||
### 2.3 Two-factor (TOTP)
|
||||
|
||||
| Flow | Steps |
|
||||
|------|-------|
|
||||
| Enable | Password prompt → `POST /totp/enable` → render QR URI + backup codes → 6-digit confirm → `POST /totp/confirm` → "Enabled" badge, `auth` user `totp_enabled:true` |
|
||||
| Flow | Steps |
|
||||
| ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| Enable | Password prompt → `POST /totp/enable` → render QR URI + backup codes → 6-digit confirm → `POST /totp/confirm` → "Enabled" badge, `auth` user `totp_enabled:true` |
|
||||
| Disable | Password confirm → `DELETE /totp`; a `403`/"required" is rewritten to "2FA is required by this server and cannot be disabled" (already the 403 rewrite in `buildTotpDisableView()`, `components/settings/AccountTab.ts`) |
|
||||
|
||||
**Target rule:** backup codes are shown exactly once, with an explicit "Save these
|
||||
@@ -85,11 +86,11 @@ now — you won't see them again" and a copy affordance.
|
||||
|
||||
### 2.4 Sessions & delete account
|
||||
|
||||
| Action | Reaction |
|
||||
|--------|----------|
|
||||
| List sessions | `GET /users/me/sessions`; show device/IP/last-used; current session marked |
|
||||
| Revoke a session | `DELETE /users/me/sessions/{id}`; optimistic removal + toast |
|
||||
| Delete account | **Modal with password confirm** (irreversible — stronger than a two-click); `DELETE /auth/account` → `clearAuth()` → connect page |
|
||||
| Action | Reaction |
|
||||
| ---------------- | --------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| List sessions | `GET /users/me/sessions`; show device/IP/last-used; current session marked |
|
||||
| Revoke a session | `DELETE /users/me/sessions/{id}`; optimistic removal + toast |
|
||||
| Delete account | **Modal with password confirm** (irreversible — stronger than a two-click); `DELETE /auth/account` → `clearAuth()` → connect page |
|
||||
|
||||
---
|
||||
|
||||
@@ -99,18 +100,19 @@ The desktop client exposes a **subset** of admin operations inline, gated by the
|
||||
actor's role. Everything here must (a) only appear for users who can perform it,
|
||||
and (b) confirm destructive actions.
|
||||
|
||||
| Operation | Affordance | REST | Reaction |
|
||||
|-----------|-----------|------|----------|
|
||||
| Change role | Member context menu → submenu | `PATCH /admin/api/users/{id}` `{role_id}` | Toast; `member_update` reflects live |
|
||||
| Kick | Member menu, two-click confirm | `DELETE /admin/api/users/{id}/sessions` | Toast "Kicked {user}"; `member_leave` |
|
||||
| Ban | Member menu, two-click confirm | `PATCH /admin/api/users/{id}` `{banned, ban_reason}` | Toast; `member_ban` removes them |
|
||||
| Create channel | Sidebar → modal | `POST /admin/api/channels` | Modal closes on success; `channel_create` |
|
||||
| Edit channel | Channel menu → modal | `PATCH /admin/api/channels/{id}` | `channel_update` |
|
||||
| Delete channel | Channel menu, two-click confirm | `DELETE /admin/api/channels/{id}` | `channel_delete`; redirect if active |
|
||||
| Reorder channels | Drag | `PATCH …/{id}` `{position}` per moved | Optimistic; roll back on failure |
|
||||
| Invites | Invite manager modal | `GET/POST/DELETE /invites` | List with masked codes, copy, revoke; empty state "No active invites" |
|
||||
| Operation | Affordance | REST | Reaction |
|
||||
| ---------------- | ------------------------------- | ---------------------------------------------------- | --------------------------------------------------------------------- |
|
||||
| Change role | Member context menu → submenu | `PATCH /admin/api/users/{id}` `{role_id}` | Toast; `member_update` reflects live |
|
||||
| Kick | Member menu, two-click confirm | `DELETE /admin/api/users/{id}/sessions` | Toast "Kicked {user}"; `member_leave` |
|
||||
| Ban | Member menu, two-click confirm | `PATCH /admin/api/users/{id}` `{banned, ban_reason}` | Toast; `member_ban` removes them |
|
||||
| Create channel | Sidebar → modal | `POST /admin/api/channels` | Modal closes on success; `channel_create` |
|
||||
| Edit channel | Channel menu → modal | `PATCH /admin/api/channels/{id}` | `channel_update` |
|
||||
| Delete channel | Channel menu, two-click confirm | `DELETE /admin/api/channels/{id}` | `channel_delete`; redirect if active |
|
||||
| Reorder channels | Drag | `PATCH …/{id}` `{position}` per moved | Optimistic; roll back on failure |
|
||||
| Invites | Invite manager modal | `GET/POST/DELETE /invites` | List with masked codes, copy, revoke; empty state "No active invites" |
|
||||
|
||||
**Target rules:**
|
||||
|
||||
- **✓ Destructive admin actions show an in-flight state (2026-08).**
|
||||
`withConfirmation` (`AdminActions.ts`) keeps the item in a pending
|
||||
label/class while the promise settles and ignores further clicks, so a slow
|
||||
@@ -122,9 +124,9 @@ and (b) confirm destructive actions.
|
||||
reason input plus a duration choice (`appendBanFlow()` in `components/AdminActions.ts`),
|
||||
and the menu passes both through
|
||||
(the `onBan` handler in `createSidebarMemberSection()`, `pages/main-page/SidebarMemberSection.ts` → `api.adminBanMember(userId, reason,
|
||||
durationHours)`), so temporary bans and stored reasons work from the client.
|
||||
durationHours)`), so temporary bans and stored reasons work from the client.
|
||||
|
||||
### 3.1 What is *not* in the client (by design)
|
||||
### 3.1 What is _not_ in the client (by design)
|
||||
|
||||
The full admin panel — user list, audit log, server settings, channel
|
||||
permissions, plugin management, backups, updates, first-run setup — is the
|
||||
@@ -177,13 +179,13 @@ sequenceDiagram
|
||||
end
|
||||
```
|
||||
|
||||
| State | Presentation |
|
||||
|-------|--------------|
|
||||
| checking | Silent (no UI until a result) |
|
||||
| available | Non-modal banner with version + Update Now / Later (already `createUpdateNotifier()`/`showBanner()`, `components/UpdateNotifier.ts`) |
|
||||
| downloading | Banner "Downloading update… N%" (or "… N.N MB" until Content-Length is known) |
|
||||
| applied | App relaunches automatically |
|
||||
| failed | "Update failed. Please try again later." + Dismiss |
|
||||
| State | Presentation |
|
||||
| ----------- | ------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| checking | Silent (no UI until a result) |
|
||||
| available | Non-modal banner with version + Update Now / Later (already `createUpdateNotifier()`/`showBanner()`, `components/UpdateNotifier.ts`) |
|
||||
| downloading | Banner "Downloading update… N%" (or "… N.N MB" until Content-Length is known) |
|
||||
| applied | App relaunches automatically |
|
||||
| failed | "Update failed. Please try again later." + Dismiss |
|
||||
|
||||
> **✅ Wired — download progress.** The Rust download callback
|
||||
> (`download_and_install_update` in `update_commands.rs`) accumulates received
|
||||
|
||||
@@ -17,7 +17,7 @@ Internally there are **two** FSMs:
|
||||
|
||||
- The **WS connection** FSM (`ws.ts`: `disconnected…connected`) — the socket.
|
||||
- The **voice session** FSM (`livekitSession.ts`: `idle | connecting |
|
||||
connected | reconnecting`) — the LiveKit room.
|
||||
connected | reconnecting`) — the LiveKit room.
|
||||
|
||||
Plus the user-facing booleans in `voice.store` (`localMuted`, `localDeafened`,
|
||||
`localCamera`, `localScreenshare`, `listenOnly`, `joinedAt`) and the per-user
|
||||
@@ -58,15 +58,16 @@ stateDiagram-v2
|
||||
failed --> idle: auto-leave + error
|
||||
```
|
||||
|
||||
| Status | Presentation | Notes |
|
||||
|--------|--------------|-------|
|
||||
| `joining` | Voice widget shows "Connecting…"; channel roster shows self pending | `handleVoiceToken` → `connectAndSetup` |
|
||||
| `securing` | "Securing connection…" indicator (lock, in-progress) | Non-key-holders block here until a room key arrives (10 s + 5 s retry, the "securing" key-exchange block in `connectAndSetup` (`lib/livekitSession.ts`) / `E2EEManager.setupKeyExchange` (`lib/livekitE2EE.ts`)) |
|
||||
| `connected` | "Voice connected · secured 🔒" + elapsed timer (from `joinedAt`) | E2EE active; per-user tiles live |
|
||||
| `reconnecting` | "Reconnecting voice…"; controls frozen, not torn down | Keypair regenerated for forward secrecy (`attemptAutoReconnect()` → `reannounceForReconnect()`, `lib/livekitSession.ts`) |
|
||||
| `failed` | Toast "Voice connection lost" / "Couldn't secure the call"; auto-leave | `onErrorCallback` fires |
|
||||
| Status | Presentation | Notes |
|
||||
| -------------- | ---------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `joining` | Voice widget shows "Connecting…"; channel roster shows self pending | `handleVoiceToken` → `connectAndSetup` |
|
||||
| `securing` | "Securing connection…" indicator (lock, in-progress) | Non-key-holders block here until a room key arrives (10 s + 5 s retry, the "securing" key-exchange block in `connectAndSetup` (`lib/livekitSession.ts`) / `E2EEManager.setupKeyExchange` (`lib/livekitE2EE.ts`)) |
|
||||
| `connected` | "Voice connected · secured 🔒" + elapsed timer (from `joinedAt`) | E2EE active; per-user tiles live |
|
||||
| `reconnecting` | "Reconnecting voice…"; controls frozen, not torn down | Keypair regenerated for forward secrecy (`attemptAutoReconnect()` → `reannounceForReconnect()`, `lib/livekitSession.ts`) |
|
||||
| `failed` | Toast "Voice connection lost" / "Couldn't secure the call"; auto-leave | `onErrorCallback` fires |
|
||||
|
||||
**Target rules:**
|
||||
|
||||
- The "connecting" vs "securing" distinction is user-visible: while a non-key-holder
|
||||
waits for the room key, show **securing**, not a generic spinner — an E2EE call
|
||||
that's still exchanging keys is not yet private.
|
||||
@@ -80,7 +81,7 @@ stateDiagram-v2
|
||||
> and `reconnecting` shows "Reconnecting voice…", neither showing the secured
|
||||
> badge. An E2EE-timeout still surfaces its `"e2ee_timeout"` toast and auto-leaves
|
||||
> (`livekitSession.ts` `connectAndSetup`). **Code vs. diagram note:** the client
|
||||
> actually runs the ECDH key exchange *before* `room.connect()`, so `securing`
|
||||
> actually runs the ECDH key exchange _before_ `room.connect()`, so `securing`
|
||||
> spans the key wait and the media connect; the state diagram below draws them in
|
||||
> the reverse order for readability. The distinction users see is unchanged:
|
||||
> non-key-holders sit in `securing` until a room key arrives.
|
||||
@@ -91,21 +92,21 @@ stateDiagram-v2
|
||||
|
||||
All four are optimistic with rollback; each also emits a WS control message.
|
||||
|
||||
| Control | Local state | WS message | Rollback |
|
||||
|---------|-------------|-----------|----------|
|
||||
| **Mute** | `localMuted` (`setLocalMuted`) — fully unpublishes the mic track | `voice_mute{muted}` | n/a (local-authoritative) |
|
||||
| **Deafen** | `localDeafened` + forces mute — unsubscribes remote *voice* audio only; screen-share/stream audio keeps playing (it has its own per-tile mute/volume) | `voice_deafen` + `voice_mute` | implies mute |
|
||||
| **Camera** | `localCamera` set optimistically, rolled back on device failure (`enableCamera()` in `lib/screenShare.ts`) | `voice_camera{enabled}` | revert on failure + toast |
|
||||
| **Screenshare** | `localScreenshare` optimistic, rollback on failure (`enableScreenshare()` in `lib/screenShare.ts`); rate-limited | `voice_screenshare{enabled}` | revert + toast |
|
||||
| Control | Local state | WS message | Rollback |
|
||||
| --------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------- | ------------------------- |
|
||||
| **Mute** | `localMuted` (`setLocalMuted`) — fully unpublishes the mic track | `voice_mute{muted}` | n/a (local-authoritative) |
|
||||
| **Deafen** | `localDeafened` + forces mute — unsubscribes remote _voice_ audio only; screen-share/stream audio keeps playing (it has its own per-tile mute/volume) | `voice_deafen` + `voice_mute` | implies mute |
|
||||
| **Camera** | `localCamera` set optimistically, rolled back on device failure (`enableCamera()` in `lib/screenShare.ts`) | `voice_camera{enabled}` | revert on failure + toast |
|
||||
| **Screenshare** | `localScreenshare` optimistic, rollback on failure (`enableScreenshare()` in `lib/screenShare.ts`); rate-limited | `voice_screenshare{enabled}` | revert + toast |
|
||||
|
||||
| Control state | Presentation |
|
||||
|---------------|--------------|
|
||||
| mic muted | Mic-slash icon on self tile + control bar |
|
||||
| deafened | Headphone-slash; implies muted styling |
|
||||
| listen-only | Badge "Listen only — no microphone" with a **Retry mic** affordance (`retryMicPermission`) |
|
||||
| camera on | Self video tile in the grid |
|
||||
| screenshare on | Screen tile; a stop-share affordance always visible |
|
||||
| speaking | Green ring on the speaking user's tile/avatar (from `voice_speakers` / ActiveSpeakers) |
|
||||
| Control state | Presentation |
|
||||
| -------------- | ------------------------------------------------------------------------------------------ |
|
||||
| mic muted | Mic-slash icon on self tile + control bar |
|
||||
| deafened | Headphone-slash; implies muted styling |
|
||||
| listen-only | Badge "Listen only — no microphone" with a **Retry mic** affordance (`retryMicPermission`) |
|
||||
| camera on | Self video tile in the grid |
|
||||
| screenshare on | Screen tile; a stop-share affordance always visible |
|
||||
| speaking | Green ring on the speaking user's tile/avatar (from `voice_speakers` / ActiveSpeakers) |
|
||||
|
||||
**Mic-permission failure** (`restoreLocalVoiceState`): on denied/absent mic, set
|
||||
`listenOnly` and surface the specific reason ("Microphone permission denied" /
|
||||
@@ -120,12 +121,12 @@ control a permanent part of the listen-only badge.
|
||||
PTT is a Rust key-poller (`ptt.rs`, 20 ms) emitting `ptt-state{pressed}` →
|
||||
`setMuted(!pressed)` only while in a channel (the `ptt-state` listener inside `initPtt()`, `lib/ptt.ts`). **Target UX:**
|
||||
|
||||
| State | Presentation |
|
||||
|-------|--------------|
|
||||
| PTT bound, released | Muted; hint "Hold {key} to talk" |
|
||||
| PTT pressed | Unmuted + speaking ring |
|
||||
| binding a key | Keybinds tab: "Press a key…" (10 s capture window, `ptt_listen_for_key`); reject text keys with "Pick a non-text key" |
|
||||
| PTT thread error | Toast "Push-to-talk stopped unexpectedly" on `ptt-error`, offer re-enable |
|
||||
| State | Presentation |
|
||||
| ------------------- | --------------------------------------------------------------------------------------------------------------------- |
|
||||
| PTT bound, released | Muted; hint "Hold {key} to talk" |
|
||||
| PTT pressed | Unmuted + speaking ring |
|
||||
| binding a key | Keybinds tab: "Press a key…" (10 s capture window, `ptt_listen_for_key`); reject text keys with "Pick a non-text key" |
|
||||
| PTT thread error | Toast "Push-to-talk stopped unexpectedly" on `ptt-error`, offer re-enable |
|
||||
|
||||
---
|
||||
|
||||
@@ -134,12 +135,12 @@ PTT is a Rust key-poller (`ptt.rs`, 20 ms) emitting `ptt-state{pressed}` →
|
||||
The channel's voice roster renders from `voiceUsers`. Each participant tile
|
||||
reflects their `speaking/muted/deafened/camera/screenshare`. **Target:**
|
||||
|
||||
| Signal | Tile reaction |
|
||||
|--------|---------------|
|
||||
| `voice_state` | Add/update the participant with their flags |
|
||||
| `voice_leave` | Remove the tile; if it's us (kick/disconnect), clear local voice state (already the `voice_leave` handler in `wireDispatcher()`, `lib/dispatcher.ts`) |
|
||||
| `voice_speakers` | Speaking ring on the listed users |
|
||||
| key-holder change | Invisible to users (re-election is automatic on leave); no UI churn |
|
||||
| Signal | Tile reaction |
|
||||
| ----------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `voice_state` | Add/update the participant with their flags |
|
||||
| `voice_leave` | Remove the tile; if it's us (kick/disconnect), clear local voice state (already the `voice_leave` handler in `wireDispatcher()`, `lib/dispatcher.ts`) |
|
||||
| `voice_speakers` | Speaking ring on the listed users |
|
||||
| key-holder change | Invisible to users (re-election is automatic on leave); no UI churn |
|
||||
|
||||
Per-user volume is adjustable and persisted (`userVolume_{id}` in the Rust store).
|
||||
|
||||
@@ -161,11 +162,11 @@ Peer identity state lives in `voice.store` (per-participant
|
||||
`lib/livekitE2EE.ts` as announces are verified against the pinned identity
|
||||
keys (`lib/identity.ts`).
|
||||
|
||||
| State | Roster badge (`verifyPresentation()`, `components/ChannelSidebar.ts`) | Interaction |
|
||||
|-------|------------------------------------------|-------------|
|
||||
| `verified` | Green shield; title "Identity verified · Safety number: {n}" | none needed |
|
||||
| `unverified` | Neutral shield; no pinned key yet | none — pins on first verified announce |
|
||||
| `mismatch` | Red shield-alert; title "Identity key changed — click to review and re-pin" | Click → blocking identity-mismatch modal |
|
||||
| State | Roster badge (`verifyPresentation()`, `components/ChannelSidebar.ts`) | Interaction |
|
||||
| ------------ | --------------------------------------------------------------------------- | ---------------------------------------- |
|
||||
| `verified` | Green shield; title "Identity verified · Safety number: {n}" | none needed |
|
||||
| `unverified` | Neutral shield; no pinned key yet | none — pins on first verified announce |
|
||||
| `mismatch` | Red shield-alert; title "Identity key changed — click to review and re-pin" | Click → blocking identity-mismatch modal |
|
||||
|
||||
The mismatch modal (`createIdentityMismatchModal()`, `components/CertMismatchModal.ts`;
|
||||
opened from `openIdentityMismatchModal()` in `components/ChannelSidebar.ts`) shows the **new key's fingerprint** so
|
||||
@@ -192,16 +193,16 @@ trust action entirely (a blind accept is refused).
|
||||
## 9. DM calls (ring)
|
||||
|
||||
DM voice is the same voice machinery on the DM's voice channel, plus a ring
|
||||
layer (no server-side call state — presence in the DM voice channel *is* the
|
||||
layer (no server-side call state — presence in the DM voice channel _is_ the
|
||||
call):
|
||||
|
||||
| Event | Reaction |
|
||||
|-------|----------|
|
||||
| Outgoing: user clicks Call | `call_ring` sent (rate-limited 1/3 s server-side); caller joins the DM voice channel |
|
||||
| Incoming: `call_incoming` | `components/IncomingCallBanner.ts` banner + ring chime (`lib/notifications.ts`), driven by the `lib/call-ring.ts` state machine (30 s auto-timeout) |
|
||||
| Accept | Join the DM voice channel; banner clears |
|
||||
| Decline | `call_decline` sent → other participants' ringing stops via `call_declined` |
|
||||
| Timeout / caller leaves | Banner clears silently |
|
||||
| Event | Reaction |
|
||||
| -------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Outgoing: user clicks Call | `call_ring` sent (rate-limited 1/3 s server-side); caller joins the DM voice channel |
|
||||
| Incoming: `call_incoming` | `components/IncomingCallBanner.ts` banner + ring chime (`lib/notifications.ts`), driven by the `lib/call-ring.ts` state machine (30 s auto-timeout) |
|
||||
| Accept | Join the DM voice channel; banner clears |
|
||||
| Decline | `call_decline` sent → other participants' ringing stops via `call_declined` |
|
||||
| Timeout / caller leaves | Banner clears silently |
|
||||
|
||||
`call_incoming` / `call_declined` are page-scoped listeners in `MainPage.ts`,
|
||||
not dispatcher handlers (see [README §4](README.md)).
|
||||
|
||||
@@ -74,6 +74,6 @@ and surface a blocking mismatch modal if it later changes (see
|
||||
[ux/voice-and-e2ee.md](ux/voice-and-e2ee.md)).
|
||||
|
||||
**Source of truth:** `Server/ws/voice_e2ee.go`, `Server/ws/livekit.go`,
|
||||
`Client/tauri-client/src/lib/livekitSession.ts`,
|
||||
`Client/tauri-client/src/lib/e2eeCrypto.ts`,
|
||||
`Client/tauri-client/src-tauri/src/livekit_proxy.rs`.
|
||||
`Client/src/lib/livekitSession.ts`,
|
||||
`Client/src/lib/e2eeCrypto.ts`,
|
||||
`Client/src-tauri/src/livekit_proxy.rs`.
|
||||
|
||||
@@ -7,10 +7,10 @@ implements the real-time engine: a single `Hub` owning all client connections,
|
||||
a topic-based pub/sub, a monotonic sequence counter, a 3-tier reconnect replay
|
||||
pipeline, and a single typed (V2) command dispatch.
|
||||
|
||||
Message-type constants are **generated**: `docs/protocol-schema.json` is the
|
||||
single source of truth, and `Server/scripts/genprotocol` emits both
|
||||
Message-type constants are **generated**: `protocol/schema.json` is the
|
||||
single source of truth, and `Server/cmd/genprotocol` emits both
|
||||
`Server/ws/message_types.go` and
|
||||
`Client/tauri-client/src/lib/protocolTypes.ts` from it
|
||||
`Client/src/lib/protocolTypes.ts` from it
|
||||
(`make protocol-generate`; CI fails on drift via `make protocol-verify`).
|
||||
The one exception is the plugin command family (`chat_command`,
|
||||
`command_reply`, `plugin_broadcast`), declared by hand in
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
# OwnCord full repository-health audit
|
||||
|
||||
**Audited:** 2026-08-23
|
||||
**Audited head:** `5cc0888964e26276d1aca145e83270a2c1b9febd` (`dev`)
|
||||
**Conclusion:** strong server foundation; not beta-ready
|
||||
|
||||
## Executive status
|
||||
|
||||
OwnCord is in a promising but pre-beta state. The server is the stronger half:
|
||||
its principal build, race, deadlock, tagged-test, and vet gates pass. The
|
||||
client has a substantial desktop foundation, but its required unit-coverage
|
||||
gate is red and its full Playwright process does not terminate. The approved
|
||||
browser/PWA/phone/tablet product is mostly still a planned workstream.
|
||||
|
||||
| Area | Status | Evidence-based conclusion |
|
||||
| ---------------------- | -------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| Server core | Strong / amber | Builds and concurrency-oriented tests pass; coverage is 74.6%; architecture, security boundaries, capacity, and operations still need work. |
|
||||
| Desktop client | Amber-red | TypeScript, build, static checks, Rust checks, and browser smoke pass; 5,255 unit tests pass but 2 fail, and Playwright does not exit cleanly. |
|
||||
| Browser/PWA/mobile | Red | No production browser target, optional server-hosted bundle, PWA lifecycle, Web Push, or beta-quality mobile navigation is complete. |
|
||||
| Security/privacy | Red / pending | Private current-head security evidence contains unresolved work. The restarted independent deep scan was not sealed because it produced no manifest or result before the audit budget limit. |
|
||||
| CI/release/platforms | Amber-red | Signing, checksums, source snapshots, and cold-boot foundations are good; exact-SHA dev evidence, ARM64 coverage, multi-architecture Docker, and native release qualification remain incomplete. |
|
||||
| Repository/community | Amber | Server/client separation is understandable; a focused client-layout and contributor-experience migration is justified. |
|
||||
| Overall beta readiness | Red | Do not publish the public beta from this head. |
|
||||
|
||||
## Validation evidence
|
||||
|
||||
### Server
|
||||
|
||||
- Default, OpenTelemetry, Wazero-tagged WASM runtime, and combined builds pass.
|
||||
- `go vet`, the full race suite, deadlock tests, and tag-gated tests pass.
|
||||
- CI-style aggregate coverage is 74.6%; there are many tests and fuzz targets,
|
||||
but no benchmark baseline for the highest-risk hot paths.
|
||||
- Docker validation was unavailable locally because no Docker daemon was
|
||||
attached. Local `golangci-lint` could not load because its binary and module
|
||||
toolchains differed; this needs matched-tool or CI evidence.
|
||||
- Structural debt remains in large hub/serve/lifecycle files and numerous
|
||||
direct database call sites.
|
||||
|
||||
### Client
|
||||
|
||||
- Application and E2E typechecks, ESLint, Prettier, Knip, production npm audit,
|
||||
Vite build, Rust Clippy, and 115 Rust tests pass.
|
||||
- Vitest coverage is red: 5,255 passed and 2 failed in the message-list and
|
||||
noise-suppression restart contracts.
|
||||
- Three direct Chromium browser tests pass, but this is a narrow API harness,
|
||||
not a production browser client.
|
||||
- The 293-test Playwright journey reaches its test activity but does not exit;
|
||||
an isolated five-test voice-widget run also hangs.
|
||||
- Oxlint exits successfully but reports 471 warnings. The largest lazy feature
|
||||
output is approximately 2.0 MB minified / 1.345 MB gzip, with additional
|
||||
oversized-chunk and dynamic-import warnings.
|
||||
|
||||
### Repository and release
|
||||
|
||||
- The audited SHA has no GitHub Actions run, so local evidence is not an
|
||||
exact-SHA integration qualification.
|
||||
- The release workflow has good signing, checksum, source-snapshot, and
|
||||
server-cold-boot foundations, but the approved Windows/Linux ARM64 and
|
||||
multi-architecture Docker matrix is not complete.
|
||||
- Node guidance is inconsistent (`.nvmrc` 20 versus CI 24); the repository
|
||||
needs one enforced version source.
|
||||
- Generated graph/ledger artifacts, protocol ownership, tool discovery, hooks,
|
||||
issue intake, and externally triggered automation all need explicit policy.
|
||||
|
||||
## Product gaps that block beta
|
||||
|
||||
The approved requirements require server-hosted browser support disabled by
|
||||
default, shared desktop/browser contracts, PWA installation, phones/tablets,
|
||||
opt-in per-server Web Push, N/N-1/N-2 protocol epochs, recovery kits, session
|
||||
alerts and sign-out-everywhere, deletion that survives restore, configurable
|
||||
retention, Message Requests, moderation reports/appeals, NSFW no-fetch-before-
|
||||
consent, translation-ready English text, and the full release architecture
|
||||
matrix. Most of these are absent or only partial today.
|
||||
|
||||
## Repository structure decision
|
||||
|
||||
A targeted migration is justified, not a rewrite:
|
||||
|
||||
1. Flatten `Client/tauri-client/` to `Client/` in adjacent pure-move and
|
||||
mechanical-path-rewrite commits.
|
||||
2. Keep one shared UI and add typed desktop/browser platform contracts later in
|
||||
B7, after server-first contracts and services are stable.
|
||||
3. Keep `Server/` and its package tree stable.
|
||||
4. Make protocol schema, generated artifacts, tools, hooks, and contributor
|
||||
commands explicit and reproducible.
|
||||
|
||||
## Deployment constraints that must be designed into beta
|
||||
|
||||
Public domains and eligible stable public IPs can use built-in ACME. Private
|
||||
or reserved LAN addresses cannot receive public-CA certificates and require a
|
||||
server-local CA plus one-time trust installation on each browser device. Web
|
||||
service workers, media capture, screen sharing, and Push API behavior require a
|
||||
secure context in normal browser use. See the [Let’s Encrypt IP certificate
|
||||
guidance](https://letsencrypt.org/2026/01/15/6day-and-ip-general-availability),
|
||||
[ACME challenges](https://letsencrypt.org/docs/challenge-types/), and [W3C
|
||||
secure-context standards](https://www.w3.org/TR/secure-contexts/).
|
||||
|
||||
Fully offline browser use can work after local trust onboarding, but closed-app
|
||||
Web Push cannot be treated as an offline capability. CGNAT, blocked ports, and
|
||||
changing raw IP origins remain operator/network limitations rather than bugs
|
||||
OwnCord can hide.
|
||||
|
||||
## Phased route
|
||||
|
||||
The approved execution sequence is B0–B10:
|
||||
|
||||
`B0 truth → B1 repository foundation → B2 protocol/trust → B3 server guardrails → B4 identity/privacy → B5 community/moderation → B6 deployment/capacity → B7 shared desktop platform → B8 browser/PWA/mobile → B9 unified UX/accessibility → B10 qualification/public release`
|
||||
|
||||
No phase closes on elapsed time. Each phase requires exact-SHA evidence, and
|
||||
B10 additionally requires a complete platform/deployment matrix, migration,
|
||||
restore, rollback, security, accessibility, capacity, and release scorecard.
|
||||
|
||||
## Audit artifacts
|
||||
|
||||
- [Beta product requirements](plans/beta-product-requirements-2026-08-23.md)
|
||||
- [Exhaustive issue register](plans/repo-health-issue-register-2026-08-23.md)
|
||||
- [Server-first roadmap](plans/repo-health-roadmap-2026-08-23.md)
|
||||
- [Requirement traceability](plans/beta-requirements-traceability-2026-08-23.md)
|
||||
- [Repository-layout audit](audit-2026-08-23-repository-layout.md)
|
||||
|
||||
The detailed reports under `docs/security-findings/` are intentionally
|
||||
untracked/private and must not be committed to a public repository before
|
||||
coordinated remediation.
|
||||
|
||||
## Recommended first implementation slice
|
||||
|
||||
Begin B0 only: repair the two failing unit contracts, make Playwright terminate
|
||||
reliably, obtain matched lint/Docker evidence, reconcile private security
|
||||
findings, and run the full exact-SHA matrix. Then perform the isolated B1
|
||||
repository migration before implementing new client features.
|
||||
@@ -0,0 +1,175 @@
|
||||
# OwnCord repository-layout and contributor-experience audit
|
||||
|
||||
**Audited:** 2026-08-23
|
||||
**Audited head:** `5cc0888964e26276d1aca145e83270a2c1b9febd` (`dev`)
|
||||
**Decision:** a targeted, isolated layout phase is justified; a wholesale
|
||||
repository or server rewrite is not
|
||||
**Product input:**
|
||||
[beta-product-requirements-2026-08-23.md](plans/beta-product-requirements-2026-08-23.md)
|
||||
**Canonical work register:**
|
||||
[repo-health-issue-register-2026-08-23.md](plans/repo-health-issue-register-2026-08-23.md)
|
||||
|
||||
## Executive verdict
|
||||
|
||||
OwnCord's top-level server/client separation is understandable and the Go
|
||||
server's package layout is generally healthy. The repository does not need a
|
||||
monorepo rewrite, a `Server/` rename, or broad movement of historical documents.
|
||||
|
||||
A smaller structural phase is worthwhile before beta feature work because the
|
||||
new browser/PWA requirement exposes a real boundary problem: the only client is
|
||||
nested and named as Tauri-specific, while the shared frontend directly imports
|
||||
native Tauri APIs in at least 20 production files. Contributor entry points,
|
||||
branch automation, release coverage, generated artifacts, and active-document
|
||||
navigation also need consolidation.
|
||||
|
||||
The structural work must be mechanical and independently reversible. File
|
||||
moves and path rewrites must not contain functional changes, and the later
|
||||
platform-boundary extraction must preserve behavior behind tests before adding
|
||||
the browser implementation.
|
||||
|
||||
Priorities follow the health register: P0 is a red required gate, P1 must close
|
||||
before beta, and P2 is scheduled architecture, quality, or operational debt.
|
||||
|
||||
## What should remain stable
|
||||
|
||||
- Keep `Server/` and its existing domain packages. Later hotspot extraction is
|
||||
architecture work, not repository layout work.
|
||||
- Keep existing release asset names and updater contracts so alpha-to-beta
|
||||
upgrades do not break.
|
||||
- Keep one shared client UI, store, protocol, and domain implementation. Do not
|
||||
fork a second web application.
|
||||
- Keep build-required generated sources committed: sqlc output, generated
|
||||
protocol types, Tauri-generated bindings, and runtime assets required by a
|
||||
release build.
|
||||
- Keep historical audits and plans at their existing paths. Index their status
|
||||
instead of moving them and breaking references.
|
||||
- Keep the canonical findings ledger until a deliberate tracker migration
|
||||
provides equivalent history and validation.
|
||||
|
||||
## Recommended target
|
||||
|
||||
```text
|
||||
Client/
|
||||
package.json
|
||||
src/
|
||||
platform/
|
||||
contracts/
|
||||
browser/
|
||||
desktop/
|
||||
src-tauri/
|
||||
tests/
|
||||
Server/
|
||||
protocol/
|
||||
schema.json
|
||||
deploy/
|
||||
docs/
|
||||
README.md
|
||||
plans/
|
||||
tools/
|
||||
```
|
||||
|
||||
The current `Client/tauri-client/` content should be flattened into `Client/`
|
||||
as two adjacent non-functional commits: first pure file moves, then mechanical
|
||||
rewrites of active paths. `Client/` has no other tracked child, and the current
|
||||
name incorrectly implies that browser/PWA support should become a separate
|
||||
application. The capitalized `Client/` and `Server/` names may remain: changing
|
||||
both for style alone would create widespread path churn without improving a
|
||||
runtime or contributor boundary.
|
||||
|
||||
The protocol schema is executable cross-component source and should move from
|
||||
`docs/protocol-schema.json` to a small root `protocol/` boundary. Documentation
|
||||
continues to explain the contract, while root tooling owns generation for both
|
||||
consumers.
|
||||
|
||||
## Findings
|
||||
|
||||
| ID | Pri | Finding | Required disposition |
|
||||
| ----- | --: | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| RL-01 | P1 | All tracked client content is under the redundant `Client/tauri-client/` level, and active automation/documentation hard-code that path. The name is misleading now that browser/PWA is approved. | Flatten to `Client/` in two adjacent non-functional commits—pure moves, then active-path rewrites—and leave historical evidence untouched. |
|
||||
| RL-02 | P1 | At least 20 frontend files directly import `@tauri-apps/*`; API, WebSocket, credentials, profiles, notifications, media, LiveKit, updates, logs, and window state do not have a browser-neutral platform seam. | Record the target boundary during the layout phase, then introduce typed `platform/contracts`, `platform/desktop`, and `platform/browser` ownership in client phase B7. Add contract tests that run against both adapters. |
|
||||
| RL-03 | P1 | The Vite configuration contains Tauri-specific HTML transformation, and there is no independent browser/PWA production build contract. | In client phase B7, split shared build configuration from target adapters and add explicit `build:web` and `build:desktop` gates using one application source tree. |
|
||||
| RL-04 | P2 | Root `package.json` exposes release and hook commands but no discoverable bootstrap, format, generated-code, server, client, or full verification entry points. | Add a cross-platform root command facade. Decide workspace consolidation from measured install/lockfile behavior rather than requiring Node for ordinary Go-only work. |
|
||||
| RL-05 | P2 | Three JavaScript package/lock roots are maintained separately, while dependency automation does not cover all of them. | Either adopt a documented npm workspace or add complete per-package automation; retain component-local commands and deterministic lockfile installs. |
|
||||
| RL-06 | P2 | Tracked `graphify-out/` is about 20 MB, led by a roughly 19.3 MB generated JSON graph. Portable regeneration was not demonstrated in this audit environment because the available launcher failed before a query could run. Repeated refreshes grow normal Git history. | Stop tracking large graph payloads after providing a portable regeneration command and CI/release artifact. Do not rewrite published history. A compact architecture report may remain if it is mechanically verified. |
|
||||
| RL-07 | P2 | `.superpowers/FINDINGS.md` duplicates the canonical ledger as a large generated rendering. The canonical JSON ledger remains necessary today. | Keep the authoritative JSON ledger, remove the tracked human rendering after a drift check exists, and generate it on demand or as a downloadable CI artifact. |
|
||||
| RL-08 | P2 | A prebuilt example `hello.wasm` is committed for a plugin system that is experimental and disabled, without a source-drift verification gate. | Keep its source, stop tracking the prebuilt example, and compile/verify it deterministically in CI or release checks without making an API compatibility promise. |
|
||||
| RL-09 | P2 | Cross-component protocol source lives under `docs/`, while a server-owned generator writes both Go and TypeScript consumers. | Move the schema/generator entry point to the root protocol/tool boundary and verify both generated outputs from one command. |
|
||||
| RL-10 | P1 | `Server/scripts/seed.go` is included in broad Go package discovery and its initialization creates a runtime data directory during test discovery. | Move executable tools under a conventional `cmd/` or tool package and remove import/test-time filesystem side effects. |
|
||||
| RL-11 | P2 | A client “unit” test reads server admin HTML directly, hiding a cross-component contract inside the wrong ownership and test tier. | Move the invariant to the owning server test or a clearly named root contract/system-test tier. Inventory siblings before moving only one file. |
|
||||
| RL-12 | P2 | There is no canonical docs landing page that distinguishes active guidance, reference material, historical audits, and superseded plans. Active files also disagree about Node and branch policy. | Add `docs/README.md` and a plan index; mark status without relocating historical evidence. Generate or check high-value version/branch/platform facts. |
|
||||
| RL-13 | P2 | The Go module declares `github.com/owncord/server`, while the public repository is `github.com/J3vb/OwnCord`. | Align the module to `github.com/J3vb/OwnCord/Server` in an isolated mechanical change and verify every import, generator, build tag, source archive, and downstream instruction. |
|
||||
| RL-14 | P0 | `dev` can receive direct commits without an exact-SHA CI run because push CI covers `main`, while `dev` relies on PR or manual events. | Add protected PR-only integration or run the complete blocking matrix for every `dev` push. This duplicates G-03 and must map to one canonical issue. |
|
||||
| RL-15 | P1 | Release automation does not cover the approved platform matrix: Windows ARM64 client/server, Linux ARM64 server, and multi-architecture Docker publication are missing. | Add build, package, install/boot smoke, signing, manifest, and update checks for all BPR-010/BPR-011 targets. |
|
||||
| RL-16 | P1 | A version tag can start publication without proving that the exact tagged commit completed the full beta gate. | Couple release publication to green exact-SHA evidence and a protected release approval; retain current version, signature, checksum, cold-boot, and source-snapshot strengths. |
|
||||
| RL-17 | P1 | Client `.nvmrc` and active contributor docs say Node 20 while CI/release use Node 24; package metadata does not enforce the intended Node/npm versions. | Establish one Node 24 source of truth read by local setup, packages, CI, release, and documentation. This duplicates C-01 and must map to one canonical issue. |
|
||||
| RL-18 | P1 | Dependency automation omits root tooling, `tools/mcp-introspect`, and Docker; runtime/build containers use mutable tags. | Cover every dependency root, pin or automatically review container digests, and produce signed SBOM/provenance evidence for releases. |
|
||||
| RL-19 | P2 | Formatting/lint coverage omits material Markdown, YAML, JSON, CSS, Rust formatting, repository-wide Go formatting, shell scripts, and workflow syntax. There is no root `.editorconfig`. | Add fast, cross-platform format and repository-lint gates with generated/vendor exclusions and one editor baseline. |
|
||||
| RL-20 | P2 | Committed hooks are POSIX shell and invoke `make`, but Windows is an official contributor platform without those prerequisites being explicit. | Make hooks thin optional wrappers around cross-platform root commands and document any Git Bash dependency until removed. |
|
||||
| RL-21 | P2 | Community intake does not match the approved model: feature requests still become Issues, and bug forms omit browser/PWA, ARM64, deployment mode, and architecture detail. | Route ideas/feedback to Discussions and modernize bug forms, PR guidance, contributor entry points, and support links. |
|
||||
| RL-22 | P1 | Authorization for externally triggered paid repository automation is not sufficiently constrained. | Limit execution to explicitly trusted maintainers, retain least-privilege permissions, add cost-abuse regression tests, and keep the concrete pre-fix mechanism private. |
|
||||
|
||||
## Strong foundations to preserve
|
||||
|
||||
- CI already exercises Go build tags, race/deadlock behavior, TypeScript/Rust
|
||||
checks, a limited browser harness, mocked-desktop Playwright, Docker smoke,
|
||||
vulnerability checks, and coverage artifacts. These are foundations, not
|
||||
evidence that a production browser/PWA client exists.
|
||||
- GitHub Actions are SHA-pinned and generally use narrow permissions.
|
||||
- Release automation already checks version agreement, cold-boots the built
|
||||
server, signs metadata, verifies signatures, generates checksums, and
|
||||
publishes an AGPL source snapshot.
|
||||
- `.gitattributes` enforces stable line endings and ignore files cover most
|
||||
ordinary build/runtime output.
|
||||
- Component documentation is detailed; the primary problem is discoverability
|
||||
and stale duplicated facts, not absence of technical knowledge.
|
||||
|
||||
## Isolated implementation sequence
|
||||
|
||||
1. Restore the two currently red client test contracts, make the full and
|
||||
isolated Playwright runs terminate after completion, and establish the exact
|
||||
baseline checks. Structural validation must start green.
|
||||
2. Add the docs/plan index and root cross-platform command facade, then align
|
||||
Node 24 and branch/CI truth.
|
||||
3. Relocate or stop tracking non-product generated artifacts after their
|
||||
replacement generation/artifact paths are proven.
|
||||
4. Flatten `Client/tauri-client/` to `Client/` as two adjacent commits in one
|
||||
PR: pure file moves, then mechanical active-path rewrites. Neither commit
|
||||
changes behavior.
|
||||
5. Record the browser-neutral contract map and owners. Implement the adapters,
|
||||
native extraction, and web production build later in client phase B7 after
|
||||
the server-first phases close.
|
||||
6. Move protocol ownership to the root and verify both generated consumers.
|
||||
7. Reclassify cross-stack tests and executable tools; remove test-time
|
||||
filesystem side effects.
|
||||
8. Correct platform/release/dependency automation independently of the moves.
|
||||
9. Run the full server, client, Rust, browser, generated-code, Docker, and
|
||||
release-path matrix on the exact resulting SHA.
|
||||
|
||||
## Exit gate
|
||||
|
||||
- a fresh Windows or Linux contributor can find one setup path and run scoped
|
||||
or full checks without guessing directories;
|
||||
- existing desktop behavior and release/update names are unchanged;
|
||||
- the browser-neutral contract design, owners, and B7 validation plan are
|
||||
approved without prematurely refactoring client runtime behavior;
|
||||
- every supported release architecture has an owned automation path;
|
||||
- every active commit on `dev` has exact-SHA CI evidence;
|
||||
- generated sources and large analysis artifacts have explicit, reproducible,
|
||||
separately verified ownership;
|
||||
- no active documentation contradicts branch, Node, platform, support, plugin,
|
||||
or beta-scope policy;
|
||||
- the complete baseline is green and the worktree contains no accidental build
|
||||
or generated output.
|
||||
|
||||
## Migration risks and controls
|
||||
|
||||
| Risk | Control |
|
||||
| --------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Hard-coded client paths are missed | Inventory active references before the move; run workflow, hook, generator, docs-link, package, and release-manifest checks afterward. |
|
||||
| Browser and desktop behavior diverge | One shared application plus tested platform contracts; no copied feature implementations. |
|
||||
| Root tooling makes Node mandatory for server contributors | Keep direct Go commands supported and make the root facade a convenience/orchestration layer. |
|
||||
| Release or updater compatibility breaks | Do not rename binaries/assets; smoke installation, update manifests, signatures, and in-place upgrades. |
|
||||
| Rename obscures functional review | Pure-move and mechanical-path-rewrite commits are followed by separately reviewed adapter changes. |
|
||||
| Generated analysis output disappears without replacement | Prove local generation and downloadable CI artifacts before untracking; retain published Git history. |
|
||||
|
||||
No production source was moved or changed during this audit.
|
||||
+268
-81
@@ -6,87 +6,116 @@ How to set up the development environment and contribute to OwnCord.
|
||||
|
||||
### Prerequisites
|
||||
|
||||
| Platform | Server | Client |
|
||||
|----------|--------|--------|
|
||||
| Windows 10+ x64 | ✅ | ✅ |
|
||||
| Linux x64 | ✅ | ✅ |
|
||||
| Linux ARM64 | ✅ | ✅ (CI only) |
|
||||
| Platform | Server | Client |
|
||||
| --------------- | ------ | ------------ |
|
||||
| Windows 10+ x64 | ✅ | ✅ |
|
||||
| Linux x64 | ✅ | ✅ |
|
||||
| Linux ARM64 | ✅ | ✅ (CI only) |
|
||||
|
||||
- **Go 1.26+** (server)
|
||||
- **Node.js 20+** (client)
|
||||
- **Node.js 24+** (client) — pinned in `Client/.nvmrc`; `engine-strict` makes a
|
||||
wrong major a hard failure, not a warning
|
||||
- **Rust / Cargo** (Tauri client — not needed for server-only work)
|
||||
- **Docker + Compose v2** (optional — alternative to building the server locally)
|
||||
|
||||
### Available Commands
|
||||
|
||||
#### Root facade — one entry point
|
||||
|
||||
From the repository root. These orchestrate the per-stack commands below; they
|
||||
are a convenience, not a replacement. Nothing here needs `make`, and everything
|
||||
works the same on Windows, macOS and Linux.
|
||||
|
||||
| Command | Description |
|
||||
| ----------------------------- | ------------------------------------------------------------------------------------------------- |
|
||||
| `npm run bootstrap` | `npm ci` in all three package roots |
|
||||
| `npm run check` | Everything CI gates on: server, client, Rust |
|
||||
| `npm run check:server` | Server only — build variants, vet, race, deadlock, lint, generated-output drift |
|
||||
| `npm run check:client` | Client only — typecheck, lint, format, unit + integration tests |
|
||||
| `npm run check:rust` | Tauri backend — `cargo test --lib` and clippy |
|
||||
| `npm run check:docs` | Fail if a watched document contradicts the ledger's finding counts, or the ledger fails to render |
|
||||
| `npm run format` | Prettier over the client, `gofmt -w` over the server |
|
||||
| `npm run generate` | Regenerate protocol constants and the sqlc query layer |
|
||||
| `npm run release:preflight` | `check` plus a client production build |
|
||||
| `node scripts/run.mjs --list` | Print the exact command every task runs, and where |
|
||||
|
||||
Tools CI installs but you may not have — `golangci-lint`, `sqlc` — are skipped
|
||||
with a printed reason rather than failing the run.
|
||||
|
||||
**Working on the server only? You never need Node.** The facade prints each
|
||||
command it runs and the directory it runs it in; those are the commands in the
|
||||
next section, and using them directly is equally correct.
|
||||
|
||||
#### Server (Go)
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `go build -o chatserver.exe -ldflags "-s -w" .` | Build server binary (Windows) |
|
||||
| `CGO_ENABLED=0 go build -o chatserver -ldflags "-s -w" .` | Build server binary (Linux) |
|
||||
| `go build -tags otel .` | Build with OpenTelemetry SDK (requires `go get` first — see Phase B) |
|
||||
| `go build -tags wazero .` | Build with Wazero plugin runtime (requires `go get` first — see Phase C) |
|
||||
| `go test ./...` | Run all server tests |
|
||||
| `go test ./... -cover` | Run server tests with coverage |
|
||||
| `go test -race ./...` | Run server tests with race detection |
|
||||
| Command | Description |
|
||||
| --------------------------------------------------------- | ------------------------------------------------------------------------ |
|
||||
| `go build -o chatserver.exe -ldflags "-s -w" .` | Build server binary (Windows) |
|
||||
| `CGO_ENABLED=0 go build -o chatserver -ldflags "-s -w" .` | Build server binary (Linux) |
|
||||
| `go build -tags otel .` | Build with OpenTelemetry SDK (requires `go get` first — see Phase B) |
|
||||
| `go build -tags wazero .` | Build with Wazero plugin runtime (requires `go get` first — see Phase C) |
|
||||
| `go test ./...` | Run all server tests |
|
||||
| `go test ./... -cover` | Run server tests with coverage |
|
||||
| `go test -race ./...` | Run server tests with race detection |
|
||||
|
||||
**Make targets** (run from `Server/`):
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `make test` | Run the test suite the way CI does (`-race`, 20 min timeout) |
|
||||
| `make test-deadlock` | Run the deadlock-detection pass CI also runs (`-tags deadlock`) |
|
||||
| `make cover` | Per-package coverage (what CI uploads) + a function summary |
|
||||
| `make cover-all` | Cross-package coverage — the honest number (also lists 0.0% functions) |
|
||||
| `make sqlc-install` | Install the pinned sqlc version into `$GOBIN` |
|
||||
| `make sqlc-generate` | Regenerate the type-safe Go query layer (`db/dbgen/`, SQLite engine) |
|
||||
| `make sqlc-verify` | Fail if the committed `dbgen` output is stale (used by CI) |
|
||||
| `make protocol-generate` | Regenerate the WS message-type constants (Go + TS) from `docs/protocol-schema.json` |
|
||||
| `make protocol-verify` | Fail if the committed protocol constants are stale (used by CI) |
|
||||
| `make otel-up` | Start Jaeger (traces) + Prometheus (metrics) via Docker for local OTel development |
|
||||
| `make otel-down` | Stop and remove the OTel dev containers |
|
||||
| Command | Description |
|
||||
| ------------------------ | ---------------------------------------------------------------------------------- |
|
||||
| `make test` | Run the test suite the way CI does (`-race`, 20 min timeout) |
|
||||
| `make test-deadlock` | Run the deadlock-detection pass CI also runs (`-tags deadlock`) |
|
||||
| `make cover` | Per-package coverage (what CI uploads) + a function summary |
|
||||
| `make cover-all` | Cross-package coverage — the honest number (also lists 0.0% functions) |
|
||||
| `make sqlc-install` | Install the pinned sqlc version into `$GOBIN` |
|
||||
| `make sqlc-generate` | Regenerate the type-safe Go query layer (`db/dbgen/`, SQLite engine) |
|
||||
| `make sqlc-verify` | Fail if the committed `dbgen` output is stale (used by CI) |
|
||||
| `make protocol-generate` | Regenerate the WS message-type constants (Go + TS) from `protocol/schema.json` |
|
||||
| `make protocol-verify` | Fail if the committed protocol constants are stale (used by CI) |
|
||||
| `make otel-up` | Start Jaeger (traces) + Prometheus (metrics) via Docker for local OTel development |
|
||||
| `make otel-down` | Stop and remove the OTel dev containers |
|
||||
|
||||
#### Client (Tauri v2)
|
||||
|
||||
**Build & dev**
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `npm run dev` | Start Vite dev server with hot reload |
|
||||
| `npm run build` | TypeScript check + Vite production build |
|
||||
| `npm run tauri dev` | Launch Tauri app in dev mode |
|
||||
| Command | Description |
|
||||
| --------------------- | ---------------------------------------------------------------- |
|
||||
| `npm run dev` | Start Vite dev server with hot reload |
|
||||
| `npm run build` | TypeScript check + Vite production build |
|
||||
| `npm run tauri dev` | Launch Tauri app in dev mode |
|
||||
| `npm run tauri build` | Build release installer (NSIS on Windows, AppImage+deb on Linux) |
|
||||
|
||||
**Tests**
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `npm test` | Run all tests (vitest) |
|
||||
| `npm run test:unit` | Unit tests only |
|
||||
| `npm run test:integration` | Integration tests only |
|
||||
| `npm run test:e2e` | Playwright E2E (mocked Tauri) |
|
||||
| `npm run test:e2e:native` | Playwright E2E (real Tauri exe + CDP) |
|
||||
| `npm run test:e2e:prod` | Playwright E2E (prod build) |
|
||||
| `npm run test:e2e:ui` | Playwright UI mode |
|
||||
| `npm run test:watch` | Vitest watch mode |
|
||||
| `npm run test:coverage` | Coverage report |
|
||||
| `npm run test:mutate` | Stryker mutation testing |
|
||||
| `npm run test:mutate:dry` | Stryker dry-run (no mutations applied) |
|
||||
| `npm run test:browser` | Vitest browser-mode tests |
|
||||
| Command | Description |
|
||||
| -------------------------- | -------------------------------------- |
|
||||
| `npm test` | Run all tests (vitest) |
|
||||
| `npm run test:unit` | Unit tests only |
|
||||
| `npm run test:integration` | Integration tests only |
|
||||
| `npm run test:contract` | Cross-component contract tests only |
|
||||
| `npm run test:e2e` | Playwright E2E (mocked Tauri) |
|
||||
| `npm run test:e2e:native` | Playwright E2E (real Tauri exe + CDP) |
|
||||
| `npm run test:e2e:admin` | Playwright E2E (real Go server + SPA) |
|
||||
| `npm run test:e2e:prod` | Playwright E2E (prod build) |
|
||||
| `npm run test:e2e:ui` | Playwright UI mode |
|
||||
| `npm run test:watch` | Vitest watch mode |
|
||||
| `npm run test:coverage` | Coverage report |
|
||||
| `npm run test:mutate` | Stryker mutation testing |
|
||||
| `npm run test:mutate:dry` | Stryker dry-run (no mutations applied) |
|
||||
| `npm run test:browser` | Vitest browser-mode tests |
|
||||
|
||||
**Type checking, linting & formatting**
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `npm run typecheck` | Full typecheck (all sources) |
|
||||
| `npm run typecheck:build` | Typecheck build config only |
|
||||
| `npm run lint` | oxlint + ESLint check (src/) |
|
||||
| `npm run lint:fix` | ESLint auto-fix |
|
||||
| `npm run lint:ox` | oxlint only (fast correctness checks) |
|
||||
| `npm run format` | Prettier format (src/ + tests/) |
|
||||
| `npm run format:check` | Prettier check only (no writes) |
|
||||
| `npm run knip` | Dead code and unused export detection |
|
||||
| Command | Description |
|
||||
| ------------------------- | ------------------------------------- |
|
||||
| `npm run typecheck` | Full typecheck (all sources) |
|
||||
| `npm run typecheck:build` | Typecheck build config only |
|
||||
| `npm run lint` | oxlint + ESLint check (src/) |
|
||||
| `npm run lint:fix` | ESLint auto-fix |
|
||||
| `npm run lint:ox` | oxlint only (fast correctness checks) |
|
||||
| `npm run format` | Prettier format (src/ + tests/) |
|
||||
| `npm run format:check` | Prettier check only (no writes) |
|
||||
| `npm run knip` | Dead code and unused export detection |
|
||||
|
||||
### Git hooks (recommended)
|
||||
|
||||
@@ -96,35 +125,93 @@ Committed hooks in `.githooks/` catch the most common CI failures locally. Enabl
|
||||
npm run hooks:install # = git config core.hooksPath .githooks
|
||||
```
|
||||
|
||||
| Hook | What it runs |
|
||||
|------|--------------|
|
||||
| Hook | What it runs |
|
||||
| ------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `pre-commit` | gofmt + `go vet` (when Go files staged), oxlint + prettier + `tsc --noEmit` (when client TS staged), `sqlc-verify` / `protocol-verify` (when their inputs staged) |
|
||||
| `pre-push` | Server build in all build-tag variants, client typecheck + type-aware ESLint. Set `OWNCORD_PREPUSH_TESTS=1` to also run `go test -race ./...` |
|
||||
| `pre-push` | Server build in all build-tag variants, client typecheck + type-aware ESLint. Set `OWNCORD_PREPUSH_TESTS=1` to also run `go test -race ./...` |
|
||||
|
||||
Bypass with `--no-verify` or `OWNCORD_SKIP_HOOKS=1` when needed — CI still enforces everything.
|
||||
|
||||
Neither hook needs `make`, and neither needs Node for the Go checks.
|
||||
|
||||
**`core.hooksPath` is exclusive.** Once set, Git resolves every hook against
|
||||
`.githooks/` and never looks in `.git/hooks/` again. `.githooks/` holds only
|
||||
`pre-commit` and `pre-push`, so `hooks:install` silently disables any other
|
||||
hook you installed there (`post-commit`, `post-checkout`, ...). Nothing warns
|
||||
you. Put it under `.githooks/` instead (untracked, so it stays yours), or skip
|
||||
`hooks:install` and use `npm run check` before pushing.
|
||||
|
||||
## Plugin Development
|
||||
|
||||
Plugins are WASM modules loaded at runtime when the server is built with `-tags wazero`.
|
||||
See `Server/plugin/examples/hello/README.md` for the full plugin ABI and build instructions.
|
||||
|
||||
**Toolchain requirements for building `.wasm` plugins with TinyGo:**
|
||||
> **The plugin ABI is experimental and carries no compatibility promise.** The
|
||||
> subsystem is disabled twice over — it compiles only under `-tags wazero`
|
||||
> (`Server/plugin/sandbox_default.go`), and `plugins.enabled` defaults to
|
||||
> `false` (`Server/config/config.go`) — and the five exported functions may
|
||||
> change or be removed in any release without a deprecation period.
|
||||
|
||||
| Tool | Version | Notes |
|
||||
|------|---------|-------|
|
||||
| TinyGo | 0.40.1 | Supports Go 1.19–1.25 only |
|
||||
| Go SDK | 1.25.x | Install alongside the system Go via `go install golang.org/dl/go1.25.3@latest && go1.25.3 download` |
|
||||
| wasm-opt | Binaryen 129 | Required by TinyGo for the `wasi` target; download from Binaryen GitHub releases |
|
||||
See [`Server/plugin/examples/hello/README.md`](../Server/plugin/examples/hello/README.md)
|
||||
for the ABI, the build command, and the pinned TinyGo/Go/Binaryen versions. That
|
||||
file is the single source of truth for the plugin toolchain — this page used to
|
||||
carry a second copy of the version table, and the two had already drifted apart
|
||||
in wording.
|
||||
|
||||
Any WASM toolchain (Rust/`wasm32-wasi`, AssemblyScript, etc.) that exports the five ABI
|
||||
functions is equally valid — TinyGo is just the example toolchain used by `examples/hello/`.
|
||||
The example's `.wasm` is not checked in: TinyGo embeds absolute host paths from
|
||||
the building machine and offers no `-trimpath`, so its output is not
|
||||
byte-reproducible and no CI job can verify it. Build it locally from the source
|
||||
beside it.
|
||||
|
||||
---
|
||||
|
||||
## Active Branches
|
||||
## Reporting problems, and where things go
|
||||
|
||||
- `main` -- stable releases
|
||||
- `dev` -- active development
|
||||
Bugs, questions and vulnerabilities have three different destinations, and the
|
||||
difference matters most for the third.
|
||||
|
||||
| Kind | Where |
|
||||
| ------------------------------- | ----------------------------------------------------------------------------------------------------------------------------- |
|
||||
| A reproducible bug | [Issues](https://github.com/J3vb/OwnCord/issues/new/choose) — the form asks for the environment detail needed to reproduce it |
|
||||
| A question about setup or usage | [Discussions → Q&A](https://github.com/J3vb/OwnCord/discussions/categories/q-a) |
|
||||
| An idea or feature suggestion | [Discussions → Ideas](https://github.com/J3vb/OwnCord/discussions/categories/ideas) — not an issue |
|
||||
| A security vulnerability | [Private advisory](https://github.com/J3vb/OwnCord/security/advisories/new) |
|
||||
|
||||
### Security reporting
|
||||
|
||||
**Never open a public issue, pull request, or discussion for a security bug.**
|
||||
Use [private security reporting](https://github.com/J3vb/OwnCord/security/advisories/new);
|
||||
[SECURITY.md](../SECURITY.md) is the canonical policy and states the response
|
||||
timeline, and [docs/security.md](security.md) says what stays private and for
|
||||
how long.
|
||||
|
||||
This repository is public, so a commit message, a PR description and a branch
|
||||
name are all disclosure channels. If you are fixing something you believe is a
|
||||
security problem, say so in the private advisory first and let the fix be
|
||||
coordinated — do not describe the weakness in the public change that repairs it.
|
||||
The same applies to weaknesses in the repository's own automation and settings,
|
||||
not only to bugs in the server or client.
|
||||
|
||||
## Branch and PR model
|
||||
|
||||
This section is the single source of truth for the branch model. Everywhere
|
||||
else -- the root `README.md`, `CLAUDE.md`, the PR template -- summarises it and
|
||||
links here rather than restating it.
|
||||
|
||||
- `dev` -- the integration branch. **All contributions target `dev`.**
|
||||
- `main` -- releases only. `dev` is merged to `main` for a release, and release
|
||||
tags are cut from `main`.
|
||||
|
||||
`dev` is protected and PR-only: direct pushes are rejected, twelve status checks
|
||||
are required, `required_approving_review_count` is 0, and the rule is enforced
|
||||
on admins. So a PR is self-mergeable once CI is green, but no commit reaches
|
||||
`dev` without CI having run on it. Settings and rationale live in
|
||||
[`docs/plans/b0-dev-branch-protection.sh`](plans/b0-dev-branch-protection.sh).
|
||||
|
||||
Two consequences worth knowing before you open a PR:
|
||||
|
||||
- The Docker and Tauri Full Build jobs are gated on `main` and report as
|
||||
_skipped_ on a PR into `dev`. That is expected, not a failure.
|
||||
- Squash merge, and a conventional commit subject on the squashed commit.
|
||||
|
||||
## Branch Naming
|
||||
|
||||
@@ -147,13 +234,34 @@ perf: cache role permissions in memory
|
||||
ci: add lint step to GitHub Actions
|
||||
```
|
||||
|
||||
For anything non-trivial the body carries the reasoning, not a restatement of
|
||||
the diff: what was wrong, why the obvious fix is wrong, what was done, concrete
|
||||
numbers, and a `Verified:` paragraph proving both directions — that the defect
|
||||
was present before and is absent after.
|
||||
|
||||
End with an explicit **`Not included:`** line naming adjacent scope you
|
||||
deliberately left out, and why. A written deferral is a deliverable: it is what
|
||||
separates considered-and-declined from silently-missed, and it means adjacent
|
||||
work you spotted mid-change does not have to become either scope creep or a
|
||||
blocking question. Put it in the commit that noticed it.
|
||||
|
||||
## Pull Request Process
|
||||
|
||||
1. Branch from `dev` (the active development branch)
|
||||
2. PRs target `dev`; `dev` is merged to `main` for releases, which are cut from tagged commits on `main`
|
||||
3. CI must pass (build + test + lint)
|
||||
See [Branch and PR model](#branch-and-pr-model) above for what to branch from
|
||||
and target.
|
||||
|
||||
1. Branch from `dev`
|
||||
2. Open the PR against `dev`
|
||||
3. All twelve required checks must pass -- `dev` is protected, so a red PR cannot
|
||||
merge
|
||||
4. Request code review
|
||||
5. Squash merge preferred
|
||||
5. Squash merge, conventional commit subject
|
||||
|
||||
If your change is one an operator would notice, add a `CHANGELOG.md` entry under
|
||||
`## Unreleased`. That file's **"How to write an entry"** section is the rule, and
|
||||
it is not optional styling: scannable lists grouped by user-facing area, one line
|
||||
per fix, what was broken then what it does now. No walls of text, no `OC-*` ids,
|
||||
no file paths.
|
||||
|
||||
## Testing
|
||||
|
||||
@@ -162,6 +270,56 @@ the Go suite has deliberately no floor (T-2026-07-25-19) — use `make cover-all
|
||||
to see the honest cross-package number. Follow a test-driven workflow and never
|
||||
lower a threshold to make a change fit.
|
||||
|
||||
### Tiers
|
||||
|
||||
| Tier | Command | CI job | Blocking |
|
||||
| -------------------------- | -------------------------- | ------------------------------------------- | -------- |
|
||||
| `Client/tests/unit` | `npm run test:unit` | Client Unit Tests | yes |
|
||||
| `Client/tests/integration` | `npm run test:integration` | Client Unit Tests | yes |
|
||||
| `Client/tests/contract` | `npm run test:contract` | Client Unit Tests | yes |
|
||||
| `Client/tests/browser` | `npm run test:browser` | — | no |
|
||||
| `Client/tests/e2e` | `npm run test:e2e` | Client E2E (Playwright) | yes |
|
||||
| `Client/tests/e2e` @parity | — | Client E2E (parity subset, blocking) | yes |
|
||||
| `Client/tests/e2e/native` | `npm run test:e2e:native` | — | no |
|
||||
| `Client/tests/e2e/admin` | `npm run test:e2e:admin` | Admin Panel E2E (real server, non-blocking) | **no** |
|
||||
| `Server/**/*_test.go` | `make test` | Server Build & Test | yes |
|
||||
| `Client/src-tauri` | `cargo test --lib` | Rust Unit Tests | yes |
|
||||
|
||||
`npm test` — not `npm run test:unit` — is what CI runs and what
|
||||
`npm run check:client` invokes, so it is the command that covers
|
||||
`tests/contract`.
|
||||
|
||||
### What belongs in `tests/contract`
|
||||
|
||||
A test is a **contract test** when its assertions read, import or execute an
|
||||
artifact owned by a _different top-level component_ (`Server/`, `Client/`, root
|
||||
`protocol/`) than the one its runner lives in. A comment referencing the other
|
||||
side does not count.
|
||||
|
||||
1. **Placement follows capability, not ownership.** A contract test lives in the
|
||||
tier whose runtime can execute or parse the artifact. If the owning component
|
||||
can execute it, it stays in that component's own suite —
|
||||
`Server/updater/tauri_key_contract_test.go` reads
|
||||
`Client/src-tauri/tauri.conf.json` and stays in Go, because Go parses JSON
|
||||
fine and the assertion is about a server constant.
|
||||
2. **Ownership is declared in the name, never in the directory.** The file name
|
||||
and the top-level `describe`/`Test` name must name the owned artifact's path.
|
||||
3. **A contract test may only live in a blocking tier.** A non-blocking job is
|
||||
not coverage. `Admin Panel E2E` is `continue-on-error: true`
|
||||
(`.github/workflows/ci.yml`), so it is ineligible however well it fits
|
||||
topically — until it graduates.
|
||||
4. `Client/` is one component: its TypeScript frontend and its thin Rust backend
|
||||
in `src-tauri/` are the same side of the boundary, so a `tests/unit` test that
|
||||
reads `src-tauri/tauri.conf.json` is an ordinary unit test. The same goes for a
|
||||
Go test reading its own package's embedded assets
|
||||
(`Server/admin/perm_grid_test.go`).
|
||||
|
||||
E2E is _runtime_ coupling rather than artifact coupling; it stays in `tests/e2e`.
|
||||
|
||||
If a tier ever gains a runner of its own, model its anti-vacuity guard on
|
||||
`Server/invariants/invariants_test.go` — it fails loudly when a configured scope
|
||||
resolves to nothing, rather than passing on an empty set.
|
||||
|
||||
## Code Style
|
||||
|
||||
- **TypeScript**: See [Client Architecture](architecture/client.md)
|
||||
@@ -185,11 +343,40 @@ closing audit findings 2026-04-07 #8 / DC-11):
|
||||
reading the changelog. Peer-coupled groups (`vitest`/`@vitest/*`,
|
||||
`@stryker-mutator/*`) update as one PR so exact peer pins cannot wedge.
|
||||
- **Security gates run on every PR:** `npm audit --omit=dev
|
||||
--audit-level=high` (shipped deps only — dev-tooling advisories are
|
||||
--audit-level=high` (shipped deps only — dev-tooling advisories are
|
||||
triaged in the workflow comment instead of blocking on unfixable pins),
|
||||
`govulncheck` for Go, `cargo audit` for Rust, and `knip` refuses unused
|
||||
client dependencies outright.
|
||||
- **Version skew is pinned at the toolchain level** too: `.nvmrc` + CI both
|
||||
say Node 20, `Server/sqlc.version` pins sqlc, Go pins via `go.mod`
|
||||
(`GOTOOLCHAIN=auto`), and GitHub Actions are SHA-pinned with Dependabot
|
||||
bumping the pins.
|
||||
- **Version skew is pinned at the toolchain level** too: `Client/.nvmrc`, every
|
||||
`actions/setup-node` in CI, and an `engines` block in all three
|
||||
`package.json` files say Node 24 — with `engine-strict=true` in each
|
||||
package's `.npmrc`, so a wrong major fails the install instead of warning.
|
||||
`Server/sqlc.version` pins sqlc, Go pins via `go.mod` (`GOTOOLCHAIN=auto`),
|
||||
and GitHub Actions are SHA-pinned with Dependabot bumping the pins. The one
|
||||
deliberate exception is the plugin toolchain: TinyGo and Binaryen are
|
||||
_documented_ rather than file-pinned, because no gate installs them and
|
||||
nothing would read the pin — the example plugin's README is their single
|
||||
source of truth.
|
||||
- **Three package roots, not an npm workspace** — measured 2026-08-26 (npm
|
||||
11.17, Node 26), not decided on principle. Making `/`, `/Client` and
|
||||
`/tools/mcp-introspect` npm workspaces buys one 298 KB lockfile instead of
|
||||
three (17 KB / 253 KB / 42 KB) and dedupes 614 resolved packages to 582 — 32
|
||||
packages, 5.2%. Client install time is unchanged: 5642 ms against 5667 ms.
|
||||
The things you would expect to break do not: `npm ci` inside `Client/` still
|
||||
exits 0, `npm run <script>` still resolves the hoisted binaries (npm prepends
|
||||
every ancestor `node_modules/.bin` to `PATH`), and `engine-strict` still
|
||||
fails the install on a wrong Node major. The costs that are real:
|
||||
- Ten CI steps key on `cache-dependency-path: Client/package-lock.json` —
|
||||
six in `ci.yml`, four in the tag-only, CI-ungated `release.yml`. That file
|
||||
stops existing, and four of the ten have no gate that would catch it.
|
||||
- Repository Hygiene installs root-only on purpose (prettier is all it
|
||||
needs). Under workspaces that grows 970 ms → 6172 ms and 39 → 318
|
||||
packages, unless every call site gains `--workspaces=false` — the
|
||||
mitigation works (1112 ms, 38 packages) but has to be remembered forever.
|
||||
- One lockfile puts all three npm Dependabot groups back into the same file.
|
||||
They rewrite three disjoint files today; undoing that reinstates the
|
||||
merge-then-rebase-then-re-run-CI storm the grouping comment at the top of
|
||||
`.github/dependabot.yml` exists to prevent.
|
||||
|
||||
Thirty-two deduped packages does not pay for that. The roots stay separate,
|
||||
`npm run bootstrap` installs all three, and Dependabot covers all three.
|
||||
|
||||
+12
-12
@@ -3,10 +3,10 @@
|
||||
The desktop client persists two secrets per server, both in the OS credential
|
||||
store under the service name `com.owncord.client`:
|
||||
|
||||
| Secret | Account name | Contents |
|
||||
| --- | --- | --- |
|
||||
| Login credential | `{host}` | JSON `{"username","token","password"}` |
|
||||
| Voice-E2EE identity private key | `identity:{host}` | base64 JWK (P-256 private key) |
|
||||
| Secret | Account name | Contents |
|
||||
| ------------------------------- | ----------------- | -------------------------------------- |
|
||||
| Login credential | `{host}` | JSON `{"username","token","password"}` |
|
||||
| Voice-E2EE identity private key | `identity:{host}` | base64 JWK (P-256 private key) |
|
||||
|
||||
The identity key is the long-term key peers pin under trust-on-first-use. Its
|
||||
public half is published to the server (`users.identity_public_key`) and its
|
||||
@@ -102,7 +102,7 @@ writes, reads back and deletes a throwaway entry and reports which backend
|
||||
served it, touching no real credential:
|
||||
|
||||
```js
|
||||
await invoke("probe_credential_store")
|
||||
await invoke("probe_credential_store");
|
||||
// { ok: true, backend: "Keyring", error: null }
|
||||
// (Backend enum variants serialize verbatim: "Keyring" | "DpapiFile" | "EncryptedFile")
|
||||
```
|
||||
@@ -148,12 +148,12 @@ These were not the cause of the 2026-07 regression, but they can genuinely stop
|
||||
Windows persisting credentials, and the client now detects and reports them
|
||||
instead of silently regenerating keys.
|
||||
|
||||
| Cause | Check | Fix |
|
||||
| --- | --- | --- |
|
||||
| Credential Manager service stopped | `sc query VaultSvc` | `sc config VaultSvc start= auto && sc start VaultSvc` |
|
||||
| "Network access: Do not allow storage of passwords and credentials for network authentication" | `reg query HKLM\SYSTEM\CurrentControlSet\Control\Lsa /v DisableDomainCreds` | Set the policy to *Disabled* (`secpol.msc` → Local Policies → Security Options), i.e. `DisableDomainCreds = 0`. Note this blocks *domain* credentials and makes writes fail with `ERROR_NO_SUCH_LOGON_SESSION`, which the client surfaces as an error rather than silently. |
|
||||
| No roaming profile, with `CRED_PERSIST_ENTERPRISE` | — | Documented Windows behaviour: the credential simply persists locally instead of roaming. Harmless. |
|
||||
| App running as a different user than the vault being inspected | `whoami` in the app's context vs. the one running `cmdkey` | Credentials are per-user; compare like for like. |
|
||||
| Cause | Check | Fix |
|
||||
| ---------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Credential Manager service stopped | `sc query VaultSvc` | `sc config VaultSvc start= auto && sc start VaultSvc` |
|
||||
| "Network access: Do not allow storage of passwords and credentials for network authentication" | `reg query HKLM\SYSTEM\CurrentControlSet\Control\Lsa /v DisableDomainCreds` | Set the policy to _Disabled_ (`secpol.msc` → Local Policies → Security Options), i.e. `DisableDomainCreds = 0`. Note this blocks _domain_ credentials and makes writes fail with `ERROR_NO_SUCH_LOGON_SESSION`, which the client surfaces as an error rather than silently. |
|
||||
| No roaming profile, with `CRED_PERSIST_ENTERPRISE` | — | Documented Windows behaviour: the credential simply persists locally instead of roaming. Harmless. |
|
||||
| App running as a different user than the vault being inspected | `whoami` in the app's context vs. the one running `cmdkey` | Credentials are per-user; compare like for like. |
|
||||
|
||||
Blob size is not a plausible cause: `CRED_MAX_CREDENTIAL_BLOB_SIZE` is 2560
|
||||
bytes and `keyring` stores the secret as UTF-16, so the ceiling is ~1280
|
||||
@@ -196,4 +196,4 @@ fallback:
|
||||
|
||||
None of this weakens the fail-closed E2EE posture: a peer whose announce
|
||||
signature does not verify is still rejected. The fallback only affects whether
|
||||
*our own* key survives a restart.
|
||||
_our own_ key survives a restart.
|
||||
|
||||
+24
-17
@@ -14,15 +14,17 @@ Production deployment guide for OwnCord server on Windows and Linux.
|
||||
## Building from Source
|
||||
|
||||
**Windows:**
|
||||
|
||||
```bash
|
||||
cd Server
|
||||
go build -o chatserver.exe -ldflags "-s -w -X main.version=1.2.0-alpha.3" .
|
||||
go build -o chatserver.exe -ldflags "-s -w -X main.version=1.2.0-alpha.4" .
|
||||
```
|
||||
|
||||
**Linux:**
|
||||
|
||||
```bash
|
||||
cd Server
|
||||
CGO_ENABLED=0 go build -o chatserver -ldflags "-s -w -X main.version=1.2.0-alpha.3" .
|
||||
CGO_ENABLED=0 go build -o chatserver -ldflags "-s -w -X main.version=1.2.0-alpha.4" .
|
||||
```
|
||||
|
||||
- `-s -w` strips debug info (smaller binary)
|
||||
@@ -30,6 +32,7 @@ CGO_ENABLED=0 go build -o chatserver -ldflags "-s -w -X main.version=1.2.0-alpha
|
||||
- `CGO_ENABLED=0` produces a fully static binary on Linux
|
||||
|
||||
Alternatively, download a pre-built binary from GitHub Releases:
|
||||
|
||||
- **Windows**: `chatserver.exe`
|
||||
- **Linux**: `chatserver-linux-amd64.tar.gz` (extract to get `chatserver`)
|
||||
|
||||
@@ -74,11 +77,11 @@ server:
|
||||
port: 8443
|
||||
|
||||
voice:
|
||||
livekit_url: "ws://livekit:7880" # Docker service DNS — do not change
|
||||
livekit_url: "ws://livekit:7880" # Docker service DNS — do not change
|
||||
quality: "medium"
|
||||
|
||||
tls:
|
||||
mode: "self_signed" # or "acme" / "manual" for production
|
||||
mode: "self_signed" # or "acme" / "manual" for production
|
||||
```
|
||||
|
||||
### Data Persistence
|
||||
@@ -310,12 +313,12 @@ The database uses SQLite WAL mode. Do NOT copy the `.db` file directly while the
|
||||
|
||||
### Admin Backup Endpoint
|
||||
|
||||
| Endpoint | Method | Description |
|
||||
|----------|--------|-------------|
|
||||
| `/admin/api/backup` | POST | Create a new backup (owner-only) |
|
||||
| `/admin/api/backups` | GET | List all backups (newest first) |
|
||||
| `/admin/api/backups/{name}` | DELETE | Delete a backup (owner-only) |
|
||||
| `/admin/api/backups/{name}/restore` | POST | Restore from backup (owner-only; creates pre-restore safety backup first) |
|
||||
| Endpoint | Method | Description |
|
||||
| ----------------------------------- | ------ | ------------------------------------------------------------------------- |
|
||||
| `/admin/api/backup` | POST | Create a new backup (owner-only) |
|
||||
| `/admin/api/backups` | GET | List all backups (newest first) |
|
||||
| `/admin/api/backups/{name}` | DELETE | Delete a backup (owner-only) |
|
||||
| `/admin/api/backups/{name}/restore` | POST | Restore from backup (owner-only; creates pre-restore safety backup first) |
|
||||
|
||||
Backups are stored in the configured backup directory (default
|
||||
`data/backups/`) with timestamps. Point it somewhere safer than the data
|
||||
@@ -450,6 +453,7 @@ descriptions):
|
||||
### Server
|
||||
|
||||
The server checks GitHub Releases for updates:
|
||||
|
||||
- Compares semver versions
|
||||
- Results are cached for 1 hour
|
||||
- Downloads `chatserver.exe` with detached Ed25519/minisign signature verification
|
||||
@@ -472,18 +476,19 @@ Set `github.token` in config for higher API rate limits (5000/hr vs 60/hr unauth
|
||||
### Client
|
||||
|
||||
The Tauri client uses NSIS installer updates:
|
||||
|
||||
- Server exposes client update assets from GitHub Releases
|
||||
- Ed25519 signature verification before applying
|
||||
|
||||
## Firewall and Ports
|
||||
|
||||
| Port | Protocol | Purpose |
|
||||
|------|----------|---------|
|
||||
| `8443` | TCP | HTTPS server (configurable via `server.port`) |
|
||||
| `80` | TCP | ACME HTTP-01 challenge (only if `tls.mode: acme`) |
|
||||
| `7880` | TCP | LiveKit server (WebSocket signaling) |
|
||||
| `7881` | TCP | LiveKit server (RTC/TURN over TCP) |
|
||||
| `50000-60000` | UDP | LiveKit WebRTC media (ICE candidates) |
|
||||
| Port | Protocol | Purpose |
|
||||
| ------------- | -------- | ------------------------------------------------- |
|
||||
| `8443` | TCP | HTTPS server (configurable via `server.port`) |
|
||||
| `80` | TCP | ACME HTTP-01 challenge (only if `tls.mode: acme`) |
|
||||
| `7880` | TCP | LiveKit server (WebSocket signaling) |
|
||||
| `7881` | TCP | LiveKit server (RTC/TURN over TCP) |
|
||||
| `50000-60000` | UDP | LiveKit WebRTC media (ICE candidates) |
|
||||
|
||||
For remote access, see the [Port Forwarding Guide](port-forwarding.md) or [Tailscale Guide](tailscale.md).
|
||||
|
||||
@@ -504,6 +509,7 @@ For remote access, see the [Port Forwarding Guide](port-forwarding.md) or [Tails
|
||||
## Background Maintenance
|
||||
|
||||
The server runs a maintenance loop every 15 minutes that:
|
||||
|
||||
- Purges expired user sessions
|
||||
- Deletes orphaned file attachments (uploaded but never linked to a message, older than 1 hour)
|
||||
- Uses a circuit breaker (pauses after 5 consecutive failures)
|
||||
@@ -511,6 +517,7 @@ The server runs a maintenance loop every 15 minutes that:
|
||||
## Graceful Shutdown
|
||||
|
||||
The server handles `Ctrl+C` (SIGINT) and `SIGTERM`:
|
||||
|
||||
1. Stops accepting new connections
|
||||
2. Closes all WebSocket connections and voice rooms
|
||||
3. Drains HTTP connections with a 30-second timeout
|
||||
|
||||
+38
-36
@@ -4,10 +4,10 @@ LiveKit is an open-source SFU (Selective Forwarding Unit) that handles real-time
|
||||
|
||||
There are two ways to run LiveKit alongside OwnCord:
|
||||
|
||||
| Method | Best for | LiveKit managed by |
|
||||
|--------|----------|--------------------|
|
||||
| **Docker Compose** | Linux servers | Docker (separate container) |
|
||||
| **Companion process** | Windows / bare-metal Linux | OwnCord (auto-start) |
|
||||
| Method | Best for | LiveKit managed by |
|
||||
| --------------------- | -------------------------- | --------------------------- |
|
||||
| **Docker Compose** | Linux servers | Docker (separate container) |
|
||||
| **Companion process** | Windows / bare-metal Linux | OwnCord (auto-start) |
|
||||
|
||||
---
|
||||
|
||||
@@ -32,7 +32,7 @@ When running OwnCord via `docker compose`, LiveKit runs as a separate container
|
||||
tcp_port: 7881
|
||||
port_range_start: 50000
|
||||
port_range_end: 60000
|
||||
node_ip: "YOUR_SERVER_PUBLIC_IP" # required for remote clients
|
||||
node_ip: "YOUR_SERVER_PUBLIC_IP" # required for remote clients
|
||||
keys:
|
||||
my-unique-key: my-secret-at-least-32-characters-long
|
||||
logging:
|
||||
@@ -43,11 +43,11 @@ When running OwnCord via `docker compose`, LiveKit runs as a separate container
|
||||
|
||||
4. **Open firewall ports** on your host:
|
||||
|
||||
| Port | Protocol | Purpose |
|
||||
|------|----------|---------|
|
||||
| `7880` | TCP | LiveKit signaling |
|
||||
| `7881` | TCP | TCP fallback for WebRTC |
|
||||
| `50000-60000` | UDP | WebRTC media |
|
||||
| Port | Protocol | Purpose |
|
||||
| ------------- | -------- | ----------------------- |
|
||||
| `7880` | TCP | LiveKit signaling |
|
||||
| `7881` | TCP | TCP fallback for WebRTC |
|
||||
| `50000-60000` | UDP | WebRTC media |
|
||||
|
||||
> **`node_ip` is required** for remote clients. Without it, LiveKit advertises internal Docker IP addresses as ICE candidates, which are unreachable from the internet. If your cloud VM has a metadata service (AWS, GCP, DigitalOcean) you can use `use_external_ip: true` instead.
|
||||
|
||||
@@ -91,17 +91,17 @@ voice:
|
||||
quality: "medium"
|
||||
```
|
||||
|
||||
| Field | Purpose | Default |
|
||||
|-------|---------|---------|
|
||||
| `livekit_api_key` | Shared API key between OwnCord and LiveKit | `"devkey"` |
|
||||
| `livekit_api_secret` | Shared secret for JWT signing (min 32 chars) | `"owncord-dev-secret-key-min-32chars"` |
|
||||
| `livekit_url` | LiveKit WebSocket URL | `ws://localhost:7880` |
|
||||
| `livekit_binary` | Path to `livekit-server` binary. Empty + auto-download off = assume externally managed | `""` |
|
||||
| `auto_download_livekit` | Download and manage a pinned `livekit-server` release automatically when `livekit_binary` is empty | `true` in generated config |
|
||||
| `livekit_version` | Override the pinned auto-download release (e.g. `"1.13.5"`) | `""` (built-in pin) |
|
||||
| `node_ip` | Public IP for WebRTC ICE candidates (remote users behind NAT) | `""` (auto-detect) |
|
||||
| `advertise_internal_ip` | Also advertise LAN IPs — enable on dual-homed servers (LAN + public IP) so local clients can connect | `false` |
|
||||
| `quality` | Default voice quality preset | `"medium"` |
|
||||
| Field | Purpose | Default |
|
||||
| ----------------------- | ---------------------------------------------------------------------------------------------------- | -------------------------------------- |
|
||||
| `livekit_api_key` | Shared API key between OwnCord and LiveKit | `"devkey"` |
|
||||
| `livekit_api_secret` | Shared secret for JWT signing (min 32 chars) | `"owncord-dev-secret-key-min-32chars"` |
|
||||
| `livekit_url` | LiveKit WebSocket URL | `ws://localhost:7880` |
|
||||
| `livekit_binary` | Path to `livekit-server` binary. Empty + auto-download off = assume externally managed | `""` |
|
||||
| `auto_download_livekit` | Download and manage a pinned `livekit-server` release automatically when `livekit_binary` is empty | `true` in generated config |
|
||||
| `livekit_version` | Override the pinned auto-download release (e.g. `"1.13.5"`) | `""` (built-in pin) |
|
||||
| `node_ip` | Public IP for WebRTC ICE candidates (remote users behind NAT) | `""` (auto-detect) |
|
||||
| `advertise_internal_ip` | Also advertise LAN IPs — enable on dual-homed servers (LAN + public IP) so local clients can connect | `false` |
|
||||
| `quality` | Default voice quality preset | `"medium"` |
|
||||
|
||||
Environment variable overrides use the `OWNCORD_` prefix: `OWNCORD_VOICE_LIVEKIT_API_KEY`, `OWNCORD_VOICE_LIVEKIT_API_SECRET`, etc.
|
||||
|
||||
@@ -111,11 +111,11 @@ Environment variable overrides use the `OWNCORD_` prefix: `OWNCORD_VOICE_LIVEKIT
|
||||
|
||||
## 3. Ports and Firewall
|
||||
|
||||
| Port | Protocol | Purpose |
|
||||
|------|----------|---------|
|
||||
| **7880** | TCP (HTTP/WS) | LiveKit signaling (WebSocket + REST API) |
|
||||
| **7881** | TCP | LiveKit internal RTC (TURN/TCP fallback) |
|
||||
| **50000-60000** | UDP | Media transport (RTP audio/video) |
|
||||
| Port | Protocol | Purpose |
|
||||
| --------------- | ------------- | ---------------------------------------- |
|
||||
| **7880** | TCP (HTTP/WS) | LiveKit signaling (WebSocket + REST API) |
|
||||
| **7881** | TCP | LiveKit internal RTC (TURN/TCP fallback) |
|
||||
| **50000-60000** | UDP | Media transport (RTP audio/video) |
|
||||
|
||||
For LAN-only setups, ensure these ports are open on Windows Firewall. For remote access, forward these through your router or use [Tailscale](tailscale.md).
|
||||
|
||||
@@ -155,6 +155,7 @@ Client OwnCord Server LiveKit Server
|
||||
```
|
||||
|
||||
**Token details:**
|
||||
|
||||
- Room name: `"channel-{channelID}"`
|
||||
- Identity: `"user-{userID}"`
|
||||
- TTL: 24 hours (refresh at 23h)
|
||||
@@ -163,6 +164,7 @@ Client OwnCord Server LiveKit Server
|
||||
- Client can request refresh via `voice_token_refresh` (rate limited to 1/60s)
|
||||
|
||||
**Client connection paths:**
|
||||
|
||||
- **Proxy path** (`/livekit`): Client connects through OwnCord's HTTPS server. Avoids mixed-content issues.
|
||||
- **Direct URL** (`ws://localhost:7880`): Used when the client is on localhost.
|
||||
|
||||
@@ -176,16 +178,16 @@ LiveKit sends webhooks to `POST /api/v1/livekit/webhook`. The endpoint verifies
|
||||
|
||||
## 7. Troubleshooting
|
||||
|
||||
| Symptom | Cause | Fix |
|
||||
|---------|-------|-----|
|
||||
| "voice not configured" error | LiveKit client failed to initialize | Check `livekit_api_key` and `livekit_api_secret` are set and secret is >= 32 chars |
|
||||
| "failed to generate voice token" | API key/secret mismatch | Ensure `config.yaml` key/secret match what LiveKit is using |
|
||||
| Voice connects but no audio | Firewall blocking UDP 50000-60000 | Open UDP port range in Windows Firewall |
|
||||
| "backend unavailable" from `/livekit` proxy | LiveKit not running on port 7880 | Check `livekit_binary` path or start LiveKit manually |
|
||||
| "too many rapid failures, giving up" in logs | LiveKit binary crashes on startup | Run `livekit-server --config data/livekit.yaml` manually to see errors |
|
||||
| Mixed content / insecure WS error | Client using direct URL over HTTPS page | Client should use the `/livekit` proxy path |
|
||||
| Voice works via public IP but not on the LAN (dual-homed server) | LiveKit only advertises the public `node_ip` | Set `voice.advertise_internal_ip: true` so LAN host candidates are advertised too |
|
||||
| `GET /api/v1/livekit/health` returns degraded | LiveKit server not reachable | Verify LiveKit is running: `curl http://localhost:7880` |
|
||||
| Symptom | Cause | Fix |
|
||||
| ---------------------------------------------------------------- | -------------------------------------------- | ---------------------------------------------------------------------------------- |
|
||||
| "voice not configured" error | LiveKit client failed to initialize | Check `livekit_api_key` and `livekit_api_secret` are set and secret is >= 32 chars |
|
||||
| "failed to generate voice token" | API key/secret mismatch | Ensure `config.yaml` key/secret match what LiveKit is using |
|
||||
| Voice connects but no audio | Firewall blocking UDP 50000-60000 | Open UDP port range in Windows Firewall |
|
||||
| "backend unavailable" from `/livekit` proxy | LiveKit not running on port 7880 | Check `livekit_binary` path or start LiveKit manually |
|
||||
| "too many rapid failures, giving up" in logs | LiveKit binary crashes on startup | Run `livekit-server --config data/livekit.yaml` manually to see errors |
|
||||
| Mixed content / insecure WS error | Client using direct URL over HTTPS page | Client should use the `/livekit` proxy path |
|
||||
| Voice works via public IP but not on the LAN (dual-homed server) | LiveKit only advertises the public `node_ip` | Set `voice.advertise_internal_ip: true` so LAN host candidates are advertised too |
|
||||
| `GET /api/v1/livekit/health` returns degraded | LiveKit server not reachable | Verify LiveKit is running: `curl http://localhost:7880` |
|
||||
|
||||
---
|
||||
|
||||
|
||||
+33
-33
@@ -9,7 +9,7 @@ adds nothing to the server binary — it is a thin wrapper over OwnCord's existi
|
||||
the client's on-disk log.
|
||||
|
||||
- **Code:** `tools/mcp-introspect/index.mjs` (one file, ~270 lines)
|
||||
- **Runtime:** Node ≥ 20, ESM. One real dependency: `@modelcontextprotocol/sdk` (+ `zod`)
|
||||
- **Runtime:** Node ≥ 24, ESM. One real dependency: `@modelcontextprotocol/sdk` (+ `zod`)
|
||||
- **Registration:** `/.mcp.json` (committed) and `.claude/settings.local.json` (local)
|
||||
|
||||
---
|
||||
@@ -59,7 +59,7 @@ the token nor the cert.)
|
||||
|
||||
### The three tools
|
||||
|
||||
**`api_request`** — a single generic passthrough that covers the *entire* REST API. It issues one
|
||||
**`api_request`** — a single generic passthrough that covers the _entire_ REST API. It issues one
|
||||
`https.request` to `https://127.0.0.1:<port><path>` with the bearer token and returns
|
||||
`{ status, headers, body }` (body is JSON-parsed when possible, else raw text). Any HTTP method is
|
||||
allowed, including destructive admin routes.
|
||||
@@ -144,13 +144,13 @@ expect `{status:200, body:{...}}`.
|
||||
|
||||
### `api_request`
|
||||
|
||||
| Param | Type | Notes |
|
||||
|-------|------|-------|
|
||||
| `method` | string | `GET`, `POST`, `PATCH`, `PUT`, `DELETE` |
|
||||
| `path` | string | Path beginning with `/` (e.g. `/admin/api/stats`) or a full URL |
|
||||
| `query` | object? | Query-string params |
|
||||
| `body` | any? | JSON body (object or string) |
|
||||
| `headers` | object? | Extra request headers |
|
||||
| Param | Type | Notes |
|
||||
| --------- | ------- | --------------------------------------------------------------- |
|
||||
| `method` | string | `GET`, `POST`, `PATCH`, `PUT`, `DELETE` |
|
||||
| `path` | string | Path beginning with `/` (e.g. `/admin/api/stats`) or a full URL |
|
||||
| `query` | object? | Query-string params |
|
||||
| `body` | any? | JSON body (object or string) |
|
||||
| `headers` | object? | Extra request headers |
|
||||
|
||||
Returns `{ status, headers, body }`.
|
||||
|
||||
@@ -167,12 +167,12 @@ Useful read-only endpoints: `/health`, `/api/v1/metrics` (runtime/process stats)
|
||||
|
||||
### `server_logs`
|
||||
|
||||
| Param | Type | Default | Notes |
|
||||
|-------|------|---------|-------|
|
||||
| `level` | string? | — | `DEBUG` \| `INFO` \| `WARN` \| `ERROR` |
|
||||
| `source` | string? | — | `websocket`, `http`, `admin`, `auth`, `database`, `storage`, `updater`, `config`, `server` |
|
||||
| `limit` | number? | 500 | Max records returned |
|
||||
| `follow_ms` | number? | 0 | `0` = backfill only; `>0` = also stream live for that many ms |
|
||||
| Param | Type | Default | Notes |
|
||||
| ----------- | ------- | ------- | ------------------------------------------------------------------------------------------ |
|
||||
| `level` | string? | — | `DEBUG` \| `INFO` \| `WARN` \| `ERROR` |
|
||||
| `source` | string? | — | `websocket`, `http`, `admin`, `auth`, `database`, `storage`, `updater`, `config`, `server` |
|
||||
| `limit` | number? | 500 | Max records returned |
|
||||
| `follow_ms` | number? | 0 | `0` = backfill only; `>0` = also stream live for that many ms |
|
||||
|
||||
Returns an array of `{ ts, level, msg, source, attrs }` (`attrs` is parsed from its JSON string when present; `req_id`/`trace_id` appear inside `attrs`).
|
||||
|
||||
@@ -183,11 +183,11 @@ Returns an array of `{ ts, level, msg, source, attrs }` (`attrs` is parsed from
|
||||
|
||||
### `client_logs`
|
||||
|
||||
| Param | Type | Default | Notes |
|
||||
|-------|------|---------|-------|
|
||||
| `lines` | number? | 200 | Trailing lines to return |
|
||||
| `level` | string? | — | Keep only lines tagged `[LEVEL]` |
|
||||
| `grep` | string? | — | Keep only lines containing this substring |
|
||||
| Param | Type | Default | Notes |
|
||||
| ------- | ------- | ------- | ----------------------------------------- |
|
||||
| `lines` | number? | 200 | Trailing lines to return |
|
||||
| `level` | string? | — | Keep only lines tagged `[LEVEL]` |
|
||||
| `grep` | string? | — | Keep only lines containing this substring |
|
||||
|
||||
Returns `{ path, found: true, lines: [...] }`, or `{ path, found: false, note }` if the client has
|
||||
not run yet.
|
||||
@@ -198,25 +198,25 @@ not run yet.
|
||||
|
||||
All optional except the token (which only the two server-backed tools need).
|
||||
|
||||
| Env var | Default | Purpose |
|
||||
|---------|---------|---------|
|
||||
| `OWNCORD_API_TOKEN` | *(required for `api_request`/`server_logs`)* | Bearer token from `server token create`. |
|
||||
| `OWNCORD_BASE_URL` | `https://127.0.0.1:<server.port>` | Override the whole base URL (e.g. a non-TLS endpoint). Port is read from `Server/config.yaml`. |
|
||||
| `OWNCORD_CERT_PATH` | `Server/data/cert.pem` | Self-signed cert to pin. |
|
||||
| `OWNCORD_CLIENT_LOG` | `%LOCALAPPDATA%\com.owncord.client\logs\owncord-client.log` | Desktop client log path. |
|
||||
| Env var | Default | Purpose |
|
||||
| -------------------- | ----------------------------------------------------------- | ---------------------------------------------------------------------------------------------- |
|
||||
| `OWNCORD_API_TOKEN` | _(required for `api_request`/`server_logs`)_ | Bearer token from `server token create`. |
|
||||
| `OWNCORD_BASE_URL` | `https://127.0.0.1:<server.port>` | Override the whole base URL (e.g. a non-TLS endpoint). Port is read from `Server/config.yaml`. |
|
||||
| `OWNCORD_CERT_PATH` | `Server/data/cert.pem` | Self-signed cert to pin. |
|
||||
| `OWNCORD_CLIENT_LOG` | `%LOCALAPPDATA%\com.owncord.client\logs\owncord-client.log` | Desktop client log path. |
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
| Symptom | Cause / fix |
|
||||
|---------|-------------|
|
||||
| `OWNCORD_API_TOKEN is not set` | Mint a token and set the env var; restart the shell/Claude Code so it's inherited. |
|
||||
| `OwnCord cert not found at …` | Start the server once to generate `Server/data/cert.pem`, or set `OWNCORD_CERT_PATH` / `OWNCORD_BASE_URL`. |
|
||||
| `api_request` returns `401` | Token missing/revoked/expired. Mint a fresh owner-bound token. |
|
||||
| Symptom | Cause / fix |
|
||||
| ----------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `OWNCORD_API_TOKEN is not set` | Mint a token and set the env var; restart the shell/Claude Code so it's inherited. |
|
||||
| `OwnCord cert not found at …` | Start the server once to generate `Server/data/cert.pem`, or set `OWNCORD_CERT_PATH` / `OWNCORD_BASE_URL`. |
|
||||
| `api_request` returns `401` | Token missing/revoked/expired. Mint a fresh owner-bound token. |
|
||||
| `api_request` returns `403` on `/admin/*` | Either the request didn't come from an allowed IP (the tool must run on the same host as the server; localhost is allowed by default), or the token's user lacks the permission that route requires — see the route table in `docs/api.md`. |
|
||||
| `server_logs` fails at the ticket step | The log stream still needs ADMINISTRATOR (the widened `/admin/api/*` perimeter does not open it), or the server isn't the current build. |
|
||||
| `client_logs` → `found: false` | The desktop client hasn't run yet, or the path differs — set `OWNCORD_CLIENT_LOG`. |
|
||||
| `server_logs` fails at the ticket step | The log stream still needs ADMINISTRATOR (the widened `/admin/api/*` perimeter does not open it), or the server isn't the current build. |
|
||||
| `client_logs` → `found: false` | The desktop client hasn't run yet, or the path differs — set `OWNCORD_CLIENT_LOG`. |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
# Plan index
|
||||
|
||||
Closes G-04. Historical plans are kept at their existing paths — links from
|
||||
audits and commit messages must keep resolving — so status is recorded **here**
|
||||
rather than by moving or rewriting them.
|
||||
|
||||
A plan's own header can drift out of date after its table is updated in place.
|
||||
Where that has happened it is called out below, and **this index is the
|
||||
authority**.
|
||||
|
||||
## Active — these drive current work
|
||||
|
||||
| Plan | State |
|
||||
| ----------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| [beta-product-requirements-2026-08-23](beta-product-requirements-2026-08-23.md) | Approved beta scope, frozen. 57 `BPR-*` requirements. |
|
||||
| [repo-health-roadmap-2026-08-23](repo-health-roadmap-2026-08-23.md) | Phase order and gates, B0–B10. **B0 and B1 complete** (HP-0 and HP-1 both accepted); B2 next. B3–B10 not started. |
|
||||
| [repo-health-issue-register-2026-08-23](repo-health-issue-register-2026-08-23.md) | 88 planning rows. Public-safe; not a replacement for the ledger. |
|
||||
| [beta-requirements-traceability-2026-08-23](beta-requirements-traceability-2026-08-23.md) | Requirement → phase → evidence map. No row is release-qualified. |
|
||||
| [b0-baseline-2026-08-25](b0-baseline-2026-08-25.md) | **Supersedes the roadmap's "current evidence snapshot."** B0 measurements and dispositions. |
|
||||
| [b1-repository-foundation-2026-08-25](b1-repository-foundation-2026-08-25.md) | **B1-0 through B1-8 all done.** B1 execution plan. Re-verifies every RL-\* claim against HEAD; several are refuted. |
|
||||
| [hp-0-scorecard-2026-08-25](hp-0-scorecard-2026-08-25.md) | **HP-0 accepted 2026-08-25.** The single baseline-acceptance artifact. Part-closes `R-08`. |
|
||||
| [hp-1-scorecard-2026-08-27](hp-1-scorecard-2026-08-27.md) | **HP-1 accepted 2026-08-27.** Structural-diff proofs for the flatten and module rename, plus the B1 exit gate. |
|
||||
| [audit-2026-08-19-remediation](audit-2026-08-19-remediation.md) | Phases 1–6 done 2026-08-20; **phase 7 pending**. Its header still reads "in progress 2026-08-19" — stale; the phase table is correct. |
|
||||
|
||||
## Partially implemented
|
||||
|
||||
| Plan | State |
|
||||
| ----------------------------------------------------------- | ---------------------------------------------------------------------------------------------- |
|
||||
| [bug-detection-improvements](bug-detection-improvements.md) | Tier 1a (`make fuzz`) and Tier 2 (five ESLint rules) shipped 2026-08-08. Remaining tiers open. |
|
||||
|
||||
## Design only — not implemented
|
||||
|
||||
| Plan | State |
|
||||
| ----------------------------------- | -------------------------------------------------- |
|
||||
| [slash-commands](slash-commands.md) | Design only. No implementation; not in beta scope. |
|
||||
|
||||
## Shipped — kept for history, do not use as current status
|
||||
|
||||
| Plan | Shipped |
|
||||
| ------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------- |
|
||||
| [audit-2026-07-19-decisions](audit-2026-07-19-decisions.md) | Decisions recorded; greenlit items implemented through 2026-07-23. |
|
||||
| [channel-visibility-unification](channel-visibility-unification.md) | 2026-07-20 (D9), re-verified 2026-08-04. |
|
||||
| [v2-dispatch-migration](v2-dispatch-migration.md) | 2026-07-20 (D10), re-verified 2026-08-04. |
|
||||
| [tauri-capability-narrowing](tauri-capability-narrowing.md) | 2026-07-20, re-verified 2026-08-04. |
|
||||
| [http-tofu-proxy](http-tofu-proxy.md) | 2026-07-19, re-verified 2026-08-04. |
|
||||
| [permission-middleware-consolidation](permission-middleware-consolidation.md) | 2026-07-23 (D13), re-verified 2026-08-04. |
|
||||
| [security-hardening-remediation](security-hardening-remediation.md) | 2026-07-23, re-confirmed 2026-08-04. |
|
||||
| [security-scan-2026-07-22-remediation](security-scan-2026-07-22-remediation.md) | All 8 findings F1–F8 closed, verified 2026-08-04. |
|
||||
| [sqlc-adoption](sqlc-adoption.md) | Shipped, verified 2026-08-04. |
|
||||
| [discord-parity](discord-parity.md) | Phases 1–6 complete, verified 2026-08-04. Phase 1's table reads as a gap list but every row shipped. |
|
||||
| [infrastructure-roadmap](infrastructure-roadmap.md) | 2026-08-15, with two recorded leftovers (TOTP persister seam; published capacity numbers). |
|
||||
|
||||
## Where status actually lives
|
||||
|
||||
Planning documents are not trackers. Do not read a defect count out of one.
|
||||
|
||||
| Concern | Source of truth |
|
||||
| -------------------------- | ------------------------------------------------------------------------------- |
|
||||
| Defect status | `.superpowers/findings-ledger.json` (`FINDINGS.md` is rendered from it) |
|
||||
| Security-sensitive defects | Private GitHub Security Advisories |
|
||||
| Product scope | [beta-product-requirements-2026-08-23](beta-product-requirements-2026-08-23.md) |
|
||||
| Phase order and gates | [repo-health-roadmap-2026-08-23](repo-health-roadmap-2026-08-23.md) |
|
||||
| Current measured baseline | [b0-baseline-2026-08-25](b0-baseline-2026-08-25.md) |
|
||||
|
||||
Ledger at 2026-08-25: **306 fixed / 38 open / 3 declined / 1 duplicate = 348**.
|
||||
All 38 open records still resolved to a live `file:line` at
|
||||
`5cc0888964e26276d1aca145e83270a2c1b9febd` when that sweep was run — it was a
|
||||
manual pass, not something a command reproduces. What the tooling does check:
|
||||
|
||||
```
|
||||
node .superpowers/render-ledger.mjs --check # the ledger's schema is valid
|
||||
node scripts/check-doc-counts.mjs # documents agree with it, and
|
||||
# FINDINGS.md is not stale
|
||||
```
|
||||
|
||||
## Adding a plan
|
||||
|
||||
1. Give it a `**Status:**` line with a date, and update that line — not only
|
||||
the phase table — when it changes.
|
||||
2. Add a row here. A plan absent from this index has no recorded status.
|
||||
3. Mark a superseded plan here; leave it at its path so existing links resolve.
|
||||
@@ -16,21 +16,21 @@ here (and the audit's closure table) as items land.
|
||||
|
||||
## Decisions
|
||||
|
||||
| # | Decision point | Audit ID | Decision | Status |
|
||||
|---|----------------|----------|----------|--------|
|
||||
| D1 | `announcement` channel type (documented + offered by admin API, rejected by DB triggers) | A-2026-07-01 | **Implement end-to-end**: migration to allow the type, posting-permission semantics, admin support, client rendering, spec updates. Not a doc-strip — this becomes a real feature. | **Implemented 2026-07-19**: migration 016 allows `announcement`; posting requires MANAGE_MESSAGES (readable like text); admin already offered it; client renders a megaphone icon + unread counts; specs updated. |
|
||||
| D2 | Data-layer direction (raw SQL vs dead sqlc `db/dbgen` vs `store.Store`) | A-2026-07-05 / A-2026-07-06 | **Adopt sqlc for real**: wire `db.DB` method bodies to the generated `dbgen` queries so sqlc becomes the actual, type-checked query layer. The `sqlc-verify` CI job stays and starts earning its keep. | **Largely done 2026-07-19**: `dbgen.Queries` wired into `db.DB`; 97 methods across all domains delegate to sqlc (no longer dead code). ~43 raw calls remain by design (variable IN, FTS, multi-statement tx, PRAGMA/VACUUM) — tracked in [sqlc-adoption.md](sqlc-adoption.md). |
|
||||
| D3 | Fate of `Server/store/` (untested abstraction seam) | prior audit #6 | **Remove `store/`** via interface segregation: delete `SQLiteStore` + `MemStore` + the `store` package; port the event/plugin methods into `db`; each consumer depends on a small interface `*db.DB` satisfies. | **Implemented 2026-07-19**: the `store/` package is deleted. Event and plugin methods moved into `db` (`event_queries.go`, `plugin_queries.go`); consumers depend on narrow interfaces `*db.DB` satisfies (`service.Store`, `ws.EventStore`, `plugin.PluginStore`). All service/ws/plugin/api tests now run against a real in-memory SQLite `db` with seed helpers; fault-injection tests embed a real `*db.DB` and override the one method under test. Full server suite + `sqlc-verify` green. |
|
||||
| D4 | Protocol constants sync (`message_types.go` / `protocolTypes.ts` claim a nonexistent `docs/protocol-schema.json`) | A-2026-07-08 | **Create real codegen**: commit an actual `protocol-schema.json` plus a generator that emits the Go and TS constant files (and, ideally, protocol.md's message table), making the "single source of truth" comment true. | **Implemented 2026-07-19**: `docs/protocol-schema.json` + `Server/scripts/genprotocol` + `make protocol-generate`/`protocol-verify` + CI gate. protocol.md table generation deferred to D7. |
|
||||
| D5 | Client HTTP TLS gap (`allowSelfSigned: true`, no TOFU pinning on the REST path) | A-2026-07-02 | **Next security work**: build the TOFU HTTP proxy in Rust (mirroring `ws_proxy.rs`) as the next security task — highest-priority security item. | **Implemented 2026-07-19** — `src-tauri/src/http_proxy.rs` (per-host loopback TCP→TLS tunnels, shared TOFU cert store + `cert-tofu` events) + `src/lib/httpProxy.ts`; REST/health/attachments routed through it; `acceptInvalidCerts`/`allowSelfSigned` and the `dangerous-settings` feature removed. See [http-tofu-proxy.md](http-tofu-proxy.md). |
|
||||
| D6 | Abandoned SolidJS beachhead + stale `docs/client-architecture.md` | A-2026-07-12 | **Delete it all**: remove `src/components/solid/`, `solidMount`/`solidAdapter`, `vite-plugin-solid`, and Solid test deps; retire `client-architecture.md` in favor of [docs/architecture/client.md](../architecture/client.md). | **Implemented 2026-07-19** — solid/ dir, solidMount/solidAdapter, setup-solid tests, vite-plugin-solid, jsx tsconfig settings, and solid-js/@solidjs deps all removed; client-architecture.md is now a pointer. |
|
||||
| D7 | Spec refresh strategy for api.md / protocol.md / schema.md | A-2026-07-03 | **One refresh PR first**, using the audit's §2 conformance matrix as the checklist; afterwards specs are kept current per-PR (see the maintenance rule in [docs/architecture/README.md](../architecture/README.md)). Announcement channels (D1) later update the *fresh* specs. | **Implemented 2026-07-19** — all three specs refreshed against the code (incl. E2EE protocol section, migrations 001–015, profile/blocks/plugin-admin endpoints); reference tables now point at `protocol-schema.json`. |
|
||||
| D8 | What to implement first | backlog §6 | **Greenlit now: Protocol codegen (D4) + the quick-wins batch** — `LogAudit` error handling (`admin/handlers_backup.go`), contradictory upload `Cache-Control` (`upload_handler.go`), hub inline settings SQL through the data layer (`ws/hub.go`), Hub constructor cleanup (required collaborators into `NewHub`). | **Implemented 2026-07-19** (all four quick wins + D4). Hub cleanup shipped as: race fix — `eventPersister`/`eventStore`/`pluginSink` are now atomic (they were plain fields written by `main.go` after `NewRouter` had already started `Run`); remaining pre-Run setters now reject late calls with an error log instead of racing silently. Note discovered during the work: the discarded-`LogAudit` pattern is repo-wide (23 call sites) — the two tracker-flagged backup handlers are fixed; whether best-effort audit writes stay the convention elsewhere needs a policy decision. |
|
||||
| D9 | Channel-visibility unification (rule duplicated across ~4 "must mirror" sites) | A-2026-07-07 / backlog 3 | **Greenlit 2026-07-20 — implement**: funnel all four sites through the existing `permissions.Checker` predicate + one filter helper; add a REST/WS agreement test. See [channel-visibility-unification.md](channel-visibility-unification.md). | **Implemented 2026-07-20** — `permissions.Checker.VisibleChannelIDs` + `ChannelRef`; `ListVisibleChannels`, `buildReady`, `computeAllowedChannels` delegate; `RefreshChannelVisibility` uses `HasChannelPerm`. REST/WS agreement test asserts all three sites yield the identical non-DM set. |
|
||||
| D10 | Finish the V2 dispatch migration; delete V1 | A-2026-07-09 / backlog 11 | **Greenlit 2026-07-20 — implement**: port the 3 remaining V1 types (`chat_command`, `voice_join`, `voice_leave`) to V2, then delete the V1 registry + fallback path. Server-internal only, no wire change. See [v2-dispatch-migration.md](v2-dispatch-migration.md). | **Implemented 2026-07-20** — the 3 types ported to typed V2 handlers (voice join/leave hand off to the hub routines via new `Result.JoinVoice`/`LeaveVoice` appliers); V1 registry + `handleMessage` fallback deleted; a constructor↔handler parity guard test locks it shut. No wire change. |
|
||||
| D11 | Disposition of the five plugin CRITICALs from audit-2026-04-07 (§1 carried-over row) | prior #1–#5 | **Close what the code already closes; fix the one cheap real gap; accept the one that hardening cannot fix.** Verified each against `Server/plugin/` rather than the tracker: #1 (no `invokeCommand` timeout) closed by PR #1182 — per-call CPU budget with a 100 ms floor plus `WithCloseOnContextDone` and lazy re-instantiation so an overrun does not brick the plugin. #2 (storage key isolation) closed as structural — the namespace is the caller's `Instance.ID` and `plugin_kv PRIMARY KEY (plugin_id, key)`; no parameter exists by which a plugin could name another's namespace, so the finding's premise was wrong. #3 (per-command ACL) was a **real gap** and is fixed here: the manifest gains a `commands` block and `RegisterCommand` refuses undeclared names, so `list_commands` can no longer widen a plugin's command surface behind the admin's back. #4 (event rate limit) closed because no guest code executes on the event path — precisely: `EventSink.Dispatch` has exactly one caller outside the plugin package's tests (`Server/ws/hub.go:1034`, on every broadcast when plugins are enabled, on the hub goroutine under `seqMu`), but its loop body invokes no guest code and no production code calls `EventSink.Subscribe`, so the subscriber set is always empty. Rather than build a limiter for guest calls that do not happen, the requirement is recorded as a SECURITY GATE comment on `Dispatch` and `Subscribe` — the exact places someone would wire delivery — including the warning that the hot call site already exists and sits under the hub's `seqMu`. #5 (HTTP exfiltration to an allowlisted host) **stays open as accepted residual risk** — an allowlisted host is by definition permitted, so closing it needs egress content policy and per-plugin allowlists (a runtime redesign, ~1–2 weeks), explicitly out of scope for P3. | **Implemented 2026-07-20** — closure tables in [audit-2026-04-07.md](../audit-2026-04-07.md) and the §1 row of [audit-2026-07-19.md](../audit-2026-07-19.md) updated; manifest `commands` ACL + key-size cap + five pinning tests landed (`Server/plugin/audit_closure_test.go`). Because #5 remains open, the standing rule fires as written: **plugins ship default-disabled at the beta gate** — re-verified in `config.DefaultConfig()` (`Plugins.Enabled: false`, empty `HTTPAllowlist`). |
|
||||
| D12 | Who supplies the GIF (Klipy) API key | P3 item 1 / A-2026-07-02 family | **Decided 2026-07-20 — per-operator key, feature default-off.** The key previously shipped inside the client bundle via `VITE_KLIPY_API_KEY`. That is not a sharing arrangement but a disclosure: Vite inlines the value verbatim, so anyone who downloaded the client could extract the maintainer's key and use it for any purpose, with the maintainer carrying the quota, abuse and terms-of-service exposure. The key is now server-side only (`gif.api_key` / `OWNCORD_GIF_API_KEY`). **Alternatives considered and rejected:** a project-hosted proxy holding the maintainer's key (preserves zero-config GIFs and keeps revoke/rate-limit control, but introduces a hard central dependency into a self-hosted product, puts every server's search queries through maintainer infrastructure, and leaves the maintainer paying the quota), and a hybrid falling back to that proxy when unconfigured (same objections, opt-out only). **Consequence accepted:** each operator requests their own key at partner.klipy.com; fresh installs have GIFs off and the client shows "GIFs are not enabled on this server". Discoverability is handled in the README feature list and a quick-start section rather than by defaulting the feature on. | **Implemented 2026-07-20** — server proxy + default-off contract in #1198; `VITE_KLIPY_API_KEY` deleted from source and from all three release build jobs. Old key rotation is a maintainer action, sequenced after the new path is verified working. |
|
||||
| D13 | Server-wide permission rule hand-rolled outside `permissions`; channel `deny` dropped — and cached — on override-fetch error | A-2026-07-16 | **Greenlit 2026-07-21 — implement**: add `permissions.HasServerPerm` (admin bypass OR all-of bit test, four lines, no DB) and collapse `RequirePermission` + `ModerationService.requireBanPermission` onto it; fail closed at both override-fetch sites; delegate `PermissionService.HasChannelPerm` and `GetAccessibleChannelIDs` to the `Checker`. Explicitly **not** making `RequirePermission` channel-aware — neither of its routes has a channel id, and a per-channel allow must never open a server-wide gate. Auth-route direct-db sweep (backlog row 12 / A-2026-07-06) deferred to a future D14; row 12 unchanged by this work. See [permission-middleware-consolidation.md](permission-middleware-consolidation.md). | **Implemented 2026-07-23** — `HasServerPerm` owns the rule (multi-bit masks now all-of); both fetch sites fail closed with `slog.Error` (nothing cached on error, so the next request retries); fifth D9 site (`GetAccessibleChannelIDs`) routes through `VisibleChannelIDs`; `AuthMiddleware` gains the dangling-role nil guard (401). Locked by failing-first deny tests plus 403 locks on both `RequirePermission` routes. |
|
||||
| # | Decision point | Audit ID | Decision | Status |
|
||||
| --- | ---------------------------------------------------------------------------------------------------------------------------- | ------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| D1 | `announcement` channel type (documented + offered by admin API, rejected by DB triggers) | A-2026-07-01 | **Implement end-to-end**: migration to allow the type, posting-permission semantics, admin support, client rendering, spec updates. Not a doc-strip — this becomes a real feature. | **Implemented 2026-07-19**: migration 016 allows `announcement`; posting requires MANAGE_MESSAGES (readable like text); admin already offered it; client renders a megaphone icon + unread counts; specs updated. |
|
||||
| D2 | Data-layer direction (raw SQL vs dead sqlc `db/dbgen` vs `store.Store`) | A-2026-07-05 / A-2026-07-06 | **Adopt sqlc for real**: wire `db.DB` method bodies to the generated `dbgen` queries so sqlc becomes the actual, type-checked query layer. The `sqlc-verify` CI job stays and starts earning its keep. | **Largely done 2026-07-19**: `dbgen.Queries` wired into `db.DB`; 97 methods across all domains delegate to sqlc (no longer dead code). ~43 raw calls remain by design (variable IN, FTS, multi-statement tx, PRAGMA/VACUUM) — tracked in [sqlc-adoption.md](sqlc-adoption.md). |
|
||||
| D3 | Fate of `Server/store/` (untested abstraction seam) | prior audit #6 | **Remove `store/`** via interface segregation: delete `SQLiteStore` + `MemStore` + the `store` package; port the event/plugin methods into `db`; each consumer depends on a small interface `*db.DB` satisfies. | **Implemented 2026-07-19**: the `store/` package is deleted. Event and plugin methods moved into `db` (`event_queries.go`, `plugin_queries.go`); consumers depend on narrow interfaces `*db.DB` satisfies (`service.Store`, `ws.EventStore`, `plugin.PluginStore`). All service/ws/plugin/api tests now run against a real in-memory SQLite `db` with seed helpers; fault-injection tests embed a real `*db.DB` and override the one method under test. Full server suite + `sqlc-verify` green. |
|
||||
| D4 | Protocol constants sync (`message_types.go` / `protocolTypes.ts` claim a nonexistent `docs/protocol-schema.json`) | A-2026-07-08 | **Create real codegen**: commit an actual `protocol-schema.json` plus a generator that emits the Go and TS constant files (and, ideally, protocol.md's message table), making the "single source of truth" comment true. | **Implemented 2026-07-19**: `docs/protocol-schema.json` + `Server/scripts/genprotocol` + `make protocol-generate`/`protocol-verify` + CI gate. protocol.md table generation deferred to D7. |
|
||||
| D5 | Client HTTP TLS gap (`allowSelfSigned: true`, no TOFU pinning on the REST path) | A-2026-07-02 | **Next security work**: build the TOFU HTTP proxy in Rust (mirroring `ws_proxy.rs`) as the next security task — highest-priority security item. | **Implemented 2026-07-19** — `src-tauri/src/http_proxy.rs` (per-host loopback TCP→TLS tunnels, shared TOFU cert store + `cert-tofu` events) + `src/lib/httpProxy.ts`; REST/health/attachments routed through it; `acceptInvalidCerts`/`allowSelfSigned` and the `dangerous-settings` feature removed. See [http-tofu-proxy.md](http-tofu-proxy.md). |
|
||||
| D6 | Abandoned SolidJS beachhead + stale `docs/client-architecture.md` | A-2026-07-12 | **Delete it all**: remove `src/components/solid/`, `solidMount`/`solidAdapter`, `vite-plugin-solid`, and Solid test deps; retire `client-architecture.md` in favor of [docs/architecture/client.md](../architecture/client.md). | **Implemented 2026-07-19** — solid/ dir, solidMount/solidAdapter, setup-solid tests, vite-plugin-solid, jsx tsconfig settings, and solid-js/@solidjs deps all removed; client-architecture.md is now a pointer. |
|
||||
| D7 | Spec refresh strategy for api.md / protocol.md / schema.md | A-2026-07-03 | **One refresh PR first**, using the audit's §2 conformance matrix as the checklist; afterwards specs are kept current per-PR (see the maintenance rule in [docs/architecture/README.md](../architecture/README.md)). Announcement channels (D1) later update the _fresh_ specs. | **Implemented 2026-07-19** — all three specs refreshed against the code (incl. E2EE protocol section, migrations 001–015, profile/blocks/plugin-admin endpoints); reference tables now point at `protocol-schema.json`. |
|
||||
| D8 | What to implement first | backlog §6 | **Greenlit now: Protocol codegen (D4) + the quick-wins batch** — `LogAudit` error handling (`admin/handlers_backup.go`), contradictory upload `Cache-Control` (`upload_handler.go`), hub inline settings SQL through the data layer (`ws/hub.go`), Hub constructor cleanup (required collaborators into `NewHub`). | **Implemented 2026-07-19** (all four quick wins + D4). Hub cleanup shipped as: race fix — `eventPersister`/`eventStore`/`pluginSink` are now atomic (they were plain fields written by `main.go` after `NewRouter` had already started `Run`); remaining pre-Run setters now reject late calls with an error log instead of racing silently. Note discovered during the work: the discarded-`LogAudit` pattern is repo-wide (23 call sites) — the two tracker-flagged backup handlers are fixed; whether best-effort audit writes stay the convention elsewhere needs a policy decision. |
|
||||
| D9 | Channel-visibility unification (rule duplicated across ~4 "must mirror" sites) | A-2026-07-07 / backlog 3 | **Greenlit 2026-07-20 — implement**: funnel all four sites through the existing `permissions.Checker` predicate + one filter helper; add a REST/WS agreement test. See [channel-visibility-unification.md](channel-visibility-unification.md). | **Implemented 2026-07-20** — `permissions.Checker.VisibleChannelIDs` + `ChannelRef`; `ListVisibleChannels`, `buildReady`, `computeAllowedChannels` delegate; `RefreshChannelVisibility` uses `HasChannelPerm`. REST/WS agreement test asserts all three sites yield the identical non-DM set. |
|
||||
| D10 | Finish the V2 dispatch migration; delete V1 | A-2026-07-09 / backlog 11 | **Greenlit 2026-07-20 — implement**: port the 3 remaining V1 types (`chat_command`, `voice_join`, `voice_leave`) to V2, then delete the V1 registry + fallback path. Server-internal only, no wire change. See [v2-dispatch-migration.md](v2-dispatch-migration.md). | **Implemented 2026-07-20** — the 3 types ported to typed V2 handlers (voice join/leave hand off to the hub routines via new `Result.JoinVoice`/`LeaveVoice` appliers); V1 registry + `handleMessage` fallback deleted; a constructor↔handler parity guard test locks it shut. No wire change. |
|
||||
| D11 | Disposition of the five plugin CRITICALs from audit-2026-04-07 (§1 carried-over row) | prior #1–#5 | **Close what the code already closes; fix the one cheap real gap; accept the one that hardening cannot fix.** Verified each against `Server/plugin/` rather than the tracker: #1 (no `invokeCommand` timeout) closed by PR #1182 — per-call CPU budget with a 100 ms floor plus `WithCloseOnContextDone` and lazy re-instantiation so an overrun does not brick the plugin. #2 (storage key isolation) closed as structural — the namespace is the caller's `Instance.ID` and `plugin_kv PRIMARY KEY (plugin_id, key)`; no parameter exists by which a plugin could name another's namespace, so the finding's premise was wrong. #3 (per-command ACL) was a **real gap** and is fixed here: the manifest gains a `commands` block and `RegisterCommand` refuses undeclared names, so `list_commands` can no longer widen a plugin's command surface behind the admin's back. #4 (event rate limit) closed because no guest code executes on the event path — precisely: `EventSink.Dispatch` has exactly one caller outside the plugin package's tests (`Server/ws/hub.go:1034`, on every broadcast when plugins are enabled, on the hub goroutine under `seqMu`), but its loop body invokes no guest code and no production code calls `EventSink.Subscribe`, so the subscriber set is always empty. Rather than build a limiter for guest calls that do not happen, the requirement is recorded as a SECURITY GATE comment on `Dispatch` and `Subscribe` — the exact places someone would wire delivery — including the warning that the hot call site already exists and sits under the hub's `seqMu`. #5 (HTTP exfiltration to an allowlisted host) **stays open as accepted residual risk** — an allowlisted host is by definition permitted, so closing it needs egress content policy and per-plugin allowlists (a runtime redesign, ~1–2 weeks), explicitly out of scope for P3. | **Implemented 2026-07-20** — closure tables in [audit-2026-04-07.md](../audit-2026-04-07.md) and the §1 row of [audit-2026-07-19.md](../audit-2026-07-19.md) updated; manifest `commands` ACL + key-size cap + five pinning tests landed (`Server/plugin/audit_closure_test.go`). Because #5 remains open, the standing rule fires as written: **plugins ship default-disabled at the beta gate** — re-verified in `config.DefaultConfig()` (`Plugins.Enabled: false`, empty `HTTPAllowlist`). |
|
||||
| D12 | Who supplies the GIF (Klipy) API key | P3 item 1 / A-2026-07-02 family | **Decided 2026-07-20 — per-operator key, feature default-off.** The key previously shipped inside the client bundle via `VITE_KLIPY_API_KEY`. That is not a sharing arrangement but a disclosure: Vite inlines the value verbatim, so anyone who downloaded the client could extract the maintainer's key and use it for any purpose, with the maintainer carrying the quota, abuse and terms-of-service exposure. The key is now server-side only (`gif.api_key` / `OWNCORD_GIF_API_KEY`). **Alternatives considered and rejected:** a project-hosted proxy holding the maintainer's key (preserves zero-config GIFs and keeps revoke/rate-limit control, but introduces a hard central dependency into a self-hosted product, puts every server's search queries through maintainer infrastructure, and leaves the maintainer paying the quota), and a hybrid falling back to that proxy when unconfigured (same objections, opt-out only). **Consequence accepted:** each operator requests their own key at partner.klipy.com; fresh installs have GIFs off and the client shows "GIFs are not enabled on this server". Discoverability is handled in the README feature list and a quick-start section rather than by defaulting the feature on. | **Implemented 2026-07-20** — server proxy + default-off contract in #1198; `VITE_KLIPY_API_KEY` deleted from source and from all three release build jobs. Old key rotation is a maintainer action, sequenced after the new path is verified working. |
|
||||
| D13 | Server-wide permission rule hand-rolled outside `permissions`; channel `deny` dropped — and cached — on override-fetch error | A-2026-07-16 | **Greenlit 2026-07-21 — implement**: add `permissions.HasServerPerm` (admin bypass OR all-of bit test, four lines, no DB) and collapse `RequirePermission` + `ModerationService.requireBanPermission` onto it; fail closed at both override-fetch sites; delegate `PermissionService.HasChannelPerm` and `GetAccessibleChannelIDs` to the `Checker`. Explicitly **not** making `RequirePermission` channel-aware — neither of its routes has a channel id, and a per-channel allow must never open a server-wide gate. Auth-route direct-db sweep (backlog row 12 / A-2026-07-06) deferred to a future D14; row 12 unchanged by this work. See [permission-middleware-consolidation.md](permission-middleware-consolidation.md). | **Implemented 2026-07-23** — `HasServerPerm` owns the rule (multi-bit masks now all-of); both fetch sites fail closed with `slog.Error` (nothing cached on error, so the next request retries); fifth D9 site (`GetAccessibleChannelIDs`) routes through `VisibleChannelIDs`; `AuthMiddleware` gains the dangling-role nil guard (401). Locked by failing-first deny tests plus 403 locks on both `RequirePermission` routes. |
|
||||
|
||||
## Suggested sequencing
|
||||
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
# Audit 2026-08-19 Remediation — Phased Plan
|
||||
|
||||
**Status:** in progress 2026-08-19 — phases execute in order; each phase's
|
||||
status is updated in place when it lands.
|
||||
**Status:** in progress — phases 1–6 landed 2026-08-20 (merged as `03fcb7d5`,
|
||||
PR #1396); **phase 7 is still pending**. Phases execute in order; each phase's
|
||||
status is updated in place when it lands, so the table below is authoritative
|
||||
for per-phase state. Indexed in [README.md](README.md).
|
||||
**Source:** [audit-2026-08-19.md](../audit-2026-08-19.md) — this plan executes
|
||||
its §8 MUST-fix verdict and §9.1 fix order verbatim. Items outside that list
|
||||
(§6 DEBT beyond D-01..D-05, §9.2 alpha-exit work) are deliberately NOT in
|
||||
@@ -11,15 +13,15 @@ the audit-report PR #1395 merged), one commit per phase, single PR to `main`.
|
||||
|
||||
## Phases
|
||||
|
||||
| # | Closes | Change | Verification | Status |
|
||||
|---|--------|--------|--------------|--------|
|
||||
| 1 | F-5 | Give the `renderWindow >30-in-2s breaker` test in `tests/unit/message-list.test.ts` an explicit timeout so CI under load cannot produce a spurious red (it performs 30 synchronous 100-row jsdom rebuilds inside vitest's default 5 s) | run the file 3× | done 2026-08-20 |
|
||||
| 2 | B-01..B-10, D-01..D-05 | Reference-doc refresh: schema.md (migrations 030/031, attachments `ON DELETE SET NULL`, index inventory, pool split, default-roles snapshot, dbgen preamble), protocol.md (DM/plugin_broadcast seq + replay tiers, retry_after, five "None" rate limits, E2EE prose + inner per-target cap, missing error codes, ready/member_join field gaps), api.md (diagnostics auth/limiter/example, error-code table, body-cap exemptions, identity_public_key, plugin text errors + header, /health 503, CIDR keys, restart-conflict 409s); stale comments (`serve_ready.go` buildReady, `tsconfig.e2e.json` + `ci.yml` spec counts, `logctx.go` stray word); three stale plan headers (bug-detection-improvements, security-scan-2026-07-22-remediation, discord-parity) | every edit re-checked against the cited code | done 2026-08-20 |
|
||||
| 3 | F-3, F-4, D-16 | `slog.Warn` on the discarded errors: lockout Upsert/Delete/Cleanup (`auth/ratelimit.go`), `EvictOldestSessions` in `CreateSession` (`db/auth_queries.go`), `UpdateReadState` in `HandleChannelFocus` (`service/channel.go`) — mirrors the shipped OC-0061 pattern; in-memory behavior unchanged | unit tests pin the warn-and-continue contract | done 2026-08-20 |
|
||||
| 4 | F-1 | Blocking a user evicts them from the pair's live 1:1 DM voice call via the existing `dmVoiceEvictor` seam `CloseDM` already exercises (group DMs stay exempt, matching `requireDMNotBlocked`) | failing-first service/API test | done 2026-08-20 |
|
||||
| 5 | F-2 | Close the role-reassign/WS-handshake race: handshake paths re-read the user row instead of trusting the auth-time snapshot, and `revokeUnreadableChannels` re-resolves the live client before acting (mirrors `RefreshChannelVisibility`'s OC-0206 hazard notes) | failing-first ws tests + `-tags deadlock` run | done 2026-08-20 |
|
||||
| 6 | F-6 | Remove the client's inert replay-dedup machinery (`replayDedup`, `isReplaying()`, the two dispatcher gates) — the server sends `auth_ok` before the burst, so the gates can never engage and their no-op behavior is the verified-correct behavior; rewrite the non-representative tests to pin the real frame ordering | client unit suite green | done 2026-08-20 (5036/5036) |
|
||||
| 7 | — | `ci-check` local CI mirror, push, PR, drive green | CI | pending |
|
||||
| # | Closes | Change | Verification | Status |
|
||||
| --- | ---------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------- | --------------------------- |
|
||||
| 1 | F-5 | Give the `renderWindow >30-in-2s breaker` test in `tests/unit/message-list.test.ts` an explicit timeout so CI under load cannot produce a spurious red (it performs 30 synchronous 100-row jsdom rebuilds inside vitest's default 5 s) | run the file 3× | done 2026-08-20 |
|
||||
| 2 | B-01..B-10, D-01..D-05 | Reference-doc refresh: schema.md (migrations 030/031, attachments `ON DELETE SET NULL`, index inventory, pool split, default-roles snapshot, dbgen preamble), protocol.md (DM/plugin_broadcast seq + replay tiers, retry_after, five "None" rate limits, E2EE prose + inner per-target cap, missing error codes, ready/member_join field gaps), api.md (diagnostics auth/limiter/example, error-code table, body-cap exemptions, identity_public_key, plugin text errors + header, /health 503, CIDR keys, restart-conflict 409s); stale comments (`serve_ready.go` buildReady, `tsconfig.e2e.json` + `ci.yml` spec counts, `logctx.go` stray word); three stale plan headers (bug-detection-improvements, security-scan-2026-07-22-remediation, discord-parity) | every edit re-checked against the cited code | done 2026-08-20 |
|
||||
| 3 | F-3, F-4, D-16 | `slog.Warn` on the discarded errors: lockout Upsert/Delete/Cleanup (`auth/ratelimit.go`), `EvictOldestSessions` in `CreateSession` (`db/auth_queries.go`), `UpdateReadState` in `HandleChannelFocus` (`service/channel.go`) — mirrors the shipped OC-0061 pattern; in-memory behavior unchanged | unit tests pin the warn-and-continue contract | done 2026-08-20 |
|
||||
| 4 | F-1 | Blocking a user evicts them from the pair's live 1:1 DM voice call via the existing `dmVoiceEvictor` seam `CloseDM` already exercises (group DMs stay exempt, matching `requireDMNotBlocked`) | failing-first service/API test | done 2026-08-20 |
|
||||
| 5 | F-2 | Close the role-reassign/WS-handshake race: handshake paths re-read the user row instead of trusting the auth-time snapshot, and `revokeUnreadableChannels` re-resolves the live client before acting (mirrors `RefreshChannelVisibility`'s OC-0206 hazard notes) | failing-first ws tests + `-tags deadlock` run | done 2026-08-20 |
|
||||
| 6 | F-6 | Remove the client's inert replay-dedup machinery (`replayDedup`, `isReplaying()`, the two dispatcher gates) — the server sends `auth_ok` before the burst, so the gates can never engage and their no-op behavior is the verified-correct behavior; rewrite the non-representative tests to pin the real frame ordering | client unit suite green | done 2026-08-20 (5036/5036) |
|
||||
| 7 | — | `ci-check` local CI mirror, push, PR, drive green | CI | pending |
|
||||
|
||||
## Decisions taken
|
||||
|
||||
|
||||
@@ -0,0 +1,267 @@
|
||||
# B0 baseline and audit reconciliation
|
||||
|
||||
**Measured:** 2026-08-25
|
||||
**Base commit:** `5cc0888964e26276d1aca145e83270a2c1b9febd` (the audited head)
|
||||
**Branch:** `fix/b0-baseline-2026-08-25`
|
||||
**Supersedes the "current evidence snapshot" in**
|
||||
[repo-health-roadmap-2026-08-23.md](repo-health-roadmap-2026-08-23.md)
|
||||
|
||||
Every row below is either **measured** in this session or explicitly marked
|
||||
**carried** from the 2026-08-23 audit without re-verification. Nothing is
|
||||
inherited silently.
|
||||
|
||||
## Environment
|
||||
|
||||
| Tool | Version | Note |
|
||||
| -------------------------- | ---------------------------- | ---------------------------------------------- |
|
||||
| Node | 26.4.0 | **Local only.** CI pins 24. See ENV-01. |
|
||||
| npm | 11.17.0 | |
|
||||
| Go | 1.26.7 | Matches `Server/go.mod` `toolchain go1.26.7`. |
|
||||
| golangci-lint | 2.11.3 (built with go1.26.5) | Runs correctly despite the mismatch. See G-05. |
|
||||
| Playwright | 1.62.1 | |
|
||||
| Vitest / Vite / TypeScript | 4.1.11 / 8.2.2 / 6.0.3 | |
|
||||
| oxlint / eslint / prettier | 1.79.0 / 10.9.0 / 3.9.6 | |
|
||||
| Client version | 1.2.0-alpha.3 | |
|
||||
|
||||
## Measured results
|
||||
|
||||
| Gate | Result | Provenance |
|
||||
| --------------------------------- | --------------------------------------------------- | ---------------------------------------------- |
|
||||
| Server build — default | pass | measured |
|
||||
| Server build — `otel` | pass | measured |
|
||||
| Server build — `wazero` | pass | measured |
|
||||
| Server build — `otel wazero` | pass | measured |
|
||||
| `go vet ./...` | pass | measured |
|
||||
| `golangci-lint run ./...` | **0 issues**, 19 linters active, 1.18s | measured |
|
||||
| Go `-race ./...` | pass (exit 0, no data race) | measured |
|
||||
| Go `-tags deadlock ./...` | pass (exit 0) | measured |
|
||||
| Client unit + integration | **5257 passed / 192 files, 0 failed** | measured |
|
||||
| Client `tsc` (build + e2e + root) | pass | measured |
|
||||
| Client `prettier --check` | pass | measured |
|
||||
| Client `npm run lint` | pass (exit 0) | measured |
|
||||
| oxlint warnings | **471** | measured — unchanged from audit |
|
||||
| Playwright full suite | **293 passed, exit 0, 37s** | measured |
|
||||
| Client production build | pass, 401ms | measured |
|
||||
| Docker build + boot smoke | **pass** — image 50.1 MB, boots on `:8443` with TLS | measured (see ENV-02) |
|
||||
| Server coverage | **74.6% aggregate** | measured — confirms the carried figure exactly |
|
||||
| Rust clippy + 115 tests | pass | **carried**, not re-measured |
|
||||
|
||||
### Bundle sizes (measured)
|
||||
|
||||
| Chunk | Minified | Gzip |
|
||||
| ----------------- | ----------: | ----------: |
|
||||
| `livekitSession` | 1,998.25 kB | 1,344.96 kB |
|
||||
| `livekit` | 495.41 kB | 127.88 kB |
|
||||
| `MainPage` | 192.18 kB | 58.92 kB |
|
||||
| `index` | 187.18 kB | 59.07 kB |
|
||||
| `SettingsOverlay` | 47.56 kB | 13.98 kB |
|
||||
|
||||
Confirms the audit's "~2.0 MB minified / 1.345 MB gzip" for the largest lazy
|
||||
chunk. This is the budget baseline B7 ratchets against.
|
||||
|
||||
## Dispositions
|
||||
|
||||
### Closed
|
||||
|
||||
| ID | Was | Now | Evidence |
|
||||
| -------------------------- | --------------------- | --------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| G-01 | P0 confirmed | **fixed** | See "G-01 was inverted" below. |
|
||||
| G-02 | P0 confirmed | **fixed** | `MediaStream` stub replaced with a real constructible class; Vitest 4 threw `is not a constructor` at `noise-suppression.ts:162` before, passes after. OC-0277 assertions unchanged. |
|
||||
| Playwright non-termination | P0-adjacent confirmed | **fixed** | Root cause and fix below. |
|
||||
| G-03 | P0 confirmed | **fixed** | `dev` branch protection applied 2026-08-25: PR required, `required_approving_review_count: 0`, `enforce_admins: true`, force-pushes and deletions off. Every dev commit now arrives via PR and hits the existing `pull_request` trigger. Also closes RL-14. Status checks still unpinned — see below. |
|
||||
|
||||
### Refuted
|
||||
|
||||
| ID | Claim | Finding |
|
||||
| ---- | --------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| G-05 | "Local `golangci-lint` could not load because its Go 1.26.5 build mismatched the module's Go 1.26.7 toolchain." | **Does not reproduce.** `golangci-lint run ./...` completes with 19 active linters (bodyclose, contextcheck, cyclop, dupl, errcheck, funlen, gocritic, gosec, govet, ineffassign, modernize, nestif, nilerr, prealloc, staticcheck, unconvert, unparam, unused, wastedassign) in 1.18s and reports 0 issues. Verified with `-v` specifically to rule out the known zero-linters false-green. The gate does not need waiving or CI substitution. |
|
||||
|
||||
### Still open
|
||||
|
||||
| ID | Pri | State | Note |
|
||||
| -------- | --- | --------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| ~~G-03~~ | P0 | **closed 2026-08-25** | `dev` is PR-only: PR required, 0 approvals, enforced on admins, force-pushes off. Moved to Closed. |
|
||||
| G-04 | P1 | **mostly closed** | Active-plan index added at [README.md](README.md): every plan in `docs/plans/` now has a recorded state (active / partial / design-only / shipped). One real stale claim found and fixed — `audit-2026-08-19-remediation.md` still read "in progress 2026-08-19" while its own table showed phases 1–6 done 2026-08-20 with only phase 7 pending. No plan was found claiming "0 open findings". Remaining: the _automated_ check that prevents conflicting status/count claims (B1). |
|
||||
| ENV-02 | — | **closed** | Docker smoke now measured locally and passing. Moved to Closed. |
|
||||
|
||||
### New findings
|
||||
|
||||
| ID | Pri | Finding |
|
||||
| ------ | --- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| ENV-03 | P2 | **`docker-smoke.sh` cannot be run from Git Bash on Windows.** MSYS path conversion rewrites the container-internal path `/chatserver` into `C:/Program Files/Git/chatserver`, so `docker exec` fails with exit 127 and the script reports `container never reported healthy within 30s` — indistinguishable from a genuine boot regression. The image is fine; with `MSYS_NO_PATHCONV=1` the same script passes. CI is unaffected (Linux). Windows is an official contributor platform, so the script should either set this itself or document it — related to RL-20. |
|
||||
| ENV-01 | P2 | **Three Node versions were in play**, not two. `.nvmrc` said 20, CI says 24, and the local runtime is 26.4.0. `.nvmrc` is now 24 to match CI. The local runtime remains 26, so every "measured" row above was produced on Node 26, not CI's 24 — this is the one standing gap between this baseline and a CI baseline. Full single-source-of-truth work stays in B1 (RL-17 / C-01). |
|
||||
|
||||
## G-01 was inverted, not stale
|
||||
|
||||
The register recorded G-01 as a stale assertion. It is worse than that: the
|
||||
original test **passed on the bug and failed on the fix**.
|
||||
|
||||
`message-list.test.ts` spied on `AbortSignal.prototype.addEventListener` and
|
||||
asserted zero `"abort"` registrations. Two things were true:
|
||||
|
||||
- The pre-OC-0286 leak registered row listeners as
|
||||
`element.addEventListener(type, fn, { signal: ac.signal })`. That path never
|
||||
calls `AbortSignal.prototype.addEventListener`, so the leak produced **zero**
|
||||
registrations and the assertion passed.
|
||||
- The OC-0286 fix rotates a per-window controller and hands rows
|
||||
`AbortSignal.any([ac.signal, rowAc.signal])`. Each rebuild registers one
|
||||
listener on a fresh signal, so five jumps produced **five** registrations and
|
||||
the assertion failed.
|
||||
|
||||
Measured directly: with the fix, 5 registrations across **5 distinct** signals,
|
||||
4 already aborted and 1 live. With the fix reverted, **0** registrations.
|
||||
|
||||
The test now captures the signal each window's row listeners are registered
|
||||
against and asserts the invariant its name always claimed:
|
||||
|
||||
- every rendered window's rows share exactly one signal;
|
||||
- each jump renders against a _fresh_ signal (nothing accumulates);
|
||||
- every superseded window's signal is already aborted, and exactly one is live.
|
||||
|
||||
Verified both directions: green on the fix, and with `beginRowRender()` reverted
|
||||
to `rowSignal = ac.signal` it fails with `expected 1 to be 5` — the accumulation
|
||||
shape, named precisely.
|
||||
|
||||
## Playwright non-termination: root cause
|
||||
|
||||
None of the three hypotheses in the B0 plan was correct.
|
||||
|
||||
The runner finished every test and then never exited, printing no summary — so
|
||||
the failure looked like "tests never finish" when it was "process never exits."
|
||||
`process.getActiveResourcesInfo()` at hang time showed the runner holding a live
|
||||
`ProcessWrap` plus several `PipeWrap`: the Vite dev server was still alive.
|
||||
|
||||
Playwright's `webServer` teardown does not kill it on Windows. Measured:
|
||||
|
||||
| webServer setup | Terminates | Tests |
|
||||
| ------------------------------------ | ---------- | ------------------- |
|
||||
| `npm run dev` | no — hangs | pass |
|
||||
| `node node_modules/vite/bin/vite.js` | no — hangs | pass |
|
||||
| `reuseExistingServer: false` | no — hangs | pass |
|
||||
| `gracefulShutdown: { SIGTERM, 3s }` | no — hangs | pass |
|
||||
| `npx vite` | yes | **290 of 293 fail** |
|
||||
| no `webServer` (server pre-started) | yes | 293 pass in 33s |
|
||||
|
||||
`npx vite` only appears to work: npx exits once Vite is up, Playwright reads
|
||||
that as the server dying and tears the group down mid-run, so later tests fail
|
||||
with `ERR_CONNECTION_REFUSED`.
|
||||
|
||||
**Fix:** `tests/e2e/global-teardown.ts` kills the process listening on the dev
|
||||
port after the run, which releases the runner's handle. The `webServer` command
|
||||
spawns Vite's entry point directly so the listening process _is_ Playwright's
|
||||
child — through `npm run dev` the npm process would still hold the handle open.
|
||||
|
||||
Result: `npm run test:e2e` exits 0 in 37s with 293 passed, reproducibly, leaving
|
||||
no orphaned listener. Before, it never exited at any timeout.
|
||||
|
||||
An earlier revision of the teardown used `netstat`, which is not on PATH in
|
||||
every shell here; the swallowed `ENOENT` made the fix look effective while the
|
||||
hang was still present. It now uses PowerShell on Windows, `lsof` elsewhere, and
|
||||
**warns on failure instead of failing silently**.
|
||||
|
||||
## G-03 — the one decision B0 still needs
|
||||
|
||||
`.github/workflows/ci.yml` triggers on `push: [main]` and
|
||||
`pull_request: [main, dev]`. A direct push to `dev` with no open PR receives no
|
||||
run at all, which is exactly why the audited head has no CI evidence.
|
||||
|
||||
The existing `concurrency` group is `ci-${{ github.ref }}`. A dev push is
|
||||
`refs/heads/dev` and its PR is `refs/pull/N/merge` — **different groups**, so
|
||||
adding `dev` to `push` genuinely double-runs the suite while a dev→main PR is
|
||||
open. The trigger comment records that as the original reason for removing it.
|
||||
|
||||
**Decision (2026-08-25): make `dev` PR-only** via branch protection. No workflow
|
||||
change is needed — the existing `pull_request: [main, dev]` trigger already
|
||||
covers every PR — and no run is duplicated. Direct pushes to `dev` stop being
|
||||
possible, which is the point.
|
||||
|
||||
The rejected alternative was adding `dev` to `push.branches`: a one-line change
|
||||
that keeps direct pushes but runs the full matrix twice per push whenever a
|
||||
dev→main PR is open, because the two events fall in different `concurrency`
|
||||
groups.
|
||||
|
||||
Apply with [`b0-dev-branch-protection.sh`](b0-dev-branch-protection.sh), which
|
||||
records the settings and the reasoning. It must be run by a human: repository
|
||||
settings writes are blocked from the agent sandbox. Status-check pinning is
|
||||
deliberately left unset until the exact job names are confirmed from a green
|
||||
run — requiring names that never report would deadlock every PR.
|
||||
|
||||
This is the canonical owner for layout finding RL-14; close both as one issue.
|
||||
|
||||
## Docker evidence has to come from a local run
|
||||
|
||||
The CI Docker job is gated `if: github.ref_name == 'main' || github.base_ref ==
|
||||
'main'`, so it is **skipped on any PR targeting `dev`** — including the PR that
|
||||
carries this baseline. A dev-targeted change therefore cannot obtain Docker
|
||||
evidence from CI at all; it must be run locally (or the gate widened). Measured
|
||||
here: image builds at 50.1 MB and `docker-smoke.sh` exits 0.
|
||||
|
||||
## HP-0 accepted — 2026-08-25
|
||||
|
||||
**HP-0 was accepted on 2026-08-25 by J3vb (repository owner).** The single
|
||||
artifact the hold point requires is
|
||||
[hp-0-scorecard-2026-08-25.md](hp-0-scorecard-2026-08-25.md); it answers the
|
||||
four questions, records three items accepted as stated limitations, and is the
|
||||
authority over the leftovers listed below. B1 is unblocked.
|
||||
|
||||
## Not yet done in B0 — closed out at acceptance
|
||||
|
||||
- ~~Step 6 follow-up: pin required status checks on `dev`~~ — **done
|
||||
2026-08-25.** Ten checks are pinned; `Server Docker Build (verify)`,
|
||||
`Tauri Full Build (*)`, `Admin Panel E2E`, and the `CodeQL` aggregate are
|
||||
deliberately excluded because they do not report a meaningful result on a
|
||||
dev-targeted PR. The names were read off a live PR, not inferred from
|
||||
`ci.yml` — three of the ten exist in no workflow file (CodeQL default setup).
|
||||
Applied with [`b0-dev-branch-protection.sh`](b0-dev-branch-protection.sh),
|
||||
which also refutes its own header note: repository-settings writes were **not**
|
||||
blocked from the agent sandbox.
|
||||
- Step 8: individual adjudication of the 38 open `OC-*` records. The count was
|
||||
verified as **306 fixed / 38 open / 3 declined / 1 duplicate = 348**, matching
|
||||
the register, and a staleness pass confirmed **all 38 still resolve to a live
|
||||
`file:line`** at this commit — none is superseded by later work, so all 38 are
|
||||
genuinely open (11 medium, 27 low, all from hunt `general-2026-08-22-b`).
|
||||
Deciding each one is bughunt-fix work, not B0 work. The duplicate pairs the
|
||||
register names are mapped: RL-14↔G-03 (closed together here) and RL-17↔C-01
|
||||
(Node, partly addressed by ENV-01).
|
||||
- Step 9: nothing outstanding. Coverage re-measured at 74.6%; the only figure
|
||||
still carried is Rust clippy + 115 tests.
|
||||
- ~~Step 10: HP-0 sign-off.~~ — **done 2026-08-25.** See
|
||||
[hp-0-scorecard-2026-08-25.md](hp-0-scorecard-2026-08-25.md).
|
||||
- The Rust row above is **no longer carried**: re-measured 2026-08-25 as 115
|
||||
passed with `cargo clippy --all-targets -- -D warnings` at exit 0, confirming
|
||||
the carried figure exactly.
|
||||
|
||||
## B1 re-measurement — 2026-08-27
|
||||
|
||||
**`ENV-01` is closed.** Every B0 number was measured on local Node 26 while CI
|
||||
pins 24, and the Node 24 figures were recorded as unverified. On 2026-08-27 the
|
||||
client suite was re-run on **Node 24**, from a fresh `git clone` inside a
|
||||
`node:24` container, after `npm run bootstrap`: **192 files, 5257 tests passed**
|
||||
— identical to the B0 figure. The fresh clone also serves as the exit gate's
|
||||
Linux setup smoke.
|
||||
|
||||
`ENV-02` re-measured the same day: image **50.1 MB**, boots as uid 65532 on
|
||||
`:8443`, `docker-smoke.sh` exit 0 — matching B0. Note that the smoke script has
|
||||
since moved to `Server/scripts/` and now takes the image as an argument; the
|
||||
build context is `Server/`, not the repository root.
|
||||
|
||||
`ENV-03` (`MSYS_NO_PATHCONV` on Git Bash) is **still open** — the script does
|
||||
not set it itself.
|
||||
|
||||
Evidence and the full gate run:
|
||||
[hp-1-scorecard-2026-08-27.md](hp-1-scorecard-2026-08-27.md).
|
||||
|
||||
## HP-1 accepted — 2026-08-27
|
||||
|
||||
**HP-1 was accepted on 2026-08-27 by J3vb (repository owner).** B1 is complete;
|
||||
B2's entry gate condition "B1 is complete and protocol source has one owner" is
|
||||
met. The single artifact is
|
||||
[hp-1-scorecard-2026-08-27.md](hp-1-scorecard-2026-08-27.md): four structural
|
||||
proofs, the eight exit conditions, and the open items carried forward.
|
||||
|
||||
One condition is accepted **as a stated limitation rather than 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. The
|
||||
trade was taken knowingly; it is not a B2 blocker.
|
||||
@@ -0,0 +1,100 @@
|
||||
#!/usr/bin/env bash
|
||||
# G-03 / RL-14: make `dev` PR-only so every integration commit gets CI.
|
||||
#
|
||||
# Today `.github/workflows/ci.yml` triggers on `push: [main]` and
|
||||
# `pull_request: [main, dev]`. A direct push to `dev` with no open PR runs
|
||||
# nothing at all — which is why the audited head 5cc08889 has no CI evidence.
|
||||
# Requiring a PR routes every dev commit through the existing pull_request
|
||||
# trigger, with no workflow change and no duplicated runs.
|
||||
#
|
||||
# Run this yourself: Claude Code's sandbox blocks repo-settings writes.
|
||||
# bash docs/plans/b0-dev-branch-protection.sh
|
||||
#
|
||||
# Choices worth knowing:
|
||||
# required_approving_review_count: 0
|
||||
# A PR is required, but you can merge your own without a second person.
|
||||
# Anything above 0 would lock a solo maintainer out entirely.
|
||||
# enforce_admins: true
|
||||
# Applies to you too. With `false` an admin silently bypasses the PR
|
||||
# requirement, which on a solo-admin repo makes the whole guard
|
||||
# decorative. Toggle it off any time if you need an emergency push.
|
||||
# required_status_checks
|
||||
# Pinned 2026-08-25 (HP-0 step 2). The names below were read off a live
|
||||
# dev-targeted PR with `gh pr checks <n>`, NOT inferred from ci.yml --
|
||||
# three of them exist in no workflow file at all, because CodeQL runs
|
||||
# from GitHub default setup configured in repository settings.
|
||||
#
|
||||
# Repository Hygiene added 2026-08-26 (B1-3). Same rule: the name was read
|
||||
# off PR #1414 after the job reported `pass`, not copied out of ci.yml.
|
||||
# This is the half of S-05 that makes the gate a gate -- a check that is
|
||||
# present but unpinned lets a formatting regression merge.
|
||||
#
|
||||
# Docs & Ledger Consistency added 2026-08-27 (B1-6). Name read off PR #1418
|
||||
# after the job reported `success`. Unpinned it was harmless while the job
|
||||
# only validated the ledger's JSON schema. B1-6 made it also reject a
|
||||
# ledger that fails to render, and L-07's closure evidence is "CI rejects
|
||||
# generation failure or drift" -- rejecting requires pinning.
|
||||
#
|
||||
# Deliberately NOT pinned, and why:
|
||||
# Server Docker Build (verify) reports "skipping" on a dev PR
|
||||
# (if: ref_name=='main' || base_ref=='main')
|
||||
# Tauri Full Build (...) reports "skipping" on a dev PR, under the
|
||||
# UNEXPANDED matrix name -- the job is
|
||||
# skipped before matrix expansion
|
||||
# Admin Panel E2E continue-on-error: true, so it reports
|
||||
# success unconditionally; requiring it is
|
||||
# theatre (that is R-01, B10 work)
|
||||
# CodeQL default-setup aggregate over the three
|
||||
# Analyze jobs; pinning those is enough
|
||||
#
|
||||
# A required check that never reports blocks every PR forever. Re-read the
|
||||
# list before changing it:
|
||||
# gh pr checks <a recent dev PR>
|
||||
#
|
||||
# To undo:
|
||||
# gh api -X DELETE repos/J3vb/OwnCord/branches/dev/protection
|
||||
set -euo pipefail
|
||||
|
||||
REPO="${REPO:-J3vb/OwnCord}"
|
||||
|
||||
gh api -X PUT "repos/${REPO}/branches/dev/protection" --input - <<'JSON'
|
||||
{
|
||||
"required_status_checks": {
|
||||
"strict": false,
|
||||
"contexts": [
|
||||
"Server Build & Test (ubuntu-latest)",
|
||||
"Server Build & Test (windows-latest)",
|
||||
"Client Static Checks",
|
||||
"Client Unit Tests",
|
||||
"Rust Unit Tests",
|
||||
"Repository Hygiene",
|
||||
"Docs & Ledger Consistency",
|
||||
"Client E2E (Playwright)",
|
||||
"Client E2E (parity subset, blocking)",
|
||||
"Analyze (go)",
|
||||
"Analyze (javascript-typescript)",
|
||||
"Analyze (actions)"
|
||||
]
|
||||
},
|
||||
"enforce_admins": true,
|
||||
"required_pull_request_reviews": {
|
||||
"required_approving_review_count": 0,
|
||||
"dismiss_stale_reviews": false,
|
||||
"require_code_owner_reviews": false
|
||||
},
|
||||
"restrictions": null,
|
||||
"allow_force_pushes": false,
|
||||
"allow_deletions": false,
|
||||
"required_conversation_resolution": false,
|
||||
"required_linear_history": false
|
||||
}
|
||||
JSON
|
||||
|
||||
echo
|
||||
echo "Applied. Verifying:"
|
||||
gh api "repos/${REPO}/branches/dev/protection" -q '
|
||||
" PR required: " + ((.required_pull_request_reviews != null)|tostring),
|
||||
" approvals needed: " + (.required_pull_request_reviews.required_approving_review_count|tostring),
|
||||
" applies to admins:" + (.enforce_admins.enabled|tostring),
|
||||
" force pushes: " + (.allow_force_pushes.enabled|tostring),
|
||||
" required checks: " + ((.required_status_checks.contexts // []) | length | tostring)'
|
||||
@@ -0,0 +1,101 @@
|
||||
#!/usr/bin/env bash
|
||||
# R-09 / RL-16, second limb: make a release tag hard to create by accident, and
|
||||
# put a human between a green tag and a published artifact.
|
||||
#
|
||||
# B1-7 landed the first limb in the workflow itself: `gate-evidence` in
|
||||
# release.yml refuses to build or publish unless every required check was green
|
||||
# on the exact tagged commit. That closes the "published from a red commit"
|
||||
# hole — v1.2.0-alpha.3 really did ship from a commit whose
|
||||
# `Server Build & Test (windows-latest)` had failed.
|
||||
#
|
||||
# What a workflow file cannot do is stop the tag existing, or require a person
|
||||
# to approve the publish. Both are repository settings. That is this script.
|
||||
#
|
||||
# Run this yourself: Claude Code's sandbox blocks repo-settings writes.
|
||||
# bash docs/plans/b1-release-tag-protection.sh
|
||||
#
|
||||
# Choices worth knowing:
|
||||
# Two independent controls, deliberately.
|
||||
# The ruleset stops a tag appearing by mistake. The environment stops a
|
||||
# tag that does exist from publishing without a person. Either alone
|
||||
# leaves a gap: a ruleset does not review, and an environment does not
|
||||
# prevent a bad tag from starting three build jobs first.
|
||||
# Ruleset, not the legacy tag-protection endpoint.
|
||||
# `repos/{owner}/{repo}/tags/protection` is deprecated. Rulesets are the
|
||||
# supported form and can additionally block update and delete, which
|
||||
# matters here: the Release workflow's own concurrency comment records a
|
||||
# tag being deleted and re-pushed, so "the tagged commit" has not always
|
||||
# been a stable referent.
|
||||
# bypass_actors: [] — nobody bypasses, including you.
|
||||
# Same reasoning as enforce_admins in b0-dev-branch-protection.sh. On a
|
||||
# solo-admin repo a bypass makes the guard decorative. Add yourself back
|
||||
# temporarily if a release genuinely needs it; that is a deliberate act
|
||||
# rather than a silent default.
|
||||
# The `release` environment has NO wait timer.
|
||||
# The point is a person looking, not a delay. A timer without a reviewer
|
||||
# is theatre; a reviewer without a timer is the control.
|
||||
#
|
||||
# AFTER RUNNING THIS, one more edit is needed and it is NOT done here:
|
||||
# add `environment: release` to the `release-server-docker` and `publish`
|
||||
# jobs in .github/workflows/release.yml. That is deliberately left out of
|
||||
# B1-7 — an `environment:` key naming an environment that does not exist yet
|
||||
# stalls the next release. Create the environment first, then add the key.
|
||||
#
|
||||
# To undo:
|
||||
# gh api -X DELETE "repos/J3vb/OwnCord/rulesets/<id>" # id from the list call below
|
||||
# gh api -X DELETE "repos/J3vb/OwnCord/environments/release"
|
||||
set -euo pipefail
|
||||
|
||||
REPO="${REPO:-J3vb/OwnCord}"
|
||||
OWNER="${REPO%%/*}"
|
||||
|
||||
# ── 1. Protect refs/tags/v* ──────────────────────────────────────────────────
|
||||
# creation is allowed (you still need to cut releases); update and deletion are
|
||||
# not, so a published tag cannot be quietly re-pointed at a different commit.
|
||||
gh api -X POST "repos/${REPO}/rulesets" --input - <<'JSON'
|
||||
{
|
||||
"name": "Release tags",
|
||||
"target": "tag",
|
||||
"enforcement": "active",
|
||||
"bypass_actors": [],
|
||||
"conditions": {
|
||||
"ref_name": {
|
||||
"include": ["refs/tags/v*"],
|
||||
"exclude": []
|
||||
}
|
||||
},
|
||||
"rules": [
|
||||
{ "type": "update" },
|
||||
{ "type": "deletion" }
|
||||
]
|
||||
}
|
||||
JSON
|
||||
|
||||
# ── 2. A reviewed environment for the publishing jobs ────────────────────────
|
||||
# Required reviewers gate the job at the point it would push to GHCR or create
|
||||
# the Release — after the gate-evidence job has already proved the commit is
|
||||
# green, so the reviewer is confirming intent, not re-checking CI.
|
||||
gh api -X PUT "repos/${REPO}/environments/release" --input - <<JSON
|
||||
{
|
||||
"wait_timer": 0,
|
||||
"prevent_self_review": false,
|
||||
"reviewers": [
|
||||
{ "type": "User", "id": $(gh api "users/${OWNER}" -q .id) }
|
||||
],
|
||||
"deployment_branch_policy": null
|
||||
}
|
||||
JSON
|
||||
|
||||
echo
|
||||
echo "Applied. Verifying:"
|
||||
gh api "repos/${REPO}/rulesets" -q '
|
||||
.[] | select(.name == "Release tags") |
|
||||
" ruleset: " + .name + " (" + .enforcement + ", target " + .target + ")"'
|
||||
gh api "repos/${REPO}/environments/release" -q '
|
||||
" environment: " + .name,
|
||||
" reviewers: " + ((.protection_rules[]? | select(.type=="required_reviewers") | .reviewers | length) // 0 | tostring),
|
||||
" wait timer: " + ((.protection_rules[]? | select(.type=="wait_timer") | .wait_timer) // 0 | tostring)'
|
||||
echo
|
||||
echo "Next: add 'environment: release' to release-server-docker and publish in"
|
||||
echo ".github/workflows/release.yml. Not before — the key stalls a release if"
|
||||
echo "the environment does not exist."
|
||||
@@ -0,0 +1,548 @@
|
||||
# B1 — Isolated repository and contributor foundation
|
||||
|
||||
**Drafted:** 2026-08-25
|
||||
**Base commit:** `6a1561fa` (`dev`, post-PR #1409)
|
||||
**Status:** in progress; **entry gate met — HP-0 accepted 2026-08-25**. B1-0
|
||||
(#1410), B1-1 (#1411), B1-2 (#1412), B1-3 (#1414), B1-4 (#1415), B1-5 (#1417),
|
||||
B1-6 (#1418), B1-7 (#1419) and B1-8 (this branch) are complete — **every B1 step
|
||||
has landed.** HP-1's structural review has been performed and the exit gate
|
||||
measured: [hp-1-scorecard-2026-08-27.md](hp-1-scorecard-2026-08-27.md). All
|
||||
eight exit conditions are evidenced; condition 6 is recorded as **partially
|
||||
met**, because `dev` carries `strict: false`, so a PR can still merge without
|
||||
re-testing against a moved base.
|
||||
|
||||
Primary inputs:
|
||||
|
||||
- [B0 measured baseline](b0-baseline-2026-08-25.md)
|
||||
- [repository-layout audit](../audit-2026-08-23-repository-layout.md) (RL-01…RL-22)
|
||||
- [beta roadmap](repo-health-roadmap-2026-08-23.md), B1 section and HP-1
|
||||
- [issue register](repo-health-issue-register-2026-08-23.md) (L-01…L-16)
|
||||
|
||||
## Context
|
||||
|
||||
B0 replaced a contradictory status picture with one measured baseline. It also
|
||||
proved the 2026-08-23 audits are **claims, not facts**: three did not survive
|
||||
verification — G-01's test passed on the bug and failed on the fix, the
|
||||
Playwright hang matched none of its three stated hypotheses, and G-05's tooling
|
||||
failure was simply false.
|
||||
|
||||
B1 makes the repository discoverable, cross-platform, and shaped for one shared
|
||||
desktop/browser application **without mixing layout churn into behaviour
|
||||
change**. Its riskiest item is RL-01: flattening `Client/tauri-client/` into
|
||||
`Client/`. That flatten is why the phase exists as an isolated migration
|
||||
(BPR-103), and it is the one item where a mistake is both easy to make and hard
|
||||
to see.
|
||||
|
||||
This plan therefore does two things the roadmap's workstream list does not: it
|
||||
**re-verifies every RL claim against HEAD before implementing it**, and it
|
||||
specifies a _mechanical_ proof that each of the two flatten commits changed
|
||||
nothing.
|
||||
|
||||
## Entry gate: HP-0 — was not accepted, now is
|
||||
|
||||
When this plan was drafted, the roadmap's B1 entry gate (`- HP-0 is accepted.`)
|
||||
was **unmet**, and nothing in the repository recorded otherwise:
|
||||
|
||||
| Evidence | Finding |
|
||||
| ---------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------ |
|
||||
| [b0-baseline-2026-08-25.md](b0-baseline-2026-08-25.md), "Not yet done in B0" | `- Step 10: HP-0 sign-off.` |
|
||||
| `git log --all --grep='HP-0' -i` | Zero commits. Same for `hold point`, `scorecard`, `sign-off`. |
|
||||
| Whole tree | **No scorecard file exists.** Every `scorecard` match is a _spec_ for one. `R-08` is still open. |
|
||||
| That file's history | One commit (`6a1561fa` = HEAD). Nothing supersedes the line. |
|
||||
| `CHANGELOG.md` | B0 / PR #1409 absent entirely. |
|
||||
|
||||
The material to answer HP-0's four questions mostly exists — spread across four
|
||||
documents rather than the single artifact the hold point requires.
|
||||
|
||||
### B1-0 — what accepting HP-0 requires
|
||||
|
||||
1. **Write the scorecard.** New `docs/plans/hp-0-scorecard-2026-08-25.md` using
|
||||
the roadmap's `## Phase scorecard` table shape, one row per metric, each cell
|
||||
linking to its B0 evidence. Part-closes `R-08`.
|
||||
2. **Pin required status checks on `dev`.** B0's own leftover. The trap is
|
||||
knowing which jobs _never report_ on a dev-targeted PR — pinning one of those
|
||||
deadlocks every PR.
|
||||
|
||||
The exact reporting set was observed on a live dev-targeted PR (#1410), not
|
||||
inferred from `ci.yml` — which matters, because **three of them exist in no
|
||||
workflow file**: CodeQL runs from GitHub default setup, so reading `.github/`
|
||||
alone would have missed them.
|
||||
|
||||
Pin: `Server Build & Test (windows-latest)`, `Server Build & Test
|
||||
(ubuntu-latest)`, `Client Static Checks`, `Client Unit Tests`, `Rust Unit
|
||||
Tests`, `Client E2E (Playwright)`, `Client E2E (parity subset, blocking)`,
|
||||
`Analyze (go)`, `Analyze (javascript-typescript)`, `Analyze (actions)`.
|
||||
|
||||
Do **not** pin:
|
||||
- `Server Docker Build (verify)` — observed as **skipping** on a dev PR
|
||||
(`if: ref_name=='main' || base_ref=='main'`).
|
||||
- `Tauri Full Build (${{ matrix.os }})` — reports **skipping** on a dev PR,
|
||||
under the _unexpanded_ matrix name, because the job is skipped before matrix
|
||||
expansion. (An earlier revision of this plan said it does not appear at all;
|
||||
that was wrong — observed on PR #1410.)
|
||||
- `CodeQL` — a default-setup aggregate over the three `Analyze` jobs. Pinning
|
||||
those three is sufficient; the aggregate is redundant.
|
||||
- `Admin Panel E2E (real server, non-blocking)` — `continue-on-error: true`,
|
||||
so it reports success unconditionally. Requiring it is theatre; that is
|
||||
`R-01`, B10 work.
|
||||
|
||||
Re-observe with `gh pr checks <n>` on a dev PR before writing the list: a
|
||||
name that never reports deadlocks every future PR.
|
||||
|
||||
Extend [`b0-dev-branch-protection.sh`](b0-dev-branch-protection.sh) with a
|
||||
`required_status_checks` block rather than adding a second script.
|
||||
|
||||
3. **Resolve the two unverified baseline rows.** Rust clippy + 115 tests is
|
||||
_carried, not re-measured_; every measured row was produced on local Node 26,
|
||||
not CI's 24 (ENV-01). Once checks are pinned, one green dev PR supplies the
|
||||
CI-side numbers.
|
||||
4. **Answer HP-0 question 2 honestly.** B0 verified the 38 open `OC-*` records
|
||||
resolve to a live `file:line` but deferred per-item phase adjudication. Either
|
||||
adjudicate them, or state in the scorecard that they are counted, non-stale,
|
||||
assigned to bughunt-fix, and that none blocks B1.
|
||||
5. **Record the private security reconciliation.** Roadmap workstream 7.
|
||||
`docs/security-findings/` is correctly gitignored and untracked; what is
|
||||
missing is a public, content-free statement that the dedup happened.
|
||||
|
||||
Then add one dated acceptance line to the baseline document. **No B1 source
|
||||
change starts before that line exists.**
|
||||
|
||||
### Status: all five closed, HP-0 accepted 2026-08-25
|
||||
|
||||
| # | Item | Outcome |
|
||||
| --- | ----------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| 1 | Scorecard | [hp-0-scorecard-2026-08-25.md](hp-0-scorecard-2026-08-25.md) written; part-closes `R-08`. |
|
||||
| 2 | Pin required checks | **Applied.** 10 checks pinned on `dev`. The assumption that repository-settings writes are blocked from the agent sandbox was **wrong** — the `PUT` succeeded. |
|
||||
| 3 | Two unverified rows | Rust **re-measured**: 115 passed, clippy `-D warnings` exit 0 — confirms the carried figure. Node 26-vs-24 accepted as a stated limitation; CI ran the full matrix on Node 24 and passed. |
|
||||
| 4 | 38 open findings | Accepted as counted, non-stale, assigned. 11 medium / 27 low, **zero high or critical**, **0 dead paths across all 348** re-verified at `6a1561fa`, and **none assigned to B1**. |
|
||||
| 5 | Security reconciliation | 7 private findings, **7 of 7 mapped** to existing public rows, 0 unmapped, 0 fixed at the reviewed revision. Content-free summary in the scorecard; detail stays private. |
|
||||
|
||||
Also corrected while closing item 2: the live check list is **not** what
|
||||
`ci.yml` implies. See the amended table above.
|
||||
|
||||
## What B0 already closed — do not redo
|
||||
|
||||
| Finding | State at HEAD | Leftover for B1 |
|
||||
| -------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| **RL-14** (dev exact-SHA CI) | **Applied.** `dev` protection live and API-verified: PR required, 0 approvals, `enforce_admins: true`, force-push/delete off. `ci.yml` unchanged — closed by settings, not code. | `required_status_checks` is absent from the live API response. A dev PR can merge red. → B1-0. |
|
||||
| **RL-17 / C-01** (Node) | **Only `.nvmrc` moved 20 → 24.** | Four places still say 20: `tools/mcp-introspect/package.json` (`">=20"`, the repo's only `engines` field), `README.md`, `docs/contributing.md`, `docs/quick-start.md`. Root and client `package.json` have no `engines` at all. → B1-2. |
|
||||
| **RL-12 / R-06** (docs index) | **Half.** `docs/plans/README.md` exists and is good. | `docs/README.md` does not exist; root `README.md` links neither it nor the plan index; no link/status drift check. → B1-2. |
|
||||
| **G-04** (status/count drift) | Index written; one stale plan header fixed. | The _automated_ check is absent. → B1-2. |
|
||||
| **G-01, G-02, C-06** (red gates) | **Fixed and measured.** | none |
|
||||
| **ENV-02** (Docker) | **Measured locally** — 50.1 MB, boots on `:8443`. | The CI Docker job is `main`-gated, so any dev-targeted PR must re-run `docker-smoke.sh` locally. |
|
||||
|
||||
## Verify before you implement
|
||||
|
||||
Every `RL-*` was re-tested against `6a1561fa`. Several are materially wrong, and
|
||||
that changes the work.
|
||||
|
||||
| Claim | Verdict | What it means |
|
||||
| ------------------------------------------------------------- | ------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| **RL-01** release names | **Safe to move** | `productName: "OwnCord"`, `identifier: "com.owncord.client"`, crate `owncord-client`, lib `owncord_client_lib` — none derived from the directory. `updater.endpoints` is `[]` (server-mediated via `Server/api/client_update.go`). Every release staging step globs by **filename suffix**, matched server-side by `Server/updater/assets.go`. **The move cannot rename a release asset.** |
|
||||
| **RL-09** "no one command verifies both consumers" | **Sub-claim refuted** | `make protocol-verify` already regenerates _and_ diffs both outputs, and it is enforced three times over: `ci.yml`, `.githooks/pre-commit`, and `Server/ws/protocol_contract_test.go`. Only the schema's _location_ is a real finding. Scope shrinks to a relocation. |
|
||||
| **RL-10** "`init()` creates a data dir during test discovery" | **Alarming half refuted** | `Server/scripts/` contains zero `_test.go` files, so Go never builds a test binary there and `init()` never fires under `go test ./...`. `seed.go` does `os.MkdirAll("data", 0o750)`, but only when the binary is run — and `.gitignore` already ignores `Server/data/`. Residual finding is narrow: an untagged `package main` in the main module's build graph. |
|
||||
| **RL-06** "regeneration not demonstrated" | **Closed by deletion** | Superseded before B1-6 opened: `a5f7d95` (#1413) removed the tool and all 7 tracked files — **20,408,656 bytes**, `graph.json` **19,463,420**. `git ls-files` now matches nothing. The outcome RL-06 wanted (no large tracked payload, history intact) holds; the method it prescribed (regenerate-then-untrack) was bypassed. Portability is moot — there is nothing to regenerate. |
|
||||
| **RL-08** "committed without its source" | **Half refuted** | The source _is_ committed (`Server/plugin/examples/hello/main.go`, `//go:build tinygo`). Only the gate is missing. **New constraint:** pinned TinyGo 0.40.1 rejects Go 1.26, so a compile-and-compare CI job needs a second Go SDK. L-08 is harder than the audit implies. |
|
||||
| **RL-07** FINDINGS.md duplication | **Confirmed, sharper** | `render-ledger.mjs --check` validates the JSON schema and returns **before** rendering, so it cannot detect drift at all; a stale 1.09 MB `FINDINGS.md` passes cleanly. B1-2 (#1412) wired that schema-only check into the `Docs & Ledger Consistency` job, which is why the original "no workflow runs it" no longer holds — the job runs, it just cannot see drift. B1-6 adds the check that can. |
|
||||
| **RL-20** hooks | **Confirmed, plus an unreported bug** | `make` is not on PATH on a normal Windows contributor box, yet the `ci-check` skill lists `make sqlc-verify protocol-verify` as required. Worse: `.githooks/pre-commit`'s protocol branch guards on `command -v go`, not `make` — so Go-without-`make` yields a false **"protocol constants are stale"** hard failure. Separately, `core.hooksPath` may be unset (so `.githooks/` never runs) while a local `post-commit` does; `npm run hooks:install` redirects `core.hooksPath` and silently disables any `.git/hooks/post-commit`. |
|
||||
| **RL-05** package roots | **Confirmed, wider** | Three JS roots, no workspaces. `dependabot.yml` covers npm for one of them — root and `tools/mcp-introspect` are uncovered — and omits the **`docker` ecosystem entirely** despite three Docker files. |
|
||||
| **RL-11** cross-stack test | **Confirmed; both directions exist** | Client→Server: `tests/unit/admin-static-channel-perms.test.ts` plus three e2e siblings (`playwright.config.admin.ts`, `tests/e2e/admin/admin-panel.spec.ts`, `tests/e2e/admin/start-server.sh`). Server→Client: `Server/updater/updater_test.go` does `os.ReadFile` on the client's `tauri.conf.json`. Also `Server/ws/protocol_contract_test.go` reads `docs/protocol-schema.json`. A sweep reporting "no server→client reads" is wrong. |
|
||||
| **RL-04** root facade | **Confirmed** | Root `package.json` has exactly three scripts. No root `Makefile`/`justfile`/`Taskfile`. Entry points exist only in `Server/Makefile` and the client `package.json`. |
|
||||
| **RL-13** module namespace | **Confirmed, bounded** | `Server/go.mod` declares `github.com/owncord/server`: **722 occurrences across 344 Go files**, plus six non-Go (go.mod, a `sed` in `Server/Makefile`, two docs, the ledger pair). **Zero** in any workflow or Dockerfile; no `.goreleaser` exists. |
|
||||
| **RL-19** format/lint gaps | **Confirmed, all sub-claims** | No `.editorconfig` anywhere. Prettier is scoped to the client's `src/` and `tests/` TypeScript, so root Markdown, all of `docs/`, every YAML/JSON and all CSS are formatted by nothing. `.golangci.yml` enables 19 linters but no `gofmt`/`gofumpt`/`goimports`. No `cargo fmt --check`. No shellcheck/actionlint/yamllint. |
|
||||
| **RL-21** intake | **Confirmed, understated** | `feature_request.md` still exists. Both templates are **Markdown, not YAML issue forms** — no `body:`, no `validations: required`, so nothing is structured or enforced. The Environment block hardcodes one OS. |
|
||||
| **RL-22** paid automation authorization | **Confirmed; done in B1-7** | Insufficient. The earlier note that impact was bounded by read-only content permissions understated it: the workflow's own token block is least-privilege, but that is not the only identity a run can hold. Mechanism, guard text, and fix stay out of public commits, issues, and PR bodies per [docs/security.md](../security.md). Tracked as `L-16` only. |
|
||||
|
||||
Net effect: **RL-09 and RL-10 shrink to near-nothing; RL-08 grows a toolchain
|
||||
constraint; RL-05, RL-07, RL-20 and RL-21 are each worse than written.**
|
||||
|
||||
## B1-1 — The flatten (RL-01 / L-01)
|
||||
|
||||
**Do this immediately after B1-0, before any other B1 work.**
|
||||
|
||||
This deliberately contradicts the audit's own step order, which puts docs and
|
||||
the command facade first. Reason: every later B1 workstream _adds_ files that
|
||||
reference the client path. Flattening now keeps the rewrite set at its minimum —
|
||||
39 tracked files, about 15 of them active automation — and lets the proof be a
|
||||
row-for-row comparison against the freshly measured B0 baseline with nothing
|
||||
else in the diff.
|
||||
|
||||
### Shape of the tree
|
||||
|
||||
- `Client/` has **exactly one tracked child**: `tauri-client/`. 473 tracked files.
|
||||
- No submodules (modes are `100644`/`100755` only). No symlinks anywhere.
|
||||
- No `.gitattributes` rule mentions `Client` — no eol/binary reclassification risk.
|
||||
- Longest tracked path is 74 characters; the move **shortens** every path by 13,
|
||||
so Windows MAX_PATH pressure strictly improves.
|
||||
|
||||
### Step 1 — freeze the environment
|
||||
|
||||
A detached `post-commit` graph rebuild used to dirty the tree between commits
|
||||
here, which is why this step once began by exporting `GRAPHIFY_SKIP_HOOK=1`.
|
||||
That tool and its hook were removed in `a5f7d95` (#1413); nothing to disable.
|
||||
|
||||
Close any editor, `cargo`, `vite`, or file watcher holding
|
||||
`Client/tauri-client/src-tauri/target/`: a Windows directory rename fails while a
|
||||
child handle is open.
|
||||
|
||||
### Step 2 — commit 1: the pure move
|
||||
|
||||
```bash
|
||||
git mv Client/tauri-client ClientTmp
|
||||
rmdir Client
|
||||
git mv ClientTmp Client
|
||||
git commit -m "refactor: move Client/tauri-client to Client (pure move, no content change)"
|
||||
```
|
||||
|
||||
Moving the **directory** rather than its contents matters twice: a shell glob
|
||||
misses the dotfiles at the client root (`.nvmrc`, `.gitignore`,
|
||||
`.prettierignore`, `.oxlintrc.json`), and a directory rename is a filesystem
|
||||
rename, so untracked heavyweights ride along — `node_modules/`,
|
||||
`src-tauri/target/`, `dist/`, `test-results/`, `playwright-report/`,
|
||||
`.stryker-tmp/`, `reports/` all exist on disk and would otherwise be stranded at
|
||||
the old path.
|
||||
|
||||
If the rename fails on a locked handle, move tracked files with
|
||||
`git ls-files -z | xargs -0 git mv` and move the untracked directories by hand.
|
||||
|
||||
### Step 3 — prove commit 1 changed nothing
|
||||
|
||||
```bash
|
||||
test "$(git rev-parse HEAD~1:Client/tauri-client)" = "$(git rev-parse HEAD:Client)"
|
||||
```
|
||||
|
||||
Both sides are Git tree object IDs. Equality is a **cryptographic proof that not
|
||||
one byte of the 473 files changed** — stronger than reading a diff, and possible
|
||||
only because `Client/` has no other tracked child. The value at `6a1561fa` is
|
||||
`6f3db90d106ffdc64c474e92607b7d7866245a79`.
|
||||
|
||||
Belt and braces:
|
||||
|
||||
```bash
|
||||
git diff --stat HEAD~1 HEAD # must be 0 insertions, 0 deletions
|
||||
git diff -M100% --name-status HEAD~1 HEAD | grep -v '^R100' && echo "NOT A PURE MOVE"
|
||||
```
|
||||
|
||||
**Do not run tests on commit 1.** It is knowingly broken; that is the point of
|
||||
splitting it. CI on the PR sees only the tip.
|
||||
|
||||
### Step 4 — commit 2: mechanical path rewrites
|
||||
|
||||
Two rules, applied to an **explicit allow-list of files**, never repo-wide:
|
||||
|
||||
- **R1** — `Client/tauri-client/` becomes `Client/` (plus the bare
|
||||
`tauri-client/` form in `Server/service/sanitize_content_fuzz_test.go`).
|
||||
- **R2** — relative paths that _escape the client root_ lose one `../`. Paths
|
||||
that stay inside the client are unchanged; depth within the subtree is
|
||||
unaffected.
|
||||
|
||||
A repo-wide `sed` is the wrong tool: it would corrupt the dated audits that are
|
||||
the record authorising this move.
|
||||
|
||||
**Build the inventory with `git grep` or ripgrep, never `grep -r` from the repo
|
||||
root.** `git worktree list` shows a locked stale worktree under
|
||||
`.claude/worktrees/`, holding a full second copy of the repository — its own
|
||||
`Server/go.mod`, its own `Client/tauri-client/`. It is correctly gitignored and
|
||||
harmless to the move, but a raw recursive grep walks into it and roughly doubles
|
||||
every count, which is exactly how a reference inventory ends up wrong in a way
|
||||
nobody notices.
|
||||
|
||||
**R1 — active automation:** `.github/workflows/ci.yml` (24 refs — seven
|
||||
`working-directory`, five `cache-dependency-path`, two `workspaces`, seven
|
||||
artifact paths), `.github/workflows/release.yml` (22, including the three
|
||||
version reads that gate the entire release), `.github/dependabot.yml` (2),
|
||||
`.githooks/pre-commit` (5), `.githooks/pre-push` (5), `.gitignore` (4),
|
||||
`Server/Makefile` (`protocol-verify` diff target),
|
||||
`Server/scripts/genprotocol/main.go` (default `-ts-out`),
|
||||
`Server/updater/updater_test.go`, `docs/protocol-schema.json` (`$comment`), and
|
||||
the four `.claude/workflows/` harness files.
|
||||
|
||||
**R1 — active documentation:** `CLAUDE.md`, `README.md`, `docs/quick-start.md`,
|
||||
`docs/architecture/voice-e2ee.md`, `docs/architecture/websocket.md`,
|
||||
`docs/architecture/system-overview.md`, `.claude/skills/ci-check/SKILL.md`,
|
||||
`.claude/skills/protocol-change/SKILL.md`, and the open plans that name live
|
||||
files (`slash-commands.md`, `bug-detection-improvements.md`,
|
||||
`tauri-capability-narrowing.md`, `security-hardening-remediation.md`,
|
||||
`security-scan-2026-07-22-remediation.md`).
|
||||
|
||||
**R2 — depth-sensitive, ranked by how quietly they fail:**
|
||||
|
||||
| # | Location | Change | Why it is dangerous |
|
||||
| --- | ------------------------------------------------------------------- | ---------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| 1 | `.github/workflows/release.yml`, "Sign server update assets" | `../../windows/…` → `../windows/…` | Runs with `working-directory: Client/tauri-client` and signs the server update manifest with the production key. Fires only on a `v*` tag — **zero CI coverage before release**. It fails closed (the next step verifies signatures), but it fails on release day. |
|
||||
| 2 | `Client/tauri-client/tests/e2e/admin/start-server.sh` | five `../` → four | Drives `admin-e2e`, which is `continue-on-error: true` — a break is **silent**. |
|
||||
| 3 | `.gitignore` entry for the generated client directory | R1 | **Silent**: a stale ignore path means generated `tauri-typegen` output starts getting committed. |
|
||||
| 4 | `Client/tauri-client/tests/unit/admin-static-channel-perms.test.ts` | four `../` → three | Blocking `Client Unit Tests`; fails loudly. Also RL-11's file — re-point it here, reclassify it later. |
|
||||
| 5 | `.claude/workflows/bughunt-fix.js` surface routing | R1 | Live control flow (`f.startsWith(...)`), not prose. Silent no-op if missed. |
|
||||
| 6 | `.claude/workflows/bughunt.harness.mjs` | derived hotspot key string | A hard string assertion in the harness self-test. |
|
||||
|
||||
Confirmed **not** depth-sensitive — all intra-client or `import.meta.dirname`
|
||||
anchored: `vite.config.ts`, `vitest.config*.ts`, all four `playwright.config*.ts`,
|
||||
all three `tsconfig*.json`, `eslint.config.js`, `eslint-rules.js`,
|
||||
`.oxlintrc.json`, `knip.json`, `stryker.config.mjs`, `src-tauri/tauri.conf.json`
|
||||
(`frontendDist: "../dist"`), `src-tauri/Cargo.toml`, `src-tauri/build.rs`, and
|
||||
the client's own `.gitignore`. `Server/Makefile` and `genprotocol` use
|
||||
`../Client/…` from `Server/`, so their depth is unchanged — only the path
|
||||
element drops.
|
||||
|
||||
**Leave alone:** every dated `docs/audit-*.md`, and `CHANGELOG.md`.
|
||||
|
||||
### The ledger is the one real judgement call
|
||||
|
||||
`.superpowers/findings-ledger.json` holds 455 `tauri-client` strings and the
|
||||
generated `.superpowers/FINDINGS.md` another 446. It is tempting to call this
|
||||
historical evidence and skip it. **22 of the 38 open findings point into
|
||||
`Client/tauri-client/`** — 58% of the live open ledger. Leaving them makes 22
|
||||
active records point at nothing, immediately undoing B0's verification that all
|
||||
38 resolve to a live `file:line`.
|
||||
|
||||
**Recommendation:** rewrite every path in the ledger under R1 — fixed records
|
||||
included, since a fixed record's path is more useful pointing at where the code
|
||||
lives now than at nothing — then re-render `FINDINGS.md`. `--check` validates
|
||||
schema only and does _not_ test that `file:line` resolves, so add a resolution
|
||||
check as the actual proof: read the ledger, assert every `file` exists on disk,
|
||||
and print the count of dead paths.
|
||||
|
||||
Run it **before** the flatten (expect zero dead paths) and **after** commit 2
|
||||
(must print the same). That before/after pair is the ledger's red-to-green proof.
|
||||
|
||||
### Step 5 — prove commit 2 is mechanical
|
||||
|
||||
The diff cannot be byte-identical, so prove the _transformation_ instead:
|
||||
regenerate the after-state from the before-state with a scripted substitution
|
||||
over the allow-list and confirm `git diff` is empty. Then review **the script and
|
||||
the allow-list**, not a hundred diff hunks. The six R2 hunks get individual human
|
||||
review — they are the only non-uniform edits.
|
||||
|
||||
Non-negotiable: `git diff HEAD~1 HEAD` must contain **no change that is not a
|
||||
path string**. Any logic edit found here is split into its own commit and the
|
||||
structural review restarts (HP-1).
|
||||
|
||||
### Step 6 — verify
|
||||
|
||||
Run the full B0 gate set and compare to the baseline row for row. Because `make`
|
||||
is unavailable on a plain Windows box, use the raw equivalents:
|
||||
|
||||
```bash
|
||||
# Server (from Server/)
|
||||
go build ./... && go build -tags otel ./... && go build -tags wazero ./... && go build -tags otel,wazero ./...
|
||||
go vet ./... && go test -race ./... && go test -tags deadlock -count=1 ./ws/
|
||||
golangci-lint run ./...
|
||||
go run ./scripts/genprotocol && git diff --exit-code ws/message_types.go ../Client/src/lib/protocolTypes.ts
|
||||
|
||||
# Client (from Client/)
|
||||
NODE_OPTIONS=--no-experimental-webstorage npm test # expect 5257 passed / 0 failed
|
||||
npm run typecheck && npm run lint && npm run format:check
|
||||
npm run test:e2e # expect 293 passed, exit 0, ~37s
|
||||
npm run build # compare the five recorded chunk sizes
|
||||
|
||||
# Rust (from Client/src-tauri/)
|
||||
cargo test && cargo clippy --all-targets -- -D warnings
|
||||
|
||||
# Docker — CI skips this job on dev PRs, so produce it locally.
|
||||
# The script moved to Server/scripts/ and now takes the image as an argument,
|
||||
# so it no longer builds anything itself — build first, exactly as ci.yml does.
|
||||
MSYS_NO_PATHCONV=1 docker build --build-arg VERSION=ci -t owncord-smoke:candidate Server/
|
||||
MSYS_NO_PATHCONV=1 bash Server/scripts/docker-smoke.sh owncord-smoke:candidate
|
||||
```
|
||||
|
||||
Two traps here. **The build context is `Server/`, not the repository root** —
|
||||
`ci.yml` sets `context: Server/`, the `Dockerfile` opens with
|
||||
`COPY go.mod go.sum ./`, and only `Server/.dockerignore` exists. Building from
|
||||
the root instead streams the whole working tree (400 MB+ of `node_modules`,
|
||||
`target/`, `dist/`) and then fails on the missing `go.mod`. And
|
||||
`MSYS_NO_PATHCONV=1` is still required, because the script still does not set it
|
||||
itself (`ENV-03`, P2, open): without it MSYS rewrites the container-internal
|
||||
`/chatserver` path and the script reports a boot failure that did not happen.
|
||||
|
||||
Targeted proofs the generic suite will not give you:
|
||||
|
||||
- `go test ./updater/ -run TestDefaultServerSignaturePublicKey_DiffersFromTauriUpdaterKey`
|
||||
— the Go test that reads the client's `tauri.conf.json` from disk.
|
||||
- `npx vitest run tests/unit/admin-static-channel-perms.test.ts` — the client
|
||||
test that reads `Server/admin/static/index.html`.
|
||||
- `node .claude/workflows/bughunt.harness.mjs` and `bughunt-fix.harness.mjs` —
|
||||
the two self-tests that assert on literal client paths.
|
||||
- `git status --porcelain` must be **empty** after a full build, proving the
|
||||
generated-output ignore path was re-pointed.
|
||||
- `npm ci` from a **fresh clone** of the branch on Windows and Linux, then one
|
||||
full check run — the exit gate's fresh-clone setup smoke.
|
||||
- A **dry run of the release signer's directory arithmetic** from `Client/`, or a
|
||||
throwaway tag on a fork. Do not discover a wrong `../../` on release day.
|
||||
|
||||
### Step 7 — refresh the graph, separately
|
||||
|
||||
**Retired.** This step ran `graphify update .` to regenerate the 13,233 stale
|
||||
path strings in `graph.json` after the flatten. The tool was removed wholesale
|
||||
in `a5f7d95` (#1413), so the command no longer exists and `git commit -am` here
|
||||
would commit nothing while appearing to succeed.
|
||||
|
||||
### PR shape
|
||||
|
||||
One PR to `dev`, three commits: pure move, mechanical rewrite, graph refresh. The
|
||||
first two are the HP-1 structural review. `dev` is PR-only with 0 required
|
||||
approvals, so it is self-mergeable — but **do not merge before required checks
|
||||
are pinned** (B1-0), or this PR can merge red, which is exactly the risk it
|
||||
carries.
|
||||
|
||||
## B1-2 — Truth, entry points, and contributor path
|
||||
|
||||
Covers `RL-17/C-01`, `RL-12/R-06`, the `G-04` remnant, `RL-04/L-04`,
|
||||
`RL-20/L-14`, and `R-02`. All against post-flatten paths.
|
||||
|
||||
1. **One Node source of truth.** Add an `engines` block pinning Node 24 and the
|
||||
intended npm major to the root, `Client/`, and
|
||||
`tools/mcp-introspect/package.json` (the last is the only existing `engines`
|
||||
field and still says `>=20`). Add `.npmrc` with `engine-strict=true` so a
|
||||
wrong major fails fast. Fix `README.md`, `docs/contributing.md`,
|
||||
`docs/quick-start.md`. Keep `Client/.nvmrc` as the human-facing pin.
|
||||
2. **`docs/README.md`** — the landing page RL-12 asked for and B0 did not write.
|
||||
Active guidance, reference, historical audits, and plans (linking
|
||||
`docs/plans/README.md`). Link it from the root `README.md` docs index, which
|
||||
today links neither.
|
||||
3. **The G-04 automated check.** Smallest thing that works: a script that counts
|
||||
ledger statuses and fails when an active document states a conflicting count.
|
||||
Wire it into `ci.yml` as a fast job. Do not build a document-status framework.
|
||||
4. **Root command facade (`RL-04`).** Add `bootstrap`, `check`, `check:server`,
|
||||
`check:client`, `check:rust`, `format`, `generate`, `release:preflight`.
|
||||
Cross-platform: no `make`, no bash-only syntax. **Go-only contributors must
|
||||
never need Node** — the facade orchestrates, it does not become the only path.
|
||||
5. **Hooks (`RL-20`).** Three problems, one a live bug:
|
||||
- `.githooks/pre-commit`'s protocol branch guards on `command -v go` rather
|
||||
than `make`, so Go-without-`make` reports a false "protocol constants are
|
||||
stale" hard failure. Fix first; it is a two-line guard.
|
||||
- Give `sqlc-verify` and `protocol-verify` `make`-free equivalents in the root
|
||||
facade — `go run ./scripts/genprotocol` plus `git diff --exit-code` already
|
||||
works — and update the `ci-check` skill to match.
|
||||
- `npm run hooks:install` repoints `core.hooksPath` at `.githooks/`, which has
|
||||
no `post-commit`, silently disabling any locally installed one.
|
||||
Either ship a chaining `.githooks/post-commit` or document the exclusivity.
|
||||
6. **Branch policy (`R-02`).** One statement of the branch/PR model. Active
|
||||
documents currently disagree.
|
||||
|
||||
## B1-3 — Repository hygiene gates
|
||||
|
||||
`RL-19/L-13` and `S-05`. Add a root `.editorconfig`, widen Prettier beyond the
|
||||
client's TypeScript, add `cargo fmt --check`, repository-wide `gofmt`,
|
||||
`shellcheck`, and `actionlint`. Exclude `Server/db/dbgen/`, the
|
||||
generated client directory, and the untracked `docs/security-findings/`. **Land
|
||||
the gate and the reformat as two commits** so the world-reformat diff is
|
||||
reviewable separately from the rule that caused it.
|
||||
|
||||
## B1-4 — Dependency automation
|
||||
|
||||
`RL-05/L-05` and `RL-18` (owned by `R-04`/`R-07`). `dependabot.yml` has four
|
||||
blocks; the gaps are npm at the root and at `tools/mcp-introspect`, plus the
|
||||
`docker` ecosystem entirely. The two client directory entries also need the
|
||||
flatten's path rewrite. Record the workspace decision from measured install
|
||||
behaviour rather than adopting workspaces on principle.
|
||||
|
||||
## B1-5 — Ownership moves (one commit each)
|
||||
|
||||
- **`RL-09/L-09`** — relocate `docs/protocol-schema.json` to
|
||||
`protocol/schema.json` and the generator entry point to the root boundary.
|
||||
**Smaller than written:** the one-command verify already exists and is enforced
|
||||
three times over. Move a file and re-point its references; do not build a
|
||||
verify.
|
||||
- **`RL-10/L-10`** — **much smaller than written.** `init()` does not fire during
|
||||
test discovery. The real item is an untagged `package main` in the module's
|
||||
build graph: move it under `Server/cmd/seed/` and shift the `os.MkdirAll` out
|
||||
of `init()` into `main()` while there.
|
||||
- **`RL-11/L-11`** — reclassify the cross-stack tests. **Both directions exist**,
|
||||
and the client→server one has three e2e siblings. Decide the tier for the whole
|
||||
set; do not move one file and declare the class closed.
|
||||
- **`RL-13/L-12`** — align the Go module to `github.com/J3vb/OwnCord/Server`.
|
||||
Blast radius is bounded and known: 722 occurrences in 344 Go files, plus
|
||||
`go.mod`, one `sed` in `Server/Makefile`, two docs, and the ledger pair; zero
|
||||
in workflows or the Dockerfile. Own PR, verified like the flatten — scripted
|
||||
substitution, empty residual diff.
|
||||
|
||||
## B1-6 — Generated artifacts
|
||||
|
||||
- **`RL-06/L-06`** — **closed by deletion, before this phase opened.** `a5f7d95`
|
||||
(#1413) removed the tool and all 7 tracked files (20,408,656 bytes;
|
||||
`graph.json` 19,463,420). Nothing graphify-related is tracked, so the
|
||||
"prove portable regeneration, then untrack" sequence has no subject: there is
|
||||
nothing left to regenerate and no committed report to drift-check. History was
|
||||
not rewritten and is not going to be. B1-6 only retires the dead operational
|
||||
steps this plan still carried.
|
||||
- **`RL-07/L-07`** — **done.** `--check` returned before `render()` and never
|
||||
opened `FINDINGS.md`, so a stale 1.09 MB rendering passed the
|
||||
`Docs & Ledger Consistency` job cleanly. B1-6 landed the drift check first,
|
||||
then untracked the rendering — which removes the drift class entirely rather
|
||||
than watching it. `findings-ledger.json` stays the only tracked copy; CI
|
||||
renders twice, compares, and uploads the result as an artifact.
|
||||
- **`RL-08/L-08`** — source is committed; only the gate is missing, and it is
|
||||
**blocked by a toolchain conflict** (pinned TinyGo rejects Go 1.26, so a
|
||||
compile-and-compare job needs a second Go SDK). The cheaper honest option may
|
||||
be to untrack the prebuilt artifact and document the build, rather than run a
|
||||
two-SDK CI job for a disabled experimental subsystem.
|
||||
|
||||
## B1-7 — Community intake and automation authorization
|
||||
|
||||
- **`RL-21/L-15`** — worse than written: the templates are Markdown, not YAML
|
||||
issue forms, so nothing is structured, required, or validated, and the
|
||||
Environment block hardcodes one OS. Convert to YAML forms; add browser/PWA,
|
||||
CPU architecture, and deployment-mode fields; route ideas and feedback to
|
||||
Discussions.
|
||||
- **`RL-22/L-16`** — **done.** The workflow now states its own trust boundary,
|
||||
bounds each run's duration, collapses repeated triggers, and carries a
|
||||
regression test inside the pinned hygiene gate. The earlier claim that impact
|
||||
was bounded by read-only content permissions understated it — a run's effective
|
||||
identity is not only the workflow's token block. Mechanism and fix are
|
||||
coordinated privately per [docs/security.md](../security.md); they do not
|
||||
appear in public commits, issues, or PR descriptions.
|
||||
- **`RL-16/R-09`** — tag publication consumes exact-SHA gate evidence.
|
||||
|
||||
## B1-8 — Platform contract map (documentation only)
|
||||
|
||||
`RL-02/L-02`. Record the browser-neutral contract folders
|
||||
(`Client/src/platform/{contracts,browser,desktop}`) and their owners. **No native
|
||||
behaviour moves in B1.** Adapter extraction is B7 and must not be smuggled in.
|
||||
|
||||
**Done** — [docs/architecture/platform-contracts.md](../architecture/platform-contracts.md).
|
||||
Two corrections to what this item assumed:
|
||||
|
||||
- **`Client/src/platform/` does not exist**, and never has: no commits, no
|
||||
files, zero importers. The item reads as though the folders are there and
|
||||
need documenting. They are a target, so the document records a design
|
||||
decision, not a structure.
|
||||
- **No human owners exist for these folders anywhere in the repository.**
|
||||
Ownership is recorded by phase (B7 for the adapters and the static check, B8
|
||||
for the browser build, B2 for the protocol both adapters speak), and the
|
||||
document says the human-owner gap is real rather than letting the absence
|
||||
read as an oversight.
|
||||
|
||||
Measured rather than 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. The count is 26 and not 22 because `lib/ws.ts` binds
|
||||
`core.invoke` to a local `tauriInvoke` first — a regex matching only
|
||||
`invoke("…")` misses four commands, which any future lint rule enforcing the
|
||||
seam has to account for.
|
||||
|
||||
## Explicitly out of scope for B1
|
||||
|
||||
- Platform adapter extraction, `build:web`, or any browser/PWA work (B7/B8).
|
||||
- Fixing any of the 38 open `OC-*` findings — B1 only re-points their paths.
|
||||
- The 471 Oxlint warnings (`C-02`) and bundle budgets (`C-07`/`C-08`).
|
||||
- The ARM64 release matrix and multi-architecture Docker (`BG-20`, B6).
|
||||
- Server architecture, database seams, hub lifecycle (B3).
|
||||
- Renaming `Server/`, lowercasing `Client`/`Server`, or monorepo consolidation —
|
||||
the layout audit explicitly rejects all three.
|
||||
- Rewriting Git history to shrink the removed `graphify-out/` blobs. The files
|
||||
are gone from the tree (#1413), but four `graph.json` revisions remain in the
|
||||
pack — ~71 MiB logical, ~3.2 MiB packed of 13.28 MiB. They stay.
|
||||
- Editing dated audit files to match new paths.
|
||||
|
||||
## Traps carried forward from B0
|
||||
|
||||
- `dev` is PR-only, 0 approvals, enforced on admins. **Required checks are not
|
||||
pinned**, so a PR can still merge red until B1-0 lands.
|
||||
- The CI Docker job is `main`-gated and therefore **skipped on dev PRs**. Produce
|
||||
Docker evidence locally; on Git Bash for Windows, `MSYS_NO_PATHCONV=1` is
|
||||
required or MSYS path conversion reports a false boot failure.
|
||||
- `.nvmrc` and CI say Node 24; a local runtime may be newer. Every B0 number was
|
||||
measured on Node 26.
|
||||
- Verify with the `ci-check` skill — but note its `make` commands do not run on a
|
||||
plain Windows box, and its client paths change in B1-1.
|
||||
@@ -0,0 +1,147 @@
|
||||
# OwnCord beta product requirements
|
||||
|
||||
**Approved:** 2026-08-23
|
||||
**Release target:** first public beta after the `1.2.0-alpha.*` line
|
||||
**Planning model:** quality-gated, with no calendar deadline
|
||||
**Owner authority:** product decisions below are fixed; engineering may choose
|
||||
the safest and most performant implementation that satisfies them.
|
||||
|
||||
This document records the decisions that define “beta-ready.” It is the product
|
||||
input to the repository-health issue register and phased roadmap; it is not an
|
||||
implementation checklist by itself.
|
||||
|
||||
Companion documents:
|
||||
|
||||
- [repository-health issue register](repo-health-issue-register-2026-08-23.md);
|
||||
- [server-first beta roadmap](repo-health-roadmap-2026-08-23.md);
|
||||
- [repository-layout audit](../audit-2026-08-23-repository-layout.md).
|
||||
|
||||
## Release and scope
|
||||
|
||||
| ID | Requirement |
|
||||
| ------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| BPR-001 | Beta is a public GitHub release that anyone can download. |
|
||||
| BPR-002 | There is no deadline. A phase closes only when its evidence and exit gates are green. |
|
||||
| BPR-003 | Beta scope is frozen to this document. New ideas go to the post-beta backlog unless needed for security, correctness, accessibility, platform parity, or completion of an approved feature. |
|
||||
| BPR-004 | Existing alpha server data, attachments, configuration, credentials, and client settings must survive an in-place beta upgrade. |
|
||||
| BPR-005 | Unsigned payloads are deterministic where the platform permits, packaging is repeatable, and releases carry signed provenance/SBOM evidence. Checksums, signatures, update metadata, source snapshots, and the final published artifacts are verified before publication; timestamped platform signatures are not required to be byte-identical across rebuilds. |
|
||||
|
||||
## Supported platforms and delivery
|
||||
|
||||
| ID | Requirement |
|
||||
| ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| BPR-010 | Official desktop targets are Windows x64, Windows ARM64, Linux x64, and Linux ARM64. Native Windows ARM64 validation is available; Linux ARM64 may use cross-build and emulated smoke evidence until real hardware is available. |
|
||||
| BPR-011 | The supported server matrix is Windows x64/ARM64 executables, Linux x64/ARM64 archives, and multi-architecture Docker images for `linux/amd64` and `linux/arm64`. Docker is the primary deployment path; standalone binaries remain fully tested release assets. |
|
||||
| BPR-012 | Each server is independently hosted by its owner. The project operates no official OwnCord community server or identity service. |
|
||||
| BPR-013 | Internet-facing servers commonly run directly behind port forwarding. A reverse proxy must not be required. |
|
||||
| BPR-014 | Domain names and raw public IP addresses are supported connection addresses. |
|
||||
| BPR-015 | Public domains and stable public IPs use a built-in automatic public-CA certificate lifecycle. Private LAN/offline deployments use a server-generated local CA with guided, unavoidable one-time trust installation on each browser device. Owners may instead supply a certificate explicitly; no reverse proxy or recurring manual renewal is required by the default paths. |
|
||||
| BPR-016 | Private LAN-only and fully offline deployments are first-class supported modes. Offline browser use works after local certificate trust, while internet-dependent capabilities such as closed-app Web Push clearly report that they are unavailable. |
|
||||
|
||||
## Browser and PWA client
|
||||
|
||||
| ID | Requirement |
|
||||
| ------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| BPR-020 | A server may host the browser client only when its owner explicitly enables it; it is disabled by default. |
|
||||
| BPR-021 | The browser client targets desktop parity wherever browser APIs permit. Installer, desktop updater, system tray, and native OS integrations are desktop-only. |
|
||||
| BPR-022 | Phones and tablets are official browser targets. Layout, touch input, virtual keyboards, safe areas, media constraints, and accessibility must be tested. |
|
||||
| BPR-023 | The browser client is installable as a Progressive Web App with icons, standalone presentation, and safely cached application assets. |
|
||||
| BPR-024 | Background Web Push is supported where the browser and operating system permit it. It requires explicit owner and user opt-in, uses no OwnCord-operated relay, and degrades honestly on offline or unsupported systems. |
|
||||
| BPR-025 | The desktop and browser clients share product behavior and contracts rather than becoming divergent applications. |
|
||||
|
||||
## Capacity and compatibility
|
||||
|
||||
| ID | Requirement |
|
||||
| ------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| BPR-030 | The beta reference profile supports at least 250 registered users, 100 simultaneous connections, and 25 concurrent voice participants per server, backed by published measurements on stated hardware. |
|
||||
| BPR-031 | A server owner upgrades the server before users install the corresponding client update. |
|
||||
| BPR-032 | An upgraded server supports the current advertised protocol epoch and the previous two epochs (`N/N-1/N-2`). Patch releases that retain an epoch remain compatible; prerelease and release metadata declare their epoch explicitly. The server-bundled browser client matches its server and is not an independently versioned compatibility generation. A new client is not required to support an older server. |
|
||||
| BPR-033 | Connected users receive a clear update notification and can install the compatible client release. Clients outside the compatibility window fail safely with an actionable update requirement. |
|
||||
| BPR-034 | One client connects to one server at a time. Saved profiles remain isolated and easy to switch; background multi-server aggregation is outside beta. |
|
||||
| BPR-035 | One server-local account may have multiple simultaneous device sessions, with a device/session list, new-login notice, individual revocation, and sign-out-everywhere. |
|
||||
|
||||
## Identity, registration, and recovery
|
||||
|
||||
| ID | Requirement |
|
||||
| ------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| BPR-040 | Accounts and usernames are local to each server. There is no global OwnCord identity. |
|
||||
| BPR-041 | New servers default to invite-only registration. Owners may explicitly enable approval-based or open registration. |
|
||||
| BPR-042 | All messages, files, calls, and moderation features require an authenticated account. Anonymous guest access is outside beta. |
|
||||
| BPR-043 | Email is optional. Registration and recovery work without SMTP or any central service. |
|
||||
| BPR-044 | Account recovery uses a locally generated recovery kit whose server-side secrets are stored only in protected, non-reversible form and rotate after use. |
|
||||
| BPR-045 | Administrators may issue short-lived recovery credentials after local identity verification. Recovery revokes affected sessions and creates a safe audit record. |
|
||||
| BPR-046 | Existing TOTP multi-factor authentication and emergency recovery codes remain supported beta features. Optional SMTP recovery may be enabled, but SMTP and all external services remain nonessential to registration, login, and local recovery. Security questions are prohibited. |
|
||||
|
||||
## Privacy, deletion, and retention
|
||||
|
||||
| ID | Requirement |
|
||||
| ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| BPR-050 | OwnCord follows Discord's hybrid privacy model: text and files are available to the trusted server for delivery, search, moderation, and backup; voice, video, and screen sharing are end-to-end encrypted between participants. |
|
||||
| BPR-051 | The server-operator trust model is disclosed plainly. Transport and at-rest controls do not claim to hide stored text or files from the machine owner. |
|
||||
| BPR-052 | Account deletion erases the user's profile, credentials, sessions, messages, reactions, uploads, and other authored data rather than leaving attributed or anonymized content behind. |
|
||||
| BPR-053 | Necessary integrity records retain no identifying or content data after deletion. Immutable moderation/audit history survives only as an unlinkable event category, time, action class, and integrity proof after the subject mapping is cryptographically erased. Durable deletion markers prevent a later backup restore from silently resurrecting erased data. |
|
||||
| BPR-054 | Message history is retained indefinitely by default. Owners may configure automatic retention at server or channel scope, with corresponding attachment cleanup. |
|
||||
| BPR-055 | OwnCord sends no automatic product or usage telemetry. Diagnostics remain local and support-bundle export is user initiated. Any future crash reporting is explicit opt-in. |
|
||||
|
||||
## Messaging, content, and safety
|
||||
|
||||
| ID | Requirement |
|
||||
| ------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| BPR-060 | First-time direct messages enter a Message Requests inbox. A recipient may safely preview, accept, ignore, delete, or block; acceptance establishes a server-local trusted-sender relationship. |
|
||||
| BPR-061 | Link previews, GIF search, YouTube/media embeds, and existing rich external content remain supported and must meet the beta security, privacy, accessibility, failure-state, and performance gates. Provider expansion beyond the existing set is optional and otherwise post-beta. |
|
||||
| BPR-062 | Automatic external retrieval uses privacy-preserving, resource-bounded, SSRF-resistant boundaries with strict redirect, address, type, size, time, concurrency, cache, and offline behavior. |
|
||||
| BPR-063 | Owner-designated NSFW channels remain supported. They require explicit labels and per-user acknowledgement, with concealed previews and no automatic third-party media loading before consent. |
|
||||
| BPR-064 | English is the only officially supported beta language. User-facing text is organized so community translations can be added later without a rewrite. |
|
||||
|
||||
## Moderation
|
||||
|
||||
| ID | Requirement |
|
||||
| ------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| BPR-070 | Users can report messages, users, and attachments to that server's local moderators. There is no central OwnCord moderation service. |
|
||||
| BPR-071 | Desktop, browser, and PWA clients contain a permission-gated Moderation Center for the report queue, evidence and surrounding context, assignment, status, internal notes, actions, and immutable audit history. |
|
||||
| BPR-072 | Day-to-day moderator actions include warning, timeout, content removal, kick, and ban according to narrowly assigned role permissions. Operational TLS, backup, and update controls remain owner-only. |
|
||||
| BPR-073 | Moderated users can submit a rate-limited in-app appeal to local moderators and receive status updates. Appeal decisions are audited. |
|
||||
|
||||
## Extensions and deferred systems
|
||||
|
||||
| ID | Requirement |
|
||||
| ------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| BPR-080 | The WASM plugin runtime remains experimental and disabled by default. No beta plugin API compatibility promise is made because no supported plugins exist yet. |
|
||||
| BPR-081 | The audit identifies cohesive features and provider integrations that could become plugins later, without moving them behind the experimental runtime during beta. |
|
||||
| BPR-082 | Server federation, cross-server messaging, and federation-specific architecture work are outside beta. The idea may be reconsidered only after the beta codebase and operations are healthy. |
|
||||
| BPR-083 | There is no centralized public server directory. Owners distribute addresses and invite links themselves. |
|
||||
|
||||
## Client experience and accessibility
|
||||
|
||||
| ID | Requirement |
|
||||
| ------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| BPR-090 | Preserve OwnCord's recognizable visual identity and familiar workflows while improving consistency, responsiveness, accessibility, and performance. A wholesale visual rebrand is outside beta. |
|
||||
| BPR-091 | Accessibility is a release property across keyboard, pointer, touch, screen reader, reduced-motion, contrast, focus, zoom, and responsive layouts—not a later cosmetic pass. |
|
||||
| BPR-092 | Browser limitations and offline states are explicit. The UI does not present unavailable media, push, update, or network behavior as functional. |
|
||||
|
||||
## Community and governance
|
||||
|
||||
| ID | Requirement |
|
||||
| ------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| BPR-100 | GitHub Issues is the official bug tracker; GitHub Discussions hosts support, ideas, and community feedback. |
|
||||
| BPR-101 | Vulnerabilities use private GitHub security reporting and are not disclosed through public issues before coordinated remediation. |
|
||||
| BPR-102 | Community pull requests are welcome. Contributor documentation defines setup, scope, quality gates, generated files, review expectations, and safe security reporting. |
|
||||
| BPR-103 | Repository layout and contributor experience are audited before implementation. A restructure occurs only when evidence shows a durable improvement and is performed as an isolated, migration-safe phase. |
|
||||
|
||||
## Engineering-controlled choices
|
||||
|
||||
Within these product boundaries, implementation details such as cryptographic
|
||||
libraries, data structures, cache policy, browser/version matrix, performance
|
||||
budgets, backup schedule, CI topology, branch automation, module boundaries,
|
||||
and release mechanics are chosen for security, maintainability, and measured
|
||||
performance. Material tradeoffs and accepted risks must still be recorded.
|
||||
|
||||
## Explicitly outside beta
|
||||
|
||||
- server federation and cross-server identity;
|
||||
- native macOS, iOS, or Android applications;
|
||||
- more than one active server connection per client;
|
||||
- anonymous guest access or a public server directory;
|
||||
- a stable third-party plugin API or bundled third-party plugins;
|
||||
- an OwnCord-operated hosting, identity, telemetry, push, or moderation service;
|
||||
- unrelated feature expansion after this scope freeze.
|
||||
@@ -0,0 +1,187 @@
|
||||
# OwnCord beta requirement traceability
|
||||
|
||||
**Prepared:** 2026-08-23
|
||||
**Requirements source:**
|
||||
[beta-product-requirements-2026-08-23.md](beta-product-requirements-2026-08-23.md)
|
||||
**Execution source:**
|
||||
[public-beta roadmap](repo-health-roadmap-2026-08-23.md)
|
||||
**Structural input:**
|
||||
[repository-layout audit](../audit-2026-08-23-repository-layout.md)
|
||||
**Current status:** all 57 requirements are mapped; none is release-qualified
|
||||
|
||||
## How to use this document
|
||||
|
||||
- The primary phase owns the implementation invariant, coordinates any
|
||||
downstream consumers, and records the first acceptance evidence.
|
||||
- A phase range means every earlier exit gate in that range is a prerequisite.
|
||||
- Some server-owned requirements need later client proof. Those rows name B7,
|
||||
B8, or B9 evidence explicitly and remain open until that proof exists.
|
||||
- B10 repeats every applicable verification against one immutable release
|
||||
candidate. Earlier green evidence cannot substitute for release evidence.
|
||||
- Security-sensitive proof is linked from a private advisory. The public row
|
||||
records only the security property, test category, and pass/fail status.
|
||||
- The canonical product wording remains in the requirements source. Short
|
||||
labels here are navigation aids, not replacements.
|
||||
|
||||
## Phase ownership
|
||||
|
||||
| Phase | Primary requirement IDs |
|
||||
| ---------------------------------------------------- | ---------------------------------------------------------------------------------------- |
|
||||
| B0 — truth and scope | BPR-002, BPR-003 |
|
||||
| B1 — repository and contributor foundation | BPR-100, BPR-101, BPR-102, BPR-103 |
|
||||
| B2 — server protocol, trust, and compatibility | BPR-031, BPR-032, BPR-040, BPR-050, BPR-051, BPR-080, BPR-081, BPR-082, BPR-083 |
|
||||
| B3 — server architecture and guardrails | No direct product requirement; mandatory prerequisite for B4–B10 |
|
||||
| B4 — identity, recovery, privacy, and data lifecycle | BPR-041, BPR-042, BPR-043, BPR-044, BPR-045, BPR-046, BPR-052, BPR-053, BPR-054, BPR-055 |
|
||||
| B5 — community, content, and moderation services | BPR-060, BPR-061, BPR-062, BPR-063, BPR-070, BPR-071, BPR-072, BPR-073 |
|
||||
| B6 — deployment, operations, and capacity | BPR-011, BPR-012, BPR-013, BPR-014, BPR-015, BPR-016, BPR-030 |
|
||||
| B7 — shared client platform and desktop parity | BPR-033, BPR-034, BPR-035 |
|
||||
| B8 — browser, PWA, phone, and tablet | BPR-020, BPR-021, BPR-022, BPR-023, BPR-024, BPR-025 |
|
||||
| B9 — unified UX, accessibility, and polish | BPR-064, BPR-090, BPR-091, BPR-092 |
|
||||
| B10 — qualification and release | BPR-001, BPR-004, BPR-005, BPR-010; final proof for every row |
|
||||
|
||||
## Release and scope
|
||||
|
||||
| ID | Short label | Primary phase | Prerequisites | Minimum verification and closure evidence |
|
||||
| ------- | -------------------------------------- | ------------- | ------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| BPR-001 | Public GitHub beta | B10 | B0–B9; BPR-005 and BPR-010 | Public release points to the qualified tag; an unauthenticated user can download every declared asset; install/cold-boot smoke succeeds; release page, source, checksums, signatures, SBOM, provenance, and support links agree. |
|
||||
| BPR-002 | No deadline; evidence gates | B0 | Approved requirements | Every phase template omits calendar-based completion, records exact-SHA evidence, and blocks closure when any exit row is red or unavailable. B10 confirms all phase scorecards are green. |
|
||||
| BPR-003 | Frozen beta scope | B0 | Approved requirements; canonical issue intake | Issue/Discussion triage maps work to a BPR or post-beta label; phase diffs show no unapproved feature; any exception records why it is required for security, correctness, accessibility, parity, or an approved feature. |
|
||||
| BPR-004 | In-place alpha-to-beta upgrade | B10 | B2 protocol; B4 migrations/deletion markers; B6 deployment/backup; B7 client settings | Upgrade representative alpha Docker and standalone datasets to the RC and verify row/file/config/credential/attachment/client-setting checksums and behavior; rehearse interrupted upgrade, restart, backup restore, and declared rollback without loss. |
|
||||
| BPR-005 | Deterministic, signed release evidence | B10 | B1 release gates; B6 supply chain; B7/B8 artifacts | Rebuild unsigned payloads twice where the platform permits and compare; repeat packaging; verify timestamped-signature exception; validate checksums, signatures, update metadata, source snapshot, signed SBOM/provenance, cold boot, and exact published bytes. |
|
||||
|
||||
## Supported platforms and delivery
|
||||
|
||||
| ID | Short label | Primary phase | Prerequisites | Minimum verification and closure evidence |
|
||||
| ------- | --------------------------------------- | ------------- | ------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| BPR-010 | Four desktop targets | B10 | B6 server artifacts; B7 desktop; B8/B9 shared-client qualification | Windows x64, native Windows ARM64, Linux x64, and Linux ARM64 packages build and pass install, boot, connect, media, update, rollback, and recovery smoke. Linux ARM64 evidence states whether it is cross-build/emulated or real hardware. |
|
||||
| BPR-011 | Complete server artifact matrix | B6 | B1 CI/release foundation; B3 lifecycle | Windows x64/ARM64 executables, Linux x64/ARM64 archives, and Docker linux/amd64 plus linux/arm64 are published from one commit; every artifact cold-boots, migrates, reports healthy, serves traffic, drains, restarts, and preserves data. |
|
||||
| BPR-012 | Independently owner-hosted | B6 | B2 central-dependency audit; B4 local identity/recovery | Fresh deployment, registration, login, messaging, moderation, update verification, recovery, and backup work without an OwnCord account/service; controlled network capture shows no undeclared central dependency. |
|
||||
| BPR-013 | No required reverse proxy | B6 | B2 trust contracts; B6 TLS configuration | Domain and public-IP deployments work through documented direct port forwarding with the built-in TLS path; a clean install contains no reverse-proxy prerequisite; blocked-port/CGNAT limits fail actionably. |
|
||||
| BPR-014 | Domain and raw public IP | B6 | BPR-013; certificate-mode design | Automated integration covers DNS name, eligible stable IPv4, and eligible stable IPv6 origins through HTTPS/WSS, reconnect, update, and browser-origin checks; address changes and unsupported cases are explicit. |
|
||||
| BPR-015 | Automatic public CA and guided local CA | B6 | B2 trust model; protected persistent configuration | ACME staging covers public domain and eligible public IP issue/renew/expiry/hot reload; LAN/offline covers local-CA generation, fingerprint, one-time trust install, rotation, and removal on supported browser devices; owner-supplied certificate mode also passes. |
|
||||
| BPR-016 | LAN-only and fully offline | B6 | BPR-015; B4 local recovery; B5 offline service behavior | After local trust, server and client cold-boot, authenticate, message, call where local media permits, back up, restore, and update from local artifacts with internet blocked; internet-dependent push/provider features report unavailable without retry storms. |
|
||||
|
||||
## Browser and PWA client
|
||||
|
||||
| ID | Short label | Primary phase | Prerequisites | Minimum verification and closure evidence |
|
||||
| ------- | ---------------------------------------- | ------------- | ---------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| BPR-020 | Optional server-hosted browser client | B8 | B6 default-off host/origin contract; B7 web contract build | Clean server exposes no browser application while disabled; owner enablement serves the version-matched signed bundle; disable removes access without affecting API/desktop; upgrade and rollback keep the setting and bundle consistent. |
|
||||
| BPR-021 | Browser desktop parity within API limits | B8 | B5 services; B7 shared contracts; BPR-020 | Requirement-journey comparison passes on desktop and supported browsers; every intentional difference has an API-based rationale and honest UI; installer, updater, tray, and native integrations remain desktop-only. |
|
||||
| BPR-022 | Phone and tablet browser support | B8 | B7 contracts; responsive navigation design; BPR-021 | Real Android phone/tablet and iPhone/iPad plus automation cover navigation, touch targets, virtual keyboard, safe areas, orientation, zoom/reflow, media constraints, screen reader, and recovery/moderation workflows. |
|
||||
| BPR-023 | Installable PWA | B8 | BPR-020; secure origin; cache policy | Manifest/icons/standalone launch pass installability checks; service worker is correctly scoped; only approved application assets cache; version change, stale cache, offline fallback, logout, and server rollback do not expose messages, credentials, attachments, or moderator evidence. |
|
||||
| BPR-024 | Opt-in Web Push without OwnCord relay | B8 | B5 per-server push backend; B6 HTTPS; BPR-023 | Owner-disabled, user-denied, subscribed, revoked, expired, 404/410 cleanup, VAPID rotation, click routing, offline, iOS installed-PWA, and unsupported cases pass; default payload is generic and network inspection shows no OwnCord relay. |
|
||||
| BPR-025 | One shared product and contracts | B8 | B1 target layout; B7 platform contracts; BPR-021 | Static checks keep native imports inside desktop ownership; the same domain/store/protocol suites run against desktop and browser adapters; no copied feature implementation or independently versioned browser protocol exists. |
|
||||
|
||||
## Capacity and compatibility
|
||||
|
||||
| ID | Short label | Primary phase | Prerequisites | Minimum verification and closure evidence |
|
||||
| ------- | -------------------------------------- | ------------- | --------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| BPR-030 | 250/100/25 reference profile | B6 | B3 benchmarks/simulation; B5 completed services | Reproducible load run on stated hardware sustains 250 registered users, 100 simultaneous connections, and 25 concurrent voice participants; publish configuration, duration, p50/p95/p99 latency, errors, CPU, memory, disk/database waits, network, reconnect, and recovery behavior. |
|
||||
| BPR-031 | Server upgrades first | B2 | B1 protocol owner and release metadata | Update-state tests prove the old server never directs users to an incompatible new client; server upgrade exposes signed compatible-client metadata; operator and client wording describes the sequence. |
|
||||
| BPR-032 | Protocol epochs N/N-1/N-2 | B2 | B1 generated protocol gate; BPR-031 | Fixtures for epochs N, N-1, and N-2 connect and exercise required journeys; N-3 rejects safely; patch versions within an epoch interoperate; prerelease/release metadata declares epoch; bundled browser version always matches server. |
|
||||
| BPR-033 | Update notice and safe incompatibility | B7 | BPR-031 and BPR-032; signed update metadata | Connected clients in-window receive a clear notice and can verify/install the compatible release; incompatible clients show an actionable non-destructive requirement; tampered, missing, offline, rollback, and user-deferral cases pass. |
|
||||
| BPR-034 | One active server connection | B7 | BPR-040; isolated profile storage; platform contracts | Instrumented unit/E2E tests prove only one live server transport/media session exists; switching tears down old resources, isolates credentials/cache/notifications, preserves profiles, and never aggregates background servers. |
|
||||
| BPR-035 | Multiple device sessions | B7 | B4 session inventory/revocation and login events; BPR-034 | Two or more devices remain active for one account; list labels/current-device state are correct; new-login notice appears; individual revoke affects only its target; sign-out-everywhere revokes all tokens and live connections. |
|
||||
|
||||
## Identity, registration, and recovery
|
||||
|
||||
| ID | Short label | Primary phase | Prerequisites | Minimum verification and closure evidence |
|
||||
| ------- | ------------------------------------------------- | ------------- | ----------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| BPR-040 | Server-local accounts | B2 | B1 protocol boundary; no central identity | The same username can represent unrelated identities on two servers; credentials, sessions, recovery, profiles, and moderation never cross; dependency/network audit finds no global identifier or OwnCord identity lookup. |
|
||||
| BPR-041 | Invite-only default, optional approval/open | B4 | B3 configuration/domain boundaries; BPR-040 | Fresh install is invite-only; valid/expired/revoked/concurrent invite tests pass; explicit transitions to approval/open and back are audited; upgrade preserves the owner's chosen mode without silently opening registration. |
|
||||
| BPR-042 | Authentication required; no guests | B4 | B2 canonical auth/authz; BPR-041 | Anonymous requests and sockets cannot message, upload, call, report, moderate, or fetch protected data; revoked/expired/partial sessions fail uniformly; UI and public docs expose no guest path. |
|
||||
| BPR-043 | Email optional and no central recovery dependency | B4 | BPR-040; local recovery design | Registration, login, recovery, device revocation, and administration pass with SMTP unset and internet blocked; optional email absence never blocks account creation or local recovery. |
|
||||
| BPR-044 | Rotating local recovery kit | B4 | B3 secret-storage guardrails; BPR-043 | Kit generation/recovery/replay/concurrency/restart tests prove only protected non-reversible server material is stored; one successful use rotates/invalidates it; logs, audit, backup, and support bundles contain no usable secret. |
|
||||
| BPR-045 | Admin-assisted short-lived recovery | B4 | BPR-044; canonical audit/session revocation | Issuance requires authorized admin and recorded local-verification decision; credential expires, is single-use and rate-limited; success revokes affected sessions; unauthorized, replay, concurrent, restart, and audit-redaction tests pass. |
|
||||
| BPR-046 | TOTP, emergency codes, optional SMTP | B4 | BPR-043–BPR-045; current MFA migration fixtures | Existing TOTP and emergency recovery codes survive upgrade/restart and pass enrollment, verification, replay, rotation, exhaustion, clock-skew, revocation, and recovery tests; SMTP failure cannot block local paths; configuration/UI contain no security questions. |
|
||||
|
||||
## Privacy, deletion, and retention
|
||||
|
||||
| ID | Short label | Primary phase | Prerequisites | Minimum verification and closure evidence |
|
||||
| ------- | -------------------------------------------------- | ------------- | ----------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| BPR-050 | Hybrid privacy and media E2EE | B2 | B1 protocol ownership; private threat model | Storage inspection confirms the trusted server can deliver/search/moderate/backup text and files; authenticated media interoperability and adversarial membership/rekey/removal tests demonstrate participant E2EE for voice, video, and screen share; docs state the boundary accurately. |
|
||||
| BPR-051 | Plain server-operator trust disclosure | B2 | BPR-050; B1 docs source of truth | Setup, privacy, backup, moderator, and client disclosures say the machine owner can access stored text/files and distinguish transport/at-rest controls from E2EE; technical review and user comprehension check find no contradictory claim. |
|
||||
| BPR-052 | Erase all user-authored data | B4 | B3 data ownership inventory; BPR-042; backup fixtures | Deletion traverses profile, credentials, sessions, messages, reactions, uploads, thumbnails/cache, request/report references, and every later data class; pre/post database and storage inventory is empty for the subject; interruption resumes safely; B7/B9 UI confirms impact and completion. |
|
||||
| BPR-053 | Unlinkable integrity history and anti-resurrection | B4 | BPR-052; cryptographic mapping and backup design | After deletion, audit/moderation rows retain only allowed event category, time, action class, and integrity proof; subject/content mapping key is cryptographically erased; correlation attempts fail; restore of an older backup reapplies the durable deletion marker and cannot resurrect data. |
|
||||
| BPR-054 | Indefinite default and configurable retention | B4 | B3 scheduler/lifecycle; BPR-052/BPR-053 | Fresh and upgraded servers default to indefinite history; server/channel policies handle precedence, clock boundaries, restart, batches, attachments, cache/search, reports/audit, deletion, and disk pressure; owner UI/docs preview and confirm effects. |
|
||||
| BPR-055 | No automatic telemetry | B4 | B1 dependency inventory; local diagnostics design | Network capture across install, startup, use, crash, update check, offline, and support workflows shows no automatic product/usage reporting; support bundle requires user action and passes secret/content review; any future crash option defaults off and records consent. |
|
||||
|
||||
## Messaging, content, and safety
|
||||
|
||||
| ID | Short label | Primary phase | Prerequisites | Minimum verification and closure evidence |
|
||||
| ------- | --------------------------------------- | ------------- | ----------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| BPR-060 | Message Requests | B5 | B4 identity/block/deletion/retention; B3 state-machine guardrails | Server state/property tests cover pending, safe preview, accept, ignore, delete, block, races, reconnect, multi-device, retention, and deletion; B9 desktop/browser/mobile E2E proves inbox UX and that only acceptance creates server-local trust. |
|
||||
| BPR-061 | Preserve existing rich content safely | B5 | BPR-062; current provider/feature inventory | Existing link preview, GIF search, YouTube/media embed, and rich-content journeys pass security, privacy, accessibility, offline/failure, and performance budgets on B9 clients; provider expansion is not required and any new provider maps to post-beta unless separately justified. |
|
||||
| BPR-062 | Bounded privacy-safe external retrieval | B5 | B2 trust model; B3 bounded-work guardrails | Private adversarial suite covers DNS rebinding/resolution, IPv4/IPv6/private addresses, redirect chains, type sniffing, compressed/streamed size, timeout, concurrency, cache partition/expiry, residual buffering, cancellation, and offline behavior for every fetch path. |
|
||||
| BPR-063 | NSFW consent before load | B5 | BPR-062; per-user preference/authz storage | Server and B9 client tests prove explicit owner label plus per-user acknowledgement; previews stay concealed; network inspection confirms content and third-party media are not requested before consent; revoke, new device, logout, accessibility, and moderator cases pass. |
|
||||
| BPR-064 | English-only, translation-ready | B9 | B7/B8 shared UI; frozen beta strings | All user-facing strings are inventoried behind translation-ready boundaries with no required second language; dynamic/plural/error/accessibility strings are covered; static scan finds unjustified hard-coded UI text; layout tolerates representative expansion. |
|
||||
|
||||
## Moderation
|
||||
|
||||
| ID | Short label | Primary phase | Prerequisites | Minimum verification and closure evidence |
|
||||
| ------- | ---------------------------------- | ------------- | ---------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| BPR-070 | Local reports only | B5 | B4 identity/deletion/retention; B2 permissions | Authenticated users can report message/user/attachment to their own server; cross-server/central delivery is impossible; duplicate/rate-limit/block/deleted-target/access-control tests pass; B9 clients expose the flows without leaking reporter/evidence. |
|
||||
| BPR-071 | Permission-gated Moderation Center | B5 | BPR-070; B2 audit/authz; B7/B8 clients | Service tests cover queue, evidence/context, assignment, status, notes, action links, immutable history, retention, and deletion unlinking; B9 desktop/browser/PWA E2E proves permitted roles see correct data and all others see none. |
|
||||
| BPR-072 | Narrow moderator actions | B5 | BPR-071; canonical effective permissions | Role matrix and adversarial tests cover warning, timeout, removal, kick, ban, hierarchy, self/peer/owner targets, concurrent changes, voice/text effects, and audit; B9 UI hides/blocks unauthorized actions; TLS/backup/update remain owner-only. |
|
||||
| BPR-073 | Rate-limited local appeals | B5 | BPR-071/BPR-072; B4 rate limits/notifications | State/property tests cover submission, rate limit, assignment, status, decision, notification, repeat/closed/blocked users, deletion, retention, and audit; B9 clients show only authorized appeal content and accurate status. |
|
||||
|
||||
## Extensions and deferred systems
|
||||
|
||||
| ID | Short label | Primary phase | Prerequisites | Minimum verification and closure evidence |
|
||||
| ------- | ------------------------------------- | ------------- | ------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| BPR-080 | Experimental WASM disabled by default | B2 | B1 artifact provenance; configuration audit | Fresh, upgraded, Docker, and standalone configurations leave WASM disabled; release/docs/UI mark it experimental with no compatibility promise; example WASM is reproducible or provenance-verified; enabling requires explicit owner action. |
|
||||
| BPR-081 | Identify future plugin candidates | B2 | B0 audit; B3 boundary inventory | Architecture note lists cohesive candidates and why they are separable, while explicitly keeping auth/authz, TLS, safe fetch, quota, E2EE, updater, deletion, recovery, and moderation audit in core; no beta feature is moved to WASM. |
|
||||
| BPR-082 | No federation in beta | B2 | B0 scope freeze; BPR-040 | Protocol/API/config/release review finds no federation, cross-server identity, or cross-server messaging feature; plans/issues route the idea post-beta; generic work is accepted only when justified by a current non-federation requirement. |
|
||||
| BPR-083 | No centralized public directory | B2 | BPR-012 and BPR-040 | Server/client/config/network review finds no automatic listing, discovery submission, central browse/search, or OwnCord directory dependency; owner-shared addresses and invite links pass connect/onboarding tests. |
|
||||
|
||||
## Client experience and accessibility
|
||||
|
||||
| ID | Short label | Primary phase | Prerequisites | Minimum verification and closure evidence |
|
||||
| ------- | ------------------------------------ | ------------- | -------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| BPR-090 | Preserve and polish OwnCord identity | B9 | B7 desktop baseline; B8 responsive client; design tokens | Visual regression and owner review show recognizable navigation/identity and familiar workflows across targets; consistency, responsiveness, accessibility, startup, interaction, and bundle budgets improve or meet accepted baselines; no wholesale rebrand. |
|
||||
| BPR-091 | Accessibility is release-blocking | B9 | B7 semantic foundations; B8 mobile/browser behavior | Automated accessibility plus manual keyboard, pointer, touch, screen reader, reduced-motion, contrast, focus, zoom/reflow, virtual-keyboard, safe-area, error/announcement, and media-control checks pass every critical journey on supported targets. |
|
||||
| BPR-092 | Honest browser/offline limitations | B9 | B6 deployment modes; B8 capability detection | Browser/device/network matrix verifies unavailable media, push, screen capture, updater, native integration, certificate, and offline behavior is disabled or explained actionably; no control falsely reports success; recovery after capability/network return passes. |
|
||||
|
||||
## Community and governance
|
||||
|
||||
| ID | Short label | Primary phase | Prerequisites | Minimum verification and closure evidence |
|
||||
| ------- | ------------------------------------------- | ------------- | -------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| BPR-100 | Issues for bugs; Discussions for community | B1 | B0 scope/intake model | Repository navigation, issue forms, blank-issue policy, Discussions links, support links, and contribution docs route bugs to Issues and support/ideas/feedback to Discussions; dry-run submissions reach the intended destination. |
|
||||
| BPR-101 | Private coordinated vulnerability reporting | B1 | B0 private/public handling; repository security settings | Security policy and forms point to private GitHub reporting; public templates warn against disclosure; permissions and a tabletop report prove private receipt, triage, advisory, fix, coordinated disclosure, and safe public status. |
|
||||
| BPR-102 | Community pull requests supported | B1 | B0 gates; root command facade | A fresh Windows and Linux contributor follows docs to bootstrap, run scoped/full checks, understand scope/generated files/review/security rules, and submit a passing sample change; CI feedback matches local commands. |
|
||||
| BPR-103 | Evidence-based isolated restructure | B1 | Layout audit; green B0 baseline | Targeted migration uses adjacent pure-move and mechanical-path-rewrite commits, preserves release names/desktop behavior/history, proves all active references and generated ownership, and passes the complete exact-SHA matrix; no wholesale repository/server rewrite occurs. |
|
||||
|
||||
## Cross-cutting qualification rule
|
||||
|
||||
A row may be marked:
|
||||
|
||||
- planned: prerequisites or implementation have not started;
|
||||
- in progress: the primary phase owns active work;
|
||||
- implemented, awaiting downstream proof: the server/core invariant is green
|
||||
but a named client or deployment journey is not;
|
||||
- phase-verified: all evidence named in the row is green on that phase commit;
|
||||
- release-qualified: B10 repeated the evidence on the immutable release
|
||||
candidate.
|
||||
|
||||
Only release-qualified satisfies beta. No row is release-qualified at the
|
||||
audited head; implementation maturity ranges from an existing partial
|
||||
foundation to entirely absent and must be refreshed during B0.
|
||||
|
||||
## Completeness check
|
||||
|
||||
The map contains exactly the approved IDs:
|
||||
|
||||
- BPR-001 through BPR-005;
|
||||
- BPR-010 through BPR-016;
|
||||
- BPR-020 through BPR-025;
|
||||
- BPR-030 through BPR-035;
|
||||
- BPR-040 through BPR-046;
|
||||
- BPR-050 through BPR-055;
|
||||
- BPR-060 through BPR-064;
|
||||
- BPR-070 through BPR-073;
|
||||
- BPR-080 through BPR-083;
|
||||
- BPR-090 through BPR-092;
|
||||
- BPR-100 through BPR-103.
|
||||
|
||||
Gaps in the numeric sequence are intentional category spacing, not missing
|
||||
requirements.
|
||||
@@ -3,7 +3,7 @@
|
||||
Date: 2026-08-08
|
||||
Status: partially implemented (verified 2026-08-19) — Tier 1a's `make fuzz`
|
||||
target exists (`Server/Makefile`) and Tier 2's five custom ESLint rules
|
||||
shipped 2026-08-08 (`Client/tauri-client/eslint-rules.js`), so the gap table
|
||||
shipped 2026-08-08 (`Client/eslint-rules.js`), so the gap table
|
||||
below is stale for those two rows; Tiers 1b/1c are on-demand npm scripts;
|
||||
Tiers 3–4 remain unimplemented.
|
||||
|
||||
@@ -20,12 +20,12 @@ by yield per token spent.
|
||||
|
||||
## What already exists and does not run
|
||||
|
||||
| Asset | State | Gap |
|
||||
| --- | --- | --- |
|
||||
| 14 `Fuzz*` harnesses under `Server/**/*_fuzz_test.go` | Committed | `go test ./...` runs a `Fuzz*` function against its **seed corpus only** — one pass per seed, zero generated inputs. `-fuzz` appears nowhere in the repo. |
|
||||
| Stryker mutation testing | `stryker.config.mjs` + `npm run test:mutate` | Referenced in `ci.yml` only inside an npm-audit comment. Has never run. |
|
||||
| Browser-mode vitest | `vitest.config.browser.ts` + `npm run test:browser` | CI runs jsdom only. |
|
||||
| Cross-package coverage | `make cover-all` prints every 0.0%-covered function | Output is not fed to anything. |
|
||||
| Asset | State | Gap |
|
||||
| ----------------------------------------------------- | --------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| 14 `Fuzz*` harnesses under `Server/**/*_fuzz_test.go` | Committed | `go test ./...` runs a `Fuzz*` function against its **seed corpus only** — one pass per seed, zero generated inputs. `-fuzz` appears nowhere in the repo. |
|
||||
| Stryker mutation testing | `stryker.config.mjs` + `npm run test:mutate` | Referenced in `ci.yml` only inside an npm-audit comment. Has never run. |
|
||||
| Browser-mode vitest | `vitest.config.browser.ts` + `npm run test:browser` | CI runs jsdom only. |
|
||||
| Cross-package coverage | `make cover-all` prints every 0.0%-covered function | Output is not fed to anything. |
|
||||
|
||||
Separately, three of the codebase's sharpest invariants are documented in
|
||||
`CLAUDE.md` files as prose and asserted nowhere:
|
||||
@@ -44,7 +44,7 @@ Prose fails no build.
|
||||
GitHub Actions.**
|
||||
|
||||
Rationale: `go test -fuzz` writes each crashing input to
|
||||
`testdata/fuzz/<Target>/<hash>`, and that file *is* a working reproducer. The
|
||||
`testdata/fuzz/<Target>/<hash>`, and that file _is_ a working reproducer. The
|
||||
root `CLAUDE.md` states: "This repo is public — unfixed defects do not belong
|
||||
in commits, issues, or PR descriptions." Actions artifacts on a public repo are
|
||||
downloadable by anyone, and a red scheduled job is itself a public signal that
|
||||
@@ -76,7 +76,7 @@ has no native Windows support (WSL or Docker only), so on this machine it would
|
||||
join `make` as tooling that cannot be run locally. ESLint flat config supports
|
||||
an inline plugin, so custom rules cost no new dependency — and `npx eslint
|
||||
src/` is already a blocking CI gate, which removes the promotion step entirely.
|
||||
Rules live in `Client/tauri-client/eslint-rules.js`, tested with `RuleTester`
|
||||
Rules live in `Client/eslint-rules.js`, tested with `RuleTester`
|
||||
in `tests/unit/eslint-rules.test.ts`. See "Tier 2 — delivered" below.
|
||||
|
||||
## Tier 1 — Turn on what already exists
|
||||
@@ -135,7 +135,7 @@ configured surface that observes that class.
|
||||
|
||||
### 1d. Prerequisite
|
||||
|
||||
Confirm `Client/tauri-client/reports/`, `Client/tauri-client/.stryker-tmp/`,
|
||||
Confirm `Client/reports/`, `Client/.stryker-tmp/`,
|
||||
`Server/coverage-all.out`, and `Server/**/testdata/fuzz/` interim output are
|
||||
covered by `.gitignore` before running any of the above. Add entries where
|
||||
they are missing.
|
||||
@@ -144,7 +144,7 @@ they are missing.
|
||||
|
||||
Roughly 200 confirmed real bugs have been fixed across the hunt and harvest
|
||||
runs. Each one currently bought exactly one fix. Encoding the recurring
|
||||
*classes* converts them into permanent detectors.
|
||||
_classes_ converts them into permanent detectors.
|
||||
|
||||
**Sources to mine:** bughunt commit history on `fix/bughunt-*` and
|
||||
`fix/bughunt-harvest-*` branches, `.superpowers/harvest-med-low-checklist.md`,
|
||||
@@ -159,13 +159,13 @@ positive fixture that must match and a negative fixture that must not.
|
||||
Five rules, all scoped to the modules their invariant governs, all proven to
|
||||
fire by reintroducing the historical bug shape into real source and reverting:
|
||||
|
||||
| Rule | Encodes |
|
||||
| --- | --- |
|
||||
| Rule | Encodes |
|
||||
| -------------------------------- | ------------------------------------------------------------------------------------------------------------- |
|
||||
| `no-leave-voice-when-superseded` | A global `leaveVoice()` inside a branch that already confirmed supersession tears down the newer live session |
|
||||
| `e2ee-epoch-needs-keypair-check` | A non-key-holder never bumps the epoch, so an epoch-only staleness guard cannot see a restarted session |
|
||||
| `e2ee-verified-status-literal` | Keeps `"verified"` tied to a hand-written call site that earned it, never a computed status |
|
||||
| `no-identity-scope-fallback` | A `?? 0` placeholder scope mints a keypair under the wrong account |
|
||||
| `no-store-write-in-ws-on` | Page-local `ws.on` handlers may read stores, not write them |
|
||||
| `e2ee-epoch-needs-keypair-check` | A non-key-holder never bumps the epoch, so an epoch-only staleness guard cannot see a restarted session |
|
||||
| `e2ee-verified-status-literal` | Keeps `"verified"` tied to a hand-written call site that earned it, never a computed status |
|
||||
| `no-identity-scope-fallback` | A `?? 0` placeholder scope mints a keypair under the wrong account |
|
||||
| `no-store-write-in-ws-on` | Page-local `ws.on` handlers may read stores, not write them |
|
||||
|
||||
**Declined: `await`-then-stale-snapshot.** Not AST-expressible. Whether an
|
||||
await needs a guard — and whether the guard present is sufficient and correctly
|
||||
@@ -179,7 +179,7 @@ correct code gets disabled and trains people to ignore the linter.
|
||||
`CLAUDE.md` was factually wrong. It claimed `ws.on(...)` appears only in
|
||||
`dispatcher.ts`; eight handlers across `main.ts`, `MainPage.ts` and
|
||||
`ChannelController.ts` say otherwise. The true invariant — dispatcher is the
|
||||
single path by which server events *write to stores* — is what the rule
|
||||
single path by which server events _write to stores_ — is what the rule
|
||||
encodes, and the doc has been corrected to match.
|
||||
|
||||
**Still open:** the server-side `ws` seq/FIFO invariant, which needs a Go
|
||||
@@ -230,7 +230,7 @@ The 2026-08-08 client hunt fixed 101 bugs and still did not converge. Four
|
||||
changes, cheapest first:
|
||||
|
||||
1. **Persistent seen-ledger.** Key on `(file, symbol, class)` and persist
|
||||
*across* runs, not only within one. Each run currently starts cold and
|
||||
_across_ runs, not only within one. Each run currently starts cold and
|
||||
re-derives ground already covered — the most likely reason convergence never
|
||||
arrives.
|
||||
2. **Sibling-sweep lens.** For every confirmed bug, enumerate the other callers
|
||||
@@ -245,15 +245,15 @@ changes, cheapest first:
|
||||
|
||||
## Order and effort
|
||||
|
||||
| Step | Effort | Runs in |
|
||||
| --- | --- | --- |
|
||||
| 1a `make fuzz` | 15 min to write | 10 min/sweep unattended |
|
||||
| 1b Stryker hotspots | 0 (already configured) | ~25 min for 3 files |
|
||||
| 1d gitignore check | 5 min | — |
|
||||
| 2 first four semgrep rules | ~1 afternoon | seconds |
|
||||
| 4.1 + 4.2 ledger and sibling lens | ~2 hours | within existing hunt |
|
||||
| 1c browser-mode vitest | 0 | minutes |
|
||||
| 3 model-based and chaos harnesses | ~1 day | minutes |
|
||||
| Step | Effort | Runs in |
|
||||
| --------------------------------- | ---------------------- | ----------------------- |
|
||||
| 1a `make fuzz` | 15 min to write | 10 min/sweep unattended |
|
||||
| 1b Stryker hotspots | 0 (already configured) | ~25 min for 3 files |
|
||||
| 1d gitignore check | 5 min | — |
|
||||
| 2 first four semgrep rules | ~1 afternoon | seconds |
|
||||
| 4.1 + 4.2 ledger and sibling lens | ~2 hours | within existing hunt |
|
||||
| 1c browser-mode vitest | 0 | minutes |
|
||||
| 3 model-based and chaos harnesses | ~1 day | minutes |
|
||||
|
||||
Tier 1a is first because 14 harnesses — the expensive part — are already
|
||||
written and produce nothing today.
|
||||
|
||||
@@ -33,20 +33,20 @@ finishing the features we have.
|
||||
|
||||
Features where one side is already built and the other was never finished.
|
||||
|
||||
| # | Item | What exists | What's missing |
|
||||
|---|------|-------------|----------------|
|
||||
| 1 | Block/unblock UI | Full server enforcement (`user_blocks`, DM create + send checks), `GET /blocks` client call | Client never calls `PUT/DELETE /api/v1/blocks/{userId}`; no menu items |
|
||||
| 2 | Channel topics in client | `channels.topic` column, admin-panel editing | Not in WS `ready` payload; chat header never renders it; client edit modal is name-only |
|
||||
| 3 | Role colors | `roles.color` stored and shipped in `ready` | Client hardcodes a switch on 4 role names (`formatting.ts`) |
|
||||
| 4 | Profile popup | `UserProfilePopup.ts` built and unit-tested | Never mounted; member click opens only the admin context menu |
|
||||
| 5 | Temp bans | `users.ban_expires`, `BanUser(..., expires)`, `IsEffectivelyBanned` all honor expiry | Every caller passes `nil`; no API field, no UI |
|
||||
| 6 | Archived channels | `channels.archived` stored, settable in admin panel | No read path filters on it — archived channels appear everywhere |
|
||||
| # | Item | What exists | What's missing |
|
||||
| --- | ------------------------ | ------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------- |
|
||||
| 1 | Block/unblock UI | Full server enforcement (`user_blocks`, DM create + send checks), `GET /blocks` client call | Client never calls `PUT/DELETE /api/v1/blocks/{userId}`; no menu items |
|
||||
| 2 | Channel topics in client | `channels.topic` column, admin-panel editing | Not in WS `ready` payload; chat header never renders it; client edit modal is name-only |
|
||||
| 3 | Role colors | `roles.color` stored and shipped in `ready` | Client hardcodes a switch on 4 role names (`formatting.ts`) |
|
||||
| 4 | Profile popup | `UserProfilePopup.ts` built and unit-tested | Never mounted; member click opens only the admin context menu |
|
||||
| 5 | Temp bans | `users.ban_expires`, `BanUser(..., expires)`, `IsEffectivelyBanned` all honor expiry | Every caller passes `nil`; no API field, no UI |
|
||||
| 6 | Archived channels | `channels.archived` stored, settable in admin panel | No read path filters on it — archived channels appear everywhere |
|
||||
|
||||
## Phase 2 — moderation depth
|
||||
|
||||
- **DONE (2026-07-31)** — Honest kick semantics. There is no membership model, so `DELETE /admin/api/users/{id}/sessions` cannot remove anyone; it revokes the target's sessions and they can sign straight back in. Rather than invent a membership table, the user-facing action is renamed to what it does: the desktop member-list menu item is now **Force Logout** (confirm "Log them out?", pending "Logging out...", toast "Forced {name} to log out"), and the admin panel's row button, modal and toast say Force Logout too, with the modal spelling out that the user can sign back in. The endpoint, the `KICK_MEMBERS` bit and the `onKick`/`adminKickMember` call sites are unchanged.
|
||||
- **DONE (2026-07-31)** — Enforce the decorative permission bits. The `/admin/api` perimeter now admits any role holding a bit of `permissions.AdminPerimeter` instead of requiring `ADMINISTRATOR`, and each route group re-checks its own bit: channels + channel overrides → `MANAGE_CHANNELS`, audit log → `VIEW_AUDIT_LOG`, settings → `MANAGE_SERVER`, force-logout → `KICK_MEMBERS`; ban/unban (`BAN_MEMBERS`) and role assignment (`MANAGE_ROLES`) are authorized inside `ModerationService`. Stats/users/`GET /me` stay perimeter-level; backups, updates, API tokens, plugins and the log stream are unchanged. `GET /admin/api/me` reports the caller's mask so the admin panel hides tabs and row actions it cannot use, and the desktop member-list context menu gates Kick/Ban/Change Role on the bits from the `ready` role list instead of on the role *name*. `MUTE_MEMBERS` admits to the perimeter but still has no route behind it (see voice moderation below).
|
||||
- **DONE (2026-07-31)** — Hierarchy checks beyond ban/unban: `ModerationService.ChangeUserRole` requires the actor to strictly outrank the target *and* refuses to assign a role positioned at or above the actor's own, closing the "any admin can promote anyone to Owner" hole. `ModerationService.ForceLogout` enforces the same outranks rule.
|
||||
- **DONE (2026-07-31)** — Enforce the decorative permission bits. The `/admin/api` perimeter now admits any role holding a bit of `permissions.AdminPerimeter` instead of requiring `ADMINISTRATOR`, and each route group re-checks its own bit: channels + channel overrides → `MANAGE_CHANNELS`, audit log → `VIEW_AUDIT_LOG`, settings → `MANAGE_SERVER`, force-logout → `KICK_MEMBERS`; ban/unban (`BAN_MEMBERS`) and role assignment (`MANAGE_ROLES`) are authorized inside `ModerationService`. Stats/users/`GET /me` stay perimeter-level; backups, updates, API tokens, plugins and the log stream are unchanged. `GET /admin/api/me` reports the caller's mask so the admin panel hides tabs and row actions it cannot use, and the desktop member-list context menu gates Kick/Ban/Change Role on the bits from the `ready` role list instead of on the role _name_. `MUTE_MEMBERS` admits to the perimeter but still has no route behind it (see voice moderation below).
|
||||
- **DONE (2026-07-31)** — Hierarchy checks beyond ban/unban: `ModerationService.ChangeUserRole` requires the actor to strictly outrank the target _and_ refuses to assign a role positioned at or above the actor's own, closing the "any admin can promote anyone to Owner" hole. `ModerationService.ForceLogout` enforces the same outranks rule.
|
||||
- **DONE (2026-07-31)** — Voice moderation on `MUTE_MEMBERS` (the bit is now live): `voice_mod_mute`, `voice_mod_deafen`, `voice_mod_move` and `voice_mod_kick`, each requiring the bit plus a strict role-position outrank of the target, rate limited 5/sec and audit-logged. `voice_states` gained `server_muted` / `server_deafened`, which the `voice_state` broadcast now carries; a server mute is also applied to the target's published audio track via the LiveKit RoomService, and the target's own `voice_mute` / `voice_deafen` unmute attempts are refused with `SERVER_MUTED` / `SERVER_DEAFENED`. Move and disconnect run the hub's voice-leave routine for the target and then send them `voice_moved` (client re-joins the destination through the ordinary join path) or `voice_disconnected`. The desktop client's voice-user context menu grows a moderation section gated on the bit, renders a distinct server-muted icon, and disables the widget's mute/deafen buttons with a reason while server muted.
|
||||
- **DONE (2026-07-31)** — Bulk message delete. `POST /api/v1/channels/{id}/messages/purge` takes `{limit: 1-100, before?}` and soft-deletes the newest matching messages, gated on `READ_MESSAGES|MANAGE_MESSAGES` for that channel (per-channel overrides apply, DMs rejected — a DM has no MANAGE_MESSAGES gate to answer to). Deletion is the same soft delete a single delete performs, so tombstones and `reply_to` targets survive; already-deleted rows are skipped and the select+update run in one writer transaction. One `message_purge` audit entry per call carries the count, and the fan-out is a single new `chat_bulk_deleted {channel_id, ids}` server->client message instead of N `chat_deleted` events. The desktop channel context menu grows a "Purge Messages…" item — gated on the actor's `MANAGE_MESSAGES` bit and hidden on voice channels — opening an inline 1-100 count prompt with a confirm step; the dispatcher marks every id in the broadcast as deleted.
|
||||
|
||||
@@ -59,30 +59,30 @@ highlights and badges from the resolved fields rather than re-parsing content.
|
||||
|
||||
- **DONE (2026-07-31)** — Server-side mention resolution and storage. `MessageService.resolveMentions` parses `@token`s out of sanitized content with a word-boundary rule (`mentionTokenRe`) that refuses address-shaped text: `mail@example` and `@@name` never match, and `@bob@example.com` is rejected whole rather than half-matched. Tokens are lowercased, deduplicated, ordered by first appearance and resolved case-insensitively against `users.username` (`UNIQUE COLLATE NOCASE`), with a second spelling that drops trailing `.`/`-` so "@bob." resolves to bob when nobody is literally named "bob.". A token matching no username resolves to nothing and stays plain text. Two caps bound the work one send can cause: at most 60 distinct tokens are looked up (`maxMentionCandidates`) and at most 20 resolve (`maxMentionsPerMessage`). Resolved IDs land in the new `message_mentions` table (migration `022`, PK `(message_id, mentioned_user_id)` plus `idx_message_mentions_user` for the per-user direction), written in the same writer transaction as the message row and rewritten wholesale on edit. Resolution failures are logged and degrade to "no mentions" — a message is never rejected because its mention lookup failed. `mentions` and `mentions_everyone` now ride on the `chat_message` and `chat_edited` broadcasts, on `GET /channels/{id}/messages`, on pinned-message responses and on FTS search results; `mentions` is always present and empty rather than null. `buildChatMessage` took a `chatMessageArgs` struct in the process — the positional list had outgrown a readable call site.
|
||||
- **DONE (2026-07-31)** — `read_states.mention_count` is live. `applyMentionCounts` raises it on message insert for every mentioned user who can actually read the channel — the role walk applies channel overrides, and DMs skip it entirely since participation is membership, not permissions. The author is always excluded, and users who have blocked the author are dropped (fail-closed: a `ListBlockersOf` error skips the whole increment, because a badge from a blocked user is worse than no badge). Edits deliberately never increment: only the original send can raise a badge, which is the simplest rule that makes double-counting a re-added mention impossible. `channel_focus` resets the count to 0 via the `UpdateReadState` upsert, and the `ready` payload ships `mention_count` per channel. Because `GetChannelUnreadCounts` covers `text`/`announcement` channels, a DM mention badge is raised live by the dispatcher but starts at 0 on reconnect — DM unreads are surfaced separately in the DM sidebar.
|
||||
- **DONE (2026-07-31)** — Client rendering, badges and notifications. `@lib/mentions` is the single source of truth shared by the renderer, the badge path and the notification gate, so all three agree on what counts as a mention; its regex mirrors the server's, and the server's `mentions`/`mentions_everyone` decide the outcome whenever present (the local token parse only stands in for servers predating the fields). Resolved mentions render as a highlighted `.mention` span, with `.mention-self` when the mention is the current user; an unresolvable token renders as plain text. In the channel sidebar a red `.mention-badge` outranks the plain unread badge — only one shows, and it counts mentions rather than messages. Desktop notifications retitle to "{user} mentioned you in #{channel}", and "Suppress @everyone" now means exactly that: it drops only a notification the `@everyone`/`@here` alone caused, so a message that also names you still notifies, and an `@everyone` the sender lacked the bit for was never a mention to suppress. No OS dock/taskbar count badge was added — the existing taskbar *flash* is the only OS-level signal; a real badge count needs a Tauri-side API and is left for a later pass.
|
||||
- **DONE (2026-07-31)** — Client rendering, badges and notifications. `@lib/mentions` is the single source of truth shared by the renderer, the badge path and the notification gate, so all three agree on what counts as a mention; its regex mirrors the server's, and the server's `mentions`/`mentions_everyone` decide the outcome whenever present (the local token parse only stands in for servers predating the fields). Resolved mentions render as a highlighted `.mention` span, with `.mention-self` when the mention is the current user; an unresolvable token renders as plain text. In the channel sidebar a red `.mention-badge` outranks the plain unread badge — only one shows, and it counts mentions rather than messages. Desktop notifications retitle to "{user} mentioned you in #{channel}", and "Suppress @everyone" now means exactly that: it drops only a notification the `@everyone`/`@here` alone caused, so a message that also names you still notifies, and an `@everyone` the sender lacked the bit for was never a mention to suppress. No OS dock/taskbar count badge was added — the existing taskbar _flash_ is the only OS-level signal; a real badge count needs a Tauri-side API and is left for a later pass.
|
||||
- **DONE (2026-07-31)** — `@everyone`/`@here` behind a permission, plus composer autocomplete. New `MENTION_EVERYONE` bit (21, `0x200000`); migration `022` grants it to the seeded Owner/Admin/Moderator roles, moving the Moderator mask from `0x000FFFFF` to `0x002FFFFF`. Without the bit the token carries no mention semantics at all — no highlight, no badge, no notification — and DM channels have no `@everyone` semantics since there is no permission surface to answer to. `@here` narrows the fan-out to readers whose status is not `offline`; `@everyone` reaches every reader. The composer opens an inline member picker on `@` (`MentionAutocomplete`, max 10 rows) whose active-token rule mirrors the server's, so it never offers a completion for text a send would not resolve; `@everyone`/`@here` appear as rows only for users who hold the bit.
|
||||
- **DONE (2026-07-31)** — Clickable `#channel` links. `#name` tokens in message content resolve case-insensitively against the channel store (DM channels excluded — they have no user-visible `#name`) and render as links; unresolvable tokens stay plain text. Navigation funnels through the new `@lib/channel-navigation.navigateToChannel`, now the single entry point shared by the sidebar item and `#channel` links, so every affordance clears the same unread and mention badges. Role mentions remain out of scope by design — they need role management, which is phase 5.
|
||||
|
||||
## Phase 4 — markdown and message polish
|
||||
|
||||
- **DONE (2026-08-01)** — Full markdown rendering, client-side. The content parser grew a real tokenizer (`message-list/markdown.ts`): one left-to-right scan with recursive descent into matched delimiter pairs, which is what makes nesting (`**bold *and italic***`), backslash escaping and "markdown is dead inside code" fall out of a single rule set instead of a pile of regexes fighting over overlaps. Inline: bold, italic (`*`/`_`, with a word-boundary rule so `snake_case_names` stay literal), underline, strikethrough and spoilers; blocks (line-start only): `>` quotes that merge contiguous lines, `>>>` for the rest of the message, `#`–`###` headings that require the space, `-`/`*`/`1.` lists with one level of nesting. Masked links accept absolute `http(s)` only — `javascript:`, `data:` and relatives render as their literal source — and are excluded from `extractUrls`, so hiding an address does not get it previewed back. Code fences take a language tag that renders as a label and drives a hand-rolled highlighter (`syntax-highlight.ts`: comments/strings/numbers/keywords for js/ts, go, python, rust, json, bash, css, html, plain fallback) — no highlighting dependency was added. Spoilers are per-span `role="button"` elements with `aria-pressed`, and the revealing click is swallowed so a link underneath cannot open with it. Rendering stays a strict DOM builder: no `innerHTML` anywhere, every `href` through `isSafeUrl`. Composer: Ctrl+B/I/U wrap (and unwrap) the selection, stopping propagation so Ctrl+U formats while typing and still uploads elsewhere.
|
||||
- **DONE (2026-08-01)** — Message navigation: fetch-around, reply jumps, permalinks. Server gained `GET /api/v1/channels/{id}/messages/around/{messageId}?limit=50` — the same read gate as history (READ_MESSAGES / DM membership), the window split half-and-half around the centre and returned **oldest-first**, with `has_more_before`/`has_more_after` derived by over-fetching one row per side rather than two extra count queries. A centre that is soft-deleted is a 404, not an empty window: history omits deleted rows, so there is nothing to centre on. The three duplicated read-permission blocks in `MessageService` collapsed into one `requireChannelRead`, and the three copies of limit parsing in the handlers into one `parseLimitParam`. Client-side every jump affordance — search hit, pinned entry, the quoted reply bar, a permalink chip, an `owncord://message/…` link from the OS — now routes through a single jumper (`lib/message-navigation.ts` registry → `main-page/MessageJump.ts`): scroll + flash when the target is loaded, otherwise fetch the around-window, swap it in, scroll + flash. A window with newer messages below it is *detached*: the store refuses to append live broadcasts onto it (they belong below a gap) and the list shows a **Jump to Present** pill that reattaches and refetches the tail. Permalinks are `owncord://message/{channelId}/{messageId}` — copied from the hover bar, parsed by the same `deep-link.ts` that owns the invite scheme (whose bare-code form now refuses the `message` route), and rendered as a compact channel-name chip when pasted into chat; a link to a channel the reader cannot see stays plain text.
|
||||
- **DONE (2026-08-01)** — Who-reacted list. Server added `GET /api/v1/channels/{id}/messages/{messageId}/reactions/{emoji}/users` (emoji percent-encoded; chi routes on `RawPath`, so the handler unescapes it) behind the same `requireChannelRead` gate as history, returning up to 100 reactors oldest-first. A separate endpoint rather than `user_ids` inline on every reaction summary: a page of chat carries dozens of pills and almost none are hovered, so the payload stays small. A message that lives in another channel is a 404 — the channel in the URL is the one the permission check ran against. Client: hovering or focusing a pill for 300 ms (the `lib/streamPreview.ts` debounce, so a pointer crossing a row fires nothing) fetches and shows *"alice, bob, carol and 4 others reacted with 👍"*. Lists are cached per message+emoji and evicted wholesale for a message on `reaction_update`, which names only the emoji that changed; a response that lands after an invalidation or after the pointer left is discarded rather than repopulating the cache or popping a tooltip nobody is hovering. Usernames go in as text nodes.
|
||||
- **DONE (2026-08-01)** — Message navigation: fetch-around, reply jumps, permalinks. Server gained `GET /api/v1/channels/{id}/messages/around/{messageId}?limit=50` — the same read gate as history (READ_MESSAGES / DM membership), the window split half-and-half around the centre and returned **oldest-first**, with `has_more_before`/`has_more_after` derived by over-fetching one row per side rather than two extra count queries. A centre that is soft-deleted is a 404, not an empty window: history omits deleted rows, so there is nothing to centre on. The three duplicated read-permission blocks in `MessageService` collapsed into one `requireChannelRead`, and the three copies of limit parsing in the handlers into one `parseLimitParam`. Client-side every jump affordance — search hit, pinned entry, the quoted reply bar, a permalink chip, an `owncord://message/…` link from the OS — now routes through a single jumper (`lib/message-navigation.ts` registry → `main-page/MessageJump.ts`): scroll + flash when the target is loaded, otherwise fetch the around-window, swap it in, scroll + flash. A window with newer messages below it is _detached_: the store refuses to append live broadcasts onto it (they belong below a gap) and the list shows a **Jump to Present** pill that reattaches and refetches the tail. Permalinks are `owncord://message/{channelId}/{messageId}` — copied from the hover bar, parsed by the same `deep-link.ts` that owns the invite scheme (whose bare-code form now refuses the `message` route), and rendered as a compact channel-name chip when pasted into chat; a link to a channel the reader cannot see stays plain text.
|
||||
- **DONE (2026-08-01)** — Who-reacted list. Server added `GET /api/v1/channels/{id}/messages/{messageId}/reactions/{emoji}/users` (emoji percent-encoded; chi routes on `RawPath`, so the handler unescapes it) behind the same `requireChannelRead` gate as history, returning up to 100 reactors oldest-first. A separate endpoint rather than `user_ids` inline on every reaction summary: a page of chat carries dozens of pills and almost none are hovered, so the payload stays small. A message that lives in another channel is a 404 — the channel in the URL is the one the permission check ran against. Client: hovering or focusing a pill for 300 ms (the `lib/streamPreview.ts` debounce, so a pointer crossing a row fires nothing) fetches and shows _"alice, bob, carol and 4 others reacted with 👍"_. Lists are cached per message+emoji and evicted wholesale for a message on `reaction_update`, which names only the emoji that changed; a response that lands after an invalidation or after the pointer left is discarded rather than repopulating the cache or popping a tooltip nobody is hovering. Usernames go in as text nodes.
|
||||
- **DONE (2026-08-01)** — Inline video/audio players. `video/mp4|webm|ogg` render as `<video controls preload="metadata">` inside the same max box as an image (download button on hover); the common audio containers render as an `<audio controls preload="metadata">` row with filename, size and download. Both are allowlists, not `video/`/`audio/` prefix tests — an unknown container gets the download chip rather than a player that fails to decode — and `image/svg+xml` is now excluded from the image path too (it can carry script, and the data-URI allowlist already refused it, so inlining only ever produced a stuck placeholder). `/api/v1/files/{id}` is permission-checked, so the source is fetched through the same cert-pinned proxy with the session bearer token images use, then handed over as a `blob:` URL rather than the image path's base64 data URI, which would inflate a 50 MB video into a string and cache it in IndexedDB.
|
||||
- **DONE (2026-08-01)** — Read-state polish. A red **NEW** divider marks the first unread message when a channel is opened with unread; because opening clears the badge, `setActiveChannel` snapshots the count first (`getUnreadOnOpen`) and the list places the line above the last *N* loaded messages — suppressed while the window is detached (a slice around an old message is not the tail) and gone on the next visit. Explicit mark-as-read arrived as a new client→server WS message `mark_read`: `channel_focus` already advances read state but also rebinds the connection's focused channel, which is wrong when marking a channel the user is not looking at. It backs **Mark as Read** in the channel context menu (disabled when already read, absent for voice) and **Mark All as Read** on the sidebar's server header, which only appears while something is unread. DM sidebar rows now show real unread counts and a red mention count that outranks them; `GetChannelUnreadCounts` includes the caller's DM rows so `ready` ships a DM `mention_count` — previously absent, which silently reset every DM mention badge on reconnect.
|
||||
- **DONE (2026-08-01)** — Read-state polish. A red **NEW** divider marks the first unread message when a channel is opened with unread; because opening clears the badge, `setActiveChannel` snapshots the count first (`getUnreadOnOpen`) and the list places the line above the last _N_ loaded messages — suppressed while the window is detached (a slice around an old message is not the tail) and gone on the next visit. Explicit mark-as-read arrived as a new client→server WS message `mark_read`: `channel_focus` already advances read state but also rebinds the connection's focused channel, which is wrong when marking a channel the user is not looking at. It backs **Mark as Read** in the channel context menu (disabled when already read, absent for voice) and **Mark All as Read** on the sidebar's server header, which only appears while something is unread. DM sidebar rows now show real unread counts and a red mention count that outranks them; `GetChannelUnreadCounts` includes the caller's DM rows so `ready` ships a DM `mention_count` — previously absent, which silently reset every DM mention badge on reconnect.
|
||||
|
||||
## Phase 5 — roles & channels management
|
||||
|
||||
- **DONE (2026-08-01)** — Role CRUD. Roles were four seeded rows whose permission masks were frozen at migration time; they are now real entities behind `/admin/api/roles` (`GET`, `POST`, `PATCH /{id}`, `DELETE /{id}`, `PATCH /roles/reorder`), gated on `MANAGE_ROLES` with the whole rule set in a new `service.RoleService` rather than in the handlers. Every rule is measured against the **actor's** role position: you may only create/edit/delete/reorder roles strictly *below* your own (equality is refused too, so a role cannot rewrite itself, and nothing outranks position 100 — which is what makes the seeded Owner role immutable and undeletable for everyone including the owner), and you may never *grant* a bit your own role lacks, though removing one is allowed because de-escalation is always safe (`ADMINISTRATOR` bypasses). The default role is undeletable — every member falls back to it — and deleting a role moves its members onto that fallback in one `UPDATE`, drops the role's `channel_overrides` rows and deletes the role in a single writer transaction, then invalidates exactly the moved members' cached permissions. Names are unique **case-insensitively** (migration `023` adds `idx_roles_name_nocase`; the column's own `UNIQUE` is BINARY, so "Moderator" and "moderator" used to be two roles the client's case-insensitive lookup could not tell apart), colors are `#rgb`/`#rrggbb` normalized to uppercase, and unknown permission bits are masked off rather than rejected. Reorder takes an ordered id list that must name exactly the roles below the actor — a partial list is refused rather than leaving the omitted ones at positions that now collide — and normalizes them to `N…1`. Every mutation audits (`role_create`/`role_update`/`role_delete`/`role_reorder`).
|
||||
Cache and client sync follow the existing patterns rather than inventing one: the permission cache is invalidated *before* the hub calls (as the channel-override handlers do), a permission change runs the new `Hub.RefreshAllChannelVisibility` — `RefreshChannelVisibility` across every non-DM channel, because a role's mask is the base every channel's effective permission derives from, where an override edit touches exactly one — and a delete additionally sends one `member_update` per reassigned member. A new `roles_update` server→client message (schema + `make protocol-generate` + `docs/protocol.md`) carries the **full** new list, so clients refresh `channelsStore.roles` without reconnecting; replacing rather than patching means a dropped intermediate event can never leave a deleted role on screen. The member list now subscribes to that list too — grouping, labels and name colors all derive from it, and before this they only re-rendered when some unrelated member change happened along. The admin panel grows a Roles section (nav gated on `MANAGE_ROLES`) listing roles by position with a color swatch and member count, up/down reorder arrows scoped to the manageable slice, a create/edit modal with a permission checkbox grid grouped as `docs/schema.md` groups the bitfield — bits the caller's own role lacks are rendered disabled — and a delete confirmation that names how many members move and where. Hoist and mentionable are still out of scope: neither has a column, and role mentions need the mention resolver to learn about roles.
|
||||
- **DONE (2026-08-01)** — Role CRUD. Roles were four seeded rows whose permission masks were frozen at migration time; they are now real entities behind `/admin/api/roles` (`GET`, `POST`, `PATCH /{id}`, `DELETE /{id}`, `PATCH /roles/reorder`), gated on `MANAGE_ROLES` with the whole rule set in a new `service.RoleService` rather than in the handlers. Every rule is measured against the **actor's** role position: you may only create/edit/delete/reorder roles strictly _below_ your own (equality is refused too, so a role cannot rewrite itself, and nothing outranks position 100 — which is what makes the seeded Owner role immutable and undeletable for everyone including the owner), and you may never _grant_ a bit your own role lacks, though removing one is allowed because de-escalation is always safe (`ADMINISTRATOR` bypasses). The default role is undeletable — every member falls back to it — and deleting a role moves its members onto that fallback in one `UPDATE`, drops the role's `channel_overrides` rows and deletes the role in a single writer transaction, then invalidates exactly the moved members' cached permissions. Names are unique **case-insensitively** (migration `023` adds `idx_roles_name_nocase`; the column's own `UNIQUE` is BINARY, so "Moderator" and "moderator" used to be two roles the client's case-insensitive lookup could not tell apart), colors are `#rgb`/`#rrggbb` normalized to uppercase, and unknown permission bits are masked off rather than rejected. Reorder takes an ordered id list that must name exactly the roles below the actor — a partial list is refused rather than leaving the omitted ones at positions that now collide — and normalizes them to `N…1`. Every mutation audits (`role_create`/`role_update`/`role_delete`/`role_reorder`).
|
||||
Cache and client sync follow the existing patterns rather than inventing one: the permission cache is invalidated _before_ the hub calls (as the channel-override handlers do), a permission change runs the new `Hub.RefreshAllChannelVisibility` — `RefreshChannelVisibility` across every non-DM channel, because a role's mask is the base every channel's effective permission derives from, where an override edit touches exactly one — and a delete additionally sends one `member_update` per reassigned member. A new `roles_update` server→client message (schema + `make protocol-generate` + `docs/protocol.md`) carries the **full** new list, so clients refresh `channelsStore.roles` without reconnecting; replacing rather than patching means a dropped intermediate event can never leave a deleted role on screen. The member list now subscribes to that list too — grouping, labels and name colors all derive from it, and before this they only re-rendered when some unrelated member change happened along. The admin panel grows a Roles section (nav gated on `MANAGE_ROLES`) listing roles by position with a color swatch and member count, up/down reorder arrows scoped to the manageable slice, a create/edit modal with a permission checkbox grid grouped as `docs/schema.md` groups the bitfield — bits the caller's own role lacks are rendered disabled — and a delete confirmation that names how many members move and where. Hoist and mentionable are still out of scope: neither has a column, and role mentions need the mention resolver to learn about roles.
|
||||
- Role CRUD leftovers: hoist and mentionable flags (no columns yet), role mentions (`@RoleName`).
|
||||
- **DONE (2026-08-01)** — Per-user channel overrides + the full override matrix UI. New table `channel_user_overrides` (migration `024`, PK `(channel_id, user_id)` plus `idx_channel_user_overrides_user` for the per-user direction) makes the resolution order Discord's: **base role permissions → role override → user override**, with the narrower layer last, so a user deny beats a role allow and a user allow beats a user deny; `ADMINISTRATOR` still bypasses both. The formula has exactly one implementation, `permissions.EffectiveChannelPerms`, which `Checker.HasChannelPerm`, `Checker.HasChannelPermBatch` and through it `VisibleChannelIDs` all route through — so extending the order was a change to one function plus the fetch, not to the dozens of `HasChannelPerm` call sites. `HasChannelPerm` grew a `userID` parameter (`0` = "no member in hand", skip the user layer), and both layers are loaded together by `db.GetChannelOverridesFor(roleID, userID)` — two batch queries, never per channel — which is now the single fetch behind `buildReady`, `computeAllowedChannels`, REST `ListVisibleChannels`, `MessageService.GetAccessibleChannelIDs`, the voice-join publish grants and the cached `service.PermissionService`. `channelCanSend` resolves both layers too, and the `@everyone` fan-out (`mentionReaders`) folds the user layer in *both* directions: a user deny drops a reader the role walk admitted (unless they hold `ADMINISTRATOR`), a user allow adds one it excluded. `Hub.RefreshChannelVisibility` and `channelReadAudience` stopped memoising visibility per role — two members of one role can now legitimately disagree about a channel, which is exactly what a per-user override edit creates. `Server/ws/channel_visibility_agreement_test.go` grew a second case proving REST, `ready` and replay filtering still agree for three members of the *same* role carrying different overrides.
|
||||
- **DONE (2026-08-01)** — Per-user channel overrides + the full override matrix UI. New table `channel_user_overrides` (migration `024`, PK `(channel_id, user_id)` plus `idx_channel_user_overrides_user` for the per-user direction) makes the resolution order Discord's: **base role permissions → role override → user override**, with the narrower layer last, so a user deny beats a role allow and a user allow beats a user deny; `ADMINISTRATOR` still bypasses both. The formula has exactly one implementation, `permissions.EffectiveChannelPerms`, which `Checker.HasChannelPerm`, `Checker.HasChannelPermBatch` and through it `VisibleChannelIDs` all route through — so extending the order was a change to one function plus the fetch, not to the dozens of `HasChannelPerm` call sites. `HasChannelPerm` grew a `userID` parameter (`0` = "no member in hand", skip the user layer), and both layers are loaded together by `db.GetChannelOverridesFor(roleID, userID)` — two batch queries, never per channel — which is now the single fetch behind `buildReady`, `computeAllowedChannels`, REST `ListVisibleChannels`, `MessageService.GetAccessibleChannelIDs`, the voice-join publish grants and the cached `service.PermissionService`. `channelCanSend` resolves both layers too, and the `@everyone` fan-out (`mentionReaders`) folds the user layer in _both_ directions: a user deny drops a reader the role walk admitted (unless they hold `ADMINISTRATOR`), a user allow adds one it excluded. `Hub.RefreshChannelVisibility` and `channelReadAudience` stopped memoising visibility per role — two members of one role can now legitimately disagree about a channel, which is exactly what a per-user override edit creates. `Server/ws/channel_visibility_agreement_test.go` grew a second case proving REST, `ready` and replay filtering still agree for three members of the _same_ role carrying different overrides.
|
||||
API: `PUT`/`DELETE /admin/api/channels/{id}/user-permissions/{userId}` with `{allow, deny}` masks, gated `MANAGE_CHANNELS` like the role layer, unknown bits masked off, audited as `channel_user_perms_update`/`channel_user_perms_clear`. They invalidate only the **target's** cache (`InvalidateUser`) rather than the whole cache the role layer must drop — a per-user override cannot change anyone else's verdict — before the hub re-sync. `GET .../permissions` now returns `users` alongside `roles`: every role (zero masks when unset) but only the members who actually carry an override row. The admin panel's single "Can access" checkbox survives as the quick private-channel shortcut, writing exactly the mask it always did, and gained a real matrix editor beneath it: pick a role **or** a member, then set allow / inherit / deny per channel-scoped bit (READ, SEND, ATTACH_FILES, ADD_REACTIONS, MANAGE_MESSAGES, MENTION_EVERYONE, CONNECT, SPEAK, VIDEO, SHARE_SCREEN). An all-inherit row is sent as a `DELETE`, because storing `(0,0)` would leave a row that resolves to nothing. `perm_grid_test.go` ties the matrix's bit list to `permissions` the same way it already tied the role grid.
|
||||
- **DONE (2026-08-01)** — Categories stopped being magic strings. The server refused any non-voice channel under a category literally named "Voice Channels" and any voice channel outside it (`validateCategoryType`), and the client mirrored the rule with a substring test on the category name. Both are gone: `POST /admin/api/channels` validates the **type** alone, categories are free text, and any type lives under any name. `PATCH /admin/api/channels/{id}` accepts `category`, so moving a channel between categories is an edit rather than a delete-and-recreate. The desktop `CreateChannelModal`'s read-only category display became an editable text input with a `<datalist>` of the categories in use (`channelsStore.getKnownCategories`), offering all three types; `EditChannelModal` gained the same field; the admin panel's create and edit forms got the same input plus datalist. The sidebar groups voice channels under whatever category they carry — sharing a group with text channels is fine — and falls back to a synthetic "Voice" group only for voice channels with no category at all (`displayCategoryOf`). Collapse persistence stays client-side, unchanged.
|
||||
- Categories as real entities (own permissions, ordering).
|
||||
- **DONE (2026-08-01)** — Channel management moved into the desktop client. `EditChannelModal` offered name, topic and category; it now also carries **slow mode** (a preset `<select>` from Off to the server's 6-hour ceiling — a free number field mostly produces typos like "300" meant as minutes, and a stored off-preset value set through the admin panel is kept as its own option rather than silently rounded), an **NSFW** toggle, and a **voice section** (User Limit / Video Limit, 0–99, 0 = unlimited) rendered for voice channels alone — the columns exist on every row, but on a text channel they are values nothing reads, so a text-channel edit omits the keys entirely rather than sending `0` and wiping limits the row happens to hold. Every control pre-fills from `channelsStore` (which `channel_update` writes into), not from the sidebar row, so the modal opens on current values. `PATCH /admin/api/channels/{id}` and `db.AdminUpdateChannel` grew `nsfw`, `voice_max_users` and `voice_max_video`; the positional argument list became `db.ChannelUpdate` once it reached nine fields, four of them ints. Bounds (`slow_mode` 0…21600, both voice limits 0…99) are validated *before* the write and refused with `400 INVALID_INPUT` rather than clamped — a caller that sent `-1` meant something — so a rejected body writes nothing at all. The whole UI is gated on **MANAGE_CHANNELS**, not on role names: `permissions.canManageChannels()` is now the single derivation behind the category "+" and the context menu's Edit/Delete, which were still asking whether the role was literally called "owner" or "admin" (a custom role the server would happily let edit a channel saw no way to, and a role merely *called* "admin" with no channel bit saw items every click would be refused for). `channel_create`/`channel_update` and `ready` all carry `slow_mode`, `nsfw` and both voice limits — always present with their zero values, never omitted, so "absent" never means two things — built by one `channelPayloadFrom` constructor so the two events cannot drift. The store applies a partial `channel_update` field by field (an absent key is left alone, not cleared) and finally handles `category`, so a category move regroups the sidebar without a reconnect.
|
||||
- **DONE (2026-08-01)** — NSFW flag end-to-end, and the voice limits surfaced. Migration `025` adds `channels.nsfw` (0/1, like `archived`). **The server does nothing with it** and says so in `schema.md`, `api.md`, `protocol.md` and the migration itself: it stores, broadcasts and audits the flag (`updated #foo (marked NSFW)` / `(unmarked NSFW)`, plain when it did not move) and applies no filtering, no age check and no restriction on who may read or post — a client ignoring the field behaves exactly as before it existed. Every consequence is the desktop client's: `@lib/nsfw-gate` remembers acknowledgement per channel in **sessionStorage** (the promise is "once per session", so localStorage would quietly make it "once ever"; a throwing storage reads as *not* acknowledged, erring toward asking again), and `NsfwGate` mounts over the messages slot — not as a modal, since the channel is live underneath and the sidebar stays usable — with "This channel may contain sensitive content — Continue?", a note stating plainly that nothing is filtered, and a Go Back that leaves the channel rather than stranding the reader. The sidebar marks flagged channels with a shield beside the name (not a recolour: unread/mention/active already own the row's colour). Voice limits: the row shows "3/5" when a user limit is set and nothing when unlimited ("3/0" would read as a bug), and the client still never pre-blocks a join — its participant list can lag and an invented refusal would be uncorrectable, so the server answers `CHANNEL_FULL`, which the dispatcher now surfaces as a toast (it was logged and otherwise silent, as was `VIDEO_LIMIT`).
|
||||
- **DONE (2026-08-01)** — The audit log stays admin-panel-only — it is a paginated, filterable table over an endpoint the desktop client otherwise never calls, and a second implementation would be a second thing to keep correct — but it stopped being unreachable. The sidebar's server header grows an "Audit Log" entry gated on `VIEW_AUDIT_LOG` (kept in sync with `authStore` *and* the role list, because `ready` can land after the header is built), opening `https://{host}/admin#audit` in the user's browser via the opener plugin. Deliberately not through the loopback TOFU proxy the REST client uses: that origin means nothing to an external browser, so a self-signed deployment shows the browser's certificate warning, which is the honest outcome. The admin panel learned to honour a `#section` fragment on load (falling back to the dashboard when the principal may not open it, exactly as a stale stored section does), so the entry lands on the log rather than on the dashboard with a tab still to find.
|
||||
- **DONE (2026-08-01)** — Channel management moved into the desktop client. `EditChannelModal` offered name, topic and category; it now also carries **slow mode** (a preset `<select>` from Off to the server's 6-hour ceiling — a free number field mostly produces typos like "300" meant as minutes, and a stored off-preset value set through the admin panel is kept as its own option rather than silently rounded), an **NSFW** toggle, and a **voice section** (User Limit / Video Limit, 0–99, 0 = unlimited) rendered for voice channels alone — the columns exist on every row, but on a text channel they are values nothing reads, so a text-channel edit omits the keys entirely rather than sending `0` and wiping limits the row happens to hold. Every control pre-fills from `channelsStore` (which `channel_update` writes into), not from the sidebar row, so the modal opens on current values. `PATCH /admin/api/channels/{id}` and `db.AdminUpdateChannel` grew `nsfw`, `voice_max_users` and `voice_max_video`; the positional argument list became `db.ChannelUpdate` once it reached nine fields, four of them ints. Bounds (`slow_mode` 0…21600, both voice limits 0…99) are validated _before_ the write and refused with `400 INVALID_INPUT` rather than clamped — a caller that sent `-1` meant something — so a rejected body writes nothing at all. The whole UI is gated on **MANAGE_CHANNELS**, not on role names: `permissions.canManageChannels()` is now the single derivation behind the category "+" and the context menu's Edit/Delete, which were still asking whether the role was literally called "owner" or "admin" (a custom role the server would happily let edit a channel saw no way to, and a role merely _called_ "admin" with no channel bit saw items every click would be refused for). `channel_create`/`channel_update` and `ready` all carry `slow_mode`, `nsfw` and both voice limits — always present with their zero values, never omitted, so "absent" never means two things — built by one `channelPayloadFrom` constructor so the two events cannot drift. The store applies a partial `channel_update` field by field (an absent key is left alone, not cleared) and finally handles `category`, so a category move regroups the sidebar without a reconnect.
|
||||
- **DONE (2026-08-01)** — NSFW flag end-to-end, and the voice limits surfaced. Migration `025` adds `channels.nsfw` (0/1, like `archived`). **The server does nothing with it** and says so in `schema.md`, `api.md`, `protocol.md` and the migration itself: it stores, broadcasts and audits the flag (`updated #foo (marked NSFW)` / `(unmarked NSFW)`, plain when it did not move) and applies no filtering, no age check and no restriction on who may read or post — a client ignoring the field behaves exactly as before it existed. Every consequence is the desktop client's: `@lib/nsfw-gate` remembers acknowledgement per channel in **sessionStorage** (the promise is "once per session", so localStorage would quietly make it "once ever"; a throwing storage reads as _not_ acknowledged, erring toward asking again), and `NsfwGate` mounts over the messages slot — not as a modal, since the channel is live underneath and the sidebar stays usable — with "This channel may contain sensitive content — Continue?", a note stating plainly that nothing is filtered, and a Go Back that leaves the channel rather than stranding the reader. The sidebar marks flagged channels with a shield beside the name (not a recolour: unread/mention/active already own the row's colour). Voice limits: the row shows "3/5" when a user limit is set and nothing when unlimited ("3/0" would read as a bug), and the client still never pre-blocks a join — its participant list can lag and an invented refusal would be uncorrectable, so the server answers `CHANNEL_FULL`, which the dispatcher now surfaces as a toast (it was logged and otherwise silent, as was `VIDEO_LIMIT`).
|
||||
- **DONE (2026-08-01)** — The audit log stays admin-panel-only — it is a paginated, filterable table over an endpoint the desktop client otherwise never calls, and a second implementation would be a second thing to keep correct — but it stopped being unreachable. The sidebar's server header grows an "Audit Log" entry gated on `VIEW_AUDIT_LOG` (kept in sync with `authStore` _and_ the role list, because `ready` can land after the header is built), opening `https://{host}/admin#audit` in the user's browser via the opener plugin. Deliberately not through the loopback TOFU proxy the REST client uses: that origin means nothing to an external browser, so a self-signed deployment shows the browser's certificate warning, which is the honest outcome. The admin panel learned to honour a `#section` fragment on load (falling back to the dashboard when the principal may not open it, exactly as a stale stored section does), so the entry lands on the log rather than on the dashboard with a tab still to find.
|
||||
|
||||
## Phase 6 — social & profiles
|
||||
|
||||
@@ -90,7 +90,7 @@ highlights and badges from the resolved fields rather than re-parsing content.
|
||||
New `emoji_update` server→client message (schema + `make protocol-generate` + `docs/protocol.md`) carries the **whole** set after every mutation, for the same reason `roles_update` does: replacing rather than patching means a dropped event can never leave a deleted emoji rendering in the messages that name it. It is deliberately _not_ in the `ready` payload — the set belongs to the server, not the session, so clients load it once over REST on ready and keep it fresh from the event. Client: a new `emojiStore` whose `resolveEmoji` is the single answer to "is `:name:` a real emoji here", consulted by message rendering, the picker, the composer autocomplete and reaction pills so none of them can disagree. `:shortcode:` renders as a 22px inline image — jumbo 48px when the message is nothing but emoji (unicode included, capped at Discord's 27, and an _unresolved_ shortcode is plain text so it never earns jumbo) — via a `.msg-text-jumbo` class that sizes glyphs and images together rather than threading a flag through four render functions. It is added in the same token pass as `@mentions` and `#channels`, so code spans and fenced blocks are excluded for free: inline code never reaches the token pass, and fences are split off before it. Images are fetched through the same cert-pinned, bearer-token path attachments use and swapped in as a data URI — assigning the server URL to `img.src` would 401 — with the shortcode as `alt`, so a message reads correctly before (and if) the bytes arrive. Reactions are free-form strings already, so a custom reaction is stored as the literal `:shortcode:` and the pill renders the image when it resolves and the text when it does not (a deleted emoji leaves a working, if plain, reaction). The reaction length cap stopped being a bare `32` and is now derived as `MaxShortcodeLen + 2`: a 31- or 32-character shortcode was a legal emoji that rendered in messages and was silently refused as a reaction, which is exactly the kind of gap a hardcoded constant on each side produces. The composer's picker finally gets its `customEmoji` option, showing a **Server** category that inserts `:shortcode:`, and gained a `:`-autocomplete mirroring the `@`-mention one — colon plus 2+ characters, custom emoji ranked above the built-in unicode set, only one popup open at a time. The admin panel grows an Emoji section (nav gated on `MANAGE_SERVER`) with upload, list and delete; it calls the ordinary member API rather than a duplicate `/admin/api` handler set, and loads thumbnails as blob URLs because `<img src>` cannot send an Authorization header (the panel's CSP gained `img-src 'self' blob:` for exactly that).
|
||||
- **DONE (2026-08-01)** — Profiles & presence depth: avatar upload, display names, about-me, custom status, real invisible, auto-idle. Migration `027` adds `users.display_name` (32), `about` (300) and `custom_status` (128) — all nullable, all bounded and HTML-sanitized in `UserService`/`ChannelService` rather than in a handler, so every transport gets the same rules and "omitted = unchanged, empty string = cleared" is one decision rather than four. `display_name` is display-only on purpose: `@mentions` keep resolving against `username`, because it is the unique case-insensitive key and a non-unique nickname would make `@alice` ambiguous the moment two people pick the same one.
|
||||
`POST /api/v1/users/me/avatar` takes a multipart PNG/JPEG/WebP (1 MiB, 1024×1024, both re-measured from the sniffed bytes; GIF is refused because an animated avatar renders in every message row, SVG for the reason emoji refuse it). The bytes land in the ordinary attachments table with no channel and `users.avatar` is pointed at `/api/v1/files/{id}` — which is what makes the picture readable: an unlinked attachment is uploader-only, and the file route now _also_ admits one that some user's avatar column currently equals (covered by a partial index added in the same migration). An avatar is public exactly while it is somebody's avatar and stops being readable the instant it is replaced; the previous file's bytes are deliberately left on disk, since a blind delete would race any request already in flight for a message rendered with it. `PATCH /users/me` still takes an https URL, and both paths end at the same column.
|
||||
**Real invisible** is the load-bearing change. `users.status` stores the status the user _chose_, invisible included; the collapse to `offline` happens at read time in exactly two functions (`db.BroadcastStatus`, `db.StatusForViewer`) that every payload builder delegates to, so a new payload cannot leak it by forgetting. A presence change to invisible splits into two events — a global broadcast excluding the owner that says `offline`, and a targeted one carrying their true state — because a client told it was offline would render its own picker wrong and re-announce online on the next reconnect. That reconnect flash is gone at the source too: `ws serve` no longer stamps `online` on connect, it reads the saved status (`db.ConnectStatus`: idle/dnd/invisible survive, anything else becomes online) and announces _that_, before `buildReady` runs so the member list and the broadcast cannot disagree. A chosen status now survives a disconnect (`MarkUserDisconnected` clears only `online`) and a restart, and the stale-choice problem that would otherwise create is handled at read time: a member with no live connection renders offline whatever the column says. The client's `restoreSavedPresence` shrank to a no-op safeguard that only speaks up when the server genuinely disagrees. The one place that read the column as a *value* rather than through the two collapse functions was the `@here` fan-out, which tested `status == "offline"` literally and so would have pinged exactly the people who had asked not to be seen; it now collapses through `db.BroadcastStatus` first, so `@here` skips invisible readers and `@everyone` still reaches them.
|
||||
**Real invisible** is the load-bearing change. `users.status` stores the status the user _chose_, invisible included; the collapse to `offline` happens at read time in exactly two functions (`db.BroadcastStatus`, `db.StatusForViewer`) that every payload builder delegates to, so a new payload cannot leak it by forgetting. A presence change to invisible splits into two events — a global broadcast excluding the owner that says `offline`, and a targeted one carrying their true state — because a client told it was offline would render its own picker wrong and re-announce online on the next reconnect. That reconnect flash is gone at the source too: `ws serve` no longer stamps `online` on connect, it reads the saved status (`db.ConnectStatus`: idle/dnd/invisible survive, anything else becomes online) and announces _that_, before `buildReady` runs so the member list and the broadcast cannot disagree. A chosen status now survives a disconnect (`MarkUserDisconnected` clears only `online`) and a restart, and the stale-choice problem that would otherwise create is handled at read time: a member with no live connection renders offline whatever the column says. The client's `restoreSavedPresence` shrank to a no-op safeguard that only speaks up when the server genuinely disagrees. The one place that read the column as a _value_ rather than through the two collapse functions was the `@here` fan-out, which tested `status == "offline"` literally and so would have pinged exactly the people who had asked not to be seen; it now collapses through `db.BroadcastStatus` first, so `@here` skips invisible readers and `@everyone` still reaches them.
|
||||
Custom status rides the presence payload rather than getting its own message: `presence_update` takes an optional `custom_status` where **omitted means "leave it alone"** and `""` clears — a distinction that exists because the auto-idle timer sends a bare status flip several times an hour and must not blank the text the user typed. It persists across reconnects and is cleared on logout (a "what I am doing right now" note that outlives the session states something no longer true, unlike the status itself, which is a preference).
|
||||
**Auto-idle** is client-side (`@lib/autoIdle`): ten quiet minutes → idle, any input → online, input listening throttled to 1 Hz because mousemove fires hundreds of times a second against a timer measured in minutes. Its whole safety property is one function, `nextAutoStatus`: only a _manual_ Online becomes an automatic Idle, and only an _automatic_ Idle goes back to Online — a manually chosen Idle is a statement, and dnd/invisible are never touched in either direction. That needed `userStatus` to record who chose the status ("auto" vs "manual"), which is also what lets a stored pre-phase-6 `"offline"` be migrated to `invisible` on read.
|
||||
Client: a shared `@lib/avatar` helper is now the single answer to "how do I draw this user" — message rows, the reply preview, the member list, the user bar, the profile popup and the account card all went through it, and it fetches the authenticated file through the same cert-pinned bearer-token path attachments and emoji use (an `<img src>` cannot carry an Authorization header, so the URL would 401) while keeping the letter as the fallback until and unless the bytes arrive. Display names render everywhere with a username fallback, resolved from the _member store_ first so a rename patches messages already on screen; the popup shows the `@handle` underneath so the thing you would actually type is still one glance away. The `about` section the popup has rendered since the quick-wins phase finally has real data behind it. The Account tab grew an avatar uploader (client-side type/size/dimension check mirroring the server's, so a refusal costs no upload) plus display-name and about fields, and the StatusPicker gained a custom-status input and sends `invisible` as its own value.
|
||||
@@ -131,8 +131,8 @@ slash commands (separate plan: `slash-commands.md`).
|
||||
by `listEmoji`/`uploadEmoji`/`deleteEmoji` against the real routes.
|
||||
- `UserProfilePopup`'s `about` section (phase 6) — built, styled and tested
|
||||
while every call site passed a hardcoded `null`; `users.about` now feeds it.
|
||||
- The DM sidebar's **Friends** nav item (phase 6) — *deleted rather than
|
||||
implemented*. Its `onFriendsClick` was never passed by any call site and its
|
||||
- The DM sidebar's **Friends** nav item (phase 6) — _deleted rather than
|
||||
implemented_. Its `onFriendsClick` was never passed by any call site and its
|
||||
`friendsActive` was never set; giving it a destination would have meant a
|
||||
follow/request model, a table and a second notion of "who may DM whom"
|
||||
alongside blocks. The item, both dead props and its CSS are gone, and a test
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
# HP-0 — Baseline acceptance scorecard
|
||||
|
||||
**Hold point:** HP-0, defined in
|
||||
[repo-health-roadmap-2026-08-23.md](repo-health-roadmap-2026-08-23.md)
|
||||
**Commit:** `6a1561fa` on `dev` (B0 integration commit, PR #1409)
|
||||
**Measured:** 2026-08-25
|
||||
**Evidence base:** [b0-baseline-2026-08-25.md](b0-baseline-2026-08-25.md)
|
||||
|
||||
**Decision: ACCEPTED — 2026-08-25 by J3vb (repository owner).**
|
||||
|
||||
This is the single artifact HP-0 requires. It answers the hold point's four
|
||||
questions and records what was accepted with known gaps rather than claimed as
|
||||
complete. Part-closes `R-08`.
|
||||
|
||||
Acceptance is not a claim that OwnCord is beta-ready. It is a claim that the
|
||||
baseline is **truthful, reproducible, and sufficient to begin B1**.
|
||||
|
||||
## Question 1 — what is green, red, unavailable, and unverified
|
||||
|
||||
| Metric | Baseline | Target | Actual | Evidence |
|
||||
| ------------------------------------- | ---------------------------- | ------------------ | ---------------------------------------------- | ------------------------------------------------------------------- |
|
||||
| Required checks green | refresh in B0 | 100% | **green** — 10 pinned checks pass | PR #1410 on `dev`; pinned set below |
|
||||
| Open P0 | 4 (G-01, G-02, G-03, C-06) | 0 for B0 | **0** — all four closed | [b0-baseline](b0-baseline-2026-08-25.md) dispositions |
|
||||
| Open P1 | 45 | 0 by B10 | **45**, none in B0 scope | [register](repo-health-issue-register-2026-08-23.md), phases B1–B10 |
|
||||
| Unresolved security findings | private count | 0 by B10 | **7**, all publicly owned, 0 unmapped | Question 4 below |
|
||||
| Requirement rows release-qualified | 0 | 100% by B10 | **0** | [traceability](beta-requirements-traceability-2026-08-23.md) |
|
||||
| Server aggregate coverage | 74.6% | ratchet in B3 | **74.6%** measured | b0-baseline, measured |
|
||||
| Client honest coverage | refresh in B0 | ratchet in B7 | **not measured** — see gaps | `C-03`, B7 |
|
||||
| Static-analysis warnings | 471 Oxlint | 0 unapproved by B7 | **471**, unchanged | `C-02`, B7 |
|
||||
| Server builds (4 tag variants) | — | pass | **pass** ×4 | measured |
|
||||
| `go vet` / `-race` / `-tags deadlock` | — | pass | **pass** | measured |
|
||||
| `golangci-lint` | claimed broken (G-05) | pass | **0 issues**, 19 linters, 1.18s | G-05 **refuted** |
|
||||
| Client unit + integration | 2 failing | green | **5257 passed / 0 failed** | G-01, G-02 fixed |
|
||||
| Client `tsc` / `lint` / `prettier` | — | pass | **pass** | measured |
|
||||
| Playwright | never terminated | green and exits | **293 passed, exit 0, 37s** | `C-06` fixed |
|
||||
| Rust tests + clippy | **carried, not re-measured** | pass | **115 passed, clippy `-D warnings` exit 0** | **re-measured 2026-08-25**; CI `Rust Unit Tests` green on Linux |
|
||||
| Docker build + boot smoke | unavailable | pass | **pass**, 50.1 MB, boots `:8443` | `ENV-02` closed |
|
||||
| Largest lazy chunk | — | budget in B7 | 1,998.25 kB min / 1,344.96 kB gzip | measured |
|
||||
| Generated/doc drift | refresh in B0 | 0 | **0** — `sqlc-verify`, `protocol-verify` green | CI |
|
||||
| Ledger path resolution | — | 0 dead | **0 dead paths / 348 records** | re-verified at `6a1561fa` |
|
||||
| Desktop/browser/device matrix | incomplete | 100% by B10 | **incomplete** | B6–B8 |
|
||||
| 250/100/25 capacity profile | unproven | met by B6 | **unproven** | `S-14`, B6 |
|
||||
| Upgrade/rollback/restore | unproven | green by B6 | **unproven** | B6 |
|
||||
|
||||
### Accepted with known gaps
|
||||
|
||||
Three items are accepted as _stated limitations_, not as green:
|
||||
|
||||
1. **Every measured row was produced on local Node 26, not CI's Node 24**
|
||||
(`ENV-01`). `.nvmrc` is now 24 and CI pins 24, but the local runtime is 26.
|
||||
The full single-source-of-truth work is B1 (`RL-17` / `C-01`). The CI-side
|
||||
confirmation now exists — PR #1410 ran the complete matrix on Node 24 and
|
||||
passed — but the _numbers_ in the table above remain the Node 26 ones.
|
||||
2. **Client coverage percentage is not measured.** `C-03` recorded that coverage
|
||||
could not complete while G-01/G-02 failed. Both are fixed, so it is now
|
||||
obtainable; establishing the honest baseline and its exclusions is B7 work.
|
||||
3. **The 38 open `OC-*` records are counted and verified non-stale, not
|
||||
individually adjudicated.** See Question 2.
|
||||
|
||||
Nothing here is a B1 blocker.
|
||||
|
||||
## Question 2 — which confirmed issues block each later phase
|
||||
|
||||
**No confirmed issue blocks B1.**
|
||||
|
||||
Open ledger, re-verified at `6a1561fa`:
|
||||
|
||||
| Status | Count |
|
||||
| --------- | ------- |
|
||||
| fixed | 306 |
|
||||
| open | **38** |
|
||||
| declined | 3 |
|
||||
| duplicate | 1 |
|
||||
| **total** | **348** |
|
||||
|
||||
Of the 38 open records:
|
||||
|
||||
- **11 medium, 27 low. Zero high, zero critical.**
|
||||
- All from one hunt, `general-2026-08-22-b`.
|
||||
- **All 38 resolve to a live `file:line`** — 0 dead paths across all 348 records,
|
||||
re-checked at this commit, not carried from B0's check at `5cc08889`.
|
||||
- 22 sit under `Client/tauri-client/`, 16 under `Server/`.
|
||||
- **None is assigned to B1.** Their register phases span B2–B10 only.
|
||||
|
||||
They are therefore accepted as _counted, non-stale, and assigned_ rather than
|
||||
individually adjudicated. Deciding each is bughunt-fix work. The 22 under the
|
||||
client path are a **sequencing input to B1-1**, not a blocker: the flatten must
|
||||
re-point their recorded paths, and the same dead-path check above is the proof.
|
||||
|
||||
Planning rows by phase are in the
|
||||
[register](repo-health-issue-register-2026-08-23.md). The 45 open P1 rows are
|
||||
distributed across B1–B10; the B1-owned ones are `L-01`, `L-10`, `L-16`, `C-01`,
|
||||
`R-04`, `R-09`, and are the subject of
|
||||
[b1-repository-foundation-2026-08-25.md](b1-repository-foundation-2026-08-25.md).
|
||||
|
||||
## Question 3 — which checks protect the integration branch
|
||||
|
||||
`dev` is protected and **status checks are now pinned** (2026-08-25), closing
|
||||
B0's one outstanding step. Applied by
|
||||
[`b0-dev-branch-protection.sh`](b0-dev-branch-protection.sh); verified against
|
||||
the live API.
|
||||
|
||||
| Setting | Value |
|
||||
| ------------------------ | ------------------- |
|
||||
| Pull request required | yes |
|
||||
| Approvals required | 0 (solo maintainer) |
|
||||
| Applies to admins | yes |
|
||||
| Force pushes / deletions | disabled |
|
||||
| Required status checks | **12** |
|
||||
|
||||
Pinned:
|
||||
|
||||
```
|
||||
Server Build & Test (ubuntu-latest) Client E2E (Playwright)
|
||||
Server Build & Test (windows-latest) Client E2E (parity subset, blocking)
|
||||
Client Static Checks Analyze (go)
|
||||
Client Unit Tests Analyze (javascript-typescript)
|
||||
Rust Unit Tests Analyze (actions)
|
||||
Repository Hygiene Docs & Ledger Consistency
|
||||
```
|
||||
|
||||
`Repository Hygiene` was added 2026-08-26 (B1-3, S-05) and
|
||||
`Docs & Ledger Consistency` 2026-08-27 (B1-6, L-07); both names were read off a
|
||||
live PR after the job reported, per the rule below.
|
||||
|
||||
Deliberately **not** pinned, with the observed reason:
|
||||
|
||||
| Check | Why not |
|
||||
| --------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| `Server Docker Build (verify)` | Reports **skipping** on a dev PR (`if: ref_name=='main' \|\| base_ref=='main'`). |
|
||||
| `Tauri Full Build (${{ matrix.os }})` | Reports **skipping** on a dev PR, under the _unexpanded_ matrix name — the job is skipped before matrix expansion. |
|
||||
| `Admin Panel E2E (real server, non-blocking)` | `continue-on-error: true`, so it reports success unconditionally. Requiring it would be theatre. Graduating it is `R-01`, B10. |
|
||||
| `CodeQL` | Default-setup aggregate over the three `Analyze` jobs; pinning those is sufficient. |
|
||||
|
||||
A required check that never reports blocks every PR forever, so the list was
|
||||
read off a live dev-targeted PR with `gh pr checks`, not inferred from
|
||||
`ci.yml`. That mattered: **three of the twelve exist in no workflow file**, because
|
||||
CodeQL runs from GitHub default setup configured in repository settings.
|
||||
|
||||
Two consequences to carry into B1:
|
||||
|
||||
- **The CI Docker job is skipped on every dev-targeted PR.** Docker evidence for
|
||||
a dev change must be produced locally.
|
||||
- **Full Tauri packaging never runs on a dev PR.** It is not an integration gate
|
||||
(`C-15`, B1/B10).
|
||||
|
||||
## Question 4 — which security details remain private
|
||||
|
||||
The independent source review of `5cc08889` is reconciled. Its detailed reports
|
||||
stay untracked and gitignored (`docs/security-findings/`, 0 tracked files); this
|
||||
section is deliberately content-free per [docs/security.md](../security.md).
|
||||
|
||||
| | |
|
||||
| ---------------------------------------- | ---------------------------------------------- |
|
||||
| Private findings | **7** — 5 medium, 2 low. No high, no critical. |
|
||||
| Confirmed fixed at the reviewed revision | **0** |
|
||||
| Mapped to an existing public row | **7 of 7** |
|
||||
| Unmapped / untracked | **0** |
|
||||
|
||||
Public owners, already opaque in the register: `SEC-01`, `SEC-02`, `SEC-03`,
|
||||
`SEC-04`, plus `C-09`, `S-01`, and one `OC-*` ledger record. Every private
|
||||
finding has exactly one public owner; no finding is tracked only in private.
|
||||
|
||||
Remediation reproduction detail, source-to-sink traces, exploit conditions, and
|
||||
affected-release analysis remain in the private reports and any advisory raised
|
||||
from them. **None of it belongs in a public commit, issue, or PR description** —
|
||||
this repository is public. `RL-22` is likewise tracked publicly as `L-16` with
|
||||
no mechanism described.
|
||||
|
||||
None of the seven is a B1 blocker; they are phased B2–B6.
|
||||
|
||||
## What acceptance does and does not authorise
|
||||
|
||||
**Authorises:** starting B1, per
|
||||
[b1-repository-foundation-2026-08-25.md](b1-repository-foundation-2026-08-25.md).
|
||||
|
||||
**Does not authorise:** treating any row above as release-qualified, starting
|
||||
server feature work (B2+), client architecture extraction (B7), or browser work
|
||||
(B8). The phase exits remain serial.
|
||||
|
||||
## Corrections this scorecard records
|
||||
|
||||
Two B0-era statements did not survive checking, and are corrected here rather
|
||||
than left to propagate:
|
||||
|
||||
- **`golangci-lint` (G-05)** — refuted in B0; recorded again because the
|
||||
register still carries the original claim.
|
||||
- **"Repository-settings writes are blocked from the agent sandbox"** —
|
||||
`b0-dev-branch-protection.sh` was written on that assumption and marked
|
||||
run-it-yourself. The `PUT` succeeded on 2026-08-25. The script remains the
|
||||
record of intent and the way to re-apply or undo the settings.
|
||||
@@ -0,0 +1,365 @@
|
||||
# HP-1 — Structural diff review and B1 exit scorecard
|
||||
|
||||
**Hold point:** HP-1, defined in
|
||||
[repo-health-roadmap-2026-08-23.md](repo-health-roadmap-2026-08-23.md)
|
||||
**Commits reviewed:** the pre-squash commits of #1411 and #1417 (table below)
|
||||
**Measured at:** `db3f28b7`, the B1-8 branch off `dev` `eb873fe7`
|
||||
**Measured:** 2026-08-27
|
||||
**Evidence base:** [b0-baseline-2026-08-25.md](b0-baseline-2026-08-25.md),
|
||||
[b1-repository-foundation-2026-08-25.md](b1-repository-foundation-2026-08-25.md)
|
||||
|
||||
**Decision: ACCEPTED — 2026-08-27 by J3vb (repository owner).**
|
||||
|
||||
All eight exit conditions are evidenced below. Condition 6 is accepted **as a
|
||||
stated limitation, not as met**: `dev` carries `strict: false`, so a PR can
|
||||
still merge without re-testing against a moved base. Nothing found in the
|
||||
structural review blocks acceptance. **B1 is complete and B2 may begin.**
|
||||
|
||||
HP-1 asks one question: were B1's structural changes **mechanical**? This
|
||||
scorecard answers it with reproducible commands rather than assertion, then
|
||||
walks the B1 exit gate's eight conditions. It follows the shape of
|
||||
[hp-0-scorecard-2026-08-25.md](hp-0-scorecard-2026-08-25.md).
|
||||
|
||||
Acceptance is not a claim that OwnCord is beta-ready. It is a claim that B1's
|
||||
migrations changed structure without changing behaviour, and that B2 may begin.
|
||||
|
||||
## The commits under review are not on `dev`
|
||||
|
||||
`dev` is squash-merge only, so each B1 PR landed as **one** commit. The
|
||||
separation HP-1 exists to review — pure move, then adjacent mechanical
|
||||
rewrite — survives only on the pull-request refs:
|
||||
|
||||
```bash
|
||||
git fetch origin 'refs/pull/1411/head:pr-1411' 'refs/pull/1417/head:pr-1417'
|
||||
```
|
||||
|
||||
| PR | On `dev` | Pre-squash commits |
|
||||
| ------------------------------------- | ---------- | --------------------------------------------------------------------------------------- |
|
||||
| #1411 — flatten `Client/tauri-client` | `7365a31b` | `4befe699` pure move, `38ddca73` path rewrite |
|
||||
| #1417 — ownership moves | `9eba6969` | `63b52249` protocol, `93ee14d5` seed, `474bb217` test tier, `7a4e5dc3` Go module rename |
|
||||
|
||||
**Reading `dev` history alone cannot satisfy HP-1.** Anyone re-running this
|
||||
review must fetch the PR refs first. This is a process finding, not a defect:
|
||||
squash merge is the repository's chosen model, so future phases that require a
|
||||
structural review should record the pre-squash SHAs at merge time, as this
|
||||
table now does.
|
||||
|
||||
## Question 1 — was the pure move pure?
|
||||
|
||||
`7c286abe..4befe699`, the first half of the flatten.
|
||||
|
||||
| Measure | Required | Actual |
|
||||
| -------------------------------------- | -------- | ------- |
|
||||
| Rename entries | — | **473** |
|
||||
| Non-rename summary entries | 0 | **0** |
|
||||
| Renames at similarity R100 | all | **473** |
|
||||
| Text files with any line added/removed | 0 | **0** |
|
||||
| Renamed blobs whose object ID changed | 0 | **0** |
|
||||
|
||||
```bash
|
||||
git diff -M --summary 7c286abe 4befe699 | grep -vc '^ rename ' # 0
|
||||
git diff -M --raw 7c286abe 4befe699 | grep -oE 'R100' | wc -l # 473
|
||||
git diff -M --numstat 7c286abe 4befe699 | awk '$1!="-" && ($1!="0"||$2!="0")' # empty
|
||||
git diff -M --raw 7c286abe 4befe699 | awk '$5 ~ /^R/ {print $3, $4}' | awk '$1!=$2' # empty
|
||||
```
|
||||
|
||||
**Verdict: PASS.** Every one of 473 files moved with a byte-identical blob,
|
||||
binaries included. Nothing was edited under cover of the move.
|
||||
|
||||
A note on method: `--numstat` prints `-` for binary files, so a naive
|
||||
`$1!="0"||$2!="0"` filter flags the six binaries (icons, `rnnoise.wasm`) as
|
||||
changes. The blob-OID comparison is the check that actually covers them, and it
|
||||
is the one to keep.
|
||||
|
||||
## Question 2 — was the path rewrite mechanical?
|
||||
|
||||
`4befe699..38ddca73`. 33 files, **983 lines added and 983 removed** — equal
|
||||
counts, which is necessary but nowhere near sufficient.
|
||||
|
||||
The real proof normalises the substitution the commit claims to make and looks
|
||||
for any line left unpaired:
|
||||
|
||||
```bash
|
||||
git diff 4befe699 38ddca73 | grep -E '^[+-]' | grep -v '^[+-][+-]' \
|
||||
| sed 's#Client/tauri-client#Client#g; s#tauri-client/##g; s#^[+-]##' \
|
||||
| sort | uniq -u
|
||||
```
|
||||
|
||||
**Result: six unpaired pairs**, every one a relative-path depth change that a
|
||||
plain string substitution cannot express — the flatten removed one directory
|
||||
level, so `../../` became `../`. Each was resolved against `HEAD`:
|
||||
|
||||
| File | Change | Verified |
|
||||
| ---------------------------------------------- | ------------------------------------- | --------------------------------------------------------------------- |
|
||||
| `.github/workflows/release.yml` ×2 | `../../windows/` → `../windows/` | step has `working-directory: Client`; `../windows` is the repo root ✓ |
|
||||
| `Server/updater/updater_test.go` | dropped `"tauri-client"` path segment | resolves to `Client/src-tauri/tauri.conf.json`, which exists ✓ |
|
||||
| `Client/tests/unit/admin-static-…test.ts` | `../../../../` → `../../../` | resolves to `Server/admin/static/index.html`, which exists ✓ |
|
||||
| `Client/tests/e2e/admin/start-server.sh` | `../../../../../` → `../../../../` | resolves to `Server/` ✓ |
|
||||
| `Server/service/sanitize_content_fuzz_test.go` | comment path | comment only, no code ✓ |
|
||||
| `.claude/workflows/bughunt.harness.mjs` | hotspot lens label | self-test assertion string, no code ✓ |
|
||||
|
||||
**Verdict: PASS with six documented exceptions**, all mechanical, none a
|
||||
behaviour change.
|
||||
|
||||
The release-signer line deserves its own note. `.github/workflows/release.yml`
|
||||
runs only on a tag, so **no CI run on any branch ever executes it** — a wrong
|
||||
`../` there would have surfaced on release day. It is correct: the step declares
|
||||
`working-directory: Client`, the artifacts sit at the repository root, so
|
||||
`../windows/chatserver.exe` resolves. A downstream step
|
||||
(`Verify signed assets against pinned server update key`) runs from the root and
|
||||
fails closed if the signer wrote to the wrong place, so the path is guarded as
|
||||
well as correct.
|
||||
|
||||
## Question 3 — was the Go module rename mechanical?
|
||||
|
||||
`474bb217..7a4e5dc3`. `github.com/owncord/server` → `github.com/J3vb/OwnCord/Server`.
|
||||
350 files, 728 added and 728 removed.
|
||||
|
||||
```bash
|
||||
git diff 474bb217 7a4e5dc3 | grep -E '^[+-]' | grep -v '^[+-][+-]' \
|
||||
| sed 's#github.com/owncord/server#github.com/J3vb/OwnCord/Server#g; s#^[+-]##' \
|
||||
| sort | uniq -u
|
||||
```
|
||||
|
||||
**Result: empty. Zero unpaired lines.**
|
||||
|
||||
**Verdict: PASS, unconditionally.** The largest single change in B1 is provably
|
||||
a pure string substitution.
|
||||
|
||||
## Question 4 — the other three ownership moves
|
||||
|
||||
`63b52249` (protocol), `93ee14d5` (seed), `474bb217` (test tier) are **not**
|
||||
pure renames, and were never claimed to be — the plan specifies content changes
|
||||
for each. Reviewed individually:
|
||||
|
||||
| Commit | Change | Assessment |
|
||||
| ---------- | ---------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `63b52249` | `docs/protocol-schema.json` → `protocol/schema.json` | The schema's only content change is its `$comment` string (it now names `npm run generate`). **The protocol constants are byte-identical** — no wire-format drift. The +115/−76 is dominated by a new 36-line `protocol/README.md`. **Mechanical.** |
|
||||
| `93ee14d5` | seed tool → `Server/cmd/seed/` | `func init()` deleted and its `os.MkdirAll("data", 0o750)` moved into `main()`. **This is a deliberate behaviour change**, specified by the plan (`RL-10`), in its own commit, and it is the intended one: the directory is created when the tool runs, not when its package is linked. **Correctly split out.** |
|
||||
| `474bb217` | cross-stack tests → `tests/contract` | Both directions retiered: `Server/updater/updater_test.go` → `tauri_key_contract_test.go`, and `Client/tests/unit/admin-static-channel-perms.test.ts` → `Client/tests/contract/server-admin-static-channel-perms.test.ts`. Renames plus assertion-preserving edits. **Mechanical.** |
|
||||
|
||||
**Verdict: PASS.** One behaviour change exists, it was authorised in advance,
|
||||
and it is isolated in its own commit — which is exactly what HP-1 asks for.
|
||||
|
||||
## Question 5 — is the active path inventory complete?
|
||||
|
||||
```bash
|
||||
git grep -Il "tauri-client" # 11 files
|
||||
```
|
||||
|
||||
All eleven are historical records, and **zero** are active code, workflows,
|
||||
scripts, hooks, or the Dockerfile:
|
||||
|
||||
- `.superpowers/findings-ledger.json` — hunt _lens labels_ such as
|
||||
`hotspot-client-tauri-client-src`, not filesystem paths. Records of what was
|
||||
hunted, and rewriting them would falsify the record.
|
||||
- Six dated `docs/audit-*.md` — deliberately left alone so links from old commit
|
||||
messages keep resolving. B1 lists editing them as out of scope.
|
||||
- Four `docs/plans/*.md` — documents that describe the move and must name the
|
||||
old path to do so.
|
||||
|
||||
**Verdict: PASS.** The rewrite is complete.
|
||||
|
||||
## B1 exit gate
|
||||
|
||||
| # | Condition | Status | Evidence |
|
||||
| --- | -------------------------------------------------------------------- | ----------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| 1 | One setup path; Windows and Linux; no directory guessing | **met** | Fresh-clone smoke, both platforms — below |
|
||||
| 2 | Desktop behaviour, release asset names, update contracts unchanged | **met** | See below |
|
||||
| 3 | Move and rewrite independently reviewable; active path refs complete | **met** | Questions 1–5 |
|
||||
| 4 | Generated sources have explicit reproducible owners | **met** | `check:server` regenerates and diffs both generators — below |
|
||||
| 5 | Protocol schema generates and verifies both consumers from the root | **met** | `protocol/schema.json` → `Server/ws/message_types.go` + `Client/src/lib/protocolTypes.ts`; enforced in `ci.yml`, `.githooks/pre-commit`, and `Server/ws/protocol_contract_test.go` |
|
||||
| 6 | Every `dev` integration commit has exact-SHA CI | **partially met** | See below — 12 checks pinned, but `strict: false` |
|
||||
| 7 | Issues, Discussions, PRs, private security reporting match the model | **met** | B1-7 (#1419), merged 2026-08-27 — see below |
|
||||
| 8 | Full B0 evidence remains green after the migration | **met** | Gate run below; every B0 number reproduced |
|
||||
|
||||
### Condition 2 — nothing that names a release asset derives from a directory
|
||||
|
||||
| Field | Value | Derived from directory? |
|
||||
| ------------------- | ---------------------- | ----------------------- |
|
||||
| `productName` | `OwnCord` | no |
|
||||
| `identifier` | `com.owncord.client` | no |
|
||||
| Cargo crate | `owncord-client` | no |
|
||||
| Cargo lib | `owncord_client_lib` | no |
|
||||
| `updater.endpoints` | `[]` (server-mediated) | n/a |
|
||||
|
||||
`Server/updater/assets.go` selects update assets with
|
||||
`strings.HasSuffix(a.Name, suffix)` against suffixes like `_amd64.AppImage.tar.gz`
|
||||
— **filename only, never a path**. The flatten therefore cannot rename a release
|
||||
asset. The cross-component test
|
||||
`Server/updater/tauri_key_contract_test.go` reads the client's
|
||||
`tauri.conf.json` from disk and still resolves after the move.
|
||||
|
||||
### Conditions 1, 4 and 8 — the gate run
|
||||
|
||||
Measured 2026-08-27 on the B1-8 branch. Every step below exited 0.
|
||||
|
||||
**Windows** — `npm run check` and `npm run check:server`:
|
||||
|
||||
| Step | Result |
|
||||
| ------------------------------------------------------------------- | ----------------------------------------------------------------- |
|
||||
| `go build ./...` ×4 tag variants (`—`, `otel`, `wazero`, both) | pass ×4 |
|
||||
| `go vet ./...` | pass |
|
||||
| `go test -race ./...` | pass (`ws` 116.2s) |
|
||||
| `go test -tags deadlock -count=1 ./ws/` | pass (54.3s) |
|
||||
| `golangci-lint run ./...` | pass |
|
||||
| `cargo fmt --check`, `cargo test --lib`, `cargo clippy -D warnings` | pass — **115 Rust tests**, clippy clean |
|
||||
| `npx prettier --check .` | pass |
|
||||
| `check:docs` | pass — 21 claims across 8 watched documents agree with the ledger |
|
||||
| `shellcheck`, `actionlint` | **skipped** — no clean Windows install; CI runs both |
|
||||
|
||||
**Linux, Node 24** — a genuinely fresh `git clone` into a `node:24` container,
|
||||
then `npm run bootstrap` and the scoped checks:
|
||||
|
||||
| Step | Result |
|
||||
| ------------------------------------- | --------------------------------------- |
|
||||
| `npm run bootstrap` (3 × `npm ci`) | pass — `engine-strict` accepted Node 24 |
|
||||
| `npm run check:docs`, `check:hygiene` | pass |
|
||||
| `npm run check:client` | pass — **192 files, 5257 tests** |
|
||||
|
||||
**This closes `ENV-01`.** B0 measured 5257 client tests on Node 26 and recorded
|
||||
the Node 24 figure as unverified. The containerised run reproduces **5257 exactly
|
||||
on Node 24**, from a clone with no untracked files — which also proves the setup
|
||||
path does not depend on anything a contributor would not receive.
|
||||
|
||||
**Condition 4 — generated sources.** `check:server` regenerates each generated
|
||||
tree and diffs it, so reproducibility is proven rather than asserted:
|
||||
|
||||
| Generator | Verification |
|
||||
| -------------------------- | ------------------------------------------------------------------------------------- |
|
||||
| `go run ./cmd/genprotocol` | `git diff --exit-code ws/message_types.go ../Client/src/lib/protocolTypes.ts` — empty |
|
||||
| `sqlc generate` | `git diff --exit-code db/dbgen` — empty |
|
||||
|
||||
**Docker** (`ENV-02`) — the CI job is `main`-gated and skips on dev PRs, so this
|
||||
was produced locally:
|
||||
|
||||
```bash
|
||||
MSYS_NO_PATHCONV=1 docker build --build-arg VERSION=ci -t owncord-smoke:candidate Server/
|
||||
MSYS_NO_PATHCONV=1 bash Server/scripts/docker-smoke.sh owncord-smoke:candidate # exit 0
|
||||
```
|
||||
|
||||
Image **50.1 MB**, boots as uid 65532 on `:8443` with TLS. Matches B0 exactly.
|
||||
|
||||
Three traps this run hit, recorded so the next person does not:
|
||||
|
||||
- **The build context is `Server/`, not the repository root.** `ci.yml` sets
|
||||
`context: Server/`; only `Server/.dockerignore` exists. Building from the root
|
||||
streams the whole working tree (400 MB+) and then fails on the missing
|
||||
`go.mod`.
|
||||
- **`docker image inspect --format '{{.Size}}'` reports 12.5 MB for this image.**
|
||||
It is not the figure B0 recorded. `docker images … --format '{{.Size}}'` gives
|
||||
50.1 MB and is the one to compare against.
|
||||
- **`ENV-03` is still open.** `docker-smoke.sh` moved to `Server/scripts/` and now
|
||||
takes the image as an argument, but still does not set `MSYS_NO_PATHCONV`
|
||||
itself, so Git Bash on Windows still reports a boot failure that did not
|
||||
happen. The B1 plan's copy of the command was stale and is corrected in this
|
||||
change.
|
||||
|
||||
### Condition 6 — pinned, but not strict
|
||||
|
||||
Live protection on `dev`, read from the API rather than inferred:
|
||||
|
||||
| Setting | Value |
|
||||
| ------------------------ | ------------------------------------------------------------------------------------------- |
|
||||
| Pull request required | yes, **0** approvals |
|
||||
| `enforce_admins` | **true** |
|
||||
| Force pushes / deletions | disabled |
|
||||
| Required checks | **12** — B1-0's 10, plus `Repository Hygiene` (B1-3) and `Docs & Ledger Consistency` (B1-6) |
|
||||
| `strict` | **false** |
|
||||
|
||||
The twelfth was pinned on 2026-08-27 by running
|
||||
[`b0-dev-branch-protection.sh`](b0-dev-branch-protection.sh), which B1-6 landed
|
||||
but deliberately did not run — repository-settings writes need a person. Until
|
||||
that run, the FINDINGS.md drift gate reported but could not block a merge.
|
||||
|
||||
`strict: false` means "require branches to be up to date before merging" is
|
||||
**off**. When `dev` advances after a PR's checks go green, that PR can still
|
||||
merge without re-running them — so the squash commit that lands on `dev` was
|
||||
never itself tested; what was tested is the PR's changes against an _older_
|
||||
base. B1 merged seven PRs across two days, so this is a live condition, not a
|
||||
theoretical one.
|
||||
|
||||
The exit gate's wording is "every `dev` integration commit has exact-SHA CI".
|
||||
Under `strict: false` that is **not satisfied**, and the scorecard records it as
|
||||
such rather than reading the pinned-checks list as sufficient.
|
||||
|
||||
**Deliberately not changed here.** Flipping `strict` to `true` closes the gap
|
||||
but forces a rebase on every open PR each time another lands, and
|
||||
`enforce_admins: true` means the repository owner is not exempt. That trade is
|
||||
the owner's to make, and it is a repository-settings change rather than a code
|
||||
one. Carried as an open item.
|
||||
|
||||
### Condition 7 — closed by B1-7, plus the settings it could not apply
|
||||
|
||||
B1-7 (#1419) landed the community model on 2026-08-27, and its two
|
||||
repository-settings scripts were applied the same day:
|
||||
|
||||
| Control | State |
|
||||
| ---------------------- | --------------------------------------------------------------------------------------------- |
|
||||
| `Release tags` ruleset | **active**, target `tag`, `refs/tags/v*`, blocks update + deletion, **0 bypass actors** |
|
||||
| `release` environment | created, **1 required reviewer** (`J3vb`), no wait timer |
|
||||
| Discussions slugs | `q-a` and `ideas` both exist and match `.github/ISSUE_TEMPLATE/config.yml` — routing resolves |
|
||||
|
||||
Two things that read-back surfaced, neither blocking:
|
||||
|
||||
- **The `release` environment has `can_admins_bypass: true`** (GitHub's
|
||||
default). The ruleset was created with `bypass_actors: []` so nobody can
|
||||
bypass _it_, but the reviewer gate on the environment is admin-bypassable.
|
||||
On a repository where the sole admin is also the sole reviewer this changes
|
||||
little, and it is the setting to revisit if that ever stops being true.
|
||||
- **`.github/workflows/claude.yml` cannot authenticate.** It passes
|
||||
`claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}`, and that
|
||||
secret does not exist on the repository — `gh secret list` shows six secrets,
|
||||
none of them it. Five `issue_comment` runs exist and every one is `skipped`,
|
||||
so the B1-7 guard is stopping them before the missing secret would matter.
|
||||
The paid-automation surface `RL-22` hardens is therefore inert today.
|
||||
|
||||
`RL-22` is coordinated privately per [security.md](../security.md) and does not
|
||||
appear in public commits, issues, or PR descriptions — so that part of this row
|
||||
is closed by B1-7's merge plus the private record, not by anything visible here.
|
||||
|
||||
The structural proofs in Questions 1–5 were measured before B1-7 merged, which
|
||||
does not matter: they compare fixed historical SHAs. The **gate run** did
|
||||
matter, so it was re-run after this branch was rebased onto `c0c87366` (B1-7),
|
||||
over the final tree — including B1-7's `check-workflow-guards.mjs`, which
|
||||
`check:hygiene` now runs twice (`--selftest`, then live). Its sibling
|
||||
`verify-gate-evidence.mjs` is **not** in the local facade: `ci.yml` runs its
|
||||
`--selftest` and the assert form needs a `$GITHUB_TOKEN` and a real SHA, so CI
|
||||
is the only place it is exercised.
|
||||
|
||||
## Open items carried past B1
|
||||
|
||||
Recorded, not fixed. Nothing here blocks B2's entry gate.
|
||||
|
||||
| Item | State |
|
||||
| -------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| `RL-08` — WASM artifact gate | **blocked.** The pinned TinyGo rejects Go 1.26, so a compile-and-compare job needs a second Go SDK. B1-6 judged the cheaper honest option to be untracking the prebuilt artifact and documenting the build. Unresolved. |
|
||||
| `dev` `strict: false` | **open.** Condition 6 above. |
|
||||
| 38 open `OC-*` findings | **counted, non-stale, assigned.** 11 medium / 27 low, zero high or critical, none assigned to B1. Accepted at HP-0; re-stated here, not re-adjudicated. |
|
||||
| Two unreferenced Rust commands | `probe_credential_store` and `ptt_get_key` are registered in `lib.rs` and invoked from nowhere. Dead-surface candidates for B7, recorded in [platform-contracts.md](../architecture/platform-contracts.md). |
|
||||
| Human owners for `platform/` | **none exist.** Ownership is recorded by phase (B7/B8/B2) because there is nothing else to record. |
|
||||
| `environment: release` in the workflow | **open, deliberately.** `.github/workflows/release.yml` does not name the environment. The key stalls a release if the environment does not exist, so B1-7 left it out; the environment now exists, so this is a separate two-line change. |
|
||||
| `release` env admin bypass | **open.** `can_admins_bypass: true` (GitHub default). The ruleset has zero bypass actors, but the reviewer gate does not. Matters only once the sole admin stops being the sole reviewer. |
|
||||
| `CLAUDE_CODE_OAUTH_TOKEN` | **absent.** `claude.yml` passes it and the repository has no such secret, so the workflow cannot authenticate. Every run to date is `skipped` at the B1-7 guard, so nothing is failing — but the automation is inert. |
|
||||
| `ENV-03` — `MSYS_NO_PATHCONV` | **open, P2.** `Server/scripts/docker-smoke.sh` still does not set it, so Git Bash on Windows reports a boot failure that did not happen. |
|
||||
|
||||
## Hand-off to B2
|
||||
|
||||
B2's entry gate has three conditions. B1 closes the first; the other two are
|
||||
**B2 entry work, not B1 debt**:
|
||||
|
||||
| B2 entry condition | State after B1 |
|
||||
| -------------------------------------------------------------------- | -------------------------------------------------------------------------------------- |
|
||||
| B1 is complete and protocol source has one owner | **met** — `protocol/schema.json`, relocated in B1-5, verified from one root command |
|
||||
| Confirmed security findings have private owners and acceptance tests | **B2 work.** HP-0 mapped 7 private findings to public rows; acceptance tests are B2's. |
|
||||
| Alpha protocol fixtures and updater contracts are captured | **B2 work.** Not started. |
|
||||
|
||||
## What acceptance does and does not authorise
|
||||
|
||||
Accepting HP-1 authorises B2 to begin. It does **not** claim:
|
||||
|
||||
- that the client can run in a browser — the seam is documented, not built (B7);
|
||||
- that every `dev` commit has been CI-tested as it landed — see condition 6;
|
||||
- that the 38 open findings are resolved — they are assigned, not fixed;
|
||||
- that B1's numbers were all re-measured on CI's exact toolchain — see the
|
||||
limitations recorded against condition 8.
|
||||
@@ -76,7 +76,7 @@ decisions live in Rust:
|
||||
- **Pin store:** the same per-host fingerprint store used by `ws_proxy.rs`
|
||||
(`certs.json` via `commands.rs`); one fingerprint per host covers all three
|
||||
transports.
|
||||
- **First contact:** unlike today, the *first* TLS contact with a server is
|
||||
- **First contact:** unlike today, the _first_ TLS contact with a server is
|
||||
the login HTTP request, not the WS connect. The HTTP proxy must therefore
|
||||
implement the same first-trust flow as `ws_proxy.rs`: unknown host →
|
||||
accept, store fingerprint, emit `cert-tofu` event (banner); known host +
|
||||
|
||||
@@ -119,7 +119,7 @@ The highest-impact track. Ordered.
|
||||
writable; ACME needs `AmbientCapabilities=CAP_NET_BIND_SERVICE`;
|
||||
`TimeoutStopSec=35` matches the 30s drain), a "Linux (systemd)" deployment
|
||||
section, and a cron backup one-liner. Add a "Reverse Proxy Topology" section
|
||||
with a working nginx snippet — and state correctly that LiveKit *signaling*
|
||||
with a working nginx snippet — and state correctly that LiveKit _signaling_
|
||||
is already proxied at `/livekit/*`; only WebRTC media (UDP range / TCP
|
||||
fallback) must be directly reachable.
|
||||
6. **Backup robustness.** Make the backup directory configurable (mirror the
|
||||
|
||||
@@ -26,13 +26,13 @@ silently divergent for any future multi-bit mask.
|
||||
|
||||
**2. A channel-level `deny` is genuinely not honoured — one layer down.**
|
||||
`PermissionService.getOrPopulate` (`permission.go:145-149`) and
|
||||
`ChannelService.ListVisibleChannels` (`channel.go:58-61`) substitute an *empty
|
||||
override map* when `GetAllChannelPermissionsForRole` errors. Every `deny` bit for
|
||||
`ChannelService.ListVisibleChannels` (`channel.go:58-61`) substitute an _empty
|
||||
override map_ when `GetAllChannelPermissionsForRole` errors. Every `deny` bit for
|
||||
that role evaporates, and `PermissionService` then **caches** the degraded
|
||||
snapshot for `permCacheTTL` (30s), across `HasChannelPerm`'s ~25 callers: message
|
||||
reads, pins, attachment serving, WS. Meanwhile `permissions.Checker`
|
||||
(`checker.go:60-63`), `MessageService.GetAccessibleChannelIDs`
|
||||
(`message.go:643-646`) and `ws.buildReady` (`serve.go:622-624`) all fail *closed*
|
||||
(`message.go:643-646`) and `ws.buildReady` (`serve.go:622-624`) all fail _closed_
|
||||
on the identical error. Two of five sites dissent, and they are the cached ones.
|
||||
|
||||
D9 also declared `VisibleChannelIDs` the single visibility predicate; it missed a
|
||||
@@ -109,7 +109,7 @@ see Non-goals.
|
||||
cannot hand a `r.Use` middleware a `{id}` declared on its own mux (v5.2.5
|
||||
`mux.go:513`), `GET /api/v1/files/{id}` could never use it (its channel id
|
||||
comes from the DB row), and ws has no HTTP middleware — so it would be a
|
||||
*second* enforcement point for a rule the `Checker` owns. `channelID=0` would
|
||||
_second_ enforcement point for a rule the `Checker` owns. `channelID=0` would
|
||||
issue a query whose right-looking answer is an accident of `ErrNoRows`
|
||||
handling (`db/channel_queries.go:140`), not a design.
|
||||
- **The auth-route DB sweep (item 12 / A-2026-07-06).** `AuthMiddleware` has 20
|
||||
@@ -123,7 +123,7 @@ see Non-goals.
|
||||
lint with a known ceiling, catching what the two new tests plus review already
|
||||
catch. Revisit as a `golangci-lint` rule if it recurs.
|
||||
- **`ws.channelCanSend`** (`serve.go:583-590`) — the last hand-rolled copy. It
|
||||
holds an override *value*, not a map, so reducing it needs a one-entry map
|
||||
holds an override _value_, not a map, so reducing it needs a one-entry map
|
||||
allocation on the ready hot path or a new value-taking predicate. Disclosed
|
||||
deliberately rather than fixed; separate PR.
|
||||
- No `(bool, error)` permission signatures (`ws/deps.go:86-90`'s
|
||||
|
||||
@@ -0,0 +1,330 @@
|
||||
# OwnCord repository-health issue register
|
||||
|
||||
**As of:** 2026-08-23
|
||||
**Audited head:** `5cc0888964e26276d1aca145e83270a2c1b9febd` (`dev`)
|
||||
**Release target:** first public beta, quality-gated with no calendar deadline
|
||||
**Purpose:** exhaustive, public-safe planning index for bringing the server,
|
||||
desktop client, browser/PWA client, repository, and release process to the
|
||||
approved beta bar.
|
||||
|
||||
Companion documents:
|
||||
|
||||
- [Beta product requirements](beta-product-requirements-2026-08-23.md)
|
||||
- [Repository-layout audit](../audit-2026-08-23-repository-layout.md)
|
||||
- [Phased beta roadmap](repo-health-roadmap-2026-08-23.md)
|
||||
|
||||
This document is a planning view, not a replacement for
|
||||
`.superpowers/findings-ledger.json`. The ledger remains authoritative for
|
||||
`OC-*` finding status. Security-sensitive reproduction detail belongs in a
|
||||
private GitHub Security Advisory; this public register contains only opaque
|
||||
work packages and non-sensitive acceptance criteria.
|
||||
|
||||
## Overall status
|
||||
|
||||
**OwnCord is not beta-ready at this audited head.** The server has a strong
|
||||
tested foundation, but security remediation, compatibility, deployment,
|
||||
capacity, identity, recovery, deletion, retention, and moderation work remain.
|
||||
The desktop client has broad automated coverage, but its required unit-coverage
|
||||
gate is red and its full Playwright run does not terminate. The approved
|
||||
browser/PWA/phone/tablet client is mostly not implemented.
|
||||
|
||||
| Surface | Evidence at the audited head | Health conclusion |
|
||||
| ------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------ |
|
||||
| Server builds | Default, OpenTelemetry, Wazero, and combined build-tag variants pass; `go vet` passes | Strong |
|
||||
| Server behavior | Full race suite, deadlock suite, and tagged tests pass; CI-style aggregate coverage is 74.6% | Strong, with missing coverage/performance gates |
|
||||
| Server local limitations | Docker daemon was unavailable; local `golangci-lint` could not load because its Go 1.26.5 build mismatched the module's Go 1.26.7 toolchain | CI/container evidence still required for the exact SHA |
|
||||
| Client static/build | App and E2E typechecks, ESLint, Prettier, Knip, production dependency audit, Vite build, Rust Clippy, and 115 Rust tests pass | Healthy foundation |
|
||||
| Client unit coverage | 5,255 tests pass and 2 fail (`message-list` and `noise-suppression-restart`) | Required gate red |
|
||||
| Client browser tests | Three Chromium browser tests pass | Useful but too narrow |
|
||||
| Client Playwright | All 293 test start markers appeared with no reported assertion failure, but the run never exited; an isolated five-test voice-widget run also hung | Cannot be claimed green |
|
||||
| Client bundles | Build succeeds, but RNNoise/live-session output is about 2.0 MB minified / 1.345 MB gzip and Vite reports oversized/dynamic-import warnings | Performance work required |
|
||||
| Browser/PWA/mobile | No standalone browser production target, optional server hosting, PWA, Web Push, or beta-quality phone/tablet navigation exists | Major beta capability gap |
|
||||
| Security | A private current-HEAD source review identified unresolved security-boundary work; public tracking uses opaque remediation families while detailed evidence remains private | Beta blocker; details remain private |
|
||||
| Repository/release | Exact `dev` SHA has no Actions run; supported ARM64 and multi-architecture release coverage is incomplete | Beta blocker |
|
||||
|
||||
## Classification and counting rules
|
||||
|
||||
Priority:
|
||||
|
||||
- **P0:** a required gate is red or the audited integration cannot be released.
|
||||
- **P1:** close before beta; security, authorization, data safety, compatibility,
|
||||
or major reliability/release risk.
|
||||
- **P2:** scheduled architecture, performance, accessibility, operational, or
|
||||
contributor-experience debt.
|
||||
- **P3:** low-risk cleanup, monitoring, or an explicitly recorded decision.
|
||||
|
||||
State:
|
||||
|
||||
- **confirmed:** reproduced, validated, or directly observed at the audited head.
|
||||
- **verify:** credible evidence exists, but a focused reproduction is required.
|
||||
- **decision:** the owner must select and record one supported direction.
|
||||
- **watch:** an upstream or accepted risk has no demonstrated reachable defect.
|
||||
- **resolved/superseded:** the original observation is no longer current; a
|
||||
broader active item owns any remaining work.
|
||||
|
||||
The tables deliberately separate four kinds of work. They must not be added
|
||||
together as if each row were a unique defect:
|
||||
|
||||
1. `OC-*` rows are the canonical open defect ledger.
|
||||
2. `G/C/S/R/L-*` rows are audit work packages, guardrails, or architecture debt.
|
||||
3. `SEC-*` rows are opaque security-remediation families; duplicates are
|
||||
explicitly named.
|
||||
4. `BG-*` rows are approved beta capabilities that are absent or incomplete,
|
||||
not regressions in an already-complete feature.
|
||||
|
||||
## Canonical findings-ledger truth
|
||||
|
||||
| Status | Count |
|
||||
| --------- | ------: |
|
||||
| Fixed | 306 |
|
||||
| Open | 38 |
|
||||
| Declined | 3 |
|
||||
| Duplicate | 1 |
|
||||
| **Total** | **348** |
|
||||
|
||||
All 38 open records are listed below. Closing a planning row does not close an
|
||||
`OC-*` record: the implementation, regression test, focused verification, full
|
||||
required gates, and ledger update must land together.
|
||||
|
||||
## Immediate gate and truth issues
|
||||
|
||||
| ID | Pri | State | Issue and evidence | Phase | Closure evidence |
|
||||
| ---- | --: | --------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----- | ----------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| G-01 | P0 | confirmed | `message-list.test.ts` expects zero abort registrations while current row-scoped cancellation registers five; the full coverage run fails. | B0 | Test states the intended row-lifetime invariant, proves the historical leak shape, and passes in the complete Node 24/Vitest 4 run. |
|
||||
| G-02 | P0 | confirmed | `noise-suppression-restart.test.ts` supplies a non-constructible arrow-function mock for `MediaStream`; Vitest 4 rejects it. | B0 | Constructible test double, meaningful RED proof, and complete coverage run green. |
|
||||
| G-03 | P0 | confirmed | Current `dev` SHA has no Actions run because ordinary `dev` pushes are not covered by the complete push matrix. This is the canonical owner for layout finding RL-14. | B0/B1 | Every integration SHA receives the protected full blocking matrix; this exact SHA or its superseding remediation SHA is green. |
|
||||
| G-04 | P1 | confirmed | The ledger now correctly exposes 38 open items, but older plans/snapshots still claim zero open or leave shipped phases pending. | B0/B1 | Active-plan index identifies current, complete, and superseded documents; automated checks prevent conflicting status/count claims. |
|
||||
|
||||
## Canonical open defect ledger
|
||||
|
||||
The wording below is intentionally concise. The ledger contains the detailed
|
||||
evidence, reproduction, and suggested fix for each record.
|
||||
|
||||
| ID | Sev | Area | Public-safe defect summary | Phase | Required closure evidence |
|
||||
| ------- | ------ | ----------------------- | --------------------------------------------------------------------------------------------------------------------------- | ------ | ------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| OC-0311 | Medium | Client voice/E2EE | A leave event from another readable voice channel can mutate the active call's peer-key state. | B2/B7 | Scope leave handling to the active channel and cover reordered leave/join/replay sequences. |
|
||||
| OC-0312 | Medium | Client PTT | Binding push-to-talk during a call can clear mute ownership before the deferred mute applies, leaving PTT unusable. | B7 | Preserve the PTT ownership transition atomically and test mid-call binding/restart. |
|
||||
| OC-0313 | Medium | Client profiles | Legacy per-user volume fallback is repeatedly copied across server profiles instead of being consumed once. | B4/B7 | One-time scoped migration, legacy-key removal, and cross-server isolation tests. |
|
||||
| OC-0314 | Medium | Client identity | The client discards the server's partial-success warning when a credential change succeeds but session revocation does not. | B4/B9 | Surface warnings for password/TOTP changes with an action to review sessions; test all affected endpoints. |
|
||||
| OC-0315 | Medium | Client replay | Replay-gate timestamps mix naive UTC server values with local wall-clock parsing. | B2/B7 | One UTC parsing contract and timezone-varied replay boundary tests. |
|
||||
| OC-0316 | Medium | Server/client E2EE | WebSocket resume restores peer public keys but not a room key rotated during the outage. | B2/B7 | Resume re-establishes the current room key and the security indicator cannot claim success prematurely; rotation/outage test passes. |
|
||||
| OC-0317 | Medium | Client DM state | The replay path can regress a DM's `lastMessageId`, undermining duplicate-count protection. | B2/B7 | Monotonic last-message updates with duplicate, out-of-order, and reconnect tests. |
|
||||
| OC-0318 | Medium | Server plugins | Install-time and restart-time plugin manifest precedence differs between JSON and TOML. | B2 | One canonical manifest contract or explicit ambiguity rejection; install/restart parity test. |
|
||||
| OC-0319 | Medium | Client accessibility | The Large Font preference is overridden by a higher-priority inline font-size value. | B9 | Verified text-scale change across restart, zoom, responsive layouts, and accessibility checks. |
|
||||
| OC-0320 | Medium | Server updater | Server self-update selects Linux AMD64 independently of the running architecture. | B6/B10 | Architecture-aware manifest selection and signed update/rollback smoke on every supported server target. |
|
||||
| OC-0321 | Medium | Server TOTP | A TOTP key-file read failure can be treated as absence and lead to key replacement. | B4 | Generate only on confirmed non-existence; all other read errors fail closed without modifying the file. |
|
||||
| OC-0322 | Low | Client connection | TypeScript host validation accepts a hostname form rejected by the native proxy. | B2/B7 | Shared validation corpus produces identical browser, desktop, and Rust decisions. |
|
||||
| OC-0323 | Low | Server unread state | Mark-read/channel-focus can overwrite a mention count from a newer message using a stale snapshot. | B3/B5 | Atomic/monotonic read-state update with concurrent-message regression coverage. |
|
||||
| OC-0324 | Low | Server auth | Login rate-limit identity folding differs from SQLite account lookup semantics. | B4 | Account lookup and limiter use one tested canonical identity rule, including Unicode collision cases. |
|
||||
| OC-0325 | Low | Client search | Search results parse naive UTC timestamps as local time. | B7/B9 | Shared UTC parser and timezone/day-boundary rendering tests. |
|
||||
| OC-0326 | Low | Client pins | Pinned-message timestamps parse naive UTC values as local time. | B7/B9 | Shared UTC parser and timezone/day-boundary rendering tests. |
|
||||
| OC-0327 | Low | Server voice moderation | Server mute/deafen also affects screen-share audio contrary to the product contract. | B5 | Effective moderation applies only to intended media sources; SFU and client-policy tests agree. |
|
||||
| OC-0328 | Low | Client unread state | Channel badges lack the message-ID replay guard already used by DMs. | B2/B7 | Monotonic channel replay guard with duplicate/out-of-order/reconnect tests. |
|
||||
| OC-0329 | Low | Client privacy | Legacy DM profile notes fall back across servers indefinitely. | B4/B7 | One-time server-scoped migration, old-key removal, and cross-server privacy test. |
|
||||
| OC-0330 | Low | Client pins | Pinned messages discard author identity and therefore cannot resolve nicknames. | B7/B9 | Preserve author ID and render the same display identity as ordinary messages. |
|
||||
| OC-0331 | Low | Server admin UI | API-token Created/Last Used values parse naive UTC timestamps as local time. | B6/B9 | Shared UTC contract and timezone/day-boundary admin tests. |
|
||||
| OC-0332 | Low | Client updater | Bare IPv6 server addresses produce an invalid updater URL. | B6/B10 | Central URL builder brackets IPv6 literals and passes domain/IPv4/IPv6/update smoke tests. |
|
||||
| OC-0333 | Low | Client voice UI | Voice-roster render identity does not change when a participant is renamed mid-call. | B7/B9 | Reactive identity signature and rename-in-call test. |
|
||||
| OC-0334 | Low | Client PTT | Escape closes Settings and can simultaneously be saved as the captured PTT key. | B7/B9 | Escape cancels capture without persistence; teardown and timeout paths are tested. |
|
||||
| OC-0335 | Low | Client lifecycle | Each Add Server modal retains listeners and its removed subtree for the connect-page lifetime. | B7 | Modal-owned abort lifecycle; repeated open/close instrumentation shows no accumulation. |
|
||||
| OC-0336 | Low | Client lifecycle | Server-profile rows re-register page-lifetime listeners on every render. | B7 | Row/render ownership prevents accumulation under repeated updates and teardown. |
|
||||
| OC-0337 | Low | Server replay | Cold-tier voice replay truncation can discard the newest events and reconstruct the wrong roster. | B2/B3 | Ordered, bounded replay retains the correct window; boundary/resume tests reconstruct the authoritative roster. |
|
||||
| OC-0338 | Low | Server plugins | TOML plugin manifests can omit configured memory and CPU resource limits. | B2/B3 | Explicit TOML mapping and JSON/TOML resource-limit parity tests. |
|
||||
| OC-0339 | Low | Server config | A valid but empty configuration section is reported as an unknown ineffective key. | B6 | Empty known sections are accepted; true unknown keys remain actionable and tested. |
|
||||
| OC-0340 | Low | Server CLI | A negative API-token expiry can create a token that never expires. | B4/B6 | CLI and HTTP share positive-expiry validation; negative/zero/boundary tests fail safely. |
|
||||
| OC-0341 | Low | Server CLI | A numeric token label cannot be revoked because parsing commits to the ID path. | B4/B6 | Unambiguous ID/label selection or safe fallback with numeric-label regression tests. |
|
||||
| OC-0342 | Low | Client voice UI | Voice avatar letter/color derives from username while the adjacent label may be a nickname. | B9 | Avatar and label consistently derive from the displayed identity. |
|
||||
| OC-0343 | Low | Desktop shell | Clicking the tray icon can hide a minimized window instead of restoring it. | B7/B9 | Minimized windows unminimize and focus; only visible, non-minimized windows toggle hidden. |
|
||||
| OC-0344 | Low | Server TLS | Automatic HTTP-to-HTTPS redirect assumes port 443 instead of the configured HTTPS endpoint. | B6 | Redirect derives the configured public origin/port and passes default/custom/domain/IP tests. |
|
||||
| OC-0345 | Low | Server owner auth | Owner middleware repeats a role read and maps a transient read failure to forbidden. | B3/B4 | Reuse the authenticated context and preserve correct unavailable/unauthorized distinctions in failure tests. |
|
||||
| OC-0346 | Low | Server telemetry | Panic recovery reads trace context before tracing middleware creates it. | B3/B6 | Middleware order gives recoveries the active trace ID; panic-path structured-log test passes. |
|
||||
| OC-0347 | Low | Client DM voice UI | A DM call label reads but does not subscribe to DM state, so it remains stale. | B7/B9 | Subscribe to the owning state and test mid-call rename/update. |
|
||||
| OC-0348 | Low | Client presence | The online-count header includes the local invisible user while the member list presents that user as offline. | B7/B9 | Count and list share one visibility policy with invisible-status regression tests. |
|
||||
|
||||
## Public-safe security remediation
|
||||
|
||||
An independent current-HEAD security review produced detailed reports that
|
||||
remain untracked/private until fixed or coordinated through private advisories.
|
||||
This register carries only non-sensitive security properties and opaque
|
||||
remediation families; an apparently related engineering row is not evidence
|
||||
that any private report is fixed.
|
||||
|
||||
| ID | Pri | State | Opaque remediation family | Phase | Public closure evidence |
|
||||
| ------ | --: | --------- | --------------------------------------------------------- | ----- | ------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| SEC-01 | P1 | confirmed | Atomic concurrent password-confirmation admission. | B4 | One server-owned admission decision, bounded concurrent attempts, and race/load regression coverage. |
|
||||
| SEC-02 | P1 | confirmed | Effective channel-level voice moderation permissions. | B5 | Voice moderation delegates to the same effective-permission policy as the authoritative channel action, with override and denial tests. |
|
||||
| SEC-03 | P1 | confirmed | Bounded per-response and aggregate preview/media reads. | B2/B5 | Streaming limits are enforced before buffering; aggregate memory/concurrency budgets, timeout, cancellation, and adversarial boundary tests pass. |
|
||||
| SEC-04 | P1 | confirmed | Durable per-user/server storage quotas and disk headroom. | B3/B6 | Transaction-safe quotas cover files and cumulative storage; low-disk behavior fails safely and is exercised by restart/concurrency tests. |
|
||||
|
||||
## Client engineering issues
|
||||
|
||||
These are broader gates and work packages; canonical `OC-*` defects above are
|
||||
not recounted here.
|
||||
|
||||
| ID | Pri | State | Issue and evidence | Phase | Closure evidence |
|
||||
| ---- | --: | --------- | ---------------------------------------------------------------------------------------------------------------------------------------------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| C-01 | P1 | confirmed | `.nvmrc`/active docs use Node 20, while CI uses Node 24 and package metadata does not enforce the intended runtime. Canonical owner for RL-17. | B1 | One Node/npm source of truth drives local setup, packages, CI, release, and docs; wrong majors fail fast. |
|
||||
| C-02 | P1 | confirmed | Oxlint exits zero with 471 warnings, concentrated in LiveKit/E2EE bindings. | B7 | Narrowly allow intentional generated/external names, fix actionable warnings, and make the blocking invocation warning-free. |
|
||||
| C-03 | P1 | confirmed | Coverage cannot complete because G-01/G-02 fail, and exercised entry/orchestration files remain excluded. | B0/B7 | Green full report includes exercised production files; exclusions are minimal and documented; thresholds ratchet from an honest baseline. |
|
||||
| C-04 | P2 | confirmed | Unit/E2E runs emit expected warnings and large expected debug/error output, obscuring unexpected failures. | B7 | Expected logs are captured/asserted; a green run has no unexplained runtime warnings or log flood. |
|
||||
| C-05 | P3 | confirmed | Knip passes with four configuration hints. | B7 | No hints, or each retained exception has a current inline rationale. |
|
||||
| C-06 | P0 | confirmed | Full Playwright and an isolated voice-widget subset fail to terminate on Windows after their observed test activity. | B0/B10 | Playwright exits unaided locally and in CI; the cause is regression-tested and no child process remains. |
|
||||
| C-07 | P1 | confirmed | Static RNNoise inclusion creates an approximately 2.0 MB minified / 1.345 MB gzip feature chunk. | B7/B9 | Load on demand, cache after first use, and prove voice/noise restart/fallback behavior. |
|
||||
| C-08 | P2 | confirmed | Vite warns about oversized chunks, but no startup/route/feature bundle budget blocks regressions. | B7/B9 | Recorded gzip budgets fail CI on regression and distinguish startup from lazy feature cost. |
|
||||
| C-09 | P1 | confirmed | Desktop external-preview destination policy is not fully centralized at the native trust boundary. | B2/B7 | One native policy owns resolution, redirects, destinations, time/body limits, and parsing; broad capability scope is removed. |
|
||||
| C-10 | P1 | confirmed | Client CSP permits broad HTTPS/WSS destinations and lacks a generated per-deployment allowlist contract. | B2/B7/B8 | Required origins/protocols are inventoried and minimized for desktop/browser modes with functional regression tests. |
|
||||
| C-11 | P2 | confirmed | Four production import cycles remain in LiveKit/audio and message attachment/media/embed code. | B7 | Production graph is acyclic or an approved seam and boundary test documents each unavoidable cycle. |
|
||||
| C-12 | P2 | confirmed | High-change client modules remain very large, including LiveKit/E2EE, dispatcher, and settings surfaces. | B7/B9 | Responsibility maps guide cohesive extractions behind stable tested seams without behavior or coverage regression. |
|
||||
| C-13 | P2 | confirmed | Duplicated color/host literals, many timer call sites, and an O(n) sidebar DOM-rebuild TODO remain. | B7/B9 | Shared tokens/config, lifecycle-owned timers, and measured incremental sidebar updates replace the duplication/hot path. |
|
||||
| C-14 | P1 | confirmed | Native smoke configuration exists but is absent from blocking CI because it needs a built app and real server. | B10 | Release candidates run packaged native smoke on the supported Windows/Linux architecture matrix. |
|
||||
| C-15 | P1 | confirmed | Full Tauri packaging is not a routine exact-SHA integration gate. | B1/B10 | Cost-conscious integration/nightly/RC jobs package without exposing signing secrets to untrusted dependency PRs. |
|
||||
| C-16 | P2 | confirmed | Mutation fixes landed after the last measured 67.04% baseline; the suite was not rerun. | B7/B10 | Fresh baseline, survivor triage, and ratcheted targets for critical transport/auth/E2EE modules. |
|
||||
| C-17 | P3 | confirmed | Direct real-browser coverage is only three Chromium tests and is concentrated on RNNoise. | B8/B10 | Browser-only API risk inventory drives blocking Chromium/Firefox/WebKit coverage plus real-device qualification where emulation is insufficient. |
|
||||
| C-18 | P3 | watch | Cargo has no known reachable vulnerability, but allowed unmaintained transitive crates and compatible patches require ownership. | B10 | Compatible patches are reviewed; warnings are revisited each dependency cycle; platform migration path is recorded. |
|
||||
|
||||
## Server engineering issues
|
||||
|
||||
| ID | Pri | State | Issue and evidence | Phase | Closure evidence |
|
||||
| ---- | --: | --------- | ----------------------------------------------------------------------------------------------------------------------------------- | ------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| S-01 | P1 | confirmed | Typing currently checks a weaker permission than posting. | B2/B3 | Typing delegates to the same send-policy predicate; denial, announcement, and override tests prevent drift. |
|
||||
| S-02 | P1 | confirmed | Invite create/revoke are privileged mutations without the audit coverage used by sibling mutation families. | B4/B5 | Successful create/revoke produce safe, non-secret audit events; failure behavior is tested. |
|
||||
| S-03 | P2 | confirmed | Admin channel name/topic/category validation lacks one explicit rune/normalization contract. | B3/B5 | Shared limits cover admin and user writers; boundary tests count runes, not bytes. |
|
||||
| S-04 | P2 | confirmed | Sibling admin channel lookups expose inconsistent DM/not-found response contracts. | B3 | One non-DM resolution policy and response contract covers both paths. |
|
||||
| S-05 | P2 | confirmed | Repository-wide Go formatting is not a required gate. | B1 | Tree is formatted and a fast required gate fails future drift. |
|
||||
| S-06 | P2 | confirmed | Server coverage is uploaded without a global or core-package regression floor; current aggregate is 74.6%. | B3/B10 | Documented baseline/exclusions and ratcheted global/core thresholds. |
|
||||
| S-07 | P2 | confirmed | Thousands of tests and 17 fuzz targets exist, but there are no Go benchmarks for hub/replay, permission, DB, or fan-out hot paths. | B6/B10 | Stable microbenchmarks and reference load baselines cover the highest-risk paths. |
|
||||
| S-08 | P2 | confirmed | Large lifecycle/hub/serve files remain structural hotspots. | B3 | Cohesive extractions preserve lifecycle, locking, race, and deadlock invariants. |
|
||||
| S-09 | P2 | confirmed | API/admin/WebSocket layers still contain many direct database call sites. | B3 | Each use moves behind a narrow service/store seam or is documented as an intentional transaction/composition boundary. |
|
||||
| S-10 | P2 | confirmed | Auth routes still consume raw database ownership and are the first intended S-09 migration slice. | B3/B4 | Tested AuthService/narrow interfaces preserve enumeration and sentinel-error behavior. |
|
||||
| S-11 | P2 | confirmed | Hub construction uses post-construction collaborator setters, leaving required wiring temporally coupled to `Run`. | B3 | Required collaborators are validated constructor/options inputs; only genuinely dynamic dependencies remain mutable. |
|
||||
| S-12 | P2 | confirmed | Ready/refresh/WebSocket paths mirror message send-permission policy by hand. | B3 | All paths delegate to one value-taking predicate with parity tests. |
|
||||
| S-13 | P2 | confirmed | Durable TOTP used-code and partial-auth persister work remains incomplete. | B4 | Hash-only persistence, expiry, restart, and failure-mode tests land without persisting sliding rate-limit windows. |
|
||||
| S-14 | P1 | confirmed | Load tooling exists, but no supported capacity result is published for the approved 250 users / 100 connections / 25 voice profile. | B6/B10 | Reproducible report states hardware/software, CPU, memory, DB waits, p95/p99 latency, and pass/fail thresholds. |
|
||||
| S-15 | P3 | verify | `voice_speakers` and `member_leave` remain reserved protocol entries with no production emit site. | B2 | Compatibility review removes unused entries before the epoch freeze or explicitly reserves and fixtures them; schema, generated types, docs, and tests agree. |
|
||||
| S-16 | P3 | verify | Voice key-holder TOCTOU hardening remains a documented follow-up without a demonstrated contract failure. | B2/B3 | Threat-model review either records why outer checks suffice or adds an in-function recheck and race-focused private test. |
|
||||
| S-17 | P3 | watch | Vulnerability tooling found no reachable Go advisory, while non-called/unmaintained upstream paths remain. | B6/B10 | Dependency path is monitored, compatible fixes are applied, and reachable-symbol scanning remains required. |
|
||||
|
||||
## Repository, CI, documentation, and supply chain
|
||||
|
||||
| ID | Pri | State | Issue and evidence | Phase | Closure evidence |
|
||||
| ---- | --: | ------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------- | --------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| R-01 | P1 | confirmed | Real-server admin E2E remains job-level non-blocking; its approximately 30-green graduation evidence is not recorded. | B10 | Thirty consecutive required integration runs or an equivalent statistically justified criterion passes before the job becomes blocking. |
|
||||
| R-02 | P2 | confirmed | Active documents disagree whether contributors branch/PR against `main` or `dev`. | B0/B1 | One protected branch model is reflected in docs, automation, templates, and repository settings. |
|
||||
| R-03 | P2 | resolved/superseded | The graph was refreshed at the audited head, resolving the old stale-SHA observation; generated-artifact ownership and local launcher portability remain under L-06. | B1 | No separate action; L-06 owns the remaining artifact-policy exit gate. |
|
||||
| R-04 | P1 | confirmed | Build/runtime container references use mutable tags without complete digest-refresh ownership. Canonical beta owner for the release supply-chain portion of RL-18. | B1/B6 | Reviewed immutable digests or automated digest PRs with smoke tests cover release/runtime images. |
|
||||
| R-05 | P2 | confirmed | Repeated API/schema/protocol prose drift is guarded mostly by a PR checkbox. | B1/B2 | Generated inventories/contract tests cover machine-checkable facts and the remaining prose has an explicit review gate. |
|
||||
| R-06 | P2 | confirmed | Active, completed, historical, and superseded plans are not indexed consistently. | B0/B1 | Docs landing page and plan index expose status; link/status checks catch contradictions. |
|
||||
| R-07 | P2 | confirmed | Major dependency, license, SBOM, and provenance review lacks one documented cadence across all dependency roots. | B1/B6/B10 | Automated coverage plus a dated recurring major/license review and signed release SBOM/provenance. |
|
||||
| R-08 | P1 | confirmed | No single beta scorecard defines allowed open priorities, platform coverage, security sign-off, soak, upgrade/restore drills, or performance evidence. | B0/B10 | Owner-approved scorecard is green after the release-candidate soak and links every evidence artifact. |
|
||||
| R-09 | P1 | confirmed | A version tag can publish without proof that the exact tagged SHA completed the protected beta gate. Canonical owner for RL-16. | B1/B10 | Publication consumes immutable exact-SHA gate evidence and a protected release approval. |
|
||||
|
||||
## Repository layout and contributor experience
|
||||
|
||||
The layout audit recommends a targeted, isolated migration—not a wholesale
|
||||
monorepo/server rewrite. Pure moves, mechanical path rewrites, and
|
||||
behavior-changing work must remain in separate reviewable commits.
|
||||
|
||||
| ID | Pri | Source | Required work | Phase | Closure evidence |
|
||||
| ---- | --: | ------ | ------------------------------------------------------------------------------------------------------------------------------------------ | ----- | ----------------------------------------------------------------------------------------------------------------------- |
|
||||
| L-01 | P1 | RL-01 | Flatten `Client/tauri-client/` to `Client/` as two adjacent non-functional commits: pure file moves, then mechanical active-path rewrites. | B1 | History/release asset names are preserved and the full baseline is unchanged after both commits. |
|
||||
| L-02 | P1 | RL-02 | Record the browser/desktop platform-contract map in B1, then introduce typed adapters for native-dependent frontend services. | B7 | The same adapter contract suite passes for desktop and browser implementations. |
|
||||
| L-03 | P1 | RL-03 | Establish independent `build:web` and `build:desktop` contracts from one shared UI after server-first phases close. | B7 | Both production builds are required and target-specific behavior is isolated. |
|
||||
| L-04 | P2 | RL-04 | Add cross-platform root bootstrap, format, generation, scoped, and full verification commands. | B1 | Fresh Windows/Linux contributors can discover and run the intended checks; Go-only direct commands remain supported. |
|
||||
| L-05 | P2 | RL-05 | Record the workspace decision and cover every lock root with deterministic install/dependency automation. | B1 | Measured rationale, immutable installs, and update coverage for all package roots. |
|
||||
| L-06 | P2 | RL-06 | Make large Graphify payloads reproducible CI artifacts; retain only a compact deterministic report if needed. | B1 | Portable local/CI generation works, committed report drift is checked, and published history is not rewritten. |
|
||||
| L-07 | P2 | RL-07 | Remove the tracked duplicate human rendering after deterministic on-demand/CI rendering and a drift check exist. | B1 | The JSON ledger remains canonical; a downloadable rendering is reproducible and CI rejects generation failure or drift. |
|
||||
| L-08 | P2 | RL-08 | Keep the example WASM source, stop tracking its prebuilt output, and compile/verify it in CI or release checks. | B1/B2 | Deterministic source build passes and no stable plugin API promise is implied. |
|
||||
| L-09 | P2 | RL-09 | Move protocol schema/generator ownership to a root protocol/tool boundary. | B1/B2 | One command generates Go and TypeScript consumers with zero drift. |
|
||||
| L-10 | P1 | RL-10 | Move executable tooling under conventional command ownership and remove package-discovery filesystem side effects. | B1 | Broad Go discovery is read-only and tool execution is explicit/tested. |
|
||||
| L-11 | P2 | RL-11 | Reclassify cross-stack invariants under an explicit owner or root system-contract tier. | B1 | Test names/location/commands expose ownership and CI runs the correct tier. |
|
||||
| L-12 | P2 | RL-13 | Align the Go module namespace to `github.com/J3vb/OwnCord/Server` in an isolated mechanical change. | B1 | Imports, generators, build tags, source archives, and downstream instructions agree. |
|
||||
| L-13 | P2 | RL-19 | Add an editor baseline and repository gates for Markdown, YAML, JSON, CSS, Rust, Go, shell, and workflows. | B1 | Cross-platform fast checks cover material tracked sources with explicit generated/vendor exclusions. |
|
||||
| L-14 | P2 | RL-20 | Make hooks portable and remove undocumented `make`/POSIX assumptions on Windows. | B1 | Hooks are thin optional wrappers around cross-platform root commands; prerequisites are explicit. |
|
||||
| L-15 | P2 | RL-21 | Route ideas/feedback to Discussions and modernize issue forms for browser, ARM64, deployment mode, and security reporting. | B1 | Intake matches BPR-100..102 and captures reproducible environment details. |
|
||||
| L-16 | P1 | RL-22 | Harden authorization for externally triggered paid automation. | B1 | Trusted authorization, least privilege, and cost-abuse regression tests are required. |
|
||||
|
||||
Layout findings reconciled elsewhere: RL-12 is owned by R-06; RL-14 by G-03;
|
||||
RL-15 by BG-20; RL-16 by R-09; RL-17 by C-01; and RL-18 by L-05,
|
||||
R-04, and R-07.
|
||||
|
||||
## Approved beta capability gaps
|
||||
|
||||
Every row below is required by the frozen beta product requirements. These are
|
||||
feature-completion gaps, not additions beyond scope.
|
||||
|
||||
| ID | Pri | State | Missing or incomplete beta capability | Phase | Exit evidence |
|
||||
| ----- | --: | --------- | ----------------------------------------------------------------------------------------------------------------------------------------- | --------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| BG-01 | P1 | confirmed | Optional server-hosted browser client, disabled by default. | B5/B8 | Owner opt-in controls hosting; disabled mode exposes no app route/assets; enabled mode passes upgrade/security smoke. |
|
||||
| BG-02 | P1 | confirmed | Installable PWA with safe shell caching, icons, standalone presentation, and update behavior. | B8 | Manifest/installability audits pass; service worker never caches API/messages/credentials and handles version changes safely. |
|
||||
| BG-03 | P1 | confirmed | Beta-quality responsive phone/tablet navigation, touch, keyboard, safe-area, and media UX. | B8/B9 | Real-device and emulated phone/tablet matrix passes defined journeys and accessibility checks. |
|
||||
| BG-04 | P1 | confirmed | Browser parity for credentials, transport, notifications, media/calls/E2EE, files, and safe external content where browser APIs allow. | B7/B8/B9 | Shared behavior/contract suite passes; every unavoidable browser limitation is explicit and safely degraded. |
|
||||
| BG-05 | P1 | confirmed | Per-server owner/user opt-in Web Push without an OwnCord-operated relay. | B5/B8 | Per-server keys/subscriptions, permission UX, unsubscribe/cleanup, privacy defaults, and supported-platform delivery tests pass. |
|
||||
| BG-06 | P1 | confirmed | Secure browser deployment for domains, raw public IPs, LAN, and offline modes without a required reverse proxy or routine manual renewal. | B6/B8 | Domain/IP automated TLS, private-LAN local-trust onboarding, manual-cert escape hatch, renewal/restart tests, and honest limitations are documented. |
|
||||
| BG-07 | P1 | confirmed | Explicit server/current-and-previous-two-client protocol negotiation and safe rejection. | B2/B7/B10 | N/N-1/N-2 compatibility matrix passes; out-of-window clients fail with an actionable update requirement. |
|
||||
| BG-08 | P1 | confirmed | New-login notices and sign-out-everywhere UI; per-session list/revoke backend exists but is not a complete user journey. | B4/B9 | Multi-device list, individual/all revocation, notices, stale-device handling, and audit tests pass. |
|
||||
| BG-09 | P1 | confirmed | Offline recovery kit, audited admin-assisted reset, and optional SMTP recovery. | B4/B9 | Non-reversible server storage, one-time rotation, session revocation, rate limits, operator/user UX, and restore tests pass. |
|
||||
| BG-10 | P1 | confirmed | Complete closed/invite/approval/open registration modes with invite-only default. | B4/B9 | Mode transitions, approvals, abuse limits, invitations, audit, and migration tests pass. |
|
||||
| BG-11 | P1 | confirmed | Full account erasure and backup non-resurrection. | B4 | Profile/auth/session/message/reaction/upload deletion is transactional/resumable, integrity logs are deidentified, and restore honors deletion markers. |
|
||||
| BG-12 | P1 | confirmed | Configurable server/channel retention with corresponding attachment cleanup. | B4/B5/B9 | Default indefinite retention remains; scheduled deletion, holds, audit, storage cleanup, backup/restore, and boundary tests pass. |
|
||||
| BG-13 | P1 | confirmed | Discord-style Message Requests for first-time DMs. | B5/B9 | Preview/accept/ignore/delete/block/trust relationship behavior is abuse-resistant and consistent across desktop/browser/PWA. |
|
||||
| BG-14 | P1 | confirmed | Local reports, permission-gated Moderation Center, workflow/audit history, moderator actions, and appeals. | B5/B9 | Report/evidence/assignment/status/notes/actions/appeal journeys enforce narrow permissions and immutable audit records. |
|
||||
| BG-15 | P1 | confirmed | Privacy-safe support bundle and verified zero automatic telemetry. | B6/B9 | Redaction tests and user preview/consent protect secrets/content; network audit proves no automatic product telemetry. |
|
||||
| BG-16 | P1 | confirmed | English-only but translation-ready user-facing text organization. | B7/B9 | User-visible strings are inventoried/extracted or deliberately exempted; locale/time/plural formatting has a stable seam. |
|
||||
| BG-17 | P1 | confirmed | Plugin-candidate boundary audit and consistent experimental/disabled labeling. | B2 | Candidate integrations are documented for post-beta; beta core security/identity/update/moderation/deletion remains core; no compatibility promise leaks. |
|
||||
| BG-18 | P1 | confirmed | NSFW consent must prevent fetch/render leakage before acknowledgement, not merely overlay already-mounted content. | B5/B9 | No content, preview, attachment, or third-party request occurs pre-consent; blur/gate/revoke tests pass. |
|
||||
| BG-19 | P1 | confirmed | Secure polish for link previews, GIFs, YouTube, and rich media. | B5/B9 | Provider boundaries, privacy controls, bounded retrieval, consent, caching, failure UX, and offline behavior pass shared tests. |
|
||||
| BG-20 | P1 | confirmed | Public-beta packaging/update matrix for Windows x64/ARM64, Linux x64/ARM64, server binaries, and multi-architecture Docker. | B6/B10 | Build, install/boot, signature/checksum, manifest, in-place alpha upgrade, rollback, and update tests pass on every approved architecture. |
|
||||
|
||||
## Discovery passes required before claiming exhaustive coverage
|
||||
|
||||
No finite static audit proves the absence of every latent defect. The strongest
|
||||
defensible completion claim is that each defined risk surface was inspected,
|
||||
candidates were independently validated, and accepted risks have owners. The
|
||||
following focused passes are mandatory during B0 through B10:
|
||||
|
||||
1. Authentication, session, recovery, TOTP, registration, and authorization
|
||||
sibling sweep.
|
||||
2. WebSocket sequencing, replay, replacement, compatibility, and lock-order
|
||||
simulation.
|
||||
3. Voice/LiveKit/E2EE lifecycle, moderation, resume, and fault injection.
|
||||
4. Client async lifetimes, detached DOM/listeners, timers, cancellation, and
|
||||
stale snapshots.
|
||||
5. Desktop proxy/TOFU/updater/signing, browser TLS/PWA/push, secure-context, and
|
||||
secrets-at-rest threat review.
|
||||
6. Database migrations, account deletion, retention, backup/restore,
|
||||
non-resurrection, disk-full, and crash consistency.
|
||||
7. Release supply chain, container provenance, dependency licenses, SBOM, and
|
||||
exact-SHA publication controls.
|
||||
8. Performance/memory profiling for startup, large histories, reconnect storms,
|
||||
100 connections, 25 voice participants, media restart, and long sessions.
|
||||
9. Keyboard, focus, screen reader, reduced motion, contrast, zoom, touch,
|
||||
phone/tablet layout, virtual keyboard, and destructive UX journeys.
|
||||
10. API/protocol/schema/config/documentation contract diff, including
|
||||
N/N-1/N-2 compatibility fixtures.
|
||||
11. Test-quality audit: stale assertions, tests that cannot fail, mutation
|
||||
survivors, fuzz targets, real-browser/native gaps, and shutdown leaks.
|
||||
12. Operational drills: unhealthy DB/disk/hub, certificate renewal, offline/LAN
|
||||
trust, backup recovery, updater rollback, and release artifact boot/install.
|
||||
|
||||
Each pass returns **confirmed / refuted / duplicate / accepted / blocked**.
|
||||
Security-sensitive confirmed detail moves to a private advisory before public
|
||||
planning or implementation discussion.
|
||||
|
||||
## Explicitly outside beta
|
||||
|
||||
Do not convert these into health-remediation work unless they reveal a defect
|
||||
in an approved beta contract:
|
||||
|
||||
- federation, cross-server identity, or cross-server messaging;
|
||||
- more than one active server connection per client;
|
||||
- anonymous guests or a centralized server directory;
|
||||
- native macOS, iOS, or Android applications;
|
||||
- a stable plugin API or bundled third-party plugins;
|
||||
- OwnCord-operated hosting, identity, push relay, telemetry, or moderation;
|
||||
- unrelated feature expansion after the frozen scope.
|
||||
|
||||
Good post-beta plugin candidates include GIF/embed providers, slash
|
||||
commands/bots/automation, webhooks/integrations, optional moderation automation
|
||||
with human/audit control retained, UI tabs, import/export bridges, and
|
||||
observability exporters. Authentication, authorization, TLS, safe fetch,
|
||||
quotas, E2EE, updates, moderation audit, deletion, and recovery remain beta core.
|
||||
File diff suppressed because it is too large
Load Diff
@@ -59,11 +59,12 @@ no coverage (repo rule: "Target 80%+ coverage; TDD is the expected workflow").
|
||||
## Wave 1 — Availability & backend-breaking (HIGH)
|
||||
|
||||
### W1-1. Plugin CPU budget must not permanently brick the module
|
||||
|
||||
- **File:** `Server/plugin/sandbox_wazero.go` (~line 224, `invokeCommand`)
|
||||
- **Root cause:** the runtime is built `WithCloseOnContextDone(true)`
|
||||
(line 72). The new per-call `context.WithTimeout` wraps `allocate`,
|
||||
`command_dispatch`, and `deallocate`, so an expired deadline *closes the
|
||||
module*. `inst.module` is only cleared by `platformDeactivate`, so nothing
|
||||
`command_dispatch`, and `deallocate`, so an expired deadline _closes the
|
||||
module_. `inst.module` is only cleared by `platformDeactivate`, so nothing
|
||||
re-instantiates it — one over-budget command bricks the plugin for all
|
||||
users until admin disable/enable or restart. The budget is wall-clock,
|
||||
floored at 100 ms, so any host HTTP call (`httpTimeout` = 10 s) trips it.
|
||||
@@ -75,13 +76,14 @@ no coverage (repo rule: "Target 80%+ coverage; TDD is the expected workflow").
|
||||
should pause during host calls). Reconsider the floor so legitimate work
|
||||
isn't killed.
|
||||
- **Verify:** new test (build tag `wazero`) that (a) a command exceeding the
|
||||
budget returns the budget error *and* a subsequent command on the same
|
||||
budget returns the budget error _and_ a subsequent command on the same
|
||||
plugin still succeeds; (b) a command performing a host HTTP call within
|
||||
`httpTimeout` is not killed by the CPU budget.
|
||||
|
||||
### W1-2. E2EE key rotation drops peers in 7+ participant calls
|
||||
|
||||
- **Files:** `Server/ws/voice_e2ee.go` (~line 151);
|
||||
`Client/tauri-client/src/lib/livekitSession.ts` (~lines 1317-1349)
|
||||
`Client/src/lib/livekitSession.ts` (~lines 1317-1349)
|
||||
- **Root cause:** the `voice_e2ee_offer` limit is 5/sec, but the key holder
|
||||
loops over every peer sending one offer each, back-to-back, with no pacing
|
||||
and no retry on `RATE_LIMITED`. With 6+ peers, offers to the 6th+ peer are
|
||||
@@ -90,7 +92,7 @@ no coverage (repo rule: "Target 80%+ coverage; TDD is the expected workflow").
|
||||
rotation.
|
||||
- **Fix (choose one, prefer server-side):**
|
||||
- Server: exempt the fan-out relay from the tight per-message cap — rate
|
||||
limit the *rotation event* (one budget per rotation) rather than each
|
||||
limit the _rotation event_ (one budget per rotation) rather than each
|
||||
per-peer offer; or scale the limit to channel size.
|
||||
- Client: add bounded pacing + retry/backoff on `RATE_LIMITED` so all peers
|
||||
eventually receive the offer.
|
||||
@@ -100,6 +102,7 @@ no coverage (repo rule: "Target 80%+ coverage; TDD is the expected workflow").
|
||||
receives the rotated key after a join/leave and after a periodic rotation.
|
||||
|
||||
### W1-3. Attachment-ownership check breaks Postgres and isn't atomic
|
||||
|
||||
- **Files:** `Server/service/message.go` (~line 183);
|
||||
`Server/db/queries/*attachment*.sql` + regenerate `dbgen`/`pgdbgen`;
|
||||
`Server/store/postgres.go`, `Server/store/sqlite.go`
|
||||
@@ -117,12 +120,13 @@ no coverage (repo rule: "Target 80%+ coverage; TDD is the expected workflow").
|
||||
`pgdbgen`); update the SQLite + Postgres migrations as a pair. This makes
|
||||
the check atomic, one query, and backend-agnostic, and removes the need for
|
||||
the `MemStore.GetAttachmentByID` `(nil,nil)` contortion.
|
||||
- **Verify:** service tests (SQLite *and* a Postgres path or a store fake that
|
||||
- **Verify:** service tests (SQLite _and_ a Postgres path or a store fake that
|
||||
implements the link semantics) covering: own unlinked attachment links;
|
||||
another user's attachment is refused; nonexistent id is skipped; already
|
||||
linked id is refused; `RowsAffected` mismatch → no message persisted.
|
||||
|
||||
### W1-4. Ban authorization guards dead code
|
||||
|
||||
- **Files:** `Server/admin/handlers_users.go` (`handlePatchUser`, ~line 112);
|
||||
`Server/service/moderation.go`
|
||||
- **Root cause:** `requireBanAuthority` (BAN_MEMBERS + role hierarchy) is
|
||||
@@ -135,7 +139,7 @@ no coverage (repo rule: "Target 80%+ coverage; TDD is the expected workflow").
|
||||
`ModerationService.BanUser`/`UnbanUser` (so the new authorization actually
|
||||
runs), or lift `requireBanAuthority` into the handler. Keep the
|
||||
admin-IP/admin-auth perimeter; add the permission + hierarchy check on top.
|
||||
Move the target-existence check *after* authorization so a caller without
|
||||
Move the target-existence check _after_ authorization so a caller without
|
||||
BAN_MEMBERS can't enumerate user ids via NotFound-vs-Forbidden.
|
||||
- **Verify:** handler test — actor without BAN_MEMBERS is refused; actor of
|
||||
equal/lower rank than target is refused; owner-rank target can't be banned
|
||||
@@ -144,6 +148,7 @@ no coverage (repo rule: "Target 80%+ coverage; TDD is the expected workflow").
|
||||
## Wave 2 — Behavioral regressions (MED-HIGH → MED)
|
||||
|
||||
### W2-1. Client-update rate limiter shares the auth bucket
|
||||
|
||||
- **File:** `Server/api/router.go` (~line 257)
|
||||
- **Root cause:** it uses the empty-prefix `RateLimitMiddleware` on the shared
|
||||
`limiter`, colliding per-IP with `verifyTOTP`, the sensitive endpoints, and
|
||||
@@ -156,6 +161,7 @@ no coverage (repo rule: "Target 80%+ coverage; TDD is the expected workflow").
|
||||
subsequent `verify-totp`/password request from the same IP.
|
||||
|
||||
### W2-2. ChangePassword reports failure after the password is committed
|
||||
|
||||
- **Files:** `Server/service/user.go` (~line 60); caller
|
||||
`Server/api/profile_handler.go` (~line 231)
|
||||
- **Root cause:** `UpdateUserPassword` commits first; if `DeleteOtherSessions`
|
||||
@@ -173,11 +179,12 @@ no coverage (repo rule: "Target 80%+ coverage; TDD is the expected workflow").
|
||||
password is unchanged.
|
||||
|
||||
### W2-3. Plugin activation via RegisterCommand breaks in-place upgrades
|
||||
|
||||
- **Files:** `Server/plugin/sandbox_wazero.go` (~line 140);
|
||||
`Server/plugin/host_commands.go` (~line 36);
|
||||
`Server/plugin/registry.go` (`installFromDisk`, `InstallFromZip`)
|
||||
- **Root cause:** `RegisterCommand` refuses when `existing != inst` by
|
||||
*pointer*, but `installFromDisk` replaces `r.plugins[id]`/`r.byName` with a
|
||||
_pointer_, but `installFromDisk` replaces `r.plugins[id]`/`r.byName` with a
|
||||
fresh `*Instance` without clearing the old command bindings. Re-installing an
|
||||
enabled plugin leaves stale bindings that block re-registration; dispatch
|
||||
keeps routing to the orphaned old module until restart. The old
|
||||
@@ -185,13 +192,14 @@ no coverage (repo rule: "Target 80%+ coverage; TDD is the expected workflow").
|
||||
- **Fix:** compare ownership by plugin identity (name/id), not instance
|
||||
pointer — allow the same plugin to re-bind its own command — and/or clear a
|
||||
plugin's stale command bindings during reinstall/deactivation before
|
||||
re-activation. Preserve the cross-plugin hijack protection (a *different*
|
||||
re-activation. Preserve the cross-plugin hijack protection (a _different_
|
||||
plugin still can't claim an owned command).
|
||||
- **Verify:** test that upgrading an enabled plugin in place rebinds its
|
||||
commands and dispatch routes to the new module; a different plugin claiming
|
||||
an owned command is still refused.
|
||||
|
||||
### W2-4. Attachment check rejects legit retries and legacy uploads
|
||||
|
||||
- **File:** `Server/service/message.go` (~line 191)
|
||||
- **Root cause:** `att.MessageID != nil → ErrForbidden` means a client retry of
|
||||
a send whose first attempt already linked the attachment can never succeed;
|
||||
@@ -202,6 +210,7 @@ no coverage (repo rule: "Target 80%+ coverage; TDD is the expected workflow").
|
||||
- **Verify:** covered by W1-3 tests (already-linked id → skipped, not fatal).
|
||||
|
||||
### W2-5. XFF right-to-left walk collapses/spoofs on broad trusted CIDRs
|
||||
|
||||
- **File:** `Server/api/middleware.go` (~line 227, `clientIPWithProxies`)
|
||||
- **Root cause:** the walk skips every entry inside `trustedCIDRs`. With a
|
||||
broad config (e.g. `trusted_proxies: 10.0.0.0/8` covering LAN clients), the
|
||||
@@ -210,7 +219,7 @@ no coverage (repo rule: "Target 80%+ coverage; TDD is the expected workflow").
|
||||
(one user's failed logins lock out everyone), or letting a client at a
|
||||
trusted IP forge the key.
|
||||
- **Fix:** when the walk exhausts without a non-trusted candidate, return the
|
||||
left-most *valid* XFF entry (the furthest-upstream client) rather than
|
||||
left-most _valid_ XFF entry (the furthest-upstream client) rather than
|
||||
`RemoteAddr`, so distinct clients keep distinct keys. Document that
|
||||
`trusted_proxies` should list only proxy hops, and validate config on
|
||||
startup. Pre-parse `trustedCIDRs` into `[]*net.IPNet` once (see W3-3).
|
||||
@@ -219,6 +228,7 @@ no coverage (repo rule: "Target 80%+ coverage; TDD is the expected workflow").
|
||||
leftmost entry from an untrusted RemoteAddr is ignored.
|
||||
|
||||
### W2-6. SSRF-hardened dialer loses multi-address fallback
|
||||
|
||||
- **File:** `Server/plugin/host_http.go` (~line 115)
|
||||
- **Root cause:** after validating every resolved IP, it dials only `ips[0]`,
|
||||
dropping Happy-Eyeballs/next-record fallback. An allowlisted dual-stack or
|
||||
@@ -233,6 +243,7 @@ no coverage (repo rule: "Target 80%+ coverage; TDD is the expected workflow").
|
||||
is still refused.
|
||||
|
||||
### W2-7. Plugin-broadcast gate omits the block check
|
||||
|
||||
- **Files:** `Server/ws/handlers_command.go` (~line 107);
|
||||
`Server/permissions/checker.go` (~line 79)
|
||||
- **Root cause:** `requireChannelBroadcastAccess` routes through
|
||||
@@ -250,6 +261,7 @@ no coverage (repo rule: "Target 80%+ coverage; TDD is the expected workflow").
|
||||
## Wave 3 — Cleanup, efficiency, hardening depth (LOW-MED)
|
||||
|
||||
### W3-1. Updater text-asset cache: add coalescing + negative caching
|
||||
|
||||
- **File:** `Server/updater/updater.go` (~line 729, `FetchTextAssetCached`)
|
||||
- **Fix:** guard refresh with `golang.org/x/sync/singleflight` so a TTL-expiry
|
||||
burst issues one outbound fetch; briefly cache errors so an upstream outage
|
||||
@@ -257,6 +269,7 @@ no coverage (repo rule: "Target 80%+ coverage; TDD is the expected workflow").
|
||||
- **Verify:** concurrent cold-cache test issues exactly one upstream fetch.
|
||||
|
||||
### W3-2. De-duplicate the update binary hashing
|
||||
|
||||
- **File:** `Server/admin/update_handlers.go` (~line 166, `fileSHA256`)
|
||||
- **Fix:** `fileSHA256` duplicates `updater.VerifyChecksum`'s hashing body and
|
||||
re-reads the just-verified binary. Export one hashing helper from the updater
|
||||
@@ -264,6 +277,7 @@ no coverage (repo rule: "Target 80%+ coverage; TDD is the expected workflow").
|
||||
and reuse it for the TOCTOU snapshot.
|
||||
|
||||
### W3-3. Update TOCTOU guard depth + XFF CIDR pre-parsing
|
||||
|
||||
- **Files:** `Server/admin/update_handlers.go` (~line 117);
|
||||
`Server/api/middleware.go` (`isTrustedProxy`)
|
||||
- **Fix:** the re-verify narrows but does not close the swap window (verify by
|
||||
@@ -273,6 +287,7 @@ no coverage (repo rule: "Target 80%+ coverage; TDD is the expected workflow").
|
||||
`[]*net.IPNet` once at middleware construction.
|
||||
|
||||
### W3-4. Cache-Control header contradiction
|
||||
|
||||
- **File:** `Server/api/upload_handler.go` (~line 309) + test at
|
||||
`upload_handler_test.go` (~line 733)
|
||||
- **Fix:** `private, max-age=31536000, no-cache` is self-contradictory —
|
||||
@@ -280,6 +295,7 @@ no coverage (repo rule: "Target 80%+ coverage; TDD is the expected workflow").
|
||||
Use `private, no-cache` and update the test assertion.
|
||||
|
||||
### W3-5. Restore test coverage lost to the MemStore change
|
||||
|
||||
- **File:** `Server/store/memstore.go` (~line 693)
|
||||
- **Fix:** subsumed by W1-3 (atomic link removes the need for the `(nil,nil)`
|
||||
stub). If MemStore keeps attachment stubs, ensure the ownership behavior is
|
||||
@@ -296,7 +312,7 @@ no coverage (repo rule: "Target 80%+ coverage; TDD is the expected workflow").
|
||||
## Cross-cutting requirements
|
||||
|
||||
- **Tests:** every fix ships with tests (repo rule: 80%+ coverage, TDD). Add
|
||||
the missing coverage for the *existing* new security code too:
|
||||
the missing coverage for the _existing_ new security code too:
|
||||
`requireBanAuthority`, `FetchTextAssetCached`, `requireChannelBroadcastAccess`,
|
||||
fail-closed `DecryptTOTPSecret`.
|
||||
- **Build-tag matrix:** W1-1/W2-3 touch `//go:build wazero` code — verify the
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
> 2026-08-05 (DC-08): the lookup is now tri-state
|
||||
> (pinned/unpinned/**unavailable**, `identity.ts` `getIdentityPin`) and
|
||||
> `verifyPeerAnnounce` fails closed on "unavailable"; follow-up 4 is accepted
|
||||
> behavior (degrades to *unverified*, never wrongly-*verified*). The scan artifact
|
||||
> behavior (degrades to _unverified_, never wrongly-_verified_). The scan artifact
|
||||
> directory `CLAUDE-SECURITY-20260722-184557/` referenced below is not part of
|
||||
> this repository.
|
||||
|
||||
@@ -21,22 +21,22 @@ This is a continuation/handoff doc: what is done, what remains, and how to resum
|
||||
|
||||
## Status at a glance
|
||||
|
||||
| # | Sev | Finding | Status |
|
||||
|---|-----|---------|--------|
|
||||
| F1 | MED | Login lockout keyed on un-canonicalized username (vs `COLLATE NOCASE`) | ✅ done, committed `7145f76` |
|
||||
| F2 | MED | Unsynchronized concurrent wazero module invocation (data race) | ✅ done, committed `71b5f13` |
|
||||
| F3 | MED | Voice E2EE trusts server-relayed ECDH keys (server MITM) | ✅ **implemented (branch `feat/e2ee-identity-tofu`)** — MITM closed for published+pinned peers; UI surfacing is follow-up (see below) |
|
||||
| F4 | MED | HTTP TOFU proxy accepts any cert on first use (credential exposure) | ✅ done, committed `f22985a` |
|
||||
| F5 | LOW | Voice perms use stale connect-time role snapshot | ✅ done, committed `260d038` |
|
||||
| F6 | LOW | Lost cache invalidation in `PermissionService.getOrPopulate` | ✅ done, committed `e6a0d87` |
|
||||
| F7 | LOW | ReDoS regex on link-preview HTML | ✅ done, committed `6952202` |
|
||||
| F8 | LOW | WS TOFU verifier accepts any cert on first use | ✅ done, committed `f22985a` (with F4) |
|
||||
| # | Sev | Finding | Status |
|
||||
| --- | --- | ---------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| F1 | MED | Login lockout keyed on un-canonicalized username (vs `COLLATE NOCASE`) | ✅ done, committed `7145f76` |
|
||||
| F2 | MED | Unsynchronized concurrent wazero module invocation (data race) | ✅ done, committed `71b5f13` |
|
||||
| F3 | MED | Voice E2EE trusts server-relayed ECDH keys (server MITM) | ✅ **implemented (branch `feat/e2ee-identity-tofu`)** — MITM closed for published+pinned peers; UI surfacing is follow-up (see below) |
|
||||
| F4 | MED | HTTP TOFU proxy accepts any cert on first use (credential exposure) | ✅ done, committed `f22985a` |
|
||||
| F5 | LOW | Voice perms use stale connect-time role snapshot | ✅ done, committed `260d038` |
|
||||
| F6 | LOW | Lost cache invalidation in `PermissionService.getOrPopulate` | ✅ done, committed `e6a0d87` |
|
||||
| F7 | LOW | ReDoS regex on link-preview HTML | ✅ done, committed `6952202` |
|
||||
| F8 | LOW | WS TOFU verifier accepts any cert on first use | ✅ done, committed `f22985a` (with F4) |
|
||||
|
||||
## Resume checklist (do these first)
|
||||
|
||||
1. **Confirm the F4/F8 Rust compiles.** It could not be built in the dev sandbox
|
||||
(no local Tauri builds per `Client/tauri-client/CLAUDE.md`). Run
|
||||
`cd Client/tauri-client/src-tauri && cargo clippy -- -D warnings` (or push and
|
||||
(no local Tauri builds per `Client/CLAUDE.md`). Run
|
||||
`cd Client/src-tauri && cargo clippy -- -D warnings` (or push and
|
||||
let CI do it). Pure `tofu` logic has `#[cfg(test)]` unit tests; the frontend is
|
||||
covered by the 3311-green unit suite.
|
||||
2. ~~**Then F3**~~ — **DONE 2026-07-23** on branch `feat/e2ee-identity-tofu` (see the
|
||||
@@ -81,17 +81,18 @@ identity key is published and locally pinned — an ephemeral-key swap fails ECD
|
||||
verification and the room key is never wrapped for the attacker.
|
||||
|
||||
**Follow-up (not MITM holes — deferred, none block the crypto):**
|
||||
|
||||
1. **Surface the safety number in the voice panel.** `safetyNumber`/`peerVerifications` are
|
||||
computed and stored but **no component renders them**, so the out-of-band check that
|
||||
detects the inherent TOFU *first-contact* window is not user-reachable yet.
|
||||
detects the inherent TOFU _first-contact_ window is not user-reachable yet.
|
||||
2. **Wire the verified/unverified/mismatch badge + a re-pin affordance.** `rePinPeerIdentity`
|
||||
exists but no UI calls it — a legitimately rotated peer key currently blocks voice with no
|
||||
in-app recovery (mirror `main.ts`'s `createCertMismatchModal onAccept` flow).
|
||||
3. `getIdentityPin` **fail-opens** on a transient local keyring/store read error (one announce
|
||||
falls through to legacy). Not server-controllable; consider fail-closed when a pin *may*
|
||||
falls through to legacy). Not server-controllable; consider fail-closed when a pin _may_
|
||||
exist.
|
||||
4. Fast-join timing: a peer joining voice before peers process its `user_update` is seen as
|
||||
legacy for that announce — degrades to *unverified*, never wrongly-*verified*.
|
||||
legacy for that announce — degrades to _unverified_, never wrongly-_verified_.
|
||||
|
||||
## F3 — Voice E2EE identity keys + TOFU (the remaining work)
|
||||
|
||||
@@ -111,6 +112,7 @@ server can only MITM at first-ever contact (the accepted TOFU window), and the
|
||||
optional safety-number makes even that detectable.
|
||||
|
||||
### What gets signed
|
||||
|
||||
WebCrypto **ECDSA P-256** (same curve family as the existing ECDH; works in all
|
||||
three webviews — Ed25519 is unreliable on WKWebView/WebKitGTK; zero new deps).
|
||||
When announcing its ephemeral key `E_pub`, the client signs
|
||||
@@ -119,8 +121,10 @@ private key. Binding `myUserId` stops the server re-attributing a valid announce
|
||||
to a different user. Receivers verify against the peer's **pinned** identity key.
|
||||
|
||||
### Verify + TOFU-pin (receive path)
|
||||
|
||||
In `handleE2EEAnnounce` (`livekitSession.ts` ~1195, before the `_peerPublicKeys`
|
||||
store at ~1198 and the holder's wrap at ~1207), and the queued-drain at ~852-857:
|
||||
|
||||
1. Resolve the peer's identity key — first sight → take it from the member
|
||||
payload and **pin** it (`identity_pins.json`, key `{host}:{userId}`);
|
||||
subsequent → use the pin; delivered key differs → emit `identity-tofu`, block/
|
||||
@@ -129,16 +133,18 @@ store at ~1198 and the holder's wrap at ~1207), and the queued-drain at ~852-857
|
||||
reject (MITM), do not store/wrap.
|
||||
|
||||
### Infrastructure (mirror existing patterns)
|
||||
|
||||
- **Identity private key → OS keyring:** `save/load/delete_identity_key` Tauri
|
||||
commands mirroring `src-tauri/src/credentials.rs` `save_credential`, account
|
||||
`identity:{host}`; TS wrapper copies `src/lib/credentials.ts`. Never localStorage.
|
||||
- **Peer pins → new `identity_pins.json`** `tauri-plugin-store` file +
|
||||
`store/get_identity_pin` commands, near-verbatim copy of the `certs.json`
|
||||
cert-pin commands in `src-tauri/src/commands.rs`.
|
||||
- **Safety number:** repoint `computeKeyFingerprint` at the *stable* identity key;
|
||||
- **Safety number:** repoint `computeKeyFingerprint` at the _stable_ identity key;
|
||||
surface a per-peer/combined safety number in the voice panel (optional OOB verify).
|
||||
|
||||
### Server (db-change + protocol-change workflows)
|
||||
|
||||
- Migration `Server/migrations/017_user_identity_key.sql`:
|
||||
`ALTER TABLE users ADD COLUMN identity_public_key TEXT;` (mirrors `totp_secret`).
|
||||
Add `UpdateUserIdentityKey` query; include the column in the user + `ListMembers`
|
||||
@@ -156,23 +162,27 @@ store at ~1198 and the holder's wrap at ~1207), and the queued-drain at ~852-857
|
||||
replay-to-late-joiners path (`voice_join.go:217-218`) doesn't drop it.
|
||||
|
||||
### Client session (`livekitSession.ts`)
|
||||
|
||||
Sign the ephemeral announce at all three sites (~916, ~467, ~891); verify+pin on
|
||||
receive as above. Move the primary announce earlier (~876) so the added identity
|
||||
round-trip doesn't stack on the existing 10s non-holder stall.
|
||||
|
||||
### Compatibility posture (transition)
|
||||
|
||||
Peer has published an identity key but the announce signature is missing/invalid
|
||||
→ **fail closed** (reject). Peer has no identity key at all (legacy client) →
|
||||
accept but mark **unverified** in the UI, pin-pending. Avoids a hard cutover for
|
||||
alpha while closing the hole for upgraded clients.
|
||||
|
||||
### Suggested PR split
|
||||
|
||||
- **PR-a (server):** identity-key column + publish/fetch + `voice_e2ee_announce`
|
||||
signature field + `SetE2EEPubKey` carries the signature.
|
||||
- **PR-b (client):** keygen + keyring commands, sign/verify, TOFU pin store,
|
||||
safety-number UI, receive-path verification.
|
||||
|
||||
### Verification (planned)
|
||||
|
||||
- vitest for `signEphemeralKey`/`verifyEphemeralKeySignature` and the TOFU pin
|
||||
(first-sight pins, changed key flags, invalid signature rejects); a "server
|
||||
substitutes a peer's ephemeral key → verify fails" test; keyring round-trip
|
||||
@@ -181,6 +191,7 @@ alpha while closing the hole for upgraded clients.
|
||||
`-race`/`-tags deadlock`; client `npm test` + typecheck/lint/format; `ci-check`.
|
||||
|
||||
## Notes carried from the build
|
||||
|
||||
- F4/F8 approach was simplified vs the original design: instead of a new
|
||||
`check_server_cert` peek command, first-use is handled by **reject-and-retry**
|
||||
— the proxy captures the fingerprint, rejects (ws `Err` / http `502`), and emits
|
||||
|
||||
@@ -10,9 +10,9 @@
|
||||
> `Server/db/` now); `src/state/` does not exist in the client (state modules
|
||||
> live in `src/stores/`). One slice of this plan did land separately: the
|
||||
> manifest `commands` name-only ACL (see the inline note in §"Manifest").
|
||||
**Owner:** TBD
|
||||
**Tracks:** deferred feature backlog (post-beta; see CHANGELOG "Deferred work")
|
||||
**Estimated effort:** 1–2 weeks of focused work
|
||||
> **Owner:** TBD
|
||||
> **Tracks:** deferred feature backlog (post-beta; see CHANGELOG "Deferred work")
|
||||
> **Estimated effort:** 1–2 weeks of focused work
|
||||
|
||||
## Why
|
||||
|
||||
@@ -119,7 +119,7 @@ truth for the per-command schema; the runtime never trusts what the plugin
|
||||
says at dispatch time. Example:
|
||||
|
||||
> **Partially landed 2026-07-20** (audit-2026-04-07 CRITICAL #3): the
|
||||
> *name-only* slice of this block exists today — `plugin.json` accepts
|
||||
> _name-only_ slice of this block exists today — `plugin.json` accepts
|
||||
> `"commands": [{"name": "kick"}]` and `Registry.RegisterCommand` refuses any
|
||||
> command the manifest did not declare, so `list_commands` can no longer bind
|
||||
> names behind the admin's back. `description` / `options` /
|
||||
@@ -197,25 +197,25 @@ namespace collisions are confusing for users.
|
||||
|
||||
### Code surface
|
||||
|
||||
| File | Change |
|
||||
|---|---|
|
||||
| `Server/ws/message_types.go` | Add `MsgTypeCommandInvoke`, `MsgTypeCommandAutocomplete`, `MsgTypeCommandReply`, `MsgTypeCommandAutocompleteResult`. |
|
||||
| `Server/ws/command.go` | Add `CommandInvokeCmd`, `CommandAutocompleteCmd` structs and constructors. Validate name regex + arg count cap (25) at parse time so the dispatcher trusts its input. |
|
||||
| `Server/ws/handlers_command.go` | **New file.** `handleCommandInvokeV2`, `handleCommandAutocompleteV2`. Pure handlers — return a `Result` like the existing chat handlers. |
|
||||
| `Server/ws/handlers.go` | Register the new handlers via `r.RegisterV2(MsgTypeCommandInvoke, handleCommandInvokeV2, deps)`. |
|
||||
| `Server/ws/deps.go` | Add a `CommandDeps` carrying `*plugin.Registry`, `service.PermissionService`, and `service.MessageService`. |
|
||||
| `Server/plugin/host_commands.go` | Extend `DispatchCommand` to take a typed arg map (`map[string]any`) instead of `[]string`. Add `Autocomplete(ctx, name, focused, partial)`. |
|
||||
| `Server/plugin/manifest.go` | Add `Commands []CommandSpec` to `Manifest`, `validateCommands()`, and a `Manifest.Command(name)` lookup. |
|
||||
| `Server/store/sqlite_plugin_commands.go` | **New file.** CRUD over the `plugin_commands` table. |
|
||||
| `Server/migrations/016_plugin_commands.sql` | New migration. |
|
||||
| `Client/tauri-client/src/state/commands.ts` | **New module.** Caches per-server command list (fetched at `auth_ok` time via a new `commands_list` REST endpoint), feeds the autocomplete UI. |
|
||||
| `Client/tauri-client/src/components/Composer/SlashCommandPopup.tsx` | New component — autocomplete dropdown that opens when the message buffer starts with `/`. |
|
||||
| `docs/protocol.md` | Document the four new wire messages. |
|
||||
| File | Change |
|
||||
| ------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `Server/ws/message_types.go` | Add `MsgTypeCommandInvoke`, `MsgTypeCommandAutocomplete`, `MsgTypeCommandReply`, `MsgTypeCommandAutocompleteResult`. |
|
||||
| `Server/ws/command.go` | Add `CommandInvokeCmd`, `CommandAutocompleteCmd` structs and constructors. Validate name regex + arg count cap (25) at parse time so the dispatcher trusts its input. |
|
||||
| `Server/ws/handlers_command.go` | **New file.** `handleCommandInvokeV2`, `handleCommandAutocompleteV2`. Pure handlers — return a `Result` like the existing chat handlers. |
|
||||
| `Server/ws/handlers.go` | Register the new handlers via `r.RegisterV2(MsgTypeCommandInvoke, handleCommandInvokeV2, deps)`. |
|
||||
| `Server/ws/deps.go` | Add a `CommandDeps` carrying `*plugin.Registry`, `service.PermissionService`, and `service.MessageService`. |
|
||||
| `Server/plugin/host_commands.go` | Extend `DispatchCommand` to take a typed arg map (`map[string]any`) instead of `[]string`. Add `Autocomplete(ctx, name, focused, partial)`. |
|
||||
| `Server/plugin/manifest.go` | Add `Commands []CommandSpec` to `Manifest`, `validateCommands()`, and a `Manifest.Command(name)` lookup. |
|
||||
| `Server/store/sqlite_plugin_commands.go` | **New file.** CRUD over the `plugin_commands` table. |
|
||||
| `Server/migrations/016_plugin_commands.sql` | New migration. |
|
||||
| `Client/src/state/commands.ts` | **New module.** Caches per-server command list (fetched at `auth_ok` time via a new `commands_list` REST endpoint), feeds the autocomplete UI. |
|
||||
| `Client/src/components/Composer/SlashCommandPopup.tsx` | New component — autocomplete dropdown that opens when the message buffer starts with `/`. |
|
||||
| `docs/protocol.md` | Document the four new wire messages. |
|
||||
|
||||
### Permission model
|
||||
|
||||
`default_member_permissions` is enforced **server-side** in
|
||||
`handleCommandInvokeV2` *before* the plugin is invoked, by calling
|
||||
`handleCommandInvokeV2` _before_ the plugin is invoked, by calling
|
||||
`PermissionService.HasChannelPerm` for each declared permission. Plugins
|
||||
do not get to decide who can use their commands; the manifest declares,
|
||||
the host enforces.
|
||||
@@ -229,10 +229,10 @@ no plugin invocation, no telemetry leak.
|
||||
Two slash commands ship in-tree (no plugin required), to validate the
|
||||
dispatcher and to give bare-metal deployments something useful:
|
||||
|
||||
| Command | Implementation | Why in-tree |
|
||||
|---|---|---|
|
||||
| Command | Implementation | Why in-tree |
|
||||
| ------------ | ----------------------------------------- | ------------------------------ |
|
||||
| `/me <text>` | Built-in handler in `handlers_command.go` | Discord parity, IRC tradition. |
|
||||
| `/shrug` | Built-in handler | Same. Trivial. |
|
||||
| `/shrug` | Built-in handler | Same. Trivial. |
|
||||
|
||||
A future PR can add `/poll`, `/remind`, `/nick` etc. — all should follow
|
||||
the same handler shape so a plugin author can read the source as the
|
||||
@@ -254,18 +254,19 @@ canonical example.
|
||||
|
||||
## Failure modes & UX
|
||||
|
||||
| Failure | Server response | Client UX |
|
||||
|---|---|---|
|
||||
| No such command | `command_reply` ephemeral: `Unknown command: /foo` | Red banner under composer. |
|
||||
| Plugin runtime not built (default build) | Existing fallback in `DispatchCommand` returns the helpful error message | Same banner, no crash. |
|
||||
| Plugin handler timeout (>3s) | `command_reply` ephemeral: `/foo timed out` + audit log entry | Banner + telemetry tag. |
|
||||
| Plugin handler panics | Recovered in the runtime, ephemeral error, plugin auto-disabled after 3 panics in 60s | Banner + plugin marked unhealthy in admin panel. |
|
||||
| Permission denied | `command_invoke` returns `ErrCodeForbidden` before invocation | Banner: "You lack permission". |
|
||||
| Argument validation fails | `command_invoke` returns `ErrCodeBadPayload` with the field name | Composer highlights the bad option. |
|
||||
| Failure | Server response | Client UX |
|
||||
| ---------------------------------------- | ------------------------------------------------------------------------------------- | ------------------------------------------------ |
|
||||
| No such command | `command_reply` ephemeral: `Unknown command: /foo` | Red banner under composer. |
|
||||
| Plugin runtime not built (default build) | Existing fallback in `DispatchCommand` returns the helpful error message | Same banner, no crash. |
|
||||
| Plugin handler timeout (>3s) | `command_reply` ephemeral: `/foo timed out` + audit log entry | Banner + telemetry tag. |
|
||||
| Plugin handler panics | Recovered in the runtime, ephemeral error, plugin auto-disabled after 3 panics in 60s | Banner + plugin marked unhealthy in admin panel. |
|
||||
| Permission denied | `command_invoke` returns `ErrCodeForbidden` before invocation | Banner: "You lack permission". |
|
||||
| Argument validation fails | `command_invoke` returns `ErrCodeBadPayload` with the field name | Composer highlights the bad option. |
|
||||
|
||||
## Testing strategy
|
||||
|
||||
Unit:
|
||||
|
||||
- `manifest_test.go` — extend with command validation (name regex, option
|
||||
type enum, max 25 options, max 100 char description).
|
||||
- `host_commands_test.go` — `DispatchCommand` with a stub `Instance`,
|
||||
@@ -274,11 +275,13 @@ Unit:
|
||||
V2 test pattern (`stubMessageSvc`, `stubPermSvc`).
|
||||
|
||||
Integration:
|
||||
|
||||
- Add a new in-tree test plugin under `Server/plugin/examples/echo` (no
|
||||
wasm needed — installable via the default build) that registers `/echo`
|
||||
and is loaded inside `ws_integration_test.go`.
|
||||
|
||||
Contract:
|
||||
|
||||
- `docs/protocol.md` round trip — JSON examples kept in sync with the
|
||||
parser via golden tests.
|
||||
|
||||
@@ -341,7 +344,7 @@ Each step is independently shippable.
|
||||
- [ ] `Server/ws/deps.go` — `CommandDeps`
|
||||
- [ ] `Server/ws/handlers_command_test.go`
|
||||
- [ ] `Server/api/router.go` — `GET /api/v1/commands` (cached schema dump)
|
||||
- [ ] `Client/tauri-client/src/state/commands.ts`
|
||||
- [ ] `Client/tauri-client/src/components/Composer/SlashCommandPopup.tsx`
|
||||
- [ ] `Client/src/state/commands.ts`
|
||||
- [ ] `Client/src/components/Composer/SlashCommandPopup.tsx`
|
||||
- [ ] `docs/protocol.md` — four new wire messages
|
||||
- [ ] `CHANGELOG.md` — Phase D entry
|
||||
|
||||
@@ -33,6 +33,7 @@ Two mechanical frictions drive the per-domain effort:
|
||||
## Status
|
||||
|
||||
### Phase 1 + 2 — done (2026-07-19)
|
||||
|
||||
sqlc is now **load-bearing in production** (previously dead code). **97 `db.DB`
|
||||
methods delegate** to `dbgen` across every domain; 43 raw `d.sqlDB` calls
|
||||
remain (the `db.go` passthrough helpers, `migrate.go`, and the intentionally
|
||||
@@ -46,6 +47,7 @@ attachments, voice, dm (simple ops), channels + permission overrides, admin
|
||||
pins/read-state).
|
||||
|
||||
### Deliberately kept raw (no clean sqlc mapping)
|
||||
|
||||
- **Variable-length `IN(...)`** (sqlc can't express): `GetAttachmentsByMessageIDs`,
|
||||
`LinkAttachmentsToMessage`, `GetChannelTypes`.
|
||||
- **FTS / dynamic WHERE / cursor pagination**: `GetMessages`, `SearchMessages`,
|
||||
@@ -63,10 +65,12 @@ accept they stay raw), but none block the D2 goal: `dbgen` is no longer dead
|
||||
and owns the SQL for the overwhelming majority of the data layer.
|
||||
|
||||
### Out of scope for D2
|
||||
|
||||
- `store/` event + plugin SQL (`store/sqlite_events.go`, plugin store) — these
|
||||
live in the store layer being **removed in D3**; converting them is throwaway.
|
||||
D3 moves the surviving `db` methods (sqlc-backed) to direct service use.
|
||||
|
||||
## Verification (per phase)
|
||||
|
||||
`go build ./...`; `go test -race ./db/ ./service/ ./auth/ ./api/ ./ws/`;
|
||||
`make sqlc-verify` (generated output committed & in sync).
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# Tauri HTTP Capability Narrowing — Design
|
||||
|
||||
**Status:** implemented (2026-07-20), re-verified 2026-08-04 — the Decision
|
||||
below landed in `Client/tauri-client/src-tauri/capabilities/default.json`,
|
||||
below landed in `Client/src-tauri/capabilities/default.json`,
|
||||
guarded by `tests/unit/capabilities-scope.test.ts`. The follow-up at the end
|
||||
of this document (move the link-preview fetch behind a Rust command that
|
||||
resolves DNS and rejects private/loopback IPs, closing DNS rebinding and
|
||||
@@ -13,7 +13,7 @@ the remaining outbound host set enumerable.
|
||||
|
||||
## Problem
|
||||
|
||||
`Client/tauri-client/src-tauri/capabilities/default.json` grants three HTTP
|
||||
`Client/src-tauri/capabilities/default.json` grants three HTTP
|
||||
identifiers — `http:allow-fetch`, `http:allow-fetch-send`,
|
||||
`http:allow-fetch-read-body` — each scoped to `https://*`, `https://*:*` and
|
||||
`http://127.0.0.1:*`. In practice that is "the renderer may reach any host on
|
||||
@@ -30,7 +30,7 @@ the code, change what this PR can achieve.
|
||||
(`:366`) and `fetch_read_body` (`:418`) take a `ResourceId` for an
|
||||
already-validated request and never consult a scope at all. And in Tauri's ACL
|
||||
resolver (`tauri-utils/src/acl/resolved.rs:105-125`) a permission that declares
|
||||
`commands.allow` contributes its scope as *command* scope for those commands
|
||||
`commands.allow` contributes its scope as _command_ scope for those commands
|
||||
only — it never merges into the plugin's global scope. `allow-fetch-send` and
|
||||
`allow-fetch-read-body` each declare exactly one command
|
||||
(`permissions/autogenerated/commands/fetch_send.toml`, `fetch_read_body.toml`).
|
||||
@@ -46,13 +46,13 @@ work changes that; only moving the fetch out of the renderer does.
|
||||
|
||||
## What each consumer actually needs
|
||||
|
||||
| Consumer | Reachable hosts | Enumerable? |
|
||||
|---|---|---|
|
||||
| `src/lib/api.ts` | `http://127.0.0.1:{port}` only — `baseUrl()`/`adminBaseUrl()` (`:64-70`) and the health probe (`:467`) all resolve through `ensureHttpProxy`. Upload (`:374`) uses `baseUrl()`. | yes — loopback |
|
||||
| `src/lib/profiles.ts` | `http://127.0.0.1:{port}` only — `resolveHealthOrigin` (`:200`) returns `ensureHttpProxy(host)`; the direct `https://{host}` branch is reachable only when a test injects `fetchFn`. | yes — loopback |
|
||||
| Consumer | Reachable hosts | Enumerable? |
|
||||
| -------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------- |
|
||||
| `src/lib/api.ts` | `http://127.0.0.1:{port}` only — `baseUrl()`/`adminBaseUrl()` (`:64-70`) and the health probe (`:467`) all resolve through `ensureHttpProxy`. Upload (`:374`) uses `baseUrl()`. | yes — loopback |
|
||||
| `src/lib/profiles.ts` | `http://127.0.0.1:{port}` only — `resolveHealthOrigin` (`:200`) returns `ensureHttpProxy(host)`; the direct `https://{host}` branch is reachable only when a test injects `fetchFn`. | yes — loopback |
|
||||
| `src/components/message-list/attachments.ts` | `http://127.0.0.1:{port}` only. Traced end-to-end: `chat_send`'s `attachments` are attachment **IDs**, not URLs (`Server/ws/command.go:259-281` → `service/message.go:188` `LinkAttachmentsToMessage`), and the only URL the client ever sees is server-generated `/api/v1/files/<id>` (`Server/db/attachment_queries.go:170`). Relative → `resolveServerUrl` → `isServerUrl` → `toFetchUrl` (`:124`) → loopback. Both plugin fetches (image cache `:247`, download `:408`) go through `toFetchUrl`. | yes — loopback |
|
||||
| `src/components/message-list/media.ts` | Exactly one URL shape: `https://www.youtube.com/oembed?url=…` (`:143`). Not a provider registry — YouTube is the only oEmbed provider in the client. Thumbnails and the player are `<img>`/`<iframe>` under CSP, not plugin fetches. | yes — one host |
|
||||
| `src/components/message-list/embeds.ts` | **Arbitrary public https hosts.** `fetchOgMeta` (`:160`) fetches any URL a user posts in a message. `isBlockedForPreview`/`isPrivateHost` (`:104-152`) bound it to non-private hostnames; the response is regex-scraped for `og:` tags only (`parseOgTags`), capped at 5 s and 50 KB, and never executed or injected as HTML. `og:image` is rendered via `<img src>` under CSP `img-src`, not fetched through the plugin. | **no** |
|
||||
| `src/components/message-list/media.ts` | Exactly one URL shape: `https://www.youtube.com/oembed?url=…` (`:143`). Not a provider registry — YouTube is the only oEmbed provider in the client. Thumbnails and the player are `<img>`/`<iframe>` under CSP, not plugin fetches. | yes — one host |
|
||||
| `src/components/message-list/embeds.ts` | **Arbitrary public https hosts.** `fetchOgMeta` (`:160`) fetches any URL a user posts in a message. `isBlockedForPreview`/`isPrivateHost` (`:104-152`) bound it to non-private hostnames; the response is regex-scraped for `og:` tags only (`parseOgTags`), capped at 5 s and 50 KB, and never executed or injected as HTML. `og:image` is rendered via `<img src>` under CSP `img-src`, not fetched through the plugin. | **no** |
|
||||
|
||||
Not consumers, checked and excluded: `src/lib/gifProvider.ts` hits
|
||||
`https://api.klipy.com` with the **webview's** `fetch`, not the plugin (so it is
|
||||
|
||||
@@ -98,7 +98,7 @@ V1-shadowing guard inside `RegisterV2`. `registerVoiceHandlersV1` /
|
||||
identically.
|
||||
- Not decomposing the Hub or reworking replay/seq (backlog 12).
|
||||
- `handleVoiceLeave` remains a hub-internal routine for the disconnect/switch
|
||||
callers; only its message *dispatch* moves to V2.
|
||||
callers; only its message _dispatch_ moves to V2.
|
||||
|
||||
## As implemented (2026-07-20)
|
||||
|
||||
|
||||
@@ -10,17 +10,17 @@ If you want a simpler remote-access path, use [Tailscale](tailscale.md) and skip
|
||||
|
||||
### Always required
|
||||
|
||||
| Port | Protocol | Purpose |
|
||||
| ---- | -------- | ------- |
|
||||
| `8443` | TCP | OwnCord HTTPS + WebSocket |
|
||||
| Port | Protocol | Purpose |
|
||||
| ------ | -------- | ------------------------- |
|
||||
| `8443` | TCP | OwnCord HTTPS + WebSocket |
|
||||
|
||||
### Required only for voice/video
|
||||
|
||||
| Port | Protocol | Purpose |
|
||||
| ---- | -------- | ------- |
|
||||
| `7880` | TCP | LiveKit signaling |
|
||||
| `7881` | TCP | LiveKit TCP fallback |
|
||||
| `50000-60000` | UDP | LiveKit media |
|
||||
| Port | Protocol | Purpose |
|
||||
| ------------- | -------- | -------------------- |
|
||||
| `7880` | TCP | LiveKit signaling |
|
||||
| `7881` | TCP | LiveKit TCP fallback |
|
||||
| `50000-60000` | UDP | LiveKit media |
|
||||
|
||||
## Router Steps
|
||||
|
||||
|
||||
@@ -1,109 +0,0 @@
|
||||
{
|
||||
"$comment": "Single source of truth for WebSocket protocol message-type constants. Server/ws/message_types.go and Client/tauri-client/src/lib/protocolTypes.ts are generated from this file — edit here, then run `make protocol-generate` in Server/. CI runs `make protocol-verify` to reject drift.",
|
||||
"version": 1,
|
||||
"client_to_server": [
|
||||
{ "wire": "auth", "go": "MsgTypeAuth", "ts": "AUTH" },
|
||||
{ "wire": "chat_send", "go": "MsgTypeChatSend", "ts": "CHAT_SEND" },
|
||||
{ "wire": "chat_edit", "go": "MsgTypeChatEdit", "ts": "CHAT_EDIT" },
|
||||
{ "wire": "chat_delete", "go": "MsgTypeChatDelete", "ts": "CHAT_DELETE" },
|
||||
{ "wire": "reaction_add", "go": "MsgTypeReactionAdd", "ts": "REACTION_ADD" },
|
||||
{ "wire": "reaction_remove", "go": "MsgTypeReactionRemove", "ts": "REACTION_REMOVE" },
|
||||
{ "wire": "typing_start", "go": "MsgTypeTypingStart", "ts": "TYPING_START" },
|
||||
{ "wire": "channel_focus", "go": "MsgTypeChannelFocus", "ts": "CHANNEL_FOCUS" },
|
||||
{ "wire": "mark_read", "go": "MsgTypeMarkRead", "ts": "MARK_READ" },
|
||||
{ "wire": "presence_update", "go": "MsgTypePresenceUpdate", "ts": "PRESENCE_UPDATE" },
|
||||
{ "wire": "voice_join", "go": "MsgTypeVoiceJoin", "ts": "VOICE_JOIN" },
|
||||
{ "wire": "voice_leave", "go": "MsgTypeVoiceLeave", "ts": "VOICE_LEAVE" },
|
||||
{ "wire": "voice_mute", "go": "MsgTypeVoiceMute", "ts": "VOICE_MUTE" },
|
||||
{ "wire": "voice_deafen", "go": "MsgTypeVoiceDeafen", "ts": "VOICE_DEAFEN" },
|
||||
{ "wire": "voice_camera", "go": "MsgTypeVoiceCamera", "ts": "VOICE_CAMERA" },
|
||||
{ "wire": "voice_screenshare", "go": "MsgTypeVoiceScreenshare", "ts": "VOICE_SCREENSHARE" },
|
||||
{ "wire": "voice_mod_mute", "go": "MsgTypeVoiceModMute", "ts": "VOICE_MOD_MUTE" },
|
||||
{ "wire": "voice_mod_deafen", "go": "MsgTypeVoiceModDeafen", "ts": "VOICE_MOD_DEAFEN" },
|
||||
{ "wire": "voice_mod_move", "go": "MsgTypeVoiceModMove", "ts": "VOICE_MOD_MOVE" },
|
||||
{ "wire": "voice_mod_kick", "go": "MsgTypeVoiceModKick", "ts": "VOICE_MOD_KICK" },
|
||||
{ "wire": "ping", "go": "MsgTypePing", "ts": "PING" },
|
||||
{
|
||||
"wire": "voice_token_refresh",
|
||||
"go": "MsgTypeVoiceTokenRefresh",
|
||||
"ts": "VOICE_TOKEN_REFRESH",
|
||||
"go_trailing_comment": "//nolint:gosec // G101: false positive — message type constant, not a credential"
|
||||
},
|
||||
{ "wire": "voice_e2ee_announce", "go": "MsgTypeVoiceE2EEAnnounce", "ts": "VOICE_E2EE_ANNOUNCE" },
|
||||
{ "wire": "voice_e2ee_offer", "go": "MsgTypeVoiceE2EEOffer", "ts": "VOICE_E2EE_OFFER" },
|
||||
{ "wire": "call_ring", "go": "MsgTypeCallRing", "ts": "CALL_RING" },
|
||||
{ "wire": "call_decline", "go": "MsgTypeCallDecline", "ts": "CALL_DECLINE" },
|
||||
{
|
||||
"wire": "chat_command",
|
||||
"go": "MsgTypeChatCommand",
|
||||
"ts": "CHAT_COMMAND",
|
||||
"note": "plugin slash-command dispatch (Phase C)"
|
||||
}
|
||||
],
|
||||
"server_to_client": [
|
||||
{ "wire": "auth_ok", "go": "MsgTypeAuthOK", "ts": "AUTH_OK" },
|
||||
{ "wire": "auth_error", "go": "MsgTypeAuthError", "ts": "AUTH_ERROR" },
|
||||
{ "wire": "ready", "go": "MsgTypeReady", "ts": "READY" },
|
||||
{ "wire": "chat_message", "go": "MsgTypeChatMessage", "ts": "CHAT_MESSAGE" },
|
||||
{ "wire": "chat_send_ok", "go": "MsgTypeChatSendOK", "ts": "CHAT_SEND_OK" },
|
||||
{ "wire": "chat_edited", "go": "MsgTypeChatEdited", "ts": "CHAT_EDITED" },
|
||||
{ "wire": "chat_deleted", "go": "MsgTypeChatDeleted", "ts": "CHAT_DELETED" },
|
||||
{ "wire": "chat_bulk_deleted", "go": "MsgTypeChatBulkDeleted", "ts": "CHAT_BULK_DELETED" },
|
||||
{ "wire": "reaction_update", "go": "MsgTypeReactionUpdate", "ts": "REACTION_UPDATE" },
|
||||
{ "wire": "typing", "go": "MsgTypeTyping", "ts": "TYPING" },
|
||||
{ "wire": "presence", "go": "MsgTypePresence", "ts": "PRESENCE" },
|
||||
{ "wire": "channel_create", "go": "MsgTypeChannelCreate", "ts": "CHANNEL_CREATE" },
|
||||
{ "wire": "channel_update", "go": "MsgTypeChannelUpdate", "ts": "CHANNEL_UPDATE" },
|
||||
{ "wire": "channel_delete", "go": "MsgTypeChannelDelete", "ts": "CHANNEL_DELETE" },
|
||||
{ "wire": "voice_state", "go": "MsgTypeVoiceState", "ts": "VOICE_STATE" },
|
||||
{ "wire": "voice_config", "go": "MsgTypeVoiceConfig", "ts": "VOICE_CONFIG" },
|
||||
{ "wire": "voice_token", "go": "MsgTypeVoiceToken", "ts": "VOICE_TOKEN" },
|
||||
{ "wire": "voice_speakers", "go": "MsgTypeVoiceSpeakers", "ts": "VOICE_SPEAKERS" },
|
||||
{
|
||||
"wire": "voice_leave",
|
||||
"go": "MsgTypeVoiceLeaveBC",
|
||||
"ts": "VOICE_LEAVE",
|
||||
"note": "broadcast (same string as client msg)"
|
||||
},
|
||||
{ "wire": "voice_moved", "go": "MsgTypeVoiceMoved", "ts": "VOICE_MOVED" },
|
||||
{ "wire": "voice_disconnected", "go": "MsgTypeVoiceDisconnected", "ts": "VOICE_DISCONNECTED" },
|
||||
{ "wire": "member_join", "go": "MsgTypeMemberJoin", "ts": "MEMBER_JOIN" },
|
||||
{ "wire": "member_leave", "go": "MsgTypeMemberLeave", "ts": "MEMBER_LEAVE" },
|
||||
{ "wire": "member_update", "go": "MsgTypeMemberUpdate", "ts": "MEMBER_UPDATE" },
|
||||
{ "wire": "user_update", "go": "MsgTypeUserUpdate", "ts": "USER_UPDATE" },
|
||||
{ "wire": "member_ban", "go": "MsgTypeMemberBan", "ts": "MEMBER_BAN" },
|
||||
{ "wire": "roles_update", "go": "MsgTypeRolesUpdate", "ts": "ROLES_UPDATE" },
|
||||
{ "wire": "emoji_update", "go": "MsgTypeEmojiUpdate", "ts": "EMOJI_UPDATE" },
|
||||
{ "wire": "server_restart", "go": "MsgTypeServerRestart", "ts": "SERVER_RESTART" },
|
||||
{ "wire": "error", "go": "MsgTypeError", "ts": "ERROR" },
|
||||
{ "wire": "pong", "go": "MsgTypePong", "ts": "PONG" },
|
||||
{ "wire": "dm_channel_open", "go": "MsgTypeDMChannelOpen", "ts": "DM_CHANNEL_OPEN" },
|
||||
{ "wire": "dm_channel_close", "go": "MsgTypeDMChannelClose", "ts": "DM_CHANNEL_CLOSE" },
|
||||
{ "wire": "call_incoming", "go": "MsgTypeCallIncoming", "ts": "CALL_INCOMING" },
|
||||
{ "wire": "call_declined", "go": "MsgTypeCallDeclined", "ts": "CALL_DECLINED" },
|
||||
{
|
||||
"wire": "voice_e2ee_announce",
|
||||
"go": "MsgTypeVoiceE2EEAnnounceBC",
|
||||
"ts": "VOICE_E2EE_ANNOUNCE",
|
||||
"note": "broadcast (same string as client msg)"
|
||||
},
|
||||
{
|
||||
"wire": "voice_e2ee_offer",
|
||||
"go": "MsgTypeVoiceE2EEOfferRelay",
|
||||
"ts": "VOICE_E2EE_OFFER",
|
||||
"note": "relay (same string as client msg)"
|
||||
},
|
||||
{
|
||||
"wire": "command_reply",
|
||||
"go": "MsgTypeCommandReply",
|
||||
"ts": "COMMAND_REPLY",
|
||||
"note": "ephemeral plugin reply, sent only to the invoking client"
|
||||
},
|
||||
{
|
||||
"wire": "plugin_broadcast",
|
||||
"go": "MsgTypePluginBroadcast",
|
||||
"ts": "PLUGIN_BROADCAST",
|
||||
"note": "plugin channel broadcast, gated by the sender's SEND_MESSAGES"
|
||||
}
|
||||
]
|
||||
}
|
||||
+204
-175
@@ -3,6 +3,7 @@
|
||||
All client-server real-time communication happens over a single WebSocket connection. Messages are JSON with a `type` and `payload`.
|
||||
|
||||
**Related docs:**
|
||||
|
||||
- [api.md](api.md) -- REST endpoints (message history, file uploads, etc.)
|
||||
- [schema.md](schema.md) -- Database tables and permission bitfields
|
||||
|
||||
@@ -47,12 +48,12 @@ The client connects via the Tauri Rust backend's WS proxy rather than native Web
|
||||
|
||||
### Transport Limits
|
||||
|
||||
| Limit | Value |
|
||||
|-------|-------|
|
||||
| Max read size | 1 MB |
|
||||
| Max message content | 4000 runes |
|
||||
| Write timeout | 10 seconds |
|
||||
| Auth deadline | 10 seconds |
|
||||
| Limit | Value |
|
||||
| ---------------------- | ------------ |
|
||||
| Max read size | 1 MB |
|
||||
| Max message content | 4000 runes |
|
||||
| Write timeout | 10 seconds |
|
||||
| Auth deadline | 10 seconds |
|
||||
| Send buffer per client | 256 messages |
|
||||
|
||||
---
|
||||
@@ -65,17 +66,17 @@ Every WebSocket message is a JSON object with these fields:
|
||||
{
|
||||
"type": "message_type",
|
||||
"id": "unique-request-id",
|
||||
"payload": { },
|
||||
"payload": {},
|
||||
"seq": 42
|
||||
}
|
||||
```
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
|-------|------|----------|-------------|
|
||||
| `type` | string | Yes | Determines how `payload` is interpreted |
|
||||
| `id` | string | Client messages only | Client-generated UUID for request/response correlation |
|
||||
| `payload` | object | Yes | Contents vary by `type`. Must be present (can be `{}`). |
|
||||
| `seq` | uint64 | Broadcast messages only | Monotonically increasing sequence number. Only present on server-to-client broadcast messages. |
|
||||
| Field | Type | Required | Description |
|
||||
| --------- | ------ | ----------------------- | ---------------------------------------------------------------------------------------------- |
|
||||
| `type` | string | Yes | Determines how `payload` is interpreted |
|
||||
| `id` | string | Client messages only | Client-generated UUID for request/response correlation |
|
||||
| `payload` | object | Yes | Contents vary by `type`. Must be present (can be `{}`). |
|
||||
| `seq` | uint64 | Broadcast messages only | Monotonically increasing sequence number. Only present on server-to-client broadcast messages. |
|
||||
|
||||
---
|
||||
|
||||
@@ -90,15 +91,15 @@ The sequence number system enables reconnection with state recovery.
|
||||
|
||||
### Which Messages Get seq
|
||||
|
||||
| Category | Has seq? | Examples |
|
||||
|----------|----------|---------|
|
||||
| Channel broadcasts | Yes | `chat_message`, `chat_edited`, `chat_deleted`, `chat_bulk_deleted`, `reaction_update` |
|
||||
| Global broadcasts | Yes | `member_join`, `member_leave`, `member_update`, `member_ban`, `roles_update`, `emoji_update`, `voice_state`, `voice_leave`, `channel_create`, `channel_update`, `channel_delete`, `server_restart` |
|
||||
| Ephemeral | No | `typing`, `presence` from a `presence_update` (see below) |
|
||||
| DM chat events | Yes | DM `chat_message`, `chat_edited`, `chat_deleted`, `reaction_update` — sequenced and replayable exactly like channel broadcasts, delivered only to the DM's participants |
|
||||
| DM lifecycle | No | `dm_channel_open`, `dm_channel_close` |
|
||||
| Call signalling | No | `call_incoming`, `call_declined` |
|
||||
| Direct responses | No | `auth_ok`, `auth_error`, `chat_send_ok`, `error`, `voice_config`, `voice_token`, `pong` |
|
||||
| Category | Has seq? | Examples |
|
||||
| ------------------ | -------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Channel broadcasts | Yes | `chat_message`, `chat_edited`, `chat_deleted`, `chat_bulk_deleted`, `reaction_update` |
|
||||
| Global broadcasts | Yes | `member_join`, `member_leave`, `member_update`, `member_ban`, `roles_update`, `emoji_update`, `voice_state`, `voice_leave`, `channel_create`, `channel_update`, `channel_delete`, `server_restart` |
|
||||
| Ephemeral | No | `typing`, `presence` from a `presence_update` (see below) |
|
||||
| DM chat events | Yes | DM `chat_message`, `chat_edited`, `chat_deleted`, `reaction_update` — sequenced and replayable exactly like channel broadcasts, delivered only to the DM's participants |
|
||||
| DM lifecycle | No | `dm_channel_open`, `dm_channel_close` |
|
||||
| Call signalling | No | `call_incoming`, `call_declined` |
|
||||
| Direct responses | No | `auth_ok`, `auth_error`, `chat_send_ok`, `error`, `voice_config`, `voice_token`, `pong` |
|
||||
|
||||
**`presence` is split, and only one half is sequenced.** Connect and disconnect
|
||||
presence is a normal sequenced global broadcast, so it replays on a warm resume.
|
||||
@@ -132,11 +133,11 @@ After the WebSocket connection is established, the client sends the first messag
|
||||
}
|
||||
```
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
|-------|------|----------|-------------|
|
||||
| `token` | string | Yes | Session token obtained from `POST /api/v1/auth/login` |
|
||||
| `last_seq` | uint64 | No | Last sequence number received. If > 0, server attempts replay. Default 0. |
|
||||
| `active_channel_id` | int64 | No | The channel the client had open when it disconnected. Honoured only on a resume (`last_seq > 0`) and only after the server re-checks read permission; an unknown or unreadable id is ignored. Omit when unknown. |
|
||||
| Field | Type | Required | Description |
|
||||
| ------------------- | ------ | -------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `token` | string | Yes | Session token obtained from `POST /api/v1/auth/login` |
|
||||
| `last_seq` | uint64 | No | Last sequence number received. If > 0, server attempts replay. Default 0. |
|
||||
| `active_channel_id` | int64 | No | The channel the client had open when it disconnected. Honoured only on a resume (`last_seq > 0`) and only after the server re-checks read permission; an unknown or unreadable id is ignored. Omit when unknown. |
|
||||
|
||||
`active_channel_id` closes a resume-only gap. The hub restores a reconnecting
|
||||
client's channel subscription by copying it from the previous connection entry,
|
||||
@@ -250,12 +251,12 @@ Every 30 seconds, the server checks all clients. Any client with no activity for
|
||||
|
||||
When a connection drops, the client automatically reconnects with exponential backoff (1s to 30s max) and sends `last_seq` in the `auth` message. The server resolves the reconnect through a **3-tier replay pipeline** (cheapest first):
|
||||
|
||||
| Tier | Condition | Server Behavior | `replay_source` |
|
||||
|------|-----------|-----------------|-----------------|
|
||||
| — | `last_seq == 0` | Full flow: `auth_ok` + `ready` + `member_join` + `presence` | `none` |
|
||||
| 1 | seq within the in-memory ring buffer (1000 events) | Replay flow: `auth_ok` + missed events + `presence` (no `member_join`, no `ready`). Channel-scoped events are permission-filtered (fail-closed). | `buffer` |
|
||||
| 2 | seq within the persistent `events` table (max 5000 events, subject to retention) | Same replay flow, served from the cold tier | `db` |
|
||||
| 3 | seq too far behind, or channel visibility changed while away | Full flow (fallback): same as `last_seq == 0` | `none` |
|
||||
| Tier | Condition | Server Behavior | `replay_source` |
|
||||
| ---- | -------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------ | --------------- |
|
||||
| — | `last_seq == 0` | Full flow: `auth_ok` + `ready` + `member_join` + `presence` | `none` |
|
||||
| 1 | seq within the in-memory ring buffer (1000 events) | Replay flow: `auth_ok` + missed events + `presence` (no `member_join`, no `ready`). Channel-scoped events are permission-filtered (fail-closed). | `buffer` |
|
||||
| 2 | seq within the persistent `events` table (max 5000 events, subject to retention) | Same replay flow, served from the cold tier | `db` |
|
||||
| 3 | seq too far behind, or channel visibility changed while away | Full flow (fallback): same as `last_seq == 0` | `none` |
|
||||
|
||||
A visibility watermark forces the tier-3 full re-sync whenever channel
|
||||
visibility changed while the client was disconnected, so permission changes
|
||||
@@ -346,12 +347,12 @@ to reconstruct them:
|
||||
}
|
||||
```
|
||||
|
||||
| Field | Type | Required | Constraints |
|
||||
|-------|------|----------|-------------|
|
||||
| `channel_id` | number | Yes | Positive integer |
|
||||
| `content` | string | Yes* | Max 4000 runes. HTML-sanitized. *Can be empty if `attachments` is non-empty. |
|
||||
| `reply_to` | number or null | No | Message ID being replied to |
|
||||
| `attachments` | string[] | No | Upload IDs from `POST /api/v1/uploads`. Requires `ATTACH_FILES` permission. |
|
||||
| Field | Type | Required | Constraints |
|
||||
| ------------- | -------------- | -------- | ---------------------------------------------------------------------------- |
|
||||
| `channel_id` | number | Yes | Positive integer |
|
||||
| `content` | string | Yes* | Max 4000 runes. HTML-sanitized. *Can be empty if `attachments` is non-empty. |
|
||||
| `reply_to` | number or null | No | Message ID being replied to |
|
||||
| `attachments` | string[] | No | Upload IDs from `POST /api/v1/uploads`. Requires `ATTACH_FILES` permission. |
|
||||
|
||||
### chat_send_ok (Server -> Client)
|
||||
|
||||
@@ -390,15 +391,17 @@ Direct response to sender (no seq):
|
||||
"reactions": [],
|
||||
"pinned": false,
|
||||
"mentions": [7, 9],
|
||||
"mentions_everyone": true
|
||||
"mentions_everyone": true,
|
||||
"mentions_here": false
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
| Field | Type | Description |
|
||||
|-------|------|-------------|
|
||||
| `mentions` | number[] | User IDs the server resolved from `@username` tokens. Always present; empty when nothing resolved. |
|
||||
| `mentions_everyone` | bool | `true` when the message carried `@everyone` or `@here` **and** the author holds `MENTION_EVERYONE` on that channel. |
|
||||
| Field | Type | Description |
|
||||
| ------------------- | -------- | ------------------------------------------------------------------------------------------------------------------- |
|
||||
| `mentions` | number[] | User IDs the server resolved from `@username` tokens. Always present; empty when nothing resolved. |
|
||||
| `mentions_everyone` | bool | `true` when the message carried `@everyone` or `@here` **and** the author holds `MENTION_EVERYONE` on that channel. |
|
||||
| `mentions_here` | bool | `true` when `mentions_everyone` came from `@here` rather than `@everyone` (never both). |
|
||||
|
||||
Mentions are resolved server-side at send time against existing usernames
|
||||
(case-insensitive, whole-word, capped at 20 per message). An `@word` that
|
||||
@@ -407,6 +410,16 @@ matches no username, and an `@everyone`/`@here` from an author without
|
||||
highlight from these fields rather than re-parsing the content. DMs never carry
|
||||
`mentions_everyone`.
|
||||
|
||||
`@everyone` and `@here` both raise `mention_count` for every reader except
|
||||
`@here` skips a reader with no live connection at send time (the server's
|
||||
`applyMentionCounts` treats that reader as unreachable, the same way a push
|
||||
notification would). A client cannot tell the two tokens apart from
|
||||
`mentions_everyone` alone, which is why `mentions_here` exists: a reconnecting
|
||||
client that replays this frame from the gap it was disconnected for must not
|
||||
raise a mention badge for a here-only mention the server never counted — there
|
||||
is no `ready` in that reconnect tier to correct a wrong badge afterward. A
|
||||
direct `mentions` hit is unaffected either way.
|
||||
|
||||
### chat_edit (Client -> Server)
|
||||
|
||||
```json
|
||||
@@ -434,15 +447,16 @@ Own messages only. Max 4000 runes.
|
||||
"content": "Hello everyone! (edited)",
|
||||
"edited_at": "2026-03-14T10:31:00Z",
|
||||
"mentions": [7],
|
||||
"mentions_everyone": false
|
||||
"mentions_everyone": false,
|
||||
"mentions_here": false
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`mentions`/`mentions_everyone` are re-resolved from the edited content and
|
||||
replace the stored set. Editing never raises anyone's `mention_count`: a badge
|
||||
is only ever raised by the original send, so re-adding an already-counted
|
||||
mention cannot double-count it.
|
||||
`mentions`/`mentions_everyone`/`mentions_here` are re-resolved from the edited
|
||||
content and replace the stored set. Editing never raises anyone's
|
||||
`mention_count`: a badge is only ever raised by the original send, so
|
||||
re-adding an already-counted mention cannot double-count it.
|
||||
|
||||
### chat_delete (Client -> Server)
|
||||
|
||||
@@ -635,7 +649,7 @@ Advances the caller's read state for `channel_id` to that channel's latest
|
||||
message and resets its `mention_count` to 0 — exactly what `channel_focus` does
|
||||
to unread state — **without** changing which channel the connection is focused
|
||||
on. This is what backs "Mark as Read" in the channel context menu and "Mark All
|
||||
as Read": marking a channel the user is *not* looking at must not rebind the
|
||||
as Read": marking a channel the user is _not_ looking at must not rebind the
|
||||
connection's focused channel, which would misroute unread bookkeeping for the
|
||||
channel actually on screen.
|
||||
|
||||
@@ -793,8 +807,22 @@ dropped intermediate event can never leave a deleted role on screen.
|
||||
"type": "roles_update",
|
||||
"payload": {
|
||||
"roles": [
|
||||
{ "id": 1, "name": "Owner", "color": "#E74C3C", "permissions": 2147483647, "position": 100, "is_default": false },
|
||||
{ "id": 4, "name": "Member", "color": null, "permissions": 1635, "position": 40, "is_default": true }
|
||||
{
|
||||
"id": 1,
|
||||
"name": "Owner",
|
||||
"color": "#E74C3C",
|
||||
"permissions": 2147483647,
|
||||
"position": 100,
|
||||
"is_default": false
|
||||
},
|
||||
{
|
||||
"id": 4,
|
||||
"name": "Member",
|
||||
"color": null,
|
||||
"permissions": 1635,
|
||||
"position": 40,
|
||||
"is_default": true
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -885,6 +913,7 @@ Voice uses LiveKit as the SFU. WebSocket messages handle signaling (join/leave/s
|
||||
```
|
||||
|
||||
On success, server sends (in order):
|
||||
|
||||
1. `voice_token` -- LiveKit JWT + URL
|
||||
2. `voice_state` broadcast -- joiner's state to all clients
|
||||
3. Existing `voice_state` messages -- one per existing participant (to joiner only)
|
||||
@@ -929,11 +958,11 @@ restricted by the user's permissions.
|
||||
|
||||
Quality presets:
|
||||
|
||||
| Preset | Bitrate |
|
||||
|--------|---------|
|
||||
| `low` | 32,000 bps |
|
||||
| `medium` | 64,000 bps |
|
||||
| `high` | 128,000 bps |
|
||||
| Preset | Bitrate |
|
||||
| -------- | ----------- |
|
||||
| `low` | 32,000 bps |
|
||||
| `medium` | 64,000 bps |
|
||||
| `high` | 128,000 bps |
|
||||
|
||||
### voice_leave (Client -> Server)
|
||||
|
||||
@@ -1404,26 +1433,26 @@ and the ringer's own 30s window already covers it.
|
||||
|
||||
### Error Codes
|
||||
|
||||
| Code | Description |
|
||||
|------|-------------|
|
||||
| `BAD_REQUEST` | Invalid payload format or field values |
|
||||
| `BAD_PAYLOAD` | Structurally valid message with a field that fails validation (E2EE announce/offer key material, signatures, targets) |
|
||||
| `INTERNAL` | Server-side error |
|
||||
| `NOT_FOUND` | Channel or message not found |
|
||||
| `FORBIDDEN` | Missing required permission |
|
||||
| `NOT_KEY_HOLDER` | `voice_e2ee_offer` sent by a participant who is not the channel's key holder |
|
||||
| `RATE_LIMITED` | Too many requests (the error carries only `code` and `message`; REST 429s carry a `Retry-After` header, WS errors do not) |
|
||||
| `ALREADY_JOINED` | Already in this voice channel |
|
||||
| `CHANNEL_FULL` | Voice channel at capacity |
|
||||
| `VOICE_ERROR` | Voice-specific error |
|
||||
| `VIDEO_LIMIT` | Maximum video streams reached |
|
||||
| `BANNED` | User is banned |
|
||||
| `INVALID_JSON` | Message is not valid JSON |
|
||||
| `UNKNOWN_TYPE` | Unrecognized message type |
|
||||
| `SLOW_MODE` | Channel has slow mode enabled |
|
||||
| `CONFLICT` | Duplicate reaction or constraint violation |
|
||||
| `SERVER_MUTED` | Self-unmute refused: a moderator imposed the mute |
|
||||
| `SERVER_DEAFENED` | Self-undeafen refused: a moderator imposed the deafen |
|
||||
| Code | Description |
|
||||
| ----------------- | ------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `BAD_REQUEST` | Invalid payload format or field values |
|
||||
| `BAD_PAYLOAD` | Structurally valid message with a field that fails validation (E2EE announce/offer key material, signatures, targets) |
|
||||
| `INTERNAL` | Server-side error |
|
||||
| `NOT_FOUND` | Channel or message not found |
|
||||
| `FORBIDDEN` | Missing required permission |
|
||||
| `NOT_KEY_HOLDER` | `voice_e2ee_offer` sent by a participant who is not the channel's key holder |
|
||||
| `RATE_LIMITED` | Too many requests (the error carries only `code` and `message`; REST 429s carry a `Retry-After` header, WS errors do not) |
|
||||
| `ALREADY_JOINED` | Already in this voice channel |
|
||||
| `CHANNEL_FULL` | Voice channel at capacity |
|
||||
| `VOICE_ERROR` | Voice-specific error |
|
||||
| `VIDEO_LIMIT` | Maximum video streams reached |
|
||||
| `BANNED` | User is banned |
|
||||
| `INVALID_JSON` | Message is not valid JSON |
|
||||
| `UNKNOWN_TYPE` | Unrecognized message type |
|
||||
| `SLOW_MODE` | Channel has slow mode enabled |
|
||||
| `CONFLICT` | Duplicate reaction or constraint violation |
|
||||
| `SERVER_MUTED` | Self-unmute refused: a moderator imposed the mute |
|
||||
| `SERVER_DEAFENED` | Self-undeafen refused: a moderator imposed the deafen |
|
||||
|
||||
After 10 consecutive invalid JSON messages, the connection is forcibly closed.
|
||||
|
||||
@@ -1433,27 +1462,27 @@ After 10 consecutive invalid JSON messages, the connection is forcibly closed.
|
||||
|
||||
All rate limits are enforced server-side using a token bucket rate limiter.
|
||||
|
||||
| Action | Limit | Window | Error Response |
|
||||
|--------|-------|--------|----------------|
|
||||
| Chat send | 10 | 1 second | `RATE_LIMITED` error |
|
||||
| Chat edit | 10 | 1 second | `RATE_LIMITED` error |
|
||||
| Chat delete | 10 | 1 second | `RATE_LIMITED` error |
|
||||
| Typing | 1 | 3 seconds | Silently dropped |
|
||||
| Presence | 1 | 10 seconds | `RATE_LIMITED` error |
|
||||
| Reactions | 5 | 1 second | `RATE_LIMITED` error |
|
||||
| Voice join / leave | 5 | 1 second | `RATE_LIMITED` error |
|
||||
| Voice camera | 2 | 1 second | `RATE_LIMITED` error |
|
||||
| Voice screenshare | 2 | 1 second | `RATE_LIMITED` error |
|
||||
| Voice token refresh | 1 | 60 seconds | `RATE_LIMITED` error |
|
||||
| Voice E2EE announce | 5 | 1 second | `RATE_LIMITED` error |
|
||||
| Voice E2EE offer | 64 | 1 second | `RATE_LIMITED` error |
|
||||
| Voice moderation (mute/deafen/move/kick) | 5 | 1 second | `RATE_LIMITED` error |
|
||||
| Call ring | 1 | 3 seconds | `RATE_LIMITED` error |
|
||||
| Call decline | 1 | 3 seconds | `RATE_LIMITED` error |
|
||||
| Plugin command (`chat_command`) | 5 | 1 second | `RATE_LIMITED` error |
|
||||
| Channel focus | 5 | 1 second | Silently dropped |
|
||||
| Mark read | 5 | 1 second (own budget, separate from focus) | Silently dropped |
|
||||
| Ping | 2 | 1 second | Silently dropped |
|
||||
| Action | Limit | Window | Error Response |
|
||||
| ---------------------------------------- | ----- | ------------------------------------------ | -------------------- |
|
||||
| Chat send | 10 | 1 second | `RATE_LIMITED` error |
|
||||
| Chat edit | 10 | 1 second | `RATE_LIMITED` error |
|
||||
| Chat delete | 10 | 1 second | `RATE_LIMITED` error |
|
||||
| Typing | 1 | 3 seconds | Silently dropped |
|
||||
| Presence | 1 | 10 seconds | `RATE_LIMITED` error |
|
||||
| Reactions | 5 | 1 second | `RATE_LIMITED` error |
|
||||
| Voice join / leave | 5 | 1 second | `RATE_LIMITED` error |
|
||||
| Voice camera | 2 | 1 second | `RATE_LIMITED` error |
|
||||
| Voice screenshare | 2 | 1 second | `RATE_LIMITED` error |
|
||||
| Voice token refresh | 1 | 60 seconds | `RATE_LIMITED` error |
|
||||
| Voice E2EE announce | 5 | 1 second | `RATE_LIMITED` error |
|
||||
| Voice E2EE offer | 64 | 1 second | `RATE_LIMITED` error |
|
||||
| Voice moderation (mute/deafen/move/kick) | 5 | 1 second | `RATE_LIMITED` error |
|
||||
| Call ring | 1 | 3 seconds | `RATE_LIMITED` error |
|
||||
| Call decline | 1 | 3 seconds | `RATE_LIMITED` error |
|
||||
| Plugin command (`chat_command`) | 5 | 1 second | `RATE_LIMITED` error |
|
||||
| Channel focus | 5 | 1 second | Silently dropped |
|
||||
| Mark read | 5 | 1 second (own budget, separate from focus) | Silently dropped |
|
||||
| Ping | 2 | 1 second | Silently dropped |
|
||||
|
||||
The E2EE offer budget is deliberately higher than the announce budget: a key
|
||||
rotation fires one offer per peer in a single burst, so the limit is sized to
|
||||
@@ -1465,96 +1494,96 @@ recipient from being flooded.
|
||||
|
||||
## Message Type Reference Table
|
||||
|
||||
The authoritative type inventory is [protocol-schema.json](protocol-schema.json),
|
||||
The authoritative type inventory is [protocol/schema.json](../protocol/schema.json),
|
||||
from which the Go and TypeScript constant files are generated
|
||||
(`make protocol-generate` / verified in CI by `make protocol-verify`). The
|
||||
tables below add per-type behavioral notes.
|
||||
|
||||
### Client -> Server (27 types)
|
||||
|
||||
| Type | Rate Limit | Notes |
|
||||
|------|-----------|-------|
|
||||
| `auth` | N/A (first message) | Token + optional last_seq |
|
||||
| `chat_send` | 10/sec | + slow mode per channel |
|
||||
| `chat_edit` | 10/sec | Own messages only |
|
||||
| `chat_delete` | 10/sec | Own or mod (non-DM) |
|
||||
| `reaction_add` | 5/sec | |
|
||||
| `reaction_remove` | 5/sec | |
|
||||
| `typing_start` | 1/3sec/channel | Silently dropped |
|
||||
| `channel_focus` | 5/sec (silently dropped) | Updates read state |
|
||||
| `mark_read` | 5/sec, own budget (silently dropped) | Updates read state without moving focus |
|
||||
| `presence_update` | 1/10sec | |
|
||||
| `voice_join` | 5/sec | |
|
||||
| `voice_leave` | 5/sec | Empty payload |
|
||||
| `voice_mute` | 2/sec | Refused with `SERVER_MUTED` while server muted |
|
||||
| `voice_deafen` | 2/sec | Refused with `SERVER_DEAFENED` while server deafened |
|
||||
| `voice_camera` | 2/sec | Requires USE_VIDEO |
|
||||
| `voice_screenshare` | 2/sec | Requires SHARE_SCREEN |
|
||||
| `voice_mod_mute` | 5/sec | Requires MUTE_MEMBERS + outranks target |
|
||||
| `voice_mod_deafen` | 5/sec | Requires MUTE_MEMBERS + outranks target |
|
||||
| `voice_mod_move` | 5/sec | Requires MUTE_MEMBERS + outranks target |
|
||||
| `voice_mod_kick` | 5/sec | Requires MUTE_MEMBERS + outranks target |
|
||||
| `voice_token_refresh` | 1/60sec | Must be in voice |
|
||||
| `voice_e2ee_announce` | 5/sec | ECDH pubkey announce |
|
||||
| `voice_e2ee_offer` | 64/sec outer, 5/sec per target | Wrapped room key to target (budgeted per key rotation) |
|
||||
| `call_ring` | 1/3sec | DM participants only; fans out as `call_incoming` |
|
||||
| `call_decline` | 1/3sec | DM participants only; fans out as `call_declined` |
|
||||
| `chat_command` | 5/sec | Plugin slash command; max 64 args; broadcast gated by `CanPost` |
|
||||
| `ping` | 2/sec (silently dropped) | Heartbeat |
|
||||
| Type | Rate Limit | Notes |
|
||||
| --------------------- | ------------------------------------ | --------------------------------------------------------------- |
|
||||
| `auth` | N/A (first message) | Token + optional last_seq |
|
||||
| `chat_send` | 10/sec | + slow mode per channel |
|
||||
| `chat_edit` | 10/sec | Own messages only |
|
||||
| `chat_delete` | 10/sec | Own or mod (non-DM) |
|
||||
| `reaction_add` | 5/sec | |
|
||||
| `reaction_remove` | 5/sec | |
|
||||
| `typing_start` | 1/3sec/channel | Silently dropped |
|
||||
| `channel_focus` | 5/sec (silently dropped) | Updates read state |
|
||||
| `mark_read` | 5/sec, own budget (silently dropped) | Updates read state without moving focus |
|
||||
| `presence_update` | 1/10sec | |
|
||||
| `voice_join` | 5/sec | |
|
||||
| `voice_leave` | 5/sec | Empty payload |
|
||||
| `voice_mute` | 2/sec | Refused with `SERVER_MUTED` while server muted |
|
||||
| `voice_deafen` | 2/sec | Refused with `SERVER_DEAFENED` while server deafened |
|
||||
| `voice_camera` | 2/sec | Requires USE_VIDEO |
|
||||
| `voice_screenshare` | 2/sec | Requires SHARE_SCREEN |
|
||||
| `voice_mod_mute` | 5/sec | Requires MUTE_MEMBERS + outranks target |
|
||||
| `voice_mod_deafen` | 5/sec | Requires MUTE_MEMBERS + outranks target |
|
||||
| `voice_mod_move` | 5/sec | Requires MUTE_MEMBERS + outranks target |
|
||||
| `voice_mod_kick` | 5/sec | Requires MUTE_MEMBERS + outranks target |
|
||||
| `voice_token_refresh` | 1/60sec | Must be in voice |
|
||||
| `voice_e2ee_announce` | 5/sec | ECDH pubkey announce |
|
||||
| `voice_e2ee_offer` | 64/sec outer, 5/sec per target | Wrapped room key to target (budgeted per key rotation) |
|
||||
| `call_ring` | 1/3sec | DM participants only; fans out as `call_incoming` |
|
||||
| `call_decline` | 1/3sec | DM participants only; fans out as `call_declined` |
|
||||
| `chat_command` | 5/sec | Plugin slash command; max 64 args; broadcast gated by `CanPost` |
|
||||
| `ping` | 2/sec (silently dropped) | Heartbeat |
|
||||
|
||||
### Server -> Client (39 types)
|
||||
|
||||
| Type | Has seq? | Delivery |
|
||||
|------|----------|----------|
|
||||
| `auth_ok` | No | Direct |
|
||||
| `auth_error` | No | Direct (then close) |
|
||||
| `ready` | No | Direct |
|
||||
| `chat_message` | Yes | Channel or DM participants |
|
||||
| `chat_send_ok` | No | Direct to sender |
|
||||
| `chat_edited` | Yes | Channel or DM participants |
|
||||
| `chat_deleted` | Yes | Channel or DM participants |
|
||||
| `chat_bulk_deleted` | Yes | Channel |
|
||||
| `reaction_update` | Yes | Channel or DM participants |
|
||||
| `typing` | No | Channel (excl. sender) or DM |
|
||||
| `presence` | Yes | All clients |
|
||||
| `channel_create` | Yes | All clients |
|
||||
| `channel_update` | Yes | All clients |
|
||||
| `channel_delete` | Yes | All clients |
|
||||
| `voice_state` | Yes | All clients |
|
||||
| `voice_leave` | Yes | All clients |
|
||||
| `voice_moved` | No | Direct to moved user |
|
||||
| `voice_disconnected` | No | Direct to disconnected user |
|
||||
| `voice_config` | No | Direct to joiner |
|
||||
| `voice_token` | No | Direct to joiner |
|
||||
| `voice_speakers` | No | Reserved — not currently emitted |
|
||||
| `member_join` | Yes | All clients |
|
||||
| `member_leave` | Yes | Reserved — not currently emitted |
|
||||
| `member_update` | Yes | All clients |
|
||||
| `user_update` | Yes | All clients (profile changes) |
|
||||
| `member_ban` | Yes | All clients |
|
||||
| `roles_update` | Yes | All clients (full role list) |
|
||||
| `emoji_update` | Yes | All clients (full custom-emoji set) |
|
||||
| `dm_channel_open` | No | Direct to participant |
|
||||
| `dm_channel_close` | No | Direct to participant |
|
||||
| `call_incoming` | No | Direct to each other DM participant |
|
||||
| `call_declined` | No | Direct to each other DM participant |
|
||||
| `voice_e2ee_announce` | No | Voice channel (excl. sender) |
|
||||
| `voice_e2ee_offer` | No | Direct to target participant |
|
||||
| `server_restart` | Yes | All clients |
|
||||
| `error` | No | Direct to requester |
|
||||
| `pong` | No | Direct to pinger |
|
||||
| `command_reply` | No | Direct to invoking client (ephemeral plugin reply) |
|
||||
| `plugin_broadcast` | Yes | Channel (plugin output posted as a broadcast; sequenced and replayable) |
|
||||
| Type | Has seq? | Delivery |
|
||||
| --------------------- | -------- | ----------------------------------------------------------------------- |
|
||||
| `auth_ok` | No | Direct |
|
||||
| `auth_error` | No | Direct (then close) |
|
||||
| `ready` | No | Direct |
|
||||
| `chat_message` | Yes | Channel or DM participants |
|
||||
| `chat_send_ok` | No | Direct to sender |
|
||||
| `chat_edited` | Yes | Channel or DM participants |
|
||||
| `chat_deleted` | Yes | Channel or DM participants |
|
||||
| `chat_bulk_deleted` | Yes | Channel |
|
||||
| `reaction_update` | Yes | Channel or DM participants |
|
||||
| `typing` | No | Channel (excl. sender) or DM |
|
||||
| `presence` | Yes | All clients |
|
||||
| `channel_create` | Yes | All clients |
|
||||
| `channel_update` | Yes | All clients |
|
||||
| `channel_delete` | Yes | All clients |
|
||||
| `voice_state` | Yes | All clients |
|
||||
| `voice_leave` | Yes | All clients |
|
||||
| `voice_moved` | No | Direct to moved user |
|
||||
| `voice_disconnected` | No | Direct to disconnected user |
|
||||
| `voice_config` | No | Direct to joiner |
|
||||
| `voice_token` | No | Direct to joiner |
|
||||
| `voice_speakers` | No | Reserved — not currently emitted |
|
||||
| `member_join` | Yes | All clients |
|
||||
| `member_leave` | Yes | Reserved — not currently emitted |
|
||||
| `member_update` | Yes | All clients |
|
||||
| `user_update` | Yes | All clients (profile changes) |
|
||||
| `member_ban` | Yes | All clients |
|
||||
| `roles_update` | Yes | All clients (full role list) |
|
||||
| `emoji_update` | Yes | All clients (full custom-emoji set) |
|
||||
| `dm_channel_open` | No | Direct to participant |
|
||||
| `dm_channel_close` | No | Direct to participant |
|
||||
| `call_incoming` | No | Direct to each other DM participant |
|
||||
| `call_declined` | No | Direct to each other DM participant |
|
||||
| `voice_e2ee_announce` | No | Voice channel (excl. sender) |
|
||||
| `voice_e2ee_offer` | No | Direct to target participant |
|
||||
| `server_restart` | Yes | All clients |
|
||||
| `error` | No | Direct to requester |
|
||||
| `pong` | No | Direct to pinger |
|
||||
| `command_reply` | No | Direct to invoking client (ephemeral plugin reply) |
|
||||
| `plugin_broadcast` | Yes | Channel (plugin output posted as a broadcast; sequenced and replayable) |
|
||||
|
||||
### Plugin command types
|
||||
|
||||
Three wire types exist for the WASM plugin system. Since 2026-08-04 they are
|
||||
listed in `protocol-schema.json` like every other type (closing DC-01), so
|
||||
listed in `protocol/schema.json` like every other type (closing DC-01), so
|
||||
the generated constants cover them and `make protocol-verify` plus the
|
||||
`ws` package's protocol-contract test gate them against drift.
|
||||
|
||||
| Type | Direction | Notes |
|
||||
|------|-----------|-------|
|
||||
| `chat_command` | Client -> Server | `{command, args[], channel_id, req_id?}`; max 64 args; unknown commands return an `error`. Rate limited at 5/sec (`RATE_LIMITED`); a channel broadcast is gated by the same `CanPost` policy as a real message send. |
|
||||
| `command_reply` | Server -> Client | Ephemeral plugin reply, sent only to the invoking client; echoes `req_id`. Payload: `{text}`. |
|
||||
| `plugin_broadcast` | Server -> Client | Plugin output posted to a channel. Payload: `{channel_id, user_id, command, text}`. |
|
||||
| Type | Direction | Notes |
|
||||
| ------------------ | ---------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `chat_command` | Client -> Server | `{command, args[], channel_id, req_id?}`; max 64 args; unknown commands return an `error`. Rate limited at 5/sec (`RATE_LIMITED`); a channel broadcast is gated by the same `CanPost` policy as a real message send. |
|
||||
| `command_reply` | Server -> Client | Ephemeral plugin reply, sent only to the invoking client; echoes `req_id`. Payload: `{text}`. |
|
||||
| `plugin_broadcast` | Server -> Client | Plugin output posted to a channel. Payload: `{channel_id, user_id, command, text}`. |
|
||||
|
||||
+19
-19
@@ -4,23 +4,23 @@ Get OwnCord running with the fewest possible steps.
|
||||
|
||||
## Choose Your Setup Path
|
||||
|
||||
| Goal | Best path |
|
||||
| ---- | --------- |
|
||||
| Fastest local/LAN setup | Prebuilt binaries |
|
||||
| Linux server with easiest operations | Docker |
|
||||
| Custom dev build | Build from source |
|
||||
| Goal | Best path |
|
||||
| ------------------------------------ | ----------------- |
|
||||
| Fastest local/LAN setup | Prebuilt binaries |
|
||||
| Linux server with easiest operations | Docker |
|
||||
| Custom dev build | Build from source |
|
||||
|
||||
## Platform Support (Current Releases)
|
||||
|
||||
| Component | Windows x64 | Linux x64 | Linux ARM64 |
|
||||
| --------- | ----------- | --------- | ----------- |
|
||||
| Server binary | Yes | Yes | Not published yet |
|
||||
| Desktop client | Yes | Yes | Yes |
|
||||
| Component | Windows x64 | Linux x64 | Linux ARM64 |
|
||||
| -------------- | ----------- | --------- | ----------------- |
|
||||
| Server binary | Yes | Yes | Not published yet |
|
||||
| Desktop client | Yes | Yes | Yes |
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Go 1.26+ (only if building server from source)
|
||||
- Node.js 20+ and Rust (only if building client from source)
|
||||
- Node.js 24+ and Rust (only if building client from source)
|
||||
- Docker + Compose v2 (Docker path only)
|
||||
- LiveKit (optional, required for voice/video)
|
||||
|
||||
@@ -28,8 +28,8 @@ Get OwnCord running with the fewest possible steps.
|
||||
|
||||
1. Download from [GitHub Releases](https://github.com/J3vb/OwnCord/releases).
|
||||
2. Start the server:
|
||||
- Windows: `chatserver.exe`
|
||||
- Linux: `./chatserver`
|
||||
- Windows: `chatserver.exe`
|
||||
- Linux: `./chatserver`
|
||||
3. Open `https://localhost:8443/admin`.
|
||||
4. Complete the setup wizard: it creates the Owner account and configures the
|
||||
basics (server name, port, security, uploads, voice). Your choices are
|
||||
@@ -55,14 +55,14 @@ Full Docker details: [Deployment Guide](deployment.md#docker-linux).
|
||||
```bash
|
||||
# Server (Windows)
|
||||
cd Server
|
||||
go build -o chatserver.exe -ldflags "-s -w -X main.version=1.2.0-alpha.3" .
|
||||
go build -o chatserver.exe -ldflags "-s -w -X main.version=1.2.0-alpha.4" .
|
||||
|
||||
# Server (Linux)
|
||||
cd Server
|
||||
CGO_ENABLED=0 go build -o chatserver -ldflags "-s -w -X main.version=1.2.0-alpha.3" .
|
||||
CGO_ENABLED=0 go build -o chatserver -ldflags "-s -w -X main.version=1.2.0-alpha.4" .
|
||||
|
||||
# Client
|
||||
cd Client/tauri-client
|
||||
cd Client
|
||||
npm install
|
||||
npm run tauri build
|
||||
```
|
||||
@@ -78,11 +78,11 @@ npm run tauri build
|
||||
|
||||
- The default server address is `https://<server-ip>:8443`.
|
||||
- The desktop client uses TOFU certificate pinning:
|
||||
- First connection prompts for trust.
|
||||
- Future connections require the same cert fingerprint.
|
||||
- First connection prompts for trust.
|
||||
- Future connections require the same cert fingerprint.
|
||||
- Linux/Wayland: the client automatically sets `WEBKIT_DISABLE_DMABUF_RENDERER=1`
|
||||
on Wayland sessions to work around WebKitGTK rendering crashes. Export the
|
||||
variable yourself (any value) before launching to override this.
|
||||
on Wayland sessions to work around WebKitGTK rendering crashes. Export the
|
||||
variable yourself (any value) before launching to override this.
|
||||
|
||||
## If Remote Users Cannot Connect
|
||||
|
||||
|
||||
+115
-115
@@ -16,15 +16,15 @@ OwnCord uses a single SQLite database file (`data/chatserver.db`) with the pure-
|
||||
|
||||
## Database Configuration
|
||||
|
||||
| PRAGMA | Value | Purpose |
|
||||
|--------|-------|---------|
|
||||
| `journal_mode` | `WAL` | Write-Ahead Logging for concurrent readers |
|
||||
| `foreign_keys` | `ON` | Enforces all `REFERENCES` constraints |
|
||||
| `busy_timeout` | `5000` | Waits up to 5 seconds for the write lock |
|
||||
| `synchronous` | `NORMAL` | Safe with WAL mode, reduces fsync calls |
|
||||
| `temp_store` | `MEMORY` | Temporary tables stored in RAM |
|
||||
| `mmap_size` | `268435456` | 256 MB memory-mapped I/O |
|
||||
| `cache_size` | `-64000` | 64 MB page cache |
|
||||
| PRAGMA | Value | Purpose |
|
||||
| -------------- | ----------- | ------------------------------------------ |
|
||||
| `journal_mode` | `WAL` | Write-Ahead Logging for concurrent readers |
|
||||
| `foreign_keys` | `ON` | Enforces all `REFERENCES` constraints |
|
||||
| `busy_timeout` | `5000` | Waits up to 5 seconds for the write lock |
|
||||
| `synchronous` | `NORMAL` | Safe with WAL mode, reduces fsync calls |
|
||||
| `temp_store` | `MEMORY` | Temporary tables stored in RAM |
|
||||
| `mmap_size` | `268435456` | 256 MB memory-mapped I/O |
|
||||
| `cache_size` | `-64000` | 64 MB page cache |
|
||||
|
||||
SQLite only allows one writer at a time. File-backed databases (the production
|
||||
mode) therefore run a split pool: a single-connection writer pool
|
||||
@@ -48,39 +48,39 @@ CREATE TABLE IF NOT EXISTS schema_versions (
|
||||
|
||||
### Migration History
|
||||
|
||||
| File | Description |
|
||||
|------|-------------|
|
||||
| `001_initial_schema.sql` | All core tables, default roles and settings |
|
||||
| `002_voice_states.sql` | Adds `voice_states` table |
|
||||
| `003_audit_log.sql` | Recreates `audit_log` with canonical column names (via a transient `audit_log_v6` rename) |
|
||||
| `004_voice_optimization.sql` | Adds `camera`, `screenshare` to voice_states; voice settings to channels |
|
||||
| `005_fix_member_permissions.sql` | Fixes Member role permissions |
|
||||
| `006_channel_overrides_index.sql` | Adds composite index on channel_overrides |
|
||||
| `007_member_video_permissions.sql` | Adds USE_VIDEO and SHARE_SCREEN to Member role |
|
||||
| `008_attachment_dimensions.sql` | Adds `width` and `height` to attachments |
|
||||
| `009_dm_tables.sql` | Adds `dm_participants` and `dm_open_state` tables |
|
||||
| `010_attachment_uploader.sql` | Adds `attachments.uploader_id` + index for upload-ownership checks |
|
||||
| `011_rate_lockouts.sql` | Adds `rate_lockouts` so rate-limit lockouts survive restarts |
|
||||
| `012_user_blocks.sql` | Adds `user_blocks` (blocks DM creation/messaging between users) |
|
||||
| `013_channel_type_constraint.sql` | INSERT/UPDATE triggers restricting `channels.type` to `text`/`voice`/`dm` |
|
||||
| `014_events_table.sql` | Adds `events` — persistent broadcast log for reconnect cold-tier replay |
|
||||
| `015_plugins.sql` | Adds `plugins` and `plugin_kv` for the WASM plugin runtime |
|
||||
| `016_announcement_channel_type.sql` | Recreates the channel-type triggers to allow `announcement` |
|
||||
| `017_user_identity_key.sql` | Adds `users.identity_public_key` (long-term E2EE identity key for voice TOFU) |
|
||||
| `018_api_tokens.sql` | Adds `api_tokens` — long-lived, revocable bearer tokens for headless clients (bot/service auth) |
|
||||
| `019_perf_indexes.sql` | Adds hot-path indexes |
|
||||
| `020_drop_redundant_indexes.sql` | Drops indexes duplicating UNIQUE auto-indexes |
|
||||
| `021_voice_server_moderation.sql` | Adds `server_muted`, `server_deafened` to voice_states (moderator-imposed) |
|
||||
| `022_message_mentions.sql` | Adds `message_mentions` + `messages.mentions_everyone`, and grants `MENTION_EVERYONE` (bit 21) to the seeded Owner/Admin/Moderator roles |
|
||||
| `023_role_management.sql` | Adds `idx_roles_name_nocase` — role names become unique case-insensitively, matching how they are looked up |
|
||||
| `024_channel_user_overrides.sql` | Adds `channel_user_overrides` — per-member channel permission overrides, the last layer of the resolution order |
|
||||
| `025_channel_nsfw.sql` | Adds `channels.nsfw` — the age-gate flag the server stores and broadcasts but imposes no behaviour of its own on |
|
||||
| `026_emoji_mime.sql` | Adds `emoji.mime_type` — the sniffed image type, so the emoji image route can send a Content-Type without re-reading the file |
|
||||
| `027_user_profile_fields.sql` | Adds `users.display_name`, `users.about`, `users.custom_status`, and a partial index on `users(avatar)` for the file route's avatar-authorization probe |
|
||||
| `028_group_dms.sql` | Adds `channels.is_group` + a partial index — marks a DM channel as a group so group-ness survives people leaving |
|
||||
| `029_drop_sounds_table.sql` | Drops `sounds` — dead since 001; the soundboard it was created for was never built (A-2026-07-13) |
|
||||
| File | Description |
|
||||
| ---------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `001_initial_schema.sql` | All core tables, default roles and settings |
|
||||
| `002_voice_states.sql` | Adds `voice_states` table |
|
||||
| `003_audit_log.sql` | Recreates `audit_log` with canonical column names (via a transient `audit_log_v6` rename) |
|
||||
| `004_voice_optimization.sql` | Adds `camera`, `screenshare` to voice_states; voice settings to channels |
|
||||
| `005_fix_member_permissions.sql` | Fixes Member role permissions |
|
||||
| `006_channel_overrides_index.sql` | Adds composite index on channel_overrides |
|
||||
| `007_member_video_permissions.sql` | Adds USE_VIDEO and SHARE_SCREEN to Member role |
|
||||
| `008_attachment_dimensions.sql` | Adds `width` and `height` to attachments |
|
||||
| `009_dm_tables.sql` | Adds `dm_participants` and `dm_open_state` tables |
|
||||
| `010_attachment_uploader.sql` | Adds `attachments.uploader_id` + index for upload-ownership checks |
|
||||
| `011_rate_lockouts.sql` | Adds `rate_lockouts` so rate-limit lockouts survive restarts |
|
||||
| `012_user_blocks.sql` | Adds `user_blocks` (blocks DM creation/messaging between users) |
|
||||
| `013_channel_type_constraint.sql` | INSERT/UPDATE triggers restricting `channels.type` to `text`/`voice`/`dm` |
|
||||
| `014_events_table.sql` | Adds `events` — persistent broadcast log for reconnect cold-tier replay |
|
||||
| `015_plugins.sql` | Adds `plugins` and `plugin_kv` for the WASM plugin runtime |
|
||||
| `016_announcement_channel_type.sql` | Recreates the channel-type triggers to allow `announcement` |
|
||||
| `017_user_identity_key.sql` | Adds `users.identity_public_key` (long-term E2EE identity key for voice TOFU) |
|
||||
| `018_api_tokens.sql` | Adds `api_tokens` — long-lived, revocable bearer tokens for headless clients (bot/service auth) |
|
||||
| `019_perf_indexes.sql` | Adds hot-path indexes |
|
||||
| `020_drop_redundant_indexes.sql` | Drops indexes duplicating UNIQUE auto-indexes |
|
||||
| `021_voice_server_moderation.sql` | Adds `server_muted`, `server_deafened` to voice_states (moderator-imposed) |
|
||||
| `022_message_mentions.sql` | Adds `message_mentions` + `messages.mentions_everyone`, and grants `MENTION_EVERYONE` (bit 21) to the seeded Owner/Admin/Moderator roles |
|
||||
| `023_role_management.sql` | Adds `idx_roles_name_nocase` — role names become unique case-insensitively, matching how they are looked up |
|
||||
| `024_channel_user_overrides.sql` | Adds `channel_user_overrides` — per-member channel permission overrides, the last layer of the resolution order |
|
||||
| `025_channel_nsfw.sql` | Adds `channels.nsfw` — the age-gate flag the server stores and broadcasts but imposes no behaviour of its own on |
|
||||
| `026_emoji_mime.sql` | Adds `emoji.mime_type` — the sniffed image type, so the emoji image route can send a Content-Type without re-reading the file |
|
||||
| `027_user_profile_fields.sql` | Adds `users.display_name`, `users.about`, `users.custom_status`, and a partial index on `users(avatar)` for the file route's avatar-authorization probe |
|
||||
| `028_group_dms.sql` | Adds `channels.is_group` + a partial index — marks a DM channel as a group so group-ness survives people leaving |
|
||||
| `029_drop_sounds_table.sql` | Drops `sounds` — dead since 001; the soundboard it was created for was never built (A-2026-07-13) |
|
||||
| `030_attachments_unlink_on_message_delete.sql` | Rebuilds `attachments` with `message_id ON DELETE SET NULL` (was CASCADE) — cascaded message deletes now unlink rows instead of removing them, so the periodic orphan sweep can still find and reclaim the stored files |
|
||||
| `031_sessions_expiry_index.sql` | Normalizes legacy `sessions.expires_at` values to RFC3339 UTC and adds `idx_sessions_expires_at` so the 15-minute expiry sweep is sargable |
|
||||
| `031_sessions_expiry_index.sql` | Normalizes legacy `sessions.expires_at` values to RFC3339 UTC and adds `idx_sessions_expires_at` so the 15-minute expiry sweep is sargable |
|
||||
|
||||
---
|
||||
|
||||
@@ -103,14 +103,14 @@ CREATE TABLE roles (
|
||||
|
||||
**Default roles** — current values after the full migration set (001 seeds
|
||||
different masks: 005/007 raise Member's, 022 grants `MENTION_EVERYONE` to
|
||||
Owner/Admin/Moderator). Not fixed at runtime — see *Role semantics* below:
|
||||
Owner/Admin/Moderator). Not fixed at runtime — see _Role semantics_ below:
|
||||
|
||||
| id | name | color | permissions | position | Notes |
|
||||
|----|------|-------|-------------|----------|-------|
|
||||
| 1 | Owner | `#E74C3C` | `0x7FFFFFFF` | 100 | All 31 permission bits set |
|
||||
| 2 | Admin | `#F39C12` | `0x3FFFFFFF` | 80 | Everything except ADMINISTRATOR |
|
||||
| 3 | Moderator | `#3498DB` | `0x002FFFFF` | 60 | All message + voice + moderation + mention-everyone |
|
||||
| 4 | Member | NULL | `0x1E63` | 40 | Send, read, attach, react, voice, video, screen share |
|
||||
| id | name | color | permissions | position | Notes |
|
||||
| --- | --------- | --------- | ------------ | -------- | ----------------------------------------------------- |
|
||||
| 1 | Owner | `#E74C3C` | `0x7FFFFFFF` | 100 | All 31 permission bits set |
|
||||
| 2 | Admin | `#F39C12` | `0x3FFFFFFF` | 80 | Everything except ADMINISTRATOR |
|
||||
| 3 | Moderator | `#3498DB` | `0x002FFFFF` | 60 | All message + voice + moderation + mention-everyone |
|
||||
| 4 | Member | NULL | `0x1E63` | 40 | Send, read, attach, react, voice, video, screen share |
|
||||
|
||||
**Role semantics:**
|
||||
|
||||
@@ -282,7 +282,7 @@ shows a one-time-per-session warning before rendering the channel and marks it
|
||||
in the sidebar.
|
||||
|
||||
`voice_max_users` and `voice_max_video` (0 = unlimited) are the only channel
|
||||
columns that *are* enforced by the server, on voice join and on video publish
|
||||
columns that _are_ enforced by the server, on voice join and on video publish
|
||||
respectively (`CHANNEL_FULL` / `VIDEO_LIMIT`). They exist on every row but are
|
||||
meaningless on a non-voice channel.
|
||||
|
||||
@@ -498,7 +498,7 @@ CREATE TABLE read_states (
|
||||
`mention_count` is incremented on message insert for every mentioned user who
|
||||
can read the channel, except the author and except users who have blocked the
|
||||
author. `@everyone` counts every reader; `@here` counts only readers whose
|
||||
*broadcast* status is not `offline` — the column stores the status the user
|
||||
_broadcast_ status is not `offline` — the column stores the status the user
|
||||
chose, so a reader who picked `invisible` is collapsed to `offline` here and is
|
||||
skipped, exactly as they appear to everyone else. Edits never increment it — a badge is only
|
||||
raised by the original send, so an edit cannot double-count a mention. The
|
||||
@@ -728,30 +728,30 @@ CREATE TABLE plugin_kv (
|
||||
|
||||
## Indexes
|
||||
|
||||
| Index Name | Table | Columns | Purpose |
|
||||
|------------|-------|---------|---------|
|
||||
| `idx_sessions_user` | sessions | `(user_id)` | Fast deletion of all sessions for a user |
|
||||
| `idx_sessions_expires_at` | sessions | `(expires_at)` | Sargable 15-minute session-expiry sweep (031) |
|
||||
| `idx_messages_channel` | messages | `(channel_id, id DESC)` | Latest messages in channel query |
|
||||
| `idx_messages_user` | messages | `(user_id)` | Filter by author |
|
||||
| `idx_messages_pinned` | messages | `(channel_id, id DESC)` partial: `WHERE pinned = 1 AND deleted = 0` | Pinned-message listing without scanning channel history (019) |
|
||||
| `idx_audit_timestamp` | audit_log | `(created_at DESC)` | Pagination of audit log |
|
||||
| `idx_audit_log_actor` | audit_log | `(actor_id)` | Filter by actor |
|
||||
| `idx_login_ip` | login_attempts | `(ip_address, timestamp)` | Rate limiting queries |
|
||||
| `idx_voice_states_channel` | voice_states | `(channel_id)` | All users in a voice channel |
|
||||
| `idx_channel_overrides_role` | channel_overrides | `(role_id, channel_id, allow, deny)` | Covering per-role override fetch (019; replaced `idx_channel_overrides_channel_role`, which duplicated the UNIQUE auto-index) |
|
||||
| `idx_dm_participants_user` | dm_participants | `(user_id)` | DM channel lookup |
|
||||
| `idx_attachments_uploader` | attachments | `(uploader_id)` | Upload-ownership checks |
|
||||
| `idx_attachments_message` | attachments | `(message_id)` | Message → attachments fetch (019, recreated by 030's rebuild) |
|
||||
| `idx_user_blocks_blocked` | user_blocks | `(blocked_id, blocker_id)` | Reverse block lookup |
|
||||
| `idx_events_channel_seq` | events | `(channel_id, seq)` | Cold-tier replay per channel |
|
||||
| `idx_events_created_at` | events | `(created_at)` | Retention pruning |
|
||||
| `idx_api_tokens_user` | api_tokens | `(user_id)` | Per-user token listing/revocation (018) |
|
||||
| `idx_message_mentions_user` | message_mentions | `(mentioned_user_id)` | Per-user mention lookup |
|
||||
| `idx_channel_user_overrides_user` | channel_user_overrides | `(user_id)` | "every override this member carries" — the direction the permission cache populates from (the PK covers the per-channel direction) |
|
||||
| `idx_roles_name_nocase` | roles | `(name COLLATE NOCASE)` UNIQUE | Case-insensitive role-name uniqueness |
|
||||
| `idx_users_avatar` | users | `(avatar)` partial: `WHERE avatar IS NOT NULL` | File route's avatar-authorization probe (027) |
|
||||
| `idx_channels_dm_group` | channels | `(is_group)` partial: `WHERE type = 'dm'` | Group-DM filtering (028) |
|
||||
| Index Name | Table | Columns | Purpose |
|
||||
| --------------------------------- | ---------------------- | ------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `idx_sessions_user` | sessions | `(user_id)` | Fast deletion of all sessions for a user |
|
||||
| `idx_sessions_expires_at` | sessions | `(expires_at)` | Sargable 15-minute session-expiry sweep (031) |
|
||||
| `idx_messages_channel` | messages | `(channel_id, id DESC)` | Latest messages in channel query |
|
||||
| `idx_messages_user` | messages | `(user_id)` | Filter by author |
|
||||
| `idx_messages_pinned` | messages | `(channel_id, id DESC)` partial: `WHERE pinned = 1 AND deleted = 0` | Pinned-message listing without scanning channel history (019) |
|
||||
| `idx_audit_timestamp` | audit_log | `(created_at DESC)` | Pagination of audit log |
|
||||
| `idx_audit_log_actor` | audit_log | `(actor_id)` | Filter by actor |
|
||||
| `idx_login_ip` | login_attempts | `(ip_address, timestamp)` | Rate limiting queries |
|
||||
| `idx_voice_states_channel` | voice_states | `(channel_id)` | All users in a voice channel |
|
||||
| `idx_channel_overrides_role` | channel_overrides | `(role_id, channel_id, allow, deny)` | Covering per-role override fetch (019; replaced `idx_channel_overrides_channel_role`, which duplicated the UNIQUE auto-index) |
|
||||
| `idx_dm_participants_user` | dm_participants | `(user_id)` | DM channel lookup |
|
||||
| `idx_attachments_uploader` | attachments | `(uploader_id)` | Upload-ownership checks |
|
||||
| `idx_attachments_message` | attachments | `(message_id)` | Message → attachments fetch (019, recreated by 030's rebuild) |
|
||||
| `idx_user_blocks_blocked` | user_blocks | `(blocked_id, blocker_id)` | Reverse block lookup |
|
||||
| `idx_events_channel_seq` | events | `(channel_id, seq)` | Cold-tier replay per channel |
|
||||
| `idx_events_created_at` | events | `(created_at)` | Retention pruning |
|
||||
| `idx_api_tokens_user` | api_tokens | `(user_id)` | Per-user token listing/revocation (018) |
|
||||
| `idx_message_mentions_user` | message_mentions | `(mentioned_user_id)` | Per-user mention lookup |
|
||||
| `idx_channel_user_overrides_user` | channel_user_overrides | `(user_id)` | "every override this member carries" — the direction the permission cache populates from (the PK covers the per-channel direction) |
|
||||
| `idx_roles_name_nocase` | roles | `(name COLLATE NOCASE)` UNIQUE | Case-insensitive role-name uniqueness |
|
||||
| `idx_users_avatar` | users | `(avatar)` partial: `WHERE avatar IS NOT NULL` | File route's avatar-authorization probe (027) |
|
||||
| `idx_channels_dm_group` | channels | `(is_group)` partial: `WHERE type = 'dm'` | Group-DM filtering (028) |
|
||||
|
||||
Sessions are looked up by token and invites by code through their `UNIQUE`
|
||||
auto-indexes; the duplicating `idx_sessions_token` / `idx_invites_code` were
|
||||
@@ -767,45 +767,45 @@ Permissions are stored as an integer bitfield (31 bits used) in
|
||||
|
||||
### Bit Map
|
||||
|
||||
| Bit | Hex | Name | Description |
|
||||
|-----|-----|------|-------------|
|
||||
| 0 | `0x1` | `SEND_MESSAGES` | Post messages in text channels |
|
||||
| 1 | `0x2` | `READ_MESSAGES` | View messages in text channels |
|
||||
| 5 | `0x20` | `ATTACH_FILES` | Upload file attachments |
|
||||
| 6 | `0x40` | `ADD_REACTIONS` | Add emoji reactions |
|
||||
| 9 | `0x200` | `CONNECT_VOICE` | Join voice channels |
|
||||
| 10 | `0x400` | `SPEAK_VOICE` | Transmit audio in voice channels |
|
||||
| 11 | `0x800` | `USE_VIDEO` | Enable camera in voice channels |
|
||||
| 12 | `0x1000` | `SHARE_SCREEN` | Share screen in voice channels |
|
||||
| 16 | `0x10000` | `MANAGE_MESSAGES` | Delete others' messages, pin/unpin |
|
||||
| 17 | `0x20000` | `MANAGE_CHANNELS` | Create, edit, delete channels, edit channel permission overrides (`/admin/api/channels*`) |
|
||||
| 18 | `0x40000` | `KICK_MEMBERS` | Force-logout a lower-ranked user (`DELETE /admin/api/users/{id}/sessions`) |
|
||||
| 19 | `0x80000` | `BAN_MEMBERS` | Ban/unban a lower-ranked user (`PATCH /admin/api/users/{id}`) |
|
||||
| 20 | `0x100000` | `MUTE_MEMBERS` | Server-side mute/deafen in voice — admits to the admin perimeter; no route enforces it yet |
|
||||
| 21 | `0x200000` | `MENTION_EVERYONE` | Give `@everyone`/`@here` real mention semantics (highlight + mention badge). Without it the token stays plain text |
|
||||
| 24 | `0x1000000` | `MANAGE_ROLES` | Assign a role below the actor's own rank to a lower-ranked user (`PATCH /admin/api/users/{id}`), and create/edit/delete/reorder roles below the actor's own (`/admin/api/roles…`) |
|
||||
| 25 | `0x2000000` | `MANAGE_SERVER` | Read and modify server settings (`/admin/api/settings`) |
|
||||
| 26 | `0x4000000` | `MANAGE_INVITES` | Create and revoke invite codes |
|
||||
| 27 | `0x8000000` | `VIEW_AUDIT_LOG` | Read the audit log (`GET /admin/api/audit-log`) |
|
||||
| 30 | `0x40000000` | `ADMINISTRATOR` | Bypasses ALL permission checks |
|
||||
| Bit | Hex | Name | Description |
|
||||
| --- | ------------ | ------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| 0 | `0x1` | `SEND_MESSAGES` | Post messages in text channels |
|
||||
| 1 | `0x2` | `READ_MESSAGES` | View messages in text channels |
|
||||
| 5 | `0x20` | `ATTACH_FILES` | Upload file attachments |
|
||||
| 6 | `0x40` | `ADD_REACTIONS` | Add emoji reactions |
|
||||
| 9 | `0x200` | `CONNECT_VOICE` | Join voice channels |
|
||||
| 10 | `0x400` | `SPEAK_VOICE` | Transmit audio in voice channels |
|
||||
| 11 | `0x800` | `USE_VIDEO` | Enable camera in voice channels |
|
||||
| 12 | `0x1000` | `SHARE_SCREEN` | Share screen in voice channels |
|
||||
| 16 | `0x10000` | `MANAGE_MESSAGES` | Delete others' messages, pin/unpin |
|
||||
| 17 | `0x20000` | `MANAGE_CHANNELS` | Create, edit, delete channels, edit channel permission overrides (`/admin/api/channels*`) |
|
||||
| 18 | `0x40000` | `KICK_MEMBERS` | Force-logout a lower-ranked user (`DELETE /admin/api/users/{id}/sessions`) |
|
||||
| 19 | `0x80000` | `BAN_MEMBERS` | Ban/unban a lower-ranked user (`PATCH /admin/api/users/{id}`) |
|
||||
| 20 | `0x100000` | `MUTE_MEMBERS` | Server-side mute/deafen in voice — admits to the admin perimeter; no route enforces it yet |
|
||||
| 21 | `0x200000` | `MENTION_EVERYONE` | Give `@everyone`/`@here` real mention semantics (highlight + mention badge). Without it the token stays plain text |
|
||||
| 24 | `0x1000000` | `MANAGE_ROLES` | Assign a role below the actor's own rank to a lower-ranked user (`PATCH /admin/api/users/{id}`), and create/edit/delete/reorder roles below the actor's own (`/admin/api/roles…`) |
|
||||
| 25 | `0x2000000` | `MANAGE_SERVER` | Read and modify server settings (`/admin/api/settings`) |
|
||||
| 26 | `0x4000000` | `MANAGE_INVITES` | Create and revoke invite codes |
|
||||
| 27 | `0x8000000` | `VIEW_AUDIT_LOG` | Read the audit log (`GET /admin/api/audit-log`) |
|
||||
| 30 | `0x40000000` | `ADMINISTRATOR` | Bypasses ALL permission checks |
|
||||
|
||||
Bits 2-4, 7, 13-15, 22-23, 28-29, 31 are reserved.
|
||||
|
||||
### Permission groups
|
||||
|
||||
The bit map above is the authority on what each bit *does*; this grouping is
|
||||
how the bits are *presented* — it is the layout of the admin panel's role
|
||||
The bit map above is the authority on what each bit _does_; this grouping is
|
||||
how the bits are _presented_ — it is the layout of the admin panel's role
|
||||
permission grid (`PERM_GROUPS` in `Server/admin/static/index.html`). It carries
|
||||
no semantics, but the two must stay in step: every defined bit belongs to
|
||||
exactly one group, and a bit missing from the grouping is a bit no operator can
|
||||
grant through the panel.
|
||||
|
||||
| Group | Bits |
|
||||
|-------|------|
|
||||
| General | `MANAGE_CHANNELS`, `MANAGE_ROLES`, `MANAGE_INVITES`, `MANAGE_SERVER`, `VIEW_AUDIT_LOG`, `ADMINISTRATOR` |
|
||||
| Text | `READ_MESSAGES`, `SEND_MESSAGES`, `ATTACH_FILES`, `ADD_REACTIONS`, `MENTION_EVERYONE`, `MANAGE_MESSAGES` |
|
||||
| Voice | `CONNECT_VOICE`, `SPEAK_VOICE`, `USE_VIDEO`, `SHARE_SCREEN` |
|
||||
| Moderation | `KICK_MEMBERS`, `BAN_MEMBERS`, `MUTE_MEMBERS` |
|
||||
| Group | Bits |
|
||||
| ---------- | -------------------------------------------------------------------------------------------------------- |
|
||||
| General | `MANAGE_CHANNELS`, `MANAGE_ROLES`, `MANAGE_INVITES`, `MANAGE_SERVER`, `VIEW_AUDIT_LOG`, `ADMINISTRATOR` |
|
||||
| Text | `READ_MESSAGES`, `SEND_MESSAGES`, `ATTACH_FILES`, `ADD_REACTIONS`, `MENTION_EVERYONE`, `MANAGE_MESSAGES` |
|
||||
| Voice | `CONNECT_VOICE`, `SPEAK_VOICE`, `USE_VIDEO`, `SHARE_SCREEN` |
|
||||
| Moderation | `KICK_MEMBERS`, `BAN_MEMBERS`, `MUTE_MEMBERS` |
|
||||
|
||||
### Admin perimeter
|
||||
|
||||
@@ -815,7 +815,7 @@ MANAGE_SERVER | VIEW_AUDIT_LOG | KICK_MEMBERS | BAN_MEMBERS | MUTE_MEMBERS`.
|
||||
Holding one bit only gets a principal through the door — each route group
|
||||
re-checks the specific bit it needs, so the seeded Moderator role can manage
|
||||
channels and ban members without reading settings or the audit log. Owner-only
|
||||
routes (backups, updates, API tokens) still gate on role *position*, not on a
|
||||
routes (backups, updates, API tokens) still gate on role _position_, not on a
|
||||
bit. See `docs/api.md` for the per-route mapping.
|
||||
|
||||
### Permission Checking Logic
|
||||
@@ -835,12 +835,12 @@ override**. Within a layer deny is applied first (strips bits) then allow (adds
|
||||
bits), so allow wins when both target the same bit. Across layers the later,
|
||||
narrower layer wins:
|
||||
|
||||
| Situation | Outcome |
|
||||
|-----------|---------|
|
||||
| role override allows, user override denies | denied |
|
||||
| role override denies, user override allows | allowed |
|
||||
| user override allows and denies the same bit | allowed |
|
||||
| holder has `ADMINISTRATOR` | allowed regardless of either layer |
|
||||
| Situation | Outcome |
|
||||
| -------------------------------------------- | ---------------------------------- |
|
||||
| role override allows, user override denies | denied |
|
||||
| role override denies, user override allows | allowed |
|
||||
| user override allows and denies the same bit | allowed |
|
||||
| holder has `ADMINISTRATOR` | allowed regardless of either layer |
|
||||
|
||||
`permissions.EffectiveChannelPerms` is the single implementation of steps 5-6,
|
||||
and `permissions.EffectivePerms` the one-layer primitive it is built from. The
|
||||
@@ -858,9 +858,9 @@ DM channels bypass role permissions entirely and use participant-based authoriza
|
||||
|
||||
### Default Role Permission Values
|
||||
|
||||
| Role | Hex | Permissions |
|
||||
|------|-----|-------------|
|
||||
| Owner | `0x7FFFFFFF` | Everything including ADMINISTRATOR |
|
||||
| Admin | `0x3FFFFFFF` | Everything except ADMINISTRATOR |
|
||||
| Role | Hex | Permissions |
|
||||
| --------- | ------------ | ------------------------------------------------------------------------------------ |
|
||||
| Owner | `0x7FFFFFFF` | Everything including ADMINISTRATOR |
|
||||
| Admin | `0x3FFFFFFF` | Everything except ADMINISTRATOR |
|
||||
| Moderator | `0x002FFFFF` | All message + voice + moderation, plus `MENTION_EVERYONE` (granted by migration 022) |
|
||||
| Member | `0x1E63` | Send, read, attach, react, voice, video, screen share |
|
||||
| Member | `0x1E63` | Send, read, attach, react, voice, video, screen share |
|
||||
|
||||
+28
-2
@@ -13,6 +13,26 @@ The repository-root [SECURITY.md](../SECURITY.md) is the canonical reporting
|
||||
policy — what to include and the response timeline (initial response within
|
||||
7 days) live there, so the two files cannot disagree.
|
||||
|
||||
## What stays private, and for how long
|
||||
|
||||
This applies to weaknesses in the repository's own automation and settings —
|
||||
workflow authorization, credential scope, release gating — as much as to bugs in
|
||||
the server or client. Planning documents cite this section as the rule; it is
|
||||
written here so the citation points at something.
|
||||
|
||||
- Public artifacts — commits, issues, pull request descriptions, changelogs —
|
||||
carry an **opaque identifier, the affected property, safe acceptance criteria,
|
||||
and a status**. Nothing more.
|
||||
- Reproduction steps, source-to-sink traces, exploit conditions, and the state a
|
||||
fix replaced stay in the private advisory. A commit that fixes a weakness
|
||||
describes the control it adds, not the gap it closes.
|
||||
- Every private finding has exactly one public owner, so nothing is tracked only
|
||||
in private and nothing is silently dropped.
|
||||
- Release notes may describe repaired impact after coordinated remediation,
|
||||
without the detail needed to reproduce it.
|
||||
|
||||
This repository is public. A commit message is a disclosure channel.
|
||||
|
||||
## Two-Factor Authentication
|
||||
|
||||
OwnCord supports TOTP-based 2FA:
|
||||
@@ -39,7 +59,7 @@ Security-relevant actions are recorded in the `audit_log` table with actor, acti
|
||||
- **Profile:** `profile_update`, `identity_key_update`
|
||||
- **Ops:** `backup_create`, `backup_delete`, `backup_restore`, `ws_connect`
|
||||
|
||||
Note: `backup_restore` is written synchronously to the live database *before*
|
||||
Note: `backup_restore` is written synchronously to the live database _before_
|
||||
the pre-restore safety copy is taken, so the row survives inside the
|
||||
`pre_restore_*.db` backup. The restored database itself will not contain it —
|
||||
the restore replaces the database file wholesale.
|
||||
@@ -49,19 +69,22 @@ the restore replaces the database file wholesale.
|
||||
The Tauri desktop client implements the following security measures:
|
||||
|
||||
### Credential Storage
|
||||
|
||||
- Credentials are stored in the OS keyring (Windows Credential Manager / macOS Keychain / Secret Service) via the `keyring` crate, with every write read back and verified; if no keyring is available they fall back to an encrypted file (Windows DPAPI with `CRYPTPROTECT_UI_FORBIDDEN`, ChaCha20-Poly1305 elsewhere) — see [credential-storage.md](credential-storage.md)
|
||||
- Plaintext passwords are **never** returned to the frontend over IPC — only tokens are accessible from JavaScript
|
||||
- Auto-login uses stored tokens for reconnection, not passwords
|
||||
|
||||
### Tauri Capabilities (Least Privilege)
|
||||
|
||||
- Filesystem write access is scoped to `$APPDATA/**` and `$APPLOG/**` only
|
||||
- DevTools command is gated behind the `devtools` feature flag (excluded from release builds)
|
||||
- HTTP fetch is restricted to `https://` origins plus `http://127.0.0.1:*` (the Rust TOFU proxy's loopback tunnel), and **denies** `https://localhost[:*]` and `https://127.0.0.1[:*]` — no legitimate flow reaches loopback over https, so the deny list keeps the renderer from probing other local services
|
||||
- `http:allow-fetch` is the **only** URL-scoped HTTP identifier. `tauri-plugin-http` validates the URL exactly once, in the `fetch` command; `fetch_send` and `fetch_read_body` operate on an already-validated `ResourceId` and never consult a scope, so `allow`/`deny` blocks on those identifiers are inert and were removed rather than left in place advertising a control that does not exist
|
||||
- The `https://*` wildcard cannot be removed today: link previews (`embeds.ts`) fetch arbitrary user-posted URLs by design, and Tauri scopes per *command*, not per JS caller. Bounded in TypeScript by `isPrivateHost`/`isBlockedForPreview`, a 5 s timeout and a 50 KB body cap; the response is regex-scraped for `og:` tags and never executed
|
||||
- The `https://*` wildcard cannot be removed today: link previews (`embeds.ts`) fetch arbitrary user-posted URLs by design, and Tauri scopes per _command_, not per JS caller. Bounded in TypeScript by `isPrivateHost`/`isBlockedForPreview`, a 5 s timeout and a 50 KB body cap; the response is regex-scraped for `og:` tags and never executed
|
||||
- Regression-guarded by `tests/unit/capabilities-scope.test.ts`; rationale and the follow-up that would remove the wildcard are in [docs/plans/tauri-capability-narrowing.md](plans/tauri-capability-narrowing.md)
|
||||
|
||||
### TLS and Certificate Pinning (TOFU)
|
||||
|
||||
- Self-signed certificates are supported via Trust-On-First-Use (TOFU) pinning
|
||||
- The WebSocket proxy (`ws_proxy`) pins the server certificate fingerprint on first connection
|
||||
- The LiveKit proxy (`livekit_proxy`) reuses the pinned fingerprint from the WS proxy
|
||||
@@ -69,6 +92,7 @@ The Tauri desktop client implements the following security measures:
|
||||
- Update downloads validate `server_url` uses `https://` and rejects URLs with userinfo
|
||||
|
||||
### Input Validation
|
||||
|
||||
- IPC commands validate host format, string lengths, and character allowlists
|
||||
- PTT virtual key codes are validated to the Win32 range (1–254)
|
||||
- LiveKit proxy `remote_host` is validated against CRLF injection
|
||||
@@ -78,6 +102,7 @@ The Tauri desktop client implements the following security measures:
|
||||
- Notification titles are sanitized (control chars stripped, length capped)
|
||||
|
||||
### XSS Prevention
|
||||
|
||||
- All user-generated content is rendered via `textContent`/`setText` — never `innerHTML`
|
||||
- The single `innerHTML` usage (SVG icons) operates on compile-time constants with a runtime guard
|
||||
- URLs are validated via `isSafeUrl` (rejects `javascript:`, `data:`, `vbscript:`)
|
||||
@@ -87,6 +112,7 @@ The Tauri desktop client implements the following security measures:
|
||||
- Linkified URLs strip trailing punctuation to prevent misleading destinations
|
||||
|
||||
### Search and Rate Limiting
|
||||
|
||||
- Client-side search requests are rate-limited (500ms minimum interval + 300ms debounce)
|
||||
|
||||
## Known Limitations
|
||||
|
||||
+125
-125
@@ -28,72 +28,72 @@ the server automatically when a startup-only value changed. Note that
|
||||
|
||||
### Server (`server`)
|
||||
|
||||
| Key | Type | Default | Description |
|
||||
|-----|------|---------|-------------|
|
||||
| `server.port` | int | `8443` | HTTP(S) listen port |
|
||||
| `server.name` | string | `"OwnCord Server"` | Server display name (shown in `/api/v1/info` and admin panel) |
|
||||
| `server.data_dir` | string | `"data"` | Directory for database, certs, uploads, backups |
|
||||
| `server.max_ws_connections` | int | `0` | Cap on concurrently connected WebSocket clients; further upgrades get 503 until connections free up. `0` = unlimited. Every connection costs goroutines and buffered send queues — set a ceiling that matches the host's memory before opening the server to a large community. |
|
||||
| `server.metrics_allowed_cidrs` | []string | `[]` | Separate allowlist for `/api/v1/metrics` and the Prometheus `/metrics` exporter, so a central scraper can be admitted without widening `/admin` to its network. Empty = falls back to `admin_allowed_cidrs`. |
|
||||
| `server.livekit_webhook_allowed_cidrs` | []string | `[]` | Separate allowlist for the LiveKit webhook/health endpoints (which also authenticate cryptographically) — an externally-hosted LiveKit's IP goes here, not in the admin allowlist. Empty = falls back to `admin_allowed_cidrs`. |
|
||||
| `server.allowed_origins` | string[] | `[]` | WebSocket CORS allowed origins for **web/browser** clients; empty list DENIES all cross-origin (set to `["*"]` to allow any origin). The OwnCord desktop client needs no entry here — its webview origins (`http(s)://tauri.localhost`, `tauri://localhost`) are always accepted. |
|
||||
| `server.trusted_proxies` | string[] | `[]` | CIDRs of trusted reverse proxies (for X-Forwarded-For) |
|
||||
| `server.admin_allowed_cidrs` | string[] | private networks | CIDRs allowed to access `/admin` routes. Default: `127.0.0.0/8`, `::1/128`, `10.0.0.0/8`, `172.16.0.0/12`, `192.168.0.0/16`, `fc00::/7` |
|
||||
| `server.waf_enabled` | bool | `false` | Enable the Coraza WAF middleware (inline rules + OWASP Core Rule Set) |
|
||||
| `server.waf_paranoia_level` | int | `2` | OWASP CRS paranoia level 1–4; values outside that range fall back to 2 |
|
||||
| `server.waf_crs_mode` | string | `"detect"` | CRS layer mode: `off` (inline rules only), `detect` (matches logged, never blocks), `block` (anomaly-scoring blocking). Unknown values fall back to `detect`. |
|
||||
| `server.restart_mode` | string | `"auto"` | How self-restarts (update apply, backup restore, setup wizard) hand off after the server drains: `supervised` exits cleanly and relies on systemd/NSSM/Docker to relaunch; `spawn` starts the replacement binary directly; `auto` picks `supervised` when a supervisor or container is detected, else `spawn`. NSSM deployments must set `supervised` explicitly (see [Deployment](deployment.md)). |
|
||||
| Key | Type | Default | Description |
|
||||
| -------------------------------------- | -------- | ------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `server.port` | int | `8443` | HTTP(S) listen port |
|
||||
| `server.name` | string | `"OwnCord Server"` | Server display name (shown in `/api/v1/info` and admin panel) |
|
||||
| `server.data_dir` | string | `"data"` | Directory for database, certs, uploads, backups |
|
||||
| `server.max_ws_connections` | int | `0` | Cap on concurrently connected WebSocket clients; further upgrades get 503 until connections free up. `0` = unlimited. Every connection costs goroutines and buffered send queues — set a ceiling that matches the host's memory before opening the server to a large community. |
|
||||
| `server.metrics_allowed_cidrs` | []string | `[]` | Separate allowlist for `/api/v1/metrics` and the Prometheus `/metrics` exporter, so a central scraper can be admitted without widening `/admin` to its network. Empty = falls back to `admin_allowed_cidrs`. |
|
||||
| `server.livekit_webhook_allowed_cidrs` | []string | `[]` | Separate allowlist for the LiveKit webhook/health endpoints (which also authenticate cryptographically) — an externally-hosted LiveKit's IP goes here, not in the admin allowlist. Empty = falls back to `admin_allowed_cidrs`. |
|
||||
| `server.allowed_origins` | string[] | `[]` | WebSocket CORS allowed origins for **web/browser** clients; empty list DENIES all cross-origin (set to `["*"]` to allow any origin). The OwnCord desktop client needs no entry here — its webview origins (`http(s)://tauri.localhost`, `tauri://localhost`) are always accepted. |
|
||||
| `server.trusted_proxies` | string[] | `[]` | CIDRs of trusted reverse proxies (for X-Forwarded-For) |
|
||||
| `server.admin_allowed_cidrs` | string[] | private networks | CIDRs allowed to access `/admin` routes. Default: `127.0.0.0/8`, `::1/128`, `10.0.0.0/8`, `172.16.0.0/12`, `192.168.0.0/16`, `fc00::/7` |
|
||||
| `server.waf_enabled` | bool | `false` | Enable the Coraza WAF middleware (inline rules + OWASP Core Rule Set) |
|
||||
| `server.waf_paranoia_level` | int | `2` | OWASP CRS paranoia level 1–4; values outside that range fall back to 2 |
|
||||
| `server.waf_crs_mode` | string | `"detect"` | CRS layer mode: `off` (inline rules only), `detect` (matches logged, never blocks), `block` (anomaly-scoring blocking). Unknown values fall back to `detect`. |
|
||||
| `server.restart_mode` | string | `"auto"` | How self-restarts (update apply, backup restore, setup wizard) hand off after the server drains: `supervised` exits cleanly and relies on systemd/NSSM/Docker to relaunch; `spawn` starts the replacement binary directly; `auto` picks `supervised` when a supervisor or container is detected, else `spawn`. NSSM deployments must set `supervised` explicitly (see [Deployment](deployment.md)). |
|
||||
|
||||
### TLS (`tls`)
|
||||
|
||||
| Key | Type | Default | Description |
|
||||
|-----|------|---------|-------------|
|
||||
| `tls.mode` | string | `"self_signed"` | TLS mode: `self_signed`, `acme`, `manual`, `off` |
|
||||
| `tls.cert_file` | string | `"data/cert.pem"` | Path to TLS certificate (used by `manual` and `self_signed`) |
|
||||
| `tls.key_file` | string | `"data/key.pem"` | Path to TLS private key |
|
||||
| `tls.domain` | string | `""` | Domain for ACME/Let's Encrypt (required when `mode: acme`) |
|
||||
| `tls.acme_cache_dir` | string | `"data/acme_certs"` | Directory for cached Let's Encrypt certificates |
|
||||
| Key | Type | Default | Description |
|
||||
| -------------------- | ------ | ------------------- | ------------------------------------------------------------ |
|
||||
| `tls.mode` | string | `"self_signed"` | TLS mode: `self_signed`, `acme`, `manual`, `off` |
|
||||
| `tls.cert_file` | string | `"data/cert.pem"` | Path to TLS certificate (used by `manual` and `self_signed`) |
|
||||
| `tls.key_file` | string | `"data/key.pem"` | Path to TLS private key |
|
||||
| `tls.domain` | string | `""` | Domain for ACME/Let's Encrypt (required when `mode: acme`) |
|
||||
| `tls.acme_cache_dir` | string | `"data/acme_certs"` | Directory for cached Let's Encrypt certificates |
|
||||
|
||||
### Database (`database`)
|
||||
|
||||
| Key | Type | Default | Description |
|
||||
|-----|------|---------|-------------|
|
||||
| `database.type` | string | `"sqlite"` | Database backend. `sqlite` (or empty) is the only supported value — any other value makes the server refuse to start. |
|
||||
| `database.path` | string | `"data/chatserver.db"` | Path to SQLite database file |
|
||||
| `database.max_readers` | int | `0` | Bound on the read-only connection pool. `0` = automatic (`max(4, CPU count)`); clamped to 1–64. Readers beyond the CPU count mostly buy queueing, not throughput. |
|
||||
| Key | Type | Default | Description |
|
||||
| ---------------------- | ------ | ---------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `database.type` | string | `"sqlite"` | Database backend. `sqlite` (or empty) is the only supported value — any other value makes the server refuse to start. |
|
||||
| `database.path` | string | `"data/chatserver.db"` | Path to SQLite database file |
|
||||
| `database.max_readers` | int | `0` | Bound on the read-only connection pool. `0` = automatic (`max(4, CPU count)`); clamped to 1–64. Readers beyond the CPU count mostly buy queueing, not throughput. |
|
||||
|
||||
### Backups (`backup`)
|
||||
|
||||
| Key | Type | Default | Description |
|
||||
|-----|------|---------|-------------|
|
||||
| Key | Type | Default | Description |
|
||||
| ------------ | ------ | ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `backup.dir` | string | `"data/backups"` | Directory where database backups are written and pruned. Point it at another disk or an off-host mount so backups don't share a single point of failure with the live database. The admin panel's Backup Schedule and Retention settings operate on this directory. |
|
||||
|
||||
### Security (`security`)
|
||||
|
||||
| Key | Type | Default | Description |
|
||||
|-----|------|---------|-------------|
|
||||
| `security.auth_rate_limit_multiplier` | float | `1.0` | Scales the per-IP auth rate limits and failure thresholds (registration, login, TOTP, sensitive endpoints). The defaults assume roughly one person per IP; raise this for communities behind a shared NAT (office, school). Clamped to 0.1–100. |
|
||||
| Key | Type | Default | Description |
|
||||
| ------------------------------------- | ----- | ------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `security.auth_rate_limit_multiplier` | float | `1.0` | Scales the per-IP auth rate limits and failure thresholds (registration, login, TOTP, sensitive endpoints). The defaults assume roughly one person per IP; raise this for communities behind a shared NAT (office, school). Clamped to 0.1–100. |
|
||||
|
||||
### Uploads (`upload`)
|
||||
|
||||
| Key | Type | Default | Description |
|
||||
|-----|------|---------|-------------|
|
||||
| `upload.max_size_mb` | int | `100` | Maximum file upload size in megabytes |
|
||||
| Key | Type | Default | Description |
|
||||
| -------------------- | ------ | ---------------- | ----------------------------------------- |
|
||||
| `upload.max_size_mb` | int | `100` | Maximum file upload size in megabytes |
|
||||
| `upload.storage_dir` | string | `"data/uploads"` | Directory where uploaded files are stored |
|
||||
|
||||
### Voice / LiveKit (`voice`)
|
||||
|
||||
| Key | Type | Default | Description |
|
||||
|-----|------|---------|-------------|
|
||||
| `voice.livekit_api_key` | string | *(random per run)* | LiveKit API key. Set a stable value for persistent voice tokens. |
|
||||
| `voice.livekit_api_secret` | string | *(random per run)* | LiveKit API secret (min 32 chars). Set a stable value for persistent tokens. |
|
||||
| `voice.livekit_url` | string | `"ws://localhost:7880"` | LiveKit server WebSocket URL |
|
||||
| `voice.livekit_binary` | string | `""` | Path to an existing `livekit-server` binary; set to skip auto-download and run your own build |
|
||||
| `voice.auto_download_livekit` | bool | `false` (compiled) / `true` in the generated config | When no `livekit_binary` is set, download a pinned `livekit-server` release from the official LiveKit GitHub releases (verified against the release `checksums.txt`) into `data/livekit/` and run it automatically |
|
||||
| `voice.livekit_version` | string | `""` | Override the pinned `livekit-server` version used by auto-download (e.g. `"1.13.5"`); empty = built-in pin |
|
||||
| `voice.node_ip` | string | `""` | Public IP for WebRTC ICE candidates; empty = auto-detect. Required for remote users behind NAT. |
|
||||
| `voice.advertise_internal_ip` | bool | `false` | Also advertise internal (LAN) IPs as ICE candidates. Enable when the server is reachable via both a LAN IP and a public IP so local-network clients can connect to voice. |
|
||||
| `voice.quality` | string | `"medium"` | Voice quality preset: `low`, `medium`, `high` |
|
||||
| Key | Type | Default | Description |
|
||||
| ----------------------------- | ------ | --------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| `voice.livekit_api_key` | string | _(random per run)_ | LiveKit API key. Set a stable value for persistent voice tokens. |
|
||||
| `voice.livekit_api_secret` | string | _(random per run)_ | LiveKit API secret (min 32 chars). Set a stable value for persistent tokens. |
|
||||
| `voice.livekit_url` | string | `"ws://localhost:7880"` | LiveKit server WebSocket URL |
|
||||
| `voice.livekit_binary` | string | `""` | Path to an existing `livekit-server` binary; set to skip auto-download and run your own build |
|
||||
| `voice.auto_download_livekit` | bool | `false` (compiled) / `true` in the generated config | When no `livekit_binary` is set, download a pinned `livekit-server` release from the official LiveKit GitHub releases (verified against the release `checksums.txt`) into `data/livekit/` and run it automatically |
|
||||
| `voice.livekit_version` | string | `""` | Override the pinned `livekit-server` version used by auto-download (e.g. `"1.13.5"`); empty = built-in pin |
|
||||
| `voice.node_ip` | string | `""` | Public IP for WebRTC ICE candidates; empty = auto-detect. Required for remote users behind NAT. |
|
||||
| `voice.advertise_internal_ip` | bool | `false` | Also advertise internal (LAN) IPs as ICE candidates. Enable when the server is reachable via both a LAN IP and a public IP so local-network clients can connect to voice. |
|
||||
| `voice.quality` | string | `"medium"` | Voice quality preset: `low`, `medium`, `high` |
|
||||
|
||||
> **Warning:** If `livekit_api_key` or `livekit_api_secret` are left empty, random credentials are generated on each startup. This means voice tokens break on restart. Always set stable credentials in production. See [LiveKit Setup](livekit-setup.md) for details.
|
||||
|
||||
@@ -105,37 +105,37 @@ For LiveKit options OwnCord does not model, you can take ownership of the auto-s
|
||||
|
||||
### GitHub / Updates (`github`)
|
||||
|
||||
| Key | Type | Default | Description |
|
||||
|-----|------|---------|-------------|
|
||||
| `github.token` | string | `""` | Optional GitHub API token for higher rate limits on update checks (5000 req/hr vs 60) |
|
||||
| `github.owner` | string | `"J3vb"` | Owner of the GitHub repository server and client updates are fetched from |
|
||||
| `github.repo` | string | `"OwnCord"` | Repository whose releases carry update assets. Must stay publicly readable — both the server self-update and the client auto-update chain fetch release assets from it |
|
||||
| Key | Type | Default | Description |
|
||||
| -------------- | ------ | ----------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `github.token` | string | `""` | Optional GitHub API token for higher rate limits on update checks (5000 req/hr vs 60) |
|
||||
| `github.owner` | string | `"J3vb"` | Owner of the GitHub repository server and client updates are fetched from |
|
||||
| `github.repo` | string | `"OwnCord"` | Repository whose releases carry update assets. Must stay publicly readable — both the server self-update and the client auto-update chain fetch release assets from it |
|
||||
|
||||
### Event Persistence (`event_persistence`)
|
||||
|
||||
Controls the tiered event log used for WebSocket reconnection replay. When enabled, missed events are stored in the database so clients that reconnect after the in-memory ring buffer window (`replay_ring_size` events) can still replay missed events from the DB tier.
|
||||
|
||||
| Key | Type | Default | Description |
|
||||
|-----|------|---------|-------------|
|
||||
| `event_persistence.enabled` | bool | `true` | Enable cold-storage event persistence. When `false`, only the in-memory ring buffer is used (lower durability). |
|
||||
| `event_persistence.retention_hours` | int | `24` | How long persisted events are kept before the pruner deletes them |
|
||||
| `event_persistence.batch_size` | int | `50` | Maximum events per database flush |
|
||||
| `event_persistence.batch_flush_ms` | int | `100` | Maximum delay between flushes (milliseconds) |
|
||||
| `event_persistence.pruner_interval_minutes` | int | `60` | How often the pruner goroutine wakes up to delete expired events |
|
||||
| `event_persistence.replay_ring_size` | int | `1000` | Capacity of the in-memory reconnect replay ring. Larger rings bridge longer disconnects without touching the database, at ~1 message payload of memory per slot. |
|
||||
| `event_persistence.replay_cold_limit` | int | `5000` | Maximum persisted events a single reconnect may replay; a larger gap falls back to a full resync. Watch the `reconnect_tier_full` metric before raising it. |
|
||||
| Key | Type | Default | Description |
|
||||
| ------------------------------------------- | ---- | ------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `event_persistence.enabled` | bool | `true` | Enable cold-storage event persistence. When `false`, only the in-memory ring buffer is used (lower durability). |
|
||||
| `event_persistence.retention_hours` | int | `24` | How long persisted events are kept before the pruner deletes them |
|
||||
| `event_persistence.batch_size` | int | `50` | Maximum events per database flush |
|
||||
| `event_persistence.batch_flush_ms` | int | `100` | Maximum delay between flushes (milliseconds) |
|
||||
| `event_persistence.pruner_interval_minutes` | int | `60` | How often the pruner goroutine wakes up to delete expired events |
|
||||
| `event_persistence.replay_ring_size` | int | `1000` | Capacity of the in-memory reconnect replay ring. Larger rings bridge longer disconnects without touching the database, at ~1 message payload of memory per slot. |
|
||||
| `event_persistence.replay_cold_limit` | int | `5000` | Maximum persisted events a single reconnect may replay; a larger gap falls back to a full resync. Watch the `reconnect_tier_full` metric before raising it. |
|
||||
|
||||
### Telemetry / OpenTelemetry (`telemetry`)
|
||||
|
||||
Controls the OpenTelemetry SDK. Requires building with `-tags otel` (see [Contributing](contributing.md)). When disabled, the server uses no-op tracer/meter providers; the legacy JSON `/api/v1/metrics` endpoint exists regardless of this setting (it is admin-IP-restricted, like all metrics surfaces).
|
||||
|
||||
| Key | Type | Default | Description |
|
||||
|-----|------|---------|-------------|
|
||||
| `telemetry.enabled` | bool | `false` | Enable the OTel SDK |
|
||||
| `telemetry.exporter` | string | `"none"` | Exporter backend: `none`, `prometheus`, `otlp` |
|
||||
| `telemetry.otlp_endpoint` | string | `""` | gRPC endpoint for the OTLP exporter (e.g. `localhost:4317`). Only used when `exporter: otlp`. |
|
||||
| `telemetry.otlp_insecure` | bool | `false` | Disable TLS for the OTLP gRPC connection. Only set `true` in development / private-network deployments. |
|
||||
| `telemetry.service_name` | string | `"owncord-server"` | OTel `service.name` resource attribute |
|
||||
| Key | Type | Default | Description |
|
||||
| ------------------------- | ------ | ------------------ | ------------------------------------------------------------------------------------------------------- |
|
||||
| `telemetry.enabled` | bool | `false` | Enable the OTel SDK |
|
||||
| `telemetry.exporter` | string | `"none"` | Exporter backend: `none`, `prometheus`, `otlp` |
|
||||
| `telemetry.otlp_endpoint` | string | `""` | gRPC endpoint for the OTLP exporter (e.g. `localhost:4317`). Only used when `exporter: otlp`. |
|
||||
| `telemetry.otlp_insecure` | bool | `false` | Disable TLS for the OTLP gRPC connection. Only set `true` in development / private-network deployments. |
|
||||
| `telemetry.service_name` | string | `"owncord-server"` | OTel `service.name` resource attribute |
|
||||
|
||||
> **Local development:** Run `make otel-up` (from `Server/`) to start Jaeger + Prometheus via Docker.
|
||||
> Jaeger UI: `http://localhost:16686` — Prometheus UI: `http://localhost:9090`
|
||||
@@ -144,13 +144,13 @@ Controls the OpenTelemetry SDK. Requires building with `-tags otel` (see [Contri
|
||||
|
||||
Controls the Wazero WASM plugin runtime. Requires building with `-tags wazero`. When disabled, no plugins are loaded; plugin admin lifecycle endpoints return `503 Service Unavailable` and the plugin list endpoint returns an empty list.
|
||||
|
||||
| Key | Type | Default | Description |
|
||||
|-----|------|---------|-------------|
|
||||
| `plugins.enabled` | bool | `false` | Enable plugin loading at startup |
|
||||
| `plugins.directory` | string | `"data/plugins"` | Directory scanned for plugin packages on startup |
|
||||
| `plugins.max_memory_mb` | int | `64` | Maximum WASM linear memory per plugin (megabytes) |
|
||||
| `plugins.cpu_budget_ms` | int | `100` | Maximum CPU time per plugin invocation (milliseconds) |
|
||||
| `plugins.http_allowlist` | string[] | `[]` | Host suffixes plugins may reach via the `host_http` capability (e.g. `["api.steampowered.com"]`). Empty = no outbound HTTP. |
|
||||
| Key | Type | Default | Description |
|
||||
| ------------------------ | -------- | ---------------- | --------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `plugins.enabled` | bool | `false` | Enable plugin loading at startup |
|
||||
| `plugins.directory` | string | `"data/plugins"` | Directory scanned for plugin packages on startup |
|
||||
| `plugins.max_memory_mb` | int | `64` | Maximum WASM linear memory per plugin (megabytes) |
|
||||
| `plugins.cpu_budget_ms` | int | `100` | Maximum CPU time per plugin invocation (milliseconds) |
|
||||
| `plugins.http_allowlist` | string[] | `[]` | Host suffixes plugins may reach via the `host_http` capability (e.g. `["api.steampowered.com"]`). Empty = no outbound HTTP. |
|
||||
|
||||
### GIF Picker (`gif`)
|
||||
|
||||
@@ -161,9 +161,9 @@ never ships in the desktop bundle — the client only ever calls
|
||||
**Disabled by default.** With no `gif.api_key` set, `/api/v1/gif/*` returns
|
||||
`503 GIF_DISABLED` and clients hide their GIF button. Nothing else changes.
|
||||
|
||||
| Key | Type | Default | Description |
|
||||
|-----|------|---------|-------------|
|
||||
| `gif.api_key` | string | `""` | Klipy API key. Get one at [partner.klipy.com](https://partner.klipy.com). Empty = feature off. |
|
||||
| Key | Type | Default | Description |
|
||||
| ------------- | ------ | ------- | ---------------------------------------------------------------------------------------------- |
|
||||
| `gif.api_key` | string | `""` | Klipy API key. Get one at [partner.klipy.com](https://partner.klipy.com). Empty = feature off. |
|
||||
|
||||
> **Treat this as a credential.** Prefer `OWNCORD_GIF_API_KEY` (or a secrets
|
||||
> manager) over writing it into `config.yaml`, and rotate it if it has ever
|
||||
@@ -174,8 +174,8 @@ never ships in the desktop bundle — the client only ever calls
|
||||
Controls server log verbosity. The level gates both stdout and the in-memory
|
||||
ring buffer that backs the admin panel's live log view.
|
||||
|
||||
| Key | Type | Default | Description |
|
||||
|-----|------|---------|-------------|
|
||||
| Key | Type | Default | Description |
|
||||
| --------------- | ------ | -------- | ------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| `logging.level` | string | `"info"` | Minimum level logged: `debug`, `info`, `warn`, `error`. Empty = `info`; an unrecognised value falls back to `info` with a startup warning. |
|
||||
|
||||
## Environment Variable Overrides
|
||||
@@ -186,38 +186,38 @@ Every config key can be overridden via environment variables using the prefix `O
|
||||
the section/key dot; the scheme covers **every** key in the file, including ones
|
||||
absent from the table below (it is a representative subset, not the full list).
|
||||
|
||||
| Environment Variable | Config Path |
|
||||
|---------------------|-------------|
|
||||
| `OWNCORD_SERVER_PORT` | `server.port` |
|
||||
| `OWNCORD_SERVER_NAME` | `server.name` |
|
||||
| `OWNCORD_SERVER_DATA_DIR` | `server.data_dir` |
|
||||
| `OWNCORD_SERVER_RESTART_MODE` | `server.restart_mode` |
|
||||
| `OWNCORD_DATABASE_PATH` | `database.path` |
|
||||
| `OWNCORD_TLS_MODE` | `tls.mode` |
|
||||
| `OWNCORD_TLS_CERT_FILE` | `tls.cert_file` |
|
||||
| `OWNCORD_TLS_DOMAIN` | `tls.domain` |
|
||||
| `OWNCORD_UPLOAD_MAX_SIZE_MB` | `upload.max_size_mb` |
|
||||
| `OWNCORD_UPLOAD_STORAGE_DIR` | `upload.storage_dir` |
|
||||
| `OWNCORD_VOICE_LIVEKIT_API_KEY` | `voice.livekit_api_key` |
|
||||
| `OWNCORD_VOICE_LIVEKIT_API_SECRET` | `voice.livekit_api_secret` |
|
||||
| `OWNCORD_VOICE_LIVEKIT_URL` | `voice.livekit_url` |
|
||||
| `OWNCORD_VOICE_NODE_IP` | `voice.node_ip` |
|
||||
| `OWNCORD_VOICE_ADVERTISE_INTERNAL_IP` | `voice.advertise_internal_ip` |
|
||||
| `OWNCORD_VOICE_QUALITY` | `voice.quality` |
|
||||
| `OWNCORD_GITHUB_TOKEN` | `github.token` |
|
||||
| `OWNCORD_EVENT_PERSISTENCE_ENABLED` | `event_persistence.enabled` |
|
||||
| Environment Variable | Config Path |
|
||||
| ------------------------------------------- | ----------------------------------- |
|
||||
| `OWNCORD_SERVER_PORT` | `server.port` |
|
||||
| `OWNCORD_SERVER_NAME` | `server.name` |
|
||||
| `OWNCORD_SERVER_DATA_DIR` | `server.data_dir` |
|
||||
| `OWNCORD_SERVER_RESTART_MODE` | `server.restart_mode` |
|
||||
| `OWNCORD_DATABASE_PATH` | `database.path` |
|
||||
| `OWNCORD_TLS_MODE` | `tls.mode` |
|
||||
| `OWNCORD_TLS_CERT_FILE` | `tls.cert_file` |
|
||||
| `OWNCORD_TLS_DOMAIN` | `tls.domain` |
|
||||
| `OWNCORD_UPLOAD_MAX_SIZE_MB` | `upload.max_size_mb` |
|
||||
| `OWNCORD_UPLOAD_STORAGE_DIR` | `upload.storage_dir` |
|
||||
| `OWNCORD_VOICE_LIVEKIT_API_KEY` | `voice.livekit_api_key` |
|
||||
| `OWNCORD_VOICE_LIVEKIT_API_SECRET` | `voice.livekit_api_secret` |
|
||||
| `OWNCORD_VOICE_LIVEKIT_URL` | `voice.livekit_url` |
|
||||
| `OWNCORD_VOICE_NODE_IP` | `voice.node_ip` |
|
||||
| `OWNCORD_VOICE_ADVERTISE_INTERNAL_IP` | `voice.advertise_internal_ip` |
|
||||
| `OWNCORD_VOICE_QUALITY` | `voice.quality` |
|
||||
| `OWNCORD_GITHUB_TOKEN` | `github.token` |
|
||||
| `OWNCORD_EVENT_PERSISTENCE_ENABLED` | `event_persistence.enabled` |
|
||||
| `OWNCORD_EVENT_PERSISTENCE_RETENTION_HOURS` | `event_persistence.retention_hours` |
|
||||
| `OWNCORD_TELEMETRY_ENABLED` | `telemetry.enabled` |
|
||||
| `OWNCORD_TELEMETRY_EXPORTER` | `telemetry.exporter` |
|
||||
| `OWNCORD_TELEMETRY_OTLP_ENDPOINT` | `telemetry.otlp_endpoint` |
|
||||
| `OWNCORD_TELEMETRY_SERVICE_NAME` | `telemetry.service_name` |
|
||||
| `OWNCORD_PLUGINS_ENABLED` | `plugins.enabled` |
|
||||
| `OWNCORD_PLUGINS_DIRECTORY` | `plugins.directory` |
|
||||
| `OWNCORD_GIF_API_KEY` | `gif.api_key` |
|
||||
| `OWNCORD_SERVER_WAF_ENABLED` | `server.waf_enabled` |
|
||||
| `OWNCORD_DATABASE_TYPE` | `database.type` |
|
||||
| `OWNCORD_TELEMETRY_OTLP_INSECURE` | `telemetry.otlp_insecure` |
|
||||
| `OWNCORD_LOGGING_LEVEL` | `logging.level` |
|
||||
| `OWNCORD_TELEMETRY_ENABLED` | `telemetry.enabled` |
|
||||
| `OWNCORD_TELEMETRY_EXPORTER` | `telemetry.exporter` |
|
||||
| `OWNCORD_TELEMETRY_OTLP_ENDPOINT` | `telemetry.otlp_endpoint` |
|
||||
| `OWNCORD_TELEMETRY_SERVICE_NAME` | `telemetry.service_name` |
|
||||
| `OWNCORD_PLUGINS_ENABLED` | `plugins.enabled` |
|
||||
| `OWNCORD_PLUGINS_DIRECTORY` | `plugins.directory` |
|
||||
| `OWNCORD_GIF_API_KEY` | `gif.api_key` |
|
||||
| `OWNCORD_SERVER_WAF_ENABLED` | `server.waf_enabled` |
|
||||
| `OWNCORD_DATABASE_TYPE` | `database.type` |
|
||||
| `OWNCORD_TELEMETRY_OTLP_INSECURE` | `telemetry.otlp_insecure` |
|
||||
| `OWNCORD_LOGGING_LEVEL` | `logging.level` |
|
||||
|
||||
## Example config.yaml
|
||||
|
||||
@@ -227,8 +227,8 @@ server:
|
||||
port: 8443
|
||||
name: "OwnCord Server"
|
||||
data_dir: "data"
|
||||
allowed_origins: [] # empty = deny all cross-origin; set to ["*"] to allow any
|
||||
trusted_proxies: [] # e.g. ["10.0.0.0/8"] if behind a reverse proxy
|
||||
allowed_origins: [] # empty = deny all cross-origin; set to ["*"] to allow any
|
||||
trusted_proxies: [] # e.g. ["10.0.0.0/8"] if behind a reverse proxy
|
||||
admin_allowed_cidrs:
|
||||
- "127.0.0.0/8"
|
||||
- "::1/128"
|
||||
@@ -240,10 +240,10 @@ database:
|
||||
path: "data/chatserver.db"
|
||||
|
||||
tls:
|
||||
mode: "self_signed" # self_signed | acme | manual | off
|
||||
mode: "self_signed" # self_signed | acme | manual | off
|
||||
cert_file: "data/cert.pem"
|
||||
key_file: "data/key.pem"
|
||||
domain: "" # required for acme mode
|
||||
domain: "" # required for acme mode
|
||||
acme_cache_dir: "data/acme_certs"
|
||||
|
||||
upload:
|
||||
@@ -254,15 +254,15 @@ voice:
|
||||
livekit_api_key: "your-api-key"
|
||||
livekit_api_secret: "your-secret-at-least-32-characters-long"
|
||||
livekit_url: "ws://localhost:7880"
|
||||
livekit_binary: "" # path to livekit-server binary
|
||||
node_ip: "" # public IP for remote users behind NAT
|
||||
advertise_internal_ip: false # also advertise LAN IPs (dual-homed servers)
|
||||
quality: "medium" # low | medium | high
|
||||
livekit_binary: "" # path to livekit-server binary
|
||||
node_ip: "" # public IP for remote users behind NAT
|
||||
advertise_internal_ip: false # also advertise LAN IPs (dual-homed servers)
|
||||
quality: "medium" # low | medium | high
|
||||
|
||||
github:
|
||||
token: "" # optional GitHub PAT for update check rate limits
|
||||
owner: "J3vb" # update source repo owner
|
||||
repo: "OwnCord" # repo holding release assets (binaries + source snapshots)
|
||||
token: "" # optional GitHub PAT for update check rate limits
|
||||
owner: "J3vb" # update source repo owner
|
||||
repo: "OwnCord" # repo holding release assets (binaries + source snapshots)
|
||||
|
||||
# Event persistence (tiered reconnect replay)
|
||||
event_persistence:
|
||||
@@ -275,8 +275,8 @@ event_persistence:
|
||||
# OpenTelemetry (requires build tag: -tags otel)
|
||||
telemetry:
|
||||
enabled: false
|
||||
exporter: "none" # none | prometheus | otlp
|
||||
otlp_endpoint: "" # e.g. "localhost:4317" for OTLP gRPC
|
||||
exporter: "none" # none | prometheus | otlp
|
||||
otlp_endpoint: "" # e.g. "localhost:4317" for OTLP gRPC
|
||||
service_name: "owncord-server"
|
||||
|
||||
# Plugin runtime (requires build tag: -tags wazero)
|
||||
@@ -285,7 +285,7 @@ plugins:
|
||||
directory: "data/plugins"
|
||||
max_memory_mb: 64
|
||||
cpu_budget_ms: 100
|
||||
http_allowlist: [] # host suffixes plugins may reach, e.g. ["api.steampowered.com"]
|
||||
http_allowlist: [] # host suffixes plugins may reach, e.g. ["api.steampowered.com"]
|
||||
|
||||
# GIF picker (server-side Klipy proxy). Empty key = feature off.
|
||||
# Prefer OWNCORD_GIF_API_KEY over storing the key in this file.
|
||||
@@ -295,7 +295,7 @@ gif:
|
||||
# Logging. "level" gates what is logged, to stdout and the admin panel's live
|
||||
# log view alike. Override without editing this file via OWNCORD_LOGGING_LEVEL.
|
||||
logging:
|
||||
level: "info" # debug | info | warn | error
|
||||
level: "info" # debug | info | warn | error
|
||||
```
|
||||
|
||||
## See Also
|
||||
|
||||
+1
-1
@@ -26,7 +26,7 @@ It works behind CGNAT and strict home routers, so setup is usually faster than m
|
||||
> server:
|
||||
> admin_allowed_cidrs:
|
||||
> - "127.0.0.0/8"
|
||||
> - "100.64.0.0/10" # Tailscale tailnet
|
||||
> - "100.64.0.0/10" # Tailscale tailnet
|
||||
> ```
|
||||
|
||||
## TLS Recommendation
|
||||
|
||||
Reference in New Issue
Block a user