@wtfalch/foundry 0.33.1 → 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 (65) hide show
  1. package/dist/bootstrap/versions.d.ts +4 -0
  2. package/dist/calls-sfu.d.ts +10 -0
  3. package/dist/calls-sfu.js +41 -0
  4. package/dist/index.d.ts +14 -6
  5. package/dist/index.js +7 -3
  6. package/dist/migrations/0051_org_services.sql +13 -0
  7. package/dist/migrations/0052_platform_mail_relay.sql +8 -0
  8. package/dist/org.d.ts +18 -0
  9. package/dist/org.js +26 -0
  10. package/dist/plan/calls-matrix.d.ts +9 -0
  11. package/dist/plan/calls-matrix.js +17 -0
  12. package/dist/plan/cloudflared-connector.d.ts +7 -1
  13. package/dist/plan/cloudflared-connector.js +14 -1
  14. package/dist/plan/diff.d.ts +29 -0
  15. package/dist/plan/diff.js +259 -18
  16. package/dist/plan/executor.d.ts +112 -0
  17. package/dist/plan/executor.js +594 -33
  18. package/dist/plan/health-check-settings.d.ts +28 -0
  19. package/dist/plan/health-check-settings.js +36 -0
  20. package/dist/plan/health-gaps.d.ts +33 -0
  21. package/dist/plan/health-gaps.js +89 -0
  22. package/dist/plan/held-key-refs.d.ts +10 -0
  23. package/dist/plan/held-key-refs.js +10 -0
  24. package/dist/plan/index.d.ts +14 -6
  25. package/dist/plan/index.js +7 -3
  26. package/dist/plan/live.d.ts +5 -2
  27. package/dist/plan/live.js +15 -2
  28. package/dist/plan/locked-hostnames.d.ts +5 -2
  29. package/dist/plan/locked-hostnames.js +9 -1
  30. package/dist/plan/login-host.d.ts +62 -2
  31. package/dist/plan/login-host.js +104 -1
  32. package/dist/plan/mail-relay.d.ts +120 -0
  33. package/dist/plan/mail-relay.js +323 -0
  34. package/dist/plan/org-names.d.ts +24 -0
  35. package/dist/plan/org-names.js +52 -0
  36. package/dist/plan/own-diff.d.ts +169 -0
  37. package/dist/plan/own-diff.js +432 -0
  38. package/dist/plan/own-dns.d.ts +6 -0
  39. package/dist/plan/own-dns.js +15 -0
  40. package/dist/plan/own-limits.d.ts +22 -0
  41. package/dist/plan/own-limits.js +101 -0
  42. package/dist/plan/own-services.d.ts +193 -0
  43. package/dist/plan/own-services.js +167 -0
  44. package/dist/plan/own-sfu.d.ts +69 -0
  45. package/dist/plan/own-sfu.js +255 -0
  46. package/dist/plan/plan-apply.d.ts +78 -7
  47. package/dist/plan/plan-apply.js +205 -12
  48. package/dist/plan/platform-diff.d.ts +28 -0
  49. package/dist/plan/platform-diff.js +45 -3
  50. package/dist/plan/platform-org.d.ts +34 -0
  51. package/dist/plan/platform-org.js +94 -2
  52. package/dist/plan/platform-providers.d.ts +356 -8
  53. package/dist/plan/platform-providers.js +661 -353
  54. package/dist/plan/sign-in-org.d.ts +1 -1
  55. package/dist/plan/sign-in-org.js +20 -2
  56. package/dist/plan/zitadel-login-policy.js +1 -1
  57. package/dist/plan/zitadel-system-api.d.ts +22 -1
  58. package/dist/plan/zitadel-system-api.js +49 -8
  59. package/dist/platform.d.ts +8 -0
  60. package/dist/slug.js +1 -0
  61. package/dist/store/postgres.js +51 -2
  62. package/dist/store/schema.d.ts +51 -0
  63. package/dist/store/schema.js +5 -0
  64. package/package.json +1 -1
  65. 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,6 +146,7 @@ export class FoundryPlanApply {
137
146
  agora;
138
147
  jobsScheduler;
139
148
  loginHostBindings;
149
+ loginHostDiscovery;
140
150
  zitadelIdTokenUserinfo;
141
151
  broker;
142
152
  edgeWorkers;
@@ -144,6 +154,8 @@ export class FoundryPlanApply {
144
154
  hostFiles;
145
155
  hostAi;
146
156
  hostEdge;
157
+ hostOwnServices;
158
+ sfuPortsOpen;
147
159
  onlySteps;
148
160
  now;
149
161
  leaseTtlMs;
@@ -161,6 +173,7 @@ export class FoundryPlanApply {
161
173
  this.agora = options.agora;
162
174
  this.jobsScheduler = options.jobsScheduler ?? false;
163
175
  this.loginHostBindings = options.loginHostBindings ?? false;
176
+ this.loginHostDiscovery = options.loginHostDiscovery;
164
177
  this.zitadelIdTokenUserinfo = options.zitadelIdTokenUserinfo ?? false;
165
178
  this.broker = options.broker ?? false;
166
179
  this.edgeWorkers = options.edgeWorkers ?? false;
@@ -168,6 +181,8 @@ export class FoundryPlanApply {
168
181
  this.hostFiles = options.filesOrganisation ?? false;
169
182
  this.hostAi = options.aiOrganisation ?? false;
170
183
  this.hostEdge = options.edge ?? false;
184
+ this.hostOwnServices = options.ownServices ?? false;
185
+ this.sfuPortsOpen = options.sfuPortsOpen;
171
186
  this.onlySteps = options.onlySteps;
172
187
  this.now = options.now ?? (() => new Date());
173
188
  this.leaseTtlMs = options.leaseTtlMs ?? DEFAULT_LEASE_TTL_MS;
@@ -897,21 +912,45 @@ export class FoundryPlanApply {
897
912
  });
898
913
  return applied;
899
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
+ }
900
930
  async computeFreshPlan(id) {
901
931
  const org = await this.store.get(id);
902
932
  if (!org)
903
933
  throw new DeclaredOrgNotFoundError(id);
904
- const [live, applied, platformReadiness, agoraHost, archonUrl] = await Promise.all([
905
- readLiveResources(id, this.readers),
906
- this.store.getAppliedState(id),
907
- this.orgPlatformReadiness(),
908
- 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 ?? {}),
909
939
  this.orgArchonUrl(),
910
940
  ]);
911
941
  const otherSlugs = (await this.store.list()).filter((o) => o.id !== id).map((o) => o.slug);
912
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);
913
951
  const detail = computePlanDetail(org, live, applied?.serviceIds ?? {}, agoraHost, this.jobsScheduler, platformReadiness, {
914
952
  loginHostBindings: this.loginHostBindings,
953
+ loginHostServesDiscovery,
915
954
  zitadelIdTokenUserinfo: this.zitadelIdTokenUserinfo,
916
955
  broker: this.broker,
917
956
  edgeWorkers: this.edgeWorkers,
@@ -920,7 +959,47 @@ export class FoundryPlanApply {
920
959
  now: () => this.now().getTime(),
921
960
  archonUrl,
922
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
+ : {}),
923
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
+ }
924
1003
  const invalid = await this.invalidHosting(org);
925
1004
  if (invalid) {
926
1005
  // Rule 2: reported in `unmanaged`, not thrown. Steps that read the
@@ -960,7 +1039,40 @@ export class FoundryPlanApply {
960
1039
  * DEFAULT_ORG_PLATFORM_READINESS} reports every service not ready, so every
961
1040
  * org step that needs one is blocked.
962
1041
  */
963
- 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() {
964
1076
  if (!this.platformDeps)
965
1077
  return DEFAULT_ORG_PLATFORM_READINESS;
966
1078
  const platform = await this.platformDeps.store.get();
@@ -998,11 +1110,14 @@ export class FoundryPlanApply {
998
1110
  * `platformDeps` {@link orgPlatformReadiness} reads -- but only when the
999
1111
  * host supplies no `agora` port of its own. An explicit `agora` (#164)
1000
1112
  * always wins, unchanged, so a host that already has its own values
1001
- * (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.
1002
1115
  */
1003
- async orgAgoraHost(id) {
1116
+ async orgAgoraHost(org, orgRecordedIds) {
1117
+ if (ownsServices(org))
1118
+ return agoraHostValuesForOwn(org, orgRecordedIds);
1004
1119
  if (this.agora)
1005
- return readAgoraHost(this.agora, id);
1120
+ return readAgoraHost(this.agora, org.id);
1006
1121
  if (!this.platformDeps)
1007
1122
  return NO_AGORA_HOST_VALUES;
1008
1123
  const platform = await this.platformDeps.store.get();
@@ -1051,16 +1166,73 @@ export class FoundryPlanApply {
1051
1166
  const callsOrgSlugs = (await this.store.list())
1052
1167
  .filter((org) => org.calls.enabled)
1053
1168
  .map((org) => org.slug);
1169
+ const mailRelay = platform.mail.relay === true ? await this.mailRelayInputs(platform) : null;
1054
1170
  const detail = computePlatformPlanDetail(platform, live, {
1055
1171
  jobsObservable: readers.coolify !== undefined,
1056
1172
  sfuBlocked,
1057
1173
  callsOrgSlugs,
1174
+ ...(mailRelay ?? {}),
1058
1175
  recordedIds: serviceIds,
1059
1176
  blocked: (await executor.blockedSteps?.(platform, serviceIds)) ?? {},
1060
1177
  drifted: (await executor.driftedSteps?.(platform, serviceIds)) ?? {},
1061
1178
  });
1062
1179
  return { platform, plan: toPlatformPlan(detail, platform.ownerOrgId), detail };
1063
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
+ }
1064
1236
  async sfuBlockedReason(platform) {
1065
1237
  if (!platform.sfu.enabled)
1066
1238
  return null;
@@ -1202,6 +1374,8 @@ function destroyTargets(serviceIds, orgId) {
1202
1374
  'cloudflare:worker:',
1203
1375
  'coolify:database:',
1204
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:',
1205
1379
  'zitadel:customers:',
1206
1380
  // F6a: an entry's ai key, `<orgId>:<entry>`; the org's own key has no `:` and is left alone.
1207
1381
  'ai:key:',
@@ -1281,6 +1455,16 @@ function destroyTargets(serviceIds, orgId) {
1281
1455
  continue;
1282
1456
  targets.push({ system: 'edge', kind: 'key', key: entryKey, id });
1283
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
+ }
1284
1468
  for (const [resourceKey, id] of Object.entries(serviceIds)) {
1285
1469
  if (!resourceKey.startsWith('zitadel:app:') || !id)
1286
1470
  continue;
@@ -1337,6 +1521,9 @@ function destroyStepDescriptions(targets) {
1337
1521
  function destroyServiceIdKey(target) {
1338
1522
  if (target.kind === 'project' || target.kind === 'org')
1339
1523
  return target.key;
1524
+ if (target.kind === 'own-files-r2' && target.key === 'undelivered') {
1525
+ return OWN_FILES_R2_UNDELIVERED_SCOPE_KEY;
1526
+ }
1340
1527
  return `${target.system}:${target.kind}:${target.key}`;
1341
1528
  }
1342
1529
  /**
@@ -1352,6 +1539,12 @@ const STEP_FIELDS = {
1352
1539
  'coolify:broker-entry': 'hosting.entries.uses.auth',
1353
1540
  'coolify:mail': 'mail',
1354
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',
1355
1548
  'coolify:calls': 'calls.enabled',
1356
1549
  'coolify:calls-service': 'calls.enabled',
1357
1550
  'coolify:calls-chat': 'calls.enabled',
@@ -3,6 +3,7 @@ import { type Plan, type PlanNote, type UnmanagedItem, type VersionChangeItem }
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
@@ -432,6 +433,18 @@ export declare const PLATFORM_MAIL_APPLIED_GIT_REF_SCOPE_KEY = "mail_applied_git
432
433
  * no key and creates nothing in the sign-in server.
433
434
  */
434
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';
435
448
  export declare const PLATFORM_MAIL_ROUTE_WANTED = "local-first";
436
449
  /**
437
450
  * #177: where the mail server answers on the Coolify network
@@ -971,6 +984,19 @@ export interface PlanPlatformOptions {
971
984
  * only when the recorded fingerprint differs; absent, it always plans it.
972
985
  */
973
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[];
974
1000
  /** The platform's recorded resource/scope ids (default `{}`). */
975
1001
  recordedIds?: Readonly<Record<string, string>>;
976
1002
  /** Per-step block reasons the executor reports (default `{}`). */
@@ -1000,6 +1026,8 @@ export interface PlanPlatformOptions {
1000
1026
  ai?: string;
1001
1027
  archon?: string;
1002
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;
1003
1031
  /** #478: why the edge host is blocked, or absent when it is not. */
1004
1032
  edge?: string;
1005
1033
  /** #604: why the Access sign-in provider cannot be made yet (no dependencies, or edge cannot confirm Access is ready), or absent. */
@@ -5,6 +5,7 @@ import { VERSION_CHANGE_KIND, } from '../plan.js';
5
5
  import { versionChangeNeedsConfirmation } from './confirm-upgrade.js';
6
6
  import { ACCESS_PROVIDER_RECORD_KEY } from './gates.js';
7
7
  import { legacyAppHostname } from './hostnames.js';
8
+ import { PLATFORM_MAIL_RELAY_KEY, planMailRelay } from './mail-relay.js';
8
9
  import { callsSfuAppliedFingerprint } from './platform-sfu.js';
9
10
  import { ZITADEL_ORG_DOMAIN_BLOCKED_SCOPE_KEY, signInDomainBlockedText } from './sign-in-org.js';
10
11
  import { withStepSystem } from './step-system.js';
@@ -482,6 +483,26 @@ export const PLATFORM_MAIL_APPLIED_GIT_REF_SCOPE_KEY = 'mail_applied_git_ref';
482
483
  * no key and creates nothing in the sign-in server.
483
484
  */
484
485
  export const PLATFORM_MAIL_ROUTE_SCOPE_KEY = 'mail_route_applied';
486
+ /**
487
+ * The host ports the platform Stalwart publishes, as the mapping string the
488
+ * last apply set on its app. Without the shared relay it binds SMTP port 25
489
+ * itself; with `mail.relay` it keeps only submission, because the relay owns
490
+ * port 25. A missing record means the mapping it was created with, `25:25,...`.
491
+ */
492
+ export const PLATFORM_MAIL_HOST_PORTS_SCOPE_KEY = 'mail_host_ports_applied';
493
+ export const PLATFORM_MAIL_HOST_PORTS_DIRECT = '25:25,465:465,587:587';
494
+ export const PLATFORM_MAIL_HOST_PORTS_RELAY = '465:465,587:587';
495
+ export function platformMailHostPorts(platform) {
496
+ return platform.mail.relay === true
497
+ ? PLATFORM_MAIL_HOST_PORTS_RELAY
498
+ : PLATFORM_MAIL_HOST_PORTS_DIRECT;
499
+ }
500
+ function mailPortsApplied(platform, recordedIds) {
501
+ return ((recordedIds[PLATFORM_MAIL_HOST_PORTS_SCOPE_KEY] ?? PLATFORM_MAIL_HOST_PORTS_DIRECT) ===
502
+ platformMailHostPorts(platform));
503
+ }
504
+ export { PLATFORM_MAIL_RELAY_KEY };
505
+ export { PLATFORM_MAIL_RELAY_ROUTES_SCOPE_KEY, } from './mail-relay.js';
485
506
  export const PLATFORM_MAIL_ROUTE_WANTED = 'local-first';
486
507
  /**
487
508
  * #177: where the mail server answers on the Coolify network
@@ -1113,7 +1134,7 @@ export function uptimeRecorded(recordedIds) {
1113
1134
  * unchanged).
1114
1135
  */
1115
1136
  export function planPlatform(platform, live, options = {}) {
1116
- const { jobsObservable = true, sfuBlocked = null, callsOrgSlugs, recordedIds = {}, blocked = {}, drifted = {}, } = options;
1137
+ const { jobsObservable = true, sfuBlocked = null, callsOrgSlugs, mailRoutes = [], mailPlatformDomains = [], mailRoutesBlocked = [], recordedIds = {}, blocked = {}, drifted = {}, } = options;
1117
1138
  const byKey = new Map(live.map((r) => [resourceKey(r.system, r.kind, r.key), r]));
1118
1139
  const claimed = new Set();
1119
1140
  const plannedSteps = [];
@@ -1178,6 +1199,18 @@ export function planPlatform(platform, live, options = {}) {
1178
1199
  const mail = planMail(platform, recordedIds, blocked.mail ?? dnsBlocked?.('mail server'), authReady);
1179
1200
  if (mail.step)
1180
1201
  plannedSteps.push(mail.step);
1202
+ // The shared relay owns host port 25 once `mail.relay` is on, so it runs after the
1203
+ // mail step that gives the port up.
1204
+ const relay = planMailRelay(platform, recordedIds, {
1205
+ routes: mailRoutes,
1206
+ platformDomains: mailPlatformDomains,
1207
+ executorBlocked: blocked.relay,
1208
+ mailReady: mail.step !== null ||
1209
+ (Boolean(recordedIds[resourceKey('coolify', 'app', PLATFORM_MAIL_KEY)]) &&
1210
+ mailPortsApplied(platform, recordedIds)),
1211
+ });
1212
+ if (relay.step)
1213
+ plannedSteps.push(relay.step);
1181
1214
  // #172: a throw halts every step after it, so this one goes late.
1182
1215
  const ai = planAi(platform, recordedIds, blocked.ai);
1183
1216
  if (ai.step)
@@ -1280,6 +1313,13 @@ export function planPlatform(platform, live, options = {}) {
1280
1313
  unmanaged.push({ description: auth.blocked, key: 'auth' });
1281
1314
  if (mail.blocked)
1282
1315
  unmanaged.push({ description: mail.blocked, key: 'mail' });
1316
+ if (relay.blocked)
1317
+ unmanaged.push({ description: relay.blocked, key: 'mail-relay' });
1318
+ if (platform.mail.relay === true) {
1319
+ for (const description of mailRoutesBlocked) {
1320
+ unmanaged.push({ description, key: 'mail-relay:route' });
1321
+ }
1322
+ }
1283
1323
  if (integrations.blocked)
1284
1324
  unmanaged.push({ description: integrations.blocked, key: 'integrations' });
1285
1325
  if (files.blocked)
@@ -1922,7 +1962,8 @@ function planMail(platform, recordedIds, executorBlocked, authReady) {
1922
1962
  // #177: a mail step applied before this key existed never recorded it, and
1923
1963
  // org steps read it for Agora's mail upstream. Missing means unapplied.
1924
1964
  recordedIds[PLATFORM_MAIL_INTERNAL_URL_SCOPE_KEY] &&
1925
- recordedIds[PLATFORM_MAIL_ROUTE_SCOPE_KEY] === PLATFORM_MAIL_ROUTE_WANTED) {
1965
+ recordedIds[PLATFORM_MAIL_ROUTE_SCOPE_KEY] === PLATFORM_MAIL_ROUTE_WANTED &&
1966
+ mailPortsApplied(platform, recordedIds)) {
1926
1967
  return { step: null, blocked: null };
1927
1968
  }
1928
1969
  // Only the route is missing: say so, the app is not redeployed.
@@ -1930,7 +1971,8 @@ function planMail(platform, recordedIds, executorBlocked, authReady) {
1930
1971
  recordedIds[PLATFORM_MAIL_CONNECTOR_APP_SCOPE_KEY] &&
1931
1972
  recordedIds[PLATFORM_MAIL_APPLIED_HOSTNAME_SCOPE_KEY] === platform.mail.hostname &&
1932
1973
  recordedIds[PLATFORM_MAIL_APPLIED_GIT_REF_SCOPE_KEY] === platform.mail.gitRef &&
1933
- recordedIds[PLATFORM_MAIL_INTERNAL_URL_SCOPE_KEY];
1974
+ recordedIds[PLATFORM_MAIL_INTERNAL_URL_SCOPE_KEY] &&
1975
+ mailPortsApplied(platform, recordedIds);
1934
1976
  // Stalwart's `x:ApiKey/set` shows a secret once. An id was minted but never
1935
1977
  // reached the host: report it, plan nothing, the same rule {@link planAuth}
1936
1978
  // follows for ZITADEL's own admin key.
@@ -1,3 +1,4 @@
1
+ import { type DeclaredOrg } from '../org.js';
1
2
  import type { DeclaredPlatform } from '../platform.js';
2
3
  import type { AgoraHostValues } from './agora.js';
3
4
  /**
@@ -151,6 +152,19 @@ export interface OrgPlatformReadiness {
151
152
  * A missing key is then reported as "no reader given" instead of "not held".
152
153
  */
153
154
  noKeyReader?: boolean;
155
+ /** The platform's declared git ref for each service, which an own org's services deploy. */
156
+ serviceGitRefs?: {
157
+ auth: string;
158
+ mail: string;
159
+ files: string;
160
+ jobs: string;
161
+ };
162
+ /**
163
+ * The platform's recorded owner invite (`PLATFORM_AUTH_OWNER_INVITE_SCOPE_KEY`): the user id the
164
+ * platform's files host takes as `AUTHZ_BOOTSTRAP_OPERATOR`, or `'none'` when the platform has no
165
+ * owner to invite. `null` or absent until recorded. An own org's files host gets the same value.
166
+ */
167
+ platformOwnerUserId?: string | null;
154
168
  }
155
169
  /**
156
170
  * What an org's plan reads when no platform is declared: nothing is ready, so
@@ -207,6 +221,26 @@ heldRefs?: ReadonlySet<string>,
207
221
  noKeyReader?: boolean,
208
222
  /** #317: true when the host's executor was built with an `EdgePort` ({@link OrgPlatformReadiness.edgeReady}). */
209
223
  edgePortPresent?: boolean): OrgPlatformReadiness;
224
+ /** The git refs the platform declares for the services an own org deploys. */
225
+ export declare function serviceGitRefsOf(platform: DeclaredPlatform): {
226
+ auth: string;
227
+ mail: string;
228
+ files: string;
229
+ jobs: string;
230
+ };
231
+ /** The runner app both the platform's and an own org's files step record. */
232
+ export declare const OWN_FILES_RUNNER_SCOPE_KEY = "coolify_platform_files_runner_app";
233
+ /**
234
+ * An own org (`services.own`) plans against its own services, so its readiness
235
+ * reads only its own recorded ids and its own held keys; nothing the shared
236
+ * services recorded reaches it. AI, edge and Access stay shared and come from
237
+ * `base`. Any other org gets `base` back unchanged.
238
+ */
239
+ export declare function orgPlatformReadinessFor(org: DeclaredOrg, base: OrgPlatformReadiness, orgRecordedIds: Readonly<Record<string, string>>, heldRefs: ReadonlySet<string>): OrgPlatformReadiness;
240
+ /** The own org's held-key refs `heldKeys` resolves right now. A resolver that throws counts as not held, and its error goes nowhere. */
241
+ export declare function heldOwnKeyRefs(heldKeys: HeldKeyResolver, org: Pick<DeclaredOrg, 'slug'>): Promise<Set<string>>;
242
+ /** {@link agoraHostValuesFromPlatform}'s counterpart for an own org: every value is the org's own, null until recorded. */
243
+ export declare function agoraHostValuesForOwn(org: DeclaredOrg, orgRecordedIds: Readonly<Record<string, string>>): AgoraHostValues;
210
244
  /**
211
245
  * #177: {@link AgoraHostValues} derived from the same platform state, rather
212
246
  * than requiring a bespoke host-supplied `AgoraHostPort` for values the