@statewalker/httpeers-libp2p 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.
Files changed (59) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +173 -0
  3. package/dist/browser.d.ts +33 -0
  4. package/dist/browser.d.ts.map +1 -0
  5. package/dist/browser.js +77 -0
  6. package/dist/duplex.d.ts +86 -0
  7. package/dist/duplex.d.ts.map +1 -0
  8. package/dist/duplex.js +101 -0
  9. package/dist/hop-reserve.d.ts +105 -0
  10. package/dist/hop-reserve.d.ts.map +1 -0
  11. package/dist/hop-reserve.js +269 -0
  12. package/dist/hub-link.d.ts +131 -0
  13. package/dist/hub-link.d.ts.map +1 -0
  14. package/dist/hub-link.js +217 -0
  15. package/dist/hub-relay.d.ts +129 -0
  16. package/dist/hub-relay.d.ts.map +1 -0
  17. package/dist/hub-relay.js +154 -0
  18. package/dist/identity.d.ts +84 -0
  19. package/dist/identity.d.ts.map +1 -0
  20. package/dist/identity.js +91 -0
  21. package/dist/index.d.ts +28 -0
  22. package/dist/index.d.ts.map +1 -0
  23. package/dist/index.js +26 -0
  24. package/dist/link.d.ts +49 -0
  25. package/dist/link.d.ts.map +1 -0
  26. package/dist/link.js +88 -0
  27. package/dist/multiaddr-parts.d.ts +21 -0
  28. package/dist/multiaddr-parts.d.ts.map +1 -0
  29. package/dist/multiaddr-parts.js +27 -0
  30. package/dist/node.d.ts +33 -0
  31. package/dist/node.d.ts.map +1 -0
  32. package/dist/node.js +60 -0
  33. package/dist/reservation.d.ts +245 -0
  34. package/dist/reservation.d.ts.map +1 -0
  35. package/dist/reservation.js +396 -0
  36. package/dist/serve-peer.d.ts +91 -0
  37. package/dist/serve-peer.d.ts.map +1 -0
  38. package/dist/serve-peer.js +68 -0
  39. package/dist/timers.d.ts +36 -0
  40. package/dist/timers.d.ts.map +1 -0
  41. package/dist/timers.js +38 -0
  42. package/dist/transport.d.ts +314 -0
  43. package/dist/transport.d.ts.map +1 -0
  44. package/dist/transport.js +410 -0
  45. package/package.json +73 -0
  46. package/src/browser.ts +118 -0
  47. package/src/duplex.ts +172 -0
  48. package/src/hop-reserve.ts +295 -0
  49. package/src/hub-link.ts +281 -0
  50. package/src/hub-relay.ts +188 -0
  51. package/src/identity.ts +155 -0
  52. package/src/index.ts +114 -0
  53. package/src/link.ts +143 -0
  54. package/src/multiaddr-parts.ts +29 -0
  55. package/src/node.ts +63 -0
  56. package/src/reservation.ts +564 -0
  57. package/src/serve-peer.ts +141 -0
  58. package/src/timers.ts +47 -0
  59. package/src/transport.ts +538 -0
