@wtfalch/foundry 0.3.0 → 0.5.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 (71) hide show
  1. package/dist/bootstrap/bootstrap.d.ts +96 -0
  2. package/dist/bootstrap/bootstrap.js +285 -0
  3. package/dist/bootstrap/declare.d.ts +36 -0
  4. package/dist/bootstrap/declare.js +94 -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 +9 -6
  10. package/dist/calls-sfu-deploy-support.js +9 -6
  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 +22 -4
  18. package/dist/calls-sfu.js +22 -4
  19. package/dist/index.d.ts +31 -6
  20. package/dist/index.js +16 -3
  21. package/dist/migrations/0014_calls_enabled.sql +7 -0
  22. package/dist/migrations/0015_domain_unique.sql +39 -0
  23. package/dist/migrations/0016_platform.sql +61 -0
  24. package/dist/migrations/0017_requested_changes.sql +42 -0
  25. package/dist/migrations/0018_platform_apply.sql +19 -0
  26. package/dist/org.d.ts +18 -2
  27. package/dist/org.js +56 -0
  28. package/dist/plan/agora.d.ts +77 -0
  29. package/dist/plan/agora.js +127 -0
  30. package/dist/plan/calls-matrix.d.ts +135 -0
  31. package/dist/plan/calls-matrix.js +275 -0
  32. package/dist/plan/cloudflared-connector.d.ts +70 -0
  33. package/dist/plan/cloudflared-connector.js +106 -0
  34. package/dist/plan/diff.d.ts +26 -5
  35. package/dist/plan/diff.js +720 -106
  36. package/dist/plan/executor.d.ts +731 -36
  37. package/dist/plan/executor.js +1180 -41
  38. package/dist/plan/index.d.ts +22 -5
  39. package/dist/plan/index.js +12 -2
  40. package/dist/plan/jobs-scheduler-http.d.ts +29 -0
  41. package/dist/plan/jobs-scheduler-http.js +50 -0
  42. package/dist/plan/live.d.ts +51 -2
  43. package/dist/plan/live.js +8 -0
  44. package/dist/plan/plan-apply.d.ts +192 -3
  45. package/dist/plan/plan-apply.js +290 -14
  46. package/dist/plan/platform-diff.d.ts +312 -0
  47. package/dist/plan/platform-diff.js +867 -0
  48. package/dist/plan/platform-org-ports.d.ts +50 -0
  49. package/dist/plan/platform-org-ports.js +70 -0
  50. package/dist/plan/platform-org.d.ts +120 -0
  51. package/dist/plan/platform-org.js +115 -0
  52. package/dist/plan/platform-providers.d.ts +501 -0
  53. package/dist/plan/platform-providers.js +3029 -0
  54. package/dist/plan/platform-sfu.d.ts +93 -0
  55. package/dist/plan/platform-sfu.js +100 -0
  56. package/dist/plan/providers-readers.js +22 -1
  57. package/dist/plan/zitadel-machine-user.d.ts +34 -0
  58. package/dist/plan/zitadel-machine-user.js +95 -0
  59. package/dist/plan.d.ts +6 -0
  60. package/dist/platform.d.ts +55 -0
  61. package/dist/platform.js +46 -0
  62. package/dist/requested-changes.d.ts +108 -0
  63. package/dist/requested-changes.js +95 -0
  64. package/dist/slug.js +21 -2
  65. package/dist/store/audit.js +4 -2
  66. package/dist/store/postgres.d.ts +40 -7
  67. package/dist/store/postgres.js +257 -29
  68. package/dist/store/schema.d.ts +700 -0
  69. package/dist/store/schema.js +58 -0
  70. package/dist/store.d.ts +56 -0
  71. package/package.json +2 -2
package/dist/plan/diff.js CHANGED
@@ -1,3 +1,6 @@
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';
3
+ import { DEFAULT_ORG_PLATFORM_READINESS } from './platform-org.js';
1
4
  /** The `Plan` (#19's public contract) a `PlanDetail` carries. */
