@wtfalch/foundry 0.33.1 → 0.35.1

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 (106) hide show
  1. package/dist/bootstrap/bootstrap.d.ts +1 -1
  2. package/dist/bootstrap/bootstrap.js +49 -11
  3. package/dist/bootstrap/declare.d.ts +12 -5
  4. package/dist/bootstrap/declare.js +33 -18
  5. package/dist/bootstrap/versions.d.ts +24 -0
  6. package/dist/bootstrap/versions.js +46 -0
  7. package/dist/calls-sfu.d.ts +10 -0
  8. package/dist/calls-sfu.js +44 -3
  9. package/dist/index.d.ts +21 -8
  10. package/dist/index.js +11 -4
  11. package/dist/migrations/0051_org_services.sql +13 -0
  12. package/dist/migrations/0052_platform_mail_relay.sql +8 -0
  13. package/dist/migrations/0053_heartbeats_provider.sql +9 -0
  14. package/dist/migrations/0053_org_version_pin.sql +6 -0
  15. package/dist/org.d.ts +76 -12
  16. package/dist/org.js +136 -20
  17. package/dist/plan/backup-gate.d.ts +93 -0
  18. package/dist/plan/backup-gate.js +261 -0
  19. package/dist/plan/broker-entries.d.ts +9 -1
  20. package/dist/plan/broker-entries.js +20 -3
  21. package/dist/plan/build-record.d.ts +56 -0
  22. package/dist/plan/build-record.js +109 -0
  23. package/dist/plan/calls-matrix.d.ts +9 -0
  24. package/dist/plan/calls-matrix.js +18 -1
  25. package/dist/plan/cloudflared-connector.d.ts +7 -1
  26. package/dist/plan/cloudflared-connector.js +15 -2
  27. package/dist/plan/company-backups.d.ts +31 -0
  28. package/dist/plan/company-backups.js +101 -0
  29. package/dist/plan/deploy-health.d.ts +12 -2
  30. package/dist/plan/deploy-health.js +3 -1
  31. package/dist/plan/destinations.d.ts +73 -0
  32. package/dist/plan/destinations.js +217 -0
  33. package/dist/plan/diff.d.ts +46 -8
  34. package/dist/plan/diff.js +367 -126
  35. package/dist/plan/edge-access.d.ts +15 -0
  36. package/dist/plan/edge-access.js +21 -2
  37. package/dist/plan/executor.d.ts +276 -5
  38. package/dist/plan/executor.js +1111 -109
  39. package/dist/plan/health-check-settings.d.ts +28 -0
  40. package/dist/plan/health-check-settings.js +36 -0
  41. package/dist/plan/health-gaps.d.ts +33 -0
  42. package/dist/plan/health-gaps.js +89 -0
  43. package/dist/plan/health-targets.d.ts +1 -3
  44. package/dist/plan/health-targets.js +36 -36
  45. package/dist/plan/held-key-refs.d.ts +15 -0
  46. package/dist/plan/held-key-refs.js +15 -0
  47. package/dist/plan/hostnames.d.ts +10 -0
  48. package/dist/plan/hostnames.js +14 -0
  49. package/dist/plan/index.d.ts +19 -6
  50. package/dist/plan/index.js +10 -3
  51. package/dist/plan/live.d.ts +11 -2
  52. package/dist/plan/live.js +16 -2
  53. package/dist/plan/locked-hostnames.d.ts +7 -3
  54. package/dist/plan/locked-hostnames.js +25 -4
  55. package/dist/plan/login-host.d.ts +61 -12
  56. package/dist/plan/login-host.js +99 -11
  57. package/dist/plan/mail-relay.d.ts +139 -0
  58. package/dist/plan/mail-relay.js +400 -0
  59. package/dist/plan/network-move.d.ts +20 -0
  60. package/dist/plan/network-move.js +41 -0
  61. package/dist/plan/oikos-ingress.d.ts +111 -0
  62. package/dist/plan/oikos-ingress.js +231 -0
  63. package/dist/plan/org-names.d.ts +30 -0
  64. package/dist/plan/org-names.js +108 -0
  65. package/dist/plan/own-diff.d.ts +170 -0
  66. package/dist/plan/own-diff.js +440 -0
  67. package/dist/plan/own-dns.d.ts +6 -0
  68. package/dist/plan/own-dns.js +15 -0
  69. package/dist/plan/own-hostnames.d.ts +27 -0
  70. package/dist/plan/own-hostnames.js +24 -0
  71. package/dist/plan/own-limits.d.ts +213 -0
  72. package/dist/plan/own-limits.js +232 -0
  73. package/dist/plan/own-services.d.ts +193 -0
  74. package/dist/plan/own-services.js +169 -0
  75. package/dist/plan/own-sfu.d.ts +69 -0
  76. package/dist/plan/own-sfu.js +256 -0
  77. package/dist/plan/plan-apply.d.ts +121 -10
  78. package/dist/plan/plan-apply.js +357 -18
  79. package/dist/plan/platform-diff.d.ts +155 -11
  80. package/dist/plan/platform-diff.js +271 -66
  81. package/dist/plan/platform-org.d.ts +107 -4
  82. package/dist/plan/platform-org.js +175 -3
  83. package/dist/plan/platform-providers.d.ts +474 -81
  84. package/dist/plan/platform-providers.js +1407 -560
  85. package/dist/plan/proxy-labels.d.ts +57 -11
  86. package/dist/plan/proxy-labels.js +94 -14
  87. package/dist/plan/run-options.d.ts +19 -0
  88. package/dist/plan/run-options.js +46 -0
  89. package/dist/plan/sign-in-org.d.ts +1 -1
  90. package/dist/plan/sign-in-org.js +20 -2
  91. package/dist/plan/zitadel-login-policy.d.ts +1 -1
  92. package/dist/plan/zitadel-login-policy.js +77 -9
  93. package/dist/plan/zitadel-system-api.d.ts +22 -1
  94. package/dist/plan/zitadel-system-api.js +49 -8
  95. package/dist/platform.d.ts +40 -0
  96. package/dist/platform.js +16 -0
  97. package/dist/provision.d.ts +2 -0
  98. package/dist/provision.js +3 -1
  99. package/dist/slug.d.ts +3 -1
  100. package/dist/slug.js +26 -3
  101. package/dist/store/postgres.js +93 -2
  102. package/dist/store/schema.d.ts +87 -0
  103. package/dist/store/schema.js +9 -0
  104. package/package.json +3 -3
  105. package/versions.digests.json +11 -0
  106. package/versions.json +3 -1
