eleven / messenger Public

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>
James Adam 4c2a91dc30ff654cd91c0c756f902ac306de2262 parent 19fe505
4 files changed, +61 −2
  • package.json +1 −1
  • test/message.test.mjs +36 −0
  • web/static/chat.js +5 −1
  • web/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);
+}