@wtfalch/foundry 0.16.16 → 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.
Files changed (67) hide show
  1. package/dist/apply.d.ts +22 -1
  2. package/dist/bootstrap/bootstrap.d.ts +6 -1
  3. package/dist/bootstrap/bootstrap.js +26 -3
  4. package/dist/bootstrap/declare.d.ts +11 -1
  5. package/dist/bootstrap/declare.js +18 -6
  6. package/dist/index.d.ts +11 -6
  7. package/dist/index.js +8 -1
  8. package/dist/migrations/0038_calls_version_pin.sql +8 -0
  9. package/dist/migrations/0039_destroy_record.sql +7 -0
  10. package/dist/migrations/0040_company_included_uuid.sql +72 -0
  11. package/dist/migrations/0041_company_included_uuid_keys.sql +132 -0
  12. package/dist/migrations/0042_mutation_leases.sql +12 -0
  13. package/dist/migrations/0043_platform_names.sql +25 -0
  14. package/dist/migrations/0044_platform_mail_lettermint_project.sql +11 -0
  15. package/dist/org.d.ts +128 -3
  16. package/dist/org.js +186 -1
  17. package/dist/plan/adopt.d.ts +76 -0
  18. package/dist/plan/adopt.js +184 -0
  19. package/dist/plan/agora.d.ts +21 -0
  20. package/dist/plan/agora.js +37 -0
  21. package/dist/plan/broker-entries.d.ts +9 -1
  22. package/dist/plan/broker-entries.js +13 -5
  23. package/dist/plan/chat-matrix.d.ts +1 -2
  24. package/dist/plan/chat-matrix.js +15 -4
  25. package/dist/plan/core-files.d.ts +25 -0
  26. package/dist/plan/core-files.js +25 -0
  27. package/dist/plan/diff.d.ts +27 -3
  28. package/dist/plan/diff.js +264 -86
  29. package/dist/plan/edge-worker.d.ts +114 -0
  30. package/dist/plan/edge-worker.js +201 -0
  31. package/dist/plan/entry-ai-key.d.ts +3 -3
  32. package/dist/plan/entry-files-key.d.ts +3 -3
  33. package/dist/plan/entry-names-key.d.ts +73 -0
  34. package/dist/plan/entry-names-key.js +236 -0
  35. package/dist/plan/executor.d.ts +292 -24
  36. package/dist/plan/executor.js +745 -77
  37. package/dist/plan/index.d.ts +6 -2
  38. package/dist/plan/index.js +3 -1
  39. package/dist/plan/live.d.ts +9 -2
  40. package/dist/plan/login-host.d.ts +13 -2
  41. package/dist/plan/login-host.js +26 -7
  42. package/dist/plan/plan-apply.d.ts +153 -6
  43. package/dist/plan/plan-apply.js +639 -78
  44. package/dist/plan/platform-diff.d.ts +106 -1
  45. package/dist/plan/platform-diff.js +182 -2
  46. package/dist/plan/platform-org.d.ts +45 -1
  47. package/dist/plan/platform-org.js +34 -3
  48. package/dist/plan/platform-providers.d.ts +45 -1
  49. package/dist/plan/platform-providers.js +585 -16
  50. package/dist/plan/providers-readers.d.ts +2 -0
  51. package/dist/plan/providers-readers.js +4 -0
  52. package/dist/plan/rollout.d.ts +52 -0
  53. package/dist/plan/rollout.js +37 -0
  54. package/dist/plan/step-system.d.ts +22 -0
  55. package/dist/plan/step-system.js +38 -0
  56. package/dist/plan.d.ts +3 -0
  57. package/dist/platform.d.ts +22 -3
  58. package/dist/platform.js +6 -0
  59. package/dist/state.d.ts +13 -0
  60. package/dist/store/audit.js +15 -1
  61. package/dist/store/postgres.d.ts +1 -1
  62. package/dist/store/postgres.js +142 -15
  63. package/dist/store/schema.d.ts +202 -5
  64. package/dist/store/schema.js +18 -1
  65. package/dist/store.d.ts +47 -4
  66. package/dist/store.js +9 -1
  67. package/package.json +1 -1
