@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
package/dist/plan/diff.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { VERSIONS, floatingTagBlocked } from '../bootstrap/versions.js';
2
- import { APP_ENTRY_KEY, ORG_POSTGRES_SERVER_SCOPE_KEY, ORG_SERVICE_SCHEMAS, ownerMailboxRecorded, ownerMailboxes, } from '../org.js';
2
+ import { APP_ENTRY_KEY, ORG_POSTGRES_SERVER_SCOPE_KEY, ORG_SERVICE_SCHEMAS, ownerMailboxRecorded, ownerMailboxes, ownsServices, } from '../org.js';
3
3
  import { AGORA_ACTIVATED_ENV_SCOPE_KEY, AGORA_APP_PORTS_EXPOSES, CALLS_INTROSPECTION_SCOPE_KEY, CALLS_INTROSPECTION_UNDELIVERED_SCOPE_KEY, CALLS_SERVICE_DEPLOYED_SCOPE_KEY, CHAT_CALLS_APPLIED_SCOPE_KEY, CHAT_CALLS_TOKEN_SCOPE_KEY, CHAT_CALLS_TOKEN_UNDELIVERED_SCOPE_KEY, CHAT_INTROSPECTION_SCOPE_KEY, NO_AGORA_HOST_VALUES, ZITADEL_ORG_SCOPE_KEY, agoraEnvDrift, agoraEnvRevision, agoraHostMissing, agoraHostnameFor, callsServiceHasName, callsServiceHasTokenEnv, callsTokenEnvMovedMessage, callsTokenHostFrom, chatCallsApplied, chatHasUpstreams, desiredAgoraEnv, withRecordedMessagingProject, } from './agora.js';
4
- import { BOULE_BROKER_ENTRY_KEY, BOULE_MAIL_TOKEN_ENV, bouleEnvDrift, bouleMailTokenDrift, bouleMailWanted, desiredBouleEnv, wantedBouleBrokerEntry, } from './boule.js';
4
+ import { BOULE_BRIDGE_SECRET_ENV, BOULE_BROKER_ENTRY_KEY, BOULE_MAIL_TOKEN_ENV, BOULE_PEER_PASSWORD_ENV, BOULE_SECRETS_RESTARTED_SCOPE_KEY, bouleEnvDrift, bouleMailTokenDrift, bouleMailWanted, desiredBouleEnv, wantedBouleBrokerEntry, } from './boule.js';
5
5
  import { brokerEntryAppliedScopeKey, brokerEntryWarningsScopeKey, entryAuthEnv, entryAuthEnvAppliedScopeKey, wantedBrokerEntry, } from './broker-entries.js';
6
6
  import { chatInternalNames, chatUpstreams } from './chat-matrix.js';
7
7
  import { planEntryAiKeys } from './entry-ai-key.js';
@@ -10,12 +10,15 @@ import { planFilesKeys } from './entry-files-key.js';
10
10
  import { hasWildcardGate, planGates } from './gates.js';
11
11
  import { appHostname, entryHostnames, tunnelDnsHostnames, tunnelDomainList } from './hostnames.js';
12
12
  import { lockedHostnames } from './locked-hostnames.js';
13
- import { LOGIN_BINDING_SCOPE_KEY, companyLoginTunnelHost, loginBindingState, loginHostClientIds, loginHostnameFor, pendingRemovalHosts, } from './login-host.js';
13
+ import { LOGIN_BINDING_SCOPE_KEY, agoraIssuerHostFor, companyLoginTunnelHost, loginBindingState, loginHostClientIds, loginHostServesAgora, loginHostnameFor, orgLoginHost, ownAuthApplied, pendingRemovalHosts, } from './login-host.js';
14
14
  import { MAIL_COPY_COMPLETED_SCOPE_KEY, MAIL_COPY_DONE_SCOPE_KEY, parseMailCopyCompleted, } from './mail-copy.js';
15
- import { CALLS_MAS_APPLIED_IMAGE_TAG_SCOPE_KEY, CALLS_SYNAPSE_APPLIED_IMAGE_TAG_SCOPE_KEY, ORG_MAIL_APPLIED_APEX_SCOPE_KEY, ORG_MAIL_APPLIED_HOST_SCOPE_KEY, ORG_MAIL_APPLIED_MODE_SCOPE_KEY, ORG_MAIL_APPLIED_USES_DOMAIN_SCOPE_KEY, appEnvAppliedScopeKey, appFolderDiffers, appSettingsOf, appStepKey, appliedAppDomains, appliedAppSettings, dbEnvAppliedScopeKey, resolveAppEnv, serviceDbEnvAppliedScopeKey, versionNotAppliedLine, zitadelAppIdTokenUserinfoAppliedScopeKey, } from './platform-diff.js';
15
+ import { OWN_AUTH_BLOCKED_KEY, OWN_FILES_BLOCKED_KEY, OWN_FILES_R2_BLOCKED_KEY, OWN_JOBS_BLOCKED_KEY, OWN_MAIL_BLOCKED_KEY, OWN_SFU_BLOCKED_KEY, OWN_SFU_FIREWALL_KEY, planOwnAuth, planOwnFiles, planOwnFilesR2, planOwnJobs, planOwnMail, planOwnSfu, } from './own-diff.js';
16
+ import { ownDnsHostnames } from './own-dns.js';
17
+ import { CALLS_MAS_APPLIED_IMAGE_TAG_SCOPE_KEY, CALLS_SYNAPSE_APPLIED_IMAGE_TAG_SCOPE_KEY, ORG_MAIL_APPLIED_APEX_SCOPE_KEY, ORG_MAIL_APPLIED_HOST_SCOPE_KEY, ORG_MAIL_APPLIED_MODE_SCOPE_KEY, ORG_MAIL_APPLIED_USES_DOMAIN_SCOPE_KEY, PLATFORM_FILES_INTERNAL_URL_SCOPE_KEY, appEnvAppliedScopeKey, appFolderDiffers, appSettingsOf, appStepKey, appliedAppDomains, appliedAppSettings, dbEnvAppliedScopeKey, resolveAppEnv, serviceDbEnvAppliedScopeKey, versionNotAppliedLine, zitadelAppIdTokenUserinfoAppliedScopeKey, } from './platform-diff.js';
16
18
  import { DEFAULT_ORG_PLATFORM_READINESS, } from './platform-org.js';
