@wtfalch/foundry 0.17.0 → 0.17.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.
@@ -139,6 +139,23 @@ export const PLATFORM_ARCHON_APPLIED_GIT_REF_SCOPE_KEY = 'archon_applied_git_ref
139
139
  * unrecorded and the old key stays pinned.
140
140
  */
141
141
  export const PLATFORM_ARCHON_INTEGRATIONS_KEY_SYNCED_SCOPE_KEY = 'archon_integrations_key_synced';
142
+ /**
143
+ * #467: sha256 of the exact env set the *last confirmed* Archon deploy ran
144
+ * with, recorded only once that deploy's `waitForHealthyVersion` passed --
145
+ * mirroring {@link PLATFORM_ARCHON_INTEGRATIONS_KEY_SYNCED_SCOPE_KEY}'s own
146
+ * confirm-after-health-check timing, generalized from one env to all of
147
+ * them. `provisionPlatformArchon` clears this to `''` the moment it decides
148
+ * a redeploy is needed, before attempting it: a deploy that then fails (or
149
+ * a crash before the health check passes) must leave the attempt
150
+ * unconfirmed, and the env hash alone cannot tell a failed retry's intended
151
+ * content apart from a successful one's, since both intend the same bytes.
152
+ * Without this, a run that writes new envs and then fails before Archon
153
+ * redeploys -- `envsDiffer` comparing desired envs against the *live*
154
+ * store, which this run's own write already updated -- leaves a retry
155
+ * reading no difference at all, and the container stays on the stale
156
+ * values until someone redeploys by hand (#467).
157
+ */
158
+ export const PLATFORM_ARCHON_ENVS_DEPLOYED_SCOPE_KEY = 'archon_envs_deployed_hash';
142
159
  /**
143
160
  * #211 round 4: the broker app id Archon last registered its entry with.
144
161
  * `provisionPlatformArchon` reads the broker to register with from
@@ -157,6 +174,21 @@ export const PLATFORM_ARCHON_APPLIED_BROKER_SCOPE_KEY = 'archon_applied_broker_a
157
174
  * has recorded it.
158
175
  */
159
176
  export const PLATFORM_ARCHON_APPLIED_OPERATOR_SCOPE_KEY = 'archon_applied_bootstrap_operator';
177
+ /**
178
+ * #475: whether the last successful Archon apply patched the app with
179
+ * `custom_network_aliases` and `is_consistent_container_name_enabled: false`
180
+ * instead of the old fixed `custom_internal_name` alone. Coolify only runs a
181
+ * rolling, health-checked deploy -- start the new container, wait, then stop
182
+ * the old one -- when the consistent-name flag is off; with it on (set by
183
+ * foundry's own pre-#475 applies, or by hand in the Coolify UI) every deploy
184
+ * stops the old container before the new one exists, which is what took
185
+ * Archon down for #475's incident. Absent on every platform applied before
186
+ * this fix landed; `planArchon` plans the step again until
187
+ * `provisionPlatformArchon` records it, which only happens once that run's
188
+ * redeploy has passed its health check -- the same confirm-after-health-check
189
+ * timing {@link PLATFORM_ARCHON_ENVS_DEPLOYED_SCOPE_KEY} uses.
190
+ */
191
+ export const PLATFORM_ARCHON_APPLIED_ROLLING_UPDATE_SCOPE_KEY = 'archon_applied_rolling_update';
160
192
  /**
161
193
  * The owner's ZITADEL user id Archon takes as its first operator, from
162
194
  * {@link PLATFORM_AUTH_OWNER_INVITE_SCOPE_KEY}. Undefined when none is
@@ -454,6 +486,20 @@ export function appEnvAppliedScopeKey(entryKey) {
454
486
  export function dbEnvAppliedScopeKey(entryKey) {
455
487
  return `db_env_applied.${entryKey}`;
456
488
  }
489
+ /**
490
+ * #456: the same marker as {@link dbEnvAppliedScopeKey}, for the chat and
491
+ * calls-service sibling platform services' own owner/runtime database split
492
+ * -- in its own `service_db_env_applied.` namespace, never
493
+ * `dbEnvAppliedScopeKey`'s `db_env_applied.`. Those two services are not
494
+ * hosting entries, but `dbEnvAppliedScopeKey`'s `entryKey` is an arbitrary
495
+ * caller-chosen string with no reserved words (`validateHostingEntry` only
496
+ * requires a DNS label), so a hosting entry a company declares with the key
497
+ * `chat` or `calls-service` would otherwise read or clear the sibling
498
+ * service's own marker, and vice versa.
499
+ */
500
+ export function serviceDbEnvAppliedScopeKey(service) {
501
+ return `service_db_env_applied.${service}`;
502
+ }
457
503
  export function appSettingsOf(entry) {
458
504
  return { folder: entry.folder, port: entry.port, autoDeploy: entry.autoDeploy };
459
505
  }
