@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 ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Mikhail Kotelnikov
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,265 @@
1
+ # @statewalker/httpeers-join
2
+
3
+ The **join-the-mesh widget**: one small piece of plain DOM that every page joining
4
+ an httpeers hub shows the same way.
5
+
6
+ - **Paste** an invitation (a join link, a join blob, or a bare invitation id), which calls
7
+ `session.join(text)`.
8
+ - **Scan** a QR code with the live camera, or from a picture on the device. The decoded text
9
+ becomes an invitation through `invitationFromQrText` from `@statewalker/httpeers-member`.
10
+ - **See the link to the hub**: the phase (`checking`, `starting` with its peer state, `live`,
11
+ `needs-invitation`, `disconnected`, `blocked`, `failed`), whether a live link is `relay` or
12
+ `direct`, and this page's own peer id, shortened.
13
+ - **See what went wrong**: the phase's own message, and any call that rejected.
14
+ - **Leave**: Disconnect (keeps membership), Reconnect, and "Leave this mesh…"
15
+ (`session.resetIdentity()`, which forgets the identity), behind a confirmation.
16
+ - **Invite** (mesh admins only): make an invitation as a member or an admin, valid for 1 hour,
17
+ 1 day or 7 days, and hand it over as a link, a QR code, the share sheet or the clipboard. See
18
+ [Invite](#invite).
19
+
20
+ Every button is shown only when `SessionState.controls` allows it. There is one addition: "Leave"
21
+ is hidden while there is no identity (a first run has nothing to forget). The widget decides
22
+ nothing else about the session: `PeerSession` already decides it, and the widget shows the
23
+ result.
24
+
25
+ ```ts
26
+ import { mountJoinWidget } from "@statewalker/httpeers-join";
27
+ import { createSession } from "@statewalker/httpeers-member/browser";
28
+
29
+ let widget;
30
+ const session = createSession({ /* … */, onChange: (state) => widget?.update(state) });
31
+ widget = mountJoinWidget(document.querySelector("#mesh-join")!, {
32
+ session,
33
+ state: session.state(),
34
+ });
35
+ await session.start();
36
+ ```
37
+
38
+ ## API
39
+
40
+ | Export | What it is |
41
+ |---|---|
42
+ | `mountJoinWidget(container, options) → JoinWidget` | Builds the widget inside `container`. |
43
+ | `JoinWidget.update(state)` | Show a new `SessionState` (or `null`: "Starting…"). Call it from `onChange`. |
44
+ | `JoinWidget.destroy()` | Stops a running camera and removes the widget. |
45
+ | `JoinWidget.element` | The widget's root element. |
46
+ | `describePhase(state)` | The status line's text, for a page that wants it elsewhere. |
47
+ | `shortPeerId(id)` | `12D3KooWAb…abcdef`. |
48
+ | `defaultQrScanner()` | The real `QrScanner` (see below). |
49
+ | `JOIN_WIDGET_CSS`, `injectJoinWidgetStyles(doc)` | The stylesheet, for a page that injects it itself. |
50
+ | `LEAVE_CONFIRMATION` | The text "Leave this mesh…" asks before it acts. |
51
+ | `createHubAdminClient(fetch, apiBase)` | The hub's admin API (`roles`, `create`, `pending`), for a page that builds its own invite UI. |
52
+ | `hubAdminBase(target)`, `adminHint(target)` | `<baseUrl><hubPeerId>/hub/api/`, and whether the mesh view makes this member a likely admin. |
53
+ | `INVITE_ROLES`, `INVITE_EXPIRIES`, `expiryMs(id)`, `invitationRequestBody(role, ttlMs)`, `offeredRoles(hubRoles)` | The panel's choices and the request it sends. |
54
+ | `ADMIN_INVITATION_WARNING` | The warning shown while "Admin" is selected. |
55
+ | `createInvitePanel(doc, make, n, options)` | The panel on its own. The widget builds it; a page rarely needs to. |
56
+
57
+ `JoinWidgetOptions`:
58
+
59
+ | Option | Default | |
60
+ |---|---|---|
61
+ | `session` | required | Anything with `join`, `disconnect`, `reconnect` and `resetIdentity`. A `PeerSession` is one. |
62
+ | `state` | `null` | The state to show at once, usually `session.state()`. |
63
+ | `compact` | `false` | One status line, plus a "Mesh" menu with Disconnect, Leave and, for an admin, Invite. No join form. For a header. |
64
+ | `scanner` | `defaultQrScanner()` | `null` hides both QR buttons. |
65
+ | `confirm` | `window.confirm` | Asked before leaving. May return a promise, for a page with its own dialog. |
66
+ | `injectStyles` | `true` | `false` leaves styling to the page. The `hp-join-` and `hp-invite-` class names are then the API. |
67
+ | `invite` | `{}` | The Invite panel's options (`fetch`, `share`, `copy`, `qrSvg`, `now`, `retryDelayMs`, `roleAttempts`), or `false` to leave the panel out. |
68
+
69
+ The state passed to `update()` may carry `handle` (a `SessionState` does). The widget reads only
70
+ its `hubPeerId`, `baseUrl` and `meshView()`, and only for the Invite panel.
71
+
72
+ **QR.** `defaultQrScanner()` loads `@statewalker/httpeers-qr/browser` (and with it
73
+ `html5-qrcode`) with a dynamic `import()` on the **first scan**, so a page that only resumes
74
+ never downloads the camera library, and a bundler gives it a chunk of its own. The camera button
75
+ is hidden when `navigator.mediaDevices.getUserMedia` is missing, which covers an insecure origin
76
+ and a browser with no camera API. The picture button stays. A camera that exists but is refused
77
+ is reported when the button is pressed, with a pointer to the picture button. A scan that finds a
78
+ QR code that is not an invitation keeps scanning (camera) or says so (picture). A bare invitation
79
+ id is not accepted from a QR code: nothing tells it apart from any other short string.
80
+
81
+ **A picture gets two decoders.** html5-qrcode's `scanFile` could not read the demos hub's own
82
+ invitation QR (a 310-character join blob) from a clean 684 px screenshot, nor at 600 or 480 px.
83
+ It read the same picture at 400 px and below. Phone screenshots and photos are larger than that.
84
+ When html5-qrcode finds nothing, `defaultQrScanner` therefore draws the picture to a canvas at
85
+ its own size (capped at 1600 px), then at 800 and 400 px, and runs `decodeQr` (jsQR, from the
86
+ `@statewalker/httpeers-qr` root, also loaded lazily) on each. Measured in Chromium with the
87
+ built demos app, every size from 300 to 3000 px decodes, as does a blurred, tilted and darkened
88
+ 3000 px copy. The same run, scanning the hub page's real QR code, joined its mesh live. The
89
+ camera path is html5-qrcode alone. It gets many frames and the person aims it.
90
+
91
+ **Accessibility.** The status line is `role="status"`. The phase message is `role="alert"` when it
92
+ reports trouble (a hub that does not know this peer, a refused invitation, `blocked`, `failed`) and
93
+ `role="status"` when it gives instructions (a first run, `disconnected`, a live note). A rejected
94
+ call and a failed scan go to a separate `role="alert"` line. The field has a `<label>`, every
95
+ control is a `<button>` (or a labelled file input), and ids are unique per instance.
96
+
97
+ **Styling.** The widget injects one `<style id="hp-join-style">` per document on its first mount.
98
+ Every rule is on an `hp-join-` or `hp-invite-` class, and none is in a cascade layer, so the rules beat
99
+ Tailwind's preflight (`@layer base`) without leaking into the page. Colours are custom properties
100
+ on `.hp-join` (`--hp-join-accent`, `--hp-join-danger`, `--hp-join-bg`, …). The widget inherits
101
+ the page's font.
102
+
103
+ ### Invite
104
+
105
+ A mesh admin gets an **Invite someone** section: below the controls in the full widget, and in
106
+ the "Mesh" menu in the compact one. It holds:
107
+
108
+ - **Invite as**: Member (the default) or Admin. While Admin is selected, a warning says that an
109
+ admin invitation grants full control of the mesh: whoever redeems it can invite others (admins
110
+ too), revoke members, and use every admin service the hub offers. The Create button turns red
111
+ and says "Create admin invitation".
112
+ - **Expiry**: 1 hour, 1 day (the default, and the hub's own default) or 7 days.
113
+ - **Create**: `POST /hub/api/invitations {"roles": [role], "ttlMs": …}`. The hub answers with
114
+ `link`, its ready join URL (its `HUB_JOIN_PAGE_URL` plus `?join=`), and the panel shows it with
115
+ its QR code, a note saying who may join as what until when, **Share** (`navigator.share`,
116
+ hidden where the browser has none, so on most desktops) and **Copy link** (the Clipboard API,
117
+ or selecting the link and `execCommand("copy")`).
118
+ - **Pending invitations**: `GET /hub/api/invitations`, loaded when the section is opened and after
119
+ each Create, with **Refresh**. Each shows its role and how long it has left. Expired ones are
120
+ left out. Every admin's invitations are listed, including those made through the door.
121
+
122
+ A failed call is shown inline, as `role="alert"`, and the panel stays usable.
123
+
124
+ **Who sees it.** The calls go where every call to the hub goes: `fetch` to
125
+ `<baseUrl><hubPeerId>/hub/api/…`, which the page's ServiceWorker edge routes over the mesh with
126
+ this member's token (llm-chat requests an LLM key the same way). The hub serves its admin API
127
+ there behind `std:mesh.admin`, which its rules give to `role("admin")`. The panel therefore
128
+ stays hidden until all three hold:
129
+
130
+ 1. the page is live;
131
+ 2. the mesh view lists this member with the `admin` role (`adminHint`). This is only a hint,
132
+ and it decides whether the hub is asked at all. Asking every member would put a refused
133
+ request, which browsers print as a console error, on every member's page on every link;
134
+ 3. `GET /hub/api/roles` succeeds. This is the actual check. A 403 (the hub says not an admin),
135
+ 404 (a hub with no admin API, such as the demos' browser-tab hub) or 401 hides the panel for
136
+ the life of that link. A network error or a 5xx is tried up to three times, 2 s apart.
137
+
138
+ The roles call also fills the picker: Member and Admin are offered only if the hub knows them.
139
+ Other roles the hub knows are **not** offered. The appliance's rules also define `hidden` (a
140
+ member the mesh view hides from non-admins), which is a tool for operators and has no place in a
141
+ two-button picker. Mint it through the door when needed. Nothing is asked again until the link
142
+ changes (a disconnect, a reconnect, another hub).
143
+
144
+ **An invitation is a bearer secret.** It redeems once, for whoever holds it. Nothing in the
145
+ panel logs one. The pending list shows no invitation ids (a bare id redeems too). The link and
146
+ QR code are cleared when the page leaves `live`. Error messages quote only the hub's `{ error }`
147
+ text, which never carries an invitation.
148
+
149
+ **The QR code** is `qrSvg` from `@statewalker/httpeers-qr`, loaded on the first Create like the
150
+ scanner. It encodes the hub's `link`, not the bare blob, so a phone's own camera app opens the
151
+ join page directly. The widget's scanner accepts the same link.
152
+
153
+ **On a phone**, below 30rem, the compact widget's menu becomes a sheet along the bottom of the
154
+ screen (at most 70% of its height, scrolling), because anchored to its button it ran off the
155
+ left edge. The header, and the toggle that closes the menu, stay in view.
156
+
157
+ ### In React
158
+
159
+ The widget is not a React component, and React pages wrap it in a few lines. The session and the
160
+ state stay in React. The widget is mounted once and fed each state:
161
+
162
+ ```tsx
163
+ function JoinWidgetView({ session, state, compact }: Props) {
164
+ const host = useRef<HTMLDivElement>(null);
165
+ const widget = useRef<JoinWidget | null>(null);
166
+ useEffect(() => {
167
+ if (host.current == null) return;
168
+ const w = mountJoinWidget(host.current, { session, compact });
169
+ widget.current = w;
170
+ return () => { w.destroy(); widget.current = null; };
171
+ }, [session, compact]);
172
+ useEffect(() => widget.current?.update(state), [state]);
173
+ return <div ref={host} />;
174
+ }
175
+ ```
176
+
177
+ `apps/llm-chat/src/mesh/join-widget.tsx` is this wrapper. It lives in `mesh/` because the
178
+ boundary test keeps httpeers out of everything the standalone page reaches.
179
+
180
+ ## Design
181
+
182
+ **What had to be shared.** Four pages join a hub: llm-chat's `mesh.html` (React and Tailwind)
183
+ and the demos' `app`, `images` and `proxy` pages (plain DOM). Each had its own copy of the join
184
+ form and the Disconnect/Reconnect buttons. None of them offered QR scanning or a way to leave, and
185
+ the copies had already drifted in wording.
186
+
187
+ **Three shapes were considered.**
188
+
189
+ 1. **A React component, with a DOM copy for the demos.** Rejected. That keeps two copies, which is
190
+ the problem this package solves, and the demos would take on a framework to show one form.
191
+ 2. **A custom element (`<httpeers-join>`).** Close. It works in both worlds and needs no wrapper.
192
+ But it registers a global name once per page, which clashes when two bundles on one origin each
193
+ ship a copy. Its state goes in through a property that React 19 sets but earlier versions do
194
+ not. Its lifecycle (`connectedCallback`, upgrade order) is harder to test than a function call.
195
+ None of this buys anything a function does not.
196
+ 3. **A function that mounts into a container and returns `{ update, destroy }`.** Chosen. It is
197
+ plain TypeScript and DOM, has no globals and no registration, and is straightforward to test
198
+ under happy-dom with a fake session. React wraps it in about ten lines, and a plain page calls
199
+ it directly.
200
+
201
+ **The widget holds no session state.** The session reports changes through one `onChange` that
202
+ the page wires up when it creates it. The page therefore owns the session and passes each
203
+ state to `update()`. The widget keeps only what exists only on screen: the typed text, a running
204
+ camera, a button whose call is in flight, and the last rejection.
205
+
206
+ **Where it lives.** It is a new package rather than an entry in `@statewalker/httpeers-member`
207
+ (`/join-widget`), for two reasons:
208
+
209
+ - `httpeers-member` compiles without the DOM lib on purpose (see its `tsconfig.json`), and its
210
+ boundary test keeps DOM out of everything the root reaches. A widget is DOM from top to bottom.
211
+ As an entry there it would need either the DOM lib for the whole package or a set of local
212
+ declarations the size of the widget.
213
+ - The widget depends on `httpeers-qr/browser` and `html5-qrcode`. Putting it in `httpeers-member`
214
+ would make the member package depend on the QR package, for a camera most members never use.
215
+
216
+ `httpeers-join` depends on `httpeers-member` (the types and `invitationFromQrText`) and
217
+ `httpeers-qr`, and nothing in `packages/` depends on it except the private conformance leaf. Apps
218
+ never import from other apps, so a package is the only place both apps can share it from.
219
+
220
+ **Nothing happens at import.** The widget touches no `document`, `window` or stylesheet until
221
+ `mountJoinWidget` runs, and the camera library is loaded by the first scan.
222
+ `tests/import.test.ts` imports the built `dist/` under plain Node to check both.
223
+ `httpeers-conformance`'s `runtime-import.test.ts` imports the entry again, together with every
224
+ other published entry.
225
+
226
+ ## The `dist/` trap
227
+
228
+ The apps' Vite configs set no `resolve.conditions`, and this package's `exports` map offers no
229
+ `source` condition. **Pages bundle `dist/`**, which is gitignored and holds whatever was last
230
+ built. After changing `src/`:
231
+
232
+ ```sh
233
+ pnpm --filter @statewalker/httpeers-join build # or: pnpm turbo build
234
+ pnpm --filter @statewalker/httpeers-llm-chat build # and/or the demos
235
+ ```
236
+
237
+ Then check that the page's bundle hash changed. If it did not, the change did not reach the
238
+ bundle. `turbo build` builds this package before the apps because they depend on it.
239
+
240
+ ## Tests
241
+
242
+ ```sh
243
+ pnpm --filter @statewalker/httpeers-join test # build, typecheck, vitest
244
+ ```
245
+
246
+ - `tests/widget.test.ts` (happy-dom): a fake session is taken through every phase. The tests
247
+ check the status text, the message and its role, which buttons show, that each button calls
248
+ the right session method (and is disabled while the call runs), the confirmation before
249
+ leaving, a rejected call shown as an alert, camera and picture scanning through a fake scanner
250
+ (including the camera stopping when the form goes away), compact mode, and unique ids per
251
+ instance.
252
+ - `tests/invite.test.ts` (happy-dom): the panel's decisions (expiry mapping, the request body
253
+ for member and admin, the roles offered, the API address, the admin hint, which failures are
254
+ final), the client (the POST it sends, a 403, an unreachable hub), and the panel inside the
255
+ widget against a fake hub. The fake hub checks that nothing is asked until live and until the
256
+ view says admin, that a 403 or 404 hides the panel with no second request, that a 502 is
257
+ retried, the admin warning, the link and QR code, Share and Copy, a refused Create shown
258
+ inline, and the pending list (roles and time left, never ids). It also checks that the link is
259
+ cleared on leaving `live`, that the panel sits in the compact menu, and that nothing is logged.
260
+ - `tests/import.test.ts` (Node, no DOM): the built entry imports and exports the widget, and the
261
+ camera library is loaded only by a dynamic `import()`.
262
+
263
+ Nothing here drives a real camera or a real hub. That is covered by the apps' Playwright smokes
264
+ (`apps/demos/scripts/join-smoke.mjs`, `apps/llm-chat/scripts/mesh-smoke.mjs`), which join through
265
+ this widget.
@@ -0,0 +1,20 @@
1
+ /**
2
+ * The join-the-mesh widget: paste or scan an invitation, see the link to the
3
+ * hub, disconnect, reconnect or leave -- and, for a mesh admin, invite
4
+ * others as members or admins.
5
+ *
6
+ * Plain DOM and no framework. A page mounts it, feeds it every `SessionState`
7
+ * its `PeerSession` reports, and destroys it when done:
8
+ *
9
+ * const widget = mountJoinWidget(el, { session, state: session.state() });
10
+ * // in the session's onChange:
11
+ * widget.update(state);
12
+ *
13
+ * See the README for the design and for how React pages wrap it.
14
+ */
15
+ export { ADMIN_INVITATION_WARNING, adminHint, type CreatedInvitation, createHubAdminClient, DEFAULT_INVITE_EXPIRY, DEFAULT_INVITE_ROLE, describeExpiry, expiryMs, type HubAdminClient, HubAdminError, type HubTarget, hubAdminBase, INVITE_EXPIRIES, INVITE_ROLES, type InviteExpiryId, type InviteRole, invitationRequestBody, isFinalRefusal, type MeshViewHint, offeredRoles, type PendingInvitation, } from "./invite.js";
16
+ export { createInvitePanel, type InvitePanel, type InvitePanelOptions } from "./invite-panel.js";
17
+ export { defaultQrScanner, type QrCameraScan, type QrFileScan, type QrScanner, } from "./qr.js";
18
+ export { injectJoinWidgetStyles, JOIN_WIDGET_CSS, JOIN_WIDGET_STYLE_ID } from "./styles.js";
19
+ export { describePhase, type JoinWidget, type JoinWidgetOptions, type JoinWidgetSession, type JoinWidgetState, LEAVE_CONFIRMATION, mountJoinWidget, shortPeerId, } from "./widget.js";
20
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;GAaG;AAEH,OAAO,EACL,wBAAwB,EACxB,SAAS,EACT,KAAK,iBAAiB,EACtB,oBAAoB,EACpB,qBAAqB,EACrB,mBAAmB,EACnB,cAAc,EACd,QAAQ,EACR,KAAK,cAAc,EACnB,aAAa,EACb,KAAK,SAAS,EACd,YAAY,EACZ,eAAe,EACf,YAAY,EACZ,KAAK,cAAc,EACnB,KAAK,UAAU,EACf,qBAAqB,EACrB,cAAc,EACd,KAAK,YAAY,EACjB,YAAY,EACZ,KAAK,iBAAiB,GACvB,MAAM,aAAa,CAAC;AACrB,OAAO,EAAE,iBAAiB,EAAE,KAAK,WAAW,EAAE,KAAK,kBAAkB,EAAE,MAAM,mBAAmB,CAAC;AACjG,OAAO,EACL,gBAAgB,EAChB,KAAK,YAAY,EACjB,KAAK,UAAU,EACf,KAAK,SAAS,GACf,MAAM,SAAS,CAAC;AACjB,OAAO,EAAE,sBAAsB,EAAE,eAAe,EAAE,oBAAoB,EAAE,MAAM,aAAa,CAAC;AAC5F,OAAO,EACL,aAAa,EACb,KAAK,UAAU,EACf,KAAK,iBAAiB,EACtB,KAAK,iBAAiB,EACtB,KAAK,eAAe,EACpB,kBAAkB,EAClB,eAAe,EACf,WAAW,GACZ,MAAM,aAAa,CAAC"}
package/dist/index.js ADDED
@@ -0,0 +1,19 @@
1
+ /**
2
+ * The join-the-mesh widget: paste or scan an invitation, see the link to the
3
+ * hub, disconnect, reconnect or leave -- and, for a mesh admin, invite
4
+ * others as members or admins.
5
+ *
6
+ * Plain DOM and no framework. A page mounts it, feeds it every `SessionState`
7
+ * its `PeerSession` reports, and destroys it when done:
8
+ *
9
+ * const widget = mountJoinWidget(el, { session, state: session.state() });
10
+ * // in the session's onChange:
11
+ * widget.update(state);
12
+ *
13
+ * See the README for the design and for how React pages wrap it.
14
+ */
15
+ export { ADMIN_INVITATION_WARNING, adminHint, createHubAdminClient, DEFAULT_INVITE_EXPIRY, DEFAULT_INVITE_ROLE, describeExpiry, expiryMs, HubAdminError, hubAdminBase, INVITE_EXPIRIES, INVITE_ROLES, invitationRequestBody, isFinalRefusal, offeredRoles, } from "./invite.js";
16
+ export { createInvitePanel } from "./invite-panel.js";
17
+ export { defaultQrScanner, } from "./qr.js";
18
+ export { injectJoinWidgetStyles, JOIN_WIDGET_CSS, JOIN_WIDGET_STYLE_ID } from "./styles.js";
19
+ export { describePhase, LEAVE_CONFIRMATION, mountJoinWidget, shortPeerId, } from "./widget.js";
@@ -0,0 +1,50 @@
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 { type HubTarget } from "./invite.js";
21
+ export interface InvitePanelOptions {
22
+ /** How the hub is called. Defaults to the page's `fetch`, which the ServiceWorker edge routes over the mesh. */
23
+ fetch?: typeof fetch;
24
+ /** The share sheet. Defaults to `navigator.share` where it exists; `null` hides Share. */
25
+ share?: ((data: ShareData) => Promise<void>) | null;
26
+ /** Copies text. Defaults to `navigator.clipboard.writeText`, falling back to selecting the link and `execCommand("copy")`. */
27
+ copy?: (text: string) => Promise<void>;
28
+ /** The QR code of a link, as SVG markup. Defaults to `qrSvg` from `@statewalker/httpeers-qr`, loaded on first use. */
29
+ qrSvg?: (text: string) => string | Promise<string>;
30
+ /** The clock expiries are shown against. */
31
+ now?: () => number;
32
+ /** How long to wait before asking for the roles again after a non-final failure. Default 2 s. */
33
+ retryDelayMs?: number;
34
+ /** How many times the roles are asked for, in all. Default 3. */
35
+ roleAttempts?: number;
36
+ }
37
+ export interface InvitePanel {
38
+ readonly element: HTMLElement;
39
+ /** Point the panel at a live link's hub, or at nothing (`null`) when the page is not live. Idempotent. */
40
+ setTarget(target: HubTarget | null): void;
41
+ destroy(): void;
42
+ }
43
+ type Make = <K extends keyof HTMLElementTagNameMap>(tag: K, className: string, text?: string) => HTMLElementTagNameMap[K];
44
+ /**
45
+ * Build the panel (hidden) with `make`, the widget's element helper. `n` is
46
+ * the widget instance number, for unique ids.
47
+ */
48
+ export declare function createInvitePanel(doc: Document, make: Make, n: number, options?: InvitePanelOptions): InvitePanel;
49
+ export {};
50
+ //# sourceMappingURL=invite-panel.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"invite-panel.d.ts","sourceRoot":"","sources":["../src/invite-panel.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;GAkBG;AAEH,OAAO,EAUL,KAAK,SAAS,EAQf,MAAM,aAAa,CAAC;AAErB,MAAM,WAAW,kBAAkB;IACjC,gHAAgH;IAChH,KAAK,CAAC,EAAE,OAAO,KAAK,CAAC;IACrB,0FAA0F;IAC1F,KAAK,CAAC,EAAE,CAAC,CAAC,IAAI,EAAE,SAAS,KAAK,OAAO,CAAC,IAAI,CAAC,CAAC,GAAG,IAAI,CAAC;IACpD,8HAA8H;IAC9H,IAAI,CAAC,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,OAAO,CAAC,IAAI,CAAC,CAAC;IACvC,sHAAsH;IACtH,KAAK,CAAC,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,CAAC;IACnD,4CAA4C;IAC5C,GAAG,CAAC,EAAE,MAAM,MAAM,CAAC;IACnB,iGAAiG;IACjG,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,iEAAiE;IACjE,YAAY,CAAC,EAAE,MAAM,CAAC;CACvB;AAED,MAAM,WAAW,WAAW;IAC1B,QAAQ,CAAC,OAAO,EAAE,WAAW,CAAC;IAC9B,0GAA0G;IAC1G,SAAS,CAAC,MAAM,EAAE,SAAS,GAAG,IAAI,GAAG,IAAI,CAAC;IAC1C,OAAO,IAAI,IAAI,CAAC;CACjB;AAED,KAAK,IAAI,GAAG,CAAC,CAAC,SAAS,MAAM,qBAAqB,EAChD,GAAG,EAAE,CAAC,EACN,SAAS,EAAE,MAAM,EACjB,IAAI,CAAC,EAAE,MAAM,KACV,qBAAqB,CAAC,CAAC,CAAC,CAAC;AAe9B;;;GAGG;AACH,wBAAgB,iBAAiB,CAC/B,GAAG,EAAE,QAAQ,EACb,IAAI,EAAE,IAAI,EACV,CAAC,EAAE,MAAM,EACT,OAAO,GAAE,kBAAuB,GAC/B,WAAW,CAgVb"}