@cosmicdrift/kumiko-framework 0.319.0 → 0.320.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 (46) hide show
  1. package/package.json +8 -4
  2. package/src/api/__tests__/auth-routes-mfa-preauth-enable-start.test.ts +6 -0
  3. package/src/api/__tests__/auth-routes-mfa-verify.test.ts +11 -0
  4. package/src/api/__tests__/auth-routes-trusted-proxy.test.ts +8 -4
  5. package/src/api/__tests__/client-ip.test.ts +139 -0
  6. package/src/api/__tests__/extra-routes.integration.test.ts +8 -1
  7. package/src/api/__tests__/http-route-rate-limit.integration.test.ts +73 -2
  8. package/src/api/__tests__/pat-route-guard.integration.test.ts +126 -0
  9. package/src/api/__tests__/post-auth-landing.test.ts +100 -0
  10. package/src/api/__tests__/request-id-middleware.test.ts +35 -2
  11. package/src/api/__tests__/schema-route.integration.test.ts +157 -0
  12. package/src/api/api-constants.ts +2 -0
  13. package/src/api/auth-routes.ts +92 -95
  14. package/src/api/client-ip.ts +91 -0
  15. package/src/api/index.ts +15 -0
  16. package/src/api/pat-route-guard.ts +51 -0
  17. package/src/api/post-auth-landing.ts +105 -0
  18. package/src/api/request-context.ts +4 -2
  19. package/src/api/request-id-middleware.ts +44 -14
  20. package/src/api/server.ts +128 -40
  21. package/src/changes.json +73 -0
  22. package/src/engine/__tests__/boot-validator-pii-retention.test.ts +47 -0
  23. package/src/engine/__tests__/metric-navigate-tab.test.ts +167 -0
  24. package/src/engine/__tests__/project-app-schema-for-roles.test.ts +500 -0
  25. package/src/engine/__tests__/toolbar-navigate-tab.test.ts +233 -0
  26. package/src/engine/boot-validator/pii-retention.ts +18 -2
  27. package/src/engine/boot-validator/screens.ts +75 -18
  28. package/src/engine/index.ts +1 -0
  29. package/src/engine/project-app-schema-for-roles.ts +950 -0
  30. package/src/engine/types/handlers.ts +1 -1
  31. package/src/engine/types/index.ts +5 -1
  32. package/src/entrypoint/index.ts +14 -1
  33. package/src/files/__tests__/files.integration.test.ts +105 -1
  34. package/src/files/file-routes.ts +54 -2
  35. package/src/files/index.ts +1 -1
  36. package/src/http/index.ts +10 -2
  37. package/src/jobs/__tests__/jobs.integration.test.ts +105 -3
  38. package/src/jobs/job-runner.ts +6 -1
  39. package/src/rate-limit/__tests__/middleware.integration.test.ts +24 -7
  40. package/src/rate-limit/middleware.ts +32 -8
  41. package/src/stack/__tests__/push-entity-projection-tables-column-sync.integration.test.ts +107 -0
  42. package/src/stack/push-entity-projection-tables.ts +52 -7
  43. package/src/stack/table-helpers.ts +17 -9
  44. package/src/stack/test-stack.ts +28 -1
  45. package/src/testing/real-providers.ts +2 -0
  46. package/src/ui-types/index.ts +1 -1
