@cello-protocol/daemon 0.0.182 → 0.0.183

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 (90) hide show
  1. package/dist/agent-settings-keys.d.ts.map +1 -1
  2. package/dist/agent-settings-keys.js +18 -0
  3. package/dist/agent-settings-keys.js.map +1 -1
  4. package/dist/consortium-bootstrap.d.ts.map +1 -1
  5. package/dist/consortium-bootstrap.js +117 -2
  6. package/dist/consortium-bootstrap.js.map +1 -1
  7. package/dist/content-encryption-status.d.ts +97 -0
  8. package/dist/content-encryption-status.d.ts.map +1 -0
  9. package/dist/content-encryption-status.js +122 -0
  10. package/dist/content-encryption-status.js.map +1 -0
  11. package/dist/content-park-client.d.ts +10 -0
  12. package/dist/content-park-client.d.ts.map +1 -1
  13. package/dist/content-park-client.js +84 -3
  14. package/dist/content-park-client.js.map +1 -1
  15. package/dist/content-park.d.ts.map +1 -1
  16. package/dist/content-park.js +184 -13
  17. package/dist/content-park.js.map +1 -1
  18. package/dist/daemon.d.ts.map +1 -1
  19. package/dist/daemon.js +306 -23
  20. package/dist/daemon.js.map +1 -1
  21. package/dist/document-handlers.d.ts.map +1 -1
  22. package/dist/document-handlers.js +119 -0
  23. package/dist/document-handlers.js.map +1 -1
  24. package/dist/document-layer.d.ts +0 -2
  25. package/dist/document-layer.d.ts.map +1 -1
  26. package/dist/document-layer.js.map +1 -1
  27. package/dist/initiate-session-handler.d.ts.map +1 -1
  28. package/dist/initiate-session-handler.js +34 -3
  29. package/dist/initiate-session-handler.js.map +1 -1
  30. package/dist/manifest-deps.d.ts +0 -11
  31. package/dist/manifest-deps.d.ts.map +1 -1
  32. package/dist/manifest-deps.js +28 -2
  33. package/dist/manifest-deps.js.map +1 -1
  34. package/dist/manifest-validity.d.ts +2 -0
  35. package/dist/manifest-validity.d.ts.map +1 -1
  36. package/dist/manifest-validity.js +1 -1
  37. package/dist/manifest-validity.js.map +1 -1
  38. package/dist/outbound-sessions.d.ts.map +1 -1
  39. package/dist/outbound-sessions.js +19 -0
  40. package/dist/outbound-sessions.js.map +1 -1
  41. package/dist/park-envelope.d.ts +23 -0
  42. package/dist/park-envelope.d.ts.map +1 -1
  43. package/dist/park-envelope.js +76 -0
  44. package/dist/park-envelope.js.map +1 -1
  45. package/dist/register-handler.d.ts.map +1 -1
  46. package/dist/register-handler.js +76 -0
  47. package/dist/register-handler.js.map +1 -1
  48. package/dist/relay-only.d.ts +133 -0
  49. package/dist/relay-only.d.ts.map +1 -0
  50. package/dist/relay-only.js +183 -0
  51. package/dist/relay-only.js.map +1 -0
  52. package/dist/retry-queue.d.ts +26 -1
  53. package/dist/retry-queue.d.ts.map +1 -1
  54. package/dist/retry-queue.js +18 -1
  55. package/dist/retry-queue.js.map +1 -1
  56. package/dist/session-ceremony.d.ts +6 -0
  57. package/dist/session-ceremony.d.ts.map +1 -1
  58. package/dist/session-ceremony.js +26 -0
  59. package/dist/session-ceremony.js.map +1 -1
  60. package/dist/session-content-handlers.d.ts +12 -0
  61. package/dist/session-content-handlers.d.ts.map +1 -1
  62. package/dist/session-content-handlers.js +39 -4
  63. package/dist/session-content-handlers.js.map +1 -1
  64. package/dist/session-node-manager.d.ts +247 -4
  65. package/dist/session-node-manager.d.ts.map +1 -1
  66. package/dist/session-node-manager.js +3088 -304
  67. package/dist/session-node-manager.js.map +1 -1
  68. package/dist/session-read-handlers.d.ts.map +1 -1
  69. package/dist/session-read-handlers.js +16 -1
  70. package/dist/session-read-handlers.js.map +1 -1
  71. package/dist/session-relay-client.d.ts +116 -0
  72. package/dist/session-relay-client.d.ts.map +1 -1
  73. package/dist/session-relay-client.js +395 -6
  74. package/dist/session-relay-client.js.map +1 -1
  75. package/dist/session-salt-agreement.d.ts +58 -4
  76. package/dist/session-salt-agreement.d.ts.map +1 -1
  77. package/dist/session-salt-agreement.js +110 -3
  78. package/dist/session-salt-agreement.js.map +1 -1
  79. package/dist/signaling-connect.d.ts +18 -1
  80. package/dist/signaling-connect.d.ts.map +1 -1
  81. package/dist/signaling-connect.js +164 -1
  82. package/dist/signaling-connect.js.map +1 -1
  83. package/dist/types.d.ts +21 -0
  84. package/dist/types.d.ts.map +1 -1
  85. package/dist/types.js.map +1 -1
  86. package/dist/wire-content-hash.d.ts +15 -3
  87. package/dist/wire-content-hash.d.ts.map +1 -1
  88. package/dist/wire-content-hash.js +15 -3
  89. package/dist/wire-content-hash.js.map +1 -1
  90. package/package.json +5 -5
