@wtfalch/foundry 0.33.1 → 0.34.0

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 (65) hide show
  1. package/dist/bootstrap/versions.d.ts +4 -0
  2. package/dist/calls-sfu.d.ts +10 -0
  3. package/dist/calls-sfu.js +41 -0
  4. package/dist/index.d.ts +14 -6
  5. package/dist/index.js +7 -3
  6. package/dist/migrations/0051_org_services.sql +13 -0
  7. package/dist/migrations/0052_platform_mail_relay.sql +8 -0
  8. package/dist/org.d.ts +18 -0
  9. package/dist/org.js +26 -0
  10. package/dist/plan/calls-matrix.d.ts +9 -0
  11. package/dist/plan/calls-matrix.js +17 -0
  12. package/dist/plan/cloudflared-connector.d.ts +7 -1
  13. package/dist/plan/cloudflared-connector.js +14 -1
  14. package/dist/plan/diff.d.ts +29 -0
  15. package/dist/plan/diff.js +259 -18
  16. package/dist/plan/executor.d.ts +112 -0
  17. package/dist/plan/executor.js +594 -33
  18. package/dist/plan/health-check-settings.d.ts +28 -0
  19. package/dist/plan/health-check-settings.js +36 -0
  20. package/dist/plan/health-gaps.d.ts +33 -0
  21. package/dist/plan/health-gaps.js +89 -0
  22. package/dist/plan/held-key-refs.d.ts +10 -0
  23. package/dist/plan/held-key-refs.js +10 -0
  24. package/dist/plan/index.d.ts +14 -6
  25. package/dist/plan/index.js +7 -3
  26. package/dist/plan/live.d.ts +5 -2
  27. package/dist/plan/live.js +15 -2
  28. package/dist/plan/locked-hostnames.d.ts +5 -2
  29. package/dist/plan/locked-hostnames.js +9 -1
  30. package/dist/plan/login-host.d.ts +62 -2
  31. package/dist/plan/login-host.js +104 -1
  32. package/dist/plan/mail-relay.d.ts +120 -0
  33. package/dist/plan/mail-relay.js +323 -0
  34. package/dist/plan/org-names.d.ts +24 -0
  35. package/dist/plan/org-names.js +52 -0
  36. package/dist/plan/own-diff.d.ts +169 -0
  37. package/dist/plan/own-diff.js +432 -0
  38. package/dist/plan/own-dns.d.ts +6 -0
  39. package/dist/plan/own-dns.js +15 -0
  40. package/dist/plan/own-limits.d.ts +22 -0
  41. package/dist/plan/own-limits.js +101 -0
  42. package/dist/plan/own-services.d.ts +193 -0
  43. package/dist/plan/own-services.js +167 -0
  44. package/dist/plan/own-sfu.d.ts +69 -0
  45. package/dist/plan/own-sfu.js +255 -0
  46. package/dist/plan/plan-apply.d.ts +78 -7
  47. package/dist/plan/plan-apply.js +205 -12
  48. package/dist/plan/platform-diff.d.ts +28 -0
  49. package/dist/plan/platform-diff.js +45 -3
  50. package/dist/plan/platform-org.d.ts +34 -0
  51. package/dist/plan/platform-org.js +94 -2
  52. package/dist/plan/platform-providers.d.ts +356 -8
  53. package/dist/plan/platform-providers.js +661 -353
  54. package/dist/plan/sign-in-org.d.ts +1 -1
  55. package/dist/plan/sign-in-org.js +20 -2
  56. package/dist/plan/zitadel-login-policy.js +1 -1
  57. package/dist/plan/zitadel-system-api.d.ts +22 -1
  58. package/dist/plan/zitadel-system-api.js +49 -8
  59. package/dist/platform.d.ts +8 -0
  60. package/dist/slug.js +1 -0
  61. package/dist/store/postgres.js +51 -2
  62. package/dist/store/schema.d.ts +51 -0
  63. package/dist/store/schema.js +5 -0
  64. package/package.json +1 -1
  65. package/versions.json +3 -1
@@ -9,16 +9,21 @@ import { ensureCloudflaredConnector } from './cloudflared-connector.js';
9
9
  import { waitForDeploymentFinished, waitForHealthyVersion, waitForNewDeployment, } from './deploy-health.js';
10
10
  import { ACCESS_POLICY_NAME, coolifyProjectMark, getDatabaseStarted, isNotFoundError, isRunningOrStarting, } from './executor.js';
11
11
  import { ACCESS_PROVIDER_RECORD_KEY, GATE_CLAIM_NAME } from './gates.js';
12
- import { healthCheckSettings } from './health-check-settings.js';
12
+ import { IMAGE_HEALTHCHECK_SETTINGS, commandHealthCheckSettings, healthCheckSettings, tcpProbe, } from './health-check-settings.js';
13
+ import { FILES_MANAGEMENT_KEY_REF, JOBS_ADMIN_TOKEN_REF, PLATFORM_AI_MANAGEMENT_KEY_REF, } from './held-key-refs.js';
14
+ export { FILES_MANAGEMENT_KEY_REF, JOBS_ADMIN_TOKEN_REF, PLATFORM_AI_MANAGEMENT_KEY_REF, } from './held-key-refs.js';
13
15
  import { coolifyDomains, coolifyTunnelDomains, filesShareHostname } from './hostnames.js';
14
- import { AUTH_ZITADEL_RUN_OPTIONS, JOBS_DEPLOY_PENDING_SCOPE_KEY, JOBS_INTERNAL_URL_SCOPE_KEY, JOBS_SCHEDULER_KEY, PLATFORM_ACCESS_PROVIDER_APPLIED_AUTH_DOMAIN_SCOPE_KEY, PLATFORM_ACCESS_PROVIDER_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_ACCESS_PROVIDER_UNDELIVERED_SCOPE_KEY, PLATFORM_AI_APPLIED_GITREF_SCOPE_KEY, PLATFORM_AI_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_AI_DEPLOY_PENDING_SCOPE_KEY, PLATFORM_AI_INTERNAL_URL_SCOPE_KEY, PLATFORM_AI_KEY, PLATFORM_AI_MANAGEMENT_KEY_CONFLICT_SCOPE_KEY, PLATFORM_AI_MANAGEMENT_KEY_SCOPE_KEY, PLATFORM_AI_MANAGEMENT_KEY_UNDELIVERED_SCOPE_KEY, PLATFORM_AI_MIGRATE_BLOCKED_SCOPE_KEY, PLATFORM_AI_ORGANISATION_SCOPE_KEY, PLATFORM_AI_SIGNING_KEY_SET_SCOPE_KEY, PLATFORM_AI_SIGNING_KEY_UNDELIVERED_SCOPE_KEY, PLATFORM_ARCHON_APPLIED_AUTH_ORG_SCOPE_KEY, PLATFORM_ARCHON_APPLIED_BROKER_SCOPE_KEY, PLATFORM_ARCHON_APPLIED_GIT_REF_SCOPE_KEY, PLATFORM_ARCHON_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_ARCHON_APPLIED_ROLLING_UPDATE_SCOPE_KEY, PLATFORM_ARCHON_CONNECTOR_APP_SCOPE_KEY, PLATFORM_ARCHON_COOLIFY_READ_TOKEN_REF, PLATFORM_ARCHON_DEPLOYMENT_SCOPE_KEY, PLATFORM_ARCHON_ENVS_DEPLOYED_SCOPE_KEY, PLATFORM_ARCHON_GATE_CLOSED_SCOPE_KEY, PLATFORM_ARCHON_ID_TOKEN_USERINFO_APPLIED_SCOPE_KEY, PLATFORM_ARCHON_INTEGRATIONS_KEY_SYNCED_SCOPE_KEY, PLATFORM_ARCHON_KEY, PLATFORM_ARCHON_MASTER_CLOUDFLARE_TOKEN_REF, PLATFORM_ARCHON_MASTER_COOLIFY_TOKEN_REF, PLATFORM_ARCHON_MASTER_COOLIFY_URL_REF, PLATFORM_ARCHON_UPTIME_TOKEN_SYNCED_SCOPE_KEY, PLATFORM_ARCHON_ZITADEL_APP_SCOPE_KEY, PLATFORM_AUTH_ADMIN_UNDELIVERED_SCOPE_KEY, PLATFORM_AUTH_ADMIN_USER_SCOPE_KEY, PLATFORM_AUTH_BROKER_DEPLOYED_SCOPE_KEY, PLATFORM_AUTH_BROKER_SCOPE_KEY, PLATFORM_AUTH_CONNECTOR_APP_SCOPE_KEY, PLATFORM_AUTH_INSTANCE_SCOPE_KEY, PLATFORM_AUTH_INSTANCE_UNDELIVERED_SCOPE_KEY, PLATFORM_AUTH_KEY, PLATFORM_AUTH_LOGIN_CLIENT_UNDELIVERED_SCOPE_KEY, PLATFORM_AUTH_LOGIN_CLIENT_USER_SCOPE_KEY, PLATFORM_AUTH_OWNER_INVITE_ORG_SCOPE_KEY, PLATFORM_AUTH_OWNER_INVITE_SCOPE_KEY, PLATFORM_AUTH_REGISTRATION_CLOSED_SCOPE_KEY, PLATFORM_AUTH_RESERVED_ADMIN_USERNAME_SCOPE_KEY, PLATFORM_AUTH_SMTP_SCOPE_KEY, PLATFORM_AUTH_ZITADEL_APPLIED_IMAGE_TAG_SCOPE_KEY, PLATFORM_AUTH_ZITADEL_RUN_OPTIONS_SCOPE_KEY, PLATFORM_BACKUP_DATABASE_SCOPE_KEYS, PLATFORM_BACKUP_SERVICE_KEYS, PLATFORM_EDGE_APPLIED_GIT_REF_SCOPE_KEY, PLATFORM_EDGE_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_EDGE_CLOUDFLARE_TOKEN_REF, PLATFORM_EDGE_INTERNAL_URL_SCOPE_KEY, PLATFORM_EDGE_KEY, PLATFORM_EDGE_KEY_REF, PLATFORM_EDGE_PLATFORM_KEY_SET_SCOPE_KEY, PLATFORM_EDGE_PLATFORM_KEY_UNDELIVERED_SCOPE_KEY, PLATFORM_FILES_APPLIED_GIT_REF_SCOPE_KEY, PLATFORM_FILES_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_FILES_INTERNAL_URL_SCOPE_KEY, PLATFORM_FILES_INTROSPECTION_SCOPE_KEY, PLATFORM_FILES_INTROSPECTION_UNDELIVERED_SCOPE_KEY, PLATFORM_FILES_KEY, PLATFORM_FILES_MANAGEMENT_KEY_SCOPE_KEY, PLATFORM_FILES_MANAGEMENT_UNDELIVERED_SCOPE_KEY, PLATFORM_FILES_ZITADEL_PROJECT_SCOPE_KEY, PLATFORM_HC_API_KEY_REF, PLATFORM_HC_APPLIED_GIT_REF_SCOPE_KEY, PLATFORM_HC_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_HC_KEY, PLATFORM_HEARTBEATS_APPLIED_SCOPE_KEY, PLATFORM_INTEGRATIONS_APPLIED_GITREF_SCOPE_KEY, PLATFORM_INTEGRATIONS_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_INTEGRATIONS_KEY, PLATFORM_INTEGRATIONS_KEY_ID_SCOPE_KEY, PLATFORM_INTEGRATIONS_KEY_ROTATION_PREVIOUS_SCOPE_KEY, PLATFORM_INTEGRATIONS_KEY_ROTATION_REQUESTED_SCOPE_KEY, PLATFORM_INTEGRATIONS_KEY_ROTATION_UNDELIVERED_SCOPE_KEY, PLATFORM_INTEGRATIONS_KEY_SET_SCOPE_KEY, PLATFORM_INTEGRATIONS_KEY_UNDELIVERED_SCOPE_KEY, PLATFORM_INTEGRATIONS_RUNNER_KEYRING_DEPLOYED_SCOPE_KEY, PLATFORM_MAIL_ADMIN_KEY_SCOPE_KEY, PLATFORM_MAIL_ADMIN_UNDELIVERED_SCOPE_KEY, PLATFORM_MAIL_APPLIED_GIT_REF_SCOPE_KEY, PLATFORM_MAIL_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_MAIL_CONNECTOR_APP_SCOPE_KEY, PLATFORM_MAIL_INTERNAL_URL_SCOPE_KEY, PLATFORM_MAIL_KEY, PLATFORM_MAIL_ROUTE_SCOPE_KEY, PLATFORM_MAIL_ROUTE_WANTED, PLATFORM_MAIL_ZITADEL_PROJECT_SCOPE_KEY, PLATFORM_OWNER_ORG_DOMAIN_BLOCKED_SCOPE_KEY, PLATFORM_OWNER_ORG_DOMAIN_SCOPE_KEY, PLATFORM_OWNER_ORG_SCOPE_KEY, PLATFORM_SFU_APPLIED_SCOPE_KEY, PLATFORM_UPTIME_ACCESS_CLIENT_ID_REF, PLATFORM_UPTIME_ACCESS_CLIENT_SECRET_REF, PLATFORM_UPTIME_ACCESS_TOKEN_ID_SCOPE_KEY, PLATFORM_UPTIME_ACCESS_TOKEN_NAME, PLATFORM_UPTIME_ACCESS_UNDELIVERED_SCOPE_KEY, PLATFORM_UPTIME_HEARTBEAT_CHECK_NAME, PLATFORM_UPTIME_KEY, PLATFORM_UPTIME_REMOVED_SCOPE_KEY, PLATFORM_UPTIME_SCRIPT_NAME, PLATFORM_UPTIME_TOKEN_ID_SCOPE_KEY, PLATFORM_UPTIME_TOKEN_REF, PLATFORM_ZITADEL_ORG_SCOPE_KEY, archonAuthOrganizationId, desiredHeartbeatCheckNames, heartbeatCheckUrlRef, parseBrokerDeployRecord, parseLoginClientRecord, platformBackupHeartbeatCheckName, platformMailMxHost, uptimeAccessTokenConflict, uptimeRecorded, uptimeVarsDigest, } from './platform-diff.js';
16
+ import { MAIL_RELAY_BLOCKED, provisionMailRelay } from './mail-relay.js';
17
+ import { recordOwnDnsHostname } from './own-dns.js';
18
+ 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';
15
20
  import { ensureSignInOrg, inviteOwnerWithLink, signInOrgDomain } from './sign-in-org.js';
16
21
  export { PLATFORM_ZITADEL_ORG_SCOPE_KEY };
17
22
  import { applyCallsSfuPlatformStep, callsSfuAppliedFingerprint, } from './platform-sfu.js';
18
23
  import { heldUptimeValues, provisionPlatformUptime, } from './uptime-worker.js';
19
- import { CreateInstanceError, createZitadelInstance, generateSystemApiKeypair, instanceWithoutBearerMessage, systemApiUsersEnv, } from './zitadel-system-api.js';
24
+ import { CreateInstanceError, createZitadelInstance, generateSystemApiKeypair, instanceWithoutBearerMessage, systemApiUsersEnv, waitForSystemApi, } from './zitadel-system-api.js';
20
25
  /** The key `runPlatformSteps` records the apex zone's id under (`system:kind:key`). */
