web: inviter's name rides the sealed invite payload — joiner's first paint says who invited them (#1437)
A fresh joiner is keyless until the instance-key grant lands, so their very first screen showed their inviter as "Someone" (#1436). The inviter's published name now travels INSIDE the encrypted invite payload (beside the DM threadKey — the server sees only ciphertext), join.js stashes it at claim time, and shell.js fills it in for exactly the hinted DM's other member until the real label decrypts, then drops it. Pure decisions in invitehint.js (Node-tested); applied on both the roster and threads apply paths so the DM titles correctly however boot ordering falls. Verified live: hermetic local rig (inviter permanently offline — the worst case where "Someone" previously stuck forever) + Playwright joiner: first paint shows "Paul" in the thread list, header, and Eleven's welcome copy; no "Someone" anywhere.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
6 files changed,
+155
−3
changelog/2026-08-08-inviter-name-first-paint.md+4 −0package.json+1 −1test/invitehint.test.mjs+47 −0web/static/invitehint.js+52 −0web/static/join.js+14 −0web/static/shell.js+37 −2
diff --git a/changelog/2026-08-08-inviter-name-first-paint.md b/changelog/2026-08-08-inviter-name-first-paint.md| new file mode 100644 |
| index 0000000..2b99781 |
| --- /dev/null |
| +++ b/changelog/2026-08-08-inviter-name-first-paint.md |
| @@ -0,0 +1,4 @@ |
| +🤖 When you join from a personal invite, the chat now greets you with your |
| +inviter's name from the very first moment — no more "Someone" while your |
| +device finishes its secure setup. The name travels inside the invite's |
| +encrypted envelope, so the server still can't read it. |
diff --git a/package.json b/package.json| index b8abb38..7c50e4c 100644 |
| --- a/package.json |
| +++ b/package.json |
| @@ -3,6 +3,6 @@ |
| "private": true, |
| "type": "module", |
| "scripts": { |
| - "test": "node --test test/module-refs.test.mjs && node test/apps-dir.test.mjs && node test/crypto.test.mjs && node test/vaultcrypto.test.mjs && node test/integrity.test.mjs && node test/ui.test.mjs && node test/blurhash.test.mjs && node test/format.test.mjs && node test/markdown.test.mjs && node test/composer.test.mjs && node test/composer-vectors.test.mjs && node test/bootscene.test.mjs && node test/message.test.mjs && node test/threadkey.test.mjs && node test/nicknames.test.mjs && node test/messageblob.test.mjs && node test/blobbytes.test.mjs && node test/nativeblob.test.mjs && node test/apps.test.mjs && node test/appcatalog.test.mjs && node test/appcatalog-seed.test.mjs && node test/cards.test.mjs && node test/event-vectors.test.mjs && node test/views.test.mjs && node test/schema-engine.test.mjs && node test/flow.test.mjs && node test/quickform.test.mjs && node test/tutorial.test.mjs && node test/actions.test.mjs && node test/commands.test.mjs && node test/context.test.mjs && node test/knowledge.test.mjs && node test/invite.test.mjs && node test/backup.test.mjs && node test/prejoin.test.mjs && node test/connect.test.mjs && node test/serviceop.test.mjs && node test/github.test.mjs && node test/github-render.test.mjs && node test/linear.test.mjs && node test/linear-render.test.mjs && node test/check-app.test.mjs && node test/sandbox.test.mjs && node test/sandbox-bridge.test.mjs && node test/sandbox-theme.test.mjs && node test/status-contract.test.mjs && node test/ledgerchain.test.mjs && node test/hardening-posture.test.mjs && node test/store.test.mjs && node test/memory.test.mjs && node test/search.test.mjs && node test/stripe-band-prices.test.mjs && node test/waterfall.test.mjs && node home/test/crypto.test.mjs && node home/test/restore-payload.test.mjs && node home/test/serverlist.test.mjs && node home/test/ceremony-errors.test.mjs && node home/test/fader.test.mjs && node home/test/fx.test.mjs && node home/test/sessionreuse.test.mjs && node home/test/joinreturn.test.mjs" |
| + "test": "node --test test/module-refs.test.mjs && node test/apps-dir.test.mjs && node test/crypto.test.mjs && node test/vaultcrypto.test.mjs && node test/integrity.test.mjs && node test/ui.test.mjs && node test/blurhash.test.mjs && node test/format.test.mjs && node test/markdown.test.mjs && node test/composer.test.mjs && node test/composer-vectors.test.mjs && node test/bootscene.test.mjs && node test/message.test.mjs && node test/threadkey.test.mjs && node test/nicknames.test.mjs && node test/messageblob.test.mjs && node test/blobbytes.test.mjs && node test/nativeblob.test.mjs && node test/apps.test.mjs && node test/appcatalog.test.mjs && node test/appcatalog-seed.test.mjs && node test/cards.test.mjs && node test/event-vectors.test.mjs && node test/views.test.mjs && node test/schema-engine.test.mjs && node test/flow.test.mjs && node test/quickform.test.mjs && node test/tutorial.test.mjs && node test/actions.test.mjs && node test/commands.test.mjs && node test/context.test.mjs && node test/knowledge.test.mjs && node test/invite.test.mjs && node test/backup.test.mjs && node test/prejoin.test.mjs && node --test test/invitehint.test.mjs && node test/connect.test.mjs && node test/serviceop.test.mjs && node test/github.test.mjs && node test/github-render.test.mjs && node test/linear.test.mjs && node test/linear-render.test.mjs && node test/check-app.test.mjs && node test/sandbox.test.mjs && node test/sandbox-bridge.test.mjs && node test/sandbox-theme.test.mjs && node test/status-contract.test.mjs && node test/ledgerchain.test.mjs && node test/hardening-posture.test.mjs && node test/store.test.mjs && node test/memory.test.mjs && node test/search.test.mjs && node test/stripe-band-prices.test.mjs && node test/waterfall.test.mjs && node home/test/crypto.test.mjs && node home/test/restore-payload.test.mjs && node home/test/serverlist.test.mjs && node home/test/ceremony-errors.test.mjs && node home/test/fader.test.mjs && node home/test/fx.test.mjs && node home/test/sessionreuse.test.mjs && node home/test/joinreturn.test.mjs" |
| } |
| } |
diff --git a/test/invitehint.test.mjs b/test/invitehint.test.mjs| new file mode 100644 |
| index 0000000..17cfe07 |
| --- /dev/null |
| +++ b/test/invitehint.test.mjs |
| @@ -0,0 +1,47 @@ |
| +// Tests for the inviter-name hint's pure helpers (invitehint.js, #1436). |
| +import test from "node:test"; |
| +import assert from "node:assert/strict"; |
| +import { inviterHintStash, readInviterHint, inviterHintTarget } from "../web/static/invitehint.js"; |
| + |
| +const NOW = 1_700_000_000_000; |
| + |
| +test("stash/read round trip, trimming and capping the name", () => { |
| + const stored = inviterHintStash({ thread: "t1", name: " Paul ", now: NOW }); |
| + assert.deepEqual(readInviterHint(stored, NOW + 1000), { thread: "t1", name: "Paul" }); |
| + const long = inviterHintStash({ thread: "t1", name: "x".repeat(200), now: NOW }); |
| + assert.equal(readInviterHint(long, NOW).name.length, 64); |
| +}); |
| + |
| +test("read rejects garbage, empties, clock skew, and stale hints", () => { |
| + assert.equal(readInviterHint("", NOW), null); |
| + assert.equal(readInviterHint("not json", NOW), null); |
| + assert.equal(readInviterHint(JSON.stringify({ v: 1, thread: "", name: "P", at: NOW }), NOW), null); |
| + assert.equal(readInviterHint(JSON.stringify({ v: 1, thread: "t", name: " ", at: NOW }), NOW), null); |
| + assert.equal(readInviterHint(inviterHintStash({ thread: "t", name: "P", now: NOW }), NOW - 1), null); |
| + const weekPlus = NOW + 7 * 24 * 60 * 60 * 1000 + 1; |
| + assert.equal(readInviterHint(inviterHintStash({ thread: "t", name: "P", now: NOW }), weekPlus), null); |
| +}); |
| + |
| +const hint = { thread: "dm1", name: "Paul" }; |
| +const threads = [{ id: "dm1", members: ["me", "inviter"] }, { id: "other", members: ["me", "x"] }]; |
| + |
| +test("target: fills the hinted DM's label-less other member", () => { |
| + const roster = [{ user_id: "me", label: "Joiner" }, { user_id: "inviter", label: "" }]; |
| + assert.deepEqual(inviterHintTarget(hint, threads, roster, "me"), { userId: "inviter", name: "Paul" }); |
| +}); |
| + |
| +test("target: resolved once the real label decrypted — caller drops the stash", () => { |
| + const roster = [{ user_id: "inviter", label: "Paul Campbell" }]; |
| + assert.deepEqual(inviterHintTarget(hint, threads, roster, "me"), { resolved: true }); |
| +}); |
| + |
| +test("target: null while thread or member not loaded yet (stash kept)", () => { |
| + assert.equal(inviterHintTarget(hint, [], [], "me"), null); |
| + assert.equal(inviterHintTarget(hint, threads, [], "me"), null); |
| + assert.equal(inviterHintTarget(null, threads, [], "me"), null); |
| +}); |
| + |
| +test("target: never labels an agent, never anyone outside the hinted DM", () => { |
| + const roster = [{ user_id: "inviter", label: "", is_agent: true }, { user_id: "x", label: "" }]; |
| + assert.equal(inviterHintTarget(hint, threads, roster, "me"), null); |
| +}); |
diff --git a/web/static/invitehint.js b/web/static/invitehint.js| new file mode 100644 |
| index 0000000..8ac0059 |
| --- /dev/null |
| +++ b/web/static/invitehint.js |
| @@ -0,0 +1,52 @@ |
| +// The inviter-name hint (#1436): a person-invite's encrypted payload carries |
| +// the inviter's display name (`from`, beside the DM threadKey — the server |
| +// can't open either), so the joiner's first paint can label their inviter |
| +// instead of showing "Someone" while the instance-key grant is still in |
| +// flight. The hint is provisional by construction: it only ever fills an |
| +// entry whose real label hasn't decrypted, and it dies the moment one has. |
| +// Pure decisions only — join.js/shell.js own the DOM and storage |
| +// (Node-tested in test/invitehint.test.mjs). |
| + |
| +export const INVITER_HINT_KEY = "lchat_inviter_hint"; |
| + |
| +// A hint outliving a week is a stale artifact of an abandoned join, not a |
| +// first paint — and the real label has almost certainly decrypted by then. |
| +const MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000; |
| + |
| +export function inviterHintStash({ thread, name, now }) { |
| + return JSON.stringify({ |
| + v: 1, |
| + thread: String(thread || ""), |
| + name: String(name || "").trim().slice(0, 64), |
| + at: now, |
| + }); |
| +} |
| + |
| +export function readInviterHint(storedJSON, now) { |
| + let h = null; |
| + try { h = JSON.parse(storedJSON || "null"); } catch { return null; } |
| + if (!h || typeof h.thread !== "string" || typeof h.name !== "string") return null; |
| + if (!h.thread || !h.name.trim()) return null; |
| + if (typeof h.at !== "number" || now < h.at || now - h.at > MAX_AGE_MS) return null; |
| + return { thread: h.thread, name: h.name.trim() }; |
| +} |
| + |
| +// inviterHintTarget: who (if anyone) the hint should provisionally label. |
| +// { userId, name } — fill this entry's missing label |
| +// { resolved: true } — the real label decrypted; drop the stash |
| +// null — nothing to do (thread/member not loaded yet, |
| +// or the hint doesn't apply): keep the stash, |
| +// a later pass may have more data. |
| +// Scoped hard to the hinted DM's other member — a hint must never rename |
| +// anyone else, and never a bot (agents carry plaintext labels, never blanks). |
| +export function inviterHintTarget(hint, threads, roster, myUserId) { |
| + if (!hint) return null; |
| + const t = (threads || []).find((x) => x && x.id === hint.thread); |
| + if (!t) return null; |
| + const otherId = (t.members || []).find((uid) => uid && uid !== myUserId); |
| + if (!otherId) return null; |
| + const e = (roster || []).find((x) => x && x.user_id === otherId); |
| + if (!e || e.is_agent) return null; |
| + if (e.label) return { resolved: true }; |
| + return { userId: otherId, name: hint.name }; |
| +} |
diff --git a/web/static/join.js b/web/static/join.js| index aa1ecf4..5c8a75e 100644 |
| --- a/web/static/join.js |
| +++ b/web/static/join.js |
| @@ -17,6 +17,7 @@ import { |
| import { parseInviteFragment } from "/static/invite.js"; |
| import { appSchemeFrom } from "/static/ui.js"; |
| import { PREJOIN_STASH_KEY, offerPrejoin, prejoinStash, readPrejoinReturn, homeSigninURL } from "/static/prejoin.js"; |
| +import { INVITER_HINT_KEY, inviterHintStash } from "/static/invitehint.js"; |
| import { launchEnrol } from "/static/backup.js"; |
| const SESSION_KEY = "lchat_session"; |
| @@ -102,6 +103,9 @@ let inviteClaimSecret = ""; |
| // published public key under it so the inviter (a T-holder) can verify the |
| // roster key wasn't substituted. Set in start(), read by finish(). |
| let inviteBindKey = ""; |
| +// The inviter's display name from the sealed payload (#1436), stashed at claim |
| +// time so the app labels them before the instance key arrives. Set in start(). |
| +let inviterFrom = ""; |
| // Whether this instance has a branding photo. Fetched in start() and shown on |
| // the onboarding cards in place of the generic emoji. On a sealed instance |
| @@ -217,6 +221,9 @@ async function start() { |
| }); |
| } |
| if (payload && payload.threadKey) { |
| + // The inviter's display name rides the sealed payload (#1436): remember it |
| + // so the app's first paint can label them before the instance key lands. |
| + inviterFrom = typeof payload.from === "string" ? payload.from.trim().slice(0, 64) : ""; |
| // User invite: it drops the joiner into a DM with the person who invited them. |
| // In the native embed the shell owns identities (Keychain) — never adopt a |
| // stray webview session; always run the fresh-person flow and hand back. |
| @@ -518,6 +525,13 @@ async function finish(id, name, pub, priv, wrappedSelf = "") { |
| localStorage.setItem(SESSION_KEY, JSON.stringify({ token: claim.token, name, userPub: pub, userPriv: priv })); |
| // Land straight in the DM this invite created (the app opens it on boot). |
| if (claim.thread_id) localStorage.setItem("lchat_open_thread", claim.thread_id); |
| + // First-paint label for the inviter (#1436) — provisional until their real |
| + // label decrypts; shell.js applies and clears it. |
| + if (claim.thread_id && inviterFrom) { |
| + try { |
| + localStorage.setItem(INVITER_HINT_KEY, inviterHintStash({ thread: claim.thread_id, name: inviterFrom, now: Date.now() })); |
| + } catch {} |
| + } |
| } |
| if (window.__lchatEmbed) { |
| // Native add-instance flow: hand the resolved identity back to the shell, which |
diff --git a/web/static/shell.js b/web/static/shell.js| index 1e7dd1a..995a6a3 100644 |
| --- a/web/static/shell.js |
| +++ b/web/static/shell.js |
| @@ -148,6 +148,7 @@ import { openMemoryPanel } from "/static/apps/memory.js"; // /memory — the AI- |
| import { setHost as setChatAppHost, setConnected as setChatAppConnected, appendNotice, setNoticeTyping, feedGlideToBottom, scrollToMessage, repaintMentionsOf } from "/static/apps/chat.js"; |
| import { avatarFor, threadAvatarFor, paintAvatar, avatarEl } from "/static/avatars.js"; |
| import { rosterLabel, publishedLabel, rosterMatches, rosterPresence, setPresenceLine, agentTag, pickerName, pickerSection } from "/static/rosterui.js"; |
| +import { INVITER_HINT_KEY, readInviterHint, inviterHintTarget } from "/static/invitehint.js"; |
| import { editGroup, showMembers, viewProfile, confirmRemoveFromSpace, updateRenameBanner, showSpaceInfo, closeChat, endConversation, toggleMute, togglePin, addPeople, manageGroupApps, leaveGroup } from "/static/groupactions.js"; |
| import { existingDM, showNewChat, showMembership, flash, showDiscover, showApps, createThread, pickerApps } from "/static/newchat.js"; |
| import { addDevice, openInApp } from "/static/adddevice.js"; |
| @@ -1867,9 +1868,30 @@ async function applyRoster(r) { |
| // they send anything before answering, recipients never see a blank sender. |
| setCurrentUser(myUserId, session.name || "New member"); // let apps do per-user logic (e.g. poll "your vote") |
| if (instanceKey) await decryptRosterLabels(); |
| + applyInviterNameHint(); |
| verifyInviteBindings(); // KT: check any invitees we hold bind keys for (#573) |
| } |
| +// The joiner's first paint (#1436): their invite's sealed payload carried the |
| +// inviter's display name. While the instance key (and so the inviter's real |
| +// label) hasn't reached this fresh device, fill the hinted DM's other member |
| +// with that name instead of "Someone" — and the moment the real label has |
| +// decrypted, drop the hint for good. Scoping (only that one member, never a |
| +// bot, never over a decrypted label) lives in inviterHintTarget. |
| +function applyInviterNameHint() { |
| + let hint = null; |
| + try { hint = readInviterHint(localStorage.getItem(INVITER_HINT_KEY) || "", Date.now()); } catch {} |
| + if (!hint) return; |
| + const t = inviterHintTarget(hint, threads, roster, myUserId); |
| + if (!t) return; |
| + if (t.resolved) { |
| + try { localStorage.removeItem(INVITER_HINT_KEY); } catch {} |
| + return; |
| + } |
| + const e = roster.find((x) => x.user_id === t.userId); |
| + if (e) e.label = t.name; |
| +} |
| + |
| // ---- key transparency: verify-before-wrap (advisory, #573) ---------------- |
| // The inviter is the one member who holds T for a joiner, so only they can |
| // check the bind-MAC. We stash each invitee's bind key at invite time, and |
| @@ -2335,6 +2357,10 @@ async function loadThreads() { |
| // path are one code path. |
| async function applyThreads(r) { |
| setThreads(r.threads || []); |
| + // Threads may land after the roster on a fresh boot — re-apply the |
| + // inviter-name hint (#1436) now that the hinted DM's members are known, |
| + // BEFORE titles compute below, so the DM lists under the inviter's name. |
| + applyInviterNameHint(); |
| for (const t of threads) { |
| if (!threadKeys[t.id] && (t.wrapped || t.open_key)) { |
| // Two key sources: the per-member wrapped copy, then — for open groups — |
| @@ -7924,7 +7950,12 @@ async function inviteSomeone(name, tierID = "") { |
| if (!me || !me.public_key) throw friendlyError("Your account is still loading — give it a second and try again."); |
| if (!instanceKey) throw friendlyError(keylessInviteReason()); |
| const selfWrapped = await wrapThreadKey(b64, me.public_key); |
| - const wrapped = await encrypt(wrapKey, { threadKey: b64 }); |
| + // My published name rides INSIDE the sealed payload (#1436): the joiner's |
| + // first paint can then label me before the instance key (and my real label) |
| + // reaches them. Published label only — never a nickname — and the server |
| + // still sees only ciphertext. |
| + const fromName = String(me.label || session.name || "").trim().slice(0, 64); |
| + const wrapped = await encrypt(wrapKey, fromName ? { threadKey: b64, from: fromName } : { threadKey: b64 }); |
| // The invitee's name is stored encrypted under the instance key (the server never |
| // sees it); the plaintext name rides in the invite link's #fragment for pre-fill. |
| const labelBlob = await encrypt(instanceKey, name); |
| @@ -7969,7 +8000,11 @@ async function resendInvite(threadId) { |
| const name = publishedLabel(other); // rides in the link they open — never my private name for them |
| const T = randomSecret(); |
| const { id, wrapKey, claimHash } = await deriveInvite(T); |
| - const wrapped = await encrypt(wrapKey, { threadKey: b64 }); |
| + // Same inviter-name hint as inviteSomeone (#1436) — a resent link's joiner |
| + // deserves the same first paint. |
| + const me = roster.find((e) => e.user_id === myUserId); |
| + const fromName = String((me && me.label) || session.name || "").trim().slice(0, 64); |
| + const wrapped = await encrypt(wrapKey, fromName ? { threadKey: b64, from: fromName } : { threadKey: b64 }); |
| const r = await ( |
| await fetch("/api/invites", { |
| method: "POST", |