diff options
Diffstat (limited to 'packages/meshbay-hub/src/meshbay_hub/static/invite-link.js')
| -rw-r--r-- | packages/meshbay-hub/src/meshbay_hub/static/invite-link.js | 125 |
1 files changed, 125 insertions, 0 deletions
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/invite-link.js b/packages/meshbay-hub/src/meshbay_hub/static/invite-link.js new file mode 100644 index 0000000..f0b4836 --- /dev/null +++ b/packages/meshbay-hub/src/meshbay_hub/static/invite-link.js @@ -0,0 +1,125 @@ +/** + * Invitation links: their one shape, and the invitation waiting in this tab. + * + * https://<hub>/#/invite?v=1&g=<group>&t=<ticket>&n=<node key>&c=<CODE> + * + * 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); |