@@ -118,6 +118,23 @@ export declare const PLATFORM_ARCHON_APPLIED_GIT_REF_SCOPE_KEY = "archon_applied
118
118
  * unrecorded and the old key stays pinned.
119
119
  */
120
120
  export declare const PLATFORM_ARCHON_INTEGRATIONS_KEY_SYNCED_SCOPE_KEY = "archon_integrations_key_synced";
121
+ /**
122
+ * #467: sha256 of the exact env set the *last confirmed* Archon deploy ran
123
+ * with, recorded only once that deploy's `waitForHealthyVersion` passed --
124
+ * mirroring {@link PLATFORM_ARCHON_INTEGRATIONS_KEY_SYNCED_SCOPE_KEY}'s own
125
+ * confirm-after-health-check timing, generalized from one env to all of
126
+ * them. `provisionPlatformArchon` clears this to `''` the moment it decides
127
+ * a redeploy is needed, before attempting it: a deploy that then fails (or
128
+ * a crash before the health check passes) must leave the attempt
129
+ * unconfirmed, and the env hash alone cannot tell a failed retry's intended
130
+ * content apart from a successful one's, since both intend the same bytes.
131
+ * Without this, a run that writes new envs and then fails before Archon
132
+ * redeploys -- `envsDiffer` comparing desired envs against the *live*
133
+ * store, which this run's own write already updated -- leaves a retry
134
+ * reading no difference at all, and the container stays on the stale
135
+ * values until someone redeploys by hand (#467).
136
+ */
137
+ export declare const PLATFORM_ARCHON_ENVS_DEPLOYED_SCOPE_KEY = "archon_envs_deployed_hash";
121
138
  /**
122
139
  * #211 round 4: the broker app id Archon last registered its entry with.
123
140
  * `provisionPlatformArchon` reads the broker to register with from
@@ -136,6 +153,21 @@ export declare const PLATFORM_ARCHON_APPLIED_BROKER_SCOPE_KEY = "archon_applied_
136
153
  * has recorded it.
137
154
  */
138
155
  export declare const PLATFORM_ARCHON_APPLIED_OPERATOR_SCOPE_KEY = "archon_applied_bootstrap_operator";
