@cello-protocol/daemon 0.0.227 → 0.0.228
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.
- package/dist/agent-admin-handlers.d.ts +56 -0
- package/dist/agent-admin-handlers.d.ts.map +1 -0
- package/dist/agent-admin-handlers.js +73 -0
- package/dist/agent-admin-handlers.js.map +1 -0
- package/dist/agent-handlers.d.ts +72 -0
- package/dist/agent-handlers.d.ts.map +1 -0
- package/dist/agent-handlers.js +604 -0
- package/dist/agent-handlers.js.map +1 -0
- package/dist/agent-id-migration.d.ts +70 -0
- package/dist/agent-id-migration.d.ts.map +1 -0
- package/dist/agent-id-migration.js +468 -0
- package/dist/agent-id-migration.js.map +1 -0
- package/dist/agent-loader.d.ts +37 -0
- package/dist/agent-loader.d.ts.map +1 -0
- package/dist/agent-loader.js +37 -0
- package/dist/agent-loader.js.map +1 -0
- package/dist/agent-selection-root.d.ts +29 -0
- package/dist/agent-selection-root.d.ts.map +1 -0
- package/dist/agent-selection-root.js +126 -0
- package/dist/agent-selection-root.js.map +1 -0
- package/dist/agent-selection.d.ts +73 -0
- package/dist/agent-selection.d.ts.map +1 -0
- package/dist/agent-selection.js +80 -0
- package/dist/agent-selection.js.map +1 -0
- package/dist/agent-settings-keys.d.ts +52 -0
- package/dist/agent-settings-keys.d.ts.map +1 -0
- package/dist/agent-settings-keys.js +114 -0
- package/dist/agent-settings-keys.js.map +1 -0
- package/dist/agent-state.d.ts +67 -0
- package/dist/agent-state.d.ts.map +1 -0
- package/dist/agent-state.js +66 -0
- package/dist/agent-state.js.map +1 -0
- package/dist/assignment-verify.d.ts +90 -0
- package/dist/assignment-verify.d.ts.map +1 -0
- package/dist/assignment-verify.js +301 -0
- package/dist/assignment-verify.js.map +1 -0
- package/dist/attendance-wiring.d.ts +23 -0
- package/dist/attendance-wiring.d.ts.map +1 -0
- package/dist/attendance-wiring.js +248 -0
- package/dist/attendance-wiring.js.map +1 -0
- package/dist/attestation-body.d.ts +47 -0
- package/dist/attestation-body.d.ts.map +1 -0
- package/dist/attestation-body.js +71 -0
- package/dist/attestation-body.js.map +1 -0
- package/dist/authorship-verification.d.ts +91 -0
- package/dist/authorship-verification.d.ts.map +1 -0
- package/dist/authorship-verification.js +532 -0
- package/dist/authorship-verification.js.map +1 -0
- package/dist/away-detection.d.ts +139 -0
- package/dist/away-detection.d.ts.map +1 -0
- package/dist/away-detection.js +186 -0
- package/dist/away-detection.js.map +1 -0
- package/dist/away-inbox-oneshot.d.ts +40 -0
- package/dist/away-inbox-oneshot.d.ts.map +1 -0
- package/dist/away-inbox-oneshot.js +311 -0
- package/dist/away-inbox-oneshot.js.map +1 -0
- package/dist/backup-restore-handlers.d.ts +10 -0
- package/dist/backup-restore-handlers.d.ts.map +1 -0
- package/dist/backup-restore-handlers.js +82 -0
- package/dist/backup-restore-handlers.js.map +1 -0
- package/dist/backup-restore.d.ts +102 -0
- package/dist/backup-restore.d.ts.map +1 -0
- package/dist/backup-restore.js +341 -0
- package/dist/backup-restore.js.map +1 -0
- package/dist/bin/cello-daemon.d.ts +13 -0
- package/dist/bin/cello-daemon.d.ts.map +1 -0
- package/dist/bin/cello-daemon.js.map +1 -0
- package/dist/boot-agents.d.ts +64 -0
- package/dist/boot-agents.d.ts.map +1 -0
- package/dist/boot-agents.js +241 -0
- package/dist/boot-agents.js.map +1 -0
- package/dist/boot-connection-state.d.ts +38 -0
- package/dist/boot-connection-state.d.ts.map +1 -0
- package/dist/boot-connection-state.js +95 -0
- package/dist/boot-connection-state.js.map +1 -0
- package/dist/boot-core.d.ts +36 -0
- package/dist/boot-core.d.ts.map +1 -0
- package/dist/boot-core.js +293 -0
- package/dist/boot-core.js.map +1 -0
- package/dist/boot-parked-content.d.ts +34 -0
- package/dist/boot-parked-content.d.ts.map +1 -0
- package/dist/boot-parked-content.js +528 -0
- package/dist/boot-parked-content.js.map +1 -0
- package/dist/boot-sweeps.d.ts +22 -0
- package/dist/boot-sweeps.d.ts.map +1 -0
- package/dist/boot-sweeps.js +51 -0
- package/dist/boot-sweeps.js.map +1 -0
- package/dist/bundled-consortium-manifest.d.ts +47 -0
- package/dist/bundled-consortium-manifest.d.ts.map +1 -0
- package/dist/bundled-consortium-manifest.js +116 -0
- package/dist/bundled-consortium-manifest.js.map +1 -0
- package/dist/cbor-blob-migration.d.ts +8 -0
- package/dist/cbor-blob-migration.d.ts.map +1 -0
- package/dist/cbor-blob-migration.js +116 -0
- package/dist/cbor-blob-migration.js.map +1 -0
- package/dist/challenge-verifier.d.ts +12 -0
- package/dist/challenge-verifier.d.ts.map +1 -0
- package/dist/challenge-verifier.js +11 -0
- package/dist/challenge-verifier.js.map +1 -0
- package/dist/channel-log-store.d.ts +80 -0
- package/dist/channel-log-store.d.ts.map +1 -0
- package/dist/channel-log-store.js +290 -0
- package/dist/channel-log-store.js.map +1 -0
- package/dist/close-commitment.d.ts +67 -0
- package/dist/close-commitment.d.ts.map +1 -0
- package/dist/close-commitment.js +78 -0
- package/dist/close-commitment.js.map +1 -0
- package/dist/close-session-handler.d.ts +94 -0
- package/dist/close-session-handler.d.ts.map +1 -0
- package/dist/close-session-handler.js +1418 -0
- package/dist/close-session-handler.js.map +1 -0
- package/dist/co-attendance.d.ts +29 -0
- package/dist/co-attendance.d.ts.map +1 -0
- package/dist/co-attendance.js +32 -0
- package/dist/co-attendance.js.map +1 -0
- package/dist/column-birth.d.ts +40 -0
- package/dist/column-birth.d.ts.map +1 -0
- package/dist/column-birth.js +61 -0
- package/dist/column-birth.js.map +1 -0
- package/dist/connect-or-start.d.ts +25 -0
- package/dist/connect-or-start.d.ts.map +1 -0
- package/dist/connect-or-start.js +175 -0
- package/dist/connect-or-start.js.map +1 -0
- package/dist/connection-agents.d.ts +16 -0
- package/dist/connection-agents.d.ts.map +1 -0
- package/dist/connection-agents.js +68 -0
- package/dist/connection-agents.js.map +1 -0
- package/dist/consent-migration.d.ts +49 -0
- package/dist/consent-migration.d.ts.map +1 -0
- package/dist/consent-migration.js +128 -0
- package/dist/consent-migration.js.map +1 -0
- package/dist/consortium-bootstrap.d.ts +138 -0
- package/dist/consortium-bootstrap.d.ts.map +1 -0
- package/dist/consortium-bootstrap.js +339 -0
- package/dist/consortium-bootstrap.js.map +1 -0
- package/dist/consortium-fingerprint.d.ts +115 -0
- package/dist/consortium-fingerprint.d.ts.map +1 -0
- package/dist/consortium-fingerprint.js +175 -0
- package/dist/consortium-fingerprint.js.map +1 -0
- package/dist/contact-handlers.d.ts +59 -0
- package/dist/contact-handlers.d.ts.map +1 -0
- package/dist/contact-handlers.js +343 -0
- package/dist/contact-handlers.js.map +1 -0
- package/dist/contact-pubkey-case.d.ts +65 -0
- package/dist/contact-pubkey-case.d.ts.map +1 -0
- package/dist/contact-pubkey-case.js +136 -0
- package/dist/contact-pubkey-case.js.map +1 -0
- package/dist/contacts-tier-migration.d.ts +90 -0
- package/dist/contacts-tier-migration.d.ts.map +1 -0
- package/dist/contacts-tier-migration.js +150 -0
- package/dist/contacts-tier-migration.js.map +1 -0
- package/dist/content-encryption-status.d.ts +111 -0
- package/dist/content-encryption-status.d.ts.map +1 -0
- package/dist/content-encryption-status.js +158 -0
- package/dist/content-encryption-status.js.map +1 -0
- package/dist/content-park-client.d.ts +90 -0
- package/dist/content-park-client.d.ts.map +1 -0
- package/dist/content-park-client.js +362 -0
- package/dist/content-park-client.js.map +1 -0
- package/dist/content-park.d.ts +52 -0
- package/dist/content-park.d.ts.map +1 -0
- package/dist/content-park.js +1309 -0
- package/dist/content-park.js.map +1 -0
- package/dist/cross-node-negotiation.d.ts +44 -0
- package/dist/cross-node-negotiation.d.ts.map +1 -0
- package/dist/cross-node-negotiation.js +34 -0
- package/dist/cross-node-negotiation.js.map +1 -0
- package/dist/daemon-handle.d.ts +57 -0
- package/dist/daemon-handle.d.ts.map +1 -0
- package/dist/daemon-handle.js +2 -0
- package/dist/daemon-handle.js.map +1 -0
- package/dist/daemon-status-report.d.ts +50 -0
- package/dist/daemon-status-report.d.ts.map +1 -0
- package/dist/daemon-status-report.js +81 -0
- package/dist/daemon-status-report.js.map +1 -0
- package/dist/daemon.d.ts +45 -0
- package/dist/daemon.d.ts.map +1 -0
- package/dist/daemon.js +1177 -0
- package/dist/daemon.js.map +1 -0
- package/dist/db-identity-store.d.ts +159 -0
- package/dist/db-identity-store.d.ts.map +1 -0
- package/dist/db-identity-store.js +502 -0
- package/dist/db-identity-store.js.map +1 -0
- package/dist/delivery-open-registry.d.ts +92 -0
- package/dist/delivery-open-registry.d.ts.map +1 -0
- package/dist/delivery-open-registry.js +121 -0
- package/dist/delivery-open-registry.js.map +1 -0
- package/dist/delivery-session-suspects.d.ts +56 -0
- package/dist/delivery-session-suspects.d.ts.map +1 -0
- package/dist/delivery-session-suspects.js +94 -0
- package/dist/delivery-session-suspects.js.map +1 -0
- package/dist/directory-auth-posture.d.ts +87 -0
- package/dist/directory-auth-posture.d.ts.map +1 -0
- package/dist/directory-auth-posture.js +134 -0
- package/dist/directory-auth-posture.js.map +1 -0
- package/dist/directory-bootstrap.d.ts +310 -0
- package/dist/directory-bootstrap.d.ts.map +1 -0
- package/dist/directory-bootstrap.js +557 -0
- package/dist/directory-bootstrap.js.map +1 -0
- package/dist/directory-connect.d.ts +27 -0
- package/dist/directory-connect.d.ts.map +1 -0
- package/dist/directory-connect.js +106 -0
- package/dist/directory-connect.js.map +1 -0
- package/dist/disconnect-cleanup.d.ts +51 -0
- package/dist/disconnect-cleanup.d.ts.map +1 -0
- package/dist/disconnect-cleanup.js +72 -0
- package/dist/disconnect-cleanup.js.map +1 -0
- package/dist/document-amendment-store.d.ts +120 -0
- package/dist/document-amendment-store.d.ts.map +1 -0
- package/dist/document-amendment-store.js +266 -0
- package/dist/document-amendment-store.js.map +1 -0
- package/dist/document-delivery-transport.d.ts +168 -0
- package/dist/document-delivery-transport.d.ts.map +1 -0
- package/dist/document-delivery-transport.js +206 -0
- package/dist/document-delivery-transport.js.map +1 -0
- package/dist/document-engine.d.ts +134 -0
- package/dist/document-engine.d.ts.map +1 -0
- package/dist/document-engine.js +282 -0
- package/dist/document-engine.js.map +1 -0
- package/dist/document-flag.d.ts +58 -0
- package/dist/document-flag.d.ts.map +1 -0
- package/dist/document-flag.js +70 -0
- package/dist/document-flag.js.map +1 -0
- package/dist/document-frame-router.d.ts +245 -0
- package/dist/document-frame-router.d.ts.map +1 -0
- package/dist/document-frame-router.js +397 -0
- package/dist/document-frame-router.js.map +1 -0
- package/dist/document-gate-wiring.d.ts +61 -0
- package/dist/document-gate-wiring.d.ts.map +1 -0
- package/dist/document-gate-wiring.js +125 -0
- package/dist/document-gate-wiring.js.map +1 -0
- package/dist/document-gate.d.ts +149 -0
- package/dist/document-gate.d.ts.map +1 -0
- package/dist/document-gate.js +509 -0
- package/dist/document-gate.js.map +1 -0
- package/dist/document-handlers.d.ts +47 -0
- package/dist/document-handlers.d.ts.map +1 -0
- package/dist/document-handlers.js +2203 -0
- package/dist/document-handlers.js.map +1 -0
- package/dist/document-handshake.d.ts +176 -0
- package/dist/document-handshake.d.ts.map +1 -0
- package/dist/document-handshake.js +452 -0
- package/dist/document-handshake.js.map +1 -0
- package/dist/document-inbound.d.ts +162 -0
- package/dist/document-inbound.d.ts.map +1 -0
- package/dist/document-inbound.js +530 -0
- package/dist/document-inbound.js.map +1 -0
- package/dist/document-json.d.ts +120 -0
- package/dist/document-json.d.ts.map +1 -0
- package/dist/document-json.js +191 -0
- package/dist/document-json.js.map +1 -0
- package/dist/document-layer.d.ts +215 -0
- package/dist/document-layer.d.ts.map +1 -0
- package/dist/document-layer.js +1025 -0
- package/dist/document-layer.js.map +1 -0
- package/dist/document-lifecycle.d.ts +52 -0
- package/dist/document-lifecycle.d.ts.map +1 -0
- package/dist/document-lifecycle.js +134 -0
- package/dist/document-lifecycle.js.map +1 -0
- package/dist/document-live-docs.d.ts +58 -0
- package/dist/document-live-docs.d.ts.map +1 -0
- package/dist/document-live-docs.js +126 -0
- package/dist/document-live-docs.js.map +1 -0
- package/dist/document-notify.d.ts +228 -0
- package/dist/document-notify.d.ts.map +1 -0
- package/dist/document-notify.js +580 -0
- package/dist/document-notify.js.map +1 -0
- package/dist/document-profile.d.ts +61 -0
- package/dist/document-profile.d.ts.map +1 -0
- package/dist/document-profile.js +112 -0
- package/dist/document-profile.js.map +1 -0
- package/dist/document-publish.d.ts +87 -0
- package/dist/document-publish.d.ts.map +1 -0
- package/dist/document-publish.js +173 -0
- package/dist/document-publish.js.map +1 -0
- package/dist/document-reachability.d.ts +42 -0
- package/dist/document-reachability.d.ts.map +1 -0
- package/dist/document-reachability.js +80 -0
- package/dist/document-reachability.js.map +1 -0
- package/dist/document-reconcile-engine.d.ts +66 -0
- package/dist/document-reconcile-engine.d.ts.map +1 -0
- package/dist/document-reconcile-engine.js +225 -0
- package/dist/document-reconcile-engine.js.map +1 -0
- package/dist/document-reconcile-scheduler.d.ts +163 -0
- package/dist/document-reconcile-scheduler.d.ts.map +1 -0
- package/dist/document-reconcile-scheduler.js +303 -0
- package/dist/document-reconcile-scheduler.js.map +1 -0
- package/dist/document-rejection.d.ts +251 -0
- package/dist/document-rejection.d.ts.map +1 -0
- package/dist/document-rejection.js +435 -0
- package/dist/document-rejection.js.map +1 -0
- package/dist/document-screen.d.ts +114 -0
- package/dist/document-screen.d.ts.map +1 -0
- package/dist/document-screen.js +223 -0
- package/dist/document-screen.js.map +1 -0
- package/dist/document-store.d.ts +372 -0
- package/dist/document-store.d.ts.map +1 -0
- package/dist/document-store.js +931 -0
- package/dist/document-store.js.map +1 -0
- package/dist/document-surface.d.ts +32 -0
- package/dist/document-surface.d.ts.map +1 -0
- package/dist/document-surface.js +151 -0
- package/dist/document-surface.js.map +1 -0
- package/dist/document-types.d.ts +94 -0
- package/dist/document-types.d.ts.map +1 -0
- package/dist/document-types.js +90 -0
- package/dist/document-types.js.map +1 -0
- package/dist/document-watch.d.ts +69 -0
- package/dist/document-watch.d.ts.map +1 -0
- package/dist/document-watch.js +108 -0
- package/dist/document-watch.js.map +1 -0
- package/dist/document-wiring.d.ts +53 -0
- package/dist/document-wiring.d.ts.map +1 -0
- package/dist/document-wiring.js +323 -0
- package/dist/document-wiring.js.map +1 -0
- package/dist/document-write-guard.d.ts +66 -0
- package/dist/document-write-guard.d.ts.map +1 -0
- package/dist/document-write-guard.js +98 -0
- package/dist/document-write-guard.js.map +1 -0
- package/dist/document-write-path.d.ts +94 -0
- package/dist/document-write-path.d.ts.map +1 -0
- package/dist/document-write-path.js +530 -0
- package/dist/document-write-path.js.map +1 -0
- package/dist/error-message.d.ts +7 -0
- package/dist/error-message.d.ts.map +1 -0
- package/dist/error-message.js +19 -0
- package/dist/error-message.js.map +1 -0
- package/dist/file-manifest-provider.d.ts +37 -0
- package/dist/file-manifest-provider.d.ts.map +1 -0
- package/dist/file-manifest-provider.js +105 -0
- package/dist/file-manifest-provider.js.map +1 -0
- package/dist/frame-values.d.ts +3 -0
- package/dist/frame-values.d.ts.map +1 -0
- package/dist/frame-values.js +84 -0
- package/dist/frame-values.js.map +1 -0
- package/dist/frontier-mismatch.d.ts +73 -0
- package/dist/frontier-mismatch.d.ts.map +1 -0
- package/dist/frontier-mismatch.js +89 -0
- package/dist/frontier-mismatch.js.map +1 -0
- package/dist/gateway-config-handlers.d.ts +26 -0
- package/dist/gateway-config-handlers.d.ts.map +1 -0
- package/dist/gateway-config-handlers.js +429 -0
- package/dist/gateway-config-handlers.js.map +1 -0
- package/dist/held-content.d.ts +145 -0
- package/dist/held-content.d.ts.map +1 -0
- package/dist/held-content.js +389 -0
- package/dist/held-content.js.map +1 -0
- package/dist/http-manifest-poll.d.ts +67 -0
- package/dist/http-manifest-poll.d.ts.map +1 -0
- package/dist/http-manifest-poll.js +150 -0
- package/dist/http-manifest-poll.js.map +1 -0
- package/dist/identity-migration.d.ts +40 -0
- package/dist/identity-migration.d.ts.map +1 -0
- package/dist/identity-migration.js +461 -0
- package/dist/identity-migration.js.map +1 -0
- package/dist/inbound-refusals.d.ts +283 -0
- package/dist/inbound-refusals.d.ts.map +1 -0
- package/dist/inbound-refusals.js +919 -0
- package/dist/inbound-refusals.js.map +1 -0
- package/dist/inbound-seal-request.d.ts +32 -0
- package/dist/inbound-seal-request.d.ts.map +1 -0
- package/dist/inbound-seal-request.js +228 -0
- package/dist/inbound-seal-request.js.map +1 -0
- package/dist/inbound-sessions.d.ts +291 -0
- package/dist/inbound-sessions.d.ts.map +1 -0
- package/dist/inbound-sessions.js +1609 -0
- package/dist/inbound-sessions.js.map +1 -0
- package/dist/inclusion-proof-handlers.d.ts +43 -0
- package/dist/inclusion-proof-handlers.d.ts.map +1 -0
- package/dist/inclusion-proof-handlers.js +565 -0
- package/dist/inclusion-proof-handlers.js.map +1 -0
- package/dist/inclusion-proof.d.ts +151 -0
- package/dist/inclusion-proof.d.ts.map +1 -0
- package/dist/inclusion-proof.js +228 -0
- package/dist/inclusion-proof.js.map +1 -0
- package/dist/index.d.ts +32 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js.map +1 -0
- package/dist/initiate-session-handler.d.ts +55 -0
- package/dist/initiate-session-handler.d.ts.map +1 -0
- package/dist/initiate-session-handler.js +310 -0
- package/dist/initiate-session-handler.js.map +1 -0
- package/dist/ipc-client.d.ts +31 -0
- package/dist/ipc-client.d.ts.map +1 -0
- package/dist/ipc-client.js +113 -0
- package/dist/ipc-client.js.map +1 -0
- package/dist/ipc-server.d.ts +63 -0
- package/dist/ipc-server.d.ts.map +1 -0
- package/dist/ipc-server.js +429 -0
- package/dist/ipc-server.js.map +1 -0
- package/dist/ipc-surface.d.ts +44 -0
- package/dist/ipc-surface.d.ts.map +1 -0
- package/dist/ipc-surface.js +107 -0
- package/dist/ipc-surface.js.map +1 -0
- package/dist/line-lcs.d.ts +51 -0
- package/dist/line-lcs.d.ts.map +1 -0
- package/dist/line-lcs.js +71 -0
- package/dist/line-lcs.js.map +1 -0
- package/dist/lock-file.d.ts +39 -0
- package/dist/lock-file.d.ts.map +1 -0
- package/dist/lock-file.js +120 -0
- package/dist/lock-file.js.map +1 -0
- package/dist/log-collapse.d.ts +66 -0
- package/dist/log-collapse.d.ts.map +1 -0
- package/dist/log-collapse.js +244 -0
- package/dist/log-collapse.js.map +1 -0
- package/dist/log-rotate.d.ts +67 -0
- package/dist/log-rotate.d.ts.map +1 -0
- package/dist/log-rotate.js +134 -0
- package/dist/log-rotate.js.map +1 -0
- package/dist/manifest-deps.d.ts +25 -0
- package/dist/manifest-deps.d.ts.map +1 -0
- package/dist/manifest-deps.js +151 -0
- package/dist/manifest-deps.js.map +1 -0
- package/dist/manifest-poll-scheduler.d.ts +31 -0
- package/dist/manifest-poll-scheduler.d.ts.map +1 -0
- package/dist/manifest-poll-scheduler.js +59 -0
- package/dist/manifest-poll-scheduler.js.map +1 -0
- package/dist/manifest-validity.d.ts +153 -0
- package/dist/manifest-validity.d.ts.map +1 -0
- package/dist/manifest-validity.js +268 -0
- package/dist/manifest-validity.js.map +1 -0
- package/dist/manifest-version-store-db.d.ts +24 -0
- package/dist/manifest-version-store-db.d.ts.map +1 -0
- package/dist/manifest-version-store-db.js +58 -0
- package/dist/manifest-version-store-db.js.map +1 -0
- package/dist/manifest-version-store.d.ts +16 -0
- package/dist/manifest-version-store.d.ts.map +1 -0
- package/dist/manifest-version-store.js +15 -0
- package/dist/manifest-version-store.js.map +1 -0
- package/dist/network-directory-node.d.ts +136 -0
- package/dist/network-directory-node.d.ts.map +1 -0
- package/dist/network-directory-node.js +810 -0
- package/dist/network-directory-node.js.map +1 -0
- package/dist/nonce-dedup.d.ts +68 -0
- package/dist/nonce-dedup.d.ts.map +1 -0
- package/dist/nonce-dedup.js +205 -0
- package/dist/nonce-dedup.js.map +1 -0
- package/dist/notification-dispatcher.d.ts +92 -0
- package/dist/notification-dispatcher.d.ts.map +1 -0
- package/dist/notification-dispatcher.js +210 -0
- package/dist/notification-dispatcher.js.map +1 -0
- package/dist/notification-handlers.d.ts +53 -0
- package/dist/notification-handlers.d.ts.map +1 -0
- package/dist/notification-handlers.js +461 -0
- package/dist/notification-handlers.js.map +1 -0
- package/dist/onboarding-guidance.d.ts +79 -0
- package/dist/onboarding-guidance.d.ts.map +1 -0
- package/dist/onboarding-guidance.js +95 -0
- package/dist/onboarding-guidance.js.map +1 -0
- package/dist/operator-guidance.d.ts +25 -0
- package/dist/operator-guidance.d.ts.map +1 -0
- package/dist/operator-guidance.js +50 -0
- package/dist/operator-guidance.js.map +1 -0
- package/dist/orphan-triage.d.ts +130 -0
- package/dist/orphan-triage.d.ts.map +1 -0
- package/dist/orphan-triage.js +207 -0
- package/dist/orphan-triage.js.map +1 -0
- package/dist/outbound-sessions.d.ts +126 -0
- package/dist/outbound-sessions.d.ts.map +1 -0
- package/dist/outbound-sessions.js +1052 -0
- package/dist/outbound-sessions.js.map +1 -0
- package/dist/park-envelope.d.ts +329 -0
- package/dist/park-envelope.d.ts.map +1 -0
- package/dist/park-envelope.js +509 -0
- package/dist/park-envelope.js.map +1 -0
- package/dist/park-recovery.d.ts +273 -0
- package/dist/park-recovery.d.ts.map +1 -0
- package/dist/park-recovery.js +717 -0
- package/dist/park-recovery.js.map +1 -0
- package/dist/park-refusals.d.ts +147 -0
- package/dist/park-refusals.d.ts.map +1 -0
- package/dist/park-refusals.js +331 -0
- package/dist/park-refusals.js.map +1 -0
- package/dist/quarantine-framing.d.ts +92 -0
- package/dist/quarantine-framing.d.ts.map +1 -0
- package/dist/quarantine-framing.js +111 -0
- package/dist/quarantine-framing.js.map +1 -0
- package/dist/reconnect-drain.d.ts +22 -0
- package/dist/reconnect-drain.d.ts.map +1 -0
- package/dist/reconnect-drain.js +65 -0
- package/dist/reconnect-drain.js.map +1 -0
- package/dist/recovered-position.d.ts +14 -0
- package/dist/recovered-position.d.ts.map +1 -0
- package/dist/recovered-position.js +59 -0
- package/dist/recovered-position.js.map +1 -0
- package/dist/refusal-notices.d.ts +196 -0
- package/dist/refusal-notices.d.ts.map +1 -0
- package/dist/refusal-notices.js +516 -0
- package/dist/refusal-notices.js.map +1 -0
- package/dist/refusal-reasons.d.ts +245 -0
- package/dist/refusal-reasons.d.ts.map +1 -0
- package/dist/refusal-reasons.js +363 -0
- package/dist/refusal-reasons.js.map +1 -0
- package/dist/register-handler.d.ts +36 -0
- package/dist/register-handler.d.ts.map +1 -0
- package/dist/register-handler.js +285 -0
- package/dist/register-handler.js.map +1 -0
- package/dist/registration-context.d.ts +72 -0
- package/dist/registration-context.d.ts.map +1 -0
- package/dist/registration-context.js +126 -0
- package/dist/registration-context.js.map +1 -0
- package/dist/registration-manager.d.ts +94 -0
- package/dist/registration-manager.d.ts.map +1 -0
- package/dist/registration-manager.js +585 -0
- package/dist/registration-manager.js.map +1 -0
- package/dist/registration-persistence.d.ts +183 -0
- package/dist/registration-persistence.d.ts.map +1 -0
- package/dist/registration-persistence.js +263 -0
- package/dist/registration-persistence.js.map +1 -0
- package/dist/registry-poll.d.ts +52 -0
- package/dist/registry-poll.d.ts.map +1 -0
- package/dist/registry-poll.js +140 -0
- package/dist/registry-poll.js.map +1 -0
- package/dist/registry-version-store-db.d.ts +22 -0
- package/dist/registry-version-store-db.d.ts.map +1 -0
- package/dist/registry-version-store-db.js +50 -0
- package/dist/registry-version-store-db.js.map +1 -0
- package/dist/relay-endpoints.d.ts +18 -0
- package/dist/relay-endpoints.d.ts.map +1 -0
- package/dist/relay-endpoints.js +9 -0
- package/dist/relay-endpoints.js.map +1 -0
- package/dist/relay-only.d.ts +140 -0
- package/dist/relay-only.d.ts.map +1 -0
- package/dist/relay-only.js +193 -0
- package/dist/relay-only.js.map +1 -0
- package/dist/relay-receipt-store.d.ts +155 -0
- package/dist/relay-receipt-store.d.ts.map +1 -0
- package/dist/relay-receipt-store.js +284 -0
- package/dist/relay-receipt-store.js.map +1 -0
- package/dist/relay-reconnect.d.ts +32 -0
- package/dist/relay-reconnect.d.ts.map +1 -0
- package/dist/relay-reconnect.js +29 -0
- package/dist/relay-reconnect.js.map +1 -0
- package/dist/reply-lag.d.ts +11 -0
- package/dist/reply-lag.d.ts.map +1 -0
- package/dist/reply-lag.js +46 -0
- package/dist/reply-lag.js.map +1 -0
- package/dist/resolve-named-agent.d.ts +49 -0
- package/dist/resolve-named-agent.d.ts.map +1 -0
- package/dist/resolve-named-agent.js +77 -0
- package/dist/resolve-named-agent.js.map +1 -0
- package/dist/restart-seal-resolver.d.ts +110 -0
- package/dist/restart-seal-resolver.d.ts.map +1 -0
- package/dist/restart-seal-resolver.js +353 -0
- package/dist/restart-seal-resolver.js.map +1 -0
- package/dist/resume-last-seen.d.ts +15 -0
- package/dist/resume-last-seen.d.ts.map +1 -0
- package/dist/resume-last-seen.js +44 -0
- package/dist/resume-last-seen.js.map +1 -0
- package/dist/retry-queue.d.ts +203 -0
- package/dist/retry-queue.d.ts.map +1 -0
- package/dist/retry-queue.js +701 -0
- package/dist/retry-queue.js.map +1 -0
- package/dist/roster-freshness.d.ts +160 -0
- package/dist/roster-freshness.d.ts.map +1 -0
- package/dist/roster-freshness.js +250 -0
- package/dist/roster-freshness.js.map +1 -0
- package/dist/screening-status.d.ts +15 -0
- package/dist/screening-status.d.ts.map +1 -0
- package/dist/screening-status.js +65 -0
- package/dist/screening-status.js.map +1 -0
- package/dist/seal-carried-close.d.ts +37 -0
- package/dist/seal-carried-close.d.ts.map +1 -0
- package/dist/seal-carried-close.js +179 -0
- package/dist/seal-carried-close.js.map +1 -0
- package/dist/seal-certificate-pull.d.ts +79 -0
- package/dist/seal-certificate-pull.d.ts.map +1 -0
- package/dist/seal-certificate-pull.js +186 -0
- package/dist/seal-certificate-pull.js.map +1 -0
- package/dist/seal-certified-root-check.d.ts +37 -0
- package/dist/seal-certified-root-check.d.ts.map +1 -0
- package/dist/seal-certified-root-check.js +127 -0
- package/dist/seal-certified-root-check.js.map +1 -0
- package/dist/seal-coordinator.d.ts +118 -0
- package/dist/seal-coordinator.d.ts.map +1 -0
- package/dist/seal-coordinator.js +927 -0
- package/dist/seal-coordinator.js.map +1 -0
- package/dist/seal-escalation.d.ts +80 -0
- package/dist/seal-escalation.d.ts.map +1 -0
- package/dist/seal-escalation.js +283 -0
- package/dist/seal-escalation.js.map +1 -0
- package/dist/seal-evidence-root-check.d.ts +70 -0
- package/dist/seal-evidence-root-check.d.ts.map +1 -0
- package/dist/seal-evidence-root-check.js +199 -0
- package/dist/seal-evidence-root-check.js.map +1 -0
- package/dist/seal-failure-store.d.ts +139 -0
- package/dist/seal-failure-store.d.ts.map +1 -0
- package/dist/seal-failure-store.js +207 -0
- package/dist/seal-failure-store.js.map +1 -0
- package/dist/seal-flows.d.ts +107 -0
- package/dist/seal-flows.d.ts.map +1 -0
- package/dist/seal-flows.js +638 -0
- package/dist/seal-flows.js.map +1 -0
- package/dist/seal-frontier-verify.d.ts +103 -0
- package/dist/seal-frontier-verify.d.ts.map +1 -0
- package/dist/seal-frontier-verify.js +143 -0
- package/dist/seal-frontier-verify.js.map +1 -0
- package/dist/seal-leaf.d.ts +58 -0
- package/dist/seal-leaf.d.ts.map +1 -0
- package/dist/seal-leaf.js +112 -0
- package/dist/seal-leaf.js.map +1 -0
- package/dist/seal-legibility-tbs.d.ts +25 -0
- package/dist/seal-legibility-tbs.d.ts.map +1 -0
- package/dist/seal-legibility-tbs.js +77 -0
- package/dist/seal-legibility-tbs.js.map +1 -0
- package/dist/seal-local-terminus.d.ts +41 -0
- package/dist/seal-local-terminus.d.ts.map +1 -0
- package/dist/seal-local-terminus.js +170 -0
- package/dist/seal-local-terminus.js.map +1 -0
- package/dist/seal-receipt-upgrade.d.ts +30 -0
- package/dist/seal-receipt-upgrade.d.ts.map +1 -0
- package/dist/seal-receipt-upgrade.js +49 -0
- package/dist/seal-receipt-upgrade.js.map +1 -0
- package/dist/seal-relay-silence.d.ts +51 -0
- package/dist/seal-relay-silence.d.ts.map +1 -0
- package/dist/seal-relay-silence.js +76 -0
- package/dist/seal-relay-silence.js.map +1 -0
- package/dist/seal-settle.d.ts +38 -0
- package/dist/seal-settle.d.ts.map +1 -0
- package/dist/seal-settle.js +33 -0
- package/dist/seal-settle.js.map +1 -0
- package/dist/seal-upgrade.d.ts +107 -0
- package/dist/seal-upgrade.d.ts.map +1 -0
- package/dist/seal-upgrade.js +200 -0
- package/dist/seal-upgrade.js.map +1 -0
- package/dist/sealed-conversation.d.ts +36 -0
- package/dist/sealed-conversation.d.ts.map +1 -0
- package/dist/sealed-conversation.js +171 -0
- package/dist/sealed-conversation.js.map +1 -0
- package/dist/sealed-leaf-set.d.ts +92 -0
- package/dist/sealed-leaf-set.d.ts.map +1 -0
- package/dist/sealed-leaf-set.js +122 -0
- package/dist/sealed-leaf-set.js.map +1 -0
- package/dist/send-claims.d.ts +56 -0
- package/dist/send-claims.d.ts.map +1 -0
- package/dist/send-claims.js +50 -0
- package/dist/send-claims.js.map +1 -0
- package/dist/session-assignment-parser.d.ts +104 -0
- package/dist/session-assignment-parser.d.ts.map +1 -0
- package/dist/session-assignment-parser.js +345 -0
- package/dist/session-assignment-parser.js.map +1 -0
- package/dist/session-category.d.ts +19 -0
- package/dist/session-category.d.ts.map +1 -0
- package/dist/session-category.js +14 -0
- package/dist/session-category.js.map +1 -0
- package/dist/session-ceremony.d.ts +297 -0
- package/dist/session-ceremony.d.ts.map +1 -0
- package/dist/session-ceremony.js +964 -0
- package/dist/session-ceremony.js.map +1 -0
- package/dist/session-closed.d.ts +79 -0
- package/dist/session-closed.d.ts.map +1 -0
- package/dist/session-closed.js +189 -0
- package/dist/session-closed.js.map +1 -0
- package/dist/session-connection-gater.d.ts +148 -0
- package/dist/session-connection-gater.d.ts.map +1 -0
- package/dist/session-connection-gater.js +332 -0
- package/dist/session-connection-gater.js.map +1 -0
- package/dist/session-content-context.d.ts +162 -0
- package/dist/session-content-context.d.ts.map +1 -0
- package/dist/session-content-context.js +2 -0
- package/dist/session-content-context.js.map +1 -0
- package/dist/session-content-handlers.d.ts +82 -0
- package/dist/session-content-handlers.d.ts.map +1 -0
- package/dist/session-content-handlers.js +1382 -0
- package/dist/session-content-handlers.js.map +1 -0
- package/dist/session-content-ingest.d.ts +206 -0
- package/dist/session-content-ingest.d.ts.map +1 -0
- package/dist/session-content-ingest.js +2167 -0
- package/dist/session-content-ingest.js.map +1 -0
- package/dist/session-content-send.d.ts +191 -0
- package/dist/session-content-send.d.ts.map +1 -0
- package/dist/session-content-send.js +1360 -0
- package/dist/session-content-send.js.map +1 -0
- package/dist/session-delivery-acks.d.ts +169 -0
- package/dist/session-delivery-acks.d.ts.map +1 -0
- package/dist/session-delivery-acks.js +569 -0
- package/dist/session-delivery-acks.js.map +1 -0
- package/dist/session-ephemerals.d.ts +279 -0
- package/dist/session-ephemerals.d.ts.map +1 -0
- package/dist/session-ephemerals.js +591 -0
- package/dist/session-ephemerals.js.map +1 -0
- package/dist/session-leaf-records.d.ts +159 -0
- package/dist/session-leaf-records.d.ts.map +1 -0
- package/dist/session-leaf-records.js +408 -0
- package/dist/session-leaf-records.js.map +1 -0
- package/dist/session-lifecycle.d.ts +303 -0
- package/dist/session-lifecycle.d.ts.map +1 -0
- package/dist/session-lifecycle.js +1679 -0
- package/dist/session-lifecycle.js.map +1 -0
- package/dist/session-liveness.d.ts +135 -0
- package/dist/session-liveness.d.ts.map +1 -0
- package/dist/session-liveness.js +347 -0
- package/dist/session-liveness.js.map +1 -0
- package/dist/session-name.d.ts +35 -0
- package/dist/session-name.d.ts.map +1 -0
- package/dist/session-name.js +60 -0
- package/dist/session-name.js.map +1 -0
- package/dist/session-node-factory.d.ts +18 -0
- package/dist/session-node-factory.d.ts.map +1 -0
- package/dist/session-node-factory.js +182 -0
- package/dist/session-node-factory.js.map +1 -0
- package/dist/session-node-manager.d.ts +877 -0
- package/dist/session-node-manager.d.ts.map +1 -0
- package/dist/session-node-manager.js +2998 -0
- package/dist/session-node-manager.js.map +1 -0
- package/dist/session-node-types.d.ts +1057 -0
- package/dist/session-node-types.d.ts.map +1 -0
- package/dist/session-node-types.js +657 -0
- package/dist/session-node-types.js.map +1 -0
- package/dist/session-notify.d.ts +46 -0
- package/dist/session-notify.d.ts.map +1 -0
- package/dist/session-notify.js +116 -0
- package/dist/session-notify.js.map +1 -0
- package/dist/session-own-chain-store.d.ts +65 -0
- package/dist/session-own-chain-store.d.ts.map +1 -0
- package/dist/session-own-chain-store.js +75 -0
- package/dist/session-own-chain-store.js.map +1 -0
- package/dist/session-queries.d.ts +476 -0
- package/dist/session-queries.d.ts.map +1 -0
- package/dist/session-queries.js +1007 -0
- package/dist/session-queries.js.map +1 -0
- package/dist/session-read-handlers.d.ts +87 -0
- package/dist/session-read-handlers.d.ts.map +1 -0
- package/dist/session-read-handlers.js +689 -0
- package/dist/session-read-handlers.js.map +1 -0
- package/dist/session-records.d.ts +341 -0
- package/dist/session-records.d.ts.map +1 -0
- package/dist/session-records.js +858 -0
- package/dist/session-records.js.map +1 -0
- package/dist/session-relay-client.d.ts +659 -0
- package/dist/session-relay-client.d.ts.map +1 -0
- package/dist/session-relay-client.js +2876 -0
- package/dist/session-relay-client.js.map +1 -0
- package/dist/session-relay.d.ts +397 -0
- package/dist/session-relay.d.ts.map +1 -0
- package/dist/session-relay.js +1636 -0
- package/dist/session-relay.js.map +1 -0
- package/dist/session-salt-agreement.d.ts +331 -0
- package/dist/session-salt-agreement.d.ts.map +1 -0
- package/dist/session-salt-agreement.js +472 -0
- package/dist/session-salt-agreement.js.map +1 -0
- package/dist/session-salts.d.ts +432 -0
- package/dist/session-salts.d.ts.map +1 -0
- package/dist/session-salts.js +1540 -0
- package/dist/session-salts.js.map +1 -0
- package/dist/session-schema.d.ts +30 -0
- package/dist/session-schema.d.ts.map +1 -0
- package/dist/session-schema.js +877 -0
- package/dist/session-schema.js.map +1 -0
- package/dist/session-seal-leaf-store.d.ts +70 -0
- package/dist/session-seal-leaf-store.d.ts.map +1 -0
- package/dist/session-seal-leaf-store.js +105 -0
- package/dist/session-seal-leaf-store.js.map +1 -0
- package/dist/session-seal.d.ts +334 -0
- package/dist/session-seal.d.ts.map +1 -0
- package/dist/session-seal.js +1017 -0
- package/dist/session-seal.js.map +1 -0
- package/dist/session-terminal-refusal.d.ts +65 -0
- package/dist/session-terminal-refusal.d.ts.map +1 -0
- package/dist/session-terminal-refusal.js +87 -0
- package/dist/session-terminal-refusal.js.map +1 -0
- package/dist/session-tree.d.ts +110 -0
- package/dist/session-tree.d.ts.map +1 -0
- package/dist/session-tree.js +144 -0
- package/dist/session-tree.js.map +1 -0
- package/dist/session-views.d.ts +47 -0
- package/dist/session-views.d.ts.map +1 -0
- package/dist/session-views.js +278 -0
- package/dist/session-views.js.map +1 -0
- package/dist/signal-handlers.d.ts +63 -0
- package/dist/signal-handlers.d.ts.map +1 -0
- package/dist/signal-handlers.js +980 -0
- package/dist/signal-handlers.js.map +1 -0
- package/dist/signal-requirement-policy.d.ts +51 -0
- package/dist/signal-requirement-policy.d.ts.map +1 -0
- package/dist/signal-requirement-policy.js +89 -0
- package/dist/signal-requirement-policy.js.map +1 -0
- package/dist/signal-revocability.d.ts +49 -0
- package/dist/signal-revocability.d.ts.map +1 -0
- package/dist/signal-revocability.js +93 -0
- package/dist/signal-revocability.js.map +1 -0
- package/dist/signal-submission.d.ts +181 -0
- package/dist/signal-submission.d.ts.map +1 -0
- package/dist/signal-submission.js +368 -0
- package/dist/signal-submission.js.map +1 -0
- package/dist/signaling-connect.d.ts +118 -0
- package/dist/signaling-connect.d.ts.map +1 -0
- package/dist/signaling-connect.js +538 -0
- package/dist/signaling-connect.js.map +1 -0
- package/dist/signaling-wiring.d.ts +105 -0
- package/dist/signaling-wiring.d.ts.map +1 -0
- package/dist/signaling-wiring.js +393 -0
- package/dist/signaling-wiring.js.map +1 -0
- package/dist/singleton-lock.d.ts +85 -0
- package/dist/singleton-lock.d.ts.map +1 -0
- package/dist/singleton-lock.js +219 -0
- package/dist/singleton-lock.js.map +1 -0
- package/dist/sqlcipher-db.d.ts +139 -0
- package/dist/sqlcipher-db.d.ts.map +1 -0
- package/dist/sqlcipher-db.js +357 -0
- package/dist/sqlcipher-db.js.map +1 -0
- package/dist/standing-receivers.d.ts +319 -0
- package/dist/standing-receivers.d.ts.map +1 -0
- package/dist/standing-receivers.js +1295 -0
- package/dist/standing-receivers.js.map +1 -0
- package/dist/start-agent.d.ts +64 -0
- package/dist/start-agent.d.ts.map +1 -0
- package/dist/start-agent.js +136 -0
- package/dist/start-agent.js.map +1 -0
- package/dist/status-handler.d.ts +54 -0
- package/dist/status-handler.d.ts.map +1 -0
- package/dist/status-handler.js +61 -0
- package/dist/status-handler.js.map +1 -0
- package/dist/submission-retry.d.ts +208 -0
- package/dist/submission-retry.d.ts.map +1 -0
- package/dist/submission-retry.js +506 -0
- package/dist/submission-retry.js.map +1 -0
- package/dist/telegram-bot-client.d.ts +34 -0
- package/dist/telegram-bot-client.d.ts.map +1 -0
- package/dist/telegram-bot-client.js +36 -0
- package/dist/telegram-bot-client.js.map +1 -0
- package/dist/telegram-doorbell.d.ts +38 -0
- package/dist/telegram-doorbell.d.ts.map +1 -0
- package/dist/telegram-doorbell.js +140 -0
- package/dist/telegram-doorbell.js.map +1 -0
- package/dist/test-handlers.d.ts +62 -0
- package/dist/test-handlers.d.ts.map +1 -0
- package/dist/test-handlers.js +240 -0
- package/dist/test-handlers.js.map +1 -0
- package/dist/testing.d.ts +10 -0
- package/dist/testing.d.ts.map +1 -0
- package/dist/testing.js +10 -0
- package/dist/testing.js.map +1 -0
- package/dist/transport-composition.d.ts +31 -0
- package/dist/transport-composition.d.ts.map +1 -0
- package/dist/transport-composition.js +55 -0
- package/dist/transport-composition.js.map +1 -0
- package/dist/transport-selector.d.ts +202 -0
- package/dist/transport-selector.d.ts.map +1 -0
- package/dist/transport-selector.js +196 -0
- package/dist/transport-selector.js.map +1 -0
- package/dist/trust-signal-pickup-listener.d.ts +43 -0
- package/dist/trust-signal-pickup-listener.d.ts.map +1 -0
- package/dist/trust-signal-pickup-listener.js +48 -0
- package/dist/trust-signal-pickup-listener.js.map +1 -0
- package/dist/trust-signal-store.d.ts +406 -0
- package/dist/trust-signal-store.d.ts.map +1 -0
- package/dist/trust-signal-store.js +939 -0
- package/dist/trust-signal-store.js.map +1 -0
- package/dist/trust-signal-sweep-tick.d.ts +60 -0
- package/dist/trust-signal-sweep-tick.d.ts.map +1 -0
- package/dist/trust-signal-sweep-tick.js +101 -0
- package/dist/trust-signal-sweep-tick.js.map +1 -0
- package/dist/trust-signal-sweep.d.ts +94 -0
- package/dist/trust-signal-sweep.d.ts.map +1 -0
- package/dist/trust-signal-sweep.js +147 -0
- package/dist/trust-signal-sweep.js.map +1 -0
- package/dist/type-registry.d.ts +42 -0
- package/dist/type-registry.d.ts.map +1 -0
- package/dist/type-registry.js +37 -0
- package/dist/type-registry.js.map +1 -0
- package/dist/types.d.ts +746 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +20 -0
- package/dist/types.js.map +1 -0
- package/dist/unresolved-nodes-report.d.ts +14 -0
- package/dist/unresolved-nodes-report.d.ts.map +1 -0
- package/dist/unresolved-nodes-report.js +96 -0
- package/dist/unresolved-nodes-report.js.map +1 -0
- package/dist/vocabulary.d.ts +150 -0
- package/dist/vocabulary.d.ts.map +1 -0
- package/dist/vocabulary.js +386 -0
- package/dist/vocabulary.js.map +1 -0
- package/dist/who-label.d.ts +28 -0
- package/dist/who-label.d.ts.map +1 -0
- package/dist/who-label.js +31 -0
- package/dist/who-label.js.map +1 -0
- package/dist/who-resolver.d.ts +15 -0
- package/dist/who-resolver.d.ts.map +1 -0
- package/dist/who-resolver.js +47 -0
- package/dist/who-resolver.js.map +1 -0
- package/dist/wire-content-hash.d.ts +93 -0
- package/dist/wire-content-hash.d.ts.map +1 -0
- package/dist/wire-content-hash.js +116 -0
- package/dist/wire-content-hash.js.map +1 -0
- package/dist/withheld-content.d.ts +13 -0
- package/dist/withheld-content.d.ts.map +1 -0
- package/dist/withheld-content.js +46 -0
- package/dist/withheld-content.js.map +1 -0
- package/dist/witness-alerts.d.ts +40 -0
- package/dist/witness-alerts.d.ts.map +1 -0
- package/dist/witness-alerts.js +102 -0
- package/dist/witness-alerts.js.map +1 -0
- package/package.json +5 -5
|
@@ -0,0 +1,2876 @@
|
|
|
1
|
+
var _a;
|
|
2
|
+
/**
|
|
3
|
+
* Daemon-side relay witness client (PER AGENT).
|
|
4
|
+
*
|
|
5
|
+
* In CELLO the relay is the ordering/witness authority (Structure 2): it never sees
|
|
6
|
+
* plaintext (content is peer↔peer), only the SIGNED content-hash leaves. It assigns
|
|
7
|
+
* the canonical `sequence_number` and forwards each witnessed leaf to the counterparty
|
|
8
|
+
* (`leaf_deliver`). A `cello_send` whose hash the relay never witnessed has no canonical
|
|
9
|
+
* sequence and is not a complete CELLO message — so the session submits the message leaf
|
|
10
|
+
* hash here, in parallel with the direct content delivery.
|
|
11
|
+
*
|
|
12
|
+
* ONE stream per AGENT, not per session. The relay authenticates a stream by the agent's
|
|
13
|
+
* K_local pubkey and keys its delivery/queue maps by that pubkey (relay-node
|
|
14
|
+
* #handleRelayStream / #processHashSubmit), so a second stream for the same pubkey would
|
|
15
|
+
* OVERWRITE the first's delivery stream and steal its queued `leaf_deliver`s. The protocol
|
|
16
|
+
* is designed for one relay connection per agent identity multiplexing all that agent's
|
|
17
|
+
* sessions — every wire frame carries `session_id`. So this client is shared across an
|
|
18
|
+
* agent's sessions: submits are globally FIFO-serialized on the stream (a `hash_submit_ack`
|
|
19
|
+
* carries NO session_id, so at most one submit may be outstanding at a time and acks match
|
|
20
|
+
* the queue head in order), while inbound `leaf_deliver` (which DOES carry `session_id`) is
|
|
21
|
+
* routed to the owning session's handler.
|
|
22
|
+
*
|
|
23
|
+
* The stream is (re)dialed from whatever live session node is current at submit time, so it
|
|
24
|
+
* survives individual session teardown (the relay treats a same-pubkey reconnect as a
|
|
25
|
+
* reconnect, re-auths, and re-drains).
|
|
26
|
+
*
|
|
27
|
+
* The server contract this must match: `packages/relay/src/relay-node.ts`.
|
|
28
|
+
*
|
|
29
|
+
* Crypto: Ed25519 RFC 8032. Relay auth domain: "CELLO-RELAY-AUTH-v1".
|
|
30
|
+
*/
|
|
31
|
+
import { createHash } from "node:crypto";
|
|
32
|
+
import * as lp from "it-length-prefixed";
|
|
33
|
+
import { decode } from "cbor-x";
|
|
34
|
+
import { encodeCbor, decodeSealPayload, encodeStructure1, decodeStructure1, computeGenesisPrevRoot, computeChainAnchor } from "@cello-protocol/protocol-types";
|
|
35
|
+
import { encodeSessionLivenessQuery, decodeSessionLivenessResponse, encodeSessionAttendanceNotice, } from "@cello-protocol/protocol-types";
|
|
36
|
+
import { verify } from "@cello-protocol/crypto";
|
|
37
|
+
import { extractErrorMessage } from "./error-message.js";
|
|
38
|
+
import { evaluateRelayAck, readSubmittedLeaf } from "./relay-receipt-store.js";
|
|
39
|
+
import { lastSeenFromRecord } from "./resume-last-seen.js";
|
|
40
|
+
import { reconnectWithBackoff, RELAY_RECONNECT_BASE_MS } from "./relay-reconnect.js";
|
|
41
|
+
/**
|
|
42
|
+
* DOD-M15-AWAYSCOPE-1 — what a liveness query answers with.
|
|
43
|
+
*
|
|
44
|
+
* `refused` is kept separate from the values rather than folded into `liveness`, because it means
|
|
45
|
+
* something different: the relay declined to say, most likely because this caller is not a
|
|
46
|
+
* participant of the session it named. The liveness is 'unknown' either way — the operator surface
|
|
47
|
+
* shows the same thing — but a caller that wants to tell "the relay would not tell me" from "the
|
|
48
|
+
* relay does not know" can, and a log line that conflated them would send the next investigation
|
|
49
|
+
* looking at the counterparty instead of at the session id.
|
|
50
|
+
*/
|
|
51
|
+
/**
|
|
52
|
+
* The key an answer is matched to its question by: the two fields the RESPONSE carries back. One
|
|
53
|
+
* relay client serves every session an agent holds there, so two concurrent queries on one stream
|
|
54
|
+
* is ordinary — and an answer routed to whatever was asked last shows the wrong counterparty's row.
|
|
55
|
+
*/
|
|
56
|
+
function livenessKey(sessionId, counterpartyPubkey) {
|
|
57
|
+
return `${Buffer.from(sessionId).toString("hex")}:${Buffer.from(counterpartyPubkey).toString("hex")}`;
|
|
58
|
+
}
|
|
59
|
+
export const RELAY_PROTOCOL_ID = "/cello/relay/1.0.0";
|
|
60
|
+
/**
|
|
61
|
+
* How long to wait before re-proving after a transport-class failure.
|
|
62
|
+
*
|
|
63
|
+
* Sized against the measured event, not guessed: libp2p logged
|
|
64
|
+
* `connection-manager closing 1 connections / stopped / started` inside 3ms, and the redial that
|
|
65
|
+
* followed completed ~1s later. A second is comfortably past the restart while still being
|
|
66
|
+
* invisible next to the ~5 minutes the previous behaviour cost (quarantine the relay, wait for the
|
|
67
|
+
* next reachability sweep).
|
|
68
|
+
*/
|
|
69
|
+
export const PROOF_RETRY_DELAY_MS = 1_000;
|
|
70
|
+
export const RELAY_AUTH_DOMAIN = "CELLO-RELAY-AUTH-v1";
|
|
71
|
+
/**
|
|
72
|
+
* DOD-WITNESS-STALL-1 — relay refusals that can NEVER resolve.
|
|
73
|
+
*
|
|
74
|
+
* `sendContent` treats a failed leaf-hash submit as a transient degradation: the content is real,
|
|
75
|
+
* the peer still gets it, and the canonical sequence is recovered later. That is correct for a relay
|
|
76
|
+
* that is briefly unreachable.
|
|
77
|
+
*
|
|
78
|
+
* It is WRONG for these two. They mean the relay has ended the session — there is no later, and
|
|
79
|
+
* nothing sent from here can ever enter the record. Collapsing them into the transient case is what
|
|
80
|
+
* let a conversation run for 68 minutes and 8 messages, every send reporting `delivered: true`,
|
|
81
|
+
* against a chain that had stopped growing.
|
|
82
|
+
*
|
|
83
|
+
* ENUMERATED, never pattern-matched. A substring rule like `reason.includes("sealed")` would absorb
|
|
84
|
+
* a future reason nobody has considered — which is the same collapse in a new coat.
|
|
85
|
+
*/
|
|
86
|
+
export const TERMINAL_RELAY_REFUSALS = new Set([
|
|
87
|
+
"session_sealed",
|
|
88
|
+
"session_not_found",
|
|
89
|
+
/**
|
|
90
|
+
* `DOD-M15-TERMINAL-REASON-1` split `session_sealed` into named causes, and this set is one of
|
|
91
|
+
* THREE places keyed on the old literal — a rename on the relay silently made a terminal refusal
|
|
92
|
+
* non-terminal here, which is the 68-minute defect above reopened by a string change.
|
|
93
|
+
*
|
|
94
|
+
* `seal_refused`: a directory READ the seal and rejected it. Terminal in the strongest sense —
|
|
95
|
+
* there is no later, and no retry can change a merits verdict.
|
|
96
|
+
*
|
|
97
|
+
* `seal_in_progress` is deliberately ABSENT. A seal in flight may still succeed, and after
|
|
98
|
+
* `DOD-M15-TRANSPORT-TERMINAL-1` the session can return to `active` — treating it as terminal
|
|
99
|
+
* would retire a conversation that is about to seal normally.
|
|
100
|
+
*/
|
|
101
|
+
"seal_refused",
|
|
102
|
+
// The counterparty closed first: terminal for THIS SEND only, never retires (live 2026-09-13).
|
|
103
|
+
"session_closing",
|
|
104
|
+
]);
|
|
105
|
+
/** True when the relay has ended this session and no later submit can succeed. */
|
|
106
|
+
export function isTerminalRelayRefusal(reason) {
|
|
107
|
+
return reason !== undefined && TERMINAL_RELAY_REFUSALS.has(reason);
|
|
108
|
+
}
|
|
109
|
+
/**
|
|
110
|
+
* DOD-M15-RELAYSLOTS-1 — **WHICH REFUSALS JUSTIFY TRYING A DIFFERENT RELAY.**
|
|
111
|
+
*
|
|
112
|
+
* We run several relays, so "move on to the next one" is always available — which is exactly why it
|
|
113
|
+
* needs a rule. Moving on from a problem that every relay will have turns one client-side fault into
|
|
114
|
+
* what looks like a fleet-wide outage, and the operator then goes looking for a broken relay.
|
|
115
|
+
*
|
|
116
|
+
* ENUMERATED, never pattern-matched, for the same reason `TERMINAL_RELAY_REFUSALS` is: a substring
|
|
117
|
+
* rule would silently absorb a future reason nobody has considered.
|
|
118
|
+
*/
|
|
119
|
+
const RELAY_SIDE_REFUSALS = new Set([
|
|
120
|
+
/**
|
|
121
|
+
* The relay holds no directory public key, so it can verify nothing and is refusing everyone.
|
|
122
|
+
* That is this relay being misconfigured, not us being wrong — another relay is the right move,
|
|
123
|
+
* and it is the whole reason we run more than one.
|
|
124
|
+
*/
|
|
125
|
+
"online_token_no_directory_key",
|
|
126
|
+
]);
|
|
127
|
+
/**
|
|
128
|
+
* ─── WHOSE FAULT IS IT? — `DOD-M15-TOKENSTALE-1` ───────────────────────────────────────────────
|
|
129
|
+
*
|
|
130
|
+
* A SECOND question about the same refusal, and it is deliberately not `tryAnotherRelay` inverted.
|
|
131
|
+
* That one asks "would another relay help?"; this asks "is the broken thing ours?". They disagree
|
|
132
|
+
* on the case that matters: a slot cap is not worth failing over AND is entirely the relay's own
|
|
133
|
+
* answer about its own table — so reusing the failover flag to mean "our fault" would be right for
|
|
134
|
+
* the wrong reason, and would start telling operators their machine is broken when it is not.
|
|
135
|
+
*
|
|
136
|
+
* The consumer is the submit boundary. `#ensureConnected` returns a BOOLEAN, so every refusal below
|
|
137
|
+
* used to collapse into `relay_unavailable` — "the relay is unreachable", which is transient,
|
|
138
|
+
* somebody else's, and the documented reason a send degrades to an unwitnessed leaf and still
|
|
139
|
+
* reports success. An expired token is none of those. Measured live 2026-09-10: an agent reported
|
|
140
|
+
* `ok:true, delivered:true` on every send for over two days while the relay refused all thirteen
|
|
141
|
+
* authentications, and no conversation it held could produce a receipt.
|
|
142
|
+
*
|
|
143
|
+
* ⚠️ AN UNKNOWN REASON IS NOT LOCAL, and the direction of that default is a real choice. A future
|
|
144
|
+
* relay-side reason defaulting to "local" would accuse the reader's own machine, and a wrong
|
|
145
|
+
* accusation about your own setup costs more than a vague one about someone else's.
|
|
146
|
+
*/
|
|
147
|
+
const LOCAL_CREDENTIAL_REFUSALS = new Set([
|
|
148
|
+
"online_token_required",
|
|
149
|
+
"online_token_expired",
|
|
150
|
+
"online_token_pubkey_mismatch",
|
|
151
|
+
]);
|
|
152
|
+
export function isLocalCredentialRefusal(reason) {
|
|
153
|
+
return LOCAL_CREDENTIAL_REFUSALS.has(reason);
|
|
154
|
+
}
|
|
155
|
+
/**
|
|
156
|
+
* Classify a relay's auth refusal: what to tell the operator, and whether another relay would help.
|
|
157
|
+
*
|
|
158
|
+
* Everything not in `RELAY_SIDE_REFUSALS` defaults to "do not try another", and that default is the
|
|
159
|
+
* safe direction. A token problem reproduces identically on every relay in the fleet, so retrying
|
|
160
|
+
* around the fleet spends real time turning a client fault into an apparent outage — and a slot cap
|
|
161
|
+
* IS satisfiable elsewhere, but spreading to another relay papers over sessions that leaked and
|
|
162
|
+
* brings the same wall back on the next one.
|
|
163
|
+
*/
|
|
164
|
+
export function classifyRelayAuthRefusal(reason, extra = {}) {
|
|
165
|
+
const tryAnotherRelay = RELAY_SIDE_REFUSALS.has(reason);
|
|
166
|
+
let advice;
|
|
167
|
+
switch (reason) {
|
|
168
|
+
case "online_token_required":
|
|
169
|
+
advice = "This agent has no online token from a directory yet. It is issued when a directory " +
|
|
170
|
+
"marks the agent online, so this usually clears itself on the next directory connection. If " +
|
|
171
|
+
"it persists, the agent is not reaching any directory — check that first, not the relay.";
|
|
172
|
+
break;
|
|
173
|
+
case "online_token_expired":
|
|
174
|
+
/**
|
|
175
|
+
* ⚠️ THIS SENTENCE USED TO PROMISE A REFRESH THAT DOES NOT HAPPEN, and the promise is why the
|
|
176
|
+
* condition survived for days. It read: *"refreshed on the next directory connection"*. True,
|
|
177
|
+
* and useless — the token is issued only in the directory handshake, it lasts ONE HOUR, and a
|
|
178
|
+
* healthy agent does not make another connection. Measured on one box: a token at
|
|
179
|
+
* 2026-09-08 09:06 and the next over two days later.
|
|
180
|
+
*
|
|
181
|
+
* It now leads with the CONSEQUENCE, because the operator arrives here having been told every
|
|
182
|
+
* send succeeded, and names the one action that works today. The automatic refresh is
|
|
183
|
+
* `DOD-M15-TOKENSTALE-1` unit 2 and needs a directory frame that does not exist yet.
|
|
184
|
+
*/
|
|
185
|
+
advice = "This agent's pass from the directory has expired, so the relay is refusing to " +
|
|
186
|
+
"witness anything it sends: messages still arrive, but nothing is being recorded as proof " +
|
|
187
|
+
"and no conversation can produce a receipt until this clears. It is NOT refreshed while the " +
|
|
188
|
+
"daemon keeps running — restart it (cello logout, then cello login) and the next session " +
|
|
189
|
+
"will be witnessed again. If it comes back within the hour, this machine's clock or its " +
|
|
190
|
+
"directory connection is the thing to look at.";
|
|
191
|
+
break;
|
|
192
|
+
case "online_token_signature_invalid":
|
|
193
|
+
case "online_token_malformed":
|
|
194
|
+
case "online_token_lifetime_too_long":
|
|
195
|
+
advice = "This relay would not accept the token this agent was issued. Most often the relay " +
|
|
196
|
+
"and the directory are not in the same consortium — check which directories this relay is " +
|
|
197
|
+
"configured to trust.";
|
|
198
|
+
break;
|
|
199
|
+
case "online_token_pubkey_mismatch":
|
|
200
|
+
advice = "The token names a different key from the one this agent signed with. That is an " +
|
|
201
|
+
"identity mix-up on this machine, not a relay problem.";
|
|
202
|
+
break;
|
|
203
|
+
case "online_token_no_directory_key":
|
|
204
|
+
advice = "This relay holds no directory public key, so it cannot verify anyone and is " +
|
|
205
|
+
"refusing every agent. Its operator needs to configure one; another relay will work now.";
|
|
206
|
+
break;
|
|
207
|
+
case "slot_cap_exceeded":
|
|
208
|
+
advice = extra.slotsHeld !== undefined && extra.slotCap !== undefined
|
|
209
|
+
? `This agent already holds ${String(extra.slotsHeld)} of a maximum ${String(extra.slotCap)} ` +
|
|
210
|
+
"reservations on this relay, and none is idle enough to reclaim. That is almost always " +
|
|
211
|
+
"sessions that were never closed — close some and this clears. Moving to another relay " +
|
|
212
|
+
"would work now and hit the same wall there."
|
|
213
|
+
: "This agent already holds the most reservations one agent may hold on this relay. That is " +
|
|
214
|
+
"almost always sessions that were never closed — close some and this clears.";
|
|
215
|
+
break;
|
|
216
|
+
case "session_tuple_cap_exceeded":
|
|
217
|
+
advice = extra.slotsHeld !== undefined && extra.slotCap !== undefined
|
|
218
|
+
? `You already have ${String(extra.slotsHeld)} conversations open with this counterparty, ` +
|
|
219
|
+
`which is the maximum of ${String(extra.slotCap)} this relay allows between one pair of ` +
|
|
220
|
+
"agents. Close some and try again — this is almost always conversations that were never " +
|
|
221
|
+
"closed rather than ones anybody is still using."
|
|
222
|
+
: "You already have the maximum number of concurrent conversations open with this " +
|
|
223
|
+
"counterparty. Close some and try again.";
|
|
224
|
+
break;
|
|
225
|
+
case "rate_limited":
|
|
226
|
+
advice = extra.retryAfterMs !== undefined
|
|
227
|
+
? `This relay is throttling this agent; it clears on its own in about ${String(Math.ceil(extra.retryAfterMs / 1000))}s.`
|
|
228
|
+
: "This relay is throttling this agent; it clears on its own after the throttle window.";
|
|
229
|
+
break;
|
|
230
|
+
default:
|
|
231
|
+
advice = "This agent could not authenticate to this relay, so it cannot hold a reservation " +
|
|
232
|
+
"here and is reachable only over a direct connection.";
|
|
233
|
+
}
|
|
234
|
+
return {
|
|
235
|
+
reason,
|
|
236
|
+
advice,
|
|
237
|
+
tryAnotherRelay,
|
|
238
|
+
...(extra.slotsHeld !== undefined ? { slotsHeld: extra.slotsHeld } : {}),
|
|
239
|
+
...(extra.slotCap !== undefined ? { slotCap: extra.slotCap } : {}),
|
|
240
|
+
...(extra.retryAfterMs !== undefined ? { retryAfterMs: extra.retryAfterMs } : {}),
|
|
241
|
+
};
|
|
242
|
+
}
|
|
243
|
+
/** Structure 1 leaf kind — these values match the relay's. Message leaf (the `cello_send` default). */
|
|
244
|
+
export const LEAF_KIND_MSG = 0x00;
|
|
245
|
+
/** Control leaf (SEAL etc.) — two distinct-sender ctrl leaves trigger directory notarization. */
|
|
246
|
+
export const LEAF_KIND_CTRL = 0x02;
|
|
247
|
+
/** Document-operation leaf (DOD-DOC-LEAF-1): a CRDT update riding the session tree. */
|
|
248
|
+
export const LEAF_KIND_DOC = 0x04;
|
|
249
|
+
/** Rejection leaf (DOD-DOC-LEAF-1): references the rejected update envelope's hash. */
|
|
250
|
+
export const LEAF_KIND_REJECT = 0x05;
|
|
251
|
+
const RELAY_AUTH_TIMEOUT_MS = 5_000;
|
|
252
|
+
const HASH_SUBMIT_TIMEOUT_MS = 10_000;
|
|
253
|
+
/**
|
|
254
|
+
* The Ed25519-signed payload that proves K_local ownership to the relay:
|
|
255
|
+
* SHA-256("CELLO-RELAY-AUTH-v1" || nonce || pubkey). The relay verifies the
|
|
256
|
+
* signature against exactly this hash (relay-node #handleRelayStream).
|
|
257
|
+
*/
|
|
258
|
+
export function buildRelayAuthPayload(nonce, pubkey) {
|
|
259
|
+
const domain = Buffer.from(RELAY_AUTH_DOMAIN, "utf8");
|
|
260
|
+
const authMsg = Buffer.concat([domain, nonce, pubkey]);
|
|
261
|
+
return new Uint8Array(createHash("sha256").update(authMsg).digest());
|
|
262
|
+
}
|
|
263
|
+
/**
|
|
264
|
+
* Rebuild the bytes a relay signs when it reports a witnessed forgery.
|
|
265
|
+
*
|
|
266
|
+
* ⚠️ **MIRRORED CODEC** — `packages/relay/src/leaf-witness.ts` `buildWitnessAlertTbs` in
|
|
267
|
+
* trustless-cello is the other half and the two MUST stay in sync. Both call `encodeCbor` from
|
|
268
|
+
* `@cello-protocol/protocol-types` rather than configuring an encoder, so the only thing that can
|
|
269
|
+
* drift is the field list, and both list it in one place.
|
|
270
|
+
*/
|
|
271
|
+
const RELAY_WITNESS_DOMAIN = "CELLO-RELAY-WITNESS-v1";
|
|
272
|
+
function buildWitnessAlertTbs(sessionId, reason, observedAt, submitterIsCounterparty) {
|
|
273
|
+
const body = encodeCbor([RELAY_WITNESS_DOMAIN, sessionId, reason, observedAt, submitterIsCounterparty]);
|
|
274
|
+
return new Uint8Array(createHash("sha256").update(body).digest());
|
|
275
|
+
}
|
|
276
|
+
/**
|
|
277
|
+
* The genesis prev_root derivable from a relay assignment — 033-ACKEMIT.
|
|
278
|
+
*
|
|
279
|
+
* The carry holds both participant keys and the session timestamp, which with the session id are
|
|
280
|
+
* exactly `computeGenesisPrevRoot`'s inputs. Returns `undefined` when there is no assignment, and
|
|
281
|
+
* the caller then has no seed: absence is reported at the submit, never papered over.
|
|
282
|
+
*
|
|
283
|
+
* ⚠️ NOT 32 ZERO BYTES, and not any other constant. A value identical across every session is one
|
|
284
|
+
* an attacker can present for any session, which would make the first message's acknowledgement
|
|
285
|
+
* unfalsifiable exactly where it is most exposed.
|
|
286
|
+
*/
|
|
287
|
+
function genesisFromAssignment(sessionIdHex, assignment) {
|
|
288
|
+
if (!assignment?.sessionSignature)
|
|
289
|
+
return undefined;
|
|
290
|
+
const genesis = computeGenesisPrevRoot(assignment.participantA, assignment.participantB, Uint8Array.from(Buffer.from(sessionIdHex, "hex")), assignment.sessionTimestamp);
|
|
291
|
+
return computeChainAnchor(genesis, assignment.sessionSignature);
|
|
292
|
+
}
|
|
293
|
+
function toU8(v) {
|
|
294
|
+
if (v instanceof Uint8Array)
|
|
295
|
+
return v;
|
|
296
|
+
if (Buffer.isBuffer(v))
|
|
297
|
+
return new Uint8Array(v);
|
|
298
|
+
if (v && typeof v.subarray === "function") {
|
|
299
|
+
return v.subarray();
|
|
300
|
+
}
|
|
301
|
+
if (v && typeof v.slice === "function") {
|
|
302
|
+
return v.slice();
|
|
303
|
+
}
|
|
304
|
+
return new Uint8Array();
|
|
305
|
+
}
|
|
306
|
+
async function nextWithTimeout(iter, ms) {
|
|
307
|
+
let timer;
|
|
308
|
+
const timeout = new Promise((resolve) => {
|
|
309
|
+
timer = setTimeout(() => resolve({ value: undefined, done: true }), ms);
|
|
310
|
+
});
|
|
311
|
+
try {
|
|
312
|
+
const result = (await Promise.race([iter.next(), timeout]));
|
|
313
|
+
return { value: result.value, done: !!result.done };
|
|
314
|
+
}
|
|
315
|
+
finally {
|
|
316
|
+
clearTimeout(timer);
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
/**
|
|
320
|
+
* Per-AGENT relay witness client. Shared across all of an agent's sessions; one
|
|
321
|
+
* authenticated stream at a time (the relay keys by agent pubkey). Submits are FIFO and
|
|
322
|
+
* single-in-flight on the stream; `leaf_deliver` is routed by session_id.
|
|
323
|
+
*/
|
|
324
|
+
export class AgentRelayClient {
|
|
325
|
+
/**
|
|
326
|
+
* DOD-RELAY-KEEPALIVE-1 (review F4): the last error that ended this relay's reader.
|
|
327
|
+
* The reservation watchdog reports `relay_connection_gone` — an exit-point label derived from a
|
|
328
|
+
* poll, with the real abort reason already discarded. This carries the cause across to it.
|
|
329
|
+
*/
|
|
330
|
+
#lastReaderError = null;
|
|
331
|
+
/** The cause of the most recent reader end, or null if it has not ended. */
|
|
332
|
+
getLastReaderError() {
|
|
333
|
+
return this.#lastReaderError;
|
|
334
|
+
}
|
|
335
|
+
#relayPeerId;
|
|
336
|
+
#relayAddrs;
|
|
337
|
+
#keyProvider;
|
|
338
|
+
#senderPubkey;
|
|
339
|
+
#logger;
|
|
340
|
+
#receiptStore;
|
|
341
|
+
#sealLeafStore;
|
|
342
|
+
#ownChainStore;
|
|
343
|
+
/**
|
|
344
|
+
* `DOD-M15-SELFCHAIN-1` — this agent's own last message per session, in memory.
|
|
345
|
+
*
|
|
346
|
+
* ⚠️ THE MAP IS THE CHAIN; THE STORE IS ITS DURABILITY. Separating them is deliberate. Within a
|
|
347
|
+
* running process the map is authoritative and always available, so a send never depends on a
|
|
348
|
+
* database being wired. Across a RESTART only the store can answer, and without it this daemon
|
|
349
|
+
* would silently start a new chain mid-conversation — the counterparty refusing every message
|
|
350
|
+
* after it, with no way to tell that from tampering.
|
|
351
|
+
*
|
|
352
|
+
* So a missing store is a durability gap, and never a reason to refuse a send: it is caused by our
|
|
353
|
+
* own wiring, never by anything a peer does, and refusing there would break the product to close a
|
|
354
|
+
* hole the peer cannot reach. It IS reported — once per client, at construction, by the error
|
|
355
|
+
* below. An earlier version of this comment promised that report and nothing emitted it, which is
|
|
356
|
+
* the shape that lets a wiring gap sit invisible until a restart days later.
|
|
357
|
+
*/
|
|
358
|
+
#ownChain = new Map();
|
|
359
|
+
/**
|
|
360
|
+
* `DOD-M15-SELFCHAIN-1` — each session's GENESIS, kept apart from `#lastSeen`, and keeping them
|
|
361
|
+
* apart is the entire point of this field.
|
|
362
|
+
*
|
|
363
|
+
* `#lastSeen` is seeded with the genesis at registration and then ADVANCES as the counterparty
|
|
364
|
+
* speaks. So after the counterparty's first message it holds THEIR last content hash, not the
|
|
365
|
+
* starting point. Reading it as the self link's fallback handed a sender the other party's hash
|
|
366
|
+
* for their own first message, which the relay then refused — and, worse, told the innocent
|
|
367
|
+
* counterparty their chain was broken. This map never advances, so it is always the right answer
|
|
368
|
+
* to "what does a party who has not spoken here yet link to?".
|
|
369
|
+
*/
|
|
370
|
+
#genesis = new Map();
|
|
371
|
+
/** DOD-M15-RELAYSLOTS-1 — read fresh at every auth. See `AgentRelayClientOpts.onlineToken`. */
|
|
372
|
+
#onlineToken;
|
|
373
|
+
/** DOD-M15-CORROBORATE-1 — where a relay's witness alert goes. See the opt of the same name. */
|
|
374
|
+
#onWitnessAlert;
|
|
375
|
+
/** DOD-M15-CORROBORATE-1 review F7 — where an UNREADABLE witness alert goes. */
|
|
376
|
+
#onWitnessUnreadable;
|
|
377
|
+
/**
|
|
378
|
+
* DOD-M15-RELAYSLOTS-1: the last refusal this relay gave us, classified. Kept because a log line
|
|
379
|
+
* reaches neither the operator asking why their agent is unreachable nor the code deciding
|
|
380
|
+
* whether a different relay would do any better.
|
|
381
|
+
*/
|
|
382
|
+
#lastAuthRefusal = null;
|
|
383
|
+
/**
|
|
384
|
+
* review F6 — which credential refusal we have already explained in full, so a condition that
|
|
385
|
+
* lasts until a relogin is explained once rather than on every send. Reset with the refusal
|
|
386
|
+
* itself, so the next occurrence after a recovery is a first occurrence again.
|
|
387
|
+
*/
|
|
388
|
+
#loggedCredentialRefusal = null;
|
|
389
|
+
/** The last classified auth refusal from this relay, or null if the last attempt succeeded. */
|
|
390
|
+
getLastAuthRefusal() {
|
|
391
|
+
return this.#lastAuthRefusal;
|
|
392
|
+
}
|
|
393
|
+
/**
|
|
394
|
+
* Drop any verdict from an earlier attempt — review HIGH-2.
|
|
395
|
+
*
|
|
396
|
+
* A METHOD rather than an inline `= null`, deliberately: assigning the field directly narrows its
|
|
397
|
+
* type to `null` for the rest of the enclosing method, and the later
|
|
398
|
+
* `#lastAuthRefusal?.reason` read then fails to compile against `never`. Clearing through a call
|
|
399
|
+
* keeps the declared type and says what the reset is for.
|
|
400
|
+
*/
|
|
401
|
+
#clearAuthRefusal() {
|
|
402
|
+
this.#loggedCredentialRefusal = null;
|
|
403
|
+
this.#lastAuthRefusal = null;
|
|
404
|
+
}
|
|
405
|
+
#stream = null;
|
|
406
|
+
#connecting = null;
|
|
407
|
+
#closed = false;
|
|
408
|
+
#reconnectRetryMs = RELAY_RECONNECT_BASE_MS;
|
|
409
|
+
#reconnecting = false;
|
|
410
|
+
/**
|
|
411
|
+
* PER-SESSION acknowledgement state (session_id hex → the position AND the content at it).
|
|
412
|
+
*
|
|
413
|
+
* `seq` is the highest relay-assigned sequence, advanced by an ack or a deliver. The relay's
|
|
414
|
+
* `seq_counter` is per session and it rejects `last_seen_seq > seq_counter`, so each session's
|
|
415
|
+
* submit MUST carry that session's own high-water mark — NOT an agent-global one (which would make a newer session's first submit look
|
|
416
|
+
* ahead and get rejected).
|
|
417
|
+
*
|
|
418
|
+
* ⚠️ `hash` IS THE SAME FACT AS `seq`, WHICH IS WHY THEY LIVE IN ONE ENTRY — 033-ACKEMIT.
|
|
419
|
+
*
|
|
420
|
+
* `last_seen_seq` is a NUMBER: "I saw position 7" attests to a POSITION and never to CONTENT, so
|
|
421
|
+
* a signed acknowledgement was an unbacked number that only the relay's separate receipt gave any
|
|
422
|
+
* meaning to. `hash` is the content hash of the message AT that position, and the two are written
|
|
423
|
+
* together in `#bumpLastSeen` from ONE decode of ONE leaf. They cannot be assigned apart, so they
|
|
424
|
+
* cannot come to mean different messages — which is the defect this unit exists to remove, not a
|
|
425
|
+
* disagreement to reconcile later.
|
|
426
|
+
*
|
|
427
|
+
* Seeded at `registerSession` with `{ seq: 0, hash: genesisPrevRoot }`: the first message of a
|
|
428
|
+
* session has seen nothing, and that case is a DEFINED 32-byte value — the agreed starting point
|
|
429
|
+
* of this two-party chain — never a missing field and never a fallback to v1.
|
|
430
|
+
*/
|
|
431
|
+
#lastSeen = new Map();
|
|
432
|
+
/** The one outstanding submit's resolver (global FIFO — ack carries no session_id). */
|
|
433
|
+
#pendingAck = null;
|
|
434
|
+
// The sender-signed structure1_cbor of the in-flight submit, paired with its ack so the
|
|
435
|
+
// SubmitResult can carry it (the ack itself only returns the relay's structure2_cbor).
|
|
436
|
+
#pendingStructure1 = null;
|
|
437
|
+
/**
|
|
438
|
+
* DOD-M15-SEALWIRE-1 bullet 5 (sent half) — OUR signature over the in-flight `#pendingStructure1`,
|
|
439
|
+
* paired with its ack for the same reason that one is: the ack returns the relay's record, never
|
|
440
|
+
* ours. Cleared wherever `#pendingStructure1` is cleared; the two must never drift apart, because
|
|
441
|
+
* a signature paired with the WRONG signed bytes is worse than no signature at all.
|
|
442
|
+
*/
|
|
443
|
+
#pendingSignature = null;
|
|
444
|
+
// The in-flight submit's leaf kind (0x00 msg / 0x02 ctrl), paired with its ack so
|
|
445
|
+
// #captureReceipt can persist it alongside the Structure2/Structure1 carry bytes for the unilateral seal.
|
|
446
|
+
#pendingLeafKind = null;
|
|
447
|
+
/** The session_id hex of the in-flight submit, so its ack updates the right #lastSeen. */
|
|
448
|
+
#pendingAckSessionHex = null;
|
|
449
|
+
/**
|
|
450
|
+
* Resolver for the in-flight `client_record_assignment` ack. The ack carries
|
|
451
|
+
* no session_id (like hash_submit_ack), so at most one record is in flight; records are serialized on
|
|
452
|
+
* the same `#submitChain` as submits, guaranteeing no overlap.
|
|
453
|
+
*/
|
|
454
|
+
#pendingRecord = null;
|
|
455
|
+
/**
|
|
456
|
+
* 054-SRSPLIT — the in-flight `relay_release_reservation`. At most one: releases are rare and
|
|
457
|
+
* serialized on the shared stream, exactly like `#pendingRecord`, whose ack also carries no id.
|
|
458
|
+
*/
|
|
459
|
+
#pendingRelease = null;
|
|
460
|
+
/**
|
|
461
|
+
* DOD-M15-AWAYSCOPE-1 — in-flight liveness queries, keyed `sessionIdHex:counterpartyHex`.
|
|
462
|
+
*
|
|
463
|
+
* ⚠️ A MAP, NOT A SLOT, and the first version was a slot. One `AgentRelayClient` serves EVERY
|
|
464
|
+
* session an agent holds on a relay and both callers fan out with `Promise.all`, so `cello status`
|
|
465
|
+
* with two open conversations fired two queries on one stream: the second overwrote the first's
|
|
466
|
+
* resolver, the first answer resolved the WRONG session's promise, and the other waited the full
|
|
467
|
+
* submit timeout — blowing the caller's budget so every row came back unenriched. With more than
|
|
468
|
+
* one session the feature did nothing, and when it did something it could show the wrong
|
|
469
|
+
* counterparty. The key is what the RESPONSE carries, so an answer is matched to its question.
|
|
470
|
+
*/
|
|
471
|
+
#pendingLiveness = new Map();
|
|
472
|
+
/** Serializes submits so only one is in flight at a time across all sessions. */
|
|
473
|
+
#submitChain = Promise.resolve();
|
|
474
|
+
/** session_id hex → { the live node to (re)dial from, inbound leaf handler, Option-B assignment to present }. */
|
|
475
|
+
#sessions = new Map();
|
|
476
|
+
constructor(opts) {
|
|
477
|
+
this.#relayPeerId = opts.relayPeerId;
|
|
478
|
+
this.#relayAddrs = opts.relayAddrs;
|
|
479
|
+
this.#keyProvider = opts.keyProvider;
|
|
480
|
+
this.#senderPubkey = opts.senderPubkey;
|
|
481
|
+
this.#logger = opts.logger;
|
|
482
|
+
this.#receiptStore = opts.receiptStore;
|
|
483
|
+
this.#sealLeafStore = opts.sealLeafStore;
|
|
484
|
+
this.#ownChainStore = opts.ownChainStore;
|
|
485
|
+
if (!this.#ownChainStore) {
|
|
486
|
+
/**
|
|
487
|
+
* ONCE PER CLIENT, AT CONSTRUCTION, and at ERROR because the consequence is invisible until a
|
|
488
|
+
* restart that may be days away — at which point this daemon silently starts a new chain
|
|
489
|
+
* mid-conversation and the counterparty refuses everything after it, for a reason that names
|
|
490
|
+
* tampering.
|
|
491
|
+
*/
|
|
492
|
+
this.#logger.error("session.relay.own_chain.store_absent", {
|
|
493
|
+
relayPeerId: opts.relayPeerId,
|
|
494
|
+
impact: "this relay client was wired without a durable record of what this agent has said. " +
|
|
495
|
+
"Conversations work until the daemon restarts; after a restart, mid-conversation, this " +
|
|
496
|
+
"side starts a new chain and the counterparty refuses every message after it as though " +
|
|
497
|
+
"the record had been altered.",
|
|
498
|
+
});
|
|
499
|
+
}
|
|
500
|
+
this.#onlineToken = opts.onlineToken;
|
|
501
|
+
this.#onWitnessAlert = opts.onWitnessAlert;
|
|
502
|
+
this.#onWitnessUnreadable = opts.onWitnessUnreadable;
|
|
503
|
+
if (opts.reconnectRetryMs !== undefined)
|
|
504
|
+
this.#reconnectRetryMs = opts.reconnectRetryMs;
|
|
505
|
+
}
|
|
506
|
+
/** The agent's K_local public key as hex — the responder identity for auto-acknowledge. */
|
|
507
|
+
get senderPubkeyHex() {
|
|
508
|
+
return Buffer.from(this.#senderPubkey).toString("hex");
|
|
509
|
+
}
|
|
510
|
+
/**
|
|
511
|
+
* Register a session's inbound leaf handler + a live node to (re)dial the relay from
|
|
512
|
+
* (idempotent). Storing the node per session lets a pure-receiver session re-establish
|
|
513
|
+
* the shared stream if the node that originally dialed is torn down.
|
|
514
|
+
*/
|
|
515
|
+
registerSession(sessionIdHex, node, onLeafDeliver, assignment,
|
|
516
|
+
/**
|
|
517
|
+
* 033-ACKEMIT — the session's genesis prev_root: what the FIRST message of this session
|
|
518
|
+
* acknowledges, before anything has been received.
|
|
519
|
+
*
|
|
520
|
+
* Supplied by the caller because `session-node-manager` is where the session record lives. When
|
|
521
|
+
* it is absent an ASSIGNMENT can still produce it (both participant keys and the session
|
|
522
|
+
* timestamp are on the carry), and that covers re-registration of a session whose row predates
|
|
523
|
+
* the column.
|
|
524
|
+
*
|
|
525
|
+
* ⚠️ WHEN NEITHER IS AVAILABLE THE SESSION CANNOT SUBMIT AT ALL — and this sentence used to say
|
|
526
|
+
* the opposite, that the first submit would claim position 0 with no hash. That shape no longer
|
|
527
|
+
* exists: `DOD-M15-SELFCHAIN-1` made both chain links required, so a session with no starting
|
|
528
|
+
* point has nothing for them to anchor to and every submit on it is refused by name. Registering
|
|
529
|
+
* without a seed is therefore a real fault, not a degraded mode.
|
|
530
|
+
*/
|
|
531
|
+
genesisPrevRoot,
|
|
532
|
+
/**
|
|
533
|
+
* 069-ORDERPROOF — the relay key this session's ordering attestations verify under, from
|
|
534
|
+
* `relay_id` on the directory-signed assignment. Supplied by the caller because
|
|
535
|
+
* `session-node-manager` is where the durable copy lives, exactly as with the genesis above.
|
|
536
|
+
*
|
|
537
|
+
* ⚠️ ABSENT IS NOT A DEGRADED MODE. A session with no anchor refuses every attestation that
|
|
538
|
+
* arrives on it, by name, because the only alternative is checking a signature against a key
|
|
539
|
+
* the signer handed us.
|
|
540
|
+
*/
|
|
541
|
+
relayAnchorHex) {
|
|
542
|
+
const existing = this.#sessions.get(sessionIdHex);
|
|
543
|
+
const carriedAssignment = assignment ?? existing?.assignment;
|
|
544
|
+
// The anchor is carried forward for the same reason the assignment is: a re-registration with
|
|
545
|
+
// nothing in hand must not silently strip a session of the key it verifies against.
|
|
546
|
+
const carriedAnchor = relayAnchorHex ?? carriedAssignment?.relayPubkeyHex ?? existing?.relayAnchorHex;
|
|
547
|
+
this.#sessions.set(sessionIdHex, {
|
|
548
|
+
node,
|
|
549
|
+
onLeafDeliver: onLeafDeliver ?? (() => { }),
|
|
550
|
+
// Carry the assignment forward across re-registration; never lose a recorded flag on re-register.
|
|
551
|
+
assignment: carriedAssignment,
|
|
552
|
+
...(carriedAnchor ? { relayAnchorHex: carriedAnchor } : {}),
|
|
553
|
+
recorded: existing?.recorded ?? false,
|
|
554
|
+
recordRejected: existing?.recordRejected ?? false,
|
|
555
|
+
recordTimedOut: existing?.recordTimedOut ?? false,
|
|
556
|
+
});
|
|
557
|
+
/**
|
|
558
|
+
* SEED THE ACKNOWLEDGEMENT, never backwards. A session that already received a leaf holds a REAL
|
|
559
|
+
* `{ seq, hash }`; the genesis must not overwrite it. And a RESUMED session starts from what its
|
|
560
|
+
* durable record says it saw, never from 0 — the gap that left interrupted sessions unsealable
|
|
561
|
+
* (see resume-last-seen.ts).
|
|
562
|
+
*/
|
|
563
|
+
const genesis = genesisPrevRoot ?? genesisFromAssignment(sessionIdHex, carriedAssignment);
|
|
564
|
+
if (genesis)
|
|
565
|
+
this.#genesis.set(sessionIdHex, genesis);
|
|
566
|
+
if (!this.#lastSeen.has(sessionIdHex) && genesis)
|
|
567
|
+
this.#lastSeen.set(sessionIdHex, { seq: 0, hash: genesis });
|
|
568
|
+
const resumed = this.#sealLeafStore ? lastSeenFromRecord(this.#sealLeafStore, this.#logger, this.#senderPubkey, sessionIdHex) : undefined;
|
|
569
|
+
if (resumed)
|
|
570
|
+
this.#bumpLastSeen(sessionIdHex, resumed.seq, resumed.hash);
|
|
571
|
+
// Eagerly present the assignment so the relay records the session (binds peer IDs, creates the
|
|
572
|
+
// session entry) BEFORE the first hash_submit or the counterparty's leaves arrive — the relay
|
|
573
|
+
// rejects frames for a session it has not recorded. Best-effort + serialized on the submit chain
|
|
574
|
+
// (the ack carries no session_id, so no overlap).
|
|
575
|
+
if (assignment) {
|
|
576
|
+
this.#submitChain = this.#submitChain
|
|
577
|
+
.then(() => this.#doRecord(node, sessionIdHex))
|
|
578
|
+
.then(() => undefined, () => undefined);
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
/**
|
|
582
|
+
* DOD-M15-RELAYAUTH-1 review H1 — **present the assignment and WAIT for the relay to say it
|
|
583
|
+
* recorded it.**
|
|
584
|
+
*
|
|
585
|
+
* `registerSession` above presents eagerly and forgets: the record is queued onto the submit chain
|
|
586
|
+
* and nobody can observe when it lands. That is correct for the witness relay, where the only
|
|
587
|
+
* requirement is "before the first submit". It is NOT sufficient for the relay that GATES A DIAL,
|
|
588
|
+
* because there the record is a precondition of an action we are about to take on another thread
|
|
589
|
+
* of the protocol — and losing that race denies a legitimate dial (review H1).
|
|
590
|
+
*
|
|
591
|
+
* Chained on `#submitChain` exactly like `#doSubmit`, so it cannot interleave with a submit on the
|
|
592
|
+
* same stream. Idempotent by construction: `#doRecord` returns `true` immediately once the session
|
|
593
|
+
* is recorded, so calling this straight after `registerSession` waits for the record that call
|
|
594
|
+
* already queued rather than sending a second one.
|
|
595
|
+
*
|
|
596
|
+
* Returns whether the relay recorded it. NEVER throws — a caller must be free to proceed on false
|
|
597
|
+
* (a dial that might be denied still beats no dial at all).
|
|
598
|
+
*/
|
|
599
|
+
async recordAssignmentAndWait(node, sessionIdHex) {
|
|
600
|
+
const run = this.#submitChain.then(() => this.#doRecord(node, sessionIdHex));
|
|
601
|
+
this.#submitChain = run.then(() => undefined, () => undefined);
|
|
602
|
+
return run.catch(() => false);
|
|
603
|
+
}
|
|
604
|
+
/**
|
|
605
|
+
* Present the directory-signed assignment to the relay. Idempotent
|
|
606
|
+
* (no-op once `recorded`, or when the session has no assignment — direct/persisted/legacy sessions).
|
|
607
|
+
* The relay reconstructs the TBS and verifies the per-node directory signature against any consortium
|
|
608
|
+
* key. On success the session is recorded; the send/ack is single-in-flight (mirrors #doSubmit).
|
|
609
|
+
*/
|
|
610
|
+
async #doRecord(node, sessionIdHex) {
|
|
611
|
+
if (this.#closed)
|
|
612
|
+
return false;
|
|
613
|
+
const sess = this.#sessions.get(sessionIdHex);
|
|
614
|
+
if (!sess || !sess.assignment)
|
|
615
|
+
return true;
|
|
616
|
+
// Nothing to PRESENT. The carry may exist only to name the relay this session's attestations
|
|
617
|
+
// verify against; there is no frame to send and nothing to wait for, so the submit proceeds.
|
|
618
|
+
if (!sess.assignment.assignmentSignature)
|
|
619
|
+
return true;
|
|
620
|
+
if (sess.recorded)
|
|
621
|
+
return true;
|
|
622
|
+
// Terminal rejection: a relay that cleanly rejected this assignment will reject it
|
|
623
|
+
// again — stop re-presenting so a misconfigured/forged case can't storm the shared stream.
|
|
624
|
+
if (sess.recordRejected)
|
|
625
|
+
return false;
|
|
626
|
+
if (!(await this.#ensureConnected(node)))
|
|
627
|
+
return false;
|
|
628
|
+
const stream = this.#stream;
|
|
629
|
+
if (!stream)
|
|
630
|
+
return false;
|
|
631
|
+
const a = sess.assignment;
|
|
632
|
+
const frame = encodeCbor({
|
|
633
|
+
type: "client_record_assignment",
|
|
634
|
+
session_id: new Uint8Array(Buffer.from(sessionIdHex, "hex")),
|
|
635
|
+
participant_a: a.participantA,
|
|
636
|
+
participant_b: a.participantB,
|
|
637
|
+
session_timestamp: a.sessionTimestamp,
|
|
638
|
+
initiator_session_peer_id: a.initiatorSessionPeerId,
|
|
639
|
+
counterparty_session_peer_id: a.counterpartySessionPeerId,
|
|
640
|
+
assignment_signature: a.assignmentSignature,
|
|
641
|
+
session_signature: a.sessionSignature,
|
|
642
|
+
});
|
|
643
|
+
let resolveRec;
|
|
644
|
+
const ackPromise = new Promise((r) => { resolveRec = r; });
|
|
645
|
+
this.#pendingRecord = resolveRec;
|
|
646
|
+
try {
|
|
647
|
+
stream.send(lp.encode.single(frame));
|
|
648
|
+
}
|
|
649
|
+
catch (err) {
|
|
650
|
+
if (this.#pendingRecord === resolveRec)
|
|
651
|
+
this.#pendingRecord = null;
|
|
652
|
+
this.#logger.warn("session.relay.record.send.failed", { relayPeerId: this.#relayPeerId, error: extractErrorMessage(err) });
|
|
653
|
+
return false;
|
|
654
|
+
}
|
|
655
|
+
let timer;
|
|
656
|
+
const timeout = new Promise((r) => { timer = setTimeout(() => r("timeout"), HASH_SUBMIT_TIMEOUT_MS); });
|
|
657
|
+
try {
|
|
658
|
+
const result = await Promise.race([ackPromise, timeout]);
|
|
659
|
+
if (result === "ok") {
|
|
660
|
+
sess.recorded = true;
|
|
661
|
+
sess.recordTimedOut = false;
|
|
662
|
+
this.#logger.info("session.relay.assignment.recorded", { relayPeerId: this.#relayPeerId, sessionShort: sessionIdHex.slice(0, 16) });
|
|
663
|
+
return true;
|
|
664
|
+
}
|
|
665
|
+
if (result === "timeout") {
|
|
666
|
+
// ONLY here reset the stream: a late ack on this superseded stream would settle a LATER submit's
|
|
667
|
+
// resolver (FIFO desync). recorded stays false ⇒ a transient timeout is retried on reconnect.
|
|
668
|
+
this.#resetStream();
|
|
669
|
+
sess.recordTimedOut = true;
|
|
670
|
+
this.#logger.warn("session.relay.assignment.record.timeout", { relayPeerId: this.#relayPeerId, sessionShort: sessionIdHex.slice(0, 16) });
|
|
671
|
+
return false;
|
|
672
|
+
}
|
|
673
|
+
if (result === "rejected") {
|
|
674
|
+
// The relay cleanly rejected (assignment_invalid) — the ack already arrived, the stream is HEALTHY.
|
|
675
|
+
// Do NOT reset (that would tear down sibling sessions' in-flight submits). Mark terminal so we stop
|
|
676
|
+
// re-presenting. The session has no relay witness; sends still complete via the direct path
|
|
677
|
+
// (sovereign-node redundancy) and a hash_submit will fail loud (session_not_found) — diagnosable.
|
|
678
|
+
sess.recordRejected = true;
|
|
679
|
+
this.#logger.warn("session.relay.assignment.record.rejected", { relayPeerId: this.#relayPeerId, sessionShort: sessionIdHex.slice(0, 16) });
|
|
680
|
+
return false;
|
|
681
|
+
}
|
|
682
|
+
// "closed": the stream dropped while the record was in flight (settled by the reader/close path).
|
|
683
|
+
// Transient — no reset needed (already gone); recorded stays false ⇒ retried on reconnect.
|
|
684
|
+
return false;
|
|
685
|
+
}
|
|
686
|
+
finally {
|
|
687
|
+
clearTimeout(timer);
|
|
688
|
+
if (this.#pendingRecord === resolveRec)
|
|
689
|
+
this.#pendingRecord = null;
|
|
690
|
+
}
|
|
691
|
+
}
|
|
692
|
+
/** Remove a session; caller closes the client when no sessions remain. */
|
|
693
|
+
unregisterSession(sessionIdHex) {
|
|
694
|
+
this.#sessions.delete(sessionIdHex);
|
|
695
|
+
this.#lastSeen.delete(sessionIdHex);
|
|
696
|
+
this.#genesis.delete(sessionIdHex);
|
|
697
|
+
}
|
|
698
|
+
hasSessions() {
|
|
699
|
+
return this.#sessions.size > 0;
|
|
700
|
+
}
|
|
701
|
+
/**
|
|
702
|
+
* Is THIS session already registered on this client?
|
|
703
|
+
*
|
|
704
|
+
* DOD-M15-RELAYLEAK-1 (review MEDIUM-5). The detached seal transport releases its registration
|
|
705
|
+
* when the submit finishes, and "did I register it, or did I find it already there?" is the
|
|
706
|
+
* difference between releasing my own and **pulling a live one out from under a concurrent
|
|
707
|
+
* caller** — which closes the client that caller is mid-`submitLeaf` on. `hasSessions()` cannot
|
|
708
|
+
* answer it: it is a count, and by then the id is in the set either way.
|
|
709
|
+
*/
|
|
710
|
+
hasSession(sessionIdHex) {
|
|
711
|
+
return this.#sessions.has(sessionIdHex);
|
|
712
|
+
}
|
|
713
|
+
/** Settle the one outstanding submit (if any) exactly once. */
|
|
714
|
+
#settlePending(r) {
|
|
715
|
+
const resolve = this.#pendingAck;
|
|
716
|
+
this.#pendingAck = null;
|
|
717
|
+
this.#pendingAckSessionHex = null;
|
|
718
|
+
this.#pendingStructure1 = null;
|
|
719
|
+
this.#pendingSignature = null;
|
|
720
|
+
this.#pendingLeafKind = null;
|
|
721
|
+
if (resolve)
|
|
722
|
+
resolve(r);
|
|
723
|
+
}
|
|
724
|
+
/**
|
|
725
|
+
* Tear down the current stream (drops it so the next submit re-dials). Used on a submit
|
|
726
|
+
* timeout: because `hash_submit_ack` carries no session_id, ack↔submit matching is purely
|
|
727
|
+
* FIFO, so a LATE ack from a timed-out submit would settle the NEXT submit's resolver and
|
|
728
|
+
* shift every subsequent ack by one. Resetting the stream prevents that desync (the relay
|
|
729
|
+
* re-auths + re-drains on the reconnect).
|
|
730
|
+
*/
|
|
731
|
+
#resetStream() {
|
|
732
|
+
const stream = this.#stream;
|
|
733
|
+
this.#stream = null;
|
|
734
|
+
if (stream) {
|
|
735
|
+
try {
|
|
736
|
+
void stream.close();
|
|
737
|
+
}
|
|
738
|
+
catch { /* best-effort */ }
|
|
739
|
+
}
|
|
740
|
+
}
|
|
741
|
+
/**
|
|
742
|
+
* Advance this session's acknowledgement to a counterparty leaf: the POSITION and the CONTENT
|
|
743
|
+
* AT IT, written together (033-ACKEMIT).
|
|
744
|
+
*
|
|
745
|
+
* `contentHash` comes from the same `decodeStructure1` of the same leaf that produced `seq`, so
|
|
746
|
+
* the pair describes one message by construction. There is no path that advances one without the
|
|
747
|
+
* other, and that is deliberate: a `last_seen_seq` and a `last_seen_hash` that could drift apart
|
|
748
|
+
* would let this daemon sign an acknowledgement of a message it never saw.
|
|
749
|
+
*
|
|
750
|
+
* Monotonic on `seq` — a re-delivery of an earlier leaf must not walk the acknowledgement
|
|
751
|
+
* backwards, and it must not swap the hash under an unchanged position either.
|
|
752
|
+
*/
|
|
753
|
+
#bumpLastSeen(sessionIdHex, seq, contentHash) {
|
|
754
|
+
if (seq < 0)
|
|
755
|
+
return;
|
|
756
|
+
const prev = this.#lastSeen.get(sessionIdHex);
|
|
757
|
+
if (prev && seq <= prev.seq)
|
|
758
|
+
return;
|
|
759
|
+
this.#lastSeen.set(sessionIdHex, { seq, hash: contentHash });
|
|
760
|
+
}
|
|
761
|
+
/** True if this Structure-1 leaf was authored by US (sender_pubkey === our K_local). */
|
|
762
|
+
#isOwnLeaf(structure1Cbor) {
|
|
763
|
+
// Structure 1 = [version, content_hash, sender_pubkey, session_id, last_seen_seq, ts], plus
|
|
764
|
+
// last_seen_hash at index 6 on a v2 claim (020-ACKHASH). sender_pubkey is index 2 in both.
|
|
765
|
+
const s1 = decodeStructure1(structure1Cbor);
|
|
766
|
+
/**
|
|
767
|
+
* ⚠️ `false` IS THE DANGEROUS DIRECTION, NOT THE CONSERVATIVE ONE — review F8, correcting a
|
|
768
|
+
* comment that claimed the opposite.
|
|
769
|
+
*
|
|
770
|
+
* It read "conservative: don't suppress a real counterparty leaf." At the call site, `false`
|
|
771
|
+
* means our OWN echoed leaf gets `#bumpLastSeen` applied — which the comment there says must not
|
|
772
|
+
* happen — and is written into the seal-leaf log as a COUNTERPARTY leaf. Returning `false`
|
|
773
|
+
* wrongly corrupts the log; returning `true` wrongly drops one witness signal.
|
|
774
|
+
*
|
|
775
|
+
* It stays `false` because the input set is bytes the relay already decoded and accepted, so an
|
|
776
|
+
* unreadable leaf here means this daemon and the relay disagree about a frame the relay passed —
|
|
777
|
+
* not a hostile peer. Widening it to every layout failure (it was only a CBOR throw before) does
|
|
778
|
+
* not change that: the relay's own decoder gates every path that reaches here.
|
|
779
|
+
*/
|
|
780
|
+
if (!s1.ok)
|
|
781
|
+
return false;
|
|
782
|
+
return Buffer.from(s1.fields.senderPubkey).equals(Buffer.from(this.#senderPubkey));
|
|
783
|
+
}
|
|
784
|
+
/**
|
|
785
|
+
* The relay pubkey the DIRECTORY named for a session — 069-ORDERPROOF, and the only key a relay
|
|
786
|
+
* ordering attestation is ever checked against.
|
|
787
|
+
*
|
|
788
|
+
* It rides the assignment carry, which is built from an assignment whose FROST signature this
|
|
789
|
+
* daemon verified before the session began, so it is anchored to something the relay does not
|
|
790
|
+
* control. `undefined` means no such key was recorded, and every attestation on that session is
|
|
791
|
+
* then refused for want of an anchor — never accepted against whatever key the frame supplies.
|
|
792
|
+
*/
|
|
793
|
+
#expectedRelayPubkeyHex(sessionIdHex) {
|
|
794
|
+
return this.#sessions.get(sessionIdHex)?.relayAnchorHex;
|
|
795
|
+
}
|
|
796
|
+
/**
|
|
797
|
+
* Verify a relay `hash_submit_ack`'s ordering attestation and durably store the receipt.
|
|
798
|
+
*
|
|
799
|
+
* The relay signs the session, the content hash, the position it assigned and the running root of
|
|
800
|
+
* the tree after this leaf. We verify it **against the relay key on the directory-signed
|
|
801
|
+
* assignment**, and refuse otherwise — missing, malformed and mismatched all take the one path.
|
|
802
|
+
*
|
|
803
|
+
* Returns true to REJECT the submit. A send must not settle ok on a position nothing witnessed.
|
|
804
|
+
*/
|
|
805
|
+
#captureReceipt(frame, structure1Cbor, seq) {
|
|
806
|
+
// ⚠️ REJECTS WHEN THE CHECK COULD NOT BE RUN, not only when it failed — `readSubmittedLeaf`
|
|
807
|
+
// carries the reasoning. Both of these used to settle the send ok having verified nothing.
|
|
808
|
+
const leaf = readSubmittedLeaf(structure1Cbor, seq);
|
|
809
|
+
if (leaf.kind === "none")
|
|
810
|
+
return false;
|
|
811
|
+
if (leaf.kind === "unreadable") {
|
|
812
|
+
this.#logger.warn(leaf.event, { seq, structure1Reason: leaf.reason, impact: "this side cannot read the leaf it just submitted, so the relay's ordering attestation has " +
|
|
813
|
+
"nothing to be checked against. The send is refused rather than settled on a position " +
|
|
814
|
+
"nothing witnessed." });
|
|
815
|
+
return true;
|
|
816
|
+
}
|
|
817
|
+
const { contentHash, sessionId } = leaf;
|
|
818
|
+
const sessionIdHex = Buffer.from(sessionId).toString("hex");
|
|
819
|
+
const ev = evaluateRelayAck({
|
|
820
|
+
sessionId,
|
|
821
|
+
contentHash,
|
|
822
|
+
runningRoot: frame["running_root"] instanceof Uint8Array ? frame["running_root"] : undefined,
|
|
823
|
+
sessionIdHex,
|
|
824
|
+
agentPubkeyHex: this.senderPubkeyHex,
|
|
825
|
+
expectedRelayPubkeyHex: this.#expectedRelayPubkeyHex(sessionIdHex),
|
|
826
|
+
relayId: typeof frame["relay_id"] === "string" ? frame["relay_id"] : undefined,
|
|
827
|
+
relaySignature: frame["relay_signature"] instanceof Uint8Array ? frame["relay_signature"] : undefined,
|
|
828
|
+
timestamp: typeof frame["timestamp"] === "number" ? frame["timestamp"] : undefined,
|
|
829
|
+
sequenceNumber: seq,
|
|
830
|
+
});
|
|
831
|
+
switch (ev.kind) {
|
|
832
|
+
case "refused":
|
|
833
|
+
/**
|
|
834
|
+
* ONE OUTCOME, and the cause is in the log rather than in the control flow.
|
|
835
|
+
*
|
|
836
|
+
* The submit is rejected for all five causes. A message whose position no assigned relay
|
|
837
|
+
* attested has no ordering evidence behind it, and letting the send settle ok would report
|
|
838
|
+
* a witnessed message that nothing witnessed. `attestation_absent` takes this path with the
|
|
839
|
+
* rest deliberately: the party who can produce the absence is the relay, which is the party
|
|
840
|
+
* the check exists to constrain, so a softer answer for absence is a mute button with our
|
|
841
|
+
* own name on it.
|
|
842
|
+
*/
|
|
843
|
+
this.#logger.warn("relay.attestation.refused", {
|
|
844
|
+
seq,
|
|
845
|
+
session: sessionIdHex,
|
|
846
|
+
cause: ev.cause,
|
|
847
|
+
hashShort: Buffer.from(contentHash).toString("hex").slice(0, 16),
|
|
848
|
+
impact: "the relay's ordering attestation for this message was refused, so this send did not " +
|
|
849
|
+
"settle and no evidence was stored. This agent holds no proof of where this message " +
|
|
850
|
+
"sits in the conversation, and nothing was recorded that could later be presented as one.",
|
|
851
|
+
});
|
|
852
|
+
return true;
|
|
853
|
+
case "store": {
|
|
854
|
+
if (!this.#receiptStore)
|
|
855
|
+
return false;
|
|
856
|
+
try {
|
|
857
|
+
const wrote = this.#receiptStore.store(ev.receipt, Date.now());
|
|
858
|
+
if (wrote) {
|
|
859
|
+
this.#logger.info("relay.receipt.stored", { seq, hashShort: ev.receipt.hashHex.slice(0, 16), relayShort: ev.receipt.relayId.slice(0, 16) });
|
|
860
|
+
}
|
|
861
|
+
// Record this OWN leaf in the seal-leaf log WITH its relay receipt (the
|
|
862
|
+
// relay's signature pins content_hash→seq — the teeth that stop a supplier reordering its own
|
|
863
|
+
// leaves). structure2_cbor rides the ack we just verified; structure1Cbor + the leaf kind are this
|
|
864
|
+
// submit's paired in-flight values. Best-effort + separate from the receipt write.
|
|
865
|
+
const structure2Cbor = frame["structure2_cbor"] instanceof Uint8Array ? frame["structure2_cbor"] : undefined;
|
|
866
|
+
if (this.#sealLeafStore && structure2Cbor && structure1Cbor && this.#pendingLeafKind !== null) {
|
|
867
|
+
/**
|
|
868
|
+
* ⚠️ **THE AUTHOR COMES FROM THE SIGNED BYTES, AND THE RECEIPT ONLY ATTACHES TO OUR OWN
|
|
869
|
+
* LEAF — 034-CARRYLEAF review F3.**
|
|
870
|
+
*
|
|
871
|
+
* This wrote `senderPubkeyHex: this.senderPubkeyHex` unconditionally, which was true for
|
|
872
|
+
* as long as the only thing a client could submit was its own leaf. `witnessReceivedLeaf`
|
|
873
|
+
* ended that: on a counter-submit `structure1Cbor` holds the COUNTERPARTY's bytes, so
|
|
874
|
+
* this row labelled their leaf as ours — and attached a relay receipt to it, which is the
|
|
875
|
+
* one thing that must never happen to a leaf we did not author, because a receipt is what
|
|
876
|
+
* pins OUR leaves to a sequence we could otherwise renumber.
|
|
877
|
+
*
|
|
878
|
+
* It was inert on the wire by luck: `sender_pubkey_hex` is not transmitted and the
|
|
879
|
+
* directory re-derives the author from `structure2_cbor`. It was never inert locally —
|
|
880
|
+
* the store is `INSERT OR IGNORE` on `(agent, session, sequence)`, and the ack arrives
|
|
881
|
+
* BEFORE the `leaf_deliver` echo, so this row won and the correct one was silently
|
|
882
|
+
* dropped.
|
|
883
|
+
*
|
|
884
|
+
* Same rule as everywhere else on this path: the identity comes from inside the bytes
|
|
885
|
+
* the author signed, never from an ambient value that happens to be right today.
|
|
886
|
+
*/
|
|
887
|
+
const authored = decodeStructure1(structure1Cbor);
|
|
888
|
+
const authorHex = authored.ok
|
|
889
|
+
? Buffer.from(authored.fields.senderPubkey).toString("hex")
|
|
890
|
+
: this.senderPubkeyHex;
|
|
891
|
+
const ourOwnLeaf = authorHex === this.senderPubkeyHex;
|
|
892
|
+
this.#sealLeafStore.store(this.senderPubkeyHex, ev.receipt.sessionIdHex, {
|
|
893
|
+
sequenceNumber: seq,
|
|
894
|
+
leafKind: this.#pendingLeafKind,
|
|
895
|
+
senderPubkeyHex: authorHex,
|
|
896
|
+
structure2Cbor,
|
|
897
|
+
structure1Cbor,
|
|
898
|
+
...(ourOwnLeaf
|
|
899
|
+
? {
|
|
900
|
+
relayId: ev.receipt.relayId,
|
|
901
|
+
relayTimestamp: ev.receipt.timestamp,
|
|
902
|
+
relaySignatureHex: ev.receipt.signatureHex,
|
|
903
|
+
...(ev.receipt.runningRootHex ? { relayRunningRootHex: ev.receipt.runningRootHex } : {}),
|
|
904
|
+
}
|
|
905
|
+
: {}),
|
|
906
|
+
}, Date.now());
|
|
907
|
+
}
|
|
908
|
+
}
|
|
909
|
+
catch (err) {
|
|
910
|
+
// A durable-evidence write failure must be LOUD — the relay will not re-emit this ack, so a
|
|
911
|
+
// swallowed write permanently loses a verified receipt.
|
|
912
|
+
this.#logger.error("relay.receipt.store_failed", { seq, error: extractErrorMessage(err) });
|
|
913
|
+
}
|
|
914
|
+
return false;
|
|
915
|
+
}
|
|
916
|
+
}
|
|
917
|
+
}
|
|
918
|
+
#dispatch(frame) {
|
|
919
|
+
const type = frame["type"];
|
|
920
|
+
if (type === "hash_submit_ack") {
|
|
921
|
+
const seq = typeof frame["sequence_number"] === "number" ? frame["sequence_number"] : -1;
|
|
922
|
+
// DO NOT advance #lastSeen here: the ack is for OUR OWN leaf. last_seen_seq must track
|
|
923
|
+
// the highest COUNTERPARTY sequence we've observed — the directory's causal check rejects a leaf
|
|
924
|
+
// whose last_seen_seq exceeds the max sequence of OTHER-sender leaves before it. Advancing on our
|
|
925
|
+
// own ack would inflate it and trip causal_chain_violated on a subsequent submit (e.g. the SEAL
|
|
926
|
+
// leaf after a sent message).
|
|
927
|
+
// Pair the relay's committed structure2_cbor with our in-flight structure1_cbor so
|
|
928
|
+
// the SubmitResult carries the full signed ordering record for the self-ordering content frame.
|
|
929
|
+
// Captured BEFORE #settlePending clears #pendingStructure1.
|
|
930
|
+
const s2 = frame["structure2_cbor"];
|
|
931
|
+
const structure2Cbor = s2 instanceof Uint8Array ? s2 : undefined;
|
|
932
|
+
const structure1Cbor = this.#pendingStructure1 ?? undefined;
|
|
933
|
+
// Captured with structure1Cbor and BEFORE #settlePending clears both — see #pendingSignature.
|
|
934
|
+
const senderSignature = this.#pendingSignature ?? undefined;
|
|
935
|
+
// Verify the relay's signed ordering record and durably store the receipt BEFORE
|
|
936
|
+
// settling (which clears #pendingStructure1, the source of the content hash + session id). A
|
|
937
|
+
// signed-but-INVALID ACK rejects the submit so the send does not settle ok on an unverified sequence.
|
|
938
|
+
const rejectSubmit = this.#captureReceipt(frame, structure1Cbor, seq);
|
|
939
|
+
this.#settlePending(rejectSubmit
|
|
940
|
+
/**
|
|
941
|
+
* ONE REASON for every refusal cause — 069-ORDERPROOF Done When 3. A distinct reason per
|
|
942
|
+
* cause would be observable, and the clause requires that a missing attestation and a
|
|
943
|
+
* malformed one look the same from outside. The cause is named in the log line above.
|
|
944
|
+
*/
|
|
945
|
+
? { ok: false, reason: "relay_ack_unverified" }
|
|
946
|
+
: seq >= 0
|
|
947
|
+
? { ok: true, sequence_number: seq, structure1_cbor: structure1Cbor, structure2_cbor: structure2Cbor, sender_signature: senderSignature }
|
|
948
|
+
: { ok: false, reason: "relay_ack_malformed" });
|
|
949
|
+
}
|
|
950
|
+
else if (type === "session_liveness_response" || type === "session_liveness_refused") {
|
|
951
|
+
/**
|
|
952
|
+
* DOD-M15-AWAYSCOPE-1 — the relay's answer to "is my counterparty reachable, and is anyone
|
|
953
|
+
* attending it". Decoded through the shared codec rather than read field by field here: that
|
|
954
|
+
* codec is what refuses an attendance paired with a non-alive liveness, and re-reading the
|
|
955
|
+
* fields inline would quietly reintroduce the tolerance branch it exists to prevent.
|
|
956
|
+
*
|
|
957
|
+
* A REFUSAL resolves the waiter rather than dropping it. The relay refuses a query naming a
|
|
958
|
+
* session the caller is not in, and it deliberately does not say which of the two reasons —
|
|
959
|
+
* so the honest local answer is "unknown", not a hang until the timeout.
|
|
960
|
+
*/
|
|
961
|
+
/**
|
|
962
|
+
* ⚠️ A REFUSAL NAMES NOTHING, so it cannot be matched. The relay refuses without echoing the
|
|
963
|
+
* session or the subject — deliberately, because telling "no such session" from "not your
|
|
964
|
+
* session" is the enumeration signal `DOD-M15-RELAYAUTH-1` closed. So there is nothing to key
|
|
965
|
+
* on, and the only sound reading is that every outstanding query on this stream was refused:
|
|
966
|
+
* the refusal is a property of the CALLER, and the caller is the same for all of them.
|
|
967
|
+
*/
|
|
968
|
+
if (type === "session_liveness_refused") {
|
|
969
|
+
for (const r of this.#pendingLiveness.values())
|
|
970
|
+
r({ liveness: "unknown", observedAt: 0, refused: true });
|
|
971
|
+
this.#pendingLiveness.clear();
|
|
972
|
+
}
|
|
973
|
+
else {
|
|
974
|
+
const decoded = decodeSessionLivenessResponse(encodeCbor(frame));
|
|
975
|
+
if (decoded === null) {
|
|
976
|
+
// Malformed fails exactly like missing. A frame this build cannot read is not evidence of
|
|
977
|
+
// anything, and reporting it as 'gone' would invent an observation nobody made. It also
|
|
978
|
+
// cannot be matched to a waiter, so every outstanding query is answered 'unknown' rather
|
|
979
|
+
// than left to time out on a stream that just proved it speaks a shape we cannot read.
|
|
980
|
+
this.#logger.warn("session.relay.liveness.response_malformed", {
|
|
981
|
+
relayPeerId: this.#relayPeerId,
|
|
982
|
+
impact: "the counterparty's reachability is reported as unknown, which is what it is",
|
|
983
|
+
});
|
|
984
|
+
for (const r of this.#pendingLiveness.values())
|
|
985
|
+
r({ liveness: "unknown", observedAt: 0 });
|
|
986
|
+
this.#pendingLiveness.clear();
|
|
987
|
+
}
|
|
988
|
+
else {
|
|
989
|
+
// MATCHED ON WHAT THE ANSWER ITSELF NAMES. Resolving the most recent waiter instead would
|
|
990
|
+
// be right exactly once — when only one query is outstanding — and silently wrong the
|
|
991
|
+
// moment an agent holds two conversations through one relay, which is ordinary.
|
|
992
|
+
const key = livenessKey(decoded.session_id, decoded.counterparty_pubkey);
|
|
993
|
+
const r = this.#pendingLiveness.get(key);
|
|
994
|
+
if (r) {
|
|
995
|
+
this.#pendingLiveness.delete(key);
|
|
996
|
+
r({
|
|
997
|
+
liveness: decoded.liveness,
|
|
998
|
+
observedAt: decoded.observed_at,
|
|
999
|
+
...(decoded.attendance !== undefined ? { attendance: decoded.attendance } : {}),
|
|
1000
|
+
...(decoded.attendance_observed_at !== undefined ? { attendanceObservedAt: decoded.attendance_observed_at } : {}),
|
|
1001
|
+
});
|
|
1002
|
+
}
|
|
1003
|
+
else {
|
|
1004
|
+
// A late answer to a query that already timed out, or one nobody asked for. Dropped —
|
|
1005
|
+
// handing it to a different session's waiter is exactly the defect this key exists for.
|
|
1006
|
+
this.#logger.debug("session.relay.liveness.response_unmatched", { relayPeerId: this.#relayPeerId });
|
|
1007
|
+
}
|
|
1008
|
+
}
|
|
1009
|
+
}
|
|
1010
|
+
}
|
|
1011
|
+
else if (type === "hash_submit_error") {
|
|
1012
|
+
const reason = typeof frame["reason"] === "string" ? frame["reason"] : "relay_rejected";
|
|
1013
|
+
// Carry the relay's `detail` through — see `SubmitResult`. Reading the class and discarding
|
|
1014
|
+
// what happened is how a refusal arrives as a bare code with no cause attached to it.
|
|
1015
|
+
const detail = typeof frame["detail"] === "string" ? frame["detail"] : undefined;
|
|
1016
|
+
// DOD-M15-RELAYABUSE-1: the relay knows when its window clears and says so. Carried, not
|
|
1017
|
+
// dropped — `#doSubmit` waits it out and resubmits, so a throttle never reaches the operator.
|
|
1018
|
+
const rawRetry = frame["retry_after_ms"];
|
|
1019
|
+
const retry_after_ms = typeof rawRetry === "number" && Number.isFinite(rawRetry) && rawRetry > 0 ? rawRetry : undefined;
|
|
1020
|
+
const awaited = frame["awaited_seq"];
|
|
1021
|
+
this.#settlePending({
|
|
1022
|
+
ok: false, reason, ...(detail ? { detail } : {}), ...(retry_after_ms !== undefined ? { retry_after_ms } : {}),
|
|
1023
|
+
...(typeof awaited === "number" && Number.isInteger(awaited) && awaited > 0 ? { awaited_seq: awaited } : {}),
|
|
1024
|
+
});
|
|
1025
|
+
}
|
|
1026
|
+
else if (type === "relay_release_ok") {
|
|
1027
|
+
// 054-SRSPLIT. `released` is the relay's own answer to "did I actually hold one?" — a
|
|
1028
|
+
// release of a slot we never had is a true `false`, not a failure, and the caller says so.
|
|
1029
|
+
const r = this.#pendingRelease;
|
|
1030
|
+
this.#pendingRelease = null;
|
|
1031
|
+
if (r)
|
|
1032
|
+
r(frame["released"] === true);
|
|
1033
|
+
}
|
|
1034
|
+
else if (type === "assignment_ok") {
|
|
1035
|
+
// The relay verified + recorded our client-presented assignment.
|
|
1036
|
+
const r = this.#pendingRecord;
|
|
1037
|
+
this.#pendingRecord = null;
|
|
1038
|
+
if (r)
|
|
1039
|
+
r("ok");
|
|
1040
|
+
}
|
|
1041
|
+
else if (type === "assignment_invalid") {
|
|
1042
|
+
// The relay rejected the assignment (e.g. directory_signature_invalid — not signed by any
|
|
1043
|
+
// consortium directory). Fail LOUD: the session has no relay witness until this is resolved.
|
|
1044
|
+
const reason = typeof frame["reason"] === "string" ? frame["reason"] : "unknown";
|
|
1045
|
+
this.#logger.warn("session.relay.assignment.invalid", { relayPeerId: this.#relayPeerId, reason });
|
|
1046
|
+
/**
|
|
1047
|
+
* DOD-M15-RELAYSLOTS-1 review M2 — **the tuple cap has to reach the operator too.**
|
|
1048
|
+
*
|
|
1049
|
+
* Clause 7 says EVERY refusal reaches them with a cause and an affordance, and this one was
|
|
1050
|
+
* arriving as `assignment_invalid` in a log. It is also the refusal most likely to hit a real
|
|
1051
|
+
* person, for the reason the order itself gives: nobody knows what sessions they have open, so
|
|
1052
|
+
* whoever hits it believes they have none. Routed through the same classifier and onto the
|
|
1053
|
+
* same surface as every other relay refusal.
|
|
1054
|
+
*/
|
|
1055
|
+
const concurrent = typeof frame["concurrent_sessions"] === "number" ? frame["concurrent_sessions"] : undefined;
|
|
1056
|
+
const cap = typeof frame["session_cap"] === "number" ? frame["session_cap"] : undefined;
|
|
1057
|
+
this.#lastAuthRefusal = classifyRelayAuthRefusal(reason, {
|
|
1058
|
+
...(concurrent !== undefined ? { slotsHeld: concurrent } : {}),
|
|
1059
|
+
...(cap !== undefined ? { slotCap: cap } : {}),
|
|
1060
|
+
});
|
|
1061
|
+
const r = this.#pendingRecord;
|
|
1062
|
+
this.#pendingRecord = null;
|
|
1063
|
+
if (r)
|
|
1064
|
+
r("rejected");
|
|
1065
|
+
}
|
|
1066
|
+
else if (type === "leaf_deliver") {
|
|
1067
|
+
const seq = typeof frame["sequence_number"] === "number" ? frame["sequence_number"] : -1;
|
|
1068
|
+
const sidHex = Buffer.from(toU8(frame["session_id"])).toString("hex");
|
|
1069
|
+
const s1 = toU8(frame["structure1_cbor"]);
|
|
1070
|
+
/**
|
|
1071
|
+
* ONE DECODE, TWO CONSUMERS — 033-ACKEMIT. The acknowledgement bump and the seal-leaf capture
|
|
1072
|
+
* below both need the sender's own signed fields, and they must agree about which leaf they
|
|
1073
|
+
* are looking at. Decoding twice would let a future edit change one read and not the other.
|
|
1074
|
+
*/
|
|
1075
|
+
const deliveredS1 = decodeStructure1(s1);
|
|
1076
|
+
// Advance last_seen ONLY for a COUNTERPARTY leaf. The relay also echoes our OWN
|
|
1077
|
+
// leaf back as a leaf_deliver — that must NOT advance it (same reason as the ack above).
|
|
1078
|
+
const authoredByUs = this.#isOwnLeaf(s1);
|
|
1079
|
+
/**
|
|
1080
|
+
* The POSITION and the CONTENT AT IT, from the counterparty's own signed bytes.
|
|
1081
|
+
*
|
|
1082
|
+
* ⚠️ THE HASH COMES FROM INSIDE `structure1_cbor`, NEVER FROM AN ENVELOPE FIELD. The frame
|
|
1083
|
+
* also carries `structure2_cbor`, which the RELAY built — taking the hash from there would key
|
|
1084
|
+
* our acknowledgement on a value the witness supplies, and a tampering relay could then make
|
|
1085
|
+
* us sign an acknowledgement of content the counterparty never sent. Index 1 of Structure 1 is
|
|
1086
|
+
* inside the bytes the counterparty signed, so it is the one copy neither we nor the relay can
|
|
1087
|
+
* move.
|
|
1088
|
+
*
|
|
1089
|
+
* A leaf whose layout this build cannot name advances NOTHING — position included. The
|
|
1090
|
+
* previous code advanced `seq` from the envelope regardless, so an unreadable leaf could move
|
|
1091
|
+
* the acknowledgement forward while leaving the hash behind it; refusing to advance keeps the
|
|
1092
|
+
* pair describing one real message, and the relay's own decoder already gates what reaches
|
|
1093
|
+
* here.
|
|
1094
|
+
*/
|
|
1095
|
+
if (seq >= 0 && !authoredByUs) {
|
|
1096
|
+
if (deliveredS1.ok) {
|
|
1097
|
+
this.#bumpLastSeen(sidHex, seq, deliveredS1.fields.contentHash);
|
|
1098
|
+
}
|
|
1099
|
+
else {
|
|
1100
|
+
this.#logger.warn("relay.leaf_deliver.unreadable", {
|
|
1101
|
+
seq,
|
|
1102
|
+
session: sidHex,
|
|
1103
|
+
structure1Reason: deliveredS1.reason,
|
|
1104
|
+
impact: "this delivered leaf could not be read, so this session's acknowledgement was NOT " +
|
|
1105
|
+
"advanced to it. The next message this agent sends will acknowledge the last leaf it " +
|
|
1106
|
+
"could read, which is honest — it never claims to have seen something it could not.",
|
|
1107
|
+
});
|
|
1108
|
+
}
|
|
1109
|
+
}
|
|
1110
|
+
// Record the COUNTERPARTY's delivered leaf in the seal-leaf log (no relay
|
|
1111
|
+
// receipt — the relay does not ack-sign a delivery to the recipient). It is pinned at seal by the
|
|
1112
|
+
// absent party's sender_signature (unforgeable) + sequence contiguity against our receipt-pinned own
|
|
1113
|
+
// leaves. Our OWN echoed leaf is skipped here (it is recorded WITH its receipt on the ack path).
|
|
1114
|
+
/**
|
|
1115
|
+
* ─── OUR OWN LEAF, WITNESSED BY SOMEBODY ELSE — 034-CARRYLEAF review F2 ──────────────────
|
|
1116
|
+
*
|
|
1117
|
+
* ⚠️ **WITHOUT THIS, MAKING THE COUNTERPARTY ABLE TO WITNESS OUR LEAF COST US THE RECEIPT.**
|
|
1118
|
+
*
|
|
1119
|
+
* When the counterparty counter-submits a leaf WE authored, the relay assigns it a position
|
|
1120
|
+
* and delivers it to us — and `authoredByUs` is true, so the capture below skipped it. We
|
|
1121
|
+
* never submitted it ourselves, so no ack ever arrived and the ack path never wrote a row
|
|
1122
|
+
* either. The result was a permanent hole at that position in our own carry: the unilateral
|
|
1123
|
+
* seal refused it as `seal_carry_noncontiguous`, and the bilateral one refused to co-sign a
|
|
1124
|
+
* root it could not judge. **An honest sender whose relay hiccuped once lost the receipt for
|
|
1125
|
+
* the entire conversation** — for a message sitting in their own transcript.
|
|
1126
|
+
*
|
|
1127
|
+
* So the leaf is stored, **with NO relay receipt**, because we hold none: nobody acked it to
|
|
1128
|
+
* us. That asymmetry is exactly the one the seal design already relies on. A bilateral seal
|
|
1129
|
+
* needs a contiguous chain and gets one. A UNILATERAL seal additionally requires every one of
|
|
1130
|
+
* our OWN leaves to carry a receipt — so a party who never witnesses their own messages still
|
|
1131
|
+
* cannot seal alone on them (`unilateral_own_leaf_unwitnessed`), which is precisely what
|
|
1132
|
+
* `DOD-M15-WITHHOLD-SEAL-1` intends.
|
|
1133
|
+
*
|
|
1134
|
+
* `INSERT OR IGNORE` keeps whichever row lands first, and a real receipt-bearing row for the
|
|
1135
|
+
* same position can only come from our own ack — which cannot exist here, or we would have
|
|
1136
|
+
* submitted it ourselves.
|
|
1137
|
+
*/
|
|
1138
|
+
if (this.#sealLeafStore && seq >= 0 && authoredByUs && deliveredS1.ok) {
|
|
1139
|
+
const s2Own = frame["structure2_cbor"];
|
|
1140
|
+
if (s2Own instanceof Uint8Array && s1.length > 0) {
|
|
1141
|
+
try {
|
|
1142
|
+
const wrote = this.#sealLeafStore.store(this.senderPubkeyHex, sidHex, {
|
|
1143
|
+
sequenceNumber: seq,
|
|
1144
|
+
leafKind: typeof frame["leaf_kind"] === "number" ? frame["leaf_kind"] : LEAF_KIND_MSG,
|
|
1145
|
+
senderPubkeyHex: this.senderPubkeyHex,
|
|
1146
|
+
structure2Cbor: s2Own,
|
|
1147
|
+
structure1Cbor: s1,
|
|
1148
|
+
}, Date.now());
|
|
1149
|
+
if (wrote) {
|
|
1150
|
+
this.#logger.info("relay.seal_leaf.own.witnessed_by_counterparty", {
|
|
1151
|
+
seq,
|
|
1152
|
+
session: sidHex,
|
|
1153
|
+
impact: "a message THIS agent wrote was witnessed by the counterparty rather than by us — " +
|
|
1154
|
+
"our own submit did not land. The leaf is kept so this conversation can still be " +
|
|
1155
|
+
"sealed together; sealing it alone would still need a receipt we do not hold.",
|
|
1156
|
+
});
|
|
1157
|
+
}
|
|
1158
|
+
}
|
|
1159
|
+
catch (err) {
|
|
1160
|
+
this.#logger.error("relay.seal_leaf.own.store_failed", { seq, session: sidHex, error: extractErrorMessage(err) });
|
|
1161
|
+
}
|
|
1162
|
+
}
|
|
1163
|
+
}
|
|
1164
|
+
if (this.#sealLeafStore && seq >= 0 && !authoredByUs) {
|
|
1165
|
+
const s2 = frame["structure2_cbor"];
|
|
1166
|
+
const structure2Cbor = s2 instanceof Uint8Array ? s2 : undefined;
|
|
1167
|
+
// Review F1: a raw positional read of index 2 with NO version and NO length check lived here
|
|
1168
|
+
// — it accepted a v3 array, a 40-element array, anything with 32 bytes at index 2, and fed
|
|
1169
|
+
// the result into the seal-leaf log as the counterparty's identity. It is in the same file as
|
|
1170
|
+
// #isOwnLeaf and #captureReceipt and was simply missed by the order's reader list, which is
|
|
1171
|
+
// exactly the "next layout change has to find them again" problem the shared decoder exists
|
|
1172
|
+
// to end. Behaviour for every relay-accepted leaf is unchanged; the fail-open closes.
|
|
1173
|
+
const senderHex = deliveredS1.ok
|
|
1174
|
+
? Buffer.from(deliveredS1.fields.senderPubkey).toString("hex")
|
|
1175
|
+
: undefined;
|
|
1176
|
+
if (structure2Cbor && s1.length > 0 && senderHex) {
|
|
1177
|
+
try {
|
|
1178
|
+
this.#sealLeafStore.store(this.senderPubkeyHex, sidHex, {
|
|
1179
|
+
sequenceNumber: seq,
|
|
1180
|
+
leafKind: typeof frame["leaf_kind"] === "number" ? frame["leaf_kind"] : LEAF_KIND_MSG,
|
|
1181
|
+
senderPubkeyHex: senderHex,
|
|
1182
|
+
structure2Cbor,
|
|
1183
|
+
structure1Cbor: s1,
|
|
1184
|
+
}, Date.now());
|
|
1185
|
+
}
|
|
1186
|
+
catch (err) {
|
|
1187
|
+
this.#logger.error("relay.seal_leaf.counterparty.store_failed", { seq, session: sidHex, error: extractErrorMessage(err) });
|
|
1188
|
+
}
|
|
1189
|
+
}
|
|
1190
|
+
else {
|
|
1191
|
+
this.#logger.warn("relay.seal_leaf.counterparty.capture_skipped", { seq, session: sidHex, hasS2: !!structure2Cbor, hasS1: s1.length > 0, hasSender: !!senderHex });
|
|
1192
|
+
}
|
|
1193
|
+
}
|
|
1194
|
+
/**
|
|
1195
|
+
* ─── THE RECIPIENT KEEPS THE ORDERING PROOF TOO — 069-ORDERPROOF unit 2 ──────────────────
|
|
1196
|
+
*
|
|
1197
|
+
* Before this, the relay's attestation went to the SENDER alone, on the submit
|
|
1198
|
+
* acknowledgement. The recipient held no proof of where a single message of the conversation
|
|
1199
|
+
* sat. That made one party's copy the only copy, which is the dependency this order exists to
|
|
1200
|
+
* remove wearing a different coat.
|
|
1201
|
+
*
|
|
1202
|
+
* It is verified here exactly as on the send path — against the relay key the DIRECTORY named
|
|
1203
|
+
* for this session, never `relay_id` on the frame — and an attestation that does not verify is
|
|
1204
|
+
* refused and NOT stored. Nothing else changes: the leaf is still delivered to the session, the
|
|
1205
|
+
* acknowledgement still advances, and no tree, root or leaf count is touched. This is evidence
|
|
1206
|
+
* recorded beside the chain, never in it.
|
|
1207
|
+
*
|
|
1208
|
+
* Our own echoed leaf comes through here too. Storing it is a no-op — the ack path already
|
|
1209
|
+
* wrote the same row at the same position, and the store is INSERT OR IGNORE — but it is not
|
|
1210
|
+
* skipped, because the echo is the only copy a leaf COUNTER-SUBMITTED by the counterparty
|
|
1211
|
+
* ever produces for us.
|
|
1212
|
+
*/
|
|
1213
|
+
if (this.#receiptStore && seq >= 0 && deliveredS1.ok) {
|
|
1214
|
+
const ev = evaluateRelayAck({
|
|
1215
|
+
sessionId: deliveredS1.fields.sessionId,
|
|
1216
|
+
contentHash: deliveredS1.fields.contentHash,
|
|
1217
|
+
runningRoot: frame["running_root"] instanceof Uint8Array ? frame["running_root"] : undefined,
|
|
1218
|
+
sessionIdHex: sidHex,
|
|
1219
|
+
agentPubkeyHex: this.senderPubkeyHex,
|
|
1220
|
+
expectedRelayPubkeyHex: this.#expectedRelayPubkeyHex(sidHex),
|
|
1221
|
+
relayId: typeof frame["relay_id"] === "string" ? frame["relay_id"] : undefined,
|
|
1222
|
+
relaySignature: frame["relay_signature"] instanceof Uint8Array ? frame["relay_signature"] : undefined,
|
|
1223
|
+
timestamp: typeof frame["timestamp"] === "number" ? frame["timestamp"] : undefined,
|
|
1224
|
+
sequenceNumber: seq,
|
|
1225
|
+
});
|
|
1226
|
+
if (ev.kind === "refused") {
|
|
1227
|
+
/**
|
|
1228
|
+
* LOUD, AND THE DELIVERY STILL LANDS. This is the one place the two halves differ, and
|
|
1229
|
+
* deliberately: on the send path a refusal means OUR message has no witness, so the send
|
|
1230
|
+
* must not settle. Here the counterparty's message has already been written and read —
|
|
1231
|
+
* dropping it would let a relay silence a conversation by withholding its own signature,
|
|
1232
|
+
* which hands the party the check constrains a far better weapon than the one it takes
|
|
1233
|
+
* away. So the message arrives and the evidence is recorded as absent.
|
|
1234
|
+
*/
|
|
1235
|
+
this.#logger.warn("relay.attestation.delivered.refused", {
|
|
1236
|
+
seq,
|
|
1237
|
+
session: sidHex,
|
|
1238
|
+
cause: ev.cause,
|
|
1239
|
+
impact: "this message arrived, but the relay's proof of WHERE it sits in the conversation did " +
|
|
1240
|
+
"not, so this agent cannot later show a third party that this message was ordered here. " +
|
|
1241
|
+
"The message itself is unaffected and is in the transcript.",
|
|
1242
|
+
});
|
|
1243
|
+
}
|
|
1244
|
+
else {
|
|
1245
|
+
try {
|
|
1246
|
+
this.#receiptStore.store(ev.receipt, Date.now());
|
|
1247
|
+
}
|
|
1248
|
+
catch (err) {
|
|
1249
|
+
// A durable-evidence write failure is LOUD: the relay never re-sends a delivery, so a
|
|
1250
|
+
// swallowed write permanently loses this side's only copy of the ordering proof.
|
|
1251
|
+
this.#logger.error("relay.attestation.delivered.store_failed", {
|
|
1252
|
+
seq, session: sidHex, error: extractErrorMessage(err),
|
|
1253
|
+
});
|
|
1254
|
+
}
|
|
1255
|
+
}
|
|
1256
|
+
}
|
|
1257
|
+
const session = this.#sessions.get(sidHex);
|
|
1258
|
+
if (session && seq >= 0) {
|
|
1259
|
+
session.onLeafDeliver({
|
|
1260
|
+
sequence_number: seq,
|
|
1261
|
+
leaf_kind: typeof frame["leaf_kind"] === "number" ? frame["leaf_kind"] : LEAF_KIND_MSG,
|
|
1262
|
+
structure1_cbor: s1,
|
|
1263
|
+
structure2_cbor: toU8(frame["structure2_cbor"]),
|
|
1264
|
+
// Tell the consumer whether this is our own echoed leaf (so the
|
|
1265
|
+
// auto-acknowledge gate never co-signs in response to its own SEAL ctrl leaf).
|
|
1266
|
+
authored_by_us: authoredByUs,
|
|
1267
|
+
});
|
|
1268
|
+
}
|
|
1269
|
+
}
|
|
1270
|
+
else if (type === "session_witness_alert") {
|
|
1271
|
+
/**
|
|
1272
|
+
* DOD-M15-CORROBORATE-1 — **the relay is telling us what it saw, and this is where it lands.**
|
|
1273
|
+
*
|
|
1274
|
+
* A relay is not trusted to send a well-formed frame any more than a peer is, so every field
|
|
1275
|
+
* is checked before anything is reported. A frame that does not decode is a MISBEHAVING OR
|
|
1276
|
+
* SKEWED RELAY, not an alert: reporting it as one would let a broken build manufacture
|
|
1277
|
+
* accusations against a counterparty who did nothing. It is logged loudly and goes no further.
|
|
1278
|
+
*/
|
|
1279
|
+
const sid = frame["session_id"];
|
|
1280
|
+
const rawRelayId = frame["relay_id"];
|
|
1281
|
+
const observedAt = frame["observed_at"];
|
|
1282
|
+
const submitterIsCounterparty = frame["submitter_is_counterparty"];
|
|
1283
|
+
const sidBytes = sid instanceof Uint8Array || Buffer.isBuffer(sid) ? toU8(sid) : new Uint8Array();
|
|
1284
|
+
const wellFormed = sidBytes.length === 16
|
|
1285
|
+
&& (frame["reason"] === "leaf_signed_by_neither_participant"
|
|
1286
|
+
|| frame["reason"] === "leaf_witnessed_by_counterparty")
|
|
1287
|
+
&& typeof observedAt === "number" && Number.isFinite(observedAt)
|
|
1288
|
+
&& typeof submitterIsCounterparty === "boolean"
|
|
1289
|
+
&& (rawRelayId === undefined || typeof rawRelayId === "string");
|
|
1290
|
+
const unreadable = (why) => {
|
|
1291
|
+
this.#logger.error("session.relay.witness.malformed", {
|
|
1292
|
+
relayPeerId: this.#relayPeerId,
|
|
1293
|
+
why,
|
|
1294
|
+
impact: "this relay sent a witness alert this build cannot read, so NOTHING has been " +
|
|
1295
|
+
"reported to the operator about it. Treat it as a relay fault or a version skew, not " +
|
|
1296
|
+
"as evidence about either participant.",
|
|
1297
|
+
});
|
|
1298
|
+
/**
|
|
1299
|
+
* Review F7 — **and it reaches a surface, not only this file.** If a version skew makes
|
|
1300
|
+
* every relay's alert unreadable, the witness layer is silently dead and the operator would
|
|
1301
|
+
* have no way to find that out. Deliberately carries NO session and NO party: it says a
|
|
1302
|
+
* relay sent something we could not read, and claims nothing about anyone.
|
|
1303
|
+
*/
|
|
1304
|
+
this.#onWitnessUnreadable?.(this.#relayPeerId, why);
|
|
1305
|
+
};
|
|
1306
|
+
if (!wellFormed) {
|
|
1307
|
+
unreadable("field_shape");
|
|
1308
|
+
return;
|
|
1309
|
+
}
|
|
1310
|
+
const sessionIdHex = Buffer.from(sidBytes).toString("hex");
|
|
1311
|
+
/**
|
|
1312
|
+
* ⚠️ **IT MUST BE A SESSION THIS CLIENT ACTUALLY HOLDS ON THIS RELAY** — review F2.
|
|
1313
|
+
*
|
|
1314
|
+
* `wellFormed` checks shape and shape only. Without this, any relay we are authenticated to
|
|
1315
|
+
* could push alerts naming arbitrary session ids — including conversations carried by a
|
|
1316
|
+
* DIFFERENT relay — and they would land in the operator's inbox as statements of fact about
|
|
1317
|
+
* a counterparty. Combined with a bounded notice list that is the cheap mute: flood
|
|
1318
|
+
* fabrications until the real one is gone.
|
|
1319
|
+
*/
|
|
1320
|
+
if (!this.#sessions.has(sessionIdHex)) {
|
|
1321
|
+
/**
|
|
1322
|
+
* ⚠️ **REFUSED AS AN ALERT, BUT NOT BINNED IN SILENCE** — fallback-finder HIGH 1.
|
|
1323
|
+
*
|
|
1324
|
+
* It cannot be reported as an observation about a conversation: a relay naming a session we
|
|
1325
|
+
* do not hold is exactly the fabrication the check above exists to stop, and rendering it
|
|
1326
|
+
* would put a claim about a counterparty in front of an operator on a stranger's say-so.
|
|
1327
|
+
*
|
|
1328
|
+
* But dropping it entirely was worse than it looked, because the relay's own copy is GONE by
|
|
1329
|
+
* then: its queue is keyed by PUBKEY and `drainWitnessAlerts` splices, so it hands over
|
|
1330
|
+
* everything it held for this agent the moment any client authenticates. The detached seal
|
|
1331
|
+
* client registers exactly ONE session and then authenticates — so a restart-then-seal on
|
|
1332
|
+
* one conversation destroyed a held alert about a different one, at both ends, and the
|
|
1333
|
+
* operator's inbox looked clean.
|
|
1334
|
+
*
|
|
1335
|
+
* So it goes to the same neutral surface a version skew does: something arrived that this
|
|
1336
|
+
* daemon could not place. No session, no party, no claim.
|
|
1337
|
+
*/
|
|
1338
|
+
this.#logger.error("session.relay.witness.unknown_session", {
|
|
1339
|
+
relayPeerId: this.#relayPeerId,
|
|
1340
|
+
session: sessionIdHex,
|
|
1341
|
+
impact: "not rendered as an observation — this relay named a session this client is not " +
|
|
1342
|
+
"holding. Reported to the operator as an unplaceable witness report, never as a claim " +
|
|
1343
|
+
"about a counterparty.",
|
|
1344
|
+
});
|
|
1345
|
+
this.#onWitnessUnreadable?.(this.#relayPeerId, "session_not_held_here");
|
|
1346
|
+
return;
|
|
1347
|
+
}
|
|
1348
|
+
/**
|
|
1349
|
+
* A DECLARED IDENTITY MUST BE PROVEN — review F3. `relay_id` is the hex of the key that signs
|
|
1350
|
+
* every `hash_submit_ack`, so the same check that verifies a receipt verifies this. Missing,
|
|
1351
|
+
* malformed and mismatched take ONE path: omitting the proof is the cheapest way to dodge it.
|
|
1352
|
+
*/
|
|
1353
|
+
const alertReason = frame["reason"];
|
|
1354
|
+
const rawSig = frame["witness_signature"];
|
|
1355
|
+
const sigBytes = rawSig instanceof Uint8Array || Buffer.isBuffer(rawSig) ? toU8(rawSig) : null;
|
|
1356
|
+
let verifiable = false;
|
|
1357
|
+
if (typeof rawRelayId === "string") {
|
|
1358
|
+
if (!/^[0-9a-fA-F]{64}$/.test(rawRelayId)) {
|
|
1359
|
+
unreadable("relay_id_not_a_pubkey");
|
|
1360
|
+
return;
|
|
1361
|
+
}
|
|
1362
|
+
if (!sigBytes || sigBytes.length !== 64) {
|
|
1363
|
+
unreadable("declared_relay_id_without_signature");
|
|
1364
|
+
return;
|
|
1365
|
+
}
|
|
1366
|
+
// THE REASON THE RELAY SENT, not a literal: the reason is inside the signed bytes, so
|
|
1367
|
+
// verifying a different one than arrived would refuse every alert of the new kind.
|
|
1368
|
+
const tbs = buildWitnessAlertTbs(sidBytes, alertReason, observedAt, submitterIsCounterparty);
|
|
1369
|
+
if (!verify(new Uint8Array(Buffer.from(rawRelayId, "hex")), tbs, sigBytes)) {
|
|
1370
|
+
unreadable("witness_signature_invalid");
|
|
1371
|
+
return;
|
|
1372
|
+
}
|
|
1373
|
+
verifiable = true;
|
|
1374
|
+
}
|
|
1375
|
+
const alert = {
|
|
1376
|
+
sessionIdHex,
|
|
1377
|
+
reason: alertReason,
|
|
1378
|
+
relayId: typeof rawRelayId === "string" ? rawRelayId : null,
|
|
1379
|
+
observedAt,
|
|
1380
|
+
submitterIsCounterparty,
|
|
1381
|
+
witnessPeerId: this.#relayPeerId,
|
|
1382
|
+
verifiable,
|
|
1383
|
+
};
|
|
1384
|
+
// BOTH halves, per Invariant 2: the log is the durable forensic record, and the callback is
|
|
1385
|
+
// the half that actually reaches a person.
|
|
1386
|
+
this.#logger.error("session.relay.witness.alert", {
|
|
1387
|
+
relayPeerId: this.#relayPeerId,
|
|
1388
|
+
session: alert.sessionIdHex,
|
|
1389
|
+
relayId: alert.relayId ?? "(unnamed)",
|
|
1390
|
+
submitterIsCounterparty: alert.submitterIsCounterparty,
|
|
1391
|
+
verifiable: alert.verifiable,
|
|
1392
|
+
observation: "one relay refused a leaf on this session because it verified against neither participant key",
|
|
1393
|
+
impact: "nothing was added to the conversation record. This is ONE relay's observation and " +
|
|
1394
|
+
"establishes only that it saw and refused that submission — not who sent it.",
|
|
1395
|
+
});
|
|
1396
|
+
if (this.#onWitnessAlert)
|
|
1397
|
+
this.#onWitnessAlert(alert);
|
|
1398
|
+
}
|
|
1399
|
+
else if (type === "relay_slot_reclaimed") {
|
|
1400
|
+
/**
|
|
1401
|
+
* DOD-M15-RELAYSLOTS-1 clause 8 — **the reaped party is told, and this is where it lands.**
|
|
1402
|
+
*
|
|
1403
|
+
* The relay reclaimed this agent's circuit reservation to free capacity. Without a branch
|
|
1404
|
+
* here the frame fell off the end of this chain and was discarded in silence, which is the
|
|
1405
|
+
* trap the order records in its own words: a refusal that only reaches the relay's log does
|
|
1406
|
+
* not exist. From the agent's side the reservation simply stops working.
|
|
1407
|
+
*
|
|
1408
|
+
* Recorded as a refusal so it reaches `cello_status` through the same surface as every other
|
|
1409
|
+
* relay refusal, with the same shape: a cause, and what to do about it.
|
|
1410
|
+
*/
|
|
1411
|
+
const idleMs = typeof frame["idle_ms"] === "number" ? frame["idle_ms"] : undefined;
|
|
1412
|
+
const detail = typeof frame["detail"] === "string" ? frame["detail"] : undefined;
|
|
1413
|
+
this.#lastAuthRefusal = {
|
|
1414
|
+
reason: "slot_reclaimed",
|
|
1415
|
+
advice: detail ?? "This relay reclaimed your circuit reservation to free capacity because it " +
|
|
1416
|
+
"had carried no traffic for a long time. Your agent stays online and rebuilds its receiver " +
|
|
1417
|
+
"automatically; a new session will take a fresh reservation.",
|
|
1418
|
+
// Not the relay's fault and not ours — it was under pressure and we were the quietest. The
|
|
1419
|
+
// client rebuilds against the same pool, so there is nothing to fail over from.
|
|
1420
|
+
tryAnotherRelay: false,
|
|
1421
|
+
};
|
|
1422
|
+
this.#logger.warn("session.relay.slot_reclaimed", {
|
|
1423
|
+
relayPeerId: this.#relayPeerId,
|
|
1424
|
+
...(idleMs !== undefined ? { idleHours: Math.round(idleMs / 3_600_000) } : {}),
|
|
1425
|
+
impact: "this relay reclaimed our circuit reservation to free capacity. Until a receiver is " +
|
|
1426
|
+
"rebuilt, this agent is reachable only over a direct connection.",
|
|
1427
|
+
});
|
|
1428
|
+
}
|
|
1429
|
+
// session_interrupted / content_park_notify are out of scope here — session interruption
|
|
1430
|
+
// is handled by the session node manager's dedicated relay-stream watcher.
|
|
1431
|
+
}
|
|
1432
|
+
/**
|
|
1433
|
+
* Proactively establish the authenticated stream from `node`. The RECEIVER must connect
|
|
1434
|
+
* before the counterparty submits, so the relay has its stream to deliver `leaf_deliver`
|
|
1435
|
+
* to (otherwise the relay queues until the recipient connects). Best-effort.
|
|
1436
|
+
*/
|
|
1437
|
+
async connect(node) {
|
|
1438
|
+
return this.#ensureConnected(node);
|
|
1439
|
+
}
|
|
1440
|
+
/**
|
|
1441
|
+
* DOD-M15-RELAYAUTH-1 review HIGH-1 — prove key possession FROM THIS NODE, on its own stream.
|
|
1442
|
+
*
|
|
1443
|
+
* ⚠️ **`connect()` CANNOT be used for this, and using it was the defect.** `#ensureConnected`
|
|
1444
|
+
* returns `true` the moment `#stream` is non-null, and `#stream` belongs to whichever node
|
|
1445
|
+
* connected FIRST. An agent legitimately runs several nodes against one relay — the node promoted
|
|
1446
|
+
* into a live session, plus the replacement standing receiver built behind it — and they share
|
|
1447
|
+
* one `AgentRelayClient` because the cache is keyed `${agent}::${relay}`. So calling `connect()`
|
|
1448
|
+
* from the replacement receiver short-circuited on the session node's stream, sent nothing, and
|
|
1449
|
+
* the relay never saw that receiver's transport identity: it revoked the reservation ~15s later,
|
|
1450
|
+
* the watchdog rebuilt, and the agent churned on a ~45s loop holding no usable circuit address
|
|
1451
|
+
* for as long as the conversation lasted. Any future "reuse the existing connection" optimisation
|
|
1452
|
+
* here reintroduces exactly that.
|
|
1453
|
+
*
|
|
1454
|
+
* So this always opens its own short-lived stream from `node`, and marks it
|
|
1455
|
+
* `purpose: "reservation"` so the relay proves possession WITHOUT rebinding the agent's delivery
|
|
1456
|
+
* stream (which would steal the live session's inbound leaves — see the relay-side dispatch).
|
|
1457
|
+
*/
|
|
1458
|
+
/**
|
|
1459
|
+
* ⚠️ **THE FIRST ATTEMPT AFTER A COLD START IS EXPECTED TO FAIL, AND THAT IS NOT A FAULT ON
|
|
1460
|
+
* EITHER SIDE.** Root-caused 2026-09-07 from libp2p's own debug log:
|
|
1461
|
+
*
|
|
1462
|
+
* ```
|
|
1463
|
+
* circuit-relay listener: making reservation on relay
|
|
1464
|
+
* reservation failed with status PERMISSION_DENIED <- the relay's gate, working as designed
|
|
1465
|
+
* could not add discovered relay
|
|
1466
|
+
* connection-manager closing 1 connections / stopped / started
|
|
1467
|
+
* ```
|
|
1468
|
+
*
|
|
1469
|
+
* libp2p's circuit-relay listener asks the relay for a reservation ON ITS OWN, before any CELLO
|
|
1470
|
+
* code has proven anything. The relay's gate refuses an unproven peer — deliberately; its flow is
|
|
1471
|
+
* *"asks, is refused, proves itself, and asks again"*. libp2p reacts to that refusal by discarding
|
|
1472
|
+
* the relay and RESTARTING ITS CONNECTION MANAGER, which closes every connection — including the
|
|
1473
|
+
* healthy one this proof is being opened on. Whichever lands first decides the outcome, so the
|
|
1474
|
+
* failure is intermittent (measured 5/5, 2/4, 0/4, 1/6 across cold logins).
|
|
1475
|
+
*
|
|
1476
|
+
* **Both components are behaving as specified, so the refusal cannot be designed away from here** —
|
|
1477
|
+
* removing it means either weakening the gate or reaching into libp2p's internal
|
|
1478
|
+
* `transportManager.listen` to defer the circuit listener until after the proof. What the client
|
|
1479
|
+
* CAN do is survive it: a transport-class failure is retried on a fresh connection, once.
|
|
1480
|
+
*
|
|
1481
|
+
* A REFUSAL IS NOT RETRIED. If the relay reached a verdict — no token, slot cap, misconfigured —
|
|
1482
|
+
* the answer will be identical a second later, and retrying would spend the operator's reachability
|
|
1483
|
+
* on a question already answered. Only a failure that never reached a verdict is retried.
|
|
1484
|
+
*/
|
|
1485
|
+
async proveReservation(node) {
|
|
1486
|
+
if (this.#closed)
|
|
1487
|
+
return false;
|
|
1488
|
+
const first = await this.#proveReservationOnce(node);
|
|
1489
|
+
if (first !== "transport_failed")
|
|
1490
|
+
return first;
|
|
1491
|
+
/**
|
|
1492
|
+
* The connection manager has just restarted, so the previous connection is gone and a fresh
|
|
1493
|
+
* dial is required — which `#proveReservationOnce` does at its head. The brief wait is for
|
|
1494
|
+
* libp2p to finish restarting; without it the redial races the restart it is recovering from.
|
|
1495
|
+
*/
|
|
1496
|
+
await new Promise((r) => setTimeout(r, PROOF_RETRY_DELAY_MS));
|
|
1497
|
+
if (this.#closed)
|
|
1498
|
+
return false;
|
|
1499
|
+
this.#logger.info("session.relay.reservation_proof.retry", {
|
|
1500
|
+
relayPeerId: this.#relayPeerId,
|
|
1501
|
+
afterMs: PROOF_RETRY_DELAY_MS,
|
|
1502
|
+
impact: "the first proof never reached a verdict — the connection was destroyed under it, which is " +
|
|
1503
|
+
"what libp2p does to a relay that just refused its automatic reservation. Retrying once on " +
|
|
1504
|
+
"a fresh connection. If this succeeds the agent is reachable and nothing was wrong.",
|
|
1505
|
+
});
|
|
1506
|
+
const second = await this.#proveReservationOnce(node);
|
|
1507
|
+
return second === true;
|
|
1508
|
+
}
|
|
1509
|
+
/**
|
|
1510
|
+
* One attempt. `true`/`false` are VERDICTS (the relay answered); `"transport_failed"` means no
|
|
1511
|
+
* verdict was reached and the question is still open — the distinction the retry above turns on.
|
|
1512
|
+
*/
|
|
1513
|
+
async #proveReservationOnce(node) {
|
|
1514
|
+
if (this.#closed)
|
|
1515
|
+
return false;
|
|
1516
|
+
/**
|
|
1517
|
+
* ⚠️ **CLEAR THE REFUSAL FIRST — review HIGH-2.** `#lastAuthRefusal` is set when a relay REFUSES
|
|
1518
|
+
* a proof on the merits, and cleared only on auth SUCCESS. Both early returns below are
|
|
1519
|
+
* transport failures that never reach a verdict, so without this a stale refusal from an
|
|
1520
|
+
* earlier attempt survives them — and `session-relay.ts` reads `getLastAuthRefusal()` off a
|
|
1521
|
+
* CACHED client, writes it into `srRelayRefusal`, and quarantines the relay and rebuilds the
|
|
1522
|
+
* receiver when it says `tryAnotherRelay`. One real refusal followed by a later transport blip
|
|
1523
|
+
* would therefore explain the agent's unreachability with a cause that is no longer true, while
|
|
1524
|
+
* churning receivers over it. A transport failure must leave no verdict behind.
|
|
1525
|
+
*/
|
|
1526
|
+
this.#clearAuthRefusal();
|
|
1527
|
+
/**
|
|
1528
|
+
* A FAILED DIAL MUST NOT BE REPORTED AS A FAILED STREAM. This loop used to swallow every dial
|
|
1529
|
+
* error and fall through to `newStream` regardless. `#connect()` below has always tracked this
|
|
1530
|
+
* and logs `session.relay.dial.failed`; the two were written from one shape and this one lost
|
|
1531
|
+
* the check.
|
|
1532
|
+
*
|
|
1533
|
+
* ⚠️ **This branch did NOT cause the cold-login failure** (measured 2026-09-07: the dial
|
|
1534
|
+
* succeeds, because libp2p's `findExistingConnection` returns an already-open connection rather
|
|
1535
|
+
* than dialling). It is a real gap on the malformed-address and limited-connection paths, and
|
|
1536
|
+
* nothing more. Do not read its presence as the reachability fix.
|
|
1537
|
+
*/
|
|
1538
|
+
let dialed = false;
|
|
1539
|
+
let lastDialError = "";
|
|
1540
|
+
for (const addr of this.#relayAddrs) {
|
|
1541
|
+
try {
|
|
1542
|
+
await node.dial(addr);
|
|
1543
|
+
dialed = true;
|
|
1544
|
+
break;
|
|
1545
|
+
}
|
|
1546
|
+
catch (err) {
|
|
1547
|
+
lastDialError = extractErrorMessage(err);
|
|
1548
|
+
}
|
|
1549
|
+
}
|
|
1550
|
+
if (!dialed && this.#relayAddrs.length > 0) {
|
|
1551
|
+
this.#logger.warn("session.relay.reservation_proof.failed", {
|
|
1552
|
+
relayPeerId: this.#relayPeerId,
|
|
1553
|
+
relayAddrs: this.#relayAddrs,
|
|
1554
|
+
reason: "dial",
|
|
1555
|
+
error: lastDialError,
|
|
1556
|
+
impact: "could not open a connection to this relay, so no proof was sent and the relay saw " +
|
|
1557
|
+
"nothing. The agent holds no reservation on it and is unreachable through it until a " +
|
|
1558
|
+
"later attempt succeeds. This is a REACHABILITY fault, not a rejection — the relay did " +
|
|
1559
|
+
"not refuse anything.",
|
|
1560
|
+
});
|
|
1561
|
+
return "transport_failed";
|
|
1562
|
+
}
|
|
1563
|
+
// TEMPORARY INSTRUMENT (2026-09-07): snapshot immediately BEFORE newStream. Paired with the
|
|
1564
|
+
// snapshot in the catch, this localises the destruction to the newStream call itself rather
|
|
1565
|
+
// than "sometime around the failure".
|
|
1566
|
+
const snap = () => node.getConnections().map((c) => ({
|
|
1567
|
+
toThisRelay: c.peerId === this.#relayPeerId,
|
|
1568
|
+
peer: c.peerId.slice(0, 16),
|
|
1569
|
+
status: c.status,
|
|
1570
|
+
muxer: c.muxerStatus,
|
|
1571
|
+
streams: c.streamCount,
|
|
1572
|
+
protocols: c.streamProtocols,
|
|
1573
|
+
}));
|
|
1574
|
+
const before = snap();
|
|
1575
|
+
let stream;
|
|
1576
|
+
try {
|
|
1577
|
+
stream = await node.newStream(this.#relayPeerId, RELAY_PROTOCOL_ID);
|
|
1578
|
+
}
|
|
1579
|
+
catch (err) {
|
|
1580
|
+
/**
|
|
1581
|
+
* ⚠️ **DO NOT COLLAPSE THIS TO `reason: "stream"`.** `CelloNode.newStream` throws STRUCTURED
|
|
1582
|
+
* errors — plain objects, not `Error`s — whose `reason` is the only field that says what
|
|
1583
|
+
* happened. This site used to overwrite it with the literal `"stream"`, so every distinct
|
|
1584
|
+
* cause reached the operator as one line. Measured 2026-09-07: a whole clean-room
|
|
1585
|
+
* investigation went to the relay servers because the cause was discarded at capture.
|
|
1586
|
+
*
|
|
1587
|
+
* The set is whatever `mapStreamError` returns — deliberately NOT enumerated here, because
|
|
1588
|
+
* the first version of this comment listed four of the six and the two it missed
|
|
1589
|
+
* (`protocol_not_supported`, `limited_connection_refused`) are exactly the ones a redial
|
|
1590
|
+
* cannot fix.
|
|
1591
|
+
*
|
|
1592
|
+
* ⚠️ **`connection_lost` IS THE CATCH-ALL, NOT A DIAGNOSIS** (`node.ts` says so). It covers a
|
|
1593
|
+
* dead muxer on a live socket, a per-protocol stream cap, and a remote reset during
|
|
1594
|
+
* multistream negotiation alike. **`muxerStatus` is what separates them**, which is why the
|
|
1595
|
+
* connections are dumped below: the transport exposes that field for this exact question and
|
|
1596
|
+
* without it the next cold-login failure is guessed at again.
|
|
1597
|
+
*
|
|
1598
|
+
* And note what this does NOT establish: the relay's handler only runs AFTER negotiation
|
|
1599
|
+
* succeeds, so a relay that reset the stream mid-negotiation would log nothing either. Relay
|
|
1600
|
+
* silence is not relay innocence.
|
|
1601
|
+
*/
|
|
1602
|
+
const raw = (err ?? {});
|
|
1603
|
+
const reason = typeof raw.reason === "string" ? raw.reason : "stream";
|
|
1604
|
+
this.#logger.warn("session.relay.reservation_proof.failed", {
|
|
1605
|
+
relayPeerId: this.#relayPeerId,
|
|
1606
|
+
relayAddrs: this.#relayAddrs,
|
|
1607
|
+
nodePeerId: node.getPeerId(),
|
|
1608
|
+
reason,
|
|
1609
|
+
error: extractErrorMessage(err),
|
|
1610
|
+
// The discriminator. `status` is the SOCKET; `muxerStatus` is the layer that carries data
|
|
1611
|
+
// and the one `newStream` checks first — a connection reading open/closed is the P5 shape.
|
|
1612
|
+
connsBeforeNewStream: before,
|
|
1613
|
+
connsAtFailure: snap(),
|
|
1614
|
+
nodeStatusAtFailure: node.lifecycleStatus?.() ?? "(unavailable)",
|
|
1615
|
+
impact: "no proof was sent, so this relay granted no reservation and the agent is unreachable " +
|
|
1616
|
+
"through it until a later attempt succeeds. The relay did not refuse anything — it may " +
|
|
1617
|
+
"never have seen the stream.",
|
|
1618
|
+
});
|
|
1619
|
+
return "transport_failed";
|
|
1620
|
+
}
|
|
1621
|
+
try {
|
|
1622
|
+
const iter = lp.decode(stream)[Symbol.asyncIterator]();
|
|
1623
|
+
const ok = await this.#authenticate(stream, iter, "reservation");
|
|
1624
|
+
this.#logger.info("session.relay.reservation_proof.result", {
|
|
1625
|
+
relayPeerId: this.#relayPeerId,
|
|
1626
|
+
nodePeerId: node.getPeerId(),
|
|
1627
|
+
ok,
|
|
1628
|
+
// DOD-M15-RELAYSLOTS-1: name the cause here too. `ok: false` alone sent people looking at
|
|
1629
|
+
// the transport for what is usually a token or a cap.
|
|
1630
|
+
...(ok ? {} : { refusalReason: this.#lastAuthRefusal?.reason ?? "no_relay_verdict" }),
|
|
1631
|
+
});
|
|
1632
|
+
return ok;
|
|
1633
|
+
}
|
|
1634
|
+
finally {
|
|
1635
|
+
await stream.close().catch(() => { });
|
|
1636
|
+
}
|
|
1637
|
+
}
|
|
1638
|
+
/**
|
|
1639
|
+
* 054-SRSPLIT — **TELL THE RELAY WE ARE DONE WITH OUR SLOT.**
|
|
1640
|
+
*
|
|
1641
|
+
* Resolves whether the relay confirmed it freed one. `false` covers "we held none" and "we could
|
|
1642
|
+
* not reach the relay to say so" alike, and the caller must not read it as failure: releasing is
|
|
1643
|
+
* tidy-up, and one that could not be delivered costs a slot until its TTL rather than breaking
|
|
1644
|
+
* anything the agent is doing. Every path that returns false says why.
|
|
1645
|
+
*
|
|
1646
|
+
* ⚠️ **IT GOES ON THE LONG-LIVED DELIVERY STREAM, NOT A RESERVATION-PURPOSE ONE, AND THAT IS NOT
|
|
1647
|
+
* A STYLE CHOICE.** The first version opened a fresh stream and authenticated with
|
|
1648
|
+
* `purpose: "reservation"` — mirroring the proof. The relay's handler treats that purpose as a
|
|
1649
|
+
* proof and nothing else: it answers `relay_auth_ok`, **closes the stream and returns without
|
|
1650
|
+
* entering the message loop**. So the release frame went into a stream nobody was reading, the
|
|
1651
|
+
* reply never came, and the verb was inert while every test passed. Found in review, not by the
|
|
1652
|
+
* suite.
|
|
1653
|
+
*
|
|
1654
|
+
* ⚠️ **THE FRAME CARRIES NO PEER ID, deliberately.** The relay frees the peer this connection
|
|
1655
|
+
* authenticated as. A peer id on the wire would let any registered agent free another's.
|
|
1656
|
+
*/
|
|
1657
|
+
/**
|
|
1658
|
+
* DOD-M15-AWAYSCOPE-1 — tell this relay whether anyone is attending this agent, for one session.
|
|
1659
|
+
*
|
|
1660
|
+
* ── WHY THIS EXISTS AT ALL ──────────────────────────────────────────────────────────────────────
|
|
1661
|
+
*
|
|
1662
|
+
* An unattended agent used to answer inbound messages with its away greeting. That greeting took a
|
|
1663
|
+
* hash-chain leaf inside a live conversation, and on session `e7dd3f43…` it cost two operators a
|
|
1664
|
+
* completed conversation's receipt, permanently. The fact itself is worth telling — a counterparty
|
|
1665
|
+
* mid-exchange genuinely needs to know whether to wait — but it is a fact ABOUT the session, not a
|
|
1666
|
+
* sentence IN it. So it rides here instead, and takes no leaf.
|
|
1667
|
+
*
|
|
1668
|
+
* ── FIRE AND FORGET, AND THAT IS THE DESIGN ─────────────────────────────────────────────────────
|
|
1669
|
+
*
|
|
1670
|
+
* Nothing is awaited and the relay sends no ack. The notice may be dropped, duplicated or arrive
|
|
1671
|
+
* out of order; `observed_at` settles it, last-write-wins. A failure changes nothing the operator
|
|
1672
|
+
* is doing — the counterparty reads "unknown" until the next notice, which is what is true.
|
|
1673
|
+
*
|
|
1674
|
+
* ⚠️ NO RETRY, NO QUEUE. Anything that made this reliable would want ordering, ordering wants a
|
|
1675
|
+
* witness, and a witnessed position is how machine chatter reached the leaf set. A status hint
|
|
1676
|
+
* that is allowed to be lost is the safe shape.
|
|
1677
|
+
*/
|
|
1678
|
+
announceAttendance(node, sessionId, attendance) {
|
|
1679
|
+
if (this.#closed)
|
|
1680
|
+
return;
|
|
1681
|
+
void (async () => {
|
|
1682
|
+
try {
|
|
1683
|
+
if (!(await this.#ensureConnected(node)))
|
|
1684
|
+
return;
|
|
1685
|
+
const stream = this.#stream;
|
|
1686
|
+
if (!stream)
|
|
1687
|
+
return;
|
|
1688
|
+
stream.send(lp.encode.single(encodeSessionAttendanceNotice({
|
|
1689
|
+
type: "session_attendance_notice",
|
|
1690
|
+
session_id: sessionId,
|
|
1691
|
+
attendance,
|
|
1692
|
+
// OUR clock, stated plainly. The relay bounds it against its own and refuses a value too
|
|
1693
|
+
// far ahead rather than clamping — clamping would turn a hostile timestamp into a valid
|
|
1694
|
+
// recent one, and this client is the code an adversary gets to rewrite.
|
|
1695
|
+
observed_at: Date.now(),
|
|
1696
|
+
})));
|
|
1697
|
+
this.#logger.debug("session.relay.attendance.announced", {
|
|
1698
|
+
relayPeerId: this.#relayPeerId,
|
|
1699
|
+
sessionId: Buffer.from(sessionId).toString("hex").slice(0, 16),
|
|
1700
|
+
attendance,
|
|
1701
|
+
});
|
|
1702
|
+
}
|
|
1703
|
+
catch (err) {
|
|
1704
|
+
this.#logger.debug("session.relay.attendance.announce_failed", {
|
|
1705
|
+
relayPeerId: this.#relayPeerId,
|
|
1706
|
+
error: extractErrorMessage(err),
|
|
1707
|
+
impact: "the counterparty reads 'unknown' for this agent until the next notice lands",
|
|
1708
|
+
});
|
|
1709
|
+
}
|
|
1710
|
+
})();
|
|
1711
|
+
}
|
|
1712
|
+
/**
|
|
1713
|
+
* DOD-M15-AWAYSCOPE-1 — ask the relay about the counterparty: reachable, and attended?
|
|
1714
|
+
*
|
|
1715
|
+
* Two facts with two producers, which is why they are separate fields. `liveness` is the RELAY's
|
|
1716
|
+
* observation — it holds that agent's standing connection or it does not. `attendance` is the far
|
|
1717
|
+
* DAEMON's assertion about itself, which the relay was told and repeats; absent whenever
|
|
1718
|
+
* `liveness` is not 'alive', because a daemon nobody can reach asserts nothing.
|
|
1719
|
+
*
|
|
1720
|
+
* ⚠️ EVERY FAILURE ANSWERS 'unknown', never 'gone'. A timeout, a dead stream, a refusal and an
|
|
1721
|
+
* unparseable frame all mean "this side learned nothing"; reporting any as `gone` invents an
|
|
1722
|
+
* observation nobody made, and `gone` is the value that tells an operator their peer has left.
|
|
1723
|
+
*/
|
|
1724
|
+
/**
|
|
1725
|
+
* Test-only seams for the liveness query path, which cannot be reached any other way.
|
|
1726
|
+
*
|
|
1727
|
+
* ⚠️ THEY EXIST BECAUSE A ONE-SESSION FAKE HID A REAL DEFECT. Every other test of this feature
|
|
1728
|
+
* stubs `queryLiveness` outright, so the code that matches an ANSWER to its QUESTION never ran —
|
|
1729
|
+
* and it was wrong: a single resolver slot handed one session's answer to another session's
|
|
1730
|
+
* promise the moment an agent held two conversations through one relay, which is ordinary.
|
|
1731
|
+
* Reaching that code needs a live stream and a dispatched frame, and both are private.
|
|
1732
|
+
*
|
|
1733
|
+
* Named `ForTest` like `__setLastActivityAtForTest` and `patchRelayClientForTest`, and doing
|
|
1734
|
+
* nothing production does not: one installs the stream a dial would have produced, the other
|
|
1735
|
+
* feeds the reader loop a frame the relay would have sent.
|
|
1736
|
+
*/
|
|
1737
|
+
installStreamForTest(stream) { this.#stream = stream; }
|
|
1738
|
+
dispatchForTest(frame) { this.#dispatch(frame); }
|
|
1739
|
+
async queryLiveness(sessionId, counterpartyPubkey) {
|
|
1740
|
+
const unknown = { liveness: "unknown", observedAt: 0 };
|
|
1741
|
+
if (this.#closed)
|
|
1742
|
+
return unknown;
|
|
1743
|
+
/**
|
|
1744
|
+
* ⚠️ IT DOES NOT DIAL — the one place in this client that refuses to connect. Every other caller
|
|
1745
|
+
* here does the operator's work; this one serves a READ (`cello status`, `cello sessions`), and
|
|
1746
|
+
* a status command that opens a connection as a side effect changes what it is reporting on. It
|
|
1747
|
+
* would also make a read of a dozen sessions pay a dial each, against a budget measured in what
|
|
1748
|
+
* a person waits at a terminal. The cost is small and precise: right after a daemon restart a
|
|
1749
|
+
* status read says 'unknown' instead of dialling; the first real message establishes the stream.
|
|
1750
|
+
*/
|
|
1751
|
+
const stream = this.#stream;
|
|
1752
|
+
if (!stream)
|
|
1753
|
+
return unknown;
|
|
1754
|
+
let resolveLiveness;
|
|
1755
|
+
const answer = new Promise((r) => { resolveLiveness = r; });
|
|
1756
|
+
const key = livenessKey(sessionId, counterpartyPubkey);
|
|
1757
|
+
this.#pendingLiveness.set(key, resolveLiveness);
|
|
1758
|
+
try {
|
|
1759
|
+
stream.send(lp.encode.single(encodeSessionLivenessQuery({
|
|
1760
|
+
type: "session_liveness_query",
|
|
1761
|
+
session_id: sessionId,
|
|
1762
|
+
counterparty_pubkey: counterpartyPubkey,
|
|
1763
|
+
})));
|
|
1764
|
+
}
|
|
1765
|
+
catch (err) {
|
|
1766
|
+
this.#pendingLiveness.delete(key);
|
|
1767
|
+
this.#logger.debug("session.relay.liveness.query_failed", {
|
|
1768
|
+
relayPeerId: this.#relayPeerId, error: extractErrorMessage(err),
|
|
1769
|
+
});
|
|
1770
|
+
return unknown;
|
|
1771
|
+
}
|
|
1772
|
+
let timer;
|
|
1773
|
+
const timeout = new Promise((r) => { timer = setTimeout(() => r("timeout"), HASH_SUBMIT_TIMEOUT_MS); });
|
|
1774
|
+
try {
|
|
1775
|
+
const result = await Promise.race([answer, timeout]);
|
|
1776
|
+
if (result === "timeout") {
|
|
1777
|
+
// NOT a stream reset, for the same reason a release timeout is not: a liveness query is not
|
|
1778
|
+
// ordered against anything, so tearing the shared stream down would cost every sibling
|
|
1779
|
+
// session its in-flight submits to tidy up one status read.
|
|
1780
|
+
this.#pendingLiveness.delete(key);
|
|
1781
|
+
this.#logger.debug("session.relay.liveness.query_timeout", { relayPeerId: this.#relayPeerId });
|
|
1782
|
+
return unknown;
|
|
1783
|
+
}
|
|
1784
|
+
return result;
|
|
1785
|
+
}
|
|
1786
|
+
finally {
|
|
1787
|
+
clearTimeout(timer);
|
|
1788
|
+
}
|
|
1789
|
+
}
|
|
1790
|
+
async releaseReservation(node) {
|
|
1791
|
+
if (this.#closed)
|
|
1792
|
+
return false;
|
|
1793
|
+
if (!(await this.#ensureConnected(node))) {
|
|
1794
|
+
this.#logger.warn("session.relay.reservation_release.failed", {
|
|
1795
|
+
relayPeerId: this.#relayPeerId,
|
|
1796
|
+
reason: "not_connected",
|
|
1797
|
+
impact: "this relay was not told the slot is free, so it holds it until the reservation " +
|
|
1798
|
+
"TTL expires. Nothing the agent is doing is affected.",
|
|
1799
|
+
});
|
|
1800
|
+
return false;
|
|
1801
|
+
}
|
|
1802
|
+
const stream = this.#stream;
|
|
1803
|
+
if (!stream)
|
|
1804
|
+
return false;
|
|
1805
|
+
let resolveRel;
|
|
1806
|
+
const relPromise = new Promise((r) => { resolveRel = r; });
|
|
1807
|
+
this.#pendingRelease = resolveRel;
|
|
1808
|
+
try {
|
|
1809
|
+
stream.send(lp.encode.single(encodeCbor({ type: "relay_release_reservation" })));
|
|
1810
|
+
}
|
|
1811
|
+
catch (err) {
|
|
1812
|
+
if (this.#pendingRelease === resolveRel)
|
|
1813
|
+
this.#pendingRelease = null;
|
|
1814
|
+
this.#logger.warn("session.relay.reservation_release.failed", {
|
|
1815
|
+
relayPeerId: this.#relayPeerId,
|
|
1816
|
+
reason: "send",
|
|
1817
|
+
error: extractErrorMessage(err),
|
|
1818
|
+
impact: "the relay holds the slot until its TTL expires.",
|
|
1819
|
+
});
|
|
1820
|
+
return false;
|
|
1821
|
+
}
|
|
1822
|
+
let timer;
|
|
1823
|
+
const timeout = new Promise((r) => { timer = setTimeout(() => r("timeout"), HASH_SUBMIT_TIMEOUT_MS); });
|
|
1824
|
+
try {
|
|
1825
|
+
const result = await Promise.race([relPromise, timeout]);
|
|
1826
|
+
if (result === "timeout") {
|
|
1827
|
+
// NOT a stream reset. A release is not ordered against anything — no ack of a later frame
|
|
1828
|
+
// can be settled by a late reply to this one, because the reader clears the resolver — so
|
|
1829
|
+
// tearing the stream down would cost sibling sessions their in-flight submits for a tidy-up.
|
|
1830
|
+
this.#logger.warn("session.relay.reservation_release.failed", {
|
|
1831
|
+
relayPeerId: this.#relayPeerId,
|
|
1832
|
+
reason: "no_reply",
|
|
1833
|
+
impact: "the relay did not answer, so it holds the slot until its TTL expires.",
|
|
1834
|
+
});
|
|
1835
|
+
return false;
|
|
1836
|
+
}
|
|
1837
|
+
this.#logger.info("session.relay.reservation_release.result", {
|
|
1838
|
+
relayPeerId: this.#relayPeerId,
|
|
1839
|
+
nodePeerId: node.getPeerId(),
|
|
1840
|
+
released: result,
|
|
1841
|
+
});
|
|
1842
|
+
return result;
|
|
1843
|
+
}
|
|
1844
|
+
finally {
|
|
1845
|
+
clearTimeout(timer);
|
|
1846
|
+
if (this.#pendingRelease === resolveRel)
|
|
1847
|
+
this.#pendingRelease = null;
|
|
1848
|
+
}
|
|
1849
|
+
}
|
|
1850
|
+
/** Ensure an authenticated stream exists, (re)dialing from `node` if needed. */
|
|
1851
|
+
async #ensureConnected(node) {
|
|
1852
|
+
if (this.#closed)
|
|
1853
|
+
return false;
|
|
1854
|
+
if (this.#stream)
|
|
1855
|
+
return true;
|
|
1856
|
+
if (this.#connecting)
|
|
1857
|
+
return this.#connecting;
|
|
1858
|
+
this.#connecting = this.#connect(node).finally(() => { this.#connecting = null; });
|
|
1859
|
+
return this.#connecting;
|
|
1860
|
+
}
|
|
1861
|
+
async #connect(node) {
|
|
1862
|
+
/**
|
|
1863
|
+
* ⚠️ DROP THE PREVIOUS ATTEMPT'S VERDICT FIRST — `DOD-M15-TOKENSTALE-1` review F4, and the same
|
|
1864
|
+
* reasoning `#proveReservationOnce` already carries in capitals twelve lines from here.
|
|
1865
|
+
*
|
|
1866
|
+
* `#lastAuthRefusal` is cleared only on auth SUCCESS. Both failure exits below — the dial and
|
|
1867
|
+
* the stream open — are transport failures that never reach a verdict at all, so without this
|
|
1868
|
+
* they return `false` carrying whatever an earlier attempt left behind.
|
|
1869
|
+
*
|
|
1870
|
+
* That was survivable while the submit boundary answered `relay_unavailable` for every `false`.
|
|
1871
|
+
* It is not now: this unit makes that boundary REPORT the stored refusal, so a stale
|
|
1872
|
+
* `online_token_expired` would relabel a relay that is genuinely down as our own dead
|
|
1873
|
+
* credential — sending the operator to restart their daemon over somebody else's outage, and
|
|
1874
|
+
* stripping the seal fallbacks that exist for exactly that outage. Clearing here is what makes
|
|
1875
|
+
* the promotion mean "this attempt's verdict" instead of "some past verdict".
|
|
1876
|
+
*/
|
|
1877
|
+
this.#clearAuthRefusal();
|
|
1878
|
+
// Best-effort dial: newStream auto-dials a known peer, but the relay's addrs may not
|
|
1879
|
+
// be in the peerstore yet, so dial each addr first. One success is enough.
|
|
1880
|
+
let dialed = false;
|
|
1881
|
+
let lastDialError = "";
|
|
1882
|
+
for (const addr of this.#relayAddrs) {
|
|
1883
|
+
try {
|
|
1884
|
+
await node.dial(addr);
|
|
1885
|
+
dialed = true;
|
|
1886
|
+
break;
|
|
1887
|
+
}
|
|
1888
|
+
catch (err) {
|
|
1889
|
+
lastDialError = extractErrorMessage(err);
|
|
1890
|
+
}
|
|
1891
|
+
}
|
|
1892
|
+
if (!dialed && this.#relayAddrs.length > 0) {
|
|
1893
|
+
this.#logger.warn("session.relay.dial.failed", {
|
|
1894
|
+
relayPeerId: this.#relayPeerId,
|
|
1895
|
+
relayAddrs: this.#relayAddrs,
|
|
1896
|
+
error: lastDialError,
|
|
1897
|
+
});
|
|
1898
|
+
return false;
|
|
1899
|
+
}
|
|
1900
|
+
let stream;
|
|
1901
|
+
try {
|
|
1902
|
+
stream = await node.newStream(this.#relayPeerId, RELAY_PROTOCOL_ID);
|
|
1903
|
+
}
|
|
1904
|
+
catch (err) {
|
|
1905
|
+
/**
|
|
1906
|
+
* Same error substitution as `proveReservation`'s stream branch, and on the MORE important
|
|
1907
|
+
* path — this is the stream that carries `leaf_deliver` into a live session. `newStream`'s
|
|
1908
|
+
* structured `reason` was being dropped here too, leaving `connection_lost`, a stream cap and
|
|
1909
|
+
* a refused protocol indistinguishable while a conversation is running.
|
|
1910
|
+
*/
|
|
1911
|
+
const raw = (err ?? {});
|
|
1912
|
+
this.#logger.warn("session.relay.stream.failed", {
|
|
1913
|
+
relayPeerId: this.#relayPeerId,
|
|
1914
|
+
reason: typeof raw.reason === "string" ? raw.reason : "stream",
|
|
1915
|
+
error: extractErrorMessage(err),
|
|
1916
|
+
relayConnections: node
|
|
1917
|
+
.getConnections()
|
|
1918
|
+
.filter((c) => c.peerId === this.#relayPeerId)
|
|
1919
|
+
.map((c) => ({ status: c.status, muxerStatus: c.muxerStatus, streams: c.streamCount })),
|
|
1920
|
+
});
|
|
1921
|
+
return false;
|
|
1922
|
+
}
|
|
1923
|
+
// ONE shared lp.decode iterator for the whole stream lifetime — splitting it signals
|
|
1924
|
+
// EOF to the relay's single-iterator reader and breaks subsequent reads.
|
|
1925
|
+
const iter = lp.decode(stream)[Symbol.asyncIterator]();
|
|
1926
|
+
if (!(await this.#authenticate(stream, iter)))
|
|
1927
|
+
return false;
|
|
1928
|
+
this.#stream = stream;
|
|
1929
|
+
this.#logger.info("session.relay.connected", { relayPeerId: this.#relayPeerId });
|
|
1930
|
+
this.#startReader(stream, iter);
|
|
1931
|
+
return true;
|
|
1932
|
+
}
|
|
1933
|
+
async #authenticate(stream, iter, purpose) {
|
|
1934
|
+
const challengeRes = await nextWithTimeout(iter, RELAY_AUTH_TIMEOUT_MS);
|
|
1935
|
+
if (challengeRes.done || challengeRes.value === undefined) {
|
|
1936
|
+
this.#logger.warn("session.relay.auth.failed", { relayPeerId: this.#relayPeerId, reason: "no_challenge" });
|
|
1937
|
+
return false;
|
|
1938
|
+
}
|
|
1939
|
+
let challenge;
|
|
1940
|
+
try {
|
|
1941
|
+
challenge = decode(toU8(challengeRes.value));
|
|
1942
|
+
}
|
|
1943
|
+
catch {
|
|
1944
|
+
this.#logger.warn("session.relay.auth.failed", { relayPeerId: this.#relayPeerId, reason: "challenge_decode" });
|
|
1945
|
+
return false;
|
|
1946
|
+
}
|
|
1947
|
+
if (challenge["type"] !== "relay_auth_challenge") {
|
|
1948
|
+
this.#logger.warn("session.relay.auth.failed", { relayPeerId: this.#relayPeerId, reason: "not_challenge" });
|
|
1949
|
+
return false;
|
|
1950
|
+
}
|
|
1951
|
+
const nonce = toU8(challenge["nonce"]);
|
|
1952
|
+
if (nonce.length !== 32) {
|
|
1953
|
+
this.#logger.warn("session.relay.auth.failed", { relayPeerId: this.#relayPeerId, reason: "bad_nonce" });
|
|
1954
|
+
return false;
|
|
1955
|
+
}
|
|
1956
|
+
const authSig = await this.#keyProvider.sign(buildRelayAuthPayload(nonce, this.#senderPubkey));
|
|
1957
|
+
/**
|
|
1958
|
+
* DOD-M15-RELAYSLOTS-1: read the token NOW, not at construction — it is reissued on every
|
|
1959
|
+
* signaling reconnect and the one this client was built with is usually already gone.
|
|
1960
|
+
*
|
|
1961
|
+
* When there is none we send the auth anyway. Declining to try would replace a named refusal
|
|
1962
|
+
* from the relay (`online_token_required`, which says what is wrong and what to do) with
|
|
1963
|
+
* silence on both sides — and silence is what an operator reads as "the product is broken".
|
|
1964
|
+
*/
|
|
1965
|
+
const onlineToken = this.#onlineToken?.();
|
|
1966
|
+
if (!onlineToken) {
|
|
1967
|
+
this.#logger.warn("session.relay.auth.no_online_token", {
|
|
1968
|
+
relayPeerId: this.#relayPeerId,
|
|
1969
|
+
impact: "authenticating without the directory's online token. The relay will refuse this and " +
|
|
1970
|
+
"will not let this node keep a circuit reservation, so the agent is reachable by nobody " +
|
|
1971
|
+
"over this relay. The usual cause is that no directory connection has been established " +
|
|
1972
|
+
"yet; the next signaling connect issues a token and the receiver re-authenticates.",
|
|
1973
|
+
});
|
|
1974
|
+
}
|
|
1975
|
+
try {
|
|
1976
|
+
stream.send(lp.encode.single(encodeCbor({
|
|
1977
|
+
type: "relay_auth_response",
|
|
1978
|
+
pubkey: this.#senderPubkey,
|
|
1979
|
+
signature: authSig,
|
|
1980
|
+
// DOD-M15-RELAYAUTH-1: absent for the ordinary session auth (which also registers this
|
|
1981
|
+
// stream as the agent's delivery target). `"reservation"` proves possession from THIS
|
|
1982
|
+
// node's transport identity and nothing more — see proveReservation().
|
|
1983
|
+
...(purpose ? { purpose } : {}),
|
|
1984
|
+
// DOD-M15-RELAYSLOTS-1: opaque bytes from the directory, forwarded verbatim. The client
|
|
1985
|
+
// never parses them — a format it does not read is a format it cannot get wrong.
|
|
1986
|
+
...(onlineToken ? { online_token: onlineToken } : {}),
|
|
1987
|
+
})));
|
|
1988
|
+
}
|
|
1989
|
+
catch (err) {
|
|
1990
|
+
this.#logger.warn("session.relay.auth.failed", { relayPeerId: this.#relayPeerId, reason: "response_send", error: extractErrorMessage(err) });
|
|
1991
|
+
return false;
|
|
1992
|
+
}
|
|
1993
|
+
const ackRes = await nextWithTimeout(iter, RELAY_AUTH_TIMEOUT_MS);
|
|
1994
|
+
if (ackRes.done || ackRes.value === undefined) {
|
|
1995
|
+
this.#logger.warn("session.relay.auth.failed", { relayPeerId: this.#relayPeerId, reason: "no_auth_ok" });
|
|
1996
|
+
return false;
|
|
1997
|
+
}
|
|
1998
|
+
let ackFrame;
|
|
1999
|
+
try {
|
|
2000
|
+
ackFrame = decode(toU8(ackRes.value));
|
|
2001
|
+
}
|
|
2002
|
+
catch {
|
|
2003
|
+
this.#logger.warn("session.relay.auth.failed", { relayPeerId: this.#relayPeerId, reason: "auth_ok_decode" });
|
|
2004
|
+
return false;
|
|
2005
|
+
}
|
|
2006
|
+
if (ackFrame["type"] !== "relay_auth_ok") {
|
|
2007
|
+
/**
|
|
2008
|
+
* DOD-M15-RELAYABUSE-1 review F2 — **THE RELAY SAYS WHY, AND WE USED TO THROW IT AWAY.**
|
|
2009
|
+
*
|
|
2010
|
+
* `relay_auth_failed` carries a `reason` — `rate_limited`, `signature_invalid`, `nonce_expired`,
|
|
2011
|
+
* `nonce_reused`, `nonce_unknown` — and, when throttled, a `retry_after_ms`. This branch
|
|
2012
|
+
* collapsed all of them into the single word `auth_rejected`, so a throttled agent looked
|
|
2013
|
+
* exactly like a bad signature, which looked exactly like a dead relay. That is precisely the
|
|
2014
|
+
* distinction the order that added those refusals set out to create, undone at the last hop.
|
|
2015
|
+
*
|
|
2016
|
+
* The difference matters to whoever is looking: `rate_limited` clears by itself and says when;
|
|
2017
|
+
* `signature_invalid` never clears and means a key or clock problem; a nonce failure means the
|
|
2018
|
+
* handshake raced and an immediate retry is the right move. One label for all three sends
|
|
2019
|
+
* someone to look for a broken relay in all three cases.
|
|
2020
|
+
*/
|
|
2021
|
+
const relayReason = typeof ackFrame["reason"] === "string" ? ackFrame["reason"] : undefined;
|
|
2022
|
+
const retryAfterMs = typeof ackFrame["retry_after_ms"] === "number" ? ackFrame["retry_after_ms"] : undefined;
|
|
2023
|
+
/**
|
|
2024
|
+
* DOD-M15-RELAYSLOTS-1: keep the refusal, do not merely log it.
|
|
2025
|
+
*
|
|
2026
|
+
* Everything below writes an excellent warn line into a file nobody opens. The operator who
|
|
2027
|
+
* runs `cello_use_agent` and finds their agent unreachable never sees it, and the daemon
|
|
2028
|
+
* deciding whether another relay would help cannot read it either. So the classified refusal
|
|
2029
|
+
* — reason, what to do about it, and whether to fail over — is stored where both can reach it.
|
|
2030
|
+
*/
|
|
2031
|
+
const slotsHeld = typeof ackFrame["slots_held"] === "number" ? ackFrame["slots_held"] : undefined;
|
|
2032
|
+
const slotCap = typeof ackFrame["slot_cap"] === "number" ? ackFrame["slot_cap"] : undefined;
|
|
2033
|
+
this.#lastAuthRefusal = classifyRelayAuthRefusal(ackFrame["type"] === "relay_auth_failed" ? (relayReason ?? "auth_rejected") : "unexpected_frame", {
|
|
2034
|
+
...(slotsHeld !== undefined ? { slotsHeld } : {}),
|
|
2035
|
+
...(slotCap !== undefined ? { slotCap } : {}),
|
|
2036
|
+
...(retryAfterMs !== undefined ? { retryAfterMs } : {}),
|
|
2037
|
+
});
|
|
2038
|
+
this.#logger.warn("session.relay.auth.failed", {
|
|
2039
|
+
relayPeerId: this.#relayPeerId,
|
|
2040
|
+
reason: ackFrame["type"] === "relay_auth_failed" ? (relayReason ?? "auth_rejected") : "unexpected_frame",
|
|
2041
|
+
...(retryAfterMs !== undefined ? { retryAfterMs } : {}),
|
|
2042
|
+
impact: relayReason === "rate_limited"
|
|
2043
|
+
? "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"
|
|
2044
|
+
: "this agent could not authenticate to this relay, so it cannot witness leaves or hold a reservation here",
|
|
2045
|
+
});
|
|
2046
|
+
return false;
|
|
2047
|
+
}
|
|
2048
|
+
// DOD-M15-RELAYSLOTS-1: a success clears the stored refusal, so a stale one is never reported
|
|
2049
|
+
// as the current state of a relay that has since started admitting us.
|
|
2050
|
+
this.#lastAuthRefusal = null;
|
|
2051
|
+
return true;
|
|
2052
|
+
}
|
|
2053
|
+
#startReader(stream, iter) {
|
|
2054
|
+
void (async () => {
|
|
2055
|
+
try {
|
|
2056
|
+
while (!this.#closed && this.#stream === stream) {
|
|
2057
|
+
const res = await iter.next();
|
|
2058
|
+
if (res.done || res.value === undefined)
|
|
2059
|
+
break;
|
|
2060
|
+
// The await above can suspend across a #resetStream() (timeout) that supersedes this
|
|
2061
|
+
// stream. Re-check identity before dispatching so a late frame from a stale stream
|
|
2062
|
+
// (e.g. a buffered ack for a timed-out submit) can't bump/settle the wrong session.
|
|
2063
|
+
if (this.#stream !== stream)
|
|
2064
|
+
break;
|
|
2065
|
+
let frame;
|
|
2066
|
+
try {
|
|
2067
|
+
frame = decode(toU8(res.value));
|
|
2068
|
+
}
|
|
2069
|
+
catch {
|
|
2070
|
+
continue;
|
|
2071
|
+
}
|
|
2072
|
+
this.#dispatch(frame);
|
|
2073
|
+
}
|
|
2074
|
+
}
|
|
2075
|
+
catch (err) {
|
|
2076
|
+
// WARN, not debug (DOD-RELAY-KEEPALIVE-1 review F4). This is the ONLY place the cause of a
|
|
2077
|
+
// dead relay link survives: the watchdog that notices later reports `relay_connection_gone`,
|
|
2078
|
+
// which names where it noticed, not why. At debug, 2,061 of these went untraced through a
|
|
2079
|
+
// launch — the reader ending is not routine, it means in-flight submits just failed.
|
|
2080
|
+
this.#logger.warn("session.relay.reader.ended", { relayPeerId: this.#relayPeerId, error: extractErrorMessage(err) });
|
|
2081
|
+
this.#lastReaderError = extractErrorMessage(err);
|
|
2082
|
+
}
|
|
2083
|
+
finally {
|
|
2084
|
+
// Stream gone — clear it so the next submit re-dials, and fail any in-flight submit.
|
|
2085
|
+
if (this.#stream === stream)
|
|
2086
|
+
this.#stream = null;
|
|
2087
|
+
this.#settlePending({ ok: false, reason: "relay_stream_closed" });
|
|
2088
|
+
// Settle an in-flight record too, so #doRecord doesn't
|
|
2089
|
+
// wait the full timeout on a dropped stream. "closed" is transient (not a directory rejection) ⇒
|
|
2090
|
+
// recorded stays false and it is retried after reconnect.
|
|
2091
|
+
{
|
|
2092
|
+
const r = this.#pendingRecord;
|
|
2093
|
+
this.#pendingRecord = null;
|
|
2094
|
+
if (r)
|
|
2095
|
+
r("closed");
|
|
2096
|
+
}
|
|
2097
|
+
// 054-SRSPLIT: settle an in-flight release too — `false`, because the relay was not told.
|
|
2098
|
+
{
|
|
2099
|
+
const r = this.#pendingRelease;
|
|
2100
|
+
this.#pendingRelease = null;
|
|
2101
|
+
if (r)
|
|
2102
|
+
r(false);
|
|
2103
|
+
}
|
|
2104
|
+
// DOD-M15-AWAYSCOPE-1: and an in-flight liveness query, so a status read does not wait its
|
|
2105
|
+
// whole timeout on a stream that is already gone. "unknown" is the honest answer.
|
|
2106
|
+
{
|
|
2107
|
+
for (const r of this.#pendingLiveness.values())
|
|
2108
|
+
r({ liveness: "unknown", observedAt: 0 });
|
|
2109
|
+
this.#pendingLiveness.clear();
|
|
2110
|
+
}
|
|
2111
|
+
// A receiving session never submits, so nothing else re-dials: reconnect so queued leaf_delivers arrive.
|
|
2112
|
+
if (!this.#closed && this.#sessions.size > 0)
|
|
2113
|
+
void this.#reconnectFromAnySession();
|
|
2114
|
+
}
|
|
2115
|
+
})();
|
|
2116
|
+
}
|
|
2117
|
+
/** Re-establish the shared stream from any session's node, retrying while one depends on it — see `relay-reconnect.ts`. */
|
|
2118
|
+
async #reconnectFromAnySession() {
|
|
2119
|
+
if (this.#reconnecting)
|
|
2120
|
+
return;
|
|
2121
|
+
this.#reconnecting = true;
|
|
2122
|
+
const stranded = () => !this.#closed && this.#stream === null && this.#sessions.size > 0;
|
|
2123
|
+
await reconnectWithBackoff({
|
|
2124
|
+
relayPeerId: this.#relayPeerId, logger: this.#logger, baseMs: this.#reconnectRetryMs, shouldStop: () => !stranded(), refusal: () => this.#lastAuthRefusal,
|
|
2125
|
+
connectOnce: async () => { for (const { node } of this.#sessions.values())
|
|
2126
|
+
if (await this.#ensureConnected(node))
|
|
2127
|
+
return true; return false; },
|
|
2128
|
+
}).finally(() => { this.#reconnecting = false; });
|
|
2129
|
+
if (stranded())
|
|
2130
|
+
void this.#reconnectFromAnySession(); // a reader that ended during the loop's exit saw the flag set
|
|
2131
|
+
}
|
|
2132
|
+
/**
|
|
2133
|
+
* Submit a session's CONTENT-leaf hash to the relay. Connects/re-connects from `node` if needed.
|
|
2134
|
+
* Globally FIFO across the agent's sessions (the ack has no session_id).
|
|
2135
|
+
*
|
|
2136
|
+
* `leafKind` defaults to MESSAGE, which is what `cello_send` wants. It is a PARAMETER because
|
|
2137
|
+
* the document path needs 0x04/0x05: the seal certificate is computed by the directory from the
|
|
2138
|
+
* leaves the RELAY witnessed, and `seal-legibility.ts` excludes doc/reject leaves from
|
|
2139
|
+
* `final_message` and from `answered` — guards that could never fire while this method hardcoded
|
|
2140
|
+
* MSG for every caller. See `document-leaf-kind-on-the-wire.test.ts` for what that cost.
|
|
2141
|
+
*/
|
|
2142
|
+
async submitMessageHash(node, sessionId, contentHash,
|
|
2143
|
+
/**
|
|
2144
|
+
* REQUIRED — `DOD-M15-SEALWIRE-1` B2b-1 pass-2 F3. This default was the last one on the path,
|
|
2145
|
+
* and it is the reason a test passing `undefined` for `leafKind` looked correct: the value was
|
|
2146
|
+
* silently rebuilt as MESSAGE one hop below the parameter the fix had just made required. Its
|
|
2147
|
+
* one production caller always passes explicitly, so the default was dead and misleading.
|
|
2148
|
+
*/
|
|
2149
|
+
leafKind) {
|
|
2150
|
+
/**
|
|
2151
|
+
* `null`, and it is now IMPOSSIBLE to omit — see `submitLeaf`'s note on why the parameter is
|
|
2152
|
+
* required. A message leaf's content belongs to the operator and never reaches the relay.
|
|
2153
|
+
*/
|
|
2154
|
+
return this.submitLeaf(node, sessionId, contentHash, leafKind, null);
|
|
2155
|
+
}
|
|
2156
|
+
/**
|
|
2157
|
+
* ─── WITNESS A LEAF THIS AGENT RECEIVED BUT DID NOT AUTHOR — 034-CARRYLEAF ────────────────────
|
|
2158
|
+
*
|
|
2159
|
+
* **This is what closes `DOD-M15-WITHHOLD-SEAL-1`.** Until it existed, `submitMessageHash` had
|
|
2160
|
+
* one production caller on the SEND path, so nothing ever witnessed a message that was RECEIVED.
|
|
2161
|
+
* A counterparty who delivered a message directly and never submitted its hash left the relay's
|
|
2162
|
+
* account of the conversation one message short — permanently — and a unilateral seal then agreed
|
|
2163
|
+
* with the witness. Every leaf validly signed, nothing false, the last thing said simply absent.
|
|
2164
|
+
*
|
|
2165
|
+
* **The teeth are the author's own signature.** It arrived on the content frame beside the bytes
|
|
2166
|
+
* it signs, this daemon verified it before ingesting anything, and it cannot be forged here. The
|
|
2167
|
+
* relay verifies it again against the session's assignment before sequencing — so what this hands
|
|
2168
|
+
* over is a claim the author made and cannot disown.
|
|
2169
|
+
*
|
|
2170
|
+
* ⚠️ **THE BYTES ARE PASSED THROUGH, NEVER REBUILT.** A signature is over the encoded bytes, and
|
|
2171
|
+
* the one measured cost of forgetting that on this exact structure was a daemon-local encoder
|
|
2172
|
+
* emitting a timestamp as float64 where the published one promotes to uint64 — same value,
|
|
2173
|
+
* different signed bytes, refused by everyone.
|
|
2174
|
+
*/
|
|
2175
|
+
async witnessReceivedLeaf(node, sessionId, contentHash, leafKind, carried) {
|
|
2176
|
+
return this.submitLeaf(node, sessionId, contentHash, leafKind, null, carried);
|
|
2177
|
+
}
|
|
2178
|
+
/**
|
|
2179
|
+
* Submit a leaf hash of a given kind (0x00 message / 0x02 control) to the relay. The SEAL
|
|
2180
|
+
* ctrl leaf rides this path: two distinct-sender ctrl leaves in the relay's
|
|
2181
|
+
* log trigger the directory's FROST notarization (relay `#maybeProcessSeal`).
|
|
2182
|
+
*
|
|
2183
|
+
* ─── `contentBytes` — `DOD-M15-SEALWIRE-1` bullets 3+4, THE SENDER LEG ───────────────────────
|
|
2184
|
+
*
|
|
2185
|
+
* The SEAL leaf's own payload, carried alongside its hash. Without it the directory holds a
|
|
2186
|
+
* SHA-256 pre-image and nothing else, so the client's SIGNED `final_root` — the one value in the
|
|
2187
|
+
* whole seal that the relay cannot produce — is unrecoverable, and every root check the directory
|
|
2188
|
+
* can make compares the relay against itself.
|
|
2189
|
+
*
|
|
2190
|
+
* ⚠️ THIS PARAMETER PUTS LEAF CONTENT ON THE RELAY, AND THE RELAY IS THE PARTY THIS PROTOCOL
|
|
2191
|
+
* EXISTS TO KEEP CONTENT AWAY FROM (INV-3: a forwarding relay sees ciphertext).
|
|
2192
|
+
*
|
|
2193
|
+
* It is safe for a SEAL ctrl leaf and for nothing else. The payload is `[session_id, final_root,
|
|
2194
|
+
* close_timestamp, "PENDING"]` and the relay already knows all four — it assigned the session,
|
|
2195
|
+
* built the tree the root comes from, and stamped the leaf. Nothing is disclosed. That reasoning
|
|
2196
|
+
* stops dead at the next leaf kind: a `msg` leaf's content is the operator's plaintext and a `doc`
|
|
2197
|
+
* leaf's is their document.
|
|
2198
|
+
*
|
|
2199
|
+
* So both directions are REFUSED rather than tidied, and refused HERE rather than at the relay:
|
|
2200
|
+
*
|
|
2201
|
+
* - content on a non-ctrl leaf → the relay would refuse the whole frame, but only after the
|
|
2202
|
+
* operator's words had already crossed the wire to the party that must not have them, and the
|
|
2203
|
+
* refusal would destroy their send rather than protect it.
|
|
2204
|
+
* - a ctrl leaf with NO payload → this was the actual defect. `submitSealLeaf` computed the
|
|
2205
|
+
* payload, hashed it, and had nowhere to put it, so it was dropped. The seal still succeeded,
|
|
2206
|
+
* the relay still acked, and three hops later the directory reported `not_carried` and blamed
|
|
2207
|
+
* the relay's build version — for a value the client never sent. Four reviewed legs shipped
|
|
2208
|
+
* over that silence. A dropped argument now fails on the machine that dropped it.
|
|
2209
|
+
*
|
|
2210
|
+
* ⚠️ REQUIRED, AND `| null` RATHER THAN `?` — THE TYPE IS THE GUARD.
|
|
2211
|
+
*
|
|
2212
|
+
* I first wrote this optional and covered it with tests. Then I ran the revert test that mattered:
|
|
2213
|
+
* drop the argument at the one call site that must pass it, exactly reproducing the original
|
|
2214
|
+
* defect. **All five new tests stayed green.** An optional parameter makes the defect a silent,
|
|
2215
|
+
* type-legal omission — which is precisely how it shipped through four reviews the first time.
|
|
2216
|
+
*
|
|
2217
|
+
* Required means the omission is a COMPILE ERROR, caught by the gate on the machine that made it,
|
|
2218
|
+
* before any test runs. Every caller must now say what this leaf carries, and `submitMessageHash`
|
|
2219
|
+
* says `null` in one visible place instead of by saying nothing at all.
|
|
2220
|
+
*/
|
|
2221
|
+
async submitLeaf(node, sessionId, contentHash, leafKind, contentBytes,
|
|
2222
|
+
/**
|
|
2223
|
+
* 034-CARRYLEAF — a leaf THIS AGENT DID NOT AUTHOR, carried on its author's behalf.
|
|
2224
|
+
*
|
|
2225
|
+
* Absent for every ordinary send, where this client builds and signs its own claim. Present
|
|
2226
|
+
* only when witnessing something received whose author never submitted it — see
|
|
2227
|
+
* `witnessReceivedLeaf`.
|
|
2228
|
+
*/
|
|
2229
|
+
carried) {
|
|
2230
|
+
if (contentBytes !== null && leafKind !== LEAF_KIND_CTRL) {
|
|
2231
|
+
// Logged at ERROR and returned: a caller reaching this line is trying to hand the relay
|
|
2232
|
+
// operator content, and the log must carry it even if the caller swallows the result.
|
|
2233
|
+
this.#logger.error("session.relay.submit.content_not_permitted", {
|
|
2234
|
+
relayPeerId: this.#relayPeerId,
|
|
2235
|
+
leafKind,
|
|
2236
|
+
impact: "the submit was NOT sent. Only a SEAL ctrl leaf may carry its content to the relay; every other leaf kind's content belongs to the operator.",
|
|
2237
|
+
guidance: "Pass contentBytes only with LEAF_KIND_CTRL. If a new leaf kind genuinely needs to disclose its content to the relay, that is a protocol decision, not a call-site one.",
|
|
2238
|
+
});
|
|
2239
|
+
return { ok: false, reason: "content_not_permitted_for_leaf_kind" };
|
|
2240
|
+
}
|
|
2241
|
+
if (contentBytes === null && leafKind === LEAF_KIND_CTRL) {
|
|
2242
|
+
this.#logger.error("session.relay.submit.seal_payload_missing", {
|
|
2243
|
+
relayPeerId: this.#relayPeerId,
|
|
2244
|
+
impact: "the seal leaf was NOT sent. Sending it without its payload produces a certificate the directory cannot check against any participant's signed transcript — silently, and reported downstream as the RELAY being on an old build.",
|
|
2245
|
+
guidance: "A ctrl leaf on this path is a SEAL leaf; pass the encodeSealPayload bytes whose SHA-256(0x02 ‖ payload) is the contentHash argument.",
|
|
2246
|
+
});
|
|
2247
|
+
return { ok: false, reason: "seal_payload_not_carried" };
|
|
2248
|
+
}
|
|
2249
|
+
if (contentBytes !== null) {
|
|
2250
|
+
/**
|
|
2251
|
+
* ⚠️ THE BYTES MUST BE A SEAL PAYLOAD FOR THIS SESSION, AND THEY MUST HASH TO THE HASH BEING
|
|
2252
|
+
* SIGNED — review pass 2, MEDIUM-1 and HIGH-1. The kind check alone was not the property this
|
|
2253
|
+
* parameter's whole justification rests on.
|
|
2254
|
+
*
|
|
2255
|
+
* The justification is: *"the payload is [session_id, final_root, close_timestamp, "PENDING"]
|
|
2256
|
+
* and the relay already knows all four, so nothing is disclosed."* The code enforced
|
|
2257
|
+
* `leafKind === CTRL` and nothing else — so a caller passing a ctrl leaf with four kilobytes
|
|
2258
|
+
* of the operator's text would have transmitted it, and been refused only at the relay, AFTER
|
|
2259
|
+
* it crossed the wire to the party that must not have it. That is the precise harm the local
|
|
2260
|
+
* guard exists to prevent, and the relay learned this same lesson at its own review (H1 in
|
|
2261
|
+
* `relay-frames.ts`) one file over. I wrote the weaker version anyway.
|
|
2262
|
+
*
|
|
2263
|
+
* THE HASH BINDING IS THE MORE IMPORTANT HALF, and it closes a mutant that survived pass 1's
|
|
2264
|
+
* type hardening. Making the parameter required catches an OMITTED argument; it cannot catch a
|
|
2265
|
+
* SUBSTITUTED one. A caller that re-derives the payload instead of passing the one it hashed
|
|
2266
|
+
* — a second `encodeSealPayload` call, a fresh `Date.now()` — compiles, and the mismatch
|
|
2267
|
+
* surfaces at the directory as `seal_payload_unbound`, whose guidance reads *"someone between
|
|
2268
|
+
* them and here altered or fabricated the payload — the relay is the only party on that path.
|
|
2269
|
+
* Treat this as relay tampering, not a version mismatch."*
|
|
2270
|
+
*
|
|
2271
|
+
* **A client-side derivation slip would be published as a named accusation against a healthy
|
|
2272
|
+
* relay operator.** Checking it here makes it a local refusal on the machine that caused it.
|
|
2273
|
+
*/
|
|
2274
|
+
const rederived = new Uint8Array(createHash("sha256").update(new Uint8Array([LEAF_KIND_CTRL])).update(contentBytes).digest());
|
|
2275
|
+
if (!Buffer.from(rederived).equals(Buffer.from(contentHash))) {
|
|
2276
|
+
this.#logger.error("session.relay.submit.seal_payload_unbound", {
|
|
2277
|
+
relayPeerId: this.#relayPeerId,
|
|
2278
|
+
impact: "the seal leaf was NOT sent. The payload does not hash to the content_hash this leaf signs, so the directory would have reported it as RELAY TAMPERING — a named accusation against a node that did nothing wrong.",
|
|
2279
|
+
guidance: "Pass the SAME bytes that produced contentHash. Re-deriving the payload at the call site produces a different close_timestamp and breaks the binding.",
|
|
2280
|
+
});
|
|
2281
|
+
return { ok: false, reason: "seal_payload_unbound" };
|
|
2282
|
+
}
|
|
2283
|
+
const decoded = decodeSealPayload(contentBytes);
|
|
2284
|
+
if (!decoded || !Buffer.from(decoded.session_id).equals(Buffer.from(sessionId))) {
|
|
2285
|
+
this.#logger.error("session.relay.submit.seal_payload_invalid", {
|
|
2286
|
+
relayPeerId: this.#relayPeerId,
|
|
2287
|
+
impact: "the submit was NOT sent. Only a SEAL payload for THIS session may be disclosed to the relay — arbitrary bytes on a ctrl leaf are still the operator's content, and a payload for another session is a replay.",
|
|
2288
|
+
guidance: decoded
|
|
2289
|
+
? "The payload names a different session than the one being submitted."
|
|
2290
|
+
: "The bytes are not a decodable SEAL payload. Build them with encodeSealPayload.",
|
|
2291
|
+
});
|
|
2292
|
+
return { ok: false, reason: "seal_payload_invalid" };
|
|
2293
|
+
}
|
|
2294
|
+
}
|
|
2295
|
+
// Chain on the prior submit so only one is outstanding at a time (FIFO). The ack
|
|
2296
|
+
// carries no session_id, so concurrent submits on one stream would be ambiguous.
|
|
2297
|
+
const run = this.#submitChain.then(() => this.#doSubmit(node, sessionId, contentHash, leafKind, contentBytes, carried));
|
|
2298
|
+
// Keep the chain alive regardless of this submit's outcome.
|
|
2299
|
+
this.#submitChain = run.then(() => undefined, () => undefined);
|
|
2300
|
+
return run;
|
|
2301
|
+
}
|
|
2302
|
+
/**
|
|
2303
|
+
* DOD-FIRSTMSG-WITNESS-1: `session_not_found` is TRANSIENT, not terminal.
|
|
2304
|
+
*
|
|
2305
|
+
* The relay answers it when it does not hold the session YET — the assignment record is still
|
|
2306
|
+
* landing, or the counterparty recorded it and our own record raced ahead of the submit. Proven
|
|
2307
|
+
* from the live log: in all 23 first-message failures the `assignment.recorded` event lands
|
|
2308
|
+
* 5 ms – 2.1 s AFTER the rejected submit. The relay is reachable and answering; it is simply
|
|
2309
|
+
* not ready.
|
|
2310
|
+
*
|
|
2311
|
+
* Before this, the rejection was returned to `sendContent`, which logged
|
|
2312
|
+
* `session.relay.hash.submit.failed` and appended the leaf UNWITNESSED anyway. Nothing retried,
|
|
2313
|
+
* so the relay's counter never counted that message and the local record stayed exactly one
|
|
2314
|
+
* ahead for the life of the session. Because the bilateral certificate is rebuilt EXCLUSIVELY
|
|
2315
|
+
* from relay-witnessed leaves, the sealed receipt omitted the conversation's opening message —
|
|
2316
|
+
* and was issued regardless.
|
|
2317
|
+
*
|
|
2318
|
+
* Retrying re-presents the assignment first (idempotent — the relay answers `assignment_ok` on
|
|
2319
|
+
* `session_already_exists`), which is also what supplies the wait: re-recording is a full
|
|
2320
|
+
* round trip, so the retry cannot busy-spin against a relay that is still catching up.
|
|
2321
|
+
*
|
|
2322
|
+
* This does NOT collapse the two states the fix must tell apart:
|
|
2323
|
+
* - `session_not_found` — a reachable relay that does not hold the session yet → retry here.
|
|
2324
|
+
* - `relay_unavailable` / stream failures — a genuine outage → returned untouched, so
|
|
2325
|
+
* `sendContent` still degrades to an unwitnessed append and the inbox stays readable.
|
|
2326
|
+
* Any other rejection (`session_sealed`, `not_a_participant`, signature failures) is terminal
|
|
2327
|
+
* and returned as-is — retrying those would be pointless traffic masking a real state.
|
|
2328
|
+
*/
|
|
2329
|
+
static #SESSION_NOT_FOUND_ATTEMPTS = 3;
|
|
2330
|
+
/**
|
|
2331
|
+
* DOD-M15-RELAYABUSE-1 review F1: how many times a throttled submit waits out the relay's stated
|
|
2332
|
+
* window before the refusal is surfaced. Three, because the window is a fixed sliding minute —
|
|
2333
|
+
* two waits clear any ordinary burst, and a third failure means something other than this
|
|
2334
|
+
* sender's own volume is going on, which the operator should hear about.
|
|
2335
|
+
*/
|
|
2336
|
+
static #RATE_LIMITED_ATTEMPTS = 3;
|
|
2337
|
+
/** Used only when the relay names no window (an older relay, or a malformed value). */
|
|
2338
|
+
static #RATE_LIMITED_FALLBACK_MS = 5_000;
|
|
2339
|
+
/**
|
|
2340
|
+
* Ceiling on a single wait, so a relay reporting an implausible window cannot park a send
|
|
2341
|
+
* indefinitely — a hostile or misconfigured relay must not be able to stall a sender by
|
|
2342
|
+
* answering `retry_after_ms: 3600000`. Past this the send fails and says so.
|
|
2343
|
+
*/
|
|
2344
|
+
static #RATE_LIMITED_MAX_WAIT_MS = 65_000;
|
|
2345
|
+
async #doSubmit(node, sessionId, contentHash, leafKind, contentBytes, carried) {
|
|
2346
|
+
const sessionIdHex = Buffer.from(sessionId).toString("hex");
|
|
2347
|
+
// Snapshotted BEFORE the first attempt, and it is the whole safety of this loop.
|
|
2348
|
+
//
|
|
2349
|
+
// The first-message race is BY DEFINITION a session we have not recorded yet. A session we DID
|
|
2350
|
+
// record and that the relay now reports missing is a DIFFERENT state: the relay destroys a
|
|
2351
|
+
// session on seal (relay-node.ts `confirmSeal`) and on idle sweep, and its store keeps NO
|
|
2352
|
+
// tombstone — `recordSession` re-creates any absent key fresh (`seq_counter: 0`, empty
|
|
2353
|
+
// leaf_log, status "active"). So `getSession` answers `session_not_found`, NOT `session_sealed`.
|
|
2354
|
+
// Re-presenting our still-valid directory-signed assignment there would silently RESURRECT a
|
|
2355
|
+
// sealed session on the relay: a ghost with an empty log, an idle timer and a delivery path,
|
|
2356
|
+
// created by a client submit retry. Two ctrl leaves on it would drive a second notarization
|
|
2357
|
+
// over a 1-leaf log for an already-certified session.
|
|
2358
|
+
//
|
|
2359
|
+
// The 2 post-seal failures in this defect's own evidence table are exactly that shape — they
|
|
2360
|
+
// reported `session_not_found`, indistinguishable at the wire from the race — which is why
|
|
2361
|
+
// this must be discriminated on OUR state, not on the relay's reason string.
|
|
2362
|
+
const recordedBefore = this.#sessions.get(sessionIdHex)?.recorded === true;
|
|
2363
|
+
let result = await this.#doSubmitOnce(node, sessionId, contentHash, leafKind, contentBytes, carried);
|
|
2364
|
+
for (let attempt = 1; attempt < _a.#SESSION_NOT_FOUND_ATTEMPTS
|
|
2365
|
+
&& !recordedBefore
|
|
2366
|
+
&& !result.ok
|
|
2367
|
+
&& (result.reason === "session_not_found" || result.reason === "session_not_recorded")
|
|
2368
|
+
&& !this.#closed; attempt++) {
|
|
2369
|
+
// Force the assignment to be re-presented: we never recorded this session, so the relay
|
|
2370
|
+
// genuinely does not hold it yet. A session with no assignment to present (direct/legacy)
|
|
2371
|
+
// re-submits without a record — still bounded, and it surfaces the same named failure
|
|
2372
|
+
// rather than hanging.
|
|
2373
|
+
const sess = this.#sessions.get(sessionIdHex);
|
|
2374
|
+
if (sess)
|
|
2375
|
+
sess.recorded = false;
|
|
2376
|
+
this.#logger.info("session.relay.submit.retry", {
|
|
2377
|
+
relayPeerId: this.#relayPeerId,
|
|
2378
|
+
sessionShort: sessionIdHex.slice(0, 16),
|
|
2379
|
+
attempt,
|
|
2380
|
+
reason: result.reason,
|
|
2381
|
+
});
|
|
2382
|
+
result = await this.#doSubmitOnce(node, sessionId, contentHash, leafKind, contentBytes, carried);
|
|
2383
|
+
}
|
|
2384
|
+
/**
|
|
2385
|
+
* DOD-M15-RELAYABUSE-1 review F1 — **A THROTTLE IS BACK-PRESSURE, NOT AN ERROR.** (Andre,
|
|
2386
|
+
* 2026-08-31: retry on the relay's own timing; surface only if the retry also fails.)
|
|
2387
|
+
*
|
|
2388
|
+
* Without this the relay's `rate_limited` fell into the caller's catch-all: one log line, and
|
|
2389
|
+
* `cello_send` returned `{ok:true, delivered:true}` for a message the relay had refused to
|
|
2390
|
+
* witness — on the parked path telling the operator it was *"sealed, witnessed and on its way"*.
|
|
2391
|
+
* The leaf went out unwitnessed with no sequence number, and the seal later covered a transcript
|
|
2392
|
+
* missing it.
|
|
2393
|
+
*
|
|
2394
|
+
* This is the ONE refusal that is safely retryable and self-clearing, and the relay tells us
|
|
2395
|
+
* exactly when. So we wait it out here, where the wait is invisible, rather than handing the
|
|
2396
|
+
* agent an error for a condition that resolves in under a minute. Bounded: if the window is
|
|
2397
|
+
* absent or implausible we fall back to a fixed wait, and after
|
|
2398
|
+
* `#RATE_LIMITED_ATTEMPTS` the refusal is returned and the caller surfaces it — Option 2 as
|
|
2399
|
+
* the fallback, not the first move.
|
|
2400
|
+
*/
|
|
2401
|
+
for (let attempt = 1; attempt < _a.#RATE_LIMITED_ATTEMPTS
|
|
2402
|
+
&& !result.ok
|
|
2403
|
+
&& result.reason === "rate_limited"
|
|
2404
|
+
&& !this.#closed; attempt++) {
|
|
2405
|
+
const waitMs = Math.min(result.retry_after_ms !== undefined ? result.retry_after_ms : _a.#RATE_LIMITED_FALLBACK_MS, _a.#RATE_LIMITED_MAX_WAIT_MS);
|
|
2406
|
+
this.#logger.info("session.relay.submit.throttled", {
|
|
2407
|
+
relayPeerId: this.#relayPeerId,
|
|
2408
|
+
sessionShort: sessionIdHex.slice(0, 16),
|
|
2409
|
+
attempt,
|
|
2410
|
+
waitMs,
|
|
2411
|
+
retryAfterMsFromRelay: result.retry_after_ms,
|
|
2412
|
+
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",
|
|
2413
|
+
});
|
|
2414
|
+
await new Promise((r) => setTimeout(r, waitMs));
|
|
2415
|
+
if (this.#closed)
|
|
2416
|
+
break;
|
|
2417
|
+
result = await this.#doSubmitOnce(node, sessionId, contentHash, leafKind, contentBytes, carried);
|
|
2418
|
+
}
|
|
2419
|
+
if (!result.ok && result.reason === "rate_limited") {
|
|
2420
|
+
// Option 2, the fallback: it did not clear within our budget, so the caller must hear it
|
|
2421
|
+
// rather than be told the message was witnessed.
|
|
2422
|
+
this.#logger.warn("session.relay.submit.throttle_persisted", {
|
|
2423
|
+
relayPeerId: this.#relayPeerId,
|
|
2424
|
+
sessionShort: sessionIdHex.slice(0, 16),
|
|
2425
|
+
attempts: _a.#RATE_LIMITED_ATTEMPTS,
|
|
2426
|
+
impact: "this message was NOT witnessed by the relay — it has no sequence number and will not appear in the notarized record",
|
|
2427
|
+
});
|
|
2428
|
+
}
|
|
2429
|
+
// The relay lost a session we had successfully recorded — sealed, idle-swept, or restarted.
|
|
2430
|
+
// Report THAT, rather than letting the caller read a bare `session_not_found` that reads like
|
|
2431
|
+
// the first-message race. Never re-present here: recreating it is the resurrection above.
|
|
2432
|
+
if (recordedBefore && !result.ok && result.reason === "session_not_found") {
|
|
2433
|
+
this.#logger.warn("session.relay.session.gone", {
|
|
2434
|
+
relayPeerId: this.#relayPeerId,
|
|
2435
|
+
sessionShort: sessionIdHex.slice(0, 16),
|
|
2436
|
+
guidance: "the relay no longer holds a session it had recorded (sealed, idle-swept, or restarted) — not re-presented, because that would recreate it with an empty leaf log",
|
|
2437
|
+
});
|
|
2438
|
+
return { ok: false, reason: "relay_session_gone" };
|
|
2439
|
+
}
|
|
2440
|
+
return result;
|
|
2441
|
+
}
|
|
2442
|
+
async #doSubmitOnce(node, sessionId, contentHash, leafKind, contentBytes, carried) {
|
|
2443
|
+
if (this.#closed)
|
|
2444
|
+
return { ok: false, reason: "relay_client_closed" };
|
|
2445
|
+
if (!(await this.#ensureConnected(node))) {
|
|
2446
|
+
/**
|
|
2447
|
+
* ─── OUR OWN DEAD CREDENTIAL IS NOT A RELAY OUTAGE — `DOD-M15-TOKENSTALE-1` ───────────────
|
|
2448
|
+
*
|
|
2449
|
+
* `#ensureConnected` answers with a BOOLEAN, so every reason a relay ever gave for refusing
|
|
2450
|
+
* us arrived here as `false` and left as `relay_unavailable`. That word is a claim about the
|
|
2451
|
+
* RELAY: unreachable, transient, someone else's, and the documented grounds on which a send
|
|
2452
|
+
* appends an unwitnessed leaf and still reports success.
|
|
2453
|
+
*
|
|
2454
|
+
* An expired online token is the opposite on all four counts, and the cost of the mislabel is
|
|
2455
|
+
* not cosmetic. Measured live 2026-09-10: an agent ran for over two days answering
|
|
2456
|
+
* `ok:true, delivered:true` to every send while the relay refused all thirteen of its
|
|
2457
|
+
* authentications. Nothing it said in that time could ever produce a receipt, and no surface
|
|
2458
|
+
* anywhere said so — because the one place that knew called it a relay problem.
|
|
2459
|
+
*
|
|
2460
|
+
* `#lastAuthRefusal` already held the truth (`cello status` reads it); the submit path simply
|
|
2461
|
+
* never looked. Only LOCAL refusals are promoted: a genuine relay fault keeps
|
|
2462
|
+
* `relay_unavailable`, so this is a split rather than a rename, and the caller's existing
|
|
2463
|
+
* degradation for a real outage is untouched.
|
|
2464
|
+
*/
|
|
2465
|
+
const refusal = this.#lastAuthRefusal;
|
|
2466
|
+
if (refusal && isLocalCredentialRefusal(refusal.reason)) {
|
|
2467
|
+
/**
|
|
2468
|
+
* ⚠️ THE PROSE ONCE PER TRANSITION, THE REASON EVERY TIME — review F6.
|
|
2469
|
+
*
|
|
2470
|
+
* This condition lasts until a relogin by construction, and every send retries it. Logging
|
|
2471
|
+
* several hundred bytes of explanation on each one is the shape `96f3179b` measured four
|
|
2472
|
+
* commits earlier: a 176 MB log that was 95% one already-fixed defect repeating itself,
|
|
2473
|
+
* which hid the defect that produced it for eleven hours. The first occurrence carries
|
|
2474
|
+
* everything; the rest are countable without being unreadable.
|
|
2475
|
+
*/
|
|
2476
|
+
const firstOfThisRun = this.#loggedCredentialRefusal !== refusal.reason;
|
|
2477
|
+
this.#loggedCredentialRefusal = refusal.reason;
|
|
2478
|
+
if (firstOfThisRun) {
|
|
2479
|
+
this.#logger.error("session.relay.submit.local_credential_refusal", {
|
|
2480
|
+
relayPeerId: this.#relayPeerId,
|
|
2481
|
+
reason: refusal.reason,
|
|
2482
|
+
impact: "the relay refused THIS AGENT'S credential, so nothing it sends can be witnessed and no " +
|
|
2483
|
+
"conversation can produce a receipt. This is a fault on this machine and it does not " +
|
|
2484
|
+
"clear on its own — it is NOT the relay being unreachable.",
|
|
2485
|
+
guidance: refusal.advice,
|
|
2486
|
+
});
|
|
2487
|
+
}
|
|
2488
|
+
else {
|
|
2489
|
+
this.#logger.warn("session.relay.submit.local_credential_refusal.again", {
|
|
2490
|
+
relayPeerId: this.#relayPeerId, reason: refusal.reason,
|
|
2491
|
+
});
|
|
2492
|
+
}
|
|
2493
|
+
return { ok: false, reason: refusal.reason };
|
|
2494
|
+
}
|
|
2495
|
+
return { ok: false, reason: "relay_unavailable" };
|
|
2496
|
+
}
|
|
2497
|
+
const sessionIdHex = Buffer.from(sessionId).toString("hex");
|
|
2498
|
+
// The relay records the session from the CLIENT-presented assignment. It MUST be recorded BEFORE
|
|
2499
|
+
// the first hash_submit — the relay rejects a submit for an unknown session. Idempotent + no-op when
|
|
2500
|
+
// there is no assignment to present (direct/persisted sessions). Runs inline on the submit chain (no re-chaining
|
|
2501
|
+
// — #doSubmit is already a chain link), and may reset the stream on failure, so capture #stream after.
|
|
2502
|
+
const recorded = await this.#doRecord(node, sessionIdHex);
|
|
2503
|
+
const sess = this.#sessions.get(sessionIdHex);
|
|
2504
|
+
if (!recorded && sess?.assignment) {
|
|
2505
|
+
// DOD-FIRSTMSG-WITNESS-1 AC3: do NOT send a hash_submit for a session we know the relay does
|
|
2506
|
+
// not hold. Previously this return value was discarded and the doomed frame went out anyway —
|
|
2507
|
+
// the line this defect's producer→consumer trace names as THE gap. Two sub-states, kept apart:
|
|
2508
|
+
// - recordRejected → TERMINAL (the relay refused the assignment as unverifiable). Retrying
|
|
2509
|
+
// cannot help and would storm the shared stream.
|
|
2510
|
+
// - anything else → the record is in flight or transiently failed. Retryable.
|
|
2511
|
+
// A session with NO assignment to present (direct/legacy) is not covered here: #doRecord
|
|
2512
|
+
// returns true for it, so it still submits exactly as before.
|
|
2513
|
+
if (sess.recordRejected)
|
|
2514
|
+
return { ok: false, reason: "relay_assignment_rejected" };
|
|
2515
|
+
// A TIMEOUT IS NOT "NOT READY YET". The retry below exists for a relay that has not finished
|
|
2516
|
+
// registering the session — it answers in milliseconds. A record that timed out means the relay
|
|
2517
|
+
// is not answering at all, and retrying spends HASH_SUBMIT_TIMEOUT_MS again per attempt on the
|
|
2518
|
+
// chain SHARED by every session this agent holds on this relay. Classifying it as unreachable
|
|
2519
|
+
// proceeds unwitnessed immediately, which is what AC2 asks for on an outage — the send is not
|
|
2520
|
+
// lost, and one sick relay cannot hold an operator's other conversations for half a minute.
|
|
2521
|
+
if (sess.recordTimedOut)
|
|
2522
|
+
return { ok: false, reason: "relay_unavailable" };
|
|
2523
|
+
return { ok: false, reason: "session_not_recorded" };
|
|
2524
|
+
}
|
|
2525
|
+
const stream = this.#stream;
|
|
2526
|
+
if (!stream)
|
|
2527
|
+
return { ok: false, reason: "relay_unavailable" };
|
|
2528
|
+
/**
|
|
2529
|
+
* ─── WHAT THIS SEND ACKNOWLEDGES — 033-ACKEMIT ───────────────────────────────────────────────
|
|
2530
|
+
*
|
|
2531
|
+
* This session's OWN high-water mark (NOT an agent-global one) — the relay's seq_counter is per
|
|
2532
|
+
* session and rejects `last_seen_seq > seq_counter` — AND the content hash at that position,
|
|
2533
|
+
* read from the one entry that holds both.
|
|
2534
|
+
*/
|
|
2535
|
+
const lastSeen = this.#lastSeen.get(sessionIdHex);
|
|
2536
|
+
/**
|
|
2537
|
+
* ⚠️ **THIS COMMENT USED TO SAY "REFUSED, NOT DOWNGRADED", AND THE CODE UNDER IT DID REFUSE.**
|
|
2538
|
+
* It is rewritten rather than deleted because a comment asserting a refusal that no longer
|
|
2539
|
+
* happens is how the next reader comes to believe a guard exists where there is a fallback.
|
|
2540
|
+
*
|
|
2541
|
+
* There is no seed only when this session was registered with neither a genesis nor an
|
|
2542
|
+
* assignment to derive one from, AND nothing has been received on it. The claim that goes out
|
|
2543
|
+
* then is `last_seen_seq: 0` with no hash — "I have seen nothing of yours" — which is true and
|
|
2544
|
+
* asserts nothing about content, so it is not the unbacked number this unit exists to stop
|
|
2545
|
+
* signing. A claim that NAMES a position with no hash is the defect, and the receiving daemon
|
|
2546
|
+
* refuses exactly that.
|
|
2547
|
+
*/
|
|
2548
|
+
/**
|
|
2549
|
+
* ⚠️ NOT FOR A CARRIED LEAF — 034-CARRYLEAF review F8. This branch describes THIS agent having
|
|
2550
|
+
* nothing to acknowledge, and on a counter-submit the acknowledgement inside the bytes is the
|
|
2551
|
+
* AUTHOR's, already made. Logging "this submit acknowledges nothing" about it would be false,
|
|
2552
|
+
* and this daemon's own seed is irrelevant to a claim it did not write.
|
|
2553
|
+
*/
|
|
2554
|
+
/**
|
|
2555
|
+
* ⚠️ THE `session.relay.submit.unacknowledged` LOG THAT WAS HERE IS GONE, AND ITS ABSENCE IS
|
|
2556
|
+
* DELIBERATE — `DOD-M15-SELFCHAIN-1`.
|
|
2557
|
+
*
|
|
2558
|
+
* It fired on exactly the condition the refusal below fires on (no seed, not a carried leaf)
|
|
2559
|
+
* and told the operator "the leaf is witnessed as normal". That is now false: nothing is
|
|
2560
|
+
* witnessed, the submit is refused. Two lines about one condition, one of them wrong, is how a
|
|
2561
|
+
* reader ends up trusting the wrong one — so the refusal below is the only thing that speaks.
|
|
2562
|
+
*/
|
|
2563
|
+
// The published encoder from protocol-types — the ONE definition of the field order, pinned by
|
|
2564
|
+
// `structure1-canonical.json` (v1) and `structure1-v2-canonical.json` (v2). A second local copy
|
|
2565
|
+
// lived here until 020-ACKHASH; it drifted, and the drift was invisible because both copies
|
|
2566
|
+
// "worked": it encoded a timestamp above 2^32-1 as a CBOR float64 while the published encoder
|
|
2567
|
+
// (and every other TBS builder in this package) promotes it to a uint64. Same value, different
|
|
2568
|
+
// signed bytes, and only the vector said which was canonical.
|
|
2569
|
+
//
|
|
2570
|
+
// `lastSeenHash` is passed on EVERY send, so every claim this daemon signs is v2 and binds to
|
|
2571
|
+
// content. Nothing here ever passes `undefined` — see the refusal above.
|
|
2572
|
+
/**
|
|
2573
|
+
* ⚠️ **A CARRIED LEAF IS SENT VERBATIM AND SIGNED BY NOBODY HERE — 034-CARRYLEAF.**
|
|
2574
|
+
*
|
|
2575
|
+
* When this agent is witnessing something it RECEIVED, the claim already exists: its author
|
|
2576
|
+
* built it, signed it, and put it on the content frame. Re-encoding it would change the signed
|
|
2577
|
+
* bytes and the relay would refuse a leaf that is perfectly valid. Signing it ourselves would be
|
|
2578
|
+
* worse — it would turn their statement into ours, which is the one thing that must never happen
|
|
2579
|
+
* to a record whose whole value is that each party's words are their own.
|
|
2580
|
+
*
|
|
2581
|
+
* So this branch takes the bytes as they arrived, and this agent's own acknowledgement state is
|
|
2582
|
+
* deliberately NOT consulted: `last_seen_seq` and `last_seen_hash` inside those bytes are the
|
|
2583
|
+
* AUTHOR's account of what THEY had seen, and they are not ours to restate.
|
|
2584
|
+
*/
|
|
2585
|
+
/**
|
|
2586
|
+
* ─── BOTH CHAIN LINKS, UNCONDITIONALLY — `DOD-M15-SELFCHAIN-1` ───────────────────────────────
|
|
2587
|
+
*
|
|
2588
|
+
* `lastSeenHash` chains this sender to their COUNTERPARTY. `prevOwnHash` chains them to
|
|
2589
|
+
* THEMSELVES. Neither is optional: a claim carrying one link is not a shape this protocol has,
|
|
2590
|
+
* and `encodeStructure1` has no branch that emits one.
|
|
2591
|
+
*
|
|
2592
|
+
* ⚠️ THE CONDITIONAL SPREAD THAT USED TO BE HERE DEFEATED THE TYPE. Writing
|
|
2593
|
+
* `...(x ? { lastSeenHash: x } : {})` satisfies a REQUIRED field as far as the compiler is
|
|
2594
|
+
* concerned, so the one guard that should have made an unlinked claim impossible to write was
|
|
2595
|
+
* silently inert. Pass both by value; let `tsc` do its job.
|
|
2596
|
+
*
|
|
2597
|
+
* ⚠️ NO SEED MEANS NO SEND. The seed is this session's genesis — the agreed starting point both
|
|
2598
|
+
* links fall back to before anything has been said. A session registered with neither a genesis
|
|
2599
|
+
* nor an assignment to derive one from has no such point, and the honest outcome is to refuse
|
|
2600
|
+
* rather than sign a chain anchored to nothing. There is no degraded shape to fall back to and
|
|
2601
|
+
* deliberately so: an unlinked message is invisible until the conversation's order is disputed,
|
|
2602
|
+
* which is far too late for anyone to act on it.
|
|
2603
|
+
*/
|
|
2604
|
+
const seed = lastSeen?.hash;
|
|
2605
|
+
const genesis = this.#genesis.get(sessionIdHex);
|
|
2606
|
+
if (!carried && !seed) {
|
|
2607
|
+
this.#logger.error("session.relay.submit.unchainable", {
|
|
2608
|
+
relayPeerId: this.#relayPeerId,
|
|
2609
|
+
session: sessionIdHex,
|
|
2610
|
+
impact: "this session has no recorded starting point on this machine, so a message sent on it " +
|
|
2611
|
+
"could not link to anything and its place in the conversation could never be proven. " +
|
|
2612
|
+
"The message was NOT sent. Restart the session so it is registered with its genesis.",
|
|
2613
|
+
});
|
|
2614
|
+
return { ok: false, reason: "session_unchainable" };
|
|
2615
|
+
}
|
|
2616
|
+
/**
|
|
2617
|
+
* This agent's own previous message, or the session genesis when it has not spoken here yet.
|
|
2618
|
+
* "I have not spoken" is a VALUE — derived per session, so it cannot be presented for a
|
|
2619
|
+
* different conversation — never an absent field.
|
|
2620
|
+
*
|
|
2621
|
+
* The in-memory map first, then the durable store for the restart case, then the genesis. See
|
|
2622
|
+
* `#ownChain` for why a missing store degrades durability rather than blocking the send.
|
|
2623
|
+
*/
|
|
2624
|
+
let prevOwn;
|
|
2625
|
+
if (!carried) {
|
|
2626
|
+
/**
|
|
2627
|
+
* ⚠️ THE LAST FALLBACK IS THE GENESIS, NOT `seed` — and `seed` is what it used to be.
|
|
2628
|
+
*
|
|
2629
|
+
* `seed` is `#lastSeen`, which ADVANCES as the counterparty speaks. So the moment the
|
|
2630
|
+
* counterparty had said anything, a party sending their FIRST message linked to the
|
|
2631
|
+
* counterparty's message instead of to the session's starting point. The relay expects the
|
|
2632
|
+
* genesis for a sender's first leaf, so it refused — and then told the counterparty, who had
|
|
2633
|
+
* done nothing, that this side's chain was broken. Every two-party conversation died on its
|
|
2634
|
+
* second message and blamed the wrong party for it.
|
|
2635
|
+
*/
|
|
2636
|
+
prevOwn = this.#ownChain.get(sessionIdHex)
|
|
2637
|
+
?? this.#ownChainStore?.lastOwnHash(this.senderPubkeyHex, sessionIdHex)
|
|
2638
|
+
?? genesis;
|
|
2639
|
+
if (!prevOwn) {
|
|
2640
|
+
/**
|
|
2641
|
+
* Unreachable while a seed exists — both come from the same registration — but stated as a
|
|
2642
|
+
* refusal rather than a non-null assertion. The one thing that must never happen here is
|
|
2643
|
+
* signing a self link chosen because it was the nearest value to hand.
|
|
2644
|
+
*/
|
|
2645
|
+
this.#logger.error("session.relay.submit.unchainable", {
|
|
2646
|
+
relayPeerId: this.#relayPeerId,
|
|
2647
|
+
session: sessionIdHex,
|
|
2648
|
+
impact: "this session has an acknowledgement but no recorded starting point, so this agent's " +
|
|
2649
|
+
"own first message has nothing to link to. The message was NOT sent. Restart the " +
|
|
2650
|
+
"session so it is registered with its genesis.",
|
|
2651
|
+
});
|
|
2652
|
+
return { ok: false, reason: "session_unchainable" };
|
|
2653
|
+
}
|
|
2654
|
+
}
|
|
2655
|
+
/**
|
|
2656
|
+
* ⚠️ **A CARRIED LEAF IS SENT VERBATIM AND SIGNED BY NOBODY HERE — 034-CARRYLEAF.**
|
|
2657
|
+
*
|
|
2658
|
+
* When this agent is witnessing something it RECEIVED, the claim already exists: its author
|
|
2659
|
+
* built it, signed it, and put it on the content frame. Re-encoding it would change the signed
|
|
2660
|
+
* bytes and the relay would refuse a leaf that is perfectly valid. Signing it ourselves would be
|
|
2661
|
+
* worse — it would turn their statement into ours, which is the one thing that must never happen
|
|
2662
|
+
* to a record whose whole value is that each party's words are their own.
|
|
2663
|
+
*
|
|
2664
|
+
* So this branch takes the bytes as they arrived, and this agent's own chain state is
|
|
2665
|
+
* deliberately NOT consulted: both links inside those bytes are the AUTHOR's account, and they
|
|
2666
|
+
* are not ours to restate.
|
|
2667
|
+
*/
|
|
2668
|
+
const structure1 = carried ? carried.structure1Cbor : encodeStructure1({
|
|
2669
|
+
contentHash,
|
|
2670
|
+
senderPubkey: this.#senderPubkey,
|
|
2671
|
+
sessionId,
|
|
2672
|
+
lastSeenSeq: lastSeen.seq,
|
|
2673
|
+
timestamp: Date.now(),
|
|
2674
|
+
lastSeenHash: seed,
|
|
2675
|
+
prevOwnHash: prevOwn,
|
|
2676
|
+
});
|
|
2677
|
+
const signature = carried ? carried.senderSignature : await this.#keyProvider.sign(structure1);
|
|
2678
|
+
const frame = encodeCbor({
|
|
2679
|
+
type: "hash_submit",
|
|
2680
|
+
session_id: sessionId,
|
|
2681
|
+
leaf_kind: leafKind,
|
|
2682
|
+
structure1_cbor: structure1,
|
|
2683
|
+
sender_signature: signature,
|
|
2684
|
+
/**
|
|
2685
|
+
* `DOD-M15-SEALWIRE-1` bullets 3+4 — the SEAL payload, and ONLY on a ctrl leaf.
|
|
2686
|
+
*
|
|
2687
|
+
* ⚠️ MY REASON FOR THE SPREAD WAS MEASURABLY WRONG, AND THE TRUE RISK IS THE OPPOSITE ONE —
|
|
2688
|
+
* review pass 2, MEDIUM-3, corrected rather than deleted.
|
|
2689
|
+
*
|
|
2690
|
+
* It said an explicit `content_bytes: undefined` *"encodes as a present CBOR key, and the
|
|
2691
|
+
* relay's guard refuses a present-but-unusable value by voiding the whole frame — that would
|
|
2692
|
+
* turn every ordinary message into a refused submit."* Measured through the production encoder:
|
|
2693
|
+
* the key IS emitted (0xf7), but it decodes back to `undefined`, so the relay's guard never
|
|
2694
|
+
* fires and the frame is **accepted with no payload**.
|
|
2695
|
+
*
|
|
2696
|
+
* So the mutation does not produce a loud federation-wide refusal. It produces a silent
|
|
2697
|
+
* `not_carried` at the directory — exactly the silent downgrade this whole unit exists to kill,
|
|
2698
|
+
* and a far worse outcome than the one I warned about. Writing the scarier consequence would
|
|
2699
|
+
* have sent the next reader hunting an availability bug instead of a mute one.
|
|
2700
|
+
*
|
|
2701
|
+
* The spread is still correct, and the ANCHOR test is what pins it: `"content_bytes" in frame`
|
|
2702
|
+
* is TRUE for the `undefined` mutant precisely because the key is present, so that assertion —
|
|
2703
|
+
* not the relay — is what catches this.
|
|
2704
|
+
*
|
|
2705
|
+
* `submitLeaf` has already established that this is set if and only if `leafKind` is ctrl, and
|
|
2706
|
+
* that the bytes are a SEAL payload for this session hashing to the signed `content_hash` —
|
|
2707
|
+
* every direction refused there, at ERROR, before anything reaches the wire.
|
|
2708
|
+
*/
|
|
2709
|
+
...(contentBytes !== null ? { content_bytes: contentBytes } : {}),
|
|
2710
|
+
});
|
|
2711
|
+
// Set the resolver synchronously (no await between the in-flight check and the set):
|
|
2712
|
+
// the submit chain guarantees no other submit runs concurrently, so #pendingAck is null.
|
|
2713
|
+
let resolveAck;
|
|
2714
|
+
const ackPromise = new Promise((r) => { resolveAck = r; });
|
|
2715
|
+
this.#pendingAck = resolveAck;
|
|
2716
|
+
this.#pendingAckSessionHex = sessionIdHex;
|
|
2717
|
+
// Remember this submit's sender-signed structure1_cbor so its ack can return the full
|
|
2718
|
+
// ordering record (the ack itself carries only the relay's structure2_cbor).
|
|
2719
|
+
this.#pendingStructure1 = structure1;
|
|
2720
|
+
this.#pendingSignature = signature;
|
|
2721
|
+
this.#pendingLeafKind = leafKind;
|
|
2722
|
+
try {
|
|
2723
|
+
stream.send(lp.encode.single(frame));
|
|
2724
|
+
}
|
|
2725
|
+
catch (err) {
|
|
2726
|
+
if (this.#pendingAck === resolveAck) {
|
|
2727
|
+
this.#pendingAck = null;
|
|
2728
|
+
this.#pendingAckSessionHex = null;
|
|
2729
|
+
this.#pendingStructure1 = null;
|
|
2730
|
+
this.#pendingSignature = null;
|
|
2731
|
+
this.#pendingLeafKind = null;
|
|
2732
|
+
}
|
|
2733
|
+
this.#logger.warn("session.relay.submit.send.failed", { relayPeerId: this.#relayPeerId, error: extractErrorMessage(err) });
|
|
2734
|
+
return { ok: false, reason: "relay_submit_send_failed" };
|
|
2735
|
+
}
|
|
2736
|
+
let timer;
|
|
2737
|
+
const timeout = new Promise((r) => {
|
|
2738
|
+
timer = setTimeout(() => r({ ok: false, reason: "relay_submit_timeout" }), HASH_SUBMIT_TIMEOUT_MS);
|
|
2739
|
+
});
|
|
2740
|
+
try {
|
|
2741
|
+
const result = await Promise.race([ackPromise, timeout]);
|
|
2742
|
+
// On timeout, reset the stream so a late ack can't settle a LATER submit (FIFO desync).
|
|
2743
|
+
if (!result.ok && result.reason === "relay_submit_timeout")
|
|
2744
|
+
this.#resetStream();
|
|
2745
|
+
/**
|
|
2746
|
+
* ─── ADVANCE THE SELF CHAIN, AND ONLY ON SUCCESS — `DOD-M15-SELFCHAIN-1` ──────────────────
|
|
2747
|
+
*
|
|
2748
|
+
* Recorded AFTER the relay acknowledged, never before. Advancing first and then failing to
|
|
2749
|
+
* send would leave the chain pointing at a message that never existed, and every later message
|
|
2750
|
+
* would be refused by the counterparty with a reason that names tampering — an outage
|
|
2751
|
+
* reported as an attack.
|
|
2752
|
+
*
|
|
2753
|
+
* A retry therefore re-reads the same predecessor, which is exactly right: a retransmission is
|
|
2754
|
+
* the same message, not the next one.
|
|
2755
|
+
*
|
|
2756
|
+
* NOT for a carried leaf. Those bytes are the AUTHOR's and this agent did not write them, so
|
|
2757
|
+
* they are no part of this agent's own chain.
|
|
2758
|
+
*/
|
|
2759
|
+
if (result.ok && !carried) {
|
|
2760
|
+
// The map is the chain — always advanced. The store is its durability, advanced when wired.
|
|
2761
|
+
this.#ownChain.set(sessionIdHex, contentHash);
|
|
2762
|
+
try {
|
|
2763
|
+
this.#ownChainStore?.record(this.senderPubkeyHex, sessionIdHex, contentHash, Date.now());
|
|
2764
|
+
}
|
|
2765
|
+
catch (err) {
|
|
2766
|
+
this.#logger.error("session.selfchain.record.failed", {
|
|
2767
|
+
session: sessionIdHex,
|
|
2768
|
+
error: extractErrorMessage(err),
|
|
2769
|
+
impact: "this message was witnessed, but this agent could not record it as the link for its " +
|
|
2770
|
+
"next message — so the next one will chain to the wrong predecessor and the " +
|
|
2771
|
+
"counterparty will refuse it. Restart the session to re-anchor the chain.",
|
|
2772
|
+
});
|
|
2773
|
+
}
|
|
2774
|
+
}
|
|
2775
|
+
return result;
|
|
2776
|
+
}
|
|
2777
|
+
finally {
|
|
2778
|
+
clearTimeout(timer);
|
|
2779
|
+
if (this.#pendingAck === resolveAck) {
|
|
2780
|
+
this.#pendingAck = null;
|
|
2781
|
+
this.#pendingAckSessionHex = null;
|
|
2782
|
+
this.#pendingStructure1 = null;
|
|
2783
|
+
this.#pendingSignature = null;
|
|
2784
|
+
this.#pendingLeafKind = null;
|
|
2785
|
+
}
|
|
2786
|
+
}
|
|
2787
|
+
}
|
|
2788
|
+
/**
|
|
2789
|
+
* Advance this session's acknowledgement from a message that ARRIVED — 033-ACKEMIT review F1.
|
|
2790
|
+
*
|
|
2791
|
+
* ⚠️ **`#bumpLastSeen` used to have exactly one caller, inside the `leaf_deliver` handler, so the
|
|
2792
|
+
* acknowledgement tracked what the RELAY DELIVERED rather than what was RECEIVED.** On a direct
|
|
2793
|
+
* session that is a real difference: the content arrives peer-to-peer and the relay's copy of the
|
|
2794
|
+
* leaf follows separately, so until it did, this daemon signed an acknowledgement one message
|
|
2795
|
+
* behind what it had actually read — and on a session where delivery never came back at all, the
|
|
2796
|
+
* acknowledgement never moved.
|
|
2797
|
+
*
|
|
2798
|
+
* The order's own words are "the content hash of the last message this sender ACTUALLY RECEIVED".
|
|
2799
|
+
* This is the caller that makes that true: the receive path calls it as soon as a message has been
|
|
2800
|
+
* verified and ingested at a known canonical position.
|
|
2801
|
+
*
|
|
2802
|
+
* **THE POSITION IS STILL REQUIRED, and that is a real limit rather than an oversight.** The pair
|
|
2803
|
+
* is (position, content-at-position), and the relay refuses a `last_seen_seq` that runs ahead of
|
|
2804
|
+
* its counter — so a message that arrived with NO ordering record cannot be acknowledged by
|
|
2805
|
+
* position at all, whatever we hold of it. That case is the withheld-submit attack itself, and it
|
|
2806
|
+
* is closed by carrying the sender's signed leaf into the seal, not from here.
|
|
2807
|
+
*/
|
|
2808
|
+
noteReceivedLeaf(sessionIdHex, relaySeq, contentHash) {
|
|
2809
|
+
this.#bumpLastSeen(sessionIdHex, relaySeq, contentHash);
|
|
2810
|
+
}
|
|
2811
|
+
/**
|
|
2812
|
+
* The POSITION and the CONTENT AT IT together — 033-ACKEMIT.
|
|
2813
|
+
*
|
|
2814
|
+
* ⚠️ **IT REPLACED `lastSeenSeq()`, WHICH IS DELETED RATHER THAN LEFT WIRED.** That accessor
|
|
2815
|
+
* returned the position alone, and `session-node-manager`'s unwitnessed content claim was its
|
|
2816
|
+
* only caller. Leaving it in place after this one took over would leave a second way to read half
|
|
2817
|
+
* of a pair that must be read whole: a `last_seen_seq` paired with a `last_seen_hash` for a
|
|
2818
|
+
* different message is worse than no acknowledgement at all, because it looks checkable and
|
|
2819
|
+
* fails.
|
|
2820
|
+
*
|
|
2821
|
+
* `undefined` means this session has no acknowledgement to make, which the caller must handle
|
|
2822
|
+
* rather than fill in.
|
|
2823
|
+
*/
|
|
2824
|
+
lastSeenAck(sessionIdHex) {
|
|
2825
|
+
return this.#lastSeen.get(sessionIdHex);
|
|
2826
|
+
}
|
|
2827
|
+
/**
|
|
2828
|
+
* This agent's OWN last message on a session — what its next message's self link must name.
|
|
2829
|
+
*
|
|
2830
|
+
* ⚠️ EXPOSED BECAUSE THERE MUST BE ONE CHAIN, NOT TWO. The unwitnessed send path lives in
|
|
2831
|
+
* `session-node-manager` and was reading only the durable store, while the witnessed path reads
|
|
2832
|
+
* this in-memory map first. A session that mixed the two — which is every session where the relay
|
|
2833
|
+
* comes and goes — was walking two different chains, and the one that lagged produced a link the
|
|
2834
|
+
* counterparty refuses.
|
|
2835
|
+
*
|
|
2836
|
+
* `undefined` means this agent has not spoken on this session yet, which is the session GENESIS
|
|
2837
|
+
* and not an absence. The caller supplies it; this class does not guess.
|
|
2838
|
+
*/
|
|
2839
|
+
lastOwnHash(sessionIdHex) {
|
|
2840
|
+
return this.#ownChain.get(sessionIdHex)
|
|
2841
|
+
?? this.#ownChainStore?.lastOwnHash(this.senderPubkeyHex, sessionIdHex)
|
|
2842
|
+
?? undefined;
|
|
2843
|
+
}
|
|
2844
|
+
/**
|
|
2845
|
+
* Record what this agent just sent on a path this client did not carry — the UNWITNESSED send.
|
|
2846
|
+
*
|
|
2847
|
+
* The two paths share one chain (see `lastOwnHash`), so a direct send has to advance it here too.
|
|
2848
|
+
* Without this, a conversation that ran while the relay was down advanced nothing, and the first
|
|
2849
|
+
* witnessed message after it linked to something long superseded.
|
|
2850
|
+
*/
|
|
2851
|
+
noteOwnLeaf(sessionIdHex, contentHash) {
|
|
2852
|
+
this.#ownChain.set(sessionIdHex, contentHash);
|
|
2853
|
+
this.#ownChainStore?.record(this.senderPubkeyHex, sessionIdHex, contentHash, Date.now());
|
|
2854
|
+
}
|
|
2855
|
+
close() {
|
|
2856
|
+
this.#closed = true;
|
|
2857
|
+
this.#settlePending({ ok: false, reason: "relay_client_closed" });
|
|
2858
|
+
// Settle any in-flight record so #doRecord resolves promptly.
|
|
2859
|
+
{
|
|
2860
|
+
const r = this.#pendingRecord;
|
|
2861
|
+
this.#pendingRecord = null;
|
|
2862
|
+
if (r)
|
|
2863
|
+
r("closed");
|
|
2864
|
+
}
|
|
2865
|
+
const stream = this.#stream;
|
|
2866
|
+
this.#stream = null;
|
|
2867
|
+
if (stream) {
|
|
2868
|
+
try {
|
|
2869
|
+
void stream.close();
|
|
2870
|
+
}
|
|
2871
|
+
catch { /* best-effort */ }
|
|
2872
|
+
}
|
|
2873
|
+
}
|
|
2874
|
+
}
|
|
2875
|
+
_a = AgentRelayClient;
|
|
2876
|
+
//# sourceMappingURL=session-relay-client.js.map
|