@statewalker/httpeers-member 0.1.4

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 (95) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +203 -0
  3. package/dist/browser-platform.d.ts +30 -0
  4. package/dist/browser-platform.d.ts.map +1 -0
  5. package/dist/browser-platform.js +30 -0
  6. package/dist/browser-profile.d.ts +85 -0
  7. package/dist/browser-profile.d.ts.map +1 -0
  8. package/dist/browser-profile.js +120 -0
  9. package/dist/browser.d.ts +48 -0
  10. package/dist/browser.d.ts.map +1 -0
  11. package/dist/browser.js +51 -0
  12. package/dist/connection-kind.d.ts +36 -0
  13. package/dist/connection-kind.d.ts.map +1 -0
  14. package/dist/connection-kind.js +52 -0
  15. package/dist/describe-error.d.ts +18 -0
  16. package/dist/describe-error.d.ts.map +1 -0
  17. package/dist/describe-error.js +72 -0
  18. package/dist/edge-control.d.ts +50 -0
  19. package/dist/edge-control.d.ts.map +1 -0
  20. package/dist/edge-control.js +69 -0
  21. package/dist/edge-dispatch.d.ts +188 -0
  22. package/dist/edge-dispatch.d.ts.map +1 -0
  23. package/dist/edge-dispatch.js +142 -0
  24. package/dist/edge-guard.d.ts +45 -0
  25. package/dist/edge-guard.d.ts.map +1 -0
  26. package/dist/edge-guard.js +62 -0
  27. package/dist/edge.d.ts +97 -0
  28. package/dist/edge.d.ts.map +1 -0
  29. package/dist/edge.js +104 -0
  30. package/dist/gateway.d.ts +49 -0
  31. package/dist/gateway.d.ts.map +1 -0
  32. package/dist/gateway.js +102 -0
  33. package/dist/identity.d.ts +78 -0
  34. package/dist/identity.d.ts.map +1 -0
  35. package/dist/identity.js +117 -0
  36. package/dist/index.d.ts +39 -0
  37. package/dist/index.d.ts.map +1 -0
  38. package/dist/index.js +37 -0
  39. package/dist/join-blob.d.ts +125 -0
  40. package/dist/join-blob.d.ts.map +1 -0
  41. package/dist/join-blob.js +195 -0
  42. package/dist/join.d.ts +343 -0
  43. package/dist/join.d.ts.map +1 -0
  44. package/dist/join.js +409 -0
  45. package/dist/kv.d.ts +20 -0
  46. package/dist/kv.d.ts.map +1 -0
  47. package/dist/kv.js +10 -0
  48. package/dist/mesh-config.d.ts +26 -0
  49. package/dist/mesh-config.d.ts.map +1 -0
  50. package/dist/mesh-config.js +39 -0
  51. package/dist/mesh-memory.d.ts +55 -0
  52. package/dist/mesh-memory.d.ts.map +1 -0
  53. package/dist/mesh-memory.js +74 -0
  54. package/dist/node.d.ts +29 -0
  55. package/dist/node.d.ts.map +1 -0
  56. package/dist/node.js +42 -0
  57. package/dist/page-wake.d.ts +24 -0
  58. package/dist/page-wake.d.ts.map +1 -0
  59. package/dist/page-wake.js +31 -0
  60. package/dist/peer-request.d.ts +29 -0
  61. package/dist/peer-request.d.ts.map +1 -0
  62. package/dist/peer-request.js +11 -0
  63. package/dist/reset.d.ts +56 -0
  64. package/dist/reset.d.ts.map +1 -0
  65. package/dist/reset.js +103 -0
  66. package/dist/session.d.ts +214 -0
  67. package/dist/session.d.ts.map +1 -0
  68. package/dist/session.js +403 -0
  69. package/dist/start-member.d.ts +176 -0
  70. package/dist/start-member.d.ts.map +1 -0
  71. package/dist/start-member.js +307 -0
  72. package/package.json +120 -0
  73. package/src/browser-platform.ts +53 -0
  74. package/src/browser-profile.ts +179 -0
  75. package/src/browser.ts +103 -0
  76. package/src/connection-kind.ts +53 -0
  77. package/src/describe-error.ts +76 -0
  78. package/src/edge-control.ts +94 -0
  79. package/src/edge-dispatch.ts +295 -0
  80. package/src/edge-guard.ts +67 -0
  81. package/src/edge.ts +175 -0
  82. package/src/gateway.ts +141 -0
  83. package/src/identity.ts +146 -0
  84. package/src/index.ts +95 -0
  85. package/src/join-blob.ts +229 -0
  86. package/src/join.ts +632 -0
  87. package/src/kv.ts +21 -0
  88. package/src/mesh-config.ts +41 -0
  89. package/src/mesh-memory.ts +104 -0
  90. package/src/node.ts +49 -0
  91. package/src/page-wake.ts +59 -0
  92. package/src/peer-request.ts +39 -0
  93. package/src/reset.ts +127 -0
  94. package/src/session.ts +579 -0
  95. package/src/start-member.ts +476 -0