156
+ /**
157
+ * #475: whether the last successful Archon apply patched the app with
158
+ * `custom_network_aliases` and `is_consistent_container_name_enabled: false`
159
+ * instead of the old fixed `custom_internal_name` alone. Coolify only runs a
160
+ * rolling, health-checked deploy -- start the new container, wait, then stop
161
+ * the old one -- when the consistent-name flag is off; with it on (set by
162
+ * foundry's own pre-#475 applies, or by hand in the Coolify UI) every deploy
163
+ * stops the old container before the new one exists, which is what took
164
+ * Archon down for #475's incident. Absent on every platform applied before
165
+ * this fix landed; `planArchon` plans the step again until
166
+ * `provisionPlatformArchon` records it, which only happens once that run's
167
+ * redeploy has passed its health check -- the same confirm-after-health-check
168
+ * timing {@link PLATFORM_ARCHON_ENVS_DEPLOYED_SCOPE_KEY} uses.
169
+ */
170
+ export declare const PLATFORM_ARCHON_APPLIED_ROLLING_UPDATE_SCOPE_KEY = "archon_applied_rolling_update";
139
171
  /**
140
172
  * The owner's ZITADEL user id Archon takes as its first operator, from
141
173
  * {@link PLATFORM_AUTH_OWNER_INVITE_SCOPE_KEY}. Undefined when none is
@@ -309,6 +341,20 @@ export declare const PLATFORM_ARCHON_CONNECTOR_APP_SCOPE_KEY = "coolify_platform
309
341
  export declare const PLATFORM_ARCHON_MASTER_COOLIFY_URL_REF = "platform:master:coolify-url";
310
342
  export declare const PLATFORM_ARCHON_MASTER_COOLIFY_TOKEN_REF = "platform:master:coolify-token";
311
343
  export declare const PLATFORM_ARCHON_MASTER_CLOUDFLARE_TOKEN_REF = "platform:master:cloudflare-token";
344
+ /**
345
+ * #436: a Coolify API token scoped to `read` only, for Archon's own
346
+ * `/backups` page (`COOLIFY_URL`/`COOLIFY_READ_TOKEN`, Archon's
347
+ * `src/lib/backups/coolify.ts`) -- deliberately not
348
+ * {@link PLATFORM_ARCHON_MASTER_COOLIFY_TOKEN_REF}'s root token, which a
349
+ * read-only status page must never hold. Coolify's token UI has no API
350
+ * counterpart to create one (`@wtfalch/providers`'s `CoolifyClient` doc:
351
+ * "Keys & Tokens -> API tokens" only), so a human creates it in Coolify's
352
+ * console and holds it under this ref; `provisionPlatformArchon` only reads
353
+ * it back, and pushes nothing until it is held. `COOLIFY_URL` needs no ref
354
+ * of its own: it is not secret, and {@link PLATFORM_ARCHON_MASTER_COOLIFY_URL_REF}
355
+ * already holds the same panel's address.
356
+ */
357
+ export declare const PLATFORM_ARCHON_COOLIFY_READ_TOKEN_REF = "platform:archon:coolify-read-token";
312
358
  /**
313
359
  * #169: `platform:<service>` for the platform's own mail server (Stalwart),
314
360
  * mirroring {@link PLATFORM_AUTH_KEY} exactly.
@@ -395,6 +441,18 @@ export declare function appEnvAppliedScopeKey(entryKey: string): string;
395
441
  * would leave a stale container that no plan notices.
396
442
  */
397
443
  export declare function dbEnvAppliedScopeKey(entryKey: string): string;
444
+ /**
445
+ * #456: the same marker as {@link dbEnvAppliedScopeKey}, for the chat and
446
+ * calls-service sibling platform services' own owner/runtime database split
447
+ * -- in its own `service_db_env_applied.` namespace, never
448
+ * `dbEnvAppliedScopeKey`'s `db_env_applied.`. Those two services are not
449
+ * hosting entries, but `dbEnvAppliedScopeKey`'s `entryKey` is an arbitrary
450
+ * caller-chosen string with no reserved words (`validateHostingEntry` only
451
+ * requires a DNS label), so a hosting entry a company declares with the key
452
+ * `chat` or `calls-service` would otherwise read or clear the sibling
453
+ * service's own marker, and vice versa.
454
+ */
455
+ export declare function serviceDbEnvAppliedScopeKey(service: 'chat' | 'calls-service'): string;
398
456
  /**
399
457
  * The settings `coolifyApp` owns on an entry's app (`folder`, `port`,
400
458
  * `autoDeploy`) as the JSON the marker below holds. `coolifyApp` records it
@@ -563,6 +621,50 @@ export declare const PLATFORM_FILES_MANAGEMENT_UNDELIVERED_SCOPE_KEY = "files_ma
563
621
  */
564
622
  export declare const PLATFORM_FILES_APPLIED_HOSTNAME_SCOPE_KEY = "files_applied_hostname";
565
623
  export declare const PLATFORM_FILES_APPLIED_GIT_REF_SCOPE_KEY = "files_applied_git_ref";
