@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.
- package/LICENSE +21 -0
- package/README.md +265 -0
- package/dist/index.d.ts +20 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +19 -0
- package/dist/invite-panel.d.ts +50 -0
- package/dist/invite-panel.d.ts.map +1 -0
- package/dist/invite-panel.js +357 -0
- package/dist/invite.d.ts +126 -0
- package/dist/invite.d.ts.map +1 -0
- package/dist/invite.js +192 -0
- package/dist/qr.d.ts +60 -0
- package/dist/qr.d.ts.map +1 -0
- package/dist/qr.js +115 -0
- package/dist/styles.d.ts +19 -0
- package/dist/styles.d.ts.map +1 -0
- package/dist/styles.js +163 -0
- package/dist/widget.d.ts +83 -0
- package/dist/widget.d.ts.map +1 -0
- package/dist/widget.js +375 -0
- package/package.json +48 -0
- package/src/index.ts +56 -0
- package/src/invite-panel.ts +429 -0
- package/src/invite.ts +250 -0
- package/src/qr.ts +136 -0
- package/src/styles.ts +165 -0
- package/src/widget.ts +450 -0
|
@@ -0,0 +1,357 @@
|
|
|
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
|
+
import { ADMIN_INVITATION_WARNING, adminHint, createHubAdminClient, DEFAULT_INVITE_EXPIRY, DEFAULT_INVITE_ROLE, describeExpiry, expiryMs, hubAdminBase, INVITE_EXPIRIES, INVITE_ROLES, isFinalRefusal, offeredRoles, } from "./invite.js";
|
|
21
|
+
const pageFetch = (input, init) => globalThis.fetch(input, init);
|
|
22
|
+
async function defaultQrSvg(text) {
|
|
23
|
+
const { qrSvg } = await import("@statewalker/httpeers-qr");
|
|
24
|
+
return qrSvg(text);
|
|
25
|
+
}
|
|
26
|
+
function defaultShare(doc) {
|
|
27
|
+
const nav = doc.defaultView?.navigator;
|
|
28
|
+
if (nav == null || typeof nav.share !== "function")
|
|
29
|
+
return null;
|
|
30
|
+
return (data) => nav.share(data);
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Build the panel (hidden) with `make`, the widget's element helper. `n` is
|
|
34
|
+
* the widget instance number, for unique ids.
|
|
35
|
+
*/
|
|
36
|
+
export function createInvitePanel(doc, make, n, options = {}) {
|
|
37
|
+
const fetchImpl = options.fetch ?? pageFetch;
|
|
38
|
+
const share = options.share === undefined ? defaultShare(doc) : options.share;
|
|
39
|
+
const toQrSvg = options.qrSvg ?? defaultQrSvg;
|
|
40
|
+
const now = options.now ?? Date.now;
|
|
41
|
+
const retryDelayMs = options.retryDelayMs ?? 2_000;
|
|
42
|
+
const roleAttempts = Math.max(1, options.roleAttempts ?? 3);
|
|
43
|
+
const button = (className, label) => {
|
|
44
|
+
const b = make("button", `hp-join-button ${className}`, label);
|
|
45
|
+
b.type = "button";
|
|
46
|
+
return b;
|
|
47
|
+
};
|
|
48
|
+
// --- structure ----------------------------------------------------------
|
|
49
|
+
const root = make("details", "hp-invite");
|
|
50
|
+
root.hidden = true;
|
|
51
|
+
const summary = make("summary", "hp-invite-toggle", "Invite someone");
|
|
52
|
+
const body = make("div", "hp-invite-body");
|
|
53
|
+
const roleGroup = make("fieldset", "hp-invite-roles");
|
|
54
|
+
const roleLegend = make("legend", "hp-invite-legend", "Invite as");
|
|
55
|
+
roleGroup.append(roleLegend);
|
|
56
|
+
const roleInputs = new Map();
|
|
57
|
+
for (const role of INVITE_ROLES) {
|
|
58
|
+
const label = make("label", `hp-invite-role hp-invite-role-${role}`);
|
|
59
|
+
const input = make("input", "hp-invite-role-input");
|
|
60
|
+
input.type = "radio";
|
|
61
|
+
input.name = `hp-invite-role-${n}`;
|
|
62
|
+
input.value = role;
|
|
63
|
+
input.checked = role === DEFAULT_INVITE_ROLE;
|
|
64
|
+
label.append(input, ` ${role === "admin" ? "Admin" : "Member"}`);
|
|
65
|
+
roleGroup.append(label);
|
|
66
|
+
roleInputs.set(role, { input, label });
|
|
67
|
+
}
|
|
68
|
+
const warning = make("p", "hp-invite-warning", ADMIN_INVITATION_WARNING);
|
|
69
|
+
warning.setAttribute("role", "note");
|
|
70
|
+
const expiry = make("select", "hp-invite-expiry");
|
|
71
|
+
expiry.id = `hp-invite-expiry-${n}`;
|
|
72
|
+
for (const e of INVITE_EXPIRIES) {
|
|
73
|
+
const option = make("option", "", `Expires in ${e.label}`);
|
|
74
|
+
option.value = e.id;
|
|
75
|
+
option.selected = e.id === DEFAULT_INVITE_EXPIRY;
|
|
76
|
+
expiry.append(option);
|
|
77
|
+
}
|
|
78
|
+
const expiryLabel = make("label", "hp-join-visually-hidden", "Expiry");
|
|
79
|
+
expiryLabel.htmlFor = expiry.id;
|
|
80
|
+
const create = button("hp-join-primary hp-invite-create", "Create invitation");
|
|
81
|
+
const createRow = make("div", "hp-join-actions");
|
|
82
|
+
createRow.append(expiryLabel, expiry, create);
|
|
83
|
+
const error = make("p", "hp-join-error hp-invite-error");
|
|
84
|
+
error.setAttribute("role", "alert");
|
|
85
|
+
error.hidden = true;
|
|
86
|
+
const result = make("div", "hp-invite-result");
|
|
87
|
+
result.hidden = true;
|
|
88
|
+
const resultNote = make("p", "hp-invite-note");
|
|
89
|
+
const qr = make("div", "hp-invite-qr");
|
|
90
|
+
const link = make("input", "hp-join-input hp-invite-link");
|
|
91
|
+
link.id = `hp-invite-link-${n}`;
|
|
92
|
+
link.type = "text";
|
|
93
|
+
link.readOnly = true;
|
|
94
|
+
link.autocomplete = "off";
|
|
95
|
+
link.spellcheck = false;
|
|
96
|
+
const linkLabel = make("label", "hp-join-visually-hidden", "Invitation link");
|
|
97
|
+
linkLabel.htmlFor = link.id;
|
|
98
|
+
const shareButton = button("hp-join-primary hp-invite-share", "Share");
|
|
99
|
+
const copyButton = button("hp-invite-copy", "Copy link");
|
|
100
|
+
const copied = make("span", "hp-invite-copied");
|
|
101
|
+
copied.setAttribute("role", "status");
|
|
102
|
+
const resultActions = make("div", "hp-join-actions");
|
|
103
|
+
resultActions.append(shareButton, copyButton, copied);
|
|
104
|
+
result.append(resultNote, qr, linkLabel, link, resultActions);
|
|
105
|
+
const pendingBox = make("div", "hp-invite-pending");
|
|
106
|
+
const pendingHead = make("div", "hp-invite-pending-head");
|
|
107
|
+
const pendingTitle = make("p", "hp-invite-pending-title", "Pending invitations");
|
|
108
|
+
const refresh = button("hp-invite-refresh", "Refresh");
|
|
109
|
+
pendingHead.append(pendingTitle, refresh);
|
|
110
|
+
const pendingList = make("ul", "hp-invite-pending-list");
|
|
111
|
+
const pendingEmpty = make("p", "hp-invite-pending-empty", "None.");
|
|
112
|
+
pendingBox.append(pendingHead, pendingList, pendingEmpty);
|
|
113
|
+
body.append(roleGroup, warning, createRow, error, result, pendingBox);
|
|
114
|
+
root.append(summary, body);
|
|
115
|
+
// --- behaviour ----------------------------------------------------------
|
|
116
|
+
let apiBase = null;
|
|
117
|
+
let client = null;
|
|
118
|
+
/** Bumped on every target change and on destroy: an answer for an older link writes nothing. */
|
|
119
|
+
let generation = 0;
|
|
120
|
+
let destroyed = false;
|
|
121
|
+
let creating = false;
|
|
122
|
+
let loadingPending = false;
|
|
123
|
+
let copiedTimer;
|
|
124
|
+
let current = null;
|
|
125
|
+
const showError = (text) => {
|
|
126
|
+
error.textContent = text ?? "";
|
|
127
|
+
error.hidden = text == null;
|
|
128
|
+
};
|
|
129
|
+
const messageOf = (err) => (err instanceof Error ? err.message : String(err));
|
|
130
|
+
const selectedRole = () => {
|
|
131
|
+
for (const [role, { input }] of roleInputs)
|
|
132
|
+
if (input.checked)
|
|
133
|
+
return role;
|
|
134
|
+
return DEFAULT_INVITE_ROLE;
|
|
135
|
+
};
|
|
136
|
+
const renderRole = () => {
|
|
137
|
+
const role = selectedRole();
|
|
138
|
+
warning.hidden = role !== "admin";
|
|
139
|
+
root.dataset.role = role;
|
|
140
|
+
create.textContent = role === "admin" ? "Create admin invitation" : "Create invitation";
|
|
141
|
+
create.classList.toggle("hp-invite-create-admin", role === "admin");
|
|
142
|
+
};
|
|
143
|
+
const clearResult = () => {
|
|
144
|
+
current = null;
|
|
145
|
+
result.hidden = true;
|
|
146
|
+
link.value = "";
|
|
147
|
+
qr.replaceChildren();
|
|
148
|
+
copied.textContent = "";
|
|
149
|
+
resultNote.textContent = "";
|
|
150
|
+
};
|
|
151
|
+
const renderPending = (items) => {
|
|
152
|
+
const t = now();
|
|
153
|
+
const live = items.filter((i) => i.expiresAt > t).sort((a, b) => a.expiresAt - b.expiresAt);
|
|
154
|
+
pendingList.replaceChildren(...live.map((item) => {
|
|
155
|
+
const li = make("li", "hp-invite-pending-item");
|
|
156
|
+
const role = item.roles.join(", ") || "(no role)";
|
|
157
|
+
const roleTag = make("span", `hp-invite-tag${item.roles.includes("admin") ? " hp-invite-tag-admin" : ""}`, role);
|
|
158
|
+
const when = make("span", "hp-invite-when", `expires ${describeExpiry(item.expiresAt, t)}`);
|
|
159
|
+
when.title = new Date(item.expiresAt).toLocaleString();
|
|
160
|
+
li.append(roleTag, " ", when);
|
|
161
|
+
return li;
|
|
162
|
+
}));
|
|
163
|
+
pendingTitle.textContent = `Pending invitations (${live.length})`;
|
|
164
|
+
pendingEmpty.hidden = live.length > 0;
|
|
165
|
+
};
|
|
166
|
+
const loadPending = async () => {
|
|
167
|
+
const c = client;
|
|
168
|
+
const gen = generation;
|
|
169
|
+
if (c == null || loadingPending)
|
|
170
|
+
return;
|
|
171
|
+
loadingPending = true;
|
|
172
|
+
refresh.disabled = true;
|
|
173
|
+
try {
|
|
174
|
+
const items = await c.pending();
|
|
175
|
+
if (gen === generation && !destroyed)
|
|
176
|
+
renderPending(items);
|
|
177
|
+
}
|
|
178
|
+
catch (err) {
|
|
179
|
+
if (gen === generation && !destroyed)
|
|
180
|
+
showError(messageOf(err));
|
|
181
|
+
}
|
|
182
|
+
finally {
|
|
183
|
+
loadingPending = false;
|
|
184
|
+
refresh.disabled = false;
|
|
185
|
+
}
|
|
186
|
+
};
|
|
187
|
+
const showCreated = async (created, role) => {
|
|
188
|
+
current = created;
|
|
189
|
+
link.value = created.link;
|
|
190
|
+
resultNote.textContent =
|
|
191
|
+
`Anyone with this link joins as ${role === "admin" ? "an ADMIN" : "a member"}, once, ` +
|
|
192
|
+
`until ${new Date(created.expiresAt).toLocaleString()}. Send it only to the person you mean.`;
|
|
193
|
+
shareButton.hidden = share == null;
|
|
194
|
+
copied.textContent = "";
|
|
195
|
+
result.hidden = false;
|
|
196
|
+
qr.replaceChildren();
|
|
197
|
+
const gen = generation;
|
|
198
|
+
try {
|
|
199
|
+
const svg = await toQrSvg(created.link);
|
|
200
|
+
if (gen !== generation || destroyed || current !== created)
|
|
201
|
+
return;
|
|
202
|
+
// Our own encoder's markup, from a string it built: no page content goes in.
|
|
203
|
+
qr.innerHTML = svg;
|
|
204
|
+
}
|
|
205
|
+
catch {
|
|
206
|
+
// No QR is not fatal: the link, Share and Copy still work.
|
|
207
|
+
}
|
|
208
|
+
};
|
|
209
|
+
const hide = () => {
|
|
210
|
+
root.hidden = true;
|
|
211
|
+
root.open = false;
|
|
212
|
+
clearResult();
|
|
213
|
+
showError(null);
|
|
214
|
+
pendingList.replaceChildren();
|
|
215
|
+
pendingEmpty.hidden = false;
|
|
216
|
+
pendingTitle.textContent = "Pending invitations";
|
|
217
|
+
};
|
|
218
|
+
const detect = async (gen, c) => {
|
|
219
|
+
for (let attempt = 1; attempt <= roleAttempts; attempt++) {
|
|
220
|
+
try {
|
|
221
|
+
const roles = await c.roles();
|
|
222
|
+
if (gen !== generation || destroyed)
|
|
223
|
+
return;
|
|
224
|
+
const offered = offeredRoles(roles);
|
|
225
|
+
if (offered.length === 0)
|
|
226
|
+
return; // an admin, but of a hub with neither role: nothing to offer
|
|
227
|
+
for (const [role, { input, label }] of roleInputs) {
|
|
228
|
+
label.hidden = !offered.includes(role);
|
|
229
|
+
input.disabled = !offered.includes(role);
|
|
230
|
+
}
|
|
231
|
+
if (!offered.includes(selectedRole())) {
|
|
232
|
+
const first = roleInputs.get(offered[0]);
|
|
233
|
+
if (first != null)
|
|
234
|
+
first.input.checked = true;
|
|
235
|
+
}
|
|
236
|
+
renderRole();
|
|
237
|
+
root.hidden = false;
|
|
238
|
+
return;
|
|
239
|
+
}
|
|
240
|
+
catch (err) {
|
|
241
|
+
if (gen !== generation || destroyed || isFinalRefusal(err))
|
|
242
|
+
return;
|
|
243
|
+
if (attempt < roleAttempts) {
|
|
244
|
+
await new Promise((resolve) => setTimeout(resolve, retryDelayMs));
|
|
245
|
+
if (gen !== generation || destroyed)
|
|
246
|
+
return;
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
};
|
|
251
|
+
const setTarget = (target) => {
|
|
252
|
+
if (destroyed)
|
|
253
|
+
return;
|
|
254
|
+
let base = hubAdminBase(target);
|
|
255
|
+
const hint = base == null ? false : adminHint(target);
|
|
256
|
+
// No view yet: keep whatever was decided for this same hub, else wait for one.
|
|
257
|
+
if (hint === undefined && base === apiBase)
|
|
258
|
+
return;
|
|
259
|
+
if (hint !== true)
|
|
260
|
+
base = null;
|
|
261
|
+
if (base === apiBase)
|
|
262
|
+
return;
|
|
263
|
+
apiBase = base;
|
|
264
|
+
generation++;
|
|
265
|
+
client = base == null ? null : createHubAdminClient(fetchImpl, base);
|
|
266
|
+
hide();
|
|
267
|
+
if (client != null)
|
|
268
|
+
void detect(generation, client);
|
|
269
|
+
};
|
|
270
|
+
for (const { input } of roleInputs.values())
|
|
271
|
+
input.addEventListener("change", renderRole);
|
|
272
|
+
root.addEventListener("toggle", () => {
|
|
273
|
+
if (root.open)
|
|
274
|
+
void loadPending();
|
|
275
|
+
});
|
|
276
|
+
refresh.addEventListener("click", () => {
|
|
277
|
+
showError(null);
|
|
278
|
+
void loadPending();
|
|
279
|
+
});
|
|
280
|
+
create.addEventListener("click", () => {
|
|
281
|
+
const c = client;
|
|
282
|
+
if (c == null || creating)
|
|
283
|
+
return;
|
|
284
|
+
const gen = generation;
|
|
285
|
+
const role = selectedRole();
|
|
286
|
+
creating = true;
|
|
287
|
+
create.disabled = true;
|
|
288
|
+
showError(null);
|
|
289
|
+
clearResult();
|
|
290
|
+
c.create(role, expiryMs(expiry.value)).then(async (created) => {
|
|
291
|
+
creating = false;
|
|
292
|
+
create.disabled = false;
|
|
293
|
+
if (gen !== generation || destroyed)
|
|
294
|
+
return;
|
|
295
|
+
await showCreated(created, role);
|
|
296
|
+
void loadPending();
|
|
297
|
+
}, (err) => {
|
|
298
|
+
creating = false;
|
|
299
|
+
create.disabled = false;
|
|
300
|
+
if (gen === generation && !destroyed)
|
|
301
|
+
showError(messageOf(err));
|
|
302
|
+
});
|
|
303
|
+
});
|
|
304
|
+
link.addEventListener("focus", () => link.select());
|
|
305
|
+
const flashCopied = (text) => {
|
|
306
|
+
copied.textContent = text;
|
|
307
|
+
clearTimeout(copiedTimer);
|
|
308
|
+
copiedTimer = setTimeout(() => {
|
|
309
|
+
copied.textContent = "";
|
|
310
|
+
}, 2_500);
|
|
311
|
+
};
|
|
312
|
+
copyButton.addEventListener("click", () => {
|
|
313
|
+
const value = current?.link;
|
|
314
|
+
if (value == null)
|
|
315
|
+
return;
|
|
316
|
+
const viaSelection = () => {
|
|
317
|
+
link.focus();
|
|
318
|
+
link.select();
|
|
319
|
+
const ok = doc.execCommand?.("copy") ?? false;
|
|
320
|
+
flashCopied(ok ? "Copied" : "Select the link and copy it");
|
|
321
|
+
};
|
|
322
|
+
const copy = options.copy ??
|
|
323
|
+
(doc.defaultView?.navigator.clipboard?.writeText != null
|
|
324
|
+
? (text) => doc.defaultView.navigator.clipboard.writeText(text)
|
|
325
|
+
: null);
|
|
326
|
+
if (copy == null) {
|
|
327
|
+
viaSelection();
|
|
328
|
+
return;
|
|
329
|
+
}
|
|
330
|
+
copy(value).then(() => flashCopied("Copied"), () => viaSelection());
|
|
331
|
+
});
|
|
332
|
+
shareButton.addEventListener("click", () => {
|
|
333
|
+
const value = current?.link;
|
|
334
|
+
if (value == null || share == null)
|
|
335
|
+
return;
|
|
336
|
+
share({ title: "Join the mesh", text: "An invitation to join the mesh:", url: value }).catch((err) => {
|
|
337
|
+
// Closing the share sheet is not an error.
|
|
338
|
+
if (err?.name === "AbortError")
|
|
339
|
+
return;
|
|
340
|
+
showError(`Could not share (${messageOf(err)}). Copy the link instead.`);
|
|
341
|
+
});
|
|
342
|
+
});
|
|
343
|
+
renderRole();
|
|
344
|
+
return {
|
|
345
|
+
element: root,
|
|
346
|
+
setTarget,
|
|
347
|
+
destroy() {
|
|
348
|
+
if (destroyed)
|
|
349
|
+
return;
|
|
350
|
+
destroyed = true;
|
|
351
|
+
generation++;
|
|
352
|
+
clearTimeout(copiedTimer);
|
|
353
|
+
clearResult();
|
|
354
|
+
root.remove();
|
|
355
|
+
},
|
|
356
|
+
};
|
|
357
|
+
}
|
package/dist/invite.d.ts
ADDED
|
@@ -0,0 +1,126 @@
|
|
|
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
|
+
/** The roles the panel offers, in order. Anything else a hub knows (`hidden`, a module's own) is not offered. */
|
|
30
|
+
export declare const INVITE_ROLES: readonly ["member", "admin"];
|
|
31
|
+
export type InviteRole = (typeof INVITE_ROLES)[number];
|
|
32
|
+
export declare const DEFAULT_INVITE_ROLE: InviteRole;
|
|
33
|
+
/** The lifetimes offered. The hub's own default is one day, and so is the panel's. */
|
|
34
|
+
export declare const INVITE_EXPIRIES: readonly [{
|
|
35
|
+
readonly id: "1h";
|
|
36
|
+
readonly label: "1 hour";
|
|
37
|
+
readonly ttlMs: number;
|
|
38
|
+
}, {
|
|
39
|
+
readonly id: "1d";
|
|
40
|
+
readonly label: "1 day";
|
|
41
|
+
readonly ttlMs: number;
|
|
42
|
+
}, {
|
|
43
|
+
readonly id: "7d";
|
|
44
|
+
readonly label: "7 days";
|
|
45
|
+
readonly ttlMs: number;
|
|
46
|
+
}];
|
|
47
|
+
export type InviteExpiryId = (typeof INVITE_EXPIRIES)[number]["id"];
|
|
48
|
+
export declare const DEFAULT_INVITE_EXPIRY: InviteExpiryId;
|
|
49
|
+
/** What an admin invitation hands over, said before one is made. */
|
|
50
|
+
export declare const ADMIN_INVITATION_WARNING: string;
|
|
51
|
+
/** A lifetime id to milliseconds. An unknown id is the default, never `undefined`: the request always says how long. */
|
|
52
|
+
export declare function expiryMs(id: string): number;
|
|
53
|
+
/** The body of `POST /hub/api/invitations`: one role, and the lifetime. */
|
|
54
|
+
export declare function invitationRequestBody(role: InviteRole, ttlMs: number): {
|
|
55
|
+
roles: string[];
|
|
56
|
+
ttlMs: number;
|
|
57
|
+
};
|
|
58
|
+
/** The roles to offer, given what the hub says it knows: ours, in our order, and only those it knows. */
|
|
59
|
+
export declare function offeredRoles(hubRoles: readonly string[]): InviteRole[];
|
|
60
|
+
/** The part of a mesh view the admin hint reads, by shape. */
|
|
61
|
+
export interface MeshViewHint {
|
|
62
|
+
self: string;
|
|
63
|
+
members: ReadonlyArray<{
|
|
64
|
+
peerId: string;
|
|
65
|
+
roles: readonly string[];
|
|
66
|
+
}>;
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* The part of a member handle the panel needs. `baseUrl` is absent where no
|
|
70
|
+
* edge is mounted (Node). `meshView`, when present, is the hint that decides
|
|
71
|
+
* whether the hub is asked at all -- see `adminHint`.
|
|
72
|
+
*/
|
|
73
|
+
export interface HubTarget {
|
|
74
|
+
hubPeerId: string;
|
|
75
|
+
baseUrl?: string;
|
|
76
|
+
meshView?: () => MeshViewHint | null;
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* Whether to ask the hub if this member is an admin: `true` when the mesh
|
|
80
|
+
* view lists this member with the `admin` role, `false` when it lists it
|
|
81
|
+
* without, `undefined` while there is no view yet (it arrives on the first
|
|
82
|
+
* heartbeat). A target with no `meshView` at all is always asked.
|
|
83
|
+
*
|
|
84
|
+
* WHY ASK ONLY A LIKELY ADMIN. The hub's 403 is the answer, but a browser
|
|
85
|
+
* reports every refused request in the console as an error, so asking every
|
|
86
|
+
* member would put a red line in every member's console on every link. The
|
|
87
|
+
* view says who holds `admin`, and the hub still decides: a view that says
|
|
88
|
+
* `admin` to a peer the hub refuses leaves the panel hidden.
|
|
89
|
+
*/
|
|
90
|
+
export declare function adminHint(target: HubTarget | null | undefined): boolean | undefined;
|
|
91
|
+
/** `<baseUrl><hubPeerId>/hub/api/`, or `null` when there is no edge to call through. */
|
|
92
|
+
export declare function hubAdminBase(target: HubTarget | null | undefined): string | null;
|
|
93
|
+
export interface CreatedInvitation {
|
|
94
|
+
id: string;
|
|
95
|
+
expiresAt: number;
|
|
96
|
+
blob: string;
|
|
97
|
+
link: string;
|
|
98
|
+
}
|
|
99
|
+
export interface PendingInvitation {
|
|
100
|
+
id: string;
|
|
101
|
+
roles: string[];
|
|
102
|
+
expiresAt: number;
|
|
103
|
+
}
|
|
104
|
+
/** A failed admin call. `status` is 0 when the request never got an answer. */
|
|
105
|
+
export declare class HubAdminError extends Error {
|
|
106
|
+
readonly status: number;
|
|
107
|
+
constructor(status: number, message: string, options?: {
|
|
108
|
+
cause?: unknown;
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
/**
|
|
112
|
+
* Whether a failed roles call means "this member is not an admin here" (or
|
|
113
|
+
* "this hub has no admin API"), as opposed to a hiccup worth another try.
|
|
114
|
+
*/
|
|
115
|
+
export declare function isFinalRefusal(error: unknown): boolean;
|
|
116
|
+
export interface HubAdminClient {
|
|
117
|
+
/** The hub's role names. Rejects with a `HubAdminError`; a 403 is the usual "not an admin". */
|
|
118
|
+
roles(): Promise<string[]>;
|
|
119
|
+
create(role: InviteRole, ttlMs: number): Promise<CreatedInvitation>;
|
|
120
|
+
pending(): Promise<PendingInvitation[]>;
|
|
121
|
+
}
|
|
122
|
+
/** The admin API at `apiBase` (`hubAdminBase`'s value), through `fetchImpl`. */
|
|
123
|
+
export declare function createHubAdminClient(fetchImpl: typeof fetch, apiBase: string): HubAdminClient;
|
|
124
|
+
/** "in 59 min", "in 23 h", "in 6 d", or "expired": how long an invitation has left, at a glance. */
|
|
125
|
+
export declare function describeExpiry(expiresAt: number, now: number): string;
|
|
126
|
+
//# sourceMappingURL=invite.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"invite.d.ts","sourceRoot":"","sources":["../src/invite.ts"],"names":[],"mappings":"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"}
|
package/dist/invite.js
ADDED
|
@@ -0,0 +1,192 @@
|
|
|
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
|
+
/** The roles the panel offers, in order. Anything else a hub knows (`hidden`, a module's own) is not offered. */
|
|
30
|
+
export const INVITE_ROLES = ["member", "admin"];
|
|
31
|
+
export const DEFAULT_INVITE_ROLE = "member";
|
|
32
|
+
const HOUR = 60 * 60 * 1000;
|
|
33
|
+
/** The lifetimes offered. The hub's own default is one day, and so is the panel's. */
|
|
34
|
+
export const INVITE_EXPIRIES = [
|
|
35
|
+
{ id: "1h", label: "1 hour", ttlMs: HOUR },
|
|
36
|
+
{ id: "1d", label: "1 day", ttlMs: 24 * HOUR },
|
|
37
|
+
{ id: "7d", label: "7 days", ttlMs: 7 * 24 * HOUR },
|
|
38
|
+
];
|
|
39
|
+
export const DEFAULT_INVITE_EXPIRY = "1d";
|
|
40
|
+
/** What an admin invitation hands over, said before one is made. */
|
|
41
|
+
export const ADMIN_INVITATION_WARNING = "An admin invitation grants full control of this mesh: whoever redeems it can invite " +
|
|
42
|
+
"others (admins too), revoke members, and use every admin service the hub offers. " +
|
|
43
|
+
"Send it only to someone you would trust with this device.";
|
|
44
|
+
/** A lifetime id to milliseconds. An unknown id is the default, never `undefined`: the request always says how long. */
|
|
45
|
+
export function expiryMs(id) {
|
|
46
|
+
const found = INVITE_EXPIRIES.find((e) => e.id === id) ??
|
|
47
|
+
INVITE_EXPIRIES.find((e) => e.id === DEFAULT_INVITE_EXPIRY);
|
|
48
|
+
return found.ttlMs;
|
|
49
|
+
}
|
|
50
|
+
/** The body of `POST /hub/api/invitations`: one role, and the lifetime. */
|
|
51
|
+
export function invitationRequestBody(role, ttlMs) {
|
|
52
|
+
return { roles: [role], ttlMs };
|
|
53
|
+
}
|
|
54
|
+
/** The roles to offer, given what the hub says it knows: ours, in our order, and only those it knows. */
|
|
55
|
+
export function offeredRoles(hubRoles) {
|
|
56
|
+
return INVITE_ROLES.filter((role) => hubRoles.includes(role));
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Whether to ask the hub if this member is an admin: `true` when the mesh
|
|
60
|
+
* view lists this member with the `admin` role, `false` when it lists it
|
|
61
|
+
* without, `undefined` while there is no view yet (it arrives on the first
|
|
62
|
+
* heartbeat). A target with no `meshView` at all is always asked.
|
|
63
|
+
*
|
|
64
|
+
* WHY ASK ONLY A LIKELY ADMIN. The hub's 403 is the answer, but a browser
|
|
65
|
+
* reports every refused request in the console as an error, so asking every
|
|
66
|
+
* member would put a red line in every member's console on every link. The
|
|
67
|
+
* view says who holds `admin`, and the hub still decides: a view that says
|
|
68
|
+
* `admin` to a peer the hub refuses leaves the panel hidden.
|
|
69
|
+
*/
|
|
70
|
+
export function adminHint(target) {
|
|
71
|
+
if (target == null)
|
|
72
|
+
return false;
|
|
73
|
+
if (typeof target.meshView !== "function")
|
|
74
|
+
return true;
|
|
75
|
+
const view = target.meshView();
|
|
76
|
+
if (view == null)
|
|
77
|
+
return undefined;
|
|
78
|
+
return view.members.find((m) => m.peerId === view.self)?.roles.includes("admin") ?? false;
|
|
79
|
+
}
|
|
80
|
+
/** `<baseUrl><hubPeerId>/hub/api/`, or `null` when there is no edge to call through. */
|
|
81
|
+
export function hubAdminBase(target) {
|
|
82
|
+
if (target?.baseUrl == null || target.baseUrl === "")
|
|
83
|
+
return null;
|
|
84
|
+
if (!/^[A-Za-z0-9]+$/.test(target.hubPeerId))
|
|
85
|
+
return null;
|
|
86
|
+
const base = target.baseUrl.endsWith("/") ? target.baseUrl : `${target.baseUrl}/`;
|
|
87
|
+
return `${base}${target.hubPeerId}/hub/api/`;
|
|
88
|
+
}
|
|
89
|
+
/** A failed admin call. `status` is 0 when the request never got an answer. */
|
|
90
|
+
export class HubAdminError extends Error {
|
|
91
|
+
status;
|
|
92
|
+
constructor(status, message, options) {
|
|
93
|
+
super(message, options);
|
|
94
|
+
this.status = status;
|
|
95
|
+
this.name = "HubAdminError";
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* Whether a failed roles call means "this member is not an admin here" (or
|
|
100
|
+
* "this hub has no admin API"), as opposed to a hiccup worth another try.
|
|
101
|
+
*/
|
|
102
|
+
export function isFinalRefusal(error) {
|
|
103
|
+
return (error instanceof HubAdminError &&
|
|
104
|
+
(error.status === 401 || error.status === 403 || error.status === 404));
|
|
105
|
+
}
|
|
106
|
+
async function failure(response, what) {
|
|
107
|
+
let reason = "";
|
|
108
|
+
try {
|
|
109
|
+
const body = (await response.json());
|
|
110
|
+
if (typeof body.error === "string")
|
|
111
|
+
reason = body.error.slice(0, 200);
|
|
112
|
+
}
|
|
113
|
+
catch {
|
|
114
|
+
// Not JSON: say the status only. A body we do not understand is not quoted.
|
|
115
|
+
}
|
|
116
|
+
const status = response.status;
|
|
117
|
+
const text = status === 403
|
|
118
|
+
? `The hub refused to ${what} (403): only a mesh admin can.`
|
|
119
|
+
: `Could not ${what}: HTTP ${status}${reason === "" ? "" : ` (${reason})`}.`;
|
|
120
|
+
return new HubAdminError(status, text);
|
|
121
|
+
}
|
|
122
|
+
/** The admin API at `apiBase` (`hubAdminBase`'s value), through `fetchImpl`. */
|
|
123
|
+
export function createHubAdminClient(fetchImpl, apiBase) {
|
|
124
|
+
const call = async (path, what, init) => {
|
|
125
|
+
let response;
|
|
126
|
+
try {
|
|
127
|
+
response = await fetchImpl(new URL(path, apiBase).href, {
|
|
128
|
+
...init,
|
|
129
|
+
headers: { accept: "application/json", ...(init?.headers ?? {}) },
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
catch (cause) {
|
|
133
|
+
throw new HubAdminError(0, `Could not reach the hub to ${what}.`, { cause });
|
|
134
|
+
}
|
|
135
|
+
if (!response.ok)
|
|
136
|
+
throw await failure(response, what);
|
|
137
|
+
try {
|
|
138
|
+
return await response.json();
|
|
139
|
+
}
|
|
140
|
+
catch (cause) {
|
|
141
|
+
throw new HubAdminError(response.status, `The hub's answer to ${what} was not JSON.`, {
|
|
142
|
+
cause,
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
};
|
|
146
|
+
return {
|
|
147
|
+
async roles() {
|
|
148
|
+
const value = await call("roles", "list its roles");
|
|
149
|
+
if (!Array.isArray(value) || !value.every((r) => typeof r === "string")) {
|
|
150
|
+
throw new HubAdminError(200, "The hub's role list is not a list of names.");
|
|
151
|
+
}
|
|
152
|
+
return value;
|
|
153
|
+
},
|
|
154
|
+
async create(role, ttlMs) {
|
|
155
|
+
const value = (await call("invitations", "create an invitation", {
|
|
156
|
+
method: "POST",
|
|
157
|
+
headers: { "content-type": "application/json" },
|
|
158
|
+
body: JSON.stringify(invitationRequestBody(role, ttlMs)),
|
|
159
|
+
}));
|
|
160
|
+
if (value == null ||
|
|
161
|
+
typeof value.link !== "string" ||
|
|
162
|
+
value.link === "" ||
|
|
163
|
+
typeof value.expiresAt !== "number") {
|
|
164
|
+
throw new HubAdminError(200, "The hub answered without an invitation link.");
|
|
165
|
+
}
|
|
166
|
+
return value;
|
|
167
|
+
},
|
|
168
|
+
async pending() {
|
|
169
|
+
const value = await call("invitations", "list pending invitations");
|
|
170
|
+
if (!Array.isArray(value)) {
|
|
171
|
+
throw new HubAdminError(200, "The hub's pending invitations are not a list.");
|
|
172
|
+
}
|
|
173
|
+
return value.filter((v) => v != null &&
|
|
174
|
+
typeof v === "object" &&
|
|
175
|
+
Array.isArray(v.roles) &&
|
|
176
|
+
typeof v.expiresAt === "number");
|
|
177
|
+
},
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
/** "in 59 min", "in 23 h", "in 6 d", or "expired": how long an invitation has left, at a glance. */
|
|
181
|
+
export function describeExpiry(expiresAt, now) {
|
|
182
|
+
const left = expiresAt - now;
|
|
183
|
+
if (left <= 0)
|
|
184
|
+
return "expired";
|
|
185
|
+
const minutes = Math.ceil(left / 60_000);
|
|
186
|
+
if (minutes < 60)
|
|
187
|
+
return `in ${minutes} min`;
|
|
188
|
+
const hours = Math.round(left / HOUR);
|
|
189
|
+
if (hours < 48)
|
|
190
|
+
return `in ${hours} h`;
|
|
191
|
+
return `in ${Math.round(left / (24 * HOUR))} d`;
|
|
192
|
+
}
|