@cosmicdrift/kumiko-framework 0.243.3 → 0.244.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.243.3",
3
+ "version": "0.244.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.243.3",
201
+ "@cosmicdrift/kumiko-types": "0.244.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.243.3",
217
+ "@cosmicdrift/kumiko-dispatcher-live": "0.244.0",
218
218
  "bun-types": "^1.3.13",
219
219
  "pino-pretty": "^13.1.3"
220
220
  },
@@ -23,6 +23,7 @@ import {
23
23
  from,
24
24
  registerEntityCrud,
25
25
  } from "../index";
26
+ import type { RowFieldExtractor } from "../types/screen";
26
27
 
27
28
  function validateBoot(features: Parameters<typeof validateBootRaw>[0]): void {
28
29
  validateBootRaw(withBootValidatorFixture(features));
@@ -3696,6 +3697,110 @@ describe("boot-validator", () => {
3696
3697
  });
3697
3698
  });
3698
3699
 
3700
+ // --- action/rowAction drawer-target reachability across the remaining
3701
+ // RowAction call sites (fw#2710): projectionDetail.actions,
3702
+ // entityEdit.actions, and a relatedList section's rowActions. The
3703
+ // entityList/projectionList rowAction and toolbarAction drawer cases
3704
+ // are covered above — this pins the drawer branch actually being
3705
+ // reached (not skipped/mis-nested) at the other three sites, and the
3706
+ // "action" actionLabel used by both non-list call sites. ---
3707
+ describe("drawer-target reachability at the remaining RowAction call sites (fw#2710)", () => {
3708
+ test('projectionDetail.actions drawer-target unknown → throw with actionLabel "action"', () => {
3709
+ const feature = defineFeature("shop", (r) => {
3710
+ r.screen({
3711
+ id: "order-detail",
3712
+ type: "projectionDetail",
3713
+ query: "shop:query:order-detail",
3714
+ layout: { sections: [{ fields: ["total"] }] },
3715
+ actions: [
3716
+ { kind: "drawer", id: "add-note", label: "actions.addNote", screen: "ghost-form" },
3717
+ ],
3718
+ });
3719
+ });
3720
+ expect(() => validateBoot([feature])).toThrow(
3721
+ /action "add-note" drawer-target "ghost-form" does not resolve to a registered screen/,
3722
+ );
3723
+ });
3724
+
3725
+ test('entityEdit.actions drawer-target unknown → throw with actionLabel "action"', () => {
3726
+ const feature = defineFeature("shop", (r) => {
3727
+ r.entity("product", createEntity({ fields: { name: createTextField() } }));
3728
+ r.screen({
3729
+ id: "product-edit",
3730
+ type: "entityEdit",
3731
+ entity: "product",
3732
+ layout: { sections: [{ title: "Basics", fields: ["name"] }] },
3733
+ actions: [
3734
+ { kind: "drawer", id: "add-note", label: "actions.addNote", screen: "ghost-form" },
3735
+ ],
3736
+ });
3737
+ });
3738
+ expect(() => validateBoot([feature])).toThrow(
3739
+ /action "add-note" drawer-target "ghost-form" does not resolve to a registered screen/,
3740
+ );
3741
+ });
3742
+
3743
+ test("entityEdit.actions drawer-target → registered actionForm → no throw", () => {
3744
+ const feature = defineFeature("shop", (r) => {
3745
+ r.entity("product", createEntity({ fields: { name: createTextField() } }));
3746
+ r.writeHandler(
3747
+ "add-note",
3748
+ z.object({ note: z.string() }),
3749
+ async () => ({ isSuccess: true as const, data: null }),
3750
+ { access: { roles: ["Admin"] } },
3751
+ );
3752
+ r.screen({
3753
+ id: "product-edit",
3754
+ type: "entityEdit",
3755
+ entity: "product",
3756
+ layout: { sections: [{ title: "Basics", fields: ["name"] }] },
3757
+ actions: [
3758
+ { kind: "drawer", id: "add-note", label: "actions.addNote", screen: "note-form" },
3759
+ ],
3760
+ });
3761
+ r.screen({
3762
+ id: "note-form",
3763
+ type: "actionForm",
3764
+ handler: "shop:write:add-note",
3765
+ fields: { note: { type: "text" } } as never,
3766
+ layout: { sections: [{ fields: ["note"] }] },
3767
+ });
3768
+ });
3769
+ expect(() => validateBoot([feature])).not.toThrow();
3770
+ });
3771
+
3772
+ test('projectionDetail relatedList section rowAction drawer-target unknown → throw with actionLabel "rowAction"', () => {
3773
+ const feature = defineFeature("shop", (r) => {
3774
+ r.screen({
3775
+ id: "order-detail",
3776
+ type: "projectionDetail",
3777
+ query: "shop:query:order-detail",
3778
+ layout: {
3779
+ sections: [
3780
+ {
3781
+ kind: "relatedList",
3782
+ title: "Positions",
3783
+ query: "shop:query:order-positions",
3784
+ columns: [{ field: "name" }],
3785
+ rowActions: [
3786
+ {
3787
+ kind: "drawer",
3788
+ id: "add-note",
3789
+ label: "actions.addNote",
3790
+ screen: "ghost-form",
3791
+ },
3792
+ ],
3793
+ },
3794
+ ],
3795
+ },
3796
+ });
3797
+ });
3798
+ expect(() => validateBoot([feature])).toThrow(
3799
+ /rowAction "add-note" drawer-target "ghost-form" does not resolve to a registered screen/,
3800
+ );
3801
+ });
3802
+ });
3803
+
3699
3804
  // --- Screen short-id collision across features ---