17
19
  import { PLATFORM_AUTH_BROKER_MAIL_KEY_REF } from './platform-providers.js';
18
20
  import { appLabelsAppliedScopeKey, entryLabelHash, entryLabelSpec } from './proxy-labels.js';
21
+ import { ZITADEL_ORG_DOMAIN_BLOCKED_SCOPE_KEY, ZITADEL_ORG_DOMAIN_SCOPE_KEY, ZITADEL_ORG_REGISTRATION_CLOSED_SCOPE_KEY, signInDomainBlockedText, signInOrgDomain, } from './sign-in-org.js';
19
22
  import { withStepSystem } from './step-system.js';
20
23
  import { planWorkers } from './worker.js';
21
24
  /** The `Plan` (#19's public contract) a `PlanDetail` carries. */
@@ -55,6 +58,10 @@ function resourceKey(system, kind, key) {
55
58
  function madeByFoundry(org, r, recordedIds, recordedValues, platformNames) {
56
59
  if (PROVIDER_KINDS[r.system]?.includes(r.kind) && recordedValues.has(r.key))
57
60
  return true;
61
+ // The reader maps an applied own step's app onto its marker (`coolify:own-<service>:<org.id>`); only this org's id is its own.
62
+ if (r.system === 'coolify' && OWN_MARKER_KINDS.has(r.kind) && r.key === org.id) {
63
+ return Boolean(recordedIds[`coolify:${r.kind}:${org.id}`]);
64
+ }
58
65
  if (r.system === 'coolify') {
59
66
  if (r.kind === 'environment')
60
67
  return true;
@@ -77,6 +84,11 @@ function madeByFoundry(org, r, recordedIds, recordedValues, platformNames) {
77
84
  if (type === 'CNAME' && content.endsWith('.cfargotunnel.com') && tunnelHostnames.includes(name)) {
78
85
  return true;
79
86
  }
87
+ if (type === 'CNAME' &&
88
+ content.endsWith('.cfargotunnel.com') &&
89
+ ownDnsHostnames(recordedIds).includes(name)) {
90
+ return true;
91
+ }
80
92
  const mailDomain = recordedIds[MAIL_DOMAIN_SCOPE_KEY]?.toLowerCase();
81
93
  if (!mailDomain)
82
94
  return false;
@@ -85,6 +97,14 @@ function madeByFoundry(org, r, recordedIds, recordedValues, platformNames) {
85
97
  }
86
98
  return name === `_dmarc.${mailDomain}` || name.endsWith(`._domainkey.${mailDomain}`);
87
99
  }
100
+ const OWN_MARKER_KINDS = new Set([
101
+ 'own-auth',
102
+ 'own-mail',
103
+ 'own-files',
104
+ 'own-files-r2',
105
+ 'own-jobs',
106
+ 'own-sfu',
107
+ ]);
88
108
  /** The kinds a provider lists a resource under before a recorded `system:kind:key` maps it to foundry's own. */
89
109
  const PROVIDER_KINDS = {
90
110
  coolify: ['application', 'database'],
@@ -165,11 +185,34 @@ export function computePlanDetail(org, live, recordedIds = {}, agoraHost = NO_AG
165
185
  // The platform's own org: its domain is the platform apex (the same fact
166
186
  // #491 reads for mail). It signs in on the shared instance's own host, so
167
187
  // `issuer.usesDomain` never moves it.
168
- const isPlatformOrg = isPlatformOwnOrg(org, platformReadiness.platformApex);
188
+ const isPlatformOrg = isPlatformOwnOrg(org, platformReadiness.platformOwnerOrgId);
189
+ // An own org's sign-in comes before every step that signs in through it.
190
+ const ownAuth = planOwnAuth({
191
+ org,
192
+ recordedIds,
193
+ readiness: platformReadiness,
194
+ isPlatformOrg,
195
+ ownServices: options.ownServices ?? false,
196
+ zone: org.domain ? zoneReadiness(org.domain, byKey) : null,
197
+ mailKeyHeld: options.ownMailKeyHeld ?? false,
198
+ mailKeyMintable: options.ownMailKeyMintable ?? false,
199
+ });
200
+ if (ownAuth.step)
201
+ plannedSteps.push(ownAuth.step);
202
+ // An own org's ZITADEL is the company's own instance: nothing that needs it plans before it exists.
203
+ const ownSignInPending = ownsServices(org) && !isPlatformOrg && !ownAuthApplied(org, recordedIds);
169
204
  const issuer = planIssuer(org, byKey, claimed, platformReadiness.authReady, isPlatformOrg);
170
205
  if (issuer.step)
171
206
  plannedSteps.push(issuer.step);
172
- plannedSteps.push(...planOwners(org, recordedIds, platformReadiness.authReady, platformReadiness.authIssuer));
207
+ // Before the domain and the owners: no invite runs on an org still open to registration.
208
+ const registrationClosed = planOrgRegistrationClosed(org, recordedIds, platformReadiness.authReady, issuer.step);
209
+ if (registrationClosed)
210
+ plannedSteps.push(registrationClosed);
211
+ const orgDomain = planOrgDomain(org, recordedIds, platformReadiness.authReady, issuer.step);
212
+ if (orgDomain)
213
+ plannedSteps.push(orgDomain);
214
+ const owners = planOwners(org, recordedIds, platformReadiness.authReady, platformReadiness.authIssuer, options.ownerInviteMail ?? false, registrationClosed !== null);
215
+ plannedSteps.push(...owners.steps);
173
216
  // #267: after the ZITADEL org step, before any app's own issuer
174
217
  // application. Apps created later in this run are not in the binding yet;
175
218
  // the next run replans this step and repoints them.
@@ -204,6 +247,17 @@ export function computePlanDetail(org, live, recordedIds = {}, agoraHost = NO_AG
204
247
  else if (zone.step) {
205
248
  plannedSteps.push(zone.step);
206
249
  }
250
+ const ownMail = planOwnMail({
251
+ org,
252
+ recordedIds,
253
+ readiness: platformReadiness,
254
+ isPlatformOrg,
255
+ ownServices: options.ownServices ?? false,
256
+ mailRelayApplied: options.mailRelayApplied ?? false,
257
+ zoneActive: zone.step === null && !zoneWaiting,
258
+ });
259
+ if (ownMail.step)
260
+ plannedSteps.push(ownMail.step);
207
261
  const mail = planMail(org, byKey, claimed, recordedIds, platformReadiness.mailHost, platformReadiness.platformApex, platformReadiness.platformLettermintProjectId);
208
262
  // #238: own-domain mail publishes DNS records into the zone, so it waits for
209
263
  // an active zone -- blocked, not planned (rule 2).
@@ -229,6 +283,19 @@ export function computePlanDetail(org, live, recordedIds = {}, agoraHost = NO_AG
229
283
  const calls = planCalls(org, byKey, claimed, platformReadiness.callsReady);
230
284
  if (calls.step)
231
285
  plannedSteps.push(calls.step);
286
+ // An own org's media server, before the calls service that reads its addresses.
287
+ const ownSfu = planOwnSfu({
288
+ org,
289
+ recordedIds,
290
+ isPlatformOrg,
291
+ ownServices: options.ownServices ?? false,
292
+ zoneActive: zone.step === null && !zoneWaiting,
293
+ readiness: platformReadiness,
294
+ sfuPortsOpen: options.sfuPortsOpen ?? false,
295
+ keysHeld: options.ownSfuKeysHeld ?? false,
296
+ });
297
+ if (ownSfu.step)
298
+ plannedSteps.push(ownSfu.step);
232
299
  // Right after the Matrix stack (#145): its own executor
233
300
  // (`executor.ts`'s `coolifyCallsService`) reads the Synapse/MAS addresses
234
301
  // and MAS client credentials `coolifyCalls` just recorded, the same
@@ -260,7 +327,30 @@ export function computePlanDetail(org, live, recordedIds = {}, agoraHost = NO_AG
260
327
  if (masChange)
261
328
  versionChanges.push(masChange);
262
329
  }
263
- const filesOrganisation = planFilesOrganisation(org, byKey, claimed, platformReadiness.filesReady, platformReadiness.missingKeys.files, platformReadiness.noKeyReader);
330
+ // An own org's files host, before its organisation on it.
331
+ const ownFiles = planOwnFiles({
332
+ org,
333
+ recordedIds,
334
+ readiness: platformReadiness,
335
+ isPlatformOrg,
336
+ ownServices: options.ownServices ?? false,
337
+ filesDeps: options.ownFilesDeps ?? { keys: false, bucket: false },
338
+ });
339
+ if (ownFiles.step)
340
+ plannedSteps.push(ownFiles.step);
341
+ // After the host is applied: its storage key is delivered to the running host.
342
+ const ownFilesR2 = options.ownFilesR2Ports
343
+ ? planOwnFilesR2({
344
+ org,
345
+ recordedIds,
346
+ isPlatformOrg,
347
+ ownServices: options.ownServices ?? false,
348
+ ports: options.ownFilesR2Ports,
349
+ })
350
+ : { step: null, blocked: null };
351
+ if (ownFilesR2.step)
352
+ plannedSteps.push(ownFilesR2.step);
353
+ const filesOrganisation = planFilesOrganisation(org, byKey, claimed, platformReadiness.filesReady, platformReadiness.missingKeys.files, platformReadiness.noKeyReader, ownsServices(org) && !isPlatformOrg && !recordedIds[`coolify:own-files:${org.id}`]);
264
354
  if (filesOrganisation.step)
265
355
  plannedSteps.push(filesOrganisation.step);
266
356
  const aiOrganisation = planAiOrganisation(org, byKey, claimed, platformReadiness.aiReady, platformReadiness.missingKeys.ai, platformReadiness.noKeyReader);
@@ -402,7 +492,9 @@ export function computePlanDetail(org, live, recordedIds = {}, agoraHost = NO_AG
402
492
  const agoraGuarded = guardApp(agoraClient);
403
493
  if (agoraGuarded)
404
494
  plannedSteps.push(agoraGuarded);
405
- const agora = planAgora(org, byKey, claimed, recordedIds, agoraHost);
495
+ const agora = planAgora(org, byKey, claimed, recordedIds, agoraHost, isPlatformOrg, {
496
+ servesDiscovery: options.loginHostServesDiscovery ?? false,
497
+ });
406
498
  if (agora.step)
407
499
  plannedSteps.push(agora.step);
408
500
  // #161 (Boule part): every org's own Boule -- always on, no toggle, the same
@@ -424,10 +516,23 @@ export function computePlanDetail(org, live, recordedIds = {}, agoraHost = NO_AG
424
516
  const bouleBroker = planBouleBrokerEntry(org, recordedIds, plannedSteps, options.broker ?? false);
425
517
  if (bouleBroker.step)
426
518
  plannedSteps.push(bouleBroker.step);
519
+ // An own org's scheduler, before its registration below.
520
+ const ownJobs = planOwnJobs({
521
+ org,
522
+ recordedIds,
523
+ readiness: platformReadiness,
524
+ isPlatformOrg,
525
+ ownServices: options.ownServices ?? false,
526
+ tokenHeld: options.ownJobsTokenHeld ?? false,
527
+ });
528
+ if (ownJobs.step)
529
+ plannedSteps.push(ownJobs.step);
427
530
  // #173: register the org's own app with the platform's jobs scheduler.
428
531
  // After Agora, so a failing registration can never stop Agora from
429
532
  // provisioning (`runSteps` stops at the first failing step).
430
- const jobsRegistration = planJobsRegistration(org, recordedIds, jobsAvailable || platformReadiness.jobsReady, platformReadiness.missingKeys.jobs, platformReadiness.noKeyReader, platformReadiness.jobsUrlMissing ?? false);
533
+ const jobsRegistration = planJobsRegistration(org, recordedIds,
534
+ // An own org never registers with the shared scheduler the host supplies.
535
+ (jobsAvailable && !ownsServices(org)) || platformReadiness.jobsReady, platformReadiness.missingKeys.jobs, platformReadiness.noKeyReader, platformReadiness.jobsUrlMissing ?? false);
431
536
  if (jobsRegistration.step)
432
537
  plannedSteps.push(jobsRegistration.step);
433
538
  // F8 (#318), after Agora and Boule, not right after the zone: a route
@@ -480,6 +585,8 @@ export function computePlanDetail(org, live, recordedIds = {}, agoraHost = NO_AG
480
585
  // item list, so it rides along with the unmanaged items.
481
586
  if (agora.blocked)
482
587
  unmanaged.push({ description: agora.blocked, key: 'coolify:agora' });
588
+ if (agora.issuerNote)
589
+ unmanaged.push({ description: agora.issuerNote, key: 'coolify:agora' });
483
590
  if (boule.blocked)
484
591
  unmanaged.push({ description: boule.blocked, key: 'coolify:boule' });
485
592
  if (boule.mailBlocked) {
@@ -494,6 +601,22 @@ export function computePlanDetail(org, live, recordedIds = {}, agoraHost = NO_AG
494
601
  unmanaged.push(...edgeWorkers.blocked);
495
602
  unmanaged.push(...entryEdgeKeys.blocked);
496
603
  unmanaged.push(...edgeReservedLabels.blocked);
604
+ if (ownAuth.blocked)
605
+ unmanaged.push({ description: ownAuth.blocked, key: OWN_AUTH_BLOCKED_KEY });
606
+ if (ownJobs.blocked)
607
+ unmanaged.push({ description: ownJobs.blocked, key: OWN_JOBS_BLOCKED_KEY });
608
+ if (ownMail.blocked)
609
+ unmanaged.push({ description: ownMail.blocked, key: OWN_MAIL_BLOCKED_KEY });
610
+ if (ownSfu.blocked)
611
+ unmanaged.push({ description: ownSfu.blocked, key: OWN_SFU_BLOCKED_KEY });
612
+ if (ownSfu.firewall)
613
+ unmanaged.push({ description: ownSfu.firewall, key: OWN_SFU_FIREWALL_KEY });
614
+ if (ownFiles.blocked) {
615
+ unmanaged.push({ description: ownFiles.blocked, key: OWN_FILES_BLOCKED_KEY });
616
+ }
617
+ if (ownFilesR2.blocked) {
618
+ unmanaged.push({ description: ownFilesR2.blocked, key: OWN_FILES_R2_BLOCKED_KEY });
619
+ }
497
620
  // Same for a jobs registration that cannot run (no scheduler wired) or must
498
621
  // not run again (minted, never delivered).
499
622
  if (jobsRegistration.blocked) {
@@ -504,8 +627,23 @@ export function computePlanDetail(org, live, recordedIds = {}, agoraHost = NO_AG
504
627
  // `platformReadiness` instead of a host-supplied port. Each is reported at
505
628
  // most once even though a dependent step (owner, calls-service, storage,
506
629
  // budget, key) would otherwise also have nothing to act on.
507
- if (issuer.blocked)
508
- unmanaged.push({ description: issuer.blocked, key: 'zitadel:issuer' });
630
+ if (ownSignInPending) {
631
+ // The sign-in step's own reason, when it has one, already says why the rest waits.
632
+ if (!ownAuth.blocked) {
633
+ unmanaged.push({
634
+ description: `${org.slug}'s ZITADEL steps are blocked, not planned: its own sign-in service is not applied yet`,
635
+ key: 'coolify:own-auth',
636
+ });
637
+ }
638
+ }
639
+ else {
640
+ if (issuer.blocked)
641
+ unmanaged.push({ description: issuer.blocked, key: 'zitadel:issuer' });
642
+ unmanaged.push(...owners.blocked);
643
+ }
644
+ const domainBlocked = orgDomainBlocked(org, recordedIds);
645
+ if (domainBlocked)
646
+ unmanaged.push(domainBlocked);
509
647
  // #267 (rule 2): a login host with no ZITADEL org to trust it in is blocked, not planned to throw.
510
648
  if (loginHost.blocked) {
511
649
  unmanaged.push({ description: loginHost.blocked, key: 'zitadel:login-host' });
@@ -555,12 +693,31 @@ export function computePlanDetail(org, live, recordedIds = {}, agoraHost = NO_AG
555
693
  unmanaged.push(...bouleBroker.blocked);
556
694
  return {
557
695
  orgId: org.id,
558
- plannedSteps: plannedSteps.map(withStepSystem),
696
+ plannedSteps: (ownSignInPending
697
+ ? plannedSteps.filter((s) => !NEEDS_OWN_ZITADEL.has(`${s.action.system}:${s.action.kind}`))
698
+ : plannedSteps).map(withStepSystem),
559
699
  unmanaged,
560
700
  versionChanges,
561
701
  orgPostgres,
562
702
  };
563
703
  }
704
+ /** Steps that run against the org's own ZITADEL instance (its projects, apps and broker entries). */
705
+ const NEEDS_OWN_ZITADEL = new Set([
706
+ 'zitadel:issuer',
707
+ 'zitadel:registration-closed',
708
+ 'zitadel:domain',
709
+ 'zitadel:owner',
710
+ 'zitadel:app',
711
+ 'zitadel:customers',
712
+ 'zitadel:id-token-userinfo',
713
+ 'zitadel:login-host',
714
+ 'coolify:chat',
715
+ 'coolify:calls-service',
716
+ 'coolify:calls-chat',
717
+ 'coolify:broker-entry',
718
+ 'coolify:boule-broker-entry',
719
+ 'files:organisation',
720
+ ]);
564
721
  /**
565
722
  * #553: a plain ZITADEL app provision (not a `recreate`) is planned only when
566
723
  * the live listing lacks the app by name. If an app id is recorded for it,
@@ -698,6 +855,70 @@ function planIssuer(org, byKey, claimed, authReady, isPlatformOrg = false) {
698
855
  blocked: null,
699
856
  };
700
857
  }
858
+ /**
859
+ * Closes self-registration on a ZITADEL org that already exists and has no
860
+ * recorded close: one made before foundry closed it, or one whose close failed
861
+ * inside the issuer step. A fresh org is closed inside the issuer step, so
862
+ * nothing is planned beside that step. While this step is planned no owner is
863
+ * invited ({@link planOwners}).
864
+ */
865
+ function planOrgRegistrationClosed(org, recordedIds, authReady, issuerStep) {
866
+ if (!authReady)
867
+ return null;
868
+ if (issuerStep?.action.desired.provision === true)
869
+ return null;
870
+ if (!recordedIds.zitadel_org)
871
+ return null;
872
+ if (recordedIds[ZITADEL_ORG_REGISTRATION_CLOSED_SCOPE_KEY] === recordedIds.zitadel_org) {
873
+ return null;
874
+ }
875
+ return {
876
+ action: { system: 'zitadel', kind: 'registration-closed', key: org.id, desired: {} },
877
+ step: {
878
+ description: `switch self-registration off on ${org.slug}'s sign-in org`,
879
+ destroys: false,
880
+ migrates: false,
881
+ },
882
+ };
883
+ }
884
+ /** The blocked item for an org sign-in domain the sign-in server refused to make primary. */
885
+ function orgDomainBlocked(org, recordedIds) {
886
+ const domain = signInOrgDomain(org);
887
+ if (recordedIds[ZITADEL_ORG_DOMAIN_BLOCKED_SCOPE_KEY]?.toLowerCase() !== domain)
888
+ return null;
889
+ if (recordedIds[ZITADEL_ORG_DOMAIN_SCOPE_KEY]?.toLowerCase() === domain)
890
+ return null;
891
+ return { description: signInDomainBlockedText(org.slug, domain), key: 'zitadel:domain' };
892
+ }
893
+ /**
894
+ * Registers the org's sign-in domain (its own, or `<slug>.oikos.host`) on its ZITADEL org
895
+ * and makes it primary, so an owner signs in as `name@<domain>`. Idempotent on the
896
+ * *recorded* `zitadel_org_domain`: a changed declared domain plans it again. A fresh
897
+ * org gets its domain inside the issuer step, so nothing is planned beside that step.
898
+ * Nothing is planned until the ZITADEL org exists.
899
+ */
900
+ function planOrgDomain(org, recordedIds, authReady, issuerStep) {
901
+ if (!authReady)
902
+ return null;
903
+ if (issuerStep?.action.desired.provision === true)
904
+ return null;
905
+ if (!recordedIds.zitadel_org)
906
+ return null;
907
+ const domain = signInOrgDomain(org);
908
+ if (recordedIds[ZITADEL_ORG_DOMAIN_SCOPE_KEY]?.toLowerCase() === domain)
909
+ return null;
910
+ // Refused once: reported as blocked (see `orgDomainBlocked`), not planned again.
911
+ if (recordedIds[ZITADEL_ORG_DOMAIN_BLOCKED_SCOPE_KEY]?.toLowerCase() === domain)
912
+ return null;
913
+ return {
914
+ action: { system: 'zitadel', kind: 'domain', key: org.id, desired: { domain } },
915
+ step: {
916
+ description: `register ${domain} as ${org.slug}'s sign-in domain`,
917
+ destroys: false,
918
+ migrates: false,
919
+ },
920
+ };
921
+ }
701
922
  /**
702
923
  * The org's owners, one step each, each invited by ZITADEL email once the org's ZITADEL org
703
924
  * exists (goal line: "the org's owner is invited by ZITADEL email and sets a
@@ -718,6 +939,11 @@ function planIssuer(org, byKey, claimed, authReady, isPlatformOrg = false) {
718
939
  * issuer step immediately before it in the same run: `plan-apply.ts`'s
719
940
  * `runSteps` awaits each planned step in array order, one at a time.
720
941
  *
942
+ * An owner signs in as `name@<org domain>`, so each unrecorded owner needs a work
943
+ * address (`mailboxAddress`, or `mail.ownerMailboxAddress` for the first owner) and the
944
+ * host needs an `ownerInviteMail` port to send the first-sign-in link to the personal
945
+ * address. Without either, that owner is a blocked item named by position (rule 2).
946
+ *
721
947
  * `desired` carries the owner's email so the executor can pass it to
722
948
  * `ZitadelClient.inviteOrgOwner` — but the step's own `description` never
723
949
  * does. `Plan` (the public contract `AuditLog.recordPlan`/`recordApply`
@@ -726,19 +952,40 @@ function planIssuer(org, byKey, claimed, authReady, isPlatformOrg = false) {
726
952
  * nothing here puts it anywhere beyond what audit already records for the
727
953
  * org.
728
954
  */
729
- function planOwners(org, recordedIds, authReady, authIssuer) {
955
+ function planOwners(org, recordedIds, authReady, authIssuer, ownerInviteMail, registrationOpen = false) {
956
+ const steps = [];
957
+ const blocked = [];
958
+ // The close step is planned in this plan; the invites come with the next one, after it succeeded.
959
+ if (registrationOpen)
960
+ return { steps, blocked };
730
961
  // #177: nothing to invite into yet -- `planIssuer` already reports the
731
962
  // platform's sign-in as blocked, so these steps stay silent rather than
732
963
  // repeating it.
733
964
  if (!authReady)
734
- return [];
735
- const steps = [];
965
+ return { steps, blocked };
966
+ const mailboxes = ownerMailboxes(org);
736
967
  org.owners.forEach((owner, index) => {
737
968
  // Every owner is keyed by its lowercased email (as `validateOwners` compares
738
969
  // it), so reordering the list never moves a key onto another person.
739
970
  const key = `${org.id}:${owner.email.toLowerCase()}`;
740
971
  if (recordedIds[resourceKey('zitadel', 'owner', key)])
741
972
  return; // already invited.
973
+ const position = index + 1;
974
+ const mailboxAddress = mailboxes[index]?.address;
975
+ if (!mailboxAddress) {
976
+ blocked.push({
977
+ description: `${org.slug}'s owner ${position} is blocked, not planned: no mailboxAddress is declared, and the owner signs in as name@<the org's domain>`,
978
+ key: `zitadel:owner:${position}`,
979
+ });
980
+ return;
981
+ }
982
+ if (!ownerInviteMail) {
983
+ blocked.push({
984
+ description: `${org.slug}'s owner ${position} is blocked, not planned: the host has no ownerInviteMail port, and the owner cannot open their work mailbox yet`,
985
+ key: `zitadel:owner:${position}`,
986
+ });
987
+ return;
988
+ }
742
989
  steps.push({
743
990
  action: {
744
991
  system: 'zitadel',
@@ -746,6 +993,7 @@ function planOwners(org, recordedIds, authReady, authIssuer) {
746
993
  key,
747
994
  desired: {
748
995
  email: owner.email,
996
+ mailboxAddress,
749
997
  givenName: owner.givenName,
750
998
  familyName: owner.familyName,
751
999
  // The platform's login app, where the invite link must land (it serves `/verify`).
@@ -761,7 +1009,7 @@ function planOwners(org, recordedIds, authReady, authIssuer) {
761
1009
  },
762
1010
  });
763
1011
  });
764
- return steps;
1012
+ return { steps, blocked };
765
1013
  }
766
1014
  /**
767
1015
  * #488: the bare hostname out of an issuer URL (`https://<hostname>`, the
@@ -771,8 +1019,8 @@ function planOwners(org, recordedIds, authReady, authIssuer) {
771
1019
  * computed `auth.<domain>` against, so the platform's own org never plans
772
1020
  * its login host equal to the shared ZITADEL instance's own.
773
1021
  */
774
- export function isPlatformOwnOrg(org, platformApex) {
775
- return Boolean(org.domain && platformApex && org.domain.toLowerCase() === platformApex.toLowerCase());
1022
+ export function isPlatformOwnOrg(org, platformOwnerOrgId) {
1023
+ return Boolean(platformOwnerOrgId && org.id === platformOwnerOrgId);
776
1024
  }
777
1025
  function platformAuthHostnameFrom(issuer) {
778
1026
  if (!issuer)
@@ -822,6 +1070,9 @@ function platformAuthHostnameFrom(issuer) {
822
1070
  */
823
1071
  function planLoginHost(org, recordedIds, authReady, issuerPlanned, bindingsWired, platformAuthHostname, isPlatformOrg) {
824
1072
  const none = { step: null, blocked: null };
1073
+ // An own org's instance has no shared login host to bind: its issuer is the sign-in service's own.
1074
+ if (ownsServices(org) && !isPlatformOrg)
1075
+ return none;
825
1076
  const key = resourceKey('zitadel', 'login-host', org.id);
826
1077
  // The platform's own org signs in on the shared instance's host whatever
827
1078
  // `issuer.usesDomain` says (turning it on would move the issuer).
@@ -834,7 +1085,7 @@ function planLoginHost(org, recordedIds, authReady, issuerPlanned, bindingsWired
834
1085
  };
835
1086
  }
836
1087
  const sharedInstanceHost = isPlatformOrg && platformAuthHostname !== null;
837
- const host = sharedInstanceHost ? platformAuthHostname : loginHostnameFor(org);
1088
+ const host = orgLoginHost(org, sharedInstanceHost ? platformAuthHostname : null);
838
1089
  // #488: the guard in `loginHostnameFor` fired -- the org's own domain host
839
1090
  // would have been the platform's shared ZITADEL instance host.
840
1091
  if (sharedInstanceHost) {
@@ -1391,7 +1642,9 @@ function planCalls(org, byKey, claimed, callsReady) {
1391
1642
  if (org.calls.enabled && !byKey.get(key) && !callsReady) {
1392
1643
  return {
1393
1644
  step: null,
1394
- blocked: `${org.slug}'s calls Matrix stack is blocked, not planned: the platform's shared calls SFU is off`,
1645
+ blocked: ownsServices(org)
1646
+ ? `${org.slug}'s calls Matrix stack is blocked, not planned: its own media server (coolify:own-sfu) is not applied yet`
1647
+ : `${org.slug}'s calls Matrix stack is blocked, not planned: the platform's shared calls SFU is off`,
1395
1648
  };
1396
1649
  }
1397
1650
  // #248: the two images this stack's own apply would run, checked the same
@@ -1440,6 +1693,13 @@ function planCallsService(org, byKey, claimed, callsReady, recordedIds, callsBlo
1440
1693
  if (org.calls.enabled && !byKey.get(key) && !callsReady) {
1441
1694
  return { step: null, blocked: null };
1442
1695
  }
1696
+ // An own org's service names its own media server, so it waits for it even when already provisioned.
1697
+ if (org.calls.enabled && ownsServices(org) && !callsReady) {
1698
+ return {
1699
+ step: null,
1700
+ blocked: `${org.slug}'s calls service is blocked, not planned: its own media server (coolify:own-sfu) is not applied yet`,
1701
+ };
1702
+ }
1443
1703
  // The introspection secret is shown once. One minted but never written to
1444
1704
  // the app is lost, so this reports it and mints no second (platform rule 1).
1445
1705
  const undelivered = recordedIds[CALLS_INTROSPECTION_UNDELIVERED_SCOPE_KEY];
@@ -1667,13 +1927,21 @@ function notReadyReason(service, keyName, missingKeyRef, noKeyReader) {
1667
1927
  * Files organisation already exists -- the old origin is left allowed
1668
1928
  * rather than silently dropped.
1669
1929
  */
1670
- function planFilesOrganisation(org, byKey, claimed, filesReady, missingKeyRef, noKeyReader = false) {
1930
+ function planFilesOrganisation(org, byKey, claimed, filesReady, missingKeyRef, noKeyReader = false,
1931
+ /** An own org whose files host (`coolify:own-files:<org.id>`) is not recorded yet. */
1932
+ ownFilesPending = false) {
1671
1933
  const key = resourceKey('files', 'organisation', org.id);
1672
1934
  const live = byKey.get(key);
1673
1935
  if (live) {
1674
1936
  claimed.add(key);
1675
1937
  return { step: null, blocked: null };
1676
1938
  }
1939
+ if (ownFilesPending) {
1940
+ return {
1941
+ step: null,
1942
+ blocked: `${org.slug}'s Files organisation is blocked, not planned: its own files host is not applied yet`,
1943
+ };
1944
+ }
1677
1945
  if (!filesReady) {
1678
1946
  return {
1679
1947
  step: null,
@@ -2046,14 +2314,32 @@ function planAgoraClient(org, byKey, claimed) {
2046
2314
  * message naming each missing value, which `computePlanDetail` lists with the
2047
2315
  * plan's non-step items, so every other step still applies.
2048
2316
  */
2049
- function planAgora(org, byKey, claimed, recordedIds, reading) {
2317
+ function planAgora(org, byKey, claimed, recordedIds, reading, isPlatformOrg, issuerSwitch) {
2050
2318
  const key = resourceKey('coolify', 'agora', org.id);
2051
2319
  const live = byKey.get(key);
2052
2320
  if (live)
2053
2321
  claimed.add(key);
2322
+ const ownSignIn = ownsServices(org) && !isPlatformOrg;
2323
+ if (ownSignIn && !ownAuthApplied(org, recordedIds)) {
2324
+ return {
2325
+ step: null,
2326
+ blocked: `${org.slug}'s Agora is blocked, not planned: its own sign-in service is not applied yet`,
2327
+ issuerNote: null,
2328
+ };
2329
+ }
2330
+ // The files host records its internal URL only while it applies, and Agora's
2331
+ // env takes its files upstream from it: planned in the same pass, the first
2332
+ // deploy would start without one. The company's files host exists only with a domain.
2333
+ if (ownSignIn && org.domain && !recordedIds[PLATFORM_FILES_INTERNAL_URL_SCOPE_KEY]) {
2334
+ return {
2335
+ step: null,
2336
+ blocked: `${org.slug}'s Agora is blocked, not planned: its own files host is not applied yet`,
2337
+ issuerNote: null,
2338
+ };
2339
+ }
2054
2340
  const error = 'error' in reading ? reading.error : null;
2055
- const host = withRecordedMessagingProject('error' in reading ? NO_AGORA_HOST_VALUES : reading, recordedIds);
2056
- const missing = agoraHostMissing(host);
2341
+ const platformHost = withRecordedMessagingProject('error' in reading ? NO_AGORA_HOST_VALUES : reading, recordedIds);
2342
+ const missing = agoraHostMissing(platformHost);
2057
2343
  if (missing.length > 0) {
2058
2344
  // A fixed text, never the port's own message: this line is part of the
2059
2345
  // stale-plan comparison, and an error that varies per call would refuse
@@ -2062,8 +2348,41 @@ function planAgora(org, byKey, claimed, recordedIds, reading) {
2062
2348
  return {
2063
2349
  step: null,
2064
2350
  blocked: `${org.slug}'s Agora is blocked, not planned: the host supplies no ${missing.join(', ')}${why}`,
2351
+ issuerNote: null,
2065
2352
  };
2066
2353
  }
2354
+ // Agora signs in at the company's own login host, and refuses any issuer off
2355
+ // its own address's domain. Only this value changes: the calls token env,
2356
+ // chat and Boule keep reading the platform issuer from `reading`, and Agora
2357
+ // always plans. The company host is written only once the login host is
2358
+ // applied with Agora's client AND a live read said it serves discovery as
2359
+ // that issuer. An Agora already on the company host keeps it while the host
2360
+ // stays applied: a later failing read never flips it back (no oscillation).
2361
+ let host = platformHost;
2362
+ let issuerNote = null;
2363
+ const choice = agoraIssuerHostFor(org, isPlatformOrg);
2364
+ if (choice.mismatch) {
2365
+ issuerNote = `${org.slug}'s Agora cannot sign in: its login host ${loginHostnameFor(org)} is not on its Agora address ${agoraHostnameFor(org)}; the login host must be on the org's domain`;
2366
+ }
2367
+ else if (choice.host) {
2368
+ const loginHost = choice.host;
2369
+ const target = `https://${loginHost}`;
2370
+ const applied = loginHostServesAgora(loginHost, org, recordedIds);
2371
+ if (applied && (liveAgoraIssuer(live) === target || issuerSwitch.servesDiscovery)) {
2372
+ host = { ...platformHost, issuer: target };
2373
+ }
2374
+ else if (ownSignIn) {
2375
+ // No platform issuer to fall back on: Agora waits for the company's own.
2376
+ return {
2377
+ step: null,
2378
+ blocked: `${org.slug}'s Agora is blocked, not planned: ${target} does not answer OIDC discovery yet`,
2379
+ issuerNote: null,
2380
+ };
2381
+ }
2382
+ else {
2383
+ issuerNote = `${org.slug}'s Agora keeps the platform issuer; the switch to ${target} is blocked, not planned, until ${loginHost} is applied with Agora's client and answers OIDC discovery`;
2384
+ }
2385
+ }
2067
2386
  // The executor writes exactly these host values, so what the plan showed
2068
2387
  // is what gets applied; the ids only the apply itself records are read at
2069
2388
  // run time.
@@ -2084,6 +2403,7 @@ function planAgora(org, byKey, claimed, recordedIds, reading) {
2084
2403
  },
2085
2404
  },
2086
2405
  blocked: null,
2406
+ issuerNote,
2087
2407
  };
2088
2408
  }
2089
2409
  const liveEnv = (live.config.env ?? {});
@@ -2111,8 +2431,9 @@ function planAgora(org, byKey, claimed, recordedIds, reading) {
2111
2431
  // actually serving this exact `env`; while it disagrees, the step stays
2112
2432
  // planned no matter what Coolify's env store already shows.
2113
2433
  const activated = recordedIds[AGORA_ACTIVATED_ENV_SCOPE_KEY] === agoraEnvRevision(env);
2114
- if (drift.length === 0 && activated && !portDrift)
2115
- return { step: null, blocked: null };
2434
+ if (drift.length === 0 && activated && !portDrift) {
2435
+ return { step: null, blocked: null, issuerNote };
2436
+ }
2116
2437
  const reasons = [
2117
2438
  ...(drift.length > 0 ? [`${drift.join(', ')} missing or different`] : []),
2118
2439
  ...(portDrift ? ["ports_exposes wrong -- Traefik can't reach it"] : []),
@@ -2129,8 +2450,22 @@ function planAgora(org, byKey, claimed, recordedIds, reading) {
2129
2450
  },
2130
2451
  },
2131
2452
  blocked: null,
2453
+ issuerNote,
2132
2454
  };
2133
2455
  }
2456
+ /** The issuer a live Agora's `AGORA_CONFIG` names, or null when it has none or is not JSON. */
2457
+ function liveAgoraIssuer(live) {
2458
+ const raw = (live?.config.env ?? {}).AGORA_CONFIG;
2459
+ if (raw === undefined)
2460
+ return null;
2461
+ try {
2462
+ const issuer = JSON.parse(raw)?.issuer;
2463
+ return typeof issuer === 'string' ? issuer : null;
2464
+ }
2465
+ catch {
2466
+ return null;
2467
+ }
2468
+ }
2134
2469
  /**
2135
2470
  * #161 (Boule part): every org gets one fixed ZITADEL web client named
2136
2471
  * `boule`, so its Boule (https://github.com/wtfalch/boule) can sign anyone in.
@@ -2237,6 +2572,7 @@ function planBoule(org, byKey, claimed, recordedIds, issuer, otherSlugs, archonU
2237
2572
  ? `${org.slug}'s Boule mail is blocked, not planned: ${mailBlockedReason}`
2238
2573
  : null;
2239
2574
  const mailOn = mailWanted && !mailBlocked;
2575
+ const platformOwner = isPlatformOwnOrg(org, readiness.platformOwnerOrgId);
2240
2576
  const action = {
2241
2577
  system: 'coolify',
2242
2578
  kind: 'boule',
@@ -2249,6 +2585,8 @@ function planBoule(org, byKey, claimed, recordedIds, issuer, otherSlugs, archonU
2249
2585
  platformApex: readiness.platformApex,
2250
2586
  platformLettermintProjectId: readiness.platformLettermintProjectId,
2251
2587
  mailBlocked: mailBlocked !== null,
2588
+ // Only the platform's own org's Boule gets the peer password for Archon.
2589
+ platformOwner,
2252
2590
  },
2253
2591
  };
2254
2592
  if (!live) {
@@ -2269,6 +2607,15 @@ function planBoule(org, byKey, claimed, recordedIds, issuer, otherSlugs, archonU
2269
2607
  bouleMailTokenDrift(liveEnv[BOULE_MAIL_TOKEN_ENV], live.config.appMailToken)) {
2270
2608
  drift.push(BOULE_MAIL_TOKEN_ENV);
2271
2609
  }
2610
+ // The peer password is secret, so only its absence is compared.
2611
+ if (platformOwner && !liveEnv[BOULE_PEER_PASSWORD_ENV])
2612
+ drift.push(BOULE_PEER_PASSWORD_ENV);
2613
+ if (platformOwner && !liveEnv[BOULE_BRIDGE_SECRET_ENV])
2614
+ drift.push(BOULE_BRIDGE_SECRET_ENV);
2615
+ // Secrets written but Boule not restarted since: plan the step so it restarts, minting nothing.
2616
+ if (platformOwner && !recordedIds[BOULE_SECRETS_RESTARTED_SCOPE_KEY]) {
2617
+ drift.push('restart after the peer password and bridge secret');
2618
+ }
2272
2619
  if (drift.length === 0)
2273
2620
  return { step: null, blocked: null, mailBlocked };
2274
2621
  return {
@@ -2755,6 +3102,16 @@ function planJobsRegistration(org, recordedIds, jobsAvailable, missingKeyRef = n
2755
3102
  // The host says whether its executor has a scheduler to register with. A
2756
3103
  // fixed text: it is part of the stale-plan comparison.
2757
3104
  if (!jobsAvailable) {
3105
+ if (ownsServices(org)) {
3106
+ return {
3107
+ step: null,
3108
+ blocked: `${org.slug}'s jobs registration is blocked, not planned: ${addressMissing
3109
+ ? `${org.slug}'s own jobs scheduler has not recorded its internal address yet; apply its own jobs step first`
3110
+ : missingKeyRef
3111
+ ? `${org.slug}'s own jobs admin token ${missingKeyRef} is not held yet`
3112
+ : `${org.slug}'s own jobs scheduler is not applied yet`}`,
3113
+ };
3114
+ }
2758
3115
  return {
2759
3116
  step: null,
2760
3117
  blocked: addressMissing