@scp-dev/broker 0.1.2 → 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/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 JudgeClientFactory = (model: JudgeModelConfig, key: () => Promise<string>) => JudgeClient
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
- export interface JudgeGateOptions {
35
- sql: Sql
36
- core: FederatedCore
37
- now: () => number
38
- /** where key_env is read */
39
- env: Record<string, string | undefined>
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
- /** what makes two sends under one client_id the same message, close to what core's idempotency compares */
534
- const fingerprint = (p: SendParams): string =>
535
- sha256Hex(
536
- JSON.stringify([
537
- [p.to].flat().map(String).sort(),
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
  }