@wtfalch/foundry 0.3.0 → 0.5.0
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/dist/bootstrap/bootstrap.d.ts +96 -0
- package/dist/bootstrap/bootstrap.js +285 -0
- package/dist/bootstrap/declare.d.ts +36 -0
- package/dist/bootstrap/declare.js +94 -0
- package/dist/bootstrap/drive.d.ts +40 -0
- package/dist/bootstrap/drive.js +51 -0
- package/dist/bootstrap/index.d.ts +3 -0
- package/dist/bootstrap/index.js +1 -0
- package/dist/calls-sfu-deploy-support.d.ts +9 -6
- package/dist/calls-sfu-deploy-support.js +9 -6
- package/dist/calls-sfu-keys.d.ts +21 -0
- package/dist/calls-sfu-keys.js +27 -0
- package/dist/calls-sfu-ssh-adapter.d.ts +55 -0
- package/dist/calls-sfu-ssh-adapter.js +78 -0
- package/dist/calls-sfu-ssh-client.d.ts +49 -0
- package/dist/calls-sfu-ssh-client.js +82 -0
- package/dist/calls-sfu.d.ts +22 -4
- package/dist/calls-sfu.js +22 -4
- package/dist/index.d.ts +31 -6
- package/dist/index.js +16 -3
- package/dist/migrations/0014_calls_enabled.sql +7 -0
- package/dist/migrations/0015_domain_unique.sql +39 -0
- package/dist/migrations/0016_platform.sql +61 -0
- package/dist/migrations/0017_requested_changes.sql +42 -0
- package/dist/migrations/0018_platform_apply.sql +19 -0
- package/dist/org.d.ts +18 -2
- package/dist/org.js +56 -0
- package/dist/plan/agora.d.ts +77 -0
- package/dist/plan/agora.js +127 -0
- package/dist/plan/calls-matrix.d.ts +135 -0
- package/dist/plan/calls-matrix.js +275 -0
- package/dist/plan/cloudflared-connector.d.ts +70 -0
- package/dist/plan/cloudflared-connector.js +106 -0
- package/dist/plan/diff.d.ts +26 -5
- package/dist/plan/diff.js +720 -106
- package/dist/plan/executor.d.ts +731 -36
- package/dist/plan/executor.js +1180 -41
- package/dist/plan/index.d.ts +22 -5
- package/dist/plan/index.js +12 -2
- package/dist/plan/jobs-scheduler-http.d.ts +29 -0
- package/dist/plan/jobs-scheduler-http.js +50 -0
- package/dist/plan/live.d.ts +51 -2
- package/dist/plan/live.js +8 -0
- package/dist/plan/plan-apply.d.ts +192 -3
- package/dist/plan/plan-apply.js +290 -14
- package/dist/plan/platform-diff.d.ts +312 -0
- package/dist/plan/platform-diff.js +867 -0
- package/dist/plan/platform-org-ports.d.ts +50 -0
- package/dist/plan/platform-org-ports.js +70 -0
- package/dist/plan/platform-org.d.ts +120 -0
- package/dist/plan/platform-org.js +115 -0
- package/dist/plan/platform-providers.d.ts +501 -0
- package/dist/plan/platform-providers.js +3029 -0
- package/dist/plan/platform-sfu.d.ts +93 -0
- package/dist/plan/platform-sfu.js +100 -0
- package/dist/plan/providers-readers.js +22 -1
- package/dist/plan/zitadel-machine-user.d.ts +34 -0
- package/dist/plan/zitadel-machine-user.js +95 -0
- package/dist/plan.d.ts +6 -0
- package/dist/platform.d.ts +55 -0
- package/dist/platform.js +46 -0
- package/dist/requested-changes.d.ts +108 -0
- package/dist/requested-changes.js +95 -0
- package/dist/slug.js +21 -2
- package/dist/store/audit.js +4 -2
- package/dist/store/postgres.d.ts +40 -7
- package/dist/store/postgres.js +257 -29
- package/dist/store/schema.d.ts +700 -0
- package/dist/store/schema.js +58 -0
- package/dist/store.d.ts +56 -0
- package/package.json +2 -2
package/dist/plan/executor.js
CHANGED
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
import { randomBytes } from 'node:crypto';
|
|
2
2
|
import { URL } from 'node:url';
|
|
3
|
+
import { CALLS_SFU_PRIVATE_URL, CALLS_SFU_PUBLIC_URL } from '../calls-sfu.js';
|
|
4
|
+
import { AGORA_CLIENT_ID_SCOPE_KEY, agoraHostnameFor, desiredAgoraEnv, } from './agora.js';
|
|
5
|
+
import { MAS_IMAGE, SYNAPSE_IMAGE, SYNAPSE_TAG, callsMasConfigYaml, callsServerName, callsSynapseHomeserverYaml, mintCallsMatrixSecrets, parseCallsDbUrl, } from './calls-matrix.js';
|
|
6
|
+
import { ORG_MAIL_APPLIED_APEX_SCOPE_KEY, ORG_MAIL_APPLIED_HOST_SCOPE_KEY, } from './platform-diff.js';
|
|
3
7
|
// ---------------------------------------------------------------------------
|
|
4
8
|
// Errors
|
|
5
9
|
// ---------------------------------------------------------------------------
|
|
@@ -32,14 +36,30 @@ function hostnameFor(org) {
|
|
|
32
36
|
}
|
|
33
37
|
/**
|
|
34
38
|
* `cms.<domain>` -- the cms studio's own hostname (`cmsSite` below), apart
|
|
35
|
-
* from the public site itself at `org.domain` (used directly there).
|
|
36
|
-
*
|
|
37
|
-
*
|
|
39
|
+
* from the public site itself at `org.domain` (used directly there). Called
|
|
40
|
+
* from `cmsSite` and from `zitadelApp` for the fixed `cms` client (#139),
|
|
41
|
+
* both of which only run once `org.site.enabled` is true, so `org.domain`
|
|
42
|
+
* is non-null by the same `validateSite` guarantee
|
|
38
43
|
* {@link tunnelIngressHostnames}'s own doc comment cites.
|
|
39
44
|
*/
|
|
40
45
|
function cmsHostnameFor(org) {
|
|
41
46
|
return `cms.${org.domain}`;
|
|
42
47
|
}
|
|
48
|
+
/**
|
|
49
|
+
* #145's "Decided" (2026-09-27): the calls service's own public hostname.
|
|
50
|
+
* `calls.usesDomain: false` (or no `domain`) is served flat, like
|
|
51
|
+
* `<slug>-stage.oikos.host` and `<slug>-pr-<n>.oikos.host` (`../slug.ts`) --
|
|
52
|
+
* the shared tunnel's wildcard `*.oikos.host` DNS record already reaches it,
|
|
53
|
+
* so nothing changes in `scripts/shared-ingress.mjs`. `calls.usesDomain: true`
|
|
54
|
+
* gets `calls.<domain>` instead, on its own per-org tunnel
|
|
55
|
+
* ({@link tunnelIngressHostnames}), the same subdomain-of-the-org's-own-domain
|
|
56
|
+
* shape {@link cmsHostnameFor} uses for `cms.<domain>`.
|
|
57
|
+
*/
|
|
58
|
+
function callsHostnameFor(org) {
|
|
59
|
+
return org.calls.usesDomain && org.domain
|
|
60
|
+
? `calls.${org.domain}`
|
|
61
|
+
: `${org.slug}-calls.oikos.host`;
|
|
62
|
+
}
|
|
43
63
|
/**
|
|
44
64
|
* The hostnames `cloudflareTunnel` routes to `COOLIFY_PROXY_ORIGIN`
|
|
45
65
|
* (https://github.com/wtfalch/foundry/issues/115). A site-enabled org
|
|
@@ -51,11 +71,24 @@ function cmsHostnameFor(org) {
|
|
|
51
71
|
* concretely a hosting org, whose one app hostname this served before #115
|
|
52
72
|
* ever existed -- keeps exactly the prior single-hostname behavior
|
|
53
73
|
* ({@link hostnameFor}), unchanged.
|
|
74
|
+
*
|
|
75
|
+
* #145: an own-domain calls service adds `calls.<domain>` on top of whichever
|
|
76
|
+
* of those a site/hosting org already gets, since calls can run alongside
|
|
77
|
+
* either (or neither).
|
|
54
78
|
*/
|
|
55
79
|
function tunnelIngressHostnames(org) {
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
80
|
+
const hostnames = org.site.enabled && org.domain ? [org.domain, `cms.${org.domain}`] : [hostnameFor(org)];
|
|
81
|
+
if (org.calls.enabled && org.calls.usesDomain && org.domain) {
|
|
82
|
+
hostnames.push(`calls.${org.domain}`);
|
|
83
|
+
}
|
|
84
|
+
// #164: Agora always moves onto `agora.<domain>` once `org.domain` is set
|
|
85
|
+
// -- no toggle to gate this on. A level-1 org (no domain) adds nothing
|
|
86
|
+
// here: `<slug>-agora.oikos.host` rides the shared `*.oikos.host` wildcard
|
|
87
|
+
// tunnel (`scripts/shared-ingress.mjs`), not a per-org tunnel.
|
|
88
|
+
if (org.domain) {
|
|
89
|
+
hostnames.push(agoraHostnameFor(org));
|
|
90
|
+
}
|
|
91
|
+
return hostnames;
|
|
59
92
|
}
|
|
60
93
|
/**
|
|
61
94
|
* The health URLs an outside prober should call for this org (#43): one per
|
|
@@ -97,7 +130,7 @@ function mailOrgFor(org, usesDomain, mode) {
|
|
|
97
130
|
*/
|
|
98
131
|
const COOLIFY_PROXY_ORIGIN = 'http://coolify-proxy:80';
|
|
99
132
|
/** The one Access policy foundry puts on its own Access app (#84). */
|
|
100
|
-
const ACCESS_POLICY_NAME = 'foundry: allowed emails';
|
|
133
|
+
export const ACCESS_POLICY_NAME = 'foundry: allowed emails';
|
|
101
134
|
/**
|
|
102
135
|
* The sign-in broker's own route mount (`@wtfalch/auth`'s `NextAuth`,
|
|
103
136
|
* `dist/next.d.ts`'s `handlers` doc comment: "`export const { GET, POST } =
|
|
@@ -141,8 +174,15 @@ const RATE_LIMIT_REQUESTS_PER_PERIOD = 10;
|
|
|
141
174
|
* org declares none of its own — the repo may not exist yet at decision
|
|
142
175
|
* time, and Coolify's own GitHub App create call is what will fail loudly
|
|
143
176
|
* against it, the same as any other repo name that does not exist.
|
|
177
|
+
*
|
|
178
|
+
* Hestia moved into this repo's own `apps/hestia` (estate DECISIONS.md,
|
|
179
|
+
* 2026-09-28; the hestia repo archives once this move lands), so the
|
|
180
|
+
* standard app now builds from foundry's own repo, told apart from
|
|
181
|
+
* `packages/foundry` by `dockerfile_location` — the same one-repo,
|
|
182
|
+
* multiple-apps shape `cmsSite`/`CMS_APP_DOCKERFILE` already use below.
|
|
144
183
|
*/
|
|
145
|
-
const DEFAULT_APP_REPO = 'wtfalch/
|
|
184
|
+
const DEFAULT_APP_REPO = 'wtfalch/foundry';
|
|
185
|
+
const DEFAULT_APP_DOCKERFILE = 'apps/hestia/Dockerfile';
|
|
146
186
|
/**
|
|
147
187
|
* The one repo `cmsSite` builds both of its apps from
|
|
148
188
|
* (https://github.com/wtfalch/cms) -- the 2026-09-25 decision this campaign
|
|
@@ -156,6 +196,25 @@ const DEFAULT_APP_REPO = 'wtfalch/hestia';
|
|
|
156
196
|
const CMS_REPO = 'wtfalch/cms';
|
|
157
197
|
const CMS_APP_DOCKERFILE = 'Dockerfile';
|
|
158
198
|
const CMS_SITE_APP_DOCKERFILE = 'apps/site/Dockerfile';
|
|
199
|
+
/**
|
|
200
|
+
* #164: the repo `coolifyAgora` builds the org's Agora from
|
|
201
|
+
* (https://github.com/wtfalch/agora) -- the issue's own "How" names this
|
|
202
|
+
* exact Dockerfile, and it is the only build strategy any app this file
|
|
203
|
+
* provisions ever uses ({@link DEFAULT_APP_DOCKERFILE}/{@link CMS_APP_DOCKERFILE}/
|
|
204
|
+
* `CALLS_SERVICE_DOCKERFILE` below are all `dockerfile_location` into a
|
|
205
|
+
* `createGithubAppApplication` build-from-repo call, never a registry
|
|
206
|
+
* image): "Coolify builds each org's Agora from the agora repo" is not a
|
|
207
|
+
* fresh product choice here, it is this file's one established convention,
|
|
208
|
+
* followed rather than reopened (#164 "Decide" 1). The agora repo's own
|
|
209
|
+
* Dockerfile (`apps/agora/Dockerfile`) already `EXPOSE`s 8080 and serves
|
|
210
|
+
* from a build built at the repository root, the same one-repo shape
|
|
211
|
+
* `CMS_REPO`'s own two apps use -- no `ports_exposes`/`PORT` env is needed
|
|
212
|
+
* the way `CALLS_SERVICE_PORT` is for the calls service's own Node process,
|
|
213
|
+
* since Coolify's github-app build already reads the exposed port the same
|
|
214
|
+
* way it does for `cmsSite`'s two apps above.
|
|
215
|
+
*/
|
|
216
|
+
const AGORA_REPO = 'wtfalch/agora';
|
|
217
|
+
const AGORA_APP_DOCKERFILE = 'apps/agora/Dockerfile';
|
|
159
218
|
/**
|
|
160
219
|
* Every app foundry deploys keeps its own logs across a redeploy, so a
|
|
161
220
|
* database outage does not also erase the only other copy of the evidence
|
|
@@ -168,9 +227,13 @@ const CMS_SITE_APP_DOCKERFILE = 'apps/site/Dockerfile';
|
|
|
168
227
|
*/
|
|
169
228
|
const LOG_DIR = '/app/logs';
|
|
170
229
|
/**
|
|
171
|
-
*
|
|
172
|
-
*
|
|
173
|
-
* `
|
|
230
|
+
* #177: the fallback Stalwart apex and mail host `coolifyMail` uses when its
|
|
231
|
+
* own step carries none (`diff.ts`'s `planMail` always supplies one once a
|
|
232
|
+
* platform is wired -- `platform-org.ts`'s `DEFAULT_ORG_PLATFORM_READINESS`
|
|
233
|
+
* is this exact pair, so a host that has not wired `FoundryPlanApplyOptions.
|
|
234
|
+
* platform` yet keeps running on the address every org's mail always ran on
|
|
235
|
+
* before the platform existed to ask, the same values `@wtfalch/email`'s own
|
|
236
|
+
* README examples use for `applyOrgMail`/`parkOrgMail`'s `OrgMailOptions`).
|
|
174
237
|
*/
|
|
175
238
|
const MAIL_APEX = 'wtfalch.dev';
|
|
176
239
|
const MAIL_HOST = 'mail.wtfalch.dev';
|
|
@@ -185,6 +248,16 @@ const MAIL_HOST = 'mail.wtfalch.dev';
|
|
|
185
248
|
* report).
|
|
186
249
|
*/
|
|
187
250
|
const APP_CLIENT_ID_SCOPE_KEY = 'zitadel_app_client_id';
|
|
251
|
+
/**
|
|
252
|
+
* #139: the `cms` app's own ZITADEL web client's `clientId`, recorded
|
|
253
|
+
* separately from {@link APP_CLIENT_ID_SCOPE_KEY} -- that key belongs to the
|
|
254
|
+
* org's own hosted app (`diff.ts`'s `planIssuerApps`), a different
|
|
255
|
+
* `zitadel:app` step from the fixed `cms` client `diff.ts`'s `planCmsClient`
|
|
256
|
+
* plans. `cmsSite` reads this key to push `AUTH_CLIENT_ID` onto the cms app,
|
|
257
|
+
* the same way `pushAppEnv` reads `APP_CLIENT_ID_SCOPE_KEY` for the hosted
|
|
258
|
+
* app.
|
|
259
|
+
*/
|
|
260
|
+
const CMS_CLIENT_ID_SCOPE_KEY = 'cms_client_id';
|
|
188
261
|
/**
|
|
189
262
|
* `cmsSite` provisions two applications from one `cms:site` step, but
|
|
190
263
|
* `execute`'s own return value auto-records only one `resourceId`, under
|
|
@@ -210,6 +283,123 @@ const SITE_APP_SCOPE_KEY = 'coolify_site_app';
|
|
|
210
283
|
* never collide with that form.
|
|
211
284
|
*/
|
|
212
285
|
const CMS_DATABASE_SCOPE_KEY = 'coolify_cms_database';
|
|
286
|
+
/**
|
|
287
|
+
* #138/#140 (2026-09-28 lettermint-org-mail swap): the mail domain
|
|
288
|
+
* `coolifyMail` resolved with Stalwart -- `org.domain` when the org uses its
|
|
289
|
+
* own domain, else `<slug>.<apex>` for a domainless org (2026-09-28:
|
|
290
|
+
* domainless orgs get their own Lettermint domain too). Recorded so the app
|
|
291
|
+
* step can derive `MAIL_FROM` and drive its own Lettermint domain/project
|
|
292
|
+
* work (`ensureAppLettermintMail`) without recomputing `mailOrgFor`'s own
|
|
293
|
+
* domain choice, the same way {@link APP_CLIENT_ID_SCOPE_KEY} carries a
|
|
294
|
+
* public value out. All of that org's Lettermint work -- ensure domain,
|
|
295
|
+
* restrict, publish DNS, verify -- now happens inside the same step that
|
|
296
|
+
* reads this key (`ensureAppLettermintMail`), so unlike the Resend design
|
|
297
|
+
* this rework replaces, no separate domain-id scope key is recorded: the
|
|
298
|
+
* domain is looked up by name fresh each apply. Renamed from
|
|
299
|
+
* `resend_mail_domain`.
|
|
300
|
+
*/
|
|
301
|
+
const MAIL_DOMAIN_SCOPE_KEY = 'mail_domain';
|
|
302
|
+
/**
|
|
303
|
+
* #162: the owner's own mailbox, created once inside `coolify:mail` after
|
|
304
|
+
* `applyOrgMail` succeeds, alongside {@link MAIL_DOMAIN_SCOPE_KEY} -- another
|
|
305
|
+
* flat scope id for the same reason, no `:`. Recorded with the address
|
|
306
|
+
* itself (not just a boolean) so a future step could read it back the same
|
|
307
|
+
* way {@link MAIL_DOMAIN_SCOPE_KEY} lets `ensureAppLettermintMail` read the
|
|
308
|
+
* domain back; `ensureOwnerMailbox` only ever checks that this key is set at
|
|
309
|
+
* all before deciding whether to call `MailPort.createMailbox` again.
|
|
310
|
+
* `plan/diff.ts` keeps its own copy of this literal (`MAIL_OWNER_MAILBOX_SCOPE_KEY`
|
|
311
|
+
* there) rather than importing it, the same restated-not-shared rule this
|
|
312
|
+
* file's own `LOG_DIR` doc comment names for `diff.ts`.
|
|
313
|
+
*/
|
|
314
|
+
const MAIL_OWNER_MAILBOX_SCOPE_KEY = 'mail_owner_mailbox';
|
|
315
|
+
/**
|
|
316
|
+
* #80: the id of an ai key `aiKey` has minted but not yet delivered, a flat
|
|
317
|
+
* scope id (no `:`). `plan/diff.ts` keeps its own copy of this literal.
|
|
318
|
+
*/
|
|
319
|
+
const AI_KEY_UNDELIVERED_SCOPE_KEY = 'ai_key_undelivered';
|
|
320
|
+
/** How many times `aiKey` tries `deliverSecret` before giving up. */
|
|
321
|
+
const AI_KEY_DELIVERY_ATTEMPTS = 3;
|
|
322
|
+
/**
|
|
323
|
+
* #173: the id of a jobs project `jobsRegistration` has registered but not
|
|
324
|
+
* yet delivered the credentials of, a flat scope id (no `:`). `serviceIds` is
|
|
325
|
+
* already per org, so the key needs no org in it. `plan/diff.ts` keeps its own
|
|
326
|
+
* copy of this literal.
|
|
327
|
+
*/
|
|
328
|
+
const JOBS_REGISTRATION_UNDELIVERED_SCOPE_KEY = 'jobs_registration_undelivered';
|
|
329
|
+
/** How many times `jobsRegistration` tries each `deliverSecret` before giving up. */
|
|
330
|
+
const JOBS_DELIVERY_ATTEMPTS = 3;
|
|
331
|
+
/**
|
|
332
|
+
* The one mailbox this executor mints on its own (#162) -- a plain,
|
|
333
|
+
* discoverable local part rather than anything derived from the owner's own
|
|
334
|
+
* (possibly personal) `email`, so it never collides with a name and never
|
|
335
|
+
* repeats a stranger's inbox back at them. #162's own "Decide" section
|
|
336
|
+
* leaves open whether every *other* person in the org also gets a mailbox
|
|
337
|
+
* from foundry, or only from Boule -- unresolved, on purpose, until a human
|
|
338
|
+
* picks one; this constant and {@link ensureOwnerMailbox} only ever cover
|
|
339
|
+
* the owner, which every candidate answer to that question already includes.
|
|
340
|
+
*/
|
|
341
|
+
const MAIL_OWNER_LOCAL_PART = 'owner';
|
|
342
|
+
/**
|
|
343
|
+
* `coolifyCalls` provisions four resources from one `coolify:calls` step
|
|
344
|
+
* (#128) — the same one-step, several-resource shape `cmsSite` already solves
|
|
345
|
+
* with flat scope ids (`CMS_APP_SCOPE_KEY`/`SITE_APP_SCOPE_KEY`/
|
|
346
|
+
* `CMS_DATABASE_SCOPE_KEY`, that constant's own doc comment). This step's own
|
|
347
|
+
* auto-recorded `resourceId` (under `coolify:calls:<orgId>`) is the MAS app's
|
|
348
|
+
* id, the one every secret this stack mints lives on
|
|
349
|
+
* ({@link CALLS_SECRET_ENV_KEYS}); the other three get their own flat scope
|
|
350
|
+
* id here for the same reason CMS's site app and database do.
|
|
351
|
+
*/
|
|
352
|
+
const CALLS_SYNAPSE_SCOPE_KEY = 'coolify_calls_synapse';
|
|
353
|
+
const CALLS_MAS_SCOPE_KEY = 'coolify_calls_mas';
|
|
354
|
+
const CALLS_SYNAPSE_DB_SCOPE_KEY = 'coolify_calls_synapse_database';
|
|
355
|
+
const CALLS_MAS_DB_SCOPE_KEY = 'coolify_calls_mas_database';
|
|
356
|
+
/**
|
|
357
|
+
* Synapse refuses to start against a database whose `LC_COLLATE`/`LC_CTYPE`
|
|
358
|
+
* is not `C` (https://element-hq.github.io/synapse/latest/postgres.html);
|
|
359
|
+
* Coolify's official postgres image otherwise creates one with `en_US.utf8`.
|
|
360
|
+
* `postgres_initdb_args` is the only point this can be set -- it cannot be
|
|
361
|
+
* migrated onto an existing database. MAS has no such requirement, so this
|
|
362
|
+
* is passed for the Synapse database only. Not using Synapse's own
|
|
363
|
+
* `allow_unsafe_locale` escape hatch: that trades a loud startup refusal for
|
|
364
|
+
* silent index corruption after a future glibc upgrade.
|
|
365
|
+
*/
|
|
366
|
+
const CALLS_SYNAPSE_DB_INITDB_ARGS = '--locale=C --encoding=UTF8';
|
|
367
|
+
/**
|
|
368
|
+
* `CallsMatrixSecrets`' six fields, held nowhere else once minted
|
|
369
|
+
* (`calls-matrix.ts`'s own doc comment: no `ctx.outputs` state file exists
|
|
370
|
+
* here) — pushed onto the MAS app's own envs purely as memory for the next
|
|
371
|
+
* apply, never read by MAS itself (MAS reads only its `config.yaml` file
|
|
372
|
+
* storage). `multiline: true` on the two PEM keys, the same flag
|
|
373
|
+
* `CoolifyPort.bulkUpsertEnvs` already carries for exactly this shape of
|
|
374
|
+
* value.
|
|
375
|
+
*/
|
|
376
|
+
const CALLS_SECRET_ENV_KEYS = {
|
|
377
|
+
masSynapseSharedSecret: 'CALLS_MATRIX_MAS_SYNAPSE_SHARED_SECRET',
|
|
378
|
+
masClientId: 'CALLS_MATRIX_MAS_CLIENT_ID',
|
|
379
|
+
masClientSecret: 'CALLS_MATRIX_MAS_CLIENT_SECRET',
|
|
380
|
+
masEncryptionKey: 'CALLS_MATRIX_MAS_ENCRYPTION_KEY',
|
|
381
|
+
masRsaKeyPem: 'CALLS_MATRIX_MAS_RSA_KEY_PEM',
|
|
382
|
+
masEcKeyPem: 'CALLS_MATRIX_MAS_EC_KEY_PEM',
|
|
383
|
+
};
|
|
384
|
+
/** `FROM ${MAS_IMAGE}` plus the entry command its published image needs (chat's own `MAS_DOCKERFILE`, same reason: no arguments by default). */
|
|
385
|
+
const CALLS_MAS_DOCKERFILE = [
|
|
386
|
+
`FROM ${MAS_IMAGE}`,
|
|
387
|
+
'CMD ["server", "--config", "/config/config.yaml"]',
|
|
388
|
+
].join('\n');
|
|
389
|
+
/**
|
|
390
|
+
* `coolifyCallsService`'s own database (#145) -- no `system:kind:key` slot
|
|
391
|
+
* of its own, the same reason {@link CALLS_SYNAPSE_SCOPE_KEY}'s doc comment
|
|
392
|
+
* gives for the Matrix stack's two: this step's one auto-recorded
|
|
393
|
+
* `resourceId` (under `coolify:calls-service:<orgId>`) is the calls service
|
|
394
|
+
* app itself, not its database.
|
|
395
|
+
*/
|
|
396
|
+
const CALLS_SERVICE_DB_SCOPE_KEY = 'coolify_calls_service_database';
|
|
397
|
+
/** `wtfalch/calls`'s own repo -- `coolifyCallsService` deploys its `packages/service` (#145, part 2 of #128). */
|
|
398
|
+
const CALLS_SERVICE_REPO = 'wtfalch/calls';
|
|
399
|
+
/** `packages/service/Dockerfile`, built from the repo root (that Dockerfile's own top comment). */
|
|
400
|
+
const CALLS_SERVICE_DOCKERFILE = 'packages/service/Dockerfile';
|
|
401
|
+
/** The port `calls/packages/service/Dockerfile` documents (`EXPOSE 8080`) -- `config.mjs`'s own `PORT` has no default, so this must be pushed explicitly. */
|
|
402
|
+
const CALLS_SERVICE_PORT = '8080';
|
|
213
403
|
/**
|
|
214
404
|
* Whether an application's own Coolify `status` (`` `running:healthy`,
|
|
215
405
|
* `exited:unhealthy` `` -- `@wtfalch/providers`' own doc comment) means it is
|
|
@@ -221,7 +411,7 @@ const CMS_DATABASE_SCOPE_KEY = 'coolify_cms_database';
|
|
|
221
411
|
* same "nothing says it's off" convention `planHostingToggle` (`diff.ts`)
|
|
222
412
|
* uses for the org's own app.
|
|
223
413
|
*/
|
|
224
|
-
function isRunningOrStarting(status) {
|
|
414
|
+
export function isRunningOrStarting(status) {
|
|
225
415
|
if (!status)
|
|
226
416
|
return true;
|
|
227
417
|
return !status.startsWith('exited') && !status.startsWith('stopped');
|
|
@@ -249,10 +439,14 @@ export class FoundryStepExecutor {
|
|
|
249
439
|
return this.coolifyApp(action, org, context);
|
|
250
440
|
case 'coolify:database':
|
|
251
441
|
return this.coolifyDatabase(action, org, context);
|
|
442
|
+
case 'coolify:calls':
|
|
443
|
+
return this.coolifyCalls(action, org, context);
|
|
444
|
+
case 'coolify:calls-service':
|
|
445
|
+
return this.coolifyCallsService(action, org, context);
|
|
252
446
|
case 'cloudflare:tunnel':
|
|
253
447
|
return this.cloudflareTunnel(action, org, context);
|
|
254
448
|
case 'cloudflare:zone':
|
|
255
|
-
return this.cloudflareZone(action);
|
|
449
|
+
return this.cloudflareZone(action, org);
|
|
256
450
|
case 'cloudflare:access':
|
|
257
451
|
return this.cloudflareAccess(org);
|
|
258
452
|
case 'cloudflare:ratelimit':
|
|
@@ -265,12 +459,18 @@ export class FoundryStepExecutor {
|
|
|
265
459
|
return this.aiOrganisation(action, org);
|
|
266
460
|
case 'ai:budget':
|
|
267
461
|
return this.aiBudget(action, org);
|
|
462
|
+
case 'ai:key':
|
|
463
|
+
return this.aiKey(action, org, context);
|
|
268
464
|
case 'coolify:mail':
|
|
269
|
-
return this.coolifyMail(action, org);
|
|
465
|
+
return this.coolifyMail(action, org, context);
|
|
270
466
|
case 'coolify:chat':
|
|
271
467
|
throw new NotBuiltError(`coolify:chat for ${org.slug}`, "chat's per-org Synapse+MAS has no admin API yet; tracked at https://github.com/wtfalch/chat/issues/40");
|
|
272
468
|
case 'cms:site':
|
|
273
469
|
return this.cmsSite(action, org, context);
|
|
470
|
+
case 'coolify:agora':
|
|
471
|
+
return this.coolifyAgora(action, org, context);
|
|
472
|
+
case 'jobs:registration':
|
|
473
|
+
return this.jobsRegistration(action, org, context);
|
|
274
474
|
default:
|
|
275
475
|
throw new Error(`FoundryStepExecutor has no dispatch for ${action.system}:${action.kind}`);
|
|
276
476
|
}
|
|
@@ -427,10 +627,39 @@ export class FoundryStepExecutor {
|
|
|
427
627
|
const projectId = await this.ensureZitadelProject(org, zitadelOrgId, context);
|
|
428
628
|
// A kind change deletes the recorded app then recreates it (task
|
|
429
629
|
// brief); the first-ever create for this key has nothing recorded, so
|
|
430
|
-
// this is a no-op then.
|
|
630
|
+
// this is a no-op then. A 404 here (already gone) is tolerated the same
|
|
631
|
+
// way `destroy` tolerates one -- a retry after the delete half already
|
|
632
|
+
// succeeded but the recreate half failed must find an already-deleted
|
|
633
|
+
// app a no-op, not a new error that wedges the apply forever.
|
|
431
634
|
const priorAppId = recorded[`zitadel:app:${action.key}`];
|
|
432
635
|
if (priorAppId) {
|
|
433
|
-
|
|
636
|
+
try {
|
|
637
|
+
await this.ports.zitadel.deleteApp(zitadelOrgId, projectId, priorAppId);
|
|
638
|
+
}
|
|
639
|
+
catch (error) {
|
|
640
|
+
if (!isNotFoundError(error))
|
|
641
|
+
throw error;
|
|
642
|
+
}
|
|
643
|
+
// The recreate half is retried on its own failure (the delete above
|
|
644
|
+
// already tolerates the resulting 404), so a retry can also land here
|
|
645
|
+
// after an earlier attempt's create call actually succeeded against
|
|
646
|
+
// ZITADEL but whose response this executor never saw (a dropped
|
|
647
|
+
// connection, a killed process). That app is deleted, not reused: the
|
|
648
|
+
// lost response also lost its clientSecret and clientId, which
|
|
649
|
+
// `listApps` cannot return, so reusing it would leave the secret
|
|
650
|
+
// undelivered and the clientId unrecorded forever. The create below
|
|
651
|
+
// then runs every side effect. A first-ever create (no `priorAppId`)
|
|
652
|
+
// has nothing to find here and never pays this read.
|
|
653
|
+
const orphan = await this.findRecreatedApp(zitadelOrgId, projectId, action.key, appType);
|
|
654
|
+
if (orphan) {
|
|
655
|
+
try {
|
|
656
|
+
await this.ports.zitadel.deleteApp(zitadelOrgId, projectId, orphan.id);
|
|
657
|
+
}
|
|
658
|
+
catch (error) {
|
|
659
|
+
if (!isNotFoundError(error))
|
|
660
|
+
throw error;
|
|
661
|
+
}
|
|
662
|
+
}
|
|
434
663
|
}
|
|
435
664
|
if (appType === 'api') {
|
|
436
665
|
const created = await this.ports.zitadel.createApiApp(zitadelOrgId, projectId, {
|
|
@@ -442,26 +671,71 @@ export class FoundryStepExecutor {
|
|
|
442
671
|
await this.ports.deliverSecret(`zitadel:app:${org.slug}:${action.key}`, created.clientSecret);
|
|
443
672
|
return { resourceId: created.appId };
|
|
444
673
|
}
|
|
445
|
-
|
|
674
|
+
// #139: the fixed `cms` client (`diff.ts`'s `planCmsClient`) signs
|
|
675
|
+
// people into the cms studio at `cms.<domain>`, not the org's own app
|
|
676
|
+
// hostname -- `org.site.enabled` guarantees `org.domain` is set
|
|
677
|
+
// (`org.ts`'s `validateSite`), the same guarantee `cmsHostnameFor`'s own
|
|
678
|
+
// doc comment relies on. #164: the fixed `agora` client (`diff.ts`'s
|
|
679
|
+
// `planAgoraClient`) is the same shape, always -- there is no
|
|
680
|
+
// `org.site.enabled`-style guard to check, since Agora is always on.
|
|
681
|
+
const isCmsClient = action.key === 'cms' && org.site.enabled;
|
|
682
|
+
// The Agora client stays `appType: 'web'`: providers' `createOidcApp`
|
|
683
|
+
// makes every non-native app a public client (authorization code + PKCE,
|
|
684
|
+
// `OIDC_AUTH_METHOD_TYPE_NONE`, no secret), which is what Agora's browser
|
|
685
|
+
// SPA needs. Providers cannot create ZITADEL's `USER_AGENT` app kind.
|
|
686
|
+
const isAgoraClient = action.key === 'agora';
|
|
687
|
+
const hostname = isCmsClient
|
|
688
|
+
? cmsHostnameFor(org)
|
|
689
|
+
: isAgoraClient
|
|
690
|
+
? agoraHostnameFor(org)
|
|
691
|
+
: hostnameFor(org);
|
|
446
692
|
// Native apps sign in via device code, with no redirect of their own
|
|
447
693
|
// (auth/scripts/provision.mjs:475-497, prior art cited in the decisions
|
|
448
694
|
// file) -- empty arrays rather than a web app's callback/logout URLs.
|
|
695
|
+
// #164: Agora's own callback path is `/callback`, not `/auth/callback`
|
|
696
|
+
// (agora/apps/agora/README.md, "Deploying an Agora": "The issuer's OAuth
|
|
697
|
+
// client needs `https://<agora address>/callback` as a redirect URI") --
|
|
698
|
+
// Agora is not built on `@wtfalch/auth`'s `NextAuth` mount the way every
|
|
699
|
+
// other web app this file provisions is.
|
|
700
|
+
const redirectUri = isAgoraClient
|
|
701
|
+
? `https://${hostname}/callback`
|
|
702
|
+
: `https://${hostname}/auth/callback`;
|
|
449
703
|
const created = await this.ports.zitadel.createOidcApp(zitadelOrgId, projectId, {
|
|
450
704
|
name: action.key,
|
|
451
705
|
appType,
|
|
452
|
-
redirectUris: appType === 'native' ? [] : [
|
|
706
|
+
redirectUris: appType === 'native' ? [] : [redirectUri],
|
|
453
707
|
postLogoutRedirectUris: appType === 'native' ? [] : [`https://${hostname}/`],
|
|
454
708
|
});
|
|
455
709
|
if (appType === 'web') {
|
|
456
|
-
// `coolifyApp`'s own `pushAppEnv` reads
|
|
457
|
-
// (level-1 app-env campaign, gap P2)
|
|
710
|
+
// `coolifyApp`'s own `pushAppEnv` reads `APP_CLIENT_ID_SCOPE_KEY` to
|
|
711
|
+
// push AUTH_CLIENT_ID (level-1 app-env campaign, gap P2); `cmsSite`
|
|
712
|
+
// reads `CMS_CLIENT_ID_SCOPE_KEY` for the same env on the cms app
|
|
713
|
+
// (#139), and `coolifyAgora` reads `AGORA_CLIENT_ID_SCOPE_KEY` for
|
|
714
|
+
// `AGORA_CONFIG`'s own `clientId` (#164) -- public, not a secret, so a
|
|
458
715
|
// plain scope id, not `deliverSecret`. A native app signs in via
|
|
459
716
|
// device code with no cookie session of its own, so its clientId is
|
|
460
|
-
// never something
|
|
461
|
-
context.recordScopeId(
|
|
717
|
+
// never something an app's env needs.
|
|
718
|
+
context.recordScopeId(isCmsClient
|
|
719
|
+
? CMS_CLIENT_ID_SCOPE_KEY
|
|
720
|
+
: isAgoraClient
|
|
721
|
+
? AGORA_CLIENT_ID_SCOPE_KEY
|
|
722
|
+
: APP_CLIENT_ID_SCOPE_KEY, created.clientId);
|
|
462
723
|
}
|
|
463
724
|
return { resourceId: created.appId };
|
|
464
725
|
}
|
|
726
|
+
/**
|
|
727
|
+
* The app an earlier attempt's recreate already made under the same
|
|
728
|
+
* name, whose response this executor never saw -- deleted before the
|
|
729
|
+
* create so no duplicate is left behind. Matches on name and the
|
|
730
|
+
* desired kind, and skips a removed app -- ZITADEL's
|
|
731
|
+
* search can return one that has since been deleted (`isNotFoundError`'s
|
|
732
|
+
* own doc comment covers why a delete can be retried into a 404), and
|
|
733
|
+
* that is not the survivor this is looking for.
|
|
734
|
+
*/
|
|
735
|
+
async findRecreatedApp(zitadelOrgId, projectId, name, appType) {
|
|
736
|
+
const apps = await this.ports.zitadel.listApps(zitadelOrgId, projectId);
|
|
737
|
+
return apps.find((app) => app.name === name && app.appType === appType && app.state !== 'APP_STATE_REMOVED');
|
|
738
|
+
}
|
|
465
739
|
async ensureZitadelProject(org, zitadelOrgId, context) {
|
|
466
740
|
const recorded = context.recordedIds();
|
|
467
741
|
const existing = recorded.zitadel_project;
|
|
@@ -537,6 +811,9 @@ export class FoundryStepExecutor {
|
|
|
537
811
|
build_pack: org.hosting.buildPack,
|
|
538
812
|
name: org.slug,
|
|
539
813
|
domains: hostnameFor(org),
|
|
814
|
+
// Only the shared standard app builds from this repo; a bespoke
|
|
815
|
+
// app's Dockerfile is at its own repo's root.
|
|
816
|
+
dockerfile_location: org.bespokeAppRepo ? undefined : DEFAULT_APP_DOCKERFILE,
|
|
540
817
|
});
|
|
541
818
|
appId = created.uuid;
|
|
542
819
|
}
|
|
@@ -562,6 +839,12 @@ export class FoundryStepExecutor {
|
|
|
562
839
|
// unlike the database push above: every app has a hostname, and an
|
|
563
840
|
// upsert of the same value on a later apply is a no-op, not a change.
|
|
564
841
|
await this.pushAppEnv(appId, org, context);
|
|
842
|
+
// #140 rework (2026-09-28 lettermint-org-mail swap): the hosted app's own
|
|
843
|
+
// Lettermint project, domain restriction and `MAIL_FROM` (only the app
|
|
844
|
+
// started by this same step, per the design's "only apps that send mail
|
|
845
|
+
// get it" -- cms and calls do not). A no-op until `coolify:mail` has
|
|
846
|
+
// actually resolved a mail domain for this org.
|
|
847
|
+
await this.ensureAppLettermintMail(appId, org, context, Boolean(existing));
|
|
565
848
|
return { resourceId: appId };
|
|
566
849
|
}
|
|
567
850
|
/**
|
|
@@ -735,6 +1018,123 @@ export class FoundryStepExecutor {
|
|
|
735
1018
|
return null;
|
|
736
1019
|
return randomBytes(32).toString('base64');
|
|
737
1020
|
}
|
|
1021
|
+
/**
|
|
1022
|
+
* #140 rework (2026-09-28 lettermint-org-mail swap, "Core rule" +
|
|
1023
|
+
* "Credential model"): all of the org's Lettermint work now lives here,
|
|
1024
|
+
* in the app step, since the first project -- and so the first domain
|
|
1025
|
+
* restriction -- can only exist once the token has a home (the app env). A
|
|
1026
|
+
* no-op until `coolify:mail` has recorded {@link MAIL_DOMAIN_SCOPE_KEY} for
|
|
1027
|
+
* this org; the next apply fills it in once `coolify:mail` runs first
|
|
1028
|
+
* (`diff.ts`'s `computePlanDetail` order).
|
|
1029
|
+
*
|
|
1030
|
+
* Order, exactly as the swap's "Core rule" states it: ensure the
|
|
1031
|
+
* `<slug>-app` project, ensure the mail domain, restrict that domain to the
|
|
1032
|
+
* org's own projects, publish its DNS records, trigger verification, then
|
|
1033
|
+
* delete every stale `<slug>-app` project. New projects are created and the
|
|
1034
|
+
* domain restricted to them before any old project is deleted, so the
|
|
1035
|
+
* restriction list never becomes empty.
|
|
1036
|
+
*
|
|
1037
|
+
* Mint rule ("Credential model"): the project is healthy when the app env
|
|
1038
|
+
* holds `LETTERMINT_API_TOKEN` and Lettermint lists exactly one project
|
|
1039
|
+
* named `<slug>-app` -- unlike Resend's key, a Lettermint project's name
|
|
1040
|
+
* carries no domain id, since a domain switch is handled entirely by
|
|
1041
|
+
* {@link LettermintPort.restrictDomainToProjects} below and never needs a
|
|
1042
|
+
* fresh project. Otherwise a fresh project is created and its token
|
|
1043
|
+
* written to the env first, and only then is every other `<slug>-app`
|
|
1044
|
+
* project deleted -- a lost mint response and a duplicate both end the
|
|
1045
|
+
* same way. An app that was already running is restarted before that
|
|
1046
|
+
* delete, since a container reads its env only at start (`running`, the
|
|
1047
|
+
* same reason {@link ensureLogVolume} restarts one).
|
|
1048
|
+
*
|
|
1049
|
+
* The domain's restriction is rebuilt from every project named `<slug>-app`
|
|
1050
|
+
* or `<slug>-relay` (the relay project itself is #149, not built yet) --
|
|
1051
|
+
* never patched, so it can never end up scoped only to a project about to
|
|
1052
|
+
* be deleted.
|
|
1053
|
+
*
|
|
1054
|
+
* `MAIL_FROM` is upserted every time: it depends only on the mail domain
|
|
1055
|
+
* `coolify:mail` resolved.
|
|
1056
|
+
*
|
|
1057
|
+
* The token goes straight to `bulkUpsertEnvs` and nowhere else: never
|
|
1058
|
+
* returned, never recorded in `serviceIds` (`recordScopeId`'s own doc
|
|
1059
|
+
* comment), never logged, never interpolated into a thrown error -- the
|
|
1060
|
+
* same discipline `pushDatabaseUrl`'s doc comment states for a database's
|
|
1061
|
+
* own connection string.
|
|
1062
|
+
*/
|
|
1063
|
+
async ensureAppLettermintMail(appId, org, context, running) {
|
|
1064
|
+
const mailDomain = context.recordedIds()[MAIL_DOMAIN_SCOPE_KEY];
|
|
1065
|
+
if (!mailDomain)
|
|
1066
|
+
return;
|
|
1067
|
+
const appName = `${org.slug}-app`;
|
|
1068
|
+
const relayName = `${org.slug}-relay`;
|
|
1069
|
+
const [projects, envs] = await Promise.all([
|
|
1070
|
+
this.ports.lettermint.listProjects(),
|
|
1071
|
+
this.ports.coolify.listEnvs(appId),
|
|
1072
|
+
]);
|
|
1073
|
+
const envValue = (key) => envs.find((e) => e.key === key && !e.is_preview)?.value;
|
|
1074
|
+
const ours = projects.filter((p) => p.name === appName);
|
|
1075
|
+
const held = ours.length === 1 ? ours[0] : undefined;
|
|
1076
|
+
const healthy = held !== undefined && Boolean(envValue('LETTERMINT_API_TOKEN'));
|
|
1077
|
+
let allProjects = projects;
|
|
1078
|
+
let keepProjectId;
|
|
1079
|
+
const envUpdates = [];
|
|
1080
|
+
if (healthy && held) {
|
|
1081
|
+
keepProjectId = held.id;
|
|
1082
|
+
}
|
|
1083
|
+
else {
|
|
1084
|
+
const created = await this.ports.lettermint.createProject(appName);
|
|
1085
|
+
keepProjectId = created.project.id;
|
|
1086
|
+
allProjects = [...projects, created.project];
|
|
1087
|
+
envUpdates.push({ key: 'LETTERMINT_API_TOKEN', value: created.token });
|
|
1088
|
+
}
|
|
1089
|
+
envUpdates.push({ key: 'MAIL_FROM', value: `no-reply@${mailDomain}` });
|
|
1090
|
+
await this.ports.coolify.bulkUpsertEnvs(appId, envUpdates);
|
|
1091
|
+
// A running container read its env at start and still holds the old
|
|
1092
|
+
// token, so a fresh one only reaches it through a restart -- before the
|
|
1093
|
+
// old project is deleted below. A just-created app starts with the new env.
|
|
1094
|
+
if (!healthy && running)
|
|
1095
|
+
await this.ports.coolify.restart(appId);
|
|
1096
|
+
const existing = await this.findExactLettermintDomain(mailDomain);
|
|
1097
|
+
// A crash between here and `restrictDomainToProjects` below is safe even
|
|
1098
|
+
// for a freshly created domain: DNS is only published, and verification
|
|
1099
|
+
// only triggered, after the restriction -- so an unrestricted domain
|
|
1100
|
+
// cannot verify and cannot send, whichever project owns it.
|
|
1101
|
+
const domain = existing
|
|
1102
|
+
? await this.ports.lettermint.getDomain(existing.id)
|
|
1103
|
+
: await this.ports.lettermint.createDomain(mailDomain);
|
|
1104
|
+
const scopedProjectIds = allProjects
|
|
1105
|
+
.filter((p) => p.name === appName || p.name === relayName)
|
|
1106
|
+
.map((p) => p.id);
|
|
1107
|
+
await this.ports.lettermint.restrictDomainToProjects(domain.id, scopedProjectIds);
|
|
1108
|
+
const zone = await this.ports.cloudflare.findZoneByApex(mailDomain);
|
|
1109
|
+
if (!zone) {
|
|
1110
|
+
throw new Error(`coolify:app for ${org.slug}: no Cloudflare zone found for ${mailDomain}`);
|
|
1111
|
+
}
|
|
1112
|
+
await this.publishLettermintDnsRecords(zone.id, domain.dnsRecords);
|
|
1113
|
+
await this.ports.lettermint.verifyDomain(domain.id);
|
|
1114
|
+
for (const project of allProjects) {
|
|
1115
|
+
if (project.name === appName && project.id !== keepProjectId) {
|
|
1116
|
+
await this.ports.lettermint.deleteProject(project.id);
|
|
1117
|
+
}
|
|
1118
|
+
}
|
|
1119
|
+
}
|
|
1120
|
+
/**
|
|
1121
|
+
* Review finding (post-#140 rework): {@link LettermintPort.findDomain}'s
|
|
1122
|
+
* own match is partial and case-insensitive, so a raw result can be a
|
|
1123
|
+
* lookalike (`notacme.com`, `mail.acme.com`) rather than `name` itself --
|
|
1124
|
+
* and every caller here full-replaces the found domain's project
|
|
1125
|
+
* restriction ({@link LettermintPort.restrictDomainToProjects}), so taking
|
|
1126
|
+
* a lookalike as a match would let it be taken over. Every domain lookup
|
|
1127
|
+
* in this class (`ensureAppLettermintMail`, `parkLettermintMail`,
|
|
1128
|
+
* `deleteStaleLettermintDomain`) goes through this instead of the port
|
|
1129
|
+
* directly, and treats anything but an exact, case-insensitive match as
|
|
1130
|
+
* not found.
|
|
1131
|
+
*/
|
|
1132
|
+
async findExactLettermintDomain(name) {
|
|
1133
|
+
const found = await this.ports.lettermint.findDomain(name);
|
|
1134
|
+
if (!found || found.domain.toLowerCase() !== name.toLowerCase())
|
|
1135
|
+
return undefined;
|
|
1136
|
+
return found;
|
|
1137
|
+
}
|
|
738
1138
|
/**
|
|
739
1139
|
* `plan/diff.ts`'s `planSite` (#116, slicing #99's "provision the cms app,
|
|
740
1140
|
* database and site app per org"): the 2026-09-25 decision
|
|
@@ -832,11 +1232,32 @@ export class FoundryStepExecutor {
|
|
|
832
1232
|
// (see that section's own comment) instead of caught, so a database
|
|
833
1233
|
// failure must not also cost the app these two calls already have
|
|
834
1234
|
// everything they need for.
|
|
835
|
-
|
|
1235
|
+
//
|
|
1236
|
+
// #139: `@wtfalch/cms`'s `AUTH_*` contract
|
|
1237
|
+
// (https://github.com/wtfalch/cms/blob/main/apps/host/.env.example),
|
|
1238
|
+
// pushed in this same call. `AUTH_ORGANIZATION_ID` and `AUTH_CLIENT_ID`
|
|
1239
|
+
// are pushed only once their own source step has recorded them --
|
|
1240
|
+
// `zitadel:issuer` and this org's `cms` `zitadel:app` step
|
|
1241
|
+
// (`diff.ts`'s `planCmsClient`), both ordered before `cms:site` for
|
|
1242
|
+
// exactly this reason, the same rule `pushAppEnv` follows for the
|
|
1243
|
+
// hosted app. `AUTH_COOKIE_SECRET` is generated once and never rotated
|
|
1244
|
+
// (`ensureAuthCookieSecret`'s own doc comment).
|
|
1245
|
+
const recorded = context.recordedIds();
|
|
1246
|
+
const cmsEnvs = [
|
|
836
1247
|
{ key: 'APP_URL', value: `https://${cmsHostnameFor(org)}` },
|
|
837
1248
|
{ key: 'CMS_ORGANISATION_ID', value: org.id },
|
|
838
1249
|
{ key: 'CMS_SITE_URL', value: `https://${domain}` },
|
|
839
|
-
]
|
|
1250
|
+
];
|
|
1251
|
+
if (recorded.zitadel_org) {
|
|
1252
|
+
cmsEnvs.push({ key: 'AUTH_ORGANIZATION_ID', value: recorded.zitadel_org });
|
|
1253
|
+
}
|
|
1254
|
+
const cmsClientId = recorded[CMS_CLIENT_ID_SCOPE_KEY];
|
|
1255
|
+
if (cmsClientId)
|
|
1256
|
+
cmsEnvs.push({ key: 'AUTH_CLIENT_ID', value: cmsClientId });
|
|
1257
|
+
const cookieSecret = await this.ensureAuthCookieSecret(cms.appId);
|
|
1258
|
+
if (cookieSecret)
|
|
1259
|
+
cmsEnvs.push({ key: 'AUTH_COOKIE_SECRET', value: cookieSecret });
|
|
1260
|
+
await this.ports.coolify.bulkUpsertEnvs(cms.appId, cmsEnvs);
|
|
840
1261
|
await this.ports.coolify.bulkUpsertEnvs(site.appId, [
|
|
841
1262
|
{ key: 'CMS_URL', value: `https://${cmsHostnameFor(org)}` },
|
|
842
1263
|
]);
|
|
@@ -1015,6 +1436,521 @@ export class FoundryStepExecutor {
|
|
|
1015
1436
|
});
|
|
1016
1437
|
return { appId: created.uuid, existed: false };
|
|
1017
1438
|
}
|
|
1439
|
+
// ---- calls (#128) ------------------------------------------------------
|
|
1440
|
+
/**
|
|
1441
|
+
* `plan/diff.ts`'s `planCalls`: the org's own calls Matrix stack, entirely
|
|
1442
|
+
* separate from chat's (`calls-matrix.ts`'s own top doc comment) -- two
|
|
1443
|
+
* Coolify-managed Postgres databases, a Synapse app and an MAS app, no
|
|
1444
|
+
* public hostname of its own (neither app is reachable from outside
|
|
1445
|
+
* Coolify's own Docker network) -- `coolifyCallsService` below (#145, part 2
|
|
1446
|
+
* of #128) is the one that gets a public hostname, since it is the one a
|
|
1447
|
+
* browser actually talks to. Park stops Synapse and MAS only, the same
|
|
1448
|
+
* park-not-delete rule `coolifyApp`/`cmsSite` already follow (#7
|
|
1449
|
+
* corrections 1) -- their databases are never touched from that branch, the
|
|
1450
|
+
* same way `cmsSite`'s own database never is.
|
|
1451
|
+
*/
|
|
1452
|
+
async coolifyCalls(action, org, context) {
|
|
1453
|
+
if (action.desired.enabled === false) {
|
|
1454
|
+
const recorded = context.recordedIds();
|
|
1455
|
+
const synapseId = recorded[CALLS_SYNAPSE_SCOPE_KEY];
|
|
1456
|
+
const masId = recorded[CALLS_MAS_SCOPE_KEY];
|
|
1457
|
+
if (!synapseId && !masId) {
|
|
1458
|
+
throw new Error(`coolify:calls for ${org.slug}: no live calls stack recorded, nothing to park`);
|
|
1459
|
+
}
|
|
1460
|
+
if (synapseId)
|
|
1461
|
+
await this.ports.coolify.raw('POST', `/applications/${synapseId}/stop`);
|
|
1462
|
+
if (masId)
|
|
1463
|
+
await this.ports.coolify.raw('POST', `/applications/${masId}/stop`);
|
|
1464
|
+
return { resourceId: masId ?? synapseId };
|
|
1465
|
+
}
|
|
1466
|
+
const projectUuid = await this.ensureCoolifyProject(org, context);
|
|
1467
|
+
const { environment } = await this.ports.coolify.findOrCreateEnvironment(projectUuid, 'production');
|
|
1468
|
+
const server = await this.resolveServer();
|
|
1469
|
+
const names = {
|
|
1470
|
+
synapse: `${org.slug}-calls-synapse`,
|
|
1471
|
+
mas: `${org.slug}-calls-mas`,
|
|
1472
|
+
synapseDb: `${org.slug}-calls-synapse-db`,
|
|
1473
|
+
masDb: `${org.slug}-calls-mas-db`,
|
|
1474
|
+
};
|
|
1475
|
+
const synapseDbId = await this.ensureCallsDatabase(context, projectUuid, environment, names.synapseDb, 'synapse', CALLS_SYNAPSE_DB_SCOPE_KEY, CALLS_SYNAPSE_DB_INITDB_ARGS);
|
|
1476
|
+
context.recordScopeId(CALLS_SYNAPSE_DB_SCOPE_KEY, synapseDbId);
|
|
1477
|
+
const masDbId = await this.ensureCallsDatabase(context, projectUuid, environment, names.masDb, 'mas', CALLS_MAS_DB_SCOPE_KEY);
|
|
1478
|
+
context.recordScopeId(CALLS_MAS_DB_SCOPE_KEY, masDbId);
|
|
1479
|
+
const [synapseDbRow, masDbRow] = await Promise.all([
|
|
1480
|
+
this.ports.coolify.getDatabase(synapseDbId),
|
|
1481
|
+
this.ports.coolify.getDatabase(masDbId),
|
|
1482
|
+
]);
|
|
1483
|
+
const synapseDb = synapseDbRow.internal_db_url
|
|
1484
|
+
? parseCallsDbUrl(synapseDbRow.internal_db_url)
|
|
1485
|
+
: null;
|
|
1486
|
+
const masDb = masDbRow.internal_db_url ? parseCallsDbUrl(masDbRow.internal_db_url) : null;
|
|
1487
|
+
if (!synapseDb || !masDb) {
|
|
1488
|
+
// Static config check before the two apps below are ever touched
|
|
1489
|
+
// (issue #128's "static config checks run before the first Coolify
|
|
1490
|
+
// call", carried from PR 344's review) -- Coolify can take a moment to
|
|
1491
|
+
// report a freshly created database's `internal_db_url`; the next
|
|
1492
|
+
// apply retries this from a clean, unstarted state rather than half
|
|
1493
|
+
// deploying Synapse or MAS against a connection string it does not have
|
|
1494
|
+
// yet.
|
|
1495
|
+
throw new Error(`coolify:calls for ${org.slug}: a database has no internal_db_url yet -- retry this apply once Coolify reports one`);
|
|
1496
|
+
}
|
|
1497
|
+
const synapse = await this.findOrCreateCallsApp(names.synapse, environment.id, () => this.ports.coolify.createDockerImageApplication({
|
|
1498
|
+
project_uuid: projectUuid,
|
|
1499
|
+
server_uuid: server.uuid,
|
|
1500
|
+
environment_name: environment.name,
|
|
1501
|
+
environment_uuid: environment.uuid,
|
|
1502
|
+
docker_registry_image_name: SYNAPSE_IMAGE,
|
|
1503
|
+
docker_registry_image_tag: SYNAPSE_TAG,
|
|
1504
|
+
ports_exposes: '8008',
|
|
1505
|
+
name: names.synapse,
|
|
1506
|
+
description: `Synapse for ${org.slug}'s calls stack (${SYNAPSE_IMAGE}:${SYNAPSE_TAG})`,
|
|
1507
|
+
instant_deploy: false,
|
|
1508
|
+
}));
|
|
1509
|
+
if (!synapse.existed) {
|
|
1510
|
+
await this.ports.coolify.updateApplication(synapse.appId, {
|
|
1511
|
+
health_check_enabled: false,
|
|
1512
|
+
custom_internal_name: names.synapse,
|
|
1513
|
+
});
|
|
1514
|
+
}
|
|
1515
|
+
context.recordScopeId(CALLS_SYNAPSE_SCOPE_KEY, synapse.appId);
|
|
1516
|
+
const mas = await this.findOrCreateCallsApp(names.mas, environment.id, () => this.ports.coolify.createDockerfileApplication({
|
|
1517
|
+
project_uuid: projectUuid,
|
|
1518
|
+
server_uuid: server.uuid,
|
|
1519
|
+
environment_name: environment.name,
|
|
1520
|
+
environment_uuid: environment.uuid,
|
|
1521
|
+
dockerfile: CALLS_MAS_DOCKERFILE,
|
|
1522
|
+
name: names.mas,
|
|
1523
|
+
description: `matrix-authentication-service for ${org.slug}'s calls stack`,
|
|
1524
|
+
instant_deploy: false,
|
|
1525
|
+
}));
|
|
1526
|
+
if (!mas.existed) {
|
|
1527
|
+
await this.ports.coolify.updateApplication(mas.appId, {
|
|
1528
|
+
health_check_enabled: false,
|
|
1529
|
+
custom_internal_name: names.mas,
|
|
1530
|
+
});
|
|
1531
|
+
}
|
|
1532
|
+
context.recordScopeId(CALLS_MAS_SCOPE_KEY, mas.appId);
|
|
1533
|
+
// Synapse's own signing key, media store and pid file (#75's own
|
|
1534
|
+
// durable-volume convention, reused here for a different reason: this
|
|
1535
|
+
// volume is Synapse's own state, not a log).
|
|
1536
|
+
const synapseStorages = await this.ports.coolify.listStorages(synapse.appId);
|
|
1537
|
+
if (!(synapseStorages.persistent_storages ?? []).some((s) => s.mount_path === '/data')) {
|
|
1538
|
+
await this.ports.coolify.createPersistentStorage(synapse.appId, {
|
|
1539
|
+
name: `${names.synapse}-data`,
|
|
1540
|
+
mount_path: '/data',
|
|
1541
|
+
});
|
|
1542
|
+
}
|
|
1543
|
+
const secrets = await this.ensureCallsMatrixSecrets(mas.appId);
|
|
1544
|
+
await this.ensureCallsFileStorage(synapse.appId, '/data/homeserver.yaml', callsSynapseHomeserverYaml({
|
|
1545
|
+
orgSlug: org.slug,
|
|
1546
|
+
db: synapseDb,
|
|
1547
|
+
masHost: names.mas,
|
|
1548
|
+
masSharedSecret: secrets.value.masSynapseSharedSecret,
|
|
1549
|
+
}));
|
|
1550
|
+
// Synapse's official image reads `/data/homeserver.yaml` by default, but
|
|
1551
|
+
// it also needs write access to the mounted volume it does not own by
|
|
1552
|
+
// default (`calls/proof/provider/scripts/guest-stack.mjs`'s own working
|
|
1553
|
+
// config carries the same three envs).
|
|
1554
|
+
await this.ports.coolify.bulkUpsertEnvs(synapse.appId, [
|
|
1555
|
+
{ key: 'SYNAPSE_CONFIG_PATH', value: '/data/homeserver.yaml' },
|
|
1556
|
+
{ key: 'UID', value: '0' },
|
|
1557
|
+
{ key: 'GID', value: '0' },
|
|
1558
|
+
]);
|
|
1559
|
+
await this.ensureCallsFileStorage(mas.appId, '/config/config.yaml', callsMasConfigYaml({
|
|
1560
|
+
orgSlug: org.slug,
|
|
1561
|
+
db: masDb,
|
|
1562
|
+
synapseHost: names.synapse,
|
|
1563
|
+
sharedSecret: secrets.value.masSynapseSharedSecret,
|
|
1564
|
+
clientId: secrets.value.masClientId,
|
|
1565
|
+
clientSecret: secrets.value.masClientSecret,
|
|
1566
|
+
encryptionKey: secrets.value.masEncryptionKey,
|
|
1567
|
+
rsaKeyPem: secrets.value.masRsaKeyPem,
|
|
1568
|
+
ecKeyPem: secrets.value.masEcKeyPem,
|
|
1569
|
+
}));
|
|
1570
|
+
// Issue #128's "a running app is redeployed when its config changed"
|
|
1571
|
+
// (PR 344's review fix): a file-storage write alone never reaches a
|
|
1572
|
+
// running container until its next deploy, but redeploying an unchanged,
|
|
1573
|
+
// already-running app on every apply would mean this step never
|
|
1574
|
+
// converges. A brand new app or a freshly minted secret set are the only
|
|
1575
|
+
// two reasons the file just written could differ from what the container
|
|
1576
|
+
// already has -- reusing existing secrets means byte-identical content.
|
|
1577
|
+
if (!synapse.existed || secrets.changed)
|
|
1578
|
+
await this.ports.coolify.deploy(synapse.appId);
|
|
1579
|
+
if (!mas.existed || secrets.changed)
|
|
1580
|
+
await this.ports.coolify.deploy(mas.appId);
|
|
1581
|
+
return { resourceId: mas.appId };
|
|
1582
|
+
}
|
|
1583
|
+
/**
|
|
1584
|
+
* `coolifyCalls`'s own find-or-create for Synapse/MAS, restarting a
|
|
1585
|
+
* found-but-stopped app (#116 review round 3, Finding 1's same fix). Reused
|
|
1586
|
+
* by `coolifyCallsService` (#145) for the calls service app itself -- the
|
|
1587
|
+
* same find-by-name-then-start-or-create shape applies unchanged.
|
|
1588
|
+
*/
|
|
1589
|
+
async findOrCreateCallsApp(name, environmentId, create) {
|
|
1590
|
+
const existing = await this.ports.coolify.findApplicationByName(name, {
|
|
1591
|
+
environmentIds: [environmentId],
|
|
1592
|
+
});
|
|
1593
|
+
if (existing) {
|
|
1594
|
+
const live = await this.ports.coolify.getApplication(existing.uuid);
|
|
1595
|
+
if (!isRunningOrStarting(live.status)) {
|
|
1596
|
+
await this.ports.coolify.raw('POST', `/applications/${existing.uuid}/start`);
|
|
1597
|
+
}
|
|
1598
|
+
return { appId: existing.uuid, existed: true };
|
|
1599
|
+
}
|
|
1600
|
+
const created = await create();
|
|
1601
|
+
return { appId: created.uuid, existed: false };
|
|
1602
|
+
}
|
|
1603
|
+
/**
|
|
1604
|
+
* `coolifyCalls`'s own database find-or-create, modeled on
|
|
1605
|
+
* `ensureCmsDatabase`: `context.recordedIds()` is checked first since
|
|
1606
|
+
* neither database has a `system:kind:key` slot of its own (this step's one
|
|
1607
|
+
* auto-recorded `resourceId` is the MAS app, not either database --
|
|
1608
|
+
* {@link CALLS_SYNAPSE_SCOPE_KEY}'s own doc comment), then `listDatabases`
|
|
1609
|
+
* finds one already created by name before `createPostgresDatabase` makes
|
|
1610
|
+
* one. Reused by `coolifyCallsService` (#145) for the calls service's own
|
|
1611
|
+
* database, whose {@link CALLS_SERVICE_DB_SCOPE_KEY} has no
|
|
1612
|
+
* `system:kind:key` slot of its own for the same reason.
|
|
1613
|
+
*/
|
|
1614
|
+
async ensureCallsDatabase(context, projectUuid, environment, name, postgresDb, scopeKey, postgresInitdbArgs) {
|
|
1615
|
+
const existing = context.recordedIds()[scopeKey];
|
|
1616
|
+
if (existing)
|
|
1617
|
+
return existing;
|
|
1618
|
+
const found = (await this.ports.coolify.listDatabases()).find((d) => d.name === name && d.environment_id === environment.id);
|
|
1619
|
+
if (found)
|
|
1620
|
+
return found.uuid;
|
|
1621
|
+
const server = await this.resolveServer();
|
|
1622
|
+
const created = await this.ports.coolify.createPostgresDatabase({
|
|
1623
|
+
project_uuid: projectUuid,
|
|
1624
|
+
server_uuid: server.uuid,
|
|
1625
|
+
environment_name: environment.name,
|
|
1626
|
+
environment_uuid: environment.uuid,
|
|
1627
|
+
name,
|
|
1628
|
+
postgres_db: postgresDb,
|
|
1629
|
+
postgres_user: postgresDb,
|
|
1630
|
+
...(postgresInitdbArgs ? { postgres_initdb_args: postgresInitdbArgs } : {}),
|
|
1631
|
+
});
|
|
1632
|
+
return created.uuid;
|
|
1633
|
+
}
|
|
1634
|
+
/**
|
|
1635
|
+
* Reads back {@link CALLS_SECRET_ENV_KEYS} from the MAS app's own envs
|
|
1636
|
+
* before minting fresh ones -- this stack's only memory across a re-run
|
|
1637
|
+
* (`calls-matrix.ts`'s `CallsMatrixSecrets` doc comment). `changed: true`
|
|
1638
|
+
* only when nothing usable was found: a partial set (a crash mid-mint) is
|
|
1639
|
+
* still treated as "mint fresh", since MAS's shared secret and Synapse's
|
|
1640
|
+
* copy of it must always agree.
|
|
1641
|
+
*/
|
|
1642
|
+
async ensureCallsMatrixSecrets(masAppId) {
|
|
1643
|
+
const envs = await this.ports.coolify.listEnvs(masAppId);
|
|
1644
|
+
const read = (key) => envs.find((e) => e.key === key && !e.is_preview)?.value;
|
|
1645
|
+
const fields = Object.entries(CALLS_SECRET_ENV_KEYS);
|
|
1646
|
+
const existing = {};
|
|
1647
|
+
for (const [field, envKey] of fields) {
|
|
1648
|
+
const value = read(envKey);
|
|
1649
|
+
if (value !== undefined)
|
|
1650
|
+
existing[field] = value;
|
|
1651
|
+
}
|
|
1652
|
+
const hasAll = fields.every(([field]) => existing[field] !== undefined);
|
|
1653
|
+
if (hasAll)
|
|
1654
|
+
return { value: existing, changed: false };
|
|
1655
|
+
const minted = mintCallsMatrixSecrets();
|
|
1656
|
+
await this.ports.coolify.bulkUpsertEnvs(masAppId, fields.map(([field, envKey]) => ({
|
|
1657
|
+
key: envKey,
|
|
1658
|
+
value: minted[field],
|
|
1659
|
+
multiline: field === 'masRsaKeyPem' || field === 'masEcKeyPem',
|
|
1660
|
+
})));
|
|
1661
|
+
return { value: minted, changed: true };
|
|
1662
|
+
}
|
|
1663
|
+
/**
|
|
1664
|
+
* Upserts a file storage's content, creating it if the application has none
|
|
1665
|
+
* at that mount path yet -- there is no way to read a file storage's
|
|
1666
|
+
* current content back from Coolify to diff it, the same limit
|
|
1667
|
+
* `archon/bootstrap/steps/chat-instance.ts`'s own `ensureFileStorage` notes,
|
|
1668
|
+
* so an existing one is always re-written.
|
|
1669
|
+
*/
|
|
1670
|
+
async ensureCallsFileStorage(applicationUuid, mountPath, content) {
|
|
1671
|
+
const storages = await this.ports.coolify.listStorages(applicationUuid);
|
|
1672
|
+
const found = storages.file_storages?.find((s) => s.mount_path === mountPath);
|
|
1673
|
+
if (found) {
|
|
1674
|
+
await this.ports.coolify.updateFileStorage(applicationUuid, found.uuid, content);
|
|
1675
|
+
}
|
|
1676
|
+
else {
|
|
1677
|
+
await this.ports.coolify.createFileStorage(applicationUuid, {
|
|
1678
|
+
mount_path: mountPath,
|
|
1679
|
+
content,
|
|
1680
|
+
});
|
|
1681
|
+
}
|
|
1682
|
+
}
|
|
1683
|
+
// ---- calls service (#145, part 2 of #128) -------------------------------
|
|
1684
|
+
/**
|
|
1685
|
+
* `plan/diff.ts`'s `planCallsService`: the org's own calls service --
|
|
1686
|
+
* `wtfalch/calls`'s `packages/service`, wired to the calls Matrix stack
|
|
1687
|
+
* {@link coolifyCalls} provisions and the shared LiveKit SFU (#122/#127).
|
|
1688
|
+
* Static config checks run first, before any Coolify call (#128's own
|
|
1689
|
+
* carried-over PR 344 review fix, restated in #145's own issue body): the
|
|
1690
|
+
* calls Matrix stack must already be recorded, its MAS app must already
|
|
1691
|
+
* carry its client id/secret, and the shared SFU's LiveKit credentials must
|
|
1692
|
+
* already be resolvable -- none of the three is minted or guessed here.
|
|
1693
|
+
* Park stops the app only, the same park-not-delete rule
|
|
1694
|
+
* {@link coolifyCalls}/{@link coolifyApp} already follow (#7 corrections
|
|
1695
|
+
* 1); its own database is never touched from that branch.
|
|
1696
|
+
*/
|
|
1697
|
+
async coolifyCallsService(action, org, context) {
|
|
1698
|
+
if (action.desired.enabled === false) {
|
|
1699
|
+
const appId = context.recordedIds()[`coolify:calls-service:${action.key}`];
|
|
1700
|
+
if (!appId) {
|
|
1701
|
+
throw new Error(`coolify:calls-service for ${org.slug}: no live app recorded, nothing to park`);
|
|
1702
|
+
}
|
|
1703
|
+
await this.ports.coolify.raw('POST', `/applications/${appId}/stop`);
|
|
1704
|
+
return { resourceId: appId };
|
|
1705
|
+
}
|
|
1706
|
+
const recorded = context.recordedIds();
|
|
1707
|
+
const synapseId = recorded[CALLS_SYNAPSE_SCOPE_KEY];
|
|
1708
|
+
const masId = recorded[CALLS_MAS_SCOPE_KEY];
|
|
1709
|
+
if (!synapseId || !masId) {
|
|
1710
|
+
throw new Error(`coolify:calls-service for ${org.slug}: the calls Matrix stack has not been provisioned yet -- coolify:calls must run first`);
|
|
1711
|
+
}
|
|
1712
|
+
const masCredentials = await this.readCallsMasClientCredentials(masId);
|
|
1713
|
+
if (!masCredentials) {
|
|
1714
|
+
throw new Error(`coolify:calls-service for ${org.slug}: the calls Matrix stack's MAS client credentials are not on the MAS app yet -- retry this apply once coolify:calls has minted them`);
|
|
1715
|
+
}
|
|
1716
|
+
const sfu = await this.ports.callsSfu.resolveCredentials();
|
|
1717
|
+
if (!sfu) {
|
|
1718
|
+
throw new Error(`coolify:calls-service for ${org.slug}: the shared calls SFU has no LiveKit credentials configured yet -- see https://github.com/wtfalch/foundry/issues/122`);
|
|
1719
|
+
}
|
|
1720
|
+
const projectUuid = await this.ensureCoolifyProject(org, context);
|
|
1721
|
+
const { environment } = await this.ports.coolify.findOrCreateEnvironment(projectUuid, 'production');
|
|
1722
|
+
const name = `${org.slug}-calls`;
|
|
1723
|
+
const synapseHost = `${org.slug}-calls-synapse`;
|
|
1724
|
+
const masHost = `${org.slug}-calls-mas`;
|
|
1725
|
+
const dbName = `${name}-db`;
|
|
1726
|
+
const dbId = await this.ensureCallsDatabase(context, projectUuid, environment, dbName, dbName.replace(/-/g, '_'), CALLS_SERVICE_DB_SCOPE_KEY);
|
|
1727
|
+
context.recordScopeId(CALLS_SERVICE_DB_SCOPE_KEY, dbId);
|
|
1728
|
+
const db = await this.ports.coolify.getDatabase(dbId);
|
|
1729
|
+
if (!db.internal_db_url) {
|
|
1730
|
+
throw new Error(`coolify:calls-service for ${org.slug}: its database has no internal_db_url yet -- retry this apply once Coolify reports one`);
|
|
1731
|
+
}
|
|
1732
|
+
const server = await this.resolveServer();
|
|
1733
|
+
const githubApp = await this.ports.coolify.requireGithubApp();
|
|
1734
|
+
const app = await this.findOrCreateCallsApp(name, environment.id, () => this.ports.coolify.createGithubAppApplication({
|
|
1735
|
+
project_uuid: projectUuid,
|
|
1736
|
+
server_uuid: server.uuid,
|
|
1737
|
+
environment_name: environment.name,
|
|
1738
|
+
environment_uuid: environment.uuid,
|
|
1739
|
+
github_app_uuid: githubApp.uuid,
|
|
1740
|
+
git_repository: CALLS_SERVICE_REPO,
|
|
1741
|
+
git_branch: 'main',
|
|
1742
|
+
build_pack: 'dockerfile',
|
|
1743
|
+
name,
|
|
1744
|
+
domains: callsHostnameFor(org),
|
|
1745
|
+
dockerfile_location: CALLS_SERVICE_DOCKERFILE,
|
|
1746
|
+
}));
|
|
1747
|
+
// Every app foundry deploys keeps its own logs (#75), reused as-is here.
|
|
1748
|
+
await this.ensureLogVolume(app.appId, org, app.existed);
|
|
1749
|
+
const bindingSecret = await this.ensureCallsBindingSecret(app.appId);
|
|
1750
|
+
const envs = [
|
|
1751
|
+
{ key: 'DATABASE_URL', value: db.internal_db_url },
|
|
1752
|
+
{ key: 'LIVEKIT_URL', value: CALLS_SFU_PRIVATE_URL },
|
|
1753
|
+
{ key: 'LIVEKIT_PUBLIC_URL', value: CALLS_SFU_PUBLIC_URL },
|
|
1754
|
+
{ key: 'LIVEKIT_API_KEY', value: sfu.apiKey },
|
|
1755
|
+
{ key: 'LIVEKIT_API_SECRET', value: sfu.apiSecret },
|
|
1756
|
+
{ key: 'MAS_URL', value: `http://${masHost}:8080/` },
|
|
1757
|
+
{ key: 'MAS_HOMESERVER', value: callsServerName(org.slug) },
|
|
1758
|
+
{ key: 'MAS_CLIENT_ID', value: masCredentials.clientId },
|
|
1759
|
+
{ key: 'MAS_CLIENT_SECRET', value: masCredentials.clientSecret },
|
|
1760
|
+
{ key: 'SYNAPSE_URL', value: `http://${synapseHost}:8008` },
|
|
1761
|
+
{ key: 'CALLS_ORIGIN', value: `https://${callsHostnameFor(org)}` },
|
|
1762
|
+
{ key: 'CALLS_HOST_ADAPTER', value: 'production' },
|
|
1763
|
+
{ key: 'CALLS_TRUSTED_IP_HEADER', value: 'cf-connecting-ip' },
|
|
1764
|
+
{ key: 'PORT', value: CALLS_SERVICE_PORT },
|
|
1765
|
+
];
|
|
1766
|
+
if (bindingSecret)
|
|
1767
|
+
envs.push({ key: 'CALLS_BINDING_SECRET', value: bindingSecret });
|
|
1768
|
+
await this.ports.coolify.bulkUpsertEnvs(app.appId, envs);
|
|
1769
|
+
return { resourceId: app.appId };
|
|
1770
|
+
}
|
|
1771
|
+
/**
|
|
1772
|
+
* Reads back the calls Matrix stack's MAS client id/secret from the MAS
|
|
1773
|
+
* app's own envs ({@link CALLS_SECRET_ENV_KEYS}) -- never mints: minting
|
|
1774
|
+
* either is {@link ensureCallsMatrixSecrets}'s own job, and a missing value
|
|
1775
|
+
* here means that stack has not finished provisioning yet, not something
|
|
1776
|
+
* this step can fix on its own.
|
|
1777
|
+
*/
|
|
1778
|
+
async readCallsMasClientCredentials(masAppId) {
|
|
1779
|
+
const envs = await this.ports.coolify.listEnvs(masAppId);
|
|
1780
|
+
const read = (key) => envs.find((e) => e.key === key && !e.is_preview)?.value;
|
|
1781
|
+
const clientId = read(CALLS_SECRET_ENV_KEYS.masClientId);
|
|
1782
|
+
const clientSecret = read(CALLS_SECRET_ENV_KEYS.masClientSecret);
|
|
1783
|
+
if (!clientId || !clientSecret)
|
|
1784
|
+
return null;
|
|
1785
|
+
return { clientId, clientSecret };
|
|
1786
|
+
}
|
|
1787
|
+
/**
|
|
1788
|
+
* `null` once the calls service app already has a `CALLS_BINDING_SECRET` --
|
|
1789
|
+
* never rotated, the same rule {@link ensureAuthCookieSecret} follows for
|
|
1790
|
+
* `AUTH_COOKIE_SECRET`: it wraps the provider-token ciphertext already
|
|
1791
|
+
* stored under any binding minted so far
|
|
1792
|
+
* (`calls/packages/service/src/config.mjs`'s own doc comment on
|
|
1793
|
+
* `CALLS_BINDING_SECRET`), so overwriting it would strand every binding
|
|
1794
|
+
* minted under the old value. 32 raw bytes, hex-encoded -- the exact shape
|
|
1795
|
+
* `config.mjs`'s own regex requires.
|
|
1796
|
+
*/
|
|
1797
|
+
async ensureCallsBindingSecret(appId) {
|
|
1798
|
+
const envs = await this.ports.coolify.listEnvs(appId);
|
|
1799
|
+
const already = envs.some((e) => e.key === 'CALLS_BINDING_SECRET' && !e.is_preview);
|
|
1800
|
+
if (already)
|
|
1801
|
+
return null;
|
|
1802
|
+
return randomBytes(32).toString('hex');
|
|
1803
|
+
}
|
|
1804
|
+
// ---- agora (#164) --------------------------------------------------------
|
|
1805
|
+
/**
|
|
1806
|
+
* `plan/diff.ts`'s `planAgora`: provisions the org's own Agora
|
|
1807
|
+
* (https://github.com/wtfalch/agora) as one more Coolify application, from
|
|
1808
|
+
* that repo's `apps/agora/Dockerfile` ({@link AGORA_REPO}'s doc comment),
|
|
1809
|
+
* and pushes its env. `findOrCreateCallsApp` is reused as-is for the
|
|
1810
|
+
* find-by-name-then-start-or-create shape, like {@link coolifyCallsService}.
|
|
1811
|
+
* There is no park branch: Agora is always on.
|
|
1812
|
+
*
|
|
1813
|
+
* The env comes from `plan/agora.ts`'s `desiredAgoraEnv`, the same function
|
|
1814
|
+
* the plan compares the live app against, so a re-plan after this step is
|
|
1815
|
+
* empty. The step never writes a partial config: when any input is missing
|
|
1816
|
+
* (the org's ZITADEL org id or Agora client id, or an issuer or
|
|
1817
|
+
* mail/messaging project id the host does not supply,
|
|
1818
|
+
* `AgoraHostPort`, read by the plan) it throws naming each one, before
|
|
1819
|
+
* it creates anything.
|
|
1820
|
+
*
|
|
1821
|
+
* `AGORA_CANONICAL_HOST` is always pushed. `AGORA_MAIL_UPSTREAM`/
|
|
1822
|
+
* `AGORA_FILES_UPSTREAM` are pushed once the platform's own mail/files
|
|
1823
|
+
* hosts are on and applied (#177's `platform-org.ts`'s
|
|
1824
|
+
* `agoraHostValuesFromPlatform`, read by `plan-apply.ts` when no host
|
|
1825
|
+
* `agora` port overrides it) -- absent otherwise. The chat upstreams are
|
|
1826
|
+
* still never pushed: chat has no admin API yet
|
|
1827
|
+
* (https://github.com/wtfalch/chat/issues/40). Agora answers 502 on those
|
|
1828
|
+
* routes rather than serving anything wrong.
|
|
1829
|
+
*/
|
|
1830
|
+
async coolifyAgora(action, org, context) {
|
|
1831
|
+
// The host values the plan read, carried in the step: never read again
|
|
1832
|
+
// here, so the env written is the env the plan showed.
|
|
1833
|
+
const host = action.desired.host;
|
|
1834
|
+
if (!host) {
|
|
1835
|
+
throw new Error(`Agora for ${org.slug} cannot be configured: the step carries no host values`);
|
|
1836
|
+
}
|
|
1837
|
+
const { env, missing } = desiredAgoraEnv(org, context.recordedIds(), host);
|
|
1838
|
+
if (missing.length > 0) {
|
|
1839
|
+
throw new Error(`Agora for ${org.slug} cannot be configured: missing ${missing.join(', ')}. Nothing was written.`);
|
|
1840
|
+
}
|
|
1841
|
+
const projectUuid = await this.ensureCoolifyProject(org, context);
|
|
1842
|
+
const { environment } = await this.ports.coolify.findOrCreateEnvironment(projectUuid, 'production');
|
|
1843
|
+
const name = `${org.slug}-agora`;
|
|
1844
|
+
const server = await this.resolveServer();
|
|
1845
|
+
const githubApp = await this.ports.coolify.requireGithubApp();
|
|
1846
|
+
const app = await this.findOrCreateCallsApp(name, environment.id, () => this.ports.coolify.createGithubAppApplication({
|
|
1847
|
+
project_uuid: projectUuid,
|
|
1848
|
+
server_uuid: server.uuid,
|
|
1849
|
+
environment_name: environment.name,
|
|
1850
|
+
environment_uuid: environment.uuid,
|
|
1851
|
+
github_app_uuid: githubApp.uuid,
|
|
1852
|
+
git_repository: AGORA_REPO,
|
|
1853
|
+
git_branch: 'main',
|
|
1854
|
+
build_pack: 'dockerfile',
|
|
1855
|
+
name,
|
|
1856
|
+
domains: agoraHostnameFor(org),
|
|
1857
|
+
dockerfile_location: AGORA_APP_DOCKERFILE,
|
|
1858
|
+
}));
|
|
1859
|
+
// Every app foundry deploys keeps its own logs (#75), reused as-is here.
|
|
1860
|
+
await this.ensureLogVolume(app.appId, org, app.existed);
|
|
1861
|
+
await this.ports.coolify.bulkUpsertEnvs(app.appId, Object.entries(env).map(([key, value]) => ({ key, value })));
|
|
1862
|
+
return { resourceId: app.appId };
|
|
1863
|
+
}
|
|
1864
|
+
// ---- jobs (#173) ---------------------------------------------------------
|
|
1865
|
+
/**
|
|
1866
|
+
* `plan/diff.ts`'s `planJobsRegistration`: registers the org's own app with
|
|
1867
|
+
* the platform's jobs scheduler (`JobsSchedulerPort.registerProject`, the
|
|
1868
|
+
* scheduler's `POST /admin/projects`), then hands the freshly minted
|
|
1869
|
+
* `secret` and `syncKey` to the host through `deliverSecret` -- but only
|
|
1870
|
+
* when this call actually minted them. The admin API is itself idempotent
|
|
1871
|
+
* by `(name, baseUrl)`: a repeat call for an already-registered project
|
|
1872
|
+
* resolves with just the `id`, and this never re-delivers a secret or sync
|
|
1873
|
+
* key it did not just mint, the same "registers an app once" rule
|
|
1874
|
+
* `diff.ts`'s own plan-level gate (recordedIds, mirroring `planOwner`)
|
|
1875
|
+
* already provides on a normal re-plan -- this check is what keeps that
|
|
1876
|
+
* true even if this step somehow runs twice on the same apply.
|
|
1877
|
+
*
|
|
1878
|
+
* Never logs, returns or records either credential (this file's own top
|
|
1879
|
+
* doc comment's "a secret is never a step's return value" rule) -- only the
|
|
1880
|
+
* scheduler's own non-secret project id comes back as `resourceId`.
|
|
1881
|
+
*/
|
|
1882
|
+
/** #177: the host's own port when it supplies one, else the platform's, built from its address and held key. */
|
|
1883
|
+
async filesPort() {
|
|
1884
|
+
const port = this.ports.filesOrganisation ?? (await this.ports.platformOrg?.files());
|
|
1885
|
+
if (!port)
|
|
1886
|
+
throw new Error('files: this executor was built without a files port (#177)');
|
|
1887
|
+
return port;
|
|
1888
|
+
}
|
|
1889
|
+
async aiPort() {
|
|
1890
|
+
const port = this.ports.aiOrganisation ?? (await this.ports.platformOrg?.ai());
|
|
1891
|
+
if (!port)
|
|
1892
|
+
throw new Error('ai: this executor was built without an ai port (#177)');
|
|
1893
|
+
return port;
|
|
1894
|
+
}
|
|
1895
|
+
async jobsPort(org) {
|
|
1896
|
+
const port = this.ports.jobsScheduler ?? (await this.ports.platformOrg?.jobs());
|
|
1897
|
+
if (!port) {
|
|
1898
|
+
throw new Error(`jobs:registration for ${org.slug}: this executor was built without a jobsScheduler port (#173)`);
|
|
1899
|
+
}
|
|
1900
|
+
return port;
|
|
1901
|
+
}
|
|
1902
|
+
async jobsRegistration(action, org, context) {
|
|
1903
|
+
const scheduler = await this.jobsPort(org);
|
|
1904
|
+
const name = action.desired.name;
|
|
1905
|
+
const baseUrl = action.desired.baseUrl;
|
|
1906
|
+
const registered = await scheduler.registerProject({ name, baseUrl });
|
|
1907
|
+
// A repeat call for an already-registered project carries neither
|
|
1908
|
+
// credential: nothing to deliver.
|
|
1909
|
+
if (!registered.secret && !registered.syncKey)
|
|
1910
|
+
return { resourceId: registered.id };
|
|
1911
|
+
// The project now exists and its credentials are shown exactly once, so
|
|
1912
|
+
// the id is recorded before anything else can fail: a step's return value
|
|
1913
|
+
// is only recorded on success, and without this a failed delivery would
|
|
1914
|
+
// leave the next run's `registerProject` answering with just `{ id }`,
|
|
1915
|
+
// the step recording success, and the sync key lost for good.
|
|
1916
|
+
// `planJobsRegistration` reports this id as blocked instead of planning
|
|
1917
|
+
// the step again.
|
|
1918
|
+
context.recordScopeId(JOBS_REGISTRATION_UNDELIVERED_SCOPE_KEY, registered.id);
|
|
1919
|
+
// Written now, so a crash during delivery cannot lose the id either.
|
|
1920
|
+
await context.persistScopeIds();
|
|
1921
|
+
if (!registered.secret || !registered.syncKey) {
|
|
1922
|
+
// Only one half came back; the other can never be fetched again.
|
|
1923
|
+
throw new Error(`jobs:registration for ${org.slug}: project ${registered.id} was registered but the scheduler returned only one of its secret and sync key; deregister it in the scheduler before registering again`);
|
|
1924
|
+
}
|
|
1925
|
+
const failed = [];
|
|
1926
|
+
for (const [part, value] of [
|
|
1927
|
+
['secret', registered.secret],
|
|
1928
|
+
['syncKey', registered.syncKey],
|
|
1929
|
+
]) {
|
|
1930
|
+
if (!(await this.deliverWithRetry(`jobs:registration:${org.slug}:${part}`, value))) {
|
|
1931
|
+
failed.push(part);
|
|
1932
|
+
}
|
|
1933
|
+
}
|
|
1934
|
+
if (failed.length > 0) {
|
|
1935
|
+
// Names the project id only; the underlying errors are dropped in case
|
|
1936
|
+
// one echoes a credential.
|
|
1937
|
+
throw new Error(`jobs:registration for ${org.slug}: project ${registered.id} was registered but its ${failed.join(' and ')} could not be delivered after ${JOBS_DELIVERY_ATTEMPTS} attempts; deregister it in the scheduler before registering again`);
|
|
1938
|
+
}
|
|
1939
|
+
return { resourceId: registered.id };
|
|
1940
|
+
}
|
|
1941
|
+
/** `deliverSecret`, tried up to {@link JOBS_DELIVERY_ATTEMPTS} times; true once one attempt succeeds. */
|
|
1942
|
+
async deliverWithRetry(ref, value) {
|
|
1943
|
+
for (let attempt = 1; attempt <= JOBS_DELIVERY_ATTEMPTS; attempt++) {
|
|
1944
|
+
try {
|
|
1945
|
+
await this.ports.deliverSecret(ref, value);
|
|
1946
|
+
return true;
|
|
1947
|
+
}
|
|
1948
|
+
catch {
|
|
1949
|
+
// Retried; the error is dropped in case it echoes the secret.
|
|
1950
|
+
}
|
|
1951
|
+
}
|
|
1952
|
+
return false;
|
|
1953
|
+
}
|
|
1018
1954
|
// ---- cloudflare --------------------------------------------------------
|
|
1019
1955
|
async cloudflareTunnel(action, org, context) {
|
|
1020
1956
|
if (action.desired.enabled === false) {
|
|
@@ -1058,12 +1994,34 @@ export class FoundryStepExecutor {
|
|
|
1058
1994
|
});
|
|
1059
1995
|
return { resourceId: tunnel.id };
|
|
1060
1996
|
}
|
|
1061
|
-
|
|
1997
|
+
/**
|
|
1998
|
+
* `plan/diff.ts`'s `planZone`: finds or creates the Cloudflare zone for the
|
|
1999
|
+
* org's own domain. Refuses to adopt a zone the store already records
|
|
2000
|
+
* under a *different* org's applied state (#131), the same
|
|
2001
|
+
* do-not-adopt-a-foreign-resource rule `cloudflareAccess` above enforces --
|
|
2002
|
+
* but where that method tells "foundry's own" from "foreign" by reading
|
|
2003
|
+
* Cloudflare's own Access-app name live, a Cloudflare zone carries no
|
|
2004
|
+
* org-identifying field of its own, so this asks the store instead. That
|
|
2005
|
+
* matters because two different, DB-unique `domain` values (`org.ts`'s
|
|
2006
|
+
* `validateDomain` plus its unique index) can still resolve to the same
|
|
2007
|
+
* Cloudflare zone -- a zone is keyed by apex, and one org's domain can be a
|
|
2008
|
+
* subdomain of another org's -- which is exactly the shared-zone,
|
|
2009
|
+
* shared-hostname collision this ticket exists to refuse. An org re-running
|
|
2010
|
+
* its own already-recorded zone is unaffected: the store then names this
|
|
2011
|
+
* same org as the owner.
|
|
2012
|
+
*/
|
|
2013
|
+
async cloudflareZone(action, org) {
|
|
1062
2014
|
const domain = action.key;
|
|
1063
|
-
const
|
|
1064
|
-
|
|
2015
|
+
const cf = this.ports.cloudflare;
|
|
2016
|
+
const existing = await cf.findZoneByApex(domain);
|
|
2017
|
+
if (existing) {
|
|
2018
|
+
const owner = await this.ports.declaredOrgStore.findOrgIdForZone(existing.id);
|
|
2019
|
+
if (owner !== null && owner !== org.id) {
|
|
2020
|
+
throw new Error(`cloudflare:zone for ${org.slug}: zone ${existing.id} (${domain}) is already recorded under org ${owner}; foundry does not adopt it`);
|
|
2021
|
+
}
|
|
1065
2022
|
return { resourceId: existing.id };
|
|
1066
|
-
|
|
2023
|
+
}
|
|
2024
|
+
const created = await cf.createZone(domain);
|
|
1067
2025
|
return { resourceId: created.id };
|
|
1068
2026
|
}
|
|
1069
2027
|
// ---- mail -----------------------------------------------------------
|
|
@@ -1071,20 +2029,95 @@ export class FoundryStepExecutor {
|
|
|
1071
2029
|
* `plan/diff.ts`'s `planMail`: gives an org mail, moves it between modes,
|
|
1072
2030
|
* or parks it on the one shared Stalwart instance (`this.ports.mail`,
|
|
1073
2031
|
* `@wtfalch/email/postmaster`'s `applyOrgMail`/`parkOrgMail` bound), then
|
|
1074
|
-
* publishes whatever DNS records the call returns
|
|
2032
|
+
* publishes whatever Stalwart DNS records the call returns, and records the
|
|
2033
|
+
* resolved mail domain as a scope id ({@link MAIL_DOMAIN_SCOPE_KEY}).
|
|
2034
|
+
* 2026-09-28 lettermint-org-mail swap: this step no longer calls the mail
|
|
2035
|
+
* relay provider at all -- registering the domain, restricting it and
|
|
2036
|
+
* publishing its own DNS records all moved to the app step
|
|
2037
|
+
* (`ensureAppLettermintMail`, "Core rule"), since the first project can
|
|
2038
|
+
* only exist once the token has a home. Parking mail (mode `'off'`) does
|
|
2039
|
+
* still reach Lettermint, the one exception: {@link parkLettermintMail}
|
|
2040
|
+
* deletes the org's own projects and then its domain, so a domain never
|
|
2041
|
+
* ends up restricted only to dead projects.
|
|
2042
|
+
*
|
|
2043
|
+
* Review finding: a mail domain switch (`org.domain` changes, or
|
|
2044
|
+
* `usesDomain` flips) must also delete the *old* Lettermint domain here,
|
|
2045
|
+
* before recording the new one -- otherwise it stays registered and
|
|
2046
|
+
* restricted to the org's still-live `<slug>-app` project, which could
|
|
2047
|
+
* then keep sending as it forever. {@link deleteStaleLettermintDomain}
|
|
2048
|
+
* does this by comparing the previously recorded domain (case-insensitive)
|
|
2049
|
+
* against the one this apply just resolved.
|
|
2050
|
+
*
|
|
2051
|
+
* #162: once `applyOrgMail` has given the org a domain, {@link ensureOwnerMailbox}
|
|
2052
|
+
* gives its declared owner a mailbox on it, so Agora's Mail section is not
|
|
2053
|
+
* empty for them after the org's first apply.
|
|
1075
2054
|
*/
|
|
1076
|
-
async coolifyMail(action, org) {
|
|
2055
|
+
async coolifyMail(action, org, context) {
|
|
1077
2056
|
const mode = action.desired.mode;
|
|
1078
2057
|
const usesDomain = action.desired.usesDomain;
|
|
1079
|
-
|
|
2058
|
+
// #177: `diff.ts`'s `planMail` carries the platform's own apex/mail host
|
|
2059
|
+
// in `desired` once it is wired -- "the executor writes exactly these
|
|
2060
|
+
// host values, so what the plan showed is what gets applied" (`planAgora`'s
|
|
2061
|
+
// own rule). `MAIL_APEX`/`MAIL_HOST` remain the fallback for a plan built
|
|
2062
|
+
// without a platform (this file's own doc comment on those constants).
|
|
2063
|
+
const options = {
|
|
2064
|
+
apex: action.desired.apex ?? MAIL_APEX,
|
|
2065
|
+
mailHost: action.desired.mailHost ?? MAIL_HOST,
|
|
2066
|
+
};
|
|
1080
2067
|
if (mode === 'off') {
|
|
1081
2068
|
await this.ports.mail.parkOrgMail(mailOrgFor(org, usesDomain, 'hosted'), options);
|
|
2069
|
+
await this.parkLettermintMail(org, context);
|
|
1082
2070
|
return undefined;
|
|
1083
2071
|
}
|
|
1084
|
-
const
|
|
2072
|
+
const mailOrg = mailOrgFor(org, usesDomain, mode);
|
|
2073
|
+
const result = await this.ports.mail.applyOrgMail(mailOrg, options);
|
|
1085
2074
|
await this.publishMailDnsRecords(org, result);
|
|
2075
|
+
await this.deleteStaleLettermintDomain(context, result.domain);
|
|
2076
|
+
context.recordScopeId(MAIL_DOMAIN_SCOPE_KEY, result.domain);
|
|
2077
|
+
// #177: what this step wrote from the platform, so `planMail` can see a later change.
|
|
2078
|
+
context.recordScopeId(ORG_MAIL_APPLIED_APEX_SCOPE_KEY, options.apex);
|
|
2079
|
+
context.recordScopeId(ORG_MAIL_APPLIED_HOST_SCOPE_KEY, options.mailHost);
|
|
2080
|
+
await this.ensureOwnerMailbox(org, mailOrg, result.domain, context);
|
|
1086
2081
|
return { resourceId: result.domain };
|
|
1087
2082
|
}
|
|
2083
|
+
/**
|
|
2084
|
+
* #162: gives the org's declared owner a mailbox on its just-resolved mail
|
|
2085
|
+
* domain, the one address this executor mints on its own
|
|
2086
|
+
* ({@link MAIL_OWNER_LOCAL_PART}'s own doc comment). A no-op when the org
|
|
2087
|
+
* has no owner yet, or once {@link MAIL_OWNER_MAILBOX_SCOPE_KEY} is already
|
|
2088
|
+
* recorded -- checked first so a second apply never calls
|
|
2089
|
+
* `MailPort.createMailbox` again, rather than relying on that port method's
|
|
2090
|
+
* own duplicate refusal.
|
|
2091
|
+
*/
|
|
2092
|
+
async ensureOwnerMailbox(org, mailOrg, domain, context) {
|
|
2093
|
+
if (!org.owner)
|
|
2094
|
+
return;
|
|
2095
|
+
if (context.recordedIds()[MAIL_OWNER_MAILBOX_SCOPE_KEY])
|
|
2096
|
+
return;
|
|
2097
|
+
const address = `${MAIL_OWNER_LOCAL_PART}@${domain}`;
|
|
2098
|
+
await this.ports.mail.createMailbox(mailOrg, {
|
|
2099
|
+
address,
|
|
2100
|
+
name: `${org.owner.givenName} ${org.owner.familyName}`,
|
|
2101
|
+
});
|
|
2102
|
+
context.recordScopeId(MAIL_OWNER_MAILBOX_SCOPE_KEY, address);
|
|
2103
|
+
}
|
|
2104
|
+
/**
|
|
2105
|
+
* Deletes the org's previous Lettermint domain once the mail domain it was
|
|
2106
|
+
* registered under has changed -- otherwise it stays registered and
|
|
2107
|
+
* restricted to the org's still-live `<slug>-app` project after the switch
|
|
2108
|
+
* (review finding, post-#140 rework). A no-op on first apply (no previous
|
|
2109
|
+
* domain recorded) and on a same-domain re-apply (compared
|
|
2110
|
+
* case-insensitively, the same rule {@link findExactLettermintDomain}
|
|
2111
|
+
* applies to the lookup itself).
|
|
2112
|
+
*/
|
|
2113
|
+
async deleteStaleLettermintDomain(context, newDomain) {
|
|
2114
|
+
const previous = context.recordedIds()[MAIL_DOMAIN_SCOPE_KEY];
|
|
2115
|
+
if (!previous || previous.toLowerCase() === newDomain.toLowerCase())
|
|
2116
|
+
return;
|
|
2117
|
+
const stale = await this.findExactLettermintDomain(previous);
|
|
2118
|
+
if (stale)
|
|
2119
|
+
await this.ports.lettermint.deleteDomain(stale.id);
|
|
2120
|
+
}
|
|
1088
2121
|
/**
|
|
1089
2122
|
* Upserts each DNS record `applyOrgMail` says foundry must publish, found
|
|
1090
2123
|
* by an exact name+type match (`CloudflarePort.findDnsRecord`'s own doc
|
|
@@ -1109,6 +2142,72 @@ export class FoundryStepExecutor {
|
|
|
1109
2142
|
}
|
|
1110
2143
|
}
|
|
1111
2144
|
}
|
|
2145
|
+
/**
|
|
2146
|
+
* Parking mail (2026-09-28 lettermint-org-mail swap, "Core rule": "Parking
|
|
2147
|
+
* mail deletes the org's projects and then the domain itself, so no
|
|
2148
|
+
* restriction points only at dead projects"). Deletes every Lettermint
|
|
2149
|
+
* project named `<slug>-app` or `<slug>-relay`, then the org's own
|
|
2150
|
+
* Lettermint domain, if one was found -- in that order, never the reverse.
|
|
2151
|
+
* A no-op until a previous apply recorded {@link MAIL_DOMAIN_SCOPE_KEY}:
|
|
2152
|
+
* nothing was ever provisioned for an org whose mail went straight from
|
|
2153
|
+
* never-applied to off.
|
|
2154
|
+
*/
|
|
2155
|
+
async parkLettermintMail(org, context) {
|
|
2156
|
+
const mailDomain = context.recordedIds()[MAIL_DOMAIN_SCOPE_KEY];
|
|
2157
|
+
if (!mailDomain)
|
|
2158
|
+
return;
|
|
2159
|
+
const names = new Set([`${org.slug}-app`, `${org.slug}-relay`]);
|
|
2160
|
+
const projects = await this.ports.lettermint.listProjects();
|
|
2161
|
+
for (const project of projects) {
|
|
2162
|
+
if (names.has(project.name))
|
|
2163
|
+
await this.ports.lettermint.deleteProject(project.id);
|
|
2164
|
+
}
|
|
2165
|
+
const domain = await this.findExactLettermintDomain(mailDomain);
|
|
2166
|
+
if (domain)
|
|
2167
|
+
await this.ports.lettermint.deleteDomain(domain.id);
|
|
2168
|
+
}
|
|
2169
|
+
/**
|
|
2170
|
+
* Upserts each DNS record Lettermint's domain names, the same find-then-
|
|
2171
|
+
* upsert `publishMailDnsRecords` above uses for Stalwart's own records --
|
|
2172
|
+
* except an existing `_dmarc` record is never overwritten (a stricter
|
|
2173
|
+
* policy an org already set, `p=quarantine`/`p=reject`, should not be
|
|
2174
|
+
* relaxed back to Lettermint's own default). Resend's own domain also
|
|
2175
|
+
* carried an apex `SPF` TXT record that had to be merged into whatever SPF
|
|
2176
|
+
* record already existed there (RFC 7208: two `v=spf1` TXT records on one
|
|
2177
|
+
* name is a permerror); Lettermint's own DNS records
|
|
2178
|
+
* (`.plans/2026-09-28-lettermint-api.md` §3) have no such record -- its
|
|
2179
|
+
* `purpose` enum is `return_path`/`dmarc`/`dkim_legacy`/`dkim_primary`/
|
|
2180
|
+
* `dkim_secondary`, none of them SPF -- so that merge has no case left to
|
|
2181
|
+
* handle here and is dropped rather than kept as dead code.
|
|
2182
|
+
*/
|
|
2183
|
+
async publishLettermintDnsRecords(zoneId, records) {
|
|
2184
|
+
const cf = this.ports.cloudflare;
|
|
2185
|
+
for (const record of records) {
|
|
2186
|
+
if (record.type === 'TXT' && record.value.toLowerCase().startsWith('v=dmarc1')) {
|
|
2187
|
+
await this.ensureLettermintDmarcRecord(cf, zoneId, record);
|
|
2188
|
+
continue;
|
|
2189
|
+
}
|
|
2190
|
+
const [existing] = await cf.findDnsRecord(zoneId, record.name, record.type);
|
|
2191
|
+
const rec = { type: record.type, name: record.name, content: record.value };
|
|
2192
|
+
if (existing) {
|
|
2193
|
+
await cf.updateDnsRecord(zoneId, existing.id, rec);
|
|
2194
|
+
}
|
|
2195
|
+
else {
|
|
2196
|
+
await cf.createDnsRecord(zoneId, rec);
|
|
2197
|
+
}
|
|
2198
|
+
}
|
|
2199
|
+
}
|
|
2200
|
+
/**
|
|
2201
|
+
* Never overwrites an existing `_dmarc` record -- a stricter policy an org
|
|
2202
|
+
* already set (`p=quarantine`/`p=reject`) should not be relaxed back to
|
|
2203
|
+
* Lettermint's own `p=none` default.
|
|
2204
|
+
*/
|
|
2205
|
+
async ensureLettermintDmarcRecord(cf, zoneId, record) {
|
|
2206
|
+
const hasDmarc = (await cf.findDnsRecord(zoneId, record.name, 'TXT')).some((r) => r.content.toLowerCase().startsWith('v=dmarc1'));
|
|
2207
|
+
if (hasDmarc)
|
|
2208
|
+
return;
|
|
2209
|
+
await cf.createDnsRecord(zoneId, { type: 'TXT', name: record.name, content: record.value });
|
|
2210
|
+
}
|
|
1112
2211
|
// ---- files / ai --------------------------------------------------------
|
|
1113
2212
|
/**
|
|
1114
2213
|
* #80: provisions the org's own organisation on Files, before
|
|
@@ -1118,7 +2217,8 @@ export class FoundryStepExecutor {
|
|
|
1118
2217
|
* idempotent by find-or-create, not by checking `recordedIds` first.
|
|
1119
2218
|
*/
|
|
1120
2219
|
async filesOrganisation(action, org) {
|
|
1121
|
-
const
|
|
2220
|
+
const files = await this.filesPort();
|
|
2221
|
+
const provisioned = await files.provisionOrganisation({
|
|
1122
2222
|
organisationId: org.id,
|
|
1123
2223
|
ceilingBytes: action.desired.ceilingBytes ?? null,
|
|
1124
2224
|
ceilingMicros: action.desired.ceilingMicros ?? null,
|
|
@@ -1126,9 +2226,9 @@ export class FoundryStepExecutor {
|
|
|
1126
2226
|
return { resourceId: provisioned.organisationId };
|
|
1127
2227
|
}
|
|
1128
2228
|
async filesStorage(action, org) {
|
|
1129
|
-
const
|
|
1130
|
-
await
|
|
1131
|
-
await
|
|
2229
|
+
const files = await this.filesPort();
|
|
2230
|
+
await files.setQuota(org.id, action.desired.limitBytes ?? null);
|
|
2231
|
+
await files.setBudget(org.id, action.desired.limitMicros ?? null);
|
|
1132
2232
|
return { resourceId: org.id };
|
|
1133
2233
|
}
|
|
1134
2234
|
/**
|
|
@@ -1139,11 +2239,12 @@ export class FoundryStepExecutor {
|
|
|
1139
2239
|
* every time regardless of whether they already ran.
|
|
1140
2240
|
*/
|
|
1141
2241
|
async aiOrganisation(action, org) {
|
|
1142
|
-
const
|
|
2242
|
+
const ai = await this.aiPort();
|
|
2243
|
+
const provisioned = await ai.provisionOrganisation({
|
|
1143
2244
|
organisationId: org.id,
|
|
1144
2245
|
limitMicros: action.desired.limitMicros ?? null,
|
|
1145
2246
|
});
|
|
1146
|
-
await
|
|
2247
|
+
await ai.setOrganisationOffers(org.id, action.desired.offers ?? []);
|
|
1147
2248
|
return { resourceId: provisioned.organisationId };
|
|
1148
2249
|
}
|
|
1149
2250
|
async aiBudget(action, org) {
|
|
@@ -1154,10 +2255,48 @@ export class FoundryStepExecutor {
|
|
|
1154
2255
|
// (Finding, this campaign's report).
|
|
1155
2256
|
throw new Error(`ai:budget for ${org.slug}: AI's updateBudget has no uncapped representation (UpdateBudget.limitMicros is a required string); declare a numeric limit until AI's API adds one`);
|
|
1156
2257
|
}
|
|
1157
|
-
const
|
|
1158
|
-
await
|
|
2258
|
+
const ai = await this.aiPort();
|
|
2259
|
+
await ai.updateBudget(org.id, { keyId: null, limitMicros });
|
|
1159
2260
|
return { resourceId: org.id };
|
|
1160
2261
|
}
|
|
2262
|
+
/**
|
|
2263
|
+
* #80 step 3: mints the org's own ai key, once, and hands its one-time
|
|
2264
|
+
* secret straight to {@link FoundryStepExecutorPorts.deliverSecret} --
|
|
2265
|
+
* never returned, stored in `serviceIds` or logged, the same discipline
|
|
2266
|
+
* `zitadelApp`'s own `created.clientSecret` follows. `diff.ts`'s
|
|
2267
|
+
* `planAiKey` never plans this step again once its resource id
|
|
2268
|
+
* (the key's id, not its secret) is recorded, since ai's own API offers no
|
|
2269
|
+
* way to check for an already-minted key.
|
|
2270
|
+
*/
|
|
2271
|
+
async aiKey(action, org, context) {
|
|
2272
|
+
const ai = await this.aiPort();
|
|
2273
|
+
const issued = await ai.issueOrganisationKey(org.id, {
|
|
2274
|
+
name: action.desired.name,
|
|
2275
|
+
expiresDays: action.desired.expiresDays,
|
|
2276
|
+
permissions: action.desired.permissions ?? [],
|
|
2277
|
+
});
|
|
2278
|
+
// Recorded before the secret is delivered: a step's return value is only
|
|
2279
|
+
// recorded on success, so a delivery that throws would otherwise lose
|
|
2280
|
+
// the id and the retry would mint a second, orphaned key. `planAiKey`
|
|
2281
|
+
// treats this id as undelivered until the returned resource id (which
|
|
2282
|
+
// only a delivered key gets) is recorded too.
|
|
2283
|
+
context.recordScopeId(AI_KEY_UNDELIVERED_SCOPE_KEY, issued.id);
|
|
2284
|
+
// Written now, so a crash during delivery cannot lose the id either.
|
|
2285
|
+
await context.persistScopeIds();
|
|
2286
|
+
let lastError;
|
|
2287
|
+
for (let attempt = 1; attempt <= AI_KEY_DELIVERY_ATTEMPTS; attempt++) {
|
|
2288
|
+
try {
|
|
2289
|
+
await this.ports.deliverSecret(`ai:key:${org.slug}`, issued.secret);
|
|
2290
|
+
return { resourceId: issued.id };
|
|
2291
|
+
}
|
|
2292
|
+
catch (e) {
|
|
2293
|
+
lastError = e;
|
|
2294
|
+
}
|
|
2295
|
+
}
|
|
2296
|
+
// Names the key id only; the underlying error is dropped in case it
|
|
2297
|
+
// echoes the secret.
|
|
2298
|
+
throw new Error(`ai:key for ${org.slug}: key ${issued.id} was minted but its secret could not be delivered after ${AI_KEY_DELIVERY_ATTEMPTS} attempts (${lastError instanceof Error ? lastError.name : 'error'}); revoke it in ai before a new one is minted`);
|
|
2299
|
+
}
|
|
1161
2300
|
// ---- destroy -------------------------------------------------------------
|
|
1162
2301
|
/**
|
|
1163
2302
|
* {@link StepDestroyer.destroy}: deletes the one resource
|
|
@@ -1233,7 +2372,7 @@ export class FoundryStepExecutor {
|
|
|
1233
2372
|
* status in: the first `→ `, after the client, method and path, and before
|
|
1234
2373
|
* `:`. It never scans the path or the response body.
|
|
1235
2374
|
*/
|
|
1236
|
-
function isNotFoundError(error) {
|
|
2375
|
+
export function isNotFoundError(error) {
|
|
1237
2376
|
const status = error?.status;
|
|
1238
2377
|
if (typeof status === 'number')
|
|
1239
2378
|
return status === 404;
|