3700
3805
  describe("screen short-id collisions across features", () => {
3701
3806
  test("two features registering the same short screen-id → Throw", () => {
@@ -4112,6 +4217,149 @@ describe("boot-validator", () => {
4112
4217
  });
4113
4218
  });
4114
4219
 
4220
+ // --- entityList rowAction drawer (fw#2710) ---
4221
+ describe("entityList rowAction drawer (fw#2710)", () => {
4222
+ function makeFeature(opts: {
4223
+ readonly targetId?: string;
4224
+ readonly targetType?: "actionForm" | "entityList";
4225
+ }) {
4226
+ const targetId = opts.targetId ?? "restock-form";
4227
+ return defineFeature("shop", (r) => {
4228
+ r.entity("product", createEntity({ fields: { name: createTextField() } }));
4229
+ r.screen({
4230
+ id: "product-list",
4231
+ type: "entityList",
4232
+ entity: "product",
4233
+ columns: ["name"],
4234
+ rowActions: [
4235
+ { kind: "drawer", id: "open-drawer", label: "actions.restock", screen: targetId },
4236
+ ],
4237
+ });
4238
+ if (opts.targetType === "entityList") {
4239
+ r.screen({
4240
+ id: targetId,
4241
+ type: "entityList",
4242
+ entity: "product",
4243
+ columns: ["name"],
4244
+ });
4245
+ return;
4246
+ }
4247
+ if (opts.targetType === "actionForm") {
4248
+ r.writeHandler(
4249
+ "restock",
4250
+ z.object({ qty: z.number() }),
4251
+ async () => ({ isSuccess: true as const, data: null }),
4252
+ { access: { roles: ["Admin"] } },
4253
+ );
4254
+ r.screen({
4255
+ id: targetId,
4256
+ type: "actionForm",
4257
+ handler: "shop:write:restock",
4258
+ fields: { qty: { type: "number" } } as never,
4259
+ layout: { sections: [{ fields: ["qty"] }] },
4260
+ });
4261
+ }
4262
+ });
4263
+ }
4264
+
4265
+ test("drawer-target → registered actionForm → no throw", () => {
4266
+ expect(() => validateBoot([makeFeature({ targetType: "actionForm" })])).not.toThrow();
4267
+ });
4268
+
4269
+ test("drawer-target → unknown → throw with a clear message", () => {
4270
+ expect(() => validateBoot([makeFeature({ targetId: "ghost-form" })])).toThrow(
4271
+ /rowAction "open-drawer" drawer-target "ghost-form" does not resolve to a registered screen/,
4272
+ );
4273
+ });
4274
+
4275
+ test("drawer-target → screen exists but is not an actionForm → throw with a clear message", () => {
4276
+ expect(() =>
4277
+ validateBoot([makeFeature({ targetId: "product-list-2", targetType: "entityList" })]),
4278
+ ).toThrow(
4279
+ /rowAction "open-drawer" drawer-target "product-list-2" is a "entityList" screen, not an actionForm/,
4280
+ );
4281
+ });
4282
+ });
4283
+
4284
+ // --- projectionList rowAction drawer + params prefill (fw#2710) ---
4285
+ // projectionList is the second list call site; its rowActions run through a
4286
+ // different branch of validateScreens than entityList's. The params cases
4287
+ // pin the prefill check: a key the target actionForm doesn't declare is
4288
+ // dropped by the renderer, so it has to fail at boot instead.
4289
+ describe("projectionList rowAction drawer (fw#2710)", () => {
4290
+ function makeFeature(opts: {
4291
+ readonly targetId?: string;
4292
+ readonly params?: RowFieldExtractor;
4293
+ }) {
4294
+ const targetId = opts.targetId ?? "restock-form";
4295
+ return defineFeature("shop", (r) => {
4296
+ r.queryHandler("products", z.object({}), async () => ({ rows: [], nextCursor: null }), {
4297
+ access: { openToAll: true },
4298
+ });
4299
+ r.writeHandler(
4300
+ "restock",
4301
+ z.object({ qty: z.number() }),
4302
+ async () => ({ isSuccess: true as const, data: null }),
4303
+ { access: { roles: ["Admin"] } },
4304
+ );
4305
+ r.screen({
4306
+ id: "product-projection",
4307
+ type: "projectionList",
4308
+ query: "shop:query:products",
4309
+ columns: ["name"],
4310
+ rowActions: [
4311
+ {
4312
+ kind: "drawer",
4313
+ id: "open-drawer",
4314
+ label: "actions.restock",
4315
+ screen: targetId,
4316
+ ...(opts.params !== undefined && { params: opts.params }),
4317
+ },
4318
+ ],
4319
+ });
4320
+ r.screen({
4321
+ id: "restock-form",
4322
+ type: "actionForm",
4323
+ handler: "shop:write:restock",
4324
+ fields: { qty: { type: "number" } } as never,
4325
+ layout: { sections: [{ fields: ["qty"] }] },
4326
+ });
4327
+ });
4328
+ }
4329
+
4330
+ test("drawer-target → registered actionForm → no throw", () => {
4331
+ expect(() => validateBoot([makeFeature({})])).not.toThrow();
4332
+ });
4333
+
4334
+ test("drawer-target → unknown → throw with a clear message", () => {
4335
+ expect(() => validateBoot([makeFeature({ targetId: "ghost-form" })])).toThrow(
4336
+ /rowAction "open-drawer" drawer-target "ghost-form" does not resolve to a registered screen/,
4337
+ );
4338
+ });
4339
+
4340
+ test("params pick a field the target actionForm declares → no throw", () => {
4341
+ expect(() => validateBoot([makeFeature({ params: { pick: ["qty"] } })])).not.toThrow();
4342
+ });
4343
+
4344
+ test("params pick a field the target actionForm does not declare → throw", () => {
4345
+ expect(() => validateBoot([makeFeature({ params: { pick: ["nope"] } })])).toThrow(
4346
+ /rowAction "open-drawer" params prefills "nope", which drawer-target "restock-form" does not declare as a field/,
4347
+ );
4348
+ });
4349
+
4350
+ test("params map to a target key the actionForm does not declare → throw", () => {
4351
+ expect(() =>
4352
+ validateBoot([makeFeature({ params: { map: { nope: "productQty" } } })]),
4353
+ ).toThrow(/params prefills "nope", which drawer-target "restock-form" does not declare/);
4354
+ });
4355
+
4356
+ test("params map to a declared target key → no throw", () => {
4357
+ expect(() =>
4358
+ validateBoot([makeFeature({ params: { map: { qty: "productQty" } } })]),
4359
+ ).not.toThrow();
4360
+ });
4361
+ });
4362
+
4115
4363
  // --- defaultSort funktioniert für ALLE Field-Types die sortable
4116
4364
  // unterstützen (Tier 2.6b Field-Erweiterung) ---
4117
4365
  // Vor Tier 2.6b war `sortable` nur auf TextFieldDef. Erweitert auf
@@ -0,0 +1,158 @@
1
+ // fw#2650 — crossTenant on write handlers. A SystemAdmin operator writes a
2
+ // row that lives in a different tenant than their own session. That needs
3
+ // two things: (1) an unfiltered db instead of the caller's tenant-scoped
4
+ // one (so the row is even visible), and (2) the event-store stream AND the
5
+ // entity's tenant-based write-ownership rule — both keyed off the ACTING
6
+ // user, not the db — so the acting user's tenantId must be rewritten to the
7
+ // target row's tenant before the write, not just handed an unfiltered db.
8
+ //
9
+ // Two stacks on the SAME Postgres database: one registers the update
10
+ // handler without `crossTenant`, the other with it — a clean A/B on the one
11
+ // flag, no handler-name collisions to work around.
12
+
13
+ import { afterAll, beforeAll, describe, expect, test } from "bun:test";
14
+ import { selectMany } from "../../db/query";
15
+ import { buildEntityTable } from "../../db/table-builder";
16
+ import { eventsTable } from "../../event-store";
17
+ import {
18
+ setupTestStack,
19
+ type TestStack,
20
+ TestUsers,
21
+ testTenantId,
22
+ unsafeCreateEntityTable,
23
+ } from "../../stack";
24
+ import {
25
+ createEntity,
26
+ createTextField,
27
+ defineEntityCreateHandler,
28
+ defineEntityUpdateHandler,
29
+ defineFeature,
30
+ from,
31
+ } from "../index";
32
+
33
+ // "Admin" passes unconditionally (any tenant may create its own rows).
34
+ // "SystemAdmin" is tenant-scoped — this is the entity-level rule the
35
+ // crossTenant acting-user rewrite must satisfy for the operator write to
36
+ // go through; without the rewrite the acting user's own tenant would never
37
+ // match a foreign row's tenantId here.
38
+ const thingEntity = createEntity({
39
+ table: "ctwrite_things",
40
+ fields: {
41
+ label: createTextField({ required: true }),
42
+ },
43
+ access: { write: { Admin: "all", SystemAdmin: from("user:tenantId", "tenantId") } },
44
+ });
45
+ const thingTable = buildEntityTable("thing", thingEntity);
46
+
47
+ function buildFeature(crossTenant: boolean) {
48
+ return defineFeature("ctwrite", (r) => {
49
+ r.entity("thing", thingEntity);
50
+ r.writeHandler(
51
+ defineEntityCreateHandler("thing", thingEntity, {
52
+ access: { roles: ["Admin", "SystemAdmin"] },
53
+ }),
54
+ );
55
+ r.writeHandler(
56
+ defineEntityUpdateHandler("thing", thingEntity, {
57
+ access: { roles: ["SystemAdmin"] },
58
+ ...(crossTenant && { crossTenant: true }),
59
+ }),
60
+ );
61
+ });
62
+ }
63
+
64
+ const dbName = `kumiko_test_ctwrite_${crypto.randomUUID().replace(/-/g, "").slice(0, 8)}`;
65
+
66
+ let tenantScopedStack: TestStack;
67
+ let crossTenantStack: TestStack;
68
+
69
+ beforeAll(async () => {
70
+ tenantScopedStack = await setupTestStack({ features: [buildFeature(false)], dbName });
71
+ await unsafeCreateEntityTable(tenantScopedStack.db, thingEntity, "thing");
72
+ // Second connection to the SAME database — persistentDb:true means its
73
+ // cleanup() only closes the pool; tenantScopedStack (created without that
74
+ // flag) owns the actual DROP DATABASE and must clean up last.
75
+ crossTenantStack = await setupTestStack({
76
+ features: [buildFeature(true)],
77
+ dbName,
78
+ persistentDb: true,
79
+ });
80
+ });
81
+
82
+ afterAll(async () => {
83
+ await crossTenantStack.cleanup();
84
+ await tenantScopedStack.cleanup();
85
+ });
86
+
87
+ async function updatedEventTenantId(aggregateId: string): Promise<string | undefined> {
88
+ const rows = await selectMany<{ type: string; tenantId: string }>(
89
+ tenantScopedStack.db,
90
+ eventsTable,
91
+ {
92
+ aggregateId,
93
+ },
94
+ );
95
+ const updatedRow = rows.find((r) => r.type.includes("updated"));
96
+ return updatedRow?.tenantId;
97
+ }
98
+
99
+ describe("entity write handlers: crossTenant option (fw#2650)", () => {
100
+ test("crossTenant handler: SystemAdmin updates a row owned by another tenant", async () => {
101
+ const created = await tenantScopedStack.http.writeOk<{ id: string }>(
102
+ "ctwrite:write:thing:create",
103
+ { label: "foreign" },
104
+ TestUsers.otherTenant,
105
+ );
106
+
107
+ const updated = await crossTenantStack.http.writeOk<{ id: string; data: { label: string } }>(
108
+ "ctwrite:write:thing:update",
109
+ { id: created.id, version: 1, changes: { label: "touched by operator" } },
110
+ TestUsers.systemAdmin,
111
+ );
112
+ expect(updated.data.label).toBe("touched by operator");
113
+
114
+ // Row stays on its original tenant — crossTenant reaches across the
115
+ // tenant boundary, it does not migrate the row.
116
+ const rows = await selectMany(tenantScopedStack.db, thingTable, { id: created.id });
117
+ expect(rows[0]?.["tenantId"]).toBe(testTenantId(2));
118
+
119
+ // The append must land on the row's own tenant stream, not the acting
120
+ // (operator) user's — otherwise the append targets an empty stream in
121
+ // the operator's tenant and the update would have failed above with a
122
+ // bogus version_conflict rather than reaching this point at all.
123
+ expect(await updatedEventTenantId(created.id)).toBe(testTenantId(2));
124
+ });
125
+
126
+ test("normal (non-crossTenant) handler cannot reach a row in another tenant", async () => {
127
+ const created = await tenantScopedStack.http.writeOk<{ id: string }>(
128
+ "ctwrite:write:thing:create",
129
+ { label: "foreign-untouched" },
130
+ TestUsers.otherTenant,
131
+ );
132
+
133
+ const err = await tenantScopedStack.http.writeErr(
134
+ "ctwrite:write:thing:update",
135
+ { id: created.id, version: 1, changes: { label: "nope" } },
136
+ TestUsers.systemAdmin,
137
+ );
138
+ expect(err.code).toBe("not_found");
139
+
140
+ const rows = await selectMany(tenantScopedStack.db, thingTable, { id: created.id });
141
+ expect(rows[0]?.["label"]).toBe("foreign-untouched");
142
+ });
143
+
144
+ test("crossTenant still gates on access — a role without SystemAdmin is denied", async () => {
145
+ const created = await tenantScopedStack.http.writeOk<{ id: string }>(
146
+ "ctwrite:write:thing:create",
147
+ { label: "foreign-denied" },
148
+ TestUsers.otherTenant,
149
+ );
150
+
151
+ const err = await crossTenantStack.http.writeErr(
152
+ "ctwrite:write:thing:update",
153
+ { id: created.id, version: 1, changes: { label: "nope" } },
154
+ TestUsers.admin,
155
+ );
156
+ expect(err.code).toBe("access_denied");
157
+ });
158
+ });
@@ -367,18 +367,25 @@ function validateScreenNavTarget(
367
367
  // validateScreens below), the drawer mounts the target inline using this
368
368
  // feature's schema, so a cross-feature reference could never actually
369
369
  // render. Two distinct failure messages: dangling reference vs. wrong
370
- // screen type (fw#2225).
371
- function validateToolbarDrawerAction(
370
+ // screen type. Shared by ToolbarAction (fw#2225) and RowAction (fw#2710)
371
+ // drawer variants — same target-resolution rule, only the label in the
372
+ // error message ("toolbarAction" vs. "rowAction" vs. "action") differs.
373
+ function validateDrawerTargetAction(
372
374
  featureName: string,
373
375
  screenId: string,
374
376
  screenKind: string,
375
- action: Extract<ToolbarAction, { kind: "drawer" }>,
377
+ actionLabel: "toolbarAction" | "rowAction" | "action",
378
+ action: {
379
+ readonly id: string;
380
+ readonly screen: string;
381
+ readonly params?: RowFieldExtractor;
382
+ },
376
383
  screens: FeatureDefinition["screens"],
377
384
  ): void {
378
385
  const target = screens[action.screen];
379
386
  if (target === undefined) {
380
387
  throw new Error(
381
- `[Feature ${featureName}] Screen "${screenId}" (${screenKind}) toolbarAction "${action.id}" ` +
388
+ `[Feature ${featureName}] Screen "${screenId}" (${screenKind}) ${actionLabel} "${action.id}" ` +
382
389
  `drawer-target "${action.screen}" does not resolve to a registered screen in this feature. ` +
383
390
  `kind:"drawer" only resolves same-feature screens (unlike kind:"navigate", which can target ` +
384
391
  `screens in any feature) — the drawer mounts the target inline using this feature's schema. ` +
@@ -387,12 +394,28 @@ function validateToolbarDrawerAction(
387
394
  }
388
395
  if (target.type !== "actionForm") {
389
396
  throw new Error(
390
- `[Feature ${featureName}] Screen "${screenId}" (${screenKind}) toolbarAction "${action.id}" ` +
397
+ `[Feature ${featureName}] Screen "${screenId}" (${screenKind}) ${actionLabel} "${action.id}" ` +
391
398
  `drawer-target "${action.screen}" is a "${target.type}" screen, not an actionForm. ` +
392
399
  `kind:"drawer" mounts an actionForm inside a Drawer widget — point "screen" at an ` +
393
400
  `actionForm screen, or use kind:"navigate" for a full-page target.`,
394
401
  );
395
402
  }
403
+ // A prefill key the target form doesn't declare is dropped silently by the
404
+ // renderer (mergeSearchParamsIntoInitial iterates the target's fields), so
405
+ // the typo would only show up as an empty field at click time.
406
+ // skip: no params extractor — nothing to check against the target's fields.
407
+ if (action.params === undefined) return;
408
+ const prefilledFields =
409
+ "pick" in action.params ? action.params.pick : Object.keys(action.params.map);
410
+ for (const fieldName of prefilledFields) {
411
+ if (Object.hasOwn(target.fields, fieldName)) continue;
412
+ throw new Error(
413
+ `[Feature ${featureName}] Screen "${screenId}" (${screenKind}) ${actionLabel} "${action.id}" ` +
414
+ `params prefills "${fieldName}", which drawer-target "${action.screen}" does not declare as a ` +
415
+ `field — the renderer would drop it and leave the form empty. Target fields: ` +
416
+ `${Object.keys(target.fields).sort().join(", ") || "(none)"}.`,
417
+ );
418
+ }
396
419
  }
397
420
 
398
421
  // fw#2228: a navigate rowAction (or projectionDetail header action, which
@@ -590,6 +613,15 @@ export function validateScreens(
590
613
  action,
591
614
  target,
592
615
  );
616
+ } else if (action.kind === "drawer") {
617
+ validateDrawerTargetAction(
618
+ feature.name,
619
+ screenId,
620
+ "projectionList",
621
+ "rowAction",
622
+ action,
623
+ feature.screens,
624
+ );
593
625
  }
594
626
  }
595
627
  validateAtMostOneRowClick(feature.name, screenId, "projectionList", screen.rowActions);
@@ -600,10 +632,11 @@ export function validateScreens(
600
632
  if (screen.toolbarActions !== undefined) {
601
633
  for (const action of screen.toolbarActions) {
602
634
  if (action.kind === "drawer") {
603
- validateToolbarDrawerAction(
635
+ validateDrawerTargetAction(
604
636
  feature.name,
605
637
  screenId,
606
638
  "projectionList",
639
+ "toolbarAction",
607
640
  action,
608
641
  feature.screens,
609
642
  );
@@ -779,6 +812,15 @@ export function validateScreens(
779
812
  action,
780
813
  target,
781
814
  );
815
+ } else if (action.kind === "drawer") {
816
+ validateDrawerTargetAction(
817
+ feature.name,
818
+ screenId,
819
+ "projectionDetail",
820
+ "rowAction",
821
+ action,
822
+ feature.screens,
823
+ );
782
824
  } else if (!allWriteHandlerQns.has(action.handler)) {
783
825
  throw new Error(
784
826
  `[Feature ${feature.name}] Screen "${screenId}" (projectionDetail) section ` +
@@ -886,6 +928,15 @@ export function validateScreens(
886
928
  action,
887
929
  target,
888
930
  );
931
+ } else if (action.kind === "drawer") {
932
+ validateDrawerTargetAction(
933
+ feature.name,
934
+ screenId,
935
+ "projectionDetail",
936
+ "action",
937
+ action,
938
+ feature.screens,
939
+ );
889
940
  } else {
890
941
  if (!allWriteHandlerQns.has(action.handler)) {
891
942
  throw new Error(
@@ -1356,6 +1407,15 @@ export function validateScreens(
1356
1407
  action,
1357
1408
  target,
1358
1409
  );
1410
+ } else if (action.kind === "drawer") {
1411
+ validateDrawerTargetAction(
1412
+ feature.name,
1413
+ screenId,
1414
+ "entityList",
1415
+ "rowAction",
1416
+ action,
1417
+ feature.screens,
1418
+ );
1359
1419
  } else {
1360
1420
  if (!allWriteHandlerQns.has(action.handler)) {
1361
1421
  throw new Error(
@@ -1391,10 +1451,11 @@ export function validateScreens(
1391
1451
  );
1392
1452
  }
1393
1453
  } else if (action.kind === "drawer") {
1394
- validateToolbarDrawerAction(
1454
+ validateDrawerTargetAction(
1395
1455
  feature.name,
1396
1456
  screenId,
1397
1457
  "entityList",
1458
+ "toolbarAction",
1398
1459
  action,
1399
1460
  feature.screens,
1400
1461
  );
@@ -1507,6 +1568,15 @@ export function validateScreens(
1507
1568
  action,
1508
1569
  target,
1509
1570
  );
1571
+ } else if (action.kind === "drawer") {
1572
+ validateDrawerTargetAction(
1573
+ feature.name,
1574
+ screenId,
1575
+ "entityEdit",
1576
+ "action",
1577
+ action,
1578
+ feature.screens,
1579
+ );
1510
1580
  } else {
1511
1581
  if (!allWriteHandlerQns.has(action.handler)) {
1512
1582
  throw new Error(
@@ -17,6 +17,7 @@ import type {
17
17
  EntityDefinition,
18
18
  HandlerContext,
19
19
  QueryHandlerDef,
20
+ SessionUser,
20
21
  WriteHandlerDef,
21
22
  } from "./types";
22
23
  import type {
@@ -190,12 +191,38 @@ export function defineEntityWriteHandler(
190
191
  // acknowledges cross-tenant access on the feature's behalf. This is
191
192
  // behavior-preserving: ctx.db was already an unfiltered "system"-mode
192
193
  // TenantDb for these handlers before the ctx.db cutover.
193
- const dbFor = (ctx: HandlerContext): TenantDb =>
194
- ctx.systemDb
195
- ? ctx.systemDb.acknowledgeCrossTenant(
196
- `entity convention handler for r.systemScope() feature (${name})`,
197
- )
198
- : ctx.db;
194
+ //
195
+ // options.crossTenant mirrors the query-handler branch below: this ONE
196
+ // write handler reads/writes across every tenant without making the
197
+ // whole feature r.systemScope().
198
+ const crossTenant = options?.crossTenant === true;
199
+
200
+ const dbFor = (ctx: HandlerContext): TenantDb => {
201
+ if (ctx.systemDb) {
202
+ return ctx.systemDb.acknowledgeCrossTenant(
203
+ `entity convention handler for r.systemScope() feature (${name})`,
204
+ );
205
+ }
206
+ return crossTenant ? createTenantDb(ctx.db.raw, ctx.db.tenantId, "system") : ctx.db;
207
+ };
208
+
209
+ // The event stream is keyed by the acting user's tenantId (streamTenantFor in
210
+ // event-store-executor-context.ts), not by the db handed to the executor — an
211
+ // unfiltered "system"-mode db alone reads the foreign row but appends to an
212
+ // empty stream in the operator's own tenant, surfacing as a bogus
213
+ // version_conflict. Rewriting the acting tenant also keeps PII envelope keys
214
+ // derived from the row's tenant rather than the operator's.
215
+ const actingUserFor = async (
216
+ user: SessionUser,
217
+ id: unknown,
218
+ db: TenantDb,
219
+ ): Promise<SessionUser> => {
220
+ if (!crossTenant || entity.systemStream) return user;
221
+ const row = await db.fetchOne<Record<string, unknown>>(table, { id });
222
+ const rowTenantId = row?.["tenantId"];
223
+ if (typeof rowTenantId !== "string" || rowTenantId === user.tenantId) return user;
224
+ return { ...user, tenantId: rowTenantId };
225
+ };
199
226
 
200
227
  let schema: ZodType;
201
228
  let handler: WriteHandlerDef["handler"];
@@ -220,29 +247,40 @@ export function defineEntityWriteHandler(
220
247
  });
221
248
  handler = async (event, ctx) => {
222
249
  const { runPreSave } = ctx;
250
+ const db = dbFor(ctx);
251
+ const payload = event.payload as UpdatePayload; // @cast-boundary engine-payload
252
+ const user = await actingUserFor(event.user, payload.id, db);
223
253
  // skipUnchanged (#464): API-driven updates diff against the stored
224
254
  // row so a resubmitted-but-identical field doesn't force a fresh
225
255
  // pii/encrypted ciphertext. Direct executor.update() callers (e.g.
226
256
  // KEK-rotation, the user-data-rights #494 backfill) don't go through
227
257
  // this handler and keep today's always-re-encrypt behavior, which
228
258
  // they rely on to intentionally force a fresh event/ciphertext.
229
- return executor.update(event.payload as UpdatePayload, event.user, dbFor(ctx), {
259
+ return executor.update(payload, user, db, {
230
260
  skipUnchanged: true,
231
261
  preSave:
232
262
  runPreSave &&
233
263
  ((changes, previous, isNew) => runPreSave(event.type, changes, previous, isNew)),
234
- }); // @cast-boundary engine-payload
264
+ });
235
265
  };
236
266
  break;
237
267
  case "delete":
238
268
  schema = idSchema;
239
- handler = async (event, ctx) =>
240
- executor.delete(event.payload as IdPayload, event.user, dbFor(ctx)); // @cast-boundary engine-payload
269
+ handler = async (event, ctx) => {
270
+ const db = dbFor(ctx);
271
+ const payload = event.payload as IdPayload; // @cast-boundary engine-payload
272
+ const user = await actingUserFor(event.user, payload.id, db);
273
+ return executor.delete(payload, user, db);
274
+ };
241
275
  break;
242
276
  case "restore":
243
277
  schema = idSchema;
244
- handler = async (event, ctx) =>
245
- executor.restore(event.payload as IdPayload, event.user, dbFor(ctx)); // @cast-boundary engine-payload
278
+ handler = async (event, ctx) => {
279
+ const db = dbFor(ctx);
280
+ const payload = event.payload as IdPayload; // @cast-boundary engine-payload
281
+ const user = await actingUserFor(event.user, payload.id, db);
282
+ return executor.restore(payload, user, db);
283
+ };
246
284
  break;
247
285
  default:
248
286
  assertUnreachable(verb, "write verb");
@@ -0,0 +1,46 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { readFileSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import { AgentReasons, FrameworkReasons } from "../reasons";
5
+
6
+ type ReasonEntry = { readonly endUser?: string; readonly developer?: string };
7
+
8
+ const LOCALES = ["en", "de"] as const;
9
+ const ALL_REASONS: readonly string[] = [
10
+ ...Object.values(FrameworkReasons),
11
+ ...Object.values(AgentReasons),
12
+ ];
13
+
14
+ function loadLocale(locale: (typeof LOCALES)[number]): Readonly<Record<string, ReasonEntry>> {
15
+ const path = join(import.meta.dirname, "..", "i18n", `${locale}.yaml`);
16
+ // Parse boundary: the YAML shape is the file's documented schema.
17
+ return Bun.YAML.parse(readFileSync(path, "utf-8")) as Readonly<Record<string, ReasonEntry>>;
18
+ }
19
+
20
+ describe("reason i18n", () => {
21
+ for (const locale of LOCALES) {
22
+ test(`${locale}.yaml renders a docs page for every declared reason`, () => {
23
+ const entries = loadLocale(locale);
24
+ for (const reason of ALL_REASONS) {
25
+ const entry = entries[reason];
26
+ // The docgen skips a reason whose entry is missing or has neither
27
+ // text — the page then stays absent from docs.kumiko.rocks.
28
+ expect(entry, `${locale}.yaml has no entry for ${reason}`).toBeDefined();
29
+ expect(entry?.endUser?.trim() ?? "").not.toBe("");
30
+ expect(entry?.developer?.trim() ?? "").not.toBe("");
31
+ }
32
+ });
33
+ }
34
+
35
+ test("agent reasons are flat keys with the dot, not a nested agent map", () => {
36
+ for (const locale of LOCALES) {
37
+ const keys = Object.keys(loadLocale(locale));
38
+ expect(keys).toContain("agent.tool_not_allowed");
39
+ expect(keys).not.toContain("agent");
40
+ }
41
+ });
42
+
43
+ test("de and en cover the same reason keys", () => {
44
+ expect(Object.keys(loadLocale("de")).sort()).toEqual(Object.keys(loadLocale("en")).sort());
45
+ });
46
+ });
@@ -1,7 +1,9 @@
1
- # i18n-Texte für FrameworkReasons (deutsch).
1
+ # i18n-Texte für die Reason-Kataloge aus reasons.ts (deutsch).
2
2
  #
3
- # Schema pro Reason-Key (snake_case, identisch mit Wert in reasons.ts
4
- # FrameworkReasons):
3
+ # Schema pro Reason-Key — der Key ist wörtlich der Reason-Wert, Punkte
4
+ # inklusive: FrameworkReasons bleibt präfixfrei (`stale_state`), AgentReasons
5
+ # behält seinen Namespace innerhalb eines flachen Keys
6
+ # (`agent.tool_not_allowed`), nie als verschachtelte `agent:`-Map:
5
7
  #
6
8
  # <reason_key>:
7
9
  # endUser: |
@@ -81,3 +83,59 @@ feature_disabled:
81
83
  `details.feature` und `details.handler` zeigen welches Feature/Handler.
82
84
  Aktiviere das Feature via Feature-Toggle oder Routing-Config (siehe
83
85
  [Feature-Toggles](/en/feature-reference/feature-toggles/)).
86
+
87
+ agent.tool_not_allowed:
88
+ endUser: |
89
+ Dieses Tool ist im aktuellen Modus nicht verfügbar.
90
+ Stell den Assistenten auf einen Modus um, der diese Aktion abdeckt, oder
91
+ führe sie selbst in der App aus.
92
+ developer: |
93
+ `AccessDeniedError` aus dem Tool-Dispatch des Agenten: das Modell hat einen
94
+ Tool-Namen außerhalb des für diesen Lauf gebauten Katalogs angefragt — ein
95
+ halluzinierter Name, oder ein Handler, den der aktuelle Agent-Modus nicht
96
+ freigibt.
97
+
98
+ Der Katalog wird aus der gemounteten Registry abgeleitet
99
+ (`buildToolCatalog`) — ein Handler ohne `description` wird also gar nicht
100
+ erst zum Tool. Handler beschreiben oder den Agent-Modus weiten, wenn der
101
+ Call funktionieren sollte.
102
+
103
+ agent.iteration_limit:
104
+ endUser: |
105
+ Der Assistent hat die maximale Anzahl an Schritten erreicht, ohne eine
106
+ eindeutige Antwort zu finden.
107
+ Bitte formuliere die Anfrage um oder teile sie in kleinere Schritte.
108
+ developer: |
109
+ `UnprocessableError` aus der Agent-Loop: der Lauf hat sein Runden-Limit
110
+ erreicht, bevor das Modell eine Endantwort produziert hat — meist ein Tool,
111
+ das immer wieder denselben Fehler liefert, oder eine Aufgabe, die mehr
112
+ Schritte braucht als das Limit erlaubt.
113
+
114
+ Runden-Limit für den Lauf anheben oder die Aufgabe verkleinern. Die
115
+ Turn-Historie der Unterhaltung zeigt, welcher Tool-Call sich wiederholt hat.
116
+
117
+ agent.permission_denied:
118
+ endUser: |
119
+ Dir fehlt die Berechtigung für diese Aktion.
120
+ Der Assistent darf nur das, was dein eigener Account darf.
121
+ developer: |
122
+ `AccessDeniedError` aus dem Tool-Dispatch des Agenten: das Tool steht im
123
+ Katalog, aber die Rollen des Aufrufers decken den dahinterliegenden Handler
124
+ nicht ab. Der Agent läuft strikt mit den Rechten des aufrufenden Users — er
125
+ eskaliert nie.
126
+
127
+ Rolle am Handler ergänzen, oder hinnehmen, dass dieses Tool für diesen User
128
+ nicht verfügbar ist.
129
+
130
+ agent.high_risk_no_always:
131
+ endUser: |
132
+ Für risikoreiche Aktionen kann „immer erlauben“ nicht gesetzt werden.
133
+ Jeder Lauf dieser Aktion braucht deine ausdrückliche Bestätigung.
134
+ developer: |
135
+ `AccessDeniedError` aus dem Risiko-Gate des Agenten: ein Handler mit
136
+ `agent: { risk: "high" }` wurde ohne die ausdrückliche Einzel-Bestätigung
137
+ aufgerufen, die solche Handler verlangen, oder es sollte eine
138
+ „immer erlauben“-Regel dafür gesetzt werden.
139
+
140
+ Deklariertes Risiko senken, wenn die Aktion wirklich Routine ist —
141
+ ansonsten ist der Bestätigungsdialog genau das gewollte Verhalten.
@@ -1,7 +1,9 @@
1
- # i18n texts for FrameworkReasons (english).
1
+ # i18n texts for the reason catalogs in reasons.ts (english).
2
2
  #
3
- # Schema per reason key (snake_case, matches the value in
4
- # reasons.ts FrameworkReasons):
3
+ # Schema per reason key — the key is the reason value verbatim, dots
4
+ # included: FrameworkReasons stays prefix-free (`stale_state`), AgentReasons
5
+ # keeps its namespace inside a flat key (`agent.tool_not_allowed`), never a
6
+ # nested `agent:` map:
5
7
  #
6
8
  # <reason_key>:
7
9
  # endUser: |
@@ -78,3 +80,56 @@ feature_disabled:
78
80
  `details.feature` and `details.handler` indicate which feature and
79
81
  handler. Enable the feature via feature toggle or routing config (see
80
82
  [Feature Toggles](/en/feature-reference/feature-toggles/)).
83
+
84
+ agent.tool_not_allowed:
85
+ endUser: |
86
+ This tool is not available in the current mode.
87
+ Switch the assistant to a mode that covers this action, or do it yourself
88
+ in the app.
89
+ developer: |
90
+ `AccessDeniedError` from the agent's tool dispatch: the model asked for a
91
+ tool name outside the catalog built for this run — a hallucinated name, or
92
+ a handler the current agent mode doesn't expose.
93
+
94
+ The catalog is derived from the mounted registry (`buildToolCatalog`), so a
95
+ handler without a `description` never becomes a tool at all. Describe the
96
+ handler, or widen the agent mode, if the call was meant to work.
97
+
98
+ agent.iteration_limit:
99
+ endUser: |
100
+ The assistant reached the maximum number of steps without a clear answer.
101
+ Please rephrase the request or split it into smaller steps.
102
+ developer: |
103
+ `UnprocessableError` from the agent loop: the run hit its maximum round
104
+ count before the model produced a final answer — usually a tool that keeps
105
+ returning the same error, or a task that needs more steps than the limit
106
+ allows.
107
+
108
+ Raise the round limit for the run, or narrow the task. The conversation's
109
+ turn history shows which tool call kept repeating.
110
+
111
+ agent.permission_denied:
112
+ endUser: |
113
+ You don't have permission for this action.
114
+ The assistant can only do what your own account is allowed to do.
115
+ developer: |
116
+ `AccessDeniedError` from the agent's tool dispatch: the tool exists in the
117
+ catalog, but the caller's roles don't cover the handler behind it. The
118
+ agent runs strictly with the calling user's permissions — it never
119
+ escalates.
120
+
121
+ Grant the role on the handler, or accept that this tool stays unavailable
122
+ for that user.
123
+
124
+ agent.high_risk_no_always:
125
+ endUser: |
126
+ High-risk actions can't be set to "always allow".
127
+ Every run of this action needs your explicit confirmation.
128
+ developer: |
129
+ `AccessDeniedError` from the agent's risk gate: a handler declared
130
+ `agent: { risk: "high" }` was invoked without the explicit per-call
131
+ confirmation such handlers require, or an "always allow" rule was requested
132
+ for one.
133
+
134
+ Lower the declared risk if the action really is routine; otherwise the
135
+ confirmation prompt is the intended behaviour.
@@ -89,6 +89,7 @@ export type {
89
89
  ProjectionDetailScreenDefinition,
90
90
  ProjectionListScreenDefinition,
91
91
  RowAction,
92
+ RowActionDrawer,
92
93
  RowActionNavigate,
93
94
  RowActionWriteHandler,
94
95
  RowFieldExtractor,