@wtfalch/foundry 0.17.1 → 0.19.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 (44) hide show
  1. package/dist/bootstrap/declare.d.ts +11 -2
  2. package/dist/bootstrap/declare.js +20 -11
  3. package/dist/index.d.ts +4 -2
  4. package/dist/index.js +2 -1
  5. package/dist/migrations/0045_names_to_edge.sql +107 -0
  6. package/dist/org.d.ts +17 -17
  7. package/dist/org.js +48 -58
  8. package/dist/plan/agora.d.ts +25 -0
  9. package/dist/plan/agora.js +25 -0
  10. package/dist/plan/boule.d.ts +8 -0
  11. package/dist/plan/boule.js +8 -0
  12. package/dist/plan/diff.d.ts +3 -1
  13. package/dist/plan/diff.js +115 -52
  14. package/dist/plan/entry-edge-key.d.ts +79 -0
  15. package/dist/plan/{entry-names-key.js → entry-edge-key.js} +90 -66
  16. package/dist/plan/executor.d.ts +87 -42
  17. package/dist/plan/executor.js +277 -120
  18. package/dist/plan/index.d.ts +2 -2
  19. package/dist/plan/index.js +1 -1
  20. package/dist/plan/live.d.ts +3 -3
  21. package/dist/plan/login-host.d.ts +15 -11
  22. package/dist/plan/login-host.js +16 -16
  23. package/dist/plan/plan-apply.d.ts +6 -5
  24. package/dist/plan/plan-apply.js +40 -38
  25. package/dist/plan/platform-diff.d.ts +22 -22
  26. package/dist/plan/platform-diff.js +45 -45
  27. package/dist/plan/platform-org-ports.d.ts +1 -0
  28. package/dist/plan/platform-org-ports.js +3 -0
  29. package/dist/plan/platform-org.d.ts +8 -8
  30. package/dist/plan/platform-org.js +7 -7
  31. package/dist/plan/platform-providers.d.ts +17 -16
  32. package/dist/plan/platform-providers.js +136 -118
  33. package/dist/plan/providers-readers.d.ts +34 -11
  34. package/dist/plan/providers-readers.js +72 -15
  35. package/dist/plan/step-system.js +1 -1
  36. package/dist/plan/{edge-worker.d.ts → worker.d.ts} +53 -33
  37. package/dist/plan/{edge-worker.js → worker.js} +92 -54
  38. package/dist/platform.d.ts +3 -3
  39. package/dist/platform.js +1 -1
  40. package/dist/store/postgres.js +7 -7
  41. package/dist/store/schema.d.ts +6 -6
  42. package/dist/store/schema.js +4 -4
  43. package/package.json +1 -1
  44. package/dist/plan/entry-names-key.d.ts +0 -73
@@ -51,8 +51,10 @@ export interface PlanOrgOptions {
51
51
  now?: () => number;
52
52
  /** #374: the Archon that bills this org (`plan-apply.ts`'s `orgArchonUrl`), written into Boule's env as `ARCHON_URL`. `null` with no platform wired. */
53
53
  archonUrl?: string | null;
54
- /** #318: the executor was built with a `WorkersPort`, read by `planEdgeWorkers`. */
54
+ /** #318: the executor was built with a `WorkersPort`, read by `planWorkers`. */
55
55
  edgeWorkers?: boolean;
56
+ /** The platform's own service hostnames: `edgeReservedLabels` reserves them under the org's domain, and a wildcard Worker route (`planWorkers`) keeps them out. */
57
+ platformHostnames?: readonly string[];
56
58
  }