@@ -651,6 +697,50 @@ export const PLATFORM_FILES_MANAGEMENT_UNDELIVERED_SCOPE_KEY = 'files_management
651
697
  */
652
698
  export const PLATFORM_FILES_APPLIED_HOSTNAME_SCOPE_KEY = 'files_applied_hostname';
653
699
  export const PLATFORM_FILES_APPLIED_GIT_REF_SCOPE_KEY = 'files_applied_git_ref';
700
+ /**
701
+ * #478: the resource key `planNames` plans under and
702
+ * `FoundryPlatformStepExecutor` dispatches on -- the {@link PLATFORM_FILES_KEY}
703
+ * shape for the names host (https://github.com/wtfalch/names). `runPlatformSteps`
704
+ * records the app's uuid under `coolify:app:platform:names` once the step
705
+ * succeeds, which is what stops `planNames` planning it again.
706
+ */
707
+ export const PLATFORM_NAMES_KEY = 'platform:names';
708
+ /**
709
+ * #478: the held-key ref the names platform secret's full value is
710
+ * delivered under, once, by {@link FoundryStepExecutorPorts.deliverSecret} --
711
+ * never the host's app env, which gets only its SHA-256 hash
712
+ * (`NAMES_PLATFORM_KEY_HASH`). Archon's own `NamesPort` reads the full
713
+ * secret back from this ref to call names' `mintKey`/`revokeKey`/
714
+ * `setReservedLabels`.
715
+ */
716
+ export const PLATFORM_NAMES_KEY_REF = 'names:platform:key';
717
+ /**
718
+ * #478: the held-key ref for names' own Cloudflare API token (tunnel edit +
719
+ * DNS edit on one account) -- the issue's own "never Archon's token": this
720
+ * is a narrower, names-only credential a human provisions and holds
721
+ * separately from whatever broader token `PlatformNamesDeps.cloudflare`
722
+ * uses to stand the host itself up on Coolify.
723
+ */
724
+ export const PLATFORM_NAMES_CLOUDFLARE_TOKEN_REF = 'cloudflare:names:apiToken';
725
+ /**
726
+ * #478: scope keys (colon-free) for the names platform key, the
727
+ * {@link PLATFORM_FILES_MANAGEMENT_UNDELIVERED_SCOPE_KEY} pattern once:
728
+ * the undelivered id is recorded and persisted the moment the secret is
729
+ * minted, before delivery; the guard is recorded only once it was delivered
730
+ * AND its hash written to the runner's env. An undelivered id with no guard
731
+ * means the secret is lost (never kept past that point): the planner
732
+ * reports it and never mints again. Neither key holds the secret itself.
733
+ */
734
+ export const PLATFORM_NAMES_PLATFORM_KEY_UNDELIVERED_SCOPE_KEY = 'names_platform_key_undelivered';
735
+ export const PLATFORM_NAMES_PLATFORM_KEY_SET_SCOPE_KEY = 'names_platform_key_set';
736
+ /**
737
+ * #478: the hostname and `gitRef` the last successful names run applied.
738
+ * `planNames` plans the step again when either differs from the declared
739
+ * value; a re-run updates `git_branch` and the runner env and redeploys,
740
+ * and mints no new platform key (the guards above hold).
741
+ */
742
+ export const PLATFORM_NAMES_APPLIED_HOSTNAME_SCOPE_KEY = 'names_applied_hostname';
743
+ export const PLATFORM_NAMES_APPLIED_GIT_REF_SCOPE_KEY = 'names_applied_git_ref';
654
744
  /**
655
745
  * #167: the platform's own diff engine, mirroring `diff.ts`'s
656
746
  * `computePlanDetail` for the one shared {@link DeclaredPlatform} record
@@ -665,7 +755,8 @@ export const PLATFORM_FILES_APPLIED_GIT_REF_SCOPE_KEY = 'files_applied_git_ref';
665
755
  * Scope for #167: the frame plus one step, {@link planApexZone}. #173 adds
666
756
  * {@link planJobs}, #170 adds {@link planSfu}, #168 adds {@link planAuth},
667
757
  * #169 adds {@link planMail}, #174 adds {@link planIntegrations}, #171 adds
668
- * {@link planFiles}, #172 adds {@link planAi} and #175 adds
758
+ * {@link planFiles}, #172 adds {@link planAi}, #478 adds {@link planNames}
759
+ * and #175 adds
669
760
  * {@link planArchon}, the same way #19 shipped org plan/apply against
670
761
  * three singletons before #27, #71, #80 and the rest added the remaining
671
762
  * twelve. Until then, a live resource under one of those eventual keys is
@@ -714,6 +805,10 @@ export function planPlatform(platform, live, options = {}) {
714
805
  const ai = planAi(platform, recordedIds, blocked.ai);
715
806
  if (ai.step)
716
807
  plannedSteps.push(ai.step);
808
+ // #478: independent of auth and files -- the names host needs neither.
809
+ const names = planNames(platform, recordedIds, blocked.names);
810
+ if (names.step)
811
+ plannedSteps.push(names.step);
717
812
  // #175: last, on purpose. Archon's sign-in client lives in the sign-in
718
813
  // `planAuth` builds, and its env reads what every other service's step
719
814
  // records or delivers, so in one apply it runs after all of them.
@@ -747,6 +842,9 @@ export function planPlatform(platform, live, options = {}) {
747
842
  integrations.blocked
748
843
  ? null
749
844
  : versionChangeLine('integrations', recordedIds[PLATFORM_INTEGRATIONS_APPLIED_GITREF_SCOPE_KEY], platform.integrations.gitRef, 'git ref, redeploy'),
845
+ names.blocked
846
+ ? null
847
+ : versionChangeLine('names', recordedIds[PLATFORM_NAMES_APPLIED_GIT_REF_SCOPE_KEY], platform.names.gitRef, 'git ref, redeploy'),
750
848
  auth.blocked
751
849
  ? null
752
850
  : versionNotAppliedLine('zitadel', Boolean(recordedIds[resourceKey('coolify', 'app', PLATFORM_AUTH_KEY)]), recordedIds[PLATFORM_AUTH_ZITADEL_APPLIED_IMAGE_TAG_SCOPE_KEY], VERSIONS.zitadel),
@@ -778,6 +876,8 @@ export function planPlatform(platform, live, options = {}) {
778
876
  unmanaged.push({ description: files.blocked, key: 'files' });
779
877
  if (ai.blocked)
780
878
  unmanaged.push({ description: ai.blocked, key: 'ai' });
879
+ if (names.blocked)
880
+ unmanaged.push({ description: names.blocked, key: 'names' });
781
881
  if (archon.blocked)
782
882
  unmanaged.push({ description: archon.blocked, key: 'archon' });
783
883
  // #398: the old integrations key stays pinned until Archon holds the new
@@ -1646,6 +1746,62 @@ function planFiles(platform, recordedIds, executorBlocked, authReady, drifted) {
1646
1746
  blocked: null,
1647
1747
  };
1648
1748
  }
1749
+ /**
1750
+ * #478: the names host (https://github.com/wtfalch/names) -- its database
1751
+ * (owner and runtime roles), its one Coolify app (no separate migrate app:
1752
+ * the Dockerfile migrates inside the runner's own entrypoint, see
1753
+ * `provisionPlatformNames`'s own doc comment), foundry's own platform key
1754
+ * (minted once, its hash alone written to the runner) and a Cloudflare
1755
+ * tunnel + DNS record at `platform.names.hostname`. One `coolify:app` step
1756
+ * keyed {@link PLATFORM_NAMES_KEY}, the {@link planFiles} bundling shape,
1757
+ * public like {@link planMail}/{@link planAuth} rather than internal-only
1758
+ * like {@link planFiles}/{@link planAi}.
1759
+ *
1760
+ * Idempotent on the recorded app id plus the applied hostname and `gitRef`,
1761
+ * for {@link planAuth}'s own reason: no platform reader reports this
1762
+ * Coolify app. A changed hostname or `gitRef` plans the step again (a
1763
+ * redeploy, no new secret).
1764
+ *
1765
+ * Blocked, not planned: a platform key minted but never delivered (lost,
1766
+ * `ServiceError`-free the way {@link planFiles}'s own management-key check
1767
+ * is), or `executorBlocked` -- the host built no names dependencies, or
1768
+ * (CLAUDE.md's "Human steps") no Cloudflare API token is held yet under
1769
+ * {@link PLATFORM_NAMES_CLOUDFLARE_TOKEN_REF}.
1770
+ */
1771
+ function planNames(platform, recordedIds, executorBlocked) {
1772
+ if (!platform.names.enabled)
1773
+ return { step: null, blocked: null };
1774
+ const undelivered = recordedIds[PLATFORM_NAMES_PLATFORM_KEY_UNDELIVERED_SCOPE_KEY];
1775
+ if (undelivered && !recordedIds[PLATFORM_NAMES_PLATFORM_KEY_SET_SCOPE_KEY]) {
1776
+ return {
1777
+ step: null,
1778
+ blocked: `the names host is blocked, not planned: its platform key (${undelivered}) was minted but never delivered and cannot be shown again; remove the recorded key ${PLATFORM_NAMES_PLATFORM_KEY_UNDELIVERED_SCOPE_KEY} to mint a new one`,
1779
+ };
1780
+ }
1781
+ const applied = Boolean(recordedIds[resourceKey('coolify', 'app', PLATFORM_NAMES_KEY)]) &&
1782
+ recordedIds[PLATFORM_NAMES_APPLIED_HOSTNAME_SCOPE_KEY] === platform.names.hostname &&
1783
+ recordedIds[PLATFORM_NAMES_APPLIED_GIT_REF_SCOPE_KEY] === platform.names.gitRef;
1784
+ if (applied)
1785
+ return { step: null, blocked: null };
1786
+ if (executorBlocked)
1787
+ return { step: null, blocked: executorBlocked };
1788
+ return {
1789
+ step: {
1790
+ action: {
1791
+ system: 'coolify',
1792
+ kind: 'app',
1793
+ key: PLATFORM_NAMES_KEY,
1794
+ desired: { hostname: platform.names.hostname, gitRef: platform.names.gitRef },
1795
+ },
1796
+ step: {
1797
+ description: `provision the names host at ${platform.names.hostname}, with its own database and foundry's platform key`,
1798
+ destroys: false,
1799
+ migrates: false,
1800
+ },
1801
+ },
1802
+ blocked: null,
1803
+ };
1804
+ }
1649
1805
  /**
1650
1806
  * #172: the platform's AI host (https://github.com/wtfalch/ai) -- a Coolify
1651
1807
  * Postgres plus three apps built from that repo's root Dockerfile, one per
@@ -1780,6 +1936,15 @@ function planArchon(platform, recordedIds, executorBlocked, authReady, drifted)
1780
1936
  recordedIds[PLATFORM_AUTH_BROKER_SCOPE_KEY] &&
1781
1937
  (recordedIds[PLATFORM_ARCHON_APPLIED_OPERATOR_SCOPE_KEY] ?? 'none') ===
1782
1938
  (archonBootstrapOperator(recordedIds) ?? 'none') &&
1939
+ // #475: a platform applied before the rolling-update fix keeps planning
1940
+ // this step until a confirmed redeploy has cleared it. #480: this marker
1941
+ // alone does not catch the live app's settings changing back afterward
1942
+ // (the consistent-name flag turned on, the alias removed, the health
1943
+ // check disabled) -- `driftedSteps`' `archonRollingUpdateDrifted` catches
1944
+ // that by re-reading the live app, and sets `drifted` below, which plans
1945
+ // the step again the same way an already-`applied` step does for any
1946
+ // other drift reason.
1947
+ recordedIds[PLATFORM_ARCHON_APPLIED_ROLLING_UPDATE_SCOPE_KEY] === '1' &&
1783
1948
  !recordedIds[PLATFORM_ARCHON_GATE_CLOSED_SCOPE_KEY] &&
1784
1949
  // #398: a rotated integrations key id Archon's env was not yet written
1785
1950
  // with (see PLATFORM_ARCHON_INTEGRATIONS_KEY_SYNCED_SCOPE_KEY). Absent
@@ -46,6 +46,21 @@ export interface OrgPlatformReadiness {
46
46
  apex: string;
47
47
  mailHost: string;
48
48
  } | null;
49
+ /**
50
+ * #491 (review round 2): `DeclaredPlatform.apex` itself (not `mailHost.apex`
51
+ * above, which is the fixed domainless-org fallback, {@link
52
+ * NO_DOMAIN_MAIL_APEX}) -- `null` only when no platform is declared at all.
53
+ * Deliberately *not* folded into `mailHost` or gated on mail being enabled
54
+ * or applied: an org's `coolify:app` step needs to recognise the platform's
55
+ * own org (its mail domain equals this) even while the platform's mail
56
+ * server is off or not yet applied -- the first review round gated this on
57
+ * `mailHost` instead, which let `ensureAppLettermintMail` take the
58
+ * unprotected path the moment platform mail went from applied back to off
59
+ * while `MAIL_DOMAIN_SCOPE_KEY` was still recorded as the apex.
60
+ */
61
+ platformApex: string | null;
62
+ /** `DeclaredPlatform.mail.lettermintProjectId`, `null` when undeclared or when no platform is declared. See {@link platformApex}'s own doc comment for why this is unconditional too. */
63
+ platformLettermintProjectId: string | null;
49
64
  /** The files host (#171). Files is always on for every org (#80), so `false` always blocks rather than only mattering when a flag is set. */
