diff options
| author | Christophe Besson <cbesson@gmail.com> | 2026-09-23 18:05:14 +0200 |
|---|---|---|
| committer | Christophe Besson <cbesson@gmail.com> | 2026-09-23 18:05:14 +0200 |
| commit | 35a7764db3f58a93c32206cb3ce74bb2f03967e7 (patch) | |
| tree | d2acb2a07ee6dc2f4241fcc785c2860d57263892 /packages/meshbay-hub/src/meshbay_hub/static/invite-link.js | |
| parent | 998f9c69308ee88fac36cfb77dfb6d07c6fa926a (diff) | |
| download | meshbay-35a7764db3f58a93c32206cb3ce74bb2f03967e7.tar.gz | |
feat(hub): open, create and join invitation links in the interface
#/invite takes the link out of the address on load and keeps it in the
tab through registration and sign-in; joining is one click, only the
ticket goes to the hub, and the code goes only to the node the link
names once it has signed its challenge. Members tab gains "Invite by
link" (shared e-mail box, pending list, cancel both halves); home page
takes a pasted link. Browser probe drives the real app, signed out and in.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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); |