@ccmsg/cli 0.1.0

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 (102) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +23 -0
  3. package/package.json +32 -0
  4. package/src/cli.ts +1074 -0
  5. package/src/daemon/control.ts +88 -0
  6. package/src/daemon/index.ts +6 -0
  7. package/src/daemon/link.ts +93 -0
  8. package/src/daemon/log.ts +116 -0
  9. package/src/daemon/registry.ts +285 -0
  10. package/src/daemon/snapshot.ts +115 -0
  11. package/src/daemon/supervise.ts +446 -0
  12. package/src/dispatch/caller.ts +47 -0
  13. package/src/dispatch/dispatch.ts +128 -0
  14. package/src/dispatch/handler.ts +55 -0
  15. package/src/dispatch/identity.ts +22 -0
  16. package/src/dispatch/index.ts +5 -0
  17. package/src/dispatch/result.ts +58 -0
  18. package/src/files/containment.ts +263 -0
  19. package/src/files/files.ts +421 -0
  20. package/src/files/index.ts +14 -0
  21. package/src/files/sandbox.ts +0 -0
  22. package/src/greeting/hook.ts +48 -0
  23. package/src/greeting/index.ts +2 -0
  24. package/src/greeting/meta.ts +66 -0
  25. package/src/instance/config.ts +424 -0
  26. package/src/instance/handlers.ts +28 -0
  27. package/src/instance/identity.ts +44 -0
  28. package/src/instance/index.ts +8 -0
  29. package/src/instance/instance.ts +911 -0
  30. package/src/instance/lock.ts +108 -0
  31. package/src/instance/log.ts +30 -0
  32. package/src/instance/paths.ts +200 -0
  33. package/src/instance/socket.ts +62 -0
  34. package/src/kv/index.ts +2 -0
  35. package/src/kv/merge.ts +66 -0
  36. package/src/kv/store.ts +195 -0
  37. package/src/launcher/index.ts +4 -0
  38. package/src/launcher/launcher.ts +190 -0
  39. package/src/launcher/roots.ts +32 -0
  40. package/src/launcher/spawn.ts +81 -0
  41. package/src/launcher/tree.ts +80 -0
  42. package/src/mesh/index.ts +5 -0
  43. package/src/mesh/keys.ts +158 -0
  44. package/src/mesh/mesh.ts +1169 -0
  45. package/src/mesh/probe.ts +100 -0
  46. package/src/mesh/relay.ts +147 -0
  47. package/src/mesh/wire.ts +96 -0
  48. package/src/messaging/delivery.ts +375 -0
  49. package/src/messaging/direct.ts +433 -0
  50. package/src/messaging/handlers.ts +14 -0
  51. package/src/messaging/inbox.ts +191 -0
  52. package/src/messaging/index.ts +5 -0
  53. package/src/messaging/notify.ts +117 -0
  54. package/src/plugin/claude.ts +148 -0
  55. package/src/plugin/index.ts +13 -0
  56. package/src/plugin/install.ts +416 -0
  57. package/src/service/index.ts +1 -0
  58. package/src/service/service.ts +359 -0
  59. package/src/sessions/classify.ts +66 -0
  60. package/src/sessions/dump.ts +105 -0
  61. package/src/sessions/fork.ts +127 -0
  62. package/src/sessions/handlers.ts +158 -0
  63. package/src/sessions/harness.ts +167 -0
  64. package/src/sessions/index.ts +26 -0
  65. package/src/sessions/last-live.ts +111 -0
  66. package/src/sessions/processes.ts +413 -0
  67. package/src/sessions/registry.ts +785 -0
  68. package/src/sessions/search.ts +278 -0
  69. package/src/sessions/status.ts +209 -0
  70. package/src/sessions/terminals.ts +72 -0
  71. package/src/sessions/workspace.ts +140 -0
  72. package/src/topics/handlers.ts +42 -0
  73. package/src/topics/index.ts +2 -0
  74. package/src/topics/topics.ts +290 -0
  75. package/src/transcript/files.ts +201 -0
  76. package/src/transcript/fold.ts +833 -0
  77. package/src/transcript/index.ts +16 -0
  78. package/src/transcript/read.ts +82 -0
  79. package/src/transcript/tail.ts +195 -0
  80. package/src/transcript/transcripts.ts +162 -0
  81. package/src/translate/helper.ts +87 -0
  82. package/src/translate/index.ts +2 -0
  83. package/src/translate/translate.ts +127 -0
  84. package/src/transport/conn.ts +129 -0
  85. package/src/transport/dial.ts +65 -0
  86. package/src/transport/driver.ts +102 -0
  87. package/src/transport/entry.ts +39 -0
  88. package/src/transport/framing.ts +131 -0
  89. package/src/transport/index.ts +8 -0
  90. package/src/transport/listener.ts +39 -0
  91. package/src/transport/uds.ts +88 -0
  92. package/src/transport/ws.ts +170 -0
  93. package/src/upstream/events.ts +125 -0
  94. package/src/upstream/gateway.ts +275 -0
  95. package/src/upstream/index.ts +8 -0
  96. package/src/upstream/json.ts +81 -0
  97. package/src/upstream/requests.ts +234 -0
  98. package/src/upstream/stats.ts +99 -0
  99. package/src/upstream/status.ts +281 -0
  100. package/src/upstream/usage.ts +208 -0
  101. package/src/upstream/webhook.ts +141 -0
  102. package/src/version.ts +8 -0