624
+ /**
625
+ * #478: the resource key `planNames` plans under and
626
+ * `FoundryPlatformStepExecutor` dispatches on -- the {@link PLATFORM_FILES_KEY}
627
+ * shape for the names host (https://github.com/wtfalch/names). `runPlatformSteps`
628
+ * records the app's uuid under `coolify:app:platform:names` once the step
629
+ * succeeds, which is what stops `planNames` planning it again.
630
+ */
631
+ export declare const PLATFORM_NAMES_KEY = "platform:names";
632
+ /**
633
+ * #478: the held-key ref the names platform secret's full value is
634
+ * delivered under, once, by {@link FoundryStepExecutorPorts.deliverSecret} --
635
+ * never the host's app env, which gets only its SHA-256 hash
636
+ * (`NAMES_PLATFORM_KEY_HASH`). Archon's own `NamesPort` reads the full
637
+ * secret back from this ref to call names' `mintKey`/`revokeKey`/
638
+ * `setReservedLabels`.
639
+ */
640
+ export declare const PLATFORM_NAMES_KEY_REF = "names:platform:key";
641
+ /**
642
+ * #478: the held-key ref for names' own Cloudflare API token (tunnel edit +
643
+ * DNS edit on one account) -- the issue's own "never Archon's token": this
644
+ * is a narrower, names-only credential a human provisions and holds
645
+ * separately from whatever broader token `PlatformNamesDeps.cloudflare`
646
+ * uses to stand the host itself up on Coolify.
647
+ */
648
+ export declare const PLATFORM_NAMES_CLOUDFLARE_TOKEN_REF = "cloudflare:names:apiToken";
649
+ /**
650
+ * #478: scope keys (colon-free) for the names platform key, the
651
+ * {@link PLATFORM_FILES_MANAGEMENT_UNDELIVERED_SCOPE_KEY} pattern once:
652
+ * the undelivered id is recorded and persisted the moment the secret is
653
+ * minted, before delivery; the guard is recorded only once it was delivered
654
+ * AND its hash written to the runner's env. An undelivered id with no guard
655
+ * means the secret is lost (never kept past that point): the planner
656
+ * reports it and never mints again. Neither key holds the secret itself.
657
+ */
658
+ export declare const PLATFORM_NAMES_PLATFORM_KEY_UNDELIVERED_SCOPE_KEY = "names_platform_key_undelivered";
659
+ export declare const PLATFORM_NAMES_PLATFORM_KEY_SET_SCOPE_KEY = "names_platform_key_set";
660
+ /**
661
+ * #478: the hostname and `gitRef` the last successful names run applied.
662
+ * `planNames` plans the step again when either differs from the declared
663
+ * value; a re-run updates `git_branch` and the runner env and redeploys,
664
+ * and mints no new platform key (the guards above hold).
665
+ */
666
+ export declare const PLATFORM_NAMES_APPLIED_HOSTNAME_SCOPE_KEY = "names_applied_hostname";
667
+ export declare const PLATFORM_NAMES_APPLIED_GIT_REF_SCOPE_KEY = "names_applied_git_ref";
566
668
  /**
567
669
  * Options for {@link planPlatform}, replacing four positional arguments that
568
670
  * shifted on every merge that added a platform service (#186, #187, #188) --
@@ -601,6 +703,8 @@ export interface PlanPlatformOptions {
601
703
  ai?: string;
602
704
  archon?: string;
603
705
  mail?: string;
706
+ /** #478: why the names host is blocked, or absent when it is not. */
707
+ names?: string;
604
708
  /**
605
709
  * #257: true once a service's database exists but the host built the
606
710
  * platform executor with no backup dependencies -- every such service's
@@ -652,7 +756,8 @@ export interface PlanPlatformOptions {
652
756
  * Scope for #167: the frame plus one step, {@link planApexZone}. #173 adds
653
757
  * {@link planJobs}, #170 adds {@link planSfu}, #168 adds {@link planAuth},
654
758
  * #169 adds {@link planMail}, #174 adds {@link planIntegrations}, #171 adds
655
- * {@link planFiles}, #172 adds {@link planAi} and #175 adds
759
+ * {@link planFiles}, #172 adds {@link planAi}, #478 adds {@link planNames}
760
+ * and #175 adds
656
761
  * {@link planArchon}, the same way #19 shipped org plan/apply against
657
762
  * three singletons before #27, #71, #80 and the rest added the remaining
658
763
  * twelve. Until then, a live resource under one of those eventual keys is
@@ -2,6 +2,7 @@ import { VERSIONS } from '../bootstrap/versions.js';
2
2
  import { APP_ENTRY_KEY } from '../org.js';
3
3
  import { VERSION_CHANGE_KIND, } from '../plan.js';
4
4
  import { legacyAppHostname } from './hostnames.js';
5
+ import { withStepSystem } from './step-system.js';
5
6
  /**
6
7
  * The `Plan` (#19's public contract) a {@link PlatformPlanDetail} carries,
7
8
  * mirroring `diff.ts`'s `toPlan`. There is no separate platform-shaped `Plan`
@@ -138,6 +139,23 @@ export const PLATFORM_ARCHON_APPLIED_GIT_REF_SCOPE_KEY = 'archon_applied_git_ref
138
139
  * unrecorded and the old key stays pinned.
139
140
  */
