zigmini 0.8.0-beta.7 → 0.9.0-beta.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.
@@ -2,11 +2,15 @@ import { entropy, isAllowed, type AllowEntry, type Rule } from "../scripts/secre
2
2
  import ruleConfig from "../scripts/secret-scan.rules.json";
3
3
  import allowConfig from "../scripts/secret-scan.allow.json";
4
4
  import { readVoiceSetting, writeVoiceSetting } from "./voice-setting.ts";
5
+ import { isDeepStrictEqual } from "node:util";
6
+ import { canonicalSecurityValue, SecurityRuntimeError, type HeldState, type SecurityApprover, type SecurityHoldRecord, type SecurityIntent, type SecurityReceipt } from "./security-allow.ts";
7
+ import type { Store } from "./store.ts";
8
+ export type { HeldState, AllowedValue } from "./security-allow.ts";
5
9
 
6
10
  // Improvement 165: every user text bound for the engine passes one security check first (work 779e5b0e).
7
11
  // The rules, the allow list and the matching helpers are the repository security check's own (scripts/secret-rules.ts,
8
- // scripts/secret-scan.rules.json, scripts/secret-scan.allow.json; docs/security-check.md); this module only compiles them
9
- // once and runs them in memory. No network, disk or process call happens per message.
12
+ // scripts/secret-scan.rules.json, scripts/secret-scan.allow.json; docs/security-check.md). Detector expressions compile
13
+ // once. Runtime approvals use the gateway-owned Store; unapproved originals stay in memory.
10
14
 
11
15
  export type SecurityRule = Rule;
12
16
  export type SecurityAllow = AllowEntry;
@@ -20,18 +24,33 @@ export interface SecurityOccurrence {
20
24
  readonly text: number;
21
25
  readonly line: number;
22
26
  readonly column: number;
27
+ readonly canonicalValue?: string;
28
+ readonly ruleId?: string;
29
+ readonly start?: number;
30
+ readonly end?: number;
23
31
  }
24
32
  /** A message held back by the check until the user sends or drops it; kept in gateway memory only. */