50
65
  filesReady: boolean;
51
66
  /**
@@ -72,6 +87,17 @@ export interface OrgPlatformReadiness {
72
87
  authReady: boolean;
73
88
  /** The jobs scheduler (#173) is enabled, applied, and its admin token is held. */
74
89
  jobsReady: boolean;
90
+ /**
91
+ * #317 (F7) and #478: the names service is declared `enabled`, its own
92
+ * `coolify:app` deploy step has applied (`provisionPlatformNames`), and
93
+ * the host's executor was built with a `NamesPort` -- the same
94
+ * `filesReady`/`aiReady` "declared and applied" shape now that #478 built
95
+ * the deploy step #317 shipped ahead of (this field's own doc comment
96
+ * used to say there was none; N1 is no longer deployed by hand).
97
+ */
98
+ namesReady: boolean;
99
+ /** `https://<platform.names.hostname>`, which an entry with `uses.names` gets as `NAMES_URL`. `null` until `namesReady`. */
100
+ namesUrl?: string | null;
75
101
  /**
76
102
  * The ref of a platform key an applied service needs that is not held yet,
77
103
  * `null` when it is held or when the service is not applied. A ref only,
@@ -139,7 +165,9 @@ export declare function orgPlatformReadinessFrom(platform: DeclaredPlatform, pla
139
165
  */