140
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';
141
159
  /**
142
160
  * #211 round 4: the broker app id Archon last registered its entry with.
143
161
  * `provisionPlatformArchon` reads the broker to register with from
@@ -156,6 +174,21 @@ export const PLATFORM_ARCHON_APPLIED_BROKER_SCOPE_KEY = 'archon_applied_broker_a
156
174
  * has recorded it.
157
175
  */
158
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';
159
192
  /**
160
193
  * The owner's ZITADEL user id Archon takes as its first operator, from
161
194
  * {@link PLATFORM_AUTH_OWNER_INVITE_SCOPE_KEY}. Undefined when none is
@@ -343,6 +376,20 @@ export const PLATFORM_ARCHON_CONNECTOR_APP_SCOPE_KEY = 'coolify_platform_archon_
343
376
  export const PLATFORM_ARCHON_MASTER_COOLIFY_URL_REF = 'platform:master:coolify-url';
344
377
  export const PLATFORM_ARCHON_MASTER_COOLIFY_TOKEN_REF = 'platform:master:coolify-token';
345
378
  export const PLATFORM_ARCHON_MASTER_CLOUDFLARE_TOKEN_REF = 'platform:master:cloudflare-token';
379
+ /**
380
+ * #436: a Coolify API token scoped to `read` only, for Archon's own
381
+ * `/backups` page (`COOLIFY_URL`/`COOLIFY_READ_TOKEN`, Archon's
382
+ * `src/lib/backups/coolify.ts`) -- deliberately not
383
+ * {@link PLATFORM_ARCHON_MASTER_COOLIFY_TOKEN_REF}'s root token, which a
384
+ * read-only status page must never hold. Coolify's token UI has no API
385
+ * counterpart to create one (`@wtfalch/providers`'s `CoolifyClient` doc:
386
+ * "Keys & Tokens -> API tokens" only), so a human creates it in Coolify's
387
+ * console and holds it under this ref; `provisionPlatformArchon` only reads
388
+ * it back, and pushes nothing until it is held. `COOLIFY_URL` needs no ref
389
+ * of its own: it is not secret, and {@link PLATFORM_ARCHON_MASTER_COOLIFY_URL_REF}
390
+ * already holds the same panel's address.
391
+ */
392
+ export const PLATFORM_ARCHON_COOLIFY_READ_TOKEN_REF = 'platform:archon:coolify-read-token';
346
393
  /**
347
394
  * #169: `platform:<service>` for the platform's own mail server (Stalwart),
348
395
  * mirroring {@link PLATFORM_AUTH_KEY} exactly.
@@ -439,6 +486,20 @@ export function appEnvAppliedScopeKey(entryKey) {
439
486
  export function dbEnvAppliedScopeKey(entryKey) {
440
487
  return `db_env_applied.${entryKey}`;
441
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
+ }
442
503
  export function appSettingsOf(entry) {
443
504
  return { folder: entry.folder, port: entry.port, autoDeploy: entry.autoDeploy };
444
505
  }
@@ -636,6 +697,50 @@ export const PLATFORM_FILES_MANAGEMENT_UNDELIVERED_SCOPE_KEY = 'files_management
636
697
  */
637
698
  export const PLATFORM_FILES_APPLIED_HOSTNAME_SCOPE_KEY = 'files_applied_hostname';
