@wtfalch/foundry 0.34.0 → 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 (88) 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 +20 -0
  6. package/dist/bootstrap/versions.js +46 -0
  7. package/dist/calls-sfu.js +3 -3
  8. package/dist/index.d.ts +9 -4
  9. package/dist/index.js +5 -2
  10. package/dist/migrations/0053_heartbeats_provider.sql +9 -0
  11. package/dist/migrations/0053_org_version_pin.sql +6 -0
  12. package/dist/org.d.ts +60 -14
  13. package/dist/org.js +118 -28
  14. package/dist/plan/backup-gate.d.ts +93 -0
  15. package/dist/plan/backup-gate.js +261 -0
  16. package/dist/plan/broker-entries.d.ts +9 -1
  17. package/dist/plan/broker-entries.js +20 -3
  18. package/dist/plan/build-record.d.ts +56 -0
  19. package/dist/plan/build-record.js +109 -0
  20. package/dist/plan/calls-matrix.js +3 -3
  21. package/dist/plan/cloudflared-connector.js +2 -2
  22. package/dist/plan/company-backups.d.ts +31 -0
  23. package/dist/plan/company-backups.js +101 -0
  24. package/dist/plan/deploy-health.d.ts +12 -2
  25. package/dist/plan/deploy-health.js +3 -1
  26. package/dist/plan/destinations.d.ts +73 -0
  27. package/dist/plan/destinations.js +217 -0
  28. package/dist/plan/diff.d.ts +17 -8
  29. package/dist/plan/diff.js +131 -131
  30. package/dist/plan/edge-access.d.ts +15 -0
  31. package/dist/plan/edge-access.js +21 -2
  32. package/dist/plan/executor.d.ts +166 -7
  33. package/dist/plan/executor.js +547 -106
  34. package/dist/plan/health-targets.d.ts +1 -3
  35. package/dist/plan/health-targets.js +36 -36
  36. package/dist/plan/held-key-refs.d.ts +5 -0
  37. package/dist/plan/held-key-refs.js +5 -0
  38. package/dist/plan/hostnames.d.ts +10 -0
  39. package/dist/plan/hostnames.js +14 -0
  40. package/dist/plan/index.d.ts +5 -0
  41. package/dist/plan/index.js +3 -0
  42. package/dist/plan/live.d.ts +6 -0
  43. package/dist/plan/live.js +3 -2
  44. package/dist/plan/locked-hostnames.d.ts +2 -1
  45. package/dist/plan/locked-hostnames.js +16 -3
  46. package/dist/plan/login-host.d.ts +9 -20
  47. package/dist/plan/login-host.js +10 -25
  48. package/dist/plan/mail-relay.d.ts +48 -29
  49. package/dist/plan/mail-relay.js +145 -68
  50. package/dist/plan/network-move.d.ts +20 -0
  51. package/dist/plan/network-move.js +41 -0
  52. package/dist/plan/oikos-ingress.d.ts +111 -0
  53. package/dist/plan/oikos-ingress.js +231 -0
  54. package/dist/plan/org-names.d.ts +8 -2
  55. package/dist/plan/org-names.js +57 -1
  56. package/dist/plan/own-diff.d.ts +9 -8
  57. package/dist/plan/own-diff.js +42 -34
  58. package/dist/plan/own-hostnames.d.ts +27 -0
  59. package/dist/plan/own-hostnames.js +24 -0
  60. package/dist/plan/own-limits.d.ts +198 -7
  61. package/dist/plan/own-limits.js +163 -32
  62. package/dist/plan/own-services.js +3 -1
  63. package/dist/plan/own-sfu.js +3 -2
  64. package/dist/plan/plan-apply.d.ts +47 -7
  65. package/dist/plan/plan-apply.js +184 -38
  66. package/dist/plan/platform-diff.d.ts +133 -17
  67. package/dist/plan/platform-diff.js +231 -68
  68. package/dist/plan/platform-org.d.ts +79 -10
  69. package/dist/plan/platform-org.js +93 -13
  70. package/dist/plan/platform-providers.d.ts +118 -73
  71. package/dist/plan/platform-providers.js +770 -231
  72. package/dist/plan/proxy-labels.d.ts +57 -11
  73. package/dist/plan/proxy-labels.js +94 -14
  74. package/dist/plan/run-options.d.ts +19 -0
  75. package/dist/plan/run-options.js +46 -0
  76. package/dist/plan/zitadel-login-policy.d.ts +1 -1
  77. package/dist/plan/zitadel-login-policy.js +76 -8
  78. package/dist/platform.d.ts +32 -0
  79. package/dist/platform.js +16 -0
  80. package/dist/provision.d.ts +2 -0
  81. package/dist/provision.js +3 -1
  82. package/dist/slug.d.ts +3 -1
  83. package/dist/slug.js +25 -3
  84. package/dist/store/postgres.js +43 -1
  85. package/dist/store/schema.d.ts +36 -0
  86. package/dist/store/schema.js +4 -0
  87. package/package.json +3 -3
  88. package/versions.digests.json +11 -0
@@ -1,22 +1,26 @@
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
14
  import { IMAGE_HEALTHCHECK_SETTINGS, commandHealthCheckSettings, healthCheckSettings, tcpProbe, } from './health-check-settings.js';
13
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';
14
17
  export { FILES_MANAGEMENT_KEY_REF, JOBS_ADMIN_TOKEN_REF, PLATFORM_AI_MANAGEMENT_KEY_REF, } from './held-key-refs.js';
15
18
  import { coolifyDomains, coolifyTunnelDomains, filesShareHostname } from './hostnames.js';
16
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';
17
21
  import { recordOwnDnsHostname } from './own-dns.js';
18
22
  import { platformNetwork, platformTarget, scopeFromPlatform, } from './own-services.js';
19
- 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_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, archonAuthOrganizationId, desiredHeartbeatCheckNames, heartbeatCheckUrlRef, parseBrokerDeployRecord, parseLoginClientRecord, platformBackupHeartbeatCheckName, platformMailHostPorts, platformMailMxHost, uptimeAccessTokenConflict, uptimeRecorded, uptimeVarsDigest, } from './platform-diff.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';
20
24
  import { ensureSignInOrg, inviteOwnerWithLink, signInOrgDomain } from './sign-in-org.js';
21
25
  export { PLATFORM_ZITADEL_ORG_SCOPE_KEY };
22
26
  import { applyCallsSfuPlatformStep, callsSfuAppliedFingerprint, } from './platform-sfu.js';
@@ -90,70 +94,6 @@ const PLATFORM_COOLIFY_PROJECT_NAME = 'platform';
90
94
  const JOBS_SCHEDULER_REPO = 'wtfalch/jobs';
91
95
  /** The port the scheduler listens on (jobs' `PORT`, default 8080). */
92
96
  const JOBS_PORT = '8080';
93
- /**
94
- * The real {@link PlatformStepExecutor} for the two steps built so far:
95
- * `cloudflare:zone` for the platform's apex (#167) and `coolify:jobs` for the
96
- * jobs scheduler (#173). `coolify` is optional so every #167-era caller --
97
- * anything that only ever plans the apex zone -- keeps constructing this with
98
- * two arguments; a `coolify:jobs` step reaching this executor with no
99
- * `coolify` port configured throws, naming what is missing, rather than
100
- * silently doing nothing.
101
- *
102
- * `cloudflareZone` mirrors `executor.ts`'s `cloudflareZone`: find the zone by
103
- * apex and adopt it, create it only when absent. companyincluded.com already
104
- * exists on Cloudflare, empty, so the first apply adopts it. The owner org
105
- * (Company Included, whose own domain is the apex) may record the same zone
106
- * under its own applied state, so that owner is allowed. Any other org that
107
- * recorded it makes this refuse to adopt. There is no delete here and
108
- * `PlatformStepExecutor` has none: neither path ever removes the zone.
109
- *
110
- * `coolifyJobs` mirrors `executor.ts`'s `coolifyApp`/`coolifyDatabase`,
111
- * simplified for one platform-level singleton rather than one per org: find
112
- * or create a dedicated `platform` Coolify project, find or create the
113
- * scheduler's own database, find or create the scheduler app itself (from
114
- * https://github.com/wtfalch/jobs, one Dockerfile at its repo root, on the
115
- * declared `gitRef`), then push the database's `internal_db_url` in as
116
- * `DATABASE_URL`. There is no park branch yet, matching `planJobs`'s own
117
- * scope note -- `platform.jobs.enabled` going from true to false plans
118
- * nothing, so this method is never asked to stop or delete anything.
119
- *
120
- * #170 adds `sfu:livekit`, the platform's shared calls SFU. `sfu` is
121
- * optional: an existing host that constructs this executor for the zone step
122
- * alone keeps compiling, and a plan that includes an `sfu:livekit` step
123
- * without it configured refuses clearly rather than silently doing nothing.
124
- * #152: the step mints one LiveKit key per org with calls enabled rather
125
- * than one shared pair, so it also needs `declaredOrgStore.list()` -- the
126
- * same store this class already takes for `findOrgIdForZone`.
127
- *
128
- * #168 adds `coolify:app` keyed {@link PLATFORM_AUTH_KEY}, the platform's own
129
- * sign-in ({@link provisionPlatformAuth}), once a host passes
130
- * {@link PlatformAuthDeps}.
131
- *
132
- * #169 adds `coolify:app` keyed {@link PLATFORM_MAIL_KEY}, the platform's own
133
- * mail server ({@link provisionPlatformMail}), once a host passes
134
- * {@link PlatformMailDeps}.
135
- *
136
- * #174 adds `coolify:app` keyed {@link PLATFORM_INTEGRATIONS_KEY},
137
- * the integrations host ({@link provisionPlatformIntegrations}), once a host
138
- * passes {@link PlatformIntegrationsDeps}.
139
- *
140
- * #171 adds `coolify:app` keyed {@link PLATFORM_FILES_KEY}, the files host
141
- * ({@link provisionPlatformFiles}), once a host passes {@link PlatformFilesDeps}.
142
- *
143
- * #172 adds `coolify:app` keyed {@link PLATFORM_AI_KEY}, the platform's AI
144
- * host ({@link provisionPlatformAi}), once a host passes {@link PlatformAiDeps}.
145
- *
146
- * #478 adds `coolify:app` keyed {@link PLATFORM_EDGE_KEY}, the edge host
147
- * ({@link provisionPlatformEdge}), once a host passes {@link PlatformEdgeDeps}.
148
- *
149
- * #175 adds `coolify:app` keyed {@link PLATFORM_ARCHON_KEY}, Archon itself
150
- * ({@link provisionPlatformArchon}), once a host passes
151
- * {@link PlatformArchonDeps}.
152
- *
153
- * #257 adds `coolify:backup:<service>`, a nightly R2 backup of each
154
- * platform database ({@link provisionPlatformBackup}), once a host passes
155
- * {@link PlatformBackupDeps}.
156
- */
157
97
  export class FoundryPlatformStepExecutor {
158
98
  cloudflare;
159
99
  declaredOrgStore;
@@ -173,6 +113,7 @@ export class FoundryPlatformStepExecutor {
173
113
  heartbeats;
174
114
  uptime;
175
115
  accessProvider;
116
+ oikosIngress;
176
117
  constructor(cloudflare, declaredOrgStore, options = {}) {
177
118
  this.cloudflare = cloudflare;
178
119
  this.declaredOrgStore = declaredOrgStore;
@@ -192,8 +133,23 @@ export class FoundryPlatformStepExecutor {
192
133
  this.heartbeats = options.heartbeats;
193
134
  this.uptime = options.uptime;
194
135
  this.accessProvider = options.accessProvider;
136
+ this.oikosIngress = options.oikosIngress;
195
137
  }
196
- async blockedSteps(platform, recordedIds = {}) {
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) };
151
+ }
152
+ async blockedSteps(platform, recordedIds = {}, owner = {}) {
197
153
  const blocked = {};
198
154
  if (platform.auth.enabled && !this.auth) {
199
155
  blocked.auth =
@@ -235,7 +191,8 @@ export class FoundryPlatformStepExecutor {
235
191
  }
236
192
  if (platform.mail.relay === true) {
237
193
  const relayBlocked = this.mail
238
- ? mailRelayBlockedReason()
194
+ ? (mailRelayBlockedReason() ??
195
+ (await relayServerBlockedReason(platform, this.mail, recordedIds)))
239
196
  : MAIL_RELAY_BLOCKED.executorMissing;
240
197
  if (relayBlocked)
241
198
  blocked.relay = relayBlocked;
@@ -298,6 +255,23 @@ export class FoundryPlatformStepExecutor {
298
255
  if (edgeBlocked)
299
256
  blocked.edge = edgeBlocked;
300
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
+ }
301
275
  if (platform.archon.enabled) {
302
276
  if (!this.archon) {
303
277
  blocked.archon =
@@ -308,7 +282,7 @@ export class FoundryPlatformStepExecutor {
308
282
  'Archon is blocked, not planned: the host names no email the Cloudflare Access app in front of it may let through';
309
283
  }
310
284
  else {
311
- const inputs = await collectArchonServiceInputs(platform, this.archon, recordedIds);
285
+ const inputs = await collectArchonServiceInputs(platform, this.archon, recordedIds, owner.files ?? null);
312
286
  if ('blocked' in inputs) {
313
287
  blocked.archon = inputs.blocked;
314
288
  }
@@ -321,6 +295,22 @@ export class FoundryPlatformStepExecutor {
321
295
  }
322
296
  }
323
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
+ }
324
314
  // Only once a database actually exists to back up -- the same
325
315
  // "nothing to report yet" rule `planBackups` (`platform-diff.ts`)
326
316
  // itself follows for "no database", so an otherwise-unrelated platform
@@ -338,21 +328,30 @@ export class FoundryPlatformStepExecutor {
338
328
  if (hcTagBlocked)
339
329
  blocked.hc = hcTagBlocked;
340
330
  }
341
- // Only once `hc` is actually applied -- `planHeartbeats` itself already
342
- // 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
343
335
  // deps wired carries no standing blocked note before there is anything
344
336
  // for it to reconcile.
345
- 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}`]))) {
346
340
  blocked.heartbeatsExecutorMissing = true;
347
341
  }
348
- else if (platform.hc.enabled && this.heartbeats) {
342
+ else if ((heartbeatsHosted || platform.hc.enabled) && this.heartbeats) {
349
343
  // #540 round 2: checked whether or not `hc` is recorded applied yet --
350
344
  // `hcReady` (`planPlatform`) goes true the same round `hc` is merely
351
345
  // *planned*, so this has to hold the heartbeats step back from the
352
346
  // very first round, before `coolify:app:platform:hc` is ever recorded.
353
347
  // See `PLATFORM_HC_API_KEY_REF`'s own doc comment (`platform-diff.ts`)
354
- // for why nothing here can mint this key itself.
355
- 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);
356
355
  if (!apiKey)
357
356
  blocked.heartbeatsApiKeyMissing = true;
358
357
  }
@@ -360,7 +359,7 @@ export class FoundryPlatformStepExecutor {
360
359
  // #604: only once edge and sign-in are applied (the planner reports
361
360
  // that case itself) and no provider is recorded yet.
362
361
  if (platform.edge.enabled &&
363
- platform.auth.enabled &&
362
+ owner.signIn &&
364
363
  !recordedIds[ACCESS_PROVIDER_RECORD_KEY] &&
365
364
  recordedIds[PLATFORM_EDGE_INTERNAL_URL_SCOPE_KEY]) {
366
365
  if (!this.accessProvider?.zitadel.createOidcAppWithSecret) {
@@ -382,9 +381,10 @@ export class FoundryPlatformStepExecutor {
382
381
  platform.archon.enabled &&
383
382
  recordedIds[PLATFORM_ARCHON_ZITADEL_APP_SCOPE_KEY]) {
384
383
  try {
385
- const zitadel = this.archon.zitadelForInstance?.(`https://${platform.auth.hostname}`) ??
386
- this.archon.zitadel;
387
- if (zitadel.updateOidcAppIdTokenUserinfoAssertion)
384
+ const zitadel = owner.signIn
385
+ ? this.archon.zitadelForInstance?.(owner.signIn.issuerUrl, owner.signIn.bearerRef)
386
+ : undefined;
387
+ if (zitadel?.updateOidcAppIdTokenUserinfoAssertion)
388
388
  blocked.archonIdTokenUserinfo = true;
389
389
  }
390
390
  catch (e) {
@@ -486,18 +486,25 @@ export class FoundryPlatformStepExecutor {
486
486
  * login-client token is no longer the sole live PAT on its ZITADEL machine
487
487
  * user.
488
488
  */
489
- async driftedSteps(platform, recordedIds = {}) {
489
+ async driftedSteps(platform, recordedIds = {}, owner = {}) {
490
490
  const result = {};
491
491
  if (platform.archon.enabled &&
492
492
  this.archon &&
493
493
  recordedIds[`coolify:app:${PLATFORM_ARCHON_KEY}`]) {
494
- 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
495
496
  ? recordedIds[PLATFORM_UPTIME_ACCESS_TOKEN_ID_SCOPE_KEY]
496
497
  : undefined);
497
- 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));
498
501
  const authOrgDrifted = !accessDrifted &&
499
502
  !brokerDrifted &&
500
- (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
+ }));
501
508
  const authzDrifted = !accessDrifted &&
502
509
  !brokerDrifted &&
503
510
  !authOrgDrifted &&
@@ -602,13 +609,13 @@ export class FoundryPlatformStepExecutor {
602
609
  throw new Error('platform:auth needs FoundryPlatformStepExecutor to be constructed with its auth dependencies (#168)');
603
610
  }
604
611
  const company = await this.declaredOrgStore.get(context.platform.ownerOrgId);
605
- return provisionPlatformAuth(context, this.auth, company);
612
+ return provisionPlatformAuth(context, this.limitedOnPlatform(this.auth, LIMITS.platformAuth), company);
606
613
  }
607
614
  if (action.system === 'coolify' && action.kind === 'app' && action.key === PLATFORM_FILES_KEY) {
608
615
  if (!this.files) {
609
616
  throw new Error('platform:files needs FoundryPlatformStepExecutor to be constructed with its files dependencies (#171)');
610
617
  }
611
- 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()));
612
619
  }
613
620
  if (action.system === 'coolify' &&
614
621
  action.kind === 'app' &&
@@ -616,19 +623,19 @@ export class FoundryPlatformStepExecutor {
616
623
  if (!this.integrations) {
617
624
  throw new Error('platform:integrations needs FoundryPlatformStepExecutor to be constructed with its integrations dependencies (#174)');
618
625
  }
619
- return provisionPlatformIntegrations(action, context, this.integrations);
626
+ return provisionPlatformIntegrations(action, context, this.limitedOnPlatform(this.integrations, LIMITS.platformIntegrations));
620
627
  }
621
628
  if (action.system === 'coolify' && action.kind === 'app' && action.key === PLATFORM_AI_KEY) {
622
629
  if (!this.ai) {
623
630
  throw new Error('platform:ai needs FoundryPlatformStepExecutor to be constructed with its ai dependencies (#172)');
624
631
  }
625
- return provisionPlatformAi(context, this.ai);
632
+ return provisionPlatformAi(context, this.limitedOnPlatform(this.ai, LIMITS.platformAi));
626
633
  }
627
634
  if (action.system === 'coolify' && action.kind === 'app' && action.key === PLATFORM_EDGE_KEY) {
628
635
  if (!this.edge) {
629
636
  throw new Error('platform:edge needs FoundryPlatformStepExecutor to be constructed with its edge dependencies (#478)');
630
637
  }
631
- return provisionPlatformEdge(context, this.edge);
638
+ return provisionPlatformEdge(context, this.limitedOnPlatform(this.edge, LIMITS.platformEdge));
632
639
  }
633
640
  if (action.system === 'coolify' &&
634
641
  action.kind === 'app' &&
@@ -637,13 +644,13 @@ export class FoundryPlatformStepExecutor {
637
644
  throw new Error('platform:archon needs FoundryPlatformStepExecutor to be constructed with its archon dependencies (#175)');
638
645
  }
639
646
  const ownerCompany = await this.declaredOrgStore.get(context.platform.ownerOrgId);
640
- return provisionPlatformArchon(context, this.archon, ownerCompany);
647
+ return provisionPlatformArchon(context, this.limitedOnPlatform(this.archon, LIMITS.platformArchon), ownerCompany);
641
648
  }
642
649
  if (action.system === 'coolify' && action.kind === 'app' && action.key === PLATFORM_MAIL_KEY) {
643
650
  if (!this.mail) {
644
651
  throw new Error('platform:mail needs FoundryPlatformStepExecutor to be constructed with its mail dependencies (#169)');
645
652
  }
646
- return provisionPlatformMail(context, this.mail);
653
+ return provisionPlatformMail(context, this.limitedOnPlatform(this.mail, LIMITS.platformMail));
647
654
  }
648
655
  if (action.system === 'coolify' &&
649
656
  action.kind === 'app' &&
@@ -651,13 +658,22 @@ export class FoundryPlatformStepExecutor {
651
658
  if (!this.mail) {
652
659
  throw new Error('platform:mail-relay needs FoundryPlatformStepExecutor to be constructed with its mail dependencies');
653
660
  }
654
- return provisionPlatformMailRelay(action, context, this.mail);
661
+ return provisionPlatformMailRelay(action, context, this.limitedOnPlatform(this.mail, LIMITS.platformMailRelay));
655
662
  }
656
663
  if (action.system === 'coolify' && action.kind === 'app' && action.key === PLATFORM_HC_KEY) {
657
664
  if (!this.hc) {
658
665
  throw new Error('platform:hc needs FoundryPlatformStepExecutor to be constructed with its hc dependencies (#540)');
659
666
  }
660
- 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);
661
677
  }
662
678
  if (action.system === 'zitadel' &&
663
679
  action.kind === 'id-token-userinfo' &&
@@ -695,6 +711,14 @@ export class FoundryPlatformStepExecutor {
695
711
  }
696
712
  return provisionPlatformUptime(context, this.uptime);
697
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
+ }
698
722
  if (action.system === 'cloudflare' && action.kind === 'zone') {
699
723
  return this.cloudflareZone(action, context);
700
724
  }
@@ -768,7 +792,11 @@ export class FoundryPlatformStepExecutor {
768
792
  if (!deliverSecret) {
769
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)');
770
794
  }
771
- return provisionJobsHost(scopeFromPlatform(context), { coolify, deliverSecret, runtime: this.runtime }, platformTarget(), {
795
+ return provisionJobsHost(scopeFromPlatform(context), {
796
+ coolify: limited(this.onPlatform(coolify), LIMITS.platformJobs),
797
+ deliverSecret,
798
+ runtime: this.runtime,
799
+ }, platformTarget(), {
772
800
  hostname: action.desired.hostname,
773
801
  gitRef: action.desired.gitRef,
774
802
  project: platformJobsProject,
@@ -944,7 +972,8 @@ export async function provisionJobsHost(scope, deps, target, input) {
944
972
  // the AI worker. A deploy that fails or times out throws here, records
945
973
  // nothing and leaves the step planned.
946
974
  if (!existingApp || tokenJustSet || pending || !running || addressNew) {
947
- await waitForNewDeployment(coolify, appId, { kind: 'deployment-only' }, runtime, runtime.sleep ?? defaultSleep, label, 'the scheduler keeps its old address');
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));
948
977
  }
949
978
  if (pending) {
950
979
  scope.recordScopeId(JOBS_DEPLOY_PENDING_SCOPE_KEY, '');
@@ -991,7 +1020,7 @@ const AUTH_ZITADEL_PORT = '8080';
991
1020
  */
992
1021
  export function zitadelDockerfile() {
993
1022
  return [
994
- `FROM ${AUTH_ZITADEL_IMAGE}:${AUTH_ZITADEL_IMAGE_TAG}`,
1023
+ `FROM ${AUTH_ZITADEL_IMAGE}:${pinnedRef('zitadel')}`,
995
1024
  `EXPOSE ${AUTH_ZITADEL_PORT}`,
996
1025
  'CMD ["start-from-init", "--masterkeyFromEnv", "--tlsMode", "external"]',
997
1026
  'HEALTHCHECK --interval=15s --timeout=5s --start-period=180s --retries=5 CMD ["/app/zitadel", "ready"]',
@@ -1505,6 +1534,10 @@ export async function provisionAuthStack(scope, deps, target, input) {
1505
1534
  { key: 'ZITADEL_DATABASE_POSTGRES_DSN', value: db.internal_db_url },
1506
1535
  { key: 'ZITADEL_FIRSTINSTANCE_SKIP', value: 'true' },
1507
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' },
1508
1541
  {
1509
1542
  key: 'ZITADEL_DEFAULTINSTANCE_FEATURES_LOGINV2_BASEURI',
1510
1543
  value: `https://${hostname}/`,
@@ -1766,6 +1799,16 @@ export async function provisionAuthStack(scope, deps, target, input) {
1766
1799
  if (recorded[PLATFORM_ZITADEL_ORG_SCOPE_KEY] !== org.id) {
1767
1800
  await record(PLATFORM_ZITADEL_ORG_SCOPE_KEY, org.id);
1768
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
+ }
1769
1812
  const { userId, token } = await zitadel.mintAdminMachineUser(org.id, {
1770
1813
  userName: PLATFORM_AUTH_ADMIN_USER_NAME,
1771
1814
  name: 'Foundry (platform admin)',
@@ -2064,6 +2107,109 @@ async function provisionPlatformAuth(context, deps, ownerCompany = null) {
2064
2107
  ownerCompany,
2065
2108
  });
2066
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
+ }
2067
2213
  /**
2068
2214
  * Reserves the username `admin` and switches self-registration off in the
2069
2215
  * platform's sign-in org (`zitadel:sign-in-policy` keyed {@link PLATFORM_AUTH_KEY}).
@@ -2138,7 +2284,7 @@ const PLATFORM_AUTH_MAIL_SENDER_NAME = 'Company Included';
2138
2284
  // #169: the platform's own mail server (Stalwart)
2139
2285
  // ---------------------------------------------------------------------------
2140
2286
  const PLATFORM_MAIL_PROJECT = 'platform';
2141
- const PLATFORM_MAIL_INTERNAL_NAME = 'platform-mail-stalwart';
2287
+ export const PLATFORM_MAIL_INTERNAL_NAME = 'platform-mail-stalwart';
2142
2288
  /**
2143
2289
  * `stalw.art/docs/install/docker`: the official image. `platform.mail.gitRef`
2144
2290
  * carries its tag -- `platform.ts`'s `PlatformService.gitRef` doc comment
@@ -2184,7 +2330,7 @@ const STALWART_RECOVERY_USER = 'admin';
2184
2330
  */
2185
2331
  const STALWART_READY_PATH = '/healthz/ready';
2186
2332
  /** Where the server the app was created on is recorded, so the MX record follows the app, not a later pick. */
2187
- const PLATFORM_MAIL_SERVER_SCOPE_KEY = 'coolify_platform_mail_server';
2333
+ export const PLATFORM_MAIL_SERVER_SCOPE_KEY = 'coolify_platform_mail_server';
2188
2334
  const PLATFORM_MAIL_PROJECT_SCOPE_KEY = 'coolify_project_platform_mail';
2189
2335
  const PLATFORM_MAIL_APP_SCOPE_KEY = 'coolify_platform_mail_app';
2190
2336
  const PLATFORM_MAIL_RELAY_APP_SCOPE_KEY = 'coolify_platform_mail_relay_app';
@@ -2357,6 +2503,7 @@ const PLATFORM_MAIL_ZITADEL_PROJECT_NAME = 'mail';
2357
2503
  */
2358
2504
  export async function provisionMailServer(scope, deps, target, input) {
2359
2505
  const { hostname, apex, mailHost, imageTag, hostPorts, issuerUrl, mxRecord, network } = input;
2506
+ const zone = input.zone ?? apex;
2360
2507
  const label = target.kind === 'org' ? `own step coolify:own-mail for ${target.id}` : 'platform:mail';
2361
2508
  const keyDescription = target.kind === 'org' ? `foundry:org:${target.id}:admin` : PLATFORM_MAIL_ADMIN_KEY_DESCRIPTION;
2362
2509
  const recorded = scope.recordedIds();
@@ -2393,7 +2540,7 @@ export async function provisionMailServer(scope, deps, target, input) {
2393
2540
  environment_name: environment.name,
2394
2541
  environment_uuid: environment.uuid,
2395
2542
  docker_registry_image_name: STALWART_IMAGE,
2396
- docker_registry_image_tag: imageTag,
2543
+ docker_registry_image_tag: pinnedTagFor('stalwart', imageTag),
2397
2544
  ports_exposes: STALWART_PORTS_EXPOSES,
2398
2545
  ...(hostPorts ? { ports_mappings: hostPorts } : {}),
2399
2546
  name: target.names.mailApp,
@@ -2413,7 +2560,7 @@ export async function provisionMailServer(scope, deps, target, input) {
2413
2560
  // `'ready'` probe below. Stalwart's own readiness route and HTTP port.
2414
2561
  await deps.coolify.updateApplication(appId, {
2415
2562
  custom_internal_name: target.internal.mail,
2416
- docker_registry_image_tag: imageTag,
2563
+ docker_registry_image_tag: pinnedTagFor('stalwart', imageTag),
2417
2564
  ...healthCheckSettings(STALWART_READY_PATH, Number(STALWART_HTTP_PORT)),
2418
2565
  // Only a platform that uses the shared relay, or one that did, changes a published port.
2419
2566
  ...(portsChanged ? { ports_mappings: hostPorts } : {}),
@@ -2513,15 +2660,15 @@ export async function provisionMailServer(scope, deps, target, input) {
2513
2660
  recordedConnectorAppId: recorded[PLATFORM_MAIL_CONNECTOR_APP_SCOPE_KEY] ?? null,
2514
2661
  recordAppId: (appId) => record(PLATFORM_MAIL_CONNECTOR_APP_SCOPE_KEY, appId),
2515
2662
  });
2516
- const zoneId = await network.zoneId(apex);
2663
+ const zoneId = await network.zoneId(zone);
2517
2664
  if (!zoneId) {
2518
- throw new Error(`${label}: no Cloudflare zone for ${apex} to put ${hostname} in`);
2665
+ throw new Error(`${label}: no Cloudflare zone for ${zone} to put ${hostname} in`);
2519
2666
  }
2520
2667
  const dnsTarget = `${tunnel.id}.cfargotunnel.com`;
2521
2668
  const dns = await network.dns();
2522
- const [dnsRecord] = await dns.findDnsRecord(apex, hostname, 'CNAME');
2669
+ const [dnsRecord] = await dns.findDnsRecord(zone, hostname, 'CNAME');
2523
2670
  if (!dnsRecord) {
2524
- await dns.createDnsRecord(apex, {
2671
+ await dns.createDnsRecord(zone, {
2525
2672
  type: 'CNAME',
2526
2673
  name: hostname,
2527
2674
  content: dnsTarget,
@@ -2529,7 +2676,7 @@ export async function provisionMailServer(scope, deps, target, input) {
2529
2676
  });
2530
2677
  }
2531
2678
  else if (dnsRecord.content !== dnsTarget) {
2532
- await dns.updateDnsRecord(apex, dnsRecord.id, {
2679
+ await dns.updateDnsRecord(zone, dnsRecord.id, {
2533
2680
  type: 'CNAME',
2534
2681
  name: hostname,
2535
2682
  content: dnsTarget,
@@ -2541,9 +2688,9 @@ export async function provisionMailServer(scope, deps, target, input) {
2541
2688
  // ---- the MX hostname: a direct, unproxied A record --------------------------
2542
2689
  if (mxRecord) {
2543
2690
  const serverIp = (mxRecord.serverIpv4 ?? server.ip); // checked by mailServerIpBlocked above
2544
- const [existingMx] = await dns.findDnsRecord(apex, mailHost, 'A');
2691
+ const [existingMx] = await dns.findDnsRecord(zone, mailHost, 'A');
2545
2692
  if (!existingMx) {
2546
- await dns.createDnsRecord(apex, {
2693
+ await dns.createDnsRecord(zone, {
2547
2694
  type: 'A',
2548
2695
  name: mailHost,
2549
2696
  content: serverIp,
@@ -2551,7 +2698,7 @@ export async function provisionMailServer(scope, deps, target, input) {
2551
2698
  });
2552
2699
  }
2553
2700
  else if (existingMx.content !== serverIp) {
2554
- await dns.updateDnsRecord(apex, existingMx.id, {
2701
+ await dns.updateDnsRecord(zone, existingMx.id, {
2555
2702
  type: 'A',
2556
2703
  name: mailHost,
2557
2704
  content: serverIp,
@@ -2797,28 +2944,87 @@ async function mailBlockedReason(platform, mail, recordedIds) {
2797
2944
  return "the platform's mail server is blocked, not planned: no usable Coolify server could be resolved for it";
2798
2945
  }
2799
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
+ }
2800
2962
  /** Why the shared mail relay cannot be built from its pinned base image, or `null`. */
2801
2963
  function mailRelayBlockedReason() {
2802
2964
  const tagBlocked = floatingTagBlocked('mail relay base', VERSIONS.alpine, '3.22.1');
2803
2965
  return tagBlocked ? `the platform's mail relay is blocked, not planned: ${tagBlocked}` : null;
2804
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
+ }
2805
2979
  /**
2806
- * Runs the shared mail relay step against the project and server the platform
2807
- * mail step recorded, so the relay sits on the Docker network and the server
2808
- * address of the Stalwart it forwards to.
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).
2809
2982
  */
2810
2983
  async function provisionPlatformMailRelay(action, context, deps) {
2811
2984
  const recorded = context.recordedIds();
2812
- const projectUuid = recorded[PLATFORM_MAIL_PROJECT_SCOPE_KEY];
2813
- const serverUuid = recorded[PLATFORM_MAIL_SERVER_SCOPE_KEY];
2814
- if (!projectUuid || !serverUuid) {
2815
- throw new Error('platform:mail-relay: the platform mail server has not recorded its project and server yet; apply the mail server first');
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);
2816
3023
  }
2817
3024
  return provisionMailRelay(action, context, deps, {
2818
3025
  projectUuid,
2819
3026
  serverUuid,
2820
- hostname: platformMailMxHost(context.platform.apex),
2821
- defaultTarget: `${PLATFORM_MAIL_INTERNAL_NAME}:25`,
3027
+ hostname: mxHost,
2822
3028
  appScopeKey: PLATFORM_MAIL_RELAY_APP_SCOPE_KEY,
2823
3029
  });
2824
3030
  }
@@ -3052,9 +3258,75 @@ const PLATFORM_ARCHON_PROJECT_SCOPE_KEY = 'coolify_project_platform_archon';
3052
3258
  const PLATFORM_ARCHON_DB_SCOPE_KEY = 'coolify_platform_archon_database';
3053
3259
  const PLATFORM_ARCHON_TUNNEL_SCOPE_KEY = 'cloudflare_platform_archon_tunnel';
3054
3260
  const PLATFORM_ARCHON_ACCESS_SCOPE_KEY = 'cloudflare_platform_archon_access_app';
3055
- 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
+ }
3056
3326
  /** The hostname the sign-in client's redirect URIs were made for; a change makes a new client. */
3057
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';
3058
3330
  /** Archon's OIDC client id: public, not a secret, so a plain scope id. */
3059
3331
  export const PLATFORM_ARCHON_CLIENT_ID_SCOPE_KEY = 'zitadel_platform_archon_client_id';
3060
3332
  /**
@@ -3065,13 +3337,16 @@ export const PLATFORM_ARCHON_CLIENT_ID_SCOPE_KEY = 'zitadel_platform_archon_clie
3065
3337
  * names the ids only, never the cause.
3066
3338
  */
3067
3339
  async function switchOnArchonIdTokenUserinfo(context, deps) {
3068
- const { platform } = context;
3069
3340
  const recorded = context.recordedIds();
3070
- 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;
3071
3346
  // The planner plans nothing without the method; a stale plan does nothing.
3072
- if (!zitadel.updateOidcAppIdTokenUserinfoAssertion)
3347
+ if (!ownerSignIn || !zitadel?.updateOidcAppIdTokenUserinfoAssertion)
3073
3348
  return {};
3074
- const orgId = recorded[PLATFORM_ZITADEL_ORG_SCOPE_KEY];
3349
+ const orgId = ownerSignIn.organisationId;
3075
3350
  const projectId = recorded[PLATFORM_ARCHON_ZITADEL_PROJECT_SCOPE_KEY];
3076
3351
  const appId = recorded[PLATFORM_ARCHON_ZITADEL_APP_SCOPE_KEY];
3077
3352
  if (!orgId || !projectId || !appId) {
@@ -3197,7 +3472,25 @@ async function provisionPlatformArchon(context, deps, ownerCompany = null) {
3197
3472
  const hostname = platform.archon.hostname;
3198
3473
  const gitRef = platform.archon.gitRef;
3199
3474
  const recorded = context.recordedIds();
3200
- 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);
3201
3494
  const appliedGitRef = recorded[PLATFORM_ARCHON_APPLIED_GIT_REF_SCOPE_KEY];
3202
3495
  const runtime = deps.runtime ?? {};
3203
3496
  const sleep = runtime.sleep ?? defaultSleep;
@@ -3205,13 +3498,7 @@ async function provisionPlatformArchon(context, deps, ownerCompany = null) {
3205
3498
  if (deps.accessEmails.length === 0) {
3206
3499
  throw new Error('platform:archon: no Access emails given; refusing to put Archon behind an Access app nobody can pass');
3207
3500
  }
3208
- // The sign-in client below is created inside the platform's own ZITADEL,
3209
- // which `platform:auth` builds. The planner orders that step first and
3210
- // blocks this one while auth is off; this is the same rule for a caller
3211
- // that reaches the step anyway.
3212
- if (!platform.auth.enabled || !recorded[`coolify:app:${PLATFORM_AUTH_KEY}`]) {
3213
- 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");
3214
- }
3501
+ const accessEmails = archonAccessEmails(deps.accessEmails, ownerCompany);
3215
3502
  const record = async (key, id) => {
3216
3503
  context.recordScopeId(key, id);
3217
3504
  await context.persist?.();
@@ -3219,7 +3506,7 @@ async function provisionPlatformArchon(context, deps, ownerCompany = null) {
3219
3506
  // Every service the platform declares must have delivered what Archon reads.
3220
3507
  // The planner blocks first; this is the same rule for a caller that reaches
3221
3508
  // the step anyway, and it is where the values are read for the env below.
3222
- const inputs = await collectArchonServiceInputs(platform, deps, recorded);
3509
+ const inputs = await collectArchonServiceInputs(platform, deps, recorded, ownerFiles);
3223
3510
  if ('blocked' in inputs)
3224
3511
  throw new Error(`platform:archon: ${inputs.blocked}`);
3225
3512
  // #213: Archon's own onboarding reads these back as held keys before it
@@ -3324,11 +3611,25 @@ async function provisionPlatformArchon(context, deps, ownerCompany = null) {
3324
3611
  ...healthCheckSettings(ARCHON_HEALTH_CHECK_PATH, Number(ARCHON_PORT), ARCHON_HEALTH_CHECK_START_PERIOD_SECONDS),
3325
3612
  git_branch: gitRef,
3326
3613
  });
3327
- // ---- sign-in client in the platform's own ZITADEL -------------------------
3328
- const zitadel = deps.zitadelForInstance?.(issuer) ?? deps.zitadel;
3329
- const { org } = await zitadel.findOrCreateOrg(PLATFORM_AUTH_ORG_NAME);
3330
- if (recorded[PLATFORM_ZITADEL_ORG_SCOPE_KEY] !== org.id) {
3331
- 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}`);
3332
3633
  }
3333
3634
  const { project } = await zitadel.findOrCreateProject(org.id, PLATFORM_ARCHON_ZITADEL_PROJECT);
3334
3635
  if (recorded[PLATFORM_ARCHON_ZITADEL_PROJECT_SCOPE_KEY] !== project.id) {
@@ -3337,7 +3638,8 @@ async function provisionPlatformArchon(context, deps, ownerCompany = null) {
3337
3638
  // The client's redirect URIs name the hostname, and no call updates them, so
3338
3639
  // a client made for another hostname is deleted and made again. Its id is
3339
3640
  // public and there is no secret to lose.
3340
- 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
3341
3643
  ? recorded[PLATFORM_ARCHON_CLIENT_ID_SCOPE_KEY]
3342
3644
  : undefined;
3343
3645
  if (!clientId) {
@@ -3369,18 +3671,18 @@ async function provisionPlatformArchon(context, deps, ownerCompany = null) {
3369
3671
  // Made with the setting on (providers 0.9.0): nothing to repair on this id.
3370
3672
  context.recordScopeId(PLATFORM_ARCHON_ID_TOKEN_USERINFO_APPLIED_SCOPE_KEY, created.appId);
3371
3673
  context.recordScopeId(PLATFORM_ARCHON_CLIENT_HOSTNAME_SCOPE_KEY, hostname);
3674
+ context.recordScopeId(PLATFORM_ARCHON_CLIENT_ORG_SCOPE_KEY, org.id);
3372
3675
  await record(PLATFORM_ARCHON_CLIENT_ID_SCOPE_KEY, created.clientId);
3373
3676
  clientId = created.clientId;
3374
3677
  }
3375
3678
  // ---- env ------------------------------------------------------------------
3376
- // People sign in from the owner company's sign-in org. The project, client
3377
- // and Access app above stay in the `foundry` org, which holds no people.
3378
- const authOrgId = archonAuthOrganizationId(context.recordedIds()) ?? org.id;
3379
3679
  const envs = [
3380
3680
  { key: 'APP_URL', value: `https://${hostname}` },
3381
3681
  { key: 'AUTH_ISSUER', value: issuer },
3382
3682
  { key: 'AUTH_ORGANIZATION_ID', value: authOrgId },
3683
+ { key: 'CI_ORGANIZATION_ID', value: ciOrgId },
3383
3684
  { key: 'AUTH_CLIENT_ID', value: clientId },
3685
+ ...(firstOwnerLink ? [{ key: 'ARCHON_FIRST_OWNER_LINK', value: firstOwnerLink }] : []),
3384
3686
  ];
3385
3687
  // #265: an owner URL or reporting site already on the app is kept as it is.
3386
3688
  // The owner URL must name the adopted database's host, or the app's two
@@ -3453,7 +3755,12 @@ async function provisionPlatformArchon(context, deps, ownerCompany = null) {
3453
3755
  // empty, and `readBrokerApps`/`validateBrokerAppEntry` would refuse it the
3454
3756
  // moment this step tried to register it. See `ensureGeneratedEnv`'s own
3455
3757
  // doc comment for why no other call site gets this argument.
3456
- 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')));
3457
3764
  if (!appKey) {
3458
3765
  throw new Error(`platform:archon: app ${appId} lost its AUTH_APP_KEY between two reads`);
3459
3766
  }
@@ -3462,7 +3769,8 @@ async function provisionPlatformArchon(context, deps, ownerCompany = null) {
3462
3769
  // rule above), and hashed as the live value so it never reads as a changed
3463
3770
  // env.
3464
3771
  const keepLiveFilesEnv = new Set();
3465
- 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']) {
3466
3774
  const live = await currentEnvValue(deps.coolify, appId, key);
3467
3775
  if (live) {
3468
3776
  keepLiveFilesEnv.add(key);
@@ -3520,6 +3828,10 @@ async function provisionPlatformArchon(context, deps, ownerCompany = null) {
3520
3828
  throw error;
3521
3829
  throw new Error(redactSecrets(error instanceof Error ? error.message : String(error), carried));
3522
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);
3523
3835
  // #436 round 2: `bulkUpsertEnvs` only adds or updates, so the
3524
3836
  // `FILES_MANAGEMENT_KEY` this step used to write before the fix above
3525
3837
  // would otherwise survive on an Archon already deployed with it --
@@ -3543,7 +3855,10 @@ async function provisionPlatformArchon(context, deps, ownerCompany = null) {
3543
3855
  const staleUptime = !platform.uptime.enabled && stale.key === 'UPTIME_TOKEN';
3544
3856
  // People now come from the owner company's store, so a first operator set by an older apply goes.
3545
3857
  const staleOperator = stale.key === 'AUTHZ_BOOTSTRAP_OPERATOR';
3546
- 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) {
3547
3862
  await deps.coolify.deleteEnv(appId, stale.uuid);
3548
3863
  staleKeyDeleted = true;
3549
3864
  }
@@ -3556,10 +3871,7 @@ async function provisionPlatformArchon(context, deps, ownerCompany = null) {
3556
3871
  // returns), and this step is unreachable until that resourceId is recorded
3557
3872
  // (the guard at the top of this function) -- so a missing id here means a
3558
3873
  // caller reached this step some other way.
3559
- const brokerAppId = recorded[PLATFORM_AUTH_BROKER_SCOPE_KEY];
3560
- if (!brokerAppId) {
3561
- throw new Error('platform:archon: platform:auth has not recorded its broker app id yet; apply platform:auth again');
3562
- }
3874
+ const brokerAppId = ownerSignIn.brokerAppId;
3563
3875
  const brokerApps = await readBrokerApps(deps.coolify, brokerAppId);
3564
3876
  // #211 round 2: merged into the matching entry, not replacing it -- the
3565
3877
  // broker's own `App` (`broker/src/apps.ts`) carries fields this step never
@@ -3573,14 +3885,22 @@ async function provisionPlatformArchon(context, deps, ownerCompany = null) {
3573
3885
  // first -- a previous bug, or a hand-edited AUTH_APPS, can leave more than
3574
3886
  // one. The first is kept (and updated below); the rest are dropped, so no
3575
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
+ }
3576
3893
  const archonMatchIndices = brokerApps
3577
- .map((a, i) => (a.organizationId === authOrgId || a.organizationId === org.id) &&
3578
- a.name === PLATFORM_ARCHON_ZITADEL_APP
3579
- ? i
3580
- : -1)
3894
+ .map((a, i) => a.organizationId === authOrgId && a.name === PLATFORM_ARCHON_ZITADEL_APP ? i : -1)
3581
3895
  .filter((i) => i >= 0);
3582
3896
  const archonEntryIndex = archonMatchIndices[0] ?? -1;
3583
- 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]);
3584
3904
  const archonEntry = {
3585
3905
  ...(archonEntryIndex >= 0 ? brokerApps[archonEntryIndex] : {}),
3586
3906
  organizationId: authOrgId,
@@ -3595,11 +3915,10 @@ async function provisionPlatformArchon(context, deps, ownerCompany = null) {
3595
3915
  // the wrong platform or not at all.
3596
3916
  from: `Company Included <noreply@${platform.apex}>`,
3597
3917
  };
3598
- const desiredBrokerApps = archonEntryIndex >= 0
3599
- ? brokerApps
3600
- .map((a, i) => (i === archonEntryIndex ? archonEntry : a))
3601
- .filter((_, i) => !duplicateIndices.has(i))
3602
- : [...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];
3603
3922
  const desiredAuthApps = [{ key: 'AUTH_APPS', value: JSON.stringify(desiredBrokerApps) }];
3604
3923
  // Rebuilt and compared every run (not gated on a freshly minted key), so a
3605
3924
  // clientId or hostname change reaches the broker too, and so a previous
@@ -3634,6 +3953,23 @@ async function provisionPlatformArchon(context, deps, ownerCompany = null) {
3634
3953
  await deps.coolify.deploy(brokerAppId);
3635
3954
  await record(PLATFORM_AUTH_BROKER_DEPLOYED_SCOPE_KEY, brokerDeployRecordValue(brokerAppId, desiredAuthAppsRaw));
3636
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
+ }
3637
3973
  // ---- Cloudflare Access, before the hostname resolves ----------------------
3638
3974
  const tunnels = await platformTunnels(deps, context.recordedIds(), 'platform:archon');
3639
3975
  const access = await platformAccess(deps, context.recordedIds(), 'platform:archon');
@@ -3641,7 +3977,7 @@ async function provisionPlatformArchon(context, deps, ownerCompany = null) {
3641
3977
  const uptimeAccessTokenId = platform.uptime.enabled
3642
3978
  ? recorded[PLATFORM_UPTIME_ACCESS_TOKEN_ID_SCOPE_KEY]
3643
3979
  : undefined;
3644
- const accessAppId = await ensureArchonAccess(access, hostname, deps.accessEmails, uptimeAccessTokenId, async () => {
3980
+ const { id: accessAppId, pinId } = await ensureArchonAccess(access, hostname, accessEmails, uptimeAccessTokenId, async () => {
3645
3981
  // The Access app is about to be rebuilt: answer 404 for every hostname
3646
3982
  // meanwhile, so it is never served without the gate. The marker is
3647
3983
  // persisted first: a failure before the real ingress is written back
@@ -3657,6 +3993,10 @@ async function provisionPlatformArchon(context, deps, ownerCompany = null) {
3657
3993
  if (recorded[PLATFORM_ARCHON_ACCESS_SCOPE_KEY] !== accessAppId) {
3658
3994
  await record(PLATFORM_ARCHON_ACCESS_SCOPE_KEY, accessAppId);
3659
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);
3660
4000
  // ---- deploy ---------------------------------------------------------------
3661
4001
  // #252: phase 1 of the shared deploy-health helper only -- Coolify's own
3662
4002
  // deployment record reaching `'finished'`, which is enough on its own to
@@ -3709,7 +4049,8 @@ async function provisionPlatformArchon(context, deps, ownerCompany = null) {
3709
4049
  // retry intends is identical to what a successful one intends, so
3710
4050
  // leaving the old confirmation in place would read as still correct.
3711
4051
  await record(PLATFORM_ARCHON_ENVS_DEPLOYED_SCOPE_KEY, '');
3712
- 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);
3713
4054
  }
3714
4055
  // ---- tunnel and DNS ---------------------------------------------------------
3715
4056
  const existingTunnel = await tunnels.findTunnelByName(PLATFORM_ARCHON_TUNNEL_NAME);
@@ -3815,6 +4156,10 @@ async function provisionPlatformArchon(context, deps, ownerCompany = null) {
3815
4156
  // it.
3816
4157
  await record(PLATFORM_ARCHON_APPLIED_BROKER_SCOPE_KEY, brokerAppId);
3817
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));
3818
4163
  // The host runs inside this app: every marker is recorded above, so the
3819
4164
  // deploy goes last and is not waited for -- the old container stops during
3820
4165
  // it, and the response, the lease and the audit row must be done first.
@@ -4126,6 +4471,9 @@ const PLATFORM_ARCHON_UPTIME_POLICY_NAME = 'foundry: uptime service token';
4126
4471
  * is added in place, one for another token rebuilds the app.
4127
4472
  */
4128
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();
4129
4477
  const onDomain = (await cf.listAccessApps()).filter((a) => a.domain === domain);
4130
4478
  const foreign = onDomain.find((a) => a.name !== PLATFORM_ARCHON_ACCESS_APP_NAME);
4131
4479
  if (foreign) {
@@ -4136,6 +4484,8 @@ async function ensureArchonAccess(cf, domain, emails, uptimeTokenId, closeGate)
4136
4484
  name: PLATFORM_ARCHON_ACCESS_APP_NAME,
4137
4485
  domain,
4138
4486
  type: 'self_hosted',
4487
+ allowedIdps: [pinId],
4488
+ autoRedirectToIdentity: true,
4139
4489
  });
4140
4490
  await cf.createAccessPolicy(created.id, archonAccessPolicy(emails));
4141
4491
  if (uptimeTokenId) {
@@ -4145,7 +4495,7 @@ async function ensureArchonAccess(cf, domain, emails, uptimeTokenId, closeGate)
4145
4495
  };
4146
4496
  const existing = onDomain[0];
4147
4497
  if (!existing)
4148
- return (await create()).id;
4498
+ return { id: (await create()).id, pinId };
4149
4499
  const policies = await cf.listAccessPolicies(existing.id);
4150
4500
  const other = policies.find((p) => p.name !== ACCESS_POLICY_NAME && p.name !== PLATFORM_ARCHON_UPTIME_POLICY_NAME);
4151
4501
  if (other) {
@@ -4154,19 +4504,52 @@ async function ensureArchonAccess(cf, domain, emails, uptimeTokenId, closeGate)
4154
4504
  const emailPolicies = policies.filter((p) => p.name === ACCESS_POLICY_NAME);
4155
4505
  const tokenPolicies = policies.filter((p) => p.name === PLATFORM_ARCHON_UPTIME_POLICY_NAME);
4156
4506
  const staleToken = tokenPolicies.some((p) => !uptimePolicyIntact(p, uptimeTokenId));
4157
- if (emailPolicies.every((p) => sameEmails(p.include, emails)) && !staleToken) {
4507
+ if (emailPolicies.every((p) => sameEmails(p.include, emails)) &&
4508
+ !staleToken &&
4509
+ accessAppPinned(existing, pinId)) {
4158
4510
  if (emailPolicies.length === 0) {
4159
4511
  await cf.createAccessPolicy(existing.id, archonAccessPolicy(emails));
4160
4512
  }
4161
4513
  if (uptimeTokenId && tokenPolicies.length === 0) {
4162
4514
  await cf.createAccessPolicy(existing.id, archonUptimePolicy(uptimeTokenId));
4163
4515
  }
4164
- return existing.id;
4516
+ return { id: existing.id, pinId };
4165
4517
  }
4166
- // 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.
4167
4520
  await closeGate();
4168
4521
  await cf.deleteAccessApp(existing.id);
4169
- 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;
4170
4553
  }
4171
4554
  function archonAccessPolicy(emails) {
4172
4555
  return {
@@ -4224,6 +4607,10 @@ async function archonAccessDrifted(deps, recorded, domain, emails, uptimeTokenId
4224
4607
  const app = (await cf.listAccessApps()).find((a) => a.domain === domain && a.name === PLATFORM_ARCHON_ACCESS_APP_NAME);
4225
4608
  if (!app)
4226
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;
4227
4614
  const policies = await cf.listAccessPolicies(app.id);
4228
4615
  const emailPolicies = policies.filter((p) => p.name === ACCESS_POLICY_NAME);
4229
4616
  const tokenPolicies = policies.filter((p) => p.name === PLATFORM_ARCHON_UPTIME_POLICY_NAME);
@@ -4234,7 +4621,15 @@ async function archonAccessDrifted(deps, recorded, domain, emails, uptimeTokenId
4234
4621
  }
4235
4622
  if (!tokenPolicies.every((p) => uptimePolicyIntact(p, uptimeTokenId)))
4236
4623
  return true;
4237
- 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;
4238
4633
  }
4239
4634
  catch {
4240
4635
  return false;
@@ -4254,19 +4649,43 @@ async function archonAccessDrifted(deps, recorded, domain, emails, uptimeTokenId
4254
4649
  * recorded) is not drift either -- `planArchon`'s other checks already cover
4255
4650
  * a step that has never applied.
4256
4651
  */
4257
- async function archonBrokerRegistrationDrifted(coolify, recordedIds) {
4258
- const brokerAppId = recordedIds[PLATFORM_AUTH_BROKER_SCOPE_KEY];
4259
- const orgId = archonAuthOrganizationId(recordedIds);
4260
- if (!brokerAppId || !orgId)
4652
+ async function archonBrokerRegistrationDrifted(coolify, signIn, archonOrgId, recorded = {}) {
4653
+ if (!signIn || !archonOrgId)
4261
4654
  return false;
4262
4655
  try {
4263
- const apps = await readBrokerApps(coolify, brokerAppId);
4264
- 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;
4265
4662
  }
4266
4663
  catch {
4267
4664
  return false;
4268
4665
  }
4269
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
+ }
4270
4689
  /** The decoded password of a connection URL, or `undefined` when it has none or cannot be parsed. */
4271
4690
  function passwordOf(url) {
4272
4691
  try {
@@ -4399,17 +4818,31 @@ async function archonAuthzDatabaseDrifted(coolify, appId, owner) {
4399
4818
  }
4400
4819
  }
4401
4820
  /**
4402
- * Whether Archon's live `AUTH_ORGANIZATION_ID` is not the sign-in org it
4403
- * should accept ({@link archonAuthOrganizationId}). Without this nothing
4404
- * replans Archon when the owner company's org changes, since the step plans
4405
- * 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.
4406
4827
  */
4407
- async function archonAuthOrgDrifted(coolify, appId, recordedIds) {
4408
- const expected = archonAuthOrganizationId(recordedIds);
4409
- if (!appId || !expected)
4828
+ async function archonAuthOrgDrifted(coolify, appId, expected) {
4829
+ if (!appId)
4410
4830
  return false;
4411
4831
  try {
4412
- 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;
4413
4846
  }
4414
4847
  catch {
4415
4848
  return false;
@@ -4492,13 +4925,15 @@ async function archonRollingUpdateDrifted(coolify, appId) {
4492
4925
  * held-key ref, never a value. Also read at plan time, so the text must not
4493
4926
  * vary between calls.
4494
4927
  */
4495
- async function collectArchonServiceInputs(platform, deps, recorded) {
4928
+ async function collectArchonServiceInputs(platform, deps, recorded, ownerFiles = null) {
4496
4929
  const block = (why) => ({
4497
4930
  blocked: `Archon is blocked, not planned: ${why}`,
4498
4931
  });
4499
4932
  const envs = [];
4500
4933
  const heldByRef = new Map();
4501
- 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);
4502
4937
  if (services.length > 0 && !deps.heldKeys) {
4503
4938
  return block(`the host built the platform executor without a held-key reader, and ${services.join(', ')} ${services.length === 1 ? 'is' : 'are'} enabled`);
4504
4939
  }
@@ -4551,31 +4986,7 @@ async function collectArchonServiceInputs(platform, deps, recorded) {
4551
4986
  }
4552
4987
  const held = (ref) => heldByRef.get(ref);
4553
4988
  for (const service of services) {
4554
- if (service === 'files') {
4555
- envs.push(
4556
- // #501: Files is private and has no DNS, so FILES_URL is the recorded
4557
- // internal address; FILES_AUDIENCE is the exact HTTPS origin the
4558
- // signature is made for, equal to Files' own `APP_URL`.
4559
- { key: 'FILES_URL', value: recorded[PLATFORM_FILES_INTERNAL_URL_SCOPE_KEY] }, { key: 'FILES_AUDIENCE', value: `https://${platform.files.hostname}` },
4560
- // #436: Company Included's own Files organisation, provisioned by
4561
- // the ordinary per-org `planFilesOrganisation` step under its own
4562
- // org id -- `executor.ts`'s `filesOrganisation` calls
4563
- // `provisionOrganisation({ organisationId: org.id, ... })`, so
4564
- // `platform.ownerOrgId` is already that organisation's id, with no
4565
- // separate platform-level mint needed.
4566
- { key: 'FILES_ORGANISATION_ID', value: platform.ownerOrgId },
4567
- // #436: the signed-assertion identity `filesSigner()` reads
4568
- // (`archon`'s `src/lib/authz/files-management.ts`), not the bearer
4569
- // token `FILES_MANAGEMENT_KEY` used to name here -- that name made
4570
- // Archon take the issued-key branch of `filesManagementFetch`,
4571
- // which then failed on the `FILES_MANAGEMENT_SCOPE` this step never
4572
- // set, surfacing as "Files is not configured" (foundry#436).
4573
- { key: 'FILES_MANAGEMENT_ISSUER', value: FILES_MANAGEMENT_ISSUER }, { key: 'FILES_MANAGEMENT_KEY_ID', value: FILES_MANAGEMENT_KEY_ID }, {
4574
- key: 'FILES_MANAGEMENT_PRIVATE_KEY',
4575
- value: held(ARCHON_SERVICE_INPUTS.files.refs[0]),
4576
- });
4577
- }
4578
- else if (service === 'ai') {
4989
+ if (service === 'ai') {
4579
4990
  envs.push({
4580
4991
  key: 'AI_URL',
4581
4992
  value: recorded[PLATFORM_AI_INTERNAL_URL_SCOPE_KEY],
@@ -4611,6 +5022,22 @@ async function collectArchonServiceInputs(platform, deps, recorded) {
4611
5022
  { key: 'JOBS_URL', value: recorded[JOBS_INTERNAL_URL_SCOPE_KEY] }, { key: 'JOBS_ADMIN_TOKEN', value: held(JOBS_ADMIN_TOKEN_REF) });
4612
5023
  }
4613
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
+ }
4614
5041
  return { envs };
4615
5042
  }
4616
5043
  /**
@@ -5209,7 +5636,8 @@ async function provisionPlatformAi(context, deps) {
5209
5636
  // nothing public is wired to either app.
5210
5637
  const internalUrl = `http://${PLATFORM_AI_RUNNER.internalName}:${PLATFORM_AI_PORT}`;
5211
5638
  if (deployRunner) {
5212
- 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);
5213
5641
  }
5214
5642
  if (deployWorker) {
5215
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'}`);
@@ -5295,6 +5723,54 @@ async function provisionPlatformAi(context, deps) {
5295
5723
  }
5296
5724
  await record(PLATFORM_AI_MANAGEMENT_KEY_SCOPE_KEY, credentialId);
5297
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);
5298
5774
  return { resourceId: runner.appId };
5299
5775
  }
5300
5776
  /**
@@ -6252,7 +6728,8 @@ export async function provisionFilesHost(scope, deps, target, input) {
6252
6728
  // record first is still what rules out the old container staying healthy
6253
6729
  // through a failed deploy.
6254
6730
  const internalUrl = `http://${target.internal.files}:${FILES_PORT}`;
6255
- 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'}`);
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);
6256
6733
  await record(PLATFORM_FILES_INTERNAL_URL_SCOPE_KEY, internalUrl);
6257
6734
  // Last: a run that failed anywhere above is planned again.
6258
6735
  scope.recordScopeId(PLATFORM_FILES_APPLIED_HOSTNAME_SCOPE_KEY, input.hostname);
@@ -6578,7 +7055,6 @@ const PLATFORM_ACCESS_PROVIDER_NAME = 'foundry';
6578
7055
  * again.
6579
7056
  */
6580
7057
  async function provisionPlatformAccessProvider(context, deps) {
6581
- const { platform } = context;
6582
7058
  const recorded = context.recordedIds();
6583
7059
  const sleep = deps.runtime?.sleep ?? defaultSleep;
6584
7060
  const attempts = deps.runtime?.deliverAttempts ?? 3;
@@ -6597,10 +7073,19 @@ async function provisionPlatformAccessProvider(context, deps) {
6597
7073
  catch {
6598
7074
  throw new Error(`${label}: edge could not confirm that Cloudflare Access is ready`);
6599
7075
  }
6600
- const issuer = `https://${platform.auth.hostname}`;
6601
- const zitadel = deps.zitadelForInstance?.(issuer) ?? deps.zitadel;
6602
- 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 };
6603
7087
  const { project } = await zitadel.findOrCreateProject(org.id, PLATFORM_ACCESS_ZITADEL_PROJECT);
7088
+ context.recordScopeId(PLATFORM_ACCESS_ZITADEL_PROJECT_SCOPE_KEY, project.id);
6604
7089
  const orphan = (await zitadel.listApps(org.id, project.id)).apps.find((a) => a.name === PLATFORM_ACCESS_ZITADEL_APP && a.state !== 'APP_STATE_REMOVED');
6605
7090
  if (orphan) {
6606
7091
  try {
@@ -6617,6 +7102,7 @@ async function provisionPlatformAccessProvider(context, deps) {
6617
7102
  postLogoutRedirectUris: [`https://${authDomain}/`],
6618
7103
  });
6619
7104
  context.recordScopeId(PLATFORM_ACCESS_PROVIDER_UNDELIVERED_SCOPE_KEY, client.appId);
7105
+ context.recordScopeId(PLATFORM_ACCESS_ZITADEL_APP_SCOPE_KEY, client.appId);
6620
7106
  await context.persist?.();
6621
7107
  let providerId = '';
6622
7108
  await deliverWithRetry(async (_ref, secret) => {
@@ -6632,7 +7118,7 @@ async function provisionPlatformAccessProvider(context, deps) {
6632
7118
  })).id;
6633
7119
  }, ACCESS_PROVIDER_RECORD_KEY, client.clientSecret, attempts, sleep, `the ZITADEL client ${client.appId}'s secret`, label);
6634
7120
  // Last: the guard says the secret reached edge.
6635
- context.recordScopeId(PLATFORM_ACCESS_PROVIDER_APPLIED_HOSTNAME_SCOPE_KEY, platform.auth.hostname);
7121
+ context.recordScopeId(PLATFORM_ACCESS_PROVIDER_APPLIED_HOSTNAME_SCOPE_KEY, ownerSignIn.hostname);
6636
7122
  context.recordScopeId(PLATFORM_ACCESS_PROVIDER_APPLIED_AUTH_DOMAIN_SCOPE_KEY, authDomain);
6637
7123
  context.recordScopeId(ACCESS_PROVIDER_RECORD_KEY, providerId);
6638
7124
  await context.persist?.();
@@ -6656,6 +7142,17 @@ const HC_PORT = '8000';
6656
7142
  const HC_HEALTH_CHECK_PATH = '/';
6657
7143
  const PLATFORM_HC_DB = 'hc';
6658
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';
6659
7156
  const HC_TUNNEL_NAME = 'platform-hc-tunnel';
6660
7157
  /** #197: the one Coolify app that runs `cloudflared` for {@link HC_TUNNEL_NAME}. */
6661
7158
  const HC_CLOUDFLARED_APP_NAME = 'platform-hc-cloudflared';
@@ -6749,7 +7246,7 @@ async function provisionPlatformHc(context, deps) {
6749
7246
  environment_name: environment.name,
6750
7247
  environment_uuid: environment.uuid,
6751
7248
  docker_registry_image_name: HC_IMAGE,
6752
- docker_registry_image_tag: gitRef,
7249
+ docker_registry_image_tag: pinnedTagFor('hc', gitRef),
6753
7250
  ports_exposes: HC_PORT,
6754
7251
  name: PLATFORM_HC_APP_NAME,
6755
7252
  description: `Healthchecks for the platform's own heartbeats (#540, ${HC_IMAGE}:${gitRef})`,
@@ -6762,7 +7259,7 @@ async function provisionPlatformHc(context, deps) {
6762
7259
  // own rule. The image tag too, so a declared release change reaches the app.
6763
7260
  await deps.coolify.updateApplication(appId, {
6764
7261
  custom_internal_name: HC_INTERNAL_NAME,
6765
- docker_registry_image_tag: gitRef,
7262
+ docker_registry_image_tag: pinnedTagFor('hc', gitRef),
6766
7263
  ...healthCheckSettings(HC_HEALTH_CHECK_PATH, Number(HC_PORT)),
6767
7264
  });
6768
7265
  // ---- env, parsed off the database's own owner credentials -----------------
@@ -7471,7 +7968,8 @@ async function provisionPlatformIntegrations(action, context, deps) {
7471
7968
  keyJustSet ||
7472
7969
  runnerKeyStale ||
7473
7970
  configChanged) {
7474
- 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);
7475
7973
  }
7476
7974
  // #398: the runner is up with the keyring that holds the active id; only
7477
7975
  // now may Archon move to it.
@@ -7602,7 +8100,7 @@ async function waitForIntegrationsMigration(coolify, appId, runtime, sleep) {
7602
8100
  /** The name every platform database's backup is registered against -- one shared R2 bucket, one storage entry. */
7603
8101
  const PLATFORM_BACKUP_S3_STORAGE_NAME = 'platform-backups';
7604
8102
  /** The uuid `provisionPlatformBackup` records once {@link PLATFORM_BACKUP_S3_STORAGE_NAME} is registered on Coolify, so a later apply never re-registers it. */
7605
- 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';
7606
8104
  /**
7607
8105
  * #257 fix round: a sha256 of the exact `endpoint|bucket|key|secret` the
7608
8106
  * storage was last pushed with -- never the values themselves, the same
@@ -7650,8 +8148,11 @@ const PLATFORM_BACKUP_DATABASE_NAMES = {
7650
8148
  * {@link repointPlatformBackupSchedules} uses for `s3_storage_uuid`.
7651
8149
  */
7652
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) {
7653
8155
  const schedules = (await coolify.listDatabaseBackups(databaseId)).filter((backup) => backup.enabled && backup.save_s3);
7654
- const database = PLATFORM_BACKUP_DATABASE_NAMES[service];
7655
8156
  if (!database)
7656
8157
  return { schedule: schedules[0], named: true };
7657
8158
  for (const schedule of schedules) {
@@ -8027,23 +8528,58 @@ async function provisionPlatformBackup(action, context, deps) {
8027
8528
  const recorded = context.recordedIds();
8028
8529
  const storageUuid = await ensurePlatformBackupStorage(coolify, r2, recorded, context);
8029
8530
  const service = action.key;
8030
- const { schedule, named } = await findPlatformBackupSchedule(coolify, service, databaseId);
8031
- const scheduleUuid = schedule?.uuid ??
8032
- (await coolify.createDatabaseBackup(databaseId, {
8033
- 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,
8034
8556
  enabled: true,
8035
8557
  save_s3: true,
8036
- s3_storage_uuid: storageUuid,
8037
- })).uuid;
8038
- // `CoolifyPort.createDatabaseBackup` takes no database name, so a new
8039
- // schedule is named here too, by the same PATCH that repairs an existing
8040
- // one dumping the wrong database.
8558
+ s3_storage_uuid: input.storageUuid,
8559
+ }))).uuid;
8041
8560
  if (!named) {
8042
- await coolify.raw('PATCH', `/databases/${databaseId}/backups/${scheduleUuid}`, {
8043
- databases_to_backup: PLATFORM_BACKUP_DATABASE_NAMES[service],
8044
- });
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);
8045
8582
  }
8046
- return undefined;
8047
8583
  }
8048
8584
  /**
8049
8585
  * #540: reconciles the live Healthchecks checks against the ones foundry
@@ -8126,7 +8662,10 @@ async function provisionPlatformHeartbeats(context, deps) {
8126
8662
  // Last: so a run that failed anywhere above (an unreconciled create or
8127
8663
  // delete) leaves this unset and `planHeartbeats` plans the step again --
8128
8664
  // the same "record markers last" rule `provisionPlatformHc` follows.
8129
- 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(',')}`);
8130
8669
  return undefined;
8131
8670
  }
8132
8671
  /**