@wtfalch/foundry 0.6.0 → 0.8.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 (58) hide show
  1. package/dist/bootstrap/bootstrap.d.ts +12 -8
  2. package/dist/bootstrap/bootstrap.js +4 -12
  3. package/dist/bootstrap/declare.d.ts +3 -1
  4. package/dist/bootstrap/declare.js +6 -2
  5. package/dist/bootstrap/versions.d.ts +25 -0
  6. package/dist/bootstrap/versions.js +43 -0
  7. package/dist/calls-sfu-deploy-support.d.ts +1 -1
  8. package/dist/calls-sfu-deploy-support.js +1 -1
  9. package/dist/calls-sfu-ssh-adapter.d.ts +1 -1
  10. package/dist/calls-sfu-ssh-adapter.js +1 -1
  11. package/dist/calls-sfu.d.ts +8 -6
  12. package/dist/calls-sfu.js +9 -6
  13. package/dist/index.d.ts +3 -4
  14. package/dist/index.js +5 -2
  15. package/dist/migrations/0022_chat_enabled.sql +6 -0
  16. package/dist/migrations/0023_hosting_app_env.sql +8 -0
  17. package/dist/migrations/0024_audit_events.sql +211 -0
  18. package/dist/migrations/0025_audit_display.sql +147 -0
  19. package/dist/migrations/0026_audit_erase_email_guard.sql +78 -0
  20. package/dist/migrations/0027_audit_chain.sql +170 -0
  21. package/dist/migrations/0028_audit_rls.sql +111 -0
  22. package/dist/migrations-dir.d.ts +9 -0
  23. package/dist/migrations-dir.js +10 -0
  24. package/dist/org.d.ts +24 -0
  25. package/dist/org.js +13 -0
  26. package/dist/plan/agora.d.ts +21 -0
  27. package/dist/plan/agora.js +23 -0
  28. package/dist/plan/calls-matrix.d.ts +4 -2
  29. package/dist/plan/calls-matrix.js +5 -2
  30. package/dist/plan/cloudflared-connector.d.ts +3 -2
  31. package/dist/plan/cloudflared-connector.js +5 -3
  32. package/dist/plan/diff.d.ts +18 -3
  33. package/dist/plan/diff.js +369 -42
  34. package/dist/plan/executor.d.ts +378 -44
  35. package/dist/plan/executor.js +816 -57
  36. package/dist/plan/index.d.ts +2 -0
  37. package/dist/plan/index.js +1 -0
  38. package/dist/plan/login-host.d.ts +88 -0
  39. package/dist/plan/login-host.js +73 -0
  40. package/dist/plan/plan-apply.d.ts +23 -8
  41. package/dist/plan/plan-apply.js +30 -10
  42. package/dist/plan/platform-diff.d.ts +102 -2
  43. package/dist/plan/platform-diff.js +232 -1
  44. package/dist/plan/platform-providers.d.ts +37 -2
  45. package/dist/plan/platform-providers.js +223 -36
  46. package/dist/plan/providers-readers.js +44 -1
  47. package/dist/plan.d.ts +43 -0
  48. package/dist/plan.js +16 -1
  49. package/dist/store/audit.d.ts +36 -4
  50. package/dist/store/audit.js +110 -29
  51. package/dist/store/postgres.d.ts +14 -0
  52. package/dist/store/postgres.js +17 -9
  53. package/dist/store/schema.d.ts +37 -1
  54. package/dist/store/schema.js +7 -0
  55. package/package.json +10 -5
  56. package/versions.json +8 -0
  57. package/dist/migrate.d.ts +0 -38
  58. package/dist/migrate.js +0 -47
@@ -1,4 +1,5 @@
1
1
  import { generateKeyPairSync, randomBytes } from 'node:crypto';
2
+ import { VERSIONS } from '../bootstrap/versions.js';
2
3
  /**
3
4
  * The calls Matrix stack (#128, part 1 of 2 -- see that issue's "If this
4
5
  * passes about 800 changed lines, split it" note): pure config generation
@@ -46,8 +47,10 @@ import { generateKeyPairSync, randomBytes } from 'node:crypto';
46
47
  * (`executor.ts`'s `callsHostnameFor`).
47
48
  */
48
49
  export const SYNAPSE_IMAGE = 'ghcr.io/element-hq/synapse';
49
- export const SYNAPSE_TAG = 'v1.161.0';
50
- export const MAS_IMAGE = 'ghcr.io/element-hq/matrix-authentication-service:1.24.0';
50
+ export const SYNAPSE_TAG = VERSIONS.synapse;
51
+ export const MAS_IMAGE = `ghcr.io/element-hq/matrix-authentication-service:${VERSIONS.mas}`;
52
+ /** #251: the version baked into {@link MAS_IMAGE}'s `FROM` line, named on its own so a version-change line can read it without re-parsing the image string. */
53
+ export const MAS_TAG = VERSIONS.mas;
51
54
  /**
52
55
  * Never resolved by DNS and never federates (`callsSynapseHomeserverYaml`'s
53
56
  * `federation_domain_whitelist: []`) -- purely a logical identifier MAS and
@@ -2,9 +2,10 @@ import type { CloudflarePort, CoolifyPort } from './executor.js';
2
2
  /**
3
3
  * #197: the pinned `cloudflared` image, the same release tag as
4
4
  * `scripts/shared-ingress.mjs`'s and `calls-sfu.ts`'s own
5
- * `CLOUDFLARED_IMAGE_REF`. A test in each keeps the three equal.
5
+ * `CLOUDFLARED_IMAGE_REF`. A test in each keeps the three equal. Pinned in
6
+ * `versions.json` (#247).
6
7
  */
