@voltro/plugin-audit 0.37.0 → 0.38.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/CHANGELOG.md CHANGED
@@ -39,6 +39,98 @@ _Changes staged for the next release accumulate here (rolled up from
39
39
 
40
40
  ---
41
41
 
42
+ ## [0.38.0] — 2026-08-14
43
+
44
+ ### ⚠ BREAKING
45
+
46
+ - **@voltro/plugin-audit, @voltro/protocol, @voltro/plugin-auth** — An audit row now says when an action was taken through an IMPERSONATED session, on the default settings, and no redactor can take that away.
47
+
48
+ `AuditEvent` gained `impersonation`, and the datastore sink a nullable json column of the same name. The mark is lifted out of `subject.metadata` BEFORE the redaction chain runs, so `redactSubject` — including a custom function that erases the subject wholesale — never gets a say.
49
+
50
+ **The defect it closes.** `@voltro/plugin-auth` mints the mark into `subject.metadata`, and `auditPlugin`'s default `redactSubject: 'metadata'` replaces that whole bag. That default is right: the bag is where a per-user provider credential lands, and an audit table is the last place a live PAT should be. The consequence was that on defaults an impersonated action was recorded indistinguishably from the user's own — the one distinction an audit trail exists to make. The documented mitigation (`redactSubject: impersonationAuditRedactor()`) worked and was opt-in, and an audit property that depends on somebody wiring it is not a property.
51
+
52
+ The alternative fix — a keep-these-keys option on `redactSubject` — was rejected for the same reason: it leaves the default wrong, and "who really did this" is not the app's metadata to configure away. It is a property of the event, so it is now a field of the event.
53
+
54
+ **BREAKING: `IMPERSONATION_METADATA_KEY` moved from `@voltro/plugin-auth` to `@voltro/protocol`.** It names the one reserved key in `Subject.metadata`, and `Subject` is protocol's type. Two packages need it — plugin-auth writes the mark, plugin-audit reads it — and a plugin must not depend on another plugin, so spelling the string in both would have made it a second definition no guard is watching. Everything else stays: plugin-auth still exports `impersonationOf`, `isImpersonated`, `ImpersonationMark` and `impersonationAuditRedactor`. The codemod repoints the import, preserving an alias and the type-only form.
55
+
56
+ The redactor keeps working and is no longer load-bearing. Set `redactSubject: 'none'` or a custom function for your own reasons; the impersonation mark is recorded either way.
57
+
58
+ No migration is needed for the new column — a `_voltro_*` change rides the declarative differ on `voltro db apply` and on a `voltro dev` boot, on every dialect.
59
+ - **@voltro/cli** — A `*.startup.ts` that fails now refuses the boot. It used to warn and let the server come up.
60
+
61
+ Measured, on a real `voltro dev` against postgres, while building the row-filter integration test in this same release. A startup reached for `ctx.store.select(...)` — a builder that lives on the request-scoped `MutationStore`, not on the `DataStore` a startup receives — and threw on its first line. The boot printed:
62
+
63
+ ```
64
+ warn startup: function rejected
65
+ info startup: registered <- next line, same file
66
+ ```
67
+
68
+ and then served every request with no row filter registered. Two lines contradicting each other, the second asserting exactly the thing that had just failed, and an app that looked healthy while its access control was absent.
69
+
70
+ **The rule this overturns was right when it was written.** The header of `startupRunner.ts` read "Errors are logged but never fatal — a failing startup MUST NOT block the rest of the app", and for the startups it was written for — an SSE bridge, a sync loop, a metric aggregator — that is the correct call. It stopped being right when a startup became the documented seam for REGISTRATION: `setRowFilter` is installed from one.
71
+
72
+ The runner cannot tell a registration from a background loop, and the two failures are not symmetric. "The app refuses to boot" is fixed in seconds and is visible to everyone; "the app serves without its access control" is visible to nobody. So the default is the recoverable one, and an app that genuinely wants best-effort writes the `try`/`catch` inside its own startup — one line, at the site where somebody decided the failure was acceptable, where a reviewer can see it. Deliberately not a flag: a flag moves that decision away from the startup it applies to and makes it one setting for all of them.
73
+
74
+ **Two sibling silences went with it**, because fixing only the rejection would have left two more ways to reach the identical state — the per-seam shape this repo keeps paying for. A startup file that cannot be imported, and one with no default-exported function, used to warn and skip. Both refuse now. The second matters more than it sounds: the convention is a DEFAULT-exported function, a named export is discovered and never runs, and that is indistinguishable from a startup that ran and did nothing.
75
+
76
+ **And the 2-second race is gone, which is the half that made the rest reliable.** The old code did not wait for a startup to settle — it raced it against 2000 ms and let the boot win. A startup slower than that was reported as fine, so a rejection arriving afterwards had nothing left to refuse. The runner now waits for the startup to SETTLE, so every failure is catchable however slow it is.
77
+
78
+ That race existed to protect one shape: a startup that never returns because it holds a fiber until shutdown. Counted before changing it, that shape appears in ZERO of the four startups this framework ships — `warm.startup.tsx` (twice), `searchBackfill.startup.tsx` and the memory fixture's all return. So the race protected the shape we discourage and penalised the shape we teach, and the penalised one includes `searchBackfill`, our own example, which awaits a full-table query plus an index backfill and is the likeliest thing in the box to exceed two seconds. Slow AND failing put a consumer back in exactly the silent state this release removes.
79
+
80
+ **So a startup that never returns now refuses the boot too**, after `VOLTRO_STARTUP_TIMEOUT_MS` (60s default), with a message naming the file and showing the `onShutdown` shape to use instead. That makes a previously-documented capability illegal — "a fiber that resolves only on shutdown" — and it is the deliberate half of this change rather than a side effect. A startup that is merely SLOW is unaffected: it is waited for and registers when it finishes.
81
+
82
+ `startup: registered` is written only when the function actually returned. The old code printed it for a failed startup and for one still running.
83
+
84
+ Verified against a real process in every direction, not only in units: the reintroduced defect exits 1 and never listens; a never-returning startup exits 1 and never listens; a slow-but-successful one still registers; and the healthy fixture boots and serves all eight row-filter assertions. `startupRunner.test.ts` is new — there were no tests on this runner at all, which is part of why the contradiction survived.
85
+
86
+ **`voltro update` carries you across this** — codemod `0.38.0/02_startup-failure-refuses-boot`.
87
+
88
+ ### Fixed
89
+
90
+ - **@voltro/cli** — A table declared through `databaseHandle({ … })` but not exported as a top-level table is now DISCOVERED — so its mixins apply.
91
+
92
+ Discovery kept whatever `isTable()` accepted out of a schema module's `Object.values`, which only ever sees tables the file exports DIRECTLY. A schema that builds its tables programmatically exports the builder's result:
93
+
94
+ ```ts
95
+ export const blogEntities = contentTypeToEntities(blogPost) // { draft, published }
96
+ export const database = databaseHandle({ …, blogPostDrafts: blogEntities.draft })
97
+ ```
98
+
99
+ `isTable({ draft, published })` is false, so those tables never entered the schema registry — while `databaseHandle` had registered them for by-name lookup perfectly well.
100
+
101
+ **Two registries with different populations, read by different things.** `getTable()` found the table, so insert validation knew `tenantId` was NOT NULL. The schema registry is what drives the mixins, so nothing stamped it. Measured against a real `voltro dev` boot of the scaffolded `api-cms` template, over real RPC:
102
+
103
+ ```
104
+ TableValidationFailed: { "table": "blogPost_drafts", "summary":
105
+ "missing required column 'tenantId' — NOT NULL with no default and not auto-stamped" }
106
+ ```
107
+
108
+ Every `content.saveDraft` in that template, since it shipped. Its own unit test could not see it, because the test builds its schema registry by hand — the one step the running app does not perform.
109
+
110
+ **The write failing was the lucky half.** Reads do not announce themselves: `makeQueryFinalizer` AND-merges `tenantId` from this same registry, so a table missing from it is a table nobody scopes. The visible symptom was a broken mutation; the invisible one was tenant isolation quietly not applied to those tables.
111
+
112
+ The fix takes the by-name registry's DELTA across each schema module's import, so a handle-declared table is attributed to the file that declared it — rather than reading the whole global registry, which by then also holds framework tables that this set deliberately excludes. It lives in `loadDiscovered`, which `voltro dev` and `voltro serve` both call, so the two cannot disagree about it.
113
+
114
+ No app change is needed; the idiomatic `databaseHandle` declaration now works as its documentation always said.
115
+ - **@voltro/database, @voltro/cli** — A migration that fails because `DB_SCHEMA` names a schema that does not exist now says so.
116
+
117
+ Postgres answers `3F000 no schema has been selected to create in`, and the only thing that puts a non-default schema on the connection's `search_path` here is `DB_SCHEMA`. The error mentions neither the variable nor the schema, so the FIRST `CREATE TABLE` of the boot fails with a message about SQL and the reader goes looking at the DDL — for a typo in an environment variable.
118
+
119
+ Found while building the row-filter integration test below: the boot aborted, the log named `_voltro_migrations` and a postgres routine, and nothing in it pointed at the one line of configuration that caused it. Same shape as a 403 that says nothing about `VOLTRO_INSPECT_TOKEN`, fixed in the same release.
120
+
121
+ The remedy also states what the framework will NOT do: Voltro creates tables, never the schema itself. That is a namespace decision, and inventing one from a typo puts a migration somewhere nobody is looking.
122
+
123
+ `remedyFor` is deliberately a one-entry map and the test asserts the empty case as well as the full one. A remedy is worth printing only where the mapping from driver code to cause is exact; a list of maybes is how a reader learns to skip the section.
124
+
125
+ **Alongside it: the row filter is now driven under `voltro dev` against a real postgres.** Both defects that reached consumers lived between layers that were each individually covered — a module-local `let` that split per instance, then a query producer that built its context from an unscoped request — and three consumer documents carried "we have not run this against a real database" as an honest caveat. `rowFilterDevPostgres.integration.test.ts` boots the fixture twice: handlers with no predicate of their own, a filter registered from a `*.startup.tsx`, and a NEGATIVE CONTROL run with the registration skipped that asserts the same queries return BOTH owners. Without the control, "the caller saw one owner" is equally consistent with a database that only ever held one.
126
+ - **@voltro/cli** — A shard that cannot be released no longer spins a fiber at full speed. Upstream `@effect/cluster` releases shards through `Effect.eventually` — retry until success, zero delay, logged at DEBUG — so a shard whose storage was unreachable retried as fast as the event loop allowed, in the one place nobody watches.
127
+
128
+ Our patch replaces it with `Effect.retry(Schedule.exponential(100) ∪ Schedule.spaced(5000))`. `union` takes the MINIMUM of the two delays, so the 5s spacing caps the backoff instead of compounding with it, and both schedules recur forever — attempts stay unbounded, which is what the original intended. The persistence was always correct; only the missing delay was the defect.
129
+
130
+ This entry exists because the change would otherwise have shipped undocumented. The patch lives in `packages/cli/templates/patches/`, and the changelog gate requires an entry for `packages/*/src` — so nothing would have demanded one, even though the file is installed into every user project that runs cluster. A rule that cannot see a path is not the same as a path with nothing on it.
131
+
132
+ ---
133
+
42
134
  ## [0.37.0] — 2026-08-13
43
135
 
44
136
  ### ⚠ BREAKING
package/dist/index.d.ts CHANGED
@@ -127,6 +127,27 @@ export declare interface AuditEvent {
127
127
  readonly scope?: unknown;
128
128
  /** The app's own note about what happened. Opaque, never interpreted. */
129
129
  readonly metadata?: unknown;
130
+ /**
131
+ * The impersonation mark, when the acting session was an impersonated one —
132
+ * absent otherwise.
133
+ *
134
+ * **A FIRST-CLASS FIELD, on purpose, and no redactor can reach it.** The mark
135
+ * is minted into `subject.metadata` by `@voltro/plugin-auth`, and the audit
136
+ * default `redactSubject: 'metadata'` replaces that whole bag — correctly, it
137
+ * is where a per-user provider credential lands. The consequence was that on
138
+ * DEFAULTS an impersonated action was indistinguishable from the user's own,
139
+ * which is the one distinction an audit trail exists to make.
140
+ *
141
+ * Fixing that with a keep-these-keys option would have left it opt-in, and
142
+ * "who really did this" is not the app's metadata to configure away — it is a
143
+ * property of the event. So it is lifted out of the subject BEFORE any
144
+ * redaction runs, and `redactSubject` is not given a say.
145
+ *
146
+ * Opaque here (`unknown`): `@voltro/plugin-audit` copies the mark, it does not
147
+ * interpret it. `impersonationOf()` from `@voltro/plugin-auth` is what turns
148
+ * it back into a typed `ImpersonationMark`.
149
+ */
150
+ readonly impersonation?: unknown;
130
151
  readonly traceId: string;
131
152
  readonly input: unknown;
132
153
  readonly outcome: {
package/dist/index.js CHANGED
@@ -1,29 +1,29 @@
1
1
  import { audit as e } from "./mixin.js";
2
2
  import { Effect as t } from "effect";
3
- import { definePlugin as n, pluginInstanceName as r } from "@voltro/protocol";
4
- import { createLogger as i } from "@voltro/logger";
5
- import { and as a, eq as o, id as s, integer as c, json as l, registerRetention as u, resolveActorSnapshot as d, retentionTtlMsFromEnv as f, table as p, text as m, timestamp as h } from "@voltro/database";
6
- import { createHash as g, createHmac as _, randomBytes as v } from "node:crypto";
3
+ import { definePlugin as n, pluginInstanceName as r, rawImpersonationMark as i } from "@voltro/protocol";
4
+ import { createLogger as a } from "@voltro/logger";
5
+ import { and as o, eq as s, id as c, integer as l, json as u, registerRetention as d, resolveActorSnapshot as f, retentionTtlMsFromEnv as p, table as m, text as h, timestamp as g } from "@voltro/database";
6
+ import { createHash as _, createHmac as v, randomBytes as y } from "node:crypto";
7
7
  //#region src/redactionShape.ts
8
- var y = 4, b = 32, x = 64, S = /^[A-Za-z_$][A-Za-z0-9_$]*$/, C = (e) => e.length <= x && S.test(e) ? e : `<key:string(${e.length})>`, w = (e, t = 0) => {
8
+ var b = 4, x = 32, S = 64, C = /^[A-Za-z_$][A-Za-z0-9_$]*$/, w = (e) => e.length <= S && C.test(e) ? e : `<key:string(${e.length})>`, T = (e, t = 0) => {
9
9
  if (e === null) return "null";
10
10
  if (e === void 0) return "undefined";
11
11
  let n = typeof e;
12
12
  if (n === "string") return `string(${e.length})`;
13
13
  if (n === "number" || n === "boolean" || n === "bigint" || n === "symbol" || n === "function") return n;
14
14
  if (e instanceof Date) return "date";
15
- if (t >= y) return Array.isArray(e) ? "array(…)" : "object(…)";
15
+ if (t >= b) return Array.isArray(e) ? "array(…)" : "object(…)";
16
16
  if (Array.isArray(e)) {
17
- let n = e.slice(0, b).map((e) => w(e, t + 1));
18
- return e.length > b ? [...n, `…${e.length - b} more`] : n;
17
+ let n = e.slice(0, x).map((e) => T(e, t + 1));
18
+ return e.length > x ? [...n, `…${e.length - x} more`] : n;
19
19
  }
20
20
  if (n === "object") {
21
21
  let n = Object.entries(e), r = {};
22
- for (let [e, i] of n.slice(0, b)) r[C(e)] = w(i, t + 1);
23
- return n.length > b && (r["…"] = `${n.length - b} more key(s)`), r;
22
+ for (let [e, i] of n.slice(0, x)) r[w(e)] = T(i, t + 1);
23
+ return n.length > x && (r["…"] = `${n.length - x} more key(s)`), r;
24
24
  }
25
25
  return n;
26
- }, T = (e) => ({ __redacted: w(e) }), E = "_voltro_audit_log", D = [
26
+ }, E = (e) => ({ __redacted: T(e) }), D = "_voltro_audit_log", O = [
27
27
  "tag",
28
28
  "at",
29
29
  "subjectId",
@@ -38,26 +38,26 @@ var y = 4, b = 32, x = 64, S = /^[A-Za-z_$][A-Za-z0-9_$]*$/, C = (e) => e.length
38
38
  "input",
39
39
  "outcome",
40
40
  "errorTag"
41
- ], O = (e) => {
41
+ ], k = (e) => {
42
42
  if (e == null) return "null";
43
43
  if (e instanceof Date) return JSON.stringify(e.toISOString());
44
44
  let t = typeof e;
45
- return t === "number" || t === "boolean" || t === "string" ? JSON.stringify(e) : t === "bigint" ? JSON.stringify(String(e)) : Array.isArray(e) ? `[${e.map(O).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)}:${O(t)}`).join(",")}}` : JSON.stringify(String(e));
46
- }, k = (e) => e instanceof Date ? e.toISOString() : e, A = (e) => O(Object.fromEntries(D.map((t) => [t, k(e[t] ?? null)]))), j = (e) => {
45
+ return t === "number" || t === "boolean" || t === "string" ? JSON.stringify(e) : t === "bigint" ? JSON.stringify(String(e)) : Array.isArray(e) ? `[${e.map(k).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)}:${k(t)}`).join(",")}}` : JSON.stringify(String(e));
46
+ }, A = (e) => e instanceof Date ? e.toISOString() : e, j = (e) => k(Object.fromEntries(O.map((t) => [t, A(e[t] ?? null)]))), M = (e) => {
47
47
  let t = process.env.VOLTRO_AUDIT_CHAIN_SECRET;
48
- return t ? _("sha256", t).update(e, "utf8").digest("hex") : g("sha256").update(e, "utf8").digest("hex");
49
- }, M = (e, t) => j(JSON.stringify([
48
+ return t ? v("sha256", t).update(e, "utf8").digest("hex") : _("sha256").update(e, "utf8").digest("hex");
49
+ }, N = (e, t) => M(JSON.stringify([
50
50
  t.chainId,
51
51
  t.seq,
52
52
  t.prevHash,
53
- A(e)
54
- ])), N = (e = v(12).toString("base64url")) => {
53
+ j(e)
54
+ ])), P = (e = y(12).toString("base64url")) => {
55
55
  let t = 0, n = null;
56
56
  return {
57
57
  chainId: e,
58
58
  link: (r) => {
59
59
  t += 1;
60
- let i = M(r, {
60
+ let i = N(r, {
61
61
  chainId: e,
62
62
  seq: t,
63
63
  prevHash: n
@@ -71,9 +71,9 @@ var y = 4, b = 32, x = 64, S = /^[A-Za-z_$][A-Za-z0-9_$]*$/, C = (e) => e.length
71
71
  return n = i, a;
72
72
  }
73
73
  };
74
- }, P = async (e, t = {}) => {
75
- let n = t.chainId === void 0 ? void 0 : o("chainId", t.chainId), r = await e.query({
76
- table: E,
74
+ }, F = async (e, t = {}) => {
75
+ let n = t.chainId === void 0 ? void 0 : s("chainId", t.chainId), r = await e.query({
76
+ table: D,
77
77
  ...n ? { predicate: n } : {},
78
78
  order: [{
79
79
  column: "chainId",
@@ -83,7 +83,7 @@ var y = 4, b = 32, x = 64, S = /^[A-Za-z_$][A-Za-z0-9_$]*$/, C = (e) => e.length
83
83
  direction: "asc"
84
84
  }],
85
85
  ...t.limit === void 0 ? {} : { take: t.limit }
86
- }), i = [], a = [], s = 0, c = 0, l = null, u = () => {
86
+ }), i = [], a = [], o = 0, c = 0, l = null, u = () => {
87
87
  l &&= (a.push({
88
88
  chainId: l.chainId,
89
89
  from: l.from,
@@ -95,16 +95,16 @@ var y = 4, b = 32, x = 64, S = /^[A-Za-z_$][A-Za-z0-9_$]*$/, C = (e) => e.length
95
95
  for (let e of r) {
96
96
  let t = e.chainId, n = e.hash, r = e.seq;
97
97
  if (typeof t != "string" || typeof n != "string" || r == null) {
98
- s += 1;
98
+ o += 1;
99
99
  continue;
100
100
  }
101
- let a = Number(r), o = typeof e.prevHash == "string" ? e.prevHash : null, d = typeof e.id == "string" ? e.id : void 0;
101
+ let a = Number(r), s = typeof e.prevHash == "string" ? e.prevHash : null, d = typeof e.id == "string" ? e.id : void 0;
102
102
  c += 1, !l || l.chainId !== t ? (u(), l = {
103
103
  chainId: t,
104
104
  from: a,
105
105
  last: a,
106
106
  tip: n
107
- }) : (a === l.last + 1 ? o !== l.tip && i.push({
107
+ }) : (a === l.last + 1 ? s !== l.tip && i.push({
108
108
  kind: "broken-link",
109
109
  chainId: t,
110
110
  seq: a,
@@ -115,10 +115,10 @@ var y = 4, b = 32, x = 64, S = /^[A-Za-z_$][A-Za-z0-9_$]*$/, C = (e) => e.length
115
115
  chainId: t,
116
116
  seq: l.last + 1,
117
117
  detail: `chain jumps from seq ${l.last} to ${a} — ${a - l.last - 1} row(s) missing`
118
- }), l.last = a, l.tip = n), M(e, {
118
+ }), l.last = a, l.tip = n), N(e, {
119
119
  chainId: t,
120
120
  seq: a,
121
- prevHash: o
121
+ prevHash: s
122
122
  }) !== n && i.push({
123
123
  kind: "tampered",
124
124
  chainId: t,
@@ -130,31 +130,32 @@ var y = 4, b = 32, x = 64, S = /^[A-Za-z_$][A-Za-z0-9_$]*$/, C = (e) => e.length
130
130
  return u(), {
131
131
  ok: i.length === 0,
132
132
  rowsChecked: c,
133
- unchainedRows: s,
133
+ unchainedRows: o,
134
134
  chains: a,
135
135
  issues: i,
136
136
  keyed: !!process.env.VOLTRO_AUDIT_CHAIN_SECRET
137
137
  };
138
- }, F = p(E, {
139
- id: s({ prefix: "audit" }),
140
- tag: m(),
141
- at: h(),
142
- subjectId: m().nullable(),
143
- tenantId: m().nullable(),
144
- traceId: m(),
145
- status: m(),
146
- durationMs: m(),
147
- subject: l(),
148
- actor: l().nullable(),
149
- scope: l().nullable(),
150
- metadata: l().nullable(),
151
- input: l(),
152
- outcome: l(),
153
- errorTag: m().nullable(),
154
- chainId: m().nullable(),
155
- seq: c().nullable(),
156
- prevHash: m().nullable(),
157
- hash: m().nullable()
138
+ }, I = m(D, {
139
+ id: c({ prefix: "audit" }),
140
+ tag: h(),
141
+ at: g(),
142
+ subjectId: h().nullable(),
143
+ tenantId: h().nullable(),
144
+ traceId: h(),
145
+ status: h(),
146
+ durationMs: h(),
147
+ subject: u(),
148
+ actor: u().nullable(),
149
+ scope: u().nullable(),
150
+ metadata: u().nullable(),
151
+ impersonation: u().nullable(),
152
+ input: u(),
153
+ outcome: u(),
154
+ errorTag: h().nullable(),
155
+ chainId: h().nullable(),
156
+ seq: l().nullable(),
157
+ prevHash: h().nullable(),
158
+ hash: h().nullable()
158
159
  }).index("byAuditTag", ["tag"]).index("byAuditTrace", ["traceId"]).index("byAuditSubjectStatus", [
159
160
  "subjectId",
160
161
  "status",
@@ -163,7 +164,7 @@ var y = 4, b = 32, x = 64, S = /^[A-Za-z_$][A-Za-z0-9_$]*$/, C = (e) => e.length
163
164
  "tenantId",
164
165
  "status",
165
166
  "at"
166
- ]).index("byAuditChain", ["chainId", "seq"]), I = [F], L = (e) => ({
167
+ ]).index("byAuditChain", ["chainId", "seq"]), L = [I], R = (e) => ({
167
168
  tag: e.tag,
168
169
  at: new Date(e.ts),
169
170
  subjectId: e.subject.id ?? null,
@@ -175,45 +176,46 @@ var y = 4, b = 32, x = 64, S = /^[A-Za-z_$][A-Za-z0-9_$]*$/, C = (e) => e.length
175
176
  actor: e.actor ?? null,
176
177
  scope: e.scope ?? null,
177
178
  metadata: e.metadata ?? null,
179
+ impersonation: e.impersonation ?? null,
178
180
  input: e.input,
179
181
  outcome: e.outcome,
180
- errorTag: e.outcome.kind === "error" ? R(e.outcome.error) : null
181
- }), R = (e) => {
182
+ errorTag: e.outcome.kind === "error" ? z(e.outcome.error) : null
183
+ }), z = (e) => {
182
184
  if (typeof e != "object" || !e) return null;
183
185
  let t = e._tag;
184
186
  return typeof t == "string" && t !== "" ? t : null;
185
- }, z = async (e, t) => e.query({
186
- table: E,
187
- predicate: o("traceId", t),
187
+ }, B = async (e, t) => e.query({
188
+ table: D,
189
+ predicate: s("traceId", t),
188
190
  order: [{
189
191
  column: "at",
190
192
  direction: "asc"
191
193
  }]
192
- }), B = async (e, t, n = {}) => {
193
- let r = o("subjectId", t);
194
+ }), V = async (e, t, n = {}) => {
195
+ let r = s("subjectId", t);
194
196
  return e.query({
195
- table: E,
196
- predicate: n.status === void 0 ? r : a(r, o("status", n.status)),
197
+ table: D,
198
+ predicate: n.status === void 0 ? r : o(r, s("status", n.status)),
197
199
  order: [{
198
200
  column: "at",
199
201
  direction: "desc"
200
202
  }],
201
203
  take: n.limit ?? 100
202
204
  });
203
- }, V = (e) => {
204
- let t = N();
205
+ }, H = (e) => {
206
+ let t = P();
205
207
  return async (n) => {
206
208
  let r = n.actor !== void 0 || typeof e.query != "function" ? n : {
207
209
  ...n,
208
- actor: await d(e, n.subject.id ?? null, n.subject.type)
210
+ actor: await f(e, n.subject.id ?? null, n.subject.type)
209
211
  };
210
- await e.insert(E, t.link(L(r)));
212
+ await e.insert(D, t.link(R(r)));
211
213
  };
212
- }, H = i({ scope: "@voltro/plugin-audit" }), U = 1e3, W = [], G = () => [...W], K = () => {
213
- W.length = 0;
214
- }, q = (e) => {
215
- W.push(e), W.length > U && W.splice(0, W.length - U);
214
+ }, U = a({ scope: "@voltro/plugin-audit" }), W = 1e3, G = [], K = () => [...G], q = () => {
215
+ G.length = 0;
216
216
  }, J = (e) => {
217
+ G.push(e), G.length > W && G.splice(0, G.length - W);
218
+ }, Y = (e) => {
217
219
  let t = e.subject.id ? `${e.subject.type}:${e.subject.id}` : e.subject.type, n = e.outcome.kind === "ok" ? "ok" : "error", r = {
218
220
  subject: t,
219
221
  tenant: e.subject.tenantId ?? null,
@@ -221,8 +223,8 @@ var y = 4, b = 32, x = 64, S = /^[A-Za-z_$][A-Za-z0-9_$]*$/, C = (e) => e.length
221
223
  durationMs: e.outcome.durationMs,
222
224
  traceId: e.traceId
223
225
  };
224
- n === "error" ? H.warn(e.tag, r) : H.info(e.tag, r);
225
- }, Y = (e, n) => typeof e == "function" ? (n) => t.suspend(() => {
226
+ n === "error" ? U.warn(e.tag, r) : U.info(e.tag, r);
227
+ }, X = (e, n) => typeof e == "function" ? (n) => t.suspend(() => {
226
228
  let r = e(n);
227
229
  return r === void 0 ? t.void : t.isEffect(r) ? r : t.tryPromise({
228
230
  try: () => Promise.resolve(r),
@@ -234,7 +236,7 @@ var y = 4, b = 32, x = 64, S = /^[A-Za-z_$][A-Za-z0-9_$]*$/, C = (e) => e.length
234
236
  try: () => r(e),
235
237
  catch: (e) => e
236
238
  }) : t.void;
237
- }) : e === "memory" ? (e) => t.sync(() => q(e)) : (e) => t.sync(() => J(e)), X = (e) => {
239
+ }) : e === "memory" ? (e) => t.sync(() => J(e)) : (e) => t.sync(() => Y(e)), Z = (e) => {
238
240
  let t = [], n = [], r = (e) => {
239
241
  if (e._tag !== "Empty") {
240
242
  if (e._tag === "Fail") {
@@ -258,33 +260,33 @@ var y = 4, b = 32, x = 64, S = /^[A-Za-z_$][A-Za-z0-9_$]*$/, C = (e) => e.length
258
260
  }
259
261
  };
260
262
  return r(e), t[0] ?? n[0] ?? e;
261
- }, Z = "@voltro/plugin-audit", Q = (e = {}) => {
262
- let i = e.sink === "datastore";
263
- i && u({
263
+ }, Q = "@voltro/plugin-audit", $ = (e = {}) => {
264
+ let a = e.sink === "datastore";
265
+ a && d({
264
266
  source: "plugin",
265
- table: E,
267
+ table: D,
266
268
  timeColumn: "at",
267
- ttlMs: f(process.env.VOLTRO_AUDIT_LOG_TTL_HOURS, 24 * 365)
269
+ ttlMs: p(process.env.VOLTRO_AUDIT_LOG_TTL_HOURS, 24 * 365)
268
270
  });
269
- let a, o = Y(e.sink, () => a), s = (e) => e === void 0 || !e.global && !e.sticky ? e : new RegExp(e.source, e.flags.replace(/[gy]/g, "")), c = s(e.exclude), l = s(e.include), d = (e) => !(c?.test(e) || l && !l.test(e)), p = (t) => {
271
+ let o, s = X(e.sink, () => o), c = (e) => e === void 0 || !e.global && !e.sticky ? e : new RegExp(e.source, e.flags.replace(/[gy]/g, "")), l = c(e.exclude), u = c(e.include), f = (e) => !(l?.test(e) || u && !u.test(e)), m = (t) => {
270
272
  let n = e.record ?? "all";
271
273
  return n === "all" ? !0 : n === "errors" ? t.outcome.kind === "error" : n(t);
272
- }, m = { __redacted: "all" }, h = (t) => {
274
+ }, h = { __redacted: "all" }, g = (t) => {
273
275
  let n = e.redactInput ?? "all";
274
276
  return n === "none" ? t : n === "all" ? {
275
277
  ...t,
276
- input: m
278
+ input: h
277
279
  } : n === "shape" ? {
278
280
  ...t,
279
- input: T(t.input)
281
+ input: E(t.input)
280
282
  } : {
281
283
  ...t,
282
284
  input: n(t)
283
285
  };
284
- }, g = (t) => {
286
+ }, _ = (t) => {
285
287
  let n = e.redactOutcome ?? "all";
286
288
  if (n === "none") return t;
287
- let r = t.outcome, i = n === "all" ? m : n === "shape" ? ((e) => T(e))(r.kind === "ok" ? r.value : r.error) : n(t);
289
+ let r = t.outcome, i = n === "all" ? h : n === "shape" ? ((e) => E(e))(r.kind === "ok" ? r.value : r.error) : n(t);
288
290
  if (r.kind === "ok") return {
289
291
  ...t,
290
292
  outcome: {
@@ -303,7 +305,7 @@ var y = 4, b = 32, x = 64, S = /^[A-Za-z_$][A-Za-z0-9_$]*$/, C = (e) => e.length
303
305
  }
304
306
  }
305
307
  };
306
- }, _ = (t) => {
308
+ }, v = (t) => {
307
309
  let n = e.redactSubject ?? "metadata";
308
310
  if (n === "none") return t;
309
311
  if (typeof n == "function") return {
@@ -312,7 +314,7 @@ var y = 4, b = 32, x = 64, S = /^[A-Za-z_$][A-Za-z0-9_$]*$/, C = (e) => e.length
312
314
  };
313
315
  let r = t.subject;
314
316
  if (r.metadata === void 0) return t;
315
- let { metadata: i, ...a } = r, o = n === "metadata-shape" ? T(i) : m;
317
+ let { metadata: i, ...a } = r, o = n === "metadata-shape" ? E(i) : h;
316
318
  return {
317
319
  ...t,
318
320
  subject: {
@@ -320,15 +322,21 @@ var y = 4, b = 32, x = 64, S = /^[A-Za-z_$][A-Za-z0-9_$]*$/, C = (e) => e.length
320
322
  metadata: o
321
323
  }
322
324
  };
323
- }, v = (e) => p(e) ? o(g(_(h(e)))) : t.void, y = (t) => {
325
+ }, y = (e) => {
326
+ let t = i(e.subject);
327
+ return t === null ? e : {
328
+ ...e,
329
+ impersonation: t
330
+ };
331
+ }, b = (e) => m(e) ? s(_(v(g(y(e))))) : t.void, x = (t) => {
324
332
  if (e.resolveScope !== void 0) try {
325
333
  return e.resolveScope(t);
326
334
  } catch {
327
335
  return;
328
336
  }
329
- }, b = (e, n) => d(n.tag) ? t.suspend(() => {
330
- let r = Date.now(), i = y(n);
331
- return e.pipe(t.tap((e) => v({
337
+ }, S = (e, n) => f(n.tag) ? t.suspend(() => {
338
+ let r = Date.now(), i = x(n);
339
+ return e.pipe(t.tap((e) => b({
332
340
  ts: r,
333
341
  tag: n.tag,
334
342
  subject: n.subject,
@@ -340,7 +348,7 @@ var y = 4, b = 32, x = 64, S = /^[A-Za-z_$][A-Za-z0-9_$]*$/, C = (e) => e.length
340
348
  value: e,
341
349
  durationMs: Date.now() - r
342
350
  }
343
- }).pipe(t.catchAllCause(() => t.void))), t.tapErrorCause((e) => v({
351
+ }).pipe(t.catchAllCause(() => t.void))), t.tapErrorCause((e) => b({
344
352
  ts: r,
345
353
  tag: n.tag,
346
354
  subject: n.subject,
@@ -349,34 +357,34 @@ var y = 4, b = 32, x = 64, S = /^[A-Za-z_$][A-Za-z0-9_$]*$/, C = (e) => e.length
349
357
  input: n.input,
350
358
  outcome: {
351
359
  kind: "error",
352
- error: X(e),
360
+ error: Z(e),
353
361
  durationMs: Date.now() - r
354
362
  }
355
363
  }).pipe(t.catchAllCause(() => t.void))));
356
- }) : e, x = b, S = b, C = b;
364
+ }) : e, C = S, w = S, T = S;
357
365
  return n({
358
366
  name: r({
359
- base: Z,
367
+ base: Q,
360
368
  alias: e.alias
361
369
  }),
362
- baseName: Z,
370
+ baseName: Q,
363
371
  description: "Records every mutation invocation; ships an audit() schema mixin for row-level metadata.",
364
372
  permissions: [
365
373
  "rpc:intercept:mutation",
366
374
  "rpc:intercept:action",
367
- ...i ? ["store:write"] : [],
375
+ ...a ? ["store:write"] : [],
368
376
  ...e.recordQueries === !0 ? ["rpc:intercept:query"] : []
369
377
  ],
370
- ...i ? {
371
- ...e.tables === !1 ? {} : { extendSchema: { tables: I } },
378
+ ...a ? {
379
+ ...e.tables === !1 ? {} : { extendSchema: { tables: L } },
372
380
  bindDataStore: (e) => {
373
- a = V(e);
381
+ o = H(e);
374
382
  }
375
383
  } : {},
376
- interceptMutation: x,
377
- interceptAction: S,
378
- ...e.recordQueries === !0 ? { interceptQuery: C } : {}
384
+ interceptMutation: C,
385
+ interceptAction: w,
386
+ ...e.recordQueries === !0 ? { interceptQuery: T } : {}
379
387
  });
380
388
  };
381
389
  //#endregion
382
- export { E as AUDIT_LOG_TABLE, e as audit, B as auditBySubject, z as auditByTrace, L as auditEventToRow, F as auditLogTable, I as auditLogTables, Q as auditPlugin, M as auditRowHash, K as clearAuditBuffer, V as dataStoreAuditSink, N as makeAuditChain, G as readAuditBuffer, P as verifyAuditChain };
390
+ export { D as AUDIT_LOG_TABLE, e as audit, V as auditBySubject, B as auditByTrace, R as auditEventToRow, I as auditLogTable, L as auditLogTables, $ as auditPlugin, N as auditRowHash, q as clearAuditBuffer, H as dataStoreAuditSink, P as makeAuditChain, K as readAuditBuffer, F as verifyAuditChain };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@voltro/plugin-audit",
3
- "version": "0.37.0",
3
+ "version": "0.38.0",
4
4
  "description": "Audit plugin — ships the `audit()` schema mixin (createdAt/updatedAt/createdBy/updatedBy → Actor) plus an optional mutation interceptor that records every call to a configurable sink (console / memory / custom function).",
5
5
  "keywords": [
6
6
  "voltro",
@@ -38,9 +38,9 @@
38
38
  "node": ">=24.0.0"
39
39
  },
40
40
  "dependencies": {
41
- "@voltro/database": "0.37.0",
42
- "@voltro/logger": "0.37.0",
43
- "@voltro/protocol": "0.37.0"
41
+ "@voltro/database": "0.38.0",
42
+ "@voltro/logger": "0.38.0",
43
+ "@voltro/protocol": "0.38.0"
44
44
  },
45
45
  "peerDependencies": {
46
46
  "effect": "^3.22.0"