@cosmicdrift/kumiko-framework 0.220.0 → 0.221.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__/store-table.integration.test.ts +2 -1
- package/src/__tests__/upgrade-cli.test.ts +81 -12
- package/src/api/__tests__/request-locale.integration.test.ts +2 -2
- package/src/api/api-constants.ts +10 -0
- package/src/api/index.ts +1 -0
- package/src/api/server.ts +17 -1
- package/src/bun-db/__tests__/coerce-row-plain-date.test.ts +2 -0
- package/src/bun-db/__tests__/coerce-row-temporal.test.ts +2 -1
- package/src/bun-db/index.ts +1 -0
- package/src/bun-db/query.ts +30 -14
- package/src/crypto/index.ts +1 -0
- package/src/crypto/is-self-pii-field.ts +8 -0
- package/src/crypto/subject-resolver.ts +4 -3
- package/src/db/__tests__/event-store-executor-list.integration.test.ts +31 -2
- package/src/db/__tests__/migrate-generator.test.ts +12 -0
- package/src/db/__tests__/multi-row-insert.integration.test.ts +2 -0
- package/src/db/__tests__/schema-migration.integration.test.ts +1 -0
- package/src/db/__tests__/source-shadow-create.integration.test.ts +2 -0
- package/src/db/blind-index-cleanup.ts +36 -19
- package/src/db/event-store-executor-context.ts +2 -2
- package/src/db/event-store-executor-read.ts +7 -6
- package/src/db/event-store-executor-write.ts +103 -49
- package/src/db/index.ts +2 -0
- package/src/db/migrate-generator.ts +14 -0
- package/src/db/queries/__tests__/unsafe-read-retrying.test.ts +8 -1
- package/src/db/queries/backfill-pii.ts +13 -10
- package/src/db/queries/raw-sql.ts +14 -2
- package/src/db/queries/seed-context.ts +8 -4
- package/src/derivatives/__tests__/variant-route.integration.test.ts +3 -0
- package/src/engine/__tests__/boot-validator-pii-retention.test.ts +32 -26
- package/src/engine/__tests__/boot-validator.test.ts +29 -3
- package/src/engine/__tests__/role-assignment.test.ts +41 -17
- package/src/engine/__tests__/schema-builder.test.ts +3 -3
- package/src/engine/boot-validator/__tests__/i18n-keys.test.ts +147 -14
- package/src/engine/boot-validator/entity-handler.ts +5 -0
- package/src/engine/boot-validator/pii-retention.ts +16 -4
- package/src/engine/boot-validator/screens.ts +9 -2
- package/src/engine/embedded-derived.ts +11 -10
- package/src/engine/feature-ast/__tests__/patch.test.ts +10 -0
- package/src/engine/feature-ast/patch.ts +9 -0
- package/src/engine/role-assignment.ts +36 -18
- package/src/errors/__tests__/classes.test.ts +5 -0
- package/src/errors/__tests__/write-failures.test.ts +3 -3
- package/src/errors/kumiko-error.ts +11 -11
- package/src/event-store/__tests__/backfill-pii.integration.test.ts +58 -0
- package/src/event-store/__tests__/perf.integration.test.ts +5 -1
- package/src/event-store/__tests__/unscoped-stream-primitives.guard.test.ts +1 -0
- package/src/event-store/event-store.ts +7 -0
- package/src/event-store/index.ts +1 -0
- package/src/files/__tests__/files.integration.test.ts +181 -2
- package/src/files/__tests__/storage-tracking.integration.test.ts +3 -0
- package/src/files/file-routes.ts +53 -6
- package/src/i18n/__tests__/mail-registry.test.ts +13 -1
- package/src/i18n/__tests__/request-locale.test.ts +19 -0
- package/src/i18n/index.ts +7 -1
- package/src/i18n/mail-registry.ts +10 -0
- package/src/i18n/request-locale.ts +11 -2
- package/src/i18n/required-surface-keys.ts +3 -1
- package/src/lifecycle/signal-handlers.ts +2 -0
- package/src/pipeline/__tests__/distributed-lock.integration.test.ts +12 -0
- package/src/pipeline/__tests__/event-dispatcher-pg-listen.integration.test.ts +26 -42
- package/src/pipeline/__tests__/load-aggregate-query.integration.test.ts +8 -6
- package/src/pipeline/distributed-lock.ts +3 -0
- package/src/schema-cli.ts +9 -4
- package/src/scripts/codemod/crypto-shredding-testing-move.ts +64 -32
- package/src/scripts/codemod/pii-personal-migration.ts +30 -14
- package/src/search/purge-subject.ts +4 -3
- package/src/search/reindex-entity.ts +2 -2
- package/src/stack/__tests__/request-helper.integration.test.ts +24 -10
- package/src/stack/test-stack.ts +3 -0
- package/src/ui-types/index.ts +1 -0
- package/src/upgrade-cli.ts +100 -12
|
@@ -2264,6 +2264,8 @@ describe("boot-validator", () => {
|
|
|
2264
2264
|
readonly fields: readonly string[];
|
|
2265
2265
|
}>;
|
|
2266
2266
|
readonly configKeys?: Readonly<Record<string, string>>;
|
|
2267
|
+
readonly mode?: "single" | "wizard";
|
|
2268
|
+
readonly draft?: boolean;
|
|
2267
2269
|
};
|
|
2268
2270
|
|
|
2269
2271
|
function makeFeature(override: ConfigEditOverride = {}) {
|
|
@@ -2291,7 +2293,11 @@ describe("boot-validator", () => {
|
|
|
2291
2293
|
scope: "tenant",
|
|
2292
2294
|
configKeys,
|
|
2293
2295
|
fields: fields as never,
|
|
2294
|
-
layout: {
|
|
2296
|
+
layout: {
|
|
2297
|
+
sections: sections as never,
|
|
2298
|
+
...(override.mode !== undefined ? { mode: override.mode } : {}),
|
|
2299
|
+
...(override.draft !== undefined ? { draft: override.draft } : {}),
|
|
2300
|
+
},
|
|
2295
2301
|
});
|
|
2296
2302
|
});
|
|
2297
2303
|
}
|
|
@@ -2383,6 +2389,26 @@ describe("boot-validator", () => {
|
|
|
2383
2389
|
const section = { kind: "extension", title: "Custom", component: { react: "Panel" } };
|
|
2384
2390
|
expect(() => validateBoot([makeFeature({ sections: [section] as never })])).not.toThrow();
|
|
2385
2391
|
});
|
|
2392
|
+
test("mode: wizard mit nur 1 Section → Throw", () => {
|
|
2393
|
+
expect(() =>
|
|
2394
|
+
validateBoot([
|
|
2395
|
+
makeFeature({
|
|
2396
|
+
mode: "wizard",
|
|
2397
|
+
sections: [{ title: "Basics", fields: ["siteName"] }],
|
|
2398
|
+
}),
|
|
2399
|
+
]),
|
|
2400
|
+
).toThrow(/\(configEdit\).*mode: "wizard" but only 1 section\(s\)/);
|
|
2401
|
+
});
|
|
2402
|
+
|
|
2403
|
+
test("draft: true ohne gemountetes form-draft-Feature → Throw", () => {
|
|
2404
|
+
const sections = [
|
|
2405
|
+
{ title: "Step 1", fields: ["siteName"] },
|
|
2406
|
+
{ title: "Step 2", fields: ["maxUploadMb"] },
|
|
2407
|
+
];
|
|
2408
|
+
expect(() => validateBoot([makeFeature({ mode: "wizard", sections, draft: true })])).toThrow(
|
|
2409
|
+
/"form-draft" is not mounted/,
|
|
2410
|
+
);
|
|
2411
|
+
});
|
|
2386
2412
|
});
|
|
2387
2413
|
|
|
2388
2414
|
// --- entityEdit extension section ---
|
|
@@ -3245,7 +3271,7 @@ describe("boot-validator", () => {
|
|
|
3245
3271
|
label: "actions.view",
|
|
3246
3272
|
screen: "product-detail",
|
|
3247
3273
|
entity: "product",
|
|
3248
|
-
},
|
|
3274
|
+
} as never,
|
|
3249
3275
|
],
|
|
3250
3276
|
});
|
|
3251
3277
|
r.screen({
|
|
@@ -3268,7 +3294,7 @@ describe("boot-validator", () => {
|
|
|
3268
3294
|
type: "entityList",
|
|
3269
3295
|
entity: "product",
|
|
3270
3296
|
columns: ["name"],
|
|
3271
|
-
rowActions: [{ kind: "navigate", id: "view", label: "actions.view" }],
|
|
3297
|
+
rowActions: [{ kind: "navigate", id: "view", label: "actions.view" } as never],
|
|
3272
3298
|
});
|
|
3273
3299
|
});
|
|
3274
3300
|
expect(() => validateBoot([feature])).toThrow(
|
|
@@ -1,35 +1,39 @@
|
|
|
1
1
|
import { describe, expect, test } from "bun:test";
|
|
2
2
|
import { findForbiddenRoleAssignment } from "../role-assignment";
|
|
3
3
|
|
|
4
|
+
// Mirror DEFAULT_INVITE_ROLE_OPTIONS — framework must not import bundled-features
|
|
5
|
+
// (tsc pulls source into framework's rootDir and fails the package build).
|
|
6
|
+
const DEFAULT_INVITE_ROLE_OPTIONS = ["User", "Editor", "Admin", "TenantAdmin"] as const;
|
|
7
|
+
|
|
4
8
|
describe("role assignment guard", () => {
|
|
5
9
|
test("rejects roles above the actor's highest role", () => {
|
|
6
|
-
expect(findForbiddenRoleAssignment(["Admin"], ["User", "TenantAdmin"])).toBe("TenantAdmin");
|
|
7
|
-
expect(findForbiddenRoleAssignment(["TenantAdmin"], ["SystemAdmin"])).toBe("SystemAdmin");
|
|
10
|
+
expect(findForbiddenRoleAssignment(["Admin"], ["User", "TenantAdmin"], [])).toBe("TenantAdmin");
|
|
11
|
+
expect(findForbiddenRoleAssignment(["TenantAdmin"], ["SystemAdmin"], [])).toBe("SystemAdmin");
|
|
8
12
|
});
|
|
9
13
|
|
|
10
14
|
test("allows equal or lower roles, including self-updates", () => {
|
|
11
|
-
expect(findForbiddenRoleAssignment(["Admin"], ["User", "Admin"])).toBeUndefined();
|
|
12
|
-
expect(findForbiddenRoleAssignment(["Admin"], ["Editor"])).toBeUndefined();
|
|
13
|
-
expect(findForbiddenRoleAssignment(["Editor"], ["User", "Editor"])).toBeUndefined();
|
|
14
|
-
expect(findForbiddenRoleAssignment(["TenantAdmin"], ["User", "Admin"])).toBeUndefined();
|
|
15
|
-
expect(findForbiddenRoleAssignment(["Admin"], ["Admin"])).toBeUndefined();
|
|
16
|
-
expect(findForbiddenRoleAssignment(["SystemAdmin"], ["SystemAdmin"])).toBeUndefined();
|
|
17
|
-
expect(findForbiddenRoleAssignment(["system"], ["SystemAdmin"])).toBeUndefined();
|
|
15
|
+
expect(findForbiddenRoleAssignment(["Admin"], ["User", "Admin"], [])).toBeUndefined();
|
|
16
|
+
expect(findForbiddenRoleAssignment(["Admin"], ["Editor"], [])).toBeUndefined();
|
|
17
|
+
expect(findForbiddenRoleAssignment(["Editor"], ["User", "Editor"], [])).toBeUndefined();
|
|
18
|
+
expect(findForbiddenRoleAssignment(["TenantAdmin"], ["User", "Admin"], [])).toBeUndefined();
|
|
19
|
+
expect(findForbiddenRoleAssignment(["Admin"], ["Admin"], [])).toBeUndefined();
|
|
20
|
+
expect(findForbiddenRoleAssignment(["SystemAdmin"], ["SystemAdmin"], [])).toBeUndefined();
|
|
21
|
+
expect(findForbiddenRoleAssignment(["system"], ["SystemAdmin"], [])).toBeUndefined();
|
|
18
22
|
});
|
|
19
23
|
|
|
20
24
|
test("rejects Editor above User, Admin above Editor", () => {
|
|
21
|
-
expect(findForbiddenRoleAssignment(["User"], ["Editor"])).toBe("Editor");
|
|
22
|
-
expect(findForbiddenRoleAssignment(["Editor"], ["Admin"])).toBe("Admin");
|
|
25
|
+
expect(findForbiddenRoleAssignment(["User"], ["Editor"], [])).toBe("Editor");
|
|
26
|
+
expect(findForbiddenRoleAssignment(["Editor"], ["Admin"], [])).toBe("Admin");
|
|
23
27
|
});
|
|
24
28
|
|
|
25
29
|
test("rejects unknown roles (fail-closed)", () => {
|
|
26
|
-
expect(findForbiddenRoleAssignment(["User"], ["Custom"])).toBe("Custom");
|
|
27
|
-
expect(findForbiddenRoleAssignment(["SystemAdmin"], ["Custom"])).toBe("Custom");
|
|
30
|
+
expect(findForbiddenRoleAssignment(["User"], ["Custom"], [])).toBe("Custom");
|
|
31
|
+
expect(findForbiddenRoleAssignment(["SystemAdmin"], ["Custom"], [])).toBe("Custom");
|
|
28
32
|
});
|
|
29
33
|
|
|
30
34
|
test("rejects assignment if actor has no roles or only unknown roles", () => {
|
|
31
|
-
expect(findForbiddenRoleAssignment([], ["User"])).toBe("User");
|
|
32
|
-
expect(findForbiddenRoleAssignment(["UnknownRole"], ["User"])).toBe("User");
|
|
35
|
+
expect(findForbiddenRoleAssignment([], ["User"], [])).toBe("User");
|
|
36
|
+
expect(findForbiddenRoleAssignment(["UnknownRole"], ["User"], [])).toBe("User");
|
|
33
37
|
});
|
|
34
38
|
|
|
35
39
|
test("rejects modifying target user with higher existing role", () => {
|
|
@@ -41,7 +45,27 @@ describe("role assignment guard", () => {
|
|
|
41
45
|
// Editor is ranked (invite options); use a true app-defined role here.
|
|
42
46
|
expect(findForbiddenRoleAssignment(["TenantAdmin"], ["User"], ["Billing"])).toBeUndefined();
|
|
43
47
|
expect(findForbiddenRoleAssignment(["Admin"], ["User"], ["Billing", "User"])).toBeUndefined();
|
|
44
|
-
//
|
|
45
|
-
expect(findForbiddenRoleAssignment(["TenantAdmin"], ["Billing"], [
|
|
48
|
+
// Cannot introduce a *new* unranked role (fail-closed).
|
|
49
|
+
expect(findForbiddenRoleAssignment(["TenantAdmin"], ["Billing"], [])).toBe("Billing");
|
|
50
|
+
// Round-trip: strip then re-assign an unranked role the target already had.
|
|
51
|
+
expect(findForbiddenRoleAssignment(["TenantAdmin"], ["Billing"], ["Billing"])).toBeUndefined();
|
|
52
|
+
expect(
|
|
53
|
+
findForbiddenRoleAssignment(["TenantAdmin"], ["User", "Billing"], ["Billing"]),
|
|
54
|
+
).toBeUndefined();
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test("prototype-polluting role names do not bypass the guard", () => {
|
|
58
|
+
expect(findForbiddenRoleAssignment(["constructor"], ["SystemAdmin"], [])).toBe("SystemAdmin");
|
|
59
|
+
expect(findForbiddenRoleAssignment(["SystemAdmin"], ["toString"], [])).toBe("toString");
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test("empty-string role is forbidden (not truthiness-skipped)", () => {
|
|
63
|
+
expect(findForbiddenRoleAssignment(["Admin"], [""], [])).toBe("");
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test("TenantAdmin can assign every default invite role", () => {
|
|
67
|
+
for (const role of DEFAULT_INVITE_ROLE_OPTIONS) {
|
|
68
|
+
expect(findForbiddenRoleAssignment(["TenantAdmin"], [role], [])).toBeUndefined();
|
|
69
|
+
}
|
|
46
70
|
});
|
|
47
71
|
});
|
|
@@ -1019,7 +1019,7 @@ describe("embedded-list derived cell recomputation (kumiko-framework#1837)", ()
|
|
|
1019
1019
|
const schema = buildInsertSchema(entity);
|
|
1020
1020
|
// Each source is in-scale for its own scale-3 field, but the sum
|
|
1021
1021
|
// (0.333) has 3 decimal digits — over-scale for the scale-2 target
|
|
1022
|
-
// without the rounding
|
|
1022
|
+
// without the rounding from #1867 (roundDerivedCellValue).
|
|
1023
1023
|
const result = schema.safeParse({ lines: [{ a: 0.111, b: 0.111, c: 0.111 }] });
|
|
1024
1024
|
expect(result.success).toBe(true);
|
|
1025
1025
|
if (result.success) {
|
|
@@ -1043,8 +1043,8 @@ describe("embedded-list derived cell recomputation (kumiko-framework#1837)", ()
|
|
|
1043
1043
|
},
|
|
1044
1044
|
});
|
|
1045
1045
|
const schema = buildInsertSchema(entity);
|
|
1046
|
-
// 2400.58 - 93 = 2307.58
|
|
1047
|
-
// integer constraint without rounding.
|
|
1046
|
+
// 2400.58 - 93 = 2307.58 major units — not representable by money's
|
|
1047
|
+
// integer minor-unit constraint without rounding (#1867).
|
|
1048
1048
|
const result = schema.safeParse({ lines: [{ gross: 2400.58, refund: 93 }] });
|
|
1049
1049
|
expect(result.success).toBe(true);
|
|
1050
1050
|
if (result.success) {
|
|
@@ -1,8 +1,28 @@
|
|
|
1
1
|
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { buildConfigFeatureSchema } from "../../build-config-feature-schema";
|
|
2
3
|
import { access, createTenantConfig } from "../../config-helpers";
|
|
3
4
|
import { defineFeature } from "../../define-feature";
|
|
5
|
+
import { createRegistry } from "../../registry";
|
|
6
|
+
import { isFieldsEditSection } from "../../screen-helpers";
|
|
7
|
+
import type { ConfigEditScreenDefinition } from "../../types";
|
|
4
8
|
import { validateBoot } from "../index";
|
|
5
9
|
|
|
10
|
+
// Mirrors packages/bundled-features/src/config/i18n.ts — the audience-parent
|
|
11
|
+
// labels every masked config key's generated hub requires regardless of
|
|
12
|
+
// which feature owns the key or which scope its keys are visible at
|
|
13
|
+
// (requiredKeysFromGeneratedConfigHub adds all four unconditionally once
|
|
14
|
+
// any masked key exists, see i18n-keys.ts:35-38).
|
|
15
|
+
const configHub = defineFeature("config", (r) => {
|
|
16
|
+
r.translations({
|
|
17
|
+
keys: {
|
|
18
|
+
"config.settings.title": { en: "Settings" },
|
|
19
|
+
"config.settings.system": { en: "Platform" },
|
|
20
|
+
"config.settings.tenant": { en: "Tenant" },
|
|
21
|
+
"config.settings.user": { en: "Personal" },
|
|
22
|
+
},
|
|
23
|
+
});
|
|
24
|
+
});
|
|
25
|
+
|
|
6
26
|
// fw#2260: the Settings-Hub generator (buildConfigFeatureSchema) labels a
|
|
7
27
|
// masked config key's generated nav entry / configEdit section with the
|
|
8
28
|
// dot-form key `${feature}.settings` — never colon-form. isI18nKey's
|
|
@@ -10,20 +30,6 @@ import { validateBoot } from "../index";
|
|
|
10
30
|
// feature could ship a generated Settings screen whose label was never
|
|
11
31
|
// translated and boot validation stayed silent.
|
|
12
32
|
describe("validateI18nSurfaceKeys — Settings-Hub generated dot-form label (fw#2260)", () => {
|
|
13
|
-
// Mirrors packages/bundled-features/src/config/i18n.ts — the audience-parent
|
|
14
|
-
// labels every masked config key's generated hub requires regardless of
|
|
15
|
-
// which feature owns the key.
|
|
16
|
-
const configHub = defineFeature("config", (r) => {
|
|
17
|
-
r.translations({
|
|
18
|
-
keys: {
|
|
19
|
-
"config.settings.title": { en: "Settings" },
|
|
20
|
-
"config.settings.system": { en: "Platform" },
|
|
21
|
-
"config.settings.tenant": { en: "Organization" },
|
|
22
|
-
"config.settings.user": { en: "Personal" },
|
|
23
|
-
},
|
|
24
|
-
});
|
|
25
|
-
});
|
|
26
|
-
|
|
27
33
|
function billingFeature(translationKeys: Record<string, { readonly en: string }>) {
|
|
28
34
|
return defineFeature("billing", (r) => {
|
|
29
35
|
r.config({
|
|
@@ -61,4 +67,131 @@ describe("validateI18nSurfaceKeys — Settings-Hub generated dot-form label (fw#
|
|
|
61
67
|
});
|
|
62
68
|
expect(() => validateBoot([configHub, billing])).not.toThrow();
|
|
63
69
|
});
|
|
70
|
+
|
|
71
|
+
test("group namespace ≠ feature name: blank translation key satisfies hub label", () => {
|
|
72
|
+
const billing = defineFeature("billing", (r) => {
|
|
73
|
+
r.config({
|
|
74
|
+
keys: {
|
|
75
|
+
apiKey: createTenantConfig("text", {
|
|
76
|
+
write: access.roles("TenantAdmin"),
|
|
77
|
+
group: "tenant-settings",
|
|
78
|
+
mask: { title: "billing.api-key" },
|
|
79
|
+
}),
|
|
80
|
+
},
|
|
81
|
+
});
|
|
82
|
+
r.translations({
|
|
83
|
+
keys: {
|
|
84
|
+
"billing.api-key": { en: "API Key" },
|
|
85
|
+
"screen:tenant-settings-tenant.title": { en: "Tenant Settings" },
|
|
86
|
+
"tenant-settings.settings": { en: "Tenant Settings" },
|
|
87
|
+
},
|
|
88
|
+
});
|
|
89
|
+
});
|
|
90
|
+
expect(() => validateBoot([configHub, billing])).not.toThrow();
|
|
91
|
+
});
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
// PR #2314 idx2: buildConfigFeatureSchema varies the generated dot-form label
|
|
95
|
+
// along two more paths that fw#2260's tests above never exercise — a tenant
|
|
96
|
+
// key with an elevated (SystemAdmin) write role surfaces the same feature
|
|
97
|
+
// under a second, broader scope (build-config-feature-schema.ts:142-158), and
|
|
98
|
+
// a feature can opt into a configEdit section description
|
|
99
|
+
// (build-config-feature-schema.ts:246-249). The `group`-namespace path is
|
|
100
|
+
// deliberately left untested here — fw#2314 idx1 tracks it as broken
|
|
101
|
+
// (treatDotFormAsKey requires an unsatisfiable `${group}.settings` key for a
|
|
102
|
+
// namespace that has no matching feature), and a test would either fail on
|
|
103
|
+
// the open bug or paper over it; it belongs with the idx1 fix, not this batch.
|
|
104
|
+
describe("validateI18nSurfaceKeys — scoped label fallback for elevated write roles (PR #2314 idx2)", () => {
|
|
105
|
+
function opsFeature(translationKeys: Record<string, { readonly en: string }>) {
|
|
106
|
+
return defineFeature("ops", (r) => {
|
|
107
|
+
r.config({
|
|
108
|
+
keys: {
|
|
109
|
+
// Home scope tenant + an elevated SystemAdmin write role surfaces
|
|
110
|
+
// "ops" under BOTH the tenant audience (home, full write set) and
|
|
111
|
+
// the system audience (cascade, write set ∩ ELEVATED_ROLES.system)
|
|
112
|
+
// — two navs/screens for one feature.
|
|
113
|
+
maintenanceMode: createTenantConfig("boolean", {
|
|
114
|
+
write: access.roles("TenantAdmin", "SystemAdmin"),
|
|
115
|
+
mask: { title: "ops.maintenance-mode" },
|
|
116
|
+
}),
|
|
117
|
+
},
|
|
118
|
+
});
|
|
119
|
+
if (Object.keys(translationKeys).length > 0) {
|
|
120
|
+
r.translations({ keys: translationKeys });
|
|
121
|
+
}
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
test("elevated write role surfaces the feature at two scopes; the scoped override labels the system nav while the tenant nav falls back to the plain key", () => {
|
|
126
|
+
const translations = {
|
|
127
|
+
"ops.maintenance-mode": { en: "Maintenance Mode" },
|
|
128
|
+
"screen:ops-tenant.title": { en: "Ops Settings" },
|
|
129
|
+
"screen:ops-system.title": { en: "Ops Settings (Platform)" },
|
|
130
|
+
// Required unconditionally: both screens' section title is always
|
|
131
|
+
// `${feature}.settings` (build-config-feature-schema.ts:248).
|
|
132
|
+
"ops.settings": { en: "Ops" },
|
|
133
|
+
// Opt-in scoped override — only the system (cascade) nav uses it.
|
|
134
|
+
"ops.settings.system": { en: "Ops (Platform Cascade)" },
|
|
135
|
+
};
|
|
136
|
+
const ops = opsFeature(translations);
|
|
137
|
+
|
|
138
|
+
const schema = buildConfigFeatureSchema(createRegistry([configHub, ops]));
|
|
139
|
+
expect(schema.navs.find((n) => n.id === "ops-system")?.label).toBe("ops.settings.system");
|
|
140
|
+
expect(schema.navs.find((n) => n.id === "ops-tenant")?.label).toBe("ops.settings");
|
|
141
|
+
|
|
142
|
+
expect(() => validateBoot([configHub, ops])).not.toThrow();
|
|
143
|
+
});
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
// isI18nKeys.ts's requiredKeysFromScreen never reads EditFieldsSection.description
|
|
147
|
+
// (only title + field labels, see required-surface-keys.ts:210-224), so this
|
|
148
|
+
// gate is invisible to validateBoot either way — a validateBoot(...).not.toThrow()
|
|
149
|
+
// assertion alone would pass identically with build-config-feature-schema.ts:249
|
|
150
|
+
// deleted outright. Assert on the generated schema itself instead, the only
|
|
151
|
+
// artifact the gate actually changes.
|
|
152
|
+
describe("validateI18nSurfaceKeys — gated configEdit section description (PR #2314 idx2)", () => {
|
|
153
|
+
function reportingFeature(translationKeys: Record<string, { readonly en: string }>) {
|
|
154
|
+
return defineFeature("reporting", (r) => {
|
|
155
|
+
r.config({
|
|
156
|
+
keys: {
|
|
157
|
+
retentionDays: createTenantConfig("number", {
|
|
158
|
+
write: access.roles("TenantAdmin"),
|
|
159
|
+
mask: { title: "reporting.retention-days" },
|
|
160
|
+
}),
|
|
161
|
+
},
|
|
162
|
+
});
|
|
163
|
+
r.translations({ keys: translationKeys });
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function reportingSection(translationKeys: Record<string, { readonly en: string }>) {
|
|
168
|
+
const reporting = reportingFeature(translationKeys);
|
|
169
|
+
const schema = buildConfigFeatureSchema(createRegistry([configHub, reporting]));
|
|
170
|
+
const screen = schema.screens.find(
|
|
171
|
+
(s): s is ConfigEditScreenDefinition => s.type === "configEdit",
|
|
172
|
+
);
|
|
173
|
+
if (screen === undefined) throw new Error("expected a generated configEdit screen");
|
|
174
|
+
const section = screen.layout.sections[0];
|
|
175
|
+
if (section === undefined || !isFieldsEditSection(section)) {
|
|
176
|
+
throw new Error("expected a fields section");
|
|
177
|
+
}
|
|
178
|
+
return section;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
test("declaring 'reporting.settings.description' adds it to the generated section; omitting it leaves the section without one", () => {
|
|
182
|
+
const withDescription = reportingSection({
|
|
183
|
+
"reporting.retention-days": { en: "Retention (days)" },
|
|
184
|
+
"screen:reporting-tenant.title": { en: "Reporting Settings" },
|
|
185
|
+
"reporting.settings": { en: "Reporting" },
|
|
186
|
+
"reporting.settings.description": { en: "Configure how long reports are kept." },
|
|
187
|
+
});
|
|
188
|
+
expect(withDescription.description).toBe("reporting.settings.description");
|
|
189
|
+
|
|
190
|
+
const withoutDescription = reportingSection({
|
|
191
|
+
"reporting.retention-days": { en: "Retention (days)" },
|
|
192
|
+
"screen:reporting-tenant.title": { en: "Reporting Settings" },
|
|
193
|
+
"reporting.settings": { en: "Reporting" },
|
|
194
|
+
});
|
|
195
|
+
expect(withoutDescription.description).toBeUndefined();
|
|
196
|
+
});
|
|
64
197
|
});
|
|
@@ -587,6 +587,11 @@ function validateEmbeddedDerivedCells(
|
|
|
587
587
|
`Embedded-list field "${fieldName}" on entity "${entityName}" has a derived cell "${derivedName}" reading unknown sub-field "${sourceName}".`,
|
|
588
588
|
);
|
|
589
589
|
}
|
|
590
|
+
if (sourceName in field.derived) {
|
|
591
|
+
throw new Error(
|
|
592
|
+
`Embedded-list field "${fieldName}" on entity "${entityName}" has a derived cell "${derivedName}" reading derived sub-field "${sourceName}".`,
|
|
593
|
+
);
|
|
594
|
+
}
|
|
590
595
|
}
|
|
591
596
|
}
|
|
592
597
|
}
|
|
@@ -218,20 +218,32 @@ export function validatePiiAndRetention(feature: FeatureDefinition): void {
|
|
|
218
218
|
}
|
|
219
219
|
|
|
220
220
|
if (retention.strategy === "blockDelete") {
|
|
221
|
-
// blockDelete
|
|
222
|
-
//
|
|
223
|
-
|
|
221
|
+
// blockDelete with no field that can carry `anonymize` is fine;
|
|
222
|
+
// subjectRef-only entities are covered by the V3 EXT_USER_DATA guard
|
|
223
|
+
// instead (#1622, #2336).
|
|
224
|
+
const entityHasAnonymizableSubjectField = Object.values(fieldsByName).some(
|
|
224
225
|
(f) => hasAnonymizableSubjectField(f as ResolvedPiiFlags), // @cast-boundary schema-walk
|
|
225
226
|
);
|
|
226
227
|
const hasAnonymize = Object.values(fieldsByName).some((f) => {
|
|
227
228
|
const a = f as ResolvedPiiFlags; // @cast-boundary schema-walk
|
|
228
229
|
return Boolean(a.anonymize);
|
|
229
230
|
});
|
|
230
|
-
if (
|
|
231
|
+
if (entityHasAnonymizableSubjectField && !hasAnonymize) {
|
|
231
232
|
// biome-ignore lint/suspicious/noConsole: boot-time dev hint, no logger available yet
|
|
232
233
|
console.warn(
|
|
233
234
|
`[kumiko:boot] [Feature ${feature.name}] Entity "${entityName}" retention.strategy="blockDelete" but no field has an anonymize-function. User-Forget cannot anonymize — Forget will return error. Add { anonymize: () => null } or () => "[ANONYMIZED]" to PII fields.`,
|
|
234
235
|
);
|
|
236
|
+
} else if (!entityHasAnonymizableSubjectField) {
|
|
237
|
+
const hasSubjectRef = Object.values(fieldsByName).some((f) => {
|
|
238
|
+
const a = f as ResolvedPiiFlags; // @cast-boundary schema-walk
|
|
239
|
+
return Boolean(a.subjectRef);
|
|
240
|
+
});
|
|
241
|
+
if (hasSubjectRef) {
|
|
242
|
+
// biome-ignore lint/suspicious/noConsole: boot-time dev hint, no logger available yet
|
|
243
|
+
console.warn(
|
|
244
|
+
`[kumiko:boot] [Feature ${feature.name}] Entity "${entityName}" retention.strategy="blockDelete" with subjectRef-only PII relies on the EXT_USER_DATA delete hook for Art.17 — make sure it is not a no-op.`,
|
|
245
|
+
);
|
|
246
|
+
}
|
|
235
247
|
}
|
|
236
248
|
}
|
|
237
249
|
}
|
|
@@ -20,7 +20,7 @@ import type {
|
|
|
20
20
|
EditLayout,
|
|
21
21
|
FieldCondition,
|
|
22
22
|
RowAction,
|
|
23
|
-
|
|
23
|
+
RowActionNavigateBase,
|
|
24
24
|
RowFieldExtractor,
|
|
25
25
|
ScreenDefinition,
|
|
26
26
|
ToolbarAction,
|
|
@@ -345,12 +345,19 @@ function validateToolbarDrawerAction(
|
|
|
345
345
|
// call sites so the mutual-exclusivity check and the entity→detailFor
|
|
346
346
|
// resolution don't drift between them (same drift risk
|
|
347
347
|
// validateRowActionNavigateParams above is already shared to avoid).
|
|
348
|
+
// Runtime-loose shape: boot still rejects both/neither for untyped schemas;
|
|
349
|
+
// authors get the exclusive union via RowActionNavigate (#2303).
|
|
350
|
+
type RowActionNavigateRuntime = RowActionNavigateBase & {
|
|
351
|
+
readonly screen?: string;
|
|
352
|
+
readonly entity?: string;
|
|
353
|
+
};
|
|
354
|
+
|
|
348
355
|
function resolveRowActionNavigateTarget(
|
|
349
356
|
featureName: string,
|
|
350
357
|
screenId: string,
|
|
351
358
|
screenType: "entityList" | "projectionList" | "projectionDetail",
|
|
352
359
|
actionLabel: "rowAction" | "action",
|
|
353
|
-
action:
|
|
360
|
+
action: RowActionNavigateRuntime,
|
|
354
361
|
allScreenQns: ReadonlySet<string>,
|
|
355
362
|
navTargetShortIds: ReadonlySet<string>,
|
|
356
363
|
screensByShortId: ReadonlyMap<
|
|
@@ -1,13 +1,11 @@
|
|
|
1
1
|
import type { EmbeddedDerivedCellDef, EmbeddedSubFieldDef } from "./types";
|
|
2
2
|
|
|
3
3
|
/** Computes a derived cell from its source values. Missing/non-numeric
|
|
4
|
-
* sources
|
|
5
|
-
* missing
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
* `withDerivedCells` rounds the result to the target sub-field's declared
|
|
10
|
-
* precision afterward. */
|
|
4
|
+
* sources: "multiply" with any missing source → undefined; "sum"/"subtract"
|
|
5
|
+
* treat missing as 0 only when at least one source is present — if every
|
|
6
|
+
* source is missing, return undefined (do not invent 0). Money cells are
|
|
7
|
+
* minor-unit integers — unit-agnostic arithmetic; `withDerivedCells` rounds
|
|
8
|
+
* to the target sub-field's declared precision afterward. */
|
|
11
9
|
export function computeDerivedCellValue(
|
|
12
10
|
op: EmbeddedDerivedCellDef["op"],
|
|
13
11
|
values: readonly (number | undefined)[],
|
|
@@ -16,6 +14,7 @@ export function computeDerivedCellValue(
|
|
|
16
14
|
if (values.some((value) => value === undefined)) return undefined;
|
|
17
15
|
return (values as readonly number[]).reduce((product, value) => product * value, 1);
|
|
18
16
|
}
|
|
17
|
+
if (values.every((value) => value === undefined)) return undefined;
|
|
19
18
|
const numeric = values.map((value) => value ?? 0);
|
|
20
19
|
if (op === "sum") return numeric.reduce((sum, value) => sum + value, 0);
|
|
21
20
|
// subtract: first value minus every subsequent value.
|
|
@@ -60,8 +59,8 @@ function roundHalfAwayFromZero(value: number, decimals: number): number {
|
|
|
60
59
|
/** Recomputes every derived cell of an embedded-list row from its raw
|
|
61
60
|
* values, overwriting whatever the client sent instead of merely checking
|
|
62
61
|
* it — the server is the authority for derived cells. Reads source values
|
|
63
|
-
* from the
|
|
64
|
-
*
|
|
62
|
+
* from the working copy so already-recomputed sources feed later cells;
|
|
63
|
+
* boot-validator rejects derived-from-derived chains. A row that isn't a
|
|
65
64
|
* plain object (already invalid, or not this field's shape) passes through
|
|
66
65
|
* untouched — validation downstream rejects it. The computed value is
|
|
67
66
|
* rounded to the target sub-field's declared precision (`schema`) before
|
|
@@ -77,11 +76,13 @@ export function withDerivedCells(
|
|
|
77
76
|
const copy: Record<string, unknown> = { ...source };
|
|
78
77
|
for (const [cellName, def] of Object.entries(derived)) {
|
|
79
78
|
const sourceValues = def.from.map((sourceField) => {
|
|
80
|
-
const value =
|
|
79
|
+
const value = copy[sourceField];
|
|
81
80
|
return typeof value === "number" ? value : undefined;
|
|
82
81
|
});
|
|
83
82
|
const computed = computeDerivedCellValue(def.op, sourceValues);
|
|
84
83
|
if (computed === undefined) {
|
|
84
|
+
// Always clear — keeping a client value would let callers spoof required
|
|
85
|
+
// derived cells when sources are missing (server must stay authoritative).
|
|
85
86
|
delete copy[cellName];
|
|
86
87
|
} else {
|
|
87
88
|
const target = schema[cellName];
|
|
@@ -391,6 +391,16 @@ describe("addPattern — error paths", () => {
|
|
|
391
391
|
const sf = makeSourceFile("export const x = 1;");
|
|
392
392
|
expect(() => addPattern(sf, newSecretPattern)).toThrow(/no defineFeature/);
|
|
393
393
|
});
|
|
394
|
+
|
|
395
|
+
test("throws for ai.* kinds (must target a workflow steps array)", () => {
|
|
396
|
+
const sf = makeSourceFile(`
|
|
397
|
+
import { defineFeature } from "@cosmicdrift/kumiko-framework/engine";
|
|
398
|
+
defineFeature("ai-host", (r) => {});
|
|
399
|
+
`);
|
|
400
|
+
expect(() =>
|
|
401
|
+
addPattern(sf, { kind: "ai.generate", stepKey: "generate" } as FeaturePattern),
|
|
402
|
+
).toThrow(/ai\.generate steps must be inserted into a defineWorkflow/);
|
|
403
|
+
});
|
|
394
404
|
});
|
|
395
405
|
|
|
396
406
|
describe("callMatchesId — positional and object-form lookups", () => {
|
|
@@ -147,6 +147,15 @@ export function applyChanges(sourceFile: SourceFile, changes: readonly PatternCh
|
|
|
147
147
|
* biome-stable formatting that matches the renderFeatureFile output.
|
|
148
148
|
*/
|
|
149
149
|
export function addPattern(sourceFile: SourceFile, pattern: FeaturePattern): void {
|
|
150
|
+
if (
|
|
151
|
+
pattern.kind === "ai.generate" ||
|
|
152
|
+
pattern.kind === "ai.extract" ||
|
|
153
|
+
pattern.kind === "ai.classify"
|
|
154
|
+
) {
|
|
155
|
+
throw new Error(
|
|
156
|
+
`addPattern: ${pattern.kind} steps must be inserted into a defineWorkflow steps array, not the setup body`,
|
|
157
|
+
);
|
|
158
|
+
}
|
|
150
159
|
const setup = findSetupCallback(sourceFile);
|
|
151
160
|
if (!setup) {
|
|
152
161
|
throw new Error("addPattern: no defineFeature(name, (r) => { ... }) call found");
|
|
@@ -1,45 +1,63 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
1
|
+
// Prototype-free rank table — Object literals leak Object.prototype
|
|
2
|
+
// (`constructor`, `toString`, …) into `ROLE_RANKS[role]` lookups and turn
|
|
3
|
+
// Math.max into NaN, which fails every `rank > actorRank` check open.
|
|
4
|
+
const ROLE_RANKS = new Map<string, number>([
|
|
5
|
+
["User", 0],
|
|
3
6
|
// Matches DEFAULT_INVITE_ROLE_OPTIONS — must stay ranked or invite UI fails closed.
|
|
4
|
-
Editor
|
|
5
|
-
Admin
|
|
6
|
-
TenantAdmin
|
|
7
|
-
SystemAdmin
|
|
8
|
-
system
|
|
9
|
-
|
|
7
|
+
["Editor", 1],
|
|
8
|
+
["Admin", 2],
|
|
9
|
+
["TenantAdmin", 3],
|
|
10
|
+
["SystemAdmin", 4],
|
|
11
|
+
["system", 5],
|
|
12
|
+
]);
|
|
13
|
+
|
|
14
|
+
// Unknown roles: +∞ on the assigned path (cannot grant what we don't know),
|
|
15
|
+
// -1 on the actor path (cannot elevate via an unrecognized self-role).
|
|
16
|
+
function roleRankOr(role: string, unknownRank: number): number {
|
|
17
|
+
return ROLE_RANKS.get(role) ?? unknownRank;
|
|
18
|
+
}
|
|
10
19
|
|
|
11
20
|
function getRoleRank(role: string): number {
|
|
12
|
-
return
|
|
21
|
+
return roleRankOr(role, Number.POSITIVE_INFINITY);
|
|
13
22
|
}
|
|
14
23
|
|
|
15
24
|
function maxRoleRank(roles: readonly string[]): number {
|
|
16
25
|
if (roles.length === 0) return -1;
|
|
17
|
-
return Math.max(...roles.map((role) =>
|
|
26
|
+
return Math.max(...roles.map((role) => roleRankOr(role, -1)));
|
|
18
27
|
}
|
|
19
28
|
|
|
20
29
|
/** Known built-in rank only — unranked app roles (Billing, …) are not privilege tiers. */
|
|
21
30
|
function knownRoleRank(role: string): number | undefined {
|
|
22
|
-
return ROLE_RANKS
|
|
31
|
+
return ROLE_RANKS.get(role);
|
|
23
32
|
}
|
|
24
33
|
|
|
25
34
|
export function findForbiddenRoleAssignment(
|
|
26
35
|
actorRoles: readonly string[],
|
|
27
36
|
assignedRoles: readonly string[],
|
|
28
|
-
|
|
37
|
+
// Required so callers cannot accidentally disable the downgrade/
|
|
38
|
+
// takeover guard by omitting the third argument (new users: pass []).
|
|
39
|
+
targetCurrentRoles: readonly string[],
|
|
29
40
|
): string | undefined {
|
|
30
41
|
const actorRank = maxRoleRank(actorRoles);
|
|
31
|
-
// Assign path: fail-closed on unknown / above-actor roles
|
|
32
|
-
|
|
33
|
-
|
|
42
|
+
// Assign path: fail-closed on unknown / above-actor roles — except unranked
|
|
43
|
+
// app roles the target already holds (round-trip restore after strip).
|
|
44
|
+
const forbiddenAssigned = assignedRoles.find((role) => {
|
|
45
|
+
if (getRoleRank(role) <= actorRank) return false;
|
|
46
|
+
if (knownRoleRank(role) === undefined && targetCurrentRoles.includes(role)) return false;
|
|
47
|
+
return true;
|
|
48
|
+
});
|
|
49
|
+
// Empty string is unknown (rank +∞) but falsy — must not use truthiness.
|
|
50
|
+
if (forbiddenAssigned !== undefined) return forbiddenAssigned;
|
|
34
51
|
|
|
35
52
|
// Target path: only ranked roles above the actor block (can't touch a
|
|
36
|
-
// SystemAdmin). Unranked app roles
|
|
37
|
-
//
|
|
53
|
+
// SystemAdmin). Unranked app roles are ignored here so members with only
|
|
54
|
+
// those roles don't freeze on demotion/updates — the assign path still
|
|
55
|
+
// rejects *new* unranked roles fail-closed.
|
|
38
56
|
const forbiddenTarget = targetCurrentRoles.find((role) => {
|
|
39
57
|
const rank = knownRoleRank(role);
|
|
40
58
|
return rank !== undefined && rank > actorRank;
|
|
41
59
|
});
|
|
42
|
-
if (forbiddenTarget) return forbiddenTarget;
|
|
60
|
+
if (forbiddenTarget !== undefined) return forbiddenTarget;
|
|
43
61
|
|
|
44
62
|
return undefined;
|
|
45
63
|
}
|
|
@@ -65,6 +65,11 @@ describe("KumikoError: abstract base", () => {
|
|
|
65
65
|
const body = serializeError(err);
|
|
66
66
|
expect(body.error.docsUrl).toBe("https://docs.kumiko.rocks/errors/stale_state");
|
|
67
67
|
});
|
|
68
|
+
|
|
69
|
+
test("falls back to code when reason slug is not URL-safe", () => {
|
|
70
|
+
const err = new ConflictError({ details: { reason: "stale state/../x" } });
|
|
71
|
+
expect(err.docsUrl).toBe("https://docs.kumiko.rocks/errors/conflict");
|
|
72
|
+
});
|
|
68
73
|
});
|
|
69
74
|
});
|
|
70
75
|
|
|
@@ -12,20 +12,20 @@ describe("failNotFound", () => {
|
|
|
12
12
|
});
|
|
13
13
|
|
|
14
14
|
describe("failUnprocessable", () => {
|
|
15
|
-
test("
|
|
15
|
+
test("builds WriteFailure with reason + custom details", () => {
|
|
16
16
|
const f = failUnprocessable("custom_business_rule", { extra: 42 });
|
|
17
17
|
expect(f.error.httpStatus).toBe(422);
|
|
18
18
|
expect(f.error.details).toMatchObject({ reason: "custom_business_rule", extra: 42 });
|
|
19
19
|
});
|
|
20
20
|
|
|
21
|
-
test("reason
|
|
21
|
+
test("positional reason survives a conflicting details.reason from the caller", () => {
|
|
22
22
|
const f = failUnprocessable("custom_business_rule", { reason: "raw cause text", extra: 42 });
|
|
23
23
|
expect(f.error.details).toEqual({ reason: "custom_business_rule", extra: 42 });
|
|
24
24
|
});
|
|
25
25
|
});
|
|
26
26
|
|
|
27
27
|
describe("failTransition", () => {
|
|
28
|
-
test("
|
|
28
|
+
test("builds WriteFailure with reason=invalid_transition + from/to/allowed", () => {
|
|
29
29
|
const f = failTransition("draft", "paid", ["sent"]);
|
|
30
30
|
expect(f.isSuccess).toBe(false);
|
|
31
31
|
expect(f.error.code).toBe("unprocessable");
|