@scp-dev/hub 0.0.0-stage → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js ADDED
@@ -0,0 +1,2878 @@
1
+ // src/federation.ts
2
+ import {
3
+ ACCOUNT_HANDLE,
4
+ admits,
5
+ cleanLabels,
6
+ cleanText,
7
+ effectiveSelector,
8
+ findSecret,
9
+ GONE_PREFIX,
10
+ groupCapKey,
11
+ HUMAN_HANDLE,
12
+ LIMITS,
13
+ MESSAGE_KINDS,
14
+ newId,
15
+ one,
16
+ randomHex,
17
+ RelayCore,
18
+ RelayError,
19
+ STATUS_STATES,
20
+ SYSTEM_HANDLE,
21
+ seqList,
22
+ unreachable,
23
+ validHandle,
24
+ validHarness,
25
+ validSelector
26
+ } from "@scp-dev/core";
27
+ var MACHINE_NAME = /^[a-z0-9][a-z0-9-]{0,31}$/;
28
+ var REMOTE = /^[a-z0-9][a-z0-9._-]{0,63}\/([a-z0-9][a-z0-9._-]{0,62})?$/;
29
+ var MESSAGE_ID = /^[a-z]{2,8}_[a-z0-9]{8,64}$/;
30
+ var AUTHS = ["local-uid", "account", "account+bridge", "contact"];
31
+ var PRESENCES = ["online", "idle", "offline"];
32
+ var UNKNOWN = { tree: null, parent_ep: null, depth: null };
33
+ var REFS = /* @__PURE__ */ new Set(["human", "policy", "inherited", "gone"]);
34
+ var INCARNATION = /^[A-Za-z0-9._:-]{1,128}$/;
35
+ var SCOPE_BYTES = 16 * 1024;
36
+ var XACCT_MIGRATION = [
37
+ `CREATE TABLE IF NOT EXISTS xinbox (seq INTEGER PRIMARY KEY, id TEXT NOT NULL, from_account TEXT NOT NULL, client_id TEXT NOT NULL, endpoint TEXT NOT NULL, sender TEXT NOT NULL, message TEXT NOT NULL, thread TEXT NOT NULL, grant TEXT, expires_at INTEGER NOT NULL, at INTEGER NOT NULL, UNIQUE (from_account, client_id))`,
38
+ `CREATE TABLE IF NOT EXISTS xcursor (endpoint TEXT PRIMARY KEY, acked_seq INTEGER NOT NULL, at INTEGER, old_seq INTEGER, old_at INTEGER) WITHOUT ROWID`,
39
+ `CREATE TABLE IF NOT EXISTS xstate (key TEXT PRIMARY KEY, value TEXT) WITHOUT ROWID`,
40
+ `CREATE TABLE IF NOT EXISTS xcounters (contact TEXT PRIMARY KEY, day INTEGER, sent INTEGER, at INTEGER) WITHOUT ROWID`
41
+ ];
42
+ var XINBOX_KEEP_MS = 864e5;
43
+ var SEQ_TOP = `MAX(COALESCE((SELECT seq FROM sqlite_sequence WHERE name = 'deliveries'), 0), COALESCE((SELECT MAX(seq) FROM xinbox), 0),
44
+ COALESCE((SELECT MAX(acked_seq) FROM xcursor), 0), COALESCE((SELECT CAST(value AS INTEGER) FROM xstate WHERE key = 'seq_floor'), 0))`;
45
+ var machinePart = (remote) => remote.slice(0, remote.indexOf("/"));
46
+ var isForeign = (remote) => remote.startsWith("@");
47
+ var foreignOf = (remote) => remote.slice(1, remote.indexOf("/"));
48
+ function foreignNode(peer) {
49
+ const address = `${peer.account}/${peer.machine}/${peer.handle || "me"}`;
50
+ return {
51
+ id: `@${address}`,
52
+ address,
53
+ subject: { handle: peer.handle, harness: null, kind: peer.handle === "" ? "human" : "agent", labels: {}, account: peer.account },
54
+ rules: {},
55
+ caps: { caps: [], allow: {} },
56
+ groups: [],
57
+ policy: []
58
+ };
59
+ }
60
+ var handlePart = (remote) => remote.slice(remote.indexOf("/") + 1);
61
+ var FederatedCore = class extends RelayCore {
62
+ machine;
63
+ aliases;
64
+ scope = null;
65
+ defaultAuth;
66
+ endpointCap;
67
+ refusalsPerMinute;
68
+ refusals = /* @__PURE__ */ new Map();
69
+ xinbox;
70
+ xfrom = null;
71
+ constructor(sql, options) {
72
+ super(sql, options);
73
+ this.machine = options.machine ?? null;
74
+ this.aliases = new Set(options.aliases ?? []);
75
+ this.defaultAuth = options.auth ?? "local-uid";
76
+ this.endpointCap = options.endpointCap ?? null;
77
+ this.refusalsPerMinute = options.refusalsPerMinute ?? null;
78
+ this.xinbox = options.xinbox === true;
79
+ }
80
+ migrate() {
81
+ super.migrate();
82
+ const columns = this.sql.all(`PRAGMA table_info(endpoints)`).map((c) => c.name);
83
+ if (!columns.includes("remote")) this.sql.all(`ALTER TABLE endpoints ADD COLUMN remote TEXT`);
84
+ if (!columns.includes("auth")) this.sql.all(`ALTER TABLE endpoints ADD COLUMN auth TEXT`);
85
+ if (!columns.includes("incarnation")) this.sql.all(`ALTER TABLE endpoints ADD COLUMN incarnation TEXT`);
86
+ if (!columns.includes("scope_epoch")) this.sql.all(`ALTER TABLE endpoints ADD COLUMN scope_epoch INTEGER`);
87
+ if (!columns.includes("mirrored_caps")) this.sql.all(`ALTER TABLE endpoints ADD COLUMN mirrored_caps TEXT`);
88
+ this.sql.all(`CREATE UNIQUE INDEX IF NOT EXISTS endpoints_remote ON endpoints (remote) WHERE remote IS NOT NULL`);
89
+ if (this.xinbox) for (const statement of XACCT_MIGRATION) this.sql.all(statement);
90
+ }
91
+ /** with xinbox, a delivery takes the next seq past xinbox's too, so a bridge's replay and ack cursor stay one space */
92
+ insertDelivery(messageId, endpoint, expiresAt) {
93
+ if (!this.xinbox) return super.insertDelivery(messageId, endpoint, expiresAt);
94
+ return one(
95
+ this.sql,
96
+ `INSERT INTO deliveries (seq, message_id, endpoint, expires_at) VALUES (1 + ${SEQ_TOP}, ?, ?, ?) RETURNING seq`,
97
+ messageId,
98
+ endpoint,
99
+ expiresAt
100
+ ).seq;
101
+ }
102
+ setAliases(aliases) {
103
+ this.aliases = new Set(aliases);
104
+ }
105
+ /** runs `fn` with unqualified handles resolving on `machine`: a direct client's own namespace on the hub */
106
+ within(machine, fn) {
107
+ const previous = this.scope;
108
+ this.scope = machine;
109
+ try {
110
+ return fn();
111
+ } finally {
112
+ this.scope = previous;
113
+ }
114
+ }
115
+ // addresses
116
+ /** the hub-side address (`<machine>/<handle>`) an address names, another account's as it is, or null when it is on this machine */
117
+ route(address) {
118
+ if (this.foreignAccount(address)) return String(address).trim();
119
+ const { machine, handle } = this.locate(address);
120
+ return machine === null ? null : `${machine}/${handle}`;
121
+ }
122
+ /** whether an address names an endpoint on another machine or of another account; malformed addresses are not remote */
123
+ isRemote(address) {
124
+ try {
125
+ return this.route(address) !== null;
126
+ } catch {
127
+ return false;
128
+ }
129
+ }
130
+ /** the local endpoint id a hub delivery for `<machine>/<handle>` is for */
131
+ localTarget(target) {
132
+ if (typeof target !== "string" || !REMOTE.test(target) || machinePart(target) !== this.machine)
133
+ throw new RelayError("not_found", `no endpoint ${String(target)} on this machine`);
134
+ const handle = handlePart(target);
135
+ const row = handle === "" ? this.byHandle(HUMAN_HANDLE) : validHandle(handle) ? this.byHandle(handle) : void 0;
136
+ if (!row) throw new RelayError("not_found", `no endpoint ${target} on this machine`);
137
+ return row.id;
138
+ }
139
+ /** the live row of `<machine>/<handle>`, for a bridge acting on its own machine's endpoints */
140
+ remoteEndpoint(machine, handle) {
141
+ if (typeof handle !== "string" || handle.includes("/")) throw new RelayError("invalid_params", "from is a handle on the bridge's own machine");
142
+ const row = this.byRemote(`${machine}/${handle}`);
143
+ if (!row) throw new RelayError("not_found", `no endpoint ${machine}/${handle}`);
144
+ return row.id;
145
+ }
146
+ /** the machine an endpoint row belongs to, or null for a row of this host */
147
+ machineOf(endpoint) {
148
+ const row = this.byId(endpoint);
149
+ return row?.remote ? machinePart(row.remote) : null;
150
+ }
151
+ /** `<account>/<machine>/<handle>` of another account (handle empty for its machine's human, `me`), or null for any other address */
152
+ foreignAccount(address) {
153
+ if (typeof address !== "string") return null;
154
+ const parts = address.trim().split("/");
155
+ if (parts.length !== 3) return null;
156
+ const [account, machine, handle] = parts;
157
+ if (this.isLocalUser(account) || !ACCOUNT_HANDLE.test(account) || !MACHINE_NAME.test(machine)) return null;
158
+ if (handle !== "me" && handle !== "" && !validHandle(handle)) return null;
159
+ return { account, machine, handle: handle === "me" ? "" : handle };
160
+ }
161
+ isLocalUser(part) {
162
+ return part === "me" || part === this.user || this.aliases.has(part);
163
+ }
164
+ /** whether an endpoint of another account `part` ever reached this relay, so `<part>/me` is that account's human */
165
+ knownAccount(part) {
166
+ return !this.isLocalUser(part) && ACCOUNT_HANDLE.test(part) && one(this.sql, `SELECT 1 FROM endpoints WHERE substr(remote, 1, ?) = ? LIMIT 1`, part.length + 2, `@${part}/`) !== void 0;
167
+ }
168
+ locate(address) {
169
+ if (typeof address !== "string" || !address.trim()) throw new RelayError("invalid_params", "an address is machine/handle, user/handle, or a handle");
170
+ const text = address.trim();
171
+ const parts = text.split("/");
172
+ let machine;
173
+ let handle;
174
+ if (parts.length === 1) [machine, handle] = [null, parts[0]];
175
+ else if (parts.length === 2 && parts[1] === "me" && this.knownAccount(parts[0]))
176
+ throw new RelayError(
177
+ "invalid_params",
178
+ `${text} is account ${parts[0]}'s human, which takes no messages yet; message one of its endpoints (${parts[0]}/<machine>/<handle>)`
179
+ );
180
+ else if (parts.length === 2) [machine, handle] = [this.isLocalUser(parts[0]) ? null : parts[0], parts[1]];
181
+ else if (parts.length === 3 && this.isLocalUser(parts[0])) [machine, handle] = [parts[1], parts[2]];
182
+ else throw unreachable(text);
183
+ if (machine !== null && handle === "me") handle = "";
184
+ if (machine === null && this.scope !== null && handle !== "") machine = this.scope;
185
+ if (machine !== null && machine === this.machine) machine = null;
186
+ return { machine, handle };
187
+ }
188
+ resolve(address) {
189
+ const { machine, handle } = this.locate(address);
190
+ const text = String(address).trim();
191
+ if (machine === null) {
192
+ const row2 = handle === "" ? this.byHandle(HUMAN_HANDLE) : validHandle(handle) ? this.byHandle(handle) : void 0;
193
+ if (!row2) throw unreachable(text);
194
+ return row2;
195
+ }
196
+ const row = this.byRemote(`${machine}/${handle}`);
197
+ if (!row) throw unreachable(text);
198
+ return row;
199
+ }
200
+ addressUsers() {
201
+ return [this.user, "me", ...this.aliases];
202
+ }
203
+ localRow(row) {
204
+ return !row.remote;
205
+ }
206
+ address(row) {
207
+ const remote = row.remote;
208
+ if (remote && isForeign(remote)) return remote.slice(1);
209
+ return remote ? `${this.user}/${remote}` : super.address(row);
210
+ }
211
+ subject(row) {
212
+ const remote = row.remote;
213
+ if (!remote) return super.subject(row);
214
+ return {
215
+ handle: "",
216
+ harness: row.harness,
217
+ kind: row.kind,
218
+ labels: JSON.parse(row.labels),
219
+ ...isForeign(remote) ? { account: foreignOf(remote) } : {}
220
+ };
221
+ }
222
+ /** a stand-in for another account's sender is no endpoint of this relay: list, peers and snapshots leave it out */
223
+ listable(row) {
224
+ const remote = row.remote;
225
+ return !remote || !isForeign(remote);
226
+ }
227
+ /** an address of another account (a sender's stand-in, or a target with none) is checked as that account's endpoint */
228
+ policySubject(address) {
229
+ const proxy = typeof address === "string" ? this.byRemote(`@${address.trim()}`) : void 0;
230
+ if (proxy) return this.subject(proxy);
231
+ const foreign = this.foreignAccount(address);
232
+ if (foreign) return foreignNode(foreign).subject;
233
+ return super.policySubject(address);
234
+ }
235
+ /**
236
+ * another machine's row sits in no local tree: a fleet of its own on its machine, so a scoped local endpoint reaches it only by a leaving
237
+ * selector; its mirrored ancestors are kept so a cap widened by one of them is clipped as on its machine
238
+ */
239
+ scopeSubject(row) {
240
+ const remote = row.remote;
241
+ if (!remote) return super.scopeSubject(row);
242
+ const ancestors = [];
243
+ for (let id = row.parent_ep; id && id !== row.id && !ancestors.includes(id) && ancestors.length <= LIMITS.depthMax; id = this.byId(id)?.parent_ep ?? null)
244
+ ancestors.push(id);
245
+ return { id: row.id, tree: row.tree ?? row.id, ancestors, groups: [], rootHandle: "", machine: machinePart(remote), local: false };
246
+ }
247
+ /** another machine's row has the rules and caps its machine mirrored; only the hub's own human (a full login on the hub) is the account's human */
248
+ scopeNode(row) {
249
+ const remote = row.remote;
250
+ if (!remote) return super.scopeNode(row);
251
+ if (isForeign(remote))
252
+ return { id: row.id, address: this.address(row), subject: this.subject(row), rules: {}, caps: { caps: [], allow: {} }, groups: [], policy: [] };
253
+ const subject = row.kind === "system" ? this.subject(row) : { ...this.subject(row), scope: this.scopeSubject(row) };
254
+ const human = row.kind === "human" && handlePart(remote) === "" && this.isLocalUser(machinePart(remote));
255
+ const caps = row.caps ? JSON.parse(row.caps) : { caps: [], allow: {} };
256
+ return {
257
+ id: row.id,
258
+ address: this.address(row),
259
+ subject,
260
+ human,
261
+ rules: row.rules ? JSON.parse(row.rules) : {},
262
+ caps: { caps: caps.caps, allow: caps.allow ?? {} },
263
+ groups: [],
264
+ policy: []
265
+ };
266
+ }
267
+ /**
268
+ * throws not_found, as send() does, when the scope refuses a local endpoint's message to an address on another machine, a reply
269
+ * (`replyTo`, its parent) to a message it got from that address passing as a solicited one; to another account, the cross-account rule on
270
+ * the sender's out lists, unless it replies to a message it got from exactly that address
271
+ */
272
+ scopeCheck(from, to, replyTo) {
273
+ const foreign = this.foreignAccount(to);
274
+ if (!foreign) {
275
+ const parent = replyTo === void 0 ? void 0 : one(this.sql, `SELECT * FROM messages WHERE id = ?`, String(replyTo));
276
+ this.checkScope(this.mustEndpoint(from), this.resolve(to), String(to).trim(), parent);
277
+ return;
278
+ }
279
+ const sender = this.mustEndpoint(from);
280
+ const text = String(to).trim();
281
+ if (replyTo !== void 0) {
282
+ const parent = one(
283
+ this.sql,
284
+ `SELECT m.from_ep AS from_ep FROM messages m JOIN deliveries d ON d.message_id = m.id WHERE m.id = ? AND d.endpoint = ?`,
285
+ replyTo,
286
+ from
287
+ );
288
+ const origin = parent ? this.byId(parent.from_ep) : void 0;
289
+ if (origin && this.address(origin) === text) return;
290
+ }
291
+ const admission = this.crossAdmission(from, "out", foreignNode(foreign));
292
+ if (admission.ok) return;
293
+ this.audit("scope_refused", from, null, { from: this.address(sender), to: text, dir: "out", reason: admission.why });
294
+ throw new RelayError("not_found", `no endpoint ${text}`);
295
+ }
296
+ /** whether direction `dir` of `endpoint` admits an endpoint of another account: only an `acct:` selector in each list, or no list at all */
297
+ crossAdmission(endpoint, dir, peer) {
298
+ const world = this.scopeWorld();
299
+ const node = world.node(endpoint);
300
+ if (!node) return { ok: false, why: "the endpoint is gone" };
301
+ return admits(world, node, dir, "id" in peer ? peer : foreignNode(peer));
302
+ }
303
+ envelope(row, seq, recipient) {
304
+ const envelope = super.envelope(row, seq, recipient);
305
+ const sender = this.byId(row.from_ep);
306
+ if (sender?.remote && isForeign(sender.remote)) {
307
+ const account = foreignOf(sender.remote);
308
+ const parts = sender.remote.slice(1).split("/");
309
+ const { rel: _, ...from } = envelope.from;
310
+ envelope.from = {
311
+ ...from,
312
+ user: account,
313
+ auth: "contact",
314
+ account,
315
+ rel: `contact:${account}`,
316
+ ...parts.length === 3 ? { machine: parts[1] } : {},
317
+ ...sender.kind === "human" ? { human: true } : {}
318
+ };
319
+ return envelope;
320
+ }
321
+ const machine = sender?.remote ? machinePart(sender.remote) : null;
322
+ const other = machine !== null && !this.isLocalUser(machine);
323
+ const human = other && sender?.kind === "human" && row.sender_tty === 1;
324
+ envelope.from = { ...envelope.from, auth: sender?.auth ?? this.defaultAuth, ...other ? { machine } : {}, ...human ? { human: true } : {} };
325
+ return envelope;
326
+ }
327
+ // mirrored and claimed rows
328
+ /**
329
+ * upserts a machine's endpoints from its views, never over a direct client's claimed row; `full` marks its mirrored rows missing from the
330
+ * list offline. An agent's place is worked out here from its mirrored parent, and a cap seen once stays until the endpoint is released
331
+ * (a new `incarnation`). A row is written only when something in it changed. `seen` gathers the remotes of a full mirror sent in several
332
+ * frames: each frame adds its own, and the closing `full` one counts every remote in it as listed.
333
+ */
334
+ mirror(machine, views, options = {}) {
335
+ this.checkMachine(machine);
336
+ if (!Array.isArray(views)) throw new RelayError("invalid_params", "endpoints must be a list");
337
+ if (views.length > (this.endpointCap ?? 4096)) throw new RelayError("too_large", `at most ${this.endpointCap ?? 4096} endpoints`);
338
+ const parsed = [...new Map(views.map((view) => this.parseView(view)).map((view) => [view.handle, view])).values()];
339
+ const auth = options.auth ?? null;
340
+ return this.tx(() => {
341
+ const now = this.now();
342
+ const changes = [];
343
+ const seen = options.seen ?? /* @__PURE__ */ new Set();
344
+ const batch = [];
345
+ for (const view of parsed) {
346
+ const remote = `${machine}/${view.handle}`;
347
+ seen.add(remote);
348
+ const before = this.byRemote(remote);
349
+ if (before && before.token_hash !== null) continue;
350
+ if (!before) this.checkCap();
351
+ batch.push({ view, id: before?.id ?? this.insertRemote(remote, view.kind, view.harness, auth, now), before });
352
+ }
353
+ const place = this.placer(machine, batch);
354
+ for (const { view, id, before } of batch) {
355
+ const row = before ?? this.byId(id);
356
+ if (before && view.kind === "agent") this.dropEarlierHolder(before, view.scope.incarnation);
357
+ const live = view.presence !== "offline";
358
+ const next = {
359
+ kind: view.kind,
360
+ harness: view.harness,
361
+ model: view.model,
362
+ cwd: view.cwd,
363
+ labels: view.labels,
364
+ status_state: view.status?.state ?? null,
365
+ status_note: view.status?.note ?? null,
366
+ token_hash: null,
367
+ connections: live ? 1 : 0,
368
+ last_seen: view.lastSeen ?? (live ? now : before?.last_seen ?? null),
369
+ auth: auth ?? row.auth,
370
+ ...view.kind === "agent" ? this.scopeColumns(machine, view.scope, row, place(view.handle)) : {}
371
+ };
372
+ const columns = Object.entries(next).filter(([column, value]) => row[column] !== value);
373
+ if (!columns.length) continue;
374
+ this.sql.all(
375
+ `UPDATE endpoints SET ${columns.map(([column]) => `${column} = ?`).join(", ")} WHERE id = ?`,
376
+ ...columns.map(([, value]) => value),
377
+ id
378
+ );
379
+ const change = this.diff(before, this.byId(id));
380
+ if (change) changes.push(change);
381
+ }
382
+ if (options.full) {
383
+ const missing = this.sql.all(
384
+ `SELECT * FROM endpoints WHERE substr(remote, 1, ?) = ? AND token_hash IS NULL AND connections > 0`,
385
+ machine.length + 1,
386
+ `${machine}/`
387
+ );
388
+ for (const row of missing.filter((r) => !seen.has(r.remote))) {
389
+ this.sql.all(`UPDATE endpoints SET connections = 0, last_seen = ? WHERE id = ?`, now, row.id);
390
+ changes.push({ view: this.remoteView(this.byId(row.id)), added: false, presence: true, status: false });
391
+ }
392
+ }
393
+ return changes;
394
+ });
395
+ }
396
+ /**
397
+ * a local agent's place, rules and caps as its machine's bridge mirrors them (MirrorScope), with its title; nothing for another row. Caps
398
+ * looser than the ones mirrored last move the endpoint's scope epoch and so its incarnation: a hub keeps the caps it has seen until that changes
399
+ */
400
+ mirrorScope(endpoint) {
401
+ const row = this.mustEndpoint(endpoint);
402
+ if (row.kind !== "agent" || !this.localRow(row)) return {};
403
+ const node = this.scopeNode(row);
404
+ const root = row.tree ? this.byId(row.tree) : row;
405
+ const parent = row.parent_ep ? this.byId(row.parent_ep) : void 0;
406
+ const caps = [...node.caps.caps];
407
+ for (const group of node.groups) {
408
+ for (const dir of ["in", "out"]) {
409
+ const rules2 = group[dir];
410
+ if (!rules2) continue;
411
+ const list = { source: "", by: group.owner, group: group.name, entries: [] };
412
+ const sels = [...rules2, ...node.caps.allow[groupCapKey(group.name, dir)] ?? []].map((entry) => ({ ...entry, sel: effectiveSelector(list, entry) }));
413
+ caps.push({ id: groupCapKey(group.name, dir), dir, by: group.owner, sels });
414
+ }
415
+ }
416
+ for (const cap of node.policy) caps.push({ id: `policy:${cap.match}:${cap.dir}`, dir: cap.dir, by: "policy", sels: cap.sels });
417
+ const allow = Object.fromEntries(Object.entries(node.caps.allow).filter(([key]) => key.startsWith("inherited:")));
418
+ const rules = { ...node.rules.in ? { in: node.rules.in } : {}, ...node.rules.out ? { out: node.rules.out } : {} };
419
+ const wire = caps.length || Object.keys(allow).length ? this.wireCaps({ caps, allow }) : null;
420
+ const epoch = this.scopeEpoch(row, wire);
421
+ return {
422
+ tree: root?.handle ?? null,
423
+ parent_ep: parent?.handle ?? null,
424
+ depth: row.depth ?? 0,
425
+ rules: Object.keys(rules).length ? rules : null,
426
+ caps: wire,
427
+ title: row.title ?? null,
428
+ incarnation: `${this.hash(row.spawned_at ? `${row.id}@${row.spawned_at}` : row.id).slice(0, 24)}.${epoch}`
429
+ };
430
+ }
431
+ scopeEpoch(row, caps) {
432
+ const epoch = row.scope_epoch ?? 0;
433
+ const text = caps ? JSON.stringify(caps) : null;
434
+ if (text === row.mirrored_caps) return epoch;
435
+ const seen = row.mirrored_caps ? JSON.parse(row.mirrored_caps) : null;
436
+ const next = seen && loosens(seen, caps ?? { caps: [] }) ? epoch + 1 : epoch;
437
+ this.sql.all(`UPDATE endpoints SET scope_epoch = ?, mirrored_caps = ? WHERE id = ?`, next || null, text, row.id);
438
+ return next;
439
+ }
440
+ /**
441
+ * a direct client's claim of `<machine>/<handle>`, with claim()'s takeover and suffix rules, and its `exact`, `scope`, `allow`, `in` and
442
+ * `out`: a new row takes the claim's own rules, a taken-over one keeps its own. An exact claim is never suffixed: it joins a live claimed
443
+ * row of the same harness no process or session holds, with no new token, and is refused as claim() refuses one otherwise
444
+ */
445
+ claimOn(machine, claim, auth = "account") {
446
+ this.checkMachine(machine);
447
+ const harness = cleanText(claim.harness, "harness", 32) ?? "cli";
448
+ if (!validHarness(harness)) throw new RelayError("invalid_params", `harness ${JSON.stringify(harness)} is not a valid name`);
449
+ const wanted = cleanText(claim.handle, "handle", 63) ?? harness;
450
+ if (!validHandle(wanted)) throw new RelayError("invalid_params", `handle ${JSON.stringify(wanted)} is not valid: lowercase letters, digits, . _ -`);
451
+ const fields = {
452
+ model: cleanText(claim.model, "model", 128),
453
+ cwd: cleanText(claim.cwd, "cwd", 1024),
454
+ parent: cleanText(claim.parent, "parent", 128),
455
+ labels: JSON.stringify(cleanLabels(claim.labels))
456
+ };
457
+ const leaked = [wanted, fields.model, fields.cwd, fields.parent, fields.labels].map((v) => v ? findSecret(v) : null).find(Boolean);
458
+ if (leaked) throw new RelayError("secret_refused", `the claim looks like it carries a credential (${leaked})`);
459
+ const rules = this.claimRules(claim);
460
+ const exact = claim.exact === true;
461
+ return this.tx(() => {
462
+ const now = this.now();
463
+ let row;
464
+ let created = false;
465
+ let joined = false;
466
+ for (let n = 1; n < (exact ? 2 : 1e3) && !row; n++) {
467
+ const handle = n === 1 ? wanted : `${wanted.slice(0, 58)}-${n}`;
468
+ const held = this.byRemote(`${machine}/${handle}`);
469
+ if (!held) {
470
+ this.checkCap();
471
+ row = this.byId(this.insertRemote(`${machine}/${handle}`, "agent", harness, auth, now));
472
+ created = true;
473
+ } else if (held.kind === "agent" && held.connections === 0 && held.harness === harness) {
474
+ row = held;
475
+ } else if (exact && held.kind === "agent" && held.token_hash !== null && held.process === null && held.session === null && held.parent_ep === null && held.harness === harness) {
476
+ row = held;
477
+ joined = true;
478
+ } else if (held.kind === "agent" && held.connections === 0 && (held.last_seen ?? held.created_at) < now - LIMITS.handleBoundS * 1e3) {
479
+ this.retire(held.id);
480
+ n--;
481
+ }
482
+ }
483
+ if (!row) {
484
+ if (exact) throw this.exactRefusal(this.byRemote(`${machine}/${wanted}`), wanted, harness, now);
485
+ throw new RelayError("invalid_params", `no free handle for ${wanted} on ${machine}`);
486
+ }
487
+ if (created) {
488
+ const own = this.boundRules(rules);
489
+ if (Object.keys(own).length) this.sql.all(`UPDATE endpoints SET rules = ? WHERE id = ?`, JSON.stringify(own), row.id);
490
+ for (const dir of ["in", "out"]) this.needGroups(this.byId(row.id), rules[dir]);
491
+ }
492
+ const token = joined ? void 0 : `kre_${randomHex(32)}`;
493
+ this.sql.all(
494
+ `UPDATE endpoints SET harness = ?, model = ?, cwd = ?, parent = ?, labels = ?, token_hash = ${token ? "?" : "token_hash"}, auth = ?, connections = connections + 1, last_seen = ? WHERE id = ?`,
495
+ harness,
496
+ fields.model,
497
+ fields.cwd,
498
+ fields.parent,
499
+ fields.labels,
500
+ ...token ? [this.hash(token)] : [],
501
+ auth,
502
+ now,
503
+ row.id
504
+ );
505
+ this.audit("claim", row.id, null, { handle: row.remote, harness, ...joined ? { joined: true } : {} });
506
+ return { endpoint: this.view(this.byId(row.id)), ...token ? { token } : {} };
507
+ });
508
+ }
509
+ /**
510
+ * resume() for a direct client of `machine`, its labels replaced by what `relabel` makes of them, held to the label limits: a token of
511
+ * another machine's endpoint is refused
512
+ */
513
+ resumeOn(machine, token, relabel) {
514
+ const row = typeof token === "string" ? one(this.sql, `SELECT * FROM endpoints WHERE token_hash = ?`, this.hash(token)) : void 0;
515
+ if (!row?.remote || machinePart(row.remote) !== machine) throw new RelayError("unauthorized", "unknown resume token");
516
+ if (!relabel) return this.resume(token);
517
+ const merged = JSON.stringify(cleanLabels(relabel(JSON.parse(row.labels))));
518
+ return this.tx(() => {
519
+ if (merged !== row.labels) this.sql.all(`UPDATE endpoints SET labels = ? WHERE id = ?`, merged, row.id);
520
+ return this.resume(token);
521
+ });
522
+ }
523
+ /** every live row of other machines (or of one machine), as views with their machine */
524
+ remoteViews(filter = {}) {
525
+ return this.sql.all(`SELECT * FROM endpoints WHERE remote IS NOT NULL ORDER BY remote`).filter((row) => (!filter.except || machinePart(row.remote) !== filter.except) && (!filter.machine || machinePart(row.remote) === filter.machine)).map((row) => this.remoteView(row));
526
+ }
527
+ remoteRowsOf(machine) {
528
+ return this.remoteViews({ machine });
529
+ }
530
+ /** a bridge went away: its mirrored rows show offline */
531
+ markOffline(machine) {
532
+ const rows = this.sql.all(
533
+ `UPDATE endpoints SET connections = 0, last_seen = ? WHERE substr(remote, 1, ?) = ? AND token_hash IS NULL AND connections > 0 RETURNING id`,
534
+ this.now(),
535
+ machine.length + 1,
536
+ `${machine}/`
537
+ );
538
+ return rows.map((row) => ({ view: this.remoteView(this.byId(row.id)), added: false, presence: true, status: false }));
539
+ }
540
+ /**
541
+ * a released machine: its rows are retired like a gone handle and can't be resolved or resumed again; with `queue`,
542
+ * their unread deliveries go too, and each message they leave delivered to no one, unless it is a request or has
543
+ * replies, which askers still look up
544
+ */
545
+ dropMachine(machine, options = {}) {
546
+ const rows = this.sql.all(
547
+ `UPDATE endpoints SET handle = ? || id, remote = NULL, token_hash = NULL, connections = 0 WHERE substr(remote, 1, ?) = ? RETURNING id`,
548
+ GONE_PREFIX,
549
+ machine.length + 1,
550
+ `${machine}/`
551
+ );
552
+ if (options.queue && this.xinbox) {
553
+ this.xfloor();
554
+ this.sql.all(`DELETE FROM xinbox WHERE substr(endpoint, 1, ?) = ?`, machine.length + 1, `${machine}/`);
555
+ }
556
+ if (options.queue) {
557
+ for (const { id } of rows) {
558
+ for (const { message_id } of this.sql.all(
559
+ `DELETE FROM deliveries WHERE endpoint = ? AND acked_at IS NULL RETURNING message_id`,
560
+ id
561
+ )) {
562
+ this.sql.all(
563
+ `DELETE FROM messages WHERE id = ? AND kind != 'request' AND NOT EXISTS (SELECT 1 FROM deliveries WHERE message_id = ?) AND NOT EXISTS (SELECT 1 FROM messages WHERE reply_to = ?)`,
564
+ message_id,
565
+ message_id,
566
+ message_id
567
+ );
568
+ }
569
+ }
570
+ }
571
+ return rows.length;
572
+ }
573
+ /**
574
+ * hands a machine's direct-client rows with no connection (or only `endpoint`) to its bridge: their resume tokens are
575
+ * dropped, so the bridge mirrors them, and their unread deliveries get fresh seqs past any bridge's replay cursor;
576
+ * returns those deliveries
577
+ */
578
+ adoptClaimed(machine, endpoint) {
579
+ return this.tx(() => {
580
+ const now = this.now();
581
+ const rows = this.sql.all(
582
+ `UPDATE endpoints SET token_hash = NULL WHERE substr(remote, 1, ?) = ? AND token_hash IS NOT NULL AND connections = 0 ${endpoint ? "AND id = ?" : ""} RETURNING id`,
583
+ ...[machine.length + 1, `${machine}/`, ...endpoint ? [endpoint] : []]
584
+ );
585
+ const adopted = [];
586
+ for (const { id } of rows) {
587
+ const unread = this.sql.all(
588
+ `DELETE FROM deliveries WHERE endpoint = ? AND acked_at IS NULL AND expires_at > ? RETURNING seq, message_id, expires_at`,
589
+ id,
590
+ now
591
+ );
592
+ for (const row of unread.sort((a, b) => a.seq - b.seq)) {
593
+ const seq = this.insertDelivery(row.message_id, id, row.expires_at);
594
+ adopted.push({ seq, endpoint: id });
595
+ }
596
+ }
597
+ return adopted;
598
+ });
599
+ }
600
+ // hops
601
+ /** the hop a message from this endpoint would get from what it received lately */
602
+ causalHop(endpoint) {
603
+ const row = this.mustEndpoint(endpoint);
604
+ return row.last_received_at !== null && row.last_received_at >= this.now() - LIMITS.causalWindowS * 1e3 ? (row.last_received_hop ?? 0) + 1 : 0;
605
+ }
606
+ /** records that an endpoint received a message of `hop`, as reading its inbox does */
607
+ markReceived(endpoint, hop) {
608
+ const now = this.now();
609
+ this.sql.all(
610
+ `UPDATE endpoints SET last_received_at = ?, last_received_hop = MAX(?, CASE WHEN last_received_at >= ? THEN COALESCE(last_received_hop, 0) ELSE 0 END) WHERE id = ?`,
611
+ now,
612
+ hop,
613
+ now - LIMITS.causalWindowS * 1e3,
614
+ endpoint
615
+ );
616
+ }
617
+ // messages across machines
618
+ /**
619
+ * why the local policy refuses a hub delivery to the local `endpoint`, or null; checks the message as admit() does and creates its sender's
620
+ * proxy row; relay notices always pass. A scope refusal throws not_found, as send() does; a redelivery of an admitted message skips it.
621
+ */
622
+ inboundRefusal(endpoint, message) {
623
+ const recipient = this.mustEndpoint(endpoint);
624
+ this.checkEnvelope(message);
625
+ const sender = this.byId(this.tx(() => this.proxyFor(message.from)));
626
+ if (sender.kind === "system") return null;
627
+ if (!one(this.sql, `SELECT 1 FROM deliveries WHERE message_id = ? AND endpoint = ?`, message.id, endpoint)) {
628
+ const parent = message.kind === "reply" && message.reply_to ? one(this.sql, `SELECT * FROM messages WHERE id = ?`, message.reply_to) : void 0;
629
+ const remote = sender.remote;
630
+ if (remote && isForeign(remote)) this.checkForeignIn(sender, recipient, parent);
631
+ else this.checkScope(sender, recipient, this.address(recipient), parent);
632
+ }
633
+ return this.checkPolicy(this.addressOf(sender.id), this.addressOf(endpoint));
634
+ }
635
+ /**
636
+ * a message the hub delivered to a local endpoint, stored under the hub's id from a proxy of its sender; idempotent.
637
+ * Throws inbox_full past the inbox cap, which a redelivery, the first reply to the endpoint's own message (as in
638
+ * send()) and relay notices skip.
639
+ */
640
+ admit(endpoint, message) {
641
+ this.mustEndpoint(endpoint);
642
+ const checked = this.checkEnvelope(message);
643
+ const stored2 = this.tx(() => {
644
+ const sender = this.proxyFor(message.from);
645
+ const had = one(this.sql, `SELECT seq FROM deliveries WHERE message_id = ? AND endpoint = ?`, message.id, endpoint);
646
+ if (had) return { seq: had.seq, duplicate: true };
647
+ const unacked = this.inboxFull(endpoint, sender, message);
648
+ if (unacked !== null) return { sender, unacked };
649
+ this.sql.all(
650
+ `INSERT OR IGNORE INTO messages (id, thread, reply_to, kind, from_ep, to_addr, body, mime, data_class, attachments, hop, created_at, expires_at, sender_tty, require_reply)
651
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
652
+ message.id,
653
+ message.thread,
654
+ message.reply_to,
655
+ message.kind,
656
+ sender,
657
+ message.to,
658
+ message.body.text,
659
+ message.body.mime,
660
+ message.data_class,
661
+ JSON.stringify(message.attachments.map((a) => ({ kind: "path", path: a.path }))),
662
+ message.hop,
663
+ checked.createdAt,
664
+ checked.expiresAt,
665
+ message.from.claims.tty === void 0 ? null : message.from.claims.tty ? 1 : 0,
666
+ message.require_reply === true && message.kind === "request" ? 1 : null
667
+ );
668
+ const seq = this.insertDelivery(message.id, endpoint, checked.expiresAt);
669
+ this.audit("inject", endpoint, message.id, { from: message.from.address, hop: message.hop });
670
+ return { seq, duplicate: false };
671
+ });
672
+ if ("unacked" in stored2) throw this.refuse(stored2.sender, "inbox_full", `${this.addressOf(endpoint)} has ${stored2.unacked} unread messages`);
673
+ return stored2;
674
+ }
675
+ /** inject() for the hooks of a local endpoint; another machine's row (mirrored, claimed on the hub, or a sender's proxy) has none and gets nothing */
676
+ inject(endpoint, options = {}) {
677
+ if (this.machineOf(endpoint) !== null) return { messages: [], offered: [], hold: null, more: 0, unread: 0, block: false };
678
+ return super.inject(endpoint, options);
679
+ }
680
+ /** offer() for a local endpoint's channel; another machine's row is offered nothing */
681
+ offer(endpoint, options = {}) {
682
+ if (this.machineOf(endpoint) !== null) return { messages: [], hold: null };
683
+ return super.offer(endpoint, options);
684
+ }
685
+ /** stopHeld() for a local endpoint; another machine's row has no hooks */
686
+ stopHeld(endpoint, options = {}) {
687
+ return this.machineOf(endpoint) === null && super.stopHeld(endpoint, options);
688
+ }
689
+ /** the endpoint's unacked count when a new delivery from `sender` would pass the inbox cap, else null */
690
+ inboxFull(endpoint, sender, message) {
691
+ if (this.byId(sender).kind === "system") return null;
692
+ const parent = message.kind === "reply" && message.reply_to ? one(this.sql, `SELECT id, from_ep FROM messages WHERE id = ?`, message.reply_to) : void 0;
693
+ const firstReply = parent?.from_ep === endpoint && one(this.sql, `SELECT 1 FROM messages WHERE reply_to = ? AND from_ep = ? AND kind = 'reply' AND id != ?`, parent.id, sender, message.id) === void 0;
694
+ if (firstReply) return null;
695
+ const unacked = one(
696
+ this.sql,
697
+ `SELECT COUNT(*) AS n FROM deliveries WHERE endpoint = ? AND acked_at IS NULL AND expires_at > ?`,
698
+ endpoint,
699
+ this.now()
700
+ ).n;
701
+ return unacked >= LIMITS.inboxMax ? unacked : null;
702
+ }
703
+ /** a send the hub accepted for a local endpoint, stored under the hub's id with no deliveries, so replyTo() and a retried client_id work */
704
+ recordForward(from, params, result, origin = {}) {
705
+ const sender = this.mustEndpoint(from);
706
+ const now = this.now();
707
+ const ttl = Number.isInteger(params.ttl_s) ? params.ttl_s : LIMITS.ttlDefaultS;
708
+ const targets = Array.isArray(params.to) ? params.to : [params.to];
709
+ const clientId = cleanText(params.client_id, "client_id", 128);
710
+ const kind = params.kind ?? "message";
711
+ this.tx(() => {
712
+ this.sql.all(
713
+ `INSERT OR IGNORE INTO messages (id, thread, reply_to, kind, from_ep, to_addr, body, mime, data_class, attachments, hop, created_at, expires_at, sender_tty, require_reply)
714
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
715
+ result.id,
716
+ result.thread,
717
+ params.reply_to ?? null,
718
+ kind,
719
+ from,
720
+ targets.map(String).join(","),
721
+ params.body,
722
+ params.mime ?? "text/markdown",
723
+ params.data_class ?? "public",
724
+ JSON.stringify((params.attachments ?? []).map((a) => ({ kind: "path", path: a.path }))),
725
+ result.hop,
726
+ now,
727
+ now + ttl * 1e3,
728
+ sender.kind === "human" ? origin.tty === true ? 1 : 0 : null,
729
+ params.require_reply === true && kind === "request" ? 1 : null
730
+ );
731
+ if (clientId) {
732
+ const { duplicate: _, ...stored2 } = result;
733
+ this.sql.all(
734
+ `INSERT OR REPLACE INTO idempotency (from_ep, client_id, payload_hash, result, created_at) VALUES (?, ?, ?, ?, ?)`,
735
+ from,
736
+ clientId,
737
+ this.forwardHash(params),
738
+ JSON.stringify(stored2),
739
+ now
740
+ );
741
+ }
742
+ if (params.reply_to)
743
+ this.sql.all(`UPDATE deliveries SET acked_at = ? WHERE message_id = ? AND endpoint = ? AND acked_at IS NULL`, now, params.reply_to, from);
744
+ this.audit("forward", from, result.id, { to: result.deliveries.map((d) => d.address), kind, hop: result.hop, thread: result.thread });
745
+ });
746
+ }
747
+ /** the earlier result of a forwarded send with this client_id, or null; a different message under the same client_id is refused */
748
+ forwarded(from, params) {
749
+ const clientId = cleanText(params.client_id, "client_id", 128);
750
+ if (!clientId) return null;
751
+ const seen = one(
752
+ this.sql,
753
+ `SELECT result, payload_hash, created_at FROM idempotency WHERE from_ep = ? AND client_id = ?`,
754
+ from,
755
+ clientId
756
+ );
757
+ if (!seen || seen.created_at < this.now() - LIMITS.idempotencyS * 1e3) return null;
758
+ if (seen.payload_hash !== this.forwardHash(params)) throw new RelayError("invalid_params", `client_id ${clientId} was already used for a different message`);
759
+ return { ...JSON.parse(seen.result), duplicate: true };
760
+ }
761
+ /** one delivery as the hub pushes it, or null when it is gone, acked or expired */
762
+ delivery(seq) {
763
+ const row = one(
764
+ this.sql,
765
+ `SELECT m.*, d.seq AS seq, d.endpoint AS recipient, e.remote AS remote, e.token_hash IS NOT NULL AS claimed FROM deliveries d JOIN messages m ON m.id = d.message_id JOIN endpoints e ON e.id = d.endpoint
766
+ WHERE d.seq = ? AND d.acked_at IS NULL AND d.expires_at > ?`,
767
+ seq,
768
+ this.now()
769
+ );
770
+ if (row)
771
+ return {
772
+ seq: row.seq,
773
+ endpoint: row.remote,
774
+ recipient: row.recipient,
775
+ mirrored: row.claimed === 0,
776
+ message: this.envelope(row, void 0, row.recipient)
777
+ };
778
+ return this.xinbox ? this.xinboxPage(`x.seq = ?`, [seq], 1)[0] ?? null : null;
779
+ }
780
+ /** the unacked deliveries to `machine`'s mirrored rows after `after` (xinbox rows after `xafter`), oldest first */
781
+ pending(machine, after, limit = 200, xafter = after) {
782
+ const deliveries = this.sql.all(
783
+ `SELECT m.*, d.seq AS seq, d.endpoint AS recipient, e.remote AS remote FROM deliveries d JOIN messages m ON m.id = d.message_id JOIN endpoints e ON e.id = d.endpoint
784
+ WHERE substr(e.remote, 1, ?) = ? AND e.token_hash IS NULL AND d.acked_at IS NULL AND d.expires_at > ? AND d.seq > ? ORDER BY d.seq LIMIT ?`,
785
+ machine.length + 1,
786
+ `${machine}/`,
787
+ this.now(),
788
+ after,
789
+ limit
790
+ ).map(
791
+ (row) => ({
792
+ seq: row.seq,
793
+ endpoint: row.remote,
794
+ recipient: row.recipient,
795
+ mirrored: true,
796
+ message: this.envelope(row, void 0, row.recipient)
797
+ })
798
+ );
799
+ if (!this.xinbox) return deliveries;
800
+ const others = this.xinboxPage(`substr(x.endpoint, 1, ?) = ? AND x.seq > ?`, [machine.length + 1, `${machine}/`, xafter], limit);
801
+ return others.length ? [...deliveries, ...others].sort((a, b) => a.seq - b.seq).slice(0, limit) : deliveries;
802
+ }
803
+ /**
804
+ * acks deliveries of `machine`'s mirrored rows only; each counts as received for the causal hop. A seq of xinbox moves its endpoint's
805
+ * cursor instead: one row per endpoint for the whole batch, no audit
806
+ */
807
+ ackOn(machine, seqs) {
808
+ let acked = 0;
809
+ const batches = /* @__PURE__ */ new Map();
810
+ for (const seq of seqList(seqs)) {
811
+ const row = this.ownDelivery(machine, seq);
812
+ if (!row) {
813
+ const endpoint = this.ownXinbox(machine, seq);
814
+ if (endpoint !== null) batches.set(endpoint, (batches.get(endpoint) ?? /* @__PURE__ */ new Set()).add(seq));
815
+ continue;
816
+ }
817
+ this.sql.all(`UPDATE deliveries SET acked_at = ? WHERE seq = ?`, this.now(), seq);
818
+ this.markReceived(row.endpoint, row.hop);
819
+ acked++;
820
+ }
821
+ if (acked) this.audit("ack", null, null, { machine, count: acked });
822
+ for (const [endpoint, batch] of batches) acked += this.advanceCursor(endpoint, batch);
823
+ return acked;
824
+ }
825
+ /** a delivery `machine`'s local policy refused: acked, and a status notice from the relay to the sender */
826
+ rejectOn(machine, seq, code) {
827
+ if (!Number.isInteger(seq)) throw new RelayError("invalid_params", "seq must be an integer");
828
+ if (typeof code !== "string" || !/^[a-z_]{1,32}$/.test(code)) throw new RelayError("invalid_params", "code is an error code");
829
+ const row = this.ownDelivery(machine, seq);
830
+ if (!row) {
831
+ const endpoint = this.ownXinbox(machine, seq);
832
+ if (endpoint === null) throw new RelayError("not_found", `no unacked delivery ${String(seq)} for ${machine}`);
833
+ this.advanceCursor(endpoint, /* @__PURE__ */ new Set([seq]));
834
+ return { notice: null };
835
+ }
836
+ return this.tx(() => {
837
+ this.sql.all(`UPDATE deliveries SET acked_at = ? WHERE seq = ?`, this.now(), seq);
838
+ this.audit("reject", row.endpoint, row.message_id, { code, machine });
839
+ const sender = this.byId(row.from_ep);
840
+ if (!sender || sender.kind === "system") return { notice: null };
841
+ const recipient = this.byId(row.endpoint);
842
+ const noticeSeq = this.noticeTo(sender.id, `${this.address(recipient)} refused message ${row.message_id} (${code}).`);
843
+ return { notice: { seq: noticeSeq, endpoint: sender.id } };
844
+ });
845
+ }
846
+ audit(event, endpoint, ref, detail) {
847
+ if (event === "refuse" && this.refusalsPerMinute !== null) {
848
+ const minute = Math.floor(this.now() / 6e4);
849
+ const code = String(detail.code);
850
+ let budget = this.refusals.get(code);
851
+ if (!budget) {
852
+ budget = { minute, written: 0, suppressed: 0 };
853
+ this.refusals.set(code, budget);
854
+ }
855
+ if (budget.minute !== minute) Object.assign(budget, { minute, written: 0 });
856
+ if (budget.written >= this.refusalsPerMinute) {
857
+ budget.suppressed++;
858
+ return;
859
+ }
860
+ budget.written++;
861
+ if (budget.suppressed) {
862
+ detail = { ...detail, suppressed: budget.suppressed };
863
+ budget.suppressed = 0;
864
+ }
865
+ }
866
+ super.audit(event, endpoint, ref, detail);
867
+ }
868
+ /** an audit row from outside the core (hub events: machine binds, releases, revocations) */
869
+ record(event, endpoint, ref, detail) {
870
+ this.audit(event, endpoint, ref, detail);
871
+ }
872
+ /**
873
+ * drops what expired, and with xinbox the rows received over XINBOX_KEEP_MS ago that expired or were acked that long ago, keeping
874
+ * the highest seq they held as the floor of the next one
875
+ */
876
+ sweep() {
877
+ super.sweep();
878
+ if (!this.xinbox) return;
879
+ this.xfloor();
880
+ const now = this.now();
881
+ const settled = now - XINBOX_KEEP_MS;
882
+ this.sql.all(
883
+ `DELETE FROM xinbox WHERE at <= ? AND (expires_at <= ? OR EXISTS (SELECT 1 FROM xcursor c WHERE c.endpoint = xinbox.endpoint
884
+ AND ((xinbox.seq <= c.acked_seq AND c.at <= ?) OR (xinbox.seq <= c.old_seq AND c.old_at <= ?))))`,
885
+ settled,
886
+ now,
887
+ settled,
888
+ settled
889
+ );
890
+ this.sql.all(`DELETE FROM xcursor WHERE endpoint NOT IN (SELECT endpoint FROM xinbox)`);
891
+ }
892
+ // other accounts (the hosted relay)
893
+ /** the mirrored row of `<machine>/<handle>` (a bridge's, never a direct client's), or null */
894
+ xendpoint(remote) {
895
+ const row = this.byRemote(remote);
896
+ return row && row.token_hash === null && row.kind !== "system" && !isForeign(remote) ? this.xview(row) : null;
897
+ }
898
+ /** the mirrored row an endpoint id is, or null for anything else */
899
+ xsender(endpoint) {
900
+ const row = this.byId(endpoint);
901
+ return row?.remote && row.token_hash === null && row.kind !== "system" && !isForeign(row.remote) ? this.xview(row) : null;
902
+ }
903
+ /** every mirrored row, for discovery */
904
+ xendpoints() {
905
+ return this.sql.all(`SELECT * FROM endpoints WHERE remote IS NOT NULL AND token_hash IS NULL AND kind != 'system' ORDER BY remote`).filter((row) => !isForeign(row.remote)).map((row) => this.xview(row));
906
+ }
907
+ /** the xinbox row `id` delivered to `<machine>/<handle>`, or null */
908
+ xreceived(endpoint, id) {
909
+ return this.xinbox ? one(this.sql, `SELECT * FROM xinbox WHERE endpoint = ? AND id = ?`, endpoint, id) ?? null : null;
910
+ }
911
+ /** the earlier row of a message from `fromAccount` under `clientId`, or null */
912
+ xduplicate(fromAccount, clientId) {
913
+ const row = one(
914
+ this.sql,
915
+ `SELECT id, seq, endpoint FROM xinbox WHERE from_account = ? AND client_id = ?`,
916
+ fromAccount,
917
+ clientId
918
+ );
919
+ if (!row) return null;
920
+ const cursor = one(this.sql, `SELECT acked_seq FROM xcursor WHERE endpoint = ?`, row.endpoint)?.acked_seq ?? 0;
921
+ return { id: row.id, acked: row.seq <= cursor };
922
+ }
923
+ /** what `fromAccount` sent here: from `sender` to `endpoint` in the last minute and hour (each with its oldest), and since `since` */
924
+ xcounts(fromAccount, sender, endpoint, since) {
925
+ const now = this.now();
926
+ const pair = one(
927
+ this.sql,
928
+ `SELECT COALESCE(SUM(at > ?), 0) AS minute, MIN(CASE WHEN at > ? THEN at END) AS minute_at, COUNT(*) AS hour, MIN(at) AS hour_at FROM xinbox
929
+ WHERE from_account = ? AND sender = ? AND endpoint = ? AND at > ?`,
930
+ now - 6e4,
931
+ now - 6e4,
932
+ fromAccount,
933
+ sender,
934
+ endpoint,
935
+ now - 36e5
936
+ );
937
+ const day = one(this.sql, `SELECT COUNT(*) AS n FROM xinbox WHERE from_account = ? AND at >= ?`, fromAccount, since).n;
938
+ return { minute: pair.minute, minuteAt: pair.minute_at, hour: pair.hour, hourAt: pair.hour_at, day };
939
+ }
940
+ /** an endpoint's unread messages: its unacked, unexpired xinbox rows and deliveries */
941
+ xunread(endpoint) {
942
+ const now = this.now();
943
+ return one(
944
+ this.sql,
945
+ `SELECT (SELECT COUNT(*) FROM xinbox x WHERE x.endpoint = ? AND x.expires_at > ? AND x.seq > COALESCE((SELECT acked_seq FROM xcursor WHERE endpoint = x.endpoint), 0))
946
+ + (SELECT COUNT(*) FROM deliveries WHERE endpoint = ? AND acked_at IS NULL AND expires_at > ?) AS n`,
947
+ endpoint.remote,
948
+ now,
949
+ endpoint.id,
950
+ now
951
+ ).n;
952
+ }
953
+ /** the accounts whose xinbox rows may be read out now; null (unknown) reads none */
954
+ xsenders(accounts) {
955
+ this.xfrom = accounts;
956
+ }
957
+ /** deletes the xinbox rows of every account but `keep`; how many it deleted */
958
+ xprune(keep) {
959
+ if (!this.xinbox) return 0;
960
+ const json = JSON.stringify([...keep]);
961
+ if (!one(this.sql, `SELECT 1 FROM xinbox WHERE from_account NOT IN (SELECT value FROM json_each(?)) LIMIT 1`, json)) return 0;
962
+ this.xfloor();
963
+ return this.sql.all(`DELETE FROM xinbox WHERE from_account NOT IN (SELECT value FROM json_each(?)) RETURNING seq`, json).length;
964
+ }
965
+ xthreadCount(endpoint, thread) {
966
+ return one(this.sql, `SELECT COUNT(*) AS n FROM xinbox WHERE endpoint = ? AND thread = ?`, endpoint, thread).n;
967
+ }
968
+ /** one xinbox row under the next seq; null when a row of the same sender and client_id is there already */
969
+ xinsert(row) {
970
+ return one(
971
+ this.sql,
972
+ `INSERT INTO xinbox (seq, id, from_account, client_id, endpoint, sender, message, thread, grant, expires_at, at) SELECT 1 + ${SEQ_TOP}, ?, ?, ?, ?, ?, ?, ?, ?, ?, ? WHERE true
973
+ ON CONFLICT (from_account, client_id) DO NOTHING RETURNING seq`,
974
+ row.id,
975
+ row.from_account,
976
+ row.client_id,
977
+ row.endpoint,
978
+ row.sender,
979
+ row.message,
980
+ row.thread,
981
+ row.grant,
982
+ row.expires_at,
983
+ row.at
984
+ )?.seq ?? null;
985
+ }
986
+ /** a value of the cross-account state, written once */
987
+ xstate(key, init) {
988
+ const found = one(this.sql, `SELECT value FROM xstate WHERE key = ?`, key);
989
+ if (found) return found.value;
990
+ const value = init();
991
+ this.sql.all(`INSERT INTO xstate (key, value) VALUES (?, ?) ON CONFLICT (key) DO NOTHING`, key, value);
992
+ return one(this.sql, `SELECT value FROM xstate WHERE key = ?`, key).value;
993
+ }
994
+ xview(row) {
995
+ const remote = row.remote;
996
+ return {
997
+ id: row.id,
998
+ remote,
999
+ machine: machinePart(remote),
1000
+ handle: handlePart(remote),
1001
+ kind: row.kind,
1002
+ depth: row.kind === "agent" ? row.depth : null,
1003
+ harness: row.harness,
1004
+ presence: this.view(row).presence
1005
+ };
1006
+ }
1007
+ /** unacked, unexpired xinbox rows of mirrored endpoints from the accounts xsenders() named, as deliveries, by `where` over `x` */
1008
+ xinboxPage(where, params, limit) {
1009
+ if (!this.xfrom?.size) return [];
1010
+ return this.sql.all(
1011
+ `SELECT x.seq AS seq, x.endpoint AS endpoint, x.message AS message, e.id AS recipient FROM xinbox x JOIN endpoints e ON e.remote = x.endpoint AND e.token_hash IS NULL
1012
+ WHERE ${where} AND x.expires_at > ? AND x.from_account IN (SELECT value FROM json_each(?))
1013
+ AND x.seq > COALESCE((SELECT acked_seq FROM xcursor WHERE endpoint = x.endpoint), 0) ORDER BY x.seq LIMIT ?`,
1014
+ ...params,
1015
+ this.now(),
1016
+ JSON.stringify([...this.xfrom]),
1017
+ limit
1018
+ ).map((row) => ({ seq: row.seq, endpoint: row.endpoint, recipient: row.recipient, mirrored: true, message: JSON.parse(row.message) }));
1019
+ }
1020
+ /** the endpoint of an unacked xinbox seq of `machine`, or null */
1021
+ ownXinbox(machine, seq) {
1022
+ if (!this.xinbox) return null;
1023
+ return one(
1024
+ this.sql,
1025
+ `SELECT x.endpoint AS endpoint FROM xinbox x WHERE x.seq = ? AND substr(x.endpoint, 1, ?) = ? AND x.seq > COALESCE((SELECT acked_seq FROM xcursor WHERE endpoint = x.endpoint), 0)`,
1026
+ seq,
1027
+ machine.length + 1,
1028
+ `${machine}/`
1029
+ )?.endpoint ?? null;
1030
+ }
1031
+ /**
1032
+ * moves an endpoint's cursor over the leading run of its unacked, unexpired rows that `batch` names, in one write or none; a seq past
1033
+ * a row it does not name stays unacked and replays. How many rows it moved over
1034
+ */
1035
+ advanceCursor(endpoint, batch) {
1036
+ const now = this.now();
1037
+ const cursor = one(this.sql, `SELECT acked_seq FROM xcursor WHERE endpoint = ?`, endpoint)?.acked_seq ?? 0;
1038
+ let to = cursor;
1039
+ let moved = 0;
1040
+ for (const { seq } of this.sql.all(
1041
+ `SELECT seq FROM xinbox WHERE endpoint = ? AND seq > ? AND seq <= ? AND expires_at > ? ORDER BY seq`,
1042
+ endpoint,
1043
+ cursor,
1044
+ Math.max(...batch),
1045
+ now
1046
+ )) {
1047
+ if (!batch.has(seq)) break;
1048
+ to = seq;
1049
+ moved++;
1050
+ }
1051
+ if (to === cursor) return 0;
1052
+ this.sql.all(
1053
+ `INSERT INTO xcursor (endpoint, acked_seq, at) VALUES (?, ?, ?) ON CONFLICT (endpoint) DO UPDATE SET
1054
+ old_seq = CASE WHEN old_at IS NULL OR excluded.at - old_at >= ? THEN acked_seq ELSE old_seq END,
1055
+ old_at = CASE WHEN old_at IS NULL OR excluded.at - old_at >= ? THEN at ELSE old_at END,
1056
+ acked_seq = excluded.acked_seq, at = excluded.at`,
1057
+ endpoint,
1058
+ to,
1059
+ now,
1060
+ XINBOX_KEEP_MS,
1061
+ XINBOX_KEEP_MS
1062
+ );
1063
+ return moved;
1064
+ }
1065
+ /** when the holder part of a mirrored agent's incarnation moved, drops its xinbox rows and deliveries: they were the earlier holder's */
1066
+ dropEarlierHolder(row, incarnation) {
1067
+ const holder = (value) => value?.includes(".") ? value.slice(0, value.indexOf(".")) : null;
1068
+ const was = holder(row.incarnation);
1069
+ const next = holder(incarnation);
1070
+ if (was === null || next === null || was === next) return;
1071
+ let xinbox = 0;
1072
+ if (this.xinbox) {
1073
+ this.xfloor();
1074
+ xinbox = this.sql.all(`DELETE FROM xinbox WHERE endpoint = ? RETURNING seq`, row.remote).length;
1075
+ }
1076
+ const deliveries = this.sql.all(`DELETE FROM deliveries WHERE endpoint = ? RETURNING seq`, row.id).length;
1077
+ this.audit("mirror_reset", row.id, null, { target: row.remote, xinbox, deliveries });
1078
+ }
1079
+ /** keeps the highest xinbox seq as the floor of the next one, before rows are deleted */
1080
+ xfloor() {
1081
+ const top = one(this.sql, `SELECT MAX(seq) AS n FROM xinbox`).n;
1082
+ const floor = Number(one(this.sql, `SELECT value FROM xstate WHERE key = 'seq_floor'`)?.value ?? 0);
1083
+ if (top !== null && top > floor)
1084
+ this.sql.all(`INSERT INTO xstate (key, value) VALUES ('seq_floor', ?) ON CONFLICT (key) DO UPDATE SET value = excluded.value`, String(top));
1085
+ }
1086
+ // internals
1087
+ ownDelivery(machine, seq) {
1088
+ return one(
1089
+ this.sql,
1090
+ `SELECT d.endpoint AS endpoint, d.message_id AS message_id, m.from_ep AS from_ep, m.hop AS hop FROM deliveries d JOIN messages m ON m.id = d.message_id JOIN endpoints e ON e.id = d.endpoint
1091
+ WHERE d.seq = ? AND d.acked_at IS NULL AND substr(e.remote, 1, ?) = ? AND e.token_hash IS NULL`,
1092
+ seq,
1093
+ machine.length + 1,
1094
+ `${machine}/`
1095
+ );
1096
+ }
1097
+ noticeTo(endpoint, text) {
1098
+ const system = this.byHandle(SYSTEM_HANDLE);
1099
+ const now = this.now();
1100
+ const id = newId("msg", now);
1101
+ const expires = now + LIMITS.ttlDefaultS * 1e3;
1102
+ this.sql.all(
1103
+ `INSERT INTO messages (id, thread, reply_to, kind, from_ep, to_addr, body, mime, data_class, attachments, hop, created_at, expires_at)
1104
+ VALUES (?, ?, NULL, 'status', ?, ?, ?, 'text/plain', 'public', '[]', 0, ?, ?)`,
1105
+ id,
1106
+ newId("thr", now),
1107
+ system.id,
1108
+ this.addressOf(endpoint),
1109
+ text,
1110
+ now,
1111
+ expires
1112
+ );
1113
+ return this.insertDelivery(id, endpoint, expires);
1114
+ }
1115
+ forwardHash(params) {
1116
+ const targets = (Array.isArray(params.to) ? params.to : [params.to]).map(String).sort();
1117
+ return this.hash(
1118
+ JSON.stringify([
1119
+ "forward",
1120
+ targets,
1121
+ params.body,
1122
+ params.kind ?? "message",
1123
+ params.thread ?? null,
1124
+ params.reply_to ?? null,
1125
+ params.data_class ?? "public",
1126
+ (params.attachments ?? []).map((a) => a.path).sort(),
1127
+ params.mime ?? null,
1128
+ params.ttl_s ?? null
1129
+ ])
1130
+ );
1131
+ }
1132
+ /** a message from another account passes the recipient's in lists by `acct:`, or as a reply to the recipient's own message to that sender */
1133
+ checkForeignIn(sender, recipient, parent) {
1134
+ if (parent && parent.from_ep === recipient.id && parent.to_addr.split(",").some((to) => to.trim() === this.address(sender))) return;
1135
+ const admission = this.crossAdmission(recipient.id, "in", this.scopeNode(sender));
1136
+ if (admission.ok) return;
1137
+ this.audit("scope_refused", sender.id, null, { from: this.address(sender), to: this.address(recipient), dir: "in", reason: admission.why });
1138
+ throw new RelayError("not_found", `no endpoint ${this.address(recipient)}`);
1139
+ }
1140
+ /** the stand-in row of another account's sender: `@<account>/<machine>/<handle>`, or `@<account>/me` for its human at a terminal */
1141
+ foreignProxy(from) {
1142
+ const bad2 = () => new RelayError("invalid_params", `the sender ${String(from.address)} is not an endpoint of a contact`);
1143
+ const account = from.account;
1144
+ if (typeof account !== "string" || !ACCOUNT_HANDLE.test(account) || this.isLocalUser(account) || from.user !== account) throw bad2();
1145
+ let remote;
1146
+ if (from.human === true) {
1147
+ if (from.address !== `${account}/me`) throw bad2();
1148
+ remote = `@${account}/me`;
1149
+ } else {
1150
+ const parsed = this.foreignAccount(from.address);
1151
+ if (!parsed || parsed.account !== account) throw bad2();
1152
+ remote = `@${account}/${parsed.machine}/${parsed.handle}`;
1153
+ }
1154
+ const harness = from.claims.harness && validHarness(from.claims.harness) ? from.claims.harness : null;
1155
+ const existing = this.byRemote(remote);
1156
+ const id = existing?.id ?? this.insertProxy(remote, from.human === true ? "human" : "agent", harness, from.auth);
1157
+ if (existing && existing.harness !== harness) this.sql.all(`UPDATE endpoints SET harness = ? WHERE id = ?`, harness, id);
1158
+ return id;
1159
+ }
1160
+ proxyFor(from) {
1161
+ if (from.auth === "contact") return this.foreignProxy(from);
1162
+ const prefix = `${from.user}/`;
1163
+ if (!from.address.startsWith(prefix)) throw new RelayError("invalid_params", "the sender's address is not of its user");
1164
+ const rest = from.address.slice(prefix.length);
1165
+ const remote = rest.includes("/") ? rest : `${from.user}/${rest}`;
1166
+ if (!REMOTE.test(remote)) throw new RelayError("invalid_params", `the sender's address ${from.address} is not valid`);
1167
+ const handle = handlePart(remote);
1168
+ const kind = handle === "" ? "human" : rest.includes("/") ? "agent" : "system";
1169
+ const harness = from.claims.harness && validHarness(from.claims.harness) ? from.claims.harness : null;
1170
+ const model = cleanText(from.claims.model, "model", 128);
1171
+ const existing = this.byRemote(remote);
1172
+ const id = existing?.id ?? this.insertProxy(remote, kind, harness, from.auth);
1173
+ if (!existing || existing.harness !== harness || existing.model !== model || existing.auth !== from.auth) {
1174
+ this.sql.all(`UPDATE endpoints SET harness = ?, model = ?, auth = ? WHERE id = ?`, harness, model, from.auth, id);
1175
+ }
1176
+ return id;
1177
+ }
1178
+ checkEnvelope(message) {
1179
+ const bad2 = (what) => new RelayError("invalid_params", `the message's ${what} is not valid`);
1180
+ if (!message || typeof message !== "object") throw bad2("shape");
1181
+ if (typeof message.id !== "string" || !MESSAGE_ID.test(message.id)) throw bad2("id");
1182
+ if (typeof message.thread !== "string" || !message.thread || message.thread.length > 128) throw bad2("thread");
1183
+ if (message.reply_to !== null && (typeof message.reply_to !== "string" || message.reply_to.length > 128)) throw bad2("reply_to");
1184
+ if (!MESSAGE_KINDS.includes(message.kind)) throw bad2("kind");
1185
+ if (typeof message.to !== "string" || message.to.length > 4096) throw bad2("to");
1186
+ if (!message.body || typeof message.body.text !== "string" || typeof message.body.mime !== "string" || message.body.mime.length > 64) throw bad2("body");
1187
+ if (new TextEncoder().encode(message.body.text).length > LIMITS.bodyBytes)
1188
+ throw new RelayError("too_large", `the body is more than ${LIMITS.bodyBytes} bytes`);
1189
+ if (message.data_class !== "public" && message.data_class !== "private") throw bad2("data_class");
1190
+ if (!Array.isArray(message.attachments) || message.attachments.length > LIMITS.attachments || message.attachments.some((a) => a?.kind !== "path" || typeof a.path !== "string" || !a.path.startsWith("/")))
1191
+ throw bad2("attachments");
1192
+ if (!Number.isInteger(message.hop) || message.hop < 0 || message.hop > LIMITS.hopMax) throw bad2("hop");
1193
+ const from = message.from;
1194
+ if (!from || typeof from.address !== "string" || typeof from.user !== "string" || !from.claims || !AUTHS.includes(from.auth)) throw bad2("sender");
1195
+ if (from.claims.tty !== void 0 && typeof from.claims.tty !== "boolean") throw bad2("sender");
1196
+ const createdAt = Date.parse(message.created_at);
1197
+ const expiresAt = Date.parse(message.expires_at);
1198
+ if (!Number.isFinite(createdAt) || !Number.isFinite(expiresAt)) throw bad2("timestamps");
1199
+ const secret = findSecret(message.body.text);
1200
+ if (secret) throw new RelayError("secret_refused", `the message looks like it carries a credential (${secret})`);
1201
+ return { createdAt, expiresAt };
1202
+ }
1203
+ parseView(value) {
1204
+ if (!value || typeof value !== "object" || Array.isArray(value)) throw new RelayError("invalid_params", "an endpoint is an object");
1205
+ const view = value;
1206
+ if (typeof view.address !== "string") throw new RelayError("invalid_params", "an endpoint needs an address");
1207
+ const handle = view.address.slice(view.address.lastIndexOf("/") + 1);
1208
+ const kind = view.kind;
1209
+ if (kind !== "agent" && kind !== "human") throw new RelayError("invalid_params", "an endpoint's kind is agent or human");
1210
+ if (kind === "human" ? handle !== "" : !validHandle(handle))
1211
+ throw new RelayError("invalid_params", `${JSON.stringify(view.address)} is not a valid endpoint address`);
1212
+ const harness = cleanText(view.harness, "harness", 32);
1213
+ if (harness !== null && !validHarness(harness)) throw new RelayError("invalid_params", `harness ${JSON.stringify(harness)} is not a valid name`);
1214
+ let status = null;
1215
+ if (view.status) {
1216
+ if (!STATUS_STATES.includes(view.status.state)) throw new RelayError("invalid_params", `state must be one of ${STATUS_STATES.join(", ")}`);
1217
+ status = { state: view.status.state, note: cleanText(view.status.note, "note", LIMITS.noteChars) ?? "" };
1218
+ }
1219
+ const presence = view.presence ?? "offline";
1220
+ if (!PRESENCES.includes(presence)) throw new RelayError("invalid_params", "presence is online, idle or offline");
1221
+ const parsedSeen = typeof view.last_seen === "string" ? Date.parse(view.last_seen) : Number.NaN;
1222
+ const out = {
1223
+ handle,
1224
+ kind,
1225
+ harness,
1226
+ model: cleanText(view.model, "model", 128),
1227
+ cwd: cleanText(view.cwd, "cwd", 1024),
1228
+ labels: JSON.stringify(cleanLabels(view.labels)),
1229
+ status,
1230
+ presence,
1231
+ lastSeen: Number.isFinite(parsedSeen) ? parsedSeen : null,
1232
+ scope: kind === "agent" ? parseScope(view) : {}
1233
+ };
1234
+ const leaked = [handle, out.model, out.cwd, out.labels, status?.note, out.scope.title].map((v) => v ? findSecret(v) : null).find(Boolean);
1235
+ if (leaked) throw new RelayError("secret_refused", `an endpoint looks like it carries a credential (${leaked})`);
1236
+ return out;
1237
+ }
1238
+ diff(before, after) {
1239
+ const view = this.remoteView(after);
1240
+ if (!before) return { view, added: true, presence: true, status: true };
1241
+ const was = this.view(before);
1242
+ const presence = was.presence !== view.presence;
1243
+ const status = before.status_state !== after.status_state || before.status_note !== after.status_note;
1244
+ const other = ["kind", "harness", "model", "cwd", "labels", "title", "tree", "parent_ep", "depth", "rules", "caps", "incarnation"].some(
1245
+ (field) => before[field] !== after[field]
1246
+ );
1247
+ return presence || status || other ? { view, added: false, presence, status } : null;
1248
+ }
1249
+ remoteView(row) {
1250
+ const remote = row.remote;
1251
+ const view = { ...this.view(row), machine: remote ? machinePart(remote) : this.machine ?? "" };
1252
+ if (row.kind !== "agent") return view;
1253
+ const handle = (id) => {
1254
+ const other = id ? this.byId(id) : void 0;
1255
+ return other?.remote ? handlePart(other.remote) : null;
1256
+ };
1257
+ return {
1258
+ ...view,
1259
+ tree: row.depth === null ? null : row.tree ? handle(row.tree) : remote ? handlePart(remote) : row.handle,
1260
+ parent_ep: handle(row.parent_ep),
1261
+ depth: row.depth,
1262
+ rules: row.rules ? JSON.parse(row.rules) : null,
1263
+ caps: row.caps ? this.wireCaps(JSON.parse(row.caps)) : null,
1264
+ incarnation: row.incarnation ?? null
1265
+ };
1266
+ }
1267
+ /**
1268
+ * the place each agent of a mirror batch gets, worked out here: a root is a view with no parent and depth 0; a child sits one below its
1269
+ * parent, which must be an agent this machine mirrors; anything else (a parent on another machine or not mirrored, a cycle, a root
1270
+ * claiming depth) is unknown. Undefined when the view says nothing about its place.
1271
+ */
1272
+ placer(machine, batch) {
1273
+ const agents = new Map(batch.filter((entry) => entry.view.kind === "agent").map((entry) => [entry.view.handle, entry]));
1274
+ const memo = /* @__PURE__ */ new Map();
1275
+ const place = (handle, trail) => {
1276
+ if (trail.includes(handle)) return UNKNOWN;
1277
+ if (memo.has(handle)) return memo.get(handle);
1278
+ const { parent, depth } = agents.get(handle).view.scope;
1279
+ let result;
1280
+ if (parent === void 0 && depth === void 0) result = void 0;
1281
+ else if (parent === void 0) result = UNKNOWN;
1282
+ else if (parent === null) result = depth === 0 ? { tree: null, parent_ep: null, depth: 0 } : UNKNOWN;
1283
+ else {
1284
+ const row = validHandle(parent) ? this.byRemote(`${machine}/${parent}`) : void 0;
1285
+ if (!row || row.token_hash !== null || row.kind !== "agent") result = UNKNOWN;
1286
+ else {
1287
+ const up = agents.has(parent) ? place(parent, [...trail, handle]) ?? (agents.get(parent).before ? stored(agents.get(parent).before) : UNKNOWN) : stored(row);
1288
+ result = up.depth === null || up.depth + 1 > LIMITS.depthMax ? UNKNOWN : { tree: up.tree ?? row.id, parent_ep: row.id, depth: up.depth + 1 };
1289
+ }
1290
+ }
1291
+ if (!trail.length) memo.set(handle, result);
1292
+ return result;
1293
+ };
1294
+ return (handle) => place(handle, []);
1295
+ }
1296
+ /** an agent row's place, rules, caps, title and incarnation after a view: what it left out stays, unless the endpoint is a new incarnation */
1297
+ scopeColumns(machine, scope, row, placement) {
1298
+ const fresh = scope.incarnation !== void 0 && scope.incarnation !== null && row.incarnation !== null && scope.incarnation !== row.incarnation;
1299
+ const place = placement ?? (fresh ? UNKNOWN : stored(row));
1300
+ let caps = fresh ? null : row.caps;
1301
+ if (scope.caps !== void 0) {
1302
+ const next = this.localCaps(machine, scope.caps ?? { caps: [], allow: {} });
1303
+ const merged = fresh || !row.caps ? next : stickyCaps(JSON.parse(row.caps), next);
1304
+ caps = merged.caps.length || Object.keys(merged.allow).length ? JSON.stringify(merged) : null;
1305
+ }
1306
+ const rules = scope.rules !== void 0 ? scope.rules : fresh ? null : void 0;
1307
+ return {
1308
+ ...place,
1309
+ rules: rules === void 0 ? row.rules : rules && (rules.in || rules.out) ? JSON.stringify(rules) : null,
1310
+ caps,
1311
+ title: scope.title !== void 0 ? scope.title : fresh ? null : row.title,
1312
+ incarnation: scope.incarnation ?? row.incarnation
1313
+ };
1314
+ }
1315
+ /** wire caps of `machine` with their `ep:<handle>` references as this core's row ids; an endpoint the machine does not mirror is `gone`, which admits nothing */
1316
+ localCaps(machine, caps) {
1317
+ const ref = (value) => {
1318
+ if (REFS.has(value)) return value;
1319
+ const handle = value.startsWith("ep:") ? value.slice(3) : "";
1320
+ const row = validHandle(handle) ? this.byRemote(`${machine}/${handle}`) : void 0;
1321
+ return row && row.token_hash === null && row.kind === "agent" ? row.id : "gone";
1322
+ };
1323
+ return mapRefs(caps, ref);
1324
+ }
1325
+ /** caps with this core's row ids named as `ep:<handle>`, as they cross machines */
1326
+ wireCaps(caps) {
1327
+ const ref = (value) => {
1328
+ if (REFS.has(value)) return value;
1329
+ const row = this.byId(value);
1330
+ if (row?.kind !== "agent") return "gone";
1331
+ return `ep:${row.remote ? handlePart(row.remote) : row.handle}`;
1332
+ };
1333
+ const mapped = mapRefs(caps, ref);
1334
+ return { caps: mapped.caps, allow: mapped.allow };
1335
+ }
1336
+ insertRemote(remote, kind, harness, auth, now) {
1337
+ const id = newId("ep", now);
1338
+ this.sql.all(
1339
+ `INSERT INTO endpoints (id, handle, kind, harness, labels, created_at, remote, auth) VALUES (?, ?, ?, ?, '{}', ?, ?, ?)`,
1340
+ id,
1341
+ `_r_${id}`,
1342
+ kind,
1343
+ harness,
1344
+ now,
1345
+ remote,
1346
+ auth
1347
+ );
1348
+ return id;
1349
+ }
1350
+ retire(id) {
1351
+ this.sql.all(`UPDATE endpoints SET handle = ? || id, remote = NULL, token_hash = NULL, connections = 0 WHERE id = ?`, GONE_PREFIX, id);
1352
+ this.dropPeek(id);
1353
+ }
1354
+ /** a new proxy row, within the endpoint cap */
1355
+ insertProxy(remote, kind, harness, auth) {
1356
+ this.checkCap();
1357
+ return this.insertRemote(remote, kind, harness, auth, this.now());
1358
+ }
1359
+ checkCap() {
1360
+ if (this.endpointCap === null) return;
1361
+ const live = one(
1362
+ this.sql,
1363
+ `SELECT COUNT(*) AS n FROM endpoints WHERE kind != 'system' AND substr(handle, 1, ?) != ?`,
1364
+ GONE_PREFIX.length,
1365
+ GONE_PREFIX
1366
+ ).n;
1367
+ if (live >= this.endpointCap) throw new RelayError("too_large", `at most ${this.endpointCap} endpoints on this relay`);
1368
+ }
1369
+ checkMachine(machine) {
1370
+ if (typeof machine !== "string" || !MACHINE_NAME.test(machine))
1371
+ throw new RelayError("invalid_params", `machine ${JSON.stringify(machine)} is not a valid name`);
1372
+ }
1373
+ byRemote(remote) {
1374
+ return one(this.sql, `SELECT * FROM endpoints WHERE remote = ?`, remote);
1375
+ }
1376
+ /** a judge status notice to `endpoint` from the relay; its delivery seq */
1377
+ judgeNoticeTo(endpoint, text) {
1378
+ return this.noticeTo(endpoint, text);
1379
+ }
1380
+ };
1381
+ var stored = (row) => ({ tree: row.tree, parent_ep: row.parent_ep, depth: row.depth });
1382
+ function mapRefs(caps, ref) {
1383
+ const entries = (list) => list.map((entry) => entry.via === void 0 ? entry : { ...entry, via: ref(entry.via) });
1384
+ return {
1385
+ caps: caps.caps.map((cap) => ({ ...cap, by: ref(cap.by), sels: entries(cap.sels) })),
1386
+ allow: Object.fromEntries(Object.entries(caps.allow ?? {}).map(([key, list]) => [key, entries(list)]))
1387
+ };
1388
+ }
1389
+ function stickyCaps(seen, next) {
1390
+ const plain = (entry) => entry.via === void 0;
1391
+ const caps = next.caps.map((cap) => {
1392
+ const old = seen.caps.find((had) => had.id === cap.id);
1393
+ return old ? { ...cap, sels: cap.sels.filter((entry) => !plain(entry) || old.sels.some((had) => plain(had) && had.sel === entry.sel && had.group === entry.group)) } : cap;
1394
+ });
1395
+ for (const old of seen.caps) if (!next.caps.some((cap) => cap.id === old.id)) caps.push(old);
1396
+ return { caps, allow: next.allow };
1397
+ }
1398
+ function loosens(seen, next) {
1399
+ const plain = (entry) => entry.via === void 0;
1400
+ return seen.caps.some((old) => {
1401
+ const cap = next.caps.find((c) => c.id === old.id);
1402
+ return !cap || cap.sels.some((entry) => plain(entry) && !old.sels.some((had) => plain(had) && had.sel === entry.sel && had.group === entry.group));
1403
+ });
1404
+ }
1405
+ var bad = (what) => new RelayError("invalid_params", `an endpoint's ${what} is not valid`);
1406
+ var isRecord = (value) => !!value && typeof value === "object" && !Array.isArray(value);
1407
+ function shortText(value, what, max = 128) {
1408
+ if (typeof value !== "string" || !value || value.length > max) throw bad(what);
1409
+ return value;
1410
+ }
1411
+ function parseSelectors(value, what) {
1412
+ if (!Array.isArray(value) || value.length > 64 || value.some((sel) => !validSelector(sel))) throw bad(what);
1413
+ return value;
1414
+ }
1415
+ function parseEntries(value) {
1416
+ if (!Array.isArray(value) || value.length > 64) throw bad("caps");
1417
+ return value.map((entry) => {
1418
+ if (!isRecord(entry) || !validSelector(entry.sel)) throw bad("caps");
1419
+ return {
1420
+ sel: entry.sel,
1421
+ ...entry.via === void 0 ? {} : { via: shortText(entry.via, "caps") },
1422
+ ...entry.group === void 0 ? {} : { group: shortText(entry.group, "caps") },
1423
+ ...entry.base === true ? { base: true } : {},
1424
+ ...entry.member === true ? { member: true } : {}
1425
+ };
1426
+ });
1427
+ }
1428
+ function parseScope(view) {
1429
+ const out = {};
1430
+ const given = (key) => key in view && view[key] !== void 0;
1431
+ if (given("tree")) out.tree = view.tree === null ? null : shortText(view.tree, "tree");
1432
+ if (given("parent_ep")) out.parent = view.parent_ep === null ? null : shortText(view.parent_ep, "parent_ep");
1433
+ if (given("depth")) {
1434
+ if (view.depth !== null && (!Number.isInteger(view.depth) || view.depth < 0 || view.depth > 1e3)) throw bad("depth");
1435
+ out.depth = view.depth ?? null;
1436
+ }
1437
+ if (given("rules")) {
1438
+ if (view.rules !== null && !isRecord(view.rules)) throw bad("rules");
1439
+ const rules = view.rules;
1440
+ out.rules = rules ? {
1441
+ ...rules.in === void 0 ? {} : { in: parseSelectors(rules.in, "rules") },
1442
+ ...rules.out === void 0 ? {} : { out: parseSelectors(rules.out, "rules") }
1443
+ } : null;
1444
+ }
1445
+ if (given("caps")) {
1446
+ if (view.caps === null) out.caps = null;
1447
+ else {
1448
+ const caps = view.caps;
1449
+ if (!isRecord(caps) || !Array.isArray(caps.caps) || caps.caps.length > 64 || caps.allow !== void 0 && !isRecord(caps.allow)) throw bad("caps");
1450
+ const allow = Object.entries(caps.allow ?? {});
1451
+ if (allow.length > 64) throw bad("caps");
1452
+ out.caps = {
1453
+ caps: caps.caps.map((cap) => {
1454
+ if (!isRecord(cap) || cap.dir !== "in" && cap.dir !== "out") throw bad("caps");
1455
+ return { id: shortText(cap.id, "caps"), dir: cap.dir, by: shortText(cap.by, "caps"), sels: parseEntries(cap.sels) };
1456
+ }),
1457
+ allow: Object.fromEntries(allow.map(([key, list]) => [shortText(key, "caps"), parseEntries(list)]))
1458
+ };
1459
+ }
1460
+ }
1461
+ if (new TextEncoder().encode(JSON.stringify([out.rules ?? null, out.caps ?? null])).length > SCOPE_BYTES)
1462
+ throw new RelayError("too_large", `an endpoint's rules and caps are more than ${SCOPE_BYTES} bytes`);
1463
+ if (given("title"))
1464
+ out.title = view.title === null ? null : cleanText(typeof view.title === "string" ? view.title.replace(/\s+/g, " ") : view.title, "title", 64) || null;
1465
+ if (given("incarnation")) {
1466
+ if (view.incarnation !== null && (typeof view.incarnation !== "string" || !INCARNATION.test(view.incarnation))) throw bad("incarnation");
1467
+ out.incarnation = view.incarnation ?? null;
1468
+ }
1469
+ return out;
1470
+ }
1471
+
1472
+ // src/hub.ts
1473
+ import {
1474
+ allowlisted,
1475
+ CAPABILITIES,
1476
+ CLI_NAME,
1477
+ cleanText as cleanText2,
1478
+ dayStart,
1479
+ declaredLimits,
1480
+ findSecret as findSecret2,
1481
+ gateRefusal,
1482
+ HUMAN_HANDLE as HUMAN_HANDLE2,
1483
+ honestSender,
1484
+ joinRedacted,
1485
+ judgeDetailForAgents,
1486
+ LIMITS as LIMITS2,
1487
+ MESSAGE_KINDS as MESSAGE_KINDS2,
1488
+ newId as newId2,
1489
+ one as one2,
1490
+ PREAMBLE,
1491
+ PROTOCOL,
1492
+ parseXMessage,
1493
+ RelayError as RelayError2,
1494
+ randomHex as randomHex2,
1495
+ replyGrant,
1496
+ SERVER_FIELDS,
1497
+ SYSTEM_HANDLE as SYSTEM_HANDLE2,
1498
+ sameSecret,
1499
+ untilTomorrow,
1500
+ XCounters,
1501
+ XLIMITS,
1502
+ xlimits
1503
+ } from "@scp-dev/core";
1504
+ var HUB_LIMITS = {
1505
+ machines: 16,
1506
+ endpoints: 512,
1507
+ sockets: 64,
1508
+ frameBytes: 262144,
1509
+ callsPerMinute: 120,
1510
+ bridgeCallsPerMinute: 600,
1511
+ accountCallsPerMinute: 1200,
1512
+ acksPerMinute: 1200,
1513
+ accountAcksPerMinute: 4800,
1514
+ storedBytes: 256 * 1024 * 1024,
1515
+ clientMachineIdleS: 14 * 86400,
1516
+ idlePurgeS: 30 * 86400,
1517
+ httpWaitS: 25,
1518
+ revalidateMs: 3e5,
1519
+ deniedS: 3600,
1520
+ refusalsPerMinute: 10
1521
+ };
1522
+ var DURATIONS = ["clientMachineIdleS", "idlePurgeS", "httpWaitS", "revalidateMs", "deniedS"];
1523
+ var SWEEP_MS = 10 * 6e4;
1524
+ var DAY_MS = 864e5;
1525
+ var MIN_ALARM_MS = 6e4;
1526
+ var REVALIDATE_RETRY_MS = 3e4;
1527
+ var REVALIDATE_ERROR = "revalidate_error:";
1528
+ var DENIED = "denied:";
1529
+ var TOUCH_MS = 1e4;
1530
+ var HUB_MIGRATION = [
1531
+ `CREATE TABLE IF NOT EXISTS machines (name TEXT PRIMARY KEY, token_id TEXT NOT NULL, kind TEXT NOT NULL, created_at INTEGER NOT NULL, last_seen INTEGER)`,
1532
+ `CREATE TABLE IF NOT EXISTS hub_state (key TEXT PRIMARY KEY, value INTEGER NOT NULL)`,
1533
+ `CREATE TABLE IF NOT EXISTS machine_tombstones (name TEXT PRIMARY KEY, actor_id TEXT, user_id TEXT, at INTEGER NOT NULL)`
1534
+ ];
1535
+ var OBSERVER_METHODS = /* @__PURE__ */ new Set(["list", "snapshot", "watch", "log", "machines", "machines_rm"]);
1536
+ var CLIENT_METHODS = /* @__PURE__ */ new Set(["whoami", "send", "ask", "ask_wait", "inbox", "ack", "status", "event", ...OBSERVER_METHODS]);
1537
+ var iso = (ms) => ms === null ? null : new Date(ms).toISOString();
1538
+ function rpcError(error) {
1539
+ if (!(error instanceof RelayError2)) return { code: -32603, message: "internal error", data: { code: "unavailable" } };
1540
+ const code = error.code === "method_not_found" ? -32601 : error.code === "invalid_params" ? -32602 : -32e3;
1541
+ return { code, message: error.message, data: { code: error.code, ...error.retryAfterS ? { retry_after: error.retryAfterS } : {} } };
1542
+ }
1543
+ function mirrorSync(value) {
1544
+ if (value === void 0 || value === null) return null;
1545
+ const { id, part, parts } = value;
1546
+ if (typeof id !== "string" || !id || id.length > 64 || !Number.isInteger(parts) || parts < 1 || parts > 4096 || !Number.isInteger(part) || part < 1 || part > parts) {
1547
+ throw new RelayError2("invalid_params", "sync is { id, part, parts } of a full mirror in several frames");
1548
+ }
1549
+ return { id, part, parts };
1550
+ }
1551
+ function serverFields(params) {
1552
+ for (const field of SERVER_FIELDS) if (field in params) throw new RelayError2("invalid_params", `${field} is set by the relay, not the sender`);
1553
+ }
1554
+ function object(value, what) {
1555
+ if (value === void 0) return {};
1556
+ if (!value || typeof value !== "object" || Array.isArray(value)) throw new RelayError2("invalid_params", `${what} must be an object`);
1557
+ return value;
1558
+ }
1559
+ var fullLogin = (grant) => grant.scoped === false;
1560
+ var fullOf = (grant, actorId) => fullLogin(grant) && grant.actorId === actorId;
1561
+ var accountAdmin = (grant) => fullLogin(grant) && (grant.role === "owner" || grant.role === "admin" || (grant.role ?? null) === null && grant.actorId === null);
1562
+ function grantLabels(grant, labels) {
1563
+ if (!grant.labels || !Object.keys(grant.labels).length) return labels;
1564
+ if (labels !== void 0 && labels !== null && (typeof labels !== "object" || Array.isArray(labels))) return labels;
1565
+ return { ...labels, ...grant.labels };
1566
+ }
1567
+ function grantViews(grant, views) {
1568
+ if (!grant.labels || !Array.isArray(views)) return views;
1569
+ return views.map(
1570
+ (view) => view && typeof view === "object" && !Array.isArray(view) ? { ...view, labels: grantLabels(grant, view.labels) } : view
1571
+ );
1572
+ }
1573
+ function listFilters(params) {
1574
+ return {
1575
+ all: params.all === true,
1576
+ harness: typeof params.harness === "string" ? params.harness : void 0,
1577
+ kind: params.kind === "agent" || params.kind === "human" ? params.kind : void 0,
1578
+ presence: params.presence === "online" || params.presence === "idle" || params.presence === "offline" ? params.presence : void 0
1579
+ };
1580
+ }
1581
+ var RelayHub = class _RelayHub {
1582
+ constructor(deps) {
1583
+ this.deps = deps;
1584
+ this.now = deps.now ?? Date.now;
1585
+ this.limits = { ...HUB_LIMITS, ...deps.limits };
1586
+ for (const key of DURATIONS) if (!Number.isFinite(this.limits[key])) throw new RangeError(`hub limit ${key} must be finite`);
1587
+ }
1588
+ deps;
1589
+ core = null;
1590
+ account = "";
1591
+ now;
1592
+ waiters = /* @__PURE__ */ new Map();
1593
+ buckets = /* @__PURE__ */ new Map();
1594
+ transient = /* @__PURE__ */ new Set();
1595
+ gone = /* @__PURE__ */ new Set();
1596
+ /** per machine, the full mirror its bridge is sending in several frames; lost on eviction, which only skips that sync's offline sweep */
1597
+ syncs = /* @__PURE__ */ new Map();
1598
+ stored = null;
1599
+ denied = null;
1600
+ xcounters = null;
1601
+ xkey = null;
1602
+ limits;
1603
+ /** idempotent: migrates and records the account handle (the address user part, e.g. "ada"); call before handling each request */
1604
+ init(account) {
1605
+ if (this.core && this.account === account.user) return;
1606
+ for (const statement of HUB_MIGRATION) this.deps.sql.all(statement);
1607
+ const columns = this.deps.sql.all(`PRAGMA table_info(machines)`).map((c) => c.name);
1608
+ if (!columns.includes("user_id")) this.deps.sql.all(`ALTER TABLE machines ADD COLUMN user_id TEXT`);
1609
+ if (!columns.includes("actor_id")) this.deps.sql.all(`ALTER TABLE machines ADD COLUMN actor_id TEXT`);
1610
+ const core = new FederatedCore(this.deps.sql, {
1611
+ user: account.user,
1612
+ now: this.now,
1613
+ hash: this.deps.hash,
1614
+ ...this.deps.transaction ? { transaction: this.deps.transaction } : {},
1615
+ auth: "account",
1616
+ endpointCap: this.limits.endpoints,
1617
+ refusalsPerMinute: this.limits.refusalsPerMinute,
1618
+ capEverySender: true,
1619
+ xinbox: this.deps.crossAccount !== void 0
1620
+ });
1621
+ core.migrate();
1622
+ this.core = core;
1623
+ this.account = account.user;
1624
+ if (this.deps.crossAccount) {
1625
+ this.xkey = core.xstate("key", () => randomHex2(32));
1626
+ this.xcounters = new XCounters(this.deps.sql, this.now);
1627
+ }
1628
+ }
1629
+ /** forgets the core and in-memory state after the object's storage was deleted; the next init migrates afresh */
1630
+ reset() {
1631
+ this.core = null;
1632
+ this.account = "";
1633
+ this.stored = null;
1634
+ this.denied = null;
1635
+ this.xcounters = null;
1636
+ this.xkey = null;
1637
+ this.buckets.clear();
1638
+ this.gone.clear();
1639
+ }
1640
+ /** a fresh connection state for an accepted socket */
1641
+ static connection(grant) {
1642
+ return { id: newId2("conn"), grant, role: "new", machine: null, endpoint: null, watching: false, subscribed: false };
1643
+ }
1644
+ /** a socket would exceed the `sockets` limit: the host refuses it (429) instead of accepting */
1645
+ full() {
1646
+ return this.deps.sockets().length >= this.limits.sockets;
1647
+ }
1648
+ /** one JSON-RPC text frame from a socket; replies and pushes go out through socket.send */
1649
+ async message(socket, text) {
1650
+ if (new TextEncoder().encode(text).length > this.limits.frameBytes) {
1651
+ this.write(socket, { jsonrpc: "2.0", id: null, error: rpcError(new RelayError2("too_large", `a frame is at most ${this.limits.frameBytes} bytes`)) });
1652
+ socket.close(1009, "frame too large");
1653
+ this.closed(socket);
1654
+ return;
1655
+ }
1656
+ let request;
1657
+ try {
1658
+ request = JSON.parse(text);
1659
+ } catch {
1660
+ this.write(socket, { jsonrpc: "2.0", id: null, error: { code: -32700, message: "parse error" } });
1661
+ return;
1662
+ }
1663
+ if (!request || typeof request !== "object" || Array.isArray(request)) {
1664
+ this.write(socket, { jsonrpc: "2.0", id: null, error: { code: -32600, message: "one JSON-RPC request per frame" } });
1665
+ return;
1666
+ }
1667
+ const response = await this.handle(socket, request);
1668
+ if (response) this.write(socket, response);
1669
+ }
1670
+ /** the socket closed (any reason) */
1671
+ closed(socket) {
1672
+ this.release(socket, false);
1673
+ }
1674
+ /** POST /v1/relay/rpc: a JSON-RPC request or batch run in order on a short-lived connection; returns the response(s) */
1675
+ async batch(grant, body) {
1676
+ if (JSON.stringify(body ?? null).length > this.limits.frameBytes) {
1677
+ return { jsonrpc: "2.0", id: null, error: rpcError(new RelayError2("too_large", `a request is at most ${this.limits.frameBytes} bytes`)) };
1678
+ }
1679
+ const requests = Array.isArray(body) ? body : [body];
1680
+ if (!requests.length) return { jsonrpc: "2.0", id: null, error: { code: -32600, message: "an empty batch" } };
1681
+ const socket = { conn: _RelayHub.connection(grant), send: () => {
1682
+ }, close: () => {
1683
+ } };
1684
+ this.transient.add(socket);
1685
+ const responses = [];
1686
+ try {
1687
+ for (const request of requests) {
1688
+ if (!request || typeof request !== "object" || Array.isArray(request)) {
1689
+ responses.push({ jsonrpc: "2.0", id: null, error: { code: -32600, message: "a request is an object" } });
1690
+ continue;
1691
+ }
1692
+ const response = await this.handle(socket, request);
1693
+ if (response) responses.push(response);
1694
+ }
1695
+ } finally {
1696
+ this.closed(socket);
1697
+ this.transient.delete(socket);
1698
+ }
1699
+ return Array.isArray(body) ? responses : responses[0] ?? null;
1700
+ }
1701
+ /**
1702
+ * closes every socket (and in-flight /rpc call) whose grant matches with 1008, audits it, and unbinds the machines
1703
+ * the matching grants bound behind a tombstone of their member, keeping their endpoint rows and queued messages;
1704
+ * returns how many closed
1705
+ */
1706
+ revoke(match) {
1707
+ const keys = Object.entries(match).filter(([, value]) => value !== void 0);
1708
+ if (!keys.length) return 0;
1709
+ let closed = 0;
1710
+ const tokens = new Set(match.tokenId !== void 0 ? [match.tokenId] : []);
1711
+ for (const socket of [...this.deps.sockets(), ...this.transient]) {
1712
+ if (this.gone.has(socket.conn.id) || !keys.every(([key, value]) => socket.conn.grant[key] === value)) continue;
1713
+ tokens.add(socket.conn.grant.tokenId);
1714
+ this.kick(socket, "the token was revoked");
1715
+ closed++;
1716
+ }
1717
+ if (closed) this.relay.record("revoke", null, null, { sockets: closed });
1718
+ const columns = match.tokenId !== void 0 ? [["token_id", match.tokenId]] : keys.map(([key, value]) => [key === "userId" ? "user_id" : "actor_id", value]);
1719
+ const where = columns.map(([column]) => `${column} = ?`).join(" AND ");
1720
+ const now = this.now();
1721
+ for (const row of this.deps.sql.all(
1722
+ `DELETE FROM machines WHERE ${where} RETURNING name, token_id, actor_id, user_id`,
1723
+ ...columns.map(([, value]) => value)
1724
+ )) {
1725
+ tokens.add(row.token_id);
1726
+ this.deps.sql.all(`INSERT OR REPLACE INTO machine_tombstones (name, actor_id, user_id, at) VALUES (?, ?, ?, ?)`, row.name, row.actor_id, row.user_id, now);
1727
+ this.relay.record("machine_unbind", null, row.name, { machine: row.name });
1728
+ }
1729
+ const denied = this.deniedGrants();
1730
+ for (const token of tokens) {
1731
+ denied.set(DENIED + token, now);
1732
+ this.setState(DENIED + token, now);
1733
+ }
1734
+ this.deps.log?.("relay_action", { account: this.account, method: "revoke", sockets: closed });
1735
+ return closed;
1736
+ }
1737
+ /**
1738
+ * the host's alarm: revalidates each socket's grant and sweeps; returns the next alarm time in ms, or null. A failed
1739
+ * check closes with 1008, an expired grant with 4001 (reconnect for a new one). A check that throws keeps the
1740
+ * sockets and retries in 30 s; once a token's checks kept throwing for the `revalidateMs` limit its sockets close
1741
+ * with the retryable 1013.
1742
+ */
1743
+ async alarm(validate) {
1744
+ const now = this.now();
1745
+ this.xcounters?.trueUp();
1746
+ const checks = /* @__PURE__ */ new Map();
1747
+ const erroring = /* @__PURE__ */ new Set();
1748
+ for (const socket of [...this.deps.sockets()]) {
1749
+ const grant = socket.conn.grant;
1750
+ if (grant.expiresAt <= now) {
1751
+ this.kick(socket, "the token grant expired, reconnect", 4001);
1752
+ continue;
1753
+ }
1754
+ const key = JSON.stringify([grant.tokenId, grant.userId, grant.actorId, grant.expiresAt, grant.scoped, grant.role]);
1755
+ let check = checks.get(key);
1756
+ if (!check) {
1757
+ check = validate(grant).catch((error) => {
1758
+ this.deps.log?.("relay_revalidate_error", { account: this.account, error: error instanceof Error ? error.name : "error" });
1759
+ return null;
1760
+ });
1761
+ checks.set(key, check);
1762
+ }
1763
+ const valid = await check;
1764
+ if (valid === false) {
1765
+ this.kick(socket, "the token is no longer valid");
1766
+ } else if (valid === null) {
1767
+ erroring.add(grant.tokenId);
1768
+ const key2 = REVALIDATE_ERROR + grant.tokenId;
1769
+ let since = this.state(key2);
1770
+ if (since === null) {
1771
+ since = now;
1772
+ this.setState(key2, since);
1773
+ }
1774
+ if (now - since >= this.limits.revalidateMs) this.kick(socket, "the token could not be revalidated, try again", 1013);
1775
+ }
1776
+ }
1777
+ for (const { key } of this.deps.sql.all(`SELECT key FROM hub_state WHERE key LIKE ?`, `${REVALIDATE_ERROR}%`)) {
1778
+ const tokenId = key.slice(REVALIDATE_ERROR.length);
1779
+ const lingering = this.deps.sockets().some((s) => s.conn.grant.tokenId === tokenId && !this.gone.has(s.conn.id));
1780
+ if (!erroring.has(tokenId) || !lingering) this.deps.sql.all(`DELETE FROM hub_state WHERE key = ?`, key);
1781
+ }
1782
+ this.setState("last_validated", now);
1783
+ if (now - (this.state("last_sweep") ?? 0) >= SWEEP_MS) {
1784
+ this.relay.sweep();
1785
+ this.expireMachines(now);
1786
+ this.forgetTombstones(now);
1787
+ const denied = this.deniedGrants();
1788
+ for (const [key, at] of denied) {
1789
+ if (at > now - this.limits.deniedS * 1e3) continue;
1790
+ denied.delete(key);
1791
+ this.deps.sql.all(`DELETE FROM hub_state WHERE key = ?`, key);
1792
+ }
1793
+ this.stored = null;
1794
+ this.gone.clear();
1795
+ for (const [key, bucket] of this.buckets) if (bucket.tokens + (now - bucket.at) * bucket.rate / 6e4 >= bucket.rate) this.buckets.delete(key);
1796
+ this.xcounters?.prune();
1797
+ this.setState("last_sweep", now);
1798
+ }
1799
+ if (this.idle(now)) {
1800
+ if (this.state("idle_since") === null) this.setState("idle_since", now);
1801
+ } else {
1802
+ this.deps.sql.all(`DELETE FROM hub_state WHERE key = 'idle_since'`);
1803
+ }
1804
+ return this.nextAlarm();
1805
+ }
1806
+ /** when the next alarm should fire (earliest grant expiry, revalidation or its retry after an error, sweep, retention), or null when the hub holds nothing */
1807
+ nextAlarm() {
1808
+ const now = this.now();
1809
+ const trueUp = this.xcounters?.dueAt() ?? null;
1810
+ const held = this.retentionAlarm(now);
1811
+ const retention = held === null || trueUp === null ? held ?? trueUp : Math.min(held, trueUp);
1812
+ const sockets = this.deps.sockets();
1813
+ if (!sockets.length) return retention;
1814
+ let validated = this.state("last_validated");
1815
+ if (validated === null) {
1816
+ validated = now;
1817
+ this.setState("last_validated", validated);
1818
+ }
1819
+ let swept = this.state("last_sweep");
1820
+ if (swept === null) {
1821
+ swept = now;
1822
+ this.setState("last_sweep", swept);
1823
+ }
1824
+ const retrying = one2(this.deps.sql, `SELECT 1 FROM hub_state WHERE key LIKE ? LIMIT 1`, `${REVALIDATE_ERROR}%`) !== void 0;
1825
+ return Math.min(
1826
+ validated + (retrying ? REVALIDATE_RETRY_MS : this.limits.revalidateMs),
1827
+ swept + SWEEP_MS,
1828
+ retention ?? Number.POSITIVE_INFINITY,
1829
+ ...sockets.map((s) => s.conn.grant.expiresAt)
1830
+ );
1831
+ }
1832
+ /** the account held no machines, tombstones, sockets or queued messages for the `idlePurgeS` limit: the object may delete its storage */
1833
+ purgeDue() {
1834
+ const since = this.state("idle_since");
1835
+ return since !== null && this.now() - since >= this.limits.idlePurgeS * 1e3 && this.idle(this.now());
1836
+ }
1837
+ /** while the hub holds rows: the earliest unacked delivery's expiry, at most a day and at least a minute away */
1838
+ retentionAlarm(now) {
1839
+ const sql = this.deps.sql;
1840
+ const held = one2(
1841
+ sql,
1842
+ `SELECT EXISTS (SELECT 1 FROM machines) OR EXISTS (SELECT 1 FROM machine_tombstones) OR EXISTS (SELECT 1 FROM endpoints WHERE handle NOT IN (?, ?))
1843
+ OR EXISTS (SELECT 1 FROM messages) OR EXISTS (SELECT 1 FROM deliveries) OR EXISTS (SELECT 1 FROM audit) AS held`,
1844
+ HUMAN_HANDLE2,
1845
+ SYSTEM_HANDLE2
1846
+ ).held;
1847
+ if (!held) return null;
1848
+ const expiry = one2(sql, `SELECT MIN(expires_at) AS at FROM deliveries WHERE acked_at IS NULL`).at;
1849
+ return Math.max(now + MIN_ALARM_MS, Math.min(expiry ?? Number.POSITIVE_INFINITY, now + DAY_MS));
1850
+ }
1851
+ /** no machine bound or tombstoned, no live socket and nothing queued */
1852
+ idle(now) {
1853
+ if (this.deps.sockets().some((s) => !this.gone.has(s.conn.id))) return false;
1854
+ return !one2(
1855
+ this.deps.sql,
1856
+ `SELECT EXISTS (SELECT 1 FROM machines) OR EXISTS (SELECT 1 FROM machine_tombstones) OR EXISTS (SELECT 1 FROM deliveries WHERE acked_at IS NULL AND expires_at > ?) AS busy`,
1857
+ now
1858
+ ).busy;
1859
+ }
1860
+ // dispatch
1861
+ get relay() {
1862
+ if (!this.core) throw new Error("RelayHub.init was not called");
1863
+ return this.core;
1864
+ }
1865
+ async handle(socket, request) {
1866
+ const id = request.id ?? null;
1867
+ const method = String(request.method);
1868
+ let code = "ok";
1869
+ try {
1870
+ this.refuseDenied(socket);
1871
+ this.spend(socket, method, request.params);
1872
+ const result = await this.dispatch(socket, method, object(request.params, "params"));
1873
+ return request.id === void 0 ? null : { jsonrpc: "2.0", id, result };
1874
+ } catch (error) {
1875
+ code = error instanceof RelayError2 ? error.code : "internal";
1876
+ if (code === "internal") this.deps.log?.("relay_internal_error", { account: this.account, method, error: error instanceof Error ? error.name : "error" });
1877
+ return request.id === void 0 ? null : { jsonrpc: "2.0", id, error: rpcError(error) };
1878
+ } finally {
1879
+ this.deps.log?.("relay_action", { account: this.account, machine: socket.conn.machine, method, code });
1880
+ }
1881
+ }
1882
+ /**
1883
+ * one call from the token's bucket for the connection's role (a bridge, or a bridge hello, has its own, larger one)
1884
+ * and from the account's; acks (one token per seq) and rejects spend from separate ack buckets of the token and the
1885
+ * account instead, so they never starve calls, and an activity event is refused once either call bucket is below a
1886
+ * quarter, so sends keep the rest
1887
+ */
1888
+ spend(socket, method, params) {
1889
+ const conn = socket.conn;
1890
+ const now = this.now();
1891
+ if (method === "ack" && (conn.role === "client" || conn.role === "bridge") || method === "reject" && conn.role === "bridge") {
1892
+ const seqs = params?.seqs;
1893
+ const cost = method === "ack" && Array.isArray(seqs) ? Math.min(Math.max(seqs.length, 1), LIMITS2.inboxPage) : 1;
1894
+ const token = this.bucket(`${conn.grant.tokenId}:ack`, this.limits.acksPerMinute, now);
1895
+ const account2 = this.bucket("account:ack", this.limits.accountAcksPerMinute, now);
1896
+ for (const [b, scope] of [
1897
+ [token, "a token"],
1898
+ [account2, "an account"]
1899
+ ]) {
1900
+ if (b.tokens < cost)
1901
+ throw new RelayError2("rate_limited", `at most ${b.rate} acked seqs per minute on ${scope}`, Math.max(1, Math.ceil((cost - b.tokens) * 60 / b.rate)));
1902
+ }
1903
+ token.tokens -= cost;
1904
+ account2.tokens -= cost;
1905
+ return;
1906
+ }
1907
+ const bridge = conn.role === "bridge" || conn.role === "new" && method === "hello" && params?.bridge === true;
1908
+ const rate = bridge ? this.limits.bridgeCallsPerMinute : this.limits.callsPerMinute;
1909
+ const bucket = this.bucket(`${conn.grant.tokenId}:${bridge ? "bridge" : "client"}`, rate, now);
1910
+ const account = this.bucket("account", this.limits.accountCallsPerMinute, now);
1911
+ if (method === "event") {
1912
+ for (const b of [bucket, account]) {
1913
+ if (b.tokens < b.rate / 4)
1914
+ throw new RelayError2(
1915
+ "rate_limited",
1916
+ "activity events wait while the token or the account is near its call limit",
1917
+ Math.max(1, Math.ceil((b.rate / 4 - b.tokens) * 60 / b.rate))
1918
+ );
1919
+ }
1920
+ }
1921
+ if (bucket.tokens < 1) {
1922
+ if (++bucket.refused >= rate) {
1923
+ socket.close(1013, "rate limited");
1924
+ this.closed(socket);
1925
+ }
1926
+ throw new RelayError2(
1927
+ "rate_limited",
1928
+ `at most ${rate} ${bridge ? "bridge " : ""}calls per minute on a token`,
1929
+ Math.max(1, Math.ceil((1 - bucket.tokens) * 60 / rate))
1930
+ );
1931
+ }
1932
+ if (account.tokens < 1) {
1933
+ throw new RelayError2(
1934
+ "rate_limited",
1935
+ `at most ${this.limits.accountCallsPerMinute} calls per minute on an account`,
1936
+ Math.max(1, Math.ceil((1 - account.tokens) * 60 / account.rate))
1937
+ );
1938
+ }
1939
+ bucket.tokens -= 1;
1940
+ bucket.refused = 0;
1941
+ account.tokens -= 1;
1942
+ }
1943
+ bucket(key, rate, now) {
1944
+ if (rate === Number.POSITIVE_INFINITY) return { rate, tokens: rate, at: now, refused: 0 };
1945
+ const bucket = this.buckets.get(key) ?? { rate, tokens: rate, at: now, refused: 0 };
1946
+ bucket.tokens = Math.min(rate, bucket.tokens + (now - bucket.at) * rate / 6e4);
1947
+ bucket.at = now;
1948
+ this.buckets.set(key, bucket);
1949
+ return bucket;
1950
+ }
1951
+ async dispatch(socket, method, params) {
1952
+ if (this.gone.has(socket.conn.id)) throw new RelayError2("unauthorized", "this connection is closed");
1953
+ if (method === "hello") return this.hello(socket, params);
1954
+ if (socket.conn.role === "new") throw new RelayError2("unauthorized", "call hello first");
1955
+ if (socket.conn.role === "bridge") return this.bridgeMethod(socket, method, params);
1956
+ if (socket.conn.role === "observer" && !OBSERVER_METHODS.has(method)) {
1957
+ if (CLIENT_METHODS.has(method)) throw new RelayError2("unauthorized", "this connection only observes: say hello as an agent or the human");
1958
+ throw new RelayError2("method_not_found", `no method ${method}`);
1959
+ }
1960
+ return this.clientMethod(socket, method, params);
1961
+ }
1962
+ hello(socket, params) {
1963
+ const core = this.relay;
1964
+ const conn = socket.conn;
1965
+ if (conn.role !== "new") throw new RelayError2("invalid_params", "hello was already called on this connection");
1966
+ if (params.protocol !== PROTOCOL) throw new RelayError2("invalid_params", `this relay speaks ${PROTOCOL}`);
1967
+ if (params.transient !== void 0)
1968
+ throw new RelayError2("invalid_params", "the hub takes no hook connections: hooks report to the local broker, which bridges them");
1969
+ serverFields(params);
1970
+ const observe = params.observe === true;
1971
+ const named = params.machine;
1972
+ if ((!observe || named !== void 0) && (typeof named !== "string" || !MACHINE_NAME.test(named) || named === "me" || named === this.account)) {
1973
+ throw new RelayError2("invalid_params", "machine is a name of lowercase letters, digits and -, other than me and the account handle");
1974
+ }
1975
+ const bridge = params.bridge === true;
1976
+ const human = params.human === true;
1977
+ this.deps.sql.all(`DELETE FROM hub_state WHERE key = 'idle_since'`);
1978
+ const info = { account: this.account, capabilities: CAPABILITIES, server_time: new Date(this.now()).toISOString(), limits: { ...LIMITS2, ...this.limits } };
1979
+ if (observe) {
1980
+ if (bridge || human || params.claim !== void 0 || params.resume !== void 0)
1981
+ throw new RelayError2("invalid_params", "an observer hello has no bridge, human, claim or resume");
1982
+ conn.role = "observer";
1983
+ conn.machine = typeof named === "string" ? named : null;
1984
+ this.save(socket);
1985
+ return { ...info, ...conn.machine ? { machine: conn.machine } : {} };
1986
+ }
1987
+ const machine = named;
1988
+ const base = { ...info, machine };
1989
+ if (bridge && this.transient.has(socket)) throw new RelayError2("invalid_params", "a bridge connects over the WebSocket");
1990
+ if (human && typeof params.tty !== "boolean")
1991
+ throw new RelayError2("invalid_params", "a human hello says whether a terminal is attached (tty: true or false)");
1992
+ if (human) {
1993
+ if (!fullLogin(conn.grant)) throw new RelayError2("unauthorized", "a scoped token acts as an agent: use --as");
1994
+ const endpoint2 = core.claimHuman(params.tty);
1995
+ conn.role = "client";
1996
+ conn.machine = machine;
1997
+ conn.endpoint = endpoint2.id;
1998
+ conn.tty = params.tty;
1999
+ this.save(socket);
2000
+ this.fleet({ type: "presence", machine, endpoint: endpoint2.address, presence: endpoint2.presence, harness: endpoint2.harness, labels: endpoint2.labels });
2001
+ return { ...base, endpoint: endpoint2, preamble: PREAMBLE };
2002
+ }
2003
+ const claimed = object(params.claim, "claim");
2004
+ const claim = conn.grant.labels ? { ...claimed, labels: grantLabels(conn.grant, claimed.labels) } : claimed;
2005
+ const held = this.bindable(machine, conn.grant);
2006
+ if (bridge) {
2007
+ this.bind(machine, conn.grant, "bridge", held);
2008
+ core.adoptClaimed(machine);
2009
+ conn.role = "bridge";
2010
+ conn.machine = machine;
2011
+ this.save(socket);
2012
+ for (const other of [...this.deps.sockets()]) {
2013
+ if (other.conn.id === conn.id || other.conn.role !== "bridge" || other.conn.machine !== machine) continue;
2014
+ other.close(4e3, "replaced by a newer bridge for this machine");
2015
+ this.release(other, true);
2016
+ }
2017
+ core.record("bridge", null, machine, { machine });
2018
+ return { ...base, endpoints: core.remoteViews({ except: machine }) };
2019
+ }
2020
+ if (one2(this.deps.sql, `SELECT kind FROM machines WHERE name = ?`, machine)?.kind === "bridge") {
2021
+ throw new RelayError2(
2022
+ "invalid_params",
2023
+ `machine ${machine} is linked through its broker's bridge; run the command there without --cloud, or pick another --machine`
2024
+ );
2025
+ }
2026
+ let endpoint;
2027
+ let token;
2028
+ if (typeof params.resume === "string")
2029
+ endpoint = core.resumeOn(machine, params.resume, conn.grant.labels ? (own) => grantLabels(conn.grant, own) : void 0);
2030
+ else ({ endpoint, token } = core.claimOn(machine, claim, "account"));
2031
+ this.bind(machine, conn.grant, "client", held);
2032
+ conn.role = "client";
2033
+ conn.machine = machine;
2034
+ conn.endpoint = endpoint.id;
2035
+ this.save(socket);
2036
+ this.fleet({ type: "presence", machine, endpoint: endpoint.address, presence: endpoint.presence, harness: endpoint.harness, labels: endpoint.labels });
2037
+ this.announceEndpoint(endpoint.id);
2038
+ return { ...base, endpoint, ...token ? { token } : {}, preamble: PREAMBLE };
2039
+ }
2040
+ /** refuses a machine bound to another token, a revoked token's machine to anyone but its member or an admin, or one past the machine cap; returns whether this token already holds it */
2041
+ bindable(machine, grant) {
2042
+ const bound = one2(this.deps.sql, `SELECT token_id FROM machines WHERE name = ?`, machine);
2043
+ if (bound && bound.token_id !== grant.tokenId) {
2044
+ this.relay.record("refuse", null, machine, { code: "unauthorized", machine });
2045
+ throw new RelayError2("unauthorized", `machine ${machine} is bound to another token; release it with ${CLI_NAME} machines rm ${machine}`);
2046
+ }
2047
+ if (bound) return true;
2048
+ const tomb = one2(this.deps.sql, `SELECT actor_id FROM machine_tombstones WHERE name = ?`, machine);
2049
+ if (tomb && !fullOf(grant, tomb.actor_id) && !accountAdmin(grant)) {
2050
+ this.relay.record("refuse", null, machine, { code: "unauthorized", machine });
2051
+ throw new RelayError2(
2052
+ "unauthorized",
2053
+ `machine ${machine} was unbound when its token was revoked; a full login of the same member (or an org owner or admin) can take it back, or ${CLI_NAME} machines rm ${machine} drops its queued messages`
2054
+ );
2055
+ }
2056
+ const count = one2(this.deps.sql, `SELECT COUNT(*) AS n FROM machines`).n;
2057
+ if (count >= this.limits.machines)
2058
+ throw new RelayError2("too_large", `at most ${this.limits.machines} machines per account; release one with ${CLI_NAME} machines rm`);
2059
+ return false;
2060
+ }
2061
+ /** binds the machine to the token once its hello succeeded; `held` is what bindable() returned */
2062
+ bind(machine, grant, kind, held) {
2063
+ const core = this.relay;
2064
+ const now = this.now();
2065
+ if (held) {
2066
+ this.deps.sql.all(
2067
+ `UPDATE machines SET last_seen = ?, user_id = ?, actor_id = ?, kind = CASE WHEN kind = 'bridge' THEN 'bridge' ELSE ? END WHERE name = ?`,
2068
+ now,
2069
+ grant.userId,
2070
+ grant.actorId,
2071
+ kind,
2072
+ machine
2073
+ );
2074
+ return;
2075
+ }
2076
+ this.deps.sql.all(
2077
+ `INSERT INTO machines (name, token_id, user_id, actor_id, kind, created_at, last_seen) VALUES (?, ?, ?, ?, ?, ?, ?)`,
2078
+ machine,
2079
+ grant.tokenId,
2080
+ grant.userId,
2081
+ grant.actorId,
2082
+ kind,
2083
+ now,
2084
+ now
2085
+ );
2086
+ this.deps.sql.all(`DELETE FROM machine_tombstones WHERE name = ?`, machine);
2087
+ this.deps.sql.all(`DELETE FROM hub_state WHERE key = 'idle_since'`);
2088
+ core.record("machine_bind", null, machine, { machine, kind });
2089
+ this.deps.log?.("relay_action", { account: this.account, machine, method: "machine_bind", code: "ok" });
2090
+ }
2091
+ async clientMethod(socket, method, params) {
2092
+ const core = this.relay;
2093
+ const conn = socket.conn;
2094
+ const me = conn.endpoint;
2095
+ if (me) core.touch(me, TOUCH_MS);
2096
+ switch (method) {
2097
+ case "list":
2098
+ if (params.contacts === true) return { contacts: await this.contactList() };
2099
+ return { endpoints: core.list(listFilters(params)) };
2100
+ case "snapshot":
2101
+ return core.snapshot({ all: params.all === true, activity: typeof params.activity === "number" ? params.activity : void 0 });
2102
+ case "watch":
2103
+ conn.watching = true;
2104
+ this.save(socket);
2105
+ return core.snapshot({ activity: 20 });
2106
+ case "log":
2107
+ return {
2108
+ rows: core.log({ since: params.since, limit: params.limit }).map((row) => row.event.startsWith("judge_") ? { ...row, detail: judgeDetailForAgents(row.detail) } : row)
2109
+ };
2110
+ case "machines":
2111
+ return { machines: this.machineList() };
2112
+ case "machines_rm":
2113
+ return this.releaseMachine(socket, params.name);
2114
+ }
2115
+ if (!me) throw new RelayError2("unauthorized", "this connection has no endpoint");
2116
+ switch (method) {
2117
+ case "whoami":
2118
+ return core.whoami(me);
2119
+ case "send":
2120
+ case "ask": {
2121
+ serverFields(params);
2122
+ const send = { ...params };
2123
+ delete send.wait_s;
2124
+ if (method === "ask") send.kind = "request";
2125
+ const bytes = this.room(me, send.body);
2126
+ const result = core.within(conn.machine, () => core.send(me, send, conn.tty === void 0 ? {} : { tty: conn.tty }));
2127
+ if (!result.duplicate) this.stash(bytes);
2128
+ if (!result.duplicate) this.delivered(me, result, send.kind ?? "message");
2129
+ if (method === "send") return result;
2130
+ return this.awaitReply(socket, me, result.id, this.waitMs(socket, params.wait_s), result);
2131
+ }
2132
+ case "ask_wait": {
2133
+ if (typeof params.id !== "string") throw new RelayError2("invalid_params", "id is the request's message id");
2134
+ return this.awaitReply(socket, me, params.id, this.waitMs(socket, params.wait_s));
2135
+ }
2136
+ case "inbox": {
2137
+ const query = { after: params.after, limit: params.limit };
2138
+ const deadline = Date.now() + this.waitMs(socket, params.wait_s);
2139
+ let messages = core.inbox(me, query);
2140
+ while (!messages.length && !this.gone.has(conn.id) && Date.now() < deadline) {
2141
+ await this.waitFor(me, deadline - Date.now(), socket);
2142
+ messages = core.inbox(me, query);
2143
+ }
2144
+ return { messages, next: messages.length ? messages[messages.length - 1].seq : query.after ?? 0 };
2145
+ }
2146
+ case "ack":
2147
+ return { acked: core.ack(me, params.seqs) };
2148
+ case "status": {
2149
+ core.status(me, params.state, params.note);
2150
+ const view = core.whoami(me);
2151
+ this.fleet({ type: "status", machine: conn.machine, endpoint: view.address, status: view.status });
2152
+ this.announceEndpoint(me);
2153
+ return view;
2154
+ }
2155
+ case "event": {
2156
+ const row = core.event(me, params);
2157
+ this.fleet({ type: "activity", machine: conn.machine, ...row });
2158
+ return row;
2159
+ }
2160
+ }
2161
+ throw new RelayError2("method_not_found", `no method ${method}`);
2162
+ }
2163
+ async bridgeMethod(socket, method, params) {
2164
+ const core = this.relay;
2165
+ const conn = socket.conn;
2166
+ const machine = conn.machine;
2167
+ switch (method) {
2168
+ case "mirror": {
2169
+ const sync = mirrorSync(params.sync);
2170
+ if (!sync) {
2171
+ const full = params.full === true;
2172
+ const changes2 = core.mirror(machine, grantViews(conn.grant, params.endpoints), { full, auth: "account+bridge" });
2173
+ this.announce(machine, changes2, full);
2174
+ return { changed: changes2.length };
2175
+ }
2176
+ let state = this.syncs.get(machine);
2177
+ if (sync.part === 1) {
2178
+ state = { id: sync.id, parts: sync.parts, next: 1, seen: /* @__PURE__ */ new Set() };
2179
+ this.syncs.set(machine, state);
2180
+ }
2181
+ const intact = state !== void 0 && state.id === sync.id && state.parts === sync.parts && state.next === sync.part;
2182
+ if (!intact) this.syncs.delete(machine);
2183
+ const closing = intact && sync.part === sync.parts;
2184
+ const changes = core.mirror(machine, grantViews(conn.grant, params.endpoints), {
2185
+ full: closing,
2186
+ auth: "account+bridge",
2187
+ ...intact ? { seen: state.seen } : {}
2188
+ });
2189
+ if (intact) state.next++;
2190
+ if (closing) this.syncs.delete(machine);
2191
+ this.announce(machine, changes, closing);
2192
+ return { changed: changes.length };
2193
+ }
2194
+ case "forward": {
2195
+ const sender = core.remoteEndpoint(machine, params.from);
2196
+ const send = { ...object(params.params, "params") };
2197
+ serverFields(send);
2198
+ const asked = params.hop_floor ?? 0;
2199
+ if (!Number.isInteger(asked) || asked < 0 || asked > 1e3) throw new RelayError2("invalid_params", "hop_floor is a hop count");
2200
+ if ([send.to].flat().some((to) => core.foreignAccount(to) !== null)) return this.xsend(sender, send, { tty: params.tty === true, hop: asked });
2201
+ delete send.attachments;
2202
+ const floor = Math.min(asked, LIMITS2.hopMax + 1);
2203
+ if (floor > 0) core.markReceived(sender, floor - 1);
2204
+ const bytes = this.room(sender, send.body);
2205
+ const result = core.within(machine, () => core.send(sender, send, { tty: params.tty === true }));
2206
+ if (!result.duplicate) this.stash(bytes);
2207
+ if (!result.duplicate) this.delivered(sender, result, send.kind ?? "message");
2208
+ return result;
2209
+ }
2210
+ case "subscribe": {
2211
+ const after = params.after ?? 0;
2212
+ if (!Number.isInteger(after) || after < 0) throw new RelayError2("invalid_params", "after is a seq");
2213
+ if (this.deps.crossAccount) core.xsenders(await this.xinbound());
2214
+ conn.subscribed = true;
2215
+ this.save(socket);
2216
+ let cursor = 0;
2217
+ let replayed = 0;
2218
+ for (; ; ) {
2219
+ const page = core.pending(machine, Math.max(cursor, after), 200, cursor);
2220
+ for (const delivery of page)
2221
+ this.write(socket, { jsonrpc: "2.0", method: "deliver", params: { seq: delivery.seq, endpoint: delivery.endpoint, message: delivery.message } });
2222
+ replayed += page.length;
2223
+ if (page.length) cursor = page[page.length - 1].seq;
2224
+ if (page.length < 200) break;
2225
+ }
2226
+ return { replayed, next: Math.max(cursor, after) };
2227
+ }
2228
+ case "ack":
2229
+ return { acked: core.ackOn(machine, params.seqs) };
2230
+ case "reject": {
2231
+ const { notice } = core.rejectOn(machine, params.seq, params.code);
2232
+ if (notice) this.route(notice.seq, notice.endpoint);
2233
+ return { rejected: true };
2234
+ }
2235
+ case "event": {
2236
+ const sender = core.remoteEndpoint(machine, params.from);
2237
+ const row = core.event(sender, params);
2238
+ this.fleet({ type: "activity", machine, ...row });
2239
+ return row;
2240
+ }
2241
+ case "list":
2242
+ if (params.contacts === true) return { contacts: await this.contactList() };
2243
+ return { endpoints: core.list(listFilters(params)) };
2244
+ case "watch":
2245
+ conn.watching = true;
2246
+ this.save(socket);
2247
+ return core.snapshot({ activity: 20 });
2248
+ }
2249
+ throw new RelayError2("method_not_found", `no method ${method} on a bridge`);
2250
+ }
2251
+ // other accounts
2252
+ /**
2253
+ * A bridge's forward to another account's endpoint, writing no row here: shape, the contact, the sender on this account's allowlist,
2254
+ * its scope (or the reply grant of the message it answers), the caps, the scrubber, the egress gate, then the peer relay. Anything
2255
+ * unresolvable is not_found, as for a missing endpoint.
2256
+ */
2257
+ async xsend(sender, send, origin) {
2258
+ const core = this.relay;
2259
+ const link = this.deps.crossAccount;
2260
+ const targets = [send.to].flat();
2261
+ const missing = () => new RelayError2("not_found", `no endpoint ${targets.map(String).join(", ")}`);
2262
+ if (targets.length !== 1) throw new RelayError2("invalid_params", "a message to another account goes to exactly one endpoint: no lists, groups or tree:");
2263
+ const target = core.foreignAccount(targets[0]);
2264
+ if (!target || !link || !this.xcounters || !this.xkey) throw missing();
2265
+ if (typeof send.body !== "string") throw new RelayError2("invalid_params", "body must be a string");
2266
+ const bytes = new TextEncoder().encode(send.body).length;
2267
+ if (bytes > XLIMITS.bodyBytes) throw new RelayError2("too_large", `a message to another account is at most ${XLIMITS.bodyBytes} bytes; this one is ${bytes}`);
2268
+ if (send.attachments !== void 0 && !(Array.isArray(send.attachments) && !send.attachments.length))
2269
+ throw new RelayError2("data_class_refused", "attachments stay in this account");
2270
+ if (send.data_class === "private") throw new RelayError2("data_class_refused", "a private message stays in this account");
2271
+ if (send.data_class !== void 0 && send.data_class !== "public") throw new RelayError2("invalid_params", "data_class must be public or private");
2272
+ const kind = send.kind ?? "message";
2273
+ if (!MESSAGE_KINDS2.includes(kind)) throw new RelayError2("invalid_params", `kind must be one of ${MESSAGE_KINDS2.join(", ")}`);
2274
+ if (send.reply_to !== void 0 && typeof send.reply_to !== "string") throw new RelayError2("invalid_params", "reply_to is a message id");
2275
+ if (origin.hop > XLIMITS.hopMax)
2276
+ throw new RelayError2(
2277
+ "loop_guard",
2278
+ `this message would be hop ${origin.hop} of a chain across accounts (limit ${XLIMITS.hopMax}); stop and report to your user`
2279
+ );
2280
+ const contact = await link.contactByHandle(target.account);
2281
+ if (!contact || contact.dir === "in" || contact.peerHandle !== target.account) throw missing();
2282
+ const me = core.xsender(sender);
2283
+ if (!me || !allowlisted(contact.allow, me.machine, me.handle, me.depth)) throw missing();
2284
+ const address = `${target.account}/${target.machine}/${target.handle || "me"}`;
2285
+ const replyTo = send.reply_to ?? null;
2286
+ const parent = replyTo ? core.xreceived(me.remote, replyTo) : null;
2287
+ const answered = parent && parent.from_account === contact.peerId ? JSON.parse(parent.message) : null;
2288
+ const granted = answered && answered.from.address === address ? parent : null;
2289
+ if (!granted && !core.crossAdmission(sender, "out", target).ok) throw missing();
2290
+ const now = this.now();
2291
+ const thread = granted?.thread ?? cleanText2(send.thread, "thread", 64) ?? newId2("thr", now);
2292
+ const hop = Math.max(origin.hop, granted ? answered.hop + 1 : 0);
2293
+ if (hop > XLIMITS.hopMax)
2294
+ throw new RelayError2("loop_guard", `this message would be hop ${hop} of a chain across accounts (limit ${XLIMITS.hopMax}); stop and report to your user`);
2295
+ this.xcounters.check(contact, me.remote, address, thread);
2296
+ const secret = findSecret2(send.body);
2297
+ if (secret) throw new RelayError2("secret_refused", `the message looks like it carries a credential (${secret}); send a reference instead`);
2298
+ const ttl = send.ttl_s ?? LIMITS2.ttlDefaultS;
2299
+ if (!Number.isInteger(ttl) || ttl < 1 || ttl > LIMITS2.ttlMaxS)
2300
+ throw new RelayError2("invalid_params", `ttl_s must be 1 to ${LIMITS2.ttlMaxS}`);
2301
+ const self = link.self;
2302
+ const own = `${self.handle}/${me.machine}/${me.handle || "me"}`;
2303
+ const human = me.kind === "human" && me.handle === "" && origin.tty;
2304
+ const from = {
2305
+ account: self.handle,
2306
+ address: human ? `${self.handle}/me` : own,
2307
+ human,
2308
+ auth: "contact",
2309
+ rel: `contact:${self.handle}`,
2310
+ machine: me.machine,
2311
+ harness: me.harness
2312
+ };
2313
+ const id = origin.id ?? newId2("msg", now);
2314
+ const expiresAt = now + ttl * 1e3;
2315
+ const declared = declaredLimits(contact.limits);
2316
+ const msg = {
2317
+ id,
2318
+ from,
2319
+ to: `${target.machine}/${target.handle}`,
2320
+ body: send.body,
2321
+ kind,
2322
+ thread,
2323
+ reply_to: replyTo,
2324
+ hop,
2325
+ client_id: origin.clientId ?? this.deps.hash(`${own}|${address}|${cleanText2(send.client_id, "client_id", 128) ?? newId2("cid", now)}`),
2326
+ created_at: now,
2327
+ expires_at: expiresAt,
2328
+ ...granted?.grant ? { reply_grant: granted.grant, reply_grant_expires_at: granted.expires_at } : {},
2329
+ grant: replyGrant(this.deps.hash, this.xkey, id, own, contact.peerId, expiresAt),
2330
+ ...declared ? { limits: declared } : {}
2331
+ };
2332
+ const verdict = origin.judged ? { verdict: "pass" } : await link.gate.egress(msg, { side: "egress", account: self.handle, peer: contact.peerHandle, peer_id: contact.peerId, sender });
2333
+ if (verdict.verdict === "refuse") throw gateRefusal(verdict, "egress");
2334
+ if (verdict.verdict === "hold") return { id: verdict.hold_id, thread, hop, deliveries: [], held: { hold_id: verdict.hold_id, side: "egress" } };
2335
+ if (verdict.verdict === "duplicate") return { id: verdict.hold_id, thread, hop, deliveries: [], duplicate: true };
2336
+ if (verdict.verdict === "redact") {
2337
+ const result = await this.xsend(sender, { ...send, body: verdict.body }, { ...origin, judged: true });
2338
+ const redacted = joinRedacted(verdict.redacted, result.redacted);
2339
+ return { ...result, ...redacted ? { redacted } : {} };
2340
+ }
2341
+ this.xcounters.check(contact, me.remote, address, thread);
2342
+ const refund = this.xcounters.sent(contact, me.remote, address, thread);
2343
+ let delivered;
2344
+ try {
2345
+ delivered = await link.deliver(contact, msg);
2346
+ } catch (error) {
2347
+ if (!(error instanceof RelayError2) || error.code === "unavailable") refund();
2348
+ throw error;
2349
+ }
2350
+ this.fleet({ type: "message", machine: me.machine, id: delivered.id, thread, kind, from: from.address, to: [address], hop });
2351
+ return {
2352
+ id: delivered.id,
2353
+ thread,
2354
+ hop,
2355
+ deliveries: [{ endpoint: address, address, seq: 0, offline: delivered.state === "queued", last_seen: null }],
2356
+ ...delivered.duplicate ? { duplicate: true } : {},
2357
+ ...delivered.held ? { held: { hold_id: delivered.held.hold_id, side: "ingress" } } : {},
2358
+ ...delivered.redacted ? { redacted: delivered.redacted } : {}
2359
+ };
2360
+ }
2361
+ /**
2362
+ * A message from the account `fromAccountId`'s relay (the host's internal route, never a client): the contact, the target on this
2363
+ * account's allowlist, its scope (or the reply grant), shape, a retry's earlier answer, the scrubber, the ingress gate, then one xinbox
2364
+ * row pushed to the target's bridge. Anything unresolvable is not_found, as for a missing endpoint.
2365
+ */
2366
+ async xreceive(fromAccountId, raw, judged = false) {
2367
+ const core = this.relay;
2368
+ const link = this.deps.crossAccount;
2369
+ const msg = parseXMessage(raw);
2370
+ const missing = () => new RelayError2("not_found", `no endpoint ${link?.self.handle ?? this.account}/${msg.to}`);
2371
+ if (!link || !this.xcounters || !this.xkey) throw missing();
2372
+ const contact = await link.contactById(fromAccountId);
2373
+ if (!contact || contact.dir === "out" || contact.peerId !== fromAccountId || msg.from.account !== contact.peerHandle) throw missing();
2374
+ const target = core.xendpoint(msg.to);
2375
+ if (!target || !allowlisted(contact.allow, target.machine, target.handle, target.depth)) throw missing();
2376
+ const address = `${link.self.handle}/${target.machine}/${target.handle || "me"}`;
2377
+ const grantExpiry = msg.reply_grant_expires_at;
2378
+ const granted = msg.reply_to !== null && msg.reply_grant !== void 0 && grantExpiry !== void 0 && grantExpiry > this.now() && sameSecret(msg.reply_grant, replyGrant(this.deps.hash, this.xkey, msg.reply_to, address, fromAccountId, grantExpiry));
2379
+ const parts = msg.from.address.split("/");
2380
+ const peer = {
2381
+ account: contact.peerHandle,
2382
+ machine: parts.length === 3 ? parts[1] : msg.from.machine ?? "me",
2383
+ handle: parts.length === 3 && parts[2] !== "me" ? parts[2] : ""
2384
+ };
2385
+ if (!granted && !core.crossAdmission(target.id, "in", peer).ok) throw missing();
2386
+ if (!honestSender(msg.from)) throw new RelayError2("invalid_params", "the sender is not one its relay may name");
2387
+ const bytes = new TextEncoder().encode(msg.body).length;
2388
+ if (bytes > XLIMITS.bodyBytes) throw new RelayError2("too_large", `a message from another account is at most ${XLIMITS.bodyBytes} bytes`);
2389
+ if (msg.hop > XLIMITS.hopMax) throw new RelayError2("loop_guard", `hop ${msg.hop} is past the limit across accounts (${XLIMITS.hopMax})`);
2390
+ if (msg.expires_at <= this.now()) throw new RelayError2("invalid_params", "the message expired");
2391
+ const earlier = core.xduplicate(fromAccountId, msg.client_id);
2392
+ if (earlier) return { id: earlier.id, state: earlier.acked ? "delivered" : "queued", duplicate: true };
2393
+ const envelope = {
2394
+ id: msg.id,
2395
+ thread: msg.thread,
2396
+ reply_to: msg.reply_to,
2397
+ kind: msg.kind,
2398
+ from: {
2399
+ address: msg.from.address,
2400
+ endpoint: msg.from.address,
2401
+ user: contact.peerHandle,
2402
+ auth: "contact",
2403
+ ...msg.from.machine ? { machine: msg.from.machine } : {},
2404
+ claims: { harness: msg.from.harness ?? null, model: null },
2405
+ rel: `contact:${contact.peerHandle}`,
2406
+ account: contact.peerHandle,
2407
+ ...msg.from.human ? { human: true } : {}
2408
+ },
2409
+ to: address,
2410
+ body: { text: msg.body, mime: "text/markdown" },
2411
+ data_class: "public",
2412
+ attachments: [],
2413
+ hop: msg.hop,
2414
+ created_at: new Date(msg.created_at).toISOString(),
2415
+ expires_at: new Date(msg.expires_at).toISOString()
2416
+ };
2417
+ const message = JSON.stringify(envelope);
2418
+ const stored2 = new TextEncoder().encode(message).length;
2419
+ this.xadmit(fromAccountId, contact, msg, target, stored2);
2420
+ const secret = findSecret2(msg.body);
2421
+ if (secret) throw new RelayError2("secret_refused", `the message looks like it carries a credential (${secret})`);
2422
+ const verdict = judged ? { verdict: "pass" } : await link.gate.ingress(msg, { side: "ingress", account: link.self.handle, peer: contact.peerHandle, peer_id: fromAccountId });
2423
+ if (verdict.verdict === "refuse") throw gateRefusal(verdict, "ingress");
2424
+ if (verdict.verdict === "hold") return { id: msg.id, state: "queued", held: { hold_id: verdict.hold_id } };
2425
+ if (verdict.verdict === "duplicate") return { id: msg.id, state: "delivered", duplicate: true };
2426
+ if (verdict.verdict === "redact") return { ...await this.xreceive(fromAccountId, { ...msg, body: verdict.body }, true), redacted: verdict.redacted };
2427
+ const raced = () => {
2428
+ const row = core.xduplicate(fromAccountId, msg.client_id);
2429
+ return row ? { id: row.id, state: row.acked ? "delivered" : "queued", duplicate: true } : null;
2430
+ };
2431
+ const again = raced();
2432
+ if (again) return again;
2433
+ this.xadmit(fromAccountId, contact, msg, target, stored2);
2434
+ const seq = core.xinsert({
2435
+ id: msg.id,
2436
+ from_account: fromAccountId,
2437
+ client_id: msg.client_id,
2438
+ endpoint: target.remote,
2439
+ sender: msg.from.address,
2440
+ message,
2441
+ thread: msg.thread,
2442
+ grant: msg.grant ?? null,
2443
+ expires_at: msg.expires_at,
2444
+ at: this.now()
2445
+ });
2446
+ if (seq === null) return raced();
2447
+ this.stash(stored2);
2448
+ let pushed = false;
2449
+ for (const socket of this.deps.sockets()) {
2450
+ if (socket.conn.role !== "bridge" || !socket.conn.subscribed || socket.conn.machine !== target.machine || this.gone.has(socket.conn.id)) continue;
2451
+ this.write(socket, { jsonrpc: "2.0", method: "deliver", params: { seq, endpoint: target.remote, message: envelope } });
2452
+ pushed = true;
2453
+ }
2454
+ this.wake(target.id);
2455
+ this.fleet({
2456
+ type: "message",
2457
+ machine: msg.from.machine ?? null,
2458
+ id: msg.id,
2459
+ thread: msg.thread,
2460
+ kind: msg.kind,
2461
+ from: msg.from.address,
2462
+ to: [address],
2463
+ hop: msg.hop
2464
+ });
2465
+ return { id: msg.id, state: pushed ? "delivered" : "queued" };
2466
+ }
2467
+ /** this account's endpoints the account `fromAccountId` may reach under its contact's allowlist: address, harness and presence only */
2468
+ async xlist(fromAccountId) {
2469
+ const link = this.deps.crossAccount;
2470
+ if (!link) return [];
2471
+ const contact = await link.contactById(fromAccountId);
2472
+ if (!contact || contact.dir === "out" || contact.peerId !== fromAccountId) return [];
2473
+ return this.relay.xendpoints().filter((e) => allowlisted(contact.allow, e.machine, e.handle, e.depth)).map((e) => ({ address: `${link.self.handle}/${e.machine}/${e.handle || "me"}`, harness: e.harness, presence: e.presence }));
2474
+ }
2475
+ /**
2476
+ * throws when one more message from `fromAccountId` would pass a cap of this side: the pair a minute and an hour and the contact a
2477
+ * day (the lower of this account's limits for the contact and the sender's declared ones), the thread, the account's stored bytes or
2478
+ * the target's unread messages; counted from xinbox itself, so a wake keeps them
2479
+ */
2480
+ xadmit(fromAccountId, contact, msg, target, bytes) {
2481
+ const core = this.relay;
2482
+ const now = this.now();
2483
+ const { perMinute, perHour, perDay } = xlimits(contact.limits, msg.limits);
2484
+ const counts = core.xcounts(fromAccountId, msg.from.address, target.remote, dayStart(now));
2485
+ const retry = (since, ms) => Math.max(1, Math.ceil(((since ?? now) + ms - now) / 1e3));
2486
+ if (counts.minute >= perMinute)
2487
+ throw new RelayError2("rate_limited", `${perMinute} messages from ${msg.from.address} to ${msg.to} a minute`, retry(counts.minuteAt, 6e4));
2488
+ if (counts.hour >= perHour)
2489
+ throw new RelayError2("rate_limited", `${perHour} messages from ${msg.from.address} to ${msg.to} an hour`, retry(counts.hourAt, 36e5));
2490
+ if (counts.day >= perDay) throw new RelayError2("rate_limited", `${perDay} messages from ${contact.peerHandle} today`, untilTomorrow(now));
2491
+ if (core.xthreadCount(target.remote, msg.thread) >= XLIMITS.threadMax)
2492
+ throw new RelayError2("loop_guard", `thread ${msg.thread} reached ${XLIMITS.threadMax} messages across accounts`);
2493
+ const cap = this.limits.storedBytes;
2494
+ if (this.storedNow() + bytes > cap) throw new RelayError2("inbox_full", `the receiving account's relay holds its cap of ${cap} bytes of messages`);
2495
+ const unread = core.xunread(target);
2496
+ if (unread >= LIMITS2.inboxMax) throw new RelayError2("inbox_full", `${msg.to} has ${unread} unread messages`);
2497
+ }
2498
+ /**
2499
+ * deletes the queued messages of every account that is no longer an active contact allowed to message this one, after the
2500
+ * contacts changed; nothing when the contacts cannot be loaded
2501
+ */
2502
+ async xprune() {
2503
+ if (!this.deps.crossAccount || !this.core) return;
2504
+ const senders = await this.xinbound();
2505
+ if (!senders || !this.core) return;
2506
+ this.relay.xsenders(senders);
2507
+ if (this.relay.xprune(senders)) this.stored = null;
2508
+ }
2509
+ /** the accounts whose messages this account takes now: active contacts with dir in or both; null when they cannot be loaded */
2510
+ async xinbound() {
2511
+ const contacts = await this.deps.crossAccount?.contacts();
2512
+ return contacts ? new Set(contacts.filter((contact) => contact.dir !== "out").map((contact) => contact.peerId)) : null;
2513
+ }
2514
+ /** what every contact this account may message lets it reach */
2515
+ async contactList() {
2516
+ const link = this.deps.crossAccount;
2517
+ if (!link) return [];
2518
+ const contacts = (await link.contacts() ?? []).filter((contact) => contact.dir !== "in");
2519
+ const lists = await Promise.all(
2520
+ contacts.map(
2521
+ (contact) => link.list(contact).then(
2522
+ (list) => ({ contact, list }),
2523
+ () => ({ contact, list: [] })
2524
+ )
2525
+ )
2526
+ );
2527
+ return lists.flatMap(
2528
+ ({ contact, list }) => list.filter((e) => typeof e?.address === "string" && e.address.startsWith(`${contact.peerHandle}/`)).map((e) => ({
2529
+ address: e.address,
2530
+ harness: typeof e.harness === "string" ? e.harness : null,
2531
+ presence: typeof e.presence === "string" ? e.presence : "offline"
2532
+ }))
2533
+ );
2534
+ }
2535
+ // push and wait
2536
+ delivered(from, result, kind) {
2537
+ const core = this.relay;
2538
+ for (const delivery of result.deliveries) this.route(delivery.seq, delivery.endpoint);
2539
+ this.fleet({
2540
+ type: "message",
2541
+ machine: core.machineOf(from),
2542
+ id: result.id,
2543
+ thread: result.thread,
2544
+ kind,
2545
+ from: core.addressOf(from),
2546
+ to: result.deliveries.map((d) => d.address),
2547
+ hop: result.hop
2548
+ });
2549
+ }
2550
+ /** the body's bytes when the account can hold them; refuses a send past the `storedBytes` limit */
2551
+ room(sender, body) {
2552
+ if (typeof body !== "string") return 0;
2553
+ const bytes = new TextEncoder().encode(body).length;
2554
+ const cap = this.limits.storedBytes;
2555
+ if (this.storedNow() + bytes > cap) {
2556
+ this.relay.record("refuse", sender, null, { code: "too_large" });
2557
+ throw new RelayError2(
2558
+ "too_large",
2559
+ `the account relay holds its cap of ${cap} bytes of message bodies; ack or let messages expire, or release a machine with ${CLI_NAME} machines rm`
2560
+ );
2561
+ }
2562
+ return bytes;
2563
+ }
2564
+ /** bytes of message bodies held, and of the messages from other accounts */
2565
+ storedNow() {
2566
+ const xinbox = this.deps.crossAccount ? ` + (SELECT COALESCE(SUM(LENGTH(CAST(message AS BLOB))), 0) FROM xinbox)` : "";
2567
+ this.stored ??= one2(this.deps.sql, `SELECT (SELECT COALESCE(SUM(LENGTH(CAST(body AS BLOB))), 0) FROM messages)${xinbox} AS n`).n;
2568
+ return this.stored;
2569
+ }
2570
+ stash(bytes) {
2571
+ if (this.stored !== null) this.stored += bytes;
2572
+ }
2573
+ /** wakes direct waiters and pushes a delivery for a mirrored row to the subscribed bridge of its machine */
2574
+ route(seq, endpoint) {
2575
+ this.wake(endpoint);
2576
+ const delivery = this.relay.delivery(seq);
2577
+ if (!delivery?.endpoint || !delivery.mirrored) return;
2578
+ const machine = delivery.endpoint.slice(0, delivery.endpoint.indexOf("/"));
2579
+ for (const socket of this.deps.sockets()) {
2580
+ if (socket.conn.role === "bridge" && socket.conn.subscribed && socket.conn.machine === machine) {
2581
+ this.write(socket, { jsonrpc: "2.0", method: "deliver", params: { seq, endpoint: delivery.endpoint, message: delivery.message } });
2582
+ }
2583
+ }
2584
+ }
2585
+ /** a machine's endpoint changes: `endpoints` to the other bridges, presence and status to watchers */
2586
+ announce(machine, changes, full) {
2587
+ if (full || changes.length) {
2588
+ const endpoints = full ? this.relay.remoteRowsOf(machine) : changes.map((c) => c.view);
2589
+ this.toBridges(machine, { machine, endpoints, full });
2590
+ }
2591
+ for (const change of changes) {
2592
+ const view = change.view;
2593
+ if (change.added || change.presence)
2594
+ this.fleet({ type: "presence", machine, endpoint: view.address, presence: view.presence, harness: view.harness, labels: view.labels });
2595
+ if (change.status && !change.added) this.fleet({ type: "status", machine, endpoint: view.address, status: view.status });
2596
+ }
2597
+ }
2598
+ /** a direct client's endpoint changed: the other bridges mirror it */
2599
+ announceEndpoint(endpoint) {
2600
+ const machine = this.relay.machineOf(endpoint);
2601
+ if (!machine) return;
2602
+ const view = this.relay.remoteRowsOf(machine).find((v) => v.id === endpoint);
2603
+ if (view) this.toBridges(machine, { machine, endpoints: [view], full: false });
2604
+ }
2605
+ toBridges(machine, params) {
2606
+ for (const socket of this.deps.sockets()) {
2607
+ if (socket.conn.role === "bridge" && socket.conn.machine !== machine) this.write(socket, { jsonrpc: "2.0", method: "endpoints", params });
2608
+ }
2609
+ }
2610
+ fleet(event) {
2611
+ const at = new Date(this.now()).toISOString();
2612
+ for (const socket of this.deps.sockets()) if (socket.conn.watching) this.write(socket, { jsonrpc: "2.0", method: "fleet", params: { at, ...event } });
2613
+ }
2614
+ write(socket, value) {
2615
+ if (this.gone.has(socket.conn.id)) return;
2616
+ try {
2617
+ socket.send(JSON.stringify(value));
2618
+ } catch {
2619
+ this.deps.log?.("relay_send_failed", { account: this.account, machine: socket.conn.machine });
2620
+ }
2621
+ }
2622
+ save(socket) {
2623
+ if (this.transient.has(socket)) return;
2624
+ this.deps.save(socket);
2625
+ }
2626
+ waitMs(socket, value) {
2627
+ if (value === void 0) return 0;
2628
+ if (typeof value !== "number" || !Number.isFinite(value) || value < 0) throw new RelayError2("invalid_params", "wait_s must be a number of seconds");
2629
+ return Math.min(value, this.transient.has(socket) ? this.limits.httpWaitS : LIMITS2.waitMaxS) * 1e3;
2630
+ }
2631
+ wake(endpoint) {
2632
+ const set = this.waiters.get(endpoint);
2633
+ if (!set) return;
2634
+ this.waiters.delete(endpoint);
2635
+ for (const fn of set) fn();
2636
+ }
2637
+ waitFor(endpoint, ms, socket) {
2638
+ return new Promise((resolve) => {
2639
+ const done = () => {
2640
+ clearTimeout(timer);
2641
+ this.waiters.get(endpoint)?.delete(done);
2642
+ resolve();
2643
+ };
2644
+ const timer = setTimeout(done, ms);
2645
+ let set = this.waiters.get(endpoint);
2646
+ if (!set) {
2647
+ set = /* @__PURE__ */ new Set();
2648
+ this.waiters.set(endpoint, set);
2649
+ }
2650
+ set.add(done);
2651
+ if (this.gone.has(socket.conn.id)) done();
2652
+ });
2653
+ }
2654
+ async awaitReply(socket, me, request, ms, sent) {
2655
+ const core = this.relay;
2656
+ const deadline = Date.now() + ms;
2657
+ for (; ; ) {
2658
+ const reply = core.replyTo(me, request);
2659
+ if (reply) {
2660
+ core.ack(me, [reply.seq]);
2661
+ return { ...sent, id: request, reply };
2662
+ }
2663
+ if (core.requestExpired(request)) return { ...sent, id: request, expired: true };
2664
+ if (this.gone.has(socket.conn.id) || Date.now() >= deadline) return { ...sent, id: request, pending: true };
2665
+ await this.waitFor(me, deadline - Date.now(), socket);
2666
+ }
2667
+ }
2668
+ // connections and machines
2669
+ kick(socket, reason, code = 1008) {
2670
+ this.write(socket, { jsonrpc: "2.0", method: "revoked", params: { reason } });
2671
+ socket.close(code, reason);
2672
+ this.release(socket, false);
2673
+ }
2674
+ release(socket, replaced) {
2675
+ const conn = socket.conn;
2676
+ if (this.gone.has(conn.id)) return;
2677
+ this.gone.add(conn.id);
2678
+ if (conn.role === "new" || !this.core) return;
2679
+ const core = this.core;
2680
+ const bound = this.boundMachine(conn);
2681
+ if (conn.role === "client" && conn.endpoint) {
2682
+ const endpoint = conn.endpoint;
2683
+ core.disconnect(endpoint);
2684
+ const machine = core.machineOf(endpoint);
2685
+ if (machine && one2(this.deps.sql, `SELECT kind FROM machines WHERE name = ?`, machine)?.kind === "bridge") {
2686
+ for (const delivery of core.adoptClaimed(machine, endpoint)) this.route(delivery.seq, delivery.endpoint);
2687
+ }
2688
+ this.wake(endpoint);
2689
+ const view = core.whoami(endpoint);
2690
+ this.fleet({ type: "presence", machine: conn.machine, endpoint: view.address, presence: view.presence });
2691
+ this.announceEndpoint(endpoint);
2692
+ } else if (conn.role === "bridge" && conn.machine) {
2693
+ const newer = this.deps.sockets().some((s) => s.conn.id !== conn.id && s.conn.role === "bridge" && s.conn.machine === conn.machine && !this.gone.has(s.conn.id));
2694
+ if (!replaced && !newer) this.announce(conn.machine, core.markOffline(conn.machine), false);
2695
+ }
2696
+ if (bound) this.deps.sql.all(`UPDATE machines SET last_seen = ? WHERE name = ?`, this.now(), bound);
2697
+ conn.role = "new";
2698
+ conn.endpoint = null;
2699
+ conn.subscribed = false;
2700
+ conn.watching = false;
2701
+ if (!this.transient.has(socket)) {
2702
+ try {
2703
+ this.deps.save(socket);
2704
+ } catch {
2705
+ }
2706
+ }
2707
+ }
2708
+ /** the machine a bridge or agent client connection holds; a human's or observer's machine is only a namespace */
2709
+ boundMachine(conn) {
2710
+ if (!conn.machine || !this.core) return null;
2711
+ if (conn.role === "bridge") return conn.machine;
2712
+ return conn.role === "client" && conn.endpoint !== null && this.core.machineOf(conn.endpoint) === conn.machine ? conn.machine : null;
2713
+ }
2714
+ /** machines a live connection holds */
2715
+ liveMachines() {
2716
+ const live = /* @__PURE__ */ new Set();
2717
+ for (const socket of [...this.deps.sockets(), ...this.transient]) {
2718
+ const machine = this.gone.has(socket.conn.id) ? null : this.boundMachine(socket.conn);
2719
+ if (machine) live.add(machine);
2720
+ }
2721
+ return live;
2722
+ }
2723
+ /** bindings, then names a revoke left behind (kind `revoked`) */
2724
+ /** a read-only view of the account for a dashboard: its machines, listed endpoints and latest activity */
2725
+ overview(activity = 20) {
2726
+ const { endpoints, activity: rows } = this.relay.snapshot({ activity });
2727
+ return { machines: this.machineList(), endpoints, activity: rows };
2728
+ }
2729
+ machineList() {
2730
+ const sockets = this.deps.sockets().filter((s) => !this.gone.has(s.conn.id));
2731
+ const bound = this.deps.sql.all(`SELECT name, kind, created_at, last_seen FROM machines ORDER BY name`).map((row) => ({
2732
+ name: row.name,
2733
+ kind: row.kind,
2734
+ online: sockets.some((s) => this.boundMachine(s.conn) === row.name),
2735
+ bridge: sockets.some((s) => s.conn.role === "bridge" && s.conn.machine === row.name),
2736
+ created_at: iso(row.created_at),
2737
+ last_seen: iso(row.last_seen)
2738
+ }));
2739
+ const revoked = this.deps.sql.all(`SELECT name, at FROM machine_tombstones WHERE name NOT IN (SELECT name FROM machines) ORDER BY name`).map((row) => ({ name: row.name, kind: "revoked", online: false, bridge: false, created_at: null, last_seen: iso(row.at) }));
2740
+ return [...bound, ...revoked];
2741
+ }
2742
+ /** only the token the machine is bound to, a full login of the member who bound it, or an admin may release it (or a revoked token's name) */
2743
+ releaseMachine(caller, name) {
2744
+ const core = this.relay;
2745
+ if (typeof name !== "string" || !MACHINE_NAME.test(name)) throw new RelayError2("invalid_params", "name is a machine name");
2746
+ const bound = one2(this.deps.sql, `SELECT token_id, actor_id FROM machines WHERE name = ?`, name);
2747
+ const tomb = bound ? void 0 : one2(this.deps.sql, `SELECT actor_id FROM machine_tombstones WHERE name = ?`, name);
2748
+ if (!bound && !tomb) throw new RelayError2("not_found", `no machine ${name}`);
2749
+ const grant = caller.conn.grant;
2750
+ if (!(bound?.token_id === grant.tokenId || fullOf(grant, (bound ?? tomb).actor_id) || accountAdmin(grant))) {
2751
+ core.record("refuse", null, name, { code: "unauthorized", machine: name });
2752
+ throw new RelayError2(
2753
+ "unauthorized",
2754
+ `only the token machine ${name} is bound to, a full login of the member who bound it, or an org owner or admin can release it`
2755
+ );
2756
+ }
2757
+ for (const socket of [...this.deps.sockets()]) {
2758
+ if (socket.conn.id !== caller.conn.id && this.boundMachine(socket.conn) === name) this.kick(socket, `machine ${name} was released`);
2759
+ }
2760
+ const dropped = this.tx(() => {
2761
+ const count = core.dropMachine(name, { queue: true });
2762
+ this.deps.sql.all(`DELETE FROM machines WHERE name = ?`, name);
2763
+ this.deps.sql.all(`DELETE FROM machine_tombstones WHERE name = ?`, name);
2764
+ core.record("machine_release", null, name, { machine: name, endpoints: count });
2765
+ return count;
2766
+ });
2767
+ this.stored = null;
2768
+ this.toBridges(name, { machine: name, endpoints: [], full: true });
2769
+ this.deps.log?.("relay_action", { account: this.account, machine: name, method: "machine_release", code: "ok" });
2770
+ return { removed: name, endpoints: dropped };
2771
+ }
2772
+ /** releases client bindings with no live connection, idle for the `clientMachineIdleS` limit, with nothing queued and no unanswered ask open; bridge bindings never expire */
2773
+ expireMachines(now) {
2774
+ const core = this.relay;
2775
+ const live = this.liveMachines();
2776
+ const idle = this.deps.sql.all(
2777
+ `SELECT name FROM machines WHERE kind = 'client' AND COALESCE(last_seen, created_at) <= ?`,
2778
+ now - this.limits.clientMachineIdleS * 1e3
2779
+ );
2780
+ for (const { name } of idle) {
2781
+ if (live.has(name)) continue;
2782
+ const queued = one2(
2783
+ this.deps.sql,
2784
+ `SELECT 1 FROM deliveries d JOIN endpoints e ON e.id = d.endpoint WHERE substr(e.remote, 1, ?) = ? AND d.acked_at IS NULL AND d.expires_at > ? LIMIT 1`,
2785
+ name.length + 1,
2786
+ `${name}/`,
2787
+ now
2788
+ );
2789
+ const asking = one2(
2790
+ this.deps.sql,
2791
+ `SELECT 1 FROM messages m JOIN endpoints e ON e.id = m.from_ep WHERE substr(e.remote, 1, ?) = ? AND m.kind = 'request' AND m.expires_at > ?
2792
+ AND NOT EXISTS (SELECT 1 FROM messages r WHERE r.reply_to = m.id AND r.kind = 'reply') LIMIT 1`,
2793
+ name.length + 1,
2794
+ `${name}/`,
2795
+ now
2796
+ );
2797
+ if (queued || asking) continue;
2798
+ const dropped = this.tx(() => {
2799
+ const count = core.dropMachine(name);
2800
+ this.deps.sql.all(`DELETE FROM machines WHERE name = ?`, name);
2801
+ core.record("machine_expire", null, name, { machine: name, endpoints: count });
2802
+ return count;
2803
+ });
2804
+ this.toBridges(name, { machine: name, endpoints: [], full: true });
2805
+ this.deps.log?.("relay_action", { account: this.account, machine: name, method: "machine_expire", code: "ok", endpoints: dropped });
2806
+ }
2807
+ }
2808
+ /** drops the tombstones past their age with nothing queued for their machine, and retires the rows and xinbox of each name still unbound */
2809
+ forgetTombstones(now) {
2810
+ const forgotten = this.tx(() => {
2811
+ const names = this.deps.sql.all(
2812
+ `DELETE FROM machine_tombstones WHERE at <= ? AND NOT EXISTS (SELECT 1 FROM deliveries d JOIN endpoints e ON e.id = d.endpoint
2813
+ WHERE substr(e.remote, 1, length(machine_tombstones.name) + 1) = machine_tombstones.name || '/' AND d.acked_at IS NULL AND d.expires_at > ?)
2814
+ RETURNING name`,
2815
+ now - (LIMITS2.ttlMaxS + LIMITS2.purgeAfterS) * 1e3,
2816
+ now
2817
+ );
2818
+ const unbound = names.map((row) => row.name).filter((name) => !one2(this.deps.sql, `SELECT 1 FROM machines WHERE name = ?`, name));
2819
+ for (const name of unbound) this.relay.record("machine_forget", null, name, { machine: name, endpoints: this.relay.dropMachine(name, { queue: true }) });
2820
+ return unbound;
2821
+ });
2822
+ for (const name of forgotten) this.toBridges(name, { machine: name, endpoints: [], full: true });
2823
+ }
2824
+ /** the hub_state keys (`denied:<tokenId>`) of tokens revoked lately, to when; loaded once per object lifetime */
2825
+ deniedGrants() {
2826
+ this.denied ??= new Map(
2827
+ this.deps.sql.all(`SELECT key, value FROM hub_state WHERE key LIKE ?`, `${DENIED}%`).map((row) => [row.key, row.value])
2828
+ );
2829
+ return this.denied;
2830
+ }
2831
+ /** a token revoked lately is refused even when the host accepted its grant before the revoke reached the object */
2832
+ refuseDenied(socket) {
2833
+ const at = this.deniedGrants().get(DENIED + socket.conn.grant.tokenId);
2834
+ if (at === void 0 || at <= this.now() - this.limits.deniedS * 1e3) return;
2835
+ this.kick(socket, "the token was revoked");
2836
+ throw new RelayError2("unauthorized", "the token was revoked");
2837
+ }
2838
+ tx(fn) {
2839
+ return this.deps.transaction ? this.deps.transaction(fn) : fn();
2840
+ }
2841
+ state(key) {
2842
+ return one2(this.deps.sql, `SELECT value FROM hub_state WHERE key = ?`, key)?.value ?? null;
2843
+ }
2844
+ setState(key, value) {
2845
+ this.deps.sql.all(`INSERT OR REPLACE INTO hub_state (key, value) VALUES (?, ?)`, key, value);
2846
+ }
2847
+ /**
2848
+ * a held cross-account message its account's human released (the host claimed the hold first): the rest of that side's path, with
2849
+ * every check run again and only the gate skipped. Egress sends as the stored sender with the time left, under the hold id its sender
2850
+ * got, and with the message's own client id, so the peer answers both a retried release and the sender's retry of the original as the
2851
+ * duplicate. Ingress runs xreceive again
2852
+ */
2853
+ async xrelease(hold) {
2854
+ if (hold.side === "ingress") return this.xreceive(hold.from_account, hold.message, true);
2855
+ const msg = hold.message;
2856
+ const left = Math.ceil((msg.expires_at - this.now()) / 1e3);
2857
+ if (left < 1) throw new RelayError2("invalid_params", "the held message expired");
2858
+ const send = {
2859
+ to: `${hold.peer}/${msg.to.endsWith("/") ? `${msg.to}me` : msg.to}`,
2860
+ body: msg.body,
2861
+ kind: msg.kind,
2862
+ thread: msg.thread,
2863
+ ...msg.reply_to ? { reply_to: msg.reply_to } : {},
2864
+ ttl_s: Math.min(left, LIMITS2.ttlMaxS)
2865
+ };
2866
+ return this.xsend(hold.sender, send, { tty: msg.from.human, hop: msg.hop, judged: true, clientId: msg.client_id, id: hold.hold_id });
2867
+ }
2868
+ /** a status message from the relay to one of this account's endpoints (a held message's sender), pushed to its bridge */
2869
+ xnotice(endpoint, text) {
2870
+ this.route(this.relay.judgeNoticeTo(endpoint, text), endpoint);
2871
+ }
2872
+ };
2873
+ export {
2874
+ FederatedCore,
2875
+ HUB_LIMITS,
2876
+ MACHINE_NAME,
2877
+ RelayHub
2878
+ };