diff --git a/.claude/skills/ci-check/SKILL.md b/.claude/skills/ci-check/SKILL.md index 88146adb..6c6f9459 100644 --- a/.claude/skills/ci-check/SKILL.md +++ b/.claude/skills/ci-check/SKILL.md @@ -37,7 +37,7 @@ golangci-lint run # CI pins v2.11.3 # 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 ./scripts/genprotocol && git diff --exit-code ws/message_types.go ../Client/src/lib/protocolTypes.ts +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/`. diff --git a/.claude/skills/protocol-change/SKILL.md b/.claude/skills/protocol-change/SKILL.md index 8d59b720..776cae6e 100644 --- a/.claude/skills/protocol-change/SKILL.md +++ b/.claude/skills/protocol-change/SKILL.md @@ -1,12 +1,12 @@ --- 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/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/`. **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 @@ -23,7 +23,7 @@ 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 `docs/protocol-schema.json`. +1. Edit `protocol/schema.json`. 2. Run `make protocol-generate` from `Server/`. 3. Commit **both** outputs — `Server/ws/message_types.go` and `Client/src/lib/protocolTypes.ts`. One run regenerates the diff --git a/.claude/workflows/bughunt-fix.js b/.claude/workflows/bughunt-fix.js index 7a38f9b8..85eb90ec 100644 --- a/.claude/workflows/bughunt-fix.js +++ b/.claude/workflows/bughunt-fix.js @@ -506,7 +506,7 @@ 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/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/src-tauri:\n` + ` cargo test\n` + ` cargo clippy --all-targets -- -D warnings`, diff --git a/.claude/workflows/bughunt.js b/.claude/workflows/bughunt.js index 1f84fb77..c90df999 100644 --- a/.claude/workflows/bughunt.js +++ b/.claude/workflows/bughunt.js @@ -120,7 +120,7 @@ Method: structural reference is evidence of coupling, not of a bug; open the cited file and confirm. 2. For every candidate, grep for ALL callers before judging - a guard may already live upstream. 3. Check whether an existing test already locks the behavior you think is wrong. If a test asserts it, - it is intended behavior, not a bug. Test files are *_test.go and tests/unit/*.test.ts. + it is intended behavior, not a bug. Test files are *_test.go, tests/unit/*.test.ts and tests/contract/*.test.ts. 4. Report EVERY finding you can prove - there is no cap. The quality bar stays: zero findings is a valid, respectable answer, and each finding needs file, line, and a concrete repro. @@ -200,7 +200,7 @@ const SURFACE_LENSES = [ `an entity when events arrive out of order; read-state that can mark unread messages read, or lose an unread ` + `count, across a reconnect; an async handler whose await lets stale state be written after a newer update ` + `(last-write-wins race); a route guard bypassable by a rapid navigation sequence.\n` + - `Check tests/unit/ before reporting - much of this behavior is already test-locked.`, + `Check tests/unit/ and tests/contract/ before reporting - much of this behavior is already test-locked.`, }, ]; diff --git a/.githooks/pre-commit b/.githooks/pre-commit index 3459467e..23a036d2 100755 --- a/.githooks/pre-commit +++ b/.githooks/pre-commit @@ -50,11 +50,11 @@ if printf '%s\n' "$staged" | grep -qE '^Server/(db/queries/|migrations/|sqlc\.ya 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 && go run ./scripts/genprotocol \ + (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 ./scripts/genprotocol' in Server/ and stage the result" + || 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 diff --git a/.githooks/pre-push b/.githooks/pre-push index 277f26a5..f0d0deab 100755 --- a/.githooks/pre-push +++ b/.githooks/pre-push @@ -54,7 +54,7 @@ if [ "$changed" = "__all__" ]; then else if printf '%s\n' "$changed" | grep -q '^Server/'; then server_changed=1; fi if printf '%s\n' "$changed" | grep -q '^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 '^protocol/schema\.json'; then server_changed=1 client_changed=1 fi diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9b580953..ac0d4e06 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 diff --git a/.superpowers/FINDINGS.md b/.superpowers/FINDINGS.md index e4d70def..7d89554a 100644 --- a/.superpowers/FINDINGS.md +++ b/.superpowers/FINDINGS.md @@ -4722,7 +4722,7 @@ Server/api/auth_handler.go:470 user, err := database.GetUserByUsername(r.Context bluemonday v1.0.27 sanitize.go:417-443 — case html.TextToken: default: buff.WriteString(token.String()) // x/net/html TextToken.String() == EscapeString(Data) -**Suggested fix:** In Server/admin/setup_handler.go:175 use the same fixpoint sanitizer as registration: `req.Username = strings.TrimSpace(service.SanitizeText(req.Username))` (Server/service/message.go:214). Server/admin already imports github.com/owncord/server/service (admin.go:12) and service does not import admin, so there is no cycle. One line, in the one place setup canonicalizes the username. +**Suggested fix:** In Server/admin/setup_handler.go:175 use the same fixpoint sanitizer as registration: `req.Username = strings.TrimSpace(service.SanitizeText(req.Username))` (Server/service/message.go:214). Server/admin already imports github.com/J3vb/OwnCord/Server/service (admin.go:12) and service does not import admin, so there is no cycle. One line, in the one place setup canonicalizes the username. **Fixed:** `29619536` · test `Server/admin/setup_handler_test.go` · revert-proof pass @@ -4742,7 +4742,7 @@ saveChannelPerms writes the quick "Can access" toggles first (PUT allow=0/deny=0 **Suggested fix:** In saveChannelPerms, record the role IDs the quick-toggle loop actually wrote and skip the matrix step when the selected target is one of them — one guard in the one function: collect `const touched=new Set()` in the loop (add role.role_id on each PUT/DELETE), then wrap the matrix block in `if(path && !(permTargetPath().indexOf('/permissions/')>-1 && touched.has(tid)))`. Cleanest variant: give the quick checkbox an onchange that patches the in-memory role.allow/role.deny in state.permChannel and calls renderPermMatrix(), so the matrix always reflects the pending toggle instead of the stale snapshot. -**Fixed:** `69258a51` · test `Client/tests/unit/admin-static-channel-perms.test.ts` · revert-proof pass +**Fixed:** `69258a51` · test `Client/tests/contract/server-admin-static-channel-perms.test.ts` · revert-proof pass ### OC-0155 — medium — Room-key offer pacing budget is per-call, so two back-to-back rotations blow the server's per-second offer cap and strand peers on a dead key @@ -6306,7 +6306,7 @@ Server/ws/voice_e2ee.go:270-272 (direct publish, bypasses h.broadcast) Server/ws/hub_broadcast.go:64-72 documents that publishing straight to pub/sub "would reintroduce exactly that kind of reordering". -**Suggested fix:** Carry the leaver's join instance in the broadcast and make the client's leave handling instance-conditional. finishVoiceLeave already holds `oldJoinToken` (voice_leave.go:56), so add it to voiceLeavePayload/buildVoiceLeave (via the protocol-change skill, since docs/protocol-schema.json is the source of truth) and record each peer's join token from voice_state in the client. Then guard the top of handleParticipantLeft: if the payload's join token is not the one currently recorded for that peer, ignore the event entirely — one guard in the shared function covers the delete, the retirement and the election at once. A client-only stopgap that removes the permanent half of the damage is to skip the `retirePeerKey` call at :1213-1215 whenever the peer is still present in `voiceStore.voiceUsers.get(channelId)`, which leaves the peer re-announceable instead of permanently blocked. +**Suggested fix:** Carry the leaver's join instance in the broadcast and make the client's leave handling instance-conditional. finishVoiceLeave already holds `oldJoinToken` (voice_leave.go:56), so add it to voiceLeavePayload/buildVoiceLeave (via the protocol-change skill, since protocol/schema.json is the source of truth) and record each peer's join token from voice_state in the client. Then guard the top of handleParticipantLeft: if the payload's join token is not the one currently recorded for that peer, ignore the event entirely — one guard in the shared function covers the delete, the retirement and the election at once. A client-only stopgap that removes the permanent half of the damage is to skip the `retirePeerKey` call at :1213-1215 whenever the peer is still present in `voiceStore.voiceUsers.get(channelId)`, which leaves the peer re-announceable instead of permanently blocked. **Fixed:** `ccd9f39b69b202dc2858c8b02e97104e67f81aec` · test `Client/tests/unit/livekit-e2ee.test.ts` · revert-proof pass @@ -8025,7 +8025,7 @@ The server deliberately withholds a mention badge for an @here from every reader **Evidence:** mentions.ts:124-127 `export function highlightsCurrentUser(content, info) { if (info?.mentionsEveryone === true) return true; ... }` — no way to distinguish @here from @everyone. dispatcher.ts:634-649 `const isMention = highlightsCurrentUser(payload.content, {mentions: payload.mentions, mentionsEveryone: payload.mentions_everyone}); ... if (isMention) incrementMention(payload.channel_id, isDetached);`. Server/service/mentions.go:187 `if set.HereOnly && (db.BroadcastStatus(r.Status) == db.StatusOffline || (s.online != nil && !s.online(r.UserID))) { continue }`. Server/ws/messages.go:83 `MentionsEveryone bool \`json:"mentions_everyone"\`` is the only mention-scope field on the wire. -**Suggested fix:** Stop collapsing the two tokens on the wire, then apply the server's rule once on the client's replay path. (1) Add a `mentions_here` bool to chatMessagePayload/chatEditedPayload (Server/ws/messages.go:83 and :149) sourced from mentionSet.HereOnly (plumb it alongside MentionsEveryone through service/message.go's SendResult/EditResult and ws/handlers_chat.go), regenerating docs/protocol-schema.json -> message_types.go/protocolTypes.ts via the protocol-change skill. (2) In Client/src/lib/dispatcher.ts, hoist the existing `isReplayFrame` computation (currently dispatcher.ts:686-690) above the unread/mention block at 634-649 and gate the badge in that one place: treat a frame as a mention only when `payload.mentions.includes(me)` or (`payload.mentions_everyone && !(payload.mentions_here && isReplayFrame)`). That mirrors applyMentionCounts exactly — a here-only mention delivered in the reconnect burst is by definition one the reader was disconnected for — and leaves live delivery, @everyone, and direct mentions untouched. No change to mentions.ts's highlightsCurrentUser is needed for highlight rendering; only the badge increment must distinguish the two. +**Suggested fix:** Stop collapsing the two tokens on the wire, then apply the server's rule once on the client's replay path. (1) Add a `mentions_here` bool to chatMessagePayload/chatEditedPayload (Server/ws/messages.go:83 and :149) sourced from mentionSet.HereOnly (plumb it alongside MentionsEveryone through service/message.go's SendResult/EditResult and ws/handlers_chat.go), regenerating protocol/schema.json -> message_types.go/protocolTypes.ts via the protocol-change skill. (2) In Client/src/lib/dispatcher.ts, hoist the existing `isReplayFrame` computation (currently dispatcher.ts:686-690) above the unread/mention block at 634-649 and gate the badge in that one place: treat a frame as a mention only when `payload.mentions.includes(me)` or (`payload.mentions_everyone && !(payload.mentions_here && isReplayFrame)`). That mirrors applyMentionCounts exactly — a here-only mention delivered in the reconnect burst is by definition one the reader was disconnected for — and leaves live delivery, @everyone, and direct mentions untouched. No change to mentions.ts's highlightsCurrentUser is needed for highlight rendering; only the badge increment must distinguish the two. **Fixed:** `6f6d0ae` · test `Client/tests/unit/dispatcher.test.ts` · revert-proof self-reported diff --git a/.superpowers/findings-ledger.json b/.superpowers/findings-ledger.json index d7f4beac..696e7b82 100644 --- a/.superpowers/findings-ledger.json +++ b/.superpowers/findings-ledger.json @@ -3755,7 +3755,7 @@ "test": "Server/admin/setup_handler_test.go", "revertProof": "pass" }, - "suggestedFix": "In Server/admin/setup_handler.go:175 use the same fixpoint sanitizer as registration: `req.Username = strings.TrimSpace(service.SanitizeText(req.Username))` (Server/service/message.go:214). Server/admin already imports github.com/owncord/server/service (admin.go:12) and service does not import admin, so there is no cycle. One line, in the one place setup canonicalizes the username.", + "suggestedFix": "In Server/admin/setup_handler.go:175 use the same fixpoint sanitizer as registration: `req.Username = strings.TrimSpace(service.SanitizeText(req.Username))` (Server/service/message.go:214). Server/admin already imports github.com/J3vb/OwnCord/Server/service (admin.go:12) and service does not import admin, so there is no cycle. One line, in the one place setup canonicalizes the username.", "fixedDate": "2026-08-19" }, { @@ -3775,7 +3775,7 @@ "confidence": "high", "fix": { "commit": "69258a51", - "test": "Client/tests/unit/admin-static-channel-perms.test.ts", + "test": "Client/tests/contract/server-admin-static-channel-perms.test.ts", "revertProof": "pass" }, "suggestedFix": "In saveChannelPerms, record the role IDs the quick-toggle loop actually wrote and skip the matrix step when the selected target is one of them — one guard in the one function: collect `const touched=new Set()` in the loop (add role.role_id on each PUT/DELETE), then wrap the matrix block in `if(path && !(permTargetPath().indexOf('/permissions/')>-1 && touched.has(tid)))`. Cleanest variant: give the quick checkbox an onchange that patches the in-memory role.allow/role.deny in state.permChannel and calls renderPermMatrix(), so the matrix always reflects the pending toggle instead of the stale snapshot.", @@ -5134,7 +5134,7 @@ "test": "Client/tests/unit/livekit-e2ee.test.ts", "revertProof": "pass" }, - "suggestedFix": "Carry the leaver's join instance in the broadcast and make the client's leave handling instance-conditional. finishVoiceLeave already holds `oldJoinToken` (voice_leave.go:56), so add it to voiceLeavePayload/buildVoiceLeave (via the protocol-change skill, since docs/protocol-schema.json is the source of truth) and record each peer's join token from voice_state in the client. Then guard the top of handleParticipantLeft: if the payload's join token is not the one currently recorded for that peer, ignore the event entirely — one guard in the shared function covers the delete, the retirement and the election at once. A client-only stopgap that removes the permanent half of the damage is to skip the `retirePeerKey` call at :1213-1215 whenever the peer is still present in `voiceStore.voiceUsers.get(channelId)`, which leaves the peer re-announceable instead of permanently blocked.", + "suggestedFix": "Carry the leaver's join instance in the broadcast and make the client's leave handling instance-conditional. finishVoiceLeave already holds `oldJoinToken` (voice_leave.go:56), so add it to voiceLeavePayload/buildVoiceLeave (via the protocol-change skill, since protocol/schema.json is the source of truth) and record each peer's join token from voice_state in the client. Then guard the top of handleParticipantLeft: if the payload's join token is not the one currently recorded for that peer, ignore the event entirely — one guard in the shared function covers the delete, the retirement and the election at once. A client-only stopgap that removes the permanent half of the damage is to skip the `retirePeerKey` call at :1213-1215 whenever the peer is still present in `voiceStore.voiceUsers.get(channelId)`, which leaves the peer re-announceable instead of permanently blocked.", "fixedDate": "2026-08-20" }, { @@ -6426,7 +6426,7 @@ "lens": "flow-message", "finder": "opus", "confidence": "medium", - "suggestedFix": "Stop collapsing the two tokens on the wire, then apply the server's rule once on the client's replay path. (1) Add a `mentions_here` bool to chatMessagePayload/chatEditedPayload (Server/ws/messages.go:83 and :149) sourced from mentionSet.HereOnly (plumb it alongside MentionsEveryone through service/message.go's SendResult/EditResult and ws/handlers_chat.go), regenerating docs/protocol-schema.json -> message_types.go/protocolTypes.ts via the protocol-change skill. (2) In Client/src/lib/dispatcher.ts, hoist the existing `isReplayFrame` computation (currently dispatcher.ts:686-690) above the unread/mention block at 634-649 and gate the badge in that one place: treat a frame as a mention only when `payload.mentions.includes(me)` or (`payload.mentions_everyone && !(payload.mentions_here && isReplayFrame)`). That mirrors applyMentionCounts exactly — a here-only mention delivered in the reconnect burst is by definition one the reader was disconnected for — and leaves live delivery, @everyone, and direct mentions untouched. No change to mentions.ts's highlightsCurrentUser is needed for highlight rendering; only the badge increment must distinguish the two.", + "suggestedFix": "Stop collapsing the two tokens on the wire, then apply the server's rule once on the client's replay path. (1) Add a `mentions_here` bool to chatMessagePayload/chatEditedPayload (Server/ws/messages.go:83 and :149) sourced from mentionSet.HereOnly (plumb it alongside MentionsEveryone through service/message.go's SendResult/EditResult and ws/handlers_chat.go), regenerating protocol/schema.json -> message_types.go/protocolTypes.ts via the protocol-change skill. (2) In Client/src/lib/dispatcher.ts, hoist the existing `isReplayFrame` computation (currently dispatcher.ts:686-690) above the unread/mention block at 634-649 and gate the badge in that one place: treat a frame as a mention only when `payload.mentions.includes(me)` or (`payload.mentions_everyone && !(payload.mentions_here && isReplayFrame)`). That mirrors applyMentionCounts exactly — a here-only mention delivered in the reconnect burst is by definition one the reader was disconnected for — and leaves live delivery, @everyone, and direct mentions untouched. No change to mentions.ts's highlightsCurrentUser is needed for highlight rendering; only the badge increment must distinguish the two.", "fix": { "commit": "6f6d0ae", "test": "Client/tests/unit/dispatcher.test.ts", diff --git a/CLAUDE.md b/CLAUDE.md index 2cec670b..ff183c46 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -14,7 +14,7 @@ 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/src/lib/protocolTypes.ts` | `docs/protocol-schema.json` | `protocol-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 diff --git a/Client/CLAUDE.md b/Client/CLAUDE.md index 21b48b2e..83e151cf 100644 --- a/Client/CLAUDE.md +++ b/Client/CLAUDE.md @@ -9,8 +9,13 @@ 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) ## Gotchas diff --git a/Client/package.json b/Client/package.json index 2aa7d88d..18ecad58 100644 --- a/Client/package.json +++ b/Client/package.json @@ -15,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", diff --git a/Client/src/lib/protocolTypes.ts b/Client/src/lib/protocolTypes.ts index f526f66c..cc877e63 100644 --- a/Client/src/lib/protocolTypes.ts +++ b/Client/src/lib/protocolTypes.ts @@ -1,7 +1,7 @@ -// Code generated by scripts/genprotocol from docs/protocol-schema.json; DO NOT EDIT. +// Code generated by cmd/genprotocol from protocol/schema.json; DO NOT EDIT. // // Shared WebSocket protocol message type constants — single source of truth -// for both Server (Go) and Client (TypeScript). Edit docs/protocol-schema.json +// for both Server (Go) and Client (TypeScript). Edit protocol/schema.json // and run `make protocol-generate` in Server/. // // Usage: import { MessageType } from "@lib/protocolTypes"; diff --git a/Client/tests/unit/admin-static-channel-perms.test.ts b/Client/tests/contract/server-admin-static-channel-perms.test.ts similarity index 91% rename from Client/tests/unit/admin-static-channel-perms.test.ts rename to Client/tests/contract/server-admin-static-channel-perms.test.ts index 455804fa..d15fa0a8 100644 --- a/Client/tests/unit/admin-static-channel-perms.test.ts +++ b/Client/tests/contract/server-admin-static-channel-perms.test.ts @@ -1,3 +1,8 @@ +// CONTRACT TEST. The artifact under test is owned by Server/admin; the runner +// lives here because placement follows capability, not ownership — the Go +// module carries no JavaScript engine, so nothing under Server/ can execute +// this SPA. See docs/contributing.md#testing for the membership rule. +// // Loads the real Server/admin/static/index.html (the Go admin panel's // single-file SPA) into a scripted jsdom window and drives its inline // channel-permissions logic directly, the same way a browser would. @@ -5,6 +10,9 @@ // There is no bundler or module system for this file — it is one inline //