@wtfalch/foundry 0.14.0 → 0.16.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.
Files changed (42) hide show
  1. package/dist/bootstrap/declare.d.ts +2 -2
  2. package/dist/bootstrap/declare.js +6 -6
  3. package/dist/index.d.ts +4 -4
  4. package/dist/index.js +3 -3
  5. package/dist/migrations/0034_owners.sql +42 -0
  6. package/dist/migrations/0035_chat_on_by_default.sql +10 -0
  7. package/dist/org.d.ts +80 -17
  8. package/dist/org.js +221 -18
  9. package/dist/plan/agora.d.ts +19 -1
  10. package/dist/plan/agora.js +37 -2
  11. package/dist/plan/chat-matrix.d.ts +99 -0
  12. package/dist/plan/chat-matrix.js +194 -0
  13. package/dist/plan/diff.d.ts +5 -3
  14. package/dist/plan/diff.js +143 -83
  15. package/dist/plan/entry-ai-key.d.ts +58 -0
  16. package/dist/plan/entry-ai-key.js +209 -0
  17. package/dist/plan/entry-files-key.d.ts +82 -0
  18. package/dist/plan/entry-files-key.js +327 -0
  19. package/dist/plan/executor.d.ts +244 -16
  20. package/dist/plan/executor.js +860 -78
  21. package/dist/plan/hostnames.d.ts +8 -1
  22. package/dist/plan/hostnames.js +18 -1
  23. package/dist/plan/index.d.ts +2 -1
  24. package/dist/plan/index.js +2 -1
  25. package/dist/plan/plan-apply.d.ts +2 -0
  26. package/dist/plan/plan-apply.js +42 -2
  27. package/dist/plan/platform-diff.d.ts +8 -0
  28. package/dist/plan/platform-diff.js +8 -0
  29. package/dist/plan/platform-org.d.ts +17 -5
  30. package/dist/plan/platform-org.js +15 -7
  31. package/dist/plan/platform-providers.d.ts +38 -12
  32. package/dist/plan/platform-providers.js +99 -16
  33. package/dist/plan/proxy-labels.d.ts +42 -0
  34. package/dist/plan/proxy-labels.js +73 -0
  35. package/dist/plan/zitadel-machine-user.d.ts +5 -1
  36. package/dist/plan/zitadel-machine-user.js +5 -1
  37. package/dist/plan/zitadel-project-roles.d.ts +25 -0
  38. package/dist/plan/zitadel-project-roles.js +104 -0
  39. package/dist/store/postgres.js +4 -17
  40. package/dist/store/schema.d.ts +13 -45
  41. package/dist/store/schema.js +5 -8
  42. package/package.json +2 -2
@@ -6,6 +6,12 @@ import type { DeclaredOrg, HostingEntry } from '../org.js';
6
6
  * acted on elsewhere.
7
7
  */
8
8
  export declare function entryHostnames(org: DeclaredOrg, entry: HostingEntry): string[];
9
+ /**
10
+ * The wildcard name (`*.<domain>`) when an entry declares `'*'`, else `null`.
11
+ * Like `entryHostnames`, it is read only while hosting is on, `usesDomain` is
12
+ * on and the org has a domain.
13
+ */
14
+ export declare function wildcardHostname(org: DeclaredOrg): string | null;
9
15
  /**
10
16
  * The one primary name of the org's app: its first, or the name the app was
11
17
  * served at before entries had hostnames. `APP_URL`, the ZITADEL redirect URI
@@ -17,7 +23,8 @@ export declare function legacyAppHostname(org: DeclaredOrg): string;
17
23
  /**
18
24
  * The hostnames `cloudflareTunnel` routes to Coolify's proxy (#115, #145,
19
25
  * #164): a site's two names, every entry's names, an own-domain calls name and
20
- * Agora's. An org that does not host keeps the single name it always had.
26
+ * Agora's, then the wildcard last. An org that does not host keeps the single
27
+ * name it always had.
21
28
  */
22
29
  export declare function tunnelIngressHostnames(org: DeclaredOrg): string[];
23
30
  /** The ingress names that get a DNS record: those under the org's own domain. */
@@ -13,6 +13,17 @@ export function entryHostnames(org, entry) {
13
13
  }
14
14
  return entry.hostnames.flatMap((name) => name === '*' ? [] : name === '@' ? [domain] : [`${name}.${domain}`]);
15
15
  }
16
+ /**
17
+ * The wildcard name (`*.<domain>`) when an entry declares `'*'`, else `null`.
18
+ * Like `entryHostnames`, it is read only while hosting is on, `usesDomain` is
19
+ * on and the org has a domain.
20
+ */
21
+ export function wildcardHostname(org) {
22
+ const { domain } = org;
23
+ if (!org.hosting.enabled || !org.hosting.usesDomain || !domain)
24
+ return null;
25
+ return org.hosting.entries.some((entry) => entry.hostnames.includes('*')) ? `*.${domain}` : null;
26
+ }
16
27
  /**
17
28
  * The one primary name of the org's app: its first, or the name the app was
18
29
  * served at before entries had hostnames. `APP_URL`, the ZITADEL redirect URI
@@ -30,7 +41,8 @@ export function legacyAppHostname(org) {
30
41
  /**
31
42
  * The hostnames `cloudflareTunnel` routes to Coolify's proxy (#115, #145,
32
43
  * #164): a site's two names, every entry's names, an own-domain calls name and
33
- * Agora's. An org that does not host keeps the single name it always had.
44
+ * Agora's, then the wildcard last. An org that does not host keeps the single
45
+ * name it always had.
34
46
  */
