zigmini 0.8.0-beta.6 → 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 +90 -19
- package/README.md +91 -19
- package/assets/agent/gateway-request-rules.md +3 -0
- package/assets/agent/gateway-rules.md +6 -0
- 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 +69 -1
- package/docs/manual.md +67 -1
- package/host/omo-bridge/task-control.ts +4 -0
- package/host/omo-check/patches/omo-5.1.35-compaction-idle.patch +77 -0
- package/host/omo-check/patches/omo-5.1.35-compaction-intent.patch +212 -0
- package/host/omo-check/patches/omo-5.1.35-compaction-model.patch +299 -0
- package/host/omo-check/patches/omo-5.1.35-compaction-preservation.patch +1063 -0
- package/host/omo-check/patches/omo-5.1.35-compaction-refusal.patch +83 -0
- package/host/omo-check/patches/omo-5.1.35-gateway-port.manifest.json +169 -7
- package/host/omo-check/patches/omo-5.1.35-request-rules.patch +109 -0
- package/host/omo-check/patches/omo-5.1.35-turn-stats.patch +76 -0
- 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 +4 -1
- package/scripts/secret-rules.ts +101 -0
- package/scripts/secret-scan.allow.json +155 -0
- package/scripts/secret-scan.rules.json +30 -0
- package/src/agent-request-rules.ts +82 -0
- package/src/cli/access.ts +31 -34
- package/src/cli/launchd.ts +30 -4
- package/src/cli/main.ts +3 -43
- package/src/cli/messages.ts +160 -0
- package/src/cli/pair.ts +32 -25
- package/src/cli/setup.ts +164 -75
- package/src/decisions.ts +48 -10
- package/src/engine/compat.json +3 -3
- package/src/hub.ts +183 -47
- package/src/main.ts +60 -4
- package/src/notification-history.ts +56 -0
- package/src/omo/client.ts +28 -0
- package/src/protocol.ts +32 -2
- package/src/reply-notifications.ts +144 -0
- package/src/runtime/paths.ts +2 -0
- package/src/security-allow.ts +126 -0
- package/src/security-check.ts +559 -0
- package/src/store.ts +236 -6
- package/src/turn-stats.ts +49 -0
- package/src/work-watch.ts +36 -10
- package/src/work.ts +45 -6
- package/web/DESIGN.md +39 -0
- package/web/app.js +289 -2
- package/web/decisions.d.ts +1 -1
- package/web/decisions.js +6 -0
- package/web/diagram.d.ts +28 -0
- package/web/diagram.js +228 -0
- package/web/index.html +29 -0
- package/web/notifications.d.ts +25 -0
- package/web/notifications.js +118 -0
- package/web/security-inbox.d.ts +35 -0
- package/web/security-inbox.js +237 -0
- package/web/style.css +43 -1
- package/web/turn-stats.d.ts +26 -0
- package/web/turn-stats.js +58 -0
|
@@ -0,0 +1,559 @@
|
|
|
1
|
+
import { entropy, isAllowed, type AllowEntry, type Rule } from "../scripts/secret-rules.ts";
|
|
2
|
+
import ruleConfig from "../scripts/secret-scan.rules.json";
|
|
3
|
+
import allowConfig from "../scripts/secret-scan.allow.json";
|
|
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";
|
|
9
|
+
|
|
10
|
+
// Improvement 165: every user text bound for the engine passes one security check first (work 779e5b0e).
|
|
11
|
+
// The rules, the allow list and the matching helpers are the repository security check's own (scripts/secret-rules.ts,
|
|
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.
|
|
14
|
+
|
|
15
|
+
export type SecurityRule = Rule;
|
|
16
|
+
export type SecurityAllow = AllowEntry;
|
|
17
|
+
/** One kind of finding and its count; the form that may reach logs. */
|
|
18
|
+
export interface SecurityFinding { readonly kind: string; readonly label: string; readonly count: number }
|
|
19
|
+
/** One matched value and where it is (1-based line and column of text `text`). Only the user's own screens get these. */
|
|
20
|
+
export interface SecurityOccurrence {
|
|
21
|
+
readonly kind: string;
|
|
22
|
+
readonly label: string;
|
|
23
|
+
readonly value: string;
|
|
24
|
+
readonly text: number;
|
|
25
|
+
readonly line: number;
|
|
26
|
+
readonly column: number;
|
|
27
|
+
readonly canonicalValue?: string;
|
|
28
|
+
readonly ruleId?: string;
|
|
29
|
+
readonly start?: number;
|
|
30
|
+
readonly end?: number;
|
|
31
|
+
}
|
|
32
|
+
/** A message held back by the check until the user sends or drops it; kept in gateway memory only. */
|
|
33
|
+
export interface HeldMessage extends HeldState {
|
|
34
|
+
readonly findings: readonly SecurityFinding[];
|
|
35
|
+
readonly occurrences: readonly SecurityOccurrence[];
|
|
36
|
+
/** The user's own text, so the screen can put it back into the composer when the message is dropped or lost. */
|
|
37
|
+
readonly draft?: string;
|
|
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
|
+
}
|
|
54
|
+
|
|
55
|
+
export const SECURITY_CHECK_PATH = "/api/settings/security-check";
|
|
56
|
+
export const SECURITY_BLOCKED = "security_check_blocked";
|
|
57
|
+
export const SECURITY_HELD_PATH = "/api/security-check/held";
|
|
58
|
+
|
|
59
|
+
/** User-facing names of the finding kinds; rule ids without an entry fall back to "기타 보안 값". */
|
|
60
|
+
const KIND_OF_RULE: Readonly<Record<string, string>> = {
|
|
61
|
+
"private-key": "private-key",
|
|
62
|
+
"ssh-public-key": "private-key",
|
|
63
|
+
"github-token": "token", "npm-token": "token", "anthropic-key": "token", "openai-key": "token", "notion-token": "token",
|
|
64
|
+
"slack-token": "token", "slack-webhook": "token", "aws-access-key": "token", "gcp-api-key": "token", "tailscale-key": "token",
|
|
65
|
+
"jwt": "token", "high-entropy-assignment": "token",
|
|
66
|
+
"credential-assignment": "credential", "authorization": "credential", "cookie-header": "credential",
|
|
67
|
+
"user-home-path": "home-path",
|
|
68
|
+
"ipv4": "internal-address", "ipv6-internal": "internal-address", "internal-host": "internal-address", "internal-url": "internal-address",
|
|
69
|
+
"email": "email", "phone-number": "phone",
|
|
70
|
+
"adb-device": "device-id", "device-serial": "device-id",
|
|
71
|
+
};
|
|
72
|
+
export const KIND_LABELS: Readonly<Record<string, string>> = {
|
|
73
|
+
"private-key": "비밀키",
|
|
74
|
+
"token": "토큰·API 키",
|
|
75
|
+
"credential": "비밀번호·인증·쿠키 값",
|
|
76
|
+
"home-path": "홈 폴더 경로",
|
|
77
|
+
"internal-address": "내부 IP·호스트",
|
|
78
|
+
"email": "이메일 주소",
|
|
79
|
+
"phone": "전화번호",
|
|
80
|
+
"device-id": "기기 식별값",
|
|
81
|
+
"other": "기타 보안 값",
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
interface Compiled { readonly id: string; readonly kind: string; readonly regex: RegExp; readonly group: number; readonly minEntropy?: number }
|
|
85
|
+
|
|
86
|
+
/** The repository rules and allow list, as shipped in the package. */
|
|
87
|
+
export function defaultChecker(): SecurityChecker {
|
|
88
|
+
return new SecurityChecker(ruleConfig.rules as Rule[], allowConfig.allow as AllowEntry[]);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Compiles every rule once; scan() reuses the compiled expressions for every message. */
|
|
92
|
+
export class SecurityChecker {
|
|
93
|
+
private readonly rules: readonly Compiled[];
|
|
94
|
+
|
|
95
|
+
constructor(rules: readonly SecurityRule[], private readonly allow: readonly SecurityAllow[] = []) {
|
|
96
|
+
this.rules = rules.map((rule) => ({
|
|
97
|
+
id: rule.id,
|
|
98
|
+
kind: KIND_OF_RULE[rule.id] ?? "other",
|
|
99
|
+
regex: new RegExp(rule.pattern, `${(rule.flags ?? "").replace(/[gyd]/g, "")}dg`),
|
|
100
|
+
group: rule.group ?? 0,
|
|
101
|
+
...(rule.minEntropy === undefined ? {} : { minEntropy: rule.minEntropy }),
|
|
102
|
+
}));
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Counts per kind over all texts, in KIND_LABELS order; empty when nothing matched. */
|
|
106
|
+
scan(texts: readonly string[]): SecurityFinding[] {
|
|
107
|
+
return countFindings(this.occurrences(texts));
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** Every matched value with its place, in text order then rule order. */
|
|
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[] {
|
|
120
|
+
const found: SecurityOccurrence[] = [];
|
|
121
|
+
for (const [index, text] of texts.entries()) {
|
|
122
|
+
if (!text) continue;
|
|
123
|
+
for (const rule of this.rules) {
|
|
124
|
+
const regex = rule.regex;
|
|
125
|
+
regex.lastIndex = 0;
|
|
126
|
+
let match: RegExpExecArray | null;
|
|
127
|
+
while ((match = regex.exec(text)) !== null) {
|
|
128
|
+
if (match[0].length === 0) regex.lastIndex += 1;
|
|
129
|
+
const value = match[rule.group];
|
|
130
|
+
if (!value) continue;
|
|
131
|
+
if (rule.minEntropy !== undefined && entropy(value) < rule.minEntropy) continue;
|
|
132
|
+
if (isAllowed(rule.id, value, this.allow)) continue;
|
|
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;
|
|
136
|
+
const before = text.slice(0, at);
|
|
137
|
+
const line = before.split("\n").length;
|
|
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
|
+
});
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
return found;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export function countFindings(occurrences: readonly SecurityOccurrence[]): SecurityFinding[] {
|
|
150
|
+
const counts = new Map<string, number>();
|
|
151
|
+
for (const occurrence of occurrences) counts.set(occurrence.kind, (counts.get(occurrence.kind) ?? 0) + 1);
|
|
152
|
+
return Object.keys(KIND_LABELS).filter((kind) => counts.has(kind))
|
|
153
|
+
.map((kind) => ({ kind, label: KIND_LABELS[kind]!, count: counts.get(kind)! }));
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** "토큰·API 키 2건, 홈 폴더 경로 1건" */
|
|
157
|
+
export function findingSummary(findings: readonly SecurityFinding[]): string {
|
|
158
|
+
return findings.map((finding) => `${finding.label} ${finding.count}건`).join(", ");
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
export function blockedMessage(findings: readonly SecurityFinding[]): string {
|
|
162
|
+
return `보안검사에 걸려 엔진에 보내지 않고 보류했습니다: ${findingSummary(findings)}.`;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** Thrown for a held decision answer; `held` (with the values) goes only to the user's screen that sent it. */
|
|
166
|
+
export class SecurityCheckError extends Error {
|
|
167
|
+
readonly code = SECURITY_BLOCKED;
|
|
168
|
+
constructor(readonly held: HeldMessage) {
|
|
169
|
+
super(blockedMessage(held.findings));
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* The on/off setting (default on) and the checker. The setting file uses the voice setting format ("off" means off,
|
|
175
|
+
* anything else or a missing file means on); it is read once at start and kept in memory, so a check reads no disk.
|
|
176
|
+
*/
|
|
177
|
+
export class MessageGuard {
|
|
178
|
+
private on: boolean;
|
|
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;
|
|
187
|
+
|
|
188
|
+
/** `onHold` sends each new held entry (with its values) straight to the user's screens; never to omo or the store. */
|
|
189
|
+
constructor(private readonly checker: SecurityChecker, readonly file: string | null, private readonly onHold: (held: HeldMessage) => void = () => {}, private readonly options?: MessageGuardOptions) {
|
|
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
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
enabled(): boolean {
|
|
205
|
+
return this.on;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
set(enabled: boolean): boolean {
|
|
209
|
+
if (this.file !== null) writeVoiceSetting(this.file, enabled);
|
|
210
|
+
this.on = enabled;
|
|
211
|
+
return enabled;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/** Matched values of the texts, or null when the check is off or nothing matched. */
|
|
215
|
+
check(texts: readonly string[]): SecurityOccurrence[] | null {
|
|
216
|
+
if (!this.on) return null;
|
|
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;
|
|
223
|
+
return found.length > 0 ? found : null;
|
|
224
|
+
}
|
|
225
|
+
|
|
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();
|
|
249
|
+
const info: HeldMessage = {
|
|
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 }),
|
|
255
|
+
};
|
|
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) });
|
|
264
|
+
this.onHold(info);
|
|
265
|
+
this.changed(info.id);
|
|
266
|
+
return info;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/** Held when check() finds anything: throws SecurityCheckError with the held entry. */
|
|
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
|
+
}
|
|
277
|
+
const found = this.check(texts);
|
|
278
|
+
if (found) throw new SecurityCheckError(this.hold(conv, source, found, send, undefined, context));
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
list(): HeldMessage[] {
|
|
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();
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
/** Removes the entry; "sent" runs its send. null when the id is unknown (already decided, or lost by a restart). */
|
|
463
|
+
async decide(id: string, send: boolean): Promise<"sent" | "dropped" | null> {
|
|
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;
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
/** GET (both ports) and PUT (trusted web port) of SECURITY_CHECK_PATH. */
|
|
471
|
+
export async function handleSecurityCheckSetting(
|
|
472
|
+
req: Request,
|
|
473
|
+
trusted: boolean,
|
|
474
|
+
guard: Pick<MessageGuard, "enabled" | "set">,
|
|
475
|
+
publish: (enabled: boolean) => void,
|
|
476
|
+
): Promise<Response | null> {
|
|
477
|
+
if (req.method === "GET") return Response.json({ enabled: guard.enabled() });
|
|
478
|
+
if (req.method !== "PUT") return null;
|
|
479
|
+
if (!trusted) return Response.json({ error: "forbidden" }, { status: 403 });
|
|
480
|
+
const body = (await req.json().catch(() => null)) as { enabled?: unknown } | null;
|
|
481
|
+
if (typeof body?.enabled !== "boolean") return Response.json({ error: "enabled_required" }, { status: 400 });
|
|
482
|
+
const enabled = guard.set(body.enabled);
|
|
483
|
+
publish(enabled);
|
|
484
|
+
return Response.json({ enabled });
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
/** GET SECURITY_HELD_PATH lists held messages; POST SECURITY_HELD_PATH/<id> {send} sends or drops one. */
|
|
488
|
+
export async function handleHeld(
|
|
489
|
+
req: Request,
|
|
490
|
+
url: URL,
|
|
491
|
+
guard: Pick<MessageGuard, "list" | "detail" | "resolve">,
|
|
492
|
+
resolved: (id: string, outcome: "sent" | "dropped") => void,
|
|
493
|
+
approver: SecurityApprover = { client: "web", deviceId: null },
|
|
494
|
+
): Promise<Response | 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;
|
|
501
|
+
const id = decodeURIComponent(url.pathname.slice(SECURITY_HELD_PATH.length + 1));
|
|
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" } });
|
|
545
|
+
try {
|
|
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);
|
|
555
|
+
} catch (error) {
|
|
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);
|
|
558
|
+
}
|
|
559
|
+
}
|