| 1 | // Invite-link fragment codec (see docs/onboarding-and-home-server.md). |
| 2 | // New links carry a versioned fingerprint — /join/join#e1.<T> or #e1.<T>~<name> — |
| 3 | // so clients (web paste-to-join, the iOS and Mac apps) can recognise an Eleven |
| 4 | // invite deterministically and offline, and future format changes can bump the |
| 5 | // version. Old unprefixed #<T> links parse identically. Pure and framework-free |
| 6 | // so it's unit-testable in Node (test/invite.test.mjs). |
| 7 | |
| 8 | export const INVITE_PREFIX = "e1."; |
| 9 | |
| 10 | // The branding key's shape (docs/no-plaintext.md): lowercase base32, minted |
| 11 | // 10 chars, tolerated 8–32 so the length can be dialed without a format bump. |
| 12 | // Used to disambiguate the third fragment slot from legacy names containing a |
| 13 | // raw "~" (encodeURIComponent leaves "~" unescaped; new builders escape it). |
| 14 | const BRANDING_KEY_RE = /^[a-z2-7]{8,32}$/; |
| 15 | |
| 16 | // parseInviteFragment("e1.<T>~<name>~<b>") -> { T, name, b }, name URL-decoded |
| 17 | // ("" if absent or undecodable), b the branding key when the link carries one |
| 18 | // (sealed instances put it in every minted link). Accepts unprefixed legacy |
| 19 | // fragments and the older 1- and 2-slot forms; slot order is fixed (T first) |
| 20 | // so old parsers keep claiming from new links. |
| 21 | export function parseInviteFragment(frag) { |
| 22 | let f = frag || ""; |
| 23 | if (f.startsWith(INVITE_PREFIX)) f = f.slice(INVITE_PREFIX.length); |
| 24 | const parts = f.split("~"); |
| 25 | const T = parts[0]; |
| 26 | let name = parts.slice(1).join("~"); |
| 27 | let b = ""; |
| 28 | const last = parts[parts.length - 1]; |
| 29 | if (parts.length >= 3 && BRANDING_KEY_RE.test(last)) { |
| 30 | b = last; |
| 31 | name = parts.slice(1, -1).join("~"); |
| 32 | } |
| 33 | return { T, name: safeDecode(name), b }; |
| 34 | } |
| 35 | |
| 36 | // buildInviteFragment(T, name?, b?) -> "e1.<T>", "e1.<T>~<encoded name>", or |
| 37 | // "e1.<T>~<encoded name>~<b>" (name slot kept, possibly empty, whenever b is |
| 38 | // present). All minters use this, so every new link carries the fingerprint — |
| 39 | // and, on a sealed instance, the branding key. |
| 40 | export function buildInviteFragment(T, name = "", b = "") { |
| 41 | let f = INVITE_PREFIX + T; |
| 42 | if (name || b) f += "~" + encodeURIComponent(name).replace(/~/g, "%7E"); |
| 43 | if (b) f += "~" + b; |
| 44 | return f; |
| 45 | } |
| 46 | |
| 47 | // True when the fragment carries the versioned fingerprint — the deterministic |
| 48 | // "this is an Eleven invite" signal (an arbitrary URL with /join/ in it isn't). |
| 49 | export function hasInviteFingerprint(frag) { |
| 50 | return (frag || "").startsWith(INVITE_PREFIX); |
| 51 | } |
| 52 | |
| 53 | function safeDecode(s) { |
| 54 | try { |
| 55 | return decodeURIComponent(s); |
| 56 | } catch { |
| 57 | return ""; |
| 58 | } |
| 59 | } |
| 60 | |