@cosmicdrift/kumiko-bundled-features 0.321.0 → 0.322.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 +9 -9
- package/src/agent-tools/__tests__/tool-catalog.test.ts +16 -1
- package/src/agent-tools/__tests__/tool-dispatch.integration.test.ts +130 -3
- package/src/agent-tools/agent-manifest.ts +13 -3
- package/src/agent-tools/tool-dispatch.ts +17 -12
- package/src/crypto-shredding/handlers/forget-subject.write.ts +3 -1
- package/src/tenant/__tests__/members-facet-i18n.test.ts +3 -2
- package/src/tenant-settings/__tests__/settings-hub-i18n.test.ts +6 -10
- package/src/user-data-rights/handlers/run-forget-cleanup.write.ts +3 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-bundled-features",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.322.0",
|
|
4
4
|
"description": "Built-in features — tenant, user, auth, delivery. The stuff you'd rewrite anyway, already typed.",
|
|
5
5
|
"license": "BUSL-1.1",
|
|
6
6
|
"author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
|
|
@@ -134,12 +134,12 @@
|
|
|
134
134
|
"./workflow-runner": "./src/workflow-runner/index.ts"
|
|
135
135
|
},
|
|
136
136
|
"dependencies": {
|
|
137
|
-
"@cosmicdrift/kumiko-dispatcher-live": "0.
|
|
138
|
-
"@cosmicdrift/kumiko-framework": "0.
|
|
139
|
-
"@cosmicdrift/kumiko-headless": "0.
|
|
140
|
-
"@cosmicdrift/kumiko-renderer": "0.
|
|
141
|
-
"@cosmicdrift/kumiko-renderer-web": "0.
|
|
142
|
-
"@cosmicdrift/kumiko-types": "0.
|
|
137
|
+
"@cosmicdrift/kumiko-dispatcher-live": "0.322.0",
|
|
138
|
+
"@cosmicdrift/kumiko-framework": "0.322.0",
|
|
139
|
+
"@cosmicdrift/kumiko-headless": "0.322.0",
|
|
140
|
+
"@cosmicdrift/kumiko-renderer": "0.322.0",
|
|
141
|
+
"@cosmicdrift/kumiko-renderer-web": "0.322.0",
|
|
142
|
+
"@cosmicdrift/kumiko-types": "0.322.0",
|
|
143
143
|
"@mollie/api-client": "^4.5.0",
|
|
144
144
|
"@node-rs/argon2": "^2.0.2",
|
|
145
145
|
"@types/mailparser": "^3.4.6",
|
|
@@ -168,8 +168,8 @@
|
|
|
168
168
|
],
|
|
169
169
|
"devDependencies": {
|
|
170
170
|
"@testing-library/user-event": "^14.6.1",
|
|
171
|
-
"@cosmicdrift/kumiko-locale-de": "0.
|
|
172
|
-
"@cosmicdrift/kumiko-locale-es": "0.
|
|
171
|
+
"@cosmicdrift/kumiko-locale-de": "0.322.0",
|
|
172
|
+
"@cosmicdrift/kumiko-locale-es": "0.322.0",
|
|
173
173
|
"jsqr": "^1.4.0"
|
|
174
174
|
}
|
|
175
175
|
}
|
|
@@ -8,7 +8,7 @@ import {
|
|
|
8
8
|
} from "@cosmicdrift/kumiko-framework/engine";
|
|
9
9
|
import * as z from "zod";
|
|
10
10
|
import { buildAgentManifest } from "../agent-manifest";
|
|
11
|
-
import { buildToolCatalog, toolNameForQn } from "../tool-catalog";
|
|
11
|
+
import { buildToolCatalog, OPEN_FORM_TOOL_NAME, toolNameForQn } from "../tool-catalog";
|
|
12
12
|
import type { AgentManifest, AgentToolMode, RegistrySearchView } from "../types";
|
|
13
13
|
|
|
14
14
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
@@ -440,6 +440,21 @@ describe("buildToolCatalog — built-in client tool names vs. a colliding handle
|
|
|
440
440
|
});
|
|
441
441
|
});
|
|
442
442
|
|
|
443
|
+
describe("buildToolCatalog — tools/dispatchTable bijection", () => {
|
|
444
|
+
test("every tool name has exactly one dispatchTable entry, and vice versa, with no duplicates", () => {
|
|
445
|
+
const catalog = buildCatalog(ADMIN);
|
|
446
|
+
const names = catalog.tools.map((t) => t.name);
|
|
447
|
+
expect(names.length).toBeGreaterThan(0);
|
|
448
|
+
expect(new Set(names).size).toBe(names.length);
|
|
449
|
+
// open_form is the one deliberate exception: dispatch-only, claimed via
|
|
450
|
+
// claimDispatchName so the approval layer can trigger it, but never
|
|
451
|
+
// listed for the model to call directly (see addClientTools).
|
|
452
|
+
const dispatchNamesForModel = new Set(catalog.dispatchTable.keys());
|
|
453
|
+
dispatchNamesForModel.delete(OPEN_FORM_TOOL_NAME);
|
|
454
|
+
expect(dispatchNamesForModel).toEqual(new Set(names));
|
|
455
|
+
});
|
|
456
|
+
});
|
|
457
|
+
|
|
443
458
|
describe("toolNameForQn", () => {
|
|
444
459
|
test("drops the verb segment, joins with underscores, sanitizes non-identifier characters", () => {
|
|
445
460
|
expect(toolNameForQn("agent-tools-test-vendor:write:vendor:approve")).toBe(
|
|
@@ -8,15 +8,21 @@
|
|
|
8
8
|
|
|
9
9
|
import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
|
|
10
10
|
import { asRawClient } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
11
|
-
import {
|
|
11
|
+
import {
|
|
12
|
+
createEntity,
|
|
13
|
+
createEntityExecutor,
|
|
14
|
+
createTextField,
|
|
15
|
+
defineFeature,
|
|
16
|
+
} from "@cosmicdrift/kumiko-framework/engine";
|
|
12
17
|
import {
|
|
13
18
|
createTestUser,
|
|
14
19
|
setupTestStack,
|
|
15
20
|
type TestStack,
|
|
16
21
|
unsafeCreateEntityTable,
|
|
17
22
|
} from "@cosmicdrift/kumiko-framework/stack";
|
|
23
|
+
import * as z from "zod";
|
|
18
24
|
import { buildAgentManifest } from "../agent-manifest";
|
|
19
|
-
import { buildToolCatalog } from "../tool-catalog";
|
|
25
|
+
import { buildToolCatalog, toolNameForQn } from "../tool-catalog";
|
|
20
26
|
import { dispatchToolCall } from "../tool-dispatch";
|
|
21
27
|
|
|
22
28
|
const vendorEntity = createEntity({
|
|
@@ -47,11 +53,92 @@ const vendorFeature = defineFeature("agent-tools-test-vendor", (r) => {
|
|
|
47
53
|
|
|
48
54
|
const VENDOR_CREATE_QN = "agent-tools-test-vendor:write:vendor:create";
|
|
49
55
|
|
|
56
|
+
// No softDelete — forget-gizmo-high performs a hard, irreversible purge and
|
|
57
|
+
// therefore must resolve agent.risk "high" to pass the executor gate.
|
|
58
|
+
const gizmoEntity = createEntity({
|
|
59
|
+
table: "agent_tools_test_gizmos",
|
|
60
|
+
fields: {
|
|
61
|
+
label: createTextField({ personal: false, reason: "test_fixture", required: true }),
|
|
62
|
+
},
|
|
63
|
+
});
|
|
64
|
+
const { executor: gizmoExecutor } = createEntityExecutor("gizmo", gizmoEntity);
|
|
65
|
+
|
|
66
|
+
const GIZMO_CREATE_QN = "agent-tools-test-gizmo:write:gizmo:create";
|
|
67
|
+
const GIZMO_FORGET_HIGH_QN = "agent-tools-test-gizmo:write:forget-gizmo-high";
|
|
68
|
+
const GIZMO_DELEGATE_FORGET_MID_QN = "agent-tools-test-gizmo:write:delegate-forget-gizmo-mid";
|
|
69
|
+
|
|
70
|
+
const gizmoFeature = defineFeature("agent-tools-test-gizmo", (r) => {
|
|
71
|
+
r.entity("gizmo", gizmoEntity);
|
|
72
|
+
|
|
73
|
+
r.writeHandler(
|
|
74
|
+
"gizmo:create",
|
|
75
|
+
z.object({ label: z.string() }),
|
|
76
|
+
async (event, ctx) => gizmoExecutor.create(event.payload, event.user, ctx.db),
|
|
77
|
+
{ access: { roles: ["Admin"] } },
|
|
78
|
+
);
|
|
79
|
+
|
|
80
|
+
r.writeHandler(
|
|
81
|
+
"forget-gizmo-high",
|
|
82
|
+
z.object({ id: z.uuid() }),
|
|
83
|
+
async (event, ctx) => gizmoExecutor.forget({ id: event.payload.id }, event.user, ctx.db),
|
|
84
|
+
{
|
|
85
|
+
access: { roles: ["Admin"] },
|
|
86
|
+
description: "Permanently forget a gizmo (GDPR erasure).",
|
|
87
|
+
agent: { risk: "high" },
|
|
88
|
+
},
|
|
89
|
+
);
|
|
90
|
+
|
|
91
|
+
r.writeHandler(
|
|
92
|
+
"delegate-forget-gizmo-mid",
|
|
93
|
+
z.object({ id: z.uuid() }),
|
|
94
|
+
async (event, ctx) =>
|
|
95
|
+
ctx.write("agent-tools-test-gizmo:write:forget-gizmo-high", { id: event.payload.id }),
|
|
96
|
+
{
|
|
97
|
+
access: { roles: ["Admin"] },
|
|
98
|
+
description: "Delegates to forget-gizmo-high via ctx.write.",
|
|
99
|
+
},
|
|
100
|
+
);
|
|
101
|
+
|
|
102
|
+
// Simulates the enterprise "approve" write handler: an outer, mid-risk
|
|
103
|
+
// handler that itself runs the real dispatchToolCall path against a
|
|
104
|
+
// tool name given as input, the way an AI-agent turn approves a queued
|
|
105
|
+
// tool call.
|
|
106
|
+
r.writeHandler(
|
|
107
|
+
"approve",
|
|
108
|
+
z.object({ toolName: z.string(), id: z.string() }),
|
|
109
|
+
async (event) => {
|
|
110
|
+
const manifest = buildAgentManifest(stack.registry, {
|
|
111
|
+
locale: "en",
|
|
112
|
+
roles: event.user.roles,
|
|
113
|
+
});
|
|
114
|
+
const catalog = buildToolCatalog(stack.registry, manifest, { mode: "edit" });
|
|
115
|
+
// runId/toolCallId feed the write's idempotency key — must be unique
|
|
116
|
+
// per call, or a second approve() reuses the first call's cached
|
|
117
|
+
// WriteResult instead of actually re-dispatching the tool.
|
|
118
|
+
const result = await dispatchToolCall({
|
|
119
|
+
dispatcher: stack.dispatcher,
|
|
120
|
+
user: event.user,
|
|
121
|
+
toolName: event.payload.toolName,
|
|
122
|
+
input: { id: event.payload.id },
|
|
123
|
+
dispatchTable: catalog.dispatchTable,
|
|
124
|
+
runId: `run-approve:${event.payload.toolName}`,
|
|
125
|
+
toolCallId: `call-approve:${event.payload.id}`,
|
|
126
|
+
});
|
|
127
|
+
return {
|
|
128
|
+
isSuccess: true as const,
|
|
129
|
+
data: result.ok ? { ok: true } : { ok: false, error: result.error },
|
|
130
|
+
};
|
|
131
|
+
},
|
|
132
|
+
{ access: { roles: ["Admin"] } },
|
|
133
|
+
);
|
|
134
|
+
});
|
|
135
|
+
|
|
50
136
|
let stack: TestStack;
|
|
51
137
|
|
|
52
138
|
beforeAll(async () => {
|
|
53
|
-
stack = await setupTestStack({ features: [vendorFeature] });
|
|
139
|
+
stack = await setupTestStack({ features: [vendorFeature, gizmoFeature] });
|
|
54
140
|
await unsafeCreateEntityTable(stack.db, vendorEntity);
|
|
141
|
+
await unsafeCreateEntityTable(stack.db, gizmoEntity, "gizmo");
|
|
55
142
|
}, 20000);
|
|
56
143
|
|
|
57
144
|
afterAll(async () => {
|
|
@@ -61,6 +148,7 @@ afterAll(async () => {
|
|
|
61
148
|
beforeEach(async () => {
|
|
62
149
|
await asRawClient(stack.db).unsafe("DELETE FROM kumiko_events");
|
|
63
150
|
await asRawClient(stack.db).unsafe("DELETE FROM agent_tools_test_vendors");
|
|
151
|
+
await asRawClient(stack.db).unsafe("DELETE FROM agent_tools_test_gizmos");
|
|
64
152
|
});
|
|
65
153
|
|
|
66
154
|
const TENANT_B = "00000000-0000-4000-8000-0000000000bb";
|
|
@@ -119,3 +207,42 @@ describe("dispatchToolCall — real <entity>:list pipeline", () => {
|
|
|
119
207
|
expect(result.error).toContain("access denied");
|
|
120
208
|
});
|
|
121
209
|
});
|
|
210
|
+
|
|
211
|
+
describe("dispatchToolCall — the invoked tool is the entry handler, not the surrounding write handler", () => {
|
|
212
|
+
async function createGizmo(label: string): Promise<string> {
|
|
213
|
+
const { id } = await stack.http.writeOk<{ id: string }>(GIZMO_CREATE_QN, { label }, adminA);
|
|
214
|
+
return id;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
async function gizmoRowExists(id: string): Promise<boolean> {
|
|
218
|
+
const rows = (await asRawClient(stack.db).unsafe(
|
|
219
|
+
"SELECT id FROM agent_tools_test_gizmos WHERE id = $1",
|
|
220
|
+
[id],
|
|
221
|
+
)) as readonly Record<string, unknown>[];
|
|
222
|
+
return rows.length > 0;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
test("approve (mid) invoking a high-risk forget tool succeeds — the tool is the entry, not approve", async () => {
|
|
226
|
+
const id = await createGizmo("gone-after-tool-forget");
|
|
227
|
+
const { ok } = await stack.http.writeOk<{ ok: boolean }>(
|
|
228
|
+
"agent-tools-test-gizmo:write:approve",
|
|
229
|
+
{ toolName: toolNameForQn(GIZMO_FORGET_HIGH_QN), id },
|
|
230
|
+
adminA,
|
|
231
|
+
);
|
|
232
|
+
expect(ok).toBe(true);
|
|
233
|
+
expect(await gizmoRowExists(id)).toBe(false);
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
test("approve (mid) invoking a mid-risk tool that delegates via ctx.write to the high forget handler is denied", async () => {
|
|
237
|
+
const id = await createGizmo("survives-delegated-forget");
|
|
238
|
+
const { ok, error } = await stack.http.writeOk<{ ok: boolean; error?: string }>(
|
|
239
|
+
"agent-tools-test-gizmo:write:approve",
|
|
240
|
+
{ toolName: toolNameForQn(GIZMO_DELEGATE_FORGET_MID_QN), id },
|
|
241
|
+
adminA,
|
|
242
|
+
);
|
|
243
|
+
expect(ok).toBe(false);
|
|
244
|
+
expect(error).toContain(GIZMO_DELEGATE_FORGET_MID_QN);
|
|
245
|
+
expect(error).toContain("irreversible");
|
|
246
|
+
expect(await gizmoRowExists(id)).toBe(true);
|
|
247
|
+
});
|
|
248
|
+
});
|
|
@@ -6,6 +6,7 @@ import type {
|
|
|
6
6
|
NavDefinition,
|
|
7
7
|
QueryHandlerDef,
|
|
8
8
|
ScreenDefinition,
|
|
9
|
+
TranslationEntry,
|
|
9
10
|
TranslationKeys,
|
|
10
11
|
WorkspaceDefinition,
|
|
11
12
|
WriteHandlerDef,
|
|
@@ -15,6 +16,7 @@ import {
|
|
|
15
16
|
isAgentVisibleScreen,
|
|
16
17
|
resolveAgentExposure,
|
|
17
18
|
} from "@cosmicdrift/kumiko-framework/engine";
|
|
19
|
+
import { translationValueOtherText } from "@cosmicdrift/kumiko-framework/ui-types";
|
|
18
20
|
import * as z from "zod";
|
|
19
21
|
import type {
|
|
20
22
|
AgentManifest,
|
|
@@ -29,6 +31,14 @@ import type {
|
|
|
29
31
|
RegistryManifestView,
|
|
30
32
|
} from "./types";
|
|
31
33
|
|
|
34
|
+
/** The manifest surfaces one string per locale — a plural entry reads as its
|
|
35
|
+
* CLDR `other` form since the manifest has no `count` to select a category. */
|
|
36
|
+
function entryAsLabels(entry: TranslationEntry): Readonly<Record<string, string>> {
|
|
37
|
+
return Object.fromEntries(
|
|
38
|
+
Object.entries(entry).map(([locale, value]) => [locale, translationValueOtherText(value)]),
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
|
|
32
42
|
/** Translation keys carry a feature prefix the manifest can't reconstruct
|
|
33
43
|
* (`showcase:entity:item:field:title`), so match on the suffix instead. */
|
|
34
44
|
function labelsForSuffix(
|
|
@@ -36,7 +46,7 @@ function labelsForSuffix(
|
|
|
36
46
|
suffix: string,
|
|
37
47
|
): Readonly<Record<string, string>> {
|
|
38
48
|
for (const [key, labels] of Object.entries(translations)) {
|
|
39
|
-
if (key === suffix || key.endsWith(suffix)) return labels;
|
|
49
|
+
if (key === suffix || key.endsWith(suffix)) return entryAsLabels(labels);
|
|
40
50
|
}
|
|
41
51
|
return {};
|
|
42
52
|
}
|
|
@@ -226,7 +236,7 @@ function buildNavs(
|
|
|
226
236
|
|
|
227
237
|
result.push({
|
|
228
238
|
id: nav.id,
|
|
229
|
-
labels: translations[nav.label] ?? {},
|
|
239
|
+
labels: entryAsLabels(translations[nav.label] ?? {}),
|
|
230
240
|
...(nav.screen !== undefined && { screen: nav.screen }),
|
|
231
241
|
...(nav.parent !== undefined && { parent: nav.parent }),
|
|
232
242
|
workspaces: visibleWorkspaces,
|
|
@@ -304,7 +314,7 @@ function buildWorkspaces(
|
|
|
304
314
|
if (!uiVisible(workspace.access, roles)) continue;
|
|
305
315
|
result.push({
|
|
306
316
|
id: workspace.id,
|
|
307
|
-
labels: translations[workspace.label] ?? {},
|
|
317
|
+
labels: entryAsLabels(translations[workspace.label] ?? {}),
|
|
308
318
|
...(workspace.default === true && { default: true as const }),
|
|
309
319
|
});
|
|
310
320
|
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { runAsDirectCallEntry } from "@cosmicdrift/kumiko-framework/api";
|
|
1
2
|
import type { SessionUser, WriteResult } from "@cosmicdrift/kumiko-framework/engine";
|
|
2
3
|
import type { ToolDispatchDescriptor } from "./types";
|
|
3
4
|
|
|
@@ -355,18 +356,22 @@ export async function dispatchToolCall(request: ToolCallRequest): Promise<ToolCa
|
|
|
355
356
|
}
|
|
356
357
|
|
|
357
358
|
try {
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
359
|
+
// The invoked tool, not the surrounding turn/approve write handler, must
|
|
360
|
+
// be what the irreversible-operation gate sees as the entry handler.
|
|
361
|
+
return await runAsDirectCallEntry(async () => {
|
|
362
|
+
switch (descriptor.kind) {
|
|
363
|
+
case "search":
|
|
364
|
+
return await dispatchSearch(descriptor, dispatcher, user, toolName, input);
|
|
365
|
+
case "findBy":
|
|
366
|
+
return await dispatchFindBy(descriptor, dispatcher, user, toolName, input);
|
|
367
|
+
case "client":
|
|
368
|
+
return dispatchClient(descriptor, input);
|
|
369
|
+
case "server":
|
|
370
|
+
return descriptor.op === "query"
|
|
371
|
+
? await dispatchServerQuery(descriptor, dispatcher, user, input)
|
|
372
|
+
: await dispatchServerWrite(descriptor, dispatcher, user, input, runId, toolCallId);
|
|
373
|
+
}
|
|
374
|
+
});
|
|
370
375
|
} catch (err) {
|
|
371
376
|
return { ok: false, error: err instanceof Error ? err.message : String(err) };
|
|
372
377
|
}
|
|
@@ -28,6 +28,7 @@ import {
|
|
|
28
28
|
writeFailure,
|
|
29
29
|
} from "@cosmicdrift/kumiko-framework/errors";
|
|
30
30
|
import { append } from "@cosmicdrift/kumiko-framework/event-store";
|
|
31
|
+
import { assertIrreversibleOperationAllowed } from "@cosmicdrift/kumiko-framework/pipeline";
|
|
31
32
|
import { purgeSearchDocumentsForSubject } from "@cosmicdrift/kumiko-framework/search";
|
|
32
33
|
import { generateId } from "@cosmicdrift/kumiko-framework/utils";
|
|
33
34
|
import * as z from "zod";
|
|
@@ -267,7 +268,7 @@ export const forgetSubjectWrite = defineWriteHandler({
|
|
|
267
268
|
"Irreversibly crypto-shreds one user, tenant or record subject by erasing its encryption key, nulling its blind indexes, purging its search documents and closing the user's login, for supervisory-authority requests and operator recovery outside the automated Art. 17 cleanup pipeline.",
|
|
268
269
|
// Erasing the subject key is irreversible: there is no undo, so an agent must
|
|
269
270
|
// not be able to reach it at all.
|
|
270
|
-
agent: { expose: false },
|
|
271
|
+
agent: { expose: false, risk: "high" },
|
|
271
272
|
escapeHatch: {
|
|
272
273
|
reason:
|
|
273
274
|
"denial audit append names the prober's own tenant stream on the outside-transaction db; " +
|
|
@@ -276,6 +277,7 @@ export const forgetSubjectWrite = defineWriteHandler({
|
|
|
276
277
|
"lifecycle update and PAT revoke run on the SYSTEM user stream.",
|
|
277
278
|
},
|
|
278
279
|
handler: async (event, ctx) => {
|
|
280
|
+
assertIrreversibleOperationAllowed("forget-subject key erase");
|
|
279
281
|
const kms = configuredPiiSubjectKms();
|
|
280
282
|
if (!kms) {
|
|
281
283
|
return writeFailure(
|
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
// so adding a facet option without translating it fails here rather than in
|
|
7
7
|
// production.
|
|
8
8
|
import { describe, expect, test } from "bun:test";
|
|
9
|
+
import { translationValueOtherText } from "@cosmicdrift/kumiko-framework/ui-types";
|
|
9
10
|
import { localeDeBundle } from "@cosmicdrift/kumiko-locale-de";
|
|
10
11
|
import { localeEsBundle } from "@cosmicdrift/kumiko-locale-es";
|
|
11
12
|
import { MEMBERS_SCREEN_ID } from "../constants";
|
|
@@ -67,7 +68,7 @@ describe("members status filter labels are localized in every shipped locale (fw
|
|
|
67
68
|
const label = translations[key]?.["en"];
|
|
68
69
|
expect(label).toBeString();
|
|
69
70
|
expect(label).not.toBe(key);
|
|
70
|
-
expect((label
|
|
71
|
+
expect((label === undefined ? "" : translationValueOtherText(label)).trim()).not.toBe("");
|
|
71
72
|
}
|
|
72
73
|
});
|
|
73
74
|
|
|
@@ -77,7 +78,7 @@ describe("members status filter labels are localized in every shipped locale (fw
|
|
|
77
78
|
const label = translations[key]?.["en"];
|
|
78
79
|
expect(label).toBeString();
|
|
79
80
|
expect(label).not.toBe(key);
|
|
80
|
-
expect((label
|
|
81
|
+
expect((label === undefined ? "" : translationValueOtherText(label)).trim()).not.toBe("");
|
|
81
82
|
}
|
|
82
83
|
});
|
|
83
84
|
|
|
@@ -14,20 +14,16 @@
|
|
|
14
14
|
import { describe, expect, test } from "bun:test";
|
|
15
15
|
import { createConfigFeature } from "@cosmicdrift/kumiko-bundled-features/config";
|
|
16
16
|
import { buildConfigFeatureSchema, createRegistry } from "@cosmicdrift/kumiko-framework/engine";
|
|
17
|
+
import { translationValueOtherText } from "@cosmicdrift/kumiko-framework/ui-types";
|
|
17
18
|
import { localeDeBundle } from "@cosmicdrift/kumiko-locale-de";
|
|
18
19
|
import { localeEsBundle } from "@cosmicdrift/kumiko-locale-es";
|
|
19
|
-
import { translationsByLocaleFromKeys } from "@cosmicdrift/kumiko-renderer";
|
|
20
|
+
import { type TranslationsByKey, translationsByLocaleFromKeys } from "@cosmicdrift/kumiko-renderer";
|
|
20
21
|
import { createTenantSettingsFeature } from "../feature";
|
|
21
22
|
|
|
22
|
-
function translate(
|
|
23
|
-
|
|
24
|
-
key
|
|
25
|
-
|
|
26
|
-
): string {
|
|
27
|
-
const byLocale = translationsByLocaleFromKeys(
|
|
28
|
-
translations as Record<string, Readonly<Record<string, string>>>,
|
|
29
|
-
);
|
|
30
|
-
return byLocale[locale]?.[key] ?? key;
|
|
23
|
+
function translate(translations: TranslationsByKey, key: string, locale = "en"): string {
|
|
24
|
+
const byLocale = translationsByLocaleFromKeys(translations);
|
|
25
|
+
const value = byLocale[locale]?.[key];
|
|
26
|
+
return value === undefined ? key : translationValueOtherText(value);
|
|
31
27
|
}
|
|
32
28
|
|
|
33
29
|
function settingsHubLabelKeys(): readonly string[] {
|
|
@@ -31,7 +31,9 @@ export function createRunForgetCleanupHandler(opts: RunForgetCleanupOptions = {}
|
|
|
31
31
|
name: "run-forget-cleanup",
|
|
32
32
|
schema: z.object({}),
|
|
33
33
|
access: { roles: access.privileged },
|
|
34
|
-
|
|
34
|
+
// Cascades into per-user forget hooks that hard-purge (executor.forget)
|
|
35
|
+
// records without softDelete — an agent must never be able to trigger it.
|
|
36
|
+
agent: { expose: false, risk: "high" },
|
|
35
37
|
escapeHatch: {
|
|
36
38
|
reason: RUN_FORGET_CLEANUP_REASON,
|
|
37
39
|
},
|