@@ -72,6 +72,97 @@ export const TERMINAL_RELAY_REFUSALS = new Set([
72
72
  export function isTerminalRelayRefusal(reason) {
73
73
  return reason !== undefined && TERMINAL_RELAY_REFUSALS.has(reason);
74
74
  }
75
+ /**
76
+ * DOD-M15-RELAYSLOTS-1 — **WHICH REFUSALS JUSTIFY TRYING A DIFFERENT RELAY.**
77
+ *
78
+ * We run several relays, so "move on to the next one" is always available — which is exactly why it
79
+ * needs a rule. Moving on from a problem that every relay will have turns one client-side fault into
80
+ * what looks like a fleet-wide outage, and the operator then goes looking for a broken relay.
81
+ *
82
+ * ENUMERATED, never pattern-matched, for the same reason `TERMINAL_RELAY_REFUSALS` is: a substring
83
+ * rule would silently absorb a future reason nobody has considered.
84
+ */
85
+ const RELAY_SIDE_REFUSALS = new Set([
86
+ /**
87
+ * The relay holds no directory public key, so it can verify nothing and is refusing everyone.
88
+ * That is this relay being misconfigured, not us being wrong — another relay is the right move,
89
+ * and it is the whole reason we run more than one.
90
+ */
91
+ "online_token_no_directory_key",
92
+ ]);
93
+ /**
94
+ * Classify a relay's auth refusal: what to tell the operator, and whether another relay would help.
95
+ *
96
+ * Everything not in `RELAY_SIDE_REFUSALS` defaults to "do not try another", and that default is the
97
+ * safe direction. A token problem reproduces identically on every relay in the fleet, so retrying
98
+ * around the fleet spends real time turning a client fault into an apparent outage — and a slot cap
99
+ * IS satisfiable elsewhere, but spreading to another relay papers over sessions that leaked and
100
+ * brings the same wall back on the next one.
101
+ */
102
+ export function classifyRelayAuthRefusal(reason, extra = {}) {
103
+ const tryAnotherRelay = RELAY_SIDE_REFUSALS.has(reason);
104
+ let advice;
105
+ switch (reason) {
106
+ case "online_token_required":
107
+ advice = "This agent has no online token from a directory yet. It is issued when a directory " +
108
+ "marks the agent online, so this usually clears itself on the next directory connection. If " +
109
+ "it persists, the agent is not reaching any directory — check that first, not the relay.";
110
+ break;
111
+ case "online_token_expired":
112
+ advice = "The online token has expired and is refreshed on the next directory connection. If " +
113
+ "it keeps expiring, this machine's clock or its directory connection is the thing to look at.";
114
+ break;
115
+ case "online_token_signature_invalid":
116
+ case "online_token_malformed":
117
+ case "online_token_lifetime_too_long":
118
+ advice = "This relay would not accept the token this agent was issued. Most often the relay " +
119
+ "and the directory are not in the same consortium — check which directories this relay is " +
120
+ "configured to trust.";
121
+ break;
122
+ case "online_token_pubkey_mismatch":
123
+ advice = "The token names a different key from the one this agent signed with. That is an " +
124
+ "identity mix-up on this machine, not a relay problem.";
125
+ break;
126
+ case "online_token_no_directory_key":
127
+ advice = "This relay holds no directory public key, so it cannot verify anyone and is " +
128
+ "refusing every agent. Its operator needs to configure one; another relay will work now.";
129
+ break;
130
+ case "slot_cap_exceeded":
131
+ advice = extra.slotsHeld !== undefined && extra.slotCap !== undefined
132
+ ? `This agent already holds ${String(extra.slotsHeld)} of a maximum ${String(extra.slotCap)} ` +
133
+ "reservations on this relay, and none is idle enough to reclaim. That is almost always " +
134
+ "sessions that were never closed — close some and this clears. Moving to another relay " +
135
+ "would work now and hit the same wall there."
136
+ : "This agent already holds the most reservations one agent may hold on this relay. That is " +
137
+ "almost always sessions that were never closed — close some and this clears.";
138
+ break;
139
+ case "session_tuple_cap_exceeded":
140
+ advice = extra.slotsHeld !== undefined && extra.slotCap !== undefined
141
+ ? `You already have ${String(extra.slotsHeld)} conversations open with this counterparty, ` +
142
+ `which is the maximum of ${String(extra.slotCap)} this relay allows between one pair of ` +
143
+ "agents. Close some and try again — this is almost always conversations that were never " +
144
+ "closed rather than ones anybody is still using."
145
+ : "You already have the maximum number of concurrent conversations open with this " +
146
+ "counterparty. Close some and try again.";
147
+ break;
148
+ case "rate_limited":
149
+ advice = extra.retryAfterMs !== undefined
150
+ ? `This relay is throttling this agent; it clears on its own in about ${String(Math.ceil(extra.retryAfterMs / 1000))}s.`
151
+ : "This relay is throttling this agent; it clears on its own after the throttle window.";
152
+ break;
153
+ default:
154
+ advice = "This agent could not authenticate to this relay, so it cannot hold a reservation " +
155
+ "here and is reachable only over a direct connection.";
156
+ }
157
+ return {
158
+ reason,
159
+ advice,
160
+ tryAnotherRelay,
161
+ ...(extra.slotsHeld !== undefined ? { slotsHeld: extra.slotsHeld } : {}),
162
+ ...(extra.slotCap !== undefined ? { slotCap: extra.slotCap } : {}),
163
+ ...(extra.retryAfterMs !== undefined ? { retryAfterMs: extra.retryAfterMs } : {}),
164
+ };
165
+ }
75
166
  export const LEAF_KIND_MSG = 0x00;
76
167
  /** Control leaf (SEAL etc.) — two distinct-sender ctrl leaves trigger directory notarization. */
77
168
  export const LEAF_KIND_CTRL = 0x02;
@@ -167,6 +258,18 @@ export class AgentRelayClient {
167
258
  #logger;
168
259
  #receiptStore;
169
260
  #sealLeafStore;
261
+ /** DOD-M15-RELAYSLOTS-1 — read fresh at every auth. See `AgentRelayClientOpts.onlineToken`. */
262
+ #onlineToken;
263
+ /**
264
+ * DOD-M15-RELAYSLOTS-1: the last refusal this relay gave us, classified. Kept because a log line
265
+ * reaches neither the operator asking why their agent is unreachable nor the code deciding
266
+ * whether a different relay would do any better.
267
+ */
268
+ #lastAuthRefusal = null;
269
+ /** The last classified auth refusal from this relay, or null if the last attempt succeeded. */
270
+ getLastAuthRefusal() {
271
+ return this.#lastAuthRefusal;
272
+ }
170
273
  #stream = null;
171
274
  #connecting = null;
172
275
  #closed = false;
@@ -182,6 +285,13 @@ export class AgentRelayClient {
182
285
  // The sender-signed structure1_cbor of the in-flight submit, paired with its ack so the
183
286
  // SubmitResult can carry it (the ack itself only returns the relay's structure2_cbor).
184
287
  #pendingStructure1 = null;
288
+ /**
289
+ * DOD-M15-SEALWIRE-1 bullet 5 (sent half) — OUR signature over the in-flight `#pendingStructure1`,
290
+ * paired with its ack for the same reason that one is: the ack returns the relay's record, never
291
+ * ours. Cleared wherever `#pendingStructure1` is cleared; the two must never drift apart, because
292
+ * a signature paired with the WRONG signed bytes is worse than no signature at all.
293
+ */
294
+ #pendingSignature = null;
185
295
  // The in-flight submit's leaf kind (0x00 msg / 0x02 ctrl), paired with its ack so
186
296
  // #captureReceipt can persist it alongside the Structure2/Structure1 carry bytes for the unilateral seal.
187
297
  #pendingLeafKind = null;
@@ -205,6 +315,7 @@ export class AgentRelayClient {
205
315
  this.#logger = opts.logger;
206
316
  this.#receiptStore = opts.receiptStore;
207
317
  this.#sealLeafStore = opts.sealLeafStore;
318
+ this.#onlineToken = opts.onlineToken;
208
319
  }
209
320
  /** The agent's K_local public key as hex — the responder identity for auto-acknowledge. */
210
321
  get senderPubkeyHex() {
@@ -236,6 +347,29 @@ export class AgentRelayClient {
236
347
  .then(() => undefined, () => undefined);
237
348
  }
238
349
  }
350
+ /**
351
+ * DOD-M15-RELAYAUTH-1 review H1 — **present the assignment and WAIT for the relay to say it
352
+ * recorded it.**
353
+ *
354
+ * `registerSession` above presents eagerly and forgets: the record is queued onto the submit chain
355
+ * and nobody can observe when it lands. That is correct for the witness relay, where the only
356
+ * requirement is "before the first submit". It is NOT sufficient for the relay that GATES A DIAL,
357
+ * because there the record is a precondition of an action we are about to take on another thread
358
+ * of the protocol — and losing that race denies a legitimate dial (review H1).
359
+ *
360
+ * Chained on `#submitChain` exactly like `#doSubmit`, so it cannot interleave with a submit on the
361
+ * same stream. Idempotent by construction: `#doRecord` returns `true` immediately once the session
362
+ * is recorded, so calling this straight after `registerSession` waits for the record that call
363
+ * already queued rather than sending a second one.
364
+ *
365
+ * Returns whether the relay recorded it. NEVER throws — a caller must be free to proceed on false
366
+ * (a dial that might be denied still beats no dial at all).
367
+ */
368
+ async recordAssignmentAndWait(node, sessionIdHex) {
369
+ const run = this.#submitChain.then(() => this.#doRecord(node, sessionIdHex));
370
+ this.#submitChain = run.then(() => undefined, () => undefined);
371
+ return run.catch(() => false);
372
+ }
239
373
  /**
240
374
  * Present the directory-signed assignment to the relay. Idempotent
241
375
  * (no-op once `recorded`, or when the session has no assignment — direct/persisted/legacy sessions).
@@ -327,12 +461,25 @@ export class AgentRelayClient {
327
461
  hasSessions() {
328
462
  return this.#sessions.size > 0;
329
463
  }
464
+ /**
465
+ * Is THIS session already registered on this client?
466
+ *
467
+ * DOD-M15-RELAYLEAK-1 (review MEDIUM-5). The detached seal transport releases its registration
468
+ * when the submit finishes, and "did I register it, or did I find it already there?" is the
469
+ * difference between releasing my own and **pulling a live one out from under a concurrent
470
+ * caller** — which closes the client that caller is mid-`submitLeaf` on. `hasSessions()` cannot
471
+ * answer it: it is a count, and by then the id is in the set either way.
472
+ */
473
+ hasSession(sessionIdHex) {
474
+ return this.#sessions.has(sessionIdHex);
475
+ }
330
476
  /** Settle the one outstanding submit (if any) exactly once. */
331
477
  #settlePending(r) {
332
478
  const resolve = this.#pendingAck;
333
479
  this.#pendingAck = null;
334
480
  this.#pendingAckSessionHex = null;
335
481
  this.#pendingStructure1 = null;
482
+ this.#pendingSignature = null;
336
483
  this.#pendingLeafKind = null;
337
484
  if (resolve)
338
485
  resolve(r);
@@ -472,6 +619,8 @@ export class AgentRelayClient {
472
619
  const s2 = frame["structure2_cbor"];
473
620
  const structure2Cbor = s2 instanceof Uint8Array ? s2 : undefined;
474
621
  const structure1Cbor = this.#pendingStructure1 ?? undefined;
622
+ // Captured with structure1Cbor and BEFORE #settlePending clears both — see #pendingSignature.
623
+ const senderSignature = this.#pendingSignature ?? undefined;
475
624
  // Verify the relay's signed ordering record and durably store the receipt BEFORE
476
625
  // settling (which clears #pendingStructure1, the source of the content hash + session id). A
477
626
  // signed-but-INVALID ACK rejects the submit so the send does not settle ok on an unverified sequence.
@@ -479,7 +628,7 @@ export class AgentRelayClient {
479
628
  this.#settlePending(rejectSubmit
480
629
  ? { ok: false, reason: "relay_ack_signature_invalid" }
481
630
  : seq >= 0
482
- ? { ok: true, sequence_number: seq, structure1_cbor: structure1Cbor, structure2_cbor: structure2Cbor }
631
+ ? { ok: true, sequence_number: seq, structure1_cbor: structure1Cbor, structure2_cbor: structure2Cbor, sender_signature: senderSignature }
483
632
  : { ok: false, reason: "relay_ack_malformed" });
484
633
  }
485
634
  else if (type === "hash_submit_error") {
@@ -487,7 +636,16 @@ export class AgentRelayClient {
487
636
  // Carry the relay's `detail` through — see `SubmitResult`. Reading the class and discarding
488
637
  // what happened is how a refusal arrives as a bare code with no cause attached to it.
489
638
  const detail = typeof frame["detail"] === "string" ? frame["detail"] : undefined;
490
- this.#settlePending({ ok: false, reason, ...(detail ? { detail } : {}) });
639
+ // DOD-M15-RELAYABUSE-1: the relay knows when its window clears and says so. Carried, not
640
+ // dropped — `#doSubmit` waits it out and resubmits, so a throttle never reaches the operator.
641
+ const rawRetry = frame["retry_after_ms"];
642
+ const retry_after_ms = typeof rawRetry === "number" && Number.isFinite(rawRetry) && rawRetry > 0 ? rawRetry : undefined;
643
+ this.#settlePending({
644
+ ok: false,
645
+ reason,
646
+ ...(detail ? { detail } : {}),
647
+ ...(retry_after_ms !== undefined ? { retry_after_ms } : {}),
648
+ });
491
649
  }
492
650
  else if (type === "assignment_ok") {
493
651
  // The relay verified + recorded our client-presented assignment.
@@ -499,7 +657,23 @@ export class AgentRelayClient {
499
657
  else if (type === "assignment_invalid") {
500
658
  // The relay rejected the assignment (e.g. directory_signature_invalid — not signed by any
501
659
  // consortium directory). Fail LOUD: the session has no relay witness until this is resolved.
502
- this.#logger.warn("session.relay.assignment.invalid", { relayPeerId: this.#relayPeerId, reason: typeof frame["reason"] === "string" ? frame["reason"] : "unknown" });
660
+ const reason = typeof frame["reason"] === "string" ? frame["reason"] : "unknown";
661
+ this.#logger.warn("session.relay.assignment.invalid", { relayPeerId: this.#relayPeerId, reason });
662
+ /**
663
+ * DOD-M15-RELAYSLOTS-1 review M2 — **the tuple cap has to reach the operator too.**
664
+ *
665
+ * Clause 7 says EVERY refusal reaches them with a cause and an affordance, and this one was
666
+ * arriving as `assignment_invalid` in a log. It is also the refusal most likely to hit a real
667
+ * person, for the reason the order itself gives: nobody knows what sessions they have open, so
668
+ * whoever hits it believes they have none. Routed through the same classifier and onto the
669
+ * same surface as every other relay refusal.
670
+ */
671
+ const concurrent = typeof frame["concurrent_sessions"] === "number" ? frame["concurrent_sessions"] : undefined;
672
+ const cap = typeof frame["session_cap"] === "number" ? frame["session_cap"] : undefined;
673
+ this.#lastAuthRefusal = classifyRelayAuthRefusal(reason, {
674
+ ...(concurrent !== undefined ? { slotsHeld: concurrent } : {}),
675
+ ...(cap !== undefined ? { slotCap: cap } : {}),
676
+ });
503
677
  const r = this.#pendingRecord;
504
678
  this.#pendingRecord = null;
505
679
  if (r)
@@ -559,6 +733,36 @@ export class AgentRelayClient {
559
733
  });
560
734
  }
561
735
  }
736
+ else if (type === "relay_slot_reclaimed") {
737
+ /**
738
+ * DOD-M15-RELAYSLOTS-1 clause 8 — **the reaped party is told, and this is where it lands.**
739
+ *
740
+ * The relay reclaimed this agent's circuit reservation to free capacity. Without a branch
741
+ * here the frame fell off the end of this chain and was discarded in silence, which is the
742
+ * trap the order records in its own words: a refusal that only reaches the relay's log does
743
+ * not exist. From the agent's side the reservation simply stops working.
744
+ *
745
+ * Recorded as a refusal so it reaches `cello_status` through the same surface as every other
746
+ * relay refusal, with the same shape: a cause, and what to do about it.
747
+ */
748
+ const idleMs = typeof frame["idle_ms"] === "number" ? frame["idle_ms"] : undefined;
749
+ const detail = typeof frame["detail"] === "string" ? frame["detail"] : undefined;
750
+ this.#lastAuthRefusal = {
751
+ reason: "slot_reclaimed",
752
+ advice: detail ?? "This relay reclaimed your circuit reservation to free capacity because it " +
753
+ "had carried no traffic for a long time. Your agent stays online and rebuilds its receiver " +
754
+ "automatically; a new session will take a fresh reservation.",
755
+ // Not the relay's fault and not ours — it was under pressure and we were the quietest. The
756
+ // client rebuilds against the same pool, so there is nothing to fail over from.
757
+ tryAnotherRelay: false,
758
+ };
759
+ this.#logger.warn("session.relay.slot_reclaimed", {
760
+ relayPeerId: this.#relayPeerId,
761
+ ...(idleMs !== undefined ? { idleHours: Math.round(idleMs / 3_600_000) } : {}),
762
+ impact: "this relay reclaimed our circuit reservation to free capacity. Until a receiver is " +
763
+ "rebuilt, this agent is reachable only over a direct connection.",
764
+ });
765
+ }
562
766
  // session_interrupted / content_park_notify are out of scope here — session interruption
