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