@wtfalch/foundry 0.5.0 → 0.7.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 (66) hide show
  1. package/dist/bootstrap/bootstrap.d.ts +16 -1
  2. package/dist/bootstrap/bootstrap.js +40 -2
  3. package/dist/bootstrap/declare.d.ts +3 -1
  4. package/dist/bootstrap/declare.js +12 -3
  5. package/dist/bootstrap/versions.d.ts +17 -0
  6. package/dist/bootstrap/versions.js +12 -0
  7. package/dist/calls-sfu-deploy-support.d.ts +8 -0
  8. package/dist/calls-sfu-deploy-support.js +10 -1
  9. package/dist/calls-sfu.d.ts +36 -12
  10. package/dist/calls-sfu.js +42 -9
  11. package/dist/index.d.ts +10 -4
  12. package/dist/index.js +6 -3
  13. package/dist/migrations/0019_mail_owner_mailbox.sql +15 -0
  14. package/dist/migrations/0020_mail_apps.sql +7 -0
  15. package/dist/migrations/0021_mail_role_mailboxes.sql +6 -0
  16. package/dist/migrations/0022_chat_enabled.sql +6 -0
  17. package/dist/migrations/0023_hosting_app_env.sql +8 -0
  18. package/dist/migrations/0024_audit_events.sql +211 -0
  19. package/dist/migrations/0025_audit_display.sql +147 -0
  20. package/dist/migrations/0026_audit_erase_email_guard.sql +78 -0
  21. package/dist/migrations/0027_audit_chain.sql +170 -0
  22. package/dist/migrations/0028_audit_rls.sql +111 -0
  23. package/dist/org.d.ts +105 -0
  24. package/dist/org.js +101 -0
  25. package/dist/plan/agora.d.ts +21 -0
  26. package/dist/plan/agora.js +23 -0
  27. package/dist/plan/calls-matrix.d.ts +2 -2
  28. package/dist/plan/calls-matrix.js +3 -2
  29. package/dist/plan/cloudflared-connector.d.ts +3 -2
  30. package/dist/plan/cloudflared-connector.js +5 -3
  31. package/dist/plan/diff.d.ts +15 -2
  32. package/dist/plan/diff.js +399 -51
  33. package/dist/plan/executor.d.ts +307 -61
  34. package/dist/plan/executor.js +489 -129
  35. package/dist/plan/index.d.ts +9 -1
  36. package/dist/plan/index.js +5 -1
  37. package/dist/plan/login-host.d.ts +64 -0
  38. package/dist/plan/login-host.js +52 -0
  39. package/dist/plan/mail-copy.d.ts +125 -0
  40. package/dist/plan/mail-copy.js +166 -0
  41. package/dist/plan/mail-identity-allowlist.d.ts +50 -0
  42. package/dist/plan/mail-identity-allowlist.js +11 -0
  43. package/dist/plan/mail-identity.d.ts +68 -0
  44. package/dist/plan/mail-identity.js +17 -0
  45. package/dist/plan/plan-apply.d.ts +26 -8
  46. package/dist/plan/plan-apply.js +10 -9
  47. package/dist/plan/platform-diff.d.ts +207 -10
  48. package/dist/plan/platform-diff.js +403 -15
  49. package/dist/plan/platform-org.d.ts +3 -2
  50. package/dist/plan/platform-providers.d.ts +139 -25
  51. package/dist/plan/platform-providers.js +1531 -95
  52. package/dist/plan/platform-sfu.d.ts +54 -19
  53. package/dist/plan/platform-sfu.js +64 -22
  54. package/dist/plan/providers-readers.js +44 -1
  55. package/dist/plan/zitadel-machine-user.d.ts +60 -11
  56. package/dist/plan/zitadel-machine-user.js +182 -39
  57. package/dist/requested-changes.d.ts +17 -7
  58. package/dist/requested-changes.js +37 -7
  59. package/dist/store/audit.d.ts +40 -4
  60. package/dist/store/audit.js +114 -29
  61. package/dist/store/postgres.js +35 -3
  62. package/dist/store/schema.d.ts +109 -1
  63. package/dist/store/schema.js +20 -0
  64. package/dist/store.d.ts +7 -0
  65. package/package.json +5 -3
  66. package/versions.json +8 -0
package/dist/plan/diff.js CHANGED
@@ -1,5 +1,7 @@
1
- import { NO_AGORA_HOST_VALUES, agoraEnvDrift, agoraHostMissing, desiredAgoraEnv, } from './agora.js';
2
- import { ORG_MAIL_APPLIED_APEX_SCOPE_KEY, ORG_MAIL_APPLIED_HOST_SCOPE_KEY, } from './platform-diff.js';
1
+ import { NO_AGORA_HOST_VALUES, ZITADEL_ORG_SCOPE_KEY, agoraEnvDrift, agoraHostMissing, desiredAgoraEnv, } from './agora.js';
2
+ import { LOGIN_BINDING_SCOPE_KEY, loginBindingState, loginHostClientIds, loginHostnameFor, } from './login-host.js';
3
+ import { MAIL_COPY_COMPLETED_SCOPE_KEY, MAIL_COPY_DONE_SCOPE_KEY, parseMailCopyCompleted, } from './mail-copy.js';
4
+ import { APP_ENV_APPLIED_SCOPE_KEY, DB_ENV_APPLIED_SCOPE_KEY, ORG_MAIL_APPLIED_APEX_SCOPE_KEY, ORG_MAIL_APPLIED_HOST_SCOPE_KEY, resolveAppEnv, } from './platform-diff.js';
3
5
  import { DEFAULT_ORG_PLATFORM_READINESS } from './platform-org.js';
