@frockbot/cloudflare 0.0.0 → 0.7.291

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (88) hide show
  1. package/build-artifact.ts +130 -0
  2. package/build-flutter-web.ts +508 -0
  3. package/deployment-config/README.md +414 -0
  4. package/deployment-config/cli.ts +228 -0
  5. package/deployment-config/generate.ts +689 -0
  6. package/deployment-config/jsonc.ts +18 -0
  7. package/deployment-config/profile-schema.generated.ts +391 -0
  8. package/deployment-config/profile.schema.json +358 -0
  9. package/deployment-config/profile.ts +124 -0
  10. package/migrations/0001_better_auth.sql +15 -0
  11. package/migrations/0002_drop_account_issuer.sql +8 -0
  12. package/package.json +61 -5
  13. package/release-version.ts +20 -0
  14. package/src/account-admission.ts +179 -0
  15. package/src/account-deletion.ts +274 -0
  16. package/src/admin-entrypoint.ts +194 -0
  17. package/src/admin-identities.ts +26 -0
  18. package/src/audit.ts +103 -0
  19. package/src/auth-package.access.ts +24 -0
  20. package/src/auth-package.ts +36 -0
  21. package/src/avatar-state-cleanup.ts +107 -0
  22. package/src/billing-computer.ts +267 -0
  23. package/src/billing-readiness.ts +21 -0
  24. package/src/billing.ts +461 -0
  25. package/src/bot-capabilities.ts +480 -0
  26. package/src/bot-recovery.ts +1 -0
  27. package/src/bot-state-channel.ts +702 -0
  28. package/src/bot-state.ts +3995 -0
  29. package/src/bot-template-cleanup.ts +98 -0
  30. package/src/bot-title-cleanup.ts +104 -0
  31. package/src/brand-icon.png +0 -0
  32. package/src/brand-logo.ts +2 -0
  33. package/src/brand.ts +36 -0
  34. package/src/client-compatibility.ts +51 -0
  35. package/src/compaction-announcement-cleanup.ts +113 -0
  36. package/src/computer-egress.ts +110 -0
  37. package/src/computer-host.ts +92 -0
  38. package/src/computer-screenshot-cleanup.ts +78 -0
  39. package/src/contracts.ts +832 -0
  40. package/src/debug.ts +272 -0
  41. package/src/default-packages-marker-cleanup.ts +39 -0
  42. package/src/deployment-policy-admin-host.ts +121 -0
  43. package/src/deployment-policy.ts +426 -0
  44. package/src/directory-profile-cleanup.ts +101 -0
  45. package/src/durable-rpc.ts +753 -0
  46. package/src/durable-session.ts +21 -0
  47. package/src/entry-boundary.ts +103 -0
  48. package/src/frock-ai.ts +338 -0
  49. package/src/gateway.ts +1392 -0
  50. package/src/group-chat.ts +736 -0
  51. package/src/hidden-bot-notifications-cleanup.ts +32 -0
  52. package/src/index.ts +2798 -0
  53. package/src/insights.ts +15 -0
  54. package/src/machine-messages-cleanup.ts +121 -0
  55. package/src/machine-socket.ts +164 -0
  56. package/src/memory-records.ts +181 -0
  57. package/src/memory.ts +145 -0
  58. package/src/model-rates.ts +252 -0
  59. package/src/native-auth.ts +1006 -0
  60. package/src/native-sessions.ts +121 -0
  61. package/src/notification-state-cleanup.ts +18 -0
  62. package/src/ollama-web-search-cleanup.ts +69 -0
  63. package/src/package-page-shapes-cleanup.ts +190 -0
  64. package/src/plugin-egress.ts +31 -0
  65. package/src/plugin-page-route.ts +72 -0
  66. package/src/plugin-panels-cleanup.ts +101 -0
  67. package/src/prepared-input-cleanup.ts +105 -0
  68. package/src/production-secrets.ts +484 -0
  69. package/src/project-cleanup.ts +123 -0
  70. package/src/project-events-cleanup.ts +152 -0
  71. package/src/publication-state-cleanup.ts +92 -0
  72. package/src/push.ts +498 -0
  73. package/src/request-body.ts +165 -0
  74. package/src/routine-state-cleanup.ts +57 -0
  75. package/src/search.ts +88 -0
  76. package/src/sidebar-label-cleanup.ts +165 -0
  77. package/src/skill-index-cleanup.ts +53 -0
  78. package/src/supersede-cleanup.ts +162 -0
  79. package/src/test-chat-cleanup.ts +93 -0
  80. package/src/uploads.ts +486 -0
  81. package/src/user-application.ts +1068 -0
  82. package/src/user-configuration.ts +4491 -0
  83. package/src/voice-assistant.ts +4999 -0
  84. package/src/voice-dictation.ts +743 -0
  85. package/src/working-context-cleanup.ts +96 -0
  86. package/src/workspace.ts +201 -0
  87. package/wrangler.jsonc +385 -0
  88. package/README.md +0 -3