638
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';
639
744
  /**
640
745
  * #167: the platform's own diff engine, mirroring `diff.ts`'s
641
746
  * `computePlanDetail` for the one shared {@link DeclaredPlatform} record
@@ -650,7 +755,8 @@ export const PLATFORM_FILES_APPLIED_GIT_REF_SCOPE_KEY = 'files_applied_git_ref';
650
755
  * Scope for #167: the frame plus one step, {@link planApexZone}. #173 adds
651
756
  * {@link planJobs}, #170 adds {@link planSfu}, #168 adds {@link planAuth},
652
757
  * #169 adds {@link planMail}, #174 adds {@link planIntegrations}, #171 adds
653
- * {@link planFiles}, #172 adds {@link planAi} and #175 adds
758
+ * {@link planFiles}, #172 adds {@link planAi}, #478 adds {@link planNames}
759
+ * and #175 adds
654
760
  * {@link planArchon}, the same way #19 shipped org plan/apply against
655
761
  * three singletons before #27, #71, #80 and the rest added the remaining
656
762
  * twelve. Until then, a live resource under one of those eventual keys is
@@ -699,6 +805,10 @@ export function planPlatform(platform, live, options = {}) {
699
805
  const ai = planAi(platform, recordedIds, blocked.ai);
700
806
  if (ai.step)
701
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);
702
812
  // #175: last, on purpose. Archon's sign-in client lives in the sign-in
703
813
  // `planAuth` builds, and its env reads what every other service's step
704
814
  // records or delivers, so in one apply it runs after all of them.
@@ -732,6 +842,9 @@ export function planPlatform(platform, live, options = {}) {
732
842
  integrations.blocked
733
843
  ? null
734
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'),
735
848
  auth.blocked
736
849
  ? null
737
850
  : versionNotAppliedLine('zitadel', Boolean(recordedIds[resourceKey('coolify', 'app', PLATFORM_AUTH_KEY)]), recordedIds[PLATFORM_AUTH_ZITADEL_APPLIED_IMAGE_TAG_SCOPE_KEY], VERSIONS.zitadel),
@@ -763,6 +876,8 @@ export function planPlatform(platform, live, options = {}) {
763
876
  unmanaged.push({ description: files.blocked, key: 'files' });
764
877
  if (ai.blocked)
765
878
  unmanaged.push({ description: ai.blocked, key: 'ai' });
879
+ if (names.blocked)
880
+ unmanaged.push({ description: names.blocked, key: 'names' });
766
881
  if (archon.blocked)
767
882
  unmanaged.push({ description: archon.blocked, key: 'archon' });
768
883
  // #398: the old integrations key stays pinned until Archon holds the new
@@ -777,7 +892,7 @@ export function planPlatform(platform, live, options = {}) {
777
892
  });
778
893
  }
779
894
  unmanaged.push(...backups.blocked);
780
- return { plannedSteps, unmanaged, versionChanges };
895
+ return { plannedSteps: plannedSteps.map(withStepSystem), unmanaged, versionChanges };
781
896
  }
782
897
  /**
783
898
  * The platform's own Cloudflare zone for its shared apex (`platform.ts`'s
@@ -1631,6 +1746,62 @@ function planFiles(platform, recordedIds, executorBlocked, authReady, drifted) {
1631
1746
  blocked: null,
1632
1747
  };
1633
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
+ }
1634
1805
  /**
1635
1806
  * #172: the platform's AI host (https://github.com/wtfalch/ai) -- a Coolify
1636
1807
  * Postgres plus three apps built from that repo's root Dockerfile, one per
@@ -1765,6 +1936,15 @@ function planArchon(platform, recordedIds, executorBlocked, authReady, drifted)
1765
1936
  recordedIds[PLATFORM_AUTH_BROKER_SCOPE_KEY] &&
1766
1937
  (recordedIds[PLATFORM_ARCHON_APPLIED_OPERATOR_SCOPE_KEY] ?? 'none') ===
1767
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' &&
1768
1948
  !recordedIds[PLATFORM_ARCHON_GATE_CLOSED_SCOPE_KEY] &&
1769
1949
  // #398: a rotated integrations key id Archon's env was not yet written
1770
1950
  // with (see PLATFORM_ARCHON_INTEGRATIONS_KEY_SYNCED_SCOPE_KEY). Absent
@@ -1,5 +1,21 @@
1
1
  import type { DeclaredPlatform } from '../platform.js';
2
2
  import type { AgoraHostValues } from './agora.js';
3
+ /**
4
+ * #443: the no-domain company mail apex -- `<slug>.oikos.host`, the same
5
+ * fixed suffix `agora.ts`'s `agoraHostnameFor`, `boule.ts`'s
6
+ * `bouleHostnameFor` and `hostnames.ts`'s own `<slug>.oikos.host` already
7
+ * use for a level-1 org's hostnames. `OrgPlatformReadiness.mailHost.apex`
8
+ * used to be `platform.apex` (the platform's own brand apex, e.g.
9
+ * `companyincluded.com`), which gave a domainless org `slug@companyincluded.com`
10
+ * instead of the settled `slug@oikos.host` contract
11
+ * (`@wtfalch/email`'s `org-mail.ts`'s `resolveDomain` is the only reader of
12
+ * this `apex`, for a level-1 org alone -- a with-domain org returns
13
+ * `org.domain` and never looks at it). `mailHost` itself (the MX server's own
14
+ * hostname, `platformMailMxHost(platform.apex)`) is unaffected: the shared
15
+ * mail server stays the platform's own, whichever apex a mailbox's address
16
+ * uses.
17
+ */
18
+ export declare const NO_DOMAIN_MAIL_APEX = "oikos.host";
3
19
  /**
4
20
  * https://github.com/wtfalch/foundry/issues/177: what an org step needs to
5
21
  * know about the platform it runs on -- whether each shared service it
@@ -30,6 +46,21 @@ export interface OrgPlatformReadiness {
30
46
  apex: string;
31
47
  mailHost: string;
32
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;
33
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. */
34
65
  filesReady: boolean;
