@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/billing.ts ADDED
@@ -0,0 +1,461 @@
1
+ import { BRAND_V1 } from "#brand";
2
+ import {
3
+ BILLING_LAUNCH_BLOCKERS,
4
+ hostedBillingEnabledV1,
5
+ } from "./billing-readiness.js";
6
+ import { COMPUTER_TARIFF } from "@frockbot/app/billing/computer";
7
+ import type { HostedModelRatesV1 } from "@frockbot/app/billing/rates";
8
+ import { normalizeFrockModelIdV1 } from "@frockbot/providers/frock-ai/catalog";
9
+ import {
10
+ BillingError,
11
+ type UsageReservation,
12
+ type UsageSettlement,
13
+ } from "@frockbot/app/billing/ledger";
14
+ import {
15
+ AccountPayments,
16
+ StripeClient,
17
+ boundedText,
18
+ object,
19
+ stripeId,
20
+ verifyStripeEvent,
21
+ type StripeConfig,
22
+ } from "@frockbot/app/billing/stripe";
23
+ import {
24
+ billingPageV1,
25
+ billingScript,
26
+ billingStyles,
27
+ } from "@frockbot/app/billing/page";
28
+ import type { BillingLedger } from "@frockbot/app/billing/ledger";
29
+ import {
30
+ SPEND_DIMENSIONS_V1,
31
+ SPEND_PERIODS_V1,
32
+ type SpendDimensionV1,
33
+ type SpendingReportV1,
34
+ type SpendPeriodV1,
35
+ } from "@frockbot/app/billing/spending";
36
+ import type { BackendRouteContribution } from "./contracts.js";
37
+
38
+ export interface BillingEnv {
39
+ STRIPE_SECRET_KEY?: string;
40
+ STRIPE_WEBHOOK_SECRET?: string;
41
+ STRIPE_MONTHLY_PRICE_ID?: string;
42
+ BETTER_AUTH_URL?: string;
43
+ }
44
+ export function stripeConfig(env: BillingEnv): StripeConfig {
45
+ if (
46
+ !env.STRIPE_SECRET_KEY ||
47
+ !env.STRIPE_WEBHOOK_SECRET ||
48
+ !env.STRIPE_MONTHLY_PRICE_ID ||
49
+ !env.BETTER_AUTH_URL
50
+ )
51
+ throw new BillingError(
52
+ "Payments are not available yet. Please check back soon.",
53
+ 503,
54
+ );
55
+ const origin = new URL(env.BETTER_AUTH_URL).origin;
56
+ if (!/^price_[a-zA-Z0-9]+$/.test(env.STRIPE_MONTHLY_PRICE_ID))
57
+ throw new BillingError("Payment plan is not configured", 503);
58
+ return {
59
+ productName: BRAND_V1.productName,
60
+ secretKey: env.STRIPE_SECRET_KEY,
61
+ webhookSecret: env.STRIPE_WEBHOOK_SECRET,
62
+ monthlyPriceId: env.STRIPE_MONTHLY_PRICE_ID,
63
+ origin,
64
+ };
65
+ }
66
+ export function accountPayments(
67
+ ledger: BillingLedger,
68
+ env: BillingEnv,
69
+ userId: string,
70
+ ) {
71
+ return new AccountPayments(
72
+ ledger,
73
+ new StripeClient(stripeConfig(env)),
74
+ userId,
75
+ );
76
+ }
77
+ export interface BillingAccountRpc {
78
+ reconcileBilling(input: {
79
+ userId: string;
80
+ command: Parameters<BillingLedger["reconcile"]>[0];
81
+ }): Promise<void>;
82
+ readBilling(input: {
83
+ userId: string;
84
+ before?: number;
85
+ }): Promise<ReturnType<BillingLedger["snapshot"]>>;
86
+ billingCheckout(input: {
87
+ userId: string;
88
+ command: { id: string; kind: "subscription" | "topup"; cents?: number };
89
+ }): Promise<{ url: string }>;
90
+ billingPortal(input: {
91
+ userId: string;
92
+ commandId: string;
93
+ }): Promise<{ url: string }>;
94
+ billingWebhook(input: {
95
+ userId: string;
96
+ event: Record<string, unknown>;
97
+ }): Promise<void>;
98
+ reserveUsage(input: {
99
+ userId: string;
100
+ reservation: UsageReservation;
101
+ }): Promise<{
102
+ status: "reserved" | "settled" | "released";
103
+ created: boolean;
104
+ }>;
105
+ settleUsage(input: {
106
+ userId: string;
107
+ settlement: UsageSettlement;
108
+ }): Promise<void>;
109
+ requirePaidAccount(input: { userId: string }): Promise<void>;
110
+ setSpendingLimit(input: {
111
+ userId: string;
112
+ scope: string;
113
+ dailyMicros: number | null;
114
+ }): Promise<void>;
115
+ readSpendingPaused(input: {
116
+ userId: string;
117
+ scopes: string[];
118
+ }): Promise<boolean>;
119
+ claimSpendingSpike(input: {
120
+ userId: string;
121
+ scope: string;
122
+ }): Promise<{ todayMicros: number; usualMicros: number } | null>;
123
+ readSpending(input: {
124
+ userId: string;
125
+ period: SpendPeriodV1;
126
+ groupBy: SpendDimensionV1;
127
+ filters: Partial<Record<SpendDimensionV1, string>>;
128
+ }): Promise<SpendingReportV1>;
129
+ }
130
+
131
+ /**
132
+ * `GET /api/billing/spending?period=30d&groupBy=cause&bot=<id>…`: one view of
133
+ * the Spending page. Each dimension may also be a filter, named by the key a
134
+ * grouping by it returned.
135
+ */
136
+ export function decodeSpendingQueryV1(url: URL): {
137
+ period: SpendPeriodV1;
138
+ groupBy: SpendDimensionV1;
139
+ filters: Partial<Record<SpendDimensionV1, string>>;
140
+ } {
141
+ const period = SPEND_PERIODS_V1.find(
142
+ (p) => p === (url.searchParams.get("period") ?? "30d"),
143
+ );
144
+ const groupBy = SPEND_DIMENSIONS_V1.find(
145
+ (d) => d === (url.searchParams.get("groupBy") ?? "bot"),
146
+ );
147
+ if (!period || !groupBy) throw new BillingError("Invalid spending view", 400);
148
+ const filters: Partial<Record<SpendDimensionV1, string>> = {};
149
+ for (const dimension of SPEND_DIMENSIONS_V1) {
150
+ const value = url.searchParams.get(dimension);
151
+ if (value === null) continue;
152
+ if (value.length > 600)
153
+ throw new BillingError("Invalid spending view", 400);
154
+ filters[dimension] = value;
155
+ }
156
+ return { period, groupBy, filters };
157
+ }
158
+ function failure(error: unknown) {
159
+ return Response.json(
160
+ {
161
+ error:
162
+ error instanceof Error
163
+ ? error.message
164
+ : "Billing is temporarily unavailable",
165
+ },
166
+ {
167
+ status: error instanceof BillingError ? error.status : 503,
168
+ headers: { "cache-control": "no-store" },
169
+ },
170
+ );
171
+ }
172
+ /**
173
+ * What an account pays per million tokens for each hosted model it can be
174
+ * charged for, the conversation-summary model included. These are ceilings: a call is charged for the model that answered it, never
175
+ * above the rate listed for the one it asked for. A pre-rename `@flock/` id is
176
+ * the same model under its old name, so it is listed once.
177
+ */
178
+ export function customerModelRatesV1(table: HostedModelRatesV1 | undefined) {
179
+ const routes = table?.routes ?? {};
180
+ return Object.fromEntries(
181
+ Object.entries(routes)
182
+ .filter(
183
+ ([model]) =>
184
+ normalizeFrockModelIdV1(model) === model ||
185
+ !Object.hasOwn(routes, normalizeFrockModelIdV1(model)),
186
+ )
187
+ .map(([model, rate]) => [
188
+ model,
189
+ {
190
+ inputUsdPerMillion: rate.inputMicrosPerToken * 2,
191
+ cachedInputUsdPerMillion: rate.cachedInputMicrosPerToken * 2,
192
+ outputUsdPerMillion: rate.outputMicrosPerToken * 2,
193
+ },
194
+ ]),
195
+ );
196
+ }
197
+
198
+ export function billingRoutes(
199
+ env: BillingEnv,
200
+ account: (userId: string) => BillingAccountRpc,
201
+ modelRates: () => Promise<HostedModelRatesV1>,
202
+ ): BackendRouteContribution {
203
+ return {
204
+ packageId: "billing",
205
+ async publicRoute(request, url) {
206
+ if (
207
+ request.method === "GET" &&
208
+ ["/billing", "/billing.js", "/billing.css"].includes(url.pathname)
209
+ ) {
210
+ const type = url.pathname.endsWith(".js")
211
+ ? "text/javascript"
212
+ : url.pathname.endsWith(".css")
213
+ ? "text/css"
214
+ : "text/html";
215
+ return new Response(
216
+ url.pathname.endsWith(".js")
217
+ ? billingScript
218
+ : url.pathname.endsWith(".css")
219
+ ? billingStyles
220
+ : billingPageV1(BRAND_V1.productName),
221
+ {
222
+ headers: {
223
+ "content-type": `${type}; charset=utf-8`,
224
+ "cache-control": "no-store",
225
+ "x-content-type-options": "nosniff",
226
+ "content-security-policy":
227
+ "default-src 'none'; script-src 'self'; style-src 'self'; connect-src 'self'; base-uri 'none'; frame-ancestors 'none'",
228
+ },
229
+ },
230
+ );
231
+ }
232
+ if (url.pathname !== "/api/billing/stripe/webhook") return;
233
+ if (request.method !== "POST") return new Response(null, { status: 405 });
234
+ try {
235
+ const config = stripeConfig(env);
236
+ const event = await verifyStripeEvent(
237
+ await boundedText(request),
238
+ request.headers.get("stripe-signature"),
239
+ config.webhookSecret,
240
+ );
241
+ const supported = new Set([
242
+ "invoice.paid",
243
+ "invoice.payment_failed",
244
+ "customer.subscription.created",
245
+ "customer.subscription.updated",
246
+ "customer.subscription.deleted",
247
+ "checkout.session.completed",
248
+ "checkout.session.async_payment_succeeded",
249
+ "charge.refunded",
250
+ "charge.dispute.created",
251
+ ]);
252
+ if (!supported.has(String(event.type)))
253
+ return Response.json({ received: true });
254
+ const stripe = new StripeClient(config);
255
+ const data = object(object(event.data).object);
256
+ // Dispute payloads identify the charge rather than its customer.
257
+ if (!data.customer && event.type === "charge.dispute.created") {
258
+ const charge = await stripe.call(`charges/${stripeId(data.charge)}`);
259
+ data.customer = charge.customer;
260
+ }
261
+ const customer = await stripe.call(
262
+ `customers/${stripeId(data.customer)}`,
263
+ );
264
+ // Deleting an account deletes its customer, and Stripe then reports
265
+ // the subscription that took with it. There is nobody left to tell,
266
+ // and refusing would only have Stripe retry for days.
267
+ if (customer.deleted === true) return Response.json({ received: true });
268
+ const userId = object(customer.metadata).frockbot_user_id;
269
+ if (
270
+ typeof userId !== "string" ||
271
+ !/^[a-zA-Z0-9_-]{1,128}$/.test(userId)
272
+ )
273
+ throw new BillingError("Unknown payment account", 400);
274
+ await account(userId).billingWebhook({ userId, event });
275
+ return Response.json({ received: true });
276
+ } catch (error) {
277
+ // An event that raced the account's deletion has nothing to apply to.
278
+ if (error instanceof Error && error.name === "AccountDeletedError")
279
+ return Response.json({ received: true });
280
+ return failure(error);
281
+ }
282
+ },
283
+ async route(request, url, context) {
284
+ if (
285
+ !url.pathname.startsWith("/api/billing") &&
286
+ !["/billing", "/billing.js", "/billing.css"].includes(url.pathname)
287
+ )
288
+ return;
289
+ if (!context.userId || context.userId === "anonymous")
290
+ return Response.json(
291
+ { error: "Sign in to manage billing" },
292
+ { status: 401 },
293
+ );
294
+ const headers = {
295
+ "cache-control": "no-store",
296
+ "x-content-type-options": "nosniff",
297
+ "content-security-policy":
298
+ "default-src 'none'; script-src 'self'; style-src 'self'; connect-src 'self'; base-uri 'none'; frame-ancestors 'none'",
299
+ "referrer-policy": "no-referrer",
300
+ };
301
+ if (request.method === "GET") {
302
+ if (url.pathname === "/billing")
303
+ return new Response(billingPageV1(BRAND_V1.productName), {
304
+ headers: { ...headers, "content-type": "text/html; charset=utf-8" },
305
+ });
306
+ if (url.pathname === "/billing.js")
307
+ return new Response(billingScript, {
308
+ headers: {
309
+ ...headers,
310
+ "content-type": "text/javascript; charset=utf-8",
311
+ },
312
+ });
313
+ if (url.pathname === "/billing.css")
314
+ return new Response(billingStyles, {
315
+ headers: { ...headers, "content-type": "text/css; charset=utf-8" },
316
+ });
317
+ }
318
+ try {
319
+ const userId = context.userId;
320
+ if (request.method === "GET" && url.pathname === "/api/billing") {
321
+ const before = url.searchParams.has("before")
322
+ ? Number(url.searchParams.get("before"))
323
+ : undefined;
324
+ if (
325
+ before !== undefined &&
326
+ (!Number.isSafeInteger(before) || before < 0)
327
+ )
328
+ throw new BillingError("Invalid usage cursor", 400);
329
+ return Response.json(
330
+ {
331
+ ...(await account(userId).readBilling({
332
+ userId,
333
+ ...(before === undefined ? {} : { before }),
334
+ })),
335
+ // A table that cannot be read just now lists no rates; the
336
+ // balance and history are still the account's to see.
337
+ modelRates: customerModelRatesV1(
338
+ await modelRates().catch(() => undefined),
339
+ ),
340
+ computerRate: {
341
+ activeUsdPerHour: COMPUTER_TARIFF.activeUsdPerHour,
342
+ storageIncludedGb: COMPUTER_TARIFF.storageIncludedGb,
343
+ viewerOpenSeconds: COMPUTER_TARIFF.viewerOpenSeconds,
344
+ viewerRenewSeconds: COMPUTER_TARIFF.viewerRenewSeconds,
345
+ },
346
+ launchBlockers: BILLING_LAUNCH_BLOCKERS,
347
+ // Whether this deployment meters at all. Off, and credit is
348
+ // meaningless: nothing is charged and nothing is refused.
349
+ metered: hostedBillingEnabledV1(env),
350
+ paymentsAvailable:
351
+ BILLING_LAUNCH_BLOCKERS.length === 0 &&
352
+ !!(
353
+ env.STRIPE_SECRET_KEY &&
354
+ env.STRIPE_WEBHOOK_SECRET &&
355
+ env.STRIPE_MONTHLY_PRICE_ID
356
+ ),
357
+ },
358
+ { headers },
359
+ );
360
+ }
361
+ if (
362
+ request.method === "GET" &&
363
+ url.pathname === "/api/billing/spending"
364
+ )
365
+ return Response.json(
366
+ await account(userId).readSpending({
367
+ userId,
368
+ ...decodeSpendingQueryV1(url),
369
+ }),
370
+ { headers },
371
+ );
372
+ if (request.method !== "POST")
373
+ return new Response(null, { status: 405 });
374
+ const origin = request.headers.get("origin");
375
+ if (
376
+ origin &&
377
+ origin !== new URL(env.BETTER_AUTH_URL ?? request.url).origin
378
+ )
379
+ throw new BillingError("Invalid request origin", 403);
380
+ if (
381
+ !request.headers.get("content-type")?.startsWith("application/json")
382
+ )
383
+ throw new BillingError("Expected JSON", 415);
384
+ const body = object(JSON.parse(await boundedText(request, 4096)));
385
+ if (url.pathname === "/api/billing/reconcile") {
386
+ if (!context.isAdmin)
387
+ throw new BillingError("Administrator access required", 403);
388
+ if (
389
+ typeof body.userId !== "string" ||
390
+ !/^[a-zA-Z0-9_-]{1,128}$/.test(body.userId)
391
+ )
392
+ throw new BillingError("Invalid account", 400);
393
+ const command = object(body.command);
394
+ if (
395
+ typeof command.id !== "string" ||
396
+ typeof command.reason !== "string"
397
+ )
398
+ throw new BillingError("Invalid reconciliation", 400);
399
+ await account(body.userId).reconcileBilling({
400
+ userId: body.userId,
401
+ command: {
402
+ ...command,
403
+ actorId: context.userId,
404
+ } as unknown as Parameters<BillingLedger["reconcile"]>[0],
405
+ });
406
+ return Response.json({ ok: true }, { headers });
407
+ }
408
+ if (url.pathname === "/api/billing/limits") {
409
+ if (
410
+ typeof body.scope !== "string" ||
411
+ (body.dailyMicros !== null && typeof body.dailyMicros !== "number")
412
+ )
413
+ throw new BillingError("Invalid daily limit", 400);
414
+ await account(userId).setSpendingLimit({
415
+ userId,
416
+ scope: body.scope,
417
+ dailyMicros: body.dailyMicros as number | null,
418
+ });
419
+ return Response.json({ ok: true }, { headers });
420
+ }
421
+ if (url.pathname === "/api/billing/checkout") {
422
+ if (BILLING_LAUNCH_BLOCKERS.length)
423
+ throw new BillingError(
424
+ "Payments are not open yet. Launch qualification is still in progress.",
425
+ 503,
426
+ );
427
+ if (
428
+ typeof body.id !== "string" ||
429
+ !["subscription", "topup"].includes(String(body.kind)) ||
430
+ (body.cents !== undefined && typeof body.cents !== "number")
431
+ )
432
+ throw new BillingError("Invalid checkout", 400);
433
+ return Response.json(
434
+ await account(userId).billingCheckout({
435
+ userId,
436
+ command: {
437
+ id: body.id,
438
+ kind: body.kind as "subscription" | "topup",
439
+ ...(body.cents === undefined
440
+ ? {}
441
+ : { cents: body.cents as number }),
442
+ },
443
+ }),
444
+ { headers },
445
+ );
446
+ }
447
+ if (
448
+ url.pathname === "/api/billing/portal" &&
449
+ typeof body.id === "string"
450
+ )
451
+ return Response.json(
452
+ await account(userId).billingPortal({ userId, commandId: body.id }),
453
+ { headers },
454
+ );
455
+ return new Response(null, { status: 404 });
456
+ } catch (error) {
457
+ return failure(error);
458
+ }
459
+ },
460
+ };
461
+ }