@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.
- package/build-artifact.ts +130 -0
- package/build-flutter-web.ts +508 -0
- package/deployment-config/README.md +414 -0
- package/deployment-config/cli.ts +228 -0
- package/deployment-config/generate.ts +689 -0
- package/deployment-config/jsonc.ts +18 -0
- package/deployment-config/profile-schema.generated.ts +391 -0
- package/deployment-config/profile.schema.json +358 -0
- package/deployment-config/profile.ts +124 -0
- package/migrations/0001_better_auth.sql +15 -0
- package/migrations/0002_drop_account_issuer.sql +8 -0
- package/package.json +61 -5
- package/release-version.ts +20 -0
- package/src/account-admission.ts +179 -0
- package/src/account-deletion.ts +274 -0
- package/src/admin-entrypoint.ts +194 -0
- package/src/admin-identities.ts +26 -0
- package/src/audit.ts +103 -0
- package/src/auth-package.access.ts +24 -0
- package/src/auth-package.ts +36 -0
- package/src/avatar-state-cleanup.ts +107 -0
- package/src/billing-computer.ts +267 -0
- package/src/billing-readiness.ts +21 -0
- package/src/billing.ts +461 -0
- package/src/bot-capabilities.ts +480 -0
- package/src/bot-recovery.ts +1 -0
- package/src/bot-state-channel.ts +702 -0
- package/src/bot-state.ts +3995 -0
- package/src/bot-template-cleanup.ts +98 -0
- package/src/bot-title-cleanup.ts +104 -0
- package/src/brand-icon.png +0 -0
- package/src/brand-logo.ts +2 -0
- package/src/brand.ts +36 -0
- package/src/client-compatibility.ts +51 -0
- package/src/compaction-announcement-cleanup.ts +113 -0
- package/src/computer-egress.ts +110 -0
- package/src/computer-host.ts +92 -0
- package/src/computer-screenshot-cleanup.ts +78 -0
- package/src/contracts.ts +832 -0
- package/src/debug.ts +272 -0
- package/src/default-packages-marker-cleanup.ts +39 -0
- package/src/deployment-policy-admin-host.ts +121 -0
- package/src/deployment-policy.ts +426 -0
- package/src/directory-profile-cleanup.ts +101 -0
- package/src/durable-rpc.ts +753 -0
- package/src/durable-session.ts +21 -0
- package/src/entry-boundary.ts +103 -0
- package/src/frock-ai.ts +338 -0
- package/src/gateway.ts +1392 -0
- package/src/group-chat.ts +736 -0
- package/src/hidden-bot-notifications-cleanup.ts +32 -0
- package/src/index.ts +2798 -0
- package/src/insights.ts +15 -0
- package/src/machine-messages-cleanup.ts +121 -0
- package/src/machine-socket.ts +164 -0
- package/src/memory-records.ts +181 -0
- package/src/memory.ts +145 -0
- package/src/model-rates.ts +252 -0
- package/src/native-auth.ts +1006 -0
- package/src/native-sessions.ts +121 -0
- package/src/notification-state-cleanup.ts +18 -0
- package/src/ollama-web-search-cleanup.ts +69 -0
- package/src/package-page-shapes-cleanup.ts +190 -0
- package/src/plugin-egress.ts +31 -0
- package/src/plugin-page-route.ts +72 -0
- package/src/plugin-panels-cleanup.ts +101 -0
- package/src/prepared-input-cleanup.ts +105 -0
- package/src/production-secrets.ts +484 -0
- package/src/project-cleanup.ts +123 -0
- package/src/project-events-cleanup.ts +152 -0
- package/src/publication-state-cleanup.ts +92 -0
- package/src/push.ts +498 -0
- package/src/request-body.ts +165 -0
- package/src/routine-state-cleanup.ts +57 -0
- package/src/search.ts +88 -0
- package/src/sidebar-label-cleanup.ts +165 -0
- package/src/skill-index-cleanup.ts +53 -0
- package/src/supersede-cleanup.ts +162 -0
- package/src/test-chat-cleanup.ts +93 -0
- package/src/uploads.ts +486 -0
- package/src/user-application.ts +1068 -0
- package/src/user-configuration.ts +4491 -0
- package/src/voice-assistant.ts +4999 -0
- package/src/voice-dictation.ts +743 -0
- package/src/working-context-cleanup.ts +96 -0
- package/src/workspace.ts +201 -0
- package/wrangler.jsonc +385 -0
- 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
|
+
}
|