web: tag every message with a content type and filter on receive (#6)
Stamp `type: "chat"` inside the encrypted payload of every message the client sends, and only render decrypted messages whose type this client understands. A null/absent type is a legacy message from before payloads were tagged, so it's still treated as a chat. The type rides inside the ciphertext, so it stays an end-to-end signal between clients and never becomes server metadata. The accepted-types set in message.js is the single lever for tightening what we render later.
Co-authored-by: Claude <noreply@anthropic.com>
4 files changed,
+61
−2
package.json+1 −1test/message.test.mjs+36 −0web/static/chat.js+5 −1web/static/message.js+19 −0
diff --git a/package.json b/package.json| index 6117162..70e0660 100644 |
| --- a/package.json |
| +++ b/package.json |
| @@ -3,6 +3,6 @@ |
| "private": true, |
| "type": "module", |
| "scripts": { |
| - "test": "node test/crypto.test.mjs && node test/ui.test.mjs && node test/format.test.mjs" |
| + "test": "node test/crypto.test.mjs && node test/ui.test.mjs && node test/format.test.mjs && node test/message.test.mjs" |
| } |
| } |
diff --git a/test/message.test.mjs b/test/message.test.mjs| new file mode 100644 |
| index 0000000..78f0187 |
| --- /dev/null |
| +++ b/test/message.test.mjs |
| @@ -0,0 +1,36 @@ |
| +// Tests for message-type filtering. node test/message.test.mjs |
| +import assert from "node:assert/strict"; |
| +import { MESSAGE_TYPE_CHAT, acceptsMessageType } from "../web/static/message.js"; |
| + |
| +let failures = 0; |
| +async function test(name, fn) { |
| + try { |
| + await fn(); |
| + console.log("PASS " + name); |
| + } catch (e) { |
| + console.log("FAIL " + name + " — " + (e && e.message)); |
| + failures++; |
| + } |
| +} |
| + |
| +await test("chat is the type we stamp", () => { |
| + assert.equal(MESSAGE_TYPE_CHAT, "chat"); |
| +}); |
| + |
| +await test("accepts chat", () => { |
| + assert.equal(acceptsMessageType("chat"), true); |
| +}); |
| + |
| +await test("accepts a legacy untyped payload (null/undefined)", () => { |
| + assert.equal(acceptsMessageType(null), true); |
| + assert.equal(acceptsMessageType(undefined), true); |
| +}); |
| + |
| +await test("rejects an unknown future type", () => { |
| + assert.equal(acceptsMessageType("system"), false); |
| + assert.equal(acceptsMessageType("presence"), false); |
| + assert.equal(acceptsMessageType(""), false); |
| +}); |
| + |
| +console.log(failures === 0 ? "\nALL PASSED" : `\n${failures} FAILED`); |
| +process.exit(failures === 0 ? 0 : 1); |
diff --git a/web/static/chat.js b/web/static/chat.js| index ffba821..03fb22e 100644 |
| --- a/web/static/chat.js |
| +++ b/web/static/chat.js |
| @@ -1,6 +1,7 @@ |
| import { importKey, encrypt, decrypt, encryptBytes, decryptBytes } from "/static/crypto.js"; |
| import { shouldSend } from "/static/ui.js"; |
| import { tokenizeText, formatBytes, replySnippet } from "/static/format.js"; |
| +import { MESSAGE_TYPE_CHAT, acceptsMessageType } from "/static/message.js"; |
| const MAX_UPLOAD = 10 * 1024 * 1024; // keep in step with maxUploadBytes (main.go) |
| const MAX_IMAGE_DIM = 2048; // long-edge cap; bigger images are downscaled in-browser |
| @@ -588,6 +589,9 @@ async function start(session) { |
| } catch { |
| return; // wrong key or corrupt — skip |
| } |
| + // Only render message kinds this client understands (a legacy untyped |
| + // payload counts as chat). Already in `seen`, so it won't be re-evaluated. |
| + if (!acceptsMessageType(payload.type)) return; |
| // Seed reaction state from history (live "msg" frames carry none yet). |
| if (Array.isArray(msg.reactions)) { |
| @@ -1547,7 +1551,7 @@ async function start(session) { |
| } |
| if (!text && !pendingAttachment) return; |
| - const payload = { name: session.name, text, ts: Date.now() }; |
| + const payload = { type: MESSAGE_TYPE_CHAT, name: session.name, text, ts: Date.now() }; |
| if (pendingAttachment) payload.attachments = [pendingAttachment]; |
| // Carry the reply link inside the encrypted payload (the server never learns |
| // the thread structure). The snippet keeps the quote self-contained. |
diff --git a/web/static/message.js b/web/static/message.js| new file mode 100644 |
| index 0000000..4997fd2 |
| --- /dev/null |
| +++ b/web/static/message.js |
| @@ -0,0 +1,19 @@ |
| +// Message-content typing. The `type` rides *inside* the encrypted payload (the |
| +// server only ever sees ciphertext), so it's an end-to-end signal between |
| +// clients, never server metadata. Everything is a "chat" message today; tagging |
| +// them now lets future clients introduce other payload kinds (system notices, |
| +// presence, …) and lets each client decide which kinds it will render. |
| + |
| +// The type this client stamps on every message it sends. |
| +export const MESSAGE_TYPE_CHAT = "chat"; |
| + |
| +// The types this client will render. A null/absent type is a legacy message |
| +// from before payloads were tagged, so it's treated as a chat. Narrowing this |
| +// set — or dropping the null allowance — is how we get "more limited" later. |
| +const ACCEPTED_TYPES = new Set([MESSAGE_TYPE_CHAT]); |
| + |
| +// acceptsMessageType reports whether a decrypted payload of the given type |
| +// should be rendered. `null`/`undefined` (a legacy, untyped payload) is allowed. |
| +export function acceptsMessageType(type) { |
| + return type == null || ACCEPTED_TYPES.has(type); |
| +} |