@blamejs/blamejs-shop 0.0.59 → 0.0.61

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.
@@ -0,0 +1,621 @@
1
+ "use strict";
2
+ /**
3
+ * @module shop.operatorAuditLog
4
+ * @title Operator audit log — append-only WHO/WHAT/WHICH/WHEN trail
5
+ *
6
+ * @intro
7
+ * Cryptographically chained audit trail for operator-side actions
8
+ * against the storefront — admin-console mutations, support-ticket
9
+ * state changes, manual refunds, price overrides, automated-job
10
+ * activity. Distinct from `b.audit` (the framework's lower-level
11
+ * record of cryptographic / authentication events) and from
12
+ * `analytics` (the customer-side behavioural stream).
13
+ *
14
+ * Every row stores `prev_hash` + `row_hash`. The chain math is
15
+ *
16
+ * row_hash = SHA3-512(prev_hash || canonical-json(row-without-hashes))
17
+ *
18
+ * `canonical-json` is the framework's RFC 8785 walker so the
19
+ * preimage is byte-identical across deployments. `verifyChain`
20
+ * walks the table in `(occurred_at, id)` order, recomputing each
21
+ * hash and reporting the first row where stored ≠ computed — any
22
+ * post-hoc edit to a recorded row, or a deletion that breaks the
23
+ * linkage, surfaces with `{ ok: false, reason, break_at, ... }`.
24
+ *
25
+ * Surface:
26
+ * - record({ actor_type, actor_id, action, resource_kind,
27
+ * resource_id, before?, after?, ip_hash?, ua_class? })
28
+ * Append a row. `before` / `after` are arbitrary JSON-safe
29
+ * shapes that document the state delta (or `null` for
30
+ * create / delete actions). Returns the persisted row with
31
+ * its assigned id + occurred_at + prev_hash + row_hash.
32
+ *
33
+ * - listByActor({ actor_id, from?, to?, cursor? })
34
+ * Newest-first feed for a single actor, optionally clipped to
35
+ * `[from, to)` epoch-ms range. Returns
36
+ * `{ rows, next_cursor }` — `next_cursor` is opaque, pass it
37
+ * back as `cursor` to fetch the next page.
38
+ *
39
+ * - listByResource({ resource_kind, resource_id, cursor? })
40
+ * Newest-first feed for a single resource (e.g. every change
41
+ * to product X).
42
+ *
43
+ * - searchAction({ action, from?, to? })
44
+ * Find every row whose `action` matches exactly, optionally
45
+ * within an epoch-ms range. Used for queries like "every
46
+ * manual refund this month".
47
+ *
48
+ * - chainHead()
49
+ * Current head hash — the `row_hash` of the most recent row,
50
+ * or the ZERO sentinel when the table is empty. Sign + store
51
+ * externally for tamper-evidence beyond the on-disk row.
52
+ *
53
+ * - verifyChain()
54
+ * Recompute every row's hash and flag the first divergence.
55
+ * O(n) in row count; intended for operator-initiated audits,
56
+ * not hot-path use.
57
+ *
58
+ * Composition:
59
+ * - `b.auditChain.canonicalize` — single source of truth for the
60
+ * canonical-json preimage shape (sorted keys, hash columns
61
+ * excluded, RFC 8785 number formatting).
62
+ * - `b.auditChain.ZERO_HASH` — anchor for the first row's
63
+ * prev_hash.
64
+ * - `b.crypto.sha3Hash` — SHA3-512 hex digest.
65
+ * - `b.uuid.v7` — row id; v7 sorts lexicographically by insertion
66
+ * time so the chain order matches the row id order.
67
+ *
68
+ * Storage:
69
+ * - operator_audit_events (migration 0074_operator_audit_log.sql).
70
+ *
71
+ * @primitive operatorAuditLog
72
+ * @related b.auditChain, b.crypto.sha3Hash, b.uuid.v7
73
+ */
74
+
75
+ var MAX_ACTOR_ID_LEN = 128;
76
+ var MAX_ACTION_LEN = 128;
77
+ var MAX_RESOURCE_KIND_LEN = 64;
78
+ var MAX_RESOURCE_ID_LEN = 128;
79
+ var MAX_JSON_BYTES = 64 * 1024;
80
+ var MAX_IP_HASH_LEN = 256;
81
+ var MAX_LIST_LIMIT = 200;
82
+ var DEFAULT_LIST_LIMIT = 50;
83
+ var SHA3_512_HEX_LEN = 128;
84
+
85
+ var ZERO_HASH = "0".repeat(SHA3_512_HEX_LEN);
86
+
87
+ var ALLOWED_ACTOR_TYPES = Object.freeze(["operator", "system", "app"]);
88
+ var ALLOWED_UA_CLASSES = Object.freeze([
89
+ "browser",
90
+ "mobile_app",
91
+ "api_client",
92
+ "cli",
93
+ "bot",
94
+ "unknown",
95
+ ]);
96
+
97
+ // Refuse C0 control bytes + DEL in operator-authored strings. The
98
+ // audit log surfaces these strings back to the operator console; the
99
+ // discipline matches every other operator-facing primitive (catalog,
100
+ // experiments, promo-banners) — strings reach the dashboard as inert
101
+ // text, never as live markup.
102
+ var CONTROL_BYTE_RE = /[\x00-\x1f\x7f]/;
103
+ var ZERO_WIDTH_RE = new RegExp(
104
+ "[\\u200B-\\u200F\\u202A-\\u202E\\u2060-\\u2064\\u2066-\\u2069\\uFEFF\\u061C]"
105
+ );
106
+
107
+ var IDENT_RE = /^[A-Za-z0-9][A-Za-z0-9._:\-]{0,127}$/;
108
+ var ACTION_RE = /^[A-Za-z0-9][A-Za-z0-9._:\-]{0,127}$/;
109
+ var KIND_RE = /^[A-Za-z0-9][A-Za-z0-9._\-]{0,63}$/;
110
+
111
+ var bShop;
112
+ function _b() {
113
+ if (!bShop) bShop = require("./index");
114
+ return bShop.framework;
115
+ }
116
+
117
+ // ---- validators ---------------------------------------------------------
118
+
119
+ function _actorType(s) {
120
+ if (typeof s !== "string" || ALLOWED_ACTOR_TYPES.indexOf(s) === -1) {
121
+ throw new TypeError("operatorAuditLog: actor_type must be one of " +
122
+ JSON.stringify(ALLOWED_ACTOR_TYPES));
123
+ }
124
+ return s;
125
+ }
126
+
127
+ function _uaClass(s) {
128
+ if (s == null) return null;
129
+ if (typeof s !== "string" || ALLOWED_UA_CLASSES.indexOf(s) === -1) {
130
+ throw new TypeError("operatorAuditLog: ua_class must be null or one of " +
131
+ JSON.stringify(ALLOWED_UA_CLASSES));
132
+ }
133
+ return s;
134
+ }
135
+
136
+ function _ident(s, label, re, maxLen) {
137
+ if (typeof s !== "string" || !re.test(s)) {
138
+ throw new TypeError("operatorAuditLog: " + label +
139
+ " must match " + re.toString() + " (≤ " + maxLen + " chars)");
140
+ }
141
+ return s;
142
+ }
143
+
144
+ function _ipHash(s) {
145
+ if (s == null) return null;
146
+ if (typeof s !== "string" || !s.length || s.length > MAX_IP_HASH_LEN) {
147
+ throw new TypeError("operatorAuditLog: ip_hash must be a non-empty string ≤ " +
148
+ MAX_IP_HASH_LEN + " chars");
149
+ }
150
+ if (CONTROL_BYTE_RE.test(s) || ZERO_WIDTH_RE.test(s)) {
151
+ throw new TypeError("operatorAuditLog: ip_hash contains control / zero-width characters");
152
+ }
153
+ return s;
154
+ }
155
+
156
+ function _jsonValue(v, label) {
157
+ if (v === undefined || v === null) return null;
158
+ // Refuse non-plain-JSON shapes upfront — operators occasionally hand
159
+ // a Date / Buffer through and would otherwise discover the silent
160
+ // serialization difference at verify-chain time.
161
+ var encoded;
162
+ try { encoded = JSON.stringify(v); }
163
+ catch (_e) {
164
+ throw new TypeError("operatorAuditLog: " + label + " is not JSON-serializable");
165
+ }
166
+ if (encoded == null) {
167
+ // JSON.stringify(undefined) returns undefined.
168
+ throw new TypeError("operatorAuditLog: " + label + " is not JSON-serializable");
169
+ }
170
+ if (Buffer.byteLength(encoded, "utf8") > MAX_JSON_BYTES) {
171
+ throw new TypeError("operatorAuditLog: " + label + " JSON exceeds " +
172
+ MAX_JSON_BYTES + " bytes");
173
+ }
174
+ return v;
175
+ }
176
+
177
+ function _epochMs(n, label) {
178
+ if (!Number.isInteger(n) || n < 0) {
179
+ throw new TypeError("operatorAuditLog: " + label +
180
+ " must be a non-negative integer (epoch ms)");
181
+ }
182
+ return n;
183
+ }
184
+
185
+ function _limit(n) {
186
+ if (n == null) return DEFAULT_LIST_LIMIT;
187
+ if (!Number.isInteger(n) || n < 1 || n > MAX_LIST_LIMIT) {
188
+ throw new TypeError("operatorAuditLog: limit must be an integer in [1, " +
189
+ MAX_LIST_LIMIT + "]");
190
+ }
191
+ return n;
192
+ }
193
+
194
+ function _now() { return Date.now(); }
195
+
196
+ // ---- opaque cursor -----------------------------------------------------
197
+ //
198
+ // The cursor encodes (occurred_at, id) — the keyset that drives every
199
+ // listBy* query — as a base64url-encoded JSON tuple. No HMAC: the
200
+ // cursor exposes nothing not already visible in the previous page's
201
+ // last row, and the index keyset is monotonic so a hand-crafted cursor
202
+ // can only seek forward through history.
203
+
204
+ function _encodeCursor(occurredAt, id) {
205
+ var raw = JSON.stringify([occurredAt, id]);
206
+ return Buffer.from(raw, "utf8").toString("base64url");
207
+ }
208
+
209
+ function _decodeCursor(cursor, label) {
210
+ if (cursor == null) return null;
211
+ if (typeof cursor !== "string" || !cursor.length) {
212
+ throw new TypeError("operatorAuditLog." + label +
213
+ ": cursor must be an opaque string or null");
214
+ }
215
+ var raw;
216
+ try { raw = Buffer.from(cursor, "base64url").toString("utf8"); }
217
+ catch (_e) {
218
+ throw new TypeError("operatorAuditLog." + label + ": cursor malformed");
219
+ }
220
+ var parsed;
221
+ try { parsed = JSON.parse(raw); }
222
+ catch (_e) {
223
+ throw new TypeError("operatorAuditLog." + label + ": cursor malformed");
224
+ }
225
+ if (!Array.isArray(parsed) || parsed.length !== 2) {
226
+ throw new TypeError("operatorAuditLog." + label + ": cursor malformed");
227
+ }
228
+ if (!Number.isInteger(parsed[0]) || parsed[0] < 0 ||
229
+ typeof parsed[1] !== "string" || !parsed[1].length) {
230
+ throw new TypeError("operatorAuditLog." + label + ": cursor malformed");
231
+ }
232
+ return { occurred_at: parsed[0], id: parsed[1] };
233
+ }
234
+
235
+ // ---- chain hashing ------------------------------------------------------
236
+ //
237
+ // The preimage layout is:
238
+ //
239
+ // row_hash = SHA3-512(prev_hash || canonical-json(row-fields))
240
+ //
241
+ // where row-fields is every column except prev_hash + row_hash. The
242
+ // canonical-json walker (b.auditChain.canonicalize) sorts keys and
243
+ // renders values per RFC 8785 so the preimage is byte-identical
244
+ // across deployments + node versions.
245
+
246
+ function _rowFieldsForHash(row) {
247
+ return {
248
+ id: row.id,
249
+ actor_type: row.actor_type,
250
+ actor_id: row.actor_id,
251
+ action: row.action,
252
+ resource_kind: row.resource_kind,
253
+ resource_id: row.resource_id,
254
+ before: row.before == null ? null : row.before,
255
+ after: row.after == null ? null : row.after,
256
+ ip_hash: row.ip_hash == null ? null : row.ip_hash,
257
+ ua_class: row.ua_class == null ? null : row.ua_class,
258
+ occurred_at: row.occurred_at,
259
+ };
260
+ }
261
+
262
+ function _computeRowHash(prevHash, rowFields) {
263
+ var canonical = _b().auditChain.canonicalize(rowFields, ["prev_hash", "row_hash"]);
264
+ var preimage = Buffer.concat([
265
+ Buffer.from(prevHash, "hex"),
266
+ Buffer.from(canonical, "utf8"),
267
+ ]);
268
+ return _b().crypto.sha3Hash(preimage);
269
+ }
270
+
271
+ // ---- row hydration ------------------------------------------------------
272
+
273
+ function _hydrate(r) {
274
+ if (!r) return null;
275
+ var before = null;
276
+ if (r.before_json != null) {
277
+ try { before = JSON.parse(r.before_json); }
278
+ catch (_e) { before = null; }
279
+ }
280
+ var after = null;
281
+ if (r.after_json != null) {
282
+ try { after = JSON.parse(r.after_json); }
283
+ catch (_e) { after = null; }
284
+ }
285
+ return {
286
+ id: r.id,
287
+ actor_type: r.actor_type,
288
+ actor_id: r.actor_id,
289
+ action: r.action,
290
+ resource_kind: r.resource_kind,
291
+ resource_id: r.resource_id,
292
+ before: before,
293
+ after: after,
294
+ ip_hash: r.ip_hash == null ? null : r.ip_hash,
295
+ ua_class: r.ua_class == null ? null : r.ua_class,
296
+ occurred_at: Number(r.occurred_at),
297
+ prev_hash: r.prev_hash,
298
+ row_hash: r.row_hash,
299
+ };
300
+ }
301
+
302
+ // ---- factory ------------------------------------------------------------
303
+
304
+ function create(opts) {
305
+ opts = opts || {};
306
+ var query = opts.query;
307
+ if (!query) {
308
+ query = function (sql, params) { return _b().externalDb.query(sql, params); };
309
+ }
310
+
311
+ async function _currentHead() {
312
+ var r = await query(
313
+ "SELECT row_hash, occurred_at, id FROM operator_audit_events " +
314
+ "ORDER BY occurred_at DESC, id DESC LIMIT 1",
315
+ [],
316
+ );
317
+ if (!r.rows.length) {
318
+ return { row_hash: ZERO_HASH, occurred_at: 0, id: null };
319
+ }
320
+ return {
321
+ row_hash: r.rows[0].row_hash,
322
+ occurred_at: Number(r.rows[0].occurred_at),
323
+ id: r.rows[0].id,
324
+ };
325
+ }
326
+
327
+ // -- record ------------------------------------------------------------
328
+
329
+ async function record(input) {
330
+ if (!input || typeof input !== "object") {
331
+ throw new TypeError("operatorAuditLog.record: input object required");
332
+ }
333
+ var actorType = _actorType(input.actor_type);
334
+ var actorId = _ident(input.actor_id, "actor_id", IDENT_RE, MAX_ACTOR_ID_LEN);
335
+ var action = _ident(input.action, "action", ACTION_RE, MAX_ACTION_LEN);
336
+ var resourceKind = _ident(input.resource_kind, "resource_kind", KIND_RE, MAX_RESOURCE_KIND_LEN);
337
+ var resourceId = _ident(input.resource_id, "resource_id", IDENT_RE, MAX_RESOURCE_ID_LEN);
338
+ var before = _jsonValue(input.before, "before");
339
+ var after = _jsonValue(input.after, "after");
340
+ var ipHash = _ipHash(input.ip_hash);
341
+ var uaClass = _uaClass(input.ua_class);
342
+
343
+ var head = await _currentHead();
344
+ var prevHash = head.row_hash;
345
+ var id = _b().uuid.v7();
346
+ // Clamp occurred_at to be strictly monotonic — same-millisecond
347
+ // appends on a fast runner still produce a deterministic chain
348
+ // order because the (occurred_at, id) tuple breaks ties on the v7
349
+ // id. If wall-clock has drifted backwards relative to the head
350
+ // (NTP step, container clock skew) we bump to head + 1ms so the
351
+ // chain stays insertion-ordered. The v7 id is generated AFTER the
352
+ // bump so its embedded timestamp matches the stored column.
353
+ var occurredAt = _now();
354
+ if (occurredAt <= head.occurred_at) {
355
+ occurredAt = head.occurred_at + 1;
356
+ }
357
+
358
+ var rowFields = {
359
+ id: id,
360
+ actor_type: actorType,
361
+ actor_id: actorId,
362
+ action: action,
363
+ resource_kind: resourceKind,
364
+ resource_id: resourceId,
365
+ before: before == null ? null : before,
366
+ after: after == null ? null : after,
367
+ ip_hash: ipHash,
368
+ ua_class: uaClass,
369
+ occurred_at: occurredAt,
370
+ };
371
+ var rowHash = _computeRowHash(prevHash, rowFields);
372
+
373
+ var beforeJson = before == null ? null : JSON.stringify(before);
374
+ var afterJson = after == null ? null : JSON.stringify(after);
375
+
376
+ await query(
377
+ "INSERT INTO operator_audit_events " +
378
+ "(id, actor_type, actor_id, action, resource_kind, resource_id, " +
379
+ " before_json, after_json, ip_hash, ua_class, occurred_at, prev_hash, row_hash) " +
380
+ "VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13)",
381
+ [id, actorType, actorId, action, resourceKind, resourceId,
382
+ beforeJson, afterJson, ipHash, uaClass, occurredAt, prevHash, rowHash],
383
+ );
384
+
385
+ return {
386
+ id: id,
387
+ actor_type: actorType,
388
+ actor_id: actorId,
389
+ action: action,
390
+ resource_kind: resourceKind,
391
+ resource_id: resourceId,
392
+ before: before == null ? null : before,
393
+ after: after == null ? null : after,
394
+ ip_hash: ipHash,
395
+ ua_class: uaClass,
396
+ occurred_at: occurredAt,
397
+ prev_hash: prevHash,
398
+ row_hash: rowHash,
399
+ };
400
+ }
401
+
402
+ // -- listByActor -------------------------------------------------------
403
+
404
+ async function listByActor(listOpts) {
405
+ if (!listOpts || typeof listOpts !== "object") {
406
+ throw new TypeError("operatorAuditLog.listByActor: input object required");
407
+ }
408
+ var actorId = _ident(listOpts.actor_id, "actor_id", IDENT_RE, MAX_ACTOR_ID_LEN);
409
+ var from = listOpts.from == null ? null : _epochMs(listOpts.from, "from");
410
+ var to = listOpts.to == null ? null : _epochMs(listOpts.to, "to");
411
+ if (from != null && to != null && to < from) {
412
+ throw new TypeError("operatorAuditLog.listByActor: to must be ≥ from");
413
+ }
414
+ var limit = _limit(listOpts.limit);
415
+ var cursor = _decodeCursor(listOpts.cursor, "listByActor");
416
+
417
+ var where = ["actor_id = ?1"];
418
+ var params = [actorId];
419
+ var idx = 2;
420
+ if (from != null) {
421
+ where.push("occurred_at >= ?" + idx);
422
+ params.push(from);
423
+ idx += 1;
424
+ }
425
+ if (to != null) {
426
+ where.push("occurred_at < ?" + idx);
427
+ params.push(to);
428
+ idx += 1;
429
+ }
430
+ if (cursor) {
431
+ where.push(
432
+ "(occurred_at < ?" + idx + " OR " +
433
+ "(occurred_at = ?" + idx + " AND id < ?" + (idx + 1) + "))"
434
+ );
435
+ params.push(cursor.occurred_at, cursor.id);
436
+ idx += 2;
437
+ }
438
+ params.push(limit);
439
+ var sql = "SELECT * FROM operator_audit_events WHERE " + where.join(" AND ") +
440
+ " ORDER BY occurred_at DESC, id DESC LIMIT ?" + idx;
441
+ var r = await query(sql, params);
442
+ var rows = r.rows.map(_hydrate);
443
+ return {
444
+ rows: rows,
445
+ next_cursor: _nextCursor(rows, limit),
446
+ };
447
+ }
448
+
449
+ // -- listByResource ----------------------------------------------------
450
+
451
+ async function listByResource(listOpts) {
452
+ if (!listOpts || typeof listOpts !== "object") {
453
+ throw new TypeError("operatorAuditLog.listByResource: input object required");
454
+ }
455
+ var resourceKind = _ident(listOpts.resource_kind, "resource_kind", KIND_RE, MAX_RESOURCE_KIND_LEN);
456
+ var resourceId = _ident(listOpts.resource_id, "resource_id", IDENT_RE, MAX_RESOURCE_ID_LEN);
457
+ var limit = _limit(listOpts.limit);
458
+ var cursor = _decodeCursor(listOpts.cursor, "listByResource");
459
+
460
+ var where = ["resource_kind = ?1", "resource_id = ?2"];
461
+ var params = [resourceKind, resourceId];
462
+ var idx = 3;
463
+ if (cursor) {
464
+ where.push(
465
+ "(occurred_at < ?" + idx + " OR " +
466
+ "(occurred_at = ?" + idx + " AND id < ?" + (idx + 1) + "))"
467
+ );
468
+ params.push(cursor.occurred_at, cursor.id);
469
+ idx += 2;
470
+ }
471
+ params.push(limit);
472
+ var sql = "SELECT * FROM operator_audit_events WHERE " + where.join(" AND ") +
473
+ " ORDER BY occurred_at DESC, id DESC LIMIT ?" + idx;
474
+ var r = await query(sql, params);
475
+ var rows = r.rows.map(_hydrate);
476
+ return {
477
+ rows: rows,
478
+ next_cursor: _nextCursor(rows, limit),
479
+ };
480
+ }
481
+
482
+ // -- searchAction ------------------------------------------------------
483
+
484
+ async function searchAction(searchOpts) {
485
+ if (!searchOpts || typeof searchOpts !== "object") {
486
+ throw new TypeError("operatorAuditLog.searchAction: input object required");
487
+ }
488
+ var action = _ident(searchOpts.action, "action", ACTION_RE, MAX_ACTION_LEN);
489
+ var from = searchOpts.from == null ? null : _epochMs(searchOpts.from, "from");
490
+ var to = searchOpts.to == null ? null : _epochMs(searchOpts.to, "to");
491
+ if (from != null && to != null && to < from) {
492
+ throw new TypeError("operatorAuditLog.searchAction: to must be ≥ from");
493
+ }
494
+ var limit = _limit(searchOpts.limit);
495
+ var cursor = _decodeCursor(searchOpts.cursor, "searchAction");
496
+
497
+ var where = ["action = ?1"];
498
+ var params = [action];
499
+ var idx = 2;
500
+ if (from != null) {
501
+ where.push("occurred_at >= ?" + idx);
502
+ params.push(from);
503
+ idx += 1;
504
+ }
505
+ if (to != null) {
506
+ where.push("occurred_at < ?" + idx);
507
+ params.push(to);
508
+ idx += 1;
509
+ }
510
+ if (cursor) {
511
+ where.push(
512
+ "(occurred_at < ?" + idx + " OR " +
513
+ "(occurred_at = ?" + idx + " AND id < ?" + (idx + 1) + "))"
514
+ );
515
+ params.push(cursor.occurred_at, cursor.id);
516
+ idx += 2;
517
+ }
518
+ params.push(limit);
519
+ var sql = "SELECT * FROM operator_audit_events WHERE " + where.join(" AND ") +
520
+ " ORDER BY occurred_at DESC, id DESC LIMIT ?" + idx;
521
+ var r = await query(sql, params);
522
+ var rows = r.rows.map(_hydrate);
523
+ return {
524
+ rows: rows,
525
+ next_cursor: _nextCursor(rows, limit),
526
+ };
527
+ }
528
+
529
+ function _nextCursor(rows, limit) {
530
+ if (rows.length < limit) return null;
531
+ var last = rows[rows.length - 1];
532
+ return _encodeCursor(last.occurred_at, last.id);
533
+ }
534
+
535
+ // -- chainHead ---------------------------------------------------------
536
+
537
+ async function chainHead() {
538
+ var head = await _currentHead();
539
+ return head.row_hash;
540
+ }
541
+
542
+ // -- verifyChain -------------------------------------------------------
543
+
544
+ async function verifyChain() {
545
+ var r = await query(
546
+ "SELECT * FROM operator_audit_events ORDER BY occurred_at ASC, id ASC",
547
+ [],
548
+ );
549
+ var rows = r.rows;
550
+ if (!rows.length) {
551
+ return { ok: true, rows_verified: 0, last_hash: ZERO_HASH };
552
+ }
553
+ var prevHash = ZERO_HASH;
554
+ for (var i = 0; i < rows.length; i += 1) {
555
+ var row = rows[i];
556
+ if (row.prev_hash !== prevHash) {
557
+ return {
558
+ ok: false,
559
+ rows_verified: i,
560
+ break_at: i,
561
+ break_row_id: row.id,
562
+ reason: "prev_hash mismatch",
563
+ expected: prevHash,
564
+ actual: row.prev_hash,
565
+ };
566
+ }
567
+ // Reconstruct the row-fields tuple the same way `record` did,
568
+ // then re-canonicalize + re-hash.
569
+ var hydrated = _hydrate(row);
570
+ var rowFields = _rowFieldsForHash(hydrated);
571
+ var computed = _computeRowHash(prevHash, rowFields);
572
+ if (computed !== row.row_hash) {
573
+ return {
574
+ ok: false,
575
+ rows_verified: i,
576
+ break_at: i,
577
+ break_row_id: row.id,
578
+ reason: "row_hash mismatch",
579
+ expected: computed,
580
+ actual: row.row_hash,
581
+ };
582
+ }
583
+ prevHash = row.row_hash;
584
+ }
585
+ return { ok: true, rows_verified: rows.length, last_hash: prevHash };
586
+ }
587
+
588
+ return {
589
+ MAX_ACTOR_ID_LEN: MAX_ACTOR_ID_LEN,
590
+ MAX_ACTION_LEN: MAX_ACTION_LEN,
591
+ MAX_RESOURCE_KIND_LEN: MAX_RESOURCE_KIND_LEN,
592
+ MAX_RESOURCE_ID_LEN: MAX_RESOURCE_ID_LEN,
593
+ MAX_JSON_BYTES: MAX_JSON_BYTES,
594
+ MAX_LIST_LIMIT: MAX_LIST_LIMIT,
595
+ DEFAULT_LIST_LIMIT: DEFAULT_LIST_LIMIT,
596
+ ALLOWED_ACTOR_TYPES: ALLOWED_ACTOR_TYPES,
597
+ ALLOWED_UA_CLASSES: ALLOWED_UA_CLASSES,
598
+ ZERO_HASH: ZERO_HASH,
599
+
600
+ record: record,
601
+ listByActor: listByActor,
602
+ listByResource: listByResource,
603
+ searchAction: searchAction,
604
+ chainHead: chainHead,
605
+ verifyChain: verifyChain,
606
+ };
607
+ }
608
+
609
+ module.exports = {
610
+ create: create,
611
+ MAX_ACTOR_ID_LEN: MAX_ACTOR_ID_LEN,
612
+ MAX_ACTION_LEN: MAX_ACTION_LEN,
613
+ MAX_RESOURCE_KIND_LEN: MAX_RESOURCE_KIND_LEN,
614
+ MAX_RESOURCE_ID_LEN: MAX_RESOURCE_ID_LEN,
615
+ MAX_JSON_BYTES: MAX_JSON_BYTES,
616
+ MAX_LIST_LIMIT: MAX_LIST_LIMIT,
617
+ DEFAULT_LIST_LIMIT: DEFAULT_LIST_LIMIT,
618
+ ALLOWED_ACTOR_TYPES: ALLOWED_ACTOR_TYPES,
619
+ ALLOWED_UA_CLASSES: ALLOWED_UA_CLASSES,
620
+ ZERO_HASH: ZERO_HASH,
621
+ };