@frockbot/cloudflare 0.0.0 → 0.7.292

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.
Files changed (88) hide show
  1. package/build-artifact.ts +130 -0
  2. package/build-flutter-web.ts +508 -0
  3. package/deployment-config/README.md +414 -0
  4. package/deployment-config/cli.ts +228 -0
  5. package/deployment-config/generate.ts +689 -0
  6. package/deployment-config/jsonc.ts +18 -0
  7. package/deployment-config/profile-schema.generated.ts +391 -0
  8. package/deployment-config/profile.schema.json +358 -0
  9. package/deployment-config/profile.ts +124 -0
  10. package/migrations/0001_better_auth.sql +15 -0
  11. package/migrations/0002_drop_account_issuer.sql +8 -0
  12. package/package.json +61 -5
  13. package/release-version.ts +20 -0
  14. package/src/account-admission.ts +179 -0
  15. package/src/account-deletion.ts +274 -0
  16. package/src/admin-entrypoint.ts +194 -0
  17. package/src/admin-identities.ts +26 -0
  18. package/src/audit.ts +103 -0
  19. package/src/auth-package.access.ts +24 -0
  20. package/src/auth-package.ts +36 -0
  21. package/src/avatar-state-cleanup.ts +107 -0
  22. package/src/billing-computer.ts +267 -0
  23. package/src/billing-readiness.ts +21 -0
  24. package/src/billing.ts +461 -0
  25. package/src/bot-capabilities.ts +480 -0
  26. package/src/bot-recovery.ts +1 -0
  27. package/src/bot-state-channel.ts +702 -0
  28. package/src/bot-state.ts +3995 -0
  29. package/src/bot-template-cleanup.ts +98 -0
  30. package/src/bot-title-cleanup.ts +104 -0
  31. package/src/brand-icon.png +0 -0
  32. package/src/brand-logo.ts +2 -0
  33. package/src/brand.ts +36 -0
  34. package/src/client-compatibility.ts +51 -0
  35. package/src/compaction-announcement-cleanup.ts +113 -0
  36. package/src/computer-egress.ts +110 -0
  37. package/src/computer-host.ts +92 -0
  38. package/src/computer-screenshot-cleanup.ts +78 -0
  39. package/src/contracts.ts +832 -0
  40. package/src/debug.ts +272 -0
  41. package/src/default-packages-marker-cleanup.ts +39 -0
  42. package/src/deployment-policy-admin-host.ts +121 -0
  43. package/src/deployment-policy.ts +426 -0
  44. package/src/directory-profile-cleanup.ts +101 -0
  45. package/src/durable-rpc.ts +753 -0
  46. package/src/durable-session.ts +21 -0
  47. package/src/entry-boundary.ts +103 -0
  48. package/src/frock-ai.ts +338 -0
  49. package/src/gateway.ts +1392 -0
  50. package/src/group-chat.ts +736 -0
  51. package/src/hidden-bot-notifications-cleanup.ts +32 -0
  52. package/src/index.ts +2798 -0
  53. package/src/insights.ts +15 -0
  54. package/src/machine-messages-cleanup.ts +121 -0
  55. package/src/machine-socket.ts +164 -0
  56. package/src/memory-records.ts +181 -0
  57. package/src/memory.ts +145 -0
  58. package/src/model-rates.ts +252 -0
  59. package/src/native-auth.ts +1006 -0
  60. package/src/native-sessions.ts +121 -0
  61. package/src/notification-state-cleanup.ts +18 -0
  62. package/src/ollama-web-search-cleanup.ts +69 -0
  63. package/src/package-page-shapes-cleanup.ts +190 -0
  64. package/src/plugin-egress.ts +31 -0
  65. package/src/plugin-page-route.ts +72 -0
  66. package/src/plugin-panels-cleanup.ts +101 -0
  67. package/src/prepared-input-cleanup.ts +105 -0
  68. package/src/production-secrets.ts +484 -0
  69. package/src/project-cleanup.ts +123 -0
  70. package/src/project-events-cleanup.ts +152 -0
  71. package/src/publication-state-cleanup.ts +92 -0
  72. package/src/push.ts +498 -0
  73. package/src/request-body.ts +165 -0
  74. package/src/routine-state-cleanup.ts +57 -0
  75. package/src/search.ts +88 -0
  76. package/src/sidebar-label-cleanup.ts +165 -0
  77. package/src/skill-index-cleanup.ts +53 -0
  78. package/src/supersede-cleanup.ts +162 -0
  79. package/src/test-chat-cleanup.ts +93 -0
  80. package/src/uploads.ts +486 -0
  81. package/src/user-application.ts +1068 -0
  82. package/src/user-configuration.ts +4491 -0
  83. package/src/voice-assistant.ts +4999 -0
  84. package/src/voice-dictation.ts +743 -0
  85. package/src/working-context-cleanup.ts +96 -0
  86. package/src/workspace.ts +201 -0
  87. package/wrangler.jsonc +385 -0
  88. package/README.md +0 -3
