@voltro/plugin-governance 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.
@@ -0,0 +1,248 @@
1
+ import { Context } from 'effect';
2
+ import { deriveKey } from '@voltro/runtime';
3
+ import { ENCRYPTED_PREFIX } from '@voltro/database';
4
+ import { FieldCipher } from '@voltro/database';
5
+ import { isEncrypted } from '@voltro/database';
6
+ import { makeAesCipher } from '@voltro/runtime';
7
+ import { makeFieldCipher } from '@voltro/runtime';
8
+ import { SchemaTable } from '@voltro/database';
9
+ import { setFieldCipher } from '@voltro/runtime';
10
+ import { VoltroPlugin } from '@voltro/protocol';
11
+
12
+ export declare const CONSENT_TABLE = "_voltro_consent";
13
+
14
+ /** Narrow slice of the framework DataStore the durable consent store needs. */
15
+ export declare interface ConsentDataStore {
16
+ query: (descriptor: Record<string, unknown>) => Promise<ReadonlyArray<Record<string, unknown>>>;
17
+ insert: (table: string, row: Record<string, unknown>) => Promise<unknown>;
18
+ }
19
+
20
+ export declare interface ConsentRecord {
21
+ readonly subjectId: string;
22
+ readonly purpose: string;
23
+ readonly granted: boolean;
24
+ readonly at: string;
25
+ readonly tenantId?: string | null;
26
+ }
27
+
28
+ export declare interface ConsentStore {
29
+ readonly record: (entry: ConsentRecord) => Promise<void>;
30
+ /** Latest decision for (subject, purpose); `false` if never recorded. */
31
+ readonly has: (subjectId: string, purpose: string) => Promise<boolean>;
32
+ readonly list: (subjectId: string) => Promise<ReadonlyArray<ConsentRecord>>;
33
+ }
34
+
35
+ /**
36
+ * The slice of a built `Table` this package exposes — name + columns +
37
+ * indexes. `SchemaTable` is @voltro/database's PUBLIC structural table type,
38
+ * which names exactly that surface WITHOUT the private column-builder class
39
+ * the fully-inferred `Table` generic would drag across the package boundary
40
+ * (TS4094). Alias kept for readability at the annotation sites below.
41
+ */
42
+ export declare type ConsentTable = SchemaTable;
43
+
44
+ /**
45
+ * The consent ledger table — an append-only log of decisions. `has()` reads the
46
+ * most-recent row per (subject,purpose); `list()` reads a subject's history. A
47
+ * new decision is a new row (never an update), so the full audit trail persists.
48
+ * Built purely from column helpers so the DDL compiler handles per-dialect
49
+ * divergence.
50
+ */
51
+ export declare const consentTable: ConsentTable;
52
+
53
+ /** The consent table, ready to spread into `extendSchema.tables`. */
54
+ export declare const consentTables: ReadonlyArray<ConsentTable>;
55
+
56
+ /**
57
+ * A DataStore-backed {@link ConsentStore} — durable + cross-instance. `record`
58
+ * appends a row; `has` reads the most-recent row for (subject,purpose); `list`
59
+ * reads a subject's decisions newest-first. Because the store is shared, a
60
+ * consent recorded on one replica is immediately visible on every other — the
61
+ * cross-instance correctness the default memory store can't give.
62
+ */
63
+ export declare const dataStoreConsentStore: (store: ConsentDataStore) => ConsentStore;
64
+
65
+ /** Adapt a framework `DataStore` (untyped here) to `GovStore`. */
66
+ export declare const dataStoreGovStore: (store: unknown) => GovStore;
67
+
68
+ export { deriveKey }
69
+
70
+ export { ENCRYPTED_PREFIX }
71
+
72
+ /** Erase a subject across `scopes`. Returns an immutable erasure-log entry. */
73
+ export declare const eraseSubject: (store: GovStore, scopes: ReadonlyArray<SubjectScope>, subjectId: string, opts?: ErasureOptions, now?: () => string) => Promise<ErasureLogEntry>;
74
+
75
+ /**
76
+ * Most-recent GDPR erasure-log entries kept in memory (most-recent-first).
77
+ * The inspect surface only ever reads the newest entries, so capping the
78
+ * array keeps a long-lived process from growing the heap without limit on
79
+ * heavy erasure traffic. The log is process-memory + restart-cleared by
80
+ * design — mirror entries to your own store for durable audit evidence.
81
+ */
82
+ export declare const ERASURE_LOG_CAP = 1000;
83
+
84
+ export declare interface ErasureLogEntry {
85
+ readonly subjectId: string;
86
+ readonly at: string;
87
+ readonly mode: 'delete' | 'anonymize';
88
+ readonly affected: ReadonlyArray<{
89
+ table: string;
90
+ count: number;
91
+ }>;
92
+ }
93
+
94
+ export declare interface ErasureOptions {
95
+ /** `'delete'` removes rows; `'anonymize'` nulls `anonymizeFields` (keeps the
96
+ * row for referential integrity / audit). Default `'delete'`. */
97
+ readonly mode?: 'delete' | 'anonymize';
98
+ readonly anonymizeFields?: ReadonlyArray<string>;
99
+ }
100
+
101
+ /** Collect every row across `scopes` that belongs to a subject → a portable bundle. */
102
+ export declare const exportSubject: (store: GovStore, scopes: ReadonlyArray<SubjectScope>, subjectId: string) => Promise<Record<string, ReadonlyArray<Record<string, unknown>>>>;
103
+
104
+ export { FieldCipher }
105
+
106
+ export declare const governancePlugin: (options?: GovernancePluginOptions) => VoltroPlugin;
107
+
108
+ export declare interface GovernancePluginOptions {
109
+ readonly retention?: ReadonlyArray<RetentionPolicy>;
110
+ /** Tables (+ subject column) the GDPR export/erase walk. */
111
+ readonly subjectScopes?: ReadonlyArray<SubjectScope>;
112
+ /** Default erasure mode + anonymise fields. */
113
+ readonly erasure?: ErasureOptions;
114
+ /**
115
+ * Consent ledger store. Default `memoryConsentStore()` (per-process,
116
+ * restart-cleared — a consent recorded on replica A reads `false` on
117
+ * replica B). Pass `'datastore'` for the DURABLE, cross-instance store
118
+ * (contributes the `_voltro_consent` table via `extendSchema`, needs
119
+ * `store:write` which the plugin already declares) — the production choice.
120
+ * Or pass a custom `ConsentStore` to back it with your own store.
121
+ */
122
+ readonly consent?: ConsentStore | 'datastore';
123
+ /** Retention sweep interval (ms). Default 1h. */
124
+ readonly sweepIntervalMs?: number;
125
+ /**
126
+ * Enable field-level encryption for `.encrypted()` columns. Registers
127
+ * an AES-256-GCM cipher whose key is resolved from the Secrets-Resolver.
128
+ * `true` reads the secret `VOLTRO_FIELD_ENCRYPTION_KEY`; pass
129
+ * `{ secretKey }` to name a different secret. Boot fails loud if the
130
+ * key can't be resolved (an `.encrypted()` column with no cipher is a
131
+ * silent-data-loss footgun).
132
+ */
133
+ readonly fieldEncryption?: boolean | {
134
+ readonly secretKey?: string;
135
+ };
136
+ readonly name?: string;
137
+ }
138
+
139
+ export declare class GovernanceService extends GovernanceService_base {
140
+ }
141
+
142
+ declare const GovernanceService_base: Context.TagClass<GovernanceService, "@voltro/plugin-governance/GovernanceService", GovernanceServiceShape>;
143
+
144
+ export declare interface GovernanceServiceShape {
145
+ readonly exportSubject: (subjectId: string) => Promise<Record<string, ReadonlyArray<Record<string, unknown>>>>;
146
+ readonly eraseSubject: (subjectId: string, opts?: ErasureOptions) => Promise<ErasureLogEntry>;
147
+ readonly recordConsent: (entry: ConsentRecord) => Promise<void>;
148
+ readonly hasConsent: (subjectId: string, purpose: string) => Promise<boolean>;
149
+ readonly runRetentionNow: () => Promise<void>;
150
+ }
151
+
152
+ export declare interface GovStore {
153
+ /**
154
+ * All rows of a table.
155
+ *
156
+ * CEILING: this materialises the WHOLE table in memory. It is used by the
157
+ * GDPR export/erase walks, which are admin-gated, occasional, single-subject
158
+ * operations — fine in practice. Do NOT use it on the retention path (the
159
+ * retention table is the one that grows unbounded) — use {@link page} +
160
+ * a bounded loop there instead.
161
+ */
162
+ readonly all: (table: string) => Promise<ReadonlyArray<Record<string, unknown>>>;
163
+ /**
164
+ * One bounded page of rows (`limit` rows starting at `offset`, ordered by a
165
+ * stable key). Lets the retention sweep scan a large table in capped batches
166
+ * instead of loading it whole — the `_voltro_traces`-incident class the
167
+ * unbounded `all()` would otherwise reintroduce on a retention table.
168
+ */
169
+ readonly page: (table: string, limit: number, offset: number) => Promise<ReadonlyArray<Record<string, unknown>>>;
170
+ readonly delete: (table: string, id: string) => Promise<void>;
171
+ readonly update: (table: string, id: string, patch: Record<string, unknown>) => Promise<void>;
172
+ }
173
+
174
+ export { isEncrypted }
175
+
176
+ export { makeAesCipher }
177
+
178
+ export { makeFieldCipher }
179
+
180
+ /**
181
+ * In-memory consent store (default + test). Latest-write-wins per
182
+ * (subject,purpose).
183
+ *
184
+ * BOUNDED: `has()` is backed by the `latest` map (one entry per
185
+ * subject+purpose — never dropped, so a recorded decision is never lost),
186
+ * while the `history` log that `list()` reads is capped at
187
+ * {@link CONSENT_HISTORY_CAP} most-recent entries. Without the cap a
188
+ * long-lived process with heavy consent traffic would grow the array — and
189
+ * the heap — without limit. This store is process-memory + restart-cleared
190
+ * by design; for durable, cross-instance consent supply a DataStore-backed
191
+ * `ConsentStore` via the plugin's `consent` option.
192
+ */
193
+ export declare const memoryConsentStore: (historyCap?: number) => ConsentStore;
194
+
195
+ /** In-memory GovStore for tests + dev. */
196
+ export declare const memoryGovStore: (seed?: Record<string, Array<Record<string, unknown>>>) => GovStore & {
197
+ dump: () => Record<string, Array<Record<string, unknown>>>;
198
+ };
199
+
200
+ export declare interface RetentionPolicy {
201
+ readonly table: string;
202
+ /** Time-to-live in ms (e.g. 90 days = 90*86_400_000). */
203
+ readonly ttlMs: number;
204
+ /** Row date field the age is measured from. Default `'createdAt'`. */
205
+ readonly dateField?: string;
206
+ /** `'delete'` removes the row; `'anonymize'` nulls/redacts `anonymizeFields`. */
207
+ readonly action?: 'delete' | 'anonymize';
208
+ /** Fields to null when `action: 'anonymize'`. */
209
+ readonly anonymizeFields?: ReadonlyArray<string>;
210
+ }
211
+
212
+ declare interface RetentionReport {
213
+ readonly table: string;
214
+ readonly action: 'delete' | 'anonymize';
215
+ readonly affected: number;
216
+ }
217
+
218
+ /**
219
+ * Apply every policy once. Returns a per-table report.
220
+ *
221
+ * BOUNDED: each table is scanned in pages of `batchSize` (default
222
+ * {@link RETENTION_BATCH_SIZE}) — the sweep never loads the whole table into
223
+ * memory, so a large retention table (the one thing retention exists to keep
224
+ * from growing) can't OOM the process or fire one statement per row in an
225
+ * unbounded burst.
226
+ *
227
+ * Window math: the page is read ordered by a stable key at a moving `offset`.
228
+ * For `delete` the acted-on rows are removed, so the next page's rows shift
229
+ * into the gap — `offset` advances only by the rows the page LEFT in place
230
+ * (`page.length - deleted`). For `anonymize` rows stay, so `offset` advances
231
+ * by the full page. The loop ends when a page comes back shorter than the
232
+ * batch (table drained).
233
+ */
234
+ export declare const runRetention: (store: GovStore, policies: ReadonlyArray<RetentionPolicy>, now?: number, batchSize?: number) => Promise<ReadonlyArray<RetentionReport>>;
235
+
236
+ /** Pure — the ids of rows past their TTL. */
237
+ export declare const selectExpired: (rows: ReadonlyArray<Record<string, unknown>>, policy: RetentionPolicy, now: number) => ReadonlyArray<string>;
238
+
239
+ export { setFieldCipher }
240
+
241
+ /** One table that references a subject, by which column. */
242
+ export declare interface SubjectScope {
243
+ readonly table: string;
244
+ /** Column holding the subject id (e.g. 'userId', 'createdBy', 'id'). */
245
+ readonly subjectField: string;
246
+ }
247
+
248
+ export { }
package/dist/index.js ADDED
@@ -0,0 +1,366 @@
1
+ import { consentDescriptor as e, eraseDescriptor as t, exportDescriptor as n, governanceRpcClientImports as r, hasConsentDescriptor as i } from "./rpc.js";
2
+ import { Context as a, Effect as o, Layer as s } from "effect";
3
+ import { ADMIN_SCOPE as c, definePlugin as l, requireScope as u } from "@voltro/protocol";
4
+ import { deriveKey as d, makeAesCipher as f, makeFieldCipher as p, makeFieldCipher as m, resolveSecret as h, setFieldCipher as g, setFieldCipher as _ } from "@voltro/runtime";
5
+ import { ENCRYPTED_PREFIX as v, and as y, boolean as b, eq as x, id as S, isEncrypted as C, table as w, text as T } from "@voltro/database";
6
+ //#region src/store.ts
7
+ var E = (e) => {
8
+ let t = e, n = (e, t) => ({
9
+ table: void 0,
10
+ predicate: void 0,
11
+ order: [{
12
+ column: "id",
13
+ direction: "asc"
14
+ }],
15
+ take: e,
16
+ skip: t,
17
+ projection: void 0
18
+ });
19
+ return {
20
+ all: (e) => t.query({
21
+ ...n(void 0, void 0),
22
+ table: e
23
+ }),
24
+ page: (e, r, i) => t.query({
25
+ ...n(r, i),
26
+ table: e
27
+ }),
28
+ delete: async (e, n) => {
29
+ await (t.hardDelete ?? t.delete).call(t, e, n);
30
+ },
31
+ update: async (e, n, r) => {
32
+ await t.update(e, n, r);
33
+ }
34
+ };
35
+ }, D = (e = {}) => {
36
+ let t = new Map(Object.entries(e).map(([e, t]) => [e, t.map((e) => ({ ...e }))])), n = (e) => {
37
+ let n = t.get(e);
38
+ return n || (n = [], t.set(e, n)), n;
39
+ };
40
+ return {
41
+ all: async (e) => [...n(e)],
42
+ page: async (e, t, r) => n(e).slice(r, r + t).map((e) => ({ ...e })),
43
+ delete: async (e, t) => {
44
+ let r = n(e), i = r.findIndex((e) => String(e.id) === t);
45
+ i !== -1 && r.splice(i, 1);
46
+ },
47
+ update: async (e, t, r) => {
48
+ let i = n(e), a = i.findIndex((e) => String(e.id) === t);
49
+ a !== -1 && (i[a] = {
50
+ ...i[a],
51
+ ...r
52
+ });
53
+ },
54
+ dump: () => Object.fromEntries([...t.entries()].map(([e, t]) => [e, t.map((e) => ({ ...e }))]))
55
+ };
56
+ }, O = (e, t, n) => {
57
+ let r = e[t], i = r instanceof Date ? r.getTime() : typeof r == "string" || typeof r == "number" ? new Date(r).getTime() : NaN;
58
+ return Number.isNaN(i) ? null : n - i;
59
+ }, k = (e, t, n) => {
60
+ let r = t.dateField ?? "createdAt";
61
+ return e.filter((e) => {
62
+ let i = O(e, r, n);
63
+ return i !== null && i > t.ttlMs;
64
+ }).map((e) => String(e.id));
65
+ }, A = 1e3, j = async (e, t, n = Date.now(), r = A) => {
66
+ let i = [];
67
+ for (let a of t) {
68
+ let t = a.action ?? "delete", o = 0, s = 0, c = !1;
69
+ for (; !c;) {
70
+ let i = await e.page(a.table, r, s), l = k(i, a, n);
71
+ if (t === "delete") {
72
+ for (let t of l) await e.delete(a.table, t);
73
+ s += i.length - l.length;
74
+ } else {
75
+ for (let t of l) {
76
+ let n = {};
77
+ for (let e of a.anonymizeFields ?? []) n[e] = null;
78
+ await e.update(a.table, t, n);
79
+ }
80
+ s += i.length;
81
+ }
82
+ o += l.length, c = i.length < r;
83
+ }
84
+ i.push({
85
+ table: a.table,
86
+ action: t,
87
+ affected: o
88
+ });
89
+ }
90
+ return i;
91
+ }, M = async (e, t, n) => {
92
+ let r = {};
93
+ for (let i of t) {
94
+ let t = (await e.all(i.table)).filter((e) => String(e[i.subjectField]) === n);
95
+ t.length > 0 && (r[i.table] = (r[i.table] ?? []).concat(t));
96
+ }
97
+ return r;
98
+ }, N = async (e, t, n, r = {}, i = () => (/* @__PURE__ */ new Date()).toISOString()) => {
99
+ let a = r.mode ?? "delete", o = [];
100
+ for (let i of t) {
101
+ let t = (await e.all(i.table)).filter((e) => String(e[i.subjectField]) === n);
102
+ for (let n of t) {
103
+ let t = String(n.id);
104
+ if (a === "delete") await e.delete(i.table, t);
105
+ else {
106
+ let n = {};
107
+ for (let e of r.anonymizeFields ?? []) n[e] = null;
108
+ await e.update(i.table, t, n);
109
+ }
110
+ }
111
+ t.length > 0 && o.push({
112
+ table: i.table,
113
+ count: t.length
114
+ });
115
+ }
116
+ return {
117
+ subjectId: n,
118
+ at: i(),
119
+ mode: a,
120
+ affected: o
121
+ };
122
+ }, P = 1e4, F = (e = P) => {
123
+ let t = /* @__PURE__ */ new Map(), n = [], r = (e, t) => `${e}|${t}`;
124
+ return {
125
+ record: async (i) => {
126
+ t.set(r(i.subjectId, i.purpose), i), n.unshift(i), n.length > e && (n.length = e);
127
+ },
128
+ has: async (e, n) => t.get(r(e, n))?.granted ?? !1,
129
+ list: async (e) => n.filter((t) => t.subjectId === e)
130
+ };
131
+ }, I = "_voltro_consent", L = w(I, {
132
+ id: S({ prefix: "consent" }),
133
+ subjectId: T(),
134
+ purpose: T(),
135
+ granted: b(),
136
+ at: T(),
137
+ tenantId: T().nullable()
138
+ }).index("byConsentSubjectPurpose", ["subjectId", "purpose"]).index("byConsentSubject", ["subjectId"]), R = [L], z = (e) => ({
139
+ subjectId: String(e.subjectId),
140
+ purpose: String(e.purpose),
141
+ granted: e.granted === !0 || e.granted === 1 || e.granted === "true",
142
+ at: String(e.at),
143
+ tenantId: e.tenantId ?? null
144
+ }), B = (e) => {
145
+ let t = (t, n) => e.query({
146
+ table: I,
147
+ predicate: t,
148
+ order: [{
149
+ column: "at",
150
+ direction: "desc"
151
+ }],
152
+ take: n,
153
+ skip: void 0,
154
+ projection: void 0
155
+ });
156
+ return {
157
+ record: async (t) => {
158
+ await e.insert(I, {
159
+ subjectId: t.subjectId,
160
+ purpose: t.purpose,
161
+ granted: t.granted,
162
+ at: t.at,
163
+ tenantId: t.tenantId ?? null
164
+ });
165
+ },
166
+ has: async (e, n) => {
167
+ let r = await t(y(x("subjectId", e), x("purpose", n)), 1);
168
+ return r[0] ? z(r[0]).granted : !1;
169
+ },
170
+ list: async (e) => (await t(x("subjectId", e))).map(z)
171
+ };
172
+ }, V = 1e3, H = class extends a.Tag("@voltro/plugin-governance/GovernanceService")() {}, U = (a = {}) => {
173
+ let d = a.name ? `@voltro/plugin-governance#${a.name}` : "@voltro/plugin-governance", f = a.consent === "datastore", p = a.consent && a.consent !== "datastore" ? a.consent : f ? void 0 : F(), g, v = {
174
+ record: (e) => (p ?? g ?? F()).record(e),
175
+ has: (e, t) => (p ?? g ?? F()).has(e, t),
176
+ list: (e) => (p ?? g ?? F()).list(e)
177
+ }, y = a.subjectScopes ?? [], b = typeof a.fieldEncryption == "object" ? a.fieldEncryption.secretKey ?? "VOLTRO_FIELD_ENCRYPTION_KEY" : "VOLTRO_FIELD_ENCRYPTION_KEY", x = a.fieldEncryption ? [{
178
+ name: b,
179
+ required: !1,
180
+ secret: !0,
181
+ description: "AES-256-GCM key for `.encrypted()` columns (field encryption). Lose the key, lose the data — GCM fails closed."
182
+ }] : [], S = [], C, w, T = () => void 0, D = [], O = null, k = !1, A = () => {
183
+ if (!C) throw Error("governance: data store not bound yet");
184
+ return C;
185
+ }, P = {
186
+ exportSubject: (e) => M(A(), y, e),
187
+ eraseSubject: async (e, t) => {
188
+ let n = await N(A(), y, e, t ?? a.erasure ?? {});
189
+ return S.unshift(n), S.length > 1e3 && (S.length = V), n;
190
+ },
191
+ recordConsent: (e) => v.record(e),
192
+ hasConsent: (e, t) => v.has(e, t),
193
+ runRetentionNow: async () => {
194
+ if (!C || !a.retention) return;
195
+ let e = await j(C, a.retention);
196
+ D = e, O = (/* @__PURE__ */ new Date()).toISOString(), T("retention sweep", { report: e });
197
+ }
198
+ }, I = [
199
+ {
200
+ ...n,
201
+ description: "GDPR data export for a subject (admin only).",
202
+ execute: (e, t) => o.gen(function* () {
203
+ return yield* u(t.request.subject, c), yield* o.promise(() => P.exportSubject(e.subjectId));
204
+ })
205
+ },
206
+ {
207
+ ...t,
208
+ description: "GDPR erasure (right-to-be-forgotten) for a subject (admin only).",
209
+ execute: (e, t) => o.gen(function* () {
210
+ yield* u(t.request.subject, c);
211
+ let { subjectId: n, mode: r } = e;
212
+ return yield* o.promise(() => P.eraseSubject(n, r ? { mode: r } : void 0));
213
+ })
214
+ },
215
+ {
216
+ ...e,
217
+ description: "Record the calling subject's consent for a purpose.",
218
+ execute: (e, t) => o.promise(async () => {
219
+ let { purpose: n, granted: r } = e, i = t.request.subject;
220
+ return await P.recordConsent({
221
+ subjectId: i.id ?? "anonymous",
222
+ purpose: n,
223
+ granted: r,
224
+ at: (/* @__PURE__ */ new Date()).toISOString(),
225
+ tenantId: i.tenantId ?? null
226
+ }), { ok: !0 };
227
+ })
228
+ },
229
+ {
230
+ ...i,
231
+ description: "Whether the calling subject has granted consent for a purpose.",
232
+ execute: (e, t) => o.promise(async () => {
233
+ let n = t.request.subject;
234
+ return { granted: await P.hasConsent(n.id ?? "anonymous", e.purpose) };
235
+ })
236
+ }
237
+ ], L = (e) => ({
238
+ kind: "json",
239
+ data: e
240
+ }), z = (e, t) => ({
241
+ kind: "json",
242
+ status: e,
243
+ data: { error: t }
244
+ }), U = (e, t) => new URLSearchParams(new URL(e, "http://x").search).get(t);
245
+ return l({
246
+ name: d,
247
+ description: "Data governance — retention TTL sweep, GDPR export/erasure, consent ledger.",
248
+ permissions: ["store:write", "inspect:read"],
249
+ declaredEnv: x,
250
+ services: s.succeed(H, P),
251
+ routes: I,
252
+ rpcClientDescriptors: r,
253
+ inspectEndpoints: [
254
+ {
255
+ method: "GET",
256
+ path: "/status",
257
+ description: "Retention policies, subject scopes, field-encryption state, last sweep.",
258
+ handler: () => o.succeed(L({
259
+ retention: (a.retention ?? []).map((e) => ({
260
+ table: e.table,
261
+ ttlMs: e.ttlMs,
262
+ action: e.action ?? "delete"
263
+ })),
264
+ scopes: y.map((e) => ({
265
+ table: e.table,
266
+ subjectField: e.subjectField
267
+ })),
268
+ fieldEncryption: k,
269
+ sweepIntervalMs: a.sweepIntervalMs ?? 36e5,
270
+ lastSweepAt: O,
271
+ lastSweep: D,
272
+ erasureCount: S.length
273
+ }))
274
+ },
275
+ {
276
+ method: "GET",
277
+ path: "/erasures",
278
+ description: "GDPR erasure log (most recent first).",
279
+ handler: () => o.succeed(L({ erasures: S.slice(0, 100) }))
280
+ },
281
+ {
282
+ method: "GET",
283
+ path: "/consent",
284
+ description: "Consent ledger for a subject (?subjectId=…).",
285
+ handler: (e) => {
286
+ let t = U(e.url, "subjectId");
287
+ return t ? o.promise(async () => L({ records: await v.list(t) })) : o.succeed(z(400, "subjectId query param required"));
288
+ }
289
+ },
290
+ {
291
+ method: "POST",
292
+ path: "/export",
293
+ description: "Run a GDPR subject export.",
294
+ handler: (e) => o.promise(async () => {
295
+ let t;
296
+ try {
297
+ t = JSON.parse(e.body || "{}");
298
+ } catch {
299
+ return z(400, "invalid JSON body");
300
+ }
301
+ if (!t.subjectId) return z(400, "subjectId required");
302
+ try {
303
+ return L({ bundle: await P.exportSubject(t.subjectId) });
304
+ } catch (e) {
305
+ return z(500, e.message);
306
+ }
307
+ })
308
+ },
309
+ {
310
+ method: "POST",
311
+ path: "/erase",
312
+ description: "Run a GDPR erasure (right-to-be-forgotten).",
313
+ handler: (e) => o.promise(async () => {
314
+ let t;
315
+ try {
316
+ t = JSON.parse(e.body || "{}");
317
+ } catch {
318
+ return z(400, "invalid JSON body");
319
+ }
320
+ if (!t.subjectId) return z(400, "subjectId required");
321
+ try {
322
+ return L({ entry: await P.eraseSubject(t.subjectId, t.mode ? { mode: t.mode } : void 0) });
323
+ } catch (e) {
324
+ return z(500, e.message);
325
+ }
326
+ })
327
+ },
328
+ {
329
+ method: "POST",
330
+ path: "/sweep",
331
+ description: "Run the retention sweep now.",
332
+ handler: () => o.promise(async () => (await P.runRetentionNow(), L({
333
+ lastSweepAt: O,
334
+ lastSweep: D
335
+ })))
336
+ }
337
+ ],
338
+ ...f ? { extendSchema: { tables: R } } : {},
339
+ bindDataStore: (e, t) => {
340
+ if (C = E(e), f && (g = B(e)), a.retention && a.retention.length > 0) {
341
+ let e = a.sweepIntervalMs ?? 36e5;
342
+ w = t?.scheduleCoordinated("governance.retention.sweep", e, () => P.runRetentionNow());
343
+ }
344
+ },
345
+ onActivate: (e) => o.gen(function* () {
346
+ T = (t, n) => e.logger.warn(t, n);
347
+ let t = !1;
348
+ if (a.fieldEncryption) {
349
+ let e = b, n = yield* o.promise(() => h(e));
350
+ if (!n) return yield* o.die(/* @__PURE__ */ Error(`governance: fieldEncryption is enabled but secret "${e}" did not resolve — set it via the Secrets-Resolver / env`));
351
+ _(m(n)), t = !0, k = !0;
352
+ }
353
+ !f && !a.consent && e.logger.warn("governance: consent ledger uses the in-memory store — per-process + restart-cleared. Under >1 replica a consent recorded on one replica is invisible to others. Pass consent: 'datastore' (durable, cross-instance) in production."), e.logger.info("governance active", {
354
+ retention: a.retention?.length ?? 0,
355
+ scopes: y.length,
356
+ fieldEncryption: t,
357
+ consent: f ? "datastore" : a.consent ? "custom" : "memory"
358
+ });
359
+ }),
360
+ onDeactivate: () => o.sync(() => {
361
+ w?.stop(), w = void 0, a.fieldEncryption && _(void 0);
362
+ })
363
+ });
364
+ };
365
+ //#endregion
366
+ export { I as CONSENT_TABLE, v as ENCRYPTED_PREFIX, V as ERASURE_LOG_CAP, H as GovernanceService, L as consentTable, R as consentTables, B as dataStoreConsentStore, E as dataStoreGovStore, d as deriveKey, N as eraseSubject, M as exportSubject, U as governancePlugin, C as isEncrypted, f as makeAesCipher, p as makeFieldCipher, F as memoryConsentStore, D as memoryGovStore, j as runRetention, k as selectExpired, g as setFieldCipher };
package/dist/rpc.d.ts ADDED
@@ -0,0 +1,41 @@
1
+ import { ActionProcedureDescriptor } from '@voltro/protocol';
2
+ import { MutationProcedureDescriptor } from '@voltro/protocol';
3
+ import { PluginRpcClientDescriptor } from '@voltro/protocol';
4
+ import { QueryProcedureDescriptor } from '@voltro/protocol';
5
+ import { Schema } from 'effect';
6
+
7
+ export declare const consentDescriptor: MutationProcedureDescriptor<"governance.consent", Schema.Struct<{
8
+ purpose: typeof Schema.String;
9
+ granted: typeof Schema.Boolean;
10
+ }>, Schema.Struct<{
11
+ ok: typeof Schema.Boolean;
12
+ }>, typeof Schema.Never>;
13
+
14
+ export declare const eraseDescriptor: ActionProcedureDescriptor<"governance.erase", Schema.Struct<{
15
+ subjectId: typeof Schema.String;
16
+ mode: Schema.optional<Schema.Literal<["delete", "anonymize"]>>;
17
+ }>, Schema.Struct<{
18
+ subjectId: typeof Schema.String;
19
+ at: typeof Schema.String;
20
+ mode: typeof Schema.String;
21
+ affected: Schema.Array$<Schema.Struct<{
22
+ table: typeof Schema.String;
23
+ count: typeof Schema.Number;
24
+ }>>;
25
+ }>, typeof Schema.Never>;
26
+
27
+ export declare const exportDescriptor: ActionProcedureDescriptor<"governance.export", Schema.Struct<{
28
+ subjectId: typeof Schema.String;
29
+ }>, Schema.Record$<typeof Schema.String, typeof Schema.Unknown>, typeof Schema.Never>;
30
+
31
+ /** Declared for the codegen (`VoltroPlugin.rpcClientDescriptors`) so each tag is
32
+ * emitted into the generated client group. Kept in lockstep with the exports. */
33
+ export declare const governanceRpcClientImports: ReadonlyArray<PluginRpcClientDescriptor>;
34
+
35
+ export declare const hasConsentDescriptor: QueryProcedureDescriptor<"governance.hasConsent", Schema.Struct<{
36
+ purpose: typeof Schema.String;
37
+ }>, Schema.Struct<{
38
+ granted: typeof Schema.Boolean;
39
+ }>, typeof Schema.Never>;
40
+
41
+ export { }