aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/src/meshbay_hub/static/invite-link.js
diff options
context:
space:
mode:
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.js125
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);