7
- export declare const CLOUDFLARED_IMAGE_REF = "cloudflare/cloudflared:2026.9.3";
8
+ export declare const CLOUDFLARED_IMAGE_REF: string;
8
9
  /**
9
10
  * The inline Dockerfile a docker-image `cloudflared` app needs, copied from
10
11
  * `scripts/shared-ingress.mjs`'s `cloudflaredDockerfile()`: the published
@@ -1,10 +1,12 @@
1
+ import { VERSIONS } from '../bootstrap/versions.js';
1
2
  import { isNotFoundError } from './executor.js';
2
3
  /**
3
4
  * #197: the pinned `cloudflared` image, the same release tag as
4
5
  * `scripts/shared-ingress.mjs`'s and `calls-sfu.ts`'s own
5
- * `CLOUDFLARED_IMAGE_REF`. A test in each keeps the three equal.
6
+ * `CLOUDFLARED_IMAGE_REF`. A test in each keeps the three equal. Pinned in
7
+ * `versions.json` (#247).
6
8
  */
7
- export const CLOUDFLARED_IMAGE_REF = 'cloudflare/cloudflared:2026.9.3';
9
+ export const CLOUDFLARED_IMAGE_REF = `cloudflare/cloudflared:${VERSIONS.cloudflared}`;
8
10
  /**
9
11
  * The inline Dockerfile a docker-image `cloudflared` app needs, copied from
10
12
  * `scripts/shared-ingress.mjs`'s `cloudflaredDockerfile()`: the published
@@ -72,7 +74,7 @@ export async function ensureCloudflaredConnector(input) {
72
74
  environment_uuid: input.environment.uuid,
73
75
  dockerfile: cloudflaredDockerfile(),
74
76
  name: input.appName,
75
- description: 'cloudflared connector for this platform tunnel (#197)',
77
+ description: 'cloudflared connector for a Cloudflare tunnel',
76
78
  instant_deploy: false,
77
79
  });
78
80
  appId = created.uuid;
@@ -1,5 +1,5 @@
1
1
  import type { DeclaredOrg, OrgId } from '../org.js';
2
- import type { Plan, PlanStep, UnmanagedItem } from '../plan.js';
2
+ import type { Plan, PlanStep, UnmanagedItem, VersionChangeItem } from '../plan.js';
3
3
  import { type AgoraHostReading } from './agora.js';
4
4
  import type { LiveResource } from './live.js';
5
5
  import { type OrgPlatformReadiness } from './platform-org.js';
@@ -26,10 +26,21 @@ export interface PlannedStep {
26
26
  step: PlanStep;
27
27
  action: StepAction;
28
28
  }
29
+ /**
30
+ * Facts about the host's executor ports that an org's plan needs, so a step
31
+ * whose port is not wired is reported blocked instead of planned (rule 2).
32
+ * New ones go here, not in new positional arguments (rule 7).
33
+ */
34
+ export interface PlanOrgOptions {
35
+ /** #267: the executor was built with a `loginHostBindings` port. */
36
+ loginHostBindings?: boolean;
37
+ }
29
38
  export interface PlanDetail {
30
39
  orgId: OrgId;
31
40
  plannedSteps: PlannedStep[];
32
41
  unmanaged: UnmanagedItem[];
42
+ /** #251: see `VersionChangeItem` (`../plan.ts`). Never folded into `plannedSteps`. */
43
+ versionChanges: VersionChangeItem[];
33
44
  }
34
45
  /** The `Plan` (#19's public contract) a `PlanDetail` carries. */
35
46
  export declare function toPlan(detail: PlanDetail): Plan;
@@ -73,7 +84,11 @@ export declare function toPlan(detail: PlanDetail): Plan;
73
84
  * app with the platform's jobs scheduler, idempotent on the *recorded*
74
85
  * resource id like {@link planOwner} rather than a live resource, since
75
86
  * nothing in `live` can report a scheduler registration (no reader exists for
76
- * it). Everything
87
+ * it). #267 adds one more, right after {@link planOwner} and before every
88
+ * app-creating step below: {@link planLoginHost}, the org's own company
89
+ * login host -- also idempotent on the *recorded* resource id, since
90
+ * `live` has no notion of a ZITADEL trusted domain or a login binding
91
+ * either. Everything
77
92
  * else in `live` that none of these eighteen singletons and however many