@@ -0,0 +1,1169 @@
1
+ import { hostname } from "node:os";
2
+ import {
3
+ type CallerIdentity,
4
+ type Endpoint,
5
+ type InstanceId,
6
+ type InstanceInfo,
7
+ PROTOCOL_VERSION,
8
+ type Sid,
9
+ type Timestamp,
10
+ } from "@ccmsg/protocol";
11
+ import { type Conn, type ConnRegistry, dialWs } from "../transport/index.ts";
12
+ import {
13
+ type DispatchResult,
14
+ failure,
15
+ OpError,
16
+ reply,
17
+ type Requester,
18
+ type SettledIdentity,
19
+ } from "../dispatch/index.ts";
20
+ import type { TopicValue } from "../topics/index.ts";
21
+ import { isClusterTopic, Relay } from "./relay.ts";
22
+ import {
23
+ ALLOWED_ALGS,
24
+ EphemeralKey,
25
+ type MeshJwk,
26
+ MESH_VER,
27
+ parseProof,
28
+ PROOF_LIFETIME_MS,
29
+ ProofError,
30
+ randomId,
31
+ verifyProof,
32
+ } from "./keys.ts";
33
+ import { PeerProbe, type PeerReport } from "./probe.ts";
34
+ import {
35
+ isProbePath,
36
+ jwkEndpoint,
37
+ type JwkRequest,
38
+ type JwkResponse,
39
+ kidOfPath,
40
+ MESH_PROTOCOL,
41
+ meshFrameOf,
42
+ type ProbeBody,
43
+ wsEndpoint,
44
+ } from "./wire.ts";
45
+
46
+ /** What a greeting claims, as the contract spells it. Taken as unknown fields
47
+ * because nothing here trusts it until the proof lands (mesh-peer-auth §5.3). */
48
+ export interface MeshClaim {
49
+ readonly ver: number;
50
+ readonly iss: Endpoint;
51
+ readonly aud: Endpoint;
52
+ /** Which instance answers at `iss`. Worth nothing until the proof lands,
53
+ * after which the whole greeting is trusted and this is what binds the
54
+ * endpoint to an id (DR-0001 §2.1). */
55
+ readonly id: InstanceId;
56
+ readonly kid: string;
57
+ }
58
+
59
+ /** The close code a glare loser is closed with.
60
+ *
61
+ * In the range WebSocket leaves to applications. It exists so the far end can
62
+ * tell this closure from a fault: losing a glare is its normal course, and
63
+ * reconnecting on it would reopen exactly the connection both sides just
64
+ * agreed to drop (§8.1). */
65
+ export const GLARE_CLOSE = 4000;
66
+
67
+ /** How often a link is asked whether it is still there, and how long silence
68
+ * may last before it is treated as gone.
69
+ *
70
+ * Provisional values: mesh-peer-auth §8.3 requires a heartbeat and states no
71
+ * period, and no primary source gives one, so these are chosen rather than
72
+ * derived. The reasoning behind the choice is that the heartbeat exists to
73
+ * catch a silent drop by a middlebox, whose idle timeouts are conventionally
74
+ * around a minute, and that three missed beats is the usual margin before a
75
+ * link is called dead. Replace them with measured values when a deployment
76
+ * gives any.
77
+ *
78
+ * This is the periodic timer this layer owns, and its whole justification (M3).
79
+ * There is no other: reconnection is scheduled per failure rather than polled,
80
+ * and reachability is read from the links rather than swept. */
81
+ export const HEARTBEAT_MS = 20_000;
82
+ export const HEARTBEAT_TIMEOUT_MS = 3 * HEARTBEAT_MS;
83
+
84
+ /** The reconnection backoff (§8.2).
85
+ *
86
+ * Loose on purpose: a peer that comes back dials us, so the moment it recovers
87
+ * is signalled by its own start rather than found by our retries. What this
88
+ * schedule is for is the case where only our side of the link failed. */
89
+ export const RECONNECT_MIN_MS = 5_000;
90
+ export const RECONNECT_MAX_MS = 60_000;
91
+
92
+ /** How many key requests are answered per second, over all callers.
93
+ *
94
+ * The key endpoint is reached before anything is proven (§6), so it is the one
95
+ * surface an unauthenticated caller can make this instance do work on. The cap
96
+ * is well above what a mesh of any size needs — one request per connection
97
+ * established — and well below what would cost anything. */
98
+ const JWK_RATE_LIMIT = 20;
99
+ const JWK_RATE_WINDOW_MS = 1_000;
100
+
101
+ /** How long a forwarded op may take before its caller is told the instance
102
+ * could not be reached (§7.3).
103
+ *
104
+ * Chosen rather than derived: no primary source states a deadline. The
105
+ * reasoning is that the two outcomes this sits between are both worse than a
106
+ * plain answer — a caller left waiting on an instance that will never reply,
107
+ * and a caller told "unreachable" about an instance that was merely busy — and
108
+ * that the heartbeat already declares a silent link dead well after this, so
109
+ * the deadline is about the op rather than about the link. */
110
+ export const FORWARD_TIMEOUT_MS = 10_000;
111
+
112
+ /** What the instance gives the mesh once it exists.
113
+ *
114
+ * The mesh is built before the instance, because the listener has to be up for
115
+ * self-identification to reach it (§8.3), so the two things a link needs from
116
+ * the instance arrive afterwards rather than through the constructor. */
117
+ export interface MeshHost {
118
+ /** The one door a frame goes through (§3.2). A request a peer carried here
119
+ * is answered by the same dispatch every other request is. */
120
+ handle(frame: unknown, conn: Requester): Promise<DispatchResult>;
121
+ /** Hand a relayed frame to this instance's own subscribers (§7.4). */
122
+ publish(topic: string, data: unknown, instance: InstanceId): void;
123
+ /** Which instances can be reached has changed, which is part of what this
124
+ * instance states on `peers` (§7.5). */
125
+ changed(): void;
126
+ }
127
+
128
+ export interface MeshDeps {
129
+ /** This instance's id, which is what it is called on the wire. */
130
+ readonly id: InstanceId;
131
+ /** Where peers reach this instance, from config (DR-0001 §2.7). */
132
+ readonly self: Endpoint;
133
+ readonly peers: readonly Endpoint[];
134
+ readonly conns: ConnRegistry;
135
+ readonly log?: (msg: string, fields?: Record<string, unknown>) => void;
136
+ /** Something changed about which peers are reachable. */
137
+ readonly onChanged?: () => void;
138
+ readonly heartbeatMs?: number;
139
+ readonly heartbeatTimeoutMs?: number;
140
+ readonly reconnectMinMs?: number;
141
+ readonly forwardTimeoutMs?: number;
142
+ /** The clock the retention window of §7.5 is read against. */
143
+ readonly now?: () => Timestamp;
144
+ }
145
+
146
+ /** One established mesh link. */
147
+ interface Link {
148
+ readonly conn: Requester;
149
+ /** One actor per caller this link has spoken for, keyed by the identity
150
+ * itself. Cached rather than made per request because a subscription is held
151
+ * by a connection and released when it closes (§6.3): the topic mechanism
152
+ * has to see the same object each time one caller subscribes. */
153
+ readonly actors: Map<string, PeerActor>;
154
+ /** Which end opened the socket, which is what the glare rule compares (§8.1). */
155
+ readonly dialledByUs: boolean;
156
+ readonly heartbeat: ReturnType<typeof setInterval>;
157
+ lastHeard: number;
158
+ }
159
+
160
+ /** One request this instance forwarded and is waiting on (§7.3). */
161
+ interface Forwarded {
162
+ readonly peer: Endpoint;
163
+ /** The id the caller used, restored on the reply so the caller's connection
164
+ * settles the request it actually made. */
165
+ readonly requestId: string;
166
+ readonly settle: (result: DispatchResult) => void;
167
+ readonly timer: ReturnType<typeof setTimeout>;
168
+ }
169
+
170
+ /** One caller a proven peer forwarded here, as the connection its request
171
+ * arrived on.
172
+ *
173
+ * The envelope's `caller` is taken as said. It is the one thing the
174
+ * destination believes on the forwarder's word, and it can, because the link
175
+ * is authenticated: mesh-peer-auth proved the far end is an instance on the
176
+ * peer list, and a peer list is one deployment (§8.2). Everything else is
177
+ * decided here — the role check reads this identity against this instance's
178
+ * own attribute table, and so do the capability and locality checks, which is
179
+ * what §7.3 means by putting a forwarded op through the steps again rather
180
+ * than taking the forwarder's outcome for it.
181
+ *
182
+ * `from_instance` is not part of that judgement: the field says where the
183
+ * reply goes, and the link says who sent it. */
184
+ class PeerActor implements Requester {
185
+ constructor(
186
+ private readonly conn: Requester,
187
+ readonly identity: SettledIdentity,
188
+ ) {}
189
+
190
+ send(frame: object): void {
191
+ this.conn.send(frame);
192
+ }
193
+
194
+ /** Sent rather than queued. Deferring exists so a subscribe's snapshot
195
+ * follows its acknowledgement on the caller's connection; a peer settles its
196
+ * subscription on the reply's `request_id` and folds frames as they arrive,
197
+ * so there is no order here to keep. */
198
+ deferSend(frame: object): void {
199
+ this.conn.send(frame);
200
+ }
201
+
202
+ onClose(listener: () => void): void {
203
+ this.conn.onClose(listener);
204
+ }
205
+
206
+ close(code?: number, reason?: string): void {
207
+ this.conn.close(code, reason);
208
+ }
209
+ }
210
+
211
+ /** One handshake this instance is verifying, as the receiving end (§5).
212
+ *
213
+ * Everything the verification needs is here and nowhere else, so the whole of
214
+ * what a handshake leaves behind is one map entry that is deleted when it
215
+ * finishes — which is what §10.5 asks be true. */
216
+ interface Pending {
217
+ readonly claim: MeshClaim;
218
+ readonly challenge: string;
219
+ readonly deliver: (jws: string) => void;
220
+ readonly fail: (cause: Error) => void;
221
+ }
222
+
223
+ /** One key this instance minted for a connection it dialled (§7). */
224
+ interface Minted {
225
+ readonly key: EphemeralKey;
226
+ readonly aud: Endpoint;
227
+ conn?: Requester;
228
+ }
229
+
230
+ /** Which of two connections to one peer survives a glare (§8.1).
231
+ *
232
+ * The connection opened by the smaller `iss` is the one that stays. Neither is
233
+ * better than the other — both were verified before either was dropped — so
234
+ * there is nothing to prefer; what the rule is for is that the two ends reach
235
+ * the same conclusion, which they do because they compare the same two strings.
236
+ *
237
+ * Stated apart from the link it decides about, because that is what makes "both
238
+ * ends agree" a thing that can be checked rather than inferred. */
239
+ export function glareKeepsNew(self: Endpoint, peer: Endpoint, dialledByUs: boolean): boolean {
240
+ return dialledByUs ? self < peer : peer < self;
241
+ }
242
+
243
+ /** The mesh: the links to the other instances, and the handshake that decides
244
+ * what each of them is.
245
+ *
246
+ * Every instance dials every peer, so there is no side that owns a link and no
247
+ * peer that cannot be recovered from the other end (§8, and §12's reason for
248
+ * not assigning the duty to one side). */
249
+ export class Mesh {
250
+ readonly #links = new Map<Endpoint, Link>();
251
+ readonly #pending = new Map<Requester, Pending>();
252
+ readonly #minted = new Map<string, Minted>();
253
+ readonly #retries = new Map<Endpoint, ReturnType<typeof setTimeout>>();
254
+ readonly #backoff = new Map<Endpoint, number>();
255
+ readonly #probe = new PeerProbe();
256
+ /** The configured endpoints that turned out to be this instance, filled in by
257
+ * `verify`. `self` is always among them; an alias of it that a peer list also
258
+ * names joins it there, and none of them is dialled. */
259
+ readonly #ours = new Set<Endpoint>();
260
+ /** The authenticated endpoint-to-id mapping (DR-0001 §2.1), in both
261
+ * directions: a handshake writes it, `to_instance` reads it to find the link
262
+ * to dial down, and a disconnection leaves it standing so a peer that is out
263
+ * of reach is still an instance this one knows the name of.
264
+ *
265
+ * One id binds to one endpoint. A greeting naming an id already bound
266
+ * elsewhere is refused, the standing binding being the one the operator's
267
+ * endpoint list has already vouched for. */
268
+ readonly #idOf = new Map<Endpoint, InstanceId>();
269
+ readonly #endpointOf = new Map<InstanceId, Endpoint>();
270
+ #jwkWindow = 0;
271
+ #jwkServed = 0;
272
+ #stopping = false;
273
+
274
+ readonly #marked = new WeakSet<Requester>();
275
+ /** The link a proven connection belongs to, for the two questions asked per
276
+ * frame: who it may speak for, and whether it may settle a forwarded reply. */
277
+ readonly #linkOf = new Map<Requester, Link>();
278
+ readonly #forwarded = new Map<string, Forwarded>();
279
+ /** The relayed topics local subscribers are asking for right now. `peers` is
280
+ * always among them: it is the routing table of §7.3, and a question about
281
+ * where a session lives is answered whether or not anyone is subscribed
282
+ * (§6.3, "reading the current value is not what subscription drives"). */
283
+ readonly #demanded = new Set<string>(["peers"]);
284
+ #host: MeshHost | undefined;
285
+
286
+ /** What the peers said, kept across a disconnection (§7.5). */
287
+ readonly relay: Relay;
288
+
289
+ constructor(private readonly deps: MeshDeps) {
290
+ // The table opens with the one binding this instance did not have to learn:
291
+ // its own. That is what makes "an id already answering elsewhere" cover the
292
+ // case of a peer claiming to be us — which is what the instance at a moved
293
+ // instance's old URL looks like from the new one.
294
+ this.#bind(deps.self, deps.id);
295
+ this.relay = new Relay({
296
+ publish: (topic, data, instance) => {
297
+ this.#host?.publish(topic, data, instance);
298
+ },
299
+ ...(deps.now === undefined ? {} : { now: deps.now }),
300
+ });
301
+ }
302
+
303
+ /** Give the mesh the instance it belongs to (§8.3). */
304
+ bind(host: MeshHost): void {
305
+ this.#host = host;
306
+ }
307
+
308
+ /** The registry the mesh's own connections are in. It is the instance's, and
309
+ * is shared because a mesh link is one of its connections (§3.1). */
310
+ get conns(): ConnRegistry {
311
+ return this.deps.conns;
312
+ }
313
+
314
+ /** Note a connection that was let past the entry token as a peer. */
315
+ accept(conn: Requester, info: { readonly mesh: boolean }): void {
316
+ if (info.mesh) this.#marked.add(conn);
317
+ }
318
+
319
+ /** Whether this connection came in as a peer and has not proven what it is.
320
+ *
321
+ * Such a connection presented nothing: it was admitted so that it could make
322
+ * the one claim that can be checked, and until it does it may do that and
323
+ * nothing else. */
324
+ unproven(conn: Requester): boolean {
325
+ return this.#marked.has(conn) && conn.identity.state !== "settled";
326
+ }
327
+
328
+ /** The peers this instance dials: the configured list without itself.
329
+ *
330
+ * The list is the same on every instance, which is what lets one file be
331
+ * distributed to all of them (§8.2) — and it may name this instance, so
332
+ * removing ourselves is the reader's job rather than the writer's. `verify`
333
+ * is what found which entries are ours, the configured `self` among them. */
334
+ get peers(): Endpoint[] {
335
+ return this.deps.peers.filter((peer) => !this.#ours.has(peer));
336
+ }
337
+
338
+ get self(): Endpoint {
339
+ return this.deps.self;
340
+ }
341
+
342
+ get id(): InstanceId {
343
+ return this.deps.id;
344
+ }
345
+
346
+ /** Whether a link to this peer is established and proven. */
347
+ reachable(peer: Endpoint): boolean {
348
+ return this.#links.has(peer);
349
+ }
350
+
351
+ /** What `hello` reports: this instance, then every configured peer, with
352
+ * whether it can be reached right now (§7.5).
353
+ *
354
+ * A peer no handshake has settled yet is listed without an id. The operator
355
+ * configured that endpoint, so it is an entry of the cluster whether or not
356
+ * anything has answered there — and leaving it out would hide exactly the
357
+ * peer whose link is down, which is the one a reader is looking for. */
358
+ instances(): InstanceInfo[] {
359
+ return [
360
+ { id: this.deps.id, endpoint: this.deps.self, host: hostname(), reachable: true },
361
+ ...this.peers.map((peer) => {
362
+ const id = this.#idOf.get(peer);
363
+ return {
364
+ ...(id === undefined ? {} : { id }),
365
+ endpoint: peer,
366
+ host: new URL(peer).hostname,
367
+ reachable: this.reachable(peer),
368
+ };
369
+ }),
370
+ ];
371
+ }
372
+
373
+ /** Whether any peer is currently out of reach.
374
+ *
375
+ * What separates "no instance in the cluster knows this session" from "an
376
+ * instance that might know it cannot be asked" — the one distinction §4.2
377
+ * says rests on the mesh's connection state and on nothing else. */
378
+ anyUnreachable(): boolean {
379
+ return this.peers.some((peer) => !this.reachable(peer));
380
+ }
381
+
382
+ /** Which instance should answer for a session this instance does not hold.
383
+ *
384
+ * A session the cluster has named belongs to the instance its `peers` row
385
+ * states. One nobody has named while a peer is out of reach is answered with
386
+ * that peer: forwarding there fails and the caller is told
387
+ * `instance_unreachable`, which is what §4.2 asks for in place of deciding
388
+ * the session does not exist. */
389
+ ownerOf(sid: Sid): InstanceId | undefined {
390
+ const owner = this.relay.owner(sid);
391
+ if (owner !== undefined) return owner;
392
+ // Any peer that is out of reach and whose name this instance knows. One it
393
+ // has never handshaken with cannot be named as the owner, and answering
394
+ // with no owner is what forwarding does when there is nowhere to forward.
395
+ for (const peer of this.peers) {
396
+ if (this.reachable(peer)) continue;
397
+ const id = this.#idOf.get(peer);
398
+ if (id !== undefined) return id;
399
+ }
400
+ return undefined;
401
+ }
402
+
403
+ // --- op forwarding (§7.3) ---
404
+
405
+ /** Carry one op to the instance that owns its subject, and bring the answer
406
+ * back.
407
+ *
408
+ * The request keeps its shape and gains the envelope's three fields. Its
409
+ * `request_id` is reissued because uniqueness has to hold among one
410
+ * connection's in-flight requests (contract, `RequestEnvelope`) and this
411
+ * connection is the link, not the caller's; the caller's id is put back on
412
+ * the reply.
413
+ *
414
+ * `caller` is who the destination will run it as. It is stated by this
415
+ * instance from the connection the request came in on, never carried over
416
+ * from what the request said about itself — a client that wrote a `caller`
417
+ * of its own would otherwise choose the identity it is forwarded as. */
418
+ async forward(
419
+ to: InstanceId,
420
+ frame: Record<string, unknown>,
421
+ caller: CallerIdentity | undefined,
422
+ ): Promise<DispatchResult> {
423
+ const self = this.deps.id;
424
+ const requestId = frame["request_id"] as string;
425
+ // The id names the instance; which link carries it is the binding a
426
+ // handshake left behind (DR-0001 §2.1).
427
+ const endpoint = this.#endpointOf.get(to);
428
+ const link = endpoint === undefined ? undefined : this.#links.get(endpoint);
429
+ if (link === undefined) {
430
+ return failure(requestId, "instance_unreachable", `${to} cannot be reached`);
431
+ }
432
+ const peer = endpoint as Endpoint;
433
+ const hops = Array.isArray(frame["hops"]) ? (frame["hops"] as InstanceId[]) : [];
434
+ const op = String(frame["op"]);
435
+ const carried = `mesh-fwd-${randomId()}`;
436
+ const settled = Promise.withResolvers<DispatchResult>();
437
+ const timer = setTimeout(() => {
438
+ this.#forwarded.delete(carried);
439
+ settled.resolve(
440
+ failure(requestId, "instance_unreachable", `${to} did not answer ${op} in time`),
441
+ );
442
+ }, this.deps.forwardTimeoutMs ?? FORWARD_TIMEOUT_MS);
443
+ timer.unref?.();
444
+ this.#forwarded.set(carried, {
445
+ peer,
446
+ requestId,
447
+ timer,
448
+ settle: settled.resolve,
449
+ });
450
+ link.conn.send({
451
+ ...frame,
452
+ request_id: carried,
453
+ to_instance: to,
454
+ from_instance: self,
455
+ hops: [...hops, self],
456
+ ...(caller === undefined ? {} : { caller }),
457
+ });
458
+ return await settled.promise;
459
+ }
460
+
461
+ /** Whether this connection is an established link to a peer. */
462
+ isLink(conn: Requester): boolean {
463
+ return this.#linkOf.has(conn);
464
+ }
465
+
466
+ /** The connection a forwarded request is dispatched as (§7.3).
467
+ *
468
+ * The caller the envelope names, on the link it arrived over. A request that
469
+ * names none is dispatched as the link itself, whose role is `instance` —
470
+ * which the attribute table already answers, since no instance-local op is
471
+ * open to an instance. */
472
+ caller(conn: Requester, caller: CallerIdentity | undefined): Requester {
473
+ const link = this.#linkOf.get(conn);
474
+ if (link === undefined || caller === undefined) return conn;
475
+ const key = `${caller.role}/${caller.sid ?? ""}`;
476
+ const held = link.actors.get(key);
477
+ if (held !== undefined) return held;
478
+ const actor = new PeerActor(conn, {
479
+ state: "settled",
480
+ role: caller.role,
481
+ ...(caller.sid === undefined ? {} : { sid: caller.sid }),
482
+ });
483
+ link.actors.set(key, actor);
484
+ return actor;
485
+ }
486
+
487
+ // --- event relay (§7.4) ---
488
+
489
+ /** The current value of a relayed topic, one entry per instance that has
490
+ * stated one. Handed to a fresh local subscriber beside this instance's own
491
+ * snapshot, so it opens on the cluster rather than on us. */
492
+ snapshot(topic: string): readonly TopicValue[] {
493
+ return this.relay.snapshot(topic);
494
+ }
495
+
496
+ /** A local subscriber appeared on a cluster topic, or the last one left.
497
+ *
498
+ * The subscription travels: what a subscriber asks of this instance, this
499
+ * instance asks of every peer, and the frames come back unchanged (§7.4).
500
+ * `peers` is never given up, because it is also the routing table. */
501
+ demand(topic: string, wanted: boolean): void {
502
+ if (!isClusterTopic(topic)) return;
503
+ if (wanted) {
504
+ if (this.#demanded.has(topic)) return;
505
+ this.#demanded.add(topic);
506
+ for (const link of this.#links.values()) this.#ask(link.conn, topic, true);
507
+ return;
508
+ }
509
+ if (topic === "peers" || !this.#demanded.delete(topic)) return;
510
+ for (const link of this.#links.values()) this.#ask(link.conn, topic, false);
511
+ }
512
+
513
+ /** Ask a peer for a topic, or give it up.
514
+ *
515
+ * `afterAck` is for the one moment this cannot be sent straight away: on the
516
+ * link we accepted, the handshake is finished by the reply to the greeting,
517
+ * and a peer that hears anything before that reply reads it as speaking out
518
+ * of turn and drops the connection (mesh-peer-auth §5.8). Deferring is what
519
+ * the connection already offers for "once the reply in flight has gone", so
520
+ * the ordering is the connection's rather than a delay chosen here. */
521
+ #ask(conn: Requester, topic: string, wanted: boolean, afterAck = false): void {
522
+ const frame = {
523
+ op: wanted ? "topic_subscribe" : "topic_unsubscribe",
524
+ request_id: `mesh-sub-${randomId()}`,
525
+ topic,
526
+ // The instance asks on behalf of whoever subscribed to it, and what they
527
+ // have in common is that they are this deployment's people rather than
528
+ // any one session: a cluster topic is the same value for all of them
529
+ // (§6.2), so there is nothing narrower to name.
530
+ caller: { role: "user" } satisfies CallerIdentity,
531
+ };
532
+ if (afterAck) conn.deferSend(frame);
533
+ else conn.send(frame);
534
+ }
535
+
536
+ /** Check the endpoint list against reality before anything is dialled (§7.1).
537
+ *
538
+ * Run once the listener is up, because the probe this instance sends to its
539
+ * own `self` has to arrive somewhere — and that probe is the whole test: a
540
+ * `self` that answers as somebody else ends the start, while a peer that is
541
+ * merely asleep is recorded and dialled later. */
542
+ async verify(): Promise<PeerReport> {
543
+ const report = await this.#probe.verify(this.deps.self, this.deps.peers);
544
+ for (const ours of report.ours) this.#ours.add(ours);
545
+ return report;
546
+ }
547
+
548
+ /** Start dialling. Each peer is attempted independently, and a peer that is
549
+ * not there is retried rather than waited for. */
550
+ connect(): void {
551
+ for (const peer of this.peers) void this.#dial(peer);
552
+ }
553
+
554
+ // --- the receiving end (mesh-peer-auth §5, steps 4 and 10) ---
555
+
556
+ /** Verify a greeting, and answer only if the connection is proven to be the
557
+ * peer it names.
558
+ *
559
+ * The whole judgement is here, inside the op that dispatch already validated
560
+ * and allowed: nothing settles an identity on another path, and a handshake
561
+ * that fails any step throws, which is what leaves the connection anonymous
562
+ * (§3.2 step 7). */
563
+ async greet(conn: Requester, claim: MeshClaim): Promise<void> {
564
+ const self = this.deps.self;
565
+ // 1-3 of §5.7, asked before the key is fetched: the cheap comparisons come
566
+ // first because the fetch reaches out to another host.
567
+ if (claim.ver !== MESH_VER) {
568
+ throw new OpError("invalid_args", `this instance speaks mesh handshake ${MESH_VER}`);
569
+ }
570
+ if (!this.deps.peers.includes(claim.iss)) {
571
+ throw new OpError("forbidden", `${claim.iss} is not a peer of this instance`);
572
+ }
573
+ if (claim.aud !== self) {
574
+ throw new OpError("forbidden", `this instance is ${self}, not ${claim.aud}`);
575
+ }
576
+ // The id is checked before the proof is fetched for the same reason: a
577
+ // greeting that cannot be accepted whatever it proves is refused now.
578
+ this.#checkBinding(claim.iss, claim.id);
579
+ const challenge = randomId();
580
+ const proof = Promise.withResolvers<string>();
581
+ this.#pending.set(conn, {
582
+ claim,
583
+ challenge,
584
+ deliver: proof.resolve,
585
+ fail: proof.reject,
586
+ });
587
+ // The connection can go before the key has even been fetched, which is what
588
+ // a peer refused for speaking out of turn does to its own handshake. The
589
+ // rejection is claimed here so that it is never one nobody is waiting for;
590
+ // what acts on it is still the await below.
591
+ proof.promise.catch(() => undefined);
592
+ conn.onClose(() => {
593
+ proof.reject(new Error("the connection went before its proof arrived"));
594
+ });
595
+ try {
596
+ // The key comes over a connection of its own, opened to the endpoint the
597
+ // greeting names. Asking for it on this connection would let whoever
598
+ // opened it answer with their own key and pass their own signature (§6).
599
+ const jwk = await this.#fetchKey(claim, challenge);
600
+ const jws = await withTimeout(
601
+ proof.promise,
602
+ PROOF_LIFETIME_MS,
603
+ "no proof arrived before this handshake expired",
604
+ );
605
+ this.#verify(jws, claim, challenge, jwk);
606
+ } catch (cause) {
607
+ throw cause instanceof OpError
608
+ ? cause
609
+ : new OpError(
610
+ "forbidden",
611
+ `this connection was not proven to be ${claim.iss}: ${String(cause)}`,
612
+ );
613
+ } finally {
614
+ // The challenge is spent whatever happened, so there is no record of it
615
+ // anywhere once the handshake ends (§5.5, §10.5).
616
+ this.#pending.delete(conn);
617
+ }
618
+ // The claim is checked again now that it is trusted: the fetch and the wait
619
+ // took time, and another connection may have taken the id in between.
620
+ this.#checkBinding(claim.iss, claim.id);
621
+ this.#bind(claim.iss, claim.id);
622
+ this.#hold(claim.iss, conn, false);
623
+ }
624
+
625
+ /** Refuse a greeting that names an id already answering somewhere else.
626
+ *
627
+ * One id belongs to one endpoint, and the endpoint list is the only thing
628
+ * vouching for either — so the binding that stands is kept and the newcomer
629
+ * is the one turned away (DR-0001 §2.1). This is what an instance that has
630
+ * moved runs into while the one at its old URL is still up: the remedy is to
631
+ * take the old endpoint out of every peer list, not to let the newer link
632
+ * win here. */
633
+ #checkBinding(endpoint: Endpoint, id: InstanceId): void {
634
+ const bound = this.#endpointOf.get(id);
635
+ if (bound !== undefined && bound !== endpoint) {
636
+ throw new OpError("forbidden", `${id} is already the instance at ${bound}`);
637
+ }
638
+ }
639
+
640
+ #bind(endpoint: Endpoint, id: InstanceId): void {
641
+ const previous = this.#idOf.get(endpoint);
642
+ if (previous !== undefined && previous !== id) this.#endpointOf.delete(previous);
643
+ this.#idOf.set(endpoint, id);
644
+ this.#endpointOf.set(id, endpoint);
645
+ }
646
+
647
+ /** A frame that is not an op. True when the mesh took it.
648
+ *
649
+ * The proof arrives here because it belongs on the connection being
650
+ * authenticated (§5), which is the one connection the op vocabulary has no
651
+ * name for: mesh carries no ops of its own (contract, `Plane`). */
652
+ frame(conn: Requester, frame: unknown): boolean {
653
+ const mesh = meshFrameOf(frame);
654
+ if (mesh === undefined) return this.#peerFrame(conn, frame);
655
+ if (mesh.mesh === "ping") {
656
+ conn.send({ mesh: "pong" });
657
+ return true;
658
+ }
659
+ if (mesh.mesh === "pong") {
660
+ this.#heard(conn);
661
+ return true;
662
+ }
663
+ const pending = this.#pending.get(conn);
664
+ // A proof with no handshake waiting for it: either none was started, or the
665
+ // challenge it answers has already been spent. Neither is retried (§5.5).
666
+ if (pending === undefined) {
667
+ conn.close();
668
+ return true;
669
+ }
670
+ pending.deliver(mesh.jws);
671
+ return true;
672
+ }
673
+
674
+ /** What a proven link wrote that is not a request: a topic frame to relay
675
+ * (§7.4), or the reply to something this instance forwarded (§7.3).
676
+ *
677
+ * Only a link is read this way. A client connection could otherwise guess a
678
+ * forwarded id and settle a request it has nothing to do with, and could
679
+ * push a topic frame this instance would pass on as a peer's. */
680
+ #peerFrame(conn: Requester, frame: unknown): boolean {
681
+ if (!this.#linkOf.has(conn)) return false;
682
+ if (typeof frame !== "object" || frame === null) return false;
683
+ const fields = frame as Record<string, unknown>;
684
+
685
+ const requestId = fields["request_id"];
686
+ // A reply, told from a request by `ok`: the two share the correlation id,
687
+ // and a peer forwarding an op to us uses ids of the same shape we use for
688
+ // our own.
689
+ if (typeof requestId === "string" && typeof fields["ok"] === "boolean") {
690
+ const waiting = this.#forwarded.get(requestId);
691
+ if (waiting !== undefined) {
692
+ this.#forwarded.delete(requestId);
693
+ clearTimeout(waiting.timer);
694
+ waiting.settle(answerOf(fields, waiting.requestId));
695
+ return true;
696
+ }
697
+ // The peer's answer to a subscription this instance asked for. It
698
+ // acknowledges and says nothing further; the value arrives as frames.
699
+ if (requestId.startsWith("mesh-sub-")) return true;
700
+ }
701
+
702
+ if (fields["ev"] !== "topic") return false;
703
+ const topic = fields["topic"];
704
+ const instance = fields["instance"];
705
+ if (typeof topic !== "string" || typeof instance !== "string") return true;
706
+ // Our own value, come back around a triangle. Relaying it again would put
707
+ // this instance's value on the wire as something it received.
708
+ if (instance === this.deps.id) return true;
709
+ this.relay.accept(instance as InstanceId, topic, fields["data"]);
710
+ return true;
711
+ }
712
+
713
+ /** Whether this connection is mid-handshake, which is what makes an ordinary
714
+ * request on it a protocol violation rather than an early call (§5.8). */
715
+ handshaking(conn: Requester): boolean {
716
+ return this.#pending.has(conn);
717
+ }
718
+
719
+ // --- the HTTP surface: the key of §6 and the probe of self-identification ---
720
+
721
+ /** Answer the two requests that are served before anything is proven, or
722
+ * nothing when the request is not one of them. */
723
+ async route(request: Request): Promise<Response | undefined> {
724
+ // Below `self` and nowhere else. The mesh's routes are the one part of the
725
+ // surface that stays tied to the configured endpoint, because that tie is
726
+ // what keeps two instances on one origin from answering for each other's
727
+ // keys (mesh-peer-auth §6.3). The person's entry is matched by the end of
728
+ // the path instead (DR-0001 §2.7).
729
+ const pathname = new URL(request.url).pathname;
730
+ const base = this.deps.self;
731
+ if (isProbePath(pathname, base)) return await this.#answerProbe(request);
732
+ const kid = kidOfPath(pathname, base);
733
+ if (kid !== undefined) return await this.#serveKey(kid, request);
734
+ return undefined;
735
+ }
736
+
737
+ async #answerProbe(request: Request): Promise<Response> {
738
+ let body: unknown;
739
+ try {
740
+ body = await request.json();
741
+ } catch {
742
+ return new Response("a probe is a JSON object", { status: 400 });
743
+ }
744
+ const probe = body as Partial<ProbeBody>;
745
+ // An unknown generation is ignored rather than refused: the comparison is
746
+ // the sender's, so a receiver that cannot read the probe costs the sender
747
+ // nothing it could not already have (§5.1).
748
+ if (probe.ver === MESH_VER && typeof probe.token === "string") {
749
+ this.#probe.accept(probe.token);
750
+ }
751
+ return Response.json({});
752
+ }
753
+
754
+ async #serveKey(kid: string, request: Request): Promise<Response> {
755
+ if (!this.#allowKeyRequest()) {
756
+ return new Response("too many key requests", { status: 429 });
757
+ }
758
+ let body: unknown;
759
+ try {
760
+ body = await request.json();
761
+ } catch {
762
+ return new Response("a key request carries its challenge", { status: 400 });
763
+ }
764
+ const asked = body as Partial<JwkRequest>;
765
+ if (asked.ver !== MESH_VER || typeof asked.challenge !== "string" || asked.challenge === "") {
766
+ return new Response("a key request carries a generation and a challenge", { status: 400 });
767
+ }
768
+ const minted = this.#minted.get(kid);
769
+ // Unknown to us, or known and no longer connected to the handshake it was
770
+ // made for. Either way there is no key to give (§6.1).
771
+ if (minted === undefined || minted.conn === undefined) {
772
+ return new Response("no such key", { status: 404 });
773
+ }
774
+ // The proof goes back on the connection being authenticated, not on this
775
+ // one. The two meet at the `kid`.
776
+ const claim = {
777
+ ver: MESH_VER,
778
+ iss: this.deps.self,
779
+ aud: minted.aud,
780
+ challenge: asked.challenge,
781
+ exp: Math.floor((Date.now() + PROOF_LIFETIME_MS) / 1000),
782
+ };
783
+ minted.conn.send({ mesh: "proof", jws: minted.key.proof(claim) });
784
+ const answer: JwkResponse["jwk"] = minted.key.jwk();
785
+ return new Response(JSON.stringify(answer), {
786
+ headers: { "content-type": "application/jwk+json" },
787
+ });
788
+ }
789
+
790
+ /** A fixed window rather than a queue: what this protects against is a caller
791
+ * making this instance sign and serve in a loop, and a count per second says
792
+ * that plainly. */
793
+ #allowKeyRequest(): boolean {
794
+ const now = Date.now();
795
+ if (now - this.#jwkWindow >= JWK_RATE_WINDOW_MS) {
796
+ this.#jwkWindow = now;
797
+ this.#jwkServed = 0;
798
+ }
799
+ this.#jwkServed += 1;
800
+ return this.#jwkServed <= JWK_RATE_LIMIT;
801
+ }
802
+
803
+ // --- the dialling end (§5, steps 1-3 and 13) ---
804
+
805
+ async #dial(peer: Endpoint): Promise<void> {
806
+ if (this.#stopping || this.#links.has(peer)) return;
807
+ const self = this.deps.self;
808
+ const key = new EphemeralKey();
809
+ const minted: Minted = { key, aud: peer };
810
+ this.#minted.set(key.kid, minted);
811
+ let conn: Conn;
812
+ try {
813
+ conn = await dialWs({
814
+ url: wsEndpoint(peer),
815
+ protocols: [MESH_PROTOCOL],
816
+ conns: this.deps.conns,
817
+ onFrame: (frame, on) => {
818
+ this.#dialledFrame(peer, on, frame, key.kid);
819
+ },
820
+ onClose: (code) => {
821
+ this.#minted.delete(key.kid);
822
+ this.#drop(peer, conn);
823
+ // The loser of a glare is closed on purpose by the other end, and
824
+ // redialling it would reopen what both sides just agreed to drop.
825
+ if (code !== GLARE_CLOSE) this.#retry(peer);
826
+ },
827
+ });
828
+ } catch {
829
+ this.#minted.delete(key.kid);
830
+ this.#retry(peer);
831
+ return;
832
+ }
833
+ minted.conn = conn;
834
+ conn.send({
835
+ op: "hello",
836
+ request_id: `mesh-hello-${key.kid}`,
837
+ role: "instance",
838
+ protocol_version: PROTOCOL_VERSION,
839
+ mesh: { ver: MESH_VER, iss: self, aud: peer, id: this.deps.id, kid: key.kid },
840
+ });
841
+ }
842
+
843
+ /** What the far end wrote on a connection we opened.
844
+ *
845
+ * The greeting's reply is the acknowledgement of §5.8: it is what says the
846
+ * peer finished verifying, which is both the moment this instance may speak
847
+ * and the moment its key has no further use. */
848
+ #dialledFrame(peer: Endpoint, conn: Requester, frame: unknown, kid: string): void {
849
+ if (this.frame(conn, frame)) return;
850
+ const fields = frame as Record<string, unknown>;
851
+ if (fields["request_id"] !== `mesh-hello-${kid}`) {
852
+ // A request the peer forwarded to us. A dialled connection is answered by
853
+ // whoever dialled it (transport, `DialOptions`), so the reply goes out
854
+ // here rather than through the driver — but what decides it is the same
855
+ // dispatch every other request goes through (§7.3).
856
+ if (typeof fields["op"] === "string") this.#answer(conn, fields);
857
+ return;
858
+ }
859
+ this.#minted.delete(kid);
860
+ if (fields["ok"] !== true) {
861
+ const error = fields["error"] as { msg?: string } | undefined;
862
+ this.deps.log?.("mesh peer refused this instance", { peer, msg: error?.msg });
863
+ conn.close();
864
+ return;
865
+ }
866
+ // Which instance answered there, taken from its greeting. What vouches for
867
+ // it on this side of the link is the endpoint itself — the URL is what was
868
+ // dialled and what its certificate was checked against — so the binding is
869
+ // made here rather than waiting for a claim the peer never sends us.
870
+ const id = fields["instance"];
871
+ if (typeof id !== "string") {
872
+ this.deps.log?.("mesh peer named no instance", { peer });
873
+ conn.close();
874
+ return;
875
+ }
876
+ const bound = this.#endpointOf.get(id);
877
+ if (bound !== undefined && bound !== peer) {
878
+ this.deps.log?.("mesh peer claimed an id held elsewhere", { peer, id, bound });
879
+ conn.close();
880
+ return;
881
+ }
882
+ this.#bind(peer, id);
883
+ this.#hold(peer, conn, true);
884
+ }
885
+
886
+ /** Run one request a peer wrote on a connection we dialled, and write the
887
+ * answer back on it. */
888
+ #answer(conn: Requester, fields: Record<string, unknown>): void {
889
+ void this.#host?.handle(fields, conn).then(
890
+ (result) => {
891
+ if (result.kind === "none") return;
892
+ conn.send(
893
+ result.kind === "forward"
894
+ ? failure(
895
+ fields["request_id"] as string,
896
+ "instance_unreachable",
897
+ `${result.to} cannot be reached`,
898
+ ).response
899
+ : result.response,
900
+ );
901
+ },
902
+ (cause: unknown) => {
903
+ conn.send(
904
+ failure(
905
+ fields["request_id"] as string,
906
+ "internal_error",
907
+ `the forwarded request could not be answered: ${String(cause)}`,
908
+ ).response,
909
+ );
910
+ },
911
+ );
912
+ }
913
+
914
+ // --- links, glare and the heartbeat ---
915
+
916
+ /** Take a proven connection as the link to this peer, resolving a glare if
917
+ * one is already held.
918
+ *
919
+ * Both connections are verified before either is dropped, so whichever
920
+ * survives is one that was proven (§8.1). */
921
+ #hold(peer: Endpoint, conn: Requester, dialledByUs: boolean): void {
922
+ const self = this.deps.self;
923
+ const held = this.#links.get(peer);
924
+ if (held !== undefined) {
925
+ if (held.conn === conn) return;
926
+ const keepNew = glareKeepsNew(self, peer, dialledByUs);
927
+ if (!keepNew) {
928
+ conn.close(GLARE_CLOSE, "glare");
929
+ return;
930
+ }
931
+ clearInterval(held.heartbeat);
932
+ this.#links.delete(peer);
933
+ this.#linkOf.delete(held.conn);
934
+ held.conn.close(GLARE_CLOSE, "glare");
935
+ }
936
+ const heartbeat = setInterval(() => {
937
+ this.#beat(peer);
938
+ }, this.deps.heartbeatMs ?? HEARTBEAT_MS);
939
+ heartbeat.unref?.();
940
+ const link: Link = {
941
+ conn,
942
+ actors: new Map<string, PeerActor>(),
943
+ dialledByUs,
944
+ heartbeat,
945
+ lastHeard: Date.now(),
946
+ };
947
+ this.#linkOf.set(conn, link);
948
+ this.#links.set(peer, link);
949
+ this.#backoff.delete(peer);
950
+ conn.onClose(() => {
951
+ this.#drop(peer, conn);
952
+ });
953
+ // What it said before is still held and stops being marked; what it says
954
+ // now replaces it, which is the whole of "restored by reconnection" (§7.5).
955
+ // Under the id, because that is what its frames name themselves with: the
956
+ // endpoint is where the link was dialled and says nothing about the value.
957
+ const id = this.#idOf.get(peer);
958
+ if (id !== undefined) this.relay.restored(id);
959
+ for (const topic of this.#demanded) this.#ask(conn, topic, true, !dialledByUs);
960
+ this.deps.log?.("mesh peer established", { peer, dialled_by_us: dialledByUs });
961
+ this.#changed();
962
+ }
963
+
964
+ /** Say that the set of reachable instances moved. Two listeners: whatever
965
+ * the deps gave, and the instance, which restates `peers` — the topic the
966
+ * view rides on (§7.5). */
967
+ #changed(): void {
968
+ this.deps.onChanged?.();
969
+ this.#host?.changed();
970
+ }
971
+
972
+ #beat(peer: Endpoint): void {
973
+ const link = this.#links.get(peer);
974
+ if (link === undefined) return;
975
+ const silence = Date.now() - link.lastHeard;
976
+ if (silence > (this.deps.heartbeatTimeoutMs ?? HEARTBEAT_TIMEOUT_MS)) {
977
+ // Nothing has come back for long enough that the link is gone whatever
978
+ // the socket believes — which is the whole reason for the heartbeat
979
+ // (§8.3): a middlebox drops a connection without telling either end.
980
+ this.deps.log?.("mesh peer went silent", { peer, silence_ms: silence });
981
+ link.conn.close();
982
+ this.#drop(peer, link.conn);
983
+ return;
984
+ }
985
+ link.conn.send({ mesh: "ping" });
986
+ }
987
+
988
+ #heard(conn: Requester): void {
989
+ for (const link of this.#links.values()) {
990
+ if (link.conn === conn) link.lastHeard = Date.now();
991
+ }
992
+ }
993
+
994
+ #drop(peer: Endpoint, conn: Requester): void {
995
+ const link = this.#links.get(peer);
996
+ if (link === undefined || link.conn !== conn) return;
997
+ clearInterval(link.heartbeat);
998
+ this.#links.delete(peer);
999
+ this.#linkOf.delete(conn);
1000
+ // Its sessions become a kind of Disappeared and its values are marked
1001
+ // rather than dropped (§7.5), and anything on its way there is answered
1002
+ // now instead of waiting out a deadline it can no longer beat.
1003
+ const id = this.#idOf.get(peer);
1004
+ if (id !== undefined) this.relay.lost(id);
1005
+ this.#abandon(peer);
1006
+ this.deps.log?.("mesh peer lost", { peer });
1007
+ this.#changed();
1008
+ if (link.dialledByUs) this.#retry(peer);
1009
+ }
1010
+
1011
+ /** Answer everything that was waiting on a peer that has gone. */
1012
+ #abandon(peer: Endpoint): void {
1013
+ for (const [carried, waiting] of this.#forwarded) {
1014
+ if (waiting.peer !== peer) continue;
1015
+ this.#forwarded.delete(carried);
1016
+ clearTimeout(waiting.timer);
1017
+ waiting.settle(
1018
+ failure(waiting.requestId, "instance_unreachable", `${peer} cannot be reached`),
1019
+ );
1020
+ }
1021
+ }
1022
+
1023
+ /** Try again, later each time up to the ceiling. */
1024
+ #retry(peer: Endpoint): void {
1025
+ if (this.#stopping || this.#retries.has(peer)) return;
1026
+ const min = this.deps.reconnectMinMs ?? RECONNECT_MIN_MS;
1027
+ const previous = this.#backoff.get(peer) ?? 0;
1028
+ const wait = previous === 0 ? min : Math.min(previous * 2, RECONNECT_MAX_MS);
1029
+ this.#backoff.set(peer, wait);
1030
+ const timer = setTimeout(() => {
1031
+ this.#retries.delete(peer);
1032
+ void this.#dial(peer);
1033
+ }, wait);
1034
+ timer.unref?.();
1035
+ this.#retries.set(peer, timer);
1036
+ }
1037
+
1038
+ /** Let every link and every timer go. Called from the stop order (§8.5). */
1039
+ stop(): void {
1040
+ this.#stopping = true;
1041
+ for (const timer of this.#retries.values()) clearTimeout(timer);
1042
+ this.#retries.clear();
1043
+ // The timers go, and so does every link — the ones this instance dialled
1044
+ // and the ones it accepted alike.
1045
+ //
1046
+ // The far end has no other way to learn this instance is going: it would
1047
+ // keep the link, keep answering `reachable`, and keep routing
1048
+ // `instance-local` ops here until its own heartbeat gave up minutes later,
1049
+ // where the disconnection of §7.5 is supposed to be immediate. Which side
1050
+ // dialled a link is decided by the glare rule from a comparison of
1051
+ // endpoint strings (§8.1), so which of a peer's links this instance
1052
+ // accepted is not something either end chose — leaving those open makes a
1053
+ // clean stop look like a silent one to whichever half of the cluster the
1054
+ // comparison put on this side.
1055
+ //
1056
+ // An accepted socket is transport's to release (§8.5 step 5), and left to
1057
+ // it the far end is told whenever the listener gets round to it: measured
1058
+ // against Bun 1.3.13, `stop` on a server that has itself closed a
1059
+ // WebSocket — which the mesh does, to drop the loser of a glare — never
1060
+ // settles, and the wait for it is capped rather than trusted. That cap
1061
+ // bounds this instance's own exit; it cannot bound when the peer hears.
1062
+ // Closing here is what makes the notice the mesh's own rather than a side
1063
+ // effect of a listener going down. The step-3 notice this precedes is
1064
+ // `restarting`, which is addressed to clients — a peer learns from the
1065
+ // link, and that is the whole of what §7.5 asks for.
1066
+ for (const link of this.#links.values()) {
1067
+ clearInterval(link.heartbeat);
1068
+ link.conn.close();
1069
+ }
1070
+ for (const peer of this.#links.keys()) this.#abandon(peer);
1071
+ this.#links.clear();
1072
+ this.#linkOf.clear();
1073
+ // Keys die with the connections they were made for, and none outlives this
1074
+ // (§7).
1075
+ this.#minted.clear();
1076
+ for (const pending of this.#pending.values()) {
1077
+ pending.fail(new Error("this instance is stopping"));
1078
+ }
1079
+ this.#pending.clear();
1080
+ }
1081
+
1082
+ /** What is held per handshake right now, so a test can state that nothing is
1083
+ * kept once one has finished (§10.5). */
1084
+ get held(): { keys: number; handshakes: number; links: number } {
1085
+ return {
1086
+ keys: this.#minted.size,
1087
+ handshakes: this.#pending.size,
1088
+ links: this.#links.size,
1089
+ };
1090
+ }
1091
+
1092
+ // --- verification ---
1093
+
1094
+ async #fetchKey(claim: MeshClaim, challenge: string): Promise<MeshJwk> {
1095
+ const body: JwkRequest = { ver: MESH_VER, challenge };
1096
+ const response = await fetch(jwkEndpoint(claim.iss, claim.kid), {
1097
+ method: "POST",
1098
+ // One request and close, which is what the second connection is
1099
+ // (§6.2): it exists to carry the key and the challenge, and keeping it
1100
+ // pooled afterwards would leave a connection nothing speaks on.
1101
+ headers: { "content-type": "application/json", connection: "close" },
1102
+ body: JSON.stringify(body),
1103
+ signal: AbortSignal.timeout(PROOF_LIFETIME_MS),
1104
+ });
1105
+ if (!response.ok) throw new Error(`${claim.iss} did not hand out the key ${claim.kid}`);
1106
+ const jwk = (await response.json()) as MeshJwk;
1107
+ // §5.7-8, the third of the three ids that have to agree: a key served under
1108
+ // one id and answering to another would break the correspondence the whole
1109
+ // exchange is keyed on.
1110
+ if (jwk.kid !== claim.kid) throw new Error("the key served is not the key asked for");
1111
+ return jwk;
1112
+ }
1113
+
1114
+ /** §5.7, steps 4 to 8. */
1115
+ #verify(jws: string, claim: MeshClaim, challenge: string, jwk: MeshJwk): void {
1116
+ let parsed;
1117
+ try {
1118
+ parsed = parseProof(jws);
1119
+ } catch (cause) {
1120
+ throw cause instanceof ProofError ? cause : new Error(String(cause));
1121
+ }
1122
+ if (!ALLOWED_ALGS.has(parsed.alg)) throw new Error(`${parsed.alg} is not an allowed algorithm`);
1123
+ if (parsed.kid !== claim.kid) throw new Error("the proof names another key than the greeting");
1124
+ const stated = parsed.claim;
1125
+ if (stated.ver !== claim.ver || stated.iss !== claim.iss || stated.aud !== claim.aud) {
1126
+ throw new Error("the proof states something other than the greeting did");
1127
+ }
1128
+ if (stated.challenge !== challenge) throw new Error("the proof answers another challenge");
1129
+ if (typeof stated.exp !== "number" || stated.exp * 1000 <= Date.now()) {
1130
+ throw new Error("the proof has expired");
1131
+ }
1132
+ if (!verifyProof(jws, jwk)) throw new Error("the signature is not the key's");
1133
+ }
1134
+ }
1135
+
1136
+ /** A peer's reply, as the answer to the request the caller made.
1137
+ *
1138
+ * The body is passed through untouched — the destination decided it, and this
1139
+ * instance re-deciding any of it would put the same judgement in two places.
1140
+ * Only the correlation id changes, back to the one the caller used. */
1141
+ function answerOf(fields: Record<string, unknown>, requestId: string): DispatchResult {
1142
+ const { ok: _ok, request_id: _id, ...body } = fields;
1143
+ if (fields["ok"] === true) return reply(requestId, body);
1144
+ const error = fields["error"] as { code?: string; msg?: string } | undefined;
1145
+ return failure(
1146
+ requestId,
1147
+ (error?.code ?? "internal_error") as Parameters<typeof failure>[1],
1148
+ error?.msg ?? "the instance that answered said nothing about why",
1149
+ );
1150
+ }
1151
+
1152
+ function withTimeout<T>(promise: Promise<T>, ms: number, msg: string): Promise<T> {
1153
+ return new Promise<T>((resolve, reject) => {
1154
+ const timer = setTimeout(() => {
1155
+ reject(new Error(msg));
1156
+ }, ms);
1157
+ timer.unref?.();
1158
+ promise.then(
1159
+ (value) => {
1160
+ clearTimeout(timer);
1161
+ resolve(value);
1162
+ },
1163
+ (cause: unknown) => {
1164
+ clearTimeout(timer);
1165
+ reject(cause instanceof Error ? cause : new Error(String(cause)));
1166
+ },
1167
+ );
1168
+ });
1169
+ }