@wtfalch/foundry 0.7.1 → 0.9.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 (54) hide show
  1. package/dist/bootstrap/bootstrap.d.ts +12 -8
  2. package/dist/bootstrap/bootstrap.js +4 -12
  3. package/dist/bootstrap/declare.js +1 -5
  4. package/dist/bootstrap/versions.d.ts +8 -0
  5. package/dist/bootstrap/versions.js +31 -0
  6. package/dist/calls-sfu-deploy-support.d.ts +1 -1
  7. package/dist/calls-sfu-deploy-support.js +1 -1
  8. package/dist/calls-sfu-ssh-adapter.d.ts +1 -1
  9. package/dist/calls-sfu-ssh-adapter.js +1 -1
  10. package/dist/calls-sfu.d.ts +5 -4
  11. package/dist/calls-sfu.js +5 -4
  12. package/dist/index.d.ts +4 -5
  13. package/dist/index.js +6 -3
  14. package/dist/migrations/0024_audit_events.sql +1 -1
  15. package/dist/migrations/0025_audit_display.sql +1 -1
  16. package/dist/migrations/0026_audit_erase_email_guard.sql +1 -1
  17. package/dist/migrations/0027_audit_chain.sql +1 -1
  18. package/dist/migrations/0028_audit_rls.sql +2 -2
  19. package/dist/migrations/0029_hosting_entries.sql +109 -0
  20. package/dist/migrations-dir.d.ts +9 -0
  21. package/dist/migrations-dir.js +10 -0
  22. package/dist/org.d.ts +59 -27
  23. package/dist/org.js +98 -26
  24. package/dist/plan/calls-matrix.d.ts +2 -0
  25. package/dist/plan/calls-matrix.js +2 -0
  26. package/dist/plan/diff.d.ts +3 -1
  27. package/dist/plan/diff.js +100 -32
  28. package/dist/plan/executor.d.ts +324 -19
  29. package/dist/plan/executor.js +764 -34
  30. package/dist/plan/index.d.ts +1 -1
  31. package/dist/plan/index.js +1 -1
  32. package/dist/plan/login-host.d.ts +24 -0
  33. package/dist/plan/login-host.js +21 -0
  34. package/dist/plan/plan-apply.d.ts +2 -0
  35. package/dist/plan/plan-apply.js +26 -10
  36. package/dist/plan/platform-diff.d.ts +102 -12
  37. package/dist/plan/platform-diff.js +236 -15
  38. package/dist/plan/platform-providers.d.ts +43 -1
  39. package/dist/plan/platform-providers.js +245 -33
  40. package/dist/plan/zitadel-system-api.d.ts +64 -0
  41. package/dist/plan/zitadel-system-api.js +125 -0
  42. package/dist/plan.d.ts +43 -0
  43. package/dist/plan.js +16 -1
  44. package/dist/platform.d.ts +1 -1
  45. package/dist/store/audit.d.ts +13 -17
  46. package/dist/store/audit.js +14 -18
  47. package/dist/store/postgres.d.ts +14 -0
  48. package/dist/store/postgres.js +15 -18
  49. package/dist/store/schema.d.ts +20 -1
  50. package/dist/store/schema.js +7 -1
  51. package/package.json +6 -3
  52. package/versions.json +1 -1
  53. package/dist/migrate.d.ts +0 -38
  54. package/dist/migrate.js +0 -47
package/dist/org.js CHANGED
@@ -2,6 +2,12 @@
2
2
  export const DEFAULT_MAIL_APPS = ['agora', 'webmail'];
3
3
  /** {@link MailConfig.noReplyLocalPart}'s own default, restated wherever it is read rather than imported (this file's `LOG_DIR`-style convention, named in `plan/executor.ts`'s own doc comments). */
4
4
  export const DEFAULT_NO_REPLY_LOCAL_PART = 'no-reply';
