@cosmicdrift/kumiko-framework 0.119.0 → 0.121.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/package.json +2 -2
- package/src/crypto/__tests__/event-pii.test.ts +161 -0
- package/src/crypto/__tests__/kek-rotation.integration.test.ts +180 -0
- package/src/crypto/event-pii.ts +69 -0
- package/src/crypto/index.ts +11 -0
- package/src/crypto/pg-kms-adapter.ts +126 -4
- package/src/crypto/pii-field-encryption.ts +14 -0
- package/src/db/queries/backfill-pii.ts +276 -0
- package/src/engine/define-feature.ts +25 -2
- package/src/engine/registry.ts +16 -0
- package/src/engine/types/feature.ts +7 -1
- package/src/engine/types/handlers.ts +7 -0
- package/src/engine/types/index.ts +1 -0
- package/src/event-store/__tests__/backfill-pii.integration.test.ts +279 -0
- package/src/event-store/event-store.ts +11 -6
- package/src/event-store/index.ts +6 -0
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
// One-time backfill for pre-KMS plaintext PII in kumiko_events (#799).
|
|
2
|
+
//
|
|
3
|
+
// Crypto-shredding (#724/#818) only covers NEW writes — events appended
|
|
4
|
+
// before a KMS was configured still carry plaintext (user.created email,
|
|
5
|
+
// delivery attempt recipientAddress, job payloads). This tool re-encrypts
|
|
6
|
+
// them in place, per field, under the owning subject's DEK:
|
|
7
|
+
//
|
|
8
|
+
// - entity lifecycle events (<entity>.created/updated/deleted/forgotten/
|
|
9
|
+
// restored) for every entity with PII subject annotations
|
|
10
|
+
// - custom events from the event-PII catalog (r.defineEvent piiFields)
|
|
11
|
+
//
|
|
12
|
+
// Already-forgotten subjects must NOT get a fresh key minted for their old
|
|
13
|
+
// plaintext — three erased-detection layers write [[erased]] instead:
|
|
14
|
+
// 1. KeyErasedError from the KMS (subject forgotten in the KMS era)
|
|
15
|
+
// 2. the event's own aggregate has a *.forgotten event (pre-KMS forget)
|
|
16
|
+
// 3. the resolved user subject's id has a *.forgotten event (custom
|
|
17
|
+
// events referencing a pre-KMS-forgotten user)
|
|
18
|
+
//
|
|
19
|
+
// Idempotent: ciphertext and sentinel values pass through untouched — a
|
|
20
|
+
// second run reports 0 updates. One failing event does not abort the run;
|
|
21
|
+
// failures are collected and reported (fail-loud at the caller).
|
|
22
|
+
//
|
|
23
|
+
// Snapshots of touched aggregates are dropped (they may cache plaintext);
|
|
24
|
+
// the next snapshotting load recreates them. AFTER a run, rebuild the
|
|
25
|
+
// affected projections — applyEntityEvent materializes ciphertext AND the
|
|
26
|
+
// blind-index columns, which keeps equality lookups (login by email) alive.
|
|
27
|
+
|
|
28
|
+
import { asRawClient } from "../../bun-db";
|
|
29
|
+
import { configuredEventPiiCatalog } from "../../crypto/event-pii";
|
|
30
|
+
import type { KmsContext, LocalKeyKmsAdapter, SubjectId } from "../../crypto/kms-adapter";
|
|
31
|
+
import { KeyErasedError } from "../../crypto/kms-adapter";
|
|
32
|
+
import {
|
|
33
|
+
configuredPiiSubjectKms,
|
|
34
|
+
encryptPiiValueForSubject,
|
|
35
|
+
isPiiCiphertext,
|
|
36
|
+
PII_ERASED_SENTINEL,
|
|
37
|
+
} from "../../crypto/pii-field-encryption";
|
|
38
|
+
import { collectPiiSubjectFields, resolveSubjectForField } from "../../crypto/subject-resolver";
|
|
39
|
+
import type { EntityDefinition, Registry, TenantId } from "../../engine/types";
|
|
40
|
+
import type { DbRunner } from "../connection";
|
|
41
|
+
|
|
42
|
+
const LIFECYCLE_VERBS = ["created", "updated", "deleted", "restored", "forgotten"] as const;
|
|
43
|
+
|
|
44
|
+
export type PiiBackfillFailure = {
|
|
45
|
+
readonly eventId: string;
|
|
46
|
+
readonly reason: string;
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
export type PiiBackfillResult = {
|
|
50
|
+
readonly scannedEvents: number;
|
|
51
|
+
readonly updatedEvents: number;
|
|
52
|
+
readonly encryptedFields: number;
|
|
53
|
+
readonly erasedFields: number;
|
|
54
|
+
readonly deletedSnapshots: number;
|
|
55
|
+
readonly failures: readonly PiiBackfillFailure[];
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
export type PiiBackfillOptions = {
|
|
59
|
+
readonly batchSize?: number;
|
|
60
|
+
// Scan + count only, write nothing.
|
|
61
|
+
readonly dryRun?: boolean;
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
type EventRow = {
|
|
65
|
+
readonly id: bigint | string;
|
|
66
|
+
readonly aggregate_id: string;
|
|
67
|
+
readonly aggregate_type: string;
|
|
68
|
+
readonly tenant_id: string;
|
|
69
|
+
readonly type: string;
|
|
70
|
+
readonly payload: Record<string, unknown>;
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
type FieldOutcome = "unchanged" | "encrypted" | "erased";
|
|
74
|
+
|
|
75
|
+
export async function backfillEventPiiEncryption(
|
|
76
|
+
db: DbRunner,
|
|
77
|
+
registry: Registry,
|
|
78
|
+
options: PiiBackfillOptions = {},
|
|
79
|
+
): Promise<PiiBackfillResult> {
|
|
80
|
+
const kms = configuredPiiSubjectKms();
|
|
81
|
+
if (!kms) {
|
|
82
|
+
throw new Error(
|
|
83
|
+
"backfillEventPiiEncryption requires a configured subject KMS — boot with " +
|
|
84
|
+
"runProdApp({ kms }) / configurePiiSubjectKms(adapter) before running the backfill.",
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
const batchSize = options.batchSize ?? 500;
|
|
88
|
+
const raw = asRawClient(db);
|
|
89
|
+
const kmsCtx: KmsContext = { requestId: "pii-backfill" };
|
|
90
|
+
|
|
91
|
+
const entityTargets = new Map<
|
|
92
|
+
string,
|
|
93
|
+
{ readonly entity: EntityDefinition; readonly piiFields: readonly string[] }
|
|
94
|
+
>();
|
|
95
|
+
for (const [name, entity] of registry.getAllEntities()) {
|
|
96
|
+
const piiFields = collectPiiSubjectFields(entity);
|
|
97
|
+
if (piiFields.length > 0) entityTargets.set(name, { entity, piiFields });
|
|
98
|
+
}
|
|
99
|
+
const eventCatalog = configuredEventPiiCatalog();
|
|
100
|
+
|
|
101
|
+
const aggregateTypes = [...entityTargets.keys()];
|
|
102
|
+
const catalogTypes = [...eventCatalog.keys()];
|
|
103
|
+
const result = {
|
|
104
|
+
scannedEvents: 0,
|
|
105
|
+
updatedEvents: 0,
|
|
106
|
+
encryptedFields: 0,
|
|
107
|
+
erasedFields: 0,
|
|
108
|
+
deletedSnapshots: 0,
|
|
109
|
+
failures: [] as PiiBackfillFailure[],
|
|
110
|
+
};
|
|
111
|
+
if (aggregateTypes.length === 0 && catalogTypes.length === 0) return result;
|
|
112
|
+
|
|
113
|
+
// Pre-KMS forgets left no key tombstone — the *.forgotten event on the
|
|
114
|
+
// stream is the only durable marker. Collect once; aggregate_id doubles
|
|
115
|
+
// as the user id for user-subject lookups.
|
|
116
|
+
const forgottenRows = (await raw.unsafe(
|
|
117
|
+
`SELECT DISTINCT "aggregate_id" FROM "kumiko_events" WHERE "type" LIKE '%.forgotten'`,
|
|
118
|
+
)) as ReadonlyArray<{ aggregate_id: string }>;
|
|
119
|
+
const forgottenAggregates = new Set(forgottenRows.map((r) => r.aggregate_id));
|
|
120
|
+
|
|
121
|
+
const touchedAggregates = new Set<string>();
|
|
122
|
+
let cursor = "0";
|
|
123
|
+
|
|
124
|
+
for (;;) {
|
|
125
|
+
const rows = (await raw.unsafe(
|
|
126
|
+
`SELECT "id", "aggregate_id", "aggregate_type", "tenant_id", "type", "payload"
|
|
127
|
+
FROM "kumiko_events"
|
|
128
|
+
WHERE ("aggregate_type" = ANY($1::text[]) OR "type" = ANY($2::text[])) AND "id" > $3::bigint
|
|
129
|
+
ORDER BY "id" ASC
|
|
130
|
+
LIMIT $4`,
|
|
131
|
+
[aggregateTypes, catalogTypes, cursor, batchSize],
|
|
132
|
+
)) as ReadonlyArray<EventRow>;
|
|
133
|
+
if (rows.length === 0) break;
|
|
134
|
+
|
|
135
|
+
for (const row of rows) {
|
|
136
|
+
result.scannedEvents++;
|
|
137
|
+
try {
|
|
138
|
+
const outcome = await transformEvent(row);
|
|
139
|
+
if (outcome === null) continue;
|
|
140
|
+
result.encryptedFields += outcome.encrypted;
|
|
141
|
+
result.erasedFields += outcome.erased;
|
|
142
|
+
if (!options.dryRun) {
|
|
143
|
+
await raw.unsafe(`UPDATE "kumiko_events" SET "payload" = $1::jsonb WHERE "id" = $2`, [
|
|
144
|
+
JSON.stringify(outcome.payload),
|
|
145
|
+
row.id,
|
|
146
|
+
]);
|
|
147
|
+
}
|
|
148
|
+
result.updatedEvents++;
|
|
149
|
+
touchedAggregates.add(row.aggregate_id);
|
|
150
|
+
} catch (e) {
|
|
151
|
+
result.failures.push({
|
|
152
|
+
eventId: String(row.id),
|
|
153
|
+
reason: e instanceof Error ? e.message : String(e),
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
const last = rows[rows.length - 1];
|
|
158
|
+
if (last === undefined) break;
|
|
159
|
+
cursor = String(last.id);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// Snapshots may cache the plaintext state of touched aggregates.
|
|
163
|
+
if (!options.dryRun && touchedAggregates.size > 0) {
|
|
164
|
+
const deleted = (await raw.unsafe(
|
|
165
|
+
`DELETE FROM "kumiko_snapshots" WHERE "aggregate_id" = ANY($1::uuid[]) RETURNING "aggregate_id"`,
|
|
166
|
+
[[...touchedAggregates]],
|
|
167
|
+
)) as ReadonlyArray<unknown>;
|
|
168
|
+
result.deletedSnapshots = deleted.length;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
return result;
|
|
172
|
+
|
|
173
|
+
async function transformEvent(
|
|
174
|
+
row: EventRow,
|
|
175
|
+
): Promise<{ payload: Record<string, unknown>; encrypted: number; erased: number } | null> {
|
|
176
|
+
const counters = { encrypted: 0, erased: 0 };
|
|
177
|
+
const payload = structuredClone(row.payload);
|
|
178
|
+
|
|
179
|
+
const catalogFields = eventCatalog.get(row.type);
|
|
180
|
+
if (catalogFields) {
|
|
181
|
+
for (const [field, spec] of Object.entries(catalogFields)) {
|
|
182
|
+
const subjectId = payload[spec.subjectField];
|
|
183
|
+
if (typeof subjectId !== "string" || subjectId.length === 0) continue;
|
|
184
|
+
const outcome = await encryptField(payload, field, { kind: "user", userId: subjectId });
|
|
185
|
+
bump(outcome);
|
|
186
|
+
}
|
|
187
|
+
} else {
|
|
188
|
+
const target = entityTargets.get(row.aggregate_type);
|
|
189
|
+
if (!target || !isLifecycleEventOf(row.type, row.aggregate_type)) return null;
|
|
190
|
+
const sections = lifecycleSections(payload);
|
|
191
|
+
for (const section of sections) {
|
|
192
|
+
// Update-changes may carry a pii field without its owner field —
|
|
193
|
+
// resolve subjects from the merged view; aggregate_id backs the
|
|
194
|
+
// self-subject when a section lacks the id column.
|
|
195
|
+
const subjectSource: Record<string, unknown> = {
|
|
196
|
+
id: row.aggregate_id,
|
|
197
|
+
...Object.assign({}, ...sections),
|
|
198
|
+
...section,
|
|
199
|
+
};
|
|
200
|
+
for (const field of target.piiFields) {
|
|
201
|
+
const subject = resolveSubjectForField(target.entity, field, subjectSource, {
|
|
202
|
+
// @cast-boundary db-read — tenant_id column is the branded TenantId
|
|
203
|
+
tenantId: row.tenant_id as TenantId,
|
|
204
|
+
});
|
|
205
|
+
if (subject === null) continue;
|
|
206
|
+
const outcome = await encryptField(section, field, subject);
|
|
207
|
+
bump(outcome);
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
if (counters.encrypted === 0 && counters.erased === 0) return null;
|
|
213
|
+
return { payload, ...counters };
|
|
214
|
+
|
|
215
|
+
function bump(outcome: FieldOutcome): void {
|
|
216
|
+
if (outcome === "encrypted") counters.encrypted++;
|
|
217
|
+
if (outcome === "erased") counters.erased++;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
async function encryptField(
|
|
221
|
+
section: Record<string, unknown>,
|
|
222
|
+
field: string,
|
|
223
|
+
subject: SubjectId,
|
|
224
|
+
): Promise<FieldOutcome> {
|
|
225
|
+
const value = section[field];
|
|
226
|
+
if (value === null || value === undefined) return "unchanged";
|
|
227
|
+
if (typeof value !== "string") return "unchanged";
|
|
228
|
+
if (isPiiCiphertext(value) || value === PII_ERASED_SENTINEL) return "unchanged";
|
|
229
|
+
if (isForgottenSubject(subject, row.aggregate_id)) {
|
|
230
|
+
section[field] = PII_ERASED_SENTINEL;
|
|
231
|
+
return "erased";
|
|
232
|
+
}
|
|
233
|
+
try {
|
|
234
|
+
section[field] = await encryptPiiValueForSubject(
|
|
235
|
+
kms as LocalKeyKmsAdapter,
|
|
236
|
+
subject,
|
|
237
|
+
value,
|
|
238
|
+
kmsCtx,
|
|
239
|
+
);
|
|
240
|
+
return "encrypted";
|
|
241
|
+
} catch (e) {
|
|
242
|
+
if (e instanceof KeyErasedError) {
|
|
243
|
+
section[field] = PII_ERASED_SENTINEL;
|
|
244
|
+
return "erased";
|
|
245
|
+
}
|
|
246
|
+
throw e;
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function isForgottenSubject(subject: SubjectId, aggregateId: string): boolean {
|
|
252
|
+
if (forgottenAggregates.has(aggregateId)) return true;
|
|
253
|
+
return subject.kind === "user" && forgottenAggregates.has(subject.userId);
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
function isLifecycleEventOf(eventType: string, aggregateType: string): boolean {
|
|
258
|
+
if (!eventType.startsWith(`${aggregateType}.`)) return false;
|
|
259
|
+
const verb = eventType.slice(aggregateType.length + 1);
|
|
260
|
+
return (LIFECYCLE_VERBS as readonly string[]).includes(verb);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
// created events carry the fields flat; updated carries { changes, previous };
|
|
264
|
+
// deleted/forgotten/restored carry { previous }. Returned sections are the
|
|
265
|
+
// mutable objects INSIDE the payload clone — encryptField writes in place.
|
|
266
|
+
function lifecycleSections(payload: Record<string, unknown>): Record<string, unknown>[] {
|
|
267
|
+
const sections: Record<string, unknown>[] = [];
|
|
268
|
+
if (isRecord(payload["changes"])) sections.push(payload["changes"]);
|
|
269
|
+
if (isRecord(payload["previous"])) sections.push(payload["previous"]);
|
|
270
|
+
if (sections.length === 0) sections.push(payload);
|
|
271
|
+
return sections;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
275
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
276
|
+
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type
|
|
1
|
+
import { ZodObject, type ZodType, type z } from "zod";
|
|
2
2
|
import type { EntityTableMeta } from "../db/entity-table-meta";
|
|
3
3
|
import { toTableName } from "../db/table-builder";
|
|
4
4
|
import { LifecycleHookTypes } from "./constants";
|
|
@@ -19,6 +19,7 @@ import type {
|
|
|
19
19
|
EntityRef,
|
|
20
20
|
EventDef,
|
|
21
21
|
EventMigrationDef,
|
|
22
|
+
EventPiiFields,
|
|
22
23
|
EventUpcastFn,
|
|
23
24
|
ExtensionSelectorDef,
|
|
24
25
|
FeatureDefinition,
|
|
@@ -511,7 +512,7 @@ export function defineFeature<const TName extends string, TExports = undefined>(
|
|
|
511
512
|
defineEvent: <const TInner extends string, TPayload>(
|
|
512
513
|
eventName: TInner,
|
|
513
514
|
schema: ZodType<TPayload>,
|
|
514
|
-
options?: { readonly version?: number },
|
|
515
|
+
options?: { readonly version?: number; readonly piiFields?: EventPiiFields },
|
|
515
516
|
): EventDef<TPayload, QualifiedEventName<TName, TInner>> => {
|
|
516
517
|
// Return the fully-qualified event name so callers can pass it
|
|
517
518
|
// straight to ctx.appendEvent without hand-building the
|
|
@@ -530,6 +531,27 @@ export function defineFeature<const TName extends string, TExports = undefined>(
|
|
|
530
531
|
`[Feature ${name}] defineEvent("${eventName}"): version must be a positive integer, got ${String(version)}`,
|
|
531
532
|
);
|
|
532
533
|
}
|
|
534
|
+
// piiFields misconfiguration is a boot-time error, not a silent
|
|
535
|
+
// plaintext leak: both the pii field and its subjectField must exist
|
|
536
|
+
// on the payload schema (checkable when the schema is a ZodObject).
|
|
537
|
+
const piiFields = options?.piiFields;
|
|
538
|
+
if (piiFields) {
|
|
539
|
+
const shape = schema instanceof ZodObject ? schema.shape : undefined;
|
|
540
|
+
for (const [field, spec] of Object.entries(piiFields)) {
|
|
541
|
+
if (field === spec.subjectField) {
|
|
542
|
+
throw new Error(
|
|
543
|
+
`[Feature ${name}] defineEvent("${eventName}"): piiFields."${field}" cannot use itself as subjectField — the subject id is a plaintext pseudonymous fk, the pii field is the value it owns.`,
|
|
544
|
+
);
|
|
545
|
+
}
|
|
546
|
+
for (const required of [field, spec.subjectField]) {
|
|
547
|
+
if (shape && !(required in shape)) {
|
|
548
|
+
throw new Error(
|
|
549
|
+
`[Feature ${name}] defineEvent("${eventName}"): piiFields references "${required}" which is not a field of the payload schema.`,
|
|
550
|
+
);
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
}
|
|
533
555
|
// @cast-boundary engine-bridge — runtime-string mirrors the
|
|
534
556
|
// template-literal-type via QualifiedEventName + toKebab. Both
|
|
535
557
|
// sides are tested (CamelToKebab type-tests + scan-events kebab
|
|
@@ -538,6 +560,7 @@ export function defineFeature<const TName extends string, TExports = undefined>(
|
|
|
538
560
|
name: qualified as QualifiedEventName<TName, TInner>,
|
|
539
561
|
schema,
|
|
540
562
|
version,
|
|
563
|
+
...(piiFields !== undefined && { piiFields }),
|
|
541
564
|
};
|
|
542
565
|
events[eventName] = def;
|
|
543
566
|
return def;
|
package/src/engine/registry.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { configureEventPiiCatalog } from "../crypto/event-pii";
|
|
1
2
|
import { applyEntityEvent } from "../db/apply-entity-event";
|
|
2
3
|
import {
|
|
3
4
|
assertBackingTableSuperset,
|
|
@@ -26,6 +27,7 @@ import type {
|
|
|
26
27
|
EntityProjectionExtension,
|
|
27
28
|
EntityRelations,
|
|
28
29
|
EventDef,
|
|
30
|
+
EventPiiFields,
|
|
29
31
|
EventUpcastFn,
|
|
30
32
|
FeatureDefinition,
|
|
31
33
|
FeatureMetricDef,
|
|
@@ -1363,6 +1365,16 @@ export function createRegistry(features: readonly FeatureDefinition[]): Registry
|
|
|
1363
1365
|
return false;
|
|
1364
1366
|
})();
|
|
1365
1367
|
|
|
1368
|
+
// Publish the event-PII catalog (#799): append() — the single write funnel
|
|
1369
|
+
// into kumiko_events — encrypts catalogued payload fields regardless of
|
|
1370
|
+
// which path produced the event (ctx.appendEvent, MSP-apply, low-level
|
|
1371
|
+
// append in delivery/jobs loggers).
|
|
1372
|
+
const eventPiiCatalog = new Map<string, EventPiiFields>();
|
|
1373
|
+
for (const [qualified, def] of eventMap) {
|
|
1374
|
+
if (def.piiFields) eventPiiCatalog.set(qualified, def.piiFields);
|
|
1375
|
+
}
|
|
1376
|
+
configureEventPiiCatalog(eventPiiCatalog);
|
|
1377
|
+
|
|
1366
1378
|
// Auto-wire the soft-delete cleanup cron + its grace-days config key when ANY
|
|
1367
1379
|
// entity opts into softDelete — the framework owns this machinery, no feature
|
|
1368
1380
|
// declares it (mirrors the auto restore-handler). Job-runner reads getAllJobs
|
|
@@ -1395,6 +1407,10 @@ export function createRegistry(features: readonly FeatureDefinition[]): Registry
|
|
|
1395
1407
|
return entityMap.get(name);
|
|
1396
1408
|
},
|
|
1397
1409
|
|
|
1410
|
+
getAllEntities(): ReadonlyMap<string, EntityDefinition> {
|
|
1411
|
+
return entityMap;
|
|
1412
|
+
},
|
|
1413
|
+
|
|
1398
1414
|
getWriteHandler(name: string): WriteHandlerDef | undefined {
|
|
1399
1415
|
return writeHandlerMap.get(name);
|
|
1400
1416
|
},
|
|
@@ -36,6 +36,7 @@ import type {
|
|
|
36
36
|
EntityRef,
|
|
37
37
|
EventDef,
|
|
38
38
|
EventMigrationDef,
|
|
39
|
+
EventPiiFields,
|
|
39
40
|
EventUpcastFn,
|
|
40
41
|
HandlerRef,
|
|
41
42
|
NameOrRef,
|
|
@@ -529,10 +530,14 @@ export type FeatureRegistrar<TFeature extends string = string> = {
|
|
|
529
530
|
// on first registration. When you bump the payload shape, raise version
|
|
530
531
|
// AND register r.eventMigration(shortName, N, N+1, transform) — the
|
|
531
532
|
// framework refuses to boot if the chain from 1 → version has gaps.
|
|
533
|
+
//
|
|
534
|
+
// `options.piiFields` declares PII payload fields encrypted under the DEK
|
|
535
|
+
// of the user named by `subjectField` (crypto-shredding, #799). append()
|
|
536
|
+
// enforces the catalog on every write path.
|
|
532
537
|
defineEvent<const TInner extends string, TPayload>(
|
|
533
538
|
name: TInner,
|
|
534
539
|
schema: ZodType<TPayload>,
|
|
535
|
-
options?: { readonly version?: number },
|
|
540
|
+
options?: { readonly version?: number; readonly piiFields?: EventPiiFields },
|
|
536
541
|
): EventDef<TPayload, QualifiedEventName<TFeature, TInner>>;
|
|
537
542
|
|
|
538
543
|
// Register a step-wise payload transform for event-schema evolution.
|
|
@@ -789,6 +794,7 @@ export type Registry = {
|
|
|
789
794
|
|
|
790
795
|
getFeature(name: string): FeatureDefinition | undefined;
|
|
791
796
|
getEntity(name: string): EntityDefinition | undefined;
|
|
797
|
+
getAllEntities(): ReadonlyMap<string, EntityDefinition>;
|
|
792
798
|
getWriteHandler(name: string): WriteHandlerDef | undefined;
|
|
793
799
|
getQueryHandler(name: string): QueryHandlerDef | undefined;
|
|
794
800
|
getSearchableFields(entityName: string): readonly string[];
|
|
@@ -584,6 +584,12 @@ export type QualifiedEventName<
|
|
|
584
584
|
TInner extends string,
|
|
585
585
|
> = `${CamelToKebab<TFeature>}:event:${CamelToKebab<TInner>}`;
|
|
586
586
|
|
|
587
|
+
// PII payload fields on a custom event (#799): `field` is encrypted under
|
|
588
|
+
// the DEK of the user named by the payload's `subjectField` (crypto-
|
|
589
|
+
// shredding). A null subject field leaves the value plaintext — there is
|
|
590
|
+
// no user key to shred for system-triggered events.
|
|
591
|
+
export type EventPiiFields = Readonly<Record<string, { readonly subjectField: string }>>;
|
|
592
|
+
|
|
587
593
|
export type EventDef<TPayload = unknown, TName extends string = string> = {
|
|
588
594
|
readonly name: TName;
|
|
589
595
|
readonly schema: ZodType<TPayload>;
|
|
@@ -592,6 +598,7 @@ export type EventDef<TPayload = unknown, TName extends string = string> = {
|
|
|
592
598
|
// upcasts older stored events. Reads consult this to decide if upcasters
|
|
593
599
|
// need to run before the payload hits consumer code.
|
|
594
600
|
readonly version: number;
|
|
601
|
+
readonly piiFields?: EventPiiFields;
|
|
595
602
|
};
|
|
596
603
|
|
|
597
604
|
// Args for ctx.appendEvent — explicit aggregate target, Marten-style.
|