@@ -0,0 +1,233 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import * as z from "zod";
3
+ import { withBootValidatorFixture } from "../../testing/boot-validator-fixture";
4
+ import { validateBoot as validateBootRaw } from "../boot-validator";
5
+ import { defineFeature } from "../define-feature";
6
+ import { createEntity, createTextField } from "../factories";
7
+
8
+ function validateBoot(features: Parameters<typeof validateBootRaw>[0]): void {
9
+ validateBootRaw(withBootValidatorFixture(features));
10
+ }
11
+
12
+ describe("validateBoot — ToolbarAction.tab", () => {
13
+ test("entityList toolbar navigate + unknown tab throws with the action id", () => {
14
+ const feature = defineFeature("app", (r) => {
15
+ r.queryHandler("vehicle:detail", z.object({}), async () => ({ name: "x" }), {
16
+ access: { openToAll: { reason: "test handler callable by any signed-in test user" } },
17
+ });
18
+ r.screen({
19
+ id: "vehicle-detail",
20
+ type: "projectionDetail",
21
+ query: "app:query:vehicle:detail",
22
+ layout: {
23
+ mode: "tabs",
24
+ sections: [
25
+ { id: "overview", title: "Overview", fields: ["name"] },
26
+ { id: "channels", title: "Channels", fields: ["name"] },
27
+ ],
28
+ },
29
+ });
30
+ r.entity(
31
+ "car",
32
+ createEntity({
33
+ fields: { name: createTextField({ personal: false, reason: "test_fixture" }) },
34
+ }),
35
+ );
36
+ r.screen({
37
+ id: "car-list",
38
+ type: "entityList",
39
+ entity: "car",
40
+ columns: ["name"],
41
+ toolbarActions: [
42
+ {
43
+ kind: "navigate",
44
+ id: "view-channels",
45
+ label: "app.actions.viewChannels",
46
+ screen: "vehicle-detail",
47
+ tab: "unknown",
48
+ },
49
+ ],
50
+ });
51
+ });
52
+ expect(() => validateBoot([feature])).toThrow(
53
+ /toolbarAction "view-channels".*navigates to tab "unknown", which is not a section id on target screen/,
54
+ );
55
+ });
56
+
57
+ test("entityList toolbar navigate + known tab boots cleanly", () => {
58
+ const feature = defineFeature("app", (r) => {
59
+ r.queryHandler("vehicle:detail", z.object({}), async () => ({ name: "x" }), {
60
+ access: { openToAll: { reason: "test handler callable by any signed-in test user" } },
61
+ });
62
+ r.screen({
63
+ id: "vehicle-detail",
64
+ type: "projectionDetail",
65
+ query: "app:query:vehicle:detail",
66
+ layout: {
67
+ mode: "tabs",
68
+ sections: [
69
+ { id: "overview", title: "Overview", fields: ["name"] },
70
+ { id: "channels", title: "Channels", fields: ["name"] },
71
+ ],
72
+ },
73
+ });
74
+ r.entity(
75
+ "car",
76
+ createEntity({
77
+ fields: { name: createTextField({ personal: false, reason: "test_fixture" }) },
78
+ }),
79
+ );
80
+ r.screen({
81
+ id: "car-list",
82
+ type: "entityList",
83
+ entity: "car",
84
+ columns: ["name"],
85
+ toolbarActions: [
86
+ {
87
+ kind: "navigate",
88
+ id: "view-channels",
89
+ label: "app.actions.viewChannels",
90
+ screen: "vehicle-detail",
91
+ tab: "channels",
92
+ },
93
+ ],
94
+ });
95
+ });
96
+ expect(() => validateBoot([feature])).not.toThrow();
97
+ });
98
+
99
+ test("entityList toolbar navigate targeting a non-tabs projectionDetail throws", () => {
100
+ const feature = defineFeature("app", (r) => {
101
+ r.queryHandler("vehicle:detail", z.object({}), async () => ({ name: "x" }), {
102
+ access: { openToAll: { reason: "test handler callable by any signed-in test user" } },
103
+ });
104
+ r.screen({
105
+ id: "vehicle-detail",
106
+ type: "projectionDetail",
107
+ query: "app:query:vehicle:detail",
108
+ layout: { sections: [{ title: "Overview", fields: ["name"] }] },
109
+ });
110
+ r.entity(
111
+ "car",
112
+ createEntity({
113
+ fields: { name: createTextField({ personal: false, reason: "test_fixture" }) },
114
+ }),
115
+ );
116
+ r.screen({
117
+ id: "car-list",
118
+ type: "entityList",
119
+ entity: "car",
120
+ columns: ["name"],
121
+ toolbarActions: [
122
+ {
123
+ kind: "navigate",
124
+ id: "view-channels",
125
+ label: "app.actions.viewChannels",
126
+ screen: "vehicle-detail",
127
+ tab: "overview",
128
+ },
129
+ ],
130
+ });
131
+ });
132
+ expect(() => validateBoot([feature])).toThrow(
133
+ /is not a projectionDetail with layout.mode "tabs"/,
134
+ );
135
+ });
136
+
137
+ test("projectionList toolbar navigate + unknown tab throws", () => {
138
+ const feature = defineFeature("app", (r) => {
139
+ r.queryHandler("vehicle:detail", z.object({}), async () => ({ name: "x" }), {
140
+ access: { openToAll: { reason: "test handler callable by any signed-in test user" } },
141
+ });
142
+ r.screen({
143
+ id: "vehicle-detail",
144
+ type: "projectionDetail",
145
+ query: "app:query:vehicle:detail",
146
+ layout: {
147
+ mode: "tabs",
148
+ sections: [
149
+ { id: "overview", title: "Overview", fields: ["name"] },
150
+ { id: "channels", title: "Channels", fields: ["name"] },
151
+ ],
152
+ },
153
+ });
154
+ r.queryHandler("car:list", z.object({}), async () => ({ rows: [], nextCursor: null }), {
155
+ access: { openToAll: { reason: "test handler callable by any signed-in test user" } },
156
+ });
157
+ r.screen({
158
+ id: "car-projection-list",
159
+ type: "projectionList",
160
+ query: "app:query:car:list",
161
+ columns: ["name"],
162
+ toolbarActions: [
163
+ {
164
+ kind: "navigate",
165
+ id: "view-channels",
166
+ label: "app.actions.viewChannels",
167
+ screen: "vehicle-detail",
168
+ tab: "unknown",
169
+ },
170
+ ],
171
+ });
172
+ });
173
+ expect(() => validateBoot([feature])).toThrow(
174
+ /toolbarAction "view-channels".*navigates to tab "unknown", which is not a section id on target screen/,
175
+ );
176
+ });
177
+
178
+ test("relatedList section toolbar navigate + unknown tab throws", () => {
179
+ const feature = defineFeature("app", (r) => {
180
+ r.queryHandler("vehicle:detail", z.object({}), async () => ({ name: "x" }), {
181
+ access: { openToAll: { reason: "test handler callable by any signed-in test user" } },
182
+ });
183
+ r.screen({
184
+ id: "vehicle-detail",
185
+ type: "projectionDetail",
186
+ query: "app:query:vehicle:detail",
187
+ layout: {
188
+ mode: "tabs",
189
+ sections: [
190
+ { id: "overview", title: "Overview", fields: ["name"] },
191
+ { id: "channels", title: "Channels", fields: ["name"] },
192
+ ],
193
+ },
194
+ });
195
+ r.queryHandler("lease:detail", z.object({}), async () => ({ id: "1" }), {
196
+ access: { openToAll: { reason: "test handler callable by any signed-in test user" } },
197
+ });
198
+ r.queryHandler(
199
+ "lease:positions",
200
+ z.object({}),
201
+ async () => ({ rows: [], nextCursor: null }),
202
+ { access: { openToAll: { reason: "test handler callable by any signed-in test user" } } },
203
+ );
204
+ r.screen({
205
+ id: "lease-detail",
206
+ type: "projectionDetail",
207
+ query: "app:query:lease:detail",
208
+ layout: {
209
+ sections: [
210
+ {
211
+ kind: "relatedList",
212
+ title: "Positions",
213
+ query: "app:query:lease:positions",
214
+ columns: ["id"],
215
+ toolbarActions: [
216
+ {
217
+ kind: "navigate",
218
+ id: "view-channels",
219
+ label: "app.actions.viewChannels",
220
+ screen: "vehicle-detail",
221
+ tab: "unknown",
222
+ },
223
+ ],
224
+ },
225
+ ],
226
+ },
227
+ });
228
+ });
229
+ expect(() => validateBoot([feature])).toThrow(
230
+ /toolbarAction "view-channels".*navigates to tab "unknown", which is not a section id on target screen/,
231
+ );
232
+ });
233
+ });
@@ -1,5 +1,5 @@
1
1
  import type { FeatureDefinition } from "../types";
