@cello-protocol/daemon 0.0.53 → 0.0.55

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 (177) hide show
  1. package/dist/agent-handlers.d.ts +57 -0
  2. package/dist/agent-handlers.d.ts.map +1 -0
  3. package/dist/agent-handlers.js +360 -0
  4. package/dist/agent-handlers.js.map +1 -0
  5. package/dist/agent-id-migration.d.ts.map +1 -1
  6. package/dist/agent-id-migration.js +1 -0
  7. package/dist/agent-id-migration.js.map +1 -1
  8. package/dist/agent-loader.d.ts +3 -4
  9. package/dist/agent-loader.d.ts.map +1 -1
  10. package/dist/agent-loader.js +3 -4
  11. package/dist/agent-loader.js.map +1 -1
  12. package/dist/cbor-blob-migration.d.ts +8 -0
  13. package/dist/cbor-blob-migration.d.ts.map +1 -0
  14. package/dist/cbor-blob-migration.js +115 -0
  15. package/dist/cbor-blob-migration.js.map +1 -0
  16. package/dist/close-session-handler.d.ts +41 -0
  17. package/dist/close-session-handler.d.ts.map +1 -0
  18. package/dist/close-session-handler.js +399 -0
  19. package/dist/close-session-handler.js.map +1 -0
  20. package/dist/consortium-bootstrap.d.ts +83 -0
  21. package/dist/consortium-bootstrap.d.ts.map +1 -0
  22. package/dist/consortium-bootstrap.js +120 -0
  23. package/dist/consortium-bootstrap.js.map +1 -0
  24. package/dist/contact-handlers.d.ts +56 -0
  25. package/dist/contact-handlers.d.ts.map +1 -0
  26. package/dist/contact-handlers.js +303 -0
  27. package/dist/contact-handlers.js.map +1 -0
  28. package/dist/content-park-client.d.ts.map +1 -1
  29. package/dist/content-park-client.js +7 -7
  30. package/dist/content-park-client.js.map +1 -1
  31. package/dist/content-park.d.ts +15 -0
  32. package/dist/content-park.d.ts.map +1 -0
  33. package/dist/content-park.js +227 -0
  34. package/dist/content-park.js.map +1 -0
  35. package/dist/daemon.d.ts +4 -10
  36. package/dist/daemon.d.ts.map +1 -1
  37. package/dist/daemon.js +430 -4563
  38. package/dist/daemon.js.map +1 -1
  39. package/dist/db-identity-store.d.ts +31 -35
  40. package/dist/db-identity-store.d.ts.map +1 -1
  41. package/dist/db-identity-store.js +62 -66
  42. package/dist/db-identity-store.js.map +1 -1
  43. package/dist/directory-bootstrap.d.ts +27 -31
  44. package/dist/directory-bootstrap.d.ts.map +1 -1
  45. package/dist/directory-bootstrap.js +18 -21
  46. package/dist/directory-bootstrap.js.map +1 -1
  47. package/dist/frame-values.d.ts +3 -0
  48. package/dist/frame-values.d.ts.map +1 -0
  49. package/dist/frame-values.js +73 -0
  50. package/dist/frame-values.js.map +1 -0
  51. package/dist/identity-migration.d.ts +8 -8
  52. package/dist/identity-migration.d.ts.map +1 -1
  53. package/dist/identity-migration.js +30 -31
  54. package/dist/identity-migration.js.map +1 -1
  55. package/dist/inbound-seal-request.d.ts +17 -0
  56. package/dist/inbound-seal-request.d.ts.map +1 -0
  57. package/dist/inbound-seal-request.js +155 -0
  58. package/dist/inbound-seal-request.js.map +1 -0
  59. package/dist/inbound-sessions.d.ts +54 -0
  60. package/dist/inbound-sessions.d.ts.map +1 -0
  61. package/dist/inbound-sessions.js +603 -0
  62. package/dist/inbound-sessions.js.map +1 -0
  63. package/dist/index.d.ts.map +1 -1
  64. package/dist/index.js +8 -10
  65. package/dist/index.js.map +1 -1
  66. package/dist/initiate-session-handler.d.ts +22 -0
  67. package/dist/initiate-session-handler.d.ts.map +1 -0
  68. package/dist/initiate-session-handler.js +150 -0
  69. package/dist/initiate-session-handler.js.map +1 -0
  70. package/dist/network-directory-node.d.ts +6 -6
  71. package/dist/network-directory-node.d.ts.map +1 -1
  72. package/dist/network-directory-node.js +29 -28
  73. package/dist/network-directory-node.js.map +1 -1
  74. package/dist/notification-handlers.d.ts +32 -0
  75. package/dist/notification-handlers.d.ts.map +1 -0
  76. package/dist/notification-handlers.js +73 -0
  77. package/dist/notification-handlers.js.map +1 -0
  78. package/dist/outbound-sessions.d.ts +54 -0
  79. package/dist/outbound-sessions.d.ts.map +1 -0
  80. package/dist/outbound-sessions.js +429 -0
  81. package/dist/outbound-sessions.js.map +1 -0
  82. package/dist/park-envelope.d.ts.map +1 -1
  83. package/dist/park-envelope.js +3 -2
  84. package/dist/park-envelope.js.map +1 -1
  85. package/dist/register-handler.d.ts +35 -0
  86. package/dist/register-handler.d.ts.map +1 -0
  87. package/dist/register-handler.js +179 -0
  88. package/dist/register-handler.js.map +1 -0
  89. package/dist/registration-manager.d.ts.map +1 -1
  90. package/dist/registration-manager.js +3 -6
  91. package/dist/registration-manager.js.map +1 -1
  92. package/dist/relay-receipt-store.d.ts.map +1 -1
  93. package/dist/relay-receipt-store.js +1 -2
  94. package/dist/relay-receipt-store.js.map +1 -1
  95. package/dist/retry-queue.d.ts +18 -60
  96. package/dist/retry-queue.d.ts.map +1 -1
  97. package/dist/retry-queue.js +46 -97
  98. package/dist/retry-queue.js.map +1 -1
  99. package/dist/seal-coordinator.d.ts +47 -0
  100. package/dist/seal-coordinator.d.ts.map +1 -0
  101. package/dist/seal-coordinator.js +551 -0
  102. package/dist/seal-coordinator.js.map +1 -0
  103. package/dist/seal-flows.d.ts +50 -0
  104. package/dist/seal-flows.d.ts.map +1 -0
  105. package/dist/seal-flows.js +454 -0
  106. package/dist/seal-flows.js.map +1 -0
  107. package/dist/seal-frontier-verify.d.ts +9 -9
  108. package/dist/seal-frontier-verify.d.ts.map +1 -1
  109. package/dist/seal-frontier-verify.js +11 -12
  110. package/dist/seal-frontier-verify.js.map +1 -1
  111. package/dist/seal-leaf.d.ts +58 -0
  112. package/dist/seal-leaf.d.ts.map +1 -0
  113. package/dist/seal-leaf.js +111 -0
  114. package/dist/seal-leaf.js.map +1 -0
  115. package/dist/seal-receipt-upgrade.d.ts +1 -1
  116. package/dist/seal-receipt-upgrade.js +1 -1
  117. package/dist/seal-upgrade.d.ts +10 -10
  118. package/dist/seal-upgrade.d.ts.map +1 -1
  119. package/dist/seal-upgrade.js +23 -24
  120. package/dist/seal-upgrade.js.map +1 -1
  121. package/dist/session-assignment-parser.d.ts +16 -19
  122. package/dist/session-assignment-parser.d.ts.map +1 -1
  123. package/dist/session-assignment-parser.js +30 -29
  124. package/dist/session-assignment-parser.js.map +1 -1
  125. package/dist/session-ceremony.d.ts +3 -6
  126. package/dist/session-ceremony.d.ts.map +1 -1
  127. package/dist/session-ceremony.js +7 -9
  128. package/dist/session-ceremony.js.map +1 -1
  129. package/dist/session-connection-gater.d.ts +4 -2
  130. package/dist/session-connection-gater.d.ts.map +1 -1
  131. package/dist/session-connection-gater.js +16 -10
  132. package/dist/session-connection-gater.js.map +1 -1
  133. package/dist/session-content-handlers.d.ts +25 -0
  134. package/dist/session-content-handlers.d.ts.map +1 -0
  135. package/dist/session-content-handlers.js +436 -0
  136. package/dist/session-content-handlers.js.map +1 -0
  137. package/dist/session-name.d.ts +35 -0
  138. package/dist/session-name.d.ts.map +1 -0
  139. package/dist/session-name.js +60 -0
  140. package/dist/session-name.js.map +1 -0
  141. package/dist/session-node-manager.d.ts +47 -62
  142. package/dist/session-node-manager.d.ts.map +1 -1
  143. package/dist/session-node-manager.js +297 -97
  144. package/dist/session-node-manager.js.map +1 -1
  145. package/dist/session-read-handlers.d.ts +38 -0
  146. package/dist/session-read-handlers.d.ts.map +1 -0
  147. package/dist/session-read-handlers.js +233 -0
  148. package/dist/session-read-handlers.js.map +1 -0
  149. package/dist/session-relay-client.d.ts +8 -8
  150. package/dist/session-relay-client.d.ts.map +1 -1
  151. package/dist/session-relay-client.js +44 -46
  152. package/dist/session-relay-client.js.map +1 -1
  153. package/dist/session-tree.d.ts +11 -23
  154. package/dist/session-tree.d.ts.map +1 -1
  155. package/dist/session-tree.js +14 -26
  156. package/dist/session-tree.js.map +1 -1
  157. package/dist/signaling-connect.d.ts +13 -5
  158. package/dist/signaling-connect.d.ts.map +1 -1
  159. package/dist/signaling-connect.js +55 -13
  160. package/dist/signaling-connect.js.map +1 -1
  161. package/dist/sqlcipher-db.d.ts +18 -19
  162. package/dist/sqlcipher-db.d.ts.map +1 -1
  163. package/dist/sqlcipher-db.js +30 -30
  164. package/dist/sqlcipher-db.js.map +1 -1
  165. package/dist/telegram-doorbell.d.ts +38 -0
  166. package/dist/telegram-doorbell.d.ts.map +1 -0
  167. package/dist/telegram-doorbell.js +139 -0
  168. package/dist/telegram-doorbell.js.map +1 -0
  169. package/dist/types.d.ts +86 -79
  170. package/dist/types.d.ts.map +1 -1
  171. package/dist/types.js +0 -1
  172. package/dist/types.js.map +1 -1
  173. package/dist/vocabulary.d.ts +30 -30
  174. package/dist/vocabulary.d.ts.map +1 -1
  175. package/dist/vocabulary.js +41 -49
  176. package/dist/vocabulary.js.map +1 -1
  177. package/package.json +5 -5
