@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
@@ -0,0 +1,689 @@
1
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
+ import { dirname, isAbsolute, join, relative, resolve } from "node:path";
3
+ import { parseJsoncV1 } from "./jsonc.ts";
4
+ import {
5
+ decodeBrandV1,
6
+ isBuiltInAuthPackageIdV1,
7
+ type AuthPackageSettingV1,
8
+ type BuiltInAuthPackageIdV1,
9
+ } from "@frockbot/core/contracts";
10
+ import {
11
+ PACKAGE_ROOT_V1,
12
+ type DeploymentProfileV1,
13
+ type DeploymentWorkerV1,
14
+ } from "./profile.ts";
15
+
16
+ /**
17
+ * The deployables, and where each one's tracked template lives, relative to
18
+ * the directory this package sits in: `apps/` in the FrockBot repository.
19
+ *
20
+ * Only the app Worker is `@frockbot/cloudflare` itself, and so the only one a
21
+ * consumer's `node_modules` carries. The others are read from beside it when
22
+ * they are there, which they are in a FrockBot checkout; a profile outside it
23
+ * that names one is refused with the reason (see `readTemplateV1`).
24
+ */
25
+ export const DEPLOYABLE_WORKERS_V1 = {
26
+ app: { directory: "cloudflare" },
27
+ computerHost: { directory: "computer-host" },
28
+ appletBuild: { directory: "applet-build" },
29
+ marketing: { directory: "marketing" },
30
+ adminPortal: { directory: "admin-portal" },
31
+ } as const;
32
+
33
+ export type DeployableWorkerV1 = keyof typeof DEPLOYABLE_WORKERS_V1;
34
+
35
+ /** The directory one Worker's generated config is written to, under a profile. */
36
+ export const WORKER_OUTPUT_DIRECTORIES_V1: Record<DeployableWorkerV1, string> =
37
+ {
38
+ app: "app",
39
+ computerHost: "computer-host",
40
+ appletBuild: "applet-build",
41
+ marketing: "marketing",
42
+ adminPortal: "admin-portal",
43
+ };
44
+
45
+ /**
46
+ * The image `release.yml` publishes for each container Worker. One name per
47
+ * image rather than per deployment: every deployment that pulls rather than
48
+ * builds pulls the same bytes for a given tag.
49
+ */
50
+ export const CONTAINER_IMAGE_REPOSITORIES_V1: Partial<
51
+ Record<DeployableWorkerV1, string>
52
+ > = {
53
+ computerHost: "frockbot-computer-host",
54
+ appletBuild: "frockbot-applet-build",
55
+ };
56
+
57
+ /**
58
+ * Where `release.yml` pushes them, and so the default an installer writes into
59
+ * a profile. Docker Hub because Cloudflare pulls a *public* image from it with
60
+ * no credentials configured in the pulling account, which is the only registry
61
+ * of the four it supports where that is true (see the README).
62
+ */
63
+ export const PUBLISHED_IMAGE_REGISTRY_V1 = "docker.io/timoconnellaus";
64
+
65
+ /**
66
+ * The specifier the Worker imports its sign-in Package through.
67
+ *
68
+ * `apps/cloudflare/package.json` maps it to the better-auth chooser, which is
69
+ * what `wrangler dev`, every suite and the hosted deploy resolve. An `access`
70
+ * profile's generated config aliases it to the other chooser, and a profile
71
+ * that names a chooser by path — a white-label's own Package (ADR 0038 §3) —
72
+ * aliases it to that file, so the build a deployment ships is decided by its
73
+ * own config and no bundle carries a Package it did not choose. A bare
74
+ * specifier rather than a relative path because esbuild — which is what
75
+ * wrangler's `alias` reaches — refuses to alias a relative import.
76
+ */
77
+ const AUTH_PACKAGE_ALIAS_V1 = "#auth-package";
78
+
79
+ /** The build the tracked source already resolves to, so no alias is written. */
80
+ const TRACKED_AUTH_PACKAGE_V1 = "better-auth";
81
+
82
+ /** The chooser each built-in auth Package's build resolves that specifier to. */
83
+ export const AUTH_PACKAGE_CHOOSERS_V1: Record<BuiltInAuthPackageIdV1, string> =
84
+ {
85
+ "better-auth": "./src/auth-package.ts",
86
+ access: "./src/auth-package.access.ts",
87
+ };
88
+
89
+ /**
90
+ * The specifier the Worker imports its brand through
91
+ * ([ADR 0038](../../../docs/adr/0038-white-label-deployments.md)).
92
+ *
93
+ * The same shape as `#auth-package`: `apps/cloudflare/package.json` maps it to
94
+ * FrockBot's brand, and a profile that names its own brand module gets an
95
+ * alias to that file. No alias is written otherwise, so the hosted and staging
96
+ * configs are unchanged by the seam.
97
+ */
98
+ export const BRAND_ALIAS_V1 = "#brand";
99
+
100
+ /**
101
+ * Config values that name a file or directory. `wrangler -c <path>` resolves
102
+ * each one against the config's own directory, so a generated config that
103
+ * copied them verbatim would look for `src/index.ts` inside `.deployment/`.
104
+ */
105
+ const PATH_FIELDS_V1 = [
106
+ ["$schema"],
107
+ ["main"],
108
+ ["assets", "directory"],
109
+ // Written below as the app template's own relative path, then rewritten with
110
+ // every other path so wrangler resolves it from `.deployment/` too.
111
+ ["alias", AUTH_PACKAGE_ALIAS_V1],
112
+ ["alias", BRAND_ALIAS_V1],
113
+ ] as const;
114
+
115
+ export interface GenerateOptionsV1 {
116
+ profile: DeploymentProfileV1;
117
+ /**
118
+ * The `AUTH_DB` identifier when the profile leaves it to the deploy: a
119
+ * disposable stage creates its database in the same job that deploys it.
120
+ */
121
+ d1DatabaseId?: string;
122
+ /**
123
+ * The sha256 of the application artifact this deployment uploaded, which is
124
+ * the R2 key it is stored under. Resolved by whoever uploads it — the release
125
+ * job, or the installer for the tag it downloaded — so it is a flag rather
126
+ * than a profile field, the way `d1DatabaseId` is.
127
+ */
128
+ applicationHash?: string;
129
+ /**
130
+ * Where the profile file lives, which a `brand`, `webClient` or auth
131
+ * Package path is relative to.
132
+ */
133
+ profileDirectory: string;
134
+ /** Where `<name>/<worker>/wrangler.jsonc` is written: a `.deployment/`. */
135
+ outputRoot: string;
136
+ }
137
+
138
+ export interface GeneratedConfigV1 {
139
+ worker: DeployableWorkerV1;
140
+ /** Where the config was written, or would be. */
141
+ file: string;
142
+ config: Record<string, unknown>;
143
+ }
144
+
145
+ function derivedWorkerNameV1(
146
+ worker: DeployableWorkerV1,
147
+ profile: DeploymentProfileV1,
148
+ ): string {
149
+ const configured = profile.workers?.[worker]?.name;
150
+ if (configured) return configured;
151
+ switch (worker) {
152
+ case "app":
153
+ return profile.prefix;
154
+ case "computerHost":
155
+ return `${profile.prefix}-computer-host`;
156
+ case "appletBuild":
157
+ return `${profile.prefix}-applet-build`;
158
+ case "marketing":
159
+ return `${profile.prefix}-marketing`;
160
+ case "adminPortal":
161
+ return `${profile.prefix}-admin-portal`;
162
+ }
163
+ }
164
+
165
+ /**
166
+ * The bucket, index and database names this profile binds.
167
+ *
168
+ * Exported because the installer creates and writes to exactly these, and a
169
+ * second derivation of the same names is a bucket nothing opens.
170
+ */
171
+ export function resourceNamesV1(profile: DeploymentProfileV1) {
172
+ const named = profile.resources ?? {};
173
+ return {
174
+ applicationArtifactsBucket:
175
+ named.applicationArtifactsBucket ??
176
+ `${profile.prefix}-application-artifacts`,
177
+ memoryFilesBucket:
178
+ named.memoryFilesBucket ?? `${profile.prefix}-memory-files`,
179
+ memoryIndex: named.memoryIndex ?? `${profile.prefix}-memory`,
180
+ authDatabaseName: named.authDatabaseName ?? `${profile.prefix}-auth`,
181
+ };
182
+ }
183
+
184
+ /** Which Workers a profile deploys: the ones it names. */
185
+ export function profileWorkersV1(
186
+ profile: DeploymentProfileV1,
187
+ ): DeployableWorkerV1[] {
188
+ const named = profile.workers ?? {};
189
+ return (Object.keys(DEPLOYABLE_WORKERS_V1) as DeployableWorkerV1[]).filter(
190
+ (worker) => named[worker] !== undefined,
191
+ );
192
+ }
193
+
194
+ function readTemplateV1(workersRoot: string, worker: DeployableWorkerV1) {
195
+ const directory = join(workersRoot, DEPLOYABLE_WORKERS_V1[worker].directory);
196
+ const file = join(directory, "wrangler.jsonc");
197
+ if (!existsSync(file)) {
198
+ throw new Error(
199
+ `${worker} has no tracked wrangler config at ${file}. Only the app Worker is published as @frockbot/cloudflare; ` +
200
+ "deploy the other Workers from a FrockBot checkout of the same release.",
201
+ );
202
+ }
203
+ const config = parseJsoncV1(readFileSync(file, "utf8"), file);
204
+ if (typeof config !== "object" || config === null || Array.isArray(config)) {
205
+ throw new Error(`${file} is not a wrangler config`);
206
+ }
207
+ return { directory, config: config as Record<string, unknown> };
208
+ }
209
+
210
+ function entryAt(
211
+ config: Record<string, unknown>,
212
+ path: readonly string[],
213
+ ): { holder: Record<string, unknown>; key: string } | undefined {
214
+ let holder: Record<string, unknown> = config;
215
+ for (const segment of path.slice(0, -1)) {
216
+ const next = holder[segment];
217
+ if (typeof next !== "object" || next === null || Array.isArray(next)) {
218
+ return undefined;
219
+ }
220
+ holder = next as Record<string, unknown>;
221
+ }
222
+ const key = path[path.length - 1]!;
223
+ return key in holder ? { holder, key } : undefined;
224
+ }
225
+
226
+ /**
227
+ * Rewrite every path in a config so it resolves from `to` the same place it
228
+ * resolved from `from`. A path already absolute is left alone.
229
+ */
230
+ function rewritePathsV1(
231
+ config: Record<string, unknown>,
232
+ from: string,
233
+ to: string,
234
+ ): void {
235
+ const rewrite = (value: unknown): string | undefined => {
236
+ if (typeof value !== "string" || value === "" || isAbsolute(value)) {
237
+ return undefined;
238
+ }
239
+ return relative(to, resolve(from, value)) || ".";
240
+ };
241
+ for (const path of PATH_FIELDS_V1) {
242
+ const entry = entryAt(config, path);
243
+ if (!entry) continue;
244
+ const next = rewrite(entry.holder[entry.key]);
245
+ if (next === undefined) continue;
246
+ // esbuild reads an alias target without a leading dot as a package name.
247
+ entry.holder[entry.key] =
248
+ path[0] === "alias" && !next.startsWith(".") ? `./${next}` : next;
249
+ }
250
+ for (const database of asArray(config.d1_databases)) {
251
+ const next = rewrite(database.migrations_dir);
252
+ if (next !== undefined) database.migrations_dir = next;
253
+ }
254
+ for (const container of asArray(config.containers)) {
255
+ for (const key of ["image", "image_build_context"] as const) {
256
+ const next = rewrite(container[key]);
257
+ if (next !== undefined) container[key] = next;
258
+ }
259
+ }
260
+ }
261
+
262
+ /**
263
+ * Point the container entries at the published image instead of the Dockerfile.
264
+ *
265
+ * Runs after the path rewrite, because a registry reference is not a path and
266
+ * resolving it against a directory would mangle it.
267
+ */
268
+ function applyPublishedImagesV1(
269
+ worker: DeployableWorkerV1,
270
+ config: Record<string, unknown>,
271
+ profile: DeploymentProfileV1,
272
+ ): void {
273
+ const images = profile.images;
274
+ if (!images || images.source === "dockerfile") return;
275
+ const containers = asArray(config.containers);
276
+ if (containers.length === 0) return;
277
+ const repository = CONTAINER_IMAGE_REPOSITORIES_V1[worker];
278
+ if (!repository) {
279
+ throw new Error(`${worker} fronts a container but publishes no image`);
280
+ }
281
+ for (const container of containers) {
282
+ container.image = `${images.registry}/${repository}:${images.tag}`;
283
+ // Nothing is built, so there is no context to build it in; wrangler refuses
284
+ // the pair.
285
+ delete container.image_build_context;
286
+ }
287
+ }
288
+
289
+ function asArray(value: unknown): Record<string, unknown>[] {
290
+ return Array.isArray(value)
291
+ ? value.filter(
292
+ (entry): entry is Record<string, unknown> =>
293
+ typeof entry === "object" && entry !== null && !Array.isArray(entry),
294
+ )
295
+ : [];
296
+ }
297
+
298
+ function bindingNamed(
299
+ entries: Record<string, unknown>[],
300
+ binding: string,
301
+ ): Record<string, unknown> | undefined {
302
+ return entries.find((entry) => entry.binding === binding);
303
+ }
304
+
305
+ /**
306
+ * The brand module a profile names, as an absolute path, or undefined for
307
+ * FrockBot's own.
308
+ */
309
+ export function profileBrandModuleV1(
310
+ profile: DeploymentProfileV1,
311
+ profileDirectory: string,
312
+ ): string | undefined {
313
+ return profile.brand === undefined
314
+ ? undefined
315
+ : resolve(profileDirectory, profile.brand);
316
+ }
317
+
318
+ /**
319
+ * Load the brand module a profile names and hold it to `BrandV1`: every look
320
+ * through the ThemeDocument decoder and its contrast floor, and the icon a
321
+ * file the artifact build can read. This is the build-time refusal of a brand
322
+ * that would ship an unreadable look.
323
+ */
324
+ export async function validateProfileBrandV1(
325
+ profile: DeploymentProfileV1,
326
+ profileDirectory: string,
327
+ ): Promise<void> {
328
+ const module = profileBrandModuleV1(profile, profileDirectory);
329
+ if (module === undefined) return;
330
+ if (!existsSync(module)) {
331
+ throw new Error(
332
+ `Profile "${profile.name}" names the brand ${profile.brand}, and there is no ${module}`,
333
+ );
334
+ }
335
+ const loaded = (await import(module)) as { BRAND_V1?: unknown };
336
+ const brand = decodeBrandV1(loaded.BRAND_V1);
337
+ const icon = resolve(dirname(module), brand.iconPng);
338
+ if (!existsSync(icon)) {
339
+ throw new Error(
340
+ `The brand at ${module} names the icon ${icon}, which does not exist`,
341
+ );
342
+ }
343
+ }
344
+
345
+ /**
346
+ * The chooser module a profile names by path, as an absolute path, or
347
+ * undefined for a built-in Package.
348
+ */
349
+ export function profileAuthChooserV1(
350
+ profile: DeploymentProfileV1,
351
+ profileDirectory: string,
352
+ ): string | undefined {
353
+ return isBuiltInAuthPackageIdV1(profile.authPackage)
354
+ ? undefined
355
+ : resolve(profileDirectory, profile.authPackage);
356
+ }
357
+
358
+ /** The shape of a chooser module, as far as the generator reads one. */
359
+ interface LoadedAuthChooserV1 {
360
+ AUTH_PACKAGE_V1?: {
361
+ id?: unknown;
362
+ required?: readonly AuthPackageSettingV1[];
363
+ nativeTokenSecret?: { name?: unknown };
364
+ };
365
+ }
366
+
367
+ /**
368
+ * What the production-secrets check requires of a deploy's auth Package, for
369
+ * a profile whose Package is not one this package ships: the chooser's own
370
+ * name and the secrets its profile says it needs. Undefined for a built-in
371
+ * Package, whose requirements the manifest already knows.
372
+ */
373
+ export async function profileAuthPackageV1(
374
+ profile: DeploymentProfileV1,
375
+ profileDirectory: string,
376
+ ): Promise<
377
+ { id: string; required: readonly AuthPackageSettingV1[] } | undefined
378
+ > {
379
+ const chooser = profileAuthChooserV1(profile, profileDirectory);
380
+ if (chooser === undefined) return undefined;
381
+ const id = (await loadAuthChooserV1(profile, chooser)).id;
382
+ return { id, required: profile.authEnvironment?.secrets ?? [] };
383
+ }
384
+
385
+ async function loadAuthChooserV1(
386
+ profile: DeploymentProfileV1,
387
+ chooser: string,
388
+ ): Promise<{ id: string; required: readonly AuthPackageSettingV1[] }> {
389
+ if (!existsSync(chooser)) {
390
+ throw new Error(
391
+ `Profile "${profile.name}" names the auth Package ${profile.authPackage}, and there is no ${chooser}`,
392
+ );
393
+ }
394
+ const build = ((await import(chooser)) as LoadedAuthChooserV1)
395
+ .AUTH_PACKAGE_V1;
396
+ if (
397
+ typeof build !== "object" ||
398
+ build === null ||
399
+ typeof build.id !== "string" ||
400
+ !Array.isArray(build.required)
401
+ ) {
402
+ throw new Error(
403
+ `${chooser} exports no AUTH_PACKAGE_V1 with an id and its required settings; an auth Package chooser exports what apps/cloudflare/src/auth-package.ts does`,
404
+ );
405
+ }
406
+ return { id: build.id, required: build.required };
407
+ }
408
+
409
+ /**
410
+ * Load the chooser a profile names by path and hold it to its profile: it
411
+ * must name itself something other than the two Packages this repository
412
+ * ships, and the settings it requires must be exactly the ones the profile
413
+ * says where to find — as secrets the release checks and deploys, or as vars
414
+ * the config carries. A setting in neither would reach production unset, and
415
+ * the Package would answer 503 to every sign-in.
416
+ */
417
+ export async function validateProfileAuthPackageV1(
418
+ profile: DeploymentProfileV1,
419
+ profileDirectory: string,
420
+ ): Promise<void> {
421
+ const chooser = profileAuthChooserV1(profile, profileDirectory);
422
+ if (chooser === undefined) return;
423
+ const build = await loadAuthChooserV1(profile, chooser);
424
+ if (isBuiltInAuthPackageIdV1(build.id)) {
425
+ throw new Error(
426
+ `${chooser} names itself "${build.id}", which is one of the Packages @frockbot/cloudflare ships; an external Package names itself`,
427
+ );
428
+ }
429
+ const environment = profile.authEnvironment ?? {};
430
+ const secrets = (environment.secrets ?? []).map((secret) => secret.name);
431
+ const vars = Object.keys(environment.vars ?? {});
432
+ const both = secrets.filter((name) => vars.includes(name));
433
+ if (both.length > 0) {
434
+ throw new Error(
435
+ `Profile "${profile.name}" names ${both.join(", ")} as both a secret and a var`,
436
+ );
437
+ }
438
+ const named = new Set([...secrets, ...vars]);
439
+ const required = new Set(build.required.map((setting) => setting.name));
440
+ const unnamed = [...required].filter((name) => !named.has(name));
441
+ const unread = [...named].filter((name) => !required.has(name));
442
+ if (unnamed.length > 0 || unread.length > 0) {
443
+ throw new Error(
444
+ [
445
+ `Profile "${profile.name}"'s authEnvironment must name exactly what ${chooser} requires.`,
446
+ ...(unnamed.length > 0
447
+ ? [`Required and not named: ${unnamed.join(", ")}.`]
448
+ : []),
449
+ ...(unread.length > 0
450
+ ? [`Named and not required: ${unread.join(", ")}.`]
451
+ : []),
452
+ ].join(" "),
453
+ );
454
+ }
455
+ }
456
+
457
+ function identityVarsV1(
458
+ worker: DeployableWorkerV1,
459
+ profile: DeploymentProfileV1,
460
+ ): Record<string, string> {
461
+ if (worker !== "app") return {};
462
+ const vars: Record<string, string> = {};
463
+ if (profile.nativeAuth) {
464
+ vars.NATIVE_SLICE_2_AUTH = profile.nativeAuth.join(",");
465
+ }
466
+ if (profile.aiGateway?.id) vars.FROCK_AI_GATEWAY_ID = profile.aiGateway.id;
467
+ if (profile.aiGateway) vars.FROCK_AI_ACCOUNT_ID = profile.aiGateway.accountId;
468
+ if (profile.aiGateway?.autoRoute) {
469
+ vars.FROCK_AI_AUTO_ROUTE = profile.aiGateway.autoRoute;
470
+ }
471
+ if (profile.access) {
472
+ vars.ACCESS_TEAM_DOMAIN = profile.access.teamDomain;
473
+ vars.ACCESS_AUD = profile.access.aud;
474
+ }
475
+ if (profile.email) vars.EMAIL_DOMAIN = profile.email.domain;
476
+ Object.assign(vars, profile.authEnvironment?.vars ?? {});
477
+ // A string rather than a JSON var, so it is read and decoded exactly like
478
+ // every other setting the Worker takes (`native-auth.ts`).
479
+ if (profile.nativeApps) vars.NATIVE_APPS = JSON.stringify(profile.nativeApps);
480
+ return vars;
481
+ }
482
+
483
+ /**
484
+ * The app Worker's outbound sender, Cloudflare Email Service, when the
485
+ * profile names the email domain (`app/email/sender.ts`).
486
+ *
487
+ * Every Bot sends from its own address on that domain, and the binding cannot
488
+ * say "any address on one domain": `allowed_sender_addresses` is a list of
489
+ * exact addresses, with no wildcard or domain form
490
+ * (developers.cloudflare.com/email-service/configuration/send-bindings/,
491
+ * read 2026-09-25). So the binding carries no sender restriction and the
492
+ * sender enforces the domain instead: it composes every `from` itself, on
493
+ * `EMAIL_DOMAIN`, and refuses any other before the binding is reached. The
494
+ * platform refuses any domain the account has not onboarded for Email
495
+ * Sending. No destination restriction either: a Bot writes to its owner, and a
496
+ * draft card to whoever the person approved.
497
+ */
498
+ function emailBindingsV1(
499
+ worker: DeployableWorkerV1,
500
+ profile: DeploymentProfileV1,
501
+ ): Record<string, unknown>[] {
502
+ if (worker !== "app" || !profile.email) return [];
503
+ return [{ name: "SEND_EMAIL" }];
504
+ }
505
+
506
+ export function generateWorkerConfigV1(
507
+ worker: DeployableWorkerV1,
508
+ options: GenerateOptionsV1,
509
+ ): GeneratedConfigV1 {
510
+ const { profile, outputRoot } = options;
511
+ // The directory this package sits in, where `DEPLOYABLE_WORKERS_V1` names
512
+ // each template.
513
+ const workersRoot = join(PACKAGE_ROOT_V1, "..");
514
+ const entry = profile.workers?.[worker];
515
+ if (!entry) {
516
+ throw new Error(`Profile "${profile.name}" does not deploy ${worker}`);
517
+ }
518
+ const template = readTemplateV1(workersRoot, worker);
519
+ const config = structuredClone(template.config);
520
+ const resources = resourceNamesV1(profile);
521
+
522
+ // The `development` and `e2e` environments belong to the tracked file, where
523
+ // `wrangler dev`, the suites and the harness read them. A generated config is
524
+ // deployed, and a named environment in it would be a second Worker nobody
525
+ // asked for.
526
+ delete config.env;
527
+
528
+ config.name = derivedWorkerNameV1(worker, profile);
529
+ config.account_id = profile.accountId;
530
+
531
+ const routes = entry.hostnames ?? [];
532
+ if (routes.length > 0) {
533
+ config.routes = routes.map((pattern) => ({
534
+ pattern,
535
+ custom_domain: true,
536
+ }));
537
+ } else {
538
+ delete config.routes;
539
+ // A Worker with no zone is reached on `workers.dev`. A template that has
540
+ // already decided — the Computer host and the build service are reachable
541
+ // only over a service binding — keeps its answer.
542
+ if (!("workers_dev" in config)) config.workers_dev = true;
543
+ }
544
+
545
+ const buckets = asArray(config.r2_buckets);
546
+ const artifacts = bindingNamed(buckets, "APPLICATION_ARTIFACTS");
547
+ if (artifacts) artifacts.bucket_name = resources.applicationArtifactsBucket;
548
+ const memoryFiles = bindingNamed(buckets, "MEMORY_FILES");
549
+ if (memoryFiles) memoryFiles.bucket_name = resources.memoryFilesBucket;
550
+
551
+ const index = bindingNamed(asArray(config.vectorize), "MEMORY_INDEX");
552
+ if (index) index.index_name = resources.memoryIndex;
553
+
554
+ const services = asArray(config.services);
555
+ const computerHost = bindingNamed(services, "COMPUTER_HOST");
556
+ if (computerHost) {
557
+ computerHost.service = derivedWorkerNameV1("computerHost", profile);
558
+ }
559
+ const appletBuild = bindingNamed(services, "APPLET_BUILD");
560
+ if (appletBuild) {
561
+ appletBuild.service = derivedWorkerNameV1("appletBuild", profile);
562
+ }
563
+ // The admin portal's one binding: the app Worker's `AdminEntrypoint`.
564
+ const app = bindingNamed(services, "APP");
565
+ if (app) app.service = derivedWorkerNameV1("app", profile);
566
+
567
+ const authDatabase = bindingNamed(asArray(config.d1_databases), "AUTH_DB");
568
+ if (authDatabase) {
569
+ const databaseId = options.d1DatabaseId ?? profile.d1DatabaseId;
570
+ if (
571
+ profile.authPackage === "access" ||
572
+ (profile.authPackage !== "better-auth" && databaseId === undefined)
573
+ ) {
574
+ // The Access Package stores nothing, so the deployment has no database to
575
+ // bind and the installer creates none. A white-label's Package gets the
576
+ // `AUTH_DB` binding only when its profile names a database for it.
577
+ delete config.d1_databases;
578
+ } else {
579
+ if (!databaseId) {
580
+ throw new Error(
581
+ `Profile "${profile.name}" builds better-auth but names no d1DatabaseId; pass --d1-database-id when the deploy creates it`,
582
+ );
583
+ }
584
+ authDatabase.database_name = resources.authDatabaseName;
585
+ authDatabase.database_id = databaseId;
586
+ }
587
+ }
588
+
589
+ if (worker === "app" && profile.authPackage !== TRACKED_AUTH_PACKAGE_V1) {
590
+ // Only when the profile builds another Package: the tracked source
591
+ // already resolves `#auth-package` to the default chooser, so the hosted
592
+ // and staging configs stay byte-for-byte what production runs and the
593
+ // equivalence gate has nothing new to approve.
594
+ const external = profileAuthChooserV1(profile, options.profileDirectory);
595
+ config.alias = {
596
+ ...((config.alias as Record<string, unknown>) ?? {}),
597
+ // Written relative to the template, like the brand below, and rewritten
598
+ // with every other path once the output directory is known.
599
+ [AUTH_PACKAGE_ALIAS_V1]:
600
+ external === undefined
601
+ ? AUTH_PACKAGE_CHOOSERS_V1[
602
+ profile.authPackage as BuiltInAuthPackageIdV1
603
+ ]
604
+ : relative(template.directory, external),
605
+ };
606
+ }
607
+
608
+ if (worker === "app" && profile.webClient !== undefined) {
609
+ // The white-label's own client, staged by `build-flutter-web.ts` from its
610
+ // own application; rewritten from the template's directory below.
611
+ config.assets = {
612
+ ...((config.assets as Record<string, unknown>) ?? {}),
613
+ directory: relative(
614
+ template.directory,
615
+ resolve(options.profileDirectory, profile.webClient),
616
+ ),
617
+ };
618
+ }
619
+
620
+ const brandModule =
621
+ worker === "app"
622
+ ? profileBrandModuleV1(profile, options.profileDirectory)
623
+ : undefined;
624
+ if (brandModule !== undefined) {
625
+ // Written relative to the template, like the auth alias above, and
626
+ // rewritten with every other path once the output directory is known.
627
+ config.alias = {
628
+ ...((config.alias as Record<string, unknown>) ?? {}),
629
+ [BRAND_ALIAS_V1]: relative(template.directory, brandModule),
630
+ };
631
+ }
632
+
633
+ const email = emailBindingsV1(worker, profile);
634
+ if (email.length > 0) {
635
+ config.send_email = [...asArray(config.send_email), ...email];
636
+ }
637
+
638
+ const identity = identityVarsV1(worker, profile);
639
+ if (Object.keys(identity).length > 0) {
640
+ config.vars = {
641
+ ...((config.vars as Record<string, unknown>) ?? {}),
642
+ ...identity,
643
+ };
644
+ }
645
+
646
+ if (worker === "app" && options.applicationHash) {
647
+ // The Worker loads its application from R2 under that file's own sha256, so
648
+ // the var has to name the artifact this deployment actually uploaded. The
649
+ // tracked placeholder `foundation-v1` is no object in anybody's bucket.
650
+ config.vars = {
651
+ ...((config.vars as Record<string, unknown>) ?? {}),
652
+ DEFAULT_APPLICATION_HASH: options.applicationHash,
653
+ };
654
+ }
655
+
656
+ const file = join(
657
+ outputRoot,
658
+ profile.name,
659
+ WORKER_OUTPUT_DIRECTORIES_V1[worker],
660
+ "wrangler.jsonc",
661
+ );
662
+ rewritePathsV1(config, template.directory, dirname(file));
663
+ applyPublishedImagesV1(worker, config, profile);
664
+ return { worker, file, config };
665
+ }
666
+
667
+ export function generateProfileConfigsV1(
668
+ options: GenerateOptionsV1,
669
+ ): GeneratedConfigV1[] {
670
+ return profileWorkersV1(options.profile).map((worker) =>
671
+ generateWorkerConfigV1(worker, options),
672
+ );
673
+ }
674
+
675
+ export function writeGeneratedConfigsV1(
676
+ generated: readonly GeneratedConfigV1[],
677
+ profileName: string,
678
+ ): void {
679
+ for (const { file, config } of generated) {
680
+ mkdirSync(dirname(file), { recursive: true });
681
+ writeFileSync(
682
+ file,
683
+ `// Generated by \`frockbot-deployment-config ${profileName}\`. Not tracked.\n` +
684
+ `// Deployment identity comes from \`deployments/${profileName}.json\`; everything\n` +
685
+ `// else is the tracked wrangler config this was derived from.\n` +
686
+ `${JSON.stringify(config, null, 2)}\n`,
687
+ );
688
+ }
689
+ }
@@ -0,0 +1,18 @@
1
+ /**
2
+ * Enough of JSONC for a wrangler config: `//` and block comments, and trailing
3
+ * commas. A real parser would be a dependency; these files use two extensions
4
+ * and both are removable before `JSON.parse`, which is the whole of it.
5
+ */
6
+ export function parseJsoncV1(source: string, what: string): unknown {
7
+ const withoutComments = source.replace(
8
+ /"(?:[^"\\]|\\.)*"|\/\/[^\n]*|\/\*[\s\S]*?\*\//g,
9
+ (match) => (match.startsWith('"') ? match : ""),
10
+ );
11
+ try {
12
+ return JSON.parse(withoutComments.replace(/,(\s*[}\]])/g, "$1")) as unknown;
13
+ } catch (error) {
14
+ throw new Error(
15
+ `${what} is not valid JSONC: ${error instanceof Error ? error.message : String(error)}`,
16
+ );
17
+ }
18
+ }