Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
45 commits
Select commit Hold shift + click to select a range
ff03a9a
docs(spec): room encryption keys design
claude May 11, 2026
75fa131
docs(plan): room encryption keys implementation plan
claude May 11, 2026
2e78b60
feat(pkg/subject): ServerRoomKeyGet builder for inter-site key RPC
claude May 11, 2026
b1948b5
feat(pkg/model): NewKeyVersion, RoomType, RoomKeyGetRequest
claude May 11, 2026
e8e73f2
feat(pkg/roomkeysender): NatsPublisher adapter; Send accepts RoomKeyE…
claude May 11, 2026
231ed0b
feat(pkg/roomkeymetrics): OTel meter instruments for room key operations
claude May 11, 2026
8449942
docs(pkg/roomkeystore): package-level doc — versioning, concurrency, …
claude May 11, 2026
e1ccb03
feat(room-service): RoomKeyStore Set+Rotate methods; generateRoomKeyP…
claude May 11, 2026
bd72482
feat(room-service): generate key on create; rotate on channel member …
claude May 11, 2026
755fd6f
feat(room-worker): wire Valkey keystore and roomkeysender into Handler
claude May 11, 2026
9364386
feat(room-worker): consume canonical events; fan out RoomKeyEvent to …
claude May 11, 2026
ddf851f
test(room-worker): integration tests for key persistence and fan-out
claude May 11, 2026
2bc732f
feat(inbox-worker): inter-site key RPC client; Valkey + sender wiring
claude May 11, 2026
11d8d5f
feat(inbox-worker): replicate room keypair into local Valkey for cros…
claude May 11, 2026
2e941cd
test(room-service,inbox-worker): integration tests for key persistenc…
claude May 11, 2026
0bb0c41
docs(client-api): document RoomKeyEvent; wire VALKEY config in worker…
claude May 11, 2026
3429f72
fix(integration): repair integration test compilation after API refac…
claude May 11, 2026
f00ccaa
refactor: drop unnecessary code; unexport sentinels; polish
claude May 11, 2026
89f86eb
refactor(inbox-worker): merge duplicate rotateLocalKey + replicateRoo…
claude May 11, 2026
441cd53
fix(room-service): skip rotation on org-remove when no subscriptions …
claude May 11, 2026
4266a7b
feat: errRoomKeyAbsent sentinel + bounded retries on cross-site RPC
claude May 11, 2026
b4248dc
fix(integration): update inbox-worker test after fan-out change; skip…
claude May 11, 2026
94dcc8f
fix(inbox-worker): replicate origin key version + fail-fast on misconfig
claude May 11, 2026
27205f1
fix(room-worker,room-service): NAK on key-path failures + clarify intent
claude May 11, 2026
9f7fdf3
docs,test: align with implementation + stronger keypair/sender assert…
claude May 11, 2026
7305bb1
fix(room-worker,inbox-worker): require VALKEY_ADDR at startup
claude May 11, 2026
15439a1
fix(inbox-worker): idempotent subscription upserts on cross-site replay
claude May 11, 2026
b45c6e2
test,docs: tighten new assertions + flag stale plan paragraphs
claude May 11, 2026
c88eaad
test(room-worker): wire stub key deps into integration test handlers
claude May 11, 2026
c286c5e
fix,docs: address CodeRabbit review findings against 54b938a
claude May 11, 2026
71f2863
docs(plan): rewrite stale snippets to match shipped code
claude May 11, 2026
48d9953
chore: regenerate stale mocks across repo
claude May 11, 2026
4877fba
fix(room-worker): wire keystore stubs into tests ported from main
claude May 13, 2026
dafefac
docs(client-api): fix stale anchor + add fenced-code language
claude May 13, 2026
ccec424
docs(client-api): clarify DM vs channel key behavior in §5.1
claude May 13, 2026
0b58d12
refactor(inbox-worker): drop cross-site room-key replication
claude May 14, 2026
301ed5b
refactor(room-worker): drop unused siteID arg from ListByRoom
claude May 14, 2026
14067d0
docs(spec,plan): record removal of cross-site key replication
claude May 14, 2026
d81f345
refactor: drop dead inter-site room-key RPC handler
claude May 15, 2026
a916635
refactor(room-key): omit PublicKey from client wire payload
claude May 15, 2026
5d1008e
refactor(room-key): move rotation from room-service to room-worker
claude May 15, 2026
b9a627f
test(room-worker): flip PublicKey assertion to expect empty on wire
claude May 15, 2026
94aa94b
refactor,docs: drop dead inbox-worker plumbing + per-section spec sta…
claude May 15, 2026
860dad0
docs(spec,plan): fix stream name and clarify actually_deleted skip cases
claude May 15, 2026
0057498
fix(room-worker): publish subscription.update before room.key in add-…
claude May 15, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 15 additions & 15 deletions broadcast-worker/mock_store_test.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

