@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
@@ -7,7 +7,7 @@ import { ensureCloudflaredConnector } from './cloudflared-connector.js';
7
7
  import { waitForDeploymentFinished, waitForHealthyVersion, waitForNewDeployment, } from './deploy-health.js';
8
8
  import { ACCESS_POLICY_NAME, getDatabaseStarted, isNotFoundError, isRunningOrStarting, } from './executor.js';
9
9
  import { coolifyDomains, filesShareHostname } from './hostnames.js';
10
- import { AUTH_ZITADEL_RUN_OPTIONS, JOBS_SCHEDULER_KEY, PLATFORM_AI_APPLIED_GITREF_SCOPE_KEY, PLATFORM_AI_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_AI_DEPLOY_PENDING_SCOPE_KEY, PLATFORM_AI_INTERNAL_URL_SCOPE_KEY, PLATFORM_AI_KEY, PLATFORM_AI_MANAGEMENT_KEY_CONFLICT_SCOPE_KEY, PLATFORM_AI_MANAGEMENT_KEY_SCOPE_KEY, PLATFORM_AI_MANAGEMENT_KEY_UNDELIVERED_SCOPE_KEY, PLATFORM_AI_MIGRATE_BLOCKED_SCOPE_KEY, PLATFORM_AI_ORGANISATION_SCOPE_KEY, PLATFORM_AI_SIGNING_KEY_SET_SCOPE_KEY, PLATFORM_AI_SIGNING_KEY_UNDELIVERED_SCOPE_KEY, PLATFORM_ARCHON_APPLIED_BROKER_SCOPE_KEY, PLATFORM_ARCHON_APPLIED_GIT_REF_SCOPE_KEY, PLATFORM_ARCHON_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_ARCHON_APPLIED_OPERATOR_SCOPE_KEY, PLATFORM_ARCHON_CONNECTOR_APP_SCOPE_KEY, PLATFORM_ARCHON_GATE_CLOSED_SCOPE_KEY, PLATFORM_ARCHON_INTEGRATIONS_KEY_SYNCED_SCOPE_KEY, PLATFORM_ARCHON_KEY, PLATFORM_ARCHON_MASTER_CLOUDFLARE_TOKEN_REF, PLATFORM_ARCHON_MASTER_COOLIFY_TOKEN_REF, PLATFORM_ARCHON_MASTER_COOLIFY_URL_REF, PLATFORM_AUTH_ADMIN_UNDELIVERED_SCOPE_KEY, PLATFORM_AUTH_ADMIN_USER_SCOPE_KEY, PLATFORM_AUTH_BROKER_DEPLOYED_SCOPE_KEY, PLATFORM_AUTH_BROKER_SCOPE_KEY, PLATFORM_AUTH_CONNECTOR_APP_SCOPE_KEY, PLATFORM_AUTH_INSTANCE_SCOPE_KEY, PLATFORM_AUTH_INSTANCE_UNDELIVERED_SCOPE_KEY, PLATFORM_AUTH_KEY, PLATFORM_AUTH_LOGIN_CLIENT_UNDELIVERED_SCOPE_KEY, PLATFORM_AUTH_LOGIN_CLIENT_USER_SCOPE_KEY, PLATFORM_AUTH_OWNER_INVITE_SCOPE_KEY, PLATFORM_AUTH_SMTP_SCOPE_KEY, PLATFORM_AUTH_ZITADEL_APPLIED_IMAGE_TAG_SCOPE_KEY, PLATFORM_AUTH_ZITADEL_RUN_OPTIONS_SCOPE_KEY, PLATFORM_BACKUP_DATABASE_SCOPE_KEYS, PLATFORM_BACKUP_SERVICE_KEYS, PLATFORM_FILES_APPLIED_GIT_REF_SCOPE_KEY, PLATFORM_FILES_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_FILES_INTERNAL_URL_SCOPE_KEY, PLATFORM_FILES_INTROSPECTION_SCOPE_KEY, PLATFORM_FILES_INTROSPECTION_UNDELIVERED_SCOPE_KEY, PLATFORM_FILES_KEY, PLATFORM_FILES_MANAGEMENT_KEY_SCOPE_KEY, PLATFORM_FILES_MANAGEMENT_UNDELIVERED_SCOPE_KEY, PLATFORM_FILES_ZITADEL_PROJECT_SCOPE_KEY, PLATFORM_INTEGRATIONS_APPLIED_GITREF_SCOPE_KEY, PLATFORM_INTEGRATIONS_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_INTEGRATIONS_KEY, PLATFORM_INTEGRATIONS_KEY_ID_SCOPE_KEY, PLATFORM_INTEGRATIONS_KEY_ROTATION_PREVIOUS_SCOPE_KEY, PLATFORM_INTEGRATIONS_KEY_ROTATION_REQUESTED_SCOPE_KEY, PLATFORM_INTEGRATIONS_KEY_ROTATION_UNDELIVERED_SCOPE_KEY, PLATFORM_INTEGRATIONS_KEY_SET_SCOPE_KEY, PLATFORM_INTEGRATIONS_KEY_UNDELIVERED_SCOPE_KEY, PLATFORM_INTEGRATIONS_RUNNER_KEYRING_DEPLOYED_SCOPE_KEY, PLATFORM_MAIL_ADMIN_KEY_SCOPE_KEY, PLATFORM_MAIL_ADMIN_UNDELIVERED_SCOPE_KEY, PLATFORM_MAIL_APPLIED_GIT_REF_SCOPE_KEY, PLATFORM_MAIL_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_MAIL_CONNECTOR_APP_SCOPE_KEY, PLATFORM_MAIL_INTERNAL_URL_SCOPE_KEY, PLATFORM_MAIL_KEY, PLATFORM_MAIL_ZITADEL_PROJECT_SCOPE_KEY, archonBootstrapOperator, parseBrokerDeployRecord, parseLoginClientRecord, platformMailMxHost, } from './platform-diff.js';
10
+ import { AUTH_ZITADEL_RUN_OPTIONS, JOBS_SCHEDULER_KEY, PLATFORM_AI_APPLIED_GITREF_SCOPE_KEY, PLATFORM_AI_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_AI_DEPLOY_PENDING_SCOPE_KEY, PLATFORM_AI_INTERNAL_URL_SCOPE_KEY, PLATFORM_AI_KEY, PLATFORM_AI_MANAGEMENT_KEY_CONFLICT_SCOPE_KEY, PLATFORM_AI_MANAGEMENT_KEY_SCOPE_KEY, PLATFORM_AI_MANAGEMENT_KEY_UNDELIVERED_SCOPE_KEY, PLATFORM_AI_MIGRATE_BLOCKED_SCOPE_KEY, PLATFORM_AI_ORGANISATION_SCOPE_KEY, PLATFORM_AI_SIGNING_KEY_SET_SCOPE_KEY, PLATFORM_AI_SIGNING_KEY_UNDELIVERED_SCOPE_KEY, PLATFORM_ARCHON_APPLIED_BROKER_SCOPE_KEY, PLATFORM_ARCHON_APPLIED_GIT_REF_SCOPE_KEY, PLATFORM_ARCHON_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_ARCHON_APPLIED_OPERATOR_SCOPE_KEY, PLATFORM_ARCHON_APPLIED_ROLLING_UPDATE_SCOPE_KEY, PLATFORM_ARCHON_CONNECTOR_APP_SCOPE_KEY, PLATFORM_ARCHON_COOLIFY_READ_TOKEN_REF, PLATFORM_ARCHON_ENVS_DEPLOYED_SCOPE_KEY, PLATFORM_ARCHON_GATE_CLOSED_SCOPE_KEY, PLATFORM_ARCHON_INTEGRATIONS_KEY_SYNCED_SCOPE_KEY, PLATFORM_ARCHON_KEY, PLATFORM_ARCHON_MASTER_CLOUDFLARE_TOKEN_REF, PLATFORM_ARCHON_MASTER_COOLIFY_TOKEN_REF, PLATFORM_ARCHON_MASTER_COOLIFY_URL_REF, PLATFORM_AUTH_ADMIN_UNDELIVERED_SCOPE_KEY, PLATFORM_AUTH_ADMIN_USER_SCOPE_KEY, PLATFORM_AUTH_BROKER_DEPLOYED_SCOPE_KEY, PLATFORM_AUTH_BROKER_SCOPE_KEY, PLATFORM_AUTH_CONNECTOR_APP_SCOPE_KEY, PLATFORM_AUTH_INSTANCE_SCOPE_KEY, PLATFORM_AUTH_INSTANCE_UNDELIVERED_SCOPE_KEY, PLATFORM_AUTH_KEY, PLATFORM_AUTH_LOGIN_CLIENT_UNDELIVERED_SCOPE_KEY, PLATFORM_AUTH_LOGIN_CLIENT_USER_SCOPE_KEY, PLATFORM_AUTH_OWNER_INVITE_SCOPE_KEY, PLATFORM_AUTH_SMTP_SCOPE_KEY, PLATFORM_AUTH_ZITADEL_APPLIED_IMAGE_TAG_SCOPE_KEY, PLATFORM_AUTH_ZITADEL_RUN_OPTIONS_SCOPE_KEY, PLATFORM_BACKUP_DATABASE_SCOPE_KEYS, PLATFORM_BACKUP_SERVICE_KEYS, PLATFORM_FILES_APPLIED_GIT_REF_SCOPE_KEY, PLATFORM_FILES_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_FILES_INTERNAL_URL_SCOPE_KEY, PLATFORM_FILES_INTROSPECTION_SCOPE_KEY, PLATFORM_FILES_INTROSPECTION_UNDELIVERED_SCOPE_KEY, PLATFORM_FILES_KEY, PLATFORM_FILES_MANAGEMENT_KEY_SCOPE_KEY, PLATFORM_FILES_MANAGEMENT_UNDELIVERED_SCOPE_KEY, PLATFORM_FILES_ZITADEL_PROJECT_SCOPE_KEY, PLATFORM_INTEGRATIONS_APPLIED_GITREF_SCOPE_KEY, PLATFORM_INTEGRATIONS_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_INTEGRATIONS_KEY, PLATFORM_INTEGRATIONS_KEY_ID_SCOPE_KEY, PLATFORM_INTEGRATIONS_KEY_ROTATION_PREVIOUS_SCOPE_KEY, PLATFORM_INTEGRATIONS_KEY_ROTATION_REQUESTED_SCOPE_KEY, PLATFORM_INTEGRATIONS_KEY_ROTATION_UNDELIVERED_SCOPE_KEY, PLATFORM_INTEGRATIONS_KEY_SET_SCOPE_KEY, PLATFORM_INTEGRATIONS_KEY_UNDELIVERED_SCOPE_KEY, PLATFORM_INTEGRATIONS_RUNNER_KEYRING_DEPLOYED_SCOPE_KEY, PLATFORM_MAIL_ADMIN_KEY_SCOPE_KEY, PLATFORM_MAIL_ADMIN_UNDELIVERED_SCOPE_KEY, PLATFORM_MAIL_APPLIED_GIT_REF_SCOPE_KEY, PLATFORM_MAIL_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_MAIL_CONNECTOR_APP_SCOPE_KEY, PLATFORM_MAIL_INTERNAL_URL_SCOPE_KEY, PLATFORM_MAIL_KEY, PLATFORM_MAIL_ZITADEL_PROJECT_SCOPE_KEY, PLATFORM_NAMES_APPLIED_GIT_REF_SCOPE_KEY, PLATFORM_NAMES_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_NAMES_CLOUDFLARE_TOKEN_REF, PLATFORM_NAMES_KEY, PLATFORM_NAMES_KEY_REF, PLATFORM_NAMES_PLATFORM_KEY_SET_SCOPE_KEY, PLATFORM_NAMES_PLATFORM_KEY_UNDELIVERED_SCOPE_KEY, archonBootstrapOperator, parseBrokerDeployRecord, parseLoginClientRecord, platformMailMxHost, } from './platform-diff.js';
11
11
  import { applyCallsSfuPlatformStep } from './platform-sfu.js';
