@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 +3 -3
- package/src/engine/__tests__/boot-validator.test.ts +248 -0
- package/src/engine/__tests__/entity-write-crosstenant.integration.test.ts +158 -0
- package/src/engine/boot-validator/screens.ts +77 -7
- package/src/engine/entity-handlers.ts +50 -12
- package/src/errors/__tests__/reason-i18n.test.ts +46 -0
- package/src/errors/i18n/de.yaml +61 -3
- package/src/errors/i18n/en.yaml +58 -3
- package/src/ui-types/index.ts +1 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-framework",
|
|
3
|
-
"version": "0.
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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})
|
|
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})
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
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(
|
|
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
|
-
});
|
|
264
|
+
});
|
|
235
265
|
};
|
|
236
266
|
break;
|
|
237
267
|
case "delete":
|
|
238
268
|
schema = idSchema;
|
|
239
|
-
handler = async (event, ctx) =>
|
|
240
|
-
|
|
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
|
-
|
|
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
|
+
});
|
package/src/errors/i18n/de.yaml
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
|
-
# i18n-Texte für
|
|
1
|
+
# i18n-Texte für die Reason-Kataloge aus reasons.ts (deutsch).
|
|
2
2
|
#
|
|
3
|
-
# Schema pro Reason-Key
|
|
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.
|
package/src/errors/i18n/en.yaml
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
|
-
# i18n texts for
|
|
1
|
+
# i18n texts for the reason catalogs in reasons.ts (english).
|
|
2
2
|
#
|
|
3
|
-
# Schema per reason key
|
|
4
|
-
#
|
|
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.
|