@@ -1,11 +1,14 @@
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';
10
+ import { NetworkMoveBackupMissingError, UpgradeBackupMissingError, platformDatabasesForService, requireFreshBackupsForMove, requireFreshBackupsForUpgrades, } from './backup-gate.js';
11
+ import { readLiveBuilds } from './build-record.js';
9
12
  import { unconfirmedUpgrades } from './confirm-upgrade.js';
10
13
  import { AI_KEY_UNDELIVERED_SCOPE_KEY, JOBS_REGISTRATION_UNDELIVERED_SCOPE_KEY, computePlanDetail, toPlan, } from './diff.js';
11
14
  import { entryAiUndeliveredScopeKey } from './entry-ai-key.js';
@@ -13,9 +16,15 @@ import { BOULE_EDGE_ENTRY_KEY, entryEdgeRetiredScopeKey, entryEdgeUndeliveredSco
13
16
  import { FILES_ROOT_UNDELIVERED_SCOPE_KEY, entryFilesUndeliveredScopeKey, } from './entry-files-key.js';
14
17
  import { readLivePlatformResources, readLiveResources, } from './live.js';
15
18
  import { lockedHostnames } from './locked-hostnames.js';
16
- 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';
18
- import { PLATFORM_AUTH_BROKER_MAIL_KEY_REF } from './platform-providers.js';
19
+ import { agoraIssuerHostFor, loginHostServesAgora, readLoginHostDiscovery, } from './login-host.js';
20
+ import { OWN_MAIL_SMTP_INTERNAL_SCOPE_KEY, OWN_MAIL_SMTP_PORT_BASE, OWN_MAIL_SMTP_PORT_MAX, OWN_MAIL_SMTP_PORT_SCOPE_KEY, PLATFORM_MAIL_RELAY_ROUTES_SCOPE_KEY, isOwnMailSmtpPort, mailRelayFingerprint, } from './mail-relay.js';
21
+ import { findNameClashes, nameClashMessage } from './org-names.js';
22
+ import { ownHostnames } from './own-hostnames.js';
23
+ import { OWN_FILES_R2_UNDELIVERED_SCOPE_KEY, orgTarget } from './own-services.js';
24
+ import { PLATFORM_ARCHON_KEY, PLATFORM_BACKUP_DATABASE_SCOPE_KEYS, PLATFORM_FILES_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_FILES_KEY, appStepKey, planPlatform as computePlatformPlanDetail, toPlatformPlan, } from './platform-diff.js';
25
+ import { DEFAULT_ORG_PLATFORM_READINESS, ORG_PLATFORM_KEY_REFS, agoraHostValuesForOwn, agoraHostValuesFromPlatform, dnsBlockedReason, heldOwnKeyRefs, heldPlatformKeyRefs, orgPlatformReadinessFor, orgPlatformReadinessFrom, ownerFilesFrom, ownerSignInFrom, platformOwnerUserIdFrom, serviceGitRefsOf, } from './platform-org.js';
26
+ import { PLATFORM_AUTH_BROKER_MAIL_KEY_REF, PLATFORM_BACKUP_S3_STORAGE_SCOPE_KEY, } from './platform-providers.js';
27
+ import { CLOUDFLARE_TUNNEL_ENTRY_ROUTES_SCOPE_KEY, CLOUDFLARE_TUNNEL_OIKOS_HOSTNAMES_SCOPE_KEY, } from './proxy-labels.js';
19
28
  import { liveWorkers, workerTraceScopeKeys } from './worker.js';
20
29
  /** #441: the lease scope that serializes every mutation of one company, and the one for the shared platform. */
21
30
  const companyScope = (orgId) => `company:${orgId}`;
@@ -126,6 +135,10 @@ const HOSTNAME_STEPS = new Set([
126
135
  * destructive or migrating step without the org's name typed, or one the
127
136
  * actor is not authorized to run.
128
137
  */
138
+ /** `unmanaged` key of the fixed line for an own org on a host with no `ownServices` port. */
139
+ const OWN_SERVICES_PORT_ABSENT_KEY = 'own-services:port';
140
+ /** `unmanaged` key of the fixed line for an own org whose scoped ports have no files-organisation port. */
141
+ const OWN_FILES_ORGANISATION_PORT_ABSENT_KEY = 'own-services:files-port';
129
142
  export class FoundryPlanApply {
130
143
  store;
131
144
  readers;
@@ -137,6 +150,7 @@ export class FoundryPlanApply {
137
150
  agora;
138
151
  jobsScheduler;
139
152
  loginHostBindings;
153
+ loginHostDiscovery;
140
154
  zitadelIdTokenUserinfo;
141
155
  broker;
142
156
  edgeWorkers;
@@ -144,10 +158,13 @@ export class FoundryPlanApply {
144
158
  hostFiles;
145
159
  hostAi;
146
160
  hostEdge;
161
+ hostOwnServices;
162
+ sfuPortsOpen;
147
163
  onlySteps;
148
164
  now;
149
165
  leaseTtlMs;
150
166
  appliedVersions;
167
+ backupGate;
151
168
  platformDeps;
152
169
  runner;
153
170
  constructor(options) {
@@ -161,6 +178,7 @@ export class FoundryPlanApply {
161
178
  this.agora = options.agora;
162
179
  this.jobsScheduler = options.jobsScheduler ?? false;
163
180
  this.loginHostBindings = options.loginHostBindings ?? false;
181
+ this.loginHostDiscovery = options.loginHostDiscovery;
164
182
  this.zitadelIdTokenUserinfo = options.zitadelIdTokenUserinfo ?? false;
165
183
  this.broker = options.broker ?? false;
166
184
  this.edgeWorkers = options.edgeWorkers ?? false;
@@ -168,10 +186,13 @@ export class FoundryPlanApply {
168
186
  this.hostFiles = options.filesOrganisation ?? false;
169
187
  this.hostAi = options.aiOrganisation ?? false;
170
188
  this.hostEdge = options.edge ?? false;
189
+ this.hostOwnServices = options.ownServices ?? false;
190
+ this.sfuPortsOpen = options.sfuPortsOpen;
171
191
  this.onlySteps = options.onlySteps;
172
192
  this.now = options.now ?? (() => new Date());
173
193
  this.leaseTtlMs = options.leaseTtlMs ?? DEFAULT_LEASE_TTL_MS;
174
194
  this.appliedVersions = options.appliedVersions;
195
+ this.backupGate = options.backupGate;
175
196
  this.platformDeps = options.platform;
176
197
  this.runner = options.runner;
177
198
  }
@@ -345,6 +366,7 @@ export class FoundryPlanApply {
345
366
  const reader = this.readers.coolify;
346
367
  const live = project && reader.listProject ? await reader.listProject(project) : null;
347
368
  const claimedElsewhere = [];
369
+ const platformRecorded = new Set(Object.values((await this.platformDeps?.store.getServiceIds()) ?? {}));
348
370
  for (const [what, uuid] of [
349
371
  ['application', input.applicationUuid],
350
372
  ['database', input.databaseUuid],
@@ -352,6 +374,10 @@ export class FoundryPlanApply {
352
374
  ]) {
353
375
  if (!uuid)
354
376
  continue;
377
+ if (platformRecorded.has(uuid)) {
378
+ claimedElsewhere.push(`${what} ${uuid} is recorded by the platform`);
379
+ continue;
380
+ }
355
381
  const owner = await this.store.findOtherOrgRecordingCoolifyId(uuid, id);
356
382
  if (owner)
357
383
  claimedElsewhere.push(`${what} ${uuid} is already recorded by org ${owner}`);
@@ -379,12 +405,50 @@ export class FoundryPlanApply {
379
405
  await this.refuse(plan, `version change without confirmUpgrade naming: ${unconfirmed.map((u) => u.service).join(', ')}`);
380
406
  throw new UpgradeConfirmationRequiredError(unconfirmed);
381
407
  }
408
+ // F6: before step one. No override.
409
+ let backupId = null;
410
+ if (this.backupGate) {
411
+ try {
412
+ ({ backupId } = await requireFreshBackupsForUpgrades(freshPlan, options.confirmUpgrade, {
413
+ coolify: this.backupGate.coolify,
414
+ orgId,
415
+ recordedIds: (await this.store.getAppliedState(orgId))?.serviceIds ?? {},
416
+ now: () => this.now().getTime(),
417
+ }));
418
+ }
419
+ catch (e) {
420
+ if (e instanceof UpgradeBackupMissingError) {
421
+ await this.refuse(plan, `upgrade of ${e.service} without a fresh backup`);
422
+ }
423
+ throw e;
424
+ }
425
+ }
426
+ // A move to the company's own network deletes its databases: a fresh dump of each first.
427
+ if (detail.plannedSteps.some((s) => s.action.system === 'coolify' && s.action.kind === 'network')) {
428
+ try {
429
+ if (!this.backupGate)
430
+ throw new NetworkMoveBackupMissingError('no-gate');
431
+ await requireFreshBackupsForMove({
432
+ coolify: this.backupGate.coolify,
433
+ projectUuid: (await this.store.getAppliedState(orgId))?.serviceIds
434
+ ?.coolify_project,
435
+ ownsMail: ownsServices(org) && org.mail.mode !== 'off',
436
+ now: () => this.now().getTime(),
437
+ });
438
+ }
439
+ catch (e) {
440
+ if (e instanceof NetworkMoveBackupMissingError) {
441
+ await this.refuse(plan, 'network move without a fresh backup');
442
+ }
443
+ throw e;
444
+ }
445
+ }
382
446
  if (planNeedsConfirmation(plan.steps) &&
383
447
  confirmedOrgName.trim() !== org.displayName.trim()) {
384
448
  await this.refuse(plan, "destructive or migrating step without the org's name typed correctly");
385
449
  throw new ConfirmationRequiredError(orgId);
386
450
  }
387
- const applied = await this.runSteps(org, plan, detail, fence);
451
+ const applied = await this.runSteps(org, plan, detail, fence, backupId);
388
452
  await this.refreshPlatformFilesShare();
389
453
  return applied;
390
454
  }));
@@ -479,6 +543,27 @@ export class FoundryPlanApply {
479
543
  await this.refusePlatform(plan, platform.ownerOrgId, `version change without confirmUpgrade naming: ${unconfirmed.map((u) => u.service).join(', ')}`);
480
544
  throw new UpgradeConfirmationRequiredError(unconfirmed);
481
545
  }
546
+ // F6: before step one. No override. The platform records no applied versions, so no backup id is kept.
547
+ if (this.backupGate) {
548
+ try {
549
+ await requireFreshBackupsForUpgrades(freshPlan, options.confirmUpgrade, {
550
+ coolify: this.backupGate.coolify,
551
+ orgId: platform.ownerOrgId,
552
+ recordedIds: await this.requirePlatformDeps().store.getServiceIds(),
553
+ now: () => this.now().getTime(),
554
+ scope: {
555
+ keys: PLATFORM_BACKUP_DATABASE_SCOPE_KEYS,
556
+ forService: platformDatabasesForService,
557
+ },
558
+ });
559
+ }
560
+ catch (e) {
561
+ if (e instanceof UpgradeBackupMissingError) {
562
+ await this.refusePlatform(plan, platform.ownerOrgId, `upgrade of ${e.service} without a fresh backup`);
563
+ }
564
+ throw e;
565
+ }
566
+ }
482
567
  if (planNeedsConfirmation(plan.steps) && confirmedName.trim() !== 'platform') {
483
568
  await this.refusePlatform(plan, platform.ownerOrgId, "destructive or migrating step without 'platform' typed correctly");
484
569
  throw new PlatformConfirmationRequiredError();
@@ -572,6 +657,11 @@ export class FoundryPlanApply {
572
657
  // gone" itself.
573
658
  await this.destroyer.destroy(target, { org, recordedIds: { ...serviceIds } });
574
659
  delete serviceIds[destroyServiceIdKey(target)];
660
+ if (target.system === 'cloudflare' && target.kind === 'tunnel') {
661
+ // The tunnel is gone, so what was written into it is too.
662
+ delete serviceIds[CLOUDFLARE_TUNNEL_ENTRY_ROUTES_SCOPE_KEY];
663
+ delete serviceIds[CLOUDFLARE_TUNNEL_OIKOS_HOSTNAMES_SCOPE_KEY];
664
+ }
575
665
  if (target.system === 'cloudflare' && target.kind === 'worker') {
576
666
  // #318: its partial-deploy traces too, or a retry after a later
577
667
  // step fails would read this Worker as live again.
@@ -792,15 +882,17 @@ export class FoundryPlanApply {
792
882
  at: this.now(),
793
883
  });
794
884
  }
795
- async runSteps(org, plan, detail, fence) {
885
+ async runSteps(org, plan, detail, fence, backupId = null) {
796
886
  const orgId = plan.orgId;
797
887
  const previous = await this.store.getAppliedState(orgId);
798
888
  const serviceIds = { ...(previous?.serviceIds ?? {}) };
799
889
  const checkpoints = [];
800
890
  // Backed by the very `serviceIds` map this method persists once, at the
801
891
  // end — see `StepExecutionContext`'s doc comment for why.
892
+ const ownerCompany = (await this.platformDeps?.store.get())?.ownerOrgId === orgId;
802
893
  const context = {
803
894
  org,
895
+ ownerCompany,
804
896
  recordedIds: () => ({ ...serviceIds }),
805
897
  now: () => this.now().getTime(),
806
898
  recordScopeId: (key, id) => {
@@ -887,6 +979,7 @@ export class FoundryPlanApply {
887
979
  service: orgId,
888
980
  ref: lastApplied.declaredOrgVersion,
889
981
  appliedAt: lastApplied.at,
982
+ backupId,
890
983
  });
891
984
  await this.audit.recordApply({
892
985
  actor: this.actor,
@@ -897,30 +990,118 @@ export class FoundryPlanApply {
897
990
  });
898
991
  return applied;
899
992
  }
993
+ /** An own org with calls on: the host's answer about its slot's ports. A failure or a slow answer is "not open". */
994
+ async readSfuPortsOpen(org) {
995
+ const slot = org.services?.sfuSlot;
996
+ if (!this.sfuPortsOpen || !ownsServices(org) || !org.calls.enabled || !slot)
997
+ return false;
998
+ let ports;
999
+ try {
1000
+ ports = sfuSlotPorts(slot);
1001
+ }
1002
+ catch {
1003
+ return false;
1004
+ }
1005
+ const timeout = new Promise((resolve) => setTimeout(() => resolve(false), 5_000));
1006
+ return Promise.race([this.sfuPortsOpen(ports).catch(() => false), timeout]);
1007
+ }
900
1008
  async computeFreshPlan(id) {
901
1009
  const org = await this.store.get(id);
902
1010
  if (!org)
903
1011
  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),
1012
+ const applied = await this.store.getAppliedState(id);
1013
+ const [live, platformReadiness, agoraHost, archonUrl] = await Promise.all([
1014
+ readLiveResources(id, this.readers, org, applied?.serviceIds ?? {}),
1015
+ this.orgPlatformReadiness(org, applied?.serviceIds ?? {}),
1016
+ this.orgAgoraHost(org, applied?.serviceIds ?? {}),
909
1017
  this.orgArchonUrl(),
910
1018
  ]);
911
1019
  const otherSlugs = (await this.store.list()).filter((o) => o.id !== id).map((o) => o.slug);
912
1020
  const platformHostnames = await this.platformServiceHostnames();
1021
+ const recorded = applied?.serviceIds ?? {};
1022
+ // Read only for an org Agora may switch and whose login host is applied.
1023
+ const issuerHost = agoraIssuerHostFor(org).host;
1024
+ const loginHostServesDiscovery = issuerHost !== null && loginHostServesAgora(issuerHost, org, recorded)
1025
+ ? await readLoginHostDiscovery(this.loginHostDiscovery, issuerHost)
1026
+ : false;
1027
+ const sfuPortsOpen = await this.readSfuPortsOpen(org);
1028
+ const resumable = await this.ownResumableRefs(org);
913
1029
  const detail = computePlanDetail(org, live, applied?.serviceIds ?? {}, agoraHost, this.jobsScheduler, platformReadiness, {
914
1030
  loginHostBindings: this.loginHostBindings,
1031
+ loginHostServesDiscovery,
915
1032
  zitadelIdTokenUserinfo: this.zitadelIdTokenUserinfo,
916
- broker: this.broker,
1033
+ broker: ownsServices(org) && this.hostOwnServices && this.executor.ownBrokerPort
1034
+ ? await this.executor.ownBrokerPort(org, recorded)
1035
+ : this.broker,
917
1036
  edgeWorkers: this.edgeWorkers,
918
1037
  ownerInviteMail: this.ownerInviteMail,
919
1038
  otherSlugs,
920
1039
  now: () => this.now().getTime(),
921
1040
  archonUrl,
922
1041
  platformHostnames,
1042
+ ownServices: this.hostOwnServices,
1043
+ // The `oikos.host` zone, its wildcard and its tunnel are the platform's `platform:oikos-ingress` step.
1044
+ oikosZoneReady: platformReadiness.oikosIngressApplied === true,
1045
+ ownMailKeyHeld: await this.ownMailKeyHeld(org),
1046
+ ownMailKeyMintable: this.executor.ownMailKeyMintable?.() ?? false,
1047
+ ...(this.backupGate
1048
+ ? {
1049
+ backupStorageUuid: (await this.platformDeps?.store.getServiceIds())?.[PLATFORM_BACKUP_S3_STORAGE_SCOPE_KEY] || null,
1050
+ }
1051
+ : {}),
1052
+ ownJobsTokenHeld: resumable.jobs,
1053
+ ownSfuKeysHeld: resumable.sfu,
1054
+ ...(ownsServices(org)
1055
+ ? {
1056
+ mailRelayApplied: await this.mailRelayApplied(),
1057
+ ownMailSmtpPort: await this.ownMailSmtpPort(org),
1058
+ mailRelayRoute: await this.mailRelayHoldsRoute(org),
1059
+ }
1060
+ : {}),
1061
+ sfuPortsOpen,
1062
+ ownFilesDeps: this.executor.ownFilesDeps?.(),
1063
+ ...(ownsServices(org) && this.executor.ownFilesR2Ports
1064
+ ? { ownFilesR2Ports: await this.executor.ownFilesR2Ports(org, recorded) }
1065
+ : {}),
923
1066
  });
1067
+ if (!ownsServices(org)) {
1068
+ // No company runs the shared services; each runs its own.
1069
+ detail.plannedSteps = [];
1070
+ detail.versionChanges = [];
1071
+ detail.unmanaged.push({
1072
+ description: `${org.slug} has no services.own: the shared services are removed, so a company runs its own. Set services.own.`,
1073
+ key: 'services:shared-removed',
1074
+ });
1075
+ }
1076
+ if (ownsServices(org) && !this.hostOwnServices) {
1077
+ detail.unmanaged.push({
1078
+ description: `${org.slug} runs its own services, and this host has no ownServices port`,
1079
+ key: OWN_SERVICES_PORT_ABSENT_KEY,
1080
+ });
1081
+ }
1082
+ if (ownsServices(org) &&
1083
+ this.hostOwnServices &&
1084
+ detail.plannedSteps.some(({ action }) => action.system === 'files' && action.kind === 'organisation') &&
1085
+ (await this.executor.ownFilesOrganisationPort?.(org, recorded)) === false) {
1086
+ detail.plannedSteps = detail.plannedSteps.filter(({ action }) => !(action.system === 'files' && action.kind === 'organisation'));
1087
+ detail.unmanaged.push({
1088
+ description: `${org.slug}'s Files organisation is blocked, not planned: the host gave the company no files-organisation port`,
1089
+ key: OWN_FILES_ORGANISATION_PORT_ABSENT_KEY,
1090
+ });
1091
+ }
1092
+ const clashes = findNameClashes(org, await this.store.list());
1093
+ for (const source of ['own', 'hosting']) {
1094
+ const clash = clashes.find((c) => c.source === source);
1095
+ if (!clash)
1096
+ continue;
1097
+ detail.plannedSteps = detail.plannedSteps.filter(({ action }) => source === 'own'
1098
+ ? !(action.system === 'coolify' && action.kind.startsWith('own-'))
1099
+ : !(action.system === 'coolify' && (action.kind === 'app' || action.kind === 'database')));
1100
+ detail.unmanaged.push({
1101
+ description: `${org.slug}'s ${source === 'own' ? 'own services are' : 'hosting is'} blocked, not planned: ${nameClashMessage(org.slug, clash)}`,
1102
+ key: `names:clash:${source}`,
1103
+ });
1104
+ }
924
1105
  const invalid = await this.invalidHosting(org);
925
1106
  if (invalid) {
926
1107
  // Rule 2: reported in `unmanaged`, not thrown. Steps that read the
@@ -960,7 +1141,78 @@ export class FoundryPlanApply {
960
1141
  * DEFAULT_ORG_PLATFORM_READINESS} reports every service not ready, so every
961
1142
  * org step that needs one is blocked.
962
1143
  */
963
- async orgPlatformReadiness() {
1144
+ async orgPlatformReadiness(org, orgRecordedIds) {
1145
+ const base = await this.sharedPlatformReadiness();
1146
+ if (!ownsServices(org))
1147
+ return base;
1148
+ const held = this.platformDeps?.heldKeys;
1149
+ const platform = await this.platformDeps?.store.get();
1150
+ const readiness = orgPlatformReadinessFor(org, base, orgRecordedIds, held ? await heldOwnKeyRefs(held, org) : new Set());
1151
+ return platform
1152
+ ? { ...readiness, serviceGitRefs: serviceGitRefsOf(platform, org.versionPin) }
1153
+ : readiness;
1154
+ }
1155
+ /** An own org's Lettermint key is held. A resolver that throws counts as not held. */
1156
+ async ownMailKeyHeld(org) {
1157
+ const held = this.platformDeps?.heldKeys;
1158
+ if (!ownsServices(org) || !held)
1159
+ return false;
1160
+ return (await heldOwnKeyRefs(held, org)).has(orgTarget(org).refs.brokerMailKey);
1161
+ }
1162
+ /** Which of an own org's undelivered-secret refs hold a value now, so a rerun resumes instead of blocking. */
1163
+ async ownResumableRefs(org) {
1164
+ const held = this.platformDeps?.heldKeys;
1165
+ if (!ownsServices(org) || !held)
1166
+ return { jobs: false, sfu: false };
1167
+ const refs = orgTarget(org).refs;
1168
+ const heldRefs = await heldOwnKeyRefs(held, org);
1169
+ return { jobs: heldRefs.has(refs.jobsAdminToken), sfu: heldRefs.has(refs.livekitKeys) };
1170
+ }
1171
+ /**
1172
+ * The host port an own org's Stalwart publishes for the relay: the one it already has recorded, else
1173
+ * the lowest port in the range no other org has recorded. Two plans made at the same moment could pick
1174
+ * the same port; the second Stalwart then fails to bind it and the step fails, with nothing recorded.
1175
+ */
1176
+ async ownMailSmtpPort(org) {
1177
+ const own = (await this.store.getAppliedState(org.id))?.serviceIds?.[OWN_MAIL_SMTP_PORT_SCOPE_KEY];
1178
+ if (own && isOwnMailSmtpPort(Number(own)))
1179
+ return Number(own);
1180
+ const taken = new Set();
1181
+ for (const other of await this.store.list()) {
1182
+ const port = (await this.store.getAppliedState(other.id))?.serviceIds?.[OWN_MAIL_SMTP_PORT_SCOPE_KEY];
1183
+ if (port)
1184
+ taken.add(Number(port));
1185
+ }
1186
+ for (let port = OWN_MAIL_SMTP_PORT_BASE; port <= OWN_MAIL_SMTP_PORT_MAX; port += 1) {
1187
+ if (!taken.has(port))
1188
+ return port;
1189
+ }
1190
+ return OWN_MAIL_SMTP_PORT_MAX + 1;
1191
+ }
1192
+ /** The relay is declared and has recorded the routes it last applied: port 25 reaches the mail servers. */
1193
+ async mailRelayApplied() {
1194
+ const platform = await this.platformDeps?.store.get();
1195
+ if (!platform?.mail.relay)
1196
+ return false;
1197
+ const recorded = await this.platformDeps?.store.getServiceIds();
1198
+ return Boolean(recorded?.[PLATFORM_MAIL_RELAY_ROUTES_SCOPE_KEY]);
1199
+ }
1200
+ /**
1201
+ * The relay's last applied routes (a fingerprint, recorded when it deploys) are the routes the
1202
+ * orgs' recorded SMTP addresses give now, and one of them is this org's mail domain.
1203
+ */
1204
+ async mailRelayHoldsRoute(org) {
1205
+ const platform = await this.platformDeps?.store.get();
1206
+ if (!platform?.mail.relay)
1207
+ return false;
1208
+ const { mailRoutes } = await this.mailRelayInputs();
1209
+ const domain = ownHostnames(org).mailDomain.toLowerCase();
1210
+ if (!mailRoutes.some((r) => r.domain.toLowerCase() === domain))
1211
+ return false;
1212
+ const recorded = await this.platformDeps?.store.getServiceIds();
1213
+ return recorded?.[PLATFORM_MAIL_RELAY_ROUTES_SCOPE_KEY] === mailRelayFingerprint(mailRoutes);
1214
+ }
1215
+ async sharedPlatformReadiness() {
964
1216
  if (!this.platformDeps)
965
1217
  return DEFAULT_ORG_PLATFORM_READINESS;
966
1218
  const platform = await this.platformDeps.store.get();
@@ -985,8 +1237,13 @@ export class FoundryPlanApply {
985
1237
  catch {
986
1238
  // not held
987
1239
  }
1240
+ const ownerOrg = await this.store.get(platform.ownerOrgId);
1241
+ const ownerRecorded = ownerOrg
1242
+ ? (await this.store.getAppliedState(ownerOrg.id))?.serviceIds
1243
+ : {};
988
1244
  return {
989
1245
  ...readiness,
1246
+ platformOwnerUserId: platformOwnerUserIdFrom(ownerOrg, ownerRecorded ?? {}),
990
1247
  mailKeyHeld,
991
1248
  canHoldSecrets: typeof heldKeys?.put === 'function',
992
1249
  dnsBlocked: dnsBlockedReason(platform, recorded),
@@ -998,11 +1255,14 @@ export class FoundryPlanApply {
998
1255
  * `platformDeps` {@link orgPlatformReadiness} reads -- but only when the
999
1256
  * host supplies no `agora` port of its own. An explicit `agora` (#164)
1000
1257
  * always wins, unchanged, so a host that already has its own values
1001
- * (perhaps not yet wired to this platform) keeps them.
1258
+ * (perhaps not yet wired to this platform) keeps them. An org that owns its
1259
+ * services never takes the host's values: they belong to the shared services.
1002
1260
  */
1003
- async orgAgoraHost(id) {
1261
+ async orgAgoraHost(org, orgRecordedIds) {
1262
+ if (ownsServices(org))
1263
+ return agoraHostValuesForOwn(org, orgRecordedIds);
1004
1264
  if (this.agora)
1005
- return readAgoraHost(this.agora, id);
1265
+ return readAgoraHost(this.agora, org.id);
1006
1266
  if (!this.platformDeps)
1007
1267
  return NO_AGORA_HOST_VALUES;
1008
1268
  const platform = await this.platformDeps.store.get();
@@ -1051,16 +1311,73 @@ export class FoundryPlanApply {
1051
1311
  const callsOrgSlugs = (await this.store.list())
1052
1312
  .filter((org) => org.calls.enabled)
1053
1313
  .map((org) => org.slug);
1314
+ const mailRelay = platform.mail.relay === true ? await this.mailRelayInputs() : null;
1315
+ const owner = await this.platformOwnerInput(platform);
1054
1316
  const detail = computePlatformPlanDetail(platform, live, {
1317
+ owner,
1055
1318
  jobsObservable: readers.coolify !== undefined,
1056
1319
  sfuBlocked,
1057
1320
  callsOrgSlugs,
1321
+ ...(mailRelay ?? {}),
1058
1322
  recordedIds: serviceIds,
1059
- blocked: (await executor.blockedSteps?.(platform, serviceIds)) ?? {},
1060
- drifted: (await executor.driftedSteps?.(platform, serviceIds)) ?? {},
1323
+ liveBuilds: await readLiveBuilds(readers.coolify, serviceIds),
1324
+ blocked: (await executor.blockedSteps?.(platform, serviceIds, owner)) ?? {},
1325
+ drifted: (await executor.driftedSteps?.(platform, serviceIds, owner)) ?? {},
1061
1326
  });
1062
1327
  return { platform, plan: toPlatformPlan(detail, platform.ownerOrgId), detail };
1063
1328
  }
1329
+ /** The owner company's own sign-in and files host, read from its applied ids; `null` members until applied. */
1330
+ async platformOwnerInput(platform) {
1331
+ const owner = await this.store.get(platform.ownerOrgId);
1332
+ if (!owner)
1333
+ return { signIn: null, files: null };
1334
+ const recorded = (await this.store.getAppliedState(owner.id))?.serviceIds ?? {};
1335
+ return { signIn: ownerSignInFrom(owner, recorded), files: ownerFilesFrom(owner, recorded) };
1336
+ }
1337
+ /**
1338
+ * What the shared mail relay needs from the orgs. A route exists for an org
1339
+ * that owns its services, serves mail on its own domain and has recorded its
1340
+ * mail server's SMTP address (`mail_smtp_internal`, written by the company
1341
+ * mail step), on its mail domain: its own domain, or `<slug>.oikos.host`.
1342
+ * There is no platform mail server: a domain with no route is refused. An org
1343
+ * that owns its services but has no mail server yet has no route, so the
1344
+ * relay refuses its mail until the server exists.
1345
+ */
1346
+ async mailRelayInputs() {
1347
+ const candidates = [];
1348
+ for (const org of await this.store.list()) {
1349
+ if (org.mail.mode === 'off')
1350
+ continue;
1351
+ if (!ownsServices(org))
1352
+ continue;
1353
+ const recorded = (await this.store.getAppliedState(org.id))?.serviceIds ?? {};
1354
+ const target = recorded[OWN_MAIL_SMTP_INTERNAL_SCOPE_KEY];
1355
+ if (recorded.coolify_platform_mail_app && target) {
1356
+ candidates.push({
1357
+ slug: org.slug,
1358
+ route: { domain: ownHostnames(org).mailDomain, target },
1359
+ });
1360
+ }
1361
+ }
1362
+ // A domain two companies claim gets no route; the other routes stay.
1363
+ const claims = new Map();
1364
+ for (const { route } of candidates) {
1365
+ const domain = route.domain.toLowerCase();
1366
+ claims.set(domain, (claims.get(domain) ?? 0) + 1);
1367
+ }
1368
+ const mailRoutes = [];
1369
+ const mailRoutesBlocked = [];
1370
+ for (const { slug, route } of candidates) {
1371
+ const domain = route.domain.toLowerCase();
1372
+ if ((claims.get(domain) ?? 0) > 1) {
1373
+ mailRoutesBlocked.push(`${slug}'s mail route for ${route.domain} is blocked, not planned: another company claims ${route.domain}`);
1374
+ }
1375
+ else {
1376
+ mailRoutes.push(route);
1377
+ }
1378
+ }
1379
+ return { mailRoutes, mailRoutesBlocked };
1380
+ }
1064
1381
  async sfuBlockedReason(platform) {
1065
1382
  if (!platform.sfu.enabled)
1066
1383
  return null;
@@ -1100,6 +1417,7 @@ export class FoundryPlanApply {
1100
1417
  const serviceIds = { ...(await store.getServiceIds()) };
1101
1418
  const context = {
1102
1419
  platform,
1420
+ owner: await this.platformOwnerInput(platform),
1103
1421
  recordedIds: () => ({ ...serviceIds }),
1104
1422
  recordScopeId: (key, id) => {
1105
1423
  serviceIds[key] = id;
@@ -1202,6 +1520,8 @@ function destroyTargets(serviceIds, orgId) {
1202
1520
  'cloudflare:worker:',
1203
1521
  'coolify:database:',
1204
1522
  'coolify:app:',
1523
+ // An own files host's storage key: the token id is the step's recorded resource id.
1524
+ 'coolify:own-files-r2:',
1205
1525
  'zitadel:customers:',
1206
1526
  // F6a: an entry's ai key, `<orgId>:<entry>`; the org's own key has no `:` and is left alone.
1207
1527
  'ai:key:',
@@ -1281,6 +1601,16 @@ function destroyTargets(serviceIds, orgId) {
1281
1601
  continue;
1282
1602
  targets.push({ system: 'edge', kind: 'key', key: entryKey, id });
1283
1603
  }
1604
+ // A storage key minted but never delivered has no main id; its token is deleted too.
1605
+ const r2Undelivered = serviceIds[OWN_FILES_R2_UNDELIVERED_SCOPE_KEY];
1606
+ if (r2Undelivered) {
1607
+ targets.push({
1608
+ system: 'coolify',
1609
+ kind: 'own-files-r2',
1610
+ key: 'undelivered',
1611
+ id: r2Undelivered,
1612
+ });
1613
+ }
1284
1614
  for (const [resourceKey, id] of Object.entries(serviceIds)) {
1285
1615
  if (!resourceKey.startsWith('zitadel:app:') || !id)
1286
1616
  continue;
@@ -1337,6 +1667,9 @@ function destroyStepDescriptions(targets) {
1337
1667
  function destroyServiceIdKey(target) {
1338
1668
  if (target.kind === 'project' || target.kind === 'org')
1339
1669
  return target.key;
1670
+ if (target.kind === 'own-files-r2' && target.key === 'undelivered') {
1671
+ return OWN_FILES_R2_UNDELIVERED_SCOPE_KEY;
1672
+ }
1340
1673
  return `${target.system}:${target.kind}:${target.key}`;
1341
1674
  }
1342
1675
  /**
@@ -1352,6 +1685,12 @@ const STEP_FIELDS = {
1352
1685
  'coolify:broker-entry': 'hosting.entries.uses.auth',
1353
1686
  'coolify:mail': 'mail',
1354
1687
  'coolify:chat': 'chat.usesDomain',
1688
+ 'coolify:own-auth': 'services.own',
1689
+ 'coolify:own-files': 'services.own',
1690
+ 'coolify:own-files-r2': 'services.own',
1691
+ 'coolify:own-jobs': 'services.own',
1692
+ 'coolify:own-mail': 'services.own',
1693
+ 'coolify:own-sfu': 'services.own',
1355
1694
  'coolify:calls': 'calls.enabled',
1356
1695
  'coolify:calls-service': 'calls.enabled',
1357
1696
  'coolify:calls-chat': 'calls.enabled',