@klarkxy/dsh-safe-auto 0.3.0 → 0.5.0
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.md +24 -7
- package/README.zh-CN.md +24 -7
- package/dsh.plugin.json +3 -3
- package/lib/client.js +471 -61
- package/package.json +19 -11
- package/src/audit.js +706 -0
- package/src/index.js +229 -16
- package/src/model-route.js +0 -2
- package/src/reviewer.js +27 -14
- package/src/ui-host.js +9 -0
package/src/audit.js
ADDED
|
@@ -0,0 +1,706 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Audit store for Safe Auto reviews. Owns the `dsh_safe_auto_audit` domain
|
|
3
|
+
* (per-record layout; one row per reviewed approval request) so the runtime
|
|
4
|
+
* and the ui-host history view share one durable log.
|
|
5
|
+
*
|
|
6
|
+
* Design notes:
|
|
7
|
+
* - No delete API and no auto-expire: finish/execution may update a row in
|
|
8
|
+
* place, but history is never removed.
|
|
9
|
+
* - `put` resolves only after durability and `update` is atomic on the
|
|
10
|
+
* domain's write chain, so finish/execution/merge patches via `update`
|
|
11
|
+
* never race; all writes in one domain serialize on its chain anyway.
|
|
12
|
+
* A local `tail` additionally orders begin->prepare->finish->execution.
|
|
13
|
+
* - A row still `pending` when the domain reopens had its review interrupted
|
|
14
|
+
* by a host restart — it is reported as outcome `interrupted`, never as
|
|
15
|
+
* reviewed. Rows begun in this process honestly stay `pending`.
|
|
16
|
+
* - The reviewer's verdict (`outcome`) and the host's native grant
|
|
17
|
+
* (`approvalState`) are two separate facts. Only an observed native
|
|
18
|
+
* decision confirms a grant, so a row that says the reviewer allowed the
|
|
19
|
+
* call but whose grant was never confirmed is reported as `interrupted`
|
|
20
|
+
* too — a grant nobody confirmed must not read as one. The reviewer's own
|
|
21
|
+
* details (risk, authorization, rationale) are preserved either way.
|
|
22
|
+
* - Every persisted free-text field passes through `auditRedact` as a
|
|
23
|
+
* best-effort second line of defence: the caller already redacts, but a
|
|
24
|
+
* schema-less payload must never leak a raw secret to the medium.
|
|
25
|
+
* - Oversize review payloads are never silently truncated: a field over its
|
|
26
|
+
* bound is replaced by an explicit marker and the redacted snapshot (up to
|
|
27
|
+
* 1 MiB, sha256-tagged) lands in `capture`.
|
|
28
|
+
*/
|
|
29
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
30
|
+
import { defineDomain, domainTable } from '@deepseek-ai/dsh-storage-domain';
|
|
31
|
+
import { z } from 'zod';
|
|
32
|
+
|
|
33
|
+
export const AUDIT_DOMAIN_NAME = 'dsh_safe_auto_audit';
|
|
34
|
+
export const AUDIT_DOMAIN_VERSION = 1;
|
|
35
|
+
|
|
36
|
+
const MAX_ID = 128;
|
|
37
|
+
const MAX_KEY = 256; // sessionId/agentId/callId
|
|
38
|
+
const MAX_SHORT = 256; // tool/code/risk/authorization/…
|
|
39
|
+
const MAX_LONG = 320 * 1024; // action/environment/input/policy (~320 KiB)
|
|
40
|
+
const MAX_TEXT = 64 * 1024; // rationale/reason/error free text
|
|
41
|
+
const MAX_CAPTURE = 1024 * 1024; // capture snapshot payload
|
|
42
|
+
const MAX_CAPTURE_FIELD = MAX_CAPTURE + 8192; // snapshot + truncation marker
|
|
43
|
+
const MAX_JSON_DEPTH = 6; // nested JSON payloads are parsed this deep
|
|
44
|
+
const MAX_ATTEMPTS = 32;
|
|
45
|
+
const MAX_ATTEMPT_TEXT = MAX_LONG;
|
|
46
|
+
const DEFAULT_LIMIT = 25;
|
|
47
|
+
const MAX_LIMIT = 100;
|
|
48
|
+
const MAX_QUERY = 256;
|
|
49
|
+
const MAX_CURSOR = 512;
|
|
50
|
+
|
|
51
|
+
const bounded = max => z.string().max(max);
|
|
52
|
+
const isoString = () => z.iso.datetime({ offset: true });
|
|
53
|
+
|
|
54
|
+
const triggerSchema = z.object({
|
|
55
|
+
kind: z.enum(['dangerous-command', 'sandbox-escalation', 'approval']),
|
|
56
|
+
rule: bounded(MAX_SHORT).optional(),
|
|
57
|
+
reason: bounded(MAX_TEXT),
|
|
58
|
+
}).strict();
|
|
59
|
+
|
|
60
|
+
const reviewerSchema = z.object({
|
|
61
|
+
provider: bounded(4096).optional(),
|
|
62
|
+
model: bounded(4096).optional(),
|
|
63
|
+
reasoningEffort: bounded(4096).optional(),
|
|
64
|
+
}).strict();
|
|
65
|
+
|
|
66
|
+
const settingsSchema = z.object({
|
|
67
|
+
timeoutMs: z.number().int().nonnegative().optional(),
|
|
68
|
+
outputTokens: z.number().int().nonnegative().optional(),
|
|
69
|
+
maxTranscriptBytes: z.number().int().nonnegative().optional(),
|
|
70
|
+
reviewerPrompt: bounded(MAX_TEXT).optional(),
|
|
71
|
+
}).strict();
|
|
72
|
+
|
|
73
|
+
const attemptSchema = z.object({
|
|
74
|
+
attempt: z.number().int().nonnegative(),
|
|
75
|
+
startedAt: bounded(64),
|
|
76
|
+
finishedAt: bounded(64),
|
|
77
|
+
response: bounded(MAX_ATTEMPT_TEXT).optional(), // raw reviewer reply, redacted
|
|
78
|
+
error: bounded(MAX_TEXT).optional(),
|
|
79
|
+
}).strict();
|
|
80
|
+
|
|
81
|
+
const executionSchema = z.object({
|
|
82
|
+
state: z.enum(['not-observed', 'succeeded', 'failed']),
|
|
83
|
+
completedAt: bounded(64).optional(),
|
|
84
|
+
error: bounded(MAX_TEXT).optional(),
|
|
85
|
+
}).strict();
|
|
86
|
+
|
|
87
|
+
/* The observed native approval decision, separate from the reviewer's verdict. */
|
|
88
|
+
export const APPROVAL_STATES = ['not-observed', 'allowed-once', 'rejected', 'cancelled', 'unavailable'];
|
|
89
|
+
const CONFIRMED_APPROVALS = new Set(['allowed-once', 'rejected', 'cancelled']);
|
|
90
|
+
|
|
91
|
+
const recordSchema = z.object({
|
|
92
|
+
id: bounded(MAX_ID),
|
|
93
|
+
sessionId: bounded(MAX_KEY),
|
|
94
|
+
agentId: bounded(MAX_KEY),
|
|
95
|
+
callId: bounded(MAX_KEY),
|
|
96
|
+
tool: bounded(MAX_SHORT),
|
|
97
|
+
turn: z.number().int().nullable(),
|
|
98
|
+
trigger: triggerSchema,
|
|
99
|
+
// Optional at begin(): the review input/environment/reviewer may not be
|
|
100
|
+
// computed yet — begin persists as early as possible, prepare fills them in.
|
|
101
|
+
action: bounded(MAX_LONG).optional(), // redacted JSON string
|
|
102
|
+
environment: bounded(MAX_LONG).optional(), // redacted JSON string
|
|
103
|
+
input: bounded(MAX_LONG).optional(), // redacted review prompt
|
|
104
|
+
policy: bounded(MAX_LONG).optional(), // system policy text
|
|
105
|
+
reviewer: reviewerSchema.optional(),
|
|
106
|
+
settings: settingsSchema.optional(),
|
|
107
|
+
capture: bounded(MAX_CAPTURE_FIELD).optional(),
|
|
108
|
+
attempts: z.array(attemptSchema).max(MAX_ATTEMPTS).optional(),
|
|
109
|
+
status: z.enum(['pending', 'finished']),
|
|
110
|
+
outcome: z.enum(['pending', 'allowed-once', 'rejected', 'cancelled']),
|
|
111
|
+
// The host's native approval decision, observed after the review. Absent on
|
|
112
|
+
// rows written before this field existed; 'not-observed' means the decision
|
|
113
|
+
// has not been seen (yet).
|
|
114
|
+
approvalState: z.enum(APPROVAL_STATES).optional(),
|
|
115
|
+
code: bounded(MAX_SHORT).optional(),
|
|
116
|
+
risk: bounded(MAX_SHORT).optional(),
|
|
117
|
+
authorization: bounded(MAX_SHORT).optional(), // grant scope, not a credential
|
|
118
|
+
rationale: bounded(MAX_TEXT).optional(),
|
|
119
|
+
durationMs: z.number().nonnegative().optional(),
|
|
120
|
+
startedAt: bounded(64),
|
|
121
|
+
finishedAt: bounded(64).optional(),
|
|
122
|
+
execution: executionSchema,
|
|
123
|
+
}).strict();
|
|
124
|
+
|
|
125
|
+
export const auditDomain = defineDomain({
|
|
126
|
+
name: AUDIT_DOMAIN_NAME,
|
|
127
|
+
version: AUDIT_DOMAIN_VERSION,
|
|
128
|
+
layout: 'per-record',
|
|
129
|
+
tables: { records: domainTable(recordSchema) },
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
/* ------------------------------------------------------------------ *
|
|
133
|
+
* Best-effort audit redaction. Best-effort means: known credential shapes *
|
|
134
|
+
* (sensitive keys, bearer/basic headers, URL credentials, common token *
|
|
135
|
+
* formats, PEM blocks, JWTs) are masked; an unknown secret shape can *
|
|
136
|
+
* survive — the caller's primary redaction stays responsible. *
|
|
137
|
+
* *
|
|
138
|
+
* A key is sensitive when its normalized name matches the list below *
|
|
139
|
+
* (lowercased, `[-_]` folded) or when its word segments name a credential *
|
|
140
|
+
* (`GITHUB_TOKEN`, `DB_PASSWORD`, `openaiApiKey`). Substring matching is *
|
|
141
|
+
* wrong: `outputTokens` is numeric review metadata, not a credential. The *
|
|
142
|
+
* record's own `authorization` field (reviewer grant scope 'high'/'turn') is *
|
|
143
|
+
* kept verbatim by the store, but this redactor does not exempt the key — a *
|
|
144
|
+
* caller that passes a nested `headers.Authorization` must still see it *
|
|
145
|
+
* masked; the store restores the top-level record field after redaction. *
|
|
146
|
+
* ------------------------------------------------------------------ */
|
|
147
|
+
const SENSITIVE_KEYS = new Set([
|
|
148
|
+
'password', 'passwd', 'pwd', 'passphrase', 'secret', 'apikey', 'apisecret',
|
|
149
|
+
'token', 'accesstoken', 'authtoken', 'refreshtoken', 'idtoken', 'bearertoken',
|
|
150
|
+
'credential', 'credentials', 'cookie', 'sessionkey',
|
|
151
|
+
'privatekey', 'clientsecret', 'accesskey', 'secretkey', 'signingkey', 'sshkey',
|
|
152
|
+
'authorization', 'xapikey', 'proxyauthorization', 'wwwauthenticate',
|
|
153
|
+
]);
|
|
154
|
+
const normalizeKey = key => key.toLowerCase().replace(/[-_\s]/g, '');
|
|
155
|
+
const REDACTED = '[REDACTED]';
|
|
156
|
+
|
|
157
|
+
/*
|
|
158
|
+
* Credential words. A key names a credential when one of these words appears
|
|
159
|
+
* in it as a whole word: `GITHUB_TOKEN`, `DB_PASSWORD`, `X-Custom-Token` and
|
|
160
|
+
* `sessionSecret` all carry one. Substring matching would be wrong in both
|
|
161
|
+
* directions — `outputTokens` is numeric review metadata, not a credential,
|
|
162
|
+
* and `secretion` is not a secret — so the rules work on whole words.
|
|
163
|
+
*/
|
|
164
|
+
const SECRET_WORDS = new Set([
|
|
165
|
+
'password', 'passwd', 'pwd', 'passphrase', 'secret', 'token', 'apikey',
|
|
166
|
+
'credential', 'credentials', 'cookie', 'authorization', 'authenticate',
|
|
167
|
+
]);
|
|
168
|
+
/*
|
|
169
|
+
* `key` alone is too broad — `sortKey`, `primaryKey` and `cacheKey` are not
|
|
170
|
+
* credentials — so it only counts beside a qualifier that names a credential
|
|
171
|
+
* holder: `AWS_SECRET_ACCESS_KEY`, `OPENAI_API_KEY`, `PRIVATE_KEY`.
|
|
172
|
+
*/
|
|
173
|
+
const KEY_QUALIFIERS = new Set([
|
|
174
|
+
'secret', 'private', 'access', 'api', 'session', 'client', 'server', 'signing',
|
|
175
|
+
'ssh', 'encryption', 'master', 'auth', 'credential', 'account', 'personal',
|
|
176
|
+
'user', 'app', 'service', 'storage', 'database', 'db', 'aws', 'azure', 'gcp',
|
|
177
|
+
'provider', 'bearer', 'refresh',
|
|
178
|
+
]);
|
|
179
|
+
|
|
180
|
+
/** Split a key into lowercased words across separators and camel-case runs. */
|
|
181
|
+
const keyWords = key => String(key)
|
|
182
|
+
.split(/[^A-Za-z0-9]+/)
|
|
183
|
+
.filter(Boolean)
|
|
184
|
+
.flatMap(part => part.match(/[A-Z]{2,}(?=[A-Z][a-z]|$)|[A-Z]?[a-z]+|[A-Z]+|[0-9]+/g) ?? [])
|
|
185
|
+
.map(word => word.toLowerCase());
|
|
186
|
+
|
|
187
|
+
/** True when the key names a credential, by exact name or by its word segments. */
|
|
188
|
+
const isSensitiveKey = key => {
|
|
189
|
+
if (SENSITIVE_KEYS.has(normalizeKey(key))) return true;
|
|
190
|
+
const words = keyWords(key);
|
|
191
|
+
if (words.some(word => SECRET_WORDS.has(word))) return true;
|
|
192
|
+
return words.includes('key') && words.some(word => KEY_QUALIFIERS.has(word));
|
|
193
|
+
};
|
|
194
|
+
|
|
195
|
+
function redactString(text) {
|
|
196
|
+
if (typeof text !== 'string' || text.length === 0) return text;
|
|
197
|
+
let out = text;
|
|
198
|
+
// Authorization / proxy headers and bearer-style credentials.
|
|
199
|
+
out = out.replace(/\b(bearer|basic|digest|negotiate)\s+[A-Za-z0-9._~+/=-]{4,}/gi, '$1 ' + REDACTED);
|
|
200
|
+
// Credential key=value pairs, including JSON-quoted keys ("apiKey":"v"):
|
|
201
|
+
// the key name is checked against the credential rules, so 'outputTokens'
|
|
202
|
+
// and ordinary keys survive while 'token'/'apiKey'/'GITHUB_TOKEN' mask.
|
|
203
|
+
// The unquoted value is bounded by its delimiters only — a capped value
|
|
204
|
+
// would mask just a prefix and leave the rest of the secret in the text.
|
|
205
|
+
out = out.replace(/("?)([A-Za-z][A-Za-z0-9_-]{0,60})\1\s*[:=]\s*("((?:\\.|[^"\\])*)"|'((?:\\.|[^'\\])*)'|[^\s,;&"']+)/g,
|
|
206
|
+
(m, _q, key, _val, dq, sq) => {
|
|
207
|
+
if (!isSensitiveKey(key)) return m;
|
|
208
|
+
if (dq !== undefined) return `"${key}":"${REDACTED}"`;
|
|
209
|
+
if (sq !== undefined) return `'${key}':'${REDACTED}'`;
|
|
210
|
+
return `${key}=${REDACTED}`;
|
|
211
|
+
});
|
|
212
|
+
// URL userinfo: scheme://user:pass@host
|
|
213
|
+
out = out.replace(/([a-zA-Z][a-zA-Z0-9+.-]{1,15}:\/\/)([^/\s:@?#]+):([^/\s@?#]+)@/g, `$1${REDACTED}@`);
|
|
214
|
+
// PEM blocks.
|
|
215
|
+
out = out.replace(/-----BEGIN [A-Z0-9 ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z0-9 ]*PRIVATE KEY-----/g, REDACTED);
|
|
216
|
+
// OpenAI / Anthropic / GitHub / Slack / AWS / Google / JWT-shaped tokens.
|
|
217
|
+
out = out.replace(/\bsk-(?:proj-|ant-)?[A-Za-z0-9_-]{8,}\b/g, REDACTED);
|
|
218
|
+
out = out.replace(/\b(?:ghp|gho|ghu|ghs|ghr|github_pat|xox[baprs]|glpat|dop_v1|npm|pypi|hf|AIza|ya29|AKIA|ASIA)[A-Za-z0-9_-]{6,}\b/g, REDACTED);
|
|
219
|
+
out = out.replace(/\beyJ[A-Za-z0-9_-]{4,}\.[A-Za-z0-9_-]{4,}\.[A-Za-z0-9_-]{4,}\b/g, REDACTED);
|
|
220
|
+
return out;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* Best-effort recursive redaction for audit-facing data. Strings are scanned
|
|
225
|
+
* for credential shapes; objects mask values of sensitive keys. Cycle-safe.
|
|
226
|
+
* Exported so the caller can pre-redact records before persistence.
|
|
227
|
+
*/
|
|
228
|
+
export function auditRedact(value, depth = 0, seen = new WeakSet()) {
|
|
229
|
+
if (value === null || value === undefined) return value;
|
|
230
|
+
if (typeof value === 'string') {
|
|
231
|
+
// Whole-string JSON payloads (action/environment/input are serialized
|
|
232
|
+
// JSON) get structural redaction so escaped secrets like "apiKey":"a\b"
|
|
233
|
+
// are masked by key, not just by regex. A nested JSON string is parsed
|
|
234
|
+
// too: the regex cannot see a credential that sits inside a quoted
|
|
235
|
+
// value of a non-sensitive key ("command":"token=…"), so the payload is
|
|
236
|
+
// masked by key instead. The depth cap keeps the expansion bounded.
|
|
237
|
+
if (depth <= MAX_JSON_DEPTH && (value.startsWith('{') || value.startsWith('['))) {
|
|
238
|
+
try {
|
|
239
|
+
const parsed = JSON.parse(value);
|
|
240
|
+
if (parsed && typeof parsed === 'object') {
|
|
241
|
+
return JSON.stringify(auditRedact(parsed, depth + 1, seen));
|
|
242
|
+
}
|
|
243
|
+
} catch { /* not JSON — fall through to regex pass */ }
|
|
244
|
+
}
|
|
245
|
+
return redactString(value);
|
|
246
|
+
}
|
|
247
|
+
if (typeof value !== 'object') return value;
|
|
248
|
+
if (depth > 24 || seen.has(value)) return REDACTED;
|
|
249
|
+
seen.add(value);
|
|
250
|
+
if (Array.isArray(value)) return value.map(item => auditRedact(item, depth + 1, seen));
|
|
251
|
+
const out = {};
|
|
252
|
+
for (const [key, item] of Object.entries(value)) {
|
|
253
|
+
if (isSensitiveKey(key) && item !== null && item !== undefined) { out[key] = REDACTED; continue; }
|
|
254
|
+
out[key] = auditRedact(item, depth + 1, seen);
|
|
255
|
+
}
|
|
256
|
+
return out;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* Redact a record patch while preserving the record's own `authorization`
|
|
261
|
+
* field — the reviewer grant scope, not a credential. Nested authorization
|
|
262
|
+
* headers inside free-text fields are still masked by redactString.
|
|
263
|
+
*/
|
|
264
|
+
function redactPatch(patch) {
|
|
265
|
+
const authorization = patch.authorization;
|
|
266
|
+
const out = auditRedact(patch);
|
|
267
|
+
if (authorization !== undefined) out.authorization = authorization;
|
|
268
|
+
return out;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* Serialize an arbitrary capture value to a bounded redacted snapshot string.
|
|
273
|
+
* Strings pass through as-is; objects are JSON-serialized; unserializable
|
|
274
|
+
* values degrade to a placeholder. Snapshots over `maxBytes` are truncated
|
|
275
|
+
* with an explicit `[truncated … sha256=…]` marker — never silently.
|
|
276
|
+
* @returns {string|undefined}
|
|
277
|
+
*/
|
|
278
|
+
export function auditedCapture(value, maxBytes = MAX_CAPTURE) {
|
|
279
|
+
if (value === undefined) return undefined;
|
|
280
|
+
let text;
|
|
281
|
+
if (typeof value === 'string') text = value;
|
|
282
|
+
else {
|
|
283
|
+
try { text = JSON.stringify(auditRedact(value)); }
|
|
284
|
+
catch { text = '[unserializable capture]'; }
|
|
285
|
+
}
|
|
286
|
+
text = redactString(text);
|
|
287
|
+
const bytes = Buffer.byteLength(text, 'utf8');
|
|
288
|
+
if (bytes <= maxBytes) return text;
|
|
289
|
+
const sha256 = createHash('sha256').update(text, 'utf8').digest('hex');
|
|
290
|
+
const marker = `\n…[audit capture truncated: original ${bytes} bytes, sha256=${sha256}]`;
|
|
291
|
+
// Byte-bounded truncation at a code-point boundary: slice the UTF-8 buffer,
|
|
292
|
+
// then back off at most 3 bytes — UTF-8 continuation bytes share the
|
|
293
|
+
// 10xxxxxx pattern, so landing mid-sequence is detected cheaply without a
|
|
294
|
+
// per-character O(n²) rescan. A partial surrogate pair at the cut is also
|
|
295
|
+
// impossible here because the boundary is a whole code point.
|
|
296
|
+
const budget = maxBytes - Buffer.byteLength(marker, 'utf8');
|
|
297
|
+
const buf = Buffer.from(text, 'utf8');
|
|
298
|
+
let end = Math.min(buf.length, Math.max(0, budget));
|
|
299
|
+
// The cut splits a code point when the byte AT the cut is a UTF-8
|
|
300
|
+
// continuation (0b10xxxxxx). Walk back over the whole incomplete sequence —
|
|
301
|
+
// its lead byte is 1–4 bytes earlier (0b110xxxxx 2B / 0b1110xxxx 3B /
|
|
302
|
+
// 0b11110xxx 4B) — so the surviving prefix ends on a complete code point
|
|
303
|
+
// and no U+FFFD / stray surrogate is emitted by toString.
|
|
304
|
+
if (end < buf.length && (buf[end] & 0b1100_0000) === 0b1000_0000) {
|
|
305
|
+
// Walk back over the continuation bytes; `end` lands on the sequence's
|
|
306
|
+
// lead byte index and subarray(0, end) excludes the incomplete sequence.
|
|
307
|
+
while (end > 0 && (buf[end] & 0b1100_0000) === 0b1000_0000) end--;
|
|
308
|
+
}
|
|
309
|
+
return buf.subarray(0, Math.max(0, end)).toString('utf8') + marker;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/* ------------------------------------------------ validation helpers */
|
|
313
|
+
const invalid = message => { const e = new Error(message); e.code = 'AUDIT_INVALID'; return e; };
|
|
314
|
+
|
|
315
|
+
function boundedString(value, field, max) {
|
|
316
|
+
if (value === undefined) return undefined;
|
|
317
|
+
if (typeof value !== 'string' || value.length > max) throw invalid(`audit: ${field} must be a string of at most ${max} chars`);
|
|
318
|
+
return value;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
const listPayloadSchema = z.object({
|
|
322
|
+
sessionId: bounded(MAX_SHORT).optional(),
|
|
323
|
+
tool: bounded(MAX_SHORT).optional(),
|
|
324
|
+
outcome: z.enum(['pending', 'interrupted', 'allowed-once', 'rejected', 'cancelled']).optional(),
|
|
325
|
+
risk: bounded(MAX_SHORT).optional(),
|
|
326
|
+
from: isoString().optional(),
|
|
327
|
+
to: isoString().optional(),
|
|
328
|
+
query: bounded(MAX_QUERY).optional(),
|
|
329
|
+
limit: z.number().int().min(1).max(MAX_LIMIT).optional(),
|
|
330
|
+
cursor: bounded(MAX_CURSOR).optional(),
|
|
331
|
+
}).strict();
|
|
332
|
+
|
|
333
|
+
function encodeCursor(row) {
|
|
334
|
+
return Buffer.from(JSON.stringify([row.startedAt, row.id]), 'utf8').toString('base64url');
|
|
335
|
+
}
|
|
336
|
+
function decodeCursor(cursor) {
|
|
337
|
+
let parsed;
|
|
338
|
+
try { parsed = JSON.parse(Buffer.from(cursor, 'base64url').toString('utf8')); }
|
|
339
|
+
catch { throw invalid('audit: invalid cursor'); }
|
|
340
|
+
if (!Array.isArray(parsed) || parsed.length !== 2
|
|
341
|
+
|| typeof parsed[0] !== 'string' || typeof parsed[1] !== 'string'
|
|
342
|
+
|| parsed[0].length > 64 || parsed[1].length > MAX_ID) throw invalid('audit: invalid cursor');
|
|
343
|
+
return { startedAt: parsed[0], id: parsed[1] };
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
/* Stable ordering: startedAt desc, id desc. */
|
|
347
|
+
const byStartedDesc = (a, b) => a.startedAt === b.startedAt
|
|
348
|
+
? (a.id < b.id ? 1 : -1)
|
|
349
|
+
: (a.startedAt < b.startedAt ? 1 : -1);
|
|
350
|
+
const afterCursor = (row, c) => row.startedAt < c.startedAt
|
|
351
|
+
|| (row.startedAt === c.startedAt && row.id < c.id);
|
|
352
|
+
|
|
353
|
+
/** Fields kept out of list summaries: the heavy context/action payloads. */
|
|
354
|
+
const HEAVY = new Set(['action', 'environment', 'input', 'policy', 'capture', 'attempts', 'settings']);
|
|
355
|
+
|
|
356
|
+
/* Fields that may carry oversize content moved into capture. */
|
|
357
|
+
const OVERSIZE_FIELDS = new Set(['action', 'environment', 'input', 'policy']);
|
|
358
|
+
|
|
359
|
+
/**
|
|
360
|
+
* Apply oversize handling to a patch before persistence: any long field over
|
|
361
|
+
* its bound is replaced by an explicit marker and its redacted snapshot (up
|
|
362
|
+
* to 1 MiB, sha256-tagged) is appended to `capture`.
|
|
363
|
+
*/
|
|
364
|
+
function foldOversize(patch) {
|
|
365
|
+
const captures = [];
|
|
366
|
+
const next = { ...patch };
|
|
367
|
+
for (const field of OVERSIZE_FIELDS) {
|
|
368
|
+
const value = next[field];
|
|
369
|
+
if (typeof value !== 'string' || Buffer.byteLength(value, 'utf8') <= MAX_LONG) continue;
|
|
370
|
+
const bytes = Buffer.byteLength(value, 'utf8');
|
|
371
|
+
const sha256 = createHash('sha256').update(value, 'utf8').digest('hex');
|
|
372
|
+
captures.push(`--- ${field} (${bytes} bytes, sha256=${sha256}) ---\n${auditedCapture(value)}`);
|
|
373
|
+
next[field] = `[${field}: ${bytes} bytes over the ${MAX_LONG}-byte audit field limit; redacted snapshot in capture, sha256=${sha256}]`;
|
|
374
|
+
}
|
|
375
|
+
if (captures.length > 0) {
|
|
376
|
+
const prior = typeof next.capture === 'string' && next.capture.length > 0 ? `${next.capture}\n` : '';
|
|
377
|
+
next.capture = auditedCapture(prior + captures.join('\n'), MAX_CAPTURE_FIELD);
|
|
378
|
+
}
|
|
379
|
+
return next;
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
/**
|
|
383
|
+
* Open the audit store over the given storageDomain service.
|
|
384
|
+
* Minimal table contract the store relies on (see test/audit.test.js for a
|
|
385
|
+
* fake): `get(key)` sync read; `entries()` sync snapshot iterator;
|
|
386
|
+
* `put(key, row)` resolves after durability (a rejection leaves reads
|
|
387
|
+
* unchanged); `update(key, fn)` is an atomic serialized read-modify-write
|
|
388
|
+
* that rejects when the key is missing; `close()` drains queued writes.
|
|
389
|
+
* @param {{open: (spec: typeof auditDomain) => Promise<{table: (name: string) => object, close: () => Promise<void>}>}} storageDomain
|
|
390
|
+
*/
|
|
391
|
+
export async function openAudit(storageDomain) {
|
|
392
|
+
if (!storageDomain || typeof storageDomain.open !== 'function') throw invalid('audit: storageDomain.open is required');
|
|
393
|
+
const domain = await storageDomain.open(auditDomain);
|
|
394
|
+
const table = domain.table('records');
|
|
395
|
+
// Rows unfinished when the domain reopened: their review never resolved
|
|
396
|
+
// (host restart mid-review). Report them interrupted, never as reviewed.
|
|
397
|
+
const interrupted = new Set();
|
|
398
|
+
// Rows stored before this process opened the domain whose reviewer verdict
|
|
399
|
+
// was a grant: no native decision confirmed them here, so they must not
|
|
400
|
+
// read as a confirmed grant once the domain is reopened.
|
|
401
|
+
const unconfirmedGrants = new Set();
|
|
402
|
+
for (const [id, row] of table.entries()) {
|
|
403
|
+
if (row?.outcome === 'pending') interrupted.add(id);
|
|
404
|
+
else if (row?.outcome === 'allowed-once' && !CONFIRMED_APPROVALS.has(row.approvalState)) unconfirmedGrants.add(id);
|
|
405
|
+
}
|
|
406
|
+
// Local write tail keeps begin/prepare/finish/execution strictly ordered
|
|
407
|
+
// even under concurrent callers; the domain chain serializes durability.
|
|
408
|
+
let tail = Promise.resolve();
|
|
409
|
+
const enqueue = op => {
|
|
410
|
+
const run = tail.then(op, op);
|
|
411
|
+
tail = run.then(() => {}, () => {});
|
|
412
|
+
return run;
|
|
413
|
+
};
|
|
414
|
+
/*
|
|
415
|
+
* The outcome a reader sees. Two facts are kept apart: `outcome` is the
|
|
416
|
+
* reviewer's verdict, `approvalState` is the native decision the host
|
|
417
|
+
* reported. A pending row that was still unfinished when the domain reopened
|
|
418
|
+
* never resolved ('interrupted'). A grant is only effective when the host
|
|
419
|
+
* confirmed it: an observed rejection or cancellation stands, and a grant
|
|
420
|
+
* nobody confirmed is reported as interrupted. The reviewer's own details
|
|
421
|
+
* (code, risk, authorization, rationale) are preserved either way.
|
|
422
|
+
*/
|
|
423
|
+
const reportedOutcome = row => {
|
|
424
|
+
if (row.outcome === 'pending') return interrupted.has(row.id) ? 'interrupted' : 'pending';
|
|
425
|
+
if (row.outcome !== 'allowed-once') return row.outcome;
|
|
426
|
+
if (row.approvalState === 'allowed-once') return 'allowed-once';
|
|
427
|
+
if (row.approvalState === 'rejected' || row.approvalState === 'cancelled') return row.approvalState;
|
|
428
|
+
return unconfirmedGrants.has(row.id) ? 'interrupted' : 'allowed-once';
|
|
429
|
+
};
|
|
430
|
+
/** Apply the reported outcome and its marker to a copy of the record. */
|
|
431
|
+
const decorate = (row, out) => {
|
|
432
|
+
const outcome = reportedOutcome(row);
|
|
433
|
+
if (outcome !== row.outcome) {
|
|
434
|
+
out.outcome = outcome;
|
|
435
|
+
// The reviewer's own verdict stays visible next to the reported one.
|
|
436
|
+
out.reviewOutcome = row.outcome;
|
|
437
|
+
}
|
|
438
|
+
if (row.outcome === 'pending' && interrupted.has(row.id)) out.interrupted = true;
|
|
439
|
+
else if (outcome === 'interrupted' && row.outcome === 'allowed-once') out.unconfirmed = true;
|
|
440
|
+
return out;
|
|
441
|
+
};
|
|
442
|
+
const view = row => {
|
|
443
|
+
if (row === undefined) return undefined;
|
|
444
|
+
// Deep copy: callers must never mutate the authoritative in-memory row.
|
|
445
|
+
const next = { ...structuredClone(row), executionState: row.execution?.state ?? 'not-observed' };
|
|
446
|
+
return decorate(row, next);
|
|
447
|
+
};
|
|
448
|
+
/*
|
|
449
|
+
* List summary. The heavy payloads (action/environment/input/policy/capture/
|
|
450
|
+
* attempts/settings) are dropped from a summary anyway, so only the
|
|
451
|
+
* lightweight fields are collected and copied — cloning the whole row would
|
|
452
|
+
* copy the entire history on every page. The collected fields are copied
|
|
453
|
+
* once, so a caller cannot reach the authoritative row through a summary.
|
|
454
|
+
*/
|
|
455
|
+
const summary = row => {
|
|
456
|
+
const light = {};
|
|
457
|
+
for (const [key, value] of Object.entries(row)) if (!HEAVY.has(key)) light[key] = value;
|
|
458
|
+
const out = structuredClone(light);
|
|
459
|
+
out.executionState = row.execution?.state ?? 'not-observed';
|
|
460
|
+
decorate(row, out);
|
|
461
|
+
return out;
|
|
462
|
+
};
|
|
463
|
+
/*
|
|
464
|
+
* Free-text search over the record's fields, in full — the heavy fields are
|
|
465
|
+
* searched as they are stored, never truncated. Each field is searched on
|
|
466
|
+
* its own and the scan short-circuits on the first hit, so a query costs
|
|
467
|
+
* one pass per field instead of one concatenated haystack.
|
|
468
|
+
*/
|
|
469
|
+
const SEARCH_VALUES = row => [row.tool, row.callId, row.sessionId, row.agentId, row.trigger?.reason,
|
|
470
|
+
row.code, row.rationale, row.risk, row.outcome, row.action, row.input];
|
|
471
|
+
const matchesNeedle = (row, needle) => {
|
|
472
|
+
for (const value of SEARCH_VALUES(row)) {
|
|
473
|
+
if (typeof value === 'string' && value.toLowerCase().includes(needle)) return true;
|
|
474
|
+
}
|
|
475
|
+
return false;
|
|
476
|
+
};
|
|
477
|
+
|
|
478
|
+
const validateAttempts = attempts => {
|
|
479
|
+
if (!Array.isArray(attempts) || attempts.length > MAX_ATTEMPTS) throw invalid('audit: attempts must be a bounded array');
|
|
480
|
+
for (const a of attempts) {
|
|
481
|
+
if (!a || typeof a !== 'object' || !Number.isInteger(a.attempt) || a.attempt < 0) throw invalid('audit: malformed attempt');
|
|
482
|
+
boundedString(a.startedAt, 'attempts.startedAt', 64);
|
|
483
|
+
boundedString(a.finishedAt, 'attempts.finishedAt', 64);
|
|
484
|
+
boundedString(a.response, 'attempts.response', MAX_ATTEMPT_TEXT);
|
|
485
|
+
boundedString(a.error, 'attempts.error', MAX_TEXT);
|
|
486
|
+
}
|
|
487
|
+
};
|
|
488
|
+
const validatePatch = patch => {
|
|
489
|
+
if (patch.code !== undefined) boundedString(patch.code, 'code', MAX_SHORT);
|
|
490
|
+
if (patch.risk !== undefined) boundedString(patch.risk, 'risk', MAX_SHORT);
|
|
491
|
+
if (patch.authorization !== undefined) boundedString(patch.authorization, 'authorization', MAX_SHORT);
|
|
492
|
+
if (patch.rationale !== undefined) boundedString(patch.rationale, 'rationale', MAX_TEXT);
|
|
493
|
+
if (patch.durationMs !== undefined && (typeof patch.durationMs !== 'number' || !(patch.durationMs >= 0))) {
|
|
494
|
+
throw invalid('audit: durationMs must be a non-negative number');
|
|
495
|
+
}
|
|
496
|
+
if (patch.attempts !== undefined) validateAttempts(patch.attempts);
|
|
497
|
+
// Oversize long fields are folded into capture before the bound check so
|
|
498
|
+
// the check applies to the explicit marker that remains.
|
|
499
|
+
for (const field of OVERSIZE_FIELDS) {
|
|
500
|
+
if (patch[field] !== undefined && typeof patch[field] !== 'string') throw invalid(`audit: ${field} must be a string`);
|
|
501
|
+
}
|
|
502
|
+
if (patch.capture !== undefined && typeof patch.capture !== 'string') throw invalid('audit: capture must be a string');
|
|
503
|
+
if (patch.reviewer !== undefined) reviewerSchema.parse(patch.reviewer);
|
|
504
|
+
if (patch.settings !== undefined) settingsSchema.parse(patch.settings);
|
|
505
|
+
};
|
|
506
|
+
const checkId = id => { if (typeof id !== 'string' || id.length === 0 || id.length > MAX_ID) throw invalid('audit: id is required'); };
|
|
507
|
+
|
|
508
|
+
const store = {
|
|
509
|
+
/** Persist a fresh pending review record as early as possible; resolves with its id. */
|
|
510
|
+
begin(record) {
|
|
511
|
+
return enqueue(async () => {
|
|
512
|
+
const now = new Date().toISOString();
|
|
513
|
+
const id = randomUUID();
|
|
514
|
+
if (!record || typeof record !== 'object') throw invalid('audit: begin requires a record');
|
|
515
|
+
const row = {
|
|
516
|
+
id,
|
|
517
|
+
sessionId: boundedString(record.sessionId, 'sessionId', MAX_KEY),
|
|
518
|
+
agentId: boundedString(record.agentId, 'agentId', MAX_KEY) ?? '',
|
|
519
|
+
callId: boundedString(record.callId, 'callId', MAX_KEY) ?? '',
|
|
520
|
+
tool: boundedString(record.tool, 'tool', MAX_SHORT),
|
|
521
|
+
turn: record.turn === undefined ? null : record.turn,
|
|
522
|
+
trigger: record.trigger,
|
|
523
|
+
action: record.action,
|
|
524
|
+
environment: record.environment,
|
|
525
|
+
input: record.input,
|
|
526
|
+
policy: record.policy,
|
|
527
|
+
reviewer: record.reviewer,
|
|
528
|
+
settings: record.settings,
|
|
529
|
+
capture: record.capture,
|
|
530
|
+
status: 'pending',
|
|
531
|
+
outcome: 'pending',
|
|
532
|
+
approvalState: 'not-observed',
|
|
533
|
+
startedAt: now,
|
|
534
|
+
execution: { state: 'not-observed' },
|
|
535
|
+
};
|
|
536
|
+
if (row.sessionId === undefined) throw invalid('audit: sessionId is required');
|
|
537
|
+
if (row.tool === undefined) throw invalid('audit: tool is required');
|
|
538
|
+
if (row.turn !== null && !Number.isInteger(row.turn)) throw invalid('audit: turn must be an integer or null');
|
|
539
|
+
for (const field of OVERSIZE_FIELDS) {
|
|
540
|
+
if (row[field] !== undefined && typeof row[field] !== 'string') throw invalid(`audit: ${field} must be a string`);
|
|
541
|
+
}
|
|
542
|
+
if (row.capture !== undefined && typeof row.capture !== 'string') throw invalid('audit: capture must be a string');
|
|
543
|
+
const persisted = recordSchema.parse(redactPatch(foldOversize(row)));
|
|
544
|
+
await table.put(id, persisted);
|
|
545
|
+
return id;
|
|
546
|
+
});
|
|
547
|
+
},
|
|
548
|
+
|
|
549
|
+
/**
|
|
550
|
+
* Persist the actual review request before calling the model, and each
|
|
551
|
+
* finished review attempt's raw reply/error before retrying:
|
|
552
|
+
* input/environment/reviewer/capture/action/policy/attempts on a
|
|
553
|
+
* still-pending record. Never sets finishedAt; rejects once the record
|
|
554
|
+
* is finished.
|
|
555
|
+
*/
|
|
556
|
+
prepare(id, patch) {
|
|
557
|
+
return enqueue(async () => {
|
|
558
|
+
checkId(id);
|
|
559
|
+
if (!patch || typeof patch !== 'object') throw invalid('audit: prepare requires a patch');
|
|
560
|
+
const allowed = ['input', 'environment', 'reviewer', 'capture', 'action', 'policy', 'attempts'];
|
|
561
|
+
const merged = {};
|
|
562
|
+
for (const key of Object.keys(patch)) {
|
|
563
|
+
if (!allowed.includes(key)) throw invalid(`audit: prepare cannot set ${key}`);
|
|
564
|
+
if (patch[key] !== undefined) merged[key] = patch[key];
|
|
565
|
+
}
|
|
566
|
+
validatePatch(merged);
|
|
567
|
+
await table.update(id, current => {
|
|
568
|
+
if (current.outcome !== 'pending') throw invalid('audit: prepare rejected — record already finished');
|
|
569
|
+
const next = { ...current, ...redactPatch(foldOversize(merged)) };
|
|
570
|
+
recordSchema.parse(next); // validate before returning — never persist a bad row
|
|
571
|
+
return next;
|
|
572
|
+
});
|
|
573
|
+
});
|
|
574
|
+
},
|
|
575
|
+
|
|
576
|
+
/** Merge the review verdict patch into a record; sets finishedAt and attempts. */
|
|
577
|
+
finish(id, patch) {
|
|
578
|
+
return enqueue(async () => {
|
|
579
|
+
checkId(id);
|
|
580
|
+
const outcome = patch?.outcome;
|
|
581
|
+
if (!['allowed-once', 'rejected', 'cancelled'].includes(outcome)) {
|
|
582
|
+
throw invalid('audit: outcome must be allowed-once, rejected or cancelled');
|
|
583
|
+
}
|
|
584
|
+
const allowed = ['code', 'durationMs', 'risk', 'authorization', 'rationale',
|
|
585
|
+
'input', 'environment', 'reviewer', 'capture', 'attempts', 'settings', 'policy', 'action'];
|
|
586
|
+
const merged = {};
|
|
587
|
+
for (const key of Object.keys(patch)) {
|
|
588
|
+
if (key === 'outcome') continue;
|
|
589
|
+
if (!allowed.includes(key)) throw invalid(`audit: finish cannot set ${key}`);
|
|
590
|
+
if (patch[key] !== undefined) merged[key] = patch[key];
|
|
591
|
+
}
|
|
592
|
+
merged.outcome = outcome;
|
|
593
|
+
validatePatch(merged);
|
|
594
|
+
const finishedAt = new Date().toISOString();
|
|
595
|
+
await table.update(id, current => {
|
|
596
|
+
// Transitions: pending may take any verdict; a finished row may only
|
|
597
|
+
// be re-written to 'cancelled' by the final pre-approval re-check
|
|
598
|
+
// (a granted review invalidated while its durable write was in flight).
|
|
599
|
+
if (current.outcome !== 'pending' && !(current.outcome === 'allowed-once' && outcome === 'cancelled')) {
|
|
600
|
+
throw invalid('audit: cannot finish a record with outcome ' + current.outcome);
|
|
601
|
+
}
|
|
602
|
+
const next = { ...current, ...redactPatch(foldOversize(merged)), status: 'finished', finishedAt };
|
|
603
|
+
recordSchema.parse(next);
|
|
604
|
+
return next;
|
|
605
|
+
});
|
|
606
|
+
});
|
|
607
|
+
},
|
|
608
|
+
|
|
609
|
+
/**
|
|
610
|
+
* Record the native approval decision the host reported for this review.
|
|
611
|
+
* The reviewer's verdict is untouched: `outcome` says what the reviewer
|
|
612
|
+
* decided, `approvalState` says what the host confirmed. Only an observed
|
|
613
|
+
* decision counts as a confirmation, so a row whose grant was never
|
|
614
|
+
* confirmed is reported as interrupted instead of allowed.
|
|
615
|
+
*/
|
|
616
|
+
approval(id, result) {
|
|
617
|
+
return enqueue(async () => {
|
|
618
|
+
checkId(id);
|
|
619
|
+
const state = result?.outcome;
|
|
620
|
+
if (!['allowed-once', 'rejected', 'cancelled', 'unavailable'].includes(state)) {
|
|
621
|
+
throw invalid('audit: approval outcome must be allowed-once, rejected, cancelled or unavailable');
|
|
622
|
+
}
|
|
623
|
+
await table.update(id, current => {
|
|
624
|
+
const next = { ...current, approvalState: state };
|
|
625
|
+
recordSchema.parse(next);
|
|
626
|
+
return next;
|
|
627
|
+
});
|
|
628
|
+
});
|
|
629
|
+
},
|
|
630
|
+
|
|
631
|
+
/** Record whether the approved call actually ran afterwards. */
|
|
632
|
+
execution(id, result) {
|
|
633
|
+
return enqueue(async () => {
|
|
634
|
+
checkId(id);
|
|
635
|
+
const state = result?.state;
|
|
636
|
+
if (!['succeeded', 'failed'].includes(state)) throw invalid('audit: execution state must be succeeded or failed');
|
|
637
|
+
const completedAt = result?.completedAt === undefined
|
|
638
|
+
? new Date().toISOString()
|
|
639
|
+
: boundedString(result.completedAt, 'completedAt', 64);
|
|
640
|
+
const error = boundedString(result?.error, 'error', MAX_TEXT);
|
|
641
|
+
await table.update(id, current => {
|
|
642
|
+
const next = { ...current, execution: { state, completedAt, ...(error ? { error: redactString(error) } : {}) } };
|
|
643
|
+
recordSchema.parse(next);
|
|
644
|
+
return next;
|
|
645
|
+
});
|
|
646
|
+
});
|
|
647
|
+
},
|
|
648
|
+
|
|
649
|
+
/**
|
|
650
|
+
* Synchronous filtered listing, newest first, keyset-paginated so new
|
|
651
|
+
* inserts during iteration never shift page boundaries.
|
|
652
|
+
* @returns {{items: object[], nextCursor: string|undefined, total: number}}
|
|
653
|
+
*/
|
|
654
|
+
list(payload = {}) {
|
|
655
|
+
const filter = listPayloadSchema.parse(payload ?? {});
|
|
656
|
+
const limit = filter.limit ?? DEFAULT_LIMIT;
|
|
657
|
+
const cursor = filter.cursor === undefined ? undefined : decodeCursor(filter.cursor);
|
|
658
|
+
const needle = filter.query === undefined ? undefined : filter.query.toLowerCase();
|
|
659
|
+
const fromMs = filter.from === undefined ? undefined : Date.parse(filter.from);
|
|
660
|
+
const toMs = filter.to === undefined ? undefined : Date.parse(filter.to);
|
|
661
|
+
if (fromMs !== undefined && toMs !== undefined && fromMs > toMs) throw invalid('audit: from must not be after to');
|
|
662
|
+
const rows = [];
|
|
663
|
+
let total = 0;
|
|
664
|
+
for (const [, row] of table.entries()) {
|
|
665
|
+
if (!row) continue;
|
|
666
|
+
if (filter.sessionId !== undefined && row.sessionId !== filter.sessionId) continue;
|
|
667
|
+
if (filter.tool !== undefined && row.tool !== filter.tool) continue;
|
|
668
|
+
if (filter.risk !== undefined && row.risk !== filter.risk) continue;
|
|
669
|
+
const startedMs = Date.parse(row.startedAt);
|
|
670
|
+
if (fromMs !== undefined && !(startedMs >= fromMs)) continue;
|
|
671
|
+
if (toMs !== undefined && !(startedMs <= toMs)) continue;
|
|
672
|
+
if (filter.outcome !== undefined && reportedOutcome(row) !== filter.outcome) continue;
|
|
673
|
+
if (needle !== undefined && !matchesNeedle(row, needle)) continue;
|
|
674
|
+
total++;
|
|
675
|
+
rows.push(row);
|
|
676
|
+
}
|
|
677
|
+
rows.sort(byStartedDesc);
|
|
678
|
+
const page = [];
|
|
679
|
+
for (const row of rows) {
|
|
680
|
+
if (page.length >= limit) break;
|
|
681
|
+
if (cursor && !afterCursor(row, cursor)) continue;
|
|
682
|
+
page.push(row);
|
|
683
|
+
}
|
|
684
|
+
const last = page[page.length - 1];
|
|
685
|
+
const hasMore = last !== undefined && rows.some(row => afterCursor(row, { startedAt: last.startedAt, id: last.id }));
|
|
686
|
+
return { items: page.map(summary), nextCursor: hasMore ? encodeCursor(last) : undefined, total };
|
|
687
|
+
},
|
|
688
|
+
|
|
689
|
+
/** Synchronous full record read, including heavy fields. */
|
|
690
|
+
get(payload = {}) {
|
|
691
|
+
if (!payload || typeof payload !== 'object') throw invalid('audit: get requires {id}');
|
|
692
|
+
for (const key of Object.keys(payload)) if (key !== 'id') throw invalid(`audit: unknown get field ${key}`);
|
|
693
|
+
if (typeof payload.id !== 'string' || payload.id.length > MAX_ID) throw invalid('audit: get requires {id}');
|
|
694
|
+
const row = view(table.get(payload.id));
|
|
695
|
+
if (row === undefined) { const e = new Error('audit: record not found'); e.code = 'AUDIT_NOT_FOUND'; throw e; }
|
|
696
|
+
return row;
|
|
697
|
+
},
|
|
698
|
+
|
|
699
|
+
/** Drain queued writes and close the domain. */
|
|
700
|
+
async close() {
|
|
701
|
+
await tail.catch(() => {});
|
|
702
|
+
await domain.close();
|
|
703
|
+
},
|
|
704
|
+
};
|
|
705
|
+
return store;
|
|
706
|
+
}
|