agent-embassy 3.0.0 → 4.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (115) hide show
  1. package/CHANGELOG.md +42 -0
  2. package/CONTRIBUTING.md +92 -200
  3. package/README.md +159 -138
  4. package/SECURITY.md +191 -335
  5. package/dist/src/gateway/broker-check.d.ts +26 -0
  6. package/dist/src/gateway/broker-check.js +89 -0
  7. package/dist/src/gateway/broker-check.js.map +1 -0
  8. package/dist/src/gateway/broker-control.d.ts +72 -0
  9. package/dist/src/gateway/broker-control.js +164 -0
  10. package/dist/src/gateway/broker-control.js.map +1 -0
  11. package/dist/src/gateway/broker.d.ts +117 -0
  12. package/dist/src/gateway/broker.js +186 -0
  13. package/dist/src/gateway/broker.js.map +1 -0
  14. package/dist/src/gateway/claude-peer.d.ts +1 -124
  15. package/dist/src/gateway/claude-peer.js +62 -1053
  16. package/dist/src/gateway/claude-peer.js.map +1 -1
  17. package/dist/src/gateway/codex-stateless-transport.d.ts +0 -12
  18. package/dist/src/gateway/codex-stateless-transport.js +8 -142
  19. package/dist/src/gateway/codex-stateless-transport.js.map +1 -1
  20. package/dist/src/gateway/config.d.ts +2 -6
  21. package/dist/src/gateway/config.js +17 -27
  22. package/dist/src/gateway/config.js.map +1 -1
  23. package/dist/src/gateway/coordinator.d.ts +57 -0
  24. package/dist/src/gateway/coordinator.js +217 -0
  25. package/dist/src/gateway/coordinator.js.map +1 -0
  26. package/dist/src/gateway/core-cli.d.ts +15 -0
  27. package/dist/src/gateway/core-cli.js +282 -0
  28. package/dist/src/gateway/core-cli.js.map +1 -0
  29. package/dist/src/gateway/core-service-command.d.ts +16 -0
  30. package/dist/src/gateway/core-service-command.js +71 -0
  31. package/dist/src/gateway/core-service-command.js.map +1 -0
  32. package/dist/src/gateway/endpoint-directory.d.ts +33 -0
  33. package/dist/src/gateway/endpoint-directory.js +223 -0
  34. package/dist/src/gateway/endpoint-directory.js.map +1 -0
  35. package/dist/src/gateway/federation.d.ts +89 -0
  36. package/dist/src/gateway/federation.js +632 -0
  37. package/dist/src/gateway/federation.js.map +1 -0
  38. package/dist/src/gateway/ledger-codec.d.ts +3 -0
  39. package/dist/src/gateway/ledger-codec.js +159 -0
  40. package/dist/src/gateway/ledger-codec.js.map +1 -0
  41. package/dist/src/gateway/ledger.d.ts +138 -0
  42. package/dist/src/gateway/ledger.js +319 -0
  43. package/dist/src/gateway/ledger.js.map +1 -0
  44. package/dist/src/gateway/local-control.d.ts +23 -0
  45. package/dist/src/gateway/local-control.js +489 -0
  46. package/dist/src/gateway/local-control.js.map +1 -0
  47. package/dist/src/gateway/native-destinations.d.ts +34 -0
  48. package/dist/src/gateway/native-destinations.js +272 -0
  49. package/dist/src/gateway/native-destinations.js.map +1 -0
  50. package/dist/src/gateway/owned-state.d.ts +57 -0
  51. package/dist/src/gateway/owned-state.js +398 -0
  52. package/dist/src/gateway/owned-state.js.map +1 -0
  53. package/dist/src/gateway/provenance-envelope.d.ts +3 -3
  54. package/dist/src/gateway/provenance-envelope.js +3 -23
  55. package/dist/src/gateway/provenance-envelope.js.map +1 -1
  56. package/dist/src/gateway/runtime.d.ts +41 -0
  57. package/dist/src/gateway/runtime.js +175 -0
  58. package/dist/src/gateway/runtime.js.map +1 -0
  59. package/docs/CONFIGURATION.md +178 -266
  60. package/docs/DELIVERY.md +133 -82
  61. package/docs/GATEWAY-ARCHITECTURE.md +276 -943
  62. package/package.json +5 -5
  63. package/skills/embassy-peer/SKILL.md +38 -194
  64. package/skills/embassy-peer/agents/openai.yaml +2 -2
  65. package/dist/src/gateway/claude-helper-client.d.ts +0 -2
  66. package/dist/src/gateway/claude-helper-client.js +0 -2
  67. package/dist/src/gateway/claude-helper-client.js.map +0 -1
  68. package/dist/src/gateway/claude-helper-protocol.d.ts +0 -117
  69. package/dist/src/gateway/claude-helper-protocol.js +0 -122
  70. package/dist/src/gateway/claude-helper-protocol.js.map +0 -1
  71. package/dist/src/gateway/claude-helper-supervisor.d.ts +0 -87
  72. package/dist/src/gateway/claude-helper-supervisor.js +0 -347
  73. package/dist/src/gateway/claude-helper-supervisor.js.map +0 -1
  74. package/dist/src/gateway/claude-helper.d.ts +0 -1
  75. package/dist/src/gateway/claude-helper.js +0 -252
  76. package/dist/src/gateway/claude-helper.js.map +0 -1
  77. package/dist/src/gateway/cli.d.ts +0 -57
  78. package/dist/src/gateway/cli.js +0 -1503
  79. package/dist/src/gateway/cli.js.map +0 -1
  80. package/dist/src/gateway/codex-socket-holder.d.ts +0 -26
  81. package/dist/src/gateway/codex-socket-holder.js +0 -76
  82. package/dist/src/gateway/codex-socket-holder.js.map +0 -1
  83. package/dist/src/gateway/control.d.ts +0 -261
  84. package/dist/src/gateway/control.js +0 -903
  85. package/dist/src/gateway/control.js.map +0 -1
  86. package/dist/src/gateway/peer-client.d.ts +0 -58
  87. package/dist/src/gateway/peer-client.js +0 -217
  88. package/dist/src/gateway/peer-client.js.map +0 -1
  89. package/dist/src/gateway/peer-mailbox.d.ts +0 -78
  90. package/dist/src/gateway/peer-mailbox.js +0 -168
  91. package/dist/src/gateway/peer-mailbox.js.map +0 -1
  92. package/dist/src/gateway/peer-protocol.d.ts +0 -105
  93. package/dist/src/gateway/peer-protocol.js +0 -70
  94. package/dist/src/gateway/peer-protocol.js.map +0 -1
  95. package/dist/src/gateway/peer-stdio.d.ts +0 -23
  96. package/dist/src/gateway/peer-stdio.js +0 -100
  97. package/dist/src/gateway/peer-stdio.js.map +0 -1
  98. package/dist/src/gateway/providers.d.ts +0 -163
  99. package/dist/src/gateway/providers.js +0 -972
  100. package/dist/src/gateway/providers.js.map +0 -1
  101. package/dist/src/gateway/server.d.ts +0 -47
  102. package/dist/src/gateway/server.js +0 -219
  103. package/dist/src/gateway/server.js.map +0 -1
  104. package/dist/src/gateway/service.d.ts +0 -373
  105. package/dist/src/gateway/service.js +0 -2274
  106. package/dist/src/gateway/service.js.map +0 -1
  107. package/dist/src/gateway/status-view.d.ts +0 -167
  108. package/dist/src/gateway/status-view.js +0 -488
  109. package/dist/src/gateway/status-view.js.map +0 -1
  110. package/dist/src/gateway/store.d.ts +0 -185
  111. package/dist/src/gateway/store.js +0 -2359
  112. package/dist/src/gateway/store.js.map +0 -1
  113. package/dist/src/gateway/types.d.ts +0 -528
  114. package/dist/src/gateway/types.js +0 -341
  115. package/dist/src/gateway/types.js.map +0 -1