35
66
  /**
@@ -56,6 +87,17 @@ export interface OrgPlatformReadiness {
56
87
  authReady: boolean;
57
88
  /** The jobs scheduler (#173) is enabled, applied, and its admin token is held. */
58
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;
59
101
  /**
60
102
  * The ref of a platform key an applied service needs that is not held yet,
61
103
  * `null` when it is held or when the service is not applied. A ref only,
@@ -123,7 +165,9 @@ export declare function orgPlatformReadinessFrom(platform: DeclaredPlatform, pla
123
165
  */
124
166
  heldRefs?: ReadonlySet<string>,
125
167
  /** True when the host wired no held-keys reader ({@link OrgPlatformReadiness.noKeyReader}). */
126
- 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;
127
171
  /**
128
172
  * #177: {@link AgoraHostValues} derived from the same platform state, rather
129
173
  * than requiring a bespoke host-supplied `AgoraHostPort` for values the
@@ -1,5 +1,21 @@
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
+ /**
4
+ * #443: the no-domain company mail apex -- `<slug>.oikos.host`, the same
5
+ * fixed suffix `agora.ts`'s `agoraHostnameFor`, `boule.ts`'s
6
+ * `bouleHostnameFor` and `hostnames.ts`'s own `<slug>.oikos.host` already
7
+ * use for a level-1 org's hostnames. `OrgPlatformReadiness.mailHost.apex`
8
+ * used to be `platform.apex` (the platform's own brand apex, e.g.
9
+ * `companyincluded.com`), which gave a domainless org `slug@companyincluded.com`
10
+ * instead of the settled `slug@oikos.host` contract
11
+ * (`@wtfalch/email`'s `org-mail.ts`'s `resolveDomain` is the only reader of
12
+ * this `apex`, for a level-1 org alone -- a with-domain org returns
13
+ * `org.domain` and never looks at it). `mailHost` itself (the MX server's own
14
+ * hostname, `platformMailMxHost(platform.apex)`) is unaffected: the shared
15
+ * mail server stays the platform's own, whichever apex a mailbox's address
16
+ * uses.
17
+ */
18
+ export const NO_DOMAIN_MAIL_APEX = 'oikos.host';
3
19
  /**
4
20
  * What an org's plan reads when no platform is declared: nothing is ready, so
5
21
  * every org step that depends on a platform service is blocked (#177). A host
@@ -9,11 +25,14 @@ import { FILES_MANAGEMENT_KEY_REF, JOBS_ADMIN_TOKEN_REF, PLATFORM_AI_MANAGEMENT_
9
25
  */
