@frockbot/cloudflare 0.0.0 → 0.7.291
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/build-artifact.ts +130 -0
- package/build-flutter-web.ts +508 -0
- package/deployment-config/README.md +414 -0
- package/deployment-config/cli.ts +228 -0
- package/deployment-config/generate.ts +689 -0
- package/deployment-config/jsonc.ts +18 -0
- package/deployment-config/profile-schema.generated.ts +391 -0
- package/deployment-config/profile.schema.json +358 -0
- package/deployment-config/profile.ts +124 -0
- package/migrations/0001_better_auth.sql +15 -0
- package/migrations/0002_drop_account_issuer.sql +8 -0
- package/package.json +61 -5
- package/release-version.ts +20 -0
- package/src/account-admission.ts +179 -0
- package/src/account-deletion.ts +274 -0
- package/src/admin-entrypoint.ts +194 -0
- package/src/admin-identities.ts +26 -0
- package/src/audit.ts +103 -0
- package/src/auth-package.access.ts +24 -0
- package/src/auth-package.ts +36 -0
- package/src/avatar-state-cleanup.ts +107 -0
- package/src/billing-computer.ts +267 -0
- package/src/billing-readiness.ts +21 -0
- package/src/billing.ts +461 -0
- package/src/bot-capabilities.ts +480 -0
- package/src/bot-recovery.ts +1 -0
- package/src/bot-state-channel.ts +702 -0
- package/src/bot-state.ts +3995 -0
- package/src/bot-template-cleanup.ts +98 -0
- package/src/bot-title-cleanup.ts +104 -0
- package/src/brand-icon.png +0 -0
- package/src/brand-logo.ts +2 -0
- package/src/brand.ts +36 -0
- package/src/client-compatibility.ts +51 -0
- package/src/compaction-announcement-cleanup.ts +113 -0
- package/src/computer-egress.ts +110 -0
- package/src/computer-host.ts +92 -0
- package/src/computer-screenshot-cleanup.ts +78 -0
- package/src/contracts.ts +832 -0
- package/src/debug.ts +272 -0
- package/src/default-packages-marker-cleanup.ts +39 -0
- package/src/deployment-policy-admin-host.ts +121 -0
- package/src/deployment-policy.ts +426 -0
- package/src/directory-profile-cleanup.ts +101 -0
- package/src/durable-rpc.ts +753 -0
- package/src/durable-session.ts +21 -0
- package/src/entry-boundary.ts +103 -0
- package/src/frock-ai.ts +338 -0
- package/src/gateway.ts +1392 -0
- package/src/group-chat.ts +736 -0
- package/src/hidden-bot-notifications-cleanup.ts +32 -0
- package/src/index.ts +2798 -0
- package/src/insights.ts +15 -0
- package/src/machine-messages-cleanup.ts +121 -0
- package/src/machine-socket.ts +164 -0
- package/src/memory-records.ts +181 -0
- package/src/memory.ts +145 -0
- package/src/model-rates.ts +252 -0
- package/src/native-auth.ts +1006 -0
- package/src/native-sessions.ts +121 -0
- package/src/notification-state-cleanup.ts +18 -0
- package/src/ollama-web-search-cleanup.ts +69 -0
- package/src/package-page-shapes-cleanup.ts +190 -0
- package/src/plugin-egress.ts +31 -0
- package/src/plugin-page-route.ts +72 -0
- package/src/plugin-panels-cleanup.ts +101 -0
- package/src/prepared-input-cleanup.ts +105 -0
- package/src/production-secrets.ts +484 -0
- package/src/project-cleanup.ts +123 -0
- package/src/project-events-cleanup.ts +152 -0
- package/src/publication-state-cleanup.ts +92 -0
- package/src/push.ts +498 -0
- package/src/request-body.ts +165 -0
- package/src/routine-state-cleanup.ts +57 -0
- package/src/search.ts +88 -0
- package/src/sidebar-label-cleanup.ts +165 -0
- package/src/skill-index-cleanup.ts +53 -0
- package/src/supersede-cleanup.ts +162 -0
- package/src/test-chat-cleanup.ts +93 -0
- package/src/uploads.ts +486 -0
- package/src/user-application.ts +1068 -0
- package/src/user-configuration.ts +4491 -0
- package/src/voice-assistant.ts +4999 -0
- package/src/voice-dictation.ts +743 -0
- package/src/working-context-cleanup.ts +96 -0
- package/src/workspace.ts +201 -0
- package/wrangler.jsonc +385 -0
- package/README.md +0 -3
|
@@ -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
|
+
}
|