@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/diff.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { NO_AGORA_HOST_VALUES, agoraEnvDrift, agoraHostMissing, desiredAgoraEnv, } from './agora.js';
|
|
2
|
+
import { MAIL_COPY_COMPLETED_SCOPE_KEY, MAIL_COPY_DONE_SCOPE_KEY, parseMailCopyCompleted, } from './mail-copy.js';
|
|
2
3
|
import { ORG_MAIL_APPLIED_APEX_SCOPE_KEY, ORG_MAIL_APPLIED_HOST_SCOPE_KEY, } from './platform-diff.js';
|
|
3
4
|
import { DEFAULT_ORG_PLATFORM_READINESS } from './platform-org.js';
|
|
4
5
|
/** The `Plan` (#19's public contract) a `PlanDetail` carries. */
|
|
@@ -73,6 +74,9 @@ export function computePlanDetail(org, live, recordedIds = {}, agoraHost = NO_AG
|
|
|
73
74
|
const mail = planMail(org, byKey, claimed, recordedIds, platformReadiness.mailHost);
|
|
74
75
|
if (mail.step)
|
|
75
76
|
plannedSteps.push(mail.step);
|
|
77
|
+
const mailCopy = planMailCopy(org, byKey, recordedIds);
|
|
78
|
+
if (mailCopy)
|
|
79
|
+
plannedSteps.push(mailCopy);
|
|
76
80
|
const chat = planChat(org, byKey, claimed);
|
|
77
81
|
if (chat)
|
|
78
82
|
plannedSteps.push(chat);
|
|
@@ -197,6 +201,11 @@ export function computePlanDetail(org, live, recordedIds = {}, agoraHost = NO_AG
|
|
|
197
201
|
unmanaged.push({ description: issuer.blocked, key: 'zitadel:issuer' });
|
|
198
202
|
if (mail.blocked)
|
|
199
203
|
unmanaged.push({ description: mail.blocked, key: 'coolify:mail' });
|
|
204
|
+
// #162: reported separately from `mail.blocked` -- missing an owner
|
|
205
|
+
// mailbox address never blocks the rest of this org's mail.
|
|
206
|
+
if (mail.mailboxBlocked) {
|
|
207
|
+
unmanaged.push({ description: mail.mailboxBlocked, key: 'coolify:mail:owner-mailbox' });
|
|
208
|
+
}
|
|
200
209
|
if (calls.blocked)
|
|
201
210
|
unmanaged.push({ description: calls.blocked, key: 'coolify:calls' });
|
|
202
211
|
if (filesOrganisation.blocked) {
|
|
@@ -353,6 +362,10 @@ function planOwner(org, recordedIds, authReady) {
|
|
|
353
362
|
* `LOG_DIR` precedent follows for a value shared with `executor.ts`.
|
|
354
363
|
*/
|
|
355
364
|
const MAIL_OWNER_MAILBOX_SCOPE_KEY = 'mail_owner_mailbox';
|
|
365
|
+
/** #230: restated from `executor.ts`'s `MAIL_ROLE_MAILBOXES_SCOPE_KEY`, same rule: comma-joined local parts already created. */
|
|
366
|
+
const MAIL_ROLE_MAILBOXES_SCOPE_KEY = 'mail_role_mailboxes';
|
|
367
|
+
/** Restated from `executor.ts`, the same rule: the mail domain the last apply resolved. */
|
|
368
|
+
const MAIL_DOMAIN_SCOPE_KEY = 'mail_domain';
|
|
356
369
|
/** Restated from `executor.ts`, the same rule: the id of an ai key minted but not delivered. */
|
|
357
370
|
export const AI_KEY_UNDELIVERED_SCOPE_KEY = 'ai_key_undelivered';
|
|
358
371
|
/** Restated from `executor.ts`, the same rule: the id of a jobs project registered but not delivered. */
|
|
@@ -376,13 +389,14 @@ function planMail(org, byKey, claimed, recordedIds, mailHost) {
|
|
|
376
389
|
// after having it would silently hide its own leftover mail resource from
|
|
377
390
|
// the unmanaged list.
|
|
378
391
|
if (org.mail.mode === 'off')
|
|
379
|
-
return { step: null, blocked: null };
|
|
392
|
+
return { step: null, blocked: null, mailboxBlocked: null };
|
|
380
393
|
if (live)
|
|
381
394
|
claimed.add(key);
|
|
382
395
|
if (!mailHost) {
|
|
383
396
|
return {
|
|
384
397
|
step: null,
|
|
385
398
|
blocked: `${org.slug}'s mail is blocked, not planned: the platform's mail server is off or not yet applied`,
|
|
399
|
+
mailboxBlocked: null,
|
|
386
400
|
};
|
|
387
401
|
}
|
|
388
402
|
const desired = {
|
|
@@ -391,12 +405,33 @@ function planMail(org, byKey, claimed, recordedIds, mailHost) {
|
|
|
391
405
|
apex: mailHost.apex,
|
|
392
406
|
mailHost: mailHost.mailHost,
|
|
393
407
|
};
|
|
394
|
-
// #162: an owner's mailbox is minted inside the same
|
|
395
|
-
// after `applyOrgMail` -- so as long as one is still
|
|
396
|
-
// keep showing up even once mode/usesDomain
|
|
397
|
-
// to change, or the operator would apply and
|
|
398
|
-
|
|
399
|
-
|
|
408
|
+
// #162 (decided 2026-09-29): an owner's mailbox is minted inside the same
|
|
409
|
+
// `coolify:mail` step, after `applyOrgMail` -- so as long as one is still
|
|
410
|
+
// owed, this step must keep showing up even once mode/usesDomain
|
|
411
|
+
// themselves have nothing left to change, or the operator would apply and
|
|
412
|
+
// never see it happen. Nothing is guessed any more: without a declared
|
|
413
|
+
// `mail.ownerMailboxAddress`, the mailbox alone is reported blocked below
|
|
414
|
+
// (`mailboxBlocked`), the same "blocked, not planned" pattern a blocked
|
|
415
|
+
// Agora already follows (PR 180) -- the rest of this step, and every
|
|
416
|
+
// other step in the org's plan, is unaffected.
|
|
417
|
+
const mailboxAlreadyGiven = Boolean(recordedIds[MAIL_OWNER_MAILBOX_SCOPE_KEY]);
|
|
418
|
+
const hasOwner = Boolean(org.owner);
|
|
419
|
+
const ownerMailboxAddress = org.mail.ownerMailboxAddress ?? null;
|
|
420
|
+
const mailboxBlocked = hasOwner && !mailboxAlreadyGiven && !ownerMailboxAddress
|
|
421
|
+
? `${org.slug}'s owner mailbox is blocked, not planned: mail.ownerMailboxAddress is not declared`
|
|
422
|
+
: null;
|
|
423
|
+
const mailboxOwed = hasOwner && !mailboxAlreadyGiven && ownerMailboxAddress !== null;
|
|
424
|
+
// #230: declared role mailboxes not yet recorded as created. Names only
|
|
425
|
+
// ever count here, never appear in a description, same as the owner's address.
|
|
426
|
+
// Recorded per address, so a changed mail domain owes its own set. A hosted
|
|
427
|
+
// org's domain is only known once recorded; until then every role is owed.
|
|
428
|
+
const createdRoles = new Set((recordedIds[MAIL_ROLE_MAILBOXES_SCOPE_KEY] ?? '').split(','));
|
|
429
|
+
const roleDomain = (org.mail.usesDomain && org.domain) || recordedIds[MAIL_DOMAIN_SCOPE_KEY];
|
|
430
|
+
const rolesOwed = (org.mail.roleMailboxes ?? []).filter((r) => !roleDomain || !createdRoles.has(`${r}@${roleDomain}`.toLowerCase())).length;
|
|
431
|
+
const ownerNote = mailboxOwed ? `give ${org.slug}'s owner a mailbox` : '';
|
|
432
|
+
const roleNote = rolesOwed > 0 ? `create ${rolesOwed} role mailbox(es) for ${org.slug}` : '';
|
|
433
|
+
const mailboxWork = [ownerNote, roleNote].filter(Boolean).join(' and ');
|
|
434
|
+
const mailboxNote = mailboxWork ? ` and ${mailboxWork}` : '';
|
|
400
435
|
if (!live) {
|
|
401
436
|
return {
|
|
402
437
|
step: {
|
|
@@ -408,6 +443,7 @@ function planMail(org, byKey, claimed, recordedIds, mailHost) {
|
|
|
408
443
|
},
|
|
409
444
|
},
|
|
410
445
|
blocked: null,
|
|
446
|
+
mailboxBlocked,
|
|
411
447
|
};
|
|
412
448
|
}
|
|
413
449
|
const liveMode = live.config.mode;
|
|
@@ -417,20 +453,21 @@ function planMail(org, byKey, claimed, recordedIds, mailHost) {
|
|
|
417
453
|
// them (or it never recorded them). Re-applying is idempotent.
|
|
418
454
|
const addressMoved = recordedIds[ORG_MAIL_APPLIED_APEX_SCOPE_KEY] !== mailHost.apex ||
|
|
419
455
|
recordedIds[ORG_MAIL_APPLIED_HOST_SCOPE_KEY] !== mailHost.mailHost;
|
|
420
|
-
if (!
|
|
421
|
-
return { step: null, blocked: null };
|
|
456
|
+
if (!mailboxWork && !addressMoved)
|
|
457
|
+
return { step: null, blocked: null, mailboxBlocked };
|
|
422
458
|
return {
|
|
423
459
|
step: {
|
|
424
460
|
action: { system: 'coolify', kind: 'mail', key: org.id, desired },
|
|
425
461
|
step: {
|
|
426
462
|
description: addressMoved
|
|
427
463
|
? `re-apply ${org.slug}'s mail on the platform's mail host ${mailHost.mailHost}${mailboxNote}`
|
|
428
|
-
:
|
|
464
|
+
: mailboxWork,
|
|
429
465
|
destroys: false,
|
|
430
466
|
migrates: false,
|
|
431
467
|
},
|
|
432
468
|
},
|
|
433
469
|
blocked: null,
|
|
470
|
+
mailboxBlocked,
|
|
434
471
|
};
|
|
435
472
|
}
|
|
436
473
|
if (liveUsesDomain && !desired.usesDomain) {
|
|
@@ -446,6 +483,21 @@ function planMail(org, byKey, claimed, recordedIds, mailHost) {
|
|
|
446
483
|
},
|
|
447
484
|
},
|
|
448
485
|
blocked: null,
|
|
486
|
+
mailboxBlocked,
|
|
487
|
+
};
|
|
488
|
+
}
|
|
489
|
+
// #113: moving from `received` to `hosted` is an MX cutover, and foundry
|
|
490
|
+
// never cuts over before an org's old mail has copied into Stalwart --
|
|
491
|
+
// `planMailCopy`'s own step is what runs that copy. Blocked, not planned,
|
|
492
|
+
// the same "withhold the step rather than apply it half-ready" rule
|
|
493
|
+
// `mailboxBlocked` already follows just above.
|
|
494
|
+
if (liveMode === 'received' &&
|
|
495
|
+
desired.mode === 'hosted' &&
|
|
496
|
+
!recordedIds[MAIL_COPY_DONE_SCOPE_KEY]) {
|
|
497
|
+
return {
|
|
498
|
+
step: null,
|
|
499
|
+
blocked: `${org.slug}'s mail cannot move to hosted yet: its old mail is still being copied into Stalwart (coolify:mail-copy, #113)`,
|
|
500
|
+
mailboxBlocked,
|
|
449
501
|
};
|
|
450
502
|
}
|
|
451
503
|
// A mode transition (#7 "Transitions") or turning a domain on is a
|
|
@@ -460,6 +512,37 @@ function planMail(org, byKey, claimed, recordedIds, mailHost) {
|
|
|
460
512
|
},
|
|
461
513
|
},
|
|
462
514
|
blocked: null,
|
|
515
|
+
mailboxBlocked,
|
|
516
|
+
};
|
|
517
|
+
}
|
|
518
|
+
/**
|
|
519
|
+
* #113: the one-time copy of an org's old mail into Stalwart that must
|
|
520
|
+
* finish before `planMail` allows the `received` -> `hosted` MX cutover
|
|
521
|
+
* above. Planned only for that exact transition -- a fresh org declared
|
|
522
|
+
* `hosted` from the start, or one moving between any other pair of modes,
|
|
523
|
+
* has nothing to migrate from and never sees this step. Never matched
|
|
524
|
+
* against a live resource: like `platform-diff.ts`'s `planSfu`, there is no
|
|
525
|
+
* live state to probe here (`runMailCopyStep`'s own progress is recorded
|
|
526
|
+
* state, not something a reader lists), so this plans again on every apply
|
|
527
|
+
* until {@link MAIL_COPY_DONE_SCOPE_KEY} is recorded.
|
|
528
|
+
*/
|
|
529
|
+
function planMailCopy(org, byKey, recordedIds) {
|
|
530
|
+
if (org.mail.mode !== 'hosted')
|
|
531
|
+
return null;
|
|
532
|
+
if (recordedIds[MAIL_COPY_DONE_SCOPE_KEY])
|
|
533
|
+
return null;
|
|
534
|
+
const live = byKey.get(resourceKey('coolify', 'mail', org.id));
|
|
535
|
+
if (!live || live.config.mode !== 'received')
|
|
536
|
+
return null;
|
|
537
|
+
const completed = parseMailCopyCompleted(recordedIds[MAIL_COPY_COMPLETED_SCOPE_KEY]);
|
|
538
|
+
const progress = completed.length > 0 ? ` (${completed.length} account(s) already copied)` : '';
|
|
539
|
+
return {
|
|
540
|
+
action: { system: 'coolify', kind: 'mail-copy', key: org.id, desired: {} },
|
|
541
|
+
step: {
|
|
542
|
+
description: `copy ${org.slug}'s old mail into Stalwart before the MX cutover${progress} -- re-apply again to continue, or tomorrow if a provider's daily limit was hit`,
|
|
543
|
+
destroys: false,
|
|
544
|
+
migrates: false,
|
|
545
|
+
},
|
|
463
546
|
};
|
|
464
547
|
}
|
|
465
548
|
function planChat(org, byKey, claimed) {
|
package/dist/plan/executor.d.ts
CHANGED
|
@@ -2,6 +2,7 @@ import type { OrgMailOptions, OrgMailOrg, OrgMailResult } from '@wtfalch/email/p
|
|
|
2
2
|
import type { DeclaredOrg, HostingBuildPack } from '../org.js';
|
|
3
3
|
import type { DeclaredOrgStore } from '../store.js';
|
|
4
4
|
import type { StepAction } from './diff.js';
|
|
5
|
+
import { type MailCopyPort } from './mail-copy.js';
|
|
5
6
|
import type { DestroyTarget, StepDestroyer, StepExecutionContext, StepExecutor } from './plan-apply.js';
|
|
6
7
|
/**
|
|
7
8
|
* The real {@link StepExecutor} (#20 "provision Company Factory end to
|
|
@@ -504,35 +505,65 @@ export interface MailPort {
|
|
|
504
505
|
* `createMailbox`, resolving `org`'s domain to a `domainId` the same way
|
|
505
506
|
* `applyOrgMail` does -- that function already refuses a duplicate `local`
|
|
506
507
|
* on the resolved domain, so this port method never has to. `ensureOwnerMailbox`
|
|
507
|
-
* below
|
|
508
|
+
* and `ensureRoleMailboxes` (#230) below are this executor's callers, and each checks its own recorded scope
|
|
508
509
|
* id first, so a second apply never calls this at all.
|
|
509
510
|
*/
|
|
510
511
|
createMailbox(org: OrgMailOrg, mailbox: {
|
|
511
512
|
address: string;
|
|
512
513
|
name: string;
|
|
513
514
|
}): Promise<void>;
|
|
515
|
+
/**
|
|
516
|
+
* Upserts this org's own outbound relay route on Stalwart (#149): an
|
|
517
|
+
* `MtaRoute` through Lettermint, restricted to `org`'s domain, keyed by
|
|
518
|
+
* `org.slug` -- the host implements this with `@wtfalch/email/postmaster`'s
|
|
519
|
+
* own `applyOrgRelay` (https://github.com/wtfalch/email/issues/118), which
|
|
520
|
+
* rebuilds the shared routing expression so two orgs' routes never
|
|
521
|
+
* collide. `secret` is the Lettermint project token this executor just
|
|
522
|
+
* minted (`ensureOrgLettermintRelay`'s own mint rule) -- it goes straight
|
|
523
|
+
* here and nowhere else: never returned, never recorded, never logged, the
|
|
524
|
+
* same discipline `ensureAppLettermintMail`'s own doc comment states for
|
|
525
|
+
* `LETTERMINT_API_TOKEN`.
|
|
526
|
+
*/
|
|
527
|
+
applyOrgRelay(org: OrgMailOrg, secret: string): Promise<void>;
|
|
528
|
+
/**
|
|
529
|
+
* Drops this org's relay route (#149), implemented with
|
|
530
|
+
* `@wtfalch/email/postmaster`'s own `removeOrgRelay` -- a missing route is
|
|
531
|
+
* a no-op, so `parkLettermintMail` can call this unconditionally before it
|
|
532
|
+
* deletes the Lettermint project the route pointed at.
|
|
533
|
+
*/
|
|
534
|
+
removeOrgRelay(org: OrgMailOrg): Promise<void>;
|
|
535
|
+
/**
|
|
536
|
+
* True once this org's relay route exists on Stalwart with a secret set
|
|
537
|
+
* (#149). `ensureOrgLettermintRelay`'s own mint rule reads this alongside
|
|
538
|
+
* Lettermint's own project listing: a project without a live route (the
|
|
539
|
+
* route recorded a token this executor never minted) is exactly as
|
|
540
|
+
* unhealthy as a route with no project behind it, so a fresh mint fixes
|
|
541
|
+
* either.
|
|
542
|
+
*/
|
|
543
|
+
hasOrgRelay(org: OrgMailOrg): Promise<boolean>;
|
|
514
544
|
}
|
|
515
545
|
/**
|
|
516
|
-
* The shared calls SFU's own LiveKit API credentials (#122, #127,
|
|
517
|
-
* Before #170, a human minted
|
|
518
|
-
* read wherever `scripts/calls-sfu-deploy.mjs`'s
|
|
519
|
-
* delivered it from -- this package never minted or
|
|
520
|
-
* key/secret pair itself.
|
|
546
|
+
* The shared calls SFU's own per-org LiveKit API credentials (#122, #127,
|
|
547
|
+
* #145, #152). Before #170, a human minted one pair by hand, shared by every
|
|
548
|
+
* org, and `resolveCredentials` read wherever `scripts/calls-sfu-deploy.mjs`'s
|
|
549
|
+
* own `CallsSfuConfig` delivered it from -- this package never minted or
|
|
550
|
+
* invented a LiveKit key/secret pair itself.
|
|
521
551
|
*
|
|
522
552
|
* As of #170 the platform's own `sfu:livekit` step
|
|
523
|
-
* (`plan/platform-sfu.ts`'s `applyCallsSfuPlatformStep`) mints the pair,
|
|
524
|
-
*
|
|
525
|
-
* `CallsSfuKeysPort.deliverLivekitKeys
|
|
553
|
+
* (`plan/platform-sfu.ts`'s `applyCallsSfuPlatformStep`) mints the pair, and
|
|
554
|
+
* as of #152 it mints one *per org*, the first time that org applies, and
|
|
555
|
+
* delivers it to held keys through `CallsSfuKeysPort.deliverLivekitKeys`,
|
|
556
|
+
* keyed by the org's slug. `resolveCredentials` reads a specific org's own
|
|
526
557
|
* held-keys entry back -- still resolved by the host (archon), never minted
|
|
527
558
|
* or read directly by this port itself, the same "the executor is
|
|
528
559
|
* constructed with narrow ports ... a host supplies real clients" rule this
|
|
529
560
|
* file's own top doc comment states for every provider credential.
|
|
530
561
|
* `resolveCredentials` returns `null` before the platform's `sfu` step has
|
|
531
|
-
* ever applied, the same "nothing recorded" signal a bare
|
|
562
|
+
* ever applied for that org, the same "nothing recorded" signal a bare
|
|
532
563
|
* `CoolifyPort.listEnvs` gives `ensureCallsMatrixSecrets`.
|
|
533
564
|
*/
|
|
534
565
|
export interface CallsSfuPort {
|
|
535
|
-
resolveCredentials(): Promise<{
|
|
566
|
+
resolveCredentials(orgSlug: string): Promise<{
|
|
536
567
|
apiKey: string;
|
|
537
568
|
apiSecret: string;
|
|
538
569
|
} | null>;
|
|
@@ -664,6 +695,77 @@ export interface ZitadelPort {
|
|
|
664
695
|
userId: string;
|
|
665
696
|
token: string;
|
|
666
697
|
}>;
|
|
698
|
+
/**
|
|
699
|
+
* #216 (building on #209/#215): mints a second, narrower machine user on
|
|
700
|
+
* the same instance, with only the `IAM_LOGIN_CLIENT` role -- never
|
|
701
|
+
* `IAM_OWNER`, which {@link mintAdminMachineUser} already covers and which
|
|
702
|
+
* this method must not reuse or grant in addition. The login app and the
|
|
703
|
+
* broker both refuse to start without this token
|
|
704
|
+
* (`platform-providers.ts`'s `provisionPlatformAuth`, "Known gaps").
|
|
705
|
+
*
|
|
706
|
+
* Idempotent on the user by `userName`, like {@link mintAdminMachineUser},
|
|
707
|
+
* but never on the token the same way: ZITADEL shows a PAT's value once,
|
|
708
|
+
* and a crash or a failed delivery between a prior mint and the caller
|
|
709
|
+
* recording it would otherwise leave that lost token live forever. Unlike
|
|
710
|
+
* {@link mintAdminMachineUser}, revoking this user's PAT destroys nothing
|
|
711
|
+
* -- nothing else was ever encrypted with it, the way ZITADEL's own
|
|
712
|
+
* masterkey protects the admin path's data -- so a real implementation
|
|
713
|
+
* lists and deletes every PAT this user already holds before minting a
|
|
714
|
+
* fresh one, leaving exactly one live token no matter how many times this
|
|
715
|
+
* is called for the same user. This is what lets the caller
|
|
716
|
+
* (`provisionPlatformAuth`) simply re-mint on every rerun until delivery
|
|
717
|
+
* succeeds, rather than blocking forever the way an undelivered admin key
|
|
718
|
+
* does.
|
|
719
|
+
*
|
|
720
|
+
* Refuses (a named error, never a silent reuse) when a `foundry-login-client`
|
|
721
|
+
* user already exists with any IAM role other than exactly
|
|
722
|
+
* `IAM_LOGIN_CLIENT` -- that user was not created by this method, and
|
|
723
|
+
* granting it an additional role, or reusing its PAT, would widen access
|
|
724
|
+
* this caller never asked for.
|
|
725
|
+
*
|
|
726
|
+
* #216 round 2: also returns `patId`, the minted PAT's own id, so the
|
|
727
|
+
* caller can hand it to {@link listLoginClientPats} after delivering the
|
|
728
|
+
* token and confirm nothing else revoked or replaced it in the meantime.
|
|
729
|
+
*
|
|
730
|
+
* #216 (third PR review): also returns `revokedPriorPat`, true exactly
|
|
731
|
+
* when a live PAT existed on this user and was revoked before this mint --
|
|
732
|
+
* never true on a first-ever mint, where nothing was live to revoke. The
|
|
733
|
+
* caller uses this to word a delivery-failure error correctly: on a
|
|
734
|
+
* re-mint the login app and broker are left with no live token at all
|
|
735
|
+
* (the old one is already gone); on a first mint they simply never had one
|
|
736
|
+
* yet.
|
|
737
|
+
*/
|
|
738
|
+
mintLoginClientMachineUser(orgId: string, input: {
|
|
739
|
+
userName: string;
|
|
740
|
+
name: string;
|
|
741
|
+
}): Promise<{
|
|
742
|
+
userId: string;
|
|
743
|
+
token: string;
|
|
744
|
+
patId: string;
|
|
745
|
+
revokedPriorPat: boolean;
|
|
746
|
+
}>;
|
|
747
|
+
/**
|
|
748
|
+
* #216 round 2: every live PAT id on the `foundry-login-client` machine
|
|
749
|
+
* user, read straight from ZITADEL. `provisionPlatformAuth`
|
|
750
|
+
* (`platform-providers.ts`) calls this right after it delivers a freshly
|
|
751
|
+
* minted token to the login app and the broker, to close a race two
|
|
752
|
+
* concurrent applies can otherwise hit: run A mints token a and is about to
|
|
753
|
+
* deliver it; run B's own mint (`mintLoginClientMachineUser`, idempotent on
|
|
754
|
+
* the same `userName`) revokes a and mints token b first; run A then
|
|
755
|
+
* delivers the now-revoked a and would, without this check, still record
|
|
756
|
+
* success. Expecting the result to be exactly `[patId]` (the id
|
|
757
|
+
* {@link mintLoginClientMachineUser} returned) catches that: anything else
|
|
758
|
+
* means some other mint ran in between, so this run must fail rather than
|
|
759
|
+
* record delivery, leaving the next apply to re-mint and re-deliver.
|
|
760
|
+
*
|
|
761
|
+
* #216 round 3: a single call made once, after every write, cannot catch a
|
|
762
|
+
* write that lands after it -- `provisionPlatformAuth` now calls this
|
|
763
|
+
* again immediately before each individual write (each retry attempt of
|
|
764
|
+
* each of the login app's env, the broker's env, and each app's deploy),
|
|
765
|
+
* not only once at the end, so a write about to deliver a token this call
|
|
766
|
+
* reports no longer live is never attempted at all.
|
|
767
|
+
*/
|
|
768
|
+
listLoginClientPats(orgId: string, userId: string): Promise<string[]>;
|
|
667
769
|
}
|
|
668
770
|
/**
|
|
669
771
|
* Provisions one org's organisation on Files (#80), matching
|
|
@@ -839,6 +941,15 @@ export interface FoundryStepExecutorPorts {
|
|
|
839
941
|
jobsScheduler?: JobsSchedulerPort;
|
|
840
942
|
/** The shared calls SFU's own LiveKit credentials (#145) -- see {@link CallsSfuPort}'s own doc comment. */
|
|
841
943
|
callsSfu: CallsSfuPort;
|
|
944
|
+
/**
|
|
945
|
+
* #113: the org's old-mail copy into Stalwart, ahead of a `received` ->
|
|
946
|
+
* `hosted` MX cutover -- see {@link MailCopyPort}'s own doc comment.
|
|
947
|
+
* Optional the same way `jobsScheduler` is: a host that never runs this
|
|
948
|
+
* transition builds the executor without it, and `coolify:mail-copy`
|
|
949
|
+
* throws a clear "not built" error rather than every other step losing
|
|
950
|
+
* compilation over one dependency nothing else needs.
|
|
951
|
+
*/
|
|
952
|
+
mailCopy?: MailCopyPort;
|
|
842
953
|
/** Flat, not a factory: `organisationId` is an explicit argument on every method of both ports (see each port's own doc comment; #177 dropped the `filesLimitsFor`/`aiLimitsFor` factories this used to need). */
|
|
843
954
|
filesOrganisation?: FilesOrganisationPort;
|
|
844
955
|
aiOrganisation?: AiOrganisationPort;
|
|
@@ -1033,9 +1144,10 @@ export declare class FoundryStepExecutor implements StepExecutor, StepDestroyer
|
|
|
1033
1144
|
* `zitadel:issuer` (`AUTH_ORGANIZATION_ID`), `zitadel:owner`
|
|
1034
1145
|
* (`OWNER_USER_ID`, gap P3) and every `zitadel:app` step
|
|
1035
1146
|
* (`AUTH_CLIENT_ID`), all ordered before `coolify:app` for exactly this
|
|
1036
|
-
* reason. AUTH_APP_KEY (
|
|
1037
|
-
*
|
|
1038
|
-
*
|
|
1147
|
+
* reason. `AUTH_APP_KEY` (#206 -- which broker an org's app registers with
|
|
1148
|
+
* is its own design question, left out of #204's own "How" rather than
|
|
1149
|
+
* guessed) and DATABASE_URL_OWNER (a runtime-role split not built yet,
|
|
1150
|
+
* `pushDatabaseUrl`'s own doc comment) are out of scope here (task brief).
|
|
1039
1151
|
*/
|
|
1040
1152
|
private pushAppEnv;
|
|
1041
1153
|
/**
|
|
@@ -1079,7 +1191,10 @@ export declare class FoundryStepExecutor implements StepExecutor, StepDestroyer
|
|
|
1079
1191
|
* be deleted.
|
|
1080
1192
|
*
|
|
1081
1193
|
* `MAIL_FROM` is upserted every time: it depends only on the mail domain
|
|
1082
|
-
* `coolify:mail` resolved.
|
|
1194
|
+
* `coolify:mail` resolved and `org.mail.noReplyLocalPart` (#162, decided
|
|
1195
|
+
* 2026-09-29 -- defaults to `'no-reply'`, restated here rather than
|
|
1196
|
+
* imported from `org.ts`'s `DEFAULT_NO_REPLY_LOCAL_PART`, this file's own
|
|
1197
|
+
* `LOG_DIR`-style convention).
|
|
1083
1198
|
*
|
|
1084
1199
|
* The token goes straight to `bulkUpsertEnvs` and nowhere else: never
|
|
1085
1200
|
* returned, never recorded in `serviceIds` (`recordScopeId`'s own doc
|
|
@@ -1088,6 +1203,63 @@ export declare class FoundryStepExecutor implements StepExecutor, StepDestroyer
|
|
|
1088
1203
|
* own connection string.
|
|
1089
1204
|
*/
|
|
1090
1205
|
private ensureAppLettermintMail;
|
|
1206
|
+
/**
|
|
1207
|
+
* #149: mints this org's own Lettermint relay credential and writes it to
|
|
1208
|
+
* Stalwart via `MailPort.applyOrgRelay`, the same mint rule
|
|
1209
|
+
* {@link ensureAppLettermintMail} already uses for the org's app token --
|
|
1210
|
+
* "healthy" means Stalwart's own route exists ({@link MailPort.hasOrgRelay}),
|
|
1211
|
+
* AND Lettermint lists a project named `<slug>-relay` whose id
|
|
1212
|
+
* {@link ORG_MAIL_RELAY_SCOPE_KEY} records for this
|
|
1213
|
+
* exact mail domain (review finding: a route with a secret and a
|
|
1214
|
+
* same-named project both present is not enough -- the project could be a
|
|
1215
|
+
* lost-and-reprovisioned one this step never wrote a route for, and a mail
|
|
1216
|
+
* domain switch must re-point the route rather than read as healthy
|
|
1217
|
+
* forever because the old route still has a secret in it). A route with no
|
|
1218
|
+
* live project behind it, a project the route's own secret never reached,
|
|
1219
|
+
* or a recorded project id/domain that no longer matches, are all exactly
|
|
1220
|
+
* as unhealthy as neither existing.
|
|
1221
|
+
*
|
|
1222
|
+
* A fresh project is created and its token written to Stalwart first, and
|
|
1223
|
+
* only then is every other `<slug>-relay` project deleted -- a lost route
|
|
1224
|
+
* and a duplicate project both end the same way. The new project's domain
|
|
1225
|
+
* restriction runs before the token is written, so a failure there never
|
|
1226
|
+
* touches the working route. Review finding: if either the
|
|
1227
|
+
* domain-restriction step or `applyOrgRelay` throws after a
|
|
1228
|
+
* fresh project was minted, that project is deleted before the error
|
|
1229
|
+
* propagates -- otherwise every retry sees more than one `<slug>-relay`
|
|
1230
|
+
* project and mints yet another one, growing the orphan count without
|
|
1231
|
+
* bound.
|
|
1232
|
+
*
|
|
1233
|
+
* `coolify:mail` runs before `coolify:app` (`diff.ts`'s own step order), so
|
|
1234
|
+
* this is very often the first of the two to reach the org's Lettermint
|
|
1235
|
+
* domain: {@link ensureLettermintDomainRestriction} registers and restricts
|
|
1236
|
+
* it the same way the app step does, rather than assuming it already
|
|
1237
|
+
* exists -- the domain must never end up unrestricted, whichever step gets
|
|
1238
|
+
* there first.
|
|
1239
|
+
*/
|
|
1240
|
+
private ensureOrgLettermintRelay;
|
|
1241
|
+
/**
|
|
1242
|
+
* Deletes a just-minted project from inside a `catch`. A failed delete is
|
|
1243
|
+
* dropped so it never replaces the error being rethrown; the orphan is
|
|
1244
|
+
* swept by the next apply's duplicate delete.
|
|
1245
|
+
*/
|
|
1246
|
+
private discardLettermintProject;
|
|
1247
|
+
/**
|
|
1248
|
+
* Ensures this org's Lettermint domain exists, is restricted to exactly
|
|
1249
|
+
* the `<slug>-app`/`<slug>-relay` projects live in `allProjects`, has its
|
|
1250
|
+
* DNS published and a verification triggered. Shared by
|
|
1251
|
+
* {@link ensureAppLettermintMail} and {@link ensureOrgLettermintRelay}
|
|
1252
|
+
* (#149) -- whichever of the two steps runs first on a fresh org creates
|
|
1253
|
+
* the domain, and the other then finds it (`findExactLettermintDomain`).
|
|
1254
|
+
*
|
|
1255
|
+
* A crash between the restriction write and the DNS publish/verify below
|
|
1256
|
+
* is safe even for a freshly created domain: DNS is only published, and
|
|
1257
|
+
* verification only triggered, after the restriction -- so an
|
|
1258
|
+
* unrestricted domain cannot verify and cannot send, whichever project
|
|
1259
|
+
* owns it (post-#140 review finding, restated here rather than only at
|
|
1260
|
+
* the one call site it used to have).
|
|
1261
|
+
*/
|
|
1262
|
+
private ensureLettermintDomainRestriction;
|
|
1091
1263
|
/**
|
|
1092
1264
|
* Review finding (post-#140 rework): {@link LettermintPort.findDomain}'s
|
|
1093
1265
|
* own match is partial and case-insensitive, so a raw result can be a
|
|
@@ -1374,20 +1546,21 @@ export declare class FoundryStepExecutor implements StepExecutor, StepDestroyer
|
|
|
1374
1546
|
* `@wtfalch/email/postmaster`'s `applyOrgMail`/`parkOrgMail` bound), then
|
|
1375
1547
|
* publishes whatever Stalwart DNS records the call returns, and records the
|
|
1376
1548
|
* resolved mail domain as a scope id ({@link MAIL_DOMAIN_SCOPE_KEY}).
|
|
1377
|
-
*
|
|
1378
|
-
*
|
|
1379
|
-
*
|
|
1380
|
-
* (`ensureAppLettermintMail`, "Core rule"), since the
|
|
1381
|
-
* only exist once the token has a home. Parking mail
|
|
1382
|
-
*
|
|
1383
|
-
* deletes the org's own projects and
|
|
1384
|
-
* ends up restricted only to dead projects
|
|
1549
|
+
*
|
|
1550
|
+
* #149: this step also mints the org's own Lettermint *relay* credential
|
|
1551
|
+
* ({@link ensureOrgLettermintRelay}) -- the app's own *sending* credential
|
|
1552
|
+
* stays in the app step (`ensureAppLettermintMail`, "Core rule"), since the
|
|
1553
|
+
* first app project can only exist once the token has a home. Parking mail
|
|
1554
|
+
* (mode `'off'`) reaches Lettermint too: {@link parkLettermintMail} drops
|
|
1555
|
+
* the relay route, then deletes the org's own projects and its domain, so
|
|
1556
|
+
* a domain never ends up restricted only to dead projects and no route
|
|
1557
|
+
* ever points at a project this apply is about to remove.
|
|
1385
1558
|
*
|
|
1386
1559
|
* Review finding: a mail domain switch (`org.domain` changes, or
|
|
1387
1560
|
* `usesDomain` flips) must also delete the *old* Lettermint domain here,
|
|
1388
1561
|
* before recording the new one -- otherwise it stays registered and
|
|
1389
|
-
* restricted to the org's still-live `<slug>-app`
|
|
1390
|
-
* then keep sending as it forever. {@link deleteStaleLettermintDomain}
|
|
1562
|
+
* restricted to the org's still-live `<slug>-app`/`<slug>-relay` projects,
|
|
1563
|
+
* which could then keep sending as it forever. {@link deleteStaleLettermintDomain}
|
|
1391
1564
|
* does this by comparing the previously recorded domain (case-insensitive)
|
|
1392
1565
|
* against the one this apply just resolved.
|
|
1393
1566
|
*
|
|
@@ -1397,15 +1570,39 @@ export declare class FoundryStepExecutor implements StepExecutor, StepDestroyer
|
|
|
1397
1570
|
*/
|
|
1398
1571
|
private coolifyMail;
|
|
1399
1572
|
/**
|
|
1400
|
-
* #
|
|
1401
|
-
*
|
|
1402
|
-
*
|
|
1403
|
-
*
|
|
1404
|
-
*
|
|
1405
|
-
* `
|
|
1406
|
-
*
|
|
1573
|
+
* `plan/diff.ts`'s `planMailCopy` (#113): this apply's share of copying
|
|
1574
|
+
* the org's old mail into Stalwart, via `mail-copy.ts`'s `runMailCopyStep`
|
|
1575
|
+
* -- the only thing this method itself does is read back what the last
|
|
1576
|
+
* apply recorded, hand it to that function, and record what comes back.
|
|
1577
|
+
* An account that failed to copy records the progress made, never
|
|
1578
|
+
* `mail_copy_done`, then throws with the failing account, phase and
|
|
1579
|
+
* redacted stderr tail, so the apply reports it as not done. A missing
|
|
1580
|
+
* `mailCopy` port throws too: a configuration gap a retry cannot fix.
|
|
1581
|
+
*/
|
|
1582
|
+
private mailCopy;
|
|
1583
|
+
/**
|
|
1584
|
+
* #162 (decided 2026-09-29): gives the org's declared owner a mailbox at
|
|
1585
|
+
* its declared `mail.ownerMailboxAddress` -- nothing is guessed any more
|
|
1586
|
+
* (the hardcoded `owner@<domain>` PR 163 shipped first is gone). A no-op
|
|
1587
|
+
* when the org has no owner yet, when no address is declared yet
|
|
1588
|
+
* (`plan/diff.ts`'s `planMail` reports that case as blocked, not planned,
|
|
1589
|
+
* so this step's own mode/domain work still runs), or once
|
|
1590
|
+
* {@link MAIL_OWNER_MAILBOX_SCOPE_KEY} is already recorded -- checked last
|
|
1591
|
+
* so a second apply never calls `MailPort.createMailbox` again, rather
|
|
1592
|
+
* than relying on that port method's own duplicate refusal.
|
|
1407
1593
|
*/
|
|
1408
1594
|
private ensureOwnerMailbox;
|
|
1595
|
+
/**
|
|
1596
|
+
* #230: creates each of the org's declared `mail.roleMailboxes` on its
|
|
1597
|
+
* just-resolved mail domain, once. A role mailbox has no person behind it,
|
|
1598
|
+
* so its display name is just its local part, and like the owner's it is
|
|
1599
|
+
* created with no password: `MailPort.createMailbox` takes none, so there
|
|
1600
|
+
* 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
|
|
1601
|
+
* {@link MAIL_ROLE_MAILBOXES_SCOPE_KEY} after every mailbox, so a second
|
|
1602
|
+
* apply (or a retry after a crash) skips what exists rather than relying
|
|
1603
|
+
* on the mail server's own duplicate refusal.
|
|
1604
|
+
*/
|
|
1605
|
+
private ensureRoleMailboxes;
|
|
1409
1606
|
/**
|
|
1410
1607
|
* Deletes the org's previous Lettermint domain once the mail domain it was
|
|
1411
1608
|
* registered under has changed -- otherwise it stays registered and
|
|
@@ -1427,9 +1624,13 @@ export declare class FoundryStepExecutor implements StepExecutor, StepDestroyer
|
|
|
1427
1624
|
/**
|
|
1428
1625
|
* Parking mail (2026-09-28 lettermint-org-mail swap, "Core rule": "Parking
|
|
1429
1626
|
* mail deletes the org's projects and then the domain itself, so no
|
|
1430
|
-
* restriction points only at dead projects").
|
|
1431
|
-
*
|
|
1432
|
-
*
|
|
1627
|
+
* restriction points only at dead projects"). #149: first drops the
|
|
1628
|
+
* Stalwart relay route with `MailPort.removeOrgRelay` -- a missing route is
|
|
1629
|
+
* a no-op, per that port method's own contract, so this is safe to call
|
|
1630
|
+
* even for an org whose relay was never minted -- then deletes every
|
|
1631
|
+
* Lettermint project named `<slug>-app` or `<slug>-relay`, then the org's
|
|
1632
|
+
* own Lettermint domain, if one was found -- in that order, never the
|
|
1633
|
+
* reverse, so the route is always gone before the project it pointed at.
|
|
1433
1634
|
* A no-op until a previous apply recorded {@link MAIL_DOMAIN_SCOPE_KEY}:
|
|
1434
1635
|
* nothing was ever provisioned for an org whose mail went straight from
|
|
1435
1636
|
* never-applied to off.
|