package/src/audit.ts ADDED
@@ -0,0 +1,103 @@
1
+ // The audit seam, bound in production.
2
+ //
3
+ // Two directions cross a Durable Object boundary here, and both are decoded on
4
+ // arrival — "Cross-runtime communication uses narrow, versioned DTOs, and
5
+ // every inbound value is decoded at its seam".
6
+ //
7
+ // * `createUserAuditSinkV1` is the Bot Durable Object's half: a settled
8
+ // Turn's entries go to the User Durable Object, which is the authority for
9
+ // User-scoped state. Unlike the transcript index this is *not*
10
+ // fire-and-forget — the Bot object holds them in a bounded durable outbox
11
+ // until the User object accepts them (parity register row 30b), because an
12
+ // audit surface with silent gaps answers no question anybody asks of it.
13
+ // * `createBotAuditEntryPageV1` is the User Durable Object's half: one page
14
+ // of a Bot's entries, projected from that Bot's own stored runs, which is
15
+ // what makes the table rebuildable rather than authoritative.
16
+ import {
17
+ auditEntriesFromStoredRunV1,
18
+ type AuditEntryPageV1,
19
+ type AuditEntryV1,
20
+ type AuditProjectableRunV1,
21
+ type AuditSinkV1,
22
+ } from "@frockbot/app/audit";
23
+
24
+ /** The User Durable Object's audit RPC surface, as the Bot object calls it. */
25
+ export interface UserAuditRpc {
26
+ indexAuditEntries(input: unknown): Promise<unknown>;
27
+ }
28
+
29
+ /** The Bot Durable Object's projection RPC, as the User object calls it. */
30
+ export interface BotAuditRpc {
31
+ projectAuditEntries(input: unknown): Promise<unknown>;
32
+ }
33
+
34
+ /**
35
+ * `AuditSinkV1` over the User Durable Object.
36
+ *
37
+ * A failure here *throws*, on purpose, and that is the difference between this
38
+ * sink and the transcript index's. The caller is draining a durable outbox: a
39
+ * throw leaves the entries queued for the next settlement or alarm, and
40
+ * swallowing it would turn an at-least-once queue into a silent drop. The Turn
41
+ * that produced them is already durable and is never failed by this.
42
+ */
43
+ export function createUserAuditSinkV1(
44
+ rpc: UserAuditRpc,
45
+ identity: { userId: string; botId: string },
46
+ ): AuditSinkV1 {
47
+ return {
48
+ async indexEntries(entries: readonly AuditEntryV1[]) {
49
+ if (entries.length === 0) return;
50
+ const owned = entries.filter((entry) => entry.botId === identity.botId);
51
+ if (owned.length === 0) return;
52
+ await rpc.indexAuditEntries({
53
+ schemaVersion: 1,
54
+ userId: identity.userId,
55
+ botId: identity.botId,
56
+ entries: owned,
57
+ });
58
+ },
59
+ };
60
+ }
61
+
62
+ /** The stored-run page the Shell Package offers, as this adapter reads it. */
63
+ export interface StoredRunEventPageV1 {
64
+ runs: ReadonlyArray<{
65
+ runId: string;
66
+ acceptedAt: string;
67
+ status: string;
68
+ events: AuditProjectableRunV1["events"];
69
+ }>;
70
+ nextCursor?: string;
71
+ }
72
+
73
+ /**
74
+ * One page of a Bot's entries, projected from its own durable session events.
75
+ *
76
+ * The *stored* run rather than the client projection: the client projection
77
+ * drops `call.input`, and the argument digest needs the exact arguments. Same
78
+ * function as settlement uses, so the entries a rebuild writes are the entries
79
+ * a settlement would have written — the property the whole "the table is a
80
+ * projection" claim rests on.
81
+ */
82
+ export async function createBotAuditEntryPageV1(
83
+ botId: string,
84
+ page: StoredRunEventPageV1,
85
+ ): Promise<AuditEntryPageV1> {
86
+ const entries: AuditEntryV1[] = [];
87
+ for (const run of page.runs) {
88
+ entries.push(
89
+ ...(await auditEntriesFromStoredRunV1(botId, {
90
+ runId: run.runId,
91
+ status: run.status,
92
+ acceptedAt: run.acceptedAt,
93
+ events: run.events,
94
+ })),
95
+ );
96
+ }
97
+ return {
98
+ schemaVersion: 1,
99
+ botId,
100
+ entries,
101
+ ...(page.nextCursor === undefined ? {} : { nextCursor: page.nextCursor }),
102
+ };
103
+ }
@@ -0,0 +1,24 @@
1
+ /**
2
+ * How a simple deployment signs people in: Cloudflare Access, and nothing
3
+ * stored anywhere.
4
+ *
5
+ * The twin of `auth-package.ts`, which is the tracked default. A profile whose
6
+ * `authPackage` is `access` makes the generator write
7
+ * `alias: { "#auth-package": "<this file> }` into the app Worker's config, and
8
+ * wrangler resolves every `#auth-package` import here instead — so the Access
9
+ * bundle carries no better-auth and the hosted bundle carries no Access
10
+ * verifier. Neither file imports the other, which is what makes that true.
11
+ *
12
+ * `tsconfig.access.json` type-checks the whole Worker against this file, so an
13
+ * `env` name only the hosted build has cannot reach the Access build unnoticed.
14
+ */
15
+ import type { AuthPackageBuildV1 } from "@frockbot/core/contracts";
16
+ import { ACCESS_AUTH_PACKAGE_V1 } from "@frockbot/app/auth/access";
17
+ import type { AccessEnvironmentV1 } from "@frockbot/app/auth/access";
18
+
19
+ /** What a Worker must hand the Package this build deploys. */
20
+ export type AuthPackageEnvironmentV1 = AccessEnvironmentV1;
21
+
22
+ /** The sign-in Package this build deploys. */
23
+ export const AUTH_PACKAGE_V1: AuthPackageBuildV1<AuthPackageEnvironmentV1> =
24
+ ACCESS_AUTH_PACKAGE_V1;
@@ -0,0 +1,36 @@
1
+ /**
2
+ * How the hosted deployment signs people in: better-auth with Google.
3
+ *
4
+ * `AuthPackageV1` is the interface the gateway speaks to sign-in
5
+ * (`core/contracts/auth-package.ts`); this file and `auth-package.access.ts`
6
+ * are the two builds of it, and each is exactly one value import, so that a
7
+ * build is a one-line file and a `grep` finds it.
8
+ *
9
+ * This is the one the tracked source resolves: `wrangler dev`, every suite and
10
+ * the hosted deploy all reach it through `#auth-package`, which
11
+ * `apps/cloudflare/package.json` maps here. A profile whose `authPackage` is
12
+ * `access` gets a generated wrangler config carrying
13
+ * `alias: { "#auth-package": "<path to auth-package.access.ts>" }`, so the
14
+ * Access build resolves that specifier to the other file and this one is not in
15
+ * its bundle at all.
16
+ *
17
+ * `scripts/check-auth-package-imports.ts` keeps the `better-auth` dependency
18
+ * inside `app/auth/better-auth/**` and this file. Nothing else in the Worker may
19
+ * name an implementation.
20
+ */
21
+ import type { AuthPackageBuildV1 } from "@frockbot/core/contracts";
22
+ import { BETTER_AUTH_PACKAGE_V1 } from "@frockbot/app/auth/better-auth";
23
+ import type { BetterAuthEnvironmentV1 } from "@frockbot/app/auth/better-auth";
24
+
25
+ /**
26
+ * What a Worker must hand the Package this build deploys.
27
+ *
28
+ * One Package's settings, not both: the Worker's `env` declares every name
29
+ * either build reads, and the build that is not here reads none of it. Each
30
+ * chooser exports this name, so `index.ts` is the same file on both builds.
31
+ */
32
+ export type AuthPackageEnvironmentV1 = BetterAuthEnvironmentV1;
33
+
34
+ /** The sign-in Package this build deploys. */
35
+ export const AUTH_PACKAGE_V1: AuthPackageBuildV1<AuthPackageEnvironmentV1> =
36
+ BETTER_AUTH_PACKAGE_V1;
@@ -0,0 +1,107 @@
1
+ /**
2
+ * Disposable pre-user cleanup for the retired layered-sheep appearance.
3
+ *
4
+ * Directory registrations are retained so their conversations remain
5
+ * reachable. Only the incompatible appearance and receipts which fingerprinted
6
+ * it are replaced. Bot objects drop the old identity keys; the new identity is
7
+ * materialized from the cleaned registration on first use.
8
+ */
9
+
10
+ const USER_RECEIPT = "maintenance:avatar-cast:2026-09-16-v2";
11
+ const BOT_RECEIPT = "maintenance:avatar-cast:2026-09-16";
12
+ const DIRECTORY_KEY = "flock:directory:v1";
13
+
14
+ const pixel = { schemaVersion: 1, characterId: "pixel", primary: "#fc85ae" };
15
+
16
+ function layeredAvatar(value: unknown): boolean {
17
+ if (typeof value !== "object" || value === null || Array.isArray(value)) {
18
+ return false;
19
+ }
20
+ const avatar = value as Record<string, unknown>;
21
+ return (
22
+ typeof avatar.background === "string" ||
23
+ typeof avatar.upper === "string" ||
24
+ typeof avatar.middle === "string" ||
25
+ typeof avatar.lower === "string"
26
+ );
27
+ }
28
+
29
+ export async function cleanUserAvatarTestState(
30
+ storage: DurableObjectStorage,
31
+ ): Promise<void> {
32
+ await storage.transaction(async (tx) => {
33
+ if (await tx.get(USER_RECEIPT)) return;
34
+ const stored = await tx.get<unknown>(DIRECTORY_KEY);
35
+ let replaced = 0;
36
+ if (
37
+ typeof stored === "object" &&
38
+ stored !== null &&
39
+ !Array.isArray(stored)
40
+ ) {
41
+ const directory = stored as Record<string, unknown>;
42
+ if (Array.isArray(directory.bots)) {
43
+ const bots = directory.bots.map((entry) => {
44
+ if (
45
+ typeof entry !== "object" ||
46
+ entry === null ||
47
+ Array.isArray(entry)
48
+ ) {
49
+ return entry;
50
+ }
51
+ const registration = entry as Record<string, unknown>;
52
+ const carriedSheep = Object.hasOwn(registration, "sheep");
53
+ if (!carriedSheep && !layeredAvatar(registration.avatar))
54
+ return entry;
55
+ replaced += 1;
56
+ const { sheep: _retired, ...current } = registration;
57
+ return {
58
+ ...current,
59
+ avatar: layeredAvatar(registration.avatar)
60
+ ? { ...pixel }
61
+ : (registration.avatar ?? { ...pixel }),
62
+ };
63
+ });
64
+ if (replaced > 0) await tx.put(DIRECTORY_KEY, { ...directory, bots });
65
+ }
66
+ }
67
+ let deletedReceipts = 0;
68
+ for (;;) {
69
+ const keys = [
70
+ ...(
71
+ await tx.list({ prefix: "flock:create-receipt:", limit: 128 })
72
+ ).keys(),
73
+ ];
74
+ if (!keys.length) break;
75
+ deletedReceipts += await tx.delete(keys);
76
+ }
77
+ await tx.put(USER_RECEIPT, {
78
+ schemaVersion: 1,
79
+ replaced,
80
+ deletedReceipts,
81
+ completedAt: new Date().toISOString(),
82
+ });
83
+ });
84
+ }
85
+
86
+ export async function cleanBotAvatarTestState(
87
+ storage: DurableObjectStorage,
88
+ ): Promise<void> {
89
+ await storage.transaction(async (tx) => {
90
+ if (await tx.get(BOT_RECEIPT)) return;
91
+ let deleted = (await tx.delete("flock:sheep:v1")) ? 1 : 0;
92
+ for (;;) {
93
+ const keys = [
94
+ ...(
95
+ await tx.list({ prefix: "flock:sheep-receipt:", limit: 128 })
96
+ ).keys(),
97
+ ];
98
+ if (!keys.length) break;
99
+ deleted += await tx.delete(keys);
100
+ }
101
+ await tx.put(BOT_RECEIPT, {
102
+ schemaVersion: 1,
103
+ deleted,
104
+ completedAt: new Date().toISOString(),
105
+ });
106
+ });
107
+ }
@@ -0,0 +1,267 @@
1
+ import {
2
+ BillingError,
3
+ BILLING_PLAN,
4
+ type UsageAttributionV1,
5
+ } from "@frockbot/app/billing/ledger";
6
+ import {
7
+ COMPUTER_TARIFF,
8
+ computerChargeMicros,
9
+ computerCostMicros,
10
+ } from "@frockbot/app/billing/computer";
11
+ import {
12
+ decodeComputerHostHttpRequestV1,
13
+ encodeComputerHostRequestV1,
14
+ problem,
15
+ type ComputerHostOperationV1,
16
+ type ComputerHostRequestV1,
17
+ } from "@frockbot/computer/host-protocol";
18
+ import type { BillingAccountRpc } from "./billing.js";
19
+
20
+ /**
21
+ * Fixed launch tariff for Computers, in micro-US-dollars per active hour.
22
+ *
23
+ * Fly exposes 8 vCPU, 16 GB RAM and 100 GB storage to one Computer. At the
24
+ * published rates that ceiling costs US$1.3283/hour while active. Two times
25
+ * the ceiling is US$2.6566; the public rate rounds up so an unmetered Computer
26
+ * cannot turn unusually heavy use into a loss.
27
+ */
28
+ export const COMPUTER_ACTIVE_MICROS_PER_HOUR =
29
+ COMPUTER_TARIFF.activeMicrosPerHour;
30
+ export const COMPUTER_RATE_DESCRIPTION = "Computer · US$2.75 per active hour";
31
+
32
+ const SECOND_MS = 1_000;
33
+ const VIEWER_OPEN_MS = COMPUTER_TARIFF.viewerOpenSeconds * SECOND_MS;
34
+ const VIEWER_RENEW_MS = COMPUTER_TARIFF.viewerRenewSeconds * SECOND_MS;
35
+ const DEFAULT_MAXIMUM_MS = 60_000;
36
+ const OPEN_MAXIMUM_MS = 10 * 60_000;
37
+ const SERVICE_MAXIMUM_MS = 120_000;
38
+ const EXEC_SETTLEMENT_GRACE_MS = 6_000;
39
+ /** A checkpoint or a restore, which the host bounds at four minutes. */
40
+ const CHECKPOINT_MAXIMUM_MS = 5 * 60_000;
41
+ /** A sign-in restore may start the browser before it writes. */
42
+ const LOGINS_MAXIMUM_MS = 2 * 60_000;
43
+ /** A replacement discards the machine and waits for its name to free. */
44
+ const REPLACE_MAXIMUM_MS = 3 * 60_000;
45
+
46
+ function maximumDuration(operation: ComputerHostOperationV1): number {
47
+ if (operation.kind === "exec")
48
+ return operation.timeoutMs + EXEC_SETTLEMENT_GRACE_MS;
49
+ if (operation.kind === "open") return OPEN_MAXIMUM_MS;
50
+ if (operation.kind === "service") return SERVICE_MAXIMUM_MS;
51
+ if (operation.kind === "checkpoint") return CHECKPOINT_MAXIMUM_MS;
52
+ if (operation.kind === "logins") return LOGINS_MAXIMUM_MS;
53
+ if (operation.kind === "replace") return REPLACE_MAXIMUM_MS;
54
+ if (operation.kind === "viewer")
55
+ return operation.action === "open"
56
+ ? VIEWER_OPEN_MS
57
+ : operation.action === "renew"
58
+ ? VIEWER_RENEW_MS
59
+ : 0;
60
+ return DEFAULT_MAXIMUM_MS;
61
+ }
62
+
63
+ function billedDuration(
64
+ operation: ComputerHostOperationV1,
65
+ elapsedMs: number,
66
+ granted = true,
67
+ ): number {
68
+ if (operation.kind === "viewer" && granted) {
69
+ if (operation.action === "open") return VIEWER_OPEN_MS;
70
+ if (operation.action === "renew") return VIEWER_RENEW_MS;
71
+ }
72
+ return Math.max(SECOND_MS, Math.ceil(elapsedMs / SECOND_MS) * SECOND_MS);
73
+ }
74
+
75
+ function cleanupOperation(operation: ComputerHostOperationV1): boolean {
76
+ return (
77
+ operation.kind === "cancel" ||
78
+ (operation.kind === "viewer" && operation.action === "revoke") ||
79
+ (operation.kind === "control" && operation.action === "release")
80
+ );
81
+ }
82
+
83
+ function revokeViewerRequest(
84
+ request: Request,
85
+ decoded: ComputerHostRequestV1,
86
+ ): Request | undefined {
87
+ if (
88
+ decoded.operation.kind !== "viewer" ||
89
+ decoded.operation.action !== "renew" ||
90
+ !decoded.operation.sessionId
91
+ )
92
+ return;
93
+ return new Request(request.url, {
94
+ method: request.method,
95
+ headers: request.headers,
96
+ body: JSON.stringify(
97
+ encodeComputerHostRequestV1({
98
+ ...decoded,
99
+ operation: {
100
+ kind: "viewer",
101
+ action: "revoke",
102
+ sessionId: decoded.operation.sessionId,
103
+ },
104
+ }),
105
+ ),
106
+ });
107
+ }
108
+
109
+ async function responseWithSettlement(
110
+ response: Response,
111
+ settle: () => Promise<void>,
112
+ ): Promise<Response> {
113
+ if (!response.body) {
114
+ await settle();
115
+ return response;
116
+ }
117
+ const reader = response.body.getReader();
118
+ let settled = false;
119
+ const once = async () => {
120
+ if (settled) return;
121
+ settled = true;
122
+ await settle();
123
+ };
124
+ return new Response(
125
+ new ReadableStream<Uint8Array>({
126
+ async pull(controller) {
127
+ try {
128
+ const next = await reader.read();
129
+ if (!next.done) {
130
+ controller.enqueue(next.value);
131
+ return;
132
+ }
133
+ await once();
134
+ controller.close();
135
+ } catch (error) {
136
+ await once().catch(() => undefined);
137
+ controller.error(error);
138
+ }
139
+ },
140
+ async cancel(reason) {
141
+ try {
142
+ await reader.cancel(reason);
143
+ } finally {
144
+ await once();
145
+ }
146
+ },
147
+ }),
148
+ {
149
+ status: response.status,
150
+ statusText: response.statusText,
151
+ headers: response.headers,
152
+ },
153
+ );
154
+ }
155
+
156
+ /**
157
+ * What a Computer charge is recorded against. Watching or taking control is
158
+ * the person at the Computer, whatever the Bot is doing meanwhile; anything
159
+ * else is the Turn the Bot is running, when it is running one.
160
+ */
161
+ export type ComputerSpendV1 = (
162
+ botId: string,
163
+ personal: boolean,
164
+ ) => Promise<UsageAttributionV1 | undefined>;
165
+
166
+ function personalOperation(operation: ComputerHostOperationV1): boolean {
167
+ return operation.kind === "viewer" || operation.kind === "control";
168
+ }
169
+
170
+ /** Authorize and settle fixed-rate Computer time at the existing host seam. */
171
+ export function prepaidComputerHost(
172
+ host: Fetcher,
173
+ account: (userId: string) => BillingAccountRpc,
174
+ now: () => number = Date.now,
175
+ spend?: ComputerSpendV1,
176
+ ): Fetcher {
177
+ return new Proxy(host, {
178
+ get(target, property) {
179
+ if (property !== "fetch") {
180
+ const value = Reflect.get(target, property);
181
+ return typeof value === "function" ? value.bind(target) : value;
182
+ }
183
+ return async (request: Request): Promise<Response> => {
184
+ const decoded = await decodeComputerHostHttpRequestV1(
185
+ request.clone() as unknown as Parameters<
186
+ typeof decodeComputerHostHttpRequestV1
187
+ >[0],
188
+ );
189
+ if (!decoded.ok) return decoded.response;
190
+ const { operation, identity, tenant, effectId } = decoded.value;
191
+ if (cleanupOperation(operation)) return target.fetch(request);
192
+
193
+ const billing = account(identity.userId);
194
+ // Attribution never stops the Computer.
195
+ const attribution = await spend?.(
196
+ tenant.botId,
197
+ personalOperation(operation),
198
+ ).catch(() => undefined);
199
+ let reservation:
200
+ | {
201
+ status: "reserved" | "settled" | "released";
202
+ created: boolean;
203
+ }
204
+ | undefined;
205
+ try {
206
+ reservation = await billing.reserveUsage({
207
+ userId: identity.userId,
208
+ reservation: {
209
+ id: `computer:${effectId}`,
210
+ kind: "computer",
211
+ maximumMicros: computerChargeMicros(maximumDuration(operation)),
212
+ botId: tenant.botId,
213
+ description: COMPUTER_RATE_DESCRIPTION,
214
+ pricingVersion: BILLING_PLAN.pricingVersion,
215
+ unitRates: {
216
+ activeMicrosPerHour: COMPUTER_ACTIVE_MICROS_PER_HOUR,
217
+ },
218
+ ...(attribution ? { attribution } : {}),
219
+ },
220
+ });
221
+ } catch (error) {
222
+ const revoke = revokeViewerRequest(request, decoded.value);
223
+ if (revoke) await target.fetch(revoke).catch(() => undefined);
224
+ return problem(
225
+ error instanceof BillingError ? error.status : 402,
226
+ "not-authorized",
227
+ error instanceof Error
228
+ ? error.message
229
+ : "Open Billing to check your subscription and balance.",
230
+ );
231
+ }
232
+
233
+ const started = now();
234
+ const settle = async (granted = true) => {
235
+ if (!reservation?.created) return;
236
+ const durationMs = billedDuration(
237
+ operation,
238
+ now() - started,
239
+ granted,
240
+ );
241
+ const chargeMicros = computerChargeMicros(durationMs);
242
+ await billing.settleUsage({
243
+ userId: identity.userId,
244
+ settlement: {
245
+ id: `computer:${effectId}`,
246
+ costMicros: computerCostMicros(durationMs),
247
+ chargeMicros,
248
+ quantities: { activeSeconds: durationMs / SECOND_MS },
249
+ },
250
+ });
251
+ };
252
+
253
+ // A thrown service-binding failure does not say whether the host ran
254
+ // the effect. Keep that reservation for evidence-based reconciliation
255
+ // instead of inventing either a charge or a refund.
256
+ const response = await target.fetch(request);
257
+ const streaming =
258
+ operation.kind === "exec"
259
+ ? operation.stream
260
+ : operation.kind === "open" && operation.stream;
261
+ if (streaming) return await responseWithSettlement(response, settle);
262
+ await settle(response.ok);
263
+ return response;
264
+ };
265
+ },
266
+ });
267
+ }
@@ -0,0 +1,21 @@
1
+ /** Remove each blocker only with its implemented and verified replacement. */
2
+ export const BILLING_LAUNCH_BLOCKERS: readonly string[] = [];
3
+
4
+ export interface BillingSwitchEnv {
5
+ STRIPE_SECRET_KEY?: string;
6
+ BETTER_AUTH_URL?: string;
7
+ }
8
+
9
+ /**
10
+ * Billing is switched off until Stripe is configured. Without
11
+ * `STRIPE_SECRET_KEY` a deployment meters nothing and requires no
12
+ * subscription, so a release can ship the billing code while the account has
13
+ * no customers. A local origin stays off even with a key, so a copied
14
+ * `.dev.vars.example` cannot bill a developer.
15
+ */
16
+ export function hostedBillingEnabledV1(env: BillingSwitchEnv): boolean {
17
+ if (!env.STRIPE_SECRET_KEY?.trim() || !env.BETTER_AUTH_URL) return false;
18
+ return !/^(localhost|127\.0\.0\.1|\[::1\])$/.test(
19
+ new URL(env.BETTER_AUTH_URL).hostname,
20
+ );
21
+ }