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.
- package/README.ko.md +18 -5
- package/README.md +18 -5
- package/assets/agent/gateway-request-rules.md +3 -1
- package/assets/android/zigmini.apk +0 -0
- package/assets/android/zigmini.apk.json +3 -3
- package/assets/build.json +1 -1
- package/docs/manual.ko.md +50 -4
- package/docs/manual.md +48 -4
- package/host/omo-check/patches/omo-5.1.44-compaction-port.patch +2630 -0
- package/host/omo-check/patches/omo-5.1.44-gateway-port.manifest.json +329 -0
- package/host/omo-check/patches/omo-5.1.44-provider-port.patch +440 -0
- package/host/omo-check/patches/omo-5.1.44-rpc-port.patch +778 -0
- package/package.json +1 -1
- package/src/decisions.ts +32 -13
- package/src/engine/compat.json +3 -3
- package/src/hub.ts +57 -31
- package/src/main.ts +22 -7
- package/src/notification-history.ts +56 -0
- package/src/protocol.ts +2 -1
- package/src/security-allow.ts +126 -0
- package/src/security-check.ts +355 -37
- package/src/store.ts +165 -5
- package/src/work.ts +21 -7
- package/web/DESIGN.md +39 -0
- package/web/app.js +198 -69
- package/web/decisions.d.ts +1 -1
- package/web/decisions.js +3 -0
- package/web/diagram.d.ts +28 -0
- package/web/diagram.js +228 -0
- package/web/index.html +13 -0
- package/web/security-inbox.d.ts +35 -0
- package/web/security-inbox.js +237 -0
- package/web/style.css +31 -1
package/src/security-check.ts
CHANGED
|
@@ -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)
|
|
9
|
-
// once
|
|
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(/[
|
|
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:
|
|
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.
|
|
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
|
-
/**
|
|
172
|
-
|
|
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:
|
|
175
|
-
|
|
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.
|
|
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:
|
|
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()].
|
|
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
|
-
|
|
195
|
-
|
|
196
|
-
|
|
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" | "
|
|
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
|
-
|
|
228
|
-
if (
|
|
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
|
-
|
|
231
|
-
|
|
232
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|