@cosmicdrift/kumiko-framework 0.155.0 → 0.156.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.
Files changed (74) hide show
  1. package/README.md +9 -38
  2. package/package.json +2 -2
  3. package/src/__tests__/entity-permalink-open.integration.test.ts +94 -0
  4. package/src/__tests__/raw-table.integration.test.ts +17 -40
  5. package/src/crypto/__tests__/pii-field-encryption.test.ts +77 -0
  6. package/src/crypto/index.ts +1 -0
  7. package/src/crypto/pii-field-encryption.ts +19 -0
  8. package/src/db/__tests__/assert-no-unreachable-live-rows.integration.test.ts +27 -1
  9. package/src/db/__tests__/blind-index.integration.test.ts +16 -0
  10. package/src/db/__tests__/collect-table-metas.test.ts +6 -6
  11. package/src/db/__tests__/feature-table-sources.test.ts +7 -7
  12. package/src/db/__tests__/migrate-generator.test.ts +21 -0
  13. package/src/db/__tests__/number-field-fractional.integration.test.ts +1 -1
  14. package/src/db/__tests__/tenant-db-where-merge.test.ts +39 -0
  15. package/src/db/collect-table-metas.ts +6 -7
  16. package/src/db/entity-table-meta.ts +1 -1
  17. package/src/db/feature-table-sources.ts +5 -11
  18. package/src/db/migrate-generator.ts +39 -6
  19. package/src/db/queries/shadow-swap.ts +85 -3
  20. package/src/db/tenant-db.ts +8 -0
  21. package/src/engine/__tests__/boot-validator-pii-retention.test.ts +102 -0
  22. package/src/engine/__tests__/build-config-feature-schema.test.ts +16 -0
  23. package/src/engine/__tests__/feature-crud-shorthand.test.ts +28 -0
  24. package/src/engine/__tests__/field-access.test.ts +23 -1
  25. package/src/engine/__tests__/raw-table.test.ts +134 -84
  26. package/src/engine/__tests__/registry.test.ts +40 -0
  27. package/src/engine/boot-validator/__tests__/config-deps.test.ts +52 -2
  28. package/src/engine/boot-validator/action-wiring.ts +2 -2
  29. package/src/engine/boot-validator/config-deps.ts +35 -0
  30. package/src/engine/boot-validator/index.ts +2 -0
  31. package/src/engine/boot-validator/pii-retention.ts +35 -3
  32. package/src/engine/build-config-feature-schema.ts +3 -3
  33. package/src/engine/config-helpers.ts +2 -0
  34. package/src/engine/define-feature.ts +2 -6
  35. package/src/engine/entity-handlers.ts +2 -4
  36. package/src/engine/feature-ast/__tests__/parse-real-features.test.ts +1 -1
  37. package/src/engine/feature-ast/__tests__/parse.test.ts +170 -0
  38. package/src/engine/feature-ast/__tests__/patch.test.ts +31 -0
  39. package/src/engine/feature-ast/__tests__/render-roundtrip.test.ts +6 -1
  40. package/src/engine/feature-ast/extractors/events.ts +322 -0
  41. package/src/engine/feature-ast/extractors/handlers.ts +222 -0
  42. package/src/engine/feature-ast/extractors/hooks.ts +243 -0
  43. package/src/engine/feature-ast/extractors/index.ts +32 -24
  44. package/src/engine/feature-ast/extractors/jobs-routes.ts +221 -0
  45. package/src/engine/feature-ast/extractors/projections-screens.ts +269 -0
  46. package/src/engine/feature-ast/extractors/round5.ts +3 -3
  47. package/src/engine/feature-ast/parse.ts +3 -3
  48. package/src/engine/feature-ast/render.ts +16 -11
  49. package/src/engine/feature-builder-state.ts +0 -3
  50. package/src/engine/feature-config-events-jobs.ts +3 -6
  51. package/src/engine/feature-entity-handlers.ts +9 -1
  52. package/src/engine/feature-ui-extensions.ts +10 -40
  53. package/src/engine/field-access.ts +13 -2
  54. package/src/engine/object-form.ts +15 -0
  55. package/src/engine/registry-facade.ts +4 -0
  56. package/src/engine/registry-ingest.ts +19 -29
  57. package/src/engine/registry-state.ts +1 -7
  58. package/src/engine/registry-validate.ts +5 -28
  59. package/src/engine/registry.ts +0 -2
  60. package/src/engine/types/config.ts +7 -0
  61. package/src/engine/types/feature.ts +40 -68
  62. package/src/engine/types/fields.ts +6 -0
  63. package/src/engine/types/index.ts +0 -3
  64. package/src/es-ops/README.md +1 -1
  65. package/src/es-ops/__tests__/runner.integration.test.ts +74 -0
  66. package/src/es-ops/context.ts +4 -2
  67. package/src/es-ops/types.ts +8 -1
  68. package/src/pipeline/msp-rebuild.ts +4 -0
  69. package/src/pipeline/projection-rebuild.ts +35 -0
  70. package/src/search/__tests__/meilisearch-adapter.integration.test.ts +8 -1
  71. package/src/stack/test-stack.ts +17 -2
  72. package/src/ui-types/index.ts +1 -0
  73. package/src/engine/__tests__/unmanaged-table.test.ts +0 -172
  74. 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. Full DB roundtrip (setupTestStack pushes the table INSERT
3
- // / SELECT) lives in src/__tests__/raw-table.integration.ts.
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 type { SchemaTable } from "../../db/dialect";
7
- import { table, text } from "../../db/dialect";
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 probeTable = table("rt_probe_table", {
13
- id: text("id").primaryKey(),
18
+ const probeMeta = defineUnmanagedTable({
19
+ tableName: "rt_probe",
20
+ columns: [{ name: "id", pgType: "text", notNull: true, primaryKey: true }],
14
21
  });
15
- const probeTableTwo = table("rt_probe_table_two", {
16
- id: text("id").primaryKey(),
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 non-kebab-case names", () => {
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("BadName", probeTable, { reason: "test" });
35
+ r.rawTable(badMeta, { reason: "test" });
24
36
  }),
25
- ).toThrow(/must be kebab-case/);
37
+ ).toThrow(/must be a valid identifier/);
26
38
  });