25
- export interface HeldMessage {
26
- readonly id: string;
27
- readonly conv: string;
28
- readonly source: "message" | "answer";
29
- readonly createdAt: string;
33
+ export interface HeldMessage extends HeldState {
30
34
  readonly findings: readonly SecurityFinding[];
31
35
  readonly occurrences: readonly SecurityOccurrence[];
32
36
  /** The user's own text, so the screen can put it back into the composer when the message is dropped or lost. */
33
37
  readonly draft?: string;
34
38
  }
39
+ export type HeldDetail = HeldState & Partial<Pick<HeldMessage, "findings" | "occurrences" | "draft">>;
40
+ export interface HeldContext {
41
+ readonly key?: string;
42
+ readonly snapshot?: unknown;
43
+ readonly originalDecisionId?: string;
44
+ readonly submissionId?: string;
45
+ }
46
+ export type HeldSend = (receipt: (ref: SecurityReceipt) => void) => Promise<void>;
47
+ export interface MessageGuardOptions {
48
+ readonly store: Store;
49
+ readonly receiptTimeoutMs?: number;
50
+ readonly state?: (state: HeldState) => void;
51
+ readonly allowedUpdated?: (revision: number) => void;
52
+ readonly decisionUpdated?: () => void;
53
+ }
35
54
 
36
55
  export const SECURITY_CHECK_PATH = "/api/settings/security-check";
37
56
  export const SECURITY_BLOCKED = "security_check_blocked";
@@ -77,7 +96,7 @@ export class SecurityChecker {
77
96
  this.rules = rules.map((rule) => ({
78
97
  id: rule.id,
79
98
  kind: KIND_OF_RULE[rule.id] ?? "other",
80
- regex: new RegExp(rule.pattern, `${(rule.flags ?? "").replace(/[gy]/g, "")}g`),
99
+ regex: new RegExp(rule.pattern, `${(rule.flags ?? "").replace(/[gyd]/g, "")}dg`),
81
100
  group: rule.group ?? 0,
82
101
  ...(rule.minEntropy === undefined ? {} : { minEntropy: rule.minEntropy }),
83
102
  }));
@@ -90,6 +109,14 @@ export class SecurityChecker {
90
109
 
91
110
  /** Every matched value with its place, in text order then rule order. */
92
111
  occurrences(texts: readonly string[]): SecurityOccurrence[] {
112
+ return this.detect(texts, false);
113
+ }
114
+
115
+ runtimeOccurrences(texts: readonly string[]): SecurityOccurrence[] {
116
+ return this.detect(texts, true);
117
+ }
118
+
119
+ private detect(texts: readonly string[], runtime: boolean): SecurityOccurrence[] {
93
120
  const found: SecurityOccurrence[] = [];
94
121
  for (const [index, text] of texts.entries()) {
95
122
  if (!text) continue;
@@ -104,9 +131,14 @@ export class SecurityChecker {
104
131
  if (rule.minEntropy !== undefined && entropy(value) < rule.minEntropy) continue;
105
132
  if (isAllowed(rule.id, value, this.allow)) continue;
106
133
  const at = match.index + (rule.group === 0 ? 0 : match[0].indexOf(value));
134
+ const start = match.indices?.[rule.group]?.[0] ?? at;
135
+ const end = match.indices?.[rule.group]?.[1] ?? start + value.length;
107
136
  const before = text.slice(0, at);
108
137
  const line = before.split("\n").length;
109
- found.push({ kind: rule.kind, label: KIND_LABELS[rule.kind]!, value, text: index, line, column: at - before.lastIndexOf("\n") });
138
+ found.push({ kind: rule.kind, label: KIND_LABELS[rule.kind]!, value, text: index, line, column: at - before.lastIndexOf("\n"),
139
+ ...(runtime ? { ruleId: rule.id, start, end,
140
+ canonicalValue: canonicalSecurityValue(text, rule.id, start, end) } : {}),
141
+ });
110
142
  }
111
143
  }
112
144
  }
@@ -144,11 +176,29 @@ export class SecurityCheckError extends Error {
144
176
  */
145
177
  export class MessageGuard {
146
178
  private on: boolean;
147
- private readonly held = new Map<string, { info: HeldMessage; send: () => Promise<void> }>();
179
+ private readonly held = new Map<string, { info: HeldMessage; send: HeldSend; snapshot: unknown }>();
180
+ private readonly memory = new Map<string, SecurityHoldRecord>();
181
+ private readonly flights = new Map<string, Promise<void>>();
182
+ private readonly failures = new Map<string, SecurityRuntimeError>();
183
+ private readonly alarms = new Map<string, ReturnType<typeof setTimeout>>();
184
+ private readonly waiters = new Map<string, Set<() => void>>();
185
+ private reconciling = false;
186
+ private readonly unsubscribe?: () => void;
148
187
 
149
188
  /** `onHold` sends each new held entry (with its values) straight to the user's screens; never to omo or the store. */
150
- constructor(private readonly checker: SecurityChecker, readonly file: string | null, private readonly onHold: (held: HeldMessage) => void = () => {}) {
189
+ constructor(private readonly checker: SecurityChecker, readonly file: string | null, private readonly onHold: (held: HeldMessage) => void = () => {}, private readonly options?: MessageGuardOptions) {
151
190
  this.on = file === null ? true : readVoiceSetting(file);
191
+ if (options) {
192
+ for (const record of options.store.securityHolds()) {
193
+ this.memory.set(record.state.id, record);
194
+ if (record.state.status === "pending" || record.state.status === "processing") {
195
+ options.store.updateSecurityHold(record.state.id, record.dispatchReserved
196
+ ? { status: "unconfirmed", errorCode: "dispatch_unknown" } : { status: "failed", errorCode: "payload_lost" });
197
+ }
198
+ }
199
+ this.unsubscribe = options.store.onSecurityReceipt(() => this.reconcile());
200
+ this.reconcile();
201
+ }
152
202
  }
153
203
 
154
204
  enabled(): boolean {
@@ -164,39 +214,256 @@ export class MessageGuard {
164
214
  /** Matched values of the texts, or null when the check is off or nothing matched. */
165
215
  check(texts: readonly string[]): SecurityOccurrence[] | null {
166
216
  if (!this.on) return null;
167
- const found = this.checker.occurrences(texts);
217
+ const found = this.checker.runtimeOccurrences(texts);
218
+ if (found.length === 0) return null;
219
+ let allowed: Set<string>;
220
+ try { allowed = new Set(this.options?.store.securityAllowedValues().values.map((entry) => entry.value) ?? []); }
221
+ catch { throw new SecurityRuntimeError("allowlist_persist_failed"); }
222
+ if (found.length > 0 && found.every((occurrence) => allowed.has(occurrence.canonicalValue ?? occurrence.value))) return null;
168
223
  return found.length > 0 ? found : null;
169
224
  }
170
225
 
171
- /** Keeps `send` until the user decides; nothing is written to disk. */
172
- hold(conv: string, source: HeldMessage["source"], occurrences: readonly SecurityOccurrence[], send: () => Promise<void>, draft?: string): HeldMessage {
226
+ /** Called before filtering: an old source key never becomes a second transmission. */
227
+ existing(context: HeldContext): HeldDetail | null {
228
+ if (!context.key) return null;
229
+ let record: SecurityHoldRecord | null | undefined;
230
+ try { record = this.options ? this.options.store.securityHoldByKey(context.key) : [...this.memory.values()].find((r) => r.key === context.key); }
231
+ catch { throw new SecurityRuntimeError("state_persist_failed"); }
232
+ if (!record) return null;
233
+ const live = this.held.get(record.state.id);
234
+ if (live && !isDeepStrictEqual(live.snapshot, context.snapshot)) throw new SecurityRuntimeError("held_conflict", record.state);
235
+ if (live) this.onHold({ ...live.info, ...record.state });
236
+ else this.options?.state?.(record.state);
237
+ return this.detail(record.state.id);
238
+ }
239
+
240
+ /** Keeps the original and `send` in memory; the Store receives only the opaque hold record. */
241
+ hold(conv: string, source: HeldMessage["source"], occurrences: readonly SecurityOccurrence[], send: HeldSend, draft?: string, context: HeldContext = {}): HeldMessage {
242
+ const prior = this.existing(context);
243
+ if (prior) {
244
+ const live = this.held.get(prior.id);
245
+ if (live) return { ...live.info, ...prior };
246
+ throw new SecurityRuntimeError("held_conflict", prior);
247
+ }
248
+ const id = crypto.randomUUID();
173
249
  const info: HeldMessage = {
174
- id: crypto.randomUUID(), conv, source, createdAt: new Date().toISOString(),
175
- findings: countFindings(occurrences), occurrences, ...(draft === undefined ? {} : { draft }),
250
+ id, decisionId: id, revision: 1, conv, source, createdAt: new Date().toISOString(),
251
+ originalDecisionId: context.originalDecisionId ?? null, status: "pending", submissionId: context.submissionId ?? null,
252
+ remembered: false, allowlistRevision: this.options?.store.securityAllowedValues().revision ?? 0, errorCode: null,
253
+ findings: countFindings(occurrences), occurrences: occurrences.map(({ ruleId: _rule, start: _start, end: _end, ...occurrence }) => occurrence),
254
+ ...(draft === undefined ? {} : { draft }),
176
255
  };
177
- this.held.set(info.id, { info, send });
256
+ const record: SecurityHoldRecord = { state: this.state(info), key: context.key ?? null, intent: null, dispatchReserved: false, receipt: null };
257
+ try {
258
+ if (this.options) this.options.store.createSecurityHold(record);
259
+ this.memory.set(id, record);
260
+ } catch {
261
+ throw new SecurityRuntimeError("state_persist_failed");
262
+ }
263
+ this.held.set(info.id, { info, send, snapshot: structuredClone(context.snapshot) });
178
264
  this.onHold(info);
265
+ this.changed(info.id);
179
266
  return info;
180
267
  }
181
268
 
182
269
  /** Held when check() finds anything: throws SecurityCheckError with the held entry. */
183
- holdOrPass(conv: string, source: HeldMessage["source"], texts: readonly string[], send: () => Promise<void>): void {
270
+ holdOrPass(conv: string, source: HeldMessage["source"], texts: readonly string[], send: HeldSend, context: HeldContext = {}): void {
271
+ const prior = this.existing(context);
272
+ if (prior) {
273
+ const live = this.held.get(prior.id);
274
+ if (live) throw new SecurityCheckError({ ...live.info, ...prior });
275
+ throw new SecurityRuntimeError("held_conflict", prior);
276
+ }
184
277
  const found = this.check(texts);
185
- if (found) throw new SecurityCheckError(this.hold(conv, source, found, send));
278
+ if (found) throw new SecurityCheckError(this.hold(conv, source, found, send, undefined, context));
186
279
  }
187
280
 
188
281
  list(): HeldMessage[] {
189
- return [...this.held.values()].map((entry) => entry.info);
282
+ return [...this.held.values()].flatMap((entry) => {
283
+ const state = this.record(entry.info.id)?.state;
284
+ return state && (state.status === "pending" || state.status === "processing") ? [{ ...entry.info, ...state }] : [];
285
+ });
286
+ }
287
+
288
+ private state(info: HeldMessage): HeldState {
289
+ const { findings: _findings, occurrences: _occurrences, draft: _draft, ...state } = info;
290
+ return state;
291
+ }
292
+
293
+ private record(id: string): SecurityHoldRecord | null {
294
+ if (!this.options) return this.memory.get(id) ?? null;
295
+ try {
296
+ const record = this.options.store.securityHold(id);
297
+ if (record) this.memory.set(id, record);
298
+ return record;
299
+ } catch { throw new SecurityRuntimeError("state_persist_failed", this.memory.get(id)?.state); }
300
+ }
301
+
302
+ detail(id: string): HeldDetail | null {
303
+ this.reconcile();
304
+ const record = this.record(id);
305
+ if (!record) return null;
306
+ const live = this.held.get(id);
307
+ return live && (record.state.status === "pending" || record.state.status === "processing")
308
+ ? { ...live.info, ...record.state } : record.state;
309
+ }
310
+
311
+ private changed(id: string): void {
312
+ const record = this.record(id);
313
+ if (!record) return;
314
+ this.options?.state?.(record.state);
315
+ this.options?.decisionUpdated?.();
316
+ if (!["pending", "processing"].includes(record.state.status)) {
317
+ if (record.state.status === "sent") this.failures.delete(id);
318
+ this.held.delete(id);
319
+ const alarm = this.alarms.get(id);
320
+ if (alarm) clearTimeout(alarm);
321
+ this.alarms.delete(id);
322
+ for (const resolve of this.waiters.get(id) ?? []) resolve();
323
+ this.waiters.delete(id);
324
+ }
325
+ }
326
+
327
+ private update(id: string, change: Parameters<Store["updateSecurityHold"]>[1], receipt?: SecurityReceipt, intent?: SecurityIntent): void {
328
+ if (this.options) this.memory.set(id, this.options.store.updateSecurityHold(id, change, receipt, intent));
329
+ else {
330
+ const record = this.record(id);
331
+ if (!record) return;
332
+ this.memory.set(id, { ...record, ...(receipt ? { receipt } : {}), state: { ...record.state, ...change, revision: record.state.revision + 1 } });
333
+ }
334
+ this.changed(id);
335
+ }
336
+
337
+ reconcile(): void {
338
+ if (!this.options || this.reconciling) return;
339
+ this.reconciling = true;
340
+ try {
341
+ for (const record of this.options.store.securityHolds()) {
342
+ if (!record.receipt || !["processing", "unconfirmed"].includes(record.state.status)) continue;
343
+ const status = this.options.store.securityReceipt(record.receipt);
344
+ if (status && status !== record.state.status) {
345
+ this.update(record.state.id, { status, errorCode: status === "sent" ? null : "dispatch_unknown" });
346
+ }
347
+ }
348
+ } finally { this.reconciling = false; }
349
+ }
350
+
351
+ async resolve(id: string, intent: SecurityIntent, approver: SecurityApprover): Promise<HeldState | null> {
352
+ this.reconcile();
353
+ const initial = this.record(id);
354
+ if (!initial) return null;
355
+ const previousFailure = this.failures.get(id);
356
+ if (previousFailure) throw previousFailure;
357
+ let claimed: { record: SecurityHoldRecord; fresh: boolean };
358
+ try {
359
+ if (this.options) claimed = this.options.store.claimSecurityHold(id, intent);
360
+ else {
361
+ if (initial.intent && !isDeepStrictEqual(initial.intent, intent)) throw new SecurityRuntimeError("held_conflict", initial.state);
362
+ if (!initial.intent && (initial.state.status !== "pending" ||
363
+ (intent.expectedRevision !== null && intent.expectedRevision !== initial.state.revision))) throw new SecurityRuntimeError("held_conflict", initial.state);
364
+ claimed = { record: initial, fresh: initial.intent === null };
365
+ if (claimed.fresh) {
366
+ const record = { ...initial, intent, state: { ...initial.state, status: intent.send ? "processing" as const : "cancelled" as const, revision: initial.state.revision + 1 } };
367
+ this.memory.set(id, record);
368
+ claimed = { record, fresh: true };
369
+ }
370
+ }
371
+ } catch (error) {
372
+ if (error instanceof SecurityRuntimeError) throw error;
373
+ this.failPersistence(id, "state_persist_failed", intent);
374
+ }
375
+ if (claimed.fresh) {
376
+ this.changed(id);
377
+ if (intent.send) {
378
+ const entry = this.held.get(id);
379
+ if (!entry) this.failPersistence(id, "state_persist_failed");
380
+ try {
381
+ if (this.options) {
382
+ const before = this.options.store.securityAllowedValues().revision;
383
+ const reserved = this.options.store.reserveSecurityDispatch(id, entry.info.occurrences, approver);
384
+ this.changed(id);
385
+ if (reserved.state.allowlistRevision !== before) this.options.allowedUpdated?.(reserved.state.allowlistRevision);
386
+ }
387
+ } catch {
388
+ this.failPersistence(id, intent.remember ? "allowlist_persist_failed" : "state_persist_failed");
389
+ }
390
+ const flight = this.dispatch(id).finally(() => this.flights.delete(id));
391
+ this.flights.set(id, flight);
392
+ // A storage outage may prevent even a terminal record. Retain the durable reservation, never replay.
393
+ void flight.catch(() => {
394
+ this.failures.set(id, new SecurityRuntimeError("state_persist_failed", this.memory.get(id)?.state));
395
+ for (const resolve of this.waiters.get(id) ?? []) resolve();
396
+ this.waiters.delete(id);
397
+ });
398
+ }
399
+ }
400
+ // New clients receive the durable claim immediately. Legacy waits only for receipt or its bound.
401
+ if (intent.expectedRevision === null && this.record(id)?.state.status === "processing") {
402
+ await new Promise<void>((resolve) => {
403
+ const set = this.waiters.get(id) ?? new Set<() => void>();
404
+ set.add(resolve);
405
+ this.waiters.set(id, set);
406
+ if (this.record(id)?.state.status !== "processing") { set.delete(resolve); resolve(); }
407
+ });
408
+ }
409
+ const failure = this.failures.get(id);
410
+ if (failure) throw failure;
411
+ return this.record(id)?.state ?? null;
412
+ }
413
+
414
+ private failPersistence(id: string, code: "state_persist_failed" | "allowlist_persist_failed", intent?: SecurityIntent): never {
415
+ this.failures.set(id, new SecurityRuntimeError(code, this.memory.get(id)?.state));
416
+ try { this.update(id, { status: "failed", errorCode: code }, undefined, intent); }
417
+ catch { throw new SecurityRuntimeError(code, this.memory.get(id)?.state); }
418
+ const failure = new SecurityRuntimeError(code, this.memory.get(id)?.state);
419
+ this.failures.set(id, failure);
420
+ throw failure;
421
+ }
422
+
423
+ private async dispatch(id: string): Promise<void> {
424
+ const entry = this.held.get(id);
425
+ if (!entry) { this.update(id, { status: "failed", errorCode: "payload_lost" }); return; }
426
+ const timer = setTimeout(() => {
427
+ try {
428
+ if (this.record(id)?.state.status === "processing") this.update(id, { status: "unconfirmed", errorCode: "dispatch_unknown" });
429
+ } catch {
430
+ this.failures.set(id, new SecurityRuntimeError("state_persist_failed"));
431
+ for (const resolve of this.waiters.get(id) ?? []) resolve();
432
+ this.waiters.delete(id);
433
+ }
434
+ }, this.options?.receiptTimeoutMs ?? 30_000);
435
+ timer.unref();
436
+ this.alarms.set(id, timer);
437
+ try {
438
+ await entry.send((receipt) => {
439
+ try { this.update(id, {}, receipt); }
440
+ catch { throw new SecurityRuntimeError("state_persist_failed", this.memory.get(id)?.state); }
441
+ });
442
+ if (!this.options) this.update(id, { status: "sent", errorCode: null });
443
+ else this.reconcile();
444
+ } catch (error) {
445
+ if (this.record(id)?.state.status === "sent") return;
446
+ if (error instanceof SecurityRuntimeError && error.code === "state_persist_failed") {
447
+ this.failPersistence(id, "state_persist_failed");
448
+ }
449
+ const conflict = error instanceof Error && ["decision_conflict", "submission_conflict", "decision_not_found", "invalid_decision_answer"].includes(error.message);
450
+ const unsent = error instanceof SecurityRuntimeError && error.code === "dispatch_failed";
451
+ this.update(id, { status: conflict || unsent ? "failed" : "unconfirmed", errorCode: conflict ? "original_decision_conflict" : unsent ? "dispatch_failed" : "dispatch_unknown" });
452
+ }
453
+ }
454
+
455
+ stop(): void {
456
+ this.unsubscribe?.();
457
+ for (const timer of this.alarms.values()) clearTimeout(timer);
458
+ this.alarms.clear();
459
+ this.held.clear();
190
460
  }
191
461
 
192
462
  /** Removes the entry; "sent" runs its send. null when the id is unknown (already decided, or lost by a restart). */
193
463
  async decide(id: string, send: boolean): Promise<"sent" | "dropped" | null> {
194
- const entry = this.held.get(id);
195
- if (!entry) return null;
196
- this.held.delete(id);
197
- if (!send) return "dropped";
198
- await entry.send();
199
- return "sent";
464
+ if (!this.held.has(id)) return null;
465
+ const state = await this.resolve(id, { send, remember: false, expectedRevision: null, submissionId: null }, { client: "web", deviceId: null });
466
+ return state?.status === "sent" ? "sent" : state?.status === "cancelled" ? "dropped" : null;
200
467
  }
201
468
  }
202
469
 
@@ -221,21 +488,72 @@ export async function handleSecurityCheckSetting(
221
488
  export async function handleHeld(
222
489
  req: Request,
223
490
  url: URL,
224
- guard: Pick<MessageGuard, "list" | "decide">,
491
+ guard: Pick<MessageGuard, "list" | "detail" | "resolve">,
225
492
  resolved: (id: string, outcome: "sent" | "dropped") => void,
493
+ approver: SecurityApprover = { client: "web", deviceId: null },
226
494
  ): Promise<Response | null> {
227
- if (url.pathname === SECURITY_HELD_PATH) return req.method === "GET" ? Response.json({ held: guard.list() }) : null;
228
- if (!url.pathname.startsWith(`${SECURITY_HELD_PATH}/`) || req.method !== "POST") return null;
495
+ const json = (value: unknown, status = 200) => Response.json(value, { status, headers: { "Cache-Control": "no-store" } });
496
+ if (url.pathname === SECURITY_HELD_PATH) {
497
+ try { return req.method === "GET" ? json({ held: guard.list() }) : null; }
498
+ catch { return json({ error: "state_persist_failed" }, 503); }
499
+ }
500
+ if (!url.pathname.startsWith(`${SECURITY_HELD_PATH}/`)) return null;
229
501
  const id = decodeURIComponent(url.pathname.slice(SECURITY_HELD_PATH.length + 1));
230
- const body = (await req.json().catch(() => null)) as { send?: unknown } | null;
231
- if (typeof body?.send !== "boolean") return Response.json({ error: "send_required" }, { status: 400 });
232
- let outcome: "sent" | "dropped" | null;
502
+ if (req.method === "GET") {
503
+ try {
504
+ const detail = guard.detail(id);
505
+ return detail ? json(detail) : json({ error: "held_not_found" }, 404);
506
+ } catch { return json({ error: "state_persist_failed" }, 503); }
507
+ }
508
+ if (req.method !== "POST") return null;
509
+ const body: unknown = await req.json().catch(() => null);
510
+ if (typeof body !== "object" || body === null || !("send" in body) || typeof body.send !== "boolean") return json({ error: "invalid_held_request" }, 400);
511
+ const remember = "remember" in body ? body.remember : undefined;
512
+ if (body.send && remember !== undefined && typeof remember !== "boolean") return json({ error: "invalid_held_request" }, 400);
513
+ const hasRevision = "expectedRevision" in body;
514
+ const hasSubmission = "submissionId" in body;
515
+ const expectedRevision = hasRevision ? body.expectedRevision : null;
516
+ const submissionId = hasSubmission ? body.submissionId : null;
517
+ if (hasRevision !== hasSubmission ||
518
+ (hasRevision && (typeof expectedRevision !== "number" || !Number.isSafeInteger(expectedRevision) || expectedRevision < 1)) ||
519
+ (hasSubmission && (typeof submissionId !== "string" || !/^[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}$/i.test(submissionId)))) return json({ error: "invalid_held_request" }, 400);
520
+ try {
521
+ const state = await guard.resolve(id, {
522
+ send: body.send, remember: body.send && remember === true,
523
+ expectedRevision: typeof expectedRevision === "number" ? expectedRevision : null,
524
+ submissionId: typeof submissionId === "string" ? submissionId : null,
525
+ }, approver);
526
+ if (!state) return json({ error: "held_not_found" }, 404);
527
+ const outcome = state.status === "sent" ? "sent" : state.status === "cancelled" ? "dropped" : null;
528
+ if (outcome) resolved(id, outcome);
529
+ if (state.errorCode === "allowlist_persist_failed" || state.errorCode === "state_persist_failed") return json({ error: state.errorCode, held: state }, 503);
530
+ if (state.errorCode === "dispatch_failed" || state.errorCode === "dispatch_unknown") return json({ error: state.errorCode, held: state }, 502);
531
+ return json({ ...state, ...(outcome ? { outcome } : {}) }, state.status === "processing" ? 202 : 200);
532
+ } catch (error) {
533
+ if (error instanceof SecurityRuntimeError) {
534
+ const status = error.code === "held_conflict" ? 409 : error.code.endsWith("persist_failed") ? 503 : 502;
535
+ return json({ error: error.code, ...(error.held ? { held: error.held } : {}) }, status);
536
+ }
537
+ return json({ error: "state_persist_failed" }, 503);
538
+ }
539
+ }
540
+
541
+ export const SECURITY_ALLOWED_PATH = `${SECURITY_CHECK_PATH}/allowed-values`;
542
+ export async function handleSecurityAllowedValues(req: Request, url: URL, store: Store, publish: (revision: number) => void): Promise<Response | null> {
543
+ if (url.pathname !== SECURITY_ALLOWED_PATH && !url.pathname.startsWith(`${SECURITY_ALLOWED_PATH}/`)) return null;
544
+ const json = (value: unknown, status = 200) => Response.json(value, { status, headers: { "Cache-Control": "no-store" } });
233
545
  try {
234
- outcome = await guard.decide(id, body.send);
546
+ if (req.method === "GET" && url.pathname === SECURITY_ALLOWED_PATH) return json(store.securityAllowedValues());
547
+ if (req.method !== "DELETE") return null;
548
+ const body: unknown = await req.json().catch(() => null);
549
+ if (typeof body !== "object" || body === null || !("expectedRevision" in body) ||
550
+ typeof body.expectedRevision !== "number" || !Number.isSafeInteger(body.expectedRevision) || body.expectedRevision < 0) return json({ error: "invalid_held_request" }, 400);
551
+ const id = url.pathname === SECURITY_ALLOWED_PATH ? undefined : decodeURIComponent(url.pathname.slice(SECURITY_ALLOWED_PATH.length + 1));
552
+ const result = store.deleteSecurityAllowedValues(body.expectedRevision, id);
553
+ if (result.removed > 0) publish(result.revision);
554
+ return json(result);
235
555
  } catch (error) {
236
- return Response.json({ error: "send_failed", message: error instanceof Error ? error.message : String(error) }, { status: 502 });
556
+ if (error instanceof SecurityRuntimeError && error.code === "allowlist_conflict") return json({ error: error.code, revision: error.revision }, 409);
557
+ return json({ error: "allowlist_persist_failed" }, 503);
237
558
  }
238
- if (outcome === null) return Response.json({ error: "held_not_found" }, { status: 404 });
239
- resolved(id, outcome);
240
- return Response.json({ id, outcome });
241
559
  }