@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
package/dist/plan/diff.js CHANGED
@@ -1,5 +1,5 @@
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
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';
@@ -10,9 +10,11 @@ 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';
@@ -56,6 +58,10 @@ function resourceKey(system, kind, key) {
56
58
  function madeByFoundry(org, r, recordedIds, recordedValues, platformNames) {
57
59
  if (PROVIDER_KINDS[r.system]?.includes(r.kind) && recordedValues.has(r.key))
58
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
+ }
59
65
  if (r.system === 'coolify') {
60
66
  if (r.kind === 'environment')
61
67
  return true;
@@ -78,6 +84,11 @@ function madeByFoundry(org, r, recordedIds, recordedValues, platformNames) {
78
84
  if (type === 'CNAME' && content.endsWith('.cfargotunnel.com') && tunnelHostnames.includes(name)) {
79
85
  return true;
80
86
  }
87
+ if (type === 'CNAME' &&
88
+ content.endsWith('.cfargotunnel.com') &&
89
+ ownDnsHostnames(recordedIds).includes(name)) {
90
+ return true;
91
+ }
81
92
  const mailDomain = recordedIds[MAIL_DOMAIN_SCOPE_KEY]?.toLowerCase();
82
93
  if (!mailDomain)
83
94
  return false;
@@ -86,6 +97,14 @@ function madeByFoundry(org, r, recordedIds, recordedValues, platformNames) {
86
97
  }
87
98
  return name === `_dmarc.${mailDomain}` || name.endsWith(`._domainkey.${mailDomain}`);
88
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
+ ]);
89
108
  /** The kinds a provider lists a resource under before a recorded `system:kind:key` maps it to foundry's own. */
90
109
  const PROVIDER_KINDS = {
91
110
  coolify: ['application', 'database'],
@@ -167,6 +186,21 @@ export function computePlanDetail(org, live, recordedIds = {}, agoraHost = NO_AG
167
186
  // #491 reads for mail). It signs in on the shared instance's own host, so
168
187
  // `issuer.usesDomain` never moves it.
169
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);
170
204
  const issuer = planIssuer(org, byKey, claimed, platformReadiness.authReady, isPlatformOrg);
171
205
  if (issuer.step)
172
206
  plannedSteps.push(issuer.step);
@@ -213,6 +247,17 @@ export function computePlanDetail(org, live, recordedIds = {}, agoraHost = NO_AG
213
247
  else if (zone.step) {
214
248
  plannedSteps.push(zone.step);
215
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);
216
261
  const mail = planMail(org, byKey, claimed, recordedIds, platformReadiness.mailHost, platformReadiness.platformApex, platformReadiness.platformLettermintProjectId);
217
262
  // #238: own-domain mail publishes DNS records into the zone, so it waits for
218
263
  // an active zone -- blocked, not planned (rule 2).
@@ -238,6 +283,19 @@ export function computePlanDetail(org, live, recordedIds = {}, agoraHost = NO_AG
238
283
  const calls = planCalls(org, byKey, claimed, platformReadiness.callsReady);
239
284
  if (calls.step)
240
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);
241
299
  // Right after the Matrix stack (#145): its own executor
242
300
  // (`executor.ts`'s `coolifyCallsService`) reads the Synapse/MAS addresses
243
301
  // and MAS client credentials `coolifyCalls` just recorded, the same
@@ -269,7 +327,30 @@ export function computePlanDetail(org, live, recordedIds = {}, agoraHost = NO_AG
269
327
  if (masChange)
270
328
  versionChanges.push(masChange);
271
329
  }
272
- 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}`]);
273
354
  if (filesOrganisation.step)
274
355
  plannedSteps.push(filesOrganisation.step);
275
356
  const aiOrganisation = planAiOrganisation(org, byKey, claimed, platformReadiness.aiReady, platformReadiness.missingKeys.ai, platformReadiness.noKeyReader);
@@ -411,7 +492,9 @@ export function computePlanDetail(org, live, recordedIds = {}, agoraHost = NO_AG
411
492
  const agoraGuarded = guardApp(agoraClient);
412
493
  if (agoraGuarded)
413
494
  plannedSteps.push(agoraGuarded);
414
- const agora = planAgora(org, byKey, claimed, recordedIds, agoraHost);
495
+ const agora = planAgora(org, byKey, claimed, recordedIds, agoraHost, isPlatformOrg, {
496
+ servesDiscovery: options.loginHostServesDiscovery ?? false,
497
+ });
415
498
  if (agora.step)
416
499
  plannedSteps.push(agora.step);
417
500
  // #161 (Boule part): every org's own Boule -- always on, no toggle, the same
@@ -433,10 +516,23 @@ export function computePlanDetail(org, live, recordedIds = {}, agoraHost = NO_AG
433
516
  const bouleBroker = planBouleBrokerEntry(org, recordedIds, plannedSteps, options.broker ?? false);
434
517
  if (bouleBroker.step)
435
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);
436
530
  // #173: register the org's own app with the platform's jobs scheduler.
437
531
  // After Agora, so a failing registration can never stop Agora from
438
532
  // provisioning (`runSteps` stops at the first failing step).
439
- 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);
440
536
  if (jobsRegistration.step)
441
537
  plannedSteps.push(jobsRegistration.step);
442
538
  // F8 (#318), after Agora and Boule, not right after the zone: a route
@@ -489,6 +585,8 @@ export function computePlanDetail(org, live, recordedIds = {}, agoraHost = NO_AG
489
585
  // item list, so it rides along with the unmanaged items.
490
586
  if (agora.blocked)
491
587
  unmanaged.push({ description: agora.blocked, key: 'coolify:agora' });
588
+ if (agora.issuerNote)
589
+ unmanaged.push({ description: agora.issuerNote, key: 'coolify:agora' });
492
590
  if (boule.blocked)
493
591
  unmanaged.push({ description: boule.blocked, key: 'coolify:boule' });
494
592
  if (boule.mailBlocked) {
@@ -503,6 +601,22 @@ export function computePlanDetail(org, live, recordedIds = {}, agoraHost = NO_AG
503
601
  unmanaged.push(...edgeWorkers.blocked);
504
602
  unmanaged.push(...entryEdgeKeys.blocked);
505
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
+ }
506
620
  // Same for a jobs registration that cannot run (no scheduler wired) or must
507
621
  // not run again (minted, never delivered).
508
622
  if (jobsRegistration.blocked) {
@@ -513,9 +627,20 @@ export function computePlanDetail(org, live, recordedIds = {}, agoraHost = NO_AG
513
627
  // `platformReadiness` instead of a host-supplied port. Each is reported at
514
628
  // most once even though a dependent step (owner, calls-service, storage,
515
629
  // budget, key) would otherwise also have nothing to act on.
516
- if (issuer.blocked)
517
- unmanaged.push({ description: issuer.blocked, key: 'zitadel:issuer' });
518
- unmanaged.push(...owners.blocked);
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
+ }
519
644
  const domainBlocked = orgDomainBlocked(org, recordedIds);
520
645
  if (domainBlocked)
521
646
  unmanaged.push(domainBlocked);
@@ -568,12 +693,31 @@ export function computePlanDetail(org, live, recordedIds = {}, agoraHost = NO_AG
568
693
  unmanaged.push(...bouleBroker.blocked);
569
694
  return {
570
695
  orgId: org.id,
571
- 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),
572
699
  unmanaged,
573
700
  versionChanges,
574
701
  orgPostgres,
575
702
  };
576
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
+ ]);
577
721
  /**
578
722
  * #553: a plain ZITADEL app provision (not a `recreate`) is planned only when
579
723
  * the live listing lacks the app by name. If an app id is recorded for it,
@@ -926,6 +1070,9 @@ function platformAuthHostnameFrom(issuer) {
926
1070
  */
927
1071
  function planLoginHost(org, recordedIds, authReady, issuerPlanned, bindingsWired, platformAuthHostname, isPlatformOrg) {
928
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;
929
1076
  const key = resourceKey('zitadel', 'login-host', org.id);
930
1077
  // The platform's own org signs in on the shared instance's host whatever
931
1078
  // `issuer.usesDomain` says (turning it on would move the issuer).
@@ -938,7 +1085,7 @@ function planLoginHost(org, recordedIds, authReady, issuerPlanned, bindingsWired
938
1085
  };
939
1086
  }
940
1087
  const sharedInstanceHost = isPlatformOrg && platformAuthHostname !== null;
941
- const host = sharedInstanceHost ? platformAuthHostname : loginHostnameFor(org);
1088
+ const host = orgLoginHost(org, sharedInstanceHost ? platformAuthHostname : null);
942
1089
  // #488: the guard in `loginHostnameFor` fired -- the org's own domain host
943
1090
  // would have been the platform's shared ZITADEL instance host.
944
1091
  if (sharedInstanceHost) {
@@ -1495,7 +1642,9 @@ function planCalls(org, byKey, claimed, callsReady) {
1495
1642
  if (org.calls.enabled && !byKey.get(key) && !callsReady) {
1496
1643
  return {
1497
1644
  step: null,
1498
- 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`,
1499
1648
  };
1500
1649
  }
