@frockbot/plugin-bot-template 0.0.0 → 0.1.1
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/frockbot.json +43 -0
- package/package.json +47 -6
- package/src/agent.test.ts +194 -0
- package/src/agent.ts +221 -0
- package/src/backend.test.ts +326 -0
- package/src/backend.ts +262 -0
- package/src/client/BotTemplateImportSection.vue +325 -0
- package/src/client/BotTemplateSection.vue +338 -0
- package/src/client/index.ts +210 -0
- package/src/client/state.ts +43 -0
- package/src/env.d.ts +6 -0
- package/src/import-apply.test.ts +455 -0
- package/src/import.test.ts +266 -0
- package/src/import.ts +292 -0
- package/src/index.ts +9 -0
- package/src/manifest.ts +3 -0
- package/src/scrub.test.ts +528 -0
- package/src/scrub.ts +523 -0
- package/src/shared.ts +620 -0
- package/src/user.test.ts +361 -0
- package/src/user.ts +913 -0
- package/tsconfig.json +15 -0
- package/vite.config.ts +31 -0
- package/README.md +0 -3
package/src/user.test.ts
ADDED
|
@@ -0,0 +1,361 @@
|
|
|
1
|
+
// The User Contribution: staging, visibility, revocation, and the public read.
|
|
2
|
+
//
|
|
3
|
+
// Everything here runs against the real `UserSettingsBackendContribution` over
|
|
4
|
+
// an in-memory storage, so the identity assertion, the packages projection and
|
|
5
|
+
// the Connection projection are the product's own rather than a stub's.
|
|
6
|
+
import { describe, expect, it } from "bun:test";
|
|
7
|
+
import type { BotSettingsViewV1 } from "@frockbot/configuration-core";
|
|
8
|
+
import {
|
|
9
|
+
createUserSettingsBackendContribution,
|
|
10
|
+
type UserSettingsStorage,
|
|
11
|
+
type UserSettingsTransaction,
|
|
12
|
+
} from "@frockbot/plugin-settings/user";
|
|
13
|
+
import {
|
|
14
|
+
parseBotTemplateDocumentV1,
|
|
15
|
+
templateObjectKeyV1,
|
|
16
|
+
} from "@frockbot/template-core";
|
|
17
|
+
import { createBotTemplateUserBackendContribution } from "./user.ts";
|
|
18
|
+
import type { TemplateBlobStoreV1, TemplateBotReaderV1 } from "./user.ts";
|
|
19
|
+
|
|
20
|
+
const USER = "user-1";
|
|
21
|
+
const BOT = "budget";
|
|
22
|
+
|
|
23
|
+
class MemoryStorage implements UserSettingsStorage {
|
|
24
|
+
readonly values = new Map<string, unknown>();
|
|
25
|
+
|
|
26
|
+
get<T>(key: string): Promise<T | undefined> {
|
|
27
|
+
return Promise.resolve(this.values.get(key) as T | undefined);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
put<T>(key: string, value: T): Promise<void>;
|
|
31
|
+
put(entries: Record<string, unknown>): Promise<void>;
|
|
32
|
+
put<T>(
|
|
33
|
+
keyOrEntries: string | Record<string, unknown>,
|
|
34
|
+
value?: T,
|
|
35
|
+
): Promise<void> {
|
|
36
|
+
if (typeof keyOrEntries === "string") this.values.set(keyOrEntries, value);
|
|
37
|
+
else {
|
|
38
|
+
for (const [key, entry] of Object.entries(keyOrEntries)) {
|
|
39
|
+
this.values.set(key, entry);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
return Promise.resolve();
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async transaction<T>(
|
|
46
|
+
callback: (storage: UserSettingsTransaction) => Promise<T>,
|
|
47
|
+
): Promise<T> {
|
|
48
|
+
const before = new Map(this.values);
|
|
49
|
+
try {
|
|
50
|
+
return await callback(this);
|
|
51
|
+
} catch (error) {
|
|
52
|
+
this.values.clear();
|
|
53
|
+
for (const [key, entry] of before) this.values.set(key, entry);
|
|
54
|
+
throw error;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
class MemoryBlobs implements TemplateBlobStoreV1 {
|
|
60
|
+
readonly objects = new Map<string, string>();
|
|
61
|
+
writes = 0;
|
|
62
|
+
|
|
63
|
+
async putImmutable(key: string, document: string): Promise<void> {
|
|
64
|
+
const existing = this.objects.get(key);
|
|
65
|
+
if (existing !== undefined) {
|
|
66
|
+
if (existing !== document) {
|
|
67
|
+
throw new Error(`immutable artifact collision at ${key}`);
|
|
68
|
+
}
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
this.writes += 1;
|
|
72
|
+
this.objects.set(key, document);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
read(key: string): Promise<string | undefined> {
|
|
76
|
+
return Promise.resolve(this.objects.get(key));
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const sheep = {
|
|
81
|
+
schemaVersion: 1 as const,
|
|
82
|
+
background: "meadow",
|
|
83
|
+
upper: "wool",
|
|
84
|
+
middle: "scarf",
|
|
85
|
+
lower: "boots",
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
function botSettings(): BotSettingsViewV1 {
|
|
89
|
+
return {
|
|
90
|
+
schemaVersion: 1,
|
|
91
|
+
botId: BOT,
|
|
92
|
+
revision: 3,
|
|
93
|
+
profile: { name: "Budget", title: "Money minder" },
|
|
94
|
+
notifications: { enabled: true },
|
|
95
|
+
assignments: [
|
|
96
|
+
{
|
|
97
|
+
assignmentId: "a-1",
|
|
98
|
+
packageId: "mcp",
|
|
99
|
+
capabilityId: "mcp-tools",
|
|
100
|
+
connectionId: "connection-1",
|
|
101
|
+
state: "enabled",
|
|
102
|
+
},
|
|
103
|
+
],
|
|
104
|
+
assignmentOperations: [],
|
|
105
|
+
model: { connectionId: "connection-1", providerModelId: "glm" },
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const bots: TemplateBotReaderV1 = {
|
|
110
|
+
readSettings: () => Promise.resolve(botSettings()),
|
|
111
|
+
readSheep: () => Promise.resolve(sheep),
|
|
112
|
+
readSkills: () =>
|
|
113
|
+
Promise.resolve([
|
|
114
|
+
{
|
|
115
|
+
source: "bot" as const,
|
|
116
|
+
slug: "reconcile",
|
|
117
|
+
name: "Reconcile",
|
|
118
|
+
body: "# Reconcile\nOpen the ledger.",
|
|
119
|
+
writer: { kind: "bot" as const },
|
|
120
|
+
},
|
|
121
|
+
]),
|
|
122
|
+
readRoutines: () =>
|
|
123
|
+
Promise.resolve([
|
|
124
|
+
{
|
|
125
|
+
routineId: "r-1",
|
|
126
|
+
name: "On delivery",
|
|
127
|
+
prompt: "Handle the payload.",
|
|
128
|
+
trigger: { kind: "webhook" as const },
|
|
129
|
+
timezone: "UTC",
|
|
130
|
+
},
|
|
131
|
+
]),
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
async function harness(
|
|
135
|
+
options: { secrets?: string[]; connectionUrl?: string } = {},
|
|
136
|
+
) {
|
|
137
|
+
const storage = new MemoryStorage();
|
|
138
|
+
const blobs = new MemoryBlobs();
|
|
139
|
+
const settings = createUserSettingsBackendContribution({
|
|
140
|
+
storage,
|
|
141
|
+
availablePackages: [{ packageId: "mcp", version: "0.0.1" }],
|
|
142
|
+
});
|
|
143
|
+
await settings.read(USER);
|
|
144
|
+
await settings.createConnection(USER, {
|
|
145
|
+
connectionId: "connection-1",
|
|
146
|
+
packageId: "mcp",
|
|
147
|
+
connectionTypeId: "mcp-remote-key",
|
|
148
|
+
displayName: "Beeper",
|
|
149
|
+
state: "ready",
|
|
150
|
+
authorization: {
|
|
151
|
+
schemaVersion: 1,
|
|
152
|
+
kind: "api-key",
|
|
153
|
+
credential: {
|
|
154
|
+
schemaVersion: 1,
|
|
155
|
+
configured: true,
|
|
156
|
+
source: "api-key",
|
|
157
|
+
writable: true,
|
|
158
|
+
},
|
|
159
|
+
},
|
|
160
|
+
settings: {
|
|
161
|
+
url: options.connectionUrl ?? "https://beeper.example.test/mcp",
|
|
162
|
+
},
|
|
163
|
+
safeMetadata: { toolsHash: "abc" },
|
|
164
|
+
});
|
|
165
|
+
const secrets = options.secrets ?? ["a".repeat(32), "b".repeat(32)];
|
|
166
|
+
let issued = 0;
|
|
167
|
+
const contribution = createBotTemplateUserBackendContribution({
|
|
168
|
+
storage,
|
|
169
|
+
settings,
|
|
170
|
+
bots,
|
|
171
|
+
blobs,
|
|
172
|
+
now: () => Date.parse("2026-08-31T00:00:00.000Z"),
|
|
173
|
+
randomSecret: () => secrets[issued++ % secrets.length]!,
|
|
174
|
+
});
|
|
175
|
+
return { storage, blobs, settings, contribution };
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
describe("template/stage", () => {
|
|
179
|
+
it("stages privately, stores the blob and returns a summary", async () => {
|
|
180
|
+
const { blobs, contribution } = await harness();
|
|
181
|
+
const receipt = await contribution.execute(USER, {
|
|
182
|
+
schemaVersion: 1,
|
|
183
|
+
type: "template/stage",
|
|
184
|
+
commandId: "stage-1",
|
|
185
|
+
botId: BOT,
|
|
186
|
+
});
|
|
187
|
+
expect(receipt.share.visibility).toBe("private");
|
|
188
|
+
expect(receipt.share.shareId).toBe(`${USER}.${"a".repeat(32)}`);
|
|
189
|
+
expect(receipt.summary?.skills).toBe(1);
|
|
190
|
+
expect(receipt.summary?.routines).toBe(1);
|
|
191
|
+
expect(receipt.summary?.needsConnection).toBe(1);
|
|
192
|
+
|
|
193
|
+
const document = blobs.objects.get(templateObjectKeyV1(receipt.share.hash));
|
|
194
|
+
expect(document).toBeDefined();
|
|
195
|
+
const template = parseBotTemplateDocumentV1(document!);
|
|
196
|
+
expect(template.skills[0]?.body).toContain("Open the ledger");
|
|
197
|
+
expect(template.routines[0]?.triggerKind).toBe("webhook");
|
|
198
|
+
expect(document).not.toContain("connection-1");
|
|
199
|
+
expect(document).not.toContain("beeper.example.test");
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
it("replays a stage as a read, staging nothing a second time", async () => {
|
|
203
|
+
const { blobs, contribution } = await harness();
|
|
204
|
+
const first = await contribution.execute(USER, {
|
|
205
|
+
schemaVersion: 1,
|
|
206
|
+
type: "template/stage",
|
|
207
|
+
commandId: "stage-1",
|
|
208
|
+
botId: BOT,
|
|
209
|
+
});
|
|
210
|
+
const second = await contribution.execute(USER, {
|
|
211
|
+
schemaVersion: 1,
|
|
212
|
+
type: "template/stage",
|
|
213
|
+
commandId: "stage-1",
|
|
214
|
+
botId: BOT,
|
|
215
|
+
});
|
|
216
|
+
expect(second).toEqual(first);
|
|
217
|
+
expect((await contribution.listShares(USER)).shares).toHaveLength(1);
|
|
218
|
+
expect(blobs.writes).toBe(1);
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
it("re-staging identical content writes no second object", async () => {
|
|
222
|
+
const { blobs, contribution } = await harness();
|
|
223
|
+
const first = await contribution.execute(USER, {
|
|
224
|
+
schemaVersion: 1,
|
|
225
|
+
type: "template/stage",
|
|
226
|
+
commandId: "stage-1",
|
|
227
|
+
botId: BOT,
|
|
228
|
+
});
|
|
229
|
+
const second = await contribution.execute(USER, {
|
|
230
|
+
schemaVersion: 1,
|
|
231
|
+
type: "template/stage",
|
|
232
|
+
commandId: "stage-2",
|
|
233
|
+
botId: BOT,
|
|
234
|
+
});
|
|
235
|
+
expect(second.share.hash).toBe(first.share.hash);
|
|
236
|
+
expect(second.share.shareId).not.toBe(first.share.shareId);
|
|
237
|
+
expect(blobs.objects.size).toBe(1);
|
|
238
|
+
expect(blobs.writes).toBe(1);
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
it("refuses one command id reused for a different command", async () => {
|
|
242
|
+
const { contribution } = await harness();
|
|
243
|
+
await contribution.execute(USER, {
|
|
244
|
+
schemaVersion: 1,
|
|
245
|
+
type: "template/stage",
|
|
246
|
+
commandId: "stage-1",
|
|
247
|
+
botId: BOT,
|
|
248
|
+
});
|
|
249
|
+
await expect(
|
|
250
|
+
contribution.execute(USER, {
|
|
251
|
+
schemaVersion: 1,
|
|
252
|
+
type: "template/stage",
|
|
253
|
+
commandId: "stage-1",
|
|
254
|
+
botId: "another-bot",
|
|
255
|
+
}),
|
|
256
|
+
).rejects.toThrow(/reused for a different command/);
|
|
257
|
+
});
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
describe("visibility and revocation", () => {
|
|
261
|
+
it("refuses an unauthenticated read of a private share", async () => {
|
|
262
|
+
const { contribution } = await harness();
|
|
263
|
+
const receipt = await contribution.execute(USER, {
|
|
264
|
+
schemaVersion: 1,
|
|
265
|
+
type: "template/stage",
|
|
266
|
+
commandId: "stage-1",
|
|
267
|
+
botId: BOT,
|
|
268
|
+
});
|
|
269
|
+
expect(
|
|
270
|
+
await contribution.resolvePublicShare(receipt.share.shareId),
|
|
271
|
+
).toBeUndefined();
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
it("serves a link share and stops on revocation", async () => {
|
|
275
|
+
const { contribution } = await harness();
|
|
276
|
+
const staged = await contribution.execute(USER, {
|
|
277
|
+
schemaVersion: 1,
|
|
278
|
+
type: "template/stage",
|
|
279
|
+
commandId: "stage-1",
|
|
280
|
+
botId: BOT,
|
|
281
|
+
});
|
|
282
|
+
const shareId = staged.share.shareId;
|
|
283
|
+
await contribution.execute(USER, {
|
|
284
|
+
schemaVersion: 1,
|
|
285
|
+
type: "template/set-visibility",
|
|
286
|
+
commandId: "visibility-1",
|
|
287
|
+
shareId,
|
|
288
|
+
visibility: "link",
|
|
289
|
+
});
|
|
290
|
+
const resolved = await contribution.resolvePublicShare(shareId);
|
|
291
|
+
expect(resolved?.share.visibility).toBe("link");
|
|
292
|
+
expect(resolved?.document).toContain("Budget");
|
|
293
|
+
|
|
294
|
+
await contribution.execute(USER, {
|
|
295
|
+
schemaVersion: 1,
|
|
296
|
+
type: "template/revoke",
|
|
297
|
+
commandId: "revoke-1",
|
|
298
|
+
shareId,
|
|
299
|
+
});
|
|
300
|
+
expect(await contribution.resolvePublicShare(shareId)).toBeUndefined();
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
it("moves through private, link and public and back", async () => {
|
|
304
|
+
const { contribution } = await harness();
|
|
305
|
+
const staged = await contribution.execute(USER, {
|
|
306
|
+
schemaVersion: 1,
|
|
307
|
+
type: "template/stage",
|
|
308
|
+
commandId: "stage-1",
|
|
309
|
+
botId: BOT,
|
|
310
|
+
});
|
|
311
|
+
const shareId = staged.share.shareId;
|
|
312
|
+
for (const [index, visibility] of (
|
|
313
|
+
["link", "public", "private"] as const
|
|
314
|
+
).entries()) {
|
|
315
|
+
const receipt = await contribution.execute(USER, {
|
|
316
|
+
schemaVersion: 1,
|
|
317
|
+
type: "template/set-visibility",
|
|
318
|
+
commandId: `visibility-${index}`,
|
|
319
|
+
shareId,
|
|
320
|
+
visibility,
|
|
321
|
+
});
|
|
322
|
+
expect(receipt.share.visibility).toBe(visibility);
|
|
323
|
+
}
|
|
324
|
+
expect(await contribution.resolvePublicShare(shareId)).toBeUndefined();
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
it("keeps the moment a revoked share was revoked at", async () => {
|
|
328
|
+
const { contribution } = await harness();
|
|
329
|
+
const staged = await contribution.execute(USER, {
|
|
330
|
+
schemaVersion: 1,
|
|
331
|
+
type: "template/stage",
|
|
332
|
+
commandId: "stage-1",
|
|
333
|
+
botId: BOT,
|
|
334
|
+
});
|
|
335
|
+
const first = await contribution.execute(USER, {
|
|
336
|
+
schemaVersion: 1,
|
|
337
|
+
type: "template/revoke",
|
|
338
|
+
commandId: "revoke-1",
|
|
339
|
+
shareId: staged.share.shareId,
|
|
340
|
+
});
|
|
341
|
+
const second = await contribution.execute(USER, {
|
|
342
|
+
schemaVersion: 1,
|
|
343
|
+
type: "template/revoke",
|
|
344
|
+
commandId: "revoke-2",
|
|
345
|
+
shareId: staged.share.shareId,
|
|
346
|
+
});
|
|
347
|
+
expect(second.share.revokedAt).toBe(first.share.revokedAt!);
|
|
348
|
+
});
|
|
349
|
+
|
|
350
|
+
it("refuses to change a share whose id names another User", async () => {
|
|
351
|
+
const { contribution } = await harness();
|
|
352
|
+
await expect(
|
|
353
|
+
contribution.execute(USER, {
|
|
354
|
+
schemaVersion: 1,
|
|
355
|
+
type: "template/revoke",
|
|
356
|
+
commandId: "revoke-1",
|
|
357
|
+
shareId: `someone-else.${"c".repeat(32)}`,
|
|
358
|
+
}),
|
|
359
|
+
).rejects.toThrow(/was not found/);
|
|
360
|
+
});
|
|
361
|
+
});
|