@cosmicdrift/kumiko-framework 0.319.0 → 0.321.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 (71) hide show
  1. package/package.json +8 -4
  2. package/src/__tests__/schema-cli.integration.test.ts +11 -0
  3. package/src/__tests__/upgrade-cli.test.ts +50 -3
  4. package/src/api/__tests__/auth-routes-mfa-preauth-enable-start.test.ts +6 -0
  5. package/src/api/__tests__/auth-routes-mfa-verify.test.ts +11 -0
  6. package/src/api/__tests__/auth-routes-trusted-proxy.test.ts +8 -4
  7. package/src/api/__tests__/client-ip.test.ts +164 -0
  8. package/src/api/__tests__/extra-routes.integration.test.ts +50 -1
  9. package/src/api/__tests__/http-route-rate-limit.integration.test.ts +73 -2
  10. package/src/api/__tests__/pat-route-guard.integration.test.ts +126 -0
  11. package/src/api/__tests__/post-auth-landing.test.ts +100 -0
  12. package/src/api/__tests__/request-id-middleware.test.ts +35 -2
  13. package/src/api/__tests__/schema-route.integration.test.ts +157 -0
  14. package/src/api/api-constants.ts +2 -0
  15. package/src/api/auth-routes.ts +92 -95
  16. package/src/api/client-ip.ts +118 -0
  17. package/src/api/extra-route.ts +3 -0
  18. package/src/api/index.ts +17 -0
  19. package/src/api/pat-route-guard.ts +51 -0
  20. package/src/api/post-auth-landing.ts +105 -0
  21. package/src/api/request-context.ts +4 -2
  22. package/src/api/request-id-middleware.ts +44 -14
  23. package/src/api/server.ts +130 -40
  24. package/src/changes.json +121 -0
  25. package/src/db/queries/event-store-transfer.ts +73 -0
  26. package/src/engine/__tests__/boot-validator-pii-retention.test.ts +47 -0
  27. package/src/engine/__tests__/metric-navigate-tab.test.ts +167 -0
  28. package/src/engine/__tests__/project-app-schema-for-roles.test.ts +500 -0
  29. package/src/engine/__tests__/steps-webhook-send.test.ts +2 -2
  30. package/src/engine/__tests__/toolbar-navigate-tab.test.ts +233 -0
  31. package/src/engine/boot-validator/pii-retention.ts +18 -2
  32. package/src/engine/boot-validator/screens.ts +75 -18
  33. package/src/engine/feature-ast/__tests__/fixtures/resolve-import/alias-reexport.ts +1 -0
  34. package/src/engine/feature-ast/__tests__/fixtures/resolve-import/cycle-a.ts +1 -0
  35. package/src/engine/feature-ast/__tests__/fixtures/resolve-import/cycle-b.ts +1 -0
  36. package/src/engine/feature-ast/__tests__/fixtures/resolve-import/entry.ts +3 -0
  37. package/src/engine/feature-ast/__tests__/fixtures/resolve-import/local-rebind.ts +3 -0
  38. package/src/engine/feature-ast/__tests__/fixtures/resolve-import/origin.ts +1 -0
  39. package/src/engine/feature-ast/__tests__/fixtures/resolve-import/star-barrel.ts +1 -0
  40. package/src/engine/feature-ast/__tests__/parse.test.ts +4 -4
  41. package/src/engine/feature-ast/__tests__/resolve-import.test.ts +138 -0
  42. package/src/engine/feature-ast/extractors/index.ts +4 -0
  43. package/src/engine/feature-ast/extractors/resolve-import.ts +187 -0
  44. package/src/engine/feature-ast/extractors/shared.ts +26 -19
  45. package/src/engine/feature-ast/parse.ts +13 -20
  46. package/src/engine/index.ts +1 -0
  47. package/src/engine/project-app-schema-for-roles.ts +950 -0
  48. package/src/engine/steps/webhook-send.ts +4 -2
  49. package/src/engine/types/handlers.ts +1 -1
  50. package/src/engine/types/index.ts +5 -1
  51. package/src/entrypoint/index.ts +14 -1
  52. package/src/event-store/__tests__/transfer.integration.test.ts +236 -0
  53. package/src/event-store/index.ts +6 -0
  54. package/src/event-store/transfer.ts +84 -0
  55. package/src/files/__tests__/files.integration.test.ts +105 -1
  56. package/src/files/file-routes.ts +54 -2
  57. package/src/files/index.ts +1 -1
  58. package/src/http/index.ts +10 -2
  59. package/src/jobs/__tests__/jobs.integration.test.ts +105 -3
  60. package/src/jobs/job-runner.ts +6 -1
  61. package/src/pipeline/multi-stream-apply-context.ts +4 -0
  62. package/src/rate-limit/__tests__/middleware.integration.test.ts +24 -7
  63. package/src/rate-limit/middleware.ts +32 -8
  64. package/src/schema-cli.ts +5 -1
  65. package/src/stack/__tests__/push-entity-projection-tables-column-sync.integration.test.ts +107 -0
  66. package/src/stack/push-entity-projection-tables.ts +52 -7
  67. package/src/stack/table-helpers.ts +17 -9
  68. package/src/stack/test-stack.ts +28 -1
  69. package/src/testing/real-providers.ts +2 -0
  70. package/src/ui-types/index.ts +1 -1
  71. package/src/upgrade-cli.ts +58 -18