12
12
  import { CreateInstanceError, createZitadelInstance, generateSystemApiKeypair, instanceWithoutBearerMessage, systemApiUsersEnv, } from './zitadel-system-api.js';
13
13
  /** The key `runPlatformSteps` records the apex zone's id under (`system:kind:key`). */
@@ -94,6 +94,9 @@ const JOBS_SCHEDULER_REPO = 'wtfalch/jobs';
94
94
  * #172 adds `coolify:app` keyed {@link PLATFORM_AI_KEY}, the platform's AI
95
95
  * host ({@link provisionPlatformAi}), once a host passes {@link PlatformAiDeps}.
96
96
  *
97
+ * #478 adds `coolify:app` keyed {@link PLATFORM_NAMES_KEY}, the names host
98
+ * ({@link provisionPlatformNames}), once a host passes {@link PlatformNamesDeps}.
99
+ *
97
100
  * #175 adds `coolify:app` keyed {@link PLATFORM_ARCHON_KEY}, Archon itself
98
101
  * ({@link provisionPlatformArchon}), once a host passes
99
102
  * {@link PlatformArchonDeps}.
@@ -113,6 +116,7 @@ export class FoundryPlatformStepExecutor {
113
116
  integrations;
114
117
  files;
115
118
  ai;
119
+ names;
116
120
  archon;
117
121
  backup;
118
122
  constructor(cloudflare, declaredOrgStore, options = {}) {
@@ -126,6 +130,7 @@ export class FoundryPlatformStepExecutor {
126
130
  this.integrations = options.integrations;
127
131
  this.files = options.files;
128
132
  this.ai = options.ai;
133
+ this.names = options.names;
129
134
  this.archon = options.archon;
130
135
  this.backup = options.backup;
131
136
  }
@@ -212,6 +217,15 @@ export class FoundryPlatformStepExecutor {
212
217
  blocked.ai =
213
218
  "the platform's AI host is blocked, not planned: its signing key is already pinned but the host built the platform executor without a held-key reader to read back its private half (PlatformAiDeps.heldKeys)";
214
219
  }
220
+ if (platform.names.enabled && !this.names) {
221
+ blocked.names =
222
+ 'the names host is blocked, not planned: the host built the platform executor without its names dependencies';
223
+ }
224
+ else if (platform.names.enabled && this.names) {
225
+ const namesBlocked = await namesBlockedReason(this.names);
226
+ if (namesBlocked)
227
+ blocked.names = namesBlocked;
228
+ }
215
229
  if (platform.archon.enabled) {
216
230
  if (!this.archon) {
217
231
  blocked.archon =
@@ -262,7 +276,17 @@ export class FoundryPlatformStepExecutor {
262
276
  const bootstrapOperatorDrifted = !accessDrifted &&
263
277
  !brokerDrifted &&
264
278
  (await archonBootstrapOperatorDrifted(this.archon.coolify, recordedIds[`coolify:app:${PLATFORM_ARCHON_KEY}`], recordedIds));
265
- if (accessDrifted || brokerDrifted || bootstrapOperatorDrifted)
279
+ // #480: re-reads the live app's rolling-update/health-check settings
280
+ // even once `PLATFORM_ARCHON_APPLIED_ROLLING_UPDATE_SCOPE_KEY` is
281
+ // recorded -- that marker only ever proves one past apply patched them,
282
+ // and nothing else notices a later hand-edit in the Coolify UI turning
283
+ // `is_consistent_container_name_enabled` back on or the health check
284
+ // back off.
285
+ const rollingUpdateDrifted = !accessDrifted &&
286
+ !brokerDrifted &&
287
+ !bootstrapOperatorDrifted &&
288
+ (await archonRollingUpdateDrifted(this.archon.coolify, recordedIds[`coolify:app:${PLATFORM_ARCHON_KEY}`]));
289
+ if (accessDrifted || brokerDrifted || bootstrapOperatorDrifted || rollingUpdateDrifted)
266
290
  result.archon = true;
267
291
  }
268
292
  if (platform.auth.enabled && this.auth) {
@@ -351,6 +375,12 @@ export class FoundryPlatformStepExecutor {
351
375
  }
352
376
  return provisionPlatformAi(context, this.ai);
353
377
  }
378
+ if (action.system === 'coolify' && action.kind === 'app' && action.key === PLATFORM_NAMES_KEY) {
379
+ if (!this.names) {
380
+ throw new Error('platform:names needs FoundryPlatformStepExecutor to be constructed with its names dependencies (#478)');
381
+ }
382
+ return provisionPlatformNames(context, this.names);
383
+ }
354
384
  if (action.system === 'coolify' &&
355
385
  action.kind === 'app' &&
356
386
  action.key === PLATFORM_ARCHON_KEY) {
@@ -2314,6 +2344,24 @@ const PLATFORM_ARCHON_CLOUDFLARED_APP_NAME = 'platform-archon-cloudflared';
2314
2344
  /** https://github.com/wtfalch/archon: one Dockerfile at its root, `runner` stage on port 3000. */
2315
2345
  const ARCHON_REPO = 'wtfalch/archon';
2316
2346
  const ARCHON_PORT = '3000';
2347
+ /**
2348
+ * #480: Archon's own health route (`@wtfalch/reporting`'s `healthHandler`,
2349
+ * the same one `waitForHealthyVersion` below already polls over the public
2350
+ * hostname once the tunnel exists).
2351
+ */
2352
+ const ARCHON_HEALTH_CHECK_PATH = '/api/health';
2353
+ /**
2354
+ * #480: Coolify stops the old container as soon as its own health check
2355
+ * passes against the new one. Archon's container runs `migrate.mjs` (over a
2356
+ * hundred migrations, append-only and fast individually) and then boots
2357
+ * Next.js before it answers anything, so a check run from the moment the
2358
+ * container starts would fail and fail again until Coolify gave up. A start
2359
+ * period this long means Coolify does not count a failing check against the
2360
+ * new container until this many seconds after it starts -- long enough for
2361
+ * the slowest migration run plus boot seen so far, with margin; there is no
2362
+ * live measurement to pin it to instead.
2363
+ */
2364
+ const ARCHON_HEALTH_CHECK_START_PERIOD_SECONDS = 30;
2317
2365
  /**
2318
2366
  * The name of foundry's own Cloudflare Access app in front of Archon. Same
2319
2367
  * `foundry:<key>` convention `executor.ts`'s `cloudflareAccess` uses for an
@@ -2365,7 +2413,15 @@ export const PLATFORM_ARCHON_CLIENT_ID_SCOPE_KEY = 'zitadel_platform_archon_clie
2365
2413
  * does not parse fails the step without printing it.
2366
2414
  * - For each of files, ai, integrations and jobs that the platform declares
2367
2415
  * enabled: its `https://<hostname>` URL, and its management key when
2368
- * {@link PlatformArchonDeps.heldKeys} holds one.
2416
+ * {@link PlatformArchonDeps.heldKeys} holds one. Files also gets
2417
+ * `FILES_ORGANISATION_ID` (`platform.ownerOrgId`, Company Included's own
2418
+ * Files organisation -- `executor.ts`'s `filesOrganisation` provisions it
2419
+ * under that same id) and its signed-assertion identity
2420
+ * (`FILES_MANAGEMENT_ISSUER`, `_KEY_ID`, `_PRIVATE_KEY`; #436).
2421
+ * - `COOLIFY_URL`/`COOLIFY_READ_TOKEN` for `/backups`, once a human has
2422
+ * created a Coolify token scoped to read only and held it under
2423
+ * {@link PLATFORM_ARCHON_COOLIFY_READ_TOKEN_REF} -- that ref's own doc
2424
+ * comment says why foundry cannot mint one itself (#436).
2369
2425
  *
2370
2426
  * The OIDC client is ZITADEL's public web client (authorization code with
2371
2427
  * PKCE, no client secret: `createOidcApp` never returns one, see
@@ -2397,10 +2453,7 @@ export const PLATFORM_ARCHON_CLIENT_ID_SCOPE_KEY = 'zitadel_platform_archon_clie
2397
2453
  * steps are exported by those steps' own modules. A
2398
2454
  * missing one blocks the plan (`blockedSteps`); it is never skipped. The
2399
2455
  * env names `AI_*`, `INTEGRATIONS_*`, `JOBS_URL` and `JOBS_ADMIN_TOKEN` are
2400
- * this step's proposal; Archon reads only `FILES_URL` and
2401
- * `FILES_MANAGEMENT_KEY` of these today. Files' signed-assertion identity
2402
- * (`FILES_MANAGEMENT_ISSUER`, `_KEY_ID`, `_PRIVATE_KEY`, `_SCOPE`) and
2403
- * `FILES_ORGANISATION_ID` are not wired.
2456
+ * this step's proposal.
2404
2457
  * - The step plans again when the hostname, `gitRef` or the Access policy's
2405
2458
  * include list changes. Env for a service enabled later is not pushed until
2406
2459
  * one of those changes.
@@ -2408,6 +2461,24 @@ export const PLATFORM_ARCHON_CLIENT_ID_SCOPE_KEY = 'zitadel_platform_archon_clie
2408
2461
  * the tunnel hostname, deletes the Access app and builds it again.
2409
2462
  * - #197: a `cloudflared` connector runs for this tunnel, as in `provisionPlatformAuth`.
2410
2463
  * - Retiring `operator.wtfalch.dev` stays a human step.
2464
+ * - #475: deploys this app with a stable `custom_network_aliases` instead of a
2465
+ * fixed `custom_internal_name`, so Coolify runs its rolling update (start
2466
+ * the new container, wait for health, then stop the old one) instead of
2467
+ * stopping the old container first -- the cause of the #475 incident.
2468
+ * - #480: also sends `health_check_enabled: true`,
2469
+ * `health_check_path: '/api/health'` and `health_check_port: 3000` on
2470
+ * every run, plus a start period long enough for Archon's migrations and
2471
+ * boot ({@link ARCHON_HEALTH_CHECK_START_PERIOD_SECONDS}). Without a
2472
+ * health check Coolify's rolling update has nothing to wait on and stops
2473
+ * the old container immediately -- the #475 fix alone was not enough, and
2474
+ * the live app only answered a health check because someone had set this
2475
+ * by hand outside foundry. {@link archonRollingUpdateDrifted} re-reads
2476
+ * these settings (and the #475 ones) at plan time, so a human turning any
2477
+ * of them back off in the Coolify UI is noticed and repaired, not just a
2478
+ * platform that applied before this patch existed. The same fix for
2479
+ * `platform:auth`, `platform:files`, `platform:ai` and the calls stack's
2480
+ * stateless app is deliberately left to a follow-up once each image's
2481
+ * health probe is confirmed.
2411
2482
  */
2412
2483
  async function provisionPlatformArchon(context, deps) {
2413
2484
  const { platform } = context;
@@ -2514,8 +2585,30 @@ async function provisionPlatformArchon(context, deps) {
2514
2585
  // right after the create must not leave it unnamed for good.
2515
2586
  // `git_branch` is patched too, so a changed `gitRef` reaches the app; the
2516
2587
  // deploy below is decided from the last applied `gitRef`.
2588
+ //
2589
+ // #475: `custom_network_aliases` (a stable Docker-network DNS name) plus
2590
+ // `is_consistent_container_name_enabled: false`, not the old
2591
+ // `custom_internal_name` alone -- a fixed *container* name is what disables
2592
+ // Coolify's rolling update (the new container cannot take a name the old
2593
+ // one still holds), while a fixed *alias* lets Coolify name each container
2594
+ // uniquely and still answer `http://platform-archon:3000` (the tunnel
2595
+ // ingress below) throughout the swap. Same string as before, so nothing
2596
+ // downstream changes.
2597
+ //
2598
+ // #480: the health check fields too, every run -- the rolling update above
2599
+ // has nothing to wait on without one, so Coolify stops the old container
2600
+ // immediately regardless of the alias/consistent-name patch. This was
2601
+ // live on platform-archon already, set by hand outside foundry; sending it
2602
+ // here means a freshly provisioned platform gets it too, and a human
2603
+ // turning it back off is repaired the same way `is_consistent_container_name_enabled`
2604
+ // is (see `archonRollingUpdateDrifted`).
2517
2605
  await deps.coolify.updateApplication(appId, {
2518
- custom_internal_name: PLATFORM_ARCHON_INTERNAL_NAME,
2606
+ custom_network_aliases: PLATFORM_ARCHON_INTERNAL_NAME,
2607
+ is_consistent_container_name_enabled: false,
2608
+ health_check_enabled: true,
2609
+ health_check_path: ARCHON_HEALTH_CHECK_PATH,
2610
+ health_check_port: Number(ARCHON_PORT),
2611
+ health_check_start_period: ARCHON_HEALTH_CHECK_START_PERIOD_SECONDS,
2519
2612
  git_branch: gitRef,
2520
2613
  });
2521
2614
  // ---- sign-in client in the platform's own ZITADEL -------------------------
@@ -2581,7 +2674,8 @@ async function provisionPlatformArchon(context, deps) {
2581
2674
  else {
2582
2675
  envs.push({ key: 'DATABASE_URL_OWNER', value: db.internal_db_url });
2583
2676
  }
2584
- if (!(await currentEnvValue(deps.coolify, appId, 'REPORTING_SITE'))) {
2677
+ const liveReportingSite = await currentEnvValue(deps.coolify, appId, 'REPORTING_SITE');
2678
+ if (!liveReportingSite) {
2585
2679
  envs.push({ key: 'REPORTING_SITE', value: 'archon' });
2586
2680
  }
2587
2681
  const cookieSecret = await ensureGeneratedEnv(deps.coolify, appId, 'AUTH_COOKIE_SECRET', () => randomBytes(32).toString('base64'));
@@ -2590,6 +2684,25 @@ async function provisionPlatformArchon(context, deps) {
2590
2684
  const runtimeUrl = await ensureArchonRuntimeUrl(deps.coolify, appId, db.internal_db_url);
2591
2685
  if (runtimeUrl)
2592
2686
  envs.push({ key: 'DATABASE_URL', value: runtimeUrl });
2687
+ // #467: `envs` above only ever carries these four keys on the run that
2688
+ // generates or adopts them -- a later run that keeps them as they are
2689
+ // pushes none of it, so `envs` itself shrinks from one run to the next
2690
+ // with nothing having actually changed. `hashArchonEnvs` below hashes
2691
+ // `envs` plus this map's values instead of `envs` alone, so the hash
2692
+ // reflects the env Archon actually ends up running with, not just this
2693
+ // run's delta.
2694
+ const archonEnvHashOverrides = new Map([
2695
+ ['DATABASE_URL_OWNER', liveOwnerUrl ?? db.internal_db_url],
2696
+ ['REPORTING_SITE', liveReportingSite ?? 'archon'],
2697
+ ]);
2698
+ const effectiveCookieSecret = cookieSecret ?? (await currentEnvValue(deps.coolify, appId, 'AUTH_COOKIE_SECRET'));
2699
+ if (effectiveCookieSecret) {
2700
+ archonEnvHashOverrides.set('AUTH_COOKIE_SECRET', effectiveCookieSecret);
2701
+ }
2702
+ const effectiveRuntimeUrl = runtimeUrl ?? (await currentEnvValue(deps.coolify, appId, 'DATABASE_URL'));
2703
+ if (effectiveRuntimeUrl) {
2704
+ archonEnvHashOverrides.set('DATABASE_URL', effectiveRuntimeUrl);
2705
+ }
2593
2706
  // `AUTH_APP_KEY` (#204): minted once like the cookie secret above, but read
2594
2707
  // back (never regenerated) rather than skipped when already set, since its
2595
2708
  // value is needed below to keep the broker's own registration in sync even
@@ -2630,8 +2743,53 @@ async function provisionPlatformArchon(context, deps) {
2630
2743
  // unflagged, Coolify writes it into `.env` unquoted and the container never
2631
2744
  // starts.
2632
2745
  envs.push(...inputs.envs.map((e) => (e.value.includes('\n') ? { ...e, multiline: true } : e)));
2746
+ // #436: /backups needs a Coolify token scoped to read only, never the
2747
+ // root token `putArchonMasterKeys` above already held. Nothing mints that
2748
+ // token over Coolify's API (this ref's own doc comment), so until a human
2749
+ // creates one and holds it under PLATFORM_ARCHON_COOLIFY_READ_TOKEN_REF,
2750
+ // this pushes nothing and /backups keeps showing its own "set COOLIFY_URL
2751
+ // and COOLIFY_READ_TOKEN" message -- never a reason to block the rest of
2752
+ // this step.
2753
+ const coolifyReadToken = await deps.heldKeys.resolve(PLATFORM_ARCHON_COOLIFY_READ_TOKEN_REF);
2754
+ if (coolifyReadToken) {
2755
+ const coolifyUrl = await deps.heldKeys.resolve(PLATFORM_ARCHON_MASTER_COOLIFY_URL_REF);
2756
+ if (coolifyUrl) {
2757
+ envs.push({ key: 'COOLIFY_URL', value: coolifyUrl }, { key: 'COOLIFY_READ_TOKEN', value: coolifyReadToken });
2758
+ }
2759
+ }
2633
2760
  const changed = await envsDiffer(deps.coolify, appId, envs);
2761
+ // #467: this run's intended env set, hashed before anything below writes
2762
+ // it -- compared against {@link PLATFORM_ARCHON_ENVS_DEPLOYED_SCOPE_KEY}
2763
+ // (that constant's own doc comment explains why `changed` above, which
2764
+ // only ever reads the *live* store, cannot catch a crash or a failed
2765
+ // deploy between this run's write and its confirmation).
2766
+ const envsHash = hashArchonEnvs(envs, archonEnvHashOverrides);
2767
+ const envsHashChanged = recorded[PLATFORM_ARCHON_ENVS_DEPLOYED_SCOPE_KEY] !== envsHash;
2634
2768
  await deps.coolify.bulkUpsertEnvs(appId, envs);
2769
+ // #436 round 2: `bulkUpsertEnvs` only adds or updates, so the
2770
+ // `FILES_MANAGEMENT_KEY` this step used to write before the fix above
2771
+ // would otherwise survive on an Archon already deployed with it --
2772
+ // `filesManagementFetch` checks that var first, so a stale copy kept
2773
+ // steering it into the issued-key path this issue's wiring fixes, and it
2774
+ // left a second copy of the private key sitting in Coolify. Every row
2775
+ // under this key, including `listEnvs`'s own preview duplicate, is
2776
+ // deleted outright -- never overwritten, which would still leave a row.
2777
+ //
2778
+ // #462: the deletes happen after `changed` is already computed above, so
2779
+ // a retry that finds the envs already matching (`changed` false) but a
2780
+ // stale `FILES_MANAGEMENT_KEY` row still present must still redeploy --
2781
+ // the running container otherwise keeps reading the stale key out of its
2782
+ // own process env until someone redeploys by hand. `staleKeyDeleted`
2783
+ // feeds `archonDeployedNow` below for exactly that case; `envsHashChanged`
2784
+ // above does not cover it, because the key's removal is never part of
2785
+ // what either hash names (#467).
2786
+ let staleKeyDeleted = false;
2787
+ for (const stale of await deps.coolify.listEnvs(appId)) {
2788
+ if (stale.key === 'FILES_MANAGEMENT_KEY' && stale.uuid) {
2789
+ await deps.coolify.deleteEnv(appId, stale.uuid);
2790
+ staleKeyDeleted = true;
2791
+ }
2792
+ }
2635
2793
  // ---- register with the broker (#204) ---------------------------------------
2636
2794
  // After Archon's own `AUTH_APP_KEY` is durably written above, so a throw
2637
2795
  // earlier in this function (a bad database URL, a Cloudflare failure) never
@@ -2752,13 +2910,29 @@ async function provisionPlatformArchon(context, deps) {
2752
2910
  const keyUnsynced = Boolean(integrationsKeyId) &&
2753
2911
  Boolean(recorded[PLATFORM_INTEGRATIONS_KEY_ID_SCOPE_KEY]) &&
2754
2912
  recorded[PLATFORM_ARCHON_INTEGRATIONS_KEY_SYNCED_SCOPE_KEY] !== integrationsKeyId;
2913
+ // #475: a platform applied before the rolling-update fix (or an already-live
2914
+ // app the Coolify UI set the consistent-name flag on) has this unset; force
2915
+ // one redeploy so the `custom_network_aliases`/`is_consistent_container_name_enabled`
2916
+ // patch above actually takes effect on the running container, not just on
2917
+ // Coolify's stored config.
2918
+ const rollingUpdateApplied = recorded[PLATFORM_ARCHON_APPLIED_ROLLING_UPDATE_SCOPE_KEY] === '1';
2755
2919
  const archonDeployedNow = keyUnsynced ||
2756
2920
  app.justCreated ||
2757
2921
  !app.running ||
2758
2922
  changed ||
2923
+ staleKeyDeleted ||
2924
+ envsHashChanged ||
2759
2925
  appliedGitRef !== gitRef ||
2926
+ !rollingUpdateApplied ||
2760
2927
  recorded[PLATFORM_ARCHON_APPLIED_HOSTNAME_SCOPE_KEY] !== hostname;
2761
2928
  if (archonDeployedNow) {
2929
+ // #467: cleared before the deploy below is even attempted, not after --
2930
+ // a deploy that then fails, or a crash before `waitForHealthyVersion`
2931
+ // passes, must leave this run's attempt unconfirmed. Recording it only
2932
+ // on success would not do that on its own: the env content a failed
2933
+ // retry intends is identical to what a successful one intends, so
2934
+ // leaving the old confirmation in place would read as still correct.
2935
+ await record(PLATFORM_ARCHON_ENVS_DEPLOYED_SCOPE_KEY, '');
2762
2936
  await waitForDeploymentFinished(deps.coolify, appId, runtime, 'platform:archon', `archon stays at ${appliedGitRef ?? 'no recorded ref'} on ${recorded[PLATFORM_ARCHON_APPLIED_HOSTNAME_SCOPE_KEY] ?? 'no recorded hostname'}`);
2763
2937
  }
2764
2938
  // ---- tunnel and DNS ---------------------------------------------------------
@@ -2831,6 +3005,16 @@ async function provisionPlatformArchon(context, deps) {
2831
3005
  if (keyUnsynced && integrationsKeyId) {
2832
3006
  await record(PLATFORM_ARCHON_INTEGRATIONS_KEY_SYNCED_SCOPE_KEY, integrationsKeyId);
2833
3007
  }
3008
+ // #475: confirmed only now, after the health check above has passed on a
3009
+ // container that was deployed with the alias patch in place -- the same
3010
+ // confirm-after-health-check timing as the two records just above.
3011
+ if (!rollingUpdateApplied) {
3012
+ await record(PLATFORM_ARCHON_APPLIED_ROLLING_UPDATE_SCOPE_KEY, '1');
3013
+ }
3014
+ // #467: confirmed only now, after the health check above has passed (or,
3015
+ // when no deploy was needed this run, left exactly as it already was --
3016
+ // `envsHashChanged` false means it already equals `envsHash`).
3017
+ await record(PLATFORM_ARCHON_ENVS_DEPLOYED_SCOPE_KEY, envsHash);
2834
3018
  // Last, after every write the applied values depend on, so a run that failed
2835
3019
  // halfway plans again.
2836
3020
  await record(PLATFORM_ARCHON_APPLIED_HOSTNAME_SCOPE_KEY, hostname);
@@ -3242,6 +3426,37 @@ async function archonBootstrapOperatorDrifted(coolify, appId, recordedIds) {
3242
3426
  return false;
3243
3427
  }
3244
3428
  }
3429
+ /**
3430
+ * #480: whether the live app disagrees with the patch
3431
+ * `provisionPlatformArchon` sends on every run -- `is_consistent_container_name_enabled`
3432
+ * turned back on, `custom_network_aliases` missing or changed, or the health
3433
+ * check disabled -- any of which means Coolify stops the old container
3434
+ * before the new one is up, the #475 incident's own failure mode.
3435
+ * `PLATFORM_ARCHON_APPLIED_ROLLING_UPDATE_SCOPE_KEY` only proves one past
3436
+ * apply sent this patch; nothing re-reads the live app afterward, so a
3437
+ * setting changed back by hand in the Coolify UI would otherwise go
3438
+ * unnoticed forever. A failed read is not drift, the same rule
3439
+ * {@link archonAccessDrifted} follows: the next plan reads again.
3440
+ *
3441
+ * #480 PR review: `is_consistent_container_name_enabled` comes back nested
3442
+ * under `settings`, confirmed against a live read of platform-archon on
3443
+ * Coolify 4.3.23 -- a top-level read always came back absent, so the first
3444
+ * version of this check never actually fired. `custom_network_aliases` and
3445
+ * `health_check_enabled` are both top-level there.
3446
+ */
3447
+ async function archonRollingUpdateDrifted(coolify, appId) {
3448
+ if (!appId)
3449
+ return false;
3450
+ try {
3451
+ const app = await coolify.getApplication(appId);
3452
+ return (app.settings?.is_consistent_container_name_enabled === true ||
3453
+ app.custom_network_aliases !== PLATFORM_ARCHON_INTERNAL_NAME ||
3454
+ app.health_check_enabled !== true);
3455
+ }
3456
+ catch {
3457
+ return false;
3458
+ }
3459
+ }
3245
3460
  /**
3246
3461
  * What Archon reads from each enabled service, gathered before anything is
3247
3462
  * built. Either everything is there and the env values come back, or the first
@@ -3309,11 +3524,31 @@ async function collectArchonServiceInputs(platform, deps, recorded) {
3309
3524
  const held = (ref) => heldByRef.get(ref);
3310
3525
  for (const service of services) {
3311
3526
  if (service === 'files') {
3312
- envs.push({
3313
- key: 'FILES_URL',
3314
- value: recorded[PLATFORM_FILES_INTERNAL_URL_SCOPE_KEY],
3315
- }, {
3316
- key: 'FILES_MANAGEMENT_KEY',
3527
+ envs.push(
3528
+ // #436 round 2: not `PLATFORM_FILES_INTERNAL_URL_SCOPE_KEY`'s
3529
+ // internal `http://<name>:3000` -- Archon's signed path needs an
3530
+ // exact HTTPS origin (`filesOrigin()`/`filesBaseUrl()`,
3531
+ // `wtfalch/archon`'s `src/lib/authz/files-management.ts` and
3532
+ // `src/lib/files-admin/client.ts`), and that origin is also the
3533
+ // signing audience, so it must equal Files' own `APP_URL` exactly
3534
+ // (this file, the files runner env above: `https://${platform.
3535
+ // files.hostname}`).
3536
+ { key: 'FILES_URL', value: `https://${platform.files.hostname}` },
3537
+ // #436: Company Included's own Files organisation, provisioned by
3538
+ // the ordinary per-org `planFilesOrganisation` step under its own
3539
+ // org id -- `executor.ts`'s `filesOrganisation` calls
3540
+ // `provisionOrganisation({ organisationId: org.id, ... })`, so
3541
+ // `platform.ownerOrgId` is already that organisation's id, with no
3542
+ // separate platform-level mint needed.
3543
+ { key: 'FILES_ORGANISATION_ID', value: platform.ownerOrgId },
3544
+ // #436: the signed-assertion identity `filesSigner()` reads
3545
+ // (`archon`'s `src/lib/authz/files-management.ts`), not the bearer
3546
+ // token `FILES_MANAGEMENT_KEY` used to name here -- that name made
3547
+ // Archon take the issued-key branch of `filesManagementFetch`,
3548
+ // which then failed on the `FILES_MANAGEMENT_SCOPE` this step never
3549
+ // set, surfacing as "Files is not configured" (foundry#436).
3550
+ { key: 'FILES_MANAGEMENT_ISSUER', value: FILES_MANAGEMENT_ISSUER }, { key: 'FILES_MANAGEMENT_KEY_ID', value: FILES_MANAGEMENT_KEY_ID }, {
3551
+ key: 'FILES_MANAGEMENT_PRIVATE_KEY',
3317
3552
  value: held(ARCHON_SERVICE_INPUTS.files.refs[0]),
3318
3553
  });
3319
3554
  }
@@ -4368,6 +4603,24 @@ function hashAuthApps(raw) {
4368
4603
  .update(raw ?? '')
4369
4604
  .digest('hex');
4370
4605
  }
4606
+ /**
4607
+ * #467: sha256 of the full env set `provisionPlatformArchon` intends
4608
+ * Archon to be running with, sorted by key so two calls that build the
4609
+ * identical set in a different push order still hash the same. `overrides`
4610
+ * replaces (never adds to) a same-named entry already in `envs` -- see its
4611
+ * call site: `envs` itself only ever carries a handful of keys on the run
4612
+ * that generates or adopts them, so those keys' actual values have to come
4613
+ * from the caller's own read-back for every other run to hash the same.
4614
+ * Paired with {@link PLATFORM_ARCHON_ENVS_DEPLOYED_SCOPE_KEY}, which records
4615
+ * this value only once a deploy carrying it is confirmed healthy.
4616
+ */
4617
+ function hashArchonEnvs(envs, overrides) {
4618
+ const values = new Map(envs.map((e) => [e.key, e.value]));
4619
+ for (const [key, value] of overrides)
4620
+ values.set(key, value);
4621
+ const sorted = [...values.entries()].sort(([a], [b]) => a.localeCompare(b));
4622
+ return createHash('sha256').update(JSON.stringify(sorted)).digest('hex');
4623
+ }
4371
4624
  /**
4372
4625
  * #211 round 5: the one rule `provisionPlatformAuth` and
4373
4626
  * `provisionPlatformArchon` both call to decide whether the broker needs a
@@ -4852,6 +5105,320 @@ shareDomains) {
4852
5105
  await record(PLATFORM_FILES_APPLIED_GIT_REF_SCOPE_KEY, gitRef);
4853
5106
  return { resourceId: runnerApp.appId };
4854
5107
  }
5108
+ /** https://github.com/wtfalch/names -- root Dockerfile, one `runner` target (see {@link provisionPlatformNames}'s own doc comment for why there is no `migration` target any more). */
5109
+ const NAMES_REPO = 'wtfalch/names';
5110
+ const PLATFORM_NAMES_PROJECT = 'platform';
5111
+ const PLATFORM_NAMES_DB_NAME = 'platform-names-db';
5112
+ const NAMES_APP_NAME = 'names';
5113
+ /** The app's fixed `custom_internal_name`, the way `platform:files`' runner names itself. */
5114
+ const NAMES_RUNNER_INTERNAL_NAME = 'platform-names';
5115
+ /** The runner's port (its Dockerfile: `PORT=3000`, `EXPOSE 3000`). */
5116
+ const NAMES_PORT = '3000';
5117
+ /** The Postgres database name, and the owner role's name per the host's own `.env.example`. */
5118
+ const NAMES_DB = 'names';
5119
+ const NAMES_OWNER_ROLE = 'names_owner';
5120
+ /** The restricted runtime role `migrate.mjs`'s own `ensureRuntimeRole` creates or syncs at every boot. */
5121
+ const NAMES_RUNTIME_ROLE = 'names_rt';
5122
+ const NAMES_TUNNEL_NAME = 'platform-names-tunnel';
5123
+ /** #197: the one Coolify app that runs `cloudflared` for {@link NAMES_TUNNEL_NAME}. */
5124
+ const NAMES_CLOUDFLARED_APP_NAME = 'platform-names-cloudflared';
5125
+ const PLATFORM_NAMES_PROJECT_SCOPE_KEY = 'coolify_project_platform_names';
5126
+ const PLATFORM_NAMES_DB_SCOPE_KEY = 'coolify_platform_names_database';
5127
+ const PLATFORM_NAMES_APP_SCOPE_KEY = 'coolify_platform_names_app';
5128
+ const PLATFORM_NAMES_TUNNEL_SCOPE_KEY = 'cloudflare_platform_names_tunnel';
5129
+ const PLATFORM_NAMES_CONNECTOR_APP_SCOPE_KEY = 'coolify_platform_names_connector_app';
5130
+ /**
5131
+ * Written only when absent, never overwritten -- the `PLATFORM_FILES_RUNNER_DEFAULTS`
5132
+ * convention, so an operator's own cap or rate survives every apply. Values
5133
+ * are the host's own `.env.example` defaults.
5134
+ */
5135
+ const PLATFORM_NAMES_RUNNER_DEFAULTS = [
5136
+ { key: 'MAX_CLAIMS_PER_ORGANISATION', value: '100' },
5137
+ { key: 'NAMES_RATE_BURST', value: '10' },
5138
+ { key: 'NAMES_RATE_PER_SECOND', value: '0.5' },
5139
+ ];
5140
+ /**
5141
+ * Why `coolify:app` keyed {@link PLATFORM_NAMES_KEY} cannot be planned yet,
5142
+ * or `null` when it can -- the host built no names dependencies (checked by
5143
+ * the caller, `FoundryPlatformStepExecutor.blockedSteps`, before this is
5144
+ * even called), no Cloudflare API token held under
5145
+ * {@link PLATFORM_NAMES_CLOUDFLARE_TOKEN_REF} (CLAUDE.md's "Human steps"),
5146
+ * or no Cloudflare account id supplied beside it.
5147
+ */
5148
+ async function namesBlockedReason(names) {
5149
+ let token;
5150
+ try {
5151
+ token = (await names.heldKeys?.resolve(PLATFORM_NAMES_CLOUDFLARE_TOKEN_REF)) ?? null;
5152
+ }
5153
+ catch {
5154
+ token = null;
5155
+ }
5156
+ if (!token) {
5157
+ return `the names host is blocked, not planned: no Cloudflare API token is held under ${PLATFORM_NAMES_CLOUDFLARE_TOKEN_REF} yet (the names service's own token, tunnel edit + DNS edit, never the platform's) -- a human step`;
5158
+ }
5159
+ if (!names.cloudflareAccountId) {
5160
+ return 'the names host is blocked, not planned: the host supplies no Cloudflare account id (PlatformNamesDeps.cloudflareAccountId) for the token above';
5161
+ }
5162
+ return null;
5163
+ }
5164
+ /**
5165
+ * #478: makes `coolify:app` keyed {@link PLATFORM_NAMES_KEY} true -- the
5166
+ * names host (https://github.com/wtfalch/names), its own database, and
5167
+ * foundry's own platform key, reached at `platform.names.hostname` through
5168
+ * a Cloudflare tunnel and DNS record (the public-service shape
5169
+ * {@link provisionPlatformMail}/{@link provisionPlatformAuth} use, not
5170
+ * `platform:files`' internal-only one -- `NAMES_URL` has to be reachable by
5171
+ * every org's own app, which runs outside this Coolify network, and by
5172
+ * Archon).
5173
+ *
5174
+ * One Coolify app only, unlike `platform:files`/`platform:ai`'s separate
5175
+ * `migrate` app: names' own Dockerfile dropped its `migration` target
5176
+ * (its own comment: no generated package's Coolify app ever built or ran
5177
+ * that stage, which once shipped `wtfalch/cms` with an empty database and
5178
+ * no runtime role). `apps/host/scripts/start.sh` now runs `migrate.mjs`
5179
+ * inside the runner's own entrypoint, before `exec`'ing the server, so the
5180
+ * one app's own Coolify health check (`/api/health`, a 120s start period)
5181
+ * is the migration's own proof of success -- there is no separate
5182
+ * `running:healthy` to wait on first.
5183
+ *
5184
+ * Two roles on one database, the `platform:files` pattern: Coolify creates
5185
+ * it with `postgres_user: 'names_owner'`, which is `DATABASE_URL_OWNER` as
5186
+ * returned (the migrate step's own connection, stripped from the server's
5187
+ * environment before it starts, per `start.sh`'s own doc comment); the
5188
+ * restricted `names_rt` role is never created here -- `migrate.mjs`'s own
5189
+ * `ensureRuntimeRole` call creates or syncs it and sets its password from
5190
+ * `DATABASE_URL` at every boot, so this function only has to mint that
5191
+ * password once and reuse it (`ensureNamesRuntimeUrl`, the
5192
+ * `ensureFilesRuntimeUrl` shape) -- never rotated, since the role's
5193
+ * password is set from it on every migration.
5194
+ *
5195
+ * The platform key follows `platform:files`' management-key discipline
5196
+ * (CLAUDE.md rule 1): a random secret is minted once, its id recorded
5197
+ * undelivered and persisted before {@link PlatformNamesDeps.deliverSecret}
5198
+ * is even tried, and the guard recorded only once delivery AND the env
5199
+ * write (the SHA-256 hash, as `NAMES_PLATFORM_KEY_HASH`) both succeeded. The
5200
+ * secret itself is never written to the host's own env, logged, returned
5201
+ * or put in an error -- only its hash ever reaches names, and the full
5202
+ * value goes to {@link PlatformNamesDeps.deliverSecret} under
5203
+ * {@link PLATFORM_NAMES_KEY_REF} alone, for Archon's own `NamesPort` to read
5204
+ * back later when it mints or revokes an entry's own names key.
5205
+ *
5206
+ * `CLOUDFLARE_API_TOKEN`/`CLOUDFLARE_ACCOUNT_ID` on the runner are never
5207
+ * minted here either: they are the names service's own credential (never
5208
+ * Archon's), read back from {@link PlatformNamesDeps.heldKeys} under
5209
+ * {@link PLATFORM_NAMES_CLOUDFLARE_TOKEN_REF} once a human has put one there
5210
+ * (the issue's own "Human steps") -- `blockedSteps`
5211
+ * ({@link namesBlockedReason}) reports the step blocked, not planned, until
5212
+ * then.
5213
+ *
5214
+ * Every part is safe to run again after a throw partway, `platform:files`'
5215
+ * own rules: each id is recorded (and persisted) as soon as its resource
5216
+ * exists, the app is deployed on every run that needs it, and the platform
5217
+ * key is delivered before the guard that skips the mint is recorded. A
5218
+ * changed hostname or `gitRef` (recorded as applied scope ids) plans the
5219
+ * step again: `git_branch` and `APP_URL` are patched on every run and the
5220
+ * app redeploys, with no new secret minted.
5221
+ *
5222
+ * Known gaps, left for review:
5223
+ * - `NAMES_MAX_QUEUE_DEPTH` (the host's `.env.example`) is not set; the host
5224
+ * falls back to its own default.
5225
+ * - `NAMES_TRUST_CF_CONNECTING_IP` is always written `true` rather than
5226
+ * left to the host's own default, since this step always puts the
5227
+ * runner behind the Cloudflare tunnel it also creates.
5228
+ */
5229
+ async function provisionPlatformNames(context, deps) {
5230
+ const { platform } = context;
5231
+ const hostname = platform.names.hostname;
5232
+ const gitRef = platform.names.gitRef;
5233
+ const recorded = context.recordedIds();
5234
+ const runtime = deps.runtime ?? {};
5235
+ const sleep = runtime.sleep ?? defaultSleep;
5236
+ const attempts = runtime.deliverAttempts ?? 3;
5237
+ const cloudflareToken = await deps.heldKeys?.resolve(PLATFORM_NAMES_CLOUDFLARE_TOKEN_REF);
5238
+ if (!cloudflareToken || !deps.cloudflareAccountId) {
5239
+ throw new Error(`platform:names needs the names service's own Cloudflare API token (held under ${PLATFORM_NAMES_CLOUDFLARE_TOKEN_REF}) and account id (PlatformNamesDeps.cloudflareAccountId); names never becomes healthy without them`);
5240
+ }
5241
+ // Never plan-and-throw (CLAUDE.md rule 2): the planner reports a lost
5242
+ // secret and plans nothing; this is the same rule for a caller that
5243
+ // reaches the step anyway.
5244
+ const lostKey = recorded[PLATFORM_NAMES_PLATFORM_KEY_UNDELIVERED_SCOPE_KEY];
5245
+ if (lostKey && !recorded[PLATFORM_NAMES_PLATFORM_KEY_SET_SCOPE_KEY]) {
5246
+ throw new Error(`platform:names: the platform key (${lostKey}) was minted but never delivered; remove the recorded key ${PLATFORM_NAMES_PLATFORM_KEY_UNDELIVERED_SCOPE_KEY} to mint a new one`);
5247
+ }
5248
+ const record = async (key, id) => {
5249
+ context.recordScopeId(key, id);
5250
+ await context.persist?.();
5251
+ };
5252
+ let projectUuid = recorded[PLATFORM_NAMES_PROJECT_SCOPE_KEY];
5253
+ if (!projectUuid) {
5254
+ const found = await deps.coolify.findProjectByName(PLATFORM_NAMES_PROJECT);
5255
+ projectUuid = found
5256
+ ? found.uuid
5257
+ : (await deps.coolify.createProject(PLATFORM_NAMES_PROJECT)).uuid;
5258
+ await record(PLATFORM_NAMES_PROJECT_SCOPE_KEY, projectUuid);
5259
+ }
5260
+ const { environment } = await deps.coolify.findOrCreateEnvironment(projectUuid, 'production');
5261
+ const server = await resolvePlatformServer(deps.coolify);
5262
+ // ---- database ----------------------------------------------------------
5263
+ let dbId = recorded[PLATFORM_NAMES_DB_SCOPE_KEY];
5264
+ if (!dbId) {
5265
+ const found = (await deps.coolify.listDatabases()).find((db) => db.name === PLATFORM_NAMES_DB_NAME && db.environment_id === environment.id);
5266
+ if (found) {
5267
+ dbId = found.uuid;
5268
+ }
5269
+ else {
5270
+ const created = await deps.coolify.createPostgresDatabase({
5271
+ project_uuid: projectUuid,
5272
+ server_uuid: server.uuid,
5273
+ environment_name: environment.name,
5274
+ environment_uuid: environment.uuid,
5275
+ name: PLATFORM_NAMES_DB_NAME,
5276
+ postgres_db: NAMES_DB,
5277
+ postgres_user: NAMES_OWNER_ROLE,
5278
+ });
5279
+ dbId = created.uuid;
5280
+ }
5281
+ await record(PLATFORM_NAMES_DB_SCOPE_KEY, dbId);
5282
+ }
5283
+ const db = await getDatabaseStarted(deps.coolify, dbId);
5284
+ if (!db.internal_db_url) {
5285
+ throw new Error('coolify:app for the platform (names): the database has no internal_db_url yet -- retry this apply once Coolify reports one');
5286
+ }
5287
+ // ---- the app -------------------------------------------------------------
5288
+ const app = await findOrCreatePlatformApp(deps.coolify, NAMES_APP_NAME, environment, async () => {
5289
+ const githubApp = await deps.coolify.requireGithubApp();
5290
+ return deps.coolify.createGithubAppApplication({
5291
+ project_uuid: projectUuid,
5292
+ server_uuid: server.uuid,
5293
+ environment_name: environment.name,
5294
+ environment_uuid: environment.uuid,
5295
+ github_app_uuid: githubApp.uuid,
5296
+ git_repository: NAMES_REPO,
5297
+ git_branch: gitRef,
5298
+ build_pack: 'dockerfile',
5299
+ name: NAMES_APP_NAME,
5300
+ });
5301
+ });
5302
+ const appId = app.appId;
5303
+ await record(PLATFORM_NAMES_APP_SCOPE_KEY, appId);
5304
+ // Set on every run, not only after a create: a throw right after the
5305
+ // create must not leave the app on the wrong target for good, and a
5306
+ // changed `gitRef` has to reach an app made on an earlier one.
5307
+ await deps.coolify.updateApplication(appId, {
5308
+ dockerfile_target_build: 'runner',
5309
+ custom_internal_name: NAMES_RUNNER_INTERNAL_NAME,
5310
+ git_branch: gitRef,
5311
+ });
5312
+ const runtimeUrl = await ensureNamesRuntimeUrl(deps.coolify, appId, db.internal_db_url);
5313
+ // ---- the platform key, once ----------------------------------------------
5314
+ // The recorded `set` id *is* the hash (never read back from Coolify's own
5315
+ // env, which can be missing or stale on a re-apply): the hash is never
5316
+ // secret, so it doubles as both the undelivered id and the value the base
5317
+ // env below writes, with nothing extra to compute or a live read that
5318
+ // could fall back to a blank value.
5319
+ let keyHash = recorded[PLATFORM_NAMES_PLATFORM_KEY_SET_SCOPE_KEY] ?? '';
5320
+ if (!keyHash) {
5321
+ const secret = randomBytes(32).toString('base64url');
5322
+ keyHash = createHash('sha256').update(secret).digest('hex');
5323
+ await record(PLATFORM_NAMES_PLATFORM_KEY_UNDELIVERED_SCOPE_KEY, keyHash);
5324
+ await deliverWithRetry(deps.deliverSecret, PLATFORM_NAMES_KEY_REF, secret, attempts, sleep, 'the names platform key', 'platform:names');
5325
+ // Recorded as soon as delivery succeeds, before the env write below (the
5326
+ // base env block, further down, is what actually writes the hash) --
5327
+ // so a failed or crashed env write is simply retried on the next apply
5328
+ // instead of leaving an undelivered secret with no `set` partner and no
5329
+ // way to recover but a hand edit of the recorded ids.
5330
+ await record(PLATFORM_NAMES_PLATFORM_KEY_SET_SCOPE_KEY, keyHash);
5331
+ }
5332
+ // ---- base env, written on every run --------------------------------------
5333
+ await deps.coolify.bulkUpsertEnvs(appId, [
5334
+ { key: 'DATABASE_URL_OWNER', value: db.internal_db_url },
5335
+ { key: 'DATABASE_URL', value: runtimeUrl },
5336
+ { key: 'APP_URL', value: `https://${hostname}` },
5337
+ { key: 'CLOUDFLARE_API_TOKEN', value: cloudflareToken },
5338
+ { key: 'CLOUDFLARE_ACCOUNT_ID', value: deps.cloudflareAccountId },
5339
+ { key: 'NAMES_TRUST_CF_CONNECTING_IP', value: 'true' },
5340
+ { key: 'NODE_ENV', value: 'production' },
5341
+ { key: 'NAMES_PLATFORM_KEY_HASH', value: keyHash },
5342
+ ...(await missingEnvs(deps.coolify, appId, PLATFORM_NAMES_RUNNER_DEFAULTS)),
5343
+ ]);
5344
+ // ---- tunnel and DNS -------------------------------------------------------
5345
+ const existingTunnel = await deps.cloudflare.findTunnelByName(NAMES_TUNNEL_NAME);
5346
+ const tunnel = existingTunnel ?? (await deps.cloudflare.createTunnel(NAMES_TUNNEL_NAME));
5347
+ await record(PLATFORM_NAMES_TUNNEL_SCOPE_KEY, tunnel.id);
5348
+ await deps.cloudflare.putTunnelConfig(tunnel.id, {
5349
+ ingress: [
5350
+ { hostname, service: `http://${NAMES_RUNNER_INTERNAL_NAME}:${NAMES_PORT}` },
5351
+ { service: 'http_status:404' },
5352
+ ],
5353
+ });
5354
+ // #197: without a connector, `hostname` answers with a Cloudflare error.
5355
+ await ensureCloudflaredConnector({
5356
+ coolify: deps.coolify,
5357
+ cloudflare: deps.cloudflare,
5358
+ tunnelId: tunnel.id,
5359
+ appName: NAMES_CLOUDFLARED_APP_NAME,
5360
+ projectUuid,
5361
+ environment,
5362
+ resolveServer: async () => server,
5363
+ recordedConnectorAppId: recorded[PLATFORM_NAMES_CONNECTOR_APP_SCOPE_KEY] ?? null,
5364
+ recordAppId: (connectorAppId) => record(PLATFORM_NAMES_CONNECTOR_APP_SCOPE_KEY, connectorAppId),
5365
+ });
5366
+ const zoneId = recorded[apexZoneKey(platform.apex)] ?? (await exactZone(deps.cloudflare, platform.apex))?.id;
5367
+ if (!zoneId) {
5368
+ throw new Error(`platform:names: no Cloudflare zone for ${platform.apex} to put ${hostname} in`);
5369
+ }
5370
+ const dnsTarget = `${tunnel.id}.cfargotunnel.com`;
5371
+ const [dnsRecord] = await deps.cloudflare.findDnsRecord(zoneId, hostname, 'CNAME');
5372
+ if (!dnsRecord) {
5373
+ await deps.cloudflare.createDnsRecord(zoneId, {
5374
+ type: 'CNAME',
5375
+ name: hostname,
5376
+ content: dnsTarget,
5377
+ proxied: true,
5378
+ });
5379
+ }
5380
+ else if (dnsRecord.content !== dnsTarget) {
5381
+ await deps.cloudflare.updateDnsRecord(zoneId, dnsRecord.id, {
5382
+ type: 'CNAME',
5383
+ name: hostname,
5384
+ content: dnsTarget,
5385
+ proxied: true,
5386
+ });
5387
+ }
5388
+ // ---- deploy and wait -------------------------------------------------------
5389
+ // #252: the tunnel and DNS above already route `hostname`, so the combined
5390
+ // helper (deployment-record check, then the `/api/health` probe) can run
5391
+ // in one call -- unlike `platform:mail`, nothing here needs to happen
5392
+ // between the two phases.
5393
+ await waitForNewDeployment(deps.coolify, appId, { kind: 'ready', url: `https://${hostname}/api/health` }, runtime, sleep, 'platform:names', `names stays at ${recorded[PLATFORM_NAMES_APPLIED_GIT_REF_SCOPE_KEY] ?? 'no recorded ref'}`);
5394
+ // Last: a run that failed anywhere above is planned again.
5395
+ context.recordScopeId(PLATFORM_NAMES_APPLIED_HOSTNAME_SCOPE_KEY, hostname);
5396
+ await record(PLATFORM_NAMES_APPLIED_GIT_REF_SCOPE_KEY, gitRef);
5397
+ return { resourceId: appId };
5398
+ }
5399
+ /**
5400
+ * The runtime `DATABASE_URL` the names app gets: the value already set on
5401
+ * it, else a new `names_rt` credential on the database's host, the
5402
+ * `ensureFilesRuntimeUrl` shape for one app instead of two. A parse failure
5403
+ * throws a message with no part of the URL in it, since the URL carries the
5404
+ * owner's password.
5405
+ */
5406
+ async function ensureNamesRuntimeUrl(coolify, appId, dbInternalUrl) {
5407
+ const existing = (await coolify.listEnvs(appId)).find((e) => e.key === 'DATABASE_URL' && !e.is_preview && e.value);
5408
+ if (existing)
5409
+ return existing.value;
5410
+ let host;
5411
+ let pathname;
5412
+ let search;
5413
+ try {
5414
+ ({ host, pathname, search } = new URL(dbInternalUrl));
5415
+ }
5416
+ catch {
5417
+ throw new Error("coolify:app for the platform (names): the database's internal_db_url could not be parsed to build the runtime DATABASE_URL");
5418
+ }
5419
+ const password = randomBytes(24).toString('base64url');
5420
+ return `postgres://${NAMES_RUNTIME_ROLE}:${password}@${host}${pathname}${search}`;
5421
+ }
4855
5422
  function aiKeys(deps) {
4856
5423
  const keys = deps?.keys;
4857
5424
  return keys?.workerUrl && keys.credential && keys.verifyKeys ? keys : null;
@@ -4955,7 +5522,9 @@ async function stopIfRunning(coolify, appId) {
4955
5522
  * Hands `secret` to the host, retrying. The thrown error names `what` only,
4956
5523
  * never the secret or the cause, which could echo it.
4957
5524
  */
4958
- async function deliverWithRetry(deliverSecret, ref, secret, attempts, sleep, what) {
5525
+ async function deliverWithRetry(deliverSecret, ref, secret, attempts, sleep, what,
5526
+ /** The step name every error is prefixed with (default `'platform:files'`, this helper's first caller). */
5527
+ label = 'platform:files') {
4959
5528
  for (let attempt = 1;; attempt += 1) {
4960
5529
  try {
4961
5530
  await deliverSecret(ref, secret);
@@ -4963,7 +5532,7 @@ async function deliverWithRetry(deliverSecret, ref, secret, attempts, sleep, wha
4963
5532
  }
4964
5533
  catch {
4965
5534
  if (attempt >= attempts) {
4966
- throw new Error(`platform:files: ${what} could not be delivered after ${attempts} tries; the next plan reports it as lost and mints no new one`);
5535
+ throw new Error(`${label}: ${what} could not be delivered after ${attempts} tries; the next plan reports it as lost and mints no new one`);
4967
5536
  }
4968
5537
  await sleep(1000 * attempt);
4969
5538
  }