@wtfalch/foundry 0.32.0 → 0.34.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 (74) hide show
  1. package/dist/bootstrap/bootstrap.d.ts +18 -1
  2. package/dist/bootstrap/bootstrap.js +167 -28
  3. package/dist/bootstrap/declare.d.ts +5 -1
  4. package/dist/bootstrap/declare.js +3 -1
  5. package/dist/bootstrap/index.d.ts +1 -1
  6. package/dist/bootstrap/versions.d.ts +4 -0
  7. package/dist/calls-sfu.d.ts +10 -0
  8. package/dist/calls-sfu.js +41 -0
  9. package/dist/index.d.ts +18 -8
  10. package/dist/index.js +8 -3
  11. package/dist/migrations/0051_org_services.sql +13 -0
  12. package/dist/migrations/0052_platform_mail_relay.sql +8 -0
  13. package/dist/org.d.ts +24 -2
  14. package/dist/org.js +42 -0
  15. package/dist/plan/boule.d.ts +24 -1
  16. package/dist/plan/boule.js +29 -1
  17. package/dist/plan/calls-matrix.d.ts +9 -0
  18. package/dist/plan/calls-matrix.js +17 -0
  19. package/dist/plan/cloudflared-connector.d.ts +7 -1
  20. package/dist/plan/cloudflared-connector.js +14 -1
  21. package/dist/plan/diff.d.ts +32 -1
  22. package/dist/plan/diff.js +383 -26
  23. package/dist/plan/executor.d.ts +177 -7
  24. package/dist/plan/executor.js +738 -51
  25. package/dist/plan/health-check-settings.d.ts +28 -0
  26. package/dist/plan/health-check-settings.js +36 -0
  27. package/dist/plan/health-gaps.d.ts +33 -0
  28. package/dist/plan/health-gaps.js +89 -0
  29. package/dist/plan/health-targets.js +1 -1
  30. package/dist/plan/held-key-refs.d.ts +10 -0
  31. package/dist/plan/held-key-refs.js +10 -0
  32. package/dist/plan/index.d.ts +16 -6
  33. package/dist/plan/index.js +8 -3
  34. package/dist/plan/live.d.ts +5 -2
  35. package/dist/plan/live.js +15 -2
  36. package/dist/plan/locked-hostnames.d.ts +5 -2
  37. package/dist/plan/locked-hostnames.js +9 -1
  38. package/dist/plan/login-host.d.ts +62 -2
  39. package/dist/plan/login-host.js +104 -1
  40. package/dist/plan/mail-relay.d.ts +120 -0
  41. package/dist/plan/mail-relay.js +323 -0
  42. package/dist/plan/org-names.d.ts +24 -0
  43. package/dist/plan/org-names.js +52 -0
  44. package/dist/plan/own-diff.d.ts +169 -0
  45. package/dist/plan/own-diff.js +432 -0
  46. package/dist/plan/own-dns.d.ts +6 -0
  47. package/dist/plan/own-dns.js +15 -0
  48. package/dist/plan/own-limits.d.ts +22 -0
  49. package/dist/plan/own-limits.js +101 -0
  50. package/dist/plan/own-services.d.ts +193 -0
  51. package/dist/plan/own-services.js +167 -0
  52. package/dist/plan/own-sfu.d.ts +69 -0
  53. package/dist/plan/own-sfu.js +255 -0
  54. package/dist/plan/plan-apply.d.ts +101 -7
  55. package/dist/plan/plan-apply.js +216 -12
  56. package/dist/plan/platform-diff.d.ts +61 -12
  57. package/dist/plan/platform-diff.js +140 -34
  58. package/dist/plan/platform-org.d.ts +36 -0
  59. package/dist/plan/platform-org.js +96 -2
  60. package/dist/plan/platform-providers.d.ts +407 -12
  61. package/dist/plan/platform-providers.js +947 -422
  62. package/dist/plan/sign-in-org.d.ts +99 -0
  63. package/dist/plan/sign-in-org.js +151 -0
  64. package/dist/plan/zitadel-login-policy.js +1 -1
  65. package/dist/plan/zitadel-system-api.d.ts +22 -1
  66. package/dist/plan/zitadel-system-api.js +49 -8
  67. package/dist/plan.d.ts +18 -0
  68. package/dist/platform.d.ts +8 -0
  69. package/dist/slug.js +1 -0
  70. package/dist/store/postgres.js +51 -2
  71. package/dist/store/schema.d.ts +51 -0
  72. package/dist/store/schema.js +5 -0
  73. package/package.json +1 -1
  74. package/versions.json +3 -1
@@ -1,20 +1,25 @@
1
1
  import { createHash, randomUUID } from 'node:crypto';
2
2
  import { VERSIONS, floatingTagBlocked } from '../bootstrap/versions.js';
3
- import { validateHosting } from '../org.js';
3
+ import { sfuSlotPorts } from '../calls-sfu.js';
4
+ import { ownsServices, validateHosting } from '../org.js';
4
5
  import { PLATFORM_SERVICE_KEYS } from '../platform.js';