@@ -0,0 +1,167 @@
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 — MetricNavigate.tab (cross-screen)", () => {
13
+ test("metric navigate.screen + valid tab boots cleanly", () => {
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.queryHandler("fleet:summary", z.object({}), async () => ({ name: "x" }), {
31
+ access: { openToAll: { reason: "test handler callable by any signed-in test user" } },
32
+ });
33
+ r.screen({
34
+ id: "fleet-summary",
35
+ type: "projectionDetail",
36
+ query: "app:query:fleet:summary",
37
+ layout: { sections: [{ title: "Summary", fields: ["name"] }] },
38
+ metrics: [
39
+ {
40
+ field: "name",
41
+ label: "app.metrics.name",
42
+ navigate: { screen: "vehicle-detail", tab: "channels" },
43
+ },
44
+ ],
45
+ });
46
+ });
47
+ expect(() => validateBoot([feature])).not.toThrow();
48
+ });
49
+
50
+ test("metric navigate.screen + unknown tab throws with the metric field name", () => {
51
+ const feature = defineFeature("app", (r) => {
52
+ r.queryHandler("vehicle:detail", z.object({}), async () => ({ name: "x" }), {
53
+ access: { openToAll: { reason: "test handler callable by any signed-in test user" } },
54
+ });
55
+ r.screen({
56
+ id: "vehicle-detail",
57
+ type: "projectionDetail",
58
+ query: "app:query:vehicle:detail",
59
+ layout: {
60
+ mode: "tabs",
61
+ sections: [
62
+ { id: "overview", title: "Overview", fields: ["name"] },
63
+ { id: "channels", title: "Channels", fields: ["name"] },
64
+ ],
65
+ },
66
+ });
67
+ r.queryHandler("fleet:summary", z.object({}), async () => ({ name: "x" }), {
68
+ access: { openToAll: { reason: "test handler callable by any signed-in test user" } },
69
+ });
70
+ r.screen({
71
+ id: "fleet-summary",
72
+ type: "projectionDetail",
73
+ query: "app:query:fleet:summary",
74
+ layout: { sections: [{ title: "Summary", fields: ["name"] }] },
75
+ metrics: [
76
+ {
77
+ field: "name",
78
+ label: "app.metrics.name",
79
+ navigate: { screen: "vehicle-detail", tab: "unknown" },
80
+ },
81
+ ],
82
+ });
83
+ });
84
+ expect(() => validateBoot([feature])).toThrow(
85
+ /metric "name" navigates to tab "unknown", which is not a section id on target screen/,
86
+ );
87
+ });
88
+
89
+ test("metric navigate.entity + entityId + unknown tab throws", () => {
90
+ const feature = defineFeature("app", (r) => {
91
+ r.entity(
92
+ "vehicle",
93
+ createEntity({
94
+ fields: { name: createTextField({ personal: false, reason: "test_fixture" }) },
95
+ }),
96
+ );
97
+ r.queryHandler("vehicle:detail", z.object({}), async () => ({ name: "x" }), {
98
+ access: { openToAll: { reason: "test handler callable by any signed-in test user" } },
99
+ });
100
+ r.screen({
101
+ id: "vehicle-detail",
102
+ type: "projectionDetail",
103
+ detailFor: "vehicle",
104
+ query: "app:query:vehicle:detail",
105
+ layout: {
106
+ mode: "tabs",
107
+ sections: [
108
+ { id: "overview", title: "Overview", fields: ["name"] },
109
+ { id: "channels", title: "Channels", fields: ["name"] },
110
+ ],
111
+ },
112
+ });
113
+ r.queryHandler("fleet:summary", z.object({}), async () => ({ name: "x" }), {
114
+ access: { openToAll: { reason: "test handler callable by any signed-in test user" } },
115
+ });
116
+ r.screen({
117
+ id: "fleet-summary",
118
+ type: "projectionDetail",
119
+ query: "app:query:fleet:summary",
120
+ layout: { sections: [{ title: "Summary", fields: ["name"] }] },
121
+ metrics: [
122
+ {
123
+ field: "name",
124
+ label: "app.metrics.name",
125
+ navigate: { entity: "vehicle", entityId: "vehicleId", tab: "unknown" },
126
+ },
127
+ ],
128
+ });
129
+ });
130
+ expect(() => validateBoot([feature])).toThrow(
131
+ /metric "name" navigates to tab "unknown", which is not a section id on target screen/,
132
+ );
133
+ });
134
+
135
+ test("metric navigate.screen targeting a non-tabs projectionDetail throws", () => {
136
+ const feature = defineFeature("app", (r) => {
137
+ r.queryHandler("vehicle:detail", z.object({}), async () => ({ name: "x" }), {
138
+ access: { openToAll: { reason: "test handler callable by any signed-in test user" } },
139
+ });
140
+ r.screen({
141
+ id: "vehicle-detail",
142
+ type: "projectionDetail",
143
+ query: "app:query:vehicle:detail",
144
+ layout: { sections: [{ title: "Overview", fields: ["name"] }] },
145
+ });
146
+ r.queryHandler("fleet:summary", z.object({}), async () => ({ name: "x" }), {
147
+ access: { openToAll: { reason: "test handler callable by any signed-in test user" } },
148
+ });
149
+ r.screen({
150
+ id: "fleet-summary",
151
+ type: "projectionDetail",
152
+ query: "app:query:fleet:summary",
153
+ layout: { sections: [{ title: "Summary", fields: ["name"] }] },
154
+ metrics: [
155
+ {
156
+ field: "name",
157
+ label: "app.metrics.name",
158
+ navigate: { screen: "vehicle-detail", tab: "overview" },
159
+ },
160
+ ],
161
+ });
162
+ });
163
+ expect(() => validateBoot([feature])).toThrow(
164
+ /is not a projectionDetail with layout.mode "tabs"/,
165
+ );
166
+ });
167
+ });
@@ -0,0 +1,500 @@
1
+ // Tests for projectAppSchemaForRoles (fw#3314). Pins:
2
+ // 1. LEAK INVARIANT — a role-gated screen disappears from EVERY reference
3
+ // site the projection file header claims to cover for a denied role,
4
+ // and survives — with the projection returning the exact input
5
+ // reference — for a granted one: the screen itself, nav.screen, a
6
+ // standalone nav's actions/createAction (TreeAction.screen), a content
7
+ // collection's nav and its separate nav-copy actions/createAction, a
8
+ // workspace's navMember + definition.nav, rowAction navigate by both
9
+ // `screen` and `entity` (detailFor), a drawer rowAction/toolbarAction
10
+ // (same-feature analog — see the comment on hostFeature below), a
11
+ // toolbar navigate, a projectionDetail's header actions/metrics.navigate/
12
+ // listScreenId, an entityEdit redirect, an actionForm redirect/
13
+ // cancelTarget/listScreenId, a dashboard screen-panel, and every
14
+ // relatedList-section field (actions/rowActions/toolbarActions/
15
+ // rowClick.entity/emptyState.action). The denied-role assertion checks
16
+ // this both structurally, per site, and by proving the secret screen
17
+ // id is absent from the full serialized {screens, navs,
18
+ // contentCollections} of every feature plus `workspaces` — not just
19
+ // from the sites this file happened to think to check.
20
+ // 2. No mutation of the input schema.
21
+ // 3. Settings-Hub-generated configEdit screens/navs are gated by their
22
+ // derived write-role access, same as author-registered ones.
23
+ // 4. Empty-section drop: a parent nav whose every child got dropped is
24
+ // itself dropped.
25
+ // 5. Default-workspace drop: a workspace with no surviving nav members
26
+ // is removed from the switcher list.
27
+ import { describe, expect, test } from "bun:test";
28
+ import { buildAppSchema } from "../build-app-schema";
29
+ import { access, createSystemConfig, createTenantConfig } from "../config-helpers";
30
+ import { defineFeature } from "../define-feature";
31
+ import { projectAppSchemaForRoles } from "../project-app-schema-for-roles";
32
+ import { createRegistry } from "../registry";
33
+
34
+ const SECRET_SCREEN_ID = "zz-secret-audit";
35
+ const ADMIN_ONLY = { roles: ["Admin"] } as const;
36
+
37
+ function findFeature(app: ReturnType<typeof buildAppSchema>, featureName: string) {
38
+ const feature = app.features.find((f) => f.featureName === featureName);
39
+ if (feature === undefined) throw new Error(`feature "${featureName}" missing from schema`);
40
+ return feature;
41
+ }
42
+
43
+ describe("projectAppSchemaForRoles — leak invariant", () => {
44
+ const SECRET_QN = `audit:screen:${SECRET_SCREEN_ID}`;
45
+
46
+ const auditFeature = defineFeature("audit", (r) => {
47
+ r.entity("record", { fields: { label: { type: "text" } } });
48
+ r.screen({
49
+ id: SECRET_SCREEN_ID,
50
+ type: "entityList",
51
+ entity: "record",
52
+ columns: ["label"],
53
+ detailFor: "record",
54
+ access: ADMIN_ONLY,
55
+ });
56
+ });
57
+
58
+ // "host-secret-form" is the drawer-action target below: RowActionDrawer/
59
+ // ToolbarAction "drawer" resolve same-feature-only (validateDrawerTargetAction),
60
+ // so SECRET_SCREEN_ID itself (a different feature) can never be a drawer
61
+ // target — this is the one site the header lists that a real app could
62
+ // never point cross-feature. Gating this screen the same way (ADMIN_ONLY)
63
+ // proves the drawer-stripping code path with the closest valid analog.
64
+ const hostFeature = defineFeature("host", (r) => {
65
+ r.entity("hostRecord", { fields: { label: { type: "text" } } });
66
+
67
+ r.nav({ id: "secret-nav", label: "Secret", screen: SECRET_QN });
68
+ // Standalone grouping nav with no `.screen` of its own — isolates the
69
+ // TreeAction-array stripping (actions/createAction) from the
70
+ // whole-nav-drop path "secret-nav" above already exercises.
71
+ r.nav({
72
+ id: "browse",
73
+ label: "Browse",
74
+ actions: [{ icon: "eye", label: "Open", screen: SECRET_QN }],
75
+ createAction: { icon: "plus", label: "New", screen: SECRET_QN },
76
+ });
77
+
78
+ r.contentCollection({
79
+ id: "docs",
80
+ kind: "text-block",
81
+ nav: {
82
+ label: "Docs",
83
+ actions: [{ icon: "eye", label: "Open", screen: SECRET_QN }],
84
+ createAction: { icon: "plus", label: "New", screen: SECRET_QN },
85
+ },
86
+ });
87
+
88
+ r.workspace({ id: "audit-ws", label: "Audit WS", nav: ["host:nav:secret-nav"] });
89
+
90
+ r.screen({
91
+ id: "host-secret-form",
92
+ type: "actionForm",
93
+ handler: "host:write:noop",
94
+ fields: { note: { type: "text" } },
95
+ layout: { sections: [{ fields: ["note"] }] },
96
+ access: ADMIN_ONLY,
97
+ });
98
+
99
+ r.screen({
100
+ id: "list",
101
+ type: "entityList",
102
+ entity: "hostRecord",
103
+ columns: ["label"],
104
+ rowActions: [
105
+ { kind: "navigate", id: "open-secret", label: "Open", screen: SECRET_SCREEN_ID },
106
+ { kind: "navigate", id: "open-secret-by-entity", label: "Open2", entity: "record" },
107
+ { kind: "drawer", id: "open-secret-drawer", label: "Drawer", screen: "host-secret-form" },
108
+ ],
109
+ toolbarActions: [
110
+ { kind: "navigate", id: "open-secret-tb", label: "Open", screen: SECRET_SCREEN_ID },
111
+ {
112
+ kind: "drawer",
113
+ id: "open-secret-drawer-tb",
114
+ label: "DrawerTB",
115
+ screen: "host-secret-form",
116
+ },
117
+ ],
118
+ });
119
+ r.screen({
120
+ id: "edit",
121
+ type: "entityEdit",
122
+ entity: "hostRecord",
123
+ layout: { sections: [{ fields: ["label"] }] },
124
+ redirect: SECRET_QN,
125
+ });
126
+ r.screen({
127
+ id: "board",
128
+ type: "dashboard",
129
+ panels: [{ kind: "screen", id: "secret-panel", screen: SECRET_QN }],
130
+ });
131
+ r.screen({
132
+ id: "host-action",
133
+ type: "actionForm",
134
+ handler: "host:write:noop",
135
+ fields: { note: { type: "text" } },
136
+ layout: { sections: [{ fields: ["note"] }] },
137
+ redirect: SECRET_QN,
138
+ cancelTarget: SECRET_QN,
139
+ listScreenId: SECRET_SCREEN_ID,
140
+ });
141
+ r.screen({
142
+ id: "detail",
143
+ type: "projectionDetail",
144
+ query: "host:query:record:detail",
145
+ layout: {
146
+ sections: [
147
+ { fields: ["label"] },
148
+ // relatedList is only valid on projectionDetail (fw#2166) — the
149
+ // boot-validator rejects it on entityEdit/actionForm/configEdit,
150
+ // so this is the closest valid host for its own site set.
151
+ {
152
+ kind: "relatedList",
153
+ title: "Related",
154
+ query: "host:query:record:related",
155
+ columns: ["label"],
156
+ rowClick: { entity: "record" },
157
+ actions: [
158
+ { kind: "navigate", id: "rl-actions", label: "RL", screen: SECRET_SCREEN_ID },
159
+ ],
160
+ rowActions: [
161
+ { kind: "navigate", id: "rl-row", label: "RLRow", screen: SECRET_SCREEN_ID },
162
+ ],
163
+ toolbarActions: [
164
+ { kind: "navigate", id: "rl-tb", label: "RLTb", screen: SECRET_SCREEN_ID },
165
+ ],
166
+ emptyState: {
167
+ title: "none",
168
+ action: {
169
+ kind: "navigate",
170
+ id: "rl-empty",
171
+ label: "Empty",
172
+ screen: SECRET_SCREEN_ID,
173
+ },
174
+ },
175
+ },
176
+ ],
177
+ },
178
+ actions: [
179
+ { kind: "navigate", id: "detail-header-action", label: "Header", screen: SECRET_SCREEN_ID },
180
+ ],
181
+ metrics: [{ field: "label", navigate: { screen: SECRET_SCREEN_ID } }],
182
+ listScreenId: SECRET_SCREEN_ID,
183
+ });
184
+ });
185
+
186
+ const registry = createRegistry([auditFeature, hostFeature]);
187
+ const app = buildAppSchema(registry);
188
+
189
+ // Sanity: the absence check below isn't vacuous — the unprojected schema
190
+ // really does repeat the id this many times across the wired sites.
191
+ test("sanity: the unprojected schema contains the secret screen id many times", () => {
192
+ const occurrences = JSON.stringify(app).split(SECRET_SCREEN_ID).length - 1;
193
+ expect(occurrences).toBeGreaterThanOrEqual(15);
194
+ });
195
+
196
+ test("granted role returns the identical schema — every site survives untouched", () => {
197
+ const projected = projectAppSchemaForRoles(app, ["Admin"]);
198
+ expect(projected).toBe(app);
199
+ });
200
+
201
+ test("granted role: per-site structural spot checks", () => {
202
+ const projected = projectAppSchemaForRoles(app, ["Admin"]);
203
+ const audit = findFeature(projected, "audit");
204
+ expect(audit.screens.map((s) => s.id)).toContain(SECRET_SCREEN_ID);
205
+
206
+ const host = findFeature(projected, "host");
207
+ expect(host.navs?.find((n) => n.id === "secret-nav")).toBeDefined();
208
+ const browse = host.navs?.find((n) => n.id === "browse");
209
+ expect(browse?.actions).toHaveLength(1);
210
+ expect(browse?.createAction).toBeDefined();
211
+
212
+ const docs = host.contentCollections?.find((c) => c.id === "docs");
213
+ expect(docs?.nav.actions).toHaveLength(1);
214
+ expect(docs?.nav.createAction).toBeDefined();
215
+
216
+ expect(projected.workspaces?.some((w) => w.definition.id === "audit-ws")).toBe(true);
217
+
218
+ const list = host.screens.find((s) => s.id === "list");
219
+ expect(list?.type).toBe("entityList");
220
+ if (list?.type !== "entityList") throw new Error("unreachable");
221
+ expect(list.rowActions).toHaveLength(3);
222
+ expect(list.toolbarActions).toHaveLength(2);
223
+
224
+ const edit = host.screens.find((s) => s.id === "edit");
225
+ expect(edit?.type).toBe("entityEdit");
226
+ if (edit?.type !== "entityEdit") throw new Error("unreachable");
227
+ expect(edit.redirect).toBe(SECRET_QN);
228
+
229
+ const board = host.screens.find((s) => s.id === "board");
230
+ expect(board?.type).toBe("dashboard");
231
+ if (board?.type !== "dashboard") throw new Error("unreachable");
232
+ expect(board.panels).toHaveLength(1);
233
+
234
+ const hostAction = host.screens.find((s) => s.id === "host-action");
235
+ expect(hostAction?.type).toBe("actionForm");
236
+ if (hostAction?.type !== "actionForm") throw new Error("unreachable");
237
+ expect(hostAction.redirect).toBe(SECRET_QN);
238
+ expect(hostAction.cancelTarget).toBe(SECRET_QN);
239
+ expect(hostAction.listScreenId).toBe(SECRET_SCREEN_ID);
240
+
241
+ const detail = host.screens.find((s) => s.id === "detail");
242
+ expect(detail?.type).toBe("projectionDetail");
243
+ if (detail?.type !== "projectionDetail") throw new Error("unreachable");
244
+ expect(detail.metrics?.[0]).toEqual({ field: "label", navigate: { screen: SECRET_SCREEN_ID } });
245
+ expect(detail.actions).toHaveLength(1);
246
+ expect(detail.listScreenId).toBe(SECRET_SCREEN_ID);
247
+ const relatedList = detail.layout.sections.find((s) => s.kind === "relatedList");
248
+ expect(relatedList?.kind).toBe("relatedList");
249
+ if (relatedList?.kind !== "relatedList") throw new Error("unreachable");
250
+ expect(relatedList.actions).toHaveLength(1);
251
+ expect(relatedList.rowActions).toHaveLength(1);
252
+ expect(relatedList.toolbarActions).toHaveLength(1);
253
+ expect(relatedList.rowClick).toEqual({ entity: "record" });
254
+ expect(relatedList.emptyState?.action).toBeDefined();
255
+ });
256
+
257
+ test("denied role: the secret screen id disappears from every projected feature and from workspaces", () => {
258
+ const projected = projectAppSchemaForRoles(app, ["User"]);
259
+ for (const feature of projected.features) {
260
+ const featureJson = JSON.stringify({
261
+ screens: feature.screens,
262
+ navs: feature.navs,
263
+ contentCollections: feature.contentCollections,
264
+ });
265
+ expect(featureJson).not.toContain(SECRET_SCREEN_ID);
266
+ }
267
+ expect(JSON.stringify(projected.workspaces ?? null)).not.toContain(SECRET_SCREEN_ID);
268
+ });
269
+
270
+ test("denied role: per-site structural spot checks", () => {
271
+ const projected = projectAppSchemaForRoles(app, ["User"]);
272
+ const audit = findFeature(projected, "audit");
273
+ expect(audit.screens.map((s) => s.id)).not.toContain(SECRET_SCREEN_ID);
274
+
275
+ const host = findFeature(projected, "host");
276
+ expect(host.navs?.find((n) => n.id === "secret-nav")).toBeUndefined();
277
+ const browse = host.navs?.find((n) => n.id === "browse");
278
+ expect(browse?.actions).toBeUndefined();
279
+ expect(browse?.createAction).toBeUndefined();
280
+
281
+ const docs = host.contentCollections?.find((c) => c.id === "docs");
282
+ expect(docs?.nav.actions).toBeUndefined();
283
+ expect(docs?.nav.createAction).toBeUndefined();
284
+
285
+ // "audit-ws" was the only workspace, so losing its only member drops the
286
+ // workspace itself — omitted entirely (undefined), not an empty array.
287
+ expect(projected.workspaces?.some((w) => w.definition.id === "audit-ws") ?? false).toBe(false);
288
+
289
+ const list = host.screens.find((s) => s.id === "list");
290
+ expect(list?.type).toBe("entityList");
291
+ if (list?.type !== "entityList") throw new Error("unreachable");
292
+ // "open-secret-drawer"/"-tb" target "host-secret-form", itself
293
+ // ADMIN_ONLY — stripped for User too (same-feature drawer analog, see
294
+ // the comment on hostFeature above).
295
+ expect(list.rowActions).toBeUndefined();
296
+ expect(list.toolbarActions).toBeUndefined();
297
+
298
+ const edit = host.screens.find((s) => s.id === "edit");
299
+ expect(edit?.type).toBe("entityEdit");
300
+ if (edit?.type !== "entityEdit") throw new Error("unreachable");
301
+ expect(edit.redirect).toBeUndefined();
302
+
303
+ const board = host.screens.find((s) => s.id === "board");
304
+ expect(board?.type).toBe("dashboard");
305
+ if (board?.type !== "dashboard") throw new Error("unreachable");
306
+ expect(board.panels).toHaveLength(0);
307
+
308
+ const hostAction = host.screens.find((s) => s.id === "host-action");
309
+ expect(hostAction?.type).toBe("actionForm");
310
+ if (hostAction?.type !== "actionForm") throw new Error("unreachable");
311
+ expect(hostAction.redirect).toBeUndefined();
312
+ expect(hostAction.cancelTarget).toBeUndefined();
313
+ expect(hostAction.listScreenId).toBeUndefined();
314
+
315
+ const detail = host.screens.find((s) => s.id === "detail");
316
+ expect(detail?.type).toBe("projectionDetail");
317
+ if (detail?.type !== "projectionDetail") throw new Error("unreachable");
318
+ expect(detail.metrics?.[0]).toEqual({ field: "label" });
319
+ expect(detail.actions).toBeUndefined();
320
+ expect(detail.listScreenId).toBeUndefined();
321
+ const relatedList = detail.layout.sections.find((s) => s.kind === "relatedList");
322
+ expect(relatedList?.kind).toBe("relatedList");
323
+ if (relatedList?.kind !== "relatedList") throw new Error("unreachable");
324
+ expect(relatedList.actions).toBeUndefined();
325
+ expect(relatedList.rowActions).toBeUndefined();
326
+ expect(relatedList.toolbarActions).toBeUndefined();
327
+ expect(relatedList.rowClick).toBeUndefined();
328
+ expect(relatedList.emptyState?.action).toBeUndefined();
329
+ });
330
+ });
331
+
332
+ describe("projectAppSchemaForRoles — no mutation", () => {
333
+ test("never mutates the input schema", () => {
334
+ const feature = defineFeature("widgets", (r) => {
335
+ r.entity("widget", { fields: { name: { type: "text" } } });
336
+ r.screen({
337
+ id: "secret",
338
+ type: "entityList",
339
+ entity: "widget",
340
+ columns: ["name"],
341
+ access: ADMIN_ONLY,
342
+ });
343
+ r.screen({ id: "list", type: "entityList", entity: "widget", columns: ["name"] });
344
+ r.nav({ id: "secret", label: "Secret", screen: "secret" });
345
+ r.nav({ id: "list", label: "List", screen: "list" });
346
+ });
347
+ const app = buildAppSchema(createRegistry([feature]));
348
+ const before = JSON.stringify(app);
349
+
350
+ projectAppSchemaForRoles(app, ["User"]);
351
+ projectAppSchemaForRoles(app, ["Admin"]);
352
+
353
+ expect(JSON.stringify(app)).toBe(before);
354
+ });
355
+
356
+ test("returns the identical reference when a fully-open role changes nothing", () => {
357
+ const feature = defineFeature("open-widgets", (r) => {
358
+ r.entity("widget", { fields: { name: { type: "text" } } });
359
+ r.screen({ id: "list", type: "entityList", entity: "widget", columns: ["name"] });
360
+ r.nav({ id: "list", label: "List", screen: "list" });
361
+ });
362
+ const app = buildAppSchema(createRegistry([feature]));
363
+ const projected = projectAppSchemaForRoles(app, ["Whatever"]);
364
+ expect(projected).toBe(app);
365
+ });
366
+ });
367
+
368
+ describe("projectAppSchemaForRoles — Settings-Hub visibility", () => {
369
+ const billing = defineFeature("billing", (r) => {
370
+ r.config({
371
+ keys: {
372
+ stripeKey: createTenantConfig("text", { mask: { title: "billing.stripe-key" } }),
373
+ platformFee: createSystemConfig("number", {
374
+ write: access.systemAdmin,
375
+ mask: { title: "billing.platform-fee" },
376
+ }),
377
+ },
378
+ });
379
+ });
380
+ const app = buildAppSchema(createRegistry([billing]));
381
+
382
+ test("Admin sees the tenant-scoped hub screen but not the SystemAdmin-only one", () => {
383
+ const projected = projectAppSchemaForRoles(app, ["Admin"]);
384
+ const config = projected.features.find((f) => f.featureName === "config");
385
+ expect(config?.screens.map((s) => s.id)).toContain("billing-tenant");
386
+ expect(config?.screens.map((s) => s.id)).not.toContain("billing-system");
387
+ expect(config?.navs?.map((n) => n.id)).toContain("billing-tenant");
388
+ expect(config?.navs?.map((n) => n.id)).not.toContain("billing-system");
389
+ });
390
+
391
+ test("plain User sees neither hub screen", () => {
392
+ const projected = projectAppSchemaForRoles(app, ["User"]);
393
+ const config = projected.features.find((f) => f.featureName === "config");
394
+ expect(config?.screens.map((s) => s.id)).not.toContain("billing-tenant");
395
+ expect(config?.screens.map((s) => s.id)).not.toContain("billing-system");
396
+ });
397
+ });
398
+
399
+ describe("projectAppSchemaForRoles — nav pruning", () => {
400
+ test("a parent nav whose only child gets dropped is itself dropped (empty section)", () => {
401
+ const feature = defineFeature("menu", (r) => {
402
+ r.entity("widget", { fields: { name: { type: "text" } } });
403
+ r.nav({ id: "section", label: "Section" }); // pure grouping node, no screen
404
+ r.nav({
405
+ id: "child",
406
+ label: "Child",
407
+ parent: "menu:nav:section",
408
+ screen: "child-screen",
409
+ access: ADMIN_ONLY,
410
+ });
411
+ r.screen({
412
+ id: "child-screen",
413
+ type: "entityList",
414
+ entity: "widget",
415
+ columns: ["name"],
416
+ access: ADMIN_ONLY,
417
+ });
418
+ });
419
+ const app = buildAppSchema(createRegistry([feature]));
420
+
421
+ const forAdmin = projectAppSchemaForRoles(app, ["Admin"]);
422
+ const adminMenu = findFeature(forAdmin, "menu");
423
+ expect(adminMenu.navs?.map((n) => n.id).sort()).toEqual(["child", "section"]);
424
+
425
+ const forUser = projectAppSchemaForRoles(app, ["User"]);
426
+ const userMenu = findFeature(forUser, "menu");
427
+ expect(userMenu.navs).toBeUndefined();
428
+ });
429
+
430
+ test("a descendant three levels deep is dropped when its own access denies", () => {
431
+ const feature = defineFeature("tree", (r) => {
432
+ r.entity("widget", { fields: { name: { type: "text" } } });
433
+ r.nav({ id: "root", label: "Root" });
434
+ r.nav({ id: "mid", label: "Mid", parent: "tree:nav:root" });
435
+ r.nav({
436
+ id: "leaf",
437
+ label: "Leaf",
438
+ parent: "tree:nav:mid",
439
+ screen: "leaf-screen",
440
+ access: ADMIN_ONLY,
441
+ });
442
+ r.screen({
443
+ id: "leaf-screen",
444
+ type: "entityList",
445
+ entity: "widget",
446
+ columns: ["name"],
447
+ access: ADMIN_ONLY,
448
+ });
449
+ });
450
+ const app = buildAppSchema(createRegistry([feature]));
451
+
452
+ const forUser = projectAppSchemaForRoles(app, ["User"]);
453
+ const userTree = findFeature(forUser, "tree");
454
+ expect(userTree.navs).toBeUndefined(); // root+mid become empty sections too
455
+ });
456
+ });
457
+
458
+ describe("projectAppSchemaForRoles — workspace pruning", () => {
459
+ test("drops the default workspace when its role loses every member, and keeps the other one", () => {
460
+ const feature = defineFeature("shell", (r) => {
461
+ r.entity("widget", { fields: { name: { type: "text" } } });
462
+ r.screen({
463
+ id: "admin-home",
464
+ type: "entityList",
465
+ entity: "widget",
466
+ columns: ["name"],
467
+ access: ADMIN_ONLY,
468
+ });
469
+ r.screen({ id: "user-home", type: "entityList", entity: "widget", columns: ["name"] });
470
+ r.nav({ id: "admin-home", label: "Admin Home", screen: "admin-home" });
471
+ r.nav({ id: "user-home", label: "User Home", screen: "user-home" });
472
+ r.workspace({ id: "admin", label: "Admin", default: true, nav: ["shell:nav:admin-home"] });
473
+ r.workspace({ id: "user", label: "User", nav: ["shell:nav:user-home"] });
474
+ });
475
+ const app = buildAppSchema(createRegistry([feature]));
476
+
477
+ const forUser = projectAppSchemaForRoles(app, ["User"]);
478
+ const ids = forUser.workspaces?.map((w) => w.definition.id);
479
+ expect(ids).toEqual(["user"]);
480
+
481
+ const forAdmin = projectAppSchemaForRoles(app, ["Admin"]);
482
+ expect(forAdmin.workspaces?.map((w) => w.definition.id).sort()).toEqual(["admin", "user"]);
483
+ });
484
+
485
+ test("keeps a workspace that was declared without members instead of treating it as emptied", () => {
486
+ const feature = defineFeature("shell", (r) => {
487
+ r.entity("widget", { fields: { name: { type: "text" } } });
488
+ r.screen({ id: "user-home", type: "entityList", entity: "widget", columns: ["name"] });
489
+ r.nav({ id: "user-home", label: "User Home", screen: "user-home" });
490
+ r.workspace({ id: "work", label: "Work", nav: ["shell:nav:user-home"] });
491
+ r.workspace({ id: "empty", label: "Empty" });
492
+ });
493
+ const app = buildAppSchema(createRegistry([feature]));
494
+ expect(app.workspaces?.find((w) => w.definition.id === "empty")?.navMembers).toEqual([]);
495
+
496
+ const forUser = projectAppSchemaForRoles(app, ["User"]);
497
+
498
+ expect(forUser.workspaces?.map((w) => w.definition.id).sort()).toEqual(["empty", "work"]);
499
+ });
500
+ });
@@ -39,7 +39,7 @@ describe("buildWebhookSendStep", () => {
39
39
  method: "PUT",
40
40
  headers: { "X-Custom": "val" },
41
41
  body: { event: "test" },
42
- auth: { kind: "bearer", secretRef: "MY_SECRET" },
42
+ auth: { kind: "bearer", secret: "MY_SECRET" },
43
43
  retry: { times: 5, backoff: "linear" },
44
44
  mode: "deferred",
45
45
  });
@@ -125,7 +125,7 @@ describe("webhook.send run", () => {
125
125
 
126
126
  it("passes auth config through when provided", async () => {
127
127
  const stepDef = getStep("webhook.send");
128
- const auth = { kind: "bearer" as const, secretRef: "WEBHOOK_TOKEN" };
128
+ const auth = { kind: "bearer" as const, secret: "WEBHOOK_TOKEN" };
129
129
 
130
130
  await stepDef!.run({ url: "https://hooks.example/secured", mode: "deferred", auth }, mockCtx);
131
131