eleven / messenger Public

web: the reference test reads template interpolations — and catches adddevice's dropped pair (#985)

An Aikido-scan agent's incidental observation found buildInviteFragment
and brandingB unresolved in adddevice.js — inside a template literal,
which the module-refs scanner stripped whole, interpolations included.
The scanner now extracts ${...} expressions before stripping; the
upgraded test caught exactly the real bug and nothing else. brandingB
joins shellstate (5 writers converted); buildInviteFragment imports
from invite.js. Add-a-device would have thrown on opening the sheet
with a fresh session.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell 72c6971caa8f6e1eb467d7730c7a2dea123f5597 parent dfb63a7
4 files changed, +16 −8
  • test/module-refs.test.mjs +6 −0
  • web/static/adddevice.js +2 −1
  • web/static/shell.js +6 −7
  • web/static/shellstate.js +2 −0
diff --git a/test/module-refs.test.mjs b/test/module-refs.test.mjs
index 3de53d7..30983ef 100644
--- a/test/module-refs.test.mjs
+++ b/test/module-refs.test.mjs
@@ -34,7 +34,13 @@ function scan(name) {
const src = readFileSync(new URL(`../web/static/${name}.js`, import.meta.url), "utf8");
let code = src.replace(/\/\/[^\n]*/g, "");
code = code.replace(/\/\*[\s\S]*?\*\//g, "");
+ // Template literals: their ${...} interpolations are real code — extract
+ // them (one brace level) before stripping the literal text, or references
+ // hiding in interpolations vanish from the scan (the adddevice.js
+ // buildInviteFragment escape, 2026-07-15).
+ const interp = [...code.matchAll(/\$\{([^{}]*)\}/g)].map((m) => m[1]);
code = code.replace(/`(?:[^`\\]|\\[\s\S])*`/g, "``");
+ code += "\n;" + interp.join(";\n;") + ";";
code = code.replace(/"(?:[^"\\]|\\.)*"/g, '""');
code = code.replace(/'(?:[^'\\]|\\.)*'/g, "''");
code = code.replace(/\/(?:[^/\\\n[]|\\.|\[(?:[^\]\\]|\\.)*\])+\/[gimsuy]*/g, "0"); // regex literals
diff --git a/web/static/adddevice.js b/web/static/adddevice.js
index 7c0b22d..71d9333 100644
--- a/web/static/adddevice.js
+++ b/web/static/adddevice.js
@@ -2,7 +2,8 @@
// link + QR (the fragment carries the one-time secret; the server never sees
// it). Extracted verbatim from shell.js.
import { verbs } from "/static/shellverbs.js";
-import { session } from "/static/shellstate.js";
+import { session, brandingB } from "/static/shellstate.js";
+import { buildInviteFragment } from "/static/invite.js";
import { deriveInvite, randomSecret, encrypt } from "/static/crypto.js";
import { qrCanvas } from "/static/qrcode.js";
import { launchEnrol } from "/static/backup.js";
diff --git a/web/static/shell.js b/web/static/shell.js
index 29c87a6..a9cbf7c 100644
--- a/web/static/shell.js
+++ b/web/static/shell.js
@@ -144,7 +144,7 @@ import { verbs } from "/static/shellverbs.js"; // the behaviour seam (docs/shell
import { scheduleMessageSearch, wireSearch, searchIdx } from "/static/searchui.js"; // message search over the local store (#586)
import { loadMemberApps, setMemberSeeder, setSurfaceRefresh, setMemberAdmin, recentLocalDeploy, pushTheme } from "/static/sandbox.js"; // member-built apps, run sandboxed (docs/member-apps.md)
import { THEME_META, THEME_ORDER, currentTheme, currentAppearance, setTheme, setAppearance, currentTextSize, setTextSize, onThemeApplied } from "/static/theme.js"; // applies the saved theme/text-size on import
-import { session, roster, myUserId, threads, threadKeys, activeId, ws, setSession, setRoster, setMyUserId, setThreads, setActiveId, setWs, nicknames, setNicknames, threadName, instanceKey, instanceKeyB64, setInstanceKey, threadKeyB64, unread, hidden, dmClosed, embed, ENABLE_AI, ENABLE_BOTS, ENABLE_APPS, threadApp, tiers, setTiers, tierKeys, tierKeyB64, pendingJoinThread, setPendingJoinThread, ACTIVE_KEY, MAX_UPLOAD, pinned, muted, sealedBranding, setSealedBranding } from "/static/shellstate.js"; // the shared shell state (docs/shell-modules.md)
+import { session, roster, myUserId, threads, threadKeys, activeId, ws, setSession, setRoster, setMyUserId, setThreads, setActiveId, setWs, nicknames, setNicknames, threadName, instanceKey, instanceKeyB64, setInstanceKey, threadKeyB64, unread, hidden, dmClosed, embed, ENABLE_AI, ENABLE_BOTS, ENABLE_APPS, threadApp, tiers, setTiers, tierKeys, tierKeyB64, pendingJoinThread, setPendingJoinThread, ACTIVE_KEY, MAX_UPLOAD, pinned, muted, sealedBranding, setSealedBranding, brandingB, setBrandingB } from "/static/shellstate.js"; // the shared shell state (docs/shell-modules.md)
import { openLightbox } from "/static/lightbox.js";
import { mutedPeople, blockedPeople, loadModPrefs, setPersonMuted, openReportSheet, loadBlocks, setPersonBlocked, onModerationChange } from "/static/moderation.js";
@@ -568,7 +568,6 @@ const blobURLs = {}; // blob id -> decrypted object URL (cache)
// in one server-held ciphertext blob, opened with the link-carried branding
// key B. Members hold B here (localStorage after a link or an instance-key
// unwrap); every minted invite link carries it in the #fragment.
-let brandingB = null; // the short branding key (lowercase base32)
let brandingSalt = ""; // PBKDF2 salt for deriving from B (not secret)
let serverBrandingSealed = false;
@@ -586,13 +585,13 @@ async function bootstrapBranding() {
serverBrandingSealed = true;
brandingSalt = g.branding_salt || "";
try {
- brandingB = localStorage.getItem("lchat_branding_b") || null;
+ setBrandingB(localStorage.getItem("lchat_branding_b") || null);
} catch {}
if (!brandingB && g.branding_key_sealed && instanceKey) {
try {
const opened = await decrypt(instanceKey, g.branding_key_sealed);
if (opened && opened.b) {
- brandingB = opened.b;
+ setBrandingB(opened.b);
try {
localStorage.setItem("lchat_branding_b", brandingB);
} catch {}
@@ -613,7 +612,7 @@ async function bootstrapBranding() {
try {
const opened = await decrypt(instanceKey, g.branding_key_sealed);
if (opened && opened.b && opened.b !== brandingB) {
- brandingB = opened.b;
+ setBrandingB(opened.b);
try {
localStorage.setItem("lchat_branding_b", brandingB);
} catch {}
@@ -744,7 +743,7 @@ async function sealBrandingNow() {
body: JSON.stringify(body),
});
if (!r.ok) return r.status === 409 ? "ok" : "error";
- brandingB = b;
+ setBrandingB(b);
brandingSalt = salt;
setSealedBranding(branding);
serverBrandingSealed = true;
@@ -777,7 +776,7 @@ async function unsealBrandingNow() {
if (!r.ok) return "error";
serverBrandingSealed = false;
setSealedBranding(null);
- brandingB = null;
+ setBrandingB(null);
try {
localStorage.removeItem("lchat_branding_b");
} catch {}
diff --git a/web/static/shellstate.js b/web/static/shellstate.js
index d731b45..0e70de1 100644
--- a/web/static/shellstate.js
+++ b/web/static/shellstate.js
@@ -30,6 +30,7 @@ export let dmClosed = {}; // thread id -> bool: DM ended by either party (server
export let pinned = {}; // thread id -> bool: pinned to the top of the list, synced from the server
export let muted = {}; // thread id -> bool: muted (no notifications), synced from the server
export let sealedBranding = null; // decrypted {name, description, admin_name, admin_email, avatar_alt, avatar_mime}
+export let brandingB = null; // the short branding key (lowercase base32)
export let threadApp = {}; // thread id -> mini-app name, for a kind=="self" app-thread
export let tiers = []; // /api/tiers rows, personalised to this user
export let tierKeys = {}; // tier id -> CryptoKey
@@ -48,6 +49,7 @@ export function setWs(v) { ws = v; }
export function setTiers(v) { tiers = v; }
export function setPendingJoinThread(v) { pendingJoinThread = v; }
export function setSealedBranding(v) { sealedBranding = v; }
+export function setBrandingB(v) { brandingB = v; }
export function setNicknames(v) { nicknames = v; }
export function setThreadName(v) { threadName = v; }
export function setInstanceKey(key, b64) { instanceKey = key; instanceKeyB64 = b64; }