2
5
  export function toPlan(detail) {
3
6
  return {
@@ -34,42 +37,76 @@ function resourceKey(system, kind, key) {
34
37
  * rest: the org's owner, invited by ZITADEL email, idempotent on the
35
38
  * *recorded* resource id (`recordedIds`, sourced from
36
39
  * `AppliedState.serviceIds`) rather than a live resource, since a human user
37
- * is never something `live` can contain. Everything else in `live` that none
38
- * of these twelve singletons and however many declared issuer apps claim is
39
- * unmanaged. The full field-by-field provisioning surface (SSO, guests,
40
- * calls, adopted/bespoke apps, the actual
40
+ * is never something `live` can contain. #128 adds one more singleton, off
41
+ * by default unlike chat's own: {@link planCalls}, the org's own calls
42
+ * Matrix stack (`org.ts`'s `CallsConfig.enabled`). #145 adds one more,
43
+ * planned right after it and gated on the same flag: {@link planCallsService},
44
+ * the calls service itself (`wtfalch/calls`'s `packages/service`). #164 adds
45
+ * two more, unconditionally -- no `DeclaredOrg` field gates either, since
46
+ * every org's own Agora is always on: {@link planAgoraClient}, a fixed
47
+ * ZITADEL web client named `agora` (modeled on {@link planCmsClient}), and
48
+ * {@link planAgora}, the Agora itself as a Coolify app (modeled on
49
+ * {@link planSite}, minus the park branch -- foundry never deletes an
50
+ * Agora). #173 adds one more, gated on `org.hosting.enabled` like
51
+ * {@link planApp} itself: {@link planJobsRegistration}, registering the org's
52
+ * app with the platform's jobs scheduler, idempotent on the *recorded*
53
+ * resource id like {@link planOwner} rather than a live resource, since
54
+ * nothing in `live` can report a scheduler registration (no reader exists for
55
+ * it). Everything
56
+ * else in `live` that none of these eighteen singletons and however many
57
+ * declared issuer apps claim is unmanaged. The full field-by-field provisioning surface (SSO,
58
+ * guests, calls on the org's own domain, adopted/bespoke apps, the actual
41
59
  * Zitadel/Coolify/Cloudflare/Files/AI/cms shapes) is #20's "provision
42
60
  * Company Factory end to end", which wires real readers and executors
43
61
  * against this seam.
44
62
  */
45
- export function computePlanDetail(org, live, recordedIds = {}) {
63
+ export function computePlanDetail(org, live, recordedIds = {}, agoraHost = NO_AGORA_HOST_VALUES, jobsAvailable = false, platformReadiness = DEFAULT_ORG_PLATFORM_READINESS) {
46
64
  const byKey = new Map(live.map((r) => [resourceKey(r.system, r.kind, r.key), r]));
47
65
  const claimed = new Set();
48
66
  const plannedSteps = [];
49
- const issuer = planIssuer(org, byKey, claimed);
50
- if (issuer)
51
- plannedSteps.push(issuer);
52
- const owner = planOwner(org, recordedIds);
67
+ const issuer = planIssuer(org, byKey, claimed, platformReadiness.authReady);
68
+ if (issuer.step)
69
+ plannedSteps.push(issuer.step);
70
+ const owner = planOwner(org, recordedIds, platformReadiness.authReady);
53
71
  if (owner)
54
72
  plannedSteps.push(owner);
55
- const mail = planMail(org, byKey, claimed);
56
- if (mail)
57
- plannedSteps.push(mail);
73
+ const mail = planMail(org, byKey, claimed, recordedIds, platformReadiness.mailHost);
74
+ if (mail.step)
75
+ plannedSteps.push(mail.step);
58
76
  const chat = planChat(org, byKey, claimed);
59
77
  if (chat)
60
78
  plannedSteps.push(chat);
61
- const filesOrganisation = planFilesOrganisation(org, byKey, claimed);
62
- if (filesOrganisation)
63
- plannedSteps.push(filesOrganisation);
64
- const storage = planStorage(org, byKey, claimed);
65
- if (storage)
66
- plannedSteps.push(storage);
67
- const aiOrganisation = planAiOrganisation(org, byKey, claimed);
68
- if (aiOrganisation)
69
- plannedSteps.push(aiOrganisation);
70
- const aiBudget = planAiBudget(org, byKey, claimed);
71
- if (aiBudget)
72
- plannedSteps.push(aiBudget);
79
+ const calls = planCalls(org, byKey, claimed, platformReadiness.callsReady);
80
+ if (calls.step)
81
+ plannedSteps.push(calls.step);
82
+ // Right after the Matrix stack (#145): its own executor
83
+ // (`executor.ts`'s `coolifyCallsService`) reads the Synapse/MAS addresses
84
+ // and MAS client credentials `coolifyCalls` just recorded, the same
85
+ // ordering `planOwner` relies on for `zitadel:issuer`.
86
+ const callsService = planCallsService(org, byKey, claimed, platformReadiness.callsReady);
87
+ if (callsService.step)
88
+ plannedSteps.push(callsService.step);
89
+ const filesOrganisation = planFilesOrganisation(org, byKey, claimed, platformReadiness.filesReady, platformReadiness.missingKeys.files, platformReadiness.noKeyReader);
90
+ if (filesOrganisation.step)
91
+ plannedSteps.push(filesOrganisation.step);
92
+ const storage = planStorage(org, byKey, claimed, platformReadiness.filesReady, platformReadiness.missingKeys.files, platformReadiness.noKeyReader);
93
+ if (storage.step)
94
+ plannedSteps.push(storage.step);
95
+ const aiOrganisation = planAiOrganisation(org, byKey, claimed, platformReadiness.aiReady, platformReadiness.missingKeys.ai, platformReadiness.noKeyReader);
96
+ if (aiOrganisation.step)
97
+ plannedSteps.push(aiOrganisation.step);
98
+ const aiBudget = planAiBudget(org, byKey, claimed, platformReadiness.aiReady, platformReadiness.missingKeys.ai, platformReadiness.noKeyReader);
99
+ if (aiBudget.step)
100
+ plannedSteps.push(aiBudget.step);
101
+ const aiKey = planAiKey(org, recordedIds, platformReadiness.aiReady);
102
+ if (aiKey.step)
103
+ plannedSteps.push(aiKey.step);
104
+ // Before `planSite`'s own step (#139): `cmsSite`'s executor reads this
105
+ // step's recorded client id, the same way `coolifyApp` runs after the
106
+ // hosted app's own `zitadel:app` steps.
107
+ const cmsClient = planCmsClient(org, byKey, claimed);
108
+ if (cmsClient)
109
+ plannedSteps.push(cmsClient);
73
110
  const site = planSite(org, byKey, claimed);
74
111
  if (site)
75
112
  plannedSteps.push(site);
@@ -114,6 +151,22 @@ export function computePlanDetail(org, live, recordedIds = {}) {
114
151
  const customers = planCustomers(org, live, claimed, recordedIds);
115
152
  if (customers)
116
153
  plannedSteps.push(customers);
154
+ // #164: every org's own Agora -- always on, no toggle. Last, so nothing
155
+ // else in the org waits on it: `runSteps` stops at the first failing step.
156
+ // Its fixed ZITADEL client before the Coolify app it signs into:
157
+ // `coolifyAgora` reads this step's recorded client id.
158
+ const agoraClient = planAgoraClient(org, byKey, claimed);
159
+ if (agoraClient)
160
+ plannedSteps.push(agoraClient);
161
+ const agora = planAgora(org, byKey, claimed, recordedIds, agoraHost);
162
+ if (agora.step)
163
+ plannedSteps.push(agora.step);
164
+ // #173: register the org's own app with the platform's jobs scheduler.
165
+ // After Agora, so a failing registration can never stop Agora from
166
+ // provisioning (`runSteps` stops at the first failing step).
167
+ const jobsRegistration = planJobsRegistration(org, recordedIds, jobsAvailable || platformReadiness.jobsReady, platformReadiness.missingKeys.jobs, platformReadiness.noKeyReader);
168
+ if (jobsRegistration.step)
169
+ plannedSteps.push(jobsRegistration.step);
117
170
  // #10 (resolved): "foundry manages only what the declared org names";
118
171
  // anything left unclaimed is listed, never proposed for deletion.
119
172
  const unmanaged = live
@@ -121,6 +174,41 @@ export function computePlanDetail(org, live, recordedIds = {}) {
121
174
  .map((r) => ({
122
175
  description: `${r.system} ${r.kind} "${r.key}" is live but not named by the declared org`,
123
176
  }));
177
+ // A blocked Agora is reported here, not planned: a step the executor must
178
+ // refuse would halt every step after it. This is the plan's one non-step
179
+ // item list, so it rides along with the unmanaged items.
180
+ if (agora.blocked)
181
+ unmanaged.push({ description: agora.blocked, key: 'coolify:agora' });
182
+ // Same for an ai key minted but never delivered: planning a new mint would
183
+ // orphan a second live key.
184
+ if (aiKey.blocked)
185
+ unmanaged.push({ description: aiKey.blocked, key: 'ai:key' });
186
+ // Same for a jobs registration that cannot run (no scheduler wired) or must
187
+ // not run again (minted, never delivered).
188
+ if (jobsRegistration.blocked) {
189
+ unmanaged.push({ description: jobsRegistration.blocked, key: 'jobs:registration' });
190
+ }
191
+ // #177: an org step whose own shared platform service is off or not yet
192
+ // applied is blocked here, not planned -- the same rule, now driven by
193
+ // `platformReadiness` instead of a host-supplied port. Each is reported at
194
+ // most once even though a dependent step (owner, calls-service, storage,
195
+ // budget, key) would otherwise also have nothing to act on.
196
+ if (issuer.blocked)
197
+ unmanaged.push({ description: issuer.blocked, key: 'zitadel:issuer' });
198
+ if (mail.blocked)
199
+ unmanaged.push({ description: mail.blocked, key: 'coolify:mail' });
200
+ if (calls.blocked)
201
+ unmanaged.push({ description: calls.blocked, key: 'coolify:calls' });
202
+ if (filesOrganisation.blocked) {
203
+ unmanaged.push({ description: filesOrganisation.blocked, key: 'files:organisation' });
204
+ }
205
+ if (aiOrganisation.blocked) {
206
+ unmanaged.push({ description: aiOrganisation.blocked, key: 'ai:organisation' });
207
+ }
208
+ if (storage.blocked)
209
+ unmanaged.push({ description: storage.blocked, key: 'files:storage' });
210
+ if (aiBudget.blocked)
211
+ unmanaged.push({ description: aiBudget.blocked, key: 'ai:budget' });
124
212
  return { orgId: org.id, plannedSteps, unmanaged };
125
213
  }
126
214
  /**
@@ -138,48 +226,67 @@ export function computePlanDetail(org, live, recordedIds = {}) {
138
226
  * (#7 corrections 2) and is not built in v1 (decisions file, same section:
139
227
  * "domain verification and passkey invalidation need their own design"), so
140
228
  * `executor.ts` throws `NotBuiltError` on it rather than run it.
229
+ *
230
+ * #177: every org's ZITADEL org lives inside the platform's own shared
231
+ * sign-in (#168), so a first-ever provision (no live issuer yet) is blocked,
232
+ * not planned, while `platformReadiness.authReady` is false -- the same rule
233
+ * every platform-dependent org step in this file now follows. An issuer that
234
+ * already exists live is unaffected: its own domain-move logic runs
235
+ * regardless, since that never re-provisions the ZITADEL org itself.
141
236
  */
142
- function planIssuer(org, byKey, claimed) {
237
+ function planIssuer(org, byKey, claimed, authReady) {
143
238
  const key = resourceKey('zitadel', 'issuer', org.id);
144
239
  const live = byKey.get(key);
145
240
  const desiredUsesDomain = org.issuer.usesDomain;
146
241
  if (!live) {
242
+ if (!authReady) {
243
+ return {
244
+ step: null,
245
+ blocked: `${org.slug}'s ZITADEL org is blocked, not planned: the platform's sign-in is off or not yet applied`,
246
+ };
247
+ }
147
248
  return {
148
- action: {
149
- system: 'zitadel',
150
- kind: 'issuer',
151
- key: org.id,
152
- desired: { provision: true, usesDomain: desiredUsesDomain },
153
- },
154
249
  step: {
155
- description: `provision ${org.slug}'s own ZITADEL org`,
156
- destroys: false,
157
- migrates: false,
250
+ action: {
251
+ system: 'zitadel',
252
+ kind: 'issuer',
253
+ key: org.id,
254
+ desired: { provision: true, usesDomain: desiredUsesDomain },
255
+ },
256
+ step: {
257
+ description: `provision ${org.slug}'s own ZITADEL org`,
258
+ destroys: false,
259
+ migrates: false,
260
+ },
158
261
  },
262
+ blocked: null,
159
263
  };
160
264
  }
161
265
  claimed.add(key);
162
266
  const liveUsesDomain = Boolean(live.config.usesDomain);
163
267
  if (liveUsesDomain === desiredUsesDomain)
164
- return null;
268
+ return { step: null, blocked: null };
165
269
  const domain = org.domain ?? 'the org domain';
166
270
  return {
167
- action: {
168
- system: 'zitadel',
169
- kind: 'issuer',
170
- key: org.id,
171
- desired: { usesDomain: desiredUsesDomain },
172
- },
173
271
  step: {
174
- description: desiredUsesDomain
175
- ? `move the issuer onto ${domain}, invalidating every passkey in the org`
176
- : `move the issuer off ${domain} back onto the shared issuer, invalidating every passkey in the org`,
177
- // Any issuer move invalidates every passkey in the org (#7 corrections
178
- // 2, #19 "Done when"). It never moves data between systems, so it is
179
- // never `migrates`.
180
- destroys: true,
181
- migrates: false,
272
+ action: {
273
+ system: 'zitadel',
274
+ kind: 'issuer',
275
+ key: org.id,
276
+ desired: { usesDomain: desiredUsesDomain },
277
+ },
278
+ step: {
279
+ description: desiredUsesDomain
280
+ ? `move the issuer onto ${domain}, invalidating every passkey in the org`
281
+ : `move the issuer off ${domain} back onto the shared issuer, invalidating every passkey in the org`,
282
+ // Any issuer move invalidates every passkey in the org (#7 corrections
283
+ // 2, #19 "Done when"). It never moves data between systems, so it is
284
+ // never `migrates`.
285
+ destroys: true,
286
+ migrates: false,
287
+ },
182
288
  },
289
+ blocked: null,
183
290
  };
184
291
  }
185
292
  /**
@@ -210,12 +317,17 @@ function planIssuer(org, byKey, claimed) {
210
317
  * nothing here puts it anywhere beyond what audit already records for the
211
318
  * org.
212
319
  */
213
- function planOwner(org, recordedIds) {
320
+ function planOwner(org, recordedIds, authReady) {
214
321
  if (!org.owner)
215
322
  return null;
216
323
  const key = resourceKey('zitadel', 'owner', org.id);
217
324
  if (recordedIds[key])
218
325
  return null; // already invited -- a re-run plans nothing.
326
+ // #177: nothing to invite into yet -- `planIssuer` already reports the
327
+ // platform's sign-in as blocked, so this step stays silent rather than
328
+ // repeating it.
329
+ if (!authReady)
330
+ return null;
219
331
  return {
220
332
  action: {
221
333
  system: 'zitadel',
@@ -234,7 +346,27 @@ function planOwner(org, recordedIds) {
234
346
  },
235
347
  };
236
348
  }
237
- function planMail(org, byKey, claimed) {
349
+ /**
350
+ * #162: the flat scope id `executor.ts`'s `ensureOwnerMailbox` records once
351
+ * it has given the org's owner a mailbox (`MAIL_OWNER_MAILBOX_SCOPE_KEY`
352
+ * there) — restated here rather than imported, the same rule this file's own
353
+ * `LOG_DIR` precedent follows for a value shared with `executor.ts`.
354
+ */
355
+ const MAIL_OWNER_MAILBOX_SCOPE_KEY = 'mail_owner_mailbox';
356
+ /** Restated from `executor.ts`, the same rule: the id of an ai key minted but not delivered. */
357
+ export const AI_KEY_UNDELIVERED_SCOPE_KEY = 'ai_key_undelivered';
358
+ /** Restated from `executor.ts`, the same rule: the id of a jobs project registered but not delivered. */
359
+ export const JOBS_REGISTRATION_UNDELIVERED_SCOPE_KEY = 'jobs_registration_undelivered';
360
+ /**
361
+ * #177: `mailHost` is `platformReadiness.mailHost` -- the platform's own
362
+ * `apex`/`mail.hostname` once its mail server is enabled and applied, `null`
363
+ * otherwise. `executor.ts`'s `coolifyMail` reads `apex`/`mailHost` straight
364
+ * off this step's own `desired`, the same "the executor writes exactly these
365
+ * host values, so what the plan showed is what gets applied" rule
366
+ * `planAgora` already follows for its own host values -- never rebuilding
367
+ * them itself from a literal or a second lookup.
368
+ */
369
+ function planMail(org, byKey, claimed, recordedIds, mailHost) {
238
370
  const key = resourceKey('coolify', 'mail', org.id);
239
371
  const live = byKey.get(key);
240
372
  // `off` means mail is not foundry's concern here (org.ts's MailMode doc);
@@ -244,45 +376,90 @@ function planMail(org, byKey, claimed) {
244
376
  // after having it would silently hide its own leftover mail resource from
245
377
  // the unmanaged list.
246
378
  if (org.mail.mode === 'off')
247
- return null;
379
+ return { step: null, blocked: null };
248
380
  if (live)
249
381
  claimed.add(key);
250
- const desired = { mode: org.mail.mode, usesDomain: org.mail.usesDomain };
382
+ if (!mailHost) {
383
+ return {
384
+ step: null,
385
+ blocked: `${org.slug}'s mail is blocked, not planned: the platform's mail server is off or not yet applied`,
386
+ };
387
+ }
388
+ const desired = {
389
+ mode: org.mail.mode,
390
+ usesDomain: org.mail.usesDomain,
391
+ apex: mailHost.apex,
392
+ mailHost: mailHost.mailHost,
393
+ };
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` : '';
251
400
  if (!live) {
252
401
  return {
253
- action: { system: 'coolify', kind: 'mail', key: org.id, desired },
254
402
  step: {
255
- description: `provision mail (${org.mail.mode}) for ${org.slug}`,
256
- destroys: false,
257
- migrates: false,
403
+ action: { system: 'coolify', kind: 'mail', key: org.id, desired },
404
+ step: {
405
+ description: `provision mail (${org.mail.mode}) for ${org.slug}${mailboxNote}`,
406
+ destroys: false,
407
+ migrates: false,
408
+ },
258
409
  },
410
+ blocked: null,
259
411
  };
260
412
  }
261
413
  const liveMode = live.config.mode;
262
414
  const liveUsesDomain = Boolean(live.config.usesDomain);
263
- if (liveMode === desired.mode && liveUsesDomain === desired.usesDomain)
264
- return null;
415
+ if (liveMode === desired.mode && liveUsesDomain === desired.usesDomain) {
416
+ // #177: the platform's apex or mail host moved since this step last wrote
417
+ // them (or it never recorded them). Re-applying is idempotent.
418
+ const addressMoved = recordedIds[ORG_MAIL_APPLIED_APEX_SCOPE_KEY] !== mailHost.apex ||
419
+ recordedIds[ORG_MAIL_APPLIED_HOST_SCOPE_KEY] !== mailHost.mailHost;
420
+ if (!mailboxOwed && !addressMoved)
421
+ return { step: null, blocked: null };
422
+ return {
423
+ step: {
424
+ action: { system: 'coolify', kind: 'mail', key: org.id, desired },
425
+ step: {
426
+ description: addressMoved
427
+ ? `re-apply ${org.slug}'s mail on the platform's mail host ${mailHost.mailHost}${mailboxNote}`
428
+ : `give ${org.slug}'s owner a mailbox`,
429
+ destroys: false,
430
+ migrates: false,
431
+ },
432
+ },
433
+ blocked: null,
434
+ };
435
+ }
265
436
  if (liveUsesDomain && !desired.usesDomain) {
266
437
  // #7 corrections 1: parked, not deleted — mailboxes and messages stay —
267
438
  // but still disruptive enough to gate like a destructive step.
268
439
  return {
269
- action: { system: 'coolify', kind: 'mail', key: org.id, desired },
270
440
  step: {
271
- description: `park ${org.slug}'s mail domain (stops receiving; mailboxes and messages are retained, not deleted — #7 corrections 1)`,
272
- destroys: true,
273
- migrates: false,
441
+ action: { system: 'coolify', kind: 'mail', key: org.id, desired },
442
+ step: {
443
+ description: `park ${org.slug}'s mail domain (stops receiving; mailboxes and messages are retained, not deleted — #7 corrections 1)`,
444
+ destroys: true,
445
+ migrates: false,
446
+ },
274
447
  },
448
+ blocked: null,
275
449
  };
276
450
  }
277
451
  // A mode transition (#7 "Transitions") or turning a domain on is a
278
452
  // one-time copy, never a deletion.
279
453
  return {
280
- action: { system: 'coolify', kind: 'mail', key: org.id, desired },
281
454
  step: {
282
- description: `move ${org.slug}'s mail from ${String(liveMode)} to ${desired.mode}`,
283
- destroys: false,
284
- migrates: true,
455
+ action: { system: 'coolify', kind: 'mail', key: org.id, desired },
456
+ step: {
457
+ description: `move ${org.slug}'s mail from ${String(liveMode)} to ${desired.mode}${mailboxNote}`,
458
+ destroys: false,
459
+ migrates: true,
460
+ },
285
461
  },
462
+ blocked: null,
286
463
  };
287
464
  }
288
465
  function planChat(org, byKey, claimed) {
@@ -335,6 +512,62 @@ function planChat(org, byKey, claimed) {
335
512
  },
336
513
  };
337
514
  }
515
+ /**
516
+ * The org's own calls Matrix stack (#128) -- a dedicated Synapse and
517
+ * matrix-authentication-service, entirely separate from chat's
518
+ * (`plan/calls-matrix.ts`'s own top doc comment: a call guest must never gain
519
+ * chat membership). Unlike chat, off by default: `org.ts`'s
520
+ * `CallsConfig.enabled` doc comment. Same on/off, park-not-delete shape as
521
+ * {@link planApp}/{@link planDatabase}/{@link planTunnel} below
522
+ * (`planHostingToggle`, #7 corrections 1) -- turning it off parks Synapse and
523
+ * MAS, never either database (`executor.ts`'s `coolifyCalls`).
524
+ *
525
+ * #177: a first-ever provision (no live resource yet) is blocked, not
526
+ * planned, while `platformReadiness.callsReady` is false -- an org's own
527
+ * Matrix stack is wired to the shared calls SFU (#122/#127), so there is
528
+ * nothing for it to authenticate calls against until the platform's own
529
+ * `sfu:livekit` step is declared. Parking, and an already-provisioned stack,
530
+ * are unaffected.
531
+ */
532
+ function planCalls(org, byKey, claimed, callsReady) {
533
+ const key = resourceKey('coolify', 'calls', org.id);
534
+ if (org.calls.enabled && !byKey.get(key) && !callsReady) {
535
+ return {
536
+ step: null,
537
+ blocked: `${org.slug}'s calls Matrix stack is blocked, not planned: the platform's shared calls SFU is off`,
538
+ };
539
+ }
540
+ return {
541
+ 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)`),
542
+ blocked: null,
543
+ };
544
+ }
545
+ /**
546
+ * The org's own calls service (#145, part 2 of #128) -- `wtfalch/calls`'s
547
+ * `packages/service`, deployed alongside the calls Matrix stack {@link planCalls}
548
+ * provisions and wired to it and to the shared LiveKit SFU (foundry#122/#127).
549
+ * Gated on the same `org.calls.enabled` flag as the Matrix stack, not a flag
550
+ * of its own: a calls service with no Matrix stack behind it has nothing to
551
+ * authenticate a guest against, and a Matrix stack alone answers no browser,
552
+ * so the two are declared as one on/off switch even though they are two
553
+ * planner steps. Same on/off, park-not-delete shape as {@link planCalls}
554
+ * (`planHostingToggle`, #7 corrections 1) -- turning it off parks the app,
555
+ * never its database (`executor.ts`'s `coolifyCallsService`).
556
+ *
557
+ * #177: silently plans nothing (no second blocked message) while the Matrix
558
+ * stack it rides beside is itself blocked on `callsReady` -- {@link planCalls}
559
+ * already reports the platform's shared SFU as missing once.
560
+ */
561
+ function planCallsService(org, byKey, claimed, callsReady) {
562
+ const key = resourceKey('coolify', 'calls-service', org.id);
563
+ if (org.calls.enabled && !byKey.get(key) && !callsReady) {
564
+ return { step: null, blocked: null };
565
+ }
566
+ return {
567
+ 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)`),
568
+ blocked: null,
569
+ };
570
+ }
338
571
  /**
339
572
  * Deduped `ai.*` permissions offered to every organisation by default
340
573
  * (Decision, this campaign's report): the minimum set that lets an org
@@ -346,6 +579,19 @@ function planChat(org, byKey, claimed) {
346
579
  * explicit opt-in this default deliberately leaves out.
347
580
  */
348
581
  const AI_DEFAULT_OFFERS = ['ai.offerings:invoke', 'ai.runs:create'];
582
+ /**
583
+ * Why an org step on a shared platform service is blocked: a key that is not
584
+ * held, no held-keys reader given at all, or the service off or not applied.
585
+ * Fixed text (part of the stale-plan comparison); a ref only, never a value.
586
+ */
587
+ function notReadyReason(service, keyName, missingKeyRef, noKeyReader) {
588
+ if (missingKeyRef && noKeyReader) {
589
+ return `the host gave no held-keys reader and no ${service} port, so the platform's ${keyName} ${missingKeyRef} cannot be checked`;
590
+ }
591
+ return missingKeyRef
592
+ ? `the platform's ${keyName} ${missingKeyRef} is not held yet`
593
+ : `the platform's ${service} host is off or not yet applied`;
594
+ }
349
595
  /**
350
596
  * #80: every org's own organisation on Files, a per-org singleton resource
351
597
  * planned before {@link planStorage} -- `setQuota`/`setBudget` 404 against an
@@ -359,26 +605,39 @@ const AI_DEFAULT_OFFERS = ['ai.offerings:invoke', 'ai.runs:create'];
359
605
  * concern (`org.ts`'s `StorageConfig` doc: "a separate, operator-driven hard
360
606
  * cap this field has no opinion on"), so provisioning always asks for an
361
607
  * uncapped ceiling -- Decision, this campaign's report.
608
+ *
609
+ * #177: blocked, not planned, while `filesReady` is false -- the platform's
610
+ * own files host (#171) is off or not yet applied, so there is nothing here
611
+ * for `FilesOrganisationPort.provisionOrganisation` to reach.
362
612
  */
363
- function planFilesOrganisation(org, byKey, claimed) {
613
+ function planFilesOrganisation(org, byKey, claimed, filesReady, missingKeyRef, noKeyReader = false) {
364
614
  const key = resourceKey('files', 'organisation', org.id);
365
615
  const live = byKey.get(key);
366
616
  if (live) {
367
617
  claimed.add(key);
368
- return null;
618
+ return { step: null, blocked: null };
619
+ }
620
+ if (!filesReady) {
621
+ return {
622
+ step: null,
623
+ blocked: `${org.slug}'s Files organisation is blocked, not planned: ${notReadyReason('files', 'files management key', missingKeyRef, noKeyReader)}`,
624
+ };
369
625
  }
370
626
  return {
371
- action: {
372
- system: 'files',
373
- kind: 'organisation',
374
- key: org.id,
375
- desired: { provision: true, ceilingBytes: null, ceilingMicros: null },
376
- },
377
627
  step: {
378
- description: `provision ${org.slug}'s own organisation on Files`,
379
- destroys: false,
380
- migrates: false,
628
+ action: {
629
+ system: 'files',
630
+ kind: 'organisation',
631
+ key: org.id,
632
+ desired: { provision: true, ceilingBytes: null, ceilingMicros: null },
633
+ },
634
+ step: {
635
+ description: `provision ${org.slug}'s own organisation on Files`,
636
+ destroys: false,
637
+ migrates: false,
638
+ },
381
639
  },
640
+ blocked: null,
382
641
  };
383
642
  }
384
643
  /**
@@ -395,30 +654,42 @@ function planFilesOrganisation(org, byKey, claimed) {
395
654
  * first budget row with whatever the org already declares (including
396
655
  * `null` for uncapped), so a brand new org never needs a follow-up
397
656
  * {@link planAiBudget} step just to reach its own declared default.
657
+ *
658
+ * #177: blocked, not planned, while `aiReady` is false -- the platform's own
659
+ * AI host (#172) is off or not yet applied.
398
660
  */
399
- function planAiOrganisation(org, byKey, claimed) {
661
+ function planAiOrganisation(org, byKey, claimed, aiReady, missingKeyRef, noKeyReader = false) {
400
662
  const key = resourceKey('ai', 'organisation', org.id);
401
663
  const live = byKey.get(key);
402
664
  if (live) {
403
665
  claimed.add(key);
404
- return null;
666
+ return { step: null, blocked: null };
667
+ }
668
+ if (!aiReady) {
669
+ return {
670
+ step: null,
671
+ blocked: `${org.slug}'s AI organisation is blocked, not planned: ${notReadyReason('AI', 'AI management key', missingKeyRef, noKeyReader)}`,
672
+ };
405
673
  }
406
674
  return {
407
- action: {
408
- system: 'ai',
409
- kind: 'organisation',
410
- key: org.id,
411
- desired: {
412
- provision: true,
413
- limitMicros: org.ai.limitMicros,
414
- offers: [...AI_DEFAULT_OFFERS],
415
- },
416
- },
417
675
  step: {
418
- description: `provision ${org.slug}'s own organisation on AI and switch AI use on`,
419
- destroys: false,
420
- migrates: false,
676
+ action: {
677
+ system: 'ai',
678
+ kind: 'organisation',
679
+ key: org.id,
680
+ desired: {
681
+ provision: true,
682
+ limitMicros: org.ai.limitMicros,
683
+ offers: [...AI_DEFAULT_OFFERS],
684
+ },
685
+ },
686
+ step: {
687
+ description: `provision ${org.slug}'s own organisation on AI and switch AI use on`,
688
+ destroys: false,
689
+ migrates: false,
690
+ },
421
691
  },
692
+ blocked: null,
422
693
  };
423
694
  }
424
695
  /**
@@ -428,23 +699,40 @@ function planAiOrganisation(org, byKey, claimed) {
428
699
  * reversible"). A missing live resource reads as uncapped (`null`), the same
429
700
  * value a freshly-declared org's `storage` starts at, so a brand new org with
430
701
  * no limits declared produces no step.
702
+ *
703
+ * #177: silently plans nothing while there is no organisation to size a limit
704
+ * on yet and `filesReady` is false -- {@link planFilesOrganisation} already
705
+ * reports the platform's files host as missing once.
431
706
  */
432
- function planStorage(org, byKey, claimed) {
707
+ function planStorage(org, byKey, claimed, filesReady, missingKeyRef, noKeyReader = false) {
433
708
  const key = resourceKey('files', 'storage', org.id);
434
709
  const live = byKey.get(key);
435
710
  if (live)
436
711
  claimed.add(key);
712
+ if (!filesReady && !live)
713
+ return { step: null, blocked: null };
437
714
  const desired = { limitBytes: org.storage.limitBytes, limitMicros: org.storage.limitMicros };
438
715
  const liveLimitBytes = live ? (live.config.limitBytes ?? null) : null;
439
716
  const liveLimitMicros = live ? (live.config.limitMicros ?? null) : null;
440
- if (liveLimitBytes === desired.limitBytes && liveLimitMicros === desired.limitMicros)
441
- return null;
717
+ if (liveLimitBytes === desired.limitBytes && liveLimitMicros === desired.limitMicros) {
718
+ return { step: null, blocked: null };
719
+ }
720
+ // A live resource with drift needs the platform's key to change; blocked, not planned to throw at apply.
721
+ if (!filesReady) {
722
+ return {
723
+ step: null,
724
+ blocked: `${org.slug}'s storage limit change is blocked, not planned: ${notReadyReason('files', 'files management key', missingKeyRef, noKeyReader)}`,
725
+ };
726
+ }
442
727
  return {
443
- action: { system: 'files', kind: 'storage', key: org.id, desired },
728
+ blocked: null,
444
729
  step: {
445
- description: `set ${org.slug}'s storage limit to ${desired.limitBytes ?? 'uncapped'} bytes / ${desired.limitMicros ?? 'uncapped'} micros`,
446
- destroys: false,
447
- migrates: false,
730
+ action: { system: 'files', kind: 'storage', key: org.id, desired },
731
+ step: {
732
+ description: `set ${org.slug}'s storage limit to ${desired.limitBytes ?? 'uncapped'} bytes / ${desired.limitMicros ?? 'uncapped'} micros`,
733
+ destroys: false,
734
+ migrates: false,
735
+ },
448
736
  },
449
737
  };
450
738
  }
@@ -454,21 +742,132 @@ function planStorage(org, byKey, claimed) {
454
742
  * (#80), which already seeds a brand new org's first budget row from this
455
743
  * same `org.ai.limitMicros` -- this planner only fires again once that row's
456
744
  * live value has drifted from what is declared.
745
+ *
746
+ * #177: silently plans nothing while there is no organisation to size a
747
+ * budget on yet and `aiReady` is false -- {@link planAiOrganisation} already
748
+ * reports the platform's AI host as missing once.
457
749
  */
458
- function planAiBudget(org, byKey, claimed) {
750
+ function planAiBudget(org, byKey, claimed, aiReady, missingKeyRef, noKeyReader = false) {
459
751
  const key = resourceKey('ai', 'budget', org.id);
460
752
  const live = byKey.get(key);
461
753
  if (live)
462
754
  claimed.add(key);
755
+ if (!aiReady && !live)
756
+ return { step: null, blocked: null };
463
757
  const desired = { limitMicros: org.ai.limitMicros };
464
758
  const liveLimitMicros = live ? (live.config.limitMicros ?? null) : null;
465
759
  if (liveLimitMicros === desired.limitMicros)
760
+ return { step: null, blocked: null };
761
+ if (!aiReady) {
762
+ return {
763
+ step: null,
764
+ blocked: `${org.slug}'s AI budget change is blocked, not planned: ${notReadyReason('AI', 'AI management key', missingKeyRef, noKeyReader)}`,
765
+ };
766
+ }
767
+ return {
768
+ blocked: null,
769
+ step: {
770
+ action: { system: 'ai', kind: 'budget', key: org.id, desired },
771
+ step: {
772
+ description: `set ${org.slug}'s AI budget limit to ${desired.limitMicros ?? 'uncapped'} micros`,
773
+ destroys: false,
774
+ migrates: false,
775
+ },
776
+ },
777
+ };
778
+ }
779
+ /**
780
+ * #80 step 3: the org's own ai key, minted once its organisation is
781
+ * provisioned and offered ({@link planAiOrganisation}). Idempotent on the
782
+ * *recorded* resource id (`recordedIds['ai:key:<orgId>']`), the same shape
783
+ * {@link planOwner} uses and for the same reason: ai's `issueOrganisationKey`
784
+ * (`wtfalch/ai#108`/`#113`) shows a key's secret exactly once and offers no
785
+ * way to list or look up an already-minted key by name, so there is nothing
786
+ * in `live` an existing key could ever appear as. `permissions` mirrors
787
+ * {@link AI_DEFAULT_OFFERS} -- the key can only do what the org's own offers
788
+ * already allow, so the two only ever widen together.
789
+ *
790
+ * Not built here: rotating the key before its `expiresDays` runs out. #80
791
+ * only asks for "give every org... a working ai key"; renewal is its own
792
+ * follow-up once a real org has carried one for a while.
793
+ *
794
+ * #177: silently plans nothing while `aiReady` is false -- there is no
795
+ * organisation to mint a key inside yet, and {@link planAiOrganisation}
796
+ * already reports the platform's AI host as missing once.
797
+ */
798
+ function planAiKey(org, recordedIds, aiReady) {
799
+ const key = resourceKey('ai', 'key', org.id);
800
+ if (recordedIds[key])
801
+ return { step: null, blocked: null }; // delivered -- a re-run plans nothing.
802
+ if (!aiReady)
803
+ return { step: null, blocked: null };
804
+ // Minted but never delivered (`executor.ts`'s `aiKey`): nobody holds the
805
+ // secret and ai has no per-key revoke yet, so a new mint would only add
806
+ // another orphan. Reported, not planned.
807
+ const undelivered = recordedIds[AI_KEY_UNDELIVERED_SCOPE_KEY];
808
+ if (undelivered) {
809
+ return {
810
+ step: null,
811
+ 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)`,
812
+ };
813
+ }
814
+ return {
815
+ blocked: null,
816
+ step: {
817
+ action: {
818
+ system: 'ai',
819
+ kind: 'key',
820
+ key: org.id,
821
+ desired: {
822
+ name: `${org.slug}-app`,
823
+ expiresDays: 365,
824
+ permissions: [...AI_DEFAULT_OFFERS],
825
+ },
826
+ },
827
+ step: {
828
+ description: `mint ${org.slug}'s own ai key`,
829
+ destroys: false,
830
+ migrates: false,
831
+ },
832
+ },
833
+ };
834
+ }
835
+ /**
836
+ * https://github.com/wtfalch/foundry/issues/139 (parent decision:
837
+ * https://github.com/wtfalch/foundry/issues/99): every site-enabled org gets
838
+ * one fixed ZITADEL web client named `cms`, so its studio
839
+ * (`@wtfalch/cms`'s `apps/host`) can sign anyone in -- automatically, with no
840
+ * new field on `DeclaredOrg`. Modeled on {@link planIssuerApps}'s single-app
841
+ * shape: provision when missing, recreate when the live `appType` differs,
842
+ * nothing otherwise. There is no park for this client either, for the same
843
+ * reason `planIssuerApps`'s own doc comment gives -- ZITADEL has no notion of
844
+ * a disabled-but-retained OIDC client.
845
+ */
846
+ function planCmsClient(org, byKey, claimed) {
847
+ if (!org.site.enabled)
848
+ return null;
849
+ const key = resourceKey('zitadel', 'app', 'cms');
850
+ const live = byKey.get(key);
851
+ if (live)
852
+ claimed.add(key);
853
+ const desired = { appType: 'web' };
854
+ if (!live) {
855
+ return {
856
+ action: { system: 'zitadel', kind: 'app', key: 'cms', desired },
857
+ step: {
858
+ description: `provision the web ZITADEL app 'cms' for ${org.slug}'s cms studio sign-in`,
859
+ destroys: false,
860
+ migrates: false,
861
+ },
862
+ };
863
+ }
864
+ if (live.config.appType === 'web')
466
865
  return null;
467
866
  return {
468
- action: { system: 'ai', kind: 'budget', key: org.id, desired },
867
+ action: { system: 'zitadel', kind: 'app', key: 'cms', desired },
469
868
  step: {
470
- description: `set ${org.slug}'s AI budget limit to ${desired.limitMicros ?? 'uncapped'} micros`,
471
- destroys: false,
869
+ description: `recreate ZITADEL app 'cms' for ${org.slug} as web (was ${String(live.config.appType)})`,
870
+ destroys: true,
472
871
  migrates: false,
473
872
  },
474
873
  };
@@ -536,6 +935,129 @@ function planSite(org, byKey, claimed) {
536
935
  },
537
936
  };
538
937
  }
938
+ /**
939
+ * https://github.com/wtfalch/foundry/issues/164 (per William,
940
+ * https://github.com/wtfalch/foundry/issues/161#issuecomment-5890087180:
941
+ * "every company gets its own Agora, always on"): every org gets one fixed
942
+ * ZITADEL web client named `agora`, so its Agora workspace
943
+ * (https://github.com/wtfalch/agora) can sign anyone in -- automatically,
944
+ * with no new field on `DeclaredOrg` (unlike {@link planCmsClient}'s own
945
+ * `cms` client, which only exists once `org.site.enabled` is true). Modeled
946
+ * on {@link planIssuerApps}'s/{@link planCmsClient}'s single-app shape:
947
+ * provision when missing, recreate when the live `appType` differs, nothing
948
+ * otherwise. There is no park for this client either, for the same reason
949
+ * `planIssuerApps`'s own doc comment gives -- ZITADEL has no notion of a
950
+ * disabled-but-retained OIDC client.
951
+ */
952
+ function planAgoraClient(org, byKey, claimed) {
953
+ const key = resourceKey('zitadel', 'app', 'agora');
954
+ const live = byKey.get(key);
955
+ if (live)
956
+ claimed.add(key);
957
+ const desired = { appType: 'web' };
958
+ if (!live) {
959
+ return {
960
+ action: { system: 'zitadel', kind: 'app', key: 'agora', desired },
961
+ step: {
962
+ description: `provision the web ZITADEL app 'agora' for ${org.slug}'s Agora sign-in`,
963
+ destroys: false,
964
+ migrates: false,
965
+ },
966
+ };
967
+ }
968
+ if (live.config.appType === 'web')
969
+ return null;
970
+ return {
971
+ action: { system: 'zitadel', kind: 'app', key: 'agora', desired },
972
+ step: {
973
+ description: `recreate ZITADEL app 'agora' for ${org.slug} as web (was ${String(live.config.appType)})`,
974
+ destroys: true,
975
+ migrates: false,
976
+ },
977
+ };
978
+ }
979
+ /**
980
+ * #164: the org's own Agora, a per-org singleton resource keyed by the org
981
+ * id like {@link planMail}/{@link planSite}, but under its own `agora` kind
982
+ * rather than `app` -- it is a separate Coolify application from the org's
983
+ * own hosted app ({@link planApp}, `org.hosting.enabled`), provisioned from
984
+ * https://github.com/wtfalch/agora's `apps/agora/Dockerfile`
985
+ * (`executor.ts`'s `coolifyAgora`), never toggled by any declared field.
986
+ *
987
+ * Once the app is live this still plans an update whenever the live app's
988
+ * Agora env (`config.env`, read by `providers-readers.ts`) lacks a desired
989
+ * key or holds a different value, using `agora.ts`'s `desiredAgoraEnv`, the
990
+ * same function the executor writes from. There is no park branch: foundry
991
+ * never deletes an Agora.
992
+ *
993
+ * The host supplies the issuer and the mail/messaging project ids
994
+ * (`agora.ts`'s `AgoraHostPort`). While it lacks any (or has no port, or the
995
+ * port throws), no step is planned at all: the blocked Agora comes back as a
996
+ * message naming each missing value, which `computePlanDetail` lists with the
997
+ * plan's non-step items, so every other step still applies.
998
+ */
999
+ function planAgora(org, byKey, claimed, recordedIds, reading) {
1000
+ const key = resourceKey('coolify', 'agora', org.id);
1001
+ const live = byKey.get(key);
1002
+ if (live)
1003
+ claimed.add(key);
1004
+ const error = 'error' in reading ? reading.error : null;
1005
+ const host = 'error' in reading ? NO_AGORA_HOST_VALUES : reading;
1006
+ const missing = agoraHostMissing(host);
1007
+ if (missing.length > 0) {
1008
+ // A fixed text, never the port's own message: this line is part of the
1009
+ // stale-plan comparison, and an error that varies per call would refuse
1010
+ // every apply as stale.
1011
+ const why = error ? ' (reading them failed)' : '';
1012
+ return {
1013
+ step: null,
1014
+ blocked: `${org.slug}'s Agora is blocked, not planned: the host supplies no ${missing.join(', ')}${why}`,
1015
+ };
1016
+ }
1017
+ // The executor writes exactly these host values, so what the plan showed
1018
+ // is what gets applied; the ids only the apply itself records are read at
1019
+ // run time.
1020
+ const action = {
1021
+ system: 'coolify',
1022
+ kind: 'agora',
1023
+ key: org.id,
1024
+ desired: { host },
1025
+ };
1026
+ if (!live) {
1027
+ return {
1028
+ step: {
1029
+ action,
1030
+ step: {
1031
+ description: `provision ${org.slug}'s Agora`,
1032
+ destroys: false,
1033
+ migrates: false,
1034
+ },
1035
+ },
1036
+ blocked: null,
1037
+ };
1038
+ }
1039
+ const liveEnv = (live.config.env ?? {});
1040
+ const { env } = desiredAgoraEnv(org, recordedIds, host);
1041
+ const drift = agoraEnvDrift(env, liveEnv);
1042
+ // The ZITADEL ids may be unrecorded (so `env` has no AGORA_CONFIG to
1043
+ // compare); a live app with no AGORA_CONFIG at all still needs one.
1044
+ if (liveEnv.AGORA_CONFIG === undefined && !drift.includes('AGORA_CONFIG')) {
1045
+ drift.push('AGORA_CONFIG');
1046
+ }
1047
+ if (drift.length === 0)
1048
+ return { step: null, blocked: null };
1049
+ return {
1050
+ step: {
1051
+ action,
1052
+ step: {
1053
+ description: `update ${org.slug}'s Agora config (${drift.join(', ')} missing or different)`,
1054
+ destroys: false,
1055
+ migrates: false,
1056
+ },
1057
+ },
1058
+ blocked: null,
1059
+ };
1060
+ }
539
1061
  /**
540
1062
  * The shared on/off shape behind {@link planApp}, {@link planDatabase} and
541
1063
  * {@link planTunnel} (#71): a per-org singleton resource, keyed by the org
@@ -616,6 +1138,72 @@ function planApp(org, byKey, claimed) {
616
1138
  },
617
1139
  };
618
1140
  }
1141
+ /**
1142
+ * #173: register the org's own app with the platform's jobs scheduler
1143
+ * (https://github.com/wtfalch/jobs's admin API, jobs#62), so the scheduler
1144
+ * knows where to deliver that org's schedules. Idempotent on the *recorded*
1145
+ * resource id, the same shape {@link planOwner} uses for inviting a human
1146
+ * owner by email: nothing in `live` can report "is this org registered" (no
1147
+ * reader exists for the scheduler's own admin API), and the admin API's own
1148
+ * `POST /admin/projects` is itself idempotent by `(name, baseUrl)` -- a
1149
+ * repeat call returns `200` with just the project id, never a fresh secret --
1150
+ * but this plan-level gate means a re-plan after a successful apply proposes
1151
+ * nothing at all, rather than relying on the executor to notice the repeat
1152
+ * every time.
1153
+ *
1154
+ * Only planned once the org has its own app to register
1155
+ * (`org.hosting.enabled`) -- an org with no hosted app has no `baseUrl` for
1156
+ * the scheduler to deliver to. The hostname is derived the same way
1157
+ * {@link planSite}'s own `hostname` local does: `DeclaredOrg.domain` once
1158
+ * `hosting.usesDomain` is true, `<slug>.oikos.host` otherwise.
1159
+ */
1160
+ function planJobsRegistration(org, recordedIds, jobsAvailable, missingKeyRef = null, noKeyReader = false) {
1161
+ if (!org.hosting.enabled)
1162
+ return { step: null, blocked: null };
1163
+ const key = resourceKey('jobs', 'registration', org.id);
1164
+ if (recordedIds[key])
1165
+ return { step: null, blocked: null }; // already registered -- a re-run plans nothing.
1166
+ // Registered but the credentials never reached the host (`executor.ts`'s
1167
+ // `jobsRegistration`): the scheduler shows them once and a repeat call
1168
+ // returns only the id, so planning the step again would record success with
1169
+ // no sync key. Reported, not planned.
1170
+ const undelivered = recordedIds[JOBS_REGISTRATION_UNDELIVERED_SCOPE_KEY];
1171
+ if (undelivered) {
1172
+ return {
1173
+ step: null,
1174
+ 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`,
1175
+ };
1176
+ }
1177
+ // The host says whether its executor has a scheduler to register with. A
1178
+ // fixed text: it is part of the stale-plan comparison.
1179
+ if (!jobsAvailable) {
1180
+ return {
1181
+ step: null,
1182
+ blocked: !missingKeyRef
1183
+ ? `${org.slug}'s jobs registration is blocked, not planned: the host supplies no jobs scheduler`
1184
+ : `${org.slug}'s jobs registration is blocked, not planned: ${noKeyReader
1185
+ ? `the host gave no held-keys reader and no jobs scheduler port, so the platform's jobs admin token ${missingKeyRef} cannot be checked`
1186
+ : `the platform's jobs admin token ${missingKeyRef} is not held yet`}`,
1187
+ };
1188
+ }
1189
+ const hostname = org.hosting.usesDomain && org.domain ? org.domain : `${org.slug}.oikos.host`;
1190
+ return {
1191
+ blocked: null,
1192
+ step: {
1193
+ action: {
1194
+ system: 'jobs',
1195
+ kind: 'registration',
1196
+ key: org.id,
1197
+ desired: { name: org.slug, baseUrl: `https://${hostname}` },
1198
+ },
1199
+ step: {
1200
+ description: `register ${org.slug}'s app with the jobs scheduler`,
1201
+ destroys: false,
1202
+ migrates: false,
1203
+ },
1204
+ },
1205
+ };
1206
+ }
619
1207
  /**
620
1208
  * A Postgres database beside the org's app (`org.ts`'s
621
1209
  * `HostingConfig.database`, #71) -- `validateHosting` requires
@@ -643,10 +1231,24 @@ function planDatabase(org, byKey, claimed) {
643
1231
  * (`executor.ts`'s `tunnelIngressHostnames`) -- there is no separate
644
1232
  * `site.tunnel` toggle to gate on the way `hosting.tunnel` does, since a site
645
1233
  * has no meaning without one.
1234
+ *
1235
+ * #145: an own-domain calls service (`org.ts`'s `CallsConfig.usesDomain`)
1236
+ * needs a tunnel too, for the same reason a site does -- an org that wants
1237
+ * `calls.<domain>` but declares no other own-domain feature must still get
1238
+ * one provisioned, not silently have nowhere for that hostname to route.
1239
+ *
1240
+ * #164: Agora needs a tunnel for the same reason, the moment `org.domain` is
1241
+ * set. Agora is always on (William, 2026-09-29), so every org with a domain
1242
+ * needs routing for `agora.<domain>` even with hosting and site off -- there
1243
+ * is no `usesDomain` flag of its own to gate this on. A level-1 org (no
1244
+ * domain) plans no per-org tunnel for Agora: `<slug>-agora.oikos.host` rides
1245
+ * the shared `*.oikos.host` wildcard tunnel (`scripts/shared-ingress.mjs`).
646
1246
  */
647
1247
  function planTunnel(org, byKey, claimed) {
648
1248
  const desired = (org.hosting.tunnel && org.hosting.usesDomain && Boolean(org.domain)) ||
649
- (org.site.enabled && Boolean(org.domain));
1249
+ (org.site.enabled && Boolean(org.domain)) ||
1250
+ (org.calls.enabled && org.calls.usesDomain && Boolean(org.domain)) ||
1251
+ Boolean(org.domain);
650
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)`);
651
1253
  }
652
1254
  /**
@@ -664,9 +1266,21 @@ function planTunnel(org, byKey, claimed) {
664
1266
  * though it can never have `hosting` on at the same time (`validateSite`) --
665
1267
  * `org.site.enabled` already guarantees both `domain` and `site.usesDomain`
666
1268
  * are set, so the `org.domain` check below still covers it.
1269
+ *
1270
+ * #145: an own-domain calls service (`org.ts`'s `CallsConfig.usesDomain`)
1271
+ * needs a zone for the same reason, whether or not the org also has hosting
1272
+ * or a site on its domain.
1273
+ *
1274
+ * #164: Agora needs a zone for the same reason, the moment `org.domain` is
1275
+ * set -- Agora is always on, so every org with a domain wants its zone even
1276
+ * with hosting and site off (see {@link planTunnel}).
667
1277
  */
668
1278
  function planZone(org, byKey, claimed) {
669
- if (!(org.hosting.usesDomain || org.site.enabled) || !org.domain)
1279
+ const usesDomain = org.hosting.usesDomain ||
1280
+ org.site.enabled ||
1281
+ (org.calls.enabled && org.calls.usesDomain) ||
1282
+ Boolean(org.domain);
1283
+ if (!usesDomain || !org.domain)
670
1284
  return null;
671
1285
  const key = resourceKey('cloudflare', 'zone', org.domain);
672
1286
  const live = byKey.get(key);