@cosmicdrift/kumiko-framework 0.209.1 → 0.210.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 +3 -3
- package/src/__tests__/entity-permalink-open.integration.test.ts +6 -1
- package/src/crypto/__tests__/blind-index.test.ts +1 -1
- package/src/crypto/__tests__/pii-field-encryption.test.ts +16 -10
- package/src/crypto/__tests__/subject-resolver.test.ts +9 -3
- package/src/db/__tests__/blind-index.integration.test.ts +1 -1
- package/src/db/__tests__/cursor.test.ts +26 -1
- package/src/db/__tests__/eagerload.integration.test.ts +3 -3
- package/src/db/__tests__/entity-table-meta-source.test.ts +4 -1
- package/src/db/__tests__/event-store-executor-context.pii-roundtrip.test.ts +1 -1
- package/src/db/__tests__/event-store-executor-list.integration.test.ts +144 -1
- package/src/db/__tests__/event-store-executor.integration.test.ts +5 -2
- package/src/db/__tests__/implicit-projection-equivalence.integration.test.ts +1 -1
- package/src/db/cursor.ts +32 -0
- package/src/db/event-store-executor-read.ts +80 -9
- package/src/db/index.ts +2 -2
- package/src/db/pg-error.ts +7 -0
- package/src/db/queries/backfill-pii.ts +188 -18
- package/src/engine/__tests__/boot-validator-boot-check.test.ts +6 -1
- package/src/engine/__tests__/boot-validator-pii-retention.test.ts +140 -140
- package/src/engine/__tests__/boot-validator-s0-integration.test.ts +17 -5
- package/src/engine/__tests__/factories-personal.test.ts +130 -0
- package/src/engine/__tests__/field-access.test.ts +1 -23
- package/src/engine/__tests__/store-table.test.ts +4 -1
- package/src/engine/boot-validator/pii-retention.ts +22 -54
- package/src/engine/build-config-feature-schema.ts +9 -1
- package/src/engine/factories.ts +108 -30
- package/src/engine/field-access.ts +2 -13
- package/src/engine/index.ts +7 -1
- package/src/engine/types/index.ts +7 -1
- package/src/event-store/__tests__/backfill-pii.integration.test.ts +179 -2
- package/src/files/file-ref-entity.ts +2 -2
- package/src/search/__tests__/reindex-entity.integration.test.ts +1 -1
- package/src/search/__tests__/search-pii-derived-index.integration.test.ts +2 -2
- package/src/testing/shared-entities.ts +6 -3
|
@@ -20,12 +20,25 @@
|
|
|
20
20
|
// second run reports 0 updates. One failing event does not abort the run;
|
|
21
21
|
// failures are collected and reported (fail-loud at the caller).
|
|
22
22
|
//
|
|
23
|
+
// Owner resolution for entity-lifecycle events (backfill-only — the live
|
|
24
|
+
// write path in event-store-executor-write.ts / subject-resolver.ts stays
|
|
25
|
+
// fail-closed and is untouched by this):
|
|
26
|
+
// 1. payload — the event section itself names the owner field.
|
|
27
|
+
// 2. projection ({@link PiiBackfillOptions.resolveOwnerFromProjection}) —
|
|
28
|
+
// pre-owner-field-addition events don't carry it; read it from the
|
|
29
|
+
// entity's current projection row instead.
|
|
30
|
+
// 3. erase ({@link PiiBackfillOptions.eraseUnresolvableSubjects}) — still
|
|
31
|
+
// unresolvable (hard-deleted aggregate, no projection) → PII_ERASED_SENTINEL.
|
|
32
|
+
// Both stages are opt-in and off by default; without them an unresolvable
|
|
33
|
+
// subject still fails loud into `failures`, as before.
|
|
34
|
+
//
|
|
23
35
|
// Snapshots of touched aggregates are dropped (they may cache plaintext);
|
|
24
36
|
// the next snapshotting load recreates them. AFTER a run, rebuild the
|
|
25
37
|
// affected projections — applyEntityEvent materializes ciphertext AND the
|
|
26
38
|
// blind-index columns, which keeps equality lookups (login by email) alive.
|
|
27
39
|
|
|
28
40
|
import { asRawClient } from "../../bun-db";
|
|
41
|
+
import { quoteIdent } from "../../crypto/ciphertext-pattern";
|
|
29
42
|
import { configuredEventPiiCatalog } from "../../crypto/event-pii";
|
|
30
43
|
import type { KmsContext, LocalKeyKmsAdapter, SubjectId } from "../../crypto/kms-adapter";
|
|
31
44
|
import { KeyErasedError } from "../../crypto/kms-adapter";
|
|
@@ -35,9 +48,16 @@ import {
|
|
|
35
48
|
isPiiCiphertext,
|
|
36
49
|
PII_ERASED_SENTINEL,
|
|
37
50
|
} from "../../crypto/pii-field-encryption";
|
|
38
|
-
import {
|
|
51
|
+
import {
|
|
52
|
+
collectPiiSubjectFields,
|
|
53
|
+
resolveSubjectForField,
|
|
54
|
+
SubjectResolutionError,
|
|
55
|
+
} from "../../crypto/subject-resolver";
|
|
39
56
|
import type { EntityDefinition, Registry, TenantId } from "../../engine/types";
|
|
40
57
|
import type { DbRunner } from "../connection";
|
|
58
|
+
import { resolveTableName } from "../entity-table-meta";
|
|
59
|
+
import { isUndefinedTable } from "../pg-error";
|
|
60
|
+
import { toSnakeCase } from "../table-builder";
|
|
41
61
|
|
|
42
62
|
const LIFECYCLE_VERBS = ["created", "updated", "deleted", "restored", "forgotten"] as const;
|
|
43
63
|
|
|
@@ -51,6 +71,12 @@ export type PiiBackfillResult = {
|
|
|
51
71
|
readonly updatedEvents: number;
|
|
52
72
|
readonly encryptedFields: number;
|
|
53
73
|
readonly erasedFields: number;
|
|
74
|
+
// Subset of encryptedFields + erasedFields whose owner came from stage 2
|
|
75
|
+
// (the payload itself lacked it).
|
|
76
|
+
readonly ownerFromProjection: number;
|
|
77
|
+
// Subset of erasedFields written because the subject stayed unresolvable
|
|
78
|
+
// through stage 3, not because the subject was already forgotten.
|
|
79
|
+
readonly erasedUnresolvable: number;
|
|
54
80
|
readonly deletedSnapshots: number;
|
|
55
81
|
readonly failures: readonly PiiBackfillFailure[];
|
|
56
82
|
};
|
|
@@ -59,6 +85,12 @@ export type PiiBackfillOptions = {
|
|
|
59
85
|
readonly batchSize?: number;
|
|
60
86
|
// Scan + count only, write nothing.
|
|
61
87
|
readonly dryRun?: boolean;
|
|
88
|
+
// Stage 2: fall back to the entity's projection row (by aggregate_id)
|
|
89
|
+
// when a lifecycle event's payload doesn't name the owner field.
|
|
90
|
+
readonly resolveOwnerFromProjection?: boolean;
|
|
91
|
+
// Stage 3: write PII_ERASED_SENTINEL for subjects still unresolvable
|
|
92
|
+
// after stage 1+2, instead of failing the event into `failures`.
|
|
93
|
+
readonly eraseUnresolvableSubjects?: boolean;
|
|
62
94
|
};
|
|
63
95
|
|
|
64
96
|
type EventRow = {
|
|
@@ -105,6 +137,8 @@ export async function backfillEventPiiEncryption(
|
|
|
105
137
|
updatedEvents: 0,
|
|
106
138
|
encryptedFields: 0,
|
|
107
139
|
erasedFields: 0,
|
|
140
|
+
ownerFromProjection: 0,
|
|
141
|
+
erasedUnresolvable: 0,
|
|
108
142
|
deletedSnapshots: 0,
|
|
109
143
|
failures: [] as PiiBackfillFailure[],
|
|
110
144
|
};
|
|
@@ -132,13 +166,17 @@ export async function backfillEventPiiEncryption(
|
|
|
132
166
|
)) as ReadonlyArray<EventRow>;
|
|
133
167
|
if (rows.length === 0) break;
|
|
134
168
|
|
|
169
|
+
const projectionOwnersByType = await loadProjectionOwners(rows);
|
|
170
|
+
|
|
135
171
|
for (const row of rows) {
|
|
136
172
|
result.scannedEvents++;
|
|
137
173
|
try {
|
|
138
|
-
const outcome = await transformEvent(row);
|
|
174
|
+
const outcome = await transformEvent(row, projectionOwnersByType.get(row.aggregate_type));
|
|
139
175
|
if (outcome === null) continue;
|
|
140
176
|
result.encryptedFields += outcome.encrypted;
|
|
141
177
|
result.erasedFields += outcome.erased;
|
|
178
|
+
result.ownerFromProjection += outcome.ownerFromProjection;
|
|
179
|
+
result.erasedUnresolvable += outcome.erasedUnresolvable;
|
|
142
180
|
if (!options.dryRun) {
|
|
143
181
|
await raw.unsafe(`UPDATE "kumiko_events" SET "payload" = $1::jsonb WHERE "id" = $2`, [
|
|
144
182
|
outcome.payload,
|
|
@@ -148,9 +186,15 @@ export async function backfillEventPiiEncryption(
|
|
|
148
186
|
result.updatedEvents++;
|
|
149
187
|
touchedAggregates.add(row.aggregate_id);
|
|
150
188
|
} catch (e) {
|
|
189
|
+
const reason = e instanceof Error ? e.message : String(e);
|
|
151
190
|
result.failures.push({
|
|
152
191
|
eventId: String(row.id),
|
|
153
|
-
reason:
|
|
192
|
+
reason:
|
|
193
|
+
e instanceof SubjectResolutionError
|
|
194
|
+
? `${reason} (retry with { resolveOwnerFromProjection: true } to resolve the owner ` +
|
|
195
|
+
"from the entity's projection table, and/or { eraseUnresolvableSubjects: true } to " +
|
|
196
|
+
"erase fields whose subject stays unresolvable)"
|
|
197
|
+
: reason,
|
|
154
198
|
});
|
|
155
199
|
}
|
|
156
200
|
}
|
|
@@ -170,10 +214,57 @@ export async function backfillEventPiiEncryption(
|
|
|
170
214
|
|
|
171
215
|
return result;
|
|
172
216
|
|
|
217
|
+
async function loadProjectionOwners(
|
|
218
|
+
rows: readonly EventRow[],
|
|
219
|
+
): Promise<ReadonlyMap<string, ReadonlyMap<string, Record<string, unknown>>>> {
|
|
220
|
+
const byType = new Map<string, ReadonlyMap<string, Record<string, unknown>>>();
|
|
221
|
+
if (!options.resolveOwnerFromProjection) return byType;
|
|
222
|
+
|
|
223
|
+
const idsByType = new Map<string, Set<string>>();
|
|
224
|
+
for (const row of rows) {
|
|
225
|
+
if (!entityTargets.has(row.aggregate_type)) continue;
|
|
226
|
+
const ids = idsByType.get(row.aggregate_type) ?? new Set<string>();
|
|
227
|
+
ids.add(row.aggregate_id);
|
|
228
|
+
idsByType.set(row.aggregate_type, ids);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
for (const [aggregateType, ids] of idsByType) {
|
|
232
|
+
const target = entityTargets.get(aggregateType);
|
|
233
|
+
if (!target) continue;
|
|
234
|
+
const tableName = resolveTableName(aggregateType, target.entity, undefined);
|
|
235
|
+
const ownersById = new Map<string, Record<string, unknown>>();
|
|
236
|
+
try {
|
|
237
|
+
const projectionRows = (await raw.unsafe(
|
|
238
|
+
`SELECT * FROM ${quoteIdent(tableName)} WHERE "id" = ANY($1::uuid[])`,
|
|
239
|
+
[[...ids]],
|
|
240
|
+
)) as ReadonlyArray<Record<string, unknown>>;
|
|
241
|
+
for (const projectionRow of projectionRows) {
|
|
242
|
+
const id = projectionRow["id"];
|
|
243
|
+
if (typeof id === "string") {
|
|
244
|
+
ownersById.set(id, projectionRowToCamel(target.entity, projectionRow));
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
} catch (e) {
|
|
248
|
+
// Entity never mounted/rebuilt (no projection table yet) — stage 3
|
|
249
|
+
// still gets a chance; this batch just contributes no owners.
|
|
250
|
+
if (!isUndefinedTable(e)) throw e;
|
|
251
|
+
}
|
|
252
|
+
byType.set(aggregateType, ownersById);
|
|
253
|
+
}
|
|
254
|
+
return byType;
|
|
255
|
+
}
|
|
256
|
+
|
|
173
257
|
async function transformEvent(
|
|
174
258
|
row: EventRow,
|
|
175
|
-
|
|
176
|
-
|
|
259
|
+
projectionOwners: ReadonlyMap<string, Record<string, unknown>> | undefined,
|
|
260
|
+
): Promise<{
|
|
261
|
+
payload: Record<string, unknown>;
|
|
262
|
+
encrypted: number;
|
|
263
|
+
erased: number;
|
|
264
|
+
ownerFromProjection: number;
|
|
265
|
+
erasedUnresolvable: number;
|
|
266
|
+
} | null> {
|
|
267
|
+
const counters = { encrypted: 0, erased: 0, ownerFromProjection: 0, erasedUnresolvable: 0 };
|
|
177
268
|
const payload = structuredClone(row.payload);
|
|
178
269
|
|
|
179
270
|
const catalogFields = eventCatalog.get(row.type);
|
|
@@ -187,6 +278,21 @@ export async function backfillEventPiiEncryption(
|
|
|
187
278
|
} else {
|
|
188
279
|
const target = entityTargets.get(row.aggregate_type);
|
|
189
280
|
if (!target || !isLifecycleEventOf(row.type, row.aggregate_type)) return null;
|
|
281
|
+
await applyEntityLifecycleFields(target, projectionOwners?.get(row.aggregate_id));
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
if (counters.encrypted === 0 && counters.erased === 0) return null;
|
|
285
|
+
return { payload, ...counters };
|
|
286
|
+
|
|
287
|
+
function bump(outcome: FieldOutcome): void {
|
|
288
|
+
if (outcome === "encrypted") counters.encrypted++;
|
|
289
|
+
if (outcome === "erased") counters.erased++;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
async function applyEntityLifecycleFields(
|
|
293
|
+
target: { readonly entity: EntityDefinition; readonly piiFields: readonly string[] },
|
|
294
|
+
projectionOwnerRow: Record<string, unknown> | undefined,
|
|
295
|
+
): Promise<void> {
|
|
190
296
|
const sections = lifecycleSections(payload);
|
|
191
297
|
for (const section of sections) {
|
|
192
298
|
// Update-changes may carry a pii field without its owner field —
|
|
@@ -198,25 +304,39 @@ export async function backfillEventPiiEncryption(
|
|
|
198
304
|
...section,
|
|
199
305
|
};
|
|
200
306
|
for (const field of target.piiFields) {
|
|
201
|
-
const
|
|
307
|
+
const value = section[field];
|
|
308
|
+
if (value === null || value === undefined) continue;
|
|
309
|
+
if (typeof value !== "string") continue;
|
|
310
|
+
// Already-ciphertext/sentinel fields must never enter owner
|
|
311
|
+
// resolution — an unresolvable owner on an already-handled field
|
|
312
|
+
// must stay untouched, not get erased by stage 3.
|
|
313
|
+
if (isPiiCiphertext(value) || value === PII_ERASED_SENTINEL) continue;
|
|
314
|
+
|
|
315
|
+
const resolution = resolveOwnerSubject(
|
|
316
|
+
target.entity,
|
|
317
|
+
field,
|
|
318
|
+
subjectSource,
|
|
319
|
+
projectionOwnerRow,
|
|
202
320
|
// @cast-boundary db-read — tenant_id column is the branded TenantId
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
if (
|
|
206
|
-
|
|
321
|
+
row.tenant_id as TenantId,
|
|
322
|
+
);
|
|
323
|
+
if (resolution.kind === "unannotated") continue;
|
|
324
|
+
if (resolution.kind === "unresolved") {
|
|
325
|
+
if (options.eraseUnresolvableSubjects) {
|
|
326
|
+
section[field] = PII_ERASED_SENTINEL;
|
|
327
|
+
counters.erased++;
|
|
328
|
+
counters.erasedUnresolvable++;
|
|
329
|
+
continue;
|
|
330
|
+
}
|
|
331
|
+
throw resolution.error;
|
|
332
|
+
}
|
|
333
|
+
if (resolution.viaProjection) counters.ownerFromProjection++;
|
|
334
|
+
const outcome = await encryptField(section, field, resolution.subject);
|
|
207
335
|
bump(outcome);
|
|
208
336
|
}
|
|
209
337
|
}
|
|
210
338
|
}
|
|
211
339
|
|
|
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
340
|
async function encryptField(
|
|
221
341
|
section: Record<string, unknown>,
|
|
222
342
|
field: string,
|
|
@@ -255,6 +375,56 @@ export async function backfillEventPiiEncryption(
|
|
|
255
375
|
}
|
|
256
376
|
}
|
|
257
377
|
|
|
378
|
+
type OwnerSubjectResolution =
|
|
379
|
+
| { readonly kind: "unannotated" }
|
|
380
|
+
| { readonly kind: "unresolved"; readonly error: SubjectResolutionError }
|
|
381
|
+
| { readonly kind: "resolved"; readonly subject: SubjectId; readonly viaProjection: boolean };
|
|
382
|
+
|
|
383
|
+
// Stage 1 (payload) then stage 2 (projection row, when supplied) — stage 3
|
|
384
|
+
// (erase) is the caller's call, made from the "unresolved" outcome.
|
|
385
|
+
function resolveOwnerSubject(
|
|
386
|
+
entity: EntityDefinition,
|
|
387
|
+
field: string,
|
|
388
|
+
subjectSource: Record<string, unknown>,
|
|
389
|
+
projectionOwnerRow: Record<string, unknown> | undefined,
|
|
390
|
+
tenantId: TenantId,
|
|
391
|
+
): OwnerSubjectResolution {
|
|
392
|
+
try {
|
|
393
|
+
const subject = resolveSubjectForField(entity, field, subjectSource, { tenantId });
|
|
394
|
+
return subject === null
|
|
395
|
+
? { kind: "unannotated" }
|
|
396
|
+
: { kind: "resolved", subject, viaProjection: false };
|
|
397
|
+
} catch (e) {
|
|
398
|
+
if (!(e instanceof SubjectResolutionError)) throw e;
|
|
399
|
+
if (projectionOwnerRow) {
|
|
400
|
+
try {
|
|
401
|
+
const augmented = { ...subjectSource, ...projectionOwnerRow };
|
|
402
|
+
const subject = resolveSubjectForField(entity, field, augmented, { tenantId });
|
|
403
|
+
if (subject !== null) return { kind: "resolved", subject, viaProjection: true };
|
|
404
|
+
} catch (e2) {
|
|
405
|
+
if (!(e2 instanceof SubjectResolutionError)) throw e2;
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
return { kind: "unresolved", error: e };
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
// Projection columns are snake_case (author_id); event-payload/subject
|
|
413
|
+
// fields are camelCase (authorId) — forward-map from entity.fields like
|
|
414
|
+
// reindexEntity's rowToState, so any owner field resolves generically
|
|
415
|
+
// instead of hand-listing column names per entity.
|
|
416
|
+
function projectionRowToCamel(
|
|
417
|
+
entity: EntityDefinition,
|
|
418
|
+
row: Record<string, unknown>,
|
|
419
|
+
): Record<string, unknown> {
|
|
420
|
+
const camel: Record<string, unknown> = {};
|
|
421
|
+
for (const fieldName of Object.keys(entity.fields)) {
|
|
422
|
+
const snake = toSnakeCase(fieldName);
|
|
423
|
+
if (Object.hasOwn(row, snake)) camel[fieldName] = row[snake];
|
|
424
|
+
}
|
|
425
|
+
return camel;
|
|
426
|
+
}
|
|
427
|
+
|
|
258
428
|
function isLifecycleEventOf(eventType: string, aggregateType: string): boolean {
|
|
259
429
|
if (!eventType.startsWith(`${aggregateType}.`)) return false;
|
|
260
430
|
const verb = eventType.slice(aggregateType.length + 1);
|
|
@@ -25,7 +25,12 @@ const requiresUserDataHook = (features: readonly { readonly name: string }[]) =>
|
|
|
25
25
|
|
|
26
26
|
const promptStore = () =>
|
|
27
27
|
defineFeature("prompt-store", (r) => {
|
|
28
|
-
const promptFields = {
|
|
28
|
+
const promptFields = {
|
|
29
|
+
text: createTextField({
|
|
30
|
+
personal: "self",
|
|
31
|
+
find: "none",
|
|
32
|
+
}),
|
|
33
|
+
};
|
|
29
34
|
r.entity("prompt", createEntity({ fields: promptFields }));
|
|
30
35
|
r.bootCheck(({ features }) => {
|
|
31
36
|
// Conditional on this feature's own shape (has a pii field), closed
|