@frockbot/cloudflare 0.0.0 → 0.7.292

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (88) hide show
  1. package/build-artifact.ts +130 -0
  2. package/build-flutter-web.ts +508 -0
  3. package/deployment-config/README.md +414 -0
  4. package/deployment-config/cli.ts +228 -0
  5. package/deployment-config/generate.ts +689 -0
  6. package/deployment-config/jsonc.ts +18 -0
  7. package/deployment-config/profile-schema.generated.ts +391 -0
  8. package/deployment-config/profile.schema.json +358 -0
  9. package/deployment-config/profile.ts +124 -0
  10. package/migrations/0001_better_auth.sql +15 -0
  11. package/migrations/0002_drop_account_issuer.sql +8 -0
  12. package/package.json +61 -5
  13. package/release-version.ts +20 -0
  14. package/src/account-admission.ts +179 -0
  15. package/src/account-deletion.ts +274 -0
  16. package/src/admin-entrypoint.ts +194 -0
  17. package/src/admin-identities.ts +26 -0
  18. package/src/audit.ts +103 -0
  19. package/src/auth-package.access.ts +24 -0
  20. package/src/auth-package.ts +36 -0
  21. package/src/avatar-state-cleanup.ts +107 -0
  22. package/src/billing-computer.ts +267 -0
  23. package/src/billing-readiness.ts +21 -0
  24. package/src/billing.ts +461 -0
  25. package/src/bot-capabilities.ts +480 -0
  26. package/src/bot-recovery.ts +1 -0
  27. package/src/bot-state-channel.ts +702 -0
  28. package/src/bot-state.ts +3995 -0
  29. package/src/bot-template-cleanup.ts +98 -0
  30. package/src/bot-title-cleanup.ts +104 -0
  31. package/src/brand-icon.png +0 -0
  32. package/src/brand-logo.ts +2 -0
  33. package/src/brand.ts +36 -0
  34. package/src/client-compatibility.ts +51 -0
  35. package/src/compaction-announcement-cleanup.ts +113 -0
  36. package/src/computer-egress.ts +110 -0
  37. package/src/computer-host.ts +92 -0
  38. package/src/computer-screenshot-cleanup.ts +78 -0
  39. package/src/contracts.ts +832 -0
  40. package/src/debug.ts +272 -0
  41. package/src/default-packages-marker-cleanup.ts +39 -0
  42. package/src/deployment-policy-admin-host.ts +121 -0
  43. package/src/deployment-policy.ts +426 -0
  44. package/src/directory-profile-cleanup.ts +101 -0
  45. package/src/durable-rpc.ts +753 -0
  46. package/src/durable-session.ts +21 -0
  47. package/src/entry-boundary.ts +103 -0
  48. package/src/frock-ai.ts +338 -0
  49. package/src/gateway.ts +1392 -0
  50. package/src/group-chat.ts +736 -0
  51. package/src/hidden-bot-notifications-cleanup.ts +32 -0
  52. package/src/index.ts +2798 -0
  53. package/src/insights.ts +15 -0
  54. package/src/machine-messages-cleanup.ts +121 -0
  55. package/src/machine-socket.ts +164 -0
  56. package/src/memory-records.ts +181 -0
  57. package/src/memory.ts +145 -0
  58. package/src/model-rates.ts +252 -0
  59. package/src/native-auth.ts +1006 -0
  60. package/src/native-sessions.ts +121 -0
  61. package/src/notification-state-cleanup.ts +18 -0
  62. package/src/ollama-web-search-cleanup.ts +69 -0
  63. package/src/package-page-shapes-cleanup.ts +190 -0
  64. package/src/plugin-egress.ts +31 -0
  65. package/src/plugin-page-route.ts +72 -0
  66. package/src/plugin-panels-cleanup.ts +101 -0
  67. package/src/prepared-input-cleanup.ts +105 -0
  68. package/src/production-secrets.ts +484 -0
  69. package/src/project-cleanup.ts +123 -0
  70. package/src/project-events-cleanup.ts +152 -0
  71. package/src/publication-state-cleanup.ts +92 -0
  72. package/src/push.ts +498 -0
  73. package/src/request-body.ts +165 -0
  74. package/src/routine-state-cleanup.ts +57 -0
  75. package/src/search.ts +88 -0
  76. package/src/sidebar-label-cleanup.ts +165 -0
  77. package/src/skill-index-cleanup.ts +53 -0
  78. package/src/supersede-cleanup.ts +162 -0
  79. package/src/test-chat-cleanup.ts +93 -0
  80. package/src/uploads.ts +486 -0
  81. package/src/user-application.ts +1068 -0
  82. package/src/user-configuration.ts +4491 -0
  83. package/src/voice-assistant.ts +4999 -0
  84. package/src/voice-dictation.ts +743 -0
  85. package/src/working-context-cleanup.ts +96 -0
  86. package/src/workspace.ts +201 -0
  87. package/wrangler.jsonc +385 -0
  88. package/README.md +0 -3
