@frockbot/template-core 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/package.json +17 -6
- package/src/index.test.ts +323 -0
- package/src/index.ts +795 -0
- package/tsconfig.json +14 -0
- package/README.md +0 -3
package/package.json
CHANGED
|
@@ -1,14 +1,25 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@frockbot/template-core",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"private": false,
|
|
5
|
+
"type": "module",
|
|
6
|
+
"exports": {
|
|
7
|
+
".": "./src/index.ts"
|
|
8
|
+
},
|
|
9
|
+
"scripts": {
|
|
10
|
+
"test": "bun test src",
|
|
11
|
+
"typecheck": "tsc --noEmit -p tsconfig.json"
|
|
12
|
+
},
|
|
13
|
+
"devDependencies": {
|
|
14
|
+
"@types/bun": "1.3.6",
|
|
15
|
+
"typescript": "^7.0.2"
|
|
16
|
+
},
|
|
17
|
+
"publishConfig": {
|
|
18
|
+
"access": "public"
|
|
19
|
+
},
|
|
6
20
|
"repository": {
|
|
7
21
|
"type": "git",
|
|
8
22
|
"url": "git+https://github.com/timoconnellaus/frockbot.git",
|
|
9
23
|
"directory": "packages/template-core"
|
|
10
|
-
},
|
|
11
|
-
"publishConfig": {
|
|
12
|
-
"access": "public"
|
|
13
24
|
}
|
|
14
25
|
}
|
|
@@ -0,0 +1,323 @@
|
|
|
1
|
+
import { describe, expect, it } from "bun:test";
|
|
2
|
+
import {
|
|
3
|
+
assertTemplateDocumentSizeV1,
|
|
4
|
+
canonicalBotTemplateDocumentV1,
|
|
5
|
+
decodeBotTemplateDocumentV1,
|
|
6
|
+
decodeBotTemplateV1,
|
|
7
|
+
decodeTemplateShareRecordV1,
|
|
8
|
+
isTemplateShareReadableV1,
|
|
9
|
+
MAX_TEMPLATE_BYTES_V1,
|
|
10
|
+
MAX_TEMPLATE_SKILLS_V1,
|
|
11
|
+
parseBotTemplateDocumentV1,
|
|
12
|
+
parseTemplateShareIdV1,
|
|
13
|
+
templateContentHashV1,
|
|
14
|
+
templateObjectKeyV1,
|
|
15
|
+
templateShareIdV1,
|
|
16
|
+
TemplateDecodeError,
|
|
17
|
+
type BotTemplateV1,
|
|
18
|
+
} from "./index.ts";
|
|
19
|
+
|
|
20
|
+
const sheep = {
|
|
21
|
+
schemaVersion: 1 as const,
|
|
22
|
+
background: "meadow",
|
|
23
|
+
upper: "wool",
|
|
24
|
+
middle: "scarf",
|
|
25
|
+
lower: "boots",
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
function template(overrides: Partial<BotTemplateV1> = {}): BotTemplateV1 {
|
|
29
|
+
return {
|
|
30
|
+
schemaVersion: 1,
|
|
31
|
+
profile: {
|
|
32
|
+
name: "Budget",
|
|
33
|
+
title: "Money minder",
|
|
34
|
+
avatar: { kind: "sheep", recipe: sheep },
|
|
35
|
+
},
|
|
36
|
+
skills: [
|
|
37
|
+
{ slug: "reconcile", name: "Reconcile", body: "# Reconcile\nSteps." },
|
|
38
|
+
],
|
|
39
|
+
routines: [
|
|
40
|
+
{
|
|
41
|
+
slug: "daily",
|
|
42
|
+
name: "Daily",
|
|
43
|
+
prompt: "Check the ledger.",
|
|
44
|
+
schedule: "0 9 * * *",
|
|
45
|
+
timezone: "Australia/Sydney",
|
|
46
|
+
triggerKind: "cron",
|
|
47
|
+
},
|
|
48
|
+
],
|
|
49
|
+
packages: [
|
|
50
|
+
{
|
|
51
|
+
packageId: "mcp",
|
|
52
|
+
catalogId: "example-connector",
|
|
53
|
+
version: "0.0.1",
|
|
54
|
+
displayName: "Example",
|
|
55
|
+
},
|
|
56
|
+
],
|
|
57
|
+
mcpServers: [
|
|
58
|
+
{
|
|
59
|
+
kind: "public",
|
|
60
|
+
name: "Example",
|
|
61
|
+
url: "https://mcp.example.test/mcp",
|
|
62
|
+
transport: "streamable-http",
|
|
63
|
+
},
|
|
64
|
+
],
|
|
65
|
+
sourceCatalogGeneration: "gen-1",
|
|
66
|
+
...overrides,
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
describe("decodeBotTemplateV1", () => {
|
|
71
|
+
it("round-trips a full template", () => {
|
|
72
|
+
expect(decodeBotTemplateV1(template())).toEqual(template());
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it("refuses an unknown key", () => {
|
|
76
|
+
expect(() => decodeBotTemplateV1({ ...template(), memory: [] })).toThrow(
|
|
77
|
+
TemplateDecodeError,
|
|
78
|
+
);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it("refuses an unknown key inside a skill", () => {
|
|
82
|
+
expect(() =>
|
|
83
|
+
decodeBotTemplateV1(
|
|
84
|
+
template({
|
|
85
|
+
skills: [
|
|
86
|
+
{
|
|
87
|
+
slug: "a",
|
|
88
|
+
name: "A",
|
|
89
|
+
body: "b",
|
|
90
|
+
// @ts-expect-error a template skill carries no writer
|
|
91
|
+
writer: { kind: "user" },
|
|
92
|
+
},
|
|
93
|
+
],
|
|
94
|
+
}),
|
|
95
|
+
),
|
|
96
|
+
).toThrow(TemplateDecodeError);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it("refuses an avatar that is not a sheep recipe", () => {
|
|
100
|
+
expect(() =>
|
|
101
|
+
decodeBotTemplateV1({
|
|
102
|
+
...template(),
|
|
103
|
+
profile: {
|
|
104
|
+
name: "Budget",
|
|
105
|
+
avatar: { kind: "image", recipe: sheep },
|
|
106
|
+
},
|
|
107
|
+
}),
|
|
108
|
+
).toThrow(/sheep recipe/);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it("refuses an avatar carrying uploaded image bytes", () => {
|
|
112
|
+
expect(() =>
|
|
113
|
+
decodeBotTemplateV1({
|
|
114
|
+
...template(),
|
|
115
|
+
profile: {
|
|
116
|
+
name: "Budget",
|
|
117
|
+
avatar: { kind: "image", digest: "a".repeat(64) },
|
|
118
|
+
},
|
|
119
|
+
}),
|
|
120
|
+
).toThrow(TemplateDecodeError);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it("refuses a repeated skill slug", () => {
|
|
124
|
+
expect(() =>
|
|
125
|
+
decodeBotTemplateV1(
|
|
126
|
+
template({
|
|
127
|
+
skills: [
|
|
128
|
+
{ slug: "a", name: "A", body: "one" },
|
|
129
|
+
{ slug: "a", name: "A2", body: "two" },
|
|
130
|
+
],
|
|
131
|
+
}),
|
|
132
|
+
),
|
|
133
|
+
).toThrow(/repeats a skill slug/);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it("refuses more skills than the bound", () => {
|
|
137
|
+
expect(() =>
|
|
138
|
+
decodeBotTemplateV1(
|
|
139
|
+
template({
|
|
140
|
+
skills: Array.from(
|
|
141
|
+
{ length: MAX_TEMPLATE_SKILLS_V1 + 1 },
|
|
142
|
+
(_value, index) => ({
|
|
143
|
+
slug: `skill-${index}`,
|
|
144
|
+
name: "S",
|
|
145
|
+
body: "b",
|
|
146
|
+
}),
|
|
147
|
+
),
|
|
148
|
+
}),
|
|
149
|
+
),
|
|
150
|
+
).toThrow(/bounded array/);
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it("refuses a webhook routine that also carries a schedule", () => {
|
|
154
|
+
expect(() =>
|
|
155
|
+
decodeBotTemplateV1(
|
|
156
|
+
template({
|
|
157
|
+
routines: [
|
|
158
|
+
{
|
|
159
|
+
slug: "hook",
|
|
160
|
+
name: "Hook",
|
|
161
|
+
prompt: "go",
|
|
162
|
+
schedule: "0 9 * * *",
|
|
163
|
+
timezone: "UTC",
|
|
164
|
+
triggerKind: "webhook",
|
|
165
|
+
},
|
|
166
|
+
],
|
|
167
|
+
}),
|
|
168
|
+
),
|
|
169
|
+
).toThrow(/carries no schedule/);
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
it("refuses a non-https MCP server url", () => {
|
|
173
|
+
expect(() =>
|
|
174
|
+
decodeBotTemplateV1(
|
|
175
|
+
template({
|
|
176
|
+
mcpServers: [
|
|
177
|
+
{
|
|
178
|
+
kind: "public",
|
|
179
|
+
name: "Example",
|
|
180
|
+
url: "http://mcp.example.test/mcp",
|
|
181
|
+
transport: "sse",
|
|
182
|
+
},
|
|
183
|
+
],
|
|
184
|
+
}),
|
|
185
|
+
),
|
|
186
|
+
).toThrow(/https/);
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
it("accepts a needs-connection placeholder with no url at all", () => {
|
|
190
|
+
const decoded = decodeBotTemplateV1(
|
|
191
|
+
template({
|
|
192
|
+
mcpServers: [
|
|
193
|
+
{
|
|
194
|
+
kind: "needs-connection",
|
|
195
|
+
name: "Beeper",
|
|
196
|
+
connectionTypeId: "mcp-remote-key",
|
|
197
|
+
hint: "Add your own key.",
|
|
198
|
+
},
|
|
199
|
+
],
|
|
200
|
+
}),
|
|
201
|
+
);
|
|
202
|
+
expect(decoded.mcpServers[0]).toEqual({
|
|
203
|
+
kind: "needs-connection",
|
|
204
|
+
name: "Beeper",
|
|
205
|
+
connectionTypeId: "mcp-remote-key",
|
|
206
|
+
hint: "Add your own key.",
|
|
207
|
+
});
|
|
208
|
+
});
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
describe("canonical bytes and the content hash", () => {
|
|
212
|
+
it("is stable across key order", async () => {
|
|
213
|
+
const one = canonicalBotTemplateDocumentV1(template());
|
|
214
|
+
const shuffled = JSON.parse(
|
|
215
|
+
JSON.stringify({
|
|
216
|
+
mcpServers: template().mcpServers,
|
|
217
|
+
packages: template().packages,
|
|
218
|
+
routines: template().routines,
|
|
219
|
+
skills: template().skills,
|
|
220
|
+
profile: template().profile,
|
|
221
|
+
sourceCatalogGeneration: template().sourceCatalogGeneration,
|
|
222
|
+
schemaVersion: 1,
|
|
223
|
+
}),
|
|
224
|
+
) as BotTemplateV1;
|
|
225
|
+
const two = canonicalBotTemplateDocumentV1(shuffled);
|
|
226
|
+
expect(two).toBe(one);
|
|
227
|
+
expect(await templateContentHashV1(two)).toBe(
|
|
228
|
+
await templateContentHashV1(one),
|
|
229
|
+
);
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
it("verifies the hash before it parses", async () => {
|
|
233
|
+
const document = canonicalBotTemplateDocumentV1(template());
|
|
234
|
+
const hash = await templateContentHashV1(document);
|
|
235
|
+
expect(
|
|
236
|
+
(await decodeBotTemplateDocumentV1(document, hash)).profile.name,
|
|
237
|
+
).toBe("Budget");
|
|
238
|
+
await expect(
|
|
239
|
+
decodeBotTemplateDocumentV1(document, "0".repeat(64)),
|
|
240
|
+
).rejects.toThrow(/content hash/);
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
it("keys a blob by its hash", async () => {
|
|
244
|
+
const hash = await templateContentHashV1("{}");
|
|
245
|
+
expect(templateObjectKeyV1(hash)).toBe(`templates/${hash}.json`);
|
|
246
|
+
expect(() => templateObjectKeyV1("not-a-hash")).toThrow(
|
|
247
|
+
TemplateDecodeError,
|
|
248
|
+
);
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
it("refuses an oversize document in bytes, not characters", () => {
|
|
252
|
+
const padded = "é".repeat(MAX_TEMPLATE_BYTES_V1 - 10);
|
|
253
|
+
expect(padded.length).toBeLessThan(MAX_TEMPLATE_BYTES_V1);
|
|
254
|
+
expect(() => assertTemplateDocumentSizeV1(padded)).toThrow(/bound is/);
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
it("refuses an oversize blob before it parses it", () => {
|
|
258
|
+
expect(() =>
|
|
259
|
+
parseBotTemplateDocumentV1(" ".repeat(MAX_TEMPLATE_BYTES_V1 + 1)),
|
|
260
|
+
).toThrow(/bound is/);
|
|
261
|
+
});
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
describe("share identity and visibility", () => {
|
|
265
|
+
it("round-trips a share id", () => {
|
|
266
|
+
const shareId = templateShareIdV1("user-42", "a".repeat(32));
|
|
267
|
+
expect(shareId).toBe(`user-42.${"a".repeat(32)}`);
|
|
268
|
+
expect(parseTemplateShareIdV1(shareId)).toEqual({
|
|
269
|
+
ownerId: "user-42",
|
|
270
|
+
secret: "a".repeat(32),
|
|
271
|
+
});
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
it("splits on the last dot so a dotted owner id survives", () => {
|
|
275
|
+
const shareId = `first.last.${"b".repeat(32)}`;
|
|
276
|
+
expect(() => parseTemplateShareIdV1(shareId)).toThrow(TemplateDecodeError);
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
it("refuses a share id with no secret", () => {
|
|
280
|
+
expect(() => parseTemplateShareIdV1("user-42.short")).toThrow(
|
|
281
|
+
TemplateDecodeError,
|
|
282
|
+
);
|
|
283
|
+
expect(() => parseTemplateShareIdV1(".aaaa")).toThrow(TemplateDecodeError);
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
it("reads only a link or public share that is not revoked", () => {
|
|
287
|
+
const base = decodeTemplateShareRecordV1({
|
|
288
|
+
schemaVersion: 1,
|
|
289
|
+
shareId: templateShareIdV1("user-42", "c".repeat(32)),
|
|
290
|
+
hash: "d".repeat(64),
|
|
291
|
+
botId: "budget",
|
|
292
|
+
visibility: "link",
|
|
293
|
+
createdAt: "2026-08-31T00:00:00.000Z",
|
|
294
|
+
});
|
|
295
|
+
expect(isTemplateShareReadableV1(base)).toBe(true);
|
|
296
|
+
expect(isTemplateShareReadableV1({ ...base, visibility: "public" })).toBe(
|
|
297
|
+
true,
|
|
298
|
+
);
|
|
299
|
+
expect(isTemplateShareReadableV1({ ...base, visibility: "private" })).toBe(
|
|
300
|
+
false,
|
|
301
|
+
);
|
|
302
|
+
expect(
|
|
303
|
+
isTemplateShareReadableV1({
|
|
304
|
+
...base,
|
|
305
|
+
revokedAt: "2026-08-31T01:00:00.000Z",
|
|
306
|
+
}),
|
|
307
|
+
).toBe(false);
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
it("refuses a share record with an unknown key", () => {
|
|
311
|
+
expect(() =>
|
|
312
|
+
decodeTemplateShareRecordV1({
|
|
313
|
+
schemaVersion: 1,
|
|
314
|
+
shareId: templateShareIdV1("user-42", "c".repeat(32)),
|
|
315
|
+
hash: "d".repeat(64),
|
|
316
|
+
botId: "budget",
|
|
317
|
+
visibility: "link",
|
|
318
|
+
createdAt: "2026-08-31T00:00:00.000Z",
|
|
319
|
+
apiKey: "secret",
|
|
320
|
+
}),
|
|
321
|
+
).toThrow(TemplateDecodeError);
|
|
322
|
+
});
|
|
323
|
+
});
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,795 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Bot Template seam.
|
|
3
|
+
*
|
|
4
|
+
* A Bot Template is a *recipe, not a backup*: an immutable, content-addressed
|
|
5
|
+
* JSON document describing how to build a Bot like this one, in prose and
|
|
6
|
+
* references only. It never carries state, secrets, credentials, Connections,
|
|
7
|
+
* Memory, transcripts, or bytes.
|
|
8
|
+
*
|
|
9
|
+
* The register (`docs/research/grokbot-computer.md` lines 313–330) fixes the
|
|
10
|
+
* shape GrokBot's `create_bot_share_json` packs and the contracts that go with
|
|
11
|
+
* it: the host never falls back to the owner's live files, scrubbing lives only
|
|
12
|
+
* in the pack arguments, managed and plugin Skills are always excluded, and the
|
|
13
|
+
* payload is bounded at ~100 000 characters. Three FrockBot readings depart
|
|
14
|
+
* from it, each recorded in `docs/adr/0015-bot-template-recipe.md`: a
|
|
15
|
+
* marketplace `pluginId` becomes `packageId` + `catalogId` + `version`,
|
|
16
|
+
* publication is a User act rather than a tool argument, and Memory is not
|
|
17
|
+
* exported at all.
|
|
18
|
+
*
|
|
19
|
+
* This module mirrors `catalog-core`'s role exactly: DTOs, strict exact-key
|
|
20
|
+
* decoders, bounds, the object-key layout and the content hash — no I/O, no
|
|
21
|
+
* runtime dependency, importable by the gateway, the User Durable Object, the
|
|
22
|
+
* Bot's runtime Contribution and the browser alike.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
export class TemplateDecodeError extends Error {
|
|
26
|
+
constructor(message: string, options?: ErrorOptions) {
|
|
27
|
+
super(message, options);
|
|
28
|
+
this.name = "TemplateDecodeError";
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Bounds every template document. `MAX_TEMPLATE_BYTES_V1` is the register's own
|
|
34
|
+
* ~100 000-character payload bound (line 329); the rest mirror `MAX_CATALOG_*`
|
|
35
|
+
* so a hostile blob cannot exhaust a Durable Object that decodes one.
|
|
36
|
+
*/
|
|
37
|
+
export const MAX_TEMPLATE_BYTES_V1 = 100_000;
|
|
38
|
+
export const MAX_TEMPLATE_SKILLS_V1 = 200;
|
|
39
|
+
export const MAX_TEMPLATE_ROUTINES_V1 = 100;
|
|
40
|
+
export const MAX_TEMPLATE_PACKAGES_V1 = 32;
|
|
41
|
+
export const MAX_TEMPLATE_SERVERS_V1 = 16;
|
|
42
|
+
export const MAX_TEMPLATE_SKILL_BODY_BYTES_V1 = 16_384;
|
|
43
|
+
export const MAX_TEMPLATE_ROUTINE_PROMPT_BYTES_V1 = 8_000;
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* The generated sheep avatar, structurally.
|
|
47
|
+
*
|
|
48
|
+
* Declared here rather than imported from `plugin-flock` so this package keeps
|
|
49
|
+
* its promise of no runtime dependency: a template travels between deployments,
|
|
50
|
+
* and its decoder must not need a Package mounted to read it. The layer ids are
|
|
51
|
+
* opaque strings on this side of the seam — the Flock's own decoder is the one
|
|
52
|
+
* that knows which ids exist, and it is what an importing deployment runs
|
|
53
|
+
* before it materializes a Bot.
|
|
54
|
+
*/
|
|
55
|
+
export interface TemplateSheepRecipeV1 {
|
|
56
|
+
schemaVersion: 1;
|
|
57
|
+
background: string;
|
|
58
|
+
upper: string;
|
|
59
|
+
middle: string;
|
|
60
|
+
lower: string;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** The profile a template carries. Its avatar is the Bot's sheep recipe (D1). */
|
|
64
|
+
export interface TemplateProfileV1 {
|
|
65
|
+
name: string;
|
|
66
|
+
title?: string;
|
|
67
|
+
description?: string;
|
|
68
|
+
avatar: { kind: "sheep"; recipe: TemplateSheepRecipeV1 };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** One own-root Skill, body verbatim. Managed and plugin Skills never appear. */
|
|
72
|
+
export interface TemplateSkillV1 {
|
|
73
|
+
slug: string;
|
|
74
|
+
name: string;
|
|
75
|
+
description?: string;
|
|
76
|
+
body: string;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* One Routine. A webhook Routine carries `triggerKind: "webhook"` and nothing
|
|
81
|
+
* else about its trigger: `plugin-routines/src/shared.ts` — "A `RoutineViewV1`
|
|
82
|
+
* never carries a webhook key or its digest" — and a template is weaker still,
|
|
83
|
+
* because it crosses to another User entirely.
|
|
84
|
+
*/
|
|
85
|
+
export interface TemplateRoutineV1 {
|
|
86
|
+
slug: string;
|
|
87
|
+
name: string;
|
|
88
|
+
prompt: string;
|
|
89
|
+
schedule?: string;
|
|
90
|
+
timezone: string;
|
|
91
|
+
triggerKind?: "webhook" | "cron";
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* One installable Package, by Catalog identity. There is no numeric
|
|
96
|
+
* marketplace id in FrockBot, and an install must validate against an
|
|
97
|
+
* immutable generation, so a template names what the importer looks up in
|
|
98
|
+
* *their own* pinned generation rather than a version this one happened to see.
|
|
99
|
+
*/
|
|
100
|
+
export interface TemplatePackageV1 {
|
|
101
|
+
packageId: string;
|
|
102
|
+
catalogId: string;
|
|
103
|
+
version: string;
|
|
104
|
+
displayName: string;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export type TemplateMcpServerV1 =
|
|
108
|
+
| {
|
|
109
|
+
kind: "public";
|
|
110
|
+
name: string;
|
|
111
|
+
url: string;
|
|
112
|
+
transport: "streamable-http" | "sse";
|
|
113
|
+
}
|
|
114
|
+
| {
|
|
115
|
+
kind: "needs-connection";
|
|
116
|
+
name: string;
|
|
117
|
+
connectionTypeId: string;
|
|
118
|
+
hint?: string;
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* One template document.
|
|
123
|
+
*
|
|
124
|
+
* There is deliberately no `createdAt` here. A template is content-addressed,
|
|
125
|
+
* so the bytes must be a pure function of the Bot they describe: a timestamp
|
|
126
|
+
* would make every re-export a different hash and a different object, and
|
|
127
|
+
* "re-exporting an unchanged Bot changes nothing" is exactly the property
|
|
128
|
+
* content addressing is for. When a share was packed is share metadata, and it
|
|
129
|
+
* lives on {@link TemplateShareRecordV1}, which is mutable state anyway.
|
|
130
|
+
*/
|
|
131
|
+
export interface BotTemplateV1 {
|
|
132
|
+
schemaVersion: 1;
|
|
133
|
+
profile: TemplateProfileV1;
|
|
134
|
+
skills: TemplateSkillV1[];
|
|
135
|
+
routines: TemplateRoutineV1[];
|
|
136
|
+
packages: TemplatePackageV1[];
|
|
137
|
+
mcpServers: TemplateMcpServerV1[];
|
|
138
|
+
/** The Catalog generation the *source* User was pinned to, for provenance. */
|
|
139
|
+
sourceCatalogGeneration?: string;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* A share of one template blob.
|
|
144
|
+
*
|
|
145
|
+
* Visibility is *not* in the blob. The blob is content-addressed and immutable,
|
|
146
|
+
* so it can never be un-published; a share is revocable state, and it lives in
|
|
147
|
+
* the User Durable Object that owns it (D3). `shareId` carries the owning
|
|
148
|
+
* User's public id as its first component, so an unauthenticated `GET` routes
|
|
149
|
+
* to exactly one User Durable Object without a lookup table anywhere.
|
|
150
|
+
*/
|
|
151
|
+
export type TemplateVisibilityV1 = "private" | "link" | "public";
|
|
152
|
+
|
|
153
|
+
export const TEMPLATE_VISIBILITIES_V1: readonly TemplateVisibilityV1[] = [
|
|
154
|
+
"private",
|
|
155
|
+
"link",
|
|
156
|
+
"public",
|
|
157
|
+
];
|
|
158
|
+
|
|
159
|
+
export interface TemplateShareRecordV1 {
|
|
160
|
+
schemaVersion: 1;
|
|
161
|
+
shareId: string;
|
|
162
|
+
hash: string;
|
|
163
|
+
botId: string;
|
|
164
|
+
visibility: TemplateVisibilityV1;
|
|
165
|
+
createdAt: string;
|
|
166
|
+
revokedAt?: string;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const HASH_PATTERN = /^[0-9a-f]{64}$/;
|
|
170
|
+
const SLUG_PATTERN = /^[a-z0-9][a-z0-9-]{0,127}$/;
|
|
171
|
+
const PACKAGE_ID_PATTERN = /^[a-z][a-z0-9-]*$/;
|
|
172
|
+
const CATALOG_ID_PATTERN = /^[a-z0-9][a-z0-9-]{0,63}$/;
|
|
173
|
+
const GENERATION_PATTERN = /^[a-z0-9][a-z0-9-]{0,63}$/;
|
|
174
|
+
const CONNECTION_TYPE_PATTERN = /^[a-z0-9][a-z0-9-]{0,63}$/;
|
|
175
|
+
const SHARE_SECRET_PATTERN = /^[0-9a-f]{32}$/;
|
|
176
|
+
const SHARE_OWNER_PATTERN = /^[a-zA-Z0-9][a-zA-Z0-9_-]{0,95}$/;
|
|
177
|
+
|
|
178
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
179
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function exactRecord(
|
|
183
|
+
value: unknown,
|
|
184
|
+
label: string,
|
|
185
|
+
required: readonly string[],
|
|
186
|
+
optional: readonly string[] = [],
|
|
187
|
+
): Record<string, unknown> {
|
|
188
|
+
if (!isRecord(value)) {
|
|
189
|
+
throw new TemplateDecodeError(`${label} must be an object`);
|
|
190
|
+
}
|
|
191
|
+
const allowed = new Set([...required, ...optional]);
|
|
192
|
+
for (const key of Reflect.ownKeys(value)) {
|
|
193
|
+
if (typeof key !== "string" || !allowed.has(key)) {
|
|
194
|
+
throw new TemplateDecodeError(
|
|
195
|
+
`${label} has unknown field "${String(key)}"`,
|
|
196
|
+
);
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
for (const key of required) {
|
|
200
|
+
if (!Object.hasOwn(value, key)) {
|
|
201
|
+
throw new TemplateDecodeError(`${label} is missing "${key}"`);
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
return value;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function text(
|
|
208
|
+
value: unknown,
|
|
209
|
+
label: string,
|
|
210
|
+
maxLength: number,
|
|
211
|
+
{ allowEmpty = false } = {},
|
|
212
|
+
): string {
|
|
213
|
+
if (
|
|
214
|
+
typeof value !== "string" ||
|
|
215
|
+
value.length > maxLength ||
|
|
216
|
+
(!allowEmpty && value.trim().length === 0)
|
|
217
|
+
) {
|
|
218
|
+
throw new TemplateDecodeError(`${label} is invalid`);
|
|
219
|
+
}
|
|
220
|
+
return value;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function optionalText(
|
|
224
|
+
value: unknown,
|
|
225
|
+
label: string,
|
|
226
|
+
maxLength: number,
|
|
227
|
+
): string | undefined {
|
|
228
|
+
return value === undefined ? undefined : text(value, label, maxLength);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function pattern(
|
|
232
|
+
value: unknown,
|
|
233
|
+
label: string,
|
|
234
|
+
expression: RegExp,
|
|
235
|
+
maxLength = 128,
|
|
236
|
+
): string {
|
|
237
|
+
const candidate = text(value, label, maxLength);
|
|
238
|
+
if (!expression.test(candidate)) {
|
|
239
|
+
throw new TemplateDecodeError(`${label} is invalid`);
|
|
240
|
+
}
|
|
241
|
+
return candidate;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
function boundedArray(
|
|
245
|
+
value: unknown,
|
|
246
|
+
label: string,
|
|
247
|
+
maxLength: number,
|
|
248
|
+
): unknown[] {
|
|
249
|
+
if (!Array.isArray(value) || value.length > maxLength) {
|
|
250
|
+
throw new TemplateDecodeError(`${label} must be a bounded array`);
|
|
251
|
+
}
|
|
252
|
+
return value;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
function timestamp(value: unknown, label: string): string {
|
|
256
|
+
const candidate = text(value, label, 64);
|
|
257
|
+
if (!Number.isFinite(new Date(candidate).getTime())) {
|
|
258
|
+
throw new TemplateDecodeError(`${label} is invalid`);
|
|
259
|
+
}
|
|
260
|
+
return candidate;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* A template's MCP server URL is rendered by a browser and may be handed to a
|
|
265
|
+
* fetch on import, so only an absolute `https:` URL is admitted here — the same
|
|
266
|
+
* rule `catalog-core` holds a logo to, for the same reason.
|
|
267
|
+
*/
|
|
268
|
+
function httpsUrl(value: unknown, label: string): string {
|
|
269
|
+
const candidate = text(value, label, 2_048);
|
|
270
|
+
const url = URL.parse(candidate);
|
|
271
|
+
if (!url || url.protocol !== "https:" || url.username || url.password) {
|
|
272
|
+
throw new TemplateDecodeError(`${label} must be an https URL`);
|
|
273
|
+
}
|
|
274
|
+
return candidate;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
function withOptional<T extends Record<string, unknown>>(
|
|
278
|
+
base: T,
|
|
279
|
+
optional: Record<string, string | undefined>,
|
|
280
|
+
): T {
|
|
281
|
+
const result: Record<string, unknown> = { ...base };
|
|
282
|
+
for (const [key, value] of Object.entries(optional)) {
|
|
283
|
+
if (value !== undefined) result[key] = value;
|
|
284
|
+
}
|
|
285
|
+
return result as T;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
export function decodeTemplateContentHashV1(
|
|
289
|
+
value: unknown,
|
|
290
|
+
label = "template hash",
|
|
291
|
+
): string {
|
|
292
|
+
return pattern(value, label, HASH_PATTERN, 64);
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
export function decodeTemplateSheepRecipeV1(
|
|
296
|
+
value: unknown,
|
|
297
|
+
): TemplateSheepRecipeV1 {
|
|
298
|
+
const recipe = exactRecord(value, "template sheep", [
|
|
299
|
+
"schemaVersion",
|
|
300
|
+
"background",
|
|
301
|
+
"upper",
|
|
302
|
+
"middle",
|
|
303
|
+
"lower",
|
|
304
|
+
]);
|
|
305
|
+
if (recipe.schemaVersion !== 1) {
|
|
306
|
+
throw new TemplateDecodeError("template sheep schema version is invalid");
|
|
307
|
+
}
|
|
308
|
+
return {
|
|
309
|
+
schemaVersion: 1,
|
|
310
|
+
background: text(recipe.background, "template sheep background", 128),
|
|
311
|
+
upper: text(recipe.upper, "template sheep upper", 128),
|
|
312
|
+
middle: text(recipe.middle, "template sheep middle", 128),
|
|
313
|
+
lower: text(recipe.lower, "template sheep lower", 128),
|
|
314
|
+
};
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
function decodeTemplateProfileV1(value: unknown): TemplateProfileV1 {
|
|
318
|
+
const profile = exactRecord(
|
|
319
|
+
value,
|
|
320
|
+
"template profile",
|
|
321
|
+
["name", "avatar"],
|
|
322
|
+
["title", "description"],
|
|
323
|
+
);
|
|
324
|
+
const avatar = exactRecord(profile.avatar, "template avatar", [
|
|
325
|
+
"kind",
|
|
326
|
+
"recipe",
|
|
327
|
+
]);
|
|
328
|
+
if (avatar.kind !== "sheep") {
|
|
329
|
+
// Sheep recipes are the only Bot avatar. A template claiming another kind
|
|
330
|
+
// is refused rather than silently downgraded.
|
|
331
|
+
throw new TemplateDecodeError("template avatar must be a sheep recipe");
|
|
332
|
+
}
|
|
333
|
+
return withOptional(
|
|
334
|
+
{
|
|
335
|
+
name: text(profile.name, "template profile name", 100),
|
|
336
|
+
avatar: {
|
|
337
|
+
kind: "sheep" as const,
|
|
338
|
+
recipe: decodeTemplateSheepRecipeV1(avatar.recipe),
|
|
339
|
+
},
|
|
340
|
+
},
|
|
341
|
+
{
|
|
342
|
+
title: optionalText(profile.title, "template profile title", 120),
|
|
343
|
+
description: optionalText(
|
|
344
|
+
profile.description,
|
|
345
|
+
"template profile description",
|
|
346
|
+
10_000,
|
|
347
|
+
),
|
|
348
|
+
},
|
|
349
|
+
);
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
function decodeTemplateSkillV1(value: unknown): TemplateSkillV1 {
|
|
353
|
+
const skill = exactRecord(
|
|
354
|
+
value,
|
|
355
|
+
"template skill",
|
|
356
|
+
["slug", "name", "body"],
|
|
357
|
+
["description"],
|
|
358
|
+
);
|
|
359
|
+
return withOptional(
|
|
360
|
+
{
|
|
361
|
+
slug: pattern(skill.slug, "template skill slug", SLUG_PATTERN),
|
|
362
|
+
name: text(skill.name, "template skill name", 100),
|
|
363
|
+
body: text(
|
|
364
|
+
skill.body,
|
|
365
|
+
"template skill body",
|
|
366
|
+
MAX_TEMPLATE_SKILL_BODY_BYTES_V1,
|
|
367
|
+
),
|
|
368
|
+
},
|
|
369
|
+
{
|
|
370
|
+
description: optionalText(
|
|
371
|
+
skill.description,
|
|
372
|
+
"template skill description",
|
|
373
|
+
2_000,
|
|
374
|
+
),
|
|
375
|
+
},
|
|
376
|
+
);
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
function decodeTemplateRoutineV1(value: unknown): TemplateRoutineV1 {
|
|
380
|
+
const routine = exactRecord(
|
|
381
|
+
value,
|
|
382
|
+
"template routine",
|
|
383
|
+
["slug", "name", "prompt", "timezone"],
|
|
384
|
+
["schedule", "triggerKind"],
|
|
385
|
+
);
|
|
386
|
+
if (
|
|
387
|
+
routine.triggerKind !== undefined &&
|
|
388
|
+
routine.triggerKind !== "webhook" &&
|
|
389
|
+
routine.triggerKind !== "cron"
|
|
390
|
+
) {
|
|
391
|
+
throw new TemplateDecodeError("template routine triggerKind is invalid");
|
|
392
|
+
}
|
|
393
|
+
const schedule = optionalText(
|
|
394
|
+
routine.schedule,
|
|
395
|
+
"template routine schedule",
|
|
396
|
+
256,
|
|
397
|
+
);
|
|
398
|
+
if (routine.triggerKind === "webhook" && schedule !== undefined) {
|
|
399
|
+
throw new TemplateDecodeError(
|
|
400
|
+
"a webhook template routine carries no schedule",
|
|
401
|
+
);
|
|
402
|
+
}
|
|
403
|
+
return {
|
|
404
|
+
slug: pattern(routine.slug, "template routine slug", SLUG_PATTERN),
|
|
405
|
+
name: text(routine.name, "template routine name", 100),
|
|
406
|
+
prompt: text(
|
|
407
|
+
routine.prompt,
|
|
408
|
+
"template routine prompt",
|
|
409
|
+
MAX_TEMPLATE_ROUTINE_PROMPT_BYTES_V1,
|
|
410
|
+
),
|
|
411
|
+
...(schedule === undefined ? {} : { schedule }),
|
|
412
|
+
timezone: text(routine.timezone, "template routine timezone", 64),
|
|
413
|
+
...(routine.triggerKind === undefined
|
|
414
|
+
? {}
|
|
415
|
+
: { triggerKind: routine.triggerKind }),
|
|
416
|
+
};
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
function decodeTemplatePackageV1(value: unknown): TemplatePackageV1 {
|
|
420
|
+
const entry = exactRecord(value, "template package", [
|
|
421
|
+
"packageId",
|
|
422
|
+
"catalogId",
|
|
423
|
+
"version",
|
|
424
|
+
"displayName",
|
|
425
|
+
]);
|
|
426
|
+
return {
|
|
427
|
+
packageId: pattern(
|
|
428
|
+
entry.packageId,
|
|
429
|
+
"template packageId",
|
|
430
|
+
PACKAGE_ID_PATTERN,
|
|
431
|
+
64,
|
|
432
|
+
),
|
|
433
|
+
catalogId: pattern(
|
|
434
|
+
entry.catalogId,
|
|
435
|
+
"template catalogId",
|
|
436
|
+
CATALOG_ID_PATTERN,
|
|
437
|
+
64,
|
|
438
|
+
),
|
|
439
|
+
version: text(entry.version, "template package version", 100),
|
|
440
|
+
displayName: text(entry.displayName, "template package displayName", 100),
|
|
441
|
+
};
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
function decodeTemplateMcpServerV1(value: unknown): TemplateMcpServerV1 {
|
|
445
|
+
if (!isRecord(value)) {
|
|
446
|
+
throw new TemplateDecodeError("template MCP server must be an object");
|
|
447
|
+
}
|
|
448
|
+
if (value.kind === "public") {
|
|
449
|
+
const server = exactRecord(value, "template MCP server", [
|
|
450
|
+
"kind",
|
|
451
|
+
"name",
|
|
452
|
+
"url",
|
|
453
|
+
"transport",
|
|
454
|
+
]);
|
|
455
|
+
if (server.transport !== "streamable-http" && server.transport !== "sse") {
|
|
456
|
+
throw new TemplateDecodeError("template MCP transport is invalid");
|
|
457
|
+
}
|
|
458
|
+
return {
|
|
459
|
+
kind: "public",
|
|
460
|
+
name: text(server.name, "template MCP server name", 100),
|
|
461
|
+
url: httpsUrl(server.url, "template MCP server url"),
|
|
462
|
+
transport: server.transport,
|
|
463
|
+
};
|
|
464
|
+
}
|
|
465
|
+
if (value.kind === "needs-connection") {
|
|
466
|
+
const server = exactRecord(
|
|
467
|
+
value,
|
|
468
|
+
"template MCP placeholder",
|
|
469
|
+
["kind", "name", "connectionTypeId"],
|
|
470
|
+
["hint"],
|
|
471
|
+
);
|
|
472
|
+
return withOptional(
|
|
473
|
+
{
|
|
474
|
+
kind: "needs-connection" as const,
|
|
475
|
+
name: text(server.name, "template MCP placeholder name", 100),
|
|
476
|
+
connectionTypeId: pattern(
|
|
477
|
+
server.connectionTypeId,
|
|
478
|
+
"template MCP connectionTypeId",
|
|
479
|
+
CONNECTION_TYPE_PATTERN,
|
|
480
|
+
64,
|
|
481
|
+
),
|
|
482
|
+
},
|
|
483
|
+
{ hint: optionalText(server.hint, "template MCP hint", 500) },
|
|
484
|
+
);
|
|
485
|
+
}
|
|
486
|
+
throw new TemplateDecodeError("template MCP server kind is invalid");
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
export function decodeBotTemplateV1(input: unknown): BotTemplateV1 {
|
|
490
|
+
const value = exactRecord(
|
|
491
|
+
input,
|
|
492
|
+
"bot template",
|
|
493
|
+
[
|
|
494
|
+
"schemaVersion",
|
|
495
|
+
"profile",
|
|
496
|
+
"skills",
|
|
497
|
+
"routines",
|
|
498
|
+
"packages",
|
|
499
|
+
"mcpServers",
|
|
500
|
+
],
|
|
501
|
+
["sourceCatalogGeneration"],
|
|
502
|
+
);
|
|
503
|
+
if (value.schemaVersion !== 1) {
|
|
504
|
+
throw new TemplateDecodeError("bot template schema version is unsupported");
|
|
505
|
+
}
|
|
506
|
+
const skills = boundedArray(
|
|
507
|
+
value.skills,
|
|
508
|
+
"template skills",
|
|
509
|
+
MAX_TEMPLATE_SKILLS_V1,
|
|
510
|
+
).map(decodeTemplateSkillV1);
|
|
511
|
+
if (new Set(skills.map((skill) => skill.slug)).size !== skills.length) {
|
|
512
|
+
throw new TemplateDecodeError("bot template repeats a skill slug");
|
|
513
|
+
}
|
|
514
|
+
const routines = boundedArray(
|
|
515
|
+
value.routines,
|
|
516
|
+
"template routines",
|
|
517
|
+
MAX_TEMPLATE_ROUTINES_V1,
|
|
518
|
+
).map(decodeTemplateRoutineV1);
|
|
519
|
+
if (new Set(routines.map((entry) => entry.slug)).size !== routines.length) {
|
|
520
|
+
throw new TemplateDecodeError("bot template repeats a routine slug");
|
|
521
|
+
}
|
|
522
|
+
const packages = boundedArray(
|
|
523
|
+
value.packages,
|
|
524
|
+
"template packages",
|
|
525
|
+
MAX_TEMPLATE_PACKAGES_V1,
|
|
526
|
+
).map(decodeTemplatePackageV1);
|
|
527
|
+
if (
|
|
528
|
+
new Set(packages.map((entry) => entry.catalogId)).size !== packages.length
|
|
529
|
+
) {
|
|
530
|
+
throw new TemplateDecodeError("bot template repeats a catalogId");
|
|
531
|
+
}
|
|
532
|
+
const sourceCatalogGeneration =
|
|
533
|
+
value.sourceCatalogGeneration === undefined
|
|
534
|
+
? undefined
|
|
535
|
+
: pattern(
|
|
536
|
+
value.sourceCatalogGeneration,
|
|
537
|
+
"template sourceCatalogGeneration",
|
|
538
|
+
GENERATION_PATTERN,
|
|
539
|
+
64,
|
|
540
|
+
);
|
|
541
|
+
return {
|
|
542
|
+
schemaVersion: 1,
|
|
543
|
+
profile: decodeTemplateProfileV1(value.profile),
|
|
544
|
+
skills,
|
|
545
|
+
routines,
|
|
546
|
+
packages,
|
|
547
|
+
mcpServers: boundedArray(
|
|
548
|
+
value.mcpServers,
|
|
549
|
+
"template MCP servers",
|
|
550
|
+
MAX_TEMPLATE_SERVERS_V1,
|
|
551
|
+
).map(decodeTemplateMcpServerV1),
|
|
552
|
+
...(sourceCatalogGeneration === undefined
|
|
553
|
+
? {}
|
|
554
|
+
: { sourceCatalogGeneration }),
|
|
555
|
+
};
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
/**
|
|
559
|
+
* The canonical bytes of one template.
|
|
560
|
+
*
|
|
561
|
+
* Content addressing needs one serialization, so this is it: keys in declared
|
|
562
|
+
* order, no whitespace, and a decode first so a caller cannot hash a document
|
|
563
|
+
* the decoder would refuse. Hashing what `JSON.stringify` happens to produce
|
|
564
|
+
* for an arbitrary object would make the same template hash differently
|
|
565
|
+
* depending on which surface built it.
|
|
566
|
+
*/
|
|
567
|
+
export function canonicalBotTemplateDocumentV1(
|
|
568
|
+
template: BotTemplateV1,
|
|
569
|
+
): string {
|
|
570
|
+
const decoded = decodeBotTemplateV1(template);
|
|
571
|
+
const document = JSON.stringify({
|
|
572
|
+
schemaVersion: 1,
|
|
573
|
+
profile: {
|
|
574
|
+
name: decoded.profile.name,
|
|
575
|
+
...(decoded.profile.title === undefined
|
|
576
|
+
? {}
|
|
577
|
+
: { title: decoded.profile.title }),
|
|
578
|
+
...(decoded.profile.description === undefined
|
|
579
|
+
? {}
|
|
580
|
+
: { description: decoded.profile.description }),
|
|
581
|
+
avatar: {
|
|
582
|
+
kind: "sheep",
|
|
583
|
+
recipe: {
|
|
584
|
+
schemaVersion: 1,
|
|
585
|
+
background: decoded.profile.avatar.recipe.background,
|
|
586
|
+
upper: decoded.profile.avatar.recipe.upper,
|
|
587
|
+
middle: decoded.profile.avatar.recipe.middle,
|
|
588
|
+
lower: decoded.profile.avatar.recipe.lower,
|
|
589
|
+
},
|
|
590
|
+
},
|
|
591
|
+
},
|
|
592
|
+
skills: decoded.skills.map((skill) => ({
|
|
593
|
+
slug: skill.slug,
|
|
594
|
+
name: skill.name,
|
|
595
|
+
...(skill.description === undefined
|
|
596
|
+
? {}
|
|
597
|
+
: { description: skill.description }),
|
|
598
|
+
body: skill.body,
|
|
599
|
+
})),
|
|
600
|
+
routines: decoded.routines.map((routine) => ({
|
|
601
|
+
slug: routine.slug,
|
|
602
|
+
name: routine.name,
|
|
603
|
+
prompt: routine.prompt,
|
|
604
|
+
...(routine.schedule === undefined ? {} : { schedule: routine.schedule }),
|
|
605
|
+
timezone: routine.timezone,
|
|
606
|
+
...(routine.triggerKind === undefined
|
|
607
|
+
? {}
|
|
608
|
+
: { triggerKind: routine.triggerKind }),
|
|
609
|
+
})),
|
|
610
|
+
packages: decoded.packages.map((entry) => ({
|
|
611
|
+
packageId: entry.packageId,
|
|
612
|
+
catalogId: entry.catalogId,
|
|
613
|
+
version: entry.version,
|
|
614
|
+
displayName: entry.displayName,
|
|
615
|
+
})),
|
|
616
|
+
mcpServers: decoded.mcpServers.map((server) =>
|
|
617
|
+
server.kind === "public"
|
|
618
|
+
? {
|
|
619
|
+
kind: "public",
|
|
620
|
+
name: server.name,
|
|
621
|
+
url: server.url,
|
|
622
|
+
transport: server.transport,
|
|
623
|
+
}
|
|
624
|
+
: {
|
|
625
|
+
kind: "needs-connection",
|
|
626
|
+
name: server.name,
|
|
627
|
+
connectionTypeId: server.connectionTypeId,
|
|
628
|
+
...(server.hint === undefined ? {} : { hint: server.hint }),
|
|
629
|
+
},
|
|
630
|
+
),
|
|
631
|
+
...(decoded.sourceCatalogGeneration === undefined
|
|
632
|
+
? {}
|
|
633
|
+
: { sourceCatalogGeneration: decoded.sourceCatalogGeneration }),
|
|
634
|
+
});
|
|
635
|
+
assertTemplateDocumentSizeV1(document);
|
|
636
|
+
return document;
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
export function assertTemplateDocumentSizeV1(document: string): void {
|
|
640
|
+
// The bound is on bytes, and a template carries prose in any script, so a
|
|
641
|
+
// character count would let a non-Latin template past a byte budget the
|
|
642
|
+
// bucket and the decoder both measure in bytes.
|
|
643
|
+
const bytes = new TextEncoder().encode(document).byteLength;
|
|
644
|
+
if (bytes > MAX_TEMPLATE_BYTES_V1) {
|
|
645
|
+
throw new TemplateDecodeError(
|
|
646
|
+
`bot template is ${bytes} bytes; the bound is ${MAX_TEMPLATE_BYTES_V1}`,
|
|
647
|
+
);
|
|
648
|
+
}
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
/**
|
|
652
|
+
* SHA-256 over the exact bytes the bucket holds, hex-encoded. Identical to
|
|
653
|
+
* `catalogContentHashV1` in shape and in reason: a pinned hash must mean
|
|
654
|
+
* "these bytes", not "something that re-serializes to this".
|
|
655
|
+
*/
|
|
656
|
+
export async function templateContentHashV1(document: string): Promise<string> {
|
|
657
|
+
const digest = await crypto.subtle.digest(
|
|
658
|
+
"SHA-256",
|
|
659
|
+
new TextEncoder().encode(document),
|
|
660
|
+
);
|
|
661
|
+
return [...new Uint8Array(digest)]
|
|
662
|
+
.map((byte) => byte.toString(16).padStart(2, "0"))
|
|
663
|
+
.join("");
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
/** The immutable object key one template blob lives at. */
|
|
667
|
+
export function templateObjectKeyV1(hash: string): string {
|
|
668
|
+
return `templates/${decodeTemplateContentHashV1(hash)}.json`;
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
/** Parse a stored blob, verifying the hash before anything downstream sees it. */
|
|
672
|
+
export async function decodeBotTemplateDocumentV1(
|
|
673
|
+
document: string,
|
|
674
|
+
expectedHash: string,
|
|
675
|
+
): Promise<BotTemplateV1> {
|
|
676
|
+
assertTemplateDocumentSizeV1(document);
|
|
677
|
+
const actual = await templateContentHashV1(document);
|
|
678
|
+
if (actual !== decodeTemplateContentHashV1(expectedHash)) {
|
|
679
|
+
throw new TemplateDecodeError(
|
|
680
|
+
"bot template failed content hash verification",
|
|
681
|
+
);
|
|
682
|
+
}
|
|
683
|
+
return parseBotTemplateDocumentV1(document);
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
/** Parse a document whose hash the caller has not pinned. */
|
|
687
|
+
export function parseBotTemplateDocumentV1(document: string): BotTemplateV1 {
|
|
688
|
+
assertTemplateDocumentSizeV1(document);
|
|
689
|
+
let value: unknown;
|
|
690
|
+
try {
|
|
691
|
+
value = JSON.parse(document) as unknown;
|
|
692
|
+
} catch (error) {
|
|
693
|
+
throw new TemplateDecodeError("bot template is not JSON", { cause: error });
|
|
694
|
+
}
|
|
695
|
+
return decodeBotTemplateV1(value);
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
/**
|
|
699
|
+
* `<publicUserId>.<random>`.
|
|
700
|
+
*
|
|
701
|
+
* The owner's public id is the routing half: an unauthenticated
|
|
702
|
+
* `GET /templates/v1/:shareId` derives the one User Durable Object that can
|
|
703
|
+
* answer for it, with no global index. The random half is the capability half —
|
|
704
|
+
* a content hash alone is guessable by anyone holding the same content, so it
|
|
705
|
+
* could never be the secret a `link` share rests on.
|
|
706
|
+
*/
|
|
707
|
+
export function templateShareIdV1(ownerId: string, secret: string): string {
|
|
708
|
+
if (!SHARE_OWNER_PATTERN.test(ownerId)) {
|
|
709
|
+
throw new TemplateDecodeError("template share owner id is invalid");
|
|
710
|
+
}
|
|
711
|
+
if (!SHARE_SECRET_PATTERN.test(secret)) {
|
|
712
|
+
throw new TemplateDecodeError("template share secret is invalid");
|
|
713
|
+
}
|
|
714
|
+
return `${ownerId}.${secret}`;
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
export interface ParsedTemplateShareIdV1 {
|
|
718
|
+
ownerId: string;
|
|
719
|
+
secret: string;
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
export function parseTemplateShareIdV1(
|
|
723
|
+
value: unknown,
|
|
724
|
+
): ParsedTemplateShareIdV1 {
|
|
725
|
+
const candidate = text(value, "template shareId", 160);
|
|
726
|
+
const separator = candidate.lastIndexOf(".");
|
|
727
|
+
if (separator <= 0) {
|
|
728
|
+
throw new TemplateDecodeError("template shareId is invalid");
|
|
729
|
+
}
|
|
730
|
+
const ownerId = candidate.slice(0, separator);
|
|
731
|
+
const secret = candidate.slice(separator + 1);
|
|
732
|
+
if (
|
|
733
|
+
!SHARE_OWNER_PATTERN.test(ownerId) ||
|
|
734
|
+
!SHARE_SECRET_PATTERN.test(secret)
|
|
735
|
+
) {
|
|
736
|
+
throw new TemplateDecodeError("template shareId is invalid");
|
|
737
|
+
}
|
|
738
|
+
return { ownerId, secret };
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
export function decodeTemplateVisibilityV1(
|
|
742
|
+
value: unknown,
|
|
743
|
+
): TemplateVisibilityV1 {
|
|
744
|
+
const found = TEMPLATE_VISIBILITIES_V1.find((known) => known === value);
|
|
745
|
+
if (!found) throw new TemplateDecodeError("template visibility is invalid");
|
|
746
|
+
return found;
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
export function decodeTemplateShareRecordV1(
|
|
750
|
+
input: unknown,
|
|
751
|
+
): TemplateShareRecordV1 {
|
|
752
|
+
const value = exactRecord(
|
|
753
|
+
input,
|
|
754
|
+
"template share",
|
|
755
|
+
["schemaVersion", "shareId", "hash", "botId", "visibility", "createdAt"],
|
|
756
|
+
["revokedAt"],
|
|
757
|
+
);
|
|
758
|
+
if (value.schemaVersion !== 1) {
|
|
759
|
+
throw new TemplateDecodeError("template share schema version is invalid");
|
|
760
|
+
}
|
|
761
|
+
parseTemplateShareIdV1(value.shareId);
|
|
762
|
+
return withOptional(
|
|
763
|
+
{
|
|
764
|
+
schemaVersion: 1 as const,
|
|
765
|
+
shareId: value.shareId as string,
|
|
766
|
+
hash: decodeTemplateContentHashV1(value.hash),
|
|
767
|
+
botId: text(value.botId, "template share botId", 128),
|
|
768
|
+
visibility: decodeTemplateVisibilityV1(value.visibility),
|
|
769
|
+
createdAt: timestamp(value.createdAt, "template share createdAt"),
|
|
770
|
+
},
|
|
771
|
+
{
|
|
772
|
+
revokedAt:
|
|
773
|
+
value.revokedAt === undefined
|
|
774
|
+
? undefined
|
|
775
|
+
: timestamp(value.revokedAt, "template share revokedAt"),
|
|
776
|
+
},
|
|
777
|
+
);
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
/**
|
|
781
|
+
* Whether an unauthenticated read of this share is allowed.
|
|
782
|
+
*
|
|
783
|
+
* `private` is the visibility a staged share starts at, and revocation is the
|
|
784
|
+
* only thing that can happen to an immutable blob, so both answers are "no"
|
|
785
|
+
* here and a 404 at the route: an unauthenticated caller learns nothing about
|
|
786
|
+
* whether a share exists.
|
|
787
|
+
*/
|
|
788
|
+
export function isTemplateShareReadableV1(
|
|
789
|
+
share: TemplateShareRecordV1,
|
|
790
|
+
): boolean {
|
|
791
|
+
return (
|
|
792
|
+
share.revokedAt === undefined &&
|
|
793
|
+
(share.visibility === "link" || share.visibility === "public")
|
|
794
|
+
);
|
|
795
|
+
}
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2023",
|
|
4
|
+
"module": "ESNext",
|
|
5
|
+
"moduleResolution": "Bundler",
|
|
6
|
+
"allowImportingTsExtensions": true,
|
|
7
|
+
"strict": true,
|
|
8
|
+
"noEmit": true,
|
|
9
|
+
"skipLibCheck": true,
|
|
10
|
+
"lib": ["ES2023", "DOM"],
|
|
11
|
+
"types": ["bun"]
|
|
12
|
+
},
|
|
13
|
+
"include": ["src/**/*.ts"]
|
|
14
|
+
}
|
package/README.md
DELETED