@wtfalch/foundry 0.5.0 → 0.6.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 +16 -1
- package/dist/bootstrap/bootstrap.js +40 -2
- package/dist/bootstrap/declare.js +6 -1
- 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 +31 -8
- package/dist/calls-sfu.js +36 -5
- package/dist/index.d.ts +8 -2
- package/dist/index.js +5 -2
- 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/org.d.ts +81 -0
- package/dist/org.js +88 -0
- package/dist/plan/diff.js +93 -10
- package/dist/plan/executor.d.ts +236 -35
- package/dist/plan/executor.js +274 -76
- package/dist/plan/index.d.ts +7 -1
- package/dist/plan/index.js +4 -1
- 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 +5 -0
- package/dist/plan/platform-diff.d.ts +184 -9
- package/dist/plan/platform-diff.js +368 -15
- package/dist/plan/platform-org.d.ts +3 -2
- package/dist/plan/platform-providers.d.ts +138 -24
- package/dist/plan/platform-providers.js +1460 -79
- package/dist/plan/platform-sfu.d.ts +54 -19
- package/dist/plan/platform-sfu.js +64 -22
- 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/postgres.js +31 -2
- package/dist/store/schema.d.ts +72 -0
- package/dist/store/schema.js +13 -0
- package/dist/store.d.ts +7 -0
- package/package.json +1 -1
package/dist/plan/executor.js
CHANGED
|
@@ -3,6 +3,7 @@ import { URL } from 'node:url';
|
|
|
3
3
|
import { CALLS_SFU_PRIVATE_URL, CALLS_SFU_PUBLIC_URL } from '../calls-sfu.js';
|
|
4
4
|
import { AGORA_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 { MAIL_COPY_COMPLETED_SCOPE_KEY, MAIL_COPY_DONE_SCOPE_KEY, parseMailCopyCompleted, runMailCopyStep, } from './mail-copy.js';
|
|
6
7
|
import { ORG_MAIL_APPLIED_APEX_SCOPE_KEY, ORG_MAIL_APPLIED_HOST_SCOPE_KEY, } from './platform-diff.js';
|
|
7
8
|
// ---------------------------------------------------------------------------
|
|
8
9
|
// Errors
|
|
@@ -108,10 +109,11 @@ export function healthUrls(org) {
|
|
|
108
109
|
}
|
|
109
110
|
/**
|
|
110
111
|
* 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
|
|
112
|
+
* `mail.usesDomain` off has no domain of its own, so it is level 1 on the
|
|
113
|
+
* platform's own mail apex (`coolifyMail`'s `options.apex`) and its mode is
|
|
114
|
+
* forced to `'hosted'` — `@wtfalch/email`'s own `resolveDomain` refuses any
|
|
115
|
+
* other mode for a domain-less org, and a level-1 `{slug}.{apex}` domain
|
|
116
|
+
* always has Stalwart's MX (foundry#7 "Mail").
|
|
115
117
|
*/
|
|
116
118
|
function mailOrgFor(org, usesDomain, mode) {
|
|
117
119
|
return usesDomain && org.domain
|
|
@@ -226,17 +228,6 @@ const AGORA_APP_DOCKERFILE = 'apps/agora/Dockerfile';
|
|
|
226
228
|
* restated rather than shared across the two files.
|
|
227
229
|
*/
|
|
228
230
|
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
231
|
/**
|
|
241
232
|
* The recorded OIDC app's `clientId` (level-1 app-env campaign, gap P2) —
|
|
242
233
|
* not a `system:kind:key` resource id (that slot already holds the app's
|
|
@@ -312,6 +303,29 @@ const MAIL_DOMAIN_SCOPE_KEY = 'mail_domain';
|
|
|
312
303
|
* file's own `LOG_DIR` doc comment names for `diff.ts`.
|
|
313
304
|
*/
|
|
314
305
|
const MAIL_OWNER_MAILBOX_SCOPE_KEY = 'mail_owner_mailbox';
|
|
306
|
+
/**
|
|
307
|
+
* #149 review finding: `MailPort.hasOrgRelay` only confirms Stalwart's route
|
|
308
|
+
* holds *a* secret, not that it is the one {@link FoundryStepExecutor
|
|
309
|
+
* .ensureOrgLettermintRelay} itself wrote for the project Lettermint
|
|
310
|
+
* currently lists under `<slug>-relay` -- a route a stale run wrote for a
|
|
311
|
+
* project that has since been lost and reprovisioned under the same name
|
|
312
|
+
* would otherwise read as healthy. Recorded as `<project id>@<mail domain>`
|
|
313
|
+
* right after a successful `MailPort.applyOrgRelay`, a flat scope id (no
|
|
314
|
+
* `:`) the same way {@link MAIL_OWNER_MAILBOX_SCOPE_KEY} is. The domain half
|
|
315
|
+
* also catches a domain switch: the healthy branch never calls
|
|
316
|
+
* `applyOrgRelay` again, so without this the Stalwart route would stay
|
|
317
|
+
* restricted to the org's previous domain forever.
|
|
318
|
+
*/
|
|
319
|
+
const ORG_MAIL_RELAY_SCOPE_KEY = 'mail_relay';
|
|
320
|
+
/**
|
|
321
|
+
* #230: the role mailboxes {@link ensureRoleMailboxes} has created, as one
|
|
322
|
+
* flat scope id holding their full addresses (`local@domain`, lowercased, so a
|
|
323
|
+
* mail domain change owes the new domain its own set) joined by `,`
|
|
324
|
+
* (`validateMail` refuses a `,` in a name, so the join is unambiguous). Updated after each
|
|
325
|
+
* mailbox, so a crash part-way through never recreates the ones already
|
|
326
|
+
* made. `plan/diff.ts` keeps its own copy of this literal.
|
|
327
|
+
*/
|
|
328
|
+
const MAIL_ROLE_MAILBOXES_SCOPE_KEY = 'mail_role_mailboxes';
|
|
315
329
|
/**
|
|
316
330
|
* #80: the id of an ai key `aiKey` has minted but not yet delivered, a flat
|
|
317
331
|
* scope id (no `:`). `plan/diff.ts` keeps its own copy of this literal.
|
|
@@ -328,17 +342,6 @@ const AI_KEY_DELIVERY_ATTEMPTS = 3;
|
|
|
328
342
|
const JOBS_REGISTRATION_UNDELIVERED_SCOPE_KEY = 'jobs_registration_undelivered';
|
|
329
343
|
/** How many times `jobsRegistration` tries each `deliverSecret` before giving up. */
|
|
330
344
|
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
345
|
/**
|
|
343
346
|
* `coolifyCalls` provisions four resources from one `coolify:calls` step
|
|
344
347
|
* (#128) — the same one-step, several-resource shape `cmsSite` already solves
|
|
@@ -463,6 +466,8 @@ export class FoundryStepExecutor {
|
|
|
463
466
|
return this.aiKey(action, org, context);
|
|
464
467
|
case 'coolify:mail':
|
|
465
468
|
return this.coolifyMail(action, org, context);
|
|
469
|
+
case 'coolify:mail-copy':
|
|
470
|
+
return this.mailCopy(org, context);
|
|
466
471
|
case 'coolify:chat':
|
|
467
472
|
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
473
|
case 'cms:site':
|
|
@@ -970,9 +975,10 @@ export class FoundryStepExecutor {
|
|
|
970
975
|
* `zitadel:issuer` (`AUTH_ORGANIZATION_ID`), `zitadel:owner`
|
|
971
976
|
* (`OWNER_USER_ID`, gap P3) and every `zitadel:app` step
|
|
972
977
|
* (`AUTH_CLIENT_ID`), all ordered before `coolify:app` for exactly this
|
|
973
|
-
* reason. AUTH_APP_KEY (
|
|
974
|
-
*
|
|
975
|
-
*
|
|
978
|
+
* reason. `AUTH_APP_KEY` (#206 -- which broker an org's app registers with
|
|
979
|
+
* is its own design question, left out of #204's own "How" rather than
|
|
980
|
+
* guessed) and DATABASE_URL_OWNER (a runtime-role split not built yet,
|
|
981
|
+
* `pushDatabaseUrl`'s own doc comment) are out of scope here (task brief).
|
|
976
982
|
*/
|
|
977
983
|
async pushAppEnv(appId, org, context) {
|
|
978
984
|
const recorded = context.recordedIds();
|
|
@@ -1052,7 +1058,10 @@ export class FoundryStepExecutor {
|
|
|
1052
1058
|
* be deleted.
|
|
1053
1059
|
*
|
|
1054
1060
|
* `MAIL_FROM` is upserted every time: it depends only on the mail domain
|
|
1055
|
-
* `coolify:mail` resolved.
|
|
1061
|
+
* `coolify:mail` resolved and `org.mail.noReplyLocalPart` (#162, decided
|
|
1062
|
+
* 2026-09-29 -- defaults to `'no-reply'`, restated here rather than
|
|
1063
|
+
* imported from `org.ts`'s `DEFAULT_NO_REPLY_LOCAL_PART`, this file's own
|
|
1064
|
+
* `LOG_DIR`-style convention).
|
|
1056
1065
|
*
|
|
1057
1066
|
* The token goes straight to `bulkUpsertEnvs` and nowhere else: never
|
|
1058
1067
|
* returned, never recorded in `serviceIds` (`recordScopeId`'s own doc
|
|
@@ -1065,7 +1074,6 @@ export class FoundryStepExecutor {
|
|
|
1065
1074
|
if (!mailDomain)
|
|
1066
1075
|
return;
|
|
1067
1076
|
const appName = `${org.slug}-app`;
|
|
1068
|
-
const relayName = `${org.slug}-relay`;
|
|
1069
1077
|
const [projects, envs] = await Promise.all([
|
|
1070
1078
|
this.ports.lettermint.listProjects(),
|
|
1071
1079
|
this.ports.coolify.listEnvs(appId),
|
|
@@ -1086,18 +1094,133 @@ export class FoundryStepExecutor {
|
|
|
1086
1094
|
allProjects = [...projects, created.project];
|
|
1087
1095
|
envUpdates.push({ key: 'LETTERMINT_API_TOKEN', value: created.token });
|
|
1088
1096
|
}
|
|
1089
|
-
envUpdates.push({
|
|
1097
|
+
envUpdates.push({
|
|
1098
|
+
key: 'MAIL_FROM',
|
|
1099
|
+
value: `${org.mail.noReplyLocalPart ?? 'no-reply'}@${mailDomain}`,
|
|
1100
|
+
});
|
|
1090
1101
|
await this.ports.coolify.bulkUpsertEnvs(appId, envUpdates);
|
|
1091
1102
|
// A running container read its env at start and still holds the old
|
|
1092
1103
|
// token, so a fresh one only reaches it through a restart -- before the
|
|
1093
1104
|
// old project is deleted below. A just-created app starts with the new env.
|
|
1094
1105
|
if (!healthy && running)
|
|
1095
1106
|
await this.ports.coolify.restart(appId);
|
|
1107
|
+
await this.ensureLettermintDomainRestriction(org, mailDomain, allProjects);
|
|
1108
|
+
for (const project of allProjects) {
|
|
1109
|
+
if (project.name === appName && project.id !== keepProjectId) {
|
|
1110
|
+
await this.ports.lettermint.deleteProject(project.id);
|
|
1111
|
+
}
|
|
1112
|
+
}
|
|
1113
|
+
}
|
|
1114
|
+
/**
|
|
1115
|
+
* #149: mints this org's own Lettermint relay credential and writes it to
|
|
1116
|
+
* Stalwart via `MailPort.applyOrgRelay`, the same mint rule
|
|
1117
|
+
* {@link ensureAppLettermintMail} already uses for the org's app token --
|
|
1118
|
+
* "healthy" means Stalwart's own route exists ({@link MailPort.hasOrgRelay}),
|
|
1119
|
+
* AND Lettermint lists a project named `<slug>-relay` whose id
|
|
1120
|
+
* {@link ORG_MAIL_RELAY_SCOPE_KEY} records for this
|
|
1121
|
+
* exact mail domain (review finding: a route with a secret and a
|
|
1122
|
+
* same-named project both present is not enough -- the project could be a
|
|
1123
|
+
* lost-and-reprovisioned one this step never wrote a route for, and a mail
|
|
1124
|
+
* domain switch must re-point the route rather than read as healthy
|
|
1125
|
+
* forever because the old route still has a secret in it). A route with no
|
|
1126
|
+
* live project behind it, a project the route's own secret never reached,
|
|
1127
|
+
* or a recorded project id/domain that no longer matches, are all exactly
|
|
1128
|
+
* as unhealthy as neither existing.
|
|
1129
|
+
*
|
|
1130
|
+
* A fresh project is created and its token written to Stalwart first, and
|
|
1131
|
+
* only then is every other `<slug>-relay` project deleted -- a lost route
|
|
1132
|
+
* and a duplicate project both end the same way. The new project's domain
|
|
1133
|
+
* restriction runs before the token is written, so a failure there never
|
|
1134
|
+
* touches the working route. Review finding: if either the
|
|
1135
|
+
* domain-restriction step or `applyOrgRelay` throws after a
|
|
1136
|
+
* fresh project was minted, that project is deleted before the error
|
|
1137
|
+
* propagates -- otherwise every retry sees more than one `<slug>-relay`
|
|
1138
|
+
* project and mints yet another one, growing the orphan count without
|
|
1139
|
+
* bound.
|
|
1140
|
+
*
|
|
1141
|
+
* `coolify:mail` runs before `coolify:app` (`diff.ts`'s own step order), so
|
|
1142
|
+
* this is very often the first of the two to reach the org's Lettermint
|
|
1143
|
+
* domain: {@link ensureLettermintDomainRestriction} registers and restricts
|
|
1144
|
+
* it the same way the app step does, rather than assuming it already
|
|
1145
|
+
* exists -- the domain must never end up unrestricted, whichever step gets
|
|
1146
|
+
* there first.
|
|
1147
|
+
*/
|
|
1148
|
+
async ensureOrgLettermintRelay(org, mailOrg, mailDomain, context) {
|
|
1149
|
+
const relayName = `${org.slug}-relay`;
|
|
1150
|
+
const [projects, hasRelay] = await Promise.all([
|
|
1151
|
+
this.ports.lettermint.listProjects(),
|
|
1152
|
+
this.ports.mail.hasOrgRelay(mailOrg),
|
|
1153
|
+
]);
|
|
1154
|
+
// The recorded project, not "the only one": a leftover duplicate whose
|
|
1155
|
+
// delete keeps failing must not force a fresh mint on every apply.
|
|
1156
|
+
const recordedRelay = context.recordedIds()[ORG_MAIL_RELAY_SCOPE_KEY];
|
|
1157
|
+
const held = projects.find((p) => p.name === relayName && `${p.id}@${mailDomain}` === recordedRelay);
|
|
1158
|
+
const healthy = held !== undefined && hasRelay;
|
|
1159
|
+
let allProjects = projects;
|
|
1160
|
+
let keepProjectId;
|
|
1161
|
+
if (healthy && held) {
|
|
1162
|
+
keepProjectId = held.id;
|
|
1163
|
+
await this.ensureLettermintDomainRestriction(org, mailDomain, allProjects);
|
|
1164
|
+
}
|
|
1165
|
+
else {
|
|
1166
|
+
const created = await this.ports.lettermint.createProject(relayName);
|
|
1167
|
+
keepProjectId = created.project.id;
|
|
1168
|
+
allProjects = [...projects, created.project];
|
|
1169
|
+
try {
|
|
1170
|
+
// Everything that can fail for the new project runs before the token
|
|
1171
|
+
// reaches Stalwart: a failure here leaves the working route alone.
|
|
1172
|
+
// The restriction covers old and new projects, so the old one keeps
|
|
1173
|
+
// sending until the route switches.
|
|
1174
|
+
await this.ensureLettermintDomainRestriction(org, mailDomain, allProjects);
|
|
1175
|
+
await this.ports.mail.applyOrgRelay(mailOrg, created.token);
|
|
1176
|
+
}
|
|
1177
|
+
catch (err) {
|
|
1178
|
+
await this.discardLettermintProject(created.project.id);
|
|
1179
|
+
throw err;
|
|
1180
|
+
}
|
|
1181
|
+
context.recordScopeId(ORG_MAIL_RELAY_SCOPE_KEY, `${keepProjectId}@${mailDomain}`);
|
|
1182
|
+
// Written now: a crash before the end-of-run write would leave the old
|
|
1183
|
+
// project recorded, and the next apply would sweep the one the route uses.
|
|
1184
|
+
await context.persistScopeIds();
|
|
1185
|
+
}
|
|
1186
|
+
for (const project of allProjects) {
|
|
1187
|
+
if (project.name === relayName && project.id !== keepProjectId) {
|
|
1188
|
+
await this.ports.lettermint.deleteProject(project.id);
|
|
1189
|
+
}
|
|
1190
|
+
}
|
|
1191
|
+
}
|
|
1192
|
+
/**
|
|
1193
|
+
* Deletes a just-minted project from inside a `catch`. A failed delete is
|
|
1194
|
+
* dropped so it never replaces the error being rethrown; the orphan is
|
|
1195
|
+
* swept by the next apply's duplicate delete.
|
|
1196
|
+
*/
|
|
1197
|
+
async discardLettermintProject(id) {
|
|
1198
|
+
try {
|
|
1199
|
+
await this.ports.lettermint.deleteProject(id);
|
|
1200
|
+
}
|
|
1201
|
+
catch {
|
|
1202
|
+
// Dropped: the caller rethrows the original error.
|
|
1203
|
+
}
|
|
1204
|
+
}
|
|
1205
|
+
/**
|
|
1206
|
+
* Ensures this org's Lettermint domain exists, is restricted to exactly
|
|
1207
|
+
* the `<slug>-app`/`<slug>-relay` projects live in `allProjects`, has its
|
|
1208
|
+
* DNS published and a verification triggered. Shared by
|
|
1209
|
+
* {@link ensureAppLettermintMail} and {@link ensureOrgLettermintRelay}
|
|
1210
|
+
* (#149) -- whichever of the two steps runs first on a fresh org creates
|
|
1211
|
+
* the domain, and the other then finds it (`findExactLettermintDomain`).
|
|
1212
|
+
*
|
|
1213
|
+
* A crash between the restriction write and the DNS publish/verify below
|
|
1214
|
+
* is safe even for a freshly created domain: DNS is only published, and
|
|
1215
|
+
* verification only triggered, after the restriction -- so an
|
|
1216
|
+
* unrestricted domain cannot verify and cannot send, whichever project
|
|
1217
|
+
* owns it (post-#140 review finding, restated here rather than only at
|
|
1218
|
+
* the one call site it used to have).
|
|
1219
|
+
*/
|
|
1220
|
+
async ensureLettermintDomainRestriction(org, mailDomain, allProjects) {
|
|
1221
|
+
const appName = `${org.slug}-app`;
|
|
1222
|
+
const relayName = `${org.slug}-relay`;
|
|
1096
1223
|
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
1224
|
const domain = existing
|
|
1102
1225
|
? await this.ports.lettermint.getDomain(existing.id)
|
|
1103
1226
|
: await this.ports.lettermint.createDomain(mailDomain);
|
|
@@ -1107,15 +1230,10 @@ export class FoundryStepExecutor {
|
|
|
1107
1230
|
await this.ports.lettermint.restrictDomainToProjects(domain.id, scopedProjectIds);
|
|
1108
1231
|
const zone = await this.ports.cloudflare.findZoneByApex(mailDomain);
|
|
1109
1232
|
if (!zone) {
|
|
1110
|
-
throw new Error(`
|
|
1233
|
+
throw new Error(`Lettermint for ${org.slug}: no Cloudflare zone found for ${mailDomain}`);
|
|
1111
1234
|
}
|
|
1112
1235
|
await this.publishLettermintDnsRecords(zone.id, domain.dnsRecords);
|
|
1113
1236
|
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
1237
|
}
|
|
1120
1238
|
/**
|
|
1121
1239
|
* Review finding (post-#140 rework): {@link LettermintPort.findDomain}'s
|
|
@@ -1713,9 +1831,9 @@ export class FoundryStepExecutor {
|
|
|
1713
1831
|
if (!masCredentials) {
|
|
1714
1832
|
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
1833
|
}
|
|
1716
|
-
const sfu = await this.ports.callsSfu.resolveCredentials();
|
|
1834
|
+
const sfu = await this.ports.callsSfu.resolveCredentials(org.slug);
|
|
1717
1835
|
if (!sfu) {
|
|
1718
|
-
throw new Error(`coolify:calls-service for ${org.slug}: the shared calls SFU has no LiveKit credentials
|
|
1836
|
+
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
1837
|
}
|
|
1720
1838
|
const projectUuid = await this.ensureCoolifyProject(org, context);
|
|
1721
1839
|
const { environment } = await this.ports.coolify.findOrCreateEnvironment(projectUuid, 'production');
|
|
@@ -2031,20 +2149,21 @@ export class FoundryStepExecutor {
|
|
|
2031
2149
|
* `@wtfalch/email/postmaster`'s `applyOrgMail`/`parkOrgMail` bound), then
|
|
2032
2150
|
* publishes whatever Stalwart DNS records the call returns, and records the
|
|
2033
2151
|
* 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
|
|
2152
|
+
*
|
|
2153
|
+
* #149: this step also mints the org's own Lettermint *relay* credential
|
|
2154
|
+
* ({@link ensureOrgLettermintRelay}) -- the app's own *sending* credential
|
|
2155
|
+
* stays in the app step (`ensureAppLettermintMail`, "Core rule"), since the
|
|
2156
|
+
* first app project can only exist once the token has a home. Parking mail
|
|
2157
|
+
* (mode `'off'`) reaches Lettermint too: {@link parkLettermintMail} drops
|
|
2158
|
+
* the relay route, then deletes the org's own projects and its domain, so
|
|
2159
|
+
* a domain never ends up restricted only to dead projects and no route
|
|
2160
|
+
* ever points at a project this apply is about to remove.
|
|
2042
2161
|
*
|
|
2043
2162
|
* Review finding: a mail domain switch (`org.domain` changes, or
|
|
2044
2163
|
* `usesDomain` flips) must also delete the *old* Lettermint domain here,
|
|
2045
2164
|
* 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}
|
|
2165
|
+
* restricted to the org's still-live `<slug>-app`/`<slug>-relay` projects,
|
|
2166
|
+
* which could then keep sending as it forever. {@link deleteStaleLettermintDomain}
|
|
2048
2167
|
* does this by comparing the previously recorded domain (case-insensitive)
|
|
2049
2168
|
* against the one this apply just resolved.
|
|
2050
2169
|
*
|
|
@@ -2058,15 +2177,22 @@ export class FoundryStepExecutor {
|
|
|
2058
2177
|
// #177: `diff.ts`'s `planMail` carries the platform's own apex/mail host
|
|
2059
2178
|
// in `desired` once it is wired -- "the executor writes exactly these
|
|
2060
2179
|
// host values, so what the plan showed is what gets applied" (`planAgora`'s
|
|
2061
|
-
// own rule)
|
|
2062
|
-
//
|
|
2063
|
-
|
|
2064
|
-
|
|
2065
|
-
|
|
2066
|
-
|
|
2180
|
+
// own rule) -- and blocks the step (never plans it) while the platform's
|
|
2181
|
+
// mail is off or not yet applied. So a `desired` missing either value
|
|
2182
|
+
// here is not a live gap to fall back on (#205): it is a step planned
|
|
2183
|
+
// before that rule existed, now stale. Fail rather than silently point an
|
|
2184
|
+
// org's mail at a fixed address nothing may still be listening on; the
|
|
2185
|
+
// fix is to plan this org's mail again.
|
|
2186
|
+
const apex = action.desired.apex;
|
|
2187
|
+
const mailHost = action.desired.mailHost;
|
|
2188
|
+
if (!apex || !mailHost) {
|
|
2189
|
+
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`);
|
|
2190
|
+
}
|
|
2191
|
+
const options = { apex, mailHost };
|
|
2067
2192
|
if (mode === 'off') {
|
|
2068
|
-
|
|
2069
|
-
await this.
|
|
2193
|
+
const parkedMailOrg = mailOrgFor(org, usesDomain, 'hosted');
|
|
2194
|
+
await this.ports.mail.parkOrgMail(parkedMailOrg, options);
|
|
2195
|
+
await this.parkLettermintMail(org, parkedMailOrg, context);
|
|
2070
2196
|
return undefined;
|
|
2071
2197
|
}
|
|
2072
2198
|
const mailOrg = mailOrgFor(org, usesDomain, mode);
|
|
@@ -2077,30 +2203,97 @@ export class FoundryStepExecutor {
|
|
|
2077
2203
|
// #177: what this step wrote from the platform, so `planMail` can see a later change.
|
|
2078
2204
|
context.recordScopeId(ORG_MAIL_APPLIED_APEX_SCOPE_KEY, options.apex);
|
|
2079
2205
|
context.recordScopeId(ORG_MAIL_APPLIED_HOST_SCOPE_KEY, options.mailHost);
|
|
2080
|
-
await this.ensureOwnerMailbox(org, mailOrg,
|
|
2206
|
+
await this.ensureOwnerMailbox(org, mailOrg, context);
|
|
2207
|
+
await this.ensureOrgLettermintRelay(org, mailOrg, result.domain, context);
|
|
2208
|
+
await this.ensureRoleMailboxes(org, mailOrg, result.domain, context);
|
|
2081
2209
|
return { resourceId: result.domain };
|
|
2082
2210
|
}
|
|
2083
2211
|
/**
|
|
2084
|
-
* #
|
|
2085
|
-
*
|
|
2086
|
-
*
|
|
2087
|
-
*
|
|
2088
|
-
*
|
|
2089
|
-
* `
|
|
2090
|
-
*
|
|
2212
|
+
* `plan/diff.ts`'s `planMailCopy` (#113): this apply's share of copying
|
|
2213
|
+
* the org's old mail into Stalwart, via `mail-copy.ts`'s `runMailCopyStep`
|
|
2214
|
+
* -- the only thing this method itself does is read back what the last
|
|
2215
|
+
* apply recorded, hand it to that function, and record what comes back.
|
|
2216
|
+
* An account that failed to copy records the progress made, never
|
|
2217
|
+
* `mail_copy_done`, then throws with the failing account, phase and
|
|
2218
|
+
* redacted stderr tail, so the apply reports it as not done. A missing
|
|
2219
|
+
* `mailCopy` port throws too: a configuration gap a retry cannot fix.
|
|
2091
2220
|
*/
|
|
2092
|
-
async
|
|
2221
|
+
async mailCopy(org, context) {
|
|
2222
|
+
if (!this.ports.mailCopy) {
|
|
2223
|
+
throw new NotBuiltError(`coolify:mail-copy for ${org.slug}`, 'construct FoundryStepExecutor with `mailCopy` (#113)');
|
|
2224
|
+
}
|
|
2225
|
+
const alreadyCompleted = new Set(parseMailCopyCompleted(context.recordedIds()[MAIL_COPY_COMPLETED_SCOPE_KEY]));
|
|
2226
|
+
const result = await runMailCopyStep(org, this.ports.mailCopy, alreadyCompleted);
|
|
2227
|
+
context.recordScopeId(MAIL_COPY_COMPLETED_SCOPE_KEY, JSON.stringify(result.completed));
|
|
2228
|
+
if (result.done)
|
|
2229
|
+
context.recordScopeId(MAIL_COPY_DONE_SCOPE_KEY, '1');
|
|
2230
|
+
if (result.failure) {
|
|
2231
|
+
const { account, phase, exitCode, detail } = result.failure;
|
|
2232
|
+
// Written now: the throw below ends the step before the end-of-run write.
|
|
2233
|
+
await context.persistScopeIds();
|
|
2234
|
+
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)`);
|
|
2235
|
+
}
|
|
2236
|
+
return undefined;
|
|
2237
|
+
}
|
|
2238
|
+
/**
|
|
2239
|
+
* #162 (decided 2026-09-29): gives the org's declared owner a mailbox at
|
|
2240
|
+
* its declared `mail.ownerMailboxAddress` -- nothing is guessed any more
|
|
2241
|
+
* (the hardcoded `owner@<domain>` PR 163 shipped first is gone). A no-op
|
|
2242
|
+
* when the org has no owner yet, when no address is declared yet
|
|
2243
|
+
* (`plan/diff.ts`'s `planMail` reports that case as blocked, not planned,
|
|
2244
|
+
* so this step's own mode/domain work still runs), or once
|
|
2245
|
+
* {@link MAIL_OWNER_MAILBOX_SCOPE_KEY} is already recorded -- checked last
|
|
2246
|
+
* so a second apply never calls `MailPort.createMailbox` again, rather
|
|
2247
|
+
* than relying on that port method's own duplicate refusal.
|
|
2248
|
+
*/
|
|
2249
|
+
async ensureOwnerMailbox(org, mailOrg, context) {
|
|
2093
2250
|
if (!org.owner)
|
|
2094
2251
|
return;
|
|
2252
|
+
const address = org.mail.ownerMailboxAddress ?? null;
|
|
2253
|
+
if (!address)
|
|
2254
|
+
return;
|
|
2095
2255
|
if (context.recordedIds()[MAIL_OWNER_MAILBOX_SCOPE_KEY])
|
|
2096
2256
|
return;
|
|
2097
|
-
const address = `${MAIL_OWNER_LOCAL_PART}@${domain}`;
|
|
2098
2257
|
await this.ports.mail.createMailbox(mailOrg, {
|
|
2099
2258
|
address,
|
|
2100
2259
|
name: `${org.owner.givenName} ${org.owner.familyName}`,
|
|
2101
2260
|
});
|
|
2102
2261
|
context.recordScopeId(MAIL_OWNER_MAILBOX_SCOPE_KEY, address);
|
|
2103
2262
|
}
|
|
2263
|
+
/**
|
|
2264
|
+
* #230: creates each of the org's declared `mail.roleMailboxes` on its
|
|
2265
|
+
* just-resolved mail domain, once. A role mailbox has no person behind it,
|
|
2266
|
+
* so its display name is just its local part, and like the owner's it is
|
|
2267
|
+
* created with no password: `MailPort.createMailbox` takes none, so there
|
|
2268
|
+
* 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
|
|
2269
|
+
* {@link MAIL_ROLE_MAILBOXES_SCOPE_KEY} after every mailbox, so a second
|
|
2270
|
+
* apply (or a retry after a crash) skips what exists rather than relying
|
|
2271
|
+
* on the mail server's own duplicate refusal.
|
|
2272
|
+
*/
|
|
2273
|
+
async ensureRoleMailboxes(org, mailOrg, domain, context) {
|
|
2274
|
+
const declared = org.mail.roleMailboxes ?? [];
|
|
2275
|
+
if (declared.length === 0)
|
|
2276
|
+
return;
|
|
2277
|
+
const recorded = context.recordedIds()[MAIL_ROLE_MAILBOXES_SCOPE_KEY];
|
|
2278
|
+
const created = new Set(recorded ? recorded.split(',') : []);
|
|
2279
|
+
for (const local of declared) {
|
|
2280
|
+
const address = `${local}@${domain}`;
|
|
2281
|
+
const entry = address.toLowerCase();
|
|
2282
|
+
if (created.has(entry))
|
|
2283
|
+
continue;
|
|
2284
|
+
try {
|
|
2285
|
+
await this.ports.mail.createMailbox(mailOrg, { address, name: local });
|
|
2286
|
+
}
|
|
2287
|
+
catch (error) {
|
|
2288
|
+
// A crash between create and record leaves the mailbox made but
|
|
2289
|
+
// unrecorded; the port exposes no existence check, only the refusal.
|
|
2290
|
+
if (!/already has a mailbox/i.test(error instanceof Error ? error.message : String(error)))
|
|
2291
|
+
throw error;
|
|
2292
|
+
}
|
|
2293
|
+
created.add(entry);
|
|
2294
|
+
context.recordScopeId(MAIL_ROLE_MAILBOXES_SCOPE_KEY, [...created].join(','));
|
|
2295
|
+
}
|
|
2296
|
+
}
|
|
2104
2297
|
/**
|
|
2105
2298
|
* Deletes the org's previous Lettermint domain once the mail domain it was
|
|
2106
2299
|
* registered under has changed -- otherwise it stays registered and
|
|
@@ -2145,17 +2338,22 @@ export class FoundryStepExecutor {
|
|
|
2145
2338
|
/**
|
|
2146
2339
|
* Parking mail (2026-09-28 lettermint-org-mail swap, "Core rule": "Parking
|
|
2147
2340
|
* mail deletes the org's projects and then the domain itself, so no
|
|
2148
|
-
* restriction points only at dead projects").
|
|
2149
|
-
*
|
|
2150
|
-
*
|
|
2341
|
+
* restriction points only at dead projects"). #149: first drops the
|
|
2342
|
+
* Stalwart relay route with `MailPort.removeOrgRelay` -- a missing route is
|
|
2343
|
+
* a no-op, per that port method's own contract, so this is safe to call
|
|
2344
|
+
* even for an org whose relay was never minted -- then deletes every
|
|
2345
|
+
* Lettermint project named `<slug>-app` or `<slug>-relay`, then the org's
|
|
2346
|
+
* own Lettermint domain, if one was found -- in that order, never the
|
|
2347
|
+
* reverse, so the route is always gone before the project it pointed at.
|
|
2151
2348
|
* A no-op until a previous apply recorded {@link MAIL_DOMAIN_SCOPE_KEY}:
|
|
2152
2349
|
* nothing was ever provisioned for an org whose mail went straight from
|
|
2153
2350
|
* never-applied to off.
|
|
2154
2351
|
*/
|
|
2155
|
-
async parkLettermintMail(org, context) {
|
|
2352
|
+
async parkLettermintMail(org, mailOrg, context) {
|
|
2156
2353
|
const mailDomain = context.recordedIds()[MAIL_DOMAIN_SCOPE_KEY];
|
|
2157
2354
|
if (!mailDomain)
|
|
2158
2355
|
return;
|
|
2356
|
+
await this.ports.mail.removeOrgRelay(mailOrg);
|
|
2159
2357
|
const names = new Set([`${org.slug}-app`, `${org.slug}-relay`]);
|
|
2160
2358
|
const projects = await this.ports.lettermint.listProjects();
|
|
2161
2359
|
for (const project of projects) {
|
package/dist/plan/index.d.ts
CHANGED
|
@@ -14,14 +14,20 @@ export { FoundryPlatformStepExecutor, ProvidersPlatformCloudflareReader, Provide
|
|
|
14
14
|
export type { PlatformAuthDeps, PlatformAuthRuntime, PlatformFilesDeps, PlatformFilesRuntime, } from './platform-providers.js';
|
|
15
15
|
export type { PlatformMailDeps, PlatformMailAdminPort, PlatformMailRuntime, } from './platform-providers.js';
|
|
16
16
|
export { FILES_INTROSPECTION_SECRET_REF, FILES_MANAGEMENT_ISSUER, FILES_MANAGEMENT_KEY_ID, FILES_MANAGEMENT_KEY_REF, } from './platform-providers.js';
|
|
17
|
-
export { PLATFORM_AI_MANAGEMENT_KEY_REF, PLATFORM_AI_MANAGEMENT_PERMISSIONS,
|
|
17
|
+
export { AI_MANAGEMENT_ISSUER, AI_MANAGEMENT_KEY_ID, AI_SIGNING_KEY_REF, AiManagementKeyExistsError, createAiManagementKeyMinter, PLATFORM_AI_MANAGEMENT_KEY_REF, PLATFORM_AI_MANAGEMENT_PERMISSIONS, } from './platform-providers.js';
|
|
18
18
|
export type { AiManagementKeyMinter, PlatformAiDeps, PlatformAiRuntime, } from './platform-providers.js';
|
|
19
19
|
export { createZitadelAdminMinter } from './zitadel-machine-user.js';
|
|
20
20
|
export type { CallsSfuConnection, CallsSfuConnectionPort, CallsSfuKeysPort, CallsSfuSshConnector, CallsSfuPlatformDeps, } from './platform-sfu.js';
|
|
21
21
|
export { MissingCallsSfuConnectionError, applyCallsSfuPlatformStep } from './platform-sfu.js';
|
|
22
|
+
export type { MailCopyJob, MailCopyPort, MailCopyStepResult } from './mail-copy.js';
|
|
23
|
+
export { MAIL_COPY_COMPLETED_SCOPE_KEY, MAIL_COPY_DONE_SCOPE_KEY, MissingMailCopyAccountsError, MissingMailCopySourceTokenError, MissingMailCopyStalwartTokenError, parseMailCopyCompleted, runMailCopyStep, } from './mail-copy.js';
|
|
22
24
|
export type { ScopeResolver, RecordedIds } from './providers-readers.js';
|
|
23
25
|
export { ProvidersCloudflareReader, ProvidersCmsReader, ProvidersCoolifyReader, ProvidersZitadelReader, storeScopeResolver, storeRecordedIds, } from './providers-readers.js';
|
|
24
26
|
export type { AgoraHostPort, AgoraHostValues } from './agora.js';
|
|
27
|
+
export type { MailIdentityApp, MailIdentityAudienceCheck } from './mail-identity.js';
|
|
28
|
+
export { checkMailIdentityAudience } from './mail-identity.js';
|
|
29
|
+
export type { MailAllowlistApp } from './mail-identity-allowlist.js';
|
|
30
|
+
export { checkMailAppsAllowlist } from './mail-identity-allowlist.js';
|
|
25
31
|
export type { CoolifyPort, CloudflarePort, ZitadelPort, CallsSfuPort, JobsSchedulerPort, FilesOrganisationPort, AiOrganisationPort, FoundryStepExecutorPorts, PlatformOrgPortSource, } from './executor.js';
|
|
26
32
|
export { FoundryStepExecutor, NotBuiltError, healthUrls } from './executor.js';
|
|
27
33
|
export { HttpJobsSchedulerClient } from './jobs-scheduler-http.js';
|
package/dist/plan/index.js
CHANGED
|
@@ -7,9 +7,12 @@ export { FoundryPlanApply, DeclaredOrgNotFoundError, StalePlanError, Confirmatio
|
|
|
7
7
|
export { FoundryPlatformStepExecutor, ProvidersPlatformCloudflareReader, ProvidersPlatformCoolifyReader, PLATFORM_MAIL_ADMIN_KEY_DESCRIPTION, } from './platform-providers.js';
|
|
8
8
|
// #171: how a host signs files management requests with the delivered key.
|
|
9
9
|
export { FILES_INTROSPECTION_SECRET_REF, FILES_MANAGEMENT_ISSUER, FILES_MANAGEMENT_KEY_ID, FILES_MANAGEMENT_KEY_REF, } from './platform-providers.js';
|
|
10
|
-
export { PLATFORM_AI_MANAGEMENT_KEY_REF, PLATFORM_AI_MANAGEMENT_PERMISSIONS,
|
|
10
|
+
export { AI_MANAGEMENT_ISSUER, AI_MANAGEMENT_KEY_ID, AI_SIGNING_KEY_REF, AiManagementKeyExistsError, createAiManagementKeyMinter, PLATFORM_AI_MANAGEMENT_KEY_REF, PLATFORM_AI_MANAGEMENT_PERMISSIONS, } from './platform-providers.js';
|
|
11
11
|
export { createZitadelAdminMinter } from './zitadel-machine-user.js';
|
|
12
12
|
export { MissingCallsSfuConnectionError, applyCallsSfuPlatformStep } from './platform-sfu.js';
|
|
13
|
+
export { MAIL_COPY_COMPLETED_SCOPE_KEY, MAIL_COPY_DONE_SCOPE_KEY, MissingMailCopyAccountsError, MissingMailCopySourceTokenError, MissingMailCopyStalwartTokenError, parseMailCopyCompleted, runMailCopyStep, } from './mail-copy.js';
|
|
13
14
|
export { ProvidersCloudflareReader, ProvidersCmsReader, ProvidersCoolifyReader, ProvidersZitadelReader, storeScopeResolver, storeRecordedIds, } from './providers-readers.js';
|
|
15
|
+
export { checkMailIdentityAudience } from './mail-identity.js';
|
|
16
|
+
export { checkMailAppsAllowlist } from './mail-identity-allowlist.js';
|
|
14
17
|
export { FoundryStepExecutor, NotBuiltError, healthUrls } from './executor.js';
|
|
15
18
|
export { HttpJobsSchedulerClient } from './jobs-scheduler-http.js';
|