@scp-dev/broker 0.1.3 → 0.1.4
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 +28 -115
- package/dist/index.js +94 -514
- package/package.json +3 -3
- package/src/client.ts +2 -2
- package/src/index.ts +3 -2
- package/src/judge-gate.ts +14 -545
- package/src/server.ts +51 -18
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@scp-dev/broker",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.4",
|
|
4
4
|
"description": "Swarm Communication Protocol local broker: a unix socket JSON-RPC daemon over the relay core, its client and the bridge to an account hub.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"homepage": "https://scp.dev",
|
|
@@ -37,8 +37,8 @@
|
|
|
37
37
|
"typecheck": "tsc -p ."
|
|
38
38
|
},
|
|
39
39
|
"dependencies": {
|
|
40
|
-
"@scp-dev/core": "0.1.
|
|
41
|
-
"@scp-dev/hub": "0.
|
|
40
|
+
"@scp-dev/core": "0.1.3",
|
|
41
|
+
"@scp-dev/hub": "0.3.0"
|
|
42
42
|
},
|
|
43
43
|
"devDependencies": {
|
|
44
44
|
"tsup": "^8.5.1"
|
package/src/client.ts
CHANGED
|
@@ -31,8 +31,8 @@ export interface Hello {
|
|
|
31
31
|
tty?: boolean
|
|
32
32
|
/** a hook's connection: it claims like an agent but never counts as connected, and posts only event and status */
|
|
33
33
|
transient?: boolean
|
|
34
|
-
/** with `transient`: the session joins the relay on demand, taking `handle` when given */
|
|
35
|
-
join?: { handle?: string }
|
|
34
|
+
/** with `transient`: the session joins the relay on demand, taking `handle` when given, and links itself under the agent `under` */
|
|
35
|
+
join?: { handle?: string; under?: string }
|
|
36
36
|
/** a one-shot command: the broker announces no presence change when it connects or goes */
|
|
37
37
|
brief?: boolean
|
|
38
38
|
/** no identity: list, status, watch, log and policy only */
|
package/src/index.ts
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
export { type BrokerInfo, BrokerUnavailable, connectBroker, type GrantParams, type GroupParams, RelayClient, type Hello, type HelloResult } from "./client"
|
|
2
|
-
export { relaySocketUrl, type Bridge, type BridgeConfig, type BridgeStatus } from "./bridge"
|
|
2
|
+
export { relaySocketUrl, type Bridge, type BridgeConfig, type BridgeHooks, type BridgeStatus } from "./bridge"
|
|
3
3
|
export { BrokerRunning } from "./lock"
|
|
4
4
|
export { MAX_LINE } from "./ndjson"
|
|
5
5
|
export { accountHome, localHostName, policyPath, privateDir, relayDir, shortHostname, socketPath, systemHostname, thisHostname } from "./paths"
|
|
6
|
-
export
|
|
6
|
+
export type { Deliver } from "./judge-gate"
|
|
7
|
+
export { startBroker, type Broker, type BrokerOptions, type LinkContext, type LinkFactory } from "./server"
|
|
7
8
|
export { readSessionMarker, type SessionMarker, writeSessionMarker } from "./sessions"
|
package/src/judge-gate.ts
CHANGED
|
@@ -1,552 +1,21 @@
|
|
|
1
1
|
import { accessSync, constants, readFileSync } from "node:fs"
|
|
2
|
-
import {
|
|
3
|
-
applyRedactions,
|
|
4
|
-
DEFAULT_JUDGE_BUDGET,
|
|
5
|
-
findSecret,
|
|
6
|
-
HttpJudgeClient,
|
|
7
|
-
JUDGE_PROMPT_VERSION,
|
|
8
|
-
type JudgeAuditDetail,
|
|
9
|
-
judgeBoundary,
|
|
10
|
-
type JudgeClient,
|
|
11
|
-
type JudgeModelConfig,
|
|
12
|
-
type JudgePolicy,
|
|
13
|
-
LIMITS,
|
|
14
|
-
mergeVerdicts,
|
|
15
|
-
newId,
|
|
16
|
-
newJudgeStats,
|
|
17
|
-
one,
|
|
18
|
-
RelayError,
|
|
19
|
-
runJudge,
|
|
20
|
-
type SendParams,
|
|
21
|
-
type SendResult,
|
|
22
|
-
sha256Hex,
|
|
23
|
-
type Sql,
|
|
24
|
-
type Verdict,
|
|
25
|
-
VerdictCache,
|
|
26
|
-
} from "@scp-dev/core"
|
|
27
|
-
import type { FederatedCore } from "@scp-dev/hub"
|
|
2
|
+
import { JudgeGate as HubJudgeGate, type JudgeGateOptions } from "@scp-dev/hub"
|
|
28
3
|
|
|
29
|
-
export type
|
|
30
|
-
type Origin = { tty?: boolean; unverified?: boolean }
|
|
31
|
-
/** sends as `from` through the same path an unjudged send takes, waking recipients */
|
|
32
|
-
export type Deliver = (from: string, params: SendParams, origin: Origin) => Promise<SendResult>
|
|
4
|
+
export type { Deliver, HoldView, JudgeClientFactory, JudgeGateOptions } from "@scp-dev/hub"
|
|
33
5
|
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
client?: JudgeClientFactory | undefined
|
|
41
|
-
log: (line: string) => void
|
|
42
|
-
/** an endpoint got a notice */
|
|
43
|
-
wake: (endpoint: string) => void
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
interface HoldRow {
|
|
47
|
-
id: string
|
|
48
|
-
from_ep: string
|
|
49
|
-
params: string
|
|
50
|
-
origin: string
|
|
51
|
-
verdict: string
|
|
52
|
-
created_at: number
|
|
53
|
-
expires_at: number
|
|
54
|
-
state: "held" | "releasing" | "released" | "refused" | "expired"
|
|
55
|
-
by: string | null
|
|
56
|
-
resolved_at: number | null
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
type StoredVerdict = Verdict & { boundary: string; to: string[] }
|
|
60
|
-
|
|
61
|
-
/** a hold as the human's review lists it: never the body */
|
|
62
|
-
export interface HoldView {
|
|
63
|
-
id: string
|
|
64
|
-
from: string
|
|
65
|
-
to: string[]
|
|
66
|
-
state: HoldRow["state"]
|
|
67
|
-
created_at: string
|
|
68
|
-
expires_at: string
|
|
69
|
-
categories: string[]
|
|
70
|
-
reason: string
|
|
71
|
-
boundary: string
|
|
72
|
-
source: Verdict["source"]
|
|
73
|
-
bytes: number
|
|
74
|
-
by: string | null
|
|
75
|
-
resolved_at: string | null
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
/** held sends never reach the send's rate limits, so a sender's waiting holds are capped */
|
|
79
|
-
const HELD_PER_SENDER = 100
|
|
80
|
-
|
|
81
|
-
const iso = (ms: number | null) => (ms === null ? null : new Date(ms).toISOString())
|
|
82
|
-
|
|
83
|
-
/**
|
|
84
|
-
* the broker's local judge (policy `judge`): shadow judges after delivery and only audits; enforce judges before delivery and passes,
|
|
85
|
-
* redacts, refuses or holds. Holds wait in `judge_holds` for the human at a terminal; their body is purged once resolved or expired
|
|
86
|
-
*/
|
|
87
|
-
export class JudgeGate {
|
|
88
|
-
readonly stats = newJudgeStats()
|
|
89
|
-
private policy: JudgePolicy | undefined
|
|
90
|
-
private version = ""
|
|
91
|
-
private client: JudgeClient | null = null
|
|
92
|
-
private readonly cache = new VerdictCache()
|
|
93
|
-
private readonly shadows = new Set<Promise<void>>()
|
|
94
|
-
/** judged sends in the last hour per budget: `ep:<id>`, `tree:<root id>` and `*` for the whole broker */
|
|
95
|
-
private readonly spent = new Map<string, number[]>()
|
|
96
|
-
|
|
97
|
-
constructor(private readonly o: JudgeGateOptions) {
|
|
98
|
-
o.sql.all(
|
|
99
|
-
`CREATE TABLE IF NOT EXISTS judge_holds (id TEXT PRIMARY KEY, from_ep TEXT NOT NULL, params TEXT NOT NULL, origin TEXT NOT NULL, verdict TEXT NOT NULL,
|
|
100
|
-
created_at INTEGER NOT NULL, expires_at INTEGER NOT NULL, state TEXT NOT NULL, by TEXT, resolved_at INTEGER)`,
|
|
101
|
-
)
|
|
102
|
-
o.sql.all(
|
|
103
|
-
`CREATE TABLE IF NOT EXISTS judge_sent (from_ep TEXT NOT NULL, client_id TEXT NOT NULL, fingerprint TEXT NOT NULL, result TEXT NOT NULL, created_at INTEGER NOT NULL,
|
|
104
|
-
PRIMARY KEY (from_ep, client_id))`,
|
|
105
|
-
)
|
|
106
|
-
o.sql.all(`UPDATE judge_holds SET state = 'held' WHERE state = 'releasing'`)
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
/** the policy's judge section; a model client only when its key is readable, else the prefilter alone */
|
|
110
|
-
configure(policy: JudgePolicy | undefined): void {
|
|
111
|
-
this.policy = policy
|
|
112
|
-
this.version = policy ? sha256Hex(JSON.stringify(policy)) : ""
|
|
113
|
-
this.client = null
|
|
114
|
-
const model = policy?.model
|
|
115
|
-
if (!policy || policy.mode === "off" || !model) return
|
|
116
|
-
const key = this.keyReader(model)
|
|
117
|
-
if (!key) {
|
|
118
|
-
this.o.log(
|
|
119
|
-
`relay broker: the judge model's key (${model.key_env ? `key_env ${model.key_env}` : `key_file ${model.key_file}`}) is not readable; judging with the prefilter only`,
|
|
120
|
-
)
|
|
121
|
-
return
|
|
122
|
-
}
|
|
123
|
-
this.client = (this.o.client ?? ((m, k) => new HttpJudgeClient({ model: m.id, effort: m.effort, key: k })))(model, key)
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
private keyReader(model: JudgeModelConfig): (() => Promise<string>) | null {
|
|
127
|
-
if (model.key_env) {
|
|
128
|
-
const name = model.key_env
|
|
129
|
-
return this.o.env[name] ? async () => this.o.env[name] ?? "" : null
|
|
130
|
-
}
|
|
131
|
-
const file = model.key_file!
|
|
132
|
-
try {
|
|
133
|
-
accessSync(file, constants.R_OK)
|
|
134
|
-
} catch {
|
|
135
|
-
return null
|
|
136
|
-
}
|
|
137
|
-
return async () => readFileSync(file, "utf8").trim()
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
/** resolves once every shadow judgement started so far finished */
|
|
141
|
-
async settled(): Promise<void> {
|
|
142
|
-
await Promise.all([...this.shadows])
|
|
143
|
-
}
|
|
144
|
-
|
|
145
|
-
/** a send or ask through the judge; an unjudged one goes straight to `deliver` */
|
|
146
|
-
async send(from: string, params: SendParams, origin: Origin, deliver: Deliver): Promise<SendResult> {
|
|
147
|
-
const policy = this.policy
|
|
148
|
-
const body = params.body
|
|
149
|
-
if (!policy || policy.mode === "off" || typeof body !== "string" || new TextEncoder().encode(body).length > LIMITS.bodyBytes || findSecret(body))
|
|
150
|
-
return deliver(from, params, origin)
|
|
151
|
-
const targets = Array.isArray(params.to) ? params.to : [params.to]
|
|
152
|
-
if (targets.some((to) => this.o.core.foreignAccount(to) !== null)) {
|
|
153
|
-
if (policy.mode === "enforce") {
|
|
154
|
-
this.o.core.auditJudge("judge_skipped", from, null, {
|
|
155
|
-
side: "local",
|
|
156
|
-
boundary: "other account",
|
|
157
|
-
prompt_version: JUDGE_PROMPT_VERSION,
|
|
158
|
-
categories: [],
|
|
159
|
-
sha256: sha256Hex(body),
|
|
160
|
-
verdict: "skipped",
|
|
161
|
-
reason: "other_account_judged_by_hub",
|
|
162
|
-
to: targets.map(String),
|
|
163
|
-
})
|
|
164
|
-
}
|
|
165
|
-
return deliver(from, params, origin)
|
|
166
|
-
}
|
|
167
|
-
if (policy.mode === "shadow") {
|
|
168
|
-
const result = await deliver(from, params, origin)
|
|
169
|
-
if (!result.duplicate) this.shadow(policy, from, params, origin, result.id)
|
|
170
|
-
return result
|
|
171
|
-
}
|
|
172
|
-
const { groups, tree } = this.boundaries(policy, from, params, origin)
|
|
173
|
-
if (!groups.size) return deliver(from, params, origin)
|
|
174
|
-
const ttl = params.ttl_s ?? LIMITS.ttlDefaultS
|
|
175
|
-
if (!Number.isInteger(ttl) || ttl < 1 || ttl > LIMITS.ttlMaxS) throw new RelayError("invalid_params", `ttl_s must be 1 to ${LIMITS.ttlMaxS}`)
|
|
176
|
-
const seen = params.client_id === undefined ? undefined : this.seen(from, params)
|
|
177
|
-
if (seen) return seen
|
|
178
|
-
const waiting = one<{ n: number }>(this.o.sql, `SELECT COUNT(*) AS n FROM judge_holds WHERE from_ep = ? AND state IN ('held', 'releasing')`, from)!.n
|
|
179
|
-
if (waiting >= HELD_PER_SENDER)
|
|
180
|
-
throw new RelayError("rate_limited", `${waiting} of your messages wait for review by your human; send more once they released or refused some`, 3600)
|
|
181
|
-
this.spend(policy, from, tree)
|
|
182
|
-
const verdict = await this.judge(policy, from, body, groups)
|
|
183
|
-
const detail = this.detail(verdict, body, groups)
|
|
184
|
-
switch (verdict.verdict) {
|
|
185
|
-
case "refuse":
|
|
186
|
-
this.o.core.auditJudge("judge_refuse", from, null, detail)
|
|
187
|
-
throw new RelayError(
|
|
188
|
-
"judge_refused",
|
|
189
|
-
`the relay judge refused this message (local; ${verdict.categories.join(", ") || "no category"}): ${verdict.reason}`,
|
|
190
|
-
undefined,
|
|
191
|
-
{
|
|
192
|
-
side: "local",
|
|
193
|
-
categories: verdict.categories,
|
|
194
|
-
reason: verdict.reason,
|
|
195
|
-
},
|
|
196
|
-
)
|
|
197
|
-
case "hold":
|
|
198
|
-
return this.hold(from, params, origin, verdict, groups, detail)
|
|
199
|
-
case "redact": {
|
|
200
|
-
const applied = applyRedactions(body, verdict.redactions)
|
|
201
|
-
const result = await deliver(from, { ...params, body: applied.text }, origin)
|
|
202
|
-
const redacted = { n: applied.n, kinds: applied.kinds }
|
|
203
|
-
this.o.core.auditJudge(verdict.source === "error" ? "judge_skipped" : "judge_redact", from, result.id, { ...detail, redacted })
|
|
204
|
-
return this.remember(from, params, { ...result, redacted })
|
|
205
|
-
}
|
|
206
|
-
case "pass": {
|
|
207
|
-
const result = await deliver(from, params, origin)
|
|
208
|
-
if (verdict.source === "error") this.o.core.auditJudge("judge_skipped", from, result.id, detail)
|
|
209
|
-
return this.remember(from, params, result)
|
|
210
|
-
}
|
|
211
|
-
}
|
|
212
|
-
}
|
|
213
|
-
|
|
214
|
-
/** an earlier judged send or waiting hold under the send's client_id: the same message is answered as before, another one refused as core refuses it */
|
|
215
|
-
private seen(from: string, params: SendParams): SendResult | undefined {
|
|
216
|
-
const clientId = params.client_id!
|
|
217
|
-
const held = one<{ id: string; params: string }>(
|
|
218
|
-
this.o.sql,
|
|
219
|
-
`SELECT id, params FROM judge_holds WHERE from_ep = ? AND json_extract(params, '$.client_id') = ? AND state IN ('held', 'releasing')`,
|
|
220
|
-
from,
|
|
221
|
-
clientId,
|
|
222
|
-
)
|
|
223
|
-
const sent = held
|
|
224
|
-
? undefined
|
|
225
|
-
: one<{ fingerprint: string; result: string }>(
|
|
226
|
-
this.o.sql,
|
|
227
|
-
`SELECT fingerprint, result FROM judge_sent WHERE from_ep = ? AND client_id = ? AND created_at > ?`,
|
|
228
|
-
from,
|
|
229
|
-
clientId,
|
|
230
|
-
this.o.now() - LIMITS.idempotencyS * 1000,
|
|
231
|
-
)
|
|
232
|
-
const before = held ? fingerprint(JSON.parse(held.params) as SendParams) : sent?.fingerprint
|
|
233
|
-
if (before === undefined) return undefined
|
|
234
|
-
if (before !== fingerprint(params)) throw new RelayError("invalid_params", `client_id ${clientId} was already used for a different message`)
|
|
235
|
-
return held ? heldResult(held.id, params) : { ...(JSON.parse(sent!.result) as SendResult), duplicate: true }
|
|
236
|
-
}
|
|
237
|
-
|
|
238
|
-
private remember(from: string, params: SendParams, result: SendResult): SendResult {
|
|
239
|
-
if (params.client_id !== undefined && !result.duplicate) {
|
|
240
|
-
this.o.sql.all(
|
|
241
|
-
`INSERT OR REPLACE INTO judge_sent (from_ep, client_id, fingerprint, result, created_at) VALUES (?, ?, ?, ?, ?)`,
|
|
242
|
-
from,
|
|
243
|
-
params.client_id,
|
|
244
|
-
fingerprint(params),
|
|
245
|
-
JSON.stringify(result),
|
|
246
|
-
this.o.now(),
|
|
247
|
-
)
|
|
248
|
-
}
|
|
249
|
-
return result
|
|
250
|
-
}
|
|
251
|
-
|
|
252
|
-
/**
|
|
253
|
-
* a judged send counts before any model call against its sender's, its lineage tree's and the whole broker's budget, since refused and
|
|
254
|
-
* held sends never reach the send's own limits, and a new handle or a helper process must not bring a fresh budget
|
|
255
|
-
*/
|
|
256
|
-
private spend(policy: JudgePolicy, from: string, tree: string): void {
|
|
257
|
-
const now = this.o.now()
|
|
258
|
-
const global = policy.budget ?? DEFAULT_JUDGE_BUDGET
|
|
259
|
-
const budgets: [key: string, perMinute: number, perHour: number, what: string][] = [
|
|
260
|
-
[`ep:${from}`, LIMITS.pairPerMinute, LIMITS.pairPerHour, "from this endpoint"],
|
|
261
|
-
[`tree:${tree}`, LIMITS.pairPerMinute * 2, LIMITS.pairPerHour * 2, "from this lineage tree"],
|
|
262
|
-
["*", global.per_minute, global.per_hour, "on this broker"],
|
|
263
|
-
]
|
|
264
|
-
const recent = budgets.map(([key]) => (this.spent.get(key) ?? []).filter((at) => at > now - 3_600_000))
|
|
265
|
-
budgets.forEach(([key, perMinute, perHour, what], i) => {
|
|
266
|
-
for (const [limit, windowS] of [
|
|
267
|
-
[perMinute, 60],
|
|
268
|
-
[perHour, 3600],
|
|
269
|
-
] as const) {
|
|
270
|
-
const inWindow = recent[i]!.filter((at) => at > now - windowS * 1000)
|
|
271
|
-
if (inWindow.length >= limit) {
|
|
272
|
-
this.spent.set(key, recent[i]!)
|
|
273
|
-
throw new RelayError(
|
|
274
|
-
"rate_limited",
|
|
275
|
-
`${limit} judged messages ${what} in ${windowS} s`,
|
|
276
|
-
Math.max(1, Math.ceil((inWindow[0]! + windowS * 1000 - now) / 1000)),
|
|
277
|
-
)
|
|
278
|
-
}
|
|
279
|
-
}
|
|
280
|
-
})
|
|
281
|
-
for (const [i, [key]] of budgets.entries()) this.spent.set(key, [...recent[i]!, now])
|
|
282
|
-
}
|
|
283
|
-
|
|
284
|
-
/** how many budgets are tracked (idle ones are evicted by the sweep) */
|
|
285
|
-
budgetKeys(): number {
|
|
286
|
-
return this.spent.size
|
|
287
|
-
}
|
|
288
|
-
|
|
289
|
-
/** the held messages, newest first, without bodies */
|
|
290
|
-
holds(state?: string): HoldView[] {
|
|
291
|
-
const rows = state
|
|
292
|
-
? this.o.sql.all<HoldRow>(`SELECT * FROM judge_holds WHERE state = ? ORDER BY created_at DESC, rowid DESC LIMIT 200`, state)
|
|
293
|
-
: this.o.sql.all<HoldRow>(`SELECT * FROM judge_holds ORDER BY created_at DESC, rowid DESC LIMIT 200`)
|
|
294
|
-
return rows.map((row) => this.view(row))
|
|
295
|
-
}
|
|
296
|
-
|
|
297
|
-
/** one hold with its body and the judge's redactions while it waits */
|
|
298
|
-
show(id: string): HoldView & { body?: string; redactions?: { quote?: string; kind: string }[] } {
|
|
299
|
-
const row = this.row(id)
|
|
300
|
-
const params = JSON.parse(row.params) as Partial<SendParams>
|
|
301
|
-
const verdict = JSON.parse(row.verdict) as StoredVerdict
|
|
302
|
-
return { ...this.view(row), ...(typeof params.body === "string" ? { body: params.body } : {}), redactions: verdict.redactions }
|
|
303
|
-
}
|
|
304
|
-
|
|
305
|
-
/** delivers a held message as its sender, as stored or redacted: scope, policy and limits are checked again by the send itself */
|
|
306
|
-
async release(id: string, options: { redacted?: boolean; by: string }, deliver: Deliver): Promise<SendResult> {
|
|
307
|
-
const row = one<HoldRow>(
|
|
308
|
-
this.o.sql,
|
|
309
|
-
`UPDATE judge_holds SET state = 'releasing' WHERE id = ? AND state = 'held' AND expires_at > ? RETURNING *`,
|
|
310
|
-
id,
|
|
311
|
-
this.o.now(),
|
|
312
|
-
)
|
|
313
|
-
if (!row) throw new RelayError("not_found", `no held message ${id}`)
|
|
314
|
-
const params = JSON.parse(row.params) as SendParams
|
|
315
|
-
const verdict = JSON.parse(row.verdict) as StoredVerdict
|
|
316
|
-
let sent = params
|
|
317
|
-
let redacted: { n: number; kinds: string[] } | undefined
|
|
318
|
-
if (options.redacted) {
|
|
319
|
-
const applied = applyRedactions(params.body, verdict.redactions)
|
|
320
|
-
if (!applied.n) {
|
|
321
|
-
this.o.sql.all(`UPDATE judge_holds SET state = 'held' WHERE id = ?`, id)
|
|
322
|
-
throw new RelayError("invalid_params", `hold ${id} has no redactions: release it as it is or refuse it`)
|
|
323
|
-
}
|
|
324
|
-
sent = { ...params, body: applied.text }
|
|
325
|
-
redacted = { n: applied.n, kinds: applied.kinds }
|
|
326
|
-
}
|
|
327
|
-
let result: SendResult
|
|
328
|
-
try {
|
|
329
|
-
result = await deliver(row.from_ep, sent, JSON.parse(row.origin) as Origin)
|
|
330
|
-
} catch (error) {
|
|
331
|
-
this.o.sql.all(`UPDATE judge_holds SET state = 'held' WHERE id = ?`, id)
|
|
332
|
-
throw error
|
|
333
|
-
}
|
|
334
|
-
this.remember(row.from_ep, params, { ...result, ...(redacted ? { redacted } : {}) })
|
|
335
|
-
this.resolve(row, "released", options.by)
|
|
336
|
-
this.o.core.auditJudge("judge_override", row.from_ep, id, {
|
|
337
|
-
...this.storedDetail(row, verdict),
|
|
338
|
-
by: options.by,
|
|
339
|
-
action: redacted ? "release_redacted" : "release",
|
|
340
|
-
...(redacted ? { redacted } : {}),
|
|
341
|
-
})
|
|
342
|
-
this.tell(
|
|
343
|
-
row.from_ep,
|
|
344
|
-
`relay: your message held for review as ${id} was released by your human${redacted ? " with redactions" : ""} and delivered as ${result.id}`,
|
|
345
|
-
)
|
|
346
|
-
return { ...result, ...(redacted ? { redacted } : {}) }
|
|
347
|
-
}
|
|
348
|
-
|
|
349
|
-
/** refuses a held message: it is never delivered, and its sender hears the human's reason */
|
|
350
|
-
refuse(id: string, reason: string, by: string): HoldView {
|
|
351
|
-
this.expire(this.o.now())
|
|
352
|
-
const row = one<HoldRow>(this.o.sql, `SELECT * FROM judge_holds WHERE id = ? AND state = 'held'`, id)
|
|
353
|
-
if (!row) throw new RelayError("not_found", `no held message ${id}`)
|
|
354
|
-
const verdict = JSON.parse(row.verdict) as StoredVerdict
|
|
355
|
-
this.resolve(row, "refused", by)
|
|
356
|
-
this.o.core.auditJudge("judge_override", row.from_ep, id, { ...this.storedDetail(row, verdict), by, action: "refuse", reason })
|
|
357
|
-
this.tell(row.from_ep, `relay: your message held for review as ${id} was refused by your human: ${reason}`)
|
|
358
|
-
return this.view(this.row(id))
|
|
359
|
-
}
|
|
360
|
-
|
|
361
|
-
/** expires holds past their message TTL (body purged, sender told) and drops resolved ones past the audit window */
|
|
362
|
-
sweep(): void {
|
|
363
|
-
const now = this.o.now()
|
|
364
|
-
this.expire(now)
|
|
365
|
-
this.o.sql.all(`DELETE FROM judge_holds WHERE state IN ('released', 'refused', 'expired') AND resolved_at <= ?`, now - LIMITS.auditKeepS * 1000)
|
|
366
|
-
this.o.sql.all(`DELETE FROM judge_sent WHERE created_at <= ?`, now - LIMITS.idempotencyS * 1000)
|
|
367
|
-
for (const [key, times] of this.spent) if (!times.length || times[times.length - 1]! <= now - 3_600_000) this.spent.delete(key)
|
|
368
|
-
}
|
|
369
|
-
|
|
370
|
-
private expire(now: number): void {
|
|
371
|
-
for (const row of this.o.sql.all<HoldRow>(`SELECT * FROM judge_holds WHERE state = 'held' AND expires_at <= ?`, now)) {
|
|
372
|
-
const verdict = JSON.parse(row.verdict) as StoredVerdict
|
|
373
|
-
this.resolve(row, "expired", null)
|
|
374
|
-
this.o.core.auditJudge("judge_expire", row.from_ep, row.id, this.storedDetail(row, verdict))
|
|
375
|
-
this.tell(row.from_ep, `relay: your message held for review as ${row.id} expired without review and was not delivered`)
|
|
376
|
-
}
|
|
377
|
-
}
|
|
378
|
-
|
|
379
|
-
/** the recipients by the boundary their message crosses; throws as send does for a target the sender cannot reach */
|
|
380
|
-
private boundaries(policy: JudgePolicy, from: string, params: SendParams, origin: Origin): { groups: Map<string, string[]>; tree: string } {
|
|
381
|
-
const { sender, recipients, tree } = this.o.core.judgeSubjects(from, params, origin)
|
|
382
|
-
const groups = new Map<string, string[]>()
|
|
383
|
-
for (const recipient of recipients) {
|
|
384
|
-
const label = judgeBoundary(policy, sender, recipient)
|
|
385
|
-
if (label !== null) groups.set(label, [...(groups.get(label) ?? []), recipient.address])
|
|
386
|
-
}
|
|
387
|
-
return { groups, tree }
|
|
388
|
-
}
|
|
389
|
-
|
|
390
|
-
private async judge(policy: JudgePolicy, from: string, body: string, groups: Map<string, string[]>): Promise<Verdict> {
|
|
391
|
-
const verdicts = await Promise.all(
|
|
392
|
-
[...groups].map(([boundary, to]) =>
|
|
393
|
-
runJudge({
|
|
394
|
-
body,
|
|
395
|
-
side: "local",
|
|
396
|
-
boundary,
|
|
397
|
-
configVersion: this.version,
|
|
398
|
-
client: this.client,
|
|
399
|
-
cache: this.cache,
|
|
400
|
-
timeoutMs: policy.timeout_s * 1000,
|
|
401
|
-
failMode: policy.fail,
|
|
402
|
-
...(this.client && policy.model ? { model: policy.model.id } : {}),
|
|
403
|
-
from: this.o.core.addressOf(from),
|
|
404
|
-
to,
|
|
405
|
-
now: this.o.now,
|
|
406
|
-
stats: this.stats,
|
|
407
|
-
}),
|
|
408
|
-
),
|
|
409
|
-
)
|
|
410
|
-
return mergeVerdicts(verdicts)
|
|
411
|
-
}
|
|
412
|
-
|
|
413
|
-
private shadow(policy: JudgePolicy, from: string, params: SendParams, origin: Origin, ref: string): void {
|
|
414
|
-
const task = (async () => {
|
|
415
|
-
try {
|
|
416
|
-
const { groups, tree } = this.boundaries(policy, from, params, origin)
|
|
417
|
-
if (!groups.size) return
|
|
418
|
-
this.spend(policy, from, tree)
|
|
419
|
-
const body = params.body
|
|
420
|
-
const verdict = await this.judge(policy, from, body, groups)
|
|
421
|
-
this.o.core.auditJudge("judge_shadow", from, ref, this.detail(verdict, body, groups))
|
|
422
|
-
} catch (error) {
|
|
423
|
-
this.o.log(`relay broker: a shadow judgement failed: ${error instanceof Error ? error.message : String(error)}`)
|
|
424
|
-
}
|
|
425
|
-
})()
|
|
426
|
-
this.shadows.add(task)
|
|
427
|
-
void task.finally(() => this.shadows.delete(task))
|
|
428
|
-
}
|
|
429
|
-
|
|
430
|
-
private hold(from: string, params: SendParams, origin: Origin, verdict: Verdict, groups: Map<string, string[]>, detail: JudgeAuditDetail): SendResult {
|
|
431
|
-
const now = this.o.now()
|
|
432
|
-
const id = newId("hold", now)
|
|
433
|
-
const stored: SendParams = { ...params, client_id: params.client_id ?? id }
|
|
434
|
-
const to = [...groups.values()].flat()
|
|
435
|
-
const record: StoredVerdict = { ...verdict, boundary: [...groups.keys()].join(" | "), to }
|
|
436
|
-
this.o.sql.all(
|
|
437
|
-
`INSERT INTO judge_holds (id, from_ep, params, origin, verdict, created_at, expires_at, state) VALUES (?, ?, ?, ?, ?, ?, ?, 'held')`,
|
|
438
|
-
id,
|
|
439
|
-
from,
|
|
440
|
-
JSON.stringify(stored),
|
|
441
|
-
JSON.stringify(origin),
|
|
442
|
-
JSON.stringify(record),
|
|
443
|
-
now,
|
|
444
|
-
now + (params.ttl_s ?? LIMITS.ttlDefaultS) * 1000,
|
|
445
|
-
)
|
|
446
|
-
this.o.core.auditJudge("judge_hold", from, id, detail)
|
|
447
|
-
return heldResult(id, params)
|
|
448
|
-
}
|
|
449
|
-
|
|
450
|
-
private detail(verdict: Verdict, body: string, groups: Map<string, string[]>): JudgeAuditDetail {
|
|
451
|
-
return {
|
|
452
|
-
side: "local",
|
|
453
|
-
boundary: [...groups.keys()].join(" | "),
|
|
454
|
-
prompt_version: verdict.prompt_version,
|
|
455
|
-
categories: verdict.categories,
|
|
456
|
-
sha256: sha256Hex(body),
|
|
457
|
-
verdict: verdict.verdict,
|
|
458
|
-
source: verdict.source,
|
|
459
|
-
reason: verdict.reason,
|
|
460
|
-
to: [...groups.values()].flat(),
|
|
461
|
-
...(verdict.model ? { model: verdict.model } : {}),
|
|
462
|
-
...(verdict.ms !== undefined ? { ms: verdict.ms } : {}),
|
|
463
|
-
}
|
|
464
|
-
}
|
|
465
|
-
|
|
466
|
-
private storedDetail(row: HoldRow, verdict: StoredVerdict): JudgeAuditDetail {
|
|
467
|
-
const params = JSON.parse(row.params) as { body?: string; sha256?: string }
|
|
468
|
-
return {
|
|
469
|
-
side: "local",
|
|
470
|
-
boundary: verdict.boundary,
|
|
471
|
-
prompt_version: verdict.prompt_version,
|
|
472
|
-
categories: verdict.categories,
|
|
473
|
-
sha256: params.sha256 ?? sha256Hex(params.body ?? ""),
|
|
474
|
-
verdict: verdict.verdict,
|
|
475
|
-
to: verdict.to,
|
|
476
|
-
...(verdict.model ? { model: verdict.model } : {}),
|
|
477
|
-
}
|
|
478
|
-
}
|
|
479
|
-
|
|
480
|
-
/** marks a hold resolved and purges its body and the judge's quotes of it */
|
|
481
|
-
private resolve(row: HoldRow, state: "released" | "refused" | "expired", by: string | null): void {
|
|
482
|
-
const { body, ...params } = JSON.parse(row.params) as SendParams
|
|
483
|
-
const verdict = JSON.parse(row.verdict) as StoredVerdict
|
|
484
|
-
const purged = { ...params, bytes: new TextEncoder().encode(body).length, sha256: sha256Hex(body) }
|
|
485
|
-
const kinds = { ...verdict, redactions: verdict.redactions.map((r) => ({ kind: r.kind })) }
|
|
486
|
-
this.o.sql.all(
|
|
487
|
-
`UPDATE judge_holds SET state = ?, by = ?, resolved_at = ?, params = ?, verdict = ? WHERE id = ?`,
|
|
488
|
-
state,
|
|
489
|
-
by,
|
|
490
|
-
this.o.now(),
|
|
491
|
-
JSON.stringify(purged),
|
|
492
|
-
JSON.stringify(kinds),
|
|
493
|
-
row.id,
|
|
494
|
-
)
|
|
495
|
-
}
|
|
496
|
-
|
|
497
|
-
private tell(endpoint: string, text: string): void {
|
|
498
|
-
try {
|
|
499
|
-
this.o.core.judgeNotice(endpoint, text)
|
|
500
|
-
this.o.wake(endpoint)
|
|
501
|
-
} catch (error) {
|
|
502
|
-
if (!(error instanceof RelayError)) throw error
|
|
503
|
-
}
|
|
504
|
-
}
|
|
505
|
-
|
|
506
|
-
private row(id: string): HoldRow {
|
|
507
|
-
const row = one<HoldRow>(this.o.sql, `SELECT * FROM judge_holds WHERE id = ?`, id)
|
|
508
|
-
if (!row) throw new RelayError("not_found", `no held message ${id}`)
|
|
509
|
-
return row
|
|
510
|
-
}
|
|
511
|
-
|
|
512
|
-
private view(row: HoldRow): HoldView {
|
|
513
|
-
const params = JSON.parse(row.params) as Partial<SendParams> & { bytes?: number }
|
|
514
|
-
const verdict = JSON.parse(row.verdict) as StoredVerdict
|
|
515
|
-
return {
|
|
516
|
-
id: row.id,
|
|
517
|
-
from: this.o.core.addressOf(row.from_ep),
|
|
518
|
-
to: verdict.to,
|
|
519
|
-
state: row.state,
|
|
520
|
-
created_at: iso(row.created_at)!,
|
|
521
|
-
expires_at: iso(row.expires_at)!,
|
|
522
|
-
categories: verdict.categories,
|
|
523
|
-
reason: verdict.reason,
|
|
524
|
-
boundary: verdict.boundary,
|
|
525
|
-
source: verdict.source,
|
|
526
|
-
bytes: params.bytes ?? new TextEncoder().encode(params.body ?? "").length,
|
|
527
|
-
by: row.by,
|
|
528
|
-
resolved_at: iso(row.resolved_at),
|
|
529
|
-
}
|
|
6
|
+
/** a key file read at each call, or null while it is not readable */
|
|
7
|
+
export function readKeyFile(file: string): (() => Promise<string>) | null {
|
|
8
|
+
try {
|
|
9
|
+
accessSync(file, constants.R_OK)
|
|
10
|
+
} catch {
|
|
11
|
+
return null
|
|
530
12
|
}
|
|
13
|
+
return async () => readFileSync(file, "utf8").trim()
|
|
531
14
|
}
|
|
532
15
|
|
|
533
|
-
/**
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
p.body,
|
|
539
|
-
p.kind ?? "message",
|
|
540
|
-
p.thread ?? null,
|
|
541
|
-
p.reply_to ?? null,
|
|
542
|
-
p.data_class ?? "public",
|
|
543
|
-
(p.attachments ?? []).map((a) => a?.path).sort(),
|
|
544
|
-
p.mime ?? null,
|
|
545
|
-
p.ttl_s ?? LIMITS.ttlDefaultS,
|
|
546
|
-
p.require_reply ?? null,
|
|
547
|
-
]),
|
|
548
|
-
)
|
|
549
|
-
|
|
550
|
-
function heldResult(id: string, params: SendParams): SendResult {
|
|
551
|
-
return { id, thread: params.thread ?? "", hop: 0, deliveries: [], held: { hold_id: id, side: "local" } }
|
|
16
|
+
/** the hub's judge gate reading `key_file` keys from disk */
|
|
17
|
+
export class JudgeGate extends HubJudgeGate {
|
|
18
|
+
constructor(options: JudgeGateOptions) {
|
|
19
|
+
super({ keyFile: readKeyFile, ...options })
|
|
20
|
+
}
|
|
552
21
|
}
|