@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.
@@ -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 { ZodType, z } from "zod";
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;
@@ -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.
@@ -136,6 +136,7 @@ export type {
136
136
  EntityRef,
137
137
  EventDef,
138
138
  EventMigrationDef,
139
+ EventPiiFields,
139
140
  EventUpcastCtx,
140
141
  EventUpcastFn,
141
142
  FetchForWritingArgs,