@wtfalch/foundry 0.4.0 → 0.6.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 (76) hide show
  1. package/dist/bootstrap/bootstrap.d.ts +111 -0
  2. package/dist/bootstrap/bootstrap.js +323 -0
  3. package/dist/bootstrap/declare.d.ts +36 -0
  4. package/dist/bootstrap/declare.js +99 -0
  5. package/dist/bootstrap/drive.d.ts +40 -0
  6. package/dist/bootstrap/drive.js +51 -0
  7. package/dist/bootstrap/index.d.ts +3 -0
  8. package/dist/bootstrap/index.js +1 -0
  9. package/dist/calls-sfu-deploy-support.d.ts +17 -6
  10. package/dist/calls-sfu-deploy-support.js +19 -7
  11. package/dist/calls-sfu-keys.d.ts +21 -0
  12. package/dist/calls-sfu-keys.js +27 -0
  13. package/dist/calls-sfu-ssh-adapter.d.ts +55 -0
  14. package/dist/calls-sfu-ssh-adapter.js +78 -0
  15. package/dist/calls-sfu-ssh-client.d.ts +49 -0
  16. package/dist/calls-sfu-ssh-client.js +82 -0
  17. package/dist/calls-sfu.d.ts +36 -12
  18. package/dist/calls-sfu.js +41 -9
  19. package/dist/index.d.ts +38 -7
  20. package/dist/index.js +20 -4
  21. package/dist/migrations/0016_platform.sql +61 -0
  22. package/dist/migrations/0017_requested_changes.sql +42 -0
  23. package/dist/migrations/0018_platform_apply.sql +19 -0
  24. package/dist/migrations/0019_mail_owner_mailbox.sql +15 -0
  25. package/dist/migrations/0020_mail_apps.sql +7 -0
  26. package/dist/migrations/0021_mail_role_mailboxes.sql +6 -0
  27. package/dist/org.d.ts +81 -0
  28. package/dist/org.js +96 -0
  29. package/dist/plan/agora.d.ts +77 -0
  30. package/dist/plan/agora.js +127 -0
  31. package/dist/plan/cloudflared-connector.d.ts +70 -0
  32. package/dist/plan/cloudflared-connector.js +106 -0
  33. package/dist/plan/diff.d.ts +20 -3
  34. package/dist/plan/diff.js +713 -115
  35. package/dist/plan/executor.d.ts +680 -172
  36. package/dist/plan/executor.js +755 -189
  37. package/dist/plan/index.d.ts +28 -5
  38. package/dist/plan/index.js +15 -2
  39. package/dist/plan/jobs-scheduler-http.d.ts +29 -0
  40. package/dist/plan/jobs-scheduler-http.js +50 -0
  41. package/dist/plan/live.d.ts +51 -2
  42. package/dist/plan/live.js +8 -0
  43. package/dist/plan/mail-copy.d.ts +125 -0
  44. package/dist/plan/mail-copy.js +166 -0
  45. package/dist/plan/mail-identity-allowlist.d.ts +50 -0
  46. package/dist/plan/mail-identity-allowlist.js +11 -0
  47. package/dist/plan/mail-identity.d.ts +68 -0
  48. package/dist/plan/mail-identity.js +17 -0
  49. package/dist/plan/plan-apply.d.ts +197 -3
  50. package/dist/plan/plan-apply.js +278 -9
  51. package/dist/plan/platform-diff.d.ts +487 -0
  52. package/dist/plan/platform-diff.js +1220 -0
  53. package/dist/plan/platform-org-ports.d.ts +50 -0
  54. package/dist/plan/platform-org-ports.js +70 -0
  55. package/dist/plan/platform-org.d.ts +121 -0
  56. package/dist/plan/platform-org.js +115 -0
  57. package/dist/plan/platform-providers.d.ts +615 -0
  58. package/dist/plan/platform-providers.js +4410 -0
  59. package/dist/plan/platform-sfu.d.ts +128 -0
  60. package/dist/plan/platform-sfu.js +142 -0
  61. package/dist/plan/providers-readers.js +22 -1
  62. package/dist/plan/zitadel-machine-user.d.ts +83 -0
  63. package/dist/plan/zitadel-machine-user.js +238 -0
  64. package/dist/plan.d.ts +6 -0
  65. package/dist/platform.d.ts +55 -0
  66. package/dist/platform.js +46 -0
  67. package/dist/requested-changes.d.ts +118 -0
  68. package/dist/requested-changes.js +125 -0
  69. package/dist/slug.js +15 -2
  70. package/dist/store/audit.js +4 -2
  71. package/dist/store/postgres.d.ts +29 -1
  72. package/dist/store/postgres.js +203 -4
  73. package/dist/store/schema.d.ts +755 -0
  74. package/dist/store/schema.js +70 -0
  75. package/dist/store.d.ts +53 -0
  76. package/package.json +1 -1
package/dist/plan/diff.js CHANGED
@@ -1,3 +1,7 @@
1
+ import { NO_AGORA_HOST_VALUES, agoraEnvDrift, agoraHostMissing, desiredAgoraEnv, } from './agora.js';
2
+ import { MAIL_COPY_COMPLETED_SCOPE_KEY, MAIL_COPY_DONE_SCOPE_KEY, parseMailCopyCompleted, } from './mail-copy.js';
3
+ import { ORG_MAIL_APPLIED_APEX_SCOPE_KEY, ORG_MAIL_APPLIED_HOST_SCOPE_KEY, } from './platform-diff.js';
4
+ import { DEFAULT_ORG_PLATFORM_READINESS } from './platform-org.js';
1
5
  /** The `Plan` (#19's public contract) a `PlanDetail` carries. */
