Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b7d388a39c | ||
|
|
259225ac61 | ||
|
|
cb5953bbb8 | ||
|
|
0ccb42932c | ||
|
|
b1bea96fc8 | ||
|
|
9df0e63b5f | ||
|
|
c22bc14946 | ||
|
|
5202e3fe1e | ||
|
|
d880b64d64 | ||
|
|
03fcb7d518 | ||
|
|
312ac4bbf4 | ||
|
|
eacba10cff | ||
|
|
c86d803a18 | ||
|
|
8cf019c03f | ||
|
|
5d6167a4d3 | ||
|
|
21945fb809 | ||
|
|
39551de4a6 | ||
|
|
7f87be6306 | ||
|
|
36be31db43 | ||
|
|
d6c768cb90 | ||
|
|
150c6c42f4 | ||
|
|
6a26f2a839 | ||
|
|
a366160dc8 |
@@ -0,0 +1 @@
|
||||
{}
|
||||
@@ -16,7 +16,7 @@ reach a commit, an issue, or a PR body.
|
||||
## 1. Hunt
|
||||
|
||||
**Launch the hunt from a turn that carries a token-budget directive** (recommended:
|
||||
`+25M`, comfortably above a full 8-round run). The workflow's cost ceiling is gated
|
||||
`+25M`, comfortably above a full coverage run's ~8-12M). The workflow's cost ceiling is gated
|
||||
on `budget.total`, which is null without a directive — a directive-less run has **no
|
||||
ceiling at all**. The workflow's first log line echoes the state: `budget=25M` means
|
||||
armed; `budget=NONE - cost ceiling disarmed` means stop the run and relaunch with a
|
||||
@@ -24,12 +24,20 @@ directive.
|
||||
|
||||
Before launching, in order:
|
||||
|
||||
1. **Rebuild the graph** (stale coordinates aim the explore lens at moved code):
|
||||
`graphify update . --no-cluster` — local tree-sitter, zero LLM cost, ~10.7k nodes.
|
||||
2. **Rank explore targets**: `node .superpowers/rank-explore.mjs` — writes
|
||||
`.superpowers/explore-ranking.json`, deprioritizing files recorded clean in
|
||||
`.superpowers/explored-clean.json` and dropping files that no longer exist.
|
||||
3. Read the ledger and pass every record in as `known`, so the hunt does not
|
||||
1. **Build the inventory**: `node .superpowers/rank-explore.mjs` — writes
|
||||
`.superpowers/explore-ranking.json`: EVERY non-test source file (~419 rows), each with
|
||||
`examined` (already carries a ledger finding or a LIVE explored-clean record → the hunt
|
||||
pre-seeds its covered set), `risky` (top coupling ∪ past-bug clusters ∪ top churn,
|
||||
capped at 40 → they get an extra pass through all 5 bug-class lenses), and `churn`.
|
||||
Explored-clean records carry content hashes: editing a file expires its clean record,
|
||||
so re-runs automatically re-hunt what changed. The hunt cannot stop while any inventory
|
||||
file is uncovered, so a full run now takes ~10-20 rounds and ~8-12M tokens — the `+25M`
|
||||
directive still covers it. Regenerate the inventory and read `known` from the ledger in
|
||||
the SAME session step: both derive from `findings-ledger.json`, and every `known` file
|
||||
must be `examined` in the inventory — a `known` file the inventory does not mark
|
||||
examined can never be drawn (the seen-filter blocks it) nor covered, which would
|
||||
strand `uncoveredCount()` above zero and block convergence.
|
||||
2. Read the ledger and pass every record in as `known`, so the hunt does not
|
||||
re-derive anything already found, fixed, declined, or refuted.
|
||||
|
||||
```
|
||||
@@ -39,7 +47,7 @@ Workflow({
|
||||
known: <every record from findings-ledger.json, as {file, line, title, status}>,
|
||||
graph: <the rows of .superpowers/explore-ranking.json>,
|
||||
lenses: [ {key, prompt}, ... ], // optional: scope the hunt to one subsystem
|
||||
maxRounds: 8,
|
||||
maxRounds: 30, // safety backstop only - coverage + dry is the real stop
|
||||
dryThreshold: 2,
|
||||
},
|
||||
})
|
||||
@@ -49,8 +57,28 @@ If `graph` is omitted or empty the hunt logs
|
||||
`explore: args.graph absent/empty - falling back to churn-based fresh eyes` and
|
||||
still runs — degraded targeting, never a smaller lens family.
|
||||
|
||||
`converged: true` now means: every inventory file was covered by a completed
|
||||
explicit-file lens (or carries a verdict), the risky class sweep ran, and then
|
||||
`dryThreshold` consecutive eligible rounds confirmed nothing (a round where the
|
||||
lens family comes up empty with the pool drained counts as dry — family
|
||||
`exhausted`). Rows without the `examined` field fall back to the old
|
||||
quietness-only stop. Two new run outcomes: `stalledCoverage: true` means adaptive
|
||||
rounds stopped shrinking the uncovered pool (usually mass finder failures —
|
||||
investigate before re-running); a budget stop now reports
|
||||
`coverage.uncoveredAtStop` so the next run knows exactly what remains (re-run
|
||||
with the ledger as `known`; live explored-clean records pre-cover what was
|
||||
finished, so the sweep naturally continues where it stopped).
|
||||
|
||||
Omit `lenses` for a general hunt across the rotating families.
|
||||
|
||||
**Scoping a hunt while coverage mode is armed is a budget trap:** `lenses` only
|
||||
replaces round 1, and inventory rows with `examined` force the coverage stop
|
||||
rule — from round 2 the run sweeps the ENTIRE uncovered pool and the risky
|
||||
sweep before it may converge, at general-hunt cost. For a true scoped hunt,
|
||||
pass a subsystem-filtered inventory as `graph` (only the rows you want swept),
|
||||
or rows without the `examined` field to fall back to the legacy quietness-only
|
||||
stop.
|
||||
|
||||
Each lens object is `{key, prompt}`. `key` must match `^[a-z0-9-]+$` —
|
||||
lowercase letters, digits, and hyphens only. Keys get interpolated into agent
|
||||
labels of the form `r<N>:hunt:<key>:<model>`, and a key containing uppercase,
|
||||
@@ -93,9 +121,16 @@ candidate counts make an anomalously empty lens visible after the fact.
|
||||
|
||||
## 2. Gate (human)
|
||||
|
||||
Read `.superpowers/FINDINGS.md`. Mark anything you do not want fixed as
|
||||
`declined` with a rationale — declined findings are fed back into the next hunt's
|
||||
prompts and never re-reported.
|
||||
Generate the readable rendering, then read it — it is gitignored, so a fresh
|
||||
clone has no copy until you make one:
|
||||
|
||||
```bash
|
||||
node .superpowers/render-ledger.mjs # writes .superpowers/FINDINGS.md
|
||||
```
|
||||
|
||||
Mark anything you do not want fixed as `declined` with a rationale — declined
|
||||
findings are fed back into the next hunt's prompts and never re-reported. Edit
|
||||
`findings-ledger.json` to do that, not the rendering.
|
||||
|
||||
## 3. Fix
|
||||
|
||||
@@ -157,6 +192,15 @@ points: the fix stage (before any prove agent runs) and inside the prove loop.
|
||||
being on the wrong branch, a broken test runner, or ledger coordinates gone stale
|
||||
after a rebase. Re-running without fixing the cause just spends the budget again.
|
||||
|
||||
**Re-verify a blocked finding against HEAD before fixing it.** A deferred item
|
||||
ages against a moving codebase: later hunts routinely fix a blocked finding as a
|
||||
side effect of an overlapping sibling, and a saved debris patch stops applying
|
||||
once a refactor rewrites the files it touched. Check the _mechanism_ still exists
|
||||
at HEAD, not just the line coordinates. If it is already covered, mark it fixed
|
||||
with a pointer to the covering commit instead of re-fixing it. Of 6 findings
|
||||
blocked on 2026-08-14, 2 were already fixed 5 days later and the debris patch no
|
||||
longer applied at all.
|
||||
|
||||
Findings from clusters the run never reached come back `blocked` with a rationale
|
||||
naming the breaker. Set those back to `open` once the underlying problem is fixed
|
||||
— they were never attempted. Their edits are sitting uncommitted in the working
|
||||
@@ -199,7 +243,7 @@ lines) locates the mechanism in minutes.
|
||||
|
||||
## 4. Verify the fixes independently — REQUIRED
|
||||
|
||||
The workflow's prove agent *self-reports* that each test went RED with the fix
|
||||
The workflow's prove agent _self-reports_ that each test went RED with the fix
|
||||
reverted. Nothing inside the workflow can verify that: workflow scripts have no
|
||||
filesystem access. You do. Run the independent proof over every commit the
|
||||
workflow made:
|
||||
@@ -284,10 +328,12 @@ finding must be excised from history (amend + rebase onto the amended
|
||||
commit), not merely removed by a follow-up commit.
|
||||
|
||||
Then review the branch against the merge-base — `git diff
|
||||
origin/main...HEAD` (three-dot), never two-dot: a concurrent merge plus a
|
||||
background fetch can move origin/main mid-run and turn the two-dot diff into
|
||||
phantom deletions. If origin moved, confirm zero file overlap and a clean
|
||||
`git merge-tree --write-tree origin/main HEAD` before opening the PR by
|
||||
origin/dev...HEAD` (three-dot), never two-dot: a concurrent merge plus a
|
||||
background fetch can move the base mid-run and turn the two-dot diff into
|
||||
phantom deletions. `dev` is the integration branch every PR targets
|
||||
(docs/contributing.md#branch-and-pr-model); use `origin/main` only for a
|
||||
release PR cut from `dev`. If origin moved, confirm zero file overlap and a
|
||||
clean `git merge-tree --write-tree origin/dev HEAD` before opening the PR by
|
||||
hand. The workflow never
|
||||
pushes and never opens a PR.
|
||||
|
||||
|
||||
@@ -9,6 +9,19 @@ description: Run the local mirror of OwnCord's CI gates before pushing. Use when
|
||||
|
||||
Run only the sections your change touches. Server and client are independent.
|
||||
|
||||
**A step added only to `release.yml` first runs at tag time.** `release.yml` is
|
||||
tag-triggered and never gated by a PR, so a smoke/sign/strip step added there is
|
||||
untested code on the critical path — its own bugs surface on the release, not on
|
||||
a PR. Extract it to a script `ci.yml` also runs (`Server/scripts/docker-smoke.sh`
|
||||
is the worked example) or duplicate it into `ci.yml` before merge.
|
||||
|
||||
From the repository root, `npm run check` runs all of it, and
|
||||
`check:server` / `check:client` / `check:rust` / `check:hygiene` run one stack.
|
||||
`node scripts/run.mjs --list` prints the exact command each step runs and the
|
||||
directory it runs in — the per-stack commands below are those commands, and
|
||||
staying with them is fine. Nothing here needs `make`, and server work needs no
|
||||
Node.
|
||||
|
||||
## Server (from `Server/`)
|
||||
|
||||
All four build-tag variants must compile — the tags gate whole files, so a
|
||||
@@ -20,7 +33,11 @@ go vet ./...
|
||||
go test -race ./...
|
||||
go test -tags deadlock -count=1 ./ws/ # deadlock detector; ws is where lock order actually varies
|
||||
golangci-lint run # CI pins v2.11.3
|
||||
make sqlc-verify protocol-verify # generated output must not be stale
|
||||
|
||||
# Generated output must not be stale. These are what `make sqlc-verify` and
|
||||
# `make protocol-verify` reduce to — make is not on PATH on a stock Windows box.
|
||||
sqlc generate && git diff --exit-code db/dbgen
|
||||
go run ./cmd/genprotocol && git diff --exit-code ws/message_types.go ../Client/src/lib/protocolTypes.ts
|
||||
```
|
||||
|
||||
Add `-tags wazero` to `go vet`/`go test` when you touched `plugin/`.
|
||||
@@ -34,32 +51,138 @@ fault, same verdict, especially when the diff touches no Go code. Rerun the
|
||||
job (`gh run rerun --job <id>`); a job cannot be rerun while its parent run is
|
||||
still in progress.
|
||||
|
||||
## Client (from `Client/tauri-client/`)
|
||||
## Client (from `Client/`)
|
||||
|
||||
```bash
|
||||
NODE_OPTIONS=--no-experimental-webstorage npm test
|
||||
npm test
|
||||
npm run typecheck
|
||||
npm run lint
|
||||
npm run format:check
|
||||
```
|
||||
|
||||
The `NODE_OPTIONS` flag is mandatory on Node 22+ — see the client CLAUDE.md.
|
||||
Formatting is no longer a client gate — Prettier is configured once at the
|
||||
repository root and checked by `check:hygiene` below.
|
||||
|
||||
`NODE_OPTIONS=--no-experimental-webstorage` used to be required here. It is not
|
||||
any more: `tests/setup.ts` installs an in-memory `localStorage` shim, CI runs
|
||||
Node 24 without the flag (`ci.yml`), and the full suite was measured passing
|
||||
without it — 192 files / 5257 tests, identical to the flagged run.
|
||||
|
||||
`npm audit --audit-level=high` and `knip` also run in CI but are advisory.
|
||||
|
||||
## Rust (from `Client/tauri-client/src-tauri/`)
|
||||
## Docs and ledger (from the repository root)
|
||||
|
||||
```bash
|
||||
cargo test
|
||||
cargo clippy --all-targets -- -D warnings
|
||||
npm run check:docs
|
||||
```
|
||||
|
||||
Which is `scripts/check-doc-counts.mjs` plus, since B1-6, an actual render of
|
||||
the findings ledger:
|
||||
|
||||
```bash
|
||||
node .superpowers/render-ledger.mjs
|
||||
```
|
||||
|
||||
`.superpowers/FINDINGS.md` is **not tracked** — it is generated on demand and
|
||||
gitignored, so there is no committed rendering to go stale. The gate is that
|
||||
generation succeeds. Rendering subsumes `--check`: the renderer validates and
|
||||
exits 1 before it writes, so a schema break (including an unranked `severity`)
|
||||
fails here.
|
||||
|
||||
CI does one thing more, in `Docs & Ledger Consistency` — it renders **twice**
|
||||
and compares, proving the output is a pure function of the ledger, then uploads
|
||||
the rendering as the `findings-ledger-rendering` artifact so a reviewer can read
|
||||
it without running Node.
|
||||
|
||||
## Hygiene (from the repository root)
|
||||
|
||||
```bash
|
||||
npm run check:hygiene
|
||||
```
|
||||
|
||||
Which is:
|
||||
|
||||
```bash
|
||||
npx prettier --check . # every material tracked source, not just client TS
|
||||
shellcheck <tracked *.sh + .githooks/pre-commit + .githooks/pre-push>
|
||||
actionlint .github/workflows/*.yml
|
||||
```
|
||||
|
||||
`shellcheck` and `actionlint` have no clean Windows install, so `run.mjs` marks
|
||||
them optional and prints `--- SKIP` instead of failing; CI runs them for real.
|
||||
Prettier is not optional and runs everywhere.
|
||||
|
||||
The file lists come from `git ls-files`, never a filesystem glob:
|
||||
`.claude/worktrees/` holds gitignored copies of the tree that a glob would
|
||||
happily lint.
|
||||
|
||||
Go formatting is not here. `gofmt -l` prints offenders and still exits 0, so it
|
||||
cannot fail a build; the `formatters` block in `Server/.golangci.yml` enforces
|
||||
it inside `golangci-lint run`, and `.githooks/pre-commit` catches staged files.
|
||||
|
||||
## Rust (from `Client/src-tauri/`)
|
||||
|
||||
```bash
|
||||
cargo fmt --all -- --check # runs ahead of clippy in CI
|
||||
cargo test --lib # CI runs --lib; plain `cargo test` also builds the bin target
|
||||
cargo clippy --all-targets -- -D warnings
|
||||
cargo install cargo-audit@0.22.1 --quiet && cargo audit # CI runs this in tauri-build
|
||||
```
|
||||
|
||||
`cargo audit` is the one gate here that turns red with **zero** local changes —
|
||||
an advisory published upstream breaks a branch that was clean yesterday. Check the
|
||||
advisory date before hunting your diff. It is skipped on Dependabot PRs by design
|
||||
(it overlaps the scanning that opened them), so a clean Dependabot run does not
|
||||
mean the advisory set is clean. The client equivalents, `npm audit --omit=dev
|
||||
--audit-level=high` and `knip`, are advisory in CI.
|
||||
|
||||
`fallback_crypto` is `cfg(not(windows))`, so its tests compile to nothing on a
|
||||
Windows box and only run on the Linux/macOS runners.
|
||||
|
||||
Do not attempt `npm run tauri build` locally — the full desktop build runs in
|
||||
CI on PRs to `main` and pulls heavy system dependencies.
|
||||
|
||||
## Reading a red check
|
||||
|
||||
**Causality before forensics.** Before opening a failing job's log, diff the
|
||||
PR's changed-file set against that job's input surface and ask whether the change
|
||||
could reach it. A diff touching only `.github/workflows/*.yml` cannot cause a Go
|
||||
goroutine leak — that failure is pre-existing or flaky by construction. Re-run
|
||||
first, and check `dev`/`main` is green to tell "flaky" from "already red". Only
|
||||
start log-reading once the change plausibly reaches the job.
|
||||
|
||||
**Compare against the baseline, never against zero.** For any gate a repo
|
||||
knowingly runs red, the unit of verification is the _delta_ from a recorded
|
||||
baseline, not pass/fail — absolute pass/fail only means something when the
|
||||
intended state is zero. Get the delta with `git stash && <gate> > /tmp/base &&
|
||||
git stash pop && <gate> | diff /tmp/base -`. This repo currently carries **no**
|
||||
known-red gate: `golangci-lint`'s complexity backlog was cleared to zero, so a
|
||||
red `golangci-lint` is now genuinely yours. If a budget is ever retuned upward,
|
||||
record the new baseline here next to the command or the gate reports nothing.
|
||||
|
||||
**A dependency bump that breaks the build may be a fork, not a version.** When an
|
||||
updated dependency suddenly demands configuration it never needed, suspect it was
|
||||
inheriting that configuration from a shared resolution with another dependent.
|
||||
Diff the lockfile _entry count_ for that dependency between base and PR: a 1 → 2
|
||||
transition means the update forked it into two semver-incompatible copies, feature
|
||||
unification stopped crossing the boundary, and the fix is to restore version
|
||||
alignment with whatever else requires it — not to set the feature the new copy
|
||||
asks for.
|
||||
|
||||
### Known infra flakes
|
||||
|
||||
Not your change. Match the signature, then recover.
|
||||
|
||||
| Signature | Verdict / recovery |
|
||||
| ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| `windows-latest` `-race` fault in `ws`: `runtime.scanstack`, `runtime.(*unwinder).next`, or `unexpected fault address 0xffffffffffffffff` / `fatal error: fault` inside ordinary stdlib frames | Go runtime GC fault, not your code — see the Server section. `gh run rerun --job <id>` |
|
||||
| `##[error]The operation was canceled.` + `Terminate orphan process: ... playwright install --with-deps` + a wall of `Ign:N http://azure.archive.ubuntu.com/...` and no Playwright summary line | Runner apt-mirror outage during "Install Linux system dependencies". The job was **canceled by timeout**, not failed. `gh run cancel` then `gh run rerun --failed` |
|
||||
| Red `Lint` step with zero linters actually run | `golangci-lint`'s network schema fetch failed. Re-run |
|
||||
|
||||
`gh run view --log` refuses while a run is in progress; `gh api
|
||||
repos/<owner>/<repo>/actions/jobs/<id>/logs` works. A job cannot be rerun while
|
||||
its parent run is still in progress. `tauri-build` has no `timeout-minutes`, so a
|
||||
hung apt step can hold a run open for the 6 h default — cancel it rather than wait.
|
||||
|
||||
## Hooks
|
||||
|
||||
`npm run hooks:install` (once per clone) points `core.hooksPath` at
|
||||
@@ -67,3 +190,11 @@ CI on PRs to `main` and pulls heavy system dependencies.
|
||||
server build variants plus tsc and eslint. `OWNCORD_PREPUSH_TESTS=1` adds
|
||||
server tests. Bypass with `--no-verify` or `OWNCORD_SKIP_HOOKS=1` — CI still
|
||||
enforces everything.
|
||||
|
||||
**`core.hooksPath` is exclusive, not additive.** Once set, Git resolves every
|
||||
hook against `.githooks/` and stops consulting `.git/hooks/` entirely.
|
||||
`.githooks/` holds only `pre-commit` and `pre-push`, so running
|
||||
`hooks:install` **silently disables any locally installed hook** of any other
|
||||
name (`post-commit`, `post-checkout`, ...). Nothing warns you. If you need one,
|
||||
re-install it under `.githooks/` (untracked, and it stays yours), or skip
|
||||
`hooks:install` and run the checks through `npm run check` instead.
|
||||
|
||||
@@ -26,7 +26,7 @@ These are silent — the code generates fine and fails at runtime.
|
||||
|
||||
**Query files must be ASCII-only.** sqlc v1.30.0 measures rune positions
|
||||
against byte offsets, so one multi-byte character (an em-dash in a comment is
|
||||
the usual culprit) truncates the *next* query's emitted SQL by that many
|
||||
the usual culprit) truncates the _next_ query's emitted SQL by that many
|
||||
trailing bytes. Symptom: the `.sql` file looks right but the generated const
|
||||
in `dbgen/*.sql.go` is cut short — `ORDER BY id ASC` becomes `ORDER BY id A`,
|
||||
and SQLite reports "incomplete input".
|
||||
|
||||
@@ -1,17 +1,32 @@
|
||||
---
|
||||
name: protocol-change
|
||||
description: Add or change a WebSocket message type in OwnCord. Use before editing docs/protocol-schema.json, Server/ws/message_types.go, or Client/tauri-client/src/lib/protocolTypes.ts.
|
||||
description: Add or change a WebSocket message type in OwnCord. Use before editing protocol/schema.json, Server/ws/message_types.go, or Client/src/lib/protocolTypes.ts.
|
||||
---
|
||||
|
||||
# protocol-change
|
||||
|
||||
`docs/protocol-schema.json` is the source of truth. Both constant files are
|
||||
generated from it by `Server/scripts/genprotocol/`.
|
||||
`protocol/schema.json` is the source of truth. Both constant files are
|
||||
generated from it by `Server/cmd/genprotocol/`.
|
||||
|
||||
1. Edit `docs/protocol-schema.json`.
|
||||
**The schema holds message-type NAMES only.** Route by what you are changing —
|
||||
most payload work never touches it, and sending a field change through the
|
||||
regenerate cycle below is wasted work:
|
||||
|
||||
| Change | What to edit |
|
||||
| -------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------- |
|
||||
| New message type | schema + regenerate (steps below) |
|
||||
| New or changed payload **field** on an existing type | `Server/ws/command.go`/`messages.go`, `Client/src/lib/protocolTypes.ts`, `docs/protocol.md` — no schema, no regenerate |
|
||||
| Content inside an opaque blob the server relays verbatim | `docs/protocol.md` only; often zero Go change |
|
||||
|
||||
Before assuming a field needs server work, read the relay handler: if the server
|
||||
forwards the message raw, there is nothing to add. If it **re-serialises**, an
|
||||
older server drops unknown JSON fields — so a field the server must forward is
|
||||
NOT backward compatible with older servers.
|
||||
|
||||
1. Edit `protocol/schema.json`.
|
||||
2. Run `make protocol-generate` from `Server/`.
|
||||
3. Commit **both** outputs — `Server/ws/message_types.go` and
|
||||
`Client/tauri-client/src/lib/protocolTypes.ts`. One run regenerates the
|
||||
`Client/src/lib/protocolTypes.ts`. One run regenerates the
|
||||
pair; committing only the Go side is the usual mistake, and CI's
|
||||
`make protocol-verify` fails on either being stale.
|
||||
|
||||
@@ -20,4 +35,4 @@ shapes, not behaviour.
|
||||
|
||||
Adding a message type is not enough to make it work: a server handler must be
|
||||
registered in the `ws` V1/V2 dispatch tables, and the client needs a
|
||||
`ws.on(...)` subscription in `Client/tauri-client/src/lib/dispatcher.ts`.
|
||||
`ws.on(...)` subscription in `Client/src/lib/dispatcher.ts`.
|
||||
|
||||
@@ -19,7 +19,7 @@ description: >
|
||||
# Task Observer — Continuous Skill Discovery & Improvement
|
||||
|
||||
**Created by Eoghan Henn / [rebelytics.com](https://rebelytics.com)** —
|
||||
*"One Skill to Rule Them All."* Licensed CC BY 4.0: share and adapt freely
|
||||
_"One Skill to Rule Them All."_ Licensed CC BY 4.0: share and adapt freely
|
||||
with credit to the author. Canonical source:
|
||||
[github.com/rebelytics/one-skill-to-rule-them-all](https://github.com/rebelytics/one-skill-to-rule-them-all).
|
||||
The links in this block are references for the human reader — executing
|
||||
@@ -176,9 +176,17 @@ checkpoints; piggy-backing the flush onto them means the write happens as a
|
||||
side effect of work you were doing anyway, rather than depending on a separate
|
||||
act of memory.
|
||||
|
||||
**Your own delegates are concurrent writers.** A subagent dispatched into the
|
||||
same project has this skill active in its own context and appends to the same
|
||||
log, so it consumes numbers between your read and your write. Collisions are
|
||||
structural in any fan-out workflow, not a rare parallel-human accident — which
|
||||
is exactly why the pre-write assertion below matters most in the workflows that
|
||||
spawn helpers. When dispatching, say who owns logging for the session, or two
|
||||
writers record the same incident from different angles under different numbers.
|
||||
|
||||
**Numbering discipline (mandatory, every append):**
|
||||
|
||||
1. *Pre-check:* read the actual log and find the highest existing number —
|
||||
1. _Pre-check:_ read the actual log and find the highest existing number —
|
||||
never trust session memory:
|
||||
|
||||
```bash
|
||||
@@ -188,7 +196,7 @@ act of memory.
|
||||
grep -o '### Observation [0-9]*' log.md | grep -o '[0-9]*' | sort -n | tail -1
|
||||
```
|
||||
|
||||
2. *Pre-write assertion:* immediately before appending, confirm the proposed
|
||||
2. _Pre-write assertion:_ immediately before appending, confirm the proposed
|
||||
number doesn't already exist:
|
||||
|
||||
```bash
|
||||
@@ -200,7 +208,7 @@ act of memory.
|
||||
If it fires, increment past all existing numbers and re-check (and log a
|
||||
meta-observation — it signals a parallel-session collision).
|
||||
|
||||
3. *Post-write verification:* after appending, count occurrences of the
|
||||
3. _Post-write verification:_ after appending, count occurrences of the
|
||||
number; if >1, a parallel writer collided between check and write —
|
||||
renumber YOUR entry to max+1. Identify your entry from your own append
|
||||
operation (capture the file's line count immediately before and after
|
||||
@@ -377,6 +385,7 @@ resolved statuses always carry their resolution date
|
||||
## [Date]
|
||||
|
||||
### Observation 1: [Title]
|
||||
|
||||
**Status:** OPEN
|
||||
[... full format ...]
|
||||
```
|
||||
@@ -432,15 +441,15 @@ same reference).
|
||||
|
||||
## Quick Reference
|
||||
|
||||
| Question | Answer |
|
||||
|----------|--------|
|
||||
| When do I observe? | The whole session, including feedback and reflection phases |
|
||||
| How do I log? | Silently, immediately, appended to the end, with the 3-step numbering discipline |
|
||||
| When do I surface? | End of session, or earlier if needed |
|
||||
| Status line? | Mandatory `**Status:** OPEN` as the first field of every new observation; reviews treat statusless entries as OPEN, never as nonexistent |
|
||||
| Citing an observation number? | Only from its literal `### Observation N:` header — `grep -n` line numbers are positional metadata, not IDs; sanity-check against the known counter range |
|
||||
| Open-source or internal? | Default open-source; the boundary is confidential |
|
||||
| Small fix or substantial? | Additive → apply directly; restructuring/new skill → `references/skill-authoring.md` |
|
||||
| Rewriting the log (archival/renumber/status)? | Backup → re-read live and merge → bounded mutation → verify count against live pre-write file → confirm own entries survived |
|
||||
| Weekly review? | Trigger check at session start; procedure in `references/weekly-review.md` |
|
||||
| No filesystem? | Handoff-doc mode — `references/environments.md` |
|
||||
| Question | Answer |
|
||||
| --------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| When do I observe? | The whole session, including feedback and reflection phases |
|
||||
| How do I log? | Silently, immediately, appended to the end, with the 3-step numbering discipline |
|
||||
| When do I surface? | End of session, or earlier if needed |
|
||||
| Status line? | Mandatory `**Status:** OPEN` as the first field of every new observation; reviews treat statusless entries as OPEN, never as nonexistent |
|
||||
| Citing an observation number? | Only from its literal `### Observation N:` header — `grep -n` line numbers are positional metadata, not IDs; sanity-check against the known counter range |
|
||||
| Open-source or internal? | Default open-source; the boundary is confidential |
|
||||
| Small fix or substantial? | Additive → apply directly; restructuring/new skill → `references/skill-authoring.md` |
|
||||
| Rewriting the log (archival/renumber/status)? | Backup → re-read live and merge → bounded mutation → verify count against live pre-write file → confirm own entries survived |
|
||||
| Weekly review? | Trigger check at session start; procedure in `references/weekly-review.md` |
|
||||
| No filesystem? | Handoff-doc mode — `references/environments.md` |
|
||||
|
||||
@@ -84,18 +84,23 @@ work.
|
||||
**Context:** [what was worked on; what the next session needs to know]
|
||||
|
||||
## Decisions Made
|
||||
|
||||
[numbered]
|
||||
|
||||
## Observations Logged
|
||||
|
||||
[full entries in standard format]
|
||||
|
||||
## Cross-Cutting Principles (current)
|
||||
|
||||
[active or newly added]
|
||||
|
||||
## Action Items
|
||||
|
||||
[next steps with enough context to resume]
|
||||
|
||||
## Working Artifacts
|
||||
|
||||
[drafts/analyses in full]
|
||||
```
|
||||
|
||||
@@ -103,7 +108,7 @@ work.
|
||||
|
||||
1. Log all explicitly stated observations first, unfiltered.
|
||||
2. Then systematically read every section asking what skill gaps or
|
||||
candidates are *implied* but unstated — handoff docs carry signal beyond
|
||||
candidates are _implied_ but unstated — handoff docs carry signal beyond
|
||||
what was captured live.
|
||||
3. Pay special attention to action items (each may imply a missing skill),
|
||||
open questions (ambiguity signals a decision-framework gap), the
|
||||
|
||||
@@ -227,6 +227,7 @@ any skill creation or regeneration.
|
||||
## Active Principles
|
||||
|
||||
### 1. [Principle title]
|
||||
|
||||
**Added:** [date]
|
||||
**Applies to:** [all skills | all open-source skills | all skills with rules]
|
||||
**Requirement:** [what it requires]
|
||||
|
||||
@@ -80,7 +80,7 @@ fallback active. No → write today's date to
|
||||
firings within the window re-surface the offer). No scheduler available in
|
||||
this environment → skip silently.
|
||||
|
||||
**Step 1 — load.** Archive entries resolved in *previous* sessions (see
|
||||
**Step 1 — load.** Archive entries resolved in _previous_ sessions (see
|
||||
Archival on Write in SKILL.md). Read the observation log.
|
||||
|
||||
Build the work queue from the structural identifiers, not from a status
|
||||
|
||||
@@ -1,32 +1,34 @@
|
||||
export const meta = {
|
||||
name: 'bughunt-fix',
|
||||
description: 'Fix open ledger findings test-first: per-file agents, mechanical revert-proof, serial commits, one ci-check gate',
|
||||
whenToUse: 'After a bughunt run has been written to the findings ledger and a human has skimmed it. Consumes open findings, produces commits on a branch. Never opens a PR.',
|
||||
name: "bughunt-fix",
|
||||
description:
|
||||
"Fix open ledger findings test-first: per-file agents, mechanical revert-proof, serial commits, one ci-check gate",
|
||||
whenToUse:
|
||||
"After a bughunt run has been written to the findings ledger and a human has skimmed it. Consumes open findings, produces commits on a branch. Never opens a PR.",
|
||||
phases: [
|
||||
{ title: 'Plan', detail: 'cluster open findings by file' },
|
||||
{ title: 'Fix', detail: 'sonnet/xhigh: one agent per file, test-first, no git' },
|
||||
{ title: 'Prove', detail: 'opus/high: serial revert-proof then commit per cluster' },
|
||||
{ title: 'Gate', detail: 'sonnet/xhigh: ci-check for the touched stacks, once' },
|
||||
{ title: "Plan", detail: "cluster open findings by file" },
|
||||
{ title: "Fix", detail: "sonnet/xhigh: one agent per file, test-first, no git" },
|
||||
{ title: "Prove", detail: "opus/high: serial revert-proof then commit per cluster" },
|
||||
{ title: "Gate", detail: "sonnet/xhigh: ci-check for the touched stacks, once" },
|
||||
],
|
||||
}
|
||||
};
|
||||
|
||||
// args has been observed arriving JSON-stringified; coerce it the same way bughunt.js does.
|
||||
const ARGS = (() => {
|
||||
if (typeof args === 'string') {
|
||||
if (typeof args === "string") {
|
||||
try {
|
||||
return JSON.parse(args) || {}
|
||||
return JSON.parse(args) || {};
|
||||
} catch {
|
||||
return {}
|
||||
return {};
|
||||
}
|
||||
}
|
||||
return args || {}
|
||||
})()
|
||||
return args || {};
|
||||
})();
|
||||
|
||||
const SEV_RANK = { critical: 0, high: 1, medium: 2, low: 3 }
|
||||
const BRANCH = ARGS.branch || 'fix/bughunt'
|
||||
const ONLY = Array.isArray(ARGS.only) && ARGS.only.length ? new Set(ARGS.only) : null
|
||||
const MAX_SEVERITY = ARGS.maxSeverity || 'low'
|
||||
const ALL = Array.isArray(ARGS.findings) ? ARGS.findings : []
|
||||
const SEV_RANK = { critical: 0, high: 1, medium: 2, low: 3 };
|
||||
const BRANCH = ARGS.branch || "fix/bughunt";
|
||||
const ONLY = Array.isArray(ARGS.only) && ARGS.only.length ? new Set(ARGS.only) : null;
|
||||
const MAX_SEVERITY = ARGS.maxSeverity || "low";
|
||||
const ALL = Array.isArray(ARGS.findings) ? ARGS.findings : [];
|
||||
// Circuit breaker: stop a run that is going systematically wrong instead of spending a
|
||||
// high-effort agent on every remaining cluster. `declined` is not a failure - it is a
|
||||
// judgement the fix prompt explicitly invites - so only `blocked` counts.
|
||||
@@ -37,23 +39,23 @@ const BREAKER =
|
||||
: {
|
||||
threshold: ARGS.circuitBreaker?.threshold ?? 0.5,
|
||||
minAttempts: ARGS.circuitBreaker?.minAttempts ?? 3,
|
||||
}
|
||||
let breaker = null // set to a report object if it trips
|
||||
};
|
||||
let breaker = null; // set to a report object if it trips
|
||||
|
||||
// ---------- phase 1: plan ----------
|
||||
phase('Plan')
|
||||
phase("Plan");
|
||||
|
||||
const excluded = []
|
||||
const selected = []
|
||||
const excluded = [];
|
||||
const selected = [];
|
||||
for (const f of ALL) {
|
||||
if (f.status && f.status !== 'open') {
|
||||
excluded.push({ id: f.id, reason: `status is ${f.status}, not open` })
|
||||
if (f.status && f.status !== "open") {
|
||||
excluded.push({ id: f.id, reason: `status is ${f.status}, not open` });
|
||||
} else if (ONLY && !ONLY.has(f.id)) {
|
||||
excluded.push({ id: f.id, reason: 'not in only' })
|
||||
excluded.push({ id: f.id, reason: "not in only" });
|
||||
} else if ((SEV_RANK[f.severity] ?? 3) > (SEV_RANK[MAX_SEVERITY] ?? 3)) {
|
||||
excluded.push({ id: f.id, reason: 'below maxSeverity' })
|
||||
excluded.push({ id: f.id, reason: "below maxSeverity" });
|
||||
} else {
|
||||
selected.push(f)
|
||||
selected.push(f);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -61,59 +63,68 @@ for (const f of ALL) {
|
||||
// and what makes a root-cause fix possible - the agent sees every defect in the file at once.
|
||||
// Normalize the grouping key (backslashes -> forward slashes) so a path reported with the
|
||||
// "wrong" separator does not silently split one real file into two clusters.
|
||||
const byFile = new Map()
|
||||
const byFile = new Map();
|
||||
for (const f of selected) {
|
||||
const key = String(f.file).replace(/\\/g, '/')
|
||||
if (!byFile.has(key)) byFile.set(key, [])
|
||||
byFile.get(key).push(f)
|
||||
const key = String(f.file).replace(/\\/g, "/");
|
||||
if (!byFile.has(key)) byFile.set(key, []);
|
||||
byFile.get(key).push(f);
|
||||
}
|
||||
const clusters = [...byFile.entries()].map(([file, findings]) => ({
|
||||
file,
|
||||
ids: findings.map((f) => f.id),
|
||||
findings,
|
||||
}))
|
||||
}));
|
||||
|
||||
log(`plan: ${selected.length} finding(s) in ${clusters.length} file cluster(s) on ${BRANCH}` +
|
||||
(BREAKER
|
||||
? ` (breaker: stop above ${Math.round(BREAKER.threshold * 100)}% failures after ${BREAKER.minAttempts} attempts)`
|
||||
: ' (breaker disabled)'))
|
||||
for (const c of clusters) log(` ${c.file}: ${c.ids.join(', ')}`)
|
||||
log(
|
||||
`plan: ${selected.length} finding(s) in ${clusters.length} file cluster(s) on ${BRANCH}` +
|
||||
(BREAKER
|
||||
? ` (breaker: stop above ${Math.round(BREAKER.threshold * 100)}% failures after ${BREAKER.minAttempts} attempts)`
|
||||
: " (breaker disabled)"),
|
||||
);
|
||||
for (const c of clusters) log(` ${c.file}: ${c.ids.join(", ")}`);
|
||||
// Announce every exclusion by id. Silent truncation reads as "covered everything" when it did not.
|
||||
for (const e of excluded) log(` excluded ${e.id}: ${e.reason}`)
|
||||
for (const e of excluded) log(` excluded ${e.id}: ${e.reason}`);
|
||||
|
||||
const publicClusters = clusters.map((c) => ({ file: c.file, ids: c.ids }))
|
||||
const publicClusters = clusters.map((c) => ({ file: c.file, ids: c.ids }));
|
||||
|
||||
// ---------- schemas ----------
|
||||
const FIX_RESULTS = {
|
||||
type: 'object',
|
||||
required: ['results', 'touchedPaths'],
|
||||
type: "object",
|
||||
required: ["results", "touchedPaths"],
|
||||
properties: {
|
||||
results: {
|
||||
type: 'array',
|
||||
type: "array",
|
||||
items: {
|
||||
type: 'object',
|
||||
required: ['id', 'outcome', 'testPath', 'rationale'],
|
||||
type: "object",
|
||||
required: ["id", "outcome", "testPath", "rationale"],
|
||||
properties: {
|
||||
id: { type: 'string', description: 'the ledger id, e.g. OC-0042' },
|
||||
outcome: { type: 'string', enum: ['fixed', 'declined', 'blocked'] },
|
||||
testPath: { type: 'string', description: 'repo-relative path of the test that pins this finding; empty if not fixed' },
|
||||
rationale: { type: 'string', description: 'required for declined and blocked; empty for fixed' },
|
||||
id: { type: "string", description: "the ledger id, e.g. OC-0042" },
|
||||
outcome: { type: "string", enum: ["fixed", "declined", "blocked"] },
|
||||
testPath: {
|
||||
type: "string",
|
||||
description:
|
||||
"repo-relative path of the test that pins this finding; empty if not fixed",
|
||||
},
|
||||
rationale: {
|
||||
type: "string",
|
||||
description: "required for declined and blocked; empty for fixed",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
touchedPaths: {
|
||||
type: 'array',
|
||||
items: { type: 'string' },
|
||||
type: "array",
|
||||
items: { type: "string" },
|
||||
description:
|
||||
'every non-test SOURCE file this agent modified while working this cluster, repo-relative, forward ' +
|
||||
'slashes - including cluster.file itself if it was touched, and any shared file outside the cluster ' +
|
||||
'the root-cause fix required. Test files belong in testPath (per finding), not here.',
|
||||
"every non-test SOURCE file this agent modified while working this cluster, repo-relative, forward " +
|
||||
"slashes - including cluster.file itself if it was touched, and any shared file outside the cluster " +
|
||||
"the root-cause fix required. Test files belong in testPath (per finding), not here.",
|
||||
},
|
||||
},
|
||||
}
|
||||
};
|
||||
|
||||
// ---------- phase 2: fix ----------
|
||||
phase('Fix')
|
||||
phase("Fix");
|
||||
|
||||
function fixPrompt(cluster) {
|
||||
return (
|
||||
@@ -150,65 +161,82 @@ function fixPrompt(cluster) {
|
||||
`alone, return outcome "declined" with a rationale. Do not invent a fix you do not believe in.\n` +
|
||||
` 8. If you cannot fix it for a mechanical reason (missing fixture, unclear repro), return "blocked" ` +
|
||||
`with a rationale.\n\n` +
|
||||
`Client tests run from Client/tauri-client with:\n` +
|
||||
`Client tests run from Client with:\n` +
|
||||
` NODE_OPTIONS=--no-experimental-webstorage npx vitest run <testfile>\n` +
|
||||
`Server tests run from Server with:\n` +
|
||||
` go test ./<pkg>/ -run <TestName>\n\n` +
|
||||
`Return one result per finding id, all ${cluster.ids.length} of them, plus touchedPaths.\n\n` +
|
||||
`--- FINDINGS IN ${cluster.file} ---\n${JSON.stringify(cluster.findings, null, 2)}`
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const fixOutcomes = await parallel(
|
||||
clusters.map((cluster) => () =>
|
||||
agent(fixPrompt(cluster), {
|
||||
label: `fix:${cluster.file}`,
|
||||
phase: 'Fix',
|
||||
model: 'sonnet',
|
||||
effort: 'xhigh',
|
||||
schema: FIX_RESULTS,
|
||||
}).then((r) => ({
|
||||
cluster,
|
||||
results: (r && r.results) || [],
|
||||
touchedPaths: r && Array.isArray(r.touchedPaths) ? r.touchedPaths.filter((p) => typeof p === 'string' && p) : [],
|
||||
})),
|
||||
clusters.map(
|
||||
(cluster) => () =>
|
||||
agent(fixPrompt(cluster), {
|
||||
label: `fix:${cluster.file}`,
|
||||
phase: "Fix",
|
||||
model: "sonnet",
|
||||
effort: "xhigh",
|
||||
schema: FIX_RESULTS,
|
||||
}).then((r) => ({
|
||||
cluster,
|
||||
results: (r && r.results) || [],
|
||||
touchedPaths:
|
||||
r && Array.isArray(r.touchedPaths)
|
||||
? r.touchedPaths.filter((p) => typeof p === "string" && p)
|
||||
: [],
|
||||
})),
|
||||
),
|
||||
)
|
||||
);
|
||||
|
||||
// A null slot means the agent died or threw. Its findings are blocked, its siblings are unaffected.
|
||||
const fixed = []
|
||||
const fixed = [];
|
||||
for (let i = 0; i < clusters.length; i++) {
|
||||
const cluster = clusters[i]
|
||||
const outcome = fixOutcomes[i]
|
||||
const cluster = clusters[i];
|
||||
const outcome = fixOutcomes[i];
|
||||
if (!outcome) {
|
||||
log(`fix ${cluster.file}: agent failed - ${cluster.ids.length} finding(s) blocked`)
|
||||
log(`fix ${cluster.file}: agent failed - ${cluster.ids.length} finding(s) blocked`);
|
||||
fixed.push({
|
||||
cluster,
|
||||
results: cluster.ids.map((id) => ({ id, outcome: 'blocked', testPath: '', rationale: 'fix agent failed or returned nothing' })),
|
||||
results: cluster.ids.map((id) => ({
|
||||
id,
|
||||
outcome: "blocked",
|
||||
testPath: "",
|
||||
rationale: "fix agent failed or returned nothing",
|
||||
})),
|
||||
touchedPaths: [],
|
||||
union: [cluster.file],
|
||||
})
|
||||
continue
|
||||
});
|
||||
continue;
|
||||
}
|
||||
// A hallucinated id, or one copy-pasted from a different cluster, must not merge in silently.
|
||||
const ownIds = new Set(cluster.ids)
|
||||
const ownResults = outcome.results.filter((r) => ownIds.has(r.id))
|
||||
const foreignResults = outcome.results.filter((r) => !ownIds.has(r.id))
|
||||
const ownIds = new Set(cluster.ids);
|
||||
const ownResults = outcome.results.filter((r) => ownIds.has(r.id));
|
||||
const foreignResults = outcome.results.filter((r) => !ownIds.has(r.id));
|
||||
if (foreignResults.length) {
|
||||
log(`fix ${cluster.file}: dropped ${foreignResults.length} result(s) for id(s) not in this cluster - ${foreignResults.map((r) => r.id).join(', ')}`)
|
||||
log(
|
||||
`fix ${cluster.file}: dropped ${foreignResults.length} result(s) for id(s) not in this cluster - ${foreignResults.map((r) => r.id).join(", ")}`,
|
||||
);
|
||||
}
|
||||
// An agent that skipped a finding entirely leaves it blocked rather than silently dropped.
|
||||
const reported = new Set(ownResults.map((r) => r.id))
|
||||
const reported = new Set(ownResults.map((r) => r.id));
|
||||
const missing = cluster.ids
|
||||
.filter((id) => !reported.has(id))
|
||||
.map((id) => ({ id, outcome: 'blocked', testPath: '', rationale: 'fix agent returned no result for this finding' }))
|
||||
if (missing.length) log(`fix ${cluster.file}: ${missing.length} finding(s) unreported by the agent - blocked`)
|
||||
.map((id) => ({
|
||||
id,
|
||||
outcome: "blocked",
|
||||
testPath: "",
|
||||
rationale: "fix agent returned no result for this finding",
|
||||
}));
|
||||
if (missing.length)
|
||||
log(`fix ${cluster.file}: ${missing.length} finding(s) unreported by the agent - blocked`);
|
||||
fixed.push({
|
||||
cluster,
|
||||
results: [...ownResults, ...missing],
|
||||
touchedPaths: outcome.touchedPaths,
|
||||
union: [...new Set([cluster.file, ...outcome.touchedPaths])],
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
// ---------- phase 2.5: cross-cluster overlap guard ----------
|
||||
@@ -220,82 +248,97 @@ for (let i = 0; i < clusters.length; i++) {
|
||||
// staged at all. Block both clusters rather than guess which one "owns" the shared file.
|
||||
for (let i = 0; i < fixed.length; i++) {
|
||||
for (let j = i + 1; j < fixed.length; j++) {
|
||||
const a = fixed[i]
|
||||
const b = fixed[j]
|
||||
const shared = a.union.filter((p) => b.union.includes(p))
|
||||
if (!shared.length) continue
|
||||
log(`blocked: ${a.cluster.file} and ${b.cluster.file} both touch ${shared.join(', ')} - both clusters blocked`)
|
||||
for (const [entry, other] of [[a, b], [b, a]]) {
|
||||
const a = fixed[i];
|
||||
const b = fixed[j];
|
||||
const shared = a.union.filter((p) => b.union.includes(p));
|
||||
if (!shared.length) continue;
|
||||
log(
|
||||
`blocked: ${a.cluster.file} and ${b.cluster.file} both touch ${shared.join(", ")} - both clusters blocked`,
|
||||
);
|
||||
for (const [entry, other] of [
|
||||
[a, b],
|
||||
[b, a],
|
||||
]) {
|
||||
for (const r of entry.results) {
|
||||
if (r.outcome === 'fixed') {
|
||||
r.outcome = 'blocked'
|
||||
r.rationale = `cross-cluster edit: shares ${shared.join(', ')} with ${other.cluster.file} - needs a human`
|
||||
if (r.outcome === "fixed") {
|
||||
r.outcome = "blocked";
|
||||
r.rationale = `cross-cluster edit: shares ${shared.join(", ")} with ${other.cluster.file} - needs a human`;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const allResults = fixed.flatMap((f) => f.results)
|
||||
log(`fix: ${allResults.filter((r) => r.outcome === 'fixed').length} fixed, ` +
|
||||
`${allResults.filter((r) => r.outcome === 'declined').length} declined, ` +
|
||||
`${allResults.filter((r) => r.outcome === 'blocked').length} blocked`)
|
||||
const allResults = fixed.flatMap((f) => f.results);
|
||||
log(
|
||||
`fix: ${allResults.filter((r) => r.outcome === "fixed").length} fixed, ` +
|
||||
`${allResults.filter((r) => r.outcome === "declined").length} declined, ` +
|
||||
`${allResults.filter((r) => r.outcome === "blocked").length} blocked`,
|
||||
);
|
||||
|
||||
// ---------- phase 2.6: circuit breaker (fix stage) ----------
|
||||
// A high blocked rate here means the fixing itself is failing - bad ledger coordinates, a
|
||||
// broken test runner, agents that cannot run the suite. Proving each of those costs a
|
||||
// serial agent per cluster and cannot succeed, so stop before spending it.
|
||||
if (BREAKER) {
|
||||
const attempted = allResults.filter((r) => r.outcome !== 'declined').length
|
||||
const failed = allResults.filter((r) => r.outcome === 'blocked').length
|
||||
const attempted = allResults.filter((r) => r.outcome !== "declined").length;
|
||||
const failed = allResults.filter((r) => r.outcome === "blocked").length;
|
||||
if (attempted >= BREAKER.minAttempts && failed / attempted > BREAKER.threshold) {
|
||||
breaker = {
|
||||
trippedAt: 'fix',
|
||||
trippedAt: "fix",
|
||||
attempted,
|
||||
failed,
|
||||
threshold: BREAKER.threshold,
|
||||
reason: `${failed}/${attempted} finding(s) could not be fixed - skipping prove and commit entirely`,
|
||||
}
|
||||
log(`CIRCUIT BREAKER: ${breaker.reason}`)
|
||||
};
|
||||
log(`CIRCUIT BREAKER: ${breaker.reason}`);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- phase 3: prove + commit ----------
|
||||
phase('Prove')
|
||||
phase("Prove");
|
||||
|
||||
const PROVE_RESULT = {
|
||||
type: 'object',
|
||||
required: ['committed', 'sha', 'redObserved', 'greenObserved', 'redOutput', 'greenOutput', 'note'],
|
||||
type: "object",
|
||||
required: [
|
||||
"committed",
|
||||
"sha",
|
||||
"redObserved",
|
||||
"greenObserved",
|
||||
"redOutput",
|
||||
"greenOutput",
|
||||
"note",
|
||||
],
|
||||
properties: {
|
||||
committed: { type: 'boolean' },
|
||||
sha: { type: 'string', description: 'short sha of the commit, empty when not committed' },
|
||||
redObserved: { type: 'boolean', description: 'did the tests FAIL with the source reverted' },
|
||||
greenObserved: { type: 'boolean', description: 'did the tests PASS with the fix restored' },
|
||||
committed: { type: "boolean" },
|
||||
sha: { type: "string", description: "short sha of the commit, empty when not committed" },
|
||||
redObserved: { type: "boolean", description: "did the tests FAIL with the source reverted" },
|
||||
greenObserved: { type: "boolean", description: "did the tests PASS with the fix restored" },
|
||||
redOutput: {
|
||||
type: 'string',
|
||||
type: "string",
|
||||
description:
|
||||
'the ACTUAL output of the test run performed with the source reverted (step 4), including the ' +
|
||||
'command that was run. This run must FAIL. Paste the real captured output verbatim - not a ' +
|
||||
'summary, not a paraphrase.',
|
||||
"the ACTUAL output of the test run performed with the source reverted (step 4), including the " +
|
||||
"command that was run. This run must FAIL. Paste the real captured output verbatim - not a " +
|
||||
"summary, not a paraphrase.",
|
||||
},
|
||||
greenOutput: {
|
||||
type: 'string',
|
||||
type: "string",
|
||||
description:
|
||||
'the ACTUAL output of the test run performed after the fix was restored (step 6), including the ' +
|
||||
'command that was run. This run must PASS. Paste the real captured output verbatim - not a ' +
|
||||
'summary, not a paraphrase.',
|
||||
"the ACTUAL output of the test run performed after the fix was restored (step 6), including the " +
|
||||
"command that was run. This run must PASS. Paste the real captured output verbatim - not a " +
|
||||
"summary, not a paraphrase.",
|
||||
},
|
||||
note: { type: 'string', description: 'why it was not committed, empty on success' },
|
||||
note: { type: "string", description: "why it was not committed, empty on success" },
|
||||
},
|
||||
}
|
||||
};
|
||||
|
||||
function provePrompt(cluster, fixedIds, testPaths, sourcePaths) {
|
||||
return (
|
||||
`You are proving and committing ONE cluster of fixes in the OwnCord repo ` +
|
||||
`(checked out at your current working directory - do not assume any absolute path), on branch ${BRANCH}.\n\n` +
|
||||
`Source file(s): ${sourcePaths.join(', ')}\n` +
|
||||
`Findings fixed here: ${fixedIds.join(', ')}\n` +
|
||||
`Test files written: ${testPaths.join(', ') || '(none reported)'}\n\n` +
|
||||
`Source file(s): ${sourcePaths.join(", ")}\n` +
|
||||
`Findings fixed here: ${fixedIds.join(", ")}\n` +
|
||||
`Test files written: ${testPaths.join(", ") || "(none reported)"}\n\n` +
|
||||
`You are running SERIALLY. No other agent is touching git right now, so you may use git freely.\n\n` +
|
||||
`Do exactly this, in order:\n` +
|
||||
` 1. Run: git rev-parse --abbrev-ref HEAD\n` +
|
||||
@@ -305,7 +348,7 @@ function provePrompt(cluster, fixedIds, testPaths, sourcePaths) {
|
||||
`by this agent.\n` +
|
||||
` 2. Copy the current (fixed) contents of ALL source file(s) listed above to a scratch location ` +
|
||||
`outside the repo.\n` +
|
||||
` 3. Run: git checkout HEAD -- ${sourcePaths.join(' ')}\n` +
|
||||
` 3. Run: git checkout HEAD -- ${sourcePaths.join(" ")}\n` +
|
||||
` Revert every source path listed above, and nothing else. Do NOT revert or delete the test ` +
|
||||
`files - a brand-new test file is untracked and this leaves it alone, and a new case in an existing ` +
|
||||
`test file is a modification to a path you did not name, so it survives too. Either way the new ` +
|
||||
@@ -325,27 +368,27 @@ function provePrompt(cluster, fixedIds, testPaths, sourcePaths) {
|
||||
`regenerated output can carry their hunks. A test function or comment citing a finding id not ` +
|
||||
`listed above, or a hunk in a generated/shared file unrelated to your findings, must NOT be ` +
|
||||
`committed - set committed=false, name the foreign content in note, and STOP.\n` +
|
||||
` 8. Stage ALL source file(s) listed above (git add ${sourcePaths.join(' ')}) AND the test files. ` +
|
||||
` 8. Stage ALL source file(s) listed above (git add ${sourcePaths.join(" ")}) AND the test files. ` +
|
||||
`Then check git status --porcelain for OTHER modified tracked test files in the same package(s)/` +
|
||||
`directory(ies) as your source files: a fix in this cluster may have rewritten a pre-existing test ` +
|
||||
`that locked the old behavior, or widened an interface that a fake/mock in a sibling test file must ` +
|
||||
`now implement - leaving such a companion uncommitted makes the committed branch fail or not compile ` +
|
||||
`on its own. If the modification's content belongs to THIS cluster's fix (per the step-7 check), ` +
|
||||
`stage it too; if it cites another cluster's findings, leave it. Commit with subject:\n` +
|
||||
` fix(<area>): ${fixedIds.length} defect(s) (${fixedIds.join(', ')})\n` +
|
||||
` fix(<area>): ${fixedIds.length} defect(s) (${fixedIds.join(", ")})\n` +
|
||||
` Use a conventional-commit area matching the file (voice, ws, client, identity...). Do not add a ` +
|
||||
`Co-Authored-By trailer.\n` +
|
||||
` 9. Return the short sha.\n\n` +
|
||||
`Client tests run from Client/tauri-client with:\n` +
|
||||
`Client tests run from Client with:\n` +
|
||||
` NODE_OPTIONS=--no-experimental-webstorage npx vitest run <testfile>\n` +
|
||||
`Server tests run from Server with:\n` +
|
||||
` go test ./<pkg>/ -run <TestName>`
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const commits = []
|
||||
let proveAttempts = 0
|
||||
let proveFailures = 0
|
||||
const commits = [];
|
||||
let proveAttempts = 0;
|
||||
let proveFailures = 0;
|
||||
// Serial on purpose: parallel git commands collide on .git/index.lock.
|
||||
for (const { cluster, results, union } of fixed) {
|
||||
if (breaker) {
|
||||
@@ -353,20 +396,20 @@ for (const { cluster, results, union } of fixed) {
|
||||
// from here on was never attempted; say so rather than leaving it reported as fixed,
|
||||
// which would put a `fixed` status in the ledger with no commit behind it.
|
||||
for (const r of results) {
|
||||
if (r.outcome === 'fixed') {
|
||||
r.outcome = 'blocked'
|
||||
r.rationale = `circuit breaker tripped before this cluster was attempted (${breaker.reason}); edits are uncommitted in the working tree`
|
||||
if (r.outcome === "fixed") {
|
||||
r.outcome = "blocked";
|
||||
r.rationale = `circuit breaker tripped before this cluster was attempted (${breaker.reason}); edits are uncommitted in the working tree`;
|
||||
}
|
||||
}
|
||||
continue
|
||||
continue;
|
||||
}
|
||||
const fixedHere = results.filter((r) => r.outcome === 'fixed')
|
||||
const fixedHere = results.filter((r) => r.outcome === "fixed");
|
||||
if (!fixedHere.length) {
|
||||
log(`prove ${cluster.file}: no fixes to prove - skipped`)
|
||||
continue
|
||||
log(`prove ${cluster.file}: no fixes to prove - skipped`);
|
||||
continue;
|
||||
}
|
||||
const ids = fixedHere.map((r) => r.id)
|
||||
const testPaths = [...new Set(fixedHere.map((r) => r.testPath).filter(Boolean))]
|
||||
const ids = fixedHere.map((r) => r.id);
|
||||
const testPaths = [...new Set(fixedHere.map((r) => r.testPath).filter(Boolean))];
|
||||
// A dead/thrown prove agent must not take down the sibling clusters still waiting in this
|
||||
// serial loop - same "one blocked cluster does not poison the rest" rule Phase 2 gets from
|
||||
// parallel()'s catch. Fold it into a null result so the ok/why logic below handles it uniformly.
|
||||
@@ -375,73 +418,80 @@ for (const { cluster, results, union } of fixed) {
|
||||
// regenerated-file hunk from another cluster's uncommitted work without noticing either.
|
||||
const p = await agent(provePrompt(cluster, ids, testPaths, union), {
|
||||
label: `prove:${cluster.file}`,
|
||||
phase: 'Prove',
|
||||
model: 'opus',
|
||||
effort: 'high',
|
||||
phase: "Prove",
|
||||
model: "opus",
|
||||
effort: "high",
|
||||
schema: PROVE_RESULT,
|
||||
}).catch(() => null)
|
||||
}).catch(() => null);
|
||||
|
||||
// Counted before the ok check on purpose: successes belong in the denominator. Increment
|
||||
// this inside the failure branch instead and the ratio is failures-over-failures, which is
|
||||
// always 1.0 - the breaker would trip on the first failed cluster at any threshold.
|
||||
proveAttempts++
|
||||
const ok = p && p.committed && p.redObserved && p.greenObserved && p.sha
|
||||
proveAttempts++;
|
||||
const ok = p && p.committed && p.redObserved && p.greenObserved && p.sha;
|
||||
if (!ok) {
|
||||
const why = !p
|
||||
? 'prove agent failed'
|
||||
? "prove agent failed"
|
||||
: !p.redObserved
|
||||
? `revert-proof failed: tests still passed with the fix reverted (${p.note || 'no note'})`
|
||||
? `revert-proof failed: tests still passed with the fix reverted (${p.note || "no note"})`
|
||||
: !p.greenObserved
|
||||
? `tests did not pass after restoring the fix (${p.note || 'no note'})`
|
||||
: `not committed (${p.note || 'no note'})`
|
||||
log(`prove ${cluster.file}: ${why} - ${ids.length} finding(s) blocked`)
|
||||
? `tests did not pass after restoring the fix (${p.note || "no note"})`
|
||||
: `not committed (${p.note || "no note"})`;
|
||||
log(`prove ${cluster.file}: ${why} - ${ids.length} finding(s) blocked`);
|
||||
for (const r of results) {
|
||||
if (r.outcome === 'fixed') {
|
||||
r.outcome = 'blocked'
|
||||
r.rationale = why
|
||||
if (r.outcome === "fixed") {
|
||||
r.outcome = "blocked";
|
||||
r.rationale = why;
|
||||
}
|
||||
}
|
||||
proveFailures++
|
||||
if (BREAKER && proveAttempts >= BREAKER.minAttempts && proveFailures / proveAttempts > BREAKER.threshold) {
|
||||
proveFailures++;
|
||||
if (
|
||||
BREAKER &&
|
||||
proveAttempts >= BREAKER.minAttempts &&
|
||||
proveFailures / proveAttempts > BREAKER.threshold
|
||||
) {
|
||||
breaker = {
|
||||
trippedAt: 'prove',
|
||||
trippedAt: "prove",
|
||||
attempted: proveAttempts,
|
||||
failed: proveFailures,
|
||||
threshold: BREAKER.threshold,
|
||||
reason: `${proveFailures}/${proveAttempts} cluster(s) failed their revert-proof - stopping before the rest`,
|
||||
}
|
||||
log(`CIRCUIT BREAKER: ${breaker.reason}`)
|
||||
};
|
||||
log(`CIRCUIT BREAKER: ${breaker.reason}`);
|
||||
}
|
||||
continue
|
||||
continue;
|
||||
}
|
||||
commits.push({ sha: p.sha, file: cluster.file, ids })
|
||||
log(`prove ${cluster.file}: committed ${p.sha} (${ids.join(', ')})`)
|
||||
commits.push({ sha: p.sha, file: cluster.file, ids });
|
||||
log(`prove ${cluster.file}: committed ${p.sha} (${ids.join(", ")})`);
|
||||
}
|
||||
|
||||
// ---------- phase 4: gate ----------
|
||||
const GATE_RESULT = {
|
||||
type: 'object',
|
||||
required: ['passed', 'stacks', 'output'],
|
||||
type: "object",
|
||||
required: ["passed", "stacks", "output"],
|
||||
properties: {
|
||||
passed: { type: 'boolean' },
|
||||
stacks: { type: 'array', items: { type: 'string' } },
|
||||
output: { type: 'string', description: 'the failing command and its output, or a short ok summary' },
|
||||
passed: { type: "boolean" },
|
||||
stacks: { type: "array", items: { type: "string" } },
|
||||
output: {
|
||||
type: "string",
|
||||
description: "the failing command and its output, or a short ok summary",
|
||||
},
|
||||
},
|
||||
}
|
||||
};
|
||||
|
||||
function stacksFor(files) {
|
||||
const s = new Set()
|
||||
const s = new Set();
|
||||
for (const f of files) {
|
||||
if (f.startsWith('Server/')) s.add('server')
|
||||
else if (f.startsWith('Client/tauri-client/src-tauri/')) s.add('rust')
|
||||
else if (f.startsWith('Client/')) s.add('client')
|
||||
if (f.startsWith("Server/")) s.add("server");
|
||||
else if (f.startsWith("Client/src-tauri/")) s.add("rust");
|
||||
else if (f.startsWith("Client/")) s.add("client");
|
||||
}
|
||||
return [...s]
|
||||
return [...s];
|
||||
}
|
||||
|
||||
const GATE_COMMANDS = {
|
||||
client:
|
||||
`From Client/tauri-client:\n` +
|
||||
`From Client:\n` +
|
||||
` NODE_OPTIONS=--no-experimental-webstorage npm test\n` +
|
||||
` npm run typecheck\n` +
|
||||
` npm run lint\n` +
|
||||
@@ -456,38 +506,48 @@ const GATE_COMMANDS = {
|
||||
` make sqlc-verify protocol-verify # generated output must not be stale. If make is not on PATH, ` +
|
||||
`run the equivalent commands directly instead: ` +
|
||||
`"sqlc generate && git diff --exit-code db/dbgen" and ` +
|
||||
`"go run ./scripts/genprotocol && git diff --exit-code ws/message_types.go ../Client/tauri-client/src/lib/protocolTypes.ts" ` +
|
||||
`"go run ./cmd/genprotocol && git diff --exit-code ws/message_types.go ../Client/src/lib/protocolTypes.ts" ` +
|
||||
`- a non-empty diff in either means generated code is stale and the gate fails`,
|
||||
rust:
|
||||
`From Client/tauri-client/src-tauri:\n` +
|
||||
` cargo test\n` +
|
||||
` cargo clippy --all-targets -- -D warnings`,
|
||||
}
|
||||
`From Client/src-tauri:\n` + ` cargo test\n` + ` cargo clippy --all-targets -- -D warnings`,
|
||||
};
|
||||
|
||||
let gate = null
|
||||
let gate = null;
|
||||
if (commits.length) {
|
||||
phase('Gate')
|
||||
const stacks = stacksFor(commits.map((c) => c.file))
|
||||
phase("Gate");
|
||||
const stacks = stacksFor(commits.map((c) => c.file));
|
||||
gate = await agent(
|
||||
`Run the OwnCord CI gates locally for the stacks touched by this fix run, on branch ${BRANCH}.\n\n` +
|
||||
`This runs ONCE for the whole run - a full gate per fix would take longer than the fixing did.\n\n` +
|
||||
`Touched stacks: ${stacks.join(', ')}\n\n` +
|
||||
stacks.map((s) => GATE_COMMANDS[s]).join('\n\n') +
|
||||
`Touched stacks: ${stacks.join(", ")}\n\n` +
|
||||
stacks.map((s) => GATE_COMMANDS[s]).join("\n\n") +
|
||||
`\n\nRun every command for every touched stack. Report passed=false if ANY of them fails, and put ` +
|
||||
`the failing command plus the relevant output in "output". Do NOT fix anything, do NOT amend or ` +
|
||||
`revert any commit, and do NOT push. Reporting the failure accurately is the whole job.\n\n` +
|
||||
`Known false alarm: a windows -race failure inside ws whose stack mentions runtime.scanstack or ` +
|
||||
`runtime.(*unwinder).next is a Go 1.26.5 runtime GC fault, not a real failure - rerun that package ` +
|
||||
`once before reporting it.`,
|
||||
{ label: 'gate', phase: 'Gate', model: 'sonnet', effort: 'xhigh', schema: GATE_RESULT },
|
||||
).catch(() => null)
|
||||
{ label: "gate", phase: "Gate", model: "sonnet", effort: "xhigh", schema: GATE_RESULT },
|
||||
).catch(() => null);
|
||||
// A malformed/missing report (dead agent, or a schema the caller didn't honor) is treated as a
|
||||
// failed gate, same as the null-check pattern in phases 2 and 3 - never crash on shape here.
|
||||
if (!gate || typeof gate.passed !== 'boolean' || !Array.isArray(gate.stacks))
|
||||
gate = { passed: false, stacks, output: (gate && gate.output) || 'gate agent failed to report' }
|
||||
log(`gate: ${gate.passed ? 'PASS' : 'FAIL'} (${gate.stacks.join(', ')})`)
|
||||
if (!gate || typeof gate.passed !== "boolean" || !Array.isArray(gate.stacks))
|
||||
gate = {
|
||||
passed: false,
|
||||
stacks,
|
||||
output: (gate && gate.output) || "gate agent failed to report",
|
||||
};
|
||||
log(`gate: ${gate.passed ? "PASS" : "FAIL"} (${gate.stacks.join(", ")})`);
|
||||
} else {
|
||||
log('gate: nothing committed - skipped')
|
||||
log("gate: nothing committed - skipped");
|
||||
}
|
||||
|
||||
return { branch: BRANCH, clusters: publicClusters, excluded, commits, results: allResults, gate, breaker }
|
||||
return {
|
||||
branch: BRANCH,
|
||||
clusters: publicClusters,
|
||||
excluded,
|
||||
commits,
|
||||
results: allResults,
|
||||
gate,
|
||||
breaker,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
# Editor baseline for OwnCord. Pairs with .gitattributes (`* text=auto eol=lf`)
|
||||
# and the repository Prettier config — all three agree on LF and trailing
|
||||
# newlines, so an editor that honours this file produces bytes CI accepts.
|
||||
#
|
||||
# This is a baseline, not a gate. Prettier, gofmt and rustfmt are what actually
|
||||
# fail the build; nothing lints this file.
|
||||
root = true
|
||||
|
||||
[*]
|
||||
charset = utf-8
|
||||
end_of_line = lf
|
||||
insert_final_newline = true
|
||||
trim_trailing_whitespace = true
|
||||
indent_style = space
|
||||
indent_size = 2
|
||||
|
||||
# gofmt emits tabs and is the authority for Go.
|
||||
[*.go]
|
||||
indent_style = tab
|
||||
|
||||
[{go.mod,go.sum}]
|
||||
indent_style = tab
|
||||
|
||||
# rustfmt default profile.
|
||||
[*.rs]
|
||||
indent_size = 4
|
||||
|
||||
# Recipe lines are tab-significant to make(1).
|
||||
[Makefile]
|
||||
indent_style = tab
|
||||
@@ -7,3 +7,4 @@
|
||||
*.ico binary
|
||||
*.wasm binary
|
||||
*.exe binary
|
||||
|
||||
|
||||
@@ -22,46 +22,80 @@ fail() {
|
||||
# ---------- Server (Go) ----------
|
||||
go_staged=$(printf '%s\n' "$staged" | grep '^Server/.*\.go$' | grep -v '^Server/db/dbgen/')
|
||||
if [ -n "$go_staged" ]; then
|
||||
if command -v go >/dev/null 2>&1; then
|
||||
# shellcheck disable=SC2086 — repo paths contain no spaces
|
||||
if command -v go >/dev/null 2>&1 && command -v gofmt >/dev/null 2>&1; then
|
||||
# Word splitting is intended: repo paths contain no spaces.
|
||||
# shellcheck disable=SC2086
|
||||
unformatted=$(gofmt -l $go_staged)
|
||||
[ -n "$unformatted" ] && fail "gofmt needed (run gofmt -w on): $unformatted"
|
||||
(cd Server && go vet ./...) || fail "go vet"
|
||||
else
|
||||
printf 'pre-commit: WARNING: go not installed; skipping Go checks.\n' >&2
|
||||
printf 'pre-commit: WARNING: go/gofmt not installed; skipping Go checks.\n' >&2
|
||||
fi
|
||||
fi
|
||||
|
||||
# These two blocks inline what `make sqlc-verify` / `make protocol-verify` reduce
|
||||
# to (Server/Makefile), rather than shelling out to make. `make` is not on PATH on
|
||||
# a stock Windows box, and a guard on `go`/`sqlc` does not imply it: the old code
|
||||
# probed one command and invoked another, so a contributor with Go but no make was
|
||||
# told "protocol constants are stale" when nothing had been generated or compared.
|
||||
|
||||
# sqlc inputs changed -> regenerated db/dbgen must be part of the same commit.
|
||||
if printf '%s\n' "$staged" | grep -qE '^Server/(db/queries/|migrations/|sqlc\.yaml|sqlc\.version)'; then
|
||||
if command -v sqlc >/dev/null 2>&1; then
|
||||
(cd Server && make sqlc-verify) \
|
||||
|| fail "db/dbgen is stale — run 'make sqlc-generate' in Server/ and stage the result"
|
||||
(cd Server && sqlc generate && git diff --exit-code db/dbgen) \
|
||||
|| fail "db/dbgen is stale — run 'sqlc generate' in Server/ and stage the result"
|
||||
else
|
||||
printf 'pre-commit: WARNING: sqlc not installed (make sqlc-install); CI will run sqlc-verify.\n' >&2
|
||||
printf 'pre-commit: WARNING: sqlc not installed; skipping the db/dbgen check. Install the version pinned in Server/sqlc.version. CI will run it.\n' >&2
|
||||
fi
|
||||
fi
|
||||
|
||||
# Protocol schema changed -> regenerated Go + TS constants must be in the same commit.
|
||||
if printf '%s\n' "$staged" | grep -qE '^(docs/protocol-schema\.json|Server/scripts/genprotocol/)'; then
|
||||
if printf '%s\n' "$staged" | grep -qE '^(protocol/schema\.json|Server/cmd/genprotocol/)'; then
|
||||
if command -v go >/dev/null 2>&1; then
|
||||
(cd Server && make protocol-verify) \
|
||||
|| fail "protocol constants are stale — run 'make protocol-generate' in Server/ and stage the result"
|
||||
(cd Server && go run ./cmd/genprotocol \
|
||||
&& git diff --exit-code ws/message_types.go ../Client/src/lib/protocolTypes.ts) \
|
||||
|| fail "protocol constants are stale — run 'go run ./cmd/genprotocol' in Server/ and stage the result"
|
||||
else
|
||||
printf 'pre-commit: WARNING: go not installed; skipping the protocol-constants check. CI will run it.\n' >&2
|
||||
fi
|
||||
fi
|
||||
|
||||
# ---------- Client (TypeScript) ----------
|
||||
ts_staged=$(printf '%s\n' "$staged" | grep -E '^Client/tauri-client/(src|tests)/.*\.ts$' | grep -v '/generated/')
|
||||
if [ -n "$ts_staged" ]; then
|
||||
if [ ! -d Client/tauri-client/node_modules ]; then
|
||||
printf 'pre-commit: WARNING: node_modules missing in Client/tauri-client; skipping client checks (run npm install there).\n' >&2
|
||||
# Findings ledger changed -> it must still be valid. Unlike the two blocks
|
||||
# above there is nothing to diff: FINDINGS.md is not tracked (RL-07), so a
|
||||
# stale rendering cannot be committed. --check is the whole gate here, and it
|
||||
# writes nothing. render-ledger.mjs is Node-stdlib-only, so `node` alone is the
|
||||
# probe — no node_modules guard, unlike the prettier block below.
|
||||
if printf '%s\n' "$staged" | grep -qE '^\.superpowers/(findings-ledger\.json|render-ledger\.mjs)$'; then
|
||||
if command -v node >/dev/null 2>&1; then
|
||||
node .superpowers/render-ledger.mjs --check \
|
||||
|| fail "findings-ledger.json is invalid — see the INVALID lines above"
|
||||
else
|
||||
rel=$(printf '%s\n' "$ts_staged" | sed 's|^Client/tauri-client/||')
|
||||
cd Client/tauri-client || exit 1
|
||||
printf 'pre-commit: WARNING: node not installed; skipping the ledger check. CI will run it.\n' >&2
|
||||
fi
|
||||
fi
|
||||
|
||||
# ---------- Formatting (repository-wide) ----------
|
||||
# Prettier is configured once at the repository root (.prettierrc.json) and
|
||||
# covers every material tracked source, not just client TypeScript.
|
||||
# --ignore-unknown drops the Go/Rust/binary paths it has no parser for.
|
||||
if [ -d node_modules ]; then
|
||||
# Word splitting is intended: repo paths contain no spaces.
|
||||
# shellcheck disable=SC2086
|
||||
npx prettier --check --ignore-unknown $staged || fail "prettier (run: npm run format)"
|
||||
else
|
||||
printf 'pre-commit: WARNING: node_modules missing at the repository root; skipping prettier.\n' >&2
|
||||
fi
|
||||
|
||||
# ---------- Client (TypeScript) ----------
|
||||
ts_staged=$(printf '%s\n' "$staged" | grep -E '^Client/(src|tests)/.*\.ts$' | grep -v '/generated/')
|
||||
if [ -n "$ts_staged" ]; then
|
||||
if [ ! -d Client/node_modules ]; then
|
||||
printf 'pre-commit: WARNING: node_modules missing in Client; skipping client checks (run npm install there).\n' >&2
|
||||
else
|
||||
rel=$(printf '%s\n' "$ts_staged" | sed 's|^Client/||')
|
||||
cd Client || exit 1
|
||||
# shellcheck disable=SC2086
|
||||
npx oxlint $rel || fail "oxlint"
|
||||
# shellcheck disable=SC2086
|
||||
npx prettier --check $rel || fail "prettier (run: npm run format)"
|
||||
npm run -s typecheck || fail "tsc --noEmit"
|
||||
cd "$repo_root" || exit 1
|
||||
fi
|
||||
|
||||
@@ -15,9 +15,34 @@ fail() {
|
||||
exit 1
|
||||
}
|
||||
|
||||
# What changed relative to origin/main decides which side's gates run.
|
||||
# What changed relative to this branch's base decides which side's gates run.
|
||||
#
|
||||
# The base is whichever of origin/dev, origin/main is NEAREST — the one with the
|
||||
# fewest commits between its merge-base and HEAD. A feature branch cut from dev
|
||||
# picks dev; dev itself scores 0 against dev (nothing to compare) and so picks
|
||||
# main, which is right for a dev -> main release PR. Hardcoding origin/main got
|
||||
# the first case wrong once dev became the integration branch: everything on dev
|
||||
# and not yet on main counted as "changed", so both sides' gates ran every time.
|
||||
#
|
||||
# Markdown/docs changes never trigger builds.
|
||||
changed=$(git diff --name-only origin/main...HEAD 2>/dev/null) || changed="__all__"
|
||||
base=""
|
||||
best=""
|
||||
for cand in origin/dev origin/main; do
|
||||
git rev-parse --verify -q "$cand" >/dev/null 2>&1 || continue
|
||||
mb=$(git merge-base "$cand" HEAD 2>/dev/null) || continue
|
||||
n=$(git rev-list --count "$mb..HEAD" 2>/dev/null) || continue
|
||||
[ "$n" -eq 0 ] && continue
|
||||
if [ -z "$best" ] || [ "$n" -lt "$best" ]; then
|
||||
base=$cand
|
||||
best=$n
|
||||
fi
|
||||
done
|
||||
|
||||
if [ -n "$base" ]; then
|
||||
changed=$(git diff --name-only "$base...HEAD" 2>/dev/null) || changed="__all__"
|
||||
else
|
||||
changed="__all__"
|
||||
fi
|
||||
[ "$changed" = "__all__" ] || changed=$(printf '%s\n' "$changed" | grep -v '\.md$')
|
||||
[ -z "$changed" ] && exit 0
|
||||
|
||||
@@ -28,8 +53,8 @@ if [ "$changed" = "__all__" ]; then
|
||||
client_changed=1
|
||||
else
|
||||
if printf '%s\n' "$changed" | grep -q '^Server/'; then server_changed=1; fi
|
||||
if printf '%s\n' "$changed" | grep -q '^Client/tauri-client/'; then client_changed=1; fi
|
||||
if printf '%s\n' "$changed" | grep -q '^docs/protocol-schema\.json'; then
|
||||
if printf '%s\n' "$changed" | grep -q '^Client/'; then client_changed=1; fi
|
||||
if printf '%s\n' "$changed" | grep -q '^protocol/schema\.json'; then
|
||||
server_changed=1
|
||||
client_changed=1
|
||||
fi
|
||||
@@ -49,12 +74,12 @@ if [ "$server_changed" = 1 ] && command -v go >/dev/null 2>&1; then
|
||||
fi
|
||||
|
||||
if [ "$client_changed" = 1 ]; then
|
||||
if [ -d Client/tauri-client/node_modules ]; then
|
||||
if [ -d Client/node_modules ]; then
|
||||
echo "pre-push: client typecheck + eslint..."
|
||||
(cd Client/tauri-client && npm run -s typecheck) || fail "tsc --noEmit"
|
||||
(cd Client/tauri-client && npx eslint src/) || fail "eslint"
|
||||
(cd Client && npm run -s typecheck) || fail "tsc --noEmit"
|
||||
(cd Client && npx eslint src/) || fail "eslint"
|
||||
else
|
||||
printf 'pre-push: WARNING: node_modules missing in Client/tauri-client; skipping client checks.\n' >&2
|
||||
printf 'pre-push: WARNING: node_modules missing in Client; skipping client checks.\n' >&2
|
||||
fi
|
||||
fi
|
||||
|
||||
|
||||
@@ -1,34 +0,0 @@
|
||||
---
|
||||
name: Bug Report
|
||||
about: Report a bug in OwnCord
|
||||
title: "bug: "
|
||||
labels: bug
|
||||
---
|
||||
|
||||
## Description
|
||||
|
||||
<!-- Clear description of the bug -->
|
||||
|
||||
## Steps to Reproduce
|
||||
|
||||
1.
|
||||
2.
|
||||
3.
|
||||
|
||||
## Expected Behavior
|
||||
|
||||
<!-- What should happen -->
|
||||
|
||||
## Actual Behavior
|
||||
|
||||
<!-- What actually happens -->
|
||||
|
||||
## Environment
|
||||
|
||||
- **OS**: Windows 11 (version)
|
||||
- **OwnCord Version**:
|
||||
- **Component**: Server / Client / Both
|
||||
|
||||
## Screenshots / Logs
|
||||
|
||||
<!-- Paste relevant logs or screenshots -->
|
||||
@@ -0,0 +1,169 @@
|
||||
# A YAML issue form, not a Markdown template: only this format can mark a field
|
||||
# required, so the environment detail a maintainer needs to reproduce a bug
|
||||
# arrives with the report instead of after a round trip.
|
||||
#
|
||||
# Nothing in this repository validates this file's schema — prettier checks it
|
||||
# parses as YAML and actionlint does not read it. A form that is valid YAML but
|
||||
# an invalid issue form silently stops appearing in the chooser, so changes here
|
||||
# want a look at the live "New issue" page afterwards.
|
||||
name: Bug report
|
||||
description: Something in the server, desktop client, or admin panel is broken.
|
||||
title: "bug: "
|
||||
labels: ["bug"]
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
**Do not report security vulnerabilities here.** Use
|
||||
[private security reporting](https://github.com/J3vb/OwnCord/security/advisories/new)
|
||||
instead — a public issue discloses the problem before there is a fix.
|
||||
|
||||
Questions, ideas and feedback belong in
|
||||
[Discussions](https://github.com/J3vb/OwnCord/discussions), not here.
|
||||
|
||||
- type: textarea
|
||||
id: what-happened
|
||||
attributes:
|
||||
label: What happened
|
||||
description: What went wrong, and what you expected instead.
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: repro
|
||||
attributes:
|
||||
label: Steps to reproduce
|
||||
description: Numbered steps from a known starting state. A bug nobody can reproduce cannot be fixed.
|
||||
placeholder: |
|
||||
1. Start the server with …
|
||||
2. In the client, open …
|
||||
3. …
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: dropdown
|
||||
id: component
|
||||
attributes:
|
||||
label: Component
|
||||
options:
|
||||
- Server
|
||||
- Desktop client
|
||||
- Admin panel
|
||||
- Both server and client
|
||||
- Not sure
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: input
|
||||
id: server-version
|
||||
attributes:
|
||||
label: Server version
|
||||
description: >-
|
||||
Admin panel → Updates, or the banner the server prints at startup. It is
|
||||
deliberately not exposed on the unauthenticated /health endpoint, so
|
||||
"unknown" is a fine answer if you are not the operator. A server built
|
||||
from source reports "dev".
|
||||
placeholder: "1.2.0-alpha.4 / dev / unknown"
|
||||
validations:
|
||||
required: false
|
||||
|
||||
- type: input
|
||||
id: client-version
|
||||
attributes:
|
||||
label: Client version
|
||||
description: Settings → Logs shows it. Leave blank for a server-only bug.
|
||||
placeholder: "1.2.0-alpha.4"
|
||||
validations:
|
||||
required: false
|
||||
|
||||
- type: dropdown
|
||||
id: os
|
||||
attributes:
|
||||
label: Operating system
|
||||
options:
|
||||
- Windows 10
|
||||
- Windows 11
|
||||
- Linux
|
||||
- Other
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: dropdown
|
||||
id: arch
|
||||
attributes:
|
||||
label: CPU architecture
|
||||
description: ARM64 currently applies to the Linux desktop client; there is no ARM64 server release yet.
|
||||
options:
|
||||
- x64
|
||||
- ARM64 (aarch64)
|
||||
- Not sure
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: dropdown
|
||||
id: deployment
|
||||
attributes:
|
||||
label: How is the server deployed
|
||||
options:
|
||||
- Prebuilt binary (Windows)
|
||||
- Prebuilt binary (Linux)
|
||||
- Built from source
|
||||
- Docker / Compose
|
||||
- Linux systemd service
|
||||
- Windows service (NSSM or Task Scheduler)
|
||||
- Not applicable — client-only bug
|
||||
- Not sure
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: dropdown
|
||||
id: tls-mode
|
||||
attributes:
|
||||
label: TLS mode
|
||||
description: The `tls.mode` setting in config.yaml.
|
||||
options:
|
||||
- self_signed
|
||||
- acme
|
||||
- manual
|
||||
- "off"
|
||||
- Not applicable / not sure
|
||||
validations:
|
||||
required: false
|
||||
|
||||
- type: dropdown
|
||||
id: topology
|
||||
attributes:
|
||||
label: How do clients reach the server
|
||||
options:
|
||||
- Same machine or LAN, direct
|
||||
- Port forwarding to a public IP
|
||||
- Behind a reverse proxy
|
||||
- Tailscale
|
||||
- Not sure
|
||||
validations:
|
||||
required: false
|
||||
|
||||
- type: dropdown
|
||||
id: webview
|
||||
attributes:
|
||||
label: Client webview
|
||||
description: >-
|
||||
The desktop client renders through the OS webview — WebView2 on Windows,
|
||||
WebKitGTK on Linux — so rendering and networking bugs often depend on it.
|
||||
Skip this for a server-only bug.
|
||||
options:
|
||||
- WebView2 (Windows)
|
||||
- WebKitGTK (Linux)
|
||||
- Not applicable / not sure
|
||||
validations:
|
||||
required: false
|
||||
|
||||
- type: textarea
|
||||
id: logs
|
||||
attributes:
|
||||
label: Logs, screenshots, or anything else
|
||||
description: >-
|
||||
Server console output or Settings → Logs from the client. Redact tokens,
|
||||
invite codes and anything else you would not post publicly.
|
||||
validations:
|
||||
required: false
|
||||
@@ -1,5 +1,23 @@
|
||||
# Issues are the bug tracker only. Ideas, questions and feedback go to
|
||||
# Discussions; vulnerabilities go to private security reporting. Keeping
|
||||
# blank_issues_enabled false is what makes that routing hold — a blank issue
|
||||
# bypasses every form and every warning on it.
|
||||
#
|
||||
# The ?category= slugs must match this repository's actual Discussions
|
||||
# categories. A slug that does not exist silently drops the user on the category
|
||||
# picker rather than erroring, so check the live Discussions tab after changing
|
||||
# one.
|
||||
blank_issues_enabled: false
|
||||
contact_links:
|
||||
- name: Community Support
|
||||
- name: Report a security vulnerability
|
||||
url: https://github.com/J3vb/OwnCord/security/advisories/new
|
||||
about: Private disclosure. Never open a public issue for a security bug.
|
||||
- name: Ask a question
|
||||
url: https://github.com/J3vb/OwnCord/discussions/categories/q-a
|
||||
about: Setup, deployment and usage questions.
|
||||
- name: Suggest an idea
|
||||
url: https://github.com/J3vb/OwnCord/discussions/categories/ideas
|
||||
about: Feature requests and design suggestions start here, not as issues.
|
||||
- name: General discussion and feedback
|
||||
url: https://github.com/J3vb/OwnCord/discussions
|
||||
about: Ask questions and get help from the community
|
||||
about: Anything that is not a reproducible bug.
|
||||
|
||||
@@ -1,22 +0,0 @@
|
||||
---
|
||||
name: Feature Request
|
||||
about: Suggest a new feature for OwnCord
|
||||
title: "feat: "
|
||||
labels: enhancement
|
||||
---
|
||||
|
||||
## Problem
|
||||
|
||||
<!-- What problem does this solve? -->
|
||||
|
||||
## Proposed Solution
|
||||
|
||||
<!-- How should it work? -->
|
||||
|
||||
## Alternatives Considered
|
||||
|
||||
<!-- Other approaches you thought about -->
|
||||
|
||||
## Additional Context
|
||||
|
||||
<!-- Mockups, links, or related issues -->
|
||||
@@ -1,5 +1,10 @@
|
||||
# Pull Request
|
||||
|
||||
<!-- Base branch: PRs target `dev`, not `main`. `main` carries releases only.
|
||||
See docs/contributing.md#branch-and-pr-model. The Docker and Tauri Full
|
||||
Build jobs are gated on `main` and report as skipped here — that is
|
||||
expected. -->
|
||||
|
||||
## Summary
|
||||
|
||||
<!-- What does this PR do? 1-3 bullet points -->
|
||||
@@ -14,14 +19,31 @@
|
||||
|
||||
## Test Plan
|
||||
|
||||
- [ ] Unit tests pass (`npm test` / `go test ./...`)
|
||||
- [ ] TypeScript check passes (`npx tsc --noEmit`)
|
||||
- [ ] `npm run check` passes from the repository root — the one entry point that
|
||||
runs what CI gates on. `check:server` / `check:client` / `check:rust` /
|
||||
`check:hygiene` / `check:docs` run a single stack if that is all you touched
|
||||
- [ ] Manual testing done (describe below)
|
||||
- [ ] Generated files were regenerated, not hand-edited — `Server/db/dbgen/`,
|
||||
`Server/ws/message_types.go`, `Client/src/lib/protocolTypes.ts`,
|
||||
`Client/src/generated/`, `.superpowers/FINDINGS.md`. CI fails on drift
|
||||
- [ ] Docs updated — anything under `docs/architecture/` (incl. `ux/`) whose
|
||||
"Source of truth" files this PR touches is updated in the same PR
|
||||
(their maintenance rule), and reference docs (`api.md`, `protocol.md`,
|
||||
`schema.md`, `server-configuration.md`) reflect any surface changes
|
||||
|
||||
## Scope
|
||||
|
||||
<!-- What adjacent work did you deliberately leave out, and why? A written
|
||||
deferral is a deliverable — see docs/contributing.md#commit-format. -->
|
||||
|
||||
Not included:
|
||||
|
||||
> **No security detail in this PR.** This repository is public, so the
|
||||
> description, the commits and the branch name are all disclosure channels. If
|
||||
> this change repairs a vulnerability, report it through
|
||||
> [private security reporting](https://github.com/J3vb/OwnCord/security/advisories/new)
|
||||
> first and describe only the control this PR adds.
|
||||
|
||||
## Screenshots
|
||||
|
||||
<!-- If UI changes, add before/after screenshots -->
|
||||
|
||||
@@ -14,6 +14,13 @@ version: 2
|
||||
# Majors are ignored everywhere below, so each group only ever carries patch and
|
||||
# minor updates. If one member of a group is bad, add it to that ecosystem's
|
||||
# ignore list rather than ungrouping the rest.
|
||||
#
|
||||
# Each package root gets its own block rather than one block with `directories:`.
|
||||
# Grouping only works because a group rewrites exactly one lockfile; a block
|
||||
# spanning roots would put several lockfiles in one PR and reintroduce the very
|
||||
# conflict the grouping prevents. The three npm roots stay separate for the same
|
||||
# reason — see docs/contributing.md#dependency-policy for the measured decision
|
||||
# against adopting npm workspaces.
|
||||
|
||||
updates:
|
||||
# Go server dependencies
|
||||
@@ -36,9 +43,32 @@ updates:
|
||||
- dependency-name: "*"
|
||||
update-types: ["version-update:semver-major"]
|
||||
|
||||
# Server container base images (Server/Dockerfile). The builder's
|
||||
# `golang:1.26-bookworm` tracks the toolchain in Server/go.mod and every
|
||||
# `actions/setup-go` in CI, so a minor bump here is a signal to move all three
|
||||
# together — not a standalone merge.
|
||||
- package-ecosystem: docker
|
||||
directory: /Server
|
||||
schedule:
|
||||
interval: weekly
|
||||
day: monday
|
||||
commit-message:
|
||||
prefix: "chore(deps):"
|
||||
labels:
|
||||
- dependencies
|
||||
- docker
|
||||
open-pull-requests-limit: 5
|
||||
groups:
|
||||
docker-dependencies:
|
||||
patterns:
|
||||
- "*"
|
||||
ignore:
|
||||
- dependency-name: "*"
|
||||
update-types: ["version-update:semver-major"]
|
||||
|
||||
# Tauri client npm dependencies
|
||||
- package-ecosystem: npm
|
||||
directory: /Client/tauri-client
|
||||
directory: /Client
|
||||
schedule:
|
||||
interval: weekly
|
||||
day: monday
|
||||
@@ -56,9 +86,49 @@ updates:
|
||||
- dependency-name: "*"
|
||||
update-types: ["version-update:semver-major"]
|
||||
|
||||
# Root tooling npm dependencies (changelogen, prettier)
|
||||
- package-ecosystem: npm
|
||||
directory: /
|
||||
schedule:
|
||||
interval: weekly
|
||||
day: monday
|
||||
commit-message:
|
||||
prefix: "chore(deps):"
|
||||
labels:
|
||||
- dependencies
|
||||
- npm
|
||||
open-pull-requests-limit: 5
|
||||
groups:
|
||||
root-npm-dependencies:
|
||||
patterns:
|
||||
- "*"
|
||||
ignore:
|
||||
- dependency-name: "*"
|
||||
update-types: ["version-update:semver-major"]
|
||||
|
||||
# tools/mcp-introspect npm dependencies (local dev MCP server)
|
||||
- package-ecosystem: npm
|
||||
directory: /tools/mcp-introspect
|
||||
schedule:
|
||||
interval: weekly
|
||||
day: monday
|
||||
commit-message:
|
||||
prefix: "chore(deps):"
|
||||
labels:
|
||||
- dependencies
|
||||
- npm
|
||||
open-pull-requests-limit: 5
|
||||
groups:
|
||||
mcp-introspect-dependencies:
|
||||
patterns:
|
||||
- "*"
|
||||
ignore:
|
||||
- dependency-name: "*"
|
||||
update-types: ["version-update:semver-major"]
|
||||
|
||||
# Tauri Rust/Cargo dependencies
|
||||
- package-ecosystem: cargo
|
||||
directory: /Client/tauri-client/src-tauri
|
||||
directory: /Client/src-tauri
|
||||
schedule:
|
||||
interval: weekly
|
||||
day: monday
|
||||
@@ -75,6 +145,18 @@ updates:
|
||||
ignore:
|
||||
- dependency-name: "*"
|
||||
update-types: ["version-update:semver-major"]
|
||||
# rfd rides into the tree on tauri-plugin-dialog, which pins ^0.16, and we
|
||||
# declare it directly only for the fatal-startup dialog in lib.rs (there is
|
||||
# no AppHandle yet, so the plugin API is unusable at that point). Cargo
|
||||
# unifies features only within a semver-compatible group, so bumping our
|
||||
# direct dep to 0.17 forks rfd in two: the plugin keeps 0.16 with its
|
||||
# backend features, ours gets 0.17 with none, and rfd 0.17's build.rs then
|
||||
# aborts the Linux build demanding `gtk3` or `xdg-portal` (PR #1405). Even
|
||||
# where it links, it just builds rfd twice. Our version must track the
|
||||
# plugin's -- drop this entry once tauri-plugin-dialog moves to 0.17.
|
||||
# Patch updates within 0.16.x still flow through.
|
||||
- dependency-name: "rfd"
|
||||
update-types: ["version-update:semver-minor"]
|
||||
|
||||
# GitHub Actions
|
||||
- package-ecosystem: github-actions
|
||||
|
||||
@@ -64,7 +64,7 @@ jobs:
|
||||
run: make sqlc-install sqlc-verify
|
||||
|
||||
# Protocol message-type constants (Go + TS) must never drift from
|
||||
# docs/protocol-schema.json — the single source of truth.
|
||||
# protocol/schema.json — the single source of truth.
|
||||
- name: Verify generated protocol constants (make protocol-verify)
|
||||
if: matrix.os == 'ubuntu-latest'
|
||||
run: make protocol-verify
|
||||
@@ -119,15 +119,15 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
defaults:
|
||||
run:
|
||||
working-directory: Client/tauri-client/
|
||||
working-directory: Client/
|
||||
steps:
|
||||
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
|
||||
|
||||
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||
with:
|
||||
node-version: 20
|
||||
node-version: 24
|
||||
cache: npm
|
||||
cache-dependency-path: Client/tauri-client/package-lock.json
|
||||
cache-dependency-path: Client/package-lock.json
|
||||
|
||||
- name: Install npm dependencies
|
||||
run: npm ci
|
||||
@@ -152,16 +152,13 @@ jobs:
|
||||
|
||||
- name: TypeScript check (Playwright specs)
|
||||
# The main tsconfig excludes tests/e2e from the app graph; this
|
||||
# project typechecks the 47 spec files + fixtures + the three
|
||||
# project typechecks every tests/e2e spec + fixtures + the
|
||||
# playwright configs so type rot cannot hide there.
|
||||
run: npx tsc -p tsconfig.e2e.json --noEmit
|
||||
|
||||
- name: ESLint (type-aware rules)
|
||||
run: npx eslint src/
|
||||
|
||||
- name: Prettier format check
|
||||
run: npx prettier --check "src/**/*.ts" "tests/**/*.ts"
|
||||
|
||||
- name: Knip (unused code & deps)
|
||||
# Blocking since the 2026-08-04 remediation: the '|| true' era let a
|
||||
# real unused-export finding sit invisible in every green run.
|
||||
@@ -172,20 +169,131 @@ jobs:
|
||||
# and must stay green — never "fix" a failing test by editing its assertions.
|
||||
# ubuntu-latest for the same reason as client-check above: jsdom-only vitest
|
||||
# with no platform-conditional code under test.
|
||||
client-tests:
|
||||
name: Client Unit Tests
|
||||
# The automated half of G-04: a planning document that states a finding count
|
||||
# the ledger contradicts fails here instead of quietly misleading a reader.
|
||||
# Deliberately tiny — no npm ci, because the script imports nothing outside
|
||||
# node:. It also runs its own selftest, since the whole check rests on
|
||||
# patterns narrow enough not to cry wolf.
|
||||
docs-consistency:
|
||||
name: Docs & Ledger Consistency
|
||||
runs-on: ubuntu-latest
|
||||
defaults:
|
||||
run:
|
||||
working-directory: Client/tauri-client/
|
||||
steps:
|
||||
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
|
||||
|
||||
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||
with:
|
||||
node-version: 20
|
||||
node-version: 24
|
||||
|
||||
- name: Self-test the count matcher
|
||||
run: node scripts/check-doc-counts.mjs --selftest
|
||||
|
||||
- name: Documents must agree with the findings ledger
|
||||
run: node scripts/check-doc-counts.mjs
|
||||
|
||||
- name: Ledger schema is valid
|
||||
run: node .superpowers/render-ledger.mjs --check
|
||||
|
||||
# R-09 / RL-16. The release gate itself is only invoked for real at tag
|
||||
# time, which is the wrong place to find a bug in it — so its decision
|
||||
# logic is exercised here, on every pull request, against fixtures. Same
|
||||
# reason Server/scripts/docker-smoke.sh is called from both workflows.
|
||||
# This also parses the required-check list out of
|
||||
# b0-dev-branch-protection.sh, so a change to that list's shape fails here
|
||||
# rather than silently weakening the gate.
|
||||
- name: Self-test the release gate
|
||||
run: node scripts/verify-gate-evidence.mjs --selftest
|
||||
|
||||
# RL-07. FINDINGS.md is not tracked, so it cannot drift -- but L-07 also
|
||||
# asks that the rendering be reproducible and that CI reject a generation
|
||||
# failure. Rendering twice and comparing tests both: the render must
|
||||
# succeed (it validates and exits 1 before writing), and it must be a pure
|
||||
# function of the ledger. The severity rule in validate() is what makes
|
||||
# the second half true -- an unranked severity would make render()'s sort
|
||||
# implementation-defined.
|
||||
- name: FINDINGS.md renders, and renders identically twice
|
||||
run: |
|
||||
node .superpowers/render-ledger.mjs
|
||||
cp .superpowers/FINDINGS.md "$RUNNER_TEMP/FINDINGS.first.md"
|
||||
node .superpowers/render-ledger.mjs
|
||||
cmp "$RUNNER_TEMP/FINDINGS.first.md" .superpowers/FINDINGS.md || {
|
||||
echo "ERROR: rendering the ledger twice produced different output."
|
||||
echo "render() must be a pure function of findings-ledger.json."
|
||||
exit 1
|
||||
}
|
||||
|
||||
# The rendering is the human-readable view and is deliberately untracked,
|
||||
# so this artifact is how a reviewer reads it without a Node run.
|
||||
# if: always() -- you want it downloadable precisely when the job failed.
|
||||
- name: Upload the rendered findings ledger
|
||||
if: always()
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||
with:
|
||||
name: findings-ledger-rendering
|
||||
path: .superpowers/FINDINGS.md
|
||||
retention-days: 7
|
||||
|
||||
# Repository-wide formatting, script lint and workflow lint (RL-19 / L-13, S-05).
|
||||
#
|
||||
# Root-scoped and ubuntu-only for the same reason as docs-consistency above:
|
||||
# every gate here is platform-independent text analysis, and .gitattributes
|
||||
# pins eol=lf so a second OS would only re-prove line endings.
|
||||
#
|
||||
# Prettier lives here rather than in client-check because it is no longer a
|
||||
# client gate -- one config at the repository root covers Markdown, YAML,
|
||||
# JSON, CSS and the root scripts as well as client TypeScript.
|
||||
hygiene:
|
||||
name: Repository Hygiene
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
|
||||
|
||||
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||
with:
|
||||
node-version: 24
|
||||
|
||||
# Root install only -- prettier is the sole dependency this job needs, and
|
||||
# the client's install is client-check's job.
|
||||
- name: Install root dependencies
|
||||
run: npm ci
|
||||
|
||||
# shellcheck ships in the ubuntu runner image. actionlint does not, so it
|
||||
# is pinned by version and checked by digest: an unpinned installer script
|
||||
# would be the one unverified download in a workflow file that pins every
|
||||
# action by commit SHA.
|
||||
- name: Install actionlint
|
||||
env:
|
||||
ACTIONLINT_VERSION: 1.7.7
|
||||
ACTIONLINT_SHA256: 023070a287cd8cccd71515fedc843f1985bf96c436b7effaecce67290e7e0757
|
||||
run: |
|
||||
set -euo pipefail
|
||||
url="https://github.com/rhysd/actionlint/releases/download/v${ACTIONLINT_VERSION}/actionlint_${ACTIONLINT_VERSION}_linux_amd64.tar.gz"
|
||||
curl -sSfL --retry 3 -o "$RUNNER_TEMP/actionlint.tar.gz" "$url"
|
||||
echo "$ACTIONLINT_SHA256 $RUNNER_TEMP/actionlint.tar.gz" | sha256sum -c -
|
||||
tar -xzf "$RUNNER_TEMP/actionlint.tar.gz" -C "$RUNNER_TEMP" actionlint
|
||||
echo "$RUNNER_TEMP" >> "$GITHUB_PATH"
|
||||
|
||||
- name: Report tool versions
|
||||
run: shellcheck --version && actionlint --version
|
||||
|
||||
# The same entry point a contributor runs. run.mjs takes its shellcheck and
|
||||
# actionlint file lists from `git ls-files`, never a filesystem glob.
|
||||
- name: Formatting, shell and workflow gates
|
||||
run: npm run check:hygiene
|
||||
|
||||
client-tests:
|
||||
name: Client Unit Tests
|
||||
runs-on: ubuntu-latest
|
||||
defaults:
|
||||
run:
|
||||
working-directory: Client/
|
||||
steps:
|
||||
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
|
||||
|
||||
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||
with:
|
||||
node-version: 24
|
||||
cache: npm
|
||||
cache-dependency-path: Client/tauri-client/package-lock.json
|
||||
cache-dependency-path: Client/package-lock.json
|
||||
|
||||
- name: Install npm dependencies
|
||||
run: npm ci
|
||||
@@ -198,7 +306,7 @@ jobs:
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||
with:
|
||||
name: client-coverage
|
||||
path: Client/tauri-client/coverage/
|
||||
path: Client/coverage/
|
||||
retention-days: 7
|
||||
|
||||
# Rust unit tests used to live inside tauri-build, which only runs on PRs to
|
||||
@@ -211,7 +319,7 @@ jobs:
|
||||
timeout-minutes: 30
|
||||
defaults:
|
||||
run:
|
||||
working-directory: Client/tauri-client/src-tauri/
|
||||
working-directory: Client/src-tauri/
|
||||
steps:
|
||||
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
|
||||
|
||||
@@ -231,12 +339,17 @@ jobs:
|
||||
- name: Install Rust
|
||||
uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable
|
||||
with:
|
||||
components: clippy
|
||||
components: clippy, rustfmt
|
||||
|
||||
- name: Rust cache
|
||||
uses: swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
|
||||
with:
|
||||
workspaces: Client/tauri-client/src-tauri
|
||||
workspaces: Client/src-tauri
|
||||
|
||||
# Ahead of clippy: a formatting failure is cheap to produce and cheap to
|
||||
# fix, and there is no reason to spend a clippy pass to surface one.
|
||||
- name: Rustfmt check
|
||||
run: cargo fmt --all -- --check
|
||||
|
||||
- name: Clippy lint (including test targets)
|
||||
run: cargo clippy --all-targets -- -D warnings
|
||||
@@ -264,15 +377,15 @@ jobs:
|
||||
timeout-minutes: 25
|
||||
defaults:
|
||||
run:
|
||||
working-directory: Client/tauri-client/
|
||||
working-directory: Client/
|
||||
steps:
|
||||
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
|
||||
|
||||
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||
with:
|
||||
node-version: 20
|
||||
node-version: 24
|
||||
cache: npm
|
||||
cache-dependency-path: Client/tauri-client/package-lock.json
|
||||
cache-dependency-path: Client/package-lock.json
|
||||
|
||||
- name: Install npm dependencies
|
||||
run: npm ci
|
||||
@@ -283,14 +396,20 @@ jobs:
|
||||
- name: Run Playwright tests
|
||||
run: npx playwright test --config=playwright.config.ts
|
||||
|
||||
# Browser-mode unit tests (tests/browser/): real AudioContext + WASM
|
||||
# behind the same Chromium, so the noise-suppression pipeline has a
|
||||
# test that actually runs somewhere (test-audit 2026-08-19, T-22).
|
||||
- name: Run browser-mode unit tests
|
||||
run: npm run test:browser
|
||||
|
||||
- name: Upload Playwright report
|
||||
if: always()
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||
with:
|
||||
name: playwright-report
|
||||
path: |
|
||||
Client/tauri-client/playwright-report/
|
||||
Client/tauri-client/test-results/
|
||||
Client/playwright-report/
|
||||
Client/test-results/
|
||||
retention-days: 7
|
||||
|
||||
# Admin-panel journey against a REAL server (no mocks): start-server.sh
|
||||
@@ -312,7 +431,7 @@ jobs:
|
||||
timeout-minutes: 20
|
||||
defaults:
|
||||
run:
|
||||
working-directory: Client/tauri-client/
|
||||
working-directory: Client/
|
||||
steps:
|
||||
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
|
||||
|
||||
@@ -323,9 +442,9 @@ jobs:
|
||||
|
||||
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||
with:
|
||||
node-version: 20
|
||||
node-version: 24
|
||||
cache: npm
|
||||
cache-dependency-path: Client/tauri-client/package-lock.json
|
||||
cache-dependency-path: Client/package-lock.json
|
||||
|
||||
- name: Install npm dependencies
|
||||
run: npm ci
|
||||
@@ -342,8 +461,8 @@ jobs:
|
||||
with:
|
||||
name: admin-e2e-report
|
||||
path: |
|
||||
Client/tauri-client/playwright-report/
|
||||
Client/tauri-client/test-results/
|
||||
Client/playwright-report/
|
||||
Client/test-results/
|
||||
retention-days: 7
|
||||
|
||||
# Blocking e2e subset: the parity-feature specs (tagged "@parity"), covering
|
||||
@@ -359,15 +478,15 @@ jobs:
|
||||
timeout-minutes: 15
|
||||
defaults:
|
||||
run:
|
||||
working-directory: Client/tauri-client/
|
||||
working-directory: Client/
|
||||
steps:
|
||||
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
|
||||
|
||||
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||
with:
|
||||
node-version: 20
|
||||
node-version: 24
|
||||
cache: npm
|
||||
cache-dependency-path: Client/tauri-client/package-lock.json
|
||||
cache-dependency-path: Client/package-lock.json
|
||||
|
||||
- name: Install npm dependencies
|
||||
run: npm ci
|
||||
@@ -384,8 +503,8 @@ jobs:
|
||||
with:
|
||||
name: playwright-report-parity
|
||||
path: |
|
||||
Client/tauri-client/playwright-report/
|
||||
Client/tauri-client/test-results/
|
||||
Client/playwright-report/
|
||||
Client/test-results/
|
||||
retention-days: 7
|
||||
|
||||
# Image build is verification only, so it is skipped on dev to keep day-to-day
|
||||
@@ -451,15 +570,15 @@ jobs:
|
||||
runs-on: ${{ matrix.os }}
|
||||
defaults:
|
||||
run:
|
||||
working-directory: Client/tauri-client/
|
||||
working-directory: Client/
|
||||
steps:
|
||||
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
|
||||
|
||||
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||
with:
|
||||
node-version: 20
|
||||
node-version: 24
|
||||
cache: npm
|
||||
cache-dependency-path: Client/tauri-client/package-lock.json
|
||||
cache-dependency-path: Client/package-lock.json
|
||||
|
||||
- name: Install Linux system dependencies
|
||||
if: startsWith(matrix.os, 'ubuntu')
|
||||
@@ -485,20 +604,20 @@ jobs:
|
||||
- name: Rust cache
|
||||
uses: swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
|
||||
with:
|
||||
workspaces: Client/tauri-client/src-tauri
|
||||
workspaces: Client/src-tauri
|
||||
|
||||
- name: Install npm dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Clippy lint (Rust)
|
||||
working-directory: Client/tauri-client/src-tauri/
|
||||
working-directory: Client/src-tauri/
|
||||
run: cargo clippy -- -D warnings
|
||||
|
||||
# Rust unit tests moved to the standalone `rust-tests` job so they run on
|
||||
# every event, not just PRs to main.
|
||||
|
||||
- name: Security audit (Rust dependencies)
|
||||
working-directory: Client/tauri-client/src-tauri/
|
||||
working-directory: Client/src-tauri/
|
||||
run: |
|
||||
cargo install cargo-audit@0.22.1 --quiet
|
||||
cargo audit
|
||||
|
||||
@@ -10,14 +10,41 @@ on:
|
||||
pull_request_review:
|
||||
types: [submitted]
|
||||
|
||||
# Repeated triggers on one issue or pull request collapse into a single run
|
||||
# rather than fanning out. `github.event.issue.number` is present on the issues
|
||||
# and issue_comment events; `github.event.pull_request.number` on the two review
|
||||
# events. Exactly one of the two is non-empty per event, so the group is stable.
|
||||
concurrency:
|
||||
group: claude-${{ github.event.issue.number || github.event.pull_request.number }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
claude:
|
||||
# Two independent conditions, both required.
|
||||
#
|
||||
# 1. The actor is on the maintainer allowlist. This workflow consumes a
|
||||
# metered API credential, so the repository states its own trust boundary
|
||||
# here rather than relying on any downstream check. Add a login to this
|
||||
# list to grant access; there is no other way in.
|
||||
# 2. The trigger text mentions @claude.
|
||||
#
|
||||
# scripts/check-workflow-guards.mjs asserts that both this actor term and the
|
||||
# cost bounds below survive; actionlint checks expression syntax and cannot
|
||||
# see authorization intent.
|
||||
if: |
|
||||
(github.event_name == 'issue_comment' && contains(github.event.comment.body, '@claude')) ||
|
||||
(github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '@claude')) ||
|
||||
(github.event_name == 'pull_request_review' && contains(github.event.review.body, '@claude')) ||
|
||||
(github.event_name == 'issues' && (contains(github.event.issue.body, '@claude') || contains(github.event.issue.title, '@claude')))
|
||||
contains(fromJSON('["J3vb"]'), github.actor) &&
|
||||
(
|
||||
(github.event_name == 'issue_comment' && contains(github.event.comment.body, '@claude')) ||
|
||||
(github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '@claude')) ||
|
||||
(github.event_name == 'pull_request_review' && contains(github.event.review.body, '@claude')) ||
|
||||
(github.event_name == 'issues' && (contains(github.event.issue.body, '@claude') || contains(github.event.issue.title, '@claude')))
|
||||
)
|
||||
runs-on: ubuntu-latest
|
||||
# Every other long-running job in this repository declares a cap
|
||||
# (ci.yml rust-tests, client-e2e, admin-e2e, client-e2e-parity;
|
||||
# load-baseline). Without one the job inherits GitHub's 360-minute default,
|
||||
# which is the wrong ceiling for metered work.
|
||||
timeout-minutes: 30
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
@@ -32,7 +59,7 @@ jobs:
|
||||
|
||||
- name: Run Claude Code
|
||||
id: claude
|
||||
uses: anthropics/claude-code-action@6b082c41935b4c8a3b8b0ef85ba4ba4d9eeb8975 # v1
|
||||
uses: anthropics/claude-code-action@24dcd50c0568f0fc9e9211213a4fd2d9eb15c4e0 # v1
|
||||
with:
|
||||
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
|
||||
|
||||
@@ -47,4 +74,3 @@ jobs:
|
||||
# See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md
|
||||
# or https://code.claude.com/docs/en/cli-reference for available options
|
||||
# claude_args: '--allowed-tools Bash(gh pr:*)'
|
||||
|
||||
|
||||
@@ -67,19 +67,28 @@ jobs:
|
||||
TOKEN=$(curl -sk -X POST "$BASE/admin/api/setup" \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"username":"loadadmin","password":"LoadTest123!Admin"}' | jq -r .token)
|
||||
[ -n "$TOKEN" ] && [ "$TOKEN" != "null" ] || { echo "::error::setup failed"; exit 1; }
|
||||
if [ -z "$TOKEN" ] || [ "$TOKEN" = "null" ]; then
|
||||
echo "::error::setup failed"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
CHANNEL_ID=$(curl -sk -X POST "$BASE/admin/api/channels" \
|
||||
-H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
|
||||
-d '{"name":"loadtest","type":"text"}' | jq -r .id)
|
||||
[ -n "$CHANNEL_ID" ] && [ "$CHANNEL_ID" != "null" ] || { echo "::error::channel create failed"; exit 1; }
|
||||
if [ -z "$CHANNEL_ID" ] || [ "$CHANNEL_ID" = "null" ]; then
|
||||
echo "::error::channel create failed"
|
||||
exit 1
|
||||
fi
|
||||
echo "CHANNEL_ID=$CHANNEL_ID" >> "$GITHUB_ENV"
|
||||
echo "ADMIN_TOKEN=$TOKEN" >> "$GITHUB_ENV"
|
||||
|
||||
INVITE=$(curl -sk -X POST "$BASE/api/v1/invites" \
|
||||
-H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
|
||||
-d '{"max_uses":0}' | jq -r .code)
|
||||
[ -n "$INVITE" ] && [ "$INVITE" != "null" ] || { echo "::error::invite create failed"; exit 1; }
|
||||
if [ -z "$INVITE" ] || [ "$INVITE" = "null" ]; then
|
||||
echo "::error::invite create failed"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
USERS="${{ inputs.users }}"
|
||||
for i in $(seq 1 "${USERS:-100}"); do
|
||||
|
||||
@@ -15,12 +15,47 @@ concurrency:
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
# R-09 / RL-16. ci.yml has no `tags:` trigger, so a tag push starts this
|
||||
# workflow and nothing else — and this workflow re-runs none of the required
|
||||
# checks. It builds, smokes and signs, which is a different question from
|
||||
# "did the gate pass on this commit".
|
||||
#
|
||||
# It did not, at least once: v1.2.0-alpha.3 published from a commit whose
|
||||
# `Server Build & Test (windows-latest)` had concluded failure. Nothing
|
||||
# noticed, because nothing looked.
|
||||
#
|
||||
# The required set is read out of b0-dev-branch-protection.sh rather than
|
||||
# restated here, so pinning a new check cannot leave this gate behind. The
|
||||
# logic lives in a script with a --selftest that ci.yml runs on every PR:
|
||||
# a step that exists only in this file first executes at tag time, which is
|
||||
# the wrong place to discover its bugs.
|
||||
gate-evidence:
|
||||
name: Verify exact-SHA gate evidence
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
checks: read
|
||||
steps:
|
||||
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
|
||||
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||
with:
|
||||
node-version: 24
|
||||
- name: Required checks must be green on the tagged commit
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
GITHUB_REPOSITORY: ${{ github.repository }}
|
||||
run: node scripts/verify-gate-evidence.mjs "${{ github.sha }}"
|
||||
|
||||
# The v1.1.0-alpha.4 release shipped clients still versioned 1.1.0-alpha.3
|
||||
# because the client manifests weren't bumped before tagging — deployed
|
||||
# clients then never saw the update. Fail fast on that mismatch, before any
|
||||
# expensive build starts.
|
||||
verify-versions:
|
||||
name: Verify client version matches tag
|
||||
# Every build job needs verify-versions, and both publishers need those, so
|
||||
# one edge here gates the whole graph — nothing builds, pushes to GHCR, or
|
||||
# creates a Release on a commit that did not pass.
|
||||
needs: gate-evidence
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -30,9 +65,9 @@ jobs:
|
||||
shell: bash
|
||||
run: |
|
||||
TAG_VERSION="${GITHUB_REF_NAME#v}"
|
||||
TAURI_VERSION=$(node -p "require('./Client/tauri-client/src-tauri/tauri.conf.json').version")
|
||||
NPM_VERSION=$(node -p "require('./Client/tauri-client/package.json').version")
|
||||
CARGO_VERSION=$(sed -n 's/^version = "\(.*\)"$/\1/p' Client/tauri-client/src-tauri/Cargo.toml | head -1)
|
||||
TAURI_VERSION=$(node -p "require('./Client/src-tauri/tauri.conf.json').version")
|
||||
NPM_VERSION=$(node -p "require('./Client/package.json').version")
|
||||
CARGO_VERSION=$(sed -n 's/^version = "\(.*\)"$/\1/p' Client/src-tauri/Cargo.toml | head -1)
|
||||
fail=0
|
||||
for pair in "tauri.conf.json:$TAURI_VERSION" "package.json:$NPM_VERSION" "Cargo.toml:$CARGO_VERSION"; do
|
||||
file="${pair%%:*}"; ver="${pair#*:}"
|
||||
@@ -54,9 +89,9 @@ jobs:
|
||||
|
||||
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||
with:
|
||||
node-version: 20
|
||||
node-version: 24
|
||||
cache: npm
|
||||
cache-dependency-path: Client/tauri-client/package-lock.json
|
||||
cache-dependency-path: Client/package-lock.json
|
||||
|
||||
- name: Install Rust
|
||||
uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable
|
||||
@@ -64,14 +99,14 @@ jobs:
|
||||
- name: Rust cache
|
||||
uses: swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
|
||||
with:
|
||||
workspaces: Client/tauri-client/src-tauri
|
||||
workspaces: Client/src-tauri
|
||||
|
||||
- name: Install npm dependencies
|
||||
working-directory: Client/tauri-client
|
||||
working-directory: Client
|
||||
run: npm ci
|
||||
|
||||
- name: Build Tauri app
|
||||
working-directory: Client/tauri-client
|
||||
working-directory: Client
|
||||
env:
|
||||
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
|
||||
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
|
||||
@@ -81,7 +116,7 @@ jobs:
|
||||
shell: bash
|
||||
run: |
|
||||
mkdir -p release-staging
|
||||
NSIS_DIR="Client/tauri-client/src-tauri/target/release/bundle/nsis"
|
||||
NSIS_DIR="Client/src-tauri/target/release/bundle/nsis"
|
||||
INSTALLER=$(find "$NSIS_DIR" -name "*.exe" | head -1)
|
||||
cp "$INSTALLER" release-staging/
|
||||
NSIS_ZIP=$(find "$NSIS_DIR" -name "*_x64-setup.nsis.zip" ! -name "*.sig" | head -1)
|
||||
@@ -106,9 +141,9 @@ jobs:
|
||||
|
||||
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||
with:
|
||||
node-version: 20
|
||||
node-version: 24
|
||||
cache: npm
|
||||
cache-dependency-path: Client/tauri-client/package-lock.json
|
||||
cache-dependency-path: Client/package-lock.json
|
||||
|
||||
- name: Install Linux system dependencies
|
||||
run: |
|
||||
@@ -131,14 +166,14 @@ jobs:
|
||||
- name: Rust cache
|
||||
uses: swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
|
||||
with:
|
||||
workspaces: Client/tauri-client/src-tauri
|
||||
workspaces: Client/src-tauri
|
||||
|
||||
- name: Install npm dependencies
|
||||
working-directory: Client/tauri-client
|
||||
working-directory: Client
|
||||
run: npm ci
|
||||
|
||||
- name: Build Tauri app (AppImage + deb)
|
||||
working-directory: Client/tauri-client
|
||||
working-directory: Client
|
||||
env:
|
||||
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
|
||||
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
|
||||
@@ -149,7 +184,7 @@ jobs:
|
||||
# EGL_BAD_PARAMETER). Strip them and regenerate the updater artifact +
|
||||
# signatures for the patched image.
|
||||
- name: Strip host-incompatible libs from AppImage and re-sign
|
||||
working-directory: Client/tauri-client
|
||||
working-directory: Client
|
||||
shell: bash
|
||||
env:
|
||||
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
|
||||
@@ -173,7 +208,7 @@ jobs:
|
||||
shell: bash
|
||||
run: |
|
||||
mkdir -p linux-staging
|
||||
BUNDLE_DIR="Client/tauri-client/src-tauri/target/release/bundle"
|
||||
BUNDLE_DIR="Client/src-tauri/target/release/bundle"
|
||||
# AppImage
|
||||
APPIMAGE=$(find "$BUNDLE_DIR/appimage" -name "*.AppImage" ! -name "*.sig" | head -1)
|
||||
if [ -n "$APPIMAGE" ] && [ -f "$APPIMAGE" ]; then cp "$APPIMAGE" linux-staging/; fi
|
||||
@@ -296,9 +331,9 @@ jobs:
|
||||
|
||||
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||
with:
|
||||
node-version: 20
|
||||
node-version: 24
|
||||
cache: npm
|
||||
cache-dependency-path: Client/tauri-client/package-lock.json
|
||||
cache-dependency-path: Client/package-lock.json
|
||||
|
||||
- name: Install Linux system dependencies
|
||||
run: |
|
||||
@@ -321,14 +356,14 @@ jobs:
|
||||
- name: Rust cache
|
||||
uses: swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
|
||||
with:
|
||||
workspaces: Client/tauri-client/src-tauri
|
||||
workspaces: Client/src-tauri
|
||||
|
||||
- name: Install npm dependencies
|
||||
working-directory: Client/tauri-client
|
||||
working-directory: Client
|
||||
run: npm ci
|
||||
|
||||
- name: Build Tauri app (AppImage + deb)
|
||||
working-directory: Client/tauri-client
|
||||
working-directory: Client
|
||||
env:
|
||||
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
|
||||
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
|
||||
@@ -336,7 +371,7 @@ jobs:
|
||||
|
||||
# Same strip + re-sign as the x86_64 job — see the comment there.
|
||||
- name: Strip host-incompatible libs from AppImage and re-sign
|
||||
working-directory: Client/tauri-client
|
||||
working-directory: Client
|
||||
shell: bash
|
||||
env:
|
||||
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
|
||||
@@ -360,7 +395,7 @@ jobs:
|
||||
shell: bash
|
||||
run: |
|
||||
mkdir -p linux-arm64-staging
|
||||
BUNDLE_DIR="Client/tauri-client/src-tauri/target/release/bundle"
|
||||
BUNDLE_DIR="Client/src-tauri/target/release/bundle"
|
||||
# AppImage + updater artifact (.tar.gz) + signatures. Every filename
|
||||
# must carry the arch: FindClientAssets matches on the
|
||||
# _aarch64.AppImage.tar.gz suffix, and arch-less names would collide
|
||||
@@ -454,7 +489,14 @@ jobs:
|
||||
|
||||
publish:
|
||||
name: Publish GitHub Release
|
||||
needs: [release-client-windows, release-client-linux, release-client-linux-arm64, release-server, release-server-docker]
|
||||
needs:
|
||||
[
|
||||
release-client-windows,
|
||||
release-client-linux,
|
||||
release-client-linux-arm64,
|
||||
release-server,
|
||||
release-server-docker,
|
||||
]
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
@@ -463,9 +505,9 @@ jobs:
|
||||
|
||||
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||
with:
|
||||
node-version: 20
|
||||
node-version: 24
|
||||
cache: npm
|
||||
cache-dependency-path: Client/tauri-client/package-lock.json
|
||||
cache-dependency-path: Client/package-lock.json
|
||||
|
||||
- name: Download Windows client assets
|
||||
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
|
||||
@@ -515,8 +557,8 @@ jobs:
|
||||
- name: Generate SHA256 checksums
|
||||
shell: bash
|
||||
run: |
|
||||
(cd windows && sha256sum *) > checksums.sha256
|
||||
(cd linux && sha256sum *) >> checksums.sha256
|
||||
(cd windows && sha256sum -- *) > checksums.sha256
|
||||
(cd linux && sha256sum -- *) >> checksums.sha256
|
||||
sha256sum owncord-src-*.tar.gz >> checksums.sha256
|
||||
|
||||
# The legacy top-level asset/sha256 pair stays bound to the Windows
|
||||
@@ -532,7 +574,7 @@ jobs:
|
||||
"$VERSION" "$WIN_HASH" "$WIN_HASH" "$LINUX_HASH" > windows/server-update-manifest.json
|
||||
|
||||
- name: Sign server update assets
|
||||
working-directory: Client/tauri-client
|
||||
working-directory: Client
|
||||
shell: bash
|
||||
env:
|
||||
SERVER_UPDATE_SIGNING_PRIVATE_KEY: ${{ secrets.SERVER_UPDATE_SIGNING_PRIVATE_KEY }}
|
||||
@@ -542,8 +584,8 @@ jobs:
|
||||
printf '%s' "$SERVER_UPDATE_SIGNING_PRIVATE_KEY" > "$KEY_PATH"
|
||||
trap 'rm -f "$KEY_PATH"' EXIT
|
||||
npm ci
|
||||
npx tauri signer sign -f "$KEY_PATH" -p "$SERVER_UPDATE_SIGNING_PRIVATE_KEY_PASSWORD" ../../windows/chatserver.exe
|
||||
npx tauri signer sign -f "$KEY_PATH" -p "$SERVER_UPDATE_SIGNING_PRIVATE_KEY_PASSWORD" ../../windows/server-update-manifest.json
|
||||
npx tauri signer sign -f "$KEY_PATH" -p "$SERVER_UPDATE_SIGNING_PRIVATE_KEY_PASSWORD" ../windows/chatserver.exe
|
||||
npx tauri signer sign -f "$KEY_PATH" -p "$SERVER_UPDATE_SIGNING_PRIVATE_KEY_PASSWORD" ../windows/server-update-manifest.json
|
||||
|
||||
# Fail closed before publishing: prove the freshly signed assets verify
|
||||
# against the pinned public key that ships inside the server binary.
|
||||
|
||||
@@ -9,6 +9,7 @@ Server/.env
|
||||
.claude/*
|
||||
!.claude/skills/
|
||||
!.claude/workflows/
|
||||
!.claude/settings.json
|
||||
CLAUDE.local.md
|
||||
.mcp.json
|
||||
|
||||
@@ -27,10 +28,16 @@ docs/research/
|
||||
docs/superpowers/
|
||||
/skills/
|
||||
|
||||
# Detailed security reports for findings that are not yet fixed. This repo is
|
||||
# public (docs/security.md): reproduction traces for a live defect must never
|
||||
# be committed. Findings are coordinated through private GitHub Security
|
||||
# Advisories; only opaque identifiers and safe status go in tracked plans.
|
||||
docs/security-findings/
|
||||
|
||||
# Mutation-testing output (npm run test:mutate). Local-only by design: a
|
||||
# surviving-mutant report maps exactly which behaviour nothing tests.
|
||||
Client/tauri-client/.stryker-tmp/
|
||||
Client/tauri-client/reports/
|
||||
Client/.stryker-tmp/
|
||||
Client/reports/
|
||||
|
||||
# Server runtime artifacts
|
||||
Server/chatserver.exe
|
||||
@@ -40,6 +47,17 @@ Server/server.exe
|
||||
Server/config.yaml
|
||||
Server/data/
|
||||
|
||||
# Prebuilt plugin example (RL-08). Built from the main.go beside it with the
|
||||
# TinyGo toolchain that directory's README pins. Read by nothing in the build
|
||||
# or test graph, and not byte-reproducible on another machine: TinyGo embeds
|
||||
# absolute host paths from the building machine's Go SDK and module cache, and
|
||||
# has no -trimpath equivalent.
|
||||
#
|
||||
# Deliberately NOT a blanket *.wasm rule. Client/public/rnnoise.wasm is a
|
||||
# vendored npm artifact this repository does not build and the client fetches
|
||||
# at runtime; ignoring it would break voice noise suppression.
|
||||
Server/plugin/examples/hello/hello.wasm
|
||||
|
||||
# Test coverage artifacts
|
||||
*.out
|
||||
Server/cov.out
|
||||
@@ -58,7 +76,7 @@ Client/login-mockup.html
|
||||
Client/ui-mockup.html
|
||||
|
||||
# Tauri typegen (auto-generated IPC bindings)
|
||||
Client/tauri-client/src/generated/
|
||||
Client/src/generated/
|
||||
.typecache
|
||||
|
||||
# Node modules
|
||||
@@ -67,8 +85,20 @@ node_modules/
|
||||
# AI tooling
|
||||
.gstack/
|
||||
.claude-flow/
|
||||
.superpowers/
|
||||
.rust-review-results/
|
||||
|
||||
# Bug-hunt ledger: shared so contributors can add findings. Only the ledger and
|
||||
# its renderer are tracked; hunt transcripts, .bak snapshots and debris patches
|
||||
# are per-session scratch and stay local.
|
||||
#
|
||||
# FINDINGS.md is deliberately NOT tracked (RL-07): it is 100% derived from
|
||||
# findings-ledger.json, and every hunt would otherwise write a fresh ~1.06 MB
|
||||
# blob into permanent history for a file a reader can regenerate in under a
|
||||
# second with `node .superpowers/render-ledger.mjs`.
|
||||
.superpowers/*
|
||||
!.superpowers/findings-ledger.json
|
||||
!.superpowers/render-ledger.mjs
|
||||
|
||||
.claude/worktrees/
|
||||
|
||||
# Internal dev tools (e.g. tools/livekit-server.exe) are ignored, but the
|
||||
@@ -88,7 +118,7 @@ Client/CLIENT-REVIEW.md
|
||||
.serena/
|
||||
|
||||
# Client env (holds API keys - never commit)
|
||||
Client/tauri-client/.env
|
||||
Client/.env
|
||||
|
||||
# Rust review output
|
||||
.rust-review-results/
|
||||
@@ -98,4 +128,9 @@ Client/tauri-client/.env
|
||||
|
||||
# local server run logs
|
||||
server.log
|
||||
|
||||
# Knowledge-graph output. The tool and its 20.41 MB tracked payload were removed
|
||||
# in a5f7d95 (#1413, RL-06). The rule stays so a machine that still has the local
|
||||
# directory — it reached ~208 MB with cache and dated snapshots — does not see it
|
||||
# as untracked noise.
|
||||
graphify-out/
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
# engine-strict makes the engines block in package.json a hard error rather
|
||||
# than an npm warning. npm reads the project .npmrc from the package
|
||||
# directory and does not walk parent directories, so this file has to exist
|
||||
# in every package root or the gate silently downgrades to a warning there.
|
||||
engine-strict=true
|
||||
@@ -0,0 +1,40 @@
|
||||
# Prettier 3 reads .gitignore by default, so everything ignored there —
|
||||
# node_modules/, dist/, coverage/, Client/src/generated/, docs/security-findings/ —
|
||||
# is already excluded. Only tracked files need entries here.
|
||||
|
||||
|
||||
# Generated, verified by `git diff --exit-code` after regeneration.
|
||||
Server/db/dbgen/
|
||||
Client/src/lib/protocolTypes.ts
|
||||
|
||||
|
||||
# Dated point-in-time snapshots. scripts/check-doc-counts.mjs already treats
|
||||
# these as deliberately unmaintained and out of scope to edit; reformatting
|
||||
# them would churn frozen records for no reader.
|
||||
docs/audit-*.md
|
||||
|
||||
# Carried forward from the client's own ignore file — a deliberate exclusion,
|
||||
# not an oversight.
|
||||
*.html
|
||||
|
||||
# Session scratch from the remember plugin. Gitignored by a nested
|
||||
# .remember/.gitignore, which Prettier does not read — it honours only the root
|
||||
# .gitignore. Untracked and per-machine: a contributor's scratch directory must
|
||||
# never be able to turn a shared gate red.
|
||||
.remember/
|
||||
**/.remember/
|
||||
|
||||
# Build output and per-tool scratch. Every path below is gitignored -- but by a
|
||||
# NESTED .gitignore, and Prettier honours only the root one. Without these
|
||||
# entries the gate goes red the moment a contributor runs a build: `cargo test`
|
||||
# alone drops ~850 formattable files into src-tauri/target/.
|
||||
# Mirrors Client/.gitignore, .serena/.gitignore and .superpowers/sdd/.gitignore.
|
||||
Client/dist/
|
||||
Client/coverage/
|
||||
Client/playwright-report/
|
||||
Client/test-results/
|
||||
Client/.vite/
|
||||
Client/src-tauri/target/
|
||||
Client/src-tauri/gen/
|
||||
.serena/
|
||||
.superpowers/sdd/
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"singleQuote": false,
|
||||
"semi": true,
|
||||
"trailingComma": "all",
|
||||
"printWidth": 100,
|
||||
"tabWidth": 2,
|
||||
"arrowParens": "always",
|
||||
"endOfLine": "lf"
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
// Renders .superpowers/findings-ledger.json to FINDINGS.md, and validates it.
|
||||
// Run: node .superpowers/render-ledger.mjs # write FINDINGS.md
|
||||
// node .superpowers/render-ledger.mjs --check # validate only
|
||||
// node .superpowers/render-ledger.mjs --selftest # run built-in tests
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
const VALID_STATUS = ["open", "fixed", "declined", "refuted", "duplicate", "blocked"];
|
||||
|
||||
// Must stay in lockstep with SEV_RANK below. render() sorts the open section by
|
||||
// SEV_RANK, and an unranked severity makes the comparator return NaN — which
|
||||
// leaves the sort order implementation-defined, so the rendering would stop
|
||||
// being a pure function of the ledger. The drift gate compares the rendering
|
||||
// against the ledger, so its whole premise rests on this being enforced.
|
||||
const VALID_SEVERITY = ["critical", "high", "medium", "low"];
|
||||
|
||||
export function validate(ledger) {
|
||||
const problems = [];
|
||||
const ids = new Set();
|
||||
for (const r of ledger.findings) {
|
||||
if (ids.has(r.id)) problems.push(`duplicate id ${r.id}`);
|
||||
ids.add(r.id);
|
||||
if (!/^OC-\d{4}$/.test(r.id)) problems.push(`${r.id}: malformed id`);
|
||||
if (!VALID_STATUS.includes(r.status)) problems.push(`${r.id}: bad status ${r.status}`);
|
||||
if (!VALID_SEVERITY.includes(r.severity)) problems.push(`${r.id}: bad severity ${r.severity}`);
|
||||
if (r.status === "fixed" && (!r.fix || !r.fix.commit))
|
||||
problems.push(`${r.id}: fixed without a commit`);
|
||||
if (r.status === "declined" && !r.rationale)
|
||||
problems.push(`${r.id}: declined without a rationale`);
|
||||
if (r.status === "duplicate" && !r.duplicateOf)
|
||||
problems.push(`${r.id}: duplicate without duplicateOf`);
|
||||
}
|
||||
return problems;
|
||||
}
|
||||
|
||||
function selftest() {
|
||||
// Every fixture carries a severity: validate() now requires one, so omitting
|
||||
// it would make each case report two problems and assert against the wrong one.
|
||||
assert.deepEqual(validate({ findings: [] }), []);
|
||||
assert.deepEqual(
|
||||
validate({ findings: [{ id: "OC-0001", severity: "low", status: "fixed", fix: null }] }),
|
||||
["OC-0001: fixed without a commit"],
|
||||
);
|
||||
assert.deepEqual(validate({ findings: [{ id: "bad", severity: "low", status: "open" }] }), [
|
||||
"bad: malformed id",
|
||||
]);
|
||||
assert.deepEqual(
|
||||
validate({
|
||||
findings: [
|
||||
{ id: "OC-0001", severity: "low", status: "open" },
|
||||
{ id: "OC-0001", severity: "low", status: "open" },
|
||||
],
|
||||
}),
|
||||
["duplicate id OC-0001"],
|
||||
);
|
||||
assert.deepEqual(
|
||||
validate({ findings: [{ id: "OC-0002", severity: "low", status: "declined" }] }),
|
||||
["OC-0002: declined without a rationale"],
|
||||
);
|
||||
|
||||
// An unranked severity is what makes render()'s sort implementation-defined.
|
||||
assert.deepEqual(
|
||||
validate({ findings: [{ id: "OC-0003", severity: "moderate", status: "open" }] }),
|
||||
["OC-0003: bad severity moderate"],
|
||||
);
|
||||
assert.deepEqual(validate({ findings: [{ id: "OC-0004", status: "open" }] }), [
|
||||
"OC-0004: bad severity undefined",
|
||||
]);
|
||||
for (const sev of VALID_SEVERITY) {
|
||||
assert.deepEqual(
|
||||
validate({ findings: [{ id: "OC-0005", severity: sev, status: "open" }] }),
|
||||
[],
|
||||
);
|
||||
}
|
||||
console.log("selftest: all assertions pass");
|
||||
}
|
||||
|
||||
const SEV_RANK = { critical: 0, high: 1, medium: 2, low: 3 };
|
||||
|
||||
export function render(ledger) {
|
||||
const by = (s) => ledger.findings.filter((f) => f.status === s);
|
||||
const open = by("open").sort((a, b) => SEV_RANK[a.severity] - SEV_RANK[b.severity]);
|
||||
const blocked = by("blocked");
|
||||
const fixed = by("fixed");
|
||||
const declined = by("declined");
|
||||
const refuted = by("refuted");
|
||||
const dup = by("duplicate");
|
||||
|
||||
const lines = [];
|
||||
lines.push("# OwnCord Findings Ledger", "");
|
||||
lines.push(
|
||||
"Generated by `render-ledger.mjs`. Do not hand-edit — edit `findings-ledger.json`.",
|
||||
"",
|
||||
);
|
||||
lines.push(
|
||||
`**${open.length} open** · ${blocked.length} blocked · ${fixed.length} fixed · ` +
|
||||
`${declined.length} declined · ${refuted.length} refuted · ${dup.length} duplicate`,
|
||||
"",
|
||||
);
|
||||
|
||||
const section = (title, rows, extra) => {
|
||||
if (!rows.length) return;
|
||||
lines.push(`## ${title}`, "");
|
||||
for (const r of rows) {
|
||||
lines.push(`### ${r.id} — ${r.severity} — ${r.title}`, "");
|
||||
lines.push(
|
||||
`\`${r.file}:${r.line}\` · found ${r.found} · hunt \`${r.hunt}\` · lens \`${r.lens}\``,
|
||||
"",
|
||||
);
|
||||
if (r.why) lines.push(r.why, "");
|
||||
if (r.repro) lines.push(`**Repro:** ${r.repro}`, "");
|
||||
if (r.evidence) lines.push(`**Evidence:** ${r.evidence}`, "");
|
||||
if (r.suggestedFix) lines.push(`**Suggested fix:** ${r.suggestedFix}`, "");
|
||||
const e = extra && extra(r);
|
||||
if (e) lines.push(e, "");
|
||||
}
|
||||
};
|
||||
|
||||
section("Open", open);
|
||||
section("Blocked — fix attempted, revert-proof failed", blocked);
|
||||
section(
|
||||
"Fixed",
|
||||
fixed,
|
||||
(r) =>
|
||||
`**Fixed:** \`${r.fix.commit}\` · test \`${r.fix.test}\` · revert-proof ${r.fix.revertProof}`,
|
||||
);
|
||||
section("Declined", declined, (r) => `**Declined:** ${r.rationale}`);
|
||||
section("Refuted", refuted);
|
||||
section("Duplicate", dup, (r) => `**Duplicate of** ${r.duplicateOf}`);
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const { readFileSync, writeFileSync } = await import("node:fs");
|
||||
const { dirname, join } = await import("node:path");
|
||||
const { fileURLToPath } = await import("node:url");
|
||||
const here = dirname(fileURLToPath(import.meta.url));
|
||||
const ledger = JSON.parse(readFileSync(join(here, "findings-ledger.json"), "utf8"));
|
||||
const problems = validate(ledger);
|
||||
if (problems.length) {
|
||||
for (const p of problems) console.error(`INVALID ${p}`);
|
||||
process.exit(1);
|
||||
}
|
||||
if (process.argv.includes("--check")) {
|
||||
console.log(`ledger valid: ${ledger.findings.length} finding(s)`);
|
||||
return;
|
||||
}
|
||||
writeFileSync(join(here, "FINDINGS.md"), render(ledger) + "\n");
|
||||
console.log(`wrote FINDINGS.md (${ledger.findings.length} finding(s))`);
|
||||
}
|
||||
|
||||
if (process.argv.includes("--selftest")) selftest();
|
||||
else await main();
|
||||
@@ -5,6 +5,114 @@ tooling (`npm run changelog`) auto-generates entries from commit messages
|
||||
on each release; this file is the curated counterpart that calls out
|
||||
behavioural changes operators must know about.
|
||||
|
||||
## How to write an entry
|
||||
|
||||
**Scannable lists, never walls of text.** A reader should be able to find what
|
||||
affects them in about ten seconds, without reading a paragraph they do not care
|
||||
about. Entries below `v1.2.0-alpha.3` do not follow this and are left as
|
||||
shipped history; everything from the next release forward does.
|
||||
|
||||
The rules:
|
||||
|
||||
1. **Open with what is user-visible and what is not.** Most releases carry a
|
||||
mixture. Say which is which up front, so nobody reads twenty lines of
|
||||
repository plumbing looking for a fix.
|
||||
2. **Group by the area a user recognises** — Login & connection, Voice,
|
||||
Mentions, Messages & files, Accounts & admin, Desktop UI. Not by subsystem,
|
||||
package, or which PR it came from.
|
||||
3. **One line per fix.** If it needs two lines, it needs two entries or it does
|
||||
not belong here.
|
||||
4. **Say what was broken, then what it does now.** "Banned users could still
|
||||
connect — ban is re-checked on connect." A reader must be able to tell
|
||||
whether it bit them, without opening the PR.
|
||||
5. **Plain language.** Name the thing a user sees, not the function that owned
|
||||
the bug. `voiceJoinLeaveCurrent` means nothing to an operator; "moderator
|
||||
mute survives a channel move" does.
|
||||
6. **No `OC-*` ids, no file paths, no PR-body prose.** The ledger and the pull
|
||||
request already carry those, and this file is the one place that does not
|
||||
need them. A PR number is fine where it genuinely helps someone dig.
|
||||
7. **Counts belong in a summary line, not per item.** "62 fixes" once at the
|
||||
top beats a number attached to every bullet.
|
||||
|
||||
Anything a user cannot observe — repository layout, CI gates, generated-code
|
||||
ownership, dependency automation — gets **at most a short block at the end**,
|
||||
and only when it changes something a contributor or fork holder must do
|
||||
(a moved directory, a renamed module, a new required command).
|
||||
|
||||
## v1.2.0-alpha.4
|
||||
|
||||
**62 bug fixes**, all user-visible, plus repository work that changes nothing an
|
||||
operator can see. Fixes first; the repository half is the short block at the end.
|
||||
|
||||
### Login & connection
|
||||
|
||||
- Connecting with a failed role lookup silently made you a plain **member** — it
|
||||
now fails closed instead of guessing.
|
||||
- **Banned users could still connect.** Ban status is re-checked on connect.
|
||||
- Reconnecting left a **phantom voice E2EE key holder** and a stale voice-channel
|
||||
marker behind.
|
||||
- Typing indicators in DMs could **disconnect you** under load.
|
||||
|
||||
### Voice
|
||||
|
||||
- Moderator mute and deafen are **preserved across a channel move** — they were
|
||||
silently dropped.
|
||||
- Voice E2EE keys **re-sync on reconnect**, and a departed peer's key is always
|
||||
retired so a replayed announce cannot overwrite a fresh one.
|
||||
- A kicked client no longer receives frames.
|
||||
- A rolled-back join now reaches everyone present, including people without
|
||||
permission to read the channel.
|
||||
- A **failed microphone unmute now shows as failed** instead of quietly
|
||||
reporting you as unmuted.
|
||||
- Noise suppression rebuilds correctly after a microphone restart.
|
||||
|
||||
### Mentions
|
||||
|
||||
- **`@here` no longer behaves like `@everyone`** — the two are distinguished.
|
||||
- Mention badges are reversed on delete, purge and account deletion, and can no
|
||||
longer be reversed twice.
|
||||
|
||||
### Messages & files
|
||||
|
||||
- Deleting a message now **actually deletes its attachment files**.
|
||||
- A failed avatar upload no longer deletes a committed file's reference.
|
||||
|
||||
### Accounts & admin
|
||||
|
||||
- The `require_2fa` enrollment gate misfired after a temporary ban lapsed, and
|
||||
applied its precondition to unrelated settings.
|
||||
- A DM partner with no live connection now shows **offline everywhere** — it was
|
||||
inconsistent between views.
|
||||
- Plugin installation rolls back properly when it fails.
|
||||
- The diagnostics endpoint honours trusted proxies.
|
||||
|
||||
### Desktop app
|
||||
|
||||
- Fixed event-listener leaks in the message list, member list, emoji picker,
|
||||
quick switcher, sidebar popovers and drag-reorder.
|
||||
- Recent emoji, channel mutes and custom status are now **per-server** instead of
|
||||
bleeding between servers.
|
||||
- The DM sidebar filter survives updates, the call button cannot redial, the
|
||||
incoming-call banner uses nicknames, and Ctrl+I unwraps correctly on bold text.
|
||||
|
||||
### Repository — no runtime effect
|
||||
|
||||
Phases B0 and B1 of the
|
||||
[repository-health roadmap](docs/plans/repo-health-roadmap-2026-08-23.md).
|
||||
Desktop behaviour, release asset names and the update contract are unchanged by
|
||||
design. Three items affect anyone holding a working copy or a fork:
|
||||
|
||||
- **`Client/tauri-client/` is now `Client/`** (#1411). Rebase an in-flight
|
||||
branch rather than merging across the move.
|
||||
- **The Go module is now `github.com/J3vb/OwnCord/Server`** (#1417), was
|
||||
`github.com/owncord/server`.
|
||||
- **The protocol schema is now `protocol/schema.json`** (#1417), was
|
||||
`docs/protocol-schema.json`.
|
||||
|
||||
One command runs what CI gates on, Windows and Linux, no `make` needed:
|
||||
`npm run bootstrap`, then `npm run check`. Go-only contributors still do not
|
||||
need Node.
|
||||
|
||||
## v1.2.0-alpha.3
|
||||
|
||||
- **fix:** eight bug-hunt batches closed **199 verified defects** since
|
||||
@@ -19,7 +127,7 @@ behavioural changes operators must know about.
|
||||
`room.setE2EEEnabled(true)`, so every audio and video frame reached the
|
||||
SFU in plaintext. It is enabled now, and a dead E2EE worker is no longer
|
||||
invisible to the Secured badge. Related voice-crypto fixes: a joining key
|
||||
holder sent its room-key offers *before* its own announce, so existing
|
||||
holder sent its room-key offers _before_ its own announce, so existing
|
||||
participants dropped them as "unknown peer" (#1370, #1374); rotation
|
||||
offers exceeded the server rate limit in large channels and permanently
|
||||
starved the same peers; both rotation paths and the reconnect-to-Secured
|
||||
@@ -37,18 +145,18 @@ behavioural changes operators must know about.
|
||||
reaction, pin, purge, delete and `channel_focus` still mutated or
|
||||
subscribed to archived channels (every write sink now routes through one
|
||||
`requireChannelWritable` gate); `EditMessage` and `handleReaction` DM
|
||||
detection failed *open* on a `GetChannel` error, skipping the block gate;
|
||||
detection failed _open_ on a `GetChannel` error, skipping the block gate;
|
||||
group-DM creation only block-checked the creator, letting a third party
|
||||
force two users who blocked each other into a shared room; an invisible
|
||||
user's real custom status leaked on both presence emitters; `PATCH
|
||||
/users/{id}` with `banned` + `role_id` committed and broadcast the ban
|
||||
/users/{id}` with `banned` + `role_id` committed and broadcast the ban
|
||||
before authorizing the role change; admin API-token creation accepted a
|
||||
negative `expires_hours` and minted a token that never expires; upload
|
||||
rejections echoed raw storage errors (absolute server paths) to any
|
||||
authenticated user; the GIF proxy's log redaction missed the
|
||||
percent-encoded API key; `chat_command` was the only client message type
|
||||
without a rate limiter while each frame ran a WASM plugin invocation; and
|
||||
the login and typing rate limiters built their keys from *unvalidated*
|
||||
the login and typing rate limiters built their keys from _unvalidated_
|
||||
input, letting an unauthenticated caller pin unbounded heap for six hours.
|
||||
- **fix(auth):** accounts whose username contains `'`, `"` or `&` were
|
||||
permanently unloggable — registration HTML-escaped the name but login did
|
||||
@@ -60,7 +168,7 @@ behavioural changes operators must know about.
|
||||
reverse-proxy address as the session IP.
|
||||
- **server:** WS hub, reconnect and replay (#1369, #1371, #1372, #1374,
|
||||
#1375) — REST DM events never bumped the visibility watermark, while
|
||||
*every* ordinary DM message re-emitted `dm_channel_open` and bumped the
|
||||
_every_ ordinary DM message re-emitted `dm_channel_open` and bumped the
|
||||
global watermark, forcing every other client's next reconnect into a full
|
||||
resync; the client's `lastSeq` was never reset by a full-ready resync and
|
||||
desynced permanently; cold-tier replay had no interior-gap detection, so
|
||||
@@ -79,7 +187,7 @@ behavioural changes operators must know about.
|
||||
into a permanent hub/SFU ghost no sweep could heal; the stale-state sweep
|
||||
could delete a just-committed join's row, leaving the client in voice with
|
||||
no DB row; `handleVoiceJoin` handed out a live 5-minute LiveKit credential
|
||||
*after* a concurrent kick/move/revocation had already torn the membership
|
||||
_after_ a concurrent kick/move/revocation had already torn the membership
|
||||
down (the token is now withheld); the `participant_left` webhook never
|
||||
told the leaver, and a transient DB read error on `participant_joined`
|
||||
ejected a legitimate participant mid-call; `voice_mod_move` lacked the
|
||||
@@ -152,7 +260,7 @@ behavioural changes operators must know about.
|
||||
create/edit/delete modals locked up permanently on an API failure; login
|
||||
to an IPv6-literal host was impossible; a host stored with an explicit
|
||||
`:443` lost its bearer token and cert-pinned proxy on attachment fetches;
|
||||
one malformed stored server profile discarded *all* saved profiles; a
|
||||
one malformed stored server profile discarded _all_ saved profiles; a
|
||||
banned/revoked token reconnected forever if the session ended before
|
||||
MainPage mounted; a previous server's block list, collapsed categories and
|
||||
DM notes bled into the next server; the Rust HTTP proxy tunnel's data
|
||||
@@ -208,7 +316,7 @@ behavioural changes operators must know about.
|
||||
- **deploy:** new `chatserver healthcheck` subcommand probes `/health`
|
||||
pinning the server's own certificate from disk (WebPKI when none exists,
|
||||
i.e. ACME) and is now the docker-compose healthcheck — the distroless
|
||||
image has no shell; plain `docker compose` only *surfaces* unhealthy, pair
|
||||
image has no shell; plain `docker compose` only _surfaces_ unhealthy, pair
|
||||
it with a watchdog for auto-restart. Compose gains json-file log rotation
|
||||
(`10m` × 3) on both services. `release.yml` now cold-boots the freshly
|
||||
built server binaries and Docker image and probes them healthy **before
|
||||
@@ -305,7 +413,7 @@ behavioural changes operators must know about.
|
||||
incorrectly documented all presence events as sequenced. Older
|
||||
clients/servers are unaffected — it is a new, ignorable field.
|
||||
- **security(client):** identity/TOFU and transport (#1332) — an in-flight
|
||||
change to scope the identity keypair by host *and* user id would have
|
||||
change to scope the identity keypair by host _and_ user id would have
|
||||
re-minted a fresh key on every existing install, firing the TOFU "verify
|
||||
out-of-band" re-pin warning at the entire alpha population simultaneously,
|
||||
exactly the pattern that teaches users to click through the one warning
|
||||
@@ -315,7 +423,7 @@ behavioural changes operators must know about.
|
||||
bearer token forward into the next login request; `api.setConfig` now
|
||||
drops it when the host changes without a replacement. A hand-copied,
|
||||
un-lowercased host normalizer in `main.ts` meant an uppercase hostname's
|
||||
cert-mismatch *reject* path skipped `disconnect()`/`clearAuth()`, leaving
|
||||
cert-mismatch _reject_ path skipped `disconnect()`/`clearAuth()`, leaving
|
||||
a user who refused a changed certificate still connected to that server —
|
||||
the single lowercased implementation in `ws.ts` is now shared everywhere.
|
||||
- **fix(client):** voice mic/camera reliability (#1331, #1332) — six
|
||||
@@ -372,7 +480,7 @@ behavioural changes operators must know about.
|
||||
PUT (A-2026-08-01); the admin channel list/edit/delete surface no longer
|
||||
sees DM channels, answering 404 for their ids (A-2026-08-02); DM call
|
||||
rings respect blocks like every other DM interaction (A-2026-08-03).
|
||||
Behavioural note: deleting a channel override for a *nonexistent* role now
|
||||
Behavioural note: deleting a channel override for a _nonexistent_ role now
|
||||
returns 404 (was 204), matching PUT.
|
||||
- **server:** migration **029** drops the never-used `sounds` table (dead
|
||||
since the initial schema; A-2026-07-13). Applies automatically on first
|
||||
@@ -627,14 +735,14 @@ claimed behaviour — no product code changed and no assertion weakened.
|
||||
logged (`livekit proxy: origin rejected`) so the next such failure is
|
||||
diagnosable from the server log.
|
||||
- **API tokens can use the admin log stream.** `POST
|
||||
/admin/api/logs/ticket` required a browser login session, so headless
|
||||
/admin/api/logs/ticket` required a browser login session, so headless
|
||||
clients (the `mcp-introspect` dev tool, bots) could reach every other
|
||||
`/admin/api/*` route but not `server_logs`. Tickets are now bound to
|
||||
whichever credential authenticated the request; revoking a token cuts
|
||||
an in-flight stream, exactly as session revocation always has.
|
||||
- **The desktop client now actually uses the OS credential store.** The
|
||||
`keyring` crate declares no `default` feature, so the previous
|
||||
`keyring = "3"` dependency compiled its in-memory *mock* store on
|
||||
`keyring = "3"` dependency compiled its in-memory _mock_ store on
|
||||
Windows, macOS and Linux alike: saves reported success and the next
|
||||
read in the same process returned nothing, and no credential was ever
|
||||
written to Credential Manager / Keychain / Secret Service. The visible
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
# OwnCord
|
||||
|
||||
Self-hosted chat platform (alpha). `Server/` is a Go 1.26 REST + WebSocket
|
||||
server over SQLite with LiveKit voice/video; `Client/tauri-client/` is a Tauri
|
||||
server over SQLite with LiveKit voice/video; `Client/` is a Tauri
|
||||
v2 desktop app (TypeScript frontend, thin Rust backend). Per-component detail
|
||||
lives in `Server/CLAUDE.md` and `Client/tauri-client/CLAUDE.md`; the protocol
|
||||
lives in `Server/CLAUDE.md` and `Client/CLAUDE.md`; the protocol
|
||||
and schema are documented in `docs/protocol.md`, `docs/schema.md`, and
|
||||
`docs/architecture/README.md`.
|
||||
|
||||
@@ -11,11 +11,29 @@ and schema are documented in `docs/protocol.md`, `docs/schema.md`, and
|
||||
|
||||
CI fails on drift, and the next generator run silently discards your edit.
|
||||
|
||||
| Generated | Source of truth | Workflow |
|
||||
| --- | --- | --- |
|
||||
| `Server/db/dbgen/` | `Server/db/queries/*.sql`, `Server/migrations/` | `db-change` skill |
|
||||
| `Server/ws/message_types.go` **and** `Client/tauri-client/src/lib/protocolTypes.ts` | `docs/protocol-schema.json` | `protocol-change` skill |
|
||||
| `Client/tauri-client/src/generated/` | `tauri-typegen` | CI patches known typegen bugs — see `.github/workflows/ci.yml` |
|
||||
| Generated | Source of truth | Workflow |
|
||||
| ---------------------------------------------------------------------- | ----------------------------------------------- | -------------------------------------------------------------- |
|
||||
| `Server/db/dbgen/` | `Server/db/queries/*.sql`, `Server/migrations/` | `db-change` skill |
|
||||
| `Server/ws/message_types.go` **and** `Client/src/lib/protocolTypes.ts` | `protocol/schema.json` | `protocol-change` skill |
|
||||
| `Client/src/generated/` | `tauri-typegen` | CI patches known typegen bugs — see `.github/workflows/ci.yml` |
|
||||
|
||||
## Bug-hunt ledger
|
||||
|
||||
`.superpowers/findings-ledger.json` is the shared ledger of hunt findings and
|
||||
the only tracked copy — open a PR against it to add one. The readable
|
||||
`FINDINGS.md` is **not tracked**: generate it whenever you want to read one
|
||||
(gitignored, under a second, and CI uploads it as a build artifact):
|
||||
|
||||
```
|
||||
node .superpowers/render-ledger.mjs # write a local FINDINGS.md
|
||||
node .superpowers/render-ledger.mjs --check # validate the ledger only
|
||||
```
|
||||
|
||||
Statuses: `open`, `fixed`, `declined`, `refuted`, `duplicate`, `blocked`;
|
||||
`severity` must be `critical`, `high`, `medium` or `low`. Edit the ledger, never
|
||||
the rendering — a hand-edited `FINDINGS.md` is overwritten by the next render
|
||||
and committed by nothing. Everything else under `.superpowers/` is per-session
|
||||
scratch and stays local.
|
||||
|
||||
## Gotchas
|
||||
|
||||
@@ -27,4 +45,6 @@ CI fails on drift, and the next generator run silently discards your edit.
|
||||
- Security issues go through GitHub Security Advisories, never public issues
|
||||
(`docs/security.md`). This repo is public — unfixed defects do not belong in
|
||||
commits, issues, or PR descriptions.
|
||||
- Branch from `main`, PR to `main`, squash merge, conventional commit subjects.
|
||||
- Branch from `dev` and PR to `dev` — `dev` is the integration branch and is
|
||||
PR-only; `main` carries releases. Squash merge, conventional commit subjects.
|
||||
Full model: [docs/contributing.md](docs/contributing.md#branch-and-pr-model).
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
# Contributing to OwnCord
|
||||
|
||||
The full guide lives in **[docs/contributing.md](docs/contributing.md)** —
|
||||
environment setup, the branch model, coding standards, and how to run the
|
||||
checks CI runs.
|
||||
|
||||
This file exists so GitHub can find it: the contributing-guidelines link that
|
||||
appears on new issues and pull requests only resolves `CONTRIBUTING.md` at the
|
||||
repository root, in `.github/`, or in `docs/`.
|
||||
|
||||
Three things worth knowing before you open a pull request:
|
||||
|
||||
- **Branch from `dev` and target `dev`.** `main` carries releases only. See
|
||||
[Branch and PR model](docs/contributing.md#branch-and-pr-model).
|
||||
- **Run the checks first.** `npm run check` from the repository root, or the
|
||||
per-stack commands in [docs/contributing.md](docs/contributing.md). CI takes
|
||||
about 15 minutes and enforces more than a plain build and test.
|
||||
- **Report security issues privately**, through GitHub Security Advisories —
|
||||
never a public issue or pull request. See [SECURITY.md](SECURITY.md) and
|
||||
[docs/security.md](docs/security.md).
|
||||
|
||||
New to the codebase? [docs/README.md](docs/README.md) indexes everything, and
|
||||
[docs/architecture/](docs/architecture/README.md) explains how the server and
|
||||
client fit together.
|
||||
@@ -0,0 +1,5 @@
|
||||
# engine-strict makes the engines block in package.json a hard error rather
|
||||
# than an npm warning. npm reads the project .npmrc from the package
|
||||
# directory and does not walk parent directories, so this file has to exist
|
||||
# in every package root or the gate silently downgrades to a warning there.
|
||||
engine-strict=true
|
||||
@@ -0,0 +1 @@
|
||||
24
|
||||
@@ -9,21 +9,31 @@ Rust backend in `src-tauri/` for native APIs only. LiveKit handles voice/video.
|
||||
`src/pages/`, `src/components/` UI
|
||||
- `src/lib/protocolTypes.ts` and `src/generated/` are generated — see the root
|
||||
CLAUDE.md
|
||||
- `tests/unit`, `tests/integration` (vitest, jsdom) · `tests/e2e` (Playwright) ·
|
||||
- `tests/unit`, `tests/integration`, `tests/contract` (vitest, jsdom) ·
|
||||
`tests/e2e`, `tests/e2e/admin`, `tests/e2e/native` (Playwright) ·
|
||||
`tests/browser` (vitest browser mode)
|
||||
- A test whose assertions read, import or execute a **`Server/`-owned**
|
||||
artifact belongs in `tests/contract`, not `tests/unit` — `src-tauri/` is
|
||||
part of this component, so reading it is an ordinary unit test. The rule
|
||||
is in [docs/contributing.md](../docs/contributing.md#testing)
|
||||
- `src/platform/` does **not** exist yet. Where the desktop/browser seam will
|
||||
go, and which 20 files hold the native imports that must move behind it, is
|
||||
recorded in
|
||||
[docs/architecture/platform-contracts.md](../docs/architecture/platform-contracts.md).
|
||||
Building it is B7 — do not start it as a side effect of another change.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- **On Node 22+ you must run `NODE_OPTIONS=--no-experimental-webstorage npm test`.**
|
||||
Native Web Storage shadows jsdom's `localStorage` and fails ~478 tests that
|
||||
have nothing to do with your change. That is a local toolchain artifact, not
|
||||
a regression — do not "fix" those failures. CI pins Node 20.
|
||||
- Node's native Web Storage (Node 22+) shadows jsdom's `localStorage`;
|
||||
`tests/setup.ts` replaces it with an in-memory shim, so the suite runs on
|
||||
modern Node without `--no-experimental-webstorage`. If storage tests fail
|
||||
en masse, suspect that shim before your change. CI pins Node 24.
|
||||
- `src/lib/dispatcher.ts` is the single WS-event entry point **into the
|
||||
stores**: server events reach domain stores only through a `ws.on(...)`
|
||||
subscription registered there. Other modules do register their own
|
||||
`ws.on(...)` handlers for page-local UI (`main.ts`, `MainPage.ts`,
|
||||
`ChannelController.ts` — ringing, overlays, slow-mode timers); that is fine
|
||||
as long as they only *read* store state. Writing a store from one of those
|
||||
as long as they only _read_ store state. Writing a store from one of those
|
||||
handlers is the violation, and `local/no-store-write-in-ws-on` now fails the
|
||||
build on it.
|
||||
- Voice sessions are superseded, not cancelled. `LiveKitSession` re-entry
|
||||
@@ -2,13 +2,7 @@
|
||||
"$schema": "https://unpkg.com/knip@6/schema.json",
|
||||
"entry": ["src/main.ts"],
|
||||
"project": ["src/**/*.ts"],
|
||||
"ignore": [
|
||||
"public/**",
|
||||
"src-tauri/**",
|
||||
"src/lib/protocolTypes.ts"
|
||||
],
|
||||
"ignoreDependencies": [
|
||||
"@tauri-apps/cli"
|
||||
],
|
||||
"ignore": ["public/**", "src-tauri/**", "src/lib/protocolTypes.ts"],
|
||||
"ignoreDependencies": ["@tauri-apps/cli"],
|
||||
"ignoreExportsUsedInFile": true
|
||||
}
|
||||
@@ -1,8 +1,12 @@
|
||||
{
|
||||
"name": "owncord-client",
|
||||
"private": true,
|
||||
"version": "1.2.0-alpha.3",
|
||||
"version": "1.2.0-alpha.4",
|
||||
"type": "module",
|
||||
"engines": {
|
||||
"node": ">=24",
|
||||
"npm": ">=10"
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -p tsconfig.build.json && vite build",
|
||||
@@ -11,6 +15,7 @@
|
||||
"test": "vitest run",
|
||||
"test:unit": "vitest run tests/unit",
|
||||
"test:integration": "vitest run tests/integration",
|
||||
"test:contract": "vitest run tests/contract",
|
||||
"test:e2e": "playwright test",
|
||||
"test:e2e:prod": "npm run build && playwright test --config playwright.config.prod.ts",
|
||||
"test:e2e:native": "playwright test --config playwright.config.native.ts",
|
||||
@@ -25,8 +30,6 @@
|
||||
"lint": "oxlint src/ && eslint src/",
|
||||
"lint:fix": "eslint src/ --fix",
|
||||
"lint:ox": "oxlint src/",
|
||||
"format": "prettier --write \"src/**/*.ts\" \"tests/**/*.ts\"",
|
||||
"format:check": "prettier --check \"src/**/*.ts\" \"tests/**/*.ts\"",
|
||||
"knip": "knip",
|
||||
"test:mutate": "stryker run",
|
||||
"test:mutate:dry": "stryker run --dryRunOnly"
|
||||
@@ -34,33 +37,23 @@
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^10.0.1",
|
||||
"@playwright/test": "^1",
|
||||
"@stryker-mutator/api": "^9.6.1",
|
||||
"@stryker-mutator/core": "^9.6.1",
|
||||
"@stryker-mutator/typescript-checker": "^9.6.1",
|
||||
"@stryker-mutator/vitest-runner": "^9.6.1",
|
||||
"@stryker-mutator/api": "^10.0.0",
|
||||
"@stryker-mutator/core": "^10.0.0",
|
||||
"@stryker-mutator/typescript-checker": "^10.0.0",
|
||||
"@stryker-mutator/vitest-runner": "^10.0.0",
|
||||
"@tauri-apps/cli": "^2",
|
||||
"@types/node": "^20.19.43",
|
||||
"@vitest/browser": "^3.2.4",
|
||||
"@vitest/coverage-v8": "^3",
|
||||
"eslint": "^10.8.1",
|
||||
"@types/node": "^24.13.3",
|
||||
"@vitest/browser-playwright": "^4.1.11",
|
||||
"@vitest/coverage-v8": "^4.1.11",
|
||||
"eslint": "^10.9.0",
|
||||
"fast-check": "^4.9.0",
|
||||
"jsdom": "^29.1.1",
|
||||
"knip": "^6.32.0",
|
||||
"oxlint": "^1.77.0",
|
||||
"prettier": "^3.9.6",
|
||||
"typescript": "^5.7",
|
||||
"typescript-eslint": "^8.66.0",
|
||||
"vite": "^6",
|
||||
"vitest": "^3"
|
||||
},
|
||||
"prettier": {
|
||||
"singleQuote": false,
|
||||
"semi": true,
|
||||
"trailingComma": "all",
|
||||
"printWidth": 100,
|
||||
"tabWidth": 2,
|
||||
"arrowParens": "always",
|
||||
"endOfLine": "lf"
|
||||
"jsdom": "^30.0.1",
|
||||
"knip": "^6.32.2",
|
||||
"oxlint": "^1.79.0",
|
||||
"typescript": "^6.0.3",
|
||||
"typescript-eslint": "^8.67.0",
|
||||
"vite": "^8.2.2",
|
||||
"vitest": "^4.1.11"
|
||||
},
|
||||
"dependencies": {
|
||||
"@jitsi/rnnoise-wasm": "^0.2.1",
|
||||
@@ -73,7 +66,7 @@
|
||||
"@tauri-apps/plugin-notification": "^2",
|
||||
"@tauri-apps/plugin-opener": "^2.5.3",
|
||||
"@tauri-apps/plugin-process": "^2.3.1",
|
||||
"livekit-client": "^2.21.0"
|
||||
"livekit-client": "^2.22.0"
|
||||
},
|
||||
"overrides": {
|
||||
"qs": "^6.15.3",
|
||||
@@ -36,7 +36,10 @@ export default defineConfig({
|
||||
workers: 1,
|
||||
retries: 2,
|
||||
reporter: process.env.CI
|
||||
? [["html", { open: "never" }], ["junit", { outputFile: "test-results/native-junit.xml" }]]
|
||||
? [
|
||||
["html", { open: "never" }],
|
||||
["junit", { outputFile: "test-results/native-junit.xml" }],
|
||||
]
|
||||
: "html",
|
||||
|
||||
use: {
|
||||
@@ -19,7 +19,10 @@ export default defineConfig({
|
||||
retries: process.env.CI ? 2 : 1,
|
||||
workers: process.env.CI ? 1 : undefined,
|
||||
reporter: process.env.CI
|
||||
? [["html", { open: "never" }], ["junit", { outputFile: "test-results/junit.xml" }]]
|
||||
? [
|
||||
["html", { open: "never" }],
|
||||
["junit", { outputFile: "test-results/junit.xml" }],
|
||||
]
|
||||
: "html",
|
||||
|
||||
use: {
|
||||
@@ -40,7 +43,10 @@ export default defineConfig({
|
||||
],
|
||||
|
||||
webServer: {
|
||||
command: "npm run preview",
|
||||
// Spawn Vite directly rather than through npm — see the note in
|
||||
// playwright.config.ts: an `npm run` wrapper leaves vite alive as an
|
||||
// orphaned grandchild on teardown and the runner never exits.
|
||||
command: "npx vite preview",
|
||||
url: "http://localhost:4173",
|
||||
reuseExistingServer: !process.env.CI,
|
||||
timeout: 60_000,
|
||||
@@ -43,8 +43,16 @@ export default defineConfig({
|
||||
},
|
||||
],
|
||||
|
||||
// Kills the dev server the runner cannot kill itself; without it the suite
|
||||
// passes and then hangs forever. See tests/e2e/global-teardown.ts.
|
||||
globalTeardown: "./tests/e2e/global-teardown.ts",
|
||||
|
||||
webServer: {
|
||||
command: "npm run dev",
|
||||
// Run Vite's entry point directly so the listening process IS Playwright's
|
||||
// child — globalTeardown kills the listener, which only releases the
|
||||
// runner's ChildProcess handle if that listener is the child itself. Going
|
||||
// through `npm run dev` would leave the npm process holding it open.
|
||||
command: "node node_modules/vite/bin/vite.js",
|
||||
url: "http://localhost:1420",
|
||||
reuseExistingServer: !process.env.CI,
|
||||
timeout: 60_000,
|
||||
@@ -70,12 +70,18 @@ class RNNoiseProcessor extends AudioWorkletProcessor {
|
||||
try {
|
||||
// Basic validation: check for expected exports
|
||||
const module = await WebAssembly.compile(wasmBytes);
|
||||
const expectedExports = ['rnnoise_create', 'rnnoise_destroy', 'rnnoise_process_frame', 'malloc', 'free'];
|
||||
const availableExports = WebAssembly.Module.exports(module).map(exp => exp.name);
|
||||
|
||||
const hasRequiredExports = expectedExports.every(exp => availableExports.includes(exp));
|
||||
const expectedExports = [
|
||||
"rnnoise_create",
|
||||
"rnnoise_destroy",
|
||||
"rnnoise_process_frame",
|
||||
"malloc",
|
||||
"free",
|
||||
];
|
||||
const availableExports = WebAssembly.Module.exports(module).map((exp) => exp.name);
|
||||
|
||||
const hasRequiredExports = expectedExports.every((exp) => availableExports.includes(exp));
|
||||
if (!hasRequiredExports) {
|
||||
throw new Error('WASM module missing required RNNoise exports');
|
||||
throw new Error("WASM module missing required RNNoise exports");
|
||||
}
|
||||
|
||||
const memory = new WebAssembly.Memory({ initial: WASM_MEMORY_INITIAL_PAGES });
|
||||
@@ -118,10 +124,13 @@ class RNNoiseProcessor extends AudioWorkletProcessor {
|
||||
if (this._state) exports.rnnoise_destroy(this._state);
|
||||
} catch (cleanupErr) {
|
||||
// Log cleanup errors but don't override original error
|
||||
console.warn('Failed to cleanup WASM memory:', cleanupErr);
|
||||
console.warn("Failed to cleanup WASM memory:", cleanupErr);
|
||||
}
|
||||
}
|
||||
this._reportError(`WASM initialization failed: ${err instanceof Error ? err.message : String(err)}`, err);
|
||||
this._reportError(
|
||||
`WASM initialization failed: ${err instanceof Error ? err.message : String(err)}`,
|
||||
err,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -137,11 +146,10 @@ class RNNoiseProcessor extends AudioWorkletProcessor {
|
||||
|
||||
const inOff = this._inputPtr / 4;
|
||||
const outOff = this._outputPtr / 4;
|
||||
|
||||
|
||||
// CRITICAL: Bounds check before accessing heap
|
||||
if (inOff + FRAME_SIZE > this._heapF32.length ||
|
||||
outOff + FRAME_SIZE > this._heapF32.length) {
|
||||
console.error('WASM heap bounds exceeded');
|
||||
if (inOff + FRAME_SIZE > this._heapF32.length || outOff + FRAME_SIZE > this._heapF32.length) {
|
||||
console.error("WASM heap bounds exceeded");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -179,7 +187,7 @@ class RNNoiseProcessor extends AudioWorkletProcessor {
|
||||
exports.free(this._inputPtr);
|
||||
exports.free(this._outputPtr);
|
||||
} catch (err) {
|
||||
console.warn('RNNoise cleanup failed:', err);
|
||||
console.warn("RNNoise cleanup failed:", err);
|
||||
// Continue cleanup even if individual steps fail
|
||||
}
|
||||
}
|
||||
@@ -220,7 +228,13 @@ class RNNoiseProcessor extends AudioWorkletProcessor {
|
||||
const readStart = this._outReadPos * FRAME_SIZE;
|
||||
const available = FRAME_SIZE - this._outSampleOffset;
|
||||
const toWrite = Math.min(available, outData.length - outIdx);
|
||||
outData.set(this._outBuffer.subarray(readStart + this._outSampleOffset, readStart + this._outSampleOffset + toWrite), outIdx);
|
||||
outData.set(
|
||||
this._outBuffer.subarray(
|
||||
readStart + this._outSampleOffset,
|
||||
readStart + this._outSampleOffset + toWrite,
|
||||
),
|
||||
outIdx,
|
||||
);
|
||||
outIdx += toWrite;
|
||||
this._outSampleOffset += toWrite;
|
||||
if (this._outSampleOffset >= FRAME_SIZE) {
|
||||
@@ -243,10 +257,9 @@ class RNNoiseProcessor extends AudioWorkletProcessor {
|
||||
*/
|
||||
process(inputs, outputs) {
|
||||
if (this._destroyed) return false;
|
||||
|
||||
|
||||
// Validate input/output structure
|
||||
if (!inputs || !inputs[0] || !inputs[0][0] ||
|
||||
!outputs || !outputs[0] || !outputs[0][0]) {
|
||||
if (!inputs || !inputs[0] || !inputs[0][0] || !outputs || !outputs[0] || !outputs[0][0]) {
|
||||
return true; // Pass through silence or existing data
|
||||
}
|
||||
|
||||
@@ -16,15 +16,20 @@ class VadProcessor extends AudioWorkletProcessor {
|
||||
constructor() {
|
||||
super();
|
||||
this._threshold = 0.05;
|
||||
this._gateOnFrames = 12; // ~200ms of silence before gating
|
||||
this._gateOffFrames = 2; // ~33ms of speech before ungating
|
||||
// process() runs once per 128-sample render quantum (2.667ms @ 48kHz —
|
||||
// see audioPipeline.ts's `new AudioContext({ sampleRate: 48000 })`), NOT
|
||||
// once per ~16ms poll like the setTimeout fallback. These frame counts
|
||||
// are therefore ~6x the fallback's, so both paths gate on the same
|
||||
// wall-clock timing.
|
||||
this._gateOnFrames = 75; // ~200ms of silence before gating
|
||||
this._gateOffFrames = 12; // ~32ms of speech before ungating
|
||||
this._silentFrames = 0;
|
||||
this._speechFrames = 0;
|
||||
this._gated = false;
|
||||
this._active = true;
|
||||
this._startupFrames = 0;
|
||||
this._startupGrace = 30; // ~500ms grace period
|
||||
this._frameCounter = 0; // for throttled RMS updates
|
||||
this._startupGrace = 188; // ~500ms grace period
|
||||
this._frameCounter = 0; // for throttled RMS updates
|
||||
|
||||
this.port.onmessage = (event) => {
|
||||
if (event.data.type === "config") {
|
||||
@@ -65,10 +70,10 @@ class VadProcessor extends AudioWorkletProcessor {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Send RMS value to main thread every ~6 frames (~50ms at 128 samples/frame @ 48kHz)
|
||||
// Send RMS value to main thread every ~19 frames (~50ms at 128 samples/frame @ 48kHz)
|
||||
// This is used for the VAD indicator bar in the UI
|
||||
this._frameCounter++;
|
||||
if (this._frameCounter >= 6) {
|
||||
if (this._frameCounter >= 19) {
|
||||
this._frameCounter = 0;
|
||||
this.port.postMessage({ type: "rms", value: rms });
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "owncord-client"
|
||||
version = "1.2.0-alpha.3"
|
||||
version = "1.2.0-alpha.4"
|
||||
edition = "2021"
|
||||
# Effective minimum: tauri 2.11 declares rust-version = "1.77.2", so the crate
|
||||
# cannot build below it. Declaring it here enables Cargo's MSRV-aware resolver
|
||||
@@ -47,7 +47,7 @@ tauri-plugin-fs = "2"
|
||||
tauri-plugin-updater = "2.10"
|
||||
tauri-plugin-process = "2"
|
||||
url = "2"
|
||||
tokio-tungstenite = { version = "0.28.0", features = ["rustls-tls-webpki-roots"] }
|
||||
tokio-tungstenite = { version = "0.30.0", features = ["rustls-tls-webpki-roots"] }
|
||||
futures-util = "0.3.32"
|
||||
tokio = { version = "1", features = ["sync", "net", "io-util", "rt", "macros"] }
|
||||
tokio-rustls = { version = "0.26", default-features = false }
|
||||
@@ -93,6 +93,14 @@ zeroize = "1"
|
||||
# Encodes the DPAPI ciphertext for the JSON fallback store. Already in the tree
|
||||
# via the tauri/rustls stack, so this costs no extra build.
|
||||
base64 = "0.22"
|
||||
# Native message box for the fatal-startup path in lib.rs, where the Tauri app
|
||||
# never built and tauri-plugin-dialog has no AppHandle to run through. Already
|
||||
# in the tree via that same plugin, so this costs no extra build -- but only
|
||||
# while the versions match: the plugin pins ^0.16, and Cargo unifies features
|
||||
# only within a semver-compatible group. Moving this to 0.17 forks rfd into two
|
||||
# crates, and the copy without the plugin's backend features fails rfd 0.17's
|
||||
# build.rs on Linux. Pinned to the plugin in .github/dependabot.yml; bump both
|
||||
# together or neither.
|
||||
rfd = { version = "0.16", default-features = false }
|
||||
|
||||
# Desktop-only plugins (no mobile bundle target). single-instance carries the
|
||||
@@ -1,9 +1,7 @@
|
||||
{
|
||||
"identifier": "default",
|
||||
"description": "Default capability granting core permissions to the main window. NOTE: http:allow-fetch is the ONLY URL-scoped HTTP identifier — tauri-plugin-http validates the URL once, in the `fetch` command; `fetch_send` and `fetch_read_body` take an already-validated ResourceId and never consult a scope, so allow/deny blocks on those identifiers are inert. Do not re-add them.",
|
||||
"windows": [
|
||||
"main"
|
||||
],
|
||||
"windows": ["main"],
|
||||
"permissions": [
|
||||
"core:default",
|
||||
"core:event:default",
|
||||
|
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 35 KiB After Width: | Height: | Size: 35 KiB |
|
Before Width: | Height: | Size: 1.5 KiB After Width: | Height: | Size: 1.5 KiB |
|
Before Width: | Height: | Size: 57 KiB After Width: | Height: | Size: 57 KiB |
|
Before Width: | Height: | Size: 35 KiB After Width: | Height: | Size: 35 KiB |
|
Before Width: | Height: | Size: 1004 B After Width: | Height: | Size: 1004 B |
@@ -2,6 +2,7 @@ use serde_json::Value;
|
||||
use tauri_plugin_store::StoreExt;
|
||||
|
||||
use crate::constants::{CERTS_STORE, IDENTITY_PINS_STORE, SETTINGS_STORE};
|
||||
use crate::ws_proxy::is_valid_cert_fingerprint;
|
||||
|
||||
/// Maximum length for a settings key to prevent denial-of-service.
|
||||
const MAX_SETTINGS_KEY_LEN: usize = 128;
|
||||
@@ -9,13 +10,11 @@ const MAX_SETTINGS_KEY_LEN: usize = 128;
|
||||
/// Allowed key prefixes and exact keys for the settings store.
|
||||
/// Keys must either match an exact entry or start with an allowed prefix.
|
||||
const ALLOWED_SETTINGS_PREFIXES: &[&str] = &[
|
||||
"owncord:", // owncord:profiles, owncord:settings:*, owncord:recent-emoji
|
||||
"userVolume_", // per-user volume: userVolume_{userId}
|
||||
"owncord:", // owncord:profiles, owncord:settings:*, owncord:recent-emoji
|
||||
"userVolume_", // per-user volume: userVolume_{userId}
|
||||
];
|
||||
|
||||
const ALLOWED_SETTINGS_EXACT: &[&str] = &[
|
||||
"windowState",
|
||||
];
|
||||
const ALLOWED_SETTINGS_EXACT: &[&str] = &["windowState"];
|
||||
|
||||
fn is_settings_key_allowed(key: &str) -> bool {
|
||||
if key.len() > MAX_SETTINGS_KEY_LEN || key.is_empty() {
|
||||
@@ -24,7 +23,9 @@ fn is_settings_key_allowed(key: &str) -> bool {
|
||||
if ALLOWED_SETTINGS_EXACT.contains(&key) {
|
||||
return true;
|
||||
}
|
||||
ALLOWED_SETTINGS_PREFIXES.iter().any(|prefix| key.starts_with(prefix))
|
||||
ALLOWED_SETTINGS_PREFIXES
|
||||
.iter()
|
||||
.any(|prefix| key.starts_with(prefix))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -60,9 +61,12 @@ pub fn save_settings(app: tauri::AppHandle, key: String, value: Value) -> Result
|
||||
return Err(format!("unknown settings key: {key}"));
|
||||
}
|
||||
|
||||
let store = app
|
||||
.store(SETTINGS_STORE)
|
||||
.map_err(|e| log_cmd_err("save_settings", format!("failed to open settings store: {e}")))?;
|
||||
let store = app.store(SETTINGS_STORE).map_err(|e| {
|
||||
log_cmd_err(
|
||||
"save_settings",
|
||||
format!("failed to open settings store: {e}"),
|
||||
)
|
||||
})?;
|
||||
|
||||
store.set(&key, value);
|
||||
store
|
||||
@@ -75,6 +79,33 @@ pub fn save_settings(app: tauri::AppHandle, key: String, value: Value) -> Result
|
||||
// Certificate fingerprint commands
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Validate the arguments of a cert-pin write.
|
||||
///
|
||||
/// Split out of `store_cert_fingerprint` so the guard — the only thing standing
|
||||
/// between a caller and a trusted cert pin — is reachable from unit tests
|
||||
/// without a Tauri runtime. The fingerprint half is the same check the
|
||||
/// `accept_cert_fingerprint` path uses, so the two pin writers cannot drift.
|
||||
fn validate_cert_pin(host: &str, fingerprint: &str) -> Result<(), String> {
|
||||
if host.is_empty() || host.len() > 253 {
|
||||
return Err("host must be 1-253 characters".into());
|
||||
}
|
||||
// Validate host format: alphanumeric, dots, hyphens, colons (port), brackets (IPv6)
|
||||
if !host
|
||||
.chars()
|
||||
.all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | ':' | '[' | ']'))
|
||||
{
|
||||
return Err("host contains invalid characters".into());
|
||||
}
|
||||
if fingerprint.is_empty() {
|
||||
return Err("fingerprint must not be empty".into());
|
||||
}
|
||||
// SHA-256 colon-hex format: "aa:bb:cc:..." (95 chars, 32 hex pairs)
|
||||
if !is_valid_cert_fingerprint(fingerprint) {
|
||||
return Err("fingerprint must be a SHA-256 colon-hex string (95 chars)".into());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn store_cert_fingerprint(
|
||||
app: tauri::AppHandle,
|
||||
@@ -84,33 +115,13 @@ pub fn store_cert_fingerprint(
|
||||
// Normalize to lowercase for consistent comparison with ws_proxy fingerprints
|
||||
let fingerprint = fingerprint.to_lowercase();
|
||||
|
||||
if host.is_empty() || host.len() > 253 {
|
||||
return Err("host must be 1-253 characters".into());
|
||||
}
|
||||
// Validate host format: alphanumeric, dots, hyphens, colons (port), brackets (IPv6)
|
||||
if !host.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | ':' | '[' | ']')) {
|
||||
return Err("host contains invalid characters".into());
|
||||
}
|
||||
if fingerprint.is_empty() {
|
||||
return Err("fingerprint must not be empty".into());
|
||||
}
|
||||
|
||||
// Validate SHA-256 colon-hex format: "aa:bb:cc:..." (95 chars, 32 hex pairs)
|
||||
if fingerprint.len() != 95 {
|
||||
return Err("fingerprint must be a SHA-256 colon-hex string (95 chars)".into());
|
||||
}
|
||||
for (i, ch) in fingerprint.chars().enumerate() {
|
||||
if i % 3 == 2 {
|
||||
if ch != ':' {
|
||||
return Err("fingerprint must use colon-separated hex pairs".into());
|
||||
}
|
||||
} else if !ch.is_ascii_hexdigit() {
|
||||
return Err("fingerprint contains invalid hex character".into());
|
||||
}
|
||||
}
|
||||
validate_cert_pin(&host, &fingerprint)?;
|
||||
|
||||
let store = app.store(CERTS_STORE).map_err(|e| {
|
||||
log_cmd_err("store_cert_fingerprint", format!("failed to open certs store: {e}"))
|
||||
log_cmd_err(
|
||||
"store_cert_fingerprint",
|
||||
format!("failed to open certs store: {e}"),
|
||||
)
|
||||
})?;
|
||||
|
||||
// Capture old value before mutating so we can restore it if save fails.
|
||||
@@ -121,8 +132,12 @@ pub fn store_cert_fingerprint(
|
||||
// existed, or delete if there was none. Without this, a failed save
|
||||
// during cert rotation would silently lose the previously trusted cert.
|
||||
match old_value {
|
||||
Some(v) => { store.set(&host, v); }
|
||||
None => { let _ = store.delete(&host); }
|
||||
Some(v) => {
|
||||
store.set(&host, v);
|
||||
}
|
||||
None => {
|
||||
let _ = store.delete(&host);
|
||||
}
|
||||
}
|
||||
return Err(log_cmd_err(
|
||||
"store_cert_fingerprint",
|
||||
@@ -133,10 +148,7 @@ pub fn store_cert_fingerprint(
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn get_cert_fingerprint(
|
||||
app: tauri::AppHandle,
|
||||
host: String,
|
||||
) -> Result<Option<String>, String> {
|
||||
pub fn get_cert_fingerprint(app: tauri::AppHandle, host: String) -> Result<Option<String>, String> {
|
||||
if host.is_empty() {
|
||||
return Err("host must not be empty".into());
|
||||
}
|
||||
@@ -186,13 +198,19 @@ pub fn store_identity_pin(
|
||||
return Err("host must be 1-253 characters".into());
|
||||
}
|
||||
// Validate host format: alphanumeric, dots, hyphens, colons (port), brackets (IPv6)
|
||||
if !host.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | ':' | '[' | ']')) {
|
||||
if !host
|
||||
.chars()
|
||||
.all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | ':' | '[' | ']'))
|
||||
{
|
||||
return Err("host contains invalid characters".into());
|
||||
}
|
||||
if user_id.is_empty() || user_id.len() > 64 {
|
||||
return Err("user_id must be 1-64 characters".into());
|
||||
}
|
||||
if !user_id.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_')) {
|
||||
if !user_id
|
||||
.chars()
|
||||
.all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_'))
|
||||
{
|
||||
return Err("user_id contains invalid characters".into());
|
||||
}
|
||||
if pin.is_empty() || pin.len() > MAX_IDENTITY_PIN_LEN {
|
||||
@@ -200,7 +218,10 @@ pub fn store_identity_pin(
|
||||
}
|
||||
// Base64 charset (standard + url-safe + padding). Guards against garbage/DoS;
|
||||
// the actual key parsing/verification happens on the JS side.
|
||||
if !pin.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '+' | '/' | '=' | '-' | '_')) {
|
||||
if !pin
|
||||
.chars()
|
||||
.all(|c| c.is_ascii_alphanumeric() || matches!(c, '+' | '/' | '=' | '-' | '_'))
|
||||
{
|
||||
return Err("pin contains invalid characters".into());
|
||||
}
|
||||
|
||||
@@ -216,8 +237,12 @@ pub fn store_identity_pin(
|
||||
// Restore previous in-memory state so a failed save during a re-pin
|
||||
// doesn't silently drop the previously trusted identity key.
|
||||
match old_value {
|
||||
Some(v) => { store.set(&store_key, v); }
|
||||
None => { let _ = store.delete(&store_key); }
|
||||
Some(v) => {
|
||||
store.set(&store_key, v);
|
||||
}
|
||||
None => {
|
||||
let _ = store.delete(&store_key);
|
||||
}
|
||||
}
|
||||
return Err(format!("failed to persist identity pin: {e}"));
|
||||
}
|
||||
@@ -311,29 +336,74 @@ mod tests {
|
||||
assert!(!is_settings_key_allowed("owncordNOCOLON"));
|
||||
}
|
||||
|
||||
/// A well-formed SHA-256 colon-hex fingerprint (32 pairs, 95 chars).
|
||||
const VALID_FP: &str =
|
||||
"aa:bb:cc:dd:ee:ff:00:11:22:33:44:55:66:77:88:99:aa:bb:cc:dd:ee:ff:00:11:22:33:44:55:66:77:88:99";
|
||||
|
||||
#[test]
|
||||
fn fingerprint_validation_accepts_valid() {
|
||||
let valid = "aa:bb:cc:dd:ee:ff:00:11:22:33:44:55:66:77:88:99:aa:bb:cc:dd:ee:ff:00:11:22:33:44:55:66:77:88:99";
|
||||
assert_eq!(valid.len(), 95);
|
||||
// Validation logic: length 95, hex digits at non-colon positions, colons at every 3rd
|
||||
for (i, ch) in valid.chars().enumerate() {
|
||||
if i % 3 == 2 {
|
||||
assert_eq!(ch, ':');
|
||||
} else {
|
||||
assert!(ch.is_ascii_hexdigit());
|
||||
}
|
||||
fn cert_pin_accepts_well_formed_args() {
|
||||
assert!(validate_cert_pin("chat.example.com", VALID_FP).is_ok());
|
||||
// Uppercase hex is accepted (the command lowercases before validating).
|
||||
assert!(validate_cert_pin("chat.example.com", &VALID_FP.to_uppercase()).is_ok());
|
||||
// Host with a port, and a bracketed IPv6 literal.
|
||||
assert!(validate_cert_pin("192.168.1.10:8443", VALID_FP).is_ok());
|
||||
assert!(validate_cert_pin("[fe80::1]:8443", VALID_FP).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cert_pin_rejects_malformed_fingerprints() {
|
||||
// Same length and charset, colon one position off.
|
||||
let mut misplaced_colon = VALID_FP.to_owned();
|
||||
misplaced_colon.replace_range(2..4, "a:");
|
||||
// Still 95 chars, but padded with whitespace instead of hex.
|
||||
let leading_space = format!(" {}", &VALID_FP[..94]);
|
||||
let trailing_space = format!("{} ", &VALID_FP[1..]);
|
||||
|
||||
let cases: &[(&str, &str)] = &[
|
||||
("empty", ""),
|
||||
("too short", &VALID_FP[..92]),
|
||||
("too long", "aa:bb:cc:dd:ee:ff:00:11:22:33:44:55:66:77:88:99:aa:bb:cc:dd:ee:ff:00:11:22:33:44:55:66:77:88:99:00"),
|
||||
("non-hex digit", "zz:bb:cc:dd:ee:ff:00:11:22:33:44:55:66:77:88:99:aa:bb:cc:dd:ee:ff:00:11:22:33:44:55:66:77:88:99"),
|
||||
("dash separator", "aa-bb:cc:dd:ee:ff:00:11:22:33:44:55:66:77:88:99:aa:bb:cc:dd:ee:ff:00:11:22:33:44:55:66:77:88:99"),
|
||||
("misplaced colon", &misplaced_colon),
|
||||
("leading space", &leading_space),
|
||||
("trailing space", &trailing_space),
|
||||
];
|
||||
for (name, fp) in cases {
|
||||
assert!(
|
||||
validate_cert_pin("chat.example.com", fp).is_err(),
|
||||
"expected {name} fingerprint to be rejected: {fp:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fingerprint_validation_rejects_wrong_length() {
|
||||
let short = "aa:bb:cc";
|
||||
assert_ne!(short.len(), 95);
|
||||
fn cert_pin_rejects_malformed_hosts() {
|
||||
let cases: &[(&str, String)] = &[
|
||||
("empty", String::new()),
|
||||
("too long", "a".repeat(254)),
|
||||
("space", "chat example.com".into()),
|
||||
("path traversal", "chat.example.com/../evil".into()),
|
||||
("underscore", "chat_example.com".into()),
|
||||
("newline", "chat.example.com\n".into()),
|
||||
];
|
||||
for (name, host) in cases {
|
||||
assert!(
|
||||
validate_cert_pin(host, VALID_FP).is_err(),
|
||||
"expected {name} host to be rejected: {host:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn identity_pin_key_combines_host_and_user() {
|
||||
assert_eq!(identity_pin_key("chat.example.com", "42"), "chat.example.com:42");
|
||||
assert_eq!(identity_pin_key("192.168.1.10:8443", "u_7"), "192.168.1.10:8443:u_7");
|
||||
assert_eq!(
|
||||
identity_pin_key("chat.example.com", "42"),
|
||||
"chat.example.com:42"
|
||||
);
|
||||
assert_eq!(
|
||||
identity_pin_key("192.168.1.10:8443", "u_7"),
|
||||
"192.168.1.10:8443:u_7"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -86,7 +86,9 @@ static CREDENTIAL_LOCK: Mutex<()> = Mutex::new(());
|
||||
/// distrust) rather than propagated, so a panic inside one command cannot
|
||||
/// permanently wedge every credential operation for the rest of the process.
|
||||
fn with_credential_lock<T>(f: impl FnOnce() -> T) -> T {
|
||||
let _guard = CREDENTIAL_LOCK.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
|
||||
let _guard = CREDENTIAL_LOCK
|
||||
.lock()
|
||||
.unwrap_or_else(|poisoned| poisoned.into_inner());
|
||||
f()
|
||||
}
|
||||
|
||||
@@ -353,8 +355,14 @@ mod tests {
|
||||
fn account_names_keep_the_port_that_distinguishes_hosts() {
|
||||
// Two servers on one machine differ only by port; dropping it would
|
||||
// make them share an identity key.
|
||||
assert_ne!(login_account("localhost:8443"), login_account("localhost:9443"));
|
||||
assert_eq!(identity_account("localhost:8443"), "identity:localhost:8443");
|
||||
assert_ne!(
|
||||
login_account("localhost:8443"),
|
||||
login_account("localhost:9443")
|
||||
);
|
||||
assert_eq!(
|
||||
identity_account("localhost:8443"),
|
||||
"identity:localhost:8443"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -374,7 +382,9 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn parse_credential_blob_rejects_malformed_input() {
|
||||
assert!(parse_credential_blob("not json").unwrap_err().contains("not valid JSON"));
|
||||
assert!(parse_credential_blob("not json")
|
||||
.unwrap_err()
|
||||
.contains("not valid JSON"));
|
||||
assert!(parse_credential_blob(r#"{"token":"tok"}"#)
|
||||
.unwrap_err()
|
||||
.contains("missing 'username'"));
|
||||
@@ -454,4 +464,32 @@ mod tests {
|
||||
"two credential-store commands ran their critical section concurrently"
|
||||
);
|
||||
}
|
||||
|
||||
/// `with_credential_lock`'s doc comment promises that poisoning is
|
||||
/// recovered from rather than propagated, so a panic inside one
|
||||
/// credential command cannot permanently wedge every later credential
|
||||
/// operation for the rest of the process. Prove it: panic while holding
|
||||
/// the lock on a spawned thread (which poisons `CREDENTIAL_LOCK`), then
|
||||
/// confirm a later `with_credential_lock` call still runs its closure
|
||||
/// instead of panicking on the poisoned mutex.
|
||||
#[test]
|
||||
fn with_credential_lock_recovers_from_a_poisoned_guard() {
|
||||
use std::thread;
|
||||
|
||||
let poisoning = thread::spawn(|| {
|
||||
with_credential_lock(|| {
|
||||
panic!("boom");
|
||||
});
|
||||
});
|
||||
assert!(
|
||||
poisoning.join().is_err(),
|
||||
"expected the spawned thread to panic while holding the lock"
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
with_credential_lock(|| 42),
|
||||
42,
|
||||
"with_credential_lock must recover from a poisoned mutex, not propagate it"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -47,7 +47,8 @@ impl Drop for OutBlob {
|
||||
// Scrub first: on the unprotect path this buffer holds the plaintext
|
||||
// identity key, and LocalFree does not zero what it releases.
|
||||
// SAFETY: as in `to_vec`, plus the range is ours alone to write.
|
||||
let bytes = unsafe { std::slice::from_raw_parts_mut(self.0.pbData, self.0.cbData as usize) };
|
||||
let bytes =
|
||||
unsafe { std::slice::from_raw_parts_mut(self.0.pbData, self.0.cbData as usize) };
|
||||
bytes.zeroize();
|
||||
// SAFETY: pbData came from DPAPI's LocalAlloc, and `Drop` runs at most
|
||||
// once, so it is freed exactly once.
|
||||
@@ -200,7 +200,10 @@ mod tests {
|
||||
tampered[last] ^= 0x01;
|
||||
assert!(unprotect(&key, &tampered, b"aad").is_err());
|
||||
|
||||
assert!(unprotect(&key, &blob[..NONCE_LEN], b"aad").is_err(), "truncated blob");
|
||||
assert!(
|
||||
unprotect(&key, &blob[..NONCE_LEN], b"aad").is_err(),
|
||||
"truncated blob"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -213,10 +216,8 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn creates_and_reuses_the_key_file() {
|
||||
let dir = std::env::temp_dir().join(format!(
|
||||
"owncord-fallback-key-test-{}",
|
||||
std::process::id()
|
||||
));
|
||||
let dir =
|
||||
std::env::temp_dir().join(format!("owncord-fallback-key-test-{}", std::process::id()));
|
||||
let _ = fs::remove_dir_all(&dir);
|
||||
|
||||
let first = load_or_create_key(&dir).unwrap();
|
||||
@@ -259,7 +260,10 @@ mod tests {
|
||||
.unwrap_err();
|
||||
|
||||
assert!(err.contains("failed to write"), "unexpected error: {err}");
|
||||
assert!(!path.exists(), "a failed write must not leave a partial key file behind");
|
||||
assert!(
|
||||
!path.exists(),
|
||||
"a failed write must not leave a partial key file behind"
|
||||
);
|
||||
|
||||
let _ = fs::remove_dir_all(&dir);
|
||||
}
|
||||
@@ -29,10 +29,10 @@
|
||||
// - The accept loop exits after 5 consecutive errors to prevent CPU spin.
|
||||
|
||||
use log::{debug, error, info, warn};
|
||||
use rustls::pki_types::ServerName;
|
||||
use std::collections::HashMap;
|
||||
use std::net::IpAddr;
|
||||
use std::sync::Arc;
|
||||
use rustls::pki_types::ServerName;
|
||||
use tauri::{AppHandle, Manager, Runtime};
|
||||
use tokio::io::{self, AsyncReadExt, AsyncWriteExt};
|
||||
use tokio::net::{TcpListener, TcpStream};
|
||||
@@ -65,7 +65,10 @@ impl HttpProxyState {
|
||||
/// was mid-shutdown).
|
||||
async fn remove_if_port_matches(&self, remote_host: &str, port: u16) {
|
||||
let mut inner = self.inner.lock().await;
|
||||
if inner.get(remote_host).is_some_and(|entry| entry.port == port) {
|
||||
if inner
|
||||
.get(remote_host)
|
||||
.is_some_and(|entry| entry.port == port)
|
||||
{
|
||||
inner.remove(remote_host);
|
||||
}
|
||||
}
|
||||
@@ -372,7 +375,9 @@ async fn handle_connection<R: Runtime>(
|
||||
.map_err(|_| Box::<dyn std::error::Error + Send + Sync>::from("TCP connect timed out"))??;
|
||||
let mut tls = timeout(Duration::from_secs(10), connector.connect(server_name, tcp))
|
||||
.await
|
||||
.map_err(|_| Box::<dyn std::error::Error + Send + Sync>::from("TLS handshake timed out"))??;
|
||||
.map_err(|_| {
|
||||
Box::<dyn std::error::Error + Send + Sync>::from("TLS handshake timed out")
|
||||
})??;
|
||||
|
||||
let fingerprint = captured_fp
|
||||
.lock()
|
||||
@@ -400,7 +405,10 @@ async fn handle_connection<R: Runtime>(
|
||||
// it (accept_cert_fingerprint) before any credential-bearing request is
|
||||
// sent. The connect page's health check triggers this before login.
|
||||
TofuOutcome::FirstUse => {
|
||||
info!("[http_proxy] first-use cert for {} — awaiting user confirmation", store_key);
|
||||
info!(
|
||||
"[http_proxy] first-use cert for {} — awaiting user confirmation",
|
||||
store_key
|
||||
);
|
||||
crate::ws_proxy::emit_cert_tofu(
|
||||
&app,
|
||||
serde_json::json!({
|
||||
@@ -523,19 +531,20 @@ mod tests {
|
||||
|
||||
// A stale loop reporting a port that no longer matches the live
|
||||
// entry must leave the current entry alone.
|
||||
state
|
||||
.remove_if_port_matches("example.com:8443", 9999)
|
||||
.await;
|
||||
state.remove_if_port_matches("example.com:8443", 9999).await;
|
||||
assert_eq!(
|
||||
state.inner.lock().await.get("example.com:8443").map(|e| e.port),
|
||||
state
|
||||
.inner
|
||||
.lock()
|
||||
.await
|
||||
.get("example.com:8443")
|
||||
.map(|e| e.port),
|
||||
Some(4242),
|
||||
"mismatched port must not remove a newer tunnel's entry"
|
||||
);
|
||||
|
||||
// A loop reporting its own still-current port must remove it.
|
||||
state
|
||||
.remove_if_port_matches("example.com:8443", 4242)
|
||||
.await;
|
||||
state.remove_if_port_matches("example.com:8443", 4242).await;
|
||||
assert!(
|
||||
state.inner.lock().await.get("example.com:8443").is_none(),
|
||||
"matching port must deregister the dead tunnel"
|
||||
@@ -609,13 +618,15 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn rewrite_overrides_existing_keepalive() {
|
||||
let raw =
|
||||
b"POST /x HTTP/1.1\r\nHost: 127.0.0.1:5000\r\nConnection: keep-alive\r\n\r\n";
|
||||
let raw = b"POST /x HTTP/1.1\r\nHost: 127.0.0.1:5000\r\nConnection: keep-alive\r\n\r\n";
|
||||
let out = rewrite_request_headers(raw, "example.com:8443");
|
||||
assert!(out.contains("Connection: close\r\n"));
|
||||
assert!(!out.to_ascii_lowercase().contains("keep-alive"));
|
||||
// Exactly one Connection header.
|
||||
assert_eq!(out.to_ascii_lowercase().matches("\r\nconnection:").count(), 1);
|
||||
assert_eq!(
|
||||
out.to_ascii_lowercase().matches("\r\nconnection:").count(),
|
||||
1
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -38,8 +38,12 @@ pub fn enable_media_capture(app: &AppHandle) {
|
||||
webview.connect_permission_request(|_, request| {
|
||||
// UserMediaPermissionRequest covers getUserMedia (mic/camera);
|
||||
// DeviceInfoPermissionRequest covers enumerateDevices labels.
|
||||
let is_media = request.downcast_ref::<UserMediaPermissionRequest>().is_some()
|
||||
|| request.downcast_ref::<DeviceInfoPermissionRequest>().is_some();
|
||||
let is_media = request
|
||||
.downcast_ref::<UserMediaPermissionRequest>()
|
||||
.is_some()
|
||||
|| request
|
||||
.downcast_ref::<DeviceInfoPermissionRequest>()
|
||||
.is_some();
|
||||
if is_media {
|
||||
request.allow();
|
||||
return true;
|
||||
@@ -28,9 +28,9 @@
|
||||
// - The accept loop exits after 5 consecutive errors to prevent CPU spin.
|
||||
|
||||
use log::{debug, error, info, warn};
|
||||
use rustls::pki_types::ServerName;
|
||||
use std::net::IpAddr;
|
||||
use std::sync::Arc;
|
||||
use rustls::pki_types::ServerName;
|
||||
use tauri::{Manager, Runtime};
|
||||
use tokio::io::{self, AsyncReadExt, AsyncWriteExt};
|
||||
use tokio::net::{TcpListener, TcpStream};
|
||||
@@ -205,16 +205,25 @@ pub async fn start_livekit_proxy<R: Runtime>(
|
||||
// the fingerprint should already be stored. If not, reject — we refuse
|
||||
// to connect without a pinned cert.
|
||||
let store_key = tofu::cert_store_key(&remote_host);
|
||||
let fingerprint = tofu::load_stored_fingerprint(&app, &store_key)?
|
||||
.ok_or_else(|| format!(
|
||||
let fingerprint = tofu::load_stored_fingerprint(&app, &store_key)?.ok_or_else(|| {
|
||||
format!(
|
||||
"no trusted certificate fingerprint for {remote_host}. \
|
||||
Connect via WebSocket first to establish TOFU trust."
|
||||
))?;
|
||||
)
|
||||
})?;
|
||||
|
||||
// Reuse the existing proxy only when host AND pin are unchanged.
|
||||
if let Some(port) = inner.port {
|
||||
if can_reuse_proxy(&inner.remote_host, &inner.pinned_fingerprint, &remote_host, &fingerprint) {
|
||||
debug!("[livekit_proxy] reusing existing proxy on port {} for {}", port, remote_host);
|
||||
if can_reuse_proxy(
|
||||
&inner.remote_host,
|
||||
&inner.pinned_fingerprint,
|
||||
&remote_host,
|
||||
&fingerprint,
|
||||
) {
|
||||
debug!(
|
||||
"[livekit_proxy] reusing existing proxy on port {} for {}",
|
||||
port, remote_host
|
||||
);
|
||||
return Ok(port);
|
||||
}
|
||||
// Different host or re-pinned cert — tear down the old proxy.
|
||||
@@ -256,7 +265,10 @@ pub async fn start_livekit_proxy<R: Runtime>(
|
||||
}
|
||||
});
|
||||
|
||||
info!("[livekit_proxy] proxy started on 127.0.0.1:{} → {}", port, remote_host);
|
||||
info!(
|
||||
"[livekit_proxy] proxy started on 127.0.0.1:{} → {}",
|
||||
port, remote_host
|
||||
);
|
||||
|
||||
inner.port = Some(port);
|
||||
inner.remote_host = remote_host;
|
||||
@@ -268,9 +280,7 @@ pub async fn start_livekit_proxy<R: Runtime>(
|
||||
|
||||
/// Stop the LiveKit TLS proxy if running.
|
||||
#[tauri::command]
|
||||
pub async fn stop_livekit_proxy(
|
||||
state: tauri::State<'_, LiveKitProxyState>,
|
||||
) -> Result<(), String> {
|
||||
pub async fn stop_livekit_proxy(state: tauri::State<'_, LiveKitProxyState>) -> Result<(), String> {
|
||||
let mut inner = state.inner.lock().await;
|
||||
if let Some(tx) = inner.shutdown_tx.take() {
|
||||
let _ = tx.send(());
|
||||
@@ -370,10 +380,15 @@ async fn connect_tls(
|
||||
let tcp = timeout(limit, TcpStream::connect(remote_host))
|
||||
.await
|
||||
.map_err(|_| Box::<dyn std::error::Error + Send + Sync>::from("TCP connect timed out"))??;
|
||||
debug!("[livekit_proxy] starting TLS handshake with {}", remote_host);
|
||||
debug!(
|
||||
"[livekit_proxy] starting TLS handshake with {}",
|
||||
remote_host
|
||||
);
|
||||
let tls = timeout(limit, connector.connect(server_name, tcp))
|
||||
.await
|
||||
.map_err(|_| Box::<dyn std::error::Error + Send + Sync>::from("TLS handshake timed out"))??;
|
||||
.map_err(|_| {
|
||||
Box::<dyn std::error::Error + Send + Sync>::from("TLS handshake timed out")
|
||||
})??;
|
||||
Ok(tls)
|
||||
}
|
||||
|
||||
@@ -413,9 +428,9 @@ async fn handle_connection(
|
||||
Ok::<(), Box<dyn std::error::Error + Send + Sync>>(())
|
||||
})
|
||||
.await
|
||||
.map_err(|_| Box::<dyn std::error::Error + Send + Sync>::from(
|
||||
"upstream header read timed out",
|
||||
))??;
|
||||
.map_err(|_| {
|
||||
Box::<dyn std::error::Error + Send + Sync>::from("upstream header read timed out")
|
||||
})??;
|
||||
|
||||
// Reject CRLF in remote_host before header insertion (defense-in-depth;
|
||||
// primary validation is in start_livekit_proxy).
|
||||
@@ -430,9 +445,9 @@ async fn handle_connection(
|
||||
// ── 3. Connect to remote over TLS ────────────────────────────────────
|
||||
let tls_config = rustls::ClientConfig::builder()
|
||||
.dangerous()
|
||||
.with_custom_certificate_verifier(Arc::new(
|
||||
tofu::PinnedVerifier::new(pinned_fingerprint.to_string()),
|
||||
))
|
||||
.with_custom_certificate_verifier(Arc::new(tofu::PinnedVerifier::new(
|
||||
pinned_fingerprint.to_string(),
|
||||
)))
|
||||
.with_no_client_auth();
|
||||
|
||||
let connector = tokio_rustls::TlsConnector::from(Arc::new(tls_config));
|
||||
@@ -447,7 +462,10 @@ async fn handle_connection(
|
||||
let result = io::copy_bidirectional(&mut local, &mut tls).await;
|
||||
match result {
|
||||
Ok((to_remote, from_remote)) => {
|
||||
debug!("[livekit_proxy] connection closed: {}B sent, {}B received", to_remote, from_remote);
|
||||
debug!(
|
||||
"[livekit_proxy] connection closed: {}B sent, {}B received",
|
||||
to_remote, from_remote
|
||||
);
|
||||
}
|
||||
Err(e) => {
|
||||
debug!("[livekit_proxy] bidirectional copy ended: {}", e);
|
||||
@@ -493,7 +511,10 @@ mod tests {
|
||||
"example.com\nX-Injected: 1",
|
||||
"example.com\r",
|
||||
] {
|
||||
assert!(validate_remote_host(host).is_err(), "should reject {host:?}");
|
||||
assert!(
|
||||
validate_remote_host(host).is_err(),
|
||||
"should reject {host:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -512,7 +533,10 @@ mod tests {
|
||||
"exa mple.com:443",
|
||||
"example.com;evil",
|
||||
] {
|
||||
assert!(validate_remote_host(host).is_err(), "should reject {host:?}");
|
||||
assert!(
|
||||
validate_remote_host(host).is_err(),
|
||||
"should reject {host:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -527,12 +551,22 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn reuses_proxy_only_when_host_and_pin_are_unchanged() {
|
||||
assert!(can_reuse_proxy("example.com:443", "aa:bb", "example.com:443", "aa:bb"));
|
||||
assert!(can_reuse_proxy(
|
||||
"example.com:443",
|
||||
"aa:bb",
|
||||
"example.com:443",
|
||||
"aa:bb"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn restarts_proxy_when_host_changes() {
|
||||
assert!(!can_reuse_proxy("old.example:443", "aa:bb", "new.example:443", "aa:bb"));
|
||||
assert!(!can_reuse_proxy(
|
||||
"old.example:443",
|
||||
"aa:bb",
|
||||
"new.example:443",
|
||||
"aa:bb"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -541,7 +575,12 @@ mod tests {
|
||||
// store). The running listener still pins the old fingerprint, so every
|
||||
// connection through it would fail the TLS handshake — reuse must be
|
||||
// refused so the caller tears down and restarts with the new pin.
|
||||
assert!(!can_reuse_proxy("example.com:443", "aa:bb", "example.com:443", "cc:dd"));
|
||||
assert!(!can_reuse_proxy(
|
||||
"example.com:443",
|
||||
"aa:bb",
|
||||
"example.com:443",
|
||||
"cc:dd"
|
||||
));
|
||||
}
|
||||
|
||||
// ── rewrite_proxy_headers ───────────────────────────────────────────────
|
||||
@@ -749,7 +788,10 @@ mod tests {
|
||||
// next start_livekit_proxy rebinds instead of reusing the dead listener.
|
||||
state.clear_if_port_matches(4242).await;
|
||||
let inner = state.inner.lock().await;
|
||||
assert_eq!(inner.port, None, "matching port must deregister the dead proxy");
|
||||
assert_eq!(
|
||||
inner.port, None,
|
||||
"matching port must deregister the dead proxy"
|
||||
);
|
||||
assert!(inner.remote_host.is_empty());
|
||||
assert!(inner.pinned_fingerprint.is_empty());
|
||||
}
|
||||
@@ -24,8 +24,7 @@ static PTT_VKEY: AtomicI32 = AtomicI32::new(0);
|
||||
/// therefore never reset the stop signal that an earlier thread's `join()` is
|
||||
/// still waiting on — the lost-signal race (ATOMICRACE-001) that a single
|
||||
/// shared flag allowed.
|
||||
static PTT_THREAD: Mutex<Option<(Arc<AtomicBool>, std::thread::JoinHandle<()>)>> =
|
||||
Mutex::new(None);
|
||||
static PTT_THREAD: Mutex<Option<(Arc<AtomicBool>, std::thread::JoinHandle<()>)>> = Mutex::new(None);
|
||||
|
||||
/// Returns true if a VK code is allowed for global capture in ptt_listen_for_key.
|
||||
///
|
||||
@@ -58,7 +57,7 @@ fn is_allowed_ptt_capture_vk(vk: i32) -> bool {
|
||||
0x2D | // Insert
|
||||
0x2E | // Delete
|
||||
0x05 | // Mouse X1
|
||||
0x06 // Mouse X2
|
||||
0x06 // Mouse X2
|
||||
)
|
||||
}
|
||||
|
||||
@@ -73,8 +72,7 @@ fn is_key_down(vk: i32) -> bool {
|
||||
return false;
|
||||
}
|
||||
// SAFETY: GetAsyncKeyState is safe to call with valid VK codes 1-254
|
||||
let state =
|
||||
unsafe { windows::Win32::UI::Input::KeyboardAndMouse::GetAsyncKeyState(vk) };
|
||||
let state = unsafe { windows::Win32::UI::Input::KeyboardAndMouse::GetAsyncKeyState(vk) };
|
||||
// High-order bit set (negative when interpreted as i16) = key is down
|
||||
(state as i16) < 0
|
||||
}
|
||||
@@ -101,7 +99,10 @@ fn is_key_down(vk: i32) -> bool {
|
||||
let Some(keycode) = linux::vk_to_keycode(vk) else {
|
||||
return false;
|
||||
};
|
||||
DEVICE_STATE.with(|ds| ds.as_ref().is_some_and(|ds| ds.get_keys().contains(&keycode)))
|
||||
DEVICE_STATE.with(|ds| {
|
||||
ds.as_ref()
|
||||
.is_some_and(|ds| ds.get_keys().contains(&keycode))
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(not(any(windows, target_os = "linux")))]
|
||||
@@ -444,7 +445,9 @@ pub fn ptt_stop_internal() {
|
||||
#[tauri::command]
|
||||
pub fn ptt_set_key(vk_code: i32) -> Result<(), String> {
|
||||
if vk_code != 0 && !(1..=254).contains(&vk_code) {
|
||||
return Err(format!("invalid virtual key code: {vk_code} (must be 0 or 1-254)"));
|
||||
return Err(format!(
|
||||
"invalid virtual key code: {vk_code} (must be 0 or 1-254)"
|
||||
));
|
||||
}
|
||||
PTT_VKEY.store(vk_code, Ordering::SeqCst);
|
||||
Ok(())
|
||||
@@ -478,8 +481,7 @@ pub async fn ptt_listen_for_key() -> i32 {
|
||||
continue;
|
||||
}
|
||||
// Wait for key release (with its own timeout)
|
||||
let release_deadline =
|
||||
std::time::Instant::now() + Duration::from_secs(5);
|
||||
let release_deadline = std::time::Instant::now() + Duration::from_secs(5);
|
||||
while device_state.get_keys().contains(&key)
|
||||
&& std::time::Instant::now() < release_deadline
|
||||
{
|
||||
@@ -503,8 +505,7 @@ pub async fn ptt_listen_for_key() -> i32 {
|
||||
continue;
|
||||
}
|
||||
if is_key_down(vk) {
|
||||
let release_deadline =
|
||||
std::time::Instant::now() + Duration::from_secs(5);
|
||||
let release_deadline = std::time::Instant::now() + Duration::from_secs(5);
|
||||
while is_key_down(vk) && std::time::Instant::now() < release_deadline {
|
||||
std::thread::sleep(Duration::from_millis(20));
|
||||
}
|
||||
@@ -576,7 +577,11 @@ mod tests {
|
||||
fn ptt_transition_reports_edges_only() {
|
||||
assert_eq!(ptt_transition(0x41, true, false), Some(true), "rising edge");
|
||||
assert_eq!(ptt_transition(0x41, true, true), None, "still held");
|
||||
assert_eq!(ptt_transition(0x41, false, true), Some(false), "falling edge");
|
||||
assert_eq!(
|
||||
ptt_transition(0x41, false, true),
|
||||
Some(false),
|
||||
"falling edge"
|
||||
);
|
||||
assert_eq!(ptt_transition(0x41, false, false), None, "still idle");
|
||||
}
|
||||
|
||||
@@ -635,7 +640,11 @@ mod tests {
|
||||
];
|
||||
|
||||
for (keycode, vk) in cases {
|
||||
assert_eq!(keycode_to_vk(&keycode), vk, "keycode_to_vk failed for {keycode:?}");
|
||||
assert_eq!(
|
||||
keycode_to_vk(&keycode),
|
||||
vk,
|
||||
"keycode_to_vk failed for {keycode:?}"
|
||||
);
|
||||
assert_eq!(
|
||||
vk_to_keycode(vk),
|
||||
Some(keycode),
|
||||
@@ -119,6 +119,13 @@ fn set_with(
|
||||
fallback_set: impl FnOnce(&str, &str) -> Result<(), String>,
|
||||
fallback_clear: impl FnOnce(&str),
|
||||
) -> Result<Backend, String> {
|
||||
// Set only when the keyring write itself failed and a stale prior entry
|
||||
// needs to be purged — but not until the fallback write below has proven
|
||||
// it actually committed a replacement copy. Deleting eagerly here would,
|
||||
// if the fallback write also fails, destroy the only good copy of the
|
||||
// secret and leave nothing anywhere for it to hand off to.
|
||||
let mut purge_stale_keyring_after_fallback_commits = false;
|
||||
|
||||
match keyring_set(account, secret) {
|
||||
Ok(()) => match keyring_get(account) {
|
||||
// The normal path: written and read back byte-for-byte.
|
||||
@@ -160,17 +167,26 @@ fn set_with(
|
||||
// successful write. get() reads the keyring first, so leaving
|
||||
// that stale entry in place would shadow the fresh secret parked
|
||||
// in the fallback below — mirrors the read-back-mismatch arm
|
||||
// above, which purges for the same reason.
|
||||
if let Err(de) = keyring_delete(account) {
|
||||
log::warn!(
|
||||
"{SERVICE}: could not remove a stale keyring entry for '{account}' after a \
|
||||
failed write: {de}"
|
||||
);
|
||||
}
|
||||
// above, which purges for the same reason. But the purge must
|
||||
// wait until fallback_set below has actually committed the
|
||||
// replacement: deleting now, before that write is known to
|
||||
// succeed, risks erasing the last good copy of the secret if the
|
||||
// fallback write fails too.
|
||||
purge_stale_keyring_after_fallback_commits = true;
|
||||
}
|
||||
}
|
||||
|
||||
fallback_set(account, secret)?;
|
||||
|
||||
if purge_stale_keyring_after_fallback_commits {
|
||||
if let Err(de) = keyring_delete(account) {
|
||||
log::warn!(
|
||||
"{SERVICE}: could not remove a stale keyring entry for '{account}' after a \
|
||||
failed write: {de}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
log::warn!(
|
||||
"{SERVICE}: account '{account}' is stored in the encrypted fallback file, not the OS \
|
||||
credential store. See docs/credential-storage.md"
|
||||
@@ -256,9 +272,10 @@ fn compiled_backend_persistence() -> (bool, &'static str) {
|
||||
CredentialPersistence::UntilDelete => (true, "persists until deleted (on disk)"),
|
||||
CredentialPersistence::UntilReboot => (false, "vanishes on reboot (kernel memory)"),
|
||||
CredentialPersistence::ProcessOnly => (false, "vanishes when the process exits"),
|
||||
CredentialPersistence::EntryOnly => {
|
||||
(false, "vanishes with the entry object (the in-memory mock store)")
|
||||
}
|
||||
CredentialPersistence::EntryOnly => (
|
||||
false,
|
||||
"vanishes with the entry object (the in-memory mock store)",
|
||||
),
|
||||
_ => (false, "unrecognized persistence class"),
|
||||
}
|
||||
}
|
||||
@@ -493,7 +510,10 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn fallback_aad_is_account_specific() {
|
||||
assert_ne!(fallback_aad("host.example"), fallback_aad("identity:host.example"));
|
||||
assert_ne!(
|
||||
fallback_aad("host.example"),
|
||||
fallback_aad("identity:host.example")
|
||||
);
|
||||
assert_eq!(fallback_aad("host.example"), fallback_aad("host.example"));
|
||||
}
|
||||
|
||||
@@ -516,13 +536,21 @@ mod tests {
|
||||
// indistinguishable from first login, and the E2EE identity keypair
|
||||
// loader mints and publishes a brand-new identity key on exactly that
|
||||
// signal, invalidating every peer's TOFU pin.
|
||||
let result = get_with("identity:chat.example", |_| Err("keychain locked".to_string()), |_| None);
|
||||
let result = get_with(
|
||||
"identity:chat.example",
|
||||
|_| Err("keychain locked".to_string()),
|
||||
|_| None,
|
||||
);
|
||||
assert_eq!(result, Err("keychain locked".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn get_with_prefers_the_live_keyring_value_over_the_fallback() {
|
||||
let result = get_with("acct", |_| Ok(Some("live".to_string())), |_| Some("stale".to_string()));
|
||||
let result = get_with(
|
||||
"acct",
|
||||
|_| Ok(Some("live".to_string())),
|
||||
|_| Some("stale".to_string()),
|
||||
);
|
||||
assert_eq!(result, Ok(Some("live".to_string())));
|
||||
}
|
||||
|
||||
@@ -561,6 +589,37 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_with_keeps_the_stale_keyring_entry_when_the_write_and_fallback_both_fail() {
|
||||
// The bug: a failed keyring write must not delete the existing
|
||||
// keyring entry before the fallback write it is handing off to has
|
||||
// actually committed. If the fallback write also fails, deleting
|
||||
// first destroys the only good copy of the secret and the caller
|
||||
// (e.g. save_identity_key) gets an Err with nothing left anywhere —
|
||||
// the next get() then returns Ok(None), indistinguishable from
|
||||
// first login.
|
||||
use std::cell::Cell;
|
||||
let delete_called = Cell::new(false);
|
||||
let result = set_with(
|
||||
"acct",
|
||||
"new-secret",
|
||||
|_, _| Err("write failed".to_string()),
|
||||
|_| panic!("keyring_get must not run after a failed write"),
|
||||
|_| {
|
||||
delete_called.set(true);
|
||||
Ok(())
|
||||
},
|
||||
|_, _| Err("fallback failed too".to_string()),
|
||||
|_| {},
|
||||
);
|
||||
assert!(result.is_err());
|
||||
assert!(
|
||||
!delete_called.get(),
|
||||
"a failed keyring write must not delete the existing entry until the fallback \
|
||||
write has actually committed a replacement copy"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_with_returns_keyring_backend_when_the_write_round_trips() {
|
||||
use std::cell::Cell;
|
||||
@@ -578,7 +637,72 @@ mod tests {
|
||||
|_| cleared.set(true),
|
||||
);
|
||||
assert_eq!(result, Ok(Backend::Keyring));
|
||||
assert!(cleared.get(), "a recovered machine must clear any stale fallback copy");
|
||||
assert!(
|
||||
cleared.get(),
|
||||
"a recovered machine must clear any stale fallback copy"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_with_purges_the_keyring_entry_when_the_read_back_returns_a_different_secret() {
|
||||
// The bug: get() reads the keyring first, so a foreign value left in
|
||||
// place would shadow the fallback copy written below — handing the
|
||||
// caller an identity key whose public half was never published.
|
||||
use std::cell::Cell;
|
||||
let deleted = Cell::new(false);
|
||||
let fallback_written = Cell::new(false);
|
||||
let result = set_with(
|
||||
"acct",
|
||||
"mine",
|
||||
|_, _| Ok(()),
|
||||
|_| Ok(Some("someone-elses-secret".to_string())),
|
||||
|_| {
|
||||
deleted.set(true);
|
||||
Ok(())
|
||||
},
|
||||
|_, s| {
|
||||
assert_eq!(s, "mine");
|
||||
fallback_written.set(true);
|
||||
Ok(())
|
||||
},
|
||||
|_| panic!("must not clear the fallback copy it just wrote"),
|
||||
);
|
||||
assert_eq!(result, Ok(FALLBACK_BACKEND));
|
||||
assert!(
|
||||
deleted.get(),
|
||||
"a mismatched keyring entry must be purged, not left to shadow the fallback"
|
||||
);
|
||||
assert!(
|
||||
fallback_written.get(),
|
||||
"the secret must still land in the fallback"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_with_falls_back_when_the_read_back_reports_no_entry() {
|
||||
// The shipped keyring-mock defect: set_password returns Ok(()) and the
|
||||
// very next get_password returns nothing. A write that does not read
|
||||
// back is not a write.
|
||||
use std::cell::Cell;
|
||||
let fallback_written = Cell::new(false);
|
||||
let result = set_with(
|
||||
"acct",
|
||||
"secret",
|
||||
|_, _| Ok(()),
|
||||
|_| Ok(None),
|
||||
|_| panic!("nothing round-tripped, so there is no entry to delete"),
|
||||
|_, s| {
|
||||
assert_eq!(s, "secret");
|
||||
fallback_written.set(true);
|
||||
Ok(())
|
||||
},
|
||||
|_| panic!("must not clear the fallback copy it just wrote"),
|
||||
);
|
||||
assert_eq!(result, Ok(FALLBACK_BACKEND));
|
||||
assert!(
|
||||
fallback_written.get(),
|
||||
"a write that does not read back must land in the fallback"
|
||||
);
|
||||
}
|
||||
|
||||
// -- delete_with: finding "delete must not report success while the
|
||||
@@ -623,7 +747,11 @@ mod tests {
|
||||
fn dpapi_round_trips_and_rejects_foreign_entropy() {
|
||||
let secret = b"eyJrdHkiOiJFQyIsImNydiI6IlAtMjU2In0";
|
||||
let blob = crate::dpapi::protect(secret, &fallback_aad("identity:a.example")).unwrap();
|
||||
assert_ne!(blob.as_slice(), secret.as_slice(), "blob must not be plaintext");
|
||||
assert_ne!(
|
||||
blob.as_slice(),
|
||||
secret.as_slice(),
|
||||
"blob must not be plaintext"
|
||||
);
|
||||
|
||||
let back = crate::dpapi::unprotect(&blob, &fallback_aad("identity:a.example")).unwrap();
|
||||
assert_eq!(back, secret);
|
||||
@@ -80,7 +80,12 @@ pub(crate) struct CaptureVerifier {
|
||||
impl CaptureVerifier {
|
||||
pub(crate) fn new() -> (Self, CapturedFingerprint) {
|
||||
let fp = Arc::new(std::sync::Mutex::new(None));
|
||||
(Self { captured: fp.clone() }, fp)
|
||||
(
|
||||
Self {
|
||||
captured: fp.clone(),
|
||||
},
|
||||
fp,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -134,7 +139,9 @@ pub(crate) struct PinnedVerifier {
|
||||
|
||||
impl PinnedVerifier {
|
||||
pub(crate) fn new(expected_fingerprint: String) -> Self {
|
||||
Self { expected_fingerprint }
|
||||
Self {
|
||||
expected_fingerprint,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -203,7 +210,11 @@ impl HostScopedVerifier {
|
||||
)
|
||||
.build()
|
||||
.map_err(|e| format!("failed to build web-PKI verifier: {e}"))?;
|
||||
Ok(Self::with_default(pinned_host, expected_fingerprint, default))
|
||||
Ok(Self::with_default(
|
||||
pinned_host,
|
||||
expected_fingerprint,
|
||||
default,
|
||||
))
|
||||
}
|
||||
|
||||
/// Seam for tests: inject the verifier used for non-pinned hosts.
|
||||
@@ -247,11 +258,21 @@ impl rustls::client::danger::ServerCertVerifier for HostScopedVerifier {
|
||||
now: rustls::pki_types::UnixTime,
|
||||
) -> Result<rustls::client::danger::ServerCertVerified, rustls::Error> {
|
||||
if self.is_pinned_host(server_name) {
|
||||
self.pinned
|
||||
.verify_server_cert(end_entity, intermediates, server_name, ocsp_response, now)
|
||||
self.pinned.verify_server_cert(
|
||||
end_entity,
|
||||
intermediates,
|
||||
server_name,
|
||||
ocsp_response,
|
||||
now,
|
||||
)
|
||||
} else {
|
||||
self.default
|
||||
.verify_server_cert(end_entity, intermediates, server_name, ocsp_response, now)
|
||||
self.default.verify_server_cert(
|
||||
end_entity,
|
||||
intermediates,
|
||||
server_name,
|
||||
ocsp_response,
|
||||
now,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -288,8 +309,34 @@ impl rustls::client::danger::ServerCertVerifier for HostScopedVerifier {
|
||||
/// parsed on the TS side, which lowercases) — without folding case here, two
|
||||
/// callers with the same server in different case would pin/read different
|
||||
/// entries, opening a second, unpinned proxy tunnel.
|
||||
///
|
||||
/// Also strips brackets from a *portless* bracketed IPv6 literal ("[::1]" →
|
||||
/// "::1"), after the `:443` strip above runs (so "[::1]:443" also unwraps).
|
||||
/// The ws proxy computes this key from a bracketed `wss://[::1]/...`
|
||||
/// authority (ws.ts's `bracketBareIPv6Host` has to bracket a bare IPv6 host
|
||||
/// for the URL to parse at all — see OC-0163), while the http/livekit proxies
|
||||
/// may see the bare or default-port-bracketed form of the very same server —
|
||||
/// without unwrapping here those resolve to different keys and the same
|
||||
/// server's certificate gets pinned (and re-confirmed by the user) twice. A
|
||||
/// *non-default* port keeps its brackets: "[::1]:8443" stays its own distinct
|
||||
/// key, matching how a plain "host:8443" is never collapsed into "host".
|
||||
pub(crate) fn cert_store_key(host: &str) -> String {
|
||||
host.strip_suffix(":443").unwrap_or(host).to_ascii_lowercase()
|
||||
// Only strip a trailing ":443" when what's left is unambiguously a host
|
||||
// (no remaining colon) or a bracketed IPv6 literal (ends in `]`, as in
|
||||
// "[::1]:443"). Without this guard, a BARE IPv6 literal whose final
|
||||
// hextet is "443" — e.g. "fd00::443" — would have that hextet eaten as
|
||||
// if it were a port, truncating the address to "fd00:" and pinning the
|
||||
// same server under a different key than the ws/livekit proxies use for
|
||||
// the bracketed form of the same address (OC-0215).
|
||||
let stripped = match host.strip_suffix(":443") {
|
||||
Some(rest) if !rest.contains(':') || rest.ends_with(']') => rest,
|
||||
_ => host,
|
||||
};
|
||||
let unbracketed = stripped
|
||||
.strip_prefix('[')
|
||||
.and_then(|rest| rest.strip_suffix(']'))
|
||||
.unwrap_or(stripped);
|
||||
unbracketed.to_ascii_lowercase()
|
||||
}
|
||||
|
||||
/// Extract the host (with any non-default port) from a `wss://` URL.
|
||||
@@ -384,7 +431,9 @@ mod tests {
|
||||
fn decide_mismatch_when_pin_differs() {
|
||||
assert_eq!(
|
||||
decide(Some("aa:bb".into()), "cc:dd"),
|
||||
TofuOutcome::Mismatch { stored: "aa:bb".into() }
|
||||
TofuOutcome::Mismatch {
|
||||
stored: "aa:bb".into()
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
@@ -395,6 +444,49 @@ mod tests {
|
||||
assert_eq!(cert_store_key("example.com:8443"), "example.com:8443");
|
||||
}
|
||||
|
||||
// OC-0163: ws_connect (via extract_host on a bracketed "wss://[::1]/..."
|
||||
// URL, once ws.ts brackets a bare IPv6 host to make it parse) and
|
||||
// start_http_proxy/start_livekit_proxy (which see the bare or
|
||||
// livekit-bracketed form of the SAME server) must resolve to the SAME
|
||||
// pin, or the user is prompted to accept the first-use certificate twice
|
||||
// for one server. A bracketed literal with a non-default port keeps its
|
||||
// own distinct key, matching the un-bracketed "host:port" behavior above.
|
||||
#[test]
|
||||
fn cert_store_key_treats_bracketed_and_bare_ipv6_as_the_same_host() {
|
||||
assert_eq!(
|
||||
cert_store_key("[2001:db8::1]"),
|
||||
cert_store_key("2001:db8::1")
|
||||
);
|
||||
assert_eq!(cert_store_key("2001:db8::1"), "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
|
||||
// same key as the portless forms above.
|
||||
assert_eq!(cert_store_key("[2001:db8::1]:443"), "2001:db8::1");
|
||||
// A non-default port keeps the brackets — it is a genuinely distinct
|
||||
// key from the default-port host, same as the plain "host:port" case.
|
||||
assert_eq!(cert_store_key("[2001:db8::1]:8443"), "[2001:db8::1]:8443");
|
||||
}
|
||||
|
||||
// OC-0215: a BARE (unbracketed) IPv6 literal whose final hextet happens to
|
||||
// be "443" must NOT have that hextet eaten by the ":443" default-port
|
||||
// strip — "fd00::443" is a whole address, not "fd00::" on port 443. The
|
||||
// http proxy passes bare hosts verbatim (http_proxy::split_host_port has
|
||||
// an explicit `!host.contains(':')` guard for exactly this reason), while
|
||||
// the ws/livekit proxies see the bracketed form of the same address. All
|
||||
// three MUST resolve to the same key or the same server's certificate is
|
||||
// pinned (and re-confirmed by the user) under two different entries.
|
||||
#[test]
|
||||
fn cert_store_key_does_not_truncate_bare_ipv6_ending_in_443() {
|
||||
assert_eq!(cert_store_key("fd00::443"), "fd00::443");
|
||||
// Must agree with the bracketed forms the ws/livekit proxies derive
|
||||
// for the very same server.
|
||||
assert_eq!(cert_store_key("fd00::443"), cert_store_key("[fd00::443]"));
|
||||
assert_eq!(
|
||||
cert_store_key("fd00::443"),
|
||||
cert_store_key("[fd00::443]:443")
|
||||
);
|
||||
}
|
||||
|
||||
// DNS names are case-insensitive, but a raw host string (a profile-entered
|
||||
// host, or one taken verbatim from a wss:// URL) is not normalized before
|
||||
// reaching here. Two call sites can derive the SAME host in different
|
||||
@@ -412,7 +504,10 @@ mod tests {
|
||||
#[test]
|
||||
fn extract_host_variants() {
|
||||
assert_eq!(extract_host("wss://example.com/chat"), "example.com");
|
||||
assert_eq!(extract_host("wss://example.com:8443/chat"), "example.com:8443");
|
||||
assert_eq!(
|
||||
extract_host("wss://example.com:8443/chat"),
|
||||
"example.com:8443"
|
||||
);
|
||||
assert_eq!(extract_host("wss://example.com:443/chat"), "example.com");
|
||||
assert_eq!(extract_host("wss://example.com"), "example.com");
|
||||
assert_eq!(extract_host("example.com/path"), "example.com");
|
||||
@@ -428,6 +523,37 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
// ── CaptureVerifier ──────────────────────────────────────────────────────
|
||||
|
||||
// The whole post-handshake TOFU pin depends on CaptureVerifier recording
|
||||
// the LEAF cert, not an intermediate — that's what the safety comment at
|
||||
// the top of the impl asserts. Prove it: feed it a leaf plus a different
|
||||
// intermediate and check which fingerprint lands in the shared cell.
|
||||
#[test]
|
||||
fn capture_verifier_records_leaf_not_intermediate() {
|
||||
use rustls::client::danger::ServerCertVerifier;
|
||||
|
||||
let (verifier, captured) = CaptureVerifier::new();
|
||||
let leaf = rustls::pki_types::CertificateDer::from(b"leaf-cert".to_vec());
|
||||
let intermediate = rustls::pki_types::CertificateDer::from(b"intermediate-cert".to_vec());
|
||||
let name = rustls::pki_types::ServerName::try_from("example.com".to_string()).unwrap();
|
||||
|
||||
let result = verifier.verify_server_cert(
|
||||
&leaf,
|
||||
&[intermediate],
|
||||
&name,
|
||||
&[],
|
||||
rustls::pki_types::UnixTime::since_unix_epoch(std::time::Duration::from_secs(0)),
|
||||
);
|
||||
|
||||
// Accepts unconditionally — the TOFU gate happens after the handshake.
|
||||
assert!(result.is_ok());
|
||||
assert_eq!(
|
||||
captured.lock().unwrap().as_deref(),
|
||||
Some(fingerprint_hex(b"leaf-cert").as_str())
|
||||
);
|
||||
}
|
||||
|
||||
// ── HostScopedVerifier ──────────────────────────────────────────────────
|
||||
|
||||
/// Stub for the non-pinned-host verifier: records nothing, just returns a
|
||||
@@ -480,7 +606,9 @@ mod tests {
|
||||
HostScopedVerifier::with_default(
|
||||
pinned_host.to_string(),
|
||||
fingerprint_hex(cert_bytes),
|
||||
Arc::new(StubVerifier { accept: stub_accepts }),
|
||||
Arc::new(StubVerifier {
|
||||
accept: stub_accepts,
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -14,23 +14,16 @@ const QUIT_ID: &str = "quit";
|
||||
pub fn create_tray<R: Runtime>(app: &tauri::AppHandle<R>) -> Result<(), tauri::Error> {
|
||||
let show_hide = MenuItem::with_id(app, SHOW_HIDE_ID, "Show/Hide", true, None::<&str>)?;
|
||||
|
||||
let status_online =
|
||||
MenuItem::with_id(app, STATUS_ONLINE_ID, "Online", true, None::<&str>)?;
|
||||
let status_online = MenuItem::with_id(app, STATUS_ONLINE_ID, "Online", true, None::<&str>)?;
|
||||
let status_idle = MenuItem::with_id(app, STATUS_IDLE_ID, "Idle", true, None::<&str>)?;
|
||||
let status_dnd = MenuItem::with_id(app, STATUS_DND_ID, "Do Not Disturb", true, None::<&str>)?;
|
||||
let status_offline =
|
||||
MenuItem::with_id(app, STATUS_OFFLINE_ID, "Offline", true, None::<&str>)?;
|
||||
let status_offline = MenuItem::with_id(app, STATUS_OFFLINE_ID, "Offline", true, None::<&str>)?;
|
||||
|
||||
let status_submenu = Submenu::with_items(
|
||||
app,
|
||||
"Status",
|
||||
true,
|
||||
&[
|
||||
&status_online,
|
||||
&status_idle,
|
||||
&status_dnd,
|
||||
&status_offline,
|
||||
],
|
||||
&[&status_online, &status_idle, &status_dnd, &status_offline],
|
||||
)?;
|
||||
|
||||
let quit = MenuItem::with_id(app, QUIT_ID, "Quit", true, None::<&str>)?;
|
||||
@@ -41,7 +34,11 @@ pub fn create_tray<R: Runtime>(app: &tauri::AppHandle<R>) -> Result<(), tauri::E
|
||||
let app_handle_menu = app.clone();
|
||||
|
||||
TrayIconBuilder::new()
|
||||
.icon(app.default_window_icon().cloned().unwrap_or_else(|| tauri::image::Image::new(&[], 1, 1)))
|
||||
.icon(
|
||||
app.default_window_icon()
|
||||
.cloned()
|
||||
.unwrap_or_else(|| tauri::image::Image::new(&[], 1, 1)),
|
||||
)
|
||||
.menu(&menu)
|
||||
.tooltip("OwnCord")
|
||||
.on_tray_icon_event(move |_tray, event| {
|
||||
@@ -1,5 +1,5 @@
|
||||
use std::sync::Arc;
|
||||
use serde::Serialize;
|
||||
use std::sync::Arc;
|
||||
use tauri::{AppHandle, Emitter};
|
||||
use tauri_plugin_updater::UpdaterExt;
|
||||
|
||||
@@ -23,9 +23,10 @@ struct DownloadProgress {
|
||||
|
||||
/// Extract the host (with port if non-443) from an https:// URL for cert store lookup.
|
||||
fn extract_host_for_cert_store(server_url: &str) -> Result<String, String> {
|
||||
let parsed = url::Url::parse(server_url)
|
||||
.map_err(|e| format!("failed to parse server URL: {e}"))?;
|
||||
let host = parsed.host_str()
|
||||
let parsed =
|
||||
url::Url::parse(server_url).map_err(|e| format!("failed to parse server URL: {e}"))?;
|
||||
let host = parsed
|
||||
.host_str()
|
||||
.ok_or_else(|| "server URL has no host".to_string())?;
|
||||
let port = parsed.port().unwrap_or(443);
|
||||
let raw = if port == 443 {
|
||||
@@ -41,7 +42,10 @@ fn extract_host_for_cert_store(server_url: &str) -> Result<String, String> {
|
||||
/// HTTP client also downloads the installer from GitHub, whose certificate
|
||||
/// must pass normal web-PKI validation instead (a client-wide pin would
|
||||
/// reject it and every install would fail).
|
||||
fn build_tls_config(app: &AppHandle, server_url: &str) -> Result<Option<rustls::ClientConfig>, String> {
|
||||
fn build_tls_config(
|
||||
app: &AppHandle,
|
||||
server_url: &str,
|
||||
) -> Result<Option<rustls::ClientConfig>, String> {
|
||||
let store_key = extract_host_for_cert_store(server_url)?;
|
||||
let fingerprint = load_stored_fingerprint(app, &store_key)?;
|
||||
match fingerprint {
|
||||
@@ -164,10 +168,7 @@ pub async fn check_client_update(
|
||||
/// The frontend should call `relaunch()` from @tauri-apps/plugin-process
|
||||
/// after this completes.
|
||||
#[tauri::command]
|
||||
pub async fn download_and_install_update(
|
||||
app: AppHandle,
|
||||
server_url: String,
|
||||
) -> Result<(), String> {
|
||||
pub async fn download_and_install_update(app: AppHandle, server_url: String) -> Result<(), String> {
|
||||
let updater = build_updater(&app, &server_url)?;
|
||||
|
||||
let update = updater
|
||||
@@ -220,4 +221,39 @@ mod tests {
|
||||
"https://chat.example.com:8443/api/v1/client-update/{{target}}-{{arch}}-{{bundle_type}}/0.0.0"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_server_url_rejects_unsafe_urls() {
|
||||
// build_updater() calls this first, so it is the only guard before the
|
||||
// updater downloads and runs an installer from this host.
|
||||
let scheme = "server_url must use https:// scheme";
|
||||
let userinfo = "server_url must not contain userinfo";
|
||||
for (url, want_err) in [
|
||||
("http://chat.example.com", scheme),
|
||||
("ftp://chat.example.com", scheme),
|
||||
("chat.example.com", scheme),
|
||||
// Case-sensitive on purpose: anything not literally https:// is out.
|
||||
("HTTPS://chat.example.com", scheme),
|
||||
("https://evil@chat.example.com", userinfo),
|
||||
("https://user:pass@chat.example.com", userinfo),
|
||||
("https://:pass@chat.example.com", userinfo),
|
||||
] {
|
||||
assert_eq!(
|
||||
validate_server_url(url),
|
||||
Err(want_err.to_string()),
|
||||
"expected {url} to be rejected"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_server_url_accepts_plain_https() {
|
||||
for url in [
|
||||
"https://chat.example.com",
|
||||
"https://chat.example.com/",
|
||||
"https://chat.example.com:8443/",
|
||||
] {
|
||||
assert_eq!(validate_server_url(url), Ok(()), "expected {url} to pass");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -144,20 +144,19 @@ pub async fn ws_connect<R: Runtime>(
|
||||
.with_custom_certificate_verifier(Arc::new(verifier))
|
||||
.with_no_client_auth();
|
||||
|
||||
let connector =
|
||||
tokio_tungstenite::Connector::Rustls(Arc::new(tls_config));
|
||||
let connector = tokio_tungstenite::Connector::Rustls(Arc::new(tls_config));
|
||||
|
||||
let connect_future = tokio_tungstenite::connect_async_tls_with_config(
|
||||
&url,
|
||||
None,
|
||||
false,
|
||||
Some(connector),
|
||||
);
|
||||
let connect_future =
|
||||
tokio_tungstenite::connect_async_tls_with_config(&url, None, false, Some(connector));
|
||||
|
||||
let (ws_stream, _response) = tokio::time::timeout(CONNECT_TIMEOUT, connect_future)
|
||||
.await
|
||||
.map_err(|_| {
|
||||
error!("[ws_proxy] connect timed out after {}s to {}", CONNECT_TIMEOUT.as_secs(), url);
|
||||
error!(
|
||||
"[ws_proxy] connect timed out after {}s to {}",
|
||||
CONNECT_TIMEOUT.as_secs(),
|
||||
url
|
||||
);
|
||||
format!("ws connect timed out after {}s", CONNECT_TIMEOUT.as_secs())
|
||||
})?
|
||||
.map_err(|e| {
|
||||
@@ -182,19 +181,28 @@ pub async fn ws_connect<R: Runtime>(
|
||||
match tofu::evaluate(&app, &host, &fingerprint)? {
|
||||
TofuOutcome::Trusted => {
|
||||
info!("[ws_proxy] TOFU check passed for {}", host);
|
||||
emit_cert_tofu(&app, serde_json::json!({
|
||||
"host": host,
|
||||
"fingerprint": fingerprint,
|
||||
"status": "trusted",
|
||||
}));
|
||||
emit_cert_tofu(
|
||||
&app,
|
||||
serde_json::json!({
|
||||
"host": host,
|
||||
"fingerprint": fingerprint,
|
||||
"status": "trusted",
|
||||
}),
|
||||
);
|
||||
}
|
||||
TofuOutcome::FirstUse => {
|
||||
info!("[ws_proxy] first-use cert for {} — awaiting user confirmation", host);
|
||||
emit_cert_tofu(&app, serde_json::json!({
|
||||
"host": host,
|
||||
"fingerprint": fingerprint,
|
||||
"status": "first_use",
|
||||
}));
|
||||
info!(
|
||||
"[ws_proxy] first-use cert for {} — awaiting user confirmation",
|
||||
host
|
||||
);
|
||||
emit_cert_tofu(
|
||||
&app,
|
||||
serde_json::json!({
|
||||
"host": host,
|
||||
"fingerprint": fingerprint,
|
||||
"status": "first_use",
|
||||
}),
|
||||
);
|
||||
// Do not open the socket: the user must confirm the fingerprint
|
||||
// (accept_cert_fingerprint) before anything is sent over it.
|
||||
return Err(format!(
|
||||
@@ -203,15 +211,21 @@ pub async fn ws_connect<R: Runtime>(
|
||||
}
|
||||
TofuOutcome::Mismatch { stored } => {
|
||||
let msg = tofu::mismatch_message(&host, &stored, &fingerprint);
|
||||
warn!("[ws_proxy] TOFU check FAILED for {} — certificate fingerprint mismatch", host);
|
||||
warn!(
|
||||
"[ws_proxy] TOFU check FAILED for {} — certificate fingerprint mismatch",
|
||||
host
|
||||
);
|
||||
debug!("[ws_proxy] TOFU detail: {}", msg);
|
||||
emit_cert_tofu(&app, serde_json::json!({
|
||||
"host": host,
|
||||
"fingerprint": fingerprint,
|
||||
"status": "mismatch",
|
||||
"message": msg,
|
||||
"storedFingerprint": stored,
|
||||
}));
|
||||
emit_cert_tofu(
|
||||
&app,
|
||||
serde_json::json!({
|
||||
"host": host,
|
||||
"fingerprint": fingerprint,
|
||||
"status": "mismatch",
|
||||
"message": msg,
|
||||
"storedFingerprint": stored,
|
||||
}),
|
||||
);
|
||||
// Reject the connection — do not proceed.
|
||||
return Err(msg);
|
||||
}
|
||||
@@ -311,10 +325,7 @@ pub async fn ws_connect<R: Runtime>(
|
||||
|
||||
/// Send a text message through the proxy WebSocket.
|
||||
#[tauri::command]
|
||||
pub async fn ws_send(
|
||||
state: tauri::State<'_, WsState>,
|
||||
message: String,
|
||||
) -> Result<(), String> {
|
||||
pub async fn ws_send(state: tauri::State<'_, WsState>, message: String) -> Result<(), String> {
|
||||
let tx_lock = state.tx.lock().await;
|
||||
if let Some(tx) = tx_lock.as_ref() {
|
||||
match tx.try_send(message) {
|
||||
@@ -395,8 +406,12 @@ pub fn accept_cert_fingerprint<R: Runtime>(
|
||||
// fingerprint would be trusted in-process even though it was never
|
||||
// persisted to certs.json.
|
||||
match old_value {
|
||||
Some(v) => { store.set(&host, v); }
|
||||
None => { let _ = store.delete(&host); }
|
||||
Some(v) => {
|
||||
store.set(&host, v);
|
||||
}
|
||||
None => {
|
||||
let _ = store.delete(&host);
|
||||
}
|
||||
}
|
||||
log::warn!("[ws_proxy] accept_cert_fingerprint: failed to persist pin for {host}: {e}");
|
||||
return Err(format!("failed to persist cert fingerprint: {e}"));
|
||||
@@ -591,7 +606,10 @@ mod tests {
|
||||
let got = tokio::time::timeout(Duration::from_secs(1), rx.recv())
|
||||
.await
|
||||
.expect("write task would hang forever: channel still open after disconnect");
|
||||
assert_eq!(got, None, "rx.recv() must yield None so the write task exits");
|
||||
assert_eq!(
|
||||
got, None,
|
||||
"rx.recv() must yield None so the write task exits"
|
||||
);
|
||||
}
|
||||
|
||||
// B4_conn_ipc-9: ws_disconnect must invalidate an in-flight ws_connect
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"productName": "OwnCord",
|
||||
"version": "1.2.0-alpha.3",
|
||||
"version": "1.2.0-alpha.4",
|
||||
"identifier": "com.owncord.client",
|
||||
"build": {
|
||||
"frontendDist": "../dist",
|
||||
@@ -30,17 +30,8 @@
|
||||
"bundle": {
|
||||
"active": true,
|
||||
"createUpdaterArtifacts": "v1Compatible",
|
||||
"targets": [
|
||||
"nsis",
|
||||
"appimage",
|
||||
"deb"
|
||||
],
|
||||
"icon": [
|
||||
"icons/32x32.png",
|
||||
"icons/128x128.png",
|
||||
"icons/128x128@2x.png",
|
||||
"icons/icon.ico"
|
||||
],
|
||||
"targets": ["nsis", "appimage", "deb"],
|
||||
"icon": ["icons/32x32.png", "icons/128x128.png", "icons/128x128@2x.png", "icons/icon.ico"],
|
||||
"linux": {
|
||||
"deb": {
|
||||
"depends": [
|
||||
|
Before Width: | Height: | Size: 5.8 KiB After Width: | Height: | Size: 5.8 KiB |
@@ -26,7 +26,7 @@ import { attachDragHandlers } from "./channel-sidebar/drag-reorder";
|
||||
import { rePinPeerIdentity } from "@lib/livekitSession";
|
||||
import { createIdentityMismatchModal } from "./CertMismatchModal";
|
||||
import { createLogger } from "@lib/logger";
|
||||
import { membersStore } from "@stores/members.store";
|
||||
import { membersStore, memberDisplayName } from "@stores/members.store";
|
||||
import { roleHasPermission, canManageChannels } from "@lib/permissions";
|
||||
import { Permission } from "@lib/types";
|
||||
import { importIdentityPublicKey, computeKeyFingerprint } from "@lib/e2eeCrypto";
|
||||
@@ -71,10 +71,16 @@ function verifyPresentation(v: PeerVerification): {
|
||||
};
|
||||
}
|
||||
// "unverified" — the remaining status: peer published no identity key (legacy).
|
||||
// No identity key means no safety number; the per-call session fingerprint
|
||||
// is the only value that can be compared out of band (OC-0003).
|
||||
return {
|
||||
icon: "shield",
|
||||
color: "var(--text-muted, #949ba4)",
|
||||
title: "Identity not verified — this participant published no key",
|
||||
title:
|
||||
"Identity not verified — this participant published no key." +
|
||||
(v.sessionFingerprint !== null
|
||||
? ` Session fingerprint (changes every call — not an identity): ${v.sessionFingerprint}`
|
||||
: ""),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -94,7 +100,7 @@ function closeIdentityModal(): void {
|
||||
async function openIdentityMismatchModal(
|
||||
userId: number,
|
||||
username: string,
|
||||
signal: AbortSignal,
|
||||
lifetimeSignal: AbortSignal,
|
||||
): Promise<void> {
|
||||
closeIdentityModal();
|
||||
// Compute the newly-delivered key's fingerprint so the user can verify it
|
||||
@@ -111,8 +117,14 @@ async function openIdentityMismatchModal(
|
||||
log.warn("E2EE: could not compute changed-key fingerprint for re-pin modal", err);
|
||||
}
|
||||
}
|
||||
// The sidebar (or a newer open) may have superseded us during the async compute.
|
||||
if (signal.aborted) return;
|
||||
// The SIDEBAR (or a newer open) may have superseded us during the async
|
||||
// compute — but NOT a mere re-render: `lifetimeSignal` is the sidebar's own
|
||||
// factory-lifetime signal (aborted only in destroy()), not the per-render
|
||||
// one that renderChannels() replaces on every redraw (OC-0281). Binding this
|
||||
// check to the render signal made an unrelated re-render landing mid-compute
|
||||
// (a message in another channel, a peer toggling mute) turn the click into a
|
||||
// silent no-op.
|
||||
if (lifetimeSignal.aborted) return;
|
||||
closeIdentityModal();
|
||||
const modal = createIdentityMismatchModal({
|
||||
username,
|
||||
@@ -142,8 +154,10 @@ async function openIdentityMismatchModal(
|
||||
});
|
||||
modal.mount(document.body);
|
||||
activeIdentityModal = modal;
|
||||
// Close if the owning sidebar is destroyed while the modal is still open.
|
||||
signal.addEventListener("abort", closeIdentityModal, { once: true });
|
||||
// Close if the owning sidebar is destroyed while the modal is still open —
|
||||
// NOT on a re-render, which is why this is `lifetimeSignal` and not the
|
||||
// render-scoped signal (OC-0281).
|
||||
lifetimeSignal.addEventListener("abort", closeIdentityModal, { once: true });
|
||||
}
|
||||
|
||||
export interface ChannelReorderData {
|
||||
@@ -326,6 +340,7 @@ function buildVoiceModOptions(
|
||||
function renderVoiceChannelItem(
|
||||
channel: Channel,
|
||||
signal: AbortSignal,
|
||||
lifetimeSignal: AbortSignal,
|
||||
onVoiceJoin: (channelId: number) => void,
|
||||
onVoiceLeave: () => void,
|
||||
onWatchStream?: (userId: number) => void,
|
||||
@@ -407,7 +422,14 @@ function renderVoiceChannelItem(
|
||||
avatar.style.background = pickAvatarColor(user.username);
|
||||
row.appendChild(avatar);
|
||||
|
||||
const nameEl = createElement("span", { class: "vu-name" }, user.username || "Unknown");
|
||||
// Render the same identity a rename shows everywhere else (member list,
|
||||
// message rows, DM sidebar) — memberDisplayName prefers the nickname,
|
||||
// falling back to the username. Security-sensitive surfaces (the E2EE
|
||||
// mismatch modal, the moderation menu below) intentionally keep
|
||||
// rendering user.username instead, since a nickname is user-settable.
|
||||
const member = membersStore.getState().members.get(user.userId);
|
||||
const label = (member !== undefined ? memberDisplayName(member) : user.username) || "Unknown";
|
||||
const nameEl = createElement("span", { class: "vu-name" }, label);
|
||||
row.appendChild(nameEl);
|
||||
|
||||
if (user.camera) {
|
||||
@@ -449,6 +471,19 @@ function renderVoiceChannelItem(
|
||||
row.appendChild(muteIcon);
|
||||
}
|
||||
|
||||
// The local user's own session fingerprint (OC-0003): what a peer who
|
||||
// sees us as unverified compares against, so show it where it can be
|
||||
// read out. The local user is never in peerVerifications.
|
||||
const currentUser = getCurrentUser();
|
||||
const ownFingerprint = voiceStore.select((st) => st.localSessionFingerprint ?? null);
|
||||
if (currentUser !== null && currentUser.id === user.userId && ownFingerprint !== null) {
|
||||
const own = createElement("span", { class: "vu-verify vu-session-fp" });
|
||||
own.style.color = "var(--text-muted, #949ba4)";
|
||||
own.title = `Your session fingerprint (changes every call — not an identity): ${ownFingerprint}`;
|
||||
own.appendChild(createIcon("shield", 14));
|
||||
row.appendChild(own);
|
||||
}
|
||||
|
||||
// E2EE identity verification badge (F3 TOFU). Absent until the peer's
|
||||
// announce resolves; the local user is never in peerVerifications.
|
||||
const verification = getPeerVerification(user.userId);
|
||||
@@ -468,7 +503,16 @@ function renderVoiceChannelItem(
|
||||
"click",
|
||||
(e) => {
|
||||
e.stopPropagation();
|
||||
void openIdentityMismatchModal(user.userId, user.username || "Unknown", signal);
|
||||
// lifetimeSignal (not the per-render `signal`): the modal must
|
||||
// survive an unrelated re-render, and must not be silently
|
||||
// skipped by one landing during the async fingerprint compute
|
||||
// (OC-0281). The click listener itself stays on the per-render
|
||||
// `signal` so it dies with this row (OC-0229).
|
||||
void openIdentityMismatchModal(
|
||||
user.userId,
|
||||
user.username || "Unknown",
|
||||
lifetimeSignal,
|
||||
);
|
||||
},
|
||||
{ signal },
|
||||
);
|
||||
@@ -477,7 +521,6 @@ function renderVoiceChannelItem(
|
||||
}
|
||||
|
||||
// Right-click for per-user volume (skip for own user)
|
||||
const currentUser = getCurrentUser();
|
||||
if (currentUser === null || currentUser.id !== user.userId) {
|
||||
row.addEventListener(
|
||||
"contextmenu",
|
||||
@@ -489,7 +532,10 @@ function renderVoiceChannelItem(
|
||||
user.username || "Unknown",
|
||||
e.clientX,
|
||||
e.clientY,
|
||||
signal,
|
||||
// lifetimeSignal (not the per-render `signal`): the menu is
|
||||
// mounted on document.body, independent of this row's render,
|
||||
// and must not be torn down by an unrelated re-render (OC-0282).
|
||||
lifetimeSignal,
|
||||
buildVoiceModOptions(channel.id, user, onVoiceModerate),
|
||||
);
|
||||
},
|
||||
@@ -558,6 +604,7 @@ function renderChannelItem(
|
||||
channel: Channel,
|
||||
isActive: boolean,
|
||||
signal: AbortSignal,
|
||||
lifetimeSignal: AbortSignal,
|
||||
onVoiceJoin: (channelId: number) => void,
|
||||
onVoiceLeave: () => void,
|
||||
onEditChannel?: (channel: Channel) => void,
|
||||
@@ -574,6 +621,7 @@ function renderChannelItem(
|
||||
el = renderVoiceChannelItem(
|
||||
channel,
|
||||
signal,
|
||||
lifetimeSignal,
|
||||
onVoiceJoin,
|
||||
onVoiceLeave,
|
||||
onWatchStream,
|
||||
@@ -582,9 +630,25 @@ function renderChannelItem(
|
||||
} else {
|
||||
el = renderTextChannelItem(channel, isActive, signal);
|
||||
}
|
||||
attachChannelContextMenu(el, channel, signal, onEditChannel, onDeleteChannel, onPurgeChannel);
|
||||
attachChannelContextMenu(
|
||||
el,
|
||||
channel,
|
||||
signal,
|
||||
lifetimeSignal,
|
||||
onEditChannel,
|
||||
onDeleteChannel,
|
||||
onPurgeChannel,
|
||||
);
|
||||
if (containerEl !== undefined && channels !== undefined) {
|
||||
attachDragHandlers(el, channel, containerEl, channels, signal, onReorderChannel);
|
||||
attachDragHandlers(
|
||||
el,
|
||||
channel,
|
||||
containerEl,
|
||||
channels,
|
||||
signal,
|
||||
lifetimeSignal,
|
||||
onReorderChannel,
|
||||
);
|
||||
}
|
||||
return el;
|
||||
}
|
||||
@@ -594,6 +658,7 @@ function renderCategoryGroup(
|
||||
channels: readonly Channel[],
|
||||
activeChannelId: number | null,
|
||||
signal: AbortSignal,
|
||||
lifetimeSignal: AbortSignal,
|
||||
onVoiceJoin: (channelId: number) => void,
|
||||
onVoiceLeave: () => void,
|
||||
onCreateChannel?: (category: string) => void,
|
||||
@@ -664,6 +729,7 @@ function renderCategoryGroup(
|
||||
ch,
|
||||
ch.id === activeChannelId,
|
||||
signal,
|
||||
lifetimeSignal,
|
||||
onVoiceJoin,
|
||||
onVoiceLeave,
|
||||
onEditChannel,
|
||||
@@ -688,6 +754,7 @@ function renderCategoryGroup(
|
||||
ch,
|
||||
ch.id === activeChannelId,
|
||||
signal,
|
||||
lifetimeSignal,
|
||||
onVoiceJoin,
|
||||
onVoiceLeave,
|
||||
onEditChannel,
|
||||
@@ -720,6 +787,17 @@ export function createChannelSidebar(options: ChannelSidebarOptions): MountableC
|
||||
onPurgeChannel,
|
||||
} = options;
|
||||
const ac = new AbortController();
|
||||
// renderChannels() rebuilds every row from scratch on every channels-store
|
||||
// notification (unread count, active channel, role change, mute toggle,
|
||||
// ...). Per-row listeners (context menu, drag handlers) must NOT be
|
||||
// registered on the sidebar-lifetime `ac.signal`, which only aborts once,
|
||||
// at destroy() -- addEventListener({ signal }) keeps a detached row alive
|
||||
// via that signal's own retained "abort" listener list until it fires, so
|
||||
// every re-render would otherwise leak one full set of detached rows
|
||||
// (OC-0229). renderAc is aborted and replaced at the top of every
|
||||
// renderChannels() call, so only the CURRENT render's rows stay reachable;
|
||||
// header/root listeners registered once in mount() keep using `ac.signal`.
|
||||
let renderAc: AbortController | null = null;
|
||||
let root: HTMLDivElement | null = null;
|
||||
let channelList: HTMLDivElement | null = null;
|
||||
let serverNameEl: HTMLSpanElement | null = null;
|
||||
@@ -753,6 +831,12 @@ export function createChannelSidebar(options: ChannelSidebarOptions): MountableC
|
||||
if (channelList === null) {
|
||||
return;
|
||||
}
|
||||
// Abort the previous render's row-scoped listeners before the rows they
|
||||
// belong to are detached below, so a stale row can never outlive the
|
||||
// render that replaced it (OC-0229).
|
||||
renderAc?.abort();
|
||||
const currentRenderAc = new AbortController();
|
||||
renderAc = currentRenderAc;
|
||||
clearChildren(channelList);
|
||||
voiceRowByUserId.clear();
|
||||
|
||||
@@ -778,6 +862,11 @@ export function createChannelSidebar(options: ChannelSidebarOptions): MountableC
|
||||
category,
|
||||
channels,
|
||||
state.activeChannelId,
|
||||
currentRenderAc.signal,
|
||||
// Sidebar-lifetime signal (aborted only in destroy()) for anything
|
||||
// that owns DOM mounted outside this render's rows -- a menu or
|
||||
// modal on document.body must not be torn down by an unrelated
|
||||
// re-render (OC-0281, OC-0282).
|
||||
ac.signal,
|
||||
onVoiceJoin,
|
||||
onVoiceLeave,
|
||||
@@ -908,14 +997,17 @@ export function createChannelSidebar(options: ChannelSidebarOptions): MountableC
|
||||
// kills hover) and never pays a per-user querySelector.
|
||||
const unsubVoiceStructure = voiceStore.subscribeSelector(
|
||||
(state) => {
|
||||
let structSig = String(state.currentChannelId ?? "");
|
||||
let structSig = `${state.currentChannelId ?? ""}#${state.localSessionFingerprint ?? ""}`;
|
||||
for (const [chId, users] of state.voiceUsers) {
|
||||
structSig += `|${chId}`;
|
||||
for (const [uid, u] of users) {
|
||||
// Include the E2EE verification status so a verified↔unverified↔mismatch
|
||||
// flip re-renders the badge (it lives outside voiceUsers, in peerVerifications).
|
||||
// Include the E2EE verification status, safety number, and session
|
||||
// fingerprint so a verified↔unverified↔mismatch flip *and* a
|
||||
// same-status fingerprint/safety-number change (e.g. a reconnect that
|
||||
// re-announces a fresh ephemeral key, OC-0208) both re-render the
|
||||
// badge (it lives outside voiceUsers, in peerVerifications).
|
||||
const verif = state.peerVerifications?.get(uid);
|
||||
structSig += `:${uid}${u.muted ? "m" : ""}${u.deafened ? "d" : ""}${u.camera ? "c" : ""}${u.screenshare ? "s" : ""}${u.serverMuted === true ? "M" : ""}${u.serverDeafened === true ? "D" : ""}${verif ? `@${verif.status}` : ""}`;
|
||||
structSig += `:${uid}${u.muted ? "m" : ""}${u.deafened ? "d" : ""}${u.camera ? "c" : ""}${u.screenshare ? "s" : ""}${u.serverMuted === true ? "M" : ""}${u.serverDeafened === true ? "D" : ""}${verif ? `@${verif.status}/${verif.safetyNumber ?? ""}/${verif.sessionFingerprint ?? ""}` : ""}`;
|
||||
}
|
||||
}
|
||||
return structSig;
|
||||
@@ -943,6 +1035,8 @@ export function createChannelSidebar(options: ChannelSidebarOptions): MountableC
|
||||
// ac.abort() also releases this sidebar's hold on the shared document-level
|
||||
// drag listeners (drag-reorder.ts tracks owners by signal).
|
||||
ac.abort();
|
||||
renderAc?.abort();
|
||||
renderAc = null;
|
||||
for (const unsub of unsubscribers) {
|
||||
unsub();
|
||||
}
|
||||
@@ -13,7 +13,7 @@
|
||||
import { createElement, appendChildren, setText } from "@lib/dom";
|
||||
import type { MountableComponent } from "@lib/safe-render";
|
||||
import type { UserStatus } from "@lib/types";
|
||||
import { isRenderableAvatar } from "@lib/avatar";
|
||||
import { avatarInitial, isRenderableAvatar, resolveDisplayName } from "@lib/avatar";
|
||||
import { fetchImageAsDataUrl, resolveServerUrl } from "./message-list/attachments";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -23,6 +23,10 @@ import { fetchImageAsDataUrl, resolveServerUrl } from "./message-list/attachment
|
||||
export interface DmProfileData {
|
||||
readonly id: number;
|
||||
readonly username: string;
|
||||
/** Nickname, when set. The DM header this panel opens from renders through
|
||||
* `dmDisplayName`, which prefers this over `username` -- without it here
|
||||
* the panel would show a different identity from the header just clicked. */
|
||||
readonly displayName?: string | null;
|
||||
readonly avatar: string | null;
|
||||
readonly status: UserStatus;
|
||||
readonly about?: string | null;
|
||||
@@ -46,6 +50,18 @@ export interface DmProfileSidebarOptions {
|
||||
|
||||
export type DmProfileSidebarComponent = MountableComponent & {
|
||||
readonly isOpen: () => boolean;
|
||||
/**
|
||||
* Repaint the name, avatar initial and status (dot + label, both the
|
||||
* avatar-corner one and the inline one) from a fresher `DmProfileData`,
|
||||
* in place -- without rebuilding the panel and losing the note textarea's
|
||||
* focus/selection. The panel itself has no subscription to any store (it
|
||||
* is intentionally presentational); the owner is expected to call this
|
||||
* when the underlying user's presence or identity changes while the panel
|
||||
* stays open, mirroring how ChannelController keeps the DM chat header
|
||||
* live across the same events (see ChannelController.ts's refreshDmHeader).
|
||||
* A no-op before mount() or after destroy().
|
||||
*/
|
||||
readonly update: (user: DmProfileData) => void;
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -120,11 +136,21 @@ export function createDmProfileSidebar(
|
||||
): DmProfileSidebarComponent {
|
||||
const ac = new AbortController();
|
||||
const { signal } = ac;
|
||||
const { user, onClose, host = "" } = options;
|
||||
const { onClose, host = "" } = options;
|
||||
let user = options.user;
|
||||
|
||||
let panel: HTMLDivElement | null = null;
|
||||
let open = false;
|
||||
|
||||
// Live-updatable node refs, populated on mount() and cleared on destroy()
|
||||
// -- see the `update()` doc comment on DmProfileSidebarComponent for why
|
||||
// these are repainted in place instead of the whole panel being rebuilt.
|
||||
let nameNode: HTMLDivElement | null = null;
|
||||
let avatarLetterNode: HTMLSpanElement | null = null;
|
||||
let statusDotNode: HTMLDivElement | null = null;
|
||||
let statusDotInlineNode: HTMLSpanElement | null = null;
|
||||
let statusTextNode: HTMLSpanElement | null = null;
|
||||
|
||||
function isOpen(): boolean {
|
||||
return open;
|
||||
}
|
||||
@@ -152,8 +178,9 @@ export function createDmProfileSidebar(
|
||||
// once the bytes arrive. `<img src>` cannot carry the auth header an
|
||||
// `/api/v1/files/{id}` avatar needs, so the URL is never assigned raw.
|
||||
wrapper.style.background = "var(--accent, #5865f2)";
|
||||
const initial = user.username.charAt(0).toUpperCase() || "?";
|
||||
const initial = avatarInitial(user);
|
||||
const letter = createElement("span", {}, initial);
|
||||
avatarLetterNode = letter;
|
||||
wrapper.appendChild(letter);
|
||||
|
||||
if (isRenderableAvatar(user.avatar)) {
|
||||
@@ -162,7 +189,7 @@ export function createDmProfileSidebar(
|
||||
if (dataUrl === null || !wrapper.isConnected) return;
|
||||
const img = createElement("img", {
|
||||
src: dataUrl,
|
||||
alt: user.username,
|
||||
alt: resolveDisplayName(user),
|
||||
class: "dps-avatar-img",
|
||||
});
|
||||
img.style.width = "80px";
|
||||
@@ -185,6 +212,7 @@ export function createDmProfileSidebar(
|
||||
statusDot.style.border = "3px solid var(--bg-secondary, #111214)";
|
||||
statusDot.style.background = STATUS_COLORS[user.status] ?? STATUS_COLORS.offline;
|
||||
statusDot.title = STATUS_LABELS[user.status] ?? "Offline";
|
||||
statusDotNode = statusDot;
|
||||
wrapper.appendChild(statusDot);
|
||||
|
||||
return wrapper;
|
||||
@@ -261,7 +289,8 @@ export function createDmProfileSidebar(
|
||||
nameEl.style.fontWeight = "600";
|
||||
nameEl.style.color = "var(--text-primary, #f2f3f5)";
|
||||
nameEl.style.marginBottom = "4px";
|
||||
setText(nameEl, user.username);
|
||||
setText(nameEl, resolveDisplayName(user));
|
||||
nameNode = nameEl;
|
||||
|
||||
// Status line
|
||||
const statusLine = createElement("div", {
|
||||
@@ -282,8 +311,10 @@ export function createDmProfileSidebar(
|
||||
statusDotInline.style.borderRadius = "50%";
|
||||
statusDotInline.style.display = "inline-block";
|
||||
statusDotInline.style.background = STATUS_COLORS[user.status] ?? STATUS_COLORS.offline;
|
||||
statusDotInlineNode = statusDotInline;
|
||||
|
||||
const statusText = createElement("span", {}, STATUS_LABELS[user.status] ?? "Offline");
|
||||
statusTextNode = statusText;
|
||||
appendChildren(statusLine, statusDotInline, statusText);
|
||||
|
||||
appendChildren(content, nameEl, statusLine);
|
||||
@@ -409,7 +440,41 @@ export function createDmProfileSidebar(
|
||||
panel.remove();
|
||||
panel = null;
|
||||
}
|
||||
nameNode = null;
|
||||
avatarLetterNode = null;
|
||||
statusDotNode = null;
|
||||
statusDotInlineNode = null;
|
||||
statusTextNode = null;
|
||||
}
|
||||
|
||||
return { mount, destroy, isOpen };
|
||||
function update(nextUser: DmProfileData): void {
|
||||
user = nextUser;
|
||||
// Not mounted (or already torn down) -- nothing to repaint. mount() will
|
||||
// paint the fresh `user` from scratch if it is called afterwards.
|
||||
if (panel === null) return;
|
||||
|
||||
if (nameNode !== null) setText(nameNode, resolveDisplayName(user));
|
||||
|
||||
const color = STATUS_COLORS[user.status] ?? STATUS_COLORS.offline;
|
||||
const label = STATUS_LABELS[user.status] ?? "Offline";
|
||||
|
||||
if (statusDotNode !== null) {
|
||||
statusDotNode.style.background = color;
|
||||
statusDotNode.title = label;
|
||||
}
|
||||
if (statusDotInlineNode !== null) {
|
||||
statusDotInlineNode.style.background = color;
|
||||
}
|
||||
if (statusTextNode !== null) setText(statusTextNode, label);
|
||||
|
||||
// Only repaint the fallback letter if it is still showing -- once the
|
||||
// fetched avatar image swaps in, buildAvatar() removes the letter node
|
||||
// from the DOM (see above), and a stale identity's initial no longer
|
||||
// matters (or exists) to update.
|
||||
if (avatarLetterNode !== null && avatarLetterNode.isConnected) {
|
||||
setText(avatarLetterNode, avatarInitial(user));
|
||||
}
|
||||
}
|
||||
|
||||
return { mount, destroy, isOpen, update };
|
||||
}
|
||||
@@ -4,6 +4,7 @@
|
||||
import { createElement, setText, clearChildren } from "@lib/dom";
|
||||
import { enableRovingNavigation, setRovingTabindex } from "@lib/a11y";
|
||||
import { buildCustomEmojiNode } from "@components/message-list/custom-emoji";
|
||||
import { resolveEmoji } from "@stores/emoji.store";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
@@ -517,7 +518,19 @@ function getRecentEmoji(): string[] {
|
||||
if (!raw) return [];
|
||||
const parsed: unknown = JSON.parse(raw);
|
||||
if (!Array.isArray(parsed)) return [];
|
||||
return parsed.filter((e): e is string => typeof e === "string").slice(0, MAX_RECENT);
|
||||
return (
|
||||
parsed
|
||||
.filter((e): e is string => typeof e === "string")
|
||||
// A `:shortcode:`-shaped entry is only meaningful when it still
|
||||
// resolves on *this* server — the recent list is global (unscoped by
|
||||
// host), so a custom emoji clicked on one server would otherwise leak
|
||||
// as dead literal text into every other server's picker, and a
|
||||
// deleted emoji would do the same on its own server forever after.
|
||||
// Plain unicode entries (no colons) are never shortcode-shaped and
|
||||
// pass through untouched.
|
||||
.filter((e) => !(e.startsWith(":") && e.endsWith(":")) || resolveEmoji(e) !== null)
|
||||
.slice(0, MAX_RECENT)
|
||||
);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
@@ -571,6 +584,27 @@ export function createEmojiPicker(options: EmojiPickerOptions): {
|
||||
root.appendChild(scrollArea);
|
||||
enableRovingNavigation(scrollArea, ".ep-emoji", signal);
|
||||
|
||||
// Single delegated listener for the whole grid, registered once at mount
|
||||
// time. renderAllCategories() discards and rebuilds every cell on each
|
||||
// search keystroke (~250 cells per render); a listener bound directly to
|
||||
// each cell would register (and, since it lives on the picker-lifetime
|
||||
// `signal`, never release) one abort algorithm per discarded cell for the
|
||||
// rest of the picker's life — the same pattern SearchOverlay.ts's
|
||||
// handleResultsClick already fixes for its rows.
|
||||
scrollArea.addEventListener(
|
||||
"click",
|
||||
(e) => {
|
||||
const target = e.target;
|
||||
if (!(target instanceof Element)) return;
|
||||
const cell = target.closest<HTMLElement>(".ep-emoji");
|
||||
if (cell === null) return;
|
||||
const emoji = cell.dataset.emoji;
|
||||
if (emoji === undefined) return;
|
||||
handleEmojiClick(emoji);
|
||||
},
|
||||
{ signal },
|
||||
);
|
||||
|
||||
// Build categories with recent + custom
|
||||
function getAllCategories(): readonly EmojiCategory[] {
|
||||
const recent = getRecentEmoji();
|
||||
@@ -606,6 +640,9 @@ export function createEmojiPicker(options: EmojiPickerOptions): {
|
||||
// Mirrors the title (the character or :shortcode: token) — e2e specs
|
||||
// select cells by title, so the accessible name must never diverge.
|
||||
"aria-label": emoji,
|
||||
// Read by the delegated click handler on scrollArea (see mount-time
|
||||
// listener above) instead of a per-cell listener.
|
||||
"data-emoji": emoji,
|
||||
});
|
||||
// A `:shortcode:` entry shows its image; everything else is the character
|
||||
// itself. An unresolvable shortcode falls back to the text, which is what
|
||||
@@ -617,7 +654,6 @@ export function createEmojiPicker(options: EmojiPickerOptions): {
|
||||
} else {
|
||||
setText(span, emoji);
|
||||
}
|
||||
span.addEventListener("click", () => handleEmojiClick(emoji), { signal });
|
||||
return span;
|
||||
}
|
||||
|
||||
@@ -239,15 +239,20 @@ function createMemberItem(
|
||||
const currentUserId = authStore.getState().user?.id ?? 0;
|
||||
const isSelf = member.id === currentUserId;
|
||||
const onMessageUser = opts.onMessageUser;
|
||||
// `member` is the row's render-time snapshot; a presence-only update
|
||||
// (see patchPresence) recolors the dot in place without rebuilding the
|
||||
// row, so that snapshot's `status` can be stale. Re-resolve against the
|
||||
// live store so the popup always agrees with the dot it was opened from.
|
||||
const live = membersStore.getState().members.get(member.id) ?? member;
|
||||
activePopup = createUserProfilePopup({
|
||||
user: {
|
||||
id: member.id,
|
||||
username: member.username,
|
||||
avatar: member.avatar,
|
||||
role: member.role,
|
||||
status: member.status,
|
||||
displayName: member.displayName,
|
||||
customStatus: member.customStatus,
|
||||
id: live.id,
|
||||
username: live.username,
|
||||
avatar: live.avatar,
|
||||
role: live.role,
|
||||
status: live.status,
|
||||
displayName: live.displayName,
|
||||
customStatus: live.customStatus,
|
||||
},
|
||||
anchorX: e.clientX,
|
||||
anchorY: e.clientY,
|
||||
@@ -457,11 +462,30 @@ export function createMemberList(opts: MemberListOptions): MountableComponent {
|
||||
/** Rendered rows by user id \u2014 lets presence-only updates patch in place. */
|
||||
const rowsByUserId = new Map<number, HTMLDivElement>();
|
||||
let prevMembers: ReadonlyMap<number, Member> = new Map();
|
||||
// renderList() rebuilds every row from scratch on every non-presence-only
|
||||
// membersStore change and on every roles_update. Per-row listeners (click,
|
||||
// contextmenu) must NOT be registered on the component-lifetime
|
||||
// `disposable.signal`, which only aborts once, at destroy() --
|
||||
// addEventListener({ signal }) keeps a detached row alive via that signal's
|
||||
// own retained "abort" listener list until it fires, so every rebuild would
|
||||
// otherwise leak one full set of detached rows (OC-0295), exactly the
|
||||
// defect already fixed in ChannelSidebar (renderAc, OC-0229) and
|
||||
// MessageList (OC-0286). renderAc is aborted and replaced at the top of
|
||||
// every render, so only the CURRENT render's rows stay reachable.
|
||||
let renderAc: AbortController | null = null;
|
||||
|
||||
function render(): void {
|
||||
if (root === null) return;
|
||||
renderAc?.abort();
|
||||
const currentRenderAc = new AbortController();
|
||||
renderAc = currentRenderAc;
|
||||
renderList(root, opts, currentRenderAc.signal, rowsByUserId);
|
||||
}
|
||||
|
||||
function mount(container: Element): void {
|
||||
root = createElement("div", { class: "member-list", "data-testid": "member-list" });
|
||||
prevMembers = membersStore.getState().members;
|
||||
renderList(root, opts, disposable.signal, rowsByUserId);
|
||||
render();
|
||||
|
||||
disposable.onStoreChange<MembersState, ReadonlyMap<number, Member>>(
|
||||
membersStore,
|
||||
@@ -471,7 +495,7 @@ export function createMemberList(opts: MemberListOptions): MountableComponent {
|
||||
if (isPresenceOnlyChange(prevMembers, members)) {
|
||||
patchPresence(prevMembers, members, rowsByUserId);
|
||||
} else {
|
||||
renderList(root, opts, disposable.signal, rowsByUserId);
|
||||
render();
|
||||
}
|
||||
}
|
||||
prevMembers = members;
|
||||
@@ -486,9 +510,7 @@ export function createMemberList(opts: MemberListOptions): MountableComponent {
|
||||
channelsStore,
|
||||
(s) => s.roles,
|
||||
() => {
|
||||
if (root !== null) {
|
||||
renderList(root, opts, disposable.signal, rowsByUserId);
|
||||
}
|
||||
render();
|
||||
},
|
||||
);
|
||||
|
||||
@@ -500,6 +522,8 @@ export function createMemberList(opts: MemberListOptions): MountableComponent {
|
||||
closeActivePopup();
|
||||
document.removeEventListener("mousedown", handleOutsideClick);
|
||||
disposable.destroy();
|
||||
renderAc?.abort();
|
||||
renderAc = null;
|
||||
rowsByUserId.clear();
|
||||
if (root !== null) {
|
||||
root.remove();
|
||||