57
59
  export interface PlanDetail {
58
60
  orgId: OrgId;
package/dist/plan/diff.js CHANGED
@@ -1,12 +1,11 @@
1
1
  import { VERSIONS, floatingTagBlocked } from '../bootstrap/versions.js';
2
2
  import { APP_ENTRY_KEY, ownerMailboxRecorded, ownerMailboxes } from '../org.js';
3
- import { AGORA_ACTIVATED_ENV_SCOPE_KEY, CALLS_INTROSPECTION_SCOPE_KEY, CALLS_INTROSPECTION_UNDELIVERED_SCOPE_KEY, NO_AGORA_HOST_VALUES, ZITADEL_ORG_SCOPE_KEY, agoraEnvDrift, agoraEnvRevision, agoraHostMissing, agoraHostnameFor, callsServiceHasName, callsServiceHasTokenEnv, callsTokenEnvMovedMessage, callsTokenHostFrom, desiredAgoraEnv, withRecordedMessagingProject, } from './agora.js';
3
+ import { AGORA_ACTIVATED_ENV_SCOPE_KEY, AGORA_APP_PORTS_EXPOSES, CALLS_INTROSPECTION_SCOPE_KEY, CALLS_INTROSPECTION_UNDELIVERED_SCOPE_KEY, NO_AGORA_HOST_VALUES, ZITADEL_ORG_SCOPE_KEY, agoraEnvDrift, agoraEnvRevision, agoraHostMissing, agoraHostnameFor, callsServiceHasName, callsServiceHasTokenEnv, callsTokenEnvMovedMessage, callsTokenHostFrom, desiredAgoraEnv, withRecordedMessagingProject, } from './agora.js';
4
4
  import { BOULE_BROKER_ENTRY_KEY, bouleEnvDrift, desiredBouleEnv, wantedBouleBrokerEntry, } from './boule.js';
5
5
  import { brokerEntryAppliedScopeKey, brokerEntryWarningsScopeKey, entryAuthEnv, entryAuthEnvAppliedScopeKey, wantedBrokerEntry, } from './broker-entries.js';
6
- import { planEdgeWorkers } from './edge-worker.js';
7
6
  import { planEntryAiKeys } from './entry-ai-key.js';
7
+ import { planEdgeReservedLabels, planEntryEdgeKeys } from './entry-edge-key.js';
8
8
  import { planFilesKeys } from './entry-files-key.js';
9
- import { planEntryNamesKeys, planNamesReservedLabels } from './entry-names-key.js';
10
9
  import { appHostname, entryHostnames, tunnelDnsHostnames } from './hostnames.js';
11
10
  import { LOGIN_BINDING_SCOPE_KEY, loginBindingState, loginHostClientIds, loginHostnameFor, pendingRemovalHosts, } from './login-host.js';
12
11
  import { MAIL_COPY_COMPLETED_SCOPE_KEY, MAIL_COPY_DONE_SCOPE_KEY, parseMailCopyCompleted, } from './mail-copy.js';
@@ -15,6 +14,7 @@ import { DEFAULT_ORG_PLATFORM_READINESS } from './platform-org.js';
15
14
  import { PLATFORM_AUTH_BROKER_MAIL_KEY_REF } from './platform-providers.js';
16
15
  import { appLabelsAppliedScopeKey, entryLabelHash, entryLabelSpec } from './proxy-labels.js';
17
16
  import { withStepSystem } from './step-system.js';
17
+ import { planWorkers } from './worker.js';
18
18
  /** The `Plan` (#19's public contract) a `PlanDetail` carries. */
19
19
  export function toPlan(detail) {
20
20
  return {
@@ -82,7 +82,11 @@ export function computePlanDetail(org, live, recordedIds = {}, agoraHost = NO_AG
82
82
  const byKey = new Map(live.map((r) => [resourceKey(r.system, r.kind, r.key), r]));
83
83
  const claimed = new Set();
84
84
  const plannedSteps = [];
85
- const issuer = planIssuer(org, byKey, claimed, platformReadiness.authReady);
85
+ // The platform's own org: its domain is the platform apex (the same fact
86
+ // #491 reads for mail). It signs in on the shared instance's own host, so
87
+ // `issuer.usesDomain` never moves it.
88
+ const isPlatformOrg = isPlatformOwnOrg(org, platformReadiness.platformApex);
89
+ const issuer = planIssuer(org, byKey, claimed, platformReadiness.authReady, isPlatformOrg);
86
90
  if (issuer.step)
87
91
  plannedSteps.push(issuer.step);
88
92
  plannedSteps.push(...planOwners(org, recordedIds, platformReadiness.authReady, 'error' in agoraHost ? null : agoraHost.issuer));
@@ -94,7 +98,7 @@ export function computePlanDetail(org, live, recordedIds = {}, agoraHost = NO_AG
94
98
  // URL (`https://<platform.auth.hostname>`), the same value every org's
95
99
  // sign-in already reads through `planOwners` above -- no separate port
96
100
  // or plumbing needed to know the platform's own auth hostname.
97
- platformAuthHostnameFrom('error' in agoraHost ? null : agoraHost.issuer));
101
+ platformAuthHostnameFrom('error' in agoraHost ? null : agoraHost.issuer), isPlatformOrg);
98
102
  if (loginHost.step)
99
103
  plannedSteps.push(loginHost.step);
100
104
  // Planned ahead of everything that needs the zone live (#238); its own step
@@ -206,14 +210,14 @@ export function computePlanDetail(org, live, recordedIds = {}, agoraHost = NO_AG
206
210
  plannedSteps.push(...filesKeys.steps);
207
211
  // F7 (#317): reserved labels before any entry's own key, so a label is
208
212
  // never claimable by a customer in the gap between an entry's key going
209
- // live and its siblings' labels reaching names.
210
- const namesReservedLabels = planNamesReservedLabels(org, recordedIds, platformReadiness);
211
- if (namesReservedLabels.step)
212
- plannedSteps.push(namesReservedLabels.step);
213
- // After the apps -- a names key needs the entry's app recorded, the same
213
+ // live and its siblings' labels reaching edge.
214
+ const edgeReservedLabels = planEdgeReservedLabels(org, recordedIds, platformReadiness, options.platformHostnames ?? []);
215
+ if (edgeReservedLabels.step)
216
+ plannedSteps.push(edgeReservedLabels.step);
217
+ // After the apps -- a edge key needs the entry's app recorded, the same
214
218
  // dependency `planEntryAiKeys`/`planFilesKeys` have.
215
- const entryNamesKeys = planEntryNamesKeys(org, recordedIds, plannedSteps, platformReadiness, (options.now ?? Date.now)());
216
- plannedSteps.push(...entryNamesKeys.steps);
219
+ const entryEdgeKeys = planEntryEdgeKeys(org, recordedIds, plannedSteps, platformReadiness, (options.now ?? Date.now)());
220
+ plannedSteps.push(...entryEdgeKeys.steps);
217
221
  const tunnel = planTunnel(org, byKey, claimed, recordedIds);
218
222
  // #238: a tunnel that is being provisioned waits for an active zone; parking
219
223
  // one never does.
@@ -274,16 +278,16 @@ export function computePlanDetail(org, live, recordedIds = {}, agoraHost = NO_AG
274
278
  // F8 (#318), after Agora and Boule, not right after the zone: a route
275
279
  // already bound by a hand-made entry, another entry's route, a taken
276
280
  // script name, or no Workers token all throw at apply, and `runSteps`
277
- // stops at the first failure -- planned this early, a failing edge step
281
+ // stops at the first failure -- planned this early, a failing worker step
278
282
  // stopped Agora (and everything after it) from provisioning at all
279
283
  // (#318 re-review). `cloudflareWorker` still resolves the zone by apex
280
284
  // domain the same way `planRateLimit` does, so a deploy (never a
281
285
  // retire, which needs no new zone) still waits for it.
282
- const edgeWorkers = planEdgeWorkers(org, recordedIds, options.edgeWorkers ?? false);
286
+ const edgeWorkers = planWorkers(org, recordedIds, options.edgeWorkers ?? false, options.platformHostnames ?? []);
283
287
  for (const step of edgeWorkers.steps) {
284
288
  if (zoneWaiting && step.action.desired.mode === 'apply') {
285
289
  zoneBlocked.push({
286
- description: `${org.slug}'s ${step.action.key.slice(org.id.length + 1)} edge Worker is blocked, not planned: the zone for ${org.domain} is not active yet`,
290
+ description: `${org.slug}'s ${step.action.key.slice(org.id.length + 1)} Worker is blocked, not planned: the zone for ${org.domain} is not active yet`,
287
291
  key: step.action.key,
288
292
  });
289
293
  }
@@ -312,8 +316,8 @@ export function computePlanDetail(org, live, recordedIds = {}, agoraHost = NO_AG
312
316
  unmanaged.push(...filesKeys.blocked);
313
317
  unmanaged.push(...entryAiKeys.blocked);
314
318
  unmanaged.push(...edgeWorkers.blocked);
315
- unmanaged.push(...entryNamesKeys.blocked);
316
- unmanaged.push(...namesReservedLabels.blocked);
319
+ unmanaged.push(...entryEdgeKeys.blocked);
320
+ unmanaged.push(...edgeReservedLabels.blocked);
317
321
  // Same for a jobs registration that cannot run (no scheduler wired) or must
318
322
  // not run again (minted, never delivered).
319
323
  if (jobsRegistration.blocked) {
@@ -391,7 +395,7 @@ export function computePlanDetail(org, live, recordedIds = {}, agoraHost = NO_AG
391
395
  * already exists live is unaffected: its own domain-move logic runs
392
396
  * regardless, since that never re-provisions the ZITADEL org itself.
393
397
  */
394
- function planIssuer(org, byKey, claimed, authReady) {
398
+ function planIssuer(org, byKey, claimed, authReady, isPlatformOrg = false) {
395
399
  const key = resourceKey('zitadel', 'issuer', org.id);
396
400
  const live = byKey.get(key);
397
401
  const desiredUsesDomain = org.issuer.usesDomain;
@@ -420,9 +424,14 @@ function planIssuer(org, byKey, claimed, authReady) {
420
424
  };
421
425
  }
422
426
  claimed.add(key);
423
- const liveUsesDomain = Boolean(live.config.usesDomain);
424
- if (liveUsesDomain === desiredUsesDomain)
427
+ // The platform's own org already signs in on the shared host: no move. A
428
+ // live issuer that does not say (the reader never does: foundry v1 never
429
+ // moves one) is not read as a different one either.
430
+ if (isPlatformOrg ||
431
+ live.config.usesDomain === undefined ||
432
+ Boolean(live.config.usesDomain) === desiredUsesDomain) {
425
433
  return { step: null, blocked: null };
434
+ }
426
435
  const domain = org.domain ?? 'the org domain';
427
436
  return {
428
437
  step: {
@@ -519,6 +528,9 @@ function planOwners(org, recordedIds, authReady, authIssuer) {
519
528
  * computed `auth.<domain>` against, so the platform's own org never plans
520
529
  * its login host equal to the shared ZITADEL instance's own.
521
530
  */
531
+ function isPlatformOwnOrg(org, platformApex) {
532
+ return Boolean(org.domain && platformApex && org.domain.toLowerCase() === platformApex.toLowerCase());
533
+ }
522
534
  function platformAuthHostnameFrom(issuer) {
523
535
  if (!issuer)
524
536
  return null;
@@ -565,16 +577,58 @@ function platformAuthHostnameFrom(issuer) {
565
577
  * platform's own domain, never plans the shared ZITADEL instance's own login
566
578
  * host as its own -- `auth.companyincluded.com` colliding with itself.
567
579
  */
568
- function planLoginHost(org, recordedIds, authReady, issuerPlanned, bindingsWired, platformAuthHostname) {
580
+ function planLoginHost(org, recordedIds, authReady, issuerPlanned, bindingsWired, platformAuthHostname, isPlatformOrg) {
569
581
  const none = { step: null, blocked: null };
570
582
  const key = resourceKey('zitadel', 'login-host', org.id);
571
- const host = loginHostnameFor(org, platformAuthHostname);
583
+ // The platform's own org signs in on the shared instance's host whatever
584
+ // `issuer.usesDomain` says (turning it on would move the issuer).
585
+ // Before that host is known, plan nothing: a guessed host would delete the
586
+ // working one, and a normal step would trust a domain `destroy` later deletes.
587
+ if (isPlatformOrg && platformAuthHostname === null) {
588
+ return {
589
+ step: null,
590
+ blocked: `${org.slug}'s login host is waiting for the platform auth host`,
591
+ };
592
+ }
593
+ const sharedInstanceHost = isPlatformOrg && platformAuthHostname !== null;
594
+ const host = sharedInstanceHost ? platformAuthHostname : loginHostnameFor(org);
572
595
  // #488: the guard in `loginHostnameFor` fired -- the org's own domain host
573
596
  // would have been the platform's shared ZITADEL instance host.
574
- const sharedInstanceHost = Boolean(org.domain) &&
575
- org.issuer.usesDomain &&
576
- `auth.${org.domain}` === platformAuthHostname &&
577
- host !== `auth.${org.domain}`;
597
+ if (sharedInstanceHost) {
598
+ // The host is the shared instance's own: nothing to trust or bind, and an
599
+ // org with no recorded host already signs in there. Only an org recorded
600
+ // on another host (the old `<slug>-auth.oikos.host`) plans one step, to
601
+ // move its apps over and remove the old host; then it plans nothing.
602
+ const recordedHost = recordedIds[key];
603
+ const moving = recordedHost !== undefined && recordedHost !== host;
604
+ const removalOwed = pendingRemovalHosts(recordedIds).length > 0;
605
+ if (!authReady || !(moving || removalOwed))
606
+ return none;
607
+ // The same gates as the normal branch: the executor's shared step also
608
+ // needs the binding port and the ZITADEL org.
609
+ const waiting = loginHostWaiting(org, host, recordedIds, bindingsWired, issuerPlanned);
610
+ return waiting ?? stepFor(true);
611
+ }
612
+ function stepFor(shared) {
613
+ return {
614
+ step: {
615
+ action: {
616
+ system: 'zitadel',
617
+ kind: 'login-host',
618
+ key: org.id,
619
+ desired: shared ? { host, shared: true } : { host },
620
+ },
621
+ step: {
622
+ description: shared
623
+ ? `move ${org.slug}'s apps onto the platform's own sign-in host ${host}`
624
+ : `set ${org.slug}'s login host to ${host}`,
625
+ destroys: false,
626
+ migrates: false,
627
+ },
628
+ },
629
+ blocked: null,
630
+ };
631
+ }
578
632
  // Already applied -- a re-run plans nothing unless the host changed, an app
579
633
  // client id appeared since the binding was last written (#267, rule 4), or
580
634
  // an earlier host change still has a removal owed (#274 fix round 2):
@@ -595,6 +649,10 @@ function planLoginHost(org, recordedIds, authReady, issuerPlanned, bindingsWired
595
649
  // Isolation between orgs rests on zitadel-login's binding, and repointing
596
650
  // an app at a host with no binding breaks its sign-in: trust nothing and
597
651
  // repoint nothing until the port that writes the binding is wired.
652
+ return loginHostWaiting(org, host, recordedIds, bindingsWired, issuerPlanned) ?? stepFor(false);
653
+ }
654
+ /** Why a login-host step cannot run yet, or `null` when nothing is missing. */
655
+ function loginHostWaiting(org, host, recordedIds, bindingsWired, issuerPlanned) {
598
656
  if (!bindingsWired) {
599
657
  return {
600
658
  step: null,
@@ -609,24 +667,7 @@ function planLoginHost(org, recordedIds, authReady, issuerPlanned, bindingsWired
609
667
  blocked: `${org.slug}'s login host ${host} waits for its ZITADEL org, which is not recorded yet`,
610
668
  };
611
669
  }
612
- return {
613
- step: {
614
- action: {
615
- system: 'zitadel',
616
- kind: 'login-host',
617
- key: org.id,
618
- desired: { host },
619
- },
620
- step: {
621
- description: sharedInstanceHost
622
- ? `set ${org.slug}'s login host to ${host}: its domain host (auth.${org.domain}) is the platform's own shared ZITADEL instance host, so it stays on oikos.host`
623
- : `set ${org.slug}'s login host to ${host}`,
624
- destroys: false,
625
- migrates: false,
626
- },
627
- },
628
- blocked: null,
629
- };
670
+ return null;
630
671
  }
631
672
  /** #230: restated from `executor.ts`'s `MAIL_ROLE_MAILBOXES_SCOPE_KEY`, same rule: comma-joined local parts already created. */
632
673
  const MAIL_ROLE_MAILBOXES_SCOPE_KEY = 'mail_role_mailboxes';
@@ -1594,6 +1635,13 @@ function planAgora(org, byKey, claimed, recordedIds, reading) {
1594
1635
  if (liveEnv.AGORA_CONFIG === undefined && !drift.includes('AGORA_CONFIG')) {
1595
1636
  drift.push('AGORA_CONFIG');
1596
1637
  }
1638
+ // #506: an app Coolify never got `ports_exposes` on (or got the wrong one)
1639
+ // answers 502 behind the tunnel even though its env already matches --
1640
+ // exactly the Company Included incident this issue names. Compared
1641
+ // separately from `drift` above: it is a Coolify application setting, not
1642
+ // an env var, and `providers-readers.ts`'s `withAgoraEnvConfig` reads it
1643
+ // from `getApplication`, never `listEnvs`.
1644
+ const portDrift = live.config.ports_exposes !== AGORA_APP_PORTS_EXPOSES;
1597
1645
  // #442: `drift` alone, once this step has ever run, is comparing `env`
1598
1646
  // against Coolify's own env store -- which the executor's `bulkUpsertEnvs`
1599
1647
  // leaves matching `env` the moment the write succeeds, restarted or not.
@@ -1604,14 +1652,18 @@ function planAgora(org, byKey, claimed, recordedIds, reading) {
1604
1652
  // actually serving this exact `env`; while it disagrees, the step stays
1605
1653
  // planned no matter what Coolify's env store already shows.
1606
1654
  const activated = recordedIds[AGORA_ACTIVATED_ENV_SCOPE_KEY] === agoraEnvRevision(env);
1607
- if (drift.length === 0 && activated)
1655
+ if (drift.length === 0 && activated && !portDrift)
1608
1656
  return { step: null, blocked: null };
1657
+ const reasons = [
1658
+ ...(drift.length > 0 ? [`${drift.join(', ')} missing or different`] : []),
1659
+ ...(portDrift ? ["ports_exposes wrong -- Traefik can't reach it"] : []),
1660
+ ];
1609
1661
  return {
1610
1662
  step: {
1611
1663
  action,
1612
1664
  step: {
1613
- description: drift.length > 0
1614
- ? `update ${org.slug}'s Agora config (${drift.join(', ')} missing or different)`
1665
+ description: reasons.length > 0
1666
+ ? `update ${org.slug}'s Agora config (${reasons.join('; ')})`
1615
1667
  : `activate ${org.slug}'s Agora config -- written but not yet confirmed restarted`,
1616
1668
  destroys: false,
1617
1669
  migrates: false,
@@ -1735,13 +1787,13 @@ function planBoule(org, byKey, claimed, recordedIds, issuer, otherSlugs, archonU
1735
1787
  * park-not-delete rule (#7 corrections 1) -- turning it off never deletes
1736
1788
  * the org's own app, database or tunnel configuration, it parks it.
1737
1789
  */
1738
- function planHostingToggle(system, kind, desiredEnabled, org, byKey, claimed, describeProvision, describePark, stepKey = org.id) {
1790
+ function planHostingToggle(system, kind, desiredEnabled, org, byKey, claimed, describeProvision, describePark, stepKey = org.id, isOn = (live) => live.config.enabled !== false) {
1739
1791
  const key = resourceKey(system, kind, stepKey);
1740
1792
  const live = byKey.get(key);
1741
1793
  // A live resource is on unless it says it is parked. Providers' own
1742
1794
  // configs carry no `enabled` field at all, so only a park step's
1743
1795
  // explicit `enabled: false` reads as off.
1744
- const liveEnabled = live !== undefined && live.config.enabled !== false;
1796
+ const liveEnabled = live !== undefined && isOn(live);
1745
1797
  if (!desiredEnabled) {
1746
1798
  // Nothing declared and nothing live: there is nothing to park.
1747
1799
  if (!live)
@@ -2192,7 +2244,18 @@ function planTunnel(org, byKey, claimed, recordedIds) {
2192
2244
  (org.site.enabled && Boolean(org.domain)) ||
2193
2245
  (org.calls.enabled && org.calls.usesDomain && Boolean(org.domain)) ||
2194
2246
  Boolean(org.domain);
2195
- 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)`);
2247
+ 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)`, org.id,
2248
+ // Cloudflare's status, read against what is declared: a park cannot change
2249
+ // it, so judging on/off by status alone would never let the plan empty.
2250
+ // Wanted on: only `down` needs a repair (`inactive` is a fresh tunnel
2251
+ // whose connector has not dialled in yet). Wanted off: only a connected
2252
+ // tunnel needs parking; `inactive` and `down` are already parked.
2253
+ (live) => {
2254
+ const status = live.config.status;
2255
+ if (typeof status !== 'string')
2256
+ return live.config.enabled !== false;
2257
+ return desired ? status !== 'down' : status === 'healthy' || status === 'degraded';
2258
+ });
2196
2259
  if (toggled || !desired)
2197
2260
  return toggled;
2198
2261
  // A live, enabled tunnel re-plans the same step again when either:
@@ -2214,10 +2277,10 @@ function planTunnel(org, byKey, claimed, recordedIds) {
2214
2277
  // differ from the names now declared. An org that never recorded a list
2215
2278
  // has nothing to compare, so only the domain check above applies to it.
2216
2279
  const published = recordedIds[CLOUDFLARE_TUNNEL_DNS_HOSTNAMES_SCOPE_KEY];
2217
- const namesChanged = published !== undefined &&
2280
+ const edgeChanged = published !== undefined &&
2218
2281
  org.domain !== null &&
2219
2282
  sortedNames(published.split(',')) !== sortedNames(tunnelDnsHostnames(org, org.domain));
2220
- const dnsStale = recordedIds[CLOUDFLARE_TUNNEL_DNS_DOMAIN_SCOPE_KEY] !== org.domain || namesChanged;
2283
+ const dnsStale = recordedIds[CLOUDFLARE_TUNNEL_DNS_DOMAIN_SCOPE_KEY] !== org.domain || edgeChanged;
2221
2284
  if (!connectorMissing && !dnsStale)
2222
2285
  return null;
2223
2286
  return {
@@ -0,0 +1,79 @@
1
+ import type { DeclaredOrg, HostingEntry } from '../org.js';
2
+ import type { DraftStep } from './diff.js';
3
+ import type { OrgPlatformReadiness } from './platform-org.js';
4
+ /**
5
+ * F7 (#317): one edge key per entry with `uses.edge`, minted directly
6
+ * below the org with the edge service (`EdgePort.mintKey({
7
+ * organisationId, ... })` -- no org-level root key the way files/ai have
8
+ * one, since the edge service takes the org id itself). `EDGE_URL` and
9
+ * `EDGE_API_KEY` go to the entry's env and nowhere else. A edge key has no
10
+ * declared renewal window in the issue, so unlike `planEntryAiKeys`/
11
+ * `planFilesKeys` this mints once and never renews.
12
+ *
13
+ * `EDGE_*` is written in this step, after the app's own `coolify:app`
14
+ * create (which never knows the key yet), so the app's first boot has
15
+ * neither and its first deploy restarts once this step's env write lands --
16
+ * the same one-extra-restart shape `planEntryAiKeys`'s `AI_*`/
17
+ * `planFilesKeys`'s `FILES_*` already have, not a names-specific gap.
18
+ *
19
+ * A domain change after mint is not detected (#317 re-review): the key is
20
+ * bound to the domain passed at mint time, but nothing here records that
21
+ * domain to compare against a later `org.domain`, so an org that changes
22
+ * domains keeps a edge key still bound to the old one. Not built --
23
+ * resolving it needs a manual revoke-and-re-mint (drop `uses.edge`, apply,
24
+ * re-add it) until this is.
25
+ */
26
+ export declare const entryEdgeUndeliveredScopeKey: (entryKey: string) => string;
27
+ /** The id of a key that was live when its entry stopped declaring `uses.edge`, once handled. */
28
+ export declare const entryEdgeRetiredScopeKey: (entryKey: string) => string;
29
+ export declare const ENTRY_EDGE_KEY_DELIVERY_ATTEMPTS = 3;
30
+ /** How long an entry's edge key lives. The issue names no renewal window, so this mints once and never renews. */
31
+ export declare const EDGE_ENTRY_KEY_DAYS = 365;
32
+ /** The ISO time a delivered key's mint succeeded -- the only trace of its age, since there is no renewal to track against. */
33
+ export declare const entryEdgeMintedAtScopeKey: (entryKey: string) => string;
34
+ /** A key with fewer days left than this is surfaced as a blocked warning row (#317 review): still live, but nothing here renews it. */
35
+ export declare const EDGE_ENTRY_KEY_WARN_WINDOW_DAYS = 30;
36
+ /** The reserved labels last sent to the edge service, as that sorted list's own JSON. */
37
+ export declare const EDGE_RESERVED_LABELS_APPLIED_SCOPE_KEY = "edge_reserved_labels_applied";
38
+ export declare const entryEdgeKeyName: (org: DeclaredOrg, entry: HostingEntry) => string;
39
+ /**
40
+ * Every label under `org.domain` that foundry provisions or the platform
41
+ * uses, so edge never lets a company claim one: the hostnames foundry routes
42
+ * (`tunnelIngressHostnames`: entries, site, cms, calls, Agora, Boule, share),
43
+ * the names it locks (`lockedHostnames`: also the sign-in host and the
44
+ * platform's service hostnames), each entry's own labels, the mail labels
45
+ * and `www`. The entry's `*` is the space users claim in, so it reserves
46
+ * nothing, and neither does the bare domain.
47
+ *
48
+ * An org without a domain has no hostnames edge sees (`uses.edge` needs a
49
+ * domain), so it reserves `www` only.
50
+ */
51
+ export declare function edgeReservedLabels(org: DeclaredOrg, platformHostnames?: readonly string[]): string[];
52
+ type Blocked = Array<{
53
+ description: string;
54
+ key: string;
55
+ }>;
56
+ /**
57
+ * One step per entry whose edge key is missing, plus one retire step per
58
+ * entry whose `uses.edge` was removed but still has a recorded key.
59
+ * Blocked, not planned (rule 2), while the platform has no edge service
60
+ * wired, the entry's app is not recorded, or a previous mint's delivery
61
+ * failed.
62
+ */
63
+ export declare function planEntryEdgeKeys(org: DeclaredOrg, recordedIds: Readonly<Record<string, string>>, plannedSteps: readonly DraftStep[], readiness: OrgPlatformReadiness, now?: number): {
64
+ steps: DraftStep[];
65
+ blocked: Blocked;
66
+ };
67
+ /**
68
+ * The reserved-labels step: resent whenever the org's own label set
69
+ * (`edgeReservedLabels`) differs from what was last sent. While the org
70
+ * uses edge at all (at least one entry with `uses.edge`) that set is
71
+ * `edgeReservedLabels(org)`; once the last entry drops `uses.edge`, this
72
+ * clears whatever was last sent (an empty list) rather than leaving it
73
+ * stuck in names forever -- a one-time step, same as any other change.
74
+ */
75
+ export declare function planEdgeReservedLabels(org: DeclaredOrg, recordedIds: Readonly<Record<string, string>>, readiness: OrgPlatformReadiness, platformHostnames?: readonly string[]): {
76
+ step: DraftStep | null;
77
+ blocked: Blocked;
78
+ };
79
+ export {};