78
93
  * declared issuer apps claim is unmanaged. The full field-by-field provisioning surface (SSO,
79
94
  * guests, calls on the org's own domain, adopted/bespoke apps, the actual
@@ -81,7 +96,7 @@ export declare function toPlan(detail: PlanDetail): Plan;
81
96
  * Company Factory end to end", which wires real readers and executors
82
97
  * against this seam.
83
98
  */
84
- export declare function computePlanDetail(org: DeclaredOrg, live: LiveResource[], recordedIds?: Readonly<Record<string, string>>, agoraHost?: AgoraHostReading, jobsAvailable?: boolean, platformReadiness?: OrgPlatformReadiness): PlanDetail;
99
+ export declare function computePlanDetail(org: DeclaredOrg, live: LiveResource[], recordedIds?: Readonly<Record<string, string>>, agoraHost?: AgoraHostReading, jobsAvailable?: boolean, platformReadiness?: OrgPlatformReadiness, options?: PlanOrgOptions): PlanDetail;
85
100
  /** Restated from `executor.ts`, the same rule: the id of an ai key minted but not delivered. */
86
101
  export declare const AI_KEY_UNDELIVERED_SCOPE_KEY = "ai_key_undelivered";
87
102
  /** Restated from `executor.ts`, the same rule: the id of a jobs project registered but not delivered. */
package/dist/plan/diff.js CHANGED
@@ -1,6 +1,8 @@
1
- import { NO_AGORA_HOST_VALUES, agoraEnvDrift, agoraHostMissing, desiredAgoraEnv, } from './agora.js';
1
+ import { VERSIONS, floatingTagBlocked } from '../bootstrap/versions.js';
2
+ import { NO_AGORA_HOST_VALUES, ZITADEL_ORG_SCOPE_KEY, agoraEnvDrift, agoraHostMissing, desiredAgoraEnv, } from './agora.js';
3
+ import { LOGIN_BINDING_SCOPE_KEY, loginBindingState, loginHostClientIds, loginHostnameFor, pendingRemovalHosts, } from './login-host.js';
2
4
  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';
5
+ import { APP_ENV_APPLIED_SCOPE_KEY, CALLS_MAS_APPLIED_IMAGE_TAG_SCOPE_KEY, CALLS_SYNAPSE_APPLIED_IMAGE_TAG_SCOPE_KEY, DB_ENV_APPLIED_SCOPE_KEY, ORG_MAIL_APPLIED_APEX_SCOPE_KEY, ORG_MAIL_APPLIED_HOST_SCOPE_KEY, resolveAppEnv, versionNotAppliedLine, } from './platform-diff.js';
4
6
  import { DEFAULT_ORG_PLATFORM_READINESS } from './platform-org.js';
5
7
  /** The `Plan` (#19's public contract) a `PlanDetail` carries. */
6
8
  export function toPlan(detail) {
@@ -8,6 +10,7 @@ export function toPlan(detail) {
8
10
  orgId: detail.orgId,
9
11
  steps: detail.plannedSteps.map((p) => p.step),
10
12
  unmanaged: detail.unmanaged,
13
+ versionChanges: detail.versionChanges,
11
14
  };
12
15
  }
13
16
  function resourceKey(system, kind, key) {
@@ -53,7 +56,11 @@ function resourceKey(system, kind, key) {
53
56
  * app with the platform's jobs scheduler, idempotent on the *recorded*
54
57
  * resource id like {@link planOwner} rather than a live resource, since
55
58
  * nothing in `live` can report a scheduler registration (no reader exists for
56
- * it). Everything
59
+ * it). #267 adds one more, right after {@link planOwner} and before every
60
+ * app-creating step below: {@link planLoginHost}, the org's own company
61
+ * login host -- also idempotent on the *recorded* resource id, since
62
+ * `live` has no notion of a ZITADEL trusted domain or a login binding
63
+ * either. Everything
57
64
  * else in `live` that none of these eighteen singletons and however many
58
65
  * declared issuer apps claim is unmanaged. The full field-by-field provisioning surface (SSO,
59
66
  * guests, calls on the org's own domain, adopted/bespoke apps, the actual
@@ -61,7 +68,7 @@ function resourceKey(system, kind, key) {
61
68
  * Company Factory end to end", which wires real readers and executors
62
69
  * against this seam.
63
70
  */
64
- export function computePlanDetail(org, live, recordedIds = {}, agoraHost = NO_AGORA_HOST_VALUES, jobsAvailable = false, platformReadiness = DEFAULT_ORG_PLATFORM_READINESS) {
71
+ export function computePlanDetail(org, live, recordedIds = {}, agoraHost = NO_AGORA_HOST_VALUES, jobsAvailable = false, platformReadiness = DEFAULT_ORG_PLATFORM_READINESS, options = {}) {
65
72
  const byKey = new Map(live.map((r) => [resourceKey(r.system, r.kind, r.key), r]));
66
73
  const claimed = new Set();
67
74
  const plannedSteps = [];
@@ -71,9 +78,29 @@ export function computePlanDetail(org, live, recordedIds = {}, agoraHost = NO_AG
71
78
  const owner = planOwner(org, recordedIds, platformReadiness.authReady);
72
79
  if (owner)
73
80
  plannedSteps.push(owner);
81
+ // #267: after the ZITADEL org step, before any app's own issuer
82
+ // application. Apps created later in this run are not in the binding yet;
83
+ // the next run replans this step and repoints them.
84
+ const loginHost = planLoginHost(org, recordedIds, platformReadiness.authReady, issuer.step !== null, options.loginHostBindings ?? false);
85
+ if (loginHost.step)
86
+ plannedSteps.push(loginHost.step);
87
+ // Planned ahead of everything that needs the zone live (#238); its own step
88
+ // is still pushed in its usual place below.
89
+ const zone = planZone(org, byKey, claimed);
90
+ const zoneWaiting = zone.blocked !== null;
91
+ const zoneBlocked = [];
74
92
  const mail = planMail(org, byKey, claimed, recordedIds, platformReadiness.mailHost);
75
- if (mail.step)
93
+ // #238: own-domain mail publishes DNS records into the zone, so it waits for
94
+ // an active zone -- blocked, not planned (rule 2).
95
+ if (mail.step && zoneWaiting && org.mail.usesDomain && org.domain) {
96
+ zoneBlocked.push({
97
+ description: `${org.slug}'s mail is blocked, not planned: the zone for ${org.domain} is not active yet`,
98
+ key: 'coolify:mail:zone',
99
+ });
100
+ }
101
+ else if (mail.step) {
76
102
  plannedSteps.push(mail.step);
103
+ }
77
104
  const mailCopy = planMailCopy(org, byKey, recordedIds);
78
105
  if (mailCopy)
79
106
  plannedSteps.push(mailCopy);
@@ -90,6 +117,22 @@ export function computePlanDetail(org, live, recordedIds = {}, agoraHost = NO_AG
90
117
  const callsService = planCallsService(org, byKey, claimed, platformReadiness.callsReady);
91
118
  if (callsService.step)
92
119
  plannedSteps.push(callsService.step);
120
+ // #251: same mechanism as the platform's own version-change lines -- see
121
+ // `planPlatform`'s own block in `platform-diff.ts`. Synapse and MAS never
122
+ // patch a running app's image tag once created, so this is
123
+ // `versionNotAppliedLine`, the same honest wording zitadel's own line
124
+ // uses, never `versionChangeLine`'s "X -> Y (redeploy)" -- nothing here
125
+ // ever performs that redeploy.
126
+ const versionChanges = [];
127
+ if (org.calls.enabled && !calls.blocked) {
128
+ const callsAppLive = Boolean(byKey.get(resourceKey('coolify', 'calls', org.id)));
129
+ const synapseChange = versionNotAppliedLine('synapse', callsAppLive, recordedIds[CALLS_SYNAPSE_APPLIED_IMAGE_TAG_SCOPE_KEY], VERSIONS.synapse);
130
+ if (synapseChange)
131
+ versionChanges.push(synapseChange);
132
+ const masChange = versionNotAppliedLine('mas', callsAppLive, recordedIds[CALLS_MAS_APPLIED_IMAGE_TAG_SCOPE_KEY], VERSIONS.mas);
133
+ if (masChange)
134
+ versionChanges.push(masChange);
135
+ }
93
136
  const filesOrganisation = planFilesOrganisation(org, byKey, claimed, platformReadiness.filesReady, platformReadiness.missingKeys.files, platformReadiness.noKeyReader);
94
137
  if (filesOrganisation.step)
95
138
  plannedSteps.push(filesOrganisation.step);
@@ -135,21 +178,36 @@ export function computePlanDetail(org, live, recordedIds = {}, agoraHost = NO_AG
135
178
  const database = planDatabase(org, byKey, claimed);
136
179
  if (database)
137
180
  plannedSteps.push(database);
138
- const app = planApp(org, byKey, claimed);
139
- if (app)
140
- plannedSteps.push(app);
141
- const tunnel = planTunnel(org, byKey, claimed);
142
- if (tunnel)
181
+ const app = planApp(org, byKey, claimed, recordedIds, plannedSteps);
182
+ if (app.step)
183
+ plannedSteps.push(app.step);
184
+ const tunnel = planTunnel(org, byKey, claimed, recordedIds);
185
+ // #238: a tunnel that is being provisioned waits for an active zone; parking
186
+ // one never does.
187
+ if (tunnel && zoneWaiting && tunnel.action.desired.enabled !== false) {
188
+ zoneBlocked.push({
189
+ description: `${org.slug}'s Cloudflare tunnel is blocked, not planned: the zone for ${org.domain} is not active yet`,
190
+ key: 'cloudflare:tunnel',
191
+ });
192
+ }
193
+ else if (tunnel) {
143
194
  plannedSteps.push(tunnel);
144
- const zone = planZone(org, byKey, claimed);
145
- if (zone)
146
- plannedSteps.push(zone);
195
+ }
196
+ if (zone.step)
197
+ plannedSteps.push(zone.step);
147
198
  // Right after the zone (#43): `cloudflareRateLimit` resolves the zone by
148
199
  // apex domain, so the zone step above must already have run (or the zone
149
200
  // must already exist live) by the time this one's action executes.
150
201
  const rateLimit = planRateLimit(org, recordedIds);
151
- if (rateLimit)
202
+ if (rateLimit && zoneWaiting) {
203
+ zoneBlocked.push({
204
+ description: `${org.slug}'s sign-in rate limit is blocked, not planned: the zone for ${org.domain} is not active yet`,
205
+ key: 'cloudflare:ratelimit',
206
+ });
207
+ }
208
+ else if (rateLimit) {
152
209
  plannedSteps.push(rateLimit);
210
+ }
153
211
  // After the apps: the customers org is granted the project the first app
154
212
  // step creates.
155
213
  const customers = planCustomers(org, live, claimed, recordedIds);
@@ -199,6 +257,10 @@ export function computePlanDetail(org, live, recordedIds = {}, agoraHost = NO_AG
199
257
  // budget, key) would otherwise also have nothing to act on.
200
258
  if (issuer.blocked)
201
259
  unmanaged.push({ description: issuer.blocked, key: 'zitadel:issuer' });
260
+ // #267 (rule 2): a login host with no ZITADEL org to trust it in is blocked, not planned to throw.
261
+ if (loginHost.blocked) {
262
+ unmanaged.push({ description: loginHost.blocked, key: 'zitadel:login-host' });
263
+ }
202
264
  if (mail.blocked)
203
265
  unmanaged.push({ description: mail.blocked, key: 'coolify:mail' });
204
266
  // #162: reported separately from `mail.blocked` -- missing an owner
@@ -218,7 +280,14 @@ export function computePlanDetail(org, live, recordedIds = {}, agoraHost = NO_AG
218
280
  unmanaged.push({ description: storage.blocked, key: 'files:storage' });
219
281
  if (aiBudget.blocked)
220
282
  unmanaged.push({ description: aiBudget.blocked, key: 'ai:budget' });
221
- return { orgId: org.id, plannedSteps, unmanaged };
283
+ // #238: a pending zone, reported here with its nameservers rather than
284
+ // silently treated as converged.
285
+ if (zone.blocked)
286
+ unmanaged.push({ description: zone.blocked, key: 'cloudflare:zone' });
287
+ unmanaged.push(...zoneBlocked);
288
+ // #243: an appEnv source nothing will record is reported, not silently skipped.
289
+ unmanaged.push(...app.blocked);
290
+ return { orgId: org.id, plannedSteps, unmanaged, versionChanges };
222
291
  }
223
292
  /**
224
293
  * The org's own ZITADEL org (issuer), a per-org singleton like every other
@@ -355,6 +424,92 @@ function planOwner(org, recordedIds, authReady) {
355
424
  },
356
425
  };
357
426
  }
427
+ /**
428
+ * #267: the org's own company login host (`login-host.ts`'s
429
+ * `loginHostnameFor`) -- `auth.<domain>` once the org has one,
430
+ * `<slug>-auth.oikos.host` for tier 1. Idempotent on the *recorded*
431
+ * resource id, the same shape `planOwner` above uses and for the same
432
+ * reason: `ZitadelReader.listResources` has no notion of a trusted domain
433
+ * or a login binding, so there is nothing in `live` this step could ever
434
+ * claim or compare against.
435
+ *
436
+ * Unlike `planOwner`, a re-run is not simply "already done or not": the
437
+ * recorded value is compared against {@link loginHostnameFor}'s own value
438
+ * for *this* apply, so an org moving from tier 1 onto its own domain (or
439
+ * losing one) replans this step rather than staying silent forever once
440
+ * first applied -- `executor.ts`'s `zitadelLoginHost` then also moves every
441
+ * already-live app onto the new base URI, and (#274) removes the *old* host
442
+ * from ZITADEL's trusted domains and zitadel-login's binding, reading it
443
+ * back from this same recorded value before overwriting it -- no separate
444
+ * action field or planning step of its own needed for that half. Mints
445
+ * nothing either way: adding a trusted domain is not a one-time secret, so
446
+ * there is no "undelivered"
447
+ * guard to check the way `planJobsRegistration`'s own does.
448
+ *
449
+ * Ordered right after `planOwner` (not after mail, chat, or any hosting
450
+ * step), for the same reason `planOwner`'s own doc comment gives: on a
451
+ * first-ever apply the ZITADEL org this step's executor needs
452
+ * (`context.recordedIds().zitadel_org`) has already been recorded by the
453
+ * issuer step two steps before it in the same run, and this step itself
454
+ * runs well before `planIssuerApps`/`planCmsClient`/`planAgoraClient`
455
+ * create any app under the org's project -- #267's own "before any app's
456
+ * issuer application".
457
+ */
458
+ function planLoginHost(org, recordedIds, authReady, issuerPlanned, bindingsWired) {
459
+ const none = { step: null, blocked: null };
460
+ const key = resourceKey('zitadel', 'login-host', org.id);
461
+ const host = loginHostnameFor(org);
462
+ // Already applied -- a re-run plans nothing unless the host changed, an app
463
+ // client id appeared since the binding was last written (#267, rule 4), or
464
+ // an earlier host change still has a removal owed (#274 fix round 2):
465
+ // `zitadelLoginHost` always advances the recorded host to the current one,
466
+ // so a pending removal is the only way to know one is still owed, and
467
+ // nothing else here would replan the step to retry it.
468
+ if (recordedIds[key] === host &&
469
+ recordedIds[LOGIN_BINDING_SCOPE_KEY] ===
470
+ loginBindingState(host, loginHostClientIds(recordedIds)) &&
471
+ pendingRemovalHosts(recordedIds).length === 0) {
472
+ return none;
473
+ }
474
+ // #177: nothing to add a trusted domain to yet -- `planIssuer` already
475
+ // reports the platform's sign-in as blocked, so this step stays silent
476
+ // rather than repeating it, the same rule `planOwner` follows.
477
+ if (!authReady)
478
+ return none;
479
+ // Isolation between orgs rests on zitadel-login's binding, and repointing
480
+ // an app at a host with no binding breaks its sign-in: trust nothing and
481
+ // repoint nothing until the port that writes the binding is wired.
482
+ if (!bindingsWired) {
483
+ return {
484
+ step: null,
485
+ blocked: `${org.slug}'s login host ${host} waits for the loginHostBindings port, which the host has not wired`,
486
+ };
487
+ }
488
+ // No ZITADEL org to trust the host in: wait for it, unless the issuer step
489
+ // in this very plan creates it first (it runs before this one).
490
+ if (!recordedIds[ZITADEL_ORG_SCOPE_KEY] && !issuerPlanned) {
491
+ return {
492
+ step: null,
493
+ blocked: `${org.slug}'s login host ${host} waits for its ZITADEL org, which is not recorded yet`,
494
+ };
495
+ }
496
+ return {
497
+ step: {
498
+ action: {
499
+ system: 'zitadel',
500
+ kind: 'login-host',
501
+ key: org.id,
502
+ desired: { host },
503
+ },
504
+ step: {
505
+ description: `set ${org.slug}'s login host to ${host}`,
506
+ destroys: false,
507
+ migrates: false,
508
+ },
509
+ },
510
+ blocked: null,
511
+ };
512
+ }
358
513
  /**
359
514
  * #162: the flat scope id `executor.ts`'s `ensureOwnerMailbox` records once
360
515
  * it has given the org's owner a mailbox (`MAIL_OWNER_MAILBOX_SCOPE_KEY`
@@ -364,6 +519,10 @@ function planOwner(org, recordedIds, authReady) {
364
519
  const MAIL_OWNER_MAILBOX_SCOPE_KEY = 'mail_owner_mailbox';
365
520
  /** #230: restated from `executor.ts`'s `MAIL_ROLE_MAILBOXES_SCOPE_KEY`, same rule: comma-joined local parts already created. */
366
521
  const MAIL_ROLE_MAILBOXES_SCOPE_KEY = 'mail_role_mailboxes';
522
+ /** Restated from `executor.ts`, the same rule: the app id of the org tunnel's cloudflared connector. */
523
+ const CLOUDFLARE_TUNNEL_CONNECTOR_APP_SCOPE_KEY = 'cloudflare_tunnel_connector_app';
524
+ /** #236: restated from `executor.ts`, the same rule: the domain the org tunnel's DNS was last published under. */
525
+ const CLOUDFLARE_TUNNEL_DNS_DOMAIN_SCOPE_KEY = 'cloudflare_tunnel_dns_domain';
367
526
  /** Restated from `executor.ts`, the same rule: the mail domain the last apply resolved. */
368
527
  const MAIL_DOMAIN_SCOPE_KEY = 'mail_domain';
369
528
  /** Restated from `executor.ts`, the same rule: the id of an ai key minted but not delivered. */
@@ -546,6 +705,10 @@ function planMailCopy(org, byKey, recordedIds) {
546
705
  };
547
706
  }
548
707
  function planChat(org, byKey, claimed) {
708
+ // Chat off (not in v1): plan nothing, and leave any live chat unclaimed so
709
+ // it is reported in `unmanaged` and never adopted or destroyed.
710
+ if (!org.chat.enabled)
711
+ return null;
549
712
  const key = resourceKey('coolify', 'chat', org.id);
550
713
  const live = byKey.get(key);
551
714
  if (live)
@@ -620,6 +783,26 @@ function planCalls(org, byKey, claimed, callsReady) {
620
783
  blocked: `${org.slug}'s calls Matrix stack is blocked, not planned: the platform's shared calls SFU is off`,
621
784
  };
622
785
  }
786
+ // #248: the two images this stack's own apply would run, checked the same
787
+ // way the platform's SFU checks LiveKit/cloudflared and ZITADEL checks its
788
+ // own tag -- a re-apply of the same declaration must not silently run a
789
+ // different version with no diff shown anywhere.
790
+ if (org.calls.enabled && !byKey.get(key)) {
791
+ const synapseBlocked = floatingTagBlocked('Synapse', VERSIONS.synapse, 'v1.161.0');
792
+ if (synapseBlocked) {
793
+ return {
794
+ step: null,
795
+ blocked: `${org.slug}'s calls Matrix stack is blocked, not planned: ${synapseBlocked}`,
796
+ };
797
+ }
798
+ const masBlocked = floatingTagBlocked('matrix-authentication-service', VERSIONS.mas, '1.24.0');
799
+ if (masBlocked) {
800
+ return {
801
+ step: null,
802
+ blocked: `${org.slug}'s calls Matrix stack is blocked, not planned: ${masBlocked}`,
803
+ };
804
+ }
805
+ }
623
806
  return {
624
807
  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
808
  blocked: null,
@@ -1203,23 +1386,103 @@ function planHostingToggle(system, kind, desiredEnabled, org, byKey, claimed, de
1203
1386
  * resource's `enabled` field -- a live resource nothing has actually
1204
1387
  * checked yet is assumed fine rather than replanned every time.
1205
1388
  */
1206
- function planApp(org, byKey, claimed) {
1389
+ function planApp(org, byKey, claimed, recordedIds, plannedSteps) {
1390
+ // #243: an `appEnv` source with no recorded value and no step planned to
1391
+ // record one this run has nothing to push, now or after this apply.
1392
+ const sourceStep = {
1393
+ filesOrganisationId: ['files', 'organisation'],
1394
+ aiOrganisationId: ['ai', 'organisation'],
1395
+ mailDomain: ['coolify', 'mail'],
1396
+ };
1397
+ const { values: wantedEnv, unresolved } = resolveAppEnv(org, recordedIds);
1398
+ const blocked = org.hosting.enabled
1399
+ ? unresolved
1400
+ .filter(([, source]) => !plannedSteps.some((p) => p.action.system === sourceStep[source][0] &&
1401
+ p.action.kind === sourceStep[source][1]))
1402
+ .map(([name, source]) => ({
1403
+ description: `${org.slug}'s app env ${name} is blocked, not planned: its source ${source} has no recorded value yet`,
1404
+ key: `coolify:app:env:${name}`,
1405
+ }))
1406
+ : [];
1207
1407
  const toggled = planHostingToggle('coolify', 'app', org.hosting.enabled, org, byKey, claimed, () => `provision ${org.slug}'s app`, () => `park ${org.slug}'s app (stops serving; app data is retained, not deleted -- #7 corrections 1)`);
1208
1408
  if (toggled)
1209
- return toggled;
1409
+ return { step: toggled, blocked };
1210
1410
  if (!org.hosting.enabled)
1211
- return null;
1411
+ return { step: null, blocked };
1212
1412
  const live = byKey.get(resourceKey('coolify', 'app', org.id));
1213
- if (!live || live.config.hasLogVolume !== false)
1214
- return null;
1215
- return {
1216
- action: { system: 'coolify', kind: 'app', key: org.id, desired: { enabled: true } },
1217
- step: {
1218
- description: `add a durable log directory to ${org.slug}'s app (persistent volume at /app/logs + LOG_DIR) so a database outage does not erase the evidence (#75)`,
1219
- destroys: false,
1220
- migrates: false,
1221
- },
1413
+ if (!live)
1414
+ return { step: null, blocked };
1415
+ const action = {
1416
+ system: 'coolify',
1417
+ kind: 'app',
1418
+ key: org.id,
1419
+ desired: { enabled: true },
1222
1420
  };
1421
+ if (live.config.hasLogVolume === false) {
1422
+ return {
1423
+ step: {
1424
+ action,
1425
+ step: {
1426
+ description: `add a durable log directory to ${org.slug}'s app (persistent volume at /app/logs + LOG_DIR) so a database outage does not erase the evidence (#75)`,
1427
+ destroys: false,
1428
+ migrates: false,
1429
+ },
1430
+ },
1431
+ blocked,
1432
+ };
1433
+ }
1434
+ // #239 (rule 4), from live state: an app with a database but no
1435
+ // `DATABASE_URL_OWNER` env still has the pre-split superuser URL, or never
1436
+ // got the push. Names only, never values. A reader that did not list env
1437
+ // names (`envNames` absent) reads as compliant, like `hasLogVolume`.
1438
+ const envNames = live.config.envNames;
1439
+ const dbLive = byKey.get(resourceKey('coolify', 'database', org.id));
1440
+ const dbReady = org.hosting.database && Boolean(recordedIds[`coolify:database:${org.id}`]);
1441
+ if (dbReady && dbLive?.config.hasInternalUrl === false) {
1442
+ // Rule 2: the push cannot succeed yet, so report it rather than replan
1443
+ // forever.
1444
+ blocked.push({
1445
+ description: `${org.slug}'s app database URLs are blocked, not planned: the database reports no internal connection URL yet`,
1446
+ key: 'coolify:app:database-url',
1447
+ });
1448
+ }
1449
+ else if (dbReady &&
1450
+ // Only a read that saw the URL replans: an unread database (a failed
1451
+ // read) would plan a step that pushes nothing, forever.
1452
+ dbLive?.config.hasInternalUrl === true &&
1453
+ ((Array.isArray(envNames) && !envNames.includes('DATABASE_URL_OWNER')) ||
1454
+ // A crash between the push and the restart leaves the env right and no
1455
+ // marker: replan once more to restart and set it.
1456
+ !recordedIds[DB_ENV_APPLIED_SCOPE_KEY])) {
1457
+ return {
1458
+ step: {
1459
+ action,
1460
+ step: {
1461
+ description: `give ${org.slug}'s app its owner and runtime database URLs and restart it (#239)`,
1462
+ destroys: false,
1463
+ migrates: false,
1464
+ },
1465
+ },
1466
+ blocked,
1467
+ };
1468
+ }
1469
+ // #243 (rule 3): a changed `hosting.appEnv` reaches an app that is already
1470
+ // live. `pushAppEnv` records what it pushed, so this plans the same step
1471
+ // again, with a redeploy, only once the resolved map differs from that.
1472
+ if (JSON.stringify(wantedEnv) !== (recordedIds[APP_ENV_APPLIED_SCOPE_KEY] ?? '{}')) {
1473
+ return {
1474
+ step: {
1475
+ action,
1476
+ step: {
1477
+ description: `push ${org.slug}'s changed app env (appEnv) and redeploy the app`,
1478
+ destroys: false,
1479
+ migrates: false,
1480
+ },
1481
+ },
1482
+ blocked,
1483
+ };
1484
+ }
1485
+ return { step: null, blocked };
1223
1486
  }
1224
1487
  /**
1225
1488
  * #173: register the org's own app with the platform's jobs scheduler
@@ -1327,12 +1590,61 @@ function planDatabase(org, byKey, claimed) {
1327
1590
  * domain) plans no per-org tunnel for Agora: `<slug>-agora.oikos.host` rides
1328
1591
  * the shared `*.oikos.host` wildcard tunnel (`scripts/shared-ingress.mjs`).
1329
1592
  */
1330
- function planTunnel(org, byKey, claimed) {
1593
+ function planTunnel(org, byKey, claimed, recordedIds) {
1331
1594
  const desired = (org.hosting.tunnel && org.hosting.usesDomain && Boolean(org.domain)) ||
1332
1595
  (org.site.enabled && Boolean(org.domain)) ||
1333
1596
  (org.calls.enabled && org.calls.usesDomain && Boolean(org.domain)) ||
1334
1597
  Boolean(org.domain);
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)`);
1598
+ const toggled = planHostingToggle('cloudflare', 'tunnel', desired, org, byKey, claimed, () => `provision a Cloudflare tunnel for ${org.slug}'s app`, () => `park ${org.slug}'s Cloudflare tunnel (configuration is retained, not deleted -- #7 corrections 1)`);
1599
+ if (toggled || !desired)
1600
+ return toggled;
1601
+ // A live, enabled tunnel re-plans the same step again when either:
1602
+ // #235, no connector recorded -- a first connector create that failed, or
1603
+ // a tunnel that predates connectors; or #236, its DNS was last published
1604
+ // under a domain other than the one now declared -- never published at
1605
+ // all (unset), or the org's domain has since changed. `org.domain` is set
1606
+ // whenever `desired` is true above, in every branch. The step itself
1607
+ // (`executor.ts`'s `cloudflareTunnel`) finds or creates the connector by
1608
+ // name and upserts DNS idempotently (and, on a domain change, cleans up
1609
+ // the old one once the new one is published -- round 3: publishing first
1610
+ // means a conflict on the new domain leaves the old one still live rather
1611
+ // than taking the org dark), so re-running it never duplicates either.
1612
+ const live = byKey.get(resourceKey('cloudflare', 'tunnel', org.id));
1613
+ if (!live)
1614
+ return null;
1615
+ const connectorMissing = !recordedIds[CLOUDFLARE_TUNNEL_CONNECTOR_APP_SCOPE_KEY];
1616
+ const dnsStale = recordedIds[CLOUDFLARE_TUNNEL_DNS_DOMAIN_SCOPE_KEY] !== org.domain;
1617
+ if (!connectorMissing && !dnsStale)
1618
+ return null;
1619
+ return {
1620
+ action: { system: 'cloudflare', kind: 'tunnel', key: org.id, desired: { enabled: true } },
1621
+ step: {
1622
+ description: connectorMissing
1623
+ ? `run a cloudflared connector for ${org.slug}'s Cloudflare tunnel`
1624
+ : `point ${org.slug}'s hostnames at its Cloudflare tunnel`,
1625
+ destroys: false,
1626
+ migrates: false,
1627
+ },
1628
+ };
1629
+ }
1630
+ /**
1631
+ * #238: the org's own zone's live `status`/`name_servers`, read from the
1632
+ * same `kind: 'zone'` {@link LiveResource} `planZone` already matches by key
1633
+ * -- `@wtfalch/providers/cloudflare`'s `CloudflareClient.listResources`
1634
+ * already returns both (`config: { id, status, name_servers }`), so nothing
1635
+ * new has to be fetched; this just reads what was already there. `null`
1636
+ * means no zone is live yet at all (not a readiness problem `planZone`
1637
+ * itself reports -- it is still planning the zone's own creation).
1638
+ */
1639
+ function zoneReadiness(domain, byKey) {
1640
+ const zone = byKey.get(resourceKey('cloudflare', 'zone', domain));
1641
+ if (!zone)
1642
+ return null;
1643
+ const config = zone.config;
1644
+ return {
1645
+ active: config.status === 'active',
1646
+ nameServers: config.name_servers ?? [],
1647
+ };
1336
1648
  }
1337
1649
  /**
1338
1650
  * The org's Cloudflare zone for its own domain (`org.ts`'s
@@ -1364,25 +1676,40 @@ function planZone(org, byKey, claimed) {
1364
1676
  (org.calls.enabled && org.calls.usesDomain) ||
1365
1677
  Boolean(org.domain);
1366
1678
  if (!usesDomain || !org.domain)
1367
- return null;
1679
+ return { step: null, blocked: null };
1368
1680
  const key = resourceKey('cloudflare', 'zone', org.domain);
1369
1681
  const live = byKey.get(key);
1370
- if (live)
1682
+ if (live) {
1371
1683
  claimed.add(key);
1372
- if (live)
1373
- return null;
1684
+ const readiness = zoneReadiness(org.domain, byKey);
1685
+ // #238: a zone stays pending until the customer changes nameservers at
1686
+ // their registrar -- reported here, not silently treated as converged,
1687
+ // so an apply says what it is waiting for. A later apply, once
1688
+ // Cloudflare marks the zone active, reports nothing and the DNS- and
1689
+ // mail-dependent steps below (`planMail`) resume.
1690
+ if (readiness && !readiness.active) {
1691
+ return {
1692
+ step: null,
1693
+ blocked: `${org.slug}'s zone for ${org.domain} is waiting for nameservers: ${readiness.nameServers.join(', ')}`,
1694
+ };
1695
+ }
1696
+ return { step: null, blocked: null };
1697
+ }
1374
1698
  return {
1375
- action: {
1376
- system: 'cloudflare',
1377
- kind: 'zone',
1378
- key: org.domain,
1379
- desired: { domain: org.domain },
1380
- },
1381
1699
  step: {
1382
- description: `provision a Cloudflare zone for ${org.domain}`,
1383
- destroys: false,
1384
- migrates: false,
1700
+ action: {
1701
+ system: 'cloudflare',
1702
+ kind: 'zone',
1703
+ key: org.domain,
1704
+ desired: { domain: org.domain },
1705
+ },
1706
+ step: {
1707
+ description: `provision a Cloudflare zone for ${org.domain}`,
1708
+ destroys: false,
1709
+ migrates: false,
1710
+ },
1385
1711
  },
1712
+ blocked: null,
1386
1713
  };
1387
1714
  }
1388
1715
  /**