2
- import type { ResolvedPiiFlags } from "../types/fields";
2
+ import type { EntityDefinition, ResolvedPiiFlags } from "../types/fields";
3
3
  import {
4
4
  PII_DIRECT_NAME_HINTS,
5
5
  PII_USER_OWNED_NAME_HINTS,
@@ -27,6 +27,17 @@ function hasAnonymizableSubjectField(annot: ResolvedPiiFlags): boolean {
27
27
  return Boolean(annot.pii || annot.userOwned || annot.tenantOwned || annot.recordOwned);
28
28
  }
29
29
 
30
+ function ownerFieldNamesReferencedByPersonalOf(
31
+ fieldsByName: EntityDefinition["fields"],
32
+ ): ReadonlySet<string> {
33
+ const ownerFieldNames = new Set<string>();
34
+ for (const field of Object.values(fieldsByName)) {
35
+ const ownerFieldName = (field as ResolvedPiiFlags).userOwned?.ownerField; // @cast-boundary schema-walk
36
+ if (typeof ownerFieldName === "string") ownerFieldNames.add(ownerFieldName);
37
+ }
38
+ return ownerFieldNames;
39
+ }
40
+
30
41
  // --- PII / Subject-Key Annotations + Retention validation ---
31
42
  //
32
43
  // Drei Klassen von Checks:
@@ -54,6 +65,7 @@ function hasAnonymizableSubjectField(annot: ResolvedPiiFlags): boolean {
54
65
  export function validatePiiAndRetention(feature: FeatureDefinition): void {
55
66
  for (const [entityName, entity] of Object.entries(feature.entities ?? {})) {
56
67
  const fieldsByName = entity.fields;
68
+ const personalOfOwnerFieldNames = ownerFieldNamesReferencedByPersonalOf(fieldsByName);
57
69
 
58
70
  for (const [fieldName, field] of Object.entries(fieldsByName)) {
59
71
  // ResolvedPiiFlags properties are type-level optional. On field defs
@@ -192,7 +204,11 @@ export function validatePiiAndRetention(feature: FeatureDefinition): void {
192
204
  console.warn(
193
205
  `[kumiko:boot] [Feature ${feature.name}] Field "${fieldName}" on entity "${entityName}" has a user-content-typical name but no personal annotation. If this contains user-generated content, mark it { personal: { of: "<authorIdField>" }, find: ... }. If business data, set { personal: false, reason: "..." } to silence.`,
194
206
  );
195
- } else if (PII_USER_REFERENCE_NAME_HINTS.has(lower) && !annot.subjectRef) {
207
+ } else if (
208
+ PII_USER_REFERENCE_NAME_HINTS.has(lower) &&
209
+ !annot.subjectRef &&
210
+ !personalOfOwnerFieldNames.has(fieldName)
211
+ ) {
196
212
  // biome-ignore lint/suspicious/noConsole: boot-time dev hint, no logger available yet
197
213
  console.warn(
198
214
  `[kumiko:boot] [Feature ${feature.name}] Field "${fieldName}" on entity "${entityName}" has a user-reference-typical name but no personal annotation — a foreign key into \`user\` carries Art.17 obligations even with no annotated content on the entity. Mark it { personal: "ref" } AND register r.useExtension(EXT_USER_DATA, "${entityName}", …) for Art.17 coverage — this warning is the only boot-time check for that, registering the hook is not enforced. Or { personal: { of: "${fieldName}" } } on the field it owns. If business data, set { personal: false, reason: "..." } to silence.`,
@@ -450,7 +450,7 @@ export function validateScreenShortIdCollisions(
450
450
  // cross-feature screen QN (`<feature>:screen:<id>`) given verbatim — a
451
451
  // short id can never itself be a valid QN (QN_SEGMENT forbids colons), so
452
452
  // the two forms don't collide.
453
- function resolveScreenTargetQn(featureName: string, target: string): string {
453
+ export function resolveScreenTargetQn(featureName: string, target: string): string {
454
454
  return isValidQn(target) ? target : qualifyEntityName(featureName, "screen", target);
455
455
  }
456
456
 
@@ -1158,9 +1158,9 @@ export function validateScreens(
1158
1158
  }
1159
1159
  validateAtMostOneRowClick(feature.name, screenId, "projectionList", screen.rowActions);
1160
1160
  }
1161
- // Only drawer-kind is validated here — navigate/writeHandler toolbarActions
1162
- // on projectionList have no boot check yet (pre-existing gap, out of
1163
- // scope for fw#2225).
1161
+ // Only drawer-kind and navigate actions that set tab are validated
1162
+ // here — plain navigate (no tab) and writeHandler toolbarActions on
1163
+ // projectionList have no boot check yet (pre-existing gap).
1164
1164
  if (screen.toolbarActions !== undefined) {
1165
1165
  for (const action of screen.toolbarActions) {
1166
1166
  if (action.kind === "drawer") {
@@ -1173,6 +1173,19 @@ export function validateScreens(
1173
1173
  feature.screens,
1174
1174
  );
1175
1175
  }
1176
+ if (action.kind === "navigate" && action.tab !== undefined) {
1177
+ resolveRowActionNavigateTarget(
1178
+ feature.name,
1179
+ screenId,
1180
+ "projectionList",
1181
+ "toolbarAction",
1182
+ action,
1183
+ allScreenQns,
1184
+ navTargetShortIds,
1185
+ screensByShortId,
1186
+ detailForScreens,
1187
+ );
1188
+ }
1176
1189
  }
1177
1190
  }
1178
1191
  continue;
@@ -1239,18 +1252,35 @@ export function validateScreens(
1239
1252
  );
1240
1253
  }
1241
1254
  const navigate = typeof metric === "string" ? undefined : metric.navigate;
1242
- // Cross-screen navigate+tab isn't checked here — the target screen
1243
- // validates its own section ids when the loop reaches it.
1244
- if (
1245
- navigate?.tab !== undefined &&
1246
- navigate.screen === undefined &&
1247
- navigate.entity === undefined &&
1248
- !screen.layout.sections.some((section) => section.id === navigate.tab)
1249
- ) {
1250
- throw new Error(
1251
- `[Feature ${feature.name}] Screen "${screenId}" (projectionDetail) metric "${field}" ` +
1252
- `navigates to tab "${navigate.tab}", which is not a section id on this screen.`,
1253
- );
1255
+ if (navigate?.tab !== undefined) {
1256
+ if (navigate.screen === undefined && navigate.entity === undefined) {
1257
+ if (!screen.layout.sections.some((section) => section.id === navigate.tab)) {
1258
+ throw new Error(
1259
+ `[Feature ${feature.name}] Screen "${screenId}" (projectionDetail) metric "${field}" ` +
1260
+ `navigates to tab "${navigate.tab}", which is not a section id on this screen.`,
1261
+ );
1262
+ }
1263
+ } else {
1264
+ resolveRowActionNavigateTarget(
1265
+ feature.name,
1266
+ screenId,
1267
+ "projectionDetail",
1268
+ "metric",
1269
+ {
1270
+ kind: "navigate",
1271
+ id: field,
1272
+ label: field,
1273
+ ...(navigate.screen !== undefined ? { screen: navigate.screen } : {}),
1274
+ ...(navigate.entity !== undefined ? { entity: navigate.entity } : {}),
1275
+ ...(navigate.entityId !== undefined ? { entityId: navigate.entityId } : {}),
1276
+ tab: navigate.tab,
1277
+ },
1278
+ allScreenQns,
1279
+ navTargetShortIds,
1280
+ screensByShortId,
1281
+ detailForScreens,
1282
+ );
1283
+ }
1254
1284
  }
1255
1285
  }
1256
1286
  }
@@ -1459,8 +1489,9 @@ export function validateScreens(
1459
1489
  featureMap,
1460
1490
  );
1461
1491
  }
1462
- // Only drawer-kind is validated here — navigate/writeHandler
1463
- // toolbarActions have no boot check yet, same gap as above.
1492
+ // Only drawer-kind and navigate actions that set tab are validated
1493
+ // here — plain navigate (no tab) and writeHandler toolbarActions
1494
+ // have no boot check yet, same gap as above.
1464
1495
  if (section.toolbarActions !== undefined) {
1465
1496
  for (const action of section.toolbarActions) {
1466
1497
  if (action.kind === "drawer") {
@@ -1473,6 +1504,19 @@ export function validateScreens(
1473
1504
  feature.screens,
1474
1505
  );
1475
1506
  }
1507
+ if (action.kind === "navigate" && action.tab !== undefined) {
1508
+ resolveRowActionNavigateTarget(
1509
+ feature.name,
1510
+ screenId,
1511
+ "projectionDetail",
1512
+ "toolbarAction",
1513
+ action,
1514
+ allScreenQns,
1515
+ navTargetShortIds,
1516
+ screensByShortId,
1517
+ detailForScreens,
1518
+ );
1519
+ }
1476
1520
  }
1477
1521
  }
1478
1522
  continue;
@@ -2002,6 +2046,19 @@ export function validateScreens(
2002
2046
  `navigate-target "${action.screen}" does not resolve to a registered screen in any feature.`,
2003
2047
  );
2004
2048
  }
2049
+ if (action.tab !== undefined) {
2050
+ resolveRowActionNavigateTarget(
2051
+ feature.name,
2052
+ screenId,
2053
+ "entityList",
2054
+ "toolbarAction",
2055
+ action,
2056
+ allScreenQns,
2057
+ navTargetShortIds,
2058
+ screensByShortId,
2059
+ detailForScreens,
2060
+ );
2061
+ }
2005
2062
  } else if (action.kind === "drawer") {
2006
2063
  validateDrawerTargetAction(
2007
2064
  feature.name,
@@ -267,6 +267,7 @@ export {
267
267
  export type { ParsedRefTarget } from "./parse-ref-target";
268
268
  export { parseRefTarget, parseRefTargetEntityName } from "./parse-ref-target";
269
269
  export { buildPipelineSteps, stepsPipeline } from "./pipeline";
270
+ export { projectAppSchemaForRoles } from "./project-app-schema-for-roles";
270
271
  export { defineApply, defineMspApply, setFields } from "./projection-helpers";
271
272
  export type { BuiltinQnType, ParsedQn, QnType } from "./qualified-name";
272
273
  export { isValidQn, parseQn, QnTypes, qn, toKebab } from "./qualified-name";