4
6
  /** The `Plan` (#19's public contract) a `PlanDetail` carries. */
5
7
  export function toPlan(detail) {
@@ -52,7 +54,11 @@ function resourceKey(system, kind, key) {
52
54
  * app with the platform's jobs scheduler, idempotent on the *recorded*
53
55
  * resource id like {@link planOwner} rather than a live resource, since
54
56
  * nothing in `live` can report a scheduler registration (no reader exists for
55
- * it). Everything
57
+ * it). #267 adds one more, right after {@link planOwner} and before every
58
+ * app-creating step below: {@link planLoginHost}, the org's own company
59
+ * login host -- also idempotent on the *recorded* resource id, since
60
+ * `live` has no notion of a ZITADEL trusted domain or a login binding
61
+ * either. Everything
56
62
  * else in `live` that none of these eighteen singletons and however many
57
63
  * declared issuer apps claim is unmanaged. The full field-by-field provisioning surface (SSO,
58
64
  * guests, calls on the org's own domain, adopted/bespoke apps, the actual
@@ -60,7 +66,7 @@ function resourceKey(system, kind, key) {
60
66
  * Company Factory end to end", which wires real readers and executors
61
67
  * against this seam.
62
68
  */
63
- export function computePlanDetail(org, live, recordedIds = {}, agoraHost = NO_AGORA_HOST_VALUES, jobsAvailable = false, platformReadiness = DEFAULT_ORG_PLATFORM_READINESS) {
69
+ export function computePlanDetail(org, live, recordedIds = {}, agoraHost = NO_AGORA_HOST_VALUES, jobsAvailable = false, platformReadiness = DEFAULT_ORG_PLATFORM_READINESS, options = {}) {
64
70
  const byKey = new Map(live.map((r) => [resourceKey(r.system, r.kind, r.key), r]));
65
71
  const claimed = new Set();
66
72
  const plannedSteps = [];
@@ -70,9 +76,32 @@ export function computePlanDetail(org, live, recordedIds = {}, agoraHost = NO_AG
70
76
  const owner = planOwner(org, recordedIds, platformReadiness.authReady);
71
77
  if (owner)
72
78
  plannedSteps.push(owner);
79
+ // #267: after the ZITADEL org step, before any app's own issuer
80
+ // application. Apps created later in this run are not in the binding yet;
81
+ // the next run replans this step and repoints them.
82
+ const loginHost = planLoginHost(org, recordedIds, platformReadiness.authReady, issuer.step !== null, options.loginHostBindings ?? false);
83
+ if (loginHost.step)
84
+ plannedSteps.push(loginHost.step);
85
+ // Planned ahead of everything that needs the zone live (#238); its own step
86
+ // is still pushed in its usual place below.
87
+ const zone = planZone(org, byKey, claimed);
88
+ const zoneWaiting = zone.blocked !== null;
89
+ const zoneBlocked = [];
73
90
  const mail = planMail(org, byKey, claimed, recordedIds, platformReadiness.mailHost);
74
- if (mail.step)
91
+ // #238: own-domain mail publishes DNS records into the zone, so it waits for
92
+ // an active zone -- blocked, not planned (rule 2).
93
+ if (mail.step && zoneWaiting && org.mail.usesDomain && org.domain) {
94
+ zoneBlocked.push({
95
+ description: `${org.slug}'s mail is blocked, not planned: the zone for ${org.domain} is not active yet`,
96
+ key: 'coolify:mail:zone',
97
+ });
98
+ }
99
+ else if (mail.step) {
75
100
  plannedSteps.push(mail.step);
101
+ }
102
+ const mailCopy = planMailCopy(org, byKey, recordedIds);
103
+ if (mailCopy)
104
+ plannedSteps.push(mailCopy);
76
105
  const chat = planChat(org, byKey, claimed);
77
106
  if (chat)
78
107
  plannedSteps.push(chat);
@@ -131,21 +160,36 @@ export function computePlanDetail(org, live, recordedIds = {}, agoraHost = NO_AG
131
160
  const database = planDatabase(org, byKey, claimed);
132
161
  if (database)
133
162
  plannedSteps.push(database);
134
- const app = planApp(org, byKey, claimed);
135
- if (app)
136
- plannedSteps.push(app);
137
- const tunnel = planTunnel(org, byKey, claimed);
138
- if (tunnel)
163
+ const app = planApp(org, byKey, claimed, recordedIds, plannedSteps);
164
+ if (app.step)
165
+ plannedSteps.push(app.step);
166
+ const tunnel = planTunnel(org, byKey, claimed, recordedIds);
167
+ // #238: a tunnel that is being provisioned waits for an active zone; parking
168
+ // one never does.
169
+ if (tunnel && zoneWaiting && tunnel.action.desired.enabled !== false) {
170
+ zoneBlocked.push({
171
+ description: `${org.slug}'s Cloudflare tunnel is blocked, not planned: the zone for ${org.domain} is not active yet`,
172
+ key: 'cloudflare:tunnel',
173
+ });
174
+ }
175
+ else if (tunnel) {
139
176
  plannedSteps.push(tunnel);
140
- const zone = planZone(org, byKey, claimed);
141
- if (zone)
142
- plannedSteps.push(zone);
177
+ }
178
+ if (zone.step)
179
+ plannedSteps.push(zone.step);
143
180
  // Right after the zone (#43): `cloudflareRateLimit` resolves the zone by
144
181
  // apex domain, so the zone step above must already have run (or the zone
145
182
  // must already exist live) by the time this one's action executes.
146
183
  const rateLimit = planRateLimit(org, recordedIds);
147
- if (rateLimit)
184
+ if (rateLimit && zoneWaiting) {
185
+ zoneBlocked.push({
186
+ description: `${org.slug}'s sign-in rate limit is blocked, not planned: the zone for ${org.domain} is not active yet`,
187
+ key: 'cloudflare:ratelimit',
188
+ });
189
+ }
190
+ else if (rateLimit) {
148
191
  plannedSteps.push(rateLimit);
192
+ }
149
193
  // After the apps: the customers org is granted the project the first app
150
194
  // step creates.
151
195
  const customers = planCustomers(org, live, claimed, recordedIds);
@@ -195,8 +239,17 @@ export function computePlanDetail(org, live, recordedIds = {}, agoraHost = NO_AG
195
239
  // budget, key) would otherwise also have nothing to act on.
196
240
  if (issuer.blocked)
197
241
  unmanaged.push({ description: issuer.blocked, key: 'zitadel:issuer' });
242
+ // #267 (rule 2): a login host with no ZITADEL org to trust it in is blocked, not planned to throw.
243
+ if (loginHost.blocked) {
244
+ unmanaged.push({ description: loginHost.blocked, key: 'zitadel:login-host' });
245
+ }
198
246
  if (mail.blocked)
199
247
  unmanaged.push({ description: mail.blocked, key: 'coolify:mail' });
248
+ // #162: reported separately from `mail.blocked` -- missing an owner
249
+ // mailbox address never blocks the rest of this org's mail.
250
+ if (mail.mailboxBlocked) {
251
+ unmanaged.push({ description: mail.mailboxBlocked, key: 'coolify:mail:owner-mailbox' });
252
+ }
200
253
  if (calls.blocked)
201
254
  unmanaged.push({ description: calls.blocked, key: 'coolify:calls' });
202
255
  if (filesOrganisation.blocked) {
@@ -209,6 +262,13 @@ export function computePlanDetail(org, live, recordedIds = {}, agoraHost = NO_AG
209
262
  unmanaged.push({ description: storage.blocked, key: 'files:storage' });
210
263
  if (aiBudget.blocked)
211
264
  unmanaged.push({ description: aiBudget.blocked, key: 'ai:budget' });
265
+ // #238: a pending zone, reported here with its nameservers rather than
266
+ // silently treated as converged.
267
+ if (zone.blocked)
268
+ unmanaged.push({ description: zone.blocked, key: 'cloudflare:zone' });
269
+ unmanaged.push(...zoneBlocked);
270
+ // #243: an appEnv source nothing will record is reported, not silently skipped.
271
+ unmanaged.push(...app.blocked);
212
272
  return { orgId: org.id, plannedSteps, unmanaged };
213
273
  }
214
274
  /**
@@ -346,6 +406,83 @@ function planOwner(org, recordedIds, authReady) {
346
406
  },
347
407
  };
348
408
  }
409
+ /**
410
+ * #267: the org's own company login host (`login-host.ts`'s
411
+ * `loginHostnameFor`) -- `auth.<domain>` once the org has one,
412
+ * `<slug>-auth.oikos.host` for tier 1. Idempotent on the *recorded*
413
+ * resource id, the same shape `planOwner` above uses and for the same
414
+ * reason: `ZitadelReader.listResources` has no notion of a trusted domain
415
+ * or a login binding, so there is nothing in `live` this step could ever
416
+ * claim or compare against.
417
+ *
418
+ * Unlike `planOwner`, a re-run is not simply "already done or not": the
419
+ * recorded value is compared against {@link loginHostnameFor}'s own value
420
+ * for *this* apply, so an org moving from tier 1 onto its own domain (or
421
+ * losing one) replans this step rather than staying silent forever once
422
+ * first applied -- `executor.ts`'s `zitadelLoginHost` then also moves every
423
+ * already-live app onto the new base URI. Mints nothing either way: adding
424
+ * a trusted domain is not a one-time secret, so there is no "undelivered"
425
+ * guard to check the way `planJobsRegistration`'s own does.
426
+ *
427
+ * Ordered right after `planOwner` (not after mail, chat, or any hosting
428
+ * step), for the same reason `planOwner`'s own doc comment gives: on a
429
+ * first-ever apply the ZITADEL org this step's executor needs
430
+ * (`context.recordedIds().zitadel_org`) has already been recorded by the
431
+ * issuer step two steps before it in the same run, and this step itself
432
+ * runs well before `planIssuerApps`/`planCmsClient`/`planAgoraClient`
433
+ * create any app under the org's project -- #267's own "before any app's
434
+ * issuer application".
435
+ */
436
+ function planLoginHost(org, recordedIds, authReady, issuerPlanned, bindingsWired) {
437
+ const none = { step: null, blocked: null };
438
+ const key = resourceKey('zitadel', 'login-host', org.id);
439
+ const host = loginHostnameFor(org);
440
+ // Already applied -- a re-run plans nothing unless the host changed or an app
441
+ // client id appeared since the binding was last written (#267, rule 4).
442
+ if (recordedIds[key] === host &&
443
+ recordedIds[LOGIN_BINDING_SCOPE_KEY] ===
444
+ loginBindingState(host, loginHostClientIds(recordedIds))) {
445
+ return none;
446
+ }
447
+ // #177: nothing to add a trusted domain to yet -- `planIssuer` already
448
+ // reports the platform's sign-in as blocked, so this step stays silent
449
+ // rather than repeating it, the same rule `planOwner` follows.
450
+ if (!authReady)
451
+ return none;
452
+ // Isolation between orgs rests on zitadel-login's binding, and repointing
453
+ // an app at a host with no binding breaks its sign-in: trust nothing and
454
+ // repoint nothing until the port that writes the binding is wired.
455
+ if (!bindingsWired) {
456
+ return {
457
+ step: null,
458
+ blocked: `${org.slug}'s login host ${host} waits for the loginHostBindings port, which the host has not wired`,
459
+ };
460
+ }
461
+ // No ZITADEL org to trust the host in: wait for it, unless the issuer step
462
+ // in this very plan creates it first (it runs before this one).
463
+ if (!recordedIds[ZITADEL_ORG_SCOPE_KEY] && !issuerPlanned) {
464
+ return {
465
+ step: null,
466
+ blocked: `${org.slug}'s login host ${host} waits for its ZITADEL org, which is not recorded yet`,
467
+ };
468
+ }
469
+ return {
470
+ step: {
471
+ action: {
472
+ system: 'zitadel',
473
+ kind: 'login-host',
474
+ key: org.id,
475
+ desired: { host },
476
+ },
477
+ step: {
478
+ description: `set ${org.slug}'s login host to ${host}`,
479
+ destroys: false,
480
+ migrates: false,
481
+ },
482
+ },
483
+ blocked: null,
484
+ };
485
+ }
349
486
  /**
350
487
  * #162: the flat scope id `executor.ts`'s `ensureOwnerMailbox` records once
351
488
  * it has given the org's owner a mailbox (`MAIL_OWNER_MAILBOX_SCOPE_KEY`
@@ -353,6 +490,12 @@ function planOwner(org, recordedIds, authReady) {
353
490
  * `LOG_DIR` precedent follows for a value shared with `executor.ts`.
354
491
  */
355
492
  const MAIL_OWNER_MAILBOX_SCOPE_KEY = 'mail_owner_mailbox';
493
+ /** #230: restated from `executor.ts`'s `MAIL_ROLE_MAILBOXES_SCOPE_KEY`, same rule: comma-joined local parts already created. */
494
+ const MAIL_ROLE_MAILBOXES_SCOPE_KEY = 'mail_role_mailboxes';
495
+ /** Restated from `executor.ts`, the same rule: the app id of the org tunnel's cloudflared connector. */
496
+ const CLOUDFLARE_TUNNEL_CONNECTOR_APP_SCOPE_KEY = 'cloudflare_tunnel_connector_app';
497
+ /** Restated from `executor.ts`, the same rule: the mail domain the last apply resolved. */
498
+ const MAIL_DOMAIN_SCOPE_KEY = 'mail_domain';
356
499
  /** Restated from `executor.ts`, the same rule: the id of an ai key minted but not delivered. */
357
500
  export const AI_KEY_UNDELIVERED_SCOPE_KEY = 'ai_key_undelivered';
358
501
  /** Restated from `executor.ts`, the same rule: the id of a jobs project registered but not delivered. */
@@ -376,13 +519,14 @@ function planMail(org, byKey, claimed, recordedIds, mailHost) {
376
519
  // after having it would silently hide its own leftover mail resource from
377
520
  // the unmanaged list.
378
521
  if (org.mail.mode === 'off')
379
- return { step: null, blocked: null };
522
+ return { step: null, blocked: null, mailboxBlocked: null };
380
523
  if (live)
381
524
  claimed.add(key);
382
525
  if (!mailHost) {
383
526
  return {
384
527
  step: null,
385
528
  blocked: `${org.slug}'s mail is blocked, not planned: the platform's mail server is off or not yet applied`,
529
+ mailboxBlocked: null,
386
530
  };
387
531
  }
388
532
  const desired = {
@@ -391,12 +535,33 @@ function planMail(org, byKey, claimed, recordedIds, mailHost) {
391
535
  apex: mailHost.apex,
392
536
  mailHost: mailHost.mailHost,
393
537
  };
394
- // #162: an owner's mailbox is minted inside the same `coolify:mail` step,
395
- // after `applyOrgMail` -- so as long as one is still owed, this step must
396
- // keep showing up even once mode/usesDomain themselves have nothing left
397
- // to change, or the operator would apply and never see it happen.
398
- const mailboxOwed = Boolean(org.owner) && !recordedIds[MAIL_OWNER_MAILBOX_SCOPE_KEY];
399
- const mailboxNote = mailboxOwed ? ` and give ${org.slug}'s owner a mailbox` : '';
538
+ // #162 (decided 2026-09-29): an owner's mailbox is minted inside the same
539
+ // `coolify:mail` step, after `applyOrgMail` -- so as long as one is still
540
+ // owed, this step must keep showing up even once mode/usesDomain
541
+ // themselves have nothing left to change, or the operator would apply and
542
+ // never see it happen. Nothing is guessed any more: without a declared
543
+ // `mail.ownerMailboxAddress`, the mailbox alone is reported blocked below
544
+ // (`mailboxBlocked`), the same "blocked, not planned" pattern a blocked
545
+ // Agora already follows (PR 180) -- the rest of this step, and every
546
+ // other step in the org's plan, is unaffected.
547
+ const mailboxAlreadyGiven = Boolean(recordedIds[MAIL_OWNER_MAILBOX_SCOPE_KEY]);
548
+ const hasOwner = Boolean(org.owner);
549
+ const ownerMailboxAddress = org.mail.ownerMailboxAddress ?? null;
550
+ const mailboxBlocked = hasOwner && !mailboxAlreadyGiven && !ownerMailboxAddress
551
+ ? `${org.slug}'s owner mailbox is blocked, not planned: mail.ownerMailboxAddress is not declared`
552
+ : null;
553
+ const mailboxOwed = hasOwner && !mailboxAlreadyGiven && ownerMailboxAddress !== null;
554
+ // #230: declared role mailboxes not yet recorded as created. Names only
555
+ // ever count here, never appear in a description, same as the owner's address.
556
+ // Recorded per address, so a changed mail domain owes its own set. A hosted
557
+ // org's domain is only known once recorded; until then every role is owed.
558
+ const createdRoles = new Set((recordedIds[MAIL_ROLE_MAILBOXES_SCOPE_KEY] ?? '').split(','));
559
+ const roleDomain = (org.mail.usesDomain && org.domain) || recordedIds[MAIL_DOMAIN_SCOPE_KEY];
560
+ const rolesOwed = (org.mail.roleMailboxes ?? []).filter((r) => !roleDomain || !createdRoles.has(`${r}@${roleDomain}`.toLowerCase())).length;
561
+ const ownerNote = mailboxOwed ? `give ${org.slug}'s owner a mailbox` : '';
562
+ const roleNote = rolesOwed > 0 ? `create ${rolesOwed} role mailbox(es) for ${org.slug}` : '';
563
+ const mailboxWork = [ownerNote, roleNote].filter(Boolean).join(' and ');
564
+ const mailboxNote = mailboxWork ? ` and ${mailboxWork}` : '';
400
565
  if (!live) {
401
566
  return {
402
567
  step: {
@@ -408,6 +573,7 @@ function planMail(org, byKey, claimed, recordedIds, mailHost) {
408
573
  },
409
574
  },
410
575
  blocked: null,
576
+ mailboxBlocked,
411
577
  };
412
578
  }
413
579
  const liveMode = live.config.mode;
@@ -417,20 +583,21 @@ function planMail(org, byKey, claimed, recordedIds, mailHost) {
417
583
  // them (or it never recorded them). Re-applying is idempotent.
418
584
  const addressMoved = recordedIds[ORG_MAIL_APPLIED_APEX_SCOPE_KEY] !== mailHost.apex ||
419
585
  recordedIds[ORG_MAIL_APPLIED_HOST_SCOPE_KEY] !== mailHost.mailHost;
420
- if (!mailboxOwed && !addressMoved)
421
- return { step: null, blocked: null };
586
+ if (!mailboxWork && !addressMoved)
587
+ return { step: null, blocked: null, mailboxBlocked };
422
588
  return {
423
589
  step: {
424
590
  action: { system: 'coolify', kind: 'mail', key: org.id, desired },
425
591
  step: {
426
592
  description: addressMoved
427
593
  ? `re-apply ${org.slug}'s mail on the platform's mail host ${mailHost.mailHost}${mailboxNote}`
428
- : `give ${org.slug}'s owner a mailbox`,
594
+ : mailboxWork,
429
595
  destroys: false,
430
596
  migrates: false,
431
597
  },
432
598
  },
433
599
  blocked: null,
600
+ mailboxBlocked,
434
601
  };
435
602
  }
436
603
  if (liveUsesDomain && !desired.usesDomain) {
@@ -446,6 +613,21 @@ function planMail(org, byKey, claimed, recordedIds, mailHost) {
446
613
  },
447
614
  },
448
615
  blocked: null,
616
+ mailboxBlocked,
617
+ };
618
+ }
619
+ // #113: moving from `received` to `hosted` is an MX cutover, and foundry
620
+ // never cuts over before an org's old mail has copied into Stalwart --
621
+ // `planMailCopy`'s own step is what runs that copy. Blocked, not planned,
622
+ // the same "withhold the step rather than apply it half-ready" rule
623
+ // `mailboxBlocked` already follows just above.
624
+ if (liveMode === 'received' &&
625
+ desired.mode === 'hosted' &&
626
+ !recordedIds[MAIL_COPY_DONE_SCOPE_KEY]) {
627
+ return {
628
+ step: null,
629
+ blocked: `${org.slug}'s mail cannot move to hosted yet: its old mail is still being copied into Stalwart (coolify:mail-copy, #113)`,
630
+ mailboxBlocked,
449
631
  };
450
632
  }
451
633
  // A mode transition (#7 "Transitions") or turning a domain on is a
@@ -460,9 +642,44 @@ function planMail(org, byKey, claimed, recordedIds, mailHost) {
460
642
  },
461
643
  },
462
644
  blocked: null,
645
+ mailboxBlocked,
646
+ };
647
+ }
648
+ /**
649
+ * #113: the one-time copy of an org's old mail into Stalwart that must
650
+ * finish before `planMail` allows the `received` -> `hosted` MX cutover
651
+ * above. Planned only for that exact transition -- a fresh org declared
652
+ * `hosted` from the start, or one moving between any other pair of modes,
653
+ * has nothing to migrate from and never sees this step. Never matched
654
+ * against a live resource: like `platform-diff.ts`'s `planSfu`, there is no
655
+ * live state to probe here (`runMailCopyStep`'s own progress is recorded
656
+ * state, not something a reader lists), so this plans again on every apply
657
+ * until {@link MAIL_COPY_DONE_SCOPE_KEY} is recorded.
658
+ */
659
+ function planMailCopy(org, byKey, recordedIds) {
660
+ if (org.mail.mode !== 'hosted')
661
+ return null;
662
+ if (recordedIds[MAIL_COPY_DONE_SCOPE_KEY])
663
+ return null;
664
+ const live = byKey.get(resourceKey('coolify', 'mail', org.id));
665
+ if (!live || live.config.mode !== 'received')
666
+ return null;
667
+ const completed = parseMailCopyCompleted(recordedIds[MAIL_COPY_COMPLETED_SCOPE_KEY]);
668
+ const progress = completed.length > 0 ? ` (${completed.length} account(s) already copied)` : '';
669
+ return {
670
+ action: { system: 'coolify', kind: 'mail-copy', key: org.id, desired: {} },
671
+ step: {
672
+ description: `copy ${org.slug}'s old mail into Stalwart before the MX cutover${progress} -- re-apply again to continue, or tomorrow if a provider's daily limit was hit`,
673
+ destroys: false,
674
+ migrates: false,
675
+ },
463
676
  };
464
677
  }
465
678
  function planChat(org, byKey, claimed) {
679
+ // Chat off (not in v1): plan nothing, and leave any live chat unclaimed so
680
+ // it is reported in `unmanaged` and never adopted or destroyed.
681
+ if (!org.chat.enabled)
682
+ return null;
466
683
  const key = resourceKey('coolify', 'chat', org.id);
467
684
  const live = byKey.get(key);
468
685
  if (live)
@@ -1120,23 +1337,103 @@ function planHostingToggle(system, kind, desiredEnabled, org, byKey, claimed, de
1120
1337
  * resource's `enabled` field -- a live resource nothing has actually
1121
1338
  * checked yet is assumed fine rather than replanned every time.
1122
1339
  */
1123
- function planApp(org, byKey, claimed) {
1340
+ function planApp(org, byKey, claimed, recordedIds, plannedSteps) {
1341
+ // #243: an `appEnv` source with no recorded value and no step planned to
1342
+ // record one this run has nothing to push, now or after this apply.
1343
+ const sourceStep = {
1344
+ filesOrganisationId: ['files', 'organisation'],
1345
+ aiOrganisationId: ['ai', 'organisation'],
1346
+ mailDomain: ['coolify', 'mail'],
1347
+ };
1348
+ const { values: wantedEnv, unresolved } = resolveAppEnv(org, recordedIds);
1349
+ const blocked = org.hosting.enabled
1350
+ ? unresolved
1351
+ .filter(([, source]) => !plannedSteps.some((p) => p.action.system === sourceStep[source][0] &&
1352
+ p.action.kind === sourceStep[source][1]))
1353
+ .map(([name, source]) => ({
1354
+ description: `${org.slug}'s app env ${name} is blocked, not planned: its source ${source} has no recorded value yet`,
1355
+ key: `coolify:app:env:${name}`,
1356
+ }))
1357
+ : [];
1124
1358
  const toggled = planHostingToggle('coolify', 'app', org.hosting.enabled, org, byKey, claimed, () => `provision ${org.slug}'s app`, () => `park ${org.slug}'s app (stops serving; app data is retained, not deleted -- #7 corrections 1)`);
1125
1359
  if (toggled)
1126
- return toggled;
1360
+ return { step: toggled, blocked };
1127
1361
  if (!org.hosting.enabled)
1128
- return null;
1362
+ return { step: null, blocked };
1129
1363
  const live = byKey.get(resourceKey('coolify', 'app', org.id));
1130
- if (!live || live.config.hasLogVolume !== false)
1131
- return null;
1132
- return {
1133
- action: { system: 'coolify', kind: 'app', key: org.id, desired: { enabled: true } },
1134
- step: {
1135
- description: `add a durable log directory to ${org.slug}'s app (persistent volume at /app/logs + LOG_DIR) so a database outage does not erase the evidence (#75)`,
1136
- destroys: false,
1137
- migrates: false,
1138
- },
1364
+ if (!live)
1365
+ return { step: null, blocked };
1366
+ const action = {
1367
+ system: 'coolify',
1368
+ kind: 'app',
1369
+ key: org.id,
1370
+ desired: { enabled: true },
1139
1371
  };
1372
+ if (live.config.hasLogVolume === false) {
1373
+ return {
1374
+ step: {
1375
+ action,
1376
+ step: {
1377
+ description: `add a durable log directory to ${org.slug}'s app (persistent volume at /app/logs + LOG_DIR) so a database outage does not erase the evidence (#75)`,
1378
+ destroys: false,
1379
+ migrates: false,
1380
+ },
1381
+ },
1382
+ blocked,
1383
+ };
1384
+ }
1385
+ // #239 (rule 4), from live state: an app with a database but no
1386
+ // `DATABASE_URL_OWNER` env still has the pre-split superuser URL, or never
1387
+ // got the push. Names only, never values. A reader that did not list env
1388
+ // names (`envNames` absent) reads as compliant, like `hasLogVolume`.
1389
+ const envNames = live.config.envNames;
1390
+ const dbLive = byKey.get(resourceKey('coolify', 'database', org.id));
1391
+ const dbReady = org.hosting.database && Boolean(recordedIds[`coolify:database:${org.id}`]);
1392
+ if (dbReady && dbLive?.config.hasInternalUrl === false) {
1393
+ // Rule 2: the push cannot succeed yet, so report it rather than replan
1394
+ // forever.
1395
+ blocked.push({
1396
+ description: `${org.slug}'s app database URLs are blocked, not planned: the database reports no internal connection URL yet`,
1397
+ key: 'coolify:app:database-url',
1398
+ });
1399
+ }
1400
+ else if (dbReady &&
1401
+ // Only a read that saw the URL replans: an unread database (a failed
1402
+ // read) would plan a step that pushes nothing, forever.
1403
+ dbLive?.config.hasInternalUrl === true &&
1404
+ ((Array.isArray(envNames) && !envNames.includes('DATABASE_URL_OWNER')) ||
1405
+ // A crash between the push and the restart leaves the env right and no
1406
+ // marker: replan once more to restart and set it.
1407
+ !recordedIds[DB_ENV_APPLIED_SCOPE_KEY])) {
1408
+ return {
1409
+ step: {
1410
+ action,
1411
+ step: {
1412
+ description: `give ${org.slug}'s app its owner and runtime database URLs and restart it (#239)`,
1413
+ destroys: false,
1414
+ migrates: false,
1415
+ },
1416
+ },
1417
+ blocked,
1418
+ };
1419
+ }
1420
+ // #243 (rule 3): a changed `hosting.appEnv` reaches an app that is already
1421
+ // live. `pushAppEnv` records what it pushed, so this plans the same step
1422
+ // again, with a redeploy, only once the resolved map differs from that.
1423
+ if (JSON.stringify(wantedEnv) !== (recordedIds[APP_ENV_APPLIED_SCOPE_KEY] ?? '{}')) {
1424
+ return {
1425
+ step: {
1426
+ action,
1427
+ step: {
1428
+ description: `push ${org.slug}'s changed app env (appEnv) and redeploy the app`,
1429
+ destroys: false,
1430
+ migrates: false,
1431
+ },
1432
+ },
1433
+ blocked,
1434
+ };
1435
+ }
1436
+ return { step: null, blocked };
1140
1437
  }
1141
1438
  /**
1142
1439
  * #173: register the org's own app with the platform's jobs scheduler
@@ -1244,12 +1541,48 @@ function planDatabase(org, byKey, claimed) {
1244
1541
  * domain) plans no per-org tunnel for Agora: `<slug>-agora.oikos.host` rides
1245
1542
  * the shared `*.oikos.host` wildcard tunnel (`scripts/shared-ingress.mjs`).
1246
1543
  */
1247
- function planTunnel(org, byKey, claimed) {
1544
+ function planTunnel(org, byKey, claimed, recordedIds) {
1248
1545
  const desired = (org.hosting.tunnel && org.hosting.usesDomain && Boolean(org.domain)) ||
1249
1546
  (org.site.enabled && Boolean(org.domain)) ||
1250
1547
  (org.calls.enabled && org.calls.usesDomain && Boolean(org.domain)) ||
1251
1548
  Boolean(org.domain);
1252
- return planHostingToggle('cloudflare', 'tunnel', desired, org, byKey, claimed, () => `provision a Cloudflare tunnel for ${org.slug}'s app`, () => `park ${org.slug}'s Cloudflare tunnel (configuration is retained, not deleted -- #7 corrections 1)`);
1549
+ const toggled = planHostingToggle('cloudflare', 'tunnel', desired, org, byKey, claimed, () => `provision a Cloudflare tunnel for ${org.slug}'s app`, () => `park ${org.slug}'s Cloudflare tunnel (configuration is retained, not deleted -- #7 corrections 1)`);
1550
+ if (toggled || !desired)
1551
+ return toggled;
1552
+ // #235: a live, enabled tunnel with no connector recorded -- a first
1553
+ // connector create that failed, or a tunnel that predates connectors --
1554
+ // plans the same step again. The step finds or creates the connector by
1555
+ // name, so it never makes a second one.
1556
+ const live = byKey.get(resourceKey('cloudflare', 'tunnel', org.id));
1557
+ if (!live || recordedIds[CLOUDFLARE_TUNNEL_CONNECTOR_APP_SCOPE_KEY])
1558
+ return null;
1559
+ return {
1560
+ action: { system: 'cloudflare', kind: 'tunnel', key: org.id, desired: { enabled: true } },
1561
+ step: {
1562
+ description: `run a cloudflared connector for ${org.slug}'s Cloudflare tunnel`,
1563
+ destroys: false,
1564
+ migrates: false,
1565
+ },
1566
+ };
1567
+ }
1568
+ /**
1569
+ * #238: the org's own zone's live `status`/`name_servers`, read from the
1570
+ * same `kind: 'zone'` {@link LiveResource} `planZone` already matches by key
1571
+ * -- `@wtfalch/providers/cloudflare`'s `CloudflareClient.listResources`
1572
+ * already returns both (`config: { id, status, name_servers }`), so nothing
1573
+ * new has to be fetched; this just reads what was already there. `null`
1574
+ * means no zone is live yet at all (not a readiness problem `planZone`
1575
+ * itself reports -- it is still planning the zone's own creation).
1576
+ */
1577
+ function zoneReadiness(domain, byKey) {
1578
+ const zone = byKey.get(resourceKey('cloudflare', 'zone', domain));
1579
+ if (!zone)
1580
+ return null;
1581
+ const config = zone.config;
1582
+ return {
1583
+ active: config.status === 'active',
1584
+ nameServers: config.name_servers ?? [],
1585
+ };
1253
1586
  }
1254
1587
  /**
1255
1588
  * The org's Cloudflare zone for its own domain (`org.ts`'s
@@ -1281,25 +1614,40 @@ function planZone(org, byKey, claimed) {
1281
1614
  (org.calls.enabled && org.calls.usesDomain) ||
1282
1615
  Boolean(org.domain);
1283
1616
  if (!usesDomain || !org.domain)
1284
- return null;
1617
+ return { step: null, blocked: null };
1285
1618
  const key = resourceKey('cloudflare', 'zone', org.domain);
1286
1619
  const live = byKey.get(key);
1287
- if (live)
1620
+ if (live) {
1288
1621
  claimed.add(key);
1289
- if (live)
1290
- return null;
1622
+ const readiness = zoneReadiness(org.domain, byKey);
1623
+ // #238: a zone stays pending until the customer changes nameservers at
1624
+ // their registrar -- reported here, not silently treated as converged,
1625
+ // so an apply says what it is waiting for. A later apply, once
1626
+ // Cloudflare marks the zone active, reports nothing and the DNS- and
1627
+ // mail-dependent steps below (`planMail`) resume.
1628
+ if (readiness && !readiness.active) {
1629
+ return {
1630
+ step: null,
1631
+ blocked: `${org.slug}'s zone for ${org.domain} is waiting for nameservers: ${readiness.nameServers.join(', ')}`,
1632
+ };
1633
+ }
1634
+ return { step: null, blocked: null };
1635
+ }
1291
1636
  return {
1292
- action: {
1293
- system: 'cloudflare',
1294
- kind: 'zone',
1295
- key: org.domain,
1296
- desired: { domain: org.domain },
1297
- },
1298
1637
  step: {
1299
- description: `provision a Cloudflare zone for ${org.domain}`,
1300
- destroys: false,
1301
- migrates: false,
1638
+ action: {
1639
+ system: 'cloudflare',
1640
+ kind: 'zone',
1641
+ key: org.domain,
1642
+ desired: { domain: org.domain },
1643
+ },
1644
+ step: {
1645
+ description: `provision a Cloudflare zone for ${org.domain}`,
1646
+ destroys: false,
1647
+ migrates: false,
1648
+ },
1302
1649
  },
1650
+ blocked: null,
1303
1651
  };
1304
1652
  }
1305
1653
  /**