@@ -8,73 +8,26 @@
8
8
  * 2. Standing receiver node: pre-created, open gater, kept alive at all times.
9
9
  * Handed to the first inbound session; immediately replaced.
10
10
  * 3. 32-node cap: enforced before any new node is created.
11
- * 4. Session status in SQLite: active → sealed (on close) or interrupted
11
+ * 4. Session status in the DB: active → sealed (on close) or interrupted
12
12
  * (on graceful shutdown or SIGKILL-restart detection).
13
13
  *
14
- * Pseudocode (SPARC Phase P):
15
- *
16
- * initialize():
17
- * 1. Open SQLite (node:sqlite), create sessions table if not exists
18
- * 2. Detect interrupted sessions: SELECT * FROM sessions WHERE status='active'
19
- * → batch-update to 'interrupted', log session.interrupted.detected for each
20
- * (source: 'daemon_restart') — runs before IPC socket opens so no race
21
- * 3. Create standing receiver node (fresh libp2p, open gater, sentinel agentName)
22
- * 4. Start standing receiver, set standingReceiverReady=true
23
- * 5. Log session.node.created for the standing receiver
24
- *
25
- * createSessionNode(sessionId, agentName, counterpartyPubkey, counterpartyPeerId, correlationId):
26
- * Pseudocode:
27
- * 1. Check activeNodes.size >= MAX_SESSION_NODES → log cap.reached, return error
28
- * 2. Create SessionConnectionGater(counterpartyPeerId) — restricted from birth
29
- * 3. nodeFactory.createNode({gater}) → fresh libp2p node
30
- * 4. node.start() — bind TCP ephemeral port
31
- * 5. Insert SQLite row status='active'
32
- * 6. Log session.node.created
33
- * 7. Add to activeNodes map
34
- * 8. Return {ok:true, peerId, addrs}
35
- * On libp2p error: extract error.message (never ${error}), log create.failed, return error
36
- *
37
- * acceptSession(sessionId, agentName, counterpartyPubkey, initiatorPeerId, correlationId):
38
- * Pseudocode:
39
- * 1. If !standingReceiverReady → return standing_receiver_unavailable
40
- * 2. Take standing receiver from slot (clear slot atomically)
41
- * 3. gater.setAllowedPeer(initiatorPeerId) ← BEFORE returning multiaddr (AC-015)
42
- * 4. Insert SQLite row status='active'
43
- * 5. Log session.node.created
44
- * 6. Add to activeNodes map
45
- * 7. Trigger async replacement of standing receiver (do NOT await)
46
- * 8. Return {ok:true, peerId, addrs}
47
- *
48
- * destroySessionNode(sessionId, reason):
49
- * Pseudocode:
50
- * 1. Find node in activeNodes
51
- * 2. stop node
52
- * 3. Update SQLite status to sealed/interrupted/error
53
- * 4. Remove from activeNodes
54
- * 5. Log session.node.destroyed
55
- *
56
- * gracefulShutdown():
57
- * Pseudocode:
58
- * 1. Get all activeNodes
59
- * 2. For each: update SQLite 'interrupted', log destroyed(reason:'interrupted')
60
- * 3. Stop all nodes
61
- * 4. Stop standing receiver
62
- *
63
- * getStatus(): { standingReceiverReady: boolean }
14
+ * Interrupted-session detection runs BEFORE the IPC socket opens, so a client cannot observe a
15
+ * stale 'active' row from a previous process.
64
16
  */