@@ -0,0 +1,281 @@
1
+ /**
2
+ * A member's link to its hub: reach it, reserve on it, and address other
3
+ * members through it. See `./hub-relay.ts` for the hub's side and why.
4
+ *
5
+ * TRANSPORT-NEUTRAL, like `./reservation.ts`: nothing here is browser-only,
6
+ * so the same code runs in a page and under the Node tests.
7
+ */
8
+
9
+ import type { Connection, Libp2p } from "@libp2p/interface";
10
+ import { peerIdFromString } from "@libp2p/peer-id";
11
+ import { multiaddr } from "@multiformats/multiaddr";
12
+ import { lastPeerIdOf } from "./multiaddr-parts.js";
13
+ import { type RelaySupervisor, superviseRelay } from "./reservation.js";
14
+
15
+ /**
16
+ * The address a member dials another member at: through their hub, with a
17
+ * WebRTC upgrade. The `/webrtc` step is what turns the hub's circuit into a
18
+ * signalling channel -- dialled without it, the result is a limited
19
+ * connection through the hub, which carries nothing.
20
+ *
21
+ * COMPOSED, NEVER TAKEN FROM A PEER'S OWN ADDRESSES. A reservation on the
22
+ * hub makes libp2p report a double-circuit address
23
+ * (`<relay>/p2p-circuit/webrtc/p2p/<hub>/p2p-circuit/p2p/<member>`) that
24
+ * nothing can dial. This works because the circuit transport reuses the
25
+ * connection it already holds to the hub.
26
+ */
27
+ export function hubRoute(hubPeerId: string, peerId: string): string {
28
+ return `/p2p/${hubPeerId}/p2p-circuit/webrtc/p2p/${peerId}`;
29
+ }
30
+
31
+ /**
32
+ * Reach the hub over WebRTC through the public relay, and keep ONLY the
33
+ * WebRTC connection.
34
+ *
35
+ * WHY THE SIGNALLING CIRCUIT IS CLOSED. Reaching the hub leaves two
36
+ * connections to it: the limited circuit through the public relay that
37
+ * carried the SDP exchange, and the WebRTC one. The circuit transport relays
38
+ * through `getConnections(relay)[0]` -- the FIRST connection to the relay,
39
+ * limited or not -- so while the limited one is open, every dial THROUGH the
40
+ * hub fails with `LimitedConnectionError`. Found by the `proto/hub-relay`
41
+ * prototype; the "connects two members directly" test fails without it.
42
+ */
43
+ export async function reachHub(node: Libp2p, relayAddr: string, hubPeerId: string): Promise<void> {
44
+ await node.dial(multiaddr(`${relayAddr}/p2p-circuit/webrtc/p2p/${hubPeerId}`));
45
+ const limited = node.getConnections(peerIdFromString(hubPeerId)).filter((c) => c.limits != null);
46
+ await Promise.all(limited.map((c) => c.close()));
47
+ }
48
+
49
+ /**
50
+ * Reach the hub through the public relay WITHOUT the WebRTC upgrade, and KEEP
51
+ * the circuit: the fallback for when `reachHub` cannot work (a hub in Docker on
52
+ * a bridge network has nothing a WebRTC dial can reach).
53
+ *
54
+ * The result is a LIMITED connection, and nothing crosses it unless both ends
55
+ * opt in: the hub serves with `serveOnLimitedConnection: true`, and the member
56
+ * calls with `callOnLimitedConnection` allowing the hub (`servePeer`,
57
+ * `createRemote`), which then opens its streams on this connection rather than
58
+ * dialling again.
59
+ *
60
+ * RETURNED AS LIBP2P RETURNS IT, and not always limited: if an unlimited
61
+ * connection to the hub already exists (a WebRTC upgrade that did come up),
62
+ * `dial` hands that back instead of opening a circuit. Check `limits` if it
63
+ * matters; the intended caller only gets here after `reachHub` failed, or
64
+ * after it succeeded but the hub refused a reservation -- and then hangs up
65
+ * the WebRTC connection first (`startMember`).
66
+ *
67
+ * NOT CLOSED, unlike `reachHub`'s circuit, because it is the data path. Two
68
+ * consequences for the caller: `reserveOnHub` has nothing to reserve over, so
69
+ * other members cannot reach this one; and `leaveRelay` would hang up the
70
+ * circuit itself.
71
+ */
72
+ export async function reachHubRelayed(
73
+ node: Libp2p,
74
+ relayAddr: string,
75
+ hubPeerId: string,
76
+ ): Promise<Connection> {
77
+ return node.dial(multiaddr(`${relayAddr}/p2p-circuit/p2p/${hubPeerId}`));
78
+ }
79
+
80
+ /**
81
+ * Why a hub did not grant a reservation, as far as libp2p lets us tell.
82
+ *
83
+ * - `"store-full"` -- `RESERVATION_REFUSED`: the hub's relay already holds
84
+ * as many reservations as it grants (libp2p's `maxReservations`). A
85
+ * capacity condition on the hub; nothing about this peer.
86
+ * - `"resource-limit"` -- `RESOURCE_LIMIT_EXCEEDED`: the same kind of thing,
87
+ * from a relay that meters resources rather than counting reservations.
88
+ * - `"not-a-member"` -- `PERMISSION_DENIED`: the hub's `membershipGater`
89
+ * does not count this peer as a member. (libp2p also answers it to a
90
+ * reservation asked for over a relayed connection, which `reserveOnHub`
91
+ * never does: it runs over `reachHub`'s WebRTC connection.)
92
+ * - `"no-relay"` -- the hub does not speak the relay protocol at all
93
+ * (`UnsupportedProtocolError`): a hub deployed without `hubRelayService`.
94
+ * - `"no-answer"` -- no status came back: a timeout, a link that dropped
95
+ * mid-request. Nothing was refused; nothing was granted either.
96
+ * - `"other"` -- any other status, named verbatim in the message.
97
+ */
98
+ export type HubReservationRefusal =
99
+ | "store-full"
100
+ | "resource-limit"
101
+ | "not-a-member"
102
+ | "no-relay"
103
+ | "no-answer"
104
+ | "other";
105
+
106
+ /** `reserveOnHub`'s failure: which refusal, the relay's own status when it sent one, and libp2p's error as `cause`. */
107
+ export class HubReservationError extends Error {
108
+ constructor(
109
+ readonly hubPeerId: string,
110
+ /** The circuit-relay v2 status the hub answered (`"RESERVATION_REFUSED"`, ...), or `null` when it answered none. */
111
+ readonly status: string | null,
112
+ readonly refusal: HubReservationRefusal,
113
+ options: { cause: unknown },
114
+ ) {
115
+ super(describeRefusal(hubPeerId, status, refusal, options.cause), options);
116
+ this.name = "HubReservationError";
117
+ }
118
+ }
119
+
120
+ /**
121
+ * Read the refusal out of libp2p's error.
122
+ *
123
+ * FROM THE TEXT, BECAUSE THAT IS ALL THERE IS. The transport manager wraps
124
+ * every failed listen in one `UnsupportedListenAddressesError` whose message
125
+ * embeds the inner error's stack -- `reservation failed with status <STATUS>`
126
+ * from the reservation store, or `UnsupportedProtocolError: ...` from protocol
127
+ * negotiation -- and keeps no reference to the inner error itself. The
128
+ * patterns are pinned against a real hub by the conformance suite's
129
+ * `member-reservation-fallback.test.ts`.
130
+ */
131
+ function classify(err: unknown): { status: string | null; refusal: HubReservationRefusal } {
132
+ const text =
133
+ err instanceof Error ? `${err.name}: ${err.message}\n${err.stack ?? ""}` : String(err);
134
+ const status = /reservation failed with status (\w+)/.exec(text)?.[1] ?? null;
135
+ switch (status) {
136
+ case "RESERVATION_REFUSED":
137
+ return { status, refusal: "store-full" };
138
+ case "RESOURCE_LIMIT_EXCEEDED":
139
+ return { status, refusal: "resource-limit" };
140
+ case "PERMISSION_DENIED":
141
+ return { status, refusal: "not-a-member" };
142
+ case null:
143
+ return {
144
+ status,
145
+ refusal: /UnsupportedProtocolError|could not negotiate/.test(text)
146
+ ? "no-relay"
147
+ : "no-answer",
148
+ };
149
+ default:
150
+ return { status, refusal: "other" };
151
+ }
152
+ }
153
+
154
+ function describeRefusal(
155
+ hubPeerId: string,
156
+ status: string | null,
157
+ refusal: HubReservationRefusal,
158
+ cause: unknown,
159
+ ): string {
160
+ const hub = `hub-link: the hub (${hubPeerId})`;
161
+ switch (refusal) {
162
+ case "store-full":
163
+ return (
164
+ `${hub} refused a reservation with RESERVATION_REFUSED: the hub's reservation store is ` +
165
+ "full (it grants a fixed number, each held until it expires). Nothing is wrong with this " +
166
+ "peer; the hub has no slot for it."
167
+ );
168
+ case "resource-limit":
169
+ return `${hub} refused a reservation with RESOURCE_LIMIT_EXCEEDED: the hub's relay is at a resource limit.`;
170
+ case "not-a-member":
171
+ return (
172
+ `${hub} refused a reservation with PERMISSION_DENIED: this peer is not a member as far ` +
173
+ "as the hub is concerned (a hub grants reservations to its members only)."
174
+ );
175
+ case "no-relay":
176
+ return `${hub} does not relay at all -- it has no circuit-relay service (UnsupportedProtocolError).`;
177
+ case "no-answer":
178
+ return `${hub} gave no answer to a reservation request. Cause: ${firstLine(cause)}`;
179
+ case "other":
180
+ return `${hub} refused a reservation with ${status}.`;
181
+ }
182
+ }
183
+
184
+ /** The inner error libp2p quotes, or the error itself -- without the stack. */
185
+ function firstLine(err: unknown): string {
186
+ const text = err instanceof Error ? err.message : String(err);
187
+ const lines = text.split("\n").map((line) => line.trim());
188
+ // libp2p's wrapper: a header line, then `<addr>: <inner error>` per address.
189
+ const inner = lines.find((line) => line.startsWith("/") && line.includes(": "));
190
+ return inner != null ? inner.slice(inner.indexOf(": ") + 2) : (lines[0] ?? "");
191
+ }
192
+
193
+ /**
194
+ * Reserve on the hub, over the WebRTC connection `reachHub` left open.
195
+ * Returns the reserved address. Only a member is granted one -- call this
196
+ * after the hub has accepted this peer, not before.
197
+ *
198
+ * A REFUSAL IS A `HubReservationError` that names the relay's own status --
199
+ * see `HubReservationRefusal`. libp2p reports every refusal alike, as "Some
200
+ * configured addresses failed to be listened on"; a full store and a
201
+ * non-member used to read the same, and in production a full store was
202
+ * diagnosed as a membership problem.
203
+ *
204
+ * A CONFIGURED RELAY, NOT A DISCOVERED ONE: libp2p will not restore it on
205
+ * its own if the link to the hub drops, which is `superviseHubReservation`'s
206
+ * job, below. Each call adds a listener; after a lost link -- or a refused
207
+ * reservation -- the old one sits empty. That is one small object per
208
+ * attempt, accepted rather than reaching into libp2p's internals to reuse it.
209
+ */
210
+ export async function reserveOnHub(node: Libp2p, hubPeerId: string): Promise<string> {
211
+ try {
212
+ await transportManagerOf(node).listen([multiaddr(`/p2p/${hubPeerId}/p2p-circuit`)]);
213
+ } catch (err) {
214
+ const { status, refusal } = classify(err);
215
+ throw new HubReservationError(hubPeerId, status, refusal, { cause: err });
216
+ }
217
+ const reserved = node
218
+ .getMultiaddrs()
219
+ .map((addr) => addr.toString())
220
+ .find((addr) => addr.includes(`/p2p/${hubPeerId}/p2p-circuit`));
221
+ if (reserved == null) {
222
+ throw new Error(`hub-link: listening on the hub (${hubPeerId}) produced no reserved address`);
223
+ }
224
+ return reserved;
225
+ }
226
+
227
+ /**
228
+ * Close this member's connection to the public relay, once its hub link is
229
+ * up. A member holds no reservation there any more, and other members reach
230
+ * it through the hub, so the socket would only spend the relay's capacity.
231
+ * It is reopened on demand: `reachHub` dials through the relay whenever the
232
+ * hub has to be reached again.
233
+ */
234
+ export async function leaveRelay(node: Libp2p, relayAddr: string): Promise<void> {
235
+ const relayPeerId = lastPeerIdOf(multiaddr(relayAddr));
236
+ if (relayPeerId != null) await node.hangUp(peerIdFromString(relayPeerId));
237
+ }
238
+
239
+ export interface SuperviseHubReservationInit {
240
+ node: Libp2p;
241
+ /** The public relay the hub is reached through -- `reachHub`'s. */
242
+ relayAddr: string;
243
+ hubPeerId: string;
244
+ minRetryDelayMs?: number;
245
+ maxRetryDelayMs?: number;
246
+ }
247
+
248
+ /**
249
+ * Keep this member's reservation on its hub: whenever it is lost, reach the
250
+ * hub again and re-reserve, with `superviseRelay`'s backoff and `poke()`.
251
+ * Call once the first `reserveOnHub` has succeeded.
252
+ */
253
+ export function superviseHubReservation(init: SuperviseHubReservationInit): RelaySupervisor {
254
+ const { node, relayAddr, hubPeerId } = init;
255
+ return superviseRelay({
256
+ node,
257
+ relayAddr: `/p2p/${hubPeerId}`,
258
+ minRetryDelayMs: init.minRetryDelayMs,
259
+ maxRetryDelayMs: init.maxRetryDelayMs,
260
+ restore: async () => {
261
+ await reachHub(node, relayAddr, hubPeerId);
262
+ await reserveOnHub(node, hubPeerId);
263
+ },
264
+ });
265
+ }
266
+
267
+ /**
268
+ * The node's transport manager -- the only way to add a listen address after
269
+ * start. `components` is a public field of the libp2p 3.x node class but not
270
+ * part of the `Libp2p` interface, hence the narrow cast; pinned by
271
+ * `tests/e2e/hub-relay.test.ts`, which fails if a libp2p upgrade moves it.
272
+ */
273
+ function transportManagerOf(node: Libp2p): {
274
+ listen(addrs: ReturnType<typeof multiaddr>[]): Promise<void>;
275
+ } {
276
+ return (
277
+ node as unknown as {
278
+ components: { transportManager: { listen(addrs: unknown[]): Promise<void> } };
279
+ }
280
+ ).components.transportManager;
281
+ }
@@ -0,0 +1,188 @@
1
+ /**
2
+ * The hub as a relay for its own members — signalling only, members only.
3
+ *
4
+ * WHY A HUB RELAYS AT ALL. Members reach each other through their hub rather
5
+ * than through the public relay: the hub is the one peer every member is
6
+ * already connected to and heartbeating against, so it can carry the few
7
+ * kilobytes of WebRTC offer/answer between two members, after which they talk
8
+ * directly. The public relay is then needed only to reach the hub itself.
9
+ *
10
+ * MEMBERS ONLY. `membershipGater` refuses a reservation from a non-member and
11
+ * refuses to relay unless BOTH ends are members. A non-member can still
12
+ * *connect* to a hub — it must, to redeem an invitation — it simply cannot use
13
+ * the hub to reach anyone else. The public relay, by contrast, connects anyone
14
+ * to any reserved peer whose id they know.
15
+ *
16
+ * SIGNALLING ONLY, ENFORCED RATHER THAN HOPED FOR. `hubRelayService` keeps
17
+ * libp2p's default per-circuit limits (128 KiB, 2 min), sized for an SDP
18
+ * exchange. A circuit carrying limits is a LIMITED connection, and libp2p
19
+ * refuses to open an application protocol on one — so application data cannot
20
+ * cross a hub by accident. When two members cannot connect directly, WebRTC
21
+ * falls back to TURN, never to the hub.
22
+ *
23
+ * Opening a protocol with `runOnLimitedConnection` would bypass that, and the
24
+ * prototype measured the result: a 1 MiB transfer silently cut at 112 KiB.
25
+ * **Nothing may set it for application traffic.**
26
+ *
27
+ * HOW MANY, AND FOR HOW LONG -- a different question from how much. The data
28
+ * limits above bound what one CIRCUIT may carry. The reservation store bounds
29
+ * how many members may be REACHABLE through the hub at once, and libp2p's
30
+ * default for it (15, each held for two hours, kept after its holder hangs
31
+ * up) is sized for a public relay, not for a hub. A hub on that default
32
+ * filled after fifteen distinct peers in two hours -- test runs, revoked
33
+ * members, a member whose page was reopened -- and then refused every member
34
+ * with `RESERVATION_REFUSED`, which a member reports as a failed join. So:
35
+ *
36
+ * - the store is sized for a mesh (`HUB_MAX_RESERVATIONS`), which is safe
37
+ * because only members can take a slot;
38
+ * - a reservation is released when its holder's last connection closes
39
+ * (it is useless from then on: libp2p relays only to a CONNECTED
40
+ * reservation holder, and a member re-reserves when it comes back);
41
+ * - `releaseReservation` lets the hub release a REVOKED member's
42
+ * reservation at once, rather than leave the slot held until expiry.
43
+ */
44
+
45
+ import {
46
+ type CircuitRelayServerComponents,
47
+ type CircuitRelayService,
48
+ circuitRelayServer,
49
+ } from "@libp2p/circuit-relay-v2";
50
+ import type { ConnectionGater, Libp2pEvents, PeerId, TypedEventTarget } from "@libp2p/interface";
51
+ import { peerIdFromString } from "@libp2p/peer-id";
52
+ import type { PeerIdStr } from "@statewalker/httpeers-core";
53
+
54
+ /** Is this peer a member of our mesh? Read LIVE — see `membershipGater`. */
55
+ export type IsMember = (peerId: PeerIdStr) => boolean;
56
+
57
+ /**
58
+ * A gater that admits only members to the relay.
59
+ *
60
+ * TAKES A THUNK, and that is not a style preference — it breaks a genuine
61
+ * ordering cycle. The gater is needed to CREATE the node; the answer to "is
62
+ * this peer a member" comes from the hub's member store; and the hub cannot be
63
+ * built until the node exists. Passing `isMember` directly forces a caller to
64
+ * resolve that cycle themselves, and the prototype's two profiles each did it
65
+ * differently.
66
+ *
67
+ * A thunk defers the lookup to the moment a decision is actually needed, by
68
+ * which time the hub exists. It also means a revocation takes effect on the
69
+ * next relay request rather than at the next restart, because nothing is
70
+ * captured.
71
+ */
72
+ export function membershipGater(isMember: () => IsMember): ConnectionGater {
73
+ return {
74
+ denyInboundRelayReservation: async (source: PeerId) => !isMember()(source.toString()),
75
+ denyOutboundRelayedConnection: async (source: PeerId, destination: PeerId) => {
76
+ const member = isMember();
77
+ // BOTH ends. A member must not be able to use the hub to reach a
78
+ // stranger any more than a stranger may use it to reach a member.
79
+ return !member(source.toString()) || !member(destination.toString());
80
+ },
81
+ };
82
+ }
83
+
84
+ /**
85
+ * How many members may hold a reservation on a hub at once: one per member
86
+ * currently connected to it.
87
+ *
88
+ * WHY THIS NUMBER. It is a ceiling, not an allocation -- an empty slot costs
89
+ * nothing, and a held one is a map entry of a few hundred bytes, so 4096 of
90
+ * them is about a megabyte. Only members can take a slot (`membershipGater`),
91
+ * and a slot is given back when its holder disconnects, so what this really
92
+ * bounds is simultaneously connected members; a hub serving thousands of
93
+ * members at once has other limits to meet first (libp2p's connection
94
+ * manager, the hub's own CPU). libp2p's default, 15, is sized for a public
95
+ * relay that anyone can fill, and is the value that broke a real hub.
96
+ *
97
+ * NOT RAISED TO "UNLIMITED", because the gater is the only thing between a
98
+ * large member list and this map; a finite ceiling keeps a runaway (a bug
99
+ * that mints members, say) from growing it without bound.
100
+ */
101
+ export const HUB_MAX_RESERVATIONS = 4096;
102
+
103
+ export interface HubRelayServiceInit {
104
+ /**
105
+ * The reservation store's size (default `HUB_MAX_RESERVATIONS`). How MANY
106
+ * members can be reached through the hub -- not how much data may cross it,
107
+ * which stays at libp2p's signalling-sized default whatever this is.
108
+ */
109
+ maxReservations?: number;
110
+ }
111
+
112
+ /**
113
+ * What the hub relay needs from the node: the relay server's own components,
114
+ * plus the node's event bus, to hear a holder hang up. Both are standard
115
+ * libp2p components; `createLibp2p` hands every service all of them.
116
+ */
117
+ export type HubRelayComponents = CircuitRelayServerComponents & {
118
+ events: TypedEventTarget<Libp2pEvents>;
119
+ };
120
+
121
+ /**
122
+ * The circuit-relay server a hub runs for its members.
123
+ *
124
+ * The default DATA limits are stated explicitly rather than left implicit, so
125
+ * that nobody raises them to "make it smoother": they are the thing that keeps
126
+ * a hub a signalling channel instead of a data path. The store's SIZE is a
127
+ * separate setting (`init.maxReservations`) and changes none of that.
128
+ *
129
+ * RELEASES ON DISCONNECT. libp2p keeps a reservation for its full TTL after
130
+ * the holder is gone. This listens for `peer:disconnect` -- which libp2p
131
+ * fires when a peer's LAST connection closes -- and releases the reservation
132
+ * then, rechecking that no connection came back in the meantime.
133
+ */
134
+ export function hubRelayService(
135
+ init: HubRelayServiceInit = {},
136
+ ): (components: HubRelayComponents) => CircuitRelayService {
137
+ const createServer = circuitRelayServer({
138
+ reservations: {
139
+ applyDefaultLimit: true,
140
+ maxReservations: init.maxReservations ?? HUB_MAX_RESERVATIONS,
141
+ },
142
+ });
143
+ return (components) => {
144
+ const relay = createServer(components);
145
+ components.events.addEventListener("peer:disconnect", (event) => {
146
+ const peer = event.detail;
147
+ if (components.connectionManager.getConnections(peer).length > 0) return;
148
+ releaseReservation(relay, peer);
149
+ });
150
+ return relay;
151
+ };
152
+ }
153
+
154
+ /**
155
+ * Release a peer's reservation on this hub's relay, now. Returns whether there
156
+ * was one. The daemon calls it when it revokes a member; the membership gater
157
+ * already refuses that peer a NEW reservation, and this frees the one it held.
158
+ *
159
+ * THROUGH THE SERVICE'S PUBLIC SURFACE. libp2p offers no "remove reservation"
160
+ * call, but `CircuitRelayService.reservations` is a public map and each entry
161
+ * carries its expiry `signal`. Clearing that signal before removing the entry
162
+ * matters: the store's own expiry listener deletes by PEER, so a timer left
163
+ * running would, two hours later, delete whatever reservation the same peer
164
+ * holds by then.
165
+ *
166
+ * A STRING THAT IS NOT A PEER ID holds no reservation, so it returns false
167
+ * rather than throwing: a revocation must not fail half-way over it.
168
+ */
169
+ export function releaseReservation(
170
+ relay: Pick<CircuitRelayService, "reservations">,
171
+ peer: PeerId | PeerIdStr,
172
+ ): boolean {
173
+ const peerId = typeof peer === "string" ? parsePeerId(peer) : peer;
174
+ if (peerId == null) return false;
175
+ const reservation = relay.reservations.get(peerId);
176
+ if (reservation == null) return false;
177
+ reservation.signal.clear();
178
+ relay.reservations.delete(peerId);
179
+ return true;
180
+ }
181
+
182
+ function parsePeerId(peer: PeerIdStr): PeerId | undefined {
183
+ try {
184
+ return peerIdFromString(peer);
185
+ } catch {
186
+ return undefined;
187
+ }
188
+ }
@@ -0,0 +1,155 @@
1
+ /**
2
+ * Identity, before a node exists.
3
+ *
4
+ * WHY THIS IS ITS OWN MODULE. Every process and every page in the mesh needs a
5
+ * peerId *before* it can start a node: a hub's peerId is the mesh identity that
6
+ * every token's `mesh` claim restates, a relay's is baked into the multiaddr
7
+ * its members dial, and a member's is what the hub records. The prototype's
8
+ * three assemblies each derived one their own way, and revision 1 of the
9
+ * extraction exported none of it.
10
+ *
11
+ * ED25519 ONLY, and never branching on key type. The reason is the one
12
+ * `httpeers-access` is built on: an Ed25519 peerId inlines its own public key,
13
+ * so verification never needs a fetch. A key of any other type would produce a
14
+ * peerId that cannot self-certify, and every token issued under it would be
15
+ * unverifiable — so `decodeKey` refuses one rather than carrying it forward.
16
+ *
17
+ * IDEMPOTENCE IS THE WHOLE POINT of `identityStore`. A store that quietly
18
+ * re-generated a key on a second read would silently re-found the mesh: every
19
+ * token already issued stops verifying, every policy naming the issuer goes
20
+ * stale, and nothing reports an error. There is no path in `loadOrCreate` that
21
+ * writes when a key is already present.
22
+ */
23
+
24
+ import {
25
+ generateKeyPair,
26
+ generateKeyPairFromSeed,
27
+ privateKeyFromProtobuf,
28
+ privateKeyToProtobuf,
29
+ } from "@libp2p/crypto/keys";
30
+ import type { Ed25519PrivateKey, Ed25519PublicKey } from "@libp2p/interface";
31
+ import { peerIdFromPrivateKey, peerIdFromPublicKey } from "@libp2p/peer-id";
32
+ import type { PeerIdStr } from "@statewalker/httpeers-core";
33
+
34
+ /** `generateKeyPairFromSeed("Ed25519", seed)` requires exactly this many bytes. */
35
+ const SEED_BYTES = 32;
36
+
37
+ export interface GenerateKeyInit {
38
+ /**
39
+ * Derive the key deterministically instead of drawing from the CSPRNG, so a
40
+ * test or a CI run can name a peerId as a constant. Exactly 32 bytes.
41
+ */
42
+ seed?: Uint8Array;
43
+ }
44
+
45
+ export async function generateKey(init: GenerateKeyInit = {}): Promise<Ed25519PrivateKey> {
46
+ if (init.seed === undefined) return generateKeyPair("Ed25519");
47
+ if (init.seed.length !== SEED_BYTES) {
48
+ throw new TypeError(
49
+ `generateKey: a seed must be exactly ${SEED_BYTES} bytes, got ${init.seed.length}. ` +
50
+ "Hash a passphrase to 32 bytes rather than passing it directly.",
51
+ );
52
+ }
53
+ return generateKeyPairFromSeed("Ed25519", init.seed);
54
+ }
55
+
56
+ /** The peerId a key identifies as — the mesh id, for a hub's key. */
57
+ export function peerIdOf(key: Ed25519PrivateKey | Ed25519PublicKey): PeerIdStr {
58
+ return key.type === "Ed25519" && "publicKey" in key
59
+ ? peerIdFromPrivateKey(key).toString()
60
+ : peerIdFromPublicKey(key as Ed25519PublicKey).toString();
61
+ }
62
+
63
+ /**
64
+ * The libp2p protobuf encoding — the format both platforms already persist and
65
+ * the relay already reads back. Not a format chosen here; see the prototype's
66
+ * `setup/keys.ts` for the contract it exists to satisfy.
67
+ */
68
+ export function encodeKey(key: Ed25519PrivateKey): Uint8Array {
69
+ return privateKeyToProtobuf(key);
70
+ }
71
+
72
+ /** Decode a stored key, refusing anything that is not Ed25519 **and saying what it was**. */
73
+ export function decodeKey(bytes: Uint8Array): Ed25519PrivateKey {
74
+ const key = privateKeyFromProtobuf(bytes);
75
+ if (key.type !== "Ed25519") {
76
+ throw new TypeError(
77
+ `decodeKey: expected an Ed25519 key, got ${key.type}. Only Ed25519 peerIds carry their ` +
78
+ "own public key, which is what lets a token be verified without fetching anything.",
79
+ );
80
+ }
81
+ return key;
82
+ }
83
+
84
+ /**
85
+ * The bridge to `@statewalker/httpeers-access`.
86
+ *
87
+ * Declared STRUCTURALLY rather than imported, so this package does not depend
88
+ * on `httpeers-access` — the dependency graph runs core → access and
89
+ * core → libp2p, and the two never point at each other. The shape is
90
+ * `access`'s `Signer`, and a mismatch would be a compile error at whichever
91
+ * assembly wires them together, which is where it belongs.
92
+ */
93
+ export interface SignerLike {
94
+ readonly mesh: string;
95
+ readonly seed: Uint8Array;
96
+ }
97
+
98
+ /**
99
+ * A key, as something that can mint.
100
+ *
101
+ * A libp2p Ed25519 private key's `raw` is the 64-byte expanded form — the
102
+ * 32-byte seed followed by the public key — and Biscuit signs from the seed
103
+ * alone.
104
+ */
105
+ export function signerOf(key: Ed25519PrivateKey): SignerLike {
106
+ return { mesh: peerIdOf(key), seed: key.raw.slice(0, SEED_BYTES) };
107
+ }
108
+
109
+ /** Somewhere bytes live. A file on a server, IndexedDB in a page. */
110
+ export interface BytesStore {
111
+ get(key: string): Promise<Uint8Array | undefined>;
112
+ set(key: string, value: Uint8Array): Promise<void>;
113
+ delete(key: string): Promise<void>;
114
+ }
115
+
116
+ export interface IdentityStoreInit {
117
+ backend: BytesStore;
118
+ /** Which entry in the backend holds it. Defaults to `"identity"`. */
119
+ key?: string;
120
+ }
121
+
122
+ export interface IdentityStore {
123
+ /** The stored key, or `null`. Never generates. */
124
+ read(): Promise<Ed25519PrivateKey | null>;
125
+ /** The stored key, generating and persisting one only if there is none. */
126
+ loadOrCreate(): Promise<Ed25519PrivateKey>;
127
+ /** Forget it. The next `loadOrCreate` founds a NEW identity — see the module comment. */
128
+ clear(): Promise<void>;
129
+ }
130
+
131
+ export function identityStore(init: IdentityStoreInit): IdentityStore {
132
+ const entry = init.key ?? "identity";
133
+
134
+ const read = async (): Promise<Ed25519PrivateKey | null> => {
135
+ const bytes = await init.backend.get(entry);
136
+ return bytes === undefined ? null : decodeKey(bytes);
137
+ };
138
+
139
+ return {
140
+ read,
141
+ async loadOrCreate() {
142
+ const existing = await read();
143
+ // The whole point: a key that exists is READ, never regenerated and
144
+ // never overwritten. There is no write on this branch.
145
+ if (existing !== null) return existing;
146
+
147
+ const created = await generateKey();
148
+ await init.backend.set(entry, encodeKey(created));
149
+ return created;
150
+ },
151
+ async clear() {
152
+ await init.backend.delete(entry);
153
+ },
154
+ };
155
+ }