@frockbot/cloudflare 0.0.0 → 0.7.292

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (88) hide show
  1. package/build-artifact.ts +130 -0
  2. package/build-flutter-web.ts +508 -0
  3. package/deployment-config/README.md +414 -0
  4. package/deployment-config/cli.ts +228 -0
  5. package/deployment-config/generate.ts +689 -0
  6. package/deployment-config/jsonc.ts +18 -0
  7. package/deployment-config/profile-schema.generated.ts +391 -0
  8. package/deployment-config/profile.schema.json +358 -0
  9. package/deployment-config/profile.ts +124 -0
  10. package/migrations/0001_better_auth.sql +15 -0
  11. package/migrations/0002_drop_account_issuer.sql +8 -0
  12. package/package.json +61 -5
  13. package/release-version.ts +20 -0
  14. package/src/account-admission.ts +179 -0
  15. package/src/account-deletion.ts +274 -0
  16. package/src/admin-entrypoint.ts +194 -0
  17. package/src/admin-identities.ts +26 -0
  18. package/src/audit.ts +103 -0
  19. package/src/auth-package.access.ts +24 -0
  20. package/src/auth-package.ts +36 -0
  21. package/src/avatar-state-cleanup.ts +107 -0
  22. package/src/billing-computer.ts +267 -0
  23. package/src/billing-readiness.ts +21 -0
  24. package/src/billing.ts +461 -0
  25. package/src/bot-capabilities.ts +480 -0
  26. package/src/bot-recovery.ts +1 -0
  27. package/src/bot-state-channel.ts +702 -0
  28. package/src/bot-state.ts +3995 -0
  29. package/src/bot-template-cleanup.ts +98 -0
  30. package/src/bot-title-cleanup.ts +104 -0
  31. package/src/brand-icon.png +0 -0
  32. package/src/brand-logo.ts +2 -0
  33. package/src/brand.ts +36 -0
  34. package/src/client-compatibility.ts +51 -0
  35. package/src/compaction-announcement-cleanup.ts +113 -0
  36. package/src/computer-egress.ts +110 -0
  37. package/src/computer-host.ts +92 -0
  38. package/src/computer-screenshot-cleanup.ts +78 -0
  39. package/src/contracts.ts +832 -0
  40. package/src/debug.ts +272 -0
  41. package/src/default-packages-marker-cleanup.ts +39 -0
  42. package/src/deployment-policy-admin-host.ts +121 -0
  43. package/src/deployment-policy.ts +426 -0
  44. package/src/directory-profile-cleanup.ts +101 -0
  45. package/src/durable-rpc.ts +753 -0
  46. package/src/durable-session.ts +21 -0
  47. package/src/entry-boundary.ts +103 -0
  48. package/src/frock-ai.ts +338 -0
  49. package/src/gateway.ts +1392 -0
  50. package/src/group-chat.ts +736 -0
  51. package/src/hidden-bot-notifications-cleanup.ts +32 -0
  52. package/src/index.ts +2798 -0
  53. package/src/insights.ts +15 -0
  54. package/src/machine-messages-cleanup.ts +121 -0
  55. package/src/machine-socket.ts +164 -0
  56. package/src/memory-records.ts +181 -0
  57. package/src/memory.ts +145 -0
  58. package/src/model-rates.ts +252 -0
  59. package/src/native-auth.ts +1006 -0
  60. package/src/native-sessions.ts +121 -0
  61. package/src/notification-state-cleanup.ts +18 -0
  62. package/src/ollama-web-search-cleanup.ts +69 -0
  63. package/src/package-page-shapes-cleanup.ts +190 -0
  64. package/src/plugin-egress.ts +31 -0
  65. package/src/plugin-page-route.ts +72 -0
  66. package/src/plugin-panels-cleanup.ts +101 -0
  67. package/src/prepared-input-cleanup.ts +105 -0
  68. package/src/production-secrets.ts +484 -0
  69. package/src/project-cleanup.ts +123 -0
  70. package/src/project-events-cleanup.ts +152 -0
  71. package/src/publication-state-cleanup.ts +92 -0
  72. package/src/push.ts +498 -0
  73. package/src/request-body.ts +165 -0
  74. package/src/routine-state-cleanup.ts +57 -0
  75. package/src/search.ts +88 -0
  76. package/src/sidebar-label-cleanup.ts +165 -0
  77. package/src/skill-index-cleanup.ts +53 -0
  78. package/src/supersede-cleanup.ts +162 -0
  79. package/src/test-chat-cleanup.ts +93 -0
  80. package/src/uploads.ts +486 -0
  81. package/src/user-application.ts +1068 -0
  82. package/src/user-configuration.ts +4491 -0
  83. package/src/voice-assistant.ts +4999 -0
  84. package/src/voice-dictation.ts +743 -0
  85. package/src/working-context-cleanup.ts +96 -0
  86. package/src/workspace.ts +201 -0
  87. package/wrangler.jsonc +385 -0
  88. package/README.md +0 -3
