@cosmicdrift/kumiko-framework 0.290.0 → 0.291.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__/pii-personal-migration-report-codemod.test.ts +160 -0
- package/src/api/__tests__/server-error-logging.test.ts +168 -17
- package/src/api/request-context.ts +3 -0
- package/src/api/request-id-middleware.ts +2 -1
- package/src/api/routes.ts +35 -3
- package/src/changes.json +51 -0
- package/src/crypto/__tests__/event-pii.test.ts +110 -9
- package/src/crypto/__tests__/subject-resolver.test.ts +23 -2
- package/src/crypto/subject-resolver.ts +25 -8
- package/src/db/queries/shadow-swap.ts +35 -0
- package/src/engine/__tests__/boot-validator-pii-retention.test.ts +122 -0
- package/src/engine/__tests__/boot-validator.test.ts +226 -0
- package/src/engine/__tests__/build-app-schema.test.ts +18 -0
- package/src/engine/__tests__/engine.test.ts +87 -0
- package/src/engine/__tests__/form-money-currency-types.test.ts +90 -0
- package/src/engine/boot-validator/entity-handler.ts +44 -0
- package/src/engine/boot-validator/index.ts +7 -2
- package/src/engine/boot-validator/pii-retention.ts +8 -0
- package/src/engine/boot-validator/screens.ts +50 -0
- package/src/engine/create-app.ts +54 -0
- package/src/engine/feature-config-events-jobs.ts +19 -0
- package/src/engine/index.ts +1 -0
- package/src/engine/screen-helpers.ts +1 -0
- package/src/event-store/__tests__/backfill-pii.integration.test.ts +30 -6
- package/src/i18n/required-surface-keys.ts +1 -0
- package/src/jobs/__tests__/job-last-success.integration.test.ts +135 -0
- package/src/jobs/index.ts +7 -1
- package/src/jobs/job-runner.ts +94 -4
- package/src/logging/utils.ts +14 -1
- package/src/observability/index.ts +1 -0
- package/src/observability/standard-metrics.ts +20 -0
- package/src/pipeline/__tests__/blind-index-rebuild-guard.integration.test.ts +96 -0
- package/src/pipeline/projection-rebuild.ts +7 -0
- package/src/schema-cli.ts +21 -0
- package/src/scripts/codemod/pii-personal-migration.ts +242 -2
|
@@ -644,6 +644,24 @@ describe("buildAppSchema", () => {
|
|
|
644
644
|
expect(JSON.parse(JSON.stringify(projected))).toEqual(projected);
|
|
645
645
|
});
|
|
646
646
|
|
|
647
|
+
test("money field's `currency: { kind: 'literal', code }` declaration survives the projection (fw#2839)", () => {
|
|
648
|
+
const entity = {
|
|
649
|
+
defaultCurrency: "USD",
|
|
650
|
+
fields: { price: { type: "money", currency: { kind: "literal", code: "USD" } } },
|
|
651
|
+
} as unknown as EntityDefinition;
|
|
652
|
+
|
|
653
|
+
const f = defineFeature("ent", (r) => {
|
|
654
|
+
r.entity("thing", entity);
|
|
655
|
+
});
|
|
656
|
+
const app = buildAppSchema(createRegistry([f]));
|
|
657
|
+
const projected = app.features[0]!.entities["thing"] as unknown as {
|
|
658
|
+
fields: Record<string, Record<string, unknown>>;
|
|
659
|
+
};
|
|
660
|
+
|
|
661
|
+
expect(projected.fields["price"]?.["currency"]).toEqual({ kind: "literal", code: "USD" });
|
|
662
|
+
expect(JSON.parse(JSON.stringify(projected))).toEqual(projected);
|
|
663
|
+
});
|
|
664
|
+
|
|
647
665
|
test("text format, timestamp locatedBy, file accept/maxSize, image variants and decimal scale survive the projection", () => {
|
|
648
666
|
// Regression: all of these are read by the edit view-model (password
|
|
649
667
|
// masking, wall-clock input, upload constraints, preview variant, derived
|
|
@@ -909,6 +909,45 @@ describe("createRegistry", () => {
|
|
|
909
909
|
]);
|
|
910
910
|
});
|
|
911
911
|
|
|
912
|
+
test("optionsQuery leaves searchable/sortable on the labelField column (fw#2780)", () => {
|
|
913
|
+
const feature = defineFeature("crm", (r) => {
|
|
914
|
+
r.entity(
|
|
915
|
+
"customer",
|
|
916
|
+
createEntity({
|
|
917
|
+
table: "Customers",
|
|
918
|
+
fields: { name: createTextField({ personal: false, reason: "test_fixture" }) },
|
|
919
|
+
}),
|
|
920
|
+
);
|
|
921
|
+
r.queryHandler("customer:options", z.object({}), async () => [], {
|
|
922
|
+
access: { openToAll: { reason: "test handler callable by any signed-in test user" } },
|
|
923
|
+
});
|
|
924
|
+
r.entity(
|
|
925
|
+
"order",
|
|
926
|
+
createEntity({
|
|
927
|
+
table: "Orders",
|
|
928
|
+
fields: {
|
|
929
|
+
customerId: {
|
|
930
|
+
type: "reference",
|
|
931
|
+
entity: "customer",
|
|
932
|
+
labelField: "name",
|
|
933
|
+
optionsQuery: "crm:query:customer:options",
|
|
934
|
+
searchable: true,
|
|
935
|
+
sortable: true,
|
|
936
|
+
},
|
|
937
|
+
},
|
|
938
|
+
}),
|
|
939
|
+
);
|
|
940
|
+
});
|
|
941
|
+
|
|
942
|
+
const registry = createRegistry([feature]);
|
|
943
|
+
expect(registry.getSortableReferences("order")).toEqual([
|
|
944
|
+
{ fieldName: "customerId", targetEntityName: "customer", labelField: "name" },
|
|
945
|
+
]);
|
|
946
|
+
expect(registry.getSearchableReferences("order")).toEqual([
|
|
947
|
+
{ fieldName: "customerId", targetEntityName: "customer", labelField: "name" },
|
|
948
|
+
]);
|
|
949
|
+
});
|
|
950
|
+
|
|
912
951
|
test("throws at boot when a sortable reference field has no explicit labelField (fw#2741)", () => {
|
|
913
952
|
const feature = defineFeature("crm", (r) => {
|
|
914
953
|
r.entity(
|
|
@@ -1204,6 +1243,54 @@ describe("createApp", () => {
|
|
|
1204
1243
|
);
|
|
1205
1244
|
});
|
|
1206
1245
|
|
|
1246
|
+
// fw#2839: a literal currency source is only meaningful for a code the app
|
|
1247
|
+
// knows — a typo would otherwise render and submit an unformattable amount.
|
|
1248
|
+
function literalCurrencyFeature(code: string) {
|
|
1249
|
+
return defineFeature("test", (r) => {
|
|
1250
|
+
r.entity(
|
|
1251
|
+
"invoice",
|
|
1252
|
+
createEntity({
|
|
1253
|
+
table: "Invoices",
|
|
1254
|
+
defaultCurrency: "EUR",
|
|
1255
|
+
fields: { total: { type: "money", currency: { kind: "literal", code } } },
|
|
1256
|
+
} as never),
|
|
1257
|
+
);
|
|
1258
|
+
});
|
|
1259
|
+
}
|
|
1260
|
+
|
|
1261
|
+
test("rejects currency: { kind: 'literal' } with a code outside the currencies list", () => {
|
|
1262
|
+
expect(() =>
|
|
1263
|
+
createApp({ roles: ["Admin"], features: [literalCurrencyFeature("EURO")] }),
|
|
1264
|
+
).toThrow(/code: "EURO" \} which is not in the currencies list/);
|
|
1265
|
+
});
|
|
1266
|
+
|
|
1267
|
+
test("accepts currency: { kind: 'literal' } with a code from the currencies list", () => {
|
|
1268
|
+
expect(() =>
|
|
1269
|
+
createApp({ roles: ["Admin"], features: [literalCurrencyFeature("EUR")] }),
|
|
1270
|
+
).not.toThrow();
|
|
1271
|
+
});
|
|
1272
|
+
|
|
1273
|
+
test("rejects an unknown literal code on an actionForm screen field too", () => {
|
|
1274
|
+
const feature = defineFeature("test", (r) => {
|
|
1275
|
+
r.writeHandler({
|
|
1276
|
+
name: "invoice:pay",
|
|
1277
|
+
schema: { _type: "stub" } as never,
|
|
1278
|
+
handler: async () => ({ isSuccess: true, data: {} }) as never,
|
|
1279
|
+
access: { openToAll: { reason: "test handler callable by any signed-in test user" } },
|
|
1280
|
+
});
|
|
1281
|
+
r.screen({
|
|
1282
|
+
id: "invoice-pay",
|
|
1283
|
+
type: "actionForm",
|
|
1284
|
+
handler: "test:write:invoice:pay",
|
|
1285
|
+
fields: { amount: { type: "money", currency: { kind: "literal", code: "EURO" } } } as never,
|
|
1286
|
+
layout: { sections: [{ title: "Pay", fields: ["amount"] }] } as never,
|
|
1287
|
+
});
|
|
1288
|
+
});
|
|
1289
|
+
expect(() => createApp({ roles: ["Admin"], features: [feature] })).toThrow(
|
|
1290
|
+
/Screen "invoice-pay" in feature "test", money field "amount"/,
|
|
1291
|
+
);
|
|
1292
|
+
});
|
|
1293
|
+
|
|
1207
1294
|
// hasMoneyField used to only look at top-level fields, so an entity whose
|
|
1208
1295
|
// only money lives inside an embedded-list sub-schema (e.g. invoice lines
|
|
1209
1296
|
// with no top-level money field) slipped past this check — its cells and
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
// fw#2839: the type half of the fail-closed currency-source gate. This PR
|
|
2
|
+
// ships the break in one release instead of the #2810 warn-then-throw ladder,
|
|
3
|
+
// on the premise that a consumer sees it from the COMPILER at bump time, not
|
|
4
|
+
// only from a boot error in production. These `@ts-expect-error`s are that
|
|
5
|
+
// premise: each turns into a test failure the moment the narrowing stops
|
|
6
|
+
// catching an undeclared money field in one of the three forms an author
|
|
7
|
+
// writes a screen in.
|
|
8
|
+
|
|
9
|
+
import { describe, expect, test } from "bun:test";
|
|
10
|
+
import type {
|
|
11
|
+
ActionFormScreenDefinition,
|
|
12
|
+
ScreenDefinition,
|
|
13
|
+
SecretMintScreenDefinition,
|
|
14
|
+
} from "@cosmicdrift/kumiko-types/screen";
|
|
15
|
+
|
|
16
|
+
const layout = { sections: [{ title: "Payment", fields: ["amount"] }] };
|
|
17
|
+
|
|
18
|
+
describe("money field on an entity-less form screen is a compile error without `currency`", () => {
|
|
19
|
+
test("annotated as the ScreenDefinition union", () => {
|
|
20
|
+
const screen: ScreenDefinition = {
|
|
21
|
+
id: "pay",
|
|
22
|
+
type: "actionForm",
|
|
23
|
+
handler: "billing:write:pay",
|
|
24
|
+
// @ts-expect-error — money needs a declared currency source on an
|
|
25
|
+
// entity-less form; if this compiles, FormFieldDefinition stopped
|
|
26
|
+
// narrowing and the break would only surface at boot.
|
|
27
|
+
fields: { amount: { type: "money", required: true } },
|
|
28
|
+
layout,
|
|
29
|
+
};
|
|
30
|
+
expect(screen.type).toBe("actionForm");
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test("annotated as ActionFormScreenDefinition", () => {
|
|
34
|
+
const screen: ActionFormScreenDefinition = {
|
|
35
|
+
id: "pay",
|
|
36
|
+
type: "actionForm",
|
|
37
|
+
handler: "billing:write:pay",
|
|
38
|
+
// @ts-expect-error — see above.
|
|
39
|
+
fields: { amount: { type: "money", required: true } },
|
|
40
|
+
layout,
|
|
41
|
+
};
|
|
42
|
+
expect(screen.id).toBe("pay");
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test("contextually typed through an r.screen(...)-shaped parameter", () => {
|
|
46
|
+
const register = (def: ScreenDefinition): string => def.id;
|
|
47
|
+
expect(
|
|
48
|
+
register({
|
|
49
|
+
id: "pay",
|
|
50
|
+
type: "actionForm",
|
|
51
|
+
handler: "billing:write:pay",
|
|
52
|
+
// @ts-expect-error — see above.
|
|
53
|
+
fields: { amount: { type: "money", required: true } },
|
|
54
|
+
layout,
|
|
55
|
+
}),
|
|
56
|
+
).toBe("pay");
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test("secretMint's own fields are narrowed the same way", () => {
|
|
60
|
+
const screen: SecretMintScreenDefinition = {
|
|
61
|
+
id: "mint",
|
|
62
|
+
type: "secretMint",
|
|
63
|
+
handler: "billing:write:mint",
|
|
64
|
+
// @ts-expect-error — see above.
|
|
65
|
+
fields: { amount: { type: "money", required: true } },
|
|
66
|
+
layout,
|
|
67
|
+
reveal: { fields: [{ field: "token", label: "Token" }] },
|
|
68
|
+
};
|
|
69
|
+
expect(screen.type).toBe("secretMint");
|
|
70
|
+
});
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
describe("declared currency sources compile", () => {
|
|
74
|
+
test("literal and tenant are both accepted", () => {
|
|
75
|
+
const screen: ScreenDefinition = {
|
|
76
|
+
id: "pay",
|
|
77
|
+
type: "actionForm",
|
|
78
|
+
handler: "billing:write:pay",
|
|
79
|
+
fields: {
|
|
80
|
+
amount: { type: "money", required: true, currency: { kind: "literal", code: "EUR" } },
|
|
81
|
+
fee: { type: "money", currency: { kind: "tenant" } },
|
|
82
|
+
},
|
|
83
|
+
layout: { sections: [{ title: "Payment", fields: ["amount", "fee"] }] },
|
|
84
|
+
};
|
|
85
|
+
expect(Object.keys(screen.type === "actionForm" ? screen.fields : {})).toEqual([
|
|
86
|
+
"amount",
|
|
87
|
+
"fee",
|
|
88
|
+
]);
|
|
89
|
+
});
|
|
90
|
+
});
|
|
@@ -7,6 +7,7 @@ import type {
|
|
|
7
7
|
EntityDefinition,
|
|
8
8
|
FeatureDefinition,
|
|
9
9
|
MultiSelectFieldDef,
|
|
10
|
+
QueryHandlerDef,
|
|
10
11
|
} from "../types";
|
|
11
12
|
|
|
12
13
|
export const FILE_FIELD_TYPES = new Set(["file", "image", "files", "images"]);
|
|
@@ -429,13 +430,16 @@ function isValidEmbeddedDecimalScale(scale: number): boolean {
|
|
|
429
430
|
// 3) Query handler `<feature>:query:<entity>:list` is registered — the
|
|
430
431
|
// renderer fires it on Combobox open, so a missing handler crashes the
|
|
431
432
|
// Combobox at runtime.
|
|
433
|
+
// 4) optionsQuery (if set) names a registered query handler.
|
|
432
434
|
function validateReferenceTarget(
|
|
433
435
|
entityName: string,
|
|
434
436
|
fieldPath: string,
|
|
435
437
|
refString: string,
|
|
436
438
|
labelField: string | undefined,
|
|
439
|
+
optionsQuery: string | undefined,
|
|
437
440
|
feature: FeatureDefinition,
|
|
438
441
|
featureMap: ReadonlyMap<string, FeatureDefinition>,
|
|
442
|
+
queryHandlers: ReadonlyMap<string, QueryHandlerDef>,
|
|
439
443
|
): void {
|
|
440
444
|
const target = parseRefTarget(refString, feature.name);
|
|
441
445
|
const targetFeature = featureMap.get(target.featureName);
|
|
@@ -489,6 +493,40 @@ function validateReferenceTarget(
|
|
|
489
493
|
`different label/entity.`,
|
|
490
494
|
);
|
|
491
495
|
}
|
|
496
|
+
// The check above still applies when optionsQuery is set: optionsQuery only
|
|
497
|
+
// replaces the picker's option list, while list cells keep resolving the
|
|
498
|
+
// UUID through `<targetFeature>:query:<targetEntity>:list`
|
|
499
|
+
// (use-reference-lookup).
|
|
500
|
+
validateReferenceOptionsQuery(entityName, fieldPath, optionsQuery, feature, queryHandlers);
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
// fw#2780: the picker may source its options from a query handler instead of
|
|
504
|
+
// the referenced entity's table. A typo'd QN would only surface as a 404 on
|
|
505
|
+
// first Combobox open, so it is pinned at boot — same treatment as
|
|
506
|
+
// `DashboardFilterDefinition.optionsQuery` (boot-validator/query-refs.ts).
|
|
507
|
+
function validateReferenceOptionsQuery(
|
|
508
|
+
entityName: string,
|
|
509
|
+
fieldPath: string,
|
|
510
|
+
optionsQuery: string | undefined,
|
|
511
|
+
feature: FeatureDefinition,
|
|
512
|
+
queryHandlers: ReadonlyMap<string, QueryHandlerDef>,
|
|
513
|
+
): void {
|
|
514
|
+
// skip: a field without optionsQuery keeps the entity-table option source.
|
|
515
|
+
if (optionsQuery === undefined) return;
|
|
516
|
+
if (optionsQuery.length === 0) {
|
|
517
|
+
throw new Error(
|
|
518
|
+
`[Feature ${feature.name}] Reference field "${fieldPath}" on entity "${entityName}" ` +
|
|
519
|
+
`has an empty optionsQuery. Drop the property or name a query handler.`,
|
|
520
|
+
);
|
|
521
|
+
}
|
|
522
|
+
if (!queryHandlers.has(optionsQuery)) {
|
|
523
|
+
throw new Error(
|
|
524
|
+
`[Feature ${feature.name}] Reference field "${fieldPath}" on entity "${entityName}" ` +
|
|
525
|
+
`declares optionsQuery "${optionsQuery}" which is not a registered query-handler. ` +
|
|
526
|
+
`Check the QN spelling (expected "<feature>:query:<short>") and that the handler ` +
|
|
527
|
+
`is declared via r.queryHandler(...).`,
|
|
528
|
+
);
|
|
529
|
+
}
|
|
492
530
|
}
|
|
493
531
|
|
|
494
532
|
// Tier 2.7e-3 + Cross-Feature: ReferenceFieldDef validation for top-level
|
|
@@ -498,6 +536,7 @@ function validateReferenceTarget(
|
|
|
498
536
|
export function validateReferenceFields(
|
|
499
537
|
feature: FeatureDefinition,
|
|
500
538
|
featureMap: ReadonlyMap<string, FeatureDefinition>,
|
|
539
|
+
queryHandlers: ReadonlyMap<string, QueryHandlerDef>,
|
|
501
540
|
): void {
|
|
502
541
|
for (const [entityName, entity] of Object.entries(feature.entities ?? {})) {
|
|
503
542
|
for (const [fieldName, field] of Object.entries(entity.fields)) {
|
|
@@ -507,8 +546,10 @@ export function validateReferenceFields(
|
|
|
507
546
|
fieldName,
|
|
508
547
|
field.entity,
|
|
509
548
|
field.labelField,
|
|
549
|
+
field.optionsQuery,
|
|
510
550
|
feature,
|
|
511
551
|
featureMap,
|
|
552
|
+
queryHandlers,
|
|
512
553
|
);
|
|
513
554
|
}
|
|
514
555
|
}
|
|
@@ -517,6 +558,7 @@ export function validateReferenceFields(
|
|
|
517
558
|
export function validateEmbeddedFields(
|
|
518
559
|
feature: FeatureDefinition,
|
|
519
560
|
featureMap: ReadonlyMap<string, FeatureDefinition>,
|
|
561
|
+
queryHandlers: ReadonlyMap<string, QueryHandlerDef>,
|
|
520
562
|
): void {
|
|
521
563
|
for (const [entityName, entity] of Object.entries(feature.entities ?? {})) {
|
|
522
564
|
for (const [fieldName, field] of Object.entries(entity.fields)) {
|
|
@@ -554,8 +596,10 @@ export function validateEmbeddedFields(
|
|
|
554
596
|
`${fieldName}.${subName}`,
|
|
555
597
|
subField.entity,
|
|
556
598
|
subField.labelField,
|
|
599
|
+
subField.optionsQuery,
|
|
557
600
|
feature,
|
|
558
601
|
featureMap,
|
|
602
|
+
queryHandlers,
|
|
559
603
|
);
|
|
560
604
|
}
|
|
561
605
|
}
|
|
@@ -52,6 +52,7 @@ import { collectClaimKeys, validateOwnershipRules } from "./ownership";
|
|
|
52
52
|
import { validateParentRefs } from "./parent-ref";
|
|
53
53
|
import { validatePiiAndRetention } from "./pii-retention";
|
|
54
54
|
import {
|
|
55
|
+
buildQueryHandlerMap,
|
|
55
56
|
validateProjectionListScreens,
|
|
56
57
|
validateRelatedListSectionQueries,
|
|
57
58
|
} from "./projection-list-screens";
|
|
@@ -187,6 +188,10 @@ export function validateBoot(
|
|
|
187
188
|
}
|
|
188
189
|
}
|
|
189
190
|
|
|
191
|
+
// Registered query-handler QNs, built once for the per-feature reference
|
|
192
|
+
// checks below (a reference field's `optionsQuery` may target any feature).
|
|
193
|
+
const queryHandlerQns = buildQueryHandlerMap(features);
|
|
194
|
+
|
|
190
195
|
let hasEncryptedFields = false;
|
|
191
196
|
let hasFileFields = false;
|
|
192
197
|
|
|
@@ -197,10 +202,10 @@ export function validateBoot(
|
|
|
197
202
|
validatePiiAndRetention(feature);
|
|
198
203
|
validateRecordOwnedSubjects(feature);
|
|
199
204
|
validateApiExposureMatching(feature, allExposedApis, featureMap);
|
|
200
|
-
validateEmbeddedFields(feature, featureMap);
|
|
205
|
+
validateEmbeddedFields(feature, featureMap, queryHandlerQns);
|
|
201
206
|
validateMultiSelectFields(feature);
|
|
202
207
|
validateImageVariants(feature);
|
|
203
|
-
validateReferenceFields(feature, featureMap);
|
|
208
|
+
validateReferenceFields(feature, featureMap, queryHandlerQns);
|
|
204
209
|
validateTransitions(feature);
|
|
205
210
|
validateExtensionUsages(feature, extensionProviders);
|
|
206
211
|
validateExtendSchemaCollisions(feature);
|
|
@@ -197,6 +197,14 @@ export function validatePiiAndRetention(feature: FeatureDefinition): void {
|
|
|
197
197
|
console.warn(
|
|
198
198
|
`[kumiko:boot] [Feature ${feature.name}] Field "${fieldName}" on entity "${entityName}" has a user-reference-typical name but no personal annotation — a foreign key into \`user\` carries Art.17 obligations even with no annotated content on the entity. Mark it { personal: "ref" } AND register r.useExtension(EXT_USER_DATA, "${entityName}", …) for Art.17 coverage — this warning is the only boot-time check for that, registering the hook is not enforced. Or { personal: { of: "${fieldName}" } } on the field it owns. If business data, set { personal: false, reason: "..." } to silence.`,
|
|
199
199
|
);
|
|
200
|
+
} else if (!annot.subjectRef && (field.type === "text" || field.type === "longText")) {
|
|
201
|
+
// Deprecation step towards #2810, where a text field without a
|
|
202
|
+
// stance becomes a boot error. Only text/longText: the other field
|
|
203
|
+
// types keep `personal` optional.
|
|
204
|
+
// biome-ignore lint/suspicious/noConsole: boot-time dev hint, no logger available yet
|
|
205
|
+
console.warn(
|
|
206
|
+
`[kumiko:boot] [Feature ${feature.name}] Field "${fieldName}" on entity "${entityName}" (type "${field.type}") declares no personal stance. Text fields will require one — this warning becomes a boot error in a future release (#2810). Declare { personal: "self" | "tenant" | "ref" | { of: "<ownerField>" } | false }; "false" additionally needs { reason: "..." } stating why the value is not personal data.`,
|
|
207
|
+
);
|
|
200
208
|
}
|
|
201
209
|
}
|
|
202
210
|
}
|
|
@@ -578,10 +578,59 @@ function validateFormFieldsMap(
|
|
|
578
578
|
`\`type\` set. Each field must declare a type (e.g. "text", "number", "select").`,
|
|
579
579
|
);
|
|
580
580
|
}
|
|
581
|
+
if (ftype === "money") {
|
|
582
|
+
validateFormMoneyCurrency(featureName, screenId, context, fname, fdef);
|
|
583
|
+
}
|
|
581
584
|
}
|
|
582
585
|
return fieldNames;
|
|
583
586
|
}
|
|
584
587
|
|
|
588
|
+
// Fail-closed currency-source gate (fw#2839), the runtime half of the
|
|
589
|
+
// narrowed `FormFieldDefinition` — an untyped JS consumer has no compiler to
|
|
590
|
+
// stop it. An inline form screen has no entity, so a money field there can't
|
|
591
|
+
// borrow `entity.defaultCurrency`: without a declared source the renderer
|
|
592
|
+
// seeds a bare `0` and the handler's zod schema rejects the submit. Entity
|
|
593
|
+
// money fields are exempt — create-app already refuses an entity that holds
|
|
594
|
+
// money without a `defaultCurrency`.
|
|
595
|
+
function validateFormMoneyCurrency(
|
|
596
|
+
featureName: string,
|
|
597
|
+
screenId: string,
|
|
598
|
+
context: string,
|
|
599
|
+
fieldName: string,
|
|
600
|
+
fdef: unknown,
|
|
601
|
+
): void {
|
|
602
|
+
const where = `[Feature ${featureName}] Screen "${screenId}" (${context}) money field "${fieldName}"`;
|
|
603
|
+
const validForms =
|
|
604
|
+
`Declare \`currency: { kind: "literal", code: "EUR" }\` for a fixed currency, or ` +
|
|
605
|
+
`\`currency: { kind: "tenant" }\` to take the tenant-settings bundle's per-tenant currency.`;
|
|
606
|
+
// @cast-boundary schema-walk — feature-config inspection (Author may circumvent type-check)
|
|
607
|
+
const currency = (fdef as { currency?: { kind?: unknown; code?: unknown } | null }).currency;
|
|
608
|
+
if (currency === undefined) {
|
|
609
|
+
throw new Error(
|
|
610
|
+
`${where} must declare where its currency comes from — this screen has no entity whose ` +
|
|
611
|
+
`\`defaultCurrency\` it could inherit, so the form would seed a bare \`0\` that the ` +
|
|
612
|
+
`handler's schema rejects. ${validForms}`,
|
|
613
|
+
);
|
|
614
|
+
}
|
|
615
|
+
if (typeof currency !== "object" || currency === null) {
|
|
616
|
+
throw new Error(`${where} has a non-object \`currency\`. ${validForms}`);
|
|
617
|
+
}
|
|
618
|
+
const kind = currency.kind;
|
|
619
|
+
if (kind === "literal") {
|
|
620
|
+
const code = currency.code;
|
|
621
|
+
if (typeof code !== "string" || code.trim() === "") {
|
|
622
|
+
throw new Error(
|
|
623
|
+
`${where} declares \`currency: { kind: "literal" }\` with an empty or non-string \`code\`. ` +
|
|
624
|
+
`Pass the ISO code, e.g. { kind: "literal", code: "EUR" }.`,
|
|
625
|
+
);
|
|
626
|
+
}
|
|
627
|
+
} else if (kind !== "tenant") {
|
|
628
|
+
throw new Error(
|
|
629
|
+
`${where} declares an unknown currency kind ${JSON.stringify(kind)}. ${validForms}`,
|
|
630
|
+
);
|
|
631
|
+
}
|
|
632
|
+
}
|
|
633
|
+
|
|
585
634
|
// `allowEmptySections` mirrors `validateFormFieldsMap`'s `allowEmpty` — the
|
|
586
635
|
// caller only ever passes true together with an actually-empty fields map
|
|
587
636
|
// (an input-less secretMint declares BOTH `fields: {}` and
|
|
@@ -1265,6 +1314,7 @@ export function validateScreens(
|
|
|
1265
1314
|
`(writeForm) has zero fields — drop the section or add fields to it.`,
|
|
1266
1315
|
);
|
|
1267
1316
|
}
|
|
1317
|
+
// kumiko-lint-ignore section-fields-raw writeForm sections carry no groups (EditWriteFormSection)
|
|
1268
1318
|
for (const f of section.fields) {
|
|
1269
1319
|
const fieldName = normalizeEditField(f).field;
|
|
1270
1320
|
if (section.fieldDefs[fieldName] === undefined) {
|
package/src/engine/create-app.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { ScreenDefinition } from "@cosmicdrift/kumiko-types/screen";
|
|
1
2
|
import { type ValidateBootOptions, validateBoot } from "./boot-validator";
|
|
2
3
|
import { dedupeFeatures } from "./dedupe-features";
|
|
3
4
|
import { createRegistry } from "./registry";
|
|
@@ -20,6 +21,37 @@ export type App = {
|
|
|
20
21
|
currencies: readonly string[];
|
|
21
22
|
};
|
|
22
23
|
|
|
24
|
+
// Every field map an entity-less inline form screen renders: actionForm's and
|
|
25
|
+
// secretMint's own fields, plus a secretMint's separate `confirm` step.
|
|
26
|
+
function inlineFormFieldMaps(
|
|
27
|
+
screen: ScreenDefinition,
|
|
28
|
+
): readonly Readonly<Record<string, unknown>>[] {
|
|
29
|
+
if (screen.type === "actionForm") return [screen.fields];
|
|
30
|
+
if (screen.type !== "secretMint") return [];
|
|
31
|
+
return screen.confirm !== undefined ? [screen.fields, screen.confirm.fields] : [screen.fields];
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// `currency: { kind: "literal", code }` (fw#2839) is only meaningful for a
|
|
35
|
+
// code the app knows — a typo would otherwise render and submit amounts in a
|
|
36
|
+
// currency no formatter or rate table covers.
|
|
37
|
+
function validateLiteralCurrencyCode(
|
|
38
|
+
where: string,
|
|
39
|
+
field: unknown,
|
|
40
|
+
currencies: readonly string[],
|
|
41
|
+
): void {
|
|
42
|
+
// @cast-boundary schema-walk — feature-config inspection (Author may circumvent type-check)
|
|
43
|
+
const shape = field as { type?: unknown; currency?: { kind?: unknown; code?: unknown } };
|
|
44
|
+
if (shape.type === "money" && shape.currency?.kind === "literal") {
|
|
45
|
+
const code = shape.currency.code;
|
|
46
|
+
if (typeof code !== "string" || !currencies.includes(code)) {
|
|
47
|
+
throw new Error(
|
|
48
|
+
`${where} declares currency: { kind: "literal", code: ${JSON.stringify(code)} } which is ` +
|
|
49
|
+
`not in the currencies list. Available: ${currencies.join(", ")}`,
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
23
55
|
export function createApp(config: AppConfig): App {
|
|
24
56
|
const features = dedupeFeatures(config.features);
|
|
25
57
|
const validRoles = new Set(config.roles);
|
|
@@ -108,6 +140,28 @@ export function createApp(config: AppConfig): App {
|
|
|
108
140
|
`Entity "${entityName}" in feature "${feature.name}" has money fields but no defaultCurrency. Set defaultCurrency on the entity definition.`,
|
|
109
141
|
);
|
|
110
142
|
}
|
|
143
|
+
for (const [fieldName, field] of Object.entries(entity.fields)) {
|
|
144
|
+
validateLiteralCurrencyCode(
|
|
145
|
+
`Entity "${entityName}" in feature "${feature.name}", money field "${fieldName}"`,
|
|
146
|
+
field,
|
|
147
|
+
currencies,
|
|
148
|
+
);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
// A money field on an entity-less form screen names its own currency
|
|
152
|
+
// source (fw#2839) — a literal code has to be one the app actually knows,
|
|
153
|
+
// same rule the entity `defaultCurrency` check above applies.
|
|
154
|
+
for (const [screenId, screen] of Object.entries(feature.screens ?? {})) {
|
|
155
|
+
const inlineFields = inlineFormFieldMaps(screen);
|
|
156
|
+
for (const fields of inlineFields) {
|
|
157
|
+
for (const [fieldName, field] of Object.entries(fields)) {
|
|
158
|
+
validateLiteralCurrencyCode(
|
|
159
|
+
`Screen "${screenId}" in feature "${feature.name}", money field "${fieldName}"`,
|
|
160
|
+
field,
|
|
161
|
+
currencies,
|
|
162
|
+
);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
111
165
|
}
|
|
112
166
|
}
|
|
113
167
|
|
|
@@ -30,6 +30,17 @@ import type {
|
|
|
30
30
|
TranslationsDef,
|
|
31
31
|
} from "./types";
|
|
32
32
|
|
|
33
|
+
// A payload field that still holds null/undefined after parsing — the case
|
|
34
|
+
// where an event-PII owner field yields no subject at append time (fw#2776).
|
|
35
|
+
// A `.default(...)` accepts undefined but parses to a value, so it is not
|
|
36
|
+
// absentable.
|
|
37
|
+
function isAbsentable(field: ZodType): boolean {
|
|
38
|
+
return [null, undefined].some((candidate) => {
|
|
39
|
+
const parsed = field.safeParse(candidate);
|
|
40
|
+
return parsed.success && (parsed.data === null || parsed.data === undefined);
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
|
|
33
44
|
// Builds config/secrets/claims/events/jobs/notifications registrar methods.
|
|
34
45
|
export function buildConfigEventsJobsMethods<TName extends string>(
|
|
35
46
|
state: FeatureBuilderState,
|
|
@@ -130,6 +141,14 @@ export function buildConfigEventsJobsMethods<TName extends string>(
|
|
|
130
141
|
);
|
|
131
142
|
}
|
|
132
143
|
}
|
|
144
|
+
const owner = shape?.[normalized.ownerField];
|
|
145
|
+
if (normalized.whenAbsent === undefined && owner !== undefined && isAbsentable(owner)) {
|
|
146
|
+
throw new Error(
|
|
147
|
+
`[Feature ${name}] defineEvent("${eventName}"): piiFields."${field}" is owned by "${normalized.ownerField}", which the payload schema allows to be null/undefined. ` +
|
|
148
|
+
`Declare what happens then: { personal: { of: "${normalized.ownerField}", whenAbsent: "tenant" } } encrypts under the envelope tenant key, ` +
|
|
149
|
+
`whenAbsent: "plaintext" acknowledges that the value ships unencrypted and cannot be crypto-shredded (fw#2776).`,
|
|
150
|
+
);
|
|
151
|
+
}
|
|
133
152
|
}
|
|
134
153
|
}
|
|
135
154
|
|
package/src/engine/index.ts
CHANGED
|
@@ -112,6 +112,7 @@ export type FieldsOrGroupsSection = {
|
|
|
112
112
|
// one runs after the fields-XOR-groups check, collectors run without it and must
|
|
113
113
|
// not drop a source (fw#2986).
|
|
114
114
|
export function sectionFieldSpecs(section: FieldsOrGroupsSection): readonly EditFieldSpec[] {
|
|
115
|
+
// kumiko-lint-ignore section-fields-raw this IS the union reader — the groups source is added on the same line
|
|
115
116
|
return [...section.fields, ...(section.groups?.flatMap((group) => group.fields) ?? [])];
|
|
116
117
|
}
|
|
117
118
|
|
|
@@ -56,12 +56,20 @@ const crmFeature = defineFeature("crm", (r) => {
|
|
|
56
56
|
// hand-written, so a camelCase event name (tenantNote → tenant-note) can
|
|
57
57
|
// never drift from what the catalog is actually keyed by (fw#2801).
|
|
58
58
|
let PING_EVENT_TYPE: string;
|
|
59
|
+
let PING_TENANT_EVENT_TYPE: string;
|
|
60
|
+
const pingSchema = z.object({
|
|
61
|
+
targetId: z.string().nullable(),
|
|
62
|
+
address: z.string().nullable(),
|
|
63
|
+
});
|
|
59
64
|
const mailerFeature = defineFeature("mailer", (r) => {
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
).name;
|
|
65
|
+
// targetId is nullable, so the stance has to say what happens without an
|
|
66
|
+
// owner — the two events below cover both declarations (fw#2776).
|
|
67
|
+
PING_EVENT_TYPE = r.defineEvent("ping", pingSchema, {
|
|
68
|
+
piiFields: { address: { personal: { of: "targetId", whenAbsent: "plaintext" } } },
|
|
69
|
+
}).name;
|
|
70
|
+
PING_TENANT_EVENT_TYPE = r.defineEvent("pingTenant", pingSchema, {
|
|
71
|
+
piiFields: { address: { personal: { of: "targetId", whenAbsent: "tenant" } } },
|
|
72
|
+
}).name;
|
|
65
73
|
});
|
|
66
74
|
|
|
67
75
|
// fw#2801: custom events declaring a tenant/self subject — no entity,
|
|
@@ -272,7 +280,7 @@ describe("backfillEventPiiEncryption", () => {
|
|
|
272
280
|
>;
|
|
273
281
|
expect(erased["address"]).toBe(PII_ERASED_SENTINEL);
|
|
274
282
|
|
|
275
|
-
// No subject
|
|
283
|
+
// No subject and whenAbsent: "plaintext" → declared, stays plaintext.
|
|
276
284
|
const system = (await loadAggregate(testDb.db, p3, TENANT))[0]?.payload as Record<
|
|
277
285
|
string,
|
|
278
286
|
unknown
|
|
@@ -280,6 +288,22 @@ describe("backfillEventPiiEncryption", () => {
|
|
|
280
288
|
expect(system["address"]).toBe("ops@x.com");
|
|
281
289
|
});
|
|
282
290
|
|
|
291
|
+
test('an absent owner with whenAbsent: "tenant" backfills under the tenant key (fw#2776)', async () => {
|
|
292
|
+
const p4 = generateId();
|
|
293
|
+
await appendPlain(p4, "ping", PING_TENANT_EVENT_TYPE, {
|
|
294
|
+
targetId: null,
|
|
295
|
+
address: "ops@x.com",
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
armKms();
|
|
299
|
+
const result = await backfillEventPiiEncryption(testDb.db, registry);
|
|
300
|
+
expect(result.failures).toEqual([]);
|
|
301
|
+
|
|
302
|
+
const row = (await loadAggregate(testDb.db, p4, TENANT))[0]?.payload as Record<string, unknown>;
|
|
303
|
+
expect(isPiiCiphertext(row["address"])).toBe(true);
|
|
304
|
+
expect(String(row["address"])).toContain(`tenant:${TENANT}`);
|
|
305
|
+
});
|
|
306
|
+
|
|
283
307
|
test("idempotent: second run updates nothing; dryRun writes nothing", async () => {
|
|
284
308
|
const c1 = generateId();
|
|
285
309
|
await appendPlain(c1, "contact", "contact.created", { id: c1, email: "a@x.com" });
|
|
@@ -328,6 +328,7 @@ export function requiredKeysFromScreen(
|
|
|
328
328
|
if (isWriteFormEditSection(section)) {
|
|
329
329
|
pushKey(out, section.title);
|
|
330
330
|
pushKey(out, section.submitLabel);
|
|
331
|
+
// kumiko-lint-ignore section-fields-raw writeForm sections carry no groups (EditWriteFormSection)
|
|
331
332
|
for (const f of section.fields) {
|
|
332
333
|
const fieldName = editFieldName(f);
|
|
333
334
|
out.add(fieldLabelKey(featureName, WRITE_FORM_SECTION_ENTITY, fieldName));
|