eleven / messenger Public

Clone
git clone https://amadan.net/eleven/messenger

Plain git — no account needed to clone.

Download

Download this file

1// Paid access levels + billing (subscriptions.go). The tier list load/render,
2// the per-tier key unwrap, and the member subscription calls (subscribe /
3// cancel / resume / change / portal — each returns {url} to redirect, true for
4// a plain ok, or null on failure). Extracted verbatim from shell.js; the 8
5// verbs newchat/groupactions reach through the seam stay registered there.
6import { verbs } from "/static/shellverbs.js";
7import { session, roster, myUserId, tiers, tierKeys, tierKeyB64, setTiers } from "/static/shellstate.js";
8import { wrapThreadKey, unwrapThreadKey, importKey, generateKey } from "/static/crypto.js";
9
10// Fetch the access levels and unwrap our tier keys (each an instance-key-style
11// wrapped blob). A level we belong to but hold no key for yet gets a targeted
12// nudge — the server prods current holders, one of whom wraps it to us.
13export async function loadTiers() {
14 let rows;
15 try {
16 rows = (await (await fetch(`/api/tiers?token=${encodeURIComponent(session.token)}`)).json()).tiers || [];
17 } catch {
18 return; // offline — the next boot or WS prod retries
19 }
20 verbs.cacheTiersRaw(rows); // shell owns the boot-store cache
21 await applyTiers(rows, true);
22}
23
24// The apply half of loadTiers. `live` gates the nudge/grant side-effects —
25// a cached-snapshot hydrate only unwraps keys; the real fetch that follows
26// does the network housekeeping.
27export async function applyTiers(rows, live) {
28 setTiers(rows);
29 for (const t of tiers) {
30 if (!tierKeys[t.id] && t.wrapped) {
31 try {
32 const b64 = await unwrapThreadKey(t.wrapped, session.userPriv);
33 tierKeys[t.id] = await importKey(b64);
34 tierKeyB64[t.id] = b64;
35 } catch (e) {
36 console.error("unwrap tier", t.id, e);
37 }
38 }
39 if (!live) continue;
40 if (t.mine && !t.wrapped && (t.have || []).length) {
41 fetch(`/api/tiers/${encodeURIComponent(t.id)}/nudge`, {
42 method: "POST",
43 headers: { "Content-Type": "application/json" },
44 body: JSON.stringify({ token: session.token }),
45 }).catch(() => {});
46 }
47 // Holder-side self-healing (grantInstanceKeyTo's rule, per level): wrap
48 // our copy to any member still missing theirs — no nudge needed, so key
49 // delivery survives the one-shot prod being missed.
50 if (tierKeyB64[t.id]) {
51 const have = new Set(t.have || []);
52 const grants = [];
53 for (const uid of (t.members || []).filter((u) => !have.has(u))) {
54 const e = roster.find((x) => x.user_id === uid && x.public_key);
55 if (e) grants.push({ user_id: uid, wrapped: await wrapThreadKey(tierKeyB64[t.id], e.public_key) });
56 }
57 if (grants.length) {
58 fetch(`/api/tiers/${encodeURIComponent(t.id)}/keys`, {
59 method: "POST",
60 headers: { "Content-Type": "application/json" },
61 body: JSON.stringify({ token: session.token, grants }),
62 }).catch(() => {});
63 }
64 }
65 }
66}
67
68// Access levels re-sync on focus (throttled): the instance key polls on a
69// timer; levels get the cheaper version — returning to the tab retries key
70// delivery both ways (our nudge, our holder backfill).
71let tiersFocusSyncAt = 0;
72window.addEventListener("focus", () => {
73 if (!session || Date.now() - tiersFocusSyncAt < 60000) return;
74 tiersFocusSyncAt = Date.now();
75 loadTiers();
76});
77
78// A member's own show/hide for a level (honoured only under the "user"
79// policy). Used by the self-view in Edit profile; returns true on success.
80export async function setTierShown(id, show) {
81 try {
82 const r = await fetch(`/api/tiers/${encodeURIComponent(id)}/show`, {
83 method: "POST",
84 headers: { "Content-Type": "application/json" },
85 body: JSON.stringify({ token: session.token, show }),
86 });
87 return r.ok;
88 } catch {
89 return false;
90 }
91}
92
93// Each returns {url} for a redirect, true for a plain ok, or null on failure.
94async function billingPost(path, body) {
95 try {
96 const r = await fetch(path, {
97 method: "POST",
98 headers: { "Content-Type": "application/json" },
99 body: JSON.stringify({ token: session.token, ...body }),
100 });
101 if (!r.ok) return null;
102 return await r.json().catch(() => ({}));
103 } catch {
104 return null;
105 }
106}
107export const subscribeToLevel = (id) => billingPost(`/api/tiers/${encodeURIComponent(id)}/subscribe`, {});
108export const cancelLevelSub = (id) => billingPost(`/api/tiers/${encodeURIComponent(id)}/cancel-subscription`, {});
109// Resume un-cancels the EXISTING subscription (clears cancel-at-period-end) —
110// never a fresh checkout, which would open a second, double-charging sub.
111export const resumeLevelSub = (id) => billingPost(`/api/tiers/${encodeURIComponent(id)}/resume`, {});
112export const changeLevelSub = (fromId, toId) => billingPost(`/api/tiers/${encodeURIComponent(fromId)}/change`, { to_tier: toId });
113export const levelPortal = (id) => billingPost(`/api/tiers/${encodeURIComponent(id)}/portal`, {});
114// The five const-arrow billing verbs can't join the boot-time assign above
115// (no hoisting); register them here, once they exist.
116Object.assign(verbs, { subscribeToLevel, cancelLevelSub, resumeLevelSub, changeLevelSub, levelPortal });
117
118// A calm, dated day like "5 February 2026" from unix seconds.
119export function billingDate(unixSecs) {
120 if (!unixSecs) return "";
121 try {
122 return new Date(unixSecs * 1000).toLocaleDateString(undefined, { day: "numeric", month: "long", year: "numeric" });
123 } catch {
124 return "";
125 }
126}
127
128// A level's price as "€5 / month" (symbol-lite: uses the currency code when we
129// don't know the symbol — clearer than guessing).
130export function levelPrice(t) {
131 if (!t.price_cents) return "";
132 const amount = (t.price_cents / 100).toFixed(t.price_cents % 100 ? 2 : 0);
133 const sym = { usd: "$", eur: "€", gbp: "£" }[(t.currency || "").toLowerCase()] || (t.currency || "").toUpperCase() + " ";
134 // 'once' is a lifetime purchase — rendered "· once", never "/ once".
135 const per = t.interval === "once" ? "once" : t.interval === "year" ? "year" : "month";
136 return { text: `${sym}${amount}`, per, once: t.interval === "once" };
137}
138
139// Make sure every member of a level holds its key, as far as THIS client can:
140// wrap with our copy if we hold one, or mint if nobody holds one yet (the mint
141// moment — the server allows it for admins only). A minting admin who isn't a
142// member of the level DISCARDS the raw key after wrapping: curating a level
143// must not silently grant the curator read access to what it will gate.
144export async function ensureTierKeys(id) {
145 await loadTiers();
146 const level = tiers.find((t) => t.id === id);
147 if (!level) return;
148 const have = new Set(level.have || []);
149 const need = (level.members || []).filter((uid) => !have.has(uid));
150 if (!need.length) return;
151 let b64 = tierKeyB64[id];
152 let minting = false;
153 if (!b64) {
154 if (have.size) return; // holders exist — the server's prod asks them instead
155 minting = true;
156 b64 = (await generateKey()).b64;
157 }
158 const grants = [];
159 for (const uid of need) {
160 const e = roster.find((x) => x.user_id === uid && x.public_key);
161 if (e) grants.push({ user_id: uid, wrapped: await wrapThreadKey(b64, e.public_key) });
162 }
163 if (!grants.length) return; // a key that reached nobody must not be installed
164 const r = await fetch(`/api/tiers/${encodeURIComponent(id)}/keys`, {
165 method: "POST",
166 headers: { "Content-Type": "application/json" },
167 body: JSON.stringify({ token: session.token, grants }),
168 });
169 if (!r.ok) {
170 // Lost a mint race (409), the level's key is gone (410), or auth/server
171 // trouble — NEVER install a key the server rejected: a locally-kept
172 // loser key would gate groups nobody else can open. Reload and let the
173 // holder/nudge machinery take over from the server's truth.
174 await loadTiers();
175 return;
176 }
177 if (minting && (level.members || []).includes(myUserId)) {
178 tierKeyB64[id] = b64;
179 tierKeys[id] = await importKey(b64);
180 }
181 // minting && not a member → b64 goes out of scope right here, on purpose.
182}
183