@voltro/plugin-audit 0.85.0 → 0.86.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.
@@ -1,11 +1,11 @@
1
1
  # Third-Party Notices — @voltro/plugin-audit
2
2
 
3
- This package depends on the open-source software listed below. Each is the
3
+ This package uses the open-source software listed below. Each is the
4
4
  property of its respective copyright holders and is used under the terms of
5
5
  its license. This file is provided for attribution; it grants no rights in
6
6
  @voltro/plugin-audit itself, which is proprietary (see LICENSE).
7
7
 
8
- Generated from the resolved runtime dependency closure (5 packages).
8
+ Generated from the resolved runtime dependency closure (4 packages).
9
9
 
10
10
  ---
11
11
 
@@ -37,34 +37,6 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
37
37
  SOFTWARE.
38
38
  ```
39
39
 
40
- ## layerr@3.0.0
41
-
42
- License: MIT
43
-
44
- ```
45
- MIT License
46
-
47
- Copyright (c) 2020 Perry Mitchell
48
-
49
- Permission is hereby granted, free of charge, to any person obtaining a copy
50
- of this software and associated documentation files (the "Software"), to deal
51
- in the Software without restriction, including without limitation the rights
52
- to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
53
- copies of the Software, and to permit persons to whom the Software is
54
- furnished to do so, subject to the following conditions:
55
-
56
- The above copyright notice and this permission notice shall be included in all
57
- copies or substantial portions of the Software.
58
-
59
- THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
60
- IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
61
- FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
62
- AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
63
- LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
64
- OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
65
- SOFTWARE.
66
- ```
67
-
68
40
  ## typeid-js@1.2.0
69
41
 
70
42
  License: Apache-2.0
@@ -273,14 +245,14 @@ Apache License
273
245
  limitations under the License.
274
246
  ```
275
247
 
276
- ## ulidx@2.4.1
248
+ ## ulid@3.0.2
277
249
 
278
250
  License: MIT
279
251
 
