@wtfalch/foundry 0.33.1 → 0.35.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 (106) hide show
  1. package/dist/bootstrap/bootstrap.d.ts +1 -1
  2. package/dist/bootstrap/bootstrap.js +49 -11
  3. package/dist/bootstrap/declare.d.ts +12 -5
  4. package/dist/bootstrap/declare.js +33 -18
  5. package/dist/bootstrap/versions.d.ts +24 -0
  6. package/dist/bootstrap/versions.js +46 -0
  7. package/dist/calls-sfu.d.ts +10 -0
  8. package/dist/calls-sfu.js +44 -3
  9. package/dist/index.d.ts +21 -8
  10. package/dist/index.js +11 -4
  11. package/dist/migrations/0051_org_services.sql +13 -0
  12. package/dist/migrations/0052_platform_mail_relay.sql +8 -0
  13. package/dist/migrations/0053_heartbeats_provider.sql +9 -0
  14. package/dist/migrations/0053_org_version_pin.sql +6 -0
  15. package/dist/org.d.ts +76 -12
  16. package/dist/org.js +136 -20
  17. package/dist/plan/backup-gate.d.ts +93 -0
  18. package/dist/plan/backup-gate.js +261 -0
  19. package/dist/plan/broker-entries.d.ts +9 -1
  20. package/dist/plan/broker-entries.js +20 -3
  21. package/dist/plan/build-record.d.ts +56 -0
  22. package/dist/plan/build-record.js +109 -0
  23. package/dist/plan/calls-matrix.d.ts +9 -0
  24. package/dist/plan/calls-matrix.js +18 -1
  25. package/dist/plan/cloudflared-connector.d.ts +7 -1
  26. package/dist/plan/cloudflared-connector.js +15 -2
  27. package/dist/plan/company-backups.d.ts +31 -0
  28. package/dist/plan/company-backups.js +101 -0
  29. package/dist/plan/deploy-health.d.ts +12 -2
  30. package/dist/plan/deploy-health.js +3 -1
  31. package/dist/plan/destinations.d.ts +73 -0
  32. package/dist/plan/destinations.js +217 -0
  33. package/dist/plan/diff.d.ts +46 -8
  34. package/dist/plan/diff.js +367 -126
  35. package/dist/plan/edge-access.d.ts +15 -0
  36. package/dist/plan/edge-access.js +21 -2
  37. package/dist/plan/executor.d.ts +276 -5
  38. package/dist/plan/executor.js +1111 -109
  39. package/dist/plan/health-check-settings.d.ts +28 -0
  40. package/dist/plan/health-check-settings.js +36 -0
  41. package/dist/plan/health-gaps.d.ts +33 -0
  42. package/dist/plan/health-gaps.js +89 -0
  43. package/dist/plan/health-targets.d.ts +1 -3
  44. package/dist/plan/health-targets.js +36 -36
  45. package/dist/plan/held-key-refs.d.ts +15 -0
  46. package/dist/plan/held-key-refs.js +15 -0
  47. package/dist/plan/hostnames.d.ts +10 -0
  48. package/dist/plan/hostnames.js +14 -0
  49. package/dist/plan/index.d.ts +19 -6
  50. package/dist/plan/index.js +10 -3
  51. package/dist/plan/live.d.ts +11 -2
  52. package/dist/plan/live.js +16 -2
  53. package/dist/plan/locked-hostnames.d.ts +7 -3
  54. package/dist/plan/locked-hostnames.js +25 -4
  55. package/dist/plan/login-host.d.ts +61 -12
  56. package/dist/plan/login-host.js +99 -11
  57. package/dist/plan/mail-relay.d.ts +139 -0
  58. package/dist/plan/mail-relay.js +400 -0
  59. package/dist/plan/network-move.d.ts +20 -0
  60. package/dist/plan/network-move.js +41 -0
  61. package/dist/plan/oikos-ingress.d.ts +111 -0
  62. package/dist/plan/oikos-ingress.js +231 -0
  63. package/dist/plan/org-names.d.ts +30 -0
  64. package/dist/plan/org-names.js +108 -0
  65. package/dist/plan/own-diff.d.ts +170 -0
  66. package/dist/plan/own-diff.js +440 -0
  67. package/dist/plan/own-dns.d.ts +6 -0
  68. package/dist/plan/own-dns.js +15 -0
  69. package/dist/plan/own-hostnames.d.ts +27 -0
  70. package/dist/plan/own-hostnames.js +24 -0
  71. package/dist/plan/own-limits.d.ts +213 -0
  72. package/dist/plan/own-limits.js +232 -0
  73. package/dist/plan/own-services.d.ts +193 -0
  74. package/dist/plan/own-services.js +169 -0
  75. package/dist/plan/own-sfu.d.ts +69 -0
  76. package/dist/plan/own-sfu.js +256 -0
  77. package/dist/plan/plan-apply.d.ts +121 -10
  78. package/dist/plan/plan-apply.js +357 -18
  79. package/dist/plan/platform-diff.d.ts +155 -11
  80. package/dist/plan/platform-diff.js +271 -66
  81. package/dist/plan/platform-org.d.ts +107 -4
  82. package/dist/plan/platform-org.js +175 -3
  83. package/dist/plan/platform-providers.d.ts +474 -81
  84. package/dist/plan/platform-providers.js +1407 -560
  85. package/dist/plan/proxy-labels.d.ts +57 -11
  86. package/dist/plan/proxy-labels.js +94 -14
  87. package/dist/plan/run-options.d.ts +19 -0
  88. package/dist/plan/run-options.js +46 -0
  89. package/dist/plan/sign-in-org.d.ts +1 -1
  90. package/dist/plan/sign-in-org.js +20 -2
  91. package/dist/plan/zitadel-login-policy.d.ts +1 -1
  92. package/dist/plan/zitadel-login-policy.js +77 -9
  93. package/dist/plan/zitadel-system-api.d.ts +22 -1
  94. package/dist/plan/zitadel-system-api.js +49 -8
  95. package/dist/platform.d.ts +40 -0
  96. package/dist/platform.js +16 -0
  97. package/dist/provision.d.ts +2 -0
  98. package/dist/provision.js +3 -1
  99. package/dist/slug.d.ts +3 -1
  100. package/dist/slug.js +26 -3
  101. package/dist/store/postgres.js +93 -2
  102. package/dist/store/schema.d.ts +87 -0
  103. package/dist/store/schema.js +9 -0
  104. package/package.json +3 -3
  105. package/versions.digests.json +11 -0
  106. package/versions.json +3 -1
@@ -1,24 +1,33 @@
1
1
  import { createHash, createPrivateKey, generateKeyPairSync, randomBytes, randomUUID, sign, } from 'node:crypto';
2
2
  import { isIPv4 } from 'node:net';
3
3
  import { applyObjects, openInstance, platformMailSettingsObjects } from '@wtfalch/email/postmaster';
4
- import { VERSIONS, floatingTagBlocked } from '../bootstrap/versions.js';
4
+ import { VERSIONS, floatingTagBlocked, pinnedRef, pinnedTagFor } from '../bootstrap/versions.js';
5
5
  import { redactSecrets } from '../calls-sfu-deploy-support.js';
6
6
  import { ownerMailboxes } from '../org.js';
7
7
  import { BOULE_BRIDGE_SECRET_ENV, BOULE_PEER_PASSWORD_ENV, BOULE_PEER_ROLE, bouleHostnameFor, } from './boule.js';
8
+ import { recordBuiltCommit } from './build-record.js';
8
9
  import { ensureCloudflaredConnector } from './cloudflared-connector.js';
9
10
  import { waitForDeploymentFinished, waitForHealthyVersion, waitForNewDeployment, } from './deploy-health.js';
11
+ import { placedOnPlatform } from './destinations.js';
10
12
  import { ACCESS_POLICY_NAME, coolifyProjectMark, getDatabaseStarted, isNotFoundError, isRunningOrStarting, } from './executor.js';
11
13
  import { ACCESS_PROVIDER_RECORD_KEY, GATE_CLAIM_NAME } from './gates.js';
12
- import { healthCheckSettings } from './health-check-settings.js';
14
+ import { IMAGE_HEALTHCHECK_SETTINGS, commandHealthCheckSettings, healthCheckSettings, tcpProbe, } from './health-check-settings.js';
15
+ import { FILES_MANAGEMENT_KEY_REF, JOBS_ADMIN_TOKEN_REF, PLATFORM_AI_MANAGEMENT_KEY_REF, } from './held-key-refs.js';
16
+ import { LIMITS, limited } from './own-limits.js';
17
+ export { FILES_MANAGEMENT_KEY_REF, JOBS_ADMIN_TOKEN_REF, PLATFORM_AI_MANAGEMENT_KEY_REF, } from './held-key-refs.js';
13
18
  import { coolifyDomains, coolifyTunnelDomains, filesShareHostname } from './hostnames.js';
14
- import { AUTH_ZITADEL_RUN_OPTIONS, JOBS_DEPLOY_PENDING_SCOPE_KEY, JOBS_INTERNAL_URL_SCOPE_KEY, JOBS_SCHEDULER_KEY, PLATFORM_ACCESS_PROVIDER_APPLIED_AUTH_DOMAIN_SCOPE_KEY, PLATFORM_ACCESS_PROVIDER_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_ACCESS_PROVIDER_UNDELIVERED_SCOPE_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_AUTH_ORG_SCOPE_KEY, PLATFORM_ARCHON_APPLIED_BROKER_SCOPE_KEY, PLATFORM_ARCHON_APPLIED_GIT_REF_SCOPE_KEY, PLATFORM_ARCHON_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_ARCHON_APPLIED_ROLLING_UPDATE_SCOPE_KEY, PLATFORM_ARCHON_CONNECTOR_APP_SCOPE_KEY, PLATFORM_ARCHON_COOLIFY_READ_TOKEN_REF, PLATFORM_ARCHON_DEPLOYMENT_SCOPE_KEY, PLATFORM_ARCHON_ENVS_DEPLOYED_SCOPE_KEY, PLATFORM_ARCHON_GATE_CLOSED_SCOPE_KEY, PLATFORM_ARCHON_ID_TOKEN_USERINFO_APPLIED_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_ARCHON_UPTIME_TOKEN_SYNCED_SCOPE_KEY, PLATFORM_ARCHON_ZITADEL_APP_SCOPE_KEY, 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_ORG_SCOPE_KEY, PLATFORM_AUTH_OWNER_INVITE_SCOPE_KEY, PLATFORM_AUTH_REGISTRATION_CLOSED_SCOPE_KEY, PLATFORM_AUTH_RESERVED_ADMIN_USERNAME_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_EDGE_APPLIED_GIT_REF_SCOPE_KEY, PLATFORM_EDGE_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_EDGE_CLOUDFLARE_TOKEN_REF, PLATFORM_EDGE_INTERNAL_URL_SCOPE_KEY, PLATFORM_EDGE_KEY, PLATFORM_EDGE_KEY_REF, PLATFORM_EDGE_PLATFORM_KEY_SET_SCOPE_KEY, PLATFORM_EDGE_PLATFORM_KEY_UNDELIVERED_SCOPE_KEY, 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_HC_API_KEY_REF, PLATFORM_HC_APPLIED_GIT_REF_SCOPE_KEY, PLATFORM_HC_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_HC_KEY, PLATFORM_HEARTBEATS_APPLIED_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_ROUTE_SCOPE_KEY, PLATFORM_MAIL_ROUTE_WANTED, PLATFORM_MAIL_ZITADEL_PROJECT_SCOPE_KEY, PLATFORM_OWNER_ORG_DOMAIN_BLOCKED_SCOPE_KEY, PLATFORM_OWNER_ORG_DOMAIN_SCOPE_KEY, PLATFORM_OWNER_ORG_SCOPE_KEY, PLATFORM_SFU_APPLIED_SCOPE_KEY, PLATFORM_UPTIME_ACCESS_CLIENT_ID_REF, PLATFORM_UPTIME_ACCESS_CLIENT_SECRET_REF, PLATFORM_UPTIME_ACCESS_TOKEN_ID_SCOPE_KEY, PLATFORM_UPTIME_ACCESS_TOKEN_NAME, PLATFORM_UPTIME_ACCESS_UNDELIVERED_SCOPE_KEY, PLATFORM_UPTIME_HEARTBEAT_CHECK_NAME, PLATFORM_UPTIME_KEY, PLATFORM_UPTIME_REMOVED_SCOPE_KEY, PLATFORM_UPTIME_SCRIPT_NAME, PLATFORM_UPTIME_TOKEN_ID_SCOPE_KEY, PLATFORM_UPTIME_TOKEN_REF, PLATFORM_ZITADEL_ORG_SCOPE_KEY, archonAuthOrganizationId, desiredHeartbeatCheckNames, heartbeatCheckUrlRef, parseBrokerDeployRecord, parseLoginClientRecord, platformBackupHeartbeatCheckName, platformMailMxHost, uptimeAccessTokenConflict, uptimeRecorded, uptimeVarsDigest, } from './platform-diff.js';
19
+ import { MAIL_RELAY_BLOCKED, provisionMailRelay } from './mail-relay.js';
20
+ import { OIKOS_INGRESS_BLOCKED, PLATFORM_OIKOS_INGRESS_KEY, oikosIngressApplied, oikosIngressZoneBlocked, provisionOikosIngress, } from './oikos-ingress.js';
21
+ import { recordOwnDnsHostname } from './own-dns.js';
22
+ import { platformNetwork, platformTarget, scopeFromPlatform, } from './own-services.js';
23
+ import { AUTH_ZITADEL_RUN_OPTIONS, JOBS_DEPLOY_PENDING_SCOPE_KEY, JOBS_INTERNAL_URL_SCOPE_KEY, JOBS_SCHEDULER_KEY, PLATFORM_ACCESS_PROVIDER_APPLIED_AUTH_DOMAIN_SCOPE_KEY, PLATFORM_ACCESS_PROVIDER_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_ACCESS_PROVIDER_UNDELIVERED_SCOPE_KEY, PLATFORM_ACCESS_ZITADEL_APP_SCOPE_KEY, PLATFORM_ACCESS_ZITADEL_PROJECT_SCOPE_KEY, PLATFORM_AI_APPLIED_GITREF_SCOPE_KEY, PLATFORM_AI_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_AI_CONNECTOR_APP_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_AI_TUNNEL_SCOPE_KEY, PLATFORM_ARCHON_ACCESS_PIN_SCOPE_KEY, PLATFORM_ARCHON_APPLIED_AUTH_HOSTNAME_SCOPE_KEY, PLATFORM_ARCHON_APPLIED_AUTH_ORG_SCOPE_KEY, PLATFORM_ARCHON_APPLIED_BROKER_SCOPE_KEY, PLATFORM_ARCHON_APPLIED_CI_ORG_SCOPE_KEY, PLATFORM_ARCHON_APPLIED_FILES_SCOPE_KEY, PLATFORM_ARCHON_APPLIED_FIRST_OWNER_LINK_SCOPE_KEY, PLATFORM_ARCHON_APPLIED_GIT_REF_SCOPE_KEY, PLATFORM_ARCHON_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_ARCHON_APPLIED_ROLLING_UPDATE_SCOPE_KEY, PLATFORM_ARCHON_APP_KEY_ORG_SCOPE_KEY, PLATFORM_ARCHON_CONNECTOR_APP_SCOPE_KEY, PLATFORM_ARCHON_COOLIFY_READ_TOKEN_REF, PLATFORM_ARCHON_DEPLOYMENT_SCOPE_KEY, PLATFORM_ARCHON_ENVS_DEPLOYED_SCOPE_KEY, PLATFORM_ARCHON_GATE_CLOSED_SCOPE_KEY, PLATFORM_ARCHON_ID_TOKEN_USERINFO_APPLIED_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_ARCHON_ORG_DOMAIN_BLOCKED_SCOPE_KEY, PLATFORM_ARCHON_ORG_DOMAIN_SCOPE_KEY, PLATFORM_ARCHON_ORG_POLICY_SCOPE_KEY, PLATFORM_ARCHON_ORG_REGISTRATION_CLOSED_SCOPE_KEY, PLATFORM_ARCHON_ORG_SCOPE_KEY, PLATFORM_ARCHON_OWNER_INVITE_ORG_SCOPE_KEY, PLATFORM_ARCHON_OWNER_INVITE_SCOPE_KEY, PLATFORM_ARCHON_RETIRED_CLIENT_SCOPE_KEY, PLATFORM_ARCHON_UPTIME_TOKEN_SYNCED_SCOPE_KEY, PLATFORM_ARCHON_ZITADEL_APP_SCOPE_KEY, PLATFORM_ARCHON_ZITADEL_PROJECT_SCOPE_KEY, 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_ORG_SCOPE_KEY, PLATFORM_AUTH_OWNER_INVITE_SCOPE_KEY, PLATFORM_AUTH_REGISTRATION_CLOSED_SCOPE_KEY, PLATFORM_AUTH_RESERVED_ADMIN_USERNAME_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_EDGE_APPLIED_GIT_REF_SCOPE_KEY, PLATFORM_EDGE_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_EDGE_CLOUDFLARE_TOKEN_REF, PLATFORM_EDGE_INTERNAL_URL_SCOPE_KEY, PLATFORM_EDGE_KEY, PLATFORM_EDGE_KEY_REF, PLATFORM_EDGE_PLATFORM_KEY_SET_SCOPE_KEY, PLATFORM_EDGE_PLATFORM_KEY_UNDELIVERED_SCOPE_KEY, 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_HC_API_KEY_REF, PLATFORM_HC_APPLIED_GIT_REF_SCOPE_KEY, PLATFORM_HC_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_HC_KEY, PLATFORM_HEARTBEATS_APPLIED_SCOPE_KEY, PLATFORM_HEARTBEATS_HOSTED_API_KEY_REF, 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_HOST_PORTS_DIRECT, PLATFORM_MAIL_HOST_PORTS_SCOPE_KEY, PLATFORM_MAIL_INTERNAL_URL_SCOPE_KEY, PLATFORM_MAIL_KEY, PLATFORM_MAIL_RELAY_KEY, PLATFORM_MAIL_ROUTE_SCOPE_KEY, PLATFORM_MAIL_ROUTE_WANTED, PLATFORM_MAIL_ZITADEL_PROJECT_SCOPE_KEY, PLATFORM_OWNER_ORG_DOMAIN_BLOCKED_SCOPE_KEY, PLATFORM_OWNER_ORG_DOMAIN_SCOPE_KEY, PLATFORM_OWNER_ORG_SCOPE_KEY, PLATFORM_SFU_APPLIED_SCOPE_KEY, PLATFORM_UPTIME_ACCESS_CLIENT_ID_REF, PLATFORM_UPTIME_ACCESS_CLIENT_SECRET_REF, PLATFORM_UPTIME_ACCESS_TOKEN_ID_SCOPE_KEY, PLATFORM_UPTIME_ACCESS_TOKEN_NAME, PLATFORM_UPTIME_ACCESS_UNDELIVERED_SCOPE_KEY, PLATFORM_UPTIME_HEARTBEAT_CHECK_NAME, PLATFORM_UPTIME_KEY, PLATFORM_UPTIME_REMOVED_SCOPE_KEY, PLATFORM_UPTIME_SCRIPT_NAME, PLATFORM_UPTIME_TOKEN_ID_SCOPE_KEY, PLATFORM_UPTIME_TOKEN_REF, PLATFORM_ZITADEL_ORG_SCOPE_KEY, archonFilesMarker, archonFirstOwnerLink, archonOrgDomain, archonOrgName, desiredHeartbeatCheckNames, heartbeatCheckUrlRef, parseBrokerDeployRecord, parseLoginClientRecord, platformBackupHeartbeatCheckName, platformMailHostPorts, platformMailMxHost, uptimeAccessTokenConflict, uptimeRecorded, uptimeVarsDigest, } from './platform-diff.js';
15
24
  import { ensureSignInOrg, inviteOwnerWithLink, signInOrgDomain } from './sign-in-org.js';
16
25
  export { PLATFORM_ZITADEL_ORG_SCOPE_KEY };
17
26
  import { applyCallsSfuPlatformStep, callsSfuAppliedFingerprint, } from './platform-sfu.js';
18
27
  import { heldUptimeValues, provisionPlatformUptime, } from './uptime-worker.js';
19
- import { CreateInstanceError, createZitadelInstance, generateSystemApiKeypair, instanceWithoutBearerMessage, systemApiUsersEnv, } from './zitadel-system-api.js';
28
+ import { CreateInstanceError, createZitadelInstance, generateSystemApiKeypair, instanceWithoutBearerMessage, systemApiUsersEnv, waitForSystemApi, } from './zitadel-system-api.js';
20
29
  /** The key `runPlatformSteps` records the apex zone's id under (`system:kind:key`). */