65
- // CELLO-M7-PERSIST-002 (DEC-1): the daemon DB is a SQLCipher database (whole-file AES-256 at rest).
66
- // `DaemonDatabase` is the thin varargs surface the daemon uses; `openEncryptedDatabase` opens with a
67
- // PRAGMA key and `resolveDbKey` manages the single plaintext key file (DEC-2). The whole-DB cipher
68
- // supersedes the old per-column transcript cipher (AC-010).
17
+ // The daemon DB is SQLCipher (whole-file AES-256 at rest), never `node:sqlite`. `DaemonDatabase` is
18
+ // the thin varargs surface; `openEncryptedDatabase` opens with a PRAGMA key and `resolveDbKey`
19
+ // manages the single plaintext key file.
69
20
  import { openEncryptedDatabase, resolveDbKey, dbKeyPathFor, } from "./sqlcipher-db.js";
70
21
  import { migrateToEncryptedIfNeeded } from "./identity-migration.js";
71
22
  import { ensureIdentitySchema } from "./db-identity-store.js";
72
23
  import { migrateSessionTablesToAgentId } from "./agent-id-migration.js";
73
24
  import { TIER, normalizeTier, isKnownTierValue, tierBoundsFor, DEFAULT_TIER_BOUNDS, migrateContactsAddTierMetadata } from "./contacts-tier-migration.js";
25
+ import { migrateCborBlobsToCanonical } from "./cbor-blob-migration.js";
74
26
  import { boundSettingKey, settableTierName, isValidSettingKey, awayTierSettingKey, AWAY_DEFAULT_KEY } from "./agent-settings-keys.js";
75
27
  import { randomUUID, createHash } from "node:crypto";
76
28
  import * as lp from "it-length-prefixed";
77
- import { decode, Encoder } from "cbor-x";
29
+ import { decode } from "cbor-x";
30
+ import { encodeCbor } from "@cello-protocol/protocol-types";
78
31
  import { MAX_SESSION_NODES, STANDING_RECEIVER_AGENT_NAME } from "./types.js";
79
32
  import { SessionConnectionGater } from "./session-connection-gater.js";
80
33
  import { SessionTree } from "./session-tree.js";
@@ -82,17 +35,16 @@ import { CELLO_CONTENT_PROTOCOL_ID, NodeAutoNatService } from "@cello-protocol/t
82
35
  import { verify } from "@cello-protocol/crypto";
83
36
  import { encodeSealPayload, MONIKER_RE, validateMoniker } from "@cello-protocol/protocol-types";
84
37
  import { decodeParkEnvelope, authenticateParkedEntry, pubkeyMatchesHex } from "./park-envelope.js";
85
- import { AgentRelayClient, LEAF_KIND_CTRL } from "./session-relay-client.js";
38
+ import { isValidMultiaddr } from "@cello-protocol/transport";
39
+ import { AgentRelayClient, LEAF_KIND_CTRL, extractErrorMessage } from "./session-relay-client.js";
86
40
  import { RelayReceiptStore } from "./relay-receipt-store.js";
87
41
  import { SessionSealLeafStore } from "./session-seal-leaf-store.js";
88
42
  import { PassthroughGatewayClient, GATEWAY_UNAVAILABLE, GOVERNANCE_TIMEOUT, } from "@cello-protocol/gateway";
89
- const CBOR_ENC = new Encoder({ tagUint8Array: false });
90
43
  /** SEC-1 / review M4: cap on the refused-parked-entry memo (remote-fed → must be bounded). */
91
44
  const MAX_REFUSED_PARKED_ENTRIES = 512;