563
767
  // is handled by the session node manager's dedicated relay-stream watcher.
564
768
  }
@@ -570,6 +774,61 @@ export class AgentRelayClient {
570
774
  async connect(node) {
571
775
  return this.#ensureConnected(node);
572
776
  }
777
+ /**
778
+ * DOD-M15-RELAYAUTH-1 review HIGH-1 — prove key possession FROM THIS NODE, on its own stream.
779
+ *
780
+ * ⚠️ **`connect()` CANNOT be used for this, and using it was the defect.** `#ensureConnected`
781
+ * returns `true` the moment `#stream` is non-null, and `#stream` belongs to whichever node
782
+ * connected FIRST. An agent legitimately runs several nodes against one relay — the node promoted
783
+ * into a live session, plus the replacement standing receiver built behind it — and they share
784
+ * one `AgentRelayClient` because the cache is keyed `${agent}::${relay}`. So calling `connect()`
785
+ * from the replacement receiver short-circuited on the session node's stream, sent nothing, and
786
+ * the relay never saw that receiver's transport identity: it revoked the reservation ~15s later,
787
+ * the watchdog rebuilt, and the agent churned on a ~45s loop holding no usable circuit address
788
+ * for as long as the conversation lasted. Any future "reuse the existing connection" optimisation
789
+ * here reintroduces exactly that.
790
+ *
791
+ * So this always opens its own short-lived stream from `node`, and marks it
792
+ * `purpose: "reservation"` so the relay proves possession WITHOUT rebinding the agent's delivery
793
+ * stream (which would steal the live session's inbound leaves — see the relay-side dispatch).
794
+ */
795
+ async proveReservation(node) {
796
+ if (this.#closed)
797
+ return false;
798
+ for (const addr of this.#relayAddrs) {
799
+ try {
800
+ await node.dial(addr);
801
+ break;
802
+ }
803
+ catch { /* try the next address */ }
804
+ }
805
+ let stream;
806
+ try {
807
+ stream = await node.newStream(this.#relayPeerId, RELAY_PROTOCOL_ID);
808
+ }
809
+ catch (err) {
810
+ this.#logger.warn("session.relay.reservation_proof.failed", {
811
+ relayPeerId: this.#relayPeerId, reason: "stream", error: extractErrorMessage(err),
812
+ });
813
+ return false;
814
+ }
815
+ try {
816
+ const iter = lp.decode(stream)[Symbol.asyncIterator]();
817
+ const ok = await this.#authenticate(stream, iter, "reservation");
818
+ this.#logger.info("session.relay.reservation_proof.result", {
819
+ relayPeerId: this.#relayPeerId,
820
+ nodePeerId: node.getPeerId(),
821
+ ok,
822
+ // DOD-M15-RELAYSLOTS-1: name the cause here too. `ok: false` alone sent people looking at
823
+ // the transport for what is usually a token or a cap.
824
+ ...(ok ? {} : { refusalReason: this.#lastAuthRefusal?.reason ?? "no_relay_verdict" }),
825
+ });
826
+ return ok;
827
+ }
828
+ finally {
829
+ await stream.close().catch(() => { });
830
+ }
831
+ }
573
832
  /** Ensure an authenticated stream exists, (re)dialing from `node` if needed. */
574
833
  async #ensureConnected(node) {
575
834
  if (this.#closed)
@@ -622,7 +881,7 @@ export class AgentRelayClient {
622
881
  this.#startReader(stream, iter);
623
882
  return true;
624
883
  }
625
- async #authenticate(stream, iter) {
884
+ async #authenticate(stream, iter, purpose) {
626
885
  const challengeRes = await nextWithTimeout(iter, RELAY_AUTH_TIMEOUT_MS);
627
886
  if (challengeRes.done || challengeRes.value === undefined) {
628
887
  this.#logger.warn("session.relay.auth.failed", { relayPeerId: this.#relayPeerId, reason: "no_challenge" });
@@ -646,8 +905,37 @@ export class AgentRelayClient {
646
905
  return false;
647
906
  }
648
907
  const authSig = await this.#keyProvider.sign(buildRelayAuthPayload(nonce, this.#senderPubkey));
908
+ /**
909
+ * DOD-M15-RELAYSLOTS-1: read the token NOW, not at construction — it is reissued on every
910
+ * signaling reconnect and the one this client was built with is usually already gone.
911
+ *
912
+ * When there is none we send the auth anyway. Declining to try would replace a named refusal
913
+ * from the relay (`online_token_required`, which says what is wrong and what to do) with
914
+ * silence on both sides — and silence is what an operator reads as "the product is broken".
915
+ */
916
+ const onlineToken = this.#onlineToken?.();
917
+ if (!onlineToken) {
918
+ this.#logger.warn("session.relay.auth.no_online_token", {
919
+ relayPeerId: this.#relayPeerId,
920
+ impact: "authenticating without the directory's online token. The relay will refuse this and " +
921
+ "will not let this node keep a circuit reservation, so the agent is reachable by nobody " +
922
+ "over this relay. The usual cause is that no directory connection has been established " +
923
+ "yet; the next signaling connect issues a token and the receiver re-authenticates.",
924
+ });
925
+ }
649
926
  try {
650
- stream.send(lp.encode.single(encodeCbor({ type: "relay_auth_response", pubkey: this.#senderPubkey, signature: authSig })));
927
+ stream.send(lp.encode.single(encodeCbor({
928
+ type: "relay_auth_response",
929
+ pubkey: this.#senderPubkey,
930
+ signature: authSig,
931
+ // DOD-M15-RELAYAUTH-1: absent for the ordinary session auth (which also registers this
932
+ // stream as the agent's delivery target). `"reservation"` proves possession from THIS
933
+ // node's transport identity and nothing more — see proveReservation().
934
+ ...(purpose ? { purpose } : {}),
935
+ // DOD-M15-RELAYSLOTS-1: opaque bytes from the directory, forwarded verbatim. The client
936
+ // never parses them — a format it does not read is a format it cannot get wrong.
937
+ ...(onlineToken ? { online_token: onlineToken } : {}),
938
+ })));
651
939
  }
652
940
  catch (err) {
653
941
  this.#logger.warn("session.relay.auth.failed", { relayPeerId: this.#relayPeerId, reason: "response_send", error: extractErrorMessage(err) });
@@ -667,12 +955,50 @@ export class AgentRelayClient {
667
955
  return false;
668
956
  }
669
957
  if (ackFrame["type"] !== "relay_auth_ok") {
958
+ /**
959
+ * DOD-M15-RELAYABUSE-1 review F2 — **THE RELAY SAYS WHY, AND WE USED TO THROW IT AWAY.**
960
+ *
961
+ * `relay_auth_failed` carries a `reason` — `rate_limited`, `signature_invalid`, `nonce_expired`,
962
+ * `nonce_reused`, `nonce_unknown` — and, when throttled, a `retry_after_ms`. This branch
963
+ * collapsed all of them into the single word `auth_rejected`, so a throttled agent looked
964
+ * exactly like a bad signature, which looked exactly like a dead relay. That is precisely the
965
+ * distinction the order that added those refusals set out to create, undone at the last hop.
966
+ *
967
+ * The difference matters to whoever is looking: `rate_limited` clears by itself and says when;
968
+ * `signature_invalid` never clears and means a key or clock problem; a nonce failure means the
969
+ * handshake raced and an immediate retry is the right move. One label for all three sends
970
+ * someone to look for a broken relay in all three cases.
971
+ */
972
+ const relayReason = typeof ackFrame["reason"] === "string" ? ackFrame["reason"] : undefined;
973
+ const retryAfterMs = typeof ackFrame["retry_after_ms"] === "number" ? ackFrame["retry_after_ms"] : undefined;
974
+ /**
975
+ * DOD-M15-RELAYSLOTS-1: keep the refusal, do not merely log it.
976
+ *
977
+ * Everything below writes an excellent warn line into a file nobody opens. The operator who
978
+ * runs `cello_use_agent` and finds their agent unreachable never sees it, and the daemon
979
+ * deciding whether another relay would help cannot read it either. So the classified refusal
980
+ * — reason, what to do about it, and whether to fail over — is stored where both can reach it.
981
+ */
982
+ const slotsHeld = typeof ackFrame["slots_held"] === "number" ? ackFrame["slots_held"] : undefined;
983
+ const slotCap = typeof ackFrame["slot_cap"] === "number" ? ackFrame["slot_cap"] : undefined;
984
+ this.#lastAuthRefusal = classifyRelayAuthRefusal(ackFrame["type"] === "relay_auth_failed" ? (relayReason ?? "auth_rejected") : "unexpected_frame", {
985
+ ...(slotsHeld !== undefined ? { slotsHeld } : {}),
986
+ ...(slotCap !== undefined ? { slotCap } : {}),
987
+ ...(retryAfterMs !== undefined ? { retryAfterMs } : {}),
988
+ });
670
989
  this.#logger.warn("session.relay.auth.failed", {
671
990
  relayPeerId: this.#relayPeerId,
672
- reason: ackFrame["type"] === "relay_auth_failed" ? "auth_rejected" : "unexpected_frame",
991
+ reason: ackFrame["type"] === "relay_auth_failed" ? (relayReason ?? "auth_rejected") : "unexpected_frame",
992
+ ...(retryAfterMs !== undefined ? { retryAfterMs } : {}),
993
+ impact: relayReason === "rate_limited"
994
+ ? "this relay is throttling us; it clears on its own after the stated window, and until it does this agent cannot reserve or witness here"
995
+ : "this agent could not authenticate to this relay, so it cannot witness leaves or hold a reservation here",
673
996
  });
674
997
  return false;
675
998
  }
999
+ // DOD-M15-RELAYSLOTS-1: a success clears the stored refusal, so a stale one is never reported
1000
+ // as the current state of a relay that has since started admitting us.
1001
+ this.#lastAuthRefusal = null;
676
1002
  return true;
677
1003
  }
678
1004
  #startReader(stream, iter) {
@@ -906,6 +1232,21 @@ export class AgentRelayClient {
906
1232
  * and returned as-is — retrying those would be pointless traffic masking a real state.
907
1233
  */
908
1234
  static #SESSION_NOT_FOUND_ATTEMPTS = 3;
1235
+ /**
1236
+ * DOD-M15-RELAYABUSE-1 review F1: how many times a throttled submit waits out the relay's stated
1237
+ * window before the refusal is surfaced. Three, because the window is a fixed sliding minute —
1238
+ * two waits clear any ordinary burst, and a third failure means something other than this
1239
+ * sender's own volume is going on, which the operator should hear about.
1240
+ */
1241
+ static #RATE_LIMITED_ATTEMPTS = 3;
1242
+ /** Used only when the relay names no window (an older relay, or a malformed value). */
1243
+ static #RATE_LIMITED_FALLBACK_MS = 5_000;
1244
+ /**
1245
+ * Ceiling on a single wait, so a relay reporting an implausible window cannot park a send
1246
+ * indefinitely — a hostile or misconfigured relay must not be able to stall a sender by
1247
+ * answering `retry_after_ms: 3600000`. Past this the send fails and says so.
1248
+ */
1249
+ static #RATE_LIMITED_MAX_WAIT_MS = 65_000;
909
1250
  async #doSubmit(node, sessionId, contentHash, leafKind, contentBytes) {
910
1251
  const sessionIdHex = Buffer.from(sessionId).toString("hex");
911
1252
  // Snapshotted BEFORE the first attempt, and it is the whole safety of this loop.
@@ -945,6 +1286,51 @@ export class AgentRelayClient {
945
1286
  });
946
1287
  result = await this.#doSubmitOnce(node, sessionId, contentHash, leafKind, contentBytes);
947
1288
  }