@@ -0,0 +1,414 @@
1
+ # Deployment configs
2
+
3
+ Deployment identity — the Cloudflare account, the Worker names, the hostnames,
4
+ the resource names and the identity vars — lives in `deployments/<name>.json`.
5
+ The tracked `wrangler.jsonc` files hold bindings, migrations, the local
6
+ environments and their comments, and nothing that names a deployment.
7
+
8
+ ```
9
+ bun run deployment:config hosted
10
+ bun run deployment:config staging --d1-database-id <uuid>
11
+ bun run deployment:config simple --application-hash <sha256>
12
+ ```
13
+
14
+ writes `.deployment/<profile>/<worker>/wrangler.jsonc`, which is what every
15
+ `wrangler deploy -c`, `wrangler d1 migrations apply -c` and `wrangler r2 object
16
+ put -c` in `release.yml` and `main.yml` takes. `.deployment/` is git-ignored.
17
+
18
+ The generator is `@frockbot/cloudflare`'s, published with the Worker, and its
19
+ bin is `frockbot-deployment-config` (`cli.ts`, run by Bun).
20
+ `bun run deployment:config` is that bin over this repository's own
21
+ `deployments/` and `.deployment/`, wherever it is run from; a white-label runs
22
+ the bin itself, from its own repository (see [White-label](#white-label)).
23
+
24
+ `profile.schema.json`, beside the generator, is the contract. `ajv` refuses a
25
+ profile that does not meet it, so a missing account or a malformed hostname
26
+ fails before a config is written rather than during a deploy. The TypeScript
27
+ type is generated from the same document:
28
+ `scripts/generate-deployment-profile-schema.ts` writes
29
+ `profile-schema.generated.ts` as `FromSchema` with `parseIfThenElseKeywords`,
30
+ and `profile.ts` spells out the three auth Package cases on top of it, because
31
+ `FromSchema` reads a path-or-name `authPackage` as a plain `string` — so an
32
+ Access profile that names no Access application, or a chooser path with no
33
+ `authEnvironment`, is invalid at the type as well. `bun run typecheck` fails
34
+ when the generated file is stale.
35
+
36
+ Two values are flags rather than profile fields, because whoever deploys resolves
37
+ them in the same run: `--d1-database-id` for a disposable stage that creates its
38
+ database, and `--application-hash` for the sha256 of the application artifact the
39
+ deployer just uploaded, which is the R2 key the Worker loads it from. Without the
40
+ second, the config keeps the tracked placeholder `foundation-v1`, which is no
41
+ object in anybody's bucket.
42
+
43
+ ## Simple deployment
44
+
45
+ Nobody writes `deployments/simple.json` by hand. `bun run setup`
46
+ (`scripts/setup.ts`) does: it picks the account, asks for the hostname, the admin
47
+ emails and the Zero Trust team, writes the profile, runs this generator, creates
48
+ the buckets and the index, mints the internal secrets, asks for the Fly token the
49
+ Computer host needs, sets up the two Access applications — Allow on the app's
50
+ hostname, Bypass on `/api` — downloads the client and the application
51
+ artifact for the checked-out tag, and deploys the three Workers.
52
+ `bun run setup --dry-run` asks the same questions and then prints every command
53
+ and every value it would write, running no wrangler command and reaching no
54
+ network; add `--yes` to take the defaults instead of answering, which is how it
55
+ runs in a check. `scripts/setup-production.sh` is a different thing: it is the
56
+ hosted deployment's wizard, and it sets GitHub environment secrets for
57
+ `release.yml` rather than creating anything in Cloudflare.
58
+
59
+ The simple profile is the one that builds the Access auth Package, which the
60
+ generator writes as one `alias` entry:
61
+
62
+ ```json
63
+ "alias": { "#auth-package": "../../../apps/cloudflare/src/auth-package.access.ts" }
64
+ ```
65
+
66
+ `apps/cloudflare/package.json` maps `#auth-package` to
67
+ `src/auth-package.ts` — better-auth, the tracked default that `wrangler dev`, the
68
+ suites and the hosted deploy resolve — and that alias is what makes the deployed
69
+ bundle resolve the Access chooser instead. A bare specifier rather than a relative
70
+ path because esbuild, which is what wrangler's `alias` reaches, refuses to alias a
71
+ relative import. Nothing is written for a `better-auth` profile: the tracked
72
+ source already resolves to it, so the hosted and staging configs stay byte-for-byte
73
+ what production runs. `apps/cloudflare/tsconfig.access.json` type-checks the whole
74
+ Worker against the other chooser, so an `env` name only the hosted build has
75
+ cannot reach the Access build unnoticed.
76
+
77
+ Five deployables: the app Worker, the Computer host, the Plugin build service,
78
+ the marketing site and the admin portal. A profile generates exactly the ones it
79
+ names, which is how `staging.json` has neither the marketing site nor the portal,
80
+ and how `simple.json` has neither either: with Access deciding admission there is
81
+ no admin operation left to administer.
82
+
83
+ ## What a generated config is
84
+
85
+ The tracked file, with identity applied:
86
+
87
+ | Tracked | Generated |
88
+ | ----------------------------------------- | --------------------------------------------------------------------------------------------------------- |
89
+ | no `account_id` | the profile's account |
90
+ | no `routes` | the Worker's hostnames as custom domains, or `workers.dev` |
91
+ | bindings with no bucket, index or db name | the profile's resource names, derived from `prefix` |
92
+ | `services` with no target | the profile's own Worker names: the Computer host, the build service, and the app Worker the portal binds |
93
+ | `vars` without identity | plus the identity vars below |
94
+ | `containers[].image` a Dockerfile path | the published image, when the profile's `images.source` is `registry` |
95
+ | no `send_email` | the app Worker's `SEND_EMAIL` sender, when the profile names an `email` domain (below) |
96
+ | no `alias` | `#auth-package` for an `access` profile or a chooser path, `#brand` for a profile that names a `brand` |
97
+ | `env.development`, `env.e2e` | dropped — a named environment in a deployed config is a second Worker |
98
+
99
+ `assets.directory` becomes the profile's `webClient`, relative to the profile,
100
+ when it names one: a white-label's own staged client (below).
101
+
102
+ The identity vars the app Worker gains: `NATIVE_SLICE_2_AUTH` (the profile's
103
+ `nativeAuth` list, comma-joined),
104
+ `FROCK_AI_GATEWAY_ID`, `FROCK_AI_ACCOUNT_ID`, `FROCK_AI_AUTO_ROUTE`, `ACCESS_TEAM_DOMAIN`/`ACCESS_AUD` when the profile
105
+ builds the Access auth Package, and `EMAIL_DOMAIN` when it names an `email`
106
+ domain (below), and `NATIVE_APPS` — the profile's `nativeApps` as JSON — when it
107
+ names the signed apps its association files list, and the `authEnvironment.vars`
108
+ of a profile whose auth Package is its own (below). `FROCK_AI_ACCOUNT_ID` is what selects the compat
109
+ HTTP transport, the only one that accepts a `dynamic/<route>` model
110
+ (cloudflare/ai#617); a profile with no `aiGateway` takes the `AI` binding, where
111
+ Auto resolves to a concrete Workers AI model instead.
112
+
113
+ `-c` changes the directory wrangler resolves relative paths against, so `main`,
114
+ `assets.directory`, `migrations_dir`, `$schema` and the containers' `image` and
115
+ `image_build_context` are rewritten to point from the written location at the
116
+ same files they pointed at before. `deployment-config.test.ts` resolves both
117
+ sides and compares the targets, so a rewrite that drifts fails there.
118
+
119
+ The tracked `name` stays: it is the name `wrangler dev` and the e2e harness give
120
+ the local Worker, and every generated config overrides it from the profile.
121
+
122
+ Some fields keep a placeholder rather than nothing: wrangler's validator refuses a `services` entry with no target and a `vectorize` entry with no index even in a config it never deploys, so the app Worker's two services and its Vectorize binding, and the admin portal's one service, all say `named-by-deployment-config`. Nothing reads those values — `wrangler dev --env development` and the `e2e` harness resolve their own environments, and the generator writes the deployment's own names. A bucket or database name is left out entirely, because wrangler does not ask for one.
123
+
124
+ `adminEmails` is in the schema and in no generated config. It is the
125
+ `FROCKBOT_ADMIN_EMAILS` secret the installer sets; the hosted deployment already
126
+ carries it as a repository secret, which is why `hosted.json` omits it.
127
+
128
+ ## Brand
129
+
130
+ What a person sees — the product's name, the built-in model's name, the
131
+ homepage outbound requests point back to, the icon and page logo, the palettes
132
+ behind the named looks and whether What's New is served — is a `BrandV1`
133
+ (`core/contracts/brand.ts`), chosen at build time the way the auth Package is
134
+ ([ADR 0038](../../../docs/adr/0038-white-label-deployments.md)). The Worker imports
135
+ it through `#brand`, which `apps/cloudflare/package.json` maps to FrockBot's own,
136
+ `apps/cloudflare/src/brand.ts`, and hands it to app code as data. A profile that
137
+ names another module, relative to the profile file:
138
+
139
+ ```json
140
+ "brand": "./wallet-pal/brand.ts"
141
+ ```
142
+
143
+ gets a generated `alias` for `#brand` to it, and the generator imports it and
144
+ refuses a brand whose looks fail the ThemeDocument decoder or contrast floor, or
145
+ whose icon is not there. The hosted and staging profiles name none, so their
146
+ configs carry no alias.
147
+
148
+ The application artifact is bundled by `apps/cloudflare/build-artifact.ts`, not
149
+ by wrangler, so the alias never reaches it. Build it with the same module:
150
+
151
+ ```
152
+ bun run apps/cloudflare/build-artifact.ts --brand deployments/wallet-pal/brand.ts
153
+ ```
154
+
155
+ Without `--brand` it resolves `#brand` through the package import, which is
156
+ FrockBot's. Its icon, `src/brand-icon.png`, is a copy of
157
+ `assets/marketing/app-icon/frockbot-icon-64.png` kept inside the package so the
158
+ published default builds too; `src/brand.test.ts` holds the two to the same
159
+ bytes.
160
+
161
+ Where a deployment runs and which native apps sign in to it are the profile's
162
+ (`nativeApps`), not the brand's.
163
+
164
+ ## Email
165
+
166
+ Email to and from Bots is off until a profile names one domain for both
167
+ directions:
168
+
169
+ ```json
170
+ "email": { "domain": "bots.frockbot.com" }
171
+ ```
172
+
173
+ Each Bot's address is its name and the account's username at it,
174
+ `fox.tim@bots.frockbot.com` (`docs/architecture.md`, "By email"): mail to the
175
+ Bot arrives there, and mail from the Bot leaves from there. The generated app
176
+ config gains the var both directions read and the sender's binding:
177
+
178
+ ```json
179
+ "vars": { "EMAIL_DOMAIN": "bots.frockbot.com" },
180
+ "send_email": [{ "name": "SEND_EMAIL" }]
181
+ ```
182
+
183
+ The binding names no sender, deliberately. Every Bot sends from its own
184
+ address, and a `send_email` binding cannot be told "any address on one
185
+ domain": `allowed_sender_addresses` is a list of exact addresses, with no
186
+ wildcard or domain form ([send bindings][send-bindings], read 2026-09-25). So
187
+ `app/email/sender.ts` holds the domain instead — the kernel composes every
188
+ `from` itself, and the sender refuses one that is not on `EMAIL_DOMAIN` before
189
+ the binding is reached — and Email Service refuses any domain the account has
190
+ not onboarded. It names no destination either: a Bot writes to its person, and
191
+ a draft card to whoever the person approved.
192
+
193
+ `hosted` names `bots.frockbot.com`; `staging` names none, so staging receives
194
+ and sends no email. What the domain needs in Cloudflare, once per deployment:
195
+
196
+ 1. **Choose the domain.** Both Email Routing and Email Sending take over its
197
+ records, so it must receive no mail through another provider; a subdomain
198
+ of the app's zone is the simple choice. It may be the apex: a Bot's address
199
+ always has a dot before the `@`, so a plain mailbox like `hello@` is never a
200
+ Bot's and the Worker refuses it.
201
+ 2. **Receiving.** In the dashboard, open the zone → **Email** → **Email
202
+ Routing** and enable it for the domain (for a subdomain, add it under
203
+ **Settings → Subdomains**), accepting the MX and SPF records it asks for.
204
+ Under **Routing rules**, set the **Catch-all address** to **Send to a
205
+ Worker**, choose the app Worker (`frockbot-cloudflare` for the hosted
206
+ profile), and enable it. A plain mailbox that should reach a person, such as
207
+ `postmaster@`, gets its own custom address above it; no username can be one
208
+ of those names.
209
+ 3. **Sending.** Workers Paid (3,000 messages a month included, then $0.35 per
210
+ 1,000), then **Compute › Email Service › Email Sending › Onboard Domain**
211
+ for the same domain. Cloudflare writes MX, SPF and DKIM on the `cf-bounce`
212
+ subdomain and DMARC on `_dmarc.<domain>`; for `bots.frockbot.com` those are
213
+ live, with `p=reject`. Until the domain is verified every send is refused
214
+ with `E_SENDER_NOT_VERIFIED`, which the sender reports as "not sent" and
215
+ never as "may have sent". A new account starts on a conservative daily
216
+ quota, which the Limit Increase Request Form raises; past it a send is
217
+ refused with `E_DAILY_LIMIT_EXCEEDED` and nothing leaves.
218
+ 4. **The profile.** Add `email`, and for `hosted` update
219
+ `scripts/deployment-config/fixtures/hosted/app.wrangler.jsonc` in the same commit — the equivalence
220
+ gate below exists to make exactly that visible. Until the deploy lands the
221
+ Worker has no domain: it refuses every message and sends none.
222
+ 5. **Check it.** In the app, choose a username under Account → Email
223
+ username, switch a Bot's settings → Email on, and send it a message from
224
+ your sign-in address; ask it to email you back. The Worker logs one
225
+ `inbound-email` line per message with its outcome; a `rejected` with code
226
+ `unauthenticated` means the message carried no DMARC verdict the Worker
227
+ believes (`docs/known-issues.md` 51).
228
+
229
+ Removing `email` turns both directions off again: every message is refused,
230
+ nothing is sent, and the addresses start working again when it comes back.
231
+
232
+ [send-bindings]: https://developers.cloudflare.com/email-service/configuration/send-bindings/
233
+
234
+ ## The equivalence gate
235
+
236
+ `scripts/deployment-config/fixtures/hosted/` holds the five wrangler configs exactly as production and
237
+ staging ran them before identity moved out. `scripts/deployment-config.test.ts`
238
+ generates `hosted` and `staging` and proves the result is still those files,
239
+ comments, key order and path spelling aside — because a Worker name, Durable
240
+ Object class or migration tag that differs on deploy is a new namespace, which is
241
+ data loss. It runs under `bun test`, so `Check` and `main.yml` enforce it, and
242
+ `release.yml` runs it again as a dry run before `deploy-backend` deploys.
243
+
244
+ Changing a binding, a migration or a var means updating the fixture in the same
245
+ commit. That is the point: the change is seen rather than discovered in
246
+ production.
247
+
248
+ Two things the fixtures make explicit:
249
+
250
+ - **The staging expectation is derived.** The gate resolves `env.staging` over
251
+ the fixture's top level the way `wrangler --env staging` does, and asserts
252
+ staging redefines every non-inheritable key so that overlay is that
253
+ resolution. It also asserts staging's Computer host and Plugin build service
254
+ are production's Workers, which is deliberate: both are stateless request
255
+ handlers holding no per-user data, so staging exercises the ones production
256
+ runs rather than paying for a second container deployment. The consequence is
257
+ production's ordering constraint — a change to the host's contract ships with a
258
+ tag, so staging sees it only once that tag lands.
259
+ - **Staging's `AUTH_DB` identifier is not in its profile.** The staging deploy
260
+ creates the database if absent and resolves its identifier from
261
+ `wrangler d1 list` in the same job, then passes it with `--d1-database-id`.
262
+ That replaced the regex that used to rewrite the tracked file in place.
263
+
264
+ ## What a release publishes, and what an installer pulls
265
+
266
+ A deployer runs `wrangler deploy -c` in their own account with no Docker and no
267
+ Flutter, so everything those two would have produced is published by
268
+ `release.yml` for the tag and fetched from it (ADR 0028 step 5).
269
+
270
+ **The container images**, by the `publish-images` job, built once from the
271
+ repository root context for `linux/amd64` and pushed under two tags each:
272
+
273
+ | Image | Built from |
274
+ | ------------------------------------------------- | ------------------------------- |
275
+ | `docker.io/timoconnellaus/frockbot-computer-host` | `apps/computer-host/Dockerfile` |
276
+ | `docker.io/timoconnellaus/frockbot-applet-build` | `apps/applet-build/Dockerfile` |
277
+
278
+ `:<version>` is the release, `:latest` is the newest release. A profile names
279
+ them by setting `images`:
280
+
281
+ ```json
282
+ "images": { "source": "registry", "registry": "docker.io/timoconnellaus", "tag": "0.7.20" }
283
+ ```
284
+
285
+ and the generator writes `"image": "<registry>/frockbot-<worker>:<tag>"` with no
286
+ `image_build_context`. `"source": "dockerfile"`, which is also what an absent
287
+ `images` means, keeps today's behaviour — wrangler builds the image locally,
288
+ which is what the hosted profile still does. `CONTAINER_IMAGE_REPOSITORIES_V1`
289
+ and `PUBLISHED_IMAGE_REGISTRY_V1` in `generate.ts` are the one spelling of these
290
+ names, and `deployment-config.test.ts` proves `release.yml` pushes the same ones.
291
+
292
+ **What a deployer's account needs for the pull: nothing.** Cloudflare Containers
293
+ pull from [four registries][image-management] — the Cloudflare managed registry,
294
+ Docker Hub, Amazon ECR and Google Artifact Registry — and of those Docker Hub is
295
+ the only one where a public image needs no credentials: "Public Docker Hub images
296
+ do not require registry configuration." So the installer sets no registry
297
+ credentials and runs no `wrangler containers registries configure`. Two
298
+ consequences worth knowing:
299
+
300
+ - **GHCR is not one of the four.** `ghcr.io` images cannot be pulled by the
301
+ platform at all; the documented way to use an image from any other registry is
302
+ to pull it locally and `wrangler containers push` it, which needs the Docker
303
+ the installer is avoiding.
304
+ - Cloudflare does not cache Docker Hub pulls, so a deployment is subject to
305
+ Docker Hub's anonymous pull limits. A deployer who hits them configures their
306
+ own read-only Docker Hub token once, with
307
+ `wrangler containers registries configure docker.io --dockerhub-username=<user>`;
308
+ the images themselves stay public.
309
+
310
+ Publishing needs the repository secrets `DOCKERHUB_USERNAME` and
311
+ `DOCKERHUB_TOKEN` (a Docker Hub personal access token with write access to the
312
+ `timoconnellaus` namespace, which is that account's username; no organisation
313
+ is needed). While they are unset, `publish-images` skips with a warning and
314
+ `deploy-backend` does not wait on it, so the hosted deployment keeps shipping.
315
+ Once the simple profile is announced, `deploy-backend` gains `publish-images`
316
+ in its `needs`, so a tag production is running is always a tag an installer can
317
+ install.
318
+
319
+ **The release assets**, by `release-assets` and attached by `github-release`:
320
+
321
+ | Asset | What it is |
322
+ | --------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------ |
323
+ | `frockbot-web-client-<version>.zip` | `apps/cloudflare/dist/web` — unpack into it, and the generated config's `assets.directory` is the app Worker's payload |
324
+ | `frockbot-application-artifact-<version>.mjs` | `dist/artifacts/foundation-v1.mjs` — put in the `APPLICATION_ARTIFACTS` bucket under `applications/<its own sha256>.mjs` |
325
+
326
+ The artifact's key is its own sha256, and a generated config carries the
327
+ placeholder `"DEFAULT_APPLICATION_HASH": "foundation-v1"` from the tracked file
328
+ unless `--application-hash` names the real one. `bun run setup` passes it once it
329
+ has computed the digest of the artifact it downloaded; `deploy-backend` rewrites
330
+ the written file in place instead, in its `Configure application artifact` step. A
331
+ Worker whose var still says `foundation-v1` looks for an object that is not there.
332
+
333
+ `frockbot.apk` is also attached, by `patch-android` when the tag cut a full release and by `android-apk` otherwise. It is the hosted phone app,
334
+ not an installer asset: `bun run setup` does not download it, and a deployer
335
+ who wants the phone app builds it against their own origin (`docs/app-updates.md`).
336
+
337
+ [image-management]: https://developers.cloudflare.com/containers/image-management/
338
+
339
+ ## White-label
340
+
341
+ A white-label product is its own repository that installs FrockBot's packages at
342
+ a release version ([ADR 0038](../../../docs/adr/0038-white-label-deployments.md)).
343
+ Every workspace the Worker's graph reaches is published by `release.yml`'s
344
+ `publish-npm` job at the tag's version — `@frockbot/core`, `app`, `providers`,
345
+ `computer`, `frock-compose`, `applets` and this package, `@frockbot/cloudflare`
346
+ — listed once in `scripts/npm-publish.ts`, which rewrites every `workspace:*`
347
+ between them to that exact version. Pin the same exact version of each.
348
+
349
+ Its repository holds a profile, a brand module, its own auth Package and its own
350
+ thin Flutter application:
351
+
352
+ ```json
353
+ {
354
+ "schemaVersion": 1,
355
+ "name": "wallet-pal",
356
+ "accountId": "…",
357
+ "prefix": "wallet-pal",
358
+ "authPackage": "../auth/chooser.ts",
359
+ "authEnvironment": {
360
+ "secrets": [{ "name": "SIGN_IN_SECRET", "why": "Signs every session." }],
361
+ "vars": { "SIGN_IN_APP": "…" }
362
+ },
363
+ "brand": "../brand/brand.ts",
364
+ "webClient": "../client/web",
365
+ "workers": { "app": { "hostnames": ["app.wallet-pal.example"] } }
366
+ }
367
+ ```
368
+
369
+ - **`authPackage` by path** names a chooser module the white-label wrote: it
370
+ exports `AUTH_PACKAGE_V1: AuthPackageBuildV1<AuthPackageEnvironmentV1>` and
371
+ the `AuthPackageEnvironmentV1` type, from nothing but
372
+ `@frockbot/core/contracts`, as `src/auth-package.ts` does. The generator
373
+ aliases `#auth-package` to it, imports it, refuses one that names itself
374
+ `better-auth` or `access`, and refuses a profile whose `authEnvironment` does
375
+ not name exactly the settings the chooser's `required` lists — each as a
376
+ secret the deploy carries or a var the config carries. It binds `AUTH_DB` only
377
+ when the profile names a `d1DatabaseId`.
378
+ - **Secrets.** The production-secrets manifest (`src/production-secrets.ts`)
379
+ cannot import a chooser it was not built with, so the profile's
380
+ `authEnvironment.secrets` are what it requires in place of a built-in
381
+ Package's:
382
+
383
+ ```
384
+ frockbot-deployment-config secrets wallet-pal check
385
+ frockbot-deployment-config secrets wallet-pal write-secrets-file secrets.json
386
+ wrangler deploy -c .deployment/wallet-pal/app/wrangler.jsonc --secrets-file secrets.json
387
+ ```
388
+
389
+ - **The client** is built from the white-label's own application, and the
390
+ artifact with its brand:
391
+
392
+ ```
393
+ bun node_modules/@frockbot/cloudflare/build-flutter-web.ts --app . --dist dist
394
+ bun node_modules/@frockbot/cloudflare/build-artifact.ts --brand brand/brand.ts --dist dist
395
+ ```
396
+
397
+ `webClient` then names `dist/web` relative to the profile, and
398
+ `dist/artifacts/foundation-v1.mjs` goes into the artifacts bucket under its own
399
+ sha256, which `--application-hash` names.
400
+
401
+ - **Only the app Worker is in the package.** The Computer host and the Plugin
402
+ build service are deployed from a FrockBot checkout of the same release,
403
+ whose profile may pull the images `publish-images` pushes; a profile outside
404
+ this repository that names them is refused with that reason.
405
+
406
+ `scripts/white-label-fixture/` is such a repository in miniature, with a STUB
407
+ auth Package, and `bun run build:white-label` (`scripts/white-label-fixture.ts`)
408
+ is the gate that proves the packages are consumable: it packs every published
409
+ workspace exactly as the release does, installs the tarballs with npm into a
410
+ scratch consumer, typechecks its chooser and brand with stock TypeScript, runs
411
+ the bin, the secrets check and the artifact build, and runs `wrangler deploy
412
+ --dry-run`, checking that the bundle carries its chooser and brand and neither
413
+ better-auth nor FrockBot's brand. It runs with the build category and in
414
+ `main.yml`'s `Validate` job.
@@ -0,0 +1,228 @@
1
+ #!/usr/bin/env bun
2
+ /**
3
+ * `frockbot-deployment-config`: write the deployable wrangler configs for one
4
+ * deployment profile, and check a deploy's secrets against it.
5
+ *
6
+ * The tracked `wrangler.jsonc` files hold bindings, migrations and comments and
7
+ * no deployment identity at all; a profile holds the identity. This joins them
8
+ * and writes `<out>/<profile>/<worker>/wrangler.jsonc`, which is what
9
+ * `wrangler deploy -c` takes (ADR 0028 step 3). A white-label runs it from its
10
+ * own repository, where `deployments/<name>.json` names its brand and its own
11
+ * auth Package by path (ADR 0038 §5):
12
+ *
13
+ * frockbot-deployment-config <profile> [--profiles <dir>] [--out <dir>]
14
+ * [--d1-database-id <uuid>] [--application-hash <sha256>]
15
+ * frockbot-deployment-config secrets <profile> check [--profiles <dir>]
16
+ * frockbot-deployment-config secrets <profile> write-secrets-file <path> [--profiles <dir>]
17
+ *
18
+ * `--profiles` defaults to `./deployments` and `--out` to `./.deployment`.
19
+ * Bun runs it: the package is TypeScript source, as wrangler bundles it.
20
+ */
21
+ import { writeFileSync } from "node:fs";
22
+ import { relative, resolve } from "node:path";
23
+ import {
24
+ AUTH_PACKAGE_CHOOSERS_V1,
25
+ generateProfileConfigsV1,
26
+ profileAuthPackageV1,
27
+ profileBrandModuleV1,
28
+ validateProfileAuthPackageV1,
29
+ validateProfileBrandV1,
30
+ writeGeneratedConfigsV1,
31
+ } from "./generate.ts";
32
+ import { loadProfileV1, PACKAGE_ROOT_V1 } from "./profile.ts";
33
+
34
+ export interface DeploymentConfigCliOptionsV1 {
35
+ /** Where `<name>.json` is read from. */
36
+ profileDirectory: string;
37
+ /** Where `<name>/<worker>/wrangler.jsonc` is written. */
38
+ outputRoot: string;
39
+ /** What printed paths are relative to. */
40
+ displayRoot: string;
41
+ /** How a usage error names the command. */
42
+ command: string;
43
+ }
44
+
45
+ class UsageError extends Error {}
46
+
47
+ function takeFlag(
48
+ args: string[],
49
+ names: readonly string[],
50
+ ): Record<string, string> {
51
+ const flags: Record<string, string> = {};
52
+ for (let index = 0; index < args.length;) {
53
+ const flag = args[index]!;
54
+ if (!names.includes(flag)) {
55
+ index += 1;
56
+ continue;
57
+ }
58
+ const value = args[index + 1];
59
+ if (!value || value.startsWith("--")) {
60
+ throw new UsageError(`${flag} needs a value`);
61
+ }
62
+ flags[flag] = value;
63
+ args.splice(index, 2);
64
+ }
65
+ return flags;
66
+ }
67
+
68
+ async function generate(
69
+ args: string[],
70
+ options: DeploymentConfigCliOptionsV1,
71
+ ): Promise<number> {
72
+ const flags = takeFlag(args, ["--d1-database-id", "--application-hash"]);
73
+ const [name, ...rest] = args;
74
+ if (!name || name.startsWith("-") || rest.length > 0) {
75
+ throw new UsageError(
76
+ `usage: ${options.command} <profile> [--d1-database-id <uuid>] [--application-hash <sha256>]`,
77
+ );
78
+ }
79
+ const { profileDirectory } = options;
80
+ const profile = loadProfileV1(name, profileDirectory);
81
+ await validateProfileBrandV1(profile, profileDirectory);
82
+ await validateProfileAuthPackageV1(profile, profileDirectory);
83
+ const d1DatabaseId = flags["--d1-database-id"];
84
+ const applicationHash = flags["--application-hash"];
85
+ const generated = generateProfileConfigsV1({
86
+ profile,
87
+ profileDirectory,
88
+ outputRoot: options.outputRoot,
89
+ ...(d1DatabaseId === undefined ? {} : { d1DatabaseId }),
90
+ ...(applicationHash === undefined ? {} : { applicationHash }),
91
+ });
92
+ writeGeneratedConfigsV1(generated, profile.name);
93
+
94
+ const shown = (path: string) => relative(options.displayRoot, path);
95
+ const external = await profileAuthPackageV1(profile, profileDirectory);
96
+ const brand = profileBrandModuleV1(profile, profileDirectory);
97
+ console.log(`Deployment profile ${profile.name}`);
98
+ console.log(` account ${profile.accountId}`);
99
+ console.log(
100
+ ` auth Package ${
101
+ external === undefined
102
+ ? `${profile.authPackage} (${shown(
103
+ resolve(
104
+ PACKAGE_ROOT_V1,
105
+ AUTH_PACKAGE_CHOOSERS_V1[
106
+ profile.authPackage as keyof typeof AUTH_PACKAGE_CHOOSERS_V1
107
+ ],
108
+ ),
109
+ )})`
110
+ : `${external.id} (${shown(resolve(profileDirectory, profile.authPackage))})`
111
+ }`,
112
+ );
113
+ console.log(
114
+ ` brand ${
115
+ brand === undefined
116
+ ? `FrockBot (${shown(resolve(PACKAGE_ROOT_V1, "src/brand.ts"))})`
117
+ : shown(brand)
118
+ }`,
119
+ );
120
+ // Whether the deploy needs Docker is the difference worth printing here,
121
+ // for a profile that deploys a container Worker at all.
122
+ if (profile.workers?.computerHost || profile.workers?.appletBuild) {
123
+ console.log(
124
+ ` images ${
125
+ profile.images?.source === "registry"
126
+ ? `pulled from ${profile.images.registry} at ${profile.images.tag}`
127
+ : "built from the Dockerfile, which needs Docker"
128
+ }`,
129
+ );
130
+ }
131
+ for (const { worker, config, file } of generated) {
132
+ const hostnames = (
133
+ (config.routes as { pattern: string }[] | undefined) ?? []
134
+ ).map((route) => route.pattern);
135
+ const reach =
136
+ hostnames.length > 0
137
+ ? `on ${hostnames.join(", ")}`
138
+ : config.workers_dev === false
139
+ ? "reached only over a service binding"
140
+ : "on workers.dev";
141
+ console.log(` ${worker.padEnd(13)}${String(config.name)} ${reach}`);
142
+ console.log(` ${shown(file)}`);
143
+ }
144
+ return 0;
145
+ }
146
+
147
+ /**
148
+ * The production-secrets check for a profile, which is how a white-label's
149
+ * own auth Package's secrets are checked and deployed: the manifest cannot
150
+ * know them, and the profile names them.
151
+ */
152
+ async function secrets(
153
+ args: string[],
154
+ options: DeploymentConfigCliOptionsV1,
155
+ ): Promise<number> {
156
+ const [name, action, path, ...rest] = args;
157
+ const usage = `usage: ${options.command} secrets <profile> check | write-secrets-file <path>`;
158
+ if (!name || rest.length > 0) throw new UsageError(usage);
159
+ const profile = loadProfileV1(name, options.profileDirectory);
160
+ await validateProfileAuthPackageV1(profile, options.profileDirectory);
161
+ const auth = await profileAuthPackageV1(profile, options.profileDirectory);
162
+ // Only here: the manifest reaches the Worker's own chooser through
163
+ // `#auth-package`, which writing a config has no reason to load.
164
+ const { deployedSecretNamesV1, productionSecretsReportV1 } =
165
+ await import("../src/production-secrets.ts");
166
+ if (action === "check" && path === undefined) {
167
+ const report = productionSecretsReportV1(process.env, undefined, auth);
168
+ for (const warning of report.warnings) console.log(`warning: ${warning}`);
169
+ for (const failure of report.failures) console.error(failure);
170
+ if (report.ok) {
171
+ console.log(
172
+ `Production secrets check passed: ${deployedSecretNamesV1(auth).length} names carried by this deploy.`,
173
+ );
174
+ }
175
+ return report.ok ? 0 : 1;
176
+ }
177
+ if (action === "write-secrets-file" && path !== undefined) {
178
+ // JSON, as `wrangler deploy --secrets-file` reads first; an unset optional
179
+ // name is omitted rather than written empty.
180
+ const values: Record<string, string> = {};
181
+ for (const secret of deployedSecretNamesV1(auth)) {
182
+ const value = process.env[secret];
183
+ if (value !== undefined && value !== "") values[secret] = value;
184
+ }
185
+ writeFileSync(path, JSON.stringify(values), { mode: 0o600 });
186
+ console.log(
187
+ `Wrote ${Object.keys(values).length} secrets for wrangler --secrets-file.`,
188
+ );
189
+ return 0;
190
+ }
191
+ throw new UsageError(usage);
192
+ }
193
+
194
+ export async function runDeploymentConfigCliV1(
195
+ argv: readonly string[],
196
+ options: DeploymentConfigCliOptionsV1,
197
+ ): Promise<number> {
198
+ const args = [...argv];
199
+ try {
200
+ return args[0] === "secrets"
201
+ ? await secrets(args.slice(1), options)
202
+ : await generate(args, options);
203
+ } catch (error) {
204
+ if (!(error instanceof UsageError)) throw error;
205
+ console.error(error.message);
206
+ return 2;
207
+ }
208
+ }
209
+
210
+ if (import.meta.main) {
211
+ const args = process.argv.slice(2);
212
+ const cwd = process.cwd();
213
+ let flags: Record<string, string>;
214
+ try {
215
+ flags = takeFlag(args, ["--profiles", "--out"]);
216
+ } catch (error) {
217
+ console.error((error as Error).message);
218
+ process.exit(2);
219
+ }
220
+ process.exit(
221
+ await runDeploymentConfigCliV1(args, {
222
+ profileDirectory: resolve(cwd, flags["--profiles"] ?? "deployments"),
223
+ outputRoot: resolve(cwd, flags["--out"] ?? ".deployment"),
224
+ displayRoot: cwd,
225
+ command: "frockbot-deployment-config",
226
+ }),
227
+ );
228
+ }