@@ -0,0 +1,1068 @@
1
+ import {
2
+ foundationPackageCatalogV1,
3
+ FOUNDATION_PACKAGE_VERSION_V1,
4
+ } from "@frockbot/app/runtime";
5
+ import { BRAND_V1 } from "#brand";
6
+ import { COMPUTER_HOST_CAPABILITIES_V1 } from "./computer-host.js";
7
+ import {
8
+ decodeBotIdV1,
9
+ isApplicationDeploymentHash,
10
+ isRpcIdentifier,
11
+ } from "@frockbot/core/configuration";
12
+ import {
13
+ decodeClientNotificationAcknowledgementCommandV1,
14
+ type ClientNotificationAcknowledgementV1,
15
+ type ClientNotificationListV1,
16
+ decodeClientRunAdmissionFenceCommandV1,
17
+ decodeClientRunLookupQueryV1,
18
+ decodeClientRunListQueryV1,
19
+ parseExchangeCounterpartParamV1,
20
+ clientProtocolOfV1,
21
+ decodeClientRunStopCommandV1,
22
+ decodeClientTurnCommandV1,
23
+ RUN_ATTACHMENTS_PROTOCOL_V1,
24
+ withoutRunAttachmentsV1,
25
+ type ClientRunLookupQueryV1,
26
+ type ClientRunStopCommandV1,
27
+ type ClientTurnCommandV1,
28
+ type ClientTurnRefusalReasonV1,
29
+ type ClientTurnRefusalV1,
30
+ } from "@frockbot/app/shell/run-protocol";
31
+ import { decodeApprovalDecisionCommandV1 } from "@frockbot/app/shell/approvals";
32
+ import {
33
+ decodeSecretSubmitCommandV1,
34
+ isSecretRequestIdV1,
35
+ SecretDecodeError,
36
+ } from "@frockbot/app/secrets/shared";
37
+ import {
38
+ decodeCardActionCommandV1,
39
+ decodeCardSurfaceIdV1,
40
+ } from "@frockbot/app/shell/cards";
41
+ import { botTurnRefusalCodeV1 } from "@frockbot/core/durable";
42
+ import type { UserApplicationEnv } from "./contracts.js";
43
+ import { answeredEntryV1, entryFailureStatusV1 } from "./entry-boundary.js";
44
+ import { INSIGHTS_REPORT_ORIGIN, INSIGHTS_SCRIPT_ORIGIN } from "./insights.js";
45
+ import {
46
+ drainedAnswerV1,
47
+ isRequestTooLargeV1,
48
+ RequestTooLargeError,
49
+ TURN_BODY_MAX_BYTES_V1,
50
+ TURN_TOO_LONG_MESSAGE_V1,
51
+ turnBodyIsOversizedV1,
52
+ } from "./request-body.js";
53
+ import { whatsNewImageResponseV1 } from "@frockbot/app/whats-new";
54
+
55
+ declare const __FROCKBOT_FLUTTER_BUILD__: string;
56
+ declare const __FROCKBOT_CLIENT_ICON__: string;
57
+
58
+ /**
59
+ * The content-addressed prefix the Flutter client is served from.
60
+ *
61
+ * The payload is the Worker's own static assets, uploaded with the deploy and
62
+ * served straight from the edge; the artifact only names it. Every URL under
63
+ * the prefix carries the build hash, so the document is the one thing that
64
+ * changes when the client does.
65
+ */
66
+ const FLUTTER_BASE =
67
+ typeof __FROCKBOT_FLUTTER_BUILD__ === "string"
68
+ ? `/_flutter/${__FROCKBOT_FLUTTER_BUILD__}/`
69
+ : "/_flutter/development/";
70
+ // The site icon is a PNG, so it rides the artifact as base64 and is decoded
71
+ // once at module scope rather than on every request.
72
+ const APP_ICON = Uint8Array.from(
73
+ atob(
74
+ typeof __FROCKBOT_CLIENT_ICON__ === "string"
75
+ ? __FROCKBOT_CLIENT_ICON__
76
+ : "",
77
+ ),
78
+ (character) => character.charCodeAt(0),
79
+ );
80
+
81
+ /** The product's name as the document title carries it. */
82
+ const DOCUMENT_TITLE = BRAND_V1.productName
83
+ .replaceAll("&", "&")
84
+ .replaceAll("<", "&lt;");
85
+
86
+ type HostedAuthModeV1 = "anonymous" | "better-auth" | "development";
87
+
88
+ function hostedAuthMode(request: Request): HostedAuthModeV1 {
89
+ const mode = request.headers.get("x-frockbot-auth-session-v1");
90
+ if (
91
+ mode !== "anonymous" &&
92
+ mode !== "better-auth" &&
93
+ mode !== "development"
94
+ ) {
95
+ throw new Error("hosted auth session projection is invalid");
96
+ }
97
+ return mode;
98
+ }
99
+
100
+ function hostedIsAdmin(request: Request): boolean {
101
+ const value = request.headers.get("x-frockbot-is-admin-v1");
102
+ if (value !== "true" && value !== "false") {
103
+ throw new Error("hosted admin projection is invalid");
104
+ }
105
+ return value === "true";
106
+ }
107
+
108
+ /**
109
+ * The `<body>` attributes the client reads before it has asked anything.
110
+ *
111
+ * A browser's session is a cookie it cannot see, so the account is stamped
112
+ * onto the document the Worker renders and the Flutter app adopts it on its
113
+ * first frame (`apps/native/packages/frockbot_client/lib/client/identity_web.dart`) rather than
114
+ * flashing the sign-in door at someone who is already signed in. The identity
115
+ * read still happens; this is what it confirms.
116
+ */
117
+ export const HOSTED_EMBEDDED_BODY_ATTRIBUTES_V1 = [
118
+ "data-frockbot-user-id",
119
+ "data-frockbot-auth-mode",
120
+ "data-frockbot-is-admin",
121
+ ] as const;
122
+
123
+ function appHtml(
124
+ userId: string,
125
+ applicationHash: string,
126
+ authMode: HostedAuthModeV1,
127
+ isAdmin: boolean,
128
+ ): string {
129
+ if (!isRpcIdentifier(userId)) throw new Error("invalid user id");
130
+ if (!isApplicationDeploymentHash(applicationHash)) {
131
+ throw new Error("invalid application hash");
132
+ }
133
+ return `<!doctype html>
134
+ <html lang="en">
135
+ <head>
136
+ <meta charset="utf-8">
137
+ <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
138
+ <meta name="frockbot-application" content="${applicationHash}">
139
+ <base href="${FLUTTER_BASE}">
140
+ <title>${DOCUMENT_TITLE}</title>
141
+ <link rel="icon" type="image/png" href="/favicon.ico">
142
+ <link rel="apple-touch-icon" href="/favicon.ico">
143
+ <meta name="color-scheme" content="dark">
144
+ <meta name="theme-color" content="#15151e">
145
+ <style>html,body{margin:0;height:100%;background:#15151e}</style>
146
+ </head>
147
+ <body data-frockbot-user-id="${userId}" data-frockbot-user-application="${applicationHash}" data-frockbot-auth-mode="${authMode}" data-frockbot-is-admin="${String(isAdmin)}">
148
+ <script src="${FLUTTER_BASE}flutter_bootstrap.js" async></script>
149
+ </body>
150
+ </html>`;
151
+ }
152
+
153
+ /**
154
+ * What the app frames: a Plugin's pages, which are served from this origin
155
+ * but only under `/plugin-pages/` and each sandboxed by its own policy, and
156
+ * the Computer host's viewer origins.
157
+ */
158
+ function frameSources(applicationUrl: URL): string {
159
+ return [
160
+ `${applicationUrl.origin}/plugin-pages/`,
161
+ ...COMPUTER_HOST_CAPABILITIES_V1.viewerFrameOrigins,
162
+ ].join(" ");
163
+ }
164
+
165
+ function withSecurityHeaders(
166
+ response: Response,
167
+ applicationUrl: URL,
168
+ ): Response {
169
+ const secured = new Response(response.body, response);
170
+ secured.headers.set("x-content-type-options", "nosniff");
171
+ secured.headers.set("referrer-policy", "no-referrer");
172
+ secured.headers.set(
173
+ "content-security-policy",
174
+ // The app frames a Plugin's pages and the expanded Computer viewer.
175
+ //
176
+ // Cloudflare Insights is injected into every response by the zone itself,
177
+ // above this Worker, so the page loads it whether or not the policy allows
178
+ // it — and under `script-src 'self'` it was refused on every page load and
179
+ // logged a red console error for every User. Naming the beacon's origin is
180
+ // the honest fix: it is the deployment's own analytics, its script is
181
+ // fetched from one Cloudflare host and it reports to another. Removing it
182
+ // instead would mean turning the feature off in the zone, which the code
183
+ // cannot state or keep true.
184
+ //
185
+ // `script-src 'wasm-unsafe-eval'` and `style-src 'unsafe-inline'` are what
186
+ // the Flutter engine needs and neither is optional: CanvasKit instantiates
187
+ // WebAssembly, and the engine injects a `<style>` element to measure text.
188
+ // `base-uri 'self'` rather than `'none'` because the document sets a
189
+ // `<base href>` of its own to the content-addressed directory every engine
190
+ // URL is relative to.
191
+ `default-src 'self'; script-src 'self' 'wasm-unsafe-eval' ${INSIGHTS_SCRIPT_ORIGIN}; style-src 'self' 'unsafe-inline'; font-src 'self' data:; img-src 'self' data: blob:; connect-src 'self' ${INSIGHTS_REPORT_ORIGIN} ${applicationUrl.protocol === "https:" ? "wss:" : "ws:"}//${applicationUrl.host}; frame-src ${frameSources(applicationUrl)}; frame-ancestors 'none'; base-uri 'self'`,
192
+ );
193
+ return secured;
194
+ }
195
+
196
+ function jsonError(
197
+ status: number,
198
+ message: string,
199
+ options?: { definitive?: boolean },
200
+ ): Response {
201
+ return Response.json(
202
+ {
203
+ error: message,
204
+ ...(options?.definitive ? { definitive: true } : {}),
205
+ },
206
+ { status },
207
+ );
208
+ }
209
+
210
+ /**
211
+ * A Bot-scoped call that failed, answered with the status the failure is owed.
212
+ *
213
+ * These routes check the registration once, at the top, and then talk to the
214
+ * Bot Durable Object — and a Bot can stop existing in between. Deleting a Bot
215
+ * makes that window real and routine: the panels of the Bot being looked at
216
+ * poll it, so a read is almost always in flight when the delete lands. Such a
217
+ * read is a 404 or a 410, not a server fault, and answering 500 made the
218
+ * client log an error for the correct answer to its own question.
219
+ *
220
+ * Only a failure the entry boundary names gets a status of its own; anything
221
+ * unrecognised is still this Worker's 500.
222
+ */
223
+ function botFailure(error: unknown, fallback: string): Response {
224
+ return jsonError(
225
+ entryFailureStatusV1(error),
226
+ error instanceof Error ? error.message : fallback,
227
+ );
228
+ }
229
+
230
+ /**
231
+ * A Turn the Bot Durable Object declined to admit, told apart from one that
232
+ * broke.
233
+ *
234
+ * Admission refusals are ordinary, expected answers — the object is busy with
235
+ * a Turn this command did not ask to replace, or is holding an uncertain
236
+ * effect that has to be retrieved first, or the run was fenced. None of them
237
+ * is a server fault, and answering 500 made the client log a console error for
238
+ * something it should simply show the person. They are 409: the request was
239
+ * well formed and the Bot's current state refuses it.
240
+ */
241
+ const TURN_ADMISSION_REFUSALS_V1: readonly {
242
+ match: RegExp;
243
+ reason: ClientTurnRefusalReasonV1;
244
+ }[] = [
245
+ { match: /bot already has an active run/i, reason: "busy" },
246
+ { match: /admission was fenced/i, reason: "fenced" },
247
+ { match: /already (exists|completed)/i, reason: "duplicate" },
248
+ ];
249
+
250
+ export type { ClientTurnRefusalReasonV1, ClientTurnRefusalV1 };
251
+
252
+ function turnRefusal(error: unknown): ClientTurnRefusalV1 | undefined {
253
+ const message = error instanceof Error ? error.message : "";
254
+ // The typed refusal first: the authority names its own reason, and the name
255
+ // is what survives the Durable Object RPC. The prose match stays as a
256
+ // fallback for refusals a Package still raises as plain errors.
257
+ const reason =
258
+ botTurnRefusalCodeV1(error) ??
259
+ TURN_ADMISSION_REFUSALS_V1.find((candidate) =>
260
+ candidate.match.test(message),
261
+ )?.reason;
262
+ if (!reason) return undefined;
263
+ return {
264
+ schemaVersion: 1,
265
+ status: "refused",
266
+ reason,
267
+ error: message,
268
+ };
269
+ }
270
+
271
+ async function requireRegisteredBot(
272
+ env: UserApplicationEnv,
273
+ botId: string,
274
+ allowArchived = false,
275
+ ): Promise<Response | undefined> {
276
+ try {
277
+ await env.BOT_STATE.assertRegistered({ schemaVersion: 1, botId });
278
+ return undefined;
279
+ } catch (error) {
280
+ if (typeof error === "object" && error !== null && "name" in error) {
281
+ if (error.name === "BotNotFoundError")
282
+ return jsonError(
283
+ 404,
284
+ error instanceof Error ? error.message : "Bot not found",
285
+ );
286
+ if (error.name === "BotArchivedError") {
287
+ if (allowArchived) return undefined;
288
+ return jsonError(
289
+ 409,
290
+ error instanceof Error ? error.message : "Bot is archived",
291
+ );
292
+ }
293
+ if (error.name === "BotDeletedError")
294
+ return jsonError(
295
+ 410,
296
+ error instanceof Error ? error.message : "Bot is deleted",
297
+ );
298
+ }
299
+ return jsonError(
300
+ 503,
301
+ error instanceof Error
302
+ ? error.message
303
+ : "Bot registration is temporarily unavailable",
304
+ );
305
+ }
306
+ }
307
+
308
+ /**
309
+ * Read a send, refusing an oversized one before the body is touched.
310
+ *
311
+ * The refusal is typed rather than prose so the route can answer 413 with the
312
+ * sentence the composer shows, and so it is told apart from a body that simply
313
+ * would not parse. The gateway checks the same length one isolate earlier;
314
+ * this check is the one that holds however else a request reaches this Worker.
315
+ */
316
+ async function readTurnCommand(request: Request): Promise<ClientTurnCommandV1> {
317
+ const contentLength = Number(request.headers.get("content-length") ?? "0");
318
+ if (contentLength > TURN_BODY_MAX_BYTES_V1) {
319
+ throw new RequestTooLargeError();
320
+ }
321
+ try {
322
+ return decodeClientTurnCommandV1(await request.json());
323
+ } catch (error) {
324
+ // The decoder's own bound on `text`. Same refusal, said the same way: a
325
+ // body under the wire limit can still carry a message over the text one.
326
+ if (
327
+ error instanceof Error &&
328
+ /turn command\.text\b.*\b(exceeds|too long|too large)/i.test(
329
+ error.message,
330
+ )
331
+ ) {
332
+ throw new RequestTooLargeError();
333
+ }
334
+ throw error;
335
+ }
336
+ }
337
+
338
+ export function createUserApplication() {
339
+ const route = createUserApplicationRoute();
340
+ /*
341
+ * The User application's own outermost wrapper.
342
+ *
343
+ * This Worker is loaded into its own isolate and receives its own `Request`,
344
+ * so the gateway's drain does nothing for it: an early return here — a 404
345
+ * on a POST, a body refused for its size — is exactly the shape that makes
346
+ * workerd tear the isolate down with "Can't read from request stream after
347
+ * response has been sent". Every answer passes through the drain, and the
348
+ * size refusal is given before any route runs so an oversized send is never
349
+ * parsed.
350
+ */
351
+ return async (
352
+ request: Request,
353
+ env: UserApplicationEnv,
354
+ ): Promise<Response> => {
355
+ let oversized = false;
356
+ try {
357
+ oversized = turnBodyIsOversizedV1(request, new URL(request.url));
358
+ } catch {
359
+ // An unparseable URL is the route's 400 to give, not this guard's.
360
+ }
361
+ if (oversized) {
362
+ return drainedAnswerV1(request, jsonError(413, TURN_TOO_LONG_MESSAGE_V1));
363
+ }
364
+ // This Worker's `fetch` is an entry point of its own: a route that throws
365
+ // has no caller left inside the isolate, and the log showed exactly that —
366
+ // `BotNotFoundError` five times in one window, then a refused Turn, then
367
+ // the process exiting. A Bot that is not there is a 404 the client can act
368
+ // on; anything else is a 500 that still carries a readable reason, and the
369
+ // isolate survives to answer the next request.
370
+ return drainedAnswerV1(
371
+ request,
372
+ await answeredEntryV1("request failed", () => route(request, env)),
373
+ );
374
+ };
375
+ }
376
+
377
+ function createUserApplicationRoute() {
378
+ return async (
379
+ request: Request,
380
+ env: UserApplicationEnv,
381
+ ): Promise<Response> => {
382
+ let url: URL;
383
+ try {
384
+ url = new URL(request.url);
385
+ } catch {
386
+ return jsonError(400, "invalid request URL");
387
+ }
388
+
389
+ if (request.method === "GET" && url.pathname === "/") {
390
+ return withSecurityHeaders(
391
+ new Response(
392
+ appHtml(
393
+ env.DEPLOYMENT.userId,
394
+ env.DEPLOYMENT.applicationHash,
395
+ hostedAuthMode(request),
396
+ hostedIsAdmin(request),
397
+ ),
398
+ {
399
+ headers: { "content-type": "text/html; charset=utf-8" },
400
+ },
401
+ ),
402
+ url,
403
+ );
404
+ }
405
+ if (request.method === "GET" && url.pathname === "/favicon.ico") {
406
+ // Browsers request `/favicon.ico` by convention even when a link element
407
+ // names it, so the site icon answers on that one path in its real type.
408
+ return withSecurityHeaders(
409
+ new Response(APP_ICON, {
410
+ headers: {
411
+ "content-type": "image/png",
412
+ "cache-control": "no-cache",
413
+ },
414
+ }),
415
+ url,
416
+ );
417
+ }
418
+ if (request.method === "GET" && BRAND_V1.whatsNew) {
419
+ const picture = whatsNewImageResponseV1(url.pathname);
420
+ if (picture) {
421
+ return withSecurityHeaders(picture, url);
422
+ }
423
+ }
424
+ if (request.method === "GET" && url.pathname === "/app-manifest") {
425
+ return Response.json({
426
+ schemaVersion: 1,
427
+ deployment: env.DEPLOYMENT,
428
+ // The client needs model-provider facts even when a Package is
429
+ // platform-owned, so every Package is projected and each carries the
430
+ // ownership its definition declares; enablement surfaces omit those
431
+ // rows while model resolution still sees them.
432
+ packages: foundationPackageCatalogV1(BRAND_V1).entries.map((pkg) => ({
433
+ id: pkg.id,
434
+ displayName: pkg.displayName,
435
+ version: FOUNDATION_PACKAGE_VERSION_V1,
436
+ ...(pkg.platformOwned ? { platformOwned: true } : {}),
437
+ ...(pkg.settings ? { settings: pkg.settings } : {}),
438
+ ...(pkg.capabilities ? { capabilities: pkg.capabilities } : {}),
439
+ ...(pkg.connectionTypes
440
+ ? { connectionTypes: pkg.connectionTypes }
441
+ : {}),
442
+ })),
443
+ });
444
+ }
445
+
446
+ const notificationMatch = url.pathname.match(
447
+ /^\/api\/bots\/([^/]+)\/notifications$/,
448
+ );
449
+ if (notificationMatch) {
450
+ let notificationBotId: string;
451
+ try {
452
+ notificationBotId = decodeURIComponent(notificationMatch[1]);
453
+ } catch {
454
+ return jsonError(400, "invalid bot id");
455
+ }
456
+ try {
457
+ notificationBotId = decodeBotIdV1(notificationBotId);
458
+ } catch {
459
+ return jsonError(400, "invalid bot id");
460
+ }
461
+ const missingBot = await requireRegisteredBot(env, notificationBotId);
462
+ if (missingBot) return missingBot;
463
+ if (request.method === "GET") {
464
+ return Response.json({
465
+ schemaVersion: 1,
466
+ notifications: await env.BOT_STATE.listNotifications({
467
+ schemaVersion: 1,
468
+ botId: notificationBotId,
469
+ }),
470
+ } satisfies ClientNotificationListV1);
471
+ }
472
+ if (request.method !== "POST") {
473
+ return jsonError(405, "method not allowed");
474
+ }
475
+ let command;
476
+ try {
477
+ command = decodeClientNotificationAcknowledgementCommandV1(
478
+ await request.json(),
479
+ );
480
+ } catch (error) {
481
+ return jsonError(
482
+ 400,
483
+ error instanceof Error
484
+ ? error.message
485
+ : "invalid notification acknowledgement",
486
+ );
487
+ }
488
+ await env.BOT_STATE.acknowledgeNotification({
489
+ schemaVersion: 1,
490
+ botId: notificationBotId,
491
+ notificationId: command.notificationId,
492
+ });
493
+ return Response.json({
494
+ schemaVersion: 1,
495
+ status: "acknowledged",
496
+ } satisfies ClientNotificationAcknowledgementV1);
497
+ }
498
+
499
+ // Approval cards (row 53). Bot-scoped and beside the notifications route,
500
+ // because a pending decision is Bot state the User answers, not a Package
501
+ // surface of its own.
502
+ const approvalsMatch = url.pathname.match(
503
+ /^\/api\/bots\/([^/]+)\/approvals$/,
504
+ );
505
+ const approvalMatch = url.pathname.match(
506
+ /^\/api\/bots\/([^/]+)\/approvals\/([^/]+)$/,
507
+ );
508
+ if (approvalsMatch || approvalMatch) {
509
+ let approvalBotId: string;
510
+ try {
511
+ approvalBotId = decodeBotIdV1(
512
+ decodeURIComponent((approvalsMatch ?? approvalMatch)![1]),
513
+ );
514
+ } catch {
515
+ return jsonError(400, "invalid bot id");
516
+ }
517
+ const missing = await requireRegisteredBot(env, approvalBotId);
518
+ if (missing) return missing;
519
+ if (approvalsMatch) {
520
+ if (request.method !== "GET") {
521
+ return jsonError(405, "method not allowed");
522
+ }
523
+ try {
524
+ return Response.json(
525
+ await env.BOT_STATE.listApprovals({
526
+ schemaVersion: 1,
527
+ botId: approvalBotId,
528
+ }),
529
+ );
530
+ } catch (error) {
531
+ return botFailure(error, "approvals failed");
532
+ }
533
+ }
534
+ if (request.method !== "POST") {
535
+ return jsonError(405, "method not allowed");
536
+ }
537
+ let approvalId: string;
538
+ let command;
539
+ try {
540
+ approvalId = decodeURIComponent(approvalMatch![2]);
541
+ if (!isRpcIdentifier(approvalId)) {
542
+ throw new Error("approval id is invalid");
543
+ }
544
+ command = decodeApprovalDecisionCommandV1(await request.json());
545
+ } catch (error) {
546
+ return jsonError(
547
+ 400,
548
+ error instanceof Error ? error.message : "invalid approval decision",
549
+ );
550
+ }
551
+ try {
552
+ // The decision is durable before this answers: "admit input durably
553
+ // before acknowledging" applies to a person's answer as much as to a
554
+ // Turn's, and a replay reads back the one decision that was recorded.
555
+ return Response.json(
556
+ await env.BOT_STATE.decideApproval({
557
+ schemaVersion: 1,
558
+ botId: approvalBotId,
559
+ approvalId,
560
+ command,
561
+ }),
562
+ );
563
+ } catch (error) {
564
+ const message =
565
+ error instanceof Error ? error.message : "approval decision failed";
566
+ const name = error instanceof Error ? error.name : "";
567
+ if (name === "ApprovalNotFoundError") return jsonError(404, message);
568
+ return jsonError(500, message);
569
+ }
570
+ }
571
+
572
+ // Cards (ADR 0030). Beside the approvals it can carry: a GET is the Bot's
573
+ // surfaces as they stand, a GET of one id is that surface whatever the
574
+ // listing's byte budget cut, and a POST is one action on one of them,
575
+ // which the kernel — never the Card — decides the meaning of.
576
+ const cardsMatch = url.pathname.match(/^\/api\/bots\/([^/]+)\/cards$/);
577
+ const cardMatch = url.pathname.match(
578
+ /^\/api\/bots\/([^/]+)\/cards\/([^/]+)$/,
579
+ );
580
+ if (cardsMatch || cardMatch) {
581
+ let cardBotId: string;
582
+ try {
583
+ cardBotId = decodeBotIdV1(
584
+ decodeURIComponent((cardsMatch ?? cardMatch)![1]!),
585
+ );
586
+ } catch {
587
+ return jsonError(400, "invalid bot id");
588
+ }
589
+ const missing = await requireRegisteredBot(env, cardBotId);
590
+ if (missing) return missing;
591
+ if (cardMatch) {
592
+ if (request.method !== "GET") {
593
+ return jsonError(405, "method not allowed");
594
+ }
595
+ let surfaceId: string;
596
+ try {
597
+ surfaceId = decodeCardSurfaceIdV1(decodeURIComponent(cardMatch[2]!));
598
+ } catch (error) {
599
+ return jsonError(
600
+ 400,
601
+ error instanceof Error ? error.message : "invalid surface id",
602
+ );
603
+ }
604
+ try {
605
+ return Response.json(
606
+ await env.BOT_STATE.readCard({
607
+ schemaVersion: 1,
608
+ botId: cardBotId,
609
+ surfaceId,
610
+ }),
611
+ );
612
+ } catch (error) {
613
+ const message =
614
+ error instanceof Error ? error.message : "card read failed";
615
+ const name = error instanceof Error ? error.name : "";
616
+ if (name === "CardNotFoundError") return jsonError(404, message);
617
+ if (name === "CardDecodeError") return jsonError(400, message);
618
+ return jsonError(500, message);
619
+ }
620
+ }
621
+ if (request.method === "GET") {
622
+ try {
623
+ return Response.json(
624
+ await env.BOT_STATE.listCards({
625
+ schemaVersion: 1,
626
+ botId: cardBotId,
627
+ }),
628
+ );
629
+ } catch (error) {
630
+ return botFailure(error, "cards failed");
631
+ }
632
+ }
633
+ if (request.method !== "POST") {
634
+ return jsonError(405, "method not allowed");
635
+ }
636
+ let command;
637
+ try {
638
+ command = decodeCardActionCommandV1(await request.json());
639
+ } catch (error) {
640
+ return jsonError(
641
+ 400,
642
+ error instanceof Error ? error.message : "invalid card action",
643
+ );
644
+ }
645
+ try {
646
+ return Response.json(
647
+ await env.BOT_STATE.cardAction({
648
+ schemaVersion: 1,
649
+ botId: cardBotId,
650
+ command,
651
+ }),
652
+ );
653
+ } catch (error) {
654
+ const message =
655
+ error instanceof Error ? error.message : "card action failed";
656
+ const name = error instanceof Error ? error.name : "";
657
+ // A surface that has moved is not a fault: the person answered the
658
+ // card they were shown, and the client redraws and asks again. A
659
+ // refused action is the client's, not the kernel's.
660
+ if (name === "CardStaleError") return jsonError(409, message);
661
+ if (name === "CardNotFoundError") return jsonError(404, message);
662
+ if (name === "CardDecodeError") return jsonError(400, message);
663
+ // An approval a Card named but the kernel does not hold is the same
664
+ // answer the approvals route gives for it, and for the same reason.
665
+ if (name === "ApprovalNotFoundError") return jsonError(404, message);
666
+ if (name === "ApprovalDecodeError") return jsonError(400, message);
667
+ return jsonError(500, message);
668
+ }
669
+ }
670
+
671
+ // A secret a person typed on a Bot's secret-request card. The body is
672
+ // decoded without ever being echoed, handed to the Bot that asked, and
673
+ // sealed by the User object before this answers; nothing here keeps,
674
+ // logs or repeats it, and a failure answers in fixed words.
675
+ const secretMatch = url.pathname.match(
676
+ /^\/api\/bots\/([^/]+)\/secret-requests\/([^/]+)$/,
677
+ );
678
+ if (secretMatch) {
679
+ if (request.method !== "POST") {
680
+ return jsonError(405, "method not allowed");
681
+ }
682
+ let secretBotId: string;
683
+ let requestId: string;
684
+ let command;
685
+ try {
686
+ secretBotId = decodeBotIdV1(decodeURIComponent(secretMatch[1]!));
687
+ requestId = decodeURIComponent(secretMatch[2]!);
688
+ if (!isSecretRequestIdV1(requestId)) {
689
+ return jsonError(404, "That secret request was not found.");
690
+ }
691
+ } catch {
692
+ return jsonError(400, "invalid bot id");
693
+ }
694
+ const missing = await requireRegisteredBot(env, secretBotId);
695
+ if (missing) return missing;
696
+ try {
697
+ command = decodeSecretSubmitCommandV1(await request.json());
698
+ } catch (error) {
699
+ return jsonError(
700
+ 400,
701
+ error instanceof SecretDecodeError
702
+ ? error.message
703
+ : "That secret couldn't be read.",
704
+ );
705
+ }
706
+ try {
707
+ return Response.json(
708
+ await env.BOT_STATE.submitSecret({
709
+ schemaVersion: 1,
710
+ botId: secretBotId,
711
+ requestId,
712
+ command,
713
+ }),
714
+ { headers: { "cache-control": "no-store" } },
715
+ );
716
+ } catch (error) {
717
+ const name = error instanceof Error ? error.name : "";
718
+ if (name === "SecretRequestNotFoundError") {
719
+ return jsonError(404, "That secret request was not found.");
720
+ }
721
+ if (name === "SecretLimitError" && error instanceof Error) {
722
+ return jsonError(409, error.message);
723
+ }
724
+ return jsonError(500, "The secret couldn't be saved. Try again.");
725
+ }
726
+ }
727
+
728
+ const skillsMatch = url.pathname.match(/^\/api\/bots\/([^/]+)\/skills$/);
729
+ const workspaceFileMatch = url.pathname.match(
730
+ /^\/api\/bots\/([^/]+)\/workspace\/file$/,
731
+ );
732
+ const turnMatch = url.pathname.match(/^\/api\/bots\/([^/]+)\/turns$/);
733
+ const lookupMatch = url.pathname.match(
734
+ /^\/api\/bots\/([^/]+)\/turns\/([^/]+)$/,
735
+ );
736
+ const fenceMatch = url.pathname.match(
737
+ /^\/api\/bots\/([^/]+)\/turns\/([^/]+)\/fence$/,
738
+ );
739
+ const questionsMatch = url.pathname.match(
740
+ /^\/api\/bots\/([^/]+)\/turns\/([^/]+)\/questions$/,
741
+ );
742
+ const stopMatch = url.pathname.match(
743
+ /^\/api\/bots\/([^/]+)\/turns\/([^/]+)\/stop$/,
744
+ );
745
+ if (
746
+ !skillsMatch &&
747
+ !workspaceFileMatch &&
748
+ !turnMatch &&
749
+ !lookupMatch &&
750
+ !questionsMatch &&
751
+ !fenceMatch &&
752
+ !stopMatch
753
+ ) {
754
+ return jsonError(404, "not found");
755
+ }
756
+ let botId: string;
757
+ try {
758
+ const matched =
759
+ skillsMatch ??
760
+ workspaceFileMatch ??
761
+ turnMatch ??
762
+ lookupMatch ??
763
+ questionsMatch ??
764
+ fenceMatch ??
765
+ stopMatch;
766
+ botId = decodeURIComponent(matched![1]);
767
+ } catch {
768
+ return jsonError(400, "invalid bot id");
769
+ }
770
+ try {
771
+ botId = decodeBotIdV1(botId);
772
+ } catch {
773
+ return jsonError(400, "invalid bot id");
774
+ }
775
+ const missingBot = await requireRegisteredBot(
776
+ env,
777
+ botId,
778
+ request.method === "GET" &&
779
+ Boolean(turnMatch || lookupMatch || questionsMatch),
780
+ );
781
+ if (missingBot) return missingBot;
782
+ // An installed client older than protocol 3 refuses a Run it does not
783
+ // know every field of, so the files are left off what it is sent.
784
+ const forClient = <T>(value: T): T =>
785
+ clientProtocolOfV1(request.headers.get("x-frockbot-client")) >=
786
+ RUN_ATTACHMENTS_PROTOCOL_V1
787
+ ? value
788
+ : withoutRunAttachmentsV1(value);
789
+
790
+ if (skillsMatch) {
791
+ // Read-only, and named refs only: the popover learns which Skills exist
792
+ // and never receives a body.
793
+ if (request.method !== "GET") return jsonError(405, "method not allowed");
794
+ try {
795
+ return Response.json(
796
+ await env.BOT_STATE.listSkills({ schemaVersion: 1, botId }),
797
+ );
798
+ } catch (error) {
799
+ return botFailure(error, "skill catalog failed");
800
+ }
801
+ }
802
+
803
+ if (workspaceFileMatch) {
804
+ // Read-only, and the durable root and path arrive as one encoded
805
+ // `WorkspacePathV1` so the route cannot assemble a root the decoder
806
+ // would not accept. The bytes come from object storage; no Computer
807
+ // wakes to serve this.
808
+ if (request.method !== "GET") return jsonError(405, "method not allowed");
809
+ const encoded = url.searchParams.get("path");
810
+ if (!encoded) return jsonError(400, "a workspace path is required");
811
+ let path: unknown;
812
+ try {
813
+ path = JSON.parse(encoded);
814
+ } catch {
815
+ return jsonError(400, "invalid workspace path");
816
+ }
817
+ try {
818
+ const answer = await env.BOT_STATE.readWorkspaceFileV1({
819
+ schemaVersion: 1,
820
+ botId,
821
+ path,
822
+ });
823
+ if (answer.status !== "ok") {
824
+ return jsonError(
825
+ answer.status === "not-found" ? 404 : 409,
826
+ "reason" in answer ? answer.reason : "workspace read failed",
827
+ );
828
+ }
829
+ const binary = atob(answer.bytesBase64);
830
+ const bytes = new Uint8Array(binary.length);
831
+ for (let index = 0; index < binary.length; index += 1) {
832
+ bytes[index] = binary.charCodeAt(index);
833
+ }
834
+ return new Response(bytes, {
835
+ headers: {
836
+ "content-type": "application/octet-stream",
837
+ "cache-control": "private, max-age=60",
838
+ etag: `"${answer.contentHash}"`,
839
+ },
840
+ });
841
+ } catch (error) {
842
+ return jsonError(
843
+ 400,
844
+ error instanceof Error ? error.message : "workspace read failed",
845
+ );
846
+ }
847
+ }
848
+
849
+ if (fenceMatch) {
850
+ if (request.method !== "POST") {
851
+ return jsonError(405, "method not allowed");
852
+ }
853
+ let query: ClientRunLookupQueryV1;
854
+ try {
855
+ decodeClientRunAdmissionFenceCommandV1(await request.json());
856
+ query = decodeClientRunLookupQueryV1({
857
+ schemaVersion: 1,
858
+ runId: decodeURIComponent(fenceMatch[2]),
859
+ });
860
+ } catch (error) {
861
+ return jsonError(
862
+ 400,
863
+ error instanceof Error ? error.message : "invalid admission fence",
864
+ );
865
+ }
866
+ try {
867
+ return Response.json(
868
+ forClient(
869
+ await env.BOT_STATE.fenceRunAdmission({
870
+ schemaVersion: 1,
871
+ botId,
872
+ query,
873
+ }),
874
+ ),
875
+ );
876
+ } catch (error) {
877
+ return botFailure(error, "admission fence failed");
878
+ }
879
+ }
880
+
881
+ if (stopMatch) {
882
+ if (request.method !== "POST") {
883
+ return jsonError(405, "method not allowed");
884
+ }
885
+ let command: ClientRunStopCommandV1;
886
+ try {
887
+ const body: unknown = await request.json();
888
+ command = decodeClientRunStopCommandV1(body);
889
+ if (command.runId !== decodeURIComponent(stopMatch[2])) {
890
+ throw new Error("run stop command does not match the request path");
891
+ }
892
+ } catch (error) {
893
+ return jsonError(
894
+ 400,
895
+ error instanceof Error ? error.message : "invalid stop command",
896
+ );
897
+ }
898
+ try {
899
+ return Response.json(
900
+ forClient(
901
+ await env.BOT_STATE.stopRun({ schemaVersion: 1, botId, command }),
902
+ ),
903
+ );
904
+ } catch (error) {
905
+ return jsonError(
906
+ 409,
907
+ error instanceof Error ? error.message : "Stop failed",
908
+ );
909
+ }
910
+ }
911
+
912
+ if (questionsMatch) {
913
+ if (request.method !== "GET") {
914
+ return jsonError(405, "method not allowed");
915
+ }
916
+ let query: ClientRunLookupQueryV1;
917
+ try {
918
+ if ([...url.searchParams.keys()].length > 0) {
919
+ throw new Error("run questions do not accept URL parameters");
920
+ }
921
+ query = decodeClientRunLookupQueryV1({
922
+ schemaVersion: 1,
923
+ runId: decodeURIComponent(questionsMatch[2]),
924
+ });
925
+ } catch (error) {
926
+ return jsonError(
927
+ 400,
928
+ error instanceof Error ? error.message : "invalid run questions",
929
+ );
930
+ }
931
+ try {
932
+ return Response.json(
933
+ await env.BOT_STATE.runQuestions({ schemaVersion: 1, botId, query }),
934
+ );
935
+ } catch (error) {
936
+ return botFailure(error, "run questions failed");
937
+ }
938
+ }
939
+
940
+ if (lookupMatch) {
941
+ if (request.method !== "GET") {
942
+ return jsonError(405, "method not allowed");
943
+ }
944
+ let query: ClientRunLookupQueryV1;
945
+ try {
946
+ if ([...url.searchParams.keys()].length > 0) {
947
+ throw new Error("run lookup query does not accept URL parameters");
948
+ }
949
+ query = decodeClientRunLookupQueryV1({
950
+ schemaVersion: 1,
951
+ runId: decodeURIComponent(lookupMatch[2]),
952
+ });
953
+ } catch (error) {
954
+ return jsonError(
955
+ 400,
956
+ error instanceof Error ? error.message : "invalid run lookup",
957
+ );
958
+ }
959
+ try {
960
+ return Response.json(
961
+ forClient(
962
+ await env.BOT_STATE.lookupRun({ schemaVersion: 1, botId, query }),
963
+ ),
964
+ );
965
+ } catch (error) {
966
+ return botFailure(error, "run lookup failed");
967
+ }
968
+ }
969
+
970
+ if (request.method === "GET") {
971
+ let query;
972
+ try {
973
+ const queryKeys = [...url.searchParams.keys()];
974
+ if (
975
+ queryKeys.some((key) => key !== "before" && key !== "with") ||
976
+ url.searchParams.getAll("before").length > 1 ||
977
+ url.searchParams.getAll("with").length > 1
978
+ ) {
979
+ throw new Error("run list query is invalid");
980
+ }
981
+ const before = url.searchParams.get("before");
982
+ const counterpart = url.searchParams.get("with");
983
+ query = decodeClientRunListQueryV1({
984
+ schemaVersion: 1,
985
+ ...(before === null ? {} : { before }),
986
+ ...(counterpart === null
987
+ ? {}
988
+ : { counterpart: parseExchangeCounterpartParamV1(counterpart) }),
989
+ });
990
+ } catch (error) {
991
+ return jsonError(
992
+ 400,
993
+ error instanceof Error ? error.message : "invalid run page",
994
+ );
995
+ }
996
+ try {
997
+ return Response.json(
998
+ forClient(
999
+ await env.BOT_STATE.listRuns({ schemaVersion: 1, botId, query }),
1000
+ ),
1001
+ );
1002
+ } catch (error) {
1003
+ // A stored run the current codec refuses is a visible failure with
1004
+ // its reason, never a crash of the whole application Worker.
1005
+ return botFailure(error, "run list failed");
1006
+ }
1007
+ }
1008
+ if (request.method !== "POST") return jsonError(405, "method not allowed");
1009
+
1010
+ let turnCommand: ClientTurnCommandV1;
1011
+ try {
1012
+ turnCommand = await readTurnCommand(request);
1013
+ } catch (error) {
1014
+ if (isRequestTooLargeV1(error)) {
1015
+ return jsonError(413, TURN_TOO_LONG_MESSAGE_V1);
1016
+ }
1017
+ return jsonError(
1018
+ 400,
1019
+ error instanceof Error ? error.message : "invalid prompt",
1020
+ );
1021
+ }
1022
+
1023
+ try {
1024
+ return Response.json(
1025
+ // Every Turn a client asks for is admitted as `chat`. The turn type is
1026
+ // never carried here: `decodeClientTurnCommandV1` accepts exact keys,
1027
+ // and the Bot Durable Object's run RPC accepts exact keys too, so a
1028
+ // client cannot name one, and an absent turn type means `chat`. Only an
1029
+ // in-Durable-Object producer may admit another type.
1030
+ //
1031
+ // The answer is the admission receipt. Execution continues on the Bot
1032
+ // object's drive and recovery alarm; this response does not wait for
1033
+ // either.
1034
+ await env.BOT_STATE.admitRun({
1035
+ schemaVersion: 1,
1036
+ botId,
1037
+ command: {
1038
+ runId: turnCommand.commandId,
1039
+ sessionId: `${env.DEPLOYMENT.userId}:${botId}`,
1040
+ acceptedAt: new Date().toISOString(),
1041
+ text: turnCommand.text,
1042
+ ...(turnCommand.retryOf ? { retryOf: turnCommand.retryOf } : {}),
1043
+ // Refs only: the client names a Skill and never carries its text,
1044
+ // so what a Turn runs on is still whatever the instruction root
1045
+ // holds at the generation the Turn resolves.
1046
+ ...(turnCommand.skills ? { skills: turnCommand.skills } : {}),
1047
+ // Refs again: the bytes were admitted by the upload route, and
1048
+ // the Bot resolves each name against its own uploads.
1049
+ ...(turnCommand.attachments
1050
+ ? { attachments: turnCommand.attachments }
1051
+ : {}),
1052
+ },
1053
+ }),
1054
+ { status: 202 },
1055
+ );
1056
+ } catch (error) {
1057
+ const refusal = turnRefusal(error);
1058
+ if (refusal) return Response.json(refusal, { status: 409 });
1059
+ return botFailure(error, "Bot turn failed");
1060
+ }
1061
+ };
1062
+ }
1063
+
1064
+ const fetchUserApplication = createUserApplication();
1065
+
1066
+ export default {
1067
+ fetch: fetchUserApplication,
1068
+ } satisfies ExportedHandler<UserApplicationEnv>;