@cosmicdrift/kumiko-framework 0.254.0 → 0.255.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/bun-db/__tests__/run-in-savepoint-if-supported.integration.test.ts +47 -0
- package/src/bun-db/query.ts +16 -10
- package/src/crypto/__tests__/event-pii.test.ts +144 -11
- package/src/crypto/__tests__/subject-key-record.test.ts +10 -0
- package/src/crypto/__tests__/subject-resolver.test.ts +89 -0
- package/src/crypto/event-pii.ts +16 -9
- package/src/crypto/index.ts +2 -0
- package/src/crypto/subject-resolver.ts +62 -1
- package/src/db/queries/backfill-pii.ts +10 -6
- package/src/engine/__tests__/projection-detail-tabs.test.ts +33 -2
- package/src/engine/boot-validator/query-output-columns.ts +4 -2
- package/src/engine/boot-validator/screens.ts +7 -4
- package/src/engine/feature-config-events-jobs.ts +18 -5
- package/src/engine/index.ts +2 -0
- package/src/engine/types/index.ts +3 -0
- package/src/engine/types/screen.ts +1 -0
- package/src/event-store/__tests__/backfill-pii.integration.test.ts +133 -7
- package/src/event-store/event-store.ts +5 -1
- package/src/pipeline/__tests__/ctx-bridge.integration.test.ts +79 -0
- package/src/pipeline/dispatch-write.ts +28 -10
- package/src/ui-types/index.ts +3 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-framework",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.255.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.255.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.255.0",
|
|
218
218
|
"bun-types": "^1.3.13",
|
|
219
219
|
"pino-pretty": "^13.1.3"
|
|
220
220
|
},
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
// dead-tx-in-afterCommit-hooks: runInSavepointIfSupported must fail
|
|
2
|
+
// loudly on a transaction handle that has already committed, instead of
|
|
3
|
+
// silently re-running `fn` outside the savepoint's isolation. Uses a real
|
|
4
|
+
// Postgres connection — a mock handle can't produce the driver's actual
|
|
5
|
+
// PG 25P01 ("no active sql transaction") on a post-commit savepoint call.
|
|
6
|
+
|
|
7
|
+
import { afterAll, describe, expect, test } from "bun:test";
|
|
8
|
+
import { InternalError } from "../../errors";
|
|
9
|
+
import { asRawClient, runInSavepointIfSupported } from "../query";
|
|
10
|
+
import { closeDb, getDb } from "./_helpers";
|
|
11
|
+
|
|
12
|
+
afterAll(async () => {
|
|
13
|
+
await closeDb();
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
describe("runInSavepointIfSupported — already-committed transaction", () => {
|
|
17
|
+
test("throws InternalError naming afterCommit + dbOutsideTransaction, never runs fn", async () => {
|
|
18
|
+
const db = await getDb();
|
|
19
|
+
const raw = asRawClient(db);
|
|
20
|
+
|
|
21
|
+
let committedTx: unknown;
|
|
22
|
+
await raw.begin(async (tx) => {
|
|
23
|
+
committedTx = tx;
|
|
24
|
+
});
|
|
25
|
+
// `committedTx` is now a driver handle whose transaction has committed —
|
|
26
|
+
// it still exposes `.savepoint()`, calling it fails with PG 25P01.
|
|
27
|
+
|
|
28
|
+
let fnCalls = 0;
|
|
29
|
+
const fn = async () => {
|
|
30
|
+
fnCalls++;
|
|
31
|
+
return "should-not-run";
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
let caught: unknown;
|
|
35
|
+
try {
|
|
36
|
+
await runInSavepointIfSupported(committedTx, fn);
|
|
37
|
+
} catch (e) {
|
|
38
|
+
caught = e;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
expect(caught).toBeInstanceOf(InternalError);
|
|
42
|
+
const message = (caught as Error).message;
|
|
43
|
+
expect(message).toContain("afterCommit");
|
|
44
|
+
expect(message).toContain("dbOutsideTransaction");
|
|
45
|
+
expect(fnCalls).toBe(0);
|
|
46
|
+
});
|
|
47
|
+
});
|
package/src/bun-db/query.ts
CHANGED
|
@@ -34,6 +34,7 @@ import type { EntityTableMeta } from "../db/entity-table-meta";
|
|
|
34
34
|
import { extractPgError } from "../db/pg-error";
|
|
35
35
|
import { type NotExecutorOnly, toSnakeCase } from "../db/table-builder";
|
|
36
36
|
import { camelCase as envCamelCase } from "../env";
|
|
37
|
+
import { InternalError } from "../errors";
|
|
37
38
|
import { parseJsonSafe } from "../utils/safe-json";
|
|
38
39
|
|
|
39
40
|
// Idempotent snake_case → camelCase. `env.camelCase` always lowercases first
|
|
@@ -145,15 +146,11 @@ export async function runInSavepoint<T>(tx: unknown, fn: (sp: unknown) => Promis
|
|
|
145
146
|
// and doesn't need one, since each statement there is its own auto-committed
|
|
146
147
|
// unit and a failed statement can't poison anything downstream.
|
|
147
148
|
//
|
|
148
|
-
// `.savepoint` being present isn't proof the transaction is still open:
|
|
149
|
-
//
|
|
150
|
-
//
|
|
151
|
-
//
|
|
152
|
-
//
|
|
153
|
-
// active sql transaction") because there's no BEGIN left to nest into. That
|
|
154
|
-
// SAVEPOINT command is the first thing the driver sends, before `fn` runs,
|
|
155
|
-
// so catching 25P01 and retrying directly is safe — nothing from `fn` has
|
|
156
|
-
// executed yet.
|
|
149
|
+
// `.savepoint` being present isn't proof the transaction is still open: a
|
|
150
|
+
// committed TransactionSql-shaped db still exposes `.savepoint`, and calling
|
|
151
|
+
// it fails with PG 25P01 ("no active sql transaction") since there's no
|
|
152
|
+
// BEGIN left to nest into. That is now a programming error, not something
|
|
153
|
+
// to paper over by re-running `fn` without the savepoint's isolation.
|
|
157
154
|
export async function runInSavepointIfSupported<T>(
|
|
158
155
|
db: unknown,
|
|
159
156
|
fn: (sp: DbRunner) => Promise<T>,
|
|
@@ -167,7 +164,16 @@ export async function runInSavepointIfSupported<T>(
|
|
|
167
164
|
try {
|
|
168
165
|
return await raw.savepoint((sp) => fn(asRunner(sp)));
|
|
169
166
|
} catch (e) {
|
|
170
|
-
if (extractPgError(e)?.code === "25P01")
|
|
167
|
+
if (extractPgError(e)?.code === "25P01") {
|
|
168
|
+
throw new InternalError({
|
|
169
|
+
message:
|
|
170
|
+
"runInSavepointIfSupported: savepoint on a transaction that has already committed — " +
|
|
171
|
+
"this is almost always database work in an afterCommit hook. afterCommit is the default " +
|
|
172
|
+
"phase of r.hook/postSave/postDelete. Use HookPhases.inTransaction for writes that must " +
|
|
173
|
+
"roll back with the trigger, or ctx.dbOutsideTransaction for work that must survive a rollback.",
|
|
174
|
+
cause: e instanceof Error ? e : undefined,
|
|
175
|
+
});
|
|
176
|
+
}
|
|
171
177
|
throw e;
|
|
172
178
|
}
|
|
173
179
|
}
|
|
@@ -4,8 +4,10 @@
|
|
|
4
4
|
// are testable without a database.
|
|
5
5
|
|
|
6
6
|
import { afterEach, describe, expect, test } from "bun:test";
|
|
7
|
+
import { normalizeEventPiiSubject } from "@cosmicdrift/kumiko-types/handlers";
|
|
7
8
|
import { z } from "zod";
|
|
8
9
|
import { createRegistry, defineFeature } from "../../engine";
|
|
10
|
+
import type { TenantId } from "../../engine/types/identifiers";
|
|
9
11
|
import {
|
|
10
12
|
configuredEventPiiCatalog,
|
|
11
13
|
configureEventPiiCatalog,
|
|
@@ -20,6 +22,7 @@ import {
|
|
|
20
22
|
PII_ERASED_SENTINEL,
|
|
21
23
|
resetPiiSubjectKmsForTests,
|
|
22
24
|
} from "../pii-field-encryption";
|
|
25
|
+
import type { EventSubjectEnvelope } from "../subject-resolver";
|
|
23
26
|
|
|
24
27
|
const attemptSchema = z.object({
|
|
25
28
|
recipientId: z.string().nullable(),
|
|
@@ -29,6 +32,12 @@ const attemptSchema = z.object({
|
|
|
29
32
|
|
|
30
33
|
const EVENT_TYPE = "mailer:event:attempt";
|
|
31
34
|
|
|
35
|
+
const ENVELOPE: EventSubjectEnvelope = {
|
|
36
|
+
tenantId: "6b2f4a0e-1c9d-4f3a-9d2e-0000000000e1" as TenantId,
|
|
37
|
+
aggregateType: "mailer-attempt",
|
|
38
|
+
aggregateId: "6b2f4a0e-1c9d-4f3a-9d2e-0000000000e2",
|
|
39
|
+
};
|
|
40
|
+
|
|
32
41
|
function catalogWithAttempt(): void {
|
|
33
42
|
configureEventPiiCatalog(
|
|
34
43
|
new Map([[EVENT_TYPE, { recipientAddress: { subjectField: "recipientId" } }]]),
|
|
@@ -40,6 +49,19 @@ afterEach(() => {
|
|
|
40
49
|
resetPiiSubjectKmsForTests();
|
|
41
50
|
});
|
|
42
51
|
|
|
52
|
+
describe("normalizeEventPiiSubject", () => {
|
|
53
|
+
test("canonical personal.of and legacy subjectField normalize to the same form", () => {
|
|
54
|
+
expect(normalizeEventPiiSubject({ personal: { of: "recipientId" } })).toEqual({
|
|
55
|
+
kind: "user",
|
|
56
|
+
ownerField: "recipientId",
|
|
57
|
+
});
|
|
58
|
+
expect(normalizeEventPiiSubject({ subjectField: "recipientId" })).toEqual({
|
|
59
|
+
kind: "user",
|
|
60
|
+
ownerField: "recipientId",
|
|
61
|
+
});
|
|
62
|
+
});
|
|
63
|
+
});
|
|
64
|
+
|
|
43
65
|
describe("defineEvent piiFields validation", () => {
|
|
44
66
|
test("valid piiFields land on the EventDef and in the registry catalog", () => {
|
|
45
67
|
const feature = defineFeature("mailer", (r) => {
|
|
@@ -73,14 +95,46 @@ describe("defineEvent piiFields validation", () => {
|
|
|
73
95
|
).toThrow(/piiFields references "ownerId"/);
|
|
74
96
|
});
|
|
75
97
|
|
|
76
|
-
test("field cannot be its own subjectField", () => {
|
|
98
|
+
test("field cannot be its own subjectField (legacy form)", () => {
|
|
77
99
|
expect(() =>
|
|
78
100
|
defineFeature("mailer", (r) => {
|
|
79
101
|
r.defineEvent("attempt", attemptSchema, {
|
|
80
102
|
piiFields: { recipientAddress: { subjectField: "recipientAddress" } },
|
|
81
103
|
});
|
|
82
104
|
}),
|
|
83
|
-
).toThrow(/cannot use itself as
|
|
105
|
+
).toThrow(/cannot use itself as the owner field/);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
test("valid canonical personal.of piiFields land on the EventDef and in the registry catalog", () => {
|
|
109
|
+
const feature = defineFeature("mailer", (r) => {
|
|
110
|
+
r.defineEvent("attempt", attemptSchema, {
|
|
111
|
+
piiFields: { recipientAddress: { personal: { of: "recipientId" } } },
|
|
112
|
+
});
|
|
113
|
+
});
|
|
114
|
+
createRegistry([feature]);
|
|
115
|
+
expect(configuredEventPiiCatalog().get(EVENT_TYPE)).toEqual({
|
|
116
|
+
recipientAddress: { personal: { of: "recipientId" } },
|
|
117
|
+
});
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
test("unknown owner field throws at definition time (canonical form)", () => {
|
|
121
|
+
expect(() =>
|
|
122
|
+
defineFeature("mailer", (r) => {
|
|
123
|
+
r.defineEvent("attempt", attemptSchema, {
|
|
124
|
+
piiFields: { recipientAddress: { personal: { of: "ownerId" } } },
|
|
125
|
+
});
|
|
126
|
+
}),
|
|
127
|
+
).toThrow(/piiFields references "ownerId"/);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
test("field cannot be its own owner field (canonical form)", () => {
|
|
131
|
+
expect(() =>
|
|
132
|
+
defineFeature("mailer", (r) => {
|
|
133
|
+
r.defineEvent("attempt", attemptSchema, {
|
|
134
|
+
piiFields: { recipientAddress: { personal: { of: "recipientAddress" } } },
|
|
135
|
+
});
|
|
136
|
+
}),
|
|
137
|
+
).toThrow(/cannot use itself as the owner field/);
|
|
84
138
|
});
|
|
85
139
|
|
|
86
140
|
test("omitting the options argument throws an explicit PII stance error (fw#2558)", () => {
|
|
@@ -112,6 +166,46 @@ describe("defineEvent piiFields validation", () => {
|
|
|
112
166
|
createRegistry([feature]);
|
|
113
167
|
expect(configuredEventPiiCatalog().has(EVENT_TYPE)).toBe(false);
|
|
114
168
|
});
|
|
169
|
+
|
|
170
|
+
test('personal: "tenant" needs only the pii field itself on the schema, no owner field', () => {
|
|
171
|
+
const feature = defineFeature("mailer", (r) => {
|
|
172
|
+
r.defineEvent("attempt", attemptSchema, {
|
|
173
|
+
piiFields: { recipientAddress: { personal: "tenant" } },
|
|
174
|
+
});
|
|
175
|
+
});
|
|
176
|
+
createRegistry([feature]);
|
|
177
|
+
expect(configuredEventPiiCatalog().get(EVENT_TYPE)).toEqual({
|
|
178
|
+
recipientAddress: { personal: "tenant" },
|
|
179
|
+
});
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
test('personal: "self" needs only the pii field itself on the schema, no owner field', () => {
|
|
183
|
+
const feature = defineFeature("mailer", (r) => {
|
|
184
|
+
r.defineEvent("attempt", attemptSchema, {
|
|
185
|
+
piiFields: { recipientAddress: { personal: "self" } },
|
|
186
|
+
});
|
|
187
|
+
});
|
|
188
|
+
createRegistry([feature]);
|
|
189
|
+
expect(configuredEventPiiCatalog().get(EVENT_TYPE)).toEqual({
|
|
190
|
+
recipientAddress: { personal: "self" },
|
|
191
|
+
});
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
test('personal: "tenant" on a field not in the schema throws', () => {
|
|
195
|
+
expect(() =>
|
|
196
|
+
defineFeature("mailer", (r) => {
|
|
197
|
+
r.defineEvent("attempt", attemptSchema, { piiFields: { nope: { personal: "tenant" } } });
|
|
198
|
+
}),
|
|
199
|
+
).toThrow(/piiFields references "nope"/);
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
test('personal: "self" on a field not in the schema throws', () => {
|
|
203
|
+
expect(() =>
|
|
204
|
+
defineFeature("mailer", (r) => {
|
|
205
|
+
r.defineEvent("attempt", attemptSchema, { piiFields: { nope: { personal: "self" } } });
|
|
206
|
+
}),
|
|
207
|
+
).toThrow(/piiFields references "nope"/);
|
|
208
|
+
});
|
|
115
209
|
});
|
|
116
210
|
|
|
117
211
|
describe("encryptEventPayloadPii", () => {
|
|
@@ -119,12 +213,12 @@ describe("encryptEventPayloadPii", () => {
|
|
|
119
213
|
|
|
120
214
|
test("uncatalogued event type returns the payload untouched (same reference)", async () => {
|
|
121
215
|
configurePiiSubjectKms(new InMemoryKmsAdapter());
|
|
122
|
-
expect(await encryptEventPayloadPii("other:event:x", payload)).toBe(payload);
|
|
216
|
+
expect(await encryptEventPayloadPii("other:event:x", payload, ENVELOPE)).toBe(payload);
|
|
123
217
|
});
|
|
124
218
|
|
|
125
219
|
test("no KMS configured → plaintext passthrough (rollout mode)", async () => {
|
|
126
220
|
catalogWithAttempt();
|
|
127
|
-
expect(await encryptEventPayloadPii(EVENT_TYPE, payload)).toBe(payload);
|
|
221
|
+
expect(await encryptEventPayloadPii(EVENT_TYPE, payload, ENVELOPE)).toBe(payload);
|
|
128
222
|
});
|
|
129
223
|
|
|
130
224
|
test("encrypts under the subject's DEK; subject fk stays plaintext", async () => {
|
|
@@ -132,7 +226,7 @@ describe("encryptEventPayloadPii", () => {
|
|
|
132
226
|
const kms = new InMemoryKmsAdapter();
|
|
133
227
|
configurePiiSubjectKms(kms);
|
|
134
228
|
|
|
135
|
-
const out = await encryptEventPayloadPii(EVENT_TYPE, payload);
|
|
229
|
+
const out = await encryptEventPayloadPii(EVENT_TYPE, payload, ENVELOPE);
|
|
136
230
|
expect(isPiiCiphertext(out["recipientAddress"])).toBe(true);
|
|
137
231
|
expect(String(out["recipientAddress"])).toContain("user:u-1");
|
|
138
232
|
expect(out["recipientId"]).toBe("u-1");
|
|
@@ -144,6 +238,23 @@ describe("encryptEventPayloadPii", () => {
|
|
|
144
238
|
expect(back["recipientAddress"]).toBe("u1@example.com");
|
|
145
239
|
});
|
|
146
240
|
|
|
241
|
+
test("legacy subjectField and canonical personal.of encrypt under the same subject key", async () => {
|
|
242
|
+
const LEGACY_TYPE = "mailer:event:legacy-attempt";
|
|
243
|
+
const CANONICAL_TYPE = "mailer:event:canonical-attempt";
|
|
244
|
+
configureEventPiiCatalog(
|
|
245
|
+
new Map([
|
|
246
|
+
[LEGACY_TYPE, { recipientAddress: { subjectField: "recipientId" } }],
|
|
247
|
+
[CANONICAL_TYPE, { recipientAddress: { personal: { of: "recipientId" } } }],
|
|
248
|
+
]),
|
|
249
|
+
);
|
|
250
|
+
configurePiiSubjectKms(new InMemoryKmsAdapter());
|
|
251
|
+
|
|
252
|
+
const legacy = await encryptEventPayloadPii(LEGACY_TYPE, payload, ENVELOPE);
|
|
253
|
+
const canonical = await encryptEventPayloadPii(CANONICAL_TYPE, payload, ENVELOPE);
|
|
254
|
+
expect(String(legacy["recipientAddress"])).toContain("user:u-1");
|
|
255
|
+
expect(String(canonical["recipientAddress"])).toContain("user:u-1");
|
|
256
|
+
});
|
|
257
|
+
|
|
147
258
|
test("null subject field → value stays plaintext (no user key to shred)", async () => {
|
|
148
259
|
catalogWithAttempt();
|
|
149
260
|
configurePiiSubjectKms(new InMemoryKmsAdapter());
|
|
@@ -152,25 +263,25 @@ describe("encryptEventPayloadPii", () => {
|
|
|
152
263
|
recipientAddress: "ops@example.com",
|
|
153
264
|
status: "sent",
|
|
154
265
|
};
|
|
155
|
-
expect(await encryptEventPayloadPii(EVENT_TYPE, systemPayload)).toBe(systemPayload);
|
|
266
|
+
expect(await encryptEventPayloadPii(EVENT_TYPE, systemPayload, ENVELOPE)).toBe(systemPayload);
|
|
156
267
|
});
|
|
157
268
|
|
|
158
269
|
test("null pii value passes through", async () => {
|
|
159
270
|
catalogWithAttempt();
|
|
160
271
|
configurePiiSubjectKms(new InMemoryKmsAdapter());
|
|
161
272
|
const skipped = { recipientId: "u-1", recipientAddress: null, status: "skipped" };
|
|
162
|
-
expect(await encryptEventPayloadPii(EVENT_TYPE, skipped)).toBe(skipped);
|
|
273
|
+
expect(await encryptEventPayloadPii(EVENT_TYPE, skipped, ENVELOPE)).toBe(skipped);
|
|
163
274
|
});
|
|
164
275
|
|
|
165
276
|
test("idempotent: ciphertext and erased sentinel stay as-is", async () => {
|
|
166
277
|
catalogWithAttempt();
|
|
167
278
|
configurePiiSubjectKms(new InMemoryKmsAdapter());
|
|
168
|
-
const once = await encryptEventPayloadPii(EVENT_TYPE, payload);
|
|
169
|
-
const twice = await encryptEventPayloadPii(EVENT_TYPE, once);
|
|
279
|
+
const once = await encryptEventPayloadPii(EVENT_TYPE, payload, ENVELOPE);
|
|
280
|
+
const twice = await encryptEventPayloadPii(EVENT_TYPE, once, ENVELOPE);
|
|
170
281
|
expect(twice["recipientAddress"]).toBe(once["recipientAddress"]);
|
|
171
282
|
|
|
172
283
|
const erased = { ...payload, recipientAddress: PII_ERASED_SENTINEL };
|
|
173
|
-
const out = await encryptEventPayloadPii(EVENT_TYPE, erased);
|
|
284
|
+
const out = await encryptEventPayloadPii(EVENT_TYPE, erased, ENVELOPE);
|
|
174
285
|
expect(out["recipientAddress"]).toBe(PII_ERASED_SENTINEL);
|
|
175
286
|
});
|
|
176
287
|
|
|
@@ -178,6 +289,28 @@ describe("encryptEventPayloadPii", () => {
|
|
|
178
289
|
catalogWithAttempt();
|
|
179
290
|
configurePiiSubjectKms(new InMemoryKmsAdapter());
|
|
180
291
|
const broken = { recipientId: "u-1", recipientAddress: 42, status: "sent" };
|
|
181
|
-
expect(encryptEventPayloadPii(EVENT_TYPE, broken)).rejects.toThrow(
|
|
292
|
+
expect(encryptEventPayloadPii(EVENT_TYPE, broken, ENVELOPE)).rejects.toThrow(
|
|
293
|
+
/must be a string/,
|
|
294
|
+
);
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
test('personal: "tenant" encrypts under the envelope tenantId, not a payload field', async () => {
|
|
298
|
+
configureEventPiiCatalog(new Map([[EVENT_TYPE, { recipientAddress: { personal: "tenant" } }]]));
|
|
299
|
+
configurePiiSubjectKms(new InMemoryKmsAdapter());
|
|
300
|
+
|
|
301
|
+
const out = await encryptEventPayloadPii(EVENT_TYPE, payload, ENVELOPE);
|
|
302
|
+
expect(isPiiCiphertext(out["recipientAddress"])).toBe(true);
|
|
303
|
+
expect(String(out["recipientAddress"])).toContain(`tenant:${ENVELOPE.tenantId}`);
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
test('personal: "self" encrypts under the envelope aggregateType:aggregateId', async () => {
|
|
307
|
+
configureEventPiiCatalog(new Map([[EVENT_TYPE, { recipientAddress: { personal: "self" } }]]));
|
|
308
|
+
configurePiiSubjectKms(new InMemoryKmsAdapter());
|
|
309
|
+
|
|
310
|
+
const out = await encryptEventPayloadPii(EVENT_TYPE, payload, ENVELOPE);
|
|
311
|
+
expect(isPiiCiphertext(out["recipientAddress"])).toBe(true);
|
|
312
|
+
expect(String(out["recipientAddress"])).toContain(
|
|
313
|
+
`record:${ENVELOPE.aggregateType}:${ENVELOPE.aggregateId}`,
|
|
314
|
+
);
|
|
182
315
|
});
|
|
183
316
|
});
|
|
@@ -56,6 +56,16 @@ describe("record subject key round-trip", () => {
|
|
|
56
56
|
expect(() => subjectKeyForRecord("bad:name", UUID_A)).toThrow();
|
|
57
57
|
});
|
|
58
58
|
|
|
59
|
+
test("subjectKeyForRecord rejects entity names that subjectIdSchema (forget-subject) could never shred (fw#2801)", () => {
|
|
60
|
+
expect(() => subjectKeyForRecord("v2.digest", UUID_A)).toThrow();
|
|
61
|
+
expect(() => subjectKeyForRecord("_internal", UUID_A)).toThrow();
|
|
62
|
+
expect(() => subjectKeyForRecord("3rd", UUID_A)).toThrow();
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test("subjectKeyForRecord accepts a kebab-case registry entity name", () => {
|
|
66
|
+
expect(subjectKeyForRecord("mail-account", UUID_A)).toBe(`record:mail-account:${UUID_A}`);
|
|
67
|
+
});
|
|
68
|
+
|
|
59
69
|
test("ciphertext round-trip for a record-owned field", async () => {
|
|
60
70
|
const kms = new InMemoryKmsAdapter();
|
|
61
71
|
const fields = collectPiiSubjectFields(recordEntity);
|
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import { describe, expect, test } from "bun:test";
|
|
2
2
|
import { createEntity, createTextField } from "../../engine/factories";
|
|
3
|
+
import type { TenantId } from "../../engine/types/identifiers";
|
|
3
4
|
import {
|
|
4
5
|
collectPiiSubjectFields,
|
|
6
|
+
resolveEventSubject,
|
|
5
7
|
resolveSubjectForField,
|
|
6
8
|
SubjectResolutionError,
|
|
7
9
|
} from "../subject-resolver";
|
|
@@ -138,3 +140,90 @@ describe("collectPiiSubjectFields", () => {
|
|
|
138
140
|
expect(collectPiiSubjectFields(brandingEntity)).toEqual(["brandColor"]);
|
|
139
141
|
});
|
|
140
142
|
});
|
|
143
|
+
|
|
144
|
+
const EVENT_TENANT = "6b2f4a0e-1c9d-4f3a-9d2e-0000000000e1" as TenantId;
|
|
145
|
+
|
|
146
|
+
describe("resolveEventSubject (fw#2801)", () => {
|
|
147
|
+
test("personal: { of } → user subject from the named payload field", () => {
|
|
148
|
+
const subject = resolveEventSubject(
|
|
149
|
+
"note",
|
|
150
|
+
{ personal: { of: "authorId" } },
|
|
151
|
+
{ authorId: UUID_A },
|
|
152
|
+
{ tenantId: EVENT_TENANT, aggregateType: "note", aggregateId: UUID_B },
|
|
153
|
+
);
|
|
154
|
+
expect(subject).toEqual({ kind: "user", userId: UUID_A });
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
test("personal: { of } with a missing owner value → null, no throw (system-triggered event)", () => {
|
|
158
|
+
const subject = resolveEventSubject(
|
|
159
|
+
"note",
|
|
160
|
+
{ personal: { of: "authorId" } },
|
|
161
|
+
{ authorId: null },
|
|
162
|
+
{ tenantId: EVENT_TENANT, aggregateType: "note", aggregateId: UUID_B },
|
|
163
|
+
);
|
|
164
|
+
expect(subject).toBeNull();
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
test('personal: "tenant" → subject from the envelope tenantId', () => {
|
|
168
|
+
const subject = resolveEventSubject(
|
|
169
|
+
"note",
|
|
170
|
+
{ personal: "tenant" },
|
|
171
|
+
{},
|
|
172
|
+
{ tenantId: EVENT_TENANT, aggregateType: "note", aggregateId: UUID_B },
|
|
173
|
+
);
|
|
174
|
+
expect(subject).toEqual({ kind: "tenant", tenantId: EVENT_TENANT });
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
test('personal: "self" → record subject from the envelope aggregateType/aggregateId', () => {
|
|
178
|
+
const subject = resolveEventSubject(
|
|
179
|
+
"note",
|
|
180
|
+
{ personal: "self" },
|
|
181
|
+
{},
|
|
182
|
+
{ tenantId: EVENT_TENANT, aggregateType: "note", aggregateId: UUID_B },
|
|
183
|
+
);
|
|
184
|
+
expect(subject).toEqual({ kind: "record", entity: "note", id: UUID_B });
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
test('personal: "tenant" with an empty envelope tenantId throws and names the pii field, not "tenant"', () => {
|
|
188
|
+
expect(() =>
|
|
189
|
+
resolveEventSubject(
|
|
190
|
+
"note",
|
|
191
|
+
{ personal: "tenant" },
|
|
192
|
+
{},
|
|
193
|
+
{ tenantId: "" as TenantId, aggregateType: "note", aggregateId: UUID_B },
|
|
194
|
+
),
|
|
195
|
+
).toThrow(/field "note"/);
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
test('personal: "self" with an empty envelope aggregateId throws and names the pii field, not "self"', () => {
|
|
199
|
+
expect(() =>
|
|
200
|
+
resolveEventSubject(
|
|
201
|
+
"note",
|
|
202
|
+
{ personal: "self" },
|
|
203
|
+
{},
|
|
204
|
+
{ tenantId: EVENT_TENANT, aggregateType: "note", aggregateId: "" },
|
|
205
|
+
),
|
|
206
|
+
).toThrow(/field "note"/);
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
test('personal: "self" with an aggregateType that violates the record-entity pattern throws — mint must never outrun shred (fw#2801)', () => {
|
|
210
|
+
expect(() =>
|
|
211
|
+
resolveEventSubject(
|
|
212
|
+
"note",
|
|
213
|
+
{ personal: "self" },
|
|
214
|
+
{},
|
|
215
|
+
{ tenantId: EVENT_TENANT, aggregateType: "v2.digest", aggregateId: UUID_B },
|
|
216
|
+
),
|
|
217
|
+
).toThrow(/field "note".*v2\.digest/s);
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
test("legacy subjectField normalizes to the same user subject as personal: { of }", () => {
|
|
221
|
+
const subject = resolveEventSubject(
|
|
222
|
+
"note",
|
|
223
|
+
{ subjectField: "authorId" },
|
|
224
|
+
{ authorId: UUID_A },
|
|
225
|
+
{ tenantId: EVENT_TENANT, aggregateType: "note", aggregateId: UUID_B },
|
|
226
|
+
);
|
|
227
|
+
expect(subject).toEqual({ kind: "user", userId: UUID_A });
|
|
228
|
+
});
|
|
229
|
+
});
|
package/src/crypto/event-pii.ts
CHANGED
|
@@ -12,9 +12,10 @@
|
|
|
12
12
|
// silent no-op (forgetting the option) is now either an explicit "none" or a
|
|
13
13
|
// missing subject KMS at encrypt-time, both visible states, not a gap.
|
|
14
14
|
|
|
15
|
+
import type { EventPiiFields } from "@cosmicdrift/kumiko-types/handlers";
|
|
15
16
|
import { requestContext } from "../api/request-context";
|
|
16
|
-
import type { EventPiiFields } from "../engine/types/handlers";
|
|
17
17
|
import { configuredPiiSubjectKms, encryptPiiValueForSubject } from "./pii-field-encryption";
|
|
18
|
+
import { type EventSubjectEnvelope, resolveEventSubject } from "./subject-resolver";
|
|
18
19
|
|
|
19
20
|
export type EventPiiCatalog = ReadonlyMap<string, EventPiiFields>;
|
|
20
21
|
|
|
@@ -35,14 +36,20 @@ export function resetEventPiiCatalogForTests(): void {
|
|
|
35
36
|
catalog = new Map();
|
|
36
37
|
}
|
|
37
38
|
|
|
38
|
-
// Encrypts catalogued payload fields under the
|
|
39
|
-
//
|
|
40
|
-
//
|
|
41
|
-
//
|
|
42
|
-
//
|
|
39
|
+
// Encrypts catalogued payload fields under the declared subject's DEK
|
|
40
|
+
// (user/tenant/self — resolveEventSubject, fw#2801). No-op when the event
|
|
41
|
+
// type is uncatalogued or no subject KMS is configured (plaintext rollout
|
|
42
|
+
// mode — the hard boot gate governs whether that is acceptable). A
|
|
43
|
+
// user-subject field with no resolvable owner (system cron runs,
|
|
44
|
+
// recipient-less skip attempts) leaves the value plaintext: there is no
|
|
45
|
+
// user key to shred. This is the ONLY live-write encrypt path — the backfill
|
|
46
|
+
// catalog branch (`backfill-pii.ts`) resolves through the same
|
|
47
|
+
// resolveEventSubject so a field never ends up encrypted under different
|
|
48
|
+
// subjects depending on which path wrote it.
|
|
43
49
|
export async function encryptEventPayloadPii(
|
|
44
50
|
eventType: string,
|
|
45
51
|
payload: Record<string, unknown>,
|
|
52
|
+
envelope: EventSubjectEnvelope,
|
|
46
53
|
): Promise<Record<string, unknown>> {
|
|
47
54
|
const piiFields = catalog.get(eventType);
|
|
48
55
|
if (!piiFields) return payload;
|
|
@@ -58,11 +65,11 @@ export async function encryptEventPayloadPii(
|
|
|
58
65
|
`Event "${eventType}" piiFields."${field}" must be a string payload field, got ${typeof value}`,
|
|
59
66
|
);
|
|
60
67
|
}
|
|
61
|
-
const
|
|
62
|
-
if (
|
|
68
|
+
const subject = resolveEventSubject(field, spec, payload, envelope);
|
|
69
|
+
if (subject === null) continue;
|
|
63
70
|
const encrypted = await encryptPiiValueForSubject(
|
|
64
71
|
kms,
|
|
65
|
-
|
|
72
|
+
subject,
|
|
66
73
|
value,
|
|
67
74
|
{ requestId: requestContext.get()?.requestId ?? "append-event" },
|
|
68
75
|
field,
|
package/src/crypto/index.ts
CHANGED
|
@@ -24,12 +24,14 @@ export {
|
|
|
24
24
|
type KmsContext,
|
|
25
25
|
type KmsHealth,
|
|
26
26
|
type LocalKeyKmsAdapter,
|
|
27
|
+
RECORD_ENTITY_PATTERN,
|
|
27
28
|
type RemoteCryptoKmsAdapter,
|
|
28
29
|
type SubjectDek,
|
|
29
30
|
type SubjectId,
|
|
30
31
|
type SubjectKey,
|
|
31
32
|
subjectIdFromKey,
|
|
32
33
|
subjectIdToKey,
|
|
34
|
+
subjectKeyForRecord,
|
|
33
35
|
subjectKeyForTenant,
|
|
34
36
|
subjectKeyForUser,
|
|
35
37
|
} from "./kms-adapter";
|
|
@@ -1,7 +1,8 @@
|
|
|
1
|
+
import { type EventPiiSubject, normalizeEventPiiSubject } from "@cosmicdrift/kumiko-types/handlers";
|
|
1
2
|
import type { EntityDefinition } from "../engine/types/fields";
|
|
2
3
|
import type { TenantId } from "../engine/types/identifiers";
|
|
3
4
|
import { isSelfPiiField } from "./is-self-pii-field";
|
|
4
|
-
import type
|
|
5
|
+
import { RECORD_ENTITY_PATTERN, type SubjectId } from "./kms-adapter";
|
|
5
6
|
|
|
6
7
|
// Thrown when a field IS pii-annotated but the row can't name its subject —
|
|
7
8
|
// that must surface as an error, not fall back to plaintext.
|
|
@@ -125,6 +126,66 @@ export function resolveSubjectForField(
|
|
|
125
126
|
return null;
|
|
126
127
|
}
|
|
127
128
|
|
|
129
|
+
// Envelope facts an event's own aggregate stream always carries — unlike a
|
|
130
|
+
// row's tenantId/id columns, these are structural, so a missing/empty value
|
|
131
|
+
// here is a caller bug, not a normal "no subject to shred" case.
|
|
132
|
+
export interface EventSubjectEnvelope {
|
|
133
|
+
readonly tenantId: TenantId;
|
|
134
|
+
readonly aggregateType: string;
|
|
135
|
+
readonly aggregateId: string;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// Same resolver for the live-append path (encryptEventPayloadPii) and the
|
|
139
|
+
// backfill catalog path (backfillEventPiiEncryption) — the reason the two
|
|
140
|
+
// can never encrypt the same field under different subjects. "user" returns
|
|
141
|
+
// null on a missing owner (no key to shred for system-triggered events);
|
|
142
|
+
// "tenant"/"self" throw instead, since their envelope facts are structural.
|
|
143
|
+
export function resolveEventSubject(
|
|
144
|
+
fieldName: string,
|
|
145
|
+
spec: EventPiiSubject,
|
|
146
|
+
payload: Record<string, unknown>,
|
|
147
|
+
envelope: EventSubjectEnvelope,
|
|
148
|
+
): SubjectId | null {
|
|
149
|
+
const normalized = normalizeEventPiiSubject(spec);
|
|
150
|
+
|
|
151
|
+
if (normalized.kind === "user") {
|
|
152
|
+
const userId = nonEmptyString(payload[normalized.ownerField]);
|
|
153
|
+
return userId === null ? null : { kind: "user", userId };
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
if (normalized.kind === "tenant") {
|
|
157
|
+
if (nonEmptyString(envelope.tenantId) === null) {
|
|
158
|
+
throw new SubjectResolutionError(fieldName, "event envelope tenantId is empty");
|
|
159
|
+
}
|
|
160
|
+
return { kind: "tenant", tenantId: envelope.tenantId };
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
if (normalized.kind === "self") {
|
|
164
|
+
if (
|
|
165
|
+
nonEmptyString(envelope.aggregateType) === null ||
|
|
166
|
+
nonEmptyString(envelope.aggregateId) === null
|
|
167
|
+
) {
|
|
168
|
+
throw new SubjectResolutionError(
|
|
169
|
+
fieldName,
|
|
170
|
+
"event envelope aggregateType/aggregateId is empty",
|
|
171
|
+
);
|
|
172
|
+
}
|
|
173
|
+
// aggregate_type is a free-form text column (events-schema.ts) — reject
|
|
174
|
+
// here rather than mint a record key that subjectIdSchema (forget-
|
|
175
|
+
// subject.write.ts) would later refuse to shred.
|
|
176
|
+
if (!RECORD_ENTITY_PATTERN.test(envelope.aggregateType)) {
|
|
177
|
+
throw new SubjectResolutionError(
|
|
178
|
+
fieldName,
|
|
179
|
+
`event aggregateType "${envelope.aggregateType}" does not match ${RECORD_ENTITY_PATTERN} — not a shreddable record subject`,
|
|
180
|
+
);
|
|
181
|
+
}
|
|
182
|
+
return { kind: "record", entity: envelope.aggregateType, id: envelope.aggregateId };
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const exhaustiveCheck: never = normalized;
|
|
186
|
+
throw new Error(`Unhandled normalized event PII subject: ${JSON.stringify(exhaustiveCheck)}`);
|
|
187
|
+
}
|
|
188
|
+
|
|
128
189
|
function hasSubjectAnnotation(field: EntityDefinition["fields"][string]): boolean {
|
|
129
190
|
return (
|
|
130
191
|
("userOwned" in field && field.userOwned !== undefined) ||
|
|
@@ -11,7 +11,8 @@
|
|
|
11
11
|
// same collectPiiSubjectFields/resolveSubjectForField the live write
|
|
12
12
|
// path uses
|
|
13
13
|
// - custom events from the event-PII catalog (r.defineEvent piiFields) —
|
|
14
|
-
// user
|
|
14
|
+
// user/tenant/self subjects, via the same resolveEventSubject the live
|
|
15
|
+
// append path uses (fw#2801)
|
|
15
16
|
//
|
|
16
17
|
// Already-forgotten subjects must NOT get a fresh key minted for their old
|
|
17
18
|
// plaintext — three erased-detection layers write [[erased]] instead:
|
|
@@ -59,6 +60,7 @@ import {
|
|
|
59
60
|
} from "../../crypto/pii-field-encryption";
|
|
60
61
|
import {
|
|
61
62
|
collectPiiSubjectFields,
|
|
63
|
+
resolveEventSubject,
|
|
62
64
|
resolveSubjectForField,
|
|
63
65
|
SubjectResolutionError,
|
|
64
66
|
} from "../../crypto/subject-resolver";
|
|
@@ -284,11 +286,13 @@ export async function backfillEventPiiEncryption(
|
|
|
284
286
|
const catalogFields = eventCatalog.get(row.type);
|
|
285
287
|
if (catalogFields) {
|
|
286
288
|
for (const [field, spec] of Object.entries(catalogFields)) {
|
|
287
|
-
const
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
289
|
+
const subject = resolveEventSubject(field, spec, payload, {
|
|
290
|
+
tenantId: row.tenant_id as TenantId, // @cast-boundary db-read — tenant_id column is the branded TenantId
|
|
291
|
+
aggregateType: row.aggregate_type,
|
|
292
|
+
aggregateId: row.aggregate_id,
|
|
293
|
+
});
|
|
294
|
+
if (subject === null) continue;
|
|
295
|
+
const outcome = await encryptField(payload, field, subject);
|
|
292
296
|
bump(outcome);
|
|
293
297
|
}
|
|
294
298
|
} else {
|
|
@@ -132,6 +132,37 @@ describe("validateBoot — projectionDetail tabs (fw record-layout)", () => {
|
|
|
132
132
|
expect(() => validateBoot([feature])).toThrow(/metric "balance" has no entry in fieldLabels/);
|
|
133
133
|
});
|
|
134
134
|
|
|
135
|
+
test("object-form metric with its own label needs no fieldLabels entry", () => {
|
|
136
|
+
const feature = defineFeature("app", (r) => {
|
|
137
|
+
r.queryHandler("rent:detail", z.object({}), async () => ({ description: "x" }), {
|
|
138
|
+
access: { openToAll: true },
|
|
139
|
+
});
|
|
140
|
+
r.screen({
|
|
141
|
+
id: "rent-detail",
|
|
142
|
+
type: "projectionDetail",
|
|
143
|
+
query: "app:query:rent:detail",
|
|
144
|
+
layout: { sections: [{ title: "s", fields: ["description"] }] },
|
|
145
|
+
metrics: [{ field: "balance", label: "rent.balance" }],
|
|
146
|
+
});
|
|
147
|
+
});
|
|
148
|
+
expect(() => validateBoot([feature])).not.toThrow();
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
test("object-form metric with neither its own label nor a fieldLabels entry throws", () => {
|
|
152
|
+
const feature = defineFeature("app", (r) => {
|
|
153
|
+
r.screen({
|
|
154
|
+
id: "rent-detail",
|
|
155
|
+
type: "projectionDetail",
|
|
156
|
+
query: "app:query:rent:detail",
|
|
157
|
+
layout: { sections: [{ title: "s", fields: ["description"] }] },
|
|
158
|
+
metrics: [{ field: "balance" }],
|
|
159
|
+
});
|
|
160
|
+
});
|
|
161
|
+
expect(() => validateBoot([feature])).toThrow(
|
|
162
|
+
/metric "balance" has no entry in fieldLabels and no own "label"/,
|
|
163
|
+
);
|
|
164
|
+
});
|
|
165
|
+
|
|
135
166
|
test("valid tabs + header + metrics declaration boots cleanly", () => {
|
|
136
167
|
const feature = defineFeature("app", (r) => {
|
|
137
168
|
r.queryHandler("rent:detail", z.object({}), async () => ({ description: "x" }), {
|
|
@@ -142,8 +173,8 @@ describe("validateBoot — projectionDetail tabs (fw record-layout)", () => {
|
|
|
142
173
|
type: "projectionDetail",
|
|
143
174
|
query: "app:query:rent:detail",
|
|
144
175
|
header: { title: "name", subtitle: "address", status: "state" },
|
|
145
|
-
metrics: ["balance", "overdueDays"],
|
|
146
|
-
fieldLabels: { balance: "rent.balance"
|
|
176
|
+
metrics: ["balance", { field: "overdueDays", label: "rent.overdueDays" }],
|
|
177
|
+
fieldLabels: { balance: "rent.balance" },
|
|
147
178
|
layout: {
|
|
148
179
|
mode: "tabs",
|
|
149
180
|
sections: [
|
|
@@ -12,6 +12,7 @@ import type {
|
|
|
12
12
|
QueryHandlerDef,
|
|
13
13
|
ScreenDefinition,
|
|
14
14
|
} from "../types";
|
|
15
|
+
import { metricField } from "../types";
|
|
15
16
|
import { buildQueryHandlerMap } from "./projection-list-screens";
|
|
16
17
|
import { getZodObjectShape, getZodRowShape } from "./zod-shape";
|
|
17
18
|
|
|
@@ -130,11 +131,12 @@ function checkProjectionDetailOutputFields(
|
|
|
130
131
|
if (screen.header.status !== undefined) checkHeaderField("status", screen.header.status);
|
|
131
132
|
}
|
|
132
133
|
for (const metric of screen.metrics ?? []) {
|
|
134
|
+
const field = metricField(metric);
|
|
133
135
|
checkFieldExists(
|
|
134
136
|
recordShape,
|
|
135
|
-
|
|
137
|
+
field,
|
|
136
138
|
() =>
|
|
137
|
-
`${prefix} metrics references field "${
|
|
139
|
+
`${prefix} metrics references field "${field}" which is not present in query "${screen.query}"'s outputSchema.`,
|
|
138
140
|
);
|
|
139
141
|
}
|
|
140
142
|
}
|
|
@@ -16,6 +16,7 @@ import {
|
|
|
16
16
|
normalizeListColumn,
|
|
17
17
|
} from "../screen-helpers";
|
|
18
18
|
import type { EntityDefinition, FeatureDefinition } from "../types";
|
|
19
|
+
import { metricField } from "../types";
|
|
19
20
|
import type {
|
|
20
21
|
DashboardCustomPanel,
|
|
21
22
|
DashboardFilterDefinition,
|
|
@@ -697,11 +698,13 @@ export function validateScreens(
|
|
|
697
698
|
}
|
|
698
699
|
if (screen.metrics !== undefined) {
|
|
699
700
|
for (const metric of screen.metrics) {
|
|
700
|
-
|
|
701
|
+
const field = metricField(metric);
|
|
702
|
+
const hasOwnLabel = typeof metric !== "string" && metric.label !== undefined;
|
|
703
|
+
if (!hasOwnLabel && screen.fieldLabels?.[field] === undefined) {
|
|
701
704
|
throw new Error(
|
|
702
|
-
`[Feature ${feature.name}] Screen "${screenId}" (projectionDetail) metric "${
|
|
703
|
-
`no entry in fieldLabels — every metrics field needs
|
|
704
|
-
`the raw column name.`,
|
|
705
|
+
`[Feature ${feature.name}] Screen "${screenId}" (projectionDetail) metric "${field}" has ` +
|
|
706
|
+
`no entry in fieldLabels and no own "label" — every metrics field needs one of the two, ` +
|
|
707
|
+
`there is no fallback to the raw column name.`,
|
|
705
708
|
);
|
|
706
709
|
}
|
|
707
710
|
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { normalizeEventPiiSubject } from "@cosmicdrift/kumiko-types/handlers";
|
|
1
2
|
import { ZodObject, type ZodType, type z } from "zod";
|
|
2
3
|
import type { FeatureBuilderState } from "./feature-builder-state";
|
|
3
4
|
import { resolveName } from "./handler-helpers";
|
|
@@ -96,7 +97,7 @@ export function buildConfigEventsJobsMethods<TName extends string>(
|
|
|
96
97
|
}
|
|
97
98
|
|
|
98
99
|
// piiFields misconfiguration is a boot-time error, not a silent
|
|
99
|
-
// plaintext leak: both the pii field and its
|
|
100
|
+
// plaintext leak: both the pii field and its owner field must exist
|
|
100
101
|
// on the payload schema (checkable when the schema is a ZodObject).
|
|
101
102
|
function validateEventPiiFields(
|
|
102
103
|
eventName: string,
|
|
@@ -105,12 +106,24 @@ export function buildConfigEventsJobsMethods<TName extends string>(
|
|
|
105
106
|
): void {
|
|
106
107
|
const shape = schema instanceof ZodObject ? schema.shape : undefined;
|
|
107
108
|
for (const [field, spec] of Object.entries(piiFields)) {
|
|
108
|
-
|
|
109
|
+
const normalized = normalizeEventPiiSubject(spec);
|
|
110
|
+
// Only the user form names an owner field — tenant/self subjects come
|
|
111
|
+
// from the write-time envelope (tenantId / aggregateType+aggregateId),
|
|
112
|
+
// so there is no owner-field existence or self-reference check for them.
|
|
113
|
+
if (normalized.kind !== "user") {
|
|
114
|
+
if (shape && !(field in shape)) {
|
|
115
|
+
throw new Error(
|
|
116
|
+
`[Feature ${name}] defineEvent("${eventName}"): piiFields references "${field}" which is not a field of the payload schema.`,
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
continue;
|
|
120
|
+
}
|
|
121
|
+
if (field === normalized.ownerField) {
|
|
109
122
|
throw new Error(
|
|
110
|
-
`[Feature ${name}] defineEvent("${eventName}"): piiFields."${field}" cannot use itself as
|
|
123
|
+
`[Feature ${name}] defineEvent("${eventName}"): piiFields."${field}" cannot use itself as the owner field — the subject id is a plaintext pseudonymous fk, the pii field is the value it owns.`,
|
|
111
124
|
);
|
|
112
125
|
}
|
|
113
|
-
for (const required of [field,
|
|
126
|
+
for (const required of [field, normalized.ownerField]) {
|
|
114
127
|
if (shape && !(required in shape)) {
|
|
115
128
|
throw new Error(
|
|
116
129
|
`[Feature ${name}] defineEvent("${eventName}"): piiFields references "${required}" which is not a field of the payload schema.`,
|
|
@@ -238,7 +251,7 @@ export function buildConfigEventsJobsMethods<TName extends string>(
|
|
|
238
251
|
// an event without declaring whether its payload carries personal data.
|
|
239
252
|
const missingStanceError = () =>
|
|
240
253
|
new Error(
|
|
241
|
-
`[Feature ${name}] defineEvent("${eventName}") must declare an explicit PII stance. Pass { piiFields: { <payloadField>: {
|
|
254
|
+
`[Feature ${name}] defineEvent("${eventName}") must declare an explicit PII stance. Pass { piiFields: { <payloadField>: { personal: { of: "<userIdField>" } } } } for payload fields owned by a user (or { personal: "tenant" } / { personal: "self" } for tenant- or record-scoped subjects), or { piiFields: "none" } when the payload holds none.`,
|
|
242
255
|
);
|
|
243
256
|
if (options === undefined || options.piiFields === undefined) {
|
|
244
257
|
throw missingStanceError();
|
package/src/engine/index.ts
CHANGED
|
@@ -292,6 +292,8 @@ export type {
|
|
|
292
292
|
ListPaginationMode,
|
|
293
293
|
ListSortDir,
|
|
294
294
|
ListSortSpec,
|
|
295
|
+
MetricNavigate,
|
|
296
|
+
MetricSpec,
|
|
295
297
|
PlatformComponent,
|
|
296
298
|
ProjectionDetailScreenDefinition,
|
|
297
299
|
ProjectionListScreenDefinition,
|
|
@@ -311,6 +313,7 @@ export type {
|
|
|
311
313
|
ToolbarAction,
|
|
312
314
|
UnitKey,
|
|
313
315
|
} from "@cosmicdrift/kumiko-types/screen";
|
|
316
|
+
export { metricField } from "@cosmicdrift/kumiko-types/screen";
|
|
314
317
|
export type { TargetRef } from "@cosmicdrift/kumiko-types/target-ref";
|
|
315
318
|
export type {
|
|
316
319
|
Subscribe,
|
|
@@ -51,12 +51,31 @@ const crmFeature = defineFeature("crm", (r) => {
|
|
|
51
51
|
r.entity("contact", contactEntity);
|
|
52
52
|
});
|
|
53
53
|
|
|
54
|
+
// Qualified event-type strings are kebab-case (qn/toKebab, feature-config-
|
|
55
|
+
// events-jobs.ts) — captured from defineEvent's own return value instead of
|
|
56
|
+
// hand-written, so a camelCase event name (tenantNote → tenant-note) can
|
|
57
|
+
// never drift from what the catalog is actually keyed by (fw#2801).
|
|
58
|
+
let PING_EVENT_TYPE: string;
|
|
54
59
|
const mailerFeature = defineFeature("mailer", (r) => {
|
|
55
|
-
r.defineEvent(
|
|
60
|
+
PING_EVENT_TYPE = r.defineEvent(
|
|
56
61
|
"ping",
|
|
57
62
|
z.object({ targetId: z.string().nullable(), address: z.string().nullable() }),
|
|
58
63
|
{ piiFields: { address: { subjectField: "targetId" } } },
|
|
59
|
-
);
|
|
64
|
+
).name;
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
// fw#2801 step 2: custom events declaring a tenant/self subject — no entity,
|
|
68
|
+
// pure catalog events — proving the catalog branch resolves through the
|
|
69
|
+
// same resolveEventSubject the live append path uses.
|
|
70
|
+
let TENANT_NOTE_EVENT_TYPE: string;
|
|
71
|
+
let SELF_NOTE_EVENT_TYPE: string;
|
|
72
|
+
const signalsFeature = defineFeature("signals", (r) => {
|
|
73
|
+
TENANT_NOTE_EVENT_TYPE = r.defineEvent("tenantNote", z.object({ note: z.string().nullable() }), {
|
|
74
|
+
piiFields: { note: { personal: "tenant" } },
|
|
75
|
+
}).name;
|
|
76
|
+
SELF_NOTE_EVENT_TYPE = r.defineEvent("selfNote", z.object({ note: z.string().nullable() }), {
|
|
77
|
+
piiFields: { note: { personal: "self" } },
|
|
78
|
+
}).name;
|
|
60
79
|
});
|
|
61
80
|
|
|
62
81
|
// personal: { of } (not "self") — the named owner field can legitimately be
|
|
@@ -111,7 +130,13 @@ async function appendPlain(
|
|
|
111
130
|
|
|
112
131
|
beforeAll(async () => {
|
|
113
132
|
testDb = await createTestDb();
|
|
114
|
-
registry = createRegistry([
|
|
133
|
+
registry = createRegistry([
|
|
134
|
+
crmFeature,
|
|
135
|
+
mailerFeature,
|
|
136
|
+
notesFeature,
|
|
137
|
+
surveyFeature,
|
|
138
|
+
signalsFeature,
|
|
139
|
+
]);
|
|
115
140
|
await createEventsTable(testDb.db);
|
|
116
141
|
await unsafeCreateEntityTable(testDb.db, contactEntity, "contact");
|
|
117
142
|
await unsafeCreateEntityTable(testDb.db, noteEntity, "note");
|
|
@@ -220,15 +245,15 @@ describe("backfillEventPiiEncryption", () => {
|
|
|
220
245
|
const p1 = generateId();
|
|
221
246
|
const p2 = generateId();
|
|
222
247
|
const p3 = generateId();
|
|
223
|
-
await appendPlain(p1, "ping",
|
|
248
|
+
await appendPlain(p1, "ping", PING_EVENT_TYPE, {
|
|
224
249
|
targetId: "u-7",
|
|
225
250
|
address: "u7@x.com",
|
|
226
251
|
});
|
|
227
|
-
await appendPlain(p2, "ping",
|
|
252
|
+
await appendPlain(p2, "ping", PING_EVENT_TYPE, {
|
|
228
253
|
targetId: forgottenUser,
|
|
229
254
|
address: "f@x.com",
|
|
230
255
|
});
|
|
231
|
-
await appendPlain(p3, "ping",
|
|
256
|
+
await appendPlain(p3, "ping", PING_EVENT_TYPE, { targetId: null, address: "ops@x.com" });
|
|
232
257
|
|
|
233
258
|
armKms();
|
|
234
259
|
const result = await backfillEventPiiEncryption(testDb.db, registry);
|
|
@@ -386,7 +411,7 @@ describe("backfillEventPiiEncryption", () => {
|
|
|
386
411
|
|
|
387
412
|
test("dryRun over a catalogued custom event mints no key for its payload-resolved subject", async () => {
|
|
388
413
|
const p1 = generateId();
|
|
389
|
-
await appendPlain(p1, "ping",
|
|
414
|
+
await appendPlain(p1, "ping", PING_EVENT_TYPE, {
|
|
390
415
|
targetId: "u-7",
|
|
391
416
|
address: "u7@x.com",
|
|
392
417
|
});
|
|
@@ -730,3 +755,104 @@ describe("backfillEventPiiEncryption: raw jsonb column type (fw#2253)", () => {
|
|
|
730
755
|
expect(rows[0]?.t).toBe("object");
|
|
731
756
|
});
|
|
732
757
|
});
|
|
758
|
+
|
|
759
|
+
// The security-critical invariant this PR exists for: a catalogued field
|
|
760
|
+
// must resolve to the IDENTICAL subject key whether it was encrypted at
|
|
761
|
+
// live-append time or re-encrypted by the backfill — both paths now go
|
|
762
|
+
// through the same resolveEventSubject (subject-resolver.ts). If they ever
|
|
763
|
+
// diverged, an Art. 17 erase of one subject key would leave the same field's
|
|
764
|
+
// other-path ciphertext readable under a different key.
|
|
765
|
+
describe("backfillEventPiiEncryption: catalog subject kinds match live append (fw#2801)", () => {
|
|
766
|
+
test("user subject: backfill and live append encrypt under the same subject key", async () => {
|
|
767
|
+
const preKmsId = generateId();
|
|
768
|
+
await appendPlain(preKmsId, "ping", PING_EVENT_TYPE, {
|
|
769
|
+
targetId: "u-42",
|
|
770
|
+
address: "pre-kms@x.com",
|
|
771
|
+
});
|
|
772
|
+
|
|
773
|
+
armKms();
|
|
774
|
+
const liveId = generateId();
|
|
775
|
+
await append(testDb.db, {
|
|
776
|
+
aggregateId: liveId,
|
|
777
|
+
aggregateType: "ping",
|
|
778
|
+
tenantId: TENANT,
|
|
779
|
+
expectedVersion: 0,
|
|
780
|
+
type: PING_EVENT_TYPE,
|
|
781
|
+
payload: { targetId: "u-42", address: "live@x.com" },
|
|
782
|
+
metadata: { userId: "system" },
|
|
783
|
+
});
|
|
784
|
+
|
|
785
|
+
await backfillEventPiiEncryption(testDb.db, registry);
|
|
786
|
+
|
|
787
|
+
const backfilled = (await loadAggregate(testDb.db, preKmsId, TENANT))[0]?.payload as Record<
|
|
788
|
+
string,
|
|
789
|
+
unknown
|
|
790
|
+
>;
|
|
791
|
+
const live = (await loadAggregate(testDb.db, liveId, TENANT))[0]?.payload as Record<
|
|
792
|
+
string,
|
|
793
|
+
unknown
|
|
794
|
+
>;
|
|
795
|
+
expect(String(backfilled["address"])).toContain("user:u-42");
|
|
796
|
+
expect(String(live["address"])).toContain("user:u-42");
|
|
797
|
+
});
|
|
798
|
+
|
|
799
|
+
test("tenant subject: backfill and live append encrypt under the same subject key", async () => {
|
|
800
|
+
const preKmsId = generateId();
|
|
801
|
+
await appendPlain(preKmsId, "signal", TENANT_NOTE_EVENT_TYPE, { note: "pre-kms" });
|
|
802
|
+
|
|
803
|
+
armKms();
|
|
804
|
+
const liveId = generateId();
|
|
805
|
+
await append(testDb.db, {
|
|
806
|
+
aggregateId: liveId,
|
|
807
|
+
aggregateType: "signal",
|
|
808
|
+
tenantId: TENANT,
|
|
809
|
+
expectedVersion: 0,
|
|
810
|
+
type: TENANT_NOTE_EVENT_TYPE,
|
|
811
|
+
payload: { note: "live" },
|
|
812
|
+
metadata: { userId: "system" },
|
|
813
|
+
});
|
|
814
|
+
|
|
815
|
+
await backfillEventPiiEncryption(testDb.db, registry);
|
|
816
|
+
|
|
817
|
+
const backfilled = (await loadAggregate(testDb.db, preKmsId, TENANT))[0]?.payload as Record<
|
|
818
|
+
string,
|
|
819
|
+
unknown
|
|
820
|
+
>;
|
|
821
|
+
const live = (await loadAggregate(testDb.db, liveId, TENANT))[0]?.payload as Record<
|
|
822
|
+
string,
|
|
823
|
+
unknown
|
|
824
|
+
>;
|
|
825
|
+
expect(String(backfilled["note"])).toContain(`tenant:${TENANT}`);
|
|
826
|
+
expect(String(live["note"])).toContain(`tenant:${TENANT}`);
|
|
827
|
+
});
|
|
828
|
+
|
|
829
|
+
test("self (record) subject: backfill and live append encrypt each under its own aggregate's key", async () => {
|
|
830
|
+
const preKmsId = generateId();
|
|
831
|
+
await appendPlain(preKmsId, "signal", SELF_NOTE_EVENT_TYPE, { note: "pre-kms" });
|
|
832
|
+
|
|
833
|
+
armKms();
|
|
834
|
+
const liveId = generateId();
|
|
835
|
+
await append(testDb.db, {
|
|
836
|
+
aggregateId: liveId,
|
|
837
|
+
aggregateType: "signal",
|
|
838
|
+
tenantId: TENANT,
|
|
839
|
+
expectedVersion: 0,
|
|
840
|
+
type: SELF_NOTE_EVENT_TYPE,
|
|
841
|
+
payload: { note: "live" },
|
|
842
|
+
metadata: { userId: "system" },
|
|
843
|
+
});
|
|
844
|
+
|
|
845
|
+
await backfillEventPiiEncryption(testDb.db, registry);
|
|
846
|
+
|
|
847
|
+
const backfilled = (await loadAggregate(testDb.db, preKmsId, TENANT))[0]?.payload as Record<
|
|
848
|
+
string,
|
|
849
|
+
unknown
|
|
850
|
+
>;
|
|
851
|
+
const live = (await loadAggregate(testDb.db, liveId, TENANT))[0]?.payload as Record<
|
|
852
|
+
string,
|
|
853
|
+
unknown
|
|
854
|
+
>;
|
|
855
|
+
expect(String(backfilled["note"])).toContain(`record:signal:${preKmsId}`);
|
|
856
|
+
expect(String(live["note"])).toContain(`record:signal:${liveId}`);
|
|
857
|
+
});
|
|
858
|
+
});
|
|
@@ -78,7 +78,11 @@ export const EVENTS_PUBSUB_CHANNEL = "kumiko_events_new";
|
|
|
78
78
|
export async function append(db: DbRunner, event: EventToAppend): Promise<StoredEvent> {
|
|
79
79
|
// Event-PII (#799): stored payload AND returned echo carry ciphertext, so
|
|
80
80
|
// inline projections and rebuilds materialize identical rows.
|
|
81
|
-
const payload = await encryptEventPayloadPii(event.type, event.payload
|
|
81
|
+
const payload = await encryptEventPayloadPii(event.type, event.payload, {
|
|
82
|
+
tenantId: event.tenantId,
|
|
83
|
+
aggregateType: event.aggregateType,
|
|
84
|
+
aggregateId: event.aggregateId,
|
|
85
|
+
});
|
|
82
86
|
const toStore = payload === event.payload ? event : { ...event, payload };
|
|
83
87
|
const newVersion = toStore.expectedVersion + 1;
|
|
84
88
|
const eventVersion = toStore.eventVersion ?? 1;
|
|
@@ -10,7 +10,9 @@ import {
|
|
|
10
10
|
createSystemUser,
|
|
11
11
|
createTextField,
|
|
12
12
|
defineFeature,
|
|
13
|
+
HookPhases,
|
|
13
14
|
} from "../../engine";
|
|
15
|
+
import type { HandlerContext } from "../../engine/types";
|
|
14
16
|
import { UnprocessableError, writeFailure } from "../../errors";
|
|
15
17
|
import { setupTestStack, type TestStack, TestUsers, unsafeCreateEntityTable } from "../../stack";
|
|
16
18
|
|
|
@@ -44,14 +46,32 @@ const secretEntity = createEntity({
|
|
|
44
46
|
});
|
|
45
47
|
const secretTable = buildEntityTable("secret", secretEntity);
|
|
46
48
|
|
|
49
|
+
// Target entity for the afterCommit-hook-writes-a-second-entity test below
|
|
50
|
+
// (dead-tx-in-afterCommit-hooks). Plain Admin access — kept separate
|
|
51
|
+
// from `secret` so that test doesn't entangle with the privileged-access
|
|
52
|
+
// assertions the other describe blocks make about it.
|
|
53
|
+
const echoEntity = createEntity({
|
|
54
|
+
table: "ctx_echoes",
|
|
55
|
+
fields: {
|
|
56
|
+
bagId: createTextField({ required: true }),
|
|
57
|
+
},
|
|
58
|
+
});
|
|
59
|
+
const echoTable = buildEntityTable("echo", echoEntity);
|
|
60
|
+
|
|
47
61
|
let stack: TestStack;
|
|
48
62
|
const admin = TestUsers.admin;
|
|
49
63
|
|
|
50
64
|
const afterCommitLog: string[] = [];
|
|
51
65
|
|
|
66
|
+
// Toggled per-test so the afterCommit hook only writes echo rows for
|
|
67
|
+
// the test that exercises it — reset in beforeEach.
|
|
68
|
+
let afterCommitHookShouldEchoBag = false;
|
|
69
|
+
const echoedBagIds: string[] = [];
|
|
70
|
+
|
|
52
71
|
const bridgeFeature = defineFeature("ctxbridge", (r) => {
|
|
53
72
|
const bag = r.entity("bag", bagEntity);
|
|
54
73
|
const secret = r.entity("secret", secretEntity);
|
|
74
|
+
r.entity("echo", echoEntity);
|
|
55
75
|
|
|
56
76
|
r.writeHandler(
|
|
57
77
|
"bag:create",
|
|
@@ -182,11 +202,45 @@ const bridgeFeature = defineFeature("ctxbridge", (r) => {
|
|
|
182
202
|
{ access: { roles: ["Admin"] } },
|
|
183
203
|
);
|
|
184
204
|
|
|
205
|
+
r.writeHandler(
|
|
206
|
+
"echo:create",
|
|
207
|
+
z.object({ bagId: z.string() }),
|
|
208
|
+
async (event, ctx) => {
|
|
209
|
+
const crud = createEventStoreExecutor(echoTable, echoEntity, {
|
|
210
|
+
entityName: "echo",
|
|
211
|
+
});
|
|
212
|
+
return crud.create(event.payload, event.user, ctx.db);
|
|
213
|
+
},
|
|
214
|
+
{ access: { roles: ["Admin"] } },
|
|
215
|
+
);
|
|
216
|
+
|
|
185
217
|
// afterCommit hook on bag — fires once per outer commit.
|
|
186
218
|
r.hook("postSave", { allOf: bag }, async (result) => {
|
|
187
219
|
afterCommitLog.push(`bag:${result.data["label"]}`);
|
|
188
220
|
});
|
|
189
221
|
|
|
222
|
+
// afterCommit hook on bag that writes a SECOND entity via ctx.write,
|
|
223
|
+
// going through the real event-store append path (crud.create ->
|
|
224
|
+
// runInSavepointIfSupported). Proves the afterCommit context's ctx.db
|
|
225
|
+
// is a live, usable handle rather than the already-committed tx
|
|
226
|
+
// (dead-tx-in-afterCommit-hooks). Explicit phase so the test says
|
|
227
|
+
// what it means, even though afterCommit is already the default.
|
|
228
|
+
r.hook(
|
|
229
|
+
"postSave",
|
|
230
|
+
{ allOf: bag },
|
|
231
|
+
async (result, ctx) => {
|
|
232
|
+
if (!afterCommitHookShouldEchoBag) return;
|
|
233
|
+
const bagId = String(result.id);
|
|
234
|
+
// Hooks are typed against AppContext (no bridge), but the runtime
|
|
235
|
+
// object is always the full HandlerContext — see the comment on
|
|
236
|
+
// AppContext in @cosmicdrift/kumiko-types/handlers. @cast-boundary
|
|
237
|
+
const handlerCtx = ctx as unknown as HandlerContext;
|
|
238
|
+
const echoRes = await handlerCtx.write("ctxbridge:write:echo:create", { bagId });
|
|
239
|
+
if (echoRes.isSuccess) echoedBagIds.push(bagId);
|
|
240
|
+
},
|
|
241
|
+
{ phase: HookPhases.afterCommit },
|
|
242
|
+
);
|
|
243
|
+
|
|
190
244
|
// afterCommit hook on secret — the entity targeted by the nested writeAs.
|
|
191
245
|
// Proves: (a) hook fires exactly once per successful writeAs, (b) hook
|
|
192
246
|
// does NOT fire when the outer transaction rolls back.
|
|
@@ -199,6 +253,7 @@ beforeAll(async () => {
|
|
|
199
253
|
stack = await setupTestStack({ features: [bridgeFeature] });
|
|
200
254
|
await unsafeCreateEntityTable(stack.db, bagEntity);
|
|
201
255
|
await unsafeCreateEntityTable(stack.db, secretEntity);
|
|
256
|
+
await unsafeCreateEntityTable(stack.db, echoEntity);
|
|
202
257
|
});
|
|
203
258
|
|
|
204
259
|
afterAll(async () => {
|
|
@@ -207,8 +262,11 @@ afterAll(async () => {
|
|
|
207
262
|
|
|
208
263
|
beforeEach(async () => {
|
|
209
264
|
afterCommitLog.length = 0;
|
|
265
|
+
afterCommitHookShouldEchoBag = false;
|
|
266
|
+
echoedBagIds.length = 0;
|
|
210
267
|
await asRawClient(stack.db).unsafe(`DELETE FROM "${bagTable.tableName}"`);
|
|
211
268
|
await asRawClient(stack.db).unsafe(`DELETE FROM "${secretTable.tableName}"`);
|
|
269
|
+
await asRawClient(stack.db).unsafe(`DELETE FROM "${echoTable.tableName}"`);
|
|
212
270
|
// Clear the event-dedup cache — tests re-use entity ids (Postgres sequences
|
|
213
271
|
// reset, each test sees id=1). Without flushing Redis the second test hits
|
|
214
272
|
// a dedup hit on the same handler:id:version:phase key and the hook is
|
|
@@ -331,3 +389,24 @@ describe("ctx.dbOutsideTransaction", () => {
|
|
|
331
389
|
expect(labels).toEqual(["signal-probe-outside-tx"]);
|
|
332
390
|
});
|
|
333
391
|
});
|
|
392
|
+
|
|
393
|
+
describe("afterCommit hook context is usable for real DB work", () => {
|
|
394
|
+
test("a postSave afterCommit hook can write a second entity via ctx.write", async () => {
|
|
395
|
+
afterCommitHookShouldEchoBag = true;
|
|
396
|
+
|
|
397
|
+
const res = await stack.http.write("ctxbridge:write:bag:create", { label: "echo-me" }, admin);
|
|
398
|
+
expect((await res.json()).isSuccess).toBe(true);
|
|
399
|
+
|
|
400
|
+
// The effect, not just "no throw": afterCommit hook errors are caught
|
|
401
|
+
// and logged by the framework (dispatch-batch.ts flushAfterCommit), so
|
|
402
|
+
// a broken hook still returns a 200 here. What proves the fix is that
|
|
403
|
+
// the hook's ctx.write actually landed a row through the event-store
|
|
404
|
+
// append path.
|
|
405
|
+
expect(echoedBagIds).toHaveLength(1);
|
|
406
|
+
const [expectedBagId] = echoedBagIds;
|
|
407
|
+
if (!expectedBagId) throw new Error("echoedBagIds[0] missing");
|
|
408
|
+
const echoes = await selectMany(stack.db, echoTable);
|
|
409
|
+
expect(echoes).toHaveLength(1);
|
|
410
|
+
expect((echoes[0] as { bagId: string }).bagId).toBe(expectedBagId);
|
|
411
|
+
});
|
|
412
|
+
});
|
|
@@ -116,12 +116,15 @@ function invalidateTenantTimezoneCache(
|
|
|
116
116
|
// Runs lifecycle hooks for a handler result. inTransaction hooks fire NOW
|
|
117
117
|
// (they see the tx via ctx.db when batch/write opens a transaction).
|
|
118
118
|
// afterCommit hooks are queued into `afterCommitHooks` for the caller to
|
|
119
|
-
// flush after commit.
|
|
119
|
+
// flush after commit. Their context is built lazily, without `tx`: by the
|
|
120
|
+
// time they fire the transaction has already committed, so `handlerContext`
|
|
121
|
+
// (built around it) would hand them a dead handle.
|
|
120
122
|
async function runLifecycle(
|
|
121
123
|
ctx: DispatchContext,
|
|
122
124
|
type: string,
|
|
123
125
|
data: unknown,
|
|
124
126
|
handlerContext: HandlerContext,
|
|
127
|
+
user: SessionUser,
|
|
125
128
|
afterCommitHooks: AfterCommitHook[],
|
|
126
129
|
): Promise<void> {
|
|
127
130
|
const { lifecycle } = ctx;
|
|
@@ -143,15 +146,29 @@ async function runLifecycle(
|
|
|
143
146
|
|
|
144
147
|
if (result.kind === "save") {
|
|
145
148
|
await lifecycle.runPostSave(type, result, handlerContext, HookPhases.inTransaction);
|
|
146
|
-
afterCommitHooks.push(() =>
|
|
147
|
-
|
|
148
|
-
|
|
149
|
+
afterCommitHooks.push(async () => {
|
|
150
|
+
const afterCommitContext = await buildHandlerContext(
|
|
151
|
+
ctx,
|
|
152
|
+
type,
|
|
153
|
+
user,
|
|
154
|
+
undefined,
|
|
155
|
+
afterCommitHooks,
|
|
156
|
+
);
|
|
157
|
+
await lifecycle.runPostSave(type, result, afterCommitContext, HookPhases.afterCommit);
|
|
158
|
+
});
|
|
149
159
|
} else if (result.kind === "delete") {
|
|
150
160
|
await lifecycle.runPreDelete(type, result, handlerContext);
|
|
151
161
|
await lifecycle.runPostDelete(type, result, handlerContext, HookPhases.inTransaction);
|
|
152
|
-
afterCommitHooks.push(() =>
|
|
153
|
-
|
|
154
|
-
|
|
162
|
+
afterCommitHooks.push(async () => {
|
|
163
|
+
const afterCommitContext = await buildHandlerContext(
|
|
164
|
+
ctx,
|
|
165
|
+
type,
|
|
166
|
+
user,
|
|
167
|
+
undefined,
|
|
168
|
+
afterCommitHooks,
|
|
169
|
+
);
|
|
170
|
+
await lifecycle.runPostDelete(type, result, afterCommitContext, HookPhases.afterCommit);
|
|
171
|
+
});
|
|
155
172
|
}
|
|
156
173
|
}
|
|
157
174
|
|
|
@@ -159,8 +176,9 @@ async function runLifecycle(
|
|
|
159
176
|
// Used by runBatch (which opens a transaction and flushes afterCommitHooks on commit).
|
|
160
177
|
//
|
|
161
178
|
// Contract:
|
|
162
|
-
// - `tx` is the active Drizzle transaction handle
|
|
163
|
-
//
|
|
179
|
+
// - `tx` is the active Drizzle transaction handle, or undefined for "no
|
|
180
|
+
// surrounding transaction" — either no Postgres is configured, or the
|
|
181
|
+
// transaction already committed (afterCommit phase).
|
|
164
182
|
// - `afterCommitHooks` collects deferred side-effects that must only fire
|
|
165
183
|
// after the transaction commits. The caller flushes them on commit, drops
|
|
166
184
|
// them on rollback. executeWrite never fires them directly.
|
|
@@ -479,7 +497,7 @@ async function executeWriteInner(
|
|
|
479
497
|
|
|
480
498
|
if (result.isSuccess) {
|
|
481
499
|
try {
|
|
482
|
-
await runLifecycle(ctx, type, result.data, handlerContext, afterCommitHooks);
|
|
500
|
+
await runLifecycle(ctx, type, result.data, handlerContext, user, afterCommitHooks);
|
|
483
501
|
} catch (e) {
|
|
484
502
|
return writeFailure(wrapToKumiko(e));
|
|
485
503
|
}
|
package/src/ui-types/index.ts
CHANGED
|
@@ -86,6 +86,8 @@ export type {
|
|
|
86
86
|
ListColumnSpec,
|
|
87
87
|
ListFacetSpec,
|
|
88
88
|
ListSortSpec,
|
|
89
|
+
MetricNavigate,
|
|
90
|
+
MetricSpec,
|
|
89
91
|
PlatformComponent,
|
|
90
92
|
ProjectionDetailScreenDefinition,
|
|
91
93
|
ProjectionListScreenDefinition,
|
|
@@ -102,6 +104,7 @@ export type {
|
|
|
102
104
|
SecretsEditSection,
|
|
103
105
|
ToolbarAction,
|
|
104
106
|
} from "../engine/types/screen";
|
|
107
|
+
export { metricField } from "../engine/types/screen";
|
|
105
108
|
export type { TargetRef } from "../engine/types/target-ref";
|
|
106
109
|
export type { TreeAction, TreeNode, TreeNodeState } from "../engine/types/tree-node";
|
|
107
110
|
export type { WorkspaceDefinition } from "../engine/types/workspace";
|