1289
+ /**
1290
+ * DOD-M15-RELAYABUSE-1 review F1 — **A THROTTLE IS BACK-PRESSURE, NOT AN ERROR.** (Andre,
1291
+ * 2026-08-31: retry on the relay's own timing; surface only if the retry also fails.)
1292
+ *
1293
+ * Without this the relay's `rate_limited` fell into the caller's catch-all: one log line, and
1294
+ * `cello_send` returned `{ok:true, delivered:true}` for a message the relay had refused to
1295
+ * witness — on the parked path telling the operator it was *"sealed, witnessed and on its way"*.
1296
+ * The leaf went out unwitnessed with no sequence number, and the seal later covered a transcript
1297
+ * missing it.
1298
+ *
1299
+ * This is the ONE refusal that is safely retryable and self-clearing, and the relay tells us
1300
+ * exactly when. So we wait it out here, where the wait is invisible, rather than handing the
1301
+ * agent an error for a condition that resolves in under a minute. Bounded: if the window is
1302
+ * absent or implausible we fall back to a fixed wait, and after
1303
+ * `#RATE_LIMITED_ATTEMPTS` the refusal is returned and the caller surfaces it — Option 2 as
1304
+ * the fallback, not the first move.
1305
+ */
1306
+ for (let attempt = 1; attempt < _a.#RATE_LIMITED_ATTEMPTS
1307
+ && !result.ok
1308
+ && result.reason === "rate_limited"
1309
+ && !this.#closed; attempt++) {
1310
+ const waitMs = Math.min(result.retry_after_ms !== undefined ? result.retry_after_ms : _a.#RATE_LIMITED_FALLBACK_MS, _a.#RATE_LIMITED_MAX_WAIT_MS);
1311
+ this.#logger.info("session.relay.submit.throttled", {
1312
+ relayPeerId: this.#relayPeerId,
1313
+ sessionShort: sessionIdHex.slice(0, 16),
1314
+ attempt,
1315
+ waitMs,
1316
+ retryAfterMsFromRelay: result.retry_after_ms,
1317
+ impact: "the relay is throttling this sender; waiting out its stated window and resubmitting — the message is NOT lost and the operator is not told, because this clears on its own",
1318
+ });
1319
+ await new Promise((r) => setTimeout(r, waitMs));
1320
+ if (this.#closed)
1321
+ break;
1322
+ result = await this.#doSubmitOnce(node, sessionId, contentHash, leafKind, contentBytes);
1323
+ }
1324
+ if (!result.ok && result.reason === "rate_limited") {
1325
+ // Option 2, the fallback: it did not clear within our budget, so the caller must hear it
1326
+ // rather than be told the message was witnessed.
1327
+ this.#logger.warn("session.relay.submit.throttle_persisted", {
1328
+ relayPeerId: this.#relayPeerId,
1329
+ sessionShort: sessionIdHex.slice(0, 16),
1330
+ attempts: _a.#RATE_LIMITED_ATTEMPTS,
1331
+ impact: "this message was NOT witnessed by the relay — it has no sequence number and will not appear in the notarized record",
1332
+ });
1333
+ }
948
1334
  // The relay lost a session we had successfully recorded — sealed, idle-swept, or restarted.