140
166
  heldRefs?: ReadonlySet<string>,
141
167
  /** True when the host wired no held-keys reader ({@link OrgPlatformReadiness.noKeyReader}). */
142
- noKeyReader?: boolean): OrgPlatformReadiness;
168
+ noKeyReader?: boolean,
169
+ /** #317: true when the host's executor was built with a `NamesPort` ({@link OrgPlatformReadiness.namesReady}). */
170
+ namesPortPresent?: boolean): OrgPlatformReadiness;
143
171
  /**
144
172
  * #177: {@link AgoraHostValues} derived from the same platform state, rather
145
173
  * than requiring a bespoke host-supplied `AgoraHostPort` for values the
@@ -1,4 +1,4 @@
1
- import { JOBS_SCHEDULER_KEY, PLATFORM_AI_INTERNAL_URL_SCOPE_KEY, PLATFORM_AI_KEY, PLATFORM_AUTH_KEY, PLATFORM_FILES_INTERNAL_URL_SCOPE_KEY, PLATFORM_FILES_KEY, PLATFORM_MAIL_INTERNAL_URL_SCOPE_KEY, PLATFORM_MAIL_KEY, PLATFORM_MAIL_ZITADEL_PROJECT_SCOPE_KEY, platformMailMxHost, } from './platform-diff.js';
1
+ import { JOBS_SCHEDULER_KEY, PLATFORM_AI_INTERNAL_URL_SCOPE_KEY, PLATFORM_AI_KEY, PLATFORM_AUTH_KEY, PLATFORM_FILES_INTERNAL_URL_SCOPE_KEY, PLATFORM_FILES_KEY, PLATFORM_MAIL_INTERNAL_URL_SCOPE_KEY, PLATFORM_MAIL_KEY, PLATFORM_MAIL_ZITADEL_PROJECT_SCOPE_KEY, PLATFORM_NAMES_KEY, platformMailMxHost, } from './platform-diff.js';
2
2
  import { FILES_MANAGEMENT_KEY_REF, JOBS_ADMIN_TOKEN_REF, PLATFORM_AI_MANAGEMENT_KEY_REF, } from './platform-providers.js';
3
3
  /**
4
4
  * #443: the no-domain company mail apex -- `<slug>.oikos.host`, the same
@@ -25,11 +25,14 @@ export const NO_DOMAIN_MAIL_APEX = 'oikos.host';
25
25
  */
