@cosmicdrift/kumiko-framework 0.288.0 → 0.290.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 +4 -4
- package/src/__tests__/field-access.integration.test.ts +7 -3
- package/src/__tests__/ownership-where-write-path.integration.test.ts +1 -1
- package/src/__tests__/ownership.integration.test.ts +1 -1
- package/src/api/__tests__/server-boot-guards.test.ts +42 -0
- package/src/api/request-context.ts +28 -0
- package/src/api/server.ts +30 -4
- package/src/changes.json +75 -0
- package/src/compliance/__tests__/sub-processors.test.ts +10 -0
- package/src/compliance/sub-processors.ts +14 -1
- package/src/crypto/__tests__/blind-index.test.ts +1 -1
- package/src/crypto/__tests__/kek-source.test.ts +298 -0
- package/src/crypto/__tests__/pii-field-encryption.test.ts +2 -2
- package/src/crypto/__tests__/subject-resolver.test.ts +2 -2
- package/src/crypto/index.ts +3 -0
- package/src/crypto/kek-source.ts +189 -0
- package/src/crypto/kms-wiring.ts +20 -0
- package/src/db/__tests__/blind-index.integration.test.ts +1 -1
- package/src/db/__tests__/eagerload.integration.test.ts +12 -2
- package/src/db/__tests__/entity-field-encryption.test.ts +2 -2
- package/src/db/__tests__/event-store-executor-context.pii-roundtrip.test.ts +1 -1
- package/src/db/__tests__/event-store-executor-money-rehydrate.integration.test.ts +7 -2
- package/src/db/__tests__/event-store-executor-write-verbs.integration.test.ts +194 -1
- package/src/db/__tests__/event-store-executor.integration.test.ts +15 -5
- package/src/db/__tests__/list-filter-field-access.integration.test.ts +6 -2
- package/src/db/__tests__/tenant-db-declared-unsafe-raw.test.ts +60 -1
- package/src/db/event-store-executor-context.ts +79 -0
- package/src/db/event-store-executor-write.ts +42 -5
- package/src/db/index.ts +1 -0
- package/src/db/tenant-db.ts +8 -1
- package/src/engine/__tests__/boot-validator-action-wiring.test.ts +32 -0
- package/src/engine/__tests__/boot-validator-boot-check.test.ts +1 -1
- package/src/engine/__tests__/boot-validator-i18n-keys.test.ts +56 -0
- package/src/engine/__tests__/boot-validator-pii-retention.test.ts +26 -15
- package/src/engine/__tests__/entity-presave-wiring.integration.test.ts +6 -2
- package/src/engine/__tests__/factories-long-text.test.ts +6 -1
- package/src/engine/__tests__/required-surface-keys.test.ts +196 -0
- package/src/engine/boot-validator/__tests__/anonymous-rate-limit-required.test.ts +63 -0
- package/src/engine/boot-validator/__tests__/record-owned.test.ts +12 -2
- package/src/engine/boot-validator/action-wiring.ts +2 -1
- package/src/engine/boot-validator/entity-handler.ts +10 -4
- package/src/engine/boot-validator/screens.ts +4 -12
- package/src/engine/extensions/storage-provider.ts +17 -2
- package/src/engine/extensions/tenant-data.ts +9 -0
- package/src/engine/factories.ts +2 -0
- package/src/engine/index.ts +1 -0
- package/src/engine/qualified-name.ts +9 -0
- package/src/engine/screen-helpers.ts +17 -0
- package/src/errors/classes.ts +24 -0
- package/src/errors/index.ts +3 -0
- package/src/errors/member-resolution.ts +12 -0
- package/src/event-store/__tests__/backfill-pii.integration.test.ts +2 -2
- package/src/event-store/__tests__/event-attribution.integration.test.ts +186 -0
- package/src/event-store/__tests__/provenance-append.integration.test.ts +33 -1
- package/src/event-store/event-store.ts +23 -2
- package/src/event-store/provenance-append.ts +9 -1
- package/src/files/__tests__/file-handle.test.ts +28 -1
- package/src/files/file-handle.ts +26 -7
- package/src/files/index.ts +1 -1
- package/src/i18n/required-surface-keys.ts +24 -12
- package/src/jobs/job-runner.ts +10 -2
- package/src/pipeline/__tests__/member-resolution-read-only.test.ts +79 -0
- package/src/pipeline/active-membership.ts +10 -4
- package/src/pipeline/append-event-core.ts +2 -11
- package/src/pipeline/dispatch-shared.ts +31 -18
- package/src/pipeline/dispatch-stream.ts +8 -3
- package/src/pipeline/dispatch-write.ts +3 -2
- package/src/pipeline/event-dispatcher-delivery.ts +15 -3
- package/src/pipeline/event-dispatcher.ts +18 -5
- package/src/pipeline/member-read-only-transaction.ts +2 -6
- package/src/stack/__tests__/ownership-boot-guard.integration.test.ts +1 -1
- package/src/stack/table-helpers.ts +6 -0
- package/src/testing/__tests__/e2e-generator.test.ts +50 -0
- package/src/testing/e2e-generator.ts +4 -3
- package/src/ui-types/index.ts +2 -0
|
@@ -40,6 +40,17 @@ import type { LongTextFieldDef, TextFieldDef } from "../types";
|
|
|
40
40
|
const rawField = <T>(f: T) => f as unknown as TextFieldDef;
|
|
41
41
|
const rawLongTextField = <T>(f: T) => f as unknown as LongTextFieldDef;
|
|
42
42
|
|
|
43
|
+
// The heuristics under test only fire without a personal stance; after #2810
|
|
44
|
+
// the factories can no longer produce that shape.
|
|
45
|
+
const unannotatedText: TextFieldDef = {
|
|
46
|
+
type: "text",
|
|
47
|
+
maxLength: 200,
|
|
48
|
+
required: false,
|
|
49
|
+
searchable: false,
|
|
50
|
+
sortable: false,
|
|
51
|
+
};
|
|
52
|
+
const unannotatedLongText: LongTextFieldDef = { type: "longText", required: false };
|
|
53
|
+
|
|
43
54
|
// Stubt einen leeren `<entity>:list`-Query-Handler damit der reference-
|
|
44
55
|
// Field-Boot-Validator den Audit-Fix-#2-Check durchläßt. Wird gebraucht
|
|
45
56
|
// wenn ein Test ein reference-Feld benutzt — sonst liefert der validator
|
|
@@ -136,7 +147,7 @@ describe("validateBoot — PII annotations", () => {
|
|
|
136
147
|
createEntity({
|
|
137
148
|
fields: {
|
|
138
149
|
confused: rawField({
|
|
139
|
-
...
|
|
150
|
+
...unannotatedText,
|
|
140
151
|
pii: true,
|
|
141
152
|
tenantOwned: true,
|
|
142
153
|
}),
|
|
@@ -153,7 +164,7 @@ describe("validateBoot — PII annotations", () => {
|
|
|
153
164
|
"vault",
|
|
154
165
|
createEntity({
|
|
155
166
|
fields: {
|
|
156
|
-
apiToken:
|
|
167
|
+
apiToken: { ...unannotatedText, sensitive: true },
|
|
157
168
|
},
|
|
158
169
|
}),
|
|
159
170
|
);
|
|
@@ -188,7 +199,7 @@ describe("validateBoot — PII annotations", () => {
|
|
|
188
199
|
"vault",
|
|
189
200
|
createEntity({
|
|
190
201
|
fields: {
|
|
191
|
-
apiToken:
|
|
202
|
+
apiToken: { ...unannotatedText, sensitive: true, encrypted: true },
|
|
192
203
|
},
|
|
193
204
|
}),
|
|
194
205
|
);
|
|
@@ -228,7 +239,7 @@ describe("validateBoot — PII annotations", () => {
|
|
|
228
239
|
personal: { of: "authorId" },
|
|
229
240
|
find: "none",
|
|
230
241
|
}),
|
|
231
|
-
authorId:
|
|
242
|
+
authorId: { ...unannotatedText },
|
|
232
243
|
},
|
|
233
244
|
}),
|
|
234
245
|
);
|
|
@@ -258,7 +269,7 @@ describe("validateBoot — PII annotations", () => {
|
|
|
258
269
|
|
|
259
270
|
test("userOwned.ownerField referencing non-user entity warns", () => {
|
|
260
271
|
const feature = defineFeature("test", (r) => {
|
|
261
|
-
r.entity("employee", createEntity({ fields: { name:
|
|
272
|
+
r.entity("employee", createEntity({ fields: { name: { ...unannotatedText } } }));
|
|
262
273
|
stubListHandler(r, "employee");
|
|
263
274
|
r.entity(
|
|
264
275
|
"personalNote",
|
|
@@ -286,7 +297,7 @@ describe("validateBoot — PII annotations", () => {
|
|
|
286
297
|
"thing",
|
|
287
298
|
createEntity({
|
|
288
299
|
fields: {
|
|
289
|
-
email:
|
|
300
|
+
email: { ...unannotatedText },
|
|
290
301
|
},
|
|
291
302
|
}),
|
|
292
303
|
);
|
|
@@ -304,7 +315,7 @@ describe("validateBoot — PII annotations", () => {
|
|
|
304
315
|
"thing",
|
|
305
316
|
createEntity({
|
|
306
317
|
fields: {
|
|
307
|
-
body:
|
|
318
|
+
body: { ...unannotatedLongText },
|
|
308
319
|
},
|
|
309
320
|
}),
|
|
310
321
|
);
|
|
@@ -322,7 +333,7 @@ describe("validateBoot — PII annotations", () => {
|
|
|
322
333
|
"thing",
|
|
323
334
|
createEntity({
|
|
324
335
|
fields: {
|
|
325
|
-
authorId:
|
|
336
|
+
authorId: { ...unannotatedText },
|
|
326
337
|
},
|
|
327
338
|
}),
|
|
328
339
|
);
|
|
@@ -563,7 +574,7 @@ describe("validateBoot — PII annotations", () => {
|
|
|
563
574
|
"product",
|
|
564
575
|
createEntity({
|
|
565
576
|
fields: {
|
|
566
|
-
name:
|
|
577
|
+
name: { ...unannotatedText },
|
|
567
578
|
},
|
|
568
579
|
}),
|
|
569
580
|
);
|
|
@@ -581,7 +592,7 @@ describe("validateBoot — PII annotations", () => {
|
|
|
581
592
|
"person",
|
|
582
593
|
createEntity({
|
|
583
594
|
fields: {
|
|
584
|
-
displayName:
|
|
595
|
+
displayName: { ...unannotatedText },
|
|
585
596
|
},
|
|
586
597
|
}),
|
|
587
598
|
);
|
|
@@ -626,7 +637,7 @@ describe("validateBoot — retention", () => {
|
|
|
626
637
|
"auditEvent",
|
|
627
638
|
createEntity({
|
|
628
639
|
fields: {
|
|
629
|
-
note:
|
|
640
|
+
note: { ...unannotatedText },
|
|
630
641
|
},
|
|
631
642
|
retention: { keepFor: "1y", strategy: "hardDelete", reference: "createdAt" },
|
|
632
643
|
}),
|
|
@@ -641,7 +652,7 @@ describe("validateBoot — retention", () => {
|
|
|
641
652
|
"thing",
|
|
642
653
|
createEntity({
|
|
643
654
|
fields: {
|
|
644
|
-
note:
|
|
655
|
+
note: { ...unannotatedText },
|
|
645
656
|
},
|
|
646
657
|
retention: { keepFor: "30d", strategy: "hardDelete", reference: "ghostField" },
|
|
647
658
|
}),
|
|
@@ -852,7 +863,7 @@ describe("validateBoot — lookupable / blind-index (#818)", () => {
|
|
|
852
863
|
createEntity({
|
|
853
864
|
fields: {
|
|
854
865
|
slug: rawField({
|
|
855
|
-
...
|
|
866
|
+
...unannotatedText,
|
|
856
867
|
lookupable: true,
|
|
857
868
|
}),
|
|
858
869
|
},
|
|
@@ -872,7 +883,7 @@ describe("validateBoot — lookupable / blind-index (#818)", () => {
|
|
|
872
883
|
...createLongTextField({ personal: { of: "ownerId" }, find: "none" }),
|
|
873
884
|
lookupable: true,
|
|
874
885
|
}),
|
|
875
|
-
ownerId:
|
|
886
|
+
ownerId: { ...unannotatedText, required: true },
|
|
876
887
|
},
|
|
877
888
|
}),
|
|
878
889
|
);
|
|
@@ -922,7 +933,7 @@ describe("validateBoot — lookupable / blind-index (#818)", () => {
|
|
|
922
933
|
createEntity({
|
|
923
934
|
fields: {
|
|
924
935
|
passwordHash: rawField({
|
|
925
|
-
...
|
|
936
|
+
...unannotatedText,
|
|
926
937
|
pii: true,
|
|
927
938
|
sensitive: true,
|
|
928
939
|
searchable: true,
|
|
@@ -20,8 +20,12 @@ const contactEntity = createEntity({
|
|
|
20
20
|
// hook) writes it. secretNote's ownership rule checks authorId, so
|
|
21
21
|
// create only succeeds if the hook ran BEFORE the field-ownership check
|
|
22
22
|
// (kumiko-framework#1672 — see also event-store-executor-write.ts).
|
|
23
|
-
authorId: createTextField(),
|
|
24
|
-
secretNote: createTextField({
|
|
23
|
+
authorId: createTextField({ personal: false, reason: "test_fixture" }),
|
|
24
|
+
secretNote: createTextField({
|
|
25
|
+
personal: false,
|
|
26
|
+
reason: "test_fixture",
|
|
27
|
+
access: { write: { User: from("user:id", "authorId") } },
|
|
28
|
+
}),
|
|
25
29
|
},
|
|
26
30
|
});
|
|
27
31
|
|
|
@@ -41,7 +41,12 @@ describe("createLongTextField — runtime shape", () => {
|
|
|
41
41
|
});
|
|
42
42
|
|
|
43
43
|
test("encrypted + sensitive flags type-allowed", () => {
|
|
44
|
-
const f = createLongTextField({
|
|
44
|
+
const f = createLongTextField({
|
|
45
|
+
personal: false,
|
|
46
|
+
reason: "test_fixture",
|
|
47
|
+
encrypted: true,
|
|
48
|
+
sensitive: true,
|
|
49
|
+
});
|
|
45
50
|
expect(f.encrypted).toBe(true);
|
|
46
51
|
expect(f.sensitive).toBe(true);
|
|
47
52
|
});
|
|
@@ -3,10 +3,12 @@ import {
|
|
|
3
3
|
ACTION_FORM_ENTITY,
|
|
4
4
|
CONFIG_EDIT_ENTITY,
|
|
5
5
|
fieldLabelKey,
|
|
6
|
+
PROJECTION_DETAIL_ENTITY,
|
|
6
7
|
requiredKeysFromNav,
|
|
7
8
|
requiredKeysFromScreen,
|
|
8
9
|
requiredKeysFromWorkspace,
|
|
9
10
|
screenTitleKey,
|
|
11
|
+
WRITE_FORM_SECTION_ENTITY,
|
|
10
12
|
} from "../../i18n/required-surface-keys";
|
|
11
13
|
import { i18nKey } from "../i18n-key";
|
|
12
14
|
import type {
|
|
@@ -279,3 +281,197 @@ describe("dot-form label opt-in (fw#2313)", () => {
|
|
|
279
281
|
expect(requiredKeysFromNav(nav)).toContain("fw2313.optin.nav.beta");
|
|
280
282
|
});
|
|
281
283
|
});
|
|
284
|
+
|
|
285
|
+
describe("requiredKeysFromScreen reads section.groups (fw#2986)", () => {
|
|
286
|
+
test("entityEdit: group fields and group titles, override honored", () => {
|
|
287
|
+
const screen: EntityEditScreenDefinition = {
|
|
288
|
+
id: "component-edit",
|
|
289
|
+
type: "entityEdit",
|
|
290
|
+
entity: "component",
|
|
291
|
+
fieldLabels: { name: "publicstatus:override.name" },
|
|
292
|
+
layout: {
|
|
293
|
+
sections: [
|
|
294
|
+
{
|
|
295
|
+
title: "publicstatus:section.basics",
|
|
296
|
+
fields: [],
|
|
297
|
+
groups: [
|
|
298
|
+
{ title: "publicstatus:group.identity", fields: ["name"] },
|
|
299
|
+
{ title: "publicstatus:group.state", fields: [{ field: "status" }] },
|
|
300
|
+
],
|
|
301
|
+
},
|
|
302
|
+
],
|
|
303
|
+
},
|
|
304
|
+
};
|
|
305
|
+
const keys = requiredKeysFromScreen("publicstatus", screen);
|
|
306
|
+
expect(keys).toContain("publicstatus:section.basics");
|
|
307
|
+
expect(keys).toContain("publicstatus:group.identity");
|
|
308
|
+
expect(keys).toContain("publicstatus:group.state");
|
|
309
|
+
expect(keys).toContain("publicstatus:override.name");
|
|
310
|
+
expect(keys).not.toContain(fieldLabelKey("publicstatus", "component", "name"));
|
|
311
|
+
expect(keys).toContain(fieldLabelKey("publicstatus", "component", "status"));
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
test("entityEdit: a section with fields AND groups yields the union of both", () => {
|
|
315
|
+
const screen: EntityEditScreenDefinition = {
|
|
316
|
+
id: "component-edit",
|
|
317
|
+
type: "entityEdit",
|
|
318
|
+
entity: "component",
|
|
319
|
+
layout: {
|
|
320
|
+
sections: [
|
|
321
|
+
{
|
|
322
|
+
fields: ["name"],
|
|
323
|
+
groups: [{ title: "publicstatus:group.state", fields: ["status"] }],
|
|
324
|
+
},
|
|
325
|
+
],
|
|
326
|
+
},
|
|
327
|
+
};
|
|
328
|
+
const keys = requiredKeysFromScreen("publicstatus", screen);
|
|
329
|
+
expect(keys).toContain(fieldLabelKey("publicstatus", "component", "name"));
|
|
330
|
+
expect(keys).toContain(fieldLabelKey("publicstatus", "component", "status"));
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
test("actionForm: group fields and group titles", () => {
|
|
334
|
+
const keys = requiredKeysFromScreen("publicstatus", {
|
|
335
|
+
id: "incident-open-form",
|
|
336
|
+
type: "actionForm",
|
|
337
|
+
handler: "publicstatus:write:incident:open",
|
|
338
|
+
fields: { title: { type: "text" }, note: { type: "text" } },
|
|
339
|
+
fieldLabels: { title: "publicstatus:override.title" },
|
|
340
|
+
layout: {
|
|
341
|
+
sections: [
|
|
342
|
+
{
|
|
343
|
+
fields: [],
|
|
344
|
+
groups: [{ title: "publicstatus:group.body", fields: ["title", "note"] }],
|
|
345
|
+
},
|
|
346
|
+
],
|
|
347
|
+
},
|
|
348
|
+
});
|
|
349
|
+
expect(keys).toContain("publicstatus:group.body");
|
|
350
|
+
expect(keys).toContain("publicstatus:override.title");
|
|
351
|
+
expect(keys).toContain(fieldLabelKey("publicstatus", ACTION_FORM_ENTITY, "note"));
|
|
352
|
+
});
|
|
353
|
+
|
|
354
|
+
test("secretMint: group fields and group titles in mint and confirm layout", () => {
|
|
355
|
+
const keys = requiredKeysFromScreen("publicstatus", {
|
|
356
|
+
id: "token-mint",
|
|
357
|
+
type: "secretMint",
|
|
358
|
+
handler: "publicstatus:write:token:mint",
|
|
359
|
+
fields: { label: { type: "text" } },
|
|
360
|
+
layout: {
|
|
361
|
+
sections: [
|
|
362
|
+
{
|
|
363
|
+
fields: [],
|
|
364
|
+
groups: [{ title: "publicstatus:group.mint", fields: ["label"] }],
|
|
365
|
+
},
|
|
366
|
+
],
|
|
367
|
+
},
|
|
368
|
+
reveal: { fields: [{ field: "token", label: "publicstatus:reveal.token" }] },
|
|
369
|
+
confirm: {
|
|
370
|
+
handler: "publicstatus:write:token:confirm",
|
|
371
|
+
fields: { ack: { type: "boolean" } },
|
|
372
|
+
layout: {
|
|
373
|
+
sections: [
|
|
374
|
+
{
|
|
375
|
+
fields: [],
|
|
376
|
+
groups: [{ title: "publicstatus:group.confirm", fields: ["ack"] }],
|
|
377
|
+
},
|
|
378
|
+
],
|
|
379
|
+
},
|
|
380
|
+
},
|
|
381
|
+
});
|
|
382
|
+
expect(keys).toContain("publicstatus:group.mint");
|
|
383
|
+
expect(keys).toContain(fieldLabelKey("publicstatus", ACTION_FORM_ENTITY, "label"));
|
|
384
|
+
expect(keys).toContain("publicstatus:group.confirm");
|
|
385
|
+
expect(keys).toContain(fieldLabelKey("publicstatus", ACTION_FORM_ENTITY, "ack"));
|
|
386
|
+
});
|
|
387
|
+
|
|
388
|
+
test("configEdit: group fields and group titles, override honored", () => {
|
|
389
|
+
const screen: ConfigEditScreenDefinition = {
|
|
390
|
+
id: "settings-retention",
|
|
391
|
+
type: "configEdit",
|
|
392
|
+
scope: "tenant",
|
|
393
|
+
configKeys: {
|
|
394
|
+
days: "publicstatus:config:retentionDays",
|
|
395
|
+
mode: "publicstatus:config:mode",
|
|
396
|
+
},
|
|
397
|
+
fieldLabels: { days: "publicstatus:override.retentionDays" },
|
|
398
|
+
fields: { days: { type: "number" }, mode: { type: "text" } },
|
|
399
|
+
layout: {
|
|
400
|
+
sections: [
|
|
401
|
+
{
|
|
402
|
+
fields: [],
|
|
403
|
+
groups: [{ title: "publicstatus:group.retention", fields: ["days", "mode"] }],
|
|
404
|
+
},
|
|
405
|
+
],
|
|
406
|
+
},
|
|
407
|
+
};
|
|
408
|
+
const keys = requiredKeysFromScreen("publicstatus", screen);
|
|
409
|
+
expect(keys).toContain("publicstatus:group.retention");
|
|
410
|
+
expect(keys).toContain("publicstatus:override.retentionDays");
|
|
411
|
+
expect(keys).toContain(fieldLabelKey("publicstatus", CONFIG_EDIT_ENTITY, "mode"));
|
|
412
|
+
});
|
|
413
|
+
|
|
414
|
+
test("configEdit: dot-form group titles need treatDotFormAsKey, like section titles", () => {
|
|
415
|
+
const screen: ConfigEditScreenDefinition = {
|
|
416
|
+
id: "settings-retention",
|
|
417
|
+
type: "configEdit",
|
|
418
|
+
scope: "tenant",
|
|
419
|
+
configKeys: { days: "publicstatus:config:retentionDays" },
|
|
420
|
+
fields: { days: { type: "number" } },
|
|
421
|
+
layout: {
|
|
422
|
+
sections: [
|
|
423
|
+
{
|
|
424
|
+
fields: [],
|
|
425
|
+
groups: [{ title: "publicstatus.group.retention", fields: ["days"] }],
|
|
426
|
+
},
|
|
427
|
+
],
|
|
428
|
+
},
|
|
429
|
+
};
|
|
430
|
+
expect(requiredKeysFromScreen("publicstatus", screen)).not.toContain(
|
|
431
|
+
"publicstatus.group.retention",
|
|
432
|
+
);
|
|
433
|
+
expect(requiredKeysFromScreen("publicstatus", screen, { treatDotFormAsKey: true })).toContain(
|
|
434
|
+
"publicstatus.group.retention",
|
|
435
|
+
);
|
|
436
|
+
});
|
|
437
|
+
|
|
438
|
+
test("projectionDetail: group fields and group titles, override honored", () => {
|
|
439
|
+
const keys = requiredKeysFromScreen("publicstatus", {
|
|
440
|
+
id: "incident-detail",
|
|
441
|
+
type: "projectionDetail",
|
|
442
|
+
query: "publicstatus:query:incident:detail",
|
|
443
|
+
fieldLabels: { severity: "publicstatus:override.severity" },
|
|
444
|
+
layout: {
|
|
445
|
+
sections: [
|
|
446
|
+
{
|
|
447
|
+
fields: [],
|
|
448
|
+
groups: [{ title: "publicstatus:group.facts", fields: ["severity", "openedAt"] }],
|
|
449
|
+
},
|
|
450
|
+
],
|
|
451
|
+
},
|
|
452
|
+
});
|
|
453
|
+
expect(keys).toContain("publicstatus:group.facts");
|
|
454
|
+
expect(keys).toContain("publicstatus:override.severity");
|
|
455
|
+
expect(keys).toContain(fieldLabelKey("publicstatus", PROJECTION_DETAIL_ENTITY, "openedAt"));
|
|
456
|
+
});
|
|
457
|
+
|
|
458
|
+
test("projectionDetail: writeForm sections keep the WRITE_FORM_SECTION_ENTITY namespace", () => {
|
|
459
|
+
const keys = requiredKeysFromScreen("publicstatus", {
|
|
460
|
+
id: "incident-detail",
|
|
461
|
+
type: "projectionDetail",
|
|
462
|
+
query: "publicstatus:query:incident:detail",
|
|
463
|
+
layout: {
|
|
464
|
+
sections: [
|
|
465
|
+
{
|
|
466
|
+
kind: "writeForm",
|
|
467
|
+
title: "publicstatus:section.comment",
|
|
468
|
+
handler: "publicstatus:write:incident:comment",
|
|
469
|
+
fieldDefs: { body: { type: "text" } },
|
|
470
|
+
fields: ["body"],
|
|
471
|
+
},
|
|
472
|
+
],
|
|
473
|
+
},
|
|
474
|
+
});
|
|
475
|
+
expect(keys).toContain(fieldLabelKey("publicstatus", WRITE_FORM_SECTION_ENTITY, "body"));
|
|
476
|
+
});
|
|
477
|
+
});
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import { defineFeature } from "../../define-feature";
|
|
4
|
+
import { validateHandlerAccess } from "../entity-handler";
|
|
5
|
+
|
|
6
|
+
describe("validateHandlerAccess — anonymous handlers require a rateLimit", () => {
|
|
7
|
+
test("an anonymous handler with no rateLimit throws, naming the handler", () => {
|
|
8
|
+
const feature = defineFeature("rl-anon", (r) => {
|
|
9
|
+
r.writeHandler(
|
|
10
|
+
"note:create",
|
|
11
|
+
z.object({ title: z.string() }),
|
|
12
|
+
async () => ({
|
|
13
|
+
isSuccess: true as const,
|
|
14
|
+
data: {},
|
|
15
|
+
}),
|
|
16
|
+
{
|
|
17
|
+
access: { roles: ["anonymous", "Admin"] },
|
|
18
|
+
},
|
|
19
|
+
);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
expect(() => validateHandlerAccess(feature)).toThrow(/rl-anon:write:note:create/);
|
|
23
|
+
expect(() => validateHandlerAccess(feature)).toThrow(/declares no rateLimit/);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
test("an anonymous handler with rateLimit: { disabled: true, reason } boots fine", () => {
|
|
27
|
+
const feature = defineFeature("rl-anon", (r) => {
|
|
28
|
+
r.writeHandler(
|
|
29
|
+
"note:create",
|
|
30
|
+
z.object({ title: z.string() }),
|
|
31
|
+
async () => ({
|
|
32
|
+
isSuccess: true as const,
|
|
33
|
+
data: {},
|
|
34
|
+
}),
|
|
35
|
+
{
|
|
36
|
+
access: { roles: ["anonymous", "Admin"] },
|
|
37
|
+
rateLimit: { disabled: true, reason: "internal replay path" },
|
|
38
|
+
},
|
|
39
|
+
);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
expect(() => validateHandlerAccess(feature)).not.toThrow();
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test("an anonymous handler with rateLimit: { per: 'ip', ... } boots fine", () => {
|
|
46
|
+
const feature = defineFeature("rl-anon", (r) => {
|
|
47
|
+
r.writeHandler(
|
|
48
|
+
"note:create",
|
|
49
|
+
z.object({ title: z.string() }),
|
|
50
|
+
async () => ({
|
|
51
|
+
isSuccess: true as const,
|
|
52
|
+
data: {},
|
|
53
|
+
}),
|
|
54
|
+
{
|
|
55
|
+
access: { roles: ["anonymous", "Admin"] },
|
|
56
|
+
rateLimit: { per: "ip", limit: 30, windowSeconds: 60 },
|
|
57
|
+
},
|
|
58
|
+
);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
expect(() => validateHandlerAccess(feature)).not.toThrow();
|
|
62
|
+
});
|
|
63
|
+
});
|
|
@@ -7,9 +7,19 @@
|
|
|
7
7
|
import { describe, expect, test } from "bun:test";
|
|
8
8
|
import { defineFeature } from "../../define-feature";
|
|
9
9
|
import { createEntity, createTextField } from "../../factories";
|
|
10
|
-
import type { FeatureDefinition } from "../../types";
|
|
10
|
+
import type { FeatureDefinition, TextFieldDef } from "../../types";
|
|
11
11
|
import { validateRecordOwnedSubjects } from "../record-owned";
|
|
12
12
|
|
|
13
|
+
// Presence/absence of the annotation is the test variable here; after #2810
|
|
14
|
+
// the factory can no longer produce the unannotated shape.
|
|
15
|
+
const unannotatedText: TextFieldDef = {
|
|
16
|
+
type: "text",
|
|
17
|
+
maxLength: 200,
|
|
18
|
+
required: false,
|
|
19
|
+
searchable: false,
|
|
20
|
+
sortable: false,
|
|
21
|
+
};
|
|
22
|
+
|
|
13
23
|
function featureWith(
|
|
14
24
|
idType: "serial" | "uuid" | undefined,
|
|
15
25
|
withRecordOwnedField: boolean,
|
|
@@ -23,7 +33,7 @@ function featureWith(
|
|
|
23
33
|
fields: {
|
|
24
34
|
body: withRecordOwnedField
|
|
25
35
|
? createTextField({ personal: { of: "id" }, find: "none" })
|
|
26
|
-
:
|
|
36
|
+
: { ...unannotatedText },
|
|
27
37
|
},
|
|
28
38
|
}),
|
|
29
39
|
);
|
|
@@ -3,6 +3,7 @@ import {
|
|
|
3
3
|
isFieldsEditSection,
|
|
4
4
|
normalizeEditField,
|
|
5
5
|
normalizeListColumn,
|
|
6
|
+
sectionFieldSpecs,
|
|
6
7
|
} from "../screen-helpers";
|
|
7
8
|
import type {
|
|
8
9
|
EditFieldSpec,
|
|
@@ -125,7 +126,7 @@ function validateEditLayoutNoFunctions(
|
|
|
125
126
|
continue;
|
|
126
127
|
}
|
|
127
128
|
if (!isFieldsEditSection(section)) continue;
|
|
128
|
-
for (const fieldSpec of section
|
|
129
|
+
for (const fieldSpec of sectionFieldSpecs(section)) {
|
|
129
130
|
validateEditFieldNoFunctions(featureName, screenId, screenType, fieldSpec);
|
|
130
131
|
}
|
|
131
132
|
}
|
|
@@ -177,15 +177,21 @@ export function validateAnonymousRateLimit(
|
|
|
177
177
|
access: NonNullable<FeatureDefinition["writeHandlers"][string]["access"]>,
|
|
178
178
|
rateLimit: FeatureDefinition["writeHandlers"][string]["rateLimit"],
|
|
179
179
|
): void {
|
|
180
|
-
// skip: handler doesn't opt into rate-limit, no user-bucket risk
|
|
181
|
-
if (!rateLimit) return;
|
|
182
|
-
// skip: disabled declarations carry no `.per` to bucket on
|
|
183
|
-
if (isRateLimitDisabled(rateLimit)) return;
|
|
184
180
|
// skip: openToAll handlers don't allow anonymous (hasAccess rejects), so
|
|
185
181
|
// the user-bucket footgun doesn't apply
|
|
186
182
|
if (!("roles" in access)) return;
|
|
187
183
|
// skip: handler doesn't list anonymous, regular role-rate-limit is fine
|
|
188
184
|
if (!access.roles.includes("anonymous")) return;
|
|
185
|
+
// skip: disabled declarations carry no `.per` to bucket on
|
|
186
|
+
if (isRateLimitDisabled(rateLimit)) return;
|
|
187
|
+
if (!rateLimit) {
|
|
188
|
+
throw new Error(
|
|
189
|
+
`${kind} handler "${featureName}:${kind}:${handlerName}" allows anonymous callers but declares no ` +
|
|
190
|
+
`rateLimit — an anonymous, internet-facing endpoint needs an upper bound. Set rateLimit: ` +
|
|
191
|
+
`{ per: "ip", limit: ..., windowSeconds: ... }, or the documented exception ` +
|
|
192
|
+
`rateLimit: { disabled: true, reason: "..." }.`,
|
|
193
|
+
);
|
|
194
|
+
}
|
|
189
195
|
// skip: rate-limit is already keyed on something safe (ip / tenant)
|
|
190
196
|
if (!USER_BUCKETED_RATE_LIMIT_PER.has(rateLimit.per)) return;
|
|
191
197
|
throw new Error(
|
|
@@ -16,6 +16,7 @@ import {
|
|
|
16
16
|
normalizeEditField,
|
|
17
17
|
normalizeListColumn,
|
|
18
18
|
resolveNavParentScreen,
|
|
19
|
+
sectionFieldSpecs,
|
|
19
20
|
} from "../screen-helpers";
|
|
20
21
|
import type { EntityDefinition, FeatureDefinition, FieldDefinition } from "../types";
|
|
21
22
|
import { metricField } from "../types";
|
|
@@ -608,15 +609,6 @@ function validateFieldsXorGroups(
|
|
|
608
609
|
}
|
|
609
610
|
}
|
|
610
611
|
|
|
611
|
-
function flattenFieldsOrGroups(section: {
|
|
612
|
-
readonly fields: readonly EditFieldSpec[];
|
|
613
|
-
readonly groups?: readonly { readonly fields: readonly EditFieldSpec[] }[];
|
|
614
|
-
}): readonly EditFieldSpec[] {
|
|
615
|
-
return section.groups !== undefined
|
|
616
|
-
? section.groups.flatMap((group) => group.fields)
|
|
617
|
-
: section.fields;
|
|
618
|
-
}
|
|
619
|
-
|
|
620
612
|
function validateFormLayoutSections(
|
|
621
613
|
featureName: string,
|
|
622
614
|
screenId: string,
|
|
@@ -657,7 +649,7 @@ function validateFormLayoutSections(
|
|
|
657
649
|
);
|
|
658
650
|
}
|
|
659
651
|
validateFieldsXorGroups(`[Feature ${featureName}] Screen "${screenId}" (${context})`, section);
|
|
660
|
-
for (const fieldSpec of
|
|
652
|
+
for (const fieldSpec of sectionFieldSpecs(section)) {
|
|
661
653
|
const normalized = normalizeEditField(fieldSpec);
|
|
662
654
|
if (!fieldNames.has(normalized.field)) {
|
|
663
655
|
throw new Error(
|
|
@@ -1420,7 +1412,7 @@ export function validateScreens(
|
|
|
1420
1412
|
`[Feature ${feature.name}] Screen "${screenId}" (configEdit)`,
|
|
1421
1413
|
section,
|
|
1422
1414
|
);
|
|
1423
|
-
for (const fieldSpec of
|
|
1415
|
+
for (const fieldSpec of sectionFieldSpecs(section)) {
|
|
1424
1416
|
const normalized = normalizeEditField(fieldSpec);
|
|
1425
1417
|
if (!fieldNames.has(normalized.field)) {
|
|
1426
1418
|
throw new Error(
|
|
@@ -1837,7 +1829,7 @@ export function validateScreens(
|
|
|
1837
1829
|
`[Feature ${feature.name}] Screen "${screenId}" (entityEdit)`,
|
|
1838
1830
|
section,
|
|
1839
1831
|
);
|
|
1840
|
-
for (const fieldSpec of
|
|
1832
|
+
for (const fieldSpec of sectionFieldSpecs(section)) {
|
|
1841
1833
|
const normalized = normalizeEditField(fieldSpec);
|
|
1842
1834
|
if (!fieldNames.has(normalized.field)) {
|
|
1843
1835
|
throw new Error(
|
|
@@ -3,17 +3,32 @@
|
|
|
3
3
|
// Mirror of tenant-data.ts, but the destroyTenant hook takes (tenantId, ctx)
|
|
4
4
|
// rather than just (ctx) — runExtensionDestroyHooks (tenant-lifecycle/stages.ts)
|
|
5
5
|
// passes tenantId as its own positional arg for every EXT_*_RESOURCE-style
|
|
6
|
-
// extension point, not just this one. ctx here
|
|
6
|
+
// extension point, not just this one. ctx here guarantees tenantId, db, plus
|
|
7
7
|
// the optional fileProviderResolver/log; the richer stage-runner ctx
|
|
8
8
|
// (tenant-lifecycle's DestructionStageCtx) is a structural superset, so a hook
|
|
9
9
|
// typed against this minimal ctx is safely assignable wherever that richer ctx
|
|
10
10
|
// is passed.
|
|
11
|
-
|
|
11
|
+
//
|
|
12
|
+
// `db` is a raw DbRunner, NOT a tenant-scoped TenantDb — unlike
|
|
13
|
+
// TenantDataHookCtx.db (EXT_TENANT_DATA), runExtensionDestroyHooks hands every
|
|
14
|
+
// EXT_*_RESOURCE-style hook (this one included) the stage runner's own
|
|
15
|
+
// DestructionStageCtx.db straight through, with no tenant filter and no
|
|
16
|
+
// escapeHatch/unsafeRaw gate — there is nothing to declare, because nothing
|
|
17
|
+
// ever wraps it. That's deliberate for this stage family: wiping a whole
|
|
18
|
+
// search index, an S3 prefix, or infra resources is inherently a cross-tenant,
|
|
19
|
+
// bulk operation, not a row-scoped one. A hook that needs a plain read across
|
|
20
|
+
// tenants (kumiko-framework#3035: does a foreign tenant's file_refs row still
|
|
21
|
+
// reference a key under THIS tenant's storage prefix) uses `db` directly via
|
|
22
|
+
// executeRawQueryRead — there is no `ctx.systemDb` here (that belongs to
|
|
23
|
+
// HandlerContext / r.systemScope() write-handler dispatch, a different code
|
|
24
|
+
// path this stage runner never goes through).
|
|
12
25
|
import type { FileProviderResolver } from "@cosmicdrift/kumiko-types/file-provider-resolver-types";
|
|
26
|
+
import type { DbRunner } from "../../db/connection";
|
|
13
27
|
import type { TenantId } from "../types";
|
|
14
28
|
|
|
15
29
|
export interface StorageProviderHookCtx {
|
|
16
30
|
readonly tenantId: TenantId;
|
|
31
|
+
readonly db: DbRunner;
|
|
17
32
|
readonly fileProviderResolver?: FileProviderResolver;
|
|
18
33
|
readonly log?: (message: string) => void;
|
|
19
34
|
}
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
// Mirror of engine/extensions/user-data.ts at tenant granularity.
|
|
4
4
|
// tenant-lifecycle orchestrates destroy via registry.getExtensionUsages(EXT_TENANT_DATA).
|
|
5
5
|
|
|
6
|
+
import type { FileProviderResolver } from "@cosmicdrift/kumiko-types/file-provider-resolver-types";
|
|
6
7
|
import type { TenantDb } from "../../db/tenant-db";
|
|
7
8
|
import type { Registry, TenantId } from "../types";
|
|
8
9
|
|
|
@@ -12,6 +13,14 @@ export interface TenantDataHookCtx {
|
|
|
12
13
|
readonly db: TenantDb;
|
|
13
14
|
readonly registry: Registry;
|
|
14
15
|
readonly tenantId: TenantId;
|
|
16
|
+
// Threaded through from DestructionStageCtx (tenant-lifecycle/stages.ts),
|
|
17
|
+
// which resolves it unconditionally for every stage. "app-data" is the
|
|
18
|
+
// only stage where a fileRef row still exists to read a storageKey off —
|
|
19
|
+
// the "files" stage's prefix sweep runs after this stage already purged
|
|
20
|
+
// the rows (kumiko-framework#3035). Undefined when no file-provider is
|
|
21
|
+
// wired; a hook that needs it must treat that as "nothing to clean up".
|
|
22
|
+
readonly fileProviderResolver?: FileProviderResolver;
|
|
23
|
+
readonly log?: (message: string) => void;
|
|
15
24
|
}
|
|
16
25
|
|
|
17
26
|
export type TenantDataDestroyHook = (ctx: TenantDataHookCtx) => Promise<void>;
|
package/src/engine/factories.ts
CHANGED
|
@@ -472,6 +472,8 @@ export function createEntity<F, const T extends EntityTenancy = "tenant">(def: {
|
|
|
472
472
|
readonly retention?: RetentionDef;
|
|
473
473
|
readonly derivedFields?: EntityDefinition["derivedFields"];
|
|
474
474
|
readonly tenancy?: T;
|
|
475
|
+
/** See EntityDefinition.transferable. */
|
|
476
|
+
readonly transferable?: boolean;
|
|
475
477
|
}): F extends FieldsMap ? EntityDefinition<F, T> : never {
|
|
476
478
|
return {
|
|
477
479
|
softDelete: false,
|
package/src/engine/index.ts
CHANGED
|
@@ -266,6 +266,7 @@ export {
|
|
|
266
266
|
isWriteFormEditSection,
|
|
267
267
|
normalizeEditField,
|
|
268
268
|
normalizeListColumn,
|
|
269
|
+
sectionFieldSpecs,
|
|
269
270
|
} from "./screen-helpers";
|
|
270
271
|
export type { TransitionGraph } from "./state-machine";
|
|
271
272
|
export { defineTransitions, guardTransition } from "./state-machine";
|
|
@@ -130,6 +130,15 @@ export function isKebabSegment(name: string): boolean {
|
|
|
130
130
|
return QN_SEGMENT.test(name);
|
|
131
131
|
}
|
|
132
132
|
|
|
133
|
+
// Owning scope of a qualified name — the segment before the first ":".
|
|
134
|
+
// Deliberately lenient where parseQn() throws: callers here hold names that
|
|
135
|
+
// may be unqualified (system consumers, ad-hoc jobs) and want undefined, not
|
|
136
|
+
// an exception.
|
|
137
|
+
export function qnScope(qualifiedName: string): string | undefined {
|
|
138
|
+
const idx = qualifiedName.indexOf(":");
|
|
139
|
+
return idx > 0 ? qualifiedName.slice(0, idx) : undefined;
|
|
140
|
+
}
|
|
141
|
+
|
|
133
142
|
// Build a fully-qualified entity name from a feature name + QN type + short
|
|
134
143
|
// name, running both names through toKebab first. This is the canonical
|
|
135
144
|
// "how the registry qualifies things" helper — both createRegistry and
|