package/src/uploads.ts ADDED
@@ -0,0 +1,486 @@
1
+ // The upload route: where a file's bytes go before the message that carries
2
+ // it is sent.
3
+ //
4
+ // POST /api/bots/:botId/uploads?name=<file name> body: the file's bytes
5
+ // GET /api/bots/:botId/uploads/:uploadId the bytes again
6
+ //
7
+ // An upload is admitted durably before it is acknowledged. The route reads
8
+ // the bytes, decides what they are, extracts a document's text, counts them
9
+ // against the account's upload space, writes them to object storage under the
10
+ // Bot's own prefix, and has the Bot Durable Object record that it holds them
11
+ // — and only then answers with the upload's id, which is the SHA-256 of the
12
+ // bytes. A turn command names that id, and the Bot refuses any id it did not
13
+ // record, so a message can only carry a file its Bot was given.
14
+ //
15
+ // Nothing here spends money or reaches a third party except the document
16
+ // converter, and that runs with image description off, so it costs nothing.
17
+ // The steps are idempotent by content hash instead of recorded as intent: a
18
+ // retried upload of the same file counts once, writes the same bytes to the
19
+ // same key and records the same record. A write that fails after the space
20
+ // was counted leaves that one file counted until the Bot is deleted, which is
21
+ // the whole of what a crash can cost.
22
+ import { UPLOAD_MAX_BYTES_V1 } from "@frockbot/core/contracts";
23
+ import { decodeBotIdV1 } from "@frockbot/core/configuration";
24
+ import {
25
+ extractDocumentTextV1,
26
+ type DocumentConverterV1,
27
+ } from "@frockbot/app/uploads/extract";
28
+ import type { UploadQuotaAnswerV1 } from "@frockbot/app/uploads/quota";
29
+ import type { BotFileStoreV1 } from "@frockbot/app/uploads/bot";
30
+ import {
31
+ classifyUploadV1,
32
+ normalizeUploadNameV1,
33
+ uploadBotPrefixV1,
34
+ uploadObjectKeyV1,
35
+ uploadTextKeyV1,
36
+ UPLOAD_QUOTA_FULL_MESSAGE_V1,
37
+ type StoredUploadV1,
38
+ type UploadReceiptV1,
39
+ } from "@frockbot/app/uploads/shared";
40
+ import type { BackendRouteContribution } from "./contracts.js";
41
+
42
+ const UPLOAD_PATH_V1 = /^\/api\/bots\/([^/]+)\/uploads$/;
43
+ const UPLOAD_READ_PATH_V1 = /^\/api\/bots\/([^/]+)\/uploads\/([0-9a-f]{64})$/;
44
+
45
+ /** The object store surface the route writes and reads. */
46
+ export interface UploadObjectStoreV1 {
47
+ put(
48
+ key: string,
49
+ value: Uint8Array | string,
50
+ options?: {
51
+ httpMetadata?: { contentType?: string };
52
+ customMetadata?: Record<string, string>;
53
+ },
54
+ ): Promise<unknown>;
55
+ get(key: string): Promise<{
56
+ body: ReadableStream<Uint8Array>;
57
+ size: number;
58
+ httpMetadata?: { contentType?: string };
59
+ } | null>;
60
+ }
61
+
62
+ export interface UploadRouteDependenciesV1 {
63
+ /** Absent in a deployment with no object store: uploads answer 503. */
64
+ bucket?: UploadObjectStoreV1;
65
+ /** Absent: only plain-text documents can be read. */
66
+ converter?: DocumentConverterV1;
67
+ botRegistered(userId: string, botId: string): Promise<boolean>;
68
+ reserveQuota(input: {
69
+ userId: string;
70
+ botId: string;
71
+ uploadId: string;
72
+ bytes: number;
73
+ }): Promise<UploadQuotaAnswerV1>;
74
+ recordUpload(input: {
75
+ userId: string;
76
+ botId: string;
77
+ upload: StoredUploadV1;
78
+ }): Promise<StoredUploadV1>;
79
+ now?(): Date;
80
+ }
81
+
82
+ function refusal(status: number, message: string): Response {
83
+ return Response.json(
84
+ { error: message },
85
+ { status, headers: { "cache-control": "no-store" } },
86
+ );
87
+ }
88
+
89
+ const TOO_LARGE_MESSAGE_V1 = `That file is larger than ${UPLOAD_MAX_BYTES_V1 / 1024 ** 2} MB.`;
90
+
91
+ /** The request body, refused once it passes the upload bound. */
92
+ async function readBoundedBodyV1(
93
+ request: Request,
94
+ ): Promise<Uint8Array | "too-large"> {
95
+ const declared = Number(request.headers.get("content-length") ?? "");
96
+ if (Number.isFinite(declared) && declared > UPLOAD_MAX_BYTES_V1) {
97
+ return "too-large";
98
+ }
99
+ if (!request.body) return new Uint8Array();
100
+ const reader = request.body.getReader();
101
+ const chunks: Uint8Array[] = [];
102
+ let length = 0;
103
+ for (;;) {
104
+ const { done, value } = await reader.read();
105
+ if (done) break;
106
+ length += value.byteLength;
107
+ if (length > UPLOAD_MAX_BYTES_V1) {
108
+ await reader.cancel();
109
+ return "too-large";
110
+ }
111
+ chunks.push(value);
112
+ }
113
+ const bytes = new Uint8Array(length);
114
+ let offset = 0;
115
+ for (const chunk of chunks) {
116
+ bytes.set(chunk, offset);
117
+ offset += chunk.byteLength;
118
+ }
119
+ return bytes;
120
+ }
121
+
122
+ async function sha256HexV1(bytes: Uint8Array): Promise<string> {
123
+ const digest = await crypto.subtle.digest("SHA-256", bytes as BufferSource);
124
+ return [...new Uint8Array(digest)]
125
+ .map((byte) => byte.toString(16).padStart(2, "0"))
126
+ .join("");
127
+ }
128
+
129
+ function botIdOf(segment: string): string | undefined {
130
+ try {
131
+ return decodeBotIdV1(decodeURIComponent(segment));
132
+ } catch {
133
+ return undefined;
134
+ }
135
+ }
136
+
137
+ export type StoredUploadOutcomeV1 =
138
+ | { status: "stored"; upload: StoredUploadV1 }
139
+ | { status: "refused"; httpStatus: number; reason: string };
140
+
141
+ /**
142
+ * One file into a Bot's uploads: classified, its text read, counted, written
143
+ * and recorded, in that order — the composer's upload and a file an email
144
+ * carried both come through here. The Bot's own membership is the caller's
145
+ * to have checked.
146
+ */
147
+ export async function storeUploadV1(
148
+ dependencies: Omit<UploadRouteDependenciesV1, "botRegistered">,
149
+ file: {
150
+ userId: string;
151
+ botId: string;
152
+ name: string;
153
+ declaredType: string | null;
154
+ bytes: Uint8Array;
155
+ },
156
+ ): Promise<StoredUploadOutcomeV1> {
157
+ const { userId, botId, bytes } = file;
158
+ const bucket = dependencies.bucket;
159
+ if (!bucket) {
160
+ return {
161
+ status: "refused",
162
+ httpStatus: 503,
163
+ reason: "Files can't be sent on this deployment.",
164
+ };
165
+ }
166
+ if (bytes.byteLength > UPLOAD_MAX_BYTES_V1) {
167
+ return { status: "refused", httpStatus: 413, reason: TOO_LARGE_MESSAGE_V1 };
168
+ }
169
+ if (bytes.byteLength === 0) {
170
+ return {
171
+ status: "refused",
172
+ httpStatus: 400,
173
+ reason: "That file is empty.",
174
+ };
175
+ }
176
+ const name = normalizeUploadNameV1(file.name);
177
+ const classified = classifyUploadV1({
178
+ name,
179
+ declaredType: file.declaredType,
180
+ bytes,
181
+ });
182
+ if (classified.status === "refused") {
183
+ return { status: "refused", httpStatus: 415, reason: classified.reason };
184
+ }
185
+ // Read before anything is stored or counted: a document with no text is
186
+ // refused on the composer, not accepted and then shown to the Bot as
187
+ // nothing.
188
+ const text =
189
+ classified.kind === "document"
190
+ ? await extractDocumentTextV1({
191
+ name,
192
+ mediaType: classified.mediaType,
193
+ bytes,
194
+ ...(dependencies.converter
195
+ ? { converter: dependencies.converter }
196
+ : {}),
197
+ })
198
+ : undefined;
199
+ if (text?.status === "refused") {
200
+ return { status: "refused", httpStatus: 422, reason: text.reason };
201
+ }
202
+ const uploadId = await sha256HexV1(bytes);
203
+ const quota = await dependencies.reserveQuota({
204
+ userId,
205
+ botId,
206
+ uploadId,
207
+ bytes: bytes.byteLength,
208
+ });
209
+ if (quota.status === "full") {
210
+ return {
211
+ status: "refused",
212
+ httpStatus: 413,
213
+ reason: UPLOAD_QUOTA_FULL_MESSAGE_V1,
214
+ };
215
+ }
216
+ await bucket.put(uploadObjectKeyV1(userId, botId, uploadId), bytes, {
217
+ httpMetadata: { contentType: classified.mediaType },
218
+ });
219
+ if (text?.status === "ok") {
220
+ await bucket.put(uploadTextKeyV1(userId, botId, uploadId), text.text, {
221
+ httpMetadata: { contentType: "text/markdown; charset=utf-8" },
222
+ customMetadata: { chars: String(text.chars) },
223
+ });
224
+ }
225
+ const now = dependencies.now ?? (() => new Date());
226
+ const upload = await dependencies.recordUpload({
227
+ userId,
228
+ botId,
229
+ upload: {
230
+ schemaVersion: 1,
231
+ uploadId,
232
+ kind: classified.kind,
233
+ name,
234
+ mediaType: classified.mediaType,
235
+ bytes: bytes.byteLength,
236
+ uploadedAt: now().toISOString(),
237
+ ...(text?.status === "ok" ? { textChars: text.chars } : {}),
238
+ },
239
+ });
240
+ return { status: "stored", upload };
241
+ }
242
+
243
+ export function uploadRoutes(
244
+ dependencies: UploadRouteDependenciesV1,
245
+ ): BackendRouteContribution {
246
+ async function upload(
247
+ request: Request,
248
+ url: URL,
249
+ userId: string,
250
+ botId: string,
251
+ ): Promise<Response> {
252
+ if (!dependencies.bucket) {
253
+ return refusal(503, "Files can't be sent on this deployment.");
254
+ }
255
+ if (!(await dependencies.botRegistered(userId, botId))) {
256
+ return refusal(404, "Bot not found");
257
+ }
258
+ const body = await readBoundedBodyV1(request);
259
+ if (body === "too-large") return refusal(413, TOO_LARGE_MESSAGE_V1);
260
+ const stored = await storeUploadV1(dependencies, {
261
+ userId,
262
+ botId,
263
+ name: url.searchParams.get("name") ?? "",
264
+ declaredType: request.headers.get("content-type"),
265
+ bytes: body,
266
+ });
267
+ if (stored.status === "refused") {
268
+ return refusal(stored.httpStatus, stored.reason);
269
+ }
270
+ const recorded = stored.upload;
271
+ return Response.json(
272
+ {
273
+ schemaVersion: 1,
274
+ upload: {
275
+ uploadId: recorded.uploadId,
276
+ kind: recorded.kind,
277
+ name: recorded.name,
278
+ mediaType: recorded.mediaType,
279
+ bytes: recorded.bytes,
280
+ },
281
+ } satisfies UploadReceiptV1,
282
+ { status: 201, headers: { "cache-control": "no-store" } },
283
+ );
284
+ }
285
+
286
+ async function read(
287
+ userId: string,
288
+ botId: string,
289
+ uploadId: string,
290
+ ): Promise<Response> {
291
+ const object = await dependencies.bucket?.get(
292
+ uploadObjectKeyV1(userId, botId, uploadId),
293
+ );
294
+ if (!object) return refusal(404, "That file is no longer available.");
295
+ const contentType =
296
+ object.httpMetadata?.contentType ?? "application/octet-stream";
297
+ const image = contentType.startsWith("image/");
298
+ return new Response(object.body, {
299
+ headers: {
300
+ "content-type": image ? contentType : "application/octet-stream",
301
+ "content-length": String(object.size),
302
+ // The URL names the bytes' hash, so they never change under it.
303
+ "cache-control": "private, max-age=31536000, immutable",
304
+ etag: `"${uploadId}"`,
305
+ "x-content-type-options": "nosniff",
306
+ // The person's own file, served from the app's origin: never a page.
307
+ "content-security-policy": "default-src 'none'; sandbox",
308
+ ...(image ? {} : { "content-disposition": "attachment" }),
309
+ },
310
+ });
311
+ }
312
+
313
+ return {
314
+ packageId: "uploads",
315
+ async route(request, url, context) {
316
+ const uploadMatch = url.pathname.match(UPLOAD_PATH_V1);
317
+ const readMatch = uploadMatch
318
+ ? null
319
+ : url.pathname.match(UPLOAD_READ_PATH_V1);
320
+ if (!uploadMatch && !readMatch) return undefined;
321
+ const userId = context.userId;
322
+ if (!userId) return refusal(401, "authentication required");
323
+ const botId = botIdOf((uploadMatch ?? readMatch)![1]!);
324
+ if (!botId) return refusal(400, "invalid bot id");
325
+ try {
326
+ if (uploadMatch) {
327
+ if (request.method !== "POST") {
328
+ return refusal(405, "method not allowed");
329
+ }
330
+ return await upload(request, url, userId, botId);
331
+ }
332
+ if (request.method !== "GET") return refusal(405, "method not allowed");
333
+ return await read(userId, botId, readMatch![2]!);
334
+ } catch (error) {
335
+ const name =
336
+ typeof error === "object" && error !== null && "name" in error
337
+ ? String(error.name)
338
+ : "";
339
+ if (name === "BotNotFoundError") return refusal(404, "Bot not found");
340
+ if (name === "BotDeletedError") return refusal(410, "Bot is deleted");
341
+ return refusal(
342
+ 503,
343
+ "That file couldn't be saved. Try attaching it again.",
344
+ );
345
+ }
346
+ },
347
+ };
348
+ }
349
+
350
+ /** The bindings teardown needs to take a Bot's uploads with it. */
351
+ export interface UploadTeardownEnvV1 {
352
+ MEMORY_FILES?: {
353
+ list(options: {
354
+ prefix: string;
355
+ cursor?: string;
356
+ limit?: number;
357
+ }): Promise<{
358
+ objects: { key: string }[];
359
+ truncated: boolean;
360
+ cursor?: string;
361
+ }>;
362
+ delete(keys: string | string[]): Promise<void>;
363
+ };
364
+ USER_CONFIGURATIONS: {
365
+ idFromName(name: string): DurableObjectId;
366
+ get(id: DurableObjectId): unknown;
367
+ };
368
+ }
369
+
370
+ /** Every object under one Bot's upload prefix, removed. Idempotent. */
371
+ export async function deleteBotUploadsV1(
372
+ env: UploadTeardownEnvV1,
373
+ identity: { userId: string; botId: string },
374
+ ): Promise<number> {
375
+ const bucket = env.MEMORY_FILES;
376
+ if (!bucket) return 0;
377
+ const prefix = uploadBotPrefixV1(identity.userId, identity.botId);
378
+ let removed = 0;
379
+ let cursor: string | undefined;
380
+ do {
381
+ const page = await bucket.list({
382
+ prefix,
383
+ limit: 1_000,
384
+ ...(cursor === undefined ? {} : { cursor }),
385
+ });
386
+ if (page.objects.length > 0) {
387
+ await bucket.delete(page.objects.map((object) => object.key));
388
+ removed += page.objects.length;
389
+ }
390
+ cursor = page.truncated ? page.cursor : undefined;
391
+ } while (cursor !== undefined);
392
+ return removed;
393
+ }
394
+
395
+ /** The bindings a Bot needs to keep and delete files of its own. */
396
+ export interface BotFileEnvV1 {
397
+ MEMORY_FILES: {
398
+ put(
399
+ key: string,
400
+ value: Uint8Array | string,
401
+ options?: {
402
+ httpMetadata?: { contentType?: string };
403
+ customMetadata?: Record<string, string>;
404
+ },
405
+ ): Promise<unknown>;
406
+ delete(keys: string | string[]): Promise<void>;
407
+ };
408
+ USER_CONFIGURATIONS: UploadTeardownEnvV1["USER_CONFIGURATIONS"];
409
+ }
410
+
411
+ /**
412
+ * Where one Bot keeps files the product hands it — a demonstration the
413
+ * person recorded — the same store, prefix and account ledger the upload
414
+ * route writes for a file they attach.
415
+ */
416
+ export function botFileStoreV1(
417
+ env: BotFileEnvV1,
418
+ owner: { userId: string; botId: string },
419
+ storage: BotFileStoreV1["storage"],
420
+ ): BotFileStoreV1 {
421
+ // SAFETY: this binding names UserConfiguration; these are its reviewed RPCs.
422
+ const user = () =>
423
+ env.USER_CONFIGURATIONS.get(
424
+ env.USER_CONFIGURATIONS.idFromName(owner.userId),
425
+ ) as {
426
+ reserveUploadQuota(input: {
427
+ schemaVersion: 1;
428
+ userId: string;
429
+ botId: string;
430
+ uploadId: string;
431
+ bytes: number;
432
+ }): Promise<UploadQuotaAnswerV1>;
433
+ releaseUploadQuota(input: {
434
+ schemaVersion: 1;
435
+ userId: string;
436
+ botId: string;
437
+ uploadIds: string[];
438
+ }): Promise<{ released: number }>;
439
+ };
440
+ return {
441
+ bucket: {
442
+ put: (key, value, options) => env.MEMORY_FILES.put(key, value, options),
443
+ delete: (keys) => env.MEMORY_FILES.delete(keys),
444
+ },
445
+ storage,
446
+ reserveQuota: (input) =>
447
+ user().reserveUploadQuota({ schemaVersion: 1, ...owner, ...input }),
448
+ releaseQuota: async (uploadIds) => {
449
+ await user().releaseUploadQuota({
450
+ schemaVersion: 1,
451
+ ...owner,
452
+ uploadIds,
453
+ });
454
+ },
455
+ now: () => new Date(),
456
+ };
457
+ }
458
+
459
+ /** Asks the User object to give back a deleted Bot's upload space. */
460
+ export async function releaseBotUploadQuotaRpcV1(
461
+ env: UploadTeardownEnvV1,
462
+ identity: { userId: string; botId: string },
463
+ ): Promise<void> {
464
+ // SAFETY: this binding names UserConfiguration; this is its reviewed RPC.
465
+ const user = env.USER_CONFIGURATIONS.get(
466
+ env.USER_CONFIGURATIONS.idFromName(identity.userId),
467
+ ) as {
468
+ releaseBotUploadQuota(input: {
469
+ schemaVersion: 1;
470
+ userId: string;
471
+ botId: string;
472
+ }): Promise<{ released: number }>;
473
+ };
474
+ try {
475
+ await user.releaseBotUploadQuota({ schemaVersion: 1, ...identity });
476
+ } catch (error) {
477
+ // An erased account took its ledger with it: there is nothing to give
478
+ // back, and a Bot teardown that replays after it must still finish.
479
+ if (!(
480
+ error instanceof Error &&
481
+ (error.name === "AccountDeletedError" ||
482
+ /account has been deleted/i.test(error.message))
483
+ ))
484
+ throw error;
485
+ }
486
+ }