35
47
  export function tunnelIngressHostnames(org) {
36
48
  const hostnames = org.site.enabled && org.domain ? [org.domain, `cms.${org.domain}`] : [];
@@ -48,6 +60,11 @@ export function tunnelIngressHostnames(org) {
48
60
  if (org.domain) {
49
61
  hostnames.push(agoraHostnameFor(org));
50
62
  }
63
+ // The wildcard goes last: Cloudflare takes the first ingress rule that
64
+ // matches, so every exact name above must come before `*.<domain>`.
65
+ const wildcard = wildcardHostname(org);
66
+ if (wildcard)
67
+ hostnames.push(wildcard);
51
68
  return [...new Set(hostnames)];
52
69
  }
53
70
  /** The ingress names that get a DNS record: those under the org's own domain. */
@@ -3,7 +3,7 @@ export { readLiveResources, readLivePlatformResources } from './live.js';
3
3
  export type { StepAction, PlannedStep, PlanDetail } from './diff.js';
4
4
  export { computePlanDetail, toPlan } from './diff.js';
5
5
  export type { PlatformPlanDetail } from './platform-diff.js';
6
- export { planPlatform, toPlatformPlan, JOBS_SCHEDULER_KEY, PLATFORM_AI_KEY, PLATFORM_AI_INTERNAL_URL_SCOPE_KEY, PLATFORM_AI_MANAGEMENT_KEY_SCOPE_KEY, PLATFORM_AI_ORGANISATION_SCOPE_KEY, PLATFORM_AUTH_KEY, PLATFORM_FILES_KEY, PLATFORM_FILES_INTERNAL_URL_SCOPE_KEY, PLATFORM_FILES_ZITADEL_PROJECT_SCOPE_KEY, PLATFORM_MAIL_KEY, PLATFORM_MAIL_INTERNAL_URL_SCOPE_KEY, } from './platform-diff.js';
6
+ export { planPlatform, toPlatformPlan, JOBS_SCHEDULER_KEY, PLATFORM_AI_KEY, PLATFORM_AI_INTERNAL_URL_SCOPE_KEY, PLATFORM_AI_MANAGEMENT_KEY_SCOPE_KEY, PLATFORM_AI_ORGANISATION_SCOPE_KEY, PLATFORM_AUTH_KEY, PLATFORM_FILES_KEY, PLATFORM_FILES_INTERNAL_URL_SCOPE_KEY, PLATFORM_FILES_ZITADEL_PROJECT_SCOPE_KEY, PLATFORM_MAIL_KEY, PLATFORM_MAIL_INTERNAL_URL_SCOPE_KEY, PLATFORM_MAIL_ZITADEL_PROJECT_SCOPE_KEY, } from './platform-diff.js';
7
7
  export type { HeldKeyResolver, OrgPlatformReadiness } from './platform-org.js';
8
8
  export { DEFAULT_ORG_PLATFORM_READINESS, ORG_PLATFORM_KEY_REFS, agoraHostValuesFromPlatform, heldPlatformKeyRefs, orgPlatformReadinessFrom, } from './platform-org.js';
9
9
  export type { PlatformOrgClientFactories } from './platform-org-ports.js';
@@ -17,6 +17,7 @@ export { FILES_INTROSPECTION_SECRET_REF, FILES_MANAGEMENT_ISSUER, FILES_MANAGEME
17
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
+ export { createZitadelProjectRoles } from './zitadel-project-roles.js';
20
21
  export type { CallsSfuConnection, CallsSfuConnectionPort, CallsSfuKeysPort, CallsSfuSshConnector, CallsSfuPlatformDeps, } from './platform-sfu.js';
21
22
  export { MissingCallsSfuConnectionError, applyCallsSfuPlatformStep } from './platform-sfu.js';
22
23
  export type { MailCopyJob, MailCopyPort, MailCopyStepResult } from './mail-copy.js';
@@ -1,6 +1,6 @@
1
1
  export { readLiveResources, readLivePlatformResources } from './live.js';
2
2
  export { computePlanDetail, toPlan } from './diff.js';
3
- export { planPlatform, toPlatformPlan, JOBS_SCHEDULER_KEY, PLATFORM_AI_KEY, PLATFORM_AI_INTERNAL_URL_SCOPE_KEY, PLATFORM_AI_MANAGEMENT_KEY_SCOPE_KEY, PLATFORM_AI_ORGANISATION_SCOPE_KEY, PLATFORM_AUTH_KEY, PLATFORM_FILES_KEY, PLATFORM_FILES_INTERNAL_URL_SCOPE_KEY, PLATFORM_FILES_ZITADEL_PROJECT_SCOPE_KEY, PLATFORM_MAIL_KEY, PLATFORM_MAIL_INTERNAL_URL_SCOPE_KEY, } from './platform-diff.js';
3
+ export { planPlatform, toPlatformPlan, JOBS_SCHEDULER_KEY, PLATFORM_AI_KEY, PLATFORM_AI_INTERNAL_URL_SCOPE_KEY, PLATFORM_AI_MANAGEMENT_KEY_SCOPE_KEY, PLATFORM_AI_ORGANISATION_SCOPE_KEY, PLATFORM_AUTH_KEY, PLATFORM_FILES_KEY, PLATFORM_FILES_INTERNAL_URL_SCOPE_KEY, PLATFORM_FILES_ZITADEL_PROJECT_SCOPE_KEY, PLATFORM_MAIL_KEY, PLATFORM_MAIL_INTERNAL_URL_SCOPE_KEY, PLATFORM_MAIL_ZITADEL_PROJECT_SCOPE_KEY, } from './platform-diff.js';
4
4
  export { DEFAULT_ORG_PLATFORM_READINESS, ORG_PLATFORM_KEY_REFS, agoraHostValuesFromPlatform, heldPlatformKeyRefs, orgPlatformReadinessFrom, } from './platform-org.js';
5
5
  export { PlatformOrgBlockedError, PlatformOrgPorts } from './platform-org-ports.js';
6
6
  export { FoundryPlanApply, DeclaredOrgNotFoundError, StalePlanError, ConfirmationRequiredError, NotAuthorizedError, NotATestOrgError, DeclaredPlatformNotFoundError, PlatformStalePlanError, PlatformConfirmationRequiredError, planNeedsConfirmation, } from './plan-apply.js';
@@ -9,6 +9,7 @@ export { FoundryPlatformStepExecutor, ProvidersPlatformCloudflareReader, Provide
9
9
  export { FILES_INTROSPECTION_SECRET_REF, FILES_MANAGEMENT_ISSUER, FILES_MANAGEMENT_KEY_ID, FILES_MANAGEMENT_KEY_REF, PLATFORM_AUTH_INSTANCE_BEARER_REF, } from './platform-providers.js';
10
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
+ export { createZitadelProjectRoles } from './zitadel-project-roles.js';
12
13
  export { MissingCallsSfuConnectionError, applyCallsSfuPlatformStep } from './platform-sfu.js';
13
14
  export { MAIL_COPY_COMPLETED_SCOPE_KEY, MAIL_COPY_DONE_SCOPE_KEY, MissingMailCopyAccountsError, MissingMailCopySourceTokenError, MissingMailCopyStalwartTokenError, parseMailCopyCompleted, runMailCopyStep, } from './mail-copy.js';
14
15
  export { ProvidersCloudflareReader, ProvidersCmsReader, ProvidersCoolifyReader, ProvidersZitadelReader, storeScopeResolver, storeRecordedIds, } from './providers-readers.js';
@@ -120,6 +120,8 @@ export interface StepExecutionContext {
120
120
  * run, plus whatever a previous apply already persisted.
121
121
  */
122
122
  recordedIds(): Readonly<Record<string, string>>;
123
+ /** The clock the plan was made with, in ms; the same one `computePlanDetail` read. Absent: `Date.now()`. */
124
+ now?(): number;
123
125
  /**
124
126
  * Records a provider *scope* id — `coolify_project`, `zitadel_org`,
125
127
  * `zitadel_project`, `cloudflare_zone` — alongside whatever `system:kind:key`
@@ -4,6 +4,8 @@ import { validateHosting } from '../org.js';
4
4
  import { PLATFORM_SERVICE_KEYS } from '../platform.js';
5
5
  import { NO_AGORA_HOST_VALUES, readAgoraHost, } from './agora.js';
6
6
  import { AI_KEY_UNDELIVERED_SCOPE_KEY, JOBS_REGISTRATION_UNDELIVERED_SCOPE_KEY, computePlanDetail, toPlan, } from './diff.js';
7
+ import { entryAiUndeliveredScopeKey } from './entry-ai-key.js';
8
+ import { FILES_ROOT_UNDELIVERED_SCOPE_KEY, entryFilesUndeliveredScopeKey, } from './entry-files-key.js';
7
9
  import { readLivePlatformResources, readLiveResources, } from './live.js';
8
10
  import { lockedHostnames } from './locked-hostnames.js';
9
11
  import { planPlatform as computePlatformPlanDetail, toPlatformPlan, } from './platform-diff.js';
@@ -292,6 +294,7 @@ export class FoundryPlanApply {
292
294
  const context = {
293
295
  org,
294
296
  recordedIds: () => ({ ...serviceIds }),
297
+ now: () => this.now().getTime(),
295
298
  recordScopeId: (key, id) => {
296
299
  serviceIds[key] = id;
297
300
  },
@@ -368,7 +371,11 @@ export class FoundryPlanApply {
368
371
  this.orgPlatformReadiness(),
369
372
  this.orgAgoraHost(id),
370
373
  ]);
371
- const detail = computePlanDetail(org, live, applied?.serviceIds ?? {}, agoraHost, this.jobsScheduler, platformReadiness, { loginHostBindings: this.loginHostBindings, broker: this.broker });
374
+ const detail = computePlanDetail(org, live, applied?.serviceIds ?? {}, agoraHost, this.jobsScheduler, platformReadiness, {
375
+ loginHostBindings: this.loginHostBindings,
376
+ broker: this.broker,
377
+ now: () => this.now().getTime(),
378
+ });
372
379
  const invalid = await this.invalidHosting(org);
373
380
  if (invalid) {
374
381
  // Rule 2: reported in `unmanaged`, not thrown. Steps that read the
@@ -599,12 +606,20 @@ function destroyTargets(serviceIds) {
599
606
  'coolify:database:',
600
607
  'coolify:app:',
601
608
  'zitadel:customers:',
609
+ // F6a: an entry's ai key, `<orgId>:<entry>`; the org's own key has no `:` and is left alone.
610
+ 'ai:key:',
611
+ // F6b: an entry's files key, `<orgId>:<entry>`; the org's own key has no `:` and is left alone.
612
+ 'files:key:',
602
613
  ];
603
614
  const targets = [];
604
615
  for (const prefix of ORDERED_PREFIXES) {
605
616
  for (const [resourceKey, id] of Object.entries(serviceIds)) {
606
617
  if (!resourceKey.startsWith(prefix))
607
618
  continue;
619
+ if (prefix === 'ai:key:' && !resourceKey.slice(prefix.length).includes(':'))
620
+ continue;
621
+ if (prefix === 'files:key:' && !resourceKey.slice(prefix.length).includes(':'))
622
+ continue;
608
623
  const [system, kind] = prefix.split(':');
609
624
  targets.push({ system, kind, key: resourceKey.slice(prefix.length), id });
610
625
  }
@@ -642,7 +657,7 @@ function destroyServiceIdKey(target) {
642
657
  */
643
658
  const STEP_FIELDS = {
644
659
  'zitadel:issuer': 'issuer.usesDomain',
645
- 'zitadel:owner': 'owner',
660
+ 'zitadel:owner': 'owners',
646
661
  'zitadel:customers': 'hosting.entries.uses.auth',
647
662
  'coolify:broker-entry': 'hosting.entries.uses.auth',
648
663
  'coolify:mail': 'mail',
@@ -661,6 +676,13 @@ const STEP_FIELDS = {
661
676
  'cloudflare:ratelimit': 'hosting.usesDomain',
662
677
  };
663
678
  function stepField(action) {
679
+ // An entry's ai key (F6a) is keyed `<orgId>:<entry>`; the org's own is `<orgId>`.
680
+ if (action.system === 'ai' && action.kind === 'key' && action.key.includes(':')) {
681
+ return 'hosting.entries.uses.ai';
682
+ }
683
+ // The org's own files key (F6b) is keyed `<orgId>`, an entry's `<orgId>:<entry>`.
684
+ if (action.system === 'files' && action.kind === 'key')
685
+ return 'hosting.entries.uses.files';
664
686
  // An org declares several issuer apps, each keyed by its name (#71).
665
687
  if (action.system === 'zitadel' && action.kind === 'app') {
666
688
  return `hosting.issuerApps.${action.key}`;
@@ -691,6 +713,24 @@ function failedFields(org, detail, completed, serviceIds) {
691
713
  // as converged with no working key.
692
714
  if (serviceIds[AI_KEY_UNDELIVERED_SCOPE_KEY] && !serviceIds[`ai:key:${org.id}`])
693
715
  failed.add('ai');
716
+ // The same for an entry's ai key (F6a): minted, never delivered, so no step is planned.
717
+ for (const entry of org.hosting.entries) {
718
+ if (entry.uses?.ai && serviceIds[entryAiUndeliveredScopeKey(entry.key)]) {
719
+ failed.add('hosting.entries.uses.ai');
720
+ }
721
+ }
722
+ // The same for the org's files key and an entry's (F6b): minted, never delivered, so no step is planned.
723
+ if (org.hosting.entries.some((e) => e.uses?.files)) {
724
+ if (serviceIds[FILES_ROOT_UNDELIVERED_SCOPE_KEY] &&
725
+ serviceIds[FILES_ROOT_UNDELIVERED_SCOPE_KEY] !== serviceIds[`files:key:${org.id}`]) {
726
+ failed.add('hosting.entries.uses.files');
727
+ }
728
+ for (const entry of org.hosting.entries) {
729
+ if (entry.uses?.files && serviceIds[entryFilesUndeliveredScopeKey(entry.key)]) {
730
+ failed.add('hosting.entries.uses.files');
731
+ }
732
+ }
733
+ }
694
734
  // The same for a jobs registration whose credentials never reached the host.
695
735
  if (serviceIds[JOBS_REGISTRATION_UNDELIVERED_SCOPE_KEY] &&
696
736
  !serviceIds[`jobs:registration:${org.id}`]) {
@@ -299,6 +299,14 @@ export declare const PLATFORM_MAIL_APPLIED_GIT_REF_SCOPE_KEY = "mail_applied_git
299
299
  * own internal address, never its admin credential.
300
300
  */
301
301
  export declare const PLATFORM_MAIL_INTERNAL_URL_SCOPE_KEY = "mail_internal_url";
302
+ /**
303
+ * #346: the mail ZITADEL project's id in the platform's own sign-in, the
304
+ * {@link PLATFORM_FILES_ZITADEL_PROJECT_SCOPE_KEY} pattern for mail --
305
+ * `provisionPlatformMail` finds or creates it and uses the id as the OIDC
306
+ * audience Stalwart's directory checks an access token's `aud` claim
307
+ * against, so no host has to register one by hand.
308
+ */
309
+ export declare const PLATFORM_MAIL_ZITADEL_PROJECT_SCOPE_KEY = "zitadel_platform_mail_project";
302
310
  /**
303
311
  * #177: the one host the platform's mail server takes SMTP/MX on, and so the
304
312
  * `mailHost` every org's own mail step writes. `provisionPlatformMail` (the
@@ -330,6 +330,14 @@ export const PLATFORM_MAIL_APPLIED_GIT_REF_SCOPE_KEY = 'mail_applied_git_ref';
330
330
  * own internal address, never its admin credential.
331
331
  */
332
332
  export const PLATFORM_MAIL_INTERNAL_URL_SCOPE_KEY = 'mail_internal_url';
333
+ /**
334
+ * #346: the mail ZITADEL project's id in the platform's own sign-in, the
335
+ * {@link PLATFORM_FILES_ZITADEL_PROJECT_SCOPE_KEY} pattern for mail --
336
+ * `provisionPlatformMail` finds or creates it and uses the id as the OIDC
337
+ * audience Stalwart's directory checks an access token's `aud` claim
338
+ * against, so no host has to register one by hand.
339
+ */
340
+ export const PLATFORM_MAIL_ZITADEL_PROJECT_SCOPE_KEY = 'zitadel_platform_mail_project';
333
341
  /**
334
342
  * #177: the one host the platform's mail server takes SMTP/MX on, and so the
335
343
  * `mailHost` every org's own mail step writes. `provisionPlatformMail` (the
@@ -32,8 +32,18 @@ export interface OrgPlatformReadiness {
32
32
  } | null;
33
33
  /** The files host (#171). Files is always on for every org (#80), so `false` always blocks rather than only mattering when a flag is set. */
34
34
  filesReady: boolean;
35
+ /**
36
+ * The files host's internal address, which an entry with `uses.files` gets
37
+ * as `FILES_URL`. `null` or absent until the platform has recorded it.
38
+ */
39
+ filesUrl?: string | null;
35
40
  /** The AI host (#172), the same always-on shape as {@link filesReady}. */
36
41
  aiReady: boolean;
42
+ /**
43
+ * The AI host's internal address, which an entry with `uses.ai` gets as
44
+ * `AI_URL`. `null` or absent until the platform has recorded it.
45
+ */
46
+ aiUrl?: string | null;
37
47
  /**
38
48
  * The shared calls SFU (#170) an org's own calls Matrix stack depends on.
39
49
  * Declared is enough here: unlike the others, `platform-diff.ts`'s
@@ -110,11 +120,13 @@ noKeyReader?: boolean): OrgPlatformReadiness;
110
120
  * than requiring a bespoke host-supplied `AgoraHostPort` for values the
111
121
  * platform already knows -- the `issuer`/`mailUpstream`/`filesUpstream`
112
122
  * addresses `agora.ts`'s own doc comment named this issue as the source of.
113
- * `mailProjectId`/`messagingProjectId` still have no live source (chat has no
114
- * admin API yet, https://github.com/wtfalch/chat/issues/40, and mail's own
115
- * OIDC directory does not surface a project id), so a platform-derived Agora
116
- * stays blocked on those two until something records them -- this only stops
117
- * pretending the other three are unavailable once the platform actually
123
+ * `mailProjectId` is the `mail` ZITADEL project `provisionPlatformMail` finds
124
+ * or creates and records under `PLATFORM_MAIL_ZITADEL_PROJECT_SCOPE_KEY`; it
125
+ * is read once mail is enabled and applied (the same gate as `mailUpstream`),
126
+ * and null before that. `messagingProjectId` is not read from the
127
+ * platform: `agora.ts`'s `withRecordedMessagingProject` fills it from
128
+ * the project each org's chat step creates -- this only stops
129
+ * pretending the other values are unavailable once the platform actually
118
130
  * supplies them. `plan-apply.ts`'s `FoundryPlanApply` calls this only when no
119
131
  * host-supplied `agora` port overrides it.
120
132
  */
@@ -1,4 +1,4 @@
1
- import { JOBS_SCHEDULER_KEY, PLATFORM_AI_KEY, PLATFORM_AUTH_KEY, PLATFORM_FILES_INTERNAL_URL_SCOPE_KEY, PLATFORM_FILES_KEY, PLATFORM_MAIL_INTERNAL_URL_SCOPE_KEY, PLATFORM_MAIL_KEY, platformMailMxHost, } from './platform-diff.js';
1
+ import { JOBS_SCHEDULER_KEY, PLATFORM_AI_INTERNAL_URL_SCOPE_KEY, PLATFORM_AI_KEY, PLATFORM_AUTH_KEY, PLATFORM_FILES_INTERNAL_URL_SCOPE_KEY, PLATFORM_FILES_KEY, PLATFORM_MAIL_INTERNAL_URL_SCOPE_KEY, PLATFORM_MAIL_KEY, PLATFORM_MAIL_ZITADEL_PROJECT_SCOPE_KEY, platformMailMxHost, } from './platform-diff.js';
2
2
  import { FILES_MANAGEMENT_KEY_REF, JOBS_ADMIN_TOKEN_REF, PLATFORM_AI_MANAGEMENT_KEY_REF, } from './platform-providers.js';
3
3
  /**
4
4
  * What an org's plan reads when no platform is declared: nothing is ready, so
@@ -76,7 +76,11 @@ noKeyReader = false) {
76
76
  ? { apex: platform.apex, mailHost: platformMailMxHost(platform.apex) }
77
77
  : null,
78
78
  filesReady: filesApplied && missingKeys.files === null,
79
+ filesUrl: filesApplied
80
+ ? (platformRecordedIds[PLATFORM_FILES_INTERNAL_URL_SCOPE_KEY] ?? null)
81
+ : null,
79
82
  aiReady: aiApplied && missingKeys.ai === null,
83
+ aiUrl: aiApplied ? (platformRecordedIds[PLATFORM_AI_INTERNAL_URL_SCOPE_KEY] ?? null) : null,
80
84
  callsReady: platform.sfu.enabled,
81
85
  authReady: platform.auth.enabled && coolifyAppApplied(platformRecordedIds, PLATFORM_AUTH_KEY),
82
86
  jobsReady: jobsApplied && missingKeys.jobs === null,
@@ -89,11 +93,13 @@ noKeyReader = false) {
89
93
  * than requiring a bespoke host-supplied `AgoraHostPort` for values the
90
94
  * platform already knows -- the `issuer`/`mailUpstream`/`filesUpstream`
91
95
  * addresses `agora.ts`'s own doc comment named this issue as the source of.
92
- * `mailProjectId`/`messagingProjectId` still have no live source (chat has no
93
- * admin API yet, https://github.com/wtfalch/chat/issues/40, and mail's own
94
- * OIDC directory does not surface a project id), so a platform-derived Agora
95
- * stays blocked on those two until something records them -- this only stops
96
- * pretending the other three are unavailable once the platform actually
96
+ * `mailProjectId` is the `mail` ZITADEL project `provisionPlatformMail` finds
97
+ * or creates and records under `PLATFORM_MAIL_ZITADEL_PROJECT_SCOPE_KEY`; it
98
+ * is read once mail is enabled and applied (the same gate as `mailUpstream`),
99
+ * and null before that. `messagingProjectId` is not read from the
100
+ * platform: `agora.ts`'s `withRecordedMessagingProject` fills it from
101
+ * the project each org's chat step creates -- this only stops
102
+ * pretending the other values are unavailable once the platform actually
97
103
  * supplies them. `plan-apply.ts`'s `FoundryPlanApply` calls this only when no
98
104
  * host-supplied `agora` port overrides it.
99
105
  */
@@ -103,7 +109,9 @@ export function agoraHostValuesFromPlatform(platform, platformRecordedIds) {
103
109
  const filesReady = platform.files.enabled && coolifyAppApplied(platformRecordedIds, PLATFORM_FILES_KEY);
104
110
  return {
105
111
  issuer: authReady ? `https://${platform.auth.hostname}` : null,
106
- mailProjectId: null,
112
+ mailProjectId: mailReady
113
+ ? (platformRecordedIds[PLATFORM_MAIL_ZITADEL_PROJECT_SCOPE_KEY] ?? null)
114
+ : null,
107
115
  messagingProjectId: null,
108
116
  mailUpstream: mailReady
109
117
  ? (platformRecordedIds[PLATFORM_MAIL_INTERNAL_URL_SCOPE_KEY] ?? null)
@@ -248,10 +248,10 @@ export declare const PLATFORM_MAIL_ADMIN_KEY_DESCRIPTION = "foundry:platform:adm
248
248
  * instance's own URL and the recovery-admin credential this step generated
249
249
  * ({@link PlatformMailDeps.stalwartForInstance}), the same way
250
250
  * `zitadelForInstance` is built from a freshly issued instance's issuer URL.
251
- * The real implementation wraps `@wtfalch/email/postmaster`'s `openInstance`
252
- * (for the `StalwartClient`), a hand-built settings write (see
253
- * `provisionPlatformMail`'s own doc comment for why not `mailObjects`) and
254
- * `StalwartClient.createApiKey`.
251
+ * {@link defaultStalwartForInstance} is the real implementation, wrapping
252
+ * `@wtfalch/email/postmaster`'s `openInstance`, `applyObjects` with
253
+ * `platformMailSettingsObjects` (see `provisionPlatformMail`'s own doc
254
+ * comment for why not `mailObjects`) and `StalwartClient.createApiKey`.
255
255
  */
256
256
  export interface PlatformMailAdminPort {
257
257
  /**
@@ -261,6 +261,8 @@ export interface PlatformMailAdminPort {
261
261
  */
262
262
  applySettings(input: {
263
263
  mailHost: string;
264
+ /** The platform's own apex domain, `platformMailSettingsObjects`'s `usernameDomain`. */
265
+ apex: string;
264
266
  oidc: {
265
267
  issuerUrl: string;
266
268
  audience: string;
@@ -269,6 +271,26 @@ export interface PlatformMailAdminPort {
269
271
  /** `StalwartClient.createApiKey`'s own signature. */
270
272
  createApiKey(description: string, permissions: readonly string[] | 'inherit'): Promise<string>;
271
273
  }
274
+ /**
275
+ * #346: the real {@link PlatformMailAdminPort}, built from the instance's URL
276
+ * and the recovery-admin credential `provisionPlatformMail` just read back.
277
+ * `stalwartForInstance` defaults to this, so a host need not build its own --
278
+ * the narrower settings write `provisionPlatformMail`'s own doc comment
279
+ * explains ("Why not `mailObjects`/`applyObjects`" as-is) instead goes
280
+ * through `platformMailSettingsObjects`, which `applyObjects` resolves and
281
+ * writes the same way `mailObjects` would.
282
+ *
283
+ * Exported only so a unit test can call it directly against a mocked
284
+ * `@wtfalch/email/postmaster` and check that `url`/`user`/`password` reach
285
+ * `openInstance` and `apex` reaches `applySettings` -- every other test
286
+ * reaches this function only indirectly, through an injected
287
+ * `stalwartForInstance` fake.
288
+ */
289
+ export declare function defaultStalwartForInstance(input: {
290
+ url: string;
291
+ user: string;
292
+ password: string;
293
+ }): PlatformMailAdminPort;
272
294
  /**
273
295
  * #169: everything {@link provisionPlatformMail} needs beyond
274
296
  * {@link PlatformStepExecutionContext}, mirroring {@link PlatformAuthDeps}'s
@@ -278,18 +300,22 @@ export interface PlatformMailDeps {
278
300
  coolify: Pick<CoolifyPort, 'findProjectByName' | 'createProject' | 'findOrCreateEnvironment' | 'listServers' | 'findApplicationByName' | 'getApplication' | 'createDockerImageApplication' | 'createDockerfileApplication' | 'raw' | 'listStorages' | 'createPersistentStorage' | 'bulkUpsertEnvs' | 'listEnvs' | 'updateApplication' | 'deploy'>;
279
301
  cloudflare: Pick<CloudflarePort, 'findZoneByApex' | 'findTunnelByName' | 'createTunnel' | 'putTunnelConfig' | 'getTunnelToken' | 'findDnsRecord' | 'createDnsRecord' | 'updateDnsRecord'>;
280
302
  /**
281
- * The ZITADEL resource id an access token's `aud` claim must carry for
282
- * Stalwart's OIDC directory to accept it -- a resource API registered
283
- * against the platform's own sign-in (#168), which neither that step nor
284
- * this one creates. See `provisionPlatformMail`'s own doc comment, "Known
285
- * gaps".
303
+ * Must target the platform's own ZITADEL instance, or `zitadelForInstance`
304
+ * must be set. `provisionPlatformMail` finds or creates a `mail` project
305
+ * in it (the {@link PlatformFilesDeps}/{@link PlatformArchonDeps} pattern)
306
+ * and uses the project's id as the OIDC audience Stalwart's directory
307
+ * checks an access token's `aud` claim against -- #346, no host has to
308
+ * register a resource API for this by hand any more.
286
309
  */
287
- oidcAudience: string;
310
+ zitadel: Pick<ZitadelPort, 'findOrCreateOrg' | 'findOrCreateProject'>;
311
+ /** {@link PlatformArchonDeps.zitadelForInstance}, for Stalwart's OIDC directory. */
312
+ zitadelForInstance?: (issuerUrl: string) => PlatformMailDeps['zitadel'];
288
313
  /**
289
314
  * {@link PlatformMailAdminPort}, built from the instance this step just
290
- * deployed and the recovery credential it just read back.
315
+ * deployed and the recovery credential it just read back. Defaults to
316
+ * {@link defaultStalwartForInstance}.
291
317
  */
292
- stalwartForInstance(input: {
318
+ stalwartForInstance?(input: {
293
319
  url: string;
294
320
  user: string;
295
321
  password: string;
@@ -1,10 +1,11 @@
1
1
  import { createHash, createPrivateKey, generateKeyPairSync, randomBytes, randomUUID, sign, } from 'node:crypto';
2
2
  import { isIPv4 } from 'node:net';
3
+ import { applyObjects, openInstance, platformMailSettingsObjects } from '@wtfalch/email/postmaster';
3
4
  import { VERSIONS, floatingTagBlocked } from '../bootstrap/versions.js';
4
5
  import { redactSecrets } from '../calls-sfu-deploy-support.js';
5
6
  import { ensureCloudflaredConnector } from './cloudflared-connector.js';
6
7
  import { ACCESS_POLICY_NAME, isNotFoundError, isRunningOrStarting } from './executor.js';
7
- import { JOBS_SCHEDULER_KEY, PLATFORM_AI_APPLIED_GITREF_SCOPE_KEY, PLATFORM_AI_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_AI_DEPLOY_PENDING_SCOPE_KEY, PLATFORM_AI_INTERNAL_URL_SCOPE_KEY, PLATFORM_AI_KEY, PLATFORM_AI_MANAGEMENT_KEY_CONFLICT_SCOPE_KEY, PLATFORM_AI_MANAGEMENT_KEY_SCOPE_KEY, PLATFORM_AI_MANAGEMENT_KEY_UNDELIVERED_SCOPE_KEY, PLATFORM_AI_MIGRATE_BLOCKED_SCOPE_KEY, PLATFORM_AI_ORGANISATION_SCOPE_KEY, PLATFORM_AI_SIGNING_KEY_SET_SCOPE_KEY, PLATFORM_AI_SIGNING_KEY_UNDELIVERED_SCOPE_KEY, PLATFORM_ARCHON_APPLIED_BROKER_SCOPE_KEY, PLATFORM_ARCHON_APPLIED_GIT_REF_SCOPE_KEY, PLATFORM_ARCHON_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_ARCHON_CONNECTOR_APP_SCOPE_KEY, PLATFORM_ARCHON_GATE_CLOSED_SCOPE_KEY, PLATFORM_ARCHON_KEY, PLATFORM_ARCHON_MASTER_CLOUDFLARE_TOKEN_REF, PLATFORM_ARCHON_MASTER_COOLIFY_TOKEN_REF, PLATFORM_ARCHON_MASTER_COOLIFY_URL_REF, PLATFORM_AUTH_ADMIN_UNDELIVERED_SCOPE_KEY, PLATFORM_AUTH_ADMIN_USER_SCOPE_KEY, PLATFORM_AUTH_BROKER_DEPLOYED_SCOPE_KEY, PLATFORM_AUTH_BROKER_SCOPE_KEY, PLATFORM_AUTH_CONNECTOR_APP_SCOPE_KEY, PLATFORM_AUTH_INSTANCE_SCOPE_KEY, PLATFORM_AUTH_INSTANCE_UNDELIVERED_SCOPE_KEY, PLATFORM_AUTH_KEY, PLATFORM_AUTH_LOGIN_CLIENT_UNDELIVERED_SCOPE_KEY, PLATFORM_AUTH_LOGIN_CLIENT_USER_SCOPE_KEY, PLATFORM_AUTH_ZITADEL_APPLIED_IMAGE_TAG_SCOPE_KEY, PLATFORM_BACKUP_DATABASE_SCOPE_KEYS, PLATFORM_BACKUP_SERVICE_KEYS, PLATFORM_FILES_APPLIED_GIT_REF_SCOPE_KEY, PLATFORM_FILES_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_FILES_INTERNAL_URL_SCOPE_KEY, PLATFORM_FILES_INTROSPECTION_SCOPE_KEY, PLATFORM_FILES_INTROSPECTION_UNDELIVERED_SCOPE_KEY, PLATFORM_FILES_KEY, PLATFORM_FILES_MANAGEMENT_KEY_SCOPE_KEY, PLATFORM_FILES_MANAGEMENT_UNDELIVERED_SCOPE_KEY, PLATFORM_FILES_ZITADEL_PROJECT_SCOPE_KEY, PLATFORM_INTEGRATIONS_APPLIED_GITREF_SCOPE_KEY, PLATFORM_INTEGRATIONS_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_INTEGRATIONS_KEY, PLATFORM_INTEGRATIONS_KEY_SET_SCOPE_KEY, PLATFORM_INTEGRATIONS_KEY_UNDELIVERED_SCOPE_KEY, PLATFORM_MAIL_ADMIN_KEY_SCOPE_KEY, PLATFORM_MAIL_ADMIN_UNDELIVERED_SCOPE_KEY, PLATFORM_MAIL_APPLIED_GIT_REF_SCOPE_KEY, PLATFORM_MAIL_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_MAIL_CONNECTOR_APP_SCOPE_KEY, PLATFORM_MAIL_INTERNAL_URL_SCOPE_KEY, PLATFORM_MAIL_KEY, parseBrokerDeployRecord, parseLoginClientRecord, platformMailMxHost, } from './platform-diff.js';
8
+ import { JOBS_SCHEDULER_KEY, PLATFORM_AI_APPLIED_GITREF_SCOPE_KEY, PLATFORM_AI_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_AI_DEPLOY_PENDING_SCOPE_KEY, PLATFORM_AI_INTERNAL_URL_SCOPE_KEY, PLATFORM_AI_KEY, PLATFORM_AI_MANAGEMENT_KEY_CONFLICT_SCOPE_KEY, PLATFORM_AI_MANAGEMENT_KEY_SCOPE_KEY, PLATFORM_AI_MANAGEMENT_KEY_UNDELIVERED_SCOPE_KEY, PLATFORM_AI_MIGRATE_BLOCKED_SCOPE_KEY, PLATFORM_AI_ORGANISATION_SCOPE_KEY, PLATFORM_AI_SIGNING_KEY_SET_SCOPE_KEY, PLATFORM_AI_SIGNING_KEY_UNDELIVERED_SCOPE_KEY, PLATFORM_ARCHON_APPLIED_BROKER_SCOPE_KEY, PLATFORM_ARCHON_APPLIED_GIT_REF_SCOPE_KEY, PLATFORM_ARCHON_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_ARCHON_CONNECTOR_APP_SCOPE_KEY, PLATFORM_ARCHON_GATE_CLOSED_SCOPE_KEY, PLATFORM_ARCHON_KEY, PLATFORM_ARCHON_MASTER_CLOUDFLARE_TOKEN_REF, PLATFORM_ARCHON_MASTER_COOLIFY_TOKEN_REF, PLATFORM_ARCHON_MASTER_COOLIFY_URL_REF, PLATFORM_AUTH_ADMIN_UNDELIVERED_SCOPE_KEY, PLATFORM_AUTH_ADMIN_USER_SCOPE_KEY, PLATFORM_AUTH_BROKER_DEPLOYED_SCOPE_KEY, PLATFORM_AUTH_BROKER_SCOPE_KEY, PLATFORM_AUTH_CONNECTOR_APP_SCOPE_KEY, PLATFORM_AUTH_INSTANCE_SCOPE_KEY, PLATFORM_AUTH_INSTANCE_UNDELIVERED_SCOPE_KEY, PLATFORM_AUTH_KEY, PLATFORM_AUTH_LOGIN_CLIENT_UNDELIVERED_SCOPE_KEY, PLATFORM_AUTH_LOGIN_CLIENT_USER_SCOPE_KEY, PLATFORM_AUTH_ZITADEL_APPLIED_IMAGE_TAG_SCOPE_KEY, PLATFORM_BACKUP_DATABASE_SCOPE_KEYS, PLATFORM_BACKUP_SERVICE_KEYS, PLATFORM_FILES_APPLIED_GIT_REF_SCOPE_KEY, PLATFORM_FILES_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_FILES_INTERNAL_URL_SCOPE_KEY, PLATFORM_FILES_INTROSPECTION_SCOPE_KEY, PLATFORM_FILES_INTROSPECTION_UNDELIVERED_SCOPE_KEY, PLATFORM_FILES_KEY, PLATFORM_FILES_MANAGEMENT_KEY_SCOPE_KEY, PLATFORM_FILES_MANAGEMENT_UNDELIVERED_SCOPE_KEY, PLATFORM_FILES_ZITADEL_PROJECT_SCOPE_KEY, PLATFORM_INTEGRATIONS_APPLIED_GITREF_SCOPE_KEY, PLATFORM_INTEGRATIONS_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_INTEGRATIONS_KEY, PLATFORM_INTEGRATIONS_KEY_SET_SCOPE_KEY, PLATFORM_INTEGRATIONS_KEY_UNDELIVERED_SCOPE_KEY, PLATFORM_MAIL_ADMIN_KEY_SCOPE_KEY, PLATFORM_MAIL_ADMIN_UNDELIVERED_SCOPE_KEY, PLATFORM_MAIL_APPLIED_GIT_REF_SCOPE_KEY, PLATFORM_MAIL_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_MAIL_CONNECTOR_APP_SCOPE_KEY, PLATFORM_MAIL_INTERNAL_URL_SCOPE_KEY, PLATFORM_MAIL_KEY, PLATFORM_MAIL_ZITADEL_PROJECT_SCOPE_KEY, parseBrokerDeployRecord, parseLoginClientRecord, platformMailMxHost, } from './platform-diff.js';
8
9
  import { applyCallsSfuPlatformStep } from './platform-sfu.js';
9
10
  import { CreateInstanceError, createZitadelInstance, generateSystemApiKeypair, instanceWithoutBearerMessage, systemApiUsersEnv, } from './zitadel-system-api.js';
10
11
  /** The key `runPlatformSteps` records the apex zone's id under (`system:kind:key`). */
@@ -1389,6 +1390,32 @@ const PLATFORM_MAIL_DEPLOYED_GIT_REF_SCOPE_KEY = 'mail_deployed_git_ref';
1389
1390
  * {@link PLATFORM_MAIL_ADMIN_UNDELIVERED_SCOPE_KEY} records in that case.
1390
1391
  */
1391
1392
  export const PLATFORM_MAIL_ADMIN_KEY_DESCRIPTION = 'foundry:platform:admin';
1393
+ /**
1394
+ * #346: the real {@link PlatformMailAdminPort}, built from the instance's URL
1395
+ * and the recovery-admin credential `provisionPlatformMail` just read back.
1396
+ * `stalwartForInstance` defaults to this, so a host need not build its own --
1397
+ * the narrower settings write `provisionPlatformMail`'s own doc comment
1398
+ * explains ("Why not `mailObjects`/`applyObjects`" as-is) instead goes
1399
+ * through `platformMailSettingsObjects`, which `applyObjects` resolves and
1400
+ * writes the same way `mailObjects` would.
1401
+ *
1402
+ * Exported only so a unit test can call it directly against a mocked
1403
+ * `@wtfalch/email/postmaster` and check that `url`/`user`/`password` reach
1404
+ * `openInstance` and `apex` reaches `applySettings` -- every other test
1405
+ * reaches this function only indirectly, through an injected
1406
+ * `stalwartForInstance` fake.
1407
+ */
1408
+ export function defaultStalwartForInstance(input) {
1409
+ const instance = openInstance({ url: input.url, secret: input.password, user: input.user });
1410
+ return {
1411
+ async applySettings({ mailHost, apex, oidc }) {
1412
+ await applyObjects(instance.client, platformMailSettingsObjects({ apex, mailHost, oidc }), () => { });
1413
+ },
1414
+ createApiKey: (description, permissions) => instance.client.createApiKey(description, permissions),
1415
+ };
1416
+ }
1417
+ /** The ZITADEL project and resource api Stalwart's own OIDC directory checks access tokens' `aud` claim against (#346). */
1418
+ const PLATFORM_MAIL_ZITADEL_PROJECT_NAME = 'mail';
1392
1419
  /**
1393
1420
  * #169: makes `coolify:app` keyed {@link PLATFORM_MAIL_KEY} true -- Stalwart
1394
1421
  * on Coolify with its own persistent storage, reached at
@@ -1423,8 +1450,9 @@ export const PLATFORM_MAIL_ADMIN_KEY_DESCRIPTION = 'foundry:platform:admin';
1423
1450
  * 0006), and this step provisions no Cloudflare DNS token for Stalwart's own
1424
1451
  * ACME either. Calling it as-is would mint an outbound relay this platform
1425
1452
  * does not want and a credential requirement this issue does not cover, so
1426
- * {@link PlatformMailAdminPort.applySettings} is a narrower, hand-built
1427
- * settings write instead: the default hostname and the OIDC directory only.
1453
+ * {@link PlatformMailAdminPort.applySettings} is a narrower settings write
1454
+ * instead, through `platformMailSettingsObjects`: the default hostname and
1455
+ * the OIDC directory only.
1428
1456
  *
1429
1457
  * Known gaps, left for review (like #168's own "the PR stays open for
1430
1458
  * William's review"):
@@ -1432,11 +1460,6 @@ export const PLATFORM_MAIL_ADMIN_KEY_DESCRIPTION = 'foundry:platform:admin';
1432
1460
  * Stalwart's own self-signed certificate until a follow-up wires a real
1433
1461
  * one (`mailObjects`'s `AcmeProvider`/`DnsServer` shape, minus the Resend
1434
1462
  * relay it also carries).
1435
- * - **`oidcAudience` is supplied, not resolved.** #168 mints foundry's own
1436
- * ZITADEL admin machine user but registers no resource API for Stalwart's
1437
- * access-token `aud` check; whoever supplies
1438
- * {@link PlatformMailDeps.oidcAudience} must have registered one by hand
1439
- * until that is built.
1440
1463
  * - **The Lettermint team token the org app step reads still comes from
1441
1464
  * wherever the host already reads it, not held keys.** Needs
1442
1465
  * `StepExecutionContext` to carry the platform, which `plan-apply.ts`'s
@@ -1446,6 +1469,17 @@ export const PLATFORM_MAIL_ADMIN_KEY_DESCRIPTION = 'foundry:platform:admin';
1446
1469
  * instance -- `diff.ts`'s `planMail` already blocks the step until the
1447
1470
  * platform's own mail is applied, so the fallback only ever served a
1448
1471
  * plan recorded before that, and now fails loudly instead.)
1472
+ * - **A ZITADEL client's token may not carry the mail project's id in
1473
+ * `aud` by default.** #346's own "Human steps": confirm this on the first
1474
+ * live run, and if it does not, the client must request the mail
1475
+ * project's audience scope.
1476
+ * - **The admin API key is minted with `'inherit'` permissions**, not a
1477
+ * narrower set scoped to only what `applySettings` needs.
1478
+ * - **The undelivered-key guard records before `createApiKey` mints**, not
1479
+ * after -- so a crash between the two records an id (`admin.createApiKey`
1480
+ * has not even run) that no key was ever minted for. Harmless today
1481
+ * (the guard only ever blocks a retry and names an id to revoke by), but
1482
+ * worth tightening if `createApiKey` ever starts returning its own id.
1449
1483
  */
1450
1484
  async function provisionPlatformMail(context, deps) {
1451
1485
  const { platform } = context;
@@ -1610,6 +1644,57 @@ async function provisionPlatformMail(context, deps) {
1610
1644
  proxied: false,
1611
1645
  });
1612
1646
  }
1647
+ // ---- the mail ZITADEL project, in the platform's sign-in -------------------
1648
+ // #346: found or created on every run, like the files/Archon steps' own
1649
+ // projects -- its id is the OIDC audience Stalwart's directory checks an
1650
+ // access token's `aud` claim against, so no host has to register a
1651
+ // resource API for this by hand.
1652
+ const issuer = `https://${platform.auth.hostname}`;
1653
+ const zitadel = deps.zitadelForInstance?.(issuer) ?? deps.zitadel;
1654
+ const { org } = await zitadel.findOrCreateOrg(PLATFORM_AUTH_ORG_NAME);
1655
+ const { project } = await zitadel.findOrCreateProject(org.id, PLATFORM_MAIL_ZITADEL_PROJECT_NAME);
1656
+ const recordedProjectId = recorded[PLATFORM_MAIL_ZITADEL_PROJECT_SCOPE_KEY];
1657
+ const adminKeyRecorded = Boolean(recorded[PLATFORM_MAIL_ADMIN_KEY_SCOPE_KEY]);
1658
+ if (adminKeyRecorded && recordedProjectId !== project.id) {
1659
+ // #346 round 3: the OIDC-directory write below (next block) runs only
1660
+ // once, while the admin key is unrecorded -- so once it is recorded, a
1661
+ // changed project id (the project was deleted and recreated, ZITADEL was
1662
+ // re-bootstrapped, or `recordedProjectId` is simply missing, from a
1663
+ // platform applied before this change) has no other path back to a
1664
+ // correct audience. Re-apply the settings write with the new id, over
1665
+ // the same recovery-admin credential the first run reads back, and mint
1666
+ // no new key -- that stays gated on the admin key being unrecorded,
1667
+ // below. The record is written only after `applySettings` succeeds, so a
1668
+ // throw here leaves the old (still-required) id in place for the next
1669
+ // run to retry. A project deleted out of band is noticed only here, on
1670
+ // the next plan/apply -- the same way the files/Archon steps' own
1671
+ // projects are.
1672
+ await waitForMailInstance(`https://${hostname}${STALWART_READY_PATH}`, runtime, sleep);
1673
+ const recoveryEnv = (await deps.coolify.listEnvs(appId)).find((e) => e.key === 'STALWART_RECOVERY_ADMIN' && !e.is_preview);
1674
+ if (!recoveryEnv) {
1675
+ throw new Error('platform:mail: STALWART_RECOVERY_ADMIN is not set on the app; cannot authenticate to re-apply the OIDC audience');
1676
+ }
1677
+ const password = recoveryEnv.value.slice(`${STALWART_RECOVERY_USER}:`.length);
1678
+ try {
1679
+ const admin = (deps.stalwartForInstance ?? defaultStalwartForInstance)({
1680
+ url: `https://${hostname}`,
1681
+ user: STALWART_RECOVERY_USER,
1682
+ password,
1683
+ });
1684
+ await admin.applySettings({
1685
+ mailHost,
1686
+ apex: platform.apex,
1687
+ oidc: { issuerUrl: issuer, audience: project.id },
1688
+ });
1689
+ await record(PLATFORM_MAIL_ZITADEL_PROJECT_SCOPE_KEY, project.id);
1690
+ }
1691
+ catch (error) {
1692
+ throw redactedMailError(error, [password]);
1693
+ }
1694
+ }
1695
+ else if (!recordedProjectId) {
1696
+ await record(PLATFORM_MAIL_ZITADEL_PROJECT_SCOPE_KEY, project.id);
1697
+ }
1613
1698
  // ---- OIDC directory and foundry's own admin API key, once --------------------
1614
1699
  if (!recorded[PLATFORM_MAIL_ADMIN_KEY_SCOPE_KEY]) {
1615
1700
  const lost = recorded[PLATFORM_MAIL_ADMIN_UNDELIVERED_SCOPE_KEY];
@@ -1623,14 +1708,15 @@ async function provisionPlatformMail(context, deps) {
1623
1708
  }
1624
1709
  const password = recoveryEnv.value.slice(`${STALWART_RECOVERY_USER}:`.length);
1625
1710
  try {
1626
- const admin = deps.stalwartForInstance({
1711
+ const admin = (deps.stalwartForInstance ?? defaultStalwartForInstance)({
1627
1712
  url: `https://${hostname}`,
1628
1713
  user: STALWART_RECOVERY_USER,
1629
1714
  password,
1630
1715
  });
1631
1716
  await admin.applySettings({
1632
1717
  mailHost,
1633
- oidc: { issuerUrl: `https://${platform.auth.hostname}`, audience: deps.oidcAudience },
1718
+ apex: platform.apex,
1719
+ oidc: { issuerUrl: issuer, audience: project.id },
1634
1720
  });
1635
1721
  // Recorded and persisted before the secret leaves this function: if
1636
1722
  // delivery fails for good, the next plan reports the lost key rather
@@ -1696,9 +1782,9 @@ async function resolveMailServer(coolify, recordedUuid) {
1696
1782
  }
1697
1783
  /**
1698
1784
  * The mail step's own reasons it cannot run yet, as fixed text: missing
1699
- * dependencies or audience, a floating image tag, a server with no usable
1700
- * address. Skipped once the step is applied at the declared values, so a
1701
- * live app is not blocked by a later Coolify change to the server's address.
1785
+ * dependencies, a floating image tag, a server with no usable address.
1786
+ * Skipped once the step is applied at the declared values, so a live app is
1787
+ * not blocked by a later Coolify change to the server's address.
1702
1788
  */
1703
1789
  async function mailBlockedReason(platform, mail, recordedIds) {
1704
1790
  if (!mail) {
@@ -1710,9 +1796,6 @@ async function mailBlockedReason(platform, mail, recordedIds) {
1710
1796
  recordedIds[PLATFORM_MAIL_INTERNAL_URL_SCOPE_KEY]) {
1711
1797
  return null;
1712
1798
  }
1713
- if (!mail.oidcAudience || mail.oidcAudience.trim() === '') {
1714
- return "the platform's mail server is blocked, not planned: the host supplies no oidcAudience, the ZITADEL resource id Stalwart's OIDC directory checks access tokens against";
1715
- }
1716
1799
  const tagBlocked = mailImageTagBlocked(platform.mail.gitRef);
1717
1800
  if (tagBlocked)
1718
1801
  return tagBlocked;