@frockbot/plugin-audit 0.0.0 → 0.1.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/frockbot.json +37 -0
- package/package.json +43 -6
- package/src/backend.test.ts +193 -0
- package/src/backend.ts +148 -0
- package/src/bot.test.ts +250 -0
- package/src/bot.ts +312 -0
- package/src/classify.test.ts +174 -0
- package/src/classify.ts +151 -0
- package/src/client/AuditSection.vue +363 -0
- package/src/client/index.test.ts +160 -0
- package/src/client/index.ts +125 -0
- package/src/client/state.ts +35 -0
- package/src/env.d.ts +6 -0
- package/src/index.ts +24 -0
- package/src/manifest.ts +3 -0
- package/src/redact.test.ts +91 -0
- package/src/redact.ts +110 -0
- package/src/shared.ts +623 -0
- package/src/store.test.ts +168 -0
- package/src/store.ts +432 -0
- package/src/testing.ts +197 -0
- package/src/user.test.ts +139 -0
- package/src/user.ts +215 -0
- package/tsconfig.json +15 -0
- package/README.md +0 -3
package/src/shared.ts
ADDED
|
@@ -0,0 +1,623 @@
|
|
|
1
|
+
// The Audit Package's narrow, versioned DTOs and their decoders.
|
|
2
|
+
//
|
|
3
|
+
// WHAT AUDIT IS. Parity register rows 30 and 30b: GrokBot writes one
|
|
4
|
+
// `audit.jsonl` line per shell command carrying turn id and target, and a
|
|
5
|
+
// separate `audit-outbox.json` covering shell, browser navigation and MCP
|
|
6
|
+
// calls (`docs/research/grokbot-computer.md:189-195`, `:546-547`). FrockBot
|
|
7
|
+
// answers with one surface over all five kinds.
|
|
8
|
+
//
|
|
9
|
+
// WHAT IT IS NOT. It is not authority, and it records nothing a Turn did not
|
|
10
|
+
// already record. `AGENTS.md` § Authorities: the Bot's Durable Object holds
|
|
11
|
+
// the append-only event log, and `tool/call` already carries
|
|
12
|
+
// `{turn, step, occurrenceId, name, input}` with `tool/result` carrying the
|
|
13
|
+
// outcome. An audit entry is a *projection* of those durable events — the
|
|
14
|
+
// constitution's "indexes … are always rebuildable" rule applied to a second
|
|
15
|
+
// durable write nobody needs. `rebuildAuditIndex` proves it: the table can be
|
|
16
|
+
// emptied and reconstructed byte for byte.
|
|
17
|
+
//
|
|
18
|
+
// Every value here crosses a runtime boundary — a Bot Durable Object to the
|
|
19
|
+
// User Durable Object, the User object to the gateway, the gateway to a
|
|
20
|
+
// browser — so each is decoded at its seam with exact keys.
|
|
21
|
+
|
|
22
|
+
/** Most entries one User's audit table holds before the oldest are evicted. */
|
|
23
|
+
export const AUDIT_MAX_ROWS_V1 = 20_000;
|
|
24
|
+
/** The hard age bound. An entry older than this leaves whatever the row count. */
|
|
25
|
+
export const AUDIT_MAX_AGE_MS_V1 = 180 * 24 * 60 * 60 * 1_000;
|
|
26
|
+
/** Longest preview one entry carries, after redaction. */
|
|
27
|
+
export const AUDIT_MAX_PREVIEW_LENGTH_V1 = 200;
|
|
28
|
+
/** Most entries the Bot Durable Object's outbox holds before the oldest drop. */
|
|
29
|
+
export const AUDIT_MAX_OUTBOX_V1 = 512;
|
|
30
|
+
/** Most entries one contribution or rebuild page carries. */
|
|
31
|
+
export const AUDIT_MAX_ENTRY_PAGE_V1 = 512;
|
|
32
|
+
/** Longest accepted paging cursor. */
|
|
33
|
+
export const AUDIT_MAX_CURSOR_LENGTH_V1 = 64;
|
|
34
|
+
/** Most entries one query page returns. */
|
|
35
|
+
export const AUDIT_MAX_RESULTS_V1 = 100;
|
|
36
|
+
|
|
37
|
+
const MAX_ID_LENGTH = 128;
|
|
38
|
+
const MAX_TIMESTAMP_LENGTH = 64;
|
|
39
|
+
const MAX_TARGET_LENGTH = 160;
|
|
40
|
+
const MAX_TOOL_NAME_LENGTH = 128;
|
|
41
|
+
const DIGEST_PATTERN = /^[0-9a-f]{64}$/;
|
|
42
|
+
const OCCURRENCE_PATTERN =
|
|
43
|
+
/^tool:([1-9][0-9]{0,8}):([1-9][0-9]{0,8}):([0-9]{1,9})$/;
|
|
44
|
+
|
|
45
|
+
export class AuditDecodeError extends Error {
|
|
46
|
+
constructor(message: string) {
|
|
47
|
+
super(message);
|
|
48
|
+
this.name = "AuditDecodeError";
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* What an audited effect was.
|
|
54
|
+
*
|
|
55
|
+
* `process` exists for the Computer Package's `computer_process_*` tools,
|
|
56
|
+
* which are in flight (parity register row 29, background commands that
|
|
57
|
+
* outlive the Turn). The kind is declared now so the table does not change
|
|
58
|
+
* shape when they land.
|
|
59
|
+
*/
|
|
60
|
+
export type AuditKindV1 = "shell" | "browser" | "mcp" | "file" | "process";
|
|
61
|
+
|
|
62
|
+
export const AUDIT_KINDS_V1: readonly AuditKindV1[] = [
|
|
63
|
+
"shell",
|
|
64
|
+
"browser",
|
|
65
|
+
"mcp",
|
|
66
|
+
"file",
|
|
67
|
+
"process",
|
|
68
|
+
];
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* How the effect ended.
|
|
72
|
+
*
|
|
73
|
+
* `unknown` is load-bearing rather than a fallback: a `tool/call` with no
|
|
74
|
+
* matching `tool/result` is an effect whose outcome the durable log does not
|
|
75
|
+
* know, and saying so is the constitution's "Failures are observable through
|
|
76
|
+
* durable state". It is never quietly recorded as an error.
|
|
77
|
+
*/
|
|
78
|
+
export type AuditOutcomeV1 =
|
|
79
|
+
"ok" | "error" | "refused" | "interrupted" | "unknown";
|
|
80
|
+
|
|
81
|
+
export const AUDIT_OUTCOMES_V1: readonly AuditOutcomeV1[] = [
|
|
82
|
+
"ok",
|
|
83
|
+
"error",
|
|
84
|
+
"refused",
|
|
85
|
+
"interrupted",
|
|
86
|
+
"unknown",
|
|
87
|
+
];
|
|
88
|
+
|
|
89
|
+
/** The Bot's own Computer — the default target for every Computer effect. */
|
|
90
|
+
export const AUDIT_TARGET_COMPUTER_V1 = "computer";
|
|
91
|
+
/** A registered machine of the User's, `machine:<id>` (parity register §2.16). */
|
|
92
|
+
export const AUDIT_TARGET_MACHINE_PREFIX_V1 = "machine:";
|
|
93
|
+
/** A remote MCP server, `remote:<host>`. */
|
|
94
|
+
export const AUDIT_TARGET_REMOTE_PREFIX_V1 = "remote:";
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* One audited effect. Idempotent on `(botId, runId, occurrenceId)`.
|
|
98
|
+
*
|
|
99
|
+
* `argumentDigest` rather than the arguments: the digest proves two runs
|
|
100
|
+
* issued the same call without the table holding a command line, an MCP
|
|
101
|
+
* payload, or anything else a credential could be sitting in. `preview` is the
|
|
102
|
+
* bounded, redacted human-readable half; `AGENTS.md` § Computer and Workspace
|
|
103
|
+
* forbids a credential reaching durable state, so the exec op's `env` and
|
|
104
|
+
* every `credentialRef` are never projected at all.
|
|
105
|
+
*/
|
|
106
|
+
export interface AuditEntryV1 {
|
|
107
|
+
schemaVersion: 1;
|
|
108
|
+
botId: string;
|
|
109
|
+
runId: string;
|
|
110
|
+
/** `tool:<turn>:<step>:<ordinal>`; also the Computer envelope's `effectId`. */
|
|
111
|
+
occurrenceId: string;
|
|
112
|
+
turn: number;
|
|
113
|
+
step: number;
|
|
114
|
+
ordinal: number;
|
|
115
|
+
/**
|
|
116
|
+
* The durable effect identifier. `plugin-shell` writes
|
|
117
|
+
* `occurrenceId: context.effectId`, so this is the same string the Computer
|
|
118
|
+
* host's envelope carries and the key a host-journal reconciliation joins on.
|
|
119
|
+
*/
|
|
120
|
+
effectId: string;
|
|
121
|
+
/** ISO-8601: the run's admission time, so a rebuild reproduces it exactly. */
|
|
122
|
+
at: string;
|
|
123
|
+
kind: AuditKindV1;
|
|
124
|
+
/** `computer`, `machine:<id>`, or `remote:<host>`. */
|
|
125
|
+
target: string;
|
|
126
|
+
toolName: string;
|
|
127
|
+
/** Lowercase hex sha-256 of the exact argument JSON. */
|
|
128
|
+
argumentDigest: string;
|
|
129
|
+
/** At most {@link AUDIT_MAX_PREVIEW_LENGTH_V1} characters, redacted. */
|
|
130
|
+
preview: string;
|
|
131
|
+
outcome: AuditOutcomeV1;
|
|
132
|
+
exitCode?: number;
|
|
133
|
+
durationMs?: number;
|
|
134
|
+
bytesOut?: number;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** A page of one Bot's projected entries, as a rebuild pulls them. */
|
|
138
|
+
export interface AuditEntryPageV1 {
|
|
139
|
+
schemaVersion: 1;
|
|
140
|
+
botId: string;
|
|
141
|
+
entries: AuditEntryV1[];
|
|
142
|
+
/** Absent when the Bot has no further runs to project. */
|
|
143
|
+
nextCursor?: string;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export type AuditIndexStateV1 = "ready" | "rebuilding" | "truncated";
|
|
147
|
+
|
|
148
|
+
/** What one rebuild did, and what it could not explain. */
|
|
149
|
+
export interface AuditRebuildReceiptV1 {
|
|
150
|
+
schemaVersion: 1;
|
|
151
|
+
status: "rebuilt";
|
|
152
|
+
entries: number;
|
|
153
|
+
bots: number;
|
|
154
|
+
indexState: AuditIndexStateV1;
|
|
155
|
+
/**
|
|
156
|
+
* Entries whose outcome the durable event log does not know — a `tool/call`
|
|
157
|
+
* with no matching `tool/result`. Always real, because it is derived from
|
|
158
|
+
* the same events the table is.
|
|
159
|
+
*/
|
|
160
|
+
unknownOutcomes: number;
|
|
161
|
+
/**
|
|
162
|
+
* Effects the Computer host's own journal reported that no durable session
|
|
163
|
+
* event accounts for. The host is non-authoritative (`AGENTS.md`
|
|
164
|
+
* § Computer and Workspace), so such an effect is *counted and named*, never
|
|
165
|
+
* written into the table as if a Turn had recorded it.
|
|
166
|
+
*/
|
|
167
|
+
hostJournalDiscrepancies: number;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// ---------------------------------------------------------------------------
|
|
171
|
+
// Decoders.
|
|
172
|
+
// ---------------------------------------------------------------------------
|
|
173
|
+
|
|
174
|
+
function record(value: unknown, label: string): Record<string, unknown> {
|
|
175
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
|
176
|
+
throw new AuditDecodeError(`${label} must be an object`);
|
|
177
|
+
}
|
|
178
|
+
return value as Record<string, unknown>;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function exactKeys(
|
|
182
|
+
value: Record<string, unknown>,
|
|
183
|
+
allowed: readonly string[],
|
|
184
|
+
label: string,
|
|
185
|
+
): void {
|
|
186
|
+
const allowedKeys = new Set(allowed);
|
|
187
|
+
const unexpected = Reflect.ownKeys(value).find(
|
|
188
|
+
(key) =>
|
|
189
|
+
typeof key !== "string" ||
|
|
190
|
+
!allowedKeys.has(key) ||
|
|
191
|
+
!Object.prototype.propertyIsEnumerable.call(value, key),
|
|
192
|
+
);
|
|
193
|
+
if (unexpected !== undefined) {
|
|
194
|
+
const field =
|
|
195
|
+
typeof unexpected === "symbol" ? unexpected.toString() : unexpected;
|
|
196
|
+
throw new AuditDecodeError(`${label}.${field} is not allowed`);
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function text(
|
|
201
|
+
value: Record<string, unknown>,
|
|
202
|
+
key: string,
|
|
203
|
+
maximum: number,
|
|
204
|
+
label: string,
|
|
205
|
+
): string {
|
|
206
|
+
const field = value[key];
|
|
207
|
+
if (typeof field !== "string" || field.length > maximum) {
|
|
208
|
+
throw new AuditDecodeError(`${label}.${key} must be a bounded string`);
|
|
209
|
+
}
|
|
210
|
+
return field;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function identifier(
|
|
214
|
+
value: Record<string, unknown>,
|
|
215
|
+
key: string,
|
|
216
|
+
label: string,
|
|
217
|
+
): string {
|
|
218
|
+
const field = text(value, key, MAX_ID_LENGTH, label);
|
|
219
|
+
if (field.length === 0) {
|
|
220
|
+
throw new AuditDecodeError(`${label}.${key} must not be empty`);
|
|
221
|
+
}
|
|
222
|
+
return field;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function integer(
|
|
226
|
+
value: Record<string, unknown>,
|
|
227
|
+
key: string,
|
|
228
|
+
bounds: { min: number; max: number },
|
|
229
|
+
label: string,
|
|
230
|
+
): number {
|
|
231
|
+
const field = value[key];
|
|
232
|
+
if (
|
|
233
|
+
!Number.isSafeInteger(field) ||
|
|
234
|
+
(field as number) < bounds.min ||
|
|
235
|
+
(field as number) > bounds.max
|
|
236
|
+
) {
|
|
237
|
+
throw new AuditDecodeError(`${label}.${key} must be a bounded integer`);
|
|
238
|
+
}
|
|
239
|
+
return field as number;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* The `{turn, step, ordinal}` one occurrence id names.
|
|
244
|
+
*
|
|
245
|
+
* The whole design rests on this being decodable: turn, step and ordinal are
|
|
246
|
+
* already in the durable event as one string
|
|
247
|
+
* (`kernel-contracts/src/types.ts`, `toolOccurrenceId`), so audit needs no new
|
|
248
|
+
* coordinate and no new authority to place an effect in a conversation.
|
|
249
|
+
*/
|
|
250
|
+
export function decodeAuditOccurrenceIdV1(value: unknown): {
|
|
251
|
+
turn: number;
|
|
252
|
+
step: number;
|
|
253
|
+
ordinal: number;
|
|
254
|
+
} {
|
|
255
|
+
if (typeof value !== "string") {
|
|
256
|
+
throw new AuditDecodeError("audit occurrence id must be a string");
|
|
257
|
+
}
|
|
258
|
+
const match = OCCURRENCE_PATTERN.exec(value);
|
|
259
|
+
if (!match) {
|
|
260
|
+
throw new AuditDecodeError(`audit occurrence id "${value}" is invalid`);
|
|
261
|
+
}
|
|
262
|
+
return {
|
|
263
|
+
turn: Number(match[1]),
|
|
264
|
+
step: Number(match[2]),
|
|
265
|
+
ordinal: Number(match[3]),
|
|
266
|
+
};
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/** Whether a string is one of the three target shapes this schema allows. */
|
|
270
|
+
export function isAuditTargetV1(value: string): boolean {
|
|
271
|
+
if (value === AUDIT_TARGET_COMPUTER_V1) return true;
|
|
272
|
+
if (value.length > MAX_TARGET_LENGTH) return false;
|
|
273
|
+
if (value.startsWith(AUDIT_TARGET_MACHINE_PREFIX_V1)) {
|
|
274
|
+
return /^machine:[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/.test(value);
|
|
275
|
+
}
|
|
276
|
+
if (value.startsWith(AUDIT_TARGET_REMOTE_PREFIX_V1)) {
|
|
277
|
+
return /^remote:[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/.test(value);
|
|
278
|
+
}
|
|
279
|
+
return false;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
function auditKind(value: unknown, label: string): AuditKindV1 {
|
|
283
|
+
if (
|
|
284
|
+
typeof value !== "string" ||
|
|
285
|
+
!AUDIT_KINDS_V1.includes(value as AuditKindV1)
|
|
286
|
+
) {
|
|
287
|
+
throw new AuditDecodeError(`${label}.kind is invalid`);
|
|
288
|
+
}
|
|
289
|
+
return value as AuditKindV1;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
function auditOutcome(value: unknown, label: string): AuditOutcomeV1 {
|
|
293
|
+
if (
|
|
294
|
+
typeof value !== "string" ||
|
|
295
|
+
!AUDIT_OUTCOMES_V1.includes(value as AuditOutcomeV1)
|
|
296
|
+
) {
|
|
297
|
+
throw new AuditDecodeError(`${label}.outcome is invalid`);
|
|
298
|
+
}
|
|
299
|
+
return value as AuditOutcomeV1;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
const ENTRY_KEYS = [
|
|
303
|
+
"schemaVersion",
|
|
304
|
+
"botId",
|
|
305
|
+
"runId",
|
|
306
|
+
"occurrenceId",
|
|
307
|
+
"turn",
|
|
308
|
+
"step",
|
|
309
|
+
"ordinal",
|
|
310
|
+
"effectId",
|
|
311
|
+
"at",
|
|
312
|
+
"kind",
|
|
313
|
+
"target",
|
|
314
|
+
"toolName",
|
|
315
|
+
"argumentDigest",
|
|
316
|
+
"preview",
|
|
317
|
+
"outcome",
|
|
318
|
+
"exitCode",
|
|
319
|
+
"durationMs",
|
|
320
|
+
"bytesOut",
|
|
321
|
+
] as const;
|
|
322
|
+
|
|
323
|
+
export function decodeAuditEntryV1(input: unknown): AuditEntryV1 {
|
|
324
|
+
const entry = record(input, "audit entry");
|
|
325
|
+
exactKeys(entry, ENTRY_KEYS, "audit entry");
|
|
326
|
+
if (entry.schemaVersion !== 1) {
|
|
327
|
+
throw new AuditDecodeError("audit entry.schemaVersion must be 1");
|
|
328
|
+
}
|
|
329
|
+
const occurrenceId = text(
|
|
330
|
+
entry,
|
|
331
|
+
"occurrenceId",
|
|
332
|
+
MAX_ID_LENGTH,
|
|
333
|
+
"audit entry",
|
|
334
|
+
);
|
|
335
|
+
const coordinates = decodeAuditOccurrenceIdV1(occurrenceId);
|
|
336
|
+
const at = text(entry, "at", MAX_TIMESTAMP_LENGTH, "audit entry");
|
|
337
|
+
if (!Number.isFinite(Date.parse(at))) {
|
|
338
|
+
throw new AuditDecodeError("audit entry.at must be a timestamp");
|
|
339
|
+
}
|
|
340
|
+
const target = text(entry, "target", MAX_TARGET_LENGTH, "audit entry");
|
|
341
|
+
if (!isAuditTargetV1(target)) {
|
|
342
|
+
throw new AuditDecodeError(`audit entry.target "${target}" is invalid`);
|
|
343
|
+
}
|
|
344
|
+
const argumentDigest = text(entry, "argumentDigest", 64, "audit entry");
|
|
345
|
+
if (!DIGEST_PATTERN.test(argumentDigest)) {
|
|
346
|
+
throw new AuditDecodeError("audit entry.argumentDigest must be a sha-256");
|
|
347
|
+
}
|
|
348
|
+
// The coordinates are carried as well as encoded so a reader need not parse
|
|
349
|
+
// the id, and checked against it so the two can never disagree.
|
|
350
|
+
if (
|
|
351
|
+
integer(entry, "turn", { min: 1, max: 1e9 }, "audit entry") !==
|
|
352
|
+
coordinates.turn ||
|
|
353
|
+
integer(entry, "step", { min: 1, max: 1e9 }, "audit entry") !==
|
|
354
|
+
coordinates.step ||
|
|
355
|
+
integer(entry, "ordinal", { min: 0, max: 1e9 }, "audit entry") !==
|
|
356
|
+
coordinates.ordinal
|
|
357
|
+
) {
|
|
358
|
+
throw new AuditDecodeError(
|
|
359
|
+
"audit entry coordinates disagree with its occurrence id",
|
|
360
|
+
);
|
|
361
|
+
}
|
|
362
|
+
return {
|
|
363
|
+
schemaVersion: 1,
|
|
364
|
+
botId: identifier(entry, "botId", "audit entry"),
|
|
365
|
+
runId: identifier(entry, "runId", "audit entry"),
|
|
366
|
+
occurrenceId,
|
|
367
|
+
turn: coordinates.turn,
|
|
368
|
+
step: coordinates.step,
|
|
369
|
+
ordinal: coordinates.ordinal,
|
|
370
|
+
effectId: identifier(entry, "effectId", "audit entry"),
|
|
371
|
+
at,
|
|
372
|
+
kind: auditKind(entry.kind, "audit entry"),
|
|
373
|
+
target,
|
|
374
|
+
toolName: text(entry, "toolName", MAX_TOOL_NAME_LENGTH, "audit entry"),
|
|
375
|
+
argumentDigest,
|
|
376
|
+
preview: text(entry, "preview", AUDIT_MAX_PREVIEW_LENGTH_V1, "audit entry"),
|
|
377
|
+
outcome: auditOutcome(entry.outcome, "audit entry"),
|
|
378
|
+
...(entry.exitCode === undefined
|
|
379
|
+
? {}
|
|
380
|
+
: {
|
|
381
|
+
exitCode: integer(
|
|
382
|
+
entry,
|
|
383
|
+
"exitCode",
|
|
384
|
+
{ min: -1_024, max: 1_024 },
|
|
385
|
+
"audit entry",
|
|
386
|
+
),
|
|
387
|
+
}),
|
|
388
|
+
...(entry.durationMs === undefined
|
|
389
|
+
? {}
|
|
390
|
+
: {
|
|
391
|
+
durationMs: integer(
|
|
392
|
+
entry,
|
|
393
|
+
"durationMs",
|
|
394
|
+
{ min: 0, max: 2 ** 40 },
|
|
395
|
+
"audit entry",
|
|
396
|
+
),
|
|
397
|
+
}),
|
|
398
|
+
...(entry.bytesOut === undefined
|
|
399
|
+
? {}
|
|
400
|
+
: {
|
|
401
|
+
bytesOut: integer(
|
|
402
|
+
entry,
|
|
403
|
+
"bytesOut",
|
|
404
|
+
{ min: 0, max: 2 ** 40 },
|
|
405
|
+
"audit entry",
|
|
406
|
+
),
|
|
407
|
+
}),
|
|
408
|
+
};
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
export function decodeAuditEntryPageV1(input: unknown): AuditEntryPageV1 {
|
|
412
|
+
const page = record(input, "audit entry page");
|
|
413
|
+
exactKeys(
|
|
414
|
+
page,
|
|
415
|
+
["schemaVersion", "botId", "entries", "nextCursor"],
|
|
416
|
+
"audit entry page",
|
|
417
|
+
);
|
|
418
|
+
if (page.schemaVersion !== 1) {
|
|
419
|
+
throw new AuditDecodeError("audit entry page.schemaVersion must be 1");
|
|
420
|
+
}
|
|
421
|
+
if (!Array.isArray(page.entries)) {
|
|
422
|
+
throw new AuditDecodeError("audit entry page.entries must be an array");
|
|
423
|
+
}
|
|
424
|
+
if (page.entries.length > AUDIT_MAX_ENTRY_PAGE_V1) {
|
|
425
|
+
throw new AuditDecodeError("audit entry page.entries exceeds its bound");
|
|
426
|
+
}
|
|
427
|
+
const botId = identifier(page, "botId", "audit entry page");
|
|
428
|
+
const entries = page.entries.map(decodeAuditEntryV1);
|
|
429
|
+
if (entries.some((entry) => entry.botId !== botId)) {
|
|
430
|
+
throw new AuditDecodeError("audit entry page.entries names another Bot");
|
|
431
|
+
}
|
|
432
|
+
return {
|
|
433
|
+
schemaVersion: 1,
|
|
434
|
+
botId,
|
|
435
|
+
entries,
|
|
436
|
+
...(page.nextCursor === undefined
|
|
437
|
+
? {}
|
|
438
|
+
: {
|
|
439
|
+
nextCursor: text(
|
|
440
|
+
page,
|
|
441
|
+
"nextCursor",
|
|
442
|
+
AUDIT_MAX_CURSOR_LENGTH_V1 * 8,
|
|
443
|
+
"audit entry page",
|
|
444
|
+
),
|
|
445
|
+
}),
|
|
446
|
+
};
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
// ---------------------------------------------------------------------------
|
|
450
|
+
// The query, and what the client is answered with.
|
|
451
|
+
// ---------------------------------------------------------------------------
|
|
452
|
+
|
|
453
|
+
/** One filtered, paged request for a User's audit entries. */
|
|
454
|
+
export interface AuditQueryV1 {
|
|
455
|
+
schemaVersion: 1;
|
|
456
|
+
botId?: string;
|
|
457
|
+
kind?: AuditKindV1;
|
|
458
|
+
target?: string;
|
|
459
|
+
/** Opaque page cursor from a previous answer's `page.nextCursor`. */
|
|
460
|
+
before?: string;
|
|
461
|
+
limit?: number;
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
export interface ClientAuditPageV1 {
|
|
465
|
+
schemaVersion: 1;
|
|
466
|
+
entries: AuditEntryV1[];
|
|
467
|
+
/** The same page shape the transcript index answers with. */
|
|
468
|
+
page: { truncated: boolean; nextCursor?: string };
|
|
469
|
+
/** How many entries match the filters, before paging. */
|
|
470
|
+
total: number;
|
|
471
|
+
indexState: AuditIndexStateV1;
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
export function decodeAuditQueryV1(input: unknown): AuditQueryV1 {
|
|
475
|
+
const query = record(input, "audit query");
|
|
476
|
+
exactKeys(
|
|
477
|
+
query,
|
|
478
|
+
["schemaVersion", "botId", "kind", "target", "before", "limit"],
|
|
479
|
+
"audit query",
|
|
480
|
+
);
|
|
481
|
+
if (query.schemaVersion !== 1) {
|
|
482
|
+
throw new AuditDecodeError("audit query.schemaVersion must be 1");
|
|
483
|
+
}
|
|
484
|
+
if (query.kind !== undefined) auditKind(query.kind, "audit query");
|
|
485
|
+
if (query.target !== undefined) {
|
|
486
|
+
const target = text(query, "target", MAX_TARGET_LENGTH, "audit query");
|
|
487
|
+
if (!isAuditTargetV1(target)) {
|
|
488
|
+
throw new AuditDecodeError("audit query.target is invalid");
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
if (
|
|
492
|
+
query.limit !== undefined &&
|
|
493
|
+
(!Number.isSafeInteger(query.limit) ||
|
|
494
|
+
(query.limit as number) < 1 ||
|
|
495
|
+
(query.limit as number) > AUDIT_MAX_RESULTS_V1)
|
|
496
|
+
) {
|
|
497
|
+
throw new AuditDecodeError("audit query.limit must be a bounded integer");
|
|
498
|
+
}
|
|
499
|
+
return {
|
|
500
|
+
schemaVersion: 1,
|
|
501
|
+
...(query.botId === undefined
|
|
502
|
+
? {}
|
|
503
|
+
: { botId: identifier(query, "botId", "audit query") }),
|
|
504
|
+
...(query.kind === undefined ? {} : { kind: query.kind as AuditKindV1 }),
|
|
505
|
+
...(query.target === undefined ? {} : { target: query.target as string }),
|
|
506
|
+
...(query.before === undefined
|
|
507
|
+
? {}
|
|
508
|
+
: {
|
|
509
|
+
before: text(
|
|
510
|
+
query,
|
|
511
|
+
"before",
|
|
512
|
+
AUDIT_MAX_CURSOR_LENGTH_V1,
|
|
513
|
+
"audit query",
|
|
514
|
+
),
|
|
515
|
+
}),
|
|
516
|
+
...(query.limit === undefined ? {} : { limit: query.limit as number }),
|
|
517
|
+
};
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
export function decodeClientAuditPageV1(input: unknown): ClientAuditPageV1 {
|
|
521
|
+
const answer = record(input, "audit page");
|
|
522
|
+
exactKeys(
|
|
523
|
+
answer,
|
|
524
|
+
["schemaVersion", "entries", "page", "total", "indexState"],
|
|
525
|
+
"audit page",
|
|
526
|
+
);
|
|
527
|
+
if (answer.schemaVersion !== 1) {
|
|
528
|
+
throw new AuditDecodeError("audit page.schemaVersion must be 1");
|
|
529
|
+
}
|
|
530
|
+
if (!Array.isArray(answer.entries)) {
|
|
531
|
+
throw new AuditDecodeError("audit page.entries must be an array");
|
|
532
|
+
}
|
|
533
|
+
if (answer.entries.length > AUDIT_MAX_RESULTS_V1) {
|
|
534
|
+
throw new AuditDecodeError("audit page.entries exceeds its bound");
|
|
535
|
+
}
|
|
536
|
+
const page = record(answer.page, "audit page.page");
|
|
537
|
+
exactKeys(page, ["truncated", "nextCursor"], "audit page.page");
|
|
538
|
+
if (typeof page.truncated !== "boolean") {
|
|
539
|
+
throw new AuditDecodeError("audit page.page.truncated must be a boolean");
|
|
540
|
+
}
|
|
541
|
+
if (
|
|
542
|
+
!Number.isSafeInteger(answer.total) ||
|
|
543
|
+
(answer.total as number) < answer.entries.length
|
|
544
|
+
) {
|
|
545
|
+
throw new AuditDecodeError("audit page.total is invalid");
|
|
546
|
+
}
|
|
547
|
+
if (
|
|
548
|
+
answer.indexState !== "ready" &&
|
|
549
|
+
answer.indexState !== "rebuilding" &&
|
|
550
|
+
answer.indexState !== "truncated"
|
|
551
|
+
) {
|
|
552
|
+
throw new AuditDecodeError("audit page.indexState is invalid");
|
|
553
|
+
}
|
|
554
|
+
return {
|
|
555
|
+
schemaVersion: 1,
|
|
556
|
+
entries: answer.entries.map(decodeAuditEntryV1),
|
|
557
|
+
page: {
|
|
558
|
+
truncated: page.truncated,
|
|
559
|
+
...(page.nextCursor === undefined
|
|
560
|
+
? {}
|
|
561
|
+
: {
|
|
562
|
+
nextCursor: text(
|
|
563
|
+
page,
|
|
564
|
+
"nextCursor",
|
|
565
|
+
AUDIT_MAX_CURSOR_LENGTH_V1,
|
|
566
|
+
"audit page.page",
|
|
567
|
+
),
|
|
568
|
+
}),
|
|
569
|
+
},
|
|
570
|
+
total: answer.total as number,
|
|
571
|
+
indexState: answer.indexState,
|
|
572
|
+
};
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
export function decodeAuditRebuildReceiptV1(
|
|
576
|
+
input: unknown,
|
|
577
|
+
): AuditRebuildReceiptV1 {
|
|
578
|
+
const receipt = record(input, "audit rebuild receipt");
|
|
579
|
+
exactKeys(
|
|
580
|
+
receipt,
|
|
581
|
+
[
|
|
582
|
+
"schemaVersion",
|
|
583
|
+
"status",
|
|
584
|
+
"entries",
|
|
585
|
+
"bots",
|
|
586
|
+
"indexState",
|
|
587
|
+
"unknownOutcomes",
|
|
588
|
+
"hostJournalDiscrepancies",
|
|
589
|
+
],
|
|
590
|
+
"audit rebuild receipt",
|
|
591
|
+
);
|
|
592
|
+
if (receipt.schemaVersion !== 1 || receipt.status !== "rebuilt") {
|
|
593
|
+
throw new AuditDecodeError("audit rebuild receipt is invalid");
|
|
594
|
+
}
|
|
595
|
+
for (const key of [
|
|
596
|
+
"entries",
|
|
597
|
+
"bots",
|
|
598
|
+
"unknownOutcomes",
|
|
599
|
+
"hostJournalDiscrepancies",
|
|
600
|
+
] as const) {
|
|
601
|
+
if (!Number.isSafeInteger(receipt[key]) || (receipt[key] as number) < 0) {
|
|
602
|
+
throw new AuditDecodeError(
|
|
603
|
+
`audit rebuild receipt.${key} must be a non-negative integer`,
|
|
604
|
+
);
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
if (
|
|
608
|
+
receipt.indexState !== "ready" &&
|
|
609
|
+
receipt.indexState !== "rebuilding" &&
|
|
610
|
+
receipt.indexState !== "truncated"
|
|
611
|
+
) {
|
|
612
|
+
throw new AuditDecodeError("audit rebuild receipt.indexState is invalid");
|
|
613
|
+
}
|
|
614
|
+
return {
|
|
615
|
+
schemaVersion: 1,
|
|
616
|
+
status: "rebuilt",
|
|
617
|
+
entries: receipt.entries as number,
|
|
618
|
+
bots: receipt.bots as number,
|
|
619
|
+
indexState: receipt.indexState,
|
|
620
|
+
unknownOutcomes: receipt.unknownOutcomes as number,
|
|
621
|
+
hostJournalDiscrepancies: receipt.hostJournalDiscrepancies as number,
|
|
622
|
+
};
|
|
623
|
+
}
|