@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.
- package/dist/bootstrap/bootstrap.d.ts +111 -0
- package/dist/bootstrap/bootstrap.js +323 -0
- package/dist/bootstrap/declare.d.ts +36 -0
- package/dist/bootstrap/declare.js +99 -0
- package/dist/bootstrap/drive.d.ts +40 -0
- package/dist/bootstrap/drive.js +51 -0
- package/dist/bootstrap/index.d.ts +3 -0
- package/dist/bootstrap/index.js +1 -0
- package/dist/calls-sfu-deploy-support.d.ts +17 -6
- package/dist/calls-sfu-deploy-support.js +19 -7
- package/dist/calls-sfu-keys.d.ts +21 -0
- package/dist/calls-sfu-keys.js +27 -0
- package/dist/calls-sfu-ssh-adapter.d.ts +55 -0
- package/dist/calls-sfu-ssh-adapter.js +78 -0
- package/dist/calls-sfu-ssh-client.d.ts +49 -0
- package/dist/calls-sfu-ssh-client.js +82 -0
- package/dist/calls-sfu.d.ts +36 -12
- package/dist/calls-sfu.js +41 -9
- package/dist/index.d.ts +38 -7
- package/dist/index.js +20 -4
- package/dist/migrations/0016_platform.sql +61 -0
- package/dist/migrations/0017_requested_changes.sql +42 -0
- package/dist/migrations/0018_platform_apply.sql +19 -0
- package/dist/migrations/0019_mail_owner_mailbox.sql +15 -0
- package/dist/migrations/0020_mail_apps.sql +7 -0
- package/dist/migrations/0021_mail_role_mailboxes.sql +6 -0
- package/dist/org.d.ts +81 -0
- package/dist/org.js +96 -0
- package/dist/plan/agora.d.ts +77 -0
- package/dist/plan/agora.js +127 -0
- package/dist/plan/cloudflared-connector.d.ts +70 -0
- package/dist/plan/cloudflared-connector.js +106 -0
- package/dist/plan/diff.d.ts +20 -3
- package/dist/plan/diff.js +713 -115
- package/dist/plan/executor.d.ts +680 -172
- package/dist/plan/executor.js +755 -189
- package/dist/plan/index.d.ts +28 -5
- package/dist/plan/index.js +15 -2
- package/dist/plan/jobs-scheduler-http.d.ts +29 -0
- package/dist/plan/jobs-scheduler-http.js +50 -0
- package/dist/plan/live.d.ts +51 -2
- package/dist/plan/live.js +8 -0
- package/dist/plan/mail-copy.d.ts +125 -0
- package/dist/plan/mail-copy.js +166 -0
- package/dist/plan/mail-identity-allowlist.d.ts +50 -0
- package/dist/plan/mail-identity-allowlist.js +11 -0
- package/dist/plan/mail-identity.d.ts +68 -0
- package/dist/plan/mail-identity.js +17 -0
- package/dist/plan/plan-apply.d.ts +197 -3
- package/dist/plan/plan-apply.js +278 -9
- package/dist/plan/platform-diff.d.ts +487 -0
- package/dist/plan/platform-diff.js +1220 -0
- package/dist/plan/platform-org-ports.d.ts +50 -0
- package/dist/plan/platform-org-ports.js +70 -0
- package/dist/plan/platform-org.d.ts +121 -0
- package/dist/plan/platform-org.js +115 -0
- package/dist/plan/platform-providers.d.ts +615 -0
- package/dist/plan/platform-providers.js +4410 -0
- package/dist/plan/platform-sfu.d.ts +128 -0
- package/dist/plan/platform-sfu.js +142 -0
- package/dist/plan/providers-readers.js +22 -1
- package/dist/plan/zitadel-machine-user.d.ts +83 -0
- package/dist/plan/zitadel-machine-user.js +238 -0
- package/dist/plan.d.ts +6 -0
- package/dist/platform.d.ts +55 -0
- package/dist/platform.js +46 -0
- package/dist/requested-changes.d.ts +118 -0
- package/dist/requested-changes.js +125 -0
- package/dist/slug.js +15 -2
- package/dist/store/audit.js +4 -2
- package/dist/store/postgres.d.ts +29 -1
- package/dist/store/postgres.js +203 -4
- package/dist/store/schema.d.ts +755 -0
- package/dist/store/schema.js +70 -0
- package/dist/store.d.ts +53 -0
- 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`).
|
|
42
|
-
*
|
|
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
|
-
|
|
176
|
-
|
|
177
|
-
|
|
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
|
-
|
|
195
|
-
|
|
196
|
-
:
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
276
|
-
|
|
277
|
-
|
|
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
|
-
|
|
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
|
-
|
|
292
|
-
|
|
293
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
427
|
-
|
|
428
|
-
|
|
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
|
-
|
|
467
|
-
|
|
468
|
-
|
|
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
|
-
|
|
811
|
+
blocked: null,
|
|
492
812
|
step: {
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
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
|
-
|
|
851
|
+
blocked: null,
|
|
517
852
|
step: {
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
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 ||
|
|
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);
|