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