92
- // M8C-ABUSE-1 + DOD-TIER-2: persistence bounds. Formerly a binary "known contacts exempt entirely,
93
- // everyone else 3 sessions / 25 MB"; now TIER-GRADUATED via DEFAULT_TIER_BOUNDS (contacts-tier-
94
- // migration). The two consts below are kept for back-compat but DERIVE from the grid's UNKNOWN row —
95
- // the grid is the single source (DOD-TIER-2 AC4), so these can never drift from it.
45
+ // Persistence bounds are TIER-GRADUATED via DEFAULT_TIER_BOUNDS (contacts-tier-migration). The two
46
+ // consts below DERIVE from the grid's UNKNOWN row rather than restating it — the grid is the single
47
+ // source (DOD-TIER-2 AC4), so these can never drift from it.
96
48
  /** Anti-drip-feed: cumulative RECEIVED bytes per session at the UNKNOWN tier (= the grid's UNKNOWN
97
49
  * byte cap). Higher tiers get more (DEFAULT_TIER_BOUNDS); no tier is unbounded (INV-TIER-BOUND). */
98
50
  export const ABUSE_MAX_SESSION_RECEIVED_BYTES = DEFAULT_TIER_BOUNDS[TIER.UNKNOWN].maxBytesPerSession; // 25 MB
