@cosmicdrift/kumiko-framework 0.173.0 → 0.174.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +3 -3
- package/src/__tests__/schema-cli.integration.test.ts +18 -0
- package/src/api/__tests__/request-id-middleware.test.ts +73 -0
- package/src/api/request-id-middleware.ts +13 -2
- package/src/api/sse-broker.ts +9 -6
- package/src/crypto/__tests__/kms-wiring.test.ts +6 -0
- package/src/crypto/kms-wiring.ts +5 -0
- package/src/db/__tests__/eagerload.integration.test.ts +119 -1
- package/src/db/__tests__/event-store-executor-write-verbs.integration.test.ts +27 -0
- package/src/db/__tests__/migrate-runner.test.ts +4 -0
- package/src/db/eagerload.ts +58 -8
- package/src/db/event-store-executor-write.ts +86 -14
- package/src/engine/__tests__/boot-validator.test.ts +87 -1
- package/src/engine/__tests__/entity-presave-wiring.integration.test.ts +53 -1
- package/src/engine/__tests__/schema-builder.test.ts +29 -0
- package/src/engine/__tests__/store-table.test.ts +15 -0
- package/src/engine/boot-validator/screens.ts +71 -27
- package/src/engine/field-access.ts +17 -3
- package/src/engine/schema-builder.ts +4 -1
- package/src/entrypoint/__tests__/split-deploy.integration.test.ts +28 -0
- package/src/jobs/__tests__/scheduler-id.test.ts +1 -1
- package/src/pipeline/dispatch-shared.ts +11 -3
- package/src/pipeline/dispatch-stream.ts +3 -6
- package/src/pipeline/system-hooks.ts +4 -4
- package/src/search/__tests__/reindex-entity.integration.test.ts +97 -1
- package/src/search/reindex-entity.ts +15 -2
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { describe, expect, test } from "bun:test";
|
|
1
|
+
import { describe, expect, spyOn, test } from "bun:test";
|
|
2
2
|
import { z } from "zod";
|
|
3
3
|
import type { SchemaTable } from "../../db/dialect";
|
|
4
4
|
import { table, text } from "../../db/dialect";
|
|
@@ -590,6 +590,31 @@ describe("boot-validator", () => {
|
|
|
590
590
|
expect(() => validateBoot(features)).not.toThrow();
|
|
591
591
|
});
|
|
592
592
|
|
|
593
|
+
test("warns when a role is used by exactly one handler, reached through the real validateBoot wiring", () => {
|
|
594
|
+
const warnSpy = spyOn(console, "warn");
|
|
595
|
+
try {
|
|
596
|
+
const features = [
|
|
597
|
+
defineFeature("a", (r) => {
|
|
598
|
+
r.queryHandler("list", z.object({}), async () => [], {
|
|
599
|
+
access: { roles: ["OnlyHereRole"] },
|
|
600
|
+
});
|
|
601
|
+
}),
|
|
602
|
+
];
|
|
603
|
+
validateBoot(features);
|
|
604
|
+
// Not toHaveBeenCalledTimes(1): this file's tests share the process-global
|
|
605
|
+
// console.warn and run with the default concurrency (bunfig.toml) — other
|
|
606
|
+
// concurrently-running tests' own "role used by one handler" warnings can
|
|
607
|
+
// land on this spy too. Assert the wiring fired at least once instead.
|
|
608
|
+
expect(
|
|
609
|
+
warnSpy.mock.calls.some((call) =>
|
|
610
|
+
(call[0] as string | undefined)?.includes("OnlyHereRole"),
|
|
611
|
+
),
|
|
612
|
+
).toBe(true);
|
|
613
|
+
} finally {
|
|
614
|
+
warnSpy.mockRestore();
|
|
615
|
+
}
|
|
616
|
+
});
|
|
617
|
+
|
|
593
618
|
test("throws when a stream handler has no access rule", () => {
|
|
594
619
|
const features = [
|
|
595
620
|
defineFeature("a", (r) => {
|
|
@@ -2953,6 +2978,67 @@ describe("boot-validator — config key backing × scope", () => {
|
|
|
2953
2978
|
);
|
|
2954
2979
|
});
|
|
2955
2980
|
|
|
2981
|
+
// framework#1708: the same params-vs-update-mode check as entityList,
|
|
2982
|
+
// extended to projectionList rowActions (#1680 covered entityList only).
|
|
2983
|
+
test("projectionList rowAction: navigate with params to an entityEdit target + explicit entityId → Throw", () => {
|
|
2984
|
+
const feature = defineFeature("shop", (r) => {
|
|
2985
|
+
r.entity("product", createEntity({ fields: { name: createTextField() } }));
|
|
2986
|
+
r.screen({
|
|
2987
|
+
id: "product-projection",
|
|
2988
|
+
type: "projectionList",
|
|
2989
|
+
query: "shop:query:products",
|
|
2990
|
+
columns: ["name"],
|
|
2991
|
+
rowActions: [
|
|
2992
|
+
{
|
|
2993
|
+
kind: "navigate",
|
|
2994
|
+
id: "open",
|
|
2995
|
+
label: "actions.open",
|
|
2996
|
+
screen: "product-edit",
|
|
2997
|
+
entityId: "name",
|
|
2998
|
+
params: { pick: ["name"] },
|
|
2999
|
+
},
|
|
3000
|
+
],
|
|
3001
|
+
});
|
|
3002
|
+
r.screen({
|
|
3003
|
+
id: "product-edit",
|
|
3004
|
+
type: "entityEdit",
|
|
3005
|
+
entity: "product",
|
|
3006
|
+
layout: { sections: [{ columns: 1, fields: ["name"] }] },
|
|
3007
|
+
});
|
|
3008
|
+
});
|
|
3009
|
+
expect(() => validateBoot([feature])).toThrow(
|
|
3010
|
+
/\(projectionList\) rowAction "open".*resolves to UPDATE mode \(explicit entityId "name"\)/,
|
|
3011
|
+
);
|
|
3012
|
+
});
|
|
3013
|
+
|
|
3014
|
+
test("projectionList rowAction: navigate with params to an entityEdit-create target (no entityId) → no throw", () => {
|
|
3015
|
+
const feature = defineFeature("shop", (r) => {
|
|
3016
|
+
r.entity("product", createEntity({ fields: { name: createTextField() } }));
|
|
3017
|
+
r.screen({
|
|
3018
|
+
id: "product-projection",
|
|
3019
|
+
type: "projectionList",
|
|
3020
|
+
query: "shop:query:products",
|
|
3021
|
+
columns: ["name"],
|
|
3022
|
+
rowActions: [
|
|
3023
|
+
{
|
|
3024
|
+
kind: "navigate",
|
|
3025
|
+
id: "open",
|
|
3026
|
+
label: "actions.open",
|
|
3027
|
+
screen: "product-edit",
|
|
3028
|
+
params: { pick: ["name"] },
|
|
3029
|
+
},
|
|
3030
|
+
],
|
|
3031
|
+
});
|
|
3032
|
+
r.screen({
|
|
3033
|
+
id: "product-edit",
|
|
3034
|
+
type: "entityEdit",
|
|
3035
|
+
entity: "product",
|
|
3036
|
+
layout: { sections: [{ columns: 1, fields: ["name"] }] },
|
|
3037
|
+
});
|
|
3038
|
+
});
|
|
3039
|
+
expect(() => validateBoot([feature])).not.toThrow();
|
|
3040
|
+
});
|
|
3041
|
+
|
|
2956
3042
|
test("navigate with params targeting a cross-entity entityEdit screen (no explicit entityId) → no throw", () => {
|
|
2957
3043
|
// The issue's actual use case: a unit row navigates to "create contract"
|
|
2958
3044
|
// pre-filled with unitId. Different entity + no explicit entityId → the
|
|
@@ -8,6 +8,7 @@ import { asRawClient } from "../../db/query";
|
|
|
8
8
|
import { setupTestStack, type TestStack, TestUsers, unsafeCreateEntityTable } from "../../stack";
|
|
9
9
|
import { defineFeature } from "../define-feature";
|
|
10
10
|
import { createEntity, createTextField } from "../factories";
|
|
11
|
+
import { from } from "../ownership";
|
|
11
12
|
|
|
12
13
|
const contactEntity = createEntity({
|
|
13
14
|
table: "presave_wiring_contacts",
|
|
@@ -15,6 +16,12 @@ const contactEntity = createEntity({
|
|
|
15
16
|
firstName: createTextField({ required: true }),
|
|
16
17
|
lastName: createTextField({ required: true }),
|
|
17
18
|
displayName: createTextField(),
|
|
19
|
+
// authorId is never set by the client — only deriveAuthorId (a preSave
|
|
20
|
+
// hook) writes it. secretNote's ownership rule checks authorId, so
|
|
21
|
+
// create only succeeds if the hook ran BEFORE the field-ownership check
|
|
22
|
+
// (kumiko-framework#1672 — see also event-store-executor-write.ts).
|
|
23
|
+
authorId: createTextField(),
|
|
24
|
+
secretNote: createTextField({ access: { write: { User: from("user:id", "authorId") } } }),
|
|
18
25
|
},
|
|
19
26
|
});
|
|
20
27
|
|
|
@@ -30,6 +37,16 @@ const deriveDisplayName: import("../types").PreSaveHookFn = async (changes, ctx)
|
|
|
30
37
|
return { ...changes, displayName: `${first ?? ""} ${last ?? ""}`.trim() };
|
|
31
38
|
};
|
|
32
39
|
|
|
40
|
+
const deriveAuthorId: import("../types").PreSaveHookFn = async (changes) => ({
|
|
41
|
+
...changes,
|
|
42
|
+
authorId: TestUsers.user.id,
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
const THROWING_HOOK_MESSAGE = "business rule violated";
|
|
46
|
+
const throwOnPreSave: import("../types").PreSaveHookFn = async () => {
|
|
47
|
+
throw new Error(THROWING_HOOK_MESSAGE);
|
|
48
|
+
};
|
|
49
|
+
|
|
33
50
|
const contactFeature = defineFeature("presave-wiring", (r) => {
|
|
34
51
|
r.crud("contact", contactEntity, {
|
|
35
52
|
write: { access: { roles: ["User"] } },
|
|
@@ -41,17 +58,34 @@ const contactFeature = defineFeature("presave-wiring", (r) => {
|
|
|
41
58
|
// handlers, so both need their own target.
|
|
42
59
|
r.hook("preSave", "contact:create", deriveDisplayName);
|
|
43
60
|
r.hook("preSave", "contact:update", deriveDisplayName);
|
|
61
|
+
r.hook("preSave", "contact:create", deriveAuthorId);
|
|
62
|
+
r.hook("preSave", "contact:update", deriveAuthorId);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
const throwingEntity = createEntity({
|
|
66
|
+
table: "presave_wiring_throwing",
|
|
67
|
+
fields: { name: createTextField({ required: true }) },
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
const throwingFeature = defineFeature("presave-wiring-throw", (r) => {
|
|
71
|
+
r.crud("thing", throwingEntity, {
|
|
72
|
+
write: { access: { roles: ["User"] } },
|
|
73
|
+
read: { access: { openToAll: true } },
|
|
74
|
+
});
|
|
75
|
+
r.hook("preSave", "thing:create", throwOnPreSave);
|
|
44
76
|
});
|
|
45
77
|
|
|
46
78
|
const CREATE = "presave-wiring:write:contact:create";
|
|
47
79
|
const UPDATE = "presave-wiring:write:contact:update";
|
|
80
|
+
const THROWING_CREATE = "presave-wiring-throw:write:thing:create";
|
|
48
81
|
|
|
49
82
|
describe("preSave hooks — real dispatcher path (#1672)", () => {
|
|
50
83
|
let stack: TestStack;
|
|
51
84
|
|
|
52
85
|
beforeAll(async () => {
|
|
53
|
-
stack = await setupTestStack({ features: [contactFeature] });
|
|
86
|
+
stack = await setupTestStack({ features: [contactFeature, throwingFeature] });
|
|
54
87
|
await unsafeCreateEntityTable(stack.db, contactEntity);
|
|
88
|
+
await unsafeCreateEntityTable(stack.db, throwingEntity);
|
|
55
89
|
});
|
|
56
90
|
|
|
57
91
|
afterAll(async () => {
|
|
@@ -94,4 +128,22 @@ describe("preSave hooks — real dispatcher path (#1672)", () => {
|
|
|
94
128
|
expect(updated.data.displayName).toBe("Marc Kumiko");
|
|
95
129
|
expect(seenIsNew).toEqual([true, false]);
|
|
96
130
|
});
|
|
131
|
+
|
|
132
|
+
test("preSave runs before ownership checks: field authz sees the hook-derived owner id", async () => {
|
|
133
|
+
const res = await stack.http.write(
|
|
134
|
+
CREATE,
|
|
135
|
+
{ firstName: "Marc", lastName: "Ristone", secretNote: "psst" },
|
|
136
|
+
TestUsers.user,
|
|
137
|
+
);
|
|
138
|
+
expect(res.status).toBe(200);
|
|
139
|
+
const { data } = (await res.json()) as { data: { data: { secretNote: string } } };
|
|
140
|
+
expect(data.data.secretNote).toBe("psst");
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
test("a throwing preSave hook maps to a clean writeFailure, not a 500", async () => {
|
|
144
|
+
const res = await stack.http.write(THROWING_CREATE, { name: "x" }, TestUsers.user);
|
|
145
|
+
expect(res.status).toBe(422);
|
|
146
|
+
const body = (await res.json()) as { error?: { details?: { message?: string } } };
|
|
147
|
+
expect(body.error?.details?.message).toBe(THROWING_HOOK_MESSAGE);
|
|
148
|
+
});
|
|
97
149
|
});
|
|
@@ -131,6 +131,18 @@ describe("buildInsertSchema", () => {
|
|
|
131
131
|
valid: { age: 5 },
|
|
132
132
|
invalid: { age: 11 },
|
|
133
133
|
},
|
|
134
|
+
{
|
|
135
|
+
name: "integer field rejects a value outside Postgres int4 range (must 400, not crash the DB write)",
|
|
136
|
+
fields: { attempt: createNumberField({ integer: true }) },
|
|
137
|
+
valid: { attempt: 2147483647 },
|
|
138
|
+
invalid: { attempt: 2147483648 },
|
|
139
|
+
},
|
|
140
|
+
{
|
|
141
|
+
name: "integer field with explicit max narrower than int4 still enforces the explicit bound",
|
|
142
|
+
fields: { displayOrder: createNumberField({ integer: true, max: 100 }) },
|
|
143
|
+
valid: { displayOrder: 100 },
|
|
144
|
+
invalid: { displayOrder: 101 },
|
|
145
|
+
},
|
|
134
146
|
{
|
|
135
147
|
name: "date field",
|
|
136
148
|
fields: { born: createDateField() },
|
|
@@ -449,6 +461,23 @@ describe("buildInsertSchema", () => {
|
|
|
449
461
|
expect(schema.safeParse({ locale: "en" }).success).toBe(true);
|
|
450
462
|
expect(schema.safeParse({ locale: "xx" }).success).toBe(false);
|
|
451
463
|
});
|
|
464
|
+
|
|
465
|
+
// #1712: pin omitted-key + explicit undefined — ZodPipe/optin path for defaults
|
|
466
|
+
test("optional select with default applies when key omitted or undefined", () => {
|
|
467
|
+
const entity = createEntity({
|
|
468
|
+
table: "Test",
|
|
469
|
+
fields: {
|
|
470
|
+
locale: createSelectField({ options: ["de", "en", "fr"] as const, default: "de" }),
|
|
471
|
+
},
|
|
472
|
+
});
|
|
473
|
+
const schema = buildInsertSchema(entity);
|
|
474
|
+
const omitted = schema.safeParse({});
|
|
475
|
+
expect(omitted.success).toBe(true);
|
|
476
|
+
if (omitted.success) expect(omitted.data["locale"]).toBe("de");
|
|
477
|
+
const undef = schema.safeParse({ locale: undefined });
|
|
478
|
+
expect(undef.success).toBe(true);
|
|
479
|
+
if (undef.success) expect(undef.data["locale"]).toBe("de");
|
|
480
|
+
});
|
|
452
481
|
});
|
|
453
482
|
|
|
454
483
|
// --- Update schema (all partial) ---
|
|
@@ -84,6 +84,21 @@ describe("r.storeTable — declaration", () => {
|
|
|
84
84
|
).toThrow(/the "read_" prefix is reserved/);
|
|
85
85
|
});
|
|
86
86
|
|
|
87
|
+
// #1598: registration guard — plain literal meta bypasses defineUnmanagedTable
|
|
88
|
+
test("r.storeTable rejects a plain literal meta with reserved read_ prefix", () => {
|
|
89
|
+
const literalMeta = {
|
|
90
|
+
tableName: "read_rt_probe",
|
|
91
|
+
columns: [{ name: "id", pgType: "text" as const, notNull: true, primaryKey: true }],
|
|
92
|
+
source: "unmanaged" as const,
|
|
93
|
+
indexes: [],
|
|
94
|
+
};
|
|
95
|
+
expect(() =>
|
|
96
|
+
defineFeature("probe", (r) => {
|
|
97
|
+
r.storeTable(literalMeta, { reason: "test" });
|
|
98
|
+
}),
|
|
99
|
+
).toThrow(/the "read_" prefix is reserved/);
|
|
100
|
+
});
|
|
101
|
+
|
|
87
102
|
test("accepts valid registration and stores meta + reason", () => {
|
|
88
103
|
const feature = defineFeature("probe", (r) => {
|
|
89
104
|
r.storeTable(probeMeta, {
|
|
@@ -22,6 +22,47 @@ import type {
|
|
|
22
22
|
ToolbarAction,
|
|
23
23
|
} from "../types/screen";
|
|
24
24
|
|
|
25
|
+
// Tier 2.7e navigate rowAction → target-screen params validity. Shared by
|
|
26
|
+
// entityList and projectionList (framework#1708) — projectionList has no
|
|
27
|
+
// `screen.entity`, so there's no same-entity row["id"] auto-fill case: any
|
|
28
|
+
// entityEdit target without an explicit entityId reaches create there.
|
|
29
|
+
function validateRowActionNavigateParams(
|
|
30
|
+
featureName: string,
|
|
31
|
+
screenId: string,
|
|
32
|
+
screenType: "entityList" | "projectionList",
|
|
33
|
+
screenEntity: string | undefined,
|
|
34
|
+
action: RowAction,
|
|
35
|
+
target: { readonly featureName: string; readonly screen: ScreenDefinition } | undefined,
|
|
36
|
+
): void {
|
|
37
|
+
// skip: not a navigate-with-params action — nothing to validate here.
|
|
38
|
+
if (action.kind !== "navigate" || action.params === undefined) return;
|
|
39
|
+
// skip: unresolvable/custom target already reported (or exempt) elsewhere.
|
|
40
|
+
if (target === undefined || target.screen.type === "custom") return;
|
|
41
|
+
|
|
42
|
+
const isEntityEditUpdate =
|
|
43
|
+
target.screen.type === "entityEdit" &&
|
|
44
|
+
(action.entityId !== undefined ||
|
|
45
|
+
(screenEntity !== undefined && target.screen.entity === screenEntity));
|
|
46
|
+
if (
|
|
47
|
+
(target.screen.type !== "actionForm" && target.screen.type !== "entityEdit") ||
|
|
48
|
+
isEntityEditUpdate
|
|
49
|
+
) {
|
|
50
|
+
const reason = isEntityEditUpdate
|
|
51
|
+
? `resolves to UPDATE mode (${
|
|
52
|
+
action.entityId !== undefined
|
|
53
|
+
? `explicit entityId "${action.entityId}"`
|
|
54
|
+
: `same entity "${screenEntity}" auto-fills row["id"]`
|
|
55
|
+
})`
|
|
56
|
+
: `screen type "${target.screen.type}"`;
|
|
57
|
+
throw new Error(
|
|
58
|
+
`[Feature ${featureName}] Screen "${screenId}" (${screenType}) rowAction "${action.id}" ` +
|
|
59
|
+
`sets params on navigate-target "${action.screen}" which ${reason} — only actionForm ` +
|
|
60
|
+
`and entityEdit-create targets read URL search params as initial values. Remove the ` +
|
|
61
|
+
`params extractor or retarget to an actionForm / cross-entity entityEdit-create screen.`,
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
25
66
|
// --- Screen validation ---
|
|
26
67
|
//
|
|
27
68
|
// For every r.screen() declaration check what's locally knowable at boot:
|
|
@@ -169,6 +210,28 @@ export function validateScreens(
|
|
|
169
210
|
for (const col of screen.columns) {
|
|
170
211
|
validateColumnRendererForm(feature.name, screenId, normalizeListColumn(col));
|
|
171
212
|
}
|
|
213
|
+
if (screen.rowActions !== undefined) {
|
|
214
|
+
for (const action of screen.rowActions) {
|
|
215
|
+
if (action.kind === "navigate") {
|
|
216
|
+
const candidateQn = qualifyEntityName(feature.name, "screen", action.screen);
|
|
217
|
+
if (!allScreenQns.has(candidateQn) && !navTargetShortIds.has(action.screen)) {
|
|
218
|
+
throw new Error(
|
|
219
|
+
`[Feature ${feature.name}] Screen "${screenId}" (projectionList) rowAction "${action.id}" ` +
|
|
220
|
+
`navigate-target "${action.screen}" does not resolve to a registered screen in any feature.`,
|
|
221
|
+
);
|
|
222
|
+
}
|
|
223
|
+
const target = screensByShortId.get(action.screen)?.[0];
|
|
224
|
+
validateRowActionNavigateParams(
|
|
225
|
+
feature.name,
|
|
226
|
+
screenId,
|
|
227
|
+
"projectionList",
|
|
228
|
+
undefined,
|
|
229
|
+
action,
|
|
230
|
+
target,
|
|
231
|
+
);
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
}
|
|
172
235
|
continue;
|
|
173
236
|
}
|
|
174
237
|
|
|
@@ -590,33 +653,14 @@ export function validateScreens(
|
|
|
590
653
|
// explicit entityId) a same-entity target gets row["id"] auto-
|
|
591
654
|
// injected — only a cross-entity target with no explicit
|
|
592
655
|
// entityId reaches create.
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
if (
|
|
602
|
-
(target.screen.type !== "actionForm" && target.screen.type !== "entityEdit") ||
|
|
603
|
-
isEntityEditUpdate
|
|
604
|
-
) {
|
|
605
|
-
const reason = isEntityEditUpdate
|
|
606
|
-
? `resolves to UPDATE mode (${
|
|
607
|
-
action.entityId !== undefined
|
|
608
|
-
? `explicit entityId "${action.entityId}"`
|
|
609
|
-
: `same entity "${screen.entity}" auto-fills row["id"]`
|
|
610
|
-
})`
|
|
611
|
-
: `screen type "${target.screen.type}"`;
|
|
612
|
-
throw new Error(
|
|
613
|
-
`[Feature ${feature.name}] Screen "${screenId}" (entityList) rowAction "${action.id}" ` +
|
|
614
|
-
`sets params on navigate-target "${action.screen}" which ${reason} — only actionForm ` +
|
|
615
|
-
`and entityEdit-create targets read URL search params as initial values. Remove the ` +
|
|
616
|
-
`params extractor or retarget to an actionForm / cross-entity entityEdit-create screen.`,
|
|
617
|
-
);
|
|
618
|
-
}
|
|
619
|
-
}
|
|
656
|
+
validateRowActionNavigateParams(
|
|
657
|
+
feature.name,
|
|
658
|
+
screenId,
|
|
659
|
+
"entityList",
|
|
660
|
+
screen.entity,
|
|
661
|
+
action,
|
|
662
|
+
target,
|
|
663
|
+
);
|
|
620
664
|
} else {
|
|
621
665
|
if (!allWriteHandlerQns.has(action.handler)) {
|
|
622
666
|
throw new Error(
|
|
@@ -98,15 +98,29 @@ export function checkWriteFieldRoles(
|
|
|
98
98
|
// row. For creates, pass oldRow = undefined; the check degenerates to a
|
|
99
99
|
// newRow-only evaluation.
|
|
100
100
|
//
|
|
101
|
+
// `submittedChanges` and `rowContext` are deliberately separate (fw#1685):
|
|
102
|
+
// - `submittedChanges` drives WHICH fields get checked — only what the user
|
|
103
|
+
// actually wrote in the request. A preSave-hook-derived field the user
|
|
104
|
+
// never touched (e.g. a system hook setting `assignedTo`) must not be
|
|
105
|
+
// field-ownership-checked against the user at all.
|
|
106
|
+
// - `rowContext` drives what a checked field's rule is evaluated AGAINST —
|
|
107
|
+
// this needs the full post-hook row, because an ownership rule can
|
|
108
|
+
// reference a DIFFERENT column that only a hook populates (kumiko-
|
|
109
|
+
// framework#1672: a hook derives `authorId`, a user-submitted `secretNote`
|
|
110
|
+
// field's rule is `from("user:id", "authorId")` — the check needs the
|
|
111
|
+
// hook-derived `authorId` in scope even though the user only wrote
|
|
112
|
+
// `secretNote`). Defaults to `submittedChanges` when omitted.
|
|
113
|
+
//
|
|
101
114
|
// Returns the denied field name for the caller to wrap into an
|
|
102
115
|
// `ownership_denied` error with scope: "field", or null if all fields pass.
|
|
103
116
|
export function checkWriteFieldOwnership(
|
|
104
117
|
entity: EntityDefinition,
|
|
105
|
-
|
|
118
|
+
submittedChanges: Readonly<Record<string, unknown>>,
|
|
106
119
|
user: SessionUser,
|
|
107
120
|
oldRow?: Readonly<Record<string, unknown>>,
|
|
121
|
+
rowContext: Readonly<Record<string, unknown>> = submittedChanges,
|
|
108
122
|
): string | null {
|
|
109
|
-
for (const key of Object.keys(
|
|
123
|
+
for (const key of Object.keys(submittedChanges)) {
|
|
110
124
|
const field = entity.fields[key];
|
|
111
125
|
if (!field) continue;
|
|
112
126
|
|
|
@@ -120,7 +134,7 @@ export function checkWriteFieldOwnership(
|
|
|
120
134
|
const hasOwnershipRule = Object.values(accessMap).some((r) => r !== "all");
|
|
121
135
|
if (!hasOwnershipRule) continue;
|
|
122
136
|
|
|
123
|
-
const newRow: Record<string, unknown> = { ...(oldRow ?? {}), ...
|
|
137
|
+
const newRow: Record<string, unknown> = { ...(oldRow ?? {}), ...rowContext };
|
|
124
138
|
const effectiveOld = oldRow ?? newRow; // create: compare against newRow
|
|
125
139
|
|
|
126
140
|
if (!userCanWriteFieldRow(user, accessMap, effectiveOld, newRow)) {
|
|
@@ -109,7 +109,10 @@ export function fieldToZod(field: FieldDefinition, currencies: readonly string[]
|
|
|
109
109
|
}
|
|
110
110
|
case "number": {
|
|
111
111
|
let schema = z.number();
|
|
112
|
-
|
|
112
|
+
// `integer: true` maps to a Postgres int4 column (entity-table-meta.ts)
|
|
113
|
+
// — bound it here so an out-of-range write fails loud (400) at the
|
|
114
|
+
// schema boundary instead of dying in Postgres (22003 → 500).
|
|
115
|
+
if (field.integer) schema = schema.int().min(-2147483648).max(2147483647);
|
|
113
116
|
if (field.min !== undefined) schema = schema.min(field.min);
|
|
114
117
|
if (field.max !== undefined) schema = schema.max(field.max);
|
|
115
118
|
return field.default !== undefined ? schema.default(field.default) : schema;
|
|
@@ -32,6 +32,11 @@ const splitFeature = defineFeature("split", (r) => {
|
|
|
32
32
|
});
|
|
33
33
|
});
|
|
34
34
|
|
|
35
|
+
// Consumed by the worker's OWN eventDispatcher after worker.start() — proves
|
|
36
|
+
// the write→afterCommit→MSP chain runs end-to-end inside the worker lane,
|
|
37
|
+
// not just that the write itself lands in the event store (framework#1720).
|
|
38
|
+
const consumedNotes: string[] = [];
|
|
39
|
+
|
|
35
40
|
const workerWriteFeature = defineFeature("workerWrite", (r) => {
|
|
36
41
|
const noted = r.defineEvent("noted", z.object({ note: z.string() }), { version: 1 });
|
|
37
42
|
r.writeHandler(
|
|
@@ -48,8 +53,24 @@ const workerWriteFeature = defineFeature("workerWrite", (r) => {
|
|
|
48
53
|
},
|
|
49
54
|
{ access: { openToAll: true } },
|
|
50
55
|
);
|
|
56
|
+
r.multiStreamProjection({
|
|
57
|
+
name: "consume-notes",
|
|
58
|
+
apply: {
|
|
59
|
+
[noted.name]: async (event) => {
|
|
60
|
+
consumedNotes.push((event.payload as { note: string }).note);
|
|
61
|
+
},
|
|
62
|
+
},
|
|
63
|
+
});
|
|
51
64
|
});
|
|
52
65
|
|
|
66
|
+
async function waitForCondition(check: () => boolean, timeoutMs = 5000): Promise<void> {
|
|
67
|
+
const deadline = Date.now() + timeoutMs;
|
|
68
|
+
while (!check()) {
|
|
69
|
+
if (Date.now() > deadline) throw new Error("waitForCondition: timed out");
|
|
70
|
+
await new Promise((resolve) => setTimeout(resolve, 25));
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
53
74
|
const JWT = "split-deploy-test-secret-must-be-32-chars!!";
|
|
54
75
|
|
|
55
76
|
// Per-test queue-name with a random suffix. Date.now() alone collided
|
|
@@ -132,6 +153,8 @@ describe("entrypoint factories", () => {
|
|
|
132
153
|
queueNamePrefix: uniquePrefix("split-dispatch"),
|
|
133
154
|
});
|
|
134
155
|
|
|
156
|
+
consumedNotes.length = 0;
|
|
157
|
+
await worker.start();
|
|
135
158
|
try {
|
|
136
159
|
const result = await worker.dispatcher.write(
|
|
137
160
|
"worker-write:write:note",
|
|
@@ -147,6 +170,11 @@ describe("entrypoint factories", () => {
|
|
|
147
170
|
expect((rows[0] as { payload: { note: string } }).payload.note).toBe(
|
|
148
171
|
"written from the worker",
|
|
149
172
|
);
|
|
173
|
+
|
|
174
|
+
// The worker's own eventDispatcher (started above) picks the event
|
|
175
|
+
// back up and runs the MSP — proves the afterCommit/MSP chain works
|
|
176
|
+
// inside the worker lane, not just that the write itself succeeded.
|
|
177
|
+
await waitForCondition(() => consumedNotes.includes("written from the worker"));
|
|
150
178
|
} finally {
|
|
151
179
|
await worker.stop();
|
|
152
180
|
}
|
|
@@ -9,7 +9,7 @@ describe("schedulerIdForJobName", () => {
|
|
|
9
9
|
const id = schedulerIdForJobName("publicstatus:job:uptime-probe");
|
|
10
10
|
expect(id).toBe("scheduler-publicstatus-job-uptime-probe");
|
|
11
11
|
expect(id.includes(":")).toBe(false);
|
|
12
|
-
expect(`repeat:${id}:1784992080000`.split(":").length).
|
|
12
|
+
expect(`repeat:${id}:1784992080000`.split(":").length).toBe(3);
|
|
13
13
|
});
|
|
14
14
|
|
|
15
15
|
test("still collapses dotted QNs", () => {
|
|
@@ -60,7 +60,7 @@ import {
|
|
|
60
60
|
observabilityContext,
|
|
61
61
|
} from "../observability";
|
|
62
62
|
import { buildBucketKey } from "../rate-limit";
|
|
63
|
-
import { createTzContext } from "../time";
|
|
63
|
+
import { createTzContext, isValidIanaTimeZone } from "../time";
|
|
64
64
|
import { appendDomainEventCore } from "./append-event-core";
|
|
65
65
|
import { resolveAuthClaims as runAuthClaimsResolver } from "./auth-claims-resolver";
|
|
66
66
|
import { executeQuery } from "./dispatch-query";
|
|
@@ -523,10 +523,18 @@ export async function buildHandlerContext(
|
|
|
523
523
|
// tenant (createTzContext's own default). An app-injected GeoTzProvider
|
|
524
524
|
// (context.geoTzProvider) feeds ctx.tz.fromCoordinates / fromAddress.
|
|
525
525
|
const tenantTz = config !== undefined ? await config("tenant:config:timezone") : undefined;
|
|
526
|
+
// Guarded against garbage: an unvalidated string here (free-form config
|
|
527
|
+
// key, legacy JWT claim predating validation) blows up every ctx.tz call
|
|
528
|
+
// for the whole tenant with a RangeError. Fall back to UTC/tenant instead
|
|
529
|
+
// of trusting the raw value.
|
|
530
|
+
const safeTenantTz =
|
|
531
|
+
typeof tenantTz === "string" && isValidIanaTimeZone(tenantTz) ? tenantTz : "UTC";
|
|
532
|
+
const safeUserTz =
|
|
533
|
+
user.timezone !== undefined && isValidIanaTimeZone(user.timezone) ? user.timezone : undefined;
|
|
526
534
|
const tz = createTzContext({
|
|
527
535
|
...(context.geoTzProvider !== undefined ? { geoTz: context.geoTzProvider } : {}),
|
|
528
|
-
tenant:
|
|
529
|
-
...(
|
|
536
|
+
tenant: safeTenantTz,
|
|
537
|
+
...(safeUserTz !== undefined && { user: safeUserTz }),
|
|
530
538
|
});
|
|
531
539
|
|
|
532
540
|
return {
|
|
@@ -59,12 +59,9 @@ async function* executeStreamInner(
|
|
|
59
59
|
const invalidated = new Promise<void>((resolve) => {
|
|
60
60
|
resolveInvalidated = resolve;
|
|
61
61
|
});
|
|
62
|
-
const unsubscribeAccessInvalidation = ctx.sseBroker?.subscribeAccessInvalidation
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
resolveInvalidated?.();
|
|
66
|
-
},
|
|
67
|
-
);
|
|
62
|
+
const unsubscribeAccessInvalidation = ctx.sseBroker?.subscribeAccessInvalidation(user.id, () => {
|
|
63
|
+
resolveInvalidated?.();
|
|
64
|
+
});
|
|
68
65
|
|
|
69
66
|
let iterator: AsyncIterator<unknown> | undefined;
|
|
70
67
|
// When access is revoked mid-pull, `iterator.next()` is still in flight.
|
|
@@ -94,7 +94,7 @@ export function createSearchEventConsumer(
|
|
|
94
94
|
// #1610 — subject-annotated searchable fields are ciphertext in the event
|
|
95
95
|
// payload; decrypt into the derived index only. No KMS → omit ciphertext
|
|
96
96
|
// values rather than indexing blobs.
|
|
97
|
-
async function decryptSearchableSubjectFields(
|
|
97
|
+
export async function decryptSearchableSubjectFields(
|
|
98
98
|
entityName: string,
|
|
99
99
|
state: Record<string, unknown>,
|
|
100
100
|
registry: Registry,
|
|
@@ -116,7 +116,7 @@ async function decryptSearchableSubjectFields(
|
|
|
116
116
|
});
|
|
117
117
|
}
|
|
118
118
|
|
|
119
|
-
function hasErasedSearchableSubjectField(
|
|
119
|
+
export function hasErasedSearchableSubjectField(
|
|
120
120
|
entityName: string,
|
|
121
121
|
state: Record<string, unknown>,
|
|
122
122
|
registry: Registry,
|
|
@@ -416,7 +416,7 @@ export function createAccessInvalidationEventConsumer(sseBroker: SseBroker): Eve
|
|
|
416
416
|
// poison would otherwise permanently stop access-invalidation for
|
|
417
417
|
// every user behind one bad row).
|
|
418
418
|
if (typeof userId !== "string" || userId.length === 0) return;
|
|
419
|
-
sseBroker.publishAccessInvalidation
|
|
419
|
+
sseBroker.publishAccessInvalidation(userId);
|
|
420
420
|
}
|
|
421
421
|
|
|
422
422
|
if (
|
|
@@ -427,7 +427,7 @@ export function createAccessInvalidationEventConsumer(sseBroker: SseBroker): Eve
|
|
|
427
427
|
// skip: previous snapshot missing/malformed userId — same fail-open
|
|
428
428
|
// reasoning as above.
|
|
429
429
|
if (userId === undefined) return;
|
|
430
|
-
sseBroker.publishAccessInvalidation
|
|
430
|
+
sseBroker.publishAccessInvalidation(userId);
|
|
431
431
|
}
|
|
432
432
|
},
|
|
433
433
|
};
|