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/.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..c72d917f 100644 --- a/.superpowers/FINDINGS.md +++ b/.superpowers/FINDINGS.md @@ -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..3d2fa671 100644 --- a/.superpowers/findings-ledger.json +++ b/.superpowers/findings-ledger.json @@ -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/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/e2e/helpers.ts b/Client/tests/e2e/helpers.ts index 42fe1a7b..fb7bd0d7 100644 --- a/Client/tests/e2e/helpers.ts +++ b/Client/tests/e2e/helpers.ts @@ -346,7 +346,7 @@ export function chatEchoHandlers(): Array<{ type: string; handler: string }> { * Voice WS flow handlers for E2E testing. * Simulates the server-side voice protocol defined in: * docs/brain/06-Specs/PROTOCOL.md (voice_join, voice_leave, voice_token, voice_token_refresh) - * docs/protocol-schema.json (message type schemas) + * protocol/schema.json (message type schemas) * * When PROTOCOL.md voice message types change, update these handlers to match. */ diff --git a/Server/.air.toml b/Server/.air.toml index ea246d03..97c47580 100644 --- a/Server/.air.toml +++ b/Server/.air.toml @@ -9,7 +9,7 @@ tmp_dir = "tmp" bin = "./chatserver.exe" cmd = "go build -o chatserver.exe -ldflags \"-s -w\" ." delay = 1000 - exclude_dir = ["tmp", "scripts", "migrations", "data", "admin/static"] + exclude_dir = ["tmp", "scripts", "cmd", "migrations", "data", "admin/static"] exclude_file = [] exclude_regex = ["_test\\.go$"] exclude_unchanged = false diff --git a/Server/.dockerignore b/Server/.dockerignore index 45d81e73..5586775b 100644 --- a/Server/.dockerignore +++ b/Server/.dockerignore @@ -13,8 +13,9 @@ data/ # Local config (users provide their own via volume mount or env) config.yaml -# Scripts (not needed in image) +# Developer tooling (not needed in image; the image builds the root package) scripts/ +cmd/ # Git metadata .git diff --git a/Server/.golangci.yml b/Server/.golangci.yml index 042f1685..2aae8b76 100644 --- a/Server/.golangci.yml +++ b/Server/.golangci.yml @@ -72,7 +72,7 @@ linters: excludes: - G104 # unhandled errors — errcheck covers this better - G304 # file path from variable — expected in file storage code - - G306 # WriteFile perms ≤0600 — our only hits are generated source files (genprotocol), which must stay world-readable or multi-stage container builds break + - G306 # WriteFile perms ≤0600 — our only hits are generated source files (cmd/genprotocol), which must stay world-readable or multi-stage container builds break - G706 # log injection — false positive with slog structured logging (values are typed key-value pairs, not interpolated) exclusions: diff --git a/Server/CLAUDE.md b/Server/CLAUDE.md index 97af742a..38648a70 100644 --- a/Server/CLAUDE.md +++ b/Server/CLAUDE.md @@ -9,6 +9,8 @@ prometheus. - `api/` REST handlers · `ws/` WebSocket hub · `auth/` sessions/TOTP · `permissions/` role checks · `service/` domain logic shared by both entry points - `db/` hand-written query wrappers; `db/dbgen/` is generated (see `db-change`) +- `cmd/` executable tooling, one `package main` per subdirectory — + `cmd/genprotocol/` regenerates the protocol constants from `protocol/schema.json` - `admin/` web admin panel · `updater/` self-update + signature verification · `plugin/` WASM plugin runtime (`-tags wazero`) · `telemetry/` OTel (`-tags otel`) - `syncutil/` lock helpers that gain deadlock detection under `-tags deadlock` diff --git a/Server/Makefile b/Server/Makefile index 89439591..741a0f70 100644 --- a/Server/Makefile +++ b/Server/Makefile @@ -9,7 +9,7 @@ # sqlc-generate Regenerate type-safe Go from sqlc.yaml (db/dbgen). # sqlc-verify Fail if the committed dbgen output is stale (used by CI). # sqlc-install Install the pinned sqlc version into $GOBIN. -# protocol-generate Regenerate WS message-type constants (Go + TS) from docs/protocol-schema.json. +# protocol-generate Regenerate WS message-type constants (Go + TS) from ../protocol/schema.json. # protocol-verify Fail if the committed protocol constants are stale (used by CI). # otel-up Start Jaeger + Prometheus for local tracing development. # otel-down Stop and remove the OTel dev containers. @@ -82,10 +82,10 @@ sqlc-verify: ) protocol-generate: - go run ./scripts/genprotocol + go run ./cmd/genprotocol protocol-verify: - go run ./scripts/genprotocol + go run ./cmd/genprotocol @git diff --exit-code ws/message_types.go ../Client/src/lib/protocolTypes.ts || ( \ echo "ERROR: generated protocol constants are stale. Run 'make protocol-generate' and commit the result." ; \ exit 1 ; \ diff --git a/Server/scripts/genprotocol/main.go b/Server/cmd/genprotocol/main.go similarity index 92% rename from Server/scripts/genprotocol/main.go rename to Server/cmd/genprotocol/main.go index 3eb3e42d..a1f2a7e5 100644 --- a/Server/scripts/genprotocol/main.go +++ b/Server/cmd/genprotocol/main.go @@ -1,10 +1,10 @@ // genprotocol generates the WebSocket protocol message-type constant files // for both the Go server and the TypeScript client from the single source of -// truth at docs/protocol-schema.json. +// truth at protocol/schema.json. // // Usage (from the Server/ directory): // -// go run ./scripts/genprotocol +// go run ./cmd/genprotocol // // or via make: // @@ -38,7 +38,7 @@ type schema struct { } func main() { - schemaPath := flag.String("schema", "../docs/protocol-schema.json", "path to protocol-schema.json") + schemaPath := flag.String("schema", "../protocol/schema.json", "path to the protocol schema") goOut := flag.String("go-out", "ws/message_types.go", "path to the generated Go file") tsOut := flag.String("ts-out", "../Client/src/lib/protocolTypes.ts", "path to the generated TypeScript file") flag.Parse() @@ -95,14 +95,14 @@ func validate(s schema) error { } func header(comment string) string { - return "// Code generated by scripts/genprotocol from docs/protocol-schema.json; DO NOT EDIT.\n" + + return "// Code generated by cmd/genprotocol from protocol/schema.json; DO NOT EDIT.\n" + comment + "\n" } func renderGo(s schema) ([]byte, error) { var b strings.Builder b.WriteString(header("//\n// WebSocket protocol message type constants — single source of truth for\n" + - "// both Server (Go) and Client (TypeScript). Edit docs/protocol-schema.json\n" + + "// both Server (Go) and Client (TypeScript). Edit protocol/schema.json\n" + "// and run `make protocol-generate` (see Server/Makefile).")) b.WriteString("\npackage ws\n\n") @@ -131,7 +131,7 @@ func renderGo(s schema) ([]byte, error) { func renderTS(s schema) string { var b strings.Builder b.WriteString(header("//\n// Shared WebSocket protocol message type constants — single source of truth\n" + - "// for both Server (Go) and Client (TypeScript). Edit docs/protocol-schema.json\n" + + "// for both Server (Go) and Client (TypeScript). Edit protocol/schema.json\n" + "// and run `make protocol-generate` in Server/.\n//\n" + "// Usage: import { MessageType } from \"@lib/protocolTypes\";\n" + "// ws.send({ type: MessageType.CHAT_SEND, payload: { ... } });")) diff --git a/Server/scripts/k6/ws-load.js b/Server/scripts/k6/ws-load.js index e9049a69..34d9bcc9 100644 --- a/Server/scripts/k6/ws-load.js +++ b/Server/scripts/k6/ws-load.js @@ -3,7 +3,7 @@ // // The wire protocol is the envelope format from docs/protocol.md: every // client->server frame is {type, id?, payload:{...}} and the first frame MUST -// be an `auth` envelope. If you change docs/protocol-schema.json, grep this +// be an `auth` envelope. If you change protocol/schema.json, grep this // script — it is not generated and CI does not run it, so it rots silently // (it once drifted to pre-envelope framing and reported green while every // auth failed). diff --git a/Server/ws/message_types.go b/Server/ws/message_types.go index 9272a183..49efb6cd 100644 --- a/Server/ws/message_types.go +++ b/Server/ws/message_types.go @@ -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. // // WebSocket protocol message type constants — single source of truth for -// both Server (Go) and Client (TypeScript). Edit docs/protocol-schema.json +// both Server (Go) and Client (TypeScript). Edit protocol/schema.json // and run `make protocol-generate` (see Server/Makefile). package ws diff --git a/Server/ws/protocol_contract_test.go b/Server/ws/protocol_contract_test.go index d4665e7d..77fce256 100644 --- a/Server/ws/protocol_contract_test.go +++ b/Server/ws/protocol_contract_test.go @@ -1,10 +1,10 @@ package ws_test -// protocol_contract_test.go — locks docs/protocol-schema.json and the +// protocol_contract_test.go — locks protocol/schema.json and the // generated ws/message_types.go together so the two cannot silently drift. // -// message_types.go is documented as "Code generated by scripts/genprotocol -// from docs/protocol-schema.json; DO NOT EDIT" and CI runs +// message_types.go is documented as "Code generated by cmd/genprotocol +// from protocol/schema.json; DO NOT EDIT" and CI runs // `make protocol-verify`, but that only re-runs the generator and diffs its // output — it says nothing about message-type constants that exist in the ws // package outside the generated file (e.g. a handler defining its own @@ -41,7 +41,7 @@ import ( var knownUndocumentedConstants = map[string]string{} // protocolSchemaEntry mirrors one element of the client_to_server / -// server_to_client arrays in docs/protocol-schema.json. +// server_to_client arrays in protocol/schema.json. type protocolSchemaEntry struct { Wire string `json:"wire"` Go string `json:"go"` @@ -54,8 +54,8 @@ type protocolSchema struct { ServerToClient []protocolSchemaEntry `json:"server_to_client"` } -// loadProtocolSchema locates and parses docs/protocol-schema.json relative to -// this test file (ws/ -> Server/ -> repo root -> docs/), so the test does not +// loadProtocolSchema locates and parses protocol/schema.json relative to +// this test file (ws/ -> Server/ -> repo root -> protocol/), so the test does not // depend on the working directory `go test` happens to be invoked from. func loadProtocolSchema(t *testing.T) protocolSchema { t.Helper() @@ -64,7 +64,7 @@ func loadProtocolSchema(t *testing.T) protocolSchema { t.Fatal("runtime.Caller failed to resolve test file path") } wsDir := filepath.Dir(thisFile) - schemaPath := filepath.Join(wsDir, "..", "..", "docs", "protocol-schema.json") + schemaPath := filepath.Join(wsDir, "..", "..", "protocol", "schema.json") raw, err := os.ReadFile(schemaPath) if err != nil { @@ -145,7 +145,7 @@ func loadGoMsgTypeConstants(t *testing.T) map[string]string { } // TestProtocolSchema_MatchesGeneratedGoConstants is the "schema -> code" -// direction: every wire constant docs/protocol-schema.json lists (in either +// direction: every wire constant protocol/schema.json lists (in either // direction of traffic) must have a same-named Go constant in the ws package // carrying exactly the schema's wire string. func TestProtocolSchema_MatchesGeneratedGoConstants(t *testing.T) { @@ -161,16 +161,16 @@ func TestProtocolSchema_MatchesGeneratedGoConstants(t *testing.T) { continue } if got != e.Wire { - t.Errorf("%s: ws.%s = %q, want %q per protocol-schema.json", direction, e.Go, got, e.Wire) + t.Errorf("%s: ws.%s = %q, want %q per protocol/schema.json", direction, e.Go, got, e.Wire) } } } if len(schema.ClientToServer) == 0 { - t.Fatal("protocol-schema.json client_to_server is empty — schema failed to load") + t.Fatal("protocol/schema.json client_to_server is empty — schema failed to load") } if len(schema.ServerToClient) == 0 { - t.Fatal("protocol-schema.json server_to_client is empty — schema failed to load") + t.Fatal("protocol/schema.json server_to_client is empty — schema failed to load") } check("client_to_server", schema.ClientToServer) @@ -179,7 +179,7 @@ func TestProtocolSchema_MatchesGeneratedGoConstants(t *testing.T) { // TestProtocolSchema_NoUndocumentedGoConstants is the "code -> schema" // direction: every MsgType* constant declared in the ws package must appear -// in docs/protocol-schema.json, except the documented exceptions in +// in protocol/schema.json, except the documented exceptions in // knownUndocumentedConstants (see its doc comment). This is what catches a // handler minting its own wire constant instead of adding it to the schema. func TestProtocolSchema_NoUndocumentedGoConstants(t *testing.T) { @@ -200,7 +200,7 @@ func TestProtocolSchema_NoUndocumentedGoConstants(t *testing.T) { } exceptWire, isException := knownUndocumentedConstants[name] if !isException { - t.Errorf("ws.%s = %q is not in docs/protocol-schema.json and is not a documented exception "+ + t.Errorf("ws.%s = %q is not in protocol/schema.json and is not a documented exception "+ "(knownUndocumentedConstants) — add it to the schema or the exception list", name, wire) continue } @@ -219,7 +219,7 @@ func TestProtocolSchema_NoUndocumentedGoConstants(t *testing.T) { continue } if _, nowDocumented := documented[name]; nowDocumented { - t.Errorf("knownUndocumentedConstants lists %q but it is now in protocol-schema.json — remove it from the exception list", name) + t.Errorf("knownUndocumentedConstants lists %q but it is now in protocol/schema.json — remove it from the exception list", name) } } } diff --git a/docs/README.md b/docs/README.md index fca299a3..e4d65f99 100644 --- a/docs/README.md +++ b/docs/README.md @@ -37,14 +37,14 @@ dated snapshots that were true when written and were never updated, and These describe contracts the code implements. If one disagrees with the code, the code is right and the document is a bug. -| Document | Covers | -| -------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------ | -| [api.md](api.md) | REST API under `/api/v1`. | -| [protocol.md](protocol.md) | WebSocket protocol — frames, sequencing, reconnect. | -| [schema.md](schema.md) | SQLite schema and migrations. | -| [server-configuration.md](server-configuration.md) | Every server configuration option. | -| [credential-storage.md](credential-storage.md) | What the desktop client persists, and where. | -| [protocol-schema.json](protocol-schema.json) | **Generated-code source of truth.** `Server/ws/message_types.go` and `Client/src/lib/protocolTypes.ts` are generated from it — never hand-edit either. | +| Document | Covers | +| -------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| [api.md](api.md) | REST API under `/api/v1`. | +| [protocol.md](protocol.md) | WebSocket protocol — frames, sequencing, reconnect. | +| [schema.md](schema.md) | SQLite schema and migrations. | +| [server-configuration.md](server-configuration.md) | Every server configuration option. | +| [credential-storage.md](credential-storage.md) | What the desktop client persists, and where. | +| [../protocol/schema.json](../protocol/schema.json) | **Generated-code source of truth**, at the repository root because it is owned by neither side. `Server/ws/message_types.go` and `Client/src/lib/protocolTypes.ts` are generated from it — never hand-edit either. See [../protocol/README.md](../protocol/README.md). | ## Architecture diff --git a/docs/architecture/websocket.md b/docs/architecture/websocket.md index a248840f..6d6fa49e 100644 --- a/docs/architecture/websocket.md +++ b/docs/architecture/websocket.md @@ -7,8 +7,8 @@ implements the real-time engine: a single `Hub` owning all client connections, a topic-based pub/sub, a monotonic sequence counter, a 3-tier reconnect replay pipeline, and a single typed (V2) command dispatch. -Message-type constants are **generated**: `docs/protocol-schema.json` is the -single source of truth, and `Server/scripts/genprotocol` emits both +Message-type constants are **generated**: `protocol/schema.json` is the +single source of truth, and `Server/cmd/genprotocol` emits both `Server/ws/message_types.go` and `Client/src/lib/protocolTypes.ts` from it (`make protocol-generate`; CI fails on drift via `make protocol-verify`). diff --git a/docs/contributing.md b/docs/contributing.md index 27111362..aa49db8e 100644 --- a/docs/contributing.md +++ b/docs/contributing.md @@ -60,19 +60,19 @@ next section, and using them directly is equally correct. **Make targets** (run from `Server/`): -| Command | Description | -| ------------------------ | ----------------------------------------------------------------------------------- | -| `make test` | Run the test suite the way CI does (`-race`, 20 min timeout) | -| `make test-deadlock` | Run the deadlock-detection pass CI also runs (`-tags deadlock`) | -| `make cover` | Per-package coverage (what CI uploads) + a function summary | -| `make cover-all` | Cross-package coverage — the honest number (also lists 0.0% functions) | -| `make sqlc-install` | Install the pinned sqlc version into `$GOBIN` | -| `make sqlc-generate` | Regenerate the type-safe Go query layer (`db/dbgen/`, SQLite engine) | -| `make sqlc-verify` | Fail if the committed `dbgen` output is stale (used by CI) | -| `make protocol-generate` | Regenerate the WS message-type constants (Go + TS) from `docs/protocol-schema.json` | -| `make protocol-verify` | Fail if the committed protocol constants are stale (used by CI) | -| `make otel-up` | Start Jaeger (traces) + Prometheus (metrics) via Docker for local OTel development | -| `make otel-down` | Stop and remove the OTel dev containers | +| Command | Description | +| ------------------------ | ---------------------------------------------------------------------------------- | +| `make test` | Run the test suite the way CI does (`-race`, 20 min timeout) | +| `make test-deadlock` | Run the deadlock-detection pass CI also runs (`-tags deadlock`) | +| `make cover` | Per-package coverage (what CI uploads) + a function summary | +| `make cover-all` | Cross-package coverage — the honest number (also lists 0.0% functions) | +| `make sqlc-install` | Install the pinned sqlc version into `$GOBIN` | +| `make sqlc-generate` | Regenerate the type-safe Go query layer (`db/dbgen/`, SQLite engine) | +| `make sqlc-verify` | Fail if the committed `dbgen` output is stale (used by CI) | +| `make protocol-generate` | Regenerate the WS message-type constants (Go + TS) from `protocol/schema.json` | +| `make protocol-verify` | Fail if the committed protocol constants are stale (used by CI) | +| `make otel-up` | Start Jaeger (traces) + Prometheus (metrics) via Docker for local OTel development | +| `make otel-down` | Stop and remove the OTel dev containers | #### Client (Tauri v2) diff --git a/docs/protocol.md b/docs/protocol.md index 70b0adc0..bf2096f3 100644 --- a/docs/protocol.md +++ b/docs/protocol.md @@ -1494,7 +1494,7 @@ recipient from being flooded. ## Message Type Reference Table -The authoritative type inventory is [protocol-schema.json](protocol-schema.json), +The authoritative type inventory is [protocol/schema.json](../protocol/schema.json), from which the Go and TypeScript constant files are generated (`make protocol-generate` / verified in CI by `make protocol-verify`). The tables below add per-type behavioral notes. @@ -1578,7 +1578,7 @@ tables below add per-type behavioral notes. ### Plugin command types Three wire types exist for the WASM plugin system. Since 2026-08-04 they are -listed in `protocol-schema.json` like every other type (closing DC-01), so +listed in `protocol/schema.json` like every other type (closing DC-01), so the generated constants cover them and `make protocol-verify` plus the `ws` package's protocol-contract test gate them against drift. diff --git a/protocol/README.md b/protocol/README.md new file mode 100644 index 00000000..e6ea9d1c --- /dev/null +++ b/protocol/README.md @@ -0,0 +1,36 @@ +# `protocol/` + +The cross-component WebSocket contract. It lives at the repository root +because neither side owns it: `schema.json` is the single source of truth for +the message-type constants **both** the Go server and the TypeScript client +compile against. + +| File | Role | +| ------------- | --------------------------------------------------------- | +| `schema.json` | Source of truth. Every wire message type, both directions | + +Two files are generated from it and must never be hand-edited: + +- `Server/ws/message_types.go` +- `Client/src/lib/protocolTypes.ts` + +## Changing the protocol + +Edit `schema.json`, then regenerate both consumers with one command from the +repository root: + +```bash +npm run generate +``` + +(Equivalently, `make protocol-generate` or `go run ./cmd/genprotocol` from +`Server/` — the generator is a Go program, so it lives where the Go toolchain +already runs.) + +Three gates reject a stale regeneration and one gate checks the schema against +the constants independently — `.githooks/pre-commit`, `make protocol-verify` in +CI, `npm run check:server`, and `Server/ws/protocol_contract_test.go`. There is +nothing extra to run. + +The narrative protocol reference is [`docs/protocol.md`](../docs/protocol.md); +the blueprint is [`docs/architecture/websocket.md`](../docs/architecture/websocket.md). diff --git a/docs/protocol-schema.json b/protocol/schema.json similarity index 97% rename from docs/protocol-schema.json rename to protocol/schema.json index 075880d5..a9526d73 100644 --- a/docs/protocol-schema.json +++ b/protocol/schema.json @@ -1,5 +1,5 @@ { - "$comment": "Single source of truth for WebSocket protocol message-type constants. Server/ws/message_types.go and Client/src/lib/protocolTypes.ts are generated from this file — edit here, then run `make protocol-generate` in Server/. CI runs `make protocol-verify` to reject drift.", + "$comment": "Single source of truth for WebSocket protocol message-type constants. Server/ws/message_types.go and Client/src/lib/protocolTypes.ts are generated from this file — edit here, then run `npm run generate` from the repository root (or `make protocol-generate` in Server/). CI runs `make protocol-verify` to reject drift.", "version": 1, "client_to_server": [ { "wire": "auth", "go": "MsgTypeAuth", "ts": "AUTH" }, diff --git a/scripts/run.mjs b/scripts/run.mjs index 80afc7a9..5390c237 100644 --- a/scripts/run.mjs +++ b/scripts/run.mjs @@ -59,7 +59,7 @@ const tracked = (...patterns) => { // `git diff --exit-code` after regenerating is what `make protocol-verify` and // `make sqlc-verify` reduce to. Inlined so neither needs make. const PROTOCOL_VERIFY = [ - step("go", ["run", "./scripts/genprotocol"], "Server"), + step("go", ["run", "./cmd/genprotocol"], "Server"), step( "git", ["diff", "--exit-code", "ws/message_types.go", "../Client/src/lib/protocolTypes.ts"], @@ -151,7 +151,7 @@ const TASKS = { "check:hygiene": CHECK_HYGIENE, check: [...CHECK_DOCS, ...CHECK_HYGIENE, ...CHECK_SERVER, ...CHECK_CLIENT, ...CHECK_RUST], generate: [ - step("go", ["run", "./scripts/genprotocol"], "Server"), + step("go", ["run", "./cmd/genprotocol"], "Server"), optional( "sqlc", "sqlc",