@cosmicdrift/kumiko-framework 0.155.1 → 0.156.1
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/README.md +9 -38
- package/package.json +2 -2
- package/src/__tests__/entity-permalink-open.integration.test.ts +94 -0
- package/src/__tests__/raw-table.integration.test.ts +17 -40
- package/src/crypto/__tests__/pii-field-encryption.test.ts +105 -1
- package/src/crypto/index.ts +1 -0
- package/src/crypto/pii-field-encryption.ts +19 -0
- package/src/db/__tests__/assert-no-unreachable-live-rows.integration.test.ts +27 -1
- package/src/db/__tests__/blind-index.integration.test.ts +16 -0
- package/src/db/__tests__/collect-table-metas.test.ts +6 -6
- package/src/db/__tests__/event-store-executor.integration.test.ts +75 -0
- package/src/db/__tests__/feature-table-sources.test.ts +7 -7
- package/src/db/__tests__/migrate-generator.test.ts +21 -0
- package/src/db/__tests__/number-field-fractional.integration.test.ts +1 -1
- package/src/db/__tests__/tenant-db-where-merge.test.ts +39 -0
- package/src/db/collect-table-metas.ts +6 -7
- package/src/db/entity-table-meta.ts +1 -1
- package/src/db/event-store-executor-write.ts +23 -2
- package/src/db/event-store-executor.ts +1 -1
- package/src/db/feature-table-sources.ts +5 -11
- package/src/db/migrate-generator.ts +39 -6
- package/src/db/queries/shadow-swap.ts +85 -3
- package/src/db/tenant-db.ts +8 -0
- package/src/engine/__tests__/boot-validator-pii-retention.test.ts +102 -0
- package/src/engine/__tests__/build-config-feature-schema.test.ts +16 -0
- package/src/engine/__tests__/feature-crud-shorthand.test.ts +28 -0
- package/src/engine/__tests__/field-access.test.ts +23 -1
- package/src/engine/__tests__/raw-table.test.ts +134 -84
- package/src/engine/__tests__/registry.test.ts +40 -0
- package/src/engine/boot-validator/__tests__/config-deps.test.ts +52 -2
- package/src/engine/boot-validator/action-wiring.ts +2 -2
- package/src/engine/boot-validator/config-deps.ts +35 -0
- package/src/engine/boot-validator/index.ts +2 -0
- package/src/engine/boot-validator/pii-retention.ts +35 -3
- package/src/engine/build-config-feature-schema.ts +3 -3
- package/src/engine/config-helpers.ts +2 -0
- package/src/engine/define-feature.ts +2 -6
- package/src/engine/entity-handlers.ts +11 -5
- package/src/engine/feature-ast/__tests__/parse-real-features.test.ts +1 -1
- package/src/engine/feature-ast/__tests__/parse.test.ts +170 -0
- package/src/engine/feature-ast/__tests__/patch.test.ts +31 -0
- package/src/engine/feature-ast/__tests__/render-roundtrip.test.ts +6 -1
- package/src/engine/feature-ast/extractors/events.ts +322 -0
- package/src/engine/feature-ast/extractors/handlers.ts +222 -0
- package/src/engine/feature-ast/extractors/hooks.ts +243 -0
- package/src/engine/feature-ast/extractors/index.ts +32 -24
- package/src/engine/feature-ast/extractors/jobs-routes.ts +221 -0
- package/src/engine/feature-ast/extractors/projections-screens.ts +269 -0
- package/src/engine/feature-ast/extractors/round5.ts +3 -3
- package/src/engine/feature-ast/parse.ts +3 -3
- package/src/engine/feature-ast/render.ts +16 -11
- package/src/engine/feature-builder-state.ts +0 -3
- package/src/engine/feature-config-events-jobs.ts +3 -6
- package/src/engine/feature-entity-handlers.ts +9 -1
- package/src/engine/feature-ui-extensions.ts +10 -40
- package/src/engine/field-access.ts +13 -2
- package/src/engine/object-form.ts +15 -0
- package/src/engine/registry-facade.ts +4 -0
- package/src/engine/registry-ingest.ts +19 -29
- package/src/engine/registry-state.ts +1 -7
- package/src/engine/registry-validate.ts +5 -28
- package/src/engine/registry.ts +0 -2
- package/src/engine/types/config.ts +7 -0
- package/src/engine/types/feature.ts +40 -68
- package/src/engine/types/fields.ts +6 -0
- package/src/engine/types/index.ts +0 -3
- package/src/es-ops/README.md +1 -1
- package/src/es-ops/__tests__/runner.integration.test.ts +74 -0
- package/src/es-ops/context.ts +4 -2
- package/src/es-ops/types.ts +8 -1
- package/src/pipeline/__tests__/dispatcher.test.ts +29 -0
- package/src/pipeline/msp-rebuild.ts +4 -0
- package/src/pipeline/projection-rebuild.ts +35 -0
- package/src/search/__tests__/meilisearch-adapter.integration.test.ts +8 -1
- package/src/stack/test-stack.ts +17 -2
- package/src/ui-types/index.ts +1 -0
- package/src/engine/__tests__/unmanaged-table.test.ts +0 -172
- package/src/engine/feature-ast/extractors/round4.ts +0 -1227
package/README.md
CHANGED
|
@@ -28,9 +28,10 @@ Bun is the intended runtime and test runner.
|
|
|
28
28
|
## At-a-glance
|
|
29
29
|
|
|
30
30
|
```typescript
|
|
31
|
-
import {
|
|
31
|
+
import { createEntity, createTextField, defineFeature } from "@cosmicdrift/kumiko-framework/engine";
|
|
32
32
|
|
|
33
33
|
export const taskEntity = createEntity({
|
|
34
|
+
table: "read_tasks",
|
|
34
35
|
fields: {
|
|
35
36
|
title: createTextField({ required: true, searchable: true }),
|
|
36
37
|
done: createTextField(),
|
|
@@ -38,44 +39,14 @@ export const taskEntity = createEntity({
|
|
|
38
39
|
softDelete: true,
|
|
39
40
|
});
|
|
40
41
|
|
|
41
|
-
const taskTable = buildDrizzleTable("task", taskEntity);
|
|
42
|
-
const taskExecutor = createEventStoreExecutor(taskTable, taskEntity, { entityName: "task" });
|
|
43
|
-
|
|
44
42
|
export const taskFeature = defineFeature("tasks", (r) => {
|
|
45
|
-
r.
|
|
46
|
-
|
|
47
|
-
//
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
async (event, ctx) => taskExecutor.create(event.payload, event.user, ctx.db),
|
|
53
|
-
{ access: { roles: ["User"] } },
|
|
54
|
-
);
|
|
55
|
-
r.writeHandler(
|
|
56
|
-
"task:update",
|
|
57
|
-
z.object({ id: z.uuid(), version: z.number(), changes: z.object({ title: z.string().optional() }) }),
|
|
58
|
-
async (event, ctx) => taskExecutor.update(event.payload, event.user, ctx.db),
|
|
59
|
-
{ access: { roles: ["User"] } },
|
|
60
|
-
);
|
|
61
|
-
r.writeHandler(
|
|
62
|
-
"task:delete",
|
|
63
|
-
z.object({ id: z.uuid() }),
|
|
64
|
-
async (event, ctx) => taskExecutor.delete(event.payload, event.user, ctx.db),
|
|
65
|
-
{ access: { roles: ["Admin"] } },
|
|
66
|
-
);
|
|
67
|
-
r.queryHandler(
|
|
68
|
-
"task:list",
|
|
69
|
-
z.object({}),
|
|
70
|
-
async (query, ctx) => taskExecutor.list(query.payload, query.user, ctx.db),
|
|
71
|
-
{ access: { openToAll: true } },
|
|
72
|
-
);
|
|
73
|
-
r.queryHandler(
|
|
74
|
-
"task:detail",
|
|
75
|
-
z.object({ id: z.uuid() }),
|
|
76
|
-
async (query, ctx) => taskExecutor.detail(query.payload, query.user, ctx.db),
|
|
77
|
-
{ access: { openToAll: true } },
|
|
78
|
-
);
|
|
43
|
+
// r.crud wires create/update/delete/restore + list/detail queries in one
|
|
44
|
+
// call — events + projection in the same TX, optimistic locking, access
|
|
45
|
+
// control, all explicit via the options below.
|
|
46
|
+
r.crud("task", taskEntity, {
|
|
47
|
+
write: { access: { roles: ["User"] } },
|
|
48
|
+
read: { access: { openToAll: true } },
|
|
49
|
+
});
|
|
79
50
|
|
|
80
51
|
// Read-model fed from task events, rebuildable via the CLI
|
|
81
52
|
r.projection({
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-framework",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.156.1",
|
|
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>",
|
|
@@ -193,7 +193,7 @@
|
|
|
193
193
|
"zod": "^4.4.3"
|
|
194
194
|
},
|
|
195
195
|
"devDependencies": {
|
|
196
|
-
"@cosmicdrift/kumiko-dispatcher-live": "0.
|
|
196
|
+
"@cosmicdrift/kumiko-dispatcher-live": "0.156.1",
|
|
197
197
|
"bun-types": "^1.3.13",
|
|
198
198
|
"pino-pretty": "^13.1.3"
|
|
199
199
|
},
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
// Issue #912 — Teilbare Entity-Permalinks. Der Copy-Link-Button selbst ist
|
|
2
|
+
// UI-Zucker (baut eine URL, kopiert sie); die eigentliche Anforderung des
|
|
3
|
+
// Issues ist "ein Permalink darf nie Auth/Tenant-Isolation umgehen". Ein
|
|
4
|
+
// entityEdit-Permalink öffnet beim Klick exactly denselben
|
|
5
|
+
// `<feature>:query:<entity>:detail`-Call, den jede Entity schon für ihr
|
|
6
|
+
// Update-Formular nutzt — es gibt keinen separaten Permalink-Resolve-Pfad.
|
|
7
|
+
// Dieser Test beweist den Deny für beide Achsen, die einen Leak verhindern
|
|
8
|
+
// müssten: Rollen-Gate (dispatcher-seitig, default-deny) und Tenant-Scoping
|
|
9
|
+
// (strukturell über ctx.db).
|
|
10
|
+
|
|
11
|
+
import { describe, expect, test } from "bun:test";
|
|
12
|
+
import { setupTestStackFromFeatures } from "@cosmicdrift/kumiko-dev-server/setup-test-stack-from-features";
|
|
13
|
+
import {
|
|
14
|
+
createEntity,
|
|
15
|
+
createTextField,
|
|
16
|
+
defineEntityCreateHandler,
|
|
17
|
+
defineEntityDetailHandler,
|
|
18
|
+
defineFeature,
|
|
19
|
+
} from "@cosmicdrift/kumiko-framework/engine";
|
|
20
|
+
import {
|
|
21
|
+
createTestUser,
|
|
22
|
+
pushEntityProjectionTables,
|
|
23
|
+
testTenantId,
|
|
24
|
+
} from "@cosmicdrift/kumiko-framework/stack";
|
|
25
|
+
|
|
26
|
+
const noteEntity = createEntity({
|
|
27
|
+
table: "read_permalink_notes",
|
|
28
|
+
fields: {
|
|
29
|
+
title: createTextField({ required: true, maxLength: 160, allowPlaintext: "is-business-data" }),
|
|
30
|
+
},
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
const permalinkFeature = defineFeature("permalink-fixture", (r) => {
|
|
34
|
+
r.entity("note", noteEntity);
|
|
35
|
+
const access = { roles: ["TenantAdmin"] } as const;
|
|
36
|
+
r.writeHandler(defineEntityCreateHandler("note", noteEntity, { access }));
|
|
37
|
+
r.queryHandler(defineEntityDetailHandler("note", noteEntity, { access }));
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
describe("entityEdit permalink open-path denies cleanly", () => {
|
|
41
|
+
test("unauthorized role → AccessDeniedError, not the record", async () => {
|
|
42
|
+
const stack = await setupTestStackFromFeatures([permalinkFeature]);
|
|
43
|
+
try {
|
|
44
|
+
const tenantId = testTenantId(701);
|
|
45
|
+
const owner = createTestUser({ id: 701, tenantId, roles: ["TenantAdmin"] });
|
|
46
|
+
const outsider = createTestUser({ id: 702, tenantId, roles: ["TeamMember"] });
|
|
47
|
+
await pushEntityProjectionTables(stack, stack.registry);
|
|
48
|
+
|
|
49
|
+
const created = await stack.http.writeOk<{ id: string }>(
|
|
50
|
+
"permalink-fixture:write:note:create",
|
|
51
|
+
{ title: "Q3 Forecast" },
|
|
52
|
+
owner,
|
|
53
|
+
);
|
|
54
|
+
|
|
55
|
+
const res = await stack.http.query(
|
|
56
|
+
"permalink-fixture:query:note:detail",
|
|
57
|
+
{ id: created.id },
|
|
58
|
+
outsider,
|
|
59
|
+
);
|
|
60
|
+
expect(res.ok).toBe(false);
|
|
61
|
+
const body = (await res.json()) as { isSuccess?: boolean; error?: { code?: string } };
|
|
62
|
+
expect(body.isSuccess).not.toBe(true);
|
|
63
|
+
expect(body.error?.code).toBe("access_denied");
|
|
64
|
+
} finally {
|
|
65
|
+
await stack.cleanup();
|
|
66
|
+
}
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test("cross-tenant open → not-found, not the other tenant's record", async () => {
|
|
70
|
+
const stack = await setupTestStackFromFeatures([permalinkFeature]);
|
|
71
|
+
try {
|
|
72
|
+
const tenantA = testTenantId(703);
|
|
73
|
+
const tenantB = testTenantId(704);
|
|
74
|
+
const ownerA = createTestUser({ id: 703, tenantId: tenantA, roles: ["TenantAdmin"] });
|
|
75
|
+
const adminB = createTestUser({ id: 704, tenantId: tenantB, roles: ["TenantAdmin"] });
|
|
76
|
+
await pushEntityProjectionTables(stack, stack.registry);
|
|
77
|
+
|
|
78
|
+
const created = await stack.http.writeOk<{ id: string }>(
|
|
79
|
+
"permalink-fixture:write:note:create",
|
|
80
|
+
{ title: "Tenant A Secret" },
|
|
81
|
+
ownerA,
|
|
82
|
+
);
|
|
83
|
+
|
|
84
|
+
const row = await stack.http.queryOk<Record<string, unknown> | null>(
|
|
85
|
+
"permalink-fixture:query:note:detail",
|
|
86
|
+
{ id: created.id },
|
|
87
|
+
adminB,
|
|
88
|
+
);
|
|
89
|
+
expect(row).toBeNull();
|
|
90
|
+
} finally {
|
|
91
|
+
await stack.cleanup();
|
|
92
|
+
}
|
|
93
|
+
});
|
|
94
|
+
});
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
// kumiko-platform/docs/plans/architecture/table-ddl-guard.md (Stufe 3).
|
|
5
5
|
|
|
6
6
|
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
7
|
-
import {
|
|
7
|
+
import { defineUnmanagedTable } from "../db/entity-table-meta";
|
|
8
8
|
import { asRawClient, insertOne, selectMany } from "../db/query";
|
|
9
9
|
import { defineFeature } from "../engine";
|
|
10
10
|
import { setupTestStack, type TestStack, unsafePushTables } from "../stack";
|
|
@@ -13,30 +13,19 @@ import { setupTestStack, type TestStack, unsafePushTables } from "../stack";
|
|
|
13
13
|
// write-only by an integration handler, read-only by a query, never
|
|
14
14
|
// event-sourced (the data isn't a domain fact, it's a side-effect
|
|
15
15
|
// snapshot).
|
|
16
|
-
const
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
// registrations — pins the seenTables-by-reference dedupe at push time.
|
|
24
|
-
// Two logical names, one CREATE.
|
|
25
|
-
const sharedSyncCache = table("rt_int_shared_sync_cache", {
|
|
26
|
-
syncId: text("sync_id").primaryKey(),
|
|
27
|
-
payload: text("payload").notNull(),
|
|
16
|
+
const stripeWebhookCacheMeta = defineUnmanagedTable({
|
|
17
|
+
tableName: "rt_int_stripe_webhook_cache",
|
|
18
|
+
columns: [
|
|
19
|
+
{ name: "event_id", pgType: "text", notNull: true, primaryKey: true },
|
|
20
|
+
{ name: "payload", pgType: "text", notNull: true },
|
|
21
|
+
{ name: "received_at", pgType: "timestamptz", notNull: true, defaultSql: "now()" },
|
|
22
|
+
],
|
|
28
23
|
});
|
|
29
24
|
|
|
30
25
|
const webhookCacheFeature = defineFeature("webhook-cache", (r) => {
|
|
31
|
-
r.rawTable(
|
|
26
|
+
r.rawTable(stripeWebhookCacheMeta, {
|
|
32
27
|
reason: "external Stripe webhook payload cache — write-only by webhook handler",
|
|
33
28
|
});
|
|
34
|
-
r.rawTable("primary-sync", sharedSyncCache, {
|
|
35
|
-
reason: "shared sync-state cache, primary writer",
|
|
36
|
-
});
|
|
37
|
-
r.rawTable("secondary-sync", sharedSyncCache, {
|
|
38
|
-
reason: "same physical table, different logical role for read consumers",
|
|
39
|
-
});
|
|
40
29
|
});
|
|
41
30
|
|
|
42
31
|
let stack: TestStack;
|
|
@@ -54,9 +43,9 @@ describe("r.rawTable — DB roundtrip via setupTestStack", () => {
|
|
|
54
43
|
const eventId = "evt_test_123";
|
|
55
44
|
const payload = JSON.stringify({ type: "invoice.paid", amount: 4200 });
|
|
56
45
|
|
|
57
|
-
await insertOne(stack.db,
|
|
46
|
+
await insertOne(stack.db, stripeWebhookCacheMeta, { eventId, payload });
|
|
58
47
|
|
|
59
|
-
const rows = await selectMany(stack.db,
|
|
48
|
+
const rows = await selectMany(stack.db, stripeWebhookCacheMeta, { eventId: eventId });
|
|
60
49
|
|
|
61
50
|
expect(rows).toHaveLength(1);
|
|
62
51
|
expect(rows[0]?.payload).toBe(payload);
|
|
@@ -65,11 +54,11 @@ describe("r.rawTable — DB roundtrip via setupTestStack", () => {
|
|
|
65
54
|
|
|
66
55
|
test("registry exposes the raw table with its reason and featureName", () => {
|
|
67
56
|
const all = stack.registry.getAllRawTables();
|
|
68
|
-
const entry = all.get("
|
|
57
|
+
const entry = all.get("rt_int_stripe_webhook_cache");
|
|
69
58
|
expect(entry).toBeDefined();
|
|
70
59
|
expect(entry?.featureName).toBe("webhook-cache");
|
|
71
60
|
expect(entry?.reason).toContain("Stripe webhook payload cache");
|
|
72
|
-
expect(entry?.
|
|
61
|
+
expect(entry?.meta).toBe(stripeWebhookCacheMeta);
|
|
73
62
|
});
|
|
74
63
|
|
|
75
64
|
test("INSERT bypasses the event store — no kumiko_events row produced", async () => {
|
|
@@ -82,7 +71,7 @@ describe("r.rawTable — DB roundtrip via setupTestStack", () => {
|
|
|
82
71
|
);
|
|
83
72
|
const beforeCount = Number(before[0]?.count ?? 0);
|
|
84
73
|
|
|
85
|
-
await insertOne(stack.db,
|
|
74
|
+
await insertOne(stack.db, stripeWebhookCacheMeta, {
|
|
86
75
|
eventId: "evt_no_event_emitted",
|
|
87
76
|
payload: "{}",
|
|
88
77
|
});
|
|
@@ -95,22 +84,10 @@ describe("r.rawTable — DB roundtrip via setupTestStack", () => {
|
|
|
95
84
|
expect(afterCount).toBe(beforeCount);
|
|
96
85
|
});
|
|
97
86
|
|
|
98
|
-
test("two registrations sharing one PgTable result in one CREATE (dedupe by reference)", async () => {
|
|
99
|
-
// primary-sync + secondary-sync both target sharedSyncCache. If the
|
|
100
|
-
// setupTestStack dedupe (seenTables-by-table-reference) had silently
|
|
101
|
-
// broken, beforeAll's setupTestStack would have raised a 42P07 on
|
|
102
|
-
// the second push and never reached this test.
|
|
103
|
-
await insertOne(stack.db, sharedSyncCache, { syncId: "sync_1", payload: "{}" });
|
|
104
|
-
const rows = await selectMany(stack.db, sharedSyncCache, { syncId: "sync_1" });
|
|
105
|
-
expect(rows).toHaveLength(1);
|
|
106
|
-
// Both registrations are visible in the registry — same physical
|
|
107
|
-
// target, different logical handles.
|
|
108
|
-
expect(stack.registry.getAllRawTables().get("primary-sync")?.table).toBe(sharedSyncCache);
|
|
109
|
-
expect(stack.registry.getAllRawTables().get("secondary-sync")?.table).toBe(sharedSyncCache);
|
|
110
|
-
});
|
|
111
|
-
|
|
112
87
|
test("a second push on the same rawTable is idempotent — CREATE IF NOT EXISTS", async () => {
|
|
113
88
|
// unsafePushTables uses CREATE TABLE IF NOT EXISTS — idempotent by design.
|
|
114
|
-
await expect(
|
|
89
|
+
await expect(
|
|
90
|
+
unsafePushTables(stack.db, { idem: stripeWebhookCacheMeta }),
|
|
91
|
+
).resolves.toBeUndefined();
|
|
115
92
|
});
|
|
116
93
|
});
|
|
@@ -1,11 +1,20 @@
|
|
|
1
1
|
import { describe, expect, test } from "bun:test";
|
|
2
2
|
import { createEntity, createTextField } from "../../engine/factories";
|
|
3
3
|
import { InMemoryKmsAdapter } from "../in-memory-kms-adapter";
|
|
4
|
-
import {
|
|
4
|
+
import {
|
|
5
|
+
KeyErasedError,
|
|
6
|
+
KeyNotFoundError,
|
|
7
|
+
type KmsContext,
|
|
8
|
+
subjectIdFromKey,
|
|
9
|
+
subjectIdToKey,
|
|
10
|
+
} from "../kms-adapter";
|
|
5
11
|
import {
|
|
6
12
|
decryptPiiFieldValues,
|
|
13
|
+
decryptPiiValueForSubject,
|
|
7
14
|
encryptPiiFieldValues,
|
|
15
|
+
encryptPiiValueForSubject,
|
|
8
16
|
isPiiCiphertext,
|
|
17
|
+
PII_CIPHERTEXT_PREFIX,
|
|
9
18
|
PII_ERASED_SENTINEL,
|
|
10
19
|
} from "../pii-field-encryption";
|
|
11
20
|
import { collectPiiSubjectFields } from "../subject-resolver";
|
|
@@ -214,3 +223,98 @@ describe("encryptPiiFieldValues / decryptPiiFieldValues", () => {
|
|
|
214
223
|
);
|
|
215
224
|
});
|
|
216
225
|
});
|
|
226
|
+
|
|
227
|
+
describe("piiEncrypted alias (kumiko-platform#457)", () => {
|
|
228
|
+
test("piiEncrypted + tenantOwned round-trips through the same subject-KMS pipeline", async () => {
|
|
229
|
+
const kms = new InMemoryKmsAdapter();
|
|
230
|
+
const brandingWithAccess = createEntity({
|
|
231
|
+
fields: {
|
|
232
|
+
iban: createTextField({
|
|
233
|
+
piiEncrypted: true,
|
|
234
|
+
tenantOwned: true,
|
|
235
|
+
access: { read: ["TenantAdmin"] },
|
|
236
|
+
}),
|
|
237
|
+
},
|
|
238
|
+
table: "pii_branding_iban",
|
|
239
|
+
});
|
|
240
|
+
const fields = collectPiiSubjectFields(brandingWithAccess);
|
|
241
|
+
expect(fields).toEqual(["iban"]);
|
|
242
|
+
|
|
243
|
+
const row = { id: UUID_A, tenantId: UUID_B, iban: "DE89370400440532013000" };
|
|
244
|
+
const stored = await encryptPiiFieldValues(row, brandingWithAccess, fields, kms, KMS_CTX);
|
|
245
|
+
expect(isPiiCiphertext(stored["iban"])).toBe(true);
|
|
246
|
+
expect(String(stored["iban"])).toStartWith(`kumiko-pii:v1:tenant:${UUID_B}:`);
|
|
247
|
+
|
|
248
|
+
const read = await decryptPiiFieldValues(stored, fields, kms, KMS_CTX);
|
|
249
|
+
expect(read["iban"]).toBe("DE89370400440532013000");
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
test("piiEncrypted field is covered by subject erasure (Art. 17, kumiko-platform#461)", async () => {
|
|
253
|
+
const kms = new InMemoryKmsAdapter();
|
|
254
|
+
const brandingWithAccess = createEntity({
|
|
255
|
+
fields: {
|
|
256
|
+
iban: createTextField({ piiEncrypted: true, tenantOwned: true }),
|
|
257
|
+
},
|
|
258
|
+
table: "pii_branding_iban_erasure",
|
|
259
|
+
});
|
|
260
|
+
const fields = collectPiiSubjectFields(brandingWithAccess);
|
|
261
|
+
const row = { id: UUID_A, tenantId: UUID_B, iban: "DE89370400440532013000" };
|
|
262
|
+
const stored = await encryptPiiFieldValues(row, brandingWithAccess, fields, kms, KMS_CTX);
|
|
263
|
+
|
|
264
|
+
// Erasure is subject-keyed, not field-flag-keyed — kms.eraseKey doesn't
|
|
265
|
+
// know or care that this field is piiEncrypted vs. plain tenantOwned.
|
|
266
|
+
await kms.eraseKey({ kind: "tenant", tenantId: UUID_B });
|
|
267
|
+
|
|
268
|
+
const read = await decryptPiiFieldValues(stored, fields, kms, KMS_CTX);
|
|
269
|
+
expect(read["iban"]).toBe(PII_ERASED_SENTINEL);
|
|
270
|
+
});
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
describe("encryptPiiValueForSubject / decryptPiiValueForSubject (kumiko-platform#459)", () => {
|
|
274
|
+
test("round-trips a single value for a tenant subject", async () => {
|
|
275
|
+
const kms = new InMemoryKmsAdapter();
|
|
276
|
+
const subject = { kind: "tenant" as const, tenantId: UUID_B };
|
|
277
|
+
const stored = await encryptPiiValueForSubject(kms, subject, "DE89370400440532013000", KMS_CTX);
|
|
278
|
+
expect(isPiiCiphertext(stored)).toBe(true);
|
|
279
|
+
expect(stored).toStartWith(`kumiko-pii:v1:tenant:${UUID_B}:`);
|
|
280
|
+
|
|
281
|
+
const read = await decryptPiiValueForSubject(kms, stored, KMS_CTX);
|
|
282
|
+
expect(read).toBe("DE89370400440532013000");
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
test("erased subject: decrypt yields the sentinel", async () => {
|
|
286
|
+
const kms = new InMemoryKmsAdapter();
|
|
287
|
+
const subject = { kind: "user" as const, userId: UUID_A };
|
|
288
|
+
const stored = await encryptPiiValueForSubject(kms, subject, "+49 151 00000000", KMS_CTX);
|
|
289
|
+
await kms.eraseKey(subject);
|
|
290
|
+
|
|
291
|
+
const read = await decryptPiiValueForSubject(kms, stored, KMS_CTX);
|
|
292
|
+
expect(read).toBe(PII_ERASED_SENTINEL);
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
test("plaintext passes through decrypt unchanged (pre-engine rows)", async () => {
|
|
296
|
+
const kms = new InMemoryKmsAdapter();
|
|
297
|
+
const read = await decryptPiiValueForSubject(kms, "plain-value", KMS_CTX);
|
|
298
|
+
expect(read).toBe("plain-value");
|
|
299
|
+
});
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
describe("cross-subject decrypt leak (kumiko-framework#1190)", () => {
|
|
303
|
+
test("ciphertext forged to a different-but-valid subject's DEK fails GCM auth, not just KeyNotFound", async () => {
|
|
304
|
+
const kms = new InMemoryKmsAdapter();
|
|
305
|
+
const subjectA = { kind: "tenant" as const, tenantId: UUID_A };
|
|
306
|
+
const subjectB = { kind: "tenant" as const, tenantId: UUID_B };
|
|
307
|
+
// Both subjects must have a real key — otherwise this only re-proves the
|
|
308
|
+
// existing "ciphertext without a key row fails loud" (KeyNotFoundError) case.
|
|
309
|
+
await kms.createKey(subjectA);
|
|
310
|
+
|
|
311
|
+
const storedForB = await encryptPiiValueForSubject(kms, subjectB, "tenant-b-secret", KMS_CTX);
|
|
312
|
+
const blob = storedForB.slice(storedForB.lastIndexOf(":") + 1);
|
|
313
|
+
const forgedForA = `${PII_CIPHERTEXT_PREFIX}${subjectIdToKey(subjectA)}:${blob}`;
|
|
314
|
+
|
|
315
|
+
const attempt = decryptPiiValueForSubject(kms, forgedForA, KMS_CTX);
|
|
316
|
+
await expect(attempt).rejects.not.toBeInstanceOf(KeyNotFoundError);
|
|
317
|
+
await expect(attempt).rejects.not.toBeInstanceOf(KeyErasedError);
|
|
318
|
+
await expect(attempt).rejects.toThrow();
|
|
319
|
+
});
|
|
320
|
+
});
|
package/src/crypto/index.ts
CHANGED
|
@@ -99,6 +99,25 @@ export async function encryptPiiValueForSubject(
|
|
|
99
99
|
return encryptValue(subject, dek, value);
|
|
100
100
|
}
|
|
101
101
|
|
|
102
|
+
// Single-value decrypt for callers that don't have an entity/field-map to
|
|
103
|
+
// pass through decryptPiiFieldValues (config values). The subject lives
|
|
104
|
+
// inside the ciphertext itself, so no subject/scope resolution is needed
|
|
105
|
+
// on this side — only the encrypt direction has to pick one.
|
|
106
|
+
export async function decryptPiiValueForSubject(
|
|
107
|
+
kms: LocalKeyKmsAdapter,
|
|
108
|
+
value: string,
|
|
109
|
+
kmsCtx: KmsContext,
|
|
110
|
+
): Promise<string> {
|
|
111
|
+
if (!isPiiCiphertext(value)) return value;
|
|
112
|
+
const { subject, blob } = parseCiphertext(value);
|
|
113
|
+
try {
|
|
114
|
+
return decryptValue(await kms.getKey(subject, kmsCtx), blob);
|
|
115
|
+
} catch (e) {
|
|
116
|
+
if (!(e instanceof KeyErasedError)) throw e;
|
|
117
|
+
return PII_ERASED_SENTINEL;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
102
121
|
export interface EncryptPiiOptions {
|
|
103
122
|
readonly onlyKeys?: Iterable<string>;
|
|
104
123
|
// Write-time tenant for tenantOwned fields on rows without a tenantId column.
|
|
@@ -85,6 +85,7 @@ describe("assert-no-unreachable-live-rows / #722 ghost-row guard", () => {
|
|
|
85
85
|
const result = await rebuildProjection(projectionName, { db: testDb.db, registry });
|
|
86
86
|
|
|
87
87
|
expect(result.eventsProcessed).toBe(2);
|
|
88
|
+
expect(result.columnDriftCount).toBe(0);
|
|
88
89
|
expect(await snapshotTable()).toEqual(before);
|
|
89
90
|
});
|
|
90
91
|
|
|
@@ -131,7 +132,10 @@ describe("assert-no-unreachable-live-rows / #722 ghost-row guard", () => {
|
|
|
131
132
|
);
|
|
132
133
|
|
|
133
134
|
// No throw — the row is event-backed. Rebuild replays the create event.
|
|
134
|
-
|
|
135
|
+
// #916: the direct write IS reported as column drift (non-blocking) —
|
|
136
|
+
// this is the exact #494 healing case the guard leaves to replay.
|
|
137
|
+
const result = await rebuildProjection(projectionName, { db: testDb.db, registry });
|
|
138
|
+
expect(result.columnDriftCount).toBe(1);
|
|
135
139
|
|
|
136
140
|
const [rebuilt] = await selectMany(testDb.db, userTable, { id: a.data.id as string });
|
|
137
141
|
expect(rebuilt?.["email"]).toBe("a@test.de");
|
|
@@ -139,6 +143,28 @@ describe("assert-no-unreachable-live-rows / #722 ghost-row guard", () => {
|
|
|
139
143
|
expect(rebuilt?.["firstName"]).toBe("Alice");
|
|
140
144
|
});
|
|
141
145
|
|
|
146
|
+
test("columnDriftCount reports the TRUE total, not capped at the 20-row sample limit", async () => {
|
|
147
|
+
const crud = createEventStoreExecutor(userTable, userEntity, { entityName: "user" });
|
|
148
|
+
const created = await Promise.all(
|
|
149
|
+
Array.from({ length: 21 }, (_, i) =>
|
|
150
|
+
crud.create({ email: `u${i}@test.de`, firstName: `Name${i}` }, adminUser, tdb),
|
|
151
|
+
),
|
|
152
|
+
);
|
|
153
|
+
for (const c of created) {
|
|
154
|
+
if (!c.isSuccess) throw new Error("setup failed");
|
|
155
|
+
await asRawClient(testDb.db).unsafe(
|
|
156
|
+
`UPDATE read_unreachable_users SET first_name = 'DirectWrite' WHERE id = $1`,
|
|
157
|
+
[c.data.id],
|
|
158
|
+
);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const result = await rebuildProjection(projectionName, { db: testDb.db, registry });
|
|
162
|
+
|
|
163
|
+
// #916: the sample query is LIMITed to 20 rows, but the count must reflect
|
|
164
|
+
// all 21 — a min(actual, 20) would silently understate severity to ops.
|
|
165
|
+
expect(result.columnDriftCount).toBe(21);
|
|
166
|
+
});
|
|
167
|
+
|
|
142
168
|
test("archived stream (fw#832 tombstone) does NOT trip the guard", async () => {
|
|
143
169
|
// archiveStream never deletes the .created event — projection rebuild
|
|
144
170
|
// deliberately excludes archived streams from replay (fw#832, see
|
|
@@ -213,6 +213,22 @@ describe("blind-index rebuild + forget", () => {
|
|
|
213
213
|
expect(await fetchOne(tdb, personTable, { email: "marc@example.com" })).toBeUndefined();
|
|
214
214
|
});
|
|
215
215
|
|
|
216
|
+
test("erased subject → column drift excludes bidx (#916, not counted)", async () => {
|
|
217
|
+
// Pre-swap, live still holds the OLD bidx hash while the shadow already
|
|
218
|
+
// computed NULL for the erased subject — a real live-vs-shadow divergence,
|
|
219
|
+
// but the exact legitimate class countColumnDrift is designed to ignore.
|
|
220
|
+
const created = await crud.create({ email: "marc@example.com" }, adminUser, tdb);
|
|
221
|
+
if (!created.isSuccess) throw new Error("create failed");
|
|
222
|
+
|
|
223
|
+
await kms.eraseKey({ kind: "user", userId: String(created.data.id) });
|
|
224
|
+
|
|
225
|
+
const registry = createRegistry([personFeature]);
|
|
226
|
+
const result = await rebuildProjection(implicitName, { db: testDb.db, registry });
|
|
227
|
+
|
|
228
|
+
expect(result.columnDriftCount).toBe(0);
|
|
229
|
+
expect((await rawRow(String(created.data.id)))["email_bidx"]).toBeNull();
|
|
230
|
+
});
|
|
231
|
+
|
|
216
232
|
test("nullBlindIndexesForSubject nulls bidx immediately (no rebuild needed)", async () => {
|
|
217
233
|
const created = await crud.create({ email: "marc@example.com" }, adminUser, tdb);
|
|
218
234
|
if (!created.isSuccess) throw new Error("create failed");
|
|
@@ -40,13 +40,13 @@ describe("collectTableMetas (#255)", () => {
|
|
|
40
40
|
// side-effect-only MSP — no table, must be skipped without throwing
|
|
41
41
|
r.multiStreamProjection({ name: "unit-notify", apply: { "unit.created": async () => {} } });
|
|
42
42
|
r.rawTable(
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
id: uuid
|
|
46
|
-
})
|
|
43
|
+
defineUnmanagedTable({
|
|
44
|
+
tableName: "unit_cache",
|
|
45
|
+
columns: [{ name: "id", pgType: "uuid", notNull: true, primaryKey: true }],
|
|
46
|
+
}),
|
|
47
47
|
{ reason: "test fixture" },
|
|
48
48
|
);
|
|
49
|
-
r.
|
|
49
|
+
r.rawTable(
|
|
50
50
|
defineUnmanagedTable({
|
|
51
51
|
tableName: "read_unit_log",
|
|
52
52
|
columns: [{ name: "id", pgType: "serial", notNull: true, primaryKey: true }],
|
|
@@ -60,7 +60,7 @@ describe("collectTableMetas (#255)", () => {
|
|
|
60
60
|
expect(names).toContain("read_unit_counters"); // r.projection
|
|
61
61
|
expect(names).toContain("read_unit_audit"); // r.multiStreamProjection
|
|
62
62
|
expect(names).toContain("unit_cache"); // r.rawTable
|
|
63
|
-
expect(names).toContain("read_unit_log"); // r.
|
|
63
|
+
expect(names).toContain("read_unit_log"); // r.rawTable (second registration)
|
|
64
64
|
expect(names).toHaveLength(5);
|
|
65
65
|
});
|
|
66
66
|
|
|
@@ -419,6 +419,81 @@ describe("event-store-executor — encrypted fields", () => {
|
|
|
419
419
|
expect(updated.data.data["secretNote"]).toBe("new-note");
|
|
420
420
|
});
|
|
421
421
|
|
|
422
|
+
test("update WITHOUT skipUnchanged still re-encrypts a resubmitted-but-unchanged encrypted field (KEK-rotation/backfill contract)", async () => {
|
|
423
|
+
// Regression guard: direct executor.update() callers that don't opt into
|
|
424
|
+
// skipUnchanged rely on today's always-re-encrypt behavior to force a
|
|
425
|
+
// fresh ciphertext for an unchanged plaintext — e.g. auth-mfa's
|
|
426
|
+
// KEK-rotation job (reencrypt.job.ts) and the user-data-rights #494
|
|
427
|
+
// backfill both resubmit the CURRENT value on purpose to land a real
|
|
428
|
+
// event. A blanket value-diff here would silently break both.
|
|
429
|
+
const created = await crud.create(
|
|
430
|
+
{ email: "force-enc@test.de", secretNote: "same-note" },
|
|
431
|
+
adminUser,
|
|
432
|
+
tdb,
|
|
433
|
+
);
|
|
434
|
+
if (!created.isSuccess) throw new Error("create failed");
|
|
435
|
+
|
|
436
|
+
const before = (await asRawClient(testDb.db).unsafe(
|
|
437
|
+
`SELECT secret_note FROM read_es_exec_encrypted WHERE email = 'force-enc@test.de' LIMIT 1`,
|
|
438
|
+
)) as Array<{ secret_note: string }>;
|
|
439
|
+
|
|
440
|
+
const updated = await crud.update(
|
|
441
|
+
{ id: created.data.id, version: 1, changes: { secretNote: "same-note" } },
|
|
442
|
+
adminUser,
|
|
443
|
+
tdb,
|
|
444
|
+
);
|
|
445
|
+
if (!updated.isSuccess) throw new Error("update failed");
|
|
446
|
+
|
|
447
|
+
const after = (await asRawClient(testDb.db).unsafe(
|
|
448
|
+
`SELECT secret_note FROM read_es_exec_encrypted WHERE email = 'force-enc@test.de' LIMIT 1`,
|
|
449
|
+
)) as Array<{ secret_note: string }>;
|
|
450
|
+
|
|
451
|
+
// Fresh AEAD nonce per encrypt call → different ciphertext even though
|
|
452
|
+
// the plaintext is identical. Byte-identical would mean the write was
|
|
453
|
+
// skipped, which is exactly what would break KEK rotation.
|
|
454
|
+
expect(after[0]?.secret_note).toBeDefined();
|
|
455
|
+
expect(after[0]?.secret_note).not.toBe(before[0]?.secret_note);
|
|
456
|
+
});
|
|
457
|
+
|
|
458
|
+
test("update with skipUnchanged: true omits a resubmitted-but-unchanged encrypted field — no phantom re-encrypt (#464)", async () => {
|
|
459
|
+
const created = await crud.create(
|
|
460
|
+
{ email: "noop-enc@test.de", secretNote: "same-note" },
|
|
461
|
+
adminUser,
|
|
462
|
+
tdb,
|
|
463
|
+
);
|
|
464
|
+
if (!created.isSuccess) throw new Error("create failed");
|
|
465
|
+
|
|
466
|
+
const before = (await asRawClient(testDb.db).unsafe(
|
|
467
|
+
`SELECT secret_note FROM read_es_exec_encrypted WHERE email = 'noop-enc@test.de' LIMIT 1`,
|
|
468
|
+
)) as Array<{ secret_note: string }>;
|
|
469
|
+
|
|
470
|
+
const updated = await crud.update(
|
|
471
|
+
{
|
|
472
|
+
id: created.data.id,
|
|
473
|
+
version: 1,
|
|
474
|
+
changes: { secretNote: "same-note", email: "noop-enc-2@test.de" },
|
|
475
|
+
},
|
|
476
|
+
adminUser,
|
|
477
|
+
tdb,
|
|
478
|
+
{ skipUnchanged: true },
|
|
479
|
+
);
|
|
480
|
+
if (!updated.isSuccess) throw new Error("update failed");
|
|
481
|
+
|
|
482
|
+
const after = (await asRawClient(testDb.db).unsafe(
|
|
483
|
+
`SELECT secret_note FROM read_es_exec_encrypted WHERE email = 'noop-enc-2@test.de' LIMIT 1`,
|
|
484
|
+
)) as Array<{ secret_note: string }>;
|
|
485
|
+
|
|
486
|
+
// Re-encrypting the same plaintext produces a fresh AEAD nonce → a
|
|
487
|
+
// different ciphertext even for an unchanged value. Byte-identical
|
|
488
|
+
// ciphertext proves the column was never touched by this update.
|
|
489
|
+
expect(after[0]?.secret_note).toBe(before[0]?.secret_note);
|
|
490
|
+
|
|
491
|
+
const rows = (await asRawClient(testDb.db).unsafe(
|
|
492
|
+
`SELECT payload FROM kumiko_events WHERE type = 'esExecEncrypted.updated' ORDER BY id DESC LIMIT 1`,
|
|
493
|
+
)) as Array<{ payload: { changes?: Record<string, unknown> } }>;
|
|
494
|
+
expect(rows[0]?.payload.changes).not.toHaveProperty("secretNote");
|
|
495
|
+
});
|
|
496
|
+
|
|
422
497
|
test("update's persisted event carries ciphertext (not plaintext) for an encrypted field in `previous`", async () => {
|
|
423
498
|
// Regression: `previous` in the STORED event came from loadById(), which
|
|
424
499
|
// decrypts — appending it unchanged would put the plaintext of an
|
|
@@ -1,9 +1,9 @@
|
|
|
1
|
-
// Regression test for #255-class drift:
|
|
1
|
+
// Regression test for #255-class drift: rawTables must appear in
|
|
2
2
|
// enumerateFeatureTableSources so setupTestStack's auto-push and
|
|
3
3
|
// collectTableMetas see the exact same table set. Before this fix,
|
|
4
|
-
//
|
|
4
|
+
// unmanaged tables were only handled by collectTableMetas directly, so any
|
|
5
5
|
// app relying on setupTestStack's ephemeral DB (Playwright/e2e) never got
|
|
6
|
-
// its
|
|
6
|
+
// its raw tables created — e.g. the bundled "sessions" feature's
|
|
7
7
|
// read_user_sessions, breaking every login in an ephemeral test stack.
|
|
8
8
|
|
|
9
9
|
import { describe, expect, test } from "bun:test";
|
|
@@ -16,10 +16,10 @@ const probeMeta = defineUnmanagedTable({
|
|
|
16
16
|
columns: [{ name: "id", pgType: "text", notNull: true, primaryKey: true }],
|
|
17
17
|
});
|
|
18
18
|
|
|
19
|
-
describe("enumerateFeatureTableSources —
|
|
20
|
-
test("includes a feature's
|
|
19
|
+
describe("enumerateFeatureTableSources — rawTables", () => {
|
|
20
|
+
test("includes a feature's rawTable as a table source", () => {
|
|
21
21
|
const feature = defineFeature("probe", (r) => {
|
|
22
|
-
r.
|
|
22
|
+
r.rawTable(probeMeta, { reason: "direct-write store" });
|
|
23
23
|
});
|
|
24
24
|
const sources = enumerateFeatureTableSources(feature);
|
|
25
25
|
const entry = sources.find((s) => s.origin.includes("ftst_probe"));
|
|
@@ -27,7 +27,7 @@ describe("enumerateFeatureTableSources — unmanagedTables", () => {
|
|
|
27
27
|
expect(entry?.table).toBe(probeMeta);
|
|
28
28
|
});
|
|
29
29
|
|
|
30
|
-
test("a feature with no
|
|
30
|
+
test("a feature with no rawTable contributes nothing extra", () => {
|
|
31
31
|
const feature = defineFeature("plain", () => {});
|
|
32
32
|
expect(enumerateFeatureTableSources(feature)).toEqual([]);
|
|
33
33
|
});
|