@cosmicdrift/kumiko-framework 0.236.0 → 0.237.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 +3 -3
- package/src/__tests__/schema-cli.integration.test.ts +6 -4
- package/src/api/__tests__/redis-sse-broker.integration.test.ts +38 -1
- package/src/api/index.ts +1 -1
- package/src/api/redis-sse-broker.ts +28 -0
- package/src/api/server.ts +15 -13
- package/src/engine/__tests__/entity-handlers.test.ts +53 -1
- package/src/engine/__tests__/nav.test.ts +53 -1
- package/src/engine/boot-validator/index.ts +2 -0
- package/src/engine/boot-validator/nav.ts +36 -1
- package/src/engine/define-handler.ts +2 -0
- package/src/engine/entity-handlers.ts +8 -2
- package/src/engine/factories.ts +1 -0
- package/src/schema-cli.ts +9 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-framework",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.237.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>",
|
|
@@ -198,7 +198,7 @@
|
|
|
198
198
|
"./package.json": "./package.json"
|
|
199
199
|
},
|
|
200
200
|
"dependencies": {
|
|
201
|
-
"@cosmicdrift/kumiko-types": "0.
|
|
201
|
+
"@cosmicdrift/kumiko-types": "0.237.0",
|
|
202
202
|
"bullmq": "^5.76.7",
|
|
203
203
|
"bun-types": "^1.3.13",
|
|
204
204
|
"hono": "^4.13.1",
|
|
@@ -214,7 +214,7 @@
|
|
|
214
214
|
"zod": "^4.4.3"
|
|
215
215
|
},
|
|
216
216
|
"devDependencies": {
|
|
217
|
-
"@cosmicdrift/kumiko-dispatcher-live": "0.
|
|
217
|
+
"@cosmicdrift/kumiko-dispatcher-live": "0.237.0",
|
|
218
218
|
"bun-types": "^1.3.13",
|
|
219
219
|
"pino-pretty": "^13.1.3"
|
|
220
220
|
},
|
|
@@ -54,11 +54,13 @@ describe("runSchemaCli — no-DB paths", () => {
|
|
|
54
54
|
expect(cap.err).toHaveLength(0);
|
|
55
55
|
});
|
|
56
56
|
|
|
57
|
-
test("unknown subcommand
|
|
57
|
+
test("unknown subcommand exits 1 and reports it on stderr", async () => {
|
|
58
58
|
const cap = captureOut();
|
|
59
|
-
const code = await runSchemaCli(["
|
|
60
|
-
expect(code).toBe(
|
|
61
|
-
expect(cap.
|
|
59
|
+
const code = await runSchemaCli(["cutover"], appCwd, cap.out);
|
|
60
|
+
expect(code).toBe(1);
|
|
61
|
+
expect(cap.err.join("\n")).toContain('Unbekanntes Subcommand: "cutover"');
|
|
62
|
+
expect(cap.err.join("\n")).toContain("generate | validate | apply | baseline | status");
|
|
63
|
+
expect(cap.log).toHaveLength(0);
|
|
62
64
|
});
|
|
63
65
|
|
|
64
66
|
test("generate without name exits 1 with neutral usage wording", async () => {
|
|
@@ -2,7 +2,12 @@ import { afterAll, afterEach, beforeAll, describe, expect, test } from "bun:test
|
|
|
2
2
|
import { createTestRedis, type TestRedis } from "../../stack";
|
|
3
3
|
import { waitFor } from "../../testing/wait-for";
|
|
4
4
|
import { generateId } from "../../utils";
|
|
5
|
-
import {
|
|
5
|
+
import {
|
|
6
|
+
createDefaultSseBroker,
|
|
7
|
+
createRedisSseBroker,
|
|
8
|
+
isRedisSseBroker,
|
|
9
|
+
type RedisSseBroker,
|
|
10
|
+
} from "../redis-sse-broker";
|
|
6
11
|
import type { SseEvent } from "../sse-broker";
|
|
7
12
|
|
|
8
13
|
let testRedis: TestRedis;
|
|
@@ -149,3 +154,35 @@ describe("createRedisSseBroker", () => {
|
|
|
149
154
|
}
|
|
150
155
|
});
|
|
151
156
|
});
|
|
157
|
+
|
|
158
|
+
describe("createDefaultSseBroker", () => {
|
|
159
|
+
test("builds a Redis-backed broker under REDIS_URL that participates in cross-pod fanout", async () => {
|
|
160
|
+
const { sseBroker, ownedRedisSseBroker } = createDefaultSseBroker({
|
|
161
|
+
REDIS_URL: testRedis.redisUrl,
|
|
162
|
+
});
|
|
163
|
+
expect(ownedRedisSseBroker).toBeDefined();
|
|
164
|
+
expect(isRedisSseBroker(sseBroker)).toBe(true);
|
|
165
|
+
if (ownedRedisSseBroker) brokers.push(ownedRedisSseBroker);
|
|
166
|
+
|
|
167
|
+
const otherPod = trackedBroker();
|
|
168
|
+
const channel = `default-broker-${generateId()}`;
|
|
169
|
+
const received: SseEvent[] = [];
|
|
170
|
+
otherPod.addClient(
|
|
171
|
+
channel,
|
|
172
|
+
(event) => received.push(event),
|
|
173
|
+
() => {},
|
|
174
|
+
);
|
|
175
|
+
|
|
176
|
+
await waitFor(() => {
|
|
177
|
+
sseBroker.pushToChannel(channel, { type: "unit.updated", data: { id: "1" } });
|
|
178
|
+
return received.length >= 1;
|
|
179
|
+
});
|
|
180
|
+
expect(received[0]).toEqual({ type: "unit.updated", data: { id: "1" } });
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
test("falls back to the in-memory broker without REDIS_URL", () => {
|
|
184
|
+
const { sseBroker, ownedRedisSseBroker } = createDefaultSseBroker({});
|
|
185
|
+
expect(ownedRedisSseBroker).toBeUndefined();
|
|
186
|
+
expect(isRedisSseBroker(sseBroker)).toBe(false);
|
|
187
|
+
});
|
|
188
|
+
});
|
package/src/api/index.ts
CHANGED
|
@@ -42,7 +42,7 @@ export type { JwtHelper, JwtKeyring, JwtPayload } from "./jwt";
|
|
|
42
42
|
export { createJwtHelper, loadJwtSecretOrKeyring } from "./jwt";
|
|
43
43
|
export { patAllows, qnMatches } from "./pat-scope";
|
|
44
44
|
export type { RedisSseBroker, RedisSseBrokerOptions } from "./redis-sse-broker";
|
|
45
|
-
export { createRedisSseBroker } from "./redis-sse-broker";
|
|
45
|
+
export { createDefaultSseBroker, createRedisSseBroker, isRedisSseBroker } from "./redis-sse-broker";
|
|
46
46
|
export { type RequestContextData, requestContext } from "./request-context";
|
|
47
47
|
export { requestIdMiddleware } from "./request-id-middleware";
|
|
48
48
|
export { createApiRoutes } from "./routes";
|
|
@@ -22,6 +22,34 @@ export type RedisSseBroker = SseBroker & {
|
|
|
22
22
|
close(): Promise<void>;
|
|
23
23
|
};
|
|
24
24
|
|
|
25
|
+
// The one property RedisSseBroker adds over SseBroker — cheap and reliable
|
|
26
|
+
// to narrow on, so callers that only hold an SseBroker (e.g. a test that
|
|
27
|
+
// pulled it back out of a generic ServerOptions.sseBroker slot) can tell
|
|
28
|
+
// which lifecycle they own without an unsound `as` cast.
|
|
29
|
+
export function isRedisSseBroker(broker: SseBroker): broker is RedisSseBroker {
|
|
30
|
+
return "close" in broker;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Single decision point for "which SseBroker should a caller default to
|
|
34
|
+
// when it doesn't inject its own": REDIS_URL present → cross-replica
|
|
35
|
+
// Redis-backed broker, absent → the in-memory one. Both buildServer and
|
|
36
|
+
// runProdApp must funnel through this rather than each deciding on their
|
|
37
|
+
// own — runProdApp doing exactly that (building an in-memory broker
|
|
38
|
+
// unconditionally, then passing it in as ServerOptions.sseBroker) is why
|
|
39
|
+
// fw#2625's fanout fix (fw#2630) shipped without ever taking effect in
|
|
40
|
+
// production: an explicit ServerOptions.sseBroker always wins in
|
|
41
|
+
// buildServer, so its own REDIS_URL-based default never ran.
|
|
42
|
+
export function createDefaultSseBroker(env: Record<string, string | undefined> = process.env): {
|
|
43
|
+
readonly sseBroker: SseBroker;
|
|
44
|
+
// Present only when REDIS_URL resolved. undefined means the fallback
|
|
45
|
+
// in-memory broker was used, which has nothing to release on shutdown.
|
|
46
|
+
readonly ownedRedisSseBroker: RedisSseBroker | undefined;
|
|
47
|
+
} {
|
|
48
|
+
const redisUrl = env["REDIS_URL"];
|
|
49
|
+
const ownedRedisSseBroker = redisUrl ? createRedisSseBroker({ redisUrl }) : undefined;
|
|
50
|
+
return { sseBroker: ownedRedisSseBroker ?? createSseBroker(), ownedRedisSseBroker };
|
|
51
|
+
}
|
|
52
|
+
|
|
25
53
|
function isSseEvent(value: unknown): value is SseEvent {
|
|
26
54
|
return (
|
|
27
55
|
typeof value === "object" &&
|
package/src/api/server.ts
CHANGED
|
@@ -57,7 +57,7 @@ import { createJwtHelper, type JwtHelper, type JwtKeyring } from "./jwt";
|
|
|
57
57
|
import { observabilityMiddleware } from "./observability-middleware";
|
|
58
58
|
import { assertOriginGuardConfig, originMiddleware } from "./origin-middleware";
|
|
59
59
|
import { piiCiphertextResponseGuard } from "./pii-leak-guard";
|
|
60
|
-
import {
|
|
60
|
+
import { createDefaultSseBroker, type RedisSseBroker } from "./redis-sse-broker";
|
|
61
61
|
import { requestContext } from "./request-context";
|
|
62
62
|
import { buildRequestContextData, requestIdMiddleware } from "./request-id-middleware";
|
|
63
63
|
import {
|
|
@@ -68,7 +68,7 @@ import {
|
|
|
68
68
|
registerVersionRoute,
|
|
69
69
|
} from "./route-registrars";
|
|
70
70
|
import { createApiRoutes } from "./routes";
|
|
71
|
-
import {
|
|
71
|
+
import type { SseBroker } from "./sse-broker";
|
|
72
72
|
import { createSseRoute } from "./sse-route";
|
|
73
73
|
|
|
74
74
|
export type ServerOptions = {
|
|
@@ -318,17 +318,19 @@ export function buildServer(options: ServerOptions): KumikoServer {
|
|
|
318
318
|
options.jwtTtl ?? defaultJwtTtl,
|
|
319
319
|
);
|
|
320
320
|
// An explicit ServerOptions.sseBroker always wins (caller owns its
|
|
321
|
-
// lifecycle)
|
|
322
|
-
//
|
|
323
|
-
//
|
|
324
|
-
//
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
321
|
+
// lifecycle) — createDefaultSseBroker is the single decision point every
|
|
322
|
+
// other caller must funnel through instead of picking a broker on its
|
|
323
|
+
// own, so the REDIS_URL-gated cross-replica default (fw#2625) can't be
|
|
324
|
+
// silently bypassed the way runProdApp bypassed it before this existed.
|
|
325
|
+
let sseBroker: SseBroker;
|
|
326
|
+
let ownedRedisSseBroker: RedisSseBroker | undefined;
|
|
327
|
+
if (options.sseBroker) {
|
|
328
|
+
sseBroker = options.sseBroker;
|
|
329
|
+
} else {
|
|
330
|
+
const defaults = createDefaultSseBroker();
|
|
331
|
+
sseBroker = defaults.sseBroker;
|
|
332
|
+
ownedRedisSseBroker = defaults.ownedRedisSseBroker;
|
|
333
|
+
}
|
|
332
334
|
|
|
333
335
|
// Resolve the per-process instance identifier. Prefer explicit
|
|
334
336
|
// ServerOptions.instanceId (tests, deliberate wiring), fall back to the
|
|
@@ -16,7 +16,7 @@ import {
|
|
|
16
16
|
import { createEntity, createTextField } from "../factories";
|
|
17
17
|
// Barrel import, not "../entity-handlers": covers that entityListSchema is
|
|
18
18
|
// actually re-exported through engine/index.ts.
|
|
19
|
-
import { entityListSchema } from "../index";
|
|
19
|
+
import { entityListSchema, resolveAgentExposure } from "../index";
|
|
20
20
|
import type { QueryHandlerDef, WriteHandlerDef } from "../types";
|
|
21
21
|
|
|
22
22
|
const VALID_UUID = "00000000-0000-4000-8000-000000000001";
|
|
@@ -367,4 +367,56 @@ describe("registerEntityCrud", () => {
|
|
|
367
367
|
expect(Object.keys(entities)).toEqual(["note"]);
|
|
368
368
|
expect(writes.map((w) => w.name)).toEqual(["note:create"]);
|
|
369
369
|
});
|
|
370
|
+
test("descriptions: per-verb text lands on that verb only, and drives agent exposure", () => {
|
|
371
|
+
const { r, writes, queries } = createCrudRegistrarMock();
|
|
372
|
+
registerEntityCrud(r, "note", noteEntity, {
|
|
373
|
+
descriptions: {
|
|
374
|
+
create: "Files a new note.",
|
|
375
|
+
list: "Lists notes newest first.",
|
|
376
|
+
},
|
|
377
|
+
});
|
|
378
|
+
|
|
379
|
+
const create = writes.find((w) => w.name === "note:create");
|
|
380
|
+
const update = writes.find((w) => w.name === "note:update");
|
|
381
|
+
const list = queries.find((q) => q.name === "note:list");
|
|
382
|
+
const detail = queries.find((q) => q.name === "note:detail");
|
|
383
|
+
if (!create || !update || !list || !detail) throw new Error("CRUD handlers missing");
|
|
384
|
+
|
|
385
|
+
expect(create.description).toBe("Files a new note.");
|
|
386
|
+
expect(list.description).toBe("Lists notes newest first.");
|
|
387
|
+
expect(update.description).toBeUndefined();
|
|
388
|
+
expect(detail.description).toBeUndefined();
|
|
389
|
+
|
|
390
|
+
// Fail-closed exposure is why the slot exists: only the described verbs
|
|
391
|
+
// reach the agent manifest.
|
|
392
|
+
expect(resolveAgentExposure(create, "write").expose).toBe(true);
|
|
393
|
+
expect(resolveAgentExposure(list, "query").expose).toBe(true);
|
|
394
|
+
expect(resolveAgentExposure(update, "write").expose).toBe(false);
|
|
395
|
+
expect(resolveAgentExposure(detail, "query").expose).toBe(false);
|
|
396
|
+
});
|
|
397
|
+
|
|
398
|
+
test("descriptions: per-verb entry wins over the write/read fallback", () => {
|
|
399
|
+
const { r, writes } = createCrudRegistrarMock();
|
|
400
|
+
registerEntityCrud(r, "note", noteEntity, {
|
|
401
|
+
write: { description: "Shared write text." },
|
|
402
|
+
descriptions: { create: "Files a new note." },
|
|
403
|
+
verbs: { list: false, detail: false },
|
|
404
|
+
});
|
|
405
|
+
|
|
406
|
+
expect(writes.find((w) => w.name === "note:create")?.description).toBe("Files a new note.");
|
|
407
|
+
expect(writes.find((w) => w.name === "note:update")?.description).toBe("Shared write text.");
|
|
408
|
+
});
|
|
409
|
+
|
|
410
|
+
test("agent hints on the per-handler options raise the resolved risk", () => {
|
|
411
|
+
const del = defineEntityDeleteHandler("note", noteEntity, {
|
|
412
|
+
description: "Deletes a note permanently.",
|
|
413
|
+
agent: { risk: "high" },
|
|
414
|
+
});
|
|
415
|
+
const create = defineEntityCreateHandler("note", noteEntity, {
|
|
416
|
+
description: "Files a new note.",
|
|
417
|
+
});
|
|
418
|
+
|
|
419
|
+
expect(resolveAgentExposure(del, "write").risk).toBe("high");
|
|
420
|
+
expect(resolveAgentExposure(create, "write").risk).toBe("mid");
|
|
421
|
+
});
|
|
370
422
|
});
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { describe, expect, test } from "bun:test";
|
|
1
|
+
import { describe, expect, spyOn, test } from "bun:test";
|
|
2
2
|
import { withBootValidatorFixture } from "../../testing/boot-validator-fixture";
|
|
3
3
|
import { validateBoot as validateBootRaw } from "../boot-validator";
|
|
4
4
|
import { defineFeature } from "../define-feature";
|
|
@@ -358,3 +358,55 @@ describe("validateBoot — nav validation", () => {
|
|
|
358
358
|
expect(() => validateBoot([feature])).not.toThrow();
|
|
359
359
|
});
|
|
360
360
|
});
|
|
361
|
+
|
|
362
|
+
describe("nav access inversion warning (fw#2640)", () => {
|
|
363
|
+
test("leaf role-gate disjoint from its parent section's → warns but boot still succeeds", () => {
|
|
364
|
+
const warnSpy = spyOn(console, "warn").mockImplementation(() => {});
|
|
365
|
+
const feature = defineFeature("shop", (r) => {
|
|
366
|
+
r.nav({ id: "section", label: "s", access: { roles: ["Manager"] } });
|
|
367
|
+
r.nav({
|
|
368
|
+
id: "leaf",
|
|
369
|
+
label: "l",
|
|
370
|
+
parent: "shop:nav:section",
|
|
371
|
+
access: { roles: ["Admin"] },
|
|
372
|
+
});
|
|
373
|
+
});
|
|
374
|
+
expect(() => validateBoot([feature])).not.toThrow();
|
|
375
|
+
expect(warnSpy).toHaveBeenCalledWith(
|
|
376
|
+
expect.stringContaining('Nav entry "shop:nav:leaf" requires roles [Admin]'),
|
|
377
|
+
);
|
|
378
|
+
warnSpy.mockRestore();
|
|
379
|
+
});
|
|
380
|
+
|
|
381
|
+
test("leaf role-gate overlapping its parent's → no warning", () => {
|
|
382
|
+
const warnSpy = spyOn(console, "warn").mockImplementation(() => {});
|
|
383
|
+
const feature = defineFeature("shop", (r) => {
|
|
384
|
+
r.nav({ id: "section", label: "s", access: { roles: ["Manager", "Admin"] } });
|
|
385
|
+
r.nav({
|
|
386
|
+
id: "leaf",
|
|
387
|
+
label: "l",
|
|
388
|
+
parent: "shop:nav:section",
|
|
389
|
+
access: { roles: ["Admin"] },
|
|
390
|
+
});
|
|
391
|
+
});
|
|
392
|
+
expect(() => validateBoot([feature])).not.toThrow();
|
|
393
|
+
expect(warnSpy).not.toHaveBeenCalledWith(expect.stringContaining("Nav entry"));
|
|
394
|
+
warnSpy.mockRestore();
|
|
395
|
+
});
|
|
396
|
+
|
|
397
|
+
test("parent open to everyone (unset access) → a role-gated leaf never warns", () => {
|
|
398
|
+
const warnSpy = spyOn(console, "warn").mockImplementation(() => {});
|
|
399
|
+
const feature = defineFeature("shop", (r) => {
|
|
400
|
+
r.nav({ id: "section", label: "s" });
|
|
401
|
+
r.nav({
|
|
402
|
+
id: "leaf",
|
|
403
|
+
label: "l",
|
|
404
|
+
parent: "shop:nav:section",
|
|
405
|
+
access: { roles: ["Admin"] },
|
|
406
|
+
});
|
|
407
|
+
});
|
|
408
|
+
expect(() => validateBoot([feature])).not.toThrow();
|
|
409
|
+
expect(warnSpy).not.toHaveBeenCalledWith(expect.stringContaining("Nav entry"));
|
|
410
|
+
warnSpy.mockRestore();
|
|
411
|
+
});
|
|
412
|
+
});
|
|
@@ -42,6 +42,7 @@ import {
|
|
|
42
42
|
collectWriteHandlerQns,
|
|
43
43
|
validateNavCycles,
|
|
44
44
|
validateNavs,
|
|
45
|
+
warnOnNavAccessInversion,
|
|
45
46
|
} from "./nav";
|
|
46
47
|
import { validateOwnershipRules } from "./ownership";
|
|
47
48
|
import { validatePiiAndRetention } from "./pii-retention";
|
|
@@ -215,6 +216,7 @@ export function validateBoot(
|
|
|
215
216
|
}
|
|
216
217
|
|
|
217
218
|
validateNavCycles(allNavQns);
|
|
219
|
+
warnOnNavAccessInversion(allNavQns);
|
|
218
220
|
validateDefaultWorkspaceUniqueness(allWorkspaceQns);
|
|
219
221
|
validateI18nSurfaceKeys(features);
|
|
220
222
|
validateEntityListScreens(features);
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
// here since this is a structural move, not a semantic reorg).
|
|
4
4
|
|
|
5
5
|
import { qualifyEntityName } from "../qualified-name";
|
|
6
|
-
import type { FeatureDefinition, NavDefinition, WorkspaceDefinition } from "../types";
|
|
6
|
+
import type { AccessRule, FeatureDefinition, NavDefinition, WorkspaceDefinition } from "../types";
|
|
7
7
|
|
|
8
8
|
export function collectWriteHandlerQns(features: readonly FeatureDefinition[]): Set<string> {
|
|
9
9
|
const set = new Set<string>();
|
|
@@ -99,6 +99,41 @@ export function validateNavCycles(
|
|
|
99
99
|
}
|
|
100
100
|
}
|
|
101
101
|
|
|
102
|
+
// undefined access or `{ openToAll: true }` both mean "visible to everyone"
|
|
103
|
+
// — neither has a role-set an inversion check could compare against.
|
|
104
|
+
function navViewerRoles(access: AccessRule | undefined): readonly string[] | undefined {
|
|
105
|
+
if (access === undefined || "openToAll" in access) return undefined;
|
|
106
|
+
return access.roles;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// fw#2640: warn (never throw — this is a real UX bug, not a broken ref)
|
|
110
|
+
// when a nav leaf's role gate is disjoint from its parent section's — no
|
|
111
|
+
// user who can see the section could ever see this child, so the section
|
|
112
|
+
// renders with zero visible children for every user who reaches it.
|
|
113
|
+
// Only fires when BOTH sides declare an explicit, non-empty role list:
|
|
114
|
+
// an unset/openToAll side is visible to everyone, so it can't invert.
|
|
115
|
+
export function warnOnNavAccessInversion(
|
|
116
|
+
allNavQns: ReadonlyMap<string, NavDefinition & { readonly featureName: string }>,
|
|
117
|
+
): void {
|
|
118
|
+
for (const [qn, navDef] of allNavQns) {
|
|
119
|
+
if (navDef.parent === undefined) continue;
|
|
120
|
+
const parentDef = allNavQns.get(navDef.parent);
|
|
121
|
+
if (parentDef === undefined) continue; // dangling parent ref — validateNavs already throws for this
|
|
122
|
+
const parentRoles = navViewerRoles(parentDef.access);
|
|
123
|
+
const leafRoles = navViewerRoles(navDef.access);
|
|
124
|
+
if (parentRoles === undefined || leafRoles === undefined) continue;
|
|
125
|
+
if (parentRoles.length === 0) continue; // parent already visible to nobody — a different problem
|
|
126
|
+
if (leafRoles.some((role) => parentRoles.includes(role))) continue;
|
|
127
|
+
// biome-ignore lint/suspicious/noConsole: boot-time dev hint, no logger available yet
|
|
128
|
+
console.warn(
|
|
129
|
+
`[kumiko:boot] Nav entry "${qn}" requires roles [${leafRoles.join(", ")}] but its parent ` +
|
|
130
|
+
`"${navDef.parent}" only allows [${parentRoles.join(", ")}] — no user who can see the ` +
|
|
131
|
+
`parent section could ever see this entry. If this is intentional, ignore this warning; ` +
|
|
132
|
+
`otherwise align the role sets.`,
|
|
133
|
+
);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
102
137
|
// Roles we recognise at boot time. The framework has no explicit
|
|
103
138
|
// role-registry (r.defineRoles is a type helper only), so we synthesise
|
|
104
139
|
// one from every handler-access rule plus the "all"/"system" built-ins.
|
|
@@ -78,6 +78,8 @@ export function defineWriteHandler<
|
|
|
78
78
|
name: def.name,
|
|
79
79
|
schema: def.schema,
|
|
80
80
|
...(def.access && { access: def.access }),
|
|
81
|
+
...(def.description !== undefined && { description: def.description }),
|
|
82
|
+
...(def.agent && { agent: def.agent }),
|
|
81
83
|
...(def.unsafeSkipTransitionGuard && {
|
|
82
84
|
unsafeSkipTransitionGuard: def.unsafeSkipTransitionGuard,
|
|
83
85
|
}),
|
|
@@ -172,7 +172,7 @@ function parseHandlerName<TVerb extends string>(
|
|
|
172
172
|
export function defineEntityWriteHandler(
|
|
173
173
|
name: string,
|
|
174
174
|
entity: EntityDefinition,
|
|
175
|
-
options?:
|
|
175
|
+
options?: EntityHandlerOptions,
|
|
176
176
|
): WriteHandlerDef {
|
|
177
177
|
const { entityName, verb } = parseHandlerName(name, WRITE_VERBS);
|
|
178
178
|
if (verb === "restore" && !entity.softDelete) {
|
|
@@ -253,6 +253,8 @@ export function defineEntityWriteHandler(
|
|
|
253
253
|
schema,
|
|
254
254
|
handler,
|
|
255
255
|
...(options?.access && { access: options.access }),
|
|
256
|
+
...(options?.description !== undefined && { description: options.description }),
|
|
257
|
+
...(options?.agent !== undefined && { agent: options.agent }),
|
|
256
258
|
};
|
|
257
259
|
}
|
|
258
260
|
|
|
@@ -282,7 +284,7 @@ function augmentDerivedFields(
|
|
|
282
284
|
export function defineEntityQueryHandler(
|
|
283
285
|
name: string,
|
|
284
286
|
entity: EntityDefinition,
|
|
285
|
-
options?:
|
|
287
|
+
options?: EntityQueryHandlerOptions,
|
|
286
288
|
): QueryHandlerDef {
|
|
287
289
|
const { entityName, verb } = parseHandlerName(name, QUERY_VERBS);
|
|
288
290
|
|
|
@@ -367,6 +369,8 @@ export function defineEntityQueryHandler(
|
|
|
367
369
|
schema,
|
|
368
370
|
handler,
|
|
369
371
|
...(options?.access && { access: options.access }),
|
|
372
|
+
...(options?.description !== undefined && { description: options.description }),
|
|
373
|
+
...(options?.agent !== undefined && { agent: options.agent }),
|
|
370
374
|
// The "list" verb's executor.list() always returns { rows, nextCursor,
|
|
371
375
|
// total? } (see the handler body above) — brand it so the definition
|
|
372
376
|
// site documents the PagedRows contract without needing
|
|
@@ -533,10 +537,12 @@ export function registerEntityCrud(
|
|
|
533
537
|
const resolveWriteOpts = (verb: EntityCrudVerb): EntityHandlerOptions => ({
|
|
534
538
|
...writeOpts,
|
|
535
539
|
access: options?.verbAccess?.[verb] ?? writeOpts?.access,
|
|
540
|
+
description: options?.descriptions?.[verb] ?? writeOpts?.description,
|
|
536
541
|
});
|
|
537
542
|
const resolveReadOpts = (verb: EntityCrudVerb): EntityQueryHandlerOptions => ({
|
|
538
543
|
...readOpts,
|
|
539
544
|
access: options?.verbAccess?.[verb] ?? readOpts?.access,
|
|
545
|
+
description: options?.descriptions?.[verb] ?? readOpts?.description,
|
|
540
546
|
});
|
|
541
547
|
|
|
542
548
|
if (verbs.create) {
|
package/src/engine/factories.ts
CHANGED
|
@@ -439,6 +439,7 @@ export function createImagesField(
|
|
|
439
439
|
export function createEntity<F>(def: {
|
|
440
440
|
readonly table?: string;
|
|
441
441
|
readonly fields: F;
|
|
442
|
+
readonly description?: string;
|
|
442
443
|
readonly softDelete?: boolean;
|
|
443
444
|
/** Event stream lives on SYSTEM_TENANT_ID (tenant-independent aggregate, e.g.
|
|
444
445
|
* user) instead of the creator's tenant. See EntityDefinition.systemStream. */
|
package/src/schema-cli.ts
CHANGED
|
@@ -447,6 +447,15 @@ export async function runSchemaCli(
|
|
|
447
447
|
}
|
|
448
448
|
|
|
449
449
|
default: {
|
|
450
|
+
// No subcommand (`kumiko schema`) is a help request → usage + exit 0.
|
|
451
|
+
// An unrecognized one (typo, e.g. `schema cutover`) must fail loud —
|
|
452
|
+
// silently exiting 0 here let a deploy's migrate step print help and
|
|
453
|
+
// move on without ever running a migration.
|
|
454
|
+
if (sub !== undefined) {
|
|
455
|
+
out.err(` Unbekanntes Subcommand: "${sub}"`);
|
|
456
|
+
out.err(" Erlaubt: generate | validate | apply | baseline | status");
|
|
457
|
+
return 1;
|
|
458
|
+
}
|
|
450
459
|
out.log("");
|
|
451
460
|
out.log(" Subcommands:");
|
|
452
461
|
out.log(" generate <name> Schreibe neue Migration aus EntityTableMeta-Diff");
|