10
26
  export const DEFAULT_ORG_PLATFORM_READINESS = {
11
27
  mailHost: null,
28
+ platformApex: null,
29
+ platformLettermintProjectId: null,
12
30
  filesReady: false,
13
31
  aiReady: false,
14
32
  callsReady: false,
15
33
  authReady: false,
16
34
  jobsReady: false,
35
+ namesReady: false,
17
36
  missingKeys: { files: null, ai: null, jobs: null },
18
37
  };
19
38
  /** The held-key refs an org step needs from the platform, one per service. */
@@ -60,11 +79,14 @@ export function orgPlatformReadinessFrom(platform, platformRecordedIds,
60
79
  */
61
80
  heldRefs,
62
81
  /** True when the host wired no held-keys reader ({@link OrgPlatformReadiness.noKeyReader}). */
63
- noKeyReader = false) {
82
+ noKeyReader = false,
83
+ /** #317: true when the host's executor was built with a `NamesPort` ({@link OrgPlatformReadiness.namesReady}). */
84
+ namesPortPresent = false) {
64
85
  const mailApplied = platform.mail.enabled && coolifyAppApplied(platformRecordedIds, PLATFORM_MAIL_KEY);
65
86
  const filesApplied = platform.files.enabled && coolifyAppApplied(platformRecordedIds, PLATFORM_FILES_KEY);
66
87
  const aiApplied = platform.ai.enabled && coolifyAppApplied(platformRecordedIds, PLATFORM_AI_KEY);
67
88
  const jobsApplied = platform.jobs.enabled && Boolean(platformRecordedIds[`coolify:jobs:${JOBS_SCHEDULER_KEY}`]);
89
+ const namesApplied = platform.names.enabled && coolifyAppApplied(platformRecordedIds, PLATFORM_NAMES_KEY);
68
90
  const missing = (applied, ref) => applied && heldRefs !== undefined && !heldRefs.has(ref) ? ref : null;
69
91
  const missingKeys = {
70
92
  files: missing(filesApplied, ORG_PLATFORM_KEY_REFS.files),
@@ -73,8 +95,15 @@ noKeyReader = false) {
73
95
  };
74
96
  return {
75
97
  mailHost: mailApplied
76
- ? { apex: platform.apex, mailHost: platformMailMxHost(platform.apex) }
98
+ ? { apex: NO_DOMAIN_MAIL_APEX, mailHost: platformMailMxHost(platform.apex) }
77
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,
78
107
  filesReady: filesApplied && missingKeys.files === null,
79
108
  filesUrl: filesApplied
80
109
  ? (platformRecordedIds[PLATFORM_FILES_INTERNAL_URL_SCOPE_KEY] ?? null)
@@ -84,6 +113,8 @@ noKeyReader = false) {
84
113
  callsReady: platform.sfu.enabled,
85
114
  authReady: platform.auth.enabled && coolifyAppApplied(platformRecordedIds, PLATFORM_AUTH_KEY),
86
115
  jobsReady: jobsApplied && missingKeys.jobs === null,
116
+ namesReady: namesApplied && namesPortPresent,
117
+ namesUrl: namesApplied && namesPortPresent ? `https://${platform.names.hostname}` : null,
87
118
  missingKeys,
88
119
  ...(noKeyReader ? { noKeyReader } : {}),
89
120
  };