@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/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
|
+
}
|