96 changes: 72 additions & 24 deletions docs/client-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,9 @@ paths.
- [3.3 search-service](#33-search-service)
- [3.4 user-service (mock)](#34-user-service-mock)
4. [Message Send](#4-message-send)
5. [Error envelope reference](#5-error-envelope-reference)
5. [Server-Pushed Events](#5-server-pushed-events)
- [5.1 Room Encryption Keys](#51-room-encryption-keys)
6. [Error envelope reference](#6-error-envelope-reference)

---

Expand All @@ -38,9 +40,10 @@ This doc covers the public client-facing API surface only.
**Out of scope (documented elsewhere or backend-internal):**

- Backend-only JetStream subjects (MESSAGES, MESSAGES_CANONICAL, FANOUT, OUTBOX, INBOX, ROOMS streams). See [`docs/nats-subject-naming.md`](./nats-subject-naming.md).
- Server-pushed events not triggered by a specific client RPC (federation arrivals, presence, room-key rotation, cross-site member events).
- Server-to-server subjects (`chat.server.request.…`).

Server-pushed events that clients consume (room-key generation/rotation, etc.) are documented in [§5](#5-server-pushed-events). Federation arrivals, presence, and cross-site member events remain backend-internal.

### Subject placeholders

Subjects in this doc use these placeholders:
Expand Down Expand Up @@ -157,7 +160,7 @@ Exchanges an SSO token for a signed NATS user JWT. The returned JWT is what the

#### Error response

See [Error envelope](#5-error-envelope-reference). HTTP statuses:
See [Error envelope](#6-error-envelope-reference). HTTP statuses:

| Status | Meaning | Example body |
|--------|---------|--------------|
Expand Down Expand Up @@ -245,7 +248,7 @@ The created `Room` object.

##### Error response

See [Error envelope](#5-error-envelope-reference).
See [Error envelope](#6-error-envelope-reference).

```json
{ "error": "DM requires exactly one other member, got 0" }
Expand Down Expand Up @@ -301,7 +304,7 @@ Empty. Send `{}` or no payload.

##### Error response

See [Error envelope](#5-error-envelope-reference).
See [Error envelope](#6-error-envelope-reference).

##### Triggered events — success path

Expand Down Expand Up @@ -349,7 +352,7 @@ A single `Room` object. See [Create Room](#create-room) for the `Room` schema.

##### Error response

See [Error envelope](#5-error-envelope-reference).
See [Error envelope](#6-error-envelope-reference).

```json
{ "error": "room not found" }
Expand Down Expand Up @@ -407,7 +410,7 @@ The fields `requesterId`, `requesterAccount`, and `timestamp` on the Go `AddMemb

##### Error response

See [Error envelope](#5-error-envelope-reference). Returned synchronously when validation or authorization fails (e.g. requester not in room, room is full, room is restricted and requester is not owner).
See [Error envelope](#6-error-envelope-reference). Returned synchronously when validation or authorization fails (e.g. requester not in room, room is full, room is restricted and requester is not owner).

```json
{ "error": "room is at maximum capacity (200): cannot add 5 members to room with 198 existing" }
Expand Down Expand Up @@ -505,7 +508,7 @@ Exactly one of `account` or `orgId` must be set. The fields `requester` and `tim

##### Error response

See [Error envelope](#5-error-envelope-reference). Returned synchronously when validation or authorization fails (e.g. neither or both of `account`/`orgId` set, requester is not an owner, target is the last member, or org member cannot leave individually).
See [Error envelope](#6-error-envelope-reference). Returned synchronously when validation or authorization fails (e.g. neither or both of `account`/`orgId` set, requester is not an owner, target is the last member, or org member cannot leave individually).

```json
{ "error": "exactly one of account or orgId must be set" }
Expand Down Expand Up @@ -599,7 +602,7 @@ The `timestamp` field on the Go `UpdateRoleRequest` is server-set — the client

##### Error response

See [Error envelope](#5-error-envelope-reference). Returned synchronously when validation or authorization fails. Common errors include:
See [Error envelope](#6-error-envelope-reference). Returned synchronously when validation or authorization fails. Common errors include:

- Requester is not an owner of the room.
- Target account is not a member of the room.
Expand Down Expand Up @@ -716,7 +719,7 @@ When the synchronous reply is an error envelope, no events follow. The async job

##### Error response

See [Error envelope](#5-error-envelope-reference). Common errors: `"not a member of this room"`, `"limit must be > 0"`, `"offset must be >= 0"`.
See [Error envelope](#6-error-envelope-reference). Common errors: `"not a member of this room"`, `"limit must be > 0"`, `"offset must be >= 0"`.

##### Triggered events — success path

Expand Down Expand Up @@ -751,7 +754,7 @@ The subject already carries `account` and `roomID`, so no body fields are requir

##### Error response

See [Error envelope](#5-error-envelope-reference). Common errors:
See [Error envelope](#6-error-envelope-reference). Common errors:

- `"only room members can list members"` — the user has no subscription in the room (sentinel reused across membership-gated RPCs).
- `"invalid message-read subject: …"` — the subject is malformed.
Expand Down Expand Up @@ -825,7 +828,7 @@ A **synchronous, sender-only** RPC. Returns the list of users on the local site

##### Error response

See [Error envelope](#5-error-envelope-reference). Common errors:
See [Error envelope](#6-error-envelope-reference). Common errors:

- `"only room members can list members"` — the requester has no subscription in the room.
- `"message not found"` — no message matches `messageId`.
Expand Down Expand Up @@ -902,7 +905,7 @@ Empty. Send `{}` or no payload.

##### Error response

See [Error envelope](#5-error-envelope-reference).
See [Error envelope](#6-error-envelope-reference).

```json
{ "error": "invalid org" }
Expand Down Expand Up @@ -1028,7 +1031,7 @@ Used by every history-service method that returns messages. Mirrors the Cassandr

##### Error response

See [Error envelope](#5-error-envelope-reference).
See [Error envelope](#6-error-envelope-reference).

```json
{ "error": "not subscribed to room" }
Expand Down Expand Up @@ -1093,7 +1096,7 @@ Fetches messages newer than a cursor — the forward-pagination counterpart to L

##### Error response

See [Error envelope](#5-error-envelope-reference).
See [Error envelope](#6-error-envelope-reference).

##### Triggered events — success path

Expand Down Expand Up @@ -1152,7 +1155,7 @@ Fetches messages around a target message — useful for "jump to this message" n

##### Error response

See [Error envelope](#5-error-envelope-reference).
See [Error envelope](#6-error-envelope-reference).

##### Triggered events — success path

Expand Down Expand Up @@ -1195,7 +1198,7 @@ A single `Message` object. See [Message schema](#message-schema).

##### Error response

See [Error envelope](#5-error-envelope-reference).
See [Error envelope](#6-error-envelope-reference).

```json
{ "error": "message not found" }
Expand Down Expand Up @@ -1248,7 +1251,7 @@ Only the original sender may edit a message.

##### Error response

See [Error envelope](#5-error-envelope-reference). Common errors: `"only the sender can edit"`, `"message not found"`, `"newMsg must not be empty"`, `"newMsg exceeds maximum size"`, `"failed to edit message"`.
See [Error envelope](#6-error-envelope-reference). Common errors: `"only the sender can edit"`, `"message not found"`, `"newMsg must not be empty"`, `"newMsg exceeds maximum size"`, `"failed to edit message"`.

##### Triggered events — success path

Expand Down Expand Up @@ -1316,7 +1319,7 @@ Soft-deletes a message (sets `deleted=true` on the row; row is preserved for aud

##### Error response

See [Error envelope](#5-error-envelope-reference). Common errors: `"only the sender can delete"`, `"message not found"`, `"failed to delete message"`.
See [Error envelope](#6-error-envelope-reference). Common errors: `"only the sender can delete"`, `"message not found"`, `"failed to delete message"`.

##### Triggered events — success path

Expand Down Expand Up @@ -1397,7 +1400,7 @@ Returns the replies in a thread. The thread parent's `messageId` is supplied in

##### Error response

See [Error envelope](#5-error-envelope-reference).
See [Error envelope](#6-error-envelope-reference).

##### Triggered events — success path

Expand Down Expand Up @@ -1457,7 +1460,7 @@ Lists the parent messages of threads the user has subscribed to (or all threads,

##### Error response

See [Error envelope](#5-error-envelope-reference).
See [Error envelope](#6-error-envelope-reference).

##### Triggered events — success path

Expand Down Expand Up @@ -1540,7 +1543,7 @@ Display fields (user name, room name) are intentionally NOT carried in the respo

##### Error response

See [Error envelope](#5-error-envelope-reference).
See [Error envelope](#6-error-envelope-reference).

| Code | Reason |
|---|---|
Expand Down Expand Up @@ -1849,7 +1852,7 @@ Delivered on `chat.user.{account}.response.{requestId}`. The body is the persist

#### Error response

Delivered on `chat.user.{account}.response.{requestId}`. See [Error envelope](#5-error-envelope-reference). Common errors: `"invalid message ID \"…\": must be a 20-char base62 string"`, `"content must not be empty"`, `"content exceeds maximum size of 20480 bytes"`, `"user alice is not subscribed to room …"`, `"validate thread parent fields: threadParentMessageCreatedAt is required when threadParentMessageId is set"`.
Delivered on `chat.user.{account}.response.{requestId}`. See [Error envelope](#6-error-envelope-reference). Common errors: `"invalid message ID \"…\": must be a 20-char base62 string"`, `"content must not be empty"`, `"content exceeds maximum size of 20480 bytes"`, `"user alice is not subscribed to room …"`, `"validate thread parent fields: threadParentMessageCreatedAt is required when threadParentMessageId is set"`.

```json
{ "error": "content must not be empty" }
Expand Down Expand Up @@ -1968,7 +1971,52 @@ When validation fails, the gatekeeper publishes the error envelope to `chat.user

---

## 5. Error envelope reference
## 5. Server-Pushed Events

Server-pushed events are delivered to clients on NATS subjects the client is already authorized for, without a corresponding client RPC. They are distinct from the "Triggered events" sections in §3 and §4, which document events that arise as a side-effect of a specific RPC.

### 5.1 Room Encryption Keys

Each room has a P-256 keypair generated server-side at create time. Channel rooms use the key for end-to-end message encryption: `broadcast-worker` populates `encryptedMessage` on channel events (§4.1) and clients use the private key to decrypt. DM and botDM rooms still receive a `RoomKeyEvent` at create time for implementation consistency, but currently broadcast plaintext `message` (no `encryptedMessage`), so clients may skip persisting DM/botDM keys.

#### Subject

```text
chat.user.{account}.event.room.key
```

Clients are already authorized for `chat.user.{theirAccount}.>` and receive key events on this subject without additional setup.

#### Payload (`RoomKeyEvent`)

```json
{
"roomId": "<room id>",
"version": 0,
"privateKey": "<base64-encoded 32-byte P-256 scalar>",
"timestamp": 1747000000000
}
```

`[]byte` fields marshal to standard base64 in JSON. The room's public key is server-side only (used by `broadcast-worker` to encrypt outgoing messages) and is not transmitted to clients — clients only need the private key to decrypt incoming ciphertext.

#### Client behavior

1. On every `RoomKeyEvent`, store the key under `(roomId, version) → privateKey`.
2. When decrypting an incoming message, use the `version` stamped in the encrypted payload to look up the corresponding private key.
3. Retain past versions to support history scrolling. The server retains the previous version in its store for at least `VALKEY_KEY_GRACE_PERIOD` (default 24h); after that, server-side decryption of old messages may not be possible, but clients holding old keys can still decrypt locally.

#### When clients receive `RoomKeyEvent`s

- **Room creation (all room types):** sent to every initial member.
- **Add member (channels only):** sent to each newly-added account; existing members do not receive a duplicate event.
- **Remove member (channels only):** the server rotates the room key. Surviving members receive a new `RoomKeyEvent` with an incremented `version`. The removed account stops receiving events for the room.

Removed members keep prior keys for decrypting historical messages but cannot decrypt anything published after the rotation.

Comment thread
coderabbitai[bot] marked this conversation as resolved.
---

## 6. Error envelope reference

Every error response — over NATS reply subjects and HTTP — uses the same envelope:

Expand Down
Loading
Loading