@scp-dev/broker 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/server.ts ADDED
@@ -0,0 +1,1079 @@
1
+ import { createHash } from "node:crypto"
2
+ import { chmodSync, existsSync, lstatSync, readFileSync, realpathSync, rmdirSync, unlinkSync } from "node:fs"
3
+ import { createServer, type Server } from "node:net"
4
+ import { homedir } from "node:os"
5
+ import { dirname, isAbsolute, join, sep } from "node:path"
6
+ import { setTimeout as sleep } from "node:timers/promises"
7
+ import {
8
+ type Attachment,
9
+ BROKER_CAPABILITIES,
10
+ type Claim,
11
+ CLI_NAME,
12
+ type EndpointView,
13
+ LIMITS,
14
+ parsePolicy,
15
+ PREAMBLE,
16
+ type Presence,
17
+ PROTOCOL,
18
+ RelayError,
19
+ type ScopeActor,
20
+ type ScopePreset,
21
+ type SendParams,
22
+ type SendResult,
23
+ SERVER_FIELDS,
24
+ type Sql,
25
+ type StopWaitResult,
26
+ stripControl,
27
+ } from "@scp-dev/core"
28
+ import { FederatedCore } from "@scp-dev/hub"
29
+ import { type Bridge, type BridgeConfig, remoteSend, startBridge } from "./bridge"
30
+ import { type Deliver, type JudgeClientFactory, JudgeGate } from "./judge-gate"
31
+ import { releaseBrokerLock, takeBrokerLock } from "./lock"
32
+ import { encodeLine, LineReader, LineWriter, MAX_LINE } from "./ndjson"
33
+ import { accountHome, accountHomeVerified, policyPath as defaultPolicyPath, privateDir, shortHostname, socketPath } from "./paths"
34
+ import { Conn } from "./socket"
35
+ import { type Database, openDatabase } from "./sqlite"
36
+
37
+ export interface BrokerOptions {
38
+ dir: string
39
+ /** the policy file; missing means open */
40
+ policyPath?: string | undefined
41
+ /** the user part of addresses */
42
+ user?: string | undefined
43
+ /** exit after this long with no connection and nothing waiting (default 30 min); 0 never */
44
+ idleMs?: number | undefined
45
+ now?: (() => number) | undefined
46
+ /** extra roots attachments may point into, besides the sender's cwd */
47
+ roots?: string[] | undefined
48
+ /** bytes queued for one connection before fleet events to it are dropped and replies end it (default 4 MiB) */
49
+ outboundCapBytes?: number | undefined
50
+ /** requests one connection may have in flight (default 32) */
51
+ maxPending?: number | undefined
52
+ /** how long a request line may stay unfinished (default 30 s) */
53
+ partialLineMs?: number | undefined
54
+ /** where the broker reports its own failures (default stderr) */
55
+ log?: ((line: string) => void) | undefined
56
+ /** this host's machine name on the hub (default the bridge's), and for `machine:` selectors (default the short hostname) */
57
+ machine?: string | undefined
58
+ /** the link to the hub; without it the broker never opens a network connection */
59
+ bridge?: BridgeConfig | undefined
60
+ /** how often presence that decays without a request (a hook-only endpoint) is checked for changes (default 60 s) */
61
+ presenceEveryMs?: number | undefined
62
+ /** where a judge model's key_env is read (default the broker's environment) */
63
+ env?: Record<string, string | undefined> | undefined
64
+ /** the judge model client for a policy's `judge.model` (default the provider's HTTP API) */
65
+ judgeClient?: JudgeClientFactory | undefined
66
+ }
67
+
68
+ export interface Broker {
69
+ socket: string
70
+ core: FederatedCore
71
+ bridge: Bridge | null
72
+ judge: JudgeGate
73
+ policyFile: string
74
+ stopped: Promise<void>
75
+ stop(): Promise<void>
76
+ }
77
+
78
+ interface Connection {
79
+ reader: LineReader
80
+ writer: LineWriter
81
+ endpoint?: string | undefined
82
+ /** a connection that only looks: list, status, watch, log and policy */
83
+ observer: boolean
84
+ /** set when the connection claimed the human inbox: whether it said a terminal was attached */
85
+ tty?: boolean
86
+ /** the connection holds the human inbox */
87
+ human?: boolean
88
+ /**
89
+ * the lineage endpoint of an observer, or of a hello bound to it: the connection sees and acts with that endpoint's view,
90
+ * never widening unless `own`, and never counts as connected
91
+ */
92
+ lineage?: string | undefined
93
+ /** the lineage endpoint is the one of the harness whose shell runs the command, bound with no grant: it acts with the endpoint's session reach */
94
+ own?: boolean
95
+ /** a hook's connection: it posts activity and status, and never counts as connected */
96
+ transient?: boolean
97
+ /** a hook connection's harness process key, from its claim */
98
+ process?: string
99
+ /** a one-shot CLI command's connection: its comings and goings are not fleet presence events */
100
+ brief?: boolean
101
+ cwd?: string | undefined
102
+ watching: boolean
103
+ closed: boolean
104
+ inFlight: number
105
+ /** lines read but not started while `inFlight` is at the cap; the socket is paused meanwhile */
106
+ queue: string[]
107
+ queueHead: number
108
+ lineTimer?: ReturnType<typeof setTimeout> | undefined
109
+ }
110
+
111
+ const SWEEP_MS = 10 * 60_000
112
+ const PRESENCE_MS = 60_000
113
+ const OBSERVER_METHODS = new Set(["list", "snapshot", "watch", "log", "policy", "policy_check", "cloud_status"])
114
+ const SCOPE_METHODS = new Set([
115
+ "rules",
116
+ "rules_set",
117
+ "rules_widen",
118
+ "rules_lift",
119
+ "grant",
120
+ "group",
121
+ "admin",
122
+ "peers",
123
+ "why",
124
+ "spawn_exit",
125
+ "spawn_steers",
126
+ "peek",
127
+ "peek_set",
128
+ "peek_who",
129
+ ])
130
+ const CHANGES_SCOPE = new Set(["rules_set", "rules_widen", "rules_lift", "group"])
131
+ /** how often a waiting ask rechecks whether its recipients ended, since only deliveries to the asker wake it */
132
+ const ASK_RECHECK_MS = 1000
133
+ /** how often a waiting stop rechecks its workers, since a worker's exit or lapse wakes no one */
134
+ const STOP_RECHECK_MS = 1000
135
+ const TRANSIENT_METHODS = new Set(["event", "status", "whoami", "inject", "injected", "peek_turn", "stop_wait", "prompted"])
136
+ const GRANT_UNBOUND = `a spawn-env grant binds only a new harness process: run the harness with it, or name the child (${CLI_NAME} spawn-env --name / --as)`
137
+
138
+ type Request = { jsonrpc?: string; id?: string | number | null; method?: unknown; params?: unknown }
139
+
140
+ /** whether a process runs (one of another user's counts too) */
141
+ function running(pid: number): boolean {
142
+ try {
143
+ process.kill(pid, 0)
144
+ return true
145
+ } catch (error) {
146
+ return (error as { code?: string }).code === "EPERM"
147
+ }
148
+ }
149
+
150
+ /** who a connection acts for, for the calls only the harness process holding an endpoint may make through its hooks */
151
+ function hookOf(s: Conn<Connection>): { transient: boolean; process?: string } {
152
+ return { transient: s.data.transient === true, ...(s.data.process ? { process: s.data.process } : {}) }
153
+ }
154
+
155
+ export async function startBroker(options: BrokerOptions): Promise<Broker> {
156
+ const dir = privateDir(options.dir)
157
+ const lock = takeBrokerLock(join(dir, "broker.lock"))
158
+ const socket = socketPath(dir)
159
+ const connections = new Set<Conn<Connection>>()
160
+ const waiters = new Map<string, Set<() => void>>()
161
+ const policyFile = options.policyPath ?? defaultPolicyPath()
162
+ if (!options.policyPath && !accountHomeVerified())
163
+ (options.log ?? console.error)(`relay broker: the user database has no home for this account; the policy path ${policyFile} comes from HOME`)
164
+ let policyText: string | null | undefined
165
+ let policyError: string | null = null
166
+ const outboundCap = options.outboundCapBytes ?? 4 * 1024 * 1024
167
+ const maxPending = options.maxPending ?? 32
168
+ const partialLineMs = options.partialLineMs ?? 30_000
169
+ const log = options.log ?? ((line: string) => console.error(line))
170
+ const idleMs = options.idleMs ?? 30 * 60_000
171
+ let idleTimer: ReturnType<typeof setTimeout> | undefined
172
+ let pending = 0
173
+ let closing = false
174
+ let stopping: Promise<void> | undefined
175
+ let resolveStopped!: () => void
176
+ const stopped = new Promise<void>((resolve) => (resolveStopped = resolve))
177
+ const previousUmask = process.umask(0o077)
178
+ let db!: Database
179
+ let core!: FederatedCore
180
+ let judge!: JudgeGate
181
+ let bridge: Bridge | null = null
182
+ let listener!: Server
183
+ try {
184
+ privateDir(dirname(socket))
185
+ db = openDatabase(join(dir, "relay.db"))
186
+ db.exec("PRAGMA journal_mode = WAL")
187
+ db.exec("PRAGMA busy_timeout = 5000")
188
+ chmodSync(join(dir, "relay.db"), 0o600)
189
+ const sql: Sql = db
190
+ const machine = options.machine ?? options.bridge?.machine
191
+ core = new FederatedCore(sql, {
192
+ ...compact({ user: options.user, machine, now: options.now }),
193
+ hostname: shortHostname(),
194
+ hash: (value) => createHash("sha256").update(value).digest("hex"),
195
+ transaction: (fn) => db.transaction(fn),
196
+ alive: running,
197
+ })
198
+ core.migrate()
199
+ core.resetConnections()
200
+ core.sweep()
201
+ judge = new JudgeGate({ sql, core, now: options.now ?? Date.now, env: options.env ?? process.env, client: options.judgeClient, log, wake })
202
+ judge.sweep()
203
+ if (existsSync(socket)) unlinkSync(socket)
204
+ listener = createServer((raw) => {
205
+ const s = new Conn<Connection>(
206
+ raw,
207
+ {
208
+ reader: new LineReader(MAX_LINE),
209
+ writer: new LineWriter(),
210
+ observer: false,
211
+ watching: false,
212
+ closed: false,
213
+ inFlight: 0,
214
+ queue: [],
215
+ queueHead: 0,
216
+ },
217
+ { data: onData, drain: flush, close: (s) => onClose(s) },
218
+ )
219
+ if (closing) {
220
+ s.end()
221
+ return
222
+ }
223
+ connections.add(s)
224
+ activity()
225
+ })
226
+ await new Promise<void>((resolve, reject) => {
227
+ listener.once("error", reject)
228
+ listener.listen(socket, () => {
229
+ listener.off("error", reject)
230
+ resolve()
231
+ })
232
+ })
233
+ listener.on("error", (error) => log(`relay broker: the socket listener failed: ${error.message}`))
234
+ chmodSync(socket, 0o600)
235
+ } catch (error) {
236
+ listener?.close()
237
+ db?.close()
238
+ releaseBrokerLock(lock)
239
+ throw error
240
+ } finally {
241
+ process.umask(previousUmask)
242
+ }
243
+ const sweeper = setInterval(() => {
244
+ core.sweep()
245
+ judge.sweep()
246
+ }, SWEEP_MS)
247
+ const known = new Map<string, Presence>()
248
+ for (const view of localViews()) known.set(view.id, view.presence)
249
+ const presenceTimer = setInterval(() => {
250
+ const live = new Set<string>()
251
+ for (const view of localViews()) {
252
+ live.add(view.id)
253
+ if ((known.get(view.id) ?? "offline") !== view.presence) presenceChanged(view)
254
+ }
255
+ for (const id of known.keys()) if (!live.has(id)) known.delete(id)
256
+ }, options.presenceEveryMs ?? PRESENCE_MS)
257
+ if (options.bridge) {
258
+ bridge = startBridge({
259
+ ...options.bridge,
260
+ core,
261
+ onRemote: {
262
+ wake,
263
+ fleet: notifyFleet,
264
+ policyError: () => {
265
+ loadPolicy()
266
+ return policyError
267
+ },
268
+ log,
269
+ },
270
+ })
271
+ }
272
+
273
+ function activity() {
274
+ if (idleTimer) clearTimeout(idleTimer)
275
+ idleTimer = undefined
276
+ if (!closing && idleMs > 0 && connections.size === 0 && pending === 0) idleTimer = setTimeout(() => void stop(), idleMs)
277
+ }
278
+
279
+ function loadPolicy() {
280
+ let text: string | null
281
+ try {
282
+ text = readFileSync(policyFile, "utf8")
283
+ } catch (error) {
284
+ const code = (error as NodeJS.ErrnoException).code
285
+ const dangling = code === "ENOENT" && lstatSync(policyFile, { throwIfNoEntry: false }) !== undefined
286
+ if (code !== "ENOENT" || dangling) {
287
+ policyText = undefined
288
+ policyError = dangling
289
+ ? `the relay policy at ${policyFile} is a link to a missing file; messages are refused until it is fixed`
290
+ : `the relay policy at ${policyFile} cannot be read (${code ?? String(error)}); messages are refused until it is fixed`
291
+ return
292
+ }
293
+ text = null
294
+ }
295
+ if (text === policyText) return
296
+ policyText = text
297
+ if (text === null) {
298
+ core.setPolicy(null)
299
+ judge.configure(undefined)
300
+ policyError = null
301
+ bridge?.resync()
302
+ return
303
+ }
304
+ try {
305
+ const policy = parsePolicy(JSON.parse(text))
306
+ core.setPolicy(policy)
307
+ judge.configure(policy.judge)
308
+ policyError = null
309
+ bridge?.resync()
310
+ } catch (error) {
311
+ policyError = `the relay policy at ${policyFile} is invalid (${error instanceof Error ? error.message : String(error)}); messages are refused until it is fixed`
312
+ }
313
+ }
314
+
315
+ function policyInfo(): { file: string; state: "open" | "loaded" | "error"; error?: string } {
316
+ loadPolicy()
317
+ if (policyError) return { file: policyFile, state: "error", error: policyError }
318
+ return { file: policyFile, state: policyText === null ? "open" : "loaded" }
319
+ }
320
+
321
+ function brokerInfo() {
322
+ return { socket, dir, pid: process.pid, policy: policyInfo() }
323
+ }
324
+
325
+ /** a response; a connection whose unread bytes, this response included, would pass the cap is ended (one response alone always goes) */
326
+ function write(s: Conn<Connection>, value: unknown) {
327
+ if (s.data.closed) return
328
+ const line = encodeLine(value)
329
+ const queued = unread(s)
330
+ if (queued > 0 && queued + line.length > outboundCap) {
331
+ log(`relay broker: ended a connection with ${queued} bytes it did not read and a ${line.length} byte response`)
332
+ onClose(s)
333
+ s.end()
334
+ return
335
+ }
336
+ s.data.writer.pushBytes(line)
337
+ flush(s)
338
+ }
339
+
340
+ /** bytes queued for a connection that its peer has not taken yet */
341
+ function unread(s: Conn<Connection>): number {
342
+ return s.data.writer.pending + s.unsent
343
+ }
344
+
345
+ function flush(s: Conn<Connection>) {
346
+ if (!s.data.closed) s.data.writer.flush((bytes) => s.write(bytes))
347
+ }
348
+
349
+ /** a fleet event to every watcher; a watcher that falls more than the cap behind gets one lagged notice and is unsubscribed */
350
+ /** this host's own endpoints, mirrored rows of other machines left out */
351
+ function localViews(): EndpointView[] {
352
+ return core.list({ all: true }).filter((view) => core.machineOf(view.id) === null)
353
+ }
354
+
355
+ /** an endpoint's presence changed outside a connection's own hello or close: watchers and the other machines hear it */
356
+ function presenceChanged(view: EndpointView) {
357
+ known.set(view.id, view.presence)
358
+ notifyFleet({ type: "presence", endpoint: view.address, presence: view.presence, harness: view.harness, labels: view.labels })
359
+ bridge?.changed(view.id)
360
+ }
361
+
362
+ function notifyFleet(event: Record<string, unknown>) {
363
+ const at = new Date((options.now ?? Date.now)()).toISOString()
364
+ let line: Uint8Array | undefined
365
+ const views = new Map<string, Set<string>>()
366
+ for (const s of connections) {
367
+ if (!s.data.watching || s.data.closed) continue
368
+ const viewer = viewerOf(s)
369
+ let bytes: Uint8Array
370
+ if (viewer === undefined) bytes = line ??= encodeLine({ jsonrpc: "2.0", method: "fleet", params: { at, ...event } })
371
+ else {
372
+ let seen = views.get(viewer)
373
+ if (!seen) {
374
+ seen = core.visibleTo(viewer)
375
+ views.set(viewer, seen)
376
+ }
377
+ const shown = filterEvent(event, seen)
378
+ if (!shown) continue
379
+ bytes = encodeLine({ jsonrpc: "2.0", method: "fleet", params: { at, ...shown } })
380
+ }
381
+ const queued = unread(s)
382
+ if (queued + bytes.length > outboundCap) {
383
+ s.data.watching = false
384
+ s.data.writer.push({ jsonrpc: "2.0", method: "fleet", params: { at, type: "lagged", queued_bytes: queued } })
385
+ } else {
386
+ s.data.writer.pushBytes(bytes)
387
+ }
388
+ flush(s)
389
+ }
390
+ }
391
+
392
+ /** a fleet event cut to what a scoped viewer may see, or null when it may see none of it */
393
+ function filterEvent(event: Record<string, unknown>, seen: Set<string>): Record<string, unknown> | null {
394
+ if (event.type === "message") {
395
+ const to = (event.to as string[]).filter((address) => seen.has(address))
396
+ return seen.has(String(event.from)) && to.length ? { ...event, to } : null
397
+ }
398
+ return typeof event.endpoint !== "string" || seen.has(event.endpoint) ? event : null
399
+ }
400
+
401
+ /** whether the connection is the human at a terminal, the only one exempt from scopes and shown everything */
402
+ function atTerminal(s: Conn<Connection>): boolean {
403
+ return s.data.human === true && s.data.tty === true
404
+ }
405
+
406
+ /**
407
+ * the endpoint whose view a connection gets; undefined for the human at a terminal, which sees everything. The human without a terminal, and an
408
+ * observer with no lineage endpoint, see what the human may message with no exemption
409
+ */
410
+ function viewerOf(s: Conn<Connection>): string | undefined {
411
+ if (s.data.lineage) return s.data.lineage
412
+ if (s.data.observer || s.data.human) return atTerminal(s) ? undefined : core.humanId()
413
+ return s.data.endpoint
414
+ }
415
+
416
+ function actorOf(s: Conn<Connection>): ScopeActor {
417
+ if (s.data.lineage) return { kind: "endpoint", id: s.data.lineage, auth: s.data.own ? "self" : "lineage" }
418
+ if (s.data.observer || s.data.human) return { kind: "human", tty: atTerminal(s) }
419
+ return { kind: "endpoint", id: s.data.endpoint!, auth: "session" }
420
+ }
421
+
422
+ /** binds a hello to the agent endpoint its lineage lands on, acting with lineage auth, or with `own` as that endpoint (self auth) */
423
+ function bindLineage(s: Conn<Connection>, endpoint: string, params: Record<string, unknown>, own = false) {
424
+ s.data.endpoint = endpoint
425
+ s.data.lineage = endpoint
426
+ s.data.own = own
427
+ s.data.brief = true
428
+ s.data.cwd = typeof params.cwd === "string" ? params.cwd : undefined
429
+ return {
430
+ endpoint: core.whoami(endpoint),
431
+ capabilities: BROKER_CAPABILITIES,
432
+ server_time: new Date().toISOString(),
433
+ limits: LIMITS,
434
+ preamble: PREAMBLE,
435
+ broker: brokerInfo(),
436
+ }
437
+ }
438
+
439
+ function wake(endpoint: string) {
440
+ const set = waiters.get(endpoint)
441
+ if (!set) return
442
+ waiters.delete(endpoint)
443
+ for (const fn of set) fn()
444
+ }
445
+
446
+ function waitFor(endpoint: string, ms: number, s: Conn<Connection>): Promise<void> {
447
+ return new Promise((resolve) => {
448
+ const done = () => {
449
+ clearTimeout(timer)
450
+ waiters.get(endpoint)?.delete(done)
451
+ resolve()
452
+ }
453
+ const timer = setTimeout(done, ms)
454
+ let set = waiters.get(endpoint)
455
+ if (!set) {
456
+ set = new Set()
457
+ waiters.set(endpoint, set)
458
+ }
459
+ set.add(done)
460
+ if (s.data.closed) done()
461
+ })
462
+ }
463
+
464
+ function onData(s: Conn<Connection>, chunk: Uint8Array) {
465
+ const { lines, tooLong } = s.data.reader.push(chunk)
466
+ for (const line of lines) s.data.queue.push(line)
467
+ pump(s)
468
+ if (tooLong) {
469
+ write(s, { jsonrpc: "2.0", id: null, error: { code: -32600, message: "request too large", data: { code: "too_large" } } })
470
+ s.end()
471
+ return
472
+ }
473
+ if (!s.data.reader.buffered || lines.length) {
474
+ clearTimeout(s.data.lineTimer)
475
+ s.data.lineTimer = undefined
476
+ }
477
+ if (s.data.reader.buffered && !s.data.lineTimer) armLineTimer(s)
478
+ }
479
+
480
+ /** a line must end within partialLineMs of its first byte, not counting time the broker itself paused reading */
481
+ function armLineTimer(s: Conn<Connection>) {
482
+ if (partialLineMs <= 0) return
483
+ s.data.lineTimer = setTimeout(() => {
484
+ s.data.lineTimer = undefined
485
+ if (s.data.closed || !s.data.reader.buffered) return
486
+ if (s.data.queueHead < s.data.queue.length) return armLineTimer(s)
487
+ write(s, { jsonrpc: "2.0", id: null, error: { code: -32600, message: "the request line was not finished in time", data: { code: "invalid_params" } } })
488
+ s.end()
489
+ }, partialLineMs)
490
+ }
491
+
492
+ function onClose(s: Conn<Connection>) {
493
+ if (s.data.closed) return
494
+ s.data.closed = true
495
+ clearTimeout(s.data.lineTimer)
496
+ connections.delete(s)
497
+ if (s.data.endpoint && !closing && !s.data.transient && !s.data.lineage) {
498
+ const before = core.whoami(s.data.endpoint).presence
499
+ const settled = core.disconnect(s.data.endpoint, { settle: !s.data.brief })
500
+ wake(s.data.endpoint)
501
+ const view = core.whoami(s.data.endpoint)
502
+ known.set(s.data.endpoint, view.presence)
503
+ if (!s.data.brief && view.presence !== before) notifyFleet({ type: "presence", endpoint: view.address, presence: view.presence })
504
+ if (settled) notifyFleet({ type: "status", endpoint: view.address, status: null })
505
+ if (!s.data.brief || view.presence !== before || settled) bridge?.changed(s.data.endpoint)
506
+ }
507
+ activity()
508
+ }
509
+
510
+ /** starts queued lines while fewer than maxPending run; reading pauses while any wait */
511
+ function pump(s: Conn<Connection>) {
512
+ const data = s.data
513
+ while (data.queueHead < data.queue.length && data.inFlight < maxPending && !data.closed) void handleLine(s, data.queue[data.queueHead++]!)
514
+ if (data.queueHead === data.queue.length) {
515
+ data.queue = []
516
+ data.queueHead = 0
517
+ if (!data.closed) s.resume()
518
+ } else if (!data.closed) {
519
+ s.pause()
520
+ }
521
+ }
522
+
523
+ async function handleLine(s: Conn<Connection>, line: string) {
524
+ s.data.inFlight++
525
+ try {
526
+ await answer(s, line)
527
+ } catch (error) {
528
+ log(`relay broker: a request failed outside its handler: ${error instanceof Error ? (error.stack ?? error.message) : String(error)}`)
529
+ } finally {
530
+ s.data.inFlight--
531
+ pump(s)
532
+ }
533
+ }
534
+
535
+ function invalidRequest(s: Conn<Connection>, id: string | number | null, message: string) {
536
+ write(s, { jsonrpc: "2.0", id, error: { code: -32600, message, data: { code: "invalid_params" } } })
537
+ }
538
+
539
+ async function answer(s: Conn<Connection>, line: string) {
540
+ if (closing) return
541
+ let parsed: unknown
542
+ try {
543
+ parsed = JSON.parse(line)
544
+ } catch {
545
+ write(s, { jsonrpc: "2.0", id: null, error: { code: -32700, message: "parse error" } })
546
+ return
547
+ }
548
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return invalidRequest(s, null, "a request is a JSON object")
549
+ const request = parsed as Request
550
+ const hasId = "id" in request
551
+ const id = request.id ?? null
552
+ if (id !== null && typeof id !== "string" && typeof id !== "number") return invalidRequest(s, null, "a request id is a string or a number")
553
+ if (typeof request.method !== "string") return invalidRequest(s, id, "a request needs a method")
554
+ const params = request.params ?? {}
555
+ if (typeof params !== "object" || params === null || Array.isArray(params)) return invalidRequest(s, id, "params is an object")
556
+ pending++
557
+ try {
558
+ const result = await dispatch(s, request.method, params as Record<string, unknown>)
559
+ if (hasId) write(s, { jsonrpc: "2.0", id, result })
560
+ } catch (error) {
561
+ if (!hasId || closing) return
562
+ if (error instanceof RelayError) {
563
+ const code = error.code === "method_not_found" ? -32601 : error.code === "invalid_params" ? -32602 : -32000
564
+ write(s, {
565
+ jsonrpc: "2.0",
566
+ id,
567
+ error: {
568
+ code,
569
+ message: error.message,
570
+ data: { ...error.detail, code: error.code, ...(error.retryAfterS ? { retry_after: error.retryAfterS } : {}) },
571
+ },
572
+ })
573
+ } else {
574
+ log(`relay broker: ${request.method} failed: ${error instanceof Error ? (error.stack ?? error.message) : String(error)}`)
575
+ write(s, { jsonrpc: "2.0", id, error: { code: -32603, message: "internal error" } })
576
+ }
577
+ } finally {
578
+ pending--
579
+ activity()
580
+ }
581
+ }
582
+
583
+ function endpointOf(s: Conn<Connection>): string {
584
+ if (closing) throw new RelayError("unauthorized", "the relay broker is stopping")
585
+ if (!s.data.endpoint)
586
+ throw new RelayError("unauthorized", s.data.observer ? "this connection only observes: say hello as an agent or the human" : "call hello first")
587
+ core.touch(s.data.endpoint)
588
+ return s.data.endpoint
589
+ }
590
+
591
+ function waitSeconds(value: unknown): number {
592
+ if (value === undefined) return 0
593
+ if (typeof value !== "number" || !Number.isFinite(value) || value < 0) throw new RelayError("invalid_params", "wait_s must be a number of seconds")
594
+ return Math.min(value, LIMITS.waitMaxS) * 1000
595
+ }
596
+
597
+ function checkAttachments(attachments: unknown, s: Conn<Connection>): Attachment[] | undefined {
598
+ if (attachments === undefined) return undefined
599
+ if (!Array.isArray(attachments)) throw new RelayError("invalid_params", "attachments must be a list")
600
+ const tooWide = new Set(["/", realOrSelf(homedir()), realOrSelf(accountHome())])
601
+ const roots = [s.data.cwd, ...(options.roots ?? [])]
602
+ .filter((r): r is string => typeof r === "string" && isAbsolute(r))
603
+ .map(realOrSelf)
604
+ .filter((r) => !tooWide.has(r))
605
+ return attachments.map((attachment: Attachment) => {
606
+ if (attachment?.kind !== "path" || typeof attachment.path !== "string" || !isAbsolute(attachment.path)) {
607
+ throw new RelayError("invalid_params", "an attachment is {kind: path, path: an absolute path}")
608
+ }
609
+ let real: string
610
+ try {
611
+ real = realpathSync(attachment.path)
612
+ } catch {
613
+ throw new RelayError("not_found", `no file at ${attachment.path}`)
614
+ }
615
+ if (!roots.some((root) => real === root || real.startsWith(root.endsWith(sep) ? root : root + sep))) {
616
+ throw new RelayError("invalid_params", `${attachment.path} is outside the sender's working directory and the relay's allowed roots`)
617
+ }
618
+ return { kind: "path", path: real }
619
+ })
620
+ }
621
+
622
+ async function dispatch(s: Conn<Connection>, method: string, params: Record<string, unknown>): Promise<unknown> {
623
+ if (method === "hello") {
624
+ if (s.data.endpoint || s.data.observer) throw new RelayError("invalid_params", "hello was already called on this connection")
625
+ if (params.protocol !== PROTOCOL) throw new RelayError("invalid_params", `this broker speaks ${PROTOCOL}`)
626
+ if (params.transient === true) {
627
+ if (params.human === true || params.observe === true || params.resume !== undefined) {
628
+ throw new RelayError("invalid_params", "a hook connection claims an agent endpoint: transient does not go with human, observe or resume")
629
+ }
630
+ const before = new Map(core.list({ all: true }).map((view) => [view.id, view.presence]))
631
+ const { endpoint, subagent, created } = core.claim((params.claim ?? {}) as Claim, { transient: true })
632
+ s.data.endpoint = endpoint.id
633
+ s.data.transient = true
634
+ const key = (params.claim as Claim | undefined)?.process
635
+ if (typeof key === "string") s.data.process = key
636
+ if ((before.get(endpoint.id) ?? "offline") !== endpoint.presence) presenceChanged(endpoint)
637
+ return {
638
+ endpoint,
639
+ ...(subagent ? { subagent, ...(created ? { created } : {}) } : {}),
640
+ capabilities: BROKER_CAPABILITIES,
641
+ server_time: new Date().toISOString(),
642
+ limits: LIMITS,
643
+ preamble: PREAMBLE,
644
+ broker: brokerInfo(),
645
+ }
646
+ }
647
+ if (params.observe === true) {
648
+ const self = params.self === undefined ? null : core.placeLineage(params.self)
649
+ if (params.grant !== undefined) {
650
+ if (!core.grantBinds(params.grant, self)) throw new RelayError("unauthorized", GRANT_UNBOUND)
651
+ return { ...bindLineage(s, self!, params, true), grant_bound: true }
652
+ }
653
+ if (self) return bindLineage(s, self, params, true)
654
+ s.data.observer = true
655
+ if (params.lineage !== undefined) s.data.lineage = core.placeLineage(params.lineage) ?? undefined
656
+ return {
657
+ endpoint: null,
658
+ capabilities: BROKER_CAPABILITIES,
659
+ server_time: new Date().toISOString(),
660
+ limits: LIMITS,
661
+ preamble: PREAMBLE,
662
+ broker: brokerInfo(),
663
+ }
664
+ }
665
+ const claim = params.claim as Claim | undefined
666
+ if (params.human === true && params.resume === undefined && params.lineage !== undefined) {
667
+ const placed = core.placeLineage(params.lineage)
668
+ if (placed) return bindLineage(s, placed, params)
669
+ } else if (params.human !== true && params.resume === undefined && claim?.process === undefined && typeof claim?.handle === "string") {
670
+ const placed = claim.lineage === undefined ? null : core.placeLineage(claim.lineage, claim.handle)
671
+ if (placed && (claim.grant === undefined || core.grantBinds(claim.grant, placed))) {
672
+ return bindLineage(s, placed, { cwd: claim.cwd, ...params }, params.self !== undefined && core.placeLineage(params.self, claim.handle) === placed)
673
+ }
674
+ }
675
+ let token: string | undefined
676
+ let endpoint: EndpointView
677
+ const before = new Map(core.list({ all: true }).map((view) => [view.id, view]))
678
+ if (typeof params.resume === "string") endpoint = core.resume(params.resume)
679
+ else if (params.human === true) {
680
+ s.data.tty = params.tty === true
681
+ endpoint = core.claimHuman(s.data.tty)
682
+ } else {
683
+ const claimed = core.claim((params.claim ?? {}) as Claim, { self: params.self })
684
+ endpoint = claimed.endpoint
685
+ token = claimed.token
686
+ }
687
+ s.data.endpoint = endpoint.id
688
+ s.data.human = endpoint.kind === "human"
689
+ s.data.brief = params.brief === true
690
+ s.data.cwd = typeof params.cwd === "string" ? params.cwd : (endpoint.cwd ?? undefined)
691
+ known.set(endpoint.id, endpoint.presence)
692
+ const was = before.get(endpoint.id)
693
+ const moved = (was?.presence ?? "offline") !== endpoint.presence || (was !== undefined && was.address !== endpoint.address)
694
+ if (!s.data.brief && moved) {
695
+ notifyFleet({ type: "presence", endpoint: endpoint.address, presence: endpoint.presence, harness: endpoint.harness, labels: endpoint.labels })
696
+ }
697
+ if (!s.data.brief || moved) bridge?.changed(endpoint.id)
698
+ return {
699
+ endpoint,
700
+ ...(token ? { token } : {}),
701
+ capabilities: BROKER_CAPABILITIES,
702
+ server_time: new Date().toISOString(),
703
+ limits: LIMITS,
704
+ preamble: PREAMBLE,
705
+ broker: brokerInfo(),
706
+ }
707
+ }
708
+ if (closing) throw new RelayError("unauthorized", "the relay broker is stopping")
709
+ if (s.data.observer && OBSERVER_METHODS.has(method)) return observe(s, method, params)
710
+ if (s.data.observer && SCOPE_METHODS.has(method) && (s.data.lineage || method === "why")) return scope(s, method, params)
711
+ if (s.data.transient && SCOPE_METHODS.has(method)) throw new RelayError("scope_refused", "a hook connection cannot read or change scopes, groups or grants")
712
+ const me = endpointOf(s)
713
+ if (s.data.transient && !TRANSIENT_METHODS.has(method)) throw new RelayError("unauthorized", "a hook connection only posts activity and status")
714
+ if (OBSERVER_METHODS.has(method)) return observe(s, method, params)
715
+ if (SCOPE_METHODS.has(method)) return scope(s, method, params)
716
+ switch (method) {
717
+ case "whoami":
718
+ return s.data.transient ? core.whoami(me) : { ...core.whoami(me), ...core.peekState(me) }
719
+ case "peek_turn":
720
+ if (!s.data.transient || !core.hookOwns(me, s.data.process))
721
+ throw new RelayError("unauthorized", "only a hook of the endpoint's own harness process posts digest turns")
722
+ return core.peekTurn(me, { role: params.role, text: params.text })
723
+ case "send":
724
+ case "ask": {
725
+ for (const field of SERVER_FIELDS) if (field in params) throw new RelayError("invalid_params", `${field} is set by the relay, not the sender`)
726
+ loadPolicy()
727
+ if (policyError) throw new RelayError("policy_refused", policyError)
728
+ const attachments = checkAttachments(params.attachments, s)
729
+ const send: SendParams = { ...(params as unknown as SendParams), ...(attachments ? { attachments } : {}) }
730
+ if (method === "ask") send.kind = "request"
731
+ const origin = s.data.tty === undefined ? {} : { tty: s.data.tty, ...(atTerminal(s) ? {} : { unverified: true }) }
732
+ const result = await judge.send(me, send, origin, deliver)
733
+ if (method === "send" || result.held) return result
734
+ return awaitReply(s, me, result.id, waitSeconds(params.wait_s), result)
735
+ }
736
+ case "holds":
737
+ return { holds: judge.holds(optional(reviewer(s, params), "state")) }
738
+ case "hold_show":
739
+ return judge.show(required(reviewer(s, params), "id"))
740
+ case "hold_release": {
741
+ const id = required(reviewer(s, params), "id")
742
+ loadPolicy()
743
+ if (policyError) throw new RelayError("policy_refused", policyError)
744
+ return judge.release(id, { redacted: params.redacted === true, by: core.addressOf(me) }, deliver)
745
+ }
746
+ case "hold_refuse": {
747
+ const reason = required(reviewer(s, params), "reason")
748
+ if (reason.length > LIMITS.noteChars) throw new RelayError("invalid_params", `reason takes at most ${LIMITS.noteChars} characters`)
749
+ return judge.refuse(required(params, "id"), stripControl(reason), core.addressOf(me))
750
+ }
751
+ case "ask_wait": {
752
+ if (typeof params.id !== "string") throw new RelayError("invalid_params", "id is the request's message id")
753
+ return awaitReply(s, me, params.id, waitSeconds(params.wait_s))
754
+ }
755
+ case "inbox": {
756
+ const query = compact({ after: params.after as number | undefined, limit: params.limit as number | undefined })
757
+ const read = () => (params.offer === true ? core.offer(me, query) : { messages: core.inbox(me, query) })
758
+ const deadline = Date.now() + waitSeconds(params.wait_s)
759
+ let page = read()
760
+ while (!page.messages.length && !s.data.closed && Date.now() < deadline) {
761
+ await waitFor(me, deadline - Date.now(), s)
762
+ if (closing) throw new RelayError("unauthorized", "the relay broker is stopping")
763
+ page = read()
764
+ }
765
+ const { messages } = page
766
+ return { ...page, next: messages.length ? messages[messages.length - 1]!.seq : (query.after ?? 0) }
767
+ }
768
+ case "ack":
769
+ return { acked: core.ack(me, params.seqs) }
770
+ case "title": {
771
+ const view = core.title(me, params.text)
772
+ bridge?.changed(me)
773
+ return view
774
+ }
775
+ case "status": {
776
+ core.status(me, params.state, params.note)
777
+ const view = core.whoami(me)
778
+ notifyFleet({ type: "status", endpoint: view.address, status: view.status })
779
+ bridge?.changed(me)
780
+ return view
781
+ }
782
+ case "event": {
783
+ const row = core.event(me, params, hookOf(s))
784
+ notifyFleet({ type: "activity", ...row })
785
+ bridge?.activity(me, row)
786
+ const after = core.whoami(me)
787
+ if ((known.get(me) ?? "offline") !== after.presence) presenceChanged(after)
788
+ return row
789
+ }
790
+ case "inject":
791
+ return core.inject(me, {
792
+ stop: params.stop === true,
793
+ count: params.count === true,
794
+ holdUnread: params.hold_unread === true,
795
+ directives: params.directives === true,
796
+ ...hookOf(s),
797
+ })
798
+ case "stop_wait": {
799
+ const deadline = Date.now() + waitSeconds(params.wait_s)
800
+ const options = { ...hookOf(s), ...(typeof params.prompts === "number" ? { prompts: params.prompts } : {}) }
801
+ let result = core.stopWait(me, options)
802
+ const idle = (r: StopWaitResult) => !r.messages.length && !r.more && r.workers.length + r.starting > 0 && r.pushes_left > 0 && !r.prompted
803
+ while (idle(result) && !s.data.closed && Date.now() < deadline) {
804
+ await waitFor(me, Math.min(STOP_RECHECK_MS, deadline - Date.now()), s)
805
+ if (closing) throw new RelayError("unauthorized", "the relay broker is stopping")
806
+ result = core.stopWait(me, options)
807
+ }
808
+ return result
809
+ }
810
+ case "prompted":
811
+ return { prompted: core.prompted(me, hookOf(s)) }
812
+ case "injected":
813
+ return {
814
+ injected: core.injected(params.seqs, params.hold, { endpoint: me, read: params.read }),
815
+ ...(params.stop === true ? { held: core.stopHeld(me, hookOf(s)) } : {}),
816
+ ...(params.push === true ? { pushed: core.pushed(me, params.read, hookOf(s)) } : {}),
817
+ ...(params.nudged !== undefined ? { nudged: core.nudged(me, params.nudged, hookOf(s)) } : {}),
818
+ ...(params.restated !== undefined ? { restated: core.restated(me, params.restated, params.read, hookOf(s)) } : {}),
819
+ ...(params.preamble === true ? { briefed: core.briefed(me, hookOf(s)) } : {}),
820
+ ...(params.intro === true ? { introduced: core.introduced(me, hookOf(s)) } : {}),
821
+ }
822
+ case "message":
823
+ return { message: core.delivered(me, params.id) ?? (params.member === true ? core.threadMessage(me, params.id) : null) }
824
+ }
825
+ throw new RelayError("method_not_found", `no method ${method}`)
826
+ }
827
+
828
+ /** a send as `from` to local endpoints or, through the bridge, other machines; wakes the recipients and tells the watchers */
829
+ const deliver: Deliver = async (from, send, origin) => {
830
+ let result: SendResult
831
+ if (remoteSend(core, send.to)) {
832
+ if (!bridge)
833
+ throw new RelayError("not_found", `no endpoint ${[send.to].flat().join(", ")}: this broker is not linked to a hub (${CLI_NAME} cloud enable)`)
834
+ result = await bridge.forward(from, send, origin)
835
+ } else {
836
+ result = core.send(from, send, origin)
837
+ }
838
+ if (!result.duplicate) {
839
+ for (const delivery of result.deliveries) wake(delivery.endpoint)
840
+ notifyFleet({
841
+ type: "message",
842
+ id: result.id,
843
+ thread: result.thread,
844
+ kind: send.kind ?? "message",
845
+ from: core.addressOf(from),
846
+ to: result.deliveries.map((d) => d.address),
847
+ hop: result.hop,
848
+ })
849
+ }
850
+ return result
851
+ }
852
+
853
+ /** held messages are reviewed only by the human at a terminal: never an agent, a script or a relay message */
854
+ function reviewer(s: Conn<Connection>, params: Record<string, unknown>): Record<string, unknown> {
855
+ if (!atTerminal(s)) throw new RelayError("unauthorized", "only your human at a terminal reviews held messages")
856
+ return params
857
+ }
858
+
859
+ /**
860
+ * what any connection that said hello may read; an agent, or an observer on its lineage endpoint, sees only what that endpoint could message or
861
+ * be messaged by, checks the policy only for pairs it could ask why about, and reads the policy's state but not its rules
862
+ */
863
+ function observe(s: Conn<Connection>, method: string, params: Record<string, unknown>): unknown {
864
+ const viewer = viewerOf(s)
865
+ switch (method) {
866
+ case "list":
867
+ if (params.contacts === true) {
868
+ if (!bridge) throw new RelayError("unavailable", `this broker is not linked to a hub (${CLI_NAME} cloud enable), so it reaches no contacts`)
869
+ return bridge.contacts().then((contacts) => ({ contacts }))
870
+ }
871
+ return { endpoints: core.list({ ...(params as Parameters<FederatedCore["list"]>[0]), viewer, peekable: viewer === undefined }) }
872
+ case "snapshot":
873
+ return {
874
+ ...core.snapshot({ all: params.all === true, activity: typeof params.activity === "number" ? params.activity : undefined, viewer }),
875
+ broker: brokerInfo(),
876
+ }
877
+ case "watch":
878
+ s.data.watching = true
879
+ return { ...core.snapshot({ activity: 20, viewer }), broker: brokerInfo() }
880
+ case "log": {
881
+ return {
882
+ rows: core.log({
883
+ since: params.since as number | undefined,
884
+ limit: params.limit as number | undefined,
885
+ scope: params.scope === true,
886
+ judge: params.judge === true,
887
+ peek: params.peek === true,
888
+ viewer,
889
+ }),
890
+ }
891
+ }
892
+ case "policy": {
893
+ const info = policyInfo()
894
+ const text = actorOf(s).kind === "human" && info.state === "loaded" ? policyText : null
895
+ return { ...info, policy: typeof text === "string" ? (JSON.parse(text) as unknown) : null }
896
+ }
897
+ case "policy_check": {
898
+ if (viewer !== undefined) core.why(actorOf(s), String(params.from), String(params.to))
899
+ const info = policyInfo()
900
+ if (info.error) return { allowed: false, reason: info.error, file: policyFile }
901
+ const reason = core.checkPolicy(String(params.from), String(params.to))
902
+ return { allowed: reason === null, reason, file: policyFile }
903
+ }
904
+ case "cloud_status":
905
+ return bridge?.status() ?? { enabled: false }
906
+ }
907
+ throw new RelayError("method_not_found", `no method ${method}`)
908
+ }
909
+
910
+ /** rules, groups, grants, admins, peers, why and peeks, acting as the connection's actor */
911
+ function scope(s: Conn<Connection>, method: string, params: Record<string, unknown>): unknown {
912
+ const result = scopeCall(s, method, params)
913
+ if (CHANGES_SCOPE.has(method) && params.action !== "ls" && params.action !== "show") bridge?.resync()
914
+ return result
915
+ }
916
+
917
+ function scopeCall(s: Conn<Connection>, method: string, params: Record<string, unknown>): unknown {
918
+ const actor = actorOf(s)
919
+ switch (method) {
920
+ case "rules":
921
+ return core.scopeView(actor, optional(params, "endpoint"))
922
+ case "rules_set":
923
+ return core.rulesSet(
924
+ actor,
925
+ compact({
926
+ endpoint: optional(params, "endpoint"),
927
+ in: selectors(params, "in"),
928
+ out: selectors(params, "out"),
929
+ scope: optional(params, "scope") as ScopePreset | undefined,
930
+ allow: selectors(params, "allow"),
931
+ }),
932
+ )
933
+ case "rules_widen":
934
+ return core.rulesWiden(actor, compact({ endpoint: required(params, "endpoint"), in: selectors(params, "in"), out: selectors(params, "out") }))
935
+ case "rules_lift":
936
+ return core.rulesLift(actor, { endpoint: required(params, "endpoint"), cap: required(params, "cap") })
937
+ case "grant":
938
+ return core.grant(actor, params as Parameters<FederatedCore["grant"]>[1])
939
+ case "group":
940
+ return core.group(actor, params as Parameters<FederatedCore["group"]>[1])
941
+ case "admin":
942
+ return core.admin(actor, params as Parameters<FederatedCore["admin"]>[1])
943
+ case "peers":
944
+ return { peers: core.peers(viewerOf(s) ?? s.data.endpoint!, { unverified: !atTerminal(s) }) }
945
+ case "why":
946
+ return core.why(actor, required(params, "from"), required(params, "to"))
947
+ case "peek":
948
+ return core.peek(actor, { endpoint: params.endpoint, turns: params.turns, full: params.full })
949
+ case "peek_set":
950
+ return core.peekSet(actor, { ...compact({ endpoint: optional(params, "endpoint") }), level: params.level })
951
+ case "peek_who":
952
+ return core.peekWho(actor, compact({ endpoint: optional(params, "endpoint") }))
953
+ case "spawn_steers":
954
+ return core.spawnSteers(actor, { grant: params.grant as string, take: params.take === true })
955
+ case "spawn_exit": {
956
+ const { spawner, ...result } = core.spawnExit(
957
+ actor,
958
+ compact({
959
+ grant: params.grant as string,
960
+ code: params.code as number,
961
+ output: params.output as string | undefined,
962
+ stderr: params.stderr as string | undefined,
963
+ unapplied: params.unapplied as string[] | undefined,
964
+ exits: params.exits as number[] | undefined,
965
+ started: params.started as boolean | undefined,
966
+ }),
967
+ )
968
+ if (result.handback) {
969
+ const message = core.delivered(spawner, result.handback)!
970
+ wake(spawner)
971
+ notifyFleet({
972
+ type: "message",
973
+ id: message.id,
974
+ thread: message.thread,
975
+ kind: message.kind,
976
+ from: message.from.address,
977
+ to: [message.to],
978
+ hop: message.hop,
979
+ })
980
+ }
981
+ if (result.endpoint) {
982
+ notifyFleet({ type: "status", endpoint: result.endpoint.address, status: result.endpoint.status })
983
+ if ((known.get(result.endpoint.id) ?? "offline") !== result.endpoint.presence) presenceChanged(result.endpoint)
984
+ else bridge?.changed(result.endpoint.id)
985
+ }
986
+ return result
987
+ }
988
+ }
989
+ throw new RelayError("method_not_found", `no method ${method}`)
990
+ }
991
+
992
+ function realOrSelf(path: string): string {
993
+ try {
994
+ return realpathSync(path)
995
+ } catch {
996
+ return path
997
+ }
998
+ }
999
+
1000
+ /** the reply, or pending at the deadline, or pending with `ended` once every recipient present at the send went offline without replying */
1001
+ async function awaitReply(s: Conn<Connection>, me: string, request: string, ms: number, sent?: SendResult): Promise<unknown> {
1002
+ const deadline = Date.now() + ms
1003
+ const present = sent?.deliveries.filter((d) => !d.offline).map((d) => d.endpoint)
1004
+ for (;;) {
1005
+ if (closing) throw new RelayError("unauthorized", "the relay broker is stopping")
1006
+ const reply = core.replyTo(me, request)
1007
+ if (reply) {
1008
+ core.ack(me, [reply.seq])
1009
+ return { ...sent, id: request, reply }
1010
+ }
1011
+ if (core.requestExpired(request)) return { ...sent, id: request, expired: true }
1012
+ const ended = core.requestEnded(me, request, present)
1013
+ if (ended) return { ...sent, id: request, pending: true, ended }
1014
+ if (s.data.closed || Date.now() >= deadline) return { ...sent, id: request, pending: true }
1015
+ await waitFor(me, Math.min(ASK_RECHECK_MS, deadline - Date.now()), s)
1016
+ }
1017
+ }
1018
+
1019
+ async function stop(): Promise<void> {
1020
+ if (stopping) return stopping
1021
+ stopping = (async () => {
1022
+ closing = true
1023
+ clearInterval(sweeper)
1024
+ clearInterval(presenceTimer)
1025
+ await bridge?.stop()
1026
+ if (idleTimer) clearTimeout(idleTimer)
1027
+ for (const set of [...waiters.values()]) for (const fn of [...set]) fn()
1028
+ const deadline = Date.now() + 2000
1029
+ while (pending > 0 && Date.now() < deadline) await sleep(5)
1030
+ await Promise.race([judge.settled(), sleep(Math.max(0, deadline - Date.now()))])
1031
+ for (const s of connections) s.end()
1032
+ listener.close()
1033
+ try {
1034
+ unlinkSync(socket)
1035
+ } catch {}
1036
+ if (socket !== join(dir, "relay.sock")) {
1037
+ try {
1038
+ rmdirSync(dirname(socket))
1039
+ } catch {}
1040
+ }
1041
+ db.close()
1042
+ releaseBrokerLock(lock)
1043
+ resolveStopped()
1044
+ })()
1045
+ return stopping
1046
+ }
1047
+
1048
+ activity()
1049
+ return { socket, core, bridge, judge, policyFile, stopped, stop }
1050
+ }
1051
+
1052
+ type Compact<T> = { [K in keyof T as undefined extends T[K] ? never : K]: T[K] } & {
1053
+ [K in keyof T as undefined extends T[K] ? K : never]?: Exclude<T[K], undefined>
1054
+ }
1055
+
1056
+ /** `fields` without the ones that are undefined, for the optional parameters the core takes */
1057
+ function compact<T extends object>(fields: T): Compact<T> {
1058
+ return Object.fromEntries(Object.entries(fields).filter(([, value]) => value !== undefined)) as Compact<T>
1059
+ }
1060
+
1061
+ function optional(params: Record<string, unknown>, key: string): string | undefined {
1062
+ const value = params[key]
1063
+ if (value === undefined || value === null) return undefined
1064
+ if (typeof value !== "string") throw new RelayError("invalid_params", `${key} must be a string`)
1065
+ return value
1066
+ }
1067
+
1068
+ function required(params: Record<string, unknown>, key: string): string {
1069
+ const value = optional(params, key)
1070
+ if (!value) throw new RelayError("invalid_params", `${key} is required`)
1071
+ return value
1072
+ }
1073
+
1074
+ function selectors(params: Record<string, unknown>, key: string): string[] | undefined {
1075
+ const value = params[key]
1076
+ if (value === undefined || value === null) return undefined
1077
+ if (!Array.isArray(value) || !value.every((v) => typeof v === "string")) throw new RelayError("invalid_params", `${key} must be a list of selectors`)
1078
+ return value as string[]
1079
+ }