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>
4 files changed,
+16
−8
test/module-refs.test.mjs+6 −0web/static/adddevice.js+2 −1web/static/shell.js+6 −7web/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; } |