5
6
  import { AlreadyRunningError, StepFailedError } from '../runner/errors.js';
6
7
  import { StaleLeaseError, } from '../store.js';
7
8
  import { AdoptRefusedError, adoptCandidates, adoptedKeysUnreadable, adoptedScopeKey, parseAdoptSnapshot, planAdoptFrom, } from './adopt.js';
8
9
  import { NO_AGORA_HOST_VALUES, readAgoraHost, } from './agora.js';
9
10
  import { unconfirmedUpgrades } from './confirm-upgrade.js';
10
- import { AI_KEY_UNDELIVERED_SCOPE_KEY, JOBS_REGISTRATION_UNDELIVERED_SCOPE_KEY, computePlanDetail, toPlan, } from './diff.js';
11
+ import { AI_KEY_UNDELIVERED_SCOPE_KEY, JOBS_REGISTRATION_UNDELIVERED_SCOPE_KEY, computePlanDetail, isPlatformOwnOrg, toPlan, } from './diff.js';
11
12
  import { entryAiUndeliveredScopeKey } from './entry-ai-key.js';
12
13
  import { BOULE_EDGE_ENTRY_KEY, entryEdgeRetiredScopeKey, entryEdgeUndeliveredScopeKey, } from './entry-edge-key.js';
13
14
  import { FILES_ROOT_UNDELIVERED_SCOPE_KEY, entryFilesUndeliveredScopeKey, } from './entry-files-key.js';
14
15
  import { readLivePlatformResources, readLiveResources, } from './live.js';
15
16
  import { lockedHostnames } from './locked-hostnames.js';
17
+ import { agoraIssuerHostFor, loginHostServesAgora, readLoginHostDiscovery, } from './login-host.js';
18
+ import { OWN_MAIL_SMTP_INTERNAL_SCOPE_KEY, PLATFORM_MAIL_RELAY_ROUTES_SCOPE_KEY, } from './mail-relay.js';
19
+ import { findNameClashes, nameClashMessage } from './org-names.js';
20
+ import { OWN_FILES_R2_UNDELIVERED_SCOPE_KEY, orgTarget } from './own-services.js';
16
21
  import { PLATFORM_ARCHON_KEY, PLATFORM_FILES_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_FILES_KEY, appStepKey, planPlatform as computePlatformPlanDetail, toPlatformPlan, } from './platform-diff.js';
17
- import { DEFAULT_ORG_PLATFORM_READINESS, ORG_PLATFORM_KEY_REFS, agoraHostValuesFromPlatform, dnsBlockedReason, heldPlatformKeyRefs, orgPlatformReadinessFrom, } from './platform-org.js';
22
+ import { DEFAULT_ORG_PLATFORM_READINESS, NO_DOMAIN_MAIL_APEX, ORG_PLATFORM_KEY_REFS, agoraHostValuesForOwn, agoraHostValuesFromPlatform, dnsBlockedReason, heldOwnKeyRefs, heldPlatformKeyRefs, orgPlatformReadinessFor, orgPlatformReadinessFrom, serviceGitRefsOf, } from './platform-org.js';
18
23
  import { PLATFORM_AUTH_BROKER_MAIL_KEY_REF } from './platform-providers.js';
19
24
  import { liveWorkers, workerTraceScopeKeys } from './worker.js';
20
25
  /** #441: the lease scope that serializes every mutation of one company, and the one for the shared platform. */