280
252
  ```
281
- MIT License
253
+ The MIT License (MIT)
282
254
 
283
- Copyright (c) 2021 Perry Mitchell
255
+ Copyright (c) 2017 Alizain Feerasta
284
256
 
285
257
  Permission is hereby granted, free of charge, to any person obtaining a copy
286
258
  of this software and associated documentation files (the "Software"), to deal
package/dist/chain.js ADDED
@@ -0,0 +1,205 @@
1
+ import { AUDIT_LOG_TABLE as e } from "./tableName.js";
2
+ import { auditInvocationIdOf as t, hasConsistentAuditInvocationId as n } from "./invocationIdentity.js";
3
+ import { and as r, column as i, eq as a, generateId as o, gt as s, isNotNull as c, isNull as l, lte as u, or as d } from "@voltro/database";
4
+ import { createHash as f, createHmac as p, randomBytes as m } from "node:crypto";
5
+ //#region src/chain.ts
6
+ var h = [
7
+ "tag",
8
+ "at",
9
+ "subjectId",
10
+ "tenantId",
11
+ "traceId",
12
+ "status",
13
+ "durationMs",
14
+ "subject",
15
+ "actor",
16
+ "scope",
17
+ "metadata",
18
+ "input",
19
+ "outcome",
20
+ "errorTag"
21
+ ], g = [
22
+ ...h,
23
+ "hashVersion",
24
+ "id",
25
+ "impersonation",
26
+ "scopeKey"
27
+ ], _ = (e) => {
28
+ if (e == null) return "null";
29
+ if (e instanceof Date) return JSON.stringify(e.toISOString());
30
+ let t = typeof e;
31
+ return t === "number" || t === "boolean" || t === "string" ? JSON.stringify(e) : t === "bigint" ? JSON.stringify(String(e)) : Array.isArray(e) ? `[${e.map(_).join(",")}]` : t === "object" ? `{${Object.entries(e).filter(([, e]) => e !== void 0).sort(([e], [t]) => e < t ? -1 : +(e > t)).map(([e, t]) => `${JSON.stringify(e)}:${_(t)}`).join(",")}}` : JSON.stringify(String(e));
32
+ }, v = (e) => e instanceof Date ? e.toISOString() : e, y = (e, t) => _(Object.fromEntries((t === 2 ? g : h).map((t) => [t, v(e[t] ?? null)]))), b = (e) => {
33
+ let t = process.env.VOLTRO_AUDIT_CHAIN_SECRET;
34
+ return t ? p("sha256", t).update(e, "utf8").digest("hex") : f("sha256").update(e, "utf8").digest("hex");
35
+ }, x = (e, t) => {
36
+ let n = e.hashVersion ?? 1;
37
+ if (n !== 1 && n !== 2) throw TypeError("Unsupported audit hash version");
38
+ return b(JSON.stringify([
39
+ t.chainId,
40
+ t.seq,
41
+ t.prevHash,
42
+ y(e, n)
43
+ ]));
44
+ }, S = (e = m(12).toString("base64url")) => {
45
+ let n = 0, r = null;
46
+ return {
47
+ chainId: e,
48
+ link: (i) => {
49
+ n += 1;
50
+ let a = {
51
+ ...i,
52
+ invocationId: t(i.outcome),
53
+ id: i.id ?? o({
54
+ kind: "typeid",
55
+ prefix: "audit"
56
+ }, "_voltro_audit_log"),
57
+ hashVersion: 2
58
+ }, s = x(a, {
59
+ chainId: e,
60
+ seq: n,
61
+ prevHash: r
62
+ }), c = {
63
+ ...a,
64
+ chainId: e,
65
+ seq: n,
66
+ prevHash: r,
67
+ hash: s
68
+ };
69
+ return r = s, c;
70
+ }
71
+ };
72
+ };
73
+ async function* C(t, n) {
74
+ let o = n.chainId === void 0 ? void 0 : a("chainId", n.chainId), f = (t, n, r) => ({
75
+ table: e,
76
+ predicate: t,
77
+ order: r,
78
+ take: n,
79
+ skip: void 0,
80
+ projection: void 0
81
+ });
82
+ if (n.limit !== void 0) {
83
+ yield* await t.query(f(o, n.limit, [{
84
+ column: "chainId",
85
+ direction: "asc"
86
+ }, {
87
+ column: "seq",
88
+ direction: "asc"
89
+ }]));
90
+ return;
91
+ }
92
+ let p = [...await t.query({
93
+ ...f(o, void 0, [{
94
+ column: "chainId",
95
+ direction: "asc"
96
+ }]),
97
+ groupBy: ["chainId"],
98
+ aggregations: [
99
+ i("chainId"),
100
+ {
101
+ op: "max",
102
+ column: "seq",
103
+ alias: "lastSeq"
104
+ },
105
+ {
106
+ op: "max",
107
+ column: "id",
108
+ alias: "lastId"
109
+ }
110
+ ]
111
+ })].sort((e, t) => String(e.chainId) < String(t.chainId) ? -1 : +(String(e.chainId) > String(t.chainId)));
112
+ for (let e of p) {
113
+ let n = e.chainId, i = n == null ? l("chainId") : a("chainId", n), o = n == null ? [!1] : [!1, !0];
114
+ for (let p of o) {
115
+ if (p && e.lastSeq == null) continue;
116
+ let o = [i, u("id", e.lastId)];
117
+ n != null && o.push(p ? r(c("seq"), u("seq", e.lastSeq)) : l("seq"));
118
+ let m;
119
+ for (;;) {
120
+ let e = m === void 0 ? void 0 : p ? d(s("seq", m.seq), r(a("seq", m.seq), s("id", m.id))) : s("id", m.id), n = await t.query(f(r(...o, ...e ? [e] : []), 1e3, [...p ? [{
121
+ column: "seq",
122
+ direction: "asc"
123
+ }] : [], {
124
+ column: "id",
125
+ direction: "asc"
126
+ }]));
127
+ if (yield* n, n.length < 1e3) break;
128
+ let i = n[n.length - 1];
129
+ if (i.id === void 0 || i.id === m?.id) throw Error("audit verification cursor did not advance");
130
+ m = i;
131
+ }
132
+ }
133
+ }
134
+ }
135
+ var w = async (e, t = {}) => {
136
+ let r = [], i = [], a = 0, o = 0, s = 0, c = null, l = () => {
137
+ c &&= (i.push({
138
+ chainId: c.chainId,
139
+ from: c.from,
140
+ to: c.last,
141
+ tip: c.tip,
142
+ prunedPrefix: c.from > 1
143
+ }), null);
144
+ };
145
+ for await (let i of C(e, t)) {
146
+ let e = i.chainId, t = i.hash, u = i.seq;
147
+ if (typeof e != "string" || typeof t != "string" || u == null) {
148
+ a += 1;
149
+ continue;
150
+ }
151
+ let d = Number(u), f = typeof i.prevHash == "string" ? i.prevHash : null, p = typeof i.id == "string" ? i.id : void 0;
152
+ s += 1, !c || c.chainId !== e ? (l(), c = {
153
+ chainId: e,
154
+ from: d,
155
+ last: d,
156
+ tip: t
157
+ }) : (d === c.last + 1 ? f !== c.tip && r.push({
158
+ kind: "broken-link",
159
+ chainId: e,
160
+ seq: d,
161
+ ...p ? { rowId: p } : {},
162
+ detail: `prevHash does not match the hash of seq ${c.last}`
163
+ }) : r.push({
164
+ kind: "gap",
165
+ chainId: e,
166
+ seq: c.last + 1,
167
+ detail: `chain jumps from seq ${c.last} to ${d} — ${d - c.last - 1} row(s) missing`
168
+ }), c.last = d, c.tip = t);
169
+ let m = i.hashVersion ?? 1;
170
+ m !== 1 && m !== 2 ? r.push({
171
+ kind: "tampered",
172
+ chainId: e,
173
+ seq: d,
174
+ ...p ? { rowId: p } : {},
175
+ detail: "unsupported audit hash version; the row cannot be verified"
176
+ }) : (m === 1 && (o += 1), n(i) ? x(i, {
177
+ chainId: e,
178
+ seq: d,
179
+ prevHash: f
180
+ }) !== t && r.push({
181
+ kind: "tampered",
182
+ chainId: e,
183
+ seq: d,
184
+ ...p ? { rowId: p } : {},
185
+ detail: "the row content does not hash to its stored hash — a column was altered"
186
+ }) : r.push({
187
+ kind: "tampered",
188
+ chainId: e,
189
+ seq: d,
190
+ ...p ? { rowId: p } : {},
191
+ detail: "the invocation lookup does not match its protected outcome"
192
+ }));
193
+ }
194
+ return l(), {
195
+ ok: r.length === 0,
196
+ rowsChecked: s,
197
+ unchainedRows: a,
198
+ legacyHashRows: o,
199
+ chains: i,
200
+ issues: r,
201
+ keyed: !!process.env.VOLTRO_AUDIT_CHAIN_SECRET
202
+ };
203
+ };
204
+ //#endregion
205
+ export { x as auditRowHash, S as makeAuditChain, w as verifyAuditChain };
package/dist/index.d.ts CHANGED
@@ -2,9 +2,11 @@ import { AuditActor } from '@voltro/database';
2
2
  import { ColumnDefinition } from '@voltro/database';
3
3
  import { Effect } from 'effect';
4
4
  import { MixinDefinition } from '@voltro/database';
5
+ import { Query } from '@voltro/database';
6
+ import { Row } from '@voltro/database';
7
+ import { SchemaTable } from '@voltro/database';
5
8
  import { Subject } from '@voltro/protocol';
6
9
  import { TableIndex } from '@voltro/database';
7
- import { TableLike } from '@voltro/database';
8
10
  import { VoltroPlugin } from '@voltro/protocol';
9
11
 
10
12
  export declare const audit: () => MixinDefinition<{
@@ -72,6 +74,8 @@ export declare type AuditChainIssueKind = 'tampered' | 'broken-link' | 'gap';
72
74
 
73
75
  /** The columns the chain adds to `_voltro_audit_log`. */
74
76
  export declare interface AuditChainLink {
77
+ /** NULL/1 is historical coverage; new writers use format 2. */
78
+ readonly hashVersion?: number | null;
75
79
  /** Which writer's chain this row belongs to. Null for an unchained row. */
76
80
  readonly chainId: string;
77
81
  /** 1-based position within `chainId`. */
@@ -102,6 +106,9 @@ export declare interface AuditChainVerification {
102
106
  * Not an `issue` (a pre-chain deployment is not tampering) but never
103
107
  * silently dropped either. */
104
108
  readonly unchainedRows: number;
109
+ /** Rows using the historical digest, which did not cover impersonation or
110
+ * the derived scope key. Optional for compatibility with older result DTOs. */
111
+ readonly legacyHashRows?: number;
105
112
  readonly chains: ReadonlyArray<AuditChainSummary>;
106
113
  readonly issues: ReadonlyArray<AuditChainIssue>;
107
114
  /** Whether the digest was keyed. An `ok: true` from an UNKEYED run is a
@@ -125,7 +132,7 @@ export declare interface AuditEvent {
125
132
  * (anonymous, system). Absent is honest; a fabricated placeholder would be
126
133
  * the thing this field exists to prevent.
127
134
  */
128
- readonly actor?: AuditActor | undefined;
135
+ readonly actor?: AuditActor | null | undefined;
129
136
  /** The app's own scoping dimension — opaque, stored verbatim, filterable.
130
137
  * `.with(tenant())` is one level too coarse for a per-team trail. */
131
138
  readonly scope?: unknown;
@@ -158,22 +165,38 @@ export declare interface AuditEvent {
158
165
  readonly kind: 'ok';
159
166
  readonly value: unknown;
160
167
  readonly durationMs: number;
168
+ readonly invocationId?: string;
161
169
  } | {
162
170
  readonly kind: 'error';
163
171
  readonly error: unknown;
164
172
  readonly durationMs: number;
173
+ readonly invocationId?: string;
174
+ } | {
175
+ readonly kind: 'unknown';
176
+ readonly invocationId: string;
177
+ readonly durationMs: number;
165
178
  };
166
179
  }
167
180
 
168
181
  /** Map an {@link AuditEvent} to an audit-log row. */
169
182
  export declare const auditEventToRow: (event: AuditEvent) => Record<string, unknown>;
170
183
 
184
+ /** Stable display identity when an unknown admission acquires a terminal row.
185
+ * A pagination cursor still uses the selected raw row's actual ordering key. */
186
+ export declare const auditInvocationIdentity: (row: Readonly<Record<string, unknown>>) => string;
187
+
188
+ /** One logical invocation per result, before filtering/order/pagination. Raw
189
+ * admissions remain immutable. Qualify caller predicates/order with audit. */
190
+ export declare const auditInvocationQuery: () => Query<Row, string, {
191
+ completed: Row;
192
+ }>;
193
+
171
194
  /**
172
195
  * The slice of a built `Table` this package exposes — annotated explicitly so
173
196
  * @voltro/database's private column-builder class doesn't leak across the
174
197
  * package boundary (TS4094). Only name + columns + indexes are needed.
175
198
  */
176
- export declare interface AuditLogTable extends TableLike {
199
+ export declare interface AuditLogTable extends SchemaTable {
177
200
  readonly fields: Record<string, ColumnDefinition<unknown>>;
178
201
  readonly appliedIndexes: ReadonlyArray<TableIndex>;
179
202
  }
@@ -200,6 +223,32 @@ export declare const auditLogTables: ReadonlyArray<AuditLogTable>;
200
223
  export declare const auditPlugin: (options?: AuditPluginOptions) => VoltroPlugin;
201
224
 
202
225
  export declare interface AuditPluginOptions {
226
+ /** Successful registered mutations may capture a required redacted intent on
227
+ * the current transaction outbox. Requires the literal datastore sink and the
228
+ * supported Runtime/CLI hook. Default best-effort recording is unchanged.
229
+ * Required scope/metadata/redaction/encoding failures roll back the mutation.
230
+ * Callbacks must remain free of external side effects: replay may repeat them.
231
+ * Refusals, actions, workflows and HTTP remain best effort in this mode. */
232
+ readonly mutationCapture?: 'best-effort' | 'transactional-outbox';
233
+ /** Registered mutation/action/workflow failures enqueue a redacted outcome
234
+ * after the business transaction has rolled back. Requires datastore sink
235
+ * and the current host's root outcome capability. Capture failures preserve
236
+ * the original operation error and report only bounded diagnostics. Query,
237
+ * HTTP and successful non-mutation capture are unchanged. */
238
+ readonly failureCapture?: 'best-effort' | 'outbox';
239
+ /** Successful registered actions and workflow-start calls enqueue a frozen
240
+ * redacted outcome through the host's root outbox. Requires datastore sink.
241
+ * This is durable delivery after completion, not a transaction around external
242
+ * side effects: admission failures preserve the successful business result and
243
+ * emit a bounded diagnostic. Queries, HTTP and mutation capture are unchanged. */
244
+ readonly successCapture?: 'best-effort' | 'outbox';
245
+ /** Required admission for selected action/workflow-start calls. Persist an
246
+ * immutable privacy-safe unknown outcome before executing their effect, then
247
+ * correlate the terminal outcome by invocationId. Requires datastore sink,
248
+ * success/failure outbox capture and record: all. Admission failure prevents
249
+ * execution; interruption leaves unknown evidence without claiming rollback.
250
+ * Mutations, queries and HTTP are unchanged. */
251
+ readonly invocationCapture?: 'best-effort' | 'required';
203
252
  /**
204
253
  * Namespace for this plugin's inspect surface + any rpc tags it contributes. Default `audit`.
205
254
  *
@@ -306,7 +355,7 @@ export declare interface AuditPluginOptions {
306
355
  * between an indexed read path and stuffing `teamId` into `metadata` and
307
356
  * scanning — a column documented as a free-form note is not a read path.
308
357
  *
309
- * Throwing here never fails the mutation being recorded: a scope that cannot
358
+ * In default best-effort mode, throwing never fails the mutation: a scope that cannot
310
359
  * be derived is null, the same answer as not configuring one.
311
360
  */
312
361
  readonly resolveScope?: (ctx: {
@@ -323,10 +372,10 @@ export declare interface AuditPluginOptions {
323
372
  *
324
373
  * Resolved AFTER the call, so it sees the result (`outcome.kind === 'ok'`
325
374
  * with `value`, or `'error'` with the error) — the note for a failed removal
326
- * is a different note. May be async. Throwing, rejecting, or returning
327
- * `undefined` records no note; it never fails the call being recorded, and
328
- * never delays it: the note is resolved on the same fire-and-forget path
329
- * the sink write takes.
375
+ * is a different note. May be async. In default best-effort mode, throwing, rejecting, or returning
376
+ * `undefined` records no note. Resolution is awaited on the interceptor's
377
+ * recording path, but failure does not replace the call's original outcome.
378
+ * Transactional capture requires successful callback resolution.
330
379
  *
331
380
  * ```ts