2
6
  export function toPlan(detail) {
3
7
  return {
@@ -38,52 +42,69 @@ function resourceKey(system, kind, key) {
38
42
  * by default unlike chat's own: {@link planCalls}, the org's own calls
39
43
  * Matrix stack (`org.ts`'s `CallsConfig.enabled`). #145 adds one more,
40
44
  * planned right after it and gated on the same flag: {@link planCallsService},
41
- * the calls service itself (`wtfalch/calls`'s `packages/service`). Everything
42
- * else in `live` that none of these fifteen singletons and however many
45
+ * the calls service itself (`wtfalch/calls`'s `packages/service`). #164 adds
46
+ * two more, unconditionally -- no `DeclaredOrg` field gates either, since
47
+ * every org's own Agora is always on: {@link planAgoraClient}, a fixed
48
+ * ZITADEL web client named `agora` (modeled on {@link planCmsClient}), and
49
+ * {@link planAgora}, the Agora itself as a Coolify app (modeled on
50
+ * {@link planSite}, minus the park branch -- foundry never deletes an
51
+ * Agora). #173 adds one more, gated on `org.hosting.enabled` like
52
+ * {@link planApp} itself: {@link planJobsRegistration}, registering the org's
53
+ * app with the platform's jobs scheduler, idempotent on the *recorded*
54
+ * resource id like {@link planOwner} rather than a live resource, since
55
+ * nothing in `live` can report a scheduler registration (no reader exists for
56
+ * it). Everything
57
+ * else in `live` that none of these eighteen singletons and however many
43
58
  * declared issuer apps claim is unmanaged. The full field-by-field provisioning surface (SSO,
44
59
  * guests, calls on the org's own domain, adopted/bespoke apps, the actual
45
60
  * Zitadel/Coolify/Cloudflare/Files/AI/cms shapes) is #20's "provision
46
61
  * Company Factory end to end", which wires real readers and executors
47
62
  * against this seam.
48
63
  */
49
- export function computePlanDetail(org, live, recordedIds = {}) {
64
+ export function computePlanDetail(org, live, recordedIds = {}, agoraHost = NO_AGORA_HOST_VALUES, jobsAvailable = false, platformReadiness = DEFAULT_ORG_PLATFORM_READINESS) {
50
65
  const byKey = new Map(live.map((r) => [resourceKey(r.system, r.kind, r.key), r]));
51
66
  const claimed = new Set();
52
67
  const plannedSteps = [];
53
- const issuer = planIssuer(org, byKey, claimed);
54
- if (issuer)
55
- plannedSteps.push(issuer);
56
- const owner = planOwner(org, recordedIds);
68
+ const issuer = planIssuer(org, byKey, claimed, platformReadiness.authReady);
69
+ if (issuer.step)
70
+ plannedSteps.push(issuer.step);
71
+ const owner = planOwner(org, recordedIds, platformReadiness.authReady);
57
72
  if (owner)
58
73
  plannedSteps.push(owner);
59
- const mail = planMail(org, byKey, claimed);
60
- if (mail)
61
- plannedSteps.push(mail);
74
+ const mail = planMail(org, byKey, claimed, recordedIds, platformReadiness.mailHost);
75
+ if (mail.step)
76
+ plannedSteps.push(mail.step);
77
+ const mailCopy = planMailCopy(org, byKey, recordedIds);
78
+ if (mailCopy)
79
+ plannedSteps.push(mailCopy);
62
80
  const chat = planChat(org, byKey, claimed);
63
81
  if (chat)
64
82
  plannedSteps.push(chat);
65
- const calls = planCalls(org, byKey, claimed);
66
- if (calls)
67
- plannedSteps.push(calls);
83
+ const calls = planCalls(org, byKey, claimed, platformReadiness.callsReady);
84
+ if (calls.step)
85
+ plannedSteps.push(calls.step);
68
86
  // Right after the Matrix stack (#145): its own executor
69
87
  // (`executor.ts`'s `coolifyCallsService`) reads the Synapse/MAS addresses
70
88
  // and MAS client credentials `coolifyCalls` just recorded, the same
71
89
  // ordering `planOwner` relies on for `zitadel:issuer`.
72
- const callsService = planCallsService(org, byKey, claimed);
73
- if (callsService)
74
- plannedSteps.push(callsService);
75
- const filesOrganisation = planFilesOrganisation(org, byKey, claimed);
76
- if (filesOrganisation)
77
- plannedSteps.push(filesOrganisation);
78
- const storage = planStorage(org, byKey, claimed);
79
- if (storage)
80
- plannedSteps.push(storage);
81
- const aiOrganisation = planAiOrganisation(org, byKey, claimed);
82
- if (aiOrganisation)
83
- plannedSteps.push(aiOrganisation);
84
- const aiBudget = planAiBudget(org, byKey, claimed);
85
- if (aiBudget)
86
- plannedSteps.push(aiBudget);
90
+ const callsService = planCallsService(org, byKey, claimed, platformReadiness.callsReady);
91
+ if (callsService.step)
92
+ plannedSteps.push(callsService.step);
93
+ const filesOrganisation = planFilesOrganisation(org, byKey, claimed, platformReadiness.filesReady, platformReadiness.missingKeys.files, platformReadiness.noKeyReader);
94
+ if (filesOrganisation.step)
95
+ plannedSteps.push(filesOrganisation.step);
96
+ const storage = planStorage(org, byKey, claimed, platformReadiness.filesReady, platformReadiness.missingKeys.files, platformReadiness.noKeyReader);
97
+ if (storage.step)
98
+ plannedSteps.push(storage.step);
99
+ const aiOrganisation = planAiOrganisation(org, byKey, claimed, platformReadiness.aiReady, platformReadiness.missingKeys.ai, platformReadiness.noKeyReader);
100
+ if (aiOrganisation.step)
101
+ plannedSteps.push(aiOrganisation.step);
102
+ const aiBudget = planAiBudget(org, byKey, claimed, platformReadiness.aiReady, platformReadiness.missingKeys.ai, platformReadiness.noKeyReader);
103
+ if (aiBudget.step)
104
+ plannedSteps.push(aiBudget.step);
105
+ const aiKey = planAiKey(org, recordedIds, platformReadiness.aiReady);
106
+ if (aiKey.step)
107
+ plannedSteps.push(aiKey.step);
87
108
  // Before `planSite`'s own step (#139): `cmsSite`'s executor reads this
88
109
  // step's recorded client id, the same way `coolifyApp` runs after the
89
110
  // hosted app's own `zitadel:app` steps.
@@ -134,6 +155,22 @@ export function computePlanDetail(org, live, recordedIds = {}) {
134
155
  const customers = planCustomers(org, live, claimed, recordedIds);
135
156
  if (customers)
136
157
  plannedSteps.push(customers);
158
+ // #164: every org's own Agora -- always on, no toggle. Last, so nothing
159
+ // else in the org waits on it: `runSteps` stops at the first failing step.
160
+ // Its fixed ZITADEL client before the Coolify app it signs into:
161
+ // `coolifyAgora` reads this step's recorded client id.
162
+ const agoraClient = planAgoraClient(org, byKey, claimed);
163
+ if (agoraClient)
164
+ plannedSteps.push(agoraClient);
165
+ const agora = planAgora(org, byKey, claimed, recordedIds, agoraHost);
166
+ if (agora.step)
167
+ plannedSteps.push(agora.step);
168
+ // #173: register the org's own app with the platform's jobs scheduler.
169
+ // After Agora, so a failing registration can never stop Agora from
170
+ // provisioning (`runSteps` stops at the first failing step).
171
+ const jobsRegistration = planJobsRegistration(org, recordedIds, jobsAvailable || platformReadiness.jobsReady, platformReadiness.missingKeys.jobs, platformReadiness.noKeyReader);
172
+ if (jobsRegistration.step)
173
+ plannedSteps.push(jobsRegistration.step);
137
174
  // #10 (resolved): "foundry manages only what the declared org names";
138
175
  // anything left unclaimed is listed, never proposed for deletion.
139
176
  const unmanaged = live
@@ -141,6 +178,46 @@ export function computePlanDetail(org, live, recordedIds = {}) {
141
178
  .map((r) => ({
142
179
  description: `${r.system} ${r.kind} "${r.key}" is live but not named by the declared org`,
143
180
  }));
181
+ // A blocked Agora is reported here, not planned: a step the executor must
182
+ // refuse would halt every step after it. This is the plan's one non-step
183
+ // item list, so it rides along with the unmanaged items.
184
+ if (agora.blocked)
185
+ unmanaged.push({ description: agora.blocked, key: 'coolify:agora' });
186
+ // Same for an ai key minted but never delivered: planning a new mint would
187
+ // orphan a second live key.
188
+ if (aiKey.blocked)
189
+ unmanaged.push({ description: aiKey.blocked, key: 'ai:key' });
190
+ // Same for a jobs registration that cannot run (no scheduler wired) or must
191
+ // not run again (minted, never delivered).
192
+ if (jobsRegistration.blocked) {
193
+ unmanaged.push({ description: jobsRegistration.blocked, key: 'jobs:registration' });
194
+ }
195
+ // #177: an org step whose own shared platform service is off or not yet
196
+ // applied is blocked here, not planned -- the same rule, now driven by
197
+ // `platformReadiness` instead of a host-supplied port. Each is reported at
198
+ // most once even though a dependent step (owner, calls-service, storage,
199
+ // budget, key) would otherwise also have nothing to act on.
200
+ if (issuer.blocked)
201
+ unmanaged.push({ description: issuer.blocked, key: 'zitadel:issuer' });
202
+ if (mail.blocked)
203
+ unmanaged.push({ description: mail.blocked, key: 'coolify:mail' });
204
+ // #162: reported separately from `mail.blocked` -- missing an owner
205
+ // mailbox address never blocks the rest of this org's mail.
206
+ if (mail.mailboxBlocked) {
207
+ unmanaged.push({ description: mail.mailboxBlocked, key: 'coolify:mail:owner-mailbox' });
208
+ }
209
+ if (calls.blocked)
210
+ unmanaged.push({ description: calls.blocked, key: 'coolify:calls' });
211
+ if (filesOrganisation.blocked) {
212
+ unmanaged.push({ description: filesOrganisation.blocked, key: 'files:organisation' });
213
+ }
214
+ if (aiOrganisation.blocked) {
215
+ unmanaged.push({ description: aiOrganisation.blocked, key: 'ai:organisation' });
216
+ }
217
+ if (storage.blocked)
218
+ unmanaged.push({ description: storage.blocked, key: 'files:storage' });
219
+ if (aiBudget.blocked)
220
+ unmanaged.push({ description: aiBudget.blocked, key: 'ai:budget' });
144
221
  return { orgId: org.id, plannedSteps, unmanaged };
145
222
  }
146
223
  /**
@@ -158,48 +235,67 @@ export function computePlanDetail(org, live, recordedIds = {}) {
158
235
  * (#7 corrections 2) and is not built in v1 (decisions file, same section:
159
236
  * "domain verification and passkey invalidation need their own design"), so
160
237
  * `executor.ts` throws `NotBuiltError` on it rather than run it.
238
+ *
239
+ * #177: every org's ZITADEL org lives inside the platform's own shared
240
+ * sign-in (#168), so a first-ever provision (no live issuer yet) is blocked,
241
+ * not planned, while `platformReadiness.authReady` is false -- the same rule
242
+ * every platform-dependent org step in this file now follows. An issuer that
243
+ * already exists live is unaffected: its own domain-move logic runs
244
+ * regardless, since that never re-provisions the ZITADEL org itself.
161
245
  */
162
- function planIssuer(org, byKey, claimed) {
246
+ function planIssuer(org, byKey, claimed, authReady) {
163
247
  const key = resourceKey('zitadel', 'issuer', org.id);
164
248
  const live = byKey.get(key);
165
249
  const desiredUsesDomain = org.issuer.usesDomain;
166
250
  if (!live) {
251
+ if (!authReady) {
252
+ return {
253
+ step: null,
254
+ blocked: `${org.slug}'s ZITADEL org is blocked, not planned: the platform's sign-in is off or not yet applied`,
255
+ };
256
+ }
167
257
  return {
168
- action: {
169
- system: 'zitadel',
170
- kind: 'issuer',
171
- key: org.id,
172
- desired: { provision: true, usesDomain: desiredUsesDomain },
173
- },
174
258
  step: {
175
- description: `provision ${org.slug}'s own ZITADEL org`,
176
- destroys: false,
177
- migrates: false,
259
+ action: {
260
+ system: 'zitadel',
261
+ kind: 'issuer',
262
+ key: org.id,
263
+ desired: { provision: true, usesDomain: desiredUsesDomain },
264
+ },
265
+ step: {
266
+ description: `provision ${org.slug}'s own ZITADEL org`,
267
+ destroys: false,
268
+ migrates: false,
269
+ },
178
270
  },
271
+ blocked: null,
179
272
  };
180
273
  }
181
274
  claimed.add(key);
182
275
  const liveUsesDomain = Boolean(live.config.usesDomain);
183
276
  if (liveUsesDomain === desiredUsesDomain)
184
- return null;
277
+ return { step: null, blocked: null };
185
278
  const domain = org.domain ?? 'the org domain';
186
279
  return {
187
- action: {
188
- system: 'zitadel',
189
- kind: 'issuer',
190
- key: org.id,
191
- desired: { usesDomain: desiredUsesDomain },
192
- },
193
280
  step: {
194
- description: desiredUsesDomain
195
- ? `move the issuer onto ${domain}, invalidating every passkey in the org`
196
- : `move the issuer off ${domain} back onto the shared issuer, invalidating every passkey in the org`,
197
- // Any issuer move invalidates every passkey in the org (#7 corrections
198
- // 2, #19 "Done when"). It never moves data between systems, so it is
199
- // never `migrates`.
200
- destroys: true,
201
- migrates: false,
281
+ action: {
282
+ system: 'zitadel',
283
+ kind: 'issuer',
284
+ key: org.id,
285
+ desired: { usesDomain: desiredUsesDomain },
286
+ },
287
+ step: {
288
+ description: desiredUsesDomain
289
+ ? `move the issuer onto ${domain}, invalidating every passkey in the org`
290
+ : `move the issuer off ${domain} back onto the shared issuer, invalidating every passkey in the org`,
291
+ // Any issuer move invalidates every passkey in the org (#7 corrections
292
+ // 2, #19 "Done when"). It never moves data between systems, so it is
293
+ // never `migrates`.
294
+ destroys: true,
295
+ migrates: false,
296
+ },
202
297
  },
298
+ blocked: null,
203
299
  };
204
300
  }
205
301
  /**
@@ -230,12 +326,17 @@ function planIssuer(org, byKey, claimed) {
230
326
  * nothing here puts it anywhere beyond what audit already records for the
231
327
  * org.
232
328
  */
233
- function planOwner(org, recordedIds) {
329
+ function planOwner(org, recordedIds, authReady) {
234
330
  if (!org.owner)
235
331
  return null;
236
332
  const key = resourceKey('zitadel', 'owner', org.id);
237
333
  if (recordedIds[key])
238
334
  return null; // already invited -- a re-run plans nothing.
335
+ // #177: nothing to invite into yet -- `planIssuer` already reports the
336
+ // platform's sign-in as blocked, so this step stays silent rather than
337
+ // repeating it.
338
+ if (!authReady)
339
+ return null;
239
340
  return {
240
341
  action: {
241
342
  system: 'zitadel',
@@ -254,7 +355,31 @@ function planOwner(org, recordedIds) {
254
355
  },
255
356
  };
256
357
  }
257
- function planMail(org, byKey, claimed) {
358
+ /**
359
+ * #162: the flat scope id `executor.ts`'s `ensureOwnerMailbox` records once
360
+ * it has given the org's owner a mailbox (`MAIL_OWNER_MAILBOX_SCOPE_KEY`
361
+ * there) — restated here rather than imported, the same rule this file's own
362
+ * `LOG_DIR` precedent follows for a value shared with `executor.ts`.
363
+ */
364
+ const MAIL_OWNER_MAILBOX_SCOPE_KEY = 'mail_owner_mailbox';
365
+ /** #230: restated from `executor.ts`'s `MAIL_ROLE_MAILBOXES_SCOPE_KEY`, same rule: comma-joined local parts already created. */
366
+ const MAIL_ROLE_MAILBOXES_SCOPE_KEY = 'mail_role_mailboxes';
367
+ /** Restated from `executor.ts`, the same rule: the mail domain the last apply resolved. */
368
+ const MAIL_DOMAIN_SCOPE_KEY = 'mail_domain';
369
+ /** Restated from `executor.ts`, the same rule: the id of an ai key minted but not delivered. */
370
+ export const AI_KEY_UNDELIVERED_SCOPE_KEY = 'ai_key_undelivered';
371
+ /** Restated from `executor.ts`, the same rule: the id of a jobs project registered but not delivered. */
372
+ export const JOBS_REGISTRATION_UNDELIVERED_SCOPE_KEY = 'jobs_registration_undelivered';
373
+ /**
374
+ * #177: `mailHost` is `platformReadiness.mailHost` -- the platform's own
375
+ * `apex`/`mail.hostname` once its mail server is enabled and applied, `null`
376
+ * otherwise. `executor.ts`'s `coolifyMail` reads `apex`/`mailHost` straight
377
+ * off this step's own `desired`, the same "the executor writes exactly these
378
+ * host values, so what the plan showed is what gets applied" rule
379
+ * `planAgora` already follows for its own host values -- never rebuilding
380
+ * them itself from a literal or a second lookup.
381
+ */
382
+ function planMail(org, byKey, claimed, recordedIds, mailHost) {
258
383
  const key = resourceKey('coolify', 'mail', org.id);
259
384
  const live = byKey.get(key);
260
385
  // `off` means mail is not foundry's concern here (org.ts's MailMode doc);
@@ -264,44 +389,159 @@ function planMail(org, byKey, claimed) {
264
389
  // after having it would silently hide its own leftover mail resource from
265
390
  // the unmanaged list.
266
391
  if (org.mail.mode === 'off')
267
- return null;
392
+ return { step: null, blocked: null, mailboxBlocked: null };
268
393
  if (live)
269
394
  claimed.add(key);
270
- const desired = { mode: org.mail.mode, usesDomain: org.mail.usesDomain };
395
+ if (!mailHost) {
396
+ return {
397
+ step: null,
398
+ blocked: `${org.slug}'s mail is blocked, not planned: the platform's mail server is off or not yet applied`,
399
+ mailboxBlocked: null,
400
+ };
401
+ }
402
+ const desired = {
403
+ mode: org.mail.mode,
404
+ usesDomain: org.mail.usesDomain,
405
+ apex: mailHost.apex,
406
+ mailHost: mailHost.mailHost,
407
+ };
408
+ // #162 (decided 2026-09-29): an owner's mailbox is minted inside the same
409
+ // `coolify:mail` step, after `applyOrgMail` -- so as long as one is still
410
+ // owed, this step must keep showing up even once mode/usesDomain
411
+ // themselves have nothing left to change, or the operator would apply and
412
+ // never see it happen. Nothing is guessed any more: without a declared
413
+ // `mail.ownerMailboxAddress`, the mailbox alone is reported blocked below
414
+ // (`mailboxBlocked`), the same "blocked, not planned" pattern a blocked
415
+ // Agora already follows (PR 180) -- the rest of this step, and every
416
+ // other step in the org's plan, is unaffected.
417
+ const mailboxAlreadyGiven = Boolean(recordedIds[MAIL_OWNER_MAILBOX_SCOPE_KEY]);
418
+ const hasOwner = Boolean(org.owner);
419
+ const ownerMailboxAddress = org.mail.ownerMailboxAddress ?? null;
420
+ const mailboxBlocked = hasOwner && !mailboxAlreadyGiven && !ownerMailboxAddress
421
+ ? `${org.slug}'s owner mailbox is blocked, not planned: mail.ownerMailboxAddress is not declared`
422
+ : null;
423
+ const mailboxOwed = hasOwner && !mailboxAlreadyGiven && ownerMailboxAddress !== null;
424
+ // #230: declared role mailboxes not yet recorded as created. Names only
425
+ // ever count here, never appear in a description, same as the owner's address.
426
+ // Recorded per address, so a changed mail domain owes its own set. A hosted
427
+ // org's domain is only known once recorded; until then every role is owed.
428
+ const createdRoles = new Set((recordedIds[MAIL_ROLE_MAILBOXES_SCOPE_KEY] ?? '').split(','));
429
+ const roleDomain = (org.mail.usesDomain && org.domain) || recordedIds[MAIL_DOMAIN_SCOPE_KEY];
430
+ const rolesOwed = (org.mail.roleMailboxes ?? []).filter((r) => !roleDomain || !createdRoles.has(`${r}@${roleDomain}`.toLowerCase())).length;
431
+ const ownerNote = mailboxOwed ? `give ${org.slug}'s owner a mailbox` : '';
432
+ const roleNote = rolesOwed > 0 ? `create ${rolesOwed} role mailbox(es) for ${org.slug}` : '';
433
+ const mailboxWork = [ownerNote, roleNote].filter(Boolean).join(' and ');
434
+ const mailboxNote = mailboxWork ? ` and ${mailboxWork}` : '';
271
435
  if (!live) {
272
436
  return {
273
- action: { system: 'coolify', kind: 'mail', key: org.id, desired },
274
437
  step: {
275
- description: `provision mail (${org.mail.mode}) for ${org.slug}`,
276
- destroys: false,
277
- migrates: false,
438
+ action: { system: 'coolify', kind: 'mail', key: org.id, desired },
439
+ step: {
440
+ description: `provision mail (${org.mail.mode}) for ${org.slug}${mailboxNote}`,
441
+ destroys: false,
442
+ migrates: false,
443
+ },
278
444
  },
445
+ blocked: null,
446
+ mailboxBlocked,
279
447
  };
280
448
  }
281
449
  const liveMode = live.config.mode;
282
450
  const liveUsesDomain = Boolean(live.config.usesDomain);
283
- if (liveMode === desired.mode && liveUsesDomain === desired.usesDomain)
284
- return null;
451
+ if (liveMode === desired.mode && liveUsesDomain === desired.usesDomain) {
452
+ // #177: the platform's apex or mail host moved since this step last wrote
453
+ // them (or it never recorded them). Re-applying is idempotent.
454
+ const addressMoved = recordedIds[ORG_MAIL_APPLIED_APEX_SCOPE_KEY] !== mailHost.apex ||
455
+ recordedIds[ORG_MAIL_APPLIED_HOST_SCOPE_KEY] !== mailHost.mailHost;
456
+ if (!mailboxWork && !addressMoved)
457
+ return { step: null, blocked: null, mailboxBlocked };
458
+ return {
459
+ step: {
460
+ action: { system: 'coolify', kind: 'mail', key: org.id, desired },
461
+ step: {
462
+ description: addressMoved
463
+ ? `re-apply ${org.slug}'s mail on the platform's mail host ${mailHost.mailHost}${mailboxNote}`
464
+ : mailboxWork,
465
+ destroys: false,
466
+ migrates: false,
467
+ },
468
+ },
469
+ blocked: null,
470
+ mailboxBlocked,
471
+ };
472
+ }
285
473
  if (liveUsesDomain && !desired.usesDomain) {
286
474
  // #7 corrections 1: parked, not deleted — mailboxes and messages stay —
287
475
  // but still disruptive enough to gate like a destructive step.
288
476
  return {
289
- action: { system: 'coolify', kind: 'mail', key: org.id, desired },
290
477
  step: {
291
- description: `park ${org.slug}'s mail domain (stops receiving; mailboxes and messages are retained, not deleted — #7 corrections 1)`,
292
- destroys: true,
293
- migrates: false,
478
+ action: { system: 'coolify', kind: 'mail', key: org.id, desired },
479
+ step: {
480
+ description: `park ${org.slug}'s mail domain (stops receiving; mailboxes and messages are retained, not deleted — #7 corrections 1)`,
481
+ destroys: true,
482
+ migrates: false,
483
+ },
294
484
  },
485
+ blocked: null,
486
+ mailboxBlocked,
487
+ };
488
+ }
489
+ // #113: moving from `received` to `hosted` is an MX cutover, and foundry
490
+ // never cuts over before an org's old mail has copied into Stalwart --
491
+ // `planMailCopy`'s own step is what runs that copy. Blocked, not planned,
492
+ // the same "withhold the step rather than apply it half-ready" rule
493
+ // `mailboxBlocked` already follows just above.
494
+ if (liveMode === 'received' &&
495
+ desired.mode === 'hosted' &&
496
+ !recordedIds[MAIL_COPY_DONE_SCOPE_KEY]) {
497
+ return {
498
+ step: null,
499
+ blocked: `${org.slug}'s mail cannot move to hosted yet: its old mail is still being copied into Stalwart (coolify:mail-copy, #113)`,
500
+ mailboxBlocked,
295
501
  };
296
502
  }
297
503
  // A mode transition (#7 "Transitions") or turning a domain on is a
298
504
  // one-time copy, never a deletion.
299
505
  return {
300
- action: { system: 'coolify', kind: 'mail', key: org.id, desired },
301
506
  step: {
302
- description: `move ${org.slug}'s mail from ${String(liveMode)} to ${desired.mode}`,
507
+ action: { system: 'coolify', kind: 'mail', key: org.id, desired },
508
+ step: {
509
+ description: `move ${org.slug}'s mail from ${String(liveMode)} to ${desired.mode}${mailboxNote}`,
510
+ destroys: false,
511
+ migrates: true,
512
+ },
513
+ },
514
+ blocked: null,
515
+ mailboxBlocked,
516
+ };
517
+ }
518
+ /**
519
+ * #113: the one-time copy of an org's old mail into Stalwart that must
520
+ * finish before `planMail` allows the `received` -> `hosted` MX cutover
521
+ * above. Planned only for that exact transition -- a fresh org declared
522
+ * `hosted` from the start, or one moving between any other pair of modes,
523
+ * has nothing to migrate from and never sees this step. Never matched
524
+ * against a live resource: like `platform-diff.ts`'s `planSfu`, there is no
525
+ * live state to probe here (`runMailCopyStep`'s own progress is recorded
526
+ * state, not something a reader lists), so this plans again on every apply
527
+ * until {@link MAIL_COPY_DONE_SCOPE_KEY} is recorded.
528
+ */
529
+ function planMailCopy(org, byKey, recordedIds) {
530
+ if (org.mail.mode !== 'hosted')
531
+ return null;
532
+ if (recordedIds[MAIL_COPY_DONE_SCOPE_KEY])
533
+ return null;
534
+ const live = byKey.get(resourceKey('coolify', 'mail', org.id));
535
+ if (!live || live.config.mode !== 'received')
536
+ return null;
537
+ const completed = parseMailCopyCompleted(recordedIds[MAIL_COPY_COMPLETED_SCOPE_KEY]);
538
+ const progress = completed.length > 0 ? ` (${completed.length} account(s) already copied)` : '';
539
+ return {
540
+ action: { system: 'coolify', kind: 'mail-copy', key: org.id, desired: {} },
541
+ step: {
542
+ 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`,
303
543
  destroys: false,
304
- migrates: true,
544
+ migrates: false,
305
545
  },
306
546
  };
307
547
  }
@@ -364,9 +604,26 @@ function planChat(org, byKey, claimed) {
364
604
  * {@link planApp}/{@link planDatabase}/{@link planTunnel} below
365
605
  * (`planHostingToggle`, #7 corrections 1) -- turning it off parks Synapse and
366
606
  * MAS, never either database (`executor.ts`'s `coolifyCalls`).
607
+ *
608
+ * #177: a first-ever provision (no live resource yet) is blocked, not
609
+ * planned, while `platformReadiness.callsReady` is false -- an org's own
610
+ * Matrix stack is wired to the shared calls SFU (#122/#127), so there is
611
+ * nothing for it to authenticate calls against until the platform's own
612
+ * `sfu:livekit` step is declared. Parking, and an already-provisioned stack,
613
+ * are unaffected.
367
614
  */
368
- function planCalls(org, byKey, claimed) {
369
- return planHostingToggle('coolify', 'calls', org.calls.enabled, org, byKey, claimed, () => `provision ${org.slug}'s calls Matrix stack (Synapse + matrix-authentication-service)`, () => `park ${org.slug}'s calls Matrix stack (stops serving; data is retained, not deleted -- #7 corrections 1)`);
615
+ function planCalls(org, byKey, claimed, callsReady) {
616
+ const key = resourceKey('coolify', 'calls', org.id);
617
+ if (org.calls.enabled && !byKey.get(key) && !callsReady) {
618
+ return {
619
+ step: null,
620
+ blocked: `${org.slug}'s calls Matrix stack is blocked, not planned: the platform's shared calls SFU is off`,
621
+ };
622
+ }
623
+ return {
624
+ step: planHostingToggle('coolify', 'calls', org.calls.enabled, org, byKey, claimed, () => `provision ${org.slug}'s calls Matrix stack (Synapse + matrix-authentication-service)`, () => `park ${org.slug}'s calls Matrix stack (stops serving; data is retained, not deleted -- #7 corrections 1)`),
625
+ blocked: null,
626
+ };
370
627
  }
371
628
  /**
372
629
  * The org's own calls service (#145, part 2 of #128) -- `wtfalch/calls`'s
@@ -379,9 +636,20 @@ function planCalls(org, byKey, claimed) {
379
636
  * planner steps. Same on/off, park-not-delete shape as {@link planCalls}
380
637
  * (`planHostingToggle`, #7 corrections 1) -- turning it off parks the app,
381
638
  * never its database (`executor.ts`'s `coolifyCallsService`).
639
+ *
640
+ * #177: silently plans nothing (no second blocked message) while the Matrix
641
+ * stack it rides beside is itself blocked on `callsReady` -- {@link planCalls}
642
+ * already reports the platform's shared SFU as missing once.
382
643
  */
383
- function planCallsService(org, byKey, claimed) {
384
- return planHostingToggle('coolify', 'calls-service', org.calls.enabled, org, byKey, claimed, () => `provision ${org.slug}'s calls service`, () => `park ${org.slug}'s calls service (stops serving; data is retained, not deleted -- #7 corrections 1)`);
644
+ function planCallsService(org, byKey, claimed, callsReady) {
645
+ const key = resourceKey('coolify', 'calls-service', org.id);
646
+ if (org.calls.enabled && !byKey.get(key) && !callsReady) {
647
+ return { step: null, blocked: null };
648
+ }
649
+ return {
650
+ step: planHostingToggle('coolify', 'calls-service', org.calls.enabled, org, byKey, claimed, () => `provision ${org.slug}'s calls service`, () => `park ${org.slug}'s calls service (stops serving; data is retained, not deleted -- #7 corrections 1)`),
651
+ blocked: null,
652
+ };
385
653
  }
386
654
  /**
387
655
  * Deduped `ai.*` permissions offered to every organisation by default
@@ -394,6 +662,19 @@ function planCallsService(org, byKey, claimed) {
394
662
  * explicit opt-in this default deliberately leaves out.
395
663
  */
396
664
  const AI_DEFAULT_OFFERS = ['ai.offerings:invoke', 'ai.runs:create'];
665
+ /**
666
+ * Why an org step on a shared platform service is blocked: a key that is not
667
+ * held, no held-keys reader given at all, or the service off or not applied.
668
+ * Fixed text (part of the stale-plan comparison); a ref only, never a value.
669
+ */
670
+ function notReadyReason(service, keyName, missingKeyRef, noKeyReader) {
671
+ if (missingKeyRef && noKeyReader) {
672
+ return `the host gave no held-keys reader and no ${service} port, so the platform's ${keyName} ${missingKeyRef} cannot be checked`;
673
+ }
674
+ return missingKeyRef
675
+ ? `the platform's ${keyName} ${missingKeyRef} is not held yet`
676
+ : `the platform's ${service} host is off or not yet applied`;
677
+ }
397
678
  /**
398
679
  * #80: every org's own organisation on Files, a per-org singleton resource
399
680
  * planned before {@link planStorage} -- `setQuota`/`setBudget` 404 against an
@@ -407,26 +688,39 @@ const AI_DEFAULT_OFFERS = ['ai.offerings:invoke', 'ai.runs:create'];
407
688
  * concern (`org.ts`'s `StorageConfig` doc: "a separate, operator-driven hard
408
689
  * cap this field has no opinion on"), so provisioning always asks for an
409
690
  * uncapped ceiling -- Decision, this campaign's report.
691
+ *
692
+ * #177: blocked, not planned, while `filesReady` is false -- the platform's
693
+ * own files host (#171) is off or not yet applied, so there is nothing here
694
+ * for `FilesOrganisationPort.provisionOrganisation` to reach.
410
695
  */
411
- function planFilesOrganisation(org, byKey, claimed) {
696
+ function planFilesOrganisation(org, byKey, claimed, filesReady, missingKeyRef, noKeyReader = false) {
412
697
  const key = resourceKey('files', 'organisation', org.id);
413
698
  const live = byKey.get(key);
414
699
  if (live) {
415
700
  claimed.add(key);
416
- return null;
701
+ return { step: null, blocked: null };
702
+ }
703
+ if (!filesReady) {
704
+ return {
705
+ step: null,
706
+ blocked: `${org.slug}'s Files organisation is blocked, not planned: ${notReadyReason('files', 'files management key', missingKeyRef, noKeyReader)}`,
707
+ };
417
708
  }
418
709
  return {
419
- action: {
420
- system: 'files',
421
- kind: 'organisation',
422
- key: org.id,
423
- desired: { provision: true, ceilingBytes: null, ceilingMicros: null },
424
- },
425
710
  step: {
426
- description: `provision ${org.slug}'s own organisation on Files`,
427
- destroys: false,
428
- migrates: false,
711
+ action: {
712
+ system: 'files',
713
+ kind: 'organisation',
714
+ key: org.id,
715
+ desired: { provision: true, ceilingBytes: null, ceilingMicros: null },
716
+ },
717
+ step: {
718
+ description: `provision ${org.slug}'s own organisation on Files`,
719
+ destroys: false,
720
+ migrates: false,
721
+ },
429
722
  },
723
+ blocked: null,
430
724
  };
431
725
  }
432
726
  /**
@@ -443,30 +737,42 @@ function planFilesOrganisation(org, byKey, claimed) {
443
737
  * first budget row with whatever the org already declares (including
444
738
  * `null` for uncapped), so a brand new org never needs a follow-up
445
739
  * {@link planAiBudget} step just to reach its own declared default.
740
+ *
741
+ * #177: blocked, not planned, while `aiReady` is false -- the platform's own
742
+ * AI host (#172) is off or not yet applied.
446
743
  */
447
- function planAiOrganisation(org, byKey, claimed) {
744
+ function planAiOrganisation(org, byKey, claimed, aiReady, missingKeyRef, noKeyReader = false) {
448
745
  const key = resourceKey('ai', 'organisation', org.id);
449
746
  const live = byKey.get(key);
450
747
  if (live) {
451
748
  claimed.add(key);
452
- return null;
749
+ return { step: null, blocked: null };
750
+ }
751
+ if (!aiReady) {
752
+ return {
753
+ step: null,
754
+ blocked: `${org.slug}'s AI organisation is blocked, not planned: ${notReadyReason('AI', 'AI management key', missingKeyRef, noKeyReader)}`,
755
+ };
453
756
  }
454
757
  return {
455
- action: {
456
- system: 'ai',
457
- kind: 'organisation',
458
- key: org.id,
459
- desired: {
460
- provision: true,
461
- limitMicros: org.ai.limitMicros,
462
- offers: [...AI_DEFAULT_OFFERS],
463
- },
464
- },
465
758
  step: {
466
- description: `provision ${org.slug}'s own organisation on AI and switch AI use on`,
467
- destroys: false,
468
- migrates: false,
759
+ action: {
760
+ system: 'ai',
761
+ kind: 'organisation',
762
+ key: org.id,
763
+ desired: {
764
+ provision: true,
765
+ limitMicros: org.ai.limitMicros,
766
+ offers: [...AI_DEFAULT_OFFERS],
767
+ },
768
+ },
769
+ step: {
770
+ description: `provision ${org.slug}'s own organisation on AI and switch AI use on`,
771
+ destroys: false,
772
+ migrates: false,
773
+ },
469
774
  },
775
+ blocked: null,
470
776
  };
471
777
  }
472
778
  /**
@@ -476,23 +782,40 @@ function planAiOrganisation(org, byKey, claimed) {
476
782
  * reversible"). A missing live resource reads as uncapped (`null`), the same
477
783
  * value a freshly-declared org's `storage` starts at, so a brand new org with
478
784
  * no limits declared produces no step.
785
+ *
786
+ * #177: silently plans nothing while there is no organisation to size a limit
787
+ * on yet and `filesReady` is false -- {@link planFilesOrganisation} already
788
+ * reports the platform's files host as missing once.
479
789
  */
480
- function planStorage(org, byKey, claimed) {
790
+ function planStorage(org, byKey, claimed, filesReady, missingKeyRef, noKeyReader = false) {
481
791
  const key = resourceKey('files', 'storage', org.id);
482
792
  const live = byKey.get(key);
483
793
  if (live)
484
794
  claimed.add(key);
795
+ if (!filesReady && !live)
796
+ return { step: null, blocked: null };
485
797
  const desired = { limitBytes: org.storage.limitBytes, limitMicros: org.storage.limitMicros };
486
798
  const liveLimitBytes = live ? (live.config.limitBytes ?? null) : null;
487
799
  const liveLimitMicros = live ? (live.config.limitMicros ?? null) : null;
488
- if (liveLimitBytes === desired.limitBytes && liveLimitMicros === desired.limitMicros)
489
- return null;
800
+ if (liveLimitBytes === desired.limitBytes && liveLimitMicros === desired.limitMicros) {
801
+ return { step: null, blocked: null };
802
+ }
803
+ // A live resource with drift needs the platform's key to change; blocked, not planned to throw at apply.
804
+ if (!filesReady) {
805
+ return {
806
+ step: null,
807
+ blocked: `${org.slug}'s storage limit change is blocked, not planned: ${notReadyReason('files', 'files management key', missingKeyRef, noKeyReader)}`,
808
+ };
809
+ }
490
810
  return {
491
- action: { system: 'files', kind: 'storage', key: org.id, desired },
811
+ blocked: null,
492
812
  step: {
493
- description: `set ${org.slug}'s storage limit to ${desired.limitBytes ?? 'uncapped'} bytes / ${desired.limitMicros ?? 'uncapped'} micros`,
494
- destroys: false,
495
- migrates: false,
813
+ action: { system: 'files', kind: 'storage', key: org.id, desired },
814
+ step: {
815
+ description: `set ${org.slug}'s storage limit to ${desired.limitBytes ?? 'uncapped'} bytes / ${desired.limitMicros ?? 'uncapped'} micros`,
816
+ destroys: false,
817
+ migrates: false,
818
+ },
496
819
  },
497
820
  };
498
821
  }
@@ -502,22 +825,93 @@ function planStorage(org, byKey, claimed) {
502
825
  * (#80), which already seeds a brand new org's first budget row from this
503
826
  * same `org.ai.limitMicros` -- this planner only fires again once that row's
504
827
  * live value has drifted from what is declared.
828
+ *
829
+ * #177: silently plans nothing while there is no organisation to size a
830
+ * budget on yet and `aiReady` is false -- {@link planAiOrganisation} already
831
+ * reports the platform's AI host as missing once.
505
832
  */
506
- function planAiBudget(org, byKey, claimed) {
833
+ function planAiBudget(org, byKey, claimed, aiReady, missingKeyRef, noKeyReader = false) {
507
834
  const key = resourceKey('ai', 'budget', org.id);
508
835
  const live = byKey.get(key);
509
836
  if (live)
510
837
  claimed.add(key);
838
+ if (!aiReady && !live)
839
+ return { step: null, blocked: null };
511
840
  const desired = { limitMicros: org.ai.limitMicros };
512
841
  const liveLimitMicros = live ? (live.config.limitMicros ?? null) : null;
513
842
  if (liveLimitMicros === desired.limitMicros)
514
- return null;
843
+ return { step: null, blocked: null };
844
+ if (!aiReady) {
845
+ return {
846
+ step: null,
847
+ blocked: `${org.slug}'s AI budget change is blocked, not planned: ${notReadyReason('AI', 'AI management key', missingKeyRef, noKeyReader)}`,
848
+ };
849
+ }
515
850
  return {
516
- action: { system: 'ai', kind: 'budget', key: org.id, desired },
851
+ blocked: null,
517
852
  step: {
518
- description: `set ${org.slug}'s AI budget limit to ${desired.limitMicros ?? 'uncapped'} micros`,
519
- destroys: false,
520
- migrates: false,
853
+ action: { system: 'ai', kind: 'budget', key: org.id, desired },
854
+ step: {
855
+ description: `set ${org.slug}'s AI budget limit to ${desired.limitMicros ?? 'uncapped'} micros`,
856
+ destroys: false,
857
+ migrates: false,
858
+ },
859
+ },
860
+ };
861
+ }
862
+ /**
863
+ * #80 step 3: the org's own ai key, minted once its organisation is
864
+ * provisioned and offered ({@link planAiOrganisation}). Idempotent on the
865
+ * *recorded* resource id (`recordedIds['ai:key:<orgId>']`), the same shape
866
+ * {@link planOwner} uses and for the same reason: ai's `issueOrganisationKey`
867
+ * (`wtfalch/ai#108`/`#113`) shows a key's secret exactly once and offers no
868
+ * way to list or look up an already-minted key by name, so there is nothing
869
+ * in `live` an existing key could ever appear as. `permissions` mirrors
870
+ * {@link AI_DEFAULT_OFFERS} -- the key can only do what the org's own offers
871
+ * already allow, so the two only ever widen together.
872
+ *
873
+ * Not built here: rotating the key before its `expiresDays` runs out. #80
874
+ * only asks for "give every org... a working ai key"; renewal is its own
875
+ * follow-up once a real org has carried one for a while.
876
+ *
877
+ * #177: silently plans nothing while `aiReady` is false -- there is no
878
+ * organisation to mint a key inside yet, and {@link planAiOrganisation}
879
+ * already reports the platform's AI host as missing once.
880
+ */
881
+ function planAiKey(org, recordedIds, aiReady) {
882
+ const key = resourceKey('ai', 'key', org.id);
883
+ if (recordedIds[key])
884
+ return { step: null, blocked: null }; // delivered -- a re-run plans nothing.
885
+ if (!aiReady)
886
+ return { step: null, blocked: null };
887
+ // Minted but never delivered (`executor.ts`'s `aiKey`): nobody holds the
888
+ // secret and ai has no per-key revoke yet, so a new mint would only add
889
+ // another orphan. Reported, not planned.
890
+ const undelivered = recordedIds[AI_KEY_UNDELIVERED_SCOPE_KEY];
891
+ if (undelivered) {
892
+ return {
893
+ step: null,
894
+ blocked: `${org.slug}'s ai key ${undelivered} was minted but not delivered and must be revoked in ai before a new one is minted (https://github.com/wtfalch/ai/issues/138)`,
895
+ };
896
+ }
897
+ return {
898
+ blocked: null,
899
+ step: {
900
+ action: {
901
+ system: 'ai',
902
+ kind: 'key',
903
+ key: org.id,
904
+ desired: {
905
+ name: `${org.slug}-app`,
906
+ expiresDays: 365,
907
+ permissions: [...AI_DEFAULT_OFFERS],
908
+ },
909
+ },
910
+ step: {
911
+ description: `mint ${org.slug}'s own ai key`,
912
+ destroys: false,
913
+ migrates: false,
914
+ },
521
915
  },
522
916
  };
523
917
  }
@@ -624,6 +1018,129 @@ function planSite(org, byKey, claimed) {
624
1018
  },
625
1019
  };
626
1020
  }
1021
+ /**
1022
+ * https://github.com/wtfalch/foundry/issues/164 (per William,
1023
+ * https://github.com/wtfalch/foundry/issues/161#issuecomment-5890087180:
1024
+ * "every company gets its own Agora, always on"): every org gets one fixed
1025
+ * ZITADEL web client named `agora`, so its Agora workspace
1026
+ * (https://github.com/wtfalch/agora) can sign anyone in -- automatically,
1027
+ * with no new field on `DeclaredOrg` (unlike {@link planCmsClient}'s own
1028
+ * `cms` client, which only exists once `org.site.enabled` is true). Modeled
1029
+ * on {@link planIssuerApps}'s/{@link planCmsClient}'s single-app shape:
1030
+ * provision when missing, recreate when the live `appType` differs, nothing
1031
+ * otherwise. There is no park for this client either, for the same reason
1032
+ * `planIssuerApps`'s own doc comment gives -- ZITADEL has no notion of a
1033
+ * disabled-but-retained OIDC client.
1034
+ */
1035
+ function planAgoraClient(org, byKey, claimed) {
1036
+ const key = resourceKey('zitadel', 'app', 'agora');
1037
+ const live = byKey.get(key);
1038
+ if (live)
1039
+ claimed.add(key);
1040
+ const desired = { appType: 'web' };
1041
+ if (!live) {
1042
+ return {
1043
+ action: { system: 'zitadel', kind: 'app', key: 'agora', desired },
1044
+ step: {
1045
+ description: `provision the web ZITADEL app 'agora' for ${org.slug}'s Agora sign-in`,
1046
+ destroys: false,
1047
+ migrates: false,
1048
+ },
1049
+ };
1050
+ }
1051
+ if (live.config.appType === 'web')
1052
+ return null;
1053
+ return {
1054
+ action: { system: 'zitadel', kind: 'app', key: 'agora', desired },
1055
+ step: {
1056
+ description: `recreate ZITADEL app 'agora' for ${org.slug} as web (was ${String(live.config.appType)})`,
1057
+ destroys: true,
1058
+ migrates: false,
1059
+ },
1060
+ };
1061
+ }
1062
+ /**
1063
+ * #164: the org's own Agora, a per-org singleton resource keyed by the org
1064
+ * id like {@link planMail}/{@link planSite}, but under its own `agora` kind
1065
+ * rather than `app` -- it is a separate Coolify application from the org's
1066
+ * own hosted app ({@link planApp}, `org.hosting.enabled`), provisioned from
1067
+ * https://github.com/wtfalch/agora's `apps/agora/Dockerfile`
1068
+ * (`executor.ts`'s `coolifyAgora`), never toggled by any declared field.
1069
+ *
1070
+ * Once the app is live this still plans an update whenever the live app's
1071
+ * Agora env (`config.env`, read by `providers-readers.ts`) lacks a desired
1072
+ * key or holds a different value, using `agora.ts`'s `desiredAgoraEnv`, the
1073
+ * same function the executor writes from. There is no park branch: foundry
1074
+ * never deletes an Agora.
1075
+ *
1076
+ * The host supplies the issuer and the mail/messaging project ids
1077
+ * (`agora.ts`'s `AgoraHostPort`). While it lacks any (or has no port, or the
1078
+ * port throws), no step is planned at all: the blocked Agora comes back as a
1079
+ * message naming each missing value, which `computePlanDetail` lists with the
1080
+ * plan's non-step items, so every other step still applies.
1081
+ */
1082
+ function planAgora(org, byKey, claimed, recordedIds, reading) {
1083
+ const key = resourceKey('coolify', 'agora', org.id);
1084
+ const live = byKey.get(key);
1085
+ if (live)
1086
+ claimed.add(key);
1087
+ const error = 'error' in reading ? reading.error : null;
1088
+ const host = 'error' in reading ? NO_AGORA_HOST_VALUES : reading;
1089
+ const missing = agoraHostMissing(host);
1090
+ if (missing.length > 0) {
1091
+ // A fixed text, never the port's own message: this line is part of the
1092
+ // stale-plan comparison, and an error that varies per call would refuse
1093
+ // every apply as stale.
1094
+ const why = error ? ' (reading them failed)' : '';
1095
+ return {
1096
+ step: null,
1097
+ blocked: `${org.slug}'s Agora is blocked, not planned: the host supplies no ${missing.join(', ')}${why}`,
1098
+ };
1099
+ }
1100
+ // The executor writes exactly these host values, so what the plan showed
1101
+ // is what gets applied; the ids only the apply itself records are read at
1102
+ // run time.
1103
+ const action = {
1104
+ system: 'coolify',
1105
+ kind: 'agora',
1106
+ key: org.id,
1107
+ desired: { host },
1108
+ };
1109
+ if (!live) {
1110
+ return {
1111
+ step: {
1112
+ action,
1113
+ step: {
1114
+ description: `provision ${org.slug}'s Agora`,
1115
+ destroys: false,
1116
+ migrates: false,
1117
+ },
1118
+ },
1119
+ blocked: null,
1120
+ };
1121
+ }
1122
+ const liveEnv = (live.config.env ?? {});
1123
+ const { env } = desiredAgoraEnv(org, recordedIds, host);
1124
+ const drift = agoraEnvDrift(env, liveEnv);
1125
+ // The ZITADEL ids may be unrecorded (so `env` has no AGORA_CONFIG to
1126
+ // compare); a live app with no AGORA_CONFIG at all still needs one.
1127
+ if (liveEnv.AGORA_CONFIG === undefined && !drift.includes('AGORA_CONFIG')) {
1128
+ drift.push('AGORA_CONFIG');
1129
+ }
1130
+ if (drift.length === 0)
1131
+ return { step: null, blocked: null };
1132
+ return {
1133
+ step: {
1134
+ action,
1135
+ step: {
1136
+ description: `update ${org.slug}'s Agora config (${drift.join(', ')} missing or different)`,
1137
+ destroys: false,
1138
+ migrates: false,
1139
+ },
1140
+ },
1141
+ blocked: null,
1142
+ };
1143
+ }
627
1144
  /**
628
1145
  * The shared on/off shape behind {@link planApp}, {@link planDatabase} and
629
1146
  * {@link planTunnel} (#71): a per-org singleton resource, keyed by the org
@@ -704,6 +1221,72 @@ function planApp(org, byKey, claimed) {
704
1221
  },
705
1222
  };
706
1223
  }
1224
+ /**
1225
+ * #173: register the org's own app with the platform's jobs scheduler
1226
+ * (https://github.com/wtfalch/jobs's admin API, jobs#62), so the scheduler
1227
+ * knows where to deliver that org's schedules. Idempotent on the *recorded*
1228
+ * resource id, the same shape {@link planOwner} uses for inviting a human
1229
+ * owner by email: nothing in `live` can report "is this org registered" (no
1230
+ * reader exists for the scheduler's own admin API), and the admin API's own
1231
+ * `POST /admin/projects` is itself idempotent by `(name, baseUrl)` -- a
1232
+ * repeat call returns `200` with just the project id, never a fresh secret --
1233
+ * but this plan-level gate means a re-plan after a successful apply proposes
1234
+ * nothing at all, rather than relying on the executor to notice the repeat
1235
+ * every time.
1236
+ *
1237
+ * Only planned once the org has its own app to register
1238
+ * (`org.hosting.enabled`) -- an org with no hosted app has no `baseUrl` for
1239
+ * the scheduler to deliver to. The hostname is derived the same way
1240
+ * {@link planSite}'s own `hostname` local does: `DeclaredOrg.domain` once
1241
+ * `hosting.usesDomain` is true, `<slug>.oikos.host` otherwise.
1242
+ */
1243
+ function planJobsRegistration(org, recordedIds, jobsAvailable, missingKeyRef = null, noKeyReader = false) {
1244
+ if (!org.hosting.enabled)
1245
+ return { step: null, blocked: null };
1246
+ const key = resourceKey('jobs', 'registration', org.id);
1247
+ if (recordedIds[key])
1248
+ return { step: null, blocked: null }; // already registered -- a re-run plans nothing.
1249
+ // Registered but the credentials never reached the host (`executor.ts`'s
1250
+ // `jobsRegistration`): the scheduler shows them once and a repeat call
1251
+ // returns only the id, so planning the step again would record success with
1252
+ // no sync key. Reported, not planned.
1253
+ const undelivered = recordedIds[JOBS_REGISTRATION_UNDELIVERED_SCOPE_KEY];
1254
+ if (undelivered) {
1255
+ return {
1256
+ step: null,
1257
+ blocked: `${org.slug}'s jobs registration ${undelivered} was registered but its credentials were not delivered; deregister it in the scheduler (DELETE /admin/projects/${org.slug}) and clear the recorded id before registering again`,
1258
+ };
1259
+ }
1260
+ // The host says whether its executor has a scheduler to register with. A
1261
+ // fixed text: it is part of the stale-plan comparison.
1262
+ if (!jobsAvailable) {
1263
+ return {
1264
+ step: null,
1265
+ blocked: !missingKeyRef
1266
+ ? `${org.slug}'s jobs registration is blocked, not planned: the host supplies no jobs scheduler`
1267
+ : `${org.slug}'s jobs registration is blocked, not planned: ${noKeyReader
1268
+ ? `the host gave no held-keys reader and no jobs scheduler port, so the platform's jobs admin token ${missingKeyRef} cannot be checked`
1269
+ : `the platform's jobs admin token ${missingKeyRef} is not held yet`}`,
1270
+ };
1271
+ }
1272
+ const hostname = org.hosting.usesDomain && org.domain ? org.domain : `${org.slug}.oikos.host`;
1273
+ return {
1274
+ blocked: null,
1275
+ step: {
1276
+ action: {
1277
+ system: 'jobs',
1278
+ kind: 'registration',
1279
+ key: org.id,
1280
+ desired: { name: org.slug, baseUrl: `https://${hostname}` },
1281
+ },
1282
+ step: {
1283
+ description: `register ${org.slug}'s app with the jobs scheduler`,
1284
+ destroys: false,
1285
+ migrates: false,
1286
+ },
1287
+ },
1288
+ };
1289
+ }
707
1290
  /**
708
1291
  * A Postgres database beside the org's app (`org.ts`'s
709
1292
  * `HostingConfig.database`, #71) -- `validateHosting` requires
@@ -736,11 +1319,19 @@ function planDatabase(org, byKey, claimed) {
736
1319
  * needs a tunnel too, for the same reason a site does -- an org that wants
737
1320
  * `calls.<domain>` but declares no other own-domain feature must still get
738
1321
  * one provisioned, not silently have nowhere for that hostname to route.
1322
+ *
1323
+ * #164: Agora needs a tunnel for the same reason, the moment `org.domain` is
1324
+ * set. Agora is always on (William, 2026-09-29), so every org with a domain
1325
+ * needs routing for `agora.<domain>` even with hosting and site off -- there
1326
+ * is no `usesDomain` flag of its own to gate this on. A level-1 org (no
1327
+ * domain) plans no per-org tunnel for Agora: `<slug>-agora.oikos.host` rides
1328
+ * the shared `*.oikos.host` wildcard tunnel (`scripts/shared-ingress.mjs`).
739
1329
  */
740
1330
  function planTunnel(org, byKey, claimed) {
741
1331
  const desired = (org.hosting.tunnel && org.hosting.usesDomain && Boolean(org.domain)) ||
742
1332
  (org.site.enabled && Boolean(org.domain)) ||
743
- (org.calls.enabled && org.calls.usesDomain && Boolean(org.domain));
1333
+ (org.calls.enabled && org.calls.usesDomain && Boolean(org.domain)) ||
1334
+ Boolean(org.domain);
744
1335
  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)`);
745
1336
  }
746
1337
  /**
@@ -762,9 +1353,16 @@ function planTunnel(org, byKey, claimed) {
762
1353
  * #145: an own-domain calls service (`org.ts`'s `CallsConfig.usesDomain`)
763
1354
  * needs a zone for the same reason, whether or not the org also has hosting
764
1355
  * or a site on its domain.
1356
+ *
1357
+ * #164: Agora needs a zone for the same reason, the moment `org.domain` is
1358
+ * set -- Agora is always on, so every org with a domain wants its zone even
1359
+ * with hosting and site off (see {@link planTunnel}).
765
1360
  */
766
1361
  function planZone(org, byKey, claimed) {
767
- const usesDomain = org.hosting.usesDomain || org.site.enabled || (org.calls.enabled && org.calls.usesDomain);
1362
+ const usesDomain = org.hosting.usesDomain ||
1363
+ org.site.enabled ||
1364
+ (org.calls.enabled && org.calls.usesDomain) ||
1365
+ Boolean(org.domain);
768
1366
  if (!usesDomain || !org.domain)
769
1367
  return null;
770
1368
  const key = resourceKey('cloudflare', 'zone', org.domain);