@cosmicdrift/kumiko-bundled-features 0.235.3 → 0.236.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__/agent-manifest.test.ts +6 -0
- package/src/agent-tools/__tests__/agent-tools.integration.test.ts +199 -0
- package/src/agent-tools/__tests__/tool-catalog.test.ts +340 -128
- package/src/agent-tools/__tests__/tool-dispatch.integration.test.ts +28 -16
- package/src/agent-tools/__tests__/tool-dispatch.test.ts +390 -44
- package/src/agent-tools/agent-manifest.ts +3 -0
- package/src/agent-tools/index.ts +4 -2
- package/src/agent-tools/tool-catalog.ts +457 -17
- package/src/agent-tools/tool-dispatch.ts +334 -43
- package/src/agent-tools/types.ts +61 -1
- package/src/feature-toggles/__tests__/feature-toggles.integration.test.ts +55 -82
- package/src/feature-toggles/feature.ts +24 -15
- package/src/feature-toggles/index.ts +3 -0
- package/src/feature-toggles/toggle-runtime.ts +46 -8
- package/src/feature-toggles/toggle-sync-signal.ts +57 -0
- package/src/notes-history/__tests__/notes-history-ownership.integration.test.ts +202 -0
- package/src/notes-history/entity.ts +51 -32
- package/src/notes-history/feature.ts +39 -7
- package/src/notes-history-user-data/hooks.ts +12 -9
- package/src/shared/has-where-rule.ts +14 -0
- package/src/shared/index.ts +1 -0
- package/src/tags/__tests__/tags-ownership.test.ts +23 -0
- package/src/tags/entity.ts +26 -16
- package/src/tags/feature.ts +44 -5
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-bundled-features",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.236.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>",
|
|
@@ -130,12 +130,12 @@
|
|
|
130
130
|
"./workflow-runner": "./src/workflow-runner/index.ts"
|
|
131
131
|
},
|
|
132
132
|
"dependencies": {
|
|
133
|
-
"@cosmicdrift/kumiko-dispatcher-live": "0.
|
|
134
|
-
"@cosmicdrift/kumiko-framework": "0.
|
|
135
|
-
"@cosmicdrift/kumiko-headless": "0.
|
|
136
|
-
"@cosmicdrift/kumiko-renderer": "0.
|
|
137
|
-
"@cosmicdrift/kumiko-renderer-web": "0.
|
|
138
|
-
"@cosmicdrift/kumiko-types": "0.
|
|
133
|
+
"@cosmicdrift/kumiko-dispatcher-live": "0.236.0",
|
|
134
|
+
"@cosmicdrift/kumiko-framework": "0.236.0",
|
|
135
|
+
"@cosmicdrift/kumiko-headless": "0.236.0",
|
|
136
|
+
"@cosmicdrift/kumiko-renderer": "0.236.0",
|
|
137
|
+
"@cosmicdrift/kumiko-renderer-web": "0.236.0",
|
|
138
|
+
"@cosmicdrift/kumiko-types": "0.236.0",
|
|
139
139
|
"@mollie/api-client": "^4.5.0",
|
|
140
140
|
"@node-rs/argon2": "^2.0.2",
|
|
141
141
|
"@types/mailparser": "^3.4.6",
|
|
@@ -164,7 +164,7 @@
|
|
|
164
164
|
"devDependencies": {
|
|
165
165
|
"@testing-library/user-event": "^14.6.1",
|
|
166
166
|
"@types/qrcode": "^1.5.5",
|
|
167
|
-
"@cosmicdrift/kumiko-locale-de": "0.
|
|
168
|
-
"@cosmicdrift/kumiko-locale-es": "0.
|
|
167
|
+
"@cosmicdrift/kumiko-locale-de": "0.236.0",
|
|
168
|
+
"@cosmicdrift/kumiko-locale-es": "0.236.0"
|
|
169
169
|
}
|
|
170
170
|
}
|
|
@@ -85,6 +85,12 @@ describe("buildAgentManifest", () => {
|
|
|
85
85
|
expect(viewer.handlers.some((h) => h.qn.endsWith(":undescribed"))).toBe(false);
|
|
86
86
|
});
|
|
87
87
|
|
|
88
|
+
test("echoes the roles it was built for, so consumers cannot invent a second role source", () => {
|
|
89
|
+
const registry = createRegistry([buildTestFeature()]);
|
|
90
|
+
const manifest = buildAgentManifest(registry, { locale: "en", roles: ["admin", "viewer"] });
|
|
91
|
+
expect(manifest.builtForRoles).toEqual(["admin", "viewer"]);
|
|
92
|
+
});
|
|
93
|
+
|
|
88
94
|
test("role gating: viewer sees the query handler but not the admin-only write handler", () => {
|
|
89
95
|
const registry = createRegistry([buildTestFeature()]);
|
|
90
96
|
const viewer = buildAgentManifest(registry, { locale: "en", roles: ["viewer"] });
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
// Proves the agent-tools catalog + dispatch work over the REAL HTTP surface (/api/query,
|
|
2
|
+
// /api/write), not an in-process shortcut — a hand-rolled recording dispatcher would happily
|
|
3
|
+
// hide an access-check regression (it "dispatches" without ever consulting the real pipeline).
|
|
4
|
+
|
|
5
|
+
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
6
|
+
import type { WriteResult } from "@cosmicdrift/kumiko-framework/engine";
|
|
7
|
+
import {
|
|
8
|
+
createEntity,
|
|
9
|
+
createSelectField,
|
|
10
|
+
createTextField,
|
|
11
|
+
defineFeature,
|
|
12
|
+
} from "@cosmicdrift/kumiko-framework/engine";
|
|
13
|
+
import {
|
|
14
|
+
createTestUser,
|
|
15
|
+
setupTestStack,
|
|
16
|
+
type TestStack,
|
|
17
|
+
unsafeCreateEntityTable,
|
|
18
|
+
} from "@cosmicdrift/kumiko-framework/stack";
|
|
19
|
+
import { z } from "zod";
|
|
20
|
+
import { buildAgentManifest } from "../agent-manifest";
|
|
21
|
+
import { buildToolCatalog, toolNameForQn } from "../tool-catalog";
|
|
22
|
+
import type { ToolDispatcher } from "../tool-dispatch";
|
|
23
|
+
import { dispatchToolCall } from "../tool-dispatch";
|
|
24
|
+
|
|
25
|
+
const FEATURE_NAME = "agent-tools-int-test";
|
|
26
|
+
|
|
27
|
+
const widgetEntity = createEntity({
|
|
28
|
+
table: "agent_tools_int_test_widgets",
|
|
29
|
+
fields: {
|
|
30
|
+
name: createTextField({ required: true, searchable: true, filterable: true }),
|
|
31
|
+
status: createSelectField({ options: ["pending", "approved"] as const, filterable: true }),
|
|
32
|
+
},
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
const widgetFeature = defineFeature(FEATURE_NAME, (r) => {
|
|
36
|
+
r.crud("widget", widgetEntity, {
|
|
37
|
+
write: { access: { roles: ["Admin"] } },
|
|
38
|
+
read: { access: { roles: ["Admin", "Reader"] } },
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
r.writeHandler(
|
|
42
|
+
"widget:approve",
|
|
43
|
+
z.object({ id: z.string(), version: z.number() }),
|
|
44
|
+
async (event, ctx) =>
|
|
45
|
+
ctx.write(`${FEATURE_NAME}:write:widget:update`, {
|
|
46
|
+
id: event.payload.id,
|
|
47
|
+
version: event.payload.version,
|
|
48
|
+
changes: { status: "approved" },
|
|
49
|
+
}),
|
|
50
|
+
{ access: { roles: ["Admin"] }, description: "Approve a widget." },
|
|
51
|
+
);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
const WIDGET_CREATE_QN = `${FEATURE_NAME}:write:widget:create`;
|
|
55
|
+
const WIDGET_DETAIL_QN = `${FEATURE_NAME}:query:widget:detail`;
|
|
56
|
+
const APPROVE_QN = `${FEATURE_NAME}:write:widget:approve`;
|
|
57
|
+
const APPROVE_TOOL_NAME = toolNameForQn(APPROVE_QN);
|
|
58
|
+
|
|
59
|
+
let stack: TestStack;
|
|
60
|
+
|
|
61
|
+
beforeAll(async () => {
|
|
62
|
+
stack = await setupTestStack({ features: [widgetFeature] });
|
|
63
|
+
await unsafeCreateEntityTable(stack.db, widgetEntity);
|
|
64
|
+
}, 20000);
|
|
65
|
+
|
|
66
|
+
afterAll(async () => {
|
|
67
|
+
await stack.cleanup();
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
const admin = createTestUser({ roles: ["Admin"] });
|
|
71
|
+
const reader = createTestUser({ roles: ["Reader"], id: admin.id, tenantId: admin.tenantId });
|
|
72
|
+
|
|
73
|
+
function catalogFor(roles: readonly string[]) {
|
|
74
|
+
const manifest = buildAgentManifest(stack.registry, { locale: "en", roles });
|
|
75
|
+
return buildToolCatalog(stack.registry, manifest, { mode: "edit" });
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** ToolDispatcher backed by real HTTP requests through the app — response-shape empirically
|
|
79
|
+
* confirmed against `packages/framework/src/api/routes.ts`: query success is `{ data }`,
|
|
80
|
+
* query failure is `{ error }` with a non-2xx status (must throw here, or an access-denied
|
|
81
|
+
* body comes back as `ok: true` and every rejection assertion below would pass vacuously);
|
|
82
|
+
* write success/failure both already match the `WriteResult` wire shape 1:1. */
|
|
83
|
+
function buildHttpDispatcher(): ToolDispatcher {
|
|
84
|
+
return {
|
|
85
|
+
async query(type, payload, user) {
|
|
86
|
+
const res = await stack.http.query(type, payload, user);
|
|
87
|
+
const body = (await res.json()) as { data?: unknown; error?: unknown };
|
|
88
|
+
if (!res.ok) {
|
|
89
|
+
throw new Error(`query "${type}" failed: ${JSON.stringify(body.error ?? body)}`);
|
|
90
|
+
}
|
|
91
|
+
return body.data;
|
|
92
|
+
},
|
|
93
|
+
async write(type, payload, user, requestId) {
|
|
94
|
+
const res = await stack.http.write(type, payload, user, requestId);
|
|
95
|
+
return (await res.json()) as WriteResult;
|
|
96
|
+
},
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
describe("agent tools over real HTTP", () => {
|
|
101
|
+
test("the catalog contains the described write tool", () => {
|
|
102
|
+
const catalog = catalogFor(["Admin"]);
|
|
103
|
+
expect(catalog.tools.map((t) => t.name)).toContain(APPROVE_TOOL_NAME);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
test("catalog role-filtering: a Reader-scoped catalog does not expose the Admin-only approve tool", () => {
|
|
107
|
+
const catalog = catalogFor(["Reader"]);
|
|
108
|
+
expect(catalog.tools.map((t) => t.name)).not.toContain(APPROVE_TOOL_NAME);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
test("dispatch-level denial: a reader calling the (Admin-only) approve tool is rejected by the write pipeline itself, not by an absent tool", async () => {
|
|
112
|
+
// Create's wire result nests the projection under `data` (`{ id, data: { version, ... } }`
|
|
113
|
+
// — same shape asserted in domain-events-projections.integration.test.ts), not a flat
|
|
114
|
+
// `{ id, version }`.
|
|
115
|
+
const created = await stack.http.writeOk<{ id: string; data: { version: number } }>(
|
|
116
|
+
WIDGET_CREATE_QN,
|
|
117
|
+
{ name: "Widget A", status: "pending" },
|
|
118
|
+
admin,
|
|
119
|
+
);
|
|
120
|
+
|
|
121
|
+
// Built for Admin so the approve tool descriptor exists — the reader is denied by
|
|
122
|
+
// dispatcher.write's own access check, not by a catalog lookup miss. The reader still
|
|
123
|
+
// has read access (["Admin", "Reader"]), so the injectsVersion pre-read through the
|
|
124
|
+
// detail handler succeeds and the write call is what actually gets rejected.
|
|
125
|
+
const catalog = catalogFor(["Admin"]);
|
|
126
|
+
const result = await dispatchToolCall({
|
|
127
|
+
dispatcher: buildHttpDispatcher(),
|
|
128
|
+
user: reader,
|
|
129
|
+
toolName: APPROVE_TOOL_NAME,
|
|
130
|
+
input: { id: created.id },
|
|
131
|
+
dispatchTable: catalog.dispatchTable,
|
|
132
|
+
runId: "run-denied",
|
|
133
|
+
toolCallId: "call-1",
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
expect(result.ok).toBe(false);
|
|
137
|
+
if (result.ok) throw new Error("unreachable");
|
|
138
|
+
expect(result.error).not.toContain("Unknown tool");
|
|
139
|
+
expect(result.error).toContain("access denied");
|
|
140
|
+
expect(result.error).toContain("widget:approve");
|
|
141
|
+
|
|
142
|
+
const stillPending = await stack.http.queryOk<{ status: string; version: number }>(
|
|
143
|
+
WIDGET_DETAIL_QN,
|
|
144
|
+
{ id: created.id },
|
|
145
|
+
admin,
|
|
146
|
+
);
|
|
147
|
+
expect(stillPending.status).toBe("pending");
|
|
148
|
+
expect(stillPending.version).toBe(created.data.version);
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
test("dispatching the write tool with the role succeeds and increments the version", async () => {
|
|
152
|
+
const created = await stack.http.writeOk<{ id: string; data: { version: number } }>(
|
|
153
|
+
WIDGET_CREATE_QN,
|
|
154
|
+
{ name: "Widget B", status: "pending" },
|
|
155
|
+
admin,
|
|
156
|
+
);
|
|
157
|
+
|
|
158
|
+
const catalog = catalogFor(["Admin"]);
|
|
159
|
+
const result = await dispatchToolCall({
|
|
160
|
+
dispatcher: buildHttpDispatcher(),
|
|
161
|
+
user: admin,
|
|
162
|
+
toolName: APPROVE_TOOL_NAME,
|
|
163
|
+
input: { id: created.id },
|
|
164
|
+
dispatchTable: catalog.dispatchTable,
|
|
165
|
+
runId: "run-allowed",
|
|
166
|
+
toolCallId: "call-1",
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
expect(result.ok).toBe(true);
|
|
170
|
+
|
|
171
|
+
const updated = await stack.http.queryOk<{ status: string; version: number }>(
|
|
172
|
+
WIDGET_DETAIL_QN,
|
|
173
|
+
{ id: created.id },
|
|
174
|
+
admin,
|
|
175
|
+
);
|
|
176
|
+
expect(updated.status).toBe("approved");
|
|
177
|
+
expect(updated.version).toBeGreaterThan(created.data.version);
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
test("list_widget returns a numeric total", async () => {
|
|
181
|
+
await stack.http.writeOk(WIDGET_CREATE_QN, { name: "Widget C", status: "pending" }, admin);
|
|
182
|
+
|
|
183
|
+
const catalog = catalogFor(["Admin"]);
|
|
184
|
+
const result = await dispatchToolCall({
|
|
185
|
+
dispatcher: buildHttpDispatcher(),
|
|
186
|
+
user: admin,
|
|
187
|
+
toolName: "list_widget",
|
|
188
|
+
input: {},
|
|
189
|
+
dispatchTable: catalog.dispatchTable,
|
|
190
|
+
runId: "run-list",
|
|
191
|
+
toolCallId: "call-1",
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
expect(result.ok).toBe(true);
|
|
195
|
+
if (!result.ok) throw new Error("unreachable");
|
|
196
|
+
const data = result.data as { total?: unknown };
|
|
197
|
+
expect(typeof data.total).toBe("number");
|
|
198
|
+
});
|
|
199
|
+
});
|