21
- function apexZoneKey(apex) {
30
+ export function apexZoneKey(apex) {
22
31
  return `cloudflare:zone:${apex}`;
23
32
  }
24
33
  /** The key `runPlatformSteps` records the jobs scheduler app's id under (`system:kind:key`, #173). */
@@ -29,7 +38,7 @@ const JOBS_APP_KEY = `coolify:jobs:${JOBS_SCHEDULER_KEY}`;
29
38
  * those exist. Edge's step runs first and records the address
30
39
  * (`planPlatform`), so a missing one means that step has not finished.
31
40
  */
32
- async function platformDns(deps, recorded, step) {
41
+ export async function platformDns(deps, recorded, step) {
33
42
  const edgeUrl = recorded[PLATFORM_EDGE_INTERNAL_URL_SCOPE_KEY];
34
43
  if (!edgeUrl) {
35
44
  throw new Error(`${step}: edge has not been applied yet (no recorded ${PLATFORM_EDGE_INTERNAL_URL_SCOPE_KEY}); platform:edge runs first and every DNS record is written through it`);
@@ -37,7 +46,7 @@ async function platformDns(deps, recorded, step) {
37
46
  return deps.dns(edgeUrl);
38
47
  }
39
48
  /** The tunnel port for a platform step; same address and same reason as {@link platformDns}. */
40
- async function platformTunnels(deps, recorded, step) {
49
+ export async function platformTunnels(deps, recorded, step) {
41
50
  const edgeUrl = recorded[PLATFORM_EDGE_INTERNAL_URL_SCOPE_KEY];
42
51
  if (!edgeUrl) {
43
52
  throw new Error(`${step}: edge has not been applied yet (no recorded ${PLATFORM_EDGE_INTERNAL_URL_SCOPE_KEY}); platform:edge runs first and every tunnel is managed through it`);
@@ -69,81 +78,22 @@ const JOBS_DATABASE_SCOPE_KEY = 'coolify_jobs_database';
69
78
  * never rotates a token the host already holds.
70
79
  */
71
80
  const JOBS_ADMIN_TOKEN_SCOPE_KEY = 'jobs_admin_token_set';
72
- /** How many times `coolifyJobs` tries `deliverSecret` before giving up. */
81
+ /**
82
+ * An own org's scheduler only: the app's uuid, recorded and persisted before
83
+ * the admin token leaves and cleared once the token is delivered and written.
84
+ * Left set, the planner reports the scheduler blocked and never mints again.
85
+ */
86
+ export const JOBS_ADMIN_TOKEN_UNDELIVERED_SCOPE_KEY = 'jobs_admin_token_undelivered';
87
+ /** An own org's scheduler only: the git ref its app last deployed, so a changed declared ref plans the step again. */
88
+ export const JOBS_APPLIED_GIT_REF_SCOPE_KEY = 'jobs_applied_git_ref';
89
+ /** How many times `provisionJobsHost` tries `deliverSecret` before giving up. */
73
90
  const JOBS_ADMIN_TOKEN_DELIVERY_ATTEMPTS = 3;
74
- /** The ref the scheduler's admin token is delivered under: the host's real `JobsSchedulerPort` adapter reads it back. */
75
- export const JOBS_ADMIN_TOKEN_REF = 'jobs:scheduler:adminToken';
76
91
  /** The name of the Coolify project every platform-level service (starting with jobs, #173) lives in. */
77
92
  const PLATFORM_COOLIFY_PROJECT_NAME = 'platform';
78
93
  /** https://github.com/wtfalch/jobs -- the scheduler's own repo, Dockerfile at its root (its own `Dockerfile`'s doc comment). */
79
94
  const JOBS_SCHEDULER_REPO = 'wtfalch/jobs';
80
- /** The scheduler's fixed `custom_internal_name` and the port it listens on (jobs' `PORT`, default 8080). */
81
- const JOBS_INTERNAL_NAME = 'platform-jobs';
95
+ /** The port the scheduler listens on (jobs' `PORT`, default 8080). */
82
96
  const JOBS_PORT = '8080';
83
- /**
84
- * The real {@link PlatformStepExecutor} for the two steps built so far:
85
- * `cloudflare:zone` for the platform's apex (#167) and `coolify:jobs` for the
86
- * jobs scheduler (#173). `coolify` is optional so every #167-era caller --
87
- * anything that only ever plans the apex zone -- keeps constructing this with
88
- * two arguments; a `coolify:jobs` step reaching this executor with no
89
- * `coolify` port configured throws, naming what is missing, rather than
90
- * silently doing nothing.
91
- *
92
- * `cloudflareZone` mirrors `executor.ts`'s `cloudflareZone`: find the zone by
93
- * apex and adopt it, create it only when absent. companyincluded.com already
94
- * exists on Cloudflare, empty, so the first apply adopts it. The owner org
95
- * (Company Included, whose own domain is the apex) may record the same zone
96
- * under its own applied state, so that owner is allowed. Any other org that
97
- * recorded it makes this refuse to adopt. There is no delete here and
98
- * `PlatformStepExecutor` has none: neither path ever removes the zone.
99
- *
100
- * `coolifyJobs` mirrors `executor.ts`'s `coolifyApp`/`coolifyDatabase`,
101
- * simplified for one platform-level singleton rather than one per org: find
102
- * or create a dedicated `platform` Coolify project, find or create the
103
- * scheduler's own database, find or create the scheduler app itself (from
104
- * https://github.com/wtfalch/jobs, one Dockerfile at its repo root, on the
105
- * declared `gitRef`), then push the database's `internal_db_url` in as
106
- * `DATABASE_URL`. There is no park branch yet, matching `planJobs`'s own
107
- * scope note -- `platform.jobs.enabled` going from true to false plans
108
- * nothing, so this method is never asked to stop or delete anything.
109
- *
110
- * #170 adds `sfu:livekit`, the platform's shared calls SFU. `sfu` is
111
- * optional: an existing host that constructs this executor for the zone step
112
- * alone keeps compiling, and a plan that includes an `sfu:livekit` step
113
- * without it configured refuses clearly rather than silently doing nothing.
114
- * #152: the step mints one LiveKit key per org with calls enabled rather
115
- * than one shared pair, so it also needs `declaredOrgStore.list()` -- the
116
- * same store this class already takes for `findOrgIdForZone`.
117
- *
118
- * #168 adds `coolify:app` keyed {@link PLATFORM_AUTH_KEY}, the platform's own
119
- * sign-in ({@link provisionPlatformAuth}), once a host passes
120
- * {@link PlatformAuthDeps}.
121
- *
122
- * #169 adds `coolify:app` keyed {@link PLATFORM_MAIL_KEY}, the platform's own
123
- * mail server ({@link provisionPlatformMail}), once a host passes
124
- * {@link PlatformMailDeps}.
125
- *
126
- * #174 adds `coolify:app` keyed {@link PLATFORM_INTEGRATIONS_KEY},
127
- * the integrations host ({@link provisionPlatformIntegrations}), once a host
128
- * passes {@link PlatformIntegrationsDeps}.
129
- *
130
- * #171 adds `coolify:app` keyed {@link PLATFORM_FILES_KEY}, the files host
131
- * ({@link provisionPlatformFiles}), once a host passes {@link PlatformFilesDeps}.
132
- *
133
- * #172 adds `coolify:app` keyed {@link PLATFORM_AI_KEY}, the platform's AI
134
- * host ({@link provisionPlatformAi}), once a host passes {@link PlatformAiDeps}.
135
- *
136
- * #478 adds `coolify:app` keyed {@link PLATFORM_EDGE_KEY}, the edge host
137
- * ({@link provisionPlatformEdge}), once a host passes {@link PlatformEdgeDeps}.
138
- *
139
- * #175 adds `coolify:app` keyed {@link PLATFORM_ARCHON_KEY}, Archon itself
140
- * ({@link provisionPlatformArchon}), once a host passes
141
- * {@link PlatformArchonDeps}.
142
- *
143
- * #257 adds `coolify:backup:<service>`, a nightly R2 backup of each
144
- * platform database ({@link provisionPlatformBackup}), once a host passes
145
- * {@link PlatformBackupDeps}.
146
- */
147
97
  export class FoundryPlatformStepExecutor {
148
98
  cloudflare;
149
99
  declaredOrgStore;
@@ -163,6 +113,7 @@ export class FoundryPlatformStepExecutor {
163
113
  heartbeats;
164
114
  uptime;
165
115
  accessProvider;
116
+ oikosIngress;
166
117
  constructor(cloudflare, declaredOrgStore, options = {}) {
167
118
  this.cloudflare = cloudflare;
168
119
  this.declaredOrgStore = declaredOrgStore;
@@ -182,8 +133,23 @@ export class FoundryPlatformStepExecutor {
182
133
  this.heartbeats = options.heartbeats;
183
134
  this.uptime = options.uptime;
184
135
  this.accessProvider = options.accessProvider;
136
+ this.oikosIngress = options.oikosIngress;
137
+ }
138
+ platformPorts = new WeakMap();
139
+ /** The port placed on the platform's destination, made once per port so a server is listed once. */
140
+ onPlatform(coolify) {
141
+ let made = this.platformPorts.get(coolify);
142
+ if (!made) {
143
+ made = placedOnPlatform(coolify);
144
+ this.platformPorts.set(coolify, made);
145
+ }
146
+ return made;
147
+ }
148
+ /** `deps` with its Coolify port placed on the platform destination, then limited by `profile`. */
149
+ limitedOnPlatform(deps, profile) {
150
+ return { ...deps, coolify: limited(this.onPlatform(deps.coolify), profile) };
185
151
  }
186
- async blockedSteps(platform, recordedIds = {}) {
152
+ async blockedSteps(platform, recordedIds = {}, owner = {}) {
187
153
  const blocked = {};
188
154
  if (platform.auth.enabled && !this.auth) {
189
155
  blocked.auth =
@@ -223,6 +189,14 @@ export class FoundryPlatformStepExecutor {
223
189
  if (mailBlocked)
224
190
  blocked.mail = mailBlocked;
225
191
  }
192
+ if (platform.mail.relay === true) {
193
+ const relayBlocked = this.mail
194
+ ? (mailRelayBlockedReason() ??
195
+ (await relayServerBlockedReason(platform, this.mail, recordedIds)))
196
+ : MAIL_RELAY_BLOCKED.executorMissing;
197
+ if (relayBlocked)
198
+ blocked.relay = relayBlocked;
199
+ }
226
200
  if (platform.integrations.enabled && !this.integrations) {
227
201
  blocked.integrations =
228
202
  'the integrations host is blocked, not planned: the host built the platform executor without its integrations dependencies';
@@ -281,6 +255,23 @@ export class FoundryPlatformStepExecutor {
281
255
  if (edgeBlocked)
282
256
  blocked.edge = edgeBlocked;
283
257
  }
258
+ if (platform.edge.enabled && !oikosIngressApplied(recordedIds)) {
259
+ const edgeUrl = recordedIds[PLATFORM_EDGE_INTERNAL_URL_SCOPE_KEY];
260
+ if (!this.oikosIngress) {
261
+ blocked.oikosIngress = OIKOS_INGRESS_BLOCKED.executorMissing;
262
+ }
263
+ else if (edgeUrl) {
264
+ let zoneBlocked;
265
+ try {
266
+ zoneBlocked = await oikosIngressZoneBlocked(await this.oikosIngress.zones(edgeUrl), this.oikosIngress.callTimeoutMs);
267
+ }
268
+ catch {
269
+ zoneBlocked = OIKOS_INGRESS_BLOCKED.zoneUnchecked;
270
+ }
271
+ if (zoneBlocked)
272
+ blocked.oikosIngress = zoneBlocked;
273
+ }
274
+ }
284
275
  if (platform.archon.enabled) {
285
276
  if (!this.archon) {
286
277
  blocked.archon =
@@ -291,7 +282,7 @@ export class FoundryPlatformStepExecutor {
291
282
  'Archon is blocked, not planned: the host names no email the Cloudflare Access app in front of it may let through';
292
283
  }
293
284
  else {
294
- const inputs = await collectArchonServiceInputs(platform, this.archon, recordedIds);
285
+ const inputs = await collectArchonServiceInputs(platform, this.archon, recordedIds, owner.files ?? null);
295
286
  if ('blocked' in inputs) {
296
287
  blocked.archon = inputs.blocked;
297
288
  }
@@ -304,6 +295,22 @@ export class FoundryPlatformStepExecutor {
304
295
  }
305
296
  }
306
297
  }
298
+ // Archon's own sign-in organisation lives in the owner company's ZITADEL, reached through the sign-in dependencies.
299
+ if (platform.archon.enabled) {
300
+ if (!this.auth) {
301
+ blocked.archonOrg =
302
+ "Archon's sign-in organisation is blocked, not planned: the host built the platform executor without its auth dependencies";
303
+ }
304
+ else if (!this.auth.zitadelForInstance) {
305
+ blocked.archonOrg =
306
+ "Archon's sign-in organisation is blocked, not planned: the host built the platform's auth dependencies without zitadelForInstance, so it cannot reach the owner company's ZITADEL";
307
+ }
308
+ const ownerCompany = await this.declaredOrgStore.get(platform.ownerOrgId);
309
+ const inviteBlocked = ownerInviteBlockedReason(ownerCompany, Boolean(this.auth?.ownerInviteMail));
310
+ if (inviteBlocked) {
311
+ blocked.archonOrgInvite = inviteBlocked.replace("the platform owner's invite", "the first person's invite into Archon's sign-in organisation");
312
+ }
313
+ }
307
314
  // Only once a database actually exists to back up -- the same
308
315
  // "nothing to report yet" rule `planBackups` (`platform-diff.ts`)
309
316
  // itself follows for "no database", so an otherwise-unrelated platform
@@ -321,21 +328,30 @@ export class FoundryPlatformStepExecutor {
321
328
  if (hcTagBlocked)
322
329
  blocked.hc = hcTagBlocked;
323
330
  }
324
- // Only once `hc` is actually applied -- `planHeartbeats` itself already
325
- // withholds the step while `hc` is not, so a host with no heartbeats
331
+ // foundry#544: `'hosted'` needs none of `hc`'s own applied state -- only
332
+ // the self-hosted provider's standing note depends on `hc` being
333
+ // recorded applied at all. `planHeartbeats` itself already withholds the
334
+ // step while neither provider is ready, so a host with no heartbeats
326
335
  // deps wired carries no standing blocked note before there is anything
327
336
  // for it to reconcile.
328
- if (!this.heartbeats && Boolean(recordedIds[`coolify:app:${PLATFORM_HC_KEY}`])) {
337
+ const heartbeatsHosted = platform.heartbeatsProvider === 'hosted';
338
+ if (!this.heartbeats &&
339
+ (heartbeatsHosted || Boolean(recordedIds[`coolify:app:${PLATFORM_HC_KEY}`]))) {
329
340
  blocked.heartbeatsExecutorMissing = true;
330
341
  }
331
- else if (platform.hc.enabled && this.heartbeats) {
342
+ else if ((heartbeatsHosted || platform.hc.enabled) && this.heartbeats) {
332
343
  // #540 round 2: checked whether or not `hc` is recorded applied yet --
333
344
  // `hcReady` (`planPlatform`) goes true the same round `hc` is merely
334
345
  // *planned*, so this has to hold the heartbeats step back from the
335
346
  // very first round, before `coolify:app:platform:hc` is ever recorded.
336
347
  // See `PLATFORM_HC_API_KEY_REF`'s own doc comment (`platform-diff.ts`)
337
- // for why nothing here can mint this key itself.
338
- const apiKey = await this.heartbeats.heldKeys?.resolve?.(PLATFORM_HC_API_KEY_REF);
348
+ // for why nothing here can mint this key itself; foundry#544's
349
+ // `PLATFORM_HEARTBEATS_HOSTED_API_KEY_REF` is the same rule for the
350
+ // outside provider's own key.
351
+ const apiKeyRef = heartbeatsHosted
352
+ ? PLATFORM_HEARTBEATS_HOSTED_API_KEY_REF
353
+ : PLATFORM_HC_API_KEY_REF;
354
+ const apiKey = await this.heartbeats.heldKeys?.resolve?.(apiKeyRef);
339
355
  if (!apiKey)
340
356
  blocked.heartbeatsApiKeyMissing = true;
341
357
  }
@@ -343,7 +359,7 @@ export class FoundryPlatformStepExecutor {
343
359
  // #604: only once edge and sign-in are applied (the planner reports
344
360
  // that case itself) and no provider is recorded yet.
345
361
  if (platform.edge.enabled &&
346
- platform.auth.enabled &&
362
+ owner.signIn &&
347
363
  !recordedIds[ACCESS_PROVIDER_RECORD_KEY] &&
348
364
  recordedIds[PLATFORM_EDGE_INTERNAL_URL_SCOPE_KEY]) {
349
365
  if (!this.accessProvider?.zitadel.createOidcAppWithSecret) {
@@ -365,9 +381,10 @@ export class FoundryPlatformStepExecutor {
365
381
  platform.archon.enabled &&
366
382
  recordedIds[PLATFORM_ARCHON_ZITADEL_APP_SCOPE_KEY]) {
367
383
  try {
368
- const zitadel = this.archon.zitadelForInstance?.(`https://${platform.auth.hostname}`) ??
369
- this.archon.zitadel;
370
- if (zitadel.updateOidcAppIdTokenUserinfoAssertion)
384
+ const zitadel = owner.signIn
385
+ ? this.archon.zitadelForInstance?.(owner.signIn.issuerUrl, owner.signIn.bearerRef)
386
+ : undefined;
387
+ if (zitadel?.updateOidcAppIdTokenUserinfoAssertion)
371
388
  blocked.archonIdTokenUserinfo = true;
372
389
  }
373
390
  catch (e) {
@@ -469,18 +486,25 @@ export class FoundryPlatformStepExecutor {
469
486
  * login-client token is no longer the sole live PAT on its ZITADEL machine
470
487
  * user.
471
488
  */
472
- async driftedSteps(platform, recordedIds = {}) {
489
+ async driftedSteps(platform, recordedIds = {}, owner = {}) {
473
490
  const result = {};
474
491
  if (platform.archon.enabled &&
475
492
  this.archon &&
476
493
  recordedIds[`coolify:app:${PLATFORM_ARCHON_KEY}`]) {
477
- const accessDrifted = await archonAccessDrifted(this.archon, recordedIds, platform.archon.hostname, this.archon.accessEmails, platform.uptime.enabled
494
+ const ownerCompany = await this.declaredOrgStore.get(platform.ownerOrgId);
495
+ const accessDrifted = await archonAccessDrifted(this.archon, recordedIds, platform.archon.hostname, archonAccessEmails(this.archon.accessEmails, ownerCompany), platform.uptime.enabled
478
496
  ? recordedIds[PLATFORM_UPTIME_ACCESS_TOKEN_ID_SCOPE_KEY]
479
497
  : undefined);
480
- const brokerDrifted = !accessDrifted && (await archonBrokerRegistrationDrifted(this.archon.coolify, recordedIds));
498
+ const archonOrgId = recordedIds[PLATFORM_ARCHON_ORG_SCOPE_KEY];
499
+ const brokerDrifted = !accessDrifted &&
500
+ (await archonBrokerRegistrationDrifted(this.archon.coolify, owner.signIn ?? null, archonOrgId, recordedIds));
481
501
  const authOrgDrifted = !accessDrifted &&
482
502
  !brokerDrifted &&
483
- (await archonAuthOrgDrifted(this.archon.coolify, recordedIds[`coolify:app:${PLATFORM_ARCHON_KEY}`], recordedIds));
503
+ (await archonAuthOrgDrifted(this.archon.coolify, recordedIds[`coolify:app:${PLATFORM_ARCHON_KEY}`], {
504
+ AUTH_ORGANIZATION_ID: archonOrgId,
505
+ CI_ORGANIZATION_ID: owner.signIn?.organisationId,
506
+ ARCHON_FIRST_OWNER_LINK: archonFirstOwnerLink(recordedIds, owner.signIn ?? null),
507
+ }));
484
508
  const authzDrifted = !accessDrifted &&
485
509
  !brokerDrifted &&
486
510
  !authOrgDrifted &&
@@ -585,13 +609,13 @@ export class FoundryPlatformStepExecutor {
585
609
  throw new Error('platform:auth needs FoundryPlatformStepExecutor to be constructed with its auth dependencies (#168)');
586
610
  }
587
611
  const company = await this.declaredOrgStore.get(context.platform.ownerOrgId);
588
- return provisionPlatformAuth(context, this.auth, company);
612
+ return provisionPlatformAuth(context, this.limitedOnPlatform(this.auth, LIMITS.platformAuth), company);
589
613
  }
590
614
  if (action.system === 'coolify' && action.kind === 'app' && action.key === PLATFORM_FILES_KEY) {
591
615
  if (!this.files) {
592
616
  throw new Error('platform:files needs FoundryPlatformStepExecutor to be constructed with its files dependencies (#171)');
593
617
  }
594
- return provisionPlatformFiles(context, this.files, filesShareDomains(await this.declaredOrgStore.list()));
618
+ return provisionPlatformFiles(context, this.limitedOnPlatform(this.files, LIMITS.platformFiles), filesShareDomains(await this.declaredOrgStore.list()));
595
619
  }
596
620
  if (action.system === 'coolify' &&
597
621
  action.kind === 'app' &&
@@ -599,19 +623,19 @@ export class FoundryPlatformStepExecutor {
599
623
  if (!this.integrations) {
600
624
  throw new Error('platform:integrations needs FoundryPlatformStepExecutor to be constructed with its integrations dependencies (#174)');
601
625
  }
602
- return provisionPlatformIntegrations(action, context, this.integrations);
626
+ return provisionPlatformIntegrations(action, context, this.limitedOnPlatform(this.integrations, LIMITS.platformIntegrations));
603
627
  }
604
628
  if (action.system === 'coolify' && action.kind === 'app' && action.key === PLATFORM_AI_KEY) {
605
629
  if (!this.ai) {
606
630
  throw new Error('platform:ai needs FoundryPlatformStepExecutor to be constructed with its ai dependencies (#172)');
607
631
  }
608
- return provisionPlatformAi(context, this.ai);
632
+ return provisionPlatformAi(context, this.limitedOnPlatform(this.ai, LIMITS.platformAi));
609
633
  }
610
634
  if (action.system === 'coolify' && action.kind === 'app' && action.key === PLATFORM_EDGE_KEY) {
611
635
  if (!this.edge) {
612
636
  throw new Error('platform:edge needs FoundryPlatformStepExecutor to be constructed with its edge dependencies (#478)');
613
637
  }
614
- return provisionPlatformEdge(context, this.edge);
638
+ return provisionPlatformEdge(context, this.limitedOnPlatform(this.edge, LIMITS.platformEdge));
615
639
  }
616
640
  if (action.system === 'coolify' &&
617
641
  action.kind === 'app' &&
@@ -620,19 +644,36 @@ export class FoundryPlatformStepExecutor {
620
644
  throw new Error('platform:archon needs FoundryPlatformStepExecutor to be constructed with its archon dependencies (#175)');
621
645
  }
622
646
  const ownerCompany = await this.declaredOrgStore.get(context.platform.ownerOrgId);
623
- return provisionPlatformArchon(context, this.archon, ownerCompany);
647
+ return provisionPlatformArchon(context, this.limitedOnPlatform(this.archon, LIMITS.platformArchon), ownerCompany);
624
648
  }
625
649
  if (action.system === 'coolify' && action.kind === 'app' && action.key === PLATFORM_MAIL_KEY) {
626
650
  if (!this.mail) {
627
651
  throw new Error('platform:mail needs FoundryPlatformStepExecutor to be constructed with its mail dependencies (#169)');
628
652
  }
629
- return provisionPlatformMail(context, this.mail);
653
+ return provisionPlatformMail(context, this.limitedOnPlatform(this.mail, LIMITS.platformMail));
654
+ }
655
+ if (action.system === 'coolify' &&
656
+ action.kind === 'app' &&
657
+ action.key === PLATFORM_MAIL_RELAY_KEY) {
658
+ if (!this.mail) {
659
+ throw new Error('platform:mail-relay needs FoundryPlatformStepExecutor to be constructed with its mail dependencies');
660
+ }
661
+ return provisionPlatformMailRelay(action, context, this.limitedOnPlatform(this.mail, LIMITS.platformMailRelay));
630
662
  }
631
663
  if (action.system === 'coolify' && action.kind === 'app' && action.key === PLATFORM_HC_KEY) {
632
664
  if (!this.hc) {
633
665
  throw new Error('platform:hc needs FoundryPlatformStepExecutor to be constructed with its hc dependencies (#540)');
634
666
  }
635
- return provisionPlatformHc(context, this.hc);
667
+ return provisionPlatformHc(context, this.limitedOnPlatform(this.hc, LIMITS.platformHc));
668
+ }
669
+ if (action.system === 'zitadel' &&
670
+ action.kind === 'archon-org' &&
671
+ action.key === PLATFORM_ARCHON_KEY) {
672
+ if (!this.auth) {
673
+ throw new Error("platform:archon-org needs FoundryPlatformStepExecutor to be constructed with its auth dependencies: Archon's sign-in organisation is made through them");
674
+ }
675
+ const ownerCompany = await this.declaredOrgStore.get(context.platform.ownerOrgId);
676
+ return provisionPlatformArchonOrg(context, this.auth, ownerCompany);
636
677
  }
637
678
  if (action.system === 'zitadel' &&
638
679
  action.kind === 'id-token-userinfo' &&
@@ -670,6 +711,14 @@ export class FoundryPlatformStepExecutor {
670
711
  }
671
712
  return provisionPlatformUptime(context, this.uptime);
672
713
  }
714
+ if (action.system === 'coolify' &&
715
+ action.kind === 'app' &&
716
+ action.key === PLATFORM_OIKOS_INGRESS_KEY) {
717
+ if (!this.oikosIngress) {
718
+ throw new Error('platform:oikos-ingress needs FoundryPlatformStepExecutor to be constructed with its oikos ingress dependencies');
719
+ }
720
+ return provisionPlatformOikosIngress(context, this.limitedOnPlatform(this.oikosIngress, LIMITS.platformIngress));
721
+ }
673
722
  if (action.system === 'cloudflare' && action.kind === 'zone') {
674
723
  return this.cloudflareZone(action, context);
675
724
  }
@@ -743,104 +792,152 @@ export class FoundryPlatformStepExecutor {
743
792
  if (!deliverSecret) {
744
793
  throw new Error('platform step coolify:jobs needs a deliverSecret -- construct FoundryPlatformStepExecutor with one, to hand the scheduler admin token to the host (#173)');
745
794
  }
746
- const hostname = action.desired.hostname;
747
- const gitRef = action.desired.gitRef;
748
- const recorded = context.recordedIds();
749
- let projectUuid = recorded[JOBS_PROJECT_SCOPE_KEY];
750
- if (!projectUuid) {
751
- const found = await coolify.findProjectByName(PLATFORM_COOLIFY_PROJECT_NAME);
752
- projectUuid = found
753
- ? found.uuid
754
- : (await coolify.createProject(PLATFORM_COOLIFY_PROJECT_NAME)).uuid;
755
- context.recordScopeId(JOBS_PROJECT_SCOPE_KEY, projectUuid);
756
- }
757
- const { environment } = await coolify.findOrCreateEnvironment(projectUuid, 'production');
758
- let dbId = recorded[JOBS_DATABASE_SCOPE_KEY];
759
- if (!dbId) {
760
- const dbName = `${JOBS_SCHEDULER_KEY}-scheduler-db`;
761
- const found = (await coolify.listDatabases()).find((db) => db.name === dbName && db.environment_id === environment.id);
762
- if (found) {
763
- dbId = found.uuid;
764
- }
765
- else {
766
- const server = await resolvePlatformServer(coolify);
767
- const created = await coolify.createPostgresDatabase({
768
- project_uuid: projectUuid,
769
- server_uuid: server.uuid,
770
- environment_name: environment.name,
771
- environment_uuid: environment.uuid,
772
- name: dbName,
773
- postgres_db: 'jobs',
774
- postgres_user: 'jobs',
775
- });
776
- dbId = created.uuid;
777
- }
778
- context.recordScopeId(JOBS_DATABASE_SCOPE_KEY, dbId);
779
- }
780
- const existingApp = await coolify.findApplicationByName(JOBS_SCHEDULER_KEY, {
781
- environmentIds: [environment.id],
795
+ return provisionJobsHost(scopeFromPlatform(context), {
796
+ coolify: limited(this.onPlatform(coolify), LIMITS.platformJobs),
797
+ deliverSecret,
798
+ runtime: this.runtime,
799
+ }, platformTarget(), {
800
+ hostname: action.desired.hostname,
801
+ gitRef: action.desired.gitRef,
802
+ project: platformJobsProject,
782
803
  });
783
- let appId;
784
- if (existingApp) {
785
- appId = existingApp.uuid;
804
+ }
805
+ }
806
+ /** The platform's jobs scheduler lives in the shared `platform` project. */
807
+ const platformJobsProject = {
808
+ async ensure(coolify, scope) {
809
+ const recorded = scope.recordedIds()[JOBS_PROJECT_SCOPE_KEY];
810
+ if (recorded)
811
+ return recorded;
812
+ const found = await coolify.findProjectByName(PLATFORM_COOLIFY_PROJECT_NAME);
813
+ const projectUuid = found
814
+ ? found.uuid
815
+ : (await coolify.createProject(PLATFORM_COOLIFY_PROJECT_NAME)).uuid;
816
+ scope.recordScopeId(JOBS_PROJECT_SCOPE_KEY, projectUuid);
817
+ return projectUuid;
818
+ },
819
+ };
820
+ /**
821
+ * Provisions one jobs scheduler (database, app, admin token) for `target`:
822
+ * the platform's, or an own org's. Every name and the held-key ref come from
823
+ * the target.
824
+ *
825
+ * An org target also guards the mint (`CLAUDE.md`, "Platform steps" rules 1
826
+ * and 3): the token's id is recorded as undelivered and persisted before the
827
+ * token leaves, the delivered guard is recorded after the env write, and the
828
+ * applied git ref is recorded so a changed ref plans the step again. The
829
+ * platform's step keeps its provision-only behaviour.
830
+ */
831
+ export async function provisionJobsHost(scope, deps, target, input) {
832
+ const { coolify, deliverSecret, runtime } = deps;
833
+ const { hostname, gitRef } = input;
834
+ const own = target.kind === 'org';
835
+ const label = own ? `own step coolify:own-jobs for ${target.id}` : 'platform step coolify:jobs';
836
+ const projectUuid = await input.project.ensure(coolify, scope);
837
+ const { environment } = await coolify.findOrCreateEnvironment(projectUuid, 'production');
838
+ let dbId = scope.recordedIds()[JOBS_DATABASE_SCOPE_KEY];
839
+ if (!dbId) {
840
+ const dbName = target.names.jobsDb;
841
+ const found = (await coolify.listDatabases()).find((db) => db.name === dbName && db.environment_id === environment.id);
842
+ if (found) {
843
+ dbId = found.uuid;
786
844
  }
787
845
  else {
788
846
  const server = await resolvePlatformServer(coolify);
789
- const githubApp = await coolify.requireGithubApp();
790
- const created = await coolify.createGithubAppApplication({
847
+ const created = await coolify.createPostgresDatabase({
791
848
  project_uuid: projectUuid,
792
849
  server_uuid: server.uuid,
793
850
  environment_name: environment.name,
794
851
  environment_uuid: environment.uuid,
795
- github_app_uuid: githubApp.uuid,
796
- git_repository: JOBS_SCHEDULER_REPO,
797
- git_branch: gitRef,
798
- build_pack: 'dockerfile',
799
- name: JOBS_SCHEDULER_KEY,
800
- domains: coolifyDomains([hostname]),
852
+ name: dbName,
853
+ postgres_db: 'jobs',
854
+ postgres_user: 'jobs',
801
855
  });
802
- appId = created.uuid;
856
+ dbId = created.uuid;
803
857
  }
804
- // Set on every run, like `platform:files`' runner: the name another
805
- // container reaches the scheduler by. It only takes effect on a deploy.
806
- await coolify.updateApplication(appId, {
807
- custom_internal_name: JOBS_INTERNAL_NAME,
808
- ports_exposes: JOBS_PORT,
858
+ scope.recordScopeId(JOBS_DATABASE_SCOPE_KEY, dbId);
859
+ }
860
+ const existingApp = await coolify.findApplicationByName(target.names.jobsApp, {
861
+ environmentIds: [environment.id],
862
+ });
863
+ let appId;
864
+ if (existingApp) {
865
+ appId = existingApp.uuid;
866
+ }
867
+ else {
868
+ const server = await resolvePlatformServer(coolify);
869
+ const githubApp = await coolify.requireGithubApp();
870
+ const created = await coolify.createGithubAppApplication({
871
+ project_uuid: projectUuid,
872
+ server_uuid: server.uuid,
873
+ environment_name: environment.name,
874
+ environment_uuid: environment.uuid,
875
+ github_app_uuid: githubApp.uuid,
876
+ git_repository: JOBS_SCHEDULER_REPO,
877
+ git_branch: gitRef,
878
+ build_pack: 'dockerfile',
879
+ name: target.names.jobsApp,
880
+ ...(hostname ? { domains: coolifyDomains([hostname]) } : {}),
809
881
  });
810
- const db = await getDatabaseStarted(coolify, dbId);
811
- if (!db.internal_db_url) {
812
- throw new Error(`platform step coolify:jobs: database ${dbId} has no internal_db_url yet; not deploying the scheduler without a database`);
813
- }
814
- // The scheduler migrates as `DATABASE_URL_OWNER` and creates its own
815
- // `jobs_rt` role from `DATABASE_URL` (jobs README, "Deploying"). An app
816
- // with no owner URL still holds the owner's URL in `DATABASE_URL`, so
817
- // that is never reused: a runtime URL is minted, once, and kept after.
818
- const hasOwnerUrl = (await coolify.listEnvs(appId)).some((e) => e.key === 'DATABASE_URL_OWNER' && !e.is_preview && e.value);
819
- const runtimeUrl = await ensureAiRuntimeUrl(coolify, hasOwnerUrl ? [appId] : [], db.internal_db_url, 'platform step coolify:jobs');
820
- const envs = [
821
- { key: 'DATABASE_URL_OWNER', value: db.internal_db_url },
822
- { key: 'DATABASE_URL', value: runtimeUrl },
823
- ];
824
- // Whether the scheduler runs on envs other than the ones written below.
825
- // Marked and persisted before the first write, and cleared only after the
826
- // deploy, so a run that dies in between leaves the mark and the next run
827
- // deploys again (`CLAUDE.md` rule 4).
828
- const pending = Boolean(recorded[JOBS_DEPLOY_PENDING_SCOPE_KEY]) || (await envsDiffer(coolify, appId, envs));
829
- if (pending) {
830
- context.recordScopeId(JOBS_DEPLOY_PENDING_SCOPE_KEY, '1');
831
- await context.persist?.();
832
- }
833
- // SCHEDULER_ADMIN_TOKEN gates the admin API the org registrations use
834
- // (jobs README, "The admin API"); unset, every admin route answers 401.
835
- // Delivered to the host before it is written to the app, so a failed
836
- // delivery leaves nothing set and the rerun simply generates a new one.
837
- let tokenJustSet = false;
838
- if (!context.recordedIds()[JOBS_ADMIN_TOKEN_SCOPE_KEY]) {
839
- const token = randomBytes(32).toString('hex');
882
+ appId = created.uuid;
883
+ }
884
+ // An org's declared ref is applied to the app it already has; the platform's
885
+ // is a manual redeploy.
886
+ const refChanged = own && Boolean(existingApp) && scope.recordedIds()[JOBS_APPLIED_GIT_REF_SCOPE_KEY] !== gitRef;
887
+ // Set on every run, like `platform:files`' runner: the name another
888
+ // container reaches the scheduler by. It only takes effect on a deploy.
889
+ await coolify.updateApplication(appId, {
890
+ custom_internal_name: target.internal.jobs,
891
+ ports_exposes: JOBS_PORT,
892
+ // A connect, not a GET: every route but the admin one answers 401, 404 or 405, which a GET check fails.
893
+ ...commandHealthCheckSettings(tcpProbe(JOBS_PORT)),
894
+ ...(refChanged ? { git_branch: gitRef } : {}),
895
+ });
896
+ const db = await getDatabaseStarted(coolify, dbId);
897
+ if (!db.internal_db_url) {
898
+ throw new Error(`${label}: database ${dbId} has no internal_db_url yet; not deploying the scheduler without a database`);
899
+ }
900
+ // The scheduler migrates as `DATABASE_URL_OWNER` and creates its own
901
+ // `jobs_rt` role from `DATABASE_URL` (jobs README, "Deploying"). An app
902
+ // with no owner URL still holds the owner's URL in `DATABASE_URL`, so
903
+ // that is never reused: a runtime URL is minted, once, and kept after.
904
+ const hasOwnerUrl = (await coolify.listEnvs(appId)).some((e) => e.key === 'DATABASE_URL_OWNER' && !e.is_preview && e.value);
905
+ const runtimeUrl = await ensureAiRuntimeUrl(coolify, hasOwnerUrl ? [appId] : [], db.internal_db_url, label);
906
+ const envs = [
907
+ { key: 'DATABASE_URL_OWNER', value: db.internal_db_url },
908
+ { key: 'DATABASE_URL', value: runtimeUrl },
909
+ ];
910
+ // Whether the scheduler runs on envs other than the ones written below.
911
+ // Marked and persisted before the first write, and cleared only after the
912
+ // deploy, so a run that dies in between leaves the mark and the next run
913
+ // deploys again (`CLAUDE.md` rule 4).
914
+ const pending = Boolean(scope.recordedIds()[JOBS_DEPLOY_PENDING_SCOPE_KEY]) ||
915
+ refChanged ||
916
+ (await envsDiffer(coolify, appId, envs));
917
+ if (pending) {
918
+ scope.recordScopeId(JOBS_DEPLOY_PENDING_SCOPE_KEY, '1');
919
+ await scope.persist();
920
+ }
921
+ // SCHEDULER_ADMIN_TOKEN gates the admin API the org registrations use
922
+ // (jobs README, "The admin API"); unset, every admin route answers 401.
923
+ // Delivered to the host before it is written to the app.
924
+ let tokenJustSet = false;
925
+ if (!scope.recordedIds()[JOBS_ADMIN_TOKEN_SCOPE_KEY]) {
926
+ const lost = own ? scope.recordedIds()[JOBS_ADMIN_TOKEN_UNDELIVERED_SCOPE_KEY] : undefined;
927
+ const resumed = lost ? await deps.readSecret?.(target.refs.jobsAdminToken) : null;
928
+ if (lost && !resumed) {
929
+ throw new Error(`${label}: a scheduler admin token (${lost}) was minted but never confirmed delivered; remove the recorded key ${JOBS_ADMIN_TOKEN_UNDELIVERED_SCOPE_KEY} to mint a new one`);
930
+ }
931
+ const token = resumed || randomBytes(32).toString('hex');
932
+ if (!resumed) {
933
+ if (own) {
934
+ scope.recordScopeId(JOBS_ADMIN_TOKEN_UNDELIVERED_SCOPE_KEY, appId);
935
+ await scope.persist();
936
+ }
840
937
  let delivered = false;
841
938
  for (let attempt = 1; attempt <= JOBS_ADMIN_TOKEN_DELIVERY_ATTEMPTS && !delivered; attempt++) {
842
939
  try {
843
- await deliverSecret(JOBS_ADMIN_TOKEN_REF, token);
940
+ await deliverSecret(target.refs.jobsAdminToken, token);
844
941
  delivered = true;
845
942
  }
846
943
  catch {
@@ -848,48 +945,49 @@ export class FoundryPlatformStepExecutor {
848
945
  }
849
946
  }
850
947
  if (!delivered) {
851
- throw new Error(`platform step coolify:jobs: the scheduler admin token could not be delivered after ${JOBS_ADMIN_TOKEN_DELIVERY_ATTEMPTS} attempts`);
948
+ throw new Error(`${label}: the scheduler admin token could not be delivered after ${JOBS_ADMIN_TOKEN_DELIVERY_ATTEMPTS} attempts`);
852
949
  }
853
- envs.push({ key: 'SCHEDULER_ADMIN_TOKEN', value: token });
854
- await coolify.bulkUpsertEnvs(appId, envs);
855
- context.recordScopeId(JOBS_ADMIN_TOKEN_SCOPE_KEY, appId);
856
- tokenJustSet = true;
857
950
  }
858
- else {
859
- await coolify.bulkUpsertEnvs(appId, envs);
860
- }
861
- // The app is created with `instant_deploy: false` and an env change needs
862
- // a redeploy, so deploy after a create or a new token. Also when it is not
863
- // running, so a deploy that failed on an earlier run is retried.
864
- const running = (await coolify.getApplication(appId)).status?.startsWith('running') ?? false;
865
- // An app applied before its internal address was recorded has no fixed
866
- // name yet, so it deploys once to get it; after that it deploys on the
867
- // other conditions only.
868
- const addressNew = !context.recordedIds()[JOBS_INTERNAL_URL_SCOPE_KEY];
869
- // The address is recorded only once the deployment finished: the scheduler
870
- // has no route that answers without its admin token (every other path is a
871
- // 401, 404 or 405), so the deployment record is the whole check, as for
872
- // the AI worker. A deploy that fails or times out throws here, records
873
- // nothing and leaves the step planned.
874
- if (!existingApp || tokenJustSet || pending || !running || addressNew) {
875
- await waitForNewDeployment(coolify, appId, { kind: 'deployment-only' }, this.runtime, this.runtime.sleep ?? defaultSleep, 'platform step coolify:jobs', 'the scheduler keeps its old address');
876
- }
877
- if (pending) {
878
- context.recordScopeId(JOBS_DEPLOY_PENDING_SCOPE_KEY, '');
879
- await context.persist?.();
880
- }
881
- context.recordScopeId(JOBS_INTERNAL_URL_SCOPE_KEY, `http://${JOBS_INTERNAL_NAME}:${JOBS_PORT}`);
882
- return { resourceId: appId };
951
+ envs.push({ key: 'SCHEDULER_ADMIN_TOKEN', value: token });
952
+ await coolify.bulkUpsertEnvs(appId, envs);
953
+ scope.recordScopeId(JOBS_ADMIN_TOKEN_SCOPE_KEY, appId);
954
+ if (own)
955
+ scope.recordScopeId(JOBS_ADMIN_TOKEN_UNDELIVERED_SCOPE_KEY, '');
956
+ tokenJustSet = true;
883
957
  }
958
+ else {
959
+ await coolify.bulkUpsertEnvs(appId, envs);
960
+ }
961
+ // The app is created with `instant_deploy: false` and an env change needs
962
+ // a redeploy, so deploy after a create or a new token. Also when it is not
963
+ // running, so a deploy that failed on an earlier run is retried.
964
+ const running = (await coolify.getApplication(appId)).status?.startsWith('running') ?? false;
965
+ // An app applied before its internal address was recorded has no fixed
966
+ // name yet, so it deploys once to get it; after that it deploys on the
967
+ // other conditions only.
968
+ const addressNew = !scope.recordedIds()[JOBS_INTERNAL_URL_SCOPE_KEY];
969
+ // The address is recorded only once the deployment finished: the scheduler
970
+ // has no route that answers without its admin token (every other path is a
971
+ // 401, 404 or 405), so the deployment record is the whole check, as for
972
+ // the AI worker. A deploy that fails or times out throws here, records
973
+ // nothing and leaves the step planned.
974
+ if (!existingApp || tokenJustSet || pending || !running || addressNew) {
975
+ const built = await waitForNewDeployment(coolify, appId, { kind: 'deployment-only' }, runtime, runtime.sleep ?? defaultSleep, label, 'the scheduler keeps its old address');
976
+ await recordBuiltCommit(coolify, { appId: appId, deploymentUuid: built.deploymentUuid, name: 'jobs' }, (key, value) => scope.recordScopeId(key, value));
977
+ }
978
+ if (pending) {
979
+ scope.recordScopeId(JOBS_DEPLOY_PENDING_SCOPE_KEY, '');
980
+ await scope.persist();
981
+ }
982
+ if (own)
983
+ scope.recordScopeId(JOBS_APPLIED_GIT_REF_SCOPE_KEY, gitRef);
984
+ scope.recordScopeId(JOBS_INTERNAL_URL_SCOPE_KEY, `http://${target.internal.jobs}:${JOBS_PORT}`);
985
+ return { resourceId: appId };
884
986
  }
885
987
  // ---------------------------------------------------------------------------
886
988
  // #168: the platform's own sign-in (ZITADEL)
887
989
  // ---------------------------------------------------------------------------
888
990
  const PLATFORM_AUTH_PROJECT = 'platform';
889
- const PLATFORM_AUTH_DB_NAME = 'platform-auth-db';
890
- const PLATFORM_AUTH_ZITADEL_APP_NAME = 'platform-auth-zitadel';
891
- const PLATFORM_AUTH_LOGIN_APP_NAME = 'platform-auth-login';
892
- const PLATFORM_AUTH_BROKER_APP_NAME = 'platform-auth-broker';
893
991
  /**
894
992
  * Fixed `custom_internal_name`s (`diff.ts`'s `coolifyCalls` sets the same
895
993
  * kind of name on Synapse/MAS) so the Cloudflare tunnel below can address
@@ -897,12 +995,7 @@ const PLATFORM_AUTH_BROKER_APP_NAME = 'platform-auth-broker';
897
995
  * -- bypassing Coolify's own Traefik, which dispatches purely by `Host`
898
996
  * header and so cannot give two different apps the same public hostname.
899
997
  */
900
- const PLATFORM_AUTH_ZITADEL_INTERNAL_NAME = 'platform-auth-zitadel';
901
998
  export const PLATFORM_AUTH_LOGIN_INTERNAL_NAME = 'platform-auth-login';
902
- const PLATFORM_AUTH_BROKER_INTERNAL_NAME = 'platform-auth-broker';
903
- const PLATFORM_AUTH_TUNNEL_NAME = 'platform-auth-tunnel';
904
- /** #197: the one Coolify app that runs `cloudflared` for {@link PLATFORM_AUTH_TUNNEL_NAME}. */
905
- const PLATFORM_AUTH_CLOUDFLARED_APP_NAME = 'platform-auth-cloudflared';
906
999
  /** `wtfalch/auth`'s `docker-compose.yml`: `image: ghcr.io/zitadel/zitadel:${ZITADEL_VERSION:-v4.17.3}`. Pinned in `versions.json` (#247). */
907
1000
  const AUTH_ZITADEL_IMAGE = 'ghcr.io/zitadel/zitadel';
908
1001
  const AUTH_ZITADEL_IMAGE_TAG = VERSIONS.zitadel;
@@ -917,6 +1010,22 @@ const AUTH_BROKER_DOCKERFILE = '/broker/Dockerfile';
917
1010
  * own `ZITADEL_PORT: 8080` and the login image's own `EXPOSE`.
918
1011
  */
919
1012
  const AUTH_ZITADEL_PORT = '8080';
1013
+ /**
1014
+ * The inline Dockerfile of the ZITADEL app. The published image is distroless (no shell, no `curl`) and has no
1015
+ * HEALTHCHECK, and Coolify runs its own check as `sh -c`. So the app is a Dockerfile app: the image's own `ready`
1016
+ * command is its exec-form HEALTHCHECK, which Coolify keeps while its own check is off. `ready` asks the port and
1017
+ * TLS mode from the same `ZITADEL_*` settings the server reads. The start period covers the first run, which sets
1018
+ * up the database and the first instance before it listens. The start command is the one
1019
+ * {@link AUTH_ZITADEL_RUN_OPTIONS} names.
1020
+ */
1021
+ export function zitadelDockerfile() {
1022
+ return [
1023
+ `FROM ${AUTH_ZITADEL_IMAGE}:${pinnedRef('zitadel')}`,
1024
+ `EXPOSE ${AUTH_ZITADEL_PORT}`,
1025
+ 'CMD ["start-from-init", "--masterkeyFromEnv", "--tlsMode", "external"]',
1026
+ 'HEALTHCHECK --interval=15s --timeout=5s --start-period=180s --retries=5 CMD ["/app/zitadel", "ready"]',
1027
+ ].join('\n');
1028
+ }
920
1029
  export const AUTH_LOGIN_PORT = '3000';
921
1030
  /** `broker/Dockerfile`'s own `ENV PORT=3000` / `EXPOSE 3000`. */
922
1031
  const AUTH_BROKER_PORT = '3000';
@@ -942,8 +1051,8 @@ export const AUTH_ZITADEL_PATH_PATTERN = '^/(oidc|oauth|saml|management|admin|sy
942
1051
  const AUTH_BROKER_PATH_PATTERN = '^/api(/|$)';
943
1052
  const PLATFORM_AUTH_PROJECT_SCOPE_KEY = 'coolify_project_platform_auth';
944
1053
  const PLATFORM_AUTH_DB_SCOPE_KEY = 'coolify_platform_auth_database';
945
- const PLATFORM_AUTH_ZITADEL_SCOPE_KEY = 'coolify_platform_auth_zitadel_app';
946
- const PLATFORM_AUTH_LOGIN_SCOPE_KEY = 'coolify_platform_auth_login_app';
1054
+ export const PLATFORM_AUTH_ZITADEL_SCOPE_KEY = 'coolify_platform_auth_zitadel_app';
1055
+ export const PLATFORM_AUTH_LOGIN_SCOPE_KEY = 'coolify_platform_auth_login_app';
947
1056
  const PLATFORM_AUTH_TUNNEL_SCOPE_KEY = 'cloudflare_platform_auth_tunnel';
948
1057
  /** Recorded once the ZITADEL app has been deployed, so ZITADEL may have written to its database. */
949
1058
  const PLATFORM_AUTH_ZITADEL_DEPLOYED_SCOPE_KEY = 'zitadel_platform_auth_deployed';
@@ -1072,6 +1181,10 @@ function loginClientRecordValue(userId, patId, token) {
1072
1181
  * miss for a credential-liveness check than for Archon's own Access policy.
1073
1182
  */
1074
1183
  async function authLoginClientDrifted(platform, auth, recordedIds) {
1184
+ return authLoginClientDriftedAt({ hostname: platform.auth.hostname }, auth, recordedIds);
1185
+ }
1186
+ /** {@link authLoginClientDrifted} for any instance; `bearerRef` is passed to `zitadelForInstance` only when set. */
1187
+ async function authLoginClientDriftedAt(instance, auth, recordedIds) {
1075
1188
  const record = parseLoginClientRecord(recordedIds[PLATFORM_AUTH_LOGIN_CLIENT_USER_SCOPE_KEY]);
1076
1189
  if (!record)
1077
1190
  return false;
@@ -1081,7 +1194,10 @@ async function authLoginClientDrifted(platform, auth, recordedIds) {
1081
1194
  if (!orgId || !loginAppId || !brokerAppId)
1082
1195
  return true;
1083
1196
  try {
1084
- const zitadel = auth.zitadelForInstance?.(`https://${platform.auth.hostname}`) ?? auth.zitadel;
1197
+ const issuer = `https://${instance.hostname}`;
1198
+ const zitadel = (instance.bearerRef && instance.bearerRef !== PLATFORM_AUTH_INSTANCE_BEARER_REF
1199
+ ? auth.zitadelForInstance?.(issuer, instance.bearerRef)
1200
+ : auth.zitadelForInstance?.(issuer)) ?? auth.zitadel;
1085
1201
  const livePats = await zitadel.listLoginClientPats(orgId, record.userId);
1086
1202
  if (livePats.length !== 1 || livePats[0] !== record.patId)
1087
1203
  return true;
@@ -1240,28 +1356,33 @@ async function liveLoginClientTokenHash(coolify, appId) {
1240
1356
  * #197: a `cloudflared` connector for this tunnel is run
1241
1357
  * ({@link ensureCloudflaredConnector}), so `hostname` answers.
1242
1358
  */
1243
- async function provisionPlatformAuth(context, deps, ownerCompany = null) {
1244
- const { platform } = context;
1359
+ export async function provisionAuthStack(scope, deps, target, input) {
1360
+ const { hostname, apex, gitRef, ownerCompany, network } = input;
1245
1361
  const owners = ownerCompany?.owners ?? [];
1246
- const hostname = platform.auth.hostname;
1247
- const gitRef = platform.auth.gitRef;
1248
- const recorded = context.recordedIds();
1362
+ const recorded = scope.recordedIds();
1249
1363
  const runtime = deps.runtime ?? {};
1250
1364
  const sleep = runtime.sleep ?? defaultSleep;
1365
+ const label = target.kind === 'platform' ? 'platform:auth' : `${target.id}:auth`;
1366
+ const subject = target.kind === 'platform' ? 'the platform' : target.id;
1367
+ const issuer = `https://${hostname}`;
1368
+ const instanceZitadel = () => {
1369
+ const forInstance = target.refs.instanceBearer === PLATFORM_AUTH_INSTANCE_BEARER_REF
1370
+ ? deps.zitadelForInstance?.(issuer)
1371
+ : deps.zitadelForInstance?.(issuer, target.refs.instanceBearer);
1372
+ if (forInstance)
1373
+ return forInstance;
1374
+ if (target.kind === 'org') {
1375
+ throw new Error(`own step coolify:own-auth for ${target.id}: the host supplies no ZITADEL port for the company's own instance`);
1376
+ }
1377
+ return deps.zitadel;
1378
+ };
1251
1379
  // Records an id and writes it out now, so a crash before the step ends
1252
1380
  // cannot lose a resource a provider already created.
1253
1381
  const record = async (key, id) => {
1254
- context.recordScopeId(key, id);
1255
- await context.persist?.();
1382
+ scope.recordScopeId(key, id);
1383
+ await scope.persist();
1256
1384
  };
1257
- let projectUuid = recorded[PLATFORM_AUTH_PROJECT_SCOPE_KEY];
1258
- if (!projectUuid) {
1259
- const found = await deps.coolify.findProjectByName(PLATFORM_AUTH_PROJECT);
1260
- projectUuid = found
1261
- ? found.uuid
1262
- : (await deps.coolify.createProject(PLATFORM_AUTH_PROJECT)).uuid;
1263
- await record(PLATFORM_AUTH_PROJECT_SCOPE_KEY, projectUuid);
1264
- }
1385
+ const projectUuid = await input.project.ensure(deps.coolify, scope);
1265
1386
  const { environment } = await deps.coolify.findOrCreateEnvironment(projectUuid, 'production');
1266
1387
  // ---- database ----------------------------------------------------------
1267
1388
  let dbId = recorded[PLATFORM_AUTH_DB_SCOPE_KEY];
@@ -1277,7 +1398,7 @@ async function provisionPlatformAuth(context, deps, ownerCompany = null) {
1277
1398
  server_uuid: server.uuid,
1278
1399
  environment_name: environment.name,
1279
1400
  environment_uuid: environment.uuid,
1280
- name: PLATFORM_AUTH_DB_NAME,
1401
+ name: target.names.authDb,
1281
1402
  postgres_db: 'zitadel',
1282
1403
  postgres_user: 'zitadel',
1283
1404
  });
@@ -1290,22 +1411,19 @@ async function provisionPlatformAuth(context, deps, ownerCompany = null) {
1290
1411
  // `coolifyCalls` follows the same rule for its own databases): Coolify
1291
1412
  // can take a moment to report a freshly created database's
1292
1413
  // `internal_db_url`, so the next apply retries from a clean state.
1293
- throw new Error('coolify:app for the platform (auth): the database has no internal_db_url yet -- retry this apply once Coolify reports one');
1414
+ throw new Error(`coolify:app for ${subject} (auth): the database has no internal_db_url yet -- retry this apply once Coolify reports one`);
1294
1415
  }
1295
1416
  // ---- the ZITADEL app itself ---------------------------------------------
1296
- const zitadelApp = await findOrCreatePlatformApp(deps.coolify, PLATFORM_AUTH_ZITADEL_APP_NAME, environment, async () => {
1417
+ const zitadelApp = await findOrCreatePlatformApp(deps.coolify, target.names.authZitadel, environment, async () => {
1297
1418
  const server = await resolvePlatformServer(deps.coolify);
1298
- return deps.coolify.createDockerImageApplication({
1419
+ return deps.coolify.createDockerfileApplication({
1299
1420
  project_uuid: projectUuid,
1300
1421
  server_uuid: server.uuid,
1301
1422
  environment_name: environment.name,
1302
1423
  environment_uuid: environment.uuid,
1303
- docker_registry_image_name: AUTH_ZITADEL_IMAGE,
1304
- docker_registry_image_tag: AUTH_ZITADEL_IMAGE_TAG,
1305
- ports_exposes: AUTH_ZITADEL_PORT,
1306
- custom_docker_run_options: AUTH_ZITADEL_RUN_OPTIONS,
1307
- name: PLATFORM_AUTH_ZITADEL_APP_NAME,
1308
- description: `ZITADEL for the platform's own sign-in (#168, ${AUTH_ZITADEL_IMAGE}:${AUTH_ZITADEL_IMAGE_TAG})`,
1424
+ dockerfile: zitadelDockerfile(),
1425
+ name: target.names.authZitadel,
1426
+ description: `ZITADEL for ${target.kind === 'platform' ? "the platform's" : `${target.id}'s`} own sign-in (#168, ${AUTH_ZITADEL_IMAGE}:${AUTH_ZITADEL_IMAGE_TAG})`,
1309
1427
  instant_deploy: false,
1310
1428
  });
1311
1429
  });
@@ -1315,14 +1433,21 @@ async function provisionPlatformAuth(context, deps, ownerCompany = null) {
1315
1433
  // create must not leave the app unnamed for good, and the tunnel addresses
1316
1434
  // the app by this name. (The port cannot read the current name back, and
1317
1435
  // setting the same value again is harmless.)
1318
- // The run options go in the same patch, so an app made before they existed
1319
- // gets them too.
1320
- await deps.coolify.updateApplication(zitadelAppId, {
1321
- custom_internal_name: PLATFORM_AUTH_ZITADEL_INTERNAL_NAME,
1322
- custom_docker_run_options: AUTH_ZITADEL_RUN_OPTIONS,
1323
- });
1324
1436
  // The container only takes new run options at its next deploy.
1325
1437
  const zitadelRunOptionsNew = recorded[PLATFORM_AUTH_ZITADEL_RUN_OPTIONS_SCOPE_KEY] !== AUTH_ZITADEL_RUN_OPTIONS;
1438
+ // A new app is a Dockerfile app that starts with `zitadelDockerfile()`'s CMD, so it gets no run options.
1439
+ // An app made before that is an image app, found by name and never replaced: it keeps its run options (sent
1440
+ // here until the deploy that applied them is recorded), has no Docker-level check, and takes none until its
1441
+ // organisation is provisioned again from zero. The Coolify check stays off on both: the Dockerfile's
1442
+ // HEALTHCHECK is the live one, and Coolify's rolling update follows it.
1443
+ await deps.coolify.updateApplication(zitadelAppId, {
1444
+ custom_internal_name: target.internal.authZitadel,
1445
+ ports_exposes: AUTH_ZITADEL_PORT,
1446
+ health_check_enabled: false,
1447
+ ...(zitadelRunOptionsNew && !zitadelApp.justCreated
1448
+ ? { custom_docker_run_options: AUTH_ZITADEL_RUN_OPTIONS }
1449
+ : {}),
1450
+ });
1326
1451
  // #251: only on create, never unconditionally -- nothing here patches
1327
1452
  // `docker_registry_image_tag` on an app `findOrCreatePlatformApp` found
1328
1453
  // already running, so that is the only moment this tag is actually live.
@@ -1333,7 +1458,7 @@ async function provisionPlatformAuth(context, deps, ownerCompany = null) {
1333
1458
  await record(PLATFORM_AUTH_ZITADEL_APPLIED_IMAGE_TAG_SCOPE_KEY, AUTH_ZITADEL_IMAGE_TAG);
1334
1459
  }
1335
1460
  // ---- the zitadel-login app -----------------------------------------------
1336
- const loginApp = await findOrCreatePlatformApp(deps.coolify, PLATFORM_AUTH_LOGIN_APP_NAME, environment, async () => {
1461
+ const loginApp = await findOrCreatePlatformApp(deps.coolify, target.names.authLogin, environment, async () => {
1337
1462
  const server = await resolvePlatformServer(deps.coolify);
1338
1463
  const githubApp = await deps.coolify.requireGithubApp();
1339
1464
  return deps.coolify.createGithubAppApplication({
@@ -1345,17 +1470,19 @@ async function provisionPlatformAuth(context, deps, ownerCompany = null) {
1345
1470
  git_repository: AUTH_REPO,
1346
1471
  git_branch: gitRef,
1347
1472
  build_pack: 'dockerfile',
1348
- name: PLATFORM_AUTH_LOGIN_APP_NAME,
1473
+ name: target.names.authLogin,
1349
1474
  dockerfile_location: AUTH_LOGIN_DOCKERFILE,
1350
1475
  });
1351
1476
  });
1352
1477
  const loginAppId = loginApp.appId;
1353
1478
  await record(PLATFORM_AUTH_LOGIN_SCOPE_KEY, loginAppId);
1479
+ // The Dockerfile's own HEALTHCHECK is the live check (`IMAGE_HEALTHCHECK_SETTINGS`).
1354
1480
  await deps.coolify.updateApplication(loginAppId, {
1355
- custom_internal_name: PLATFORM_AUTH_LOGIN_INTERNAL_NAME,
1481
+ custom_internal_name: target.internal.authLogin,
1482
+ ...IMAGE_HEALTHCHECK_SETTINGS,
1356
1483
  });
1357
1484
  // ---- the broker app (#204) -------------------------------------------------
1358
- const brokerApp = await findOrCreatePlatformApp(deps.coolify, PLATFORM_AUTH_BROKER_APP_NAME, environment, async () => {
1485
+ const brokerApp = await findOrCreatePlatformApp(deps.coolify, target.names.authBroker, environment, async () => {
1359
1486
  const server = await resolvePlatformServer(deps.coolify);
1360
1487
  const githubApp = await deps.coolify.requireGithubApp();
1361
1488
  return deps.coolify.createGithubAppApplication({
@@ -1367,21 +1494,24 @@ async function provisionPlatformAuth(context, deps, ownerCompany = null) {
1367
1494
  git_repository: AUTH_REPO,
1368
1495
  git_branch: gitRef,
1369
1496
  build_pack: 'dockerfile',
1370
- name: PLATFORM_AUTH_BROKER_APP_NAME,
1497
+ name: target.names.authBroker,
1371
1498
  dockerfile_location: AUTH_BROKER_DOCKERFILE,
1372
1499
  });
1373
1500
  });
1374
1501
  const brokerAppId = brokerApp.appId;
1375
1502
  await record(PLATFORM_AUTH_BROKER_SCOPE_KEY, brokerAppId);
1376
1503
  await deps.coolify.updateApplication(brokerAppId, {
1377
- custom_internal_name: PLATFORM_AUTH_BROKER_INTERNAL_NAME,
1504
+ custom_internal_name: target.internal.authBroker,
1505
+ ...IMAGE_HEALTHCHECK_SETTINGS,
1378
1506
  });
1379
1507
  // ---- env -----------------------------------------------------------------
1380
1508
  // Generated once, handed straight to `bulkUpsertEnvs` and nowhere else --
1381
1509
  // never returned, recorded, logged or interpolated into a thrown error, the
1382
1510
  // same rule `executor.ts`'s `ensureAuthCookieSecret` follows for a value
1383
1511
  // this package mints itself rather than receiving from a provider.
1384
- const masterkey = await ensureMasterkey(deps.coolify, zitadelAppId, databaseInUse);
1512
+ const masterkey = await ensureMasterkey(deps.coolify, zitadelAppId, databaseInUse, label);
1513
+ if (masterkey)
1514
+ runtime.onSecret?.(masterkey);
1385
1515
  // #283: the instance is created once, with the system API, by a platform
1386
1516
  // whose admin machine user is not recorded yet (one that already has it was
1387
1517
  // built from a first instance and keeps it). The keypair is regenerated on
@@ -1391,7 +1521,7 @@ async function provisionPlatformAuth(context, deps, ownerCompany = null) {
1391
1521
  if (lostInstance &&
1392
1522
  !recorded[PLATFORM_AUTH_INSTANCE_SCOPE_KEY] &&
1393
1523
  !recorded[PLATFORM_AUTH_ADMIN_USER_SCOPE_KEY]) {
1394
- throw new Error(instanceWithoutBearerMessage(hostname, `instance ${lostInstance}`));
1524
+ throw new Error(instanceWithoutBearerMessage(hostname, `instance ${lostInstance}`, label));
1395
1525
  }
1396
1526
  const needsInstance = !recorded[PLATFORM_AUTH_ADMIN_USER_SCOPE_KEY] && !recorded[PLATFORM_AUTH_INSTANCE_SCOPE_KEY];
1397
1527
  const systemKeys = needsInstance ? generateSystemApiKeypair() : undefined;
@@ -1404,6 +1534,10 @@ async function provisionPlatformAuth(context, deps, ownerCompany = null) {
1404
1534
  { key: 'ZITADEL_DATABASE_POSTGRES_DSN', value: db.internal_db_url },
1405
1535
  { key: 'ZITADEL_FIRSTINSTANCE_SKIP', value: 'true' },
1406
1536
  { key: 'ZITADEL_DEFAULTINSTANCE_FEATURES_LOGINV2_REQUIRED', value: 'true' },
1537
+ // A username is unique per organisation, and the login name is `name@<org domain>`. Without it a
1538
+ // name is unique across the instance, so the owner's account in Archon's own organisation
1539
+ // collides with the same owner's account in the company's organisation.
1540
+ { key: 'ZITADEL_DEFAULTINSTANCE_DOMAINPOLICY_USERLOGINMUSTBEDOMAIN', value: 'true' },
1407
1541
  {
1408
1542
  key: 'ZITADEL_DEFAULTINSTANCE_FEATURES_LOGINV2_BASEURI',
1409
1543
  value: `https://${hostname}/`,
@@ -1421,6 +1555,10 @@ async function provisionPlatformAuth(context, deps, ownerCompany = null) {
1421
1555
  value: `https://${hostname}/login?samlRequest=`,
1422
1556
  },
1423
1557
  { key: 'ZITADEL_LOGSTORE_ACCESS_STDOUT_ENABLED', value: 'true' },
1558
+ // ZITADEL 4.17.3 logs every mail's template data at warning level ("missing
1559
+ // translation" for `<Type>.Footer`), which holds the one-time invite code.
1560
+ // Errors (a failed SMTP send among them) are still written.
1561
+ { key: 'ZITADEL_LOG_LEVEL', value: 'error' },
1424
1562
  ];
1425
1563
  if (masterkey)
1426
1564
  zitadelEnvs.push({ key: 'ZITADEL_MASTERKEY', value: masterkey });
@@ -1433,7 +1571,7 @@ async function provisionPlatformAuth(context, deps, ownerCompany = null) {
1433
1571
  const loginEnvs = [
1434
1572
  {
1435
1573
  key: 'ZITADEL_API_URL',
1436
- value: `http://${PLATFORM_AUTH_ZITADEL_INTERNAL_NAME}:${AUTH_ZITADEL_PORT}`,
1574
+ value: `http://${target.internal.authZitadel}:${AUTH_ZITADEL_PORT}`,
1437
1575
  },
1438
1576
  {
1439
1577
  key: 'CUSTOM_REQUEST_HEADERS',
@@ -1447,8 +1585,8 @@ async function provisionPlatformAuth(context, deps, ownerCompany = null) {
1447
1585
  // invite's own path (set a password, then nowhere to go). Only set once
1448
1586
  // Archon is declared, and only ever to Archon: the platform's one landing
1449
1587
  // app for anyone who reaches the login app outside an app's own sign-in.
1450
- ...(platform.archon.enabled
1451
- ? [{ key: 'DEFAULT_REDIRECT_URI', value: `https://${platform.archon.hostname}` }]
1588
+ ...(input.defaultRedirectUri
1589
+ ? [{ key: 'DEFAULT_REDIRECT_URI', value: input.defaultRedirectUri }]
1452
1590
  : []),
1453
1591
  ];
1454
1592
  // #210: read back now, not inside the deploy-decision block below -- this
@@ -1456,7 +1594,9 @@ async function provisionPlatformAuth(context, deps, ownerCompany = null) {
1456
1594
  // the same way `envsDiffer`/`bulkUpsertEnvs` below already do for every
1457
1595
  // other broker env, so a single apply that finds the key already held can
1458
1596
  // also see `LETTERMINT_API_TOKEN` as met and deploy in that same run.
1459
- const heldMailKey = await deps.heldKeys?.resolve(PLATFORM_AUTH_BROKER_MAIL_KEY_REF);
1597
+ const heldMailKey = await deps.heldKeys?.resolve(target.refs.brokerMailKey);
1598
+ if (heldMailKey)
1599
+ runtime.onSecret?.(heldMailKey);
1460
1600
  // `AUTH_APPS` and `ZITADEL_SERVICE_USER_TOKEN`/`_FILE` are not pushed here
1461
1601
  // -- this function's own doc comment, "Known gaps". `LETTERMINT_API_TOKEN`
1462
1602
  // is, once held (#210): pushing it costs nothing even while the broker
@@ -1465,7 +1605,7 @@ async function provisionPlatformAuth(context, deps, ownerCompany = null) {
1465
1605
  const brokerEnvs = [
1466
1606
  {
1467
1607
  key: 'ZITADEL_API_URL',
1468
- value: `http://${PLATFORM_AUTH_ZITADEL_INTERNAL_NAME}:${AUTH_ZITADEL_PORT}`,
1608
+ value: `http://${target.internal.authZitadel}:${AUTH_ZITADEL_PORT}`,
1469
1609
  },
1470
1610
  { key: 'ZITADEL_DOMAIN', value: hostname },
1471
1611
  ...(heldMailKey ? [{ key: 'LETTERMINT_API_TOKEN', value: heldMailKey }] : []),
@@ -1517,25 +1657,25 @@ async function provisionPlatformAuth(context, deps, ownerCompany = null) {
1517
1657
  await record(PLATFORM_AUTH_BROKER_DEPLOYED_SCOPE_KEY, brokerDeployRecordValue(brokerAppId, authAppsRaw));
1518
1658
  }
1519
1659
  // ---- tunnel and DNS ------------------------------------------------------
1520
- const tunnels = await platformTunnels(deps, context.recordedIds(), 'platform:auth');
1521
- const existingTunnel = await tunnels.findTunnelByName(PLATFORM_AUTH_TUNNEL_NAME);
1522
- const tunnel = existingTunnel ?? (await tunnels.createTunnel(PLATFORM_AUTH_TUNNEL_NAME));
1660
+ const tunnels = await network.tunnels();
1661
+ const existingTunnel = await tunnels.findTunnelByName(target.names.authTunnel);
1662
+ const tunnel = existingTunnel ?? (await tunnels.createTunnel(target.names.authTunnel));
1523
1663
  await record(PLATFORM_AUTH_TUNNEL_SCOPE_KEY, tunnel.id);
1524
1664
  await tunnels.putTunnelConfig(tunnel.id, {
1525
1665
  ingress: [
1526
1666
  {
1527
1667
  hostname,
1528
1668
  path: AUTH_ZITADEL_PATH_PATTERN,
1529
- service: `http://${PLATFORM_AUTH_ZITADEL_INTERNAL_NAME}:${AUTH_ZITADEL_PORT}`,
1669
+ service: `http://${target.internal.authZitadel}:${AUTH_ZITADEL_PORT}`,
1530
1670
  },
1531
1671
  {
1532
1672
  hostname,
1533
1673
  path: AUTH_BROKER_PATH_PATTERN,
1534
- service: `http://${PLATFORM_AUTH_BROKER_INTERNAL_NAME}:${AUTH_BROKER_PORT}`,
1674
+ service: `http://${target.internal.authBroker}:${AUTH_BROKER_PORT}`,
1535
1675
  },
1536
1676
  {
1537
1677
  hostname,
1538
- service: `http://${PLATFORM_AUTH_LOGIN_INTERNAL_NAME}:${AUTH_LOGIN_PORT}`,
1678
+ service: `http://${target.internal.authLogin}:${AUTH_LOGIN_PORT}`,
1539
1679
  },
1540
1680
  { service: 'http_status:404' },
1541
1681
  ],
@@ -1548,7 +1688,7 @@ async function provisionPlatformAuth(context, deps, ownerCompany = null) {
1548
1688
  coolify: deps.coolify,
1549
1689
  tunnels,
1550
1690
  tunnelId: tunnel.id,
1551
- appName: PLATFORM_AUTH_CLOUDFLARED_APP_NAME,
1691
+ appName: target.names.authCloudflared,
1552
1692
  projectUuid,
1553
1693
  environment,
1554
1694
  resolveServer: () => resolvePlatformServer(deps.coolify),
@@ -1557,15 +1697,15 @@ async function provisionPlatformAuth(context, deps, ownerCompany = null) {
1557
1697
  });
1558
1698
  // The apex zone step records the zone id before this step runs, but it
1559
1699
  // creates no record for the hostname, so it is created here.
1560
- const zoneId = recorded[apexZoneKey(platform.apex)] ?? (await exactZone(deps.cloudflare, platform.apex))?.id;
1700
+ const zoneId = await network.zoneId(apex);
1561
1701
  if (!zoneId) {
1562
- throw new Error(`platform:auth: no Cloudflare zone for ${platform.apex} to put ${hostname} in`);
1702
+ throw new Error(`${label}: no Cloudflare zone for ${apex} to put ${hostname} in`);
1563
1703
  }
1564
1704
  const dnsTarget = `${tunnel.id}.cfargotunnel.com`;
1565
- const dns = await platformDns(deps, context.recordedIds(), 'platform:auth');
1566
- const [dnsRecord] = await dns.findDnsRecord(platform.apex, hostname, 'CNAME');
1705
+ const dns = await network.dns();
1706
+ const [dnsRecord] = await dns.findDnsRecord(apex, hostname, 'CNAME');
1567
1707
  if (!dnsRecord) {
1568
- await dns.createDnsRecord(platform.apex, {
1708
+ await dns.createDnsRecord(apex, {
1569
1709
  type: 'CNAME',
1570
1710
  name: hostname,
1571
1711
  content: dnsTarget,
@@ -1573,25 +1713,36 @@ async function provisionPlatformAuth(context, deps, ownerCompany = null) {
1573
1713
  });
1574
1714
  }
1575
1715
  else if (dnsRecord.content !== dnsTarget) {
1576
- await dns.updateDnsRecord(platform.apex, dnsRecord.id, {
1716
+ await dns.updateDnsRecord(apex, dnsRecord.id, {
1577
1717
  type: 'CNAME',
1578
1718
  name: hostname,
1579
1719
  content: dnsTarget,
1580
1720
  proxied: true,
1581
1721
  });
1582
1722
  }
1723
+ if (target.kind === 'org')
1724
+ await recordOwnDnsHostname(scope, hostname);
1583
1725
  // ---- foundry's own admin machine user, once ------------------------------
1584
1726
  if (!recorded[PLATFORM_AUTH_ADMIN_USER_SCOPE_KEY]) {
1585
1727
  const lost = recorded[PLATFORM_AUTH_ADMIN_UNDELIVERED_SCOPE_KEY];
1586
1728
  if (lost) {
1587
1729
  // The planner reports this and plans nothing; this is the same rule for
1588
1730
  // a caller that reaches the step anyway.
1589
- throw new Error(`platform:auth: the admin key of ZITADEL machine user ${lost} was minted but never delivered; delete that user and the recorded key ${PLATFORM_AUTH_ADMIN_UNDELIVERED_SCOPE_KEY} to mint a new one`);
1731
+ throw new Error(`${label}: the admin key of ZITADEL machine user ${lost} was minted but never delivered; delete that user and the recorded key ${PLATFORM_AUTH_ADMIN_UNDELIVERED_SCOPE_KEY} to mint a new one`);
1590
1732
  }
1591
1733
  if (systemKeys) {
1592
1734
  // ZITADEL answers /debug/healthz without an instance; the openid
1593
1735
  // document below only exists once the instance does.
1594
- await waitForInstance(`https://${hostname}/debug/healthz`, runtime, sleep);
1736
+ await waitForInstance(`https://${hostname}/debug/healthz`, runtime, sleep, label);
1737
+ // Healthy is not ready for the system API: its gateway answers 503 for a moment after the server listens.
1738
+ await waitForSystemApi({ customDomain: hostname, privateKey: systemKeys.privateKey, label }, {
1739
+ fetch: runtime.fetch,
1740
+ now: runtime.now ?? Date.now,
1741
+ sleep,
1742
+ timeoutMs: runtime.readyTimeoutMs,
1743
+ intervalMs: runtime.readyIntervalMs,
1744
+ requestTimeoutMs: runtime.readyRequestTimeoutMs,
1745
+ });
1595
1746
  // Sent exactly once. Anything that may have reached ZITADEL leaves a
1596
1747
  // marker first, so the next run refuses instead of creating again.
1597
1748
  let created;
@@ -1601,6 +1752,7 @@ async function provisionPlatformAuth(context, deps, ownerCompany = null) {
1601
1752
  privateKey: systemKeys.privateKey,
1602
1753
  instanceName: PLATFORM_AUTH_ORG_NAME,
1603
1754
  firstOrgName: PLATFORM_AUTH_ORG_NAME,
1755
+ label,
1604
1756
  owner: {
1605
1757
  userName: PLATFORM_AUTH_BEARER_USER_NAME,
1606
1758
  name: 'Foundry (instance owner)',
@@ -1614,12 +1766,13 @@ async function provisionPlatformAuth(context, deps, ownerCompany = null) {
1614
1766
  throw error;
1615
1767
  }
1616
1768
  const { instanceId, pat } = created;
1769
+ runtime.onSecret?.(pat);
1617
1770
  // Recorded before the PAT leaves this function, like the admin key below.
1618
1771
  await record(PLATFORM_AUTH_INSTANCE_UNDELIVERED_SCOPE_KEY, instanceId);
1619
1772
  const attempts = runtime.deliverAttempts ?? 3;
1620
1773
  for (let attempt = 1;; attempt += 1) {
1621
1774
  try {
1622
- await deps.deliverSecret(PLATFORM_AUTH_INSTANCE_BEARER_REF, pat);
1775
+ await deps.deliverSecret(target.refs.instanceBearer, pat);
1623
1776
  break;
1624
1777
  }
1625
1778
  catch (error) {
@@ -1627,7 +1780,7 @@ async function provisionPlatformAuth(context, deps, ownerCompany = null) {
1627
1780
  const reason = redactSecrets(error instanceof Error ? error.message : String(error), [
1628
1781
  pat,
1629
1782
  ]);
1630
- throw new Error(`platform:auth: the owner token of ZITADEL instance ${instanceId} was created but could not be delivered after ${attempts} tries (${reason}); ${instanceWithoutBearerMessage(hostname, `instance ${instanceId}`)}`);
1783
+ throw new Error(`${label}: the owner token of ZITADEL instance ${instanceId} was created but could not be delivered after ${attempts} tries (${reason}); ${instanceWithoutBearerMessage(hostname, `instance ${instanceId}`, label)}`);
1631
1784
  }
1632
1785
  await sleep(1000 * attempt);
1633
1786
  }
@@ -1637,8 +1790,8 @@ async function provisionPlatformAuth(context, deps, ownerCompany = null) {
1637
1790
  }
1638
1791
  // Minting needs a running instance behind the tunnel: the first apply
1639
1792
  // gets here right after the deploy.
1640
- await waitForInstance(`https://${hostname}/.well-known/openid-configuration`, runtime, sleep);
1641
- const zitadel = deps.zitadelForInstance?.(`https://${hostname}`) ?? deps.zitadel;
1793
+ await waitForInstance(`https://${hostname}/.well-known/openid-configuration`, runtime, sleep, label);
1794
+ const zitadel = instanceZitadel();
1642
1795
  const { org } = await zitadel.findOrCreateOrg(PLATFORM_AUTH_ORG_NAME);
1643
1796
  // #216 round 5: recorded here too (mirroring `provisionPlatformArchon`),
1644
1797
  // so `authLoginClientDrifted` (below) never needs its own ZITADEL call
@@ -1646,10 +1799,21 @@ async function provisionPlatformAuth(context, deps, ownerCompany = null) {
1646
1799
  if (recorded[PLATFORM_ZITADEL_ORG_SCOPE_KEY] !== org.id) {
1647
1800
  await record(PLATFORM_ZITADEL_ORG_SCOPE_KEY, org.id);
1648
1801
  }
1802
+ // The new instance starts with open self-registration and its host is
1803
+ // already public: close it in the people organisation before anything
1804
+ // else runs. The sign-in policy step records the close and repeats it;
1805
+ // closing twice changes nothing.
1806
+ try {
1807
+ await zitadel.closeSelfRegistration(org.id);
1808
+ }
1809
+ catch {
1810
+ throw new Error(`${label}: closing self-registration failed in ZITADEL org ${org.id}`);
1811
+ }
1649
1812
  const { userId, token } = await zitadel.mintAdminMachineUser(org.id, {
1650
1813
  userName: PLATFORM_AUTH_ADMIN_USER_NAME,
1651
1814
  name: 'Foundry (platform admin)',
1652
1815
  });
1816
+ runtime.onSecret?.(token);
1653
1817
  // Recorded and persisted before the token leaves this function: if
1654
1818
  // delivery fails for good, the next plan reports the lost key rather than
1655
1819
  // skipping or minting again. Only a delivered key records the guard.
@@ -1661,12 +1825,12 @@ async function provisionPlatformAuth(context, deps, ownerCompany = null) {
1661
1825
  const attempts = runtime.deliverAttempts ?? 3;
1662
1826
  for (let attempt = 1;; attempt += 1) {
1663
1827
  try {
1664
- await deps.deliverSecret('zitadel:platform:admin', token);
1828
+ await deps.deliverSecret(target.refs.authAdmin, token);
1665
1829
  break;
1666
1830
  }
1667
1831
  catch {
1668
1832
  if (attempt >= attempts) {
1669
- throw new Error(`platform:auth: the admin key of ZITADEL machine user ${userId} was minted but could not be delivered after ${attempts} tries`);
1833
+ throw new Error(`${label}: the admin key of ZITADEL machine user ${userId} was minted but could not be delivered after ${attempts} tries`);
1670
1834
  }
1671
1835
  await sleep(1000 * attempt);
1672
1836
  }
@@ -1731,11 +1895,19 @@ async function provisionPlatformAuth(context, deps, ownerCompany = null) {
1731
1895
  // make the same call, not just trust that a record exists, or a drifted
1732
1896
  // platform would be planned again forever without ever actually healing.
1733
1897
  const loginClientOtherInputsMissing = brokerMissingInputs.filter((m) => m !== 'ZITADEL_SERVICE_USER_TOKEN');
1734
- const freshRecordedIds = context.recordedIds();
1898
+ const freshRecordedIds = scope.recordedIds();
1735
1899
  const freshLoginClientRecord = parseLoginClientRecord(freshRecordedIds[PLATFORM_AUTH_LOGIN_CLIENT_USER_SCOPE_KEY]);
1736
- const loginClientNeedsMint = !freshLoginClientRecord || (await authLoginClientDrifted(platform, deps, freshRecordedIds));
1900
+ const loginClientNeedsMint = !freshLoginClientRecord ||
1901
+ (await authLoginClientDriftedAt({ hostname, bearerRef: target.refs.instanceBearer }, deps, freshRecordedIds));
1737
1902
  if (loginClientNeedsMint && loginClientOtherInputsMissing.length === 0) {
1738
- const loginClientZitadel = deps.zitadelForInstance?.(`https://${hostname}`) ?? deps.zitadel;
1903
+ // An own org never mints a second time on top of a token that may be live:
1904
+ // the platform's re-mint revokes the old one, which would take the org's
1905
+ // sign-in down if the first delivery had in fact landed.
1906
+ const loginClientLost = freshRecordedIds[PLATFORM_AUTH_LOGIN_CLIENT_UNDELIVERED_SCOPE_KEY];
1907
+ if (target.kind === 'org' && loginClientLost && !freshLoginClientRecord) {
1908
+ throw new Error(`${label}: the login-client token of ZITADEL machine user ${loginClientLost} was minted but never confirmed delivered; remove the recorded key ${PLATFORM_AUTH_LOGIN_CLIENT_UNDELIVERED_SCOPE_KEY} to mint a new one`);
1909
+ }
1910
+ const loginClientZitadel = instanceZitadel();
1739
1911
  const { org: loginClientOrg } = await loginClientZitadel.findOrCreateOrg(PLATFORM_AUTH_ORG_NAME);
1740
1912
  // #216 round 5: backfills the org id for a platform whose admin block
1741
1913
  // (above) ran before this recording existed and has not run since --
@@ -1747,6 +1919,7 @@ async function provisionPlatformAuth(context, deps, ownerCompany = null) {
1747
1919
  userName: PLATFORM_AUTH_LOGIN_CLIENT_USER_NAME,
1748
1920
  name: 'Foundry (platform login client)',
1749
1921
  });
1922
+ runtime.onSecret?.(loginClientToken);
1750
1923
  // Recorded and persisted before the token leaves this function -- the
1751
1924
  // same "one mint" rule the admin block above follows. Never read back to
1752
1925
  // block planning; `planAuth` (`platform-diff.ts`) reads it only to
@@ -1788,8 +1961,8 @@ async function provisionPlatformAuth(context, deps, ownerCompany = null) {
1788
1961
  // saying "its prior token was already revoked" there would describe a
1789
1962
  // revoke that never happened, so the message only says so when it did.
1790
1963
  throw new Error(revokedPriorPat
1791
- ? `platform:auth: the login-client token of ZITADEL machine user ${loginClientUserId} could not be delivered after ${deliverAttempts} tries; its prior token was already revoked, so the login app and broker are down until a later apply mints and delivers a fresh one`
1792
- : `platform:auth: the login-client token of ZITADEL machine user ${loginClientUserId} could not be delivered after ${deliverAttempts} tries; this is its first token, so the login app and broker still have none until a later apply mints and delivers one`);
1964
+ ? `${label}: the login-client token of ZITADEL machine user ${loginClientUserId} could not be delivered after ${deliverAttempts} tries; its prior token was already revoked, so the login app and broker are down until a later apply mints and delivers a fresh one`
1965
+ : `${label}: the login-client token of ZITADEL machine user ${loginClientUserId} could not be delivered after ${deliverAttempts} tries; this is its first token, so the login app and broker still have none until a later apply mints and delivers one`);
1793
1966
  }
1794
1967
  // #216 round 4/5: records what was actually delivered -- the user id,
1795
1968
  // the exact PAT id, and (round 5) a hash of the exact token -- so the
@@ -1802,7 +1975,7 @@ async function provisionPlatformAuth(context, deps, ownerCompany = null) {
1802
1975
  // ---- the owner company's own sign-in org ----------------------------------
1803
1976
  // Archon signs people in from this org; the `foundry` org holds none. The
1804
1977
  // company executor finds the same org by name (its slug) and makes no second.
1805
- const ownerOrgZitadel = deps.zitadelForInstance?.(`https://${hostname}`) ?? deps.zitadel;
1978
+ const ownerOrgZitadel = instanceZitadel();
1806
1979
  let ownerSignInOrgId;
1807
1980
  if (ownerCompany) {
1808
1981
  ({ orgId: ownerSignInOrgId } = await ensureSignInOrg({
@@ -1810,9 +1983,9 @@ async function provisionPlatformAuth(context, deps, ownerCompany = null) {
1810
1983
  zitadel: ownerOrgZitadel,
1811
1984
  name: ownerCompany.slug,
1812
1985
  domain: signInOrgDomain(ownerCompany),
1813
- recordScopeId: (key, id) => context.recordScopeId(key, id),
1986
+ recordScopeId: (key, id) => scope.recordScopeId(key, id),
1814
1987
  persistScopeIds: async () => {
1815
- await context.persist?.();
1988
+ await scope.persist();
1816
1989
  },
1817
1990
  orgScopeKey: PLATFORM_OWNER_ORG_SCOPE_KEY,
1818
1991
  domainScopeKey: PLATFORM_OWNER_ORG_DOMAIN_SCOPE_KEY,
@@ -1827,19 +2000,22 @@ async function provisionPlatformAuth(context, deps, ownerCompany = null) {
1827
2000
  const mailToken = heldMailKey || (await currentEnvValue(deps.coolify, brokerAppId, 'LETTERMINT_API_TOKEN'));
1828
2001
  const smtpTarget = resolveAuthSmtp(deps.smtp);
1829
2002
  if (mailToken) {
1830
- const mailZitadel = deps.zitadelForInstance?.(`https://${hostname}`) ?? deps.zitadel;
2003
+ const mailZitadel = instanceZitadel();
1831
2004
  let smtpPassword;
1832
2005
  try {
1833
2006
  // Runs every time the step does (idempotent), so a rotated token lands.
1834
2007
  smtpPassword = (await deps.smtp?.password?.()) ?? mailToken;
2008
+ runtime.onSecret?.(smtpPassword);
1835
2009
  const smtp = await mailZitadel.ensureSmtpProvider({
1836
2010
  host: smtpTarget.host,
1837
2011
  user: smtpTarget.user,
1838
2012
  password: smtpPassword,
1839
- senderAddress: `noreply@${platform.apex}`,
2013
+ senderAddress: input.smtpSender,
1840
2014
  senderName: PLATFORM_AUTH_MAIL_SENDER_NAME,
1841
2015
  tls: smtpTarget.tls,
1842
- description: PLATFORM_AUTH_SMTP_DESCRIPTION,
2016
+ description: target.kind === 'platform'
2017
+ ? PLATFORM_AUTH_SMTP_DESCRIPTION
2018
+ : `foundry:org:${target.id}:lettermint`,
1843
2019
  });
1844
2020
  await record(PLATFORM_AUTH_SMTP_SCOPE_KEY, smtp.id);
1845
2021
  }
@@ -1849,10 +2025,10 @@ async function provisionPlatformAuth(context, deps, ownerCompany = null) {
1849
2025
  mailToken,
1850
2026
  ...(smtpPassword ? [smtpPassword] : []),
1851
2027
  ]);
1852
- throw new Error(`platform:auth: could not configure ZITADEL's SMTP provider for ${hostname}: ${cause}`);
2028
+ throw new Error(`${label}: could not configure ZITADEL's SMTP provider for ${hostname}: ${cause}`);
1853
2029
  }
1854
- const inviteRecorded = context.recordedIds()[PLATFORM_AUTH_OWNER_INVITE_SCOPE_KEY];
1855
- const inviteOrgRecorded = context.recordedIds()[PLATFORM_AUTH_OWNER_INVITE_ORG_SCOPE_KEY];
2030
+ const inviteRecorded = scope.recordedIds()[PLATFORM_AUTH_OWNER_INVITE_SCOPE_KEY];
2031
+ const inviteOrgRecorded = scope.recordedIds()[PLATFORM_AUTH_OWNER_INVITE_ORG_SCOPE_KEY];
1856
2032
  // An invite recorded for another org (the `foundry` org, before the owner moved) is made again.
1857
2033
  if (!inviteRecorded || (inviteRecorded !== 'none' && inviteOrgRecorded !== ownerSignInOrgId)) {
1858
2034
  const owner = owners[0];
@@ -1870,14 +2046,14 @@ async function provisionPlatformAuth(context, deps, ownerCompany = null) {
1870
2046
  zitadel: mailZitadel,
1871
2047
  mail: deps.ownerInviteMail,
1872
2048
  orgId: ownerSignInOrgId,
1873
- authIssuer: `https://${hostname}`,
2049
+ authIssuer: issuer,
1874
2050
  owner: {
1875
2051
  givenName: owner.givenName,
1876
2052
  familyName: owner.familyName,
1877
2053
  mailboxAddress,
1878
2054
  personalEmail: owner.email,
1879
2055
  },
1880
- label: 'platform:auth',
2056
+ label,
1881
2057
  ...(inviteRecorded && inviteRecorded !== 'none'
1882
2058
  ? { invitedUserId: inviteRecorded }
1883
2059
  : {}),
@@ -1892,7 +2068,7 @@ async function provisionPlatformAuth(context, deps, ownerCompany = null) {
1892
2068
  mailToken,
1893
2069
  ...(smtpPassword ? [smtpPassword] : []),
1894
2070
  ]);
1895
- throw new Error(`platform:auth: could not invite the platform owner into ZITADEL org ${ownerSignInOrgId}: ${cause}`);
2071
+ throw new Error(`${label}: could not invite the platform owner into ZITADEL org ${ownerSignInOrgId}: ${cause}`);
1896
2072
  }
1897
2073
  // The user id first: a crash between the two replans the invite, never skips it.
1898
2074
  await record(PLATFORM_AUTH_OWNER_INVITE_SCOPE_KEY, invitedUserId);
@@ -1903,6 +2079,137 @@ async function provisionPlatformAuth(context, deps, ownerCompany = null) {
1903
2079
  }
1904
2080
  return { resourceId: zitadelAppId };
1905
2081
  }
2082
+ /** The platform's sign-in project: the shared `platform` project, its id recorded under the sign-in's own scope key. */
2083
+ const platformAuthProject = {
2084
+ async ensure(coolify, scope) {
2085
+ const recorded = scope.recordedIds()[PLATFORM_AUTH_PROJECT_SCOPE_KEY];
2086
+ if (recorded)
2087
+ return recorded;
2088
+ const found = await coolify.findProjectByName(PLATFORM_AUTH_PROJECT);
2089
+ const uuid = found ? found.uuid : (await coolify.createProject(PLATFORM_AUTH_PROJECT)).uuid;
2090
+ scope.recordScopeId(PLATFORM_AUTH_PROJECT_SCOPE_KEY, uuid);
2091
+ await scope.persist();
2092
+ return uuid;
2093
+ },
2094
+ };
2095
+ /** The platform's sign-in: {@link provisionAuthStack} with {@link platformTarget} and the platform's own inputs. */
2096
+ async function provisionPlatformAuth(context, deps, ownerCompany = null) {
2097
+ const { platform } = context;
2098
+ const scope = scopeFromPlatform(context);
2099
+ return provisionAuthStack(scope, deps, platformTarget(), {
2100
+ hostname: platform.auth.hostname,
2101
+ apex: platform.apex,
2102
+ gitRef: platform.auth.gitRef,
2103
+ project: platformAuthProject,
2104
+ network: platformNetwork(deps, scope, 'platform:auth'),
2105
+ smtpSender: `noreply@${platform.apex}`,
2106
+ defaultRedirectUri: platform.archon.enabled ? `https://${platform.archon.hostname}` : null,
2107
+ ownerCompany,
2108
+ });
2109
+ }
2110
+ /**
2111
+ * Archon's own sign-in organisation (`zitadel:archon-org` keyed
2112
+ * {@link PLATFORM_ARCHON_KEY}), in the owner company's ZITADEL and apart from
2113
+ * the organisation that holds the company's people: the org, its primary
2114
+ * domain, closed self-registration, a login policy that forces a second
2115
+ * factor, and the first person's invite (a link mailed to their personal
2116
+ * address). Each call records its key right after it succeeded, so a failure
2117
+ * leaves the earlier keys recorded and the next plan names the step again; a
2118
+ * repeat converges because every call looks before it writes. The errors name
2119
+ * the org only, never the cause: the cause can carry an address or the invite.
2120
+ * The invite code reaches only the mail port (30 second timeout, see
2121
+ * `inviteOwnerWithLink`) and is never recorded or logged.
2122
+ */
2123
+ async function provisionPlatformArchonOrg(context, deps, ownerCompany) {
2124
+ const ownerSignIn = context.owner?.signIn ?? null;
2125
+ if (!ownerSignIn || !ownerCompany) {
2126
+ throw new Error("platform:archon-org: the owner company's sign-in has not been applied yet; Archon's organisation is made in its ZITADEL");
2127
+ }
2128
+ if (!deps.zitadelForInstance) {
2129
+ throw new Error("platform:archon-org: the host built the platform's auth dependencies without zitadelForInstance, so it cannot reach the owner company's ZITADEL");
2130
+ }
2131
+ const zitadel = deps.zitadelForInstance(ownerSignIn.issuerUrl, ownerSignIn.bearerRef);
2132
+ const record = async (key, value) => {
2133
+ context.recordScopeId(key, value);
2134
+ await context.persist?.();
2135
+ };
2136
+ const name = archonOrgName(ownerCompany.slug);
2137
+ let orgId;
2138
+ try {
2139
+ ({ orgId } = await ensureSignInOrg({
2140
+ zitadel,
2141
+ name,
2142
+ domain: archonOrgDomain(signInOrgDomain(ownerCompany)),
2143
+ recordScopeId: (key, id) => context.recordScopeId(key, id),
2144
+ persistScopeIds: async () => {
2145
+ await context.persist?.();
2146
+ },
2147
+ orgScopeKey: PLATFORM_ARCHON_ORG_SCOPE_KEY,
2148
+ domainScopeKey: PLATFORM_ARCHON_ORG_DOMAIN_SCOPE_KEY,
2149
+ domainBlockedScopeKey: PLATFORM_ARCHON_ORG_DOMAIN_BLOCKED_SCOPE_KEY,
2150
+ registrationClosedScopeKey: PLATFORM_ARCHON_ORG_REGISTRATION_CLOSED_SCOPE_KEY,
2151
+ }));
2152
+ }
2153
+ catch {
2154
+ throw new Error(`platform:archon-org: making the sign-in organisation ${name} (its domain and closed registration) failed in ZITADEL`);
2155
+ }
2156
+ await context.persist?.();
2157
+ try {
2158
+ await zitadel.enforceSecondFactor(orgId);
2159
+ }
2160
+ catch {
2161
+ throw new Error(`platform:archon-org: forcing a second factor failed in ZITADEL org ${orgId}`);
2162
+ }
2163
+ await record(PLATFORM_ARCHON_ORG_POLICY_SCOPE_KEY, orgId);
2164
+ const recorded = context.recordedIds();
2165
+ const inviteRecorded = recorded[PLATFORM_ARCHON_OWNER_INVITE_SCOPE_KEY];
2166
+ // An invite made in another org (an earlier Archon org) is made again.
2167
+ if (!inviteRecorded ||
2168
+ (inviteRecorded !== 'none' && recorded[PLATFORM_ARCHON_OWNER_INVITE_ORG_SCOPE_KEY] !== orgId)) {
2169
+ const owner = ownerCompany.owners[0];
2170
+ if (!owner) {
2171
+ await record(PLATFORM_ARCHON_OWNER_INVITE_SCOPE_KEY, 'none');
2172
+ }
2173
+ else {
2174
+ const mailboxAddress = ownerMailboxes(ownerCompany)[0]?.address;
2175
+ // `ownerInviteBlockedReason` reports these two as blocked; the step goes on without the invite.
2176
+ if (mailboxAddress && deps.ownerInviteMail) {
2177
+ let invitedUserId;
2178
+ try {
2179
+ ({ userId: invitedUserId } = await inviteOwnerWithLink({
2180
+ zitadel,
2181
+ mail: deps.ownerInviteMail,
2182
+ orgId,
2183
+ authIssuer: ownerSignIn.issuerUrl,
2184
+ owner: {
2185
+ givenName: owner.givenName,
2186
+ familyName: owner.familyName,
2187
+ mailboxAddress,
2188
+ personalEmail: owner.email,
2189
+ },
2190
+ label: 'platform:archon-org',
2191
+ ...(deps.runtime?.onSecret ? { onSecret: deps.runtime.onSecret } : {}),
2192
+ ...(inviteRecorded && inviteRecorded !== 'none'
2193
+ ? { invitedUserId: inviteRecorded }
2194
+ : {}),
2195
+ }));
2196
+ }
2197
+ catch (error) {
2198
+ const cause = redactSecrets(error instanceof Error ? error.message : String(error), [
2199
+ owner.email,
2200
+ owner.email.toLowerCase(),
2201
+ mailboxAddress,
2202
+ ]);
2203
+ throw new Error(`platform:archon-org: could not invite the first person into ZITADEL org ${orgId}: ${cause}`);
2204
+ }
2205
+ // The user id first: a crash between the two replans the invite, never skips it.
2206
+ await record(PLATFORM_ARCHON_OWNER_INVITE_SCOPE_KEY, invitedUserId);
2207
+ await record(PLATFORM_ARCHON_OWNER_INVITE_ORG_SCOPE_KEY, orgId);
2208
+ }
2209
+ }
2210
+ }
2211
+ return {};
2212
+ }
1906
2213
  /**
1907
2214
  * Reserves the username `admin` and switches self-registration off in the
1908
2215
  * platform's sign-in org (`zitadel:sign-in-policy` keyed {@link PLATFORM_AUTH_KEY}).
@@ -1977,11 +2284,7 @@ const PLATFORM_AUTH_MAIL_SENDER_NAME = 'Company Included';
1977
2284
  // #169: the platform's own mail server (Stalwart)
1978
2285
  // ---------------------------------------------------------------------------
1979
2286
  const PLATFORM_MAIL_PROJECT = 'platform';
1980
- const PLATFORM_MAIL_APP_NAME = 'platform-mail-stalwart';
1981
- const PLATFORM_MAIL_INTERNAL_NAME = 'platform-mail-stalwart';
1982
- const PLATFORM_MAIL_TUNNEL_NAME = 'platform-mail-tunnel';
1983
- /** #197: the one Coolify app that runs `cloudflared` for {@link PLATFORM_MAIL_TUNNEL_NAME}. */
1984
- const PLATFORM_MAIL_CLOUDFLARED_APP_NAME = 'platform-mail-cloudflared';
2287
+ export const PLATFORM_MAIL_INTERNAL_NAME = 'platform-mail-stalwart';
1985
2288
  /**
1986
2289
  * `stalw.art/docs/install/docker`: the official image. `platform.mail.gitRef`
1987
2290
  * carries its tag -- `platform.ts`'s `PlatformService.gitRef` doc comment
@@ -2004,7 +2307,6 @@ const STALWART_PORTS_EXPOSES = [STALWART_HTTP_PORT, ...STALWART_SMTP_PORTS].join
2004
2307
  * not here -- it stays behind the tunnel, exactly like `platform:auth`'s
2005
2308
  * ZITADEL/login apps.
2006
2309
  */
2007
- const STALWART_PORTS_MAPPINGS = STALWART_SMTP_PORTS.map((p) => `${p}:${p}`).join(',');
2008
2310
  /**
2009
2311
  * The v0.16 image's two VOLUMEs (its Dockerfile): the data store, and the
2010
2312
  * `config.json` Stalwart writes when bootstrap completes. The pre-0.16 image
@@ -2028,9 +2330,10 @@ const STALWART_RECOVERY_USER = 'admin';
2028
2330
  */
2029
2331
  const STALWART_READY_PATH = '/healthz/ready';
2030
2332
  /** Where the server the app was created on is recorded, so the MX record follows the app, not a later pick. */
2031
- const PLATFORM_MAIL_SERVER_SCOPE_KEY = 'coolify_platform_mail_server';
2333
+ export const PLATFORM_MAIL_SERVER_SCOPE_KEY = 'coolify_platform_mail_server';
2032
2334
  const PLATFORM_MAIL_PROJECT_SCOPE_KEY = 'coolify_project_platform_mail';
2033
2335
  const PLATFORM_MAIL_APP_SCOPE_KEY = 'coolify_platform_mail_app';
2336
+ const PLATFORM_MAIL_RELAY_APP_SCOPE_KEY = 'coolify_platform_mail_relay_app';
2034
2337
  const PLATFORM_MAIL_TUNNEL_SCOPE_KEY = 'cloudflare_platform_mail_tunnel';
2035
2338
  /**
2036
2339
  * Recorded once the app has been deployed at least once -- same role as
@@ -2198,24 +2501,21 @@ const PLATFORM_MAIL_ZITADEL_PROJECT_NAME = 'mail';
2198
2501
  * (the guard only ever blocks a retry and names an id to revoke by), but
2199
2502
  * worth tightening if `createApiKey` ever starts returning its own id.
2200
2503
  */
2201
- async function provisionPlatformMail(context, deps) {
2202
- const { platform } = context;
2203
- const hostname = platform.mail.hostname;
2204
- const mailHost = platformMailMxHost(platform.apex);
2205
- const imageTag = platform.mail.gitRef;
2206
- const recorded = context.recordedIds();
2504
+ export async function provisionMailServer(scope, deps, target, input) {
2505
+ const { hostname, apex, mailHost, imageTag, hostPorts, issuerUrl, mxRecord, network } = input;
2506
+ const zone = input.zone ?? apex;
2507
+ const label = target.kind === 'org' ? `own step coolify:own-mail for ${target.id}` : 'platform:mail';
2508
+ const keyDescription = target.kind === 'org' ? `foundry:org:${target.id}:admin` : PLATFORM_MAIL_ADMIN_KEY_DESCRIPTION;
2509
+ const recorded = scope.recordedIds();
2207
2510
  const runtime = deps.runtime ?? {};
2208
2511
  const sleep = runtime.sleep ?? defaultSleep;
2209
2512
  const record = async (key, id) => {
2210
- context.recordScopeId(key, id);
2211
- await context.persist?.();
2513
+ scope.recordScopeId(key, id);
2514
+ await scope.persist();
2212
2515
  };
2213
2516
  let projectUuid = recorded[PLATFORM_MAIL_PROJECT_SCOPE_KEY];
2214
2517
  if (!projectUuid) {
2215
- const found = await deps.coolify.findProjectByName(PLATFORM_MAIL_PROJECT);
2216
- projectUuid = found
2217
- ? found.uuid
2218
- : (await deps.coolify.createProject(PLATFORM_MAIL_PROJECT)).uuid;
2518
+ projectUuid = await input.project.ensure(deps.coolify, scope);
2219
2519
  await record(PLATFORM_MAIL_PROJECT_SCOPE_KEY, projectUuid);
2220
2520
  }
2221
2521
  const { environment } = await deps.coolify.findOrCreateEnvironment(projectUuid, 'production');
@@ -2226,23 +2526,27 @@ async function provisionPlatformMail(context, deps) {
2226
2526
  // One server for both the app and the MX record: recorded the first time,
2227
2527
  // read back after, so a later run never points `mx.<apex>` somewhere the
2228
2528
  // app is not.
2529
+ const portsChanged = (recorded[PLATFORM_MAIL_HOST_PORTS_SCOPE_KEY] ??
2530
+ (target.kind === 'org' ? '' : PLATFORM_MAIL_HOST_PORTS_DIRECT)) !== hostPorts;
2229
2531
  const server = await resolveMailServer(deps.coolify, recorded[PLATFORM_MAIL_SERVER_SCOPE_KEY]);
2230
2532
  await record(PLATFORM_MAIL_SERVER_SCOPE_KEY, server.uuid);
2231
- const serverIpBlocked = mailServerIpBlocked(server.ip, platform.serverIpv4);
2533
+ const serverIpBlocked = mxRecord ? mailServerIpBlocked(server.ip, mxRecord.serverIpv4) : null;
2232
2534
  if (serverIpBlocked)
2233
2535
  throw new Error(serverIpBlocked);
2234
- const app = await findOrCreatePlatformApp(deps.coolify, PLATFORM_MAIL_APP_NAME, environment, async () => {
2536
+ const app = await findOrCreatePlatformApp(deps.coolify, target.names.mailApp, environment, async () => {
2235
2537
  return deps.coolify.createDockerImageApplication({
2236
2538
  project_uuid: projectUuid,
2237
2539
  server_uuid: server.uuid,
2238
2540
  environment_name: environment.name,
2239
2541
  environment_uuid: environment.uuid,
2240
2542
  docker_registry_image_name: STALWART_IMAGE,
2241
- docker_registry_image_tag: imageTag,
2543
+ docker_registry_image_tag: pinnedTagFor('stalwart', imageTag),
2242
2544
  ports_exposes: STALWART_PORTS_EXPOSES,
2243
- ports_mappings: STALWART_PORTS_MAPPINGS,
2244
- name: PLATFORM_MAIL_APP_NAME,
2245
- description: `Stalwart for the platform's own mail (#169, ${STALWART_IMAGE}:${imageTag})`,
2545
+ ...(hostPorts ? { ports_mappings: hostPorts } : {}),
2546
+ name: target.names.mailApp,
2547
+ description: target.kind === 'org'
2548
+ ? `Stalwart for ${target.id}'s own mail (${STALWART_IMAGE}:${imageTag})`
2549
+ : `Stalwart for the platform's own mail (#169, ${STALWART_IMAGE}:${imageTag})`,
2246
2550
  instant_deploy: false,
2247
2551
  });
2248
2552
  });
@@ -2255,14 +2559,16 @@ async function provisionPlatformMail(context, deps) {
2255
2559
  // container before the new one can answer `waitForHealthyVersion`'s own
2256
2560
  // `'ready'` probe below. Stalwart's own readiness route and HTTP port.
2257
2561
  await deps.coolify.updateApplication(appId, {
2258
- custom_internal_name: PLATFORM_MAIL_INTERNAL_NAME,
2259
- docker_registry_image_tag: imageTag,
2562
+ custom_internal_name: target.internal.mail,
2563
+ docker_registry_image_tag: pinnedTagFor('stalwart', imageTag),
2260
2564
  ...healthCheckSettings(STALWART_READY_PATH, Number(STALWART_HTTP_PORT)),
2565
+ // Only a platform that uses the shared relay, or one that did, changes a published port.
2566
+ ...(portsChanged ? { ports_mappings: hostPorts } : {}),
2261
2567
  });
2262
2568
  // #177: org steps (an org's own `coolify:mail`/`coolify:agora`) read this
2263
2569
  // back instead of a literal, the same way files/ai's own internal
2264
2570
  // addresses are recorded.
2265
- await record(PLATFORM_MAIL_INTERNAL_URL_SCOPE_KEY, `http://${PLATFORM_MAIL_INTERNAL_NAME}:${STALWART_HTTP_PORT}`);
2571
+ await record(PLATFORM_MAIL_INTERNAL_URL_SCOPE_KEY, `http://${target.internal.mail}:${STALWART_HTTP_PORT}`);
2266
2572
  // ---- storage ---------------------------------------------------------------
2267
2573
  const storages = await deps.coolify.listStorages(appId);
2268
2574
  // An app made before this release only has the old `/opt/stalwart` volume.
@@ -2274,7 +2580,7 @@ async function provisionPlatformMail(context, deps) {
2274
2580
  continue;
2275
2581
  }
2276
2582
  await deps.coolify.createPersistentStorage(appId, {
2277
- name: `${PLATFORM_MAIL_APP_NAME}-${wanted.suffix}`,
2583
+ name: `${target.names.mailApp}-${wanted.suffix}`,
2278
2584
  mount_path: wanted.mount_path,
2279
2585
  });
2280
2586
  storagesAdded = true;
@@ -2295,7 +2601,12 @@ async function provisionPlatformMail(context, deps) {
2295
2601
  const declaredChanged = recorded[PLATFORM_MAIL_DEPLOYED_HOSTNAME_SCOPE_KEY] !== hostname ||
2296
2602
  recorded[PLATFORM_MAIL_DEPLOYED_GIT_REF_SCOPE_KEY] !== imageTag;
2297
2603
  // A new storage binds into the container only at its next start.
2298
- const mailDeployedNow = app.justCreated || !app.running || envsChanged || declaredChanged || storagesAdded;
2604
+ const mailDeployedNow = app.justCreated ||
2605
+ !app.running ||
2606
+ envsChanged ||
2607
+ declaredChanged ||
2608
+ storagesAdded ||
2609
+ portsChanged;
2299
2610
  if (mailDeployedNow) {
2300
2611
  // A container still up from before would answer the readiness probe with
2301
2612
  // the old deployment's health. Stop it and see it stopped first, so the
@@ -2311,7 +2622,7 @@ async function provisionPlatformMail(context, deps) {
2311
2622
  // instead would just poll a hostname that cannot resolve yet on a first
2312
2623
  // apply. Either way it runs before any applied ref is recorded.
2313
2624
  // The old container was stopped above (or never ran), so nothing stays live.
2314
- await waitForDeploymentFinished(deps.coolify, appId, runtime, 'platform:mail', app.justCreated || !app.running
2625
+ await waitForDeploymentFinished(deps.coolify, appId, runtime, label, app.justCreated || !app.running
2315
2626
  ? `mail stays at ${recorded[PLATFORM_MAIL_APPLIED_GIT_REF_SCOPE_KEY] ?? 'no recorded ref'}`
2316
2627
  : 'mail is stopped until the next apply');
2317
2628
  await record(PLATFORM_MAIL_DEPLOYED_SCOPE_KEY, appId);
@@ -2319,17 +2630,19 @@ async function provisionPlatformMail(context, deps) {
2319
2630
  // settings, mint) does not make the next run stop and redeploy the app.
2320
2631
  await record(PLATFORM_MAIL_DEPLOYED_HOSTNAME_SCOPE_KEY, hostname);
2321
2632
  await record(PLATFORM_MAIL_DEPLOYED_GIT_REF_SCOPE_KEY, imageTag);
2633
+ if (hostPorts)
2634
+ await record(PLATFORM_MAIL_HOST_PORTS_SCOPE_KEY, hostPorts);
2322
2635
  }
2323
2636
  // ---- tunnel and DNS: web/JMAP/management ------------------------------------
2324
- const tunnels = await platformTunnels(deps, context.recordedIds(), 'platform:mail');
2325
- const existingTunnel = await tunnels.findTunnelByName(PLATFORM_MAIL_TUNNEL_NAME);
2326
- const tunnel = existingTunnel ?? (await tunnels.createTunnel(PLATFORM_MAIL_TUNNEL_NAME));
2637
+ const tunnels = await network.tunnels();
2638
+ const existingTunnel = await tunnels.findTunnelByName(target.names.mailTunnel);
2639
+ const tunnel = existingTunnel ?? (await tunnels.createTunnel(target.names.mailTunnel));
2327
2640
  await record(PLATFORM_MAIL_TUNNEL_SCOPE_KEY, tunnel.id);
2328
2641
  await tunnels.putTunnelConfig(tunnel.id, {
2329
2642
  ingress: [
2330
2643
  {
2331
2644
  hostname,
2332
- service: `http://${PLATFORM_MAIL_INTERNAL_NAME}:${STALWART_HTTP_PORT}`,
2645
+ service: `http://${target.internal.mail}:${STALWART_HTTP_PORT}`,
2333
2646
  },
2334
2647
  { service: 'http_status:404' },
2335
2648
  ],
@@ -2339,7 +2652,7 @@ async function provisionPlatformMail(context, deps) {
2339
2652
  coolify: deps.coolify,
2340
2653
  tunnels,
2341
2654
  tunnelId: tunnel.id,
2342
- appName: PLATFORM_MAIL_CLOUDFLARED_APP_NAME,
2655
+ appName: target.names.mailCloudflared,
2343
2656
  projectUuid,
2344
2657
  environment,
2345
2658
  // The Stalwart app's own server, so the connector shares its Docker network.
@@ -2347,15 +2660,15 @@ async function provisionPlatformMail(context, deps) {
2347
2660
  recordedConnectorAppId: recorded[PLATFORM_MAIL_CONNECTOR_APP_SCOPE_KEY] ?? null,
2348
2661
  recordAppId: (appId) => record(PLATFORM_MAIL_CONNECTOR_APP_SCOPE_KEY, appId),
2349
2662
  });
2350
- const zoneId = recorded[apexZoneKey(platform.apex)] ?? (await exactZone(deps.cloudflare, platform.apex))?.id;
2663
+ const zoneId = await network.zoneId(zone);
2351
2664
  if (!zoneId) {
2352
- throw new Error(`platform:mail: no Cloudflare zone for ${platform.apex} to put ${hostname} in`);
2665
+ throw new Error(`${label}: no Cloudflare zone for ${zone} to put ${hostname} in`);
2353
2666
  }
2354
2667
  const dnsTarget = `${tunnel.id}.cfargotunnel.com`;
2355
- const dns = await platformDns(deps, context.recordedIds(), 'platform:mail');
2356
- const [dnsRecord] = await dns.findDnsRecord(platform.apex, hostname, 'CNAME');
2668
+ const dns = await network.dns();
2669
+ const [dnsRecord] = await dns.findDnsRecord(zone, hostname, 'CNAME');
2357
2670
  if (!dnsRecord) {
2358
- await dns.createDnsRecord(platform.apex, {
2671
+ await dns.createDnsRecord(zone, {
2359
2672
  type: 'CNAME',
2360
2673
  name: hostname,
2361
2674
  content: dnsTarget,
@@ -2363,31 +2676,35 @@ async function provisionPlatformMail(context, deps) {
2363
2676
  });
2364
2677
  }
2365
2678
  else if (dnsRecord.content !== dnsTarget) {
2366
- await dns.updateDnsRecord(platform.apex, dnsRecord.id, {
2679
+ await dns.updateDnsRecord(zone, dnsRecord.id, {
2367
2680
  type: 'CNAME',
2368
2681
  name: hostname,
2369
2682
  content: dnsTarget,
2370
2683
  proxied: true,
2371
2684
  });
2372
2685
  }
2686
+ if (target.kind === 'org')
2687
+ await recordOwnDnsHostname(scope, hostname);
2373
2688
  // ---- the MX hostname: a direct, unproxied A record --------------------------
2374
- const serverIp = (platform.serverIpv4 ?? server.ip); // checked by mailServerIpBlocked above
2375
- const [mxRecord] = await dns.findDnsRecord(platform.apex, mailHost, 'A');
2376
- if (!mxRecord) {
2377
- await dns.createDnsRecord(platform.apex, {
2378
- type: 'A',
2379
- name: mailHost,
2380
- content: serverIp,
2381
- proxied: false,
2382
- });
2383
- }
2384
- else if (mxRecord.content !== serverIp) {
2385
- await dns.updateDnsRecord(platform.apex, mxRecord.id, {
2386
- type: 'A',
2387
- name: mailHost,
2388
- content: serverIp,
2389
- proxied: false,
2390
- });
2689
+ if (mxRecord) {
2690
+ const serverIp = (mxRecord.serverIpv4 ?? server.ip); // checked by mailServerIpBlocked above
2691
+ const [existingMx] = await dns.findDnsRecord(zone, mailHost, 'A');
2692
+ if (!existingMx) {
2693
+ await dns.createDnsRecord(zone, {
2694
+ type: 'A',
2695
+ name: mailHost,
2696
+ content: serverIp,
2697
+ proxied: false,
2698
+ });
2699
+ }
2700
+ else if (existingMx.content !== serverIp) {
2701
+ await dns.updateDnsRecord(zone, existingMx.id, {
2702
+ type: 'A',
2703
+ name: mailHost,
2704
+ content: serverIp,
2705
+ proxied: false,
2706
+ });
2707
+ }
2391
2708
  }
2392
2709
  // #252: phase 2, now that the tunnel and DNS above actually route
2393
2710
  // `hostname`. `'ready'`, not `'version'`: Stalwart's `/healthz/ready`
@@ -2404,7 +2721,7 @@ async function provisionPlatformMail(context, deps) {
2404
2721
  const ensureMailReady = async () => {
2405
2722
  if (mailReadyConfirmed)
2406
2723
  return;
2407
- await waitForHealthyVersion(appId, { kind: 'ready', url: `https://${hostname}${STALWART_READY_PATH}` }, runtime, sleep, 'platform:mail', `mail stays at ${recorded[PLATFORM_MAIL_APPLIED_GIT_REF_SCOPE_KEY] ?? 'no recorded ref'}`);
2724
+ await waitForHealthyVersion(appId, { kind: 'ready', url: `https://${hostname}${STALWART_READY_PATH}` }, runtime, sleep, label, `mail stays at ${recorded[PLATFORM_MAIL_APPLIED_GIT_REF_SCOPE_KEY] ?? 'no recorded ref'}`);
2408
2725
  mailReadyConfirmed = true;
2409
2726
  };
2410
2727
  // Also probe when the applied ref lags the target: the deployed refs are
@@ -2419,7 +2736,7 @@ async function provisionPlatformMail(context, deps) {
2419
2736
  // projects -- its id is the OIDC audience Stalwart's directory checks an
2420
2737
  // access token's `aud` claim against, so no host has to register a
2421
2738
  // resource API for this by hand.
2422
- const issuer = `https://${platform.auth.hostname}`;
2739
+ const issuer = issuerUrl;
2423
2740
  const zitadel = deps.zitadelForInstance?.(issuer) ?? deps.zitadel;
2424
2741
  const { org } = await zitadel.findOrCreateOrg(PLATFORM_AUTH_ORG_NAME);
2425
2742
  const { project } = await zitadel.findOrCreateProject(org.id, PLATFORM_MAIL_ZITADEL_PROJECT_NAME);
@@ -2445,7 +2762,7 @@ async function provisionPlatformMail(context, deps) {
2445
2762
  await ensureMailReady();
2446
2763
  const recoveryEnv = (await deps.coolify.listEnvs(appId)).find((e) => e.key === 'STALWART_RECOVERY_ADMIN' && !e.is_preview);
2447
2764
  if (!recoveryEnv) {
2448
- throw new Error('platform:mail: STALWART_RECOVERY_ADMIN is not set on the app; cannot authenticate to re-apply the OIDC audience');
2765
+ throw new Error(`${label}: STALWART_RECOVERY_ADMIN is not set on the app; cannot authenticate to re-apply the OIDC audience`);
2449
2766
  }
2450
2767
  const password = recoveryEnv.value.slice(`${STALWART_RECOVERY_USER}:`.length);
2451
2768
  try {
@@ -2453,7 +2770,7 @@ async function provisionPlatformMail(context, deps) {
2453
2770
  url: `https://${hostname}`,
2454
2771
  user: STALWART_RECOVERY_USER,
2455
2772
  password,
2456
- apex: platform.apex,
2773
+ apex,
2457
2774
  });
2458
2775
  await applyMailSettings(admin, {
2459
2776
  coolify: deps.coolify,
@@ -2462,8 +2779,8 @@ async function provisionPlatformMail(context, deps) {
2462
2779
  sleep,
2463
2780
  hostname,
2464
2781
  settings: {
2465
- mailHost,
2466
- apex: platform.apex,
2782
+ mailHost: hostname,
2783
+ apex,
2467
2784
  oidc: { issuerUrl: issuer, audience: project.id },
2468
2785
  },
2469
2786
  });
@@ -2481,12 +2798,12 @@ async function provisionPlatformMail(context, deps) {
2481
2798
  if (!recorded[PLATFORM_MAIL_ADMIN_KEY_SCOPE_KEY]) {
2482
2799
  const lost = recorded[PLATFORM_MAIL_ADMIN_UNDELIVERED_SCOPE_KEY];
2483
2800
  if (lost) {
2484
- throw new Error(`platform:mail: an admin API key ("${lost}") was minted but never delivered; revoke it in Stalwart and remove the recorded key ${PLATFORM_MAIL_ADMIN_UNDELIVERED_SCOPE_KEY} to mint a new one`);
2801
+ throw new Error(`${label}: an admin API key ("${lost}") was minted but never delivered; revoke it in Stalwart and remove the recorded key ${PLATFORM_MAIL_ADMIN_UNDELIVERED_SCOPE_KEY} to mint a new one`);
2485
2802
  }
2486
2803
  await ensureMailReady();
2487
2804
  const recoveryEnv = (await deps.coolify.listEnvs(appId)).find((e) => e.key === 'STALWART_RECOVERY_ADMIN' && !e.is_preview);
2488
2805
  if (!recoveryEnv) {
2489
- throw new Error('platform:mail: STALWART_RECOVERY_ADMIN is not set on the app; cannot authenticate to mint the admin key');
2806
+ throw new Error(`${label}: STALWART_RECOVERY_ADMIN is not set on the app; cannot authenticate to mint the admin key`);
2490
2807
  }
2491
2808
  const password = recoveryEnv.value.slice(`${STALWART_RECOVERY_USER}:`.length);
2492
2809
  try {
@@ -2494,7 +2811,7 @@ async function provisionPlatformMail(context, deps) {
2494
2811
  url: `https://${hostname}`,
2495
2812
  user: STALWART_RECOVERY_USER,
2496
2813
  password,
2497
- apex: platform.apex,
2814
+ apex,
2498
2815
  });
2499
2816
  await applyMailSettings(admin, {
2500
2817
  coolify: deps.coolify,
@@ -2503,8 +2820,8 @@ async function provisionPlatformMail(context, deps) {
2503
2820
  sleep,
2504
2821
  hostname,
2505
2822
  settings: {
2506
- mailHost,
2507
- apex: platform.apex,
2823
+ mailHost: hostname,
2824
+ apex,
2508
2825
  oidc: { issuerUrl: issuer, audience: project.id },
2509
2826
  },
2510
2827
  });
@@ -2512,17 +2829,17 @@ async function provisionPlatformMail(context, deps) {
2512
2829
  // Recorded and persisted before the secret leaves this function: if
2513
2830
  // delivery fails for good, the next plan reports the lost key rather
2514
2831
  // than skipping or minting again.
2515
- await record(PLATFORM_MAIL_ADMIN_UNDELIVERED_SCOPE_KEY, PLATFORM_MAIL_ADMIN_KEY_DESCRIPTION);
2516
- const secret = await admin.createApiKey(PLATFORM_MAIL_ADMIN_KEY_DESCRIPTION, 'inherit');
2832
+ await record(PLATFORM_MAIL_ADMIN_UNDELIVERED_SCOPE_KEY, keyDescription);
2833
+ const secret = await admin.createApiKey(keyDescription, 'inherit');
2517
2834
  const attempts = runtime.deliverAttempts ?? 3;
2518
2835
  for (let attempt = 1;; attempt += 1) {
2519
2836
  try {
2520
- await deps.deliverSecret('stalwart:platform:admin', secret);
2837
+ await deps.deliverSecret(target.refs.mailAdminKey, secret);
2521
2838
  break;
2522
2839
  }
2523
2840
  catch {
2524
2841
  if (attempt >= attempts) {
2525
- throw new Error(`platform:mail: the admin API key ("${PLATFORM_MAIL_ADMIN_KEY_DESCRIPTION}") was minted but could not be delivered after ${attempts} tries`);
2842
+ throw new Error(`${label}: the admin API key ("${keyDescription}") was minted but could not be delivered after ${attempts} tries`);
2526
2843
  }
2527
2844
  await sleep(1000 * attempt);
2528
2845
  }
@@ -2539,6 +2856,29 @@ async function provisionPlatformMail(context, deps) {
2539
2856
  await record(PLATFORM_MAIL_APPLIED_GIT_REF_SCOPE_KEY, imageTag);
2540
2857
  return { resourceId: appId };
2541
2858
  }
2859
+ /** The platform's mail project: the shared `platform` project, its id recorded by the mail step under its own key. */
2860
+ const platformMailProject = {
2861
+ async ensure(coolify) {
2862
+ const found = await coolify.findProjectByName(PLATFORM_MAIL_PROJECT);
2863
+ return found ? found.uuid : (await coolify.createProject(PLATFORM_MAIL_PROJECT)).uuid;
2864
+ },
2865
+ };
2866
+ /** The platform's mail server: {@link provisionMailServer} with {@link platformTarget} and the platform's own inputs. */
2867
+ async function provisionPlatformMail(context, deps) {
2868
+ const { platform } = context;
2869
+ const scope = scopeFromPlatform(context);
2870
+ return provisionMailServer(scope, deps, platformTarget(), {
2871
+ hostname: platform.mail.hostname,
2872
+ apex: platform.apex,
2873
+ mailHost: platformMailMxHost(platform.apex),
2874
+ imageTag: platform.mail.gitRef,
2875
+ hostPorts: platformMailHostPorts(platform),
2876
+ issuerUrl: `https://${platform.auth.hostname}`,
2877
+ mxRecord: { serverIpv4: platform.serverIpv4 },
2878
+ project: platformMailProject,
2879
+ network: platformNetwork(deps, scope, 'platform:mail'),
2880
+ });
2881
+ }
2542
2882
  /**
2543
2883
  * Why the Stalwart image tag cannot be used, or `null`. #248: a thin wrapper
2544
2884
  * over the estate-wide {@link floatingTagBlocked} -- the mail step's own
@@ -2604,6 +2944,90 @@ async function mailBlockedReason(platform, mail, recordedIds) {
2604
2944
  return "the platform's mail server is blocked, not planned: no usable Coolify server could be resolved for it";
2605
2945
  }
2606
2946
  }
2947
+ async function provisionPlatformOikosIngress(context, deps) {
2948
+ const recorded = context.recordedIds();
2949
+ const edgeUrl = recorded[PLATFORM_EDGE_INTERNAL_URL_SCOPE_KEY];
2950
+ if (!edgeUrl)
2951
+ throw new Error(OIKOS_INGRESS_BLOCKED.edgeNotApplied);
2952
+ return provisionOikosIngress(context, {
2953
+ coolify: deps.coolify,
2954
+ tunnels: await platformTunnels(deps, recorded, 'platform:oikos-ingress'),
2955
+ dns: await platformDns(deps, recorded, 'platform:oikos-ingress'),
2956
+ ratelimits: await deps.ratelimits(edgeUrl),
2957
+ zones: await deps.zones(edgeUrl),
2958
+ resolveServer: () => resolvePlatformServer(deps.coolify),
2959
+ callTimeoutMs: deps.callTimeoutMs,
2960
+ });
2961
+ }
2962
+ /** Why the shared mail relay cannot be built from its pinned base image, or `null`. */
2963
+ function mailRelayBlockedReason() {
2964
+ const tagBlocked = floatingTagBlocked('mail relay base', VERSIONS.alpine, '3.22.1');
2965
+ return tagBlocked ? `the platform's mail relay is blocked, not planned: ${tagBlocked}` : null;
2966
+ }
2967
+ /** Why `mx.<apex>` has no address to point at, or `null`. Skipped once the relay is applied, as the mail step's own check was. */
2968
+ async function relayServerBlockedReason(platform, mail, recordedIds) {
2969
+ if (platform.serverIpv4 || recordedIds[`coolify:app:${PLATFORM_MAIL_RELAY_KEY}`])
2970
+ return null;
2971
+ try {
2972
+ const server = await resolveMailServer(mail.coolify, recordedIds[PLATFORM_MAIL_SERVER_SCOPE_KEY]);
2973
+ return mailServerIpBlocked(server.ip, platform.serverIpv4);
2974
+ }
2975
+ catch {
2976
+ return "the platform's mail relay is blocked, not planned: no usable Coolify server could be resolved for it";
2977
+ }
2978
+ }
2979
+ /**
2980
+ * Runs the shared mail relay step in the platform mail project and on the
2981
+ * platform server (recorded by the mail step when one ran, else found or made).
2982
+ */
2983
+ async function provisionPlatformMailRelay(action, context, deps) {
2984
+ const recorded = context.recordedIds();
2985
+ // No platform mail step runs any more (the owner company runs its own mail), so
2986
+ // the relay finds or makes its project and server itself, as the mail step did.
2987
+ let projectUuid = recorded[PLATFORM_MAIL_PROJECT_SCOPE_KEY];
2988
+ if (!projectUuid) {
2989
+ const found = await deps.coolify.findProjectByName(PLATFORM_MAIL_PROJECT);
2990
+ projectUuid = found
2991
+ ? found.uuid
2992
+ : (await deps.coolify.createProject(PLATFORM_MAIL_PROJECT)).uuid;
2993
+ context.recordScopeId(PLATFORM_MAIL_PROJECT_SCOPE_KEY, projectUuid);
2994
+ await context.persist?.();
2995
+ }
2996
+ let serverUuid = recorded[PLATFORM_MAIL_SERVER_SCOPE_KEY];
2997
+ if (!serverUuid) {
2998
+ serverUuid = (await resolveMailServer(deps.coolify, undefined)).uuid;
2999
+ context.recordScopeId(PLATFORM_MAIL_SERVER_SCOPE_KEY, serverUuid);
3000
+ await context.persist?.();
3001
+ }
3002
+ // The relay answers port 25 on `mx.<apex>`, and no mail step writes that name any more: the relay does,
3003
+ // first, so a failed write leaves the step planned (the routes are recorded last).
3004
+ const mxHost = platformMailMxHost(context.platform.apex);
3005
+ const server = await resolveMailServer(deps.coolify, serverUuid);
3006
+ const serverIpBlocked = mailServerIpBlocked(server.ip, context.platform.serverIpv4);
3007
+ if (serverIpBlocked)
3008
+ throw new Error(serverIpBlocked);
3009
+ const serverIp = (context.platform.serverIpv4 ?? server.ip);
3010
+ const network = platformNetwork(deps, scopeFromPlatform(context), 'platform:mail-relay');
3011
+ const zoneId = await network.zoneId(context.platform.apex);
3012
+ if (!zoneId) {
3013
+ throw new Error(`platform:mail-relay: no Cloudflare zone for ${context.platform.apex} to put ${mxHost} in`);
3014
+ }
3015
+ const dns = await network.dns();
3016
+ const [existingMx] = await dns.findDnsRecord(context.platform.apex, mxHost, 'A');
3017
+ const mxRecord = { type: 'A', name: mxHost, content: serverIp, proxied: false };
3018
+ if (!existingMx) {
3019
+ await dns.createDnsRecord(context.platform.apex, mxRecord);
3020
+ }
3021
+ else if (existingMx.content !== serverIp) {
3022
+ await dns.updateDnsRecord(context.platform.apex, existingMx.id, mxRecord);
3023
+ }
3024
+ return provisionMailRelay(action, context, deps, {
3025
+ projectUuid,
3026
+ serverUuid,
3027
+ hostname: mxHost,
3028
+ appScopeKey: PLATFORM_MAIL_RELAY_APP_SCOPE_KEY,
3029
+ });
3030
+ }
2607
3031
  /**
2608
3032
  * #211: the broker cannot start without `ZITADEL_SERVICE_USER_TOKEN`/`_FILE`,
2609
3033
  * `LETTERMINT_API_TOKEN` and `AUTH_APPS`. `provisionPlatformAuth` already
@@ -2834,9 +3258,75 @@ const PLATFORM_ARCHON_PROJECT_SCOPE_KEY = 'coolify_project_platform_archon';
2834
3258
  const PLATFORM_ARCHON_DB_SCOPE_KEY = 'coolify_platform_archon_database';
2835
3259
  const PLATFORM_ARCHON_TUNNEL_SCOPE_KEY = 'cloudflare_platform_archon_tunnel';
2836
3260
  const PLATFORM_ARCHON_ACCESS_SCOPE_KEY = 'cloudflare_platform_archon_access_app';
2837
- const PLATFORM_ARCHON_ZITADEL_PROJECT_SCOPE_KEY = 'zitadel_platform_archon_project';
3261
+ /**
3262
+ * Archon paths a machine calls and Archon authenticates itself (its `proxy.ts`:
3263
+ * `PUBLIC_EXACT` and `ORG_CHANGES_PATH_RE`). Each gets its own Access app with
3264
+ * one bypass policy, because the hostname app would otherwise ask a machine for
3265
+ * an emailed code. `/api/uptime/targets` and `/api/uptime/results` are left out
3266
+ * on purpose: they pass the hostname app through the uptime service-token
3267
+ * policy, and a bypass would make that token meaningless. `*` is one path
3268
+ * segment (the organisation id).
3269
+ */
3270
+ export const PLATFORM_ARCHON_PUBLIC_PATHS = [
3271
+ { name: 'prices', path: '/api/prices' },
3272
+ { name: 'vipps-webhook', path: '/vipps/webhook' },
3273
+ { name: 'vipps-agreement', path: '/vipps/agreement' },
3274
+ { name: 'usage-tick', path: '/api/usage/tick' },
3275
+ { name: 'reporting-collect', path: '/api/reporting/collect' },
3276
+ { name: 'org-changes', path: '/api/orgs/*/changes' },
3277
+ ];
3278
+ const PLATFORM_ARCHON_PUBLIC_POLICY_NAME = 'foundry: public path';
3279
+ const archonPublicAppName = (name) => `${PLATFORM_ARCHON_ACCESS_APP_NAME}:public:${name}`;
3280
+ /** The scope key one bypass app's id is recorded under. */
3281
+ export const archonPublicAccessScopeKey = (name) => `cloudflare_platform_archon_access_public_${name.replace(/-/g, '_')}`;
3282
+ /** Whether a policy list is exactly the one bypass-for-everyone policy. */
3283
+ function onlyBypassEveryone(policies) {
3284
+ const only = policies[0];
3285
+ return (policies.length === 1 &&
3286
+ only?.name === PLATFORM_ARCHON_PUBLIC_POLICY_NAME &&
3287
+ only.decision === 'bypass' &&
3288
+ Array.isArray(only.include) &&
3289
+ only.include.length === 1 &&
3290
+ JSON.stringify(only.include[0]) === '{"everyone":{}}');
3291
+ }
3292
+ /**
3293
+ * One Access app per public path, each with the one policy bypass/everyone and
3294
+ * no identity provider. Adopts only an app of its own name on the exact domain,
3295
+ * refuses another name there, and rebuilds one whose policies are not exactly
3296
+ * the bypass. A rebuild needs no closed gate: the hostname app covers the path
3297
+ * meanwhile, which only asks for more.
3298
+ */
3299
+ async function ensureArchonPublicAccess(cf, hostname, recorded, record) {
3300
+ for (const { name, path } of PLATFORM_ARCHON_PUBLIC_PATHS) {
3301
+ const domain = `${hostname}${path}`;
3302
+ const appName = archonPublicAppName(name);
3303
+ const onDomain = (await cf.listAccessApps()).filter((a) => a.domain === domain);
3304
+ const foreign = onDomain.find((a) => a.name !== appName);
3305
+ if (foreign) {
3306
+ throw new Error(`platform:archon: Access app '${foreign.name}' already holds ${domain}; foundry does not adopt it`);
3307
+ }
3308
+ let id = onDomain[0]?.id;
3309
+ if (id && !onlyBypassEveryone(await cf.listAccessPolicies(id))) {
3310
+ await cf.deleteAccessApp(id);
3311
+ id = undefined;
3312
+ }
3313
+ if (!id) {
3314
+ id = (await cf.createAccessApp({ name: appName, domain, type: 'self_hosted' })).id;
3315
+ await cf.createAccessPolicy(id, {
3316
+ name: PLATFORM_ARCHON_PUBLIC_POLICY_NAME,
3317
+ decision: 'bypass',
3318
+ include: [{ everyone: {} }],
3319
+ });
3320
+ }
3321
+ const key = archonPublicAccessScopeKey(name);
3322
+ if (recorded[key] !== id)
3323
+ await record(key, id);
3324
+ }
3325
+ }
2838
3326
  /** The hostname the sign-in client's redirect URIs were made for; a change makes a new client. */
2839
3327
  const PLATFORM_ARCHON_CLIENT_HOSTNAME_SCOPE_KEY = 'zitadel_platform_archon_client_hostname';
3328
+ /** The owner's ZITADEL organisation the sign-in client was made in; a change makes a new client. */
3329
+ const PLATFORM_ARCHON_CLIENT_ORG_SCOPE_KEY = 'zitadel_platform_archon_client_org';
2840
3330
  /** Archon's OIDC client id: public, not a secret, so a plain scope id. */
2841
3331
  export const PLATFORM_ARCHON_CLIENT_ID_SCOPE_KEY = 'zitadel_platform_archon_client_id';
2842
3332
  /**
@@ -2847,13 +3337,16 @@ export const PLATFORM_ARCHON_CLIENT_ID_SCOPE_KEY = 'zitadel_platform_archon_clie
2847
3337
  * names the ids only, never the cause.
2848
3338
  */
2849
3339
  async function switchOnArchonIdTokenUserinfo(context, deps) {
2850
- const { platform } = context;
2851
3340
  const recorded = context.recordedIds();
2852
- const zitadel = deps.zitadelForInstance?.(`https://${platform.auth.hostname}`) ?? deps.zitadel;
3341
+ // The client lives in the owner company's ZITADEL; without its sign-in there is nothing to switch on.
3342
+ const ownerSignIn = context.owner?.signIn ?? null;
3343
+ const zitadel = ownerSignIn
3344
+ ? deps.zitadelForInstance?.(ownerSignIn.issuerUrl, ownerSignIn.bearerRef)
3345
+ : undefined;
2853
3346
  // The planner plans nothing without the method; a stale plan does nothing.
2854
- if (!zitadel.updateOidcAppIdTokenUserinfoAssertion)
3347
+ if (!ownerSignIn || !zitadel?.updateOidcAppIdTokenUserinfoAssertion)
2855
3348
  return {};
2856
- const orgId = recorded[PLATFORM_ZITADEL_ORG_SCOPE_KEY];
3349
+ const orgId = ownerSignIn.organisationId;
2857
3350
  const projectId = recorded[PLATFORM_ARCHON_ZITADEL_PROJECT_SCOPE_KEY];
2858
3351
  const appId = recorded[PLATFORM_ARCHON_ZITADEL_APP_SCOPE_KEY];
2859
3352
  if (!orgId || !projectId || !appId) {
@@ -2979,7 +3472,25 @@ async function provisionPlatformArchon(context, deps, ownerCompany = null) {
2979
3472
  const hostname = platform.archon.hostname;
2980
3473
  const gitRef = platform.archon.gitRef;
2981
3474
  const recorded = context.recordedIds();
2982
- const issuer = `https://${platform.auth.hostname}`;
3475
+ // Operators sign in through the owner company's own sign-in, never a platform one.
3476
+ const ownerSignIn = context.owner?.signIn ?? null;
3477
+ const ownerFiles = context.owner?.files ?? null;
3478
+ if (!ownerSignIn) {
3479
+ throw new Error("platform:archon: the owner company's sign-in has not been applied yet; Archon's sign-in client lives in it");
3480
+ }
3481
+ const issuer = ownerSignIn.issuerUrl;
3482
+ // Archon signs people in from its own organisation; the company's is only handed on as CI_ORGANIZATION_ID.
3483
+ const authOrgId = recorded[PLATFORM_ARCHON_ORG_SCOPE_KEY];
3484
+ const ciOrgId = ownerSignIn.organisationId;
3485
+ if (!authOrgId) {
3486
+ throw new Error("platform:archon: Archon's own sign-in organisation is not recorded yet; zitadel:archon-org runs first");
3487
+ }
3488
+ // Archon accepts sign-ins from that organisation only once it forces a second factor and keeps registration closed.
3489
+ if (recorded[PLATFORM_ARCHON_ORG_POLICY_SCOPE_KEY] !== authOrgId ||
3490
+ recorded[PLATFORM_ARCHON_ORG_REGISTRATION_CLOSED_SCOPE_KEY] !== authOrgId) {
3491
+ throw new Error("platform:archon: Archon's sign-in organisation does not force a second factor and keep registration closed yet; zitadel:archon-org runs first");
3492
+ }
3493
+ const firstOwnerLink = archonFirstOwnerLink(recorded, ownerSignIn);
2983
3494
  const appliedGitRef = recorded[PLATFORM_ARCHON_APPLIED_GIT_REF_SCOPE_KEY];
2984
3495
  const runtime = deps.runtime ?? {};
2985
3496
  const sleep = runtime.sleep ?? defaultSleep;
@@ -2987,13 +3498,7 @@ async function provisionPlatformArchon(context, deps, ownerCompany = null) {
2987
3498
  if (deps.accessEmails.length === 0) {
2988
3499
  throw new Error('platform:archon: no Access emails given; refusing to put Archon behind an Access app nobody can pass');
2989
3500
  }
2990
- // The sign-in client below is created inside the platform's own ZITADEL,
2991
- // which `platform:auth` builds. The planner orders that step first and
2992
- // blocks this one while auth is off; this is the same rule for a caller
2993
- // that reaches the step anyway.
2994
- if (!platform.auth.enabled || !recorded[`coolify:app:${PLATFORM_AUTH_KEY}`]) {
2995
- throw new Error("platform:archon: the platform's own sign-in (platform:auth) has not been applied yet; Archon's sign-in client lives in it");
2996
- }
3501
+ const accessEmails = archonAccessEmails(deps.accessEmails, ownerCompany);
2997
3502
  const record = async (key, id) => {
2998
3503
  context.recordScopeId(key, id);
2999
3504
  await context.persist?.();
@@ -3001,7 +3506,7 @@ async function provisionPlatformArchon(context, deps, ownerCompany = null) {
3001
3506
  // Every service the platform declares must have delivered what Archon reads.
3002
3507
  // The planner blocks first; this is the same rule for a caller that reaches
3003
3508
  // the step anyway, and it is where the values are read for the env below.
3004
- const inputs = await collectArchonServiceInputs(platform, deps, recorded);
3509
+ const inputs = await collectArchonServiceInputs(platform, deps, recorded, ownerFiles);
3005
3510
  if ('blocked' in inputs)
3006
3511
  throw new Error(`platform:archon: ${inputs.blocked}`);
3007
3512
  // #213: Archon's own onboarding reads these back as held keys before it
@@ -3106,11 +3611,25 @@ async function provisionPlatformArchon(context, deps, ownerCompany = null) {
3106
3611
  ...healthCheckSettings(ARCHON_HEALTH_CHECK_PATH, Number(ARCHON_PORT), ARCHON_HEALTH_CHECK_START_PERIOD_SECONDS),
3107
3612
  git_branch: gitRef,
3108
3613
  });
3109
- // ---- sign-in client in the platform's own ZITADEL -------------------------
3110
- const zitadel = deps.zitadelForInstance?.(issuer) ?? deps.zitadel;
3111
- const { org } = await zitadel.findOrCreateOrg(PLATFORM_AUTH_ORG_NAME);
3112
- if (recorded[PLATFORM_ZITADEL_ORG_SCOPE_KEY] !== org.id) {
3113
- await record(PLATFORM_ZITADEL_ORG_SCOPE_KEY, org.id);
3614
+ // ---- sign-in client in the owner company's own ZITADEL --------------------
3615
+ // Only the owner's own instance, by its issuer and bearer reference: the
3616
+ // platform's `deps.zitadel` is never used, so a missing factory is an error.
3617
+ if (!deps.zitadelForInstance) {
3618
+ throw new Error("platform:archon: the host built the platform executor without zitadelForInstance, so it cannot reach the owner company's ZITADEL");
3619
+ }
3620
+ const zitadel = deps.zitadelForInstance(issuer, ownerSignIn.bearerRef);
3621
+ const org = { id: authOrgId };
3622
+ // The client made in another organisation (the company's, before Archon had its own) is deleted
3623
+ // once the new one serves: its ids are kept here before the recorded ones are overwritten.
3624
+ const oldClientOrg = recorded[PLATFORM_ARCHON_CLIENT_ORG_SCOPE_KEY];
3625
+ const oldProject = recorded[PLATFORM_ARCHON_ZITADEL_PROJECT_SCOPE_KEY];
3626
+ const oldApp = recorded[PLATFORM_ARCHON_ZITADEL_APP_SCOPE_KEY];
3627
+ if (oldClientOrg &&
3628
+ oldClientOrg !== authOrgId &&
3629
+ oldProject &&
3630
+ oldApp &&
3631
+ !recorded[PLATFORM_ARCHON_RETIRED_CLIENT_SCOPE_KEY]) {
3632
+ await record(PLATFORM_ARCHON_RETIRED_CLIENT_SCOPE_KEY, `${oldClientOrg}|${oldProject}|${oldApp}`);
3114
3633
  }
3115
3634
  const { project } = await zitadel.findOrCreateProject(org.id, PLATFORM_ARCHON_ZITADEL_PROJECT);
3116
3635
  if (recorded[PLATFORM_ARCHON_ZITADEL_PROJECT_SCOPE_KEY] !== project.id) {
@@ -3119,7 +3638,8 @@ async function provisionPlatformArchon(context, deps, ownerCompany = null) {
3119
3638
  // The client's redirect URIs name the hostname, and no call updates them, so
3120
3639
  // a client made for another hostname is deleted and made again. Its id is
3121
3640
  // public and there is no secret to lose.
3122
- let clientId = recorded[PLATFORM_ARCHON_CLIENT_HOSTNAME_SCOPE_KEY] === hostname
3641
+ let clientId = recorded[PLATFORM_ARCHON_CLIENT_HOSTNAME_SCOPE_KEY] === hostname &&
3642
+ recorded[PLATFORM_ARCHON_CLIENT_ORG_SCOPE_KEY] === org.id
3123
3643
  ? recorded[PLATFORM_ARCHON_CLIENT_ID_SCOPE_KEY]
3124
3644
  : undefined;
3125
3645
  if (!clientId) {
@@ -3151,18 +3671,18 @@ async function provisionPlatformArchon(context, deps, ownerCompany = null) {
3151
3671
  // Made with the setting on (providers 0.9.0): nothing to repair on this id.
3152
3672
  context.recordScopeId(PLATFORM_ARCHON_ID_TOKEN_USERINFO_APPLIED_SCOPE_KEY, created.appId);
3153
3673
  context.recordScopeId(PLATFORM_ARCHON_CLIENT_HOSTNAME_SCOPE_KEY, hostname);
3674
+ context.recordScopeId(PLATFORM_ARCHON_CLIENT_ORG_SCOPE_KEY, org.id);
3154
3675
  await record(PLATFORM_ARCHON_CLIENT_ID_SCOPE_KEY, created.clientId);
3155
3676
  clientId = created.clientId;
3156
3677
  }
3157
3678
  // ---- env ------------------------------------------------------------------
3158
- // People sign in from the owner company's sign-in org. The project, client
3159
- // and Access app above stay in the `foundry` org, which holds no people.
3160
- const authOrgId = archonAuthOrganizationId(context.recordedIds()) ?? org.id;
3161
3679
  const envs = [
3162
3680
  { key: 'APP_URL', value: `https://${hostname}` },
3163
3681
  { key: 'AUTH_ISSUER', value: issuer },
3164
3682
  { key: 'AUTH_ORGANIZATION_ID', value: authOrgId },
3683
+ { key: 'CI_ORGANIZATION_ID', value: ciOrgId },
3165
3684
  { key: 'AUTH_CLIENT_ID', value: clientId },
3685
+ ...(firstOwnerLink ? [{ key: 'ARCHON_FIRST_OWNER_LINK', value: firstOwnerLink }] : []),
3166
3686
  ];
3167
3687
  // #265: an owner URL or reporting site already on the app is kept as it is.
3168
3688
  // The owner URL must name the adopted database's host, or the app's two
@@ -3235,7 +3755,12 @@ async function provisionPlatformArchon(context, deps, ownerCompany = null) {
3235
3755
  // empty, and `readBrokerApps`/`validateBrokerAppEntry` would refuse it the
3236
3756
  // moment this step tried to register it. See `ensureGeneratedEnv`'s own
3237
3757
  // doc comment for why no other call site gets this argument.
3238
- const appKey = (await ensureGeneratedEnv(deps.coolify, appId, 'AUTH_APP_KEY', () => randomBytes(32).toString('base64url'), 32)) ?? (await currentEnvValue(deps.coolify, appId, 'AUTH_APP_KEY'));
3758
+ // A key minted for another organisation (the company's) is never kept: Archon's new
3759
+ // entry gets a fresh one, so the key the old entry held stops being Archon's.
3760
+ const freshKey = recorded[PLATFORM_ARCHON_APP_KEY_ORG_SCOPE_KEY] !== authOrgId;
3761
+ const appKey = freshKey
3762
+ ? randomBytes(32).toString('base64url')
3763
+ : ((await ensureGeneratedEnv(deps.coolify, appId, 'AUTH_APP_KEY', () => randomBytes(32).toString('base64url'), 32)) ?? (await currentEnvValue(deps.coolify, appId, 'AUTH_APP_KEY')));
3239
3764
  if (!appKey) {
3240
3765
  throw new Error(`platform:archon: app ${appId} lost its AUTH_APP_KEY between two reads`);
3241
3766
  }
@@ -3244,7 +3769,8 @@ async function provisionPlatformArchon(context, deps, ownerCompany = null) {
3244
3769
  // rule above), and hashed as the live value so it never reads as a changed
3245
3770
  // env.
3246
3771
  const keepLiveFilesEnv = new Set();
3247
- for (const key of ['FILES_URL', 'FILES_AUDIENCE']) {
3772
+ // The owner's own files host, once applied, replaces whatever is live.
3773
+ for (const key of ownerFiles ? [] : ['FILES_URL', 'FILES_AUDIENCE']) {
3248
3774
  const live = await currentEnvValue(deps.coolify, appId, key);
3249
3775
  if (live) {
3250
3776
  keepLiveFilesEnv.add(key);
@@ -3302,6 +3828,10 @@ async function provisionPlatformArchon(context, deps, ownerCompany = null) {
3302
3828
  throw error;
3303
3829
  throw new Error(redactSecrets(error instanceof Error ? error.message : String(error), carried));
3304
3830
  }
3831
+ // The fresh key is in Archon's env now: from here a repeat keeps it. Recorded before the broker write,
3832
+ // so a failure there is repaired by writing the same key, not by minting another.
3833
+ if (freshKey)
3834
+ await record(PLATFORM_ARCHON_APP_KEY_ORG_SCOPE_KEY, authOrgId);
3305
3835
  // #436 round 2: `bulkUpsertEnvs` only adds or updates, so the
3306
3836
  // `FILES_MANAGEMENT_KEY` this step used to write before the fix above
3307
3837
  // would otherwise survive on an Archon already deployed with it --
@@ -3325,7 +3855,10 @@ async function provisionPlatformArchon(context, deps, ownerCompany = null) {
3325
3855
  const staleUptime = !platform.uptime.enabled && stale.key === 'UPTIME_TOKEN';
3326
3856
  // People now come from the owner company's store, so a first operator set by an older apply goes.
3327
3857
  const staleOperator = stale.key === 'AUTHZ_BOOTSTRAP_OPERATOR';
3328
- if ((stale.key === 'FILES_MANAGEMENT_KEY' || staleUptime || staleOperator) && stale.uuid) {
3858
+ // The link is written only while both ids are known; one that is no longer wanted goes.
3859
+ const staleLink = !firstOwnerLink && stale.key === 'ARCHON_FIRST_OWNER_LINK';
3860
+ if ((stale.key === 'FILES_MANAGEMENT_KEY' || staleUptime || staleOperator || staleLink) &&
3861
+ stale.uuid) {
3329
3862
  await deps.coolify.deleteEnv(appId, stale.uuid);
3330
3863
  staleKeyDeleted = true;
3331
3864
  }
@@ -3338,10 +3871,7 @@ async function provisionPlatformArchon(context, deps, ownerCompany = null) {
3338
3871
  // returns), and this step is unreachable until that resourceId is recorded
3339
3872
  // (the guard at the top of this function) -- so a missing id here means a
3340
3873
  // caller reached this step some other way.
3341
- const brokerAppId = recorded[PLATFORM_AUTH_BROKER_SCOPE_KEY];
3342
- if (!brokerAppId) {
3343
- throw new Error('platform:archon: platform:auth has not recorded its broker app id yet; apply platform:auth again');
3344
- }
3874
+ const brokerAppId = ownerSignIn.brokerAppId;
3345
3875
  const brokerApps = await readBrokerApps(deps.coolify, brokerAppId);
3346
3876
  // #211 round 2: merged into the matching entry, not replacing it -- the
3347
3877
  // broker's own `App` (`broker/src/apps.ts`) carries fields this step never
@@ -3355,14 +3885,22 @@ async function provisionPlatformArchon(context, deps, ownerCompany = null) {
3355
3885
  // first -- a previous bug, or a hand-edited AUTH_APPS, can leave more than
3356
3886
  // one. The first is kept (and updated below); the rest are dropped, so no
3357
3887
  // duplicate carries a stale key a broker client could still present.
3888
+ // No other entry may sign in Archon's organisation: its key would reach Archon's people.
3889
+ const conflicts = archonOrgConflicts(brokerApps, authOrgId);
3890
+ if (conflicts.length > 0) {
3891
+ throw new Error(`platform:archon: broker entry ${conflicts.map((n) => `'${n}'`).join(', ')} signs in Archon's own organisation; no other entry may`);
3892
+ }
3358
3893
  const archonMatchIndices = brokerApps
3359
- .map((a, i) => (a.organizationId === authOrgId || a.organizationId === org.id) &&
3360
- a.name === PLATFORM_ARCHON_ZITADEL_APP
3361
- ? i
3362
- : -1)
3894
+ .map((a, i) => a.organizationId === authOrgId && a.name === PLATFORM_ARCHON_ZITADEL_APP ? i : -1)
3363
3895
  .filter((i) => i >= 0);
3364
3896
  const archonEntryIndex = archonMatchIndices[0] ?? -1;
3365
- const duplicateIndices = new Set(archonMatchIndices.slice(1));
3897
+ // Archon's entry in an organisation it used to sign in from (the company's, before Archon had its
3898
+ // own) goes, key and all. An entry of that name in any other organisation is not Archon's to touch.
3899
+ const oldOrgs = archonOldOrgIds(recorded, ciOrgId, authOrgId);
3900
+ const oldEntryIndices = new Set(brokerApps
3901
+ .map((a, i) => a.name === PLATFORM_ARCHON_ZITADEL_APP && oldOrgs.has(a.organizationId) ? i : -1)
3902
+ .filter((i) => i >= 0));
3903
+ const duplicateIndices = new Set([...archonMatchIndices.slice(1), ...oldEntryIndices]);
3366
3904
  const archonEntry = {
3367
3905
  ...(archonEntryIndex >= 0 ? brokerApps[archonEntryIndex] : {}),
3368
3906
  organizationId: authOrgId,
@@ -3377,11 +3915,10 @@ async function provisionPlatformArchon(context, deps, ownerCompany = null) {
3377
3915
  // the wrong platform or not at all.
3378
3916
  from: `Company Included <noreply@${platform.apex}>`,
3379
3917
  };
3380
- const desiredBrokerApps = archonEntryIndex >= 0
3381
- ? brokerApps
3382
- .map((a, i) => (i === archonEntryIndex ? archonEntry : a))
3383
- .filter((_, i) => !duplicateIndices.has(i))
3384
- : [...brokerApps, archonEntry];
3918
+ const keptBrokerApps = brokerApps
3919
+ .map((a, i) => (i === archonEntryIndex ? archonEntry : a))
3920
+ .filter((_, i) => !duplicateIndices.has(i));
3921
+ const desiredBrokerApps = archonEntryIndex >= 0 ? keptBrokerApps : [...keptBrokerApps, archonEntry];
3385
3922
  const desiredAuthApps = [{ key: 'AUTH_APPS', value: JSON.stringify(desiredBrokerApps) }];
3386
3923
  // Rebuilt and compared every run (not gated on a freshly minted key), so a
3387
3924
  // clientId or hostname change reaches the broker too, and so a previous
@@ -3416,6 +3953,23 @@ async function provisionPlatformArchon(context, deps, ownerCompany = null) {
3416
3953
  await deps.coolify.deploy(brokerAppId);
3417
3954
  await record(PLATFORM_AUTH_BROKER_DEPLOYED_SCOPE_KEY, brokerDeployRecordValue(brokerAppId, desiredAuthAppsRaw));
3418
3955
  }
3956
+ // ---- the old sign-in client ------------------------------------------------
3957
+ // The broker no longer knows it, and Archon's env names the new one: delete the old client, so
3958
+ // no account of the company's organisation can still sign in to it. A repeat deletes nothing twice.
3959
+ const retiredClient = context.recordedIds()[PLATFORM_ARCHON_RETIRED_CLIENT_SCOPE_KEY];
3960
+ if (retiredClient) {
3961
+ const [oldOrgId, oldProjectId, oldAppId] = retiredClient.split('|');
3962
+ if (oldOrgId && oldProjectId && oldAppId) {
3963
+ try {
3964
+ await zitadel.deleteApp(oldOrgId, oldProjectId, oldAppId);
3965
+ }
3966
+ catch (error) {
3967
+ if (!isNotFoundError(error))
3968
+ throw error;
3969
+ }
3970
+ }
3971
+ await record(PLATFORM_ARCHON_RETIRED_CLIENT_SCOPE_KEY, '');
3972
+ }
3419
3973
  // ---- Cloudflare Access, before the hostname resolves ----------------------
3420
3974
  const tunnels = await platformTunnels(deps, context.recordedIds(), 'platform:archon');
3421
3975
  const access = await platformAccess(deps, context.recordedIds(), 'platform:archon');
@@ -3423,7 +3977,7 @@ async function provisionPlatformArchon(context, deps, ownerCompany = null) {
3423
3977
  const uptimeAccessTokenId = platform.uptime.enabled
3424
3978
  ? recorded[PLATFORM_UPTIME_ACCESS_TOKEN_ID_SCOPE_KEY]
3425
3979
  : undefined;
3426
- const accessAppId = await ensureArchonAccess(access, hostname, deps.accessEmails, uptimeAccessTokenId, async () => {
3980
+ const { id: accessAppId, pinId } = await ensureArchonAccess(access, hostname, accessEmails, uptimeAccessTokenId, async () => {
3427
3981
  // The Access app is about to be rebuilt: answer 404 for every hostname
3428
3982
  // meanwhile, so it is never served without the gate. The marker is
3429
3983
  // persisted first: a failure before the real ingress is written back
@@ -3439,6 +3993,10 @@ async function provisionPlatformArchon(context, deps, ownerCompany = null) {
3439
3993
  if (recorded[PLATFORM_ARCHON_ACCESS_SCOPE_KEY] !== accessAppId) {
3440
3994
  await record(PLATFORM_ARCHON_ACCESS_SCOPE_KEY, accessAppId);
3441
3995
  }
3996
+ if (recorded[PLATFORM_ARCHON_ACCESS_PIN_SCOPE_KEY] !== pinId) {
3997
+ await record(PLATFORM_ARCHON_ACCESS_PIN_SCOPE_KEY, pinId);
3998
+ }
3999
+ await ensureArchonPublicAccess(access, hostname, context.recordedIds(), record);
3442
4000
  // ---- deploy ---------------------------------------------------------------
3443
4001
  // #252: phase 1 of the shared deploy-health helper only -- Coolify's own
3444
4002
  // deployment record reaching `'finished'`, which is enough on its own to
@@ -3491,7 +4049,8 @@ async function provisionPlatformArchon(context, deps, ownerCompany = null) {
3491
4049
  // retry intends is identical to what a successful one intends, so
3492
4050
  // leaving the old confirmation in place would read as still correct.
3493
4051
  await record(PLATFORM_ARCHON_ENVS_DEPLOYED_SCOPE_KEY, '');
3494
- 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'}`);
4052
+ const built = 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'}`);
4053
+ await recordBuiltCommit(deps.coolify, { appId: appId, deploymentUuid: built.deploymentUuid, name: 'archon' }, record);
3495
4054
  }
3496
4055
  // ---- tunnel and DNS ---------------------------------------------------------
3497
4056
  const existingTunnel = await tunnels.findTunnelByName(PLATFORM_ARCHON_TUNNEL_NAME);
@@ -3597,6 +4156,10 @@ async function provisionPlatformArchon(context, deps, ownerCompany = null) {
3597
4156
  // it.
3598
4157
  await record(PLATFORM_ARCHON_APPLIED_BROKER_SCOPE_KEY, brokerAppId);
3599
4158
  await record(PLATFORM_ARCHON_APPLIED_AUTH_ORG_SCOPE_KEY, authOrgId);
4159
+ await record(PLATFORM_ARCHON_APPLIED_CI_ORG_SCOPE_KEY, ciOrgId);
4160
+ await record(PLATFORM_ARCHON_APPLIED_FIRST_OWNER_LINK_SCOPE_KEY, firstOwnerLink);
4161
+ await record(PLATFORM_ARCHON_APPLIED_AUTH_HOSTNAME_SCOPE_KEY, ownerSignIn.hostname);
4162
+ await record(PLATFORM_ARCHON_APPLIED_FILES_SCOPE_KEY, archonFilesMarker(ownerFiles));
3600
4163
  // The host runs inside this app: every marker is recorded above, so the
3601
4164
  // deploy goes last and is not waited for -- the old container stops during
3602
4165
  // it, and the response, the lease and the audit row must be done first.
@@ -3908,6 +4471,9 @@ const PLATFORM_ARCHON_UPTIME_POLICY_NAME = 'foundry: uptime service token';
3908
4471
  * is added in place, one for another token rebuilds the app.
3909
4472
  */
3910
4473
  async function ensureArchonAccess(cf, domain, emails, uptimeTokenId, closeGate) {
4474
+ // The app accepts the one-time PIN and nothing else, so a person is mailed a code
4475
+ // instead of being offered the company's own sign-in provider.
4476
+ const { id: pinId } = await cf.ensureOneTimePinProvider();
3911
4477
  const onDomain = (await cf.listAccessApps()).filter((a) => a.domain === domain);
3912
4478
  const foreign = onDomain.find((a) => a.name !== PLATFORM_ARCHON_ACCESS_APP_NAME);
3913
4479
  if (foreign) {
@@ -3918,6 +4484,8 @@ async function ensureArchonAccess(cf, domain, emails, uptimeTokenId, closeGate)
3918
4484
  name: PLATFORM_ARCHON_ACCESS_APP_NAME,
3919
4485
  domain,
3920
4486
  type: 'self_hosted',
4487
+ allowedIdps: [pinId],
4488
+ autoRedirectToIdentity: true,
3921
4489
  });
3922
4490
  await cf.createAccessPolicy(created.id, archonAccessPolicy(emails));
3923
4491
  if (uptimeTokenId) {
@@ -3927,7 +4495,7 @@ async function ensureArchonAccess(cf, domain, emails, uptimeTokenId, closeGate)
3927
4495
  };
3928
4496
  const existing = onDomain[0];
3929
4497
  if (!existing)
3930
- return (await create()).id;
4498
+ return { id: (await create()).id, pinId };
3931
4499
  const policies = await cf.listAccessPolicies(existing.id);
3932
4500
  const other = policies.find((p) => p.name !== ACCESS_POLICY_NAME && p.name !== PLATFORM_ARCHON_UPTIME_POLICY_NAME);
3933
4501
  if (other) {
@@ -3936,19 +4504,52 @@ async function ensureArchonAccess(cf, domain, emails, uptimeTokenId, closeGate)
3936
4504
  const emailPolicies = policies.filter((p) => p.name === ACCESS_POLICY_NAME);
3937
4505
  const tokenPolicies = policies.filter((p) => p.name === PLATFORM_ARCHON_UPTIME_POLICY_NAME);
3938
4506
  const staleToken = tokenPolicies.some((p) => !uptimePolicyIntact(p, uptimeTokenId));
3939
- if (emailPolicies.every((p) => sameEmails(p.include, emails)) && !staleToken) {
4507
+ if (emailPolicies.every((p) => sameEmails(p.include, emails)) &&
4508
+ !staleToken &&
4509
+ accessAppPinned(existing, pinId)) {
3940
4510
  if (emailPolicies.length === 0) {
3941
4511
  await cf.createAccessPolicy(existing.id, archonAccessPolicy(emails));
3942
4512
  }
3943
4513
  if (uptimeTokenId && tokenPolicies.length === 0) {
3944
4514
  await cf.createAccessPolicy(existing.id, archonUptimePolicy(uptimeTokenId));
3945
4515
  }
3946
- return existing.id;
4516
+ return { id: existing.id, pinId };
3947
4517
  }
3948
- // No call updates an app's policy: rebuild the app, hostname closed meanwhile.
4518
+ // No call updates an app's policy or its providers (edge keeps an existing app's
4519
+ // `allowedIdps`): rebuild the app, hostname closed meanwhile.
3949
4520
  await closeGate();
3950
4521
  await cf.deleteAccessApp(existing.id);
3951
- return (await create()).id;
4522
+ return { id: (await create()).id, pinId };
4523
+ }
4524
+ /** Whether the Access app accepts the one-time PIN provider alone and sends a visitor straight to it. */
4525
+ function accessAppPinned(app, pinId) {
4526
+ return (Array.isArray(app.allowedIdps) &&
4527
+ app.allowedIdps.length === 1 &&
4528
+ app.allowedIdps[0] === pinId &&
4529
+ app.autoRedirectToIdentity === true);
4530
+ }
4531
+ /**
4532
+ * Who Archon's Access app lets through. Each person is let in on the personal
4533
+ * address their invite was mailed to, never on a work address: the work
4534
+ * mailbox is on the platform's own mail, which the platform's own sign-in
4535
+ * reaches. So the owner company's first owner's work addresses leave the host's
4536
+ * list and their personal address joins it; every other address stays as the
4537
+ * host gave it. Case-insensitive, de-duplicated, order kept.
4538
+ */
4539
+ export function archonAccessEmails(hostEmails, ownerCompany) {
4540
+ const first = ownerCompany?.owners[0];
4541
+ // The first owner's work address: the one their Archon invite is made for.
4542
+ const firstWork = ownerCompany && first ? ownerMailboxes(ownerCompany)[0]?.address : null;
4543
+ const work = new Set(firstWork ? [firstWork.toLowerCase()] : []);
4544
+ const out = [];
4545
+ const seen = new Set();
4546
+ for (const email of [...hostEmails.filter((e) => !work.has(e.toLowerCase())), first?.email]) {
4547
+ if (!email || seen.has(email.toLowerCase()))
4548
+ continue;
4549
+ seen.add(email.toLowerCase());
4550
+ out.push(email);
4551
+ }
4552
+ return out;
3952
4553
  }
3953
4554
  function archonAccessPolicy(emails) {
3954
4555
  return {
@@ -4006,6 +4607,10 @@ async function archonAccessDrifted(deps, recorded, domain, emails, uptimeTokenId
4006
4607
  const app = (await cf.listAccessApps()).find((a) => a.domain === domain && a.name === PLATFORM_ARCHON_ACCESS_APP_NAME);
4007
4608
  if (!app)
4008
4609
  return true;
4610
+ // Read against the provider id the app was last made with: a plan asks edge to make nothing.
4611
+ const pinId = recorded[PLATFORM_ARCHON_ACCESS_PIN_SCOPE_KEY];
4612
+ if (!pinId || !accessAppPinned(app, pinId))
4613
+ return true;
4009
4614
  const policies = await cf.listAccessPolicies(app.id);
4010
4615
  const emailPolicies = policies.filter((p) => p.name === ACCESS_POLICY_NAME);
4011
4616
  const tokenPolicies = policies.filter((p) => p.name === PLATFORM_ARCHON_UPTIME_POLICY_NAME);
@@ -4016,7 +4621,15 @@ async function archonAccessDrifted(deps, recorded, domain, emails, uptimeTokenId
4016
4621
  }
4017
4622
  if (!tokenPolicies.every((p) => uptimePolicyIntact(p, uptimeTokenId)))
4018
4623
  return true;
4019
- return uptimeTokenId !== undefined && tokenPolicies.length === 0;
4624
+ if (uptimeTokenId !== undefined && tokenPolicies.length === 0)
4625
+ return true;
4626
+ const apps = await cf.listAccessApps();
4627
+ for (const { name, path } of PLATFORM_ARCHON_PUBLIC_PATHS) {
4628
+ const bypass = apps.find((a) => a.domain === `${domain}${path}` && a.name === archonPublicAppName(name));
4629
+ if (!bypass || !onlyBypassEveryone(await cf.listAccessPolicies(bypass.id)))
4630
+ return true;
4631
+ }
4632
+ return false;
4020
4633
  }
4021
4634
  catch {
4022
4635
  return false;
@@ -4036,19 +4649,43 @@ async function archonAccessDrifted(deps, recorded, domain, emails, uptimeTokenId
4036
4649
  * recorded) is not drift either -- `planArchon`'s other checks already cover
4037
4650
  * a step that has never applied.
4038
4651
  */
4039
- async function archonBrokerRegistrationDrifted(coolify, recordedIds) {
4040
- const brokerAppId = recordedIds[PLATFORM_AUTH_BROKER_SCOPE_KEY];
4041
- const orgId = archonAuthOrganizationId(recordedIds);
4042
- if (!brokerAppId || !orgId)
4652
+ async function archonBrokerRegistrationDrifted(coolify, signIn, archonOrgId, recorded = {}) {
4653
+ if (!signIn || !archonOrgId)
4043
4654
  return false;
4044
4655
  try {
4045
- const apps = await readBrokerApps(coolify, brokerAppId);
4046
- return !apps.some((a) => a.organizationId === orgId && a.name === PLATFORM_ARCHON_ZITADEL_APP);
4656
+ const apps = await readBrokerApps(coolify, signIn.brokerAppId);
4657
+ const own = apps.some((a) => a.organizationId === archonOrgId && a.name === PLATFORM_ARCHON_ZITADEL_APP);
4658
+ // The company's old entry for Archon must be gone, and no other entry may sign in Archon's org.
4659
+ const oldOrgs = archonOldOrgIds(recorded, signIn.organisationId, archonOrgId);
4660
+ const stale = apps.some((a) => a.name === PLATFORM_ARCHON_ZITADEL_APP && oldOrgs.has(a.organizationId));
4661
+ return !own || stale || archonOrgConflicts(apps, archonOrgId).length > 0;
4047
4662
  }
4048
4663
  catch {
4049
4664
  return false;
4050
4665
  }
4051
4666
  }
4667
+ /** The organisations Archon signed in from before its own: the company's, and the one its last apply recorded. */
4668
+ function archonOldOrgIds(recorded, ciOrgId, archonOrgId) {
4669
+ const old = new Set([ciOrgId]);
4670
+ const applied = recorded[PLATFORM_ARCHON_APPLIED_AUTH_ORG_SCOPE_KEY];
4671
+ if (applied)
4672
+ old.add(applied);
4673
+ if (archonOrgId)
4674
+ old.delete(archonOrgId);
4675
+ return old;
4676
+ }
4677
+ /**
4678
+ * The names of the broker entries that sign in Archon's organisation without
4679
+ * being Archon's own. There must be none: a key of another app would then
4680
+ * reach Archon's people (passkeys, password reset, deactivation).
4681
+ */
4682
+ export function archonOrgConflicts(entries, archonOrgId) {
4683
+ if (!archonOrgId)
4684
+ return [];
4685
+ return entries
4686
+ .filter((e) => e.organizationId === archonOrgId && e.name !== PLATFORM_ARCHON_ZITADEL_APP)
4687
+ .map((e) => e.name.slice(0, 100));
4688
+ }
4052
4689
  /** The decoded password of a connection URL, or `undefined` when it has none or cannot be parsed. */
4053
4690
  function passwordOf(url) {
4054
4691
  try {
@@ -4181,17 +4818,31 @@ async function archonAuthzDatabaseDrifted(coolify, appId, owner) {
4181
4818
  }
4182
4819
  }
4183
4820
  /**
4184
- * Whether Archon's live `AUTH_ORGANIZATION_ID` is not the sign-in org it
4185
- * should accept ({@link archonAuthOrganizationId}). Without this nothing
4186
- * replans Archon when the owner company's org changes, since the step plans
4187
- * only on hostname, `gitRef` and the Access list. A failed read is not drift.
4821
+ * Whether one of Archon's live sign-in envs is not what it should be:
4822
+ * `AUTH_ORGANIZATION_ID` (Archon's own organisation), `CI_ORGANIZATION_ID` (the
4823
+ * company's) and `ARCHON_FIRST_OWNER_LINK` (absent when no link is wanted).
4824
+ * A name with no expected value is not checked, except the link. Without this
4825
+ * nothing replans Archon when an org changes behind its back. A failed read is
4826
+ * not drift.
4188
4827
  */
4189
- async function archonAuthOrgDrifted(coolify, appId, recordedIds) {
4190
- const expected = archonAuthOrganizationId(recordedIds);
4191
- if (!appId || !expected)
4828
+ async function archonAuthOrgDrifted(coolify, appId, expected) {
4829
+ if (!appId)
4192
4830
  return false;
4193
4831
  try {
4194
- return (await currentEnvValue(coolify, appId, 'AUTH_ORGANIZATION_ID')) !== expected;
4832
+ for (const [key, want] of Object.entries(expected)) {
4833
+ // A value expected as absent (an empty link) must be absent too; the key itself is never named in a value.
4834
+ if (key === 'ARCHON_FIRST_OWNER_LINK') {
4835
+ if (((await currentEnvValue(coolify, appId, key)) || undefined) !== (want || undefined)) {
4836
+ return true;
4837
+ }
4838
+ continue;
4839
+ }
4840
+ if (!want)
4841
+ continue;
4842
+ if ((await currentEnvValue(coolify, appId, key)) !== want)
4843
+ return true;
4844
+ }
4845
+ return false;
4195
4846
  }
4196
4847
  catch {
4197
4848
  return false;
@@ -4274,13 +4925,15 @@ async function archonRollingUpdateDrifted(coolify, appId) {
4274
4925
  * held-key ref, never a value. Also read at plan time, so the text must not
4275
4926
  * vary between calls.
4276
4927
  */
4277
- async function collectArchonServiceInputs(platform, deps, recorded) {
4928
+ async function collectArchonServiceInputs(platform, deps, recorded, ownerFiles = null) {
4278
4929
  const block = (why) => ({
4279
4930
  blocked: `Archon is blocked, not planned: ${why}`,
4280
4931
  });
4281
4932
  const envs = [];
4282
4933
  const heldByRef = new Map();
4283
- const services = ['files', 'ai', 'integrations', 'jobs'].filter((s) => platform[s].enabled);
4934
+ // Files is the owner company's own host, not a platform service: its env is
4935
+ // added once that step has applied, and Archon starts without it until then.
4936
+ const services = ['ai', 'integrations', 'jobs'].filter((s) => platform[s].enabled);
4284
4937
  if (services.length > 0 && !deps.heldKeys) {
4285
4938
  return block(`the host built the platform executor without a held-key reader, and ${services.join(', ')} ${services.length === 1 ? 'is' : 'are'} enabled`);
4286
4939
  }
@@ -4333,31 +4986,7 @@ async function collectArchonServiceInputs(platform, deps, recorded) {
4333
4986
  }
4334
4987
  const held = (ref) => heldByRef.get(ref);
4335
4988
  for (const service of services) {
4336
- if (service === 'files') {
4337
- envs.push(
4338
- // #501: Files is private and has no DNS, so FILES_URL is the recorded
4339
- // internal address; FILES_AUDIENCE is the exact HTTPS origin the
4340
- // signature is made for, equal to Files' own `APP_URL`.
4341
- { key: 'FILES_URL', value: recorded[PLATFORM_FILES_INTERNAL_URL_SCOPE_KEY] }, { key: 'FILES_AUDIENCE', value: `https://${platform.files.hostname}` },
4342
- // #436: Company Included's own Files organisation, provisioned by
4343
- // the ordinary per-org `planFilesOrganisation` step under its own
4344
- // org id -- `executor.ts`'s `filesOrganisation` calls
4345
- // `provisionOrganisation({ organisationId: org.id, ... })`, so
4346
- // `platform.ownerOrgId` is already that organisation's id, with no
4347
- // separate platform-level mint needed.
4348
- { key: 'FILES_ORGANISATION_ID', value: platform.ownerOrgId },
4349
- // #436: the signed-assertion identity `filesSigner()` reads
4350
- // (`archon`'s `src/lib/authz/files-management.ts`), not the bearer
4351
- // token `FILES_MANAGEMENT_KEY` used to name here -- that name made
4352
- // Archon take the issued-key branch of `filesManagementFetch`,
4353
- // which then failed on the `FILES_MANAGEMENT_SCOPE` this step never
4354
- // set, surfacing as "Files is not configured" (foundry#436).
4355
- { key: 'FILES_MANAGEMENT_ISSUER', value: FILES_MANAGEMENT_ISSUER }, { key: 'FILES_MANAGEMENT_KEY_ID', value: FILES_MANAGEMENT_KEY_ID }, {
4356
- key: 'FILES_MANAGEMENT_PRIVATE_KEY',
4357
- value: held(ARCHON_SERVICE_INPUTS.files.refs[0]),
4358
- });
4359
- }
4360
- else if (service === 'ai') {
4989
+ if (service === 'ai') {
4361
4990
  envs.push({
4362
4991
  key: 'AI_URL',
4363
4992
  value: recorded[PLATFORM_AI_INTERNAL_URL_SCOPE_KEY],
@@ -4393,6 +5022,22 @@ async function collectArchonServiceInputs(platform, deps, recorded) {
4393
5022
  { key: 'JOBS_URL', value: recorded[JOBS_INTERNAL_URL_SCOPE_KEY] }, { key: 'JOBS_ADMIN_TOKEN', value: held(JOBS_ADMIN_TOKEN_REF) });
4394
5023
  }
4395
5024
  }
5025
+ if (ownerFiles) {
5026
+ let key = null;
5027
+ try {
5028
+ key = (await deps.heldKeys?.resolve(ownerFiles.managementKeyRef)) ?? null;
5029
+ }
5030
+ catch {
5031
+ // The cause is dropped in case it echoes a secret.
5032
+ return block(`the held key ${ownerFiles.managementKeyRef} could not be read`);
5033
+ }
5034
+ if (!key)
5035
+ return block(`the held key ${ownerFiles.managementKeyRef} is not held yet`);
5036
+ envs.push(
5037
+ // Files is private and has no DNS, so FILES_URL is the recorded internal
5038
+ // address; FILES_AUDIENCE is the exact HTTPS origin the signature is for.
5039
+ { key: 'FILES_URL', value: ownerFiles.internalUrl }, { key: 'FILES_AUDIENCE', value: ownerFiles.audience }, { key: 'FILES_ORGANISATION_ID', value: ownerFiles.organisationId }, { key: 'FILES_MANAGEMENT_ISSUER', value: FILES_MANAGEMENT_ISSUER }, { key: 'FILES_MANAGEMENT_KEY_ID', value: FILES_MANAGEMENT_KEY_ID }, { key: 'FILES_MANAGEMENT_PRIVATE_KEY', value: key });
5040
+ }
4396
5041
  return { envs };
4397
5042
  }
4398
5043
  /**
@@ -4534,8 +5179,6 @@ function parseAiKeyMintedBody(body) {
4534
5179
  secret: record.secret,
4535
5180
  };
4536
5181
  }
4537
- /** The ref the AI management key is delivered under; the host's `AiOrganisationPort` adapter reads it back. */
4538
- export const PLATFORM_AI_MANAGEMENT_KEY_REF = 'ai:platform:managementKey';
4539
5182
  /**
4540
5183
  * What Archon's management key may do in ai: exactly `ai`'s own
4541
5184
  * `ARCHON_AI_PERMISSIONS` (`apps/host/scripts/authz/bootstrap-archon.ts`) --
@@ -4884,13 +5527,15 @@ async function provisionPlatformAi(context, deps) {
4884
5527
  // Every run, so a changed gitRef reaches an app that already exists.
4885
5528
  git_branch: gitRef,
4886
5529
  };
4887
- // #543: the health-check quartet too, runner only -- the worker exposes
4888
- // no port at all (`ports_exposes` is never set on it, just below), so
4889
- // there is nothing for Coolify's own container probe to reach.
5530
+ // #543: the health-check quartet too, on the runner. The worker exposes no
5531
+ // port (`ports_exposes` is never set on it, just below); its Dockerfile's
5532
+ // HEALTHCHECK checks the age of the heartbeat file it writes, and that is the live one.
4890
5533
  if (spec === PLATFORM_AI_RUNNER) {
4891
5534
  patch.ports_exposes = PLATFORM_AI_PORT;
4892
5535
  Object.assign(patch, healthCheckSettings('/api/health', Number(PLATFORM_AI_PORT)));
4893
5536
  }
5537
+ if (spec === PLATFORM_AI_WORKER)
5538
+ Object.assign(patch, IMAGE_HEALTHCHECK_SETTINGS);
4894
5539
  await coolify.updateApplication(app.appId, patch);
4895
5540
  return app;
4896
5541
  };
@@ -4991,7 +5636,8 @@ async function provisionPlatformAi(context, deps) {
4991
5636
  // nothing public is wired to either app.
4992
5637
  const internalUrl = `http://${PLATFORM_AI_RUNNER.internalName}:${PLATFORM_AI_PORT}`;
4993
5638
  if (deployRunner) {
4994
- await waitForNewDeployment(coolify, runner.appId, { kind: 'ready', url: `${internalUrl}/api/health` }, runtime, sleep, 'platform:ai', `the runner stays at ${recorded[PLATFORM_AI_APPLIED_GITREF_SCOPE_KEY] ?? 'no recorded ref'}`);
5639
+ const built = await waitForNewDeployment(coolify, runner.appId, { kind: 'ready', url: `${internalUrl}/api/health` }, runtime, sleep, 'platform:ai', `the runner stays at ${recorded[PLATFORM_AI_APPLIED_GITREF_SCOPE_KEY] ?? 'no recorded ref'}`);
5640
+ await recordBuiltCommit(coolify, { appId: runner.appId, deploymentUuid: built.deploymentUuid, name: 'ai' }, record);
4995
5641
  }
4996
5642
  if (deployWorker) {
4997
5643
  await waitForNewDeployment(coolify, worker.appId, { kind: 'deployment-only' }, runtime, sleep, 'platform:ai', `the worker stays at ${recorded[PLATFORM_AI_APPLIED_GITREF_SCOPE_KEY] ?? 'no recorded ref'}`);
@@ -5077,6 +5723,54 @@ async function provisionPlatformAi(context, deps) {
5077
5723
  }
5078
5724
  await record(PLATFORM_AI_MANAGEMENT_KEY_SCOPE_KEY, credentialId);
5079
5725
  }
5726
+ // ---- the public route ------------------------------------------------------
5727
+ // A company's entries run on their own network and cannot dial `platform-ai`, so they call
5728
+ // `https://<hostname>`. The route is the heartbeat server's (`platform:hc`): a tunnel that dials the
5729
+ // runner by its internal name, a connector app, and a proxied CNAME. No Coolify `domains`, no Access
5730
+ // app: ai authenticates every caller by its bearer key, and a gate in front would stop that.
5731
+ const tunnels = await platformTunnels(deps, context.recordedIds(), 'platform:ai');
5732
+ const tunnel = (await tunnels.findTunnelByName(AI_TUNNEL_NAME)) ??
5733
+ (await tunnels.createTunnel(AI_TUNNEL_NAME));
5734
+ // Recorded last, once the DNS record is written: planAi and aiUrl read it as "the route is complete",
5735
+ // so a failure below is planned again and no entry is given a dead AI_URL. The tunnel is found by name on the rerun.
5736
+ await tunnels.putTunnelConfig(tunnel.id, {
5737
+ ingress: [
5738
+ { hostname, path: AI_ENTRY_PATH_PATTERN, service: internalUrl },
5739
+ { service: 'http_status:404' },
5740
+ ],
5741
+ });
5742
+ const server = await resolvePlatformServer(coolify);
5743
+ await ensureCloudflaredConnector({
5744
+ coolify,
5745
+ tunnels,
5746
+ tunnelId: tunnel.id,
5747
+ appName: AI_CLOUDFLARED_APP_NAME,
5748
+ projectUuid,
5749
+ environment,
5750
+ resolveServer: async () => server,
5751
+ recordedConnectorAppId: context.recordedIds()[PLATFORM_AI_CONNECTOR_APP_SCOPE_KEY] ?? null,
5752
+ recordAppId: (connectorAppId) => record(PLATFORM_AI_CONNECTOR_APP_SCOPE_KEY, connectorAppId),
5753
+ });
5754
+ const dns = await platformDns(deps, context.recordedIds(), 'platform:ai');
5755
+ const dnsTarget = `${tunnel.id}.cfargotunnel.com`;
5756
+ const [dnsRecord] = await dns.findDnsRecord(platform.apex, hostname, 'CNAME');
5757
+ if (!dnsRecord) {
5758
+ await dns.createDnsRecord(platform.apex, {
5759
+ type: 'CNAME',
5760
+ name: hostname,
5761
+ content: dnsTarget,
5762
+ proxied: true,
5763
+ });
5764
+ }
5765
+ else if (dnsRecord.content !== dnsTarget) {
5766
+ await dns.updateDnsRecord(platform.apex, dnsRecord.id, {
5767
+ type: 'CNAME',
5768
+ name: hostname,
5769
+ content: dnsTarget,
5770
+ proxied: true,
5771
+ });
5772
+ }
5773
+ await record(PLATFORM_AI_TUNNEL_SCOPE_KEY, tunnel.id);
5080
5774
  return { resourceId: runner.appId };
5081
5775
  }
5082
5776
  /**
@@ -5305,7 +5999,7 @@ async function missingEnvs(coolify, appId, defaults) {
5305
5999
  }
5306
6000
  const defaultSleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
5307
6001
  /** Polls `url` until it answers 2xx, or throws once `readyTimeoutMs` has passed. */
5308
- async function waitForInstance(url, runtime, sleep) {
6002
+ async function waitForInstance(url, runtime, sleep, label = 'platform:auth') {
5309
6003
  const fetchImpl = runtime.fetch ?? globalThis.fetch;
5310
6004
  const now = runtime.now ?? Date.now;
5311
6005
  const timeout = runtime.readyTimeoutMs ?? 300_000;
@@ -5313,7 +6007,9 @@ async function waitForInstance(url, runtime, sleep) {
5313
6007
  const deadline = now() + timeout;
5314
6008
  for (;;) {
5315
6009
  try {
5316
- const response = await fetchImpl(url);
6010
+ const response = await fetchImpl(url, {
6011
+ signal: AbortSignal.timeout(runtime.readyRequestTimeoutMs ?? 10_000),
6012
+ });
5317
6013
  if (response.ok)
5318
6014
  return;
5319
6015
  }
@@ -5321,7 +6017,7 @@ async function waitForInstance(url, runtime, sleep) {
5321
6017
  // Not up yet: DNS, the tunnel or the container.
5322
6018
  }
5323
6019
  if (now() >= deadline) {
5324
- throw new Error(`platform:auth: the new ZITADEL instance did not answer at ${url} within ${Math.round(timeout / 1000)}s; retry this apply`);
6020
+ throw new Error(`${label}: the new ZITADEL instance did not answer at ${url} within ${Math.round(timeout / 1000)}s; retry this apply`);
5325
6021
  }
5326
6022
  await sleep(interval);
5327
6023
  }
@@ -5547,12 +6243,12 @@ async function currentEnvValue(coolify, appId, key) {
5547
6243
  * deployed app means someone removed it. This throws, and a person sets the key that database was
5548
6244
  * initialised with (or deletes an unused database) and applies again.
5549
6245
  */
5550
- async function ensureMasterkey(coolify, appId, databaseInUse) {
6246
+ async function ensureMasterkey(coolify, appId, databaseInUse, label = 'platform:auth') {
5551
6247
  const envs = await coolify.listEnvs(appId);
5552
6248
  if (envs.some((e) => e.key === 'ZITADEL_MASTERKEY' && !e.is_preview))
5553
6249
  return null;
5554
6250
  if (databaseInUse) {
5555
- throw new Error(`platform:auth: app ${appId} has no ZITADEL_MASTERKEY but its database is already in use; not generating a new one. Set ZITADEL_MASTERKEY on that app to the key the database was initialised with, or delete the database if ZITADEL never ran on it, then apply again`);
6251
+ throw new Error(`${label}: app ${appId} has no ZITADEL_MASTERKEY but its database is already in use; not generating a new one. Set ZITADEL_MASTERKEY on that app to the key the database was initialised with, or delete the database if ZITADEL never ran on it, then apply again`);
5556
6252
  }
5557
6253
  return randomBytes(24).toString('base64');
5558
6254
  }
@@ -5568,23 +6264,21 @@ async function ensureMasterkey(coolify, appId, databaseInUse) {
5568
6264
  */
5569
6265
  export const FILES_MANAGEMENT_ISSUER = 'foundry';
5570
6266
  export const FILES_MANAGEMENT_KEY_ID = 'foundry-1';
5571
- /** The ref the management private key (PKCS#8 PEM) is delivered under. */
5572
- export const FILES_MANAGEMENT_KEY_REF = 'files:platform:managementKey';
5573
6267
  /** The ref the files host's introspection client secret is delivered under. */
5574
6268
  export const FILES_INTROSPECTION_SECRET_REF = 'zitadel:platform:files:introspection';
5575
6269
  /** https://github.com/wtfalch/files -- root Dockerfile with `migration` and `runner` targets. */
5576
6270
  const FILES_REPO = 'wtfalch/files';
5577
6271
  const PLATFORM_FILES_PROJECT = 'platform';
5578
- const PLATFORM_FILES_DB_NAME = 'platform-files-db';
6272
+ export const PLATFORM_FILES_DB_NAME = 'platform-files-db';
5579
6273
  /** The two Coolify apps files' `apps/host/README.md` ("Deploying on Coolify") names. */
5580
- const FILES_MIGRATE_APP_NAME = 'files-migrate';
5581
- const FILES_RUNNER_APP_NAME = 'files';
6274
+ export const FILES_MIGRATE_APP_NAME = 'files-migrate';
6275
+ export const FILES_RUNNER_APP_NAME = 'files';
5582
6276
  /**
5583
6277
  * The runner's fixed `custom_internal_name`: the name another container on
5584
6278
  * the Coolify network reaches it by, and so the host part of
5585
6279
  * {@link PLATFORM_FILES_INTERNAL_URL_SCOPE_KEY}'s recorded address.
5586
6280
  */
5587
- const FILES_RUNNER_INTERNAL_NAME = 'platform-files';
6281
+ export const FILES_RUNNER_INTERNAL_NAME = 'platform-files';
5588
6282
  /** The runner's port (`Dockerfile`'s `runner` stage: `PORT=3000`, `EXPOSE 3000`). */
5589
6283
  const FILES_PORT = '3000';
5590
6284
  /** files' own health route, the same one `healthUrls` (`executor.ts`) assumes every hosted app serves. */
@@ -5681,22 +6375,106 @@ async function provisionPlatformFiles(context, deps,
5681
6375
  /** {@link filesShareDomains} of every declared org: each is a runner domain and a public host. */
5682
6376
  shareDomains) {
5683
6377
  const { platform } = context;
5684
- const gitRef = platform.files.gitRef;
5685
- const recorded = context.recordedIds();
6378
+ return provisionFilesHost(scopeFromPlatform(context), deps, platformTarget(), {
6379
+ hostname: platform.files.hostname,
6380
+ gitRef: platform.files.gitRef,
6381
+ project: platformFilesProject,
6382
+ issuer: `https://${platform.auth.hostname}`,
6383
+ zitadelOrg: { name: PLATFORM_AUTH_ORG_NAME },
6384
+ zitadelProject: {
6385
+ name: FILES_ZITADEL_PROJECT_NAME,
6386
+ scopeKey: PLATFORM_FILES_ZITADEL_PROJECT_SCOPE_KEY,
6387
+ },
6388
+ shareDomains,
6389
+ });
6390
+ }
6391
+ /** The platform's files host lives in the shared `platform` project. */
6392
+ const platformFilesProject = {
6393
+ async ensure(coolify, scope) {
6394
+ const recorded = scope.recordedIds()[PLATFORM_FILES_PROJECT_SCOPE_KEY];
6395
+ if (recorded)
6396
+ return recorded;
6397
+ const found = await coolify.findProjectByName(PLATFORM_FILES_PROJECT);
6398
+ const projectUuid = found
6399
+ ? found.uuid
6400
+ : (await coolify.createProject(PLATFORM_FILES_PROJECT)).uuid;
6401
+ scope.recordScopeId(PLATFORM_FILES_PROJECT_SCOPE_KEY, projectUuid);
6402
+ await scope.persist();
6403
+ return projectUuid;
6404
+ },
6405
+ };
6406
+ /** The credential id of the keyring an own files host runs on; recorded once both apps carry it. */
6407
+ export const OWN_FILES_KEYS_SCOPE_KEY = 'files_keys_id';
6408
+ /** The credential id minted and not yet confirmed delivered. */
6409
+ export const OWN_FILES_KEYS_UNDELIVERED_SCOPE_KEY = 'files_keys_undelivered';
6410
+ /**
6411
+ * An own files host's keyring, before its env writes. Both refs held: those values, no mint (a rerun
6412
+ * after a failed run resumes here). Nothing held: one mint, which is checked, registered as a
6413
+ * secret, recorded by id as undelivered, then delivered to both refs. A re-mint is harmless: it
6414
+ * yields an equivalent credential. Returns the guard id for {@link FilesHostInput.keysGuardId}.
6415
+ */
6416
+ export async function resolveOwnFilesKeys(scope, deps, target, mint, workerUrl, label) {
6417
+ const runtime = deps.runtime ?? {};
6418
+ const recorded = scope.recordedIds();
6419
+ const credential = await deps.readSecret?.(target.refs.keysCredential);
6420
+ const verifyKeys = await deps.readSecret?.(target.refs.keysVerifyKeys);
6421
+ if (credential && verifyKeys) {
6422
+ runtime.onSecret?.(credential);
6423
+ return {
6424
+ keys: { workerUrl, credential, verifyKeys },
6425
+ guardId: recorded[OWN_FILES_KEYS_SCOPE_KEY] ||
6426
+ recorded[OWN_FILES_KEYS_UNDELIVERED_SCOPE_KEY] ||
6427
+ 'held',
6428
+ };
6429
+ }
6430
+ const answer = await mint();
6431
+ runtime.onSecret?.(answer.credential);
6432
+ const keys = { workerUrl, credential: answer.credential, verifyKeys: answer.verifyKeys };
6433
+ const invalid = answer.credential
6434
+ ? filesDepsInvalid({ keys })
6435
+ : 'credential';
6436
+ if (invalid)
6437
+ throw new Error(`${label}: the keys worker's answer is not usable (${invalid})`);
6438
+ // Recorded and persisted before the credential leaves this function.
6439
+ scope.recordScopeId(OWN_FILES_KEYS_UNDELIVERED_SCOPE_KEY, answer.credentialId);
6440
+ await scope.persist();
6441
+ const attempts = runtime.deliverAttempts ?? 3;
6442
+ const sleep = runtime.sleep ?? defaultSleep;
6443
+ await deliverWithRetry(deps.deliverSecret, target.refs.keysCredential, answer.credential, attempts, sleep, `the keys credential (${answer.credentialId})`, label);
6444
+ await deliverWithRetry(deps.deliverSecret, target.refs.keysVerifyKeys, answer.verifyKeys, attempts, sleep, `the verify keys of credential ${answer.credentialId}`, label);
6445
+ return { keys, guardId: answer.credentialId };
6446
+ }
6447
+ /**
6448
+ * The body of {@link provisionPlatformFiles} for any files host: the
6449
+ * platform's, or an own org's. Every name, the internal name and the two
6450
+ * held-key refs come from `target`; the project, sign-in and ZITADEL home
6451
+ * come from `input`. See {@link provisionPlatformFiles} for what the step does.
6452
+ */
6453
+ export async function provisionFilesHost(scope, deps, target, input) {
6454
+ const { gitRef, shareDomains } = input;
6455
+ const own = target.kind === 'org';
6456
+ const label = own ? `own step coolify:own-files for ${target.id}` : 'platform:files';
6457
+ const recorded = scope.recordedIds();
5686
6458
  const runtime = deps.runtime ?? {};
5687
6459
  const sleep = runtime.sleep ?? defaultSleep;
5688
6460
  const attempts = runtime.deliverAttempts ?? 3;
5689
6461
  const keys = filesKeys(deps);
5690
6462
  if (!keys) {
5691
- throw new Error('platform:files needs the keys worker URL, credential and verify keys in its files dependencies (KEYS_WORKER_URL, KEYS_CREDENTIAL, KEYS_VERIFY_KEYS); files never becomes healthy without them');
5692
- }
6463
+ throw new Error(`${label} needs the keys worker URL, credential and verify keys in its files dependencies (KEYS_WORKER_URL, KEYS_CREDENTIAL, KEYS_VERIFY_KEYS); files never becomes healthy without them`);
6464
+ }
6465
+ runtime.onSecret?.(keys.credential);
6466
+ // The platform's audit token and jobs secrets never go to a company's host (SC4).
6467
+ const auditToken = own ? undefined : deps.keys?.auditToken;
6468
+ const jobsSigningSecrets = own ? undefined : deps.jobsSigningSecrets;
6469
+ if (auditToken)
6470
+ runtime.onSecret?.(auditToken);
5693
6471
  // #380: R2_BUCKET/R2_ENDPOINT, the same "block the whole step" treatment
5694
6472
  // as a missing `keys` above -- the runner's `ensureStarted()` requires
5695
6473
  // both, and only this function's caller (`blockedSteps`, `planFiles`) ever
5696
6474
  // has anything useful to report while they are unset.
5697
6475
  const r2 = filesR2(deps);
5698
6476
  if (!r2) {
5699
- throw new Error('platform:files needs the R2 bucket and endpoint in its files dependencies (R2_BUCKET, R2_ENDPOINT); files never becomes healthy without them');
6477
+ throw new Error(`${label} needs the R2 bucket and endpoint in its files dependencies (R2_BUCKET, R2_ENDPOINT); files never becomes healthy without them`);
5700
6478
  }
5701
6479
  // Never plan-and-throw: the planner reports a lost secret and plans
5702
6480
  // nothing; this is the same rule for a caller that reaches the step anyway.
@@ -5706,28 +6484,21 @@ shareDomains) {
5706
6484
  ]) {
5707
6485
  const lost = recorded[undeliveredKey];
5708
6486
  if (lost && !recorded[guardKey]) {
5709
- throw new Error(`platform:files: a secret (${lost}) was minted but never delivered; remove the recorded key ${undeliveredKey} to mint a new one`);
6487
+ throw new Error(`${label}: a secret (${lost}) was minted but never delivered; remove the recorded key ${undeliveredKey} to mint a new one`);
5710
6488
  }
5711
6489
  }
5712
6490
  const record = async (key, id) => {
5713
- context.recordScopeId(key, id);
5714
- await context.persist?.();
6491
+ scope.recordScopeId(key, id);
6492
+ await scope.persist();
5715
6493
  };
5716
- let projectUuid = recorded[PLATFORM_FILES_PROJECT_SCOPE_KEY];
5717
- if (!projectUuid) {
5718
- const found = await deps.coolify.findProjectByName(PLATFORM_FILES_PROJECT);
5719
- projectUuid = found
5720
- ? found.uuid
5721
- : (await deps.coolify.createProject(PLATFORM_FILES_PROJECT)).uuid;
5722
- await record(PLATFORM_FILES_PROJECT_SCOPE_KEY, projectUuid);
5723
- }
6494
+ const projectUuid = await input.project.ensure(deps.coolify, scope);
5724
6495
  const { environment } = await deps.coolify.findOrCreateEnvironment(projectUuid, 'production');
5725
6496
  // ---- database ----------------------------------------------------------
5726
6497
  let dbId = recorded[PLATFORM_FILES_DB_SCOPE_KEY];
5727
6498
  if (!dbId) {
5728
6499
  // Found by name first, so a crash between create and record never makes
5729
6500
  // a second one (`coolifyJobs` does the same).
5730
- const found = (await deps.coolify.listDatabases()).find((db) => db.name === PLATFORM_FILES_DB_NAME && db.environment_id === environment.id);
6501
+ const found = (await deps.coolify.listDatabases()).find((db) => db.name === target.names.filesDb && db.environment_id === environment.id);
5731
6502
  if (found) {
5732
6503
  dbId = found.uuid;
5733
6504
  }
@@ -5738,7 +6509,7 @@ shareDomains) {
5738
6509
  server_uuid: server.uuid,
5739
6510
  environment_name: environment.name,
5740
6511
  environment_uuid: environment.uuid,
5741
- name: PLATFORM_FILES_DB_NAME,
6512
+ name: target.names.filesDb,
5742
6513
  postgres_db: FILES_DB,
5743
6514
  postgres_user: FILES_DB,
5744
6515
  });
@@ -5748,7 +6519,7 @@ shareDomains) {
5748
6519
  }
5749
6520
  const db = await getDatabaseStarted(deps.coolify, dbId);
5750
6521
  if (!db.internal_db_url) {
5751
- throw new Error('coolify:app for the platform (files): the database has no internal_db_url yet -- retry this apply once Coolify reports one');
6522
+ throw new Error(`${own ? label : 'coolify:app for the platform (files)'}: the database has no internal_db_url yet -- retry this apply once Coolify reports one`);
5752
6523
  }
5753
6524
  // ---- the two apps --------------------------------------------------------
5754
6525
  const createFilesApp = (name) => async () => {
@@ -5766,7 +6537,7 @@ shareDomains) {
5766
6537
  name,
5767
6538
  });
5768
6539
  };
5769
- const migrateApp = await findOrCreatePlatformApp(deps.coolify, FILES_MIGRATE_APP_NAME, environment, createFilesApp(FILES_MIGRATE_APP_NAME));
6540
+ const migrateApp = await findOrCreatePlatformApp(deps.coolify, target.names.filesMigrate, environment, createFilesApp(target.names.filesMigrate));
5770
6541
  await record(PLATFORM_FILES_MIGRATE_SCOPE_KEY, migrateApp.appId);
5771
6542
  // Set on every run, like `platform:auth`'s `custom_internal_name`: a throw
5772
6543
  // right after a create must not leave the app on the wrong target.
@@ -5775,11 +6546,11 @@ shareDomains) {
5775
6546
  dockerfile_target_build: 'migration',
5776
6547
  git_branch: gitRef,
5777
6548
  });
5778
- const runnerApp = await findOrCreatePlatformApp(deps.coolify, FILES_RUNNER_APP_NAME, environment, createFilesApp(FILES_RUNNER_APP_NAME));
6549
+ const runnerApp = await findOrCreatePlatformApp(deps.coolify, target.names.filesRunner, environment, createFilesApp(target.names.filesRunner));
5779
6550
  await record(PLATFORM_FILES_RUNNER_SCOPE_KEY, runnerApp.appId);
5780
6551
  await deps.coolify.updateApplication(runnerApp.appId, {
5781
6552
  dockerfile_target_build: 'runner',
5782
- custom_internal_name: FILES_RUNNER_INTERNAL_NAME,
6553
+ custom_internal_name: target.internal.files,
5783
6554
  git_branch: gitRef,
5784
6555
  // #543: the health-check quartet, same reason Archon's doc comment
5785
6556
  // gives. The migrate app gets none -- it idles rather than serves
@@ -5791,13 +6562,14 @@ shareDomains) {
5791
6562
  // nobody gave a share host keeps no `domains`.
5792
6563
  ...filesShareDomainsPatch(recorded, shareDomains),
5793
6564
  });
5794
- context.recordScopeId(PLATFORM_FILES_SHARE_DOMAINS_SCOPE_KEY, normalHosts(shareDomains).join(','));
6565
+ scope.recordScopeId(PLATFORM_FILES_SHARE_DOMAINS_SCOPE_KEY, normalHosts(shareDomains).join(','));
5795
6566
  // ---- database env ----------------------------------------------------------
5796
6567
  // The migrate app sets `files_rt`'s password from DATABASE_URL on every
5797
6568
  // boot (files' `scripts/db-runtime-role.sh`), and the runner connects with
5798
6569
  // the same URL, so both apps carry one value, minted once and never
5799
6570
  // rotated: an existing value on either app is reused.
5800
6571
  const runtimeUrl = await ensureFilesRuntimeUrl(deps.coolify, [migrateApp.appId, runnerApp.appId], db.internal_db_url);
6572
+ runtime.onSecret?.(runtimeUrl);
5801
6573
  // The keys worker the migrate app needs to become healthy (files' README:
5802
6574
  // `DATABASE_URL_OWNER`, `DATABASE_URL`, `KEYS_WORKER_URL`, `KEYS_CREDENTIAL`).
5803
6575
  // `files-migrate` never runs `ensureStarted()`, so it needs neither
@@ -5812,11 +6584,13 @@ shareDomains) {
5812
6584
  ...migrateKeysEnvs,
5813
6585
  ]);
5814
6586
  // ---- the files ZITADEL project, in the platform's sign-in ----------------
5815
- const issuer = `https://${platform.auth.hostname}`;
6587
+ const { issuer } = input;
5816
6588
  const zitadel = deps.zitadelForInstance?.(issuer) ?? deps.zitadel;
5817
- const { org } = await zitadel.findOrCreateOrg(PLATFORM_AUTH_ORG_NAME);
5818
- const { project } = await zitadel.findOrCreateProject(org.id, FILES_ZITADEL_PROJECT_NAME);
5819
- await record(PLATFORM_FILES_ZITADEL_PROJECT_SCOPE_KEY, project.id);
6589
+ const zitadelOrgId = 'id' in input.zitadelOrg
6590
+ ? input.zitadelOrg.id
6591
+ : (await zitadel.findOrCreateOrg(input.zitadelOrg.name)).org.id;
6592
+ const { project } = await zitadel.findOrCreateProject(zitadelOrgId, input.zitadelProject.name);
6593
+ await record(input.zitadelProject.scopeKey, project.id);
5820
6594
  // #380: the runner's own required settings -- `KEYS_VERIFY_KEYS` and
5821
6595
  // `R2_BUCKET`/`R2_ENDPOINT` -- plus whatever optional ones the host
5822
6596
  // supplies. `files-migrate` needs none of these (see `migrateKeysEnvs`
@@ -5825,10 +6599,10 @@ shareDomains) {
5825
6599
  ...migrateKeysEnvs,
5826
6600
  { key: 'KEYS_VERIFY_KEYS', value: keys.verifyKeys },
5827
6601
  ];
5828
- if (deps.keys?.auditToken)
6602
+ if (auditToken)
5829
6603
  runnerKeysEnvs.push({
5830
6604
  key: 'KEYS_AUDIT_TOKEN',
5831
- value: deps.keys.auditToken,
6605
+ value: auditToken,
5832
6606
  });
5833
6607
  const runnerOptionalEnvs = [];
5834
6608
  if (deps.clamd?.host) {
@@ -5843,13 +6617,16 @@ shareDomains) {
5843
6617
  });
5844
6618
  }
5845
6619
  runnerOptionalEnvs.push(...filesPublicHostsEnvs(recorded, deps.publicHosts, shareDomains));
5846
- if (deps.jobsSigningSecrets)
6620
+ if (jobsSigningSecrets)
5847
6621
  runnerOptionalEnvs.push({
5848
6622
  key: 'JOBS_SIGNING_SECRETS',
5849
- value: deps.jobsSigningSecrets,
6623
+ value: jobsSigningSecrets,
5850
6624
  });
5851
6625
  // #501: the same person platform:archon names; Files has no sign-in page.
5852
- const ownerInviteUserId = recorded[PLATFORM_AUTH_OWNER_INVITE_SCOPE_KEY];
6626
+ // An own org's files host gets the platform's owner too, carried in by the planner: its own scope holds none.
6627
+ const ownerInviteUserId = own
6628
+ ? input.bootstrapOperator
6629
+ : recorded[PLATFORM_AUTH_OWNER_INVITE_SCOPE_KEY];
5853
6630
  if (ownerInviteUserId && ownerInviteUserId !== 'none') {
5854
6631
  runnerOptionalEnvs.push({ key: 'AUTHZ_BOOTSTRAP_OPERATOR', value: ownerInviteUserId });
5855
6632
  }
@@ -5857,7 +6634,7 @@ shareDomains) {
5857
6634
  { key: 'DATABASE_URL', value: runtimeUrl },
5858
6635
  // The management verifier's audience, which must be an HTTPS origin. The
5859
6636
  // host has no public address, so this is a name, not a route.
5860
- { key: 'APP_URL', value: `https://${platform.files.hostname}` },
6637
+ { key: 'APP_URL', value: `https://${input.hostname}` },
5861
6638
  { key: 'AUTH_ISSUER', value: issuer },
5862
6639
  { key: 'AUTH_PERSON_TOKEN_AUDIENCE', value: project.id },
5863
6640
  ...runnerKeysEnvs,
@@ -5872,28 +6649,32 @@ shareDomains) {
5872
6649
  // Base env first, so a secret's own write below cannot be the only one
5873
6650
  // that lands, and a re-run with a changed hostname updates `APP_URL`.
5874
6651
  await deps.coolify.bulkUpsertEnvs(runnerApp.appId, runnerEnvs);
6652
+ // Both apps now carry the company's own keyring: the delivery is confirmed.
6653
+ if (own && input.keysGuardId)
6654
+ await record(OWN_FILES_KEYS_SCOPE_KEY, input.keysGuardId);
5875
6655
  // ---- the introspection client, once --------------------------------------
5876
6656
  if (!recorded[PLATFORM_FILES_INTROSPECTION_SCOPE_KEY]) {
5877
6657
  // An app by this name here has no recorded secret (no undelivered id, or
5878
6658
  // the throw above would have fired): a crash between the create and the
5879
6659
  // record. Nothing was delivered, so it is deleted and made again.
5880
- const stale = (await zitadel.listApps(org.id, project.id)).apps.filter((app) => app.name === FILES_INTROSPECTION_APP_NAME && app.state !== 'APP_STATE_REMOVED');
6660
+ const stale = (await zitadel.listApps(zitadelOrgId, project.id)).apps.filter((app) => app.name === FILES_INTROSPECTION_APP_NAME && app.state !== 'APP_STATE_REMOVED');
5881
6661
  for (const app of stale) {
5882
6662
  try {
5883
- await zitadel.deleteApp(org.id, project.id, app.id);
6663
+ await zitadel.deleteApp(zitadelOrgId, project.id, app.id);
5884
6664
  }
5885
6665
  catch (error) {
5886
6666
  if (!isNotFoundError(error))
5887
6667
  throw error;
5888
6668
  }
5889
6669
  }
5890
- const client = await zitadel.createApiApp(org.id, project.id, {
6670
+ const client = await zitadel.createApiApp(zitadelOrgId, project.id, {
5891
6671
  name: FILES_INTROSPECTION_APP_NAME,
5892
6672
  });
6673
+ runtime.onSecret?.(client.clientSecret);
5893
6674
  // Recorded and persisted before the secret leaves this function: a failed
5894
6675
  // delivery or env write is then reported by the planner, not remade.
5895
6676
  await record(PLATFORM_FILES_INTROSPECTION_UNDELIVERED_SCOPE_KEY, client.appId);
5896
- await deliverWithRetry(deps.deliverSecret, FILES_INTROSPECTION_SECRET_REF, client.clientSecret, attempts, sleep, `the introspection client secret (ZITADEL app ${client.appId})`);
6677
+ await deliverWithRetry(deps.deliverSecret, target.refs.filesIntrospection, client.clientSecret, attempts, sleep, `the introspection client secret (ZITADEL app ${client.appId})`, label);
5897
6678
  await deps.coolify.bulkUpsertEnvs(runnerApp.appId, [
5898
6679
  {
5899
6680
  key: 'AUTH_PERSON_TOKEN_INTROSPECTION_CLIENT_ID',
@@ -5912,8 +6693,9 @@ shareDomains) {
5912
6693
  publicKeyEncoding: { type: 'spki', format: 'pem' },
5913
6694
  privateKeyEncoding: { type: 'pkcs8', format: 'pem' },
5914
6695
  });
6696
+ runtime.onSecret?.(privateKey);
5915
6697
  await record(PLATFORM_FILES_MANAGEMENT_UNDELIVERED_SCOPE_KEY, FILES_MANAGEMENT_KEY_ID);
5916
- await deliverWithRetry(deps.deliverSecret, FILES_MANAGEMENT_KEY_REF, privateKey, attempts, sleep, 'the files management key');
6698
+ await deliverWithRetry(deps.deliverSecret, target.refs.filesManagement, privateKey, attempts, sleep, 'the files management key', label);
5917
6699
  await deps.coolify.bulkUpsertEnvs(runnerApp.appId, [
5918
6700
  { key: 'MANAGEMENT_ISSUER', value: FILES_MANAGEMENT_ISSUER },
5919
6701
  {
@@ -5929,12 +6711,12 @@ shareDomains) {
5929
6711
  // container before the new migration has run.
5930
6712
  await stopIfRunning(deps.coolify, migrateApp.appId);
5931
6713
  const migrateDeployed = await deps.coolify.deploy(migrateApp.appId);
5932
- await waitForMigration(deps.coolify, migrateApp.appId, runtime, sleep);
6714
+ await waitForMigration(deps.coolify, migrateApp.appId, runtime, sleep, label, target.names.filesMigrate);
5933
6715
  // #399: files-migrate idles rather than exits once it has migrated, so it
5934
6716
  // reads healthy well before Coolify's own deployment does; stopping it on
5935
6717
  // that signal alone can find the container already gone and make Coolify
5936
6718
  // mark a deployment that actually migrated as `failed`.
5937
- await waitForMigrateDeployment(deps.coolify, migrateApp.appId, queuedDeploymentUuid(migrateDeployed) ?? 'untracked', 'platform:files', 'files-migrate', runtime.now ?? Date.now, runtime.migrateTimeoutMs ?? 900_000, runtime.migrateIntervalMs ?? 10_000, sleep);
6719
+ await waitForMigrateDeployment(deps.coolify, migrateApp.appId, queuedDeploymentUuid(migrateDeployed) ?? 'untracked', label, target.names.filesMigrate, runtime.now ?? Date.now, runtime.migrateTimeoutMs ?? 900_000, runtime.migrateIntervalMs ?? 10_000, sleep);
5938
6720
  await deps.coolify.raw('POST', `/applications/${migrateApp.appId}/stop`);
5939
6721
  // #252/#543: no public `domains`, tunnel or DNS record is wired to the
5940
6722
  // runner (its own doc comment: "Nothing public is wired"), but it does
@@ -5945,11 +6727,12 @@ shareDomains) {
5945
6727
  // container); `coolify.deployAndWait` reaching Coolify's own deployment
5946
6728
  // record first is still what rules out the old container staying healthy
5947
6729
  // through a failed deploy.
5948
- const internalUrl = `http://${FILES_RUNNER_INTERNAL_NAME}:${FILES_PORT}`;
5949
- await waitForNewDeployment(deps.coolify, runnerApp.appId, { kind: 'ready', url: `${internalUrl}${FILES_HEALTH_CHECK_PATH}` }, runtime, sleep, 'platform:files', `the runner stays at ${recorded[PLATFORM_FILES_APPLIED_GIT_REF_SCOPE_KEY] ?? 'no recorded ref'}`);
6730
+ const internalUrl = `http://${target.internal.files}:${FILES_PORT}`;
6731
+ const built = await waitForNewDeployment(deps.coolify, runnerApp.appId, { kind: 'ready', url: `${internalUrl}${FILES_HEALTH_CHECK_PATH}` }, runtime, sleep, label, `the runner stays at ${recorded[PLATFORM_FILES_APPLIED_GIT_REF_SCOPE_KEY] ?? 'no recorded ref'}`);
6732
+ await recordBuiltCommit(deps.coolify, { appId: runnerApp.appId, deploymentUuid: built.deploymentUuid, name: 'files' }, record);
5950
6733
  await record(PLATFORM_FILES_INTERNAL_URL_SCOPE_KEY, internalUrl);
5951
6734
  // Last: a run that failed anywhere above is planned again.
5952
- context.recordScopeId(PLATFORM_FILES_APPLIED_HOSTNAME_SCOPE_KEY, platform.files.hostname);
6735
+ scope.recordScopeId(PLATFORM_FILES_APPLIED_HOSTNAME_SCOPE_KEY, input.hostname);
5953
6736
  await record(PLATFORM_FILES_APPLIED_GIT_REF_SCOPE_KEY, gitRef);
5954
6737
  return { resourceId: runnerApp.appId };
5955
6738
  }
@@ -6272,7 +7055,6 @@ const PLATFORM_ACCESS_PROVIDER_NAME = 'foundry';
6272
7055
  * again.
6273
7056
  */
6274
7057
  async function provisionPlatformAccessProvider(context, deps) {
6275
- const { platform } = context;
6276
7058
  const recorded = context.recordedIds();
6277
7059
  const sleep = deps.runtime?.sleep ?? defaultSleep;
6278
7060
  const attempts = deps.runtime?.deliverAttempts ?? 3;
@@ -6291,10 +7073,19 @@ async function provisionPlatformAccessProvider(context, deps) {
6291
7073
  catch {
6292
7074
  throw new Error(`${label}: edge could not confirm that Cloudflare Access is ready`);
6293
7075
  }
6294
- const issuer = `https://${platform.auth.hostname}`;
6295
- const zitadel = deps.zitadelForInstance?.(issuer) ?? deps.zitadel;
6296
- const { org } = await zitadel.findOrCreateOrg(PLATFORM_AUTH_ORG_NAME);
7076
+ // The client lives in the owner company's own ZITADEL, never a platform one.
7077
+ const ownerSignIn = context.owner?.signIn ?? null;
7078
+ if (!ownerSignIn) {
7079
+ throw new Error(`${label}: the owner company's sign-in has not been applied yet`);
7080
+ }
7081
+ if (!deps.zitadelForInstance) {
7082
+ throw new Error(`${label}: the host built the Access provider dependencies without zitadelForInstance, so it cannot reach the owner company's ZITADEL`);
7083
+ }
7084
+ const issuer = ownerSignIn.issuerUrl;
7085
+ const zitadel = deps.zitadelForInstance(issuer, ownerSignIn.bearerRef);
7086
+ const org = { id: ownerSignIn.organisationId };
6297
7087
  const { project } = await zitadel.findOrCreateProject(org.id, PLATFORM_ACCESS_ZITADEL_PROJECT);
7088
+ context.recordScopeId(PLATFORM_ACCESS_ZITADEL_PROJECT_SCOPE_KEY, project.id);
6298
7089
  const orphan = (await zitadel.listApps(org.id, project.id)).apps.find((a) => a.name === PLATFORM_ACCESS_ZITADEL_APP && a.state !== 'APP_STATE_REMOVED');
6299
7090
  if (orphan) {
6300
7091
  try {
@@ -6311,6 +7102,7 @@ async function provisionPlatformAccessProvider(context, deps) {
6311
7102
  postLogoutRedirectUris: [`https://${authDomain}/`],
6312
7103
  });
6313
7104
  context.recordScopeId(PLATFORM_ACCESS_PROVIDER_UNDELIVERED_SCOPE_KEY, client.appId);
7105
+ context.recordScopeId(PLATFORM_ACCESS_ZITADEL_APP_SCOPE_KEY, client.appId);
6314
7106
  await context.persist?.();
6315
7107
  let providerId = '';
6316
7108
  await deliverWithRetry(async (_ref, secret) => {
@@ -6326,7 +7118,7 @@ async function provisionPlatformAccessProvider(context, deps) {
6326
7118
  })).id;
6327
7119
  }, ACCESS_PROVIDER_RECORD_KEY, client.clientSecret, attempts, sleep, `the ZITADEL client ${client.appId}'s secret`, label);
6328
7120
  // Last: the guard says the secret reached edge.
6329
- context.recordScopeId(PLATFORM_ACCESS_PROVIDER_APPLIED_HOSTNAME_SCOPE_KEY, platform.auth.hostname);
7121
+ context.recordScopeId(PLATFORM_ACCESS_PROVIDER_APPLIED_HOSTNAME_SCOPE_KEY, ownerSignIn.hostname);
6330
7122
  context.recordScopeId(PLATFORM_ACCESS_PROVIDER_APPLIED_AUTH_DOMAIN_SCOPE_KEY, authDomain);
6331
7123
  context.recordScopeId(ACCESS_PROVIDER_RECORD_KEY, providerId);
6332
7124
  await context.persist?.();
@@ -6350,6 +7142,17 @@ const HC_PORT = '8000';
6350
7142
  const HC_HEALTH_CHECK_PATH = '/';
6351
7143
  const PLATFORM_HC_DB = 'hc';
6352
7144
  const PLATFORM_HC_DB_USER = 'hc';
7145
+ /** The tunnel that carries the AI host's public hostname, and the app that runs its connector. */
7146
+ const AI_TUNNEL_NAME = 'platform-ai-tunnel';
7147
+ /**
7148
+ * The only paths the AI host's public route serves; everything else answers 404 at the tunnel. A company entry's key
7149
+ * holds `ai.runs:create` and `ai.offerings:invoke` (`AI_ENTRY_PERMISSIONS`), and `@wtfalch/ai`'s client reaches them
7150
+ * through `POST /v1/runs` (also `?wait=`), `POST /v1/runs/batch`, `GET /v1/runs`, `GET /v1/runs/:id` and
7151
+ * `GET /v1/offerings`. The management routes (`/v1/organisations`, `/v1/keys`, `/v1/budgets`, `/v1/webhooks`,
7152
+ * `/v1/connections`, `/v1/platform/keys`, ...) and `/api/health` are not on the internet.
7153
+ */
7154
+ export const AI_ENTRY_PATH_PATTERN = '^/v1/(runs(/|$)|offerings$)';
7155
+ const AI_CLOUDFLARED_APP_NAME = 'platform-ai-cloudflared';
6353
7156
  const HC_TUNNEL_NAME = 'platform-hc-tunnel';
6354
7157
  /** #197: the one Coolify app that runs `cloudflared` for {@link HC_TUNNEL_NAME}. */
6355
7158
  const HC_CLOUDFLARED_APP_NAME = 'platform-hc-cloudflared';
@@ -6443,7 +7246,7 @@ async function provisionPlatformHc(context, deps) {
6443
7246
  environment_name: environment.name,
6444
7247
  environment_uuid: environment.uuid,
6445
7248
  docker_registry_image_name: HC_IMAGE,
6446
- docker_registry_image_tag: gitRef,
7249
+ docker_registry_image_tag: pinnedTagFor('hc', gitRef),
6447
7250
  ports_exposes: HC_PORT,
6448
7251
  name: PLATFORM_HC_APP_NAME,
6449
7252
  description: `Healthchecks for the platform's own heartbeats (#540, ${HC_IMAGE}:${gitRef})`,
@@ -6456,7 +7259,7 @@ async function provisionPlatformHc(context, deps) {
6456
7259
  // own rule. The image tag too, so a declared release change reaches the app.
6457
7260
  await deps.coolify.updateApplication(appId, {
6458
7261
  custom_internal_name: HC_INTERNAL_NAME,
6459
- docker_registry_image_tag: gitRef,
7262
+ docker_registry_image_tag: pinnedTagFor('hc', gitRef),
6460
7263
  ...healthCheckSettings(HC_HEALTH_CHECK_PATH, Number(HC_PORT)),
6461
7264
  });
6462
7265
  // ---- env, parsed off the database's own owner credentials -----------------
@@ -6577,7 +7380,7 @@ function aiKeys(deps) {
6577
7380
  const keys = deps?.keys;
6578
7381
  return keys?.workerUrl && keys.credential && keys.verifyKeys ? keys : null;
6579
7382
  }
6580
- function filesKeys(deps) {
7383
+ export function filesKeys(deps) {
6581
7384
  const keys = deps?.keys;
6582
7385
  return keys?.workerUrl && keys.credential && keys.verifyKeys
6583
7386
  ? {
@@ -6600,7 +7403,7 @@ function filesR2(deps) {
6600
7403
  * naming the field instead of deploying a host that never turns healthy.
6601
7404
  * Never echoes the value.
6602
7405
  */
6603
- function filesDepsInvalid(deps) {
7406
+ export function filesDepsInvalid(deps) {
6604
7407
  const r2 = filesR2(deps);
6605
7408
  if (r2) {
6606
7409
  let ok = false;
@@ -6727,7 +7530,7 @@ async function ensureFilesRuntimeUrl(coolify, appIds, dbInternalUrl) {
6727
7530
  * stands in for the log line). Throws once `migrateTimeoutMs` has passed; the
6728
7531
  * runner is then never deployed against an unmigrated database.
6729
7532
  */
6730
- async function waitForMigration(coolify, appId, runtime, sleep) {
7533
+ async function waitForMigration(coolify, appId, runtime, sleep, label = 'platform:files', appName = 'files-migrate') {
6731
7534
  const now = runtime.now ?? Date.now;
6732
7535
  const timeout = runtime.migrateTimeoutMs ?? 900_000;
6733
7536
  const interval = runtime.migrateIntervalMs ?? 10_000;
@@ -6740,7 +7543,7 @@ async function waitForMigration(coolify, appId, runtime, sleep) {
6740
7543
  if (status !== 'running:healthy')
6741
7544
  left = true;
6742
7545
  if (now() >= deadline) {
6743
- throw new Error(`platform:files: files-migrate (${appId}) did not report healthy within ${Math.round(timeout / 1000)}s (last status: ${status ?? 'unknown'}); the files app was not deployed. Check its logs for "files-migrate: done" and retry this apply`);
7546
+ throw new Error(`${label}: ${appName} (${appId}) did not report healthy within ${Math.round(timeout / 1000)}s (last status: ${status ?? 'unknown'}); the files app was not deployed. Check its logs for "files-migrate: done" and retry this apply`);
6744
7547
  }
6745
7548
  await sleep(interval);
6746
7549
  }
@@ -6950,6 +7753,8 @@ async function provisionPlatformIntegrations(action, context, deps) {
6950
7753
  await coolify.updateApplication(runnerApp.appId, {
6951
7754
  dockerfile_target_build: INTEGRATIONS_RUNNER_APP.target,
6952
7755
  ports_exposes: INTEGRATIONS_RUNNER_PORT,
7756
+ // The runner's Dockerfile HEALTHCHECK (curl on /api/health) is the live check.
7757
+ ...IMAGE_HEALTHCHECK_SETTINGS,
6953
7758
  git_branch: gitRef,
6954
7759
  domains: coolifyDomains([hostname]),
6955
7760
  });
@@ -7163,7 +7968,8 @@ async function provisionPlatformIntegrations(action, context, deps) {
7163
7968
  keyJustSet ||
7164
7969
  runnerKeyStale ||
7165
7970
  configChanged) {
7166
- await waitForNewDeployment(coolify, runnerApp.appId, { kind: 'deployment-only' }, runtime, sleep, 'platform:integrations', `the runner stays at ${recorded[PLATFORM_INTEGRATIONS_APPLIED_GITREF_SCOPE_KEY] ?? 'no recorded ref'}`);
7971
+ const built = await waitForNewDeployment(coolify, runnerApp.appId, { kind: 'deployment-only' }, runtime, sleep, 'platform:integrations', `the runner stays at ${recorded[PLATFORM_INTEGRATIONS_APPLIED_GITREF_SCOPE_KEY] ?? 'no recorded ref'}`);
7972
+ await recordBuiltCommit(coolify, { appId: runnerApp.appId, deploymentUuid: built.deploymentUuid, name: 'integrations' }, record);
7167
7973
  }
7168
7974
  // #398: the runner is up with the keyring that holds the active id; only
7169
7975
  // now may Archon move to it.
@@ -7294,7 +8100,7 @@ async function waitForIntegrationsMigration(coolify, appId, runtime, sleep) {
7294
8100
  /** The name every platform database's backup is registered against -- one shared R2 bucket, one storage entry. */
7295
8101
  const PLATFORM_BACKUP_S3_STORAGE_NAME = 'platform-backups';
7296
8102
  /** The uuid `provisionPlatformBackup` records once {@link PLATFORM_BACKUP_S3_STORAGE_NAME} is registered on Coolify, so a later apply never re-registers it. */
7297
- const PLATFORM_BACKUP_S3_STORAGE_SCOPE_KEY = 'coolify_platform_backup_s3_storage';
8103
+ export const PLATFORM_BACKUP_S3_STORAGE_SCOPE_KEY = 'coolify_platform_backup_s3_storage';
7298
8104
  /**
7299
8105
  * #257 fix round: a sha256 of the exact `endpoint|bucket|key|secret` the
7300
8106
  * storage was last pushed with -- never the values themselves, the same
@@ -7342,8 +8148,11 @@ const PLATFORM_BACKUP_DATABASE_NAMES = {
7342
8148
  * {@link repointPlatformBackupSchedules} uses for `s3_storage_uuid`.
7343
8149
  */
7344
8150
  async function findPlatformBackupSchedule(coolify, service, databaseId) {
8151
+ return findDatabaseBackupSchedule(coolify, PLATFORM_BACKUP_DATABASE_NAMES[service], databaseId);
8152
+ }
8153
+ /** {@link findPlatformBackupSchedule} for any database; `database` undefined means the resource's first database. */
8154
+ async function findDatabaseBackupSchedule(coolify, database, databaseId) {
7345
8155
  const schedules = (await coolify.listDatabaseBackups(databaseId)).filter((backup) => backup.enabled && backup.save_s3);
7346
- const database = PLATFORM_BACKUP_DATABASE_NAMES[service];
7347
8156
  if (!database)
7348
8157
  return { schedule: schedules[0], named: true };
7349
8158
  for (const schedule of schedules) {
@@ -7719,23 +8528,58 @@ async function provisionPlatformBackup(action, context, deps) {
7719
8528
  const recorded = context.recordedIds();
7720
8529
  const storageUuid = await ensurePlatformBackupStorage(coolify, r2, recorded, context);
7721
8530
  const service = action.key;
7722
- const { schedule, named } = await findPlatformBackupSchedule(coolify, service, databaseId);
7723
- const scheduleUuid = schedule?.uuid ??
7724
- (await coolify.createDatabaseBackup(databaseId, {
7725
- frequency: deps.frequency ?? PLATFORM_BACKUP_DEFAULT_FREQUENCY,
8531
+ await provisionDatabaseBackup(coolify, {
8532
+ databaseUuid: databaseId,
8533
+ label: `platform ${service}`,
8534
+ storageUuid,
8535
+ frequency: deps.frequency,
8536
+ databaseName: PLATFORM_BACKUP_DATABASE_NAMES[service],
8537
+ });
8538
+ return undefined;
8539
+ }
8540
+ /**
8541
+ * F6: one nightly schedule of one database to a registered S3 storage, the
8542
+ * body `provisionPlatformBackup` had and every company database now shares.
8543
+ * An existing enabled, `save_s3` schedule is reused, never doubled: the lookup
8544
+ * runs before the create, so a run that created a schedule and died before
8545
+ * the caller recorded its uuid finds it again on the next apply. The
8546
+ * `PATCH` names the database to dump, because `createDatabaseBackup` takes no
8547
+ * database name; it also repairs an existing schedule that dumps the wrong
8548
+ * one. Returns the schedule's uuid.
8549
+ */
8550
+ export async function provisionDatabaseBackup(coolify, input) {
8551
+ const { databaseUuid, label } = input;
8552
+ const { schedule, named } = await backupCall(label, () => findDatabaseBackupSchedule(coolify, input.databaseName, databaseUuid));
8553
+ const uuid = schedule?.uuid ??
8554
+ (await backupCall(label, () => coolify.createDatabaseBackup(databaseUuid, {
8555
+ frequency: input.frequency ?? PLATFORM_BACKUP_DEFAULT_FREQUENCY,
7726
8556
  enabled: true,
7727
8557
  save_s3: true,
7728
- s3_storage_uuid: storageUuid,
7729
- })).uuid;
7730
- // `CoolifyPort.createDatabaseBackup` takes no database name, so a new
7731
- // schedule is named here too, by the same PATCH that repairs an existing
7732
- // one dumping the wrong database.
8558
+ s3_storage_uuid: input.storageUuid,
8559
+ }))).uuid;
7733
8560
  if (!named) {
7734
- await coolify.raw('PATCH', `/databases/${databaseId}/backups/${scheduleUuid}`, {
7735
- databases_to_backup: PLATFORM_BACKUP_DATABASE_NAMES[service],
7736
- });
8561
+ await backupCall(label, () => coolify.raw('PATCH', `/databases/${databaseUuid}/backups/${uuid}`, {
8562
+ databases_to_backup: input.databaseName,
8563
+ }));
8564
+ }
8565
+ return { uuid };
8566
+ }
8567
+ /** How long one Coolify call of a database backup may take. */
8568
+ const BACKUP_CALL_TIMEOUT_MS = 30_000;
8569
+ /** Runs one Coolify call with a timeout; a timeout names the database, not the call's arguments. */
8570
+ async function backupCall(label, call) {
8571
+ let timer;
8572
+ try {
8573
+ return await Promise.race([
8574
+ call(),
8575
+ new Promise((_, reject) => {
8576
+ timer = setTimeout(() => reject(new Error(`backup of ${label}: Coolify did not answer within ${BACKUP_CALL_TIMEOUT_MS / 1000} seconds`)), BACKUP_CALL_TIMEOUT_MS);
8577
+ }),
8578
+ ]);
8579
+ }
8580
+ finally {
8581
+ clearTimeout(timer);
7737
8582
  }
7738
- return undefined;
7739
8583
  }
7740
8584
  /**
7741
8585
  * #540: reconciles the live Healthchecks checks against the ones foundry
@@ -7818,7 +8662,10 @@ async function provisionPlatformHeartbeats(context, deps) {
7818
8662
  // Last: so a run that failed anywhere above (an unreconciled create or
7819
8663
  // delete) leaves this unset and `planHeartbeats` plans the step again --
7820
8664
  // the same "record markers last" rule `provisionPlatformHc` follows.
7821
- context.recordScopeId(PLATFORM_HEARTBEATS_APPLIED_SCOPE_KEY, [...desiredNames].sort().join(','));
8665
+ // foundry#544: the provider is folded in, matching `planHeartbeats`'s own
8666
+ // `desired` -- a provider switch with the same check names otherwise
8667
+ // desired is still a change from this marker.
8668
+ context.recordScopeId(PLATFORM_HEARTBEATS_APPLIED_SCOPE_KEY, `${context.platform.heartbeatsProvider}:${[...desiredNames].sort().join(',')}`);
7822
8669
  return undefined;
7823
8670
  }
7824
8671
  /**
@@ -7964,7 +8811,7 @@ export class ProvidersPlatformCoolifyReader {
7964
8811
  * answers with the parent zone. The platform's zone is its apex exactly;
7965
8812
  * any other zone is never adopted or reported as the platform's.
7966
8813
  */
7967
- async function exactZone(client, apex) {
8814
+ export async function exactZone(client, apex) {
7968
8815
  const zone = await client.findZoneByApex(apex);
7969
8816
  return zone && zone.name === apex ? zone : undefined;
7970
8817
  }