@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
|
@@ -0,0 +1,326 @@
|
|
|
1
|
+
import { describe, expect, it } from "bun:test";
|
|
2
|
+
import {
|
|
3
|
+
createBotTemplateBackendContribution,
|
|
4
|
+
type BotTemplateGatewayHostV1,
|
|
5
|
+
type PublishedTemplateV1,
|
|
6
|
+
} from "./backend.ts";
|
|
7
|
+
import type {
|
|
8
|
+
TemplateCommandV1,
|
|
9
|
+
TemplateImportRecordV1,
|
|
10
|
+
TemplateShareListViewV1,
|
|
11
|
+
TemplateShareReceiptV1,
|
|
12
|
+
} from "./shared.ts";
|
|
13
|
+
|
|
14
|
+
const SHARE_ID = `user-1.${"a".repeat(32)}`;
|
|
15
|
+
const HASH = "b".repeat(64);
|
|
16
|
+
const DOCUMENT = '{"schemaVersion":1}';
|
|
17
|
+
|
|
18
|
+
function share(visibility: "private" | "link" | "public" = "link") {
|
|
19
|
+
return {
|
|
20
|
+
schemaVersion: 1 as const,
|
|
21
|
+
shareId: SHARE_ID,
|
|
22
|
+
hash: HASH,
|
|
23
|
+
botId: "budget",
|
|
24
|
+
visibility,
|
|
25
|
+
createdAt: "2026-08-31T00:00:00.000Z",
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function host(
|
|
30
|
+
overrides: Partial<BotTemplateGatewayHostV1> = {},
|
|
31
|
+
): BotTemplateGatewayHostV1 & { commands: TemplateCommandV1[] } {
|
|
32
|
+
const commands: TemplateCommandV1[] = [];
|
|
33
|
+
return {
|
|
34
|
+
commands,
|
|
35
|
+
listTemplateShares: (): Promise<TemplateShareListViewV1> =>
|
|
36
|
+
Promise.resolve({ schemaVersion: 1, shares: [share()] }),
|
|
37
|
+
executeTemplateCommand: (
|
|
38
|
+
_userId: string,
|
|
39
|
+
command: TemplateCommandV1,
|
|
40
|
+
): Promise<TemplateShareReceiptV1> => {
|
|
41
|
+
commands.push(command);
|
|
42
|
+
return Promise.resolve({
|
|
43
|
+
schemaVersion: 1,
|
|
44
|
+
commandId: command.commandId,
|
|
45
|
+
status: "applied",
|
|
46
|
+
share: share(),
|
|
47
|
+
});
|
|
48
|
+
},
|
|
49
|
+
readPublishedTemplate: (): Promise<PublishedTemplateV1 | undefined> =>
|
|
50
|
+
Promise.resolve({
|
|
51
|
+
hash: HASH,
|
|
52
|
+
visibility: "link",
|
|
53
|
+
document: DOCUMENT,
|
|
54
|
+
}),
|
|
55
|
+
listTemplateImports: () =>
|
|
56
|
+
Promise.resolve({ schemaVersion: 1 as const, imports: [] }),
|
|
57
|
+
executeTemplateImport: (
|
|
58
|
+
_userId: string,
|
|
59
|
+
command: TemplateCommandV1,
|
|
60
|
+
): Promise<TemplateImportRecordV1> => {
|
|
61
|
+
commands.push(command);
|
|
62
|
+
return Promise.resolve({
|
|
63
|
+
schemaVersion: 1,
|
|
64
|
+
importId: "import-1",
|
|
65
|
+
shareId: SHARE_ID,
|
|
66
|
+
hash: HASH,
|
|
67
|
+
botId: "budget-abc123456789",
|
|
68
|
+
status: "planned",
|
|
69
|
+
botName: "Budget",
|
|
70
|
+
packages: [],
|
|
71
|
+
connections: [],
|
|
72
|
+
skills: [],
|
|
73
|
+
routines: [],
|
|
74
|
+
steps: [{ key: "bot/create", kind: "bot/create", status: "pending" }],
|
|
75
|
+
createdAt: "2026-09-01T00:00:00.000Z",
|
|
76
|
+
updatedAt: "2026-09-01T00:00:00.000Z",
|
|
77
|
+
});
|
|
78
|
+
},
|
|
79
|
+
...overrides,
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function get(
|
|
84
|
+
path: string,
|
|
85
|
+
headers: Record<string, string> = {},
|
|
86
|
+
): [Request, URL] {
|
|
87
|
+
const url = new URL(`https://bot.frockbot.com${path}`);
|
|
88
|
+
return [new Request(url, { headers }), url];
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
describe("the public template route", () => {
|
|
92
|
+
it("serves a published blob with its content hash as the etag", async () => {
|
|
93
|
+
const contribution = createBotTemplateBackendContribution(host());
|
|
94
|
+
const [request, url] = get(`/templates/v1/${SHARE_ID}`);
|
|
95
|
+
const response = await contribution.publicRoute(request, url, {
|
|
96
|
+
client: "browser",
|
|
97
|
+
});
|
|
98
|
+
expect(response?.status).toBe(200);
|
|
99
|
+
expect(response?.headers.get("etag")).toBe(`"${HASH}"`);
|
|
100
|
+
expect(response?.headers.get("cache-control")).toContain("private");
|
|
101
|
+
expect(await response?.text()).toBe(DOCUMENT);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it("answers 304 to a matching etag", async () => {
|
|
105
|
+
const contribution = createBotTemplateBackendContribution(host());
|
|
106
|
+
const [request, url] = get(`/templates/v1/${SHARE_ID}`, {
|
|
107
|
+
"if-none-match": `"${HASH}"`,
|
|
108
|
+
});
|
|
109
|
+
const response = await contribution.publicRoute(request, url, {
|
|
110
|
+
client: "browser",
|
|
111
|
+
});
|
|
112
|
+
expect(response?.status).toBe(304);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it("answers 404 for a private, revoked or missing share alike", async () => {
|
|
116
|
+
const contribution = createBotTemplateBackendContribution(
|
|
117
|
+
host({ readPublishedTemplate: () => Promise.resolve(undefined) }),
|
|
118
|
+
);
|
|
119
|
+
const [request, url] = get(`/templates/v1/${SHARE_ID}`);
|
|
120
|
+
const response = await contribution.publicRoute(request, url, {
|
|
121
|
+
client: "browser",
|
|
122
|
+
});
|
|
123
|
+
expect(response?.status).toBe(404);
|
|
124
|
+
expect(await response?.text()).not.toContain("private");
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it("answers 404 for a malformed share id rather than 400", async () => {
|
|
128
|
+
const contribution = createBotTemplateBackendContribution(host());
|
|
129
|
+
const [request, url] = get("/templates/v1/not-a-share");
|
|
130
|
+
const response = await contribution.publicRoute(request, url, {
|
|
131
|
+
client: "browser",
|
|
132
|
+
});
|
|
133
|
+
expect(response?.status).toBe(404);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it("ignores a path it does not own", async () => {
|
|
137
|
+
const contribution = createBotTemplateBackendContribution(host());
|
|
138
|
+
const [request, url] = get("/api/settings");
|
|
139
|
+
expect(
|
|
140
|
+
await contribution.publicRoute(request, url, { client: "browser" }),
|
|
141
|
+
).toBeUndefined();
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it("refuses a write on the public route", async () => {
|
|
145
|
+
const contribution = createBotTemplateBackendContribution(host());
|
|
146
|
+
const url = new URL(`https://bot.frockbot.com/templates/v1/${SHARE_ID}`);
|
|
147
|
+
const response = await contribution.publicRoute(
|
|
148
|
+
new Request(url, { method: "POST" }),
|
|
149
|
+
url,
|
|
150
|
+
{ client: "browser" },
|
|
151
|
+
);
|
|
152
|
+
expect(response?.status).toBe(405);
|
|
153
|
+
});
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
describe("the authenticated share route", () => {
|
|
157
|
+
it("lists this User's shares", async () => {
|
|
158
|
+
const contribution = createBotTemplateBackendContribution(host());
|
|
159
|
+
const [request, url] = get("/api/bot-templates");
|
|
160
|
+
const response = await contribution.route(request, url, {
|
|
161
|
+
userId: "user-1",
|
|
162
|
+
client: "browser",
|
|
163
|
+
});
|
|
164
|
+
expect(response?.status).toBe(200);
|
|
165
|
+
expect(await response?.json()).toEqual({
|
|
166
|
+
schemaVersion: 1,
|
|
167
|
+
shares: [share()],
|
|
168
|
+
});
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
it("refuses an anonymous caller", async () => {
|
|
172
|
+
const contribution = createBotTemplateBackendContribution(host());
|
|
173
|
+
const [request, url] = get("/api/bot-templates");
|
|
174
|
+
const response = await contribution.route(request, url, {
|
|
175
|
+
client: "browser",
|
|
176
|
+
});
|
|
177
|
+
expect(response?.status).toBe(401);
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
it("carries a decoded command to the authority", async () => {
|
|
181
|
+
const dependencies = host();
|
|
182
|
+
const contribution = createBotTemplateBackendContribution(dependencies);
|
|
183
|
+
const url = new URL("https://bot.frockbot.com/api/bot-templates");
|
|
184
|
+
const response = await contribution.route(
|
|
185
|
+
new Request(url, {
|
|
186
|
+
method: "POST",
|
|
187
|
+
headers: { "content-type": "application/json" },
|
|
188
|
+
body: JSON.stringify({
|
|
189
|
+
schemaVersion: 1,
|
|
190
|
+
type: "template/set-visibility",
|
|
191
|
+
commandId: "visibility-1",
|
|
192
|
+
shareId: SHARE_ID,
|
|
193
|
+
visibility: "link",
|
|
194
|
+
}),
|
|
195
|
+
}),
|
|
196
|
+
url,
|
|
197
|
+
{ userId: "user-1", client: "browser" },
|
|
198
|
+
);
|
|
199
|
+
expect(response?.status).toBe(200);
|
|
200
|
+
expect(dependencies.commands).toEqual([
|
|
201
|
+
{
|
|
202
|
+
schemaVersion: 1,
|
|
203
|
+
type: "template/set-visibility",
|
|
204
|
+
commandId: "visibility-1",
|
|
205
|
+
shareId: SHARE_ID,
|
|
206
|
+
visibility: "link",
|
|
207
|
+
},
|
|
208
|
+
]);
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
it("refuses a command with an unknown field", async () => {
|
|
212
|
+
const contribution = createBotTemplateBackendContribution(host());
|
|
213
|
+
const url = new URL("https://bot.frockbot.com/api/bot-templates");
|
|
214
|
+
const response = await contribution.route(
|
|
215
|
+
new Request(url, {
|
|
216
|
+
method: "POST",
|
|
217
|
+
headers: { "content-type": "application/json" },
|
|
218
|
+
body: JSON.stringify({
|
|
219
|
+
schemaVersion: 1,
|
|
220
|
+
type: "template/stage",
|
|
221
|
+
commandId: "stage-1",
|
|
222
|
+
botId: "budget",
|
|
223
|
+
visibility: "public",
|
|
224
|
+
}),
|
|
225
|
+
}),
|
|
226
|
+
url,
|
|
227
|
+
{ userId: "user-1", client: "browser" },
|
|
228
|
+
);
|
|
229
|
+
expect(response?.status).toBe(400);
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
it("answers 404 when the authority does not know the share", async () => {
|
|
233
|
+
const contribution = createBotTemplateBackendContribution(
|
|
234
|
+
host({
|
|
235
|
+
executeTemplateCommand: () => {
|
|
236
|
+
const error = new Error("template share was not found");
|
|
237
|
+
error.name = "TemplateShareNotFoundError";
|
|
238
|
+
return Promise.reject(error);
|
|
239
|
+
},
|
|
240
|
+
}),
|
|
241
|
+
);
|
|
242
|
+
const url = new URL("https://bot.frockbot.com/api/bot-templates");
|
|
243
|
+
const response = await contribution.route(
|
|
244
|
+
new Request(url, {
|
|
245
|
+
method: "POST",
|
|
246
|
+
headers: { "content-type": "application/json" },
|
|
247
|
+
body: JSON.stringify({
|
|
248
|
+
schemaVersion: 1,
|
|
249
|
+
type: "template/revoke",
|
|
250
|
+
commandId: "revoke-1",
|
|
251
|
+
shareId: SHARE_ID,
|
|
252
|
+
}),
|
|
253
|
+
}),
|
|
254
|
+
url,
|
|
255
|
+
{ userId: "user-1", client: "browser" },
|
|
256
|
+
);
|
|
257
|
+
expect(response?.status).toBe(404);
|
|
258
|
+
});
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
describe("the import route", () => {
|
|
262
|
+
it("plans an import and returns the review card", async () => {
|
|
263
|
+
const dependencies = host();
|
|
264
|
+
const contribution = createBotTemplateBackendContribution(dependencies);
|
|
265
|
+
const url = new URL("https://bot.frockbot.com/api/bot-template-imports");
|
|
266
|
+
const response = await contribution.route(
|
|
267
|
+
new Request(url, {
|
|
268
|
+
method: "POST",
|
|
269
|
+
headers: { "content-type": "application/json" },
|
|
270
|
+
body: JSON.stringify({
|
|
271
|
+
schemaVersion: 1,
|
|
272
|
+
type: "template/plan-import",
|
|
273
|
+
commandId: "import-1",
|
|
274
|
+
shareId: SHARE_ID,
|
|
275
|
+
}),
|
|
276
|
+
}),
|
|
277
|
+
url,
|
|
278
|
+
{ userId: "user-b", client: "browser" },
|
|
279
|
+
);
|
|
280
|
+
expect(response?.status).toBe(200);
|
|
281
|
+
expect(await response?.json()).toMatchObject({ status: "planned" });
|
|
282
|
+
expect(dependencies.commands[0]).toMatchObject({
|
|
283
|
+
type: "template/plan-import",
|
|
284
|
+
});
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
it("refuses a share command on the import route", async () => {
|
|
288
|
+
const contribution = createBotTemplateBackendContribution(host());
|
|
289
|
+
const url = new URL("https://bot.frockbot.com/api/bot-template-imports");
|
|
290
|
+
const response = await contribution.route(
|
|
291
|
+
new Request(url, {
|
|
292
|
+
method: "POST",
|
|
293
|
+
headers: { "content-type": "application/json" },
|
|
294
|
+
body: JSON.stringify({
|
|
295
|
+
schemaVersion: 1,
|
|
296
|
+
type: "template/revoke",
|
|
297
|
+
commandId: "revoke-1",
|
|
298
|
+
shareId: SHARE_ID,
|
|
299
|
+
}),
|
|
300
|
+
}),
|
|
301
|
+
url,
|
|
302
|
+
{ userId: "user-b", client: "browser" },
|
|
303
|
+
);
|
|
304
|
+
expect(response?.status).toBe(400);
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
it("refuses an import command on the share route", async () => {
|
|
308
|
+
const contribution = createBotTemplateBackendContribution(host());
|
|
309
|
+
const url = new URL("https://bot.frockbot.com/api/bot-templates");
|
|
310
|
+
const response = await contribution.route(
|
|
311
|
+
new Request(url, {
|
|
312
|
+
method: "POST",
|
|
313
|
+
headers: { "content-type": "application/json" },
|
|
314
|
+
body: JSON.stringify({
|
|
315
|
+
schemaVersion: 1,
|
|
316
|
+
type: "template/apply-import",
|
|
317
|
+
commandId: "apply-1",
|
|
318
|
+
importId: "import-1",
|
|
319
|
+
}),
|
|
320
|
+
}),
|
|
321
|
+
url,
|
|
322
|
+
{ userId: "user-b", client: "browser" },
|
|
323
|
+
);
|
|
324
|
+
expect(response?.status).toBe(400);
|
|
325
|
+
});
|
|
326
|
+
});
|
package/src/backend.ts
ADDED
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
// The Bot Template gateway Contribution.
|
|
2
|
+
//
|
|
3
|
+
// GET /api/bot-templates this User's shares
|
|
4
|
+
// POST /api/bot-templates one template command
|
|
5
|
+
// GET /templates/v1/:shareId the published blob, unauthenticated
|
|
6
|
+
//
|
|
7
|
+
// The last one is the only unauthenticated route in this Package, and it is
|
|
8
|
+
// unauthenticated on purpose: a `link` share is a capability URL, and asking
|
|
9
|
+
// the recipient to hold an account first would make it something else. It is
|
|
10
|
+
// served through the gateway's `publicRoute` seam, which runs before identity,
|
|
11
|
+
// and it answers a `private` or revoked share exactly as it answers one that
|
|
12
|
+
// never existed — 404, with no body — so the route cannot be probed.
|
|
13
|
+
//
|
|
14
|
+
// The gateway owns no state here. It carries each request to the User Durable
|
|
15
|
+
// Object that is the authority for that User's shares, and the share id names
|
|
16
|
+
// which one that is.
|
|
17
|
+
import type { Plugin } from "cordis";
|
|
18
|
+
import {
|
|
19
|
+
decodeTemplateContentHashV1,
|
|
20
|
+
parseTemplateShareIdV1,
|
|
21
|
+
TemplateDecodeError,
|
|
22
|
+
type TemplateVisibilityV1,
|
|
23
|
+
} from "@frockbot/template-core";
|
|
24
|
+
import {
|
|
25
|
+
decodeTemplateCommandV1,
|
|
26
|
+
decodeTemplateImportListViewV1,
|
|
27
|
+
decodeTemplateImportRecordV1,
|
|
28
|
+
decodeTemplateShareListViewV1,
|
|
29
|
+
decodeTemplateShareReceiptV1,
|
|
30
|
+
type TemplateCommandV1,
|
|
31
|
+
type TemplateImportListViewV1,
|
|
32
|
+
type TemplateImportRecordV1,
|
|
33
|
+
type TemplateShareListViewV1,
|
|
34
|
+
type TemplateShareReceiptV1,
|
|
35
|
+
} from "./shared.js";
|
|
36
|
+
|
|
37
|
+
export interface PublishedTemplateV1 {
|
|
38
|
+
hash: string;
|
|
39
|
+
visibility: TemplateVisibilityV1;
|
|
40
|
+
document: string;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface BotTemplateGatewayHostV1 {
|
|
44
|
+
listTemplateShares(userId: string): Promise<TemplateShareListViewV1>;
|
|
45
|
+
executeTemplateCommand(
|
|
46
|
+
userId: string,
|
|
47
|
+
command: TemplateCommandV1,
|
|
48
|
+
): Promise<TemplateShareReceiptV1>;
|
|
49
|
+
/** `undefined` for a share that is missing, private, or revoked, alike. */
|
|
50
|
+
readPublishedTemplate(
|
|
51
|
+
shareId: string,
|
|
52
|
+
): Promise<PublishedTemplateV1 | undefined>;
|
|
53
|
+
listTemplateImports(userId: string): Promise<TemplateImportListViewV1>;
|
|
54
|
+
executeTemplateImport(
|
|
55
|
+
userId: string,
|
|
56
|
+
command: TemplateCommandV1,
|
|
57
|
+
): Promise<TemplateImportRecordV1>;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export interface BotTemplateBackendRouteContribution {
|
|
61
|
+
packageId: string;
|
|
62
|
+
publicRoute(
|
|
63
|
+
request: Request,
|
|
64
|
+
url: URL,
|
|
65
|
+
context: { userId?: string; client: "browser" | "desktop" },
|
|
66
|
+
): Promise<Response | undefined>;
|
|
67
|
+
route(
|
|
68
|
+
request: Request,
|
|
69
|
+
url: URL,
|
|
70
|
+
context: { userId?: string; client: "browser" | "desktop" },
|
|
71
|
+
): Promise<Response | undefined>;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const SHARES_PATH = "/api/bot-templates";
|
|
75
|
+
const IMPORTS_PATH = "/api/bot-template-imports";
|
|
76
|
+
const PUBLIC_PREFIX = "/templates/v1/";
|
|
77
|
+
|
|
78
|
+
function jsonError(status: number, message: string): Response {
|
|
79
|
+
return Response.json({ error: message }, { status });
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function notFound(): Response {
|
|
83
|
+
return jsonError(404, "template share was not found");
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function isDecodeError(error: unknown): boolean {
|
|
87
|
+
return (
|
|
88
|
+
error instanceof TemplateDecodeError ||
|
|
89
|
+
(typeof error === "object" &&
|
|
90
|
+
error !== null &&
|
|
91
|
+
"name" in error &&
|
|
92
|
+
error.name === "TemplateDecodeError")
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function isMissing(error: unknown): boolean {
|
|
97
|
+
return (
|
|
98
|
+
typeof error === "object" &&
|
|
99
|
+
error !== null &&
|
|
100
|
+
"name" in error &&
|
|
101
|
+
(error.name === "TemplateShareNotFoundError" ||
|
|
102
|
+
error.name === "BotNotFoundError")
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function errorResponse(error: unknown): Response {
|
|
107
|
+
if (isMissing(error)) return notFound();
|
|
108
|
+
if (isDecodeError(error)) {
|
|
109
|
+
return jsonError(
|
|
110
|
+
400,
|
|
111
|
+
error instanceof Error ? error.message : "template request is invalid",
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
return jsonError(
|
|
115
|
+
500,
|
|
116
|
+
error instanceof Error ? error.message : "template request failed",
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* The blob is content-addressed and immutable, so a matched `etag` is the whole
|
|
122
|
+
* answer: the same hash can only ever be the same bytes. Revocation still
|
|
123
|
+
* bites, because the share record is read before the cache header is ever
|
|
124
|
+
* considered.
|
|
125
|
+
*/
|
|
126
|
+
function templateResponse(
|
|
127
|
+
request: Request,
|
|
128
|
+
found: PublishedTemplateV1,
|
|
129
|
+
): Response {
|
|
130
|
+
const etag = `"${decodeTemplateContentHashV1(found.hash)}"`;
|
|
131
|
+
const headers = {
|
|
132
|
+
"content-type": "application/json; charset=utf-8",
|
|
133
|
+
// Never `public`: revoking a share must not leave copies in a shared cache.
|
|
134
|
+
"cache-control": "private, max-age=300, must-revalidate",
|
|
135
|
+
etag,
|
|
136
|
+
"x-content-type-options": "nosniff",
|
|
137
|
+
"referrer-policy": "no-referrer",
|
|
138
|
+
};
|
|
139
|
+
if (request.headers.get("if-none-match") === etag) {
|
|
140
|
+
return new Response(null, { status: 304, headers });
|
|
141
|
+
}
|
|
142
|
+
return new Response(found.document, { headers });
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export function createBotTemplateBackendContribution(
|
|
146
|
+
host: BotTemplateGatewayHostV1,
|
|
147
|
+
): BotTemplateBackendRouteContribution {
|
|
148
|
+
return {
|
|
149
|
+
packageId: "bot-template",
|
|
150
|
+
|
|
151
|
+
async publicRoute(request, url) {
|
|
152
|
+
if (!url.pathname.startsWith(PUBLIC_PREFIX)) return undefined;
|
|
153
|
+
if (request.method !== "GET" && request.method !== "HEAD") {
|
|
154
|
+
return jsonError(405, "method not allowed");
|
|
155
|
+
}
|
|
156
|
+
if ([...url.searchParams.keys()].length > 0) {
|
|
157
|
+
return jsonError(400, "the template route takes no query parameters");
|
|
158
|
+
}
|
|
159
|
+
let shareId: string;
|
|
160
|
+
try {
|
|
161
|
+
shareId = decodeURIComponent(url.pathname.slice(PUBLIC_PREFIX.length));
|
|
162
|
+
parseTemplateShareIdV1(shareId);
|
|
163
|
+
} catch {
|
|
164
|
+
// A malformed share id is indistinguishable from a missing one to the
|
|
165
|
+
// caller, so it gets the same answer.
|
|
166
|
+
return notFound();
|
|
167
|
+
}
|
|
168
|
+
try {
|
|
169
|
+
const found = await host.readPublishedTemplate(shareId);
|
|
170
|
+
if (!found) return notFound();
|
|
171
|
+
return templateResponse(request, found);
|
|
172
|
+
} catch (error) {
|
|
173
|
+
if (isMissing(error) || isDecodeError(error)) return notFound();
|
|
174
|
+
return jsonError(
|
|
175
|
+
502,
|
|
176
|
+
error instanceof Error ? error.message : "template read failed",
|
|
177
|
+
);
|
|
178
|
+
}
|
|
179
|
+
},
|
|
180
|
+
|
|
181
|
+
async route(request, url, context) {
|
|
182
|
+
if (url.pathname !== SHARES_PATH && url.pathname !== IMPORTS_PATH) {
|
|
183
|
+
return undefined;
|
|
184
|
+
}
|
|
185
|
+
if (!context.userId) return jsonError(401, "authentication required");
|
|
186
|
+
if ([...url.searchParams.keys()].length > 0) {
|
|
187
|
+
return jsonError(400, "the template route takes no query parameters");
|
|
188
|
+
}
|
|
189
|
+
if (url.pathname === IMPORTS_PATH) {
|
|
190
|
+
try {
|
|
191
|
+
if (request.method === "GET") {
|
|
192
|
+
return Response.json(
|
|
193
|
+
decodeTemplateImportListViewV1(
|
|
194
|
+
await host.listTemplateImports(context.userId),
|
|
195
|
+
),
|
|
196
|
+
);
|
|
197
|
+
}
|
|
198
|
+
if (request.method !== "POST") {
|
|
199
|
+
return jsonError(405, "method not allowed");
|
|
200
|
+
}
|
|
201
|
+
const command = decodeTemplateCommandV1(await request.json());
|
|
202
|
+
if (
|
|
203
|
+
command.type !== "template/plan-import" &&
|
|
204
|
+
command.type !== "template/apply-import"
|
|
205
|
+
) {
|
|
206
|
+
return jsonError(
|
|
207
|
+
400,
|
|
208
|
+
"the import route takes an import command only",
|
|
209
|
+
);
|
|
210
|
+
}
|
|
211
|
+
return Response.json(
|
|
212
|
+
decodeTemplateImportRecordV1(
|
|
213
|
+
await host.executeTemplateImport(context.userId, command),
|
|
214
|
+
),
|
|
215
|
+
);
|
|
216
|
+
} catch (error) {
|
|
217
|
+
return errorResponse(error);
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
try {
|
|
221
|
+
if (request.method === "GET") {
|
|
222
|
+
return Response.json(
|
|
223
|
+
decodeTemplateShareListViewV1(
|
|
224
|
+
await host.listTemplateShares(context.userId),
|
|
225
|
+
),
|
|
226
|
+
);
|
|
227
|
+
}
|
|
228
|
+
if (request.method !== "POST") {
|
|
229
|
+
return jsonError(405, "method not allowed");
|
|
230
|
+
}
|
|
231
|
+
const command = decodeTemplateCommandV1(await request.json());
|
|
232
|
+
if (
|
|
233
|
+
command.type === "template/plan-import" ||
|
|
234
|
+
command.type === "template/apply-import"
|
|
235
|
+
) {
|
|
236
|
+
return jsonError(
|
|
237
|
+
400,
|
|
238
|
+
"an import command belongs on the import route",
|
|
239
|
+
);
|
|
240
|
+
}
|
|
241
|
+
return Response.json(
|
|
242
|
+
decodeTemplateShareReceiptV1(
|
|
243
|
+
await host.executeTemplateCommand(context.userId, command),
|
|
244
|
+
),
|
|
245
|
+
);
|
|
246
|
+
} catch (error) {
|
|
247
|
+
return errorResponse(error);
|
|
248
|
+
}
|
|
249
|
+
},
|
|
250
|
+
};
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
export namespace createBotTemplateBackendContribution {
|
|
254
|
+
export function plugin(
|
|
255
|
+
host: BotTemplateGatewayHostV1,
|
|
256
|
+
lifecycle: {
|
|
257
|
+
mount(value: BotTemplateBackendRouteContribution): () => void;
|
|
258
|
+
},
|
|
259
|
+
): Plugin {
|
|
260
|
+
return () => lifecycle.mount(createBotTemplateBackendContribution(host));
|
|
261
|
+
}
|
|
262
|
+
}
|