@@ -126,6 +131,10 @@ const HOSTNAME_STEPS = new Set([
126
131
  * destructive or migrating step without the org's name typed, or one the
127
132
  * actor is not authorized to run.
128
133
  */
134
+ /** `unmanaged` key of the fixed line for an own org on a host with no `ownServices` port. */
135
+ const OWN_SERVICES_PORT_ABSENT_KEY = 'own-services:port';
136
+ /** `unmanaged` key of the fixed line for an own org whose scoped ports have no files-organisation port. */
137
+ const OWN_FILES_ORGANISATION_PORT_ABSENT_KEY = 'own-services:files-port';
129
138
  export class FoundryPlanApply {
130
139
  store;
131
140
  readers;
@@ -137,12 +146,17 @@ export class FoundryPlanApply {
137
146
  agora;
138
147
  jobsScheduler;
139
148
  loginHostBindings;
149
+ loginHostDiscovery;
140
150
  zitadelIdTokenUserinfo;
141
151
  broker;
142
152
  edgeWorkers;
153
+ ownerInviteMail;
143
154
  hostFiles;
144
155
  hostAi;
145
156
  hostEdge;
157
+ hostOwnServices;
158
+ sfuPortsOpen;
159
+ onlySteps;
146
160
  now;
147
161
  leaseTtlMs;
148
162
  appliedVersions;
@@ -159,12 +173,17 @@ export class FoundryPlanApply {
159
173
  this.agora = options.agora;
160
174
  this.jobsScheduler = options.jobsScheduler ?? false;
161
175
  this.loginHostBindings = options.loginHostBindings ?? false;
176
+ this.loginHostDiscovery = options.loginHostDiscovery;
162
177
  this.zitadelIdTokenUserinfo = options.zitadelIdTokenUserinfo ?? false;
163
178
  this.broker = options.broker ?? false;
164
179
  this.edgeWorkers = options.edgeWorkers ?? false;
180
+ this.ownerInviteMail = options.ownerInviteMail ?? false;
165
181
  this.hostFiles = options.filesOrganisation ?? false;
166
182
  this.hostAi = options.aiOrganisation ?? false;
167
183
  this.hostEdge = options.edge ?? false;
184
+ this.hostOwnServices = options.ownServices ?? false;
185
+ this.sfuPortsOpen = options.sfuPortsOpen;
186
+ this.onlySteps = options.onlySteps;
168
187
  this.now = options.now ?? (() => new Date());
169
188
  this.leaseTtlMs = options.leaseTtlMs ?? DEFAULT_LEASE_TTL_MS;
170
189
  this.appliedVersions = options.appliedVersions;
@@ -893,29 +912,94 @@ export class FoundryPlanApply {
893
912
  });
894
913
  return applied;
895
914
  }
915
+ /** An own org with calls on: the host's answer about its slot's ports. A failure or a slow answer is "not open". */
916
+ async readSfuPortsOpen(org) {
917
+ const slot = org.services?.sfuSlot;
918
+ if (!this.sfuPortsOpen || !ownsServices(org) || !org.calls.enabled || !slot)
919
+ return false;
920
+ let ports;
921
+ try {
922
+ ports = sfuSlotPorts(slot);
923
+ }
924
+ catch {
925
+ return false;
926
+ }
927
+ const timeout = new Promise((resolve) => setTimeout(() => resolve(false), 5_000));
928
+ return Promise.race([this.sfuPortsOpen(ports).catch(() => false), timeout]);
929
+ }
896
930
  async computeFreshPlan(id) {
897
931
  const org = await this.store.get(id);
898
932
  if (!org)
899
933
  throw new DeclaredOrgNotFoundError(id);
900
- const [live, applied, platformReadiness, agoraHost, archonUrl] = await Promise.all([
901
- readLiveResources(id, this.readers),
902
- this.store.getAppliedState(id),
903
- this.orgPlatformReadiness(),
904
- this.orgAgoraHost(id),
934
+ const applied = await this.store.getAppliedState(id);
935
+ const [live, platformReadiness, agoraHost, archonUrl] = await Promise.all([
936
+ readLiveResources(id, this.readers, org, applied?.serviceIds ?? {}),
937
+ this.orgPlatformReadiness(org, applied?.serviceIds ?? {}),
938
+ this.orgAgoraHost(org, applied?.serviceIds ?? {}),
905
939
  this.orgArchonUrl(),
906
940
  ]);
907
941
  const otherSlugs = (await this.store.list()).filter((o) => o.id !== id).map((o) => o.slug);
908
942
  const platformHostnames = await this.platformServiceHostnames();
943
+ const recorded = applied?.serviceIds ?? {};
944
+ // Read only for an org Agora may switch and whose login host is applied.
945
+ const issuerHost = agoraIssuerHostFor(org, isPlatformOwnOrg(org, platformReadiness.platformOwnerOrgId)).host;
946
+ const loginHostServesDiscovery = issuerHost !== null && loginHostServesAgora(issuerHost, org, recorded)
947
+ ? await readLoginHostDiscovery(this.loginHostDiscovery, issuerHost)
948
+ : false;
949
+ const sfuPortsOpen = await this.readSfuPortsOpen(org);
950
+ const resumable = await this.ownResumableRefs(org);
909
951
  const detail = computePlanDetail(org, live, applied?.serviceIds ?? {}, agoraHost, this.jobsScheduler, platformReadiness, {
910
952
  loginHostBindings: this.loginHostBindings,
953
+ loginHostServesDiscovery,
911
954
  zitadelIdTokenUserinfo: this.zitadelIdTokenUserinfo,
912
955
  broker: this.broker,
913
956
  edgeWorkers: this.edgeWorkers,
957
+ ownerInviteMail: this.ownerInviteMail,
914
958
  otherSlugs,
915
959
  now: () => this.now().getTime(),
916
960
  archonUrl,
917
961
  platformHostnames,
962
+ ownServices: this.hostOwnServices,
963
+ ownMailKeyHeld: await this.ownMailKeyHeld(org),
964
+ ownMailKeyMintable: this.executor.ownMailKeyMintable?.() ?? false,
965
+ ownJobsTokenHeld: resumable.jobs,
966
+ ownSfuKeysHeld: resumable.sfu,
967
+ ...(ownsServices(org) ? { mailRelayApplied: await this.mailRelayApplied() } : {}),
968
+ sfuPortsOpen,
969
+ ownFilesDeps: this.executor.ownFilesDeps?.(),
970
+ ...(ownsServices(org) && this.executor.ownFilesR2Ports
971
+ ? { ownFilesR2Ports: await this.executor.ownFilesR2Ports(org, recorded) }
972
+ : {}),
918
973
  });
974
+ if (ownsServices(org) && !this.hostOwnServices) {
975
+ detail.unmanaged.push({
976
+ description: `${org.slug} runs its own services, and this host has no ownServices port`,
977
+ key: OWN_SERVICES_PORT_ABSENT_KEY,
978
+ });
979
+ }
980
+ if (ownsServices(org) &&
981
+ this.hostOwnServices &&
982
+ detail.plannedSteps.some(({ action }) => action.system === 'files' && action.kind === 'organisation') &&
983
+ (await this.executor.ownFilesOrganisationPort?.(org, recorded)) === false) {
984
+ detail.plannedSteps = detail.plannedSteps.filter(({ action }) => !(action.system === 'files' && action.kind === 'organisation'));
985
+ detail.unmanaged.push({
986
+ description: `${org.slug}'s Files organisation is blocked, not planned: the host gave the company no files-organisation port`,
987
+ key: OWN_FILES_ORGANISATION_PORT_ABSENT_KEY,
988
+ });
989
+ }
990
+ const clashes = findNameClashes(org, await this.store.list());
991
+ for (const source of ['own', 'hosting']) {
992
+ const clash = clashes.find((c) => c.source === source);
993
+ if (!clash)
994
+ continue;
995
+ detail.plannedSteps = detail.plannedSteps.filter(({ action }) => source === 'own'
996
+ ? !(action.system === 'coolify' && action.kind.startsWith('own-'))
997
+ : !(action.system === 'coolify' && (action.kind === 'app' || action.kind === 'database')));
998
+ detail.unmanaged.push({
999
+ description: `${org.slug}'s ${source === 'own' ? 'own services are' : 'hosting is'} blocked, not planned: ${nameClashMessage(org.slug, clash)}`,
1000
+ key: `names:clash:${source}`,
1001
+ });
1002
+ }
919
1003
  const invalid = await this.invalidHosting(org);
920
1004
  if (invalid) {
921
1005
  // Rule 2: reported in `unmanaged`, not thrown. Steps that read the
@@ -926,6 +1010,11 @@ export class FoundryPlanApply {
926
1010
  key: 'hosting:invalid',
927
1011
  });
928
1012
  }
1013
+ if (this.onlySteps) {
1014
+ const { step, blocked } = this.onlySteps;
1015
+ detail.plannedSteps = detail.plannedSteps.filter(({ action }) => step(action));
1016
+ detail.unmanaged = detail.unmanaged.filter((item) => item.key === undefined || blocked(item.key));
1017
+ }
929
1018
  return { org, plan: toPlan(detail), detail };
930
1019
  }
931
1020
  /**
@@ -950,7 +1039,40 @@ export class FoundryPlanApply {
950
1039
  * DEFAULT_ORG_PLATFORM_READINESS} reports every service not ready, so every
951
1040
  * org step that needs one is blocked.
952
1041
  */
953
- async orgPlatformReadiness() {
1042
+ async orgPlatformReadiness(org, orgRecordedIds) {
1043
+ const base = await this.sharedPlatformReadiness();
1044
+ if (!ownsServices(org))
1045
+ return base;
1046
+ const held = this.platformDeps?.heldKeys;
1047
+ const platform = await this.platformDeps?.store.get();
1048
+ const readiness = orgPlatformReadinessFor(org, base, orgRecordedIds, held ? await heldOwnKeyRefs(held, org) : new Set());
1049
+ return platform ? { ...readiness, serviceGitRefs: serviceGitRefsOf(platform) } : readiness;
1050
+ }
1051
+ /** An own org's Lettermint key is held. A resolver that throws counts as not held. */
1052
+ async ownMailKeyHeld(org) {
1053
+ const held = this.platformDeps?.heldKeys;
1054
+ if (!ownsServices(org) || !held)
1055
+ return false;
1056
+ return (await heldOwnKeyRefs(held, org)).has(orgTarget(org).refs.brokerMailKey);
1057
+ }
1058
+ /** Which of an own org's undelivered-secret refs hold a value now, so a rerun resumes instead of blocking. */
1059
+ async ownResumableRefs(org) {
1060
+ const held = this.platformDeps?.heldKeys;
1061
+ if (!ownsServices(org) || !held)
1062
+ return { jobs: false, sfu: false };
1063
+ const refs = orgTarget(org).refs;
1064
+ const heldRefs = await heldOwnKeyRefs(held, org);
1065
+ return { jobs: heldRefs.has(refs.jobsAdminToken), sfu: heldRefs.has(refs.livekitKeys) };
1066
+ }
1067
+ /** The relay is declared and has recorded the routes it last applied: port 25 reaches the mail servers. */
1068
+ async mailRelayApplied() {
1069
+ const platform = await this.platformDeps?.store.get();
1070
+ if (!platform?.mail.relay)
1071
+ return false;
1072
+ const recorded = await this.platformDeps?.store.getServiceIds();
1073
+ return Boolean(recorded?.[PLATFORM_MAIL_RELAY_ROUTES_SCOPE_KEY]);
1074
+ }
1075
+ async sharedPlatformReadiness() {
954
1076
  if (!this.platformDeps)
955
1077
  return DEFAULT_ORG_PLATFORM_READINESS;
956
1078
  const platform = await this.platformDeps.store.get();
@@ -988,11 +1110,14 @@ export class FoundryPlanApply {
988
1110
  * `platformDeps` {@link orgPlatformReadiness} reads -- but only when the
989
1111
  * host supplies no `agora` port of its own. An explicit `agora` (#164)
990
1112
  * always wins, unchanged, so a host that already has its own values
991
- * (perhaps not yet wired to this platform) keeps them.
1113
+ * (perhaps not yet wired to this platform) keeps them. An org that owns its
1114
+ * services never takes the host's values: they belong to the shared services.
992
1115
  */
993
- async orgAgoraHost(id) {
1116
+ async orgAgoraHost(org, orgRecordedIds) {
1117
+ if (ownsServices(org))
1118
+ return agoraHostValuesForOwn(org, orgRecordedIds);
994
1119
  if (this.agora)
995
- return readAgoraHost(this.agora, id);
1120
+ return readAgoraHost(this.agora, org.id);
996
1121
  if (!this.platformDeps)
997
1122
  return NO_AGORA_HOST_VALUES;
998
1123
  const platform = await this.platformDeps.store.get();
@@ -1041,16 +1166,73 @@ export class FoundryPlanApply {
1041
1166
  const callsOrgSlugs = (await this.store.list())
1042
1167
  .filter((org) => org.calls.enabled)
1043
1168
  .map((org) => org.slug);
1169
+ const mailRelay = platform.mail.relay === true ? await this.mailRelayInputs(platform) : null;
1044
1170
  const detail = computePlatformPlanDetail(platform, live, {
1045
1171
  jobsObservable: readers.coolify !== undefined,
1046
1172
  sfuBlocked,
1047
1173
  callsOrgSlugs,
1174
+ ...(mailRelay ?? {}),
1048
1175
  recordedIds: serviceIds,
1049
1176
  blocked: (await executor.blockedSteps?.(platform, serviceIds)) ?? {},
1050
1177
  drifted: (await executor.driftedSteps?.(platform, serviceIds)) ?? {},
1051
1178
  });
1052
1179
  return { platform, plan: toPlatformPlan(detail, platform.ownerOrgId), detail };
1053
1180
  }
1181
+ /**
1182
+ * What the shared mail relay needs from the orgs. A route exists for an org
1183
+ * that owns its services, serves mail on its own domain and has recorded its
1184
+ * mail server's SMTP address (`mail_smtp_internal`, written by the company
1185
+ * mail step). Every other org with mail on its own domain, the platform
1186
+ * apex and the shared no-domain apex are platform domains: the platform's Stalwart receives them. An org
1187
+ * that owns its services but has no mail server yet appears in neither list,
1188
+ * so the relay refuses its mail until the server exists.
1189
+ */
1190
+ async mailRelayInputs(platform) {
1191
+ const candidates = [];
1192
+ const mailPlatformDomains = [platform.apex];
1193
+ for (const org of await this.store.list()) {
1194
+ if (org.mail.mode === 'off')
1195
+ continue;
1196
+ // Mail without the org's own domain is served on the shared no-domain apex.
1197
+ if (!org.domain || !org.mail.usesDomain) {
1198
+ if (!mailPlatformDomains.includes(NO_DOMAIN_MAIL_APEX)) {
1199
+ mailPlatformDomains.push(NO_DOMAIN_MAIL_APEX);
1200
+ }
1201
+ continue;
1202
+ }
1203
+ if (!ownsServices(org)) {
1204
+ mailPlatformDomains.push(org.domain);
1205
+ continue;
1206
+ }
1207
+ const recorded = (await this.store.getAppliedState(org.id))?.serviceIds ?? {};
1208
+ const target = recorded[OWN_MAIL_SMTP_INTERNAL_SCOPE_KEY];
1209
+ if (recorded.coolify_platform_mail_app && target) {
1210
+ candidates.push({ slug: org.slug, route: { domain: org.domain, target } });
1211
+ }
1212
+ }
1213
+ // A domain the platform serves, or two companies claim, gets no route; the other routes stay.
1214
+ const platformSet = new Set(mailPlatformDomains.map((d) => d.toLowerCase()));
1215
+ const claims = new Map();
1216
+ for (const { route } of candidates) {
1217
+ const domain = route.domain.toLowerCase();
1218
+ claims.set(domain, (claims.get(domain) ?? 0) + 1);
1219
+ }
1220
+ const mailRoutes = [];
1221
+ const mailRoutesBlocked = [];
1222
+ for (const { slug, route } of candidates) {
1223
+ const domain = route.domain.toLowerCase();
1224
+ if (platformSet.has(domain)) {
1225
+ mailRoutesBlocked.push(`${slug}'s mail route for ${route.domain} is blocked, not planned: ${route.domain} is a platform mail domain`);
1226
+ }
1227
+ else if ((claims.get(domain) ?? 0) > 1) {
1228
+ mailRoutesBlocked.push(`${slug}'s mail route for ${route.domain} is blocked, not planned: another company claims ${route.domain}`);
1229
+ }
1230
+ else {
1231
+ mailRoutes.push(route);
1232
+ }
1233
+ }
1234
+ return { mailRoutes, mailPlatformDomains, mailRoutesBlocked };
1235
+ }
1054
1236
  async sfuBlockedReason(platform) {
1055
1237
  if (!platform.sfu.enabled)
1056
1238
  return null;
@@ -1192,6 +1374,8 @@ function destroyTargets(serviceIds, orgId) {
1192
1374
  'cloudflare:worker:',
1193
1375
  'coolify:database:',
1194
1376
  'coolify:app:',
1377
+ // An own files host's storage key: the token id is the step's recorded resource id.
1378
+ 'coolify:own-files-r2:',
1195
1379
  'zitadel:customers:',
1196
1380
  // F6a: an entry's ai key, `<orgId>:<entry>`; the org's own key has no `:` and is left alone.
1197
1381
  'ai:key:',
@@ -1271,6 +1455,16 @@ function destroyTargets(serviceIds, orgId) {
1271
1455
  continue;
1272
1456
  targets.push({ system: 'edge', kind: 'key', key: entryKey, id });
1273
1457
  }
1458
+ // A storage key minted but never delivered has no main id; its token is deleted too.
1459
+ const r2Undelivered = serviceIds[OWN_FILES_R2_UNDELIVERED_SCOPE_KEY];
1460
+ if (r2Undelivered) {
1461
+ targets.push({
1462
+ system: 'coolify',
1463
+ kind: 'own-files-r2',
1464
+ key: 'undelivered',
1465
+ id: r2Undelivered,
1466
+ });
1467
+ }
1274
1468
  for (const [resourceKey, id] of Object.entries(serviceIds)) {
1275
1469
  if (!resourceKey.startsWith('zitadel:app:') || !id)
1276
1470
  continue;
@@ -1327,6 +1521,9 @@ function destroyStepDescriptions(targets) {
1327
1521
  function destroyServiceIdKey(target) {
1328
1522
  if (target.kind === 'project' || target.kind === 'org')
1329
1523
  return target.key;
1524
+ if (target.kind === 'own-files-r2' && target.key === 'undelivered') {
1525
+ return OWN_FILES_R2_UNDELIVERED_SCOPE_KEY;
1526
+ }
1330
1527
  return `${target.system}:${target.kind}:${target.key}`;
1331
1528
  }
1332
1529
  /**
@@ -1342,6 +1539,12 @@ const STEP_FIELDS = {
1342
1539
  'coolify:broker-entry': 'hosting.entries.uses.auth',
1343
1540
  'coolify:mail': 'mail',
1344
1541
  'coolify:chat': 'chat.usesDomain',
1542
+ 'coolify:own-auth': 'services.own',
1543
+ 'coolify:own-files': 'services.own',
1544
+ 'coolify:own-files-r2': 'services.own',
1545
+ 'coolify:own-jobs': 'services.own',
1546
+ 'coolify:own-mail': 'services.own',
1547
+ 'coolify:own-sfu': 'services.own',
1345
1548
  'coolify:calls': 'calls.enabled',
1346
1549
  'coolify:calls-service': 'calls.enabled',
1347
1550
  'coolify:calls-chat': 'calls.enabled',
@@ -1489,6 +1692,7 @@ function canonical(plan) {
1489
1692
  // version-change line must still be caught as stale, the same as any
1490
1693
  // other field here.
1491
1694
  versionChanges: [...(plan.versionChanges ?? [])].sort((x, y) => x.description.localeCompare(y.description)),
1695
+ notes: [...(plan.notes ?? [])].sort((x, y) => x.key.localeCompare(y.key)),
1492
1696
  };
1493
1697
  }
1494
1698
  /**
@@ -1,8 +1,9 @@
1
1
  import type { DeclaredOrg, EnvSharedSecret, HostingEntry, OrgId } from '../org.js';
2
- import { type Plan, type UnmanagedItem, type VersionChangeItem } from '../plan.js';
2
+ import { type Plan, type PlanNote, type UnmanagedItem, type VersionChangeItem } from '../plan.js';
3
3
  import type { DeclaredPlatform } from '../platform.js';
4
4
  import type { PlannedStep } from './diff.js';
5
5
  import type { LiveResource } from './live.js';
6
+ import { type MailRoute, PLATFORM_MAIL_RELAY_KEY } from './mail-relay.js';
6
7
  /**
7
8
  * The platform's own diff result, the {@link DeclaredPlatform} counterpart to
8
9
  * `diff.ts`'s `PlanDetail` -- everything the same, minus the `orgId` an org's
@@ -16,6 +17,8 @@ export interface PlatformPlanDetail {
16
17
  unmanaged: UnmanagedItem[];
17
18
  /** #251: see {@link VersionChangeItem}. Never folded into `plannedSteps`. */
18
19
  versionChanges: VersionChangeItem[];
20
+ /** Advisory lines that block no step; see {@link PlanNote}. */
21
+ notes: PlanNote[];
19
22
  }
20
23
  /**
21
24
  * The `Plan` (#19's public contract) a {@link PlatformPlanDetail} carries,
@@ -161,12 +164,11 @@ export declare const PLATFORM_ARCHON_DEPLOYMENT_SCOPE_KEY = "archon_deployment";
161
164
  */
162
165
  export declare const PLATFORM_ARCHON_APPLIED_BROKER_SCOPE_KEY = "archon_applied_broker_app";
163
166
  /**
164
- * The `AUTHZ_BOOTSTRAP_OPERATOR` the last Archon apply set (`none` when it set
165
- * none). `planArchon` plans the step again when the owner's id recorded by
166
- * `platform:auth` differs from it, so Archon picks the owner up once auth
167
- * has recorded it.
167
+ * The sign-in org id the last Archon apply set as `AUTH_ORGANIZATION_ID` and
168
+ * registered its broker entry under. `planArchon` plans the step again when
169
+ * {@link archonAuthOrganizationId} differs from it.
168
170
  */
169
- export declare const PLATFORM_ARCHON_APPLIED_OPERATOR_SCOPE_KEY = "archon_applied_bootstrap_operator";
171
+ export declare const PLATFORM_ARCHON_APPLIED_AUTH_ORG_SCOPE_KEY = "archon_applied_auth_org";
170
172
  /**
171
173
  * #475: whether the last successful Archon apply patched the app with
172
174
  * `custom_network_aliases` and `is_consistent_container_name_enabled: false`
@@ -183,11 +185,11 @@ export declare const PLATFORM_ARCHON_APPLIED_OPERATOR_SCOPE_KEY = "archon_applie
183
185
  */
184
186
  export declare const PLATFORM_ARCHON_APPLIED_ROLLING_UPDATE_SCOPE_KEY = "archon_applied_rolling_update";
185
187
  /**
186
- * The owner's ZITADEL user id Archon takes as its first operator, from
187
- * {@link PLATFORM_AUTH_OWNER_INVITE_SCOPE_KEY}. Undefined when none is
188
- * recorded or the recorded value is `none` (no owner declared).
188
+ * The sign-in org Archon accepts: the platform owner company's own
189
+ * ({@link PLATFORM_OWNER_ORG_SCOPE_KEY}), else the platform's `foundry` org for
190
+ * a platform with no owner company declared. Undefined when neither is recorded.
189
191
  */
190
- export declare function archonBootstrapOperator(recorded: Readonly<Record<string, string>>): string | undefined;
192
+ export declare function archonAuthOrganizationId(recorded: Readonly<Record<string, string>>): string | undefined;
191
193
  /**
192
194
  * #175: set to '1' before the step points the tunnel at `http_status:404`
193
195
  * (Access app rebuild), cleared to '' once the real ingress is written back.
@@ -207,6 +209,26 @@ export declare const PLATFORM_AUTH_SMTP_SCOPE_KEY = "zitadel_platform_smtp_provi
207
209
  * requested, so the invite goes out once. `none` when no owner is declared.
208
210
  */
209
211
  export declare const PLATFORM_AUTH_OWNER_INVITE_SCOPE_KEY = "zitadel_platform_owner_invite_user_id";
212
+ /** The platform's own ZITADEL org (`foundry`, the one `provisionPlatformAuth` mints its admin user in). */
213
+ export declare const PLATFORM_ZITADEL_ORG_SCOPE_KEY = "zitadel_platform_org";
214
+ /**
215
+ * The id of the platform owner company's own sign-in organisation, made by
216
+ * `platform:auth` with the same name and primary domain a company apply uses,
217
+ * so that apply finds this org and makes no second one. `none` when the owner
218
+ * company is not declared. Archon signs people in from this org; the
219
+ * `foundry` org ({@link PLATFORM_ZITADEL_ORG_SCOPE_KEY}) holds no people.
220
+ */
221
+ export declare const PLATFORM_OWNER_ORG_SCOPE_KEY = "zitadel_platform_owner_org";
222
+ /** The primary domain `platform:auth` registered on the owner company's sign-in org. */
223
+ export declare const PLATFORM_OWNER_ORG_DOMAIN_SCOPE_KEY = "zitadel_platform_owner_org_domain";
224
+ /** The owner org's sign-in domain the sign-in server refused to make primary; set instead of failing, reported as blocked. */
225
+ export declare const PLATFORM_OWNER_ORG_DOMAIN_BLOCKED_SCOPE_KEY = "zitadel_platform_owner_org_domain_blocked";
226
+ /**
227
+ * The sign-in org the recorded owner invite ({@link PLATFORM_AUTH_OWNER_INVITE_SCOPE_KEY})
228
+ * was made in. An invite recorded against another org (the `foundry` org, on a
229
+ * platform applied before the owner moved) plans the auth step again.
230
+ */
231
+ export declare const PLATFORM_AUTH_OWNER_INVITE_ORG_SCOPE_KEY = "zitadel_platform_owner_invite_org";
210
232
  /**
211
233
  * #394: the id of the role-less ZITADEL machine user that reserves the
212
234
  * username `admin` in the platform's own org, so nobody can sign up under a
@@ -216,8 +238,6 @@ export declare const PLATFORM_AUTH_OWNER_INVITE_SCOPE_KEY = "zitadel_platform_ow
216
238
  export declare const PLATFORM_AUTH_RESERVED_ADMIN_USERNAME_SCOPE_KEY = "zitadel_platform_reserved_admin_username";
217
239
  /** #394: recorded once the platform's own ZITADEL org has self-registration forced off. */
218
240
  export declare const PLATFORM_AUTH_REGISTRATION_CLOSED_SCOPE_KEY = "zitadel_platform_registration_closed";
219
- /** The platform's own ZITADEL org (`foundry`, the one `provisionPlatformAuth` mints its admin user in). */
220
- export declare const PLATFORM_ZITADEL_ORG_SCOPE_KEY = "zitadel_platform_org";
221
241
  export declare const PLATFORM_AUTH_ADMIN_USER_SCOPE_KEY = "zitadel_platform_admin_user_id";
222
242
  export declare const PLATFORM_AUTH_ADMIN_UNDELIVERED_SCOPE_KEY = "zitadel_platform_admin_user_undelivered";
223
243
  /** #283: the created instance's id, recorded once its owner token is delivered. */
@@ -413,6 +433,18 @@ export declare const PLATFORM_MAIL_APPLIED_GIT_REF_SCOPE_KEY = "mail_applied_git
413
433
  * no key and creates nothing in the sign-in server.
414
434
  */
415
435
  export declare const PLATFORM_MAIL_ROUTE_SCOPE_KEY = "mail_route_applied";
436
+ /**
437
+ * The host ports the platform Stalwart publishes, as the mapping string the
438
+ * last apply set on its app. Without the shared relay it binds SMTP port 25
439
+ * itself; with `mail.relay` it keeps only submission, because the relay owns
440
+ * port 25. A missing record means the mapping it was created with, `25:25,...`.
441
+ */
442
+ export declare const PLATFORM_MAIL_HOST_PORTS_SCOPE_KEY = "mail_host_ports_applied";
443
+ export declare const PLATFORM_MAIL_HOST_PORTS_DIRECT = "25:25,465:465,587:587";
444
+ export declare const PLATFORM_MAIL_HOST_PORTS_RELAY = "465:465,587:587";
445
+ export declare function platformMailHostPorts(platform: DeclaredPlatform): string;
446
+ export { PLATFORM_MAIL_RELAY_KEY };
447
+ export { PLATFORM_MAIL_RELAY_ROUTES_SCOPE_KEY, type MailRoute, } from './mail-relay.js';
416
448
  export declare const PLATFORM_MAIL_ROUTE_WANTED = "local-first";
417
449
  /**
418
450
  * #177: where the mail server answers on the Coolify network
@@ -952,6 +984,19 @@ export interface PlanPlatformOptions {
952
984
  * only when the recorded fingerprint differs; absent, it always plans it.
953
985
  */
954
986
  callsOrgSlugs?: readonly string[];
987
+ /**
988
+ * Which company domain goes to which company mail server. Used only when
989
+ * `platform.mail.relay` is true (default `[]`).
990
+ */
991
+ mailRoutes?: readonly MailRoute[];
992
+ /**
993
+ * The domains the platform's own Stalwart serves; the relay hands them to
994
+ * it and refuses any domain in neither list. Used only when
995
+ * `platform.mail.relay` is true (default `[]`).
996
+ */
997
+ mailPlatformDomains?: readonly string[];
998
+ /** One line per company route left out of `mailRoutes`; each is reported blocked and the relay still plans without it. */
999
+ mailRoutesBlocked?: readonly string[];
955
1000
  /** The platform's recorded resource/scope ids (default `{}`). */
956
1001
  recordedIds?: Readonly<Record<string, string>>;
957
1002
  /** Per-step block reasons the executor reports (default `{}`). */
@@ -974,11 +1019,15 @@ export interface PlanPlatformOptions {
974
1019
  * these. Default `[]`.
975
1020
  */
976
1021
  authOtherInputsMissing?: string[];
1022
+ /** C3: why the owner's invite cannot be made (no work address, no mail port). The rest of the step still plans. */
1023
+ authOwnerInvite?: string;
977
1024
  files?: string;
978
1025
  integrations?: string;
979
1026
  ai?: string;
980
1027
  archon?: string;
981
1028
  mail?: string;
1029
+ /** Why the shared mail relay cannot run: the executor lacks the mail dependencies or the base image tag floats. */
1030
+ relay?: string;
982
1031
  /** #478: why the edge host is blocked, or absent when it is not. */
983
1032
  edge?: string;
984
1033
  /** #604: why the Access sign-in provider cannot be made yet (no dependencies, or edge cannot confirm Access is ready), or absent. */