@scp-dev/hub 0.2.0 → 0.3.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.d.ts +177 -3
- package/dist/index.js +668 -119
- package/package.json +2 -2
- package/src/federation.ts +24 -1
- package/src/hub.ts +102 -12
- package/src/index.ts +11 -0
- package/src/judge-gate.ts +575 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@scp-dev/hub",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.1",
|
|
4
4
|
"description": "Swarm Communication Protocol federation and account hub: mirrored endpoints across machines, bridges, direct clients and cross-account delivery.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"homepage": "https://scp.dev",
|
|
@@ -29,7 +29,7 @@
|
|
|
29
29
|
"typecheck": "tsc -p ."
|
|
30
30
|
},
|
|
31
31
|
"dependencies": {
|
|
32
|
-
"@scp-dev/core": "0.1.
|
|
32
|
+
"@scp-dev/core": "0.1.4"
|
|
33
33
|
},
|
|
34
34
|
"devDependencies": {
|
|
35
35
|
"tsup": "^8.5.1"
|
package/src/federation.ts
CHANGED
|
@@ -43,6 +43,8 @@ import {
|
|
|
43
43
|
validHandle,
|
|
44
44
|
validHarness,
|
|
45
45
|
validSelector,
|
|
46
|
+
type Wake,
|
|
47
|
+
WAKES,
|
|
46
48
|
} from "@scp-dev/core"
|
|
47
49
|
|
|
48
50
|
export type RelayAuth = Envelope["from"]["auth"]
|
|
@@ -121,6 +123,8 @@ export interface MirrorView extends MirrorScope {
|
|
|
121
123
|
presence?: Presence
|
|
122
124
|
last_seen?: string | null
|
|
123
125
|
title?: string | null
|
|
126
|
+
/** an agent's {@link Wake}; a value this hub does not know is kept as none */
|
|
127
|
+
wake?: string | null
|
|
124
128
|
/** the handle the endpoint had before a rename: its mirror is renamed in place */
|
|
125
129
|
was?: string
|
|
126
130
|
}
|
|
@@ -142,6 +146,7 @@ interface ParsedScope {
|
|
|
142
146
|
rules?: MirrorRules | null
|
|
143
147
|
caps?: ScopeCaps | null
|
|
144
148
|
title?: string | null
|
|
149
|
+
wake?: Wake | null
|
|
145
150
|
incarnation?: string | null
|
|
146
151
|
}
|
|
147
152
|
|
|
@@ -1038,6 +1043,22 @@ export class FederatedCore extends RelayCore {
|
|
|
1038
1043
|
})
|
|
1039
1044
|
}
|
|
1040
1045
|
|
|
1046
|
+
/**
|
|
1047
|
+
* records that a forwarded message reached mirrored endpoints of other machines, unacknowledged, as a hub's delivery rows would: their
|
|
1048
|
+
* replies then join its thread here. For links that deliver machine to machine; a hub bridge never needs it
|
|
1049
|
+
*/
|
|
1050
|
+
recordRemoteDelivery(messageId: string, endpoints: string[]): void {
|
|
1051
|
+
this.tx(() => {
|
|
1052
|
+
const message = one<{ expires_at: number }>(this.sql, `SELECT expires_at FROM messages WHERE id = ?`, messageId)
|
|
1053
|
+
if (!message) return
|
|
1054
|
+
for (const endpoint of endpoints) {
|
|
1055
|
+
if (this.machineOf(endpoint) === null) continue
|
|
1056
|
+
if (one(this.sql, `SELECT 1 FROM deliveries WHERE message_id = ? AND endpoint = ?`, messageId, endpoint)) continue
|
|
1057
|
+
this.insertDelivery(messageId, endpoint, message.expires_at)
|
|
1058
|
+
}
|
|
1059
|
+
})
|
|
1060
|
+
}
|
|
1061
|
+
|
|
1041
1062
|
/** the earlier result of a forwarded send with this client_id, or null; a different message under the same client_id is refused */
|
|
1042
1063
|
forwarded(from: string, params: SendParams): SendResult | null {
|
|
1043
1064
|
const clientId = cleanText(params.client_id, "client_id", 128)
|
|
@@ -1593,7 +1614,7 @@ export class FederatedCore extends RelayCore {
|
|
|
1593
1614
|
const was = this.view(before)
|
|
1594
1615
|
const presence = was.presence !== view.presence
|
|
1595
1616
|
const status = before.status_state !== after.status_state || before.status_note !== after.status_note
|
|
1596
|
-
const other = (["kind", "harness", "model", "cwd", "labels", "title", "tree", "parent_ep", "depth", "rules", "caps", "incarnation"] as const).some(
|
|
1617
|
+
const other = (["kind", "harness", "model", "cwd", "labels", "title", "wake", "tree", "parent_ep", "depth", "rules", "caps", "incarnation"] as const).some(
|
|
1597
1618
|
(field) => before[field] !== (after as FederatedRow)[field],
|
|
1598
1619
|
)
|
|
1599
1620
|
return presence || status || other ? { view, added: false, presence, status } : null
|
|
@@ -1666,6 +1687,7 @@ export class FederatedCore extends RelayCore {
|
|
|
1666
1687
|
rules: rules === undefined ? row.rules : rules && (rules.in || rules.out) ? JSON.stringify(rules) : null,
|
|
1667
1688
|
caps,
|
|
1668
1689
|
title: scope.title !== undefined ? scope.title : fresh ? null : row.title,
|
|
1690
|
+
wake: scope.wake !== undefined ? scope.wake : fresh ? null : row.wake,
|
|
1669
1691
|
incarnation: scope.incarnation ?? row.incarnation,
|
|
1670
1692
|
}
|
|
1671
1693
|
}
|
|
@@ -1844,6 +1866,7 @@ function parseScope(view: Partial<MirrorView>): ParsedScope {
|
|
|
1844
1866
|
throw new RelayError("too_large", `an endpoint's rules and caps are more than ${SCOPE_BYTES} bytes`)
|
|
1845
1867
|
if (given("title"))
|
|
1846
1868
|
out.title = view.title === null ? null : cleanText(typeof view.title === "string" ? view.title.replace(/\s+/g, " ") : view.title, "title", 64) || null
|
|
1869
|
+
if (given("wake")) out.wake = typeof view.wake === "string" && view.wake !== "none" && WAKES.includes(view.wake as Wake) ? (view.wake as Wake) : null
|
|
1847
1870
|
if (given("incarnation")) {
|
|
1848
1871
|
if (view.incarnation !== null && (typeof view.incarnation !== "string" || !INCARNATION.test(view.incarnation))) throw bad("incarnation")
|
|
1849
1872
|
out.incarnation = view.incarnation ?? null
|
package/src/hub.ts
CHANGED
|
@@ -18,6 +18,7 @@ import {
|
|
|
18
18
|
honestSender,
|
|
19
19
|
joinRedacted,
|
|
20
20
|
judgeDetailForAgents,
|
|
21
|
+
type JudgePolicy,
|
|
21
22
|
LIMITS,
|
|
22
23
|
MESSAGE_KINDS,
|
|
23
24
|
type MessageKind,
|
|
@@ -37,6 +38,7 @@ import {
|
|
|
37
38
|
SYSTEM_HANDLE,
|
|
38
39
|
sameSecret,
|
|
39
40
|
untilTomorrow,
|
|
41
|
+
type Wake,
|
|
40
42
|
type XContact,
|
|
41
43
|
XCounters,
|
|
42
44
|
type XDelivered,
|
|
@@ -48,6 +50,7 @@ import {
|
|
|
48
50
|
xlimits,
|
|
49
51
|
} from "@scp-dev/core"
|
|
50
52
|
import { FederatedCore, MACHINE_NAME, type MirrorChange, type XEndpoint } from "./federation"
|
|
53
|
+
import { type Deliver, type HoldView, type JudgeClientFactory, JudgeGate, type JudgeLimits } from "./judge-gate"
|
|
51
54
|
|
|
52
55
|
export type HubAuth = "account" | "account+bridge"
|
|
53
56
|
|
|
@@ -102,7 +105,7 @@ export interface GraphNode {
|
|
|
102
105
|
status: EndpointView["status"]
|
|
103
106
|
last_seen: string | null
|
|
104
107
|
parent: string | null
|
|
105
|
-
wake?:
|
|
108
|
+
wake?: Wake | null
|
|
106
109
|
}
|
|
107
110
|
|
|
108
111
|
/** `spawn`: parent to child; `message`: sender to recipient, with the count and newest send in the graph's window */
|
|
@@ -145,6 +148,16 @@ export interface HubDeps {
|
|
|
145
148
|
crossAccount?: CrossAccountLink
|
|
146
149
|
}
|
|
147
150
|
|
|
151
|
+
/** what a configured judge needs besides its policy */
|
|
152
|
+
export interface HubJudgeOptions {
|
|
153
|
+
/** where the model's key_env is read */
|
|
154
|
+
env: Record<string, string | undefined>
|
|
155
|
+
/** the model client (default the provider's HTTP API) */
|
|
156
|
+
client?: JudgeClientFactory | undefined
|
|
157
|
+
/** overrides of the judge's own caps; `Infinity` disables one */
|
|
158
|
+
limits?: Partial<JudgeLimits> | undefined
|
|
159
|
+
}
|
|
160
|
+
|
|
148
161
|
export const HUB_LIMITS = {
|
|
149
162
|
machines: 16,
|
|
150
163
|
endpoints: 512,
|
|
@@ -276,6 +289,8 @@ export class RelayHub {
|
|
|
276
289
|
private xcounters: XCounters | null = null
|
|
277
290
|
private xkey: string | null = null
|
|
278
291
|
private readonly limits: Readonly<HubLimits>
|
|
292
|
+
private judgeConfig: { policy: JudgePolicy; options: HubJudgeOptions } | null = null
|
|
293
|
+
private gate: JudgeGate | null = null
|
|
279
294
|
|
|
280
295
|
constructor(private readonly deps: HubDeps) {
|
|
281
296
|
this.now = deps.now ?? Date.now
|
|
@@ -303,6 +318,7 @@ export class RelayHub {
|
|
|
303
318
|
})
|
|
304
319
|
core.migrate()
|
|
305
320
|
this.core = core
|
|
321
|
+
this.gate = null
|
|
306
322
|
this.account = account.user
|
|
307
323
|
if (this.deps.crossAccount) {
|
|
308
324
|
this.xkey = core.xstate("key", () => randomHex(32))
|
|
@@ -318,10 +334,40 @@ export class RelayHub {
|
|
|
318
334
|
this.denied = null
|
|
319
335
|
this.xcounters = null
|
|
320
336
|
this.xkey = null
|
|
337
|
+
this.gate = null
|
|
321
338
|
this.buckets.clear()
|
|
322
339
|
this.gone.clear()
|
|
323
340
|
}
|
|
324
341
|
|
|
342
|
+
/**
|
|
343
|
+
* judges this account's own sends (client send and ask, bridge forward) before delivery; sends to other accounts are left to the
|
|
344
|
+
* cross-account gate. Without a policy, or with mode off, the hub holds no judge and creates no judge tables
|
|
345
|
+
*/
|
|
346
|
+
configureJudge(policy: JudgePolicy | undefined, options: HubJudgeOptions = { env: {} }): void {
|
|
347
|
+
this.gate = null
|
|
348
|
+
this.judgeConfig = policy && policy.mode !== "off" ? { policy, options } : null
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
/** the held messages, newest first, without bodies */
|
|
352
|
+
holds(state?: string): HoldView[] {
|
|
353
|
+
return this.judged().holds(state)
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
/** one hold with its body while it waits */
|
|
357
|
+
holdShow(id: string): ReturnType<JudgeGate["show"]> {
|
|
358
|
+
return this.judged().show(id)
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
/** delivers a held message as its sender, every send check run again */
|
|
362
|
+
holdRelease(id: string, options: { redacted?: boolean; by: string }): Promise<SendResult> {
|
|
363
|
+
return this.judged().release(id, options, this.deliverJudged)
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
/** refuses a held message: never delivered, its sender told why */
|
|
367
|
+
holdRefuse(id: string, reason: string, by: string): HoldView {
|
|
368
|
+
return this.judged().refuse(id, reason, by)
|
|
369
|
+
}
|
|
370
|
+
|
|
325
371
|
/** a fresh connection state for an accepted socket */
|
|
326
372
|
static connection(grant: HubGrant): HubConn {
|
|
327
373
|
return { id: newId("conn"), grant, role: "new", machine: null, endpoint: null, watching: false, subscribed: false }
|
|
@@ -483,6 +529,7 @@ export class RelayHub {
|
|
|
483
529
|
this.gone.clear()
|
|
484
530
|
for (const [key, bucket] of this.buckets) if (bucket.tokens + ((now - bucket.at) * bucket.rate) / 60_000 >= bucket.rate) this.buckets.delete(key)
|
|
485
531
|
this.xcounters?.prune()
|
|
532
|
+
this.judgeGate()?.sweep()
|
|
486
533
|
this.setState("last_sweep", now)
|
|
487
534
|
}
|
|
488
535
|
if (this.idle(now)) {
|
|
@@ -835,11 +882,12 @@ export class RelayHub {
|
|
|
835
882
|
const send = { ...(params as unknown as SendParams) }
|
|
836
883
|
delete (send as Params).wait_s
|
|
837
884
|
if (method === "ask") send.kind = "request"
|
|
838
|
-
const
|
|
839
|
-
const
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
885
|
+
const origin = conn.tty === undefined ? {} : { tty: conn.tty }
|
|
886
|
+
const gate = this.judgeGate()
|
|
887
|
+
const result = gate
|
|
888
|
+
? await gate.send(me, send, { ...origin, machine: conn.machine }, this.deliverJudged)
|
|
889
|
+
: this.deliverOn(conn.machine, me, send, origin)
|
|
890
|
+
if (method === "send" || result.held) return result
|
|
843
891
|
return this.awaitReply(socket, me, result.id, this.waitMs(socket, params.wait_s), result)
|
|
844
892
|
}
|
|
845
893
|
case "ask_wait": {
|
|
@@ -915,11 +963,10 @@ export class RelayHub {
|
|
|
915
963
|
delete send.attachments
|
|
916
964
|
const floor = Math.min(asked as number, LIMITS.hopMax + 1)
|
|
917
965
|
if (floor > 0) core.markReceived(sender, floor - 1)
|
|
918
|
-
const
|
|
919
|
-
const
|
|
920
|
-
if (
|
|
921
|
-
|
|
922
|
-
return result
|
|
966
|
+
const gate = this.judgeGate()
|
|
967
|
+
const origin = { tty: params.tty === true }
|
|
968
|
+
if (gate) return gate.send(sender, send as unknown as SendParams, { ...origin, machine }, this.deliverJudged)
|
|
969
|
+
return this.deliverOn(machine, sender, send as unknown as SendParams, origin)
|
|
923
970
|
}
|
|
924
971
|
case "subscribe": {
|
|
925
972
|
const after = params.after ?? 0
|
|
@@ -1273,6 +1320,49 @@ export class RelayHub {
|
|
|
1273
1320
|
)
|
|
1274
1321
|
}
|
|
1275
1322
|
|
|
1323
|
+
// judge
|
|
1324
|
+
|
|
1325
|
+
/** the judge, made on first use once configured; null when it is not */
|
|
1326
|
+
private judgeGate(): JudgeGate | null {
|
|
1327
|
+
if (!this.judgeConfig) return null
|
|
1328
|
+
if (this.gate) return this.gate
|
|
1329
|
+
const core = this.relay
|
|
1330
|
+
const { policy, options } = this.judgeConfig
|
|
1331
|
+
const gate = new JudgeGate({
|
|
1332
|
+
sql: this.deps.sql,
|
|
1333
|
+
core,
|
|
1334
|
+
now: this.now,
|
|
1335
|
+
env: options.env,
|
|
1336
|
+
client: options.client,
|
|
1337
|
+
limits: options.limits,
|
|
1338
|
+
log: (line) => this.deps.log?.("relay_judge", { account: this.account, line }),
|
|
1339
|
+
wake: (endpoint) => this.wake(endpoint),
|
|
1340
|
+
notice: (endpoint, text) => this.xnotice(endpoint, text),
|
|
1341
|
+
within: (origin, fn) => core.within(origin.machine ?? null, fn),
|
|
1342
|
+
})
|
|
1343
|
+
gate.configure(policy)
|
|
1344
|
+
this.gate = gate
|
|
1345
|
+
return gate
|
|
1346
|
+
}
|
|
1347
|
+
|
|
1348
|
+
private judged(): JudgeGate {
|
|
1349
|
+
const gate = this.judgeGate()
|
|
1350
|
+
if (!gate) throw new RelayError("not_found", "this relay has no judge")
|
|
1351
|
+
return gate
|
|
1352
|
+
}
|
|
1353
|
+
|
|
1354
|
+
/** a send of this account as it runs unjudged: room for the body, the core send on the sender's machine, then push */
|
|
1355
|
+
private deliverOn(machine: string | null, from: string, send: SendParams, origin: { tty?: boolean }): SendResult {
|
|
1356
|
+
const core = this.relay
|
|
1357
|
+
const bytes = this.room(from, send.body)
|
|
1358
|
+
const result = core.within(machine, () => core.send(from, send, origin))
|
|
1359
|
+
if (!result.duplicate) this.stash(bytes)
|
|
1360
|
+
if (!result.duplicate) this.delivered(from, result, send.kind ?? "message")
|
|
1361
|
+
return result
|
|
1362
|
+
}
|
|
1363
|
+
|
|
1364
|
+
private readonly deliverJudged: Deliver = async (from, send, { machine, ...origin }) => this.deliverOn(machine ?? null, from, send, origin)
|
|
1365
|
+
|
|
1276
1366
|
// push and wait
|
|
1277
1367
|
|
|
1278
1368
|
private delivered(from: string, result: SendResult, kind: string): void {
|
|
@@ -1504,7 +1594,7 @@ export class RelayHub {
|
|
|
1504
1594
|
/** a read-only graph for a dashboard: listed endpoints, spawn edges among them, and message counts per sender and recipient; never a body */
|
|
1505
1595
|
graph(minutes = GRAPH_MINUTES): HubGraph {
|
|
1506
1596
|
const window = Math.min(Math.max(1, Math.floor(Number.isFinite(minutes) ? minutes : GRAPH_MINUTES)), GRAPH_MINUTES_MAX)
|
|
1507
|
-
const endpoints = this.relay.list()
|
|
1597
|
+
const endpoints = this.relay.list()
|
|
1508
1598
|
const byId = new Map(endpoints.map((e) => [e.id, e.address]))
|
|
1509
1599
|
const listed = new Set(byId.values())
|
|
1510
1600
|
const nodes: GraphNode[] = endpoints.map((e) => ({
|
package/src/index.ts
CHANGED
|
@@ -23,9 +23,20 @@ export {
|
|
|
23
23
|
type HubAuth,
|
|
24
24
|
type HubConn,
|
|
25
25
|
type HubDeps,
|
|
26
|
+
type HubJudgeOptions,
|
|
26
27
|
type HubGrant,
|
|
27
28
|
type HubLimits,
|
|
28
29
|
type HubRole,
|
|
29
30
|
type HubSocket,
|
|
30
31
|
type MachineRow,
|
|
31
32
|
} from "./hub"
|
|
33
|
+
export {
|
|
34
|
+
type Deliver,
|
|
35
|
+
type HoldView,
|
|
36
|
+
JUDGE_LIMITS,
|
|
37
|
+
type JudgeClientFactory,
|
|
38
|
+
JudgeGate,
|
|
39
|
+
type JudgeGateOptions,
|
|
40
|
+
type JudgeLimits,
|
|
41
|
+
type Origin as JudgeOrigin,
|
|
42
|
+
} from "./judge-gate"
|