332
381
  * auditPlugin({
@@ -470,6 +519,13 @@ export declare interface AuditPluginOptions {
470
519
  * "who really did this" is not app-controlled metadata, it is the event.
471
520
  */
472
521
  readonly redactSubject?: 'metadata' | 'metadata-shape' | 'none' | ((subject: Subject) => unknown);
522
+ /** Project resolved actor displayName/email before capture or sink delivery.
523
+ * The framework retains id/type even if JavaScript returns extra fields.
524
+ * Return both labels as strings or null. The synchronous callback must be
525
+ * pure: required capture failure aborts its transaction; refusal admission
526
+ * keeps the original error. Decisions are frozen before enqueue, not rerun
527
+ * during delivery. Absent preserves the existing policy; no actor stays null. */
528
+ readonly redactActor?: (actor: Readonly<AuditActor>) => Pick<AuditActor, 'displayName' | 'email'>;
473
529
  /**
474
530
  * What happens to `AuditEvent.outcome`'s payload before it is handed to the
475
531
  * sink.
@@ -555,12 +611,17 @@ export declare type AuditSink = (event: AuditEvent) => void | Promise<void> | Ef
555
611
  export declare const clearAuditBuffer: () => void;
556
612
 
557
613
  /**
558
- * A DataStore-backed audit sink — appends each event to `_voltro_audit_log`.
559
- * Returns a `(event) => Promise<void>` so it plugs straight into the plugin's
560
- * function-sink path (which lifts it into an Effect + swallows failures, so a
561
- * write hiccup never masks the mutation).
614
+ * A DataStore-backed sink. Optional receiptId identifies one logical append
615
+ * across delivery retries and sink/process reconstruction. Stable receipts
616
+ * require a query-capable store and are refused if their content differs.
617
+ *
618
+ * A per-writer semaphore serializes append attempts. Read-capable stores derive
619
+ * the next link from persisted state, so rejected inserts and lost write
620
+ * acknowledgements cannot advance or fork the writer's chain. Insert-only
621
+ * substitutes retain the old surface, with in-memory state advanced on success.
622
+ * This is append recovery, not durable event capture before business commit.
562
623
  */
563
- export declare const dataStoreAuditSink: (store: AuditDataStore) => (event: AuditEvent) => Promise<void>;
624
+ export declare const dataStoreAuditSink: (store: AuditDataStore) => (event: AuditEvent, receiptId?: string) => Promise<void>;
564
625
 
565
626
  /** Mint a fresh chain. `chainId` is 96 bits of CSPRNG — collisions across
566
627
  * replicas would merge two chains into one apparent fork. */