@@ -1,2359 +0,0 @@
1
- import { createHash, randomUUID } from "node:crypto";
2
- import { constants } from "node:fs";
3
- import { chmod, lstat, mkdir, open, readdir, realpath, rename, unlink } from "node:fs/promises";
4
- import os from "node:os";
5
- import path from "node:path";
6
- import { BridgeError } from "../errors.js";
7
- import { KeyedMutex } from "../mutex.js";
8
- import { peerRouteRef } from "./peer-protocol.js";
9
- import { deliveryStates, directionId, gatewayActivityActions, gatewayActivityKinds, gatewayProviders, gatewayPublicSnapshotLimits, gatewayRegistrationIngressPrefixes, parseDirection, projectGatewayPublicSnapshot, routeRegistrationModes } from "./types.js";
10
- const STATE_MARKER = ".agent-embassy-state";
11
- const STATE_MARKER_CONTENT = "agent-embassy-state-v1\n";
12
- const STATE_FILE = "gateway-state.json";
13
- /** Static dashboard files a 2.x install may have left behind (emb-100 removed the feature; emb-106 sweeps the litter). */
14
- const STALE_DASHBOARD_FILES = ["gateway-dashboard.html", "gateway-dashboard.zh-CN.html"];
15
- /** 2.x wrote its dashboards via temp-file + rename; a crash mid-publish could leave one of these behind too. */
16
- const STALE_DASHBOARD_TEMP_PATTERNS = [
17
- /^\.gateway-dashboard\.html\.[^/]+\.tmp$/,
18
- /^\.gateway-dashboard\.zh-CN\.html\.[^/]+\.tmp$/,
19
- ];
20
- function isStaleDashboardArtifact(name) {
21
- return STALE_DASHBOARD_FILES.includes(name) ||
22
- STALE_DASHBOARD_TEMP_PATTERNS.some((pattern) => pattern.test(name));
23
- }
24
- /**
25
- * Locks this store moved aside as stale, kept a week so a crash stays
26
- * diagnosable. The recovery time is in the name, not in the file's mtime:
27
- * `rename` carries the crashed lock's own timestamps across, so a broker that
28
- * ran for a month would have its lock swept by the very boot that recovered it.
29
- */
30
- const STALE_LOCK_PATTERN = /^\.gateway-controller\.lock\.stale-(\d{13,16})-[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
31
- const STALE_LOCK_RETENTION_MS = 7 * 24 * 60 * 60 * 1000;
32
- /** The recovery time encoded in a stale-lock name, or undefined if this is not one. */
33
- function staleLockRecoveredAt(name) {
34
- const found = STALE_LOCK_PATTERN.exec(name);
35
- return found === null ? undefined : Number(found[1]);
36
- }
37
- /** The controller lock's file name; the CLI reads the same file for a pid. */
38
- export const GATEWAY_CONTROLLER_LOCK_FILE = ".gateway-controller.lock";
39
- const CONTROLLER_LOCK = GATEWAY_CONTROLLER_LOCK_FILE;
40
- const MAX_MARKER_FILE_BYTES = 128;
41
- const MAX_LOCK_FILE_BYTES = 4 * 1024;
42
- export const GATEWAY_MAX_STATE_FILE_BYTES = 8 * 1024 * 1024;
43
- const DEFAULT_RETAINED_BODY_BYTES = 1 * 1024 * 1024;
44
- const ALIAS_PATTERN = /^[a-z][a-z0-9_-]{0,31}@[a-z0-9](?:[a-z0-9.-]{0,61}[a-z0-9])?$/;
45
- const HOST_PATTERN = /^[a-z0-9](?:[a-z0-9.-]{0,61}[a-z0-9])?$/;
46
- const PRIVATE_TOKEN_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,255}$/;
47
- const REGISTRATION_ID_PATTERN = /^reg_[A-Za-z0-9_-]{1,252}$/;
48
- const PEER_ROUTE_HANDLE_PATTERN = /^peer:[0-9a-f]{64}$/;
49
- const SAFE_CODE_PATTERN = /^[A-Z][A-Z0-9_]{0,63}$/;
50
- const MESSAGE_ID_PATTERN = /^msg_[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
51
- const MESSAGE_SUFFIX_PATTERN = /^[0-9a-f]{8}$/;
52
- const CONVERSATION_SUFFIX_PATTERN = /^[A-Za-z0-9_-]{8}$/;
53
- const FINGERPRINT_PATTERN = /^[A-Za-z0-9_-]{43}$/;
54
- const DELIVERY_TOKEN_PATTERN = /^dlv_[A-Za-z0-9_-]{24}$/;
55
- const SHA256_PATTERN = /^[0-9a-f]{64}$/;
56
- const PROVIDERS = new Set(gatewayProviders);
57
- const REGISTRATION_MODES = new Set(routeRegistrationModes);
58
- class PostRenamePersistenceError extends BridgeError {
59
- constructor() {
60
- super("GATEWAY_STATE_COMMIT_OUTCOME_UNKNOWN", "The installed state commit could not be verified. The controller was disabled and requires recovery.");
61
- this.name = "PostRenamePersistenceError";
62
- }
63
- }
64
- class CommitAndThrow {
65
- error;
66
- constructor(error) {
67
- this.error = error;
68
- }
69
- }
70
- function isObject(value) {
71
- return Boolean(value) && typeof value === "object" && !Array.isArray(value);
72
- }
73
- function hasOnlyKeys(value, required, optional = []) {
74
- const keys = Object.keys(value);
75
- return (required.every((key) => Object.hasOwn(value, key)) &&
76
- keys.every((key) => required.includes(key) || optional.includes(key)));
77
- }
78
- function isNonNegativeInteger(value) {
79
- return Number.isSafeInteger(value) && value >= 0;
80
- }
81
- function isPositiveInteger(value) {
82
- return Number.isSafeInteger(value) && value > 0;
83
- }
84
- const LOCK_HOSTNAME_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,254}$/;
85
- /**
86
- * A machine name bounded before it is compared or handed to a client hint.
87
- * Both sides of the comparison pass through here, so a local hostname the
88
- * pattern cannot represent (a name with a space, say) is never mistaken for
89
- * a different machine — it is simply unverifiable on both sides.
90
- */
91
- function boundedHostname(value) {
92
- return typeof value === "string" && LOCK_HOSTNAME_PATTERN.test(value) ? value : undefined;
93
- }
94
- const lockUnverified = () => new BridgeError("GATEWAY_STATE_LOCK_UNVERIFIED", "The gateway state lock exists but cannot be read as a controller record.");
95
- function defaultProcessLiveness(pid) {
96
- try {
97
- process.kill(pid, 0);
98
- return true;
99
- }
100
- catch (error) {
101
- // ESRCH is the only answer that means "gone"; EPERM means alive and not ours.
102
- return !(error instanceof Error && "code" in error && error.code === "ESRCH");
103
- }
104
- }
105
- function isIsoTimestamp(value) {
106
- return (typeof value === "string" &&
107
- value.length >= 20 &&
108
- value.length <= 35 &&
109
- Number.isFinite(Date.parse(value)));
110
- }
111
- function isPrivateToken(value) {
112
- return typeof value === "string" && PRIVATE_TOKEN_PATTERN.test(value);
113
- }
114
- function isSafeCode(value) {
115
- return typeof value === "string" && SAFE_CODE_PATTERN.test(value);
116
- }
117
- function isProvider(value) {
118
- return typeof value === "string" && PROVIDERS.has(value);
119
- }
120
- function isLogicalBinding(value) {
121
- return (isObject(value) &&
122
- hasOnlyKeys(value, ["provider", "hostId", "routeHandle", "registrationId"]) &&
123
- isProvider(value.provider) &&
124
- typeof value.hostId === "string" &&
125
- HOST_PATTERN.test(value.hostId) &&
126
- isPrivateToken(value.routeHandle) &&
127
- isPrivateToken(value.registrationId));
128
- }
129
- function isCounters(value) {
130
- if (!isObject(value) ||
131
- !hasOnlyKeys(value, ["accepted", "delivered", "unconfirmed", "failed",
132
- "ambiguous", "expired", "cancelled", "abandoned", "rejected", "bytesAccepted"])) {
133
- return false;
134
- }
135
- return Object.values(value).every(isNonNegativeInteger);
136
- }
137
- function isRoute(value) {
138
- return (isObject(value) &&
139
- hasOnlyKeys(value, [
140
- "alias",
141
- "binding",
142
- "registrationMode",
143
- "enabled",
144
- "busyPolicy",
145
- "registeredAt",
146
- "updatedAt",
147
- "counters",
148
- ]) &&
149
- typeof value.alias === "string" &&
150
- ALIAS_PATTERN.test(value.alias) &&
151
- isLogicalBinding(value.binding) &&
152
- REGISTRATION_ID_PATTERN.test(value.binding.registrationId) &&
153
- typeof value.registrationMode === "string" &&
154
- REGISTRATION_MODES.has(value.registrationMode) &&
155
- (value.binding.provider !== "peer" || value.registrationMode === "federated_peer" ||
156
- PEER_ROUTE_HANDLE_PATTERN.test(value.binding.routeHandle)) &&
157
- typeof value.enabled === "boolean" &&
158
- value.busyPolicy === "queue" &&
159
- isIsoTimestamp(value.registeredAt) &&
160
- isIsoTimestamp(value.updatedAt) &&
161
- isCounters(value.counters));
162
- }
163
- function peerMirrorRegistrationId(host, routeRef) {
164
- return `reg_peer_${createHash("sha256").update(`${host}\0${routeRef}`).digest("base64url").slice(0, 32)}`;
165
- }
166
- function routeModeMatchesHost(route, localHost) {
167
- if (route.registrationMode === "federated_peer") {
168
- // Without a known local host there is nothing to prove the mirror foreign
169
- // against, so the check abstains rather than refusing. The store always
170
- // supplies its configured host, so the shipped path always checks.
171
- return localHost === undefined || route.binding.hostId !== localHost;
172
- }
173
- return route.binding.hostId === localHost && (route.binding.provider === "claude"
174
- ? route.registrationMode === "selected_live_peer"
175
- : route.registrationMode === "explicit_opt_in");
176
- }
177
- function isPrepared(value) {
178
- return (isObject(value) &&
179
- hasOnlyKeys(value, ["kind", "bodyBytes", "bodySha256", "frameBytes", "sha256"]) &&
180
- (value.kind === "claude_mailbox" ||
181
- value.kind === "codex_turn_start" ||
182
- value.kind === "codex_turn_steer" ||
183
- value.kind === "peer_mailbox" ||
184
- value.kind === "peer_handoff") &&
185
- isPositiveInteger(value.bodyBytes) &&
186
- typeof value.bodySha256 === "string" &&
187
- SHA256_PATTERN.test(value.bodySha256) &&
188
- isPositiveInteger(value.frameBytes) &&
189
- value.frameBytes >= value.bodyBytes &&
190
- typeof value.sha256 === "string" &&
191
- SHA256_PATTERN.test(value.sha256));
192
- }
193
- function expectedPreparedKind(message, targetMode) {
194
- if (targetMode === "federated_peer")
195
- return "peer_handoff";
196
- const target = parseDirection(message.direction).targetProvider;
197
- if (target === "claude")
198
- return "claude_mailbox";
199
- if (target === "peer")
200
- return "peer_mailbox";
201
- return message.steer === true ? "codex_turn_steer" : "codex_turn_start";
202
- }
203
- function isAttemptAuthority(value) {
204
- return (typeof value.attemptId === "string" &&
205
- isPrivateToken(value.attemptId) &&
206
- isPositiveInteger(value.attemptCount) &&
207
- typeof value.targetRegistrationId === "string" &&
208
- isPrivateToken(value.targetRegistrationId) &&
209
- isPrivateToken(value.sourceRegistrationId));
210
- }
211
- function isMessageState(value) {
212
- if (!isObject(value) || typeof value.phase !== "string")
213
- return false;
214
- if (value.phase === "queued") {
215
- return hasOnlyKeys(value, ["phase", "attemptCount"]) &&
216
- isNonNegativeInteger(value.attemptCount);
217
- }
218
- if (value.phase === "reserved") {
219
- return (hasOnlyKeys(value, ["phase", "attemptId", "attemptCount", "targetRegistrationId",
220
- "sourceRegistrationId", "reservedAt"]) &&
221
- isAttemptAuthority(value) &&
222
- isIsoTimestamp(value.reservedAt));
223
- }
224
- if (value.phase === "armed") {
225
- return (hasOnlyKeys(value, ["phase", "attemptId", "attemptCount", "targetRegistrationId",
226
- "sourceRegistrationId", "armedAt", "prepared"]) &&
227
- isAttemptAuthority(value) &&
228
- isIsoTimestamp(value.armedAt) &&
229
- isPrepared(value.prepared));
230
- }
231
- if (value.phase === "accepted") {
232
- return (hasOnlyKeys(value, ["phase", "attemptId", "attemptCount", "targetRegistrationId",
233
- "sourceRegistrationId", "acceptedAt", "prepared", "lossOutcome"]) &&
234
- isAttemptAuthority(value) &&
235
- isIsoTimestamp(value.acceptedAt) &&
236
- isPrepared(value.prepared) &&
237
- (value.lossOutcome === "unconfirmed" || value.lossOutcome === "ambiguous"));
238
- }
239
- return (value.phase === "terminal" &&
240
- hasOnlyKeys(value, ["phase", "outcome", "terminalAt", "latencyMs"], ["safeErrorCode"]) &&
241
- [
242
- "delivered",
243
- "unconfirmed",
244
- "failed",
245
- "ambiguous",
246
- "expired",
247
- "cancelled",
248
- "abandoned",
249
- ].includes(String(value.outcome)) &&
250
- isIsoTimestamp(value.terminalAt) &&
251
- isNonNegativeInteger(value.latencyMs) &&
252
- (value.safeErrorCode === undefined || isSafeCode(value.safeErrorCode)));
253
- }
254
- function isMessage(value) {
255
- if (!isObject(value) ||
256
- !hasOnlyKeys(value, ["sequence", "messageId", "messageIdSuffix", "direction", "sourceAlias",
257
- "targetAlias", "enqueuedAt", "deadlineAt", "bytes", "sourceRegistrationId",
258
- "targetRegistrationId", "state"], ["conversationIdSuffix", "deliveryToken", "body", "steer"])) {
259
- return false;
260
- }
261
- return (isPositiveInteger(value.sequence) &&
262
- typeof value.messageId === "string" &&
263
- MESSAGE_ID_PATTERN.test(value.messageId) &&
264
- typeof value.messageIdSuffix === "string" &&
265
- MESSAGE_SUFFIX_PATTERN.test(value.messageIdSuffix) &&
266
- value.messageIdSuffix ===
267
- value.messageId.replaceAll("-", "").slice(-8).toLowerCase() &&
268
- parseDirection(value.direction) !== undefined &&
269
- typeof value.sourceAlias === "string" &&
270
- ALIAS_PATTERN.test(value.sourceAlias) &&
271
- typeof value.targetAlias === "string" &&
272
- ALIAS_PATTERN.test(value.targetAlias) &&
273
- isIsoTimestamp(value.enqueuedAt) &&
274
- isIsoTimestamp(value.deadlineAt) &&
275
- isPositiveInteger(value.bytes) &&
276
- (value.body === undefined ||
277
- (typeof value.body === "string" &&
278
- value.body.length > 0 &&
279
- !value.body.includes("\u0000") &&
280
- Buffer.byteLength(value.body, "utf8") === value.bytes)) &&
281
- (value.conversationIdSuffix === undefined ||
282
- (typeof value.conversationIdSuffix === "string" &&
283
- CONVERSATION_SUFFIX_PATTERN.test(value.conversationIdSuffix))) &&
284
- (value.deliveryToken === undefined ||
285
- (typeof value.deliveryToken === "string" &&
286
- DELIVERY_TOKEN_PATTERN.test(value.deliveryToken))) &&
287
- (value.steer === undefined || value.steer === true) &&
288
- isPrivateToken(value.sourceRegistrationId) &&
289
- (value.targetRegistrationId === null ||
290
- isPrivateToken(value.targetRegistrationId)) &&
291
- isMessageState(value.state) &&
292
- (value.state.phase === "terminal" ||
293
- value.state.attemptCount <=
294
- 1 +
295
- Math.max(0, Math.ceil((Date.parse(value.deadlineAt) -
296
- Date.parse(value.enqueuedAt)) /
297
- 500))) &&
298
- (value.state.phase === "terminal" ||
299
- (value.targetRegistrationId !== null && value.body !== undefined)));
300
- }
301
- function isDedupe(value) {
302
- return (isObject(value) &&
303
- hasOnlyKeys(value, ["fingerprint", "messageIdSuffix", "sourceAlias", "targetAlias", "direction",
304
- "firstSeenAt", "expiresAt"], ["conversationIdSuffix"]) &&
305
- typeof value.fingerprint === "string" &&
306
- FINGERPRINT_PATTERN.test(value.fingerprint) &&
307
- typeof value.messageIdSuffix === "string" &&
308
- MESSAGE_SUFFIX_PATTERN.test(value.messageIdSuffix) &&
309
- typeof value.sourceAlias === "string" &&
310
- ALIAS_PATTERN.test(value.sourceAlias) &&
311
- typeof value.targetAlias === "string" &&
312
- ALIAS_PATTERN.test(value.targetAlias) &&
313
- parseDirection(value.direction) !== undefined &&
314
- isIsoTimestamp(value.firstSeenAt) &&
315
- isIsoTimestamp(value.expiresAt) &&
316
- (value.conversationIdSuffix === undefined ||
317
- (typeof value.conversationIdSuffix === "string" &&
318
- CONVERSATION_SUFFIX_PATTERN.test(value.conversationIdSuffix))));
319
- }
320
- function isAccounting(value) {
321
- return (isObject(value) &&
322
- hasOnlyKeys(value, ["accepted", "duplicates", "delivered", "unconfirmed", "failed",
323
- "ambiguous", "expired", "cancelled", "abandoned", "rejected", "bytesAccepted", "queuedBytes"]) &&
324
- Object.values(value).every(isNonNegativeInteger));
325
- }
326
- function isNormalizedEvent(value) {
327
- if (!isObject(value) ||
328
- !hasOnlyKeys(value, ["sequence", "timestamp", "messageIdSuffix", "direction", "sourceAlias",
329
- "targetAlias", "state", "bytes"], ["conversationIdSuffix", "body", "steer", "latencyMs", "safeErrorCode"])) {
330
- return false;
331
- }
332
- return (isPositiveInteger(value.sequence) &&
333
- isIsoTimestamp(value.timestamp) &&
334
- typeof value.messageIdSuffix === "string" &&
335
- MESSAGE_SUFFIX_PATTERN.test(value.messageIdSuffix) &&
336
- parseDirection(value.direction) !== undefined &&
337
- typeof value.sourceAlias === "string" &&
338
- ALIAS_PATTERN.test(value.sourceAlias) &&
339
- typeof value.targetAlias === "string" &&
340
- ALIAS_PATTERN.test(value.targetAlias) &&
341
- deliveryStates.includes(value.state) &&
342
- isPositiveInteger(value.bytes) &&
343
- (value.conversationIdSuffix === undefined ||
344
- (typeof value.conversationIdSuffix === "string" &&
345
- CONVERSATION_SUFFIX_PATTERN.test(value.conversationIdSuffix))) &&
346
- (value.body === undefined ||
347
- (typeof value.body === "string" &&
348
- value.body.length > 0 &&
349
- !value.body.includes("\u0000") &&
350
- Buffer.byteLength(value.body, "utf8") === value.bytes)) &&
351
- (value.steer === undefined || value.steer === true) &&
352
- (value.latencyMs === undefined || isNonNegativeInteger(value.latencyMs)) &&
353
- (value.safeErrorCode === undefined || isSafeCode(value.safeErrorCode)));
354
- }
355
- function isRuntimeActivity(value) {
356
- if (!isObject(value) ||
357
- !hasOnlyKeys(value, ["type", "event"]) ||
358
- value.type !== "activity" ||
359
- !isObject(value.event) ||
360
- !hasOnlyKeys(value.event, ["sequence", "timestamp", "kind", "action", "outcome", "aliases", "operatorAction"], ["safeErrorCode"])) {
361
- return false;
362
- }
363
- const event = value.event;
364
- return (isPositiveInteger(event.sequence) &&
365
- isIsoTimestamp(event.timestamp) &&
366
- gatewayActivityKinds.includes(event.kind) &&
367
- gatewayActivityActions.includes(event.action) &&
368
- (event.outcome === "accepted" || event.outcome === "rejected") &&
369
- Array.isArray(event.aliases) &&
370
- event.aliases.length <= 2 &&
371
- event.aliases.every((alias) => typeof alias === "string" && ALIAS_PATTERN.test(alias)) &&
372
- typeof event.operatorAction === "boolean" &&
373
- (event.safeErrorCode === undefined || isSafeCode(event.safeErrorCode)));
374
- }
375
- function isMessageActivity(value) {
376
- return (isObject(value) &&
377
- hasOnlyKeys(value, ["type", "event"]) &&
378
- value.type === "message_activity" &&
379
- isNormalizedEvent(value.event));
380
- }
381
- /**
382
- * `configuredHost` is the broker's own host identity, known at construction and
383
- * always supplied on the shipped path. Deriving the local host from the routes
384
- * alone is not sufficient: a federation node whose only routes are mirrors has
385
- * no local route to derive from, and a state file it just wrote would fail its
386
- * own validator on the next persist.
387
- */
388
- export function isGatewayPersistedStateV5(value, configuredHost) {
389
- if (!isObject(value) ||
390
- // Exact keys: a file that still carries the retired `consentEdges` key is
391
- // refused here as a corrupt document; the documented recovery is a reset.
392
- !hasOnlyKeys(value, ["schemaVersion", "commit", "createdAt", "updatedAt", "eventSequence",
393
- "routes", "messages", "dedupe", "rateBuckets", "activity", "accounting"]) ||
394
- value.schemaVersion !== 5 ||
395
- !isObject(value.commit) ||
396
- !hasOnlyKeys(value.commit, ["sequence", "id"]) ||
397
- !isNonNegativeInteger(value.commit.sequence) ||
398
- !isPrivateToken(value.commit.id) ||
399
- !isIsoTimestamp(value.createdAt) ||
400
- !isIsoTimestamp(value.updatedAt) ||
401
- !isNonNegativeInteger(value.eventSequence) ||
402
- !Array.isArray(value.routes) ||
403
- !value.routes.every(isRoute) ||
404
- !Array.isArray(value.messages) ||
405
- !value.messages.every(isMessage) ||
406
- !Array.isArray(value.dedupe) ||
407
- !value.dedupe.every(isDedupe) ||
408
- !Array.isArray(value.rateBuckets) ||
409
- !value.rateBuckets.every((bucket) => isObject(bucket) &&
410
- hasOnlyKeys(bucket, ["sourceAlias", "windowStartedAt", "count"]) &&
411
- typeof bucket.sourceAlias === "string" && ALIAS_PATTERN.test(bucket.sourceAlias) &&
412
- isIsoTimestamp(bucket.windowStartedAt) && isNonNegativeInteger(bucket.count)) ||
413
- !Array.isArray(value.activity) ||
414
- !value.activity.every((entry) => isMessageActivity(entry) || isRuntimeActivity(entry)) ||
415
- !isAccounting(value.accounting)) {
416
- return false;
417
- }
418
- const state = value;
419
- const routesByAlias = new Map(state.routes.map((route) => [route.alias, route]));
420
- const registrationIds = state.routes.map((route) => route.binding.registrationId);
421
- const routeTargets = state.routes.map((route) => [route.binding.provider, route.binding.hostId, route.binding.routeHandle].join("\0"));
422
- const messages = state.messages;
423
- const activitySequences = state.activity.map((entry) => entry.event.sequence);
424
- const active = messages.filter((message) => message.state.phase !== "terminal");
425
- const queuedBytes = messages
426
- .filter((message) => message.state.phase === "queued")
427
- .reduce((total, message) => total + message.bytes, 0);
428
- const tokens = messages.flatMap((message) => message.deliveryToken ?? []);
429
- const attempts = messages.filter((message) => message.state.phase !== "queued" && message.state.phase !== "terminal");
430
- const attemptIds = attempts.map((message) => message.state.attemptId);
431
- if (routesByAlias.size !== state.routes.length ||
432
- new Set(registrationIds).size !== registrationIds.length ||
433
- new Set(routeTargets).size !== routeTargets.length ||
434
- new Set(messages.map((message) => message.messageId)).size !== messages.length ||
435
- new Set(tokens).size !== tokens.length ||
436
- new Set(attemptIds).size !== attempts.length ||
437
- state.accounting.queuedBytes !== queuedBytes ||
438
- messages.some((message) => message.sequence > state.eventSequence) ||
439
- activitySequences.some((sequence) => sequence > state.eventSequence) ||
440
- new Set([...messages.map((message) => message.sequence), ...activitySequences]).size !==
441
- messages.length + activitySequences.length ||
442
- new Set(state.dedupe.map((entry) => entry.fingerprint)).size !== state.dedupe.length ||
443
- new Set(state.rateBuckets.map((entry) => entry.sourceAlias)).size !== state.rateBuckets.length) {
444
- return false;
445
- }
446
- const localHosts = new Set(state.routes
447
- .filter((route) => route.registrationMode !== "federated_peer")
448
- .map((route) => route.binding.hostId));
449
- if (localHosts.size > 1)
450
- return false;
451
- // The configured host is authority; a local route's own hostId is checked
452
- // against it in the loop below, so the derived host is only the fallback for
453
- // a caller that has no configured identity to offer.
454
- const localHost = configuredHost ?? [...localHosts][0];
455
- for (const route of state.routes) {
456
- if (!route.alias.endsWith(`@${route.binding.hostId}`) ||
457
- !routeModeMatchesHost(route, localHost)) {
458
- return false;
459
- }
460
- const prefix = gatewayRegistrationIngressPrefixes[route.binding.provider];
461
- if (prefix !== undefined && !route.alias.startsWith(prefix))
462
- return false;
463
- }
464
- for (const message of active) {
465
- const target = routesByAlias.get(message.targetAlias);
466
- const source = routesByAlias.get(message.sourceAlias);
467
- const direction = parseDirection(message.direction);
468
- if (message.targetRegistrationId === null ||
469
- target?.binding.registrationId !== message.targetRegistrationId ||
470
- target.binding.provider !== direction.targetProvider ||
471
- source?.binding.registrationId !== message.sourceRegistrationId ||
472
- source.binding.provider !== direction.sourceProvider ||
473
- (source.binding.hostId === target.binding.hostId &&
474
- source.binding.provider === target.binding.provider)) {
475
- return false;
476
- }
477
- if ((message.state.phase === "reserved" ||
478
- message.state.phase === "armed" ||
479
- message.state.phase === "accepted") &&
480
- (message.state.sourceRegistrationId !== message.sourceRegistrationId ||
481
- message.state.targetRegistrationId !== message.targetRegistrationId)) {
482
- return false;
483
- }
484
- if ((message.state.phase === "armed" ||
485
- message.state.phase === "accepted") &&
486
- (message.state.prepared.kind !== expectedPreparedKind(message, target?.registrationMode) ||
487
- message.state.prepared.bodyBytes !== message.bytes ||
488
- message.state.prepared.bodySha256 !==
489
- createHash("sha256").update(message.body, "utf8").digest("hex"))) {
490
- return false;
491
- }
492
- }
493
- return true;
494
- }
495
- function emptyCounters() {
496
- return {
497
- accepted: 0, delivered: 0,
498
- unconfirmed: 0, failed: 0,
499
- ambiguous: 0, expired: 0,
500
- cancelled: 0, abandoned: 0,
501
- rejected: 0, bytesAccepted: 0,
502
- };
503
- }
504
- function emptyAccounting() {
505
- return {
506
- accepted: 0, duplicates: 0,
507
- delivered: 0, unconfirmed: 0,
508
- failed: 0, ambiguous: 0,
509
- expired: 0, cancelled: 0,
510
- abandoned: 0, rejected: 0,
511
- bytesAccepted: 0, queuedBytes: 0,
512
- };
513
- }
514
- function routeRecord(input, now) {
515
- const timestamp = now.toISOString();
516
- return { alias: input.alias, binding: { ...input.binding }, registrationMode: input.registrationMode,
517
- enabled: true, busyPolicy: "queue", registeredAt: timestamp, updatedAt: timestamp, counters: emptyCounters() };
518
- }
519
- function terminalState(outcome, now, enqueuedAt, safeErrorCode) {
520
- return {
521
- phase: "terminal",
522
- outcome,
523
- terminalAt: now.toISOString(),
524
- latencyMs: Math.max(0, now.getTime() - Date.parse(enqueuedAt)),
525
- ...(safeErrorCode === undefined ? {} : { safeErrorCode }),
526
- };
527
- }
528
- function sameBinding(left, right) {
529
- return (left.provider === right.provider &&
530
- left.hostId === right.hostId &&
531
- left.routeHandle === right.routeHandle &&
532
- left.registrationId === right.registrationId);
533
- }
534
- async function assertNoSymlinkComponents(candidate) {
535
- let cursor = path.resolve(candidate);
536
- while (true) {
537
- try {
538
- const info = await lstat(cursor);
539
- if (info.isSymbolicLink()) {
540
- throw new BridgeError("UNSAFE_GATEWAY_STATE_DIRECTORY", "The gateway state path cannot contain symbolic links.");
541
- }
542
- }
543
- catch (error) {
544
- if (!(error instanceof Error) || !("code" in error) || error.code !== "ENOENT") {
545
- throw error;
546
- }
547
- }
548
- const parent = path.dirname(cursor);
549
- if (parent === cursor)
550
- break;
551
- cursor = parent;
552
- }
553
- }
554
- async function canonicalFuturePath(candidate) {
555
- const absolute = path.resolve(candidate);
556
- const parsed = path.parse(absolute);
557
- const parts = absolute.slice(parsed.root.length).split(path.sep).filter(Boolean);
558
- let cursor = parsed.root;
559
- for (let index = 0; index < parts.length; index += 1) {
560
- const next = path.join(cursor, parts[index]);
561
- try {
562
- cursor = await realpath(next);
563
- }
564
- catch (error) {
565
- if (error instanceof Error && "code" in error && error.code === "ENOENT") {
566
- return path.join(cursor, ...parts.slice(index));
567
- }
568
- throw error;
569
- }
570
- }
571
- return cursor;
572
- }
573
- export class GatewayStore {
574
- config;
575
- rootDir;
576
- now;
577
- randomId;
578
- afterStateFileRename;
579
- renameStateFile;
580
- mutex = new KeyedMutex();
581
- state;
582
- lockHandle;
583
- lockToken;
584
- isProcessAlive;
585
- hostname;
586
- persistenceDeferred = false;
587
- /**
588
- * Registration ids that must never reach the durable state document. They
589
- * are ordinary rows in the in-memory state — admission, dispatch, queueing
590
- * and settlement all see them — and are projected out at the one place that
591
- * serializes, together with every row that names one. When such a
592
- * registration is removed, the same rows leave the live state in the same
593
- * mutate, before the id leaves this set; a rolled-back mutate restores the
594
- * set with the state. Nothing here is written down, so a restart reclaims
595
- * them by construction.
596
- */
597
- ephemeralRegistrations = new Set();
598
- constructor(config, dependencies = {}) {
599
- this.config = config;
600
- this.rootDir = path.resolve(config.stateDir);
601
- this.now = dependencies.now ?? (() => new Date());
602
- this.randomId = dependencies.randomId ?? randomUUID;
603
- this.renameStateFile = dependencies.renameStateFile ?? rename;
604
- this.afterStateFileRename = dependencies.afterStateFileRename;
605
- this.isProcessAlive = dependencies.isProcessAlive ?? defaultProcessLiveness;
606
- this.hostname = dependencies.hostname ?? (() => os.hostname());
607
- }
608
- get stateFilePath() {
609
- return path.join(this.rootDir, STATE_FILE);
610
- }
611
- async initialize(options = {}) {
612
- await this.mutex.run("gateway", async () => {
613
- if (this.state !== undefined)
614
- return;
615
- this.rootDir = await this.prepareOwnedDirectory();
616
- await this.acquireLock();
617
- try {
618
- const now = this.now();
619
- const loaded = await this.loadStateFile();
620
- this.state = loaded ?? {
621
- schemaVersion: 5,
622
- commit: { sequence: 0, id: this.randomId() },
623
- createdAt: now.toISOString(), updatedAt: now.toISOString(),
624
- eventSequence: 0, routes: [],
625
- messages: [],
626
- dedupe: [], rateBuckets: [],
627
- activity: [], accounting: emptyAccounting(),
628
- };
629
- this.assertConfiguredBounds(this.state);
630
- await this.removeStateDirectoryLitter(this.rootDir);
631
- this.recoverAfterRestart(now);
632
- this.prune(this.state, now);
633
- if (loaded !== undefined) {
634
- this.state.commit = {
635
- sequence: this.state.commit.sequence + 1, id: this.randomId(),
636
- };
637
- this.state.updatedAt = now.toISOString();
638
- }
639
- this.persistenceDeferred = options.deferPersistence === true;
640
- if (!this.persistenceDeferred)
641
- await this.persist();
642
- }
643
- catch (error) {
644
- this.state = undefined;
645
- this.persistenceDeferred = false;
646
- await this.releaseControllerLock();
647
- throw error;
648
- }
649
- });
650
- }
651
- async commitInitialization() {
652
- await this.mutex.run("gateway", async () => {
653
- this.requireState();
654
- if (!this.persistenceDeferred)
655
- return;
656
- await this.persist(true);
657
- this.persistenceDeferred = false;
658
- });
659
- }
660
- async close() {
661
- await this.mutex.run("gateway", async () => {
662
- this.state = undefined;
663
- this.persistenceDeferred = false;
664
- await this.releaseControllerLock();
665
- });
666
- }
667
- async inspectPrivateRoutes() {
668
- return this.read((state) => state.routes.map((route) => ({
669
- alias: route.alias, binding: { ...route.binding },
670
- registrationMode: route.registrationMode, enabled: route.enabled,
671
- })));
672
- }
673
- async listLogicalRoutes() {
674
- return this.inspectPrivateRoutes();
675
- }
676
- async inspectPrivateRoute(alias) {
677
- return this.read((state) => {
678
- const route = state.routes.find((candidate) => candidate.alias === alias);
679
- return route === undefined
680
- ? undefined
681
- : {
682
- alias: route.alias, binding: { ...route.binding },
683
- registrationMode: route.registrationMode, enabled: route.enabled,
684
- };
685
- });
686
- }
687
- async reconcilePeerCatalog(peerHost, catalog) {
688
- return this.mutate((state, now) => {
689
- const localHost = this.config.hostId;
690
- if (!this.config.allowedHosts.includes(peerHost) || peerHost === localHost ||
691
- !catalog.complete || catalog.truncated || catalog.routes.some((route) => route.host !== peerHost)) {
692
- throw new BridgeError("INVALID_PEER_CATALOG", "The peer catalog is not an exact complete projection for this configured host.");
693
- }
694
- const desired = new Map(catalog.routes.map((route) => [route.ref, route]));
695
- const existing = state.routes.filter((route) => route.registrationMode === "federated_peer" && route.binding.hostId === peerHost);
696
- const settlements = [];
697
- for (const route of existing) {
698
- const row = desired.get(route.binding.routeHandle);
699
- if (row !== undefined && row.provider === route.binding.provider)
700
- continue;
701
- settlements.push(...this.terminalizeRegistration(state, route.binding.registrationId, now));
702
- this.removeRegistrationMetadata(state, route);
703
- }
704
- for (const row of catalog.routes) {
705
- let route = state.routes.find((candidate) => candidate.registrationMode === "federated_peer" &&
706
- candidate.binding.hostId === peerHost && candidate.binding.routeHandle === row.ref &&
707
- candidate.binding.provider === row.provider);
708
- const collision = state.routes.find((candidate) => candidate.alias === row.alias && candidate !== route);
709
- if (collision !== undefined)
710
- throw new BridgeError("ROUTE_ALIAS_ALREADY_REGISTERED", "A peer catalog alias conflicts with current local authority.");
711
- if (route === undefined) {
712
- if (state.routes.length >= this.config.limits.maxRoutes)
713
- throw new BridgeError("ROUTE_CAPACITY_REACHED", "The bounded logical route inventory is full.", true);
714
- const input = { alias: row.alias, registrationMode: "federated_peer",
715
- binding: { provider: row.provider, hostId: peerHost, routeHandle: row.ref,
716
- registrationId: peerMirrorRegistrationId(peerHost, row.ref) } };
717
- route = routeRecord(input, now);
718
- state.routes.push(route);
719
- }
720
- else if (route.alias !== row.alias) {
721
- this.renameRegistrationCoordinates(state, route.alias, row.alias, route.binding.registrationId);
722
- route.alias = row.alias;
723
- }
724
- route.enabled = row.enabled;
725
- route.updatedAt = now.toISOString();
726
- }
727
- return { settlements, routes: state.routes.filter((route) => route.registrationMode === "federated_peer" &&
728
- route.binding.hostId === peerHost).map((route) => ({ alias: route.alias, binding: { ...route.binding },
729
- registrationMode: route.registrationMode, enabled: route.enabled })) };
730
- });
731
- }
732
- async enqueuePeerHandoff(peerHost, handoff) {
733
- return this.mutate((state, now) => {
734
- const localHost = this.config.hostId;
735
- // A handoff is authorized by the sending host's nodes.json membership
736
- // (checked by the service before this call) plus exact alias addressing
737
- // to a mirrored source and a local, enabled target.
738
- if (!this.config.allowedHosts.includes(peerHost) || peerHost === localHost || handoff.source.host !== peerHost ||
739
- handoff.target.host !== localHost)
740
- throw new BridgeError("INVALID_PEER_HANDOFF", "The peer handoff does not match this configured direct link.");
741
- const source = state.routes.find((route) => route.registrationMode === "federated_peer" && route.binding.hostId === peerHost &&
742
- route.alias === handoff.source.alias && route.binding.provider === handoff.source.provider && route.binding.routeHandle === handoff.source.routeRef);
743
- const target = state.routes.find((route) => route.registrationMode !== "federated_peer" && route.binding.hostId === localHost &&
744
- route.alias === handoff.target.alias && route.binding.provider === handoff.target.provider &&
745
- peerRouteRef(localHost, route.binding.registrationId) === handoff.target.routeRef && route.enabled);
746
- if (source === undefined || target === undefined)
747
- throw new BridgeError("ROUTE_UNREGISTERED", "The peer handoff endpoint is no longer current.");
748
- return this.enqueueResolved(state, now, { body: handoff.body, dedupeKey: `${peerHost}:${handoff.originMessageId}`,
749
- deadlineAt: handoff.deadlineAt, ...(handoff.conversationCorrelation === undefined ? {} :
750
- { conversationIdSuffix: handoff.conversationCorrelation }), ...(handoff.steer === true ? { steer: true } : {}) }, { sourceAlias: source.alias, targetAlias: target.alias, direction: directionId(source.binding.provider, target.binding.provider),
751
- sourceRegistrationId: source.binding.registrationId, targetRegistrationId: target.binding.registrationId,
752
- exposeDeliveryToken: false });
753
- });
754
- }
755
- async registerRoute(input) {
756
- await this.mutate((state, now) => {
757
- this.validateRouteInput(input);
758
- const byAlias = state.routes.find((route) => route.alias === input.alias);
759
- if (byAlias !== undefined) {
760
- if (sameBinding(byAlias.binding, input.binding) &&
761
- byAlias.registrationMode === input.registrationMode) {
762
- byAlias.enabled = true;
763
- byAlias.updatedAt = now.toISOString();
764
- return;
765
- }
766
- throw new BridgeError("ROUTE_ALIAS_ALREADY_REGISTERED", "The route alias already belongs to another logical registration.");
767
- }
768
- if (state.routes.some((route) => route.binding.registrationId === input.binding.registrationId ||
769
- (route.binding.provider === input.binding.provider &&
770
- route.binding.hostId === input.binding.hostId &&
771
- route.binding.routeHandle === input.binding.routeHandle))) {
772
- throw new BridgeError("ROUTE_IDENTITY_ALREADY_REGISTERED", "The logical provider identity is already registered under another alias.");
773
- }
774
- if (state.routes.length >= this.config.limits.maxRoutes) {
775
- throw new BridgeError("ROUTE_CAPACITY_REACHED", "The bounded logical route inventory is full.", true);
776
- }
777
- state.routes.push(routeRecord(input, now));
778
- if (input.ephemeral === true)
779
- this.ephemeralRegistrations.add(input.binding.registrationId);
780
- });
781
- }
782
- /** Whether this registration is memory-only; false for every durable route. */
783
- isEphemeralRegistration(registrationId) {
784
- return this.ephemeralRegistrations.has(registrationId);
785
- }
786
- /**
787
- * Everything an ephemeral registration owns, taken out of `state`: the route
788
- * itself, every message that names it as source or target, the dedupe
789
- * fingerprints and rate bucket keyed by its alias, the journal rows —
790
- * message activity and runtime activity — that name the alias, and the
791
- * queued-bytes total those messages contributed. Pure: the caller decides
792
- * whether this becomes the written projection or the live state.
793
- */
794
- ephemeralRowsRemoved(state, ids, aliases) {
795
- const kept = (message) => !ids.has(message.sourceRegistrationId) &&
796
- (message.targetRegistrationId === null || !ids.has(message.targetRegistrationId));
797
- const names = (row) => aliases.has(row.sourceAlias) || aliases.has(row.targetAlias);
798
- const droppedQueuedBytes = state.messages
799
- .filter((message) => !kept(message) && message.state.phase === "queued")
800
- .reduce((total, message) => total + message.bytes, 0);
801
- return {
802
- routes: state.routes.filter((route) => !ids.has(route.binding.registrationId)),
803
- messages: state.messages.filter(kept),
804
- dedupe: state.dedupe.filter((row) => !names(row)),
805
- rateBuckets: state.rateBuckets.filter((row) => !aliases.has(row.sourceAlias)),
806
- activity: state.activity.filter((entry) => entry.type === "message_activity"
807
- ? !names(entry.event)
808
- : !entry.event.aliases.some((alias) => aliases.has(alias))),
809
- accounting: { ...state.accounting,
810
- queuedBytes: state.accounting.queuedBytes - droppedQueuedBytes },
811
- };
812
- }
813
- /**
814
- * The document actually written. While an ephemeral registration lives, its
815
- * rows are projected out here, so the written state stays internally
816
- * consistent under the strict loader — which refuses a state whose active
817
- * message points at a route it cannot find, or whose queued total disagrees
818
- * with its queued rows. Both refusals are exactly right, and neither must
819
- * ever be reachable by a document we wrote. When the registration is
820
- * removed, `removeRegistrationMetadata` takes the same rows out of the live
821
- * state in the same mutate, so nothing of it is written afterwards either.
822
- */
823
- persistedProjection(state) {
824
- if (this.ephemeralRegistrations.size === 0)
825
- return state;
826
- const dropped = state.routes.filter((route) => this.ephemeralRegistrations.has(route.binding.registrationId));
827
- if (dropped.length === 0)
828
- return state;
829
- return { ...state, ...this.ephemeralRowsRemoved(state, new Set(dropped.map((route) => route.binding.registrationId)), new Set(dropped.map((route) => route.alias))) };
830
- }
831
- async removeOwnedRouteAtomic(input) {
832
- return this.mutate((state, now) => {
833
- if (!ALIAS_PATTERN.test(input.alias) || !isLogicalBinding(input.binding)) {
834
- throw new BridgeError("INVALID_ROUTE_BINDING", "The exact-owned route cleanup authority is malformed.");
835
- }
836
- const route = state.routes.find((candidate) => candidate.alias === input.alias);
837
- if (route === undefined || !sameBinding(route.binding, input.binding)) {
838
- return { removed: false, settlements: [] };
839
- }
840
- if (route.registrationMode === "federated_peer") {
841
- throw new BridgeError("FEDERATED_ROUTE_READ_ONLY", "A federated route is owned by its peer node; only catalog reconciliation may retire it.");
842
- }
843
- if (input.activity !== undefined && route.binding.provider !== "codex") {
844
- throw new BridgeError("INVALID_ROUTE_BINDING", "Only exact Codex unregister has public activity.");
845
- }
846
- const settlements = this.terminalizeRegistration(state, route.binding.registrationId, now);
847
- this.removeRegistrationMetadata(state, route);
848
- if (input.activity !== undefined) {
849
- this.appendRuntimeActivity(state, now, {
850
- kind: "registration", action: "codex_unregistered",
851
- outcome: "accepted", aliases: [route.alias],
852
- operatorAction: input.activity.operatorAction,
853
- });
854
- }
855
- return { removed: true, settlements };
856
- });
857
- }
858
- async replaceCodexRegistrationAtomic(input) {
859
- return this.mutate((state, now) => {
860
- this.validateRouteInput(input.replacement);
861
- if (!isPrivateToken(input.expectedOldRegistrationId)) {
862
- throw new BridgeError("INVALID_ROUTE_BINDING", "The expected succeeded registration identity is malformed.");
863
- }
864
- if (input.replacement.binding.provider !== "codex") {
865
- throw new BridgeError("CODEX_SUCCESSION_OWNER_MISMATCH", "Only a Codex logical registration can succeed another Codex registration.");
866
- }
867
- const oldRoute = state.routes.find((route) => route.alias === input.oldAlias);
868
- const installed = state.routes.find((route) => route.alias === input.replacement.alias);
869
- if (oldRoute === undefined) {
870
- if (installed !== undefined &&
871
- sameBinding(installed.binding, input.replacement.binding) &&
872
- installed.registrationMode === input.replacement.registrationMode) {
873
- return {
874
- replaced: true, idempotent: true,
875
- settlements: [],
876
- };
877
- }
878
- throw new BridgeError("CODEX_SUCCESSION_OWNER_MISMATCH", "The succeeded Codex registration is no longer installed.");
879
- }
880
- if (oldRoute.binding.registrationId !== input.expectedOldRegistrationId) {
881
- throw new BridgeError("ROUTE_UNREGISTERED", "The succeeded Codex registration is no longer the expected owner.");
882
- }
883
- if (oldRoute.binding.provider !== "codex" ||
884
- installed !== undefined ||
885
- oldRoute.binding.hostId !== input.replacement.binding.hostId ||
886
- oldRoute.alias === input.replacement.alias ||
887
- oldRoute.binding.registrationId ===
888
- input.replacement.binding.registrationId ||
889
- oldRoute.binding.routeHandle === input.replacement.binding.routeHandle) {
890
- throw new BridgeError("CODEX_SUCCESSION_OWNER_MISMATCH", "The successor must be a distinct Codex task and alias on the same host.");
891
- }
892
- const collision = state.routes.some((route) => route !== oldRoute &&
893
- (route.binding.registrationId ===
894
- input.replacement.binding.registrationId ||
895
- (route.binding.provider === "codex" &&
896
- route.binding.hostId === input.replacement.binding.hostId &&
897
- route.binding.routeHandle === input.replacement.binding.routeHandle)));
898
- if (collision) {
899
- throw new BridgeError("CODEX_SUCCESSION_OWNER_MISMATCH", "The successor logical identity is already registered.");
900
- }
901
- const settlements = this.terminalizeRegistration(state, oldRoute.binding.registrationId, now);
902
- this.removeRegistrationMetadata(state, oldRoute);
903
- state.routes.push(routeRecord(input.replacement, now));
904
- this.appendRuntimeActivity(state, now, {
905
- kind: "registration", action: "codex_succeeded",
906
- outcome: "accepted",
907
- aliases: [oldRoute.alias, input.replacement.alias],
908
- operatorAction: input.activity.operatorAction,
909
- });
910
- return { replaced: true, idempotent: false, settlements };
911
- });
912
- }
913
- /**
914
- * Installs the route of a discovered Claude session on its first send, or
915
- * brings an existing route up to date with discovery. The (host, session
916
- * UUID) pair is the identity: a session already bound keeps its
917
- * registration (and its in-flight conversations) and is renamed if its
918
- * current name changed; a route whose alias now names a different session
919
- * is displaced, its work settled `cancelled/ENDPOINT_RETIRED`. Both
920
- * outcomes are journaled so `embassy status` can show them. Installation
921
- * never creates a permission: same-UID, same-host addressing already is
922
- * the permission, and the service's alias-collision fence has already run.
923
- */
924
- async installClaudeRoute(replacement) {
925
- return this.mutate((state, now) => {
926
- this.validateRouteInput(replacement);
927
- if (replacement.binding.provider !== "claude") {
928
- throw new BridgeError("INVALID_ROUTE_BINDING", "Only a Claude logical route installs on first send.");
929
- }
930
- const byAlias = state.routes.find((route) => route.alias === replacement.alias);
931
- const byRegistration = state.routes.find((route) => route.binding.registrationId === replacement.binding.registrationId);
932
- const byIdentity = state.routes.find((route) => route.binding.provider === "claude" &&
933
- route.binding.hostId === replacement.binding.hostId &&
934
- route.binding.routeHandle === replacement.binding.routeHandle);
935
- if (byRegistration !== undefined && byRegistration !== byIdentity) {
936
- throw new BridgeError("ROUTE_IDENTITY_ALREADY_REGISTERED", "The Claude registration ID and native session do not identify one route.");
937
- }
938
- if (byIdentity !== undefined &&
939
- byIdentity.binding.registrationId !== replacement.binding.registrationId) {
940
- throw new BridgeError("ROUTE_IDENTITY_ALREADY_REGISTERED", "The native Claude session is already registered under another identity.");
941
- }
942
- const settlements = [];
943
- if (byAlias !== undefined && byAlias !== byIdentity) {
944
- if (byAlias.binding.provider !== "claude" || byAlias.registrationMode === "federated_peer") {
945
- throw new BridgeError("ROUTE_ALIAS_ALREADY_REGISTERED", "The alias belongs to a route that a Claude session cannot displace.");
946
- }
947
- settlements.push(...this.terminalizeRegistration(state, byAlias.binding.registrationId, now, "ENDPOINT_RETIRED"));
948
- this.removeRegistrationMetadata(state, byAlias);
949
- this.appendRuntimeActivity(state, now, {
950
- kind: "registration", action: "claude_route_retired",
951
- outcome: "accepted", aliases: [byAlias.alias], operatorAction: true,
952
- });
953
- }
954
- if (byIdentity === undefined) {
955
- if (state.routes.length >= this.config.limits.maxRoutes) {
956
- throw new BridgeError("ROUTE_CAPACITY_REACHED", "The bounded logical route inventory is full.", true);
957
- }
958
- state.routes.push(routeRecord(replacement, now));
959
- this.appendRuntimeActivity(state, now, {
960
- kind: "registration", action: "claude_route_installed",
961
- outcome: "accepted", aliases: [replacement.alias], operatorAction: true,
962
- });
963
- return { installed: true, settlements };
964
- }
965
- const priorAlias = byIdentity.alias;
966
- if (priorAlias !== replacement.alias) {
967
- this.renameRegistrationCoordinates(state, priorAlias, replacement.alias, byIdentity.binding.registrationId);
968
- byIdentity.alias = replacement.alias;
969
- this.appendRuntimeActivity(state, now, {
970
- kind: "registration", action: "claude_route_installed",
971
- outcome: "accepted", aliases: [priorAlias, replacement.alias], operatorAction: true,
972
- });
973
- }
974
- byIdentity.enabled = true;
975
- byIdentity.updatedAt = now.toISOString();
976
- return { installed: priorAlias !== replacement.alias, settlements };
977
- });
978
- }
979
- /**
980
- * The refusal half of admission, without the write. A send to a Claude
981
- * session that has no route yet would otherwise install one — displacing
982
- * another session and settling its queued work — on its way to being
983
- * refused for its own reasons: too large, past the deadline window, rate
984
- * limited, or into a full queue. Callers that are about to materialize a
985
- * route run this first and materialize only if it returns.
986
- *
987
- * The refusal is still journaled, because a rejection is a fact about a real
988
- * attempt; nothing else is written, and the authoritative checks still run
989
- * inside `enqueueMessage` itself. `dedupeKey` is optional: a first send's key
990
- * is broker-generated and cannot collide, and omitting it keeps this from
991
- * inventing one to test against.
992
- */
993
- async assertEnqueueAdmissible(input) {
994
- const bytes = Buffer.byteLength(typeof input.body === "string" ? input.body : "", "utf8");
995
- const refusal = await this.read((state) => {
996
- const now = this.now();
997
- if (bytes > this.config.limits.maxMessageBytes)
998
- return "MESSAGE_TOO_LARGE";
999
- const deadlineAt = input.deadlineAt ??
1000
- new Date(now.getTime() + this.config.limits.messageDeadlineMs).toISOString();
1001
- if (!isIsoTimestamp(deadlineAt) || Date.parse(deadlineAt) <= now.getTime() ||
1002
- Date.parse(deadlineAt) > now.getTime() + this.config.limits.messageDeadlineMs) {
1003
- return "INVALID_DEADLINE";
1004
- }
1005
- // Read-only rate probe: the real enqueue consumes the token, this only
1006
- // asks whether one is available.
1007
- const bucket = state.rateBuckets.find((row) => row.sourceAlias === input.sourceAlias);
1008
- const windowOpen = bucket === undefined ||
1009
- now.getTime() - Date.parse(bucket.windowStartedAt) >= this.config.limits.rateWindowMs;
1010
- if (windowOpen
1011
- ? state.rateBuckets.filter((row) => row.sourceAlias !== input.sourceAlias).length >=
1012
- this.config.limits.maxRoutes
1013
- : bucket.count >= this.config.limits.rateLimitPerRoute) {
1014
- return "GATEWAY_RATE_LIMITED";
1015
- }
1016
- const active = state.messages.filter((message) => message.state.phase !== "terminal");
1017
- if (active.length >= this.config.limits.maxQueueMessages ||
1018
- active.filter((message) => message.targetAlias === input.targetAlias).length >=
1019
- this.config.limits.maxQueueMessagesPerRoute ||
1020
- state.accounting.queuedBytes + bytes > this.config.limits.maxQueueBytes) {
1021
- return "GATEWAY_QUEUE_FULL";
1022
- }
1023
- return undefined;
1024
- });
1025
- if (refusal === undefined)
1026
- return;
1027
- await this.mutate((state, now) => {
1028
- this.recordRejection(state, { sourceAlias: input.sourceAlias, targetAlias: input.targetAlias,
1029
- direction: input.direction, sourceRegistrationId: input.sourceRegistrationId,
1030
- targetRegistrationId: input.sourceRegistrationId }, bytes, now, refusal, input);
1031
- });
1032
- throw new BridgeError(refusal, refusal === "MESSAGE_TOO_LARGE" ? "The message exceeds the configured byte bound."
1033
- : refusal === "INVALID_DEADLINE" ? "The message deadline must fall inside the configured delivery window."
1034
- : refusal === "GATEWAY_RATE_LIMITED" ? "The source exceeded the bounded gateway rate window."
1035
- : "The bounded gateway queue is full.", refusal === "GATEWAY_RATE_LIMITED" || refusal === "GATEWAY_QUEUE_FULL");
1036
- }
1037
- async enqueueMessage(input) {
1038
- return this.mutate((state, now) => {
1039
- const hasExpectedSource = input.expectedSourceRegistrationId !== undefined;
1040
- const hasExpectedTarget = input.expectedTargetRegistrationId !== undefined;
1041
- if (hasExpectedSource !== hasExpectedTarget ||
1042
- (hasExpectedSource &&
1043
- (!isPrivateToken(input.expectedSourceRegistrationId) ||
1044
- !isPrivateToken(input.expectedTargetRegistrationId)))) {
1045
- throw new BridgeError("INVALID_GATEWAY_MESSAGE", "Expected source and target registrations must be supplied together.");
1046
- }
1047
- const source = this.requireRoute(state, input.sourceAlias);
1048
- const target = this.requireRoute(state, input.targetAlias);
1049
- if (hasExpectedSource &&
1050
- (source.binding.registrationId !== input.expectedSourceRegistrationId ||
1051
- target.binding.registrationId !== input.expectedTargetRegistrationId)) {
1052
- throw new BridgeError("ROUTE_UNREGISTERED", "The correlated source or target registration is no longer installed.");
1053
- }
1054
- if (source.binding.provider === target.binding.provider &&
1055
- source.binding.hostId === target.binding.hostId) {
1056
- throw new BridgeError("ROUTE_DIRECTION_MISMATCH", "Messages route only between different providers or between configured hosts.");
1057
- }
1058
- return this.enqueueResolved(state, now, input, {
1059
- sourceAlias: source.alias, targetAlias: target.alias,
1060
- direction: directionId(source.binding.provider, target.binding.provider),
1061
- sourceRegistrationId: source.binding.registrationId, targetRegistrationId: target.binding.registrationId,
1062
- exposeDeliveryToken: true,
1063
- });
1064
- });
1065
- }
1066
- async enqueueNativeIngress(input) {
1067
- return this.mutate((state, now) => {
1068
- if (!ALIAS_PATTERN.test(input.targetAlias) ||
1069
- !isPrivateToken(input.expectedTargetRegistrationId)) {
1070
- throw new BridgeError("INVALID_ROUTE_BINDING", "The native ingress target authority is malformed.");
1071
- }
1072
- const target = state.routes.find((route) => route.alias === input.targetAlias && route.enabled);
1073
- if (target === undefined ||
1074
- target.binding.registrationId !== input.expectedTargetRegistrationId) {
1075
- throw new BridgeError("ROUTE_UNREGISTERED", "The native ingress target registration is no longer current.");
1076
- }
1077
- const nativeSourceHost = target.registrationMode === "federated_peer"
1078
- ? this.config.hostId : target.binding.hostId;
1079
- if (input.source.binding.provider !== "claude" ||
1080
- input.source.binding.hostId !== nativeSourceHost ||
1081
- input.source.alias.endsWith(`@${nativeSourceHost}`) === false) {
1082
- throw new BridgeError("INVALID_NATIVE_PEER", "The native Claude sender does not match the target host.");
1083
- }
1084
- // The sender's own route was installed by the service on this send;
1085
- // the exact binding fences a same-alias replacement that raced it.
1086
- const source = state.routes.find((route) => route.alias === input.source.alias &&
1087
- route.enabled &&
1088
- route.binding.provider === "claude" &&
1089
- route.binding.routeHandle === input.source.binding.routeHandle &&
1090
- route.binding.registrationId === input.source.binding.registrationId);
1091
- if (source === undefined) {
1092
- throw new BridgeError("ROUTE_UNREGISTERED", "The native Claude sender's route is no longer current.");
1093
- }
1094
- if (source.binding.provider === target.binding.provider &&
1095
- source.binding.hostId === target.binding.hostId) {
1096
- throw new BridgeError("ROUTE_DIRECTION_MISMATCH", "Messages route only between different providers or between configured hosts.");
1097
- }
1098
- return this.enqueueResolved(state, now, input, {
1099
- sourceAlias: source.alias, targetAlias: target.alias,
1100
- direction: directionId("claude", target.binding.provider),
1101
- sourceRegistrationId: source.binding.registrationId,
1102
- targetRegistrationId: target.binding.registrationId,
1103
- exposeDeliveryToken: false,
1104
- });
1105
- });
1106
- }
1107
- async inspectDispatchableTargets() {
1108
- return this.read((state) => [...new Set(state.messages
1109
- .filter((message) => message.state.phase === "queued")
1110
- .map((message) => message.targetAlias))]);
1111
- }
1112
- async nextDeadlineAt() {
1113
- return this.read((state) => state.messages
1114
- .filter((message) => message.state.phase !== "terminal")
1115
- .map((message) => message.deadlineAt)
1116
- .sort()[0]);
1117
- }
1118
- async deliveryStatus(token) {
1119
- if (!DELIVERY_TOKEN_PATTERN.test(token)) {
1120
- throw new BridgeError("INVALID_DELIVERY_TOKEN", "The delivery-status token is malformed.");
1121
- }
1122
- return this.read((state) => {
1123
- const message = state.messages.find((candidate) => candidate.deliveryToken === token);
1124
- return message === undefined ? undefined : structuredClone(message);
1125
- });
1126
- }
1127
- async reserveMessage(targetAlias, mode = "any") {
1128
- return this.mutate((state, now) => {
1129
- if (targetAlias !== undefined && !ALIAS_PATTERN.test(targetAlias)) {
1130
- throw new BridgeError("INVALID_GATEWAY_ALIAS", "The dispatch target alias is invalid.");
1131
- }
1132
- const inFlight = state.messages.filter((candidate) => candidate.state.phase === "reserved" ||
1133
- candidate.state.phase === "armed" ||
1134
- candidate.state.phase === "accepted").length;
1135
- if (inFlight >= this.config.limits.maxInFlightMessages) {
1136
- return { status: "empty" };
1137
- }
1138
- const message = state.messages.find((candidate) => candidate.state.phase === "queued" &&
1139
- (targetAlias === undefined || candidate.targetAlias === targetAlias) &&
1140
- (mode === "any" || candidate.steer === true));
1141
- if (message === undefined)
1142
- return { status: "empty" };
1143
- if (Date.parse(message.deadlineAt) <= now.getTime()) {
1144
- return { status: "terminal", settlement: this.finishMessage(state, message, "expired", now, "MESSAGE_EXPIRED") };
1145
- }
1146
- if (message.targetRegistrationId === null) {
1147
- return { status: "terminal", settlement: this.finishMessage(state, message, "abandoned", now, "CONTROLLER_RESTARTED") };
1148
- }
1149
- if (message.state.phase !== "queued")
1150
- return { status: "empty" };
1151
- const maximumAttemptCount = 1 + Math.max(0, Math.ceil((Date.parse(message.deadlineAt) - Date.parse(message.enqueuedAt)) / 500));
1152
- if (message.state.attemptCount >= maximumAttemptCount) {
1153
- throw new RangeError("GATEWAY_ATTEMPT_BUDGET_EXHAUSTED");
1154
- }
1155
- const attemptId = `attempt_${this.randomId()}`;
1156
- const attemptCount = message.state.attemptCount + 1;
1157
- message.state = {
1158
- phase: "reserved",
1159
- attemptId,
1160
- attemptCount,
1161
- reservedAt: now.toISOString(), sourceRegistrationId: message.sourceRegistrationId,
1162
- targetRegistrationId: message.targetRegistrationId,
1163
- };
1164
- state.accounting.queuedBytes -= message.bytes;
1165
- this.touchMessage(state, message);
1166
- return {
1167
- status: "reserved",
1168
- attempt: {
1169
- messageId: message.messageId,
1170
- attemptId,
1171
- attemptCount,
1172
- body: message.body, deadlineAt: message.deadlineAt,
1173
- direction: message.direction, sourceAlias: message.sourceAlias,
1174
- targetAlias: message.targetAlias,
1175
- ...(message.conversationIdSuffix === undefined
1176
- ? {}
1177
- : { conversationIdSuffix: message.conversationIdSuffix }),
1178
- sourceRegistrationId: message.sourceRegistrationId, targetRegistrationId: message.targetRegistrationId,
1179
- bytes: message.bytes,
1180
- ...(message.steer === true ? { steer: true } : {}),
1181
- },
1182
- };
1183
- });
1184
- }
1185
- async authorizeMessage(input) {
1186
- return this.mutate((state, now) => {
1187
- const message = state.messages.find((candidate) => candidate.messageId === input.messageId);
1188
- if (message?.state.phase !== "reserved") {
1189
- return { status: "stale", reason: "not_reserved" };
1190
- }
1191
- if (message.state.attemptId !== input.attemptId) {
1192
- return { status: "stale", reason: "attempt_mismatch" };
1193
- }
1194
- if (Date.parse(message.deadlineAt) <= now.getTime()) {
1195
- return { status: "terminal", reason: "expired",
1196
- settlement: this.finishMessage(state, message, "expired", now, "MESSAGE_EXPIRED") };
1197
- }
1198
- const bodySha256 = createHash("sha256")
1199
- .update(message.body ?? "", "utf8")
1200
- .digest("hex");
1201
- const target = state.routes.find((route) => route.alias === message.targetAlias &&
1202
- route.binding.registrationId === message.targetRegistrationId &&
1203
- route.enabled);
1204
- if (!isPrepared(input.prepared) ||
1205
- input.prepared.kind !== expectedPreparedKind(message, target?.registrationMode) ||
1206
- input.prepared.bodyBytes !== message.bytes ||
1207
- input.prepared.bodySha256 !== bodySha256) {
1208
- throw new BridgeError("INVALID_PREPARED_WRITE_EVIDENCE", "The prepared payload evidence does not match the admitted message.");
1209
- }
1210
- if (input.sourceRegistrationId !== message.sourceRegistrationId ||
1211
- input.targetRegistrationId !== message.targetRegistrationId ||
1212
- target === undefined) {
1213
- return { status: "stale", reason: "registration_changed" };
1214
- }
1215
- const source = state.routes.find((route) => route.alias === message.sourceAlias &&
1216
- route.binding.registrationId === message.sourceRegistrationId &&
1217
- route.enabled);
1218
- if (source === undefined) {
1219
- return { status: "stale", reason: "registration_changed" };
1220
- }
1221
- const authority = message.state;
1222
- message.state = {
1223
- phase: "armed", attemptId: authority.attemptId,
1224
- attemptCount: authority.attemptCount, targetRegistrationId: authority.targetRegistrationId,
1225
- sourceRegistrationId: authority.sourceRegistrationId,
1226
- armedAt: now.toISOString(), prepared: { ...input.prepared },
1227
- };
1228
- this.touchMessage(state, message);
1229
- return { status: "authorized" };
1230
- });
1231
- }
1232
- async acceptMessage(input) {
1233
- return this.mutate((state, now) => {
1234
- const message = state.messages.find((candidate) => candidate.messageId === input.messageId);
1235
- if (message?.state.phase !== "armed" ||
1236
- message.state.attemptId !== input.attemptId) {
1237
- return { status: "stale" };
1238
- }
1239
- const authority = message.state;
1240
- message.state = {
1241
- phase: "accepted", attemptId: authority.attemptId,
1242
- attemptCount: authority.attemptCount, targetRegistrationId: authority.targetRegistrationId,
1243
- sourceRegistrationId: authority.sourceRegistrationId,
1244
- acceptedAt: now.toISOString(), prepared: authority.prepared,
1245
- lossOutcome: input.lossOutcome,
1246
- };
1247
- this.touchMessage(state, message);
1248
- return { status: "accepted" };
1249
- });
1250
- }
1251
- async resolvePrewriteAttempt(input) {
1252
- return this.mutate((state, now) => {
1253
- if (input.safeErrorCode !== undefined && !isSafeCode(input.safeErrorCode)) {
1254
- throw new BridgeError("INVALID_SAFE_ERROR_CODE", "The pre-write result safe code is malformed.");
1255
- }
1256
- const message = state.messages.find((candidate) => candidate.messageId === input.messageId);
1257
- if (message?.state.phase !== "reserved" ||
1258
- message.state.attemptId !== input.attemptId) {
1259
- return { status: "stale" };
1260
- }
1261
- if (input.outcome === "requeue" && Date.parse(message.deadlineAt) > now.getTime()) {
1262
- const attemptCount = message.state.attemptCount;
1263
- message.state = { phase: "queued", attemptCount };
1264
- state.accounting.queuedBytes += message.bytes;
1265
- this.touchMessage(state, message);
1266
- return { status: "requeued" };
1267
- }
1268
- const expired = Date.parse(message.deadlineAt) <= now.getTime();
1269
- return { status: "settled", settlement: this.finishMessage(state, message, expired ? "expired" : "failed", now, expired ? "MESSAGE_EXPIRED" : input.safeErrorCode) };
1270
- });
1271
- }
1272
- async settleAttempt(input) {
1273
- return this.mutate((state, now) => {
1274
- if (input.safeErrorCode !== undefined && !isSafeCode(input.safeErrorCode)) {
1275
- throw new BridgeError("INVALID_SAFE_ERROR_CODE", "The settlement safe code is malformed.");
1276
- }
1277
- const message = state.messages.find((candidate) => candidate.messageId === input.messageId);
1278
- if (message === undefined ||
1279
- (message.state.phase !== "armed" && message.state.phase !== "accepted") ||
1280
- message.state.attemptId !== input.attemptId) {
1281
- return { status: "stale" };
1282
- }
1283
- if ((message.state.phase === "armed" &&
1284
- (input.state === "expired" ||
1285
- (input.state === "unconfirmed" &&
1286
- !(input.safeErrorCode === "PEER_HANDOFF_ACCEPTANCE_UNCONFIRMED" && message.state.prepared.kind === "peer_handoff")))) ||
1287
- (message.state.phase === "accepted" &&
1288
- (input.state === "expired" ||
1289
- (input.state === "unconfirmed" &&
1290
- message.state.lossOutcome !== "unconfirmed") ||
1291
- (input.state === "ambiguous" &&
1292
- message.state.lossOutcome !== "ambiguous")))) {
1293
- throw new RangeError("INVALID_ATTEMPT_SETTLEMENT_PHASE");
1294
- }
1295
- return { status: "settled",
1296
- settlement: this.finishMessage(state, message, input.state, now, input.safeErrorCode) };
1297
- });
1298
- }
1299
- async settleAttemptForShutdown(input) {
1300
- return this.mutate((state, now) => {
1301
- const message = state.messages.find((candidate) => candidate.messageId === input.messageId);
1302
- if (message === undefined ||
1303
- message.state.phase === "queued" ||
1304
- message.state.phase === "terminal" ||
1305
- message.state.attemptId !== input.attemptId) {
1306
- return { status: "stale" };
1307
- }
1308
- if (message.state.phase === "reserved") {
1309
- const attemptCount = message.state.attemptCount;
1310
- message.state = { phase: "queued", attemptCount };
1311
- state.accounting.queuedBytes += message.bytes;
1312
- this.touchMessage(state, message);
1313
- return { status: "requeued" };
1314
- }
1315
- const outcome = message.state.phase === "armed" ? "ambiguous" : message.state.lossOutcome;
1316
- return { status: "settled", settlement: this.finishMessage(state, message, outcome, now, outcome === "ambiguous" ? "DISPATCH_OUTCOME_AMBIGUOUS" : "DELIVERY_UNCONFIRMED") };
1317
- });
1318
- }
1319
- async settleQueuedMessageForShutdown(input) {
1320
- return this.mutate((state, now) => {
1321
- const message = state.messages.find((candidate) => candidate.messageId === input.messageId);
1322
- if (message?.state.phase !== "queued")
1323
- return { status: "stale" };
1324
- return { status: "settled",
1325
- settlement: this.finishMessage(state, message, "cancelled", now, "GATEWAY_SHUTDOWN") };
1326
- });
1327
- }
1328
- async expireDueMessages(now) {
1329
- return this.mutate((state, mutationNow) => {
1330
- const effective = now ?? mutationNow;
1331
- const settlements = [];
1332
- for (const message of state.messages) {
1333
- if (message.state.phase === "terminal" ||
1334
- Date.parse(message.deadlineAt) > effective.getTime()) {
1335
- continue;
1336
- }
1337
- const outcome = message.state.phase === "accepted"
1338
- ? message.state.lossOutcome
1339
- : message.state.phase === "armed"
1340
- ? "ambiguous"
1341
- : "expired";
1342
- settlements.push(this.finishMessage(state, message, outcome, effective, outcome === "ambiguous"
1343
- ? "DISPATCH_OUTCOME_AMBIGUOUS"
1344
- : outcome === "unconfirmed"
1345
- ? "DELIVERY_UNCONFIRMED"
1346
- : "MESSAGE_EXPIRED"));
1347
- }
1348
- return settlements;
1349
- });
1350
- }
1351
- async recordActivity(event) {
1352
- return this.mutate((state, now) => {
1353
- return this.appendRuntimeActivity(state, now, event);
1354
- });
1355
- }
1356
- async publicSnapshot() {
1357
- return this.read((state) => {
1358
- const now = this.now();
1359
- const routes = state.routes.map((route) => {
1360
- const queued = state.messages.filter((message) => message.targetRegistrationId === route.binding.registrationId &&
1361
- message.state.phase === "queued");
1362
- return {
1363
- alias: route.alias, provider: route.binding.provider,
1364
- host: route.binding.hostId, enabled: route.enabled,
1365
- state: route.enabled ? "idle" : "disabled", busyPolicy: route.busyPolicy,
1366
- queueDepth: queued.length,
1367
- ...(queued[0]?.enqueuedAt === undefined
1368
- ? {}
1369
- : { oldestQueuedAt: queued[0].enqueuedAt }),
1370
- counters: { ...route.counters },
1371
- mutable: route.registrationMode !== "federated_peer",
1372
- };
1373
- });
1374
- const currentEvents = state.messages.map((message) => this.projectMessageEvent(message));
1375
- const messageEvents = state.activity
1376
- .filter((entry) => entry.type === "message_activity")
1377
- .map((entry) => structuredClone(entry.event));
1378
- const activityEvents = state.activity
1379
- .filter((entry) => entry.type === "activity")
1380
- .map((entry) => structuredClone(entry.event));
1381
- const messages = [...messageEvents, ...currentEvents]
1382
- .sort((left, right) => left.sequence - right.sequence)
1383
- .slice(-gatewayPublicSnapshotLimits.messages);
1384
- const pressureBuckets = [
1385
- { bucket: "under_1m", settled: 0, expired: 0 },
1386
- { bucket: "1m_to_5m", settled: 0, expired: 0 },
1387
- { bucket: "5m_to_15m", settled: 0, expired: 0 },
1388
- { bucket: "15m_to_60m", settled: 0, expired: 0 },
1389
- { bucket: "over_60m", settled: 0, expired: 0 },
1390
- ];
1391
- const terminalEvidence = messages.filter((event) => event.latencyMs !== undefined && ["delivered", "unconfirmed", "failed", "ambiguous",
1392
- "expired", "cancelled", "abandoned"].includes(event.state));
1393
- for (const event of terminalEvidence) {
1394
- const latency = event.latencyMs ?? 0;
1395
- const index = latency < 60_000 ? 0 : latency < 300_000 ? 1 :
1396
- latency < 900_000 ? 2 : latency < 3_600_000 ? 3 : 4;
1397
- pressureBuckets[index].settled += 1;
1398
- if (event.state === "expired")
1399
- pressureBuckets[index].expired += 1;
1400
- }
1401
- return projectGatewayPublicSnapshot({
1402
- schemaVersion: 2, generatedAt: now.toISOString(),
1403
- health: "offline",
1404
- connectors: [], availablePeers: [],
1405
- routes,
1406
- activityEvents,
1407
- deadlinePressure: {
1408
- configuredDeadlineMs: this.config.limits.messageDeadlineMs,
1409
- ...(messages[0]?.timestamp === undefined
1410
- ? {}
1411
- : { retainedSince: messages[0].timestamp }),
1412
- terminalEvents: terminalEvidence.length,
1413
- expiredEvents: terminalEvidence.filter((event) => event.state === "expired").length,
1414
- buckets: pressureBuckets,
1415
- },
1416
- messages,
1417
- accounting: { ...state.accounting }, alerts: [],
1418
- truncation: {
1419
- connectors: 0, availablePeers: 0,
1420
- routes: 0,
1421
- activityEvents: 0,
1422
- messages: Math.max(0, messageEvents.length + currentEvents.length - messages.length),
1423
- alerts: 0,
1424
- },
1425
- });
1426
- });
1427
- }
1428
- projectMessageEvent(message) {
1429
- const state = message.state.phase === "queued"
1430
- ? "queued"
1431
- : message.state.phase === "reserved"
1432
- ? "dispatching"
1433
- : message.state.phase === "armed" || message.state.phase === "accepted"
1434
- ? "transport_written"
1435
- : message.state.outcome;
1436
- return {
1437
- sequence: message.sequence,
1438
- timestamp: message.state.phase === "queued"
1439
- ? message.enqueuedAt
1440
- : message.state.phase === "reserved"
1441
- ? message.state.reservedAt
1442
- : message.state.phase === "armed"
1443
- ? message.state.armedAt
1444
- : message.state.phase === "accepted"
1445
- ? message.state.acceptedAt
1446
- : message.state.terminalAt,
1447
- messageIdSuffix: message.messageIdSuffix,
1448
- ...(message.conversationIdSuffix === undefined
1449
- ? {}
1450
- : { conversationIdSuffix: message.conversationIdSuffix }),
1451
- direction: message.direction, sourceAlias: message.sourceAlias,
1452
- targetAlias: message.targetAlias,
1453
- state,
1454
- bytes: message.bytes,
1455
- ...(message.body === undefined ? {} : { body: message.body }),
1456
- ...(message.steer === true ? { steer: true } : {}),
1457
- ...(message.state.phase === "terminal"
1458
- ? {
1459
- latencyMs: message.state.latencyMs,
1460
- ...(message.state.safeErrorCode === undefined
1461
- ? {}
1462
- : { safeErrorCode: message.state.safeErrorCode }),
1463
- }
1464
- : {}),
1465
- };
1466
- }
1467
- validateRouteInput(input) {
1468
- const localHost = this.config.hostId;
1469
- if (!isObject(input) ||
1470
- !ALIAS_PATTERN.test(input.alias) ||
1471
- !isLogicalBinding(input.binding) ||
1472
- !REGISTRATION_ID_PATTERN.test(input.binding.registrationId) ||
1473
- !REGISTRATION_MODES.has(input.registrationMode) ||
1474
- (input.binding.provider === "peer" && input.registrationMode !== "federated_peer" &&
1475
- !PEER_ROUTE_HANDLE_PATTERN.test(input.binding.routeHandle)) ||
1476
- !input.alias.endsWith(`@${input.binding.hostId}`) ||
1477
- !this.config.allowedHosts.includes(input.binding.hostId) ||
1478
- !routeModeMatchesHost(input, localHost)) {
1479
- throw new BridgeError("INVALID_ROUTE_BINDING", "The logical route binding is invalid for this gateway.");
1480
- }
1481
- const prefix = gatewayRegistrationIngressPrefixes[input.binding.provider];
1482
- if (prefix !== undefined && !input.alias.startsWith(prefix)) {
1483
- throw new BridgeError("INVALID_ROUTE_BINDING", "The route alias does not match its provider ingress convention.");
1484
- }
1485
- }
1486
- requireRoute(state, alias) {
1487
- const route = state.routes.find((candidate) => candidate.alias === alias && candidate.enabled);
1488
- if (route === undefined) {
1489
- throw new BridgeError("ROUTE_NOT_AVAILABLE", "The exact logical route is not registered and enabled.", true);
1490
- }
1491
- return route;
1492
- }
1493
- enqueueResolved(state, now, input, authority) {
1494
- if (typeof input.body !== "string" ||
1495
- input.body.length === 0 ||
1496
- input.body.includes("\u0000") ||
1497
- typeof input.dedupeKey !== "string" ||
1498
- input.dedupeKey.length === 0 ||
1499
- (input.conversationIdSuffix !== undefined &&
1500
- !CONVERSATION_SUFFIX_PATTERN.test(input.conversationIdSuffix))) {
1501
- throw new BridgeError("INVALID_GATEWAY_MESSAGE", "The gateway message body or correlation metadata is invalid.");
1502
- }
1503
- const bytes = Buffer.byteLength(input.body, "utf8");
1504
- if (bytes > this.config.limits.maxMessageBytes) {
1505
- this.recordRejection(state, authority, bytes, now, "MESSAGE_TOO_LARGE", input);
1506
- throw new CommitAndThrow(new BridgeError("MESSAGE_TOO_LARGE", "The message exceeds the configured byte bound."));
1507
- }
1508
- if (Buffer.byteLength(input.dedupeKey, "utf8") > 512) {
1509
- throw new BridgeError("INVALID_DEDUPE_KEY", "A bounded, non-empty deduplication key is required.");
1510
- }
1511
- const deadlineAt = input.deadlineAt ??
1512
- new Date(now.getTime() + this.config.limits.messageDeadlineMs).toISOString();
1513
- if (!isIsoTimestamp(deadlineAt) ||
1514
- Date.parse(deadlineAt) <= now.getTime() ||
1515
- Date.parse(deadlineAt) > now.getTime() + this.config.limits.messageDeadlineMs) {
1516
- this.recordRejection(state, authority, bytes, now, "INVALID_DEADLINE", input);
1517
- throw new CommitAndThrow(new BridgeError("INVALID_DEADLINE", "The message deadline must fall inside the configured delivery window."));
1518
- }
1519
- const fingerprint = createHash("sha256")
1520
- .update([
1521
- authority.sourceAlias,
1522
- authority.targetAlias,
1523
- authority.direction,
1524
- input.dedupeKey,
1525
- ].join("\0"))
1526
- .digest("base64url");
1527
- const duplicate = state.dedupe.find((entry) => entry.fingerprint === fingerprint &&
1528
- Date.parse(entry.expiresAt) > now.getTime());
1529
- if (duplicate !== undefined) {
1530
- state.accounting.duplicates += 1;
1531
- return {
1532
- accepted: false, duplicate: true,
1533
- messageIdSuffix: duplicate.messageIdSuffix,
1534
- };
1535
- }
1536
- if (!this.consumeRate(state, authority.sourceAlias, now)) {
1537
- this.recordRejection(state, authority, bytes, now, "GATEWAY_RATE_LIMITED", input);
1538
- throw new CommitAndThrow(new BridgeError("GATEWAY_RATE_LIMITED", "The source exceeded the bounded gateway rate window.", true));
1539
- }
1540
- const active = state.messages.filter((message) => message.state.phase !== "terminal");
1541
- const queuedSteers = input.steer === true
1542
- ? active
1543
- .filter((message) => message.state.phase === "queued" &&
1544
- message.steer === true &&
1545
- message.sourceAlias === authority.sourceAlias &&
1546
- message.targetAlias === authority.targetAlias)
1547
- .sort((left, right) => Date.parse(left.enqueuedAt) - Date.parse(right.enqueuedAt) ||
1548
- left.sequence - right.sequence)
1549
- : [];
1550
- const superseded = queuedSteers.length >= 3 ? queuedSteers[0] : undefined;
1551
- if (active.length - (superseded === undefined ? 0 : 1) >=
1552
- this.config.limits.maxQueueMessages ||
1553
- active.filter((message) => message.targetAlias === authority.targetAlias)
1554
- .length - (superseded === undefined ? 0 : 1) >=
1555
- this.config.limits.maxQueueMessagesPerRoute ||
1556
- state.accounting.queuedBytes - (superseded?.bytes ?? 0) + bytes >
1557
- this.config.limits.maxQueueBytes) {
1558
- this.recordRejection(state, authority, bytes, now, "GATEWAY_QUEUE_FULL", input);
1559
- throw new CommitAndThrow(new BridgeError("GATEWAY_QUEUE_FULL", "The bounded gateway queue is full.", true));
1560
- }
1561
- const messageId = `msg_${this.randomId()}`;
1562
- if (!MESSAGE_ID_PATTERN.test(messageId)) {
1563
- throw new BridgeError("GATEWAY_ID_ALLOCATION_FAILED", "A valid message identifier could not be allocated.");
1564
- }
1565
- const messageIdSuffix = messageId.replaceAll("-", "").slice(-8).toLowerCase();
1566
- const deliveryToken = authority.exposeDeliveryToken
1567
- ? this.allocateDeliveryToken(state)
1568
- : undefined;
1569
- const supersededSettlement = superseded === undefined
1570
- ? undefined
1571
- : this.finishMessage(state, superseded, "cancelled", now, "STEER_QUEUE_SUPERSEDED");
1572
- state.eventSequence += 1;
1573
- const message = {
1574
- sequence: state.eventSequence,
1575
- messageId,
1576
- messageIdSuffix,
1577
- ...(input.conversationIdSuffix === undefined
1578
- ? {}
1579
- : { conversationIdSuffix: input.conversationIdSuffix }),
1580
- ...(deliveryToken === undefined ? {} : { deliveryToken }),
1581
- direction: authority.direction, sourceAlias: authority.sourceAlias,
1582
- targetAlias: authority.targetAlias, enqueuedAt: now.toISOString(),
1583
- deadlineAt,
1584
- bytes,
1585
- body: input.body,
1586
- ...(input.steer === true ? { steer: true } : {}),
1587
- sourceRegistrationId: authority.sourceRegistrationId, targetRegistrationId: authority.targetRegistrationId,
1588
- state: { phase: "queued", attemptCount: 0 },
1589
- };
1590
- state.messages.push(message);
1591
- state.accounting.accepted += 1;
1592
- state.accounting.bytesAccepted += bytes;
1593
- state.accounting.queuedBytes += bytes;
1594
- const source = state.routes.find((route) => route.binding.registrationId === authority.sourceRegistrationId);
1595
- if (source !== undefined) {
1596
- source.counters.accepted += 1;
1597
- source.counters.bytesAccepted += bytes;
1598
- }
1599
- state.dedupe.push({
1600
- fingerprint,
1601
- messageIdSuffix,
1602
- ...(input.conversationIdSuffix === undefined
1603
- ? {}
1604
- : { conversationIdSuffix: input.conversationIdSuffix }),
1605
- sourceAlias: authority.sourceAlias, targetAlias: authority.targetAlias,
1606
- direction: authority.direction,
1607
- firstSeenAt: now.toISOString(),
1608
- expiresAt: new Date(now.getTime() + this.config.limits.dedupeTtlMs).toISOString(),
1609
- });
1610
- while (state.dedupe.length > this.config.limits.dedupeCapacity) {
1611
- state.dedupe.shift();
1612
- }
1613
- return {
1614
- accepted: true, duplicate: false,
1615
- messageId,
1616
- messageIdSuffix,
1617
- ...(deliveryToken === undefined ? {} : { deliveryToken }),
1618
- ...(supersededSettlement === undefined
1619
- ? {}
1620
- : { supersededSettlement }),
1621
- };
1622
- }
1623
- allocateDeliveryToken(state) {
1624
- for (let attempt = 0; attempt < 8; attempt += 1) {
1625
- const token = `dlv_${createHash("sha256")
1626
- .update(this.randomId(), "utf8")
1627
- .digest("base64url")
1628
- .slice(0, 24)}`;
1629
- if (DELIVERY_TOKEN_PATTERN.test(token) &&
1630
- !state.messages.some((message) => message.deliveryToken === token)) {
1631
- return token;
1632
- }
1633
- }
1634
- throw new BridgeError("DELIVERY_TOKEN_CAPACITY_REACHED", "A unique bounded delivery-status token could not be allocated.", true);
1635
- }
1636
- consumeRate(state, sourceAlias, now) {
1637
- let bucket = state.rateBuckets.find((candidate) => candidate.sourceAlias === sourceAlias);
1638
- if (bucket === undefined ||
1639
- now.getTime() - Date.parse(bucket.windowStartedAt) >=
1640
- this.config.limits.rateWindowMs) {
1641
- state.rateBuckets = state.rateBuckets.filter((candidate) => candidate.sourceAlias !== sourceAlias);
1642
- if (state.rateBuckets.length >= this.config.limits.maxRoutes) {
1643
- return false;
1644
- }
1645
- bucket = {
1646
- sourceAlias,
1647
- windowStartedAt: now.toISOString(), count: 0,
1648
- };
1649
- state.rateBuckets.push(bucket);
1650
- }
1651
- if (bucket.count >= this.config.limits.rateLimitPerRoute) {
1652
- return false;
1653
- }
1654
- bucket.count += 1;
1655
- return true;
1656
- }
1657
- recordRejection(state, authority, bytes, now, safeErrorCode, input) {
1658
- state.accounting.rejected += 1;
1659
- const source = state.routes.find((route) => route.binding.registrationId === authority.sourceRegistrationId);
1660
- if (source !== undefined)
1661
- source.counters.rejected += 1;
1662
- const suffix = this.randomId().replaceAll("-", "").slice(-8).toLowerCase();
1663
- if (!MESSAGE_SUFFIX_PATTERN.test(suffix))
1664
- return;
1665
- state.eventSequence += 1;
1666
- state.activity.push({
1667
- type: "message_activity",
1668
- event: {
1669
- sequence: state.eventSequence, timestamp: now.toISOString(),
1670
- messageIdSuffix: suffix,
1671
- ...(input.conversationIdSuffix === undefined
1672
- ? {}
1673
- : { conversationIdSuffix: input.conversationIdSuffix }),
1674
- direction: authority.direction, sourceAlias: authority.sourceAlias,
1675
- targetAlias: authority.targetAlias, state: "rejected",
1676
- bytes: Math.max(1, bytes),
1677
- safeErrorCode,
1678
- ...(input.steer === true ? { steer: true } : {}),
1679
- },
1680
- });
1681
- while (state.activity.length > gatewayPublicSnapshotLimits.activityEvents) {
1682
- state.activity.shift();
1683
- }
1684
- }
1685
- finishMessage(state, message, outcome, now, safeErrorCode) {
1686
- if (message.state.phase === "terminal") {
1687
- return {
1688
- messageId: message.messageId, state: message.state.outcome,
1689
- ...(message.state.safeErrorCode === undefined
1690
- ? {}
1691
- : { safeErrorCode: message.state.safeErrorCode }),
1692
- };
1693
- }
1694
- const handedOff = outcome === "delivered" &&
1695
- (message.state.phase === "armed" || message.state.phase === "accepted") &&
1696
- message.state.prepared.kind === "peer_handoff";
1697
- if (message.state.phase === "queued") {
1698
- state.accounting.queuedBytes -= message.bytes;
1699
- }
1700
- message.state = terminalState(outcome, now, message.enqueuedAt, safeErrorCode);
1701
- this.touchMessage(state, message);
1702
- if (handedOff)
1703
- delete message.body;
1704
- state.accounting[outcome] += 1;
1705
- const target = state.routes.find((route) => route.binding.registrationId === message.targetRegistrationId);
1706
- if (target !== undefined)
1707
- target.counters[outcome] += 1;
1708
- return {
1709
- messageId: message.messageId, state: outcome,
1710
- ...(safeErrorCode === undefined ? {} : { safeErrorCode }),
1711
- };
1712
- }
1713
- touchMessage(state, message) {
1714
- state.eventSequence += 1;
1715
- message.sequence = state.eventSequence;
1716
- }
1717
- appendRuntimeActivity(state, now, event) {
1718
- state.eventSequence += 1;
1719
- const recorded = {
1720
- ...structuredClone(event),
1721
- sequence: state.eventSequence, timestamp: now.toISOString(),
1722
- };
1723
- const wrapped = {
1724
- type: "activity", event: recorded,
1725
- };
1726
- state.activity.push(wrapped);
1727
- while (state.activity.length > gatewayPublicSnapshotLimits.activityEvents) {
1728
- state.activity.shift();
1729
- }
1730
- return structuredClone(recorded);
1731
- }
1732
- terminalizeRegistration(state, registrationId, now, cancelledCode = "ROUTE_UNREGISTERED") {
1733
- const settlements = [];
1734
- for (const message of state.messages) {
1735
- if (message.state.phase === "terminal" ||
1736
- (message.sourceRegistrationId !== registrationId &&
1737
- message.targetRegistrationId !== registrationId)) {
1738
- continue;
1739
- }
1740
- const outcome = message.state.phase === "armed"
1741
- ? "ambiguous"
1742
- : message.state.phase === "accepted"
1743
- ? message.state.lossOutcome
1744
- : "cancelled";
1745
- settlements.push(this.finishMessage(state, message, outcome, now, outcome === "cancelled"
1746
- ? cancelledCode
1747
- : outcome === "ambiguous"
1748
- ? "DISPATCH_OUTCOME_AMBIGUOUS"
1749
- : "DELIVERY_UNCONFIRMED"));
1750
- }
1751
- return settlements;
1752
- }
1753
- removeRegistrationMetadata(state, route) {
1754
- if (this.ephemeralRegistrations.has(route.binding.registrationId)) {
1755
- // An ephemeral registration takes every row it owns with it — in this
1756
- // same mutate, and before its id leaves the set — so the closing
1757
- // persist finds nothing of it to write: not the message it sent, not
1758
- // the reply it received, not a journal row that names it.
1759
- Object.assign(state, this.ephemeralRowsRemoved(state, new Set([route.binding.registrationId]), new Set([route.alias])));
1760
- this.ephemeralRegistrations.delete(route.binding.registrationId);
1761
- return;
1762
- }
1763
- state.routes = state.routes.filter((candidate) => candidate !== route);
1764
- state.dedupe = state.dedupe.filter((entry) => entry.sourceAlias !== route.alias && entry.targetAlias !== route.alias);
1765
- state.rateBuckets = state.rateBuckets.filter((entry) => entry.sourceAlias !== route.alias);
1766
- }
1767
- renameRegistrationCoordinates(state, oldAlias, newAlias, registrationId) {
1768
- if (oldAlias === newAlias)
1769
- return;
1770
- if (!ALIAS_PATTERN.test(newAlias) ||
1771
- state.routes.some((route) => route.alias === newAlias)) {
1772
- throw new BridgeError("ROUTE_ALIAS_ALREADY_REGISTERED", "The replacement alias is invalid or already registered.");
1773
- }
1774
- for (const message of state.messages) {
1775
- if (message.sourceRegistrationId === registrationId) {
1776
- message.sourceAlias = newAlias;
1777
- }
1778
- if (message.targetRegistrationId === registrationId) {
1779
- message.targetAlias = newAlias;
1780
- }
1781
- }
1782
- for (const entry of state.dedupe) {
1783
- if (entry.sourceAlias === oldAlias)
1784
- entry.sourceAlias = newAlias;
1785
- if (entry.targetAlias === oldAlias)
1786
- entry.targetAlias = newAlias;
1787
- }
1788
- const oldBucket = state.rateBuckets.find((bucket) => bucket.sourceAlias === oldAlias);
1789
- const newBucket = state.rateBuckets.find((bucket) => bucket.sourceAlias === newAlias);
1790
- if (oldBucket !== undefined && newBucket === undefined) {
1791
- oldBucket.sourceAlias = newAlias;
1792
- }
1793
- else if (oldBucket !== undefined && newBucket !== undefined) {
1794
- newBucket.windowStartedAt =
1795
- newBucket.windowStartedAt < oldBucket.windowStartedAt
1796
- ? newBucket.windowStartedAt
1797
- : oldBucket.windowStartedAt;
1798
- newBucket.count = Math.min(Number.MAX_SAFE_INTEGER, newBucket.count + oldBucket.count);
1799
- state.rateBuckets = state.rateBuckets.filter((bucket) => bucket !== oldBucket);
1800
- }
1801
- }
1802
- recoverAfterRestart(now) {
1803
- const state = this.requireState();
1804
- for (const message of state.messages) {
1805
- if (message.state.phase === "terminal")
1806
- continue;
1807
- if (message.state.phase === "armed") {
1808
- this.finishMessage(state, message, "ambiguous", now, "CONTROLLER_RESTARTED");
1809
- continue;
1810
- }
1811
- if (message.state.phase === "accepted") {
1812
- this.finishMessage(state, message, message.state.lossOutcome, now, "CONTROLLER_RESTARTED");
1813
- continue;
1814
- }
1815
- if (Date.parse(message.deadlineAt) <= now.getTime() ||
1816
- (message.state.phase === "queued" && message.body === undefined)) {
1817
- this.finishMessage(state, message, Date.parse(message.deadlineAt) <= now.getTime()
1818
- ? "expired"
1819
- : "abandoned", now, Date.parse(message.deadlineAt) <= now.getTime()
1820
- ? "MESSAGE_EXPIRED"
1821
- : "CONTROLLER_RESTARTED");
1822
- continue;
1823
- }
1824
- if (message.state.phase === "reserved") {
1825
- const attemptCount = message.state.attemptCount;
1826
- message.state = { phase: "queued", attemptCount };
1827
- state.accounting.queuedBytes += message.bytes;
1828
- this.touchMessage(state, message);
1829
- continue;
1830
- }
1831
- }
1832
- }
1833
- prune(state, now) {
1834
- const cutoff = now.getTime() - this.config.limits.eventTtlMs;
1835
- const active = state.messages.filter((message) => message.state.phase !== "terminal");
1836
- const terminal = state.messages
1837
- .filter((message) => message.state.phase === "terminal" &&
1838
- Date.parse(message.state.terminalAt) > cutoff)
1839
- .sort((left, right) => left.sequence - right.sequence)
1840
- .slice(-this.config.limits.eventCapacity);
1841
- state.messages = [...active, ...terminal];
1842
- state.activity = state.activity
1843
- .filter((entry) => Date.parse(entry.event.timestamp) > cutoff)
1844
- .slice(-gatewayPublicSnapshotLimits.activityEvents);
1845
- state.dedupe = state.dedupe
1846
- .filter((entry) => Date.parse(entry.expiresAt) > now.getTime())
1847
- .slice(-this.config.limits.dedupeCapacity);
1848
- state.rateBuckets = state.rateBuckets.filter((bucket) => now.getTime() - Date.parse(bucket.windowStartedAt) <
1849
- this.config.limits.rateWindowMs);
1850
- this.pruneRetainedBodies(state);
1851
- }
1852
- pruneRetainedBodies(state) {
1853
- const cap = this.config.limits.maxRetainedBodyBytes ?? DEFAULT_RETAINED_BODY_BYTES;
1854
- if (!Number.isSafeInteger(cap) ||
1855
- cap < 1 ||
1856
- cap > GATEWAY_MAX_STATE_FILE_BYTES) {
1857
- throw new BridgeError("INVALID_GATEWAY_CONFIGURATION", "The retained message-body limit is invalid.");
1858
- }
1859
- let bytes = state.messages.reduce((total, message) => total +
1860
- (message.state.phase === "terminal" && message.body !== undefined
1861
- ? Buffer.byteLength(message.body, "utf8")
1862
- : 0), 0);
1863
- for (const message of state.messages) {
1864
- if (bytes <= cap)
1865
- break;
1866
- if (message.state.phase !== "terminal" || message.body === undefined) {
1867
- continue;
1868
- }
1869
- bytes -= Buffer.byteLength(message.body, "utf8");
1870
- delete message.body;
1871
- }
1872
- }
1873
- assertConfiguredBounds(state) {
1874
- const active = state.messages.filter((message) => message.state.phase !== "terminal");
1875
- if (state.routes.some((route) => !this.config.allowedHosts.includes(route.binding.hostId) ||
1876
- !routeModeMatchesHost(route, this.config.hostId)) ||
1877
- state.routes.length > this.config.limits.maxRoutes ||
1878
- active.length > this.config.limits.maxQueueMessages ||
1879
- active.filter((message) => message.state.phase === "reserved" ||
1880
- message.state.phase === "armed" ||
1881
- message.state.phase === "accepted").length > this.config.limits.maxInFlightMessages ||
1882
- state.dedupe.length > this.config.limits.dedupeCapacity ||
1883
- state.rateBuckets.length > this.config.limits.maxRoutes ||
1884
- state.accounting.queuedBytes > this.config.limits.maxQueueBytes ||
1885
- active.some((message) => message.bytes > this.config.limits.maxMessageBytes ||
1886
- Date.parse(message.deadlineAt) - Date.parse(message.enqueuedAt) >
1887
- this.config.limits.messageDeadlineMs)) {
1888
- throw new BridgeError("CORRUPT_GATEWAY_STATE", "The gateway state exceeds its configured bounds or host allowlist.");
1889
- }
1890
- }
1891
- async read(operation) {
1892
- return this.mutex.run("gateway", async () => operation(this.requireState()));
1893
- }
1894
- async mutate(operation) {
1895
- return this.mutex.run("gateway", async () => {
1896
- const state = this.requireState();
1897
- const before = structuredClone(state);
1898
- // The ephemeral set is state too: a removal drops an id from it inside
1899
- // the operation, and a rollback that restored the rows but not the id
1900
- // would turn a memory-only registration into a durable one.
1901
- const ephemeralBefore = new Set(this.ephemeralRegistrations);
1902
- const rollback = () => {
1903
- this.state = before;
1904
- this.ephemeralRegistrations.clear();
1905
- for (const id of ephemeralBefore)
1906
- this.ephemeralRegistrations.add(id);
1907
- };
1908
- const now = this.now();
1909
- this.prune(state, now);
1910
- let value;
1911
- try {
1912
- value = operation(state, now);
1913
- }
1914
- catch (error) {
1915
- if (error instanceof CommitAndThrow) {
1916
- state.updatedAt = now.toISOString();
1917
- state.commit = {
1918
- sequence: state.commit.sequence + 1, id: this.randomId(),
1919
- };
1920
- try {
1921
- await this.persist();
1922
- }
1923
- catch (persistError) {
1924
- if (!(persistError instanceof PostRenamePersistenceError))
1925
- rollback();
1926
- throw persistError;
1927
- }
1928
- throw error.error;
1929
- }
1930
- rollback();
1931
- throw error;
1932
- }
1933
- state.updatedAt = now.toISOString();
1934
- state.commit = {
1935
- sequence: state.commit.sequence + 1, id: this.randomId(),
1936
- };
1937
- try {
1938
- await this.persist();
1939
- }
1940
- catch (error) {
1941
- if (!(error instanceof PostRenamePersistenceError))
1942
- rollback();
1943
- throw error;
1944
- }
1945
- return value;
1946
- });
1947
- }
1948
- requireState() {
1949
- if (this.state === undefined || this.lockHandle === undefined) {
1950
- throw new BridgeError("GATEWAY_NOT_INITIALIZED", "The gateway store must hold its controller lock before use.");
1951
- }
1952
- return this.state;
1953
- }
1954
- async prepareOwnedDirectory() {
1955
- const requested = path.resolve(this.rootDir);
1956
- await assertNoSymlinkComponents(requested);
1957
- const canonical = await canonicalFuturePath(requested);
1958
- const home = await realpath(os.homedir()).catch(() => path.resolve(os.homedir()));
1959
- const temporaryRoot = await realpath(os.tmpdir()).catch(() => path.resolve(os.tmpdir()));
1960
- if (canonical === path.parse(canonical).root ||
1961
- canonical === home ||
1962
- canonical === temporaryRoot) {
1963
- throw new BridgeError("UNSAFE_GATEWAY_STATE_DIRECTORY", "The gateway state directory must be a dedicated private leaf.");
1964
- }
1965
- let existed = true;
1966
- try {
1967
- const info = await lstat(canonical);
1968
- if (info.isSymbolicLink() || !info.isDirectory()) {
1969
- throw new BridgeError("UNSAFE_GATEWAY_STATE_DIRECTORY", "The gateway state path must be a real directory.");
1970
- }
1971
- this.assertOwnedPrivate(info.uid, info.mode, "directory");
1972
- }
1973
- catch (error) {
1974
- if (error instanceof Error && "code" in error && error.code === "ENOENT") {
1975
- existed = false;
1976
- }
1977
- else {
1978
- throw error;
1979
- }
1980
- }
1981
- if (!existed) {
1982
- await mkdir(canonical, { recursive: true, mode: 0o700 });
1983
- await chmod(canonical, 0o700);
1984
- }
1985
- const root = await realpath(canonical);
1986
- if (root !== canonical) {
1987
- throw new BridgeError("UNSAFE_GATEWAY_STATE_DIRECTORY", "The gateway state path changed while it was prepared.");
1988
- }
1989
- const markerPath = path.join(root, STATE_MARKER);
1990
- let markerExists = true;
1991
- try {
1992
- await this.readPrivateFile(markerPath, MAX_MARKER_FILE_BYTES, STATE_MARKER_CONTENT);
1993
- }
1994
- catch (error) {
1995
- if (error instanceof Error && "code" in error && error.code === "ENOENT") {
1996
- markerExists = false;
1997
- }
1998
- else {
1999
- throw error;
2000
- }
2001
- }
2002
- if (!markerExists) {
2003
- const entries = await readdir(root);
2004
- if (existed && entries.some((entry) => entry !== "nodes.json")) {
2005
- throw new BridgeError("GATEWAY_STATE_DIRECTORY_NOT_OWNED", "The existing state directory is non-empty and lacks the ownership marker.");
2006
- }
2007
- const marker = await open(markerPath, constants.O_CREAT |
2008
- constants.O_EXCL |
2009
- constants.O_WRONLY |
2010
- (constants.O_NOFOLLOW ?? 0), 0o600);
2011
- try {
2012
- await marker.writeFile(STATE_MARKER_CONTENT, "utf8");
2013
- await marker.sync();
2014
- }
2015
- finally {
2016
- await marker.close();
2017
- }
2018
- }
2019
- return root;
2020
- }
2021
- /**
2022
- * Boot-time litter sweep (emb-106): the static `gateway-dashboard*.html`
2023
- * files 2.x published, any `.tmp` file its temp-file+rename publish could
2024
- * have left behind mid-crash, and controller locks this store recovered as
2025
- * stale more than seven days ago, are unlinked best-effort. Only a
2026
- * regular file owned by this uid is removed (lstat, no symlink following);
2027
- * nothing else in the state directory is touched, and a removal failure
2028
- * never blocks startup. Runs only after the controller lock is held and
2029
- * the state this boot will run on has passed both its schema check and
2030
- * assertConfiguredBounds, so a refused boot leaves the directory exactly
2031
- * as found.
2032
- */
2033
- async removeStateDirectoryLitter(root) {
2034
- let entries;
2035
- try {
2036
- entries = await readdir(root);
2037
- }
2038
- catch {
2039
- return;
2040
- }
2041
- const uid = typeof process.getuid === "function" ? process.getuid() : undefined;
2042
- const now = this.now().getTime();
2043
- for (const name of entries) {
2044
- const dashboard = isStaleDashboardArtifact(name);
2045
- const recoveredAt = dashboard ? undefined : staleLockRecoveredAt(name);
2046
- if (!dashboard && recoveredAt === undefined)
2047
- continue;
2048
- const filePath = path.join(root, name);
2049
- let info;
2050
- try {
2051
- info = await lstat(filePath);
2052
- }
2053
- catch {
2054
- continue;
2055
- }
2056
- if (info.isSymbolicLink() || !info.isFile() || (uid !== undefined && info.uid !== uid))
2057
- continue;
2058
- // A recovered lock is evidence: keep it a week from the recovery that
2059
- // created it, which is what its name records.
2060
- if (recoveredAt !== undefined && now - recoveredAt <= STALE_LOCK_RETENTION_MS)
2061
- continue;
2062
- try {
2063
- await unlink(filePath);
2064
- process.stderr.write(dashboard
2065
- ? `[embassy] removed stale ${name} left by an earlier Embassy release from the gateway state directory\n`
2066
- : `[embassy] removed ${name}, a recovered gateway lock older than seven days\n`);
2067
- }
2068
- catch {
2069
- // Best-effort cleanup; leave the file in place if it cannot be removed.
2070
- }
2071
- }
2072
- }
2073
- assertOwnedPrivate(uid, mode, kind) {
2074
- if (typeof process.getuid === "function" && uid !== process.getuid()) {
2075
- throw new BridgeError("UNSAFE_GATEWAY_STATE_DIRECTORY", `The gateway ${kind} is not owned by the current process user.`);
2076
- }
2077
- const expectedMode = kind === "directory" ? 0o700 : 0o600;
2078
- if ((mode & 0o777) !== expectedMode) {
2079
- throw new BridgeError("UNSAFE_GATEWAY_STATE_DIRECTORY", `The gateway ${kind} must use exact mode ${expectedMode.toString(8)}.`);
2080
- }
2081
- }
2082
- async readPrivateFile(filePath, maximumBytes, expectedBody) {
2083
- const info = await lstat(filePath);
2084
- if (info.isSymbolicLink() || !info.isFile()) {
2085
- throw new BridgeError("UNSAFE_GATEWAY_STATE_FILE", "A gateway controller file is not a regular file.");
2086
- }
2087
- this.assertOwnedPrivate(info.uid, info.mode, "state file");
2088
- if (info.size > maximumBytes) {
2089
- throw new BridgeError("GATEWAY_STATE_FILE_TOO_LARGE", "A gateway controller file exceeds its strict byte limit.");
2090
- }
2091
- const handle = await open(filePath, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0));
2092
- try {
2093
- const opened = await handle.stat();
2094
- if (!opened.isFile() ||
2095
- opened.dev !== info.dev ||
2096
- opened.ino !== info.ino ||
2097
- opened.size > maximumBytes) {
2098
- throw new BridgeError("UNSAFE_GATEWAY_STATE_FILE", "A gateway controller file changed during its bounded read.");
2099
- }
2100
- this.assertOwnedPrivate(opened.uid, opened.mode, "state file");
2101
- const buffer = Buffer.alloc(maximumBytes + 1);
2102
- let offset = 0;
2103
- while (offset < buffer.length) {
2104
- const read = await handle.read(buffer, offset, buffer.length - offset, offset);
2105
- if (read.bytesRead === 0)
2106
- break;
2107
- offset += read.bytesRead;
2108
- }
2109
- if (offset > maximumBytes) {
2110
- throw new BridgeError("GATEWAY_STATE_FILE_TOO_LARGE", "A gateway controller file exceeds its strict byte limit.");
2111
- }
2112
- const body = buffer.subarray(0, offset).toString("utf8");
2113
- if (expectedBody !== undefined && body !== expectedBody) {
2114
- throw new BridgeError("GATEWAY_STATE_DIRECTORY_NOT_OWNED", "The gateway ownership marker is not recognized.");
2115
- }
2116
- return body;
2117
- }
2118
- finally {
2119
- await handle.close();
2120
- }
2121
- }
2122
- /**
2123
- * Move a lock nobody owns aside instead of deleting it: a crash stays
2124
- * diagnosable for a week (the boot sweep ages these out), and the caller
2125
- * is told, because a silent recovery of another process's lock would be
2126
- * the one event an operator most needs to see.
2127
- */
2128
- async recoverStaleLock(lockPath, description) {
2129
- const name = `${CONTROLLER_LOCK}.stale-${String(this.now().getTime())}-${randomUUID()}`;
2130
- const moved = await rename(lockPath, path.join(this.rootDir, name)).then(() => true, (error) => {
2131
- // Another start recovered it first; retry the create either way.
2132
- if (error instanceof Error && "code" in error && error.code === "ENOENT")
2133
- return false;
2134
- throw error;
2135
- });
2136
- if (moved)
2137
- process.stderr.write(`[embassy] recovered a stale gateway lock (${description}) → ${name}\n`);
2138
- }
2139
- async acquireLock() {
2140
- const lockPath = path.join(this.rootDir, CONTROLLER_LOCK);
2141
- for (let attempt = 0; attempt < 3; attempt += 1) {
2142
- const token = randomUUID();
2143
- try {
2144
- const handle = await open(lockPath, constants.O_CREAT |
2145
- constants.O_EXCL |
2146
- constants.O_RDWR |
2147
- (constants.O_NOFOLLOW ?? 0), 0o600);
2148
- try {
2149
- await handle.writeFile(`${JSON.stringify({
2150
- schemaVersion: 1, pid: process.pid,
2151
- hostname: this.hostname(),
2152
- token,
2153
- })}\n`, "utf8");
2154
- await handle.sync();
2155
- }
2156
- catch (error) {
2157
- await handle.close().catch(() => undefined);
2158
- await unlink(lockPath).catch(() => undefined);
2159
- throw error;
2160
- }
2161
- this.lockHandle = handle;
2162
- this.lockToken = token;
2163
- return;
2164
- }
2165
- catch (error) {
2166
- if (!(error instanceof Error) ||
2167
- !("code" in error) ||
2168
- error.code !== "EEXIST") {
2169
- throw error;
2170
- }
2171
- }
2172
- let body;
2173
- try {
2174
- body = await this.readPrivateFile(lockPath, MAX_LOCK_FILE_BYTES);
2175
- }
2176
- catch {
2177
- throw lockUnverified();
2178
- }
2179
- // A crash between the O_EXCL create and the write leaves a zero-byte
2180
- // lock. It records no owner at all, so it can only be the corpse of a
2181
- // start that never began: recover it rather than wedging on it.
2182
- if (body.trim().length === 0) {
2183
- await this.recoverStaleLock(lockPath, "empty, left by an interrupted start");
2184
- continue;
2185
- }
2186
- let owner;
2187
- try {
2188
- owner = JSON.parse(body);
2189
- }
2190
- catch {
2191
- throw lockUnverified();
2192
- }
2193
- // Parsed, but naming no process: there is nothing to probe and nothing
2194
- // to claim about it, so this is unverified like any other unreadable
2195
- // record — never "another broker may own this".
2196
- if (!isPositiveInteger(owner.pid))
2197
- throw lockUnverified();
2198
- // Recovery is decided by process liveness alone; the recorded hostname
2199
- // is informational. A machine that renames itself — a network-triggered
2200
- // rename, a restore onto new hardware — must not wedge its own state
2201
- // directory forever, so a dead pid is stale whatever name wrote it.
2202
- const recorded = boundedHostname(owner.hostname);
2203
- const pid = String(owner.pid);
2204
- // A name this cannot represent is not reported as a name: the client
2205
- // hint drops the whole clause rather than printing a placeholder.
2206
- const detail = recorded === undefined ? { pid } : { host: recorded, pid };
2207
- if (this.isProcessAlive(owner.pid)) {
2208
- // The pid is alive, but nothing here can tell whether it is a broker:
2209
- // pid numbers are reused, and a renamed machine records its old name.
2210
- // Refuse, and hand the client the bounded facts its hint needs.
2211
- const mine = boundedHostname(this.hostname());
2212
- const message = recorded !== undefined && mine !== undefined && recorded === mine
2213
- ? `A live process (pid ${pid}) recorded on this machine owns this gateway state directory.`
2214
- : recorded === undefined
2215
- ? `The gateway state lock records pid ${pid} under a machine name that cannot be verified, and a process with that pid is alive here.`
2216
- : `The gateway state lock records host ${recorded} and pid ${pid}, and a process with that pid is alive here.`;
2217
- throw new BridgeError("GATEWAY_STATE_IN_USE", message, true, detail);
2218
- }
2219
- await this.recoverStaleLock(lockPath, recorded === undefined ? `pid ${pid}, machine name unverifiable` : `recorded host ${recorded}, pid ${pid}`);
2220
- }
2221
- throw new BridgeError("GATEWAY_STATE_IN_USE", "Could not acquire exclusive gateway controller ownership.", true);
2222
- }
2223
- async releaseControllerLock() {
2224
- const handle = this.lockHandle;
2225
- const token = this.lockToken;
2226
- this.lockHandle = undefined;
2227
- this.lockToken = undefined;
2228
- if (handle === undefined || token === undefined)
2229
- return;
2230
- await handle.close().catch(() => undefined);
2231
- const lockPath = path.join(this.rootDir, CONTROLLER_LOCK);
2232
- try {
2233
- const parsed = JSON.parse(await this.readPrivateFile(lockPath, MAX_LOCK_FILE_BYTES));
2234
- if (parsed.token === token)
2235
- await unlink(lockPath);
2236
- }
2237
- catch {
2238
- // Never remove a lock that cannot be proven to belong to this instance.
2239
- }
2240
- }
2241
- async loadStateFile() {
2242
- let body;
2243
- try {
2244
- body = await this.readPrivateFile(this.stateFilePath, GATEWAY_MAX_STATE_FILE_BYTES);
2245
- }
2246
- catch (error) {
2247
- if (error instanceof Error && "code" in error && error.code === "ENOENT") {
2248
- return undefined;
2249
- }
2250
- throw error;
2251
- }
2252
- let parsed;
2253
- try {
2254
- parsed = JSON.parse(body);
2255
- }
2256
- catch {
2257
- throw new BridgeError("CORRUPT_GATEWAY_STATE", "The gateway controller state is not valid JSON.");
2258
- }
2259
- if (isObject(parsed) && Object.hasOwn(parsed, "schemaVersion") && parsed.schemaVersion !== 5) {
2260
- throw new BridgeError("GATEWAY_STATE_SCHEMA_UNSUPPORTED", "The gateway state schema is unsupported. Stop Embassy, move gateway-state.json aside — nodes.json, if you use federation, is untouched — then restart and re-register Codex tasks.");
2261
- }
2262
- if (!isGatewayPersistedStateV5(parsed, this.config.hostId)) {
2263
- // A document that is a valid v5 state on its own terms but not under this
2264
- // broker's host identity is not corrupt: it belongs to another host, and
2265
- // the operator deserves that sentence rather than "failed validation".
2266
- throw isGatewayPersistedStateV5(parsed)
2267
- ? new BridgeError("CORRUPT_GATEWAY_STATE", "The gateway state exceeds its configured bounds or host allowlist.")
2268
- : new BridgeError("CORRUPT_GATEWAY_STATE", "The gateway controller state failed strict v5 schema validation.");
2269
- }
2270
- this.assertConfiguredBounds(parsed);
2271
- return parsed;
2272
- }
2273
- async persist(force = false) {
2274
- if (this.persistenceDeferred && !force)
2275
- return;
2276
- const state = this.persistedProjection(this.requireState());
2277
- const temporary = path.join(this.rootDir, `.gateway-state-${randomUUID()}.tmp`);
2278
- const body = `${JSON.stringify(state, null, 2)}\n`;
2279
- if (Buffer.byteLength(body, "utf8") > GATEWAY_MAX_STATE_FILE_BYTES) {
2280
- throw new BridgeError("GATEWAY_STATE_FILE_TOO_LARGE", "The bounded gateway state exceeds its durable byte limit.");
2281
- }
2282
- const prior = await this.loadStateFile();
2283
- let handle;
2284
- let renameAttempted = false;
2285
- try {
2286
- try {
2287
- handle = await open(temporary, constants.O_CREAT |
2288
- constants.O_EXCL |
2289
- constants.O_WRONLY |
2290
- (constants.O_NOFOLLOW ?? 0), 0o600);
2291
- await handle.writeFile(body, "utf8");
2292
- await handle.chmod(0o600);
2293
- await handle.sync();
2294
- await handle.close();
2295
- handle = undefined;
2296
- try {
2297
- const existing = await lstat(this.stateFilePath);
2298
- if (existing.isSymbolicLink() || !existing.isFile()) {
2299
- throw new BridgeError("UNSAFE_GATEWAY_STATE_FILE", "The gateway state target is not a regular file.");
2300
- }
2301
- this.assertOwnedPrivate(existing.uid, existing.mode, "state file");
2302
- }
2303
- catch (error) {
2304
- if (!(error instanceof Error) ||
2305
- !("code" in error) ||
2306
- error.code !== "ENOENT") {
2307
- throw error;
2308
- }
2309
- }
2310
- renameAttempted = true;
2311
- await this.renameStateFile(temporary, this.stateFilePath);
2312
- await this.afterStateFileRename?.();
2313
- const directory = await open(this.rootDir, constants.O_RDONLY);
2314
- try {
2315
- await directory.sync();
2316
- }
2317
- finally {
2318
- await directory.close();
2319
- }
2320
- }
2321
- catch (error) {
2322
- if (!renameAttempted)
2323
- throw error;
2324
- let installed;
2325
- let readbackFailed = false;
2326
- try {
2327
- installed = await this.loadStateFile();
2328
- }
2329
- catch {
2330
- readbackFailed = true;
2331
- // Rename crossed the commit point; unverifiable authority poisons this instance.
2332
- }
2333
- const installedCurrent = installed?.commit.id === state.commit.id &&
2334
- installed.commit.sequence === state.commit.sequence;
2335
- // The rename crossed the commit point and the file holds this very
2336
- // commit: the write succeeded. The live state stays as it is — the
2337
- // file is its projection, minus the ephemeral rows that must never be
2338
- // read back in, so installing the readback here would silently turn
2339
- // every live ephemeral registration into nothing.
2340
- if (installedCurrent)
2341
- return;
2342
- const installedPrior = !readbackFailed &&
2343
- (prior === undefined
2344
- ? installed === undefined
2345
- : installed?.commit.id === prior.commit.id &&
2346
- installed.commit.sequence === prior.commit.sequence);
2347
- if (installedPrior)
2348
- throw error;
2349
- this.state = undefined;
2350
- throw new PostRenamePersistenceError();
2351
- }
2352
- }
2353
- finally {
2354
- await handle?.close().catch(() => undefined);
2355
- await unlink(temporary).catch(() => undefined);
2356
- }
2357
- }
2358
- }
2359
- //# sourceMappingURL=store.js.map