26
26
  export const DEFAULT_ORG_PLATFORM_READINESS = {
27
27
  mailHost: null,
28
+ platformApex: null,
29
+ platformLettermintProjectId: null,
28
30
  filesReady: false,
29
31
  aiReady: false,
30
32
  callsReady: false,
31
33
  authReady: false,
32
34
  jobsReady: false,
35
+ namesReady: false,
33
36
  missingKeys: { files: null, ai: null, jobs: null },
34
37
  };
35
38
  /** The held-key refs an org step needs from the platform, one per service. */
@@ -76,11 +79,14 @@ export function orgPlatformReadinessFrom(platform, platformRecordedIds,
76
79
  */
77
80
  heldRefs,
78
81
  /** True when the host wired no held-keys reader ({@link OrgPlatformReadiness.noKeyReader}). */
79
- noKeyReader = false) {
82
+ noKeyReader = false,
83
+ /** #317: true when the host's executor was built with a `NamesPort` ({@link OrgPlatformReadiness.namesReady}). */
84
+ namesPortPresent = false) {
80
85
  const mailApplied = platform.mail.enabled && coolifyAppApplied(platformRecordedIds, PLATFORM_MAIL_KEY);
81
86
  const filesApplied = platform.files.enabled && coolifyAppApplied(platformRecordedIds, PLATFORM_FILES_KEY);
82
87
  const aiApplied = platform.ai.enabled && coolifyAppApplied(platformRecordedIds, PLATFORM_AI_KEY);
83
88
  const jobsApplied = platform.jobs.enabled && Boolean(platformRecordedIds[`coolify:jobs:${JOBS_SCHEDULER_KEY}`]);
89
+ const namesApplied = platform.names.enabled && coolifyAppApplied(platformRecordedIds, PLATFORM_NAMES_KEY);
84
90
  const missing = (applied, ref) => applied && heldRefs !== undefined && !heldRefs.has(ref) ? ref : null;
85
91
  const missingKeys = {
86
92
  files: missing(filesApplied, ORG_PLATFORM_KEY_REFS.files),
@@ -91,6 +97,13 @@ noKeyReader = false) {
91
97
  mailHost: mailApplied
92
98
  ? { apex: NO_DOMAIN_MAIL_APEX, mailHost: platformMailMxHost(platform.apex) }
93
99
  : null,
100
+ // #491 (review round 2): unconditional -- `platform` here is always a
101
+ // real declared platform (this function's own doc comment: "the platform
102
+ // is either declared ... or this is never called"), so its apex and its
103
+ // mail.lettermintProjectId are facts regardless of whether mail itself is
104
+ // enabled or applied.
105
+ platformApex: platform.apex,
106
+ platformLettermintProjectId: platform.mail.lettermintProjectId ?? null,
94
107
  filesReady: filesApplied && missingKeys.files === null,
95
108
  filesUrl: filesApplied
96
109
  ? (platformRecordedIds[PLATFORM_FILES_INTERNAL_URL_SCOPE_KEY] ?? null)
@@ -100,6 +113,8 @@ noKeyReader = false) {
100
113
  callsReady: platform.sfu.enabled,
101
114
  authReady: platform.auth.enabled && coolifyAppApplied(platformRecordedIds, PLATFORM_AUTH_KEY),
102
115
  jobsReady: jobsApplied && missingKeys.jobs === null,
116
+ namesReady: namesApplied && namesPortPresent,
117
+ namesUrl: namesApplied && namesPortPresent ? `https://${platform.names.hostname}` : null,
103
118
  missingKeys,
104
119
  ...(noKeyReader ? { noKeyReader } : {}),
105
120
  };
@@ -131,6 +131,12 @@ export interface FoundryPlatformStepExecutorOptions {
131
131
  * `platform.ai.enabled` without it gets the step reported as blocked.
132
132
  */
133
133
  ai?: PlatformAiDeps;
134
+ /**
135
+ * #478: dependencies for `coolify:app` keyed {@link PLATFORM_NAMES_KEY} --
136
+ * optional for the same reason as `auth`; a host that declares
137
+ * `platform.names.enabled` without it gets the step reported as blocked.
138
+ */
139
+ names?: PlatformNamesDeps;
134
140
  /**
135
141
  * #175: dependencies for `coolify:app` keyed {@link PLATFORM_ARCHON_KEY}
136
142
  * -- optional for the same reason as {@link auth}.
@@ -197,6 +203,9 @@ export interface FoundryPlatformStepExecutorOptions {
197
203
  * #172 adds `coolify:app` keyed {@link PLATFORM_AI_KEY}, the platform's AI
198
204
  * host ({@link provisionPlatformAi}), once a host passes {@link PlatformAiDeps}.
199
205
  *
206
+ * #478 adds `coolify:app` keyed {@link PLATFORM_NAMES_KEY}, the names host
207
+ * ({@link provisionPlatformNames}), once a host passes {@link PlatformNamesDeps}.
208
+ *
200
209
  * #175 adds `coolify:app` keyed {@link PLATFORM_ARCHON_KEY}, Archon itself
201
210
  * ({@link provisionPlatformArchon}), once a host passes
202
211
  * {@link PlatformArchonDeps}.
@@ -216,6 +225,7 @@ export declare class FoundryPlatformStepExecutor implements PlatformStepExecutor
216
225
  private readonly integrations?;
217
226
  private readonly files?;
218
227
  private readonly ai?;
228
+ private readonly names?;
219
229
  private readonly archon?;
220
230
  private readonly backup?;
221
231
  constructor(cloudflare: Pick<CloudflarePort, 'findZoneByApex' | 'createZone'>, declaredOrgStore: Pick<DeclaredOrgStore, 'findOrgIdForZone' | 'list' | 'get'>, options?: FoundryPlatformStepExecutorOptions);
@@ -226,6 +236,7 @@ export declare class FoundryPlatformStepExecutor implements PlatformStepExecutor
226
236
  files?: string;
227
237
  integrations?: string;
228
238
  ai?: string;
239
+ names?: string;
229
240
  archon?: string;
230
241
  mail?: string;
231
242
  backupExecutorMissing?: boolean;
@@ -811,6 +822,39 @@ export declare const FILES_MANAGEMENT_KEY_ID = "foundry-1";
811
822
  export declare const FILES_MANAGEMENT_KEY_REF = "files:platform:managementKey";
812
823
  /** The ref the files host's introspection client secret is delivered under. */
813
824
  export declare const FILES_INTROSPECTION_SECRET_REF = "zitadel:platform:files:introspection";
825
+ export interface PlatformNamesRuntime {
826
+ fetch?: typeof fetch;
827
+ sleep?: (ms: number) => Promise<void>;
828
+ now?: () => number;
829
+ /** Tries at handing the platform key to the host (default 3). */
830
+ deliverAttempts?: number;
831
+ readyTimeoutMs?: number;
832
+ readyIntervalMs?: number;
833
+ }
834
+ export interface PlatformNamesDeps {
835
+ coolify: Pick<CoolifyPort, 'findProjectByName' | 'createProject' | 'findOrCreateEnvironment' | 'listServers' | 'findApplicationByName' | 'getApplication' | 'requireGithubApp' | 'createGithubAppApplication' | 'createDockerfileApplication' | 'listDatabases' | 'createPostgresDatabase' | 'getDatabase' | 'bulkUpsertEnvs' | 'listEnvs' | 'updateApplication' | 'deploy' | 'deployAndWait' | 'raw'>;
836
+ cloudflare: Pick<CloudflarePort, 'findZoneByApex' | 'findTunnelByName' | 'createTunnel' | 'putTunnelConfig' | 'getTunnelToken' | 'findDnsRecord' | 'createDnsRecord' | 'updateDnsRecord'>;
837
+ deliverSecret: FoundryStepExecutorPorts['deliverSecret'];
838
+ /**
839
+ * Reads the names service's own Cloudflare API token back, held under
840
+ * {@link PLATFORM_NAMES_CLOUDFLARE_TOKEN_REF} -- a human step (the issue's
841
+ * own "Human steps"), never minted here: foundry has no Cloudflare
842
+ * account of its own to carve that token out of. Absent, or with nothing
843
+ * held under that ref yet, is reported blocked by `blockedSteps`
844
+ * ({@link namesBlockedReason}), never planned to throw.
845
+ */
846
+ heldKeys?: {
847
+ resolve(ref: string): Promise<string | null>;
848
+ };
849
+ /**
850
+ * The Cloudflare account id the token above is scoped to. Not a secret --
851
+ * a plain config value beside the credential it travels with, the same
852
+ * way {@link PlatformFilesDeps.r2}'s bucket name is.
853
+ */
854
+ cloudflareAccountId?: string;
855
+ /** Clock, sleep and fetch for the readiness wait and delivery retries; real ones by default. */
856
+ runtime?: PlatformNamesRuntime;
857
+ }
814
858
  /**
815
859
  * #174: everything {@link provisionPlatformIntegrations} needs beyond
816
860
  * {@link PlatformStepExecutionContext} -- a bundle, like