web: extract paid access levels + billing into billingui.js — module 14 (#1021)
The tier list load/render, per-tier key unwrap, and the member subscription calls (subscribe/cancel/resume/change/portal) move verbatim. The 8 verbs newchat/groupactions reach through the seam stay registered, so those files are untouched; the boot-store caching of raw tier rows goes behind a new cacheTiersRaw verb (the store cache is shell's, not billing's). Also corrects two drifted banners: the tier funcs were misfiled under 'per-viewer names', and the 'paid access levels' banner had accreted core code (thread data, instance key, app lifecycle) — now named for what it actually holds.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
5 files changed,
+229
−176
CLAUDE.md+3 −0refaudit.py+35 −0test/module-refs.test.mjs+1 −1web/static/billingui.js+181 −0web/static/shell.js+9 −175
diff --git a/CLAUDE.md b/CLAUDE.md| index 84b3bc3..085529c 100644 |
| --- a/CLAUDE.md |
| +++ b/CLAUDE.md |
| @@ -211,6 +211,9 @@ Frontend module layout: |
| fragment). |
| - `onboardingui.js` — one Eleven: the scripted setup/welcome conversations + |
| the quick forms, bridging the flow engine into the chat app. |
| +- `billingui.js` — paid access levels + billing (subscriptions.go): the tier |
| + list load/render, per-tier key unwrap, and member subscribe/cancel/resume/ |
| + change/portal calls. The verbs newchat/groupactions use stay in the seam. |
| - `store.js` — the local message store (#457): IndexedDB cache of RAW server |
| responses (ciphertext rows + wrapped keys, never plaintext) that boot and |
| thread-opens hydrate from before reconciling over the network. Its pure |
diff --git a/refaudit.py b/refaudit.py| new file mode 100644 |
| index 0000000..18c4586 |
| --- /dev/null |
| +++ b/refaudit.py |
| @@ -0,0 +1,35 @@ |
| +import pathlib, re, sys |
| +# Reference audit: EVERY identifier token (not just calls) unresolved against |
| +# the module's imports, declarations (scope-blind), parameters, and globals. |
| +src = pathlib.Path(sys.argv[1]).read_text() |
| +code = re.sub(r"//[^\n]*", "", src) |
| +code = re.sub(r"`(?:[^`\\]|\\.)*`", "``", code, flags=re.S) |
| +code = re.sub(r'"(?:[^"\\]|\\.)*"', '""', code) |
| +code = re.sub(r"'(?:[^'\\]|\\.)*'", "''", code) |
| +# drop property accesses (.foo) and object keys (foo:) |
| +code2 = re.sub(r"\.\s*[A-Za-z_$][\w$]*", ".", code) |
| +code2 = re.sub(r"\b[A-Za-z_$][\w$]*\s*:", ":", code2) |
| +used = set(re.findall(r"\b([A-Za-z_$][\w$]*)\b", code2)) |
| +declared = set(re.findall(r"function\s+([A-Za-z_$][\w$]*)", code)) |
| +declared |= set(re.findall(r"(?:const|let|var)\s+([A-Za-z_$][\w$]*)", code)) |
| +declared |= set(re.findall(r"(?:const|let|var)\s*\[([^\]]*)\]", code) and |
| + [w.strip() for grp in re.findall(r"(?:const|let|var)\s*\[([^\]]*)\]", code) for w in grp.split(",") if w.strip()]) |
| +declared |= set(w.strip() for grp in re.findall(r"(?:const|let|var)\s*\{([^}]*)\}", code) for w in grp.replace(":", ",").split(",") if w.strip()) |
| +declared |= set(w.strip().split(" as ")[-1].strip() for imp in re.findall(r"import\s*\{([^}]*)\}", code) for w in imp.split(",") if w.strip()) |
| +for pl in re.findall(r"\(([^()]*)\)\s*(?:=>|\{)", code): |
| + declared |= set(w.strip().split("=")[0].strip().lstrip(".") for w in pl.split(",") if w.strip()) |
| +for m in re.findall(r"catch\s*\(([^)]*)\)", code): declared.add(m.strip()) |
| +for m in re.findall(r"for\s*\(\s*(?:const|let|var)?\s*([A-Za-z_$][\w$]*)", code): declared.add(m) |
| +GLOBALS = set("""if else for while switch case default catch finally return await async new typeof delete void in of do break continue |
| +try throw this true false null undefined function const let var class extends super static get set yield |
| +fetch encodeURIComponent decodeURIComponent atob btoa JSON Promise Date Math Object Array String Number Boolean |
| +URL URLSearchParams Blob File Uint8Array ArrayBuffer FileReader Set Map WeakMap WeakSet RegExp Error TypeError RangeError |
| +console document window location history navigator crypto performance screen |
| +localStorage sessionStorage matchMedia requestAnimationFrame cancelAnimationFrame setTimeout setInterval clearTimeout |
| +clearInterval alert confirm prompt structuredClone AbortController CustomEvent Event KeyboardEvent MouseEvent WebSocket |
| +TextEncoder TextDecoder Intl isNaN isFinite parseInt parseFloat Function Symbol Proxy Reflect queueMicrotask createImageBitmap |
| +Audio Image Notification getComputedStyle getSelection open close focus blur scrollTo requestIdleCallback |
| +NaN Infinity globalThis arguments verbs""".split()) |
| +unresolved = sorted(used - declared - GLOBALS) |
| +print(f"--- {sys.argv[1]}") |
| +print("\n".join(unresolved) if unresolved else "REFS CLEAN") |
diff --git a/test/module-refs.test.mjs b/test/module-refs.test.mjs| index 30983ef..6ffcbe9 100644 |
| --- a/test/module-refs.test.mjs |
| +++ b/test/module-refs.test.mjs |
| @@ -17,7 +17,7 @@ import assert from "node:assert"; |
| const MODULES = [ |
| "theme", "lightbox", "media", "shellstate", "shellverbs", "moderation", |
| "searchui", "avatars", "rosterui", "groupactions", "newchat", "adddevice", |
| - "onboardingui", |
| + "onboardingui", "billingui", |
| ]; |
| const GLOBALS = new Set(`if else for while switch case default catch finally return await async new typeof delete void in of do break continue try throw this true false null undefined function const let var class extends super static get set yield export import from as |
diff --git a/web/static/billingui.js b/web/static/billingui.js| new file mode 100644 |
| index 0000000..34d908a |
| --- /dev/null |
| +++ b/web/static/billingui.js |
| @@ -0,0 +1,181 @@ |
| +// Paid access levels + billing (subscriptions.go). The tier list load/render, |
| +// the per-tier key unwrap, and the member subscription calls (subscribe / |
| +// cancel / resume / change / portal — each returns {url} to redirect, true for |
| +// a plain ok, or null on failure). Extracted verbatim from shell.js; the 8 |
| +// verbs newchat/groupactions reach through the seam stay registered there. |
| +import { verbs } from "/static/shellverbs.js"; |
| +import { session, roster, myUserId, tiers, tierKeys, tierKeyB64, setTiers } from "/static/shellstate.js"; |
| +import { wrapThreadKey, unwrapThreadKey, importKey, generateKey } from "/static/crypto.js"; |
| + |
| +// Fetch the access levels and unwrap our tier keys (each an instance-key-style |
| +// wrapped blob). A level we belong to but hold no key for yet gets a targeted |
| +// nudge — the server prods current holders, one of whom wraps it to us. |
| +export async function loadTiers() { |
| + let rows; |
| + try { |
| + rows = (await (await fetch(`/api/tiers?token=${encodeURIComponent(session.token)}`)).json()).tiers || []; |
| + } catch { |
| + return; // offline — the next boot or WS prod retries |
| + } |
| + verbs.cacheTiersRaw(rows); // shell owns the boot-store cache |
| + await applyTiers(rows, true); |
| +} |
| + |
| +// The apply half of loadTiers. `live` gates the nudge/grant side-effects — |
| +// a cached-snapshot hydrate only unwraps keys; the real fetch that follows |
| +// does the network housekeeping. |
| +export async function applyTiers(rows, live) { |
| + setTiers(rows); |
| + for (const t of tiers) { |
| + if (!tierKeys[t.id] && t.wrapped) { |
| + try { |
| + const b64 = await unwrapThreadKey(t.wrapped, session.userPriv); |
| + tierKeys[t.id] = await importKey(b64); |
| + tierKeyB64[t.id] = b64; |
| + } catch (e) { |
| + console.error("unwrap tier", t.id, e); |
| + } |
| + } |
| + if (!live) continue; |
| + if (t.mine && !t.wrapped && (t.have || []).length) { |
| + fetch(`/api/tiers/${encodeURIComponent(t.id)}/nudge`, { |
| + method: "POST", |
| + headers: { "Content-Type": "application/json" }, |
| + body: JSON.stringify({ token: session.token }), |
| + }).catch(() => {}); |
| + } |
| + // Holder-side self-healing (grantInstanceKeyTo's rule, per level): wrap |
| + // our copy to any member still missing theirs — no nudge needed, so key |
| + // delivery survives the one-shot prod being missed. |
| + if (tierKeyB64[t.id]) { |
| + const have = new Set(t.have || []); |
| + const grants = []; |
| + for (const uid of (t.members || []).filter((u) => !have.has(u))) { |
| + const e = roster.find((x) => x.user_id === uid && x.public_key); |
| + if (e) grants.push({ user_id: uid, wrapped: await wrapThreadKey(tierKeyB64[t.id], e.public_key) }); |
| + } |
| + if (grants.length) { |
| + fetch(`/api/tiers/${encodeURIComponent(t.id)}/keys`, { |
| + method: "POST", |
| + headers: { "Content-Type": "application/json" }, |
| + body: JSON.stringify({ token: session.token, grants }), |
| + }).catch(() => {}); |
| + } |
| + } |
| + } |
| +} |
| + |
| +// Access levels re-sync on focus (throttled): the instance key polls on a |
| +// timer; levels get the cheaper version — returning to the tab retries key |
| +// delivery both ways (our nudge, our holder backfill). |
| +let tiersFocusSyncAt = 0; |
| +window.addEventListener("focus", () => { |
| + if (!session || Date.now() - tiersFocusSyncAt < 60000) return; |
| + tiersFocusSyncAt = Date.now(); |
| + loadTiers(); |
| +}); |
| + |
| +// A member's own show/hide for a level (honoured only under the "user" |
| +// policy). Used by the self-view in Edit profile; returns true on success. |
| +export async function setTierShown(id, show) { |
| + try { |
| + const r = await fetch(`/api/tiers/${encodeURIComponent(id)}/show`, { |
| + method: "POST", |
| + headers: { "Content-Type": "application/json" }, |
| + body: JSON.stringify({ token: session.token, show }), |
| + }); |
| + return r.ok; |
| + } catch { |
| + return false; |
| + } |
| +} |
| + |
| +// Each returns {url} for a redirect, true for a plain ok, or null on failure. |
| +async function billingPost(path, body) { |
| + try { |
| + const r = await fetch(path, { |
| + method: "POST", |
| + headers: { "Content-Type": "application/json" }, |
| + body: JSON.stringify({ token: session.token, ...body }), |
| + }); |
| + if (!r.ok) return null; |
| + return await r.json().catch(() => ({})); |
| + } catch { |
| + return null; |
| + } |
| +} |
| +export const subscribeToLevel = (id) => billingPost(`/api/tiers/${encodeURIComponent(id)}/subscribe`, {}); |
| +export const cancelLevelSub = (id) => billingPost(`/api/tiers/${encodeURIComponent(id)}/cancel-subscription`, {}); |
| +// Resume un-cancels the EXISTING subscription (clears cancel-at-period-end) — |
| +// never a fresh checkout, which would open a second, double-charging sub. |
| +export const resumeLevelSub = (id) => billingPost(`/api/tiers/${encodeURIComponent(id)}/resume`, {}); |
| +export const changeLevelSub = (fromId, toId) => billingPost(`/api/tiers/${encodeURIComponent(fromId)}/change`, { to_tier: toId }); |
| +export const levelPortal = (id) => billingPost(`/api/tiers/${encodeURIComponent(id)}/portal`, {}); |
| +// The five const-arrow billing verbs can't join the boot-time assign above |
| +// (no hoisting); register them here, once they exist. |
| +Object.assign(verbs, { subscribeToLevel, cancelLevelSub, resumeLevelSub, changeLevelSub, levelPortal }); |
| + |
| +// A calm, dated day like "5 February 2026" from unix seconds. |
| +export function billingDate(unixSecs) { |
| + if (!unixSecs) return ""; |
| + try { |
| + return new Date(unixSecs * 1000).toLocaleDateString(undefined, { day: "numeric", month: "long", year: "numeric" }); |
| + } catch { |
| + return ""; |
| + } |
| +} |
| + |
| +// A level's price as "€5 / month" (symbol-lite: uses the currency code when we |
| +// don't know the symbol — clearer than guessing). |
| +export function levelPrice(t) { |
| + if (!t.price_cents) return ""; |
| + const amount = (t.price_cents / 100).toFixed(t.price_cents % 100 ? 2 : 0); |
| + const sym = { usd: "$", eur: "€", gbp: "£" }[(t.currency || "").toLowerCase()] || (t.currency || "").toUpperCase() + " "; |
| + const per = t.interval === "year" ? "year" : "month"; |
| + return { text: `${sym}${amount}`, per }; |
| +} |
| + |
| +// Make sure every member of a level holds its key, as far as THIS client can: |
| +// wrap with our copy if we hold one, or mint if nobody holds one yet (the mint |
| +// moment — the server allows it for admins only). A minting admin who isn't a |
| +// member of the level DISCARDS the raw key after wrapping: curating a level |
| +// must not silently grant the curator read access to what it will gate. |
| +export async function ensureTierKeys(id) { |
| + await loadTiers(); |
| + const level = tiers.find((t) => t.id === id); |
| + if (!level) return; |
| + const have = new Set(level.have || []); |
| + const need = (level.members || []).filter((uid) => !have.has(uid)); |
| + if (!need.length) return; |
| + let b64 = tierKeyB64[id]; |
| + let minting = false; |
| + if (!b64) { |
| + if (have.size) return; // holders exist — the server's prod asks them instead |
| + minting = true; |
| + b64 = (await generateKey()).b64; |
| + } |
| + const grants = []; |
| + for (const uid of need) { |
| + const e = roster.find((x) => x.user_id === uid && x.public_key); |
| + if (e) grants.push({ user_id: uid, wrapped: await wrapThreadKey(b64, e.public_key) }); |
| + } |
| + if (!grants.length) return; // a key that reached nobody must not be installed |
| + const r = await fetch(`/api/tiers/${encodeURIComponent(id)}/keys`, { |
| + method: "POST", |
| + headers: { "Content-Type": "application/json" }, |
| + body: JSON.stringify({ token: session.token, grants }), |
| + }); |
| + if (!r.ok) { |
| + // Lost a mint race (409), the level's key is gone (410), or auth/server |
| + // trouble — NEVER install a key the server rejected: a locally-kept |
| + // loser key would gate groups nobody else can open. Reload and let the |
| + // holder/nudge machinery take over from the server's truth. |
| + await loadTiers(); |
| + return; |
| + } |
| + if (minting && (level.members || []).includes(myUserId)) { |
| + tierKeyB64[id] = b64; |
| + tierKeys[id] = await importKey(b64); |
| + } |
| + // minting && not a member → b64 goes out of scope right here, on purpose. |
| +} |
diff --git a/web/static/shell.js b/web/static/shell.js| index 1773bdc..74d337f 100644 |
| --- a/web/static/shell.js |
| +++ b/web/static/shell.js |
| @@ -138,6 +138,7 @@ import { rosterLabel, publishedLabel, rosterMatches, rosterPresence, setPresence |
| import { editGroup, openOrCreateDM, showMembers, viewProfile, 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 } from "/static/adddevice.js"; |
| +import { loadTiers, applyTiers, setTierShown, subscribeToLevel, cancelLevelSub, resumeLevelSub, changeLevelSub, levelPortal, billingDate, levelPrice, ensureTierKeys } from "/static/billingui.js"; |
| import { sendScriptedBubble, sendTypedPayload, scriptedSaid, liveElevenAgent, maybeStartWelcome, openSetupQuickForm } from "/static/onboardingui.js"; |
| import { verbs } from "/static/shellverbs.js"; // the behaviour seam (docs/shell-modules.md) |
| import { scheduleMessageSearch, wireSearch, searchIdx } from "/static/searchui.js"; // message search over the local store (#586) |
| @@ -194,6 +195,7 @@ Object.assign(verbs, { |
| loadTiers, setNickname, syncThreadKeysToSW, |
| inviteSomeone, resolveAppThread, billingDate, levelPrice, inviteAllowed, |
| decryptRosterLabels, openThreadById, seedAppState, welcomePending, isAdmin, |
| + cacheTiersRaw, |
| }); |
| onThemeApplied(pushTheme); // live-retint mounted sandbox frames on theme change |
| @@ -1005,6 +1007,12 @@ async function loadMemberAppsNow() { |
| // next load, hydrateFromCache() replays them through the same apply pipeline — |
| // instant paint, zero network — then the real fetches reconcile. |
| const bootRaw = { threads: null, roster: null, nick: null, tiers: null, instance: null }; |
| +// Cache the raw tier rows into the boot store (billingui calls this via the |
| +// verbs seam — the store cache is shell's, not billing's). |
| +function cacheTiersRaw(rows) { |
| + bootRaw.tiers = structuredClone(rows); |
| + scheduleStoreSnapshot(); |
| +} |
| let cachePainted = false; // a cached paint happened; network failures must not blank it |
| let snapTimer = null; |
| function scheduleStoreSnapshot() { |
| @@ -1868,181 +1876,7 @@ async function pollForInstanceKey(tries = 24) { |
| } |
| } |
| -// Fetch the access levels and unwrap our tier keys (each an instance-key-style |
| -// wrapped blob). A level we belong to but hold no key for yet gets a targeted |
| -// nudge — the server prods current holders, one of whom wraps it to us. |
| -async function loadTiers() { |
| - let rows; |
| - try { |
| - rows = (await (await fetch(`/api/tiers?token=${encodeURIComponent(session.token)}`)).json()).tiers || []; |
| - } catch { |
| - return; // offline — the next boot or WS prod retries |
| - } |
| - bootRaw.tiers = structuredClone(rows); |
| - scheduleStoreSnapshot(); |
| - await applyTiers(rows, true); |
| -} |
| - |
| -// The apply half of loadTiers. `live` gates the nudge/grant side-effects — |
| -// a cached-snapshot hydrate only unwraps keys; the real fetch that follows |
| -// does the network housekeeping. |
| -async function applyTiers(rows, live) { |
| - setTiers(rows); |
| - for (const t of tiers) { |
| - if (!tierKeys[t.id] && t.wrapped) { |
| - try { |
| - const b64 = await unwrapThreadKey(t.wrapped, session.userPriv); |
| - tierKeys[t.id] = await importKey(b64); |
| - tierKeyB64[t.id] = b64; |
| - } catch (e) { |
| - console.error("unwrap tier", t.id, e); |
| - } |
| - } |
| - if (!live) continue; |
| - if (t.mine && !t.wrapped && (t.have || []).length) { |
| - fetch(`/api/tiers/${encodeURIComponent(t.id)}/nudge`, { |
| - method: "POST", |
| - headers: { "Content-Type": "application/json" }, |
| - body: JSON.stringify({ token: session.token }), |
| - }).catch(() => {}); |
| - } |
| - // Holder-side self-healing (grantInstanceKeyTo's rule, per level): wrap |
| - // our copy to any member still missing theirs — no nudge needed, so key |
| - // delivery survives the one-shot prod being missed. |
| - if (tierKeyB64[t.id]) { |
| - const have = new Set(t.have || []); |
| - const grants = []; |
| - for (const uid of (t.members || []).filter((u) => !have.has(u))) { |
| - const e = roster.find((x) => x.user_id === uid && x.public_key); |
| - if (e) grants.push({ user_id: uid, wrapped: await wrapThreadKey(tierKeyB64[t.id], e.public_key) }); |
| - } |
| - if (grants.length) { |
| - fetch(`/api/tiers/${encodeURIComponent(t.id)}/keys`, { |
| - method: "POST", |
| - headers: { "Content-Type": "application/json" }, |
| - body: JSON.stringify({ token: session.token, grants }), |
| - }).catch(() => {}); |
| - } |
| - } |
| - } |
| -} |
| - |
| -// Access levels re-sync on focus (throttled): the instance key polls on a |
| -// timer; levels get the cheaper version — returning to the tab retries key |
| -// delivery both ways (our nudge, our holder backfill). |
| -let tiersFocusSyncAt = 0; |
| -window.addEventListener("focus", () => { |
| - if (!session || Date.now() - tiersFocusSyncAt < 60000) return; |
| - tiersFocusSyncAt = Date.now(); |
| - loadTiers(); |
| -}); |
| - |
| -// A member's own show/hide for a level (honoured only under the "user" |
| -// policy). Used by the self-view in Edit profile; returns true on success. |
| -async function setTierShown(id, show) { |
| - try { |
| - const r = await fetch(`/api/tiers/${encodeURIComponent(id)}/show`, { |
| - method: "POST", |
| - headers: { "Content-Type": "application/json" }, |
| - body: JSON.stringify({ token: session.token, show }), |
| - }); |
| - return r.ok; |
| - } catch { |
| - return false; |
| - } |
| -} |
| - |
| -// ---- paid access levels: member billing calls (subscriptions.go) ---------- |
| -// Each returns {url} for a redirect, true for a plain ok, or null on failure. |
| -async function billingPost(path, body) { |
| - try { |
| - const r = await fetch(path, { |
| - method: "POST", |
| - headers: { "Content-Type": "application/json" }, |
| - body: JSON.stringify({ token: session.token, ...body }), |
| - }); |
| - if (!r.ok) return null; |
| - return await r.json().catch(() => ({})); |
| - } catch { |
| - return null; |
| - } |
| -} |
| -const subscribeToLevel = (id) => billingPost(`/api/tiers/${encodeURIComponent(id)}/subscribe`, {}); |
| -const cancelLevelSub = (id) => billingPost(`/api/tiers/${encodeURIComponent(id)}/cancel-subscription`, {}); |
| -// Resume un-cancels the EXISTING subscription (clears cancel-at-period-end) — |
| -// never a fresh checkout, which would open a second, double-charging sub. |
| -const resumeLevelSub = (id) => billingPost(`/api/tiers/${encodeURIComponent(id)}/resume`, {}); |
| -const changeLevelSub = (fromId, toId) => billingPost(`/api/tiers/${encodeURIComponent(fromId)}/change`, { to_tier: toId }); |
| -const levelPortal = (id) => billingPost(`/api/tiers/${encodeURIComponent(id)}/portal`, {}); |
| -// The five const-arrow billing verbs can't join the boot-time assign above |
| -// (no hoisting); register them here, once they exist. |
| -Object.assign(verbs, { subscribeToLevel, cancelLevelSub, resumeLevelSub, changeLevelSub, levelPortal }); |
| - |
| -// A calm, dated day like "5 February 2026" from unix seconds. |
| -function billingDate(unixSecs) { |
| - if (!unixSecs) return ""; |
| - try { |
| - return new Date(unixSecs * 1000).toLocaleDateString(undefined, { day: "numeric", month: "long", year: "numeric" }); |
| - } catch { |
| - return ""; |
| - } |
| -} |
| - |
| -// A level's price as "€5 / month" (symbol-lite: uses the currency code when we |
| -// don't know the symbol — clearer than guessing). |
| -function levelPrice(t) { |
| - if (!t.price_cents) return ""; |
| - const amount = (t.price_cents / 100).toFixed(t.price_cents % 100 ? 2 : 0); |
| - const sym = { usd: "$", eur: "€", gbp: "£" }[(t.currency || "").toLowerCase()] || (t.currency || "").toUpperCase() + " "; |
| - const per = t.interval === "year" ? "year" : "month"; |
| - return { text: `${sym}${amount}`, per }; |
| -} |
| - |
| -// Make sure every member of a level holds its key, as far as THIS client can: |
| -// wrap with our copy if we hold one, or mint if nobody holds one yet (the mint |
| -// moment — the server allows it for admins only). A minting admin who isn't a |
| -// member of the level DISCARDS the raw key after wrapping: curating a level |
| -// must not silently grant the curator read access to what it will gate. |
| -async function ensureTierKeys(id) { |
| - await loadTiers(); |
| - const level = tiers.find((t) => t.id === id); |
| - if (!level) return; |
| - const have = new Set(level.have || []); |
| - const need = (level.members || []).filter((uid) => !have.has(uid)); |
| - if (!need.length) return; |
| - let b64 = tierKeyB64[id]; |
| - let minting = false; |
| - if (!b64) { |
| - if (have.size) return; // holders exist — the server's prod asks them instead |
| - minting = true; |
| - b64 = (await generateKey()).b64; |
| - } |
| - const grants = []; |
| - for (const uid of need) { |
| - const e = roster.find((x) => x.user_id === uid && x.public_key); |
| - if (e) grants.push({ user_id: uid, wrapped: await wrapThreadKey(b64, e.public_key) }); |
| - } |
| - if (!grants.length) return; // a key that reached nobody must not be installed |
| - const r = await fetch(`/api/tiers/${encodeURIComponent(id)}/keys`, { |
| - method: "POST", |
| - headers: { "Content-Type": "application/json" }, |
| - body: JSON.stringify({ token: session.token, grants }), |
| - }); |
| - if (!r.ok) { |
| - // Lost a mint race (409), the level's key is gone (410), or auth/server |
| - // trouble — NEVER install a key the server rejected: a locally-kept |
| - // loser key would gate groups nobody else can open. Reload and let the |
| - // holder/nudge machinery take over from the server's truth. |
| - await loadTiers(); |
| - return; |
| - } |
| - if (minting && (level.members || []).includes(myUserId)) { |
| - tierKeyB64[id] = b64; |
| - tierKeys[id] = await importKey(b64); |
| - } |
| - // minting && not a member → b64 goes out of scope right here, on purpose. |
| -} |
| - |
| +// ---- thread data, instance key & app lifecycle ---------------------------- |
| // Flip the open Edit-profile sheet's photo control from "Photo syncing…" to |
| // "Change photo" the moment the instance key arrives (live, no reload needed). |
| function refreshProfilePhotoBtn() { |