/** * Invitation links: their one shape, and the invitation waiting in this tab. * * https:///#/invite?v=1&g=&t=&n=&c= * * docs/MESHBAY_DESIGN.md §3.4. Two secrets: the ticket is for the hub, which * grants membership to the one account whose address it was sent to; the code * is for the node the link names, and is never sent to the hub from here. * * Everything is after `#`, so no part of the link reaches the hub in a request. * It is read the moment this module loads — before the router, before any * `await` — and the address is rewritten to `#/invite`, so the code does not sit * in the address bar, in a bookmark or in a screenshot. What was read is kept in * this tab's `sessionStorage`: it has to survive registration, a reload and a * sign-in, and nothing else. Not `localStorage`, which every tab shares and * which outlives the tab. Cleared on success, refusal, expiry and sign-out. * * `api/invite_links.py` `invite_url` writes the same shape when the hub mails * a link; `test_invite_link_client.py` holds the two to each other. */ import * as platform from './platform.js'; const STORE_KEY = 'mb.pendingInvite'; // A client-side bound on how long an unused invitation is carried. The node // and the hub keep their own, shorter by default; this only stops a tab left // open for a month from offering a link that died long ago. const CARRY_MS = 30 * 24 * 3600 * 1000; const GROUP = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/; const TICKET = /^[A-Za-z0-9_-]{22}$/; const NODE_PK = /^[A-Za-z0-9_-]{43}$/; const CODE = /^[0-9A-Za-z]{4}-[0-9A-Za-z]{4}$/; /** * `{g, t, n, c}` out of a link, a `#/invite?…` fragment or a pasted piece of * text containing either — or null. Every field is checked for its exact shape, * so nothing read here is ever more than an id, a token, a key and a code. */ export function parseInvite(text) { const s = String(text || '').trim(); const at = s.indexOf('/invite?'); if (at < 0) return null; const q = new URLSearchParams(s.slice(at + '/invite?'.length)); const inv = { g: q.get('g') || '', t: q.get('t') || '', n: q.get('n') || '', c: (q.get('c') || '').toUpperCase() }; if (q.get('v') !== '1' || !GROUP.test(inv.g) || !TICKET.test(inv.t) || !NODE_PK.test(inv.n) || !CODE.test(inv.c)) return null; return inv; } export function buildInviteLink(origin, { g, t, n, c }) { return `${origin}/#/invite?v=1&g=${g}&t=${t}&n=${n}&c=${c}`; } /** The origin a pasted link points at, or '' if it is not a URL at all. */ export function linkOrigin(text) { try { return new URL(String(text).trim()).origin; } catch { return ''; } } /** A node key as the transport and the hub write it (standard base64). */ export function nodePkFromLink(n) { const b64 = n.replace(/-/g, '+').replace(/_/g, '/'); return b64 + '='.repeat((4 - (b64.length % 4)) % 4); } /** The same key as a link carries it: URL-safe, unpadded. */ export function nodePkForLink(b64) { return String(b64).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); } export function savePending(inv) { try { sessionStorage.setItem(STORE_KEY, JSON.stringify({ ...inv, exp: Date.now() + CARRY_MS })); } catch { /* storage refused: the link can be opened again */ } } export function loadPending() { try { const inv = JSON.parse(sessionStorage.getItem(STORE_KEY) || 'null'); if (!inv || !(inv.exp > Date.now()) || !parseInvite(buildInviteLink('', inv))) { clearPending(); return null; } return inv; } catch { return null; } } export function clearPending() { try { sessionStorage.removeItem(STORE_KEY); } catch { /* nothing kept */ } } /** The pending invitation, if it is for this group. */ export function pendingFor(groupId) { const inv = loadPending(); return inv && inv.g === groupId ? inv : null; } /** * Take an invitation out of the address, keep it, and leave `#/invite` behind. * * Runs on load and on every `hashchange` — a link pasted into the address bar * of a tab already open. `replaceState` fires no `hashchange`, so the router * reads the cleaned address, never the one with the code in it. A malformed * link is cleaned out of the address too; it is simply not kept. */ export function captureFromLocation() { const hash = window.location.hash || ''; if (!hash.startsWith('#/invite?')) return null; const inv = parseInvite(hash); window.history.replaceState( null, '', window.location.pathname + window.location.search + '#/invite'); if (inv) savePending(inv); return inv; } /** This hub's link for an invitation, for the person who creates it. */ export function inviteLinkHere(inv) { return buildInviteLink(platform.hubOrigin(), inv); } captureFromLocation(); window.addEventListener('hashchange', captureFromLocation);