@cosmicdrift/kumiko-framework 0.152.0 → 0.154.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-framework",
3
- "version": "0.152.0",
3
+ "version": "0.154.0",
4
4
  "description": "Framework core — engine, pipeline, API, DB, and every other bit that makes Kumiko go.",
5
5
  "license": "BUSL-1.1",
6
6
  "author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
@@ -193,7 +193,7 @@
193
193
  "zod": "^4.4.3"
194
194
  },
195
195
  "devDependencies": {
196
- "@cosmicdrift/kumiko-dispatcher-live": "0.152.0",
196
+ "@cosmicdrift/kumiko-dispatcher-live": "0.154.0",
197
197
  "bun-types": "^1.3.13",
198
198
  "pino-pretty": "^13.1.3"
199
199
  },
@@ -361,6 +361,31 @@ describe("anonymous access — resolverTrust: authoritative", () => {
361
361
  const body = (await res.json()) as { error: { code: string } };
362
362
  expect(body.error.code).toBe("tenant_mismatch");
363
363
  });
364
+
365
+ // pr-review kumiko-framework #1052/3 — resolveTenant treats header and
366
+ // cookie identically, but every case above only exercised the header.
367
+ // These two lock in that the cookie path behaves the same way.
368
+ test("kumiko_tenant cookie agreeing with the resolver → accepted", async () => {
369
+ const res = await stack.http.raw(
370
+ "POST",
371
+ "/api/query",
372
+ { type: "anonshop:query:product:list", payload: {} },
373
+ { Cookie: `kumiko_tenant=${TENANT_ID}` },
374
+ );
375
+ expect(res.status).toBe(200);
376
+ });
377
+
378
+ test("kumiko_tenant cookie disagreeing with the resolver → 400 tenant_mismatch, same as a disagreeing header", async () => {
379
+ const res = await stack.http.raw(
380
+ "POST",
381
+ "/api/query",
382
+ { type: "anonshop:query:product:list", payload: {} },
383
+ { Cookie: `kumiko_tenant=${OTHER_TENANT_ID}` },
384
+ );
385
+ expect(res.status).toBe(400);
386
+ const body = (await res.json()) as { error: { code: string } };
387
+ expect(body.error.code).toBe("tenant_mismatch");
388
+ });
364
389
  });
365
390
 