@@ -212,12 +164,11 @@ export class SessionNodeManager {
212
164
  // daemon.ts (depends on the notification dispatcher). Content-free by signature — carries only
213
165
  // agent / session / senderPubkey, NEVER the plaintext (INV-CONTENTFREE).
214
166
  #onContentArrived = null;
215
- // CELLO-M7-MSG-001 (AC-001/AC-002/AC-003): the send is no longer fire-and-forget.
216
- // After a content_frame is delivered over the direct session channel, the sender
217
- // arms a TTF timer and waits for an unsigned, transport-authenticated `persisted`
218
- // delivery ACK on the same /cello/content/1.0.0 protocol. A persisted ACK cancels
219
- // the timer (content.delivery.acked); TTF expiry hands the content to the park
220
- // backstop. Keyed sessionId → contentHashHex → entry.
167
+ // A send is NOT fire-and-forget. After a content_frame is delivered over the direct session
168
+ // channel, the sender arms a TTF timer and waits for an unsigned, transport-authenticated
169
+ // `persisted` delivery ACK on the same /cello/content/1.0.0 protocol. A persisted ACK cancels the
170
+ // timer (content.delivery.acked); TTF expiry hands the content to the park backstop.
171
+ // Keyed sessionId contentHashHex entry.
221
172
  #awaitingAck = new Map();
222
173
  // TTF (time-to-flush) for an un-acked content entry. Injectable so tests can drive
223
174
  // expiry deterministically; production default sits in the Part-4 proposed 10–30s band.
@@ -384,6 +335,14 @@ export class SessionNodeManager {
384
335
  // it to VERIFY the bilateral seal signature locally (the seal is signed by the initiator's
385
336
  // primary), not just accept it. NULL when this party initiated (it uses its own primary).
386
337
  "ALTER TABLE sessions ADD COLUMN counterparty_primary_pubkey TEXT",
338
+ // DOD-SESSION-NAME-1: the operator's own human-readable label for this session. LOCAL AND
339
+ // COSMETIC — it is never sent to the relay or directory, never in a wire frame, never in the
340
+ // transcript, never in the seal or a Merkle leaf, and the counterparty never sees it. It
341
+ // cannot influence protocol behaviour.
342
+ // NULL MEANS SOMETHING: a session closed through an agent usually carries a name, so an
343
+ // unnamed closed session is a hint it did not close cleanly. Never auto-generate a default —
344
+ // a fabricated name destroys that signal.
345
+ "ALTER TABLE sessions ADD COLUMN session_name TEXT",
387
346
  ]) {
388
347
  try {
389
348
  this.#db.exec(ddl);
@@ -496,6 +455,14 @@ export class SessionNodeManager {
496
455
  // agent-id re-key above (it never needs to appear in that migration's pinned DDL) and BEFORE any
497
456
  // read below. Idempotent, no column DEFAULT, grandfathers existing contacts to WHITELISTED once.
498
457
  migrateContactsAddTierMetadata(this.#db, this.#logger);
458
+ // §1.1: normalize frost_commitments / frost_verifying_shares to ONE CBOR encoding. Registration
459
+ // wrote them with the shared encoder; the refresh path wrote them with cbor-x's bare `encode`,
460
+ // so an agent's share blobs changed format the first time it ran `cello_refresh_shares` and both
461
+ // formats are on disk. Both producers now use encodeCbor; this rewrites what is already stored.
462
+ // Idempotent (a canonical blob re-encodes to itself and is skipped) and per-row fail-safe (an
463
+ // undecodable share is LEFT ALONE, never dropped — losing key material is worse than an old
464
+ // encoding cbor-x still reads).
465
+ migrateCborBlobsToCanonical(this.#db, this.#logger);
499
466
  // M8C-TGDOOR-1: daemon-wide Telegram settings (bot token + allowlisted operator chat). A
500
467
  // NEW dedicated table — NOT folded into the parked M9-CFG-001 config store, because a bot
501
468
  // token has no sensible default (a required credential, unlike AWAY/TTL/CONTACT's real
@@ -625,7 +592,7 @@ export class SessionNodeManager {
625
592
  /**
626
593
  * DOD-LOG-1 / PERSIST-002 (AC-010): append one readable message to the durable transcript, keyed
627
594
  * by the canonical leaf `sequence` so it joins to the committed hash chain. The blob is stored as
628
- * plaintext bytes the whole DB is SQLCipher-encrypted at rest, so the per-column cipher is gone.
595
+ * plaintext bytes: the whole DB is SQLCipher-encrypted at rest, so there is no per-column cipher.
629
596
  * Idempotent on replay (INSERT OR IGNORE). Never throws into the caller's content path: a
630
597
  * transcript-write failure is logged, not fatal.
631
598
  */
@@ -763,7 +730,11 @@ export class SessionNodeManager {
763
730
  AND t.session_id = ?
764
731
  AND ${SessionNodeManager.#UNREAD_RECEIVED_WHERE}`)
765
732
  .get(this.#requireAgentId(agentName), sessionId);
766
- return row ? row.unread_count : 0;
733
+ // Absent row "I cannot count", which is NOT "you are caught up". Answer the same way the
734
+ // #db guard above does. Unreachable today (SELECT COUNT(*) with no GROUP BY always yields a
735
+ // row), but a fail-OPEN default inside a fail-CLOSED gate is a defect that only needs the query
736
+ // to change once. The two branches must never disagree about what "unknown" means.
737
+ return row ? row.unread_count : 1;
767
738
  }
768
739
  /** M8C-CONTACT-1: is this pubkey a known contact of this agent? */
769
740
  isContact(agentName, pubkey) {
@@ -2330,6 +2301,27 @@ export class SessionNodeManager {
2330
2301
  // agent_id scoped this lookup (~50 daemon call sites read `record.agent_name`).
2331
2302
  return row ? { ...row, agent_name: agentName } : null;
2332
2303
  }
2304
+ /**
2305
+ * DOD-SESSION-NAME-1: set (string) or clear (null) THIS agent's name for a session.
2306
+ *
2307
+ * Returns false when the (agent_id, session_id) row does not exist — i.e. the session is not this
2308
+ * agent's — so the caller refuses with session_not_found rather than reporting a silent success on
2309
+ * a write that landed nowhere. Same contract as setContactMoniker.
2310
+ *
2311
+ * Ownership is the ONLY scope: the composite key IS the ownership check, and status is deliberately
2312
+ * not consulted. A sealed session can be named — naming one long after the fact is the point — and
2313
+ * a name is a local column, so writing it cannot touch the seal, a Merkle leaf, or the wire.
2314
+ *
2315
+ * The caller validates (validateSessionName) before calling; this stores what it is given.
2316
+ */
2317
+ setSessionName(agentName, sessionId, sessionName) {
2318
+ if (!this.#db)
2319
+ throw new Error(`setSessionName('${agentName}'): database not initialized`);
2320
+ const res = this.#db
2321
+ .prepare("UPDATE sessions SET session_name = ? WHERE agent_id = ? AND session_id = ?")
2322
+ .run(sessionName, this.#requireAgentId(agentName), sessionId);
2323
+ return res.changes > 0;
2324
+ }
2333
2325
  /**
2334
2326
  * MSG-2 startup-flush: the persisted relay endpoint for a session, or null if none was
2335
2327
  * recorded. Used by the crash-backstop flush, which runs at startup BEFORE the in-memory
@@ -2464,6 +2456,15 @@ export class SessionNodeManager {
2464
2456
  if (addrs.length === 0) {
2465
2457
  return { ok: false, reason: "no_counterparty_addrs", error: "the assignment carried no counterparty session addrs to dial" };
2466
2458
  }
2459
+ // DOD-NAT-REACHABILITY-1: a /p2p-circuit counterparty address is dialed
2460
+ // THROUGH its relay, so the gater must admit that relay peer OUTBOUND. The
2461
+ // relay id is embedded in the address, which arrived inside the FROST-signed
2462
+ // assignment — the same authorization rail as the assigned witness relay.
2463
+ for (const addr of addrs) {
2464
+ const viaRelay = addr.match(/\/p2p\/([^/]+)\/p2p-circuit/);
2465
+ if (viaRelay)
2466
+ entry.gater.setAllowedOutboundPeer(viaRelay[1]);
2467
+ }
2467
2468
  let lastError = "";
2468
2469
  for (const addr of addrs) {
2469
2470
  try {
@@ -2476,8 +2477,11 @@ export class SessionNodeManager {
2476
2477
  return { ok: true };
2477
2478
  }
2478
2479
  catch (err) {
2479
- // error.message extracted never [object Object]; try the next addr.
2480
- lastError = err instanceof Error ? err.message : String(err);
2480
+ // extractErrorMessage handles the transport's structured plain-object
2481
+ // throws (dial() never throws Error instances) the old
2482
+ // `instanceof Error` idiom logged "[object Object]" on every dial
2483
+ // failure; try the next addr.
2484
+ lastError = extractErrorMessage(err);
2481
2485
  }
2482
2486
  }
2483
2487
  this.#logger.warn("session.transport.connect.failed", {
@@ -2489,10 +2493,9 @@ export class SessionNodeManager {
2489
2493
  return { ok: false, reason: "counterparty_dial_failed", error: lastError };
2490
2494
  }
2491
2495
  /**
2492
- * DAEMON-004: send content over the session node's direct P2P content stream.
2493
- * On a dead/missing stream this returns a NAMED, diagnosable failure — never a
2494
- * silent success and never a desync (closing the old silent fire-and-forget
2495
- * content catch in the retired in-process client send path).
2496
+ * Send content over the session node's direct P2P content stream.
2497
+ * On a dead/missing stream this returns a NAMED, diagnosable failure — never a silent success
2498
+ * (which desyncs the two sides). Do not swallow a send error here.
2496
2499
  *
2497
2500
  * SCOPE / findings #3 + #4 — what this send path does and does NOT do today:
2498
2501
  * - #4: it delivers the content over the direct /cello/content/1.0.0 P2P
@@ -2517,9 +2520,11 @@ export class SessionNodeManager {
2517
2520
  // canonical sequence from the hash whether or not the counterparty is reachable for direct
2518
2521
  // content. So an OFFLINE recipient still gets a sequence, and the parked content is later
2519
2522
  // recovered AT that sequence (DOD-MSG-4 recovery-not-desync). The relay only ever sees the
2520
- // hash (INV-3). Best-effort: a relay miss degrades to local-only sequencing. Previously this
2521
- // ran AFTER a successful direct send, so an offline recipient's content got NO sequence — the
2522
- // gap R1 closes.
2523
+ // hash (INV-3). Best-effort: a relay miss degrades to local-only sequencing.
2524
+ //
2525
+ // This MUST run BEFORE the direct send, not after it: an offline recipient never completes a
2526
+ // direct send, and sequencing after it would leave their content with no sequence at all.
2527
+ //
2523
2528
  // DOD-MSG-4 (self-ordering content frame): the relay's committed ordering record for this leaf,
2524
2529
  // captured from the hash submit so it can be stamped into the content frame (and the parked
2525
2530
  // entry). Undefined if the relay is unreachable / an old relay — the receiver then falls back to
@@ -2569,7 +2574,7 @@ export class SessionNodeManager {
2569
2574
  // backstop. The correlationId rides in the frame so the receiver's
2570
2575
  // session.content.received shares ONE flow id with the sender.
2571
2576
  this.#trackAwaitingAck(agentName, sessionId, content, contentHash, correlationId, orderingS1, orderingS2);
2572
- const frame = CBOR_ENC.encode({
2577
+ const frame = encodeCbor({
2573
2578
  type: "content_frame",
2574
2579
  session_id: sessionId,
2575
2580
  content_hash: contentHash,
@@ -3010,14 +3015,15 @@ export class SessionNodeManager {
3010
3015
  const deliverContent = inboundVerdict.disposition === "redact" && inboundVerdict.content !== undefined
3011
3016
  ? inboundVerdict.content
3012
3017
  : content;
3013
- // B1 (review): screenInbound above is the ONLY suspension point in this method. Before M9 the
3014
- // dedup check (indexOfHash, above) and the leaf append (below) ran with no yield between them,
3015
- // so check-then-append was atomic under Node's single thread. The await reopened that window:
3016
- // two concurrent ingests of the SAME content hash e.g. a direct retry and a park-recovery
3017
- // racing on reconnect could BOTH pass the dedup check before either appends, producing two
3018
- // leaves for one hash (DOD-MSG-5 break leafIndex≠canonicalSeq root divergence). Re-check
3019
- // dedup now that we have resumed; everything from here to the append is synchronous (atomic),
3020
- // so the first to resume appends and the second sees its leaf and dedups.
3018
+ // screenInbound above is the ONLY suspension point in this method, and it splits the dedup check
3019
+ // (indexOfHash, above) from the leaf append (below). Across that await, two concurrent ingests of
3020
+ // the SAME content hash e.g. a direct retry and a park-recovery racing on reconnect — can BOTH
3021
+ // pass the first dedup check before either appends, producing two leaves for one hash
3022
+ // (DOD-MSG-5 break leafIndex≠canonicalSeq root divergence). So re-check dedup on resume.
3023
+ // Everything from here to the append is synchronous (atomic under Node's single thread): the
3024
+ // first to resume appends, and the second sees its leaf and dedups.
3025
+ //
3026
+ // Adding any further await between here and the append reopens the window.
3021
3027
  const dedupAfterScreen = this.getSessionTree(agentName, sessionId).indexOfHash(contentHashHex);
3022
3028
  if (dedupAfterScreen >= 0) {
3023
3029
  this.#logger.info("session.content.deduplicated", {
@@ -3115,6 +3121,37 @@ export class SessionNodeManager {
3115
3121
  const leafIndex = terminalBlock
3116
3122
  ? this.appendSessionLeaf(agentName, sessionId, "msg", contentHashHex, correlationId).leafIndex
3117
3123
  : this.#appendVerifiedContent(agentName, sessionId, deliverContent, contentHashHex, senderPubkey, correlationId).leafIndex;
3124
+ // NO relay witness for this hash. We appended it anyway — refusing would make the relay a hard
3125
+ // precondition for reading mail, so a relay outage would render the inbox unreadable, and the
3126
+ // direct path and park backstop exist precisely to survive that. But this append is a WEAKER
3127
+ // guarantee and must not masquerade as the stronger one: with a witness, the received content is
3128
+ // checked against a hash the sender committed to a third party; without one, the only available
3129
+ // hash rode in the same frame as the content, so the check is the sender's claim against the
3130
+ // sender's own claim. Say so. A sender who simply never submits to the relay is otherwise
3131
+ // indistinguishable from one the relay merely has not witnessed YET.
3132
+ // The relay witness is an INDEPENDENT attestation: a (content_hash → sequence) binding derived
3133
+ // from the sender's own signed leaf. Holding one, we check received content against a hash the
3134
+ // sender committed to a THIRD PARTY. Holding none, the only hash available rode in the same frame
3135
+ // as the content — the sender's claim checked against the sender's claim.
3136
+ //
3137
+ // Unwitnessed content is still ingested. Refusing it would make the relay a precondition for
3138
+ // READING mail, so a relay outage would render the inbox unreadable — the redundancy the direct
3139
+ // path and the park backstop exist to provide.
3140
+ //
3141
+ // Warn ONLY when a witness was EXPECTED. A session with no relay attached has no witness BY
3142
+ // DESIGN, and warning on every message there would bury the one case that means something —
3143
+ // a relay IS attached, so the sender's leaf should have been submitted and witnessed, and it
3144
+ // was not. A signal that fires on the normal case is not a signal.
3145
+ if (canonicalSeq === undefined && this.#activeNodes.get(key)?.relayClient) {
3146
+ this.#logger.warn("session.content.unwitnessed", {
3147
+ agentName,
3148
+ sessionId,
3149
+ leafIndex,
3150
+ contentHash: contentHashHex,
3151
+ correlationId,
3152
+ guidance: "a relay is attached to this session but no witness bound this content hash — it was ingested with no independent commitment from the sender",
3153
+ });
3154
+ }
3118
3155
  // A just-appended leaf may unblock held out-of-order arrivals whose turn is now next.
3119
3156
  // appendedCount = this leaf + any held leaves released by it, so a caller (recover) can tally the
3120
3157
  // leaves ACTUALLY written, not just the directly-ingested one (review #3).
@@ -3380,7 +3417,7 @@ export class SessionNodeManager {
3380
3417
  return;
3381
3418
  try {
3382
3419
  const stream = await entry.node.newStream(entry.counterpartySessionPeerId, CELLO_CONTENT_PROTOCOL_ID);
3383
- const frame = CBOR_ENC.encode({
3420
+ const frame = encodeCbor({
3384
3421
  type: "content_delivery_ack",
3385
3422
  session_id: sessionId,
3386
3423
  content_hash: contentHash,
@@ -3486,12 +3523,11 @@ export class SessionNodeManager {
3486
3523
  /**
3487
3524
  * SEC-1 — THE ONLY WAY PARKED CONTENT ENTERS THE TRANSCRIPT.
3488
3525
  *
3489
- * Authentication and ingest are fused here on purpose. Before SEC-1 the recover loop in daemon.ts
3490
- * did `decode (maybe verify ordering) ingest`, where the verify step was OPTIONAL (skipped
3491
- * entirely when the attacker simply omitted the ordering record) and could not reject anything
3492
- * even when it ran it gated ORDERING, not INGEST. Splitting the check from the write is what
3493
- * made the downgrade possible, so they no longer live apart: a caller cannot ingest parked content
3494
- * without passing the signature gate, because there is no other entry point.
3526
+ * Authentication and ingest are FUSED here on purpose, and must stay fused. A caller cannot ingest
3527
+ * parked content without passing the signature gate, because this is the only entry point. Exposing
3528
+ * a separate `decode ingest` path — with the signature check as its own optional step — is a
3529
+ * downgrade attack: an attacker omits the thing that triggers the check, and the check never runs.
3530
+ * A gate the caller can skip by leaving a field out is not a gate.
3495
3531
  *
3496
3532
  * FAILS CLOSED. No signature / bad signature / signer is not this session's counterparty / no
3497
3533
  * session at all → REFUSED, nothing is appended, nothing is written, and the caller MUST NOT
@@ -3848,6 +3884,131 @@ export class SessionNodeManager {
3848
3884
  this.#standingReceiverCreating.delete(agentName);
3849
3885
  }
3850
3886
  }
3887
+ /**
3888
+ * DOD-NAT-REACHABILITY-1 (Phase 2): relay endpoints the DIRECTORY handed this
3889
+ * agent at signaling-auth time — the freshest, health-filtered view of the
3890
+ * relay pool, and the only source a FRESH agent (no session history) has.
3891
+ */
3892
+ #directoryRelayEndpoints = new Map();
3893
+ /**
3894
+ * DOD-NAT-REACHABILITY-1 (Phase 2): accept the directory's relay-pool endpoints
3895
+ * for an agent (arrives with signaling_auth_ok, i.e. on every connect AND every
3896
+ * reconnect). If the agent's standing receiver is up but holds NO reservation —
3897
+ * the agent-online ensure raced ahead of auth_ok, or every relay was down at
3898
+ * create time — rebuild it now so the agent becomes dialable without waiting
3899
+ * for a session handoff that (being unreachable) would never come.
3900
+ */
3901
+ setDirectoryRelayEndpoints(agentName, endpoints) {
3902
+ this.#directoryRelayEndpoints.set(agentName, endpoints);
3903
+ if (endpoints.length === 0 || this.#shuttingDown)
3904
+ return;
3905
+ const sr = this.#standingReceivers.get(agentName);
3906
+ if (!sr)
3907
+ return; // not ensured yet — the coming ensure reads the map
3908
+ if (sr.node.listenAddresses().some((a) => a.includes("/p2p-circuit")))
3909
+ return; // already reserved
3910
+ this.#logger.info("session.standing_receiver.reservation.rebuild", {
3911
+ agentName,
3912
+ relayPeerIds: endpoints.map((e) => e.relayPeerId),
3913
+ });
3914
+ void this.#rebuildStandingReceiver(agentName);
3915
+ }
3916
+ /**
3917
+ * Replace an agent's reservation-less standing receiver with one that reserves.
3918
+ *
3919
+ * Deliberately NOT removeStandingReceiverForAgent()+ensureStandingReceiverForAgent():
3920
+ * the public remove CLEARS #agentsWantingReceiver, so a cello_stop_agent landing in
3921
+ * the window while node.stop() is awaited would find no map entry and no creating
3922
+ * marker, leave no tombstone, and the re-ensure would then RESURRECT a receiver for
3923
+ * an agent that asked to go dark — accepting inbound sessions for an offline agent.
3924
+ * Here the want-flag is left intact and re-checked after the stop: a concurrent stop
3925
+ * clears it, and the rebuild correctly no-ops.
3926
+ */
3927
+ async #rebuildStandingReceiver(agentName) {
3928
+ try {
3929
+ const sr = this.#standingReceivers.get(agentName);
3930
+ if (sr) {
3931
+ this.#standingReceivers.delete(agentName);
3932
+ try {
3933
+ sr.autoNat.stop();
3934
+ await sr.node.stop();
3935
+ }
3936
+ catch (err) {
3937
+ this.#logger.warn("session.standing_receiver.teardown.failed", {
3938
+ agentName,
3939
+ error: extractErrorMessage(err),
3940
+ });
3941
+ }
3942
+ }
3943
+ // The agent may have gone offline while we were stopping the old node. Its
3944
+ // want-flag is the authority — never resurrect a receiver it disowned.
3945
+ if (!this.#agentsWantingReceiver.has(agentName) || this.#shuttingDown)
3946
+ return;
3947
+ await this.#ensureStandingReceiver(agentName);
3948
+ }
3949
+ catch (err) {
3950
+ this.#logger.warn("session.standing_receiver.reservation.rebuild.failed", {
3951
+ agentName,
3952
+ error: extractErrorMessage(err),
3953
+ });
3954
+ }
3955
+ }
3956
+ /**
3957
+ * DOD-NAT-REACHABILITY-1: circuit-relay listen addresses for an agent's known
3958
+ * relays, so its standing receiver takes reservations and becomes dialable
3959
+ * behind NAT. Sources, merged and deduped by relay peer id: the directory's
3960
+ * auth-time relay pool (freshest — first), then the persisted relay endpoints
3961
+ * of past sessions (getAgentRelayEndpoints — covers a directory that predates
3962
+ * the auth_ok extension).
3963
+ */
3964
+ #reservationCircuitAddrs(agentName) {
3965
+ let persisted;
3966
+ try {
3967
+ persisted = this.getAgentRelayEndpoints(agentName);
3968
+ }
3969
+ catch (err) {
3970
+ // No DB / unknown agent — persisted source unavailable. The directory
3971
+ // source may still serve; reachability degrades only if both are empty.
3972
+ // Logged (not swallowed): a genuine DB failure must be distinguishable
3973
+ // from "fresh agent, no history" in the reachability trail.
3974
+ this.#logger.debug("session.standing_receiver.persisted_relays.unavailable", {
3975
+ agentName,
3976
+ error: extractErrorMessage(err),
3977
+ });
3978
+ persisted = [];
3979
+ }
3980
+ const merged = new Map();
3981
+ for (const ep of [...(this.#directoryRelayEndpoints.get(agentName) ?? []), ...persisted]) {
3982
+ if (!merged.has(ep.relayPeerId))
3983
+ merged.set(ep.relayPeerId, ep);
3984
+ }
3985
+ const addrs = [];
3986
+ const relayPeerIds = [];
3987
+ for (const ep of merged.values()) {
3988
+ const base = ep.relayAddrs[0];
3989
+ if (!base)
3990
+ continue;
3991
+ const candidate = base.includes("/p2p/")
3992
+ ? `${base}/p2p-circuit`
3993
+ : `${base}/p2p/${ep.relayPeerId}/p2p-circuit`;
3994
+ // These addresses are built from DIRECTORY-supplied endpoints — data from off
3995
+ // this machine. A malformed one throws inside libp2p node construction, which
3996
+ // would take the standing receiver down entirely and leave the agent deaf to
3997
+ // ALL inbound (worse than the defect this fixes). A bad endpoint must cost one
3998
+ // relay, never the receiver.
3999
+ if (!isValidMultiaddr(candidate)) {
4000
+ this.#logger.warn("session.standing_receiver.relay_endpoint.invalid", {
4001
+ agentName,
4002
+ relayPeerId: ep.relayPeerId,
4003
+ addr: candidate,
4004
+ });
4005
+ continue;
4006
+ }
4007
+ addrs.push(candidate);
4008
+ relayPeerIds.push(ep.relayPeerId);
4009
+ }
4010
+ return { addrs, relayPeerIds };
4011
+ }
3851
4012
  /** One standing-receiver create attempt (extracted for the M8B F14 retry loop). */
3852
4013
  async #tryCreateStandingReceiver(agentName, correlationId) {
3853
4014
  const sessionId = `standing_receiver_${randomUUID()}`;
@@ -3856,13 +4017,29 @@ export class SessionNodeManager {
3856
4017
  allowedPeerId: null, // open — counterparty unknown at creation time
3857
4018
  logger: this.#logger,
3858
4019
  });
4020
+ // DOD-NAT-REACHABILITY-1: reserve with the agent's known relays. The relay
4021
+ // peers are allowed OUTBOUND on the gater up front, so reservation refreshes
4022
+ // keep working after the receiver is claimed and setAllowedPeer() narrows
4023
+ // the inbound gate to the session counterparty.
4024
+ const reservations = this.#reservationCircuitAddrs(agentName);
4025
+ for (const relayPeerId of reservations.relayPeerIds) {
4026
+ gater.setAllowedOutboundPeer(relayPeerId);
4027
+ }
3859
4028
  let node;
3860
4029
  try {
3861
- node = await this.#factory.createNode({ sessionId, connectionGater: gater, nodeType: "standing_receiver" });
4030
+ node = await this.#factory.createNode({
4031
+ sessionId,
4032
+ connectionGater: gater,
4033
+ nodeType: "standing_receiver",
4034
+ ...(reservations.addrs.length > 0 ? { circuitRelayListenAddrs: reservations.addrs } : {}),
4035
+ });
3862
4036
  await node.start();
3863
4037
  }
