@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.
- package/CHANGELOG.md +52 -0
- package/LICENSE +57 -0
- package/README.md +26 -0
- package/SECURITY.md +56 -0
- package/THIRD-PARTY-NOTICES.md +4596 -0
- package/dist/index.d.ts +248 -0
- package/dist/index.js +366 -0
- package/dist/rpc.d.ts +41 -0
- package/dist/rpc.js +72 -0
- package/package.json +50 -0
package/dist/index.d.ts
ADDED
|
@@ -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 { }
|