@cosmicdrift/kumiko-framework 0.168.0 → 0.170.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-framework",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.170.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>",
|
|
@@ -182,7 +182,7 @@
|
|
|
182
182
|
"./package.json": "./package.json"
|
|
183
183
|
},
|
|
184
184
|
"dependencies": {
|
|
185
|
-
"@cosmicdrift/kumiko-types": "0.
|
|
185
|
+
"@cosmicdrift/kumiko-types": "0.170.0",
|
|
186
186
|
"bullmq": "^5.76.7",
|
|
187
187
|
"bun-types": "^1.3.13",
|
|
188
188
|
"hono": "^4.12.27",
|
|
@@ -198,7 +198,7 @@
|
|
|
198
198
|
"zod": "^4.4.3"
|
|
199
199
|
},
|
|
200
200
|
"devDependencies": {
|
|
201
|
-
"@cosmicdrift/kumiko-dispatcher-live": "0.
|
|
201
|
+
"@cosmicdrift/kumiko-dispatcher-live": "0.170.0",
|
|
202
202
|
"bun-types": "^1.3.13",
|
|
203
203
|
"pino-pretty": "^13.1.3"
|
|
204
204
|
},
|
|
@@ -67,14 +67,19 @@ export function createWriteVerbs(
|
|
|
67
67
|
} = ctx;
|
|
68
68
|
|
|
69
69
|
return {
|
|
70
|
-
async create(payload, user, db) {
|
|
70
|
+
async create(payload, user, db, options) {
|
|
71
71
|
// Respect an explicit id in the payload (seed pattern, SCIM import). Without
|
|
72
72
|
// one the framework mints a fresh UUIDv7 via generateId. Strip it out of the
|
|
73
73
|
// event payload so defaults + downstream consumers don't see a redundant id field.
|
|
74
74
|
const explicitId = typeof payload["id"] === "string" ? (payload["id"] as string) : undefined; // @cast-boundary engine-payload
|
|
75
75
|
const aggregateId = explicitId ?? generateId();
|
|
76
76
|
const { id: _id, ...payloadWithoutId } = payload;
|
|
77
|
-
|
|
77
|
+
// preSave runs before ownership checks: authorization must evaluate the
|
|
78
|
+
// row as it will actually be persisted, including hook-derived fields
|
|
79
|
+
// (kumiko-framework#1672).
|
|
80
|
+
const data = options?.preSave
|
|
81
|
+
? await options.preSave(applyDefaults(payloadWithoutId), {}, true)
|
|
82
|
+
: applyDefaults(payloadWithoutId);
|
|
78
83
|
|
|
79
84
|
// H.2 — entity-level write-ownership on create. No oldRow exists, so
|
|
80
85
|
// only the new row is checked. No Straddle concern for creates.
|
|
@@ -221,12 +226,19 @@ export function createWriteVerbs(
|
|
|
221
226
|
const previous = await loadById(payload.id, db);
|
|
222
227
|
if (!previous) return writeFailure(new NotFoundError(entityName, payload.id));
|
|
223
228
|
|
|
229
|
+
// preSave runs before ownership checks: authorization must evaluate the
|
|
230
|
+
// row as it will actually be persisted, including hook-derived fields
|
|
231
|
+
// (kumiko-framework#1672).
|
|
232
|
+
const changes = updateOptions?.preSave
|
|
233
|
+
? await updateOptions.preSave(payload.changes, previous, false)
|
|
234
|
+
: payload.changes;
|
|
235
|
+
|
|
224
236
|
// H.2 — entity-level write-ownership on update. Load old row (already
|
|
225
237
|
// done above), build post-change row via shallow merge. Straddle-safe
|
|
226
238
|
// multi-role check: at least one role must accept BOTH old and new —
|
|
227
239
|
// prevents the attack where role A passes old, role B passes new and
|
|
228
240
|
// aggregation would wrongly allow a row-grab.
|
|
229
|
-
const mergedNew: Record<string, unknown> = { ...previous, ...
|
|
241
|
+
const mergedNew: Record<string, unknown> = { ...previous, ...changes };
|
|
230
242
|
if (!userCanWriteFieldRow(user, entity.access?.write, previous, mergedNew)) {
|
|
231
243
|
return writeFailure(
|
|
232
244
|
new UnprocessableError("ownership_denied", {
|
|
@@ -247,7 +259,7 @@ export function createWriteVerbs(
|
|
|
247
259
|
// `previous`, we can run the ownership rules per field against both
|
|
248
260
|
// sides and reject individual fields the user isn't entitled to
|
|
249
261
|
// touch on this specific row.
|
|
250
|
-
const fieldDeniedUpdate = checkWriteFieldOwnership(entity,
|
|
262
|
+
const fieldDeniedUpdate = checkWriteFieldOwnership(entity, changes, user, previous);
|
|
251
263
|
if (fieldDeniedUpdate) {
|
|
252
264
|
return writeFailure(
|
|
253
265
|
new UnprocessableError("ownership_denied", {
|
|
@@ -303,11 +315,11 @@ export function createWriteVerbs(
|
|
|
303
315
|
// ownerField — the merged row still names the subject.
|
|
304
316
|
const submittedChanges = updateOptions?.skipUnchanged
|
|
305
317
|
? Object.fromEntries(
|
|
306
|
-
Object.entries(
|
|
318
|
+
Object.entries(changes).filter(
|
|
307
319
|
([key, value]) => !isUnchangedValue(value, previous[key]),
|
|
308
320
|
),
|
|
309
321
|
)
|
|
310
|
-
:
|
|
322
|
+
: changes;
|
|
311
323
|
const flatChangesPlain = flattenCompoundTypes(submittedChanges, entity);
|
|
312
324
|
const flatChanges = await encryptForStorage(flatChangesPlain, user, {
|
|
313
325
|
onlyKeys: Object.keys(submittedChanges),
|
|
@@ -368,7 +380,7 @@ export function createWriteVerbs(
|
|
|
368
380
|
kind: "save",
|
|
369
381
|
id: data["id"] as EntityId, // @cast-boundary engine-payload
|
|
370
382
|
data,
|
|
371
|
-
changes
|
|
383
|
+
changes,
|
|
372
384
|
previous,
|
|
373
385
|
isNew: false,
|
|
374
386
|
entityName,
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
// Regression coverage for kumiko-framework#1672 — preSave hooks were
|
|
2
|
+
// registered and boot-validated but never invoked by the dispatch path,
|
|
3
|
+
// making `r.hook("preSave", ...)` a silent no-op. This exercises the real
|
|
4
|
+
// HTTP dispatcher (not a hand-fed handler context) so the fix is proven at
|
|
5
|
+
// the layer app authors actually depend on.
|
|
6
|
+
import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
|
|
7
|
+
import { asRawClient } from "../../db/query";
|
|
8
|
+
import { setupTestStack, type TestStack, TestUsers, unsafeCreateEntityTable } from "../../stack";
|
|
9
|
+
import { defineFeature } from "../define-feature";
|
|
10
|
+
import { createEntity, createTextField } from "../factories";
|
|
11
|
+
|
|
12
|
+
const contactEntity = createEntity({
|
|
13
|
+
table: "presave_wiring_contacts",
|
|
14
|
+
fields: {
|
|
15
|
+
firstName: createTextField({ required: true }),
|
|
16
|
+
lastName: createTextField({ required: true }),
|
|
17
|
+
displayName: createTextField(),
|
|
18
|
+
},
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
const seenIsNew: boolean[] = [];
|
|
22
|
+
|
|
23
|
+
const deriveDisplayName: import("../types").PreSaveHookFn = async (changes, ctx) => {
|
|
24
|
+
seenIsNew.push(ctx.isNew);
|
|
25
|
+
const first =
|
|
26
|
+
(changes["firstName"] as string | undefined) ??
|
|
27
|
+
(ctx.previous["firstName"] as string | undefined);
|
|
28
|
+
const last =
|
|
29
|
+
(changes["lastName"] as string | undefined) ?? (ctx.previous["lastName"] as string | undefined);
|
|
30
|
+
return { ...changes, displayName: `${first ?? ""} ${last ?? ""}`.trim() };
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
const contactFeature = defineFeature("presave-wiring", (r) => {
|
|
34
|
+
r.crud("contact", contactEntity, {
|
|
35
|
+
write: { access: { roles: ["User"] } },
|
|
36
|
+
read: { access: { openToAll: true } },
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
// preSave has no entity-wide `{ allOf }` shorthand (unlike postSave/
|
|
40
|
+
// preDelete/postDelete) — r.crud registers separate create/update
|
|
41
|
+
// handlers, so both need their own target.
|
|
42
|
+
r.hook("preSave", "contact:create", deriveDisplayName);
|
|
43
|
+
r.hook("preSave", "contact:update", deriveDisplayName);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
const CREATE = "presave-wiring:write:contact:create";
|
|
47
|
+
const UPDATE = "presave-wiring:write:contact:update";
|
|
48
|
+
|
|
49
|
+
describe("preSave hooks — real dispatcher path (#1672)", () => {
|
|
50
|
+
let stack: TestStack;
|
|
51
|
+
|
|
52
|
+
beforeAll(async () => {
|
|
53
|
+
stack = await setupTestStack({ features: [contactFeature] });
|
|
54
|
+
await unsafeCreateEntityTable(stack.db, contactEntity);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
afterAll(async () => {
|
|
58
|
+
await stack.cleanup();
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
beforeEach(async () => {
|
|
62
|
+
seenIsNew.length = 0;
|
|
63
|
+
await asRawClient(stack.db).unsafe("DELETE FROM kumiko_events");
|
|
64
|
+
await asRawClient(stack.db).unsafe('DELETE FROM "presave_wiring_contacts"');
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test("create: preSave hook derives displayName before persistence", async () => {
|
|
68
|
+
const res = await stack.http.write(
|
|
69
|
+
CREATE,
|
|
70
|
+
{ firstName: "Marc", lastName: "Ristone" },
|
|
71
|
+
TestUsers.user,
|
|
72
|
+
);
|
|
73
|
+
expect(res.status).toBe(200);
|
|
74
|
+
const { data } = (await res.json()) as { data: { data: { displayName: string } } };
|
|
75
|
+
expect(data.data.displayName).toBe("Marc Ristone");
|
|
76
|
+
expect(seenIsNew).toEqual([true]);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test("update: preSave hook sees previous row and re-derives displayName", async () => {
|
|
80
|
+
const created = await stack.http.write(
|
|
81
|
+
CREATE,
|
|
82
|
+
{ firstName: "Marc", lastName: "Ristone" },
|
|
83
|
+
TestUsers.user,
|
|
84
|
+
);
|
|
85
|
+
const { data } = (await created.json()) as { data: { data: { id: string; version: number } } };
|
|
86
|
+
|
|
87
|
+
const res = await stack.http.write(
|
|
88
|
+
UPDATE,
|
|
89
|
+
{ id: data.data.id, version: data.data.version, changes: { lastName: "Kumiko" } },
|
|
90
|
+
TestUsers.user,
|
|
91
|
+
);
|
|
92
|
+
expect(res.status).toBe(200);
|
|
93
|
+
const { data: updated } = (await res.json()) as { data: { data: { displayName: string } } };
|
|
94
|
+
expect(updated.data.displayName).toBe("Marc Kumiko");
|
|
95
|
+
expect(seenIsNew).toEqual([true, false]);
|
|
96
|
+
});
|
|
97
|
+
});
|
|
@@ -182,7 +182,14 @@ export function defineEntityWriteHandler(
|
|
|
182
182
|
switch (verb) {
|
|
183
183
|
case "create":
|
|
184
184
|
schema = buildInsertSchema(entity);
|
|
185
|
-
handler = async (event, ctx) =>
|
|
185
|
+
handler = async (event, ctx) => {
|
|
186
|
+
const { runPreSave } = ctx;
|
|
187
|
+
return executor.create(event.payload as DbRow, event.user, ctx.db, {
|
|
188
|
+
preSave:
|
|
189
|
+
runPreSave &&
|
|
190
|
+
((changes, previous, isNew) => runPreSave(event.type, changes, previous, isNew)),
|
|
191
|
+
});
|
|
192
|
+
};
|
|
186
193
|
break;
|
|
187
194
|
case "update":
|
|
188
195
|
schema = z.object({
|
|
@@ -190,16 +197,21 @@ export function defineEntityWriteHandler(
|
|
|
190
197
|
version: z.number(),
|
|
191
198
|
changes: buildUpdateSchema(entity),
|
|
192
199
|
});
|
|
193
|
-
handler = async (event, ctx) =>
|
|
200
|
+
handler = async (event, ctx) => {
|
|
201
|
+
const { runPreSave } = ctx;
|
|
194
202
|
// skipUnchanged (#464): API-driven updates diff against the stored
|
|
195
203
|
// row so a resubmitted-but-identical field doesn't force a fresh
|
|
196
204
|
// pii/encrypted ciphertext. Direct executor.update() callers (e.g.
|
|
197
205
|
// KEK-rotation, the user-data-rights #494 backfill) don't go through
|
|
198
206
|
// this handler and keep today's always-re-encrypt behavior, which
|
|
199
207
|
// they rely on to intentionally force a fresh event/ciphertext.
|
|
200
|
-
executor.update(event.payload as UpdatePayload, event.user, ctx.db, {
|
|
208
|
+
return executor.update(event.payload as UpdatePayload, event.user, ctx.db, {
|
|
201
209
|
skipUnchanged: true,
|
|
210
|
+
preSave:
|
|
211
|
+
runPreSave &&
|
|
212
|
+
((changes, previous, isNew) => runPreSave(event.type, changes, previous, isNew)),
|
|
202
213
|
}); // @cast-boundary engine-payload
|
|
214
|
+
};
|
|
203
215
|
break;
|
|
204
216
|
case "delete":
|
|
205
217
|
schema = idSchema;
|
|
@@ -154,7 +154,7 @@ export async function buildHandlerContext(
|
|
|
154
154
|
afterCommitHooks?: AfterCommitHook[],
|
|
155
155
|
includeDeleted?: boolean,
|
|
156
156
|
): Promise<HandlerContext> {
|
|
157
|
-
const { registry, appContext: context, effectiveFeatures, jobRunner } = ctx;
|
|
157
|
+
const { registry, appContext: context, effectiveFeatures, jobRunner, lifecycle } = ctx;
|
|
158
158
|
const isSystem = registry.isHandlerSystemScoped(type);
|
|
159
159
|
// The outer dispatcher receives a DbConnection from the server/stack;
|
|
160
160
|
// AppContext's `db` union also allows TenantDb (for downstream hook calls),
|
|
@@ -537,6 +537,18 @@ export async function buildHandlerContext(
|
|
|
537
537
|
notify,
|
|
538
538
|
...(config && { config }),
|
|
539
539
|
...(files && { files }),
|
|
540
|
+
// preSave hooks need `changes`/`previous`/`isNew`, which only exist once
|
|
541
|
+
// a handler actually starts building its write — bound here so entity
|
|
542
|
+
// CRUD handlers (entity-handlers.ts) can forward it to the executor
|
|
543
|
+
// (kumiko-framework#1672).
|
|
544
|
+
...(lifecycle && {
|
|
545
|
+
runPreSave: (
|
|
546
|
+
handlerName: string,
|
|
547
|
+
changes: Record<string, unknown>,
|
|
548
|
+
previous: Readonly<Record<string, unknown>>,
|
|
549
|
+
isNew: boolean,
|
|
550
|
+
) => lifecycle.runPreSave(handlerName, changes, previous, isNew, context),
|
|
551
|
+
}),
|
|
540
552
|
tracer,
|
|
541
553
|
metrics,
|
|
542
554
|
tz,
|