949
1335
  // Report THAT, rather than letting the caller read a bare `session_not_found` that reads like
950
1336
  // the first-message race. Never re-present here: recreating it is the resurrection above.
@@ -1041,6 +1427,7 @@ export class AgentRelayClient {
1041
1427
  // Remember this submit's sender-signed structure1_cbor so its ack can return the full
1042
1428
  // ordering record (the ack itself carries only the relay's structure2_cbor).
1043
1429
  this.#pendingStructure1 = structure1;
1430
+ this.#pendingSignature = signature;
1044
1431
  this.#pendingLeafKind = leafKind;
1045
1432
  try {
1046
1433
  stream.send(lp.encode.single(frame));
@@ -1050,6 +1437,7 @@ export class AgentRelayClient {
1050
1437
  this.#pendingAck = null;
1051
1438
  this.#pendingAckSessionHex = null;
1052
1439
  this.#pendingStructure1 = null;
1440
+ this.#pendingSignature = null;
1053
1441
  this.#pendingLeafKind = null;
1054
1442
  }
1055
1443
  this.#logger.warn("session.relay.submit.send.failed", { relayPeerId: this.#relayPeerId, error: extractErrorMessage(err) });
@@ -1072,6 +1460,7 @@ export class AgentRelayClient {
1072
1460
  this.#pendingAck = null;
1073
1461
  this.#pendingAckSessionHex = null;
1074
1462
  this.#pendingStructure1 = null;
1463
+ this.#pendingSignature = null;
1075
1464
  this.#pendingLeafKind = null;
1076
1465
  }
1077
1466
  }