3864
4038
  catch (err) {
3865
- const error = err instanceof Error ? err.message : String(err);
4039
+ // extractErrorMessage, NOT String(err): the transport throws structured
4040
+ // plain objects ({ reason, message }), and String() destroys both into
4041
+ // "[object Object]" — the loud failure must carry its cause.
4042
+ const error = extractErrorMessage(err);
3866
4043
  this.#logger.error("session.node.create.failed", {
3867
4044
  sessionId,
3868
4045
  agentName: `${STANDING_RECEIVER_AGENT_NAME}:${agentName}`,
@@ -3908,6 +4085,25 @@ export class SessionNodeManager {
3908
4085
  sessionPeerId: node.getPeerId(),
3909
4086
  correlationId,
3910
4087
  });
4088
+ // DOD-NAT-REACHABILITY-1 observability: how reachable did this receiver come
4089
+ // up? circuitAddrs === 0 with reservations requested means every relay
4090
+ // refused/was unreachable — the agent is deaf to NAT'd initiators (public
4091
+ // ones can still connect directly). That must be LOUD, not a quiet shrug.
4092
+ const circuitAddrs = node.listenAddresses().filter((a) => a.includes("/p2p-circuit")).length;
4093
+ this.#logger.info("session.standing_receiver.reachability", {
4094
+ agentName,
4095
+ circuitAddrs,
4096
+ reservationsRequested: reservations.addrs.length,
4097
+ correlationId,
4098
+ });
4099
+ if (reservations.addrs.length > 0 && circuitAddrs === 0) {
4100
+ this.#logger.warn("session.standing_receiver.reservation.none", {
4101
+ agentName,
4102
+ reservationsRequested: reservations.addrs.length,
4103
+ relayPeerIds: reservations.relayPeerIds,
4104
+ correlationId,
4105
+ });
4106
+ }
3911
4107
  return { outcome: "installed" };
3912
4108
  }
3913
4109
  /**
@@ -3923,6 +4119,10 @@ export class SessionNodeManager {
3923
4119
  async removeStandingReceiverForAgent(agentName) {
3924
4120
  // M8B F14: the agent no longer wants a receiver — disarm the teardown re-arm.
3925
4121
  this.#agentsWantingReceiver.delete(agentName);
4122
+ // The directory hands these out at signaling-auth time, so a re-started agent
4123
+ // gets a fresh set on its next connect — holding the old ones would keep a
4124
+ // retired agent's relay list alive for the daemon's lifetime.
4125
+ this.#directoryRelayEndpoints.delete(agentName);
3926
4126
  const sr = this.#standingReceivers.get(agentName);
3927
4127
  if (!sr) {
3928
4128
  // L1: an #ensureStandingReceiver for this agent may be in flight (parked on start(), so no