27
39
 
28
- test("rejects duplicate names within one feature", () => {
40
+ test("rejects duplicate registrations within one feature", () => {
29
41
  expect(() =>
30
42
  defineFeature("probe", (r) => {
31
- r.rawTable("cache", probeTable, { reason: "test" });
32
- r.rawTable("cache", probeTableTwo, { reason: "test" });
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("cache", probeTable, { reason: "" });
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("cache", probeTable, { reason: " " });
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 verbatim", () => {
65
+ test("accepts valid registration and stores meta + reason", () => {
54
66
  const feature = defineFeature("probe", (r) => {
55
- r.rawTable("cache", probeTable, {
56
- reason: "external Stripe webhook cache, write-only by webhook handler",
67
+ r.rawTable(probeMeta, {
68
+ reason: "read-side projection of an event-stream",
57
69
  });
58
70
  });
59
- expect(feature.rawTables).toHaveProperty("cache");
60
- expect(feature.rawTables["cache"]?.reason).toBe(
61
- "external Stripe webhook cache, write-only by webhook handler",
62
- );
63
- expect(feature.rawTables["cache"]?.table).toBe(probeTable);
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("stripe-cache", probeTable, { reason: "external API cache" });
95
+ r.rawTable(probeMeta, { reason: "external API cache" });
71
96
  });
72
97
  const featB = defineFeature("inventory", (r) => {
73
- r.rawTable("legacy-import", probeTableTwo, { reason: "imported pre-ES" });
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("stripe-cache")?.featureName).toBe("billing");
81
- expect(all.get("stripe-cache")?.reason).toBe("external API cache");
82
- expect(all.get("legacy-import")?.featureName).toBe("inventory");
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 name collisions at boot", () => {
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("shared", probeTable, { reason: "first" });
114
+ r.rawTable(probeMeta, { reason: "first" });
88
115
  });
89
116
  const featB = defineFeature("b", (r) => {
90
- r.rawTable("shared", probeTableTwo, { reason: "second" });
117
+ r.rawTable(probeMeta, { reason: "second" });
91
118
  });
92
-
93
119
  expect(() => createRegistry([featA, featB])).toThrow(
94
- /Raw-table "shared" registered by both feature "a" and "b"/,
120
+ /Raw-table "rt_probe" registered by both feature "a" and "b"/,
95
121
  );
96
122
  });
97
123
 
98
- test("absent rawTables block on a feature is ok (legacy / hand-built features)", () => {
99
- const featNoRaw = defineFeature("no-raw", () => {
100
- // no r.rawTable calls
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
- const registry = createRegistry([featNoRaw]);
103
- expect(registry.getAllRawTables().size).toBe(0);
104
- });
105
-
106
- test("two raw tables on the same PgTable register under both names", () => {
107
- // Different logical names, identical physical target — legitimate
108
- // when one role writes (primary cache) and a second role reads
109
- // (alias view). Push-time dedupe keeps the boot to a single CREATE;
110
- // the registry keeps both entries so callers can resolve either name.
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 { validateConfigKeyAllowPerRequest, validateConfigKeyComputed } from "../config-deps";
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
- // compiles fine (the DSL types are structural, not branded) but is silently
19
- // dropped once the feature config is serialized for the client.
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
- const [first] = args;
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
- // registerEntityCrud(r, "note", noteEntity, { write: { access }, read: { access } })
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 }))
@@ -475,9 +475,7 @@ export function registerEntityCrud(
475
475
  ): void {
476
476
  const verbs = { ...defaultCrudVerbs(entity), ...options?.verbs };
477
477
  if (verbs.restore && entity.softDelete !== true) {
478
- throw new Error(
479
- `registerEntityCrud("${entityName}"): restore requested but entity has no softDelete: true`,
480
- );
478
+ throw new Error(`restore requested but entity "${entityName}" has no softDelete: true`);
481
479
  }
482
480
 
483
481
  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: ["unmanagedTable", "job"],
72
+ errorMethodNames: ["rawTable", "job"],
73
73
  },
74
74
  {
75
75
  path: "packages/bundled-features/src/auth-email-password/feature.ts",