@cosmicdrift/kumiko-framework 0.252.0 → 0.253.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
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-framework",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.253.0",
|
|
4
4
|
"description": "Framework core — engine, pipeline, API, DB, and every other bit that makes Kumiko go.",
|
|
5
5
|
"license": "BUSL-1.1",
|
|
6
6
|
"author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
|
|
@@ -198,7 +198,7 @@
|
|
|
198
198
|
"./package.json": "./package.json"
|
|
199
199
|
},
|
|
200
200
|
"dependencies": {
|
|
201
|
-
"@cosmicdrift/kumiko-types": "0.
|
|
201
|
+
"@cosmicdrift/kumiko-types": "0.253.0",
|
|
202
202
|
"bullmq": "^5.76.7",
|
|
203
203
|
"bun-types": "^1.3.13",
|
|
204
204
|
"hono": "^4.13.1",
|
|
@@ -214,7 +214,7 @@
|
|
|
214
214
|
"zod": "^4.4.3"
|
|
215
215
|
},
|
|
216
216
|
"devDependencies": {
|
|
217
|
-
"@cosmicdrift/kumiko-dispatcher-live": "0.
|
|
217
|
+
"@cosmicdrift/kumiko-dispatcher-live": "0.253.0",
|
|
218
218
|
"bun-types": "^1.3.13",
|
|
219
219
|
"pino-pretty": "^13.1.3"
|
|
220
220
|
},
|
|
@@ -6,8 +6,12 @@
|
|
|
6
6
|
// them in place, per field, under the owning subject's DEK:
|
|
7
7
|
//
|
|
8
8
|
// - entity lifecycle events (<entity>.created/updated/deleted/forgotten/
|
|
9
|
-
// restored) for every entity with PII subject annotations
|
|
10
|
-
//
|
|
9
|
+
// restored) for every entity with PII subject annotations — generic over
|
|
10
|
+
// subject kind (user/tenant/record, fw#2790), since it goes through the
|
|
11
|
+
// same collectPiiSubjectFields/resolveSubjectForField the live write
|
|
12
|
+
// path uses
|
|
13
|
+
// - custom events from the event-PII catalog (r.defineEvent piiFields) —
|
|
14
|
+
// user-subject only, see the note at its call site (fw#2801)
|
|
11
15
|
//
|
|
12
16
|
// Already-forgotten subjects must NOT get a fresh key minted for their old
|
|
13
17
|
// plaintext — three erased-detection layers write [[erased]] instead:
|
|
@@ -282,6 +286,8 @@ export async function backfillEventPiiEncryption(
|
|
|
282
286
|
for (const [field, spec] of Object.entries(catalogFields)) {
|
|
283
287
|
const subjectId = payload[spec.subjectField];
|
|
284
288
|
if (typeof subjectId !== "string" || subjectId.length === 0) continue;
|
|
289
|
+
// Catalog entries only ever resolve a user subject — a tenant/record
|
|
290
|
+
// subject in a custom event is not backfillable today (fw#2801).
|
|
285
291
|
const outcome = await encryptField(payload, field, { kind: "user", userId: subjectId });
|
|
286
292
|
bump(outcome);
|
|
287
293
|
}
|
|
@@ -20,6 +20,7 @@ import {
|
|
|
20
20
|
isPiiCiphertext,
|
|
21
21
|
KeyNotFoundError,
|
|
22
22
|
PgKmsAdapter,
|
|
23
|
+
PII_CIPHERTEXT_PREFIX,
|
|
23
24
|
PII_ERASED_SENTINEL,
|
|
24
25
|
} from "../../crypto";
|
|
25
26
|
import { applyEntityEvent } from "../../db/apply-entity-event";
|
|
@@ -72,6 +73,20 @@ const notesFeature = defineFeature("notes", (r) => {
|
|
|
72
73
|
r.entity("note", noteEntity);
|
|
73
74
|
});
|
|
74
75
|
|
|
76
|
+
// personal: { of: "id" } → recordOwned (kumiko-framework#2786): the row
|
|
77
|
+
// itself is the subject, not a user/tenant reachable via an owner field.
|
|
78
|
+
// fw#2790 — this fixture proves the pre-existing generic backfill already
|
|
79
|
+
// covers the record subject kind for lifecycle events, no new code needed.
|
|
80
|
+
const surveyEntity = createEntity({
|
|
81
|
+
fields: {
|
|
82
|
+
answer: createTextField({ personal: { of: "id" }, find: "none" }),
|
|
83
|
+
},
|
|
84
|
+
});
|
|
85
|
+
const surveyTable = buildEntityTable("survey", surveyEntity);
|
|
86
|
+
const surveyFeature = defineFeature("survey", (r) => {
|
|
87
|
+
r.entity("survey", surveyEntity);
|
|
88
|
+
});
|
|
89
|
+
|
|
75
90
|
let testDb: TestDb;
|
|
76
91
|
let registry: Registry;
|
|
77
92
|
let kms: InMemoryKmsAdapter;
|
|
@@ -96,10 +111,11 @@ async function appendPlain(
|
|
|
96
111
|
|
|
97
112
|
beforeAll(async () => {
|
|
98
113
|
testDb = await createTestDb();
|
|
99
|
-
registry = createRegistry([crmFeature, mailerFeature, notesFeature]);
|
|
114
|
+
registry = createRegistry([crmFeature, mailerFeature, notesFeature, surveyFeature]);
|
|
100
115
|
await createEventsTable(testDb.db);
|
|
101
116
|
await unsafeCreateEntityTable(testDb.db, contactEntity, "contact");
|
|
102
117
|
await unsafeCreateEntityTable(testDb.db, noteEntity, "note");
|
|
118
|
+
await unsafeCreateEntityTable(testDb.db, surveyEntity, "survey");
|
|
103
119
|
});
|
|
104
120
|
|
|
105
121
|
afterAll(async () => {
|
|
@@ -111,6 +127,7 @@ beforeEach(async () => {
|
|
|
111
127
|
await raw.unsafe(`TRUNCATE "kumiko_events" RESTART IDENTITY`);
|
|
112
128
|
await raw.unsafe(`TRUNCATE "${contactTable.tableName}"`);
|
|
113
129
|
await raw.unsafe(`TRUNCATE "${noteTable.tableName}"`);
|
|
130
|
+
await raw.unsafe(`TRUNCATE "${surveyTable.tableName}"`);
|
|
114
131
|
// Plaintext era: NO KMS while the legacy events are appended.
|
|
115
132
|
resetPiiSubjectKmsForTests();
|
|
116
133
|
resetBlindIndexKeyForTests();
|
|
@@ -590,6 +607,104 @@ describe("backfillEventPiiEncryption: owner-resolution chain (fw#2266)", () => {
|
|
|
590
607
|
});
|
|
591
608
|
});
|
|
592
609
|
|
|
610
|
+
// fw#2790 — "Altbestand: Freitext-Klartext in kumiko_events". #2596 added
|
|
611
|
+
// the recordOwned ("record") subject kind for row-scoped free text with no
|
|
612
|
+
// user/tenant owner. Nothing in this file's resolution path (subject-
|
|
613
|
+
// resolver.ts / pii-field-encryption.ts) special-cases subject kind, so the
|
|
614
|
+
// expectation is that this backfill already covers it for lifecycle events
|
|
615
|
+
// with zero new code — these tests pin that down rather than assume it.
|
|
616
|
+
describe("backfillEventPiiEncryption: record subject (kumiko-framework#2786)", () => {
|
|
617
|
+
test("encrypts a lifecycle payload under record:<entity>:<id>, not user:<id>", async () => {
|
|
618
|
+
const s1 = generateId();
|
|
619
|
+
await appendPlain(s1, "survey", "survey.created", { id: s1, answer: "plaintext answer" });
|
|
620
|
+
|
|
621
|
+
armKms();
|
|
622
|
+
const result = await backfillEventPiiEncryption(testDb.db, registry);
|
|
623
|
+
|
|
624
|
+
expect(result.failures).toEqual([]);
|
|
625
|
+
expect(result.updatedEvents).toBe(1);
|
|
626
|
+
expect(result.encryptedFields).toBe(1);
|
|
627
|
+
// Neither owner-resolution fallback is needed: the subject is the row's
|
|
628
|
+
// own id, always present on the event — stage 2/3 stay unused.
|
|
629
|
+
expect(result.ownerFromProjection).toBe(0);
|
|
630
|
+
expect(result.erasedUnresolvable).toBe(0);
|
|
631
|
+
|
|
632
|
+
const events = await loadAggregate(testDb.db, s1, TENANT);
|
|
633
|
+
const created = events[0]?.payload as Record<string, unknown>;
|
|
634
|
+
expect(isPiiCiphertext(created["answer"])).toBe(true);
|
|
635
|
+
// Exact prefix, not just "contains": the entity segment must be the
|
|
636
|
+
// registry name forget-subject resolves ("survey"), not the table name
|
|
637
|
+
// or a divergent aggregate_type — a wrong segment here would make the
|
|
638
|
+
// ciphertext unreachable by record-forget (fw#2790).
|
|
639
|
+
expect(String(created["answer"])).toStartWith(`${PII_CIPHERTEXT_PREFIX}record:survey:${s1}:`);
|
|
640
|
+
expect(String(created["answer"])).not.toContain(`user:${s1}`);
|
|
641
|
+
});
|
|
642
|
+
|
|
643
|
+
test("encrypts changes+previous on an update the same way", async () => {
|
|
644
|
+
const s2 = generateId();
|
|
645
|
+
await appendPlain(s2, "survey", "survey.created", { id: s2, answer: "first" });
|
|
646
|
+
await appendPlain(
|
|
647
|
+
s2,
|
|
648
|
+
"survey",
|
|
649
|
+
"survey.updated",
|
|
650
|
+
{ changes: { answer: "second" }, previous: { id: s2, answer: "first" } },
|
|
651
|
+
1,
|
|
652
|
+
);
|
|
653
|
+
|
|
654
|
+
armKms();
|
|
655
|
+
const result = await backfillEventPiiEncryption(testDb.db, registry);
|
|
656
|
+
expect(result.failures).toEqual([]);
|
|
657
|
+
expect(result.updatedEvents).toBe(2);
|
|
658
|
+
|
|
659
|
+
const events = await loadAggregate(testDb.db, s2, TENANT);
|
|
660
|
+
const updated = events[1]?.payload as {
|
|
661
|
+
changes: Record<string, unknown>;
|
|
662
|
+
previous: Record<string, unknown>;
|
|
663
|
+
};
|
|
664
|
+
expect(String(updated.changes["answer"])).toStartWith(
|
|
665
|
+
`${PII_CIPHERTEXT_PREFIX}record:survey:${s2}:`,
|
|
666
|
+
);
|
|
667
|
+
expect(String(updated.previous["answer"])).toStartWith(
|
|
668
|
+
`${PII_CIPHERTEXT_PREFIX}record:survey:${s2}:`,
|
|
669
|
+
);
|
|
670
|
+
});
|
|
671
|
+
|
|
672
|
+
test("a pre-KMS-forgotten row gets [[erased]], not a fresh record key", async () => {
|
|
673
|
+
const s3 = generateId();
|
|
674
|
+
await appendPlain(s3, "survey", "survey.created", { id: s3, answer: "secret" });
|
|
675
|
+
await appendPlain(
|
|
676
|
+
s3,
|
|
677
|
+
"survey",
|
|
678
|
+
"survey.forgotten",
|
|
679
|
+
{ previous: { id: s3, answer: "secret" } },
|
|
680
|
+
1,
|
|
681
|
+
);
|
|
682
|
+
|
|
683
|
+
armKms();
|
|
684
|
+
const result = await backfillEventPiiEncryption(testDb.db, registry);
|
|
685
|
+
|
|
686
|
+
expect(result.failures).toEqual([]);
|
|
687
|
+
expect(result.erasedFields).toBe(2);
|
|
688
|
+
const events = await loadAggregate(testDb.db, s3, TENANT);
|
|
689
|
+
const created = events[0]?.payload as Record<string, unknown>;
|
|
690
|
+
expect(created["answer"]).toBe(PII_ERASED_SENTINEL);
|
|
691
|
+
const forgotten = events[1]?.payload as { previous: Record<string, unknown> };
|
|
692
|
+
expect(forgotten.previous["answer"]).toBe(PII_ERASED_SENTINEL);
|
|
693
|
+
});
|
|
694
|
+
|
|
695
|
+
test("idempotent: a second run touches nothing", async () => {
|
|
696
|
+
const s4 = generateId();
|
|
697
|
+
await appendPlain(s4, "survey", "survey.created", { id: s4, answer: "once" });
|
|
698
|
+
|
|
699
|
+
armKms();
|
|
700
|
+
const first = await backfillEventPiiEncryption(testDb.db, registry);
|
|
701
|
+
expect(first.updatedEvents).toBe(1);
|
|
702
|
+
const second = await backfillEventPiiEncryption(testDb.db, registry);
|
|
703
|
+
expect(second.updatedEvents).toBe(0);
|
|
704
|
+
expect(second.failures).toEqual([]);
|
|
705
|
+
});
|
|
706
|
+
});
|
|
707
|
+
|
|
593
708
|
describe("backfillEventPiiEncryption: raw jsonb column type (fw#2253)", () => {
|
|
594
709
|
// The UPDATE used to wrap outcome.payload in JSON.stringify before handing
|
|
595
710
|
// it to the ::jsonb cast — Bun.SQL already serializes objects, so the
|
|
@@ -13,7 +13,10 @@
|
|
|
13
13
|
// are the ceiling.
|
|
14
14
|
//
|
|
15
15
|
// Runs isolated in the `event-store-perf` CI job (test:integration:perf:eventstore,
|
|
16
|
-
// #1940) — gate on p95 for typical latency; p99 keeps a separate tail budget for
|
|
16
|
+
// #1940) — gate on p95 for typical latency; p99 keeps a separate tail budget for
|
|
17
|
+
// residual scheduling noise. CI's Postgres container runs with fsync/synchronous_commit/
|
|
18
|
+
// full_page_writes off (#2798) — it exists only to be measured and thrown away, so these
|
|
19
|
+
// numbers aren't comparable to a default-config local container.
|
|
17
20
|
|
|
18
21
|
import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
|
|
19
22
|
import { type BunTestDb, createTestDb } from "../../bun-db/__tests__/bun-test-db";
|
|
@@ -98,12 +101,14 @@ describe("event-store performance — Gate A", () => {
|
|
|
98
101
|
const p50 = percentile(samples, 0.5);
|
|
99
102
|
const p95 = percentile(samples, 0.95);
|
|
100
103
|
const p99 = percentile(samples, 0.99);
|
|
104
|
+
const over30 = samples.filter((s) => s >= 30).length;
|
|
105
|
+
const top5 = samples.slice(-5).map((s) => s.toFixed(1));
|
|
101
106
|
console.log(
|
|
102
|
-
` Write-latency: p50=${p50.toFixed(2)}ms, p95=${p95.toFixed(2)}ms, p99=${p99.toFixed(2)}ms (n=200)`,
|
|
107
|
+
` Write-latency: p50=${p50.toFixed(2)}ms, p95=${p95.toFixed(2)}ms, p99=${p99.toFixed(2)}ms (n=200) over30=${over30} top5=${top5}`,
|
|
103
108
|
);
|
|
104
109
|
|
|
105
110
|
expect(p95).toBeLessThan(30);
|
|
106
|
-
// Tail budget: absorbs
|
|
111
|
+
// Tail budget: absorbs residual runner scheduling noise while still catching order-of-magnitude regressions.
|
|
107
112
|
expect(p99).toBeLessThan(250);
|
|
108
113
|
});
|
|
109
114
|
|