366
391
  describe("anonymous access — resolverTrust: authoritative, resolver returns null", () => {
@@ -0,0 +1,242 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { z } from "zod";
3
+ import { validateBoot } from "../boot-validator";
4
+ import { defineFeature } from "../define-feature";
5
+ import { createEntity, createTextField } from "../factories";
6
+
7
+ describe("validateBoot — action wiring (no function values)", () => {
8
+ test("rowAction writeHandler payload as function → Throw", () => {
9
+ const feature = defineFeature("shop", (r) => {
10
+ r.entity("product", createEntity({ fields: { name: createTextField() } }));
11
+ r.screen({
12
+ id: "product-list",
13
+ type: "entityList",
14
+ entity: "product",
15
+ columns: ["name"],
16
+ rowActions: [
17
+ {
18
+ kind: "writeHandler",
19
+ id: "sync",
20
+ label: "actions.sync",
21
+ handler: "shop:write:sync",
22
+ // biome-ignore lint/suspicious/noExplicitAny: intentional type violation under test
23
+ payload: ((row: unknown) => ({ id: row })) as any,
24
+ },
25
+ ],
26
+ });
27
+ r.writeHandler("sync", z.object({}), async () => ({ isSuccess: true as const, data: null }), {
28
+ access: { roles: ["Admin"] },
29
+ });
30
+ });
31
+ expect(() => validateBoot([feature])).toThrow(/rowAction "sync" payload is a function/);
32
+ });
33
+
34
+ test("rowAction writeHandler payload as declarative pick → kein Throw", () => {
35
+ const feature = defineFeature("shop", (r) => {
36
+ r.entity("product", createEntity({ fields: { name: createTextField({ sortable: true }) } }));
37
+ r.screen({
38
+ id: "product-list",
39
+ type: "entityList",
40
+ entity: "product",
41
+ columns: ["name"],
42
+ defaultSort: { field: "name", dir: "asc" },
43
+ rowActions: [
44
+ {
45
+ kind: "writeHandler",
46
+ id: "sync",
47
+ label: "actions.sync",
48
+ handler: "shop:write:sync",
49
+ payload: { pick: ["name"] },
50
+ },
51
+ ],
52
+ });
53
+ r.writeHandler("sync", z.object({}), async () => ({ isSuccess: true as const, data: null }), {
54
+ access: { roles: ["Admin"] },
55
+ });
56
+ r.translations({
57
+ keys: {
58
+ "screen:product-list.title": { de: "Liste", en: "List" },
59
+ "shop:entity:product:field:name": { de: "Name", en: "Name" },
60
+ },
61
+ });
62
+ });
63
+ expect(() => validateBoot([feature])).not.toThrow();
64
+ });
65
+
66
+ test("rowAction navigate visible as function → Throw", () => {
67
+ const feature = defineFeature("shop", (r) => {
68
+ r.entity("product", createEntity({ fields: { name: createTextField() } }));
69
+ r.screen({
70
+ id: "product-list",
71
+ type: "entityList",
72
+ entity: "product",
73
+ columns: ["name"],
74
+ rowActions: [
75
+ {
76
+ kind: "navigate",
77
+ id: "edit",
78
+ label: "actions.edit",
79
+ screen: "product-list",
80
+ // biome-ignore lint/suspicious/noExplicitAny: intentional type violation under test
81
+ visible: (() => true) as any,
82
+ },
83
+ ],
84
+ });
85
+ });
86
+ expect(() => validateBoot([feature])).toThrow(/rowAction "edit" visible is a function/);
87
+ });
88
+
89
+ test("toolbarAction writeHandler payload as function → Throw", () => {
90
+ const feature = defineFeature("shop", (r) => {
91
+ r.entity("product", createEntity({ fields: { name: createTextField() } }));
92
+ r.screen({
93
+ id: "product-list",
94
+ type: "entityList",
95
+ entity: "product",
96
+ columns: ["name"],
97
+ toolbarActions: [
98
+ {
99
+ kind: "writeHandler",
100
+ id: "sync",
101
+ label: "actions.sync",
102
+ handler: "shop:write:sync",
103
+ // biome-ignore lint/suspicious/noExplicitAny: intentional type violation under test
104
+ payload: (() => ({})) as any,
105
+ },
106
+ ],
107
+ });
108
+ r.writeHandler("sync", z.object({}), async () => ({ isSuccess: true as const, data: null }), {
109
+ access: { roles: ["Admin"] },
110
+ });
111
+ });
112
+ expect(() => validateBoot([feature])).toThrow(/toolbarAction "sync" payload is a function/);
113
+ });
114
+
115
+ test("entityList column renderer as function → Throw", () => {
116
+ const feature = defineFeature("shop", (r) => {
117
+ r.entity("product", createEntity({ fields: { name: createTextField() } }));
118
+ r.screen({
119
+ id: "product-list",
120
+ type: "entityList",
121
+ entity: "product",
122
+ columns: [
123
+ // biome-ignore lint/suspicious/noExplicitAny: intentional type violation under test
124
+ { field: "name", renderer: ((v: unknown) => String(v)) as any },
125
+ ],
126
+ });
127
+ });
128
+ expect(() => validateBoot([feature])).toThrow(/column "name" renderer is a function/);
129
+ });
130
+
131
+ test("entityEdit field visible as function → Throw", () => {
132
+ const feature = defineFeature("shop", (r) => {
133
+ r.entity("product", createEntity({ fields: { name: createTextField() } }));
134
+ r.screen({
135
+ id: "product-edit",
136
+ type: "entityEdit",
137
+ entity: "product",
138
+ layout: {
139
+ sections: [
140
+ {
141
+ columns: 1,
142
+ // biome-ignore lint/suspicious/noExplicitAny: intentional type violation under test
143
+ fields: [{ field: "name", visible: (() => true) as any }],
144
+ },
145
+ ],
146
+ },
147
+ });
148
+ });
149
+ expect(() => validateBoot([feature])).toThrow(/field "name" visible is a function/);
150
+ });
151
+
152
+ test("entityEdit field renderer as function → Throw", () => {
153
+ const feature = defineFeature("shop", (r) => {
154
+ r.entity("product", createEntity({ fields: { name: createTextField() } }));
155
+ r.screen({
156
+ id: "product-edit",
157
+ type: "entityEdit",
158
+ entity: "product",
159
+ layout: {
160
+ sections: [
161
+ {
162
+ columns: 1,
163
+ // biome-ignore lint/suspicious/noExplicitAny: intentional type violation under test
164
+ fields: [{ field: "name", renderer: ((v: unknown) => String(v)) as any }],
165
+ },
166
+ ],
167
+ },
168
+ });
169
+ });
170
+ expect(() => validateBoot([feature])).toThrow(/field "name" renderer is a function/);
171
+ });
172
+
173
+ test("entityEdit field with declarative visible/readOnly/required → kein Throw", () => {
174
+ const feature = defineFeature("shop", (r) => {
175
+ r.entity("product", createEntity({ fields: { name: createTextField() } }));
176
+ r.screen({
177
+ id: "product-edit",
178
+ type: "entityEdit",
179
+ entity: "product",
180
+ layout: {
181
+ sections: [
182
+ {
183
+ columns: 1,
184
+ fields: [
185
+ {
186
+ field: "name",
187
+ visible: { field: "name", ne: "" },
188
+ readOnly: false,
189
+ required: true,
190
+ },
191
+ ],
192
+ },
193
+ ],
194
+ },
195
+ });
196
+ r.translations({
197
+ keys: {
198
+ "screen:product-edit.title": { de: "Bearbeiten", en: "Edit" },
199
+ "shop:entity:product:field:name": { de: "Name", en: "Name" },
200
+ },
201
+ });
202
+ });
203
+ expect(() => validateBoot([feature])).not.toThrow();
204
+ });
205
+
206
+ test("projectionList column renderer as function → Throw", () => {
207
+ const feature = defineFeature("shop", (r) => {
208
+ r.screen({
209
+ id: "sales-list",
210
+ type: "projectionList",
211
+ query: "shop:query:sales",
212
+ columns: [
213
+ // biome-ignore lint/suspicious/noExplicitAny: intentional type violation under test
214
+ { field: "amount", renderer: ((v: unknown) => String(v)) as any },
215
+ ],
216
+ });
217
+ });
218
+ expect(() => validateBoot([feature])).toThrow(/column "amount" renderer is a function/);
219
+ });
220
+
221
+ test("projectionList rowAction payload as function → Throw", () => {
222
+ const feature = defineFeature("shop", (r) => {
223
+ r.screen({
224
+ id: "sales-list",
225
+ type: "projectionList",
226
+ query: "shop:query:sales",
227
+ columns: ["amount"],
228
+ rowActions: [
229
+ {
230
+ kind: "writeHandler",
231
+ id: "sync",
232
+ label: "actions.sync",
233
+ handler: "shop:write:sync",
234
+ // biome-ignore lint/suspicious/noExplicitAny: intentional type violation under test
235
+ payload: (() => ({})) as any,
236
+ },
237
+ ],
238
+ });
239
+ });
240
+ expect(() => validateBoot([feature])).toThrow(/rowAction "sync" payload is a function/);
241
+ });
242
+ });
@@ -1231,8 +1231,9 @@ describe("boot-validator", () => {
1231
1231
  // --- entityList column-renderer form-check ---
1232
1232
  // Validator akzeptiert die `{ react: { __component: "Name" } }`-Form
1233
1233
  // (PlatformComponent → client-side Registry-Lookup) und prüft sie
1234
- // strukturell. String-Funktionen, null/undefined, native-only und
1235
- // andere Formen bleiben opak.
1234
+ // strukturell. null/undefined, native-only und andere Formen bleiben
1235
+ // opak. Function-Renderer werfen (action-wiring.ts validateFieldWiring) —
1236
+ // von JSON.stringify verworfen, sonst stiller No-op auf dem Client.
1236
1237
  describe("entityList column renderer form", () => {
1237
1238
  function shopFeature(renderer: unknown) {
1238
1239
  return defineFeature("shop", (r) => {
@@ -1250,8 +1251,10 @@ describe("boot-validator", () => {
1250
1251
  });
1251
1252
  }
1252
1253
 
1253
- test("function-renderer → kein Throw (Bestand)", () => {
1254
- expect(() => validateBoot([shopFeature((v: unknown) => String(v))])).not.toThrow();
1254
+ test("function-renderer → Throw (Function wird von JSON.stringify verworfen)", () => {
1255
+ expect(() => validateBoot([shopFeature((v: unknown) => String(v))])).toThrow(
1256
+ /column "name" renderer is a function/,
1257
+ );
1255
1258
  });
1256
1259
 
1257
1260
  test("undefined renderer → kein Throw (Spalte ohne Renderer)", () => {
@@ -80,6 +80,86 @@ describe("r.nav() — registration", () => {
80
80
  });
81
81
  });
82
82
 
83
+ describe("r.screen({ nav }) — inline nav sugar", () => {
84
+ test("synthesizes a nav entry from the screen's id", () => {
85
+ const feature = defineFeature("shop", (r) => {
86
+ r.entity("product", productEntity());
87
+ r.screen({
88
+ id: "products",
89
+ type: "entityList",
90
+ entity: "product",
91
+ columns: ["name"],
92
+ nav: { label: "shop:nav.products", icon: "box", order: 5 },
93
+ });
94
+ });
95
+ expect(feature.navs["products"]).toMatchObject({
96
+ id: "products",
97
+ label: "shop:nav.products",
98
+ icon: "box",
99
+ order: 5,
100
+ screen: "shop:screen:products",
101
+ });
102
+ });
103
+
104
+ test("supports parent like a standalone r.nav()", () => {
105
+ const feature = defineFeature("shop", (r) => {
106
+ r.nav({ id: "catalog", label: "x" });
107
+ r.entity("product", productEntity());
108
+ r.screen({
109
+ id: "products",
110
+ type: "entityList",
111
+ entity: "product",
112
+ columns: ["name"],
113
+ nav: { label: "y", parent: "shop:nav:catalog" },
114
+ });
115
+ });
116
+ expect(feature.navs["products"]?.parent).toBe("shop:nav:catalog");
117
+ });
118
+
119
+ test("passes the same boot-validation as a standalone r.nav()", () => {
120
+ const feature = defineFeature("shop", (r) => {
121
+ r.entity("product", productEntity());
122
+ r.screen({
123
+ id: "products",
124
+ type: "entityList",
125
+ entity: "product",
126
+ columns: ["name"],
127
+ nav: { label: "y" },
128
+ });
129
+ });
130
+ expect(() => validateBoot([feature])).not.toThrow();
131
+ });
132
+
133
+ test("screen without nav registers no nav entry", () => {
134
+ const feature = defineFeature("shop", (r) => {
135
+ r.entity("product", productEntity());
136
+ r.screen({
137
+ id: "products",
138
+ type: "entityList",
139
+ entity: "product",
140
+ columns: ["name"],
141
+ });
142
+ });
143
+ expect(feature.navs["products"]).toBeUndefined();
144
+ });
145
+
146
+ test("rejects when a standalone r.nav() already used the screen's id", () => {
147
+ expect(() =>
148
+ defineFeature("shop", (r) => {
149
+ r.nav({ id: "products", label: "standalone" });
150
+ r.entity("product", productEntity());
151
+ r.screen({
152
+ id: "products",
153
+ type: "entityList",
154
+ entity: "product",
155
+ columns: ["name"],
156
+ nav: { label: "inline" },
157
+ });
158
+ }),
159
+ ).toThrow(/already registered/);
160
+ });
161
+ });
162
+
83
163
  describe("createRegistry — nav indexing", () => {
84
164
  test("indexes nav entries by qualified name", () => {
85
165
  const feature = defineFeature("shop", (r) => {
@@ -0,0 +1,99 @@
1
+ import type { EditFieldSpec, FeatureDefinition, RowAction, ToolbarAction } from "../types";
2
+ import { isExtensionEditSection, normalizeEditField, normalizeListColumn } from "../types/screen";
3
+
4
+ const FUNCTION_DROPPED_HINT =
5
+ "functions are dropped by JSON.stringify when the screen config reaches the client " +
6
+ "bundle — the action/field silently no-ops at runtime instead of failing loudly.";
7
+
8
+ function throwIfFunction(value: unknown, message: string): void {
9
+ if (typeof value === "function") {
10
+ throw new Error(message);
11
+ }
12
+ }
13
+
14
+ // rowActions/toolbarActions carry row-context extractors (payload/params),
15
+ // static visibility conditions and navigate entityId — all declarative-DSL-
16
+ // only fields (RowFieldExtractor's `{ pick }`/`{ map }`, FieldCondition's
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.
20
+ const ACTION_FUNCTION_FIELDS = ["payload", "params", "entityId", "visible"] as const;
21
+
22
+ function validateActionNoFunctions(
23
+ featureName: string,
24
+ screenId: string,
25
+ actionKind: "rowAction" | "toolbarAction",
26
+ action: RowAction | ToolbarAction,
27
+ ): void {
28
+ const record = action as unknown as Record<string, unknown>;
29
+ for (const field of ACTION_FUNCTION_FIELDS) {
30
+ throwIfFunction(
31
+ record[field],
32
+ `[Feature ${featureName}] Screen "${screenId}" ${actionKind} "${action.id}" ${field} ` +
33
+ `is a function — ${FUNCTION_DROPPED_HINT} Use the declarative DSL ({ pick }, { map }, ` +
34
+ `"fieldName", { field, eq }) instead.`,
35
+ );
36
+ }
37
+ }
38
+
39
+ export function validateActionWiring(feature: FeatureDefinition): void {
40
+ for (const screen of Object.values(feature.screens)) {
41
+ if (screen.type !== "entityList" && screen.type !== "projectionList") continue;
42
+ for (const action of screen.rowActions ?? []) {
43
+ validateActionNoFunctions(feature.name, screen.id, "rowAction", action);
44
+ }
45
+ for (const action of screen.toolbarActions ?? []) {
46
+ validateActionNoFunctions(feature.name, screen.id, "toolbarAction", action);
47
+ }
48
+ }
49
+ }
50
+
51
+ // EditFieldSpec's visible/readOnly/required are the same FieldCondition DSL
52
+ // as rowActions — same footgun, different screen type (entityEdit layouts).
53
+ const EDIT_FIELD_FUNCTION_FIELDS = ["visible", "readOnly", "required"] as const;
54
+
55
+ function validateEditFieldNoFunctions(
56
+ featureName: string,
57
+ screenId: string,
58
+ fieldSpec: EditFieldSpec,
59
+ ): void {
60
+ const normalized = normalizeEditField(fieldSpec);
61
+ const record = normalized as unknown as Record<string, unknown>;
62
+ for (const key of EDIT_FIELD_FUNCTION_FIELDS) {
63
+ throwIfFunction(
64
+ record[key],
65
+ `[Feature ${featureName}] Screen "${screenId}" (entityEdit) field "${normalized.field}" ` +
66
+ `${key} is a function — ${FUNCTION_DROPPED_HINT} Use a FieldCondition ` +
67
+ `(boolean or { field, eq }/{ field, ne }) instead.`,
68
+ );
69
+ }
70
+ throwIfFunction(
71
+ normalized.renderer,
72
+ `[Feature ${featureName}] Screen "${screenId}" (entityEdit) field "${normalized.field}" ` +
73
+ `renderer is a function — ${FUNCTION_DROPPED_HINT} Use a FormatSpec ({ format: "..." }) instead.`,
74
+ );
75
+ }
76
+
77
+ export function validateFieldWiring(feature: FeatureDefinition): void {
78
+ for (const screen of Object.values(feature.screens)) {
79
+ if (screen.type === "entityList" || screen.type === "projectionList") {
80
+ for (const col of screen.columns) {
81
+ const normalized = normalizeListColumn(col);
82
+ throwIfFunction(
83
+ normalized.renderer,
84
+ `[Feature ${feature.name}] Screen "${screen.id}" (${screen.type}) column ` +
85
+ `"${normalized.field}" renderer is a function — ${FUNCTION_DROPPED_HINT} ` +
86
+ `Use a FormatSpec ({ format: "..." }) instead.`,
87
+ );
88
+ }
89
+ continue;
90
+ }
91
+ if (screen.type !== "entityEdit") continue;
92
+ for (const section of screen.layout.sections) {
93
+ if (isExtensionEditSection(section)) continue;
94
+ for (const fieldSpec of section.fields) {
95
+ validateEditFieldNoFunctions(feature.name, screen.id, fieldSpec);
96
+ }
97
+ }
98
+ }
99
+ }
@@ -1,6 +1,7 @@
1
1
  import { validateEntityFieldEncryptionAvailable } from "../../db/entity-field-encryption";
2
2
  import { QnTypes, qualifyEntityName } from "../qualified-name";
3
3
  import type { ClaimKeyDefinition, FeatureDefinition } from "../types";
4
+ import { validateActionWiring, validateFieldWiring } from "./action-wiring";
4
5
  import { validateApiExposureMatching, validateExtensionUsages } from "./api-ext";
5
6
  import {
6
7
  validateCircularDeps,
@@ -171,6 +172,12 @@ export function validateBoot(features: readonly FeatureDefinition[]): void {
171
172
  validateConfigKeyBacking(feature);
172
173
  validateOwnershipRules(feature, allClaimKeys, knownRoles);
173
174
  validateMultiStreamProjections(feature);
175
+ // Vor validateScreens: dessen visible/entityId-Feldref-Checks werfen für
176
+ // einen Function-Wert bereits (mit verwirrender "unknown field undefined"-
177
+ // Message, weil `typeof fn !== "boolean"` true ist), bevor der klare
178
+ // Function-Check hier überhaupt liefe.
179
+ validateActionWiring(feature);
180
+ validateFieldWiring(feature);
174
181
  validateScreens(
175
182
  feature,
176
183
  featureMap,
@@ -358,3 +358,45 @@ describe("renderPattern — single-pattern shape", () => {
358
358
  expect(out).toBe('r.metric({ name: "requests", type: "counter" });');
359
359
  });
360
360
  });
361
+
362
+ // Regression guard for the class of bug the r.exposesApi/r.usesApi fold
363
+ // hit: a registrar-shape change (there, removing a method; here, adding a
364
+ // nested optional field) must survive the Designer's parse→render→parse
365
+ // cycle, not just a TS compile of the framework itself. r.screen()'s `nav`
366
+ // sugar is a generic nested object on an already-generic pattern (no
367
+ // per-field extractor/renderer), so this is the cheap case — but proving
368
+ // it beats assuming it.
369
+ const SCREEN_WITH_NAV_FEATURE = `
370
+ import { defineFeature } from "@cosmicdrift/kumiko-framework/engine";
371
+
372
+ defineFeature("shop", (r) => {
373
+ r.entity("product", { fields: { name: { type: "text" } } });
374
+ r.screen({
375
+ id: "products",
376
+ type: "entityList",
377
+ entity: "product",
378
+ columns: ["name"],
379
+ nav: { label: "shop:nav.products", icon: "box", order: 5 },
380
+ });
381
+ });
382
+ `;
383
+
384
+ describe("render → parse roundtrip — r.screen({ nav }) inline sugar", () => {
385
+ test("nested nav object survives parse → render → parse unchanged", () => {
386
+ const initial = parse(SCREEN_WITH_NAV_FEATURE);
387
+ const rendered = renderFeatureFile({
388
+ featureName: initial.featureName ?? "",
389
+ patterns: initial.patterns,
390
+ });
391
+ const reparsed = parse(rendered);
392
+ expect(reparsed.patterns.map(stripLocations)).toEqual(initial.patterns.map(stripLocations));
393
+
394
+ const screenPattern = reparsed.patterns.find((p) => p.kind === "screen");
395
+ expect(screenPattern?.kind).toBe("screen");
396
+ if (screenPattern?.kind === "screen") {
397
+ expect(screenPattern.definition).toMatchObject({
398
+ nav: { label: "shop:nav.products", icon: "box", order: 5 },
399
+ });
400
+ }
401
+ });
402
+ });
@@ -260,6 +260,27 @@ export function buildUiExtensionsMethods<TName extends string>(
260
260
  );
261
261
  }
262
262
  state.screens[definition.id] = definition;
263
+ if (definition.nav) {
264
+ // Sugar for the common "one nav entry pointing at this screen"
265
+ // case — synthesizes id/screen from the screen's own id. Beyond
266
+ // label/icon/parent/order, declare a standalone r.nav() instead.
267
+ if (state.navs[definition.id]) {
268
+ throw new Error(
269
+ `[Feature ${name}] Nav entry "${definition.id}" already registered. ` +
270
+ `Nav ids must be unique per feature — remove the standalone ` +
271
+ `r.nav("${definition.id}", ...) call or the screen's inline nav.`,
272
+ );
273
+ }
274
+ const navDefinition: NavDefinition = {
275
+ id: definition.id,
276
+ label: definition.nav.label,
277
+ icon: definition.nav.icon,
278
+ parent: definition.nav.parent,
279
+ order: definition.nav.order,
280
+ screen: `${name}:screen:${definition.id}`,
281
+ };
282
+ state.navs[definition.id] = navDefinition;
283
+ }
263
284
  },
264
285
  nav(definition: NavDefinition): void {
265
286
  // Reject kebab-drift at registration-time so the stack trace points at
@@ -684,7 +684,9 @@ export type FeatureRegistrar<TFeature extends string = string> = {
684
684
  // the registry qualifies to "<feature>:screen:<id>". Boot-validation checks
685
685
  // that entity-bound screens reference a registered entity and that the
686
686
  // columns / form-field refs name real fields — cross-feature component-QN
687
- // validation (r.uiComponent) comes in M4/M5.
687
+ // validation (r.uiComponent) comes in M4/M5. Optional `nav` field is
688
+ // sugar for a single nav entry pointing at this screen — equivalent to
689
+ // a standalone r.nav({ id: <same id>, screen: "<feature>:screen:<id>", ... }).
688
690
  screen(definition: ScreenDefinition): void;
689
691
 
690
692
  // Register a nav entry. The id is the feature-local short name (kebab-case);
@@ -727,7 +727,19 @@ export type ScreenSlots = {
727
727
 
728
728
  // --- discriminated union ---
729
729
 
730
- export type ScreenDefinition =
730
+ // Inline nav-entry sugar for `r.screen({ ..., nav: {...} })` — covers the
731
+ // common case of "one nav entry pointing at this screen". The nav entry's
732
+ // `id`/`screen` are synthesized from the screen's own id; for anything
733
+ // beyond label/icon/parent/order (access-gating, workspaces, actions),
734
+ // declare a standalone `r.nav()` entry instead.
735
+ export type ScreenNavSugar = {
736
+ readonly label: string;
737
+ readonly icon?: string;
738
+ readonly parent?: string;
739
+ readonly order?: number;
740
+ };
741
+
742
+ export type ScreenDefinition = (
731
743
  | EntityListScreenDefinition
732
744
  | ProjectionListScreenDefinition
733
745
  | ProjectionDetailScreenDefinition
@@ -735,7 +747,8 @@ export type ScreenDefinition =
735
747
  | EntityEditScreenDefinition
736
748
  | ActionFormScreenDefinition
737
749
  | ConfigEditScreenDefinition
738
- | CustomScreenDefinition;
750
+ | CustomScreenDefinition
751
+ ) & { readonly nav?: ScreenNavSugar };
739
752
 
740
753
  // Type guard — narrows FieldRenderer to FormatSpec. Useful for renderer
741
754
  // authors who branch on the three FieldRenderer variants without manual
@@ -3,6 +3,13 @@ import { requireEnv } from "./db";
3
3
 
4
4
  export type TestRedis = {
5
5
  redis: import("ioredis").default;
6
+ // The exact REDIS_URL used to build `redis` above — for a second, unrelated
7
+ // connection (e.g. the test-stack's JobRunner) that needs its own client
8
+ // rather than sharing this one's keyPrefix. Reconstructing a URL from
9
+ // `redis.options` loses password/username/tls/path (pr-review
10
+ // kumiko-framework #1036/2) — callers needing a fresh connection should use
11
+ // this raw string, not rebuild one from parsed options.
12
+ redisUrl: string;
6
13
  /** Delete every key this test created (prefix-scoped). Replaces the old
7
14
  * `redis.flushdb()` — that wiped other parallel tests' BullMQ state. */
8
15
  flushNamespace: () => Promise<void>;
@@ -35,6 +42,7 @@ export async function createTestRedis(): Promise<TestRedis> {
35
42
 
36
43
  return {
37
44
  redis,
45
+ redisUrl,
38
46
  flushNamespace,
39
47
  cleanup: async () => {
40
48
  await flushNamespace();
@@ -237,167 +237,187 @@ export async function setupTestStack(options: TestStackOptions): Promise<TestSta
237
237
  // test-stack's own ephemeral redis — no separate `redisUrl` seam needed.
238
238
  let jobRunner: JobRunner | undefined;
239
239
  if (options.jobs && registry.getAllJobs().size > 0) {
240
- const redisOpts = testRedis.redis.options;
241
240
  jobRunner = createJobRunner({
242
241
  registry,
243
242
  context: { db: testDb.db, registry },
244
- redisUrl: `redis://${redisOpts.host}:${redisOpts.port}/${redisOpts.db}`,
243
+ // The real REDIS_URL, not one rebuilt from `.options` — that lost
244
+ // password/username/tls/path (pr-review kumiko-framework #1036/2).
245
+ redisUrl: testRedis.redisUrl,
245
246
  ...(options.jobs.consumerLane !== undefined && { consumerLane: options.jobs.consumerLane }),
246
247
  ...(options.jobs.queueNamePrefix !== undefined && {
247
248
  queueNamePrefix: options.jobs.queueNamePrefix,
248
249
  }),
249
250
  });
250
- await jobRunner.start();
251
251
  }
252
252
 
253
- // Auto-configure search for tenant 1 based on registry
254
- if (enabledHooks.includes("search")) {
255
- const searchableFields: string[] = [];
256
- for (const feature of options.features) {
257
- for (const [, entity] of Object.entries(feature.entities ?? {})) {
258
- for (const [fieldName, field] of Object.entries(entity.fields)) {
259
- if (field.type === "text" && field.searchable) {
260
- searchableFields.push(fieldName);
261
- }
262
- if (field.type === "embedded") {
263
- for (const [subName, subField] of Object.entries(field.schema)) {
264
- if (subField.searchable) {
265
- searchableFields.push(`${fieldName}_${subName}`);
253
+ // From here on, any throw must stop a jobRunner that was already created
254
+ // above (createJobRunner() itself opens live BullMQ Queue/lock Redis
255
+ // connections, .start() adds a live worker) — otherwise a failing
256
+ // buildServer()/search-config/etc. leaks that connection and the test
257
+ // process hangs on exit (pr-review kumiko-framework #1036/1).
258
+ try {
259
+ if (jobRunner) await jobRunner.start();
260
+
261
+ // Auto-configure search for tenant 1 based on registry
262
+ if (enabledHooks.includes("search")) {
263
+ const searchableFields: string[] = [];
264
+ for (const feature of options.features) {
265
+ for (const [, entity] of Object.entries(feature.entities ?? {})) {
266
+ for (const [fieldName, field] of Object.entries(entity.fields)) {
267
+ if (field.type === "text" && field.searchable) {
268
+ searchableFields.push(fieldName);
269
+ }
270
+ if (field.type === "embedded") {
271
+ for (const [subName, subField] of Object.entries(field.schema)) {
272
+ if (subField.searchable) {
273
+ searchableFields.push(`${fieldName}_${subName}`);
274
+ }
266
275
  }
267
276
  }
268
277
  }
269
278
  }
270
279
  }
271
- }
272
280
 
273
- if (options.searchConfig) {
274
- await searchAdapter.configure(options.searchConfig.tenantId, {
275
- searchableFields: options.searchConfig.searchableFields,
276
- rankingFields: options.searchConfig.rankingFields,
277
- });
278
- } else if (searchableFields.length > 0) {
279
- await searchAdapter.configure("00000000-0000-4000-8000-000000000001", {
280
- searchableFields,
281
- rankingFields: searchableFields,
282
- });
281
+ if (options.searchConfig) {
282
+ await searchAdapter.configure(options.searchConfig.tenantId, {
283
+ searchableFields: options.searchConfig.searchableFields,
284
+ rankingFields: options.searchConfig.rankingFields,
285
+ });
286
+ } else if (searchableFields.length > 0) {
287
+ await searchAdapter.configure("00000000-0000-4000-8000-000000000001", {
288
+ searchableFields,
289
+ rankingFields: searchableFields,
290
+ });
291
+ }
283
292
  }
284
- }
285
293
 
286
- // Wire SSE broker with event collector
287
- const sseBroker = createSseBroker();
288
- sseBroker.addClient(
289
- "tenant:00000000-0000-4000-8000-000000000001",
290
- (event) => events.sse.push(event),
291
- () => {},
292
- );
294
+ // Wire SSE broker with event collector
295
+ const sseBroker = createSseBroker();
296
+ sseBroker.addClient(
297
+ "tenant:00000000-0000-4000-8000-000000000001",
298
+ (event) => events.sse.push(event),
299
+ () => {},
300
+ );
293
301
 
294
- const idempotency = createIdempotencyGuard(testRedis.redis, { ttlSeconds: 60 });
295
- const eventDedup = createEventDedup(testRedis.redis, { ttlSeconds: 60 });
296
- const entityCache = createEntityCache(testRedis.redis, { ttlSeconds: 60 });
302
+ const idempotency = createIdempotencyGuard(testRedis.redis, { ttlSeconds: 60 });
303
+ const eventDedup = createEventDedup(testRedis.redis, { ttlSeconds: 60 });
304
+ const entityCache = createEntityCache(testRedis.redis, { ttlSeconds: 60 });
297
305
 
298
- // A static `files.storageProvider` is wired as the per-tenant resolver — the
299
- // framework test seam that doesn't require mounting config + file-foundation.
300
- // (Bundled GDPR tests mount the real provider features instead.)
301
- let fileProviderResolver: import("../files").FileProviderResolver | undefined;
302
- if (options.files) {
303
- const provider = options.files.storageProvider;
304
- fileProviderResolver = () => Promise.resolve(provider);
305
- }
306
+ // A static `files.storageProvider` is wired as the per-tenant resolver — the
307
+ // framework test seam that doesn't require mounting config + file-foundation.
308
+ // (Bundled GDPR tests mount the real provider features instead.)
309
+ let fileProviderResolver: import("../files").FileProviderResolver | undefined;
310
+ if (options.files) {
311
+ const provider = options.files.storageProvider;
312
+ fileProviderResolver = () => Promise.resolve(provider);
313
+ }
306
314
 
307
- const server = buildServer({
308
- registry,
309
- context: {
310
- db: testDb.db,
311
- redis: testRedis.redis,
312
- searchAdapter,
313
- entityCache,
315
+ const server = buildServer({
314
316
  registry,
315
- ...(options.masterKeyProvider ? { masterKeyProvider: options.masterKeyProvider } : {}),
316
- ...(fileProviderResolver ? { _fileProviderResolver: fileProviderResolver } : {}),
317
- ...(typeof options.extraContext === "function"
318
- ? options.extraContext({ registry, db: testDb.db, sseBroker, redis: testRedis.redis })
319
- : options.extraContext),
320
- },
321
- jwtSecret,
322
- dispatcherOptions: {
323
- idempotency,
324
- ...(options.effectiveFeatures && { effectiveFeatures: options.effectiveFeatures }),
325
- ...(jobRunner && { jobRunner }),
326
- },
327
- eventDedup,
328
- sseBroker,
329
- // Tests drive the dispatcher via stack.eventDispatcher.runOnce() for
330
- // deterministic drains — no timer-induced flakiness. pollIntervalMs
331
- // stays short anyway in case a test opts into `.start()`. pgClient
332
- // plumbs through the LISTEN wake-up for tests that want to measure
333
- // post-commit latency (Sprint E.4).
334
- eventDispatcher: {
335
- pollIntervalMs: 50,
336
- pgClient: testDb.client as PgClient | undefined,
337
- systemConsumers: {
338
- sse: enabledHooks.includes("sse"),
339
- search: enabledHooks.includes("search"),
317
+ context: {
318
+ db: testDb.db,
319
+ redis: testRedis.redis,
320
+ searchAdapter,
321
+ entityCache,
322
+ registry,
323
+ ...(options.masterKeyProvider ? { masterKeyProvider: options.masterKeyProvider } : {}),
324
+ ...(fileProviderResolver ? { _fileProviderResolver: fileProviderResolver } : {}),
325
+ ...(typeof options.extraContext === "function"
326
+ ? options.extraContext({ registry, db: testDb.db, sseBroker, redis: testRedis.redis })
327
+ : options.extraContext),
340
328
  },
341
- },
342
- // Default tests to no login rate-limiter so existing suites that loop
343
- // over logins don't hit a 429 after 10 attempts. Suites specifically
344
- // testing the limiter can override via authConfig.loginRateLimit.
345
- ...(options.authConfig
346
- ? {
347
- auth: {
348
- ...options.authConfig,
349
- ...(options.authConfig.loginRateLimit === undefined ? { loginRateLimit: null } : {}),
350
- },
351
- }
352
- : {}),
353
- ...(options.observability ? { observability: options.observability } : {}),
354
- ...(options.lifecycle ? { lifecycle: options.lifecycle } : {}),
355
- ...(options.rateLimit ? { rateLimit: options.rateLimit } : {}),
356
- ...(options.anonymousAccess
357
- ? {
358
- anonymousAccess:
359
- typeof options.anonymousAccess === "function"
360
- ? options.anonymousAccess({
361
- registry,
362
- db: testDb.db,
363
- sseBroker,
364
- redis: testRedis.redis,
365
- })
366
- : options.anonymousAccess,
367
- }
368
- : {}),
369
- });
329
+ jwtSecret,
330
+ dispatcherOptions: {
331
+ idempotency,
332
+ ...(options.effectiveFeatures && { effectiveFeatures: options.effectiveFeatures }),
333
+ ...(jobRunner && { jobRunner }),
334
+ },
335
+ eventDedup,
336
+ sseBroker,
337
+ // Tests drive the dispatcher via stack.eventDispatcher.runOnce() for
338
+ // deterministic drains — no timer-induced flakiness. pollIntervalMs
339
+ // stays short anyway in case a test opts into `.start()`. pgClient
340
+ // plumbs through the LISTEN wake-up for tests that want to measure
341
+ // post-commit latency (Sprint E.4).
342
+ eventDispatcher: {
343
+ pollIntervalMs: 50,
344
+ pgClient: testDb.client as PgClient | undefined,
345
+ systemConsumers: {
346
+ sse: enabledHooks.includes("sse"),
347
+ search: enabledHooks.includes("search"),
348
+ },
349
+ },
350
+ // Default tests to no login rate-limiter so existing suites that loop
351
+ // over logins don't hit a 429 after 10 attempts. Suites specifically
352
+ // testing the limiter can override via authConfig.loginRateLimit.
353
+ ...(options.authConfig
354
+ ? {
355
+ auth: {
356
+ ...options.authConfig,
357
+ ...(options.authConfig.loginRateLimit === undefined ? { loginRateLimit: null } : {}),
358
+ },
359
+ }
360
+ : {}),
361
+ ...(options.observability ? { observability: options.observability } : {}),
362
+ ...(options.lifecycle ? { lifecycle: options.lifecycle } : {}),
363
+ ...(options.rateLimit ? { rateLimit: options.rateLimit } : {}),
364
+ ...(options.anonymousAccess
365
+ ? {
366
+ anonymousAccess:
367
+ typeof options.anonymousAccess === "function"
368
+ ? options.anonymousAccess({
369
+ registry,
370
+ db: testDb.db,
371
+ sseBroker,
372
+ redis: testRedis.redis,
373
+ })
374
+ : options.anonymousAccess,
375
+ }
376
+ : {}),
377
+ });
370
378
 
371
- const eventDispatcher: EventDispatcher | undefined = server.eventDispatcher;
379
+ const eventDispatcher: EventDispatcher | undefined = server.eventDispatcher;
372
380
 
373
- // Pre-register consumer state rows so tests can call runOnce() directly
374
- // without a preceding explicit start(). Timer fires at pollIntervalMs=50
375
- // but passInFlight serialises concurrent passes — tests that drain via
376
- // runOnce() remain deterministic. Tests that specifically exercise the
377
- // timer loop call start() again (idempotent) after setup.
378
- if (eventDispatcher) await eventDispatcher.ensureRegistered();
381
+ // Pre-register consumer state rows so tests can call runOnce() directly
382
+ // without a preceding explicit start(). Timer fires at pollIntervalMs=50
383
+ // but passInFlight serialises concurrent passes — tests that drain via
384
+ // runOnce() remain deterministic. Tests that specifically exercise the
385
+ // timer loop call start() again (idempotent) after setup.
386
+ if (eventDispatcher) await eventDispatcher.ensureRegistered();
379
387
 
380
- const http = createRequestHelper(server.app, server.jwt);
388
+ const http = createRequestHelper(server.app, server.jwt);
381
389
 
382
- return {
383
- app: server.app,
384
- jwt: server.jwt,
385
- registry,
386
- db: testDb.db,
387
- redis: testRedis,
388
- search: searchAdapter,
389
- events,
390
- http,
391
- observability: server.observability,
392
- dispatcher: server.dispatcher,
393
- ...(eventDispatcher ? { eventDispatcher } : {}),
394
- ...(server.lifecycle ? { lifecycle: server.lifecycle } : {}),
395
- ...(jobRunner ? { jobRunner } : {}),
396
- cleanup: async () => {
397
- if (jobRunner) await jobRunner.stop();
398
- if (eventDispatcher) await eventDispatcher.stop();
399
- await server.observability.shutdown();
400
- await Promise.all([testDb.cleanup(), testRedis.cleanup()]);
401
- },
402
- };
390
+ return {
391
+ app: server.app,
392
+ jwt: server.jwt,
393
+ registry,
394
+ db: testDb.db,
395
+ redis: testRedis,
396
+ search: searchAdapter,
397
+ events,
398
+ http,
399
+ observability: server.observability,
400
+ dispatcher: server.dispatcher,
401
+ ...(eventDispatcher ? { eventDispatcher } : {}),
402
+ ...(server.lifecycle ? { lifecycle: server.lifecycle } : {}),
403
+ ...(jobRunner ? { jobRunner } : {}),
404
+ cleanup: async () => {
405
+ if (jobRunner) await jobRunner.stop();
406
+ if (eventDispatcher) await eventDispatcher.stop();
407
+ await server.observability.shutdown();
408
+ await Promise.all([testDb.cleanup(), testRedis.cleanup()]);
409
+ },
410
+ };
411
+ } catch (error) {
412
+ // Best-effort — a broken jobRunner.stop() must never mask the real
413
+ // setup failure below.
414
+ if (jobRunner) {
415
+ try {
416
+ await jobRunner.stop();
417
+ } catch {
418
+ // ignore — `error` is the one that matters
419
+ }
420
+ }
421
+ throw error;
422
+ }
403
423
  }