1501
1650
  // #248: the two images this stack's own apply would run, checked the same
@@ -1544,6 +1693,13 @@ function planCallsService(org, byKey, claimed, callsReady, recordedIds, callsBlo
1544
1693
  if (org.calls.enabled && !byKey.get(key) && !callsReady) {
1545
1694
  return { step: null, blocked: null };
1546
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
+ }
1547
1703
  // The introspection secret is shown once. One minted but never written to
1548
1704
  // the app is lost, so this reports it and mints no second (platform rule 1).
1549
1705
  const undelivered = recordedIds[CALLS_INTROSPECTION_UNDELIVERED_SCOPE_KEY];
@@ -1771,13 +1927,21 @@ function notReadyReason(service, keyName, missingKeyRef, noKeyReader) {
1771
1927
  * Files organisation already exists -- the old origin is left allowed
1772
1928
  * rather than silently dropped.
1773
1929
  */
1774
- 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) {
1775
1933
  const key = resourceKey('files', 'organisation', org.id);
1776
1934
  const live = byKey.get(key);
1777
1935
  if (live) {
1778
1936
  claimed.add(key);
1779
1937
  return { step: null, blocked: null };
1780
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
+ }
1781
1945
  if (!filesReady) {
1782
1946
  return {
1783
1947
  step: null,
@@ -2150,14 +2314,32 @@ function planAgoraClient(org, byKey, claimed) {
2150
2314
  * message naming each missing value, which `computePlanDetail` lists with the
2151
2315
  * plan's non-step items, so every other step still applies.
2152
2316
  */
2153
- function planAgora(org, byKey, claimed, recordedIds, reading) {
2317
+ function planAgora(org, byKey, claimed, recordedIds, reading, isPlatformOrg, issuerSwitch) {
2154
2318
  const key = resourceKey('coolify', 'agora', org.id);
2155
2319
  const live = byKey.get(key);
2156
2320
  if (live)
2157
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
+ }
2158
2340
  const error = 'error' in reading ? reading.error : null;
2159
- const host = withRecordedMessagingProject('error' in reading ? NO_AGORA_HOST_VALUES : reading, recordedIds);
2160
- const missing = agoraHostMissing(host);
2341
+ const platformHost = withRecordedMessagingProject('error' in reading ? NO_AGORA_HOST_VALUES : reading, recordedIds);
2342
+ const missing = agoraHostMissing(platformHost);
2161
2343
  if (missing.length > 0) {
2162
2344
  // A fixed text, never the port's own message: this line is part of the
2163
2345
  // stale-plan comparison, and an error that varies per call would refuse
@@ -2166,8 +2348,41 @@ function planAgora(org, byKey, claimed, recordedIds, reading) {
2166
2348
  return {
2167
2349
  step: null,
2168
2350
  blocked: `${org.slug}'s Agora is blocked, not planned: the host supplies no ${missing.join(', ')}${why}`,
2351
+ issuerNote: null,
2169
2352
  };
2170
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
+ }
2171
2386
  // The executor writes exactly these host values, so what the plan showed
2172
2387
  // is what gets applied; the ids only the apply itself records are read at
2173
2388
  // run time.
@@ -2188,6 +2403,7 @@ function planAgora(org, byKey, claimed, recordedIds, reading) {
2188
2403
  },
2189
2404
  },
2190
2405
  blocked: null,
2406
+ issuerNote,
2191
2407
  };
2192
2408
  }
2193
2409
  const liveEnv = (live.config.env ?? {});
@@ -2215,8 +2431,9 @@ function planAgora(org, byKey, claimed, recordedIds, reading) {
2215
2431
  // actually serving this exact `env`; while it disagrees, the step stays
2216
2432
  // planned no matter what Coolify's env store already shows.
2217
2433
  const activated = recordedIds[AGORA_ACTIVATED_ENV_SCOPE_KEY] === agoraEnvRevision(env);
2218
- if (drift.length === 0 && activated && !portDrift)
2219
- return { step: null, blocked: null };
2434
+ if (drift.length === 0 && activated && !portDrift) {
2435
+ return { step: null, blocked: null, issuerNote };
2436
+ }
2220
2437
  const reasons = [
2221
2438
  ...(drift.length > 0 ? [`${drift.join(', ')} missing or different`] : []),
2222
2439
  ...(portDrift ? ["ports_exposes wrong -- Traefik can't reach it"] : []),
@@ -2233,8 +2450,22 @@ function planAgora(org, byKey, claimed, recordedIds, reading) {
2233
2450
  },
2234
2451
  },
2235
2452
  blocked: null,
2453
+ issuerNote,
2236
2454
  };
2237
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
+ }
2238
2469
  /**
2239
2470
  * #161 (Boule part): every org gets one fixed ZITADEL web client named
2240
2471
  * `boule`, so its Boule (https://github.com/wtfalch/boule) can sign anyone in.
@@ -2871,6 +3102,16 @@ function planJobsRegistration(org, recordedIds, jobsAvailable, missingKeyRef = n
2871
3102
  // The host says whether its executor has a scheduler to register with. A
2872
3103
  // fixed text: it is part of the stale-plan comparison.
2873
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
+ }
2874
3115
  return {
2875
3116
  step: null,
2876
3117
  blocked: addressMissing
@@ -12,6 +12,7 @@ import { type EdgePlatformTunnels, type TunnelPort } from './edge-tunnels.js';
12
12
  import { type EdgePlatformZones, type ZonePort } from './edge-zones.js';
13
13
  import { type LoginHostBindingsPort } from './login-host.js';
14
14
  import { type MailCopyPort } from './mail-copy.js';
15
+ import { type OwnServicesPort } from './own-services.js';
15
16
  import type { DestroyTarget, StepDestroyer, StepExecutionContext, StepExecutor } from './plan-apply.js';
16
17
  import type { EdgePortName } from './platform-org.js';
17
18
  export declare function coolifyProjectMark(org: Pick<DeclaredOrg, 'id'>): string;
@@ -107,6 +108,9 @@ export interface CoolifyPort {
107
108
  custom_network_aliases?: string | null;
108
109
  health_check_enabled?: boolean;
109
110
  ports_exposes?: string;
111
+ /** Coolify's own record of the resource limits (`plan/own-limits.ts`). */
112
+ limits_memory?: string;
113
+ limits_cpus?: string;
110
114
  }>;
111
115
  createGithubAppApplication(input: {
112
116
  project_uuid: string;
@@ -162,6 +166,8 @@ export interface CoolifyPort {
162
166
  uuid: string;
163
167
  internal_db_url?: string;
164
168
  status?: string;
169
+ limits_memory?: string;
170
+ limits_cpus?: string;
165
171
  }>;
166
172
  /**
167
173
  * Every database Coolify holds. `coolifyDatabase` finds its own by name and
@@ -1144,6 +1150,16 @@ export interface ZitadelListAppsRawApp {
1144
1150
  * an operator's `MANAGEMENT_PUBLIC_KEYS`/`MANAGEMENT_ISSUER` pins.
1145
1151
  */
1146
1152
  export interface FilesOrganisationPort {
1153
+ /**
1154
+ * An own files host's R2 storage key, sent to that host once (the files route
1155
+ * `PUT /v1/manage/r2-credentials`). Absent on a port that cannot: the own R2 key step is then
1156
+ * blocked by name. The same pair may be sent again after a failed send.
1157
+ */
1158
+ storeR2Credentials?(input: {
1159
+ accessKeyId: string;
1160
+ secretAccessKey: string;
1161
+ tokenId: string;
1162
+ }): Promise<void>;
1147
1163
  provisionOrganisation(input: {
1148
1164
  organisationId: string;
1149
1165
  ceilingBytes: string | null;
@@ -1447,6 +1463,12 @@ export interface PlatformOrgPortSource {
1447
1463
  }
1448
1464
  export interface FoundryStepExecutorPorts {
1449
1465
  coolify: CoolifyPort;
1466
+ /**
1467
+ * Ports for the instances an own org (`services.own`) runs itself. Without
1468
+ * it, a step for an own org is refused rather than run against the shared
1469
+ * services, and the planner reports the org's own services blocked.
1470
+ */
1471
+ ownServices?: OwnServicesPort;
1450
1472
  zitadel: ZitadelPort;
1451
1473
  mail: MailPort;
1452
1474
  lettermint: LettermintPort;
@@ -1694,11 +1716,32 @@ export declare class FoundryStepExecutor implements StepExecutor, StepDestroyer
1694
1716
  private readonly handledSecrets;
1695
1717
  constructor(ports: FoundryStepExecutorPorts);
1696
1718
  secretsHandled(): readonly string[];
1719
+ /**
1720
+ * An own org's step runs on a copy of this executor whose ZITADEL, mail,
1721
+ * broker, files, jobs and calls ports are the org's own. A port the org has
1722
+ * not got yet is absent or throws, never the shared one.
1723
+ */
1724
+ private executeScoped;
1725
+ /** Whether `ensureOwnAuthMailToken` can mint: the same two inputs it blocks on. */
1726
+ ownMailKeyMintable(): boolean;
1727
+ /** What the executor's own files dependencies supply, for the planner's blocked lines. */
1728
+ ownFilesDeps(): {
1729
+ keys: boolean;
1730
+ bucket: boolean;
1731
+ };
1732
+ /** Which ports the own files storage key step needs this executor has, for the planner's blocked line. */
1733
+ ownFilesR2Ports(org: DeclaredOrg, recordedIds: Readonly<Record<string, string>>): Promise<{
1734
+ r2Keys: boolean;
1735
+ storeR2Credentials: boolean;
1736
+ }>;
1737
+ /** Whether the host's scoped ports for this own org include a files-organisation port. A held key still missing counts as no port. */
1738
+ ownFilesOrganisationPort(org: DeclaredOrg, recordedIds: Readonly<Record<string, string>>): Promise<boolean>;
1697
1739
  /** #555: the edge-routed ports this executor was built with itself (they win over edge). */
1698
1740
  ownEdgePorts(): Partial<Record<EdgePortName, boolean>>;
1699
1741
  execute(action: StepAction, context: StepExecutionContext): Promise<{
1700
1742
  resourceId?: string;
1701
1743
  } | undefined>;
1744
+ private run;
1702
1745
  /**
1703
1746
  * `plan/diff.ts`'s `planAccess` (#246): puts a `hosting.access` rule's
1704
1747
  * hostname behind a Cloudflare Access app named
@@ -2189,6 +2232,22 @@ export declare class FoundryStepExecutor implements StepExecutor, StepDestroyer
2189
2232
  * (which must never delete the platform's own project) change.
2190
2233
  */
2191
2234
  private ensureOrgLettermintRelay;
2235
+ /**
2236
+ * The company's own sign-in mail token: a Lettermint project `<slug>-auth`
2237
+ * whose token goes to `orgTarget(org).refs.brokerMailKey`, where the broker
2238
+ * and ZITADEL's SMTP settings read it. The platform-wide Lettermint key
2239
+ * stays with the executor and never reaches a company. A held ref is left
2240
+ * alone. Otherwise the mint rule of {@link ensureOrgAppMailToken}: create
2241
+ * the project, restrict the domain, deliver its token, and only then
2242
+ * delete every other `<slug>-auth` project (a token cannot be read back,
2243
+ * so a project without a held token is a leftover). The restriction comes
2244
+ * before the delivery so a failure there leaves no held ref: the next run
2245
+ * starts over instead of skipping it. A failure before the delivery leaves
2246
+ * that project for the next run to delete. Every Lettermint call is
2247
+ * bounded; the token is registered for redaction before anything can throw
2248
+ * with it.
2249
+ */
2250
+ private ensureOwnAuthMailToken;
2192
2251
  /**
2193
2252
  * Deletes a just-minted project from inside a `catch`. A failed delete is
2194
2253
  * dropped so it never replaces the error being rethrown; the orphan is
@@ -2684,6 +2743,54 @@ export declare class FoundryStepExecutor implements StepExecutor, StepDestroyer
2684
2743
  */
2685
2744
  private zonePort;
2686
2745
  private jobsPort;
2746
+ /**
2747
+ * `plan/own-diff.ts`'s `planOwnJobs`: the org's own scheduler, in the org's
2748
+ * own project, with the names and the held-key ref of `orgTarget(org)`. The
2749
+ * admin token goes to `jobs:org:<slug>:adminToken` and nowhere else.
2750
+ */
2751
+ private ownJobs;
2752
+ /**
2753
+ * `plan/own-diff.ts`'s `planOwnFiles`: the org's own files host in the
2754
+ * org's own project, trusting the company's issuer `https://auth.<domain>`.
2755
+ * Its ZITADEL project `<slug>-files` and introspection app are made in the
2756
+ * company's instance (`ownServices.zitadelFor`), never the shared one. The
2757
+ * bucket comes from the host's bucket port under the target's name. The
2758
+ * management key goes to `files:org:<slug>:managementKey` and the
2759
+ * introspection secret to `zitadel:org:<slug>:files:introspection`, both
2760
+ * once, and neither is recorded or returned.
2761
+ */
2762
+ private ownFiles;
2763
+ /**
2764
+ * `plan/own-diff.ts`'s `planOwnFilesR2`: the storage key of the org's own files bucket. A token
2765
+ * recorded as undelivered is deleted first (its secret cannot be read back, so it can never be
2766
+ * finished), then one is minted, registered as a secret, recorded by id as undelivered and
2767
+ * persisted, and only then sent to the files host, the same pair on every retry. The secret is
2768
+ * never recorded and never written to a Coolify env. The id is recorded once the host has it.
2769
+ */
2770
+ private ownFilesR2;
2771
+ /**
2772
+ * `plan/own-diff.ts`'s `planOwnAuth`: the org's own sign-in at
2773
+ * `auth.<domain>`, in the org's own project, through the org's own edge
2774
+ * routed DNS and tunnel ports. The instance owner token goes to
2775
+ * `zitadel:org:<slug>:instanceBearer` and is never recorded. A changed
2776
+ * declared auth ref patches both git apps' branch and redeploys them; the
2777
+ * applied marker comes last, so a run that died halfway is planned again.
2778
+ */
2779
+ private ownAuth;
2780
+ /**
2781
+ * `plan/own-diff.ts`'s `planOwnMail`: the org's own Stalwart in the org's own
2782
+ * project, with no host port (the shared relay reaches it by internal name)
2783
+ * and a directory that trusts the org's own sign-in. The admin key goes to
2784
+ * `orgTarget(org).refs.mailAdminKey` and nowhere else. The address the relay
2785
+ * routes to is recorded last, and the step's marker after it.
2786
+ */
2787
+ private ownMail;
2788
+ /**
2789
+ * `plan/own-diff.ts`'s `planOwnSfu`: the org's own LiveKit server in the
2790
+ * org's project, on the ports of its slot. The company's key pair goes to
2791
+ * `livekit:org:<slug>:keys` and the app's env, nowhere else.
2792
+ */
2793
+ private ownSfu;
2687
2794
  private jobsRegistration;
2688
2795
  /** `deliverSecret`, tried up to {@link JOBS_DELIVERY_ATTEMPTS} times; true once one attempt succeeds. */
2689
2796
  private deliverWithRetry;
@@ -2937,6 +3044,11 @@ export declare class FoundryStepExecutor implements StepExecutor, StepDestroyer
2937
3044
  * #491: when `mailDomain` is the platform's own apex, this must never
2938
3045
  * delete the platform's own project (the delete loop skips it by id) or
2939
3046
  * the platform's own domain (returns before the domain lookup/delete).
3047
+ *
3048
+ * An org whose own sign-in is applied keeps its `<slug>-auth` project, and
3049
+ * keeps the Lettermint domain when that is the domain the sign-in sends
3050
+ * from. Every other org deletes `<slug>-auth` too, which only a failed
3051
+ * first sign-in run can have left behind.
2940
3052
  */
2941
3053
  private parkLettermintMail;
2942
3054
  /**