@frockbot/cloudflare 0.0.0 → 0.7.291

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
package/src/gateway.ts ADDED
@@ -0,0 +1,1392 @@
1
+ import { BRAND_V1 } from "#brand";
2
+ import { decodeProtocol } from "@frockbot/core/protocol-schemas";
3
+ import { settingsDocumentV1 } from "@frockbot/app/settings/document";
4
+ import { connectionsCatalogQueryV1 } from "@frockbot/app/settings/frame";
5
+ import { secretsDocumentV1 } from "@frockbot/app/secrets/document";
6
+ import {
7
+ botPluginsDocumentV1,
8
+ decodeBotPluginsCommandV1,
9
+ } from "@frockbot/app/plugins/page";
10
+ import { decodePanelFocusCommandV1 } from "@frockbot/app/plugins/panels";
11
+ import { decodeDeviceUseCommandV1 } from "@frockbot/app/audit";
12
+ import { decodePluginPageReportCommandV1 } from "@frockbot/app/plugins/page-reports";
13
+ import { accessEmailV1 } from "@frockbot/app/admin/shared";
14
+ import {
15
+ ACCOUNT_DELETION_PATH_V1,
16
+ COMPUTER_DELETION_PATH_V1,
17
+ accountDeletionConfirmationV1,
18
+ accountDeletionConfirmedV1,
19
+ decodeAccountDeletionCommandV1,
20
+ decodeComputerDeletionCommandV1,
21
+ } from "@frockbot/app/account/deletion";
22
+ import {
23
+ admissionRefusedResponse,
24
+ admissionUnavailableResponse,
25
+ } from "./account-admission.js";
26
+ import { isNativeAuthPath, readNativeJsonBody } from "./native-auth.js";
27
+ import { clientCompatibilityResponse } from "./client-compatibility.js";
28
+ import {
29
+ ConfigurationConflictError,
30
+ ConfigurationDecodeError,
31
+ decodeBotIdV1,
32
+ decodeBotSettingsViewV1,
33
+ decodeConfigurationCommandV1,
34
+ decodeConfigurationQueryV1,
35
+ decodeOperationReceiptV1,
36
+ decodeUserSettingsViewV1,
37
+ isApplicationDeploymentHash,
38
+ isPublicIdentifier,
39
+ } from "@frockbot/core/configuration";
40
+ import { DEPLOYMENT_HEADER_V1 } from "@frockbot/core/protocol";
41
+ import {
42
+ DEVELOPMENT_USER_ID,
43
+ isDeploymentAdminV1,
44
+ } from "./admin-identities.js";
45
+ import type {
46
+ GatewayDependencies,
47
+ UserApplicationIdentity,
48
+ WorkerCode,
49
+ } from "./contracts.js";
50
+ import {
51
+ VOICE_ASSISTANT_PATH_V1,
52
+ VOICE_CAPABILITIES_PATH_V1,
53
+ VOICE_DICTATION_PATH_V1,
54
+ type VoiceCapabilitiesV1,
55
+ } from "@frockbot/app/voice/shared";
56
+ import {
57
+ whatsNewFeedV1,
58
+ whatsNewImageNameV1,
59
+ whatsNewPublishedAtV1,
60
+ } from "@frockbot/app/whats-new";
61
+ import {
62
+ voiceAssistantEdgeTimingV1,
63
+ type VoiceTimingV1,
64
+ } from "@frockbot/app/voice/diagnostics";
65
+ import { createDebugRoute } from "./debug.js";
66
+ import { isPluginPagePathV1, servePluginPageV1 } from "./plugin-page-route.js";
67
+ import {
68
+ drainedAnswerV1,
69
+ forwardingBodyV1,
70
+ TURN_TOO_LONG_MESSAGE_V1,
71
+ turnBodyIsOversizedV1,
72
+ } from "./request-body.js";
73
+
74
+ const PUBLIC_APPLICATION_USER_ID = "anonymous";
75
+ /**
76
+ * What an unauthenticated GET may reach: the document, the site icon, and
77
+ * What’s New stills. The stills are product copy, not a secret, and Flutter
78
+ * loads them with a plain GET that carries no cookie on the web.
79
+ *
80
+ * The client's own payload is not here. It is the Worker's static assets,
81
+ * content-addressed under `/_flutter/<buildHash>/` and answered by the asset
82
+ * router before this Worker runs, so nothing about it is per-account and no
83
+ * request for it arrives at this function.
84
+ */
85
+ export function isPublicAssetPathV1(pathname: string): boolean {
86
+ return (
87
+ pathname === "/" ||
88
+ pathname === "/favicon.ico" ||
89
+ whatsNewImageNameV1(pathname) !== undefined
90
+ );
91
+ }
92
+
93
+ export function applicationDeploymentId(
94
+ identity: UserApplicationIdentity,
95
+ ): string {
96
+ if (!isPublicIdentifier(identity.userId)) {
97
+ throw new Error("invalid user id");
98
+ }
99
+ if (!isApplicationDeploymentHash(identity.applicationHash)) {
100
+ throw new Error("invalid application hash");
101
+ }
102
+ return `${identity.userId}:${identity.applicationHash}`;
103
+ }
104
+
105
+ function jsonError(status: number, message: string): Response {
106
+ return Response.json({ error: message }, { status });
107
+ }
108
+
109
+ function decodeBotPathSegment(value: string): string {
110
+ let botId: string;
111
+ try {
112
+ botId = decodeURIComponent(value);
113
+ } catch {
114
+ throw new ConfigurationDecodeError("invalid bot id");
115
+ }
116
+ try {
117
+ return decodeBotIdV1(botId);
118
+ } catch {
119
+ throw new ConfigurationDecodeError("invalid bot id");
120
+ }
121
+ }
122
+
123
+ interface DevelopmentIdentity {
124
+ userId?: string;
125
+ persist: boolean;
126
+ }
127
+
128
+ function developmentIdentity(request: Request): DevelopmentIdentity {
129
+ const header = request.headers.get("x-frockbot-user-id")?.trim();
130
+ if (header) return { userId: header, persist: false };
131
+
132
+ try {
133
+ const query = new URL(request.url).searchParams.get("as_user")?.trim();
134
+ if (query) return { userId: query, persist: true };
135
+ } catch {
136
+ return { persist: false };
137
+ }
138
+
139
+ const cookie = request.headers
140
+ .get("cookie")
141
+ ?.split(";")
142
+ .map((part) => part.trim())
143
+ .find((part) => part.startsWith("frockbot_dev_user="));
144
+ return {
145
+ userId: cookie?.slice("frockbot_dev_user=".length),
146
+ persist: false,
147
+ };
148
+ }
149
+
150
+ function allowedClientOrigin(
151
+ request: Request,
152
+ requestOrigin: string,
153
+ allowedOrigins: string[] | undefined,
154
+ ): string | null {
155
+ const origin = request.headers.get("origin");
156
+ if (
157
+ !origin ||
158
+ (origin !== requestOrigin && !allowedOrigins?.includes(origin))
159
+ ) {
160
+ return null;
161
+ }
162
+ return origin;
163
+ }
164
+
165
+ function preflightResponse(origin: string): Response {
166
+ return new Response(null, {
167
+ status: 204,
168
+ headers: {
169
+ "access-control-allow-origin": origin,
170
+ "access-control-allow-methods": "GET, POST, OPTIONS",
171
+ "access-control-allow-headers": "authorization, content-type",
172
+ "access-control-max-age": "600",
173
+ vary: "origin",
174
+ },
175
+ });
176
+ }
177
+
178
+ function withClientOrigin(response: Response, origin: string): Response {
179
+ const shared = new Response(response.body, response);
180
+ shared.headers.set("access-control-allow-origin", origin);
181
+ // The Android shell runs the same client from a `capacitor://` origin, so
182
+ // the header that names the application has to be exposed or the WebView
183
+ // cannot read it and the app would never notice a release.
184
+ shared.headers.set(
185
+ "access-control-expose-headers",
186
+ `set-auth-token, ${DEPLOYMENT_HEADER_V1}`,
187
+ );
188
+ shared.headers.append("vary", "origin");
189
+ return shared;
190
+ }
191
+
192
+ /**
193
+ * The one door into a User's loaded application.
194
+ *
195
+ * Both a signed-in browser request and the owner-only debug send use this
196
+ * helper. Keeping the latter on this path means its command is decoded by the
197
+ * same application artifact and admitted by the same User/Bot Durable Object
198
+ * bindings as a message from the composer.
199
+ */
200
+ async function routeUserApplication(
201
+ dependencies: GatewayDependencies,
202
+ compatibilityDate: string,
203
+ request: Request,
204
+ userId: string,
205
+ authMode: string,
206
+ isAdmin: boolean,
207
+ persistDevelopmentIdentity: boolean,
208
+ ): Promise<Response> {
209
+ let applicationHash: string;
210
+ let workerId: string;
211
+ try {
212
+ applicationHash = await dependencies.applicationHashFor(userId);
213
+ workerId = applicationDeploymentId({ userId, applicationHash });
214
+ } catch (error) {
215
+ return jsonError(
216
+ 400,
217
+ error instanceof Error ? error.message : "invalid deployment",
218
+ );
219
+ }
220
+
221
+ const identity = { userId, applicationHash };
222
+ const worker = dependencies.loader.get(workerId, async () => {
223
+ const source = await dependencies.artifacts.load(applicationHash);
224
+ const code: WorkerCode = {
225
+ compatibilityDate,
226
+ mainModule: "index.js",
227
+ modules: { "index.js": { js: source } },
228
+ env: {
229
+ BOT_STATE: dependencies.botStateFor(userId),
230
+ DEPLOYMENT: identity,
231
+ },
232
+ limits: { cpuMs: 30_000, subRequests: 1_000 },
233
+ };
234
+ return code;
235
+ });
236
+
237
+ const forwardedHeaders = new Headers(request.headers);
238
+ forwardedHeaders.delete("x-frockbot-user-id");
239
+ forwardedHeaders.set("x-frockbot-deployment", workerId);
240
+ forwardedHeaders.set("x-frockbot-auth-session-v1", authMode);
241
+ forwardedHeaders.set("x-frockbot-is-admin-v1", String(isAdmin));
242
+ const forwardedUrl = URL.parse(request.url);
243
+ if (!forwardedUrl) return jsonError(400, "invalid request URL");
244
+ if (persistDevelopmentIdentity) {
245
+ forwardedUrl.searchParams.delete("as_user");
246
+ }
247
+ const forwardedRequest = new Request(request, {
248
+ headers: forwardedHeaders,
249
+ });
250
+ // The loaded application is a separate isolate and this hands it the body.
251
+ // Recorded before the await: once it has answered, the gateway's own
252
+ // `request.body` is a stream the isolate will refuse to be touched.
253
+ const response = await worker
254
+ .getEntrypoint()
255
+ .fetch(
256
+ forwardingBodyV1(
257
+ request,
258
+ persistDevelopmentIdentity
259
+ ? new Request(forwardedUrl, forwardedRequest)
260
+ : forwardedRequest,
261
+ ),
262
+ );
263
+ const named = deploymentAnsweredV1(response, applicationHash);
264
+ if (!persistDevelopmentIdentity) return named;
265
+ const persisted = new Response(named.body, named);
266
+ persisted.headers.append(
267
+ "set-cookie",
268
+ `frockbot_dev_user=${userId}; Path=/; HttpOnly; SameSite=Strict`,
269
+ );
270
+ return persisted;
271
+ }
272
+
273
+ /**
274
+ * Names the application an answer came from.
275
+ *
276
+ * A tab left open across a release keeps running the client bundle it was
277
+ * served, and that bundle is baked into the application artifact — so the
278
+ * hash of the artifact that answered is exactly "the client you should be
279
+ * running". It is already resolved on this path, so saying it costs nothing.
280
+ *
281
+ * A WebSocket upgrade is handed back untouched: it carries no headers worth
282
+ * rewriting, and copying one throws.
283
+ */
284
+ export function deploymentAnsweredV1(
285
+ response: Response,
286
+ applicationHash: string,
287
+ ): Response {
288
+ if (response.status === 101 || response.webSocket) return response;
289
+ const named = new Response(response.body, response);
290
+ named.headers.set(DEPLOYMENT_HEADER_V1, applicationHash);
291
+ return named;
292
+ }
293
+
294
+ const NO_STORE = { "cache-control": "no-store" } as const;
295
+
296
+ function isAccountDeleted(error: unknown): boolean {
297
+ return (
298
+ typeof error === "object" &&
299
+ error !== null &&
300
+ "name" in error &&
301
+ error.name === "AccountDeletedError"
302
+ );
303
+ }
304
+
305
+ /**
306
+ * Deleting the account, and deleting the Computer.
307
+ *
308
+ * The account is deleted only against the phrase the person typed, checked
309
+ * here against the identity this request authenticated as — never one the
310
+ * body names. It answers 202 once the deletion is durably under way; the
311
+ * rest happens without them. An account whose deletion already began, or
312
+ * ended, answers as deleted, so a retried press is not an error.
313
+ */
314
+ async function deletionRoute(
315
+ dependencies: GatewayDependencies,
316
+ request: Request,
317
+ url: URL,
318
+ identity: { userId: string; email: string | undefined },
319
+ ): Promise<Response> {
320
+ const deletion = dependencies.deletion;
321
+ if (!deletion) return jsonError(503, "Deleting is unavailable");
322
+ const confirmation = accountDeletionConfirmationV1({
323
+ userId: identity.userId,
324
+ ...(identity.email ? { email: identity.email } : {}),
325
+ });
326
+ if (url.pathname === ACCOUNT_DELETION_PATH_V1 && request.method === "GET") {
327
+ return Response.json(
328
+ { schemaVersion: 1, confirmation },
329
+ { headers: NO_STORE },
330
+ );
331
+ }
332
+ if (request.method !== "POST") return jsonError(405, "method not allowed");
333
+ let body: unknown;
334
+ try {
335
+ body = await readNativeJsonBody(request);
336
+ } catch {
337
+ return jsonError(400, "invalid request");
338
+ }
339
+ try {
340
+ if (url.pathname === COMPUTER_DELETION_PATH_V1) {
341
+ let command;
342
+ try {
343
+ command = decodeComputerDeletionCommandV1(body);
344
+ } catch {
345
+ return jsonError(400, "invalid request");
346
+ }
347
+ return Response.json(
348
+ await deletion.deleteComputer(identity.userId, command.commandId),
349
+ { headers: NO_STORE },
350
+ );
351
+ }
352
+ let command;
353
+ try {
354
+ command = decodeAccountDeletionCommandV1(body);
355
+ } catch {
356
+ return jsonError(400, "invalid request");
357
+ }
358
+ if (!accountDeletionConfirmedV1(confirmation, command.confirmation)) {
359
+ return Response.json(
360
+ {
361
+ error: `Type ${confirmation} to confirm.`,
362
+ code: "confirmation-mismatch",
363
+ },
364
+ { status: 409, headers: NO_STORE },
365
+ );
366
+ }
367
+ const email = accessEmailV1(identity.email);
368
+ return Response.json(
369
+ await deletion.deleteAccount(identity.userId, {
370
+ commandId: command.commandId,
371
+ ...(email === undefined ? {} : { email }),
372
+ }),
373
+ { status: 202, headers: NO_STORE },
374
+ );
375
+ } catch (error) {
376
+ if (isAccountDeleted(error)) {
377
+ return url.pathname === ACCOUNT_DELETION_PATH_V1
378
+ ? Response.json(
379
+ { schemaVersion: 1, status: "deleting" },
380
+ { status: 202, headers: NO_STORE },
381
+ )
382
+ : jsonError(410, "This account is being deleted.");
383
+ }
384
+ return jsonError(
385
+ 502,
386
+ error instanceof Error && error.message
387
+ ? error.message
388
+ : "Deleting failed. Try again.",
389
+ );
390
+ }
391
+ }
392
+
393
+ /**
394
+ * Who a request is, and whether they may come in: a native bearer, the
395
+ * development identity, or the browser's session, then the admission
396
+ * authority. A refusal is the answer to send instead.
397
+ */
398
+ async function identifyRequest(
399
+ dependencies: GatewayDependencies,
400
+ request: Request,
401
+ url: URL,
402
+ ) {
403
+ let development = dependencies.allowDevelopmentIdentity
404
+ ? developmentIdentity(request)
405
+ : { persist: false };
406
+ const nativeIdentity = await dependencies.nativeAuth?.authenticate(request);
407
+ const nativeRefusal = nativeIdentity?.refusal;
408
+ if (nativeRefusal) return { refusal: nativeRefusal };
409
+ // The app signed in through the development door: the same identity the
410
+ // browser's "Continue as local developer" carries, with the same standing.
411
+ if (
412
+ dependencies.allowDevelopmentIdentity &&
413
+ nativeIdentity?.session?.user.id === DEVELOPMENT_USER_ID
414
+ ) {
415
+ development = { userId: DEVELOPMENT_USER_ID, persist: false };
416
+ }
417
+ const session = nativeIdentity
418
+ ? nativeIdentity.session
419
+ : development.userId
420
+ ? null
421
+ : await dependencies.auth.getSession(request.headers);
422
+ let userId = development.userId ?? session?.user.id;
423
+ const authMode: "development" | "better-auth" | "anonymous" =
424
+ development.userId ? "development" : session ? "better-auth" : "anonymous";
425
+ const isPublicAsset =
426
+ request.method === "GET" && isPublicAssetPathV1(url.pathname);
427
+ if (!userId && isPublicAsset) userId = PUBLIC_APPLICATION_USER_ID;
428
+ if (!userId) return { refusal: jsonError(401, "authentication required") };
429
+ const isAdmin =
430
+ userId !== PUBLIC_APPLICATION_USER_ID &&
431
+ isDeploymentAdminV1(
432
+ {
433
+ id: userId,
434
+ ...(session?.user.email ? { email: session.user.email } : {}),
435
+ mode: development.userId ? "development" : "better-auth",
436
+ },
437
+ dependencies.adminEmails,
438
+ );
439
+ // Every authenticated request asks, so pausing an account takes effect on
440
+ // its next request rather than at its next sign-in. An admin is never
441
+ // asked: the allowlist admits them even while the authority is down.
442
+ if (
443
+ userId !== PUBLIC_APPLICATION_USER_ID &&
444
+ !development.userId &&
445
+ !isAdmin
446
+ ) {
447
+ // A native bearer was already admitted by `nativeAuth`, which had to
448
+ // ask after verifying its existing session without provisioning a User.
449
+ let admission = nativeIdentity?.admission;
450
+ if (!admission) {
451
+ const email = accessEmailV1(session?.user.email);
452
+ try {
453
+ admission = await dependencies.admitAccount({
454
+ schemaVersion: 1,
455
+ userId,
456
+ ...(email === undefined ? {} : { email }),
457
+ emailVerified: session?.user.emailVerified === true,
458
+ isAdmin,
459
+ });
460
+ } catch {
461
+ return { refusal: admissionUnavailableResponse() };
462
+ }
463
+ }
464
+ if (!admission.admitted) {
465
+ return {
466
+ refusal: admissionRefusedResponse(
467
+ admission.reason,
468
+ request.method === "GET" && url.pathname === "/",
469
+ ),
470
+ };
471
+ }
472
+ }
473
+ return { userId, session, development, authMode, isAdmin };
474
+ }
475
+
476
+ export function createGateway(
477
+ dependencies: GatewayDependencies,
478
+ entryTiming?: VoiceTimingV1,
479
+ ) {
480
+ const compatibilityDate = dependencies.compatibilityDate ?? "2026-08-27";
481
+ const debugRoute = createDebugRoute(
482
+ dependencies.debug,
483
+ async (userId, botId, text) => {
484
+ const url = new URL(
485
+ `/api/bots/${encodeURIComponent(botId)}/turns`,
486
+ "https://frockbot.internal",
487
+ );
488
+ const request = new Request(url, {
489
+ method: "POST",
490
+ headers: { "content-type": "application/json" },
491
+ body: JSON.stringify({
492
+ schemaVersion: 1,
493
+ commandId: crypto.randomUUID(),
494
+ text,
495
+ }),
496
+ });
497
+ return routeUserApplication(
498
+ dependencies,
499
+ compatibilityDate,
500
+ request,
501
+ userId,
502
+ "better-auth",
503
+ true,
504
+ false,
505
+ );
506
+ },
507
+ );
508
+
509
+ const route = async (request: Request, url: URL): Promise<Response> => {
510
+ const incompatible = clientCompatibilityResponse(request, url);
511
+ if (incompatible) return incompatible;
512
+ const nativeResponse = await dependencies.nativeAuth?.route(request);
513
+ if (nativeResponse) return nativeResponse;
514
+ // A disabled or misconfigured native door never falls through to Better
515
+ // Auth or the authenticated application. Browser/Capacitor routes keep
516
+ // their existing path, including unrelated well-known endpoints.
517
+ if (isNativeAuthPath(url.pathname)) {
518
+ return Response.json(
519
+ { error: "Native sign-in is unavailable. Please try again later." },
520
+ {
521
+ status: 503,
522
+ headers: { "cache-control": "no-store" },
523
+ },
524
+ );
525
+ }
526
+ if (url.pathname.startsWith("/api/auth/")) {
527
+ return dependencies.auth.handler(request);
528
+ }
529
+ if (url.pathname === "/sign-out") {
530
+ if (request.method !== "GET") return jsonError(405, "method not allowed");
531
+ return dependencies.auth.signOut(request, url);
532
+ }
533
+
534
+ // Ahead of authentication: the operator surface is authorized by its own
535
+ // token, and is readable when no session can be established at all.
536
+ const debugResponse = await debugRoute(request, url);
537
+ if (debugResponse) return debugResponse;
538
+
539
+ // Resolved once, when first asked: a public route that must act only for
540
+ // the browser's own session asks, and everything after the public routes
541
+ // needs it.
542
+ let identified: ReturnType<typeof identifyRequest> | undefined;
543
+ const identify = () =>
544
+ (identified ??= identifyRequest(dependencies, request, url));
545
+
546
+ for (const contribution of dependencies.backendContributions ?? []) {
547
+ const response = await contribution.publicRoute?.(request, url, {
548
+ client:
549
+ request.headers.get("x-frockbot-client") === "desktop"
550
+ ? "desktop"
551
+ : "browser",
552
+ sessionUserId: async () => {
553
+ const identity = await identify();
554
+ return identity.refusal ||
555
+ identity.userId === PUBLIC_APPLICATION_USER_ID
556
+ ? undefined
557
+ : identity.userId;
558
+ },
559
+ });
560
+ if (response) return response;
561
+ }
562
+
563
+ // Opt-in voice diagnostics, and only for the assistant upgrade: the
564
+ // object's own first line is written after everything here has already
565
+ // happened, so authentication is invisible from inside it.
566
+ const timing = entryTiming ?? voiceAssistantEdgeTimingV1(url);
567
+ timing?.mark("edge-auth-start");
568
+ const identity = await identify();
569
+ if (identity.refusal) return identity.refusal;
570
+ const { userId, session, development, authMode, isAdmin } = identity;
571
+ // Authenticated and admitted: everything above is what a socket waits
572
+ // through before a route is even chosen.
573
+ timing?.mark("edge-auth-ready");
574
+ if (request.method === "GET" && url.pathname === "/api/identity") {
575
+ return Response.json({ schemaVersion: 1, userId, isAdmin });
576
+ }
577
+ if (url.pathname === "/api/whats-new") {
578
+ if (request.method !== "GET") return jsonError(405, "method not allowed");
579
+ // FrockBot's release notes are FrockBot's: a brand that turns them off
580
+ // serves an empty feed, which every client already shows as nothing.
581
+ return Response.json(
582
+ dependencies.whatsNew
583
+ ? whatsNewFeedV1(whatsNewPublishedAtV1)
584
+ : { schemaVersion: 1, entries: [] },
585
+ { headers: { "cache-control": "no-store" } },
586
+ );
587
+ }
588
+
589
+ if (url.pathname === "/api/push/device") {
590
+ if (request.method !== "POST")
591
+ return jsonError(405, "method not allowed");
592
+ if (!dependencies.registerPush)
593
+ return jsonError(503, "Push is unavailable");
594
+ try {
595
+ const registration = await readNativeJsonBody(request);
596
+ return Response.json(
597
+ await dependencies.registerPush(userId, registration),
598
+ );
599
+ } catch {
600
+ return jsonError(400, "Push registration failed");
601
+ }
602
+ }
603
+
604
+ if (
605
+ url.pathname === ACCOUNT_DELETION_PATH_V1 ||
606
+ url.pathname === COMPUTER_DELETION_PATH_V1
607
+ ) {
608
+ if (userId === PUBLIC_APPLICATION_USER_ID)
609
+ return jsonError(401, "authentication required");
610
+ return deletionRoute(dependencies, request, url, {
611
+ userId,
612
+ email: session?.user.email,
613
+ });
614
+ }
615
+
616
+ if (url.pathname === VOICE_CAPABILITIES_PATH_V1) {
617
+ if (request.method !== "GET") return jsonError(405, "method not allowed");
618
+ const capabilities = dependencies.voice?.capabilities() ?? {
619
+ dictation: false,
620
+ assistant: false,
621
+ };
622
+ return Response.json(
623
+ { schemaVersion: 1, ...capabilities } satisfies VoiceCapabilitiesV1,
624
+ { headers: { "cache-control": "no-store" } },
625
+ );
626
+ }
627
+ if (
628
+ url.pathname === VOICE_DICTATION_PATH_V1 ||
629
+ url.pathname === VOICE_ASSISTANT_PATH_V1
630
+ ) {
631
+ if (request.method !== "GET") return jsonError(405, "method not allowed");
632
+ if (request.headers.get("upgrade")?.toLowerCase() !== "websocket") {
633
+ return jsonError(426, "expected a WebSocket upgrade");
634
+ }
635
+ if (userId === PUBLIC_APPLICATION_USER_ID) {
636
+ return jsonError(401, "authentication required");
637
+ }
638
+ if (!dependencies.voice) return jsonError(503, "Voice is unavailable");
639
+ try {
640
+ if (url.pathname === VOICE_DICTATION_PATH_V1) {
641
+ return await dependencies.voice.openDictation(userId, request);
642
+ }
643
+ const deviceKey = (url.searchParams.get("device") ?? "").slice(0, 64);
644
+ timing?.mark("edge-assistant-forward");
645
+ const upgraded = await dependencies.voice.openAssistant(
646
+ userId,
647
+ /^[A-Za-z0-9._-]{1,64}$/.test(deviceKey) ? deviceKey : "unknown",
648
+ request,
649
+ { isAdmin, authMode },
650
+ );
651
+ timing?.mark("edge-assistant-upgraded", { status: upgraded.status });
652
+ return upgraded;
653
+ } catch (error) {
654
+ return jsonError(
655
+ 500,
656
+ error instanceof Error ? error.message : "Voice failed to open",
657
+ );
658
+ }
659
+ }
660
+
661
+ const stateChannelMatch = url.pathname.match(
662
+ /^\/api\/bots\/([^/]+)\/state-channel$/,
663
+ );
664
+ if (stateChannelMatch) {
665
+ if (request.method !== "GET") {
666
+ return jsonError(405, "method not allowed");
667
+ }
668
+ const encodedBotId = stateChannelMatch[1];
669
+ if (!encodedBotId) return jsonError(400, "invalid Bot id");
670
+ let botId: string;
671
+ try {
672
+ botId = decodeBotPathSegment(encodedBotId);
673
+ } catch (error) {
674
+ return jsonError(
675
+ 400,
676
+ error instanceof Error ? error.message : "invalid Bot id",
677
+ );
678
+ }
679
+ if (!dependencies.openBotStateChannel) {
680
+ return jsonError(503, "Bot-state channel is unavailable");
681
+ }
682
+ try {
683
+ return await dependencies.openBotStateChannel(userId, botId, request, {
684
+ isAdmin,
685
+ authMode,
686
+ });
687
+ } catch (error) {
688
+ if (
689
+ typeof error === "object" &&
690
+ error !== null &&
691
+ "name" in error &&
692
+ (error.name === "BotNotFoundError" ||
693
+ error.name === "ComputerBotNotFoundError")
694
+ ) {
695
+ return jsonError(404, "Bot not found");
696
+ }
697
+ return jsonError(
698
+ 500,
699
+ error instanceof Error ? error.message : "Bot-state channel failed",
700
+ );
701
+ }
702
+ }
703
+
704
+ for (const contribution of dependencies.backendContributions ?? []) {
705
+ const response = await contribution.route(request, url, {
706
+ userId,
707
+ isAdmin,
708
+ client:
709
+ request.headers.get("x-frockbot-client") === "desktop"
710
+ ? "desktop"
711
+ : "browser",
712
+ });
713
+ if (response) return response;
714
+ }
715
+
716
+ if (url.pathname === "/api/settings/models/options") {
717
+ if (request.method !== "POST")
718
+ return jsonError(405, "method not allowed");
719
+ let query;
720
+ try {
721
+ query = decodeProtocol(
722
+ "SettingsOptionsQuery",
723
+ await readNativeJsonBody(request),
724
+ );
725
+ } catch {
726
+ return jsonError(400, "Invalid model search");
727
+ }
728
+ try {
729
+ return Response.json(
730
+ decodeProtocol(
731
+ "SettingsOptionsPage",
732
+ await dependencies
733
+ .userConfigurationFor(userId)
734
+ .readSettingsOptions({ schemaVersion: 1, userId, query }),
735
+ ),
736
+ { headers: { "cache-control": "no-store" } },
737
+ );
738
+ } catch (error) {
739
+ if (
740
+ error instanceof Error &&
741
+ error.name === "ConfigurationConflictError"
742
+ )
743
+ return jsonError(409, "Models changed. Refresh and try again.");
744
+ return jsonError(503, "Models are temporarily unavailable.");
745
+ }
746
+ }
747
+
748
+ if (url.pathname === "/api/settings/connections") {
749
+ if (request.method !== "GET") return jsonError(405, "method not allowed");
750
+ let catalog;
751
+ try {
752
+ catalog = connectionsCatalogQueryV1(url.searchParams);
753
+ } catch {
754
+ return jsonError(400, "Invalid Marketplace search");
755
+ }
756
+ try {
757
+ const frame = decodeProtocol(
758
+ "ConnectionsFrame",
759
+ await dependencies.userConfigurationFor(userId).readConnectionsFrame({
760
+ schemaVersion: 1,
761
+ userId,
762
+ ...(catalog ? { catalog } : {}),
763
+ }),
764
+ );
765
+ return Response.json(frame, {
766
+ headers: { "cache-control": "no-store" },
767
+ });
768
+ } catch {
769
+ return jsonError(503, "Connections are temporarily unavailable.");
770
+ }
771
+ }
772
+
773
+ // The User's saved secrets, for Settings: what each is called and where
774
+ // it may be used, and a delete. Nothing here reads or answers a value.
775
+ const deleteSecretMatch = url.pathname.match(
776
+ /^\/api\/secrets\/(secret-[0-9a-f]{32})\/delete$/,
777
+ );
778
+ if (url.pathname === "/api/secrets" || deleteSecretMatch) {
779
+ const owner = dependencies.userConfigurationFor(userId);
780
+ try {
781
+ if (deleteSecretMatch) {
782
+ if (request.method !== "POST") {
783
+ return jsonError(405, "method not allowed");
784
+ }
785
+ return Response.json(
786
+ await owner.deleteSecret({
787
+ schemaVersion: 1,
788
+ userId,
789
+ secretId: deleteSecretMatch[1]!,
790
+ }),
791
+ { headers: { "cache-control": "no-store" } },
792
+ );
793
+ }
794
+ if (request.method !== "GET") {
795
+ return jsonError(405, "method not allowed");
796
+ }
797
+ const list = await owner.listSecrets({ schemaVersion: 1, userId });
798
+ return Response.json(
799
+ url.searchParams.get("as") === "document"
800
+ ? secretsDocumentV1(list)
801
+ : list,
802
+ { headers: { "cache-control": "no-store" } },
803
+ );
804
+ } catch {
805
+ return jsonError(503, "Saved secrets are temporarily unavailable.");
806
+ }
807
+ }
808
+
809
+ if (
810
+ ["/api/settings/application", "/api/settings/models"].includes(
811
+ url.pathname,
812
+ )
813
+ ) {
814
+ const home = url.pathname.endsWith("/models") ? "models" : "application";
815
+ try {
816
+ const owner = dependencies.userConfigurationFor(userId);
817
+ if (request.method === "GET") {
818
+ // Identity supplies only an unsaved profile suggestion. The User's
819
+ // saved profile wins and only a save command persists edited fields.
820
+ const identity =
821
+ home !== "application"
822
+ ? null
823
+ : development.userId
824
+ ? { name: "Local developer" }
825
+ : await dependencies.auth.profile?.(userId).catch(() => null);
826
+ const frame = decodeProtocol(
827
+ "SettingsFrame",
828
+ await owner.readSettingsFrame({
829
+ schemaVersion: 1,
830
+ userId,
831
+ home,
832
+ ...(identity?.name?.trim()
833
+ ? { identityName: identity.name.trim().slice(0, 100) }
834
+ : {}),
835
+ ...(identity?.email?.trim()
836
+ ? { identityEmail: identity.email.trim().slice(0, 320) }
837
+ : {}),
838
+ ...(identity &&
839
+ "image" in identity &&
840
+ typeof identity.image === "string" &&
841
+ identity.image.startsWith("https://")
842
+ ? { identityImage: identity.image.slice(0, 2048) }
843
+ : {}),
844
+ }),
845
+ );
846
+ // The frame is what this route produces; `as=document` asks for the
847
+ // same settings in the vocabulary the host renders every plugin
848
+ // view in. Nothing else about the route changes, so the client that
849
+ // wants a frame keeps getting one.
850
+ return Response.json(
851
+ url.searchParams.get("as") === "document"
852
+ ? settingsDocumentV1(
853
+ home === "application"
854
+ ? {
855
+ ...frame,
856
+ sections: frame.sections.filter(
857
+ (section) =>
858
+ section.id ===
859
+ (url.searchParams.get("section") ?? "profile"),
860
+ ),
861
+ }
862
+ : frame,
863
+ )
864
+ : frame,
865
+ { headers: { "cache-control": "no-store" } },
866
+ );
867
+ }
868
+ if (request.method !== "POST")
869
+ return jsonError(405, "method not allowed");
870
+ let command;
871
+ try {
872
+ command = decodeProtocol(
873
+ "SettingsChangeCommand",
874
+ await readNativeJsonBody(request, 512_000),
875
+ );
876
+ } catch {
877
+ return jsonError(400, "Invalid settings command");
878
+ }
879
+ if (command.ownerId !== userId)
880
+ return jsonError(403, "Settings belong to another account.");
881
+ return Response.json(
882
+ decodeOperationReceiptV1(
883
+ await owner.changeSettings({
884
+ schemaVersion: 1,
885
+ userId,
886
+ home,
887
+ command,
888
+ }),
889
+ ),
890
+ );
891
+ } catch (error) {
892
+ if (
893
+ error instanceof Error &&
894
+ error.name === "ConfigurationConflictError"
895
+ )
896
+ return jsonError(409, "Settings changed. Refresh and try again.");
897
+ if (error instanceof Error && error.name === "ConfigurationDecodeError")
898
+ return jsonError(400, "Check these settings and try again.");
899
+ return jsonError(503, "Settings are temporarily unavailable.");
900
+ }
901
+ }
902
+
903
+ const botPluginsMatch = url.pathname.match(
904
+ /^\/api\/bots\/([^/]+)\/plugins$/,
905
+ );
906
+ if (botPluginsMatch) {
907
+ try {
908
+ const botId = decodeBotPathSegment(botPluginsMatch[1]);
909
+ const binding = dependencies.botConfigurationFor(userId, botId);
910
+ if (request.method === "GET") {
911
+ const frame = await binding.readBotPluginsFrame({
912
+ schemaVersion: 1,
913
+ userId,
914
+ botId,
915
+ });
916
+ return Response.json(
917
+ url.searchParams.get("as") === "document"
918
+ ? botPluginsDocumentV1(frame)
919
+ : frame,
920
+ { headers: { "cache-control": "no-store" } },
921
+ );
922
+ }
923
+ if (request.method !== "POST") {
924
+ return jsonError(405, "method not allowed");
925
+ }
926
+ // The command's own decode is the only failure that is the caller's
927
+ // fault; everything past it is ours, and echoing its message would
928
+ // report a storage failure as a bad request.
929
+ let command;
930
+ try {
931
+ command = decodeBotPluginsCommandV1(await request.json());
932
+ } catch (error) {
933
+ return jsonError(
934
+ 400,
935
+ error instanceof Error && !(error instanceof SyntaxError)
936
+ ? error.message
937
+ : "invalid plugin command",
938
+ );
939
+ }
940
+ return Response.json(
941
+ command.kind === "plugin-tool"
942
+ ? await binding.executeBotPluginTool({
943
+ schemaVersion: 1,
944
+ userId,
945
+ botId,
946
+ command,
947
+ })
948
+ : await binding.setBotPluginEnabled({
949
+ schemaVersion: 1,
950
+ userId,
951
+ botId,
952
+ command,
953
+ }),
954
+ { headers: { "cache-control": "no-store" } },
955
+ );
956
+ } catch (error) {
957
+ if (error instanceof ConfigurationDecodeError) {
958
+ return jsonError(400, "invalid bot id");
959
+ }
960
+ return jsonError(503, "Plugins are temporarily unavailable.");
961
+ }
962
+ }
963
+
964
+ const botPanelsOpenMatch = url.pathname.match(
965
+ /^\/api\/bots\/([^/]+)\/panels\/open$/,
966
+ );
967
+ if (botPanelsOpenMatch) {
968
+ try {
969
+ if (request.method !== "GET") {
970
+ return jsonError(405, "method not allowed");
971
+ }
972
+ const botId = decodeBotPathSegment(botPanelsOpenMatch[1]);
973
+ return Response.json(
974
+ await dependencies
975
+ .botConfigurationFor(userId, botId)
976
+ .openFocusedPanel({
977
+ schemaVersion: 1,
978
+ userId,
979
+ botId,
980
+ appOrigin: url.origin,
981
+ }),
982
+ { headers: { "cache-control": "no-store" } },
983
+ );
984
+ } catch (error) {
985
+ if (error instanceof ConfigurationDecodeError) {
986
+ return jsonError(400, "invalid bot id");
987
+ }
988
+ return jsonError(503, "Panels are temporarily unavailable.");
989
+ }
990
+ }
991
+
992
+ const botPanelsFocusMatch = url.pathname.match(
993
+ /^\/api\/bots\/([^/]+)\/panels\/focus$/,
994
+ );
995
+ if (botPanelsFocusMatch) {
996
+ try {
997
+ const botId = decodeBotPathSegment(botPanelsFocusMatch[1]);
998
+ const binding = dependencies.botConfigurationFor(userId, botId);
999
+ if (request.method === "GET") {
1000
+ const opened = await binding.openFocusedPanel({
1001
+ schemaVersion: 1,
1002
+ userId,
1003
+ botId,
1004
+ appOrigin: url.origin,
1005
+ });
1006
+ return Response.json(
1007
+ {
1008
+ schemaVersion: 1,
1009
+ pluginId: opened.focus.pluginId,
1010
+ ...(opened.focus.surfaceId
1011
+ ? { surfaceId: opened.focus.surfaceId }
1012
+ : {}),
1013
+ },
1014
+ { headers: { "cache-control": "no-store" } },
1015
+ );
1016
+ }
1017
+ if (request.method !== "POST") {
1018
+ return jsonError(405, "method not allowed");
1019
+ }
1020
+ let command;
1021
+ try {
1022
+ command = decodePanelFocusCommandV1(await request.json());
1023
+ } catch (error) {
1024
+ return jsonError(
1025
+ 400,
1026
+ error instanceof Error && !(error instanceof SyntaxError)
1027
+ ? error.message
1028
+ : "invalid panel focus",
1029
+ );
1030
+ }
1031
+ const result = await binding.setFocusedPanel({
1032
+ schemaVersion: 1,
1033
+ userId,
1034
+ botId,
1035
+ pluginId: command.pluginId,
1036
+ ...(command.surfaceId ? { surfaceId: command.surfaceId } : {}),
1037
+ });
1038
+ if (result.status === "error") {
1039
+ return jsonError(400, result.failure);
1040
+ }
1041
+ return Response.json(result.focus, {
1042
+ headers: { "cache-control": "no-store" },
1043
+ });
1044
+ } catch (error) {
1045
+ if (error instanceof ConfigurationDecodeError) {
1046
+ return jsonError(400, "invalid bot id");
1047
+ }
1048
+ return jsonError(503, "Panels are temporarily unavailable.");
1049
+ }
1050
+ }
1051
+
1052
+ const botPanelsDeviceUseMatch = url.pathname.match(
1053
+ /^\/api\/bots\/([^/]+)\/panels\/device-use$/,
1054
+ );
1055
+ if (botPanelsDeviceUseMatch) {
1056
+ if (request.method !== "POST") {
1057
+ return jsonError(405, "method not allowed");
1058
+ }
1059
+ try {
1060
+ const botId = decodeBotPathSegment(botPanelsDeviceUseMatch[1]);
1061
+ let use;
1062
+ try {
1063
+ use = decodeDeviceUseCommandV1(await request.json());
1064
+ } catch (error) {
1065
+ return jsonError(
1066
+ 400,
1067
+ error instanceof Error && !(error instanceof SyntaxError)
1068
+ ? error.message
1069
+ : "invalid device use",
1070
+ );
1071
+ }
1072
+ const result = await dependencies
1073
+ .botConfigurationFor(userId, botId)
1074
+ .recordPanelDeviceUse({ schemaVersion: 1, userId, botId, use });
1075
+ if (result.status === "refused") {
1076
+ return jsonError(400, result.reason);
1077
+ }
1078
+ return Response.json(result, {
1079
+ headers: { "cache-control": "no-store" },
1080
+ });
1081
+ } catch (error) {
1082
+ if (error instanceof ConfigurationDecodeError) {
1083
+ return jsonError(400, "invalid bot id");
1084
+ }
1085
+ return jsonError(503, "Panels are temporarily unavailable.");
1086
+ }
1087
+ }
1088
+
1089
+ const botPanelsPageReportMatch = url.pathname.match(
1090
+ /^\/api\/bots\/([^/]+)\/panels\/page-report$/,
1091
+ );
1092
+ if (botPanelsPageReportMatch) {
1093
+ if (request.method !== "POST") {
1094
+ return jsonError(405, "method not allowed");
1095
+ }
1096
+ try {
1097
+ const botId = decodeBotPathSegment(botPanelsPageReportMatch[1]);
1098
+ let report;
1099
+ try {
1100
+ report = decodePluginPageReportCommandV1(await request.json());
1101
+ } catch (error) {
1102
+ return jsonError(
1103
+ 400,
1104
+ error instanceof Error && !(error instanceof SyntaxError)
1105
+ ? error.message
1106
+ : "invalid page report",
1107
+ );
1108
+ }
1109
+ const result = await dependencies
1110
+ .botConfigurationFor(userId, botId)
1111
+ .recordPanelPageReport({ schemaVersion: 1, userId, botId, report });
1112
+ if (result.status === "refused") {
1113
+ return jsonError(400, result.reason);
1114
+ }
1115
+ return Response.json(result, {
1116
+ headers: { "cache-control": "no-store" },
1117
+ });
1118
+ } catch (error) {
1119
+ if (error instanceof ConfigurationDecodeError) {
1120
+ return jsonError(400, "invalid bot id");
1121
+ }
1122
+ return jsonError(503, "Panels are temporarily unavailable.");
1123
+ }
1124
+ }
1125
+
1126
+ const botSettingsMatch = url.pathname.match(
1127
+ /^\/api\/bots\/([^/]+)\/settings$/,
1128
+ );
1129
+ const isUserSettings = url.pathname === "/api/settings";
1130
+ if (isUserSettings || botSettingsMatch) {
1131
+ try {
1132
+ const pathBotId = botSettingsMatch
1133
+ ? decodeBotPathSegment(botSettingsMatch[1])
1134
+ : undefined;
1135
+ if (request.method === "GET") {
1136
+ if (!botSettingsMatch) {
1137
+ if (
1138
+ url.searchParams.has("view") &&
1139
+ url.searchParams.get("view") !== "2"
1140
+ )
1141
+ return jsonError(
1142
+ 426,
1143
+ `Refresh ${BRAND_V1.productName} to update Settings.`,
1144
+ );
1145
+ return Response.json(
1146
+ settingsProjection(
1147
+ await dependencies
1148
+ .userConfigurationFor(userId)
1149
+ .readConfiguration({
1150
+ schemaVersion: 1,
1151
+ userId,
1152
+ view: url.searchParams.get("view") === "2" ? 2 : 1,
1153
+ }),
1154
+ url.searchParams.get("view"),
1155
+ ),
1156
+ );
1157
+ }
1158
+ const query = decodeConfigurationQueryV1({
1159
+ schemaVersion: 1,
1160
+ type: "bot/get",
1161
+ botId: pathBotId,
1162
+ });
1163
+ if (query.type !== "bot/get") {
1164
+ throw new ConfigurationDecodeError(
1165
+ "Bot settings require a Bot query",
1166
+ );
1167
+ }
1168
+ return Response.json(
1169
+ decodeBotSettingsViewV1(
1170
+ await dependencies
1171
+ .botConfigurationFor(userId, query.botId)
1172
+ .readConfiguration({
1173
+ schemaVersion: 1,
1174
+ userId,
1175
+ botId: query.botId,
1176
+ }),
1177
+ ),
1178
+ );
1179
+ }
1180
+ if (request.method !== "POST") {
1181
+ return jsonError(405, "method not allowed");
1182
+ }
1183
+ const command = decodeConfigurationCommandV1(await request.json());
1184
+ if (
1185
+ botSettingsMatch &&
1186
+ "botId" in command &&
1187
+ command.botId !== pathBotId
1188
+ ) {
1189
+ return jsonError(400, "Bot command does not match the request path");
1190
+ }
1191
+ if (botSettingsMatch && !("botId" in command)) {
1192
+ return jsonError(400, "Bot settings require a Bot command");
1193
+ }
1194
+ if (isUserSettings && "botId" in command) {
1195
+ return jsonError(400, "User settings require a User command");
1196
+ }
1197
+ if (
1198
+ command.type === "user/set-package-settings" &&
1199
+ command.packageId === "custom-models" &&
1200
+ (Object.hasOwn(command.values ?? {}, "account-model") ||
1201
+ command.unset?.includes("account-model"))
1202
+ ) {
1203
+ return jsonError(
1204
+ 426,
1205
+ `Refresh ${BRAND_V1.productName} to update Models.`,
1206
+ );
1207
+ }
1208
+ if (command.type === "user/set-platform-model") {
1209
+ return jsonError(
1210
+ 403,
1211
+ "Platform model can only be set by a backend Contribution",
1212
+ );
1213
+ }
1214
+ if ("botId" in command) {
1215
+ return Response.json(
1216
+ decodeOperationReceiptV1(
1217
+ await dependencies
1218
+ .botConfigurationFor(userId, command.botId)
1219
+ .executeConfiguration({
1220
+ schemaVersion: 1,
1221
+ userId,
1222
+ botId: command.botId,
1223
+ command,
1224
+ }),
1225
+ ),
1226
+ );
1227
+ }
1228
+ return Response.json(
1229
+ decodeOperationReceiptV1(
1230
+ await dependencies
1231
+ .userConfigurationFor(userId)
1232
+ .executeConfiguration({
1233
+ schemaVersion: 1,
1234
+ userId,
1235
+ command,
1236
+ }),
1237
+ ),
1238
+ );
1239
+ } catch (error) {
1240
+ if (
1241
+ error instanceof ConfigurationDecodeError ||
1242
+ // The same refusal, raised inside the User Durable Object: only the
1243
+ // authority knows which settings the installed version of a Package
1244
+ // declares, so a value it refuses is still the client's bad request
1245
+ // and not a fault of ours. The class does not survive RPC; the name
1246
+ // does, exactly as `BotNotFoundError` below relies on.
1247
+ (typeof error === "object" &&
1248
+ error !== null &&
1249
+ "name" in error &&
1250
+ error.name === "ConfigurationDecodeError")
1251
+ ) {
1252
+ return jsonError(
1253
+ 400,
1254
+ error instanceof Error ? error.message : "configuration refused",
1255
+ );
1256
+ }
1257
+ if (
1258
+ typeof error === "object" &&
1259
+ error !== null &&
1260
+ "name" in error &&
1261
+ error.name === "BotNotFoundError"
1262
+ ) {
1263
+ return jsonError(
1264
+ 404,
1265
+ error instanceof Error ? error.message : "Bot not found",
1266
+ );
1267
+ }
1268
+ if (
1269
+ error instanceof ConfigurationConflictError ||
1270
+ (typeof error === "object" &&
1271
+ error !== null &&
1272
+ "name" in error &&
1273
+ error.name === "ConfigurationConflictError" &&
1274
+ "currentRevision" in error &&
1275
+ typeof error.currentRevision === "number")
1276
+ ) {
1277
+ const currentRevision =
1278
+ error instanceof ConfigurationConflictError
1279
+ ? error.currentRevision
1280
+ : error.currentRevision;
1281
+ return Response.json(
1282
+ {
1283
+ error: `configuration revision is ${currentRevision}`,
1284
+ code: "revision-conflict",
1285
+ currentRevision,
1286
+ },
1287
+ { status: 409 },
1288
+ );
1289
+ }
1290
+ return jsonError(
1291
+ 500,
1292
+ error instanceof Error ? error.message : "Configuration failed",
1293
+ );
1294
+ }
1295
+ }
1296
+
1297
+ return routeUserApplication(
1298
+ dependencies,
1299
+ compatibilityDate,
1300
+ request,
1301
+ userId,
1302
+ authMode,
1303
+ isAdmin,
1304
+ development.persist,
1305
+ );
1306
+ };
1307
+
1308
+ const handle = async (request: Request): Promise<Response> => {
1309
+ let url: URL;
1310
+ try {
1311
+ url = new URL(request.url);
1312
+ } catch {
1313
+ return jsonError(400, "invalid request URL");
1314
+ }
1315
+
1316
+ // Anonymous and ahead of everything: a Plugin page is fetched by a
1317
+ // credentialless frame, and its own CSP is its whole posture.
1318
+ if (isPluginPagePathV1(url.pathname)) {
1319
+ return servePluginPageV1(
1320
+ request,
1321
+ url,
1322
+ dependencies.artifacts.loadPluginPage?.bind(dependencies.artifacts),
1323
+ );
1324
+ }
1325
+
1326
+ const origin = allowedClientOrigin(
1327
+ request,
1328
+ url.origin,
1329
+ dependencies.allowedClientOrigins,
1330
+ );
1331
+ const isApiPath = url.pathname.startsWith("/api/");
1332
+ const presentedOrigin = request.headers.get("origin");
1333
+ if (
1334
+ isApiPath &&
1335
+ presentedOrigin &&
1336
+ !origin &&
1337
+ (request.method !== "GET" ||
1338
+ request.headers.get("upgrade")?.toLowerCase() === "websocket") &&
1339
+ request.method !== "HEAD"
1340
+ ) {
1341
+ return jsonError(403, "request origin is not allowed");
1342
+ }
1343
+ if (!origin || !isApiPath) return route(request, url);
1344
+ if (request.method === "OPTIONS") return preflightResponse(origin);
1345
+ // The 101 response carries the WebSocket endpoint and cannot be cloned as
1346
+ // an ordinary CORS response. Origin admission above is the browser guard.
1347
+ if (request.headers.get("upgrade")?.toLowerCase() === "websocket") {
1348
+ return route(request, url);
1349
+ }
1350
+ return withClientOrigin(await route(request, url), origin);
1351
+ };
1352
+
1353
+ /*
1354
+ * The gateway's error boundary.
1355
+ *
1356
+ * Every answer this Worker gives is JSON with a readable `error`, including
1357
+ * the ones nobody planned: an unavailable binding, an artifact that will not
1358
+ * load, a Contribution route that rejects. Without it workerd answers
1359
+ * `Internal Server Error` as plain text and the browser reports a JSON parse
1360
+ * failure, which tells the User nothing about what actually broke.
1361
+ */
1362
+ return async (request: Request): Promise<Response> => {
1363
+ let refusedForSize: Response | undefined;
1364
+ try {
1365
+ refusedForSize = turnBodyIsOversizedV1(request, new URL(request.url))
1366
+ ? jsonError(413, TURN_TOO_LONG_MESSAGE_V1)
1367
+ : undefined;
1368
+ } catch {
1369
+ // An unparseable URL is `handle`'s 400 to give, not this guard's.
1370
+ }
1371
+ if (refusedForSize) return drainedAnswerV1(request, refusedForSize);
1372
+ try {
1373
+ return await drainedAnswerV1(request, await handle(request));
1374
+ } catch (error) {
1375
+ return drainedAnswerV1(
1376
+ request,
1377
+ jsonError(
1378
+ 500,
1379
+ error instanceof Error ? error.message : "gateway request failed",
1380
+ ),
1381
+ );
1382
+ }
1383
+ };
1384
+ }
1385
+
1386
+ /** The previous browser wire shape remains readable while Models moves to frames. */
1387
+ function settingsProjection(input: unknown, contract: string | null) {
1388
+ const decoded = decodeUserSettingsViewV1(input);
1389
+ if (contract === "2") return decoded;
1390
+ const { accountModel: _accountModel, ...view } = decoded;
1391
+ return view;
1392
+ }