@cosmicdrift/kumiko-framework 0.155.1 → 0.156.1
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/README.md +9 -38
- package/package.json +2 -2
- package/src/__tests__/entity-permalink-open.integration.test.ts +94 -0
- package/src/__tests__/raw-table.integration.test.ts +17 -40
- package/src/crypto/__tests__/pii-field-encryption.test.ts +105 -1
- package/src/crypto/index.ts +1 -0
- package/src/crypto/pii-field-encryption.ts +19 -0
- package/src/db/__tests__/assert-no-unreachable-live-rows.integration.test.ts +27 -1
- package/src/db/__tests__/blind-index.integration.test.ts +16 -0
- package/src/db/__tests__/collect-table-metas.test.ts +6 -6
- package/src/db/__tests__/event-store-executor.integration.test.ts +75 -0
- package/src/db/__tests__/feature-table-sources.test.ts +7 -7
- package/src/db/__tests__/migrate-generator.test.ts +21 -0
- package/src/db/__tests__/number-field-fractional.integration.test.ts +1 -1
- package/src/db/__tests__/tenant-db-where-merge.test.ts +39 -0
- package/src/db/collect-table-metas.ts +6 -7
- package/src/db/entity-table-meta.ts +1 -1
- package/src/db/event-store-executor-write.ts +23 -2
- package/src/db/event-store-executor.ts +1 -1
- package/src/db/feature-table-sources.ts +5 -11
- package/src/db/migrate-generator.ts +39 -6
- package/src/db/queries/shadow-swap.ts +85 -3
- package/src/db/tenant-db.ts +8 -0
- package/src/engine/__tests__/boot-validator-pii-retention.test.ts +102 -0
- package/src/engine/__tests__/build-config-feature-schema.test.ts +16 -0
- package/src/engine/__tests__/feature-crud-shorthand.test.ts +28 -0
- package/src/engine/__tests__/field-access.test.ts +23 -1
- package/src/engine/__tests__/raw-table.test.ts +134 -84
- package/src/engine/__tests__/registry.test.ts +40 -0
- package/src/engine/boot-validator/__tests__/config-deps.test.ts +52 -2
- package/src/engine/boot-validator/action-wiring.ts +2 -2
- package/src/engine/boot-validator/config-deps.ts +35 -0
- package/src/engine/boot-validator/index.ts +2 -0
- package/src/engine/boot-validator/pii-retention.ts +35 -3
- package/src/engine/build-config-feature-schema.ts +3 -3
- package/src/engine/config-helpers.ts +2 -0
- package/src/engine/define-feature.ts +2 -6
- package/src/engine/entity-handlers.ts +11 -5
- package/src/engine/feature-ast/__tests__/parse-real-features.test.ts +1 -1
- package/src/engine/feature-ast/__tests__/parse.test.ts +170 -0
- package/src/engine/feature-ast/__tests__/patch.test.ts +31 -0
- package/src/engine/feature-ast/__tests__/render-roundtrip.test.ts +6 -1
- package/src/engine/feature-ast/extractors/events.ts +322 -0
- package/src/engine/feature-ast/extractors/handlers.ts +222 -0
- package/src/engine/feature-ast/extractors/hooks.ts +243 -0
- package/src/engine/feature-ast/extractors/index.ts +32 -24
- package/src/engine/feature-ast/extractors/jobs-routes.ts +221 -0
- package/src/engine/feature-ast/extractors/projections-screens.ts +269 -0
- package/src/engine/feature-ast/extractors/round5.ts +3 -3
- package/src/engine/feature-ast/parse.ts +3 -3
- package/src/engine/feature-ast/render.ts +16 -11
- package/src/engine/feature-builder-state.ts +0 -3
- package/src/engine/feature-config-events-jobs.ts +3 -6
- package/src/engine/feature-entity-handlers.ts +9 -1
- package/src/engine/feature-ui-extensions.ts +10 -40
- package/src/engine/field-access.ts +13 -2
- package/src/engine/object-form.ts +15 -0
- package/src/engine/registry-facade.ts +4 -0
- package/src/engine/registry-ingest.ts +19 -29
- package/src/engine/registry-state.ts +1 -7
- package/src/engine/registry-validate.ts +5 -28
- package/src/engine/registry.ts +0 -2
- package/src/engine/types/config.ts +7 -0
- package/src/engine/types/feature.ts +40 -68
- package/src/engine/types/fields.ts +6 -0
- package/src/engine/types/index.ts +0 -3
- package/src/es-ops/README.md +1 -1
- package/src/es-ops/__tests__/runner.integration.test.ts +74 -0
- package/src/es-ops/context.ts +4 -2
- package/src/es-ops/types.ts +8 -1
- package/src/pipeline/__tests__/dispatcher.test.ts +29 -0
- package/src/pipeline/msp-rebuild.ts +4 -0
- package/src/pipeline/projection-rebuild.ts +35 -0
- package/src/search/__tests__/meilisearch-adapter.integration.test.ts +8 -1
- package/src/stack/test-stack.ts +17 -2
- package/src/ui-types/index.ts +1 -0
- package/src/engine/__tests__/unmanaged-table.test.ts +0 -172
- package/src/engine/feature-ast/extractors/round4.ts +0 -1227
|
@@ -1,35 +1,47 @@
|
|
|
1
1
|
// Unit tests for r.rawTable() — declaration-time validation + registry
|
|
2
|
-
// aggregation.
|
|
3
|
-
//
|
|
2
|
+
// aggregation. Post-drizzle-cut merge of the former r.rawTable() (legacy
|
|
3
|
+
// Drizzle PgTable) and r.unmanagedTable() (EntityTableMeta) — this file
|
|
4
|
+
// absorbs the former unmanaged-table.test.ts coverage under the new name.
|
|
5
|
+
// Full DB roundtrip (setupTestStack pushes the table → INSERT / SELECT)
|
|
6
|
+
// lives in src/__tests__/raw-table.integration.test.ts.
|
|
4
7
|
|
|
5
8
|
import { describe, expect, test } from "bun:test";
|
|
6
|
-
import
|
|
7
|
-
|
|
9
|
+
import {
|
|
10
|
+
buildEntityTableMeta,
|
|
11
|
+
defineUnmanagedTable,
|
|
12
|
+
resolveTableName,
|
|
13
|
+
} from "../../db/entity-table-meta";
|
|
8
14
|
import { defineFeature } from "../define-feature";
|
|
15
|
+
import { createEntity, createTextField } from "../index";
|
|
9
16
|
import { createRegistry } from "../registry";
|
|
10
|
-
import type { ProjectionDefinition } from "../types";
|
|
11
17
|
|
|
12
|
-
const
|
|
13
|
-
|
|
18
|
+
const probeMeta = defineUnmanagedTable({
|
|
19
|
+
tableName: "rt_probe",
|
|
20
|
+
columns: [{ name: "id", pgType: "text", notNull: true, primaryKey: true }],
|
|
14
21
|
});
|
|
15
|
-
const
|
|
16
|
-
|
|
22
|
+
const probeMetaTwo = defineUnmanagedTable({
|
|
23
|
+
tableName: "rt_probe_two",
|
|
24
|
+
columns: [{ name: "id", pgType: "text", notNull: true, primaryKey: true }],
|
|
17
25
|
});
|
|
18
26
|
|
|
19
27
|
describe("r.rawTable — declaration", () => {
|
|
20
|
-
test("rejects
|
|
28
|
+
test("rejects an invalid table name", () => {
|
|
29
|
+
const badMeta = defineUnmanagedTable({
|
|
30
|
+
tableName: "BadName",
|
|
31
|
+
columns: [{ name: "id", pgType: "text", notNull: true, primaryKey: true }],
|
|
32
|
+
});
|
|
21
33
|
expect(() =>
|
|
22
34
|
defineFeature("probe", (r) => {
|
|
23
|
-
r.rawTable(
|
|
35
|
+
r.rawTable(badMeta, { reason: "test" });
|
|
24
36
|
}),
|
|
25
|
-
).toThrow(/must be
|
|
37
|
+
).toThrow(/must be a valid identifier/);
|
|
26
38
|
});
|
|
27
39
|
|
|
28
|
-
test("rejects duplicate
|
|
40
|
+
test("rejects duplicate registrations within one feature", () => {
|
|
29
41
|
expect(() =>
|
|
30
42
|
defineFeature("probe", (r) => {
|
|
31
|
-
r.rawTable(
|
|
32
|
-
r.rawTable(
|
|
43
|
+
r.rawTable(probeMeta, { reason: "test" });
|
|
44
|
+
r.rawTable(probeMeta, { reason: "test" });
|
|
33
45
|
}),
|
|
34
46
|
).toThrow(/already registered/);
|
|
35
47
|
});
|
|
@@ -37,7 +49,7 @@ describe("r.rawTable — declaration", () => {
|
|
|
37
49
|
test("rejects empty reason", () => {
|
|
38
50
|
expect(() =>
|
|
39
51
|
defineFeature("probe", (r) => {
|
|
40
|
-
r.rawTable(
|
|
52
|
+
r.rawTable(probeMeta, { reason: "" });
|
|
41
53
|
}),
|
|
42
54
|
).toThrow(/options\.reason must be a non-empty string/);
|
|
43
55
|
});
|
|
@@ -45,106 +57,144 @@ describe("r.rawTable — declaration", () => {
|
|
|
45
57
|
test("rejects whitespace-only reason", () => {
|
|
46
58
|
expect(() =>
|
|
47
59
|
defineFeature("probe", (r) => {
|
|
48
|
-
r.rawTable(
|
|
60
|
+
r.rawTable(probeMeta, { reason: " " });
|
|
49
61
|
}),
|
|
50
62
|
).toThrow(/options\.reason must be a non-empty string/);
|
|
51
63
|
});
|
|
52
64
|
|
|
53
|
-
test("accepts valid registration and stores reason
|
|
65
|
+
test("accepts valid registration and stores meta + reason", () => {
|
|
54
66
|
const feature = defineFeature("probe", (r) => {
|
|
55
|
-
r.rawTable(
|
|
56
|
-
reason: "
|
|
67
|
+
r.rawTable(probeMeta, {
|
|
68
|
+
reason: "read-side projection of an event-stream",
|
|
57
69
|
});
|
|
58
70
|
});
|
|
59
|
-
expect(feature.rawTables).toHaveProperty("
|
|
60
|
-
expect(feature.rawTables["
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
71
|
+
expect(feature.rawTables).toHaveProperty("rt_probe");
|
|
72
|
+
expect(feature.rawTables["rt_probe"]?.reason).toBe("read-side projection of an event-stream");
|
|
73
|
+
expect(feature.rawTables["rt_probe"]?.meta).toBe(probeMeta);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test("two raw tables on one feature register under their tableName", () => {
|
|
77
|
+
const feature = defineFeature("dual", (r) => {
|
|
78
|
+
r.rawTable(probeMeta, { reason: "one" });
|
|
79
|
+
r.rawTable(probeMetaTwo, { reason: "two" });
|
|
80
|
+
});
|
|
81
|
+
expect(Object.keys(feature.rawTables).sort()).toEqual(["rt_probe", "rt_probe_two"]);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test("absent rawTables on a feature is ok", () => {
|
|
85
|
+
const feat = defineFeature("plain", () => {
|
|
86
|
+
// no r.rawTable calls
|
|
87
|
+
});
|
|
88
|
+
expect(feat.rawTables).toEqual({});
|
|
64
89
|
});
|
|
65
90
|
});
|
|
66
91
|
|
|
67
92
|
describe("createRegistry — rawTable aggregation", () => {
|
|
68
93
|
test("aggregates raw tables across features and tags featureName", () => {
|
|
69
94
|
const featA = defineFeature("billing", (r) => {
|
|
70
|
-
r.rawTable(
|
|
95
|
+
r.rawTable(probeMeta, { reason: "external API cache" });
|
|
71
96
|
});
|
|
72
97
|
const featB = defineFeature("inventory", (r) => {
|
|
73
|
-
r.rawTable(
|
|
98
|
+
r.rawTable(probeMetaTwo, { reason: "imported pre-ES" });
|
|
74
99
|
});
|
|
75
100
|
|
|
76
101
|
const registry = createRegistry([featA, featB]);
|
|
77
102
|
const all = registry.getAllRawTables();
|
|
78
103
|
|
|
79
104
|
expect(all.size).toBe(2);
|
|
80
|
-
expect(all.get("
|
|
81
|
-
expect(all.get("
|
|
82
|
-
expect(all.get("
|
|
105
|
+
expect(all.get("rt_probe")?.featureName).toBe("billing");
|
|
106
|
+
expect(all.get("rt_probe")?.reason).toBe("external API cache");
|
|
107
|
+
expect(all.get("rt_probe_two")?.featureName).toBe("inventory");
|
|
83
108
|
});
|
|
84
109
|
|
|
85
|
-
test("rejects cross-feature
|
|
110
|
+
test("rejects cross-feature tableName collisions at boot", () => {
|
|
111
|
+
// Two features can't share the same physical tableName — migrate-runner
|
|
112
|
+
// would race two CREATE TABLE statements. Boot-validator catches it.
|
|
86
113
|
const featA = defineFeature("a", (r) => {
|
|
87
|
-
r.rawTable(
|
|
114
|
+
r.rawTable(probeMeta, { reason: "first" });
|
|
88
115
|
});
|
|
89
116
|
const featB = defineFeature("b", (r) => {
|
|
90
|
-
r.rawTable(
|
|
117
|
+
r.rawTable(probeMeta, { reason: "second" });
|
|
91
118
|
});
|
|
92
|
-
|
|
93
119
|
expect(() => createRegistry([featA, featB])).toThrow(
|
|
94
|
-
/Raw-table "
|
|
120
|
+
/Raw-table "rt_probe" registered by both feature "a" and "b"/,
|
|
95
121
|
);
|
|
96
122
|
});
|
|
97
123
|
|
|
98
|
-
test("
|
|
99
|
-
const
|
|
100
|
-
|
|
124
|
+
test("two features with distinct tableNames register cleanly", () => {
|
|
125
|
+
const featA = defineFeature("a", (r) => {
|
|
126
|
+
r.rawTable(probeMeta, { reason: "first" });
|
|
127
|
+
});
|
|
128
|
+
const featB = defineFeature("b", (r) => {
|
|
129
|
+
r.rawTable(probeMetaTwo, { reason: "second" });
|
|
130
|
+
});
|
|
131
|
+
expect(() => createRegistry([featA, featB])).not.toThrow();
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
test("rejects a raw-table that collides with an entity's physical name", () => {
|
|
135
|
+
const widget = createEntity({ fields: { name: createTextField() } });
|
|
136
|
+
// resolveTableName mirrors the migrate-runner — pin the exact physical name.
|
|
137
|
+
const physical = resolveTableName("widget", widget, "shop");
|
|
138
|
+
const clashing = defineUnmanagedTable({
|
|
139
|
+
tableName: physical,
|
|
140
|
+
columns: [{ name: "id", pgType: "text", notNull: true, primaryKey: true }],
|
|
141
|
+
});
|
|
142
|
+
const entityFeature = defineFeature("shop", (r) => {
|
|
143
|
+
r.entity("widget", widget);
|
|
144
|
+
});
|
|
145
|
+
const tableFeature = defineFeature("other", (r) => {
|
|
146
|
+
r.rawTable(clashing, { reason: "clash" });
|
|
101
147
|
});
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
//
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
const sharedT = table("rt_shared_dedupe", {
|
|
112
|
-
id: text("id").primaryKey(),
|
|
113
|
-
}) as unknown as SchemaTable;
|
|
114
|
-
const feat = defineFeature("dedupe", (r) => {
|
|
115
|
-
r.rawTable("primary", sharedT, { reason: "main writer" });
|
|
116
|
-
r.rawTable("alias", sharedT, { reason: "alias for read consumers" });
|
|
117
|
-
});
|
|
118
|
-
const registry = createRegistry([feat]);
|
|
119
|
-
|
|
120
|
-
expect(registry.getAllRawTables().size).toBe(2);
|
|
121
|
-
expect(registry.getAllRawTables().get("primary")?.table).toBe(sharedT);
|
|
122
|
-
expect(registry.getAllRawTables().get("alias")?.table).toBe(sharedT);
|
|
123
|
-
});
|
|
124
|
-
|
|
125
|
-
test("rejects rawTable that shares a PgTable with an explicit projection", () => {
|
|
126
|
-
// A r.rawTable() declaring the same physical table as a registered
|
|
127
|
-
// r.projection() is almost always an authoring bug — two owners on
|
|
128
|
-
// the same physical table, one event-sourced, one bypass. Boot
|
|
129
|
-
// throws so the failure points at the misconfiguration site.
|
|
130
|
-
const sharedT = table("rt_collision_phys", {
|
|
131
|
-
id: text("id").primaryKey(),
|
|
132
|
-
}) as unknown as SchemaTable;
|
|
133
|
-
const projection: ProjectionDefinition = {
|
|
134
|
-
name: "shared-summary",
|
|
135
|
-
source: "shared",
|
|
136
|
-
table: sharedT,
|
|
137
|
-
apply: {},
|
|
138
|
-
};
|
|
139
|
-
const featProj = defineFeature("proj-owner", (r) => {
|
|
140
|
-
r.projection(projection);
|
|
141
|
-
});
|
|
142
|
-
const featRaw = defineFeature("raw-owner", (r) => {
|
|
143
|
-
r.rawTable("collision", sharedT, { reason: "intentionally bad" });
|
|
144
|
-
});
|
|
145
|
-
|
|
146
|
-
expect(() => createRegistry([featProj, featRaw])).toThrow(
|
|
147
|
-
/shares a Drizzle PgTable with a registered projection/,
|
|
148
|
+
|
|
149
|
+
// Entity registered first, then the colliding raw table.
|
|
150
|
+
expect(() => createRegistry([entityFeature, tableFeature])).toThrow(
|
|
151
|
+
new RegExp(`Raw-table "${physical}".*collides with the physical table of entity "widget"`),
|
|
152
|
+
);
|
|
153
|
+
|
|
154
|
+
// Order-independent: raw table registered first, then the entity.
|
|
155
|
+
expect(() => createRegistry([tableFeature, entityFeature])).toThrow(
|
|
156
|
+
new RegExp(`Entity "widget".*collides with r.rawTable\\("${physical}"\\)`),
|
|
148
157
|
);
|
|
149
158
|
});
|
|
150
159
|
});
|
|
160
|
+
|
|
161
|
+
describe("createRegistry — raw tables with PII-annotated fields (#820)", () => {
|
|
162
|
+
const piiEntity = createEntity({
|
|
163
|
+
table: "rt_pii_probe",
|
|
164
|
+
fields: {
|
|
165
|
+
userId: createTextField({ required: true }),
|
|
166
|
+
ip: createTextField({ userOwned: { ownerField: "userId" } }),
|
|
167
|
+
},
|
|
168
|
+
});
|
|
169
|
+
const piiMeta = buildEntityTableMeta("rt-pii-probe", piiEntity);
|
|
170
|
+
|
|
171
|
+
test("buildEntityTableMeta records the subject-annotated field names", () => {
|
|
172
|
+
expect(piiMeta.piiSubjectFields).toEqual(["ip"]);
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
test("rejects a PII-carrying raw table without piiEncryptedOnWrite", () => {
|
|
176
|
+
const feat = defineFeature("probe", (r) => {
|
|
177
|
+
r.rawTable(piiMeta, { reason: "direct-write store" });
|
|
178
|
+
});
|
|
179
|
+
expect(() => createRegistry([feat])).toThrow(
|
|
180
|
+
/has PII-annotated fields \(ip\) but direct writes bypass the executor's PII encryption/,
|
|
181
|
+
);
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
test("accepts it once the feature declares piiEncryptedOnWrite", () => {
|
|
185
|
+
const feat = defineFeature("probe", (r) => {
|
|
186
|
+
r.rawTable(piiMeta, {
|
|
187
|
+
reason: "direct-write store",
|
|
188
|
+
piiEncryptedOnWrite: true,
|
|
189
|
+
});
|
|
190
|
+
});
|
|
191
|
+
expect(() => createRegistry([feat])).not.toThrow();
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
test("a PII-free raw table needs no declaration", () => {
|
|
195
|
+
const feat = defineFeature("probe", (r) => {
|
|
196
|
+
r.rawTable(probeMeta, { reason: "plain store" });
|
|
197
|
+
});
|
|
198
|
+
expect(() => createRegistry([feat])).not.toThrow();
|
|
199
|
+
});
|
|
200
|
+
});
|
|
@@ -61,6 +61,46 @@ describe("createRegistry slot robustness", () => {
|
|
|
61
61
|
});
|
|
62
62
|
});
|
|
63
63
|
|
|
64
|
+
describe("getAllQueryHandlers", () => {
|
|
65
|
+
test("returns every registered query handler, qualified, across multiple features", () => {
|
|
66
|
+
const taskEntity = createEntity({
|
|
67
|
+
table: "registry_test_tasks",
|
|
68
|
+
fields: { title: createTextField({ required: true }) },
|
|
69
|
+
});
|
|
70
|
+
const noteEntity = createEntity({
|
|
71
|
+
table: "registry_test_notes",
|
|
72
|
+
fields: { body: createTextField({ required: true }) },
|
|
73
|
+
});
|
|
74
|
+
const taskFeature = defineFeature("registry-test-task", (r) => {
|
|
75
|
+
r.crud("task", taskEntity, {
|
|
76
|
+
write: { access: { roles: ["Admin"] } },
|
|
77
|
+
read: { access: { openToAll: true } },
|
|
78
|
+
});
|
|
79
|
+
});
|
|
80
|
+
const noteFeature = defineFeature("registry-test-note", (r) => {
|
|
81
|
+
r.crud("note", noteEntity, {
|
|
82
|
+
write: { access: { roles: ["Admin"] } },
|
|
83
|
+
read: { access: { openToAll: true } },
|
|
84
|
+
});
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
const registry = createRegistry([taskFeature, noteFeature]);
|
|
88
|
+
const handlers = registry.getAllQueryHandlers();
|
|
89
|
+
|
|
90
|
+
expect(handlers.get("registry-test-task:query:task:list")).toBeDefined();
|
|
91
|
+
expect(handlers.get("registry-test-note:query:note:list")).toBeDefined();
|
|
92
|
+
// Same Map instance getQueryHandler reads from, not a copy that can drift.
|
|
93
|
+
expect(handlers.get("registry-test-task:query:task:list")).toBe(
|
|
94
|
+
registry.getQueryHandler("registry-test-task:query:task:list"),
|
|
95
|
+
);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test("empty registry returns an empty map, not undefined", () => {
|
|
99
|
+
const registry = createRegistry([]);
|
|
100
|
+
expect(registry.getAllQueryHandlers().size).toBe(0);
|
|
101
|
+
});
|
|
102
|
+
});
|
|
103
|
+
|
|
64
104
|
describe("extensionSelector boot-validation", () => {
|
|
65
105
|
function foundationFeature() {
|
|
66
106
|
return defineFeature("probe-foundation", (r) => {
|
|
@@ -1,7 +1,11 @@
|
|
|
1
1
|
import { describe, expect, test } from "bun:test";
|
|
2
|
-
import { createSystemConfig } from "../../config-helpers";
|
|
2
|
+
import { createSystemConfig, createTenantConfig, createUserConfig } from "../../config-helpers";
|
|
3
3
|
import type { FeatureDefinition } from "../../types";
|
|
4
|
-
import {
|
|
4
|
+
import {
|
|
5
|
+
validateConfigKeyAllowPerRequest,
|
|
6
|
+
validateConfigKeyComputed,
|
|
7
|
+
validateConfigKeyPiiEncrypted,
|
|
8
|
+
} from "../config-deps";
|
|
5
9
|
|
|
6
10
|
function fakeFeature(configKeys: FeatureDefinition["configKeys"]): FeatureDefinition {
|
|
7
11
|
return { name: "test-feature", configKeys } as unknown as FeatureDefinition;
|
|
@@ -40,3 +44,49 @@ describe("validateConfigKeyAllowPerRequest", () => {
|
|
|
40
44
|
);
|
|
41
45
|
});
|
|
42
46
|
});
|
|
47
|
+
|
|
48
|
+
describe("validateConfigKeyPiiEncrypted (kumiko-platform#231/#459)", () => {
|
|
49
|
+
test("allows piiEncrypted on a tenant-scoped text key", () => {
|
|
50
|
+
const feature = fakeFeature({
|
|
51
|
+
billingAddress: createTenantConfig("text", { piiEncrypted: true }),
|
|
52
|
+
});
|
|
53
|
+
expect(() => validateConfigKeyPiiEncrypted(feature)).not.toThrow();
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test("allows piiEncrypted on a user-scoped text key", () => {
|
|
57
|
+
const feature = fakeFeature({
|
|
58
|
+
phoneNumber: createUserConfig("text", { piiEncrypted: true }),
|
|
59
|
+
});
|
|
60
|
+
expect(() => validateConfigKeyPiiEncrypted(feature)).not.toThrow();
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test("rejects piiEncrypted on a non-text key", () => {
|
|
64
|
+
const feature = fakeFeature({
|
|
65
|
+
rateLimit: { ...createTenantConfig("number"), piiEncrypted: true },
|
|
66
|
+
});
|
|
67
|
+
expect(() => validateConfigKeyPiiEncrypted(feature)).toThrow(
|
|
68
|
+
/piiEncrypted.*only applies to text keys/,
|
|
69
|
+
);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test("rejects piiEncrypted + scope:system", () => {
|
|
73
|
+
const feature = fakeFeature({
|
|
74
|
+
apiKey: createSystemConfig("text", { piiEncrypted: true }),
|
|
75
|
+
});
|
|
76
|
+
expect(() => validateConfigKeyPiiEncrypted(feature)).toThrow(/piiEncrypted.*scope="system"/);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test("rejects piiEncrypted + encrypted", () => {
|
|
80
|
+
const feature = fakeFeature({
|
|
81
|
+
iban: createTenantConfig("text", { piiEncrypted: true, encrypted: true }),
|
|
82
|
+
});
|
|
83
|
+
expect(() => validateConfigKeyPiiEncrypted(feature)).toThrow(/piiEncrypted=true and encrypted/);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test("rejects piiEncrypted + backing:secrets", () => {
|
|
87
|
+
const feature = fakeFeature({
|
|
88
|
+
iban: { ...createTenantConfig("text", { piiEncrypted: true }), backing: "secrets" as const },
|
|
89
|
+
});
|
|
90
|
+
expect(() => validateConfigKeyPiiEncrypted(feature)).toThrow(/piiEncrypted=true and encrypted/);
|
|
91
|
+
});
|
|
92
|
+
});
|
|
@@ -15,8 +15,8 @@ function throwIfFunction(value: unknown, message: string): void {
|
|
|
15
15
|
// static visibility conditions and navigate entityId — all declarative-DSL-
|
|
16
16
|
// only fields (RowFieldExtractor's `{ pick }`/`{ map }`, FieldCondition's
|
|
17
17
|
// `{ field, eq }`/`{ field, ne }`, plain strings). A function literal here
|
|
18
|
-
//
|
|
19
|
-
//
|
|
18
|
+
// does not type-check against these structural DSL types — it only reaches
|
|
19
|
+
// here via a leaked `any`/`as any`, which this runtime check backstops.
|
|
20
20
|
const ACTION_FUNCTION_FIELDS = ["payload", "params", "entityId", "visible"] as const;
|
|
21
21
|
|
|
22
22
|
function validateActionNoFunctions(
|
|
@@ -161,6 +161,41 @@ export function validateConfigKeyBacking(feature: FeatureDefinition): void {
|
|
|
161
161
|
}
|
|
162
162
|
}
|
|
163
163
|
|
|
164
|
+
// --- Config key piiEncrypted compatibility (kumiko-platform#231/#459) ---
|
|
165
|
+
|
|
166
|
+
export function validateConfigKeyPiiEncrypted(feature: FeatureDefinition): void {
|
|
167
|
+
for (const [keyName, keyDef] of Object.entries(feature.configKeys)) {
|
|
168
|
+
if (!keyDef.piiEncrypted) continue;
|
|
169
|
+
|
|
170
|
+
if (keyDef.type !== "text") {
|
|
171
|
+
throw new Error(
|
|
172
|
+
`[Feature ${feature.name}] Config key "${keyName}" has piiEncrypted=true but type="${keyDef.type}" — piiEncrypted only applies to text keys`,
|
|
173
|
+
);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// system scope has no subject (no tenant, no user) for the subject-KMS
|
|
177
|
+
// to encrypt under.
|
|
178
|
+
if (keyDef.scope === "system") {
|
|
179
|
+
throw new Error(
|
|
180
|
+
`[Feature ${feature.name}] Config key "${keyName}" has piiEncrypted=true but scope="system" — system-scope has no subject (tenant/user) for the subject-KMS to encrypt under`,
|
|
181
|
+
);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// Mutually exclusive with the shared-cipher path: a config value is
|
|
185
|
+
// either subject-keyed (piiEncrypted) or master-key-keyed (encrypted/
|
|
186
|
+
// backing="secrets"), never both — unlike entity fields, which allow
|
|
187
|
+
// both markers together (double-wrap, e.g. auth-mfa.totpSecret) because
|
|
188
|
+
// entity rows decrypt through a fixed executor pipeline that peels both
|
|
189
|
+
// layers. Config values have no such pipeline; picking one keeps the
|
|
190
|
+
// resolver simple.
|
|
191
|
+
if (isEncryptedAtRest(keyDef)) {
|
|
192
|
+
throw new Error(
|
|
193
|
+
`[Feature ${feature.name}] Config key "${keyName}" has both piiEncrypted=true and encrypted/backing="secrets" — pick one: piiEncrypted (subject-KMS, user-visible) or encrypted (shared cipher, server-only values)`,
|
|
194
|
+
);
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
164
199
|
// --- Config key cross-feature reference validation ---
|
|
165
200
|
|
|
166
201
|
export function validateConfigReads(
|
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
validateConfigKeyBacking,
|
|
10
10
|
validateConfigKeyBounds,
|
|
11
11
|
validateConfigKeyComputed,
|
|
12
|
+
validateConfigKeyPiiEncrypted,
|
|
12
13
|
validateConfigKeyRequired,
|
|
13
14
|
validateConfigReads,
|
|
14
15
|
warnOnToggleableDependencies,
|
|
@@ -170,6 +171,7 @@ export function validateBoot(features: readonly FeatureDefinition[]): void {
|
|
|
170
171
|
validateConfigKeyComputed(feature);
|
|
171
172
|
validateConfigKeyAllowPerRequest(feature);
|
|
172
173
|
validateConfigKeyBacking(feature);
|
|
174
|
+
validateConfigKeyPiiEncrypted(feature);
|
|
173
175
|
validateOwnershipRules(feature, allClaimKeys, knownRoles);
|
|
174
176
|
validateMultiStreamProjections(feature);
|
|
175
177
|
// Vor validateScreens: dessen visible/entityId-Feldref-Checks werfen für
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { FeatureDefinition } from "../types";
|
|
2
|
-
import type { PiiAnnotations } from "../types/fields";
|
|
2
|
+
import type { FieldAccess, PiiAnnotations } from "../types/fields";
|
|
3
3
|
import { PII_DIRECT_NAME_HINTS, PII_USER_OWNED_NAME_HINTS } from "./entity-handler";
|
|
4
4
|
|
|
5
5
|
// Framework-managed Timestamp-Spalten — dürfen als retention.reference
|
|
@@ -80,6 +80,38 @@ export function validatePiiAndRetention(feature: FeatureDefinition): void {
|
|
|
80
80
|
}
|
|
81
81
|
}
|
|
82
82
|
|
|
83
|
+
// piiEncrypted (kumiko-platform#231/#456): a declarative alias over
|
|
84
|
+
// the subject-KMS — text only (storage stays the plaintext column
|
|
85
|
+
// with subject ciphertext, no separate envelope path).
|
|
86
|
+
const piiEncryptedFlag = field as { readonly piiEncrypted?: boolean }; // @cast-boundary schema-walk
|
|
87
|
+
if (piiEncryptedFlag.piiEncrypted === true && field.type !== "text") {
|
|
88
|
+
throw new Error(
|
|
89
|
+
`[Feature ${feature.name}] Field "${fieldName}" on entity "${entityName}" declares { piiEncrypted: true } but has type "${field.type}" — piiEncrypted only applies to text fields.`,
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
// piiEncrypted wiring (kumiko-platform#457): resolveSubjectForField/
|
|
93
|
+
// collectPiiSubjectFields only ever look at pii/userOwned/tenantOwned
|
|
94
|
+
// — piiEncrypted alone doesn't pick a subject. Without one of the
|
|
95
|
+
// three, the field would silently stay plaintext (no encrypt-on-
|
|
96
|
+
// write happens). Fail at boot like lookupable does, not at first read.
|
|
97
|
+
if (piiEncryptedFlag.piiEncrypted === true && annotCount === 0) {
|
|
98
|
+
throw new Error(
|
|
99
|
+
`[Feature ${feature.name}] Field "${fieldName}" on entity "${entityName}" declares { piiEncrypted: true } without a subject annotation (pii / userOwned / tenantOwned) — piiEncrypted alone doesn't encrypt anything, it only adds the access/masking layer on top of the subject-key encryption.`,
|
|
100
|
+
);
|
|
101
|
+
}
|
|
102
|
+
// piiEncrypted access model (kumiko-platform#460): field-level
|
|
103
|
+
// access.read already exists + is enforced (filterReadFields), but
|
|
104
|
+
// its default without an access config is "visible to everyone" —
|
|
105
|
+
// the wrong default for a field whose entire point is "only the
|
|
106
|
+
// legitimate owner may see the plaintext". Force an explicit choice
|
|
107
|
+
// instead of silently decrypting for every row-reader.
|
|
108
|
+
const accessFlag = field as { readonly access?: FieldAccess }; // @cast-boundary schema-walk
|
|
109
|
+
if (piiEncryptedFlag.piiEncrypted === true && !accessFlag.access?.read) {
|
|
110
|
+
throw new Error(
|
|
111
|
+
`[Feature ${feature.name}] Field "${fieldName}" on entity "${entityName}" declares { piiEncrypted: true } without { access: { read: [...] } } — without it the decrypted value is visible to anyone who can read the row. Set the roles allowed to see the plaintext.`,
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
|
|
83
115
|
// sensitive-Felder liegen seit #967 als Tabellen-Ciphertext im Event-
|
|
84
116
|
// Log — ohne ciphertext-at-rest würde der Append Klartext in die
|
|
85
117
|
// immutable History schreiben.
|
|
@@ -100,12 +132,12 @@ export function validatePiiAndRetention(feature: FeatureDefinition): void {
|
|
|
100
132
|
// Substring-Suche/Sortierung auf Ciphertext ist prinzipbedingt
|
|
101
133
|
// unmöglich — searchable würde Plaintext-Kopien in den Suchindex
|
|
102
134
|
// schieben, sortable sortiert Base64-Blobs. Equality → lookupable.
|
|
103
|
-
if (annotCount > 0) {
|
|
135
|
+
if (annotCount > 0 || piiEncryptedFlag.piiEncrypted === true) {
|
|
104
136
|
const flags = field as { readonly searchable?: boolean; readonly sortable?: boolean }; // @cast-boundary schema-walk
|
|
105
137
|
if (flags.searchable === true || flags.sortable === true) {
|
|
106
138
|
const offending = flags.searchable === true ? "searchable" : "sortable";
|
|
107
139
|
throw new Error(
|
|
108
|
-
`[Feature ${feature.name}] Field "${fieldName}" on entity "${entityName}" combines a subject-key annotation with { ${offending}: true } — ${offending} on encrypted fields cannot work (ciphertext at rest). For equality lookups use { lookupable: true }; for search/sort the field must stay plaintext (allowPlaintext).`,
|
|
140
|
+
`[Feature ${feature.name}] Field "${fieldName}" on entity "${entityName}" combines a subject-key annotation or { piiEncrypted: true } with { ${offending}: true } — ${offending} on encrypted fields cannot work (ciphertext at rest). For equality lookups use { lookupable: true }; for search/sort the field must stay plaintext (allowPlaintext).`,
|
|
109
141
|
);
|
|
110
142
|
}
|
|
111
143
|
}
|
|
@@ -260,9 +260,6 @@ function deriveField(def: ConfigKeyDefinition): FieldDefinition {
|
|
|
260
260
|
function collectMaskedKeys(registry: Registry): MaskedKey[] {
|
|
261
261
|
const out: MaskedKey[] = [];
|
|
262
262
|
for (const [qn, def] of registry.getAllConfigKeys()) {
|
|
263
|
-
// computed keys derive their value — there is no row to set, so a
|
|
264
|
-
// configEdit screen could not write them. Skip even when masked.
|
|
265
|
-
if (def.mask === undefined || def.computed !== undefined) continue;
|
|
266
263
|
const sep = qn.indexOf(":config:");
|
|
267
264
|
if (sep === -1) continue;
|
|
268
265
|
const ownerFeature = qn.slice(0, sep);
|
|
@@ -271,6 +268,9 @@ function collectMaskedKeys(registry: Registry): MaskedKey[] {
|
|
|
271
268
|
`[Feature ${ownerFeature}] config key "${qn.slice(sep + ":config:".length)}" has invalid group "${def.group}" — must be kebab-case.`,
|
|
272
269
|
);
|
|
273
270
|
}
|
|
271
|
+
// computed keys derive their value — there is no row to set, so a
|
|
272
|
+
// configEdit screen could not write them. Skip even when masked.
|
|
273
|
+
if (def.mask === undefined || def.computed !== undefined) continue;
|
|
274
274
|
out.push({
|
|
275
275
|
qn,
|
|
276
276
|
feature: def.group ?? ownerFeature,
|
|
@@ -81,6 +81,7 @@ type ConfigKeyOptions<T extends ConfigKeyType> = {
|
|
|
81
81
|
read?: readonly string[];
|
|
82
82
|
default?: ConfigValue<T>;
|
|
83
83
|
encrypted?: boolean;
|
|
84
|
+
piiEncrypted?: T extends "text" ? boolean : never;
|
|
84
85
|
options?: readonly string[]; // for select type
|
|
85
86
|
bounds?: T extends "number" ? ConfigBounds : never;
|
|
86
87
|
// Regex enforced at write (set.write) — only meaningful for text keys
|
|
@@ -123,6 +124,7 @@ function createConfigKey<T extends ConfigKeyType>(
|
|
|
123
124
|
},
|
|
124
125
|
default: opts.default,
|
|
125
126
|
...(opts.encrypted ? { encrypted: true } : {}),
|
|
127
|
+
...(opts.piiEncrypted ? { piiEncrypted: true } : {}),
|
|
126
128
|
...(opts.options ? { options: opts.options } : {}),
|
|
127
129
|
bounds: opts.bounds as ConfigBounds | undefined, // @cast-boundary schema-walk
|
|
128
130
|
...(opts.pattern ? { pattern: opts.pattern } : {}),
|
|
@@ -2,6 +2,7 @@ import { createInitialFeatureBuilderState } from "./feature-builder-state";
|
|
|
2
2
|
import { buildConfigEventsJobsMethods } from "./feature-config-events-jobs";
|
|
3
3
|
import { buildEntityHandlerMethods } from "./feature-entity-handlers";
|
|
4
4
|
import { buildUiExtensionsMethods } from "./feature-ui-extensions";
|
|
5
|
+
import { unwrapArrayForm } from "./object-form";
|
|
5
6
|
import type { FeatureDefinition, FeatureRegistrar, HookMap, UiHints } from "./types";
|
|
6
7
|
import type { RequiresApi } from "./types/feature";
|
|
7
8
|
|
|
@@ -25,11 +26,7 @@ import type { RequiresApi } from "./types/feature";
|
|
|
25
26
|
function resolveFeatureNamesArgs(
|
|
26
27
|
args: readonly [{ readonly features: readonly string[] }] | readonly string[],
|
|
27
28
|
): readonly string[] {
|
|
28
|
-
|
|
29
|
-
if (typeof first === "object" && first !== null && "features" in first) {
|
|
30
|
-
return first.features;
|
|
31
|
-
}
|
|
32
|
-
return args as readonly string[];
|
|
29
|
+
return unwrapArrayForm(args, "features");
|
|
33
30
|
}
|
|
34
31
|
|
|
35
32
|
export function defineFeature<const TName extends string, TExports = undefined>(
|
|
@@ -156,7 +153,6 @@ export function defineFeature<const TName extends string, TExports = undefined>(
|
|
|
156
153
|
workspaces: state.workspaces,
|
|
157
154
|
httpRoutes: state.httpRoutes,
|
|
158
155
|
rawTables: state.rawTables,
|
|
159
|
-
unmanagedTables: state.unmanagedTables,
|
|
160
156
|
...(state.treeActions !== undefined && { treeActions: state.treeActions }),
|
|
161
157
|
...(state.envSchema !== undefined && { envSchema: state.envSchema }),
|
|
162
158
|
};
|
|
@@ -28,7 +28,7 @@ import type {
|
|
|
28
28
|
// Two API shapes — pick one per project, don't mix:
|
|
29
29
|
//
|
|
30
30
|
// PREFERRED — full standard CRUD set in one call:
|
|
31
|
-
//
|
|
31
|
+
// r.crud("note", noteEntity, { write: { access }, read: { access } })
|
|
32
32
|
//
|
|
33
33
|
// PREFERRED — one function per verb, type-safe, no magic strings:
|
|
34
34
|
// r.writeHandler(defineEntityCreateHandler("note", noteEntity, { access }))
|
|
@@ -176,7 +176,15 @@ export function defineEntityWriteHandler(
|
|
|
176
176
|
changes: buildUpdateSchema(entity),
|
|
177
177
|
});
|
|
178
178
|
handler = async (event, ctx) =>
|
|
179
|
-
|
|
179
|
+
// skipUnchanged (#464): API-driven updates diff against the stored
|
|
180
|
+
// row so a resubmitted-but-identical field doesn't force a fresh
|
|
181
|
+
// pii/encrypted ciphertext. Direct executor.update() callers (e.g.
|
|
182
|
+
// KEK-rotation, the user-data-rights #494 backfill) don't go through
|
|
183
|
+
// this handler and keep today's always-re-encrypt behavior, which
|
|
184
|
+
// they rely on to intentionally force a fresh event/ciphertext.
|
|
185
|
+
executor.update(event.payload as UpdatePayload, event.user, ctx.db, {
|
|
186
|
+
skipUnchanged: true,
|
|
187
|
+
}); // @cast-boundary engine-payload
|
|
180
188
|
break;
|
|
181
189
|
case "delete":
|
|
182
190
|
schema = idSchema;
|
|
@@ -475,9 +483,7 @@ export function registerEntityCrud(
|
|
|
475
483
|
): void {
|
|
476
484
|
const verbs = { ...defaultCrudVerbs(entity), ...options?.verbs };
|
|
477
485
|
if (verbs.restore && entity.softDelete !== true) {
|
|
478
|
-
throw new Error(
|
|
479
|
-
`registerEntityCrud("${entityName}"): restore requested but entity has no softDelete: true`,
|
|
480
|
-
);
|
|
486
|
+
throw new Error(`restore requested but entity "${entityName}" has no softDelete: true`);
|
|
481
487
|
}
|
|
482
488
|
|
|
483
489
|
if (options?.registerEntity !== false) {
|
|
@@ -69,7 +69,7 @@ const FEATURES: readonly RealFeature[] = [
|
|
|
69
69
|
path: "packages/bundled-features/src/sessions/feature.ts",
|
|
70
70
|
expectedFeatureName: "sessions",
|
|
71
71
|
recognisedKinds: ["hook", "writeHandler", "queryHandler"],
|
|
72
|
-
errorMethodNames: ["
|
|
72
|
+
errorMethodNames: ["rawTable", "job"],
|
|
73
73
|
},
|
|
74
74
|
{
|
|
75
75
|
path: "packages/bundled-features/src/auth-email-password/feature.ts",
|