package/src/join.ts ADDED
@@ -0,0 +1,632 @@
1
+ /**
2
+ * Joining the mesh: the `/webrtc` pre-dial, invitation redemption, the
3
+ * membership RESUME probe, and the two timers this runtime owns itself (the
4
+ * third -- circuit-relay's own reservation refresh -- is libp2p-managed;
5
+ * there is no code for it here, on purpose, see `startJoin`'s doc comment).
6
+ *
7
+ * THERE ARE TWO WAYS INTO A MESH, NOT ONE (Task 28). `redeemInvitation` is
8
+ * the first join and can only ever happen once per invitation -- the id is
9
+ * moved to `spentInvitationIds` on redemption and the spent check runs
10
+ * first, unconditionally (`../hub/hub-state.ts`). A page that persists its
11
+ * identity and reloads is therefore NOT making a first join: it is already
12
+ * a member, and redeeming again would fail `already-redeemed` forever.
13
+ * `resumeMembership` below is the other way in, and it needs no new hub
14
+ * route because the hub already has one that answers exactly this question
15
+ * -- see that function.
16
+ */
17
+
18
+ import type { Libp2p, PeerId } from "@libp2p/interface";
19
+ import { peerIdFromString } from "@libp2p/peer-id";
20
+ import { multiaddr } from "@multiformats/multiaddr";
21
+ import type { ChangeEntry, RevocationCache, RuleSet } from "@statewalker/httpeers-access";
22
+ import type { MeshView, PeerIdStr } from "@statewalker/httpeers-core";
23
+ import type { Peer } from "@statewalker/httpeers-libp2p";
24
+ import { hubRoute, reachHub } from "@statewalker/httpeers-libp2p";
25
+ import { peerRequest } from "./peer-request.js";
26
+
27
+ /**
28
+ * Dial `${relayAddr}/p2p-circuit/webrtc/p2p/${peerId}` explicitly, BEFORE
29
+ * opening any protocol against `peerId` (design note's Step 2; applies to
30
+ * the hub exactly as it does to any other peer -- spec §7, the hub
31
+ * declares no listen address of its own).
32
+ *
33
+ * WHY THIS IS NOT OPTIONAL. libp2p's auto-dial may already hold a
34
+ * relay-only LIMITED connection to `peerId` -- reachable, but not upgraded
35
+ * to WebRTC -- from nothing more than address exchange via `identify`.
36
+ * libp2p SILENTLY refuses to open a custom protocol (`/httpeers/1.0.0`)
37
+ * over a limited connection: the dial simply never completes, with no
38
+ * error naming a limited connection as the cause. Explicitly dialling the
39
+ * full `/webrtc/p2p/...` address forces the WebRTC upgrade before any
40
+ * `peer.call` is attempted, so that failure mode never has a chance to
41
+ * occur.
42
+ */
43
+ export async function preDialPeer(
44
+ node: Libp2p,
45
+ relayAddr: string,
46
+ peerId: PeerIdStr,
47
+ ): Promise<void> {
48
+ const target = multiaddr(`${relayAddr}/p2p-circuit/webrtc/p2p/${peerId}`);
49
+ await node.dial(target);
50
+ }
51
+
52
+ export interface RouteEnsurerInit {
53
+ node: Libp2p;
54
+ /** `httpeers.json`'s `relayAddrs[0]` -- the fallback address composer, and the same string `preDialPeer` takes. */
55
+ relayAddr: string;
56
+ /** This peer's own id. A request addressed to it is served locally and must never be dialed. */
57
+ selfPeerId: PeerIdStr;
58
+ /** The current mesh view -- `JoinHandle.meshView`. Read per call, never snapshotted: a provider's addresses change when it re-reserves. */
59
+ meshView: () => MeshView | null;
60
+ /**
61
+ * This mesh's hub. When given -- on a member page -- every other member is
62
+ * reached THROUGH it (`../hub-link.ts`'s `hubRoute`), and what members
63
+ * advertise is ignored: since members reserve on their hub rather than on
64
+ * the public relay, their own addresses are double-circuit ones nothing
65
+ * can dial. Omitted on the hub page, which cannot relay through itself
66
+ * and reaches its members over the connections they already hold to it.
67
+ */
68
+ hubPeerId?: PeerIdStr;
69
+ }
70
+
71
+ /**
72
+ * "Make sure this peer can be called" -- the function
73
+ * `../browser/edge-dispatch.ts`'s `ensureRoute` hook wants, and the piece
74
+ * that was missing for browser-to-browser calls entirely (Task 15; see that
75
+ * module's job 4 for the symptom and the diagnosis).
76
+ *
77
+ * ADDRESSES COME FROM THE MESH VIEW, WHICH IS THE CONTRACT
78
+ * `../browser/peer-runtime.ts`'s module comment already stated and nothing
79
+ * had yet had to honour: the hub builds each member's `addrs` from that
80
+ * member's own most recent heartbeat, so it is the freshest source there
81
+ * is, and libp2p's peerStore -- what `remote()` falls back on when it dials
82
+ * by bare peer id -- is exactly the stale/absent one that comment warns
83
+ * about.
84
+ *
85
+ * ONLY THE `/webrtc` ENTRIES ARE DIALED. The same view also carries bare
86
+ * `/p2p-circuit` addresses for every member; connecting over one of those
87
+ * produces a LIMITED connection, on which libp2p silently refuses to open
88
+ * `/httpeers/1.0.0` (`preDialPeer`'s own comment, and Task 14's test that
89
+ * pins the refusal). Dialing them would therefore "succeed" and leave the
90
+ * call to fail anyway.
91
+ *
92
+ * THE COMPOSED ADDRESS IS THE FALLBACK, NOT THE PRIMARY. `preDialPeer`'s
93
+ * `<relay>/p2p-circuit/webrtc/p2p/<peer>` is right whenever every peer
94
+ * reserves on the one relay `httpeers.json` names -- true of this
95
+ * deployment, and how the hub itself is reached at startup, since the hub
96
+ * is not a mesh MEMBER and so never appears in the view at all. It is the
97
+ * fallback rather than the rule because a peer that reserved on a different
98
+ * relay is reachable only at the address it reported.
99
+ *
100
+ * THE HUB ITSELF IS NEVER DIALED HERE. `hubRoute(hub, hub)` routes the hub
101
+ * through itself, which cannot work; and a member on the relay fallback holds
102
+ * only a limited circuit to the hub, so without this every edge call to the
103
+ * hub would pay a doomed dial -- up to libp2p's dial timeout -- first. The
104
+ * hub link is `startMember`'s and the keepalive's to keep.
105
+ *
106
+ * AN EXISTING UNLIMITED CONNECTION SHORT-CIRCUITS. This runs on every
107
+ * outbound mesh call, including every `<img src>` in a gallery; re-dialing
108
+ * a peer that is already connected would add a round trip per image.
109
+ */
110
+ export function createRouteEnsurer(init: RouteEnsurerInit): (peerId: PeerIdStr) => Promise<void> {
111
+ const { node, relayAddr, selfPeerId } = init;
112
+
113
+ return async function ensureRoute(peerId: PeerIdStr): Promise<void> {
114
+ if (peerId === selfPeerId || peerId === init.hubPeerId) return;
115
+
116
+ let target: PeerId;
117
+ try {
118
+ target = peerIdFromString(peerId);
119
+ } catch {
120
+ // Not a peer id after all (the shape test in `edge-dispatch.ts` is a
121
+ // regex, not a decoder). Nothing to dial; let the router decide what
122
+ // this path means.
123
+ return;
124
+ }
125
+
126
+ if (node.getConnections(target).some((conn) => conn.limits == null)) return;
127
+
128
+ if (init.hubPeerId != null && init.hubPeerId !== selfPeerId) {
129
+ await node.dial(multiaddr(hubRoute(init.hubPeerId, peerId)));
130
+ return;
131
+ }
132
+
133
+ const advertised = (init.meshView()?.members.find((m) => m.peerId === peerId)?.addrs ?? [])
134
+ .filter((addr) => addr.includes("/p2p-circuit") && addr.includes("/webrtc"))
135
+ .map((addr) => multiaddr(addr));
136
+
137
+ // Every candidate in ONE dial, not a loop: libp2p ranks and races the
138
+ // addresses of a single peer itself, and a sequential loop would pay the
139
+ // full dial timeout for each unreachable interface address the provider
140
+ // reported (a browser peer behind a relay routinely reports several).
141
+ if (advertised.length > 0) await node.dial(advertised);
142
+ else await preDialPeer(node, relayAddr, peerId);
143
+ };
144
+ }
145
+
146
+ interface InviteRedeemResponse {
147
+ token: string;
148
+ mesh: string;
149
+ roles: string[];
150
+ }
151
+
152
+ /** Redeem `invitationId` against the hub -- `POST /.well-known/invite`, `../hub/endpoints.ts`'s bootstrap handler. No token exists yet: this call is proven by the transport handshake alone. */
153
+ export async function redeemInvitation(
154
+ peer: Peer,
155
+ hubPeerId: PeerIdStr,
156
+ invitationId: string,
157
+ ): Promise<InviteRedeemResponse> {
158
+ const res = await peer.call(
159
+ hubPeerId,
160
+ peerRequest("/.well-known/invite", {
161
+ method: "POST",
162
+ headers: { "content-type": "application/json" },
163
+ body: JSON.stringify({ id: invitationId }),
164
+ }),
165
+ );
166
+ if (!res.ok) {
167
+ const body = await res.text().catch(() => "");
168
+ throw new Error(
169
+ `join: invitation redemption failed (${res.status} ${res.statusText}): ${body}`,
170
+ );
171
+ }
172
+ return (await res.json()) as InviteRedeemResponse;
173
+ }
174
+
175
+ /** This peer's own advertisement, as carried on the heartbeat body -- `../hub/endpoints.ts`'s `PresenceBody.advertisements`. */
176
+ export interface AdvertisementInput {
177
+ id: string;
178
+ kind: string;
179
+ title: string;
180
+ }
181
+
182
+ /**
183
+ * Why a presence write was refused, named rather than left as a status
184
+ * code. Three outcomes, and they call for three different things on screen.
185
+ *
186
+ * `not-a-member` -- HTTP 403, `../hub/endpoints.ts`'s presence handler
187
+ * finding no `MemberRecord` for the transport-proven peer. It means one of:
188
+ * this identity has never joined this mesh, its membership was revoked
189
+ * (`/admin/members/{peerId}`), or the hub's own state was reset. All three
190
+ * are "this hub does not know you"; an invitation is the way back in.
191
+ * NOTE that a REVOKED member also lands here rather than on a token check:
192
+ * `usesTransportIdentity()` makes a presence POST a bootstrap request, so
193
+ * the token it carries is never examined and the revocation cache is never
194
+ * consulted -- membership itself is what the handler looks up.
195
+ *
196
+ * `duplicate-identity` -- HTTP 409 `stale-sequence`, and it is the one
197
+ * genuinely surprising diagnosis in this file, so here is why it is sound.
198
+ * The hub tracks the highest `seq` it ever accepted per peer and refuses
199
+ * anything at or below it (`lastSeqByPeer`, which the TTL sweep
200
+ * deliberately does not clear). This runtime's own sequence numbers are
201
+ * seeded from the wall clock (`nextInitialSeq`) and only ever increase, so
202
+ * NOTHING THIS PAGE HAS EVER SENT can be at or below the number it is
203
+ * sending now -- not an earlier beat, not an earlier page load, not a
204
+ * delayed duplicate of either. A 409 therefore means some OTHER live node
205
+ * posted a heartbeat under this peerId, which is to say two libp2p nodes
206
+ * are running one identity: two tabs of the same origin is the ordinary
207
+ * way to get there. That is a real failure (two nodes racing to be "the"
208
+ * peer for one id -- `./identity.ts`'s own note) and not a cosmetic one, so
209
+ * it is reported rather than retried into.
210
+ *
211
+ * `refused` -- anything else, carried verbatim for the page to render.
212
+ */
213
+ export type PresenceRefusal =
214
+ | { kind: "not-a-member"; status: number; message: string }
215
+ | { kind: "duplicate-identity"; status: number; message: string }
216
+ | { kind: "refused"; status: number; message: string };
217
+
218
+ /** Turn a refused presence response into one of the three cases above. Pure, so a test can pin the mapping without a hub. */
219
+ export function classifyPresenceRefusal(status: number, body: string): PresenceRefusal {
220
+ if (status === 403) {
221
+ return {
222
+ kind: "not-a-member",
223
+ status,
224
+ message: "this hub does not list this peer as a member",
225
+ };
226
+ }
227
+ if (status === 409) {
228
+ return {
229
+ kind: "duplicate-identity",
230
+ status,
231
+ message: "the hub has already accepted a newer heartbeat for this peer id",
232
+ };
233
+ }
234
+ return { kind: "refused", status, message: body === "" ? `HTTP ${status}` : body };
235
+ }
236
+
237
+ /**
238
+ * The sequence number a fresh run starts from: THE WALL CLOCK, not zero.
239
+ *
240
+ * THIS IS NOT A STYLE CHOICE, IT IS WHAT MAKES RESUMING POSSIBLE AT ALL.
241
+ * The hub keeps `lastSeqByPeer` for the life of the process and refuses any
242
+ * presence write at or below it (`../hub/endpoints.ts`). A page that
243
+ * persists its identity and starts counting from 1 again after a reload
244
+ * would be refused `stale-sequence` on every beat until it had climbed back
245
+ * past wherever its previous run left off -- minutes of a live, joined page
246
+ * silently failing to report presence, and a mesh view in which it never
247
+ * appears. Seeding from `Date.now()` makes every run start above every
248
+ * previous run's numbers by construction (a run advances its own counter by
249
+ * 1 per beat while the clock advances by thousands), which is also exactly
250
+ * the property `PresenceRefusal`'s duplicate-identity diagnosis rests on.
251
+ *
252
+ * The hub compares numbers and nothing else -- it never reads a seq as a
253
+ * time -- so this borrows the clock's monotonicity without giving the value
254
+ * any meaning it has to keep.
255
+ */
256
+ export function nextInitialSeq(): number {
257
+ return Date.now();
258
+ }
259
+
260
+ /** What `resumeMembership` found. */
261
+ export type ResumeOutcome =
262
+ | { status: "resumed"; token: string; versions: HeartbeatVersions; ttl: number }
263
+ | { status: "refused"; refusal: PresenceRefusal };
264
+
265
+ export interface ResumeMembershipInit {
266
+ peer: Peer;
267
+ hubPeerId: PeerIdStr;
268
+ /** This peer's own current multiaddrs, exactly as a heartbeat reports them -- see `startJoin`. */
269
+ addrs: string[];
270
+ /** This peer's own advertisements. Omitted (not `[]`) when absent: the hub's handler branches on `advertisements !== undefined`, and `[]` WITHDRAWS. */
271
+ advertisements?: AdvertisementInput[];
272
+ /** This probe's sequence number -- `nextInitialSeq()`, and the number `startJoin` must then continue from. */
273
+ seq: number;
274
+ }
275
+
276
+ /**
277
+ * "Does this hub still consider me a member?" -- asked, and answered,
278
+ * WITHOUT A TOKEN, and answered by an endpoint that already exists.
279
+ *
280
+ * THE ORDERING TRAP THIS WALKS AROUND. Proving membership normally needs a
281
+ * token, and a token is what joining produces; a page resuming a membership
282
+ * has neither, and every ordinary hub read (`/.well-known/mesh` included)
283
+ * is gated on claims it cannot yet present. But `POST
284
+ * /.well-known/presence` is one of exactly two BOOTSTRAP routes
285
+ * (`usesTransportIdentity()` in `../hub/endpoints.ts`): `httpeers.core`'s
286
+ * binding middleware sees it, skips the token check and the access tree
287
+ * entirely, and hands the handler the peer id the libp2p handshake itself
288
+ * proved. That handler then looks the peer up in the member store and
289
+ * either mints a fresh token or answers 403. Which is precisely the
290
+ * question, precisely the proof, and precisely the credential -- so this
291
+ * needs no new route, and adding one would have been adding a second,
292
+ * weaker answer to a question the hub already answers.
293
+ *
294
+ * IT IS ALSO THE FIRST HEARTBEAT, NOT A PROBE BESIDE ONE. The body is an
295
+ * ordinary presence write, so a successful resume has already reported this
296
+ * peer's addresses and advertisements and is already visible in the mesh
297
+ * view -- there is no window where the page is "resumed" but absent. Its
298
+ * `seq` is the one `startJoin` continues from (`JoinInit.initialSeq`).
299
+ *
300
+ * Transport failures are NOT caught here: a call that never reached the hub
301
+ * is a different fact from a hub that answered, and only the caller knows
302
+ * what to say about it (`./peer-runtime.ts` does).
303
+ */
304
+ export async function resumeMembership(init: ResumeMembershipInit): Promise<ResumeOutcome> {
305
+ const res = await init.peer.call(
306
+ init.hubPeerId,
307
+ peerRequest("/.well-known/presence", {
308
+ method: "POST",
309
+ headers: { "content-type": "application/json" },
310
+ body: JSON.stringify({
311
+ seq: init.seq,
312
+ addrs: init.addrs,
313
+ ...(init.advertisements !== undefined ? { advertisements: init.advertisements } : {}),
314
+ }),
315
+ }),
316
+ );
317
+
318
+ if (!res.ok) {
319
+ const body = await res.text().catch(() => "");
320
+ return { status: "refused", refusal: classifyPresenceRefusal(res.status, body) };
321
+ }
322
+
323
+ const parsed = (await res.json()) as PresenceHeartbeatResponse;
324
+ return { status: "resumed", token: parsed.token, versions: parsed.versions, ttl: parsed.ttl };
325
+ }
326
+
327
+ /** The three version counters a heartbeat response reports; each gates only its own section. See `startJoin`. */
328
+ export interface HeartbeatVersions {
329
+ mesh: number;
330
+ policy: number;
331
+ rules: number;
332
+ }
333
+
334
+ interface PresenceHeartbeatResponse {
335
+ token: string;
336
+ versions: HeartbeatVersions;
337
+ ttl: number;
338
+ }
339
+
340
+ interface RevocationsResponse {
341
+ version: number;
342
+ entries: ChangeEntry[];
343
+ }
344
+
345
+ /** How often the presence heartbeat fires -- design note's Step 3, 5 s. */
346
+ export const HEARTBEAT_INTERVAL_MS = 5_000;
347
+
348
+ /**
349
+ * How often the connection-keepalive timer checks the link to the hub.
350
+ * DELIBERATELY SEPARATE from `HEARTBEAT_INTERVAL_MS`: a heartbeat that
351
+ * fails to send says "the request failed," which could be the connection,
352
+ * the hub, or a revoked membership -- it does not by itself diagnose
353
+ * "the underlying libp2p connection to the hub is gone." This timer
354
+ * checks that fact directly (`node.getConnections(hubPeerId)`) and
355
+ * re-runs the `/webrtc` pre-dial when it finds nothing open, independent
356
+ * of whether a heartbeat happens to be in flight at the same moment.
357
+ * Matches the interval the validated p2p-demo prototype uses for the same
358
+ * purpose (`workspaces/webrun-wire/apps/p2p-demo/server-page/main.ts`,
359
+ * "relay link lost; re-dialing").
360
+ */
361
+ export const KEEPALIVE_INTERVAL_MS = 10_000;
362
+
363
+ /**
364
+ * How long a pulled revocation snapshot may go unconfirmed before this
365
+ * peer refuses every token rather than trust one it can no longer vouch
366
+ * for (`RevocationCache`'s own `maxStalenessMs` -- "there is a case that
367
+ * no default is correct and this should be required," `revocation.ts`).
368
+ * Generous relative to `HEARTBEAT_INTERVAL_MS` deliberately: the cache's
369
+ * freshness clock is reset on EVERY successful heartbeat (see
370
+ * `startJoin` below), not only when `versions.policy` actually moves, so
371
+ * this bound is really "how many consecutive heartbeats may fail before
372
+ * this peer stops trusting its own revocation snapshot," not "how often
373
+ * the policy must change." Twelve missed 5 s heartbeats' worth of
374
+ * tolerance before a live peer starts refusing tokens on staleness alone.
375
+ */
376
+ export const REVOCATION_MAX_STALENESS_MS = 60_000;
377
+
378
+ export interface JoinInit {
379
+ peer: Peer;
380
+ node: Libp2p;
381
+ hubPeerId: PeerIdStr;
382
+ relayAddr: string;
383
+ /** The token that got this peer in -- `redeemInvitation`'s, or `resumeMembership`'s. `startJoin` owns rotating it from here on; the heartbeat response mints a fresh one every call. */
384
+ initialToken: string;
385
+ /**
386
+ * The sequence number the FIRST heartbeat continues from -- the one
387
+ * `resumeMembership` already spent, when there was a resume. Defaults to
388
+ * `nextInitialSeq()`; see that function for why the default is the wall
389
+ * clock and not zero, and why passing the probe's own number here (rather
390
+ * than drawing a second one) is what keeps this run's sequence strictly
391
+ * increasing across the join itself.
392
+ */
393
+ initialSeq?: number;
394
+ revocationCache: RevocationCache;
395
+ /** This peer's own advertisements, read fresh on every heartbeat. Defaults to none. */
396
+ advertisements?: () => AdvertisementInput[];
397
+ heartbeatIntervalMs?: number;
398
+ keepaliveIntervalMs?: number;
399
+ /**
400
+ * How the keepalive restores a hub link it found gone. Defaults to
401
+ * `reachHub`. A member on the relay fallback passes its own -- a new kept
402
+ * circuit, and no WebRTC retry -- because `reachHub` would never bring the
403
+ * circuit back, and a retried upgrade would leave relay mode behind.
404
+ */
405
+ relinkHub?: () => Promise<void>;
406
+ /** Fired after each successful heartbeat, whether or not any version moved -- for a caller that wants to observe liveness, not just react to a version bump. */
407
+ onHeartbeat?: (versions: HeartbeatVersions) => void;
408
+ /**
409
+ * Fired when the hub REFUSED a heartbeat -- a membership that went away
410
+ * under a live page, or a second node running this identity. Both are
411
+ * things an operator has to be told rather than left to infer from a page
412
+ * that quietly stops appearing in the mesh; see `PresenceRefusal`.
413
+ * Transport failures do NOT come through here: those are the keepalive
414
+ * timer's business and the next tick retries them.
415
+ */
416
+ onPresenceRefused?: (refusal: PresenceRefusal) => void;
417
+ }
418
+
419
+ export interface JoinHandle {
420
+ /** The mesh view as of the last time `versions.mesh` moved -- `null` until the first heartbeat lands. Never fetched more often than that: re-fetching every heartbeat regardless of whether anything changed is exactly the design the version vector replaces. */
421
+ meshView(): MeshView | null;
422
+ /** The mesh's published rule set as of the last time `versions.rules` moved -- `null` until the first heartbeat lands. READ-ONLY in the strict sense (ADR-0016): what it returns influences no decision here or anywhere else; this page's own policy is its own value. */
423
+ rules(): RuleSet | null;
424
+ /**
425
+ * This peer's CURRENT membership token, read at call time.
426
+ *
427
+ * A GETTER, NOT A VALUE, AND THAT IS THE WHOLE POINT. The hub mints a
428
+ * fresh token on every heartbeat response (see `heartbeatOnce` below,
429
+ * `token = body.token`), so the token rotates roughly every
430
+ * `HEARTBEAT_INTERVAL_MS`. Anything that read this once and cached the
431
+ * string would be sending a stale token within seconds; a caller that
432
+ * calls this per outbound request always has the live one. This exists
433
+ * for `edge-dispatch.ts`, which attaches it to requests originating at
434
+ * this peer's own ServiceWorker edge -- see that module for why the PAGE
435
+ * is never handed a token of its own.
436
+ */
437
+ token(): string;
438
+ stop(): void;
439
+ }
440
+
441
+ /**
442
+ * Start the presence heartbeat and the connection-keepalive timer over an
443
+ * already-joined peer (post `redeemInvitation`). Fires an immediate
444
+ * heartbeat rather than waiting for the first interval tick, so a caller
445
+ * observing `meshView()` sees a populated view as soon as the network
446
+ * allows rather than up to `heartbeatIntervalMs` later.
447
+ *
448
+ * THREE TIMERS, KEPT APART (design note's Step 3 / note 07 §6). This
449
+ * function owns two of them -- the heartbeat and the keepalive, below.
450
+ * THE THIRD, the reservation on the relay, is NOT started here either:
451
+ * libp2p renews a reservation it holds on its own schedule, and restoring
452
+ * one lost with the relay link -- which libp2p attempts once and then never
453
+ * again -- is `../reservation.ts`'s `superviseRelay`, started by the
454
+ * runtime. The keepalive below is NOT that: it watches the HUB connection,
455
+ * which can stay up over WebRTC after the relay link is gone. Folding
456
+ * either into these two would conflate "is my reservation still valid"
457
+ * with "does the hub still consider me a member" or "is my connection to
458
+ * the hub still open" -- three questions this design keeps separate
459
+ * because they fail independently and mean different things.
460
+ */
461
+ export function startJoin(init: JoinInit): JoinHandle {
462
+ const { peer, node, hubPeerId, relayAddr, revocationCache } = init;
463
+ const heartbeatIntervalMs = init.heartbeatIntervalMs ?? HEARTBEAT_INTERVAL_MS;
464
+ const keepaliveIntervalMs = init.keepaliveIntervalMs ?? KEEPALIVE_INTERVAL_MS;
465
+
466
+ let token = init.initialToken;
467
+ let seq = init.initialSeq ?? nextInitialSeq();
468
+ let heartbeatInFlight = false;
469
+
470
+ let meshViewCache: MeshView | null = null;
471
+ let meshVersion = 0;
472
+ let rulesCache: RuleSet | null = null;
473
+ let rulesVersion = 0;
474
+ let revocationEntriesCache: ChangeEntry[] = [];
475
+
476
+ async function heartbeatOnce(): Promise<void> {
477
+ if (heartbeatInFlight) return; // never overlap two in-flight heartbeats -- see the module comment on why this is safe to just skip a tick rather than queue.
478
+ heartbeatInFlight = true;
479
+ // EVERYTHING BELOW IS INSIDE ONE try/catch, DELIBERATELY -- not only
480
+ // the presence call. `heartbeatOnce` is invoked as `void
481
+ // heartbeatOnce()` (fire-and-forget, on a `setInterval`), so ANY
482
+ // rejection that escapes it -- a dropped connection mid-call, a
483
+ // malformed JSON body from the hub, a follow-up GET failing after the
484
+ // presence POST already succeeded -- becomes an unhandled promise
485
+ // rejection, not the "next tick retries" behaviour the rest of this
486
+ // function's comments describe. A single catch-all below is what
487
+ // actually delivers that contract; catching only the first call and
488
+ // leaving the rest unprotected (an earlier version of this function)
489
+ // does not (found on review; reproduced by forcing an unconditional
490
+ // refetch and watching a follow-up call's rejection escape uncaught).
491
+ try {
492
+ seq += 1;
493
+ // THE PEER'S OWN CURRENT MULTIADDRS -- `node.getMultiaddrs()`, read
494
+ // fresh on every heartbeat. This is what makes THIS peer's own
495
+ // addresses the freshest possible source for OTHER peers reading
496
+ // them back out of the mesh view -- see `JoinHandle.meshView`'s doc
497
+ // comment and `peer-runtime.ts`'s module comment for the other half
498
+ // of this contract (never read a PEER's addrs from libp2p's own
499
+ // `peerStore`).
500
+ const addrs = node.getMultiaddrs().map((addr) => addr.toString());
501
+ const advertisements = init.advertisements?.() ?? [];
502
+
503
+ const res = await peer.call(
504
+ hubPeerId,
505
+ peerRequest("/.well-known/presence", {
506
+ method: "POST",
507
+ token,
508
+ headers: { "content-type": "application/json" },
509
+ body: JSON.stringify({ seq, addrs, advertisements }),
510
+ }),
511
+ );
512
+ if (!res.ok) {
513
+ // A refusal is not a transport fault and must not be swallowed with
514
+ // one: 403 means this peer is no longer a member (revoked, or the
515
+ // hub's state was reset) and 409 means a SECOND node is beating
516
+ // under this identity. Both are permanent until someone acts, so a
517
+ // page that only stopped advancing its caches would sit there
518
+ // looking joined. Every cache is still left exactly as it was --
519
+ // that part was always right.
520
+ init.onPresenceRefused?.(
521
+ classifyPresenceRefusal(res.status, await res.text().catch(() => "")),
522
+ );
523
+ return;
524
+ }
525
+
526
+ const body = (await res.json()) as PresenceHeartbeatResponse;
527
+ token = body.token; // rotates every heartbeat -- the hub mints a fresh token on each call.
528
+
529
+ if (body.versions.mesh !== meshVersion) {
530
+ const meshRes = await peer.call(
531
+ hubPeerId,
532
+ peerRequest("/.well-known/mesh", { method: "GET", token }),
533
+ );
534
+ if (meshRes.ok) {
535
+ meshViewCache = (await meshRes.json()) as MeshView;
536
+ meshVersion = body.versions.mesh;
537
+ }
538
+ }
539
+
540
+ if (body.versions.rules !== rulesVersion) {
541
+ const rulesRes = await peer.call(
542
+ hubPeerId,
543
+ peerRequest("/.well-known/rules", {
544
+ method: "GET",
545
+ token,
546
+ }),
547
+ );
548
+ if (rulesRes.ok) {
549
+ rulesCache = (await rulesRes.json()) as RuleSet;
550
+ rulesVersion = body.versions.rules;
551
+ }
552
+ }
553
+
554
+ if (body.versions.policy !== revocationCache.knownVersion()) {
555
+ const revRes = await peer.call(
556
+ hubPeerId,
557
+ peerRequest("/.well-known/revocations", {
558
+ method: "GET",
559
+ token,
560
+ }),
561
+ );
562
+ if (revRes.ok) {
563
+ const revBody = (await revRes.json()) as RevocationsResponse;
564
+ revocationEntriesCache = revBody.entries;
565
+ }
566
+ }
567
+ // Refresh the cache's freshness stamp on EVERY successful heartbeat
568
+ // -- see `REVOCATION_MAX_STALENESS_MS`'s doc comment for why this is
569
+ // NOT gated on the policy version having actually moved.
570
+ revocationCache.update(body.versions.policy, revocationEntriesCache);
571
+
572
+ init.onHeartbeat?.(body.versions);
573
+ } catch {
574
+ // Dial/transport failure, a malformed response body, or any other
575
+ // fault anywhere in the sequence above -- leave every cached value
576
+ // exactly as it was; the next tick retries from scratch.
577
+ // `KEEPALIVE_INTERVAL_MS`'s timer is what actually diagnoses and
578
+ // repairs a dropped connection to the hub; this heartbeat has no
579
+ // business doing that job too, only refusing to crash the page over
580
+ // it.
581
+ } finally {
582
+ heartbeatInFlight = false;
583
+ }
584
+ }
585
+
586
+ const heartbeatTimer = setInterval(() => void heartbeatOnce(), heartbeatIntervalMs);
587
+ void heartbeatOnce();
588
+
589
+ let hubPeerIdObj: PeerId | undefined;
590
+ try {
591
+ hubPeerIdObj = peerIdFromString(hubPeerId);
592
+ } catch {
593
+ hubPeerIdObj = undefined; // malformed hubPeerId -- the keepalive timer below simply never finds a match and keeps re-predialling, which is the correct degraded behaviour.
594
+ }
595
+
596
+ // `reachHub`, not a bare pre-dial: it also closes the limited signalling
597
+ // circuit, which would otherwise break relaying THROUGH the hub (and so
598
+ // restoring this page's reservation on it) -- see `../hub-link.ts`.
599
+ const relinkHub = init.relinkHub ?? (() => reachHub(node, relayAddr, hubPeerId));
600
+ // One re-link at a time: a dial can outlast a tick, and two overlapping
601
+ // attempts would open two connections.
602
+ let relinking = false;
603
+
604
+ // A KEPT LIMITED CIRCUIT COUNTS AS OPEN, so a member on the relay fallback is
605
+ // left alone for as long as its circuit lives -- nothing here closes or
606
+ // replaces it -- and re-links only once it has gone.
607
+ const keepaliveTimer = setInterval(() => {
608
+ const stillOpen =
609
+ hubPeerIdObj != null && node.getConnections(hubPeerIdObj).some((c) => c.status === "open");
610
+ if (stillOpen || relinking) return;
611
+ relinking = true;
612
+ void relinkHub()
613
+ .catch(() => {
614
+ // Best-effort -- the next tick retries, and a heartbeat that keeps
615
+ // failing on its own schedule surfaces the same underlying
616
+ // unreachability independently.
617
+ })
618
+ .finally(() => {
619
+ relinking = false;
620
+ });
621
+ }, keepaliveIntervalMs);
622
+
623
+ return {
624
+ meshView: () => meshViewCache,
625
+ rules: () => rulesCache,
626
+ token: () => token, // read at call time, never snapshotted -- see `JoinHandle.token`.
627
+ stop() {
628
+ clearInterval(heartbeatTimer);
629
+ clearInterval(keepaliveTimer);
630
+ },
631
+ };
632
+ }
package/src/kv.ts ADDED
@@ -0,0 +1,21 @@
1
+ /**
2
+ * The key-value seams a member stores things behind — the SHAPES only.
3
+ *
4
+ * The IndexedDB implementations moved to `./browser.js`. They import
5
+ * `idb-keyval`, which needs a real IndexedDB, and a Node member reading its
6
+ * own identity has no business pulling a browser database into its bundle to
7
+ * do it. What is left here is what both platforms genuinely share: two
8
+ * interfaces, and nothing that can run.
9
+ */
10
+
11
+ export interface AsyncKeyValueBackend {
12
+ get(key: string): Promise<string | undefined>;
13
+ set(key: string, value: string): Promise<void>;
14
+ delete(key: string): Promise<void>;
15
+ }
16
+
17
+ export interface AsyncBytesBackend {
18
+ get(key: string): Promise<Uint8Array | undefined>;
19
+ set(key: string, value: Uint8Array): Promise<void>;
20
+ delete(key: string): Promise<void>;
21
+ }