aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub
diff options
context:
space:
mode:
Diffstat (limited to 'packages/meshbay-hub')
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/app.js20
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/auth-page.js6
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/group-page.js31
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/group-settings.js172
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/invite-link.js125
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/invite-page.js153
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/de.js34
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/en.js34
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/es.js34
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js34
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/it.js34
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js34
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js34
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js34
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js34
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js34
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/platform.js11
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/transport.js70
-rw-r--r--packages/meshbay-hub/tests/harness/invite_link_probe.py181
-rw-r--r--packages/meshbay-hub/tests/test_invite_link_client.py194
-rw-r--r--packages/meshbay-hub/tests/test_invite_link_flow.py61
-rw-r--r--packages/meshbay-hub/tests/test_signed_in_never_sees_the_login_form.py6
22 files changed, 1360 insertions, 10 deletions
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/app.js b/packages/meshbay-hub/src/meshbay_hub/static/app.js
index fbfb942..a757e61 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/app.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/app.js
@@ -1,3 +1,6 @@
+// First, on purpose: loading it takes an invitation link out of the address
+// before anything else here can read, log or route on it (invite-link.js).
+import { clearPending, loadPending } from './invite-link.js';
import {
html, render, useState, useEffect, useLayoutEffect, useCallback, useRef,
createContext, useContext,
@@ -29,6 +32,7 @@ import { ProfilePage } from './profile-page.js';
import { ExplorePage } from './explore-page.js';
import { GroupName } from './group-name.js';
import { FirstRunPage, LoginPage, RegisterPage, ResetPasswordPage } from './auth-page.js';
+import { InvitePage, JoinByLink } from './invite-page.js';
// ── Constants ────────────────────────────────────────────────────────────────
@@ -536,6 +540,7 @@ function HomePage({ groups, notifications, onMarkRead, onPurge, allowPublicGroup
<h2>${t('home.welcome')}</h2>
<${NotificationFeed} notifications=${notifications}
onMarkRead=${onMarkRead} onPurge=${onPurge} />
+ <${JoinByLink} hubOrigin=${platform.hubOrigin()} />
<p class="page-message">
${t('home.no_groups')}
${' '}${allowPublicGroups
@@ -568,6 +573,7 @@ function HomePage({ groups, notifications, onMarkRead, onPurge, allowPublicGroup
</a>
`)}
</div>
+ <${JoinByLink} hubOrigin=${platform.hubOrigin()} />
</div>
`;
}
@@ -680,7 +686,8 @@ function App() {
// flow signs in half-way and still has its progress and result to show.
const onAuthForm = route === '/login' || route === '/register';
useEffect(() => {
- if (user && onAuthForm) window.location.replace('#/');
+ // An invitation waiting in this tab is where a sign-in was headed.
+ if (user && onAuthForm) window.location.replace(loadPending() ? '#/invite' : '#/');
}, [user, onAuthForm]);
// A desktop build with a remembered device signs in without asking. Null
// until it has tried, so nothing renders a sign-in form the user is about to
@@ -1091,6 +1098,8 @@ function App() {
// Revoked on the hub too, so a copy of the refresh token is worth
// nothing. Read before the next line clears it.
logoutOnHub();
+ // An invitation belongs to whoever was about to use it, not to the tab.
+ clearPending();
setAuth(null);
setUser(null);
setGroups([]);
@@ -1136,6 +1145,15 @@ function App() {
: route === '/reset'
? html`<${ResetPasswordPage} onLogin=${authCtx.login} />`
: html`<${LoginPage} onLogin=${authCtx.login} />`;
+ } else if (route === '/invite') {
+ // Signed in or not: signed out, it explains and sends to register or sign
+ // in; signed in, it asks the hub what the ticket is for.
+ page = html`<${InvitePage} user=${user} onJoined=${async () => {
+ try {
+ const data = await hubFetch('/v1/groups/mine', { token: user.token });
+ setGroups(data.groups || []);
+ } catch { /* the group page fetches what it needs */ }
+ }} />`;
} else if (!user) {
page = html`<${LoginPage} onLogin=${authCtx.login} />`;
} else if (route === '/search') {
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/auth-page.js b/packages/meshbay-hub/src/meshbay_hub/static/auth-page.js
index f0af187..70c48f2 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/auth-page.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/auth-page.js
@@ -7,6 +7,7 @@ import {
} from './hub-client.js';
import * as platform from './platform.js';
import { Icon } from './icon.js';
+import { loadPending } from './invite-link.js';
const PASSWORD_MIN_BITS = 60;
const PASSWORD_MIN_LEN = 12;
@@ -185,7 +186,8 @@ export function LoginPage({ onLogin }) {
// Trimmed to match the hub's stored username and every client-side key
// derivation (auth_key, bundle_key, recovery_key all fold the username in).
await onLogin(name, password);
- navigate('/');
+ // Back to the invitation that sent them here, if one is waiting.
+ navigate(loadPending() ? '/invite' : '/');
} catch (err) {
if (err.message === 'email_verification_required') {
setPendingVerif(true);
@@ -415,6 +417,8 @@ export function RegisterPage() {
<p style="text-align:center; margin-bottom:16px; color:var(--text-secondary)">
${t('register.verified_msg')}
</p>
+ ${loadPending() && html`
+ <p style="text-align:center; margin-bottom:16px">${t('invite.after_register')}</p>`}
<a href="#/login" style="display:block; text-align:center">${t('register.go_login')}</a>
</div>
</div>
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/group-page.js b/packages/meshbay-hub/src/meshbay_hub/static/group-page.js
index 4fdf5f8..a25c07b 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/group-page.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/group-page.js
@@ -16,6 +16,7 @@ import { FilePreview } from './files-app.js';
import { VideoPlayer } from './video-player.js';
import { GroupSettingsPanel } from './group-settings.js';
import { reportIndexPush } from './index-dock.js';
+import { clearPending, nodePkFromLink, pendingFor } from './invite-link.js';
/**
* The group shell: everything a group's "applications" (Chat, Files, and
@@ -398,6 +399,17 @@ function GroupPage({ groupId, group, token, username, userId, userPrefs,
// `transport` holds the attempt in progress, and keeps whichever one
// answers — so it is null after the loop exactly when none did.
let transport = null, ack = null, lastErr = null;
+ // An invitation link for this group names the node that holds its
+ // code: that node is tried first, and only it is handed the code —
+ // the transport refuses any other (docs/MESHBAY_DESIGN.md §3.4). A
+ // code typed into the form takes precedence and goes as it always has.
+ const link = session.pendingJoinCode ? null : pendingFor(groupId);
+ const joinCode = session.pendingJoinCode || (link ? link.c : null);
+ const joinNodePk = link ? nodePkFromLink(link.n) : undefined;
+ if (link) {
+ nodesData.nodes.sort((a, b) =>
+ (b.pk_node === joinNodePk) - (a.pk_node === joinNodePk));
+ }
for (const n of nodesData.nodes) {
// The same base the API calls use: signaling is a hub endpoint like
// any other, and two sources for one address is how they drift.
@@ -417,7 +429,7 @@ function GroupPage({ groupId, group, token, username, userId, userPrefs,
try {
ack = await transport.connect(
n.node_id, live, groupId, null, sessionKeys, session.bundleKey,
- username, userId, session.pendingJoinCode, session.recoveryKey);
+ username, userId, joinCode, session.recoveryKey, joinNodePk);
break;
} catch (e) {
lastErr = e;
@@ -432,12 +444,18 @@ function GroupPage({ groupId, group, token, username, userId, userPrefs,
// A refusal that names a state of *this browser* — a code to enter,
// a passphrase, a device to approve — is the same answer from every
// node, and the operator to act on is this one's. Trying the next
- // node would only replace it with a less useful message.
- if (e.reason && e.reason !== 'not_hosted') throw e;
+ // node would only replace it with a less useful message. The one
+ // exception besides `not_hosted` is a link naming another host.
+ if (e.reason && e.reason !== 'not_hosted' && e.reason !== 'link_other_node') {
+ throw e;
+ }
}
}
if (!transport) throw (lastErr || new Error('no node served this group'));
session.pendingJoinCode = null;
+ // In: the invitation has done its job, whether its code was spent now or
+ // the node already knew us.
+ if (link) clearPending();
if (cancelled) return;
applyAck(ack);
transport.onAppsEnabled = (apps) => setEnabledApps(apps);
@@ -617,6 +635,13 @@ function GroupPage({ groupId, group, token, username, userId, userPrefs,
// one-time code from the operator before it will hand over the group
// key. Not an error to shout about — a step in joining.
if (err.reason === 'code_required') setNeedsCode(true);
+ // The link's code was refused by the node that issued it — used, or
+ // cancelled. It will not work on another try; say so, and drop it.
+ if (err.reason === 'code_invalid' && pendingFor(groupId)
+ && !session.pendingJoinCode) {
+ clearPending();
+ err.message = t('group.link_spent');
+ }
// The node has no bundle for us and this browser derived no key to make
// one — the passphrase form below is the way in, not a support request.
if (err.reason === 'no_keys') setNeedsPass(true);
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/group-settings.js b/packages/meshbay-hub/src/meshbay_hub/static/group-settings.js
index 64ea3fa..121c51b 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/group-settings.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/group-settings.js
@@ -8,6 +8,7 @@ import { CollapsibleSection, ToggleSwitch } from './settings-ui.js';
import { hubFetch, navigate } from './hub-client.js';
import { availableApps, configurableApps } from './apps.js';
import * as platform from './platform.js';
+import { inviteLinkHere, nodePkForLink } from './invite-link.js';
// The account preference behind the "send by e-mail" box. The hub's
// ALLOWED_PREF_KEYS must list it, or every toggle snaps back.
@@ -893,6 +894,112 @@ function GroupSettingsPanel({ groupId, group, token, transportRef, gekRef,
}
}, [groupId, token, inviteUser, inviteByEmail, loadMembers, transportRef]);
+ // ── Invitation links (docs/MESHBAY_DESIGN.md §3.4) ──────────────────
+ //
+ // Two halves, in the order that leaves nothing half-made: the node's code
+ // first, then the hub's ticket bound to the address; a ticket the hub then
+ // refuses takes the code back with it, since a code nobody can reach the node
+ // with only occupies one of the group's twenty places. The code reaches the
+ // hub only when the box asks the hub to write the mail.
+ const [linkEmail, setLinkEmail] = useState('');
+ const [linking, setLinking] = useState(false);
+ const [linkError, setLinkError] = useState('');
+ const [newLink, setNewLink] = useState(null);
+ const [linkCopied, setLinkCopied] = useState(false);
+ const [links, setLinks] = useState([]);
+
+ const linkSignFn = useCallback(() => {
+ const transport = transportRef && transportRef.current;
+ const sk = transport && transport.sessionKeys && transport.sessionKeys.skEdB64;
+ return (sk && window.MeshBayKeys)
+ ? (transcript) => window.MeshBayKeys.signBytes(sk, transcript)
+ : null;
+ }, [transportRef]);
+
+ const loadLinks = useCallback(() => {
+ if (!(group && group.is_admin)) return;
+ hubFetch(`/v1/groups/${groupId}/invite-links`, { token })
+ .then(data => setLinks(data.links || []))
+ .catch(() => {});
+ }, [groupId, token, group]);
+
+ useEffect(() => { loadLinks(); }, [loadLinks]);
+
+ const doCreateLink = useCallback(async (e) => {
+ e.preventDefault();
+ const email = linkEmail.trim();
+ if (!email) return;
+ setLinking(true);
+ setLinkError('');
+ setNewLink(null);
+ setLinkCopied(false);
+ try {
+ const transport = transportRef && transportRef.current;
+ if (!transport || !transport.connected) {
+ throw new Error('Not connected to the node — it must be online to invite');
+ }
+ const signFn = linkSignFn();
+ const node = await transport.createLinkInvite(groupId, signFn);
+ const n = nodePkForLink(transport.nodePk);
+ let ticket;
+ try {
+ ticket = await hubFetch(`/v1/groups/${groupId}/invite-links`, {
+ method: 'POST', token,
+ body: {
+ email, expires_at: node.expires_at, node_invite_id: node.invite_id,
+ send_email: inviteByEmail,
+ ...(inviteByEmail ? { node_pk: n, code: node.code } : {}),
+ },
+ });
+ } catch (err) {
+ try { await transport.cancelLinkInvite(node.invite_id, signFn); } catch { /* expires */ }
+ throw err;
+ }
+ setNewLink({
+ email,
+ link: inviteLinkHere({ g: groupId, t: ticket.ticket, n, c: node.code }),
+ emailStatus: ticket.email_status,
+ });
+ setLinkEmail('');
+ loadLinks();
+ } catch (err) {
+ setLinkError(err.message);
+ } finally {
+ setLinking(false);
+ }
+ }, [groupId, token, linkEmail, inviteByEmail, transportRef, linkSignFn, loadLinks]);
+
+ // Node half first, hub half regardless: a node that refuses (offline, or the
+ // code already expired there) must not leave the ticket open on the hub.
+ const cancelLink = useCallback(async (row) => {
+ setLinkError('');
+ let nodeError = '';
+ try {
+ const transport = transportRef && transportRef.current;
+ if (!transport || !transport.connected) throw new Error(t('group.offline_title'));
+ await transport.cancelLinkInvite(row.node_invite_id, linkSignFn());
+ } catch (err) {
+ nodeError = err.message;
+ }
+ try {
+ await hubFetch(`/v1/groups/${groupId}/invite-links/${row.link_id}`, {
+ method: 'DELETE', token,
+ });
+ } catch (err) {
+ nodeError = nodeError ? `${nodeError} — ${err.message}` : err.message;
+ }
+ if (nodeError) setLinkError(nodeError);
+ loadLinks();
+ }, [groupId, token, transportRef, linkSignFn, loadLinks]);
+
+ const copyLink = useCallback(async () => {
+ if (!newLink) return;
+ try {
+ await navigator.clipboard.writeText(newLink.link);
+ setLinkCopied(true);
+ } catch { /* the field is selectable */ }
+ }, [newLink]);
+
if (loading) return html`<p class="page-message">${t('explore.loading')}</p>`;
const isOwner = Boolean(isAdmin);
@@ -948,6 +1055,71 @@ function GroupSettingsPanel({ groupId, group, token, transportRef, gekRef,
</div>
`}
+ ${isAdmin && group?.join_policy !== 'open' && html`
+ <div class="settings-section">
+ <h3 class="settings-heading">${t('members.link_title')}</h3>
+ <p class="settings-hint">${t('members.link_hint')}</p>
+ ${linkError && html`<div class="error-msg" style="margin-bottom:8px">${linkError}</div>`}
+ <form onSubmit=${doCreateLink}>
+ ${newLink && html`
+ <div class="success-msg" style="margin-bottom:8px">
+ <p>${t('members.link_ready', { email: newLink.email })}</p>
+ <div class="form-row">
+ <input type="text" readonly value=${newLink.link}
+ onFocus=${e => e.target.select()} />
+ <button class="admin-btn" type="button" onClick=${copyLink}>
+ ${linkCopied ? t('members.link_copied') : t('members.link_copy')}
+ </button>
+ </div>
+ ${newLink.emailStatus === 'sent'
+ ? html`<p style="color:var(--success)">${t('members.link_email_sent')}</p>`
+ : newLink.emailStatus !== 'not_requested'
+ && html`<p style="color:var(--text-dim)">${t('members.link_email_refused')}</p>`
+ }
+ </div>
+ `}
+ <div class="form-row">
+ <input type="email" placeholder=${t('members.link_email_placeholder')}
+ value=${linkEmail} onInput=${e => setLinkEmail(e.target.value)}
+ disabled=${!connected || !operatorPaired} required />
+ <button class="admin-btn" type="submit"
+ disabled=${linking || !connected || !operatorPaired}>
+ ${linking ? '...' : t('members.link_btn')}
+ </button>
+ </div>
+ <label style="display:flex; gap:8px; align-items:flex-start; margin:6px 0 0;
+ font-size:0.88em; color:var(--text-secondary)">
+ <input type="checkbox" checked=${inviteByEmail}
+ onChange=${e => toggleInviteByEmail(e.target.checked)} />
+ <span>${t('members.invite_email_opt')}</span>
+ </label>
+ </form>
+ ${links.length > 0 && html`
+ <h4 style="margin-top:12px">${t('members.links_pending')}</h4>
+ <ul class="invite-links">
+ ${links.map(l => html`
+ <li key=${l.link_id} style="display:flex;gap:8px;align-items:center;
+ flex-wrap:wrap;word-break:break-word;margin:4px 0">
+ <span>${l.email}</span>
+ <span style="color:var(--text-dim)">
+ ${l.status === 'redeemed'
+ ? t('members.link_status_redeemed', { user: l.redeemed_by || '' })
+ : l.status === 'expired'
+ ? t('members.link_status_expired')
+ : t('members.link_expires',
+ { date: new Date(l.expires_at).toLocaleDateString() })}
+ </span>
+ ${l.status === 'pending' && html`
+ <button class="admin-btn" type="button"
+ disabled=${!connected || !operatorPaired}
+ onClick=${() => cancelLink(l)}>${t('members.link_cancel')}</button>`}
+ </li>
+ `)}
+ </ul>
+ `}
+ </div>
+ `}
+
${isNodeAdmin && !operatorPaired && connected && html`
<div class="settings-section">
<h3 class="settings-heading">${t('members.pair_title')}</h3>
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);
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/invite-page.js b/packages/meshbay-hub/src/meshbay_hub/static/invite-page.js
new file mode 100644
index 0000000..dd678aa
--- /dev/null
+++ b/packages/meshbay-hub/src/meshbay_hub/static/invite-page.js
@@ -0,0 +1,153 @@
+/**
+ * `#/invite` — an invitation link, opened (docs/MESHBAY_DESIGN.md §3.4).
+ *
+ * By the time this renders, `invite-link.js` has taken the invitation out of
+ * the address and kept it in this tab. Signed out, the page says what is
+ * waiting and offers to register or sign in; both come back here. Signed in, it
+ * asks the hub what the ticket is for and shows it — **joining is a click, never
+ * automatic**, or a link would put anyone in any group without asking.
+ *
+ * Only the ticket goes to the hub from here. The code stays in the tab until
+ * the group page hands it to the one node the link names.
+ */
+
+import { html, useCallback, useEffect, useState } from './vendor/htm-preact.js';
+import { t } from './i18n.js';
+import { hubFetch, navigate } from './hub-client.js';
+import {
+ clearPending, linkOrigin, loadPending, parseInvite, savePending,
+} from './invite-link.js';
+
+export function InvitePage({ user, onJoined }) {
+ const [inv] = useState(loadPending);
+ const [phase, setPhase] = useState(inv ? 'loading' : 'none');
+ const [preview, setPreview] = useState(null);
+ const [error, setError] = useState('');
+
+ const refused = useCallback((err) => {
+ if (err.message === 'invite_other_account') {
+ // Kept: signing out and in again as the right account is the fix.
+ setPhase('other');
+ } else if (err.message === 'invite_not_valid') {
+ clearPending();
+ setPhase('invalid');
+ } else {
+ setError(err.message);
+ setPhase('error');
+ }
+ }, []);
+
+ useEffect(() => {
+ if (!inv || !user) return undefined;
+ let cancelled = false;
+ hubFetch('/v1/invite-links/preview', {
+ method: 'POST', token: user.token, body: { ticket: inv.t },
+ })
+ .then((p) => {
+ if (cancelled) return;
+ // The group the hub names must be the group the link names.
+ if (p.group_id !== inv.g) { clearPending(); setPhase('invalid'); return; }
+ setPreview(p);
+ setPhase('confirm');
+ })
+ .catch((err) => { if (!cancelled) refused(err); });
+ return () => { cancelled = true; };
+ }, [inv, user, refused]);
+
+ const join = useCallback(async () => {
+ setPhase('joining');
+ try {
+ const r = await hubFetch('/v1/invite-links/redeem', {
+ method: 'POST', token: user.token, body: { ticket: inv.t },
+ });
+ if (r.group_id !== inv.g) { clearPending(); setPhase('invalid'); return; }
+ // The code is still pending: the group page takes it to the node.
+ if (onJoined) await onJoined(inv.g);
+ navigate(`/group/${inv.g}`);
+ } catch (err) {
+ refused(err);
+ }
+ }, [inv, user, onJoined, refused]);
+
+ const ignore = useCallback(() => {
+ clearPending();
+ navigate('/');
+ }, []);
+
+ let body;
+ if (phase === 'none') {
+ body = html`<p>${t('invite.none')}</p>`;
+ } else if (!user) {
+ body = html`
+ <p>${t('invite.signed_out')}</p>
+ <div style="display:flex;gap:8px;flex-wrap:wrap;margin-top:16px">
+ <a class="btn btn-primary" href="#/register">${t('invite.register')}</a>
+ <a class="btn btn-secondary" href="#/login">${t('invite.signin')}</a>
+ </div>`;
+ } else if (phase === 'loading' || phase === 'joining') {
+ body = html`<p>${phase === 'joining' ? t('invite.joining') : t('explore.loading')}</p>`;
+ } else if (phase === 'confirm' && preview) {
+ body = preview.already_member ? html`
+ <p>${t('invite.already_member', { group: preview.group_name })}</p>
+ <div style="margin-top:16px">
+ <button class="btn btn-primary" onClick=${join}>${t('invite.open')}</button>
+ </div>` : html`
+ <p>${t('invite.confirm', { inviter: preview.inviter, group: preview.group_name })}</p>
+ <div style="display:flex;gap:8px;flex-wrap:wrap;margin-top:16px">
+ <button class="btn btn-primary" onClick=${join}>${t('invite.join')}</button>
+ <button class="btn btn-secondary" onClick=${ignore}>${t('invite.ignore')}</button>
+ </div>`;
+ } else if (phase === 'other') {
+ body = html`
+ <p class="error-msg">${t('invite.other_account')}</p>
+ <div style="margin-top:16px">
+ <button class="btn btn-secondary" onClick=${ignore}>${t('invite.ignore')}</button>
+ </div>`;
+ } else if (phase === 'invalid') {
+ body = html`<p class="error-msg">${t('invite.invalid')}</p>`;
+ } else {
+ body = html`<p class="error-msg">${error}</p>`;
+ }
+
+ return html`
+ <div class="page-center">
+ <div class="card login-card">
+ <h2>${t('invite.title')}</h2>
+ ${body}
+ </div>
+ </div>`;
+}
+
+/**
+ * Paste a link instead of clicking it — the desktop application's way in, since
+ * a link in a mail opens the browser, and anyone's who copied rather than
+ * clicked. A link for another hub is refused here: its ticket means nothing to
+ * this one, and sending it would hand this hub someone else's secret.
+ */
+export function JoinByLink({ hubOrigin }) {
+ const [text, setText] = useState('');
+ const [error, setError] = useState('');
+ const onSubmit = useCallback((e) => {
+ e.preventDefault();
+ const inv = parseInvite(text);
+ if (!inv) { setError(t('invite.paste_invalid')); return; }
+ const origin = linkOrigin(text);
+ if (origin && origin !== hubOrigin) { setError(t('invite.paste_other_hub')); return; }
+ savePending(inv);
+ setText('');
+ setError('');
+ navigate('/invite');
+ }, [text, hubOrigin]);
+
+ return html`
+ <form class="invite-form" style="margin:16px 0" onSubmit=${onSubmit}>
+ <h4>${t('invite.paste_title')}</h4>
+ <div style="display:flex;gap:8px">
+ <input type="text" autocomplete="off" spellcheck="false"
+ placeholder=${t('invite.paste_placeholder')}
+ value=${text} onInput=${e => setText(e.target.value)} required />
+ <button class="admin-btn" type="submit">${t('invite.paste_btn')}</button>
+ </div>
+ ${error && html`<p class="error-msg" style="margin-top:8px">${error}</p>`}
+ </form>`;
+}
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js
index 17a184e..90f5c00 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js
@@ -687,6 +687,40 @@ export default {
'members.invite_email_sent': 'Eine E-Mail mit dem Code wurde an dieses Mitglied gesendet.',
'members.invite_email_failed': 'Die E-Mail konnte nicht gesendet werden — bitte teilen Sie den Code manuell mit.',
'members.invite_email_opt': 'Einladung per E-Mail senden (kann im Spam landen)',
+ 'members.link_title': "Per Link einladen",
+ 'members.link_hint': "Für jemanden, der vielleicht noch kein Konto hat. Der Link funktioniert einmal und nur für ein Konto mit dieser Adresse.",
+ 'members.link_email_placeholder': "E-Mail-Adresse",
+ 'members.link_btn': "Link erstellen",
+ 'members.link_ready': "Einladungslink für {email}:",
+ 'members.link_copy': "Kopieren",
+ 'members.link_copied': "Kopiert",
+ 'members.link_email_sent': "Der Link wurde per E-Mail gesendet.",
+ 'members.link_email_refused': "Die E-Mail konnte nicht gesendet werden — teilen Sie den Link selbst.",
+ 'members.links_pending': "Einladungslinks",
+ 'members.link_status_redeemed': "verwendet von {user}",
+ 'members.link_status_expired': "abgelaufen",
+ 'members.link_expires': "läuft ab am {date}",
+ 'members.link_cancel': "Abbrechen",
+ 'invite.title': "Sie wurden eingeladen",
+ 'invite.none': "In diesem Tab wartet keine Einladung. Öffnen Sie den erhaltenen Link erneut.",
+ 'invite.signed_out': "Jemand hat Sie in eine Gruppe auf diesem Hub eingeladen. Erstellen Sie ein Konto mit der E-Mail-Adresse, an die die Einladung ging, oder melden Sie sich an, falls Sie bereits eines haben.",
+ 'invite.register': "Konto erstellen",
+ 'invite.signin': "Anmelden",
+ 'invite.confirm': "{inviter} lädt Sie in {group} ein.",
+ 'invite.join': "Beitreten",
+ 'invite.ignore': "Ignorieren",
+ 'invite.open': "Gruppe öffnen",
+ 'invite.already_member': "Sie sind bereits Mitglied von {group}.",
+ 'invite.other_account': "Diese Einladung wurde an eine andere E-Mail-Adresse gesendet. Melden Sie sich mit dem Konto dieser Adresse an — Aliasse und Punkte müssen genau übereinstimmen.",
+ 'invite.invalid': "Diese Einladung ist nicht mehr gültig: Sie wurde verwendet, widerrufen oder ist abgelaufen. Bitten Sie um eine neue.",
+ 'invite.joining': "Beitritt…",
+ 'invite.after_register': "Melden Sie sich an, um der Gruppe beizutreten, in die Sie eingeladen wurden.",
+ 'invite.paste_title': "Mit einem Einladungslink beitreten",
+ 'invite.paste_placeholder': "Link hier einfügen",
+ 'invite.paste_btn': "Weiter",
+ 'invite.paste_invalid': "Das ist kein Einladungslink.",
+ 'invite.paste_other_hub': "Diese Einladung gilt für einen anderen Hub.",
+ 'group.link_spent': "Dieser Einladungslink ist auf dem Rechner, der die Gruppe hostet, nicht mehr gültig. Bitten Sie um einen neuen.",
'members.invite_code_hint': 'Sie können diesen Code auch über einen anderen Kanal teilen (z. B. SMS). '
+ 'Die eingeladene Person gibt ihn ein, wenn sie diese Gruppe zum ersten Mal öffnet.',
'transfers.title': 'Übertragungen',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js
index a58fdd6..2b55d08 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js
@@ -803,6 +803,40 @@ export default {
'members.invite_email_sent': 'An email with the code has been sent to this member.',
'members.invite_email_failed': 'Could not send the email — share the code manually.',
'members.invite_email_opt': 'Send the invitation by e-mail (may land in spam)',
+ 'members.link_title': "Invite by link",
+ 'members.link_hint': "For someone who may not have an account yet. The link works once, and only for an account registered with this address.",
+ 'members.link_email_placeholder': "E-mail address",
+ 'members.link_btn': "Create link",
+ 'members.link_ready': "Invitation link for {email}:",
+ 'members.link_copy': "Copy",
+ 'members.link_copied': "Copied",
+ 'members.link_email_sent': "The link has been sent by e-mail.",
+ 'members.link_email_refused': "The e-mail could not be sent — share the link yourself.",
+ 'members.links_pending': "Invitation links",
+ 'members.link_status_redeemed': "used by {user}",
+ 'members.link_status_expired': "expired",
+ 'members.link_expires': "expires {date}",
+ 'members.link_cancel': "Cancel",
+ 'invite.title': "You have been invited",
+ 'invite.none': "There is no invitation waiting in this tab. Open the link you received again.",
+ 'invite.signed_out': "Someone invited you to a group on this hub. Create an account with the e-mail address the invitation was sent to, or sign in if you already have one.",
+ 'invite.register': "Create an account",
+ 'invite.signin': "Sign in",
+ 'invite.confirm': "{inviter} invites you to join {group}.",
+ 'invite.join': "Join",
+ 'invite.ignore': "Ignore",
+ 'invite.open': "Open the group",
+ 'invite.already_member': "You are already a member of {group}.",
+ 'invite.other_account': "This invitation was sent to another e-mail address. Sign in with the account registered with that address — aliases and dots must match exactly.",
+ 'invite.invalid': "This invitation is no longer valid: it has been used, cancelled or has expired. Ask for a new one.",
+ 'invite.joining': "Joining…",
+ 'invite.after_register': "Sign in to join the group you were invited to.",
+ 'invite.paste_title': "Join with an invitation link",
+ 'invite.paste_placeholder': "Paste the link here",
+ 'invite.paste_btn': "Continue",
+ 'invite.paste_invalid': "That is not an invitation link.",
+ 'invite.paste_other_hub': "This invitation is for another hub.",
+ 'group.link_spent': "This invitation link is no longer valid on the machine hosting the group. Ask for a new one.",
'members.invite_code_hint': 'You can also share this code via another channel (e.g. SMS). '
+ 'They enter it the first time they open this group.',
'transfers.title': 'Transfers',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js
index 09592ae..5265976 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js
@@ -682,6 +682,40 @@ export default {
'members.invite_email_sent': 'Se ha enviado un correo con el código a este miembro.',
'members.invite_email_failed': 'No se pudo enviar el correo — comparta el código manualmente.',
'members.invite_email_opt': 'Enviar la invitación por correo (puede llegar a spam)',
+ 'members.link_title': "Invitar con un enlace",
+ 'members.link_hint': "Para alguien que quizá aún no tenga cuenta. El enlace sirve una vez, y solo para una cuenta registrada con esta dirección.",
+ 'members.link_email_placeholder': "Dirección de correo",
+ 'members.link_btn': "Crear enlace",
+ 'members.link_ready': "Enlace de invitación para {email}:",
+ 'members.link_copy': "Copiar",
+ 'members.link_copied': "Copiado",
+ 'members.link_email_sent': "El enlace se ha enviado por correo.",
+ 'members.link_email_refused': "No se pudo enviar el correo — comparta el enlace usted mismo.",
+ 'members.links_pending': "Enlaces de invitación",
+ 'members.link_status_redeemed': "usado por {user}",
+ 'members.link_status_expired': "caducado",
+ 'members.link_expires': "caduca el {date}",
+ 'members.link_cancel': "Cancelar",
+ 'invite.title': "Le han invitado",
+ 'invite.none': "No hay ninguna invitación esperando en esta pestaña. Vuelva a abrir el enlace que recibió.",
+ 'invite.signed_out': "Alguien le ha invitado a un grupo en este hub. Cree una cuenta con la dirección de correo a la que se envió la invitación, o inicie sesión si ya tiene una.",
+ 'invite.register': "Crear una cuenta",
+ 'invite.signin': "Iniciar sesión",
+ 'invite.confirm': "{inviter} le invita a unirse a {group}.",
+ 'invite.join': "Unirse",
+ 'invite.ignore': "Ignorar",
+ 'invite.open': "Abrir el grupo",
+ 'invite.already_member': "Ya es miembro de {group}.",
+ 'invite.other_account': "Esta invitación se envió a otra dirección de correo. Inicie sesión con la cuenta registrada con esa dirección — los alias y los puntos deben coincidir exactamente.",
+ 'invite.invalid': "Esta invitación ya no es válida: se ha usado, cancelado o ha caducado. Pida una nueva.",
+ 'invite.joining': "Uniéndose…",
+ 'invite.after_register': "Inicie sesión para unirse al grupo al que le invitaron.",
+ 'invite.paste_title': "Unirse con un enlace de invitación",
+ 'invite.paste_placeholder': "Pegue el enlace aquí",
+ 'invite.paste_btn': "Continuar",
+ 'invite.paste_invalid': "Eso no es un enlace de invitación.",
+ 'invite.paste_other_hub': "Esta invitación es para otro hub.",
+ 'group.link_spent': "Este enlace de invitación ya no es válido en la máquina que aloja el grupo. Pida uno nuevo.",
'members.invite_code_hint': 'También puede compartir este código por otro canal (ej. SMS). '
+ 'Lo introduce la primera vez que abre este grupo.',
'transfers.title': 'Transferencias',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js
index b77bf2b..b6b909e 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js
@@ -685,6 +685,40 @@ export default {
'members.invite_email_sent': "Un e-mail avec le code a été envoyé à ce membre.",
'members.invite_email_failed': "Impossible d’envoyer l’e-mail — partagez le code manuellement.",
'members.invite_email_opt': 'Envoyer l’invitation par e-mail (risque d’arriver dans les spams)',
+ 'members.link_title': "Inviter par lien",
+ 'members.link_hint': "Pour quelqu’un qui n’a peut-être pas encore de compte. Le lien ne sert qu’une fois, et seulement pour un compte créé avec cette adresse.",
+ 'members.link_email_placeholder': "Adresse e-mail",
+ 'members.link_btn': "Créer le lien",
+ 'members.link_ready': "Lien d’invitation pour {email} :",
+ 'members.link_copy': "Copier",
+ 'members.link_copied': "Copié",
+ 'members.link_email_sent': "Le lien a été envoyé par e-mail.",
+ 'members.link_email_refused': "L’e-mail n’a pas pu être envoyé — partagez le lien vous-même.",
+ 'members.links_pending': "Liens d’invitation",
+ 'members.link_status_redeemed': "utilisé par {user}",
+ 'members.link_status_expired': "expiré",
+ 'members.link_expires': "expire le {date}",
+ 'members.link_cancel': "Annuler",
+ 'invite.title': "Vous êtes invité",
+ 'invite.none': "Aucune invitation n’attend dans cet onglet. Rouvrez le lien que vous avez reçu.",
+ 'invite.signed_out': "Quelqu’un vous a invité dans un groupe sur ce hub. Créez un compte avec l’adresse e-mail à laquelle l’invitation a été envoyée, ou connectez-vous si vous en avez déjà un.",
+ 'invite.register': "Créer un compte",
+ 'invite.signin': "Se connecter",
+ 'invite.confirm': "{inviter} vous invite à rejoindre {group}.",
+ 'invite.join': "Rejoindre",
+ 'invite.ignore': "Ignorer",
+ 'invite.open': "Ouvrir le groupe",
+ 'invite.already_member': "Vous êtes déjà membre de {group}.",
+ 'invite.other_account': "Cette invitation a été envoyée à une autre adresse e-mail. Connectez-vous avec le compte créé avec cette adresse — les alias et les points doivent correspondre exactement.",
+ 'invite.invalid': "Cette invitation n’est plus valable : elle a été utilisée, annulée ou a expiré. Demandez-en une nouvelle.",
+ 'invite.joining': "Adhésion…",
+ 'invite.after_register': "Connectez-vous pour rejoindre le groupe auquel vous avez été invité.",
+ 'invite.paste_title': "Rejoindre avec un lien d’invitation",
+ 'invite.paste_placeholder': "Collez le lien ici",
+ 'invite.paste_btn': "Continuer",
+ 'invite.paste_invalid': "Ce n’est pas un lien d’invitation.",
+ 'invite.paste_other_hub': "Cette invitation concerne un autre hub.",
+ 'group.link_spent': "Ce lien d’invitation n’est plus valable sur la machine qui héberge le groupe. Demandez-en un nouveau.",
'members.invite_code_hint': "Vous pouvez aussi transmettre ce code par un autre canal (ex. SMS). "
+ "La personne le saisit la première fois qu’elle ouvre ce groupe.",
'transfers.title': 'Transferts',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js
index 2deb4fc..2a95bf7 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js
@@ -685,6 +685,40 @@ export default {
'members.invite_email_sent': "Un'e-mail con il codice è stata inviata a questo membro.",
'members.invite_email_failed': "Impossibile inviare l'e-mail — condivida il codice manualmente.",
'members.invite_email_opt': 'Invia l’invito per e-mail (potrebbe finire nello spam)',
+ 'members.link_title': "Invita tramite link",
+ 'members.link_hint': "Per chi forse non ha ancora un account. Il link funziona una volta, e solo per un account registrato con questo indirizzo.",
+ 'members.link_email_placeholder': "Indirizzo e-mail",
+ 'members.link_btn': "Crea link",
+ 'members.link_ready': "Link d’invito per {email}:",
+ 'members.link_copy': "Copia",
+ 'members.link_copied': "Copiato",
+ 'members.link_email_sent': "Il link è stato inviato per e-mail.",
+ 'members.link_email_refused': "Impossibile inviare l’e-mail — condivida il link di persona.",
+ 'members.links_pending': "Link d’invito",
+ 'members.link_status_redeemed': "usato da {user}",
+ 'members.link_status_expired': "scaduto",
+ 'members.link_expires': "scade il {date}",
+ 'members.link_cancel': "Annulla",
+ 'invite.title': "È stato invitato",
+ 'invite.none': "Nessun invito in attesa in questa scheda. Riapra il link ricevuto.",
+ 'invite.signed_out': "Qualcuno l’ha invitata in un gruppo su questo hub. Crei un account con l’indirizzo e-mail a cui è stato inviato l’invito, o acceda se ne ha già uno.",
+ 'invite.register': "Crea un account",
+ 'invite.signin': "Accedi",
+ 'invite.confirm': "{inviter} la invita a unirsi a {group}.",
+ 'invite.join': "Unisciti",
+ 'invite.ignore': "Ignora",
+ 'invite.open': "Apri il gruppo",
+ 'invite.already_member': "È già membro di {group}.",
+ 'invite.other_account': "Questo invito è stato inviato a un altro indirizzo e-mail. Acceda con l’account registrato con quell’indirizzo — alias e punti devono corrispondere esattamente.",
+ 'invite.invalid': "Questo invito non è più valido: è stato usato, annullato o è scaduto. Ne chieda uno nuovo.",
+ 'invite.joining': "Adesione…",
+ 'invite.after_register': "Acceda per unirsi al gruppo a cui è stato invitato.",
+ 'invite.paste_title': "Unisciti con un link d’invito",
+ 'invite.paste_placeholder': "Incolli qui il link",
+ 'invite.paste_btn': "Continua",
+ 'invite.paste_invalid': "Questo non è un link d’invito.",
+ 'invite.paste_other_hub': "Questo invito è per un altro hub.",
+ 'group.link_spent': "Questo link d’invito non è più valido sulla macchina che ospita il gruppo. Ne chieda uno nuovo.",
'members.invite_code_hint': 'Può anche condividere questo codice tramite un altro canale (es. SMS). '
+ 'Lo inserirà la prima volta che aprirà questo gruppo.',
'transfers.title': 'Trasferimenti',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js
index 0b46994..4068e44 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js
@@ -677,6 +677,40 @@ export default {
'members.invite_email_sent': 'このメンバーにコード付きのメールが送信されました。',
'members.invite_email_failed': 'メールを送信できませんでした。コードを手動で共有してください。',
'members.invite_email_opt': '招待をメールで送信(迷惑メールに入る場合があります)',
+ 'members.link_title': "リンクで招待",
+ 'members.link_hint': "まだアカウントを持っていない人向けです。リンクは1回だけ、このアドレスで登録したアカウントでのみ使えます。",
+ 'members.link_email_placeholder': "メールアドレス",
+ 'members.link_btn': "リンクを作成",
+ 'members.link_ready': "{email} への招待リンク:",
+ 'members.link_copy': "コピー",
+ 'members.link_copied': "コピーしました",
+ 'members.link_email_sent': "リンクをメールで送信しました。",
+ 'members.link_email_refused': "メールを送信できませんでした。リンクを直接共有してください。",
+ 'members.links_pending': "招待リンク",
+ 'members.link_status_redeemed': "{user} が使用",
+ 'members.link_status_expired': "期限切れ",
+ 'members.link_expires': "{date} に期限切れ",
+ 'members.link_cancel': "取り消す",
+ 'invite.title': "招待されています",
+ 'invite.none': "このタブで待機中の招待はありません。受け取ったリンクをもう一度開いてください。",
+ 'invite.signed_out': "このハブのグループに招待されています。招待が送られたメールアドレスでアカウントを作成するか、すでにお持ちならサインインしてください。",
+ 'invite.register': "アカウントを作成",
+ 'invite.signin': "サインイン",
+ 'invite.confirm': "{inviter} さんが {group} への参加に招待しています。",
+ 'invite.join': "参加",
+ 'invite.ignore': "無視",
+ 'invite.open': "グループを開く",
+ 'invite.already_member': "すでに {group} のメンバーです。",
+ 'invite.other_account': "この招待は別のメールアドレスに送られました。そのアドレスで登録したアカウントでサインインしてください。エイリアスやドットも完全に一致する必要があります。",
+ 'invite.invalid': "この招待は無効です。使用済み、取り消し済み、または期限切れです。新しい招待を依頼してください。",
+ 'invite.joining': "参加しています…",
+ 'invite.after_register': "招待されたグループに参加するにはサインインしてください。",
+ 'invite.paste_title': "招待リンクで参加",
+ 'invite.paste_placeholder': "ここにリンクを貼り付け",
+ 'invite.paste_btn': "続行",
+ 'invite.paste_invalid': "招待リンクではありません。",
+ 'invite.paste_other_hub': "この招待は別のハブのものです。",
+ 'group.link_spent': "この招待リンクは、グループをホストしているマシンでは無効になっています。新しいリンクを依頼してください。",
'members.invite_code_hint': 'このコードは別の手段(SMSなど)でも共有できます。'
+ '相手がこのグループを初めて開いたときに入力します。',
'transfers.title': '転送',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js
index 310bb4d..edb6f6a 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js
@@ -686,6 +686,40 @@ export default {
'members.invite_email_sent': 'Er is een e-mail met de code naar dit lid gestuurd.',
'members.invite_email_failed': 'Kon de e-mail niet verzenden — deel de code handmatig.',
'members.invite_email_opt': 'Uitnodiging per e-mail versturen (kan in spam belanden)',
+ 'members.link_title': "Uitnodigen via link",
+ 'members.link_hint': "Voor iemand die misschien nog geen account heeft. De link werkt één keer, en alleen voor een account met dit adres.",
+ 'members.link_email_placeholder': "E-mailadres",
+ 'members.link_btn': "Link maken",
+ 'members.link_ready': "Uitnodigingslink voor {email}:",
+ 'members.link_copy': "Kopiëren",
+ 'members.link_copied': "Gekopieerd",
+ 'members.link_email_sent': "De link is per e-mail verstuurd.",
+ 'members.link_email_refused': "De e-mail kon niet worden verstuurd — deel de link zelf.",
+ 'members.links_pending': "Uitnodigingslinks",
+ 'members.link_status_redeemed': "gebruikt door {user}",
+ 'members.link_status_expired': "verlopen",
+ 'members.link_expires': "verloopt op {date}",
+ 'members.link_cancel': "Annuleren",
+ 'invite.title': "U bent uitgenodigd",
+ 'invite.none': "Er wacht geen uitnodiging in dit tabblad. Open de ontvangen link opnieuw.",
+ 'invite.signed_out': "Iemand heeft u uitgenodigd voor een groep op deze hub. Maak een account aan met het e-mailadres waarnaar de uitnodiging is gestuurd, of meld u aan als u er al een hebt.",
+ 'invite.register': "Account maken",
+ 'invite.signin': "Aanmelden",
+ 'invite.confirm': "{inviter} nodigt u uit voor {group}.",
+ 'invite.join': "Deelnemen",
+ 'invite.ignore': "Negeren",
+ 'invite.open': "Groep openen",
+ 'invite.already_member': "U bent al lid van {group}.",
+ 'invite.other_account': "Deze uitnodiging is naar een ander e-mailadres gestuurd. Meld u aan met het account van dat adres — aliassen en punten moeten exact overeenkomen.",
+ 'invite.invalid': "Deze uitnodiging is niet meer geldig: ze is gebruikt, geannuleerd of verlopen. Vraag een nieuwe.",
+ 'invite.joining': "Deelnemen…",
+ 'invite.after_register': "Meld u aan om deel te nemen aan de groep waarvoor u bent uitgenodigd.",
+ 'invite.paste_title': "Deelnemen met een uitnodigingslink",
+ 'invite.paste_placeholder': "Plak de link hier",
+ 'invite.paste_btn': "Doorgaan",
+ 'invite.paste_invalid': "Dat is geen uitnodigingslink.",
+ 'invite.paste_other_hub': "Deze uitnodiging is voor een andere hub.",
+ 'group.link_spent': "Deze uitnodigingslink is niet meer geldig op de machine die de groep host. Vraag een nieuwe.",
'members.invite_code_hint': 'U kunt deze code ook via een ander kanaal delen (bijv. sms). '
+ 'De code werkt één keer en gaat nooit via de hub.',
'transfers.title': 'Overdrachten',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js
index 886f1c7..d042c48 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js
@@ -698,6 +698,40 @@ export default {
'members.invite_email_sent': 'E-mail z kodem został wysłany do tego członka.',
'members.invite_email_failed': 'Nie udało się wysłać e-maila — przekaż kod ręcznie.',
'members.invite_email_opt': 'Wyślij zaproszenie e-mailem (może trafić do spamu)',
+ 'members.link_title': "Zaproś linkiem",
+ 'members.link_hint': "Dla kogoś, kto może jeszcze nie mieć konta. Link działa raz i tylko dla konta założonego na ten adres.",
+ 'members.link_email_placeholder': "Adres e-mail",
+ 'members.link_btn': "Utwórz link",
+ 'members.link_ready': "Link zaproszenia dla {email}:",
+ 'members.link_copy': "Kopiuj",
+ 'members.link_copied': "Skopiowano",
+ 'members.link_email_sent': "Link został wysłany e-mailem.",
+ 'members.link_email_refused': "Nie udało się wysłać e-maila — przekaż link samodzielnie.",
+ 'members.links_pending': "Linki zaproszeń",
+ 'members.link_status_redeemed': "użyty przez {user}",
+ 'members.link_status_expired': "wygasł",
+ 'members.link_expires': "wygasa {date}",
+ 'members.link_cancel': "Anuluj",
+ 'invite.title': "Otrzymano zaproszenie",
+ 'invite.none': "W tej karcie nie czeka żadne zaproszenie. Otwórz ponownie otrzymany link.",
+ 'invite.signed_out': "Ktoś zaprosił cię do grupy na tym hubie. Załóż konto na adres e-mail, na który wysłano zaproszenie, albo zaloguj się, jeśli już je masz.",
+ 'invite.register': "Załóż konto",
+ 'invite.signin': "Zaloguj się",
+ 'invite.confirm': "{inviter} zaprasza cię do {group}.",
+ 'invite.join': "Dołącz",
+ 'invite.ignore': "Ignoruj",
+ 'invite.open': "Otwórz grupę",
+ 'invite.already_member': "Jesteś już członkiem {group}.",
+ 'invite.other_account': "To zaproszenie wysłano na inny adres e-mail. Zaloguj się na konto założone na ten adres — aliasy i kropki muszą się dokładnie zgadzać.",
+ 'invite.invalid': "To zaproszenie jest już nieważne: zostało użyte, anulowane lub wygasło. Poproś o nowe.",
+ 'invite.joining': "Dołączanie…",
+ 'invite.after_register': "Zaloguj się, aby dołączyć do grupy, do której cię zaproszono.",
+ 'invite.paste_title': "Dołącz przez link zaproszenia",
+ 'invite.paste_placeholder': "Wklej tutaj link",
+ 'invite.paste_btn': "Dalej",
+ 'invite.paste_invalid': "To nie jest link zaproszenia.",
+ 'invite.paste_other_hub': "To zaproszenie dotyczy innego huba.",
+ 'group.link_spent': "Ten link zaproszenia jest już nieważny na komputerze, który hostuje grupę. Poproś o nowy.",
'members.invite_code_hint': 'Możesz też przekazać ten kod innym kanałem (np. SMS). '
+ 'Kod działa jednorazowo i nigdy nie przechodzi przez hub.',
'transfers.title': 'Transfery',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js
index 643548a..61e4d44 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js
@@ -684,6 +684,40 @@ export default {
'members.invite_email_sent': 'Um e-mail com o código foi enviado a este membro.',
'members.invite_email_failed': 'Não foi possível enviar o e-mail — compartilhe o código manualmente.',
'members.invite_email_opt': 'Enviar o convite por e-mail (pode cair no spam)',
+ 'members.link_title': "Convidar por link",
+ 'members.link_hint': "Para alguém que talvez ainda não tenha conta. O link funciona uma vez, e só para uma conta registrada com este endereço.",
+ 'members.link_email_placeholder': "Endereço de e-mail",
+ 'members.link_btn': "Criar link",
+ 'members.link_ready': "Link de convite para {email}:",
+ 'members.link_copy': "Copiar",
+ 'members.link_copied': "Copiado",
+ 'members.link_email_sent': "O link foi enviado por e-mail.",
+ 'members.link_email_refused': "Não foi possível enviar o e-mail — compartilhe o link você mesmo.",
+ 'members.links_pending': "Links de convite",
+ 'members.link_status_redeemed': "usado por {user}",
+ 'members.link_status_expired': "expirado",
+ 'members.link_expires': "expira em {date}",
+ 'members.link_cancel': "Cancelar",
+ 'invite.title': "Você foi convidado",
+ 'invite.none': "Não há convite aguardando nesta aba. Abra novamente o link que recebeu.",
+ 'invite.signed_out': "Alguém convidou você para um grupo neste hub. Crie uma conta com o endereço de e-mail para o qual o convite foi enviado, ou entre se já tiver uma.",
+ 'invite.register': "Criar uma conta",
+ 'invite.signin': "Entrar",
+ 'invite.confirm': "{inviter} convida você para participar de {group}.",
+ 'invite.join': "Participar",
+ 'invite.ignore': "Ignorar",
+ 'invite.open': "Abrir o grupo",
+ 'invite.already_member': "Você já é membro de {group}.",
+ 'invite.other_account': "Este convite foi enviado para outro endereço de e-mail. Entre com a conta registrada com esse endereço — aliases e pontos devem coincidir exatamente.",
+ 'invite.invalid': "Este convite não é mais válido: foi usado, cancelado ou expirou. Peça um novo.",
+ 'invite.joining': "Entrando…",
+ 'invite.after_register': "Entre para participar do grupo para o qual foi convidado.",
+ 'invite.paste_title': "Participar com um link de convite",
+ 'invite.paste_placeholder': "Cole o link aqui",
+ 'invite.paste_btn': "Continuar",
+ 'invite.paste_invalid': "Isso não é um link de convite.",
+ 'invite.paste_other_hub': "Este convite é para outro hub.",
+ 'group.link_spent': "Este link de convite não é mais válido na máquina que hospeda o grupo. Peça um novo.",
'members.invite_code_hint': 'Você também pode compartilhar este código por outro canal (ex: SMS). '
+ 'O código funciona uma única vez e nunca passa pelo hub.',
'transfers.title': 'Transferências',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js
index 17adb8b..34fb439 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js
@@ -666,6 +666,40 @@ export default {
'members.invite_email_sent': '包含验证码的邮件已发送给该成员。',
'members.invite_email_failed': '无法发送邮件——请手动分享验证码。',
'members.invite_email_opt': '通过电子邮件发送邀请(可能进入垃圾邮件)',
+ 'members.link_title': "通过链接邀请",
+ 'members.link_hint': "适用于可能还没有账户的人。该链接只能使用一次,且仅限使用此地址注册的账户。",
+ 'members.link_email_placeholder': "电子邮件地址",
+ 'members.link_btn': "创建链接",
+ 'members.link_ready': "发给 {email} 的邀请链接:",
+ 'members.link_copy': "复制",
+ 'members.link_copied': "已复制",
+ 'members.link_email_sent': "链接已通过电子邮件发送。",
+ 'members.link_email_refused': "无法发送电子邮件——请自行分享链接。",
+ 'members.links_pending': "邀请链接",
+ 'members.link_status_redeemed': "已由 {user} 使用",
+ 'members.link_status_expired': "已过期",
+ 'members.link_expires': "{date} 过期",
+ 'members.link_cancel': "取消",
+ 'invite.title': "您收到了邀请",
+ 'invite.none': "此标签页中没有待处理的邀请。请重新打开您收到的链接。",
+ 'invite.signed_out': "有人邀请您加入此中心上的一个群组。请使用收到邀请的电子邮件地址创建账户,如果已有账户请登录。",
+ 'invite.register': "创建账户",
+ 'invite.signin': "登录",
+ 'invite.confirm': "{inviter} 邀请您加入 {group}。",
+ 'invite.join': "加入",
+ 'invite.ignore': "忽略",
+ 'invite.open': "打开群组",
+ 'invite.already_member': "您已经是 {group} 的成员。",
+ 'invite.other_account': "此邀请发送到了另一个电子邮件地址。请使用该地址注册的账户登录——别名和点号必须完全一致。",
+ 'invite.invalid': "此邀请已失效:已被使用、取消或已过期。请索取新的邀请。",
+ 'invite.joining': "正在加入…",
+ 'invite.after_register': "登录以加入您受邀的群组。",
+ 'invite.paste_title': "使用邀请链接加入",
+ 'invite.paste_placeholder': "在此粘贴链接",
+ 'invite.paste_btn': "继续",
+ 'invite.paste_invalid': "这不是邀请链接。",
+ 'invite.paste_other_hub': "此邀请属于另一个中心。",
+ 'group.link_spent': "此邀请链接在托管该群组的机器上已失效。请索取新的链接。",
'members.invite_code_hint': '您也可以通过其他渠道(如短信)分享此验证码。'
+ '验证码仅可使用一次,且绝不会经过 hub。',
'transfers.title': '传输',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/platform.js b/packages/meshbay-hub/src/meshbay_hub/static/platform.js
index cfa4d87..a3fb80b 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/platform.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/platform.js
@@ -39,6 +39,17 @@ export function hubBase() {
return bridge ? (bridge.hubBase() || '') : '';
}
+/**
+ * The hub's own address, as somebody else would type it — for a link that is
+ * sent to another person, where "same origin" means nothing. In a browser the
+ * hub served this page, so it is the page's origin; in the app it is the
+ * configured hub. Decided here and nowhere else, like `hubBase()`.
+ */
+export function hubOrigin() {
+ const base = hubBase();
+ return (base ? new URL(base).origin : window.location.origin);
+}
+
/** Native-only capabilities. A browser renders none of what these gate. */
export const capabilities = {
// Install, configure and drive a node running on this machine.
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/transport.js b/packages/meshbay-hub/src/meshbay_hub/static/transport.js
index c43422f..f6adaac 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/transport.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/transport.js
@@ -123,7 +123,7 @@ const ADMIN_OP_TYPES = new Set([
'root_add', 'root_remove', 'root_update', 'root_eject', 'root_plug',
'app_directories', 'chat_directory', 'chat_link_preview', 'chat_epoch',
'search_listed', 'member_unpin', 'gek_rotate', 'group_attach',
- 'group_detach', 'invite_create',
+ 'group_detach', 'invite_create', 'invite_link_create', 'invite_cancel',
]);
// ── Diagnostic trace (opt-in, off by default) ───────────────────────────────
@@ -614,13 +614,14 @@ class MeshBayTransport {
}
async connect(nodeId, jwtToken, groupId, gekRaw, sessionKeys, bundleKey, username,
- userId, joinCode, recoveryKey) {
+ userId, joinCode, recoveryKey, joinNodePk) {
// Remembered for _reconnectLoop, which calls connect() again with these
// same values (plus a freshly-fetched token and the identity connect()
// itself settles on below) after the WebRTC connection is declared
// "failed" — see the pc.onconnectionstatechange handler further down.
this._connectArgs = {
nodeId, groupId, gekRaw, bundleKey, username, userId, joinCode, recoveryKey,
+ joinNodePk,
};
this._lastToken = jwtToken;
// The constructor sets this once from whatever token the caller had at
@@ -984,7 +985,16 @@ class MeshBayTransport {
// This is the normal path for anyone who joined after the invite redesign —
// no bundle is pre-stored for members any more. A code is needed only the
// first time this node sees this account.
- if (!gekRaw && this._sessionKeys && userId) {
+ // A code from an invitation link goes to the node the link names and to
+ // no other, and only once that node has proved the key in its challenge
+ // (docs/MESHBAY_DESIGN.md §3.4). Otherwise nothing is sent at all — not
+ // even a join without the code, which this node would answer by asking
+ // for one.
+ const linkRefusal = _linkJoinRefusal(joinNodePk, joinCode, this.nodePk,
+ this.nodePkProved);
+ if (linkRefusal) {
+ this._joinError = linkRefusal;
+ } else if (!gekRaw && this._sessionKeys && userId) {
try {
gekRaw = await this.joinGroup(userId, groupId, joinCode);
} catch (e) {
@@ -1240,7 +1250,8 @@ class MeshBayTransport {
try {
ack = await this.connect(args.nodeId, token, args.groupId, args.gekRaw,
this._sessionKeys, args.bundleKey, args.username,
- args.userId, args.joinCode);
+ args.userId, args.joinCode, undefined,
+ args.joinNodePk);
} finally {
this._inReconnectAttempt = false;
}
@@ -2627,6 +2638,34 @@ class MeshBayTransport {
}
/**
+ * A code bound to no account, for an invitation link (MNP 3.4). Signed like
+ * any invitation, and the subject the operator signs is the outcome: a link
+ * into this group, `link:<group>`, and nothing else.
+ */
+ async createLinkInvite(groupId, signFn) {
+ const msg = await this._sendAndWait({
+ type: 'invite_link_create', v: '0.1', group_id: groupId,
+ });
+ if (msg.type === 'error') throw new Error(msg.detail);
+ if (msg.type === 'admin_challenge') {
+ return this._authorizeAdminOp(msg, 'invite_link_create', `link:${groupId}`, signFn);
+ }
+ return msg;
+ }
+
+ /** Take back an unredeemed invitation link, by the handle it was issued with. */
+ async cancelLinkInvite(inviteId, signFn) {
+ const msg = await this._sendAndWait({
+ type: 'invite_cancel', v: '0.1', invite_id: inviteId,
+ });
+ if (msg.type === 'error') throw new Error(msg.detail);
+ if (msg.type === 'admin_challenge') {
+ return this._authorizeAdminOp(msg, 'invite_cancel', inviteId, signFn);
+ }
+ return msg;
+ }
+
+ /**
* Ask the node to recognise us and hand over the group key.
*
* Sent when we hold no GEK for a group. `code` is needed only the first time
@@ -3982,6 +4021,29 @@ function _hex(bytes) {
}
/**
+ * Why a code from an invitation link must not go to this node, or null.
+ *
+ * `link_other_node` is the caller's cue to try the next node the hub listed,
+ * as for `not_hosted`: the link names one node, and this is not it. An older
+ * node that cannot prove its key early is refused rather than trusted — it
+ * cannot have issued a link code anyway.
+ */
+function _linkJoinRefusal(joinNodePk, joinCode, nodePk, nodePkProved) {
+ if (!joinNodePk || !joinCode) return null;
+ if (nodePk !== joinNodePk) {
+ const err = new Error('This invitation was issued by another machine hosting this group.');
+ err.reason = 'link_other_node';
+ return err;
+ }
+ if (!nodePkProved) {
+ const err = new Error('This node is too old to accept invitation links.');
+ err.reason = 'link_node_unproved';
+ return err;
+ }
+ return null;
+}
+
+/**
* Whether `handshake_challenge` proves the key it announces (MNP 3.4).
*
* True when it carries a signature that verifies over this connection, false
diff --git a/packages/meshbay-hub/tests/harness/invite_link_probe.py b/packages/meshbay-hub/tests/harness/invite_link_probe.py
new file mode 100644
index 0000000..97ee893
--- /dev/null
+++ b/packages/meshbay-hub/tests/harness/invite_link_probe.py
@@ -0,0 +1,181 @@
+#!/usr/bin/env python3
+"""
+An invitation link, opened in the real application.
+
+`test_invite_link_client.py` runs the link's functions one by one. What only the
+running application can show is how they meet the router, the sign-in state and
+the hub calls: that the code is out of the address before anything routes on
+it, that a signed-out reader is sent to register with the invitation kept, and
+that a signed-in reader is shown the invitation, joins with one click and lands
+on the group — with the code never in a request to the hub.
+
+Loads the shipped `app.js` in a real browser with `fetch` stubbed, twice:
+
+ signed_out — a link, no session
+ signed_in — the same link, a session; then the Join button is clicked
+
+ invite_link_probe.py
+
+Prints JSON: one object per case.
+"""
+
+import http.server
+import json
+import socketserver
+import subprocess
+import sys
+import tempfile
+import threading
+import time
+from pathlib import Path
+
+STATIC = Path(__file__).resolve().parents[2] / "src" / "meshbay_hub" / "static"
+PORT = 8771
+RECORDS = []
+socketserver.TCPServer.allow_reuse_address = True
+
+GROUP = "0f8fad5b-d9cb-469f-a165-70867728950e"
+TICKET = "AbCdEfGhIjKlMnOpQr-_12"
+NODE = "A" * 43
+CODE = "K7P2-9WQX"
+LINK = f"#/invite?v=1&g={GROUP}&t={TICKET}&n={NODE}&c={CODE}"
+
+PAGE = r"""<!doctype html><html><head><meta charset=utf-8></head><body>
+<div id="app"></div>
+<script type="module">
+const CASE = new URLSearchParams(location.search).get('case');
+const realFetch = window.fetch.bind(window);
+const post = (o) => realFetch('/log', { method: 'POST', body: JSON.stringify(o) });
+const calls = [];
+const json = (body, status = 200) => ({
+ ok: status < 400, status, statusText: '', headers: new Headers(),
+ json: async () => body, text: async () => JSON.stringify(body),
+});
+window.fetch = async (url, init = {}) => {
+ const u = String(url);
+ calls.push({ url: u, body: init.body ? String(init.body) : '' });
+ if (u.includes('/v1/users/me/preferences')) return json({});
+ if (u.includes('/v1/users/me')) return json({ user_id: 'u-1', role: 'user' });
+ if (u.includes('/v1/groups/mine')) return json({ groups: [] });
+ if (u.includes('/v1/invite-links/preview')) return json({
+ group_id: '__GROUP__', group_name: 'Some Group', inviter: 'the-owner',
+ expires_at: '2099-01-01T00:00:00+00:00', already_member: false });
+ if (u.includes('/v1/invite-links/redeem')) return json({
+ group_id: '__GROUP__', group_name: 'Some Group' });
+ if (u.includes('/nodes')) return json({ nodes: [] });
+ return json({});
+};
+if (CASE === 'signed_in') {
+ localStorage.setItem('mb_auth', JSON.stringify({
+ username: 'invitee-account', userId: 'u-1', token: 'tok', refreshToken: 'ref',
+ role: 'user' }));
+} else {
+ localStorage.removeItem('mb_auth');
+}
+sessionStorage.clear();
+history.replaceState(null, '', '/?case=' + CASE + '__LINK__');
+
+const wait = (ms) => new Promise((r) => setTimeout(r, ms));
+const text = () => document.getElementById('app').innerText;
+(async () => {
+ const out = { case: CASE };
+ try {
+ await import('/app.js');
+ await wait(1500);
+ out.hash_after_load = location.hash;
+ out.pending = JSON.parse(sessionStorage.getItem('mb.pendingInvite') || 'null');
+ out.text_after_load = text().slice(0, 600);
+ if (CASE === 'signed_out') {
+ const reg = [...document.querySelectorAll('a')]
+ .find((a) => a.getAttribute('href') === '#/register');
+ out.register_link = Boolean(reg);
+ if (reg) { reg.click(); await wait(500); }
+ out.hash_after_click = location.hash;
+ out.pending_after_click = Boolean(sessionStorage.getItem('mb.pendingInvite'));
+ } else {
+ // By its role, not its label: the browser's language picks the label.
+ const join = document.querySelector('.login-card button.btn-primary');
+ out.join_button = Boolean(join);
+ if (join) { join.click(); await wait(1500); }
+ out.hash_after_click = location.hash;
+ out.redeem_bodies = calls.filter((c) => c.url.includes('/redeem')).map((c) => c.body);
+ }
+ out.code_in_a_hub_request = calls.some(
+ (c) => c.url.includes('__CODE__') || c.body.includes('__CODE__'));
+ out.hub_calls = calls.map((c) => c.url.replace(/^https?:\/\/[^/]+/, ''));
+ } catch (e) {
+ out.error = String(e && e.stack || e);
+ }
+ post(out);
+})();
+</script></body></html>
+""".replace("__GROUP__", GROUP).replace("__LINK__", LINK).replace("__CODE__", CODE)
+
+
+class H(http.server.SimpleHTTPRequestHandler):
+ def log_message(self, *a):
+ pass
+
+ def do_POST(self):
+ length = int(self.headers.get("Content-Length") or 0)
+ body = self.rfile.read(length)
+ if self.path == "/log":
+ RECORDS.append(json.loads(body.decode()))
+ self.send_response(204)
+ self.end_headers()
+
+ def _send(self, body: bytes, ctype: str) -> None:
+ self.send_response(200)
+ self.send_header("Content-Type", ctype)
+ self.send_header("Content-Length", str(len(body)))
+ self.end_headers()
+ self.wfile.write(body)
+
+ def do_GET(self):
+ path = self.path.split("?")[0]
+ if path == "/":
+ self._send(PAGE.encode(), "text/html; charset=utf-8")
+ return
+ asset = (STATIC / path.lstrip("/")).resolve()
+ if not str(asset).startswith(str(STATIC)) or not asset.is_file():
+ self.send_response(404)
+ self.end_headers()
+ return
+ ctype = "text/javascript" if asset.suffix in (".js", ".mjs") else (
+ "application/wasm" if asset.suffix == ".wasm" else "application/octet-stream")
+ self._send(asset.read_bytes(), ctype)
+
+
+def _run(case: str) -> dict | None:
+ before = len(RECORDS)
+ with tempfile.TemporaryDirectory(ignore_cleanup_errors=True) as profile:
+ proc = subprocess.Popen(
+ ["google-chrome", "--headless=new", "--disable-gpu", "--no-sandbox",
+ f"--user-data-dir={profile}", f"http://127.0.0.1:{PORT}/?case={case}"],
+ stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
+ for _ in range(300):
+ if len(RECORDS) > before:
+ break
+ time.sleep(0.1)
+ proc.terminate()
+ try:
+ proc.wait(timeout=10)
+ except subprocess.TimeoutExpired:
+ proc.kill()
+ proc.wait()
+ return RECORDS[before] if len(RECORDS) > before else None
+
+
+def main() -> int:
+ with socketserver.TCPServer(("127.0.0.1", PORT), H) as srv:
+ threading.Thread(target=srv.serve_forever, daemon=True).start()
+ results = [_run("signed_out"), _run("signed_in")]
+ if not all(results):
+ print(json.dumps({"error": "no measurement", "got": results}), file=sys.stderr)
+ return 1
+ print(json.dumps(results, indent=1))
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/packages/meshbay-hub/tests/test_invite_link_client.py b/packages/meshbay-hub/tests/test_invite_link_client.py
new file mode 100644
index 0000000..c5c0101
--- /dev/null
+++ b/packages/meshbay-hub/tests/test_invite_link_client.py
@@ -0,0 +1,194 @@
+"""
+The browser's half of an invitation link (docs/MESHBAY_DESIGN.md §3.4).
+
+Three properties, each run against the shipped code rather than restated:
+
+- **one shape.** The hub writes a link when it mails one (`invite_url`), the
+ page writes one when it shows one (`buildInviteLink`), and the page reads both
+ (`parseInvite`). A disagreement is a link that opens on nothing.
+- **the code leaves the address at once, and the tab keeps it.** Run in node
+ against a stand-in `window`: `captureFromLocation` rewrites the address and
+ stores what it read, and a malformed link is cleaned out without being kept.
+- **the code goes to the node the link names, and to no other.** The transport's
+ `_linkJoinRefusal` is what stops it; this runs it.
+
+The rest are read from the source, which is the evidence there is for them: the
+hub is never handed the code except when the inviter ticked the mail box, the
+capture is the first thing `app.js` loads, and signing out forgets the
+invitation.
+"""
+
+import base64
+import json
+import re
+import shutil
+import subprocess
+from pathlib import Path
+
+import pytest
+from meshbay_hub import mail as mail_mod
+from meshbay_hub.api import invite_links
+
+STATIC = Path(__file__).resolve().parents[1] / "src" / "meshbay_hub" / "static"
+LINK_JS = STATIC / "invite-link.js"
+
+pytestmark = pytest.mark.skipif(shutil.which("node") is None, reason="node is not available")
+
+GROUP = "0f8fad5b-d9cb-469f-a165-70867728950e"
+TICKET = "AbCdEfGhIjKlMnOpQr-_12"
+NODE_PK_STD = base64.b64encode(bytes(range(32))).decode() # has '+', '/', '='
+CODE = "K7P2-9WQX"
+
+
+def _module_body() -> str:
+ """invite-link.js with its import, its exports and its load-time capture
+ removed — the functions as shipped, runnable against a stand-in window."""
+ src = LINK_JS.read_text(encoding="utf-8")
+ src = re.sub(r"^import .*?;\n", "", src, flags=re.M)
+ src = src.replace("export function", "function")
+ tail = "\ncaptureFromLocation();\nwindow.addEventListener('hashchange', captureFromLocation);\n"
+ assert src.endswith(tail), "invite-link.js no longer ends with its load-time capture"
+ return src[: -len(tail)]
+
+
+def _run(tmp_path, script: str):
+ harness = tmp_path / "h.js"
+ harness.write_text(script)
+ out = subprocess.run(["node", str(harness)], capture_output=True, text=True, timeout=60)
+ assert out.returncode == 0, out.stderr
+ return json.loads(out.stdout)
+
+
+_WINDOW = r"""
+const store = new Map();
+globalThis.sessionStorage = {
+ getItem: (k) => (store.has(k) ? store.get(k) : null),
+ setItem: (k, v) => store.set(k, String(v)),
+ removeItem: (k) => store.delete(k),
+};
+const replaced = [];
+globalThis.window = {
+ location: { hash: '', pathname: '/', search: '' },
+ history: { replaceState: (_s, _t, url) => replaced.push(url) },
+ addEventListener() {},
+};
+const platform = { hubOrigin: () => 'https://hub.example' };
+"""
+
+
+def test_one_shape_between_the_hub_and_the_page(tmp_path, monkeypatch):
+ monkeypatch.setattr(mail_mod, "_hub_url", "https://hub.example")
+ n = NODE_PK_STD.replace("+", "-").replace("/", "_").rstrip("=")
+ from_hub = invite_links.invite_url(GROUP, TICKET, n, CODE)
+ got = _run(tmp_path, _WINDOW + _module_body() + f"""
+ const fields = {{ g: '{GROUP}', t: '{TICKET}',
+ n: nodePkForLink('{NODE_PK_STD}'), c: '{CODE}' }};
+ process.stdout.write(JSON.stringify({{
+ parsed: parseInvite({json.dumps(from_hub)}),
+ built: buildInviteLink('https://hub.example', fields),
+ back: nodePkFromLink(fields.n),
+ lower: parseInvite({json.dumps(from_hub.replace(CODE, CODE.lower()))}),
+ }}));
+ """)
+ assert got["parsed"] == {"g": GROUP, "t": TICKET, "n": n, "c": CODE}
+ assert got["built"] == from_hub
+ assert got["back"] == NODE_PK_STD, "the key the transport compares must come back exact"
+ assert got["lower"]["c"] == CODE
+
+
+@pytest.mark.parametrize("tamper", [
+ lambda u: u.replace("v=1", "v=2"),
+ lambda u: u.replace(CODE, "K7P2-9WQ"),
+ lambda u: u.replace(CODE, "K7P2-9WQX<script>"),
+ lambda u: u.replace(TICKET, TICKET + "x"),
+ lambda u: u.replace(GROUP, "../../admin"),
+ lambda u: u.replace("&n=", "&m="),
+])
+def test_anything_but_that_shape_is_not_an_invitation(tmp_path, tamper):
+ good = f"https://hub.example/#/invite?v=1&g={GROUP}&t={TICKET}&n={'A' * 43}&c={CODE}"
+ got = _run(tmp_path, _WINDOW + _module_body()
+ + f"process.stdout.write(JSON.stringify(parseInvite({json.dumps(tamper(good))})));")
+ assert got is None
+
+
+def test_the_code_leaves_the_address_and_stays_in_the_tab(tmp_path):
+ good = f"#/invite?v=1&g={GROUP}&t={TICKET}&n={'A' * 43}&c={CODE}"
+ got = _run(tmp_path, _WINDOW + _module_body() + f"""
+ window.location.hash = {json.dumps(good)};
+ const first = captureFromLocation();
+ const kept = loadPending();
+ window.location.hash = '#/invite?v=1&g=nope';
+ captureFromLocation();
+ const afterBad = loadPending();
+ clearPending();
+ process.stdout.write(JSON.stringify({{
+ first: Boolean(first), replaced, kept, afterBad, cleared: loadPending(),
+ }}));
+ """)
+ assert got["first"] is True
+ assert got["replaced"] == ["/#/invite", "/#/invite"], (
+ "both the good link and the malformed one must be taken out of the address")
+ assert got["kept"]["c"] == CODE and got["kept"]["g"] == GROUP
+ assert got["afterBad"]["t"] == TICKET, "a malformed link must not replace a good one"
+ assert got["cleared"] is None
+
+
+def test_a_link_code_goes_to_the_node_the_link_names_and_no_other(tmp_path):
+ src = (STATIC / "transport.js").read_text(encoding="utf-8")
+ fn = re.search(r"^function _linkJoinRefusal\(.*?^\}", src, re.M | re.S)
+ assert fn, "transport.js no longer has _linkJoinRefusal"
+ got = _run(tmp_path, fn.group(0) + """
+ const r = (...a) => { const e = _linkJoinRefusal(...a); return e ? e.reason : null; };
+ process.stdout.write(JSON.stringify([
+ r('KEY', 'K7P2-9WQX', 'KEY', true),
+ r('KEY', 'K7P2-9WQX', 'OTHER', true),
+ r('KEY', 'K7P2-9WQX', 'KEY', false),
+ r(undefined, 'K7P2-9WQX', 'OTHER', false),
+ r('KEY', null, 'OTHER', false),
+ ]));
+ """)
+ assert got == [None, "link_other_node", "link_node_unproved", None, None]
+
+
+# ── Read from the source ─────────────────────────────────────────────────────
+
+def _code(name: str) -> str:
+ """The file without its comments — prose about the code is not the code."""
+ src = (STATIC / name).read_text(encoding="utf-8")
+ src = re.sub(r"/\*.*?\*/", "", src, flags=re.S)
+ return "\n".join(line for line in src.splitlines()
+ if not line.strip().startswith("//"))
+
+
+def test_the_capture_is_the_first_thing_the_app_loads():
+ imports = re.findall(r"^import .*? from '([^']+)';", _code("app.js"), re.M | re.S)
+ assert imports and imports[0] == "./invite-link.js"
+
+
+def test_the_invitation_page_never_sends_the_code_to_the_hub():
+ page = _code("invite-page.js")
+ assert "inv.t" in page, "the check below is looking at the wrong names"
+ assert not re.search(r"\binv\.c\b|\binv\[.c.\]", page), (
+ "invite-page.js reads the code; only the ticket is its to send")
+
+
+def test_the_members_tab_sends_the_code_only_for_the_mail():
+ settings = _code("group-settings.js")
+ sends = [m.start() for m in re.finditer(r"code: node\.code", settings)]
+ assert len(sends) == 1
+ before = settings[settings.rfind("\n", 0, sends[0] - 200):sends[0]]
+ assert "inviteByEmail ?" in before, "the code reaches the hub only when the box asks"
+
+
+def test_signing_out_forgets_the_invitation():
+ app = _code("app.js")
+ logout = app[app.index("logout: () => {"):]
+ logout = logout[:logout.index("},")]
+ assert "clearPending()" in logout
+
+
+def test_the_group_page_moves_on_from_another_host():
+ page = _code("group-page.js")
+ loop = page[page.index("for (const n of nodesData.nodes)"):]
+ loop = loop[:loop.index("if (!transport)")]
+ assert "link_other_node" in loop
diff --git a/packages/meshbay-hub/tests/test_invite_link_flow.py b/packages/meshbay-hub/tests/test_invite_link_flow.py
new file mode 100644
index 0000000..42f461c
--- /dev/null
+++ b/packages/meshbay-hub/tests/test_invite_link_flow.py
@@ -0,0 +1,61 @@
+"""
+An invitation link, opened in the real application (harness/invite_link_probe.py).
+
+The functions behind a link are tested one by one in
+`test_invite_link_client.py`; this is where they meet the router, the sign-in
+state and the hub. Two readers: one with no account, who must be sent to
+register with the invitation kept, and one signed in, who must be shown it,
+join with one click and land on the group. For both, the code never appears in
+a request to the hub — it is the node's, and the hub is only handed the ticket.
+"""
+
+import json
+import shutil
+import subprocess
+import sys
+from pathlib import Path
+
+import pytest
+
+HARNESS = Path(__file__).parent / "harness" / "invite_link_probe.py"
+
+
+@pytest.fixture(scope="module")
+def cases():
+ if shutil.which("google-chrome") is None:
+ pytest.skip("Chrome is not available")
+ proc = subprocess.run([sys.executable, str(HARNESS)],
+ capture_output=True, text=True, timeout=180)
+ assert proc.returncode == 0, f"probe failed: {proc.stdout}{proc.stderr}"
+ out = {c["case"]: c for c in json.loads(proc.stdout)}
+ for c in out.values():
+ assert "error" not in c, c["error"]
+ return out
+
+
+@pytest.mark.parametrize("case", ["signed_out", "signed_in"])
+def test_the_code_is_out_of_the_address_and_kept_in_the_tab(cases, case):
+ c = cases[case]
+ assert c["hash_after_load"] == "#/invite"
+ assert c["pending"] and c["pending"]["c"] == "K7P2-9WQX"
+
+
+@pytest.mark.parametrize("case", ["signed_out", "signed_in"])
+def test_the_code_never_reaches_the_hub(cases, case):
+ assert cases[case]["code_in_a_hub_request"] is False
+
+
+def test_a_reader_with_no_account_is_sent_to_register_with_the_invitation_kept(cases):
+ c = cases["signed_out"]
+ assert c["register_link"] and c["hash_after_click"] == "#/register"
+ assert c["pending_after_click"] is True
+ assert not any("/invite-links/" in u for u in c["hub_calls"]), (
+ "nothing about the invitation is asked of the hub before sign-in")
+
+
+def test_a_signed_in_reader_joins_with_one_click_and_lands_on_the_group(cases):
+ c = cases["signed_in"]
+ assert "the-owner" in c["text_after_load"] and "Some Group" in c["text_after_load"]
+ assert c["join_button"]
+ assert c["redeem_bodies"] == ['{"ticket":"AbCdEfGhIjKlMnOpQr-_12"}']
+ assert c["hash_after_click"] == "#/group/0f8fad5b-d9cb-469f-a165-70867728950e"
diff --git a/packages/meshbay-hub/tests/test_signed_in_never_sees_the_login_form.py b/packages/meshbay-hub/tests/test_signed_in_never_sees_the_login_form.py
index 547f7d4..0343ee2 100644
--- a/packages/meshbay-hub/tests/test_signed_in_never_sees_the_login_form.py
+++ b/packages/meshbay-hub/tests/test_signed_in_never_sees_the_login_form.py
@@ -56,5 +56,9 @@ def test_a_signed_in_person_on_the_form_is_sent_home_without_a_history_entry(app
assert "const onAuthForm = route === '/login' || route === '/register';" in app_body
effect = app_body[app_body.index("if (user && onAuthForm)"):]
effect = effect[:effect.index("\n")]
- assert "window.location.replace('#/')" in effect, (
+ # `replace`, whichever the destination: home, or the invitation that sent
+ # them to sign in (invite-link.js). Assigning the hash would leave the form
+ # one Back away.
+ assert "window.location.replace(" in effect and "'#/'" in effect, (
"Back must not lead to the form again")
+ assert "window.location.hash" not in effect