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.
- package/CHANGELOG.md +42 -0
- package/CONTRIBUTING.md +92 -200
- package/README.md +159 -138
- package/SECURITY.md +191 -335
- package/dist/src/gateway/broker-check.d.ts +26 -0
- package/dist/src/gateway/broker-check.js +89 -0
- package/dist/src/gateway/broker-check.js.map +1 -0
- package/dist/src/gateway/broker-control.d.ts +72 -0
- package/dist/src/gateway/broker-control.js +164 -0
- package/dist/src/gateway/broker-control.js.map +1 -0
- package/dist/src/gateway/broker.d.ts +117 -0
- package/dist/src/gateway/broker.js +186 -0
- package/dist/src/gateway/broker.js.map +1 -0
- package/dist/src/gateway/claude-peer.d.ts +1 -124
- package/dist/src/gateway/claude-peer.js +62 -1053
- package/dist/src/gateway/claude-peer.js.map +1 -1
- package/dist/src/gateway/codex-stateless-transport.d.ts +0 -12
- package/dist/src/gateway/codex-stateless-transport.js +8 -142
- package/dist/src/gateway/codex-stateless-transport.js.map +1 -1
- package/dist/src/gateway/config.d.ts +2 -6
- package/dist/src/gateway/config.js +17 -27
- package/dist/src/gateway/config.js.map +1 -1
- package/dist/src/gateway/coordinator.d.ts +57 -0
- package/dist/src/gateway/coordinator.js +217 -0
- package/dist/src/gateway/coordinator.js.map +1 -0
- package/dist/src/gateway/core-cli.d.ts +15 -0
- package/dist/src/gateway/core-cli.js +282 -0
- package/dist/src/gateway/core-cli.js.map +1 -0
- package/dist/src/gateway/core-service-command.d.ts +16 -0
- package/dist/src/gateway/core-service-command.js +71 -0
- package/dist/src/gateway/core-service-command.js.map +1 -0
- package/dist/src/gateway/endpoint-directory.d.ts +33 -0
- package/dist/src/gateway/endpoint-directory.js +223 -0
- package/dist/src/gateway/endpoint-directory.js.map +1 -0
- package/dist/src/gateway/federation.d.ts +89 -0
- package/dist/src/gateway/federation.js +632 -0
- package/dist/src/gateway/federation.js.map +1 -0
- package/dist/src/gateway/ledger-codec.d.ts +3 -0
- package/dist/src/gateway/ledger-codec.js +159 -0
- package/dist/src/gateway/ledger-codec.js.map +1 -0
- package/dist/src/gateway/ledger.d.ts +138 -0
- package/dist/src/gateway/ledger.js +319 -0
- package/dist/src/gateway/ledger.js.map +1 -0
- package/dist/src/gateway/local-control.d.ts +23 -0
- package/dist/src/gateway/local-control.js +489 -0
- package/dist/src/gateway/local-control.js.map +1 -0
- package/dist/src/gateway/native-destinations.d.ts +34 -0
- package/dist/src/gateway/native-destinations.js +272 -0
- package/dist/src/gateway/native-destinations.js.map +1 -0
- package/dist/src/gateway/owned-state.d.ts +57 -0
- package/dist/src/gateway/owned-state.js +398 -0
- package/dist/src/gateway/owned-state.js.map +1 -0
- package/dist/src/gateway/provenance-envelope.d.ts +3 -3
- package/dist/src/gateway/provenance-envelope.js +3 -23
- package/dist/src/gateway/provenance-envelope.js.map +1 -1
- package/dist/src/gateway/runtime.d.ts +41 -0
- package/dist/src/gateway/runtime.js +175 -0
- package/dist/src/gateway/runtime.js.map +1 -0
- package/docs/CONFIGURATION.md +178 -266
- package/docs/DELIVERY.md +133 -82
- package/docs/GATEWAY-ARCHITECTURE.md +276 -943
- package/package.json +5 -5
- package/skills/embassy-peer/SKILL.md +38 -194
- package/skills/embassy-peer/agents/openai.yaml +2 -2
- package/dist/src/gateway/claude-helper-client.d.ts +0 -2
- package/dist/src/gateway/claude-helper-client.js +0 -2
- package/dist/src/gateway/claude-helper-client.js.map +0 -1
- package/dist/src/gateway/claude-helper-protocol.d.ts +0 -117
- package/dist/src/gateway/claude-helper-protocol.js +0 -122
- package/dist/src/gateway/claude-helper-protocol.js.map +0 -1
- package/dist/src/gateway/claude-helper-supervisor.d.ts +0 -87
- package/dist/src/gateway/claude-helper-supervisor.js +0 -347
- package/dist/src/gateway/claude-helper-supervisor.js.map +0 -1
- package/dist/src/gateway/claude-helper.d.ts +0 -1
- package/dist/src/gateway/claude-helper.js +0 -252
- package/dist/src/gateway/claude-helper.js.map +0 -1
- package/dist/src/gateway/cli.d.ts +0 -57
- package/dist/src/gateway/cli.js +0 -1503
- package/dist/src/gateway/cli.js.map +0 -1
- package/dist/src/gateway/codex-socket-holder.d.ts +0 -26
- package/dist/src/gateway/codex-socket-holder.js +0 -76
- package/dist/src/gateway/codex-socket-holder.js.map +0 -1
- package/dist/src/gateway/control.d.ts +0 -261
- package/dist/src/gateway/control.js +0 -903
- package/dist/src/gateway/control.js.map +0 -1
- package/dist/src/gateway/peer-client.d.ts +0 -58
- package/dist/src/gateway/peer-client.js +0 -217
- package/dist/src/gateway/peer-client.js.map +0 -1
- package/dist/src/gateway/peer-mailbox.d.ts +0 -78
- package/dist/src/gateway/peer-mailbox.js +0 -168
- package/dist/src/gateway/peer-mailbox.js.map +0 -1
- package/dist/src/gateway/peer-protocol.d.ts +0 -105
- package/dist/src/gateway/peer-protocol.js +0 -70
- package/dist/src/gateway/peer-protocol.js.map +0 -1
- package/dist/src/gateway/peer-stdio.d.ts +0 -23
- package/dist/src/gateway/peer-stdio.js +0 -100
- package/dist/src/gateway/peer-stdio.js.map +0 -1
- package/dist/src/gateway/providers.d.ts +0 -163
- package/dist/src/gateway/providers.js +0 -972
- package/dist/src/gateway/providers.js.map +0 -1
- package/dist/src/gateway/server.d.ts +0 -47
- package/dist/src/gateway/server.js +0 -219
- package/dist/src/gateway/server.js.map +0 -1
- package/dist/src/gateway/service.d.ts +0 -373
- package/dist/src/gateway/service.js +0 -2274
- package/dist/src/gateway/service.js.map +0 -1
- package/dist/src/gateway/status-view.d.ts +0 -167
- package/dist/src/gateway/status-view.js +0 -488
- package/dist/src/gateway/status-view.js.map +0 -1
- package/dist/src/gateway/store.d.ts +0 -185
- package/dist/src/gateway/store.js +0 -2359
- package/dist/src/gateway/store.js.map +0 -1
- package/dist/src/gateway/types.d.ts +0 -528
- package/dist/src/gateway/types.js +0 -341
- 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
|