5
+ /** The key of the entry that is today's one app: the Coolify app named `<slug>`. */
6
+ export const APP_ENTRY_KEY = 'app';
7
+ /** The org's `app` entry: today's one app. */
8
+ export function appEntry(hosting) {
9
+ return hosting.entries.find((entry) => entry.key === APP_ENTRY_KEY);
10
+ }
5
11
  export const APP_ENV_SOURCES = new Set([
6
12
  'filesOrganisationId',
7
13
  'aiOrganisationId',
@@ -18,34 +24,18 @@ const HOSTING_BUILD_PACKS = new Set([
18
24
  * `null` when `hosting` is valid; otherwise the reason it is not (#71), same
19
25
  * shape as `validateBuyerIdentity` below.
20
26
  *
21
- * - `database` and `tunnel` each require `enabled` -- neither means anything
22
- * without the app itself running.
23
- * - `gitBranch` is non-empty and `buildPack` is one of `HostingBuildPack`'s
24
- * four values.
27
+ * - `tunnel` requires `enabled` -- it means nothing without the apps running.
25
28
  * - Every `issuerApps[]` entry has a non-empty `name`, unique within the
26
29
  * list, and a `kind` that is one of `IssuerAppKind`'s three values.
30
+ * - `enabled` requires an entry keyed {@link APP_ENTRY_KEY}.
31
+ * - Every `entries[]` entry follows {@link validateHostingEntry}; keys are
32
+ * unique, and at most one entry has a `database`, because the one
33
+ * `<slug>_rt` runtime role cannot serve two apps.
27
34
  */
28
35
  export function validateHosting(hosting) {
29
- if (hosting.database && !hosting.enabled) {
30
- return 'hosting.database requires hosting.enabled';
31
- }
32
36
  if (hosting.tunnel && !hosting.enabled) {
33
37
  return 'hosting.tunnel requires hosting.enabled';
34
38
  }
35
- if (!hosting.gitBranch) {
36
- return 'hosting.gitBranch must be non-empty';
37
- }
38
- if (!HOSTING_BUILD_PACKS.has(hosting.buildPack)) {
39
- return `hosting.buildPack has an unknown value '${hosting.buildPack}'`;
40
- }
41
- if (hosting.accessEmails.length > 0 && !hosting.enabled) {
42
- return 'hosting.accessEmails requires hosting.enabled';
43
- }
44
- for (const email of hosting.accessEmails) {
45
- if (!/^[^@\s]+@[^@\s]+$/.test(email)) {
46
- return `hosting.accessEmails has an invalid email '${email}'`;
47
- }
48
- }
49
39
  const seenNames = new Set();
50
40
  for (const app of hosting.issuerApps) {
51
41
  if (!app.name) {
@@ -67,16 +57,98 @@ export function validateHosting(hosting) {
67
57
  return `issuerApps '${app.name}' is reserved for the always-on Agora client every org gets`;
68
58
  }
69
59
  }
70
- for (const [envName, source] of Object.entries(hosting.appEnv)) {
71
- if (!envName) {
72
- return 'every appEnv key needs a non-empty env var name';
60
+ if (hosting.enabled && !appEntry(hosting)) {
61
+ return `hosting.enabled requires an entry keyed '${APP_ENTRY_KEY}'`;
62
+ }
63
+ const seenKeys = new Set();
64
+ for (const entry of hosting.entries) {
65
+ if (seenKeys.has(entry.key)) {
66
+ return `hosting.entries key '${entry.key}' is not unique`;
67
+ }
68
+ seenKeys.add(entry.key);
69
+ const invalid = validateHostingEntry(entry, hosting.enabled);
70
+ if (invalid)
71
+ return invalid;
72
+ }
73
+ if (hosting.entries.filter((entry) => entry.database).length > 1) {
74
+ return 'at most one hosting entry may set database: the one runtime database role cannot serve two apps';
75
+ }
76
+ return null;
77
+ }
78
+ const ENTRY_KEY = /^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$/;
79
+ const REPO = /^wtfalch\/[A-Za-z0-9._-]+$/;
80
+ // The env names `pushAppEnv` and `pushDatabaseUrl` set themselves.
81
+ const FOUNDRY_ENV_NAME = /^(APP_URL|LOG_DIR|OWNER_USER_ID|AUTH_.*|DATABASE_URL.*)$/;
82
+ const SECRET_ENV_NAME = /_(KEY|SECRET|TOKEN|PASSWORD)$/;
83
+ const HELD_SECRETS = 'held secrets are not available yet';
84
+ /** The rules for one {@link HostingEntry}; `null` when it is valid. */
85
+ function validateHostingEntry(entry, enabled) {
86
+ const at = `hosting.entries '${entry.key}'`;
87
+ if (!ENTRY_KEY.test(entry.key)) {
88
+ return `hosting.entries key '${entry.key}' must be a DNS label (lowercase letters, digits and hyphens, 1-63 characters)`;
89
+ }
90
+ if (entry.repo !== null && !REPO.test(entry.repo)) {
91
+ return `${at} repo must be a repo under wtfalch, or null`;
92
+ }
93
+ if (entry.folder !== null && (entry.folder.startsWith('/') || entry.folder.includes('..'))) {
94
+ return `${at} folder must be a relative path inside the repo`;
95
+ }
96
+ if (!entry.branch) {
97
+ return `${at} branch must be non-empty`;
98
+ }
99
+ if (!HOSTING_BUILD_PACKS.has(entry.buildPack)) {
100
+ return `${at} buildPack has an unknown value '${entry.buildPack}'`;
101
+ }
102
+ if (entry.buildPack === 'dockercompose') {
103
+ return `${at} buildPack dockercompose is not available for an entry`;
104
+ }
105
+ if (entry.port !== null &&
106
+ !(Number.isInteger(entry.port) && entry.port >= 1 && entry.port <= 65535)) {
107
+ return `${at} port must be a whole number from 1 to 65535, or null`;
108
+ }
109
+ if (entry.healthPath !== null && !entry.healthPath.startsWith('/')) {
110
+ return `${at} healthPath must start with '/', or be null`;
111
+ }
112
+ if (entry.database && !enabled) {
113
+ return `${at} database requires hosting.enabled`;
114
+ }
115
+ if (entry.accessEmails.length > 0 && !enabled) {
116
+ return `${at} accessEmails requires hosting.enabled`;
117
+ }
118
+ if (entry.accessEmails.length > 0 && entry.key !== APP_ENTRY_KEY) {
119
+ return `${at} accessEmails is only available on the '${APP_ENTRY_KEY}' entry for now`;
120
+ }
121
+ for (const email of entry.accessEmails) {
122
+ if (!/^[^@\s]+@[^@\s]+$/.test(email)) {
123
+ return `${at} accessEmails has an invalid email '${email}'`;
124
+ }
125
+ }
126
+ for (const [name, value] of Object.entries(entry.env)) {
127
+ if (!name) {
128
+ return `${at} env needs a non-empty env var name`;
129
+ }
130
+ if (FOUNDRY_ENV_NAME.test(name)) {
131
+ return `${at} env '${name}' is set by foundry and cannot be declared`;
132
+ }
133
+ if (typeof value !== 'string') {
134
+ if (!APP_ENV_SOURCES.has(value?.from)) {
135
+ return `${at} env '${name}' has an unknown source '${value?.from}'`;
136
+ }
73
137
  }
74
- if (!APP_ENV_SOURCES.has(source)) {
75
- return `appEnv '${envName}' has an unknown source '${source}'`;
138
+ else if (SECRET_ENV_NAME.test(name) || (name.endsWith('_URL') && urlHasPassword(value))) {
139
+ return `${at} env '${name}' looks like a secret: ${HELD_SECRETS}`;
76
140
  }
77
141
  }
78
142
  return null;
79
143
  }
144
+ function urlHasPassword(value) {
145
+ try {
146
+ return new URL(value).password !== '';
147
+ }
148
+ catch {
149
+ return false;
150
+ }
151
+ }
80
152
  /**
81
153
  * A DNS-label-per-segment format check for `DeclaredOrg.domain` (#131): every
82
154
  * dot-separated segment follows the exact rule `../slug.ts`'s `DNS_LABEL`
@@ -47,6 +47,8 @@
47
47
  export declare const SYNAPSE_IMAGE = "ghcr.io/element-hq/synapse";
48
48
  export declare const SYNAPSE_TAG: string;
49
49
  export declare const MAS_IMAGE: string;
50
+ /** #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. */
51
+ export declare const MAS_TAG: string;
50
52
  /**
51
53
  * Never resolved by DNS and never federates (`callsSynapseHomeserverYaml`'s
52
54
  * `federation_domain_whitelist: []`) -- purely a logical identifier MAS and
@@ -49,6 +49,8 @@ import { VERSIONS } from '../bootstrap/versions.js';
49
49
  export const SYNAPSE_IMAGE = 'ghcr.io/element-hq/synapse';
50
50
  export const SYNAPSE_TAG = VERSIONS.synapse;
51
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;
52
54
  /**
53
55
  * Never resolved by DNS and never federates (`callsSynapseHomeserverYaml`'s
54
56
  * `federation_domain_whitelist: []`) -- purely a logical identifier MAS and
@@ -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';
@@ -39,6 +39,8 @@ export interface PlanDetail {
39
39
  orgId: OrgId;
40
40
  plannedSteps: PlannedStep[];
41
41
  unmanaged: UnmanagedItem[];
42
+ /** #251: see `VersionChangeItem` (`../plan.ts`). Never folded into `plannedSteps`. */
43
+ versionChanges: VersionChangeItem[];
42
44
  }
43
45
  /** The `Plan` (#19's public contract) a `PlanDetail` carries. */
44
46
  export declare function toPlan(detail: PlanDetail): Plan;
package/dist/plan/diff.js CHANGED
@@ -1,7 +1,9 @@
1
+ import { VERSIONS, floatingTagBlocked } from '../bootstrap/versions.js';
2
+ import { appEntry } from '../org.js';
1
3
  import { NO_AGORA_HOST_VALUES, ZITADEL_ORG_SCOPE_KEY, agoraEnvDrift, agoraHostMissing, desiredAgoraEnv, } from './agora.js';
2
- import { LOGIN_BINDING_SCOPE_KEY, loginBindingState, loginHostClientIds, loginHostnameFor, } from './login-host.js';
4
+ import { LOGIN_BINDING_SCOPE_KEY, loginBindingState, loginHostClientIds, loginHostnameFor, pendingRemovalHosts, } from './login-host.js';
3
5
  import { MAIL_COPY_COMPLETED_SCOPE_KEY, MAIL_COPY_DONE_SCOPE_KEY, parseMailCopyCompleted, } from './mail-copy.js';
4
- import { APP_ENV_APPLIED_SCOPE_KEY, DB_ENV_APPLIED_SCOPE_KEY, ORG_MAIL_APPLIED_APEX_SCOPE_KEY, ORG_MAIL_APPLIED_HOST_SCOPE_KEY, resolveAppEnv, } from './platform-diff.js';
6
+ 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, appStepKey, resolveAppEnv, versionNotAppliedLine, } from './platform-diff.js';
5
7
  import { DEFAULT_ORG_PLATFORM_READINESS } from './platform-org.js';
6
8
  /** The `Plan` (#19's public contract) a `PlanDetail` carries. */
7
9
  export function toPlan(detail) {
@@ -9,6 +11,7 @@ export function toPlan(detail) {
9
11
  orgId: detail.orgId,
10
12
  steps: detail.plannedSteps.map((p) => p.step),
11
13
  unmanaged: detail.unmanaged,
14
+ versionChanges: detail.versionChanges,
12
15
  };
13
16
  }
14
17
  function resourceKey(system, kind, key) {
@@ -115,6 +118,22 @@ export function computePlanDetail(org, live, recordedIds = {}, agoraHost = NO_AG
115
118
  const callsService = planCallsService(org, byKey, claimed, platformReadiness.callsReady);
116
119
  if (callsService.step)
117
120
  plannedSteps.push(callsService.step);
121
+ // #251: same mechanism as the platform's own version-change lines -- see
122
+ // `planPlatform`'s own block in `platform-diff.ts`. Synapse and MAS never
123
+ // patch a running app's image tag once created, so this is
124
+ // `versionNotAppliedLine`, the same honest wording zitadel's own line
125
+ // uses, never `versionChangeLine`'s "X -> Y (redeploy)" -- nothing here
126
+ // ever performs that redeploy.
127
+ const versionChanges = [];
128
+ if (org.calls.enabled && !calls.blocked) {
129
+ const callsAppLive = Boolean(byKey.get(resourceKey('coolify', 'calls', org.id)));
130
+ const synapseChange = versionNotAppliedLine('synapse', callsAppLive, recordedIds[CALLS_SYNAPSE_APPLIED_IMAGE_TAG_SCOPE_KEY], VERSIONS.synapse);
131
+ if (synapseChange)
132
+ versionChanges.push(synapseChange);
133
+ const masChange = versionNotAppliedLine('mas', callsAppLive, recordedIds[CALLS_MAS_APPLIED_IMAGE_TAG_SCOPE_KEY], VERSIONS.mas);
134
+ if (masChange)
135
+ versionChanges.push(masChange);
136
+ }
118
137
  const filesOrganisation = planFilesOrganisation(org, byKey, claimed, platformReadiness.filesReady, platformReadiness.missingKeys.files, platformReadiness.noKeyReader);
119
138
  if (filesOrganisation.step)
120
139
  plannedSteps.push(filesOrganisation.step);
@@ -269,7 +288,7 @@ export function computePlanDetail(org, live, recordedIds = {}, agoraHost = NO_AG
269
288
  unmanaged.push(...zoneBlocked);
270
289
  // #243: an appEnv source nothing will record is reported, not silently skipped.
271
290
  unmanaged.push(...app.blocked);
272
- return { orgId: org.id, plannedSteps, unmanaged };
291
+ return { orgId: org.id, plannedSteps, unmanaged, versionChanges };
273
292
  }
274
293
  /**
275
294
  * The org's own ZITADEL org (issuer), a per-org singleton like every other
@@ -420,8 +439,12 @@ function planOwner(org, recordedIds, authReady) {
420
439
  * for *this* apply, so an org moving from tier 1 onto its own domain (or
421
440
  * losing one) replans this step rather than staying silent forever once
422
441
  * first applied -- `executor.ts`'s `zitadelLoginHost` then also moves every
423
- * already-live app onto the new base URI. Mints nothing either way: adding
424
- * a trusted domain is not a one-time secret, so there is no "undelivered"
442
+ * already-live app onto the new base URI, and (#274) removes the *old* host
443
+ * from ZITADEL's trusted domains and zitadel-login's binding, reading it
444
+ * back from this same recorded value before overwriting it -- no separate
445
+ * action field or planning step of its own needed for that half. Mints
446
+ * nothing either way: adding a trusted domain is not a one-time secret, so
447
+ * there is no "undelivered"
425
448
  * guard to check the way `planJobsRegistration`'s own does.
426
449
  *
427
450
  * Ordered right after `planOwner` (not after mail, chat, or any hosting
@@ -437,11 +460,16 @@ function planLoginHost(org, recordedIds, authReady, issuerPlanned, bindingsWired
437
460
  const none = { step: null, blocked: null };
438
461
  const key = resourceKey('zitadel', 'login-host', org.id);
439
462
  const host = loginHostnameFor(org);
440
- // Already applied -- a re-run plans nothing unless the host changed or an app
441
- // client id appeared since the binding was last written (#267, rule 4).
463
+ // Already applied -- a re-run plans nothing unless the host changed, an app
464
+ // client id appeared since the binding was last written (#267, rule 4), or
465
+ // an earlier host change still has a removal owed (#274 fix round 2):
466
+ // `zitadelLoginHost` always advances the recorded host to the current one,
467
+ // so a pending removal is the only way to know one is still owed, and
468
+ // nothing else here would replan the step to retry it.
442
469
  if (recordedIds[key] === host &&
443
470
  recordedIds[LOGIN_BINDING_SCOPE_KEY] ===
444
- loginBindingState(host, loginHostClientIds(recordedIds))) {
471
+ loginBindingState(host, loginHostClientIds(recordedIds)) &&
472
+ pendingRemovalHosts(recordedIds).length === 0) {
445
473
  return none;
446
474
  }
447
475
  // #177: nothing to add a trusted domain to yet -- `planIssuer` already
@@ -494,6 +522,8 @@ const MAIL_OWNER_MAILBOX_SCOPE_KEY = 'mail_owner_mailbox';
494
522
  const MAIL_ROLE_MAILBOXES_SCOPE_KEY = 'mail_role_mailboxes';
495
523
  /** Restated from `executor.ts`, the same rule: the app id of the org tunnel's cloudflared connector. */
496
524
  const CLOUDFLARE_TUNNEL_CONNECTOR_APP_SCOPE_KEY = 'cloudflare_tunnel_connector_app';
525
+ /** #236: restated from `executor.ts`, the same rule: the domain the org tunnel's DNS was last published under. */
526
+ const CLOUDFLARE_TUNNEL_DNS_DOMAIN_SCOPE_KEY = 'cloudflare_tunnel_dns_domain';
497
527
  /** Restated from `executor.ts`, the same rule: the mail domain the last apply resolved. */
498
528
  const MAIL_DOMAIN_SCOPE_KEY = 'mail_domain';
499
529
  /** Restated from `executor.ts`, the same rule: the id of an ai key minted but not delivered. */
@@ -754,6 +784,26 @@ function planCalls(org, byKey, claimed, callsReady) {
754
784
  blocked: `${org.slug}'s calls Matrix stack is blocked, not planned: the platform's shared calls SFU is off`,
755
785
  };
756
786
  }
787
+ // #248: the two images this stack's own apply would run, checked the same
788
+ // way the platform's SFU checks LiveKit/cloudflared and ZITADEL checks its
789
+ // own tag -- a re-apply of the same declaration must not silently run a
790
+ // different version with no diff shown anywhere.
791
+ if (org.calls.enabled && !byKey.get(key)) {
792
+ const synapseBlocked = floatingTagBlocked('Synapse', VERSIONS.synapse, 'v1.161.0');
793
+ if (synapseBlocked) {
794
+ return {
795
+ step: null,
796
+ blocked: `${org.slug}'s calls Matrix stack is blocked, not planned: ${synapseBlocked}`,
797
+ };
798
+ }
799
+ const masBlocked = floatingTagBlocked('matrix-authentication-service', VERSIONS.mas, '1.24.0');
800
+ if (masBlocked) {
801
+ return {
802
+ step: null,
803
+ blocked: `${org.slug}'s calls Matrix stack is blocked, not planned: ${masBlocked}`,
804
+ };
805
+ }
806
+ }
757
807
  return {
758
808
  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)`),
759
809
  blocked: null,
@@ -1282,8 +1332,8 @@ function planAgora(org, byKey, claimed, recordedIds, reading) {
1282
1332
  * park-not-delete rule (#7 corrections 1) -- turning it off never deletes
1283
1333
  * the org's own app, database or tunnel configuration, it parks it.
1284
1334
  */
1285
- function planHostingToggle(system, kind, desiredEnabled, org, byKey, claimed, describeProvision, describePark) {
1286
- const key = resourceKey(system, kind, org.id);
1335
+ function planHostingToggle(system, kind, desiredEnabled, org, byKey, claimed, describeProvision, describePark, stepKey = org.id) {
1336
+ const key = resourceKey(system, kind, stepKey);
1287
1337
  const live = byKey.get(key);
1288
1338
  // A live resource is on unless it says it is parked. Providers' own
1289
1339
  // configs carry no `enabled` field at all, so only a park step's
@@ -1299,7 +1349,7 @@ function planHostingToggle(system, kind, desiredEnabled, org, byKey, claimed, de
1299
1349
  if (!liveEnabled)
1300
1350
  return null;
1301
1351
  return {
1302
- action: { system, kind, key: org.id, desired: { enabled: false } },
1352
+ action: { system, kind, key: stepKey, desired: { enabled: false } },
1303
1353
  step: { description: describePark(), destroys: true, migrates: false },
1304
1354
  };
1305
1355
  }
@@ -1310,7 +1360,7 @@ function planHostingToggle(system, kind, desiredEnabled, org, byKey, claimed, de
1310
1360
  if (liveEnabled)
1311
1361
  return null;
1312
1362
  return {
1313
- action: { system, kind, key: org.id, desired: { enabled: true } },
1363
+ action: { system, kind, key: stepKey, desired: { enabled: true } },
1314
1364
  step: { description: describeProvision(), destroys: false, migrates: false },
1315
1365
  };
1316
1366
  }
@@ -1345,8 +1395,12 @@ function planApp(org, byKey, claimed, recordedIds, plannedSteps) {
1345
1395
  aiOrganisationId: ['ai', 'organisation'],
1346
1396
  mailDomain: ['coolify', 'mail'],
1347
1397
  };
1348
- const { values: wantedEnv, unresolved } = resolveAppEnv(org, recordedIds);
1349
- const blocked = org.hosting.enabled
1398
+ const entry = org.hosting.enabled ? appEntry(org.hosting) : undefined;
1399
+ const { values: wantedEnv, unresolved } = entry
1400
+ ? resolveAppEnv(org, entry, recordedIds)
1401
+ : { values: {}, unresolved: [] };
1402
+ const stepKey = appStepKey(org);
1403
+ const blocked = entry
1350
1404
  ? unresolved
1351
1405
  .filter(([, source]) => !plannedSteps.some((p) => p.action.system === sourceStep[source][0] &&
1352
1406
  p.action.kind === sourceStep[source][1]))
@@ -1355,18 +1409,18 @@ function planApp(org, byKey, claimed, recordedIds, plannedSteps) {
1355
1409
  key: `coolify:app:env:${name}`,
1356
1410
  }))
1357
1411
  : [];
1358
- const toggled = planHostingToggle('coolify', 'app', org.hosting.enabled, org, byKey, claimed, () => `provision ${org.slug}'s app`, () => `park ${org.slug}'s app (stops serving; app data is retained, not deleted -- #7 corrections 1)`);
1412
+ const toggled = planHostingToggle('coolify', 'app', entry !== undefined, org, byKey, claimed, () => `provision ${org.slug}'s app`, () => `park ${org.slug}'s app (stops serving; app data is retained, not deleted -- #7 corrections 1)`, stepKey);
1359
1413
  if (toggled)
1360
1414
  return { step: toggled, blocked };
1361
- if (!org.hosting.enabled)
1415
+ if (!entry)
1362
1416
  return { step: null, blocked };
1363
- const live = byKey.get(resourceKey('coolify', 'app', org.id));
1417
+ const live = byKey.get(resourceKey('coolify', 'app', stepKey));
1364
1418
  if (!live)
1365
1419
  return { step: null, blocked };
1366
1420
  const action = {
1367
1421
  system: 'coolify',
1368
1422
  kind: 'app',
1369
- key: org.id,
1423
+ key: stepKey,
1370
1424
  desired: { enabled: true },
1371
1425
  };
1372
1426
  if (live.config.hasLogVolume === false) {
@@ -1388,7 +1442,7 @@ function planApp(org, byKey, claimed, recordedIds, plannedSteps) {
1388
1442
  // names (`envNames` absent) reads as compliant, like `hasLogVolume`.
1389
1443
  const envNames = live.config.envNames;
1390
1444
  const dbLive = byKey.get(resourceKey('coolify', 'database', org.id));
1391
- const dbReady = org.hosting.database && Boolean(recordedIds[`coolify:database:${org.id}`]);
1445
+ const dbReady = entry.database && Boolean(recordedIds[`coolify:database:${org.id}`]);
1392
1446
  if (dbReady && dbLive?.config.hasInternalUrl === false) {
1393
1447
  // Rule 2: the push cannot succeed yet, so report it rather than replan
1394
1448
  // forever.
@@ -1417,7 +1471,7 @@ function planApp(org, byKey, claimed, recordedIds, plannedSteps) {
1417
1471
  blocked,
1418
1472
  };
1419
1473
  }
1420
- // #243 (rule 3): a changed `hosting.appEnv` reaches an app that is already
1474
+ // #243 (rule 3): a changed entry `env` reaches an app that is already
1421
1475
  // live. `pushAppEnv` records what it pushed, so this plans the same step
1422
1476
  // again, with a redeploy, only once the resolved map differs from that.
1423
1477
  if (JSON.stringify(wantedEnv) !== (recordedIds[APP_ENV_APPLIED_SCOPE_KEY] ?? '{}')) {
@@ -1503,12 +1557,12 @@ function planJobsRegistration(org, recordedIds, jobsAvailable, missingKeyRef = n
1503
1557
  }
1504
1558
  /**
1505
1559
  * A Postgres database beside the org's app (`org.ts`'s
1506
- * `HostingConfig.database`, #71) -- `validateHosting` requires
1560
+ * `HostingEntry.database`, #71) -- `validateHosting` requires
1507
1561
  * `hosting.enabled` before this can be true. Same park-not-delete shape as
1508
1562
  * {@link planApp}.
1509
1563
  */
1510
1564
  function planDatabase(org, byKey, claimed) {
1511
- return planHostingToggle('coolify', 'database', org.hosting.database, org, byKey, claimed, () => `provision a database for ${org.slug}'s app`, () => `park ${org.slug}'s database (data is retained, not deleted -- #7 corrections 1)`);
1565
+ return planHostingToggle('coolify', 'database', appEntry(org.hosting)?.database ?? false, org, byKey, claimed, () => `provision a database for ${org.slug}'s app`, () => `park ${org.slug}'s database (data is retained, not deleted -- #7 corrections 1)`);
1512
1566
  }
1513
1567
  /**
1514
1568
  * A Cloudflare tunnel reaching the org's app (`org.ts`'s
@@ -1549,17 +1603,30 @@ function planTunnel(org, byKey, claimed, recordedIds) {
1549
1603
  const toggled = planHostingToggle('cloudflare', 'tunnel', desired, org, byKey, claimed, () => `provision a Cloudflare tunnel for ${org.slug}'s app`, () => `park ${org.slug}'s Cloudflare tunnel (configuration is retained, not deleted -- #7 corrections 1)`);
1550
1604
  if (toggled || !desired)
1551
1605
  return toggled;
1552
- // #235: a live, enabled tunnel with no connector recorded -- a first
1553
- // connector create that failed, or a tunnel that predates connectors --
1554
- // plans the same step again. The step finds or creates the connector by
1555
- // name, so it never makes a second one.
1606
+ // A live, enabled tunnel re-plans the same step again when either:
1607
+ // #235, no connector recorded -- a first connector create that failed, or
1608
+ // a tunnel that predates connectors; or #236, its DNS was last published
1609
+ // under a domain other than the one now declared -- never published at
1610
+ // all (unset), or the org's domain has since changed. `org.domain` is set
1611
+ // whenever `desired` is true above, in every branch. The step itself
1612
+ // (`executor.ts`'s `cloudflareTunnel`) finds or creates the connector by
1613
+ // name and upserts DNS idempotently (and, on a domain change, cleans up
1614
+ // the old one once the new one is published -- round 3: publishing first
1615
+ // means a conflict on the new domain leaves the old one still live rather
1616
+ // than taking the org dark), so re-running it never duplicates either.
1556
1617
  const live = byKey.get(resourceKey('cloudflare', 'tunnel', org.id));
1557
- if (!live || recordedIds[CLOUDFLARE_TUNNEL_CONNECTOR_APP_SCOPE_KEY])
1618
+ if (!live)
1619
+ return null;
1620
+ const connectorMissing = !recordedIds[CLOUDFLARE_TUNNEL_CONNECTOR_APP_SCOPE_KEY];
1621
+ const dnsStale = recordedIds[CLOUDFLARE_TUNNEL_DNS_DOMAIN_SCOPE_KEY] !== org.domain;
1622
+ if (!connectorMissing && !dnsStale)
1558
1623
  return null;
1559
1624
  return {
1560
1625
  action: { system: 'cloudflare', kind: 'tunnel', key: org.id, desired: { enabled: true } },
1561
1626
  step: {
1562
- description: `run a cloudflared connector for ${org.slug}'s Cloudflare tunnel`,
1627
+ description: connectorMissing
1628
+ ? `run a cloudflared connector for ${org.slug}'s Cloudflare tunnel`
1629
+ : `point ${org.slug}'s hostnames at its Cloudflare tunnel`,
1563
1630
  destroys: false,
1564
1631
  migrates: false,
1565
1632
  },
@@ -1728,17 +1795,18 @@ function planCustomers(org, live, claimed, recordedIds) {
1728
1795
  }
1729
1796
  /**
1730
1797
  * #84: a Cloudflare Access gate on the org's app hostname, letting only
1731
- * `hosting.accessEmails` through. Idempotent on the recorded Access app id,
1798
+ * the `app` entry's `accessEmails` through. Idempotent on the recorded Access app id,
1732
1799
  * like `planOwner`: Cloudflare's live listing has no Access apps. A later
1733
1800
  * change to the email list is not reconciled yet.
1734
1801
  */
1735
1802
  function planAccess(org, recordedIds) {
1736
- if (!org.hosting.enabled || org.hosting.accessEmails.length === 0)
1803
+ const entry = org.hosting.enabled ? appEntry(org.hosting) : undefined;
1804
+ if (!entry || entry.accessEmails.length === 0)
1737
1805
  return null;
1738
- if (recordedIds[resourceKey('cloudflare', 'access', org.id)])
1806
+ if (recordedIds[resourceKey('cloudflare', 'access', appStepKey(org))])
1739
1807
  return null;
1740
1808
  return {
1741
- action: { system: 'cloudflare', kind: 'access', key: org.id, desired: {} },
1809
+ action: { system: 'cloudflare', kind: 'access', key: appStepKey(org), desired: {} },
1742
1810
  step: {
1743
1811
  description: `put ${org.slug}'s app behind Cloudflare Access`,
1744
1812
  destroys: false,