@statewalker/httpeers-join 0.1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,429 @@
1
+ /**
2
+ * The Invite panel: an admin makes an invitation -- member or admin, one
3
+ * hour, one day or seven days -- and hands it over as a link, a QR code, the
4
+ * phone's share sheet or the clipboard. Pending invitations are listed with
5
+ * their role and how long they have left.
6
+ *
7
+ * SHOWN ONLY TO AN ADMIN, AND THE HUB SAYS WHO THAT IS. The panel stays
8
+ * hidden until the page is live, the mesh view lists this member as `admin`
9
+ * (`adminHint`: the hint that decides whether to ask), and
10
+ * `GET /hub/api/roles` succeeded for it (`./invite.ts`). A 403 (a member), a 404 (a hub with no admin API)
11
+ * or a 401 hides it for the life of that link; a network error or a 5xx is
12
+ * tried again a few times, because the first call can race the link coming
13
+ * up. It is a convenience, never a gate: the hub decides every call.
14
+ *
15
+ * WHAT IS ON SCREEN IS A SECRET. The link, and the QR code of it, redeem
16
+ * once for whoever holds them. They are never logged, the pending list shows
17
+ * no invitation ids (a bare id redeems too), and all of it is cleared when the
18
+ * link to the hub goes away.
19
+ */
20
+
21
+ import {
22
+ ADMIN_INVITATION_WARNING,
23
+ adminHint,
24
+ type CreatedInvitation,
25
+ createHubAdminClient,
26
+ DEFAULT_INVITE_EXPIRY,
27
+ DEFAULT_INVITE_ROLE,
28
+ describeExpiry,
29
+ expiryMs,
30
+ type HubAdminClient,
31
+ type HubTarget,
32
+ hubAdminBase,
33
+ INVITE_EXPIRIES,
34
+ INVITE_ROLES,
35
+ type InviteRole,
36
+ isFinalRefusal,
37
+ offeredRoles,
38
+ type PendingInvitation,
39
+ } from "./invite.js";
40
+
41
+ export interface InvitePanelOptions {
42
+ /** How the hub is called. Defaults to the page's `fetch`, which the ServiceWorker edge routes over the mesh. */
43
+ fetch?: typeof fetch;
44
+ /** The share sheet. Defaults to `navigator.share` where it exists; `null` hides Share. */
45
+ share?: ((data: ShareData) => Promise<void>) | null;
46
+ /** Copies text. Defaults to `navigator.clipboard.writeText`, falling back to selecting the link and `execCommand("copy")`. */
47
+ copy?: (text: string) => Promise<void>;
48
+ /** The QR code of a link, as SVG markup. Defaults to `qrSvg` from `@statewalker/httpeers-qr`, loaded on first use. */
49
+ qrSvg?: (text: string) => string | Promise<string>;
50
+ /** The clock expiries are shown against. */
51
+ now?: () => number;
52
+ /** How long to wait before asking for the roles again after a non-final failure. Default 2 s. */
53
+ retryDelayMs?: number;
54
+ /** How many times the roles are asked for, in all. Default 3. */
55
+ roleAttempts?: number;
56
+ }
57
+
58
+ export interface InvitePanel {
59
+ readonly element: HTMLElement;
60
+ /** Point the panel at a live link's hub, or at nothing (`null`) when the page is not live. Idempotent. */
61
+ setTarget(target: HubTarget | null): void;
62
+ destroy(): void;
63
+ }
64
+
65
+ type Make = <K extends keyof HTMLElementTagNameMap>(
66
+ tag: K,
67
+ className: string,
68
+ text?: string,
69
+ ) => HTMLElementTagNameMap[K];
70
+
71
+ const pageFetch: typeof fetch = (input, init) => globalThis.fetch(input, init);
72
+
73
+ async function defaultQrSvg(text: string): Promise<string> {
74
+ const { qrSvg } = await import("@statewalker/httpeers-qr");
75
+ return qrSvg(text);
76
+ }
77
+
78
+ function defaultShare(doc: Document): ((data: ShareData) => Promise<void>) | null {
79
+ const nav = doc.defaultView?.navigator;
80
+ if (nav == null || typeof nav.share !== "function") return null;
81
+ return (data) => nav.share(data);
82
+ }
83
+
84
+ /**
85
+ * Build the panel (hidden) with `make`, the widget's element helper. `n` is
86
+ * the widget instance number, for unique ids.
87
+ */
88
+ export function createInvitePanel(
89
+ doc: Document,
90
+ make: Make,
91
+ n: number,
92
+ options: InvitePanelOptions = {},
93
+ ): InvitePanel {
94
+ const fetchImpl = options.fetch ?? pageFetch;
95
+ const share = options.share === undefined ? defaultShare(doc) : options.share;
96
+ const toQrSvg = options.qrSvg ?? defaultQrSvg;
97
+ const now = options.now ?? Date.now;
98
+ const retryDelayMs = options.retryDelayMs ?? 2_000;
99
+ const roleAttempts = Math.max(1, options.roleAttempts ?? 3);
100
+
101
+ const button = (className: string, label: string): HTMLButtonElement => {
102
+ const b = make("button", `hp-join-button ${className}`, label);
103
+ b.type = "button";
104
+ return b;
105
+ };
106
+
107
+ // --- structure ----------------------------------------------------------
108
+ const root = make("details", "hp-invite");
109
+ root.hidden = true;
110
+ const summary = make("summary", "hp-invite-toggle", "Invite someone");
111
+ const body = make("div", "hp-invite-body");
112
+
113
+ const roleGroup = make("fieldset", "hp-invite-roles");
114
+ const roleLegend = make("legend", "hp-invite-legend", "Invite as");
115
+ roleGroup.append(roleLegend);
116
+ const roleInputs = new Map<InviteRole, { input: HTMLInputElement; label: HTMLLabelElement }>();
117
+ for (const role of INVITE_ROLES) {
118
+ const label = make("label", `hp-invite-role hp-invite-role-${role}`);
119
+ const input = make("input", "hp-invite-role-input");
120
+ input.type = "radio";
121
+ input.name = `hp-invite-role-${n}`;
122
+ input.value = role;
123
+ input.checked = role === DEFAULT_INVITE_ROLE;
124
+ label.append(input, ` ${role === "admin" ? "Admin" : "Member"}`);
125
+ roleGroup.append(label);
126
+ roleInputs.set(role, { input, label });
127
+ }
128
+ const warning = make("p", "hp-invite-warning", ADMIN_INVITATION_WARNING);
129
+ warning.setAttribute("role", "note");
130
+
131
+ const expiry = make("select", "hp-invite-expiry");
132
+ expiry.id = `hp-invite-expiry-${n}`;
133
+ for (const e of INVITE_EXPIRIES) {
134
+ const option = make("option", "", `Expires in ${e.label}`);
135
+ option.value = e.id;
136
+ option.selected = e.id === DEFAULT_INVITE_EXPIRY;
137
+ expiry.append(option);
138
+ }
139
+ const expiryLabel = make("label", "hp-join-visually-hidden", "Expiry");
140
+ expiryLabel.htmlFor = expiry.id;
141
+
142
+ const create = button("hp-join-primary hp-invite-create", "Create invitation");
143
+ const createRow = make("div", "hp-join-actions");
144
+ createRow.append(expiryLabel, expiry, create);
145
+
146
+ const error = make("p", "hp-join-error hp-invite-error");
147
+ error.setAttribute("role", "alert");
148
+ error.hidden = true;
149
+
150
+ const result = make("div", "hp-invite-result");
151
+ result.hidden = true;
152
+ const resultNote = make("p", "hp-invite-note");
153
+ const qr = make("div", "hp-invite-qr");
154
+ const link = make("input", "hp-join-input hp-invite-link");
155
+ link.id = `hp-invite-link-${n}`;
156
+ link.type = "text";
157
+ link.readOnly = true;
158
+ link.autocomplete = "off";
159
+ link.spellcheck = false;
160
+ const linkLabel = make("label", "hp-join-visually-hidden", "Invitation link");
161
+ linkLabel.htmlFor = link.id;
162
+ const shareButton = button("hp-join-primary hp-invite-share", "Share");
163
+ const copyButton = button("hp-invite-copy", "Copy link");
164
+ const copied = make("span", "hp-invite-copied");
165
+ copied.setAttribute("role", "status");
166
+ const resultActions = make("div", "hp-join-actions");
167
+ resultActions.append(shareButton, copyButton, copied);
168
+ result.append(resultNote, qr, linkLabel, link, resultActions);
169
+
170
+ const pendingBox = make("div", "hp-invite-pending");
171
+ const pendingHead = make("div", "hp-invite-pending-head");
172
+ const pendingTitle = make("p", "hp-invite-pending-title", "Pending invitations");
173
+ const refresh = button("hp-invite-refresh", "Refresh");
174
+ pendingHead.append(pendingTitle, refresh);
175
+ const pendingList = make("ul", "hp-invite-pending-list");
176
+ const pendingEmpty = make("p", "hp-invite-pending-empty", "None.");
177
+ pendingBox.append(pendingHead, pendingList, pendingEmpty);
178
+
179
+ body.append(roleGroup, warning, createRow, error, result, pendingBox);
180
+ root.append(summary, body);
181
+
182
+ // --- behaviour ----------------------------------------------------------
183
+ let apiBase: string | null = null;
184
+ let client: HubAdminClient | null = null;
185
+ /** Bumped on every target change and on destroy: an answer for an older link writes nothing. */
186
+ let generation = 0;
187
+ let destroyed = false;
188
+ let creating = false;
189
+ let loadingPending = false;
190
+ let copiedTimer: ReturnType<typeof setTimeout> | undefined;
191
+ let current: CreatedInvitation | null = null;
192
+
193
+ const showError = (text: string | null): void => {
194
+ error.textContent = text ?? "";
195
+ error.hidden = text == null;
196
+ };
197
+ const messageOf = (err: unknown): string => (err instanceof Error ? err.message : String(err));
198
+
199
+ const selectedRole = (): InviteRole => {
200
+ for (const [role, { input }] of roleInputs) if (input.checked) return role;
201
+ return DEFAULT_INVITE_ROLE;
202
+ };
203
+
204
+ const renderRole = (): void => {
205
+ const role = selectedRole();
206
+ warning.hidden = role !== "admin";
207
+ root.dataset.role = role;
208
+ create.textContent = role === "admin" ? "Create admin invitation" : "Create invitation";
209
+ create.classList.toggle("hp-invite-create-admin", role === "admin");
210
+ };
211
+
212
+ const clearResult = (): void => {
213
+ current = null;
214
+ result.hidden = true;
215
+ link.value = "";
216
+ qr.replaceChildren();
217
+ copied.textContent = "";
218
+ resultNote.textContent = "";
219
+ };
220
+
221
+ const renderPending = (items: PendingInvitation[]): void => {
222
+ const t = now();
223
+ const live = items.filter((i) => i.expiresAt > t).sort((a, b) => a.expiresAt - b.expiresAt);
224
+ pendingList.replaceChildren(
225
+ ...live.map((item) => {
226
+ const li = make("li", "hp-invite-pending-item");
227
+ const role = item.roles.join(", ") || "(no role)";
228
+ const roleTag = make(
229
+ "span",
230
+ `hp-invite-tag${item.roles.includes("admin") ? " hp-invite-tag-admin" : ""}`,
231
+ role,
232
+ );
233
+ const when = make("span", "hp-invite-when", `expires ${describeExpiry(item.expiresAt, t)}`);
234
+ when.title = new Date(item.expiresAt).toLocaleString();
235
+ li.append(roleTag, " ", when);
236
+ return li;
237
+ }),
238
+ );
239
+ pendingTitle.textContent = `Pending invitations (${live.length})`;
240
+ pendingEmpty.hidden = live.length > 0;
241
+ };
242
+
243
+ const loadPending = async (): Promise<void> => {
244
+ const c = client;
245
+ const gen = generation;
246
+ if (c == null || loadingPending) return;
247
+ loadingPending = true;
248
+ refresh.disabled = true;
249
+ try {
250
+ const items = await c.pending();
251
+ if (gen === generation && !destroyed) renderPending(items);
252
+ } catch (err) {
253
+ if (gen === generation && !destroyed) showError(messageOf(err));
254
+ } finally {
255
+ loadingPending = false;
256
+ refresh.disabled = false;
257
+ }
258
+ };
259
+
260
+ const showCreated = async (created: CreatedInvitation, role: InviteRole): Promise<void> => {
261
+ current = created;
262
+ link.value = created.link;
263
+ resultNote.textContent =
264
+ `Anyone with this link joins as ${role === "admin" ? "an ADMIN" : "a member"}, once, ` +
265
+ `until ${new Date(created.expiresAt).toLocaleString()}. Send it only to the person you mean.`;
266
+ shareButton.hidden = share == null;
267
+ copied.textContent = "";
268
+ result.hidden = false;
269
+ qr.replaceChildren();
270
+ const gen = generation;
271
+ try {
272
+ const svg = await toQrSvg(created.link);
273
+ if (gen !== generation || destroyed || current !== created) return;
274
+ // Our own encoder's markup, from a string it built: no page content goes in.
275
+ qr.innerHTML = svg;
276
+ } catch {
277
+ // No QR is not fatal: the link, Share and Copy still work.
278
+ }
279
+ };
280
+
281
+ const hide = (): void => {
282
+ root.hidden = true;
283
+ root.open = false;
284
+ clearResult();
285
+ showError(null);
286
+ pendingList.replaceChildren();
287
+ pendingEmpty.hidden = false;
288
+ pendingTitle.textContent = "Pending invitations";
289
+ };
290
+
291
+ const detect = async (gen: number, c: HubAdminClient): Promise<void> => {
292
+ for (let attempt = 1; attempt <= roleAttempts; attempt++) {
293
+ try {
294
+ const roles = await c.roles();
295
+ if (gen !== generation || destroyed) return;
296
+ const offered = offeredRoles(roles);
297
+ if (offered.length === 0) return; // an admin, but of a hub with neither role: nothing to offer
298
+ for (const [role, { input, label }] of roleInputs) {
299
+ label.hidden = !offered.includes(role);
300
+ input.disabled = !offered.includes(role);
301
+ }
302
+ if (!offered.includes(selectedRole())) {
303
+ const first = roleInputs.get(offered[0] as InviteRole);
304
+ if (first != null) first.input.checked = true;
305
+ }
306
+ renderRole();
307
+ root.hidden = false;
308
+ return;
309
+ } catch (err) {
310
+ if (gen !== generation || destroyed || isFinalRefusal(err)) return;
311
+ if (attempt < roleAttempts) {
312
+ await new Promise((resolve) => setTimeout(resolve, retryDelayMs));
313
+ if (gen !== generation || destroyed) return;
314
+ }
315
+ }
316
+ }
317
+ };
318
+
319
+ const setTarget = (target: HubTarget | null): void => {
320
+ if (destroyed) return;
321
+ let base = hubAdminBase(target);
322
+ const hint = base == null ? false : adminHint(target);
323
+ // No view yet: keep whatever was decided for this same hub, else wait for one.
324
+ if (hint === undefined && base === apiBase) return;
325
+ if (hint !== true) base = null;
326
+ if (base === apiBase) return;
327
+ apiBase = base;
328
+ generation++;
329
+ client = base == null ? null : createHubAdminClient(fetchImpl, base);
330
+ hide();
331
+ if (client != null) void detect(generation, client);
332
+ };
333
+
334
+ for (const { input } of roleInputs.values()) input.addEventListener("change", renderRole);
335
+
336
+ root.addEventListener("toggle", () => {
337
+ if (root.open) void loadPending();
338
+ });
339
+ refresh.addEventListener("click", () => {
340
+ showError(null);
341
+ void loadPending();
342
+ });
343
+
344
+ create.addEventListener("click", () => {
345
+ const c = client;
346
+ if (c == null || creating) return;
347
+ const gen = generation;
348
+ const role = selectedRole();
349
+ creating = true;
350
+ create.disabled = true;
351
+ showError(null);
352
+ clearResult();
353
+ c.create(role, expiryMs(expiry.value)).then(
354
+ async (created) => {
355
+ creating = false;
356
+ create.disabled = false;
357
+ if (gen !== generation || destroyed) return;
358
+ await showCreated(created, role);
359
+ void loadPending();
360
+ },
361
+ (err: unknown) => {
362
+ creating = false;
363
+ create.disabled = false;
364
+ if (gen === generation && !destroyed) showError(messageOf(err));
365
+ },
366
+ );
367
+ });
368
+
369
+ link.addEventListener("focus", () => link.select());
370
+
371
+ const flashCopied = (text: string): void => {
372
+ copied.textContent = text;
373
+ clearTimeout(copiedTimer);
374
+ copiedTimer = setTimeout(() => {
375
+ copied.textContent = "";
376
+ }, 2_500);
377
+ };
378
+
379
+ copyButton.addEventListener("click", () => {
380
+ const value = current?.link;
381
+ if (value == null) return;
382
+ const viaSelection = (): void => {
383
+ link.focus();
384
+ link.select();
385
+ const ok = doc.execCommand?.("copy") ?? false;
386
+ flashCopied(ok ? "Copied" : "Select the link and copy it");
387
+ };
388
+ const copy =
389
+ options.copy ??
390
+ (doc.defaultView?.navigator.clipboard?.writeText != null
391
+ ? (text: string) => (doc.defaultView as Window).navigator.clipboard.writeText(text)
392
+ : null);
393
+ if (copy == null) {
394
+ viaSelection();
395
+ return;
396
+ }
397
+ copy(value).then(
398
+ () => flashCopied("Copied"),
399
+ () => viaSelection(),
400
+ );
401
+ });
402
+
403
+ shareButton.addEventListener("click", () => {
404
+ const value = current?.link;
405
+ if (value == null || share == null) return;
406
+ share({ title: "Join the mesh", text: "An invitation to join the mesh:", url: value }).catch(
407
+ (err: unknown) => {
408
+ // Closing the share sheet is not an error.
409
+ if ((err as { name?: unknown } | null)?.name === "AbortError") return;
410
+ showError(`Could not share (${messageOf(err)}). Copy the link instead.`);
411
+ },
412
+ );
413
+ });
414
+
415
+ renderRole();
416
+
417
+ return {
418
+ element: root,
419
+ setTarget,
420
+ destroy() {
421
+ if (destroyed) return;
422
+ destroyed = true;
423
+ generation++;
424
+ clearTimeout(copiedTimer);
425
+ clearResult();
426
+ root.remove();
427
+ },
428
+ };
429
+ }
package/src/invite.ts ADDED
@@ -0,0 +1,250 @@
1
+ /**
2
+ * Inviting from the widget: the hub's admin REST API, called over the mesh.
3
+ *
4
+ * NO DOM HERE, so every decision the Invite panel makes -- who counts as an
5
+ * admin, which roles are offered, what a request body carries, how long an
6
+ * invitation lives -- is a plain function a test calls with a fake `fetch`.
7
+ * `./invite-panel.ts` is the DOM on top.
8
+ *
9
+ * THE SAME PATH A PAGE ALREADY USES TO REACH THE HUB. A page's ServiceWorker
10
+ * edge routes `<baseUrl><hubPeerId>/...` over the mesh with this member's
11
+ * token attached (llm-chat's `mesh.html` requests an LLM key exactly this
12
+ * way), and the hub serves its admin API at `/hub/api/*` behind
13
+ * `std:mesh.admin` (`apps/hub/src/rules.ts`, `CORE_POLICIES`). So:
14
+ *
15
+ * - `GET /hub/api/roles` -> role names; a 403 means "not an admin".
16
+ * - `POST /hub/api/invitations` -> `{ id, expiresAt, blob, link }`.
17
+ * - `GET /hub/api/invitations` -> the pending ones, `{ id, roles, expiresAt }[]`.
18
+ *
19
+ * THE ROLES CALL IS THE ADMIN CHECK. The mesh view's own roles are a hint
20
+ * only (`adminHint`: it decides whether to ask, so a member's console is not
21
+ * sprayed with 403s); the hub's policy is the answer, and asking the hub costs
22
+ * one request per link. A hub that is a browser tab (the demos) has no `/hub/api` at all
23
+ * and answers 404, which hides the panel the same way.
24
+ *
25
+ * AN INVITATION IS A BEARER SECRET. Nothing here logs one, and no error
26
+ * message quotes a response body that could carry one: the only body read on
27
+ * failure is the hub's `{ error }`, which never includes an invitation.
28
+ */
29
+
30
+ /** The roles the panel offers, in order. Anything else a hub knows (`hidden`, a module's own) is not offered. */
31
+ export const INVITE_ROLES = ["member", "admin"] as const;
32
+ export type InviteRole = (typeof INVITE_ROLES)[number];
33
+
34
+ export const DEFAULT_INVITE_ROLE: InviteRole = "member";
35
+
36
+ const HOUR = 60 * 60 * 1000;
37
+
38
+ /** The lifetimes offered. The hub's own default is one day, and so is the panel's. */
39
+ export const INVITE_EXPIRIES = [
40
+ { id: "1h", label: "1 hour", ttlMs: HOUR },
41
+ { id: "1d", label: "1 day", ttlMs: 24 * HOUR },
42
+ { id: "7d", label: "7 days", ttlMs: 7 * 24 * HOUR },
43
+ ] as const;
44
+ export type InviteExpiryId = (typeof INVITE_EXPIRIES)[number]["id"];
45
+
46
+ export const DEFAULT_INVITE_EXPIRY: InviteExpiryId = "1d";
47
+
48
+ /** What an admin invitation hands over, said before one is made. */
49
+ export const ADMIN_INVITATION_WARNING =
50
+ "An admin invitation grants full control of this mesh: whoever redeems it can invite " +
51
+ "others (admins too), revoke members, and use every admin service the hub offers. " +
52
+ "Send it only to someone you would trust with this device.";
53
+
54
+ /** A lifetime id to milliseconds. An unknown id is the default, never `undefined`: the request always says how long. */
55
+ export function expiryMs(id: string): number {
56
+ const found =
57
+ INVITE_EXPIRIES.find((e) => e.id === id) ??
58
+ INVITE_EXPIRIES.find((e) => e.id === DEFAULT_INVITE_EXPIRY);
59
+ return (found as (typeof INVITE_EXPIRIES)[number]).ttlMs;
60
+ }
61
+
62
+ /** The body of `POST /hub/api/invitations`: one role, and the lifetime. */
63
+ export function invitationRequestBody(
64
+ role: InviteRole,
65
+ ttlMs: number,
66
+ ): { roles: string[]; ttlMs: number } {
67
+ return { roles: [role], ttlMs };
68
+ }
69
+
70
+ /** The roles to offer, given what the hub says it knows: ours, in our order, and only those it knows. */
71
+ export function offeredRoles(hubRoles: readonly string[]): InviteRole[] {
72
+ return INVITE_ROLES.filter((role) => hubRoles.includes(role));
73
+ }
74
+
75
+ /** The part of a mesh view the admin hint reads, by shape. */
76
+ export interface MeshViewHint {
77
+ self: string;
78
+ members: ReadonlyArray<{ peerId: string; roles: readonly string[] }>;
79
+ }
80
+
81
+ /**
82
+ * The part of a member handle the panel needs. `baseUrl` is absent where no
83
+ * edge is mounted (Node). `meshView`, when present, is the hint that decides
84
+ * whether the hub is asked at all -- see `adminHint`.
85
+ */
86
+ export interface HubTarget {
87
+ hubPeerId: string;
88
+ baseUrl?: string;
89
+ meshView?: () => MeshViewHint | null;
90
+ }
91
+
92
+ /**
93
+ * Whether to ask the hub if this member is an admin: `true` when the mesh
94
+ * view lists this member with the `admin` role, `false` when it lists it
95
+ * without, `undefined` while there is no view yet (it arrives on the first
96
+ * heartbeat). A target with no `meshView` at all is always asked.
97
+ *
98
+ * WHY ASK ONLY A LIKELY ADMIN. The hub's 403 is the answer, but a browser
99
+ * reports every refused request in the console as an error, so asking every
100
+ * member would put a red line in every member's console on every link. The
101
+ * view says who holds `admin`, and the hub still decides: a view that says
102
+ * `admin` to a peer the hub refuses leaves the panel hidden.
103
+ */
104
+ export function adminHint(target: HubTarget | null | undefined): boolean | undefined {
105
+ if (target == null) return false;
106
+ if (typeof target.meshView !== "function") return true;
107
+ const view = target.meshView();
108
+ if (view == null) return undefined;
109
+ return view.members.find((m) => m.peerId === view.self)?.roles.includes("admin") ?? false;
110
+ }
111
+
112
+ /** `<baseUrl><hubPeerId>/hub/api/`, or `null` when there is no edge to call through. */
113
+ export function hubAdminBase(target: HubTarget | null | undefined): string | null {
114
+ if (target?.baseUrl == null || target.baseUrl === "") return null;
115
+ if (!/^[A-Za-z0-9]+$/.test(target.hubPeerId)) return null;
116
+ const base = target.baseUrl.endsWith("/") ? target.baseUrl : `${target.baseUrl}/`;
117
+ return `${base}${target.hubPeerId}/hub/api/`;
118
+ }
119
+
120
+ export interface CreatedInvitation {
121
+ id: string;
122
+ expiresAt: number;
123
+ blob: string;
124
+ link: string;
125
+ }
126
+
127
+ export interface PendingInvitation {
128
+ id: string;
129
+ roles: string[];
130
+ expiresAt: number;
131
+ }
132
+
133
+ /** A failed admin call. `status` is 0 when the request never got an answer. */
134
+ export class HubAdminError extends Error {
135
+ constructor(
136
+ readonly status: number,
137
+ message: string,
138
+ options?: { cause?: unknown },
139
+ ) {
140
+ super(message, options);
141
+ this.name = "HubAdminError";
142
+ }
143
+ }
144
+
145
+ /**
146
+ * Whether a failed roles call means "this member is not an admin here" (or
147
+ * "this hub has no admin API"), as opposed to a hiccup worth another try.
148
+ */
149
+ export function isFinalRefusal(error: unknown): boolean {
150
+ return (
151
+ error instanceof HubAdminError &&
152
+ (error.status === 401 || error.status === 403 || error.status === 404)
153
+ );
154
+ }
155
+
156
+ export interface HubAdminClient {
157
+ /** The hub's role names. Rejects with a `HubAdminError`; a 403 is the usual "not an admin". */
158
+ roles(): Promise<string[]>;
159
+ create(role: InviteRole, ttlMs: number): Promise<CreatedInvitation>;
160
+ pending(): Promise<PendingInvitation[]>;
161
+ }
162
+
163
+ async function failure(response: Response, what: string): Promise<HubAdminError> {
164
+ let reason = "";
165
+ try {
166
+ const body = (await response.json()) as { error?: unknown };
167
+ if (typeof body.error === "string") reason = body.error.slice(0, 200);
168
+ } catch {
169
+ // Not JSON: say the status only. A body we do not understand is not quoted.
170
+ }
171
+ const status = response.status;
172
+ const text =
173
+ status === 403
174
+ ? `The hub refused to ${what} (403): only a mesh admin can.`
175
+ : `Could not ${what}: HTTP ${status}${reason === "" ? "" : ` (${reason})`}.`;
176
+ return new HubAdminError(status, text);
177
+ }
178
+
179
+ /** The admin API at `apiBase` (`hubAdminBase`'s value), through `fetchImpl`. */
180
+ export function createHubAdminClient(fetchImpl: typeof fetch, apiBase: string): HubAdminClient {
181
+ const call = async (path: string, what: string, init?: RequestInit): Promise<unknown> => {
182
+ let response: Response;
183
+ try {
184
+ response = await fetchImpl(new URL(path, apiBase).href, {
185
+ ...init,
186
+ headers: { accept: "application/json", ...(init?.headers ?? {}) },
187
+ });
188
+ } catch (cause) {
189
+ throw new HubAdminError(0, `Could not reach the hub to ${what}.`, { cause });
190
+ }
191
+ if (!response.ok) throw await failure(response, what);
192
+ try {
193
+ return await response.json();
194
+ } catch (cause) {
195
+ throw new HubAdminError(response.status, `The hub's answer to ${what} was not JSON.`, {
196
+ cause,
197
+ });
198
+ }
199
+ };
200
+
201
+ return {
202
+ async roles() {
203
+ const value = await call("roles", "list its roles");
204
+ if (!Array.isArray(value) || !value.every((r) => typeof r === "string")) {
205
+ throw new HubAdminError(200, "The hub's role list is not a list of names.");
206
+ }
207
+ return value as string[];
208
+ },
209
+ async create(role, ttlMs) {
210
+ const value = (await call("invitations", "create an invitation", {
211
+ method: "POST",
212
+ headers: { "content-type": "application/json" },
213
+ body: JSON.stringify(invitationRequestBody(role, ttlMs)),
214
+ })) as Partial<CreatedInvitation> | null;
215
+ if (
216
+ value == null ||
217
+ typeof value.link !== "string" ||
218
+ value.link === "" ||
219
+ typeof value.expiresAt !== "number"
220
+ ) {
221
+ throw new HubAdminError(200, "The hub answered without an invitation link.");
222
+ }
223
+ return value as CreatedInvitation;
224
+ },
225
+ async pending() {
226
+ const value = await call("invitations", "list pending invitations");
227
+ if (!Array.isArray(value)) {
228
+ throw new HubAdminError(200, "The hub's pending invitations are not a list.");
229
+ }
230
+ return value.filter(
231
+ (v): v is PendingInvitation =>
232
+ v != null &&
233
+ typeof v === "object" &&
234
+ Array.isArray((v as PendingInvitation).roles) &&
235
+ typeof (v as PendingInvitation).expiresAt === "number",
236
+ );
237
+ },
238
+ };
239
+ }
240
+
241
+ /** "in 59 min", "in 23 h", "in 6 d", or "expired": how long an invitation has left, at a glance. */
242
+ export function describeExpiry(expiresAt: number, now: number): string {
243
+ const left = expiresAt - now;
244
+ if (left <= 0) return "expired";
245
+ const minutes = Math.ceil(left / 60_000);
246
+ if (minutes < 60) return `in ${minutes} min`;
247
+ const hours = Math.round(left / HOUR);
248
+ if (hours < 48) return `in ${hours} h`;
249
+ return `in ${Math.round(left / (24 * HOUR))} d`;
250
+ }