@wtfalch/foundry 0.6.0 → 0.7.1

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 (45) hide show
  1. package/dist/bootstrap/declare.d.ts +3 -1
  2. package/dist/bootstrap/declare.js +6 -2
  3. package/dist/bootstrap/versions.d.ts +17 -0
  4. package/dist/bootstrap/versions.js +12 -0
  5. package/dist/calls-sfu.d.ts +5 -4
  6. package/dist/calls-sfu.js +6 -4
  7. package/dist/index.d.ts +2 -2
  8. package/dist/index.js +1 -1
  9. package/dist/migrations/0022_chat_enabled.sql +6 -0
  10. package/dist/migrations/0023_hosting_app_env.sql +8 -0
  11. package/dist/migrations/0024_audit_events.sql +211 -0
  12. package/dist/migrations/0025_audit_display.sql +147 -0
  13. package/dist/migrations/0026_audit_erase_email_guard.sql +78 -0
  14. package/dist/migrations/0027_audit_chain.sql +170 -0
  15. package/dist/migrations/0028_audit_rls.sql +111 -0
  16. package/dist/org.d.ts +24 -0
  17. package/dist/org.js +13 -0
  18. package/dist/plan/agora.d.ts +21 -0
  19. package/dist/plan/agora.js +23 -0
  20. package/dist/plan/calls-matrix.d.ts +2 -2
  21. package/dist/plan/calls-matrix.js +3 -2
  22. package/dist/plan/cloudflared-connector.d.ts +3 -2
  23. package/dist/plan/cloudflared-connector.js +5 -3
  24. package/dist/plan/diff.d.ts +15 -2
  25. package/dist/plan/diff.js +306 -41
  26. package/dist/plan/executor.d.ts +73 -28
  27. package/dist/plan/executor.js +217 -55
  28. package/dist/plan/index.d.ts +2 -0
  29. package/dist/plan/index.js +1 -0
  30. package/dist/plan/login-host.d.ts +64 -0
  31. package/dist/plan/login-host.js +52 -0
  32. package/dist/plan/plan-apply.d.ts +21 -8
  33. package/dist/plan/plan-apply.js +10 -9
  34. package/dist/plan/platform-diff.d.ts +23 -1
  35. package/dist/plan/platform-diff.js +35 -0
  36. package/dist/plan/platform-providers.d.ts +1 -1
  37. package/dist/plan/platform-providers.js +71 -16
  38. package/dist/plan/providers-readers.js +44 -1
  39. package/dist/store/audit.d.ts +40 -4
  40. package/dist/store/audit.js +114 -29
  41. package/dist/store/postgres.js +4 -1
  42. package/dist/store/schema.d.ts +37 -1
  43. package/dist/store/schema.js +7 -0
  44. package/package.json +5 -3
  45. package/versions.json +8 -0
package/dist/plan/diff.js CHANGED
@@ -1,6 +1,7 @@
1
- import { NO_AGORA_HOST_VALUES, agoraEnvDrift, agoraHostMissing, desiredAgoraEnv, } from './agora.js';
1
+ 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';
2
3
  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 { 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';
4
5
  import { DEFAULT_ORG_PLATFORM_READINESS } from './platform-org.js';
5
6
  /** The `Plan` (#19's public contract) a `PlanDetail` carries. */
6
7
  export function toPlan(detail) {
@@ -53,7 +54,11 @@ function resourceKey(system, kind, key) {
53
54
  * app with the platform's jobs scheduler, idempotent on the *recorded*
54
55
  * resource id like {@link planOwner} rather than a live resource, since
55
56
  * nothing in `live` can report a scheduler registration (no reader exists for
56
- * it). Everything
57
+ * it). #267 adds one more, right after {@link planOwner} and before every
58
+ * app-creating step below: {@link planLoginHost}, the org's own company
59
+ * login host -- also idempotent on the *recorded* resource id, since
60
+ * `live` has no notion of a ZITADEL trusted domain or a login binding
61
+ * either. Everything
57
62
  * else in `live` that none of these eighteen singletons and however many
58
63
  * declared issuer apps claim is unmanaged. The full field-by-field provisioning surface (SSO,
59
64
  * guests, calls on the org's own domain, adopted/bespoke apps, the actual
@@ -61,7 +66,7 @@ function resourceKey(system, kind, key) {
61
66
  * Company Factory end to end", which wires real readers and executors
62
67
  * against this seam.
63
68
  */
64
- export function computePlanDetail(org, live, recordedIds = {}, agoraHost = NO_AGORA_HOST_VALUES, jobsAvailable = false, platformReadiness = DEFAULT_ORG_PLATFORM_READINESS) {
69
+ export function computePlanDetail(org, live, recordedIds = {}, agoraHost = NO_AGORA_HOST_VALUES, jobsAvailable = false, platformReadiness = DEFAULT_ORG_PLATFORM_READINESS, options = {}) {
65
70
  const byKey = new Map(live.map((r) => [resourceKey(r.system, r.kind, r.key), r]));
66
71
  const claimed = new Set();
67
72
  const plannedSteps = [];
@@ -71,9 +76,29 @@ export function computePlanDetail(org, live, recordedIds = {}, agoraHost = NO_AG
71
76
  const owner = planOwner(org, recordedIds, platformReadiness.authReady);
72
77
  if (owner)
73
78
  plannedSteps.push(owner);
79
+ // #267: after the ZITADEL org step, before any app's own issuer
80
+ // application. Apps created later in this run are not in the binding yet;
81
+ // the next run replans this step and repoints them.
82
+ const loginHost = planLoginHost(org, recordedIds, platformReadiness.authReady, issuer.step !== null, options.loginHostBindings ?? false);
83
+ if (loginHost.step)
84
+ plannedSteps.push(loginHost.step);
85
+ // Planned ahead of everything that needs the zone live (#238); its own step
86
+ // is still pushed in its usual place below.
87
+ const zone = planZone(org, byKey, claimed);
88
+ const zoneWaiting = zone.blocked !== null;
89
+ const zoneBlocked = [];
74
90
  const mail = planMail(org, byKey, claimed, recordedIds, platformReadiness.mailHost);
75
- if (mail.step)
91
+ // #238: own-domain mail publishes DNS records into the zone, so it waits for
92
+ // an active zone -- blocked, not planned (rule 2).
93
+ if (mail.step && zoneWaiting && org.mail.usesDomain && org.domain) {
94
+ zoneBlocked.push({
95
+ description: `${org.slug}'s mail is blocked, not planned: the zone for ${org.domain} is not active yet`,
96
+ key: 'coolify:mail:zone',
97
+ });
98
+ }
99
+ else if (mail.step) {
76
100
  plannedSteps.push(mail.step);
101
+ }
77
102
  const mailCopy = planMailCopy(org, byKey, recordedIds);
78
103
  if (mailCopy)
79
104
  plannedSteps.push(mailCopy);
@@ -135,21 +160,36 @@ export function computePlanDetail(org, live, recordedIds = {}, agoraHost = NO_AG
135
160
  const database = planDatabase(org, byKey, claimed);
136
161
  if (database)
137
162
  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)
163
+ const app = planApp(org, byKey, claimed, recordedIds, plannedSteps);
164
+ if (app.step)
165
+ plannedSteps.push(app.step);
166
+ const tunnel = planTunnel(org, byKey, claimed, recordedIds);
167
+ // #238: a tunnel that is being provisioned waits for an active zone; parking
168
+ // one never does.
169
+ if (tunnel && zoneWaiting && tunnel.action.desired.enabled !== false) {
170
+ zoneBlocked.push({
171
+ description: `${org.slug}'s Cloudflare tunnel is blocked, not planned: the zone for ${org.domain} is not active yet`,
172
+ key: 'cloudflare:tunnel',
173
+ });
174
+ }
175
+ else if (tunnel) {
143
176
  plannedSteps.push(tunnel);
144
- const zone = planZone(org, byKey, claimed);
145
- if (zone)
146
- plannedSteps.push(zone);
177
+ }
178
+ if (zone.step)
179
+ plannedSteps.push(zone.step);
147
180
  // Right after the zone (#43): `cloudflareRateLimit` resolves the zone by
148
181
  // apex domain, so the zone step above must already have run (or the zone
149
182
  // must already exist live) by the time this one's action executes.
150
183
  const rateLimit = planRateLimit(org, recordedIds);
151
- if (rateLimit)
184
+ if (rateLimit && zoneWaiting) {
185
+ zoneBlocked.push({
186
+ description: `${org.slug}'s sign-in rate limit is blocked, not planned: the zone for ${org.domain} is not active yet`,
187
+ key: 'cloudflare:ratelimit',
188
+ });
189
+ }
190
+ else if (rateLimit) {
152
191
  plannedSteps.push(rateLimit);
192
+ }
153
193
  // After the apps: the customers org is granted the project the first app
154
194
  // step creates.
155
195
  const customers = planCustomers(org, live, claimed, recordedIds);
@@ -199,6 +239,10 @@ export function computePlanDetail(org, live, recordedIds = {}, agoraHost = NO_AG
199
239
  // budget, key) would otherwise also have nothing to act on.
200
240
  if (issuer.blocked)
201
241
  unmanaged.push({ description: issuer.blocked, key: 'zitadel:issuer' });
242
+ // #267 (rule 2): a login host with no ZITADEL org to trust it in is blocked, not planned to throw.
243
+ if (loginHost.blocked) {
244
+ unmanaged.push({ description: loginHost.blocked, key: 'zitadel:login-host' });
245
+ }
202
246
  if (mail.blocked)
203
247
  unmanaged.push({ description: mail.blocked, key: 'coolify:mail' });
204
248
  // #162: reported separately from `mail.blocked` -- missing an owner
@@ -218,6 +262,13 @@ export function computePlanDetail(org, live, recordedIds = {}, agoraHost = NO_AG
218
262
  unmanaged.push({ description: storage.blocked, key: 'files:storage' });
219
263
  if (aiBudget.blocked)
220
264
  unmanaged.push({ description: aiBudget.blocked, key: 'ai:budget' });
265
+ // #238: a pending zone, reported here with its nameservers rather than
266
+ // silently treated as converged.
267
+ if (zone.blocked)
268
+ unmanaged.push({ description: zone.blocked, key: 'cloudflare:zone' });
269
+ unmanaged.push(...zoneBlocked);
270
+ // #243: an appEnv source nothing will record is reported, not silently skipped.
271
+ unmanaged.push(...app.blocked);
221
272
  return { orgId: org.id, plannedSteps, unmanaged };
222
273
  }
223
274
  /**
@@ -355,6 +406,83 @@ function planOwner(org, recordedIds, authReady) {
355
406
  },
356
407
  };
357
408
  }
409
+ /**
410
+ * #267: the org's own company login host (`login-host.ts`'s
411
+ * `loginHostnameFor`) -- `auth.<domain>` once the org has one,
412
+ * `<slug>-auth.oikos.host` for tier 1. Idempotent on the *recorded*
413
+ * resource id, the same shape `planOwner` above uses and for the same
414
+ * reason: `ZitadelReader.listResources` has no notion of a trusted domain
415
+ * or a login binding, so there is nothing in `live` this step could ever
416
+ * claim or compare against.
417
+ *
418
+ * Unlike `planOwner`, a re-run is not simply "already done or not": the
419
+ * recorded value is compared against {@link loginHostnameFor}'s own value
420
+ * for *this* apply, so an org moving from tier 1 onto its own domain (or
421
+ * losing one) replans this step rather than staying silent forever once
422
+ * 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"
425
+ * guard to check the way `planJobsRegistration`'s own does.
426
+ *
427
+ * Ordered right after `planOwner` (not after mail, chat, or any hosting
428
+ * step), for the same reason `planOwner`'s own doc comment gives: on a
429
+ * first-ever apply the ZITADEL org this step's executor needs
430
+ * (`context.recordedIds().zitadel_org`) has already been recorded by the
431
+ * issuer step two steps before it in the same run, and this step itself
432
+ * runs well before `planIssuerApps`/`planCmsClient`/`planAgoraClient`
433
+ * create any app under the org's project -- #267's own "before any app's
434
+ * issuer application".
435
+ */
436
+ function planLoginHost(org, recordedIds, authReady, issuerPlanned, bindingsWired) {
437
+ const none = { step: null, blocked: null };
438
+ const key = resourceKey('zitadel', 'login-host', org.id);
439
+ 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).
442
+ if (recordedIds[key] === host &&
443
+ recordedIds[LOGIN_BINDING_SCOPE_KEY] ===
444
+ loginBindingState(host, loginHostClientIds(recordedIds))) {
445
+ return none;
446
+ }
447
+ // #177: nothing to add a trusted domain to yet -- `planIssuer` already
448
+ // reports the platform's sign-in as blocked, so this step stays silent
449
+ // rather than repeating it, the same rule `planOwner` follows.
450
+ if (!authReady)
451
+ return none;
452
+ // Isolation between orgs rests on zitadel-login's binding, and repointing
453
+ // an app at a host with no binding breaks its sign-in: trust nothing and
454
+ // repoint nothing until the port that writes the binding is wired.
455
+ if (!bindingsWired) {
456
+ return {
457
+ step: null,
458
+ blocked: `${org.slug}'s login host ${host} waits for the loginHostBindings port, which the host has not wired`,
459
+ };
460
+ }
461
+ // No ZITADEL org to trust the host in: wait for it, unless the issuer step
462
+ // in this very plan creates it first (it runs before this one).
463
+ if (!recordedIds[ZITADEL_ORG_SCOPE_KEY] && !issuerPlanned) {
464
+ return {
465
+ step: null,
466
+ blocked: `${org.slug}'s login host ${host} waits for its ZITADEL org, which is not recorded yet`,
467
+ };
468
+ }
469
+ return {
470
+ step: {
471
+ action: {
472
+ system: 'zitadel',
473
+ kind: 'login-host',
474
+ key: org.id,
475
+ desired: { host },
476
+ },
477
+ step: {
478
+ description: `set ${org.slug}'s login host to ${host}`,
479
+ destroys: false,
480
+ migrates: false,
481
+ },
482
+ },
483
+ blocked: null,
484
+ };
485
+ }
358
486
  /**
359
487
  * #162: the flat scope id `executor.ts`'s `ensureOwnerMailbox` records once
360
488
  * it has given the org's owner a mailbox (`MAIL_OWNER_MAILBOX_SCOPE_KEY`
@@ -364,6 +492,8 @@ function planOwner(org, recordedIds, authReady) {
364
492
  const MAIL_OWNER_MAILBOX_SCOPE_KEY = 'mail_owner_mailbox';
365
493
  /** #230: restated from `executor.ts`'s `MAIL_ROLE_MAILBOXES_SCOPE_KEY`, same rule: comma-joined local parts already created. */
366
494
  const MAIL_ROLE_MAILBOXES_SCOPE_KEY = 'mail_role_mailboxes';
495
+ /** Restated from `executor.ts`, the same rule: the app id of the org tunnel's cloudflared connector. */
496
+ const CLOUDFLARE_TUNNEL_CONNECTOR_APP_SCOPE_KEY = 'cloudflare_tunnel_connector_app';
367
497
  /** Restated from `executor.ts`, the same rule: the mail domain the last apply resolved. */
368
498
  const MAIL_DOMAIN_SCOPE_KEY = 'mail_domain';
369
499
  /** Restated from `executor.ts`, the same rule: the id of an ai key minted but not delivered. */
@@ -546,6 +676,10 @@ function planMailCopy(org, byKey, recordedIds) {
546
676
  };
547
677
  }
548
678
  function planChat(org, byKey, claimed) {
679
+ // Chat off (not in v1): plan nothing, and leave any live chat unclaimed so
680
+ // it is reported in `unmanaged` and never adopted or destroyed.
681
+ if (!org.chat.enabled)
682
+ return null;
549
683
  const key = resourceKey('coolify', 'chat', org.id);
550
684
  const live = byKey.get(key);
551
685
  if (live)
@@ -1203,23 +1337,103 @@ function planHostingToggle(system, kind, desiredEnabled, org, byKey, claimed, de
1203
1337
  * resource's `enabled` field -- a live resource nothing has actually
1204
1338
  * checked yet is assumed fine rather than replanned every time.
1205
1339
  */
1206
- function planApp(org, byKey, claimed) {
1340
+ function planApp(org, byKey, claimed, recordedIds, plannedSteps) {
1341
+ // #243: an `appEnv` source with no recorded value and no step planned to
1342
+ // record one this run has nothing to push, now or after this apply.
1343
+ const sourceStep = {
1344
+ filesOrganisationId: ['files', 'organisation'],
1345
+ aiOrganisationId: ['ai', 'organisation'],
1346
+ mailDomain: ['coolify', 'mail'],
1347
+ };
1348
+ const { values: wantedEnv, unresolved } = resolveAppEnv(org, recordedIds);
1349
+ const blocked = org.hosting.enabled
1350
+ ? unresolved
1351
+ .filter(([, source]) => !plannedSteps.some((p) => p.action.system === sourceStep[source][0] &&
1352
+ p.action.kind === sourceStep[source][1]))
1353
+ .map(([name, source]) => ({
1354
+ description: `${org.slug}'s app env ${name} is blocked, not planned: its source ${source} has no recorded value yet`,
1355
+ key: `coolify:app:env:${name}`,
1356
+ }))
1357
+ : [];
1207
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)`);
1208
1359
  if (toggled)
1209
- return toggled;
1360
+ return { step: toggled, blocked };
1210
1361
  if (!org.hosting.enabled)
1211
- return null;
1362
+ return { step: null, blocked };
1212
1363
  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
- },
1364
+ if (!live)
1365
+ return { step: null, blocked };
1366
+ const action = {
1367
+ system: 'coolify',
1368
+ kind: 'app',
1369
+ key: org.id,
1370
+ desired: { enabled: true },
1222
1371
  };
1372
+ if (live.config.hasLogVolume === false) {
1373
+ return {
1374
+ step: {
1375
+ action,
1376
+ step: {
1377
+ 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)`,
1378
+ destroys: false,
1379
+ migrates: false,
1380
+ },
1381
+ },
1382
+ blocked,
1383
+ };
1384
+ }
1385
+ // #239 (rule 4), from live state: an app with a database but no
1386
+ // `DATABASE_URL_OWNER` env still has the pre-split superuser URL, or never
1387
+ // got the push. Names only, never values. A reader that did not list env
1388
+ // names (`envNames` absent) reads as compliant, like `hasLogVolume`.
1389
+ const envNames = live.config.envNames;
1390
+ const dbLive = byKey.get(resourceKey('coolify', 'database', org.id));
1391
+ const dbReady = org.hosting.database && Boolean(recordedIds[`coolify:database:${org.id}`]);
1392
+ if (dbReady && dbLive?.config.hasInternalUrl === false) {
1393
+ // Rule 2: the push cannot succeed yet, so report it rather than replan
1394
+ // forever.
1395
+ blocked.push({
1396
+ description: `${org.slug}'s app database URLs are blocked, not planned: the database reports no internal connection URL yet`,
1397
+ key: 'coolify:app:database-url',
1398
+ });
1399
+ }
1400
+ else if (dbReady &&
1401
+ // Only a read that saw the URL replans: an unread database (a failed
1402
+ // read) would plan a step that pushes nothing, forever.
1403
+ dbLive?.config.hasInternalUrl === true &&
1404
+ ((Array.isArray(envNames) && !envNames.includes('DATABASE_URL_OWNER')) ||
1405
+ // A crash between the push and the restart leaves the env right and no
1406
+ // marker: replan once more to restart and set it.
1407
+ !recordedIds[DB_ENV_APPLIED_SCOPE_KEY])) {
1408
+ return {
1409
+ step: {
1410
+ action,
1411
+ step: {
1412
+ description: `give ${org.slug}'s app its owner and runtime database URLs and restart it (#239)`,
1413
+ destroys: false,
1414
+ migrates: false,
1415
+ },
1416
+ },
1417
+ blocked,
1418
+ };
1419
+ }
1420
+ // #243 (rule 3): a changed `hosting.appEnv` reaches an app that is already
1421
+ // live. `pushAppEnv` records what it pushed, so this plans the same step
1422
+ // again, with a redeploy, only once the resolved map differs from that.
1423
+ if (JSON.stringify(wantedEnv) !== (recordedIds[APP_ENV_APPLIED_SCOPE_KEY] ?? '{}')) {
1424
+ return {
1425
+ step: {
1426
+ action,
1427
+ step: {
1428
+ description: `push ${org.slug}'s changed app env (appEnv) and redeploy the app`,
1429
+ destroys: false,
1430
+ migrates: false,
1431
+ },
1432
+ },
1433
+ blocked,
1434
+ };
1435
+ }
1436
+ return { step: null, blocked };
1223
1437
  }
1224
1438
  /**
1225
1439
  * #173: register the org's own app with the platform's jobs scheduler
@@ -1327,12 +1541,48 @@ function planDatabase(org, byKey, claimed) {
1327
1541
  * domain) plans no per-org tunnel for Agora: `<slug>-agora.oikos.host` rides
1328
1542
  * the shared `*.oikos.host` wildcard tunnel (`scripts/shared-ingress.mjs`).
1329
1543
  */
1330
- function planTunnel(org, byKey, claimed) {
1544
+ function planTunnel(org, byKey, claimed, recordedIds) {
1331
1545
  const desired = (org.hosting.tunnel && org.hosting.usesDomain && Boolean(org.domain)) ||
1332
1546
  (org.site.enabled && Boolean(org.domain)) ||
1333
1547
  (org.calls.enabled && org.calls.usesDomain && Boolean(org.domain)) ||
1334
1548
  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)`);
1549
+ 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
+ if (toggled || !desired)
1551
+ 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.
1556
+ const live = byKey.get(resourceKey('cloudflare', 'tunnel', org.id));
1557
+ if (!live || recordedIds[CLOUDFLARE_TUNNEL_CONNECTOR_APP_SCOPE_KEY])
1558
+ return null;
1559
+ return {
1560
+ action: { system: 'cloudflare', kind: 'tunnel', key: org.id, desired: { enabled: true } },
1561
+ step: {
1562
+ description: `run a cloudflared connector for ${org.slug}'s Cloudflare tunnel`,
1563
+ destroys: false,
1564
+ migrates: false,
1565
+ },
1566
+ };
1567
+ }
1568
+ /**
1569
+ * #238: the org's own zone's live `status`/`name_servers`, read from the
1570
+ * same `kind: 'zone'` {@link LiveResource} `planZone` already matches by key
1571
+ * -- `@wtfalch/providers/cloudflare`'s `CloudflareClient.listResources`
1572
+ * already returns both (`config: { id, status, name_servers }`), so nothing
1573
+ * new has to be fetched; this just reads what was already there. `null`
1574
+ * means no zone is live yet at all (not a readiness problem `planZone`
1575
+ * itself reports -- it is still planning the zone's own creation).
1576
+ */
1577
+ function zoneReadiness(domain, byKey) {
1578
+ const zone = byKey.get(resourceKey('cloudflare', 'zone', domain));
1579
+ if (!zone)
1580
+ return null;
1581
+ const config = zone.config;
1582
+ return {
1583
+ active: config.status === 'active',
1584
+ nameServers: config.name_servers ?? [],
1585
+ };
1336
1586
  }
1337
1587
  /**
1338
1588
  * The org's Cloudflare zone for its own domain (`org.ts`'s
@@ -1364,25 +1614,40 @@ function planZone(org, byKey, claimed) {
1364
1614
  (org.calls.enabled && org.calls.usesDomain) ||
1365
1615
  Boolean(org.domain);
1366
1616
  if (!usesDomain || !org.domain)
1367
- return null;
1617
+ return { step: null, blocked: null };
1368
1618
  const key = resourceKey('cloudflare', 'zone', org.domain);
1369
1619
  const live = byKey.get(key);
1370
- if (live)
1620
+ if (live) {
1371
1621
  claimed.add(key);
1372
- if (live)
1373
- return null;
1622
+ const readiness = zoneReadiness(org.domain, byKey);
1623
+ // #238: a zone stays pending until the customer changes nameservers at
1624
+ // their registrar -- reported here, not silently treated as converged,
1625
+ // so an apply says what it is waiting for. A later apply, once
1626
+ // Cloudflare marks the zone active, reports nothing and the DNS- and
1627
+ // mail-dependent steps below (`planMail`) resume.
1628
+ if (readiness && !readiness.active) {
1629
+ return {
1630
+ step: null,
1631
+ blocked: `${org.slug}'s zone for ${org.domain} is waiting for nameservers: ${readiness.nameServers.join(', ')}`,
1632
+ };
1633
+ }
1634
+ return { step: null, blocked: null };
1635
+ }
1374
1636
  return {
1375
- action: {
1376
- system: 'cloudflare',
1377
- kind: 'zone',
1378
- key: org.domain,
1379
- desired: { domain: org.domain },
1380
- },
1381
1637
  step: {
1382
- description: `provision a Cloudflare zone for ${org.domain}`,
1383
- destroys: false,
1384
- migrates: false,
1638
+ action: {
1639
+ system: 'cloudflare',
1640
+ kind: 'zone',
1641
+ key: org.domain,
1642
+ desired: { domain: org.domain },
1643
+ },
1644
+ step: {
1645
+ description: `provision a Cloudflare zone for ${org.domain}`,
1646
+ destroys: false,
1647
+ migrates: false,
1648
+ },
1385
1649
  },
1650
+ blocked: null,
1386
1651
  };
1387
1652
  }
1388
1653
  /**
@@ -2,6 +2,7 @@ import type { OrgMailOptions, OrgMailOrg, OrgMailResult } from '@wtfalch/email/p
2
2
  import type { DeclaredOrg, HostingBuildPack } from '../org.js';
3
3
  import type { DeclaredOrgStore } from '../store.js';
4
4
  import type { StepAction } from './diff.js';
5
+ import { type LoginHostBindingsPort } from './login-host.js';
5
6
  import { type MailCopyPort } from './mail-copy.js';
6
7
  import type { DestroyTarget, StepDestroyer, StepExecutionContext, StepExecutor } from './plan-apply.js';
7
8
  /**
@@ -140,13 +141,14 @@ export interface CoolifyPort {
140
141
  }>>;
141
142
  /**
142
143
  * Upsert environment variables on an application in one call — used here to
143
- * push the database's `internal_db_url` in as the app's `DATABASE_URL`
144
- * (hosting-wiring campaign), and, for the cms app, its `DATABASE_URL_OWNER`
145
- * plus a freshly minted `DATABASE_URL` runtime-role credential (#117). Each
146
- * value is a live credential: it must reach this call and nothing else —
147
- * never a step's return value, `serviceIds` (hence the store), an audit
148
- * record, a log or a thrown error. See `coolifyApp`'s `pushDatabaseUrl` and
149
- * `cmsSite`'s `ensureCmsRuntimeUrl`.
144
+ * push the org's own app's `DATABASE_URL_OWNER` plus a freshly minted
145
+ * `DATABASE_URL` runtime-role credential (hosting-wiring campaign, split
146
+ * per #239 the same way #117 already splits the cms app's), and likewise
147
+ * for the cms app itself. Each value is a live credential: it must reach
148
+ * this call and nothing else — never a step's return value, `serviceIds`
149
+ * (hence the store), an audit record, a log or a thrown error. See
150
+ * `coolifyApp`'s `pushDatabaseUrl`/`ensureAppRuntimeUrl` and `cmsSite`'s
151
+ * `ensureCmsRuntimeUrl`.
150
152
  */
151
153
  bulkUpsertEnvs(applicationUuid: string, envs: Array<{
152
154
  key: string;
@@ -766,6 +768,24 @@ export interface ZitadelPort {
766
768
  * reports no longer live is never attempted at all.
767
769
  */
768
770
  listLoginClientPats(orgId: string, userId: string): Promise<string[]>;
771
+ /**
772
+ * #267: adds the org's own company login host to the ZITADEL instance's
773
+ * trusted domains (`@wtfalch/providers`' `ZitadelClient.ensureTrustedDomain`)
774
+ * -- the functionally critical half of `zitadelLoginHost` below, per
775
+ * `auth/docs/company-login-hosts.md`'s enforcement section: "the issuer
776
+ * refuses a public host that is not one of the instance's trusted
777
+ * domains." Idempotent: lists first, adds only if not already trusted.
778
+ */
779
+ ensureTrustedDomain(domain: string): Promise<{
780
+ added: boolean;
781
+ }>;
782
+ /**
783
+ * #267: moves an already-created app's Login V2 base URI, read-modify-
784
+ * write (`@wtfalch/providers`' `ZitadelClient.updateOidcAppLoginBaseUri`).
785
+ * `zitadelLoginHost` below calls this for each live app whose client id
786
+ * the binding holds; `zitadelApp` never sets it at creation.
787
+ */
788
+ updateOidcAppLoginBaseUri(orgId: string, projectId: string, appId: string, loginBaseUri: string): Promise<void>;
769
789
  }
770
790
  /**
771
791
  * Provisions one org's organisation on Files (#80), matching
@@ -959,6 +979,15 @@ export interface FoundryStepExecutorPorts {
959
979
  * step throws. The planner blocks these steps first while a key is not held.
960
980
  */
961
981
  platformOrg?: PlatformOrgPortSource;
982
+ /**
983
+ * #267: zitadel-login's own `LOGIN_HOST_BINDINGS` entry for the org -- see
984
+ * {@link LoginHostBindingsPort}'s own doc comment. Optional the same way
985
+ * `jobsScheduler`/`mailCopy` are: a host that has not wired this yet gets no
986
+ * login-host step at all -- the planner reports it blocked (the host also
987
+ * sets `FoundryPlanApplyOptions.loginHostBindings`), so nothing is trusted
988
+ * or repointed without the binding.
989
+ */
990
+ loginHostBindings?: LoginHostBindingsPort;
962
991
  /**
963
992
  * Hands a just-minted, one-time secret (today: an API app's
964
993
  * `clientSecret`) to the host, immediately after the provider returns it
@@ -1060,6 +1089,14 @@ export declare class FoundryStepExecutor implements StepExecutor, StepDestroyer
1060
1089
  * recorded).
1061
1090
  */
1062
1091
  private zitadelOwner;
1092
+ /**
1093
+ * `plan/diff.ts`'s `planLoginHost` (#267): adds the org's own company
1094
+ * login host (`action.desired.host`) to the ZITADEL instance's trusted
1095
+ * domains, writes zitadel-login's binding, then repoints the apps whose
1096
+ * client id that binding holds. Apps created later are not in it yet:
1097
+ * the next run replans (`diff.ts`'s `planLoginHost`) and moves them.
1098
+ */
1099
+ private zitadelLoginHost;
1063
1100
  /**
1064
1101
  * `plan/diff.ts`'s `planCustomers` (plan gap A9): the org's customers sign
1065
1102
  * in through a second ZITADEL org, `<slug> customers`, with
@@ -1108,28 +1145,34 @@ export declare class FoundryStepExecutor implements StepExecutor, StepDestroyer
1108
1145
  private ensureLogVolume;
1109
1146
  private coolifyDatabase;
1110
1147
  /**
1111
- * Pushes the database's connection string into the app's environment as
1112
- * `DATABASE_URL` -- never also as `DATABASE_URL_OWNER` (Decision, this
1113
- * campaign's report). Archon's own boot script
1114
- * (`archon/scripts/migrate-and-start.sh`) treats a bare `DATABASE_URL` with
1115
- * no `DATABASE_URL_OWNER` as "before the split": it migrates and serves as
1116
- * the one role it has, which is exactly what a database Coolify just
1117
- * created (one superuser role, no separate runtime role yet) is. Setting
1118
- * both to the *same* superuser URL would instead take the split branch --
1119
- * `db-role-guard.sh` then refuses to boot, since `DATABASE_URL_OWNER` being
1120
- * set means it expects `DATABASE_URL` to connect as a distinct,
1121
- * non-superuser runtime role (`<database>_rt`), which nothing here creates.
1122
- * Minting that role's own password and URL is a provisioning step of its
1123
- * own, not built here (Finding, this campaign's report).
1148
+ * Pushes the database's connection string into the app's environment,
1149
+ * split the same way `cmsSite`'s own database is (#117, #239): the
1150
+ * superuser connection as `DATABASE_URL_OWNER`, and a distinct, restricted
1151
+ * `<dbName>_rt` role's connection as `DATABASE_URL`. Archon's and manage's
1152
+ * own boot scripts (`db-role-guard.sh`) refuse to start against a
1153
+ * superuser `DATABASE_URL`, and lokessmie's refuses to start without
1154
+ * `DATABASE_URL_OWNER` at all -- a bare superuser `DATABASE_URL` with no
1155
+ * `DATABASE_URL_OWNER` is "before the split" there too.
1124
1156
  *
1125
- * The connection string is a live credential
1126
- * (`postgres://user:password@host/db`): it is read from
1127
- * `CoolifyPort.getDatabase` and handed straight to `bulkUpsertEnvs` and
1128
- * nowhere else in this class -- never returned, never recorded in
1129
- * `serviceIds` (hence never in the store or an audit record), never logged,
1130
- * never interpolated into a thrown error.
1157
+ * Both connection strings are live credentials
1158
+ * (`postgres://user:password@host/db`): read from `CoolifyPort.getDatabase`
1159
+ * (or minted here, for the runtime one) and handed straight to
1160
+ * `bulkUpsertEnvs` and nowhere else in this class -- never returned, never
1161
+ * recorded in `serviceIds` (hence never in the store or an audit record),
1162
+ * never logged, never interpolated into a thrown error.
1131
1163
  */
1132
1164
  private pushDatabaseUrl;
1165
+ /**
1166
+ * `null` once the app already has a `DATABASE_URL` -- never rotated, the
1167
+ * same rule `ensureCmsRuntimeUrl` follows and for the same reason: the
1168
+ * app's own boot script creates or updates the live `<dbName>_rt` Postgres
1169
+ * role from exactly the username/password this method mints, once:
1170
+ * overwriting `DATABASE_URL` on a later apply would leave that
1171
+ * already-created role's real password out of sync with whatever foundry
1172
+ * hands the app next. See `ensureCmsRuntimeUrl`'s own doc comment for the
1173
+ * parsing and encoding choices this mirrors exactly.
1174
+ */
1175
+ private ensureAppRuntimeUrl;
1133
1176
  /**
1134
1177
  * Pushes what the org's app needs to sign a user in through ZITADEL (gap
1135
1178
  * P2, `.plans/2026-09-26-level-1-org-map.md`) -- a second `bulkUpsertEnvs`
@@ -1146,8 +1189,10 @@ export declare class FoundryStepExecutor implements StepExecutor, StepDestroyer
1146
1189
  * (`AUTH_CLIENT_ID`), all ordered before `coolify:app` for exactly this
1147
1190
  * reason. `AUTH_APP_KEY` (#206 -- which broker an org's app registers with
1148
1191
  * is its own design question, left out of #204's own "How" rather than
1149
- * guessed) and DATABASE_URL_OWNER (a runtime-role split not built yet,
1150
- * `pushDatabaseUrl`'s own doc comment) are out of scope here (task brief).
1192
+ * guessed) is out of scope here (task brief). `DATABASE_URL_OWNER`/
1193
+ * `DATABASE_URL` are pushed by `pushDatabaseUrl` instead, not here --
1194
+ * that call runs only once `hosting.database` is declared and
1195
+ * provisioned, where this one is unconditional (#239).
1151
1196
  */
1152
1197
  private pushAppEnv;
1153
1198
  /**