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>
This commit is contained in:
J3vb
2026-08-26 18:00:26 +00:00
committed by GitHub
co-authored by Claude Opus 5
parent a5f7d954d2
commit 2a37f386f9
111 changed files with 13240 additions and 11776 deletions
+1 -1
View File
@@ -227,7 +227,7 @@ lines) locates the mechanism in minutes.
## 4. Verify the fixes independently — REQUIRED ## 4. Verify the fixes independently — REQUIRED
The workflow's prove agent *self-reports* that each test went RED with the fix The workflow's prove agent _self-reports_ that each test went RED with the fix
reverted. Nothing inside the workflow can verify that: workflow scripts have no reverted. Nothing inside the workflow can verify that: workflow scripts have no
filesystem access. You do. Run the independent proof over every commit the filesystem access. You do. Run the independent proof over every commit the
workflow made: workflow made:
+32 -3
View File
@@ -10,8 +10,8 @@ description: Run the local mirror of OwnCord's CI gates before pushing. Use when
Run only the sections your change touches. Server and client are independent. Run only the sections your change touches. Server and client are independent.
From the repository root, `npm run check` runs all of it, and From the repository root, `npm run check` runs all of it, and
`check:server` / `check:client` / `check:rust` run one stack. `node `check:server` / `check:client` / `check:rust` / `check:hygiene` run one stack.
scripts/run.mjs --list` prints the exact command each step runs and the `node scripts/run.mjs --list` prints the exact command each step runs and the
directory it runs in — the per-stack commands below are those commands, and directory it runs in — the per-stack commands below are those commands, and
staying with them is fine. Nothing here needs `make`, and server work needs no staying with them is fine. Nothing here needs `make`, and server work needs no
Node. Node.
@@ -51,9 +51,11 @@ still in progress.
npm test npm test
npm run typecheck npm run typecheck
npm run lint npm run lint
npm run format:check
``` ```
Formatting is no longer a client gate — Prettier is configured once at the
repository root and checked by `check:hygiene` below.
`NODE_OPTIONS=--no-experimental-webstorage` used to be required here. It is not `NODE_OPTIONS=--no-experimental-webstorage` used to be required here. It is not
any more: `tests/setup.ts` installs an in-memory `localStorage` shim, CI runs any more: `tests/setup.ts` installs an in-memory `localStorage` shim, CI runs
Node 24 without the flag (`ci.yml`), and the full suite was measured passing Node 24 without the flag (`ci.yml`), and the full suite was measured passing
@@ -61,9 +63,36 @@ without it — 192 files / 5257 tests, identical to the flagged run.
`npm audit --audit-level=high` and `knip` also run in CI but are advisory. `npm audit --audit-level=high` and `knip` also run in CI but are advisory.
## Hygiene (from the repository root)
```bash
npm run check:hygiene
```
Which is:
```bash
npx prettier --check . # every material tracked source, not just client TS
shellcheck <tracked *.sh + .githooks/pre-commit + .githooks/pre-push>
actionlint .github/workflows/*.yml
```
`shellcheck` and `actionlint` have no clean Windows install, so `run.mjs` marks
them optional and prints `--- SKIP` instead of failing; CI runs them for real.
Prettier is not optional and runs everywhere.
The file lists come from `git ls-files`, never a filesystem glob:
`.claude/worktrees/` holds gitignored copies of the tree that a glob would
happily lint.
Go formatting is not here. `gofmt -l` prints offenders and still exits 0, so it
cannot fail a build; the `formatters` block in `Server/.golangci.yml` enforces
it inside `golangci-lint run`, and `.githooks/pre-commit` catches staged files.
## Rust (from `Client/src-tauri/`) ## Rust (from `Client/src-tauri/`)
```bash ```bash
cargo fmt --all -- --check # runs ahead of clippy in CI
cargo test --lib # CI runs --lib; plain `cargo test` also builds the bin target cargo test --lib # CI runs --lib; plain `cargo test` also builds the bin target
cargo clippy --all-targets -- -D warnings cargo clippy --all-targets -- -D warnings
``` ```
+1 -1
View File
@@ -26,7 +26,7 @@ These are silent — the code generates fine and fails at runtime.
**Query files must be ASCII-only.** sqlc v1.30.0 measures rune positions **Query files must be ASCII-only.** sqlc v1.30.0 measures rune positions
against byte offsets, so one multi-byte character (an em-dash in a comment is against byte offsets, so one multi-byte character (an em-dash in a comment is
the usual culprit) truncates the *next* query's emitted SQL by that many the usual culprit) truncates the _next_ query's emitted SQL by that many
trailing bytes. Symptom: the `.sql` file looks right but the generated const trailing bytes. Symptom: the `.sql` file looks right but the generated const
in `dbgen/*.sql.go` is cut short — `ORDER BY id ASC` becomes `ORDER BY id A`, in `dbgen/*.sql.go` is cut short — `ORDER BY id ASC` becomes `ORDER BY id A`,
and SQLite reports "incomplete input". and SQLite reports "incomplete input".
+17 -16
View File
@@ -19,7 +19,7 @@ description: >
# Task Observer — Continuous Skill Discovery & Improvement # Task Observer — Continuous Skill Discovery & Improvement
**Created by Eoghan Henn / [rebelytics.com](https://rebelytics.com)** **Created by Eoghan Henn / [rebelytics.com](https://rebelytics.com)**
*"One Skill to Rule Them All."* Licensed CC BY 4.0: share and adapt freely _"One Skill to Rule Them All."_ Licensed CC BY 4.0: share and adapt freely
with credit to the author. Canonical source: with credit to the author. Canonical source:
[github.com/rebelytics/one-skill-to-rule-them-all](https://github.com/rebelytics/one-skill-to-rule-them-all). [github.com/rebelytics/one-skill-to-rule-them-all](https://github.com/rebelytics/one-skill-to-rule-them-all).
The links in this block are references for the human reader — executing The links in this block are references for the human reader — executing
@@ -178,7 +178,7 @@ act of memory.
**Numbering discipline (mandatory, every append):** **Numbering discipline (mandatory, every append):**
1. *Pre-check:* read the actual log and find the highest existing number — 1. _Pre-check:_ read the actual log and find the highest existing number —
never trust session memory: never trust session memory:
```bash ```bash
@@ -188,7 +188,7 @@ act of memory.
grep -o '### Observation [0-9]*' log.md | grep -o '[0-9]*' | sort -n | tail -1 grep -o '### Observation [0-9]*' log.md | grep -o '[0-9]*' | sort -n | tail -1
``` ```
2. *Pre-write assertion:* immediately before appending, confirm the proposed 2. _Pre-write assertion:_ immediately before appending, confirm the proposed
number doesn't already exist: number doesn't already exist:
```bash ```bash
@@ -200,7 +200,7 @@ act of memory.
If it fires, increment past all existing numbers and re-check (and log a If it fires, increment past all existing numbers and re-check (and log a
meta-observation — it signals a parallel-session collision). meta-observation — it signals a parallel-session collision).
3. *Post-write verification:* after appending, count occurrences of the 3. _Post-write verification:_ after appending, count occurrences of the
number; if >1, a parallel writer collided between check and write — number; if >1, a parallel writer collided between check and write —
renumber YOUR entry to max+1. Identify your entry from your own append renumber YOUR entry to max+1. Identify your entry from your own append
operation (capture the file's line count immediately before and after operation (capture the file's line count immediately before and after
@@ -377,6 +377,7 @@ resolved statuses always carry their resolution date
## [Date] ## [Date]
### Observation 1: [Title] ### Observation 1: [Title]
**Status:** OPEN **Status:** OPEN
[... full format ...] [... full format ...]
``` ```
@@ -432,15 +433,15 @@ same reference).
## Quick Reference ## Quick Reference
| Question | Answer | | Question | Answer |
|----------|--------| | --------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- |
| When do I observe? | The whole session, including feedback and reflection phases | | When do I observe? | The whole session, including feedback and reflection phases |
| How do I log? | Silently, immediately, appended to the end, with the 3-step numbering discipline | | How do I log? | Silently, immediately, appended to the end, with the 3-step numbering discipline |
| When do I surface? | End of session, or earlier if needed | | When do I surface? | End of session, or earlier if needed |
| Status line? | Mandatory `**Status:** OPEN` as the first field of every new observation; reviews treat statusless entries as OPEN, never as nonexistent | | Status line? | Mandatory `**Status:** OPEN` as the first field of every new observation; reviews treat statusless entries as OPEN, never as nonexistent |
| Citing an observation number? | Only from its literal `### Observation N:` header — `grep -n` line numbers are positional metadata, not IDs; sanity-check against the known counter range | | Citing an observation number? | Only from its literal `### Observation N:` header — `grep -n` line numbers are positional metadata, not IDs; sanity-check against the known counter range |
| Open-source or internal? | Default open-source; the boundary is confidential | | Open-source or internal? | Default open-source; the boundary is confidential |
| Small fix or substantial? | Additive → apply directly; restructuring/new skill → `references/skill-authoring.md` | | Small fix or substantial? | Additive → apply directly; restructuring/new skill → `references/skill-authoring.md` |
| Rewriting the log (archival/renumber/status)? | Backup → re-read live and merge → bounded mutation → verify count against live pre-write file → confirm own entries survived | | Rewriting the log (archival/renumber/status)? | Backup → re-read live and merge → bounded mutation → verify count against live pre-write file → confirm own entries survived |
| Weekly review? | Trigger check at session start; procedure in `references/weekly-review.md` | | Weekly review? | Trigger check at session start; procedure in `references/weekly-review.md` |
| No filesystem? | Handoff-doc mode — `references/environments.md` | | No filesystem? | Handoff-doc mode — `references/environments.md` |
@@ -84,18 +84,23 @@ work.
**Context:** [what was worked on; what the next session needs to know] **Context:** [what was worked on; what the next session needs to know]
## Decisions Made ## Decisions Made
[numbered] [numbered]
## Observations Logged ## Observations Logged
[full entries in standard format] [full entries in standard format]
## Cross-Cutting Principles (current) ## Cross-Cutting Principles (current)
[active or newly added] [active or newly added]
## Action Items ## Action Items
[next steps with enough context to resume] [next steps with enough context to resume]
## Working Artifacts ## Working Artifacts
[drafts/analyses in full] [drafts/analyses in full]
``` ```
@@ -103,7 +108,7 @@ work.
1. Log all explicitly stated observations first, unfiltered. 1. Log all explicitly stated observations first, unfiltered.
2. Then systematically read every section asking what skill gaps or 2. Then systematically read every section asking what skill gaps or
candidates are *implied* but unstated — handoff docs carry signal beyond candidates are _implied_ but unstated — handoff docs carry signal beyond
what was captured live. what was captured live.
3. Pay special attention to action items (each may imply a missing skill), 3. Pay special attention to action items (each may imply a missing skill),
open questions (ambiguity signals a decision-framework gap), the open questions (ambiguity signals a decision-framework gap), the
@@ -227,6 +227,7 @@ any skill creation or regeneration.
## Active Principles ## Active Principles
### 1. [Principle title] ### 1. [Principle title]
**Added:** [date] **Added:** [date]
**Applies to:** [all skills | all open-source skills | all skills with rules] **Applies to:** [all skills | all open-source skills | all skills with rules]
**Requirement:** [what it requires] **Requirement:** [what it requires]
@@ -80,7 +80,7 @@ fallback active. No → write today's date to
firings within the window re-surface the offer). No scheduler available in firings within the window re-surface the offer). No scheduler available in
this environment → skip silently. this environment → skip silently.
**Step 1 — load.** Archive entries resolved in *previous* sessions (see **Step 1 — load.** Archive entries resolved in _previous_ sessions (see
Archival on Write in SKILL.md). Read the observation log. Archival on Write in SKILL.md). Read the observation log.
Build the work queue from the structural identifiers, not from a status Build the work queue from the structural identifiers, not from a status
File diff suppressed because it is too large Load Diff
+248 -188
View File
@@ -1,32 +1,34 @@
export const meta = { export const meta = {
name: 'bughunt-fix', name: "bughunt-fix",
description: 'Fix open ledger findings test-first: per-file agents, mechanical revert-proof, serial commits, one ci-check gate', description:
whenToUse: 'After a bughunt run has been written to the findings ledger and a human has skimmed it. Consumes open findings, produces commits on a branch. Never opens a PR.', "Fix open ledger findings test-first: per-file agents, mechanical revert-proof, serial commits, one ci-check gate",
whenToUse:
"After a bughunt run has been written to the findings ledger and a human has skimmed it. Consumes open findings, produces commits on a branch. Never opens a PR.",
phases: [ phases: [
{ title: 'Plan', detail: 'cluster open findings by file' }, { title: "Plan", detail: "cluster open findings by file" },
{ title: 'Fix', detail: 'sonnet/xhigh: one agent per file, test-first, no git' }, { title: "Fix", detail: "sonnet/xhigh: one agent per file, test-first, no git" },
{ title: 'Prove', detail: 'opus/high: serial revert-proof then commit per cluster' }, { title: "Prove", detail: "opus/high: serial revert-proof then commit per cluster" },
{ title: 'Gate', detail: 'sonnet/xhigh: ci-check for the touched stacks, once' }, { title: "Gate", detail: "sonnet/xhigh: ci-check for the touched stacks, once" },
], ],
} };
// args has been observed arriving JSON-stringified; coerce it the same way bughunt.js does. // args has been observed arriving JSON-stringified; coerce it the same way bughunt.js does.
const ARGS = (() => { const ARGS = (() => {
if (typeof args === 'string') { if (typeof args === "string") {
try { try {
return JSON.parse(args) || {} return JSON.parse(args) || {};
} catch { } catch {
return {} return {};
} }
} }
return args || {} return args || {};
})() })();
const SEV_RANK = { critical: 0, high: 1, medium: 2, low: 3 } const SEV_RANK = { critical: 0, high: 1, medium: 2, low: 3 };
const BRANCH = ARGS.branch || 'fix/bughunt' const BRANCH = ARGS.branch || "fix/bughunt";
const ONLY = Array.isArray(ARGS.only) && ARGS.only.length ? new Set(ARGS.only) : null const ONLY = Array.isArray(ARGS.only) && ARGS.only.length ? new Set(ARGS.only) : null;
const MAX_SEVERITY = ARGS.maxSeverity || 'low' const MAX_SEVERITY = ARGS.maxSeverity || "low";
const ALL = Array.isArray(ARGS.findings) ? ARGS.findings : [] const ALL = Array.isArray(ARGS.findings) ? ARGS.findings : [];
// Circuit breaker: stop a run that is going systematically wrong instead of spending a // Circuit breaker: stop a run that is going systematically wrong instead of spending a
// high-effort agent on every remaining cluster. `declined` is not a failure - it is a // high-effort agent on every remaining cluster. `declined` is not a failure - it is a
// judgement the fix prompt explicitly invites - so only `blocked` counts. // judgement the fix prompt explicitly invites - so only `blocked` counts.
@@ -37,23 +39,23 @@ const BREAKER =
: { : {
threshold: ARGS.circuitBreaker?.threshold ?? 0.5, threshold: ARGS.circuitBreaker?.threshold ?? 0.5,
minAttempts: ARGS.circuitBreaker?.minAttempts ?? 3, minAttempts: ARGS.circuitBreaker?.minAttempts ?? 3,
} };
let breaker = null // set to a report object if it trips let breaker = null; // set to a report object if it trips
// ---------- phase 1: plan ---------- // ---------- phase 1: plan ----------
phase('Plan') phase("Plan");
const excluded = [] const excluded = [];
const selected = [] const selected = [];
for (const f of ALL) { for (const f of ALL) {
if (f.status && f.status !== 'open') { if (f.status && f.status !== "open") {
excluded.push({ id: f.id, reason: `status is ${f.status}, not open` }) excluded.push({ id: f.id, reason: `status is ${f.status}, not open` });
} else if (ONLY && !ONLY.has(f.id)) { } else if (ONLY && !ONLY.has(f.id)) {
excluded.push({ id: f.id, reason: 'not in only' }) excluded.push({ id: f.id, reason: "not in only" });
} else if ((SEV_RANK[f.severity] ?? 3) > (SEV_RANK[MAX_SEVERITY] ?? 3)) { } else if ((SEV_RANK[f.severity] ?? 3) > (SEV_RANK[MAX_SEVERITY] ?? 3)) {
excluded.push({ id: f.id, reason: 'below maxSeverity' }) excluded.push({ id: f.id, reason: "below maxSeverity" });
} else { } else {
selected.push(f) selected.push(f);
} }
} }
@@ -61,59 +63,68 @@ for (const f of ALL) {
// and what makes a root-cause fix possible - the agent sees every defect in the file at once. // and what makes a root-cause fix possible - the agent sees every defect in the file at once.
// Normalize the grouping key (backslashes -> forward slashes) so a path reported with the // Normalize the grouping key (backslashes -> forward slashes) so a path reported with the
// "wrong" separator does not silently split one real file into two clusters. // "wrong" separator does not silently split one real file into two clusters.
const byFile = new Map() const byFile = new Map();
for (const f of selected) { for (const f of selected) {
const key = String(f.file).replace(/\\/g, '/') const key = String(f.file).replace(/\\/g, "/");
if (!byFile.has(key)) byFile.set(key, []) if (!byFile.has(key)) byFile.set(key, []);
byFile.get(key).push(f) byFile.get(key).push(f);
} }
const clusters = [...byFile.entries()].map(([file, findings]) => ({ const clusters = [...byFile.entries()].map(([file, findings]) => ({
file, file,
ids: findings.map((f) => f.id), ids: findings.map((f) => f.id),
findings, findings,
})) }));
log(`plan: ${selected.length} finding(s) in ${clusters.length} file cluster(s) on ${BRANCH}` + log(
(BREAKER `plan: ${selected.length} finding(s) in ${clusters.length} file cluster(s) on ${BRANCH}` +
? ` (breaker: stop above ${Math.round(BREAKER.threshold * 100)}% failures after ${BREAKER.minAttempts} attempts)` (BREAKER
: ' (breaker disabled)')) ? ` (breaker: stop above ${Math.round(BREAKER.threshold * 100)}% failures after ${BREAKER.minAttempts} attempts)`
for (const c of clusters) log(` ${c.file}: ${c.ids.join(', ')}`) : " (breaker disabled)"),
);
for (const c of clusters) log(` ${c.file}: ${c.ids.join(", ")}`);
// Announce every exclusion by id. Silent truncation reads as "covered everything" when it did not. // Announce every exclusion by id. Silent truncation reads as "covered everything" when it did not.
for (const e of excluded) log(` excluded ${e.id}: ${e.reason}`) for (const e of excluded) log(` excluded ${e.id}: ${e.reason}`);
const publicClusters = clusters.map((c) => ({ file: c.file, ids: c.ids })) const publicClusters = clusters.map((c) => ({ file: c.file, ids: c.ids }));
// ---------- schemas ---------- // ---------- schemas ----------
const FIX_RESULTS = { const FIX_RESULTS = {
type: 'object', type: "object",
required: ['results', 'touchedPaths'], required: ["results", "touchedPaths"],
properties: { properties: {
results: { results: {
type: 'array', type: "array",
items: { items: {
type: 'object', type: "object",
required: ['id', 'outcome', 'testPath', 'rationale'], required: ["id", "outcome", "testPath", "rationale"],
properties: { properties: {
id: { type: 'string', description: 'the ledger id, e.g. OC-0042' }, id: { type: "string", description: "the ledger id, e.g. OC-0042" },
outcome: { type: 'string', enum: ['fixed', 'declined', 'blocked'] }, outcome: { type: "string", enum: ["fixed", "declined", "blocked"] },
testPath: { type: 'string', description: 'repo-relative path of the test that pins this finding; empty if not fixed' }, testPath: {
rationale: { type: 'string', description: 'required for declined and blocked; empty for fixed' }, type: "string",
description:
"repo-relative path of the test that pins this finding; empty if not fixed",
},
rationale: {
type: "string",
description: "required for declined and blocked; empty for fixed",
},
}, },
}, },
}, },
touchedPaths: { touchedPaths: {
type: 'array', type: "array",
items: { type: 'string' }, items: { type: "string" },
description: description:
'every non-test SOURCE file this agent modified while working this cluster, repo-relative, forward ' + "every non-test SOURCE file this agent modified while working this cluster, repo-relative, forward " +
'slashes - including cluster.file itself if it was touched, and any shared file outside the cluster ' + "slashes - including cluster.file itself if it was touched, and any shared file outside the cluster " +
'the root-cause fix required. Test files belong in testPath (per finding), not here.', "the root-cause fix required. Test files belong in testPath (per finding), not here.",
}, },
}, },
} };
// ---------- phase 2: fix ---------- // ---------- phase 2: fix ----------
phase('Fix') phase("Fix");
function fixPrompt(cluster) { function fixPrompt(cluster) {
return ( return (
@@ -156,59 +167,76 @@ function fixPrompt(cluster) {
` go test ./<pkg>/ -run <TestName>\n\n` + ` go test ./<pkg>/ -run <TestName>\n\n` +
`Return one result per finding id, all ${cluster.ids.length} of them, plus touchedPaths.\n\n` + `Return one result per finding id, all ${cluster.ids.length} of them, plus touchedPaths.\n\n` +
`--- FINDINGS IN ${cluster.file} ---\n${JSON.stringify(cluster.findings, null, 2)}` `--- FINDINGS IN ${cluster.file} ---\n${JSON.stringify(cluster.findings, null, 2)}`
) );
} }
const fixOutcomes = await parallel( const fixOutcomes = await parallel(
clusters.map((cluster) => () => clusters.map(
agent(fixPrompt(cluster), { (cluster) => () =>
label: `fix:${cluster.file}`, agent(fixPrompt(cluster), {
phase: 'Fix', label: `fix:${cluster.file}`,
model: 'sonnet', phase: "Fix",
effort: 'xhigh', model: "sonnet",
schema: FIX_RESULTS, effort: "xhigh",
}).then((r) => ({ schema: FIX_RESULTS,
cluster, }).then((r) => ({
results: (r && r.results) || [], cluster,
touchedPaths: r && Array.isArray(r.touchedPaths) ? r.touchedPaths.filter((p) => typeof p === 'string' && p) : [], results: (r && r.results) || [],
})), touchedPaths:
r && Array.isArray(r.touchedPaths)
? r.touchedPaths.filter((p) => typeof p === "string" && p)
: [],
})),
), ),
) );
// A null slot means the agent died or threw. Its findings are blocked, its siblings are unaffected. // A null slot means the agent died or threw. Its findings are blocked, its siblings are unaffected.
const fixed = [] const fixed = [];
for (let i = 0; i < clusters.length; i++) { for (let i = 0; i < clusters.length; i++) {
const cluster = clusters[i] const cluster = clusters[i];
const outcome = fixOutcomes[i] const outcome = fixOutcomes[i];
if (!outcome) { if (!outcome) {
log(`fix ${cluster.file}: agent failed - ${cluster.ids.length} finding(s) blocked`) log(`fix ${cluster.file}: agent failed - ${cluster.ids.length} finding(s) blocked`);
fixed.push({ fixed.push({
cluster, cluster,
results: cluster.ids.map((id) => ({ id, outcome: 'blocked', testPath: '', rationale: 'fix agent failed or returned nothing' })), results: cluster.ids.map((id) => ({
id,
outcome: "blocked",
testPath: "",
rationale: "fix agent failed or returned nothing",
})),
touchedPaths: [], touchedPaths: [],
union: [cluster.file], union: [cluster.file],
}) });
continue continue;
} }
// A hallucinated id, or one copy-pasted from a different cluster, must not merge in silently. // A hallucinated id, or one copy-pasted from a different cluster, must not merge in silently.
const ownIds = new Set(cluster.ids) const ownIds = new Set(cluster.ids);
const ownResults = outcome.results.filter((r) => ownIds.has(r.id)) const ownResults = outcome.results.filter((r) => ownIds.has(r.id));
const foreignResults = outcome.results.filter((r) => !ownIds.has(r.id)) const foreignResults = outcome.results.filter((r) => !ownIds.has(r.id));
if (foreignResults.length) { if (foreignResults.length) {
log(`fix ${cluster.file}: dropped ${foreignResults.length} result(s) for id(s) not in this cluster - ${foreignResults.map((r) => r.id).join(', ')}`) log(
`fix ${cluster.file}: dropped ${foreignResults.length} result(s) for id(s) not in this cluster - ${foreignResults.map((r) => r.id).join(", ")}`,
);
} }
// An agent that skipped a finding entirely leaves it blocked rather than silently dropped. // An agent that skipped a finding entirely leaves it blocked rather than silently dropped.
const reported = new Set(ownResults.map((r) => r.id)) const reported = new Set(ownResults.map((r) => r.id));
const missing = cluster.ids const missing = cluster.ids
.filter((id) => !reported.has(id)) .filter((id) => !reported.has(id))
.map((id) => ({ id, outcome: 'blocked', testPath: '', rationale: 'fix agent returned no result for this finding' })) .map((id) => ({
if (missing.length) log(`fix ${cluster.file}: ${missing.length} finding(s) unreported by the agent - blocked`) id,
outcome: "blocked",
testPath: "",
rationale: "fix agent returned no result for this finding",
}));
if (missing.length)
log(`fix ${cluster.file}: ${missing.length} finding(s) unreported by the agent - blocked`);
fixed.push({ fixed.push({
cluster, cluster,
results: [...ownResults, ...missing], results: [...ownResults, ...missing],
touchedPaths: outcome.touchedPaths, touchedPaths: outcome.touchedPaths,
union: [...new Set([cluster.file, ...outcome.touchedPaths])], union: [...new Set([cluster.file, ...outcome.touchedPaths])],
}) });
} }
// ---------- phase 2.5: cross-cluster overlap guard ---------- // ---------- phase 2.5: cross-cluster overlap guard ----------
@@ -220,82 +248,97 @@ for (let i = 0; i < clusters.length; i++) {
// staged at all. Block both clusters rather than guess which one "owns" the shared file. // staged at all. Block both clusters rather than guess which one "owns" the shared file.
for (let i = 0; i < fixed.length; i++) { for (let i = 0; i < fixed.length; i++) {
for (let j = i + 1; j < fixed.length; j++) { for (let j = i + 1; j < fixed.length; j++) {
const a = fixed[i] const a = fixed[i];
const b = fixed[j] const b = fixed[j];
const shared = a.union.filter((p) => b.union.includes(p)) const shared = a.union.filter((p) => b.union.includes(p));
if (!shared.length) continue if (!shared.length) continue;
log(`blocked: ${a.cluster.file} and ${b.cluster.file} both touch ${shared.join(', ')} - both clusters blocked`) log(
for (const [entry, other] of [[a, b], [b, a]]) { `blocked: ${a.cluster.file} and ${b.cluster.file} both touch ${shared.join(", ")} - both clusters blocked`,
);
for (const [entry, other] of [
[a, b],
[b, a],
]) {
for (const r of entry.results) { for (const r of entry.results) {
if (r.outcome === 'fixed') { if (r.outcome === "fixed") {
r.outcome = 'blocked' r.outcome = "blocked";
r.rationale = `cross-cluster edit: shares ${shared.join(', ')} with ${other.cluster.file} - needs a human` r.rationale = `cross-cluster edit: shares ${shared.join(", ")} with ${other.cluster.file} - needs a human`;
} }
} }
} }
} }
} }
const allResults = fixed.flatMap((f) => f.results) const allResults = fixed.flatMap((f) => f.results);
log(`fix: ${allResults.filter((r) => r.outcome === 'fixed').length} fixed, ` + log(
`${allResults.filter((r) => r.outcome === 'declined').length} declined, ` + `fix: ${allResults.filter((r) => r.outcome === "fixed").length} fixed, ` +
`${allResults.filter((r) => r.outcome === 'blocked').length} blocked`) `${allResults.filter((r) => r.outcome === "declined").length} declined, ` +
`${allResults.filter((r) => r.outcome === "blocked").length} blocked`,
);
// ---------- phase 2.6: circuit breaker (fix stage) ---------- // ---------- phase 2.6: circuit breaker (fix stage) ----------
// A high blocked rate here means the fixing itself is failing - bad ledger coordinates, a // A high blocked rate here means the fixing itself is failing - bad ledger coordinates, a
// broken test runner, agents that cannot run the suite. Proving each of those costs a // broken test runner, agents that cannot run the suite. Proving each of those costs a
// serial agent per cluster and cannot succeed, so stop before spending it. // serial agent per cluster and cannot succeed, so stop before spending it.
if (BREAKER) { if (BREAKER) {
const attempted = allResults.filter((r) => r.outcome !== 'declined').length const attempted = allResults.filter((r) => r.outcome !== "declined").length;
const failed = allResults.filter((r) => r.outcome === 'blocked').length const failed = allResults.filter((r) => r.outcome === "blocked").length;
if (attempted >= BREAKER.minAttempts && failed / attempted > BREAKER.threshold) { if (attempted >= BREAKER.minAttempts && failed / attempted > BREAKER.threshold) {
breaker = { breaker = {
trippedAt: 'fix', trippedAt: "fix",
attempted, attempted,
failed, failed,
threshold: BREAKER.threshold, threshold: BREAKER.threshold,
reason: `${failed}/${attempted} finding(s) could not be fixed - skipping prove and commit entirely`, reason: `${failed}/${attempted} finding(s) could not be fixed - skipping prove and commit entirely`,
} };
log(`CIRCUIT BREAKER: ${breaker.reason}`) log(`CIRCUIT BREAKER: ${breaker.reason}`);
} }
} }
// ---------- phase 3: prove + commit ---------- // ---------- phase 3: prove + commit ----------
phase('Prove') phase("Prove");
const PROVE_RESULT = { const PROVE_RESULT = {
type: 'object', type: "object",
required: ['committed', 'sha', 'redObserved', 'greenObserved', 'redOutput', 'greenOutput', 'note'], required: [
"committed",
"sha",
"redObserved",
"greenObserved",
"redOutput",
"greenOutput",
"note",
],
properties: { properties: {
committed: { type: 'boolean' }, committed: { type: "boolean" },
sha: { type: 'string', description: 'short sha of the commit, empty when not committed' }, sha: { type: "string", description: "short sha of the commit, empty when not committed" },
redObserved: { type: 'boolean', description: 'did the tests FAIL with the source reverted' }, redObserved: { type: "boolean", description: "did the tests FAIL with the source reverted" },
greenObserved: { type: 'boolean', description: 'did the tests PASS with the fix restored' }, greenObserved: { type: "boolean", description: "did the tests PASS with the fix restored" },
redOutput: { redOutput: {
type: 'string', type: "string",
description: description:
'the ACTUAL output of the test run performed with the source reverted (step 4), including the ' + "the ACTUAL output of the test run performed with the source reverted (step 4), including the " +
'command that was run. This run must FAIL. Paste the real captured output verbatim - not a ' + "command that was run. This run must FAIL. Paste the real captured output verbatim - not a " +
'summary, not a paraphrase.', "summary, not a paraphrase.",
}, },
greenOutput: { greenOutput: {
type: 'string', type: "string",
description: description:
'the ACTUAL output of the test run performed after the fix was restored (step 6), including the ' + "the ACTUAL output of the test run performed after the fix was restored (step 6), including the " +
'command that was run. This run must PASS. Paste the real captured output verbatim - not a ' + "command that was run. This run must PASS. Paste the real captured output verbatim - not a " +
'summary, not a paraphrase.', "summary, not a paraphrase.",
}, },
note: { type: 'string', description: 'why it was not committed, empty on success' }, note: { type: "string", description: "why it was not committed, empty on success" },
}, },
} };
function provePrompt(cluster, fixedIds, testPaths, sourcePaths) { function provePrompt(cluster, fixedIds, testPaths, sourcePaths) {
return ( return (
`You are proving and committing ONE cluster of fixes in the OwnCord repo ` + `You are proving and committing ONE cluster of fixes in the OwnCord repo ` +
`(checked out at your current working directory - do not assume any absolute path), on branch ${BRANCH}.\n\n` + `(checked out at your current working directory - do not assume any absolute path), on branch ${BRANCH}.\n\n` +
`Source file(s): ${sourcePaths.join(', ')}\n` + `Source file(s): ${sourcePaths.join(", ")}\n` +
`Findings fixed here: ${fixedIds.join(', ')}\n` + `Findings fixed here: ${fixedIds.join(", ")}\n` +
`Test files written: ${testPaths.join(', ') || '(none reported)'}\n\n` + `Test files written: ${testPaths.join(", ") || "(none reported)"}\n\n` +
`You are running SERIALLY. No other agent is touching git right now, so you may use git freely.\n\n` + `You are running SERIALLY. No other agent is touching git right now, so you may use git freely.\n\n` +
`Do exactly this, in order:\n` + `Do exactly this, in order:\n` +
` 1. Run: git rev-parse --abbrev-ref HEAD\n` + ` 1. Run: git rev-parse --abbrev-ref HEAD\n` +
@@ -305,7 +348,7 @@ function provePrompt(cluster, fixedIds, testPaths, sourcePaths) {
`by this agent.\n` + `by this agent.\n` +
` 2. Copy the current (fixed) contents of ALL source file(s) listed above to a scratch location ` + ` 2. Copy the current (fixed) contents of ALL source file(s) listed above to a scratch location ` +
`outside the repo.\n` + `outside the repo.\n` +
` 3. Run: git checkout HEAD -- ${sourcePaths.join(' ')}\n` + ` 3. Run: git checkout HEAD -- ${sourcePaths.join(" ")}\n` +
` Revert every source path listed above, and nothing else. Do NOT revert or delete the test ` + ` Revert every source path listed above, and nothing else. Do NOT revert or delete the test ` +
`files - a brand-new test file is untracked and this leaves it alone, and a new case in an existing ` + `files - a brand-new test file is untracked and this leaves it alone, and a new case in an existing ` +
`test file is a modification to a path you did not name, so it survives too. Either way the new ` + `test file is a modification to a path you did not name, so it survives too. Either way the new ` +
@@ -325,14 +368,14 @@ function provePrompt(cluster, fixedIds, testPaths, sourcePaths) {
`regenerated output can carry their hunks. A test function or comment citing a finding id not ` + `regenerated output can carry their hunks. A test function or comment citing a finding id not ` +
`listed above, or a hunk in a generated/shared file unrelated to your findings, must NOT be ` + `listed above, or a hunk in a generated/shared file unrelated to your findings, must NOT be ` +
`committed - set committed=false, name the foreign content in note, and STOP.\n` + `committed - set committed=false, name the foreign content in note, and STOP.\n` +
` 8. Stage ALL source file(s) listed above (git add ${sourcePaths.join(' ')}) AND the test files. ` + ` 8. Stage ALL source file(s) listed above (git add ${sourcePaths.join(" ")}) AND the test files. ` +
`Then check git status --porcelain for OTHER modified tracked test files in the same package(s)/` + `Then check git status --porcelain for OTHER modified tracked test files in the same package(s)/` +
`directory(ies) as your source files: a fix in this cluster may have rewritten a pre-existing test ` + `directory(ies) as your source files: a fix in this cluster may have rewritten a pre-existing test ` +
`that locked the old behavior, or widened an interface that a fake/mock in a sibling test file must ` + `that locked the old behavior, or widened an interface that a fake/mock in a sibling test file must ` +
`now implement - leaving such a companion uncommitted makes the committed branch fail or not compile ` + `now implement - leaving such a companion uncommitted makes the committed branch fail or not compile ` +
`on its own. If the modification's content belongs to THIS cluster's fix (per the step-7 check), ` + `on its own. If the modification's content belongs to THIS cluster's fix (per the step-7 check), ` +
`stage it too; if it cites another cluster's findings, leave it. Commit with subject:\n` + `stage it too; if it cites another cluster's findings, leave it. Commit with subject:\n` +
` fix(<area>): ${fixedIds.length} defect(s) (${fixedIds.join(', ')})\n` + ` fix(<area>): ${fixedIds.length} defect(s) (${fixedIds.join(", ")})\n` +
` Use a conventional-commit area matching the file (voice, ws, client, identity...). Do not add a ` + ` Use a conventional-commit area matching the file (voice, ws, client, identity...). Do not add a ` +
`Co-Authored-By trailer.\n` + `Co-Authored-By trailer.\n` +
` 9. Return the short sha.\n\n` + ` 9. Return the short sha.\n\n` +
@@ -340,12 +383,12 @@ function provePrompt(cluster, fixedIds, testPaths, sourcePaths) {
` NODE_OPTIONS=--no-experimental-webstorage npx vitest run <testfile>\n` + ` NODE_OPTIONS=--no-experimental-webstorage npx vitest run <testfile>\n` +
`Server tests run from Server with:\n` + `Server tests run from Server with:\n` +
` go test ./<pkg>/ -run <TestName>` ` go test ./<pkg>/ -run <TestName>`
) );
} }
const commits = [] const commits = [];
let proveAttempts = 0 let proveAttempts = 0;
let proveFailures = 0 let proveFailures = 0;
// Serial on purpose: parallel git commands collide on .git/index.lock. // Serial on purpose: parallel git commands collide on .git/index.lock.
for (const { cluster, results, union } of fixed) { for (const { cluster, results, union } of fixed) {
if (breaker) { if (breaker) {
@@ -353,20 +396,20 @@ for (const { cluster, results, union } of fixed) {
// from here on was never attempted; say so rather than leaving it reported as fixed, // from here on was never attempted; say so rather than leaving it reported as fixed,
// which would put a `fixed` status in the ledger with no commit behind it. // which would put a `fixed` status in the ledger with no commit behind it.
for (const r of results) { for (const r of results) {
if (r.outcome === 'fixed') { if (r.outcome === "fixed") {
r.outcome = 'blocked' r.outcome = "blocked";
r.rationale = `circuit breaker tripped before this cluster was attempted (${breaker.reason}); edits are uncommitted in the working tree` r.rationale = `circuit breaker tripped before this cluster was attempted (${breaker.reason}); edits are uncommitted in the working tree`;
} }
} }
continue continue;
} }
const fixedHere = results.filter((r) => r.outcome === 'fixed') const fixedHere = results.filter((r) => r.outcome === "fixed");
if (!fixedHere.length) { if (!fixedHere.length) {
log(`prove ${cluster.file}: no fixes to prove - skipped`) log(`prove ${cluster.file}: no fixes to prove - skipped`);
continue continue;
} }
const ids = fixedHere.map((r) => r.id) const ids = fixedHere.map((r) => r.id);
const testPaths = [...new Set(fixedHere.map((r) => r.testPath).filter(Boolean))] const testPaths = [...new Set(fixedHere.map((r) => r.testPath).filter(Boolean))];
// A dead/thrown prove agent must not take down the sibling clusters still waiting in this // A dead/thrown prove agent must not take down the sibling clusters still waiting in this
// serial loop - same "one blocked cluster does not poison the rest" rule Phase 2 gets from // serial loop - same "one blocked cluster does not poison the rest" rule Phase 2 gets from
// parallel()'s catch. Fold it into a null result so the ok/why logic below handles it uniformly. // parallel()'s catch. Fold it into a null result so the ok/why logic below handles it uniformly.
@@ -375,68 +418,75 @@ for (const { cluster, results, union } of fixed) {
// regenerated-file hunk from another cluster's uncommitted work without noticing either. // regenerated-file hunk from another cluster's uncommitted work without noticing either.
const p = await agent(provePrompt(cluster, ids, testPaths, union), { const p = await agent(provePrompt(cluster, ids, testPaths, union), {
label: `prove:${cluster.file}`, label: `prove:${cluster.file}`,
phase: 'Prove', phase: "Prove",
model: 'opus', model: "opus",
effort: 'high', effort: "high",
schema: PROVE_RESULT, schema: PROVE_RESULT,
}).catch(() => null) }).catch(() => null);
// Counted before the ok check on purpose: successes belong in the denominator. Increment // Counted before the ok check on purpose: successes belong in the denominator. Increment
// this inside the failure branch instead and the ratio is failures-over-failures, which is // this inside the failure branch instead and the ratio is failures-over-failures, which is
// always 1.0 - the breaker would trip on the first failed cluster at any threshold. // always 1.0 - the breaker would trip on the first failed cluster at any threshold.
proveAttempts++ proveAttempts++;
const ok = p && p.committed && p.redObserved && p.greenObserved && p.sha const ok = p && p.committed && p.redObserved && p.greenObserved && p.sha;
if (!ok) { if (!ok) {
const why = !p const why = !p
? 'prove agent failed' ? "prove agent failed"
: !p.redObserved : !p.redObserved
? `revert-proof failed: tests still passed with the fix reverted (${p.note || 'no note'})` ? `revert-proof failed: tests still passed with the fix reverted (${p.note || "no note"})`
: !p.greenObserved : !p.greenObserved
? `tests did not pass after restoring the fix (${p.note || 'no note'})` ? `tests did not pass after restoring the fix (${p.note || "no note"})`
: `not committed (${p.note || 'no note'})` : `not committed (${p.note || "no note"})`;
log(`prove ${cluster.file}: ${why} - ${ids.length} finding(s) blocked`) log(`prove ${cluster.file}: ${why} - ${ids.length} finding(s) blocked`);
for (const r of results) { for (const r of results) {
if (r.outcome === 'fixed') { if (r.outcome === "fixed") {
r.outcome = 'blocked' r.outcome = "blocked";
r.rationale = why r.rationale = why;
} }
} }
proveFailures++ proveFailures++;
if (BREAKER && proveAttempts >= BREAKER.minAttempts && proveFailures / proveAttempts > BREAKER.threshold) { if (
BREAKER &&
proveAttempts >= BREAKER.minAttempts &&
proveFailures / proveAttempts > BREAKER.threshold
) {
breaker = { breaker = {
trippedAt: 'prove', trippedAt: "prove",
attempted: proveAttempts, attempted: proveAttempts,
failed: proveFailures, failed: proveFailures,
threshold: BREAKER.threshold, threshold: BREAKER.threshold,
reason: `${proveFailures}/${proveAttempts} cluster(s) failed their revert-proof - stopping before the rest`, reason: `${proveFailures}/${proveAttempts} cluster(s) failed their revert-proof - stopping before the rest`,
} };
log(`CIRCUIT BREAKER: ${breaker.reason}`) log(`CIRCUIT BREAKER: ${breaker.reason}`);
} }
continue continue;
} }
commits.push({ sha: p.sha, file: cluster.file, ids }) commits.push({ sha: p.sha, file: cluster.file, ids });
log(`prove ${cluster.file}: committed ${p.sha} (${ids.join(', ')})`) log(`prove ${cluster.file}: committed ${p.sha} (${ids.join(", ")})`);
} }
// ---------- phase 4: gate ---------- // ---------- phase 4: gate ----------
const GATE_RESULT = { const GATE_RESULT = {
type: 'object', type: "object",
required: ['passed', 'stacks', 'output'], required: ["passed", "stacks", "output"],
properties: { properties: {
passed: { type: 'boolean' }, passed: { type: "boolean" },
stacks: { type: 'array', items: { type: 'string' } }, stacks: { type: "array", items: { type: "string" } },
output: { type: 'string', description: 'the failing command and its output, or a short ok summary' }, output: {
type: "string",
description: "the failing command and its output, or a short ok summary",
},
}, },
} };
function stacksFor(files) { function stacksFor(files) {
const s = new Set() const s = new Set();
for (const f of files) { for (const f of files) {
if (f.startsWith('Server/')) s.add('server') if (f.startsWith("Server/")) s.add("server");
else if (f.startsWith('Client/src-tauri/')) s.add('rust') else if (f.startsWith("Client/src-tauri/")) s.add("rust");
else if (f.startsWith('Client/')) s.add('client') else if (f.startsWith("Client/")) s.add("client");
} }
return [...s] return [...s];
} }
const GATE_COMMANDS = { const GATE_COMMANDS = {
@@ -459,35 +509,45 @@ const GATE_COMMANDS = {
`"go run ./scripts/genprotocol && git diff --exit-code ws/message_types.go ../Client/src/lib/protocolTypes.ts" ` + `"go run ./scripts/genprotocol && git diff --exit-code ws/message_types.go ../Client/src/lib/protocolTypes.ts" ` +
`- a non-empty diff in either means generated code is stale and the gate fails`, `- a non-empty diff in either means generated code is stale and the gate fails`,
rust: rust:
`From Client/src-tauri:\n` + `From Client/src-tauri:\n` + ` cargo test\n` + ` cargo clippy --all-targets -- -D warnings`,
` cargo test\n` + };
` cargo clippy --all-targets -- -D warnings`,
}
let gate = null let gate = null;
if (commits.length) { if (commits.length) {
phase('Gate') phase("Gate");
const stacks = stacksFor(commits.map((c) => c.file)) const stacks = stacksFor(commits.map((c) => c.file));
gate = await agent( gate = await agent(
`Run the OwnCord CI gates locally for the stacks touched by this fix run, on branch ${BRANCH}.\n\n` + `Run the OwnCord CI gates locally for the stacks touched by this fix run, on branch ${BRANCH}.\n\n` +
`This runs ONCE for the whole run - a full gate per fix would take longer than the fixing did.\n\n` + `This runs ONCE for the whole run - a full gate per fix would take longer than the fixing did.\n\n` +
`Touched stacks: ${stacks.join(', ')}\n\n` + `Touched stacks: ${stacks.join(", ")}\n\n` +
stacks.map((s) => GATE_COMMANDS[s]).join('\n\n') + stacks.map((s) => GATE_COMMANDS[s]).join("\n\n") +
`\n\nRun every command for every touched stack. Report passed=false if ANY of them fails, and put ` + `\n\nRun every command for every touched stack. Report passed=false if ANY of them fails, and put ` +
`the failing command plus the relevant output in "output". Do NOT fix anything, do NOT amend or ` + `the failing command plus the relevant output in "output". Do NOT fix anything, do NOT amend or ` +
`revert any commit, and do NOT push. Reporting the failure accurately is the whole job.\n\n` + `revert any commit, and do NOT push. Reporting the failure accurately is the whole job.\n\n` +
`Known false alarm: a windows -race failure inside ws whose stack mentions runtime.scanstack or ` + `Known false alarm: a windows -race failure inside ws whose stack mentions runtime.scanstack or ` +
`runtime.(*unwinder).next is a Go 1.26.5 runtime GC fault, not a real failure - rerun that package ` + `runtime.(*unwinder).next is a Go 1.26.5 runtime GC fault, not a real failure - rerun that package ` +
`once before reporting it.`, `once before reporting it.`,
{ label: 'gate', phase: 'Gate', model: 'sonnet', effort: 'xhigh', schema: GATE_RESULT }, { label: "gate", phase: "Gate", model: "sonnet", effort: "xhigh", schema: GATE_RESULT },
).catch(() => null) ).catch(() => null);
// A malformed/missing report (dead agent, or a schema the caller didn't honor) is treated as a // A malformed/missing report (dead agent, or a schema the caller didn't honor) is treated as a
// failed gate, same as the null-check pattern in phases 2 and 3 - never crash on shape here. // failed gate, same as the null-check pattern in phases 2 and 3 - never crash on shape here.
if (!gate || typeof gate.passed !== 'boolean' || !Array.isArray(gate.stacks)) if (!gate || typeof gate.passed !== "boolean" || !Array.isArray(gate.stacks))
gate = { passed: false, stacks, output: (gate && gate.output) || 'gate agent failed to report' } gate = {
log(`gate: ${gate.passed ? 'PASS' : 'FAIL'} (${gate.stacks.join(', ')})`) passed: false,
stacks,
output: (gate && gate.output) || "gate agent failed to report",
};
log(`gate: ${gate.passed ? "PASS" : "FAIL"} (${gate.stacks.join(", ")})`);
} else { } else {
log('gate: nothing committed - skipped') log("gate: nothing committed - skipped");
} }
return { branch: BRANCH, clusters: publicClusters, excluded, commits, results: allResults, gate, breaker } return {
branch: BRANCH,
clusters: publicClusters,
excluded,
commits,
results: allResults,
gate,
breaker,
};
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+30
View File
@@ -0,0 +1,30 @@
# Editor baseline for OwnCord. Pairs with .gitattributes (`* text=auto eol=lf`)
# and the repository Prettier config — all three agree on LF and trailing
# newlines, so an editor that honours this file produces bytes CI accepts.
#
# This is a baseline, not a gate. Prettier, gofmt and rustfmt are what actually
# fail the build; nothing lints this file.
root = true
[*]
charset = utf-8
end_of_line = lf
insert_final_newline = true
trim_trailing_whitespace = true
indent_style = space
indent_size = 2
# gofmt emits tabs and is the authority for Go.
[*.go]
indent_style = tab
[{go.mod,go.sum}]
indent_style = tab
# rustfmt default profile.
[*.rs]
indent_size = 4
# Recipe lines are tab-significant to make(1).
[Makefile]
indent_style = tab
+14 -3
View File
@@ -23,7 +23,8 @@ fail() {
go_staged=$(printf '%s\n' "$staged" | grep '^Server/.*\.go$' | grep -v '^Server/db/dbgen/') go_staged=$(printf '%s\n' "$staged" | grep '^Server/.*\.go$' | grep -v '^Server/db/dbgen/')
if [ -n "$go_staged" ]; then if [ -n "$go_staged" ]; then
if command -v go >/dev/null 2>&1 && command -v gofmt >/dev/null 2>&1; then if command -v go >/dev/null 2>&1 && command -v gofmt >/dev/null 2>&1; then
# shellcheck disable=SC2086 — repo paths contain no spaces # Word splitting is intended: repo paths contain no spaces.
# shellcheck disable=SC2086
unformatted=$(gofmt -l $go_staged) unformatted=$(gofmt -l $go_staged)
[ -n "$unformatted" ] && fail "gofmt needed (run gofmt -w on): $unformatted" [ -n "$unformatted" ] && fail "gofmt needed (run gofmt -w on): $unformatted"
(cd Server && go vet ./...) || fail "go vet" (cd Server && go vet ./...) || fail "go vet"
@@ -59,6 +60,18 @@ if printf '%s\n' "$staged" | grep -qE '^(docs/protocol-schema\.json|Server/scrip
fi fi
fi fi
# ---------- Formatting (repository-wide) ----------
# Prettier is configured once at the repository root (.prettierrc.json) and
# covers every material tracked source, not just client TypeScript.
# --ignore-unknown drops the Go/Rust/binary paths it has no parser for.
if [ -d node_modules ]; then
# Word splitting is intended: repo paths contain no spaces.
# shellcheck disable=SC2086
npx prettier --check --ignore-unknown $staged || fail "prettier (run: npm run format)"
else
printf 'pre-commit: WARNING: node_modules missing at the repository root; skipping prettier.\n' >&2
fi
# ---------- Client (TypeScript) ---------- # ---------- Client (TypeScript) ----------
ts_staged=$(printf '%s\n' "$staged" | grep -E '^Client/(src|tests)/.*\.ts$' | grep -v '/generated/') ts_staged=$(printf '%s\n' "$staged" | grep -E '^Client/(src|tests)/.*\.ts$' | grep -v '/generated/')
if [ -n "$ts_staged" ]; then if [ -n "$ts_staged" ]; then
@@ -69,8 +82,6 @@ if [ -n "$ts_staged" ]; then
cd Client || exit 1 cd Client || exit 1
# shellcheck disable=SC2086 # shellcheck disable=SC2086
npx oxlint $rel || fail "oxlint" npx oxlint $rel || fail "oxlint"
# shellcheck disable=SC2086
npx prettier --check $rel || fail "prettier (run: npm run format)"
npm run -s typecheck || fail "tsc --noEmit" npm run -s typecheck || fail "tsc --noEmit"
cd "$repo_root" || exit 1 cd "$repo_root" || exit 1
fi fi
+54 -4
View File
@@ -159,9 +159,6 @@ jobs:
- name: ESLint (type-aware rules) - name: ESLint (type-aware rules)
run: npx eslint src/ run: npx eslint src/
- name: Prettier format check
run: npx prettier --check "src/**/*.ts" "tests/**/*.ts"
- name: Knip (unused code & deps) - name: Knip (unused code & deps)
# Blocking since the 2026-08-04 remediation: the '|| true' era let a # Blocking since the 2026-08-04 remediation: the '|| true' era let a
# real unused-export finding sit invisible in every green run. # real unused-export finding sit invisible in every green run.
@@ -196,6 +193,54 @@ jobs:
- name: Ledger schema is valid - name: Ledger schema is valid
run: node .superpowers/render-ledger.mjs --check run: node .superpowers/render-ledger.mjs --check
# Repository-wide formatting, script lint and workflow lint (RL-19 / L-13, S-05).
#
# Root-scoped and ubuntu-only for the same reason as docs-consistency above:
# every gate here is platform-independent text analysis, and .gitattributes
# pins eol=lf so a second OS would only re-prove line endings.
#
# Prettier lives here rather than in client-check because it is no longer a
# client gate -- one config at the repository root covers Markdown, YAML,
# JSON, CSS and the root scripts as well as client TypeScript.
hygiene:
name: Repository Hygiene
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version: 24
# Root install only -- prettier is the sole dependency this job needs, and
# the client's install is client-check's job.
- name: Install root dependencies
run: npm ci
# shellcheck ships in the ubuntu runner image. actionlint does not, so it
# is pinned by version and checked by digest: an unpinned installer script
# would be the one unverified download in a workflow file that pins every
# action by commit SHA.
- name: Install actionlint
env:
ACTIONLINT_VERSION: 1.7.7
ACTIONLINT_SHA256: 023070a287cd8cccd71515fedc843f1985bf96c436b7effaecce67290e7e0757
run: |
set -euo pipefail
url="https://github.com/rhysd/actionlint/releases/download/v${ACTIONLINT_VERSION}/actionlint_${ACTIONLINT_VERSION}_linux_amd64.tar.gz"
curl -sSfL --retry 3 -o "$RUNNER_TEMP/actionlint.tar.gz" "$url"
echo "$ACTIONLINT_SHA256 $RUNNER_TEMP/actionlint.tar.gz" | sha256sum -c -
tar -xzf "$RUNNER_TEMP/actionlint.tar.gz" -C "$RUNNER_TEMP" actionlint
echo "$RUNNER_TEMP" >> "$GITHUB_PATH"
- name: Report tool versions
run: shellcheck --version && actionlint --version
# The same entry point a contributor runs. run.mjs takes its shellcheck and
# actionlint file lists from `git ls-files`, never a filesystem glob.
- name: Formatting, shell and workflow gates
run: npm run check:hygiene
client-tests: client-tests:
name: Client Unit Tests name: Client Unit Tests
runs-on: ubuntu-latest runs-on: ubuntu-latest
@@ -255,13 +300,18 @@ jobs:
- name: Install Rust - name: Install Rust
uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable
with: with:
components: clippy components: clippy, rustfmt
- name: Rust cache - name: Rust cache
uses: swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 uses: swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
with: with:
workspaces: Client/src-tauri workspaces: Client/src-tauri
# Ahead of clippy: a formatting failure is cheap to produce and cheap to
# fix, and there is no reason to spend a clippy pass to surface one.
- name: Rustfmt check
run: cargo fmt --all -- --check
- name: Clippy lint (including test targets) - name: Clippy lint (including test targets)
run: cargo clippy --all-targets -- -D warnings run: cargo clippy --all-targets -- -D warnings
-1
View File
@@ -47,4 +47,3 @@ jobs:
# See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md # See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md
# or https://code.claude.com/docs/en/cli-reference for available options # or https://code.claude.com/docs/en/cli-reference for available options
# claude_args: '--allowed-tools Bash(gh pr:*)' # claude_args: '--allowed-tools Bash(gh pr:*)'
+12 -3
View File
@@ -67,19 +67,28 @@ jobs:
TOKEN=$(curl -sk -X POST "$BASE/admin/api/setup" \ TOKEN=$(curl -sk -X POST "$BASE/admin/api/setup" \
-H 'Content-Type: application/json' \ -H 'Content-Type: application/json' \
-d '{"username":"loadadmin","password":"LoadTest123!Admin"}' | jq -r .token) -d '{"username":"loadadmin","password":"LoadTest123!Admin"}' | jq -r .token)
[ -n "$TOKEN" ] && [ "$TOKEN" != "null" ] || { echo "::error::setup failed"; exit 1; } if [ -z "$TOKEN" ] || [ "$TOKEN" = "null" ]; then
echo "::error::setup failed"
exit 1
fi
CHANNEL_ID=$(curl -sk -X POST "$BASE/admin/api/channels" \ CHANNEL_ID=$(curl -sk -X POST "$BASE/admin/api/channels" \
-H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \ -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
-d '{"name":"loadtest","type":"text"}' | jq -r .id) -d '{"name":"loadtest","type":"text"}' | jq -r .id)
[ -n "$CHANNEL_ID" ] && [ "$CHANNEL_ID" != "null" ] || { echo "::error::channel create failed"; exit 1; } if [ -z "$CHANNEL_ID" ] || [ "$CHANNEL_ID" = "null" ]; then
echo "::error::channel create failed"
exit 1
fi
echo "CHANNEL_ID=$CHANNEL_ID" >> "$GITHUB_ENV" echo "CHANNEL_ID=$CHANNEL_ID" >> "$GITHUB_ENV"
echo "ADMIN_TOKEN=$TOKEN" >> "$GITHUB_ENV" echo "ADMIN_TOKEN=$TOKEN" >> "$GITHUB_ENV"
INVITE=$(curl -sk -X POST "$BASE/api/v1/invites" \ INVITE=$(curl -sk -X POST "$BASE/api/v1/invites" \
-H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \ -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
-d '{"max_uses":0}' | jq -r .code) -d '{"max_uses":0}' | jq -r .code)
[ -n "$INVITE" ] && [ "$INVITE" != "null" ] || { echo "::error::invite create failed"; exit 1; } if [ -z "$INVITE" ] || [ "$INVITE" = "null" ]; then
echo "::error::invite create failed"
exit 1
fi
USERS="${{ inputs.users }}" USERS="${{ inputs.users }}"
for i in $(seq 1 "${USERS:-100}"); do for i in $(seq 1 "${USERS:-100}"); do
+10 -3
View File
@@ -454,7 +454,14 @@ jobs:
publish: publish:
name: Publish GitHub Release name: Publish GitHub Release
needs: [release-client-windows, release-client-linux, release-client-linux-arm64, release-server, release-server-docker] needs:
[
release-client-windows,
release-client-linux,
release-client-linux-arm64,
release-server,
release-server-docker,
]
runs-on: ubuntu-latest runs-on: ubuntu-latest
permissions: permissions:
contents: write contents: write
@@ -515,8 +522,8 @@ jobs:
- name: Generate SHA256 checksums - name: Generate SHA256 checksums
shell: bash shell: bash
run: | run: |
(cd windows && sha256sum *) > checksums.sha256 (cd windows && sha256sum -- *) > checksums.sha256
(cd linux && sha256sum *) >> checksums.sha256 (cd linux && sha256sum -- *) >> checksums.sha256
sha256sum owncord-src-*.tar.gz >> checksums.sha256 sha256sum owncord-src-*.tar.gz >> checksums.sha256
# The legacy top-level asset/sha256 pair stays bound to the Windows # The legacy top-level asset/sha256 pair stays bound to the Windows
+42
View File
@@ -0,0 +1,42 @@
# Prettier 3 reads .gitignore by default, so everything ignored there —
# node_modules/, dist/, coverage/, Client/src/generated/, docs/security-findings/ —
# is already excluded. Only tracked files need entries here.
# Generated, verified by `git diff --exit-code` after regeneration.
Server/db/dbgen/
Client/src/lib/protocolTypes.ts
# Rendered from findings-ledger.json by render-ledger.mjs.
.superpowers/FINDINGS.md
# Dated point-in-time snapshots. scripts/check-doc-counts.mjs already treats
# these as deliberately unmaintained and out of scope to edit; reformatting
# them would churn frozen records for no reader.
docs/audit-*.md
# Carried forward from the client's own ignore file — a deliberate exclusion,
# not an oversight.
*.html
# Session scratch from the remember plugin. Gitignored by a nested
# .remember/.gitignore, which Prettier does not read — it honours only the root
# .gitignore. Untracked and per-machine: a contributor's scratch directory must
# never be able to turn a shared gate red.
.remember/
**/.remember/
# Build output and per-tool scratch. Every path below is gitignored -- but by a
# NESTED .gitignore, and Prettier honours only the root one. Without these
# entries the gate goes red the moment a contributor runs a build: `cargo test`
# alone drops ~850 formattable files into src-tauri/target/.
# Mirrors Client/.gitignore, .serena/.gitignore and .superpowers/sdd/.gitignore.
Client/dist/
Client/coverage/
Client/playwright-report/
Client/test-results/
Client/.vite/
Client/src-tauri/target/
Client/src-tauri/gen/
.serena/
.superpowers/sdd/
+9
View File
@@ -0,0 +1,9 @@
{
"singleQuote": false,
"semi": true,
"trailingComma": "all",
"printWidth": 100,
"tabWidth": 2,
"arrowParens": "always",
"endOfLine": "lf"
}
File diff suppressed because it is too large Load Diff
+89 -69
View File
@@ -2,102 +2,122 @@
// Run: node .superpowers/render-ledger.mjs # write FINDINGS.md // Run: node .superpowers/render-ledger.mjs # write FINDINGS.md
// node .superpowers/render-ledger.mjs --check # validate only // node .superpowers/render-ledger.mjs --check # validate only
// node .superpowers/render-ledger.mjs --selftest # run built-in tests // node .superpowers/render-ledger.mjs --selftest # run built-in tests
import assert from 'node:assert/strict' import assert from "node:assert/strict";
const VALID_STATUS = ['open', 'fixed', 'declined', 'refuted', 'duplicate', 'blocked'] const VALID_STATUS = ["open", "fixed", "declined", "refuted", "duplicate", "blocked"];
export function validate(ledger) { export function validate(ledger) {
const problems = [] const problems = [];
const ids = new Set() const ids = new Set();
for (const r of ledger.findings) { for (const r of ledger.findings) {
if (ids.has(r.id)) problems.push(`duplicate id ${r.id}`) if (ids.has(r.id)) problems.push(`duplicate id ${r.id}`);
ids.add(r.id) ids.add(r.id);
if (!/^OC-\d{4}$/.test(r.id)) problems.push(`${r.id}: malformed id`) if (!/^OC-\d{4}$/.test(r.id)) problems.push(`${r.id}: malformed id`);
if (!VALID_STATUS.includes(r.status)) problems.push(`${r.id}: bad status ${r.status}`) if (!VALID_STATUS.includes(r.status)) problems.push(`${r.id}: bad status ${r.status}`);
if (r.status === 'fixed' && (!r.fix || !r.fix.commit)) problems.push(`${r.id}: fixed without a commit`) if (r.status === "fixed" && (!r.fix || !r.fix.commit))
if (r.status === 'declined' && !r.rationale) problems.push(`${r.id}: declined without a rationale`) problems.push(`${r.id}: fixed without a commit`);
if (r.status === 'duplicate' && !r.duplicateOf) problems.push(`${r.id}: duplicate without duplicateOf`) if (r.status === "declined" && !r.rationale)
problems.push(`${r.id}: declined without a rationale`);
if (r.status === "duplicate" && !r.duplicateOf)
problems.push(`${r.id}: duplicate without duplicateOf`);
} }
return problems return problems;
} }
function selftest() { function selftest() {
assert.deepEqual(validate({ findings: [] }), []) assert.deepEqual(validate({ findings: [] }), []);
assert.deepEqual(validate({ findings: [{ id: "OC-0001", status: "fixed", fix: null }] }), [
"OC-0001: fixed without a commit",
]);
assert.deepEqual(validate({ findings: [{ id: "bad", status: "open" }] }), ["bad: malformed id"]);
assert.deepEqual( assert.deepEqual(
validate({ findings: [{ id: 'OC-0001', status: 'fixed', fix: null }] }), validate({
['OC-0001: fixed without a commit'], findings: [
) { id: "OC-0001", status: "open" },
assert.deepEqual(validate({ findings: [{ id: 'bad', status: 'open' }] }), ['bad: malformed id']) { id: "OC-0001", status: "open" },
assert.deepEqual( ],
validate({ findings: [{ id: 'OC-0001', status: 'open' }, { id: 'OC-0001', status: 'open' }] }), }),
['duplicate id OC-0001'], ["duplicate id OC-0001"],
) );
assert.deepEqual(validate({ findings: [{ id: 'OC-0002', status: 'declined' }] }), ['OC-0002: declined without a rationale']) assert.deepEqual(validate({ findings: [{ id: "OC-0002", status: "declined" }] }), [
console.log('selftest: all assertions pass') "OC-0002: declined without a rationale",
]);
console.log("selftest: all assertions pass");
} }
const SEV_RANK = { critical: 0, high: 1, medium: 2, low: 3 } const SEV_RANK = { critical: 0, high: 1, medium: 2, low: 3 };
export function render(ledger) { export function render(ledger) {
const by = (s) => ledger.findings.filter((f) => f.status === s) const by = (s) => ledger.findings.filter((f) => f.status === s);
const open = by('open').sort((a, b) => SEV_RANK[a.severity] - SEV_RANK[b.severity]) const open = by("open").sort((a, b) => SEV_RANK[a.severity] - SEV_RANK[b.severity]);
const blocked = by('blocked') const blocked = by("blocked");
const fixed = by('fixed') const fixed = by("fixed");
const declined = by('declined') const declined = by("declined");
const refuted = by('refuted') const refuted = by("refuted");
const dup = by('duplicate') const dup = by("duplicate");
const lines = [] const lines = [];
lines.push('# OwnCord Findings Ledger', '') lines.push("# OwnCord Findings Ledger", "");
lines.push('Generated by `render-ledger.mjs`. Do not hand-edit — edit `findings-ledger.json`.', '') lines.push(
"Generated by `render-ledger.mjs`. Do not hand-edit — edit `findings-ledger.json`.",
"",
);
lines.push( lines.push(
`**${open.length} open** · ${blocked.length} blocked · ${fixed.length} fixed · ` + `**${open.length} open** · ${blocked.length} blocked · ${fixed.length} fixed · ` +
`${declined.length} declined · ${refuted.length} refuted · ${dup.length} duplicate`, `${declined.length} declined · ${refuted.length} refuted · ${dup.length} duplicate`,
'', "",
) );
const section = (title, rows, extra) => { const section = (title, rows, extra) => {
if (!rows.length) return if (!rows.length) return;
lines.push(`## ${title}`, '') lines.push(`## ${title}`, "");
for (const r of rows) { for (const r of rows) {
lines.push(`### ${r.id}${r.severity}${r.title}`, '') lines.push(`### ${r.id}${r.severity}${r.title}`, "");
lines.push(`\`${r.file}:${r.line}\` · found ${r.found} · hunt \`${r.hunt}\` · lens \`${r.lens}\``, '') lines.push(
if (r.why) lines.push(r.why, '') `\`${r.file}:${r.line}\` · found ${r.found} · hunt \`${r.hunt}\` · lens \`${r.lens}\``,
if (r.repro) lines.push(`**Repro:** ${r.repro}`, '') "",
if (r.evidence) lines.push(`**Evidence:** ${r.evidence}`, '') );
if (r.suggestedFix) lines.push(`**Suggested fix:** ${r.suggestedFix}`, '') if (r.why) lines.push(r.why, "");
const e = extra && extra(r) if (r.repro) lines.push(`**Repro:** ${r.repro}`, "");
if (e) lines.push(e, '') if (r.evidence) lines.push(`**Evidence:** ${r.evidence}`, "");
if (r.suggestedFix) lines.push(`**Suggested fix:** ${r.suggestedFix}`, "");
const e = extra && extra(r);
if (e) lines.push(e, "");
} }
} };
section('Open', open) section("Open", open);
section('Blocked — fix attempted, revert-proof failed', blocked) section("Blocked — fix attempted, revert-proof failed", blocked);
section('Fixed', fixed, (r) => `**Fixed:** \`${r.fix.commit}\` · test \`${r.fix.test}\` · revert-proof ${r.fix.revertProof}`) section(
section('Declined', declined, (r) => `**Declined:** ${r.rationale}`) "Fixed",
section('Refuted', refuted) fixed,
section('Duplicate', dup, (r) => `**Duplicate of** ${r.duplicateOf}`) (r) =>
return lines.join('\n') `**Fixed:** \`${r.fix.commit}\` · test \`${r.fix.test}\` · revert-proof ${r.fix.revertProof}`,
);
section("Declined", declined, (r) => `**Declined:** ${r.rationale}`);
section("Refuted", refuted);
section("Duplicate", dup, (r) => `**Duplicate of** ${r.duplicateOf}`);
return lines.join("\n");
} }
async function main() { async function main() {
const { readFileSync, writeFileSync } = await import('node:fs') const { readFileSync, writeFileSync } = await import("node:fs");
const { dirname, join } = await import('node:path') const { dirname, join } = await import("node:path");
const { fileURLToPath } = await import('node:url') const { fileURLToPath } = await import("node:url");
const here = dirname(fileURLToPath(import.meta.url)) const here = dirname(fileURLToPath(import.meta.url));
const ledger = JSON.parse(readFileSync(join(here, 'findings-ledger.json'), 'utf8')) const ledger = JSON.parse(readFileSync(join(here, "findings-ledger.json"), "utf8"));
const problems = validate(ledger) const problems = validate(ledger);
if (problems.length) { if (problems.length) {
for (const p of problems) console.error(`INVALID ${p}`) for (const p of problems) console.error(`INVALID ${p}`);
process.exit(1) process.exit(1);
} }
if (process.argv.includes('--check')) { if (process.argv.includes("--check")) {
console.log(`ledger valid: ${ledger.findings.length} finding(s)`) console.log(`ledger valid: ${ledger.findings.length} finding(s)`);
return return;
} }
writeFileSync(join(here, 'FINDINGS.md'), render(ledger) + '\n') writeFileSync(join(here, "FINDINGS.md"), render(ledger) + "\n");
console.log(`wrote FINDINGS.md (${ledger.findings.length} finding(s))`) console.log(`wrote FINDINGS.md (${ledger.findings.length} finding(s))`);
} }
if (process.argv.includes('--selftest')) selftest() if (process.argv.includes("--selftest")) selftest();
else await main() else await main();
+13 -13
View File
@@ -19,7 +19,7 @@ behavioural changes operators must know about.
`room.setE2EEEnabled(true)`, so every audio and video frame reached the `room.setE2EEEnabled(true)`, so every audio and video frame reached the
SFU in plaintext. It is enabled now, and a dead E2EE worker is no longer SFU in plaintext. It is enabled now, and a dead E2EE worker is no longer
invisible to the Secured badge. Related voice-crypto fixes: a joining key invisible to the Secured badge. Related voice-crypto fixes: a joining key
holder sent its room-key offers *before* its own announce, so existing holder sent its room-key offers _before_ its own announce, so existing
participants dropped them as "unknown peer" (#1370, #1374); rotation participants dropped them as "unknown peer" (#1370, #1374); rotation
offers exceeded the server rate limit in large channels and permanently offers exceeded the server rate limit in large channels and permanently
starved the same peers; both rotation paths and the reconnect-to-Secured starved the same peers; both rotation paths and the reconnect-to-Secured
@@ -37,18 +37,18 @@ behavioural changes operators must know about.
reaction, pin, purge, delete and `channel_focus` still mutated or reaction, pin, purge, delete and `channel_focus` still mutated or
subscribed to archived channels (every write sink now routes through one subscribed to archived channels (every write sink now routes through one
`requireChannelWritable` gate); `EditMessage` and `handleReaction` DM `requireChannelWritable` gate); `EditMessage` and `handleReaction` DM
detection failed *open* on a `GetChannel` error, skipping the block gate; detection failed _open_ on a `GetChannel` error, skipping the block gate;
group-DM creation only block-checked the creator, letting a third party group-DM creation only block-checked the creator, letting a third party
force two users who blocked each other into a shared room; an invisible force two users who blocked each other into a shared room; an invisible
user's real custom status leaked on both presence emitters; `PATCH user's real custom status leaked on both presence emitters; `PATCH
/users/{id}` with `banned` + `role_id` committed and broadcast the ban /users/{id}` with `banned` + `role_id` committed and broadcast the ban
before authorizing the role change; admin API-token creation accepted a before authorizing the role change; admin API-token creation accepted a
negative `expires_hours` and minted a token that never expires; upload negative `expires_hours` and minted a token that never expires; upload
rejections echoed raw storage errors (absolute server paths) to any rejections echoed raw storage errors (absolute server paths) to any
authenticated user; the GIF proxy's log redaction missed the authenticated user; the GIF proxy's log redaction missed the
percent-encoded API key; `chat_command` was the only client message type percent-encoded API key; `chat_command` was the only client message type
without a rate limiter while each frame ran a WASM plugin invocation; and without a rate limiter while each frame ran a WASM plugin invocation; and
the login and typing rate limiters built their keys from *unvalidated* the login and typing rate limiters built their keys from _unvalidated_
input, letting an unauthenticated caller pin unbounded heap for six hours. input, letting an unauthenticated caller pin unbounded heap for six hours.
- **fix(auth):** accounts whose username contains `'`, `"` or `&` were - **fix(auth):** accounts whose username contains `'`, `"` or `&` were
permanently unloggable — registration HTML-escaped the name but login did permanently unloggable — registration HTML-escaped the name but login did
@@ -60,7 +60,7 @@ behavioural changes operators must know about.
reverse-proxy address as the session IP. reverse-proxy address as the session IP.
- **server:** WS hub, reconnect and replay (#1369, #1371, #1372, #1374, - **server:** WS hub, reconnect and replay (#1369, #1371, #1372, #1374,
#1375) — REST DM events never bumped the visibility watermark, while #1375) — REST DM events never bumped the visibility watermark, while
*every* ordinary DM message re-emitted `dm_channel_open` and bumped the _every_ ordinary DM message re-emitted `dm_channel_open` and bumped the
global watermark, forcing every other client's next reconnect into a full global watermark, forcing every other client's next reconnect into a full
resync; the client's `lastSeq` was never reset by a full-ready resync and resync; the client's `lastSeq` was never reset by a full-ready resync and
desynced permanently; cold-tier replay had no interior-gap detection, so desynced permanently; cold-tier replay had no interior-gap detection, so
@@ -79,7 +79,7 @@ behavioural changes operators must know about.
into a permanent hub/SFU ghost no sweep could heal; the stale-state sweep into a permanent hub/SFU ghost no sweep could heal; the stale-state sweep
could delete a just-committed join's row, leaving the client in voice with could delete a just-committed join's row, leaving the client in voice with
no DB row; `handleVoiceJoin` handed out a live 5-minute LiveKit credential no DB row; `handleVoiceJoin` handed out a live 5-minute LiveKit credential
*after* a concurrent kick/move/revocation had already torn the membership _after_ a concurrent kick/move/revocation had already torn the membership
down (the token is now withheld); the `participant_left` webhook never down (the token is now withheld); the `participant_left` webhook never
told the leaver, and a transient DB read error on `participant_joined` told the leaver, and a transient DB read error on `participant_joined`
ejected a legitimate participant mid-call; `voice_mod_move` lacked the ejected a legitimate participant mid-call; `voice_mod_move` lacked the
@@ -152,7 +152,7 @@ behavioural changes operators must know about.
create/edit/delete modals locked up permanently on an API failure; login create/edit/delete modals locked up permanently on an API failure; login
to an IPv6-literal host was impossible; a host stored with an explicit to an IPv6-literal host was impossible; a host stored with an explicit
`:443` lost its bearer token and cert-pinned proxy on attachment fetches; `:443` lost its bearer token and cert-pinned proxy on attachment fetches;
one malformed stored server profile discarded *all* saved profiles; a one malformed stored server profile discarded _all_ saved profiles; a
banned/revoked token reconnected forever if the session ended before banned/revoked token reconnected forever if the session ended before
MainPage mounted; a previous server's block list, collapsed categories and MainPage mounted; a previous server's block list, collapsed categories and
DM notes bled into the next server; the Rust HTTP proxy tunnel's data DM notes bled into the next server; the Rust HTTP proxy tunnel's data
@@ -208,7 +208,7 @@ behavioural changes operators must know about.
- **deploy:** new `chatserver healthcheck` subcommand probes `/health` - **deploy:** new `chatserver healthcheck` subcommand probes `/health`
pinning the server's own certificate from disk (WebPKI when none exists, pinning the server's own certificate from disk (WebPKI when none exists,
i.e. ACME) and is now the docker-compose healthcheck — the distroless i.e. ACME) and is now the docker-compose healthcheck — the distroless
image has no shell; plain `docker compose` only *surfaces* unhealthy, pair image has no shell; plain `docker compose` only _surfaces_ unhealthy, pair
it with a watchdog for auto-restart. Compose gains json-file log rotation it with a watchdog for auto-restart. Compose gains json-file log rotation
(`10m` × 3) on both services. `release.yml` now cold-boots the freshly (`10m` × 3) on both services. `release.yml` now cold-boots the freshly
built server binaries and Docker image and probes them healthy **before built server binaries and Docker image and probes them healthy **before
@@ -305,7 +305,7 @@ behavioural changes operators must know about.
incorrectly documented all presence events as sequenced. Older incorrectly documented all presence events as sequenced. Older
clients/servers are unaffected — it is a new, ignorable field. clients/servers are unaffected — it is a new, ignorable field.
- **security(client):** identity/TOFU and transport (#1332) — an in-flight - **security(client):** identity/TOFU and transport (#1332) — an in-flight
change to scope the identity keypair by host *and* user id would have change to scope the identity keypair by host _and_ user id would have
re-minted a fresh key on every existing install, firing the TOFU "verify re-minted a fresh key on every existing install, firing the TOFU "verify
out-of-band" re-pin warning at the entire alpha population simultaneously, out-of-band" re-pin warning at the entire alpha population simultaneously,
exactly the pattern that teaches users to click through the one warning exactly the pattern that teaches users to click through the one warning
@@ -315,7 +315,7 @@ behavioural changes operators must know about.
bearer token forward into the next login request; `api.setConfig` now bearer token forward into the next login request; `api.setConfig` now
drops it when the host changes without a replacement. A hand-copied, drops it when the host changes without a replacement. A hand-copied,
un-lowercased host normalizer in `main.ts` meant an uppercase hostname's un-lowercased host normalizer in `main.ts` meant an uppercase hostname's
cert-mismatch *reject* path skipped `disconnect()`/`clearAuth()`, leaving cert-mismatch _reject_ path skipped `disconnect()`/`clearAuth()`, leaving
a user who refused a changed certificate still connected to that server — a user who refused a changed certificate still connected to that server —
the single lowercased implementation in `ws.ts` is now shared everywhere. the single lowercased implementation in `ws.ts` is now shared everywhere.
- **fix(client):** voice mic/camera reliability (#1331, #1332) — six - **fix(client):** voice mic/camera reliability (#1331, #1332) — six
@@ -372,7 +372,7 @@ behavioural changes operators must know about.
PUT (A-2026-08-01); the admin channel list/edit/delete surface no longer PUT (A-2026-08-01); the admin channel list/edit/delete surface no longer
sees DM channels, answering 404 for their ids (A-2026-08-02); DM call sees DM channels, answering 404 for their ids (A-2026-08-02); DM call
rings respect blocks like every other DM interaction (A-2026-08-03). rings respect blocks like every other DM interaction (A-2026-08-03).
Behavioural note: deleting a channel override for a *nonexistent* role now Behavioural note: deleting a channel override for a _nonexistent_ role now
returns 404 (was 204), matching PUT. returns 404 (was 204), matching PUT.
- **server:** migration **029** drops the never-used `sounds` table (dead - **server:** migration **029** drops the never-used `sounds` table (dead
since the initial schema; A-2026-07-13). Applies automatically on first since the initial schema; A-2026-07-13). Applies automatically on first
@@ -627,14 +627,14 @@ claimed behaviour — no product code changed and no assertion weakened.
logged (`livekit proxy: origin rejected`) so the next such failure is logged (`livekit proxy: origin rejected`) so the next such failure is
diagnosable from the server log. diagnosable from the server log.
- **API tokens can use the admin log stream.** `POST - **API tokens can use the admin log stream.** `POST
/admin/api/logs/ticket` required a browser login session, so headless /admin/api/logs/ticket` required a browser login session, so headless
clients (the `mcp-introspect` dev tool, bots) could reach every other clients (the `mcp-introspect` dev tool, bots) could reach every other
`/admin/api/*` route but not `server_logs`. Tickets are now bound to `/admin/api/*` route but not `server_logs`. Tickets are now bound to
whichever credential authenticated the request; revoking a token cuts whichever credential authenticated the request; revoking a token cuts
an in-flight stream, exactly as session revocation always has. an in-flight stream, exactly as session revocation always has.
- **The desktop client now actually uses the OS credential store.** The - **The desktop client now actually uses the OS credential store.** The
`keyring` crate declares no `default` feature, so the previous `keyring` crate declares no `default` feature, so the previous
`keyring = "3"` dependency compiled its in-memory *mock* store on `keyring = "3"` dependency compiled its in-memory _mock_ store on
Windows, macOS and Linux alike: saves reported success and the next Windows, macOS and Linux alike: saves reported success and the next
read in the same process returned nothing, and no credential was ever read in the same process returned nothing, and no credential was ever
written to Credential Manager / Keychain / Secret Service. The visible written to Credential Manager / Keychain / Secret Service. The visible
+5 -5
View File
@@ -11,11 +11,11 @@ and schema are documented in `docs/protocol.md`, `docs/schema.md`, and
CI fails on drift, and the next generator run silently discards your edit. CI fails on drift, and the next generator run silently discards your edit.
| Generated | Source of truth | Workflow | | Generated | Source of truth | Workflow |
| --- | --- | --- | | ---------------------------------------------------------------------- | ----------------------------------------------- | -------------------------------------------------------------- |
| `Server/db/dbgen/` | `Server/db/queries/*.sql`, `Server/migrations/` | `db-change` skill | | `Server/db/dbgen/` | `Server/db/queries/*.sql`, `Server/migrations/` | `db-change` skill |
| `Server/ws/message_types.go` **and** `Client/src/lib/protocolTypes.ts` | `docs/protocol-schema.json` | `protocol-change` skill | | `Server/ws/message_types.go` **and** `Client/src/lib/protocolTypes.ts` | `docs/protocol-schema.json` | `protocol-change` skill |
| `Client/src/generated/` | `tauri-typegen` | CI patches known typegen bugs — see `.github/workflows/ci.yml` | | `Client/src/generated/` | `tauri-typegen` | CI patches known typegen bugs — see `.github/workflows/ci.yml` |
## Bug-hunt ledger ## Bug-hunt ledger
-6
View File
@@ -1,6 +0,0 @@
dist/
src-tauri/
node_modules/
public/
coverage/
*.html
+1 -1
View File
@@ -23,7 +23,7 @@ Rust backend in `src-tauri/` for native APIs only. LiveKit handles voice/video.
subscription registered there. Other modules do register their own subscription registered there. Other modules do register their own
`ws.on(...)` handlers for page-local UI (`main.ts`, `MainPage.ts`, `ws.on(...)` handlers for page-local UI (`main.ts`, `MainPage.ts`,
`ChannelController.ts` — ringing, overlays, slow-mode timers); that is fine `ChannelController.ts` — ringing, overlays, slow-mode timers); that is fine
as long as they only *read* store state. Writing a store from one of those as long as they only _read_ store state. Writing a store from one of those
handlers is the violation, and `local/no-store-write-in-ws-on` now fails the handlers is the violation, and `local/no-store-write-in-ws-on` now fails the
build on it. build on it.
- Voice sessions are superseded, not cancelled. `LiveKitSession` re-entry - Voice sessions are superseded, not cancelled. `LiveKitSession` re-entry
+2 -8
View File
@@ -2,13 +2,7 @@
"$schema": "https://unpkg.com/knip@6/schema.json", "$schema": "https://unpkg.com/knip@6/schema.json",
"entry": ["src/main.ts"], "entry": ["src/main.ts"],
"project": ["src/**/*.ts"], "project": ["src/**/*.ts"],
"ignore": [ "ignore": ["public/**", "src-tauri/**", "src/lib/protocolTypes.ts"],
"public/**", "ignoreDependencies": ["@tauri-apps/cli"],
"src-tauri/**",
"src/lib/protocolTypes.ts"
],
"ignoreDependencies": [
"@tauri-apps/cli"
],
"ignoreExportsUsedInFile": true "ignoreExportsUsedInFile": true
} }
+4 -17
View File
@@ -36,11 +36,14 @@
"jsdom": "^30.0.1", "jsdom": "^30.0.1",
"knip": "^6.32.2", "knip": "^6.32.2",
"oxlint": "^1.79.0", "oxlint": "^1.79.0",
"prettier": "^3.9.6",
"typescript": "^6.0.3", "typescript": "^6.0.3",
"typescript-eslint": "^8.67.0", "typescript-eslint": "^8.67.0",
"vite": "^8.2.2", "vite": "^8.2.2",
"vitest": "^4.1.11" "vitest": "^4.1.11"
},
"engines": {
"node": ">=24",
"npm": ">=10"
} }
}, },
"node_modules/@asamuzakjp/css-color": { "node_modules/@asamuzakjp/css-color": {
@@ -6114,22 +6117,6 @@
"node": ">= 0.8.0" "node": ">= 0.8.0"
} }
}, },
"node_modules/prettier": {
"version": "3.9.6",
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.9.6.tgz",
"integrity": "sha512-OpN0zzVdiaiAhxpuuj5efpIS4sY9j7bY6uR5mnj5yPzGkdkjNKSJeUThPb60Jw29QuAZgA4o+/iB49kFiaBX6g==",
"dev": true,
"license": "MIT",
"bin": {
"prettier": "bin/prettier.cjs"
},
"engines": {
"node": ">=14"
},
"funding": {
"url": "https://github.com/prettier/prettier?sponsor=1"
}
},
"node_modules/pretty-ms": { "node_modules/pretty-ms": {
"version": "9.3.0", "version": "9.3.0",
"resolved": "https://registry.npmjs.org/pretty-ms/-/pretty-ms-9.3.0.tgz", "resolved": "https://registry.npmjs.org/pretty-ms/-/pretty-ms-9.3.0.tgz",
-12
View File
@@ -29,8 +29,6 @@
"lint": "oxlint src/ && eslint src/", "lint": "oxlint src/ && eslint src/",
"lint:fix": "eslint src/ --fix", "lint:fix": "eslint src/ --fix",
"lint:ox": "oxlint src/", "lint:ox": "oxlint src/",
"format": "prettier --write \"src/**/*.ts\" \"tests/**/*.ts\"",
"format:check": "prettier --check \"src/**/*.ts\" \"tests/**/*.ts\"",
"knip": "knip", "knip": "knip",
"test:mutate": "stryker run", "test:mutate": "stryker run",
"test:mutate:dry": "stryker run --dryRunOnly" "test:mutate:dry": "stryker run --dryRunOnly"
@@ -51,21 +49,11 @@
"jsdom": "^30.0.1", "jsdom": "^30.0.1",
"knip": "^6.32.2", "knip": "^6.32.2",
"oxlint": "^1.79.0", "oxlint": "^1.79.0",
"prettier": "^3.9.6",
"typescript": "^6.0.3", "typescript": "^6.0.3",
"typescript-eslint": "^8.67.0", "typescript-eslint": "^8.67.0",
"vite": "^8.2.2", "vite": "^8.2.2",
"vitest": "^4.1.11" "vitest": "^4.1.11"
}, },
"prettier": {
"singleQuote": false,
"semi": true,
"trailingComma": "all",
"printWidth": 100,
"tabWidth": 2,
"arrowParens": "always",
"endOfLine": "lf"
},
"dependencies": { "dependencies": {
"@jitsi/rnnoise-wasm": "^0.2.1", "@jitsi/rnnoise-wasm": "^0.2.1",
"@tauri-apps/api": "^2.10.1", "@tauri-apps/api": "^2.10.1",
+4 -1
View File
@@ -36,7 +36,10 @@ export default defineConfig({
workers: 1, workers: 1,
retries: 2, retries: 2,
reporter: process.env.CI reporter: process.env.CI
? [["html", { open: "never" }], ["junit", { outputFile: "test-results/native-junit.xml" }]] ? [
["html", { open: "never" }],
["junit", { outputFile: "test-results/native-junit.xml" }],
]
: "html", : "html",
use: { use: {
+4 -1
View File
@@ -19,7 +19,10 @@ export default defineConfig({
retries: process.env.CI ? 2 : 1, retries: process.env.CI ? 2 : 1,
workers: process.env.CI ? 1 : undefined, workers: process.env.CI ? 1 : undefined,
reporter: process.env.CI reporter: process.env.CI
? [["html", { open: "never" }], ["junit", { outputFile: "test-results/junit.xml" }]] ? [
["html", { open: "never" }],
["junit", { outputFile: "test-results/junit.xml" }],
]
: "html", : "html",
use: { use: {
+29 -16
View File
@@ -70,12 +70,18 @@ class RNNoiseProcessor extends AudioWorkletProcessor {
try { try {
// Basic validation: check for expected exports // Basic validation: check for expected exports
const module = await WebAssembly.compile(wasmBytes); const module = await WebAssembly.compile(wasmBytes);
const expectedExports = ['rnnoise_create', 'rnnoise_destroy', 'rnnoise_process_frame', 'malloc', 'free']; const expectedExports = [
const availableExports = WebAssembly.Module.exports(module).map(exp => exp.name); "rnnoise_create",
"rnnoise_destroy",
const hasRequiredExports = expectedExports.every(exp => availableExports.includes(exp)); "rnnoise_process_frame",
"malloc",
"free",
];
const availableExports = WebAssembly.Module.exports(module).map((exp) => exp.name);
const hasRequiredExports = expectedExports.every((exp) => availableExports.includes(exp));
if (!hasRequiredExports) { if (!hasRequiredExports) {
throw new Error('WASM module missing required RNNoise exports'); throw new Error("WASM module missing required RNNoise exports");
} }
const memory = new WebAssembly.Memory({ initial: WASM_MEMORY_INITIAL_PAGES }); const memory = new WebAssembly.Memory({ initial: WASM_MEMORY_INITIAL_PAGES });
@@ -118,10 +124,13 @@ class RNNoiseProcessor extends AudioWorkletProcessor {
if (this._state) exports.rnnoise_destroy(this._state); if (this._state) exports.rnnoise_destroy(this._state);
} catch (cleanupErr) { } catch (cleanupErr) {
// Log cleanup errors but don't override original error // Log cleanup errors but don't override original error
console.warn('Failed to cleanup WASM memory:', cleanupErr); console.warn("Failed to cleanup WASM memory:", cleanupErr);
} }
} }
this._reportError(`WASM initialization failed: ${err instanceof Error ? err.message : String(err)}`, err); this._reportError(
`WASM initialization failed: ${err instanceof Error ? err.message : String(err)}`,
err,
);
} }
} }
@@ -137,11 +146,10 @@ class RNNoiseProcessor extends AudioWorkletProcessor {
const inOff = this._inputPtr / 4; const inOff = this._inputPtr / 4;
const outOff = this._outputPtr / 4; const outOff = this._outputPtr / 4;
// CRITICAL: Bounds check before accessing heap // CRITICAL: Bounds check before accessing heap
if (inOff + FRAME_SIZE > this._heapF32.length || if (inOff + FRAME_SIZE > this._heapF32.length || outOff + FRAME_SIZE > this._heapF32.length) {
outOff + FRAME_SIZE > this._heapF32.length) { console.error("WASM heap bounds exceeded");
console.error('WASM heap bounds exceeded');
return; return;
} }
@@ -179,7 +187,7 @@ class RNNoiseProcessor extends AudioWorkletProcessor {
exports.free(this._inputPtr); exports.free(this._inputPtr);
exports.free(this._outputPtr); exports.free(this._outputPtr);
} catch (err) { } catch (err) {
console.warn('RNNoise cleanup failed:', err); console.warn("RNNoise cleanup failed:", err);
// Continue cleanup even if individual steps fail // Continue cleanup even if individual steps fail
} }
} }
@@ -220,7 +228,13 @@ class RNNoiseProcessor extends AudioWorkletProcessor {
const readStart = this._outReadPos * FRAME_SIZE; const readStart = this._outReadPos * FRAME_SIZE;
const available = FRAME_SIZE - this._outSampleOffset; const available = FRAME_SIZE - this._outSampleOffset;
const toWrite = Math.min(available, outData.length - outIdx); const toWrite = Math.min(available, outData.length - outIdx);
outData.set(this._outBuffer.subarray(readStart + this._outSampleOffset, readStart + this._outSampleOffset + toWrite), outIdx); outData.set(
this._outBuffer.subarray(
readStart + this._outSampleOffset,
readStart + this._outSampleOffset + toWrite,
),
outIdx,
);
outIdx += toWrite; outIdx += toWrite;
this._outSampleOffset += toWrite; this._outSampleOffset += toWrite;
if (this._outSampleOffset >= FRAME_SIZE) { if (this._outSampleOffset >= FRAME_SIZE) {
@@ -243,10 +257,9 @@ class RNNoiseProcessor extends AudioWorkletProcessor {
*/ */
process(inputs, outputs) { process(inputs, outputs) {
if (this._destroyed) return false; if (this._destroyed) return false;
// Validate input/output structure // Validate input/output structure
if (!inputs || !inputs[0] || !inputs[0][0] || if (!inputs || !inputs[0] || !inputs[0][0] || !outputs || !outputs[0] || !outputs[0][0]) {
!outputs || !outputs[0] || !outputs[0][0]) {
return true; // Pass through silence or existing data return true; // Pass through silence or existing data
} }
+4 -4
View File
@@ -21,15 +21,15 @@ class VadProcessor extends AudioWorkletProcessor {
// once per ~16ms poll like the setTimeout fallback. These frame counts // once per ~16ms poll like the setTimeout fallback. These frame counts
// are therefore ~6x the fallback's, so both paths gate on the same // are therefore ~6x the fallback's, so both paths gate on the same
// wall-clock timing. // wall-clock timing.
this._gateOnFrames = 75; // ~200ms of silence before gating this._gateOnFrames = 75; // ~200ms of silence before gating
this._gateOffFrames = 12; // ~32ms of speech before ungating this._gateOffFrames = 12; // ~32ms of speech before ungating
this._silentFrames = 0; this._silentFrames = 0;
this._speechFrames = 0; this._speechFrames = 0;
this._gated = false; this._gated = false;
this._active = true; this._active = true;
this._startupFrames = 0; this._startupFrames = 0;
this._startupGrace = 188; // ~500ms grace period this._startupGrace = 188; // ~500ms grace period
this._frameCounter = 0; // for throttled RMS updates this._frameCounter = 0; // for throttled RMS updates
this.port.onmessage = (event) => { this.port.onmessage = (event) => {
if (event.data.type === "config") { if (event.data.type === "config") {
+1 -3
View File
@@ -1,9 +1,7 @@
{ {
"identifier": "default", "identifier": "default",
"description": "Default capability granting core permissions to the main window. NOTE: http:allow-fetch is the ONLY URL-scoped HTTP identifier — tauri-plugin-http validates the URL once, in the `fetch` command; `fetch_send` and `fetch_read_body` take an already-validated ResourceId and never consult a scope, so allow/deny blocks on those identifiers are inert. Do not re-add them.", "description": "Default capability granting core permissions to the main window. NOTE: http:allow-fetch is the ONLY URL-scoped HTTP identifier — tauri-plugin-http validates the URL once, in the `fetch` command; `fetch_send` and `fetch_read_body` take an already-validated ResourceId and never consult a scope, so allow/deny blocks on those identifiers are inert. Do not re-add them.",
"windows": [ "windows": ["main"],
"main"
],
"permissions": [ "permissions": [
"core:default", "core:default",
"core:event:default", "core:event:default",
+53 -24
View File
@@ -10,13 +10,11 @@ const MAX_SETTINGS_KEY_LEN: usize = 128;
/// Allowed key prefixes and exact keys for the settings store. /// Allowed key prefixes and exact keys for the settings store.
/// Keys must either match an exact entry or start with an allowed prefix. /// Keys must either match an exact entry or start with an allowed prefix.
const ALLOWED_SETTINGS_PREFIXES: &[&str] = &[ const ALLOWED_SETTINGS_PREFIXES: &[&str] = &[
"owncord:", // owncord:profiles, owncord:settings:*, owncord:recent-emoji "owncord:", // owncord:profiles, owncord:settings:*, owncord:recent-emoji
"userVolume_", // per-user volume: userVolume_{userId} "userVolume_", // per-user volume: userVolume_{userId}
]; ];
const ALLOWED_SETTINGS_EXACT: &[&str] = &[ const ALLOWED_SETTINGS_EXACT: &[&str] = &["windowState"];
"windowState",
];
fn is_settings_key_allowed(key: &str) -> bool { fn is_settings_key_allowed(key: &str) -> bool {
if key.len() > MAX_SETTINGS_KEY_LEN || key.is_empty() { if key.len() > MAX_SETTINGS_KEY_LEN || key.is_empty() {
@@ -25,7 +23,9 @@ fn is_settings_key_allowed(key: &str) -> bool {
if ALLOWED_SETTINGS_EXACT.contains(&key) { if ALLOWED_SETTINGS_EXACT.contains(&key) {
return true; return true;
} }
ALLOWED_SETTINGS_PREFIXES.iter().any(|prefix| key.starts_with(prefix)) ALLOWED_SETTINGS_PREFIXES
.iter()
.any(|prefix| key.starts_with(prefix))
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -61,9 +61,12 @@ pub fn save_settings(app: tauri::AppHandle, key: String, value: Value) -> Result
return Err(format!("unknown settings key: {key}")); return Err(format!("unknown settings key: {key}"));
} }
let store = app let store = app.store(SETTINGS_STORE).map_err(|e| {
.store(SETTINGS_STORE) log_cmd_err(
.map_err(|e| log_cmd_err("save_settings", format!("failed to open settings store: {e}")))?; "save_settings",
format!("failed to open settings store: {e}"),
)
})?;
store.set(&key, value); store.set(&key, value);
store store
@@ -87,7 +90,10 @@ fn validate_cert_pin(host: &str, fingerprint: &str) -> Result<(), String> {
return Err("host must be 1-253 characters".into()); return Err("host must be 1-253 characters".into());
} }
// Validate host format: alphanumeric, dots, hyphens, colons (port), brackets (IPv6) // Validate host format: alphanumeric, dots, hyphens, colons (port), brackets (IPv6)
if !host.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | ':' | '[' | ']')) { if !host
.chars()
.all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | ':' | '[' | ']'))
{
return Err("host contains invalid characters".into()); return Err("host contains invalid characters".into());
} }
if fingerprint.is_empty() { if fingerprint.is_empty() {
@@ -112,7 +118,10 @@ pub fn store_cert_fingerprint(
validate_cert_pin(&host, &fingerprint)?; validate_cert_pin(&host, &fingerprint)?;
let store = app.store(CERTS_STORE).map_err(|e| { let store = app.store(CERTS_STORE).map_err(|e| {
log_cmd_err("store_cert_fingerprint", format!("failed to open certs store: {e}")) log_cmd_err(
"store_cert_fingerprint",
format!("failed to open certs store: {e}"),
)
})?; })?;
// Capture old value before mutating so we can restore it if save fails. // Capture old value before mutating so we can restore it if save fails.
@@ -123,8 +132,12 @@ pub fn store_cert_fingerprint(
// existed, or delete if there was none. Without this, a failed save // existed, or delete if there was none. Without this, a failed save
// during cert rotation would silently lose the previously trusted cert. // during cert rotation would silently lose the previously trusted cert.
match old_value { match old_value {
Some(v) => { store.set(&host, v); } Some(v) => {
None => { let _ = store.delete(&host); } store.set(&host, v);
}
None => {
let _ = store.delete(&host);
}
} }
return Err(log_cmd_err( return Err(log_cmd_err(
"store_cert_fingerprint", "store_cert_fingerprint",
@@ -135,10 +148,7 @@ pub fn store_cert_fingerprint(
} }
#[tauri::command] #[tauri::command]
pub fn get_cert_fingerprint( pub fn get_cert_fingerprint(app: tauri::AppHandle, host: String) -> Result<Option<String>, String> {
app: tauri::AppHandle,
host: String,
) -> Result<Option<String>, String> {
if host.is_empty() { if host.is_empty() {
return Err("host must not be empty".into()); return Err("host must not be empty".into());
} }
@@ -188,13 +198,19 @@ pub fn store_identity_pin(
return Err("host must be 1-253 characters".into()); return Err("host must be 1-253 characters".into());
} }
// Validate host format: alphanumeric, dots, hyphens, colons (port), brackets (IPv6) // Validate host format: alphanumeric, dots, hyphens, colons (port), brackets (IPv6)
if !host.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | ':' | '[' | ']')) { if !host
.chars()
.all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | ':' | '[' | ']'))
{
return Err("host contains invalid characters".into()); return Err("host contains invalid characters".into());
} }
if user_id.is_empty() || user_id.len() > 64 { if user_id.is_empty() || user_id.len() > 64 {
return Err("user_id must be 1-64 characters".into()); return Err("user_id must be 1-64 characters".into());
} }
if !user_id.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_')) { if !user_id
.chars()
.all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_'))
{
return Err("user_id contains invalid characters".into()); return Err("user_id contains invalid characters".into());
} }
if pin.is_empty() || pin.len() > MAX_IDENTITY_PIN_LEN { if pin.is_empty() || pin.len() > MAX_IDENTITY_PIN_LEN {
@@ -202,7 +218,10 @@ pub fn store_identity_pin(
} }
// Base64 charset (standard + url-safe + padding). Guards against garbage/DoS; // Base64 charset (standard + url-safe + padding). Guards against garbage/DoS;
// the actual key parsing/verification happens on the JS side. // the actual key parsing/verification happens on the JS side.
if !pin.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '+' | '/' | '=' | '-' | '_')) { if !pin
.chars()
.all(|c| c.is_ascii_alphanumeric() || matches!(c, '+' | '/' | '=' | '-' | '_'))
{
return Err("pin contains invalid characters".into()); return Err("pin contains invalid characters".into());
} }
@@ -218,8 +237,12 @@ pub fn store_identity_pin(
// Restore previous in-memory state so a failed save during a re-pin // Restore previous in-memory state so a failed save during a re-pin
// doesn't silently drop the previously trusted identity key. // doesn't silently drop the previously trusted identity key.
match old_value { match old_value {
Some(v) => { store.set(&store_key, v); } Some(v) => {
None => { let _ = store.delete(&store_key); } store.set(&store_key, v);
}
None => {
let _ = store.delete(&store_key);
}
} }
return Err(format!("failed to persist identity pin: {e}")); return Err(format!("failed to persist identity pin: {e}"));
} }
@@ -374,7 +397,13 @@ mod tests {
#[test] #[test]
fn identity_pin_key_combines_host_and_user() { fn identity_pin_key_combines_host_and_user() {
assert_eq!(identity_pin_key("chat.example.com", "42"), "chat.example.com:42"); assert_eq!(
assert_eq!(identity_pin_key("192.168.1.10:8443", "u_7"), "192.168.1.10:8443:u_7"); identity_pin_key("chat.example.com", "42"),
"chat.example.com:42"
);
assert_eq!(
identity_pin_key("192.168.1.10:8443", "u_7"),
"192.168.1.10:8443:u_7"
);
} }
} }
+14 -4
View File
@@ -86,7 +86,9 @@ static CREDENTIAL_LOCK: Mutex<()> = Mutex::new(());
/// distrust) rather than propagated, so a panic inside one command cannot /// distrust) rather than propagated, so a panic inside one command cannot
/// permanently wedge every credential operation for the rest of the process. /// permanently wedge every credential operation for the rest of the process.
fn with_credential_lock<T>(f: impl FnOnce() -> T) -> T { fn with_credential_lock<T>(f: impl FnOnce() -> T) -> T {
let _guard = CREDENTIAL_LOCK.lock().unwrap_or_else(|poisoned| poisoned.into_inner()); let _guard = CREDENTIAL_LOCK
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner());
f() f()
} }
@@ -353,8 +355,14 @@ mod tests {
fn account_names_keep_the_port_that_distinguishes_hosts() { fn account_names_keep_the_port_that_distinguishes_hosts() {
// Two servers on one machine differ only by port; dropping it would // Two servers on one machine differ only by port; dropping it would
// make them share an identity key. // make them share an identity key.
assert_ne!(login_account("localhost:8443"), login_account("localhost:9443")); assert_ne!(
assert_eq!(identity_account("localhost:8443"), "identity:localhost:8443"); login_account("localhost:8443"),
login_account("localhost:9443")
);
assert_eq!(
identity_account("localhost:8443"),
"identity:localhost:8443"
);
} }
#[test] #[test]
@@ -374,7 +382,9 @@ mod tests {
#[test] #[test]
fn parse_credential_blob_rejects_malformed_input() { fn parse_credential_blob_rejects_malformed_input() {
assert!(parse_credential_blob("not json").unwrap_err().contains("not valid JSON")); assert!(parse_credential_blob("not json")
.unwrap_err()
.contains("not valid JSON"));
assert!(parse_credential_blob(r#"{"token":"tok"}"#) assert!(parse_credential_blob(r#"{"token":"tok"}"#)
.unwrap_err() .unwrap_err()
.contains("missing 'username'")); .contains("missing 'username'"));
+2 -1
View File
@@ -47,7 +47,8 @@ impl Drop for OutBlob {
// Scrub first: on the unprotect path this buffer holds the plaintext // Scrub first: on the unprotect path this buffer holds the plaintext
// identity key, and LocalFree does not zero what it releases. // identity key, and LocalFree does not zero what it releases.
// SAFETY: as in `to_vec`, plus the range is ours alone to write. // SAFETY: as in `to_vec`, plus the range is ours alone to write.
let bytes = unsafe { std::slice::from_raw_parts_mut(self.0.pbData, self.0.cbData as usize) }; let bytes =
unsafe { std::slice::from_raw_parts_mut(self.0.pbData, self.0.cbData as usize) };
bytes.zeroize(); bytes.zeroize();
// SAFETY: pbData came from DPAPI's LocalAlloc, and `Drop` runs at most // SAFETY: pbData came from DPAPI's LocalAlloc, and `Drop` runs at most
// once, so it is freed exactly once. // once, so it is freed exactly once.
+10 -6
View File
@@ -200,7 +200,10 @@ mod tests {
tampered[last] ^= 0x01; tampered[last] ^= 0x01;
assert!(unprotect(&key, &tampered, b"aad").is_err()); assert!(unprotect(&key, &tampered, b"aad").is_err());
assert!(unprotect(&key, &blob[..NONCE_LEN], b"aad").is_err(), "truncated blob"); assert!(
unprotect(&key, &blob[..NONCE_LEN], b"aad").is_err(),
"truncated blob"
);
} }
#[test] #[test]
@@ -213,10 +216,8 @@ mod tests {
#[test] #[test]
fn creates_and_reuses_the_key_file() { fn creates_and_reuses_the_key_file() {
let dir = std::env::temp_dir().join(format!( let dir =
"owncord-fallback-key-test-{}", std::env::temp_dir().join(format!("owncord-fallback-key-test-{}", std::process::id()));
std::process::id()
));
let _ = fs::remove_dir_all(&dir); let _ = fs::remove_dir_all(&dir);
let first = load_or_create_key(&dir).unwrap(); let first = load_or_create_key(&dir).unwrap();
@@ -259,7 +260,10 @@ mod tests {
.unwrap_err(); .unwrap_err();
assert!(err.contains("failed to write"), "unexpected error: {err}"); assert!(err.contains("failed to write"), "unexpected error: {err}");
assert!(!path.exists(), "a failed write must not leave a partial key file behind"); assert!(
!path.exists(),
"a failed write must not leave a partial key file behind"
);
let _ = fs::remove_dir_all(&dir); let _ = fs::remove_dir_all(&dir);
} }
+25 -14
View File
@@ -29,10 +29,10 @@
// - The accept loop exits after 5 consecutive errors to prevent CPU spin. // - The accept loop exits after 5 consecutive errors to prevent CPU spin.
use log::{debug, error, info, warn}; use log::{debug, error, info, warn};
use rustls::pki_types::ServerName;
use std::collections::HashMap; use std::collections::HashMap;
use std::net::IpAddr; use std::net::IpAddr;
use std::sync::Arc; use std::sync::Arc;
use rustls::pki_types::ServerName;
use tauri::{AppHandle, Manager, Runtime}; use tauri::{AppHandle, Manager, Runtime};
use tokio::io::{self, AsyncReadExt, AsyncWriteExt}; use tokio::io::{self, AsyncReadExt, AsyncWriteExt};
use tokio::net::{TcpListener, TcpStream}; use tokio::net::{TcpListener, TcpStream};
@@ -65,7 +65,10 @@ impl HttpProxyState {
/// was mid-shutdown). /// was mid-shutdown).
async fn remove_if_port_matches(&self, remote_host: &str, port: u16) { async fn remove_if_port_matches(&self, remote_host: &str, port: u16) {
let mut inner = self.inner.lock().await; let mut inner = self.inner.lock().await;
if inner.get(remote_host).is_some_and(|entry| entry.port == port) { if inner
.get(remote_host)
.is_some_and(|entry| entry.port == port)
{
inner.remove(remote_host); inner.remove(remote_host);
} }
} }
@@ -372,7 +375,9 @@ async fn handle_connection<R: Runtime>(
.map_err(|_| Box::<dyn std::error::Error + Send + Sync>::from("TCP connect timed out"))??; .map_err(|_| Box::<dyn std::error::Error + Send + Sync>::from("TCP connect timed out"))??;
let mut tls = timeout(Duration::from_secs(10), connector.connect(server_name, tcp)) let mut tls = timeout(Duration::from_secs(10), connector.connect(server_name, tcp))
.await .await
.map_err(|_| Box::<dyn std::error::Error + Send + Sync>::from("TLS handshake timed out"))??; .map_err(|_| {
Box::<dyn std::error::Error + Send + Sync>::from("TLS handshake timed out")
})??;
let fingerprint = captured_fp let fingerprint = captured_fp
.lock() .lock()
@@ -400,7 +405,10 @@ async fn handle_connection<R: Runtime>(
// it (accept_cert_fingerprint) before any credential-bearing request is // it (accept_cert_fingerprint) before any credential-bearing request is
// sent. The connect page's health check triggers this before login. // sent. The connect page's health check triggers this before login.
TofuOutcome::FirstUse => { TofuOutcome::FirstUse => {
info!("[http_proxy] first-use cert for {} — awaiting user confirmation", store_key); info!(
"[http_proxy] first-use cert for {} — awaiting user confirmation",
store_key
);
crate::ws_proxy::emit_cert_tofu( crate::ws_proxy::emit_cert_tofu(
&app, &app,
serde_json::json!({ serde_json::json!({
@@ -523,19 +531,20 @@ mod tests {
// A stale loop reporting a port that no longer matches the live // A stale loop reporting a port that no longer matches the live
// entry must leave the current entry alone. // entry must leave the current entry alone.
state state.remove_if_port_matches("example.com:8443", 9999).await;
.remove_if_port_matches("example.com:8443", 9999)
.await;
assert_eq!( assert_eq!(
state.inner.lock().await.get("example.com:8443").map(|e| e.port), state
.inner
.lock()
.await
.get("example.com:8443")
.map(|e| e.port),
Some(4242), Some(4242),
"mismatched port must not remove a newer tunnel's entry" "mismatched port must not remove a newer tunnel's entry"
); );
// A loop reporting its own still-current port must remove it. // A loop reporting its own still-current port must remove it.
state state.remove_if_port_matches("example.com:8443", 4242).await;
.remove_if_port_matches("example.com:8443", 4242)
.await;
assert!( assert!(
state.inner.lock().await.get("example.com:8443").is_none(), state.inner.lock().await.get("example.com:8443").is_none(),
"matching port must deregister the dead tunnel" "matching port must deregister the dead tunnel"
@@ -609,13 +618,15 @@ mod tests {
#[test] #[test]
fn rewrite_overrides_existing_keepalive() { fn rewrite_overrides_existing_keepalive() {
let raw = let raw = b"POST /x HTTP/1.1\r\nHost: 127.0.0.1:5000\r\nConnection: keep-alive\r\n\r\n";
b"POST /x HTTP/1.1\r\nHost: 127.0.0.1:5000\r\nConnection: keep-alive\r\n\r\n";
let out = rewrite_request_headers(raw, "example.com:8443"); let out = rewrite_request_headers(raw, "example.com:8443");
assert!(out.contains("Connection: close\r\n")); assert!(out.contains("Connection: close\r\n"));
assert!(!out.to_ascii_lowercase().contains("keep-alive")); assert!(!out.to_ascii_lowercase().contains("keep-alive"));
// Exactly one Connection header. // Exactly one Connection header.
assert_eq!(out.to_ascii_lowercase().matches("\r\nconnection:").count(), 1); assert_eq!(
out.to_ascii_lowercase().matches("\r\nconnection:").count(),
1
);
} }
#[test] #[test]
+6 -2
View File
@@ -38,8 +38,12 @@ pub fn enable_media_capture(app: &AppHandle) {
webview.connect_permission_request(|_, request| { webview.connect_permission_request(|_, request| {
// UserMediaPermissionRequest covers getUserMedia (mic/camera); // UserMediaPermissionRequest covers getUserMedia (mic/camera);
// DeviceInfoPermissionRequest covers enumerateDevices labels. // DeviceInfoPermissionRequest covers enumerateDevices labels.
let is_media = request.downcast_ref::<UserMediaPermissionRequest>().is_some() let is_media = request
|| request.downcast_ref::<DeviceInfoPermissionRequest>().is_some(); .downcast_ref::<UserMediaPermissionRequest>()
.is_some()
|| request
.downcast_ref::<DeviceInfoPermissionRequest>()
.is_some();
if is_media { if is_media {
request.allow(); request.allow();
return true; return true;
+67 -25
View File
@@ -28,9 +28,9 @@
// - The accept loop exits after 5 consecutive errors to prevent CPU spin. // - The accept loop exits after 5 consecutive errors to prevent CPU spin.
use log::{debug, error, info, warn}; use log::{debug, error, info, warn};
use rustls::pki_types::ServerName;
use std::net::IpAddr; use std::net::IpAddr;
use std::sync::Arc; use std::sync::Arc;
use rustls::pki_types::ServerName;
use tauri::{Manager, Runtime}; use tauri::{Manager, Runtime};
use tokio::io::{self, AsyncReadExt, AsyncWriteExt}; use tokio::io::{self, AsyncReadExt, AsyncWriteExt};
use tokio::net::{TcpListener, TcpStream}; use tokio::net::{TcpListener, TcpStream};
@@ -205,16 +205,25 @@ pub async fn start_livekit_proxy<R: Runtime>(
// the fingerprint should already be stored. If not, reject — we refuse // the fingerprint should already be stored. If not, reject — we refuse
// to connect without a pinned cert. // to connect without a pinned cert.
let store_key = tofu::cert_store_key(&remote_host); let store_key = tofu::cert_store_key(&remote_host);
let fingerprint = tofu::load_stored_fingerprint(&app, &store_key)? let fingerprint = tofu::load_stored_fingerprint(&app, &store_key)?.ok_or_else(|| {
.ok_or_else(|| format!( format!(
"no trusted certificate fingerprint for {remote_host}. \ "no trusted certificate fingerprint for {remote_host}. \
Connect via WebSocket first to establish TOFU trust." Connect via WebSocket first to establish TOFU trust."
))?; )
})?;
// Reuse the existing proxy only when host AND pin are unchanged. // Reuse the existing proxy only when host AND pin are unchanged.
if let Some(port) = inner.port { if let Some(port) = inner.port {
if can_reuse_proxy(&inner.remote_host, &inner.pinned_fingerprint, &remote_host, &fingerprint) { if can_reuse_proxy(
debug!("[livekit_proxy] reusing existing proxy on port {} for {}", port, remote_host); &inner.remote_host,
&inner.pinned_fingerprint,
&remote_host,
&fingerprint,
) {
debug!(
"[livekit_proxy] reusing existing proxy on port {} for {}",
port, remote_host
);
return Ok(port); return Ok(port);
} }
// Different host or re-pinned cert — tear down the old proxy. // Different host or re-pinned cert — tear down the old proxy.
@@ -256,7 +265,10 @@ pub async fn start_livekit_proxy<R: Runtime>(
} }
}); });
info!("[livekit_proxy] proxy started on 127.0.0.1:{} → {}", port, remote_host); info!(
"[livekit_proxy] proxy started on 127.0.0.1:{} → {}",
port, remote_host
);
inner.port = Some(port); inner.port = Some(port);
inner.remote_host = remote_host; inner.remote_host = remote_host;
@@ -268,9 +280,7 @@ pub async fn start_livekit_proxy<R: Runtime>(
/// Stop the LiveKit TLS proxy if running. /// Stop the LiveKit TLS proxy if running.
#[tauri::command] #[tauri::command]
pub async fn stop_livekit_proxy( pub async fn stop_livekit_proxy(state: tauri::State<'_, LiveKitProxyState>) -> Result<(), String> {
state: tauri::State<'_, LiveKitProxyState>,
) -> Result<(), String> {
let mut inner = state.inner.lock().await; let mut inner = state.inner.lock().await;
if let Some(tx) = inner.shutdown_tx.take() { if let Some(tx) = inner.shutdown_tx.take() {
let _ = tx.send(()); let _ = tx.send(());
@@ -370,10 +380,15 @@ async fn connect_tls(
let tcp = timeout(limit, TcpStream::connect(remote_host)) let tcp = timeout(limit, TcpStream::connect(remote_host))
.await .await
.map_err(|_| Box::<dyn std::error::Error + Send + Sync>::from("TCP connect timed out"))??; .map_err(|_| Box::<dyn std::error::Error + Send + Sync>::from("TCP connect timed out"))??;
debug!("[livekit_proxy] starting TLS handshake with {}", remote_host); debug!(
"[livekit_proxy] starting TLS handshake with {}",
remote_host
);
let tls = timeout(limit, connector.connect(server_name, tcp)) let tls = timeout(limit, connector.connect(server_name, tcp))
.await .await
.map_err(|_| Box::<dyn std::error::Error + Send + Sync>::from("TLS handshake timed out"))??; .map_err(|_| {
Box::<dyn std::error::Error + Send + Sync>::from("TLS handshake timed out")
})??;
Ok(tls) Ok(tls)
} }
@@ -413,9 +428,9 @@ async fn handle_connection(
Ok::<(), Box<dyn std::error::Error + Send + Sync>>(()) Ok::<(), Box<dyn std::error::Error + Send + Sync>>(())
}) })
.await .await
.map_err(|_| Box::<dyn std::error::Error + Send + Sync>::from( .map_err(|_| {
"upstream header read timed out", Box::<dyn std::error::Error + Send + Sync>::from("upstream header read timed out")
))??; })??;
// Reject CRLF in remote_host before header insertion (defense-in-depth; // Reject CRLF in remote_host before header insertion (defense-in-depth;
// primary validation is in start_livekit_proxy). // primary validation is in start_livekit_proxy).
@@ -430,9 +445,9 @@ async fn handle_connection(
// ── 3. Connect to remote over TLS ──────────────────────────────────── // ── 3. Connect to remote over TLS ────────────────────────────────────
let tls_config = rustls::ClientConfig::builder() let tls_config = rustls::ClientConfig::builder()
.dangerous() .dangerous()
.with_custom_certificate_verifier(Arc::new( .with_custom_certificate_verifier(Arc::new(tofu::PinnedVerifier::new(
tofu::PinnedVerifier::new(pinned_fingerprint.to_string()), pinned_fingerprint.to_string(),
)) )))
.with_no_client_auth(); .with_no_client_auth();
let connector = tokio_rustls::TlsConnector::from(Arc::new(tls_config)); let connector = tokio_rustls::TlsConnector::from(Arc::new(tls_config));
@@ -447,7 +462,10 @@ async fn handle_connection(
let result = io::copy_bidirectional(&mut local, &mut tls).await; let result = io::copy_bidirectional(&mut local, &mut tls).await;
match result { match result {
Ok((to_remote, from_remote)) => { Ok((to_remote, from_remote)) => {
debug!("[livekit_proxy] connection closed: {}B sent, {}B received", to_remote, from_remote); debug!(
"[livekit_proxy] connection closed: {}B sent, {}B received",
to_remote, from_remote
);
} }
Err(e) => { Err(e) => {
debug!("[livekit_proxy] bidirectional copy ended: {}", e); debug!("[livekit_proxy] bidirectional copy ended: {}", e);
@@ -493,7 +511,10 @@ mod tests {
"example.com\nX-Injected: 1", "example.com\nX-Injected: 1",
"example.com\r", "example.com\r",
] { ] {
assert!(validate_remote_host(host).is_err(), "should reject {host:?}"); assert!(
validate_remote_host(host).is_err(),
"should reject {host:?}"
);
} }
} }
@@ -512,7 +533,10 @@ mod tests {
"exa mple.com:443", "exa mple.com:443",
"example.com;evil", "example.com;evil",
] { ] {
assert!(validate_remote_host(host).is_err(), "should reject {host:?}"); assert!(
validate_remote_host(host).is_err(),
"should reject {host:?}"
);
} }
} }
@@ -527,12 +551,22 @@ mod tests {
#[test] #[test]
fn reuses_proxy_only_when_host_and_pin_are_unchanged() { fn reuses_proxy_only_when_host_and_pin_are_unchanged() {
assert!(can_reuse_proxy("example.com:443", "aa:bb", "example.com:443", "aa:bb")); assert!(can_reuse_proxy(
"example.com:443",
"aa:bb",
"example.com:443",
"aa:bb"
));
} }
#[test] #[test]
fn restarts_proxy_when_host_changes() { fn restarts_proxy_when_host_changes() {
assert!(!can_reuse_proxy("old.example:443", "aa:bb", "new.example:443", "aa:bb")); assert!(!can_reuse_proxy(
"old.example:443",
"aa:bb",
"new.example:443",
"aa:bb"
));
} }
#[test] #[test]
@@ -541,7 +575,12 @@ mod tests {
// store). The running listener still pins the old fingerprint, so every // store). The running listener still pins the old fingerprint, so every
// connection through it would fail the TLS handshake — reuse must be // connection through it would fail the TLS handshake — reuse must be
// refused so the caller tears down and restarts with the new pin. // refused so the caller tears down and restarts with the new pin.
assert!(!can_reuse_proxy("example.com:443", "aa:bb", "example.com:443", "cc:dd")); assert!(!can_reuse_proxy(
"example.com:443",
"aa:bb",
"example.com:443",
"cc:dd"
));
} }
// ── rewrite_proxy_headers ─────────────────────────────────────────────── // ── rewrite_proxy_headers ───────────────────────────────────────────────
@@ -749,7 +788,10 @@ mod tests {
// next start_livekit_proxy rebinds instead of reusing the dead listener. // next start_livekit_proxy rebinds instead of reusing the dead listener.
state.clear_if_port_matches(4242).await; state.clear_if_port_matches(4242).await;
let inner = state.inner.lock().await; let inner = state.inner.lock().await;
assert_eq!(inner.port, None, "matching port must deregister the dead proxy"); assert_eq!(
inner.port, None,
"matching port must deregister the dead proxy"
);
assert!(inner.remote_host.is_empty()); assert!(inner.remote_host.is_empty());
assert!(inner.pinned_fingerprint.is_empty()); assert!(inner.pinned_fingerprint.is_empty());
} }
+22 -13
View File
@@ -24,8 +24,7 @@ static PTT_VKEY: AtomicI32 = AtomicI32::new(0);
/// therefore never reset the stop signal that an earlier thread's `join()` is /// therefore never reset the stop signal that an earlier thread's `join()` is
/// still waiting on — the lost-signal race (ATOMICRACE-001) that a single /// still waiting on — the lost-signal race (ATOMICRACE-001) that a single
/// shared flag allowed. /// shared flag allowed.
static PTT_THREAD: Mutex<Option<(Arc<AtomicBool>, std::thread::JoinHandle<()>)>> = static PTT_THREAD: Mutex<Option<(Arc<AtomicBool>, std::thread::JoinHandle<()>)>> = Mutex::new(None);
Mutex::new(None);
/// Returns true if a VK code is allowed for global capture in ptt_listen_for_key. /// Returns true if a VK code is allowed for global capture in ptt_listen_for_key.
/// ///
@@ -58,7 +57,7 @@ fn is_allowed_ptt_capture_vk(vk: i32) -> bool {
0x2D | // Insert 0x2D | // Insert
0x2E | // Delete 0x2E | // Delete
0x05 | // Mouse X1 0x05 | // Mouse X1
0x06 // Mouse X2 0x06 // Mouse X2
) )
} }
@@ -73,8 +72,7 @@ fn is_key_down(vk: i32) -> bool {
return false; return false;
} }
// SAFETY: GetAsyncKeyState is safe to call with valid VK codes 1-254 // SAFETY: GetAsyncKeyState is safe to call with valid VK codes 1-254
let state = let state = unsafe { windows::Win32::UI::Input::KeyboardAndMouse::GetAsyncKeyState(vk) };
unsafe { windows::Win32::UI::Input::KeyboardAndMouse::GetAsyncKeyState(vk) };
// High-order bit set (negative when interpreted as i16) = key is down // High-order bit set (negative when interpreted as i16) = key is down
(state as i16) < 0 (state as i16) < 0
} }
@@ -101,7 +99,10 @@ fn is_key_down(vk: i32) -> bool {
let Some(keycode) = linux::vk_to_keycode(vk) else { let Some(keycode) = linux::vk_to_keycode(vk) else {
return false; return false;
}; };
DEVICE_STATE.with(|ds| ds.as_ref().is_some_and(|ds| ds.get_keys().contains(&keycode))) DEVICE_STATE.with(|ds| {
ds.as_ref()
.is_some_and(|ds| ds.get_keys().contains(&keycode))
})
} }
#[cfg(not(any(windows, target_os = "linux")))] #[cfg(not(any(windows, target_os = "linux")))]
@@ -444,7 +445,9 @@ pub fn ptt_stop_internal() {
#[tauri::command] #[tauri::command]
pub fn ptt_set_key(vk_code: i32) -> Result<(), String> { pub fn ptt_set_key(vk_code: i32) -> Result<(), String> {
if vk_code != 0 && !(1..=254).contains(&vk_code) { if vk_code != 0 && !(1..=254).contains(&vk_code) {
return Err(format!("invalid virtual key code: {vk_code} (must be 0 or 1-254)")); return Err(format!(
"invalid virtual key code: {vk_code} (must be 0 or 1-254)"
));
} }
PTT_VKEY.store(vk_code, Ordering::SeqCst); PTT_VKEY.store(vk_code, Ordering::SeqCst);
Ok(()) Ok(())
@@ -478,8 +481,7 @@ pub async fn ptt_listen_for_key() -> i32 {
continue; continue;
} }
// Wait for key release (with its own timeout) // Wait for key release (with its own timeout)
let release_deadline = let release_deadline = std::time::Instant::now() + Duration::from_secs(5);
std::time::Instant::now() + Duration::from_secs(5);
while device_state.get_keys().contains(&key) while device_state.get_keys().contains(&key)
&& std::time::Instant::now() < release_deadline && std::time::Instant::now() < release_deadline
{ {
@@ -503,8 +505,7 @@ pub async fn ptt_listen_for_key() -> i32 {
continue; continue;
} }
if is_key_down(vk) { if is_key_down(vk) {
let release_deadline = let release_deadline = std::time::Instant::now() + Duration::from_secs(5);
std::time::Instant::now() + Duration::from_secs(5);
while is_key_down(vk) && std::time::Instant::now() < release_deadline { while is_key_down(vk) && std::time::Instant::now() < release_deadline {
std::thread::sleep(Duration::from_millis(20)); std::thread::sleep(Duration::from_millis(20));
} }
@@ -576,7 +577,11 @@ mod tests {
fn ptt_transition_reports_edges_only() { fn ptt_transition_reports_edges_only() {
assert_eq!(ptt_transition(0x41, true, false), Some(true), "rising edge"); assert_eq!(ptt_transition(0x41, true, false), Some(true), "rising edge");
assert_eq!(ptt_transition(0x41, true, true), None, "still held"); assert_eq!(ptt_transition(0x41, true, true), None, "still held");
assert_eq!(ptt_transition(0x41, false, true), Some(false), "falling edge"); assert_eq!(
ptt_transition(0x41, false, true),
Some(false),
"falling edge"
);
assert_eq!(ptt_transition(0x41, false, false), None, "still idle"); assert_eq!(ptt_transition(0x41, false, false), None, "still idle");
} }
@@ -635,7 +640,11 @@ mod tests {
]; ];
for (keycode, vk) in cases { for (keycode, vk) in cases {
assert_eq!(keycode_to_vk(&keycode), vk, "keycode_to_vk failed for {keycode:?}"); assert_eq!(
keycode_to_vk(&keycode),
vk,
"keycode_to_vk failed for {keycode:?}"
);
assert_eq!( assert_eq!(
vk_to_keycode(vk), vk_to_keycode(vk),
Some(keycode), Some(keycode),
+31 -9
View File
@@ -272,9 +272,10 @@ fn compiled_backend_persistence() -> (bool, &'static str) {
CredentialPersistence::UntilDelete => (true, "persists until deleted (on disk)"), CredentialPersistence::UntilDelete => (true, "persists until deleted (on disk)"),
CredentialPersistence::UntilReboot => (false, "vanishes on reboot (kernel memory)"), CredentialPersistence::UntilReboot => (false, "vanishes on reboot (kernel memory)"),
CredentialPersistence::ProcessOnly => (false, "vanishes when the process exits"), CredentialPersistence::ProcessOnly => (false, "vanishes when the process exits"),
CredentialPersistence::EntryOnly => { CredentialPersistence::EntryOnly => (
(false, "vanishes with the entry object (the in-memory mock store)") false,
} "vanishes with the entry object (the in-memory mock store)",
),
_ => (false, "unrecognized persistence class"), _ => (false, "unrecognized persistence class"),
} }
} }
@@ -509,7 +510,10 @@ mod tests {
#[test] #[test]
fn fallback_aad_is_account_specific() { fn fallback_aad_is_account_specific() {
assert_ne!(fallback_aad("host.example"), fallback_aad("identity:host.example")); assert_ne!(
fallback_aad("host.example"),
fallback_aad("identity:host.example")
);
assert_eq!(fallback_aad("host.example"), fallback_aad("host.example")); assert_eq!(fallback_aad("host.example"), fallback_aad("host.example"));
} }
@@ -532,13 +536,21 @@ mod tests {
// indistinguishable from first login, and the E2EE identity keypair // indistinguishable from first login, and the E2EE identity keypair
// loader mints and publishes a brand-new identity key on exactly that // loader mints and publishes a brand-new identity key on exactly that
// signal, invalidating every peer's TOFU pin. // signal, invalidating every peer's TOFU pin.
let result = get_with("identity:chat.example", |_| Err("keychain locked".to_string()), |_| None); let result = get_with(
"identity:chat.example",
|_| Err("keychain locked".to_string()),
|_| None,
);
assert_eq!(result, Err("keychain locked".to_string())); assert_eq!(result, Err("keychain locked".to_string()));
} }
#[test] #[test]
fn get_with_prefers_the_live_keyring_value_over_the_fallback() { fn get_with_prefers_the_live_keyring_value_over_the_fallback() {
let result = get_with("acct", |_| Ok(Some("live".to_string())), |_| Some("stale".to_string())); let result = get_with(
"acct",
|_| Ok(Some("live".to_string())),
|_| Some("stale".to_string()),
);
assert_eq!(result, Ok(Some("live".to_string()))); assert_eq!(result, Ok(Some("live".to_string())));
} }
@@ -625,7 +637,10 @@ mod tests {
|_| cleared.set(true), |_| cleared.set(true),
); );
assert_eq!(result, Ok(Backend::Keyring)); assert_eq!(result, Ok(Backend::Keyring));
assert!(cleared.get(), "a recovered machine must clear any stale fallback copy"); assert!(
cleared.get(),
"a recovered machine must clear any stale fallback copy"
);
} }
#[test] #[test]
@@ -657,7 +672,10 @@ mod tests {
deleted.get(), deleted.get(),
"a mismatched keyring entry must be purged, not left to shadow the fallback" "a mismatched keyring entry must be purged, not left to shadow the fallback"
); );
assert!(fallback_written.get(), "the secret must still land in the fallback"); assert!(
fallback_written.get(),
"the secret must still land in the fallback"
);
} }
#[test] #[test]
@@ -729,7 +747,11 @@ mod tests {
fn dpapi_round_trips_and_rejects_foreign_entropy() { fn dpapi_round_trips_and_rejects_foreign_entropy() {
let secret = b"eyJrdHkiOiJFQyIsImNydiI6IlAtMjU2In0"; let secret = b"eyJrdHkiOiJFQyIsImNydiI6IlAtMjU2In0";
let blob = crate::dpapi::protect(secret, &fallback_aad("identity:a.example")).unwrap(); let blob = crate::dpapi::protect(secret, &fallback_aad("identity:a.example")).unwrap();
assert_ne!(blob.as_slice(), secret.as_slice(), "blob must not be plaintext"); assert_ne!(
blob.as_slice(),
secret.as_slice(),
"blob must not be plaintext"
);
let back = crate::dpapi::unprotect(&blob, &fallback_aad("identity:a.example")).unwrap(); let back = crate::dpapi::unprotect(&blob, &fallback_aad("identity:a.example")).unwrap();
assert_eq!(back, secret); assert_eq!(back, secret);
+42 -11
View File
@@ -80,7 +80,12 @@ pub(crate) struct CaptureVerifier {
impl CaptureVerifier { impl CaptureVerifier {
pub(crate) fn new() -> (Self, CapturedFingerprint) { pub(crate) fn new() -> (Self, CapturedFingerprint) {
let fp = Arc::new(std::sync::Mutex::new(None)); let fp = Arc::new(std::sync::Mutex::new(None));
(Self { captured: fp.clone() }, fp) (
Self {
captured: fp.clone(),
},
fp,
)
} }
} }
@@ -134,7 +139,9 @@ pub(crate) struct PinnedVerifier {
impl PinnedVerifier { impl PinnedVerifier {
pub(crate) fn new(expected_fingerprint: String) -> Self { pub(crate) fn new(expected_fingerprint: String) -> Self {
Self { expected_fingerprint } Self {
expected_fingerprint,
}
} }
} }
@@ -203,7 +210,11 @@ impl HostScopedVerifier {
) )
.build() .build()
.map_err(|e| format!("failed to build web-PKI verifier: {e}"))?; .map_err(|e| format!("failed to build web-PKI verifier: {e}"))?;
Ok(Self::with_default(pinned_host, expected_fingerprint, default)) Ok(Self::with_default(
pinned_host,
expected_fingerprint,
default,
))
} }
/// Seam for tests: inject the verifier used for non-pinned hosts. /// Seam for tests: inject the verifier used for non-pinned hosts.
@@ -247,11 +258,21 @@ impl rustls::client::danger::ServerCertVerifier for HostScopedVerifier {
now: rustls::pki_types::UnixTime, now: rustls::pki_types::UnixTime,
) -> Result<rustls::client::danger::ServerCertVerified, rustls::Error> { ) -> Result<rustls::client::danger::ServerCertVerified, rustls::Error> {
if self.is_pinned_host(server_name) { if self.is_pinned_host(server_name) {
self.pinned self.pinned.verify_server_cert(
.verify_server_cert(end_entity, intermediates, server_name, ocsp_response, now) end_entity,
intermediates,
server_name,
ocsp_response,
now,
)
} else { } else {
self.default self.default.verify_server_cert(
.verify_server_cert(end_entity, intermediates, server_name, ocsp_response, now) end_entity,
intermediates,
server_name,
ocsp_response,
now,
)
} }
} }
@@ -410,7 +431,9 @@ mod tests {
fn decide_mismatch_when_pin_differs() { fn decide_mismatch_when_pin_differs() {
assert_eq!( assert_eq!(
decide(Some("aa:bb".into()), "cc:dd"), decide(Some("aa:bb".into()), "cc:dd"),
TofuOutcome::Mismatch { stored: "aa:bb".into() } TofuOutcome::Mismatch {
stored: "aa:bb".into()
}
); );
} }
@@ -430,7 +453,10 @@ mod tests {
// own distinct key, matching the un-bracketed "host:port" behavior above. // own distinct key, matching the un-bracketed "host:port" behavior above.
#[test] #[test]
fn cert_store_key_treats_bracketed_and_bare_ipv6_as_the_same_host() { fn cert_store_key_treats_bracketed_and_bare_ipv6_as_the_same_host() {
assert_eq!(cert_store_key("[2001:db8::1]"), cert_store_key("2001:db8::1")); assert_eq!(
cert_store_key("[2001:db8::1]"),
cert_store_key("2001:db8::1")
);
assert_eq!(cert_store_key("2001:db8::1"), "2001:db8::1"); assert_eq!(cert_store_key("2001:db8::1"), "2001:db8::1");
assert_eq!(cert_store_key("[2001:db8::1]"), "2001:db8::1"); assert_eq!(cert_store_key("[2001:db8::1]"), "2001:db8::1");
// The default-port livekit form ("[host]:443") also collapses to the // The default-port livekit form ("[host]:443") also collapses to the
@@ -478,7 +504,10 @@ mod tests {
#[test] #[test]
fn extract_host_variants() { fn extract_host_variants() {
assert_eq!(extract_host("wss://example.com/chat"), "example.com"); assert_eq!(extract_host("wss://example.com/chat"), "example.com");
assert_eq!(extract_host("wss://example.com:8443/chat"), "example.com:8443"); assert_eq!(
extract_host("wss://example.com:8443/chat"),
"example.com:8443"
);
assert_eq!(extract_host("wss://example.com:443/chat"), "example.com"); assert_eq!(extract_host("wss://example.com:443/chat"), "example.com");
assert_eq!(extract_host("wss://example.com"), "example.com"); assert_eq!(extract_host("wss://example.com"), "example.com");
assert_eq!(extract_host("example.com/path"), "example.com"); assert_eq!(extract_host("example.com/path"), "example.com");
@@ -577,7 +606,9 @@ mod tests {
HostScopedVerifier::with_default( HostScopedVerifier::with_default(
pinned_host.to_string(), pinned_host.to_string(),
fingerprint_hex(cert_bytes), fingerprint_hex(cert_bytes),
Arc::new(StubVerifier { accept: stub_accepts }), Arc::new(StubVerifier {
accept: stub_accepts,
}),
) )
} }
+8 -11
View File
@@ -14,23 +14,16 @@ const QUIT_ID: &str = "quit";
pub fn create_tray<R: Runtime>(app: &tauri::AppHandle<R>) -> Result<(), tauri::Error> { pub fn create_tray<R: Runtime>(app: &tauri::AppHandle<R>) -> Result<(), tauri::Error> {
let show_hide = MenuItem::with_id(app, SHOW_HIDE_ID, "Show/Hide", true, None::<&str>)?; let show_hide = MenuItem::with_id(app, SHOW_HIDE_ID, "Show/Hide", true, None::<&str>)?;
let status_online = let status_online = MenuItem::with_id(app, STATUS_ONLINE_ID, "Online", true, None::<&str>)?;
MenuItem::with_id(app, STATUS_ONLINE_ID, "Online", true, None::<&str>)?;
let status_idle = MenuItem::with_id(app, STATUS_IDLE_ID, "Idle", true, None::<&str>)?; let status_idle = MenuItem::with_id(app, STATUS_IDLE_ID, "Idle", true, None::<&str>)?;
let status_dnd = MenuItem::with_id(app, STATUS_DND_ID, "Do Not Disturb", true, None::<&str>)?; let status_dnd = MenuItem::with_id(app, STATUS_DND_ID, "Do Not Disturb", true, None::<&str>)?;
let status_offline = let status_offline = MenuItem::with_id(app, STATUS_OFFLINE_ID, "Offline", true, None::<&str>)?;
MenuItem::with_id(app, STATUS_OFFLINE_ID, "Offline", true, None::<&str>)?;
let status_submenu = Submenu::with_items( let status_submenu = Submenu::with_items(
app, app,
"Status", "Status",
true, true,
&[ &[&status_online, &status_idle, &status_dnd, &status_offline],
&status_online,
&status_idle,
&status_dnd,
&status_offline,
],
)?; )?;
let quit = MenuItem::with_id(app, QUIT_ID, "Quit", true, None::<&str>)?; let quit = MenuItem::with_id(app, QUIT_ID, "Quit", true, None::<&str>)?;
@@ -41,7 +34,11 @@ pub fn create_tray<R: Runtime>(app: &tauri::AppHandle<R>) -> Result<(), tauri::E
let app_handle_menu = app.clone(); let app_handle_menu = app.clone();
TrayIconBuilder::new() TrayIconBuilder::new()
.icon(app.default_window_icon().cloned().unwrap_or_else(|| tauri::image::Image::new(&[], 1, 1))) .icon(
app.default_window_icon()
.cloned()
.unwrap_or_else(|| tauri::image::Image::new(&[], 1, 1)),
)
.menu(&menu) .menu(&menu)
.tooltip("OwnCord") .tooltip("OwnCord")
.on_tray_icon_event(move |_tray, event| { .on_tray_icon_event(move |_tray, event| {
+10 -9
View File
@@ -1,5 +1,5 @@
use std::sync::Arc;
use serde::Serialize; use serde::Serialize;
use std::sync::Arc;
use tauri::{AppHandle, Emitter}; use tauri::{AppHandle, Emitter};
use tauri_plugin_updater::UpdaterExt; use tauri_plugin_updater::UpdaterExt;
@@ -23,9 +23,10 @@ struct DownloadProgress {
/// Extract the host (with port if non-443) from an https:// URL for cert store lookup. /// Extract the host (with port if non-443) from an https:// URL for cert store lookup.
fn extract_host_for_cert_store(server_url: &str) -> Result<String, String> { fn extract_host_for_cert_store(server_url: &str) -> Result<String, String> {
let parsed = url::Url::parse(server_url) let parsed =
.map_err(|e| format!("failed to parse server URL: {e}"))?; url::Url::parse(server_url).map_err(|e| format!("failed to parse server URL: {e}"))?;
let host = parsed.host_str() let host = parsed
.host_str()
.ok_or_else(|| "server URL has no host".to_string())?; .ok_or_else(|| "server URL has no host".to_string())?;
let port = parsed.port().unwrap_or(443); let port = parsed.port().unwrap_or(443);
let raw = if port == 443 { let raw = if port == 443 {
@@ -41,7 +42,10 @@ fn extract_host_for_cert_store(server_url: &str) -> Result<String, String> {
/// HTTP client also downloads the installer from GitHub, whose certificate /// HTTP client also downloads the installer from GitHub, whose certificate
/// must pass normal web-PKI validation instead (a client-wide pin would /// must pass normal web-PKI validation instead (a client-wide pin would
/// reject it and every install would fail). /// reject it and every install would fail).
fn build_tls_config(app: &AppHandle, server_url: &str) -> Result<Option<rustls::ClientConfig>, String> { fn build_tls_config(
app: &AppHandle,
server_url: &str,
) -> Result<Option<rustls::ClientConfig>, String> {
let store_key = extract_host_for_cert_store(server_url)?; let store_key = extract_host_for_cert_store(server_url)?;
let fingerprint = load_stored_fingerprint(app, &store_key)?; let fingerprint = load_stored_fingerprint(app, &store_key)?;
match fingerprint { match fingerprint {
@@ -164,10 +168,7 @@ pub async fn check_client_update(
/// The frontend should call `relaunch()` from @tauri-apps/plugin-process /// The frontend should call `relaunch()` from @tauri-apps/plugin-process
/// after this completes. /// after this completes.
#[tauri::command] #[tauri::command]
pub async fn download_and_install_update( pub async fn download_and_install_update(app: AppHandle, server_url: String) -> Result<(), String> {
app: AppHandle,
server_url: String,
) -> Result<(), String> {
let updater = build_updater(&app, &server_url)?; let updater = build_updater(&app, &server_url)?;
let update = updater let update = updater
+53 -35
View File
@@ -144,20 +144,19 @@ pub async fn ws_connect<R: Runtime>(
.with_custom_certificate_verifier(Arc::new(verifier)) .with_custom_certificate_verifier(Arc::new(verifier))
.with_no_client_auth(); .with_no_client_auth();
let connector = let connector = tokio_tungstenite::Connector::Rustls(Arc::new(tls_config));
tokio_tungstenite::Connector::Rustls(Arc::new(tls_config));
let connect_future = tokio_tungstenite::connect_async_tls_with_config( let connect_future =
&url, tokio_tungstenite::connect_async_tls_with_config(&url, None, false, Some(connector));
None,
false,
Some(connector),
);
let (ws_stream, _response) = tokio::time::timeout(CONNECT_TIMEOUT, connect_future) let (ws_stream, _response) = tokio::time::timeout(CONNECT_TIMEOUT, connect_future)
.await .await
.map_err(|_| { .map_err(|_| {
error!("[ws_proxy] connect timed out after {}s to {}", CONNECT_TIMEOUT.as_secs(), url); error!(
"[ws_proxy] connect timed out after {}s to {}",
CONNECT_TIMEOUT.as_secs(),
url
);
format!("ws connect timed out after {}s", CONNECT_TIMEOUT.as_secs()) format!("ws connect timed out after {}s", CONNECT_TIMEOUT.as_secs())
})? })?
.map_err(|e| { .map_err(|e| {
@@ -182,19 +181,28 @@ pub async fn ws_connect<R: Runtime>(
match tofu::evaluate(&app, &host, &fingerprint)? { match tofu::evaluate(&app, &host, &fingerprint)? {
TofuOutcome::Trusted => { TofuOutcome::Trusted => {
info!("[ws_proxy] TOFU check passed for {}", host); info!("[ws_proxy] TOFU check passed for {}", host);
emit_cert_tofu(&app, serde_json::json!({ emit_cert_tofu(
"host": host, &app,
"fingerprint": fingerprint, serde_json::json!({
"status": "trusted", "host": host,
})); "fingerprint": fingerprint,
"status": "trusted",
}),
);
} }
TofuOutcome::FirstUse => { TofuOutcome::FirstUse => {
info!("[ws_proxy] first-use cert for {} — awaiting user confirmation", host); info!(
emit_cert_tofu(&app, serde_json::json!({ "[ws_proxy] first-use cert for {} — awaiting user confirmation",
"host": host, host
"fingerprint": fingerprint, );
"status": "first_use", emit_cert_tofu(
})); &app,
serde_json::json!({
"host": host,
"fingerprint": fingerprint,
"status": "first_use",
}),
);
// Do not open the socket: the user must confirm the fingerprint // Do not open the socket: the user must confirm the fingerprint
// (accept_cert_fingerprint) before anything is sent over it. // (accept_cert_fingerprint) before anything is sent over it.
return Err(format!( return Err(format!(
@@ -203,15 +211,21 @@ pub async fn ws_connect<R: Runtime>(
} }
TofuOutcome::Mismatch { stored } => { TofuOutcome::Mismatch { stored } => {
let msg = tofu::mismatch_message(&host, &stored, &fingerprint); let msg = tofu::mismatch_message(&host, &stored, &fingerprint);
warn!("[ws_proxy] TOFU check FAILED for {} — certificate fingerprint mismatch", host); warn!(
"[ws_proxy] TOFU check FAILED for {} — certificate fingerprint mismatch",
host
);
debug!("[ws_proxy] TOFU detail: {}", msg); debug!("[ws_proxy] TOFU detail: {}", msg);
emit_cert_tofu(&app, serde_json::json!({ emit_cert_tofu(
"host": host, &app,
"fingerprint": fingerprint, serde_json::json!({
"status": "mismatch", "host": host,
"message": msg, "fingerprint": fingerprint,
"storedFingerprint": stored, "status": "mismatch",
})); "message": msg,
"storedFingerprint": stored,
}),
);
// Reject the connection — do not proceed. // Reject the connection — do not proceed.
return Err(msg); return Err(msg);
} }
@@ -311,10 +325,7 @@ pub async fn ws_connect<R: Runtime>(
/// Send a text message through the proxy WebSocket. /// Send a text message through the proxy WebSocket.
#[tauri::command] #[tauri::command]
pub async fn ws_send( pub async fn ws_send(state: tauri::State<'_, WsState>, message: String) -> Result<(), String> {
state: tauri::State<'_, WsState>,
message: String,
) -> Result<(), String> {
let tx_lock = state.tx.lock().await; let tx_lock = state.tx.lock().await;
if let Some(tx) = tx_lock.as_ref() { if let Some(tx) = tx_lock.as_ref() {
match tx.try_send(message) { match tx.try_send(message) {
@@ -395,8 +406,12 @@ pub fn accept_cert_fingerprint<R: Runtime>(
// fingerprint would be trusted in-process even though it was never // fingerprint would be trusted in-process even though it was never
// persisted to certs.json. // persisted to certs.json.
match old_value { match old_value {
Some(v) => { store.set(&host, v); } Some(v) => {
None => { let _ = store.delete(&host); } store.set(&host, v);
}
None => {
let _ = store.delete(&host);
}
} }
log::warn!("[ws_proxy] accept_cert_fingerprint: failed to persist pin for {host}: {e}"); log::warn!("[ws_proxy] accept_cert_fingerprint: failed to persist pin for {host}: {e}");
return Err(format!("failed to persist cert fingerprint: {e}")); return Err(format!("failed to persist cert fingerprint: {e}"));
@@ -591,7 +606,10 @@ mod tests {
let got = tokio::time::timeout(Duration::from_secs(1), rx.recv()) let got = tokio::time::timeout(Duration::from_secs(1), rx.recv())
.await .await
.expect("write task would hang forever: channel still open after disconnect"); .expect("write task would hang forever: channel still open after disconnect");
assert_eq!(got, None, "rx.recv() must yield None so the write task exits"); assert_eq!(
got, None,
"rx.recv() must yield None so the write task exits"
);
} }
// B4_conn_ipc-9: ws_disconnect must invalidate an in-flight ws_connect // B4_conn_ipc-9: ws_disconnect must invalidate an in-flight ws_connect
+2 -11
View File
@@ -30,17 +30,8 @@
"bundle": { "bundle": {
"active": true, "active": true,
"createUpdaterArtifacts": "v1Compatible", "createUpdaterArtifacts": "v1Compatible",
"targets": [ "targets": ["nsis", "appimage", "deb"],
"nsis", "icon": ["icons/32x32.png", "icons/128x128.png", "icons/128x128@2x.png", "icons/icon.ico"],
"appimage",
"deb"
],
"icon": [
"icons/32x32.png",
"icons/128x128.png",
"icons/128x128@2x.png",
"icons/icon.ico"
],
"linux": { "linux": {
"deb": { "deb": {
"depends": [ "depends": [
+6 -2
View File
@@ -94,9 +94,13 @@ a {
/* Thin scrollbar for sidebars */ /* Thin scrollbar for sidebars */
.channel-list::-webkit-scrollbar, .channel-list::-webkit-scrollbar,
.member-list::-webkit-scrollbar { width: 6px; } .member-list::-webkit-scrollbar {
width: 6px;
}
.channel-list::-webkit-scrollbar-thumb, .channel-list::-webkit-scrollbar-thumb,
.member-list::-webkit-scrollbar-thumb { background: var(--scrollbar-thin-thumb); } .member-list::-webkit-scrollbar-thumb {
background: var(--scrollbar-thin-thumb);
}
/* Utility classes */ /* Utility classes */
.sr-only { .sr-only {
+11 -11
View File
@@ -3,24 +3,24 @@
body.theme-neon-glow { body.theme-neon-glow {
/* Backgrounds — deeper, darker */ /* Backgrounds — deeper, darker */
--bg-tertiary: #0d0e10; --bg-tertiary: #0d0e10;
--bg-secondary: #111214; --bg-secondary: #111214;
--bg-primary: #1a1b1e; --bg-primary: #1a1b1e;
--bg-input: #252629; --bg-input: #252629;
--bg-hover: #1f2023; --bg-hover: #1f2023;
--bg-active: #2a2b2e; --bg-active: #2a2b2e;
/* Accent — defaults to OC cyan, but accent picker overrides --accent /* Accent — defaults to OC cyan, but accent picker overrides --accent
so --accent-primary and --accent-gradient follow the user's choice */ so --accent-primary and --accent-gradient follow the user's choice */
--accent: #00c8ff; --accent: #00c8ff;
--accent-hover: #7b2fff; --accent-hover: #7b2fff;
--accent-active: #6620e0; --accent-active: #6620e0;
--accent-primary: var(--accent); --accent-primary: var(--accent);
--accent-secondary: var(--accent-hover); --accent-secondary: var(--accent-hover);
--accent-gradient: linear-gradient(135deg, var(--accent-primary), var(--accent-secondary)); --accent-gradient: linear-gradient(135deg, var(--accent-primary), var(--accent-secondary));
/* Border glow — derived from accent for consistency */ /* Border glow — derived from accent for consistency */
--border: rgba(0, 200, 255, 0.08); --border: rgba(0, 200, 255, 0.08);
--border-strong: rgba(0, 200, 255, 0.15); --border-strong: rgba(0, 200, 255, 0.15);
--border-glow: rgba(0, 200, 255, 0.08); --border-glow: rgba(0, 200, 255, 0.08);
@@ -30,5 +30,5 @@ body.theme-neon-glow {
/* Semantic colors (theme contract) */ /* Semantic colors (theme contract) */
--color-success: #23a55a; --color-success: #23a55a;
--color-warning: #f0b232; --color-warning: #f0b232;
--color-danger: #f23f43; --color-danger: #f23f43;
} }
+49 -49
View File
@@ -5,103 +5,103 @@
:root { :root {
/* Backgrounds */ /* Backgrounds */
--bg-tertiary: #1e1f22; --bg-tertiary: #1e1f22;
--bg-secondary: #2b2d31; --bg-secondary: #2b2d31;
--bg-primary: #313338; --bg-primary: #313338;
--bg-input: #383a40; --bg-input: #383a40;
--bg-hover: #35373c; --bg-hover: #35373c;
--bg-active: #404249; --bg-active: #404249;
--bg-overlay: rgba(0, 0, 0, 0.7); --bg-overlay: rgba(0, 0, 0, 0.7);
/* Background modifiers (semi-transparent overlays) */ /* Background modifiers (semi-transparent overlays) */
--bg-modifier-hover: rgba(79, 84, 92, 0.16); --bg-modifier-hover: rgba(79, 84, 92, 0.16);
--bg-modifier-active: rgba(79, 84, 92, 0.24); --bg-modifier-active: rgba(79, 84, 92, 0.24);
--bg-modifier-selected: rgba(79, 84, 92, 0.32); --bg-modifier-selected: rgba(79, 84, 92, 0.32);
/* Accent */ /* Accent */
--accent: #5865f2; --accent: #5865f2;
--accent-hover: #4752c4; --accent-hover: #4752c4;
--accent-active: #3c45a5; --accent-active: #3c45a5;
/* Theme contract — overrideable by custom themes */ /* Theme contract — overrideable by custom themes */
--accent-primary: var(--accent); --accent-primary: var(--accent);
--accent-secondary: var(--accent-hover); --accent-secondary: var(--accent-hover);
--accent-gradient: linear-gradient(135deg, var(--accent-primary), var(--accent-secondary)); --accent-gradient: linear-gradient(135deg, var(--accent-primary), var(--accent-secondary));
--border-glow: transparent; --border-glow: transparent;
/* Text */ /* Text */
--text-normal: #dbdee1; --text-normal: #dbdee1;
--text-muted: #949ba4; --text-muted: #949ba4;
--text-faint: #80848e; --text-faint: #80848e;
--text-micro: #6d6f78; --text-micro: #6d6f78;
--text-link: #00a8fc; --text-link: #00a8fc;
/* Semantic text aliases */ /* Semantic text aliases */
--text-positive: #23a55a; --text-positive: #23a55a;
--text-warning: #f0b232; --text-warning: #f0b232;
--text-danger: #f23f43; --text-danger: #f23f43;
/* Header colors */ /* Header colors */
--header-primary: #f2f3f5; --header-primary: #f2f3f5;
--header-secondary: #b5bac1; --header-secondary: #b5bac1;
/* Interactive icon states */ /* Interactive icon states */
--interactive-normal: #b5bac1; --interactive-normal: #b5bac1;
--interactive-hover: #dbdee1; --interactive-hover: #dbdee1;
--interactive-active: #fff; --interactive-active: #fff;
--interactive-muted: #4e5058; --interactive-muted: #4e5058;
/* Channel icon */ /* Channel icon */
--channel-icon: #80848e; --channel-icon: #80848e;
/* Status colors */ /* Status colors */
--green: #23a55a; --green: #23a55a;
--yellow: #f0b232; --yellow: #f0b232;
--red: #f23f43; --red: #f23f43;
/* Borders */ /* Borders */
--border: #3f4147; --border: #3f4147;
--border-strong: #4e5058; --border-strong: #4e5058;
/* Role colors */ /* Role colors */
--role-owner: #e74c3c; --role-owner: #e74c3c;
--role-admin: #f39c12; --role-admin: #f39c12;
--role-mod: #2ecc71; --role-mod: #2ecc71;
--role-member: #949ba4; --role-member: #949ba4;
/* Elevation shadows */ /* Elevation shadows */
--elevation-low: 0 1px 0 rgba(4, 4, 5, 0.2), 0 1.5px 0 rgba(4, 4, 5, 0.05); --elevation-low: 0 1px 0 rgba(4, 4, 5, 0.2), 0 1.5px 0 rgba(4, 4, 5, 0.05);
--elevation-high: 0 8px 16px rgba(0, 0, 0, 0.24); --elevation-high: 0 8px 16px rgba(0, 0, 0, 0.24);
/* Scrollbar colors */ /* Scrollbar colors */
--scrollbar-thin-thumb: #1a1b1e; --scrollbar-thin-thumb: #1a1b1e;
--scrollbar-auto-thumb: #1a1b1e; --scrollbar-auto-thumb: #1a1b1e;
--scrollbar-auto-track: transparent; --scrollbar-auto-track: transparent;
/* Transition timing */ /* Transition timing */
--transition-fast: 100ms ease; --transition-fast: 100ms ease;
--transition-normal: 170ms ease; --transition-normal: 170ms ease;
--transition-slow: 200ms ease; --transition-slow: 200ms ease;
/* Typography */ /* Typography */
--font-display: "Segoe UI Variable Display", "Segoe UI", system-ui, sans-serif; --font-display: "Segoe UI Variable Display", "Segoe UI", system-ui, sans-serif;
--font-body: "Segoe UI Variable Text", "Segoe UI", system-ui, sans-serif; --font-body: "Segoe UI Variable Text", "Segoe UI", system-ui, sans-serif;
--font-mono: "Cascadia Code", "Consolas", monospace; --font-mono: "Cascadia Code", "Consolas", monospace;
/* Typography scale */ /* Typography scale */
--font-size-xxs: 10px; --font-size-xxs: 10px;
--font-size-xs: 12px; --font-size-xs: 12px;
--font-size-sm: 13px; --font-size-sm: 13px;
--font-size-md: 14px; --font-size-md: 14px;
--font-size-lg: 16px; --font-size-lg: 16px;
--font-size-xl: 20px; --font-size-xl: 20px;
--font-size-xxl: 24px; --font-size-xxl: 24px;
/* Radii */ /* Radii */
--radius-sm: 4px; --radius-sm: 4px;
--radius-md: 8px; --radius-md: 8px;
--radius-lg: 16px; --radius-lg: 16px;
--radius-pill: 24px; --radius-pill: 24px;
--radius-circle: 50%; --radius-circle: 50%;
/* Spacing */ /* Spacing */
+1 -6
View File
@@ -7,12 +7,7 @@ const config = {
vitest: { vitest: {
configFile: "vitest.config.ts", configFile: "vitest.config.ts",
}, },
mutate: [ mutate: ["src/lib/**/*.ts", "src/stores/**/*.ts", "!src/lib/types.ts", "!src/**/*.d.ts"],
"src/lib/**/*.ts",
"src/stores/**/*.ts",
"!src/lib/types.ts",
"!src/**/*.d.ts",
],
reporters: ["html", "clear-text", "progress"], reporters: ["html", "clear-text", "progress"],
htmlReporter: { htmlReporter: {
fileName: "reports/mutation/index.html", fileName: "reports/mutation/index.html",
+12 -12
View File
@@ -6,10 +6,10 @@
## Current status: 291 web tests, 291 passed (100%) ## Current status: 291 web tests, 291 passed (100%)
| Run | Result | Wall time | | Run | Result | Wall time |
| --- | ------ | --------- | | ------------------------------------------------------------------------------------------- | ---------------------------------------------- | --------- |
| Full web suite (`playwright.config.ts`) | **291 / 291 passed**, 0 flaky retries observed | 9.2 min | | Full web suite (`playwright.config.ts`) | **291 / 291 passed**, 0 flaky retries observed | 9.2 min |
| `@parity` subset (`--grep "@parity"` — mirrors the **blocking** `client-e2e-parity` CI job) | **15 / 15 passed** | 33 s | | `@parity` subset (`--grep "@parity"` — mirrors the **blocking** `client-e2e-parity` CI job) | **15 / 15 passed** | 33 s |
Changes since the `914cdac` run (+15 tests, 3 new spec files — DC-04's last Changes since the `914cdac` run (+15 tests, 3 new spec files — DC-04's last
client journeys plus the DC-13 smoke): client journeys plus the DC-13 smoke):
@@ -35,8 +35,8 @@ renamed `sidebar-header.spec.ts`, and the mock exposes its event-listener
registry (`window.__tauriEventListeners`) so specs can wait for async listener registry (`window.__tauriEventListeners`) so specs can wait for async listener
registration instead of racing it. registration instead of racing it.
Flake accounting rule used here: a spec is *flaky* only if it failed and then Flake accounting rule used here: a spec is _flaky_ only if it failed and then
passed on retry within a run; a spec failing every attempt is *failing*, named passed on retry within a run; a spec failing every attempt is _failing_, named
by file. This run had neither. by file. This run had neither.
## Suite inventory ## Suite inventory
@@ -59,12 +59,12 @@ by file. This run had neither.
## CI wiring (`.github/workflows/ci.yml`) ## CI wiring (`.github/workflows/ci.yml`)
| Job | Blocking? | What it runs | | Job | Blocking? | What it runs |
| --- | --------- | ------------ | | ------------------- | ------------------------------------------ | ---------------------------------------- |
| `client-e2e` | **Yes** (blocking since 2026-08-05, DC-07) | Full web suite, every PR | | `client-e2e` | **Yes** (blocking since 2026-08-05, DC-07) | Full web suite, every PR |
| `client-e2e-parity` | **Yes** | The `@parity` subset | | `client-e2e-parity` | **Yes** | The `@parity` subset |
| `admin-e2e` | No (`continue-on-error`, earning its soak) | The admin-panel journey vs a real server | | `admin-e2e` | No (`continue-on-error`, earning its soak) | The admin-panel journey vs a real server |
| native config | not in CI | Windows-only; run manually | | native config | not in CI | Windows-only; run manually |
## Known issues (open) ## Known issues (open)
+3 -1
View File
@@ -21,7 +21,9 @@ cleanup() {
# Playwright kills the process group on teardown; the trap covers manual # Playwright kills the process group on teardown; the trap covers manual
# runs. The temp dir is left behind on failure for post-mortems and # runs. The temp dir is left behind on failure for post-mortems and
# reaped by the OS otherwise. # reaped by the OS otherwise.
[[ -n "${SERVER_PID:-}" ]] && kill "$SERVER_PID" 2>/dev/null || true if [[ -n "${SERVER_PID:-}" ]]; then
kill "$SERVER_PID" 2>/dev/null || true
fi
} }
trap cleanup EXIT trap cleanup EXIT
+10 -10
View File
@@ -34,20 +34,20 @@ That keeps iteration fast, and it also means behaviour can change quickly betwee
## What works right now ## What works right now
| Area | Status | | Area | Status |
| ---- | ------ | | ------------------ | ------------------------------------------------------------------------------------------------------------------------------- |
| Core chat flow | Working in alpha | | Core chat flow | Working in alpha |
| Voice/video | Working in alpha | | Voice/video | Working in alpha |
| Admin panel | Working in alpha | | Admin panel | Working in alpha |
| Security hardening | Ongoing review passes; findings and their statuses are tracked in the dated audits in [docs/](docs/) (see the Docs Index below) | | Security hardening | Ongoing review passes; findings and their statuses are tracked in the dated audits in [docs/](docs/) (see the Docs Index below) |
## Platform Support (Current Releases) ## Platform Support (Current Releases)
| Component | Windows x64 | Linux x64 | Linux ARM64 | | Component | Windows x64 | Linux x64 | Linux ARM64 |
| --------- | ----------- | --------- | ----------- | | -------------- | -------------------- | --------------------------- | -------------------- |
| Server binary | Yes | Yes | Not yet | | Server binary | Yes | Yes | Not yet |
| Desktop client | Yes (NSIS installer) | Yes (AppImage, .deb) | Yes (AppImage, .deb) | | Desktop client | Yes (NSIS installer) | Yes (AppImage, .deb) | Yes (AppImage, .deb) |
| Docker server | N/A | Build from source (compose) | Not yet | | Docker server | N/A | Build from source (compose) | Not yet |
## Start Here ## Start Here
+4 -4
View File
@@ -5,10 +5,10 @@
OwnCord is in alpha. Only the **latest release** receives security fixes. OwnCord is in alpha. Only the **latest release** receives security fixes.
There are no backports. There are no backports.
| Version | Supported | | Version | Supported |
| ------- | --------- | | ------------------------------------------------------------------------- | --------- |
| Latest release (see [Releases](https://github.com/J3vb/OwnCord/releases)) | Yes | | Latest release (see [Releases](https://github.com/J3vb/OwnCord/releases)) | Yes |
| Anything older | No | | Anything older | No |
## Reporting a vulnerability ## Reporting a vulnerability
+37 -22
View File
@@ -2,40 +2,40 @@ version: "2"
linters: linters:
enable: enable:
- gocritic # opinionated checks for bugs, performance, style - gocritic # opinionated checks for bugs, performance, style
- gosec # security-focused static analysis (SQL injection, hardcoded creds, weak crypto) - gosec # security-focused static analysis (SQL injection, hardcoded creds, weak crypto)
- errcheck # ensures all errors are checked - errcheck # ensures all errors are checked
- bodyclose # detects unclosed HTTP response bodies - bodyclose # detects unclosed HTTP response bodies
- contextcheck # verifies context.Context propagation - contextcheck # verifies context.Context propagation
- nilerr # detects returning nil when err is not nil - nilerr # detects returning nil when err is not nil
- prealloc # suggests pre-allocating slices for performance - prealloc # suggests pre-allocating slices for performance
- unconvert # removes unnecessary type conversions - unconvert # removes unnecessary type conversions
- unparam # finds unused function parameters - unparam # finds unused function parameters
- wastedassign # finds wasted assignments - wastedassign # finds wasted assignments
- staticcheck # advanced static analysis (correctness, performance, deprecation) - staticcheck # advanced static analysis (correctness, performance, deprecation)
- modernize # flags outdated idioms (slices/maps/min/max, range-over-int, any, fmt.Appendf) - modernize # flags outdated idioms (slices/maps/min/max, range-over-int, any, fmt.Appendf)
# Complexity budgets. These are a RATCHET, not a standard: each threshold sits # Complexity budgets. These are a RATCHET, not a standard: each threshold sits
# just above today's worst offender, so the build is green now and the budget # just above today's worst offender, so the build is green now and the budget
# only blocks regression past the current extreme. Tighten them over time — each # only blocks regression past the current extreme. Tighten them over time — each
# step is a one-line edit here plus the refactor it forces. Measured values and # step is a one-line edit here plus the refactor it forces. Measured values and
# the work each budget is waiting on are in the settings block below. # the work each budget is waiting on are in the settings block below.
- funlen # function length (lines and statements) - funlen # function length (lines and statements)
- cyclop # cyclomatic complexity per function - cyclop # cyclomatic complexity per function
- nestif # deeply nested if-blocks - nestif # deeply nested if-blocks
- dupl # verbatim duplicated blocks - dupl # verbatim duplicated blocks
settings: settings:
staticcheck: staticcheck:
checks: checks:
- "all" - "all"
- "-SA1019" # suppress deprecated usage warnings (websocket library migration tracked separately) - "-SA1019" # suppress deprecated usage warnings (websocket library migration tracked separately)
gocritic: gocritic:
enabled-tags: enabled-tags:
- diagnostic - diagnostic
- performance - performance
disabled-checks: disabled-checks:
- hugeParam # too noisy for struct-heavy code - hugeParam # too noisy for struct-heavy code
# Complexity budgets set to a TARGET, not to whatever passes today. Existing # Complexity budgets set to a TARGET, not to whatever passes today. Existing
# offenders are deliberately left failing rather than excluded: an exclusion # offenders are deliberately left failing rather than excluded: an exclusion
# list goes stale and quietly becomes permanent, whereas a failing check is a # list goes stale and quietly becomes permanent, whereas a failing check is a
@@ -70,10 +70,10 @@ linters:
threshold: 150 threshold: 150
gosec: gosec:
excludes: excludes:
- G104 # unhandled errors — errcheck covers this better - G104 # unhandled errors — errcheck covers this better
- G304 # file path from variable — expected in file storage code - G304 # file path from variable — expected in file storage code
- G306 # WriteFile perms ≤0600 — our only hits are generated source files (genprotocol), which must stay world-readable or multi-stage container builds break - G306 # WriteFile perms ≤0600 — our only hits are generated source files (genprotocol), which must stay world-readable or multi-stage container builds break
- G706 # log injection — false positive with slog structured logging (values are typed key-value pairs, not interpolated) - G706 # log injection — false positive with slog structured logging (values are typed key-value pairs, not interpolated)
exclusions: exclusions:
# Suppress noisy linters in test files # Suppress noisy linters in test files
@@ -90,6 +90,21 @@ linters:
- linters: [funlen, cyclop, nestif, dupl] - linters: [funlen, cyclop, nestif, dupl]
path: _test\.go path: _test\.go
# gofmt is the required Go formatting gate (S-05). It lives here rather than as a
# standalone CI step because `gofmt -l` prints offenders and still exits 0, so a
# `gofmt -l` step cannot fail a build. Running it as a formatter inside
# golangci-lint means it reports through the already-pinned Lint check.
#
# In v2 the formatters moved out of `linters.enable` into their own section, and
# they carry their own exclusion list.
formatters:
enable:
- gofmt
exclusions:
paths:
# sqlc output, verified by `git diff --exit-code db/dbgen` after regeneration.
- db/dbgen
# Report every finding. The defaults hide work three separate ways, which is fine # Report every finding. The defaults hide work three separate ways, which is fine
# when the list is meant to be empty and actively misleading when it is a backlog: # when the list is meant to be empty and actively misleading when it is a backlog:
# "fixed everything shown" would leave more behind. uniq-by-line is the sharp one — # "fixed everything shown" would leave more behind. uniq-by-line is the sharp one —
+8 -8
View File
@@ -23,14 +23,14 @@ unleash:
# Mutation operators to apply # Mutation operators to apply
mutant_types: mutant_types:
- CONDITIONALS_BOUNDARY # < to <=, > to >= - CONDITIONALS_BOUNDARY # < to <=, > to >=
- CONDITIONALS_NEGATION # == to != - CONDITIONALS_NEGATION # == to !=
- INCREMENT_DECREMENT # ++ to -- - INCREMENT_DECREMENT # ++ to --
- INVERT_NEGATIVES # -x to x - INVERT_NEGATIVES # -x to x
- ARITHMETIC_BASE # + to -, * to / - ARITHMETIC_BASE # + to -, * to /
- INVERT_LOGICAL # && to || - INVERT_LOGICAL # && to ||
- INVERT_LOOPCTRL # break to continue - INVERT_LOOPCTRL # break to continue
- REMOVE_SELF_ASSIGNMENTS # x += 1 to x - REMOVE_SELF_ASSIGNMENTS # x += 1 to x
# Thresholds for pass/fail # Thresholds for pass/fail
threshold: threshold:
@@ -96,7 +96,7 @@ func TestAdminAPI_PatchUser_RoleChangeRefreshesVisibility(t *testing.T) {
// because handlePatchUser calls that exactly once, synchronously, right // because handlePatchUser calls that exactly once, synchronously, right
// after ChangeUserRole succeeds and right before the vulnerable re-read. // after ChangeUserRole succeeds and right before the vulnerable re-read.
type roleDeletingInvalidator struct { type roleDeletingInvalidator struct {
database *db.DB database *db.DB
deleteRoleID, fallbackRoleID int64 deleteRoleID, fallbackRoleID int64
} }
+4 -5
View File
@@ -13,16 +13,15 @@
# OwnCord metrics (when server is running): http://localhost:8443/metrics # OwnCord metrics (when server is running): http://localhost:8443/metrics
services: services:
jaeger: jaeger:
image: jaegertracing/all-in-one:latest image: jaegertracing/all-in-one:latest
restart: unless-stopped restart: unless-stopped
environment: environment:
COLLECTOR_OTLP_ENABLED: "true" COLLECTOR_OTLP_ENABLED: "true"
ports: ports:
- "16686:16686" # Jaeger UI - "16686:16686" # Jaeger UI
- "4317:4317" # OTLP gRPC (used by the server's tracer exporter) - "4317:4317" # OTLP gRPC (used by the server's tracer exporter)
- "4318:4318" # OTLP HTTP (alternative endpoint) - "4318:4318" # OTLP HTTP (alternative endpoint)
networks: networks:
- otel-net - otel-net
@@ -31,7 +30,7 @@ services:
restart: unless-stopped restart: unless-stopped
command: command:
- "--config.file=/etc/prometheus/prometheus.yml" - "--config.file=/etc/prometheus/prometheus.yml"
- "--storage.tsdb.retention.time=1h" # dev-only short retention - "--storage.tsdb.retention.time=1h" # dev-only short retention
volumes: volumes:
- ./prometheus.dev.yml:/etc/prometheus/prometheus.yml:ro - ./prometheus.dev.yml:/etc/prometheus/prometheus.yml:ro
ports: ports:
+3 -4
View File
@@ -19,7 +19,6 @@ x-logging: &default-logging
max-file: "3" max-file: "3"
services: services:
owncord: owncord:
image: ghcr.io/j3vb/owncord-server:latest image: ghcr.io/j3vb/owncord-server:latest
restart: unless-stopped restart: unless-stopped
@@ -61,9 +60,9 @@ services:
# LiveKit config file — copy livekit.yaml.example → livekit.yaml and fill in # LiveKit config file — copy livekit.yaml.example → livekit.yaml and fill in
command: --config /etc/livekit/livekit.yaml command: --config /etc/livekit/livekit.yaml
ports: ports:
- "7880:7880" # WebSocket API (OwnCord server + browser signalling) - "7880:7880" # WebSocket API (OwnCord server + browser signalling)
- "7881:7881" # TCP fallback for WebRTC (clients behind strict NAT) - "7881:7881" # TCP fallback for WebRTC (clients behind strict NAT)
- "50000-60000:50000-60000/udp" # WebRTC UDP media streams - "50000-60000:50000-60000/udp" # WebRTC UDP media streams
volumes: volumes:
- ./livekit.yaml:/etc/livekit/livekit.yaml:ro - ./livekit.yaml:/etc/livekit/livekit.yaml:ro
logging: *default-logging logging: *default-logging
+4 -4
View File
@@ -21,10 +21,10 @@ The pre-built `hello.wasm` (925 KiB) is checked in, but you can rebuild it:
### Prerequisites ### Prerequisites
| Tool | Version | Notes | | Tool | Version | Notes |
|------|---------|-------| | -------- | ------------ | ---------------------------------------- |
| TinyGo | 0.40.1 | Supports Go 1.191.25 only | | TinyGo | 0.40.1 | Supports Go 1.191.25 only |
| Go | 1.25.x | TinyGo 0.40.1 rejects Go 1.26+ | | Go | 1.25.x | TinyGo 0.40.1 rejects Go 1.26+ |
| wasm-opt | Binaryen 129 | Required by TinyGo for the `wasi` target | | wasm-opt | Binaryen 129 | Required by TinyGo for the `wasi` target |
On Windows, extract TinyGo to e.g. `D:\Local-Lab\Coding\Software\tinygo` and On Windows, extract TinyGo to e.g. `D:\Local-Lab\Coding\Software\tinygo` and
+1 -3
View File
@@ -5,9 +5,7 @@
"description": "Trivial proof-of-life plugin: registers /hello and echoes message_send events.", "description": "Trivial proof-of-life plugin: registers /hello and echoes message_send events.",
"entrypoint": "hello.wasm", "entrypoint": "hello.wasm",
"permissions": ["commands", "events", "storage"], "permissions": ["commands", "events", "storage"],
"commands": [ "commands": [{ "name": "hello" }],
{ "name": "hello" }
],
"resources": { "resources": {
"max_memory_mb": 16, "max_memory_mb": 16,
"cpu_budget_ms": 50 "cpu_budget_ms": 50
+1 -1
View File
@@ -10,4 +10,4 @@ scrape_configs:
- targets: ["host.docker.internal:8443"] - targets: ["host.docker.internal:8443"]
scheme: https scheme: https
tls_config: tls_config:
insecure_skip_verify: true # dev-only; real certs in staging/prod insecure_skip_verify: true # dev-only; real certs in staging/prod
+43 -43
View File
@@ -11,40 +11,40 @@ dated snapshots that were true when written and were never updated, and
## Start here ## Start here
| I want to… | Read | | I want to… | Read |
| --- | --- | | ---------------------- | --------------------------------------- |
| Run a server | [quick-start.md](quick-start.md) | | Run a server | [quick-start.md](quick-start.md) |
| Deploy for real | [deployment.md](deployment.md) | | Deploy for real | [deployment.md](deployment.md) |
| Contribute a change | [contributing.md](contributing.md) | | Contribute a change | [contributing.md](contributing.md) |
| Understand the system | [architecture/](architecture/README.md) | | Understand the system | [architecture/](architecture/README.md) |
| Report a vulnerability | [security.md](security.md) | | Report a vulnerability | [security.md](security.md) |
## Guidance ## Guidance
| Document | Covers | | Document | Covers |
| --- | --- | | ---------------------------------------- | ------------------------------------------------------------------------------------------------ |
| [quick-start.md](quick-start.md) | Getting a server running with the fewest steps. | | [quick-start.md](quick-start.md) | Getting a server running with the fewest steps. |
| [deployment.md](deployment.md) | Production deployment on Windows and Linux. | | [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. | | [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. | | [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. | | [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. | | [port-forwarding.md](port-forwarding.md) | Making a server reachable from outside the LAN. |
| [tailscale.md](tailscale.md) | Remote access without port forwarding. | | [tailscale.md](tailscale.md) | Remote access without port forwarding. |
| [mcp-introspect.md](mcp-introspect.md) | Dev-only MCP server for introspecting a running instance. | | [mcp-introspect.md](mcp-introspect.md) | Dev-only MCP server for introspecting a running instance. |
## Reference ## Reference
These describe contracts the code implements. If one disagrees with the code, These describe contracts the code implements. If one disagrees with the code,
the code is right and the document is a bug. the code is right and the document is a bug.
| Document | Covers | | Document | Covers |
| --- | --- | | -------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------ |
| [api.md](api.md) | REST API under `/api/v1`. | | [api.md](api.md) | REST API under `/api/v1`. |
| [protocol.md](protocol.md) | WebSocket protocol — frames, sequencing, reconnect. | | [protocol.md](protocol.md) | WebSocket protocol — frames, sequencing, reconnect. |
| [schema.md](schema.md) | SQLite schema and migrations. | | [schema.md](schema.md) | SQLite schema and migrations. |
| [server-configuration.md](server-configuration.md) | Every server configuration option. | | [server-configuration.md](server-configuration.md) | Every server configuration option. |
| [credential-storage.md](credential-storage.md) | What the desktop client persists, and where. | | [credential-storage.md](credential-storage.md) | What the desktop client persists, and where. |
| [protocol-schema.json](protocol-schema.json) | **Generated-code source of truth.** `Server/ws/message_types.go` and `Client/src/lib/protocolTypes.ts` are generated from it — never hand-edit either. | | [protocol-schema.json](protocol-schema.json) | **Generated-code source of truth.** `Server/ws/message_types.go` and `Client/src/lib/protocolTypes.ts` are generated from it — never hand-edit either. |
## Architecture ## Architecture
@@ -66,17 +66,17 @@ 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 keep resolving. Anything here may be stale; the ledger and the plan index carry
current status. current status.
| Audit | Scope | | 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-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-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-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-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-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-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-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-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. | | [audit-2026-04-07.md](audit-2026-04-07.md) | First comprehensive audit. |
## Plans ## Plans
@@ -89,13 +89,13 @@ over a plan's own header**, which can drift.
Do not read a defect count, or a "what works" claim, out of a document on this Do not read a defect count, or a "what works" claim, out of a document on this
page. Status has owners: page. Status has owners:
| Concern | Source of truth | | Concern | Source of truth |
| --- | --- | | -------------------------- | ---------------------------------------------------------------------------------- |
| Defect status | `.superpowers/findings-ledger.json` (`FINDINGS.md` is rendered from it) | | Defect status | `.superpowers/findings-ledger.json` (`FINDINGS.md` is rendered from it) |
| Security-sensitive defects | Private GitHub Security Advisories | | 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) | | 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) | | 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" | | 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 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 counts. Adding a count to a document means adding it to that check's allow-list
+243 -215
View File
@@ -46,19 +46,19 @@ endpoints return plain-text errors — see their section):
### Error Codes ### Error Codes
| Code | HTTP Status | When It Occurs | | Code | HTTP Status | When It Occurs |
| ---- | ----------- | -------------- | | ------------------------------- | ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `UNAUTHORIZED` | 401 | Missing/invalid/expired session token | | `UNAUTHORIZED` | 401 | Missing/invalid/expired session token |
| `INVALID_CREDENTIALS` | 401 | Login/register with bad username/password/invite (generic to prevent enumeration) | | `INVALID_CREDENTIALS` | 401 | Login/register with bad username/password/invite (generic to prevent enumeration) |
| `FORBIDDEN` | 403 | Insufficient permissions, banned account, or admin IP restriction | | `FORBIDDEN` | 403 | Insufficient permissions, banned account, or admin IP restriction |
| `NOT_FOUND` | 404 | Resource (channel, message, user, invite, file, backup) not found | | `NOT_FOUND` | 404 | Resource (channel, message, user, invite, file, backup) not found |
| `RATE_LIMITED` | 429 | Too many requests; response includes `Retry-After` header (seconds) | | `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") | | `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 | | `CONFLICT` | 409 | Duplicate username on register, or server already up-to-date on update |
| `INTERNAL_ERROR` | 500 | Internal server error | | `INTERNAL_ERROR` | 500 | Internal server error |
| `STORAGE_ERROR` | 507 | Upload could not be persisted (storage backend write failure) | | `STORAGE_ERROR` | 507 | Upload could not be persisted (storage backend write failure) |
| `BAD_GATEWAY` | 502 | Upstream failure (GitHub API, LiveKit, GIF provider, asset download) | | `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`) | | `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 | | Field | Type | Required | Notes |
| ----- | ---- | -------- | ----- | | ------------- | ------ | -------- | --------------------------------------------------------------------- |
| `username` | string | Yes | HTML-stripped, trimmed. Must be non-empty. | | `username` | string | Yes | HTML-stripped, trimmed. Must be non-empty. |
| `password` | string | Yes | Validated for strength (min length, complexity). | | `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. | | `invite_code` | string | Yes | Must be a valid, non-expired, non-revoked invite with remaining uses. |
#### Response 201 Created #### Response 201 Created
@@ -111,13 +111,13 @@ See [GET /api/v1/auth/me](#get-apiv1authme) for the full user-object field table
#### Errors #### Errors
| Status | Code | Cause | | Status | Code | Cause |
| ------ | ---- | ----- | | ------ | --------------------- | ----------------------------------------------------------------------- |
| 400 | `INVALID_INPUT` | Missing username/password/invite_code, or weak password | | 400 | `INVALID_INPUT` | Missing username/password/invite_code, or weak password |
| 400 | `INVALID_CREDENTIALS` | Bad invite code, expired/revoked invite, or duplicate username | | 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 | | 403 | `FORBIDDEN` | Registration is closed or unavailable while server-wide 2FA is required |
| 429 | `RATE_LIMITED` | Exceeded 3 registrations/minute from this IP | | 429 | `RATE_LIMITED` | Exceeded 3 registrations/minute from this IP |
| 500 | `INTERNAL_ERROR` | Hashing failure, session creation failure, or DB error | | 500 | `INTERNAL_ERROR` | Hashing failure, session creation failure, or DB error |
--- ---
@@ -173,13 +173,13 @@ If the account has TOTP enabled:
#### Errors #### Errors
| Status | Code | Cause | | Status | Code | Cause |
| ------ | ---- | ----- | | ------ | ---------------- | ------------------------------------------------------------- |
| 400 | `INVALID_INPUT` | Missing username or password | | 400 | `INVALID_INPUT` | Missing username or password |
| 401 | `UNAUTHORIZED` | Wrong username or password | | 401 | `UNAUTHORIZED` | Wrong username or password |
| 403 | `FORBIDDEN` | Account is banned/suspended | | 403 | `FORBIDDEN` | Account is banned/suspended |
| 429 | `RATE_LIMITED` | IP locked out after 10 consecutive failures (15 min cooldown) | | 429 | `RATE_LIMITED` | IP locked out after 10 consecutive failures (15 min cooldown) |
| 500 | `INTERNAL_ERROR` | Session creation failure | | 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 #### Errors
| Status | Code | Cause | | Status | Code | Cause |
| ------ | ---- | ----- | | ------ | ---------------- | ------------------------------------------------------------------- |
| 400 | `INVALID_INPUT` | Malformed request body | | 400 | `INVALID_INPUT` | Malformed request body |
| 401 | `UNAUTHORIZED` | Missing/expired challenge, invalid TOTP code, or challenge consumed | | 401 | `UNAUTHORIZED` | Missing/expired challenge, invalid TOTP code, or challenge consumed |
| 500 | `INTERNAL_ERROR` | Session creation failure | | 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, This is the canonical **user object**, also returned as `user` by register,
login and the TOTP challenge. login and the TOTP challenge.
| Field | Type | Description | | Field | Type | Description |
| ----- | ---- | ----------- | | --------------- | ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `id` | int64 | User ID | | `id` | int64 | User ID |
| `username` | string | Unique handle; the name `@mentions` resolve against | | `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 | | `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 | | `display_name` | string\|null | Nickname rendered instead of `username`; null when unset |
| `about` | string\|null | Profile bio, max 300 characters; 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 | | `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 | | `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) | | `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 | | `totp_enabled` | bool | Whether the user has a confirmed TOTP secret |
| `created_at` | string | ISO 8601 timestamp | | `created_at` | string | ISO 8601 timestamp |
--- ---
@@ -303,12 +303,12 @@ Account deleted successfully. All sessions, messages (soft-deleted), and associa
#### Errors #### Errors
| Status | Code | Cause | | Status | Code | Cause |
| ------ | ---- | ----- | | ------ | ---------------- | ------------------------------------------------------------- |
| 400 | `INVALID_INPUT` | Missing or incorrect password | | 400 | `INVALID_INPUT` | Missing or incorrect password |
| 403 | `FORBIDDEN` | Cannot delete the last admin account | | 403 | `FORBIDDEN` | Cannot delete the last admin account |
| 429 | `RATE_LIMITED` | Locked out after 3 failed password attempts (15 min cooldown) | | 429 | `RATE_LIMITED` | Locked out after 3 failed password attempts (15 min cooldown) |
| 500 | `INTERNAL_ERROR` | Database error during deletion | | 500 | `INTERNAL_ERROR` | Database error during deletion |
--- ---
@@ -399,13 +399,13 @@ event replaces the client's copy rather than patching it).
} }
``` ```
| Field | Rules | | Field | Rules |
| -------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | --------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `username` | Required. The unique handle; `@mentions` resolve against it. | | `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`. | | `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, 132 characters. Shown instead of `username` everywhere; `""` clears it and falls back to the username. Rejected if it contains control or invisible (bidi-override) characters. | | `display_name` | Optional, 132 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. | | `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). | | `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. Omitting a field leaves it unchanged; sending `""` clears the nullable ones.
`display_name` and `about` are HTML-sanitized and trimmed server-side, and the `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 ### PUT /api/v1/users/me/password
Change the authenticated user's password. Verifies the old password, enforces 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 **Auth:** Required
**Rate limit:** 5 requests/minute, plus a failed-confirmation lockout on **Rate limit:** 5 requests/minute, plus a failed-confirmation lockout on
@@ -500,11 +500,11 @@ old one).
#### Errors #### Errors
| Status | Code | Cause | | Status | Code | Cause |
| ------ | ---- | ----- | | ------ | --------------- | --------------------------------------------- |
| 400 | `INVALID_INPUT` | Weak new password, or new password equals old | | 400 | `INVALID_INPUT` | Weak new password, or new password equals old |
| 403 | `FORBIDDEN` | Incorrect old password | | 403 | `FORBIDDEN` | Incorrect old password |
| 429 | `RATE_LIMITED` | Too many attempts / lockout | | 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 | | Field | Type | Description |
| ----- | ---- | ----------- | | ----------------- | ------ | ----------------------------------------------------------------------------------------------------------------------------- |
| `id` | int64 | Channel ID | | `id` | int64 | Channel ID |
| `name` | string | Channel name | | `name` | string | Channel name |
| `type` | string | `text`, `voice`, or `announcement` (announcement channels are read like text but only `MANAGE_MESSAGES` holders can post) | | `type` | string | `text`, `voice`, or `announcement` (announcement channels are read like text but only `MANAGE_MESSAGES` holders can post) |
| `topic` | string | Channel topic/description | | `topic` | string | Channel topic/description |
| `category` | string | Category grouping | | `category` | string | Category grouping |
| `position` | int | Sort order within category | | `position` | int | Sort order within category |
| `slow_mode` | int | Slow-mode delay in seconds (0 = disabled) | | `slow_mode` | int | Slow-mode delay in seconds (0 = disabled) |
| `archived` | bool | Whether the channel is archived | | `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) | | `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_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`) | | `voice_max_video` | int | Simultaneous cameras/screen shares, 0 = unlimited. Enforced on publish (`VIDEO_LIMIT`) |
#### The `nsfw` flag #### The `nsfw` flag
@@ -608,10 +608,10 @@ Paginated message history for a channel.
#### Query Parameters #### Query Parameters
| Param | Type | Default | Range | Description | | Param | Type | Default | Range | Description |
| ----- | ---- | ------- | ----- | ----------- | | -------- | ----- | ---------- | ----- | ---------------------------------------------------- |
| `before` | int64 | 0 (latest) | >= 0 | Cursor: return messages with ID less than this value | | `before` | int64 | 0 (latest) | >= 0 | Cursor: return messages with ID less than this value |
| `limit` | int | 50 | 1-100 | Number of messages to return | | `limit` | int | 50 | 1-100 | Number of messages to return |
#### Response 200 OK #### Response 200 OK
@@ -689,9 +689,9 @@ reference, or an `owncord://message/{channelId}/{messageId}` permalink.
#### Query Parameters #### Query Parameters
| Param | Type | Default | Range | Description | | Param | Type | Default | Range | Description |
| ----- | ---- | ------- | ----- | ----------- | | ------- | ---- | ------- | ----- | ---------------------------------- |
| `limit` | int | 50 | 1-100 | Total window size, centre included | | `limit` | int | 50 | 1-100 | Total window size, centre included |
Half the window sits before the centre and the remainder after it: `limit=50` 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 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 `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 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 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 client should offer a "jump to present" affordance that refetches the normal
`GET /messages` tail. `GET /messages` tail.
#### Errors #### Errors
| Status | Code | When | | Status | Code | When |
|--------|------|------| | ------ | ------------- | ------------------------------------------------------------------------------------------------------------------- |
| 400 | `BAD_REQUEST` | `id` or `messageId` is not a positive integer, or `limit` is not a positive integer | | 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 | | 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 | | 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 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 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 | | 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. | | `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. | | `before` | integer | No | Only delete messages with an id below this one. Omit or `0` to start from the newest. |
#### Response 200 OK #### 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 emoji, which is also the answer for an emoji that does not exist). `avatar` is
`""` when the user has none. `""` when the user has none.
| Status | Error | When | | Status | Error | When |
|--------|-------|------| | ------ | ------------- | ------------------------------------------------------------------------------------------------ |
| 400 | `BAD_REQUEST` | Non-positive `id`/`messageId`, or an empty / over-32-rune / control-character emoji | | 400 | `BAD_REQUEST` | Non-positive `id`/`messageId`, or an empty / over-32-rune / control-character emoji |
| 403 | `FORBIDDEN` | No `READ_MESSAGES` on the channel | | 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 | | 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 #### Query Parameters
| Param | Type | Default | Range | Description | | Param | Type | Default | Range | Description |
| ----- | ---- | ------- | ----- | ----------- | | ------------ | ------ | -------------- | --------- | ----------------------------------- |
| `q` | string | (required) | non-empty | Search query (FTS5 syntax) | | `q` | string | (required) | non-empty | Search query (FTS5 syntax) |
| `channel_id` | int64 | (all channels) | > 0 | Restrict search to a single channel | | `channel_id` | int64 | (all channels) | > 0 | Restrict search to a single channel |
| `limit` | int | 50 | 1-100 | Maximum results to return | | `limit` | int | 50 | 1-100 | Maximum results to return |
#### Response 200 OK #### Response 200 OK
@@ -915,10 +915,10 @@ them against its `klipy.com` CDN allowlist before rendering.
#### Query Parameters #### Query Parameters
| Param | Type | Default | Range | Description | | Param | Type | Default | Range | Description |
| ----- | ---- | ------- | ----- | ----------- | | ------- | ------ | ---------- | ----------- | ------------------------- |
| `q` | string | (required) | 1-100 chars | Search term | | `q` | string | (required) | 1-100 chars | Search term |
| `limit` | int | 20 | 1-50 | Maximum results to return | | `limit` | int | 20 | 1-50 | Maximum results to return |
#### Response 200 OK #### Response 200 OK
@@ -943,22 +943,22 @@ could not leak it to clients. Results missing either format are omitted.
#### Errors #### Errors
| Status | Code | When | | Status | Code | When |
| ------ | ---- | ---- | | ------ | --------------- | -------------------------------------------------------------- |
| 400 | `INVALID_INPUT` | Missing/blank `q`, `q` over 100 chars, or `limit` outside 1-50 | | 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) | | 401 | `UNAUTHORIZED` | No valid session (checked before the disabled check) |
| 429 | `RATE_LIMITED` | Over 30 requests/minute | | 429 | `RATE_LIMITED` | Over 30 requests/minute |
| 502 | `BAD_GATEWAY` | Upstream error, timeout, or unparseable response | | 502 | `BAD_GATEWAY` | Upstream error, timeout, or unparseable response |
| 503 | `GIF_DISABLED` | `gif.api_key` is not configured | | 503 | `GIF_DISABLED` | `gif.api_key` is not configured |
### GET /api/v1/gif/trending ### GET /api/v1/gif/trending
Same auth, rate limit, response shape, and error codes as Same auth, rate limit, response shape, and error codes as
`/api/v1/gif/search`, minus the `q` parameter. `/api/v1/gif/search`, minus the `q` parameter.
| Param | Type | Default | Range | Description | | Param | Type | Default | Range | Description |
| ----- | ---- | ------- | ----- | ----------- | | ------- | ---- | ------- | ----- | ------------------------- |
| `limit` | int | 20 | 1-50 | Maximum results to return | | `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. users are rejected here even while their session is still valid.
2. **Per-route bit.** Route groups then require the specific permission below. 2. **Per-route bit.** Route groups then require the specific permission below.
`ADMINISTRATOR` bypasses every one of them; owner-only routes gate on role `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. substitutes for being the owner.
| Route | Requires | | Route | Requires |
| ----- | -------- | | ------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------- |
| `GET /admin/api/me` | perimeter only | | `GET /admin/api/me` | perimeter only |
| `GET /admin/api/stats` | perimeter only | | `GET /admin/api/stats` | perimeter only |
| `GET /admin/api/users` | 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) | | `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` | | `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/channels…` (incl. `/permissions` and `/user-permissions`) | `MANAGE_CHANNELS` |
| `GET/POST/PATCH/DELETE /admin/api/roles…` (incl. `/roles/reorder`) | `MANAGE_ROLES` | | `GET/POST/PATCH/DELETE /admin/api/roles…` (incl. `/roles/reorder`) | `MANAGE_ROLES` |
| `GET /admin/api/audit-log` | `VIEW_AUDIT_LOG` | | `GET /admin/api/audit-log` | `VIEW_AUDIT_LOG` |
| `GET/PATCH /admin/api/settings` | `MANAGE_SERVER` | | `GET/PATCH /admin/api/settings` | `MANAGE_SERVER` |
| `POST /admin/api/logs/ticket`, `GET /admin/api/logs/stream` | `ADMINISTRATOR` | | `POST /admin/api/logs/ticket`, `GET /admin/api/logs/stream` | `ADMINISTRATOR` |
| `/api/v1/admin/plugins…` | `ADMINISTRATOR` | | `/api/v1/admin/plugins…` | `ADMINISTRATOR` |
| `/admin/api/tokens…`, `/admin/api/backup(s)…`, `/admin/api/updates…` | Owner role (position 100) | | `/admin/api/tokens…`, `/admin/api/backup(s)…`, `/admin/api/updates…` | Owner role (position 100) |
Moderation routes additionally enforce the **role hierarchy**: the actor must Moderation routes additionally enforce the **role hierarchy**: the actor must
strictly outrank the target (`actor.position > target.position`), and a role 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: Array of:
| Field | Type | Notes | | Field | Type | Notes |
| ----- | ---- | ----- | | ------------- | ------- | -------------------------- |
| `id` | int | | | `id` | int | |
| `username` | string | | | `username` | string | |
| `avatar` | string? | omitted when unset | | `avatar` | string? | omitted when unset |
| `role_id` | int | | | `role_id` | int | |
| `role_name` | string | | | `role_name` | string | |
| `status` | string | presence status | | `status` | string | presence status |
| `created_at` | string | | | `created_at` | string | |
| `last_seen` | string? | omitted when never seen | | `last_seen` | string? | omitted when never seen |
| `banned` | bool | | | `banned` | bool | |
| `ban_reason` | string? | omitted when unset | | `ban_reason` | string? | omitted when unset |
| `ban_expires` | string? | omitted for permanent bans | | `ban_expires` | string? | omitted for permanent bans |
Password hashes and TOTP secrets are never included. Password hashes and TOTP secrets are never included.
@@ -1774,11 +1774,11 @@ omitted or `0` = permanent) and is only meaningful with `banned: true`.
#### Errors #### Errors
| Status | Code | Cause | | Status | Code | Cause |
| ------ | ---- | ----- | | ------ | ------------- | -------------------------------------------------------------------------------------------- |
| 400 | `BAD_REQUEST` | Invalid id/body, `ban_duration_hours` out of range, or attempting to modify your own account | | 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 | | 403 | `FORBIDDEN` | Missing bit, or the actor does not outrank the target |
| 404 | `NOT_FOUND` | User not found | | 404 | `NOT_FOUND` | User not found |
--- ---
@@ -1873,9 +1873,9 @@ user has TOTP enabled.
#### Errors #### Errors
| Status | Code | Cause | | Status | Code | Cause |
| ------ | ---- | ----- | | ------ | ------------- | -------------------------------------------------------------------- |
| 400 | `BAD_REQUEST` | Unknown key, invalid boolean, or `require_2fa` preconditions not met | | 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 #### 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.
--- ---
@@ -2054,10 +2054,10 @@ admin SPA replaces the apply button with an image-upgrade note.
#### Errors #### Errors
| Status | Code | Cause | | Status | Code | Cause |
| ------ | ---- | ----- | | ------ | --------------------- | --------------------------------- |
| 503 | `UPDATE_UNAVAILABLE` | Update checking is not configured | | 503 | `UPDATE_UNAVAILABLE` | Update checking is not configured |
| 502 | `UPDATE_CHECK_FAILED` | GitHub API failure | | 502 | `UPDATE_CHECK_FAILED` | GitHub API failure |
--- ---
@@ -2077,14 +2077,14 @@ re-verification against TOCTOU swaps), spawns the new process and shuts down.
#### Errors #### Errors
| Status | Code | Cause | | 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 | `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 | | 503 | `UPDATE_UNAVAILABLE` | Update checking is not configured |
| 409 | `RESTART_PENDING` | A restart from an earlier apply/restore is already pending | | 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 | `UPDATE_IN_PROGRESS` | Another restart-sensitive operation (update apply or backup restore) is running |
| 409 | `NO_UPDATE` | Already up to date | | 409 | `NO_UPDATE` | Already up to date |
| 502 | `UPDATE_CHECK_FAILED` / `MISSING_ASSETS` / `DOWNLOAD_FAILED` | Check, asset or download/verification failure | | 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 `MANAGE_ROLES`; `RoleService` then enforces the hierarchy rules below, so a
principal that clears the bit still cannot escalate through it. 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 - 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. 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 ```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 | | Field | Type | Required | Description |
|-------|------|----------|-------------| | ------------- | ------- | -------- | ---------------------------------------------- |
| `name` | string | Yes | 132 characters, unique case-insensitively | | `name` | string | Yes | 132 characters, unique case-insensitively |
| `color` | string | No | `#rgb`/`#rrggbb`, or `""` for none | | `color` | string | No | `#rgb`/`#rrggbb`, or `""` for none |
| `permissions` | integer | No | Bitfield; defaults to `0` | | `permissions` | integer | No | Bitfield; defaults to `0` |
| `position` | integer | No | Defaults to one below the actor's own position | | `position` | integer | No | Defaults to one below the actor's own position |
#### Response 201 Created #### Response 201 Created
@@ -2194,10 +2210,10 @@ The created role (`id`, `name`, `color`, `permissions`, `position`,
#### Errors #### Errors
| Status | Code | When | | Status | Code | When |
|--------|------|------| | ------ | ------------- | ----------------------------------------------------------------------------------- |
| 400 | `BAD_REQUEST` | Missing/blank/over-long name, duplicate name, bad color, negative position | | 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 | | 403 | `FORBIDDEN` | Missing `MANAGE_ROLES`, position at or above your own, or a permission bit you lack |
### PATCH /admin/api/roles/{id} ### PATCH /admin/api/roles/{id}
@@ -2220,11 +2236,11 @@ The updated role.
#### Errors #### Errors
| Status | Code | When | | Status | Code | When |
|--------|------|------| | ------ | ------------- | -------------------------------------------------------------------- |
| 400 | `BAD_REQUEST` | The role is the default role, or is the seeded Owner role | | 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 | | 403 | `FORBIDDEN` | Missing `MANAGE_ROLES`, or the role is at or above your own position |
| 404 | `NOT_FOUND` | No such role | | 404 | `NOT_FOUND` | No such role |
### PATCH /admin/api/roles/reorder ### PATCH /admin/api/roles/reorder
@@ -2246,10 +2262,10 @@ The full role list after the reorder, position descending.
#### Errors #### Errors
| Status | Code | When | | Status | Code | When |
|--------|------|------| | ------ | ------------- | ----------------------------------------------------------------------- |
| 400 | `BAD_REQUEST` | Wrong number of ids, or a duplicate id | | 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 | | 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 a caller that sent `-1` meant something, and storing `0` would hide it. A
refused body writes nothing at all: refused body writes nothing at all:
| Field | Range | Meaning | | Field | Range | Meaning |
|-------|-------|---------| | ----------------- | ------- | --------------------------------------------------------- |
| `slow_mode` | 0…21600 | Cooldown in seconds; 0 = off (6-hour ceiling, as Discord) | | `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_users` | 0…99 | Voice capacity; 0 = unlimited |
| `voice_max_video` | 0…99 | Simultaneous cameras/screen shares; 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 `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 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, "channel_id": 4,
"roles": [ "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": [ "users": [{ "user_id": 12, "username": "alice", "role_id": 4, "allow": 2, "deny": 0 }]
{ "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 } { "allow": 2, "deny": 1 }
``` ```
| Field | Type | Description | | Field | Type | Description |
|-------|------|-------------| | ------- | ------- | ---------------------------- |
| `allow` | integer | Bits granted in this channel | | `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 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 — 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 #### Errors
| Status | Code | When | | Status | Code | When |
|--------|------|------| | ------ | --------------- | ----------------------------- |
| 400 | `BAD_REQUEST` | Unparseable id or body | | 400 | `BAD_REQUEST` | Unparseable id or body |
| 400 | `INVALID_INPUT` | The channel is a DM | | 400 | `INVALID_INPUT` | The channel is a DM |
| 403 | `FORBIDDEN` | Missing `MANAGE_CHANNELS` | | 403 | `FORBIDDEN` | Missing `MANAGE_CHANNELS` |
| 404 | `NOT_FOUND` | Unknown channel, role or user | | 404 | `NOT_FOUND` | Unknown channel, role or user |
### DELETE /admin/api/channels/{id}/permissions/{roleId} ### 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 #### Path Parameters
| Param | Type | Description | | 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. | | `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`) | | `current_version` | string | Client's current semver version (e.g., `1.0.0`) |
#### Response 200 OK (update available) #### Response 200 OK (update available)
+14 -14
View File
@@ -11,20 +11,20 @@ natively) followed by a prose explanation and a **Source of truth** file list.
## Index ## Index
| Doc | Diagrams | Covers | | Doc | Diagrams | Covers |
|-----|----------|--------| | ---------------------------------------- | ------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------- |
| [system-overview.md](system-overview.md) | D1 System context, D8 Deployment topology | All processes, trust boundaries, ports, single-instance constraints | | [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 | | [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 | | [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 001028, grouped by domain | | [data-model.md](data-model.md) | D5 Entity-relationship overview | All 26 tables from migrations 001028, grouped by domain |
| [voice-e2ee.md](voice-e2ee.md) | D6 Voice + E2EE flow | LiveKit token flow, loopback TLS tunnel, ECDH key-holder relay | | [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) | | [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 | | [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 |
### Structure vs. behavior ### Structure vs. behavior
[client.md](client.md) maps the client *as-built* (modules, stores, wiring). The [client.md](client.md) maps the client _as-built_ (modules, stores, wiring). The
[ux/](ux/README.md) set is the complementary *behavior* spec — prescriptive [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 (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 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. **⚠ Current gap** callouts, so the set doubles as a UX improvement backlog.
@@ -33,7 +33,7 @@ maps. Where today's code diverges from the target, the UX docs carry dated
These documents are **curated, not generated**. The rule that keeps them honest: 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 > **Source of truth** list (new package, new table, new message type, changed
> flow), that PR updates the corresponding diagram in the same change. > flow), that PR updates the corresponding diagram in the same change.
@@ -43,9 +43,9 @@ in the dated audit reports, which are point-in-time snapshots by design.
## Relationship to other docs ## 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 (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: 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)). [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; - `docs/client-architecture.md` is a redirect stub kept for old links;
+13 -13
View File
@@ -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 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 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 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 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 removed in the 2026-07-22 security remediation). The remaining dashed edges
mark cross-store coupling (auth→voice→members) — known structural debt, not mark cross-store coupling (auth→voice→members) — known structural debt, not
@@ -100,18 +100,18 @@ yet scheduled.
### Key mechanisms ### Key mechanisms
| Concern | Where | How | | 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 | | 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). | | 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 | | 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 | | 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 | | 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) | | 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 | | 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 | | 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 | | 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. | | 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 ### Quality tooling
+8 -8
View File
@@ -100,15 +100,15 @@ erDiagram
### Domain notes ### 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. | | 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. | | 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. | | 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. | | 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. | | 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)`. | | 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.) | | 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 ### How the schema is accessed
+70 -70
View File
@@ -17,13 +17,13 @@ this set doubles as a UX improvement backlog. Gaps are grounded in real
## Documents ## Documents
| Doc | Covers | | 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 | | [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 | | [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 | | [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 | | [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 | | [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** The cross-cutting vocabulary and global reaction matrices below apply to **every**
document; the per-flow docs reference them rather than repeating them. 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 e.g. a view that can never be empty — but that must be a decision, not an
omission): omission):
| State | Meaning | Default presentation | | 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 | | `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 | | `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 | | `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 | | `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 | | `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 | | `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 | | `offline` | No live socket | Live-only controls disabled with the connection status surfaced |
**Principle — no silent states.** Every terminal outcome (success, empty, **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 rejected by the server must be pre-disabled with a reason; an action that
succeeds without a visible result must emit a confirmation. 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 The client has a fixed set of feedback surfaces. Each has one job; pick by the
decision table, don't improvise. decision table, don't improvise.
| Primitive | Source | Use for | Do **not** use for | | 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 | | **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 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) | | **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 | | **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 | | **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) | | **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 | | **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) | | **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, > LiveKit's own reconnection keeps retrying underneath — only the UI is gated,
> never LiveKit's machinery. > never LiveKit's machinery.
| Status | Composer / send | Voice controls | Presence picker | Reconnect banner | | Status | Composer / send | Voice controls | Presence picker | Reconnect banner |
|--------|-----------------|----------------|-----------------|------------------| | -------------- | ------------------------- | --------------------------- | --------------- | ---------------------------------- |
| `connected` | enabled | enabled | enabled | hidden | | `connected` | enabled | enabled | enabled | hidden |
| `reconnecting` | disabled, "Reconnecting…" | frozen, retrying underneath | disabled | visible, spinner | | `reconnecting` | disabled, "Reconnecting…" | frozen, retrying underneath | disabled | visible, spinner |
| `disconnected` | disabled | torn down | disabled | visible or → connect page on fatal | | `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 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 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. detail each; this is the index.
| Inbound event | Store effect | Target UI reaction | | Inbound event | Store effect | Target UI reaction |
|---------------|--------------|--------------------| | ----------------------------------------------------------------- | -------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------- |
| `auth_ok` | `auth.setAuth` | Advance handshake → ready overlay | | `auth_ok` | `auth.setAuth` | Advance handshake → ready overlay |
| `auth_error` | `ui.setTransientError` + `auth.clearAuth` | Return to connect page with the reason shown | | `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 | | `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_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_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_edited` / `chat_deleted` | `messages.editMessage` / `deleteMessage` | In-place edit / tombstone |
| `chat_bulk_deleted` | `messages.bulkDeleteMessages` | Remove every purged row in one pass | | `chat_bulk_deleted` | `messages.bulkDeleteMessages` | Remove every purged row in one pass |
| `reaction_update` | `messages.updateReaction` | Toggle the pill + count, reflect `me` | | `reaction_update` | `messages.updateReaction` | Toggle the pill + count, reflect `me` |
| `typing` | `members.setTyping` (5 s auto-clear) | Typing indicator | | `typing` | `members.setTyping` (5 s auto-clear) | Typing indicator |
| `presence` / `member_update` / `user_update` | `members.*` | Live member-list update | | `presence` / `member_update` / `user_update` | `members.*` | Live member-list update |
| `member_join` / `member_leave` / `member_ban` | `members.add/remove` | Member-list add/remove | | `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 | | `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 | | `roles_update` | `channels.setRoles` | Refresh name colors + permission-gated affordances |
| `emoji_update` | `emoji.setCustomEmoji` | Refresh picker, autocomplete, and rendered custom emoji | | `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_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_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 | | `voice_token` / `voice_e2ee_*` | `livekitSession.*` | Drive the voice-join + securing indicators |
| `dm_channel_open` / `dm_channel_close` | `dm.*` | DM list add/remove | | `dm_channel_open` / `dm_channel_close` | `dm.*` | DM list add/remove |
| `server_restart` | `ui.setTransientError` | Restart banner with countdown | | `server_restart` | `ui.setTransientError` | Restart banner with countdown |
| `error` | `ui.setTransientError` (+ `clearAuth` on `BANNED`) | Map the code → the reaction in §5 | | `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) dispatcher: `MainPage.ts` subscribes to them directly (page-scoped listeners)
and drives the ring state machine in `lib/call-ring.ts` + and drives the ring state machine in `lib/call-ring.ts` +
`components/IncomingCallBanner.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 handling is per-call-site with no shared mapper (`doFetch()` in `lib/api.ts` centralizes only
401); this matrix is the target contract. 401); this matrix is the target contract.
| Class | Source | Target reaction | | 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) | | **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 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 | | **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 | | **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 | | **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) | | **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) | | **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 | | **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) | | **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 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`) | | **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 ## 6. Cross-cutting principles
1. **Optimistic where the user acts, authoritative where the server decides.** 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 then reconcile against the server echo; on failure they roll back visibly with
a retry — never silently. a retry — never silently.
2. **Permission is expressed as affordance, not as rejection.** If the server 2. **Permission is expressed as affordance, not as rejection.** If the server
+34 -33
View File
@@ -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`): category, sorted by position. The sidebar has two modes (`ui.store.sidebarMode`):
`channels` and `dms`. `channels` and `dms`.
| State | Trigger | Target reaction | | State | Trigger | Target reaction |
|-------|---------|-----------------| | ------------------ | -------------------------------------- | --------------------------------------------------------------------------------------------------------------- |
| `ready` | Channels loaded from `ready` | Grouped, collapsible category list | | `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`) | | `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 | | category collapsed | User toggles | Persisted per-server in localStorage (`ui.toggleCategory`); chevron reflects state |
| active channel | `setActiveChannel` | Highlighted; unread cleared | | active channel | `setActiveChannel` | Highlighted; unread cleared |
| unread | `chat_message` in a non-active channel | Unread pill; badge on the channel | | unread | `chat_message` in a non-active channel | Unread pill; badge on the channel |
### 1.1 Channel type affordances ### 1.1 Channel type affordances
Each channel type gets a distinct icon and interaction: Each channel type gets a distinct icon and interaction:
| Type | Icon | Click behavior | | Type | Icon | Click behavior |
|------|------|----------------| | -------------- | -------------- | ---------------------------------------------------------------------------------------------------------- |
| `text` | hash | Focus → load messages | | `text` | hash | Focus → load messages |
| `announcement` | megaphone (D1) | Focus → load messages; **composer read-only unless MANAGE_MESSAGES** (see [messaging.md §2](messaging.md)) | | `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 | | `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 | | `dm` | — | Not in the channel list; lives in DM mode |
### 1.1a Per-channel notification mutes ### 1.1a Per-channel notification mutes
The channel context menu offers "Mute Channel" / "Unmute Channel" 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`). (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 desktop notification, no chime — while the unread badge still counts but
renders dimmed, and a message that mentions you still notifies and shows the 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 red mention badge. It is a client-side preference on purpose (stored in
@@ -63,6 +63,7 @@ sequenceDiagram
``` ```
**Target rules:** **Target rules:**
- Switching is instantaneous from cache; the message area shows its own loading - 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. 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 - 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 Renders from `members.store` (`members` map + `typingUsers`). Shows presence and
role grouping. role grouping.
| State | Trigger | Target reaction | | State | Trigger | Target reaction |
|-------|---------|-----------------| | --------------- | ----------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------- |
| `ready` | `ready.members` | Grouped by role, sorted; presence dot per member | | `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`) | | `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 | | presence change | `presence` event | Live dot update; offline members styled distinctly |
| role change | `member_update` | Re-group live | | 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`) | | 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 | | join/leave/ban | `member_join`/`member_leave`/`member_ban` | Add/remove with no reflow flash |
### 2.1 Typing indicator ### 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 DM mode (`sidebarMode: "dms"`) renders from `dm.store` (`channels` list, each with
recipient, last-message preview, unread). recipient, last-message preview, unread).
| State | Trigger | Target reaction | | State | Trigger | Target reaction |
|-------|---------|-----------------| | ------------------ | ---------------------- | ---------------------------------------------------------------------------------------------------------------------------------- |
| `ready` | `ready.dm_channels` | DM list sorted by recency | | `ready` | `ready.dm_channels` | DM list sorted by recency |
| `empty` | No DMs | "No direct messages yet" + "Start one from a member's profile" | | `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`) | | open DM | `dm_channel_open` | Prepend/move-to-top, dedup (already `addDmChannel()`, `stores/dm.store.ts`) |
| close DM | `dm_channel_close` | Remove from list | | 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 | | 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`) | | 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 ### 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, Blocking gates DM delivery server-side (a blocked user can't post into the DM,
and `IsEitherBlocked` is bidirectional). **Target UX:** and `IsEitherBlocked` is bidirectional). **Target UX:**
| Action | Reaction | | Action | Reaction |
|--------|----------| | ------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| Block user | Confirm → block; DM composer becomes read-only with "You've blocked this user. Unblock to send messages." | | 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) | | 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 > **✅ Wired (composer gating).** DM block state now drives the same
> disabled-with-reason composer mode (see [messaging.md §2](messaging.md)) via > disabled-with-reason composer mode (see [messaging.md §2](messaging.md)) via
+43 -43
View File
@@ -43,13 +43,13 @@ status area. Settings are reachable unauthenticated (for appearance/advanced).
### 2.1 Server profiles & health ### 2.1 Server profiles & health
| State | Trigger | Target reaction | | State | Trigger | Target reaction |
|-------|---------|-----------------| | ------------------- | --------------------------------------------------------------- | --------------------------------------------------------------------------- |
| `loading` | Profile list resolving from the Rust store (`owncord:profiles`) | Skeleton rows; no flash of "no servers" | | `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 | | `ready` | Profiles loaded | List with per-profile health dot |
| `empty` | No saved profiles | "Add a server to get started" with an inline add affordance | | `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: 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: 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 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 `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 auto-connecting` (the `FormState` type in `pages/connect-page/LoginForm.ts`). This is the model other views should
follow. follow.
| State | Presentation | Exit | | State | Presentation | Exit |
|-------|--------------|------| | ----------------- | --------------------------------------------------------------------------------------------------------------------------------- | ------------------------------- |
| `idle` | Enabled fields; Login/Register toggle | submit → validate | | `idle` | Enabled fields; Login/Register toggle | submit → validate |
| `loading` | Submit shows spinner, fields disabled (`updateSubmitButton()` + `updateFormInputsDisabled()` in `LoginForm.ts`) | `auth.login` resolves | | `loading` | Submit shows spinner, fields disabled (`updateSubmitButton()` + `updateFormInputsDisabled()` in `LoginForm.ts`) | `auth.login` resolves |
| `totp` | 6-digit overlay, Verify/Cancel | code → `verifyTotp` | | `totp` | 6-digit overlay, Verify/Cancel | code → `verifyTotp` |
| `connecting` | "Connecting…" while WS handshakes | ws `connected` | | `connecting` | "Connecting…" while WS handshakes | ws `connected` |
| `auto-connecting` | Dedicated spinner card for saved-profile auto-login | any key/click cancels to `idle` | | `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` | | `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, **Client-side validation before any request** (`validateForm()` in `LoginForm.ts`): host,
username, password required; password ≥ 8; register mode also requires the invite username, password required; password ≥ 8; register mode also requires the invite
@@ -105,14 +105,14 @@ sequenceDiagram
**Auth branches → reaction** (server `auth_handler.go`): **Auth branches → reaction** (server `auth_handler.go`):
| Server result | Target reaction | | Server result | Target reaction |
|---------------|-----------------| | ----------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `200 {token, user}` | Proceed to WS connect | | `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`) | | `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` 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 | | `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 | | `400` invalid input | Inline field error |
| `429` rate-limited | "Too many attempts — wait a moment." Keep entered username; re-enable after cooldown | | `429` rate-limited | "Too many attempts — wait a moment." Keep entered username; re-enable after cooldown |
### 2.4 Register-by-invite ### 2.4 Register-by-invite
@@ -145,7 +145,7 @@ sequenceDiagram
OVL->>OVL: onReady → router.navigate("main") 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 It exists specifically so Main never renders mid-populate. Everything else
(message load, member load) uses in-region loading, not a global block. (message load, member load) uses in-region loading, not a global block.
@@ -168,19 +168,19 @@ stateDiagram-v2
Restarting --> Reconnecting: server drops us Restarting --> Reconnecting: server drops us
``` ```
| Phase | Target reaction | | 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 | | `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`) | | 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 | | 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`) | | `server_restart` | `ServerBanner.showRestart(delay_seconds)` with a live countdown (`showRestart()`, `components/ServerBanner.ts`) |
| fatal (`auth_error`) | `intentionalClose`, transient-error store → connect page | | fatal (`auth_error`) | `intentionalClose`, transient-error store → connect page |
**Target rule:** reconnection is invisible on the happy path and honest on the **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 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 the disabled live-controls answer it. This is where consolidating connection
status onto `ui.store` (README §3) pays off: the composer, voice controls, and 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. 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 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 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 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 proxy usually sees the host first (the connect page's health check precedes
login and WS). 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 | | `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) | — | | `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`) | | `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 ```mermaid
sequenceDiagram sequenceDiagram
@@ -219,7 +219,7 @@ sequenceDiagram
end 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 — never auto-accept, never silently reconnect. This is correct today; the spec
locks it. locks it.
@@ -227,12 +227,12 @@ locks it.
## 6. Logout & session lifecycle ## 6. Logout & session lifecycle
| Trigger | Target behavior | | 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 | | 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." | | 401 anywhere | Same as logout, with "Your session expired — sign in again." |
| WS `BANNED` | Transient-error → connect page, no reconnect | | WS `BANNED` | Transient-error → connect page, no reconnect |
| Cert reject | Disconnect → connect page | | Cert reject | Disconnect → connect page |
> **✓ Resolved 2026-07-20 — server session revoked on logout.** User-initiated > **✓ Resolved 2026-07-20 — server session revoked on logout.** User-initiated
> logout now calls `api.logout()` (`POST /auth/logout`) via the `logout()` helper > logout now calls `api.logout()` (`POST /auth/logout`) via the `logout()` helper
+77 -77
View File
@@ -14,13 +14,13 @@ slow-mode, and announcement read-only gating.
The list renders from `messages.store` (`messagesByChannel`, capped 500/channel). The list renders from `messages.store` (`messagesByChannel`, capped 500/channel).
| State | Trigger | Target reaction | | State | Trigger | Target reaction |
|-------|---------|-----------------| | --------------- | ------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------- |
| `loading` | Channel opened, history fetch in flight, nothing cached | **In-region loading placeholder** in the message area | | `loading` | Channel opened, history fetch in flight, nothing cached | **In-region loading placeholder** in the message area |
| `ready` | Messages present | Virtualized list | | `ready` | Messages present | Virtualized list |
| `empty` | Loaded, zero messages | "This is the beginning of #channel." welcome state (already `renderEmptyState()`, `components/MessageList.ts`) | | `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`) | | `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 | | `error` | History fetch failed | **Inline section error + Retry** in the message area |
> **✓ Implemented (2026-07).** `messages.store` tracks a per-channel > **✓ Implemented (2026-07).** `messages.store` tracks a per-channel
> `historyLoadState` (`loading` / `error`, absent = idle); `loadMessages` sets it > `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 ## 2. Composer — permission & connection gating
This is the spec's canonical example of **permission-as-affordance**. The 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. possible.
```mermaid ```mermaid
@@ -54,14 +54,14 @@ stateDiagram-v2
SlowMode --> Enabled: cooldown elapsed SlowMode --> Enabled: cooldown elapsed
``` ```
| Composer state | Presentation | Reason shown | | Composer state | Presentation | Reason shown |
|----------------|--------------|--------------| | ---------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------- |
| `enabled` | Editable textarea, attach + pickers active | — | | `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." | | `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." | | `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) | | `offline` | Disabled — "Reconnecting…" while retrying, "Not connected" when disconnected | connection status (README §3) |
| `slow-mode` | Disabled with a live countdown | "Slow mode: wait Ns." | | `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 | | `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 > **✓ Implemented (2026-07).** The server sends an authoritative per-channel
> `can_send` in the ready payload (`ws/serve.go` `channelCanSend`, mirroring > `can_send` in the ready payload (`ws/serve.go` `channelCanSend`, mirroring
@@ -105,11 +105,11 @@ sequenceDiagram
end end
``` ```
| Optimistic state | Presentation | Transition | | Optimistic state | Presentation | Transition |
|------------------|--------------|------------| | ---------------- | -------------------------------------------------------------------------------------------- | ----------------------------------------- |
| `pending` | Row shown dimmed with a subtle "sending" affordance | `chat_send_ok``sent`; error → `failed` | | `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 | — | | `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 | | `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 **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 `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 ## 4. Edit / delete
| Action | Target UX | | 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 | | 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 (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`) Deleted messages are soft-deleted (kept as a tombstone in the array, `deleted:true`)
so surrounding context and reply references stay intact. so surrounding context and reply references stay intact.
@@ -147,10 +147,10 @@ so surrounding context and reply references stay intact.
## 5. Reactions ## 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 | | 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: > **✓ Implemented (2026-08).** The pill toggles on the click:
> `ReactionController.sendReaction` applies the toggle locally > `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 **Who reacted (✓ implemented 2026-08):** hovering (or focusing) a reaction pill
for 300 ms fetches the reactor list and shows a tooltip reading 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. `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` 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 (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 The composer supports file attach with client-side validation and per-item
upload state (already thorough — `MessageInput.ts`). upload state (already thorough — `MessageInput.ts`).
| State | Presentation | | State | Presentation |
|-------|--------------| | ---------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| selected | Thumbnail/chip per file | | 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`) | | 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`) | | 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 | | uploaded | Chip ready; ids attached to the `chat_send` payload |
| failed | Inline error on the chip with remove/retry | | failed | Inline error on the chip with remove/retry |
Upload goes through `POST /uploads` (multipart). **✓ Implemented (2026-07):** Upload goes through `POST /uploads` (multipart). **✓ Implemented (2026-07):**
`uploadFile` now honors the global 401 handler like every other call — a 401 `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 **Inline players (✓ implemented 2026-08):** a received attachment renders by MIME
family, not as a download chip for everything but images: family, not as a download chip for everything but images:
| MIME | Rendering | | MIME | Rendering |
|------|-----------| | ------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------- |
| `image/*` except `image/svg+xml` | Inline `<img>` (existing) | | `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 | | `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 | | `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 | | anything else, including `image/svg+xml` | Download chip |
Both player families are allowlists, not `video/`/`audio/` prefix tests: an 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 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 ## 7. Replies, pins, search, read/unread
| Feature | Target UX | | Feature | Target UX |
|---------|-----------| | ----------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Reply | Reply target chip above the composer (`setReplyTo`/`clearReply`); `reply_to` sent; rendered as a quoted preview | | 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`) | | 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 | | 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/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 **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 channel clears the badge, which destroys the only record of where the reader had
got to, so `setActiveChannel` snapshots the count first got to, so `setActiveChannel` snapshots the count first
(`channels.store.getUnreadOnOpen`); MessageList reads it once at mount and places (`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 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. 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 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` opened from the OS — goes through one path (`lib/message-navigation.ts`
registry → `main-page/MessageJump.ts`), so they behave identically. registry → `main-page/MessageJump.ts`), so they behave identically.
| Step | Target UX | | Step | Target UX |
|------|-----------| | ------------------------------------- | ---------------------------------------------------------------------------------------------------------------------- |
| Target loaded | Scroll to the row and flash it (`.highlight-flash`, 1.5s) | | 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 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 | | 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 | | 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 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 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 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 Present** pill. Clicking it reattaches and refetches the live tail. Scrolling
further up (`prependMessages`) keeps the window detached; only a fresh tail further up (`prependMessages`) keeps the window detached; only a fresh tail
fetch reattaches. 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 `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. 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 authoritative and only falls back to parsing `@tokens` locally when an older
server omits them. server omits them.
| Surface | Target UX | | 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 | | `@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 | | `@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) | | 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-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 | | 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 | | 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 | | 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 **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 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 builds DOM nodes only: `innerHTML` is never used with message content, and
every `href` passes `isSafeUrl` first. every `href` passes `isSafeUrl` first.
| Construct | Syntax | Notes | | 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 | | 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 | | 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 | | 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 | | 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 | h1h3; the space is required, so `#nospace` and `#channel` are untouched | | Heading | `# `, `## `, `### ` at line start | h1h3; 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 | | 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) | | 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 | | 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 | | 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 Bare URLs are still autolinked, and mention/`#channel` chips render inside
styled spans — a URL's own `_` and `*` are treated as address, not markup. styled spans — a URL's own `_` and `*` are treated as address, not markup.
+32 -30
View File
@@ -18,6 +18,7 @@ Appearance, Notifications, Text & Images, Accessibility, Voice & Audio, Keybinds
Advanced, Logs. Advanced, Logs.
**Target rules:** **Target rules:**
- Every save is confirmed: a toast on success, an inline error on failure. No - Every save is confirmed: a toast on success, an inline error on failure. No
silent saves. silent saves.
- Preference writes are immediate and local (localStorage `owncord:settings:*`), - 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 ### 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 | | 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) ### 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) ### 2.3 Two-factor (TOTP)
| Flow | Steps | | 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` | | 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`) | | 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 **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 ### 2.4 Sessions & delete account
| Action | Reaction | | Action | Reaction |
|--------|----------| | ---------------- | --------------------------------------------------------------------------------------------------------------------------------- |
| List sessions | `GET /users/me/sessions`; show device/IP/last-used; current session marked | | 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 | | 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 | | 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, actor's role. Everything here must (a) only appear for users who can perform it,
and (b) confirm destructive actions. and (b) confirm destructive actions.
| Operation | Affordance | REST | Reaction | | Operation | Affordance | REST | Reaction |
|-----------|-----------|------|----------| | ---------------- | ------------------------------- | ---------------------------------------------------- | --------------------------------------------------------------------- |
| Change role | Member context menu → submenu | `PATCH /admin/api/users/{id}` `{role_id}` | Toast; `member_update` reflects live | | 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` | | 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 | | 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` | | 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` | | 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 | | 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 | | 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" | | Invites | Invite manager modal | `GET/POST/DELETE /invites` | List with masked codes, copy, revoke; empty state "No active invites" |
**Target rules:** **Target rules:**
- **✓ Destructive admin actions show an in-flight state (2026-08).** - **✓ Destructive admin actions show an in-flight state (2026-08).**
`withConfirmation` (`AdminActions.ts`) keeps the item in a pending `withConfirmation` (`AdminActions.ts`) keeps the item in a pending
label/class while the promise settles and ignores further clicks, so a slow 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`), reason input plus a duration choice (`appendBanFlow()` in `components/AdminActions.ts`),
and the menu passes both through and the menu passes both through
(the `onBan` handler in `createSidebarMemberSection()`, `pages/main-page/SidebarMemberSection.ts``api.adminBanMember(userId, reason, (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 The full admin panel — user list, audit log, server settings, channel
permissions, plugin management, backups, updates, first-run setup — is the permissions, plugin management, backups, updates, first-run setup — is the
@@ -177,13 +179,13 @@ sequenceDiagram
end end
``` ```
| State | Presentation | | State | Presentation |
|-------|--------------| | ----------- | ------------------------------------------------------------------------------------------------------------------------------------ |
| checking | Silent (no UI until a result) | | checking | Silent (no UI until a result) |
| available | Non-modal banner with version + Update Now / Later (already `createUpdateNotifier()`/`showBanner()`, `components/UpdateNotifier.ts`) | | 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) | | downloading | Banner "Downloading update… N%" (or "… N.N MB" until Content-Length is known) |
| applied | App relaunches automatically | | applied | App relaunches automatically |
| failed | "Update failed. Please try again later." + Dismiss | | failed | "Update failed. Please try again later." + Dismiss |
> **✅ Wired — download progress.** The Rust download callback > **✅ Wired — download progress.** The Rust download callback
> (`download_and_install_update` in `update_commands.rs`) accumulates received > (`download_and_install_update` in `update_commands.rs`) accumulates received
+49 -48
View File
@@ -17,7 +17,7 @@ Internally there are **two** FSMs:
- The **WS connection** FSM (`ws.ts`: `disconnected…connected`) — the socket. - The **WS connection** FSM (`ws.ts`: `disconnected…connected`) — the socket.
- The **voice session** FSM (`livekitSession.ts`: `idle | connecting | - 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`, Plus the user-facing booleans in `voice.store` (`localMuted`, `localDeafened`,
`localCamera`, `localScreenshare`, `listenOnly`, `joinedAt`) and the per-user `localCamera`, `localScreenshare`, `listenOnly`, `joinedAt`) and the per-user
@@ -58,15 +58,16 @@ stateDiagram-v2
failed --> idle: auto-leave + error failed --> idle: auto-leave + error
``` ```
| Status | Presentation | Notes | | Status | Presentation | Notes |
|--------|--------------|-------| | -------------- | ---------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `joining` | Voice widget shows "Connecting…"; channel roster shows self pending | `handleVoiceToken` → `connectAndSetup` | | `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`)) | | `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 | | `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`) | | `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 | | `failed` | Toast "Voice connection lost" / "Couldn't secure the call"; auto-leave | `onErrorCallback` fires |
**Target rules:** **Target rules:**
- The "connecting" vs "securing" distinction is user-visible: while a non-key-holder - 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 waits for the room key, show **securing**, not a generic spinner — an E2EE call
that's still exchanging keys is not yet private. that's still exchanging keys is not yet private.
@@ -80,7 +81,7 @@ stateDiagram-v2
> and `reconnecting` shows "Reconnecting voice…", neither showing the secured > and `reconnecting` shows "Reconnecting voice…", neither showing the secured
> badge. An E2EE-timeout still surfaces its `"e2ee_timeout"` toast and auto-leaves > badge. An E2EE-timeout still surfaces its `"e2ee_timeout"` toast and auto-leaves
> (`livekitSession.ts` `connectAndSetup`). **Code vs. diagram note:** the client > (`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 > 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: > the reverse order for readability. The distinction users see is unchanged:
> non-key-holders sit in `securing` until a room key arrives. > 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. All four are optimistic with rollback; each also emits a WS control message.
| Control | Local state | WS message | Rollback | | Control | Local state | WS message | Rollback |
|---------|-------------|-----------|----------| | --------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------- | ------------------------- |
| **Mute** | `localMuted` (`setLocalMuted`) — fully unpublishes the mic track | `voice_mute{muted}` | n/a (local-authoritative) | | **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 | | **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 | | **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 | | **Screenshare** | `localScreenshare` optimistic, rollback on failure (`enableScreenshare()` in `lib/screenShare.ts`); rate-limited | `voice_screenshare{enabled}` | revert + toast |
| Control state | Presentation | | Control state | Presentation |
|---------------|--------------| | -------------- | ------------------------------------------------------------------------------------------ |
| mic muted | Mic-slash icon on self tile + control bar | | mic muted | Mic-slash icon on self tile + control bar |
| deafened | Headphone-slash; implies muted styling | | deafened | Headphone-slash; implies muted styling |
| listen-only | Badge "Listen only — no microphone" with a **Retry mic** affordance (`retryMicPermission`) | | listen-only | Badge "Listen only — no microphone" with a **Retry mic** affordance (`retryMicPermission`) |
| camera on | Self video tile in the grid | | camera on | Self video tile in the grid |
| screenshare on | Screen tile; a stop-share affordance always visible | | screenshare on | Screen tile; a stop-share affordance always visible |
| speaking | Green ring on the speaking user's tile/avatar (from `voice_speakers` / ActiveSpeakers) | | speaking | Green ring on the speaking user's tile/avatar (from `voice_speakers` / ActiveSpeakers) |
**Mic-permission failure** (`restoreLocalVoiceState`): on denied/absent mic, set **Mic-permission failure** (`restoreLocalVoiceState`): on denied/absent mic, set
`listenOnly` and surface the specific reason ("Microphone permission denied" / `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}` → 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:** `setMuted(!pressed)` only while in a channel (the `ptt-state` listener inside `initPtt()`, `lib/ptt.ts`). **Target UX:**
| State | Presentation | | State | Presentation |
|-------|--------------| | ------------------- | --------------------------------------------------------------------------------------------------------------------- |
| PTT bound, released | Muted; hint "Hold {key} to talk" | | PTT bound, released | Muted; hint "Hold {key} to talk" |
| PTT pressed | Unmuted + speaking ring | | 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" | | 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 | | 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 The channel's voice roster renders from `voiceUsers`. Each participant tile
reflects their `speaking/muted/deafened/camera/screenshare`. **Target:** reflects their `speaking/muted/deafened/camera/screenshare`. **Target:**
| Signal | Tile reaction | | Signal | Tile reaction |
|--------|---------------| | ----------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- |
| `voice_state` | Add/update the participant with their flags | | `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_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 | | `voice_speakers` | Speaking ring on the listed users |
| key-holder change | Invisible to users (re-election is automatic on leave); no UI churn | | 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). 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 `lib/livekitE2EE.ts` as announces are verified against the pinned identity
keys (`lib/identity.ts`). keys (`lib/identity.ts`).
| State | Roster badge (`verifyPresentation()`, `components/ChannelSidebar.ts`) | Interaction | | State | Roster badge (`verifyPresentation()`, `components/ChannelSidebar.ts`) | Interaction |
|-------|------------------------------------------|-------------| | ------------ | --------------------------------------------------------------------------- | ---------------------------------------- |
| `verified` | Green shield; title "Identity verified · Safety number: {n}" | none needed | | `verified` | Green shield; title "Identity verified · Safety number: {n}" | none needed |
| `unverified` | Neutral shield; no pinned key yet | none — pins on first verified announce | | `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 | | `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`; The mismatch modal (`createIdentityMismatchModal()`, `components/CertMismatchModal.ts`;
opened from `openIdentityMismatchModal()` in `components/ChannelSidebar.ts`) shows the **new key's fingerprint** so 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) ## 9. DM calls (ring)
DM voice is the same voice machinery on the DM's voice channel, plus a 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): call):
| Event | Reaction | | Event | Reaction |
|-------|----------| | -------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------- |
| Outgoing: user clicks Call | `call_ring` sent (rate-limited 1/3 s server-side); caller joins the DM voice channel | | 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) | | 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 | | Accept | Join the DM voice channel; banner clears |
| Decline | `call_decline` sent → other participants' ringing stops via `call_declined` | | Decline | `call_decline` sent → other participants' ringing stops via `call_declined` |
| Timeout / caller leaves | Banner clears silently | | Timeout / caller leaves | Banner clears silently |
`call_incoming` / `call_declined` are page-scoped listeners in `MainPage.ts`, `call_incoming` / `call_declined` are page-scoped listeners in `MainPage.ts`,
not dispatcher handlers (see [README §4](README.md)). not dispatcher handlers (see [README §4](README.md)).
+77 -77
View File
@@ -6,11 +6,11 @@ How to set up the development environment and contribute to OwnCord.
### Prerequisites ### Prerequisites
| Platform | Server | Client | | Platform | Server | Client |
|----------|--------|--------| | --------------- | ------ | ------------ |
| Windows 10+ x64 | ✅ | ✅ | | Windows 10+ x64 | ✅ | ✅ |
| Linux x64 | ✅ | ✅ | | Linux x64 | ✅ | ✅ |
| Linux ARM64 | ✅ | ✅ (CI only) | | Linux ARM64 | ✅ | ✅ (CI only) |
- **Go 1.26+** (server) - **Go 1.26+** (server)
- **Node.js 24+** (client) — pinned in `Client/.nvmrc`; `engine-strict` makes a - **Node.js 24+** (client) — pinned in `Client/.nvmrc`; `engine-strict` makes a
@@ -26,18 +26,18 @@ From the repository root. These orchestrate the per-stack commands below; they
are a convenience, not a replacement. Nothing here needs `make`, and everything are a convenience, not a replacement. Nothing here needs `make`, and everything
works the same on Windows, macOS and Linux. works the same on Windows, macOS and Linux.
| Command | Description | | Command | Description |
|---------|-------------| | ----------------------------- | ------------------------------------------------------------------------------- |
| `npm run bootstrap` | `npm ci` in all three package roots | | `npm run bootstrap` | `npm ci` in all three package roots |
| `npm run check` | Everything CI gates on: server, client, Rust | | `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: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:client` | Client only — typecheck, lint, format, unit + integration tests |
| `npm run check:rust` | Tauri backend — `cargo test --lib` and clippy | | `npm run check:rust` | Tauri backend — `cargo test --lib` and clippy |
| `npm run check:docs` | Fail if a watched document states a finding count the ledger contradicts | | `npm run check:docs` | Fail if a watched document states a finding count the ledger contradicts |
| `npm run format` | Prettier over the client, `gofmt -w` over the server | | `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 generate` | Regenerate protocol constants and the sqlc query layer |
| `npm run release:preflight` | `check` plus a client production build | | `npm run release:preflight` | `check` plus a client production build |
| `node scripts/run.mjs --list` | Print the exact command every task runs, and where | | `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 Tools CI installs but you may not have — `golangci-lint`, `sqlc` — are skipped
with a printed reason rather than failing the run. with a printed reason rather than failing the run.
@@ -48,72 +48,72 @@ next section, and using them directly is equally correct.
#### Server (Go) #### Server (Go)
| Command | Description | | Command | Description |
|---------|-------------| | --------------------------------------------------------- | ------------------------------------------------------------------------ |
| `go build -o chatserver.exe -ldflags "-s -w" .` | Build server binary (Windows) | | `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) | | `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 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 build -tags wazero .` | Build with Wazero plugin runtime (requires `go get` first — see Phase C) |
| `go test ./...` | Run all server tests | | `go test ./...` | Run all server tests |
| `go test ./... -cover` | Run server tests with coverage | | `go test ./... -cover` | Run server tests with coverage |
| `go test -race ./...` | Run server tests with race detection | | `go test -race ./...` | Run server tests with race detection |
**Make targets** (run from `Server/`): **Make targets** (run from `Server/`):
| Command | Description | | Command | Description |
|---------|-------------| | ------------------------ | ----------------------------------------------------------------------------------- |
| `make test` | Run the test suite the way CI does (`-race`, 20 min timeout) | | `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 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` | Per-package coverage (what CI uploads) + a function summary |
| `make cover-all` | Cross-package coverage — the honest number (also lists 0.0% functions) | | `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-install` | Install the pinned sqlc version into `$GOBIN` |
| `make sqlc-generate` | Regenerate the type-safe Go query layer (`db/dbgen/`, SQLite engine) | | `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 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-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 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-up` | Start Jaeger (traces) + Prometheus (metrics) via Docker for local OTel development |
| `make otel-down` | Stop and remove the OTel dev containers | | `make otel-down` | Stop and remove the OTel dev containers |
#### Client (Tauri v2) #### Client (Tauri v2)
**Build & dev** **Build & dev**
| Command | Description | | Command | Description |
|---------|-------------| | --------------------- | ---------------------------------------------------------------- |
| `npm run dev` | Start Vite dev server with hot reload | | `npm run dev` | Start Vite dev server with hot reload |
| `npm run build` | TypeScript check + Vite production build | | `npm run build` | TypeScript check + Vite production build |
| `npm run tauri dev` | Launch Tauri app in dev mode | | `npm run tauri dev` | Launch Tauri app in dev mode |
| `npm run tauri build` | Build release installer (NSIS on Windows, AppImage+deb on Linux) | | `npm run tauri build` | Build release installer (NSIS on Windows, AppImage+deb on Linux) |
**Tests** **Tests**
| Command | Description | | Command | Description |
|---------|-------------| | -------------------------- | -------------------------------------- |
| `npm test` | Run all tests (vitest) | | `npm test` | Run all tests (vitest) |
| `npm run test:unit` | Unit tests only | | `npm run test:unit` | Unit tests only |
| `npm run test:integration` | Integration tests only | | `npm run test:integration` | Integration tests only |
| `npm run test:e2e` | Playwright E2E (mocked Tauri) | | `npm run test:e2e` | Playwright E2E (mocked Tauri) |
| `npm run test:e2e:native` | Playwright E2E (real Tauri exe + CDP) | | `npm run test:e2e:native` | Playwright E2E (real Tauri exe + CDP) |
| `npm run test:e2e:prod` | Playwright E2E (prod build) | | `npm run test:e2e:prod` | Playwright E2E (prod build) |
| `npm run test:e2e:ui` | Playwright UI mode | | `npm run test:e2e:ui` | Playwright UI mode |
| `npm run test:watch` | Vitest watch mode | | `npm run test:watch` | Vitest watch mode |
| `npm run test:coverage` | Coverage report | | `npm run test:coverage` | Coverage report |
| `npm run test:mutate` | Stryker mutation testing | | `npm run test:mutate` | Stryker mutation testing |
| `npm run test:mutate:dry` | Stryker dry-run (no mutations applied) | | `npm run test:mutate:dry` | Stryker dry-run (no mutations applied) |
| `npm run test:browser` | Vitest browser-mode tests | | `npm run test:browser` | Vitest browser-mode tests |
**Type checking, linting & formatting** **Type checking, linting & formatting**
| Command | Description | | Command | Description |
|---------|-------------| | ------------------------- | ------------------------------------- |
| `npm run typecheck` | Full typecheck (all sources) | | `npm run typecheck` | Full typecheck (all sources) |
| `npm run typecheck:build` | Typecheck build config only | | `npm run typecheck:build` | Typecheck build config only |
| `npm run lint` | oxlint + ESLint check (src/) | | `npm run lint` | oxlint + ESLint check (src/) |
| `npm run lint:fix` | ESLint auto-fix | | `npm run lint:fix` | ESLint auto-fix |
| `npm run lint:ox` | oxlint only (fast correctness checks) | | `npm run lint:ox` | oxlint only (fast correctness checks) |
| `npm run format` | Prettier format (src/ + tests/) | | `npm run format` | Prettier format (src/ + tests/) |
| `npm run format:check` | Prettier check only (no writes) | | `npm run format:check` | Prettier check only (no writes) |
| `npm run knip` | Dead code and unused export detection | | `npm run knip` | Dead code and unused export detection |
### Git hooks (recommended) ### Git hooks (recommended)
@@ -123,10 +123,10 @@ Committed hooks in `.githooks/` catch the most common CI failures locally. Enabl
npm run hooks:install # = git config core.hooksPath .githooks 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-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. Bypass with `--no-verify` or `OWNCORD_SKIP_HOOKS=1` when needed — CI still enforces everything.
@@ -146,11 +146,11 @@ See `Server/plugin/examples/hello/README.md` for the full plugin ABI and build i
**Toolchain requirements for building `.wasm` plugins with TinyGo:** **Toolchain requirements for building `.wasm` plugins with TinyGo:**
| Tool | Version | Notes | | Tool | Version | Notes |
|------|---------|-------| | -------- | ------------ | --------------------------------------------------------------------------------------------------- |
| TinyGo | 0.40.1 | Supports Go 1.191.25 only | | TinyGo | 0.40.1 | Supports Go 1.191.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` | | 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 | | wasm-opt | Binaryen 129 | Required by TinyGo for the `wasi` target; download from Binaryen GitHub releases |
Any WASM toolchain (Rust/`wasm32-wasi`, AssemblyScript, etc.) that exports the five ABI 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/`. functions is equally valid — TinyGo is just the example toolchain used by `examples/hello/`.
@@ -176,7 +176,7 @@ on admins. So a PR is self-mergeable once CI is green, but no commit reaches
Two consequences worth knowing before you open a PR: Two consequences worth knowing before you open a PR:
- The Docker and Tauri Full Build jobs are gated on `main` and report as - 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. _skipped_ on a PR into `dev`. That is expected, not a failure.
- Squash merge, and a conventional commit subject on the squashed commit. - Squash merge, and a conventional commit subject on the squashed commit.
## Branch Naming ## Branch Naming
@@ -242,7 +242,7 @@ closing audit findings 2026-04-07 #8 / DC-11):
reading the changelog. Peer-coupled groups (`vitest`/`@vitest/*`, reading the changelog. Peer-coupled groups (`vitest`/`@vitest/*`,
`@stryker-mutator/*`) update as one PR so exact peer pins cannot wedge. `@stryker-mutator/*`) update as one PR so exact peer pins cannot wedge.
- **Security gates run on every PR:** `npm audit --omit=dev - **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), triaged in the workflow comment instead of blocking on unfixable pins),
`govulncheck` for Go, `cargo audit` for Rust, and `knip` refuses unused `govulncheck` for Go, `cargo audit` for Rust, and `knip` refuses unused
client dependencies outright. client dependencies outright.
+12 -12
View File
@@ -3,10 +3,10 @@
The desktop client persists two secrets per server, both in the OS credential The desktop client persists two secrets per server, both in the OS credential
store under the service name `com.owncord.client`: store under the service name `com.owncord.client`:
| Secret | Account name | Contents | | Secret | Account name | Contents |
| --- | --- | --- | | ------------------------------- | ----------------- | -------------------------------------- |
| Login credential | `{host}` | JSON `{"username","token","password"}` | | Login credential | `{host}` | JSON `{"username","token","password"}` |
| Voice-E2EE identity private key | `identity:{host}` | base64 JWK (P-256 private key) | | 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 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 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: served it, touching no real credential:
```js ```js
await invoke("probe_credential_store") await invoke("probe_credential_store");
// { ok: true, backend: "Keyring", error: null } // { ok: true, backend: "Keyring", error: null }
// (Backend enum variants serialize verbatim: "Keyring" | "DpapiFile" | "EncryptedFile") // (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 Windows persisting credentials, and the client now detects and reports them
instead of silently regenerating keys. instead of silently regenerating keys.
| Cause | Check | Fix | | Cause | Check | Fix |
| --- | --- | --- | | ---------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Credential Manager service stopped | `sc query VaultSvc` | `sc config VaultSvc start= auto && sc start VaultSvc` | | 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. | | "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. | | 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. | | 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 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 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 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 signature does not verify is still rejected. The fallback only affects whether
*our own* key survives a restart. _our own_ key survives a restart.
+22 -15
View File
@@ -14,12 +14,14 @@ Production deployment guide for OwnCord server on Windows and Linux.
## Building from Source ## Building from Source
**Windows:** **Windows:**
```bash ```bash
cd Server 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.3" .
``` ```
**Linux:** **Linux:**
```bash ```bash
cd Server 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.3" .
@@ -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 - `CGO_ENABLED=0` produces a fully static binary on Linux
Alternatively, download a pre-built binary from GitHub Releases: Alternatively, download a pre-built binary from GitHub Releases:
- **Windows**: `chatserver.exe` - **Windows**: `chatserver.exe`
- **Linux**: `chatserver-linux-amd64.tar.gz` (extract to get `chatserver`) - **Linux**: `chatserver-linux-amd64.tar.gz` (extract to get `chatserver`)
@@ -74,11 +77,11 @@ server:
port: 8443 port: 8443
voice: 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" quality: "medium"
tls: tls:
mode: "self_signed" # or "acme" / "manual" for production mode: "self_signed" # or "acme" / "manual" for production
``` ```
### Data Persistence ### Data Persistence
@@ -310,12 +313,12 @@ The database uses SQLite WAL mode. Do NOT copy the `.db` file directly while the
### Admin Backup Endpoint ### Admin Backup Endpoint
| Endpoint | Method | Description | | Endpoint | Method | Description |
|----------|--------|-------------| | ----------------------------------- | ------ | ------------------------------------------------------------------------- |
| `/admin/api/backup` | POST | Create a new backup (owner-only) | | `/admin/api/backup` | POST | Create a new backup (owner-only) |
| `/admin/api/backups` | GET | List all backups (newest first) | | `/admin/api/backups` | GET | List all backups (newest first) |
| `/admin/api/backups/{name}` | DELETE | Delete a backup (owner-only) | | `/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) | | `/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 Backups are stored in the configured backup directory (default
`data/backups/`) with timestamps. Point it somewhere safer than the data `data/backups/`) with timestamps. Point it somewhere safer than the data
@@ -450,6 +453,7 @@ descriptions):
### Server ### Server
The server checks GitHub Releases for updates: The server checks GitHub Releases for updates:
- Compares semver versions - Compares semver versions
- Results are cached for 1 hour - Results are cached for 1 hour
- Downloads `chatserver.exe` with detached Ed25519/minisign signature verification - 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 ### Client
The Tauri client uses NSIS installer updates: The Tauri client uses NSIS installer updates:
- Server exposes client update assets from GitHub Releases - Server exposes client update assets from GitHub Releases
- Ed25519 signature verification before applying - Ed25519 signature verification before applying
## Firewall and Ports ## Firewall and Ports
| Port | Protocol | Purpose | | Port | Protocol | Purpose |
|------|----------|---------| | ------------- | -------- | ------------------------------------------------- |
| `8443` | TCP | HTTPS server (configurable via `server.port`) | | `8443` | TCP | HTTPS server (configurable via `server.port`) |
| `80` | TCP | ACME HTTP-01 challenge (only if `tls.mode: acme`) | | `80` | TCP | ACME HTTP-01 challenge (only if `tls.mode: acme`) |
| `7880` | TCP | LiveKit server (WebSocket signaling) | | `7880` | TCP | LiveKit server (WebSocket signaling) |
| `7881` | TCP | LiveKit server (RTC/TURN over TCP) | | `7881` | TCP | LiveKit server (RTC/TURN over TCP) |
| `50000-60000` | UDP | LiveKit WebRTC media (ICE candidates) | | `50000-60000` | UDP | LiveKit WebRTC media (ICE candidates) |
For remote access, see the [Port Forwarding Guide](port-forwarding.md) or [Tailscale Guide](tailscale.md). 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 ## Background Maintenance
The server runs a maintenance loop every 15 minutes that: The server runs a maintenance loop every 15 minutes that:
- Purges expired user sessions - Purges expired user sessions
- Deletes orphaned file attachments (uploaded but never linked to a message, older than 1 hour) - Deletes orphaned file attachments (uploaded but never linked to a message, older than 1 hour)
- Uses a circuit breaker (pauses after 5 consecutive failures) - 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 ## Graceful Shutdown
The server handles `Ctrl+C` (SIGINT) and `SIGTERM`: The server handles `Ctrl+C` (SIGINT) and `SIGTERM`:
1. Stops accepting new connections 1. Stops accepting new connections
2. Closes all WebSocket connections and voice rooms 2. Closes all WebSocket connections and voice rooms
3. Drains HTTP connections with a 30-second timeout 3. Drains HTTP connections with a 30-second timeout
+38 -36
View File
@@ -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: There are two ways to run LiveKit alongside OwnCord:
| Method | Best for | LiveKit managed by | | Method | Best for | LiveKit managed by |
|--------|----------|--------------------| | --------------------- | -------------------------- | --------------------------- |
| **Docker Compose** | Linux servers | Docker (separate container) | | **Docker Compose** | Linux servers | Docker (separate container) |
| **Companion process** | Windows / bare-metal Linux | OwnCord (auto-start) | | **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 tcp_port: 7881
port_range_start: 50000 port_range_start: 50000
port_range_end: 60000 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: keys:
my-unique-key: my-secret-at-least-32-characters-long my-unique-key: my-secret-at-least-32-characters-long
logging: logging:
@@ -43,11 +43,11 @@ When running OwnCord via `docker compose`, LiveKit runs as a separate container
4. **Open firewall ports** on your host: 4. **Open firewall ports** on your host:
| Port | Protocol | Purpose | | Port | Protocol | Purpose |
|------|----------|---------| | ------------- | -------- | ----------------------- |
| `7880` | TCP | LiveKit signaling | | `7880` | TCP | LiveKit signaling |
| `7881` | TCP | TCP fallback for WebRTC | | `7881` | TCP | TCP fallback for WebRTC |
| `50000-60000` | UDP | WebRTC media | | `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. > **`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" quality: "medium"
``` ```
| Field | Purpose | Default | | Field | Purpose | Default |
|-------|---------|---------| | ----------------------- | ---------------------------------------------------------------------------------------------------- | -------------------------------------- |
| `livekit_api_key` | Shared API key between OwnCord and LiveKit | `"devkey"` | | `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_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_url` | LiveKit WebSocket URL | `ws://localhost:7880` |
| `livekit_binary` | Path to `livekit-server` binary. Empty + auto-download off = assume externally managed | `""` | | `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 | | `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) | | `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) | | `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` | | `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"` | | `quality` | Default voice quality preset | `"medium"` |
Environment variable overrides use the `OWNCORD_` prefix: `OWNCORD_VOICE_LIVEKIT_API_KEY`, `OWNCORD_VOICE_LIVEKIT_API_SECRET`, etc. 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 ## 3. Ports and Firewall
| Port | Protocol | Purpose | | Port | Protocol | Purpose |
|------|----------|---------| | --------------- | ------------- | ---------------------------------------- |
| **7880** | TCP (HTTP/WS) | LiveKit signaling (WebSocket + REST API) | | **7880** | TCP (HTTP/WS) | LiveKit signaling (WebSocket + REST API) |
| **7881** | TCP | LiveKit internal RTC (TURN/TCP fallback) | | **7881** | TCP | LiveKit internal RTC (TURN/TCP fallback) |
| **50000-60000** | UDP | Media transport (RTP audio/video) | | **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). 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:** **Token details:**
- Room name: `"channel-{channelID}"` - Room name: `"channel-{channelID}"`
- Identity: `"user-{userID}"` - Identity: `"user-{userID}"`
- TTL: 24 hours (refresh at 23h) - 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 can request refresh via `voice_token_refresh` (rate limited to 1/60s)
**Client connection paths:** **Client connection paths:**
- **Proxy path** (`/livekit`): Client connects through OwnCord's HTTPS server. Avoids mixed-content issues. - **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. - **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 ## 7. Troubleshooting
| Symptom | Cause | Fix | | 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 | | "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 | | "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 | | 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 | | "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 | | "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 | | 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 | | 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` | | `GET /api/v1/livekit/health` returns degraded | LiveKit server not reachable | Verify LiveKit is running: `curl http://localhost:7880` |
--- ---
+32 -32
View File
@@ -59,7 +59,7 @@ the token nor the cert.)
### The three tools ### 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 `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 `{ status, headers, body }` (body is JSON-parsed when possible, else raw text). Any HTTP method is
allowed, including destructive admin routes. allowed, including destructive admin routes.
@@ -144,13 +144,13 @@ expect `{status:200, body:{...}}`.
### `api_request` ### `api_request`
| Param | Type | Notes | | Param | Type | Notes |
|-------|------|-------| | --------- | ------- | --------------------------------------------------------------- |
| `method` | string | `GET`, `POST`, `PATCH`, `PUT`, `DELETE` | | `method` | string | `GET`, `POST`, `PATCH`, `PUT`, `DELETE` |
| `path` | string | Path beginning with `/` (e.g. `/admin/api/stats`) or a full URL | | `path` | string | Path beginning with `/` (e.g. `/admin/api/stats`) or a full URL |
| `query` | object? | Query-string params | | `query` | object? | Query-string params |
| `body` | any? | JSON body (object or string) | | `body` | any? | JSON body (object or string) |
| `headers` | object? | Extra request headers | | `headers` | object? | Extra request headers |
Returns `{ status, headers, body }`. Returns `{ status, headers, body }`.
@@ -167,12 +167,12 @@ Useful read-only endpoints: `/health`, `/api/v1/metrics` (runtime/process stats)
### `server_logs` ### `server_logs`
| Param | Type | Default | Notes | | Param | Type | Default | Notes |
|-------|------|---------|-------| | ----------- | ------- | ------- | ------------------------------------------------------------------------------------------ |
| `level` | string? | — | `DEBUG` \| `INFO` \| `WARN` \| `ERROR` | | `level` | string? | — | `DEBUG` \| `INFO` \| `WARN` \| `ERROR` |
| `source` | string? | — | `websocket`, `http`, `admin`, `auth`, `database`, `storage`, `updater`, `config`, `server` | | `source` | string? | — | `websocket`, `http`, `admin`, `auth`, `database`, `storage`, `updater`, `config`, `server` |
| `limit` | number? | 500 | Max records returned | | `limit` | number? | 500 | Max records returned |
| `follow_ms` | number? | 0 | `0` = backfill only; `>0` = also stream live for that many ms | | `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`). 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` ### `client_logs`
| Param | Type | Default | Notes | | Param | Type | Default | Notes |
|-------|------|---------|-------| | ------- | ------- | ------- | ----------------------------------------- |
| `lines` | number? | 200 | Trailing lines to return | | `lines` | number? | 200 | Trailing lines to return |
| `level` | string? | — | Keep only lines tagged `[LEVEL]` | | `level` | string? | — | Keep only lines tagged `[LEVEL]` |
| `grep` | string? | — | Keep only lines containing this substring | | `grep` | string? | — | Keep only lines containing this substring |
Returns `{ path, found: true, lines: [...] }`, or `{ path, found: false, note }` if the client has Returns `{ path, found: true, lines: [...] }`, or `{ path, found: false, note }` if the client has
not run yet. not run yet.
@@ -198,25 +198,25 @@ not run yet.
All optional except the token (which only the two server-backed tools need). All optional except the token (which only the two server-backed tools need).
| Env var | Default | Purpose | | Env var | Default | Purpose |
|---------|---------|---------| | -------------------- | ----------------------------------------------------------- | ---------------------------------------------------------------------------------------------- |
| `OWNCORD_API_TOKEN` | *(required for `api_request`/`server_logs`)* | Bearer token from `server token create`. | | `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_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_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. | | `OWNCORD_CLIENT_LOG` | `%LOCALAPPDATA%\com.owncord.client\logs\owncord-client.log` | Desktop client log path. |
--- ---
## Troubleshooting ## Troubleshooting
| Symptom | Cause / fix | | 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_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`. | | `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 `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`. | | `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. | | `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`. | | `client_logs``found: false` | The desktop client hasn't run yet, or the path differs — set `OWNCORD_CLIENT_LOG`. |
--- ---
+34 -34
View File
@@ -10,56 +10,56 @@ authority**.
## Active — these drive current work ## Active — these drive current work
| Plan | State | | Plan | State |
| --- | --- | | ----------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------- |
| [beta-product-requirements-2026-08-23](beta-product-requirements-2026-08-23.md) | Approved beta scope, frozen. 57 `BPR-*` requirements. | | [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, B0B10. No phase complete. | | [repo-health-roadmap-2026-08-23](repo-health-roadmap-2026-08-23.md) | Phase order and gates, B0B10. No phase complete. |
| [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. | | [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. | | [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. | | [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 done, B1-1 next.** B1 execution plan. Re-verifies every RL-* claim against HEAD; several are refuted. | | [b1-repository-foundation-2026-08-25](b1-repository-foundation-2026-08-25.md) | **B1-0 done, B1-1 next.** 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-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`. |
| [audit-2026-08-19-remediation](audit-2026-08-19-remediation.md) | Phases 16 done 2026-08-20; **phase 7 pending**. Its header still reads "in progress 2026-08-19" — stale; the phase table is correct. | | [audit-2026-08-19-remediation](audit-2026-08-19-remediation.md) | Phases 16 done 2026-08-20; **phase 7 pending**. Its header still reads "in progress 2026-08-19" — stale; the phase table is correct. |
## Partially implemented ## Partially implemented
| Plan | State | | 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. | | [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 ## Design only — not implemented
| Plan | State | | Plan | State |
| --- | --- | | ----------------------------------- | -------------------------------------------------- |
| [slash-commands](slash-commands.md) | Design only. No implementation; not in beta scope. | | [slash-commands](slash-commands.md) | Design only. No implementation; not in beta scope. |
## Shipped — kept for history, do not use as current status ## Shipped — kept for history, do not use as current status
| Plan | Shipped | | Plan | Shipped |
| --- | --- | | ------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------- |
| [audit-2026-07-19-decisions](audit-2026-07-19-decisions.md) | Decisions recorded; greenlit items implemented through 2026-07-23. | | [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. | | [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. | | [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. | | [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. | | [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. | | [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-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 F1F8 closed, verified 2026-08-04. | | [security-scan-2026-07-22-remediation](security-scan-2026-07-22-remediation.md) | All 8 findings F1F8 closed, verified 2026-08-04. |
| [sqlc-adoption](sqlc-adoption.md) | Shipped, verified 2026-08-04. | | [sqlc-adoption](sqlc-adoption.md) | Shipped, verified 2026-08-04. |
| [discord-parity](discord-parity.md) | Phases 16 complete, verified 2026-08-04. Phase 1's table reads as a gap list but every row shipped. | | [discord-parity](discord-parity.md) | Phases 16 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). | | [infrastructure-roadmap](infrastructure-roadmap.md) | 2026-08-15, with two recorded leftovers (TOTP persister seam; published capacity numbers). |
## Where status actually lives ## Where status actually lives
Planning documents are not trackers. Do not read a defect count out of one. Planning documents are not trackers. Do not read a defect count out of one.
| Concern | Source of truth | | Concern | Source of truth |
| --- | --- | | -------------------------- | ------------------------------------------------------------------------------- |
| Defect status | `.superpowers/findings-ledger.json` (`FINDINGS.md` is rendered from it) | | Defect status | `.superpowers/findings-ledger.json` (`FINDINGS.md` is rendered from it) |
| Security-sensitive defects | Private GitHub Security Advisories | | Security-sensitive defects | Private GitHub Security Advisories |
| Product scope | [beta-product-requirements-2026-08-23](beta-product-requirements-2026-08-23.md) | | 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) | | 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) | | 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**. Ledger at 2026-08-25: **306 fixed / 38 open / 3 declined / 1 duplicate = 348**.
All 38 open records still resolve to a live `file:line` at All 38 open records still resolve to a live `file:line` at
+15 -15
View File
@@ -16,21 +16,21 @@ here (and the audit's closure table) as items land.
## Decisions ## Decisions
| # | Decision point | Audit ID | Decision | Status | | # | 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. | | 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). | | 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. | | 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. | | 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). | | 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. | | 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 001015, profile/blocks/plugin-admin endpoints); reference tables now point at `protocol-schema.json`. | | 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 001015, 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. | | 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. | | 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. | | 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, ~12 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`). | | 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, ~12 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. | | 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. | | 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 ## Suggested sequencing
+9 -9
View File
@@ -13,15 +13,15 @@ the audit-report PR #1395 merged), one commit per phase, single PR to `main`.
## Phases ## Phases
| # | Closes | Change | Verification | Status | | # | 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 | | 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 | | 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 | | 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 | | 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 | | 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) | | 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 | | 7 | — | `ci-check` local CI mirror, push, PR, drive green | CI | pending |
## Decisions taken ## Decisions taken
+65 -64
View File
@@ -12,49 +12,49 @@ inherited silently.
## Environment ## Environment
| Tool | Version | Note | | Tool | Version | Note |
| --- | --- | --- | | -------------------------- | ---------------------------- | ---------------------------------------------- |
| Node | 26.4.0 | **Local only.** CI pins 24. See ENV-01. | | Node | 26.4.0 | **Local only.** CI pins 24. See ENV-01. |
| npm | 11.17.0 | | | npm | 11.17.0 | |
| Go | 1.26.7 | Matches `Server/go.mod` `toolchain go1.26.7`. | | 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. | | golangci-lint | 2.11.3 (built with go1.26.5) | Runs correctly despite the mismatch. See G-05. |
| Playwright | 1.62.1 | | | Playwright | 1.62.1 | |
| Vitest / Vite / TypeScript | 4.1.11 / 8.2.2 / 6.0.3 | | | Vitest / Vite / TypeScript | 4.1.11 / 8.2.2 / 6.0.3 | |
| oxlint / eslint / prettier | 1.79.0 / 10.9.0 / 3.9.6 | | | oxlint / eslint / prettier | 1.79.0 / 10.9.0 / 3.9.6 | |
| Client version | 1.2.0-alpha.3 | | | Client version | 1.2.0-alpha.3 | |
## Measured results ## Measured results
| Gate | Result | Provenance | | Gate | Result | Provenance |
| --- | --- | --- | | --------------------------------- | --------------------------------------------------- | ---------------------------------------------- |
| Server build — default | pass | measured | | Server build — default | pass | measured |
| Server build — `otel` | pass | measured | | Server build — `otel` | pass | measured |
| Server build — `wazero` | pass | measured | | Server build — `wazero` | pass | measured |
| Server build — `otel wazero` | pass | measured | | Server build — `otel wazero` | pass | measured |
| `go vet ./...` | pass | measured | | `go vet ./...` | pass | measured |
| `golangci-lint run ./...` | **0 issues**, 19 linters active, 1.18s | measured | | `golangci-lint run ./...` | **0 issues**, 19 linters active, 1.18s | measured |
| Go `-race ./...` | pass (exit 0, no data race) | measured | | Go `-race ./...` | pass (exit 0, no data race) | measured |
| Go `-tags deadlock ./...` | pass (exit 0) | measured | | Go `-tags deadlock ./...` | pass (exit 0) | measured |
| Client unit + integration | **5257 passed / 192 files, 0 failed** | measured | | Client unit + integration | **5257 passed / 192 files, 0 failed** | measured |
| Client `tsc` (build + e2e + root) | pass | measured | | Client `tsc` (build + e2e + root) | pass | measured |
| Client `prettier --check` | pass | measured | | Client `prettier --check` | pass | measured |
| Client `npm run lint` | pass (exit 0) | measured | | Client `npm run lint` | pass (exit 0) | measured |
| oxlint warnings | **471** | measured — unchanged from audit | | oxlint warnings | **471** | measured — unchanged from audit |
| Playwright full suite | **293 passed, exit 0, 37s** | measured | | Playwright full suite | **293 passed, exit 0, 37s** | measured |
| Client production build | pass, 401ms | 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) | | 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 | | Server coverage | **74.6% aggregate** | measured — confirms the carried figure exactly |
| Rust clippy + 115 tests | pass | **carried**, not re-measured | | Rust clippy + 115 tests | pass | **carried**, not re-measured |
### Bundle sizes (measured) ### Bundle sizes (measured)
| Chunk | Minified | Gzip | | Chunk | Minified | Gzip |
| --- | ---: | ---: | | ----------------- | ----------: | ----------: |
| `livekitSession` | 1,998.25 kB | 1,344.96 kB | | `livekitSession` | 1,998.25 kB | 1,344.96 kB |
| `livekit` | 495.41 kB | 127.88 kB | | `livekit` | 495.41 kB | 127.88 kB |
| `MainPage` | 192.18 kB | 58.92 kB | | `MainPage` | 192.18 kB | 58.92 kB |
| `index` | 187.18 kB | 59.07 kB | | `index` | 187.18 kB | 59.07 kB |
| `SettingsOverlay` | 47.56 kB | 13.98 kB | | `SettingsOverlay` | 47.56 kB | 13.98 kB |
Confirms the audit's "~2.0 MB minified / 1.345 MB gzip" for the largest lazy 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. chunk. This is the budget baseline B7 ratchets against.
@@ -63,33 +63,33 @@ chunk. This is the budget baseline B7 ratchets against.
### Closed ### Closed
| ID | Was | Now | Evidence | | ID | Was | Now | Evidence |
| --- | --- | --- | --- | | -------------------------- | --------------------- | --------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| G-01 | P0 confirmed | **fixed** | See "G-01 was inverted" below. | | 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. | | 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. | | 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. | | 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 ### Refuted
| ID | Claim | Finding | | 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. | | 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 ### Still open
| ID | Pri | State | Note | | 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-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 16 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). | | 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 16 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. | | ENV-02 | — | **closed** | Docker smoke now measured locally and passing. Moved to Closed. |
### New findings ### New findings
| ID | Pri | Finding | | 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-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). | | 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 ## G-01 was inverted, not stale
@@ -115,7 +115,7 @@ The test now captures the signal each window's row listeners are registered
against and asserts the invariant its name always claimed: against and asserts the invariant its name always claimed:
- every rendered window's rows share exactly one signal; - every rendered window's rows share exactly one signal;
- each jump renders against a *fresh* signal (nothing accumulates); - each jump renders against a _fresh_ signal (nothing accumulates);
- every superseded window's signal is already aborted, and exactly one is live. - every superseded window's signal is already aborted, and exactly one is live.
Verified both directions: green on the fix, and with `beginRowRender()` reverted Verified both directions: green on the fix, and with `beginRowRender()` reverted
@@ -133,14 +133,14 @@ the failure looked like "tests never finish" when it was "process never exits."
Playwright's `webServer` teardown does not kill it on Windows. Measured: Playwright's `webServer` teardown does not kill it on Windows. Measured:
| webServer setup | Terminates | Tests | | webServer setup | Terminates | Tests |
| --- | --- | --- | | ------------------------------------ | ---------- | ------------------- |
| `npm run dev` | no — hangs | pass | | `npm run dev` | no — hangs | pass |
| `node node_modules/vite/bin/vite.js` | no — hangs | pass | | `node node_modules/vite/bin/vite.js` | no — hangs | pass |
| `reuseExistingServer: false` | no — hangs | pass | | `reuseExistingServer: false` | no — hangs | pass |
| `gracefulShutdown: { SIGTERM, 3s }` | no — hangs | pass | | `gracefulShutdown: { SIGTERM, 3s }` | no — hangs | pass |
| `npx vite` | yes | **290 of 293 fail** | | `npx vite` | yes | **290 of 293 fail** |
| no `webServer` (server pre-started) | yes | 293 pass in 33s | | no `webServer` (server pre-started) | yes | 293 pass in 33s |
`npx vite` only appears to work: npx exits once Vite is up, Playwright reads `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 that as the server dying and tears the group down mid-run, so later tests fail
@@ -148,7 +148,7 @@ with `ERR_CONNECTION_REFUSED`.
**Fix:** `tests/e2e/global-teardown.ts` kills the process listening on the dev **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 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 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. 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 Result: `npm run test:e2e` exits 0 in 37s with 293 passed, reproducibly, leaving
@@ -205,6 +205,7 @@ four questions, records three items accepted as stated limitations, and is the
authority over the leftovers listed below. B1 is unblocked. authority over the leftovers listed below. B1 is unblocked.
## Not yet done in B0 — closed out at acceptance ## Not yet done in B0 — closed out at acceptance
- ~~Step 6 follow-up: pin required status checks on `dev`~~ — **done - ~~Step 6 follow-up: pin required status checks on `dev`~~ — **done
2026-08-25.** Ten checks are pinned; `Server Docker Build (verify)`, 2026-08-25.** Ten checks are pinned; `Server Docker Build (verify)`,
`Tauri Full Build (*)`, `Admin Panel E2E`, and the `CodeQL` aggregate are `Tauri Full Build (*)`, `Admin Panel E2E`, and the `CodeQL` aggregate are
+10
View File
@@ -24,6 +24,11 @@
# three of them exist in no workflow file at all, because CodeQL runs # three of them exist in no workflow file at all, because CodeQL runs
# from GitHub default setup configured in repository settings. # 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.
#
# Deliberately NOT pinned, and why: # Deliberately NOT pinned, and why:
# Server Docker Build (verify) reports "skipping" on a dev PR # Server Docker Build (verify) reports "skipping" on a dev PR
# (if: ref_name=='main' || base_ref=='main') # (if: ref_name=='main' || base_ref=='main')
@@ -35,6 +40,10 @@
# theatre (that is R-01, B10 work) # theatre (that is R-01, B10 work)
# CodeQL default-setup aggregate over the three # CodeQL default-setup aggregate over the three
# Analyze jobs; pinning those is enough # Analyze jobs; pinning those is enough
# Docs & Ledger Consistency reports and passes on a dev PR, and is NOT
# pinned. That looks like an oversight from
# the 2026-08-25 pass rather than a decision;
# it belongs to G-04, not to B1-3.
# #
# A required check that never reports blocks every PR forever. Re-read the # A required check that never reports blocks every PR forever. Re-read the
# list before changing it: # list before changing it:
@@ -56,6 +65,7 @@ gh api -X PUT "repos/${REPO}/branches/dev/protection" --input - <<'JSON'
"Client Static Checks", "Client Static Checks",
"Client Unit Tests", "Client Unit Tests",
"Rust Unit Tests", "Rust Unit Tests",
"Repository Hygiene",
"Client E2E (Playwright)", "Client E2E (Playwright)",
"Client E2E (parity subset, blocking)", "Client E2E (parity subset, blocking)",
"Analyze (go)", "Analyze (go)",
@@ -2,8 +2,9 @@
**Drafted:** 2026-08-25 **Drafted:** 2026-08-25
**Base commit:** `6a1561fa` (`dev`, post-PR #1409) **Base commit:** `6a1561fa` (`dev`, post-PR #1409)
**Status:** proposed; **entry gate met — HP-0 accepted 2026-08-25**. B1-0 is **Status:** in progress; **entry gate met — HP-0 accepted 2026-08-25**. B1-0
complete; B1-1 is the next step. (#1410), B1-1 (#1411), B1-2 (#1412) and B1-3 are complete; B1-4 is the next
step.
Primary inputs: Primary inputs:
@@ -29,7 +30,7 @@ to see.
This plan therefore does two things the roadmap's workstream list does not: it 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 **re-verifies every RL claim against HEAD before implementing it**, and it
specifies a *mechanical* proof that each of the two flatten commits changed specifies a _mechanical_ proof that each of the two flatten commits changed
nothing. nothing.
## Entry gate: HP-0 — was not accepted, now is ## Entry gate: HP-0 — was not accepted, now is
@@ -37,13 +38,13 @@ nothing.
When this plan was drafted, the roadmap's B1 entry gate (`- HP-0 is accepted.`) When this plan was drafted, the roadmap's B1 entry gate (`- HP-0 is accepted.`)
was **unmet**, and nothing in the repository recorded otherwise: was **unmet**, and nothing in the repository recorded otherwise:
| Evidence | Finding | | 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.` | | [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`. | | `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. | | 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. | | That file's history | One commit (`6a1561fa` = HEAD). Nothing supersedes the line. |
| `CHANGELOG.md` | B0 / PR #1409 absent entirely. | | `CHANGELOG.md` | B0 / PR #1409 absent entirely. |
The material to answer HP-0's four questions mostly exists — spread across four The material to answer HP-0's four questions mostly exists — spread across four
documents rather than the single artifact the hold point requires. documents rather than the single artifact the hold point requires.
@@ -54,7 +55,7 @@ documents rather than the single artifact the hold point requires.
the roadmap's `## Phase scorecard` table shape, one row per metric, each cell the roadmap's `## Phase scorecard` table shape, one row per metric, each cell
linking to its B0 evidence. Part-closes `R-08`. linking to its B0 evidence. Part-closes `R-08`.
2. **Pin required status checks on `dev`.** B0's own leftover. The trap is 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 knowing which jobs _never report_ on a dev-targeted PR — pinning one of those
deadlocks every PR. deadlocks every PR.
The exact reporting set was observed on a live dev-targeted PR (#1410), not The exact reporting set was observed on a live dev-targeted PR (#1410), not
@@ -63,15 +64,15 @@ documents rather than the single artifact the hold point requires.
alone would have missed them. alone would have missed them.
Pin: `Server Build & Test (windows-latest)`, `Server Build & Test Pin: `Server Build & Test (windows-latest)`, `Server Build & Test
(ubuntu-latest)`, `Client Static Checks`, `Client Unit Tests`, `Rust Unit (ubuntu-latest)`, `Client Static Checks`, `Client Unit Tests`, `Rust Unit
Tests`, `Client E2E (Playwright)`, `Client E2E (parity subset, blocking)`, Tests`, `Client E2E (Playwright)`, `Client E2E (parity subset, blocking)`,
`Analyze (go)`, `Analyze (javascript-typescript)`, `Analyze (actions)`. `Analyze (go)`, `Analyze (javascript-typescript)`, `Analyze (actions)`.
Do **not** pin: Do **not** pin:
- `Server Docker Build (verify)` — observed as **skipping** on a dev PR - `Server Docker Build (verify)` — observed as **skipping** on a dev PR
(`if: ref_name=='main' || base_ref=='main'`). (`if: ref_name=='main' || base_ref=='main'`).
- `Tauri Full Build (${{ matrix.os }})` — reports **skipping** on a dev PR, - `Tauri Full Build (${{ matrix.os }})` — reports **skipping** on a dev PR,
under the *unexpanded* matrix name, because the job is skipped before matrix 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; expansion. (An earlier revision of this plan said it does not appear at all;
that was wrong — observed on PR #1410.) that was wrong — observed on PR #1410.)
- `CodeQL` — a default-setup aggregate over the three `Analyze` jobs. Pinning - `CodeQL` — a default-setup aggregate over the three `Analyze` jobs. Pinning
@@ -85,8 +86,9 @@ documents rather than the single artifact the hold point requires.
Extend [`b0-dev-branch-protection.sh`](b0-dev-branch-protection.sh) with a Extend [`b0-dev-branch-protection.sh`](b0-dev-branch-protection.sh) with a
`required_status_checks` block rather than adding a second script. `required_status_checks` block rather than adding a second script.
3. **Resolve the two unverified baseline rows.** Rust clippy + 115 tests is 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, _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 not CI's 24 (ENV-01). Once checks are pinned, one green dev PR supplies the
CI-side numbers. CI-side numbers.
4. **Answer HP-0 question 2 honestly.** B0 verified the 38 open `OC-*` records 4. **Answer HP-0 question 2 honestly.** B0 verified the 38 open `OC-*` records
@@ -102,49 +104,49 @@ change starts before that line exists.**
### Status: all five closed, HP-0 accepted 2026-08-25 ### Status: all five closed, HP-0 accepted 2026-08-25
| # | Item | Outcome | | # | Item | Outcome |
| --- | --- | --- | | --- | ----------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| 1 | Scorecard | [hp-0-scorecard-2026-08-25.md](hp-0-scorecard-2026-08-25.md) written; part-closes `R-08`. | | 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. | | 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. | | 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**. | | 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. | | 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 Also corrected while closing item 2: the live check list is **not** what
`ci.yml` implies. See the amended table above. `ci.yml` implies. See the amended table above.
## What B0 already closed — do not redo ## What B0 already closed — do not redo
| Finding | State at HEAD | Leftover for B1 | | 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-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-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. | | **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-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 | | **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. | | **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 ## Verify before you implement
Every `RL-*` was re-tested against `6a1561fa`. Several are materially wrong, and Every `RL-*` was re-tested against `6a1561fa`. Several are materially wrong, and
that changes the work. that changes the work.
| Claim | Verdict | What it means | | 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-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-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-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" | **Refuted locally** | `graphify` 0.9.41 is installed and on PATH. Tracked payload is **20.41 MB**, `graph.json` **19.46 MB**. Note `du -sh graphify-out/` reports 208 MB — that is gitignored dated snapshots plus `cache/`, not repo weight. Linux/CI portability remains unproven. | | **RL-06** "regeneration not demonstrated" | **Refuted locally** | `graphify` 0.9.41 is installed and on PATH. Tracked payload is **20.41 MB**, `graph.json` **19.46 MB**. Note `du -sh graphify-out/` reports 208 MB — that is gitignored dated snapshots plus `cache/`, not repo weight. Linux/CI portability remains unproven. |
| **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-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. And no workflow runs it. | | **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. And no workflow runs it. |
| **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-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-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-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-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-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-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-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** | Insufficient; impact bounded today by read-only content permissions. 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. | | **RL-22** paid automation authorization | **Confirmed** | Insufficient; impact bounded today by read-only content permissions. 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 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.** constraint; RL-05, RL-07, RL-20 and RL-21 are each worse than written.**
@@ -154,7 +156,7 @@ constraint; RL-05, RL-07, RL-20 and RL-21 are each worse than written.**
**Do this immediately after B1-0, before any other B1 work.** **Do this immediately after B1-0, before any other B1 work.**
This deliberately contradicts the audit's own step order, which puts docs and 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 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 — 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 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 row-for-row comparison against the freshly measured B0 baseline with nothing
@@ -231,7 +233,7 @@ Two rules, applied to an **explicit allow-list of files**, never repo-wide:
- **R1**`Client/tauri-client/` becomes `Client/` (plus the bare - **R1**`Client/tauri-client/` becomes `Client/` (plus the bare
`tauri-client/` form in `Server/service/sanitize_content_fuzz_test.go`). `tauri-client/` form in `Server/service/sanitize_content_fuzz_test.go`).
- **R2** — relative paths that *escape the client root* lose one `../`. Paths - **R2** — relative paths that _escape the client root_ lose one `../`. Paths
that stay inside the client are unchanged; depth within the subtree is that stay inside the client are unchanged; depth within the subtree is
unaffected. unaffected.
@@ -266,14 +268,14 @@ files (`slash-commands.md`, `bug-detection-improvements.md`,
**R2 — depth-sensitive, ranked by how quietly they fail:** **R2 — depth-sensitive, ranked by how quietly they fail:**
| # | Location | Change | Why it is dangerous | | # | 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. | | 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**. | | 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. | | 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. | | 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. | | 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. | | 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` Confirmed **not** depth-sensitive — all intra-client or `import.meta.dirname`
anchored: `vite.config.ts`, `vitest.config*.ts`, all four `playwright.config*.ts`, anchored: `vite.config.ts`, `vitest.config*.ts`, all four `playwright.config*.ts`,
@@ -300,7 +302,7 @@ active records point at nothing, immediately undoing B0's verification that all
**Recommendation:** rewrite every path in the ledger under R1 — fixed records **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 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 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 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, check as the actual proof: read the ledger, assert every `file` exists on disk,
and print the count of dead paths. and print the count of dead paths.
@@ -309,7 +311,7 @@ Run it **before** the flatten (expect zero dead paths) and **after** commit 2
### Step 5 — prove commit 2 is mechanical ### Step 5 — prove commit 2 is mechanical
The diff cannot be byte-identical, so prove the *transformation* instead: The diff cannot be byte-identical, so prove the _transformation_ instead:
regenerate the after-state from the before-state with a scripted substitution 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 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 the allow-list**, not a hundred diff hunks. The six R2 hunks get individual human
@@ -458,7 +460,7 @@ behaviour rather than adopting workspaces on principle.
before a CI artifact exists. Never rewrite published history. before a CI artifact exists. Never rewrite published history.
- **`RL-07/L-07`** — the sharpest of the three. `--check` cannot detect drift and - **`RL-07/L-07`** — the sharpest of the three. `--check` cannot detect drift and
no workflow runs it. Add a real drift check (render to a temp file and diff), no workflow runs it. Add a real drift check (render to a temp file and diff),
wire it into CI, *then* consider untracking the rendering. wire it into CI, _then_ consider untracking the rendering.
- **`RL-08/L-08`** — source is committed; only the gate is missing, and it is - **`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 **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 compile-and-compare job needs a second Go SDK). The cheaper honest option may
+25 -25
View File
@@ -20,12 +20,12 @@ by yield per token spent.
## What already exists and does not run ## What already exists and does not run
| Asset | State | Gap | | 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. | | 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. | | 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. | | 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. | | 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 Separately, three of the codebase's sharpest invariants are documented in
`CLAUDE.md` files as prose and asserted nowhere: `CLAUDE.md` files as prose and asserted nowhere:
@@ -44,7 +44,7 @@ Prose fails no build.
GitHub Actions.** GitHub Actions.**
Rationale: `go test -fuzz` writes each crashing input to 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 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 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 downloadable by anyone, and a red scheduled job is itself a public signal that
@@ -144,7 +144,7 @@ they are missing.
Roughly 200 confirmed real bugs have been fixed across the hunt and harvest Roughly 200 confirmed real bugs have been fixed across the hunt and harvest
runs. Each one currently bought exactly one fix. Encoding the recurring 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 **Sources to mine:** bughunt commit history on `fix/bughunt-*` and
`fix/bughunt-harvest-*` branches, `.superpowers/harvest-med-low-checklist.md`, `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 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: 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 | | `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-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 | | `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-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 | | `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 **Declined: `await`-then-stale-snapshot.** Not AST-expressible. Whether an
await needs a guard — and whether the guard present is sufficient and correctly 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 `CLAUDE.md` was factually wrong. It claimed `ws.on(...)` appears only in
`dispatcher.ts`; eight handlers across `main.ts`, `MainPage.ts` and `dispatcher.ts`; eight handlers across `main.ts`, `MainPage.ts` and
`ChannelController.ts` say otherwise. The true invariant — dispatcher is the `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. encodes, and the doc has been corrected to match.
**Still open:** the server-side `ws` seq/FIFO invariant, which needs a Go **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: changes, cheapest first:
1. **Persistent seen-ledger.** Key on `(file, symbol, class)` and persist 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 re-derives ground already covered — the most likely reason convergence never
arrives. arrives.
2. **Sibling-sweep lens.** For every confirmed bug, enumerate the other callers 2. **Sibling-sweep lens.** For every confirmed bug, enumerate the other callers
@@ -245,15 +245,15 @@ changes, cheapest first:
## Order and effort ## Order and effort
| Step | Effort | Runs in | | Step | Effort | Runs in |
| --- | --- | --- | | --------------------------------- | ---------------------- | ----------------------- |
| 1a `make fuzz` | 15 min to write | 10 min/sweep unattended | | 1a `make fuzz` | 15 min to write | 10 min/sweep unattended |
| 1b Stryker hotspots | 0 (already configured) | ~25 min for 3 files | | 1b Stryker hotspots | 0 (already configured) | ~25 min for 3 files |
| 1d gitignore check | 5 min | — | | 1d gitignore check | 5 min | — |
| 2 first four semgrep rules | ~1 afternoon | seconds | | 2 first four semgrep rules | ~1 afternoon | seconds |
| 4.1 + 4.2 ledger and sibling lens | ~2 hours | within existing hunt | | 4.1 + 4.2 ledger and sibling lens | ~2 hours | within existing hunt |
| 1c browser-mode vitest | 0 | minutes | | 1c browser-mode vitest | 0 | minutes |
| 3 model-based and chaos harnesses | ~1 day | minutes | | 3 model-based and chaos harnesses | ~1 day | minutes |
Tier 1a is first because 14 harnesses — the expensive part — are already Tier 1a is first because 14 harnesses — the expensive part — are already
written and produce nothing today. written and produce nothing today.
+23 -23
View File
@@ -33,20 +33,20 @@ finishing the features we have.
Features where one side is already built and the other was never finished. Features where one side is already built and the other was never finished.
| # | Item | What exists | What's missing | | # | 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 | | 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 | | 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`) | | 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 | | 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 | | 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 | | 6 | Archived channels | `channels.archived` stored, settable in admin panel | No read path filters on it — archived channels appear everywhere |
## Phase 2 — moderation depth ## 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)** — 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)** — 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)** — 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)** — 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. - **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)** — 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)**`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)**`@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. - **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 ## 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)** — 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)** — 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)** — 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)** — 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 ## 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`). - **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. 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`). - 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. 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. - **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). - 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, 099, 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)** — 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, 099, 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)** — 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)** — 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 ## 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). 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. - **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. `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). 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. **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. 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. by `listEmoji`/`uploadEmoji`/`deleteEmoji` against the real routes.
- `UserProfilePopup`'s `about` section (phase 6) — built, styled and tested - `UserProfilePopup`'s `about` section (phase 6) — built, styled and tested
while every call site passed a hardcoded `null`; `users.about` now feeds it. 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 - The DM sidebar's **Friends** nav item (phase 6) — _deleted rather than
implemented*. Its `onFriendsClick` was never passed by any call site and its implemented_. Its `onFriendsClick` was never passed by any call site and its
`friendsActive` was never set; giving it a destination would have meant a `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" 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 alongside blocks. The item, both dead props and its CSS are gone, and a test
+51 -51
View File
@@ -17,40 +17,40 @@ baseline is **truthful, reproducible, and sufficient to begin B1**.
## Question 1 — what is green, red, unavailable, and unverified ## Question 1 — what is green, red, unavailable, and unverified
| Metric | Baseline | Target | Actual | Evidence | | Metric | Baseline | Target | Actual | Evidence |
| --- | --- | --- | --- | --- | | ------------------------------------- | ---------------------------- | ------------------ | ---------------------------------------------- | ------------------------------------------------------------------- |
| Required checks green | refresh in B0 | 100% | **green** — 10 pinned checks pass | PR #1410 on `dev`; pinned set below | | 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 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 B1B10 | | Open P1 | 45 | 0 by B10 | **45**, none in B0 scope | [register](repo-health-issue-register-2026-08-23.md), phases B1B10 |
| Unresolved security findings | private count | 0 by B10 | **7**, all publicly owned, 0 unmapped | Question 4 below | | 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) | | 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 | | 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 | | 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 | | Static-analysis warnings | 471 Oxlint | 0 unapproved by B7 | **471**, unchanged | `C-02`, B7 |
| Server builds (4 tag variants) | — | pass | **pass** ×4 | measured | | Server builds (4 tag variants) | — | pass | **pass** ×4 | measured |
| `go vet` / `-race` / `-tags deadlock` | — | pass | **pass** | 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** | | `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 unit + integration | 2 failing | green | **5257 passed / 0 failed** | G-01, G-02 fixed |
| Client `tsc` / `lint` / `prettier` | — | pass | **pass** | measured | | Client `tsc` / `lint` / `prettier` | — | pass | **pass** | measured |
| Playwright | never terminated | green and exits | **293 passed, exit 0, 37s** | `C-06` fixed | | 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 | | 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 | | 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 | | 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 | | 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` | | Ledger path resolution | — | 0 dead | **0 dead paths / 348 records** | re-verified at `6a1561fa` |
| Desktop/browser/device matrix | incomplete | 100% by B10 | **incomplete** | B6B8 | | Desktop/browser/device matrix | incomplete | 100% by B10 | **incomplete** | B6B8 |
| 250/100/25 capacity profile | unproven | met by B6 | **unproven** | `S-14`, B6 | | 250/100/25 capacity profile | unproven | met by B6 | **unproven** | `S-14`, B6 |
| Upgrade/rollback/restore | unproven | green by B6 | **unproven** | B6 | | Upgrade/rollback/restore | unproven | green by B6 | **unproven** | B6 |
### Accepted with known gaps ### Accepted with known gaps
Three items are accepted as *stated limitations*, not as green: 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** 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. (`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 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 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. 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 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 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. obtainable; establishing the honest baseline and its exclusions is B7 work.
@@ -65,12 +65,12 @@ Nothing here is a B1 blocker.
Open ledger, re-verified at `6a1561fa`: Open ledger, re-verified at `6a1561fa`:
| Status | Count | | Status | Count |
| --- | --- | | --------- | ------- |
| fixed | 306 | | fixed | 306 |
| open | **38** | | open | **38** |
| declined | 3 | | declined | 3 |
| duplicate | 1 | | duplicate | 1 |
| **total** | **348** | | **total** | **348** |
Of the 38 open records: Of the 38 open records:
@@ -82,7 +82,7 @@ Of the 38 open records:
- 22 sit under `Client/tauri-client/`, 16 under `Server/`. - 22 sit under `Client/tauri-client/`, 16 under `Server/`.
- **None is assigned to B1.** Their register phases span B2B10 only. - **None is assigned to B1.** Their register phases span B2B10 only.
They are therefore accepted as *counted, non-stale, and assigned* rather than They are therefore accepted as _counted, non-stale, and assigned_ rather than
individually adjudicated. Deciding each is bughunt-fix work. The 22 under the 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 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. re-point their recorded paths, and the same dead-path check above is the proof.
@@ -100,13 +100,13 @@ B0's one outstanding step. Applied by
[`b0-dev-branch-protection.sh`](b0-dev-branch-protection.sh); verified against [`b0-dev-branch-protection.sh`](b0-dev-branch-protection.sh); verified against
the live API. the live API.
| Setting | Value | | Setting | Value |
| --- | --- | | ------------------------ | ------------------- |
| Pull request required | yes | | Pull request required | yes |
| Approvals required | 0 (solo maintainer) | | Approvals required | 0 (solo maintainer) |
| Applies to admins | yes | | Applies to admins | yes |
| Force pushes / deletions | disabled | | Force pushes / deletions | disabled |
| Required status checks | **10** | | Required status checks | **10** |
Pinned: Pinned:
@@ -120,12 +120,12 @@ Rust Unit Tests Analyze (actions)
Deliberately **not** pinned, with the observed reason: Deliberately **not** pinned, with the observed reason:
| Check | Why not | | Check | Why not |
| --- | --- | | --------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------ |
| `Server Docker Build (verify)` | Reports **skipping** on a dev PR (`if: ref_name=='main' \|\| base_ref=='main'`). | | `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. | | `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. | | `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. | | `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 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 read off a live dev-targeted PR with `gh pr checks`, not inferred from
@@ -145,12 +145,12 @@ The independent source review of `5cc08889` is reconciled. Its detailed reports
stay untracked and gitignored (`docs/security-findings/`, 0 tracked files); this stay untracked and gitignored (`docs/security-findings/`, 0 tracked files); this
section is deliberately content-free per [docs/security.md](../security.md). section is deliberately content-free per [docs/security.md](../security.md).
| | | | | |
| --- | --- | | ---------------------------------------- | ---------------------------------------------- |
| Private findings | **7** — 5 medium, 2 low. No high, no critical. | | Private findings | **7** — 5 medium, 2 low. No high, no critical. |
| Confirmed fixed at the reviewed revision | **0** | | Confirmed fixed at the reviewed revision | **0** |
| Mapped to an existing public row | **7 of 7** | | Mapped to an existing public row | **7 of 7** |
| Unmapped / untracked | **0** | | Unmapped / untracked | **0** |
Public owners, already opaque in the register: `SEC-01`, `SEC-02`, `SEC-03`, 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 `SEC-04`, plus `C-09`, `S-01`, and one `OC-*` ledger record. Every private
+1 -1
View File
@@ -76,7 +76,7 @@ decisions live in Rust:
- **Pin store:** the same per-host fingerprint store used by `ws_proxy.rs` - **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 (`certs.json` via `commands.rs`); one fingerprint per host covers all three
transports. 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 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 → implement the same first-trust flow as `ws_proxy.rs`: unknown host →
accept, store fingerprint, emit `cert-tofu` event (banner); known host + accept, store fingerprint, emit `cert-tofu` event (banner); known host +
+1 -1
View File
@@ -119,7 +119,7 @@ The highest-impact track. Ordered.
writable; ACME needs `AmbientCapabilities=CAP_NET_BIND_SERVICE`; writable; ACME needs `AmbientCapabilities=CAP_NET_BIND_SERVICE`;
`TimeoutStopSec=35` matches the 30s drain), a "Linux (systemd)" deployment `TimeoutStopSec=35` matches the 30s drain), a "Linux (systemd)" deployment
section, and a cron backup one-liner. Add a "Reverse Proxy Topology" section 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 is already proxied at `/livekit/*`; only WebRTC media (UDP range / TCP
fallback) must be directly reachable. fallback) must be directly reachable.
6. **Backup robustness.** Make the backup directory configurable (mirror the 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.** **2. A channel-level `deny` is genuinely not honoured — one layer down.**
`PermissionService.getOrPopulate` (`permission.go:145-149`) and `PermissionService.getOrPopulate` (`permission.go:145-149`) and
`ChannelService.ListVisibleChannels` (`channel.go:58-61`) substitute an *empty `ChannelService.ListVisibleChannels` (`channel.go:58-61`) substitute an _empty
override map* when `GetAllChannelPermissionsForRole` errors. Every `deny` bit for override map_ when `GetAllChannelPermissionsForRole` errors. Every `deny` bit for
that role evaporates, and `PermissionService` then **caches** the degraded that role evaporates, and `PermissionService` then **caches** the degraded
snapshot for `permCacheTTL` (30s), across `HasChannelPerm`'s ~25 callers: message snapshot for `permCacheTTL` (30s), across `HasChannelPerm`'s ~25 callers: message
reads, pins, attachment serving, WS. Meanwhile `permissions.Checker` reads, pins, attachment serving, WS. Meanwhile `permissions.Checker`
(`checker.go:60-63`), `MessageService.GetAccessibleChannelIDs` (`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. 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 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 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 `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 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` issue a query whose right-looking answer is an accident of `ErrNoRows`
handling (`db/channel_queries.go:140`), not a design. handling (`db/channel_queries.go:140`), not a design.
- **The auth-route DB sweep (item 12 / A-2026-07-06).** `AuthMiddleware` has 20 - **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 lint with a known ceiling, catching what the two new tests plus review already
catch. Revisit as a `golangci-lint` rule if it recurs. catch. Revisit as a `golangci-lint` rule if it recurs.
- **`ws.channelCanSend`** (`serve.go:583-590`) — the last hand-rolled copy. It - **`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 allocation on the ready hot path or a new value-taking predicate. Disclosed
deliberately rather than fixed; separate PR. deliberately rather than fixed; separate PR.
- No `(bool, error)` permission signatures (`ws/deps.go:86-90`'s - No `(bool, error)` permission signatures (`ws/deps.go:86-90`'s
+26 -10
View File
@@ -59,11 +59,12 @@ no coverage (repo rule: "Target 80%+ coverage; TDD is the expected workflow").
## Wave 1 — Availability & backend-breaking (HIGH) ## Wave 1 — Availability & backend-breaking (HIGH)
### W1-1. Plugin CPU budget must not permanently brick the module ### W1-1. Plugin CPU budget must not permanently brick the module
- **File:** `Server/plugin/sandbox_wazero.go` (~line 224, `invokeCommand`) - **File:** `Server/plugin/sandbox_wazero.go` (~line 224, `invokeCommand`)
- **Root cause:** the runtime is built `WithCloseOnContextDone(true)` - **Root cause:** the runtime is built `WithCloseOnContextDone(true)`
(line 72). The new per-call `context.WithTimeout` wraps `allocate`, (line 72). The new per-call `context.WithTimeout` wraps `allocate`,
`command_dispatch`, and `deallocate`, so an expired deadline *closes the `command_dispatch`, and `deallocate`, so an expired deadline _closes the
module*. `inst.module` is only cleared by `platformDeactivate`, so nothing module_. `inst.module` is only cleared by `platformDeactivate`, so nothing
re-instantiates it — one over-budget command bricks the plugin for all re-instantiates it — one over-budget command bricks the plugin for all
users until admin disable/enable or restart. The budget is wall-clock, 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. floored at 100 ms, so any host HTTP call (`httpTimeout` = 10 s) trips it.
@@ -75,11 +76,12 @@ no coverage (repo rule: "Target 80%+ coverage; TDD is the expected workflow").
should pause during host calls). Reconsider the floor so legitimate work should pause during host calls). Reconsider the floor so legitimate work
isn't killed. isn't killed.
- **Verify:** new test (build tag `wazero`) that (a) a command exceeding the - **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 plugin still succeeds; (b) a command performing a host HTTP call within
`httpTimeout` is not killed by the CPU budget. `httpTimeout` is not killed by the CPU budget.
### W1-2. E2EE key rotation drops peers in 7+ participant calls ### W1-2. E2EE key rotation drops peers in 7+ participant calls
- **Files:** `Server/ws/voice_e2ee.go` (~line 151); - **Files:** `Server/ws/voice_e2ee.go` (~line 151);
`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 - **Root cause:** the `voice_e2ee_offer` limit is 5/sec, but the key holder
@@ -90,7 +92,7 @@ no coverage (repo rule: "Target 80%+ coverage; TDD is the expected workflow").
rotation. rotation.
- **Fix (choose one, prefer server-side):** - **Fix (choose one, prefer server-side):**
- Server: exempt the fan-out relay from the tight per-message cap — rate - 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. per-peer offer; or scale the limit to channel size.
- Client: add bounded pacing + retry/backoff on `RATE_LIMITED` so all peers - Client: add bounded pacing + retry/backoff on `RATE_LIMITED` so all peers
eventually receive the offer. 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. receives the rotated key after a join/leave and after a periodic rotation.
### W1-3. Attachment-ownership check breaks Postgres and isn't atomic ### W1-3. Attachment-ownership check breaks Postgres and isn't atomic
- **Files:** `Server/service/message.go` (~line 183); - **Files:** `Server/service/message.go` (~line 183);
`Server/db/queries/*attachment*.sql` + regenerate `dbgen`/`pgdbgen`; `Server/db/queries/*attachment*.sql` + regenerate `dbgen`/`pgdbgen`;
`Server/store/postgres.go`, `Server/store/sqlite.go` `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 `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 check atomic, one query, and backend-agnostic, and removes the need for
the `MemStore.GetAttachmentByID` `(nil,nil)` contortion. 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; implements the link semantics) covering: own unlinked attachment links;
another user's attachment is refused; nonexistent id is skipped; already another user's attachment is refused; nonexistent id is skipped; already
linked id is refused; `RowsAffected` mismatch → no message persisted. linked id is refused; `RowsAffected` mismatch → no message persisted.
### W1-4. Ban authorization guards dead code ### W1-4. Ban authorization guards dead code
- **Files:** `Server/admin/handlers_users.go` (`handlePatchUser`, ~line 112); - **Files:** `Server/admin/handlers_users.go` (`handlePatchUser`, ~line 112);
`Server/service/moderation.go` `Server/service/moderation.go`
- **Root cause:** `requireBanAuthority` (BAN_MEMBERS + role hierarchy) is - **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 `ModerationService.BanUser`/`UnbanUser` (so the new authorization actually
runs), or lift `requireBanAuthority` into the handler. Keep the runs), or lift `requireBanAuthority` into the handler. Keep the
admin-IP/admin-auth perimeter; add the permission + hierarchy check on top. 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. BAN_MEMBERS can't enumerate user ids via NotFound-vs-Forbidden.
- **Verify:** handler test — actor without BAN_MEMBERS is refused; actor of - **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 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) ## Wave 2 — Behavioral regressions (MED-HIGH → MED)
### W2-1. Client-update rate limiter shares the auth bucket ### W2-1. Client-update rate limiter shares the auth bucket
- **File:** `Server/api/router.go` (~line 257) - **File:** `Server/api/router.go` (~line 257)
- **Root cause:** it uses the empty-prefix `RateLimitMiddleware` on the shared - **Root cause:** it uses the empty-prefix `RateLimitMiddleware` on the shared
`limiter`, colliding per-IP with `verifyTOTP`, the sensitive endpoints, and `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. subsequent `verify-totp`/password request from the same IP.
### W2-2. ChangePassword reports failure after the password is committed ### W2-2. ChangePassword reports failure after the password is committed
- **Files:** `Server/service/user.go` (~line 60); caller - **Files:** `Server/service/user.go` (~line 60); caller
`Server/api/profile_handler.go` (~line 231) `Server/api/profile_handler.go` (~line 231)
- **Root cause:** `UpdateUserPassword` commits first; if `DeleteOtherSessions` - **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. password is unchanged.
### W2-3. Plugin activation via RegisterCommand breaks in-place upgrades ### W2-3. Plugin activation via RegisterCommand breaks in-place upgrades
- **Files:** `Server/plugin/sandbox_wazero.go` (~line 140); - **Files:** `Server/plugin/sandbox_wazero.go` (~line 140);
`Server/plugin/host_commands.go` (~line 36); `Server/plugin/host_commands.go` (~line 36);
`Server/plugin/registry.go` (`installFromDisk`, `InstallFromZip`) `Server/plugin/registry.go` (`installFromDisk`, `InstallFromZip`)
- **Root cause:** `RegisterCommand` refuses when `existing != inst` by - **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 fresh `*Instance` without clearing the old command bindings. Re-installing an
enabled plugin leaves stale bindings that block re-registration; dispatch enabled plugin leaves stale bindings that block re-registration; dispatch
keeps routing to the orphaned old module until restart. The old 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 - **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 pointer — allow the same plugin to re-bind its own command — and/or clear a
plugin's stale command bindings during reinstall/deactivation before 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). plugin still can't claim an owned command).
- **Verify:** test that upgrading an enabled plugin in place rebinds its - **Verify:** test that upgrading an enabled plugin in place rebinds its
commands and dispatch routes to the new module; a different plugin claiming commands and dispatch routes to the new module; a different plugin claiming
an owned command is still refused. an owned command is still refused.
### W2-4. Attachment check rejects legit retries and legacy uploads ### W2-4. Attachment check rejects legit retries and legacy uploads
- **File:** `Server/service/message.go` (~line 191) - **File:** `Server/service/message.go` (~line 191)
- **Root cause:** `att.MessageID != nil → ErrForbidden` means a client retry of - **Root cause:** `att.MessageID != nil → ErrForbidden` means a client retry of
a send whose first attempt already linked the attachment can never succeed; 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). - **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 ### W2-5. XFF right-to-left walk collapses/spoofs on broad trusted CIDRs
- **File:** `Server/api/middleware.go` (~line 227, `clientIPWithProxies`) - **File:** `Server/api/middleware.go` (~line 227, `clientIPWithProxies`)
- **Root cause:** the walk skips every entry inside `trustedCIDRs`. With a - **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 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 (one user's failed logins lock out everyone), or letting a client at a
trusted IP forge the key. trusted IP forge the key.
- **Fix:** when the walk exhausts without a non-trusted candidate, return the - **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 `RemoteAddr`, so distinct clients keep distinct keys. Document that
`trusted_proxies` should list only proxy hops, and validate config on `trusted_proxies` should list only proxy hops, and validate config on
startup. Pre-parse `trustedCIDRs` into `[]*net.IPNet` once (see W3-3). 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. leftmost entry from an untrusted RemoteAddr is ignored.
### W2-6. SSRF-hardened dialer loses multi-address fallback ### W2-6. SSRF-hardened dialer loses multi-address fallback
- **File:** `Server/plugin/host_http.go` (~line 115) - **File:** `Server/plugin/host_http.go` (~line 115)
- **Root cause:** after validating every resolved IP, it dials only `ips[0]`, - **Root cause:** after validating every resolved IP, it dials only `ips[0]`,
dropping Happy-Eyeballs/next-record fallback. An allowlisted dual-stack or 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. is still refused.
### W2-7. Plugin-broadcast gate omits the block check ### W2-7. Plugin-broadcast gate omits the block check
- **Files:** `Server/ws/handlers_command.go` (~line 107); - **Files:** `Server/ws/handlers_command.go` (~line 107);
`Server/permissions/checker.go` (~line 79) `Server/permissions/checker.go` (~line 79)
- **Root cause:** `requireChannelBroadcastAccess` routes through - **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) ## Wave 3 — Cleanup, efficiency, hardening depth (LOW-MED)
### W3-1. Updater text-asset cache: add coalescing + negative caching ### W3-1. Updater text-asset cache: add coalescing + negative caching
- **File:** `Server/updater/updater.go` (~line 729, `FetchTextAssetCached`) - **File:** `Server/updater/updater.go` (~line 729, `FetchTextAssetCached`)
- **Fix:** guard refresh with `golang.org/x/sync/singleflight` so a TTL-expiry - **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 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. - **Verify:** concurrent cold-cache test issues exactly one upstream fetch.
### W3-2. De-duplicate the update binary hashing ### W3-2. De-duplicate the update binary hashing
- **File:** `Server/admin/update_handlers.go` (~line 166, `fileSHA256`) - **File:** `Server/admin/update_handlers.go` (~line 166, `fileSHA256`)
- **Fix:** `fileSHA256` duplicates `updater.VerifyChecksum`'s hashing body and - **Fix:** `fileSHA256` duplicates `updater.VerifyChecksum`'s hashing body and
re-reads the just-verified binary. Export one hashing helper from the updater 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. and reuse it for the TOCTOU snapshot.
### W3-3. Update TOCTOU guard depth + XFF CIDR pre-parsing ### W3-3. Update TOCTOU guard depth + XFF CIDR pre-parsing
- **Files:** `Server/admin/update_handlers.go` (~line 117); - **Files:** `Server/admin/update_handlers.go` (~line 117);
`Server/api/middleware.go` (`isTrustedProxy`) `Server/api/middleware.go` (`isTrustedProxy`)
- **Fix:** the re-verify narrows but does not close the swap window (verify by - **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. `[]*net.IPNet` once at middleware construction.
### W3-4. Cache-Control header contradiction ### W3-4. Cache-Control header contradiction
- **File:** `Server/api/upload_handler.go` (~line 309) + test at - **File:** `Server/api/upload_handler.go` (~line 309) + test at
`upload_handler_test.go` (~line 733) `upload_handler_test.go` (~line 733)
- **Fix:** `private, max-age=31536000, no-cache` is self-contradictory — - **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. Use `private, no-cache` and update the test assertion.
### W3-5. Restore test coverage lost to the MemStore change ### W3-5. Restore test coverage lost to the MemStore change
- **File:** `Server/store/memstore.go` (~line 693) - **File:** `Server/store/memstore.go` (~line 693)
- **Fix:** subsumed by W1-3 (atomic link removes the need for the `(nil,nil)` - **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 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 ## Cross-cutting requirements
- **Tests:** every fix ships with tests (repo rule: 80%+ coverage, TDD). Add - **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`, `requireBanAuthority`, `FetchTextAssetCached`, `requireChannelBroadcastAccess`,
fail-closed `DecryptTOTPSecret`. fail-closed `DecryptTOTPSecret`.
- **Build-tag matrix:** W1-1/W2-3 touch `//go:build wazero` code — verify the - **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 > 2026-08-05 (DC-08): the lookup is now tri-state
> (pinned/unpinned/**unavailable**, `identity.ts` `getIdentityPin`) and > (pinned/unpinned/**unavailable**, `identity.ts` `getIdentityPin`) and
> `verifyPeerAnnounce` fails closed on "unavailable"; follow-up 4 is accepted > `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 > directory `CLAUDE-SECURITY-20260722-184557/` referenced below is not part of
> this repository. > this repository.
@@ -21,16 +21,16 @@ This is a continuation/handoff doc: what is done, what remains, and how to resum
## Status at a glance ## Status at a glance
| # | Sev | Finding | Status | | # | Sev | Finding | Status |
|---|-----|---------|--------| | --- | --- | ---------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------- |
| F1 | MED | Login lockout keyed on un-canonicalized username (vs `COLLATE NOCASE`) | ✅ done, committed `7145f76` | | 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` | | 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) | | 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` | | 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` | | F5 | LOW | Voice perms use stale connect-time role snapshot | ✅ done, committed `260d038` |
| F6 | LOW | Lost cache invalidation in `PermissionService.getOrPopulate` | ✅ done, committed `e6a0d87` | | F6 | LOW | Lost cache invalidation in `PermissionService.getOrPopulate` | ✅ done, committed `e6a0d87` |
| F7 | LOW | ReDoS regex on link-preview HTML | ✅ done, committed `6952202` | | 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) | | F8 | LOW | WS TOFU verifier accepts any cert on first use | ✅ done, committed `f22985a` (with F4) |
## Resume checklist (do these first) ## Resume checklist (do these first)
@@ -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. verification and the room key is never wrapped for the attacker.
**Follow-up (not MITM holes — deferred, none block the crypto):** **Follow-up (not MITM holes — deferred, none block the crypto):**
1. **Surface the safety number in the voice panel.** `safetyNumber`/`peerVerifications` are 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 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` 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 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). in-app recovery (mirror `main.ts`'s `createCertMismatchModal onAccept` flow).
3. `getIdentityPin` **fail-opens** on a transient local keyring/store read error (one announce 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. exist.
4. Fast-join timing: a peer joining voice before peers process its `user_update` is seen as 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) ## 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. optional safety-number makes even that detectable.
### What gets signed ### What gets signed
WebCrypto **ECDSA P-256** (same curve family as the existing ECDH; works in all 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). three webviews — Ed25519 is unreliable on WKWebView/WebKitGTK; zero new deps).
When announcing its ephemeral key `E_pub`, the client signs 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. to a different user. Receivers verify against the peer's **pinned** identity key.
### Verify + TOFU-pin (receive path) ### Verify + TOFU-pin (receive path)
In `handleE2EEAnnounce` (`livekitSession.ts` ~1195, before the `_peerPublicKeys` 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: 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 1. Resolve the peer's identity key — first sight → take it from the member
payload and **pin** it (`identity_pins.json`, key `{host}:{userId}`); payload and **pin** it (`identity_pins.json`, key `{host}:{userId}`);
subsequent → use the pin; delivered key differs → emit `identity-tofu`, block/ 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. reject (MITM), do not store/wrap.
### Infrastructure (mirror existing patterns) ### Infrastructure (mirror existing patterns)
- **Identity private key → OS keyring:** `save/load/delete_identity_key` Tauri - **Identity private key → OS keyring:** `save/load/delete_identity_key` Tauri
commands mirroring `src-tauri/src/credentials.rs` `save_credential`, account commands mirroring `src-tauri/src/credentials.rs` `save_credential`, account
`identity:{host}`; TS wrapper copies `src/lib/credentials.ts`. Never localStorage. `identity:{host}`; TS wrapper copies `src/lib/credentials.ts`. Never localStorage.
- **Peer pins → new `identity_pins.json`** `tauri-plugin-store` file + - **Peer pins → new `identity_pins.json`** `tauri-plugin-store` file +
`store/get_identity_pin` commands, near-verbatim copy of the `certs.json` `store/get_identity_pin` commands, near-verbatim copy of the `certs.json`
cert-pin commands in `src-tauri/src/commands.rs`. 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). surface a per-peer/combined safety number in the voice panel (optional OOB verify).
### Server (db-change + protocol-change workflows) ### Server (db-change + protocol-change workflows)
- Migration `Server/migrations/017_user_identity_key.sql`: - Migration `Server/migrations/017_user_identity_key.sql`:
`ALTER TABLE users ADD COLUMN identity_public_key TEXT;` (mirrors `totp_secret`). `ALTER TABLE users ADD COLUMN identity_public_key TEXT;` (mirrors `totp_secret`).
Add `UpdateUserIdentityKey` query; include the column in the user + `ListMembers` 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. replay-to-late-joiners path (`voice_join.go:217-218`) doesn't drop it.
### Client session (`livekitSession.ts`) ### Client session (`livekitSession.ts`)
Sign the ephemeral announce at all three sites (~916, ~467, ~891); verify+pin on 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 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. round-trip doesn't stack on the existing 10s non-holder stall.
### Compatibility posture (transition) ### Compatibility posture (transition)
Peer has published an identity key but the announce signature is missing/invalid 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) → **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 accept but mark **unverified** in the UI, pin-pending. Avoids a hard cutover for
alpha while closing the hole for upgraded clients. alpha while closing the hole for upgraded clients.
### Suggested PR split ### Suggested PR split
- **PR-a (server):** identity-key column + publish/fetch + `voice_e2ee_announce` - **PR-a (server):** identity-key column + publish/fetch + `voice_e2ee_announce`
signature field + `SetE2EEPubKey` carries the signature. signature field + `SetE2EEPubKey` carries the signature.
- **PR-b (client):** keygen + keyring commands, sign/verify, TOFU pin store, - **PR-b (client):** keygen + keyring commands, sign/verify, TOFU pin store,
safety-number UI, receive-path verification. safety-number UI, receive-path verification.
### Verification (planned) ### Verification (planned)
- vitest for `signEphemeralKey`/`verifyEphemeralKeySignature` and the TOFU pin - vitest for `signEphemeralKey`/`verifyEphemeralKeySignature` and the TOFU pin
(first-sight pins, changed key flags, invalid signature rejects); a "server (first-sight pins, changed key flags, invalid signature rejects); a "server
substitutes a peer's ephemeral key → verify fails" test; keyring round-trip 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`. `-race`/`-tags deadlock`; client `npm test` + typecheck/lint/format; `ci-check`.
## Notes carried from the build ## Notes carried from the build
- F4/F8 approach was simplified vs the original design: instead of a new - 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** `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 — the proxy captures the fingerprint, rejects (ws `Err` / http `502`), and emits
+33 -30
View File
@@ -10,9 +10,9 @@
> `Server/db/` now); `src/state/` does not exist in the client (state modules > `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 > live in `src/stores/`). One slice of this plan did land separately: the
> manifest `commands` name-only ACL (see the inline note in §"Manifest"). > manifest `commands` name-only ACL (see the inline note in §"Manifest").
**Owner:** TBD > **Owner:** TBD
**Tracks:** deferred feature backlog (post-beta; see CHANGELOG "Deferred work") > **Tracks:** deferred feature backlog (post-beta; see CHANGELOG "Deferred work")
**Estimated effort:** 12 weeks of focused work > **Estimated effort:** 12 weeks of focused work
## Why ## Why
@@ -119,7 +119,7 @@ truth for the per-command schema; the runtime never trusts what the plugin
says at dispatch time. Example: says at dispatch time. Example:
> **Partially landed 2026-07-20** (audit-2026-04-07 CRITICAL #3): the > **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 > `"commands": [{"name": "kick"}]` and `Registry.RegisterCommand` refuses any
> command the manifest did not declare, so `list_commands` can no longer bind > command the manifest did not declare, so `list_commands` can no longer bind
> names behind the admin's back. `description` / `options` / > names behind the admin's back. `description` / `options` /
@@ -197,25 +197,25 @@ namespace collisions are confusing for users.
### Code surface ### Code surface
| File | Change | | File | Change |
|---|---| | ------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `Server/ws/message_types.go` | Add `MsgTypeCommandInvoke`, `MsgTypeCommandAutocomplete`, `MsgTypeCommandReply`, `MsgTypeCommandAutocompleteResult`. | | `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/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_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/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/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/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/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/store/sqlite_plugin_commands.go` | **New file.** CRUD over the `plugin_commands` table. |
| `Server/migrations/016_plugin_commands.sql` | New migration. | | `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/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 `/`. | | `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. | | `docs/protocol.md` | Document the four new wire messages. |
### Permission model ### Permission model
`default_member_permissions` is enforced **server-side** in `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 `PermissionService.HasChannelPerm` for each declared permission. Plugins
do not get to decide who can use their commands; the manifest declares, do not get to decide who can use their commands; the manifest declares,
the host enforces. 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 Two slash commands ship in-tree (no plugin required), to validate the
dispatcher and to give bare-metal deployments something useful: 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. | | `/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 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 the same handler shape so a plugin author can read the source as the
@@ -254,18 +254,19 @@ canonical example.
## Failure modes & UX ## Failure modes & UX
| Failure | Server response | Client UX | | Failure | Server response | Client UX |
|---|---|---| | ---------------------------------------- | ------------------------------------------------------------------------------------- | ------------------------------------------------ |
| No such command | `command_reply` ephemeral: `Unknown command: /foo` | Red banner under composer. | | 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 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 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. | | 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". | | 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. | | Argument validation fails | `command_invoke` returns `ErrCodeBadPayload` with the field name | Composer highlights the bad option. |
## Testing strategy ## Testing strategy
Unit: Unit:
- `manifest_test.go` — extend with command validation (name regex, option - `manifest_test.go` — extend with command validation (name regex, option
type enum, max 25 options, max 100 char description). type enum, max 25 options, max 100 char description).
- `host_commands_test.go``DispatchCommand` with a stub `Instance`, - `host_commands_test.go``DispatchCommand` with a stub `Instance`,
@@ -274,11 +275,13 @@ Unit:
V2 test pattern (`stubMessageSvc`, `stubPermSvc`). V2 test pattern (`stubMessageSvc`, `stubPermSvc`).
Integration: Integration:
- Add a new in-tree test plugin under `Server/plugin/examples/echo` (no - Add a new in-tree test plugin under `Server/plugin/examples/echo` (no
wasm needed — installable via the default build) that registers `/echo` wasm needed — installable via the default build) that registers `/echo`
and is loaded inside `ws_integration_test.go`. and is loaded inside `ws_integration_test.go`.
Contract: Contract:
- `docs/protocol.md` round trip — JSON examples kept in sync with the - `docs/protocol.md` round trip — JSON examples kept in sync with the
parser via golden tests. parser via golden tests.
+4
View File
@@ -33,6 +33,7 @@ Two mechanical frictions drive the per-domain effort:
## Status ## Status
### Phase 1 + 2 — done (2026-07-19) ### Phase 1 + 2 — done (2026-07-19)
sqlc is now **load-bearing in production** (previously dead code). **97 `db.DB` 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 methods delegate** to `dbgen` across every domain; 43 raw `d.sqlDB` calls
remain (the `db.go` passthrough helpers, `migrate.go`, and the intentionally 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). pins/read-state).
### Deliberately kept raw (no clean sqlc mapping) ### Deliberately kept raw (no clean sqlc mapping)
- **Variable-length `IN(...)`** (sqlc can't express): `GetAttachmentsByMessageIDs`, - **Variable-length `IN(...)`** (sqlc can't express): `GetAttachmentsByMessageIDs`,
`LinkAttachmentsToMessage`, `GetChannelTypes`. `LinkAttachmentsToMessage`, `GetChannelTypes`.
- **FTS / dynamic WHERE / cursor pagination**: `GetMessages`, `SearchMessages`, - **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. and owns the SQL for the overwhelming majority of the data layer.
### Out of scope for D2 ### Out of scope for D2
- `store/` event + plugin SQL (`store/sqlite_events.go`, plugin store) — these - `store/` event + plugin SQL (`store/sqlite_events.go`, plugin store) — these
live in the store layer being **removed in D3**; converting them is throwaway. 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. D3 moves the surviving `db` methods (sqlc-backed) to direct service use.
## Verification (per phase) ## Verification (per phase)
`go build ./...`; `go test -race ./db/ ./service/ ./auth/ ./api/ ./ws/`; `go build ./...`; `go test -race ./db/ ./service/ ./auth/ ./api/ ./ws/`;
`make sqlc-verify` (generated output committed & in sync). `make sqlc-verify` (generated output committed & in sync).
+7 -7
View File
@@ -30,7 +30,7 @@ the code, change what this PR can achieve.
(`:366`) and `fetch_read_body` (`:418`) take a `ResourceId` for an (`: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 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 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 only — it never merges into the plugin's global scope. `allow-fetch-send` and
`allow-fetch-read-body` each declare exactly one command `allow-fetch-read-body` each declare exactly one command
(`permissions/autogenerated/commands/fetch_send.toml`, `fetch_read_body.toml`). (`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 ## What each consumer actually needs
| Consumer | Reachable hosts | Enumerable? | | 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/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/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/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/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/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 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 `https://api.klipy.com` with the **webview's** `fetch`, not the plugin (so it is
+1 -1
View File
@@ -98,7 +98,7 @@ V1-shadowing guard inside `RegisterV2`. `registerVoiceHandlersV1` /
identically. identically.
- Not decomposing the Hub or reworking replay/seq (backlog 12). - Not decomposing the Hub or reworking replay/seq (backlog 12).
- `handleVoiceLeave` remains a hub-internal routine for the disconnect/switch - `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) ## As implemented (2026-07-20)
+8 -8
View File
@@ -10,17 +10,17 @@ If you want a simpler remote-access path, use [Tailscale](tailscale.md) and skip
### Always required ### Always required
| Port | Protocol | Purpose | | Port | Protocol | Purpose |
| ---- | -------- | ------- | | ------ | -------- | ------------------------- |
| `8443` | TCP | OwnCord HTTPS + WebSocket | | `8443` | TCP | OwnCord HTTPS + WebSocket |
### Required only for voice/video ### Required only for voice/video
| Port | Protocol | Purpose | | Port | Protocol | Purpose |
| ---- | -------- | ------- | | ------------- | -------- | -------------------- |
| `7880` | TCP | LiveKit signaling | | `7880` | TCP | LiveKit signaling |
| `7881` | TCP | LiveKit TCP fallback | | `7881` | TCP | LiveKit TCP fallback |
| `50000-60000` | UDP | LiveKit media | | `50000-60000` | UDP | LiveKit media |
## Router Steps ## Router Steps
+5 -1
View File
@@ -29,7 +29,11 @@
"ts": "VOICE_TOKEN_REFRESH", "ts": "VOICE_TOKEN_REFRESH",
"go_trailing_comment": "//nolint:gosec // G101: false positive — message type constant, not a credential" "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_announce",
"go": "MsgTypeVoiceE2EEAnnounce",
"ts": "VOICE_E2EE_ANNOUNCE"
},
{ "wire": "voice_e2ee_offer", "go": "MsgTypeVoiceE2EEOffer", "ts": "VOICE_E2EE_OFFER" }, { "wire": "voice_e2ee_offer", "go": "MsgTypeVoiceE2EEOffer", "ts": "VOICE_E2EE_OFFER" },
{ "wire": "call_ring", "go": "MsgTypeCallRing", "ts": "CALL_RING" }, { "wire": "call_ring", "go": "MsgTypeCallRing", "ts": "CALL_RING" },
{ "wire": "call_decline", "go": "MsgTypeCallDecline", "ts": "CALL_DECLINE" }, { "wire": "call_decline", "go": "MsgTypeCallDecline", "ts": "CALL_DECLINE" },
+184 -168
View File
@@ -3,6 +3,7 @@
All client-server real-time communication happens over a single WebSocket connection. Messages are JSON with a `type` and `payload`. All client-server real-time communication happens over a single WebSocket connection. Messages are JSON with a `type` and `payload`.
**Related docs:** **Related docs:**
- [api.md](api.md) -- REST endpoints (message history, file uploads, etc.) - [api.md](api.md) -- REST endpoints (message history, file uploads, etc.)
- [schema.md](schema.md) -- Database tables and permission bitfields - [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 ### Transport Limits
| Limit | Value | | Limit | Value |
|-------|-------| | ---------------------- | ------------ |
| Max read size | 1 MB | | Max read size | 1 MB |
| Max message content | 4000 runes | | Max message content | 4000 runes |
| Write timeout | 10 seconds | | Write timeout | 10 seconds |
| Auth deadline | 10 seconds | | Auth deadline | 10 seconds |
| Send buffer per client | 256 messages | | Send buffer per client | 256 messages |
--- ---
@@ -65,17 +66,17 @@ Every WebSocket message is a JSON object with these fields:
{ {
"type": "message_type", "type": "message_type",
"id": "unique-request-id", "id": "unique-request-id",
"payload": { }, "payload": {},
"seq": 42 "seq": 42
} }
``` ```
| Field | Type | Required | Description | | Field | Type | Required | Description |
|-------|------|----------|-------------| | --------- | ------ | ----------------------- | ---------------------------------------------------------------------------------------------- |
| `type` | string | Yes | Determines how `payload` is interpreted | | `type` | string | Yes | Determines how `payload` is interpreted |
| `id` | string | Client messages only | Client-generated UUID for request/response correlation | | `id` | string | Client messages only | Client-generated UUID for request/response correlation |
| `payload` | object | Yes | Contents vary by `type`. Must be present (can be `{}`). | | `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. | | `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 ### Which Messages Get seq
| Category | Has seq? | Examples | | Category | Has seq? | Examples |
|----------|----------|---------| | ------------------ | -------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Channel broadcasts | Yes | `chat_message`, `chat_edited`, `chat_deleted`, `chat_bulk_deleted`, `reaction_update` | | 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` | | 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) | | 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 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` | | DM lifecycle | No | `dm_channel_open`, `dm_channel_close` |
| Call signalling | No | `call_incoming`, `call_declined` | | Call signalling | No | `call_incoming`, `call_declined` |
| Direct responses | No | `auth_ok`, `auth_error`, `chat_send_ok`, `error`, `voice_config`, `voice_token`, `pong` | | 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 split, and only one half is sequenced.** Connect and disconnect
presence is a normal sequenced global broadcast, so it replays on a warm resume. 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 | | Field | Type | Required | Description |
|-------|------|----------|-------------| | ------------------- | ------ | -------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `token` | string | Yes | Session token obtained from `POST /api/v1/auth/login` | | `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. | | `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` | 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 `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, 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): 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` | | Tier | Condition | Server Behavior | `replay_source` |
|------|-----------|-----------------|-----------------| | ---- | -------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------ | --------------- |
| — | `last_seq == 0` | Full flow: `auth_ok` + `ready` + `member_join` + `presence` | `none` | | — | `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` | | 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` | | 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` | | 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 A visibility watermark forces the tier-3 full re-sync whenever channel
visibility changed while the client was disconnected, so permission changes visibility changed while the client was disconnected, so permission changes
@@ -346,12 +347,12 @@ to reconstruct them:
} }
``` ```
| Field | Type | Required | Constraints | | Field | Type | Required | Constraints |
|-------|------|----------|-------------| | ------------- | -------------- | -------- | ---------------------------------------------------------------------------- |
| `channel_id` | number | Yes | Positive integer | | `channel_id` | number | Yes | Positive integer |
| `content` | string | Yes* | Max 4000 runes. HTML-sanitized. *Can be empty if `attachments` is non-empty. | | `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 | | `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. | | `attachments` | string[] | No | Upload IDs from `POST /api/v1/uploads`. Requires `ATTACH_FILES` permission. |
### chat_send_ok (Server -> Client) ### chat_send_ok (Server -> Client)
@@ -396,11 +397,11 @@ Direct response to sender (no seq):
} }
``` ```
| Field | Type | Description | | Field | Type | Description |
|-------|------|-------------| | ------------------- | -------- | ------------------------------------------------------------------------------------------------------------------- |
| `mentions` | number[] | User IDs the server resolved from `@username` tokens. Always present; empty when nothing resolved. | | `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_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_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 Mentions are resolved server-side at send time against existing usernames
(case-insensitive, whole-word, capped at 20 per message). An `@word` that (case-insensitive, whole-word, capped at 20 per message). An `@word` that
@@ -648,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 message and resets its `mention_count` to 0 — exactly what `channel_focus` does
to unread state — **without** changing which channel the connection is focused 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 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 connection's focused channel, which would misroute unread bookkeeping for the
channel actually on screen. channel actually on screen.
@@ -806,8 +807,22 @@ dropped intermediate event can never leave a deleted role on screen.
"type": "roles_update", "type": "roles_update",
"payload": { "payload": {
"roles": [ "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
}
] ]
} }
} }
@@ -898,6 +913,7 @@ Voice uses LiveKit as the SFU. WebSocket messages handle signaling (join/leave/s
``` ```
On success, server sends (in order): On success, server sends (in order):
1. `voice_token` -- LiveKit JWT + URL 1. `voice_token` -- LiveKit JWT + URL
2. `voice_state` broadcast -- joiner's state to all clients 2. `voice_state` broadcast -- joiner's state to all clients
3. Existing `voice_state` messages -- one per existing participant (to joiner only) 3. Existing `voice_state` messages -- one per existing participant (to joiner only)
@@ -942,11 +958,11 @@ restricted by the user's permissions.
Quality presets: Quality presets:
| Preset | Bitrate | | Preset | Bitrate |
|--------|---------| | -------- | ----------- |
| `low` | 32,000 bps | | `low` | 32,000 bps |
| `medium` | 64,000 bps | | `medium` | 64,000 bps |
| `high` | 128,000 bps | | `high` | 128,000 bps |
### voice_leave (Client -> Server) ### voice_leave (Client -> Server)
@@ -1417,26 +1433,26 @@ and the ringer's own 30s window already covers it.
### Error Codes ### Error Codes
| Code | Description | | Code | Description |
|------|-------------| | ----------------- | ------------------------------------------------------------------------------------------------------------------------- |
| `BAD_REQUEST` | Invalid payload format or field values | | `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) | | `BAD_PAYLOAD` | Structurally valid message with a field that fails validation (E2EE announce/offer key material, signatures, targets) |
| `INTERNAL` | Server-side error | | `INTERNAL` | Server-side error |
| `NOT_FOUND` | Channel or message not found | | `NOT_FOUND` | Channel or message not found |
| `FORBIDDEN` | Missing required permission | | `FORBIDDEN` | Missing required permission |
| `NOT_KEY_HOLDER` | `voice_e2ee_offer` sent by a participant who is not the channel's key holder | | `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) | | `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 | | `ALREADY_JOINED` | Already in this voice channel |
| `CHANNEL_FULL` | Voice channel at capacity | | `CHANNEL_FULL` | Voice channel at capacity |
| `VOICE_ERROR` | Voice-specific error | | `VOICE_ERROR` | Voice-specific error |
| `VIDEO_LIMIT` | Maximum video streams reached | | `VIDEO_LIMIT` | Maximum video streams reached |
| `BANNED` | User is banned | | `BANNED` | User is banned |
| `INVALID_JSON` | Message is not valid JSON | | `INVALID_JSON` | Message is not valid JSON |
| `UNKNOWN_TYPE` | Unrecognized message type | | `UNKNOWN_TYPE` | Unrecognized message type |
| `SLOW_MODE` | Channel has slow mode enabled | | `SLOW_MODE` | Channel has slow mode enabled |
| `CONFLICT` | Duplicate reaction or constraint violation | | `CONFLICT` | Duplicate reaction or constraint violation |
| `SERVER_MUTED` | Self-unmute refused: a moderator imposed the mute | | `SERVER_MUTED` | Self-unmute refused: a moderator imposed the mute |
| `SERVER_DEAFENED` | Self-undeafen refused: a moderator imposed the deafen | | `SERVER_DEAFENED` | Self-undeafen refused: a moderator imposed the deafen |
After 10 consecutive invalid JSON messages, the connection is forcibly closed. After 10 consecutive invalid JSON messages, the connection is forcibly closed.
@@ -1446,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. All rate limits are enforced server-side using a token bucket rate limiter.
| Action | Limit | Window | Error Response | | Action | Limit | Window | Error Response |
|--------|-------|--------|----------------| | ---------------------------------------- | ----- | ------------------------------------------ | -------------------- |
| Chat send | 10 | 1 second | `RATE_LIMITED` error | | Chat send | 10 | 1 second | `RATE_LIMITED` error |
| Chat edit | 10 | 1 second | `RATE_LIMITED` error | | Chat edit | 10 | 1 second | `RATE_LIMITED` error |
| Chat delete | 10 | 1 second | `RATE_LIMITED` error | | Chat delete | 10 | 1 second | `RATE_LIMITED` error |
| Typing | 1 | 3 seconds | Silently dropped | | Typing | 1 | 3 seconds | Silently dropped |
| Presence | 1 | 10 seconds | `RATE_LIMITED` error | | Presence | 1 | 10 seconds | `RATE_LIMITED` error |
| Reactions | 5 | 1 second | `RATE_LIMITED` error | | Reactions | 5 | 1 second | `RATE_LIMITED` error |
| Voice join / leave | 5 | 1 second | `RATE_LIMITED` error | | Voice join / leave | 5 | 1 second | `RATE_LIMITED` error |
| Voice camera | 2 | 1 second | `RATE_LIMITED` error | | Voice camera | 2 | 1 second | `RATE_LIMITED` error |
| Voice screenshare | 2 | 1 second | `RATE_LIMITED` error | | Voice screenshare | 2 | 1 second | `RATE_LIMITED` error |
| Voice token refresh | 1 | 60 seconds | `RATE_LIMITED` error | | Voice token refresh | 1 | 60 seconds | `RATE_LIMITED` error |
| Voice E2EE announce | 5 | 1 second | `RATE_LIMITED` error | | Voice E2EE announce | 5 | 1 second | `RATE_LIMITED` error |
| Voice E2EE offer | 64 | 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 | | Voice moderation (mute/deafen/move/kick) | 5 | 1 second | `RATE_LIMITED` error |
| Call ring | 1 | 3 seconds | `RATE_LIMITED` error | | Call ring | 1 | 3 seconds | `RATE_LIMITED` error |
| Call decline | 1 | 3 seconds | `RATE_LIMITED` error | | Call decline | 1 | 3 seconds | `RATE_LIMITED` error |
| Plugin command (`chat_command`) | 5 | 1 second | `RATE_LIMITED` error | | Plugin command (`chat_command`) | 5 | 1 second | `RATE_LIMITED` error |
| Channel focus | 5 | 1 second | Silently dropped | | Channel focus | 5 | 1 second | Silently dropped |
| Mark read | 5 | 1 second (own budget, separate from focus) | Silently dropped | | Mark read | 5 | 1 second (own budget, separate from focus) | Silently dropped |
| Ping | 2 | 1 second | Silently dropped | | Ping | 2 | 1 second | Silently dropped |
The E2EE offer budget is deliberately higher than the announce budget: a key 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 rotation fires one offer per peer in a single burst, so the limit is sized to
@@ -1485,79 +1501,79 @@ tables below add per-type behavioral notes.
### Client -> Server (27 types) ### Client -> Server (27 types)
| Type | Rate Limit | Notes | | Type | Rate Limit | Notes |
|------|-----------|-------| | --------------------- | ------------------------------------ | --------------------------------------------------------------- |
| `auth` | N/A (first message) | Token + optional last_seq | | `auth` | N/A (first message) | Token + optional last_seq |
| `chat_send` | 10/sec | + slow mode per channel | | `chat_send` | 10/sec | + slow mode per channel |
| `chat_edit` | 10/sec | Own messages only | | `chat_edit` | 10/sec | Own messages only |
| `chat_delete` | 10/sec | Own or mod (non-DM) | | `chat_delete` | 10/sec | Own or mod (non-DM) |
| `reaction_add` | 5/sec | | | `reaction_add` | 5/sec | |
| `reaction_remove` | 5/sec | | | `reaction_remove` | 5/sec | |
| `typing_start` | 1/3sec/channel | Silently dropped | | `typing_start` | 1/3sec/channel | Silently dropped |
| `channel_focus` | 5/sec (silently dropped) | Updates read state | | `channel_focus` | 5/sec (silently dropped) | Updates read state |
| `mark_read` | 5/sec, own budget (silently dropped) | Updates read state without moving focus | | `mark_read` | 5/sec, own budget (silently dropped) | Updates read state without moving focus |
| `presence_update` | 1/10sec | | | `presence_update` | 1/10sec | |
| `voice_join` | 5/sec | | | `voice_join` | 5/sec | |
| `voice_leave` | 5/sec | Empty payload | | `voice_leave` | 5/sec | Empty payload |
| `voice_mute` | 2/sec | Refused with `SERVER_MUTED` while server muted | | `voice_mute` | 2/sec | Refused with `SERVER_MUTED` while server muted |
| `voice_deafen` | 2/sec | Refused with `SERVER_DEAFENED` while server deafened | | `voice_deafen` | 2/sec | Refused with `SERVER_DEAFENED` while server deafened |
| `voice_camera` | 2/sec | Requires USE_VIDEO | | `voice_camera` | 2/sec | Requires USE_VIDEO |
| `voice_screenshare` | 2/sec | Requires SHARE_SCREEN | | `voice_screenshare` | 2/sec | Requires SHARE_SCREEN |
| `voice_mod_mute` | 5/sec | Requires MUTE_MEMBERS + outranks target | | `voice_mod_mute` | 5/sec | Requires MUTE_MEMBERS + outranks target |
| `voice_mod_deafen` | 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_move` | 5/sec | Requires MUTE_MEMBERS + outranks target |
| `voice_mod_kick` | 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_token_refresh` | 1/60sec | Must be in voice |
| `voice_e2ee_announce` | 5/sec | ECDH pubkey announce | | `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) | | `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_ring` | 1/3sec | DM participants only; fans out as `call_incoming` |
| `call_decline` | 1/3sec | DM participants only; fans out as `call_declined` | | `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` | | `chat_command` | 5/sec | Plugin slash command; max 64 args; broadcast gated by `CanPost` |
| `ping` | 2/sec (silently dropped) | Heartbeat | | `ping` | 2/sec (silently dropped) | Heartbeat |
### Server -> Client (39 types) ### Server -> Client (39 types)
| Type | Has seq? | Delivery | | Type | Has seq? | Delivery |
|------|----------|----------| | --------------------- | -------- | ----------------------------------------------------------------------- |
| `auth_ok` | No | Direct | | `auth_ok` | No | Direct |
| `auth_error` | No | Direct (then close) | | `auth_error` | No | Direct (then close) |
| `ready` | No | Direct | | `ready` | No | Direct |
| `chat_message` | Yes | Channel or DM participants | | `chat_message` | Yes | Channel or DM participants |
| `chat_send_ok` | No | Direct to sender | | `chat_send_ok` | No | Direct to sender |
| `chat_edited` | Yes | Channel or DM participants | | `chat_edited` | Yes | Channel or DM participants |
| `chat_deleted` | Yes | Channel or DM participants | | `chat_deleted` | Yes | Channel or DM participants |
| `chat_bulk_deleted` | Yes | Channel | | `chat_bulk_deleted` | Yes | Channel |
| `reaction_update` | Yes | Channel or DM participants | | `reaction_update` | Yes | Channel or DM participants |
| `typing` | No | Channel (excl. sender) or DM | | `typing` | No | Channel (excl. sender) or DM |
| `presence` | Yes | All clients | | `presence` | Yes | All clients |
| `channel_create` | Yes | All clients | | `channel_create` | Yes | All clients |
| `channel_update` | Yes | All clients | | `channel_update` | Yes | All clients |
| `channel_delete` | Yes | All clients | | `channel_delete` | Yes | All clients |
| `voice_state` | Yes | All clients | | `voice_state` | Yes | All clients |
| `voice_leave` | Yes | All clients | | `voice_leave` | Yes | All clients |
| `voice_moved` | No | Direct to moved user | | `voice_moved` | No | Direct to moved user |
| `voice_disconnected` | No | Direct to disconnected user | | `voice_disconnected` | No | Direct to disconnected user |
| `voice_config` | No | Direct to joiner | | `voice_config` | No | Direct to joiner |
| `voice_token` | No | Direct to joiner | | `voice_token` | No | Direct to joiner |
| `voice_speakers` | No | Reserved — not currently emitted | | `voice_speakers` | No | Reserved — not currently emitted |
| `member_join` | Yes | All clients | | `member_join` | Yes | All clients |
| `member_leave` | Yes | Reserved — not currently emitted | | `member_leave` | Yes | Reserved — not currently emitted |
| `member_update` | Yes | All clients | | `member_update` | Yes | All clients |
| `user_update` | Yes | All clients (profile changes) | | `user_update` | Yes | All clients (profile changes) |
| `member_ban` | Yes | All clients | | `member_ban` | Yes | All clients |
| `roles_update` | Yes | All clients (full role list) | | `roles_update` | Yes | All clients (full role list) |
| `emoji_update` | Yes | All clients (full custom-emoji set) | | `emoji_update` | Yes | All clients (full custom-emoji set) |
| `dm_channel_open` | No | Direct to participant | | `dm_channel_open` | No | Direct to participant |
| `dm_channel_close` | No | Direct to participant | | `dm_channel_close` | No | Direct to participant |
| `call_incoming` | No | Direct to each other DM participant | | `call_incoming` | No | Direct to each other DM participant |
| `call_declined` | 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_announce` | No | Voice channel (excl. sender) |
| `voice_e2ee_offer` | No | Direct to target participant | | `voice_e2ee_offer` | No | Direct to target participant |
| `server_restart` | Yes | All clients | | `server_restart` | Yes | All clients |
| `error` | No | Direct to requester | | `error` | No | Direct to requester |
| `pong` | No | Direct to pinger | | `pong` | No | Direct to pinger |
| `command_reply` | No | Direct to invoking client (ephemeral plugin reply) | | `command_reply` | No | Direct to invoking client (ephemeral plugin reply) |
| `plugin_broadcast` | Yes | Channel (plugin output posted as a broadcast; sequenced and replayable) | | `plugin_broadcast` | Yes | Channel (plugin output posted as a broadcast; sequenced and replayable) |
### Plugin command types ### Plugin command types
@@ -1566,8 +1582,8 @@ listed in `protocol-schema.json` like every other type (closing DC-01), so
the generated constants cover them and `make protocol-verify` plus the the generated constants cover them and `make protocol-verify` plus the
`ws` package's protocol-contract test gate them against drift. `ws` package's protocol-contract test gate them against drift.
| Type | Direction | Notes | | 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. | | `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}`. | | `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}`. | | `plugin_broadcast` | Server -> Client | Plugin output posted to a channel. Payload: `{channel_id, user_id, command, text}`. |

Some files were not shown because too many files have changed in this diff Show More