@wtfalch/foundry 0.5.0 → 0.7.1
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 +16 -1
- package/dist/bootstrap/bootstrap.js +40 -2
- package/dist/bootstrap/declare.d.ts +3 -1
- package/dist/bootstrap/declare.js +12 -3
- package/dist/bootstrap/versions.d.ts +17 -0
- package/dist/bootstrap/versions.js +12 -0
- package/dist/calls-sfu-deploy-support.d.ts +8 -0
- package/dist/calls-sfu-deploy-support.js +10 -1
- package/dist/calls-sfu.d.ts +36 -12
- package/dist/calls-sfu.js +42 -9
- package/dist/index.d.ts +10 -4
- package/dist/index.js +6 -3
- package/dist/migrations/0019_mail_owner_mailbox.sql +15 -0
- package/dist/migrations/0020_mail_apps.sql +7 -0
- package/dist/migrations/0021_mail_role_mailboxes.sql +6 -0
- package/dist/migrations/0022_chat_enabled.sql +6 -0
- package/dist/migrations/0023_hosting_app_env.sql +8 -0
- package/dist/migrations/0024_audit_events.sql +211 -0
- package/dist/migrations/0025_audit_display.sql +147 -0
- package/dist/migrations/0026_audit_erase_email_guard.sql +78 -0
- package/dist/migrations/0027_audit_chain.sql +170 -0
- package/dist/migrations/0028_audit_rls.sql +111 -0
- package/dist/org.d.ts +105 -0
- package/dist/org.js +101 -0
- package/dist/plan/agora.d.ts +21 -0
- package/dist/plan/agora.js +23 -0
- package/dist/plan/calls-matrix.d.ts +2 -2
- package/dist/plan/calls-matrix.js +3 -2
- package/dist/plan/cloudflared-connector.d.ts +3 -2
- package/dist/plan/cloudflared-connector.js +5 -3
- package/dist/plan/diff.d.ts +15 -2
- package/dist/plan/diff.js +399 -51
- package/dist/plan/executor.d.ts +307 -61
- package/dist/plan/executor.js +489 -129
- package/dist/plan/index.d.ts +9 -1
- package/dist/plan/index.js +5 -1
- package/dist/plan/login-host.d.ts +64 -0
- package/dist/plan/login-host.js +52 -0
- package/dist/plan/mail-copy.d.ts +125 -0
- package/dist/plan/mail-copy.js +166 -0
- package/dist/plan/mail-identity-allowlist.d.ts +50 -0
- package/dist/plan/mail-identity-allowlist.js +11 -0
- package/dist/plan/mail-identity.d.ts +68 -0
- package/dist/plan/mail-identity.js +17 -0
- package/dist/plan/plan-apply.d.ts +26 -8
- package/dist/plan/plan-apply.js +10 -9
- package/dist/plan/platform-diff.d.ts +207 -10
- package/dist/plan/platform-diff.js +403 -15
- package/dist/plan/platform-org.d.ts +3 -2
- package/dist/plan/platform-providers.d.ts +139 -25
- package/dist/plan/platform-providers.js +1531 -95
- package/dist/plan/platform-sfu.d.ts +54 -19
- package/dist/plan/platform-sfu.js +64 -22
- package/dist/plan/providers-readers.js +44 -1
- package/dist/plan/zitadel-machine-user.d.ts +60 -11
- package/dist/plan/zitadel-machine-user.js +182 -39
- package/dist/requested-changes.d.ts +17 -7
- package/dist/requested-changes.js +37 -7
- package/dist/store/audit.d.ts +40 -4
- package/dist/store/audit.js +114 -29
- package/dist/store/postgres.js +35 -3
- package/dist/store/schema.d.ts +109 -1
- package/dist/store/schema.js +20 -0
- package/dist/store.d.ts +7 -0
- package/package.json +5 -3
- package/versions.json +8 -0
package/dist/plan/executor.js
CHANGED
|
@@ -1,9 +1,12 @@
|
|
|
1
1
|
import { randomBytes } from 'node:crypto';
|
|
2
2
|
import { URL } from 'node:url';
|
|
3
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';
|
|
4
|
+
import { AGORA_CLIENT_ID_SCOPE_KEY, APP_CLIENT_ID_SCOPE_KEY, CMS_CLIENT_ID_SCOPE_KEY, agoraHostnameFor, desiredAgoraEnv, } from './agora.js';
|
|
5
5
|
import { MAS_IMAGE, SYNAPSE_IMAGE, SYNAPSE_TAG, callsMasConfigYaml, callsServerName, callsSynapseHomeserverYaml, mintCallsMatrixSecrets, parseCallsDbUrl, } from './calls-matrix.js';
|
|
6
|
-
import {
|
|
6
|
+
import { ensureCloudflaredConnector } from './cloudflared-connector.js';
|
|
7
|
+
import { LOGIN_BINDING_SCOPE_KEY, loginBindingState, loginHostClientIds, } from './login-host.js';
|
|
8
|
+
import { MAIL_COPY_COMPLETED_SCOPE_KEY, MAIL_COPY_DONE_SCOPE_KEY, parseMailCopyCompleted, runMailCopyStep, } from './mail-copy.js';
|
|
9
|
+
import { APP_ENV_APPLIED_SCOPE_KEY, DB_ENV_APPLIED_SCOPE_KEY, ORG_MAIL_APPLIED_APEX_SCOPE_KEY, ORG_MAIL_APPLIED_HOST_SCOPE_KEY, resolveAppEnv, } from './platform-diff.js';
|
|
7
10
|
// ---------------------------------------------------------------------------
|
|
8
11
|
// Errors
|
|
9
12
|
// ---------------------------------------------------------------------------
|
|
@@ -108,10 +111,11 @@ export function healthUrls(org) {
|
|
|
108
111
|
}
|
|
109
112
|
/**
|
|
110
113
|
* The `OrgMailOrg` argument for `applyOrgMail`/`parkOrgMail`: an org with
|
|
111
|
-
* `mail.usesDomain` off has no domain of its own, so it is level 1 on
|
|
112
|
-
* `
|
|
113
|
-
* `
|
|
114
|
-
* level-1 `{slug}.{apex}` domain
|
|
114
|
+
* `mail.usesDomain` off has no domain of its own, so it is level 1 on the
|
|
115
|
+
* platform's own mail apex (`coolifyMail`'s `options.apex`) and its mode is
|
|
116
|
+
* forced to `'hosted'` — `@wtfalch/email`'s own `resolveDomain` refuses any
|
|
117
|
+
* other mode for a domain-less org, and a level-1 `{slug}.{apex}` domain
|
|
118
|
+
* always has Stalwart's MX (foundry#7 "Mail").
|
|
115
119
|
*/
|
|
116
120
|
function mailOrgFor(org, usesDomain, mode) {
|
|
117
121
|
return usesDomain && org.domain
|
|
@@ -226,38 +230,6 @@ const AGORA_APP_DOCKERFILE = 'apps/agora/Dockerfile';
|
|
|
226
230
|
* restated rather than shared across the two files.
|
|
227
231
|
*/
|
|
228
232
|
const LOG_DIR = '/app/logs';
|
|
229
|
-
/**
|
|
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`).
|
|
237
|
-
*/
|
|
238
|
-
const MAIL_APEX = 'wtfalch.dev';
|
|
239
|
-
const MAIL_HOST = 'mail.wtfalch.dev';
|
|
240
|
-
/**
|
|
241
|
-
* The recorded OIDC app's `clientId` (level-1 app-env campaign, gap P2) —
|
|
242
|
-
* not a `system:kind:key` resource id (that slot already holds the app's
|
|
243
|
-
* `appId`, `zitadelApp`'s own `resourceId`) and not a secret (`deliverSecret`
|
|
244
|
-
* is for that), so a flat scope id like `zitadel_org`/`zitadel_project`
|
|
245
|
-
* above. A level-1 org declares exactly one web `issuerApps` entry for its
|
|
246
|
-
* one standard app, so one flat key is enough; an org with more than one web
|
|
247
|
-
* app would need a richer key, not built here (Finding, this campaign's
|
|
248
|
-
* report).
|
|
249
|
-
*/
|
|
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';
|
|
261
233
|
/**
|
|
262
234
|
* `cmsSite` provisions two applications from one `cms:site` step, but
|
|
263
235
|
* `execute`'s own return value auto-records only one `resourceId`, under
|
|
@@ -299,6 +271,13 @@ const CMS_DATABASE_SCOPE_KEY = 'coolify_cms_database';
|
|
|
299
271
|
* `resend_mail_domain`.
|
|
300
272
|
*/
|
|
301
273
|
const MAIL_DOMAIN_SCOPE_KEY = 'mail_domain';
|
|
274
|
+
/**
|
|
275
|
+
* #235: the Coolify app running `cloudflared` for this org's own tunnel --
|
|
276
|
+
* one connector per org tunnel (Decided, William, 2026-10-01), the same
|
|
277
|
+
* `ensureCloudflaredConnector` every platform tunnel step already calls
|
|
278
|
+
* (`platform-providers.ts`), never a shared tunnel with appended ingress.
|
|
279
|
+
*/
|
|
280
|
+
const CLOUDFLARE_TUNNEL_CONNECTOR_APP_SCOPE_KEY = 'cloudflare_tunnel_connector_app';
|
|
302
281
|
/**
|
|
303
282
|
* #162: the owner's own mailbox, created once inside `coolify:mail` after
|
|
304
283
|
* `applyOrgMail` succeeds, alongside {@link MAIL_DOMAIN_SCOPE_KEY} -- another
|
|
@@ -312,6 +291,29 @@ const MAIL_DOMAIN_SCOPE_KEY = 'mail_domain';
|
|
|
312
291
|
* file's own `LOG_DIR` doc comment names for `diff.ts`.
|
|
313
292
|
*/
|
|
314
293
|
const MAIL_OWNER_MAILBOX_SCOPE_KEY = 'mail_owner_mailbox';
|
|
294
|
+
/**
|
|
295
|
+
* #149 review finding: `MailPort.hasOrgRelay` only confirms Stalwart's route
|
|
296
|
+
* holds *a* secret, not that it is the one {@link FoundryStepExecutor
|
|
297
|
+
* .ensureOrgLettermintRelay} itself wrote for the project Lettermint
|
|
298
|
+
* currently lists under `<slug>-relay` -- a route a stale run wrote for a
|
|
299
|
+
* project that has since been lost and reprovisioned under the same name
|
|
300
|
+
* would otherwise read as healthy. Recorded as `<project id>@<mail domain>`
|
|
301
|
+
* right after a successful `MailPort.applyOrgRelay`, a flat scope id (no
|
|
302
|
+
* `:`) the same way {@link MAIL_OWNER_MAILBOX_SCOPE_KEY} is. The domain half
|
|
303
|
+
* also catches a domain switch: the healthy branch never calls
|
|
304
|
+
* `applyOrgRelay` again, so without this the Stalwart route would stay
|
|
305
|
+
* restricted to the org's previous domain forever.
|
|
306
|
+
*/
|
|
307
|
+
const ORG_MAIL_RELAY_SCOPE_KEY = 'mail_relay';
|
|
308
|
+
/**
|
|
309
|
+
* #230: the role mailboxes {@link ensureRoleMailboxes} has created, as one
|
|
310
|
+
* flat scope id holding their full addresses (`local@domain`, lowercased, so a
|
|
311
|
+
* mail domain change owes the new domain its own set) joined by `,`
|
|
312
|
+
* (`validateMail` refuses a `,` in a name, so the join is unambiguous). Updated after each
|
|
313
|
+
* mailbox, so a crash part-way through never recreates the ones already
|
|
314
|
+
* made. `plan/diff.ts` keeps its own copy of this literal.
|
|
315
|
+
*/
|
|
316
|
+
const MAIL_ROLE_MAILBOXES_SCOPE_KEY = 'mail_role_mailboxes';
|
|
315
317
|
/**
|
|
316
318
|
* #80: the id of an ai key `aiKey` has minted but not yet delivered, a flat
|
|
317
319
|
* scope id (no `:`). `plan/diff.ts` keeps its own copy of this literal.
|
|
@@ -328,17 +330,6 @@ const AI_KEY_DELIVERY_ATTEMPTS = 3;
|
|
|
328
330
|
const JOBS_REGISTRATION_UNDELIVERED_SCOPE_KEY = 'jobs_registration_undelivered';
|
|
329
331
|
/** How many times `jobsRegistration` tries each `deliverSecret` before giving up. */
|
|
330
332
|
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
333
|
/**
|
|
343
334
|
* `coolifyCalls` provisions four resources from one `coolify:calls` step
|
|
344
335
|
* (#128) — the same one-step, several-resource shape `cmsSite` already solves
|
|
@@ -431,6 +422,8 @@ export class FoundryStepExecutor {
|
|
|
431
422
|
return this.zitadelIssuer(action, org, context);
|
|
432
423
|
case 'zitadel:owner':
|
|
433
424
|
return this.zitadelOwner(action, org, context);
|
|
425
|
+
case 'zitadel:login-host':
|
|
426
|
+
return this.zitadelLoginHost(action, org, context);
|
|
434
427
|
case 'zitadel:app':
|
|
435
428
|
return this.zitadelApp(action, org, context);
|
|
436
429
|
case 'zitadel:customers':
|
|
@@ -463,6 +456,8 @@ export class FoundryStepExecutor {
|
|
|
463
456
|
return this.aiKey(action, org, context);
|
|
464
457
|
case 'coolify:mail':
|
|
465
458
|
return this.coolifyMail(action, org, context);
|
|
459
|
+
case 'coolify:mail-copy':
|
|
460
|
+
return this.mailCopy(org, context);
|
|
466
461
|
case 'coolify:chat':
|
|
467
462
|
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");
|
|
468
463
|
case 'cms:site':
|
|
@@ -598,6 +593,54 @@ export class FoundryStepExecutor {
|
|
|
598
593
|
});
|
|
599
594
|
return { resourceId: userId };
|
|
600
595
|
}
|
|
596
|
+
/**
|
|
597
|
+
* `plan/diff.ts`'s `planLoginHost` (#267): adds the org's own company
|
|
598
|
+
* login host (`action.desired.host`) to the ZITADEL instance's trusted
|
|
599
|
+
* domains, writes zitadel-login's binding, then repoints the apps whose
|
|
600
|
+
* client id that binding holds. Apps created later are not in it yet:
|
|
601
|
+
* the next run replans (`diff.ts`'s `planLoginHost`) and moves them.
|
|
602
|
+
*/
|
|
603
|
+
async zitadelLoginHost(action, org, context) {
|
|
604
|
+
const recorded = context.recordedIds();
|
|
605
|
+
const zitadelOrgId = recorded.zitadel_org;
|
|
606
|
+
if (!zitadelOrgId) {
|
|
607
|
+
throw new Error(`zitadel:login-host for ${org.slug}: no ZITADEL org recorded yet -- the issuer must be provisioned first`);
|
|
608
|
+
}
|
|
609
|
+
// `planLoginHost` blocks the step while this port is not wired.
|
|
610
|
+
const bindings = this.ports.loginHostBindings;
|
|
611
|
+
if (!bindings) {
|
|
612
|
+
throw new Error(`zitadel:login-host for ${org.slug}: the loginHostBindings port is not wired`);
|
|
613
|
+
}
|
|
614
|
+
const host = action.desired.host;
|
|
615
|
+
await this.ports.zitadel.ensureTrustedDomain(host);
|
|
616
|
+
// zitadel-login's own routing table, written before any app is repointed
|
|
617
|
+
// so no app ever points at a host with no binding.
|
|
618
|
+
const clientIds = loginHostClientIds(recorded);
|
|
619
|
+
await bindings.upsertBinding({ host, organizationId: zitadelOrgId, clientIds });
|
|
620
|
+
// Only apps whose client id the binding now holds: any other app in the
|
|
621
|
+
// project would lose sign-in. The rest move on a later run, once the
|
|
622
|
+
// recorded client ids differ from the binding and replan this step.
|
|
623
|
+
const boundAppIds = new Set([
|
|
624
|
+
...(recorded[APP_CLIENT_ID_SCOPE_KEY]
|
|
625
|
+
? org.hosting.issuerApps.filter((a) => a.kind === 'web').map((a) => a.name)
|
|
626
|
+
: []),
|
|
627
|
+
...(recorded[CMS_CLIENT_ID_SCOPE_KEY] ? ['cms'] : []),
|
|
628
|
+
...(recorded[AGORA_CLIENT_ID_SCOPE_KEY] ? ['agora'] : []),
|
|
629
|
+
].map((name) => recorded[`zitadel:app:${name}`]));
|
|
630
|
+
const projectId = recorded.zitadel_project;
|
|
631
|
+
if (projectId) {
|
|
632
|
+
const apps = await this.ports.zitadel.listApps(zitadelOrgId, projectId);
|
|
633
|
+
for (const app of apps) {
|
|
634
|
+
if (app.state === 'APP_STATE_REMOVED' || !boundAppIds.has(app.id))
|
|
635
|
+
continue;
|
|
636
|
+
await this.ports.zitadel.updateOidcAppLoginBaseUri(zitadelOrgId, projectId, app.id, `https://${host}/`);
|
|
637
|
+
}
|
|
638
|
+
}
|
|
639
|
+
// Last, so a crash above reruns the whole step. A client id recorded
|
|
640
|
+
// after this point differs from it and replans the step next run.
|
|
641
|
+
context.recordScopeId(LOGIN_BINDING_SCOPE_KEY, loginBindingState(host, clientIds));
|
|
642
|
+
return { resourceId: host };
|
|
643
|
+
}
|
|
601
644
|
/**
|
|
602
645
|
* `plan/diff.ts`'s `planCustomers` (plan gap A9): the org's customers sign
|
|
603
646
|
* in through a second ZITADEL org, `<slug> customers`, with
|
|
@@ -822,11 +865,17 @@ export class FoundryStepExecutor {
|
|
|
822
865
|
// resource id is already recorded by the time this runs -- but this also
|
|
823
866
|
// covers the database being turned on after the app already exists (the
|
|
824
867
|
// app step still ran this apply, found the app by name, and lands here).
|
|
868
|
+
let dbPush = { pushed: false, changed: false };
|
|
825
869
|
if (org.hosting.database) {
|
|
826
870
|
const dbId = context.recordedIds()[`coolify:database:${action.key}`];
|
|
827
|
-
if (dbId)
|
|
828
|
-
await this.pushDatabaseUrl(appId, dbId);
|
|
871
|
+
if (dbId) {
|
|
872
|
+
dbPush = await this.pushDatabaseUrl(appId, dbId, org.slug.replace(/-/g, '_'));
|
|
873
|
+
}
|
|
829
874
|
}
|
|
875
|
+
// Restart when the push changed something, or when no earlier run got as
|
|
876
|
+
// far as restarting after one (marker missing): a crash between the push
|
|
877
|
+
// and the restart leaves the env right and the container stale.
|
|
878
|
+
const dbNeedsRestart = dbPush.pushed && (dbPush.changed || !context.recordedIds()[DB_ENV_APPLIED_SCOPE_KEY]);
|
|
830
879
|
// Every app keeps its own logs across a redeploy (#75). Unconditional,
|
|
831
880
|
// like the env push below: found by mount path before create, so a
|
|
832
881
|
// re-run (or the drift step `diff.ts`'s `planApp` plans for an app that
|
|
@@ -838,7 +887,17 @@ export class FoundryStepExecutor {
|
|
|
838
887
|
// The app receives whatever it needs to sign in (gap P2). Unconditional,
|
|
839
888
|
// unlike the database push above: every app has a hostname, and an
|
|
840
889
|
// upsert of the same value on a later apply is a no-op, not a change.
|
|
841
|
-
await this.pushAppEnv(appId, org, context);
|
|
890
|
+
const appEnv = await this.pushAppEnv(appId, org, context);
|
|
891
|
+
// Rule 4 (#239, #243): a container reads its env only at start, so an app
|
|
892
|
+
// that was already there is restarted, once, when its database URLs or its
|
|
893
|
+
// declared appEnv changed.
|
|
894
|
+
if ((appEnv.changed || dbNeedsRestart) && existing)
|
|
895
|
+
await this.ports.coolify.restart(appId);
|
|
896
|
+
// Recorded only after the restart succeeded: a failed restart leaves the
|
|
897
|
+
// old value, so the next plan replans (rule 3). The db marker is not a URL.
|
|
898
|
+
context.recordScopeId(APP_ENV_APPLIED_SCOPE_KEY, appEnv.applied);
|
|
899
|
+
if (dbPush.pushed)
|
|
900
|
+
context.recordScopeId(DB_ENV_APPLIED_SCOPE_KEY, '1');
|
|
842
901
|
// #140 rework (2026-09-28 lettermint-org-mail swap): the hosted app's own
|
|
843
902
|
// Lettermint project, domain restriction and `MAIL_FROM` (only the app
|
|
844
903
|
// started by this same step, per the design's "only apps that send mail
|
|
@@ -922,39 +981,80 @@ export class FoundryStepExecutor {
|
|
|
922
981
|
// (`coolifyApp`'s own push handles the more common order, database
|
|
923
982
|
// before app on the same apply -- `diff.ts`'s `computePlanDetail`).
|
|
924
983
|
const appId = context.recordedIds()[`coolify:app:${action.key}`];
|
|
925
|
-
if (appId)
|
|
926
|
-
await this.pushDatabaseUrl(appId, dbId);
|
|
984
|
+
if (appId) {
|
|
985
|
+
const push = await this.pushDatabaseUrl(appId, dbId, org.slug.replace(/-/g, '_'));
|
|
986
|
+
if (push.pushed) {
|
|
987
|
+
if (push.changed || !context.recordedIds()[DB_ENV_APPLIED_SCOPE_KEY]) {
|
|
988
|
+
await this.ports.coolify.restart(appId);
|
|
989
|
+
}
|
|
990
|
+
context.recordScopeId(DB_ENV_APPLIED_SCOPE_KEY, '1');
|
|
991
|
+
}
|
|
992
|
+
}
|
|
927
993
|
return { resourceId: dbId };
|
|
928
994
|
}
|
|
929
995
|
/**
|
|
930
|
-
* Pushes the database's connection string into the app's environment
|
|
931
|
-
*
|
|
932
|
-
*
|
|
933
|
-
*
|
|
934
|
-
*
|
|
935
|
-
*
|
|
936
|
-
*
|
|
937
|
-
*
|
|
938
|
-
* `db-role-guard.sh` then refuses to boot, since `DATABASE_URL_OWNER` being
|
|
939
|
-
* set means it expects `DATABASE_URL` to connect as a distinct,
|
|
940
|
-
* non-superuser runtime role (`<database>_rt`), which nothing here creates.
|
|
941
|
-
* Minting that role's own password and URL is a provisioning step of its
|
|
942
|
-
* own, not built here (Finding, this campaign's report).
|
|
996
|
+
* Pushes the database's connection string into the app's environment,
|
|
997
|
+
* split the same way `cmsSite`'s own database is (#117, #239): the
|
|
998
|
+
* superuser connection as `DATABASE_URL_OWNER`, and a distinct, restricted
|
|
999
|
+
* `<dbName>_rt` role's connection as `DATABASE_URL`. Archon's and manage's
|
|
1000
|
+
* own boot scripts (`db-role-guard.sh`) refuse to start against a
|
|
1001
|
+
* superuser `DATABASE_URL`, and lokessmie's refuses to start without
|
|
1002
|
+
* `DATABASE_URL_OWNER` at all -- a bare superuser `DATABASE_URL` with no
|
|
1003
|
+
* `DATABASE_URL_OWNER` is "before the split" there too.
|
|
943
1004
|
*
|
|
944
|
-
*
|
|
945
|
-
* (`postgres://user:password@host/db`):
|
|
946
|
-
*
|
|
947
|
-
* nowhere else in this class -- never returned, never
|
|
948
|
-
* `serviceIds` (hence never in the store or an audit record),
|
|
949
|
-
* never interpolated into a thrown error.
|
|
1005
|
+
* Both connection strings are live credentials
|
|
1006
|
+
* (`postgres://user:password@host/db`): read from `CoolifyPort.getDatabase`
|
|
1007
|
+
* (or minted here, for the runtime one) and handed straight to
|
|
1008
|
+
* `bulkUpsertEnvs` and nowhere else in this class -- never returned, never
|
|
1009
|
+
* recorded in `serviceIds` (hence never in the store or an audit record),
|
|
1010
|
+
* never logged, never interpolated into a thrown error.
|
|
950
1011
|
*/
|
|
951
|
-
async pushDatabaseUrl(appId, dbId) {
|
|
1012
|
+
async pushDatabaseUrl(appId, dbId, dbName) {
|
|
952
1013
|
const db = await this.ports.coolify.getDatabase(dbId);
|
|
1014
|
+
// Nothing to push; `planApp` reports this as blocked from the live reader.
|
|
953
1015
|
if (!db.internal_db_url)
|
|
954
|
-
return;
|
|
955
|
-
|
|
956
|
-
{ key: '
|
|
957
|
-
]
|
|
1016
|
+
return { pushed: false, changed: false };
|
|
1017
|
+
const envs = [
|
|
1018
|
+
{ key: 'DATABASE_URL_OWNER', value: db.internal_db_url },
|
|
1019
|
+
];
|
|
1020
|
+
const runtimeUrl = await this.ensureAppRuntimeUrl(appId, db.internal_db_url, dbName);
|
|
1021
|
+
if (runtimeUrl)
|
|
1022
|
+
envs.push({ key: 'DATABASE_URL', value: runtimeUrl });
|
|
1023
|
+
// True when a value actually changed, so the caller restarts the app only
|
|
1024
|
+
// then (the owner URL is upserted on every apply).
|
|
1025
|
+
const ownerBefore = (await this.ports.coolify.listEnvs(appId)).find((e) => e.key === 'DATABASE_URL_OWNER' && !e.is_preview)?.value;
|
|
1026
|
+
await this.ports.coolify.bulkUpsertEnvs(appId, envs);
|
|
1027
|
+
return { pushed: true, changed: runtimeUrl !== null || ownerBefore !== db.internal_db_url };
|
|
1028
|
+
}
|
|
1029
|
+
/**
|
|
1030
|
+
* `null` once the app already has a `DATABASE_URL` -- never rotated, the
|
|
1031
|
+
* same rule `ensureCmsRuntimeUrl` follows and for the same reason: the
|
|
1032
|
+
* app's own boot script creates or updates the live `<dbName>_rt` Postgres
|
|
1033
|
+
* role from exactly the username/password this method mints, once:
|
|
1034
|
+
* overwriting `DATABASE_URL` on a later apply would leave that
|
|
1035
|
+
* already-created role's real password out of sync with whatever foundry
|
|
1036
|
+
* hands the app next. See `ensureCmsRuntimeUrl`'s own doc comment for the
|
|
1037
|
+
* parsing and encoding choices this mirrors exactly.
|
|
1038
|
+
*/
|
|
1039
|
+
async ensureAppRuntimeUrl(appId, dbInternalUrl, dbName) {
|
|
1040
|
+
const envs = await this.ports.coolify.listEnvs(appId);
|
|
1041
|
+
// An existing `DATABASE_URL` that is still the superuser string is what an
|
|
1042
|
+
// app got before the split (#239): treat it as absent so the runtime role
|
|
1043
|
+
// is minted and replaces it, or the app's db-role-guard refuses to boot.
|
|
1044
|
+
const already = envs.some((e) => e.key === 'DATABASE_URL' && !e.is_preview && e.value !== dbInternalUrl);
|
|
1045
|
+
if (already)
|
|
1046
|
+
return null;
|
|
1047
|
+
let host;
|
|
1048
|
+
let pathname;
|
|
1049
|
+
let search;
|
|
1050
|
+
try {
|
|
1051
|
+
({ host, pathname, search } = new URL(dbInternalUrl));
|
|
1052
|
+
}
|
|
1053
|
+
catch {
|
|
1054
|
+
return null;
|
|
1055
|
+
}
|
|
1056
|
+
const password = randomBytes(24).toString('base64url');
|
|
1057
|
+
return `postgres://${dbName}_rt:${password}@${host}${pathname}${search}`;
|
|
958
1058
|
}
|
|
959
1059
|
/**
|
|
960
1060
|
* Pushes what the org's app needs to sign a user in through ZITADEL (gap
|
|
@@ -970,9 +1070,12 @@ export class FoundryStepExecutor {
|
|
|
970
1070
|
* `zitadel:issuer` (`AUTH_ORGANIZATION_ID`), `zitadel:owner`
|
|
971
1071
|
* (`OWNER_USER_ID`, gap P3) and every `zitadel:app` step
|
|
972
1072
|
* (`AUTH_CLIENT_ID`), all ordered before `coolify:app` for exactly this
|
|
973
|
-
* reason. AUTH_APP_KEY (
|
|
974
|
-
*
|
|
975
|
-
*
|
|
1073
|
+
* reason. `AUTH_APP_KEY` (#206 -- which broker an org's app registers with
|
|
1074
|
+
* is its own design question, left out of #204's own "How" rather than
|
|
1075
|
+
* guessed) is out of scope here (task brief). `DATABASE_URL_OWNER`/
|
|
1076
|
+
* `DATABASE_URL` are pushed by `pushDatabaseUrl` instead, not here --
|
|
1077
|
+
* that call runs only once `hosting.database` is declared and
|
|
1078
|
+
* provisioned, where this one is unconditional (#239).
|
|
976
1079
|
*/
|
|
977
1080
|
async pushAppEnv(appId, org, context) {
|
|
978
1081
|
const recorded = context.recordedIds();
|
|
@@ -1003,7 +1106,21 @@ export class FoundryStepExecutor {
|
|
|
1003
1106
|
const cookieSecret = await this.ensureAuthCookieSecret(appId);
|
|
1004
1107
|
if (cookieSecret)
|
|
1005
1108
|
envs.push({ key: 'AUTH_COOKIE_SECRET', value: cookieSecret });
|
|
1109
|
+
// #243: the app's own declared map from its env var names to values the
|
|
1110
|
+
// plan already mints for this org (Files, AI, mail) -- each bespoke app
|
|
1111
|
+
// names these differently (lokessmie uses STORAGE_*/AI_*). Nothing is
|
|
1112
|
+
// minted here; a source with no value yet (its own step has not run,
|
|
1113
|
+
// or has not run for this apply) is simply omitted, the same way every
|
|
1114
|
+
// other optional entry above is.
|
|
1115
|
+
const appEnv = resolveAppEnv(org, recorded).values;
|
|
1116
|
+
for (const [key, value] of Object.entries(appEnv))
|
|
1117
|
+
envs.push({ key, value });
|
|
1006
1118
|
await this.ports.coolify.bulkUpsertEnvs(appId, envs);
|
|
1119
|
+
// `coolifyApp` records `applied` once the restart succeeded, so `planApp`
|
|
1120
|
+
// replans this step when the declared map changes (rule 3). Ids and a
|
|
1121
|
+
// domain, never a secret.
|
|
1122
|
+
const applied = JSON.stringify(appEnv);
|
|
1123
|
+
return { changed: applied !== (recorded[APP_ENV_APPLIED_SCOPE_KEY] ?? '{}'), applied };
|
|
1007
1124
|
}
|
|
1008
1125
|
/**
|
|
1009
1126
|
* `null` once the app already has one -- `AUTH_COOKIE_SECRET` is generated
|
|
@@ -1052,7 +1169,10 @@ export class FoundryStepExecutor {
|
|
|
1052
1169
|
* be deleted.
|
|
1053
1170
|
*
|
|
1054
1171
|
* `MAIL_FROM` is upserted every time: it depends only on the mail domain
|
|
1055
|
-
* `coolify:mail` resolved.
|
|
1172
|
+
* `coolify:mail` resolved and `org.mail.noReplyLocalPart` (#162, decided
|
|
1173
|
+
* 2026-09-29 -- defaults to `'no-reply'`, restated here rather than
|
|
1174
|
+
* imported from `org.ts`'s `DEFAULT_NO_REPLY_LOCAL_PART`, this file's own
|
|
1175
|
+
* `LOG_DIR`-style convention).
|
|
1056
1176
|
*
|
|
1057
1177
|
* The token goes straight to `bulkUpsertEnvs` and nowhere else: never
|
|
1058
1178
|
* returned, never recorded in `serviceIds` (`recordScopeId`'s own doc
|
|
@@ -1065,7 +1185,6 @@ export class FoundryStepExecutor {
|
|
|
1065
1185
|
if (!mailDomain)
|
|
1066
1186
|
return;
|
|
1067
1187
|
const appName = `${org.slug}-app`;
|
|
1068
|
-
const relayName = `${org.slug}-relay`;
|
|
1069
1188
|
const [projects, envs] = await Promise.all([
|
|
1070
1189
|
this.ports.lettermint.listProjects(),
|
|
1071
1190
|
this.ports.coolify.listEnvs(appId),
|
|
@@ -1086,18 +1205,133 @@ export class FoundryStepExecutor {
|
|
|
1086
1205
|
allProjects = [...projects, created.project];
|
|
1087
1206
|
envUpdates.push({ key: 'LETTERMINT_API_TOKEN', value: created.token });
|
|
1088
1207
|
}
|
|
1089
|
-
envUpdates.push({
|
|
1208
|
+
envUpdates.push({
|
|
1209
|
+
key: 'MAIL_FROM',
|
|
1210
|
+
value: `${org.mail.noReplyLocalPart ?? 'no-reply'}@${mailDomain}`,
|
|
1211
|
+
});
|
|
1090
1212
|
await this.ports.coolify.bulkUpsertEnvs(appId, envUpdates);
|
|
1091
1213
|
// A running container read its env at start and still holds the old
|
|
1092
1214
|
// token, so a fresh one only reaches it through a restart -- before the
|
|
1093
1215
|
// old project is deleted below. A just-created app starts with the new env.
|
|
1094
1216
|
if (!healthy && running)
|
|
1095
1217
|
await this.ports.coolify.restart(appId);
|
|
1218
|
+
await this.ensureLettermintDomainRestriction(org, mailDomain, allProjects);
|
|
1219
|
+
for (const project of allProjects) {
|
|
1220
|
+
if (project.name === appName && project.id !== keepProjectId) {
|
|
1221
|
+
await this.ports.lettermint.deleteProject(project.id);
|
|
1222
|
+
}
|
|
1223
|
+
}
|
|
1224
|
+
}
|
|
1225
|
+
/**
|
|
1226
|
+
* #149: mints this org's own Lettermint relay credential and writes it to
|
|
1227
|
+
* Stalwart via `MailPort.applyOrgRelay`, the same mint rule
|
|
1228
|
+
* {@link ensureAppLettermintMail} already uses for the org's app token --
|
|
1229
|
+
* "healthy" means Stalwart's own route exists ({@link MailPort.hasOrgRelay}),
|
|
1230
|
+
* AND Lettermint lists a project named `<slug>-relay` whose id
|
|
1231
|
+
* {@link ORG_MAIL_RELAY_SCOPE_KEY} records for this
|
|
1232
|
+
* exact mail domain (review finding: a route with a secret and a
|
|
1233
|
+
* same-named project both present is not enough -- the project could be a
|
|
1234
|
+
* lost-and-reprovisioned one this step never wrote a route for, and a mail
|
|
1235
|
+
* domain switch must re-point the route rather than read as healthy
|
|
1236
|
+
* forever because the old route still has a secret in it). A route with no
|
|
1237
|
+
* live project behind it, a project the route's own secret never reached,
|
|
1238
|
+
* or a recorded project id/domain that no longer matches, are all exactly
|
|
1239
|
+
* as unhealthy as neither existing.
|
|
1240
|
+
*
|
|
1241
|
+
* A fresh project is created and its token written to Stalwart first, and
|
|
1242
|
+
* only then is every other `<slug>-relay` project deleted -- a lost route
|
|
1243
|
+
* and a duplicate project both end the same way. The new project's domain
|
|
1244
|
+
* restriction runs before the token is written, so a failure there never
|
|
1245
|
+
* touches the working route. Review finding: if either the
|
|
1246
|
+
* domain-restriction step or `applyOrgRelay` throws after a
|
|
1247
|
+
* fresh project was minted, that project is deleted before the error
|
|
1248
|
+
* propagates -- otherwise every retry sees more than one `<slug>-relay`
|
|
1249
|
+
* project and mints yet another one, growing the orphan count without
|
|
1250
|
+
* bound.
|
|
1251
|
+
*
|
|
1252
|
+
* `coolify:mail` runs before `coolify:app` (`diff.ts`'s own step order), so
|
|
1253
|
+
* this is very often the first of the two to reach the org's Lettermint
|
|
1254
|
+
* domain: {@link ensureLettermintDomainRestriction} registers and restricts
|
|
1255
|
+
* it the same way the app step does, rather than assuming it already
|
|
1256
|
+
* exists -- the domain must never end up unrestricted, whichever step gets
|
|
1257
|
+
* there first.
|
|
1258
|
+
*/
|
|
1259
|
+
async ensureOrgLettermintRelay(org, mailOrg, mailDomain, context) {
|
|
1260
|
+
const relayName = `${org.slug}-relay`;
|
|
1261
|
+
const [projects, hasRelay] = await Promise.all([
|
|
1262
|
+
this.ports.lettermint.listProjects(),
|
|
1263
|
+
this.ports.mail.hasOrgRelay(mailOrg),
|
|
1264
|
+
]);
|
|
1265
|
+
// The recorded project, not "the only one": a leftover duplicate whose
|
|
1266
|
+
// delete keeps failing must not force a fresh mint on every apply.
|
|
1267
|
+
const recordedRelay = context.recordedIds()[ORG_MAIL_RELAY_SCOPE_KEY];
|
|
1268
|
+
const held = projects.find((p) => p.name === relayName && `${p.id}@${mailDomain}` === recordedRelay);
|
|
1269
|
+
const healthy = held !== undefined && hasRelay;
|
|
1270
|
+
let allProjects = projects;
|
|
1271
|
+
let keepProjectId;
|
|
1272
|
+
if (healthy && held) {
|
|
1273
|
+
keepProjectId = held.id;
|
|
1274
|
+
await this.ensureLettermintDomainRestriction(org, mailDomain, allProjects);
|
|
1275
|
+
}
|
|
1276
|
+
else {
|
|
1277
|
+
const created = await this.ports.lettermint.createProject(relayName);
|
|
1278
|
+
keepProjectId = created.project.id;
|
|
1279
|
+
allProjects = [...projects, created.project];
|
|
1280
|
+
try {
|
|
1281
|
+
// Everything that can fail for the new project runs before the token
|
|
1282
|
+
// reaches Stalwart: a failure here leaves the working route alone.
|
|
1283
|
+
// The restriction covers old and new projects, so the old one keeps
|
|
1284
|
+
// sending until the route switches.
|
|
1285
|
+
await this.ensureLettermintDomainRestriction(org, mailDomain, allProjects);
|
|
1286
|
+
await this.ports.mail.applyOrgRelay(mailOrg, created.token);
|
|
1287
|
+
}
|
|
1288
|
+
catch (err) {
|
|
1289
|
+
await this.discardLettermintProject(created.project.id);
|
|
1290
|
+
throw err;
|
|
1291
|
+
}
|
|
1292
|
+
context.recordScopeId(ORG_MAIL_RELAY_SCOPE_KEY, `${keepProjectId}@${mailDomain}`);
|
|
1293
|
+
// Written now: a crash before the end-of-run write would leave the old
|
|
1294
|
+
// project recorded, and the next apply would sweep the one the route uses.
|
|
1295
|
+
await context.persistScopeIds();
|
|
1296
|
+
}
|
|
1297
|
+
for (const project of allProjects) {
|
|
1298
|
+
if (project.name === relayName && project.id !== keepProjectId) {
|
|
1299
|
+
await this.ports.lettermint.deleteProject(project.id);
|
|
1300
|
+
}
|
|
1301
|
+
}
|
|
1302
|
+
}
|
|
1303
|
+
/**
|
|
1304
|
+
* Deletes a just-minted project from inside a `catch`. A failed delete is
|
|
1305
|
+
* dropped so it never replaces the error being rethrown; the orphan is
|
|
1306
|
+
* swept by the next apply's duplicate delete.
|
|
1307
|
+
*/
|
|
1308
|
+
async discardLettermintProject(id) {
|
|
1309
|
+
try {
|
|
1310
|
+
await this.ports.lettermint.deleteProject(id);
|
|
1311
|
+
}
|
|
1312
|
+
catch {
|
|
1313
|
+
// Dropped: the caller rethrows the original error.
|
|
1314
|
+
}
|
|
1315
|
+
}
|
|
1316
|
+
/**
|
|
1317
|
+
* Ensures this org's Lettermint domain exists, is restricted to exactly
|
|
1318
|
+
* the `<slug>-app`/`<slug>-relay` projects live in `allProjects`, has its
|
|
1319
|
+
* DNS published and a verification triggered. Shared by
|
|
1320
|
+
* {@link ensureAppLettermintMail} and {@link ensureOrgLettermintRelay}
|
|
1321
|
+
* (#149) -- whichever of the two steps runs first on a fresh org creates
|
|
1322
|
+
* the domain, and the other then finds it (`findExactLettermintDomain`).
|
|
1323
|
+
*
|
|
1324
|
+
* A crash between the restriction write and the DNS publish/verify below
|
|
1325
|
+
* is safe even for a freshly created domain: DNS is only published, and
|
|
1326
|
+
* verification only triggered, after the restriction -- so an
|
|
1327
|
+
* unrestricted domain cannot verify and cannot send, whichever project
|
|
1328
|
+
* owns it (post-#140 review finding, restated here rather than only at
|
|
1329
|
+
* the one call site it used to have).
|
|
1330
|
+
*/
|
|
1331
|
+
async ensureLettermintDomainRestriction(org, mailDomain, allProjects) {
|
|
1332
|
+
const appName = `${org.slug}-app`;
|
|
1333
|
+
const relayName = `${org.slug}-relay`;
|
|
1096
1334
|
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
1335
|
const domain = existing
|
|
1102
1336
|
? await this.ports.lettermint.getDomain(existing.id)
|
|
1103
1337
|
: await this.ports.lettermint.createDomain(mailDomain);
|
|
@@ -1107,15 +1341,10 @@ export class FoundryStepExecutor {
|
|
|
1107
1341
|
await this.ports.lettermint.restrictDomainToProjects(domain.id, scopedProjectIds);
|
|
1108
1342
|
const zone = await this.ports.cloudflare.findZoneByApex(mailDomain);
|
|
1109
1343
|
if (!zone) {
|
|
1110
|
-
throw new Error(`
|
|
1344
|
+
throw new Error(`Lettermint for ${org.slug}: no Cloudflare zone found for ${mailDomain}`);
|
|
1111
1345
|
}
|
|
1112
1346
|
await this.publishLettermintDnsRecords(zone.id, domain.dnsRecords);
|
|
1113
1347
|
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
1348
|
}
|
|
1120
1349
|
/**
|
|
1121
1350
|
* Review finding (post-#140 rework): {@link LettermintPort.findDomain}'s
|
|
@@ -1713,9 +1942,9 @@ export class FoundryStepExecutor {
|
|
|
1713
1942
|
if (!masCredentials) {
|
|
1714
1943
|
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
1944
|
}
|
|
1716
|
-
const sfu = await this.ports.callsSfu.resolveCredentials();
|
|
1945
|
+
const sfu = await this.ports.callsSfu.resolveCredentials(org.slug);
|
|
1717
1946
|
if (!sfu) {
|
|
1718
|
-
throw new Error(`coolify:calls-service for ${org.slug}: the shared calls SFU has no LiveKit credentials
|
|
1947
|
+
throw new Error(`coolify:calls-service for ${org.slug}: the shared calls SFU has no LiveKit credentials for this org yet -- see https://github.com/wtfalch/foundry/issues/152`);
|
|
1719
1948
|
}
|
|
1720
1949
|
const projectUuid = await this.ensureCoolifyProject(org, context);
|
|
1721
1950
|
const { environment } = await this.ports.coolify.findOrCreateEnvironment(projectUuid, 'production');
|
|
@@ -1963,6 +2192,19 @@ export class FoundryStepExecutor {
|
|
|
1963
2192
|
await this.ports.cloudflare.putTunnelConfig(tunnelId, {
|
|
1964
2193
|
ingress: [{ service: 'http_status:404' }],
|
|
1965
2194
|
});
|
|
2195
|
+
// #235: park stops the connector too -- an ingress with nothing
|
|
2196
|
+
// routed is still a running container dialed into the tunnel.
|
|
2197
|
+
const connectorAppId = context.recordedIds()[CLOUDFLARE_TUNNEL_CONNECTOR_APP_SCOPE_KEY];
|
|
2198
|
+
if (connectorAppId) {
|
|
2199
|
+
try {
|
|
2200
|
+
await this.ports.coolify.raw('POST', `/applications/${connectorAppId}/stop`);
|
|
2201
|
+
}
|
|
2202
|
+
catch (error) {
|
|
2203
|
+
// Already gone (destroyed out of band): nothing left to stop.
|
|
2204
|
+
if (!isNotFoundError(error))
|
|
2205
|
+
throw error;
|
|
2206
|
+
}
|
|
2207
|
+
}
|
|
1966
2208
|
return { resourceId: tunnelId };
|
|
1967
2209
|
}
|
|
1968
2210
|
const tunnelName = `${org.slug}-tunnel`;
|
|
@@ -1992,6 +2234,29 @@ export class FoundryStepExecutor {
|
|
|
1992
2234
|
await this.ports.cloudflare.putTunnelConfig(tunnel.id, {
|
|
1993
2235
|
ingress: [...ingress, { service: 'http_status:404' }],
|
|
1994
2236
|
});
|
|
2237
|
+
// #235: a connector for this org's own tunnel, the same call every
|
|
2238
|
+
// platform tunnel step already makes (Decided, William, 2026-10-01:
|
|
2239
|
+
// one connector per org tunnel, never a shared tunnel with appended
|
|
2240
|
+
// ingress). Without this, the tunnel exists and is configured but
|
|
2241
|
+
// nothing is dialed into it, so `hostname` answers with a Cloudflare
|
|
2242
|
+
// error -- #197's own gap, for an org's tunnel rather than a platform
|
|
2243
|
+
// one.
|
|
2244
|
+
const projectUuid = await this.ensureCoolifyProject(org, context);
|
|
2245
|
+
const { environment } = await this.ports.coolify.findOrCreateEnvironment(projectUuid, 'production');
|
|
2246
|
+
await ensureCloudflaredConnector({
|
|
2247
|
+
coolify: this.ports.coolify,
|
|
2248
|
+
cloudflare: this.ports.cloudflare,
|
|
2249
|
+
tunnelId: tunnel.id,
|
|
2250
|
+
appName: `${org.slug}-cloudflared`,
|
|
2251
|
+
projectUuid,
|
|
2252
|
+
environment,
|
|
2253
|
+
resolveServer: () => this.resolveServer(),
|
|
2254
|
+
recordedConnectorAppId: context.recordedIds()[CLOUDFLARE_TUNNEL_CONNECTOR_APP_SCOPE_KEY] ?? null,
|
|
2255
|
+
recordAppId: async (appId) => {
|
|
2256
|
+
context.recordScopeId(CLOUDFLARE_TUNNEL_CONNECTOR_APP_SCOPE_KEY, appId);
|
|
2257
|
+
await context.persistScopeIds();
|
|
2258
|
+
},
|
|
2259
|
+
});
|
|
1995
2260
|
return { resourceId: tunnel.id };
|
|
1996
2261
|
}
|
|
1997
2262
|
/**
|
|
@@ -2031,20 +2296,21 @@ export class FoundryStepExecutor {
|
|
|
2031
2296
|
* `@wtfalch/email/postmaster`'s `applyOrgMail`/`parkOrgMail` bound), then
|
|
2032
2297
|
* publishes whatever Stalwart DNS records the call returns, and records the
|
|
2033
2298
|
* resolved mail domain as a scope id ({@link MAIL_DOMAIN_SCOPE_KEY}).
|
|
2034
|
-
*
|
|
2035
|
-
*
|
|
2036
|
-
*
|
|
2037
|
-
* (`ensureAppLettermintMail`, "Core rule"), since the
|
|
2038
|
-
* only exist once the token has a home. Parking mail
|
|
2039
|
-
*
|
|
2040
|
-
* deletes the org's own projects and
|
|
2041
|
-
* ends up restricted only to dead projects
|
|
2299
|
+
*
|
|
2300
|
+
* #149: this step also mints the org's own Lettermint *relay* credential
|
|
2301
|
+
* ({@link ensureOrgLettermintRelay}) -- the app's own *sending* credential
|
|
2302
|
+
* stays in the app step (`ensureAppLettermintMail`, "Core rule"), since the
|
|
2303
|
+
* first app project can only exist once the token has a home. Parking mail
|
|
2304
|
+
* (mode `'off'`) reaches Lettermint too: {@link parkLettermintMail} drops
|
|
2305
|
+
* the relay route, then deletes the org's own projects and its domain, so
|
|
2306
|
+
* a domain never ends up restricted only to dead projects and no route
|
|
2307
|
+
* ever points at a project this apply is about to remove.
|
|
2042
2308
|
*
|
|
2043
2309
|
* Review finding: a mail domain switch (`org.domain` changes, or
|
|
2044
2310
|
* `usesDomain` flips) must also delete the *old* Lettermint domain here,
|
|
2045
2311
|
* before recording the new one -- otherwise it stays registered and
|
|
2046
|
-
* restricted to the org's still-live `<slug>-app`
|
|
2047
|
-
* then keep sending as it forever. {@link deleteStaleLettermintDomain}
|
|
2312
|
+
* restricted to the org's still-live `<slug>-app`/`<slug>-relay` projects,
|
|
2313
|
+
* which could then keep sending as it forever. {@link deleteStaleLettermintDomain}
|
|
2048
2314
|
* does this by comparing the previously recorded domain (case-insensitive)
|
|
2049
2315
|
* against the one this apply just resolved.
|
|
2050
2316
|
*
|
|
@@ -2058,15 +2324,22 @@ export class FoundryStepExecutor {
|
|
|
2058
2324
|
// #177: `diff.ts`'s `planMail` carries the platform's own apex/mail host
|
|
2059
2325
|
// in `desired` once it is wired -- "the executor writes exactly these
|
|
2060
2326
|
// host values, so what the plan showed is what gets applied" (`planAgora`'s
|
|
2061
|
-
// own rule)
|
|
2062
|
-
//
|
|
2063
|
-
|
|
2064
|
-
|
|
2065
|
-
|
|
2066
|
-
|
|
2327
|
+
// own rule) -- and blocks the step (never plans it) while the platform's
|
|
2328
|
+
// mail is off or not yet applied. So a `desired` missing either value
|
|
2329
|
+
// here is not a live gap to fall back on (#205): it is a step planned
|
|
2330
|
+
// before that rule existed, now stale. Fail rather than silently point an
|
|
2331
|
+
// org's mail at a fixed address nothing may still be listening on; the
|
|
2332
|
+
// fix is to plan this org's mail again.
|
|
2333
|
+
const apex = action.desired.apex;
|
|
2334
|
+
const mailHost = action.desired.mailHost;
|
|
2335
|
+
if (!apex || !mailHost) {
|
|
2336
|
+
throw new Error(`coolify:mail for ${org.slug}: this step's plan carries no platform mail ${!apex && !mailHost ? 'apex or host' : !apex ? 'apex' : 'host'}; plan this org's mail again`);
|
|
2337
|
+
}
|
|
2338
|
+
const options = { apex, mailHost };
|
|
2067
2339
|
if (mode === 'off') {
|
|
2068
|
-
|
|
2069
|
-
await this.
|
|
2340
|
+
const parkedMailOrg = mailOrgFor(org, usesDomain, 'hosted');
|
|
2341
|
+
await this.ports.mail.parkOrgMail(parkedMailOrg, options);
|
|
2342
|
+
await this.parkLettermintMail(org, parkedMailOrg, context);
|
|
2070
2343
|
return undefined;
|
|
2071
2344
|
}
|
|
2072
2345
|
const mailOrg = mailOrgFor(org, usesDomain, mode);
|
|
@@ -2077,30 +2350,97 @@ export class FoundryStepExecutor {
|
|
|
2077
2350
|
// #177: what this step wrote from the platform, so `planMail` can see a later change.
|
|
2078
2351
|
context.recordScopeId(ORG_MAIL_APPLIED_APEX_SCOPE_KEY, options.apex);
|
|
2079
2352
|
context.recordScopeId(ORG_MAIL_APPLIED_HOST_SCOPE_KEY, options.mailHost);
|
|
2080
|
-
await this.ensureOwnerMailbox(org, mailOrg,
|
|
2353
|
+
await this.ensureOwnerMailbox(org, mailOrg, context);
|
|
2354
|
+
await this.ensureOrgLettermintRelay(org, mailOrg, result.domain, context);
|
|
2355
|
+
await this.ensureRoleMailboxes(org, mailOrg, result.domain, context);
|
|
2081
2356
|
return { resourceId: result.domain };
|
|
2082
2357
|
}
|
|
2083
2358
|
/**
|
|
2084
|
-
* #
|
|
2085
|
-
*
|
|
2086
|
-
*
|
|
2087
|
-
*
|
|
2088
|
-
*
|
|
2089
|
-
* `
|
|
2090
|
-
*
|
|
2359
|
+
* `plan/diff.ts`'s `planMailCopy` (#113): this apply's share of copying
|
|
2360
|
+
* the org's old mail into Stalwart, via `mail-copy.ts`'s `runMailCopyStep`
|
|
2361
|
+
* -- the only thing this method itself does is read back what the last
|
|
2362
|
+
* apply recorded, hand it to that function, and record what comes back.
|
|
2363
|
+
* An account that failed to copy records the progress made, never
|
|
2364
|
+
* `mail_copy_done`, then throws with the failing account, phase and
|
|
2365
|
+
* redacted stderr tail, so the apply reports it as not done. A missing
|
|
2366
|
+
* `mailCopy` port throws too: a configuration gap a retry cannot fix.
|
|
2091
2367
|
*/
|
|
2092
|
-
async
|
|
2368
|
+
async mailCopy(org, context) {
|
|
2369
|
+
if (!this.ports.mailCopy) {
|
|
2370
|
+
throw new NotBuiltError(`coolify:mail-copy for ${org.slug}`, 'construct FoundryStepExecutor with `mailCopy` (#113)');
|
|
2371
|
+
}
|
|
2372
|
+
const alreadyCompleted = new Set(parseMailCopyCompleted(context.recordedIds()[MAIL_COPY_COMPLETED_SCOPE_KEY]));
|
|
2373
|
+
const result = await runMailCopyStep(org, this.ports.mailCopy, alreadyCompleted);
|
|
2374
|
+
context.recordScopeId(MAIL_COPY_COMPLETED_SCOPE_KEY, JSON.stringify(result.completed));
|
|
2375
|
+
if (result.done)
|
|
2376
|
+
context.recordScopeId(MAIL_COPY_DONE_SCOPE_KEY, '1');
|
|
2377
|
+
if (result.failure) {
|
|
2378
|
+
const { account, phase, exitCode, detail } = result.failure;
|
|
2379
|
+
// Written now: the throw below ends the step before the end-of-run write.
|
|
2380
|
+
await context.persistScopeIds();
|
|
2381
|
+
throw new Error(`coolify:mail-copy for ${org.slug}: ${phase} of ${account} ${exitCode === undefined ? 'threw' : `exited ${exitCode}`}${detail ? `: ${detail}` : ''} -- retry this apply to continue (a provider's daily limit looks the same)`);
|
|
2382
|
+
}
|
|
2383
|
+
return undefined;
|
|
2384
|
+
}
|
|
2385
|
+
/**
|
|
2386
|
+
* #162 (decided 2026-09-29): gives the org's declared owner a mailbox at
|
|
2387
|
+
* its declared `mail.ownerMailboxAddress` -- nothing is guessed any more
|
|
2388
|
+
* (the hardcoded `owner@<domain>` PR 163 shipped first is gone). A no-op
|
|
2389
|
+
* when the org has no owner yet, when no address is declared yet
|
|
2390
|
+
* (`plan/diff.ts`'s `planMail` reports that case as blocked, not planned,
|
|
2391
|
+
* so this step's own mode/domain work still runs), or once
|
|
2392
|
+
* {@link MAIL_OWNER_MAILBOX_SCOPE_KEY} is already recorded -- checked last
|
|
2393
|
+
* so a second apply never calls `MailPort.createMailbox` again, rather
|
|
2394
|
+
* than relying on that port method's own duplicate refusal.
|
|
2395
|
+
*/
|
|
2396
|
+
async ensureOwnerMailbox(org, mailOrg, context) {
|
|
2093
2397
|
if (!org.owner)
|
|
2094
2398
|
return;
|
|
2399
|
+
const address = org.mail.ownerMailboxAddress ?? null;
|
|
2400
|
+
if (!address)
|
|
2401
|
+
return;
|
|
2095
2402
|
if (context.recordedIds()[MAIL_OWNER_MAILBOX_SCOPE_KEY])
|
|
2096
2403
|
return;
|
|
2097
|
-
const address = `${MAIL_OWNER_LOCAL_PART}@${domain}`;
|
|
2098
2404
|
await this.ports.mail.createMailbox(mailOrg, {
|
|
2099
2405
|
address,
|
|
2100
2406
|
name: `${org.owner.givenName} ${org.owner.familyName}`,
|
|
2101
2407
|
});
|
|
2102
2408
|
context.recordScopeId(MAIL_OWNER_MAILBOX_SCOPE_KEY, address);
|
|
2103
2409
|
}
|
|
2410
|
+
/**
|
|
2411
|
+
* #230: creates each of the org's declared `mail.roleMailboxes` on its
|
|
2412
|
+
* just-resolved mail domain, once. A role mailbox has no person behind it,
|
|
2413
|
+
* so its display name is just its local part, and like the owner's it is
|
|
2414
|
+
* created with no password: `MailPort.createMailbox` takes none, so there
|
|
2415
|
+
* is no secret to store or print. A "already has a mailbox" refusal from the port counts as created. The created set is recorded under
|
|
2416
|
+
* {@link MAIL_ROLE_MAILBOXES_SCOPE_KEY} after every mailbox, so a second
|
|
2417
|
+
* apply (or a retry after a crash) skips what exists rather than relying
|
|
2418
|
+
* on the mail server's own duplicate refusal.
|
|
2419
|
+
*/
|
|
2420
|
+
async ensureRoleMailboxes(org, mailOrg, domain, context) {
|
|
2421
|
+
const declared = org.mail.roleMailboxes ?? [];
|
|
2422
|
+
if (declared.length === 0)
|
|
2423
|
+
return;
|
|
2424
|
+
const recorded = context.recordedIds()[MAIL_ROLE_MAILBOXES_SCOPE_KEY];
|
|
2425
|
+
const created = new Set(recorded ? recorded.split(',') : []);
|
|
2426
|
+
for (const local of declared) {
|
|
2427
|
+
const address = `${local}@${domain}`;
|
|
2428
|
+
const entry = address.toLowerCase();
|
|
2429
|
+
if (created.has(entry))
|
|
2430
|
+
continue;
|
|
2431
|
+
try {
|
|
2432
|
+
await this.ports.mail.createMailbox(mailOrg, { address, name: local });
|
|
2433
|
+
}
|
|
2434
|
+
catch (error) {
|
|
2435
|
+
// A crash between create and record leaves the mailbox made but
|
|
2436
|
+
// unrecorded; the port exposes no existence check, only the refusal.
|
|
2437
|
+
if (!/already has a mailbox/i.test(error instanceof Error ? error.message : String(error)))
|
|
2438
|
+
throw error;
|
|
2439
|
+
}
|
|
2440
|
+
created.add(entry);
|
|
2441
|
+
context.recordScopeId(MAIL_ROLE_MAILBOXES_SCOPE_KEY, [...created].join(','));
|
|
2442
|
+
}
|
|
2443
|
+
}
|
|
2104
2444
|
/**
|
|
2105
2445
|
* Deletes the org's previous Lettermint domain once the mail domain it was
|
|
2106
2446
|
* registered under has changed -- otherwise it stays registered and
|
|
@@ -2145,17 +2485,22 @@ export class FoundryStepExecutor {
|
|
|
2145
2485
|
/**
|
|
2146
2486
|
* Parking mail (2026-09-28 lettermint-org-mail swap, "Core rule": "Parking
|
|
2147
2487
|
* mail deletes the org's projects and then the domain itself, so no
|
|
2148
|
-
* restriction points only at dead projects").
|
|
2149
|
-
*
|
|
2150
|
-
*
|
|
2488
|
+
* restriction points only at dead projects"). #149: first drops the
|
|
2489
|
+
* Stalwart relay route with `MailPort.removeOrgRelay` -- a missing route is
|
|
2490
|
+
* a no-op, per that port method's own contract, so this is safe to call
|
|
2491
|
+
* even for an org whose relay was never minted -- then deletes every
|
|
2492
|
+
* Lettermint project named `<slug>-app` or `<slug>-relay`, then the org's
|
|
2493
|
+
* own Lettermint domain, if one was found -- in that order, never the
|
|
2494
|
+
* reverse, so the route is always gone before the project it pointed at.
|
|
2151
2495
|
* A no-op until a previous apply recorded {@link MAIL_DOMAIN_SCOPE_KEY}:
|
|
2152
2496
|
* nothing was ever provisioned for an org whose mail went straight from
|
|
2153
2497
|
* never-applied to off.
|
|
2154
2498
|
*/
|
|
2155
|
-
async parkLettermintMail(org, context) {
|
|
2499
|
+
async parkLettermintMail(org, mailOrg, context) {
|
|
2156
2500
|
const mailDomain = context.recordedIds()[MAIL_DOMAIN_SCOPE_KEY];
|
|
2157
2501
|
if (!mailDomain)
|
|
2158
2502
|
return;
|
|
2503
|
+
await this.ports.mail.removeOrgRelay(mailOrg);
|
|
2159
2504
|
const names = new Set([`${org.slug}-app`, `${org.slug}-relay`]);
|
|
2160
2505
|
const projects = await this.ports.lettermint.listProjects();
|
|
2161
2506
|
for (const project of projects) {
|
|
@@ -2309,9 +2654,24 @@ export class FoundryStepExecutor {
|
|
|
2309
2654
|
const { org, recordedIds } = context;
|
|
2310
2655
|
try {
|
|
2311
2656
|
switch (`${target.system}:${target.kind}`) {
|
|
2312
|
-
case 'cloudflare:tunnel':
|
|
2657
|
+
case 'cloudflare:tunnel': {
|
|
2658
|
+
// #235: the connector app first, its own 404 handled locally --
|
|
2659
|
+
// a retry where this already succeeded must still reach
|
|
2660
|
+
// `deleteTunnel` below, which the outer catch's own 404 handling
|
|
2661
|
+
// would otherwise short-circuit before it ran.
|
|
2662
|
+
const connectorAppId = recordedIds[CLOUDFLARE_TUNNEL_CONNECTOR_APP_SCOPE_KEY];
|
|
2663
|
+
if (connectorAppId) {
|
|
2664
|
+
try {
|
|
2665
|
+
await this.ports.coolify.deleteApplication(connectorAppId);
|
|
2666
|
+
}
|
|
2667
|
+
catch (error) {
|
|
2668
|
+
if (!isNotFoundError(error))
|
|
2669
|
+
throw error;
|
|
2670
|
+
}
|
|
2671
|
+
}
|
|
2313
2672
|
await this.ports.cloudflare.deleteTunnel(target.id);
|
|
2314
2673
|
return;
|
|
2674
|
+
}
|
|
2315
2675
|
case 'cloudflare:access':
|
|
2316
2676
|
await this.ports.cloudflare.deleteAccessApp(target.id);
|
|
2317
2677
|
return;
|