@frockbot/plugin-routines 0.0.0 → 0.1.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/frockbot.json +39 -0
- package/package.json +53 -6
- package/src/agent.test.ts +206 -0
- package/src/agent.ts +345 -0
- package/src/backend.test.ts +181 -0
- package/src/backend.ts +375 -0
- package/src/client/RoutineInboxBadge.vue +216 -0
- package/src/client/RoutinesSection.vue +601 -0
- package/src/client/RoutinesSummary.vue +150 -0
- package/src/client/index.test.ts +209 -0
- package/src/client/index.ts +289 -0
- package/src/client/state.ts +65 -0
- package/src/cron.test.ts +217 -0
- package/src/cron.ts +246 -0
- package/src/env.d.ts +6 -0
- package/src/firing.ts +222 -0
- package/src/hook.test.ts +394 -0
- package/src/hook.ts +405 -0
- package/src/inbox-store.ts +405 -0
- package/src/inbox.test.ts +402 -0
- package/src/inbox.ts +405 -0
- package/src/index.ts +9 -0
- package/src/manifest.ts +3 -0
- package/src/records.test.ts +138 -0
- package/src/records.ts +341 -0
- package/src/scheduler.test.ts +482 -0
- package/src/scheduler.ts +551 -0
- package/src/shared.test.ts +141 -0
- package/src/shared.ts +988 -0
- package/src/storage-keys.ts +202 -0
- package/src/store.test.ts +261 -0
- package/src/store.ts +789 -0
- package/src/testing.ts +55 -0
- package/tsconfig.json +15 -0
- package/vite.config.ts +31 -0
- package/README.md +0 -3
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { createRoutinesBackendContribution } from "./backend.js";
|
|
3
|
+
import { RoutineStore, RoutineNotFoundError } from "./store.js";
|
|
4
|
+
import { RoutineInboxStore } from "./inbox-store.js";
|
|
5
|
+
import { createMemoryRoutineStorageV1 } from "./testing.js";
|
|
6
|
+
import { decodeRoutineCommandV1 } from "./shared.js";
|
|
7
|
+
|
|
8
|
+
const CONTEXT = { userId: "tim", client: "browser" as const };
|
|
9
|
+
|
|
10
|
+
function contribution(options: { ownedBots?: string[] } = {}) {
|
|
11
|
+
const owned = new Set(options.ownedBots ?? ["scout"]);
|
|
12
|
+
const stores = new Map<string, RoutineStore>();
|
|
13
|
+
const inboxes = new Map<string, RoutineInboxStore>();
|
|
14
|
+
const store = (botId: string): RoutineStore => {
|
|
15
|
+
if (!owned.has(botId)) {
|
|
16
|
+
const error = new Error(`Bot "${botId}" not found`);
|
|
17
|
+
error.name = "BotNotFoundError";
|
|
18
|
+
throw error;
|
|
19
|
+
}
|
|
20
|
+
const existing = stores.get(botId);
|
|
21
|
+
if (existing) return existing;
|
|
22
|
+
const created = new RoutineStore(createMemoryRoutineStorageV1());
|
|
23
|
+
stores.set(botId, created);
|
|
24
|
+
return created;
|
|
25
|
+
};
|
|
26
|
+
const inbox = (botId: string): RoutineInboxStore => {
|
|
27
|
+
store(botId);
|
|
28
|
+
const existing = inboxes.get(botId);
|
|
29
|
+
if (existing) return existing;
|
|
30
|
+
const created = new RoutineInboxStore(createMemoryRoutineStorageV1());
|
|
31
|
+
inboxes.set(botId, created);
|
|
32
|
+
return created;
|
|
33
|
+
};
|
|
34
|
+
const inboxView = async (botId: string) => {
|
|
35
|
+
const entries = await inbox(botId).list();
|
|
36
|
+
return {
|
|
37
|
+
schemaVersion: 1 as const,
|
|
38
|
+
botId,
|
|
39
|
+
entries: entries.map((entry) => ({
|
|
40
|
+
schemaVersion: 1 as const,
|
|
41
|
+
entryId: entry.entryId,
|
|
42
|
+
runId: entry.runId,
|
|
43
|
+
routineId: entry.routineId,
|
|
44
|
+
text: entry.text,
|
|
45
|
+
attribution: entry.attribution,
|
|
46
|
+
createdAt: entry.createdAt,
|
|
47
|
+
acknowledged: entry.acknowledged,
|
|
48
|
+
})),
|
|
49
|
+
unacknowledged: entries.filter((entry) => !entry.acknowledged).length,
|
|
50
|
+
};
|
|
51
|
+
};
|
|
52
|
+
return createRoutinesBackendContribution({
|
|
53
|
+
deliverRoutineHook: () =>
|
|
54
|
+
Promise.reject(new Error("no webhook in this fixture")),
|
|
55
|
+
listRoutines: (_userId, botId) => store(botId).list(botId),
|
|
56
|
+
executeRoutineCommand: (_userId, botId, command) =>
|
|
57
|
+
store(botId).execute(command, { kind: "user" }),
|
|
58
|
+
listRoutineRuns: (_userId, botId, routineId) =>
|
|
59
|
+
store(botId).listRuns(botId, routineId),
|
|
60
|
+
readRoutineRun: (_userId, botId, routineId, runId) => {
|
|
61
|
+
store(botId);
|
|
62
|
+
const error = new Error(`run "${runId}" is unknown`);
|
|
63
|
+
error.name = "RoutineNotFoundError";
|
|
64
|
+
void routineId;
|
|
65
|
+
return Promise.reject(error);
|
|
66
|
+
},
|
|
67
|
+
listRoutineInbox: (_userId, botId) => inboxView(botId),
|
|
68
|
+
executeRoutineInboxCommand: async (_userId, botId, command) => {
|
|
69
|
+
await inbox(botId).acknowledge(command.entryIds);
|
|
70
|
+
return {
|
|
71
|
+
schemaVersion: 1 as const,
|
|
72
|
+
commandId: command.commandId,
|
|
73
|
+
status: "applied" as const,
|
|
74
|
+
inbox: await inboxView(botId),
|
|
75
|
+
};
|
|
76
|
+
},
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function call(
|
|
81
|
+
route: ReturnType<typeof contribution>,
|
|
82
|
+
path: string,
|
|
83
|
+
init?: RequestInit,
|
|
84
|
+
): Promise<Response | undefined> {
|
|
85
|
+
const url = new URL(`https://bot.frockbot.com${path}`);
|
|
86
|
+
return route.route(new Request(url, init), url, CONTEXT);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const CREATE = {
|
|
90
|
+
schemaVersion: 1,
|
|
91
|
+
type: "routine/create",
|
|
92
|
+
commandId: "cmd-1",
|
|
93
|
+
botId: "scout",
|
|
94
|
+
name: "Morning brief",
|
|
95
|
+
prompt: "Summarize overnight email.",
|
|
96
|
+
schedule: "0 7 * * *",
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
describe("Routines gateway routes", () => {
|
|
100
|
+
test("posts a command and lists the Routine back", async () => {
|
|
101
|
+
const route = contribution();
|
|
102
|
+
const posted = await call(route, "/api/bots/scout/routines", {
|
|
103
|
+
method: "POST",
|
|
104
|
+
body: JSON.stringify(CREATE),
|
|
105
|
+
});
|
|
106
|
+
expect(posted?.status).toBe(200);
|
|
107
|
+
const listed = await call(route, "/api/bots/scout/routines");
|
|
108
|
+
expect(await listed!.json()).toMatchObject({
|
|
109
|
+
schemaVersion: 1,
|
|
110
|
+
botId: "scout",
|
|
111
|
+
routines: [{ name: "Morning brief", enabled: true }],
|
|
112
|
+
});
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
test("answers an invalid cron with 400 and the reason", async () => {
|
|
116
|
+
const route = contribution();
|
|
117
|
+
const response = await call(route, "/api/bots/scout/routines", {
|
|
118
|
+
method: "POST",
|
|
119
|
+
body: JSON.stringify({ ...CREATE, schedule: "not a cron" }),
|
|
120
|
+
});
|
|
121
|
+
expect(response?.status).toBe(400);
|
|
122
|
+
expect((await response!.json()) as { error: string }).toMatchObject({
|
|
123
|
+
error: expect.stringContaining("five fields") as unknown as string,
|
|
124
|
+
});
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
test("refuses a command whose botId does not match the path", async () => {
|
|
128
|
+
const route = contribution({ ownedBots: ["scout", "other"] });
|
|
129
|
+
const response = await call(route, "/api/bots/other/routines", {
|
|
130
|
+
method: "POST",
|
|
131
|
+
body: JSON.stringify(CREATE),
|
|
132
|
+
});
|
|
133
|
+
expect(response?.status).toBe(400);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
test("a Bot the caller does not hold is 404", async () => {
|
|
137
|
+
const route = contribution({ ownedBots: ["scout"] });
|
|
138
|
+
const response = await call(route, "/api/bots/someone-else/routines");
|
|
139
|
+
expect(response?.status).toBe(404);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
test("an unknown Routine's run log is 404 and a known one starts empty", async () => {
|
|
143
|
+
const route = contribution();
|
|
144
|
+
await call(route, "/api/bots/scout/routines", {
|
|
145
|
+
method: "POST",
|
|
146
|
+
body: JSON.stringify({ ...CREATE, routineId: "brief" }),
|
|
147
|
+
});
|
|
148
|
+
expect(
|
|
149
|
+
(await call(route, "/api/bots/scout/routines/missing/runs"))?.status,
|
|
150
|
+
).toBe(404);
|
|
151
|
+
const runs = await call(route, "/api/bots/scout/routines/brief/runs");
|
|
152
|
+
expect(await runs!.json()).toMatchObject({ entries: [] });
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
test("declines every path it does not own and every method it does not serve", async () => {
|
|
156
|
+
const route = contribution();
|
|
157
|
+
expect(await call(route, "/api/bots/scout/settings")).toBeUndefined();
|
|
158
|
+
expect(
|
|
159
|
+
(await call(route, "/api/bots/scout/routines", { method: "DELETE" }))
|
|
160
|
+
?.status,
|
|
161
|
+
).toBe(405);
|
|
162
|
+
expect(
|
|
163
|
+
(await call(route, "/api/bots/scout/routines?limit=5"))?.status,
|
|
164
|
+
).toBe(400);
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
test("answers nothing without an authenticated User", async () => {
|
|
168
|
+
const route = contribution();
|
|
169
|
+
const url = new URL("https://bot.frockbot.com/api/bots/scout/routines");
|
|
170
|
+
expect(
|
|
171
|
+
await route.route(new Request(url), url, { client: "browser" }),
|
|
172
|
+
).toBeUndefined();
|
|
173
|
+
});
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
describe("RoutineNotFoundError", () => {
|
|
177
|
+
test("is the shape the routes map to 404", () => {
|
|
178
|
+
expect(new RoutineNotFoundError("brief").name).toBe("RoutineNotFoundError");
|
|
179
|
+
expect(decodeRoutineCommandV1(CREATE).botId).toBe("scout");
|
|
180
|
+
});
|
|
181
|
+
});
|
package/src/backend.ts
ADDED
|
@@ -0,0 +1,375 @@
|
|
|
1
|
+
// The Routines gateway Contribution: the authenticated HTTP surface.
|
|
2
|
+
//
|
|
3
|
+
// Three routes, all Bot-scoped and all beside `/api/bots/:id/settings`:
|
|
4
|
+
//
|
|
5
|
+
// GET /api/bots/:botId/routines list
|
|
6
|
+
// POST /api/bots/:botId/routines one command
|
|
7
|
+
// GET /api/bots/:botId/routines/:routineId/runs the bounded run log
|
|
8
|
+
//
|
|
9
|
+
// …and one that is not authenticated at all:
|
|
10
|
+
//
|
|
11
|
+
// POST /api/bots/:botId/routines/:routineId/hook one webhook delivery
|
|
12
|
+
//
|
|
13
|
+
// That last one is a `publicRoute`: an external caller has no session, so it
|
|
14
|
+
// runs before the gateway authenticates anything. Its only credential is the
|
|
15
|
+
// signed key it presents, which is verified in constant time *before* a Durable
|
|
16
|
+
// Object is addressed — the gateway is stateless and could not otherwise map a
|
|
17
|
+
// Bot to its User without creating an object on an anonymous caller's word.
|
|
18
|
+
//
|
|
19
|
+
// The gateway owns none of this state. It carries the request to the Bot
|
|
20
|
+
// Durable Object, which proves directory membership before it answers — so a
|
|
21
|
+
// Bot that is not this User's is a 404 here for the same reason it is one on
|
|
22
|
+
// `/api/bots/:id/settings`, and never because this module checked.
|
|
23
|
+
import type { Plugin } from "cordis";
|
|
24
|
+
import {
|
|
25
|
+
RoutineHookError,
|
|
26
|
+
ROUTINE_HOOK_BODY_MAX_BYTES,
|
|
27
|
+
routineDeliveryIdV1,
|
|
28
|
+
verifyRoutineHookTokenV1,
|
|
29
|
+
} from "./hook.js";
|
|
30
|
+
import {
|
|
31
|
+
decodeRoutineCommandV1,
|
|
32
|
+
decodeRoutineCommandReceiptV1,
|
|
33
|
+
decodeRoutineInboxCommandV1,
|
|
34
|
+
decodeRoutineInboxReceiptV1,
|
|
35
|
+
decodeRoutineInboxViewV1,
|
|
36
|
+
decodeRoutineListViewV1,
|
|
37
|
+
decodeRoutineRunDetailViewV1,
|
|
38
|
+
decodeRoutineRunListViewV1,
|
|
39
|
+
RoutineDecodeError,
|
|
40
|
+
type RoutineCommandReceiptV1,
|
|
41
|
+
type RoutineCommandV1,
|
|
42
|
+
type RoutineInboxCommandV1,
|
|
43
|
+
type RoutineInboxReceiptV1,
|
|
44
|
+
type RoutineInboxViewV1,
|
|
45
|
+
type RoutineListViewV1,
|
|
46
|
+
type RoutineRunDetailViewV1,
|
|
47
|
+
type RoutineRunListViewV1,
|
|
48
|
+
} from "./shared.js";
|
|
49
|
+
|
|
50
|
+
/** One delivery, as the Bot Durable Object answers it. */
|
|
51
|
+
export interface RoutineHookDeliveryReceiptV1 {
|
|
52
|
+
status: "accepted" | "duplicate";
|
|
53
|
+
fireId: string;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export interface RoutinesGatewayHost {
|
|
57
|
+
/**
|
|
58
|
+
* The HMAC secret webhook keys are signed with, or nothing. Absent means the
|
|
59
|
+
* door is closed: a delivery is refused rather than admitted unverified.
|
|
60
|
+
*/
|
|
61
|
+
routineHookSecret?: string;
|
|
62
|
+
deliverRoutineHook(
|
|
63
|
+
userId: string,
|
|
64
|
+
botId: string,
|
|
65
|
+
delivery: {
|
|
66
|
+
routineId: string;
|
|
67
|
+
keyVersion: number;
|
|
68
|
+
digest: string;
|
|
69
|
+
deliveryId: string;
|
|
70
|
+
body: string;
|
|
71
|
+
contentType?: string | null;
|
|
72
|
+
},
|
|
73
|
+
): Promise<RoutineHookDeliveryReceiptV1>;
|
|
74
|
+
listRoutines(userId: string, botId: string): Promise<RoutineListViewV1>;
|
|
75
|
+
executeRoutineCommand(
|
|
76
|
+
userId: string,
|
|
77
|
+
botId: string,
|
|
78
|
+
command: RoutineCommandV1,
|
|
79
|
+
): Promise<RoutineCommandReceiptV1>;
|
|
80
|
+
listRoutineRuns(
|
|
81
|
+
userId: string,
|
|
82
|
+
botId: string,
|
|
83
|
+
routineId: string,
|
|
84
|
+
): Promise<RoutineRunListViewV1>;
|
|
85
|
+
/**
|
|
86
|
+
* One automation run, read-only. An automation Turn is absent from the
|
|
87
|
+
* visible transcript by construction, so the run log is the only door to it
|
|
88
|
+
* and this is that door's read.
|
|
89
|
+
*/
|
|
90
|
+
readRoutineRun(
|
|
91
|
+
userId: string,
|
|
92
|
+
botId: string,
|
|
93
|
+
routineId: string,
|
|
94
|
+
runId: string,
|
|
95
|
+
): Promise<RoutineRunDetailViewV1>;
|
|
96
|
+
listRoutineInbox(userId: string, botId: string): Promise<RoutineInboxViewV1>;
|
|
97
|
+
executeRoutineInboxCommand(
|
|
98
|
+
userId: string,
|
|
99
|
+
botId: string,
|
|
100
|
+
command: RoutineInboxCommandV1,
|
|
101
|
+
): Promise<RoutineInboxReceiptV1>;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export interface RoutinesBackendRouteContribution {
|
|
105
|
+
packageId: string;
|
|
106
|
+
publicRoute?(
|
|
107
|
+
request: Request,
|
|
108
|
+
url: URL,
|
|
109
|
+
context: { userId?: string; client: "browser" | "desktop" },
|
|
110
|
+
): Promise<Response | undefined>;
|
|
111
|
+
route(
|
|
112
|
+
request: Request,
|
|
113
|
+
url: URL,
|
|
114
|
+
context: { userId?: string; client: "browser" | "desktop" },
|
|
115
|
+
): Promise<Response | undefined>;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const ROUTINES = /^\/api\/bots\/([^/]+)\/routines$/;
|
|
119
|
+
const ROUTINE_INBOX = /^\/api\/bots\/([^/]+)\/routines\/inbox$/;
|
|
120
|
+
const ROUTINE_RUNS = /^\/api\/bots\/([^/]+)\/routines\/([^/]+)\/runs$/;
|
|
121
|
+
const ROUTINE_HOOK = /^\/api\/bots\/([^/]+)\/routines\/([^/]+)\/hook$/;
|
|
122
|
+
const ROUTINE_RUN = /^\/api\/bots\/([^/]+)\/routines\/([^/]+)\/runs\/([^/]+)$/;
|
|
123
|
+
|
|
124
|
+
function jsonError(status: number, message: string): Response {
|
|
125
|
+
return Response.json({ error: message }, { status });
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function pathSegment(value: string): string {
|
|
129
|
+
try {
|
|
130
|
+
return decodeURIComponent(value);
|
|
131
|
+
} catch {
|
|
132
|
+
throw new RoutineDecodeError("request path is invalid");
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* A Bot the caller does not own, or one that does not exist, is the same
|
|
138
|
+
* answer: 404. The authority raises `BotNotFoundError`; nothing here
|
|
139
|
+
* distinguishes the two cases, which is the point.
|
|
140
|
+
*/
|
|
141
|
+
function isMissingBot(error: unknown): boolean {
|
|
142
|
+
return (
|
|
143
|
+
typeof error === "object" &&
|
|
144
|
+
error !== null &&
|
|
145
|
+
"name" in error &&
|
|
146
|
+
(error.name === "BotNotFoundError" || error.name === "RoutineNotFoundError")
|
|
147
|
+
);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function errorResponse(error: unknown): Response {
|
|
151
|
+
if (isMissingBot(error)) {
|
|
152
|
+
return jsonError(404, error instanceof Error ? error.message : "not found");
|
|
153
|
+
}
|
|
154
|
+
if (
|
|
155
|
+
error instanceof RoutineDecodeError ||
|
|
156
|
+
(typeof error === "object" &&
|
|
157
|
+
error !== null &&
|
|
158
|
+
"name" in error &&
|
|
159
|
+
error.name === "RoutineDecodeError")
|
|
160
|
+
) {
|
|
161
|
+
return jsonError(
|
|
162
|
+
400,
|
|
163
|
+
error instanceof Error ? error.message : "Routine request is invalid",
|
|
164
|
+
);
|
|
165
|
+
}
|
|
166
|
+
return jsonError(
|
|
167
|
+
500,
|
|
168
|
+
error instanceof Error ? error.message : "Routine request failed",
|
|
169
|
+
);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* One webhook delivery, from the open internet.
|
|
174
|
+
*
|
|
175
|
+
* The order of the checks is the whole design. The key is verified against the
|
|
176
|
+
* deployment's secret first, in constant time; only a token that was minted here
|
|
177
|
+
* names a User and a Bot, and only then is a Durable Object addressed. Nothing
|
|
178
|
+
* an anonymous caller sends decides which object exists.
|
|
179
|
+
*/
|
|
180
|
+
async function deliverHook(
|
|
181
|
+
host: RoutinesGatewayHost,
|
|
182
|
+
request: Request,
|
|
183
|
+
match: RegExpExecArray,
|
|
184
|
+
): Promise<Response> {
|
|
185
|
+
if (request.method !== "POST") return jsonError(405, "method not allowed");
|
|
186
|
+
const presented =
|
|
187
|
+
request.headers.get("authorization")?.replace(/^Bearer\s+/i, "") ??
|
|
188
|
+
request.headers.get("x-routine-key") ??
|
|
189
|
+
"";
|
|
190
|
+
const secret = host.routineHookSecret;
|
|
191
|
+
if (!secret) {
|
|
192
|
+
return jsonError(503, "webhook delivery is not configured");
|
|
193
|
+
}
|
|
194
|
+
let body: string;
|
|
195
|
+
try {
|
|
196
|
+
body = await request.text();
|
|
197
|
+
} catch {
|
|
198
|
+
return jsonError(400, "webhook body could not be read");
|
|
199
|
+
}
|
|
200
|
+
if (new TextEncoder().encode(body).length > ROUTINE_HOOK_BODY_MAX_BYTES) {
|
|
201
|
+
return jsonError(413, "webhook body is too large");
|
|
202
|
+
}
|
|
203
|
+
try {
|
|
204
|
+
const claims = await verifyRoutineHookTokenV1(secret, presented);
|
|
205
|
+
// The path and the key must agree. A token for one Routine presented at
|
|
206
|
+
// another's door is as good as forged.
|
|
207
|
+
if (
|
|
208
|
+
claims.b !== pathSegment(match[1]!) ||
|
|
209
|
+
claims.r !== pathSegment(match[2]!)
|
|
210
|
+
) {
|
|
211
|
+
throw new RoutineHookError(401, "webhook key is invalid");
|
|
212
|
+
}
|
|
213
|
+
const receipt = await host.deliverRoutineHook(claims.u, claims.b, {
|
|
214
|
+
routineId: claims.r,
|
|
215
|
+
keyVersion: claims.v,
|
|
216
|
+
digest: await routineHookDigestOf(presented),
|
|
217
|
+
deliveryId: await routineDeliveryIdV1(
|
|
218
|
+
claims.r,
|
|
219
|
+
body,
|
|
220
|
+
request.headers.get("idempotency-key"),
|
|
221
|
+
),
|
|
222
|
+
body,
|
|
223
|
+
contentType: request.headers.get("content-type"),
|
|
224
|
+
});
|
|
225
|
+
// 202 either way: the firing is durable and queued, and a replay answers
|
|
226
|
+
// with the firing the first delivery already made.
|
|
227
|
+
return Response.json(
|
|
228
|
+
{
|
|
229
|
+
schemaVersion: 1,
|
|
230
|
+
status: receipt.status,
|
|
231
|
+
routineId: claims.r,
|
|
232
|
+
fireId: receipt.fireId,
|
|
233
|
+
},
|
|
234
|
+
{ status: 202 },
|
|
235
|
+
);
|
|
236
|
+
} catch (error) {
|
|
237
|
+
if (
|
|
238
|
+
typeof error === "object" &&
|
|
239
|
+
error !== null &&
|
|
240
|
+
"name" in error &&
|
|
241
|
+
error.name === "RoutineHookError"
|
|
242
|
+
) {
|
|
243
|
+
const hookError = error as RoutineHookError;
|
|
244
|
+
return jsonError(hookError.status, hookError.message);
|
|
245
|
+
}
|
|
246
|
+
if (isMissingBot(error)) return jsonError(404, "Routine not found");
|
|
247
|
+
return jsonError(
|
|
248
|
+
500,
|
|
249
|
+
error instanceof Error ? error.message : "webhook delivery failed",
|
|
250
|
+
);
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/** Imported lazily so the client bundle never pulls the hook module in. */
|
|
255
|
+
async function routineHookDigestOf(token: string): Promise<string> {
|
|
256
|
+
const { routineHookDigestV1 } = await import("./hook.js");
|
|
257
|
+
return routineHookDigestV1(token);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
export function createRoutinesBackendContribution(
|
|
261
|
+
host: RoutinesGatewayHost,
|
|
262
|
+
): RoutinesBackendRouteContribution {
|
|
263
|
+
const contribution: RoutinesBackendRouteContribution = {
|
|
264
|
+
packageId: "routines",
|
|
265
|
+
async route(request, url, context) {
|
|
266
|
+
if (!context.userId) return undefined;
|
|
267
|
+
const list = ROUTINES.exec(url.pathname);
|
|
268
|
+
const inbox = ROUTINE_INBOX.exec(url.pathname);
|
|
269
|
+
const runs = ROUTINE_RUNS.exec(url.pathname);
|
|
270
|
+
const run = ROUTINE_RUN.exec(url.pathname);
|
|
271
|
+
if (!list && !inbox && !runs && !run) return undefined;
|
|
272
|
+
if ([...url.searchParams.keys()].length > 0) {
|
|
273
|
+
return jsonError(400, "Routine routes take no query parameters");
|
|
274
|
+
}
|
|
275
|
+
try {
|
|
276
|
+
const botId = pathSegment((list ?? inbox ?? runs ?? run)![1]!);
|
|
277
|
+
if (run) {
|
|
278
|
+
if (request.method !== "GET") {
|
|
279
|
+
return jsonError(405, "method not allowed");
|
|
280
|
+
}
|
|
281
|
+
return Response.json(
|
|
282
|
+
decodeRoutineRunDetailViewV1(
|
|
283
|
+
await host.readRoutineRun(
|
|
284
|
+
context.userId,
|
|
285
|
+
botId,
|
|
286
|
+
pathSegment(run[2]!),
|
|
287
|
+
pathSegment(run[3]!),
|
|
288
|
+
),
|
|
289
|
+
),
|
|
290
|
+
);
|
|
291
|
+
}
|
|
292
|
+
if (inbox) {
|
|
293
|
+
if (request.method === "GET") {
|
|
294
|
+
return Response.json(
|
|
295
|
+
decodeRoutineInboxViewV1(
|
|
296
|
+
await host.listRoutineInbox(context.userId, botId),
|
|
297
|
+
),
|
|
298
|
+
);
|
|
299
|
+
}
|
|
300
|
+
if (request.method !== "POST") {
|
|
301
|
+
return jsonError(405, "method not allowed");
|
|
302
|
+
}
|
|
303
|
+
const command = decodeRoutineInboxCommandV1(await request.json());
|
|
304
|
+
if (command.botId !== botId) {
|
|
305
|
+
return jsonError(
|
|
306
|
+
400,
|
|
307
|
+
"Routine command does not match the request path",
|
|
308
|
+
);
|
|
309
|
+
}
|
|
310
|
+
return Response.json(
|
|
311
|
+
decodeRoutineInboxReceiptV1(
|
|
312
|
+
await host.executeRoutineInboxCommand(
|
|
313
|
+
context.userId,
|
|
314
|
+
botId,
|
|
315
|
+
command,
|
|
316
|
+
),
|
|
317
|
+
),
|
|
318
|
+
);
|
|
319
|
+
}
|
|
320
|
+
if (runs) {
|
|
321
|
+
if (request.method !== "GET") {
|
|
322
|
+
return jsonError(405, "method not allowed");
|
|
323
|
+
}
|
|
324
|
+
return Response.json(
|
|
325
|
+
decodeRoutineRunListViewV1(
|
|
326
|
+
await host.listRoutineRuns(
|
|
327
|
+
context.userId,
|
|
328
|
+
botId,
|
|
329
|
+
pathSegment(runs[2]!),
|
|
330
|
+
),
|
|
331
|
+
),
|
|
332
|
+
);
|
|
333
|
+
}
|
|
334
|
+
if (request.method === "GET") {
|
|
335
|
+
return Response.json(
|
|
336
|
+
decodeRoutineListViewV1(
|
|
337
|
+
await host.listRoutines(context.userId, botId),
|
|
338
|
+
),
|
|
339
|
+
);
|
|
340
|
+
}
|
|
341
|
+
if (request.method !== "POST") {
|
|
342
|
+
return jsonError(405, "method not allowed");
|
|
343
|
+
}
|
|
344
|
+
const command = decodeRoutineCommandV1(await request.json());
|
|
345
|
+
if (command.botId !== botId) {
|
|
346
|
+
return jsonError(
|
|
347
|
+
400,
|
|
348
|
+
"Routine command does not match the request path",
|
|
349
|
+
);
|
|
350
|
+
}
|
|
351
|
+
return Response.json(
|
|
352
|
+
decodeRoutineCommandReceiptV1(
|
|
353
|
+
await host.executeRoutineCommand(context.userId, botId, command),
|
|
354
|
+
),
|
|
355
|
+
);
|
|
356
|
+
} catch (error) {
|
|
357
|
+
return errorResponse(error);
|
|
358
|
+
}
|
|
359
|
+
},
|
|
360
|
+
};
|
|
361
|
+
contribution.publicRoute = async (request, url) => {
|
|
362
|
+
const hook = ROUTINE_HOOK.exec(url.pathname);
|
|
363
|
+
return hook ? deliverHook(host, request, hook) : undefined;
|
|
364
|
+
};
|
|
365
|
+
return contribution;
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
export namespace createRoutinesBackendContribution {
|
|
369
|
+
export function plugin(
|
|
370
|
+
host: RoutinesGatewayHost,
|
|
371
|
+
lifecycle: { mount(value: RoutinesBackendRouteContribution): () => void },
|
|
372
|
+
): Plugin {
|
|
373
|
+
return () => lifecycle.mount(createRoutinesBackendContribution(host));
|
|
374
|
+
}
|
|
375
|
+
}
|