21
- function apexZoneKey(apex) {
26
+ export function apexZoneKey(apex) {
22
27
  return `cloudflare:zone:${apex}`;
23
28
  }
24
29
  /** The key `runPlatformSteps` records the jobs scheduler app's id under (`system:kind:key`, #173). */
@@ -29,7 +34,7 @@ const JOBS_APP_KEY = `coolify:jobs:${JOBS_SCHEDULER_KEY}`;
29
34
  * those exist. Edge's step runs first and records the address
30
35
  * (`planPlatform`), so a missing one means that step has not finished.
31
36
  */
32
- async function platformDns(deps, recorded, step) {
37
+ export async function platformDns(deps, recorded, step) {
33
38
  const edgeUrl = recorded[PLATFORM_EDGE_INTERNAL_URL_SCOPE_KEY];
34
39
  if (!edgeUrl) {
35
40
  throw new Error(`${step}: edge has not been applied yet (no recorded ${PLATFORM_EDGE_INTERNAL_URL_SCOPE_KEY}); platform:edge runs first and every DNS record is written through it`);
@@ -37,7 +42,7 @@ async function platformDns(deps, recorded, step) {
37
42
  return deps.dns(edgeUrl);
38
43
  }
39
44
  /** The tunnel port for a platform step; same address and same reason as {@link platformDns}. */
40
- async function platformTunnels(deps, recorded, step) {
45
+ export async function platformTunnels(deps, recorded, step) {
41
46
  const edgeUrl = recorded[PLATFORM_EDGE_INTERNAL_URL_SCOPE_KEY];
42
47
  if (!edgeUrl) {
43
48
  throw new Error(`${step}: edge has not been applied yet (no recorded ${PLATFORM_EDGE_INTERNAL_URL_SCOPE_KEY}); platform:edge runs first and every tunnel is managed through it`);
@@ -69,16 +74,21 @@ const JOBS_DATABASE_SCOPE_KEY = 'coolify_jobs_database';
69
74
  * never rotates a token the host already holds.
70
75
  */
71
76
  const JOBS_ADMIN_TOKEN_SCOPE_KEY = 'jobs_admin_token_set';
72
- /** How many times `coolifyJobs` tries `deliverSecret` before giving up. */
77
+ /**
78
+ * An own org's scheduler only: the app's uuid, recorded and persisted before
79
+ * the admin token leaves and cleared once the token is delivered and written.
80
+ * Left set, the planner reports the scheduler blocked and never mints again.
81
+ */
82
+ export const JOBS_ADMIN_TOKEN_UNDELIVERED_SCOPE_KEY = 'jobs_admin_token_undelivered';
83
+ /** An own org's scheduler only: the git ref its app last deployed, so a changed declared ref plans the step again. */
84
+ export const JOBS_APPLIED_GIT_REF_SCOPE_KEY = 'jobs_applied_git_ref';
85
+ /** How many times `provisionJobsHost` tries `deliverSecret` before giving up. */
73
86
  const JOBS_ADMIN_TOKEN_DELIVERY_ATTEMPTS = 3;
74
- /** The ref the scheduler's admin token is delivered under: the host's real `JobsSchedulerPort` adapter reads it back. */
75
- export const JOBS_ADMIN_TOKEN_REF = 'jobs:scheduler:adminToken';
76
87
  /** The name of the Coolify project every platform-level service (starting with jobs, #173) lives in. */
77
88
  const PLATFORM_COOLIFY_PROJECT_NAME = 'platform';
78
89
  /** https://github.com/wtfalch/jobs -- the scheduler's own repo, Dockerfile at its root (its own `Dockerfile`'s doc comment). */
79
90
  const JOBS_SCHEDULER_REPO = 'wtfalch/jobs';
80
- /** The scheduler's fixed `custom_internal_name` and the port it listens on (jobs' `PORT`, default 8080). */
81
- const JOBS_INTERNAL_NAME = 'platform-jobs';
91
+ /** The port the scheduler listens on (jobs' `PORT`, default 8080). */
82
92
  const JOBS_PORT = '8080';
83
93
  /**
84
94
  * The real {@link PlatformStepExecutor} for the two steps built so far:
@@ -223,6 +233,13 @@ export class FoundryPlatformStepExecutor {
223
233
  if (mailBlocked)
224
234
  blocked.mail = mailBlocked;
225
235
  }
236
+ if (platform.mail.relay === true) {
237
+ const relayBlocked = this.mail
238
+ ? mailRelayBlockedReason()
239
+ : MAIL_RELAY_BLOCKED.executorMissing;
240
+ if (relayBlocked)
241
+ blocked.relay = relayBlocked;
242
+ }
226
243
  if (platform.integrations.enabled && !this.integrations) {
227
244
  blocked.integrations =
228
245
  'the integrations host is blocked, not planned: the host built the platform executor without its integrations dependencies';
@@ -628,6 +645,14 @@ export class FoundryPlatformStepExecutor {
628
645
  }
629
646
  return provisionPlatformMail(context, this.mail);
630
647
  }
648
+ if (action.system === 'coolify' &&
649
+ action.kind === 'app' &&
650
+ action.key === PLATFORM_MAIL_RELAY_KEY) {
651
+ if (!this.mail) {
652
+ throw new Error('platform:mail-relay needs FoundryPlatformStepExecutor to be constructed with its mail dependencies');
653
+ }
654
+ return provisionPlatformMailRelay(action, context, this.mail);
655
+ }
631
656
  if (action.system === 'coolify' && action.kind === 'app' && action.key === PLATFORM_HC_KEY) {
632
657
  if (!this.hc) {
633
658
  throw new Error('platform:hc needs FoundryPlatformStepExecutor to be constructed with its hc dependencies (#540)');
@@ -743,104 +768,148 @@ export class FoundryPlatformStepExecutor {
743
768
  if (!deliverSecret) {
744
769
  throw new Error('platform step coolify:jobs needs a deliverSecret -- construct FoundryPlatformStepExecutor with one, to hand the scheduler admin token to the host (#173)');
745
770
  }
746
- const hostname = action.desired.hostname;
747
- const gitRef = action.desired.gitRef;
748
- const recorded = context.recordedIds();
749
- let projectUuid = recorded[JOBS_PROJECT_SCOPE_KEY];
750
- if (!projectUuid) {
751
- const found = await coolify.findProjectByName(PLATFORM_COOLIFY_PROJECT_NAME);
752
- projectUuid = found
753
- ? found.uuid
754
- : (await coolify.createProject(PLATFORM_COOLIFY_PROJECT_NAME)).uuid;
755
- context.recordScopeId(JOBS_PROJECT_SCOPE_KEY, projectUuid);
756
- }
757
- const { environment } = await coolify.findOrCreateEnvironment(projectUuid, 'production');
758
- let dbId = recorded[JOBS_DATABASE_SCOPE_KEY];
759
- if (!dbId) {
760
- const dbName = `${JOBS_SCHEDULER_KEY}-scheduler-db`;
761
- const found = (await coolify.listDatabases()).find((db) => db.name === dbName && db.environment_id === environment.id);
762
- if (found) {
763
- dbId = found.uuid;
764
- }
765
- else {
766
- const server = await resolvePlatformServer(coolify);
767
- const created = await coolify.createPostgresDatabase({
768
- project_uuid: projectUuid,
769
- server_uuid: server.uuid,
770
- environment_name: environment.name,
771
- environment_uuid: environment.uuid,
772
- name: dbName,
773
- postgres_db: 'jobs',
774
- postgres_user: 'jobs',
775
- });
776
- dbId = created.uuid;
777
- }
778
- context.recordScopeId(JOBS_DATABASE_SCOPE_KEY, dbId);
779
- }
780
- const existingApp = await coolify.findApplicationByName(JOBS_SCHEDULER_KEY, {
781
- environmentIds: [environment.id],
771
+ return provisionJobsHost(scopeFromPlatform(context), { coolify, deliverSecret, runtime: this.runtime }, platformTarget(), {
772
+ hostname: action.desired.hostname,
773
+ gitRef: action.desired.gitRef,
774
+ project: platformJobsProject,
782
775
  });
783
- let appId;
784
- if (existingApp) {
785
- appId = existingApp.uuid;
776
+ }
777
+ }
778
+ /** The platform's jobs scheduler lives in the shared `platform` project. */
779
+ const platformJobsProject = {
780
+ async ensure(coolify, scope) {
781
+ const recorded = scope.recordedIds()[JOBS_PROJECT_SCOPE_KEY];
782
+ if (recorded)
783
+ return recorded;
784
+ const found = await coolify.findProjectByName(PLATFORM_COOLIFY_PROJECT_NAME);
785
+ const projectUuid = found
786
+ ? found.uuid
787
+ : (await coolify.createProject(PLATFORM_COOLIFY_PROJECT_NAME)).uuid;
788
+ scope.recordScopeId(JOBS_PROJECT_SCOPE_KEY, projectUuid);
789
+ return projectUuid;
790
+ },
791
+ };
792
+ /**
793
+ * Provisions one jobs scheduler (database, app, admin token) for `target`:
794
+ * the platform's, or an own org's. Every name and the held-key ref come from
795
+ * the target.
796
+ *
797
+ * An org target also guards the mint (`CLAUDE.md`, "Platform steps" rules 1
798
+ * and 3): the token's id is recorded as undelivered and persisted before the
799
+ * token leaves, the delivered guard is recorded after the env write, and the
800
+ * applied git ref is recorded so a changed ref plans the step again. The
801
+ * platform's step keeps its provision-only behaviour.
802
+ */
803
+ export async function provisionJobsHost(scope, deps, target, input) {
804
+ const { coolify, deliverSecret, runtime } = deps;
805
+ const { hostname, gitRef } = input;
806
+ const own = target.kind === 'org';
807
+ const label = own ? `own step coolify:own-jobs for ${target.id}` : 'platform step coolify:jobs';
808
+ const projectUuid = await input.project.ensure(coolify, scope);
809
+ const { environment } = await coolify.findOrCreateEnvironment(projectUuid, 'production');
810
+ let dbId = scope.recordedIds()[JOBS_DATABASE_SCOPE_KEY];
811
+ if (!dbId) {
812
+ const dbName = target.names.jobsDb;
813
+ const found = (await coolify.listDatabases()).find((db) => db.name === dbName && db.environment_id === environment.id);
814
+ if (found) {
815
+ dbId = found.uuid;
786
816
  }
787
817
  else {
788
818
  const server = await resolvePlatformServer(coolify);
789
- const githubApp = await coolify.requireGithubApp();
790
- const created = await coolify.createGithubAppApplication({
819
+ const created = await coolify.createPostgresDatabase({
791
820
  project_uuid: projectUuid,
792
821
  server_uuid: server.uuid,
793
822
  environment_name: environment.name,
794
823
  environment_uuid: environment.uuid,
795
- github_app_uuid: githubApp.uuid,
796
- git_repository: JOBS_SCHEDULER_REPO,
797
- git_branch: gitRef,
798
- build_pack: 'dockerfile',
799
- name: JOBS_SCHEDULER_KEY,
800
- domains: coolifyDomains([hostname]),
824
+ name: dbName,
825
+ postgres_db: 'jobs',
826
+ postgres_user: 'jobs',
801
827
  });
802
- appId = created.uuid;
828
+ dbId = created.uuid;
803
829
  }
804
- // Set on every run, like `platform:files`' runner: the name another
805
- // container reaches the scheduler by. It only takes effect on a deploy.
806
- await coolify.updateApplication(appId, {
807
- custom_internal_name: JOBS_INTERNAL_NAME,
808
- ports_exposes: JOBS_PORT,
830
+ scope.recordScopeId(JOBS_DATABASE_SCOPE_KEY, dbId);
831
+ }
832
+ const existingApp = await coolify.findApplicationByName(target.names.jobsApp, {
833
+ environmentIds: [environment.id],
834
+ });
835
+ let appId;
836
+ if (existingApp) {
837
+ appId = existingApp.uuid;
838
+ }
839
+ else {
840
+ const server = await resolvePlatformServer(coolify);
841
+ const githubApp = await coolify.requireGithubApp();
842
+ const created = await coolify.createGithubAppApplication({
843
+ project_uuid: projectUuid,
844
+ server_uuid: server.uuid,
845
+ environment_name: environment.name,
846
+ environment_uuid: environment.uuid,
847
+ github_app_uuid: githubApp.uuid,
848
+ git_repository: JOBS_SCHEDULER_REPO,
849
+ git_branch: gitRef,
850
+ build_pack: 'dockerfile',
851
+ name: target.names.jobsApp,
852
+ ...(hostname ? { domains: coolifyDomains([hostname]) } : {}),
809
853
  });
810
- const db = await getDatabaseStarted(coolify, dbId);
811
- if (!db.internal_db_url) {
812
- throw new Error(`platform step coolify:jobs: database ${dbId} has no internal_db_url yet; not deploying the scheduler without a database`);
813
- }
814
- // The scheduler migrates as `DATABASE_URL_OWNER` and creates its own
815
- // `jobs_rt` role from `DATABASE_URL` (jobs README, "Deploying"). An app
816
- // with no owner URL still holds the owner's URL in `DATABASE_URL`, so
817
- // that is never reused: a runtime URL is minted, once, and kept after.
818
- const hasOwnerUrl = (await coolify.listEnvs(appId)).some((e) => e.key === 'DATABASE_URL_OWNER' && !e.is_preview && e.value);
819
- const runtimeUrl = await ensureAiRuntimeUrl(coolify, hasOwnerUrl ? [appId] : [], db.internal_db_url, 'platform step coolify:jobs');
820
- const envs = [
821
- { key: 'DATABASE_URL_OWNER', value: db.internal_db_url },
822
- { key: 'DATABASE_URL', value: runtimeUrl },
823
- ];
824
- // Whether the scheduler runs on envs other than the ones written below.
825
- // Marked and persisted before the first write, and cleared only after the
826
- // deploy, so a run that dies in between leaves the mark and the next run
827
- // deploys again (`CLAUDE.md` rule 4).
828
- const pending = Boolean(recorded[JOBS_DEPLOY_PENDING_SCOPE_KEY]) || (await envsDiffer(coolify, appId, envs));
829
- if (pending) {
830
- context.recordScopeId(JOBS_DEPLOY_PENDING_SCOPE_KEY, '1');
831
- await context.persist?.();
832
- }
833
- // SCHEDULER_ADMIN_TOKEN gates the admin API the org registrations use
834
- // (jobs README, "The admin API"); unset, every admin route answers 401.
835
- // Delivered to the host before it is written to the app, so a failed
836
- // delivery leaves nothing set and the rerun simply generates a new one.
837
- let tokenJustSet = false;
838
- if (!context.recordedIds()[JOBS_ADMIN_TOKEN_SCOPE_KEY]) {
839
- const token = randomBytes(32).toString('hex');
854
+ appId = created.uuid;
855
+ }
856
+ // An org's declared ref is applied to the app it already has; the platform's
857
+ // is a manual redeploy.
858
+ const refChanged = own && Boolean(existingApp) && scope.recordedIds()[JOBS_APPLIED_GIT_REF_SCOPE_KEY] !== gitRef;
859
+ // Set on every run, like `platform:files`' runner: the name another
860
+ // container reaches the scheduler by. It only takes effect on a deploy.
861
+ await coolify.updateApplication(appId, {
862
+ custom_internal_name: target.internal.jobs,
863
+ ports_exposes: JOBS_PORT,
864
+ // A connect, not a GET: every route but the admin one answers 401, 404 or 405, which a GET check fails.
865
+ ...commandHealthCheckSettings(tcpProbe(JOBS_PORT)),
866
+ ...(refChanged ? { git_branch: gitRef } : {}),
867
+ });
868
+ const db = await getDatabaseStarted(coolify, dbId);
869
+ if (!db.internal_db_url) {
870
+ throw new Error(`${label}: database ${dbId} has no internal_db_url yet; not deploying the scheduler without a database`);
871
+ }
872
+ // The scheduler migrates as `DATABASE_URL_OWNER` and creates its own
873
+ // `jobs_rt` role from `DATABASE_URL` (jobs README, "Deploying"). An app
874
+ // with no owner URL still holds the owner's URL in `DATABASE_URL`, so
875
+ // that is never reused: a runtime URL is minted, once, and kept after.
876
+ const hasOwnerUrl = (await coolify.listEnvs(appId)).some((e) => e.key === 'DATABASE_URL_OWNER' && !e.is_preview && e.value);
877
+ const runtimeUrl = await ensureAiRuntimeUrl(coolify, hasOwnerUrl ? [appId] : [], db.internal_db_url, label);
878
+ const envs = [
879
+ { key: 'DATABASE_URL_OWNER', value: db.internal_db_url },
880
+ { key: 'DATABASE_URL', value: runtimeUrl },
881
+ ];
882
+ // Whether the scheduler runs on envs other than the ones written below.
883
+ // Marked and persisted before the first write, and cleared only after the
884
+ // deploy, so a run that dies in between leaves the mark and the next run
885
+ // deploys again (`CLAUDE.md` rule 4).
886
+ const pending = Boolean(scope.recordedIds()[JOBS_DEPLOY_PENDING_SCOPE_KEY]) ||
887
+ refChanged ||
888
+ (await envsDiffer(coolify, appId, envs));
889
+ if (pending) {
890
+ scope.recordScopeId(JOBS_DEPLOY_PENDING_SCOPE_KEY, '1');
891
+ await scope.persist();
892
+ }
893
+ // SCHEDULER_ADMIN_TOKEN gates the admin API the org registrations use
894
+ // (jobs README, "The admin API"); unset, every admin route answers 401.
895
+ // Delivered to the host before it is written to the app.
896
+ let tokenJustSet = false;
897
+ if (!scope.recordedIds()[JOBS_ADMIN_TOKEN_SCOPE_KEY]) {
898
+ const lost = own ? scope.recordedIds()[JOBS_ADMIN_TOKEN_UNDELIVERED_SCOPE_KEY] : undefined;
899
+ const resumed = lost ? await deps.readSecret?.(target.refs.jobsAdminToken) : null;
900
+ if (lost && !resumed) {
901
+ throw new Error(`${label}: a scheduler admin token (${lost}) was minted but never confirmed delivered; remove the recorded key ${JOBS_ADMIN_TOKEN_UNDELIVERED_SCOPE_KEY} to mint a new one`);
902
+ }
903
+ const token = resumed || randomBytes(32).toString('hex');
904
+ if (!resumed) {
905
+ if (own) {
906
+ scope.recordScopeId(JOBS_ADMIN_TOKEN_UNDELIVERED_SCOPE_KEY, appId);
907
+ await scope.persist();
908
+ }
840
909
  let delivered = false;
841
910
  for (let attempt = 1; attempt <= JOBS_ADMIN_TOKEN_DELIVERY_ATTEMPTS && !delivered; attempt++) {
842
911
  try {
843
- await deliverSecret(JOBS_ADMIN_TOKEN_REF, token);
912
+ await deliverSecret(target.refs.jobsAdminToken, token);
844
913
  delivered = true;
845
914
  }
846
915
  catch {
@@ -848,48 +917,48 @@ export class FoundryPlatformStepExecutor {
848
917
  }
849
918
  }
850
919
  if (!delivered) {
851
- throw new Error(`platform step coolify:jobs: the scheduler admin token could not be delivered after ${JOBS_ADMIN_TOKEN_DELIVERY_ATTEMPTS} attempts`);
920
+ throw new Error(`${label}: the scheduler admin token could not be delivered after ${JOBS_ADMIN_TOKEN_DELIVERY_ATTEMPTS} attempts`);
852
921
  }
853
- envs.push({ key: 'SCHEDULER_ADMIN_TOKEN', value: token });
854
- await coolify.bulkUpsertEnvs(appId, envs);
855
- context.recordScopeId(JOBS_ADMIN_TOKEN_SCOPE_KEY, appId);
856
- tokenJustSet = true;
857
922
  }
858
- else {
859
- await coolify.bulkUpsertEnvs(appId, envs);
860
- }
861
- // The app is created with `instant_deploy: false` and an env change needs
862
- // a redeploy, so deploy after a create or a new token. Also when it is not
863
- // running, so a deploy that failed on an earlier run is retried.
864
- const running = (await coolify.getApplication(appId)).status?.startsWith('running') ?? false;
865
- // An app applied before its internal address was recorded has no fixed
866
- // name yet, so it deploys once to get it; after that it deploys on the
867
- // other conditions only.
868
- const addressNew = !context.recordedIds()[JOBS_INTERNAL_URL_SCOPE_KEY];
869
- // The address is recorded only once the deployment finished: the scheduler
870
- // has no route that answers without its admin token (every other path is a
871
- // 401, 404 or 405), so the deployment record is the whole check, as for
872
- // the AI worker. A deploy that fails or times out throws here, records
873
- // nothing and leaves the step planned.
874
- if (!existingApp || tokenJustSet || pending || !running || addressNew) {
875
- await waitForNewDeployment(coolify, appId, { kind: 'deployment-only' }, this.runtime, this.runtime.sleep ?? defaultSleep, 'platform step coolify:jobs', 'the scheduler keeps its old address');
876
- }
877
- if (pending) {
878
- context.recordScopeId(JOBS_DEPLOY_PENDING_SCOPE_KEY, '');
879
- await context.persist?.();
880
- }
881
- context.recordScopeId(JOBS_INTERNAL_URL_SCOPE_KEY, `http://${JOBS_INTERNAL_NAME}:${JOBS_PORT}`);
882
- return { resourceId: appId };
923
+ envs.push({ key: 'SCHEDULER_ADMIN_TOKEN', value: token });
924
+ await coolify.bulkUpsertEnvs(appId, envs);
925
+ scope.recordScopeId(JOBS_ADMIN_TOKEN_SCOPE_KEY, appId);
926
+ if (own)
927
+ scope.recordScopeId(JOBS_ADMIN_TOKEN_UNDELIVERED_SCOPE_KEY, '');
928
+ tokenJustSet = true;
883
929
  }
930
+ else {
931
+ await coolify.bulkUpsertEnvs(appId, envs);
932
+ }
933
+ // The app is created with `instant_deploy: false` and an env change needs
934
+ // a redeploy, so deploy after a create or a new token. Also when it is not
935
+ // running, so a deploy that failed on an earlier run is retried.
936
+ const running = (await coolify.getApplication(appId)).status?.startsWith('running') ?? false;
937
+ // An app applied before its internal address was recorded has no fixed
938
+ // name yet, so it deploys once to get it; after that it deploys on the
939
+ // other conditions only.
940
+ const addressNew = !scope.recordedIds()[JOBS_INTERNAL_URL_SCOPE_KEY];
941
+ // The address is recorded only once the deployment finished: the scheduler
942
+ // has no route that answers without its admin token (every other path is a
943
+ // 401, 404 or 405), so the deployment record is the whole check, as for
944
+ // the AI worker. A deploy that fails or times out throws here, records
945
+ // nothing and leaves the step planned.
946
+ 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');
948
+ }
949
+ if (pending) {
950
+ scope.recordScopeId(JOBS_DEPLOY_PENDING_SCOPE_KEY, '');
951
+ await scope.persist();
952
+ }
953
+ if (own)
954
+ scope.recordScopeId(JOBS_APPLIED_GIT_REF_SCOPE_KEY, gitRef);
955
+ scope.recordScopeId(JOBS_INTERNAL_URL_SCOPE_KEY, `http://${target.internal.jobs}:${JOBS_PORT}`);
956
+ return { resourceId: appId };
884
957
  }
885
958
  // ---------------------------------------------------------------------------
886
959
  // #168: the platform's own sign-in (ZITADEL)
887
960
  // ---------------------------------------------------------------------------
888
961
  const PLATFORM_AUTH_PROJECT = 'platform';
889
- const PLATFORM_AUTH_DB_NAME = 'platform-auth-db';
890
- const PLATFORM_AUTH_ZITADEL_APP_NAME = 'platform-auth-zitadel';
891
- const PLATFORM_AUTH_LOGIN_APP_NAME = 'platform-auth-login';
892
- const PLATFORM_AUTH_BROKER_APP_NAME = 'platform-auth-broker';
893
962
  /**
894
963
  * Fixed `custom_internal_name`s (`diff.ts`'s `coolifyCalls` sets the same
895
964
  * kind of name on Synapse/MAS) so the Cloudflare tunnel below can address
@@ -897,12 +966,7 @@ const PLATFORM_AUTH_BROKER_APP_NAME = 'platform-auth-broker';
897
966
  * -- bypassing Coolify's own Traefik, which dispatches purely by `Host`
898
967
  * header and so cannot give two different apps the same public hostname.
899
968
  */
900
- const PLATFORM_AUTH_ZITADEL_INTERNAL_NAME = 'platform-auth-zitadel';
901
969
  export const PLATFORM_AUTH_LOGIN_INTERNAL_NAME = 'platform-auth-login';
902
- const PLATFORM_AUTH_BROKER_INTERNAL_NAME = 'platform-auth-broker';
903
- const PLATFORM_AUTH_TUNNEL_NAME = 'platform-auth-tunnel';
904
- /** #197: the one Coolify app that runs `cloudflared` for {@link PLATFORM_AUTH_TUNNEL_NAME}. */
905
- const PLATFORM_AUTH_CLOUDFLARED_APP_NAME = 'platform-auth-cloudflared';
906
970
  /** `wtfalch/auth`'s `docker-compose.yml`: `image: ghcr.io/zitadel/zitadel:${ZITADEL_VERSION:-v4.17.3}`. Pinned in `versions.json` (#247). */
907
971
  const AUTH_ZITADEL_IMAGE = 'ghcr.io/zitadel/zitadel';
908
972
  const AUTH_ZITADEL_IMAGE_TAG = VERSIONS.zitadel;
@@ -917,6 +981,22 @@ const AUTH_BROKER_DOCKERFILE = '/broker/Dockerfile';
917
981
  * own `ZITADEL_PORT: 8080` and the login image's own `EXPOSE`.
918
982
  */
919
983
  const AUTH_ZITADEL_PORT = '8080';
984
+ /**
985
+ * The inline Dockerfile of the ZITADEL app. The published image is distroless (no shell, no `curl`) and has no
986
+ * HEALTHCHECK, and Coolify runs its own check as `sh -c`. So the app is a Dockerfile app: the image's own `ready`
987
+ * command is its exec-form HEALTHCHECK, which Coolify keeps while its own check is off. `ready` asks the port and
988
+ * TLS mode from the same `ZITADEL_*` settings the server reads. The start period covers the first run, which sets
989
+ * up the database and the first instance before it listens. The start command is the one
990
+ * {@link AUTH_ZITADEL_RUN_OPTIONS} names.
991
+ */
992
+ export function zitadelDockerfile() {
993
+ return [
994
+ `FROM ${AUTH_ZITADEL_IMAGE}:${AUTH_ZITADEL_IMAGE_TAG}`,
995
+ `EXPOSE ${AUTH_ZITADEL_PORT}`,
996
+ 'CMD ["start-from-init", "--masterkeyFromEnv", "--tlsMode", "external"]',
997
+ 'HEALTHCHECK --interval=15s --timeout=5s --start-period=180s --retries=5 CMD ["/app/zitadel", "ready"]',
998
+ ].join('\n');
999
+ }
920
1000
  export const AUTH_LOGIN_PORT = '3000';
921
1001
  /** `broker/Dockerfile`'s own `ENV PORT=3000` / `EXPOSE 3000`. */
922
1002
  const AUTH_BROKER_PORT = '3000';
@@ -942,8 +1022,8 @@ export const AUTH_ZITADEL_PATH_PATTERN = '^/(oidc|oauth|saml|management|admin|sy
942
1022
  const AUTH_BROKER_PATH_PATTERN = '^/api(/|$)';
943
1023
  const PLATFORM_AUTH_PROJECT_SCOPE_KEY = 'coolify_project_platform_auth';
944
1024
  const PLATFORM_AUTH_DB_SCOPE_KEY = 'coolify_platform_auth_database';
945
- const PLATFORM_AUTH_ZITADEL_SCOPE_KEY = 'coolify_platform_auth_zitadel_app';
946
- const PLATFORM_AUTH_LOGIN_SCOPE_KEY = 'coolify_platform_auth_login_app';
1025
+ export const PLATFORM_AUTH_ZITADEL_SCOPE_KEY = 'coolify_platform_auth_zitadel_app';
1026
+ export const PLATFORM_AUTH_LOGIN_SCOPE_KEY = 'coolify_platform_auth_login_app';
947
1027
  const PLATFORM_AUTH_TUNNEL_SCOPE_KEY = 'cloudflare_platform_auth_tunnel';
948
1028
  /** Recorded once the ZITADEL app has been deployed, so ZITADEL may have written to its database. */
949
1029
  const PLATFORM_AUTH_ZITADEL_DEPLOYED_SCOPE_KEY = 'zitadel_platform_auth_deployed';
@@ -1072,6 +1152,10 @@ function loginClientRecordValue(userId, patId, token) {
1072
1152
  * miss for a credential-liveness check than for Archon's own Access policy.
1073
1153
  */
1074
1154
  async function authLoginClientDrifted(platform, auth, recordedIds) {
1155
+ return authLoginClientDriftedAt({ hostname: platform.auth.hostname }, auth, recordedIds);
1156
+ }
1157
+ /** {@link authLoginClientDrifted} for any instance; `bearerRef` is passed to `zitadelForInstance` only when set. */
1158
+ async function authLoginClientDriftedAt(instance, auth, recordedIds) {
1075
1159
  const record = parseLoginClientRecord(recordedIds[PLATFORM_AUTH_LOGIN_CLIENT_USER_SCOPE_KEY]);
1076
1160
  if (!record)
1077
1161
  return false;
@@ -1081,7 +1165,10 @@ async function authLoginClientDrifted(platform, auth, recordedIds) {
1081
1165
  if (!orgId || !loginAppId || !brokerAppId)
1082
1166
  return true;
1083
1167
  try {
1084
- const zitadel = auth.zitadelForInstance?.(`https://${platform.auth.hostname}`) ?? auth.zitadel;
1168
+ const issuer = `https://${instance.hostname}`;
1169
+ const zitadel = (instance.bearerRef && instance.bearerRef !== PLATFORM_AUTH_INSTANCE_BEARER_REF
1170
+ ? auth.zitadelForInstance?.(issuer, instance.bearerRef)
1171
+ : auth.zitadelForInstance?.(issuer)) ?? auth.zitadel;
1085
1172
  const livePats = await zitadel.listLoginClientPats(orgId, record.userId);
1086
1173
  if (livePats.length !== 1 || livePats[0] !== record.patId)
1087
1174
  return true;
@@ -1240,28 +1327,33 @@ async function liveLoginClientTokenHash(coolify, appId) {
1240
1327
  * #197: a `cloudflared` connector for this tunnel is run
1241
1328
  * ({@link ensureCloudflaredConnector}), so `hostname` answers.
1242
1329
  */
1243
- async function provisionPlatformAuth(context, deps, ownerCompany = null) {
1244
- const { platform } = context;
1330
+ export async function provisionAuthStack(scope, deps, target, input) {
1331
+ const { hostname, apex, gitRef, ownerCompany, network } = input;
1245
1332
  const owners = ownerCompany?.owners ?? [];
1246
- const hostname = platform.auth.hostname;
1247
- const gitRef = platform.auth.gitRef;
1248
- const recorded = context.recordedIds();
1333
+ const recorded = scope.recordedIds();
1249
1334
  const runtime = deps.runtime ?? {};
1250
1335
  const sleep = runtime.sleep ?? defaultSleep;
1336
+ const label = target.kind === 'platform' ? 'platform:auth' : `${target.id}:auth`;
1337
+ const subject = target.kind === 'platform' ? 'the platform' : target.id;
1338
+ const issuer = `https://${hostname}`;
1339
+ const instanceZitadel = () => {
1340
+ const forInstance = target.refs.instanceBearer === PLATFORM_AUTH_INSTANCE_BEARER_REF
1341
+ ? deps.zitadelForInstance?.(issuer)
1342
+ : deps.zitadelForInstance?.(issuer, target.refs.instanceBearer);
1343
+ if (forInstance)
1344
+ return forInstance;
1345
+ if (target.kind === 'org') {
1346
+ throw new Error(`own step coolify:own-auth for ${target.id}: the host supplies no ZITADEL port for the company's own instance`);
1347
+ }
1348
+ return deps.zitadel;
1349
+ };
1251
1350
  // Records an id and writes it out now, so a crash before the step ends
1252
1351
  // cannot lose a resource a provider already created.
1253
1352
  const record = async (key, id) => {
1254
- context.recordScopeId(key, id);
1255
- await context.persist?.();
1353
+ scope.recordScopeId(key, id);
1354
+ await scope.persist();
1256
1355
  };
1257
- let projectUuid = recorded[PLATFORM_AUTH_PROJECT_SCOPE_KEY];
1258
- if (!projectUuid) {
1259
- const found = await deps.coolify.findProjectByName(PLATFORM_AUTH_PROJECT);
1260
- projectUuid = found
1261
- ? found.uuid
1262
- : (await deps.coolify.createProject(PLATFORM_AUTH_PROJECT)).uuid;
1263
- await record(PLATFORM_AUTH_PROJECT_SCOPE_KEY, projectUuid);
1264
- }
1356
+ const projectUuid = await input.project.ensure(deps.coolify, scope);
1265
1357
  const { environment } = await deps.coolify.findOrCreateEnvironment(projectUuid, 'production');
1266
1358
  // ---- database ----------------------------------------------------------
1267
1359
  let dbId = recorded[PLATFORM_AUTH_DB_SCOPE_KEY];
@@ -1277,7 +1369,7 @@ async function provisionPlatformAuth(context, deps, ownerCompany = null) {
1277
1369
  server_uuid: server.uuid,
1278
1370
  environment_name: environment.name,
1279
1371
  environment_uuid: environment.uuid,
1280
- name: PLATFORM_AUTH_DB_NAME,
1372
+ name: target.names.authDb,
1281
1373
  postgres_db: 'zitadel',
1282
1374
  postgres_user: 'zitadel',
1283
1375
  });
@@ -1290,22 +1382,19 @@ async function provisionPlatformAuth(context, deps, ownerCompany = null) {
1290
1382
  // `coolifyCalls` follows the same rule for its own databases): Coolify
1291
1383
  // can take a moment to report a freshly created database's
1292
1384
  // `internal_db_url`, so the next apply retries from a clean state.
1293
- throw new Error('coolify:app for the platform (auth): the database has no internal_db_url yet -- retry this apply once Coolify reports one');
1385
+ throw new Error(`coolify:app for ${subject} (auth): the database has no internal_db_url yet -- retry this apply once Coolify reports one`);
1294
1386
  }
1295
1387
  // ---- the ZITADEL app itself ---------------------------------------------
1296
- const zitadelApp = await findOrCreatePlatformApp(deps.coolify, PLATFORM_AUTH_ZITADEL_APP_NAME, environment, async () => {
1388
+ const zitadelApp = await findOrCreatePlatformApp(deps.coolify, target.names.authZitadel, environment, async () => {
1297
1389
  const server = await resolvePlatformServer(deps.coolify);
1298
- return deps.coolify.createDockerImageApplication({
1390
+ return deps.coolify.createDockerfileApplication({
1299
1391
  project_uuid: projectUuid,
1300
1392
  server_uuid: server.uuid,
1301
1393
  environment_name: environment.name,
1302
1394
  environment_uuid: environment.uuid,
1303
- docker_registry_image_name: AUTH_ZITADEL_IMAGE,
1304
- docker_registry_image_tag: AUTH_ZITADEL_IMAGE_TAG,
1305
- ports_exposes: AUTH_ZITADEL_PORT,
1306
- custom_docker_run_options: AUTH_ZITADEL_RUN_OPTIONS,
1307
- name: PLATFORM_AUTH_ZITADEL_APP_NAME,
1308
- description: `ZITADEL for the platform's own sign-in (#168, ${AUTH_ZITADEL_IMAGE}:${AUTH_ZITADEL_IMAGE_TAG})`,
1395
+ dockerfile: zitadelDockerfile(),
1396
+ name: target.names.authZitadel,
1397
+ description: `ZITADEL for ${target.kind === 'platform' ? "the platform's" : `${target.id}'s`} own sign-in (#168, ${AUTH_ZITADEL_IMAGE}:${AUTH_ZITADEL_IMAGE_TAG})`,
1309
1398
  instant_deploy: false,
1310
1399
  });
1311
1400
  });
@@ -1315,14 +1404,21 @@ async function provisionPlatformAuth(context, deps, ownerCompany = null) {
1315
1404
  // create must not leave the app unnamed for good, and the tunnel addresses
1316
1405
  // the app by this name. (The port cannot read the current name back, and
1317
1406
  // setting the same value again is harmless.)
1318
- // The run options go in the same patch, so an app made before they existed
1319
- // gets them too.
1320
- await deps.coolify.updateApplication(zitadelAppId, {
1321
- custom_internal_name: PLATFORM_AUTH_ZITADEL_INTERNAL_NAME,
1322
- custom_docker_run_options: AUTH_ZITADEL_RUN_OPTIONS,
1323
- });
1324
1407
  // The container only takes new run options at its next deploy.
1325
1408
  const zitadelRunOptionsNew = recorded[PLATFORM_AUTH_ZITADEL_RUN_OPTIONS_SCOPE_KEY] !== AUTH_ZITADEL_RUN_OPTIONS;
1409
+ // A new app is a Dockerfile app that starts with `zitadelDockerfile()`'s CMD, so it gets no run options.
1410
+ // An app made before that is an image app, found by name and never replaced: it keeps its run options (sent
1411
+ // here until the deploy that applied them is recorded), has no Docker-level check, and takes none until its
1412
+ // organisation is provisioned again from zero. The Coolify check stays off on both: the Dockerfile's
1413
+ // HEALTHCHECK is the live one, and Coolify's rolling update follows it.
1414
+ await deps.coolify.updateApplication(zitadelAppId, {
1415
+ custom_internal_name: target.internal.authZitadel,
1416
+ ports_exposes: AUTH_ZITADEL_PORT,
1417
+ health_check_enabled: false,
1418
+ ...(zitadelRunOptionsNew && !zitadelApp.justCreated
1419
+ ? { custom_docker_run_options: AUTH_ZITADEL_RUN_OPTIONS }
1420
+ : {}),
1421
+ });
1326
1422
  // #251: only on create, never unconditionally -- nothing here patches
1327
1423
  // `docker_registry_image_tag` on an app `findOrCreatePlatformApp` found
1328
1424
  // already running, so that is the only moment this tag is actually live.
@@ -1333,7 +1429,7 @@ async function provisionPlatformAuth(context, deps, ownerCompany = null) {
1333
1429
  await record(PLATFORM_AUTH_ZITADEL_APPLIED_IMAGE_TAG_SCOPE_KEY, AUTH_ZITADEL_IMAGE_TAG);
1334
1430
  }
1335
1431
  // ---- the zitadel-login app -----------------------------------------------
1336
- const loginApp = await findOrCreatePlatformApp(deps.coolify, PLATFORM_AUTH_LOGIN_APP_NAME, environment, async () => {
1432
+ const loginApp = await findOrCreatePlatformApp(deps.coolify, target.names.authLogin, environment, async () => {
1337
1433
  const server = await resolvePlatformServer(deps.coolify);
1338
1434
  const githubApp = await deps.coolify.requireGithubApp();
1339
1435
  return deps.coolify.createGithubAppApplication({
@@ -1345,17 +1441,19 @@ async function provisionPlatformAuth(context, deps, ownerCompany = null) {
1345
1441
  git_repository: AUTH_REPO,
1346
1442
  git_branch: gitRef,
1347
1443
  build_pack: 'dockerfile',
1348
- name: PLATFORM_AUTH_LOGIN_APP_NAME,
1444
+ name: target.names.authLogin,
1349
1445
  dockerfile_location: AUTH_LOGIN_DOCKERFILE,
1350
1446
  });
1351
1447
  });
1352
1448
  const loginAppId = loginApp.appId;
1353
1449
  await record(PLATFORM_AUTH_LOGIN_SCOPE_KEY, loginAppId);
1450
+ // The Dockerfile's own HEALTHCHECK is the live check (`IMAGE_HEALTHCHECK_SETTINGS`).
1354
1451
  await deps.coolify.updateApplication(loginAppId, {
1355
- custom_internal_name: PLATFORM_AUTH_LOGIN_INTERNAL_NAME,
1452
+ custom_internal_name: target.internal.authLogin,
1453
+ ...IMAGE_HEALTHCHECK_SETTINGS,
1356
1454
  });
1357
1455
  // ---- the broker app (#204) -------------------------------------------------
1358
- const brokerApp = await findOrCreatePlatformApp(deps.coolify, PLATFORM_AUTH_BROKER_APP_NAME, environment, async () => {
1456
+ const brokerApp = await findOrCreatePlatformApp(deps.coolify, target.names.authBroker, environment, async () => {
1359
1457
  const server = await resolvePlatformServer(deps.coolify);
1360
1458
  const githubApp = await deps.coolify.requireGithubApp();
1361
1459
  return deps.coolify.createGithubAppApplication({
@@ -1367,21 +1465,24 @@ async function provisionPlatformAuth(context, deps, ownerCompany = null) {
1367
1465
  git_repository: AUTH_REPO,
1368
1466
  git_branch: gitRef,
1369
1467
  build_pack: 'dockerfile',
1370
- name: PLATFORM_AUTH_BROKER_APP_NAME,
1468
+ name: target.names.authBroker,
1371
1469
  dockerfile_location: AUTH_BROKER_DOCKERFILE,
1372
1470
  });
1373
1471
  });
1374
1472
  const brokerAppId = brokerApp.appId;
1375
1473
  await record(PLATFORM_AUTH_BROKER_SCOPE_KEY, brokerAppId);
1376
1474
  await deps.coolify.updateApplication(brokerAppId, {
1377
- custom_internal_name: PLATFORM_AUTH_BROKER_INTERNAL_NAME,
1475
+ custom_internal_name: target.internal.authBroker,
1476
+ ...IMAGE_HEALTHCHECK_SETTINGS,
1378
1477
  });
1379
1478
  // ---- env -----------------------------------------------------------------
1380
1479
  // Generated once, handed straight to `bulkUpsertEnvs` and nowhere else --
1381
1480
  // never returned, recorded, logged or interpolated into a thrown error, the
1382
1481
  // same rule `executor.ts`'s `ensureAuthCookieSecret` follows for a value
1383
1482
  // this package mints itself rather than receiving from a provider.
1384
- const masterkey = await ensureMasterkey(deps.coolify, zitadelAppId, databaseInUse);
1483
+ const masterkey = await ensureMasterkey(deps.coolify, zitadelAppId, databaseInUse, label);
1484
+ if (masterkey)
1485
+ runtime.onSecret?.(masterkey);
1385
1486
  // #283: the instance is created once, with the system API, by a platform
1386
1487
  // whose admin machine user is not recorded yet (one that already has it was
1387
1488
  // built from a first instance and keeps it). The keypair is regenerated on
@@ -1391,7 +1492,7 @@ async function provisionPlatformAuth(context, deps, ownerCompany = null) {
1391
1492
  if (lostInstance &&
1392
1493
  !recorded[PLATFORM_AUTH_INSTANCE_SCOPE_KEY] &&
1393
1494
  !recorded[PLATFORM_AUTH_ADMIN_USER_SCOPE_KEY]) {
1394
- throw new Error(instanceWithoutBearerMessage(hostname, `instance ${lostInstance}`));
1495
+ throw new Error(instanceWithoutBearerMessage(hostname, `instance ${lostInstance}`, label));
1395
1496
  }
1396
1497
  const needsInstance = !recorded[PLATFORM_AUTH_ADMIN_USER_SCOPE_KEY] && !recorded[PLATFORM_AUTH_INSTANCE_SCOPE_KEY];
1397
1498
  const systemKeys = needsInstance ? generateSystemApiKeypair() : undefined;
@@ -1421,6 +1522,10 @@ async function provisionPlatformAuth(context, deps, ownerCompany = null) {
1421
1522
  value: `https://${hostname}/login?samlRequest=`,
1422
1523
  },
1423
1524
  { key: 'ZITADEL_LOGSTORE_ACCESS_STDOUT_ENABLED', value: 'true' },
1525
+ // ZITADEL 4.17.3 logs every mail's template data at warning level ("missing
1526
+ // translation" for `<Type>.Footer`), which holds the one-time invite code.
1527
+ // Errors (a failed SMTP send among them) are still written.
1528
+ { key: 'ZITADEL_LOG_LEVEL', value: 'error' },
1424
1529
  ];
1425
1530
  if (masterkey)
1426
1531
  zitadelEnvs.push({ key: 'ZITADEL_MASTERKEY', value: masterkey });
@@ -1433,7 +1538,7 @@ async function provisionPlatformAuth(context, deps, ownerCompany = null) {
1433
1538
  const loginEnvs = [
1434
1539
  {
1435
1540
  key: 'ZITADEL_API_URL',
1436
- value: `http://${PLATFORM_AUTH_ZITADEL_INTERNAL_NAME}:${AUTH_ZITADEL_PORT}`,
1541
+ value: `http://${target.internal.authZitadel}:${AUTH_ZITADEL_PORT}`,
1437
1542
  },
1438
1543
  {
1439
1544
  key: 'CUSTOM_REQUEST_HEADERS',
@@ -1447,8 +1552,8 @@ async function provisionPlatformAuth(context, deps, ownerCompany = null) {
1447
1552
  // invite's own path (set a password, then nowhere to go). Only set once
1448
1553
  // Archon is declared, and only ever to Archon: the platform's one landing
1449
1554
  // app for anyone who reaches the login app outside an app's own sign-in.
1450
- ...(platform.archon.enabled
1451
- ? [{ key: 'DEFAULT_REDIRECT_URI', value: `https://${platform.archon.hostname}` }]
1555
+ ...(input.defaultRedirectUri
1556
+ ? [{ key: 'DEFAULT_REDIRECT_URI', value: input.defaultRedirectUri }]
1452
1557
  : []),
1453
1558
  ];
1454
1559
  // #210: read back now, not inside the deploy-decision block below -- this
@@ -1456,7 +1561,9 @@ async function provisionPlatformAuth(context, deps, ownerCompany = null) {
1456
1561
  // the same way `envsDiffer`/`bulkUpsertEnvs` below already do for every
1457
1562
  // other broker env, so a single apply that finds the key already held can
1458
1563
  // also see `LETTERMINT_API_TOKEN` as met and deploy in that same run.
1459
- const heldMailKey = await deps.heldKeys?.resolve(PLATFORM_AUTH_BROKER_MAIL_KEY_REF);
1564
+ const heldMailKey = await deps.heldKeys?.resolve(target.refs.brokerMailKey);
1565
+ if (heldMailKey)
1566
+ runtime.onSecret?.(heldMailKey);
1460
1567
  // `AUTH_APPS` and `ZITADEL_SERVICE_USER_TOKEN`/`_FILE` are not pushed here
1461
1568
  // -- this function's own doc comment, "Known gaps". `LETTERMINT_API_TOKEN`
1462
1569
  // is, once held (#210): pushing it costs nothing even while the broker
@@ -1465,7 +1572,7 @@ async function provisionPlatformAuth(context, deps, ownerCompany = null) {
1465
1572
  const brokerEnvs = [
1466
1573
  {
1467
1574
  key: 'ZITADEL_API_URL',
1468
- value: `http://${PLATFORM_AUTH_ZITADEL_INTERNAL_NAME}:${AUTH_ZITADEL_PORT}`,
1575
+ value: `http://${target.internal.authZitadel}:${AUTH_ZITADEL_PORT}`,
1469
1576
  },
1470
1577
  { key: 'ZITADEL_DOMAIN', value: hostname },
1471
1578
  ...(heldMailKey ? [{ key: 'LETTERMINT_API_TOKEN', value: heldMailKey }] : []),
@@ -1517,25 +1624,25 @@ async function provisionPlatformAuth(context, deps, ownerCompany = null) {
1517
1624
  await record(PLATFORM_AUTH_BROKER_DEPLOYED_SCOPE_KEY, brokerDeployRecordValue(brokerAppId, authAppsRaw));
1518
1625
  }
1519
1626
  // ---- tunnel and DNS ------------------------------------------------------
1520
- const tunnels = await platformTunnels(deps, context.recordedIds(), 'platform:auth');
1521
- const existingTunnel = await tunnels.findTunnelByName(PLATFORM_AUTH_TUNNEL_NAME);
1522
- const tunnel = existingTunnel ?? (await tunnels.createTunnel(PLATFORM_AUTH_TUNNEL_NAME));
1627
+ const tunnels = await network.tunnels();
1628
+ const existingTunnel = await tunnels.findTunnelByName(target.names.authTunnel);
1629
+ const tunnel = existingTunnel ?? (await tunnels.createTunnel(target.names.authTunnel));
1523
1630
  await record(PLATFORM_AUTH_TUNNEL_SCOPE_KEY, tunnel.id);
1524
1631
  await tunnels.putTunnelConfig(tunnel.id, {
1525
1632
  ingress: [
1526
1633
  {
1527
1634
  hostname,
1528
1635
  path: AUTH_ZITADEL_PATH_PATTERN,
1529
- service: `http://${PLATFORM_AUTH_ZITADEL_INTERNAL_NAME}:${AUTH_ZITADEL_PORT}`,
1636
+ service: `http://${target.internal.authZitadel}:${AUTH_ZITADEL_PORT}`,
1530
1637
  },
1531
1638
  {
1532
1639
  hostname,
1533
1640
  path: AUTH_BROKER_PATH_PATTERN,
1534
- service: `http://${PLATFORM_AUTH_BROKER_INTERNAL_NAME}:${AUTH_BROKER_PORT}`,
1641
+ service: `http://${target.internal.authBroker}:${AUTH_BROKER_PORT}`,
1535
1642
  },
1536
1643
  {
1537
1644
  hostname,
1538
- service: `http://${PLATFORM_AUTH_LOGIN_INTERNAL_NAME}:${AUTH_LOGIN_PORT}`,
1645
+ service: `http://${target.internal.authLogin}:${AUTH_LOGIN_PORT}`,
1539
1646
  },
1540
1647
  { service: 'http_status:404' },
1541
1648
  ],
@@ -1548,7 +1655,7 @@ async function provisionPlatformAuth(context, deps, ownerCompany = null) {
1548
1655
  coolify: deps.coolify,
1549
1656
  tunnels,
1550
1657
  tunnelId: tunnel.id,
1551
- appName: PLATFORM_AUTH_CLOUDFLARED_APP_NAME,
1658
+ appName: target.names.authCloudflared,
1552
1659
  projectUuid,
1553
1660
  environment,
1554
1661
  resolveServer: () => resolvePlatformServer(deps.coolify),
@@ -1557,15 +1664,15 @@ async function provisionPlatformAuth(context, deps, ownerCompany = null) {
1557
1664
  });
1558
1665
  // The apex zone step records the zone id before this step runs, but it
1559
1666
  // creates no record for the hostname, so it is created here.
1560
- const zoneId = recorded[apexZoneKey(platform.apex)] ?? (await exactZone(deps.cloudflare, platform.apex))?.id;
1667
+ const zoneId = await network.zoneId(apex);
1561
1668
  if (!zoneId) {
1562
- throw new Error(`platform:auth: no Cloudflare zone for ${platform.apex} to put ${hostname} in`);
1669
+ throw new Error(`${label}: no Cloudflare zone for ${apex} to put ${hostname} in`);
1563
1670
  }
1564
1671
  const dnsTarget = `${tunnel.id}.cfargotunnel.com`;
1565
- const dns = await platformDns(deps, context.recordedIds(), 'platform:auth');
1566
- const [dnsRecord] = await dns.findDnsRecord(platform.apex, hostname, 'CNAME');
1672
+ const dns = await network.dns();
1673
+ const [dnsRecord] = await dns.findDnsRecord(apex, hostname, 'CNAME');
1567
1674
  if (!dnsRecord) {
1568
- await dns.createDnsRecord(platform.apex, {
1675
+ await dns.createDnsRecord(apex, {
1569
1676
  type: 'CNAME',
1570
1677
  name: hostname,
1571
1678
  content: dnsTarget,
@@ -1573,25 +1680,36 @@ async function provisionPlatformAuth(context, deps, ownerCompany = null) {
1573
1680
  });
1574
1681
  }
1575
1682
  else if (dnsRecord.content !== dnsTarget) {
1576
- await dns.updateDnsRecord(platform.apex, dnsRecord.id, {
1683
+ await dns.updateDnsRecord(apex, dnsRecord.id, {
1577
1684
  type: 'CNAME',
1578
1685
  name: hostname,
1579
1686
  content: dnsTarget,
1580
1687
  proxied: true,
1581
1688
  });
1582
1689
  }
1690
+ if (target.kind === 'org')
1691
+ await recordOwnDnsHostname(scope, hostname);
1583
1692
  // ---- foundry's own admin machine user, once ------------------------------
1584
1693
  if (!recorded[PLATFORM_AUTH_ADMIN_USER_SCOPE_KEY]) {
1585
1694
  const lost = recorded[PLATFORM_AUTH_ADMIN_UNDELIVERED_SCOPE_KEY];
1586
1695
  if (lost) {
1587
1696
  // The planner reports this and plans nothing; this is the same rule for
1588
1697
  // a caller that reaches the step anyway.
1589
- throw new Error(`platform:auth: the admin key of ZITADEL machine user ${lost} was minted but never delivered; delete that user and the recorded key ${PLATFORM_AUTH_ADMIN_UNDELIVERED_SCOPE_KEY} to mint a new one`);
1698
+ throw new Error(`${label}: the admin key of ZITADEL machine user ${lost} was minted but never delivered; delete that user and the recorded key ${PLATFORM_AUTH_ADMIN_UNDELIVERED_SCOPE_KEY} to mint a new one`);
1590
1699
  }
1591
1700
  if (systemKeys) {
1592
1701
  // ZITADEL answers /debug/healthz without an instance; the openid
1593
1702
  // document below only exists once the instance does.
1594
- await waitForInstance(`https://${hostname}/debug/healthz`, runtime, sleep);
1703
+ await waitForInstance(`https://${hostname}/debug/healthz`, runtime, sleep, label);
1704
+ // Healthy is not ready for the system API: its gateway answers 503 for a moment after the server listens.
1705
+ await waitForSystemApi({ customDomain: hostname, privateKey: systemKeys.privateKey, label }, {
1706
+ fetch: runtime.fetch,
1707
+ now: runtime.now ?? Date.now,
1708
+ sleep,
1709
+ timeoutMs: runtime.readyTimeoutMs,
1710
+ intervalMs: runtime.readyIntervalMs,
1711
+ requestTimeoutMs: runtime.readyRequestTimeoutMs,
1712
+ });
1595
1713
  // Sent exactly once. Anything that may have reached ZITADEL leaves a
1596
1714
  // marker first, so the next run refuses instead of creating again.
1597
1715
  let created;
@@ -1601,6 +1719,7 @@ async function provisionPlatformAuth(context, deps, ownerCompany = null) {
1601
1719
  privateKey: systemKeys.privateKey,
1602
1720
  instanceName: PLATFORM_AUTH_ORG_NAME,
1603
1721
  firstOrgName: PLATFORM_AUTH_ORG_NAME,
1722
+ label,
1604
1723
  owner: {
1605
1724
  userName: PLATFORM_AUTH_BEARER_USER_NAME,
1606
1725
  name: 'Foundry (instance owner)',
@@ -1614,12 +1733,13 @@ async function provisionPlatformAuth(context, deps, ownerCompany = null) {
1614
1733
  throw error;
1615
1734
  }
1616
1735
  const { instanceId, pat } = created;
1736
+ runtime.onSecret?.(pat);
1617
1737
  // Recorded before the PAT leaves this function, like the admin key below.
1618
1738
  await record(PLATFORM_AUTH_INSTANCE_UNDELIVERED_SCOPE_KEY, instanceId);
1619
1739
  const attempts = runtime.deliverAttempts ?? 3;
1620
1740
  for (let attempt = 1;; attempt += 1) {
1621
1741
  try {
1622
- await deps.deliverSecret(PLATFORM_AUTH_INSTANCE_BEARER_REF, pat);
1742
+ await deps.deliverSecret(target.refs.instanceBearer, pat);
1623
1743
  break;
1624
1744
  }
1625
1745
  catch (error) {
@@ -1627,7 +1747,7 @@ async function provisionPlatformAuth(context, deps, ownerCompany = null) {
1627
1747
  const reason = redactSecrets(error instanceof Error ? error.message : String(error), [
1628
1748
  pat,
1629
1749
  ]);
1630
- throw new Error(`platform:auth: the owner token of ZITADEL instance ${instanceId} was created but could not be delivered after ${attempts} tries (${reason}); ${instanceWithoutBearerMessage(hostname, `instance ${instanceId}`)}`);
1750
+ throw new Error(`${label}: the owner token of ZITADEL instance ${instanceId} was created but could not be delivered after ${attempts} tries (${reason}); ${instanceWithoutBearerMessage(hostname, `instance ${instanceId}`, label)}`);
1631
1751
  }
1632
1752
  await sleep(1000 * attempt);
1633
1753
  }
@@ -1637,8 +1757,8 @@ async function provisionPlatformAuth(context, deps, ownerCompany = null) {
1637
1757
  }
1638
1758
  // Minting needs a running instance behind the tunnel: the first apply
1639
1759
  // gets here right after the deploy.
1640
- await waitForInstance(`https://${hostname}/.well-known/openid-configuration`, runtime, sleep);
1641
- const zitadel = deps.zitadelForInstance?.(`https://${hostname}`) ?? deps.zitadel;
1760
+ await waitForInstance(`https://${hostname}/.well-known/openid-configuration`, runtime, sleep, label);
1761
+ const zitadel = instanceZitadel();
1642
1762
  const { org } = await zitadel.findOrCreateOrg(PLATFORM_AUTH_ORG_NAME);
1643
1763
  // #216 round 5: recorded here too (mirroring `provisionPlatformArchon`),
1644
1764
  // so `authLoginClientDrifted` (below) never needs its own ZITADEL call
@@ -1650,6 +1770,7 @@ async function provisionPlatformAuth(context, deps, ownerCompany = null) {
1650
1770
  userName: PLATFORM_AUTH_ADMIN_USER_NAME,
1651
1771
  name: 'Foundry (platform admin)',
1652
1772
  });
1773
+ runtime.onSecret?.(token);
1653
1774
  // Recorded and persisted before the token leaves this function: if
1654
1775
  // delivery fails for good, the next plan reports the lost key rather than
1655
1776
  // skipping or minting again. Only a delivered key records the guard.
@@ -1661,12 +1782,12 @@ async function provisionPlatformAuth(context, deps, ownerCompany = null) {
1661
1782
  const attempts = runtime.deliverAttempts ?? 3;
1662
1783
  for (let attempt = 1;; attempt += 1) {
1663
1784
  try {
1664
- await deps.deliverSecret('zitadel:platform:admin', token);
1785
+ await deps.deliverSecret(target.refs.authAdmin, token);
1665
1786
  break;
1666
1787
  }
1667
1788
  catch {
1668
1789
  if (attempt >= attempts) {
1669
- throw new Error(`platform:auth: the admin key of ZITADEL machine user ${userId} was minted but could not be delivered after ${attempts} tries`);
1790
+ throw new Error(`${label}: the admin key of ZITADEL machine user ${userId} was minted but could not be delivered after ${attempts} tries`);
1670
1791
  }
1671
1792
  await sleep(1000 * attempt);
1672
1793
  }
@@ -1731,11 +1852,19 @@ async function provisionPlatformAuth(context, deps, ownerCompany = null) {
1731
1852
  // make the same call, not just trust that a record exists, or a drifted
1732
1853
  // platform would be planned again forever without ever actually healing.
1733
1854
  const loginClientOtherInputsMissing = brokerMissingInputs.filter((m) => m !== 'ZITADEL_SERVICE_USER_TOKEN');
1734
- const freshRecordedIds = context.recordedIds();
1855
+ const freshRecordedIds = scope.recordedIds();
1735
1856
  const freshLoginClientRecord = parseLoginClientRecord(freshRecordedIds[PLATFORM_AUTH_LOGIN_CLIENT_USER_SCOPE_KEY]);
1736
- const loginClientNeedsMint = !freshLoginClientRecord || (await authLoginClientDrifted(platform, deps, freshRecordedIds));
1857
+ const loginClientNeedsMint = !freshLoginClientRecord ||
1858
+ (await authLoginClientDriftedAt({ hostname, bearerRef: target.refs.instanceBearer }, deps, freshRecordedIds));
1737
1859
  if (loginClientNeedsMint && loginClientOtherInputsMissing.length === 0) {
1738
- const loginClientZitadel = deps.zitadelForInstance?.(`https://${hostname}`) ?? deps.zitadel;
1860
+ // An own org never mints a second time on top of a token that may be live:
1861
+ // the platform's re-mint revokes the old one, which would take the org's
1862
+ // sign-in down if the first delivery had in fact landed.
1863
+ const loginClientLost = freshRecordedIds[PLATFORM_AUTH_LOGIN_CLIENT_UNDELIVERED_SCOPE_KEY];
1864
+ if (target.kind === 'org' && loginClientLost && !freshLoginClientRecord) {
1865
+ throw new Error(`${label}: the login-client token of ZITADEL machine user ${loginClientLost} was minted but never confirmed delivered; remove the recorded key ${PLATFORM_AUTH_LOGIN_CLIENT_UNDELIVERED_SCOPE_KEY} to mint a new one`);
1866
+ }
1867
+ const loginClientZitadel = instanceZitadel();
1739
1868
  const { org: loginClientOrg } = await loginClientZitadel.findOrCreateOrg(PLATFORM_AUTH_ORG_NAME);
1740
1869
  // #216 round 5: backfills the org id for a platform whose admin block
1741
1870
  // (above) ran before this recording existed and has not run since --
@@ -1747,6 +1876,7 @@ async function provisionPlatformAuth(context, deps, ownerCompany = null) {
1747
1876
  userName: PLATFORM_AUTH_LOGIN_CLIENT_USER_NAME,
1748
1877
  name: 'Foundry (platform login client)',
1749
1878
  });
1879
+ runtime.onSecret?.(loginClientToken);
1750
1880
  // Recorded and persisted before the token leaves this function -- the
1751
1881
  // same "one mint" rule the admin block above follows. Never read back to
1752
1882
  // block planning; `planAuth` (`platform-diff.ts`) reads it only to
@@ -1788,8 +1918,8 @@ async function provisionPlatformAuth(context, deps, ownerCompany = null) {
1788
1918
  // saying "its prior token was already revoked" there would describe a
1789
1919
  // revoke that never happened, so the message only says so when it did.
1790
1920
  throw new Error(revokedPriorPat
1791
- ? `platform:auth: the login-client token of ZITADEL machine user ${loginClientUserId} could not be delivered after ${deliverAttempts} tries; its prior token was already revoked, so the login app and broker are down until a later apply mints and delivers a fresh one`
1792
- : `platform:auth: the login-client token of ZITADEL machine user ${loginClientUserId} could not be delivered after ${deliverAttempts} tries; this is its first token, so the login app and broker still have none until a later apply mints and delivers one`);
1921
+ ? `${label}: the login-client token of ZITADEL machine user ${loginClientUserId} could not be delivered after ${deliverAttempts} tries; its prior token was already revoked, so the login app and broker are down until a later apply mints and delivers a fresh one`
1922
+ : `${label}: the login-client token of ZITADEL machine user ${loginClientUserId} could not be delivered after ${deliverAttempts} tries; this is its first token, so the login app and broker still have none until a later apply mints and delivers one`);
1793
1923
  }
1794
1924
  // #216 round 4/5: records what was actually delivered -- the user id,
1795
1925
  // the exact PAT id, and (round 5) a hash of the exact token -- so the
@@ -1802,7 +1932,7 @@ async function provisionPlatformAuth(context, deps, ownerCompany = null) {
1802
1932
  // ---- the owner company's own sign-in org ----------------------------------
1803
1933
  // Archon signs people in from this org; the `foundry` org holds none. The
1804
1934
  // company executor finds the same org by name (its slug) and makes no second.
1805
- const ownerOrgZitadel = deps.zitadelForInstance?.(`https://${hostname}`) ?? deps.zitadel;
1935
+ const ownerOrgZitadel = instanceZitadel();
1806
1936
  let ownerSignInOrgId;
1807
1937
  if (ownerCompany) {
1808
1938
  ({ orgId: ownerSignInOrgId } = await ensureSignInOrg({
@@ -1810,9 +1940,9 @@ async function provisionPlatformAuth(context, deps, ownerCompany = null) {
1810
1940
  zitadel: ownerOrgZitadel,
1811
1941
  name: ownerCompany.slug,
1812
1942
  domain: signInOrgDomain(ownerCompany),
1813
- recordScopeId: (key, id) => context.recordScopeId(key, id),
1943
+ recordScopeId: (key, id) => scope.recordScopeId(key, id),
1814
1944
  persistScopeIds: async () => {
1815
- await context.persist?.();
1945
+ await scope.persist();
1816
1946
  },
1817
1947
  orgScopeKey: PLATFORM_OWNER_ORG_SCOPE_KEY,
1818
1948
  domainScopeKey: PLATFORM_OWNER_ORG_DOMAIN_SCOPE_KEY,
@@ -1827,19 +1957,22 @@ async function provisionPlatformAuth(context, deps, ownerCompany = null) {
1827
1957
  const mailToken = heldMailKey || (await currentEnvValue(deps.coolify, brokerAppId, 'LETTERMINT_API_TOKEN'));
1828
1958
  const smtpTarget = resolveAuthSmtp(deps.smtp);
1829
1959
  if (mailToken) {
1830
- const mailZitadel = deps.zitadelForInstance?.(`https://${hostname}`) ?? deps.zitadel;
1960
+ const mailZitadel = instanceZitadel();
1831
1961
  let smtpPassword;
1832
1962
  try {
1833
1963
  // Runs every time the step does (idempotent), so a rotated token lands.
1834
1964
  smtpPassword = (await deps.smtp?.password?.()) ?? mailToken;
1965
+ runtime.onSecret?.(smtpPassword);
1835
1966
  const smtp = await mailZitadel.ensureSmtpProvider({
1836
1967
  host: smtpTarget.host,
1837
1968
  user: smtpTarget.user,
1838
1969
  password: smtpPassword,
1839
- senderAddress: `noreply@${platform.apex}`,
1970
+ senderAddress: input.smtpSender,
1840
1971
  senderName: PLATFORM_AUTH_MAIL_SENDER_NAME,
1841
1972
  tls: smtpTarget.tls,
1842
- description: PLATFORM_AUTH_SMTP_DESCRIPTION,
1973
+ description: target.kind === 'platform'
1974
+ ? PLATFORM_AUTH_SMTP_DESCRIPTION
1975
+ : `foundry:org:${target.id}:lettermint`,
1843
1976
  });
1844
1977
  await record(PLATFORM_AUTH_SMTP_SCOPE_KEY, smtp.id);
1845
1978
  }
@@ -1849,10 +1982,10 @@ async function provisionPlatformAuth(context, deps, ownerCompany = null) {
1849
1982
  mailToken,
1850
1983
  ...(smtpPassword ? [smtpPassword] : []),
1851
1984
  ]);
1852
- throw new Error(`platform:auth: could not configure ZITADEL's SMTP provider for ${hostname}: ${cause}`);
1985
+ throw new Error(`${label}: could not configure ZITADEL's SMTP provider for ${hostname}: ${cause}`);
1853
1986
  }
1854
- const inviteRecorded = context.recordedIds()[PLATFORM_AUTH_OWNER_INVITE_SCOPE_KEY];
1855
- const inviteOrgRecorded = context.recordedIds()[PLATFORM_AUTH_OWNER_INVITE_ORG_SCOPE_KEY];
1987
+ const inviteRecorded = scope.recordedIds()[PLATFORM_AUTH_OWNER_INVITE_SCOPE_KEY];
1988
+ const inviteOrgRecorded = scope.recordedIds()[PLATFORM_AUTH_OWNER_INVITE_ORG_SCOPE_KEY];
1856
1989
  // An invite recorded for another org (the `foundry` org, before the owner moved) is made again.
1857
1990
  if (!inviteRecorded || (inviteRecorded !== 'none' && inviteOrgRecorded !== ownerSignInOrgId)) {
1858
1991
  const owner = owners[0];
@@ -1870,14 +2003,14 @@ async function provisionPlatformAuth(context, deps, ownerCompany = null) {
1870
2003
  zitadel: mailZitadel,
1871
2004
  mail: deps.ownerInviteMail,
1872
2005
  orgId: ownerSignInOrgId,
1873
- authIssuer: `https://${hostname}`,
2006
+ authIssuer: issuer,
1874
2007
  owner: {
1875
2008
  givenName: owner.givenName,
1876
2009
  familyName: owner.familyName,
1877
2010
  mailboxAddress,
1878
2011
  personalEmail: owner.email,
1879
2012
  },
1880
- label: 'platform:auth',
2013
+ label,
1881
2014
  ...(inviteRecorded && inviteRecorded !== 'none'
1882
2015
  ? { invitedUserId: inviteRecorded }
1883
2016
  : {}),
@@ -1892,7 +2025,7 @@ async function provisionPlatformAuth(context, deps, ownerCompany = null) {
1892
2025
  mailToken,
1893
2026
  ...(smtpPassword ? [smtpPassword] : []),
1894
2027
  ]);
1895
- throw new Error(`platform:auth: could not invite the platform owner into ZITADEL org ${ownerSignInOrgId}: ${cause}`);
2028
+ throw new Error(`${label}: could not invite the platform owner into ZITADEL org ${ownerSignInOrgId}: ${cause}`);
1896
2029
  }
1897
2030
  // The user id first: a crash between the two replans the invite, never skips it.
1898
2031
  await record(PLATFORM_AUTH_OWNER_INVITE_SCOPE_KEY, invitedUserId);
@@ -1903,6 +2036,34 @@ async function provisionPlatformAuth(context, deps, ownerCompany = null) {
1903
2036
  }
1904
2037
  return { resourceId: zitadelAppId };
1905
2038
  }
2039
+ /** The platform's sign-in project: the shared `platform` project, its id recorded under the sign-in's own scope key. */
2040
+ const platformAuthProject = {
2041
+ async ensure(coolify, scope) {
2042
+ const recorded = scope.recordedIds()[PLATFORM_AUTH_PROJECT_SCOPE_KEY];
2043
+ if (recorded)
2044
+ return recorded;
2045
+ const found = await coolify.findProjectByName(PLATFORM_AUTH_PROJECT);
2046
+ const uuid = found ? found.uuid : (await coolify.createProject(PLATFORM_AUTH_PROJECT)).uuid;
2047
+ scope.recordScopeId(PLATFORM_AUTH_PROJECT_SCOPE_KEY, uuid);
2048
+ await scope.persist();
2049
+ return uuid;
2050
+ },
2051
+ };
2052
+ /** The platform's sign-in: {@link provisionAuthStack} with {@link platformTarget} and the platform's own inputs. */
2053
+ async function provisionPlatformAuth(context, deps, ownerCompany = null) {
2054
+ const { platform } = context;
2055
+ const scope = scopeFromPlatform(context);
2056
+ return provisionAuthStack(scope, deps, platformTarget(), {
2057
+ hostname: platform.auth.hostname,
2058
+ apex: platform.apex,
2059
+ gitRef: platform.auth.gitRef,
2060
+ project: platformAuthProject,
2061
+ network: platformNetwork(deps, scope, 'platform:auth'),
2062
+ smtpSender: `noreply@${platform.apex}`,
2063
+ defaultRedirectUri: platform.archon.enabled ? `https://${platform.archon.hostname}` : null,
2064
+ ownerCompany,
2065
+ });
2066
+ }
1906
2067
  /**
1907
2068
  * Reserves the username `admin` and switches self-registration off in the
1908
2069
  * platform's sign-in org (`zitadel:sign-in-policy` keyed {@link PLATFORM_AUTH_KEY}).
@@ -1977,11 +2138,7 @@ const PLATFORM_AUTH_MAIL_SENDER_NAME = 'Company Included';
1977
2138
  // #169: the platform's own mail server (Stalwart)
1978
2139
  // ---------------------------------------------------------------------------
1979
2140
  const PLATFORM_MAIL_PROJECT = 'platform';
1980
- const PLATFORM_MAIL_APP_NAME = 'platform-mail-stalwart';
1981
2141
  const PLATFORM_MAIL_INTERNAL_NAME = 'platform-mail-stalwart';
1982
- const PLATFORM_MAIL_TUNNEL_NAME = 'platform-mail-tunnel';
1983
- /** #197: the one Coolify app that runs `cloudflared` for {@link PLATFORM_MAIL_TUNNEL_NAME}. */
1984
- const PLATFORM_MAIL_CLOUDFLARED_APP_NAME = 'platform-mail-cloudflared';
1985
2142
  /**
1986
2143
  * `stalw.art/docs/install/docker`: the official image. `platform.mail.gitRef`
1987
2144
  * carries its tag -- `platform.ts`'s `PlatformService.gitRef` doc comment
@@ -2004,7 +2161,6 @@ const STALWART_PORTS_EXPOSES = [STALWART_HTTP_PORT, ...STALWART_SMTP_PORTS].join
2004
2161
  * not here -- it stays behind the tunnel, exactly like `platform:auth`'s
2005
2162
  * ZITADEL/login apps.
2006
2163
  */
2007
- const STALWART_PORTS_MAPPINGS = STALWART_SMTP_PORTS.map((p) => `${p}:${p}`).join(',');
2008
2164
  /**
2009
2165
  * The v0.16 image's two VOLUMEs (its Dockerfile): the data store, and the
2010
2166
  * `config.json` Stalwart writes when bootstrap completes. The pre-0.16 image
@@ -2031,6 +2187,7 @@ const STALWART_READY_PATH = '/healthz/ready';
2031
2187
  const PLATFORM_MAIL_SERVER_SCOPE_KEY = 'coolify_platform_mail_server';
2032
2188
  const PLATFORM_MAIL_PROJECT_SCOPE_KEY = 'coolify_project_platform_mail';
2033
2189
  const PLATFORM_MAIL_APP_SCOPE_KEY = 'coolify_platform_mail_app';
2190
+ const PLATFORM_MAIL_RELAY_APP_SCOPE_KEY = 'coolify_platform_mail_relay_app';
2034
2191
  const PLATFORM_MAIL_TUNNEL_SCOPE_KEY = 'cloudflare_platform_mail_tunnel';
2035
2192
  /**
2036
2193
  * Recorded once the app has been deployed at least once -- same role as
@@ -2198,24 +2355,20 @@ const PLATFORM_MAIL_ZITADEL_PROJECT_NAME = 'mail';
2198
2355
  * (the guard only ever blocks a retry and names an id to revoke by), but
2199
2356
  * worth tightening if `createApiKey` ever starts returning its own id.
2200
2357
  */
2201
- async function provisionPlatformMail(context, deps) {
2202
- const { platform } = context;
2203
- const hostname = platform.mail.hostname;
2204
- const mailHost = platformMailMxHost(platform.apex);
2205
- const imageTag = platform.mail.gitRef;
2206
- const recorded = context.recordedIds();
2358
+ export async function provisionMailServer(scope, deps, target, input) {
2359
+ const { hostname, apex, mailHost, imageTag, hostPorts, issuerUrl, mxRecord, network } = input;
2360
+ const label = target.kind === 'org' ? `own step coolify:own-mail for ${target.id}` : 'platform:mail';
2361
+ const keyDescription = target.kind === 'org' ? `foundry:org:${target.id}:admin` : PLATFORM_MAIL_ADMIN_KEY_DESCRIPTION;
2362
+ const recorded = scope.recordedIds();
2207
2363
  const runtime = deps.runtime ?? {};
2208
2364
  const sleep = runtime.sleep ?? defaultSleep;
2209
2365
  const record = async (key, id) => {
2210
- context.recordScopeId(key, id);
2211
- await context.persist?.();
2366
+ scope.recordScopeId(key, id);
2367
+ await scope.persist();
2212
2368
  };
2213
2369
  let projectUuid = recorded[PLATFORM_MAIL_PROJECT_SCOPE_KEY];
2214
2370
  if (!projectUuid) {
2215
- const found = await deps.coolify.findProjectByName(PLATFORM_MAIL_PROJECT);
2216
- projectUuid = found
2217
- ? found.uuid
2218
- : (await deps.coolify.createProject(PLATFORM_MAIL_PROJECT)).uuid;
2371
+ projectUuid = await input.project.ensure(deps.coolify, scope);
2219
2372
  await record(PLATFORM_MAIL_PROJECT_SCOPE_KEY, projectUuid);
2220
2373
  }
2221
2374
  const { environment } = await deps.coolify.findOrCreateEnvironment(projectUuid, 'production');
@@ -2226,12 +2379,14 @@ async function provisionPlatformMail(context, deps) {
2226
2379
  // One server for both the app and the MX record: recorded the first time,
2227
2380
  // read back after, so a later run never points `mx.<apex>` somewhere the
2228
2381
  // app is not.
2382
+ const portsChanged = (recorded[PLATFORM_MAIL_HOST_PORTS_SCOPE_KEY] ??
2383
+ (target.kind === 'org' ? '' : PLATFORM_MAIL_HOST_PORTS_DIRECT)) !== hostPorts;
2229
2384
  const server = await resolveMailServer(deps.coolify, recorded[PLATFORM_MAIL_SERVER_SCOPE_KEY]);
2230
2385
  await record(PLATFORM_MAIL_SERVER_SCOPE_KEY, server.uuid);
2231
- const serverIpBlocked = mailServerIpBlocked(server.ip, platform.serverIpv4);
2386
+ const serverIpBlocked = mxRecord ? mailServerIpBlocked(server.ip, mxRecord.serverIpv4) : null;
2232
2387
  if (serverIpBlocked)
2233
2388
  throw new Error(serverIpBlocked);
2234
- const app = await findOrCreatePlatformApp(deps.coolify, PLATFORM_MAIL_APP_NAME, environment, async () => {
2389
+ const app = await findOrCreatePlatformApp(deps.coolify, target.names.mailApp, environment, async () => {
2235
2390
  return deps.coolify.createDockerImageApplication({
2236
2391
  project_uuid: projectUuid,
2237
2392
  server_uuid: server.uuid,
@@ -2240,9 +2395,11 @@ async function provisionPlatformMail(context, deps) {
2240
2395
  docker_registry_image_name: STALWART_IMAGE,
2241
2396
  docker_registry_image_tag: imageTag,
2242
2397
  ports_exposes: STALWART_PORTS_EXPOSES,
2243
- ports_mappings: STALWART_PORTS_MAPPINGS,
2244
- name: PLATFORM_MAIL_APP_NAME,
2245
- description: `Stalwart for the platform's own mail (#169, ${STALWART_IMAGE}:${imageTag})`,
2398
+ ...(hostPorts ? { ports_mappings: hostPorts } : {}),
2399
+ name: target.names.mailApp,
2400
+ description: target.kind === 'org'
2401
+ ? `Stalwart for ${target.id}'s own mail (${STALWART_IMAGE}:${imageTag})`
2402
+ : `Stalwart for the platform's own mail (#169, ${STALWART_IMAGE}:${imageTag})`,
2246
2403
  instant_deploy: false,
2247
2404
  });
2248
2405
  });
@@ -2255,14 +2412,16 @@ async function provisionPlatformMail(context, deps) {
2255
2412
  // container before the new one can answer `waitForHealthyVersion`'s own
2256
2413
  // `'ready'` probe below. Stalwart's own readiness route and HTTP port.
2257
2414
  await deps.coolify.updateApplication(appId, {
2258
- custom_internal_name: PLATFORM_MAIL_INTERNAL_NAME,
2415
+ custom_internal_name: target.internal.mail,
2259
2416
  docker_registry_image_tag: imageTag,
2260
2417
  ...healthCheckSettings(STALWART_READY_PATH, Number(STALWART_HTTP_PORT)),
2418
+ // Only a platform that uses the shared relay, or one that did, changes a published port.
2419
+ ...(portsChanged ? { ports_mappings: hostPorts } : {}),
2261
2420
  });
2262
2421
  // #177: org steps (an org's own `coolify:mail`/`coolify:agora`) read this
2263
2422
  // back instead of a literal, the same way files/ai's own internal
2264
2423
  // addresses are recorded.
2265
- await record(PLATFORM_MAIL_INTERNAL_URL_SCOPE_KEY, `http://${PLATFORM_MAIL_INTERNAL_NAME}:${STALWART_HTTP_PORT}`);
2424
+ await record(PLATFORM_MAIL_INTERNAL_URL_SCOPE_KEY, `http://${target.internal.mail}:${STALWART_HTTP_PORT}`);
2266
2425
  // ---- storage ---------------------------------------------------------------
2267
2426
  const storages = await deps.coolify.listStorages(appId);
2268
2427
  // An app made before this release only has the old `/opt/stalwart` volume.
@@ -2274,7 +2433,7 @@ async function provisionPlatformMail(context, deps) {
2274
2433
  continue;
2275
2434
  }
2276
2435
  await deps.coolify.createPersistentStorage(appId, {
2277
- name: `${PLATFORM_MAIL_APP_NAME}-${wanted.suffix}`,
2436
+ name: `${target.names.mailApp}-${wanted.suffix}`,
2278
2437
  mount_path: wanted.mount_path,
2279
2438
  });
2280
2439
  storagesAdded = true;
@@ -2295,7 +2454,12 @@ async function provisionPlatformMail(context, deps) {
2295
2454
  const declaredChanged = recorded[PLATFORM_MAIL_DEPLOYED_HOSTNAME_SCOPE_KEY] !== hostname ||
2296
2455
  recorded[PLATFORM_MAIL_DEPLOYED_GIT_REF_SCOPE_KEY] !== imageTag;
2297
2456
  // A new storage binds into the container only at its next start.
2298
- const mailDeployedNow = app.justCreated || !app.running || envsChanged || declaredChanged || storagesAdded;
2457
+ const mailDeployedNow = app.justCreated ||
2458
+ !app.running ||
2459
+ envsChanged ||
2460
+ declaredChanged ||
2461
+ storagesAdded ||
2462
+ portsChanged;
2299
2463
  if (mailDeployedNow) {
2300
2464
  // A container still up from before would answer the readiness probe with
2301
2465
  // the old deployment's health. Stop it and see it stopped first, so the
@@ -2311,7 +2475,7 @@ async function provisionPlatformMail(context, deps) {
2311
2475
  // instead would just poll a hostname that cannot resolve yet on a first
2312
2476
  // apply. Either way it runs before any applied ref is recorded.
2313
2477
  // The old container was stopped above (or never ran), so nothing stays live.
2314
- await waitForDeploymentFinished(deps.coolify, appId, runtime, 'platform:mail', app.justCreated || !app.running
2478
+ await waitForDeploymentFinished(deps.coolify, appId, runtime, label, app.justCreated || !app.running
2315
2479
  ? `mail stays at ${recorded[PLATFORM_MAIL_APPLIED_GIT_REF_SCOPE_KEY] ?? 'no recorded ref'}`
2316
2480
  : 'mail is stopped until the next apply');
2317
2481
  await record(PLATFORM_MAIL_DEPLOYED_SCOPE_KEY, appId);
@@ -2319,17 +2483,19 @@ async function provisionPlatformMail(context, deps) {
2319
2483
  // settings, mint) does not make the next run stop and redeploy the app.
2320
2484
  await record(PLATFORM_MAIL_DEPLOYED_HOSTNAME_SCOPE_KEY, hostname);
2321
2485
  await record(PLATFORM_MAIL_DEPLOYED_GIT_REF_SCOPE_KEY, imageTag);
2486
+ if (hostPorts)
2487
+ await record(PLATFORM_MAIL_HOST_PORTS_SCOPE_KEY, hostPorts);
2322
2488
  }
2323
2489
  // ---- tunnel and DNS: web/JMAP/management ------------------------------------
2324
- const tunnels = await platformTunnels(deps, context.recordedIds(), 'platform:mail');
2325
- const existingTunnel = await tunnels.findTunnelByName(PLATFORM_MAIL_TUNNEL_NAME);
2326
- const tunnel = existingTunnel ?? (await tunnels.createTunnel(PLATFORM_MAIL_TUNNEL_NAME));
2490
+ const tunnels = await network.tunnels();
2491
+ const existingTunnel = await tunnels.findTunnelByName(target.names.mailTunnel);
2492
+ const tunnel = existingTunnel ?? (await tunnels.createTunnel(target.names.mailTunnel));
2327
2493
  await record(PLATFORM_MAIL_TUNNEL_SCOPE_KEY, tunnel.id);
2328
2494
  await tunnels.putTunnelConfig(tunnel.id, {
2329
2495
  ingress: [
2330
2496
  {
2331
2497
  hostname,
2332
- service: `http://${PLATFORM_MAIL_INTERNAL_NAME}:${STALWART_HTTP_PORT}`,
2498
+ service: `http://${target.internal.mail}:${STALWART_HTTP_PORT}`,
2333
2499
  },
2334
2500
  { service: 'http_status:404' },
2335
2501
  ],
@@ -2339,7 +2505,7 @@ async function provisionPlatformMail(context, deps) {
2339
2505
  coolify: deps.coolify,
2340
2506
  tunnels,
2341
2507
  tunnelId: tunnel.id,
2342
- appName: PLATFORM_MAIL_CLOUDFLARED_APP_NAME,
2508
+ appName: target.names.mailCloudflared,
2343
2509
  projectUuid,
2344
2510
  environment,
2345
2511
  // The Stalwart app's own server, so the connector shares its Docker network.
@@ -2347,15 +2513,15 @@ async function provisionPlatformMail(context, deps) {
2347
2513
  recordedConnectorAppId: recorded[PLATFORM_MAIL_CONNECTOR_APP_SCOPE_KEY] ?? null,
2348
2514
  recordAppId: (appId) => record(PLATFORM_MAIL_CONNECTOR_APP_SCOPE_KEY, appId),
2349
2515
  });
2350
- const zoneId = recorded[apexZoneKey(platform.apex)] ?? (await exactZone(deps.cloudflare, platform.apex))?.id;
2516
+ const zoneId = await network.zoneId(apex);
2351
2517
  if (!zoneId) {
2352
- throw new Error(`platform:mail: no Cloudflare zone for ${platform.apex} to put ${hostname} in`);
2518
+ throw new Error(`${label}: no Cloudflare zone for ${apex} to put ${hostname} in`);
2353
2519
  }
2354
2520
  const dnsTarget = `${tunnel.id}.cfargotunnel.com`;
2355
- const dns = await platformDns(deps, context.recordedIds(), 'platform:mail');
2356
- const [dnsRecord] = await dns.findDnsRecord(platform.apex, hostname, 'CNAME');
2521
+ const dns = await network.dns();
2522
+ const [dnsRecord] = await dns.findDnsRecord(apex, hostname, 'CNAME');
2357
2523
  if (!dnsRecord) {
2358
- await dns.createDnsRecord(platform.apex, {
2524
+ await dns.createDnsRecord(apex, {
2359
2525
  type: 'CNAME',
2360
2526
  name: hostname,
2361
2527
  content: dnsTarget,
@@ -2363,31 +2529,35 @@ async function provisionPlatformMail(context, deps) {
2363
2529
  });
2364
2530
  }
2365
2531
  else if (dnsRecord.content !== dnsTarget) {
2366
- await dns.updateDnsRecord(platform.apex, dnsRecord.id, {
2532
+ await dns.updateDnsRecord(apex, dnsRecord.id, {
2367
2533
  type: 'CNAME',
2368
2534
  name: hostname,
2369
2535
  content: dnsTarget,
2370
2536
  proxied: true,
2371
2537
  });
2372
2538
  }
2539
+ if (target.kind === 'org')
2540
+ await recordOwnDnsHostname(scope, hostname);
2373
2541
  // ---- the MX hostname: a direct, unproxied A record --------------------------
2374
- const serverIp = (platform.serverIpv4 ?? server.ip); // checked by mailServerIpBlocked above
2375
- const [mxRecord] = await dns.findDnsRecord(platform.apex, mailHost, 'A');
2376
- if (!mxRecord) {
2377
- await dns.createDnsRecord(platform.apex, {
2378
- type: 'A',
2379
- name: mailHost,
2380
- content: serverIp,
2381
- proxied: false,
2382
- });
2383
- }
2384
- else if (mxRecord.content !== serverIp) {
2385
- await dns.updateDnsRecord(platform.apex, mxRecord.id, {
2386
- type: 'A',
2387
- name: mailHost,
2388
- content: serverIp,
2389
- proxied: false,
2390
- });
2542
+ if (mxRecord) {
2543
+ const serverIp = (mxRecord.serverIpv4 ?? server.ip); // checked by mailServerIpBlocked above
2544
+ const [existingMx] = await dns.findDnsRecord(apex, mailHost, 'A');
2545
+ if (!existingMx) {
2546
+ await dns.createDnsRecord(apex, {
2547
+ type: 'A',
2548
+ name: mailHost,
2549
+ content: serverIp,
2550
+ proxied: false,
2551
+ });
2552
+ }
2553
+ else if (existingMx.content !== serverIp) {
2554
+ await dns.updateDnsRecord(apex, existingMx.id, {
2555
+ type: 'A',
2556
+ name: mailHost,
2557
+ content: serverIp,
2558
+ proxied: false,
2559
+ });
2560
+ }
2391
2561
  }
2392
2562
  // #252: phase 2, now that the tunnel and DNS above actually route
2393
2563
  // `hostname`. `'ready'`, not `'version'`: Stalwart's `/healthz/ready`
@@ -2404,7 +2574,7 @@ async function provisionPlatformMail(context, deps) {
2404
2574
  const ensureMailReady = async () => {
2405
2575
  if (mailReadyConfirmed)
2406
2576
  return;
2407
- await waitForHealthyVersion(appId, { kind: 'ready', url: `https://${hostname}${STALWART_READY_PATH}` }, runtime, sleep, 'platform:mail', `mail stays at ${recorded[PLATFORM_MAIL_APPLIED_GIT_REF_SCOPE_KEY] ?? 'no recorded ref'}`);
2577
+ await waitForHealthyVersion(appId, { kind: 'ready', url: `https://${hostname}${STALWART_READY_PATH}` }, runtime, sleep, label, `mail stays at ${recorded[PLATFORM_MAIL_APPLIED_GIT_REF_SCOPE_KEY] ?? 'no recorded ref'}`);
2408
2578
  mailReadyConfirmed = true;
2409
2579
  };
2410
2580
  // Also probe when the applied ref lags the target: the deployed refs are
@@ -2419,7 +2589,7 @@ async function provisionPlatformMail(context, deps) {
2419
2589
  // projects -- its id is the OIDC audience Stalwart's directory checks an
2420
2590
  // access token's `aud` claim against, so no host has to register a
2421
2591
  // resource API for this by hand.
2422
- const issuer = `https://${platform.auth.hostname}`;
2592
+ const issuer = issuerUrl;
2423
2593
  const zitadel = deps.zitadelForInstance?.(issuer) ?? deps.zitadel;
2424
2594
  const { org } = await zitadel.findOrCreateOrg(PLATFORM_AUTH_ORG_NAME);
2425
2595
  const { project } = await zitadel.findOrCreateProject(org.id, PLATFORM_MAIL_ZITADEL_PROJECT_NAME);
@@ -2445,7 +2615,7 @@ async function provisionPlatformMail(context, deps) {
2445
2615
  await ensureMailReady();
2446
2616
  const recoveryEnv = (await deps.coolify.listEnvs(appId)).find((e) => e.key === 'STALWART_RECOVERY_ADMIN' && !e.is_preview);
2447
2617
  if (!recoveryEnv) {
2448
- throw new Error('platform:mail: STALWART_RECOVERY_ADMIN is not set on the app; cannot authenticate to re-apply the OIDC audience');
2618
+ throw new Error(`${label}: STALWART_RECOVERY_ADMIN is not set on the app; cannot authenticate to re-apply the OIDC audience`);
2449
2619
  }
2450
2620
  const password = recoveryEnv.value.slice(`${STALWART_RECOVERY_USER}:`.length);
2451
2621
  try {
@@ -2453,7 +2623,7 @@ async function provisionPlatformMail(context, deps) {
2453
2623
  url: `https://${hostname}`,
2454
2624
  user: STALWART_RECOVERY_USER,
2455
2625
  password,
2456
- apex: platform.apex,
2626
+ apex,
2457
2627
  });
2458
2628
  await applyMailSettings(admin, {
2459
2629
  coolify: deps.coolify,
@@ -2462,8 +2632,8 @@ async function provisionPlatformMail(context, deps) {
2462
2632
  sleep,
2463
2633
  hostname,
2464
2634
  settings: {
2465
- mailHost,
2466
- apex: platform.apex,
2635
+ mailHost: hostname,
2636
+ apex,
2467
2637
  oidc: { issuerUrl: issuer, audience: project.id },
2468
2638
  },
2469
2639
  });
@@ -2481,12 +2651,12 @@ async function provisionPlatformMail(context, deps) {
2481
2651
  if (!recorded[PLATFORM_MAIL_ADMIN_KEY_SCOPE_KEY]) {
2482
2652
  const lost = recorded[PLATFORM_MAIL_ADMIN_UNDELIVERED_SCOPE_KEY];
2483
2653
  if (lost) {
2484
- throw new Error(`platform:mail: an admin API key ("${lost}") was minted but never delivered; revoke it in Stalwart and remove the recorded key ${PLATFORM_MAIL_ADMIN_UNDELIVERED_SCOPE_KEY} to mint a new one`);
2654
+ throw new Error(`${label}: an admin API key ("${lost}") was minted but never delivered; revoke it in Stalwart and remove the recorded key ${PLATFORM_MAIL_ADMIN_UNDELIVERED_SCOPE_KEY} to mint a new one`);
2485
2655
  }
2486
2656
  await ensureMailReady();
2487
2657
  const recoveryEnv = (await deps.coolify.listEnvs(appId)).find((e) => e.key === 'STALWART_RECOVERY_ADMIN' && !e.is_preview);
2488
2658
  if (!recoveryEnv) {
2489
- throw new Error('platform:mail: STALWART_RECOVERY_ADMIN is not set on the app; cannot authenticate to mint the admin key');
2659
+ throw new Error(`${label}: STALWART_RECOVERY_ADMIN is not set on the app; cannot authenticate to mint the admin key`);
2490
2660
  }
2491
2661
  const password = recoveryEnv.value.slice(`${STALWART_RECOVERY_USER}:`.length);
2492
2662
  try {
@@ -2494,7 +2664,7 @@ async function provisionPlatformMail(context, deps) {
2494
2664
  url: `https://${hostname}`,
2495
2665
  user: STALWART_RECOVERY_USER,
2496
2666
  password,
2497
- apex: platform.apex,
2667
+ apex,
2498
2668
  });
2499
2669
  await applyMailSettings(admin, {
2500
2670
  coolify: deps.coolify,
@@ -2503,8 +2673,8 @@ async function provisionPlatformMail(context, deps) {
2503
2673
  sleep,
2504
2674
  hostname,
2505
2675
  settings: {
2506
- mailHost,
2507
- apex: platform.apex,
2676
+ mailHost: hostname,
2677
+ apex,
2508
2678
  oidc: { issuerUrl: issuer, audience: project.id },
2509
2679
  },
2510
2680
  });
@@ -2512,17 +2682,17 @@ async function provisionPlatformMail(context, deps) {
2512
2682
  // Recorded and persisted before the secret leaves this function: if
2513
2683
  // delivery fails for good, the next plan reports the lost key rather
2514
2684
  // than skipping or minting again.
2515
- await record(PLATFORM_MAIL_ADMIN_UNDELIVERED_SCOPE_KEY, PLATFORM_MAIL_ADMIN_KEY_DESCRIPTION);
2516
- const secret = await admin.createApiKey(PLATFORM_MAIL_ADMIN_KEY_DESCRIPTION, 'inherit');
2685
+ await record(PLATFORM_MAIL_ADMIN_UNDELIVERED_SCOPE_KEY, keyDescription);
2686
+ const secret = await admin.createApiKey(keyDescription, 'inherit');
2517
2687
  const attempts = runtime.deliverAttempts ?? 3;
2518
2688
  for (let attempt = 1;; attempt += 1) {
2519
2689
  try {
2520
- await deps.deliverSecret('stalwart:platform:admin', secret);
2690
+ await deps.deliverSecret(target.refs.mailAdminKey, secret);
2521
2691
  break;
2522
2692
  }
2523
2693
  catch {
2524
2694
  if (attempt >= attempts) {
2525
- throw new Error(`platform:mail: the admin API key ("${PLATFORM_MAIL_ADMIN_KEY_DESCRIPTION}") was minted but could not be delivered after ${attempts} tries`);
2695
+ throw new Error(`${label}: the admin API key ("${keyDescription}") was minted but could not be delivered after ${attempts} tries`);
2526
2696
  }
2527
2697
  await sleep(1000 * attempt);
2528
2698
  }
@@ -2539,6 +2709,29 @@ async function provisionPlatformMail(context, deps) {
2539
2709
  await record(PLATFORM_MAIL_APPLIED_GIT_REF_SCOPE_KEY, imageTag);
2540
2710
  return { resourceId: appId };
2541
2711
  }
2712
+ /** The platform's mail project: the shared `platform` project, its id recorded by the mail step under its own key. */
2713
+ const platformMailProject = {
2714
+ async ensure(coolify) {
2715
+ const found = await coolify.findProjectByName(PLATFORM_MAIL_PROJECT);
2716
+ return found ? found.uuid : (await coolify.createProject(PLATFORM_MAIL_PROJECT)).uuid;
2717
+ },
2718
+ };
2719
+ /** The platform's mail server: {@link provisionMailServer} with {@link platformTarget} and the platform's own inputs. */
2720
+ async function provisionPlatformMail(context, deps) {
2721
+ const { platform } = context;
2722
+ const scope = scopeFromPlatform(context);
2723
+ return provisionMailServer(scope, deps, platformTarget(), {
2724
+ hostname: platform.mail.hostname,
2725
+ apex: platform.apex,
2726
+ mailHost: platformMailMxHost(platform.apex),
2727
+ imageTag: platform.mail.gitRef,
2728
+ hostPorts: platformMailHostPorts(platform),
2729
+ issuerUrl: `https://${platform.auth.hostname}`,
2730
+ mxRecord: { serverIpv4: platform.serverIpv4 },
2731
+ project: platformMailProject,
2732
+ network: platformNetwork(deps, scope, 'platform:mail'),
2733
+ });
2734
+ }
2542
2735
  /**
2543
2736
  * Why the Stalwart image tag cannot be used, or `null`. #248: a thin wrapper
2544
2737
  * over the estate-wide {@link floatingTagBlocked} -- the mail step's own
@@ -2604,6 +2797,31 @@ async function mailBlockedReason(platform, mail, recordedIds) {
2604
2797
  return "the platform's mail server is blocked, not planned: no usable Coolify server could be resolved for it";
2605
2798
  }
2606
2799
  }
2800
+ /** Why the shared mail relay cannot be built from its pinned base image, or `null`. */
2801
+ function mailRelayBlockedReason() {
2802
+ const tagBlocked = floatingTagBlocked('mail relay base', VERSIONS.alpine, '3.22.1');
2803
+ return tagBlocked ? `the platform's mail relay is blocked, not planned: ${tagBlocked}` : null;
2804
+ }
2805
+ /**
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.
2809
+ */
2810
+ async function provisionPlatformMailRelay(action, context, deps) {
2811
+ 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');
2816
+ }
2817
+ return provisionMailRelay(action, context, deps, {
2818
+ projectUuid,
2819
+ serverUuid,
2820
+ hostname: platformMailMxHost(context.platform.apex),
2821
+ defaultTarget: `${PLATFORM_MAIL_INTERNAL_NAME}:25`,
2822
+ appScopeKey: PLATFORM_MAIL_RELAY_APP_SCOPE_KEY,
2823
+ });
2824
+ }
2607
2825
  /**
2608
2826
  * #211: the broker cannot start without `ZITADEL_SERVICE_USER_TOKEN`/`_FILE`,
2609
2827
  * `LETTERMINT_API_TOKEN` and `AUTH_APPS`. `provisionPlatformAuth` already
@@ -4534,8 +4752,6 @@ function parseAiKeyMintedBody(body) {
4534
4752
  secret: record.secret,
4535
4753
  };
4536
4754
  }
4537
- /** The ref the AI management key is delivered under; the host's `AiOrganisationPort` adapter reads it back. */
4538
- export const PLATFORM_AI_MANAGEMENT_KEY_REF = 'ai:platform:managementKey';
4539
4755
  /**
4540
4756
  * What Archon's management key may do in ai: exactly `ai`'s own
4541
4757
  * `ARCHON_AI_PERMISSIONS` (`apps/host/scripts/authz/bootstrap-archon.ts`) --
@@ -4884,13 +5100,15 @@ async function provisionPlatformAi(context, deps) {
4884
5100
  // Every run, so a changed gitRef reaches an app that already exists.
4885
5101
  git_branch: gitRef,
4886
5102
  };
4887
- // #543: the health-check quartet too, runner only -- the worker exposes
4888
- // no port at all (`ports_exposes` is never set on it, just below), so
4889
- // there is nothing for Coolify's own container probe to reach.
5103
+ // #543: the health-check quartet too, on the runner. The worker exposes no
5104
+ // port (`ports_exposes` is never set on it, just below); its Dockerfile's
5105
+ // HEALTHCHECK checks the age of the heartbeat file it writes, and that is the live one.
4890
5106
  if (spec === PLATFORM_AI_RUNNER) {
4891
5107
  patch.ports_exposes = PLATFORM_AI_PORT;
4892
5108
  Object.assign(patch, healthCheckSettings('/api/health', Number(PLATFORM_AI_PORT)));
4893
5109
  }
5110
+ if (spec === PLATFORM_AI_WORKER)
5111
+ Object.assign(patch, IMAGE_HEALTHCHECK_SETTINGS);
4894
5112
  await coolify.updateApplication(app.appId, patch);
4895
5113
  return app;
4896
5114
  };
@@ -5305,7 +5523,7 @@ async function missingEnvs(coolify, appId, defaults) {
5305
5523
  }
5306
5524
  const defaultSleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
5307
5525
  /** Polls `url` until it answers 2xx, or throws once `readyTimeoutMs` has passed. */
5308
- async function waitForInstance(url, runtime, sleep) {
5526
+ async function waitForInstance(url, runtime, sleep, label = 'platform:auth') {
5309
5527
  const fetchImpl = runtime.fetch ?? globalThis.fetch;
5310
5528
  const now = runtime.now ?? Date.now;
5311
5529
  const timeout = runtime.readyTimeoutMs ?? 300_000;
@@ -5313,7 +5531,9 @@ async function waitForInstance(url, runtime, sleep) {
5313
5531
  const deadline = now() + timeout;
5314
5532
  for (;;) {
5315
5533
  try {
5316
- const response = await fetchImpl(url);
5534
+ const response = await fetchImpl(url, {
5535
+ signal: AbortSignal.timeout(runtime.readyRequestTimeoutMs ?? 10_000),
5536
+ });
5317
5537
  if (response.ok)
5318
5538
  return;
5319
5539
  }
@@ -5321,7 +5541,7 @@ async function waitForInstance(url, runtime, sleep) {
5321
5541
  // Not up yet: DNS, the tunnel or the container.
5322
5542
  }
5323
5543
  if (now() >= deadline) {
5324
- throw new Error(`platform:auth: the new ZITADEL instance did not answer at ${url} within ${Math.round(timeout / 1000)}s; retry this apply`);
5544
+ throw new Error(`${label}: the new ZITADEL instance did not answer at ${url} within ${Math.round(timeout / 1000)}s; retry this apply`);
5325
5545
  }
5326
5546
  await sleep(interval);
5327
5547
  }
@@ -5547,12 +5767,12 @@ async function currentEnvValue(coolify, appId, key) {
5547
5767
  * deployed app means someone removed it. This throws, and a person sets the key that database was
5548
5768
  * initialised with (or deletes an unused database) and applies again.
5549
5769
  */
5550
- async function ensureMasterkey(coolify, appId, databaseInUse) {
5770
+ async function ensureMasterkey(coolify, appId, databaseInUse, label = 'platform:auth') {
5551
5771
  const envs = await coolify.listEnvs(appId);
5552
5772
  if (envs.some((e) => e.key === 'ZITADEL_MASTERKEY' && !e.is_preview))
5553
5773
  return null;
5554
5774
  if (databaseInUse) {
5555
- throw new Error(`platform:auth: app ${appId} has no ZITADEL_MASTERKEY but its database is already in use; not generating a new one. Set ZITADEL_MASTERKEY on that app to the key the database was initialised with, or delete the database if ZITADEL never ran on it, then apply again`);
5775
+ throw new Error(`${label}: app ${appId} has no ZITADEL_MASTERKEY but its database is already in use; not generating a new one. Set ZITADEL_MASTERKEY on that app to the key the database was initialised with, or delete the database if ZITADEL never ran on it, then apply again`);
5556
5776
  }
5557
5777
  return randomBytes(24).toString('base64');
5558
5778
  }
@@ -5568,23 +5788,21 @@ async function ensureMasterkey(coolify, appId, databaseInUse) {
5568
5788
  */
5569
5789
  export const FILES_MANAGEMENT_ISSUER = 'foundry';
5570
5790
  export const FILES_MANAGEMENT_KEY_ID = 'foundry-1';
5571
- /** The ref the management private key (PKCS#8 PEM) is delivered under. */
5572
- export const FILES_MANAGEMENT_KEY_REF = 'files:platform:managementKey';
5573
5791
  /** The ref the files host's introspection client secret is delivered under. */
5574
5792
  export const FILES_INTROSPECTION_SECRET_REF = 'zitadel:platform:files:introspection';
5575
5793
  /** https://github.com/wtfalch/files -- root Dockerfile with `migration` and `runner` targets. */
5576
5794
  const FILES_REPO = 'wtfalch/files';
5577
5795
  const PLATFORM_FILES_PROJECT = 'platform';
5578
- const PLATFORM_FILES_DB_NAME = 'platform-files-db';
5796
+ export const PLATFORM_FILES_DB_NAME = 'platform-files-db';
5579
5797
  /** The two Coolify apps files' `apps/host/README.md` ("Deploying on Coolify") names. */
5580
- const FILES_MIGRATE_APP_NAME = 'files-migrate';
5581
- const FILES_RUNNER_APP_NAME = 'files';
5798
+ export const FILES_MIGRATE_APP_NAME = 'files-migrate';
5799
+ export const FILES_RUNNER_APP_NAME = 'files';
5582
5800
  /**
5583
5801
  * The runner's fixed `custom_internal_name`: the name another container on
5584
5802
  * the Coolify network reaches it by, and so the host part of
5585
5803
  * {@link PLATFORM_FILES_INTERNAL_URL_SCOPE_KEY}'s recorded address.
5586
5804
  */
5587
- const FILES_RUNNER_INTERNAL_NAME = 'platform-files';
5805
+ export const FILES_RUNNER_INTERNAL_NAME = 'platform-files';
5588
5806
  /** The runner's port (`Dockerfile`'s `runner` stage: `PORT=3000`, `EXPOSE 3000`). */
5589
5807
  const FILES_PORT = '3000';
5590
5808
  /** files' own health route, the same one `healthUrls` (`executor.ts`) assumes every hosted app serves. */
@@ -5681,22 +5899,106 @@ async function provisionPlatformFiles(context, deps,
5681
5899
  /** {@link filesShareDomains} of every declared org: each is a runner domain and a public host. */
5682
5900
  shareDomains) {
5683
5901
  const { platform } = context;
5684
- const gitRef = platform.files.gitRef;
5685
- const recorded = context.recordedIds();
5902
+ return provisionFilesHost(scopeFromPlatform(context), deps, platformTarget(), {
5903
+ hostname: platform.files.hostname,
5904
+ gitRef: platform.files.gitRef,
5905
+ project: platformFilesProject,
5906
+ issuer: `https://${platform.auth.hostname}`,
5907
+ zitadelOrg: { name: PLATFORM_AUTH_ORG_NAME },
5908
+ zitadelProject: {
5909
+ name: FILES_ZITADEL_PROJECT_NAME,
5910
+ scopeKey: PLATFORM_FILES_ZITADEL_PROJECT_SCOPE_KEY,
5911
+ },
5912
+ shareDomains,
5913
+ });
5914
+ }
5915
+ /** The platform's files host lives in the shared `platform` project. */
5916
+ const platformFilesProject = {
5917
+ async ensure(coolify, scope) {
5918
+ const recorded = scope.recordedIds()[PLATFORM_FILES_PROJECT_SCOPE_KEY];
5919
+ if (recorded)
5920
+ return recorded;
5921
+ const found = await coolify.findProjectByName(PLATFORM_FILES_PROJECT);
5922
+ const projectUuid = found
5923
+ ? found.uuid
5924
+ : (await coolify.createProject(PLATFORM_FILES_PROJECT)).uuid;
5925
+ scope.recordScopeId(PLATFORM_FILES_PROJECT_SCOPE_KEY, projectUuid);
5926
+ await scope.persist();
5927
+ return projectUuid;
5928
+ },
5929
+ };
5930
+ /** The credential id of the keyring an own files host runs on; recorded once both apps carry it. */
5931
+ export const OWN_FILES_KEYS_SCOPE_KEY = 'files_keys_id';
5932
+ /** The credential id minted and not yet confirmed delivered. */
5933
+ export const OWN_FILES_KEYS_UNDELIVERED_SCOPE_KEY = 'files_keys_undelivered';
5934
+ /**
5935
+ * An own files host's keyring, before its env writes. Both refs held: those values, no mint (a rerun
5936
+ * after a failed run resumes here). Nothing held: one mint, which is checked, registered as a
5937
+ * secret, recorded by id as undelivered, then delivered to both refs. A re-mint is harmless: it
5938
+ * yields an equivalent credential. Returns the guard id for {@link FilesHostInput.keysGuardId}.
5939
+ */
5940
+ export async function resolveOwnFilesKeys(scope, deps, target, mint, workerUrl, label) {
5941
+ const runtime = deps.runtime ?? {};
5942
+ const recorded = scope.recordedIds();
5943
+ const credential = await deps.readSecret?.(target.refs.keysCredential);
5944
+ const verifyKeys = await deps.readSecret?.(target.refs.keysVerifyKeys);
5945
+ if (credential && verifyKeys) {
5946
+ runtime.onSecret?.(credential);
5947
+ return {
5948
+ keys: { workerUrl, credential, verifyKeys },
5949
+ guardId: recorded[OWN_FILES_KEYS_SCOPE_KEY] ||
5950
+ recorded[OWN_FILES_KEYS_UNDELIVERED_SCOPE_KEY] ||
5951
+ 'held',
5952
+ };
5953
+ }
5954
+ const answer = await mint();
5955
+ runtime.onSecret?.(answer.credential);
5956
+ const keys = { workerUrl, credential: answer.credential, verifyKeys: answer.verifyKeys };
5957
+ const invalid = answer.credential
5958
+ ? filesDepsInvalid({ keys })
5959
+ : 'credential';
5960
+ if (invalid)
5961
+ throw new Error(`${label}: the keys worker's answer is not usable (${invalid})`);
5962
+ // Recorded and persisted before the credential leaves this function.
5963
+ scope.recordScopeId(OWN_FILES_KEYS_UNDELIVERED_SCOPE_KEY, answer.credentialId);
5964
+ await scope.persist();
5965
+ const attempts = runtime.deliverAttempts ?? 3;
5966
+ const sleep = runtime.sleep ?? defaultSleep;
5967
+ await deliverWithRetry(deps.deliverSecret, target.refs.keysCredential, answer.credential, attempts, sleep, `the keys credential (${answer.credentialId})`, label);
5968
+ await deliverWithRetry(deps.deliverSecret, target.refs.keysVerifyKeys, answer.verifyKeys, attempts, sleep, `the verify keys of credential ${answer.credentialId}`, label);
5969
+ return { keys, guardId: answer.credentialId };
5970
+ }
5971
+ /**
5972
+ * The body of {@link provisionPlatformFiles} for any files host: the
5973
+ * platform's, or an own org's. Every name, the internal name and the two
5974
+ * held-key refs come from `target`; the project, sign-in and ZITADEL home
5975
+ * come from `input`. See {@link provisionPlatformFiles} for what the step does.
5976
+ */
5977
+ export async function provisionFilesHost(scope, deps, target, input) {
5978
+ const { gitRef, shareDomains } = input;
5979
+ const own = target.kind === 'org';
5980
+ const label = own ? `own step coolify:own-files for ${target.id}` : 'platform:files';
5981
+ const recorded = scope.recordedIds();
5686
5982
  const runtime = deps.runtime ?? {};
5687
5983
  const sleep = runtime.sleep ?? defaultSleep;
5688
5984
  const attempts = runtime.deliverAttempts ?? 3;
5689
5985
  const keys = filesKeys(deps);
5690
5986
  if (!keys) {
5691
- throw new Error('platform:files needs the keys worker URL, credential and verify keys in its files dependencies (KEYS_WORKER_URL, KEYS_CREDENTIAL, KEYS_VERIFY_KEYS); files never becomes healthy without them');
5692
- }
5987
+ throw new Error(`${label} needs the keys worker URL, credential and verify keys in its files dependencies (KEYS_WORKER_URL, KEYS_CREDENTIAL, KEYS_VERIFY_KEYS); files never becomes healthy without them`);
5988
+ }
5989
+ runtime.onSecret?.(keys.credential);
5990
+ // The platform's audit token and jobs secrets never go to a company's host (SC4).
5991
+ const auditToken = own ? undefined : deps.keys?.auditToken;
5992
+ const jobsSigningSecrets = own ? undefined : deps.jobsSigningSecrets;
5993
+ if (auditToken)
5994
+ runtime.onSecret?.(auditToken);
5693
5995
  // #380: R2_BUCKET/R2_ENDPOINT, the same "block the whole step" treatment
5694
5996
  // as a missing `keys` above -- the runner's `ensureStarted()` requires
5695
5997
  // both, and only this function's caller (`blockedSteps`, `planFiles`) ever
5696
5998
  // has anything useful to report while they are unset.
5697
5999
  const r2 = filesR2(deps);
5698
6000
  if (!r2) {
5699
- throw new Error('platform:files needs the R2 bucket and endpoint in its files dependencies (R2_BUCKET, R2_ENDPOINT); files never becomes healthy without them');
6001
+ throw new Error(`${label} needs the R2 bucket and endpoint in its files dependencies (R2_BUCKET, R2_ENDPOINT); files never becomes healthy without them`);
5700
6002
  }
5701
6003
  // Never plan-and-throw: the planner reports a lost secret and plans
5702
6004
  // nothing; this is the same rule for a caller that reaches the step anyway.
@@ -5706,28 +6008,21 @@ shareDomains) {
5706
6008
  ]) {
5707
6009
  const lost = recorded[undeliveredKey];
5708
6010
  if (lost && !recorded[guardKey]) {
5709
- throw new Error(`platform:files: a secret (${lost}) was minted but never delivered; remove the recorded key ${undeliveredKey} to mint a new one`);
6011
+ throw new Error(`${label}: a secret (${lost}) was minted but never delivered; remove the recorded key ${undeliveredKey} to mint a new one`);
5710
6012
  }
5711
6013
  }
5712
6014
  const record = async (key, id) => {
5713
- context.recordScopeId(key, id);
5714
- await context.persist?.();
6015
+ scope.recordScopeId(key, id);
6016
+ await scope.persist();
5715
6017
  };
5716
- let projectUuid = recorded[PLATFORM_FILES_PROJECT_SCOPE_KEY];
5717
- if (!projectUuid) {
5718
- const found = await deps.coolify.findProjectByName(PLATFORM_FILES_PROJECT);
5719
- projectUuid = found
5720
- ? found.uuid
5721
- : (await deps.coolify.createProject(PLATFORM_FILES_PROJECT)).uuid;
5722
- await record(PLATFORM_FILES_PROJECT_SCOPE_KEY, projectUuid);
5723
- }
6018
+ const projectUuid = await input.project.ensure(deps.coolify, scope);
5724
6019
  const { environment } = await deps.coolify.findOrCreateEnvironment(projectUuid, 'production');
5725
6020
  // ---- database ----------------------------------------------------------
5726
6021
  let dbId = recorded[PLATFORM_FILES_DB_SCOPE_KEY];
5727
6022
  if (!dbId) {
5728
6023
  // Found by name first, so a crash between create and record never makes
5729
6024
  // a second one (`coolifyJobs` does the same).
5730
- const found = (await deps.coolify.listDatabases()).find((db) => db.name === PLATFORM_FILES_DB_NAME && db.environment_id === environment.id);
6025
+ const found = (await deps.coolify.listDatabases()).find((db) => db.name === target.names.filesDb && db.environment_id === environment.id);
5731
6026
  if (found) {
5732
6027
  dbId = found.uuid;
5733
6028
  }
@@ -5738,7 +6033,7 @@ shareDomains) {
5738
6033
  server_uuid: server.uuid,
5739
6034
  environment_name: environment.name,
5740
6035
  environment_uuid: environment.uuid,
5741
- name: PLATFORM_FILES_DB_NAME,
6036
+ name: target.names.filesDb,
5742
6037
  postgres_db: FILES_DB,
5743
6038
  postgres_user: FILES_DB,
5744
6039
  });
@@ -5748,7 +6043,7 @@ shareDomains) {
5748
6043
  }
5749
6044
  const db = await getDatabaseStarted(deps.coolify, dbId);
5750
6045
  if (!db.internal_db_url) {
5751
- throw new Error('coolify:app for the platform (files): the database has no internal_db_url yet -- retry this apply once Coolify reports one');
6046
+ throw new Error(`${own ? label : 'coolify:app for the platform (files)'}: the database has no internal_db_url yet -- retry this apply once Coolify reports one`);
5752
6047
  }
5753
6048
  // ---- the two apps --------------------------------------------------------
5754
6049
  const createFilesApp = (name) => async () => {
@@ -5766,7 +6061,7 @@ shareDomains) {
5766
6061
  name,
5767
6062
  });
5768
6063
  };
5769
- const migrateApp = await findOrCreatePlatformApp(deps.coolify, FILES_MIGRATE_APP_NAME, environment, createFilesApp(FILES_MIGRATE_APP_NAME));
6064
+ const migrateApp = await findOrCreatePlatformApp(deps.coolify, target.names.filesMigrate, environment, createFilesApp(target.names.filesMigrate));
5770
6065
  await record(PLATFORM_FILES_MIGRATE_SCOPE_KEY, migrateApp.appId);
5771
6066
  // Set on every run, like `platform:auth`'s `custom_internal_name`: a throw
5772
6067
  // right after a create must not leave the app on the wrong target.
@@ -5775,11 +6070,11 @@ shareDomains) {
5775
6070
  dockerfile_target_build: 'migration',
5776
6071
  git_branch: gitRef,
5777
6072
  });
5778
- const runnerApp = await findOrCreatePlatformApp(deps.coolify, FILES_RUNNER_APP_NAME, environment, createFilesApp(FILES_RUNNER_APP_NAME));
6073
+ const runnerApp = await findOrCreatePlatformApp(deps.coolify, target.names.filesRunner, environment, createFilesApp(target.names.filesRunner));
5779
6074
  await record(PLATFORM_FILES_RUNNER_SCOPE_KEY, runnerApp.appId);
5780
6075
  await deps.coolify.updateApplication(runnerApp.appId, {
5781
6076
  dockerfile_target_build: 'runner',
5782
- custom_internal_name: FILES_RUNNER_INTERNAL_NAME,
6077
+ custom_internal_name: target.internal.files,
5783
6078
  git_branch: gitRef,
5784
6079
  // #543: the health-check quartet, same reason Archon's doc comment
5785
6080
  // gives. The migrate app gets none -- it idles rather than serves
@@ -5791,13 +6086,14 @@ shareDomains) {
5791
6086
  // nobody gave a share host keeps no `domains`.
5792
6087
  ...filesShareDomainsPatch(recorded, shareDomains),
5793
6088
  });
5794
- context.recordScopeId(PLATFORM_FILES_SHARE_DOMAINS_SCOPE_KEY, normalHosts(shareDomains).join(','));
6089
+ scope.recordScopeId(PLATFORM_FILES_SHARE_DOMAINS_SCOPE_KEY, normalHosts(shareDomains).join(','));
5795
6090
  // ---- database env ----------------------------------------------------------
5796
6091
  // The migrate app sets `files_rt`'s password from DATABASE_URL on every
5797
6092
  // boot (files' `scripts/db-runtime-role.sh`), and the runner connects with
5798
6093
  // the same URL, so both apps carry one value, minted once and never
5799
6094
  // rotated: an existing value on either app is reused.
5800
6095
  const runtimeUrl = await ensureFilesRuntimeUrl(deps.coolify, [migrateApp.appId, runnerApp.appId], db.internal_db_url);
6096
+ runtime.onSecret?.(runtimeUrl);
5801
6097
  // The keys worker the migrate app needs to become healthy (files' README:
5802
6098
  // `DATABASE_URL_OWNER`, `DATABASE_URL`, `KEYS_WORKER_URL`, `KEYS_CREDENTIAL`).
5803
6099
  // `files-migrate` never runs `ensureStarted()`, so it needs neither
@@ -5812,11 +6108,13 @@ shareDomains) {
5812
6108
  ...migrateKeysEnvs,
5813
6109
  ]);
5814
6110
  // ---- the files ZITADEL project, in the platform's sign-in ----------------
5815
- const issuer = `https://${platform.auth.hostname}`;
6111
+ const { issuer } = input;
5816
6112
  const zitadel = deps.zitadelForInstance?.(issuer) ?? deps.zitadel;
5817
- const { org } = await zitadel.findOrCreateOrg(PLATFORM_AUTH_ORG_NAME);
5818
- const { project } = await zitadel.findOrCreateProject(org.id, FILES_ZITADEL_PROJECT_NAME);
5819
- await record(PLATFORM_FILES_ZITADEL_PROJECT_SCOPE_KEY, project.id);
6113
+ const zitadelOrgId = 'id' in input.zitadelOrg
6114
+ ? input.zitadelOrg.id
6115
+ : (await zitadel.findOrCreateOrg(input.zitadelOrg.name)).org.id;
6116
+ const { project } = await zitadel.findOrCreateProject(zitadelOrgId, input.zitadelProject.name);
6117
+ await record(input.zitadelProject.scopeKey, project.id);
5820
6118
  // #380: the runner's own required settings -- `KEYS_VERIFY_KEYS` and
5821
6119
  // `R2_BUCKET`/`R2_ENDPOINT` -- plus whatever optional ones the host
5822
6120
  // supplies. `files-migrate` needs none of these (see `migrateKeysEnvs`
@@ -5825,10 +6123,10 @@ shareDomains) {
5825
6123
  ...migrateKeysEnvs,
5826
6124
  { key: 'KEYS_VERIFY_KEYS', value: keys.verifyKeys },
5827
6125
  ];
5828
- if (deps.keys?.auditToken)
6126
+ if (auditToken)
5829
6127
  runnerKeysEnvs.push({
5830
6128
  key: 'KEYS_AUDIT_TOKEN',
5831
- value: deps.keys.auditToken,
6129
+ value: auditToken,
5832
6130
  });
5833
6131
  const runnerOptionalEnvs = [];
5834
6132
  if (deps.clamd?.host) {
@@ -5843,13 +6141,16 @@ shareDomains) {
5843
6141
  });
5844
6142
  }
5845
6143
  runnerOptionalEnvs.push(...filesPublicHostsEnvs(recorded, deps.publicHosts, shareDomains));
5846
- if (deps.jobsSigningSecrets)
6144
+ if (jobsSigningSecrets)
5847
6145
  runnerOptionalEnvs.push({
5848
6146
  key: 'JOBS_SIGNING_SECRETS',
5849
- value: deps.jobsSigningSecrets,
6147
+ value: jobsSigningSecrets,
5850
6148
  });
5851
6149
  // #501: the same person platform:archon names; Files has no sign-in page.
5852
- const ownerInviteUserId = recorded[PLATFORM_AUTH_OWNER_INVITE_SCOPE_KEY];
6150
+ // An own org's files host gets the platform's owner too, carried in by the planner: its own scope holds none.
6151
+ const ownerInviteUserId = own
6152
+ ? input.bootstrapOperator
6153
+ : recorded[PLATFORM_AUTH_OWNER_INVITE_SCOPE_KEY];
5853
6154
  if (ownerInviteUserId && ownerInviteUserId !== 'none') {
5854
6155
  runnerOptionalEnvs.push({ key: 'AUTHZ_BOOTSTRAP_OPERATOR', value: ownerInviteUserId });
5855
6156
  }
@@ -5857,7 +6158,7 @@ shareDomains) {
5857
6158
  { key: 'DATABASE_URL', value: runtimeUrl },
5858
6159
  // The management verifier's audience, which must be an HTTPS origin. The
5859
6160
  // host has no public address, so this is a name, not a route.
5860
- { key: 'APP_URL', value: `https://${platform.files.hostname}` },
6161
+ { key: 'APP_URL', value: `https://${input.hostname}` },
5861
6162
  { key: 'AUTH_ISSUER', value: issuer },
5862
6163
  { key: 'AUTH_PERSON_TOKEN_AUDIENCE', value: project.id },
5863
6164
  ...runnerKeysEnvs,
@@ -5872,28 +6173,32 @@ shareDomains) {
5872
6173
  // Base env first, so a secret's own write below cannot be the only one
5873
6174
  // that lands, and a re-run with a changed hostname updates `APP_URL`.
5874
6175
  await deps.coolify.bulkUpsertEnvs(runnerApp.appId, runnerEnvs);
6176
+ // Both apps now carry the company's own keyring: the delivery is confirmed.
6177
+ if (own && input.keysGuardId)
6178
+ await record(OWN_FILES_KEYS_SCOPE_KEY, input.keysGuardId);
5875
6179
  // ---- the introspection client, once --------------------------------------
5876
6180
  if (!recorded[PLATFORM_FILES_INTROSPECTION_SCOPE_KEY]) {
5877
6181
  // An app by this name here has no recorded secret (no undelivered id, or
5878
6182
  // the throw above would have fired): a crash between the create and the
5879
6183
  // record. Nothing was delivered, so it is deleted and made again.
5880
- const stale = (await zitadel.listApps(org.id, project.id)).apps.filter((app) => app.name === FILES_INTROSPECTION_APP_NAME && app.state !== 'APP_STATE_REMOVED');
6184
+ const stale = (await zitadel.listApps(zitadelOrgId, project.id)).apps.filter((app) => app.name === FILES_INTROSPECTION_APP_NAME && app.state !== 'APP_STATE_REMOVED');
5881
6185
  for (const app of stale) {
5882
6186
  try {
5883
- await zitadel.deleteApp(org.id, project.id, app.id);
6187
+ await zitadel.deleteApp(zitadelOrgId, project.id, app.id);
5884
6188
  }
5885
6189
  catch (error) {
5886
6190
  if (!isNotFoundError(error))
5887
6191
  throw error;
5888
6192
  }
5889
6193
  }
5890
- const client = await zitadel.createApiApp(org.id, project.id, {
6194
+ const client = await zitadel.createApiApp(zitadelOrgId, project.id, {
5891
6195
  name: FILES_INTROSPECTION_APP_NAME,
5892
6196
  });
6197
+ runtime.onSecret?.(client.clientSecret);
5893
6198
  // Recorded and persisted before the secret leaves this function: a failed
5894
6199
  // delivery or env write is then reported by the planner, not remade.
5895
6200
  await record(PLATFORM_FILES_INTROSPECTION_UNDELIVERED_SCOPE_KEY, client.appId);
5896
- await deliverWithRetry(deps.deliverSecret, FILES_INTROSPECTION_SECRET_REF, client.clientSecret, attempts, sleep, `the introspection client secret (ZITADEL app ${client.appId})`);
6201
+ await deliverWithRetry(deps.deliverSecret, target.refs.filesIntrospection, client.clientSecret, attempts, sleep, `the introspection client secret (ZITADEL app ${client.appId})`, label);
5897
6202
  await deps.coolify.bulkUpsertEnvs(runnerApp.appId, [
5898
6203
  {
5899
6204
  key: 'AUTH_PERSON_TOKEN_INTROSPECTION_CLIENT_ID',
@@ -5912,8 +6217,9 @@ shareDomains) {
5912
6217
  publicKeyEncoding: { type: 'spki', format: 'pem' },
5913
6218
  privateKeyEncoding: { type: 'pkcs8', format: 'pem' },
5914
6219
  });
6220
+ runtime.onSecret?.(privateKey);
5915
6221
  await record(PLATFORM_FILES_MANAGEMENT_UNDELIVERED_SCOPE_KEY, FILES_MANAGEMENT_KEY_ID);
5916
- await deliverWithRetry(deps.deliverSecret, FILES_MANAGEMENT_KEY_REF, privateKey, attempts, sleep, 'the files management key');
6222
+ await deliverWithRetry(deps.deliverSecret, target.refs.filesManagement, privateKey, attempts, sleep, 'the files management key', label);
5917
6223
  await deps.coolify.bulkUpsertEnvs(runnerApp.appId, [
5918
6224
  { key: 'MANAGEMENT_ISSUER', value: FILES_MANAGEMENT_ISSUER },
5919
6225
  {
@@ -5929,12 +6235,12 @@ shareDomains) {
5929
6235
  // container before the new migration has run.
5930
6236
  await stopIfRunning(deps.coolify, migrateApp.appId);
5931
6237
  const migrateDeployed = await deps.coolify.deploy(migrateApp.appId);
5932
- await waitForMigration(deps.coolify, migrateApp.appId, runtime, sleep);
6238
+ await waitForMigration(deps.coolify, migrateApp.appId, runtime, sleep, label, target.names.filesMigrate);
5933
6239
  // #399: files-migrate idles rather than exits once it has migrated, so it
5934
6240
  // reads healthy well before Coolify's own deployment does; stopping it on
5935
6241
  // that signal alone can find the container already gone and make Coolify
5936
6242
  // mark a deployment that actually migrated as `failed`.
5937
- await waitForMigrateDeployment(deps.coolify, migrateApp.appId, queuedDeploymentUuid(migrateDeployed) ?? 'untracked', 'platform:files', 'files-migrate', runtime.now ?? Date.now, runtime.migrateTimeoutMs ?? 900_000, runtime.migrateIntervalMs ?? 10_000, sleep);
6243
+ await waitForMigrateDeployment(deps.coolify, migrateApp.appId, queuedDeploymentUuid(migrateDeployed) ?? 'untracked', label, target.names.filesMigrate, runtime.now ?? Date.now, runtime.migrateTimeoutMs ?? 900_000, runtime.migrateIntervalMs ?? 10_000, sleep);
5938
6244
  await deps.coolify.raw('POST', `/applications/${migrateApp.appId}/stop`);
5939
6245
  // #252/#543: no public `domains`, tunnel or DNS record is wired to the
5940
6246
  // runner (its own doc comment: "Nothing public is wired"), but it does
@@ -5945,11 +6251,11 @@ shareDomains) {
5945
6251
  // container); `coolify.deployAndWait` reaching Coolify's own deployment
5946
6252
  // record first is still what rules out the old container staying healthy
5947
6253
  // through a failed deploy.
5948
- const internalUrl = `http://${FILES_RUNNER_INTERNAL_NAME}:${FILES_PORT}`;
5949
- await waitForNewDeployment(deps.coolify, runnerApp.appId, { kind: 'ready', url: `${internalUrl}${FILES_HEALTH_CHECK_PATH}` }, runtime, sleep, 'platform:files', `the runner stays at ${recorded[PLATFORM_FILES_APPLIED_GIT_REF_SCOPE_KEY] ?? 'no recorded ref'}`);
6254
+ 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'}`);
5950
6256
  await record(PLATFORM_FILES_INTERNAL_URL_SCOPE_KEY, internalUrl);
5951
6257
  // Last: a run that failed anywhere above is planned again.
5952
- context.recordScopeId(PLATFORM_FILES_APPLIED_HOSTNAME_SCOPE_KEY, platform.files.hostname);
6258
+ scope.recordScopeId(PLATFORM_FILES_APPLIED_HOSTNAME_SCOPE_KEY, input.hostname);
5953
6259
  await record(PLATFORM_FILES_APPLIED_GIT_REF_SCOPE_KEY, gitRef);
5954
6260
  return { resourceId: runnerApp.appId };
5955
6261
  }
@@ -6577,7 +6883,7 @@ function aiKeys(deps) {
6577
6883
  const keys = deps?.keys;
6578
6884
  return keys?.workerUrl && keys.credential && keys.verifyKeys ? keys : null;
6579
6885
  }
6580
- function filesKeys(deps) {
6886
+ export function filesKeys(deps) {
6581
6887
  const keys = deps?.keys;
6582
6888
  return keys?.workerUrl && keys.credential && keys.verifyKeys
6583
6889
  ? {
@@ -6600,7 +6906,7 @@ function filesR2(deps) {
6600
6906
  * naming the field instead of deploying a host that never turns healthy.
6601
6907
  * Never echoes the value.
6602
6908
  */
6603
- function filesDepsInvalid(deps) {
6909
+ export function filesDepsInvalid(deps) {
6604
6910
  const r2 = filesR2(deps);
6605
6911
  if (r2) {
6606
6912
  let ok = false;
@@ -6727,7 +7033,7 @@ async function ensureFilesRuntimeUrl(coolify, appIds, dbInternalUrl) {
6727
7033
  * stands in for the log line). Throws once `migrateTimeoutMs` has passed; the
6728
7034
  * runner is then never deployed against an unmigrated database.
6729
7035
  */
6730
- async function waitForMigration(coolify, appId, runtime, sleep) {
7036
+ async function waitForMigration(coolify, appId, runtime, sleep, label = 'platform:files', appName = 'files-migrate') {
6731
7037
  const now = runtime.now ?? Date.now;
6732
7038
  const timeout = runtime.migrateTimeoutMs ?? 900_000;
6733
7039
  const interval = runtime.migrateIntervalMs ?? 10_000;
@@ -6740,7 +7046,7 @@ async function waitForMigration(coolify, appId, runtime, sleep) {
6740
7046
  if (status !== 'running:healthy')
6741
7047
  left = true;
6742
7048
  if (now() >= deadline) {
6743
- throw new Error(`platform:files: files-migrate (${appId}) did not report healthy within ${Math.round(timeout / 1000)}s (last status: ${status ?? 'unknown'}); the files app was not deployed. Check its logs for "files-migrate: done" and retry this apply`);
7049
+ throw new Error(`${label}: ${appName} (${appId}) did not report healthy within ${Math.round(timeout / 1000)}s (last status: ${status ?? 'unknown'}); the files app was not deployed. Check its logs for "files-migrate: done" and retry this apply`);
6744
7050
  }
6745
7051
  await sleep(interval);
6746
7052
  }
@@ -6950,6 +7256,8 @@ async function provisionPlatformIntegrations(action, context, deps) {
6950
7256
  await coolify.updateApplication(runnerApp.appId, {
6951
7257
  dockerfile_target_build: INTEGRATIONS_RUNNER_APP.target,
6952
7258
  ports_exposes: INTEGRATIONS_RUNNER_PORT,
7259
+ // The runner's Dockerfile HEALTHCHECK (curl on /api/health) is the live check.
7260
+ ...IMAGE_HEALTHCHECK_SETTINGS,
6953
7261
  git_branch: gitRef,
6954
7262
  domains: coolifyDomains([hostname]),
6955
7263
  });
@@ -7964,7 +8272,7 @@ export class ProvidersPlatformCoolifyReader {
7964
8272
  * answers with the parent zone. The platform's zone is its apex exactly;
7965
8273
  * any other zone is never adopted or reported as the platform's.
7966
8274
  */
7967
- async function exactZone(client, apex) {
8275
+ export async function exactZone(client, apex) {
7968
8276
  const zone = await client.findZoneByApex(apex);
7969
8277
  return zone && zone.name === apex ? zone : undefined;
7970
8278
  }