@wtfalch/foundry 0.16.7 → 0.16.8

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.
@@ -178,7 +178,19 @@ export class FoundryPlatformStepExecutor {
178
178
  }
179
179
  else if (platform.files.enabled && !filesKeys(this.files)) {
180
180
  blocked.files =
181
- 'the files host is blocked, not planned: the host supplies no keys worker URL and credential (KEYS_WORKER_URL, KEYS_CREDENTIAL), so files-migrate would never become healthy';
181
+ 'the files host is blocked, not planned: the host supplies no keys worker URL, credential and verify keys (KEYS_WORKER_URL, KEYS_CREDENTIAL, KEYS_VERIFY_KEYS), so files would never become healthy';
182
+ }
183
+ else if (platform.files.enabled && !filesR2(this.files)) {
184
+ // #380: the platform files host's own gap -- it deployed and recorded
185
+ // applied, but never turned healthy, because nothing set R2_BUCKET or
186
+ // R2_ENDPOINT on the runner.
187
+ blocked.files =
188
+ 'the files host is blocked, not planned: the host supplies no R2 bucket and endpoint (R2_BUCKET, R2_ENDPOINT), so files would never become healthy';
189
+ }
190
+ else if (platform.files.enabled) {
191
+ const invalid = filesDepsInvalid(this.files);
192
+ if (invalid)
193
+ blocked.files = `the files host is blocked, not planned: ${invalid}`;
182
194
  }
183
195
  if (platform.ai.enabled && !this.ai) {
184
196
  blocked.ai =
@@ -254,6 +266,23 @@ export class FoundryPlatformStepExecutor {
254
266
  if (loginClientDrifted)
255
267
  result.auth = true;
256
268
  }
269
+ // #380: once the runner app is recorded, re-check its live env against
270
+ // every setting `ensureStarted()` requires -- the gap that left the
271
+ // platform's own files host `running:unhealthy` from the day it first
272
+ // deployed, with no live reader ever telling the planner so. Reported as
273
+ // drift so `planFiles` replans the step despite the hostname/gitRef
274
+ // match, and `provisionPlatformFiles` (which redeploys unconditionally
275
+ // on every run) fills in whatever is still missing.
276
+ if (platform.files.enabled && this.files && recordedIds[`coolify:app:${PLATFORM_FILES_KEY}`]) {
277
+ // A Coolify read error must not fail the whole plan: treat it as not drifted.
278
+ try {
279
+ if (await filesRunnerEnvDrifted(this.files.coolify, recordedIds))
280
+ result.files = true;
281
+ }
282
+ catch {
283
+ // Nothing logged: the error can carry Coolify's response.
284
+ }
285
+ }
257
286
  // #257 fix round 2: only once a platform database actually exists to
258
287
  // back up -- the same "nothing to report yet" gate `blockedSteps`'s own
259
288
  // `backupExecutorMissing` check follows above, so a platform that has
@@ -976,13 +1005,22 @@ async function provisionPlatformAuth(context, deps) {
976
1005
  { key: 'ZITADEL_DATABASE_POSTGRES_DSN', value: db.internal_db_url },
977
1006
  { key: 'ZITADEL_FIRSTINSTANCE_SKIP', value: 'true' },
978
1007
  { key: 'ZITADEL_DEFAULTINSTANCE_FEATURES_LOGINV2_REQUIRED', value: 'true' },
979
- { key: 'ZITADEL_DEFAULTINSTANCE_FEATURES_LOGINV2_BASEURI', value: `https://${hostname}/` },
980
- { key: 'ZITADEL_OIDC_DEFAULTLOGINURLV2', value: `https://${hostname}/login?authRequest=` },
1008
+ {
1009
+ key: 'ZITADEL_DEFAULTINSTANCE_FEATURES_LOGINV2_BASEURI',
1010
+ value: `https://${hostname}/`,
1011
+ },
1012
+ {
1013
+ key: 'ZITADEL_OIDC_DEFAULTLOGINURLV2',
1014
+ value: `https://${hostname}/login?authRequest=`,
1015
+ },
981
1016
  {
982
1017
  key: 'ZITADEL_OIDC_DEFAULTLOGOUTURLV2',
983
1018
  value: `https://${hostname}/logout?post_logout_redirect=`,
984
1019
  },
985
- { key: 'ZITADEL_SAML_DEFAULTLOGINURLV2', value: `https://${hostname}/login?samlRequest=` },
1020
+ {
1021
+ key: 'ZITADEL_SAML_DEFAULTLOGINURLV2',
1022
+ value: `https://${hostname}/login?samlRequest=`,
1023
+ },
986
1024
  { key: 'ZITADEL_LOGSTORE_ACCESS_STDOUT_ENABLED', value: 'true' },
987
1025
  ];
988
1026
  if (masterkey)
@@ -998,7 +1036,10 @@ async function provisionPlatformAuth(context, deps) {
998
1036
  key: 'ZITADEL_API_URL',
999
1037
  value: `http://${PLATFORM_AUTH_ZITADEL_INTERNAL_NAME}:${AUTH_ZITADEL_PORT}`,
1000
1038
  },
1001
- { key: 'CUSTOM_REQUEST_HEADERS', value: `Host:${hostname},X-Forwarded-Proto:https` },
1039
+ {
1040
+ key: 'CUSTOM_REQUEST_HEADERS',
1041
+ value: `Host:${hostname},X-Forwarded-Proto:https`,
1042
+ },
1002
1043
  { key: 'OTEL_NODE_RESOURCE_DETECTORS', value: 'none' },
1003
1044
  { key: 'API_CACHE_CONFIG', value: '{"defaultMinutes":2,"longMinutes":5}' },
1004
1045
  ];
@@ -1073,7 +1114,10 @@ async function provisionPlatformAuth(context, deps) {
1073
1114
  path: AUTH_BROKER_PATH_PATTERN,
1074
1115
  service: `http://${PLATFORM_AUTH_BROKER_INTERNAL_NAME}:${AUTH_BROKER_PORT}`,
1075
1116
  },
1076
- { hostname, service: `http://${PLATFORM_AUTH_LOGIN_INTERNAL_NAME}:${AUTH_LOGIN_PORT}` },
1117
+ {
1118
+ hostname,
1119
+ service: `http://${PLATFORM_AUTH_LOGIN_INTERNAL_NAME}:${AUTH_LOGIN_PORT}`,
1120
+ },
1077
1121
  { service: 'http_status:404' },
1078
1122
  ],
1079
1123
  });
@@ -1137,7 +1181,10 @@ async function provisionPlatformAuth(context, deps) {
1137
1181
  privateKey: systemKeys.privateKey,
1138
1182
  instanceName: PLATFORM_AUTH_ORG_NAME,
1139
1183
  firstOrgName: PLATFORM_AUTH_ORG_NAME,
1140
- owner: { userName: PLATFORM_AUTH_BEARER_USER_NAME, name: 'Foundry (instance owner)' },
1184
+ owner: {
1185
+ userName: PLATFORM_AUTH_BEARER_USER_NAME,
1186
+ name: 'Foundry (instance owner)',
1187
+ },
1141
1188
  }, { fetch: runtime.fetch, now: runtime.now ?? Date.now });
1142
1189
  }
1143
1190
  catch (error) {
@@ -1420,7 +1467,11 @@ export const PLATFORM_MAIL_ADMIN_KEY_DESCRIPTION = 'foundry:platform:admin';
1420
1467
  * `stalwartForInstance` fake.
1421
1468
  */
1422
1469
  export function defaultStalwartForInstance(input) {
1423
- const instance = openInstance({ url: input.url, secret: input.password, user: input.user });
1470
+ const instance = openInstance({
1471
+ url: input.url,
1472
+ secret: input.password,
1473
+ user: input.user,
1474
+ });
1424
1475
  return {
1425
1476
  async applySettings({ mailHost, apex, oidc }) {
1426
1477
  await applyObjects(instance.client, platformMailSettingsObjects({ apex, mailHost, oidc }), () => { });
@@ -1525,7 +1576,7 @@ async function provisionPlatformMail(context, deps) {
1525
1576
  // app is not.
1526
1577
  const server = await resolveMailServer(deps.coolify, recorded[PLATFORM_MAIL_SERVER_SCOPE_KEY]);
1527
1578
  await record(PLATFORM_MAIL_SERVER_SCOPE_KEY, server.uuid);
1528
- const serverIpBlocked = mailServerIpBlocked(server.ip);
1579
+ const serverIpBlocked = mailServerIpBlocked(server.ip, platform.serverIpv4);
1529
1580
  if (serverIpBlocked)
1530
1581
  throw new Error(serverIpBlocked);
1531
1582
  const app = await findOrCreatePlatformApp(deps.coolify, PLATFORM_MAIL_APP_NAME, environment, async () => {
@@ -1600,7 +1651,10 @@ async function provisionPlatformMail(context, deps) {
1600
1651
  await record(PLATFORM_MAIL_TUNNEL_SCOPE_KEY, tunnel.id);
1601
1652
  await deps.cloudflare.putTunnelConfig(tunnel.id, {
1602
1653
  ingress: [
1603
- { hostname, service: `http://${PLATFORM_MAIL_INTERNAL_NAME}:${STALWART_HTTP_PORT}` },
1654
+ {
1655
+ hostname,
1656
+ service: `http://${PLATFORM_MAIL_INTERNAL_NAME}:${STALWART_HTTP_PORT}`,
1657
+ },
1604
1658
  { service: 'http_status:404' },
1605
1659
  ],
1606
1660
  });
@@ -1640,7 +1694,7 @@ async function provisionPlatformMail(context, deps) {
1640
1694
  });
1641
1695
  }
1642
1696
  // ---- the MX hostname: a direct, unproxied A record --------------------------
1643
- const serverIp = server.ip; // checked by mailServerIpBlocked above
1697
+ const serverIp = (platform.serverIpv4 ?? server.ip); // checked by mailServerIpBlocked above
1644
1698
  const [mxRecord] = await deps.cloudflare.findDnsRecord(zoneId, mailHost, 'A');
1645
1699
  if (!mxRecord) {
1646
1700
  await deps.cloudflare.createDnsRecord(zoneId, {
@@ -1774,13 +1828,19 @@ function mailImageTagBlocked(tag) {
1774
1828
  ? `the platform's mail server is blocked, not planned: ${reason} as the mail gitRef`
1775
1829
  : null;
1776
1830
  }
1777
- /** Why the server's address cannot be the MX A record's content, or `null`. */
1778
- function mailServerIpBlocked(ip) {
1831
+ /**
1832
+ * Why no address can be the MX A record's content, or `null`. The declared
1833
+ * `platform.serverIpv4` wins; else the Coolify server's own ip, when it is an
1834
+ * IPv4 (the localhost server reports `host.docker.internal`, which is not).
1835
+ */
1836
+ function mailServerIpBlocked(ip, declared) {
1837
+ if (declared)
1838
+ return null; // validatePlatform already refused a non-IPv4
1779
1839
  if (!ip) {
1780
- return "the platform's mail server is blocked, not planned: the Coolify server reports no ip, so mx.<apex> has no address to point at; set the server's IP in Coolify";
1840
+ return "the platform's mail server is blocked, not planned: the Coolify server reports no ip, so mx.<apex> has no address to point at; declare the server's public IPv4 as serverIpv4 in the platform config";
1781
1841
  }
1782
1842
  if (!isIPv4(ip)) {
1783
- return `the platform's mail server is blocked, not planned: the Coolify server's ip "${ip}" is not an IPv4 address, so it cannot be the mx.<apex> A record; set the server's public IPv4 in Coolify`;
1843
+ return `the platform's mail server is blocked, not planned: the Coolify server's ip "${ip}" is not an IPv4 address, so it cannot be the mx.<apex> A record; declare the server's public IPv4 as serverIpv4 in the platform config`;
1784
1844
  }
1785
1845
  return null;
1786
1846
  }
@@ -1815,7 +1875,7 @@ async function mailBlockedReason(platform, mail, recordedIds) {
1815
1875
  return tagBlocked;
1816
1876
  try {
1817
1877
  const server = await resolveMailServer(mail.coolify, recordedIds[PLATFORM_MAIL_SERVER_SCOPE_KEY]);
1818
- return mailServerIpBlocked(server.ip);
1878
+ return mailServerIpBlocked(server.ip, platform.serverIpv4);
1819
1879
  }
1820
1880
  catch {
1821
1881
  return "the platform's mail server is blocked, not planned: no usable Coolify server could be resolved for it";
@@ -2366,7 +2426,10 @@ async function provisionPlatformArchon(context, deps) {
2366
2426
  }
2367
2427
  await deps.cloudflare.putTunnelConfig(tunnel.id, {
2368
2428
  ingress: [
2369
- { hostname, service: `http://${PLATFORM_ARCHON_INTERNAL_NAME}:${ARCHON_PORT}` },
2429
+ {
2430
+ hostname,
2431
+ service: `http://${PLATFORM_ARCHON_INTERNAL_NAME}:${ARCHON_PORT}`,
2432
+ },
2370
2433
  { service: 'http_status:404' },
2371
2434
  ],
2372
2435
  });
@@ -2799,7 +2862,9 @@ async function archonBrokerRegistrationDrifted(coolify, recordedIds) {
2799
2862
  * vary between calls.
2800
2863
  */
2801
2864
  async function collectArchonServiceInputs(platform, deps, recorded) {
2802
- const block = (why) => ({ blocked: `Archon is blocked, not planned: ${why}` });
2865
+ const block = (why) => ({
2866
+ blocked: `Archon is blocked, not planned: ${why}`,
2867
+ });
2803
2868
  const envs = [];
2804
2869
  const heldByRef = new Map();
2805
2870
  const services = ['files', 'ai', 'integrations', 'jobs'].filter((s) => platform[s].enabled);
@@ -2808,7 +2873,11 @@ async function collectArchonServiceInputs(platform, deps, recorded) {
2808
2873
  }
2809
2874
  for (const service of services) {
2810
2875
  const needs = service === 'jobs'
2811
- ? { stepKey: null, scopeKeys: [], refs: [JOBS_ADMIN_TOKEN_REF] }
2876
+ ? {
2877
+ stepKey: null,
2878
+ scopeKeys: [],
2879
+ refs: [JOBS_ADMIN_TOKEN_REF],
2880
+ }
2812
2881
  : ARCHON_SERVICE_INPUTS[service];
2813
2882
  if (needs.stepKey && !recorded[`coolify:app:${needs.stepKey}`]) {
2814
2883
  return block(`the ${service} step (${needs.stepKey}) is not applied yet`);
@@ -2835,10 +2904,22 @@ async function collectArchonServiceInputs(platform, deps, recorded) {
2835
2904
  const held = (ref) => heldByRef.get(ref);
2836
2905
  for (const service of services) {
2837
2906
  if (service === 'files') {
2838
- envs.push({ key: 'FILES_URL', value: recorded[PLATFORM_FILES_INTERNAL_URL_SCOPE_KEY] }, { key: 'FILES_MANAGEMENT_KEY', value: held(ARCHON_SERVICE_INPUTS.files.refs[0]) });
2907
+ envs.push({
2908
+ key: 'FILES_URL',
2909
+ value: recorded[PLATFORM_FILES_INTERNAL_URL_SCOPE_KEY],
2910
+ }, {
2911
+ key: 'FILES_MANAGEMENT_KEY',
2912
+ value: held(ARCHON_SERVICE_INPUTS.files.refs[0]),
2913
+ });
2839
2914
  }
2840
2915
  else if (service === 'ai') {
2841
- envs.push({ key: 'AI_URL', value: recorded[PLATFORM_AI_INTERNAL_URL_SCOPE_KEY] }, { key: 'AI_MANAGEMENT_KEY', value: held(ARCHON_SERVICE_INPUTS.ai.refs[0]) }, {
2916
+ envs.push({
2917
+ key: 'AI_URL',
2918
+ value: recorded[PLATFORM_AI_INTERNAL_URL_SCOPE_KEY],
2919
+ }, {
2920
+ key: 'AI_MANAGEMENT_KEY',
2921
+ value: held(ARCHON_SERVICE_INPUTS.ai.refs[0]),
2922
+ }, {
2842
2923
  key: 'AI_ORGANISATION_ID',
2843
2924
  value: recorded[PLATFORM_AI_ORGANISATION_SCOPE_KEY],
2844
2925
  });
@@ -2848,7 +2929,13 @@ async function collectArchonServiceInputs(platform, deps, recorded) {
2848
2929
  envs.push({ key: 'INTEGRATIONS_URL', value: url }, {
2849
2930
  key: 'INTEGRATIONS_MANAGEMENT_PRIVATE_KEY',
2850
2931
  value: held(INTEGRATIONS_MANAGEMENT_KEY_REF),
2851
- }, { key: 'INTEGRATIONS_MANAGEMENT_ISSUER', value: INTEGRATIONS_MANAGEMENT_ISSUER }, { key: 'INTEGRATIONS_MANAGEMENT_KEY_ID', value: INTEGRATIONS_MANAGEMENT_KEY_ID }, { key: 'INTEGRATIONS_MANAGEMENT_AUDIENCE', value: url });
2932
+ }, {
2933
+ key: 'INTEGRATIONS_MANAGEMENT_ISSUER',
2934
+ value: INTEGRATIONS_MANAGEMENT_ISSUER,
2935
+ }, {
2936
+ key: 'INTEGRATIONS_MANAGEMENT_KEY_ID',
2937
+ value: INTEGRATIONS_MANAGEMENT_KEY_ID,
2938
+ }, { key: 'INTEGRATIONS_MANAGEMENT_AUDIENCE', value: url });
2852
2939
  }
2853
2940
  else {
2854
2941
  envs.push({ key: 'JOBS_URL', value: `https://${platform.jobs.hostname}` }, { key: 'JOBS_ADMIN_TOKEN', value: held(JOBS_ADMIN_TOKEN_REF) });
@@ -2964,7 +3051,10 @@ function parseAiKeyExistsBody(body) {
2964
3051
  typeof record.organisationId !== 'string') {
2965
3052
  throw new Error("ai's platform/keys route answered 200 with a body missing credentialId/organisationId");
2966
3053
  }
2967
- return { credentialId: record.credentialId, organisationId: record.organisationId };
3054
+ return {
3055
+ credentialId: record.credentialId,
3056
+ organisationId: record.organisationId,
3057
+ };
2968
3058
  }
2969
3059
  function parseAiKeyMintedBody(body) {
2970
3060
  const record = body;
@@ -3093,7 +3183,10 @@ export function createAiManagementKeyMinter(options) {
3093
3183
  });
3094
3184
  const response = await fetchImpl(`${internalUrl}${AI_PLATFORM_KEYS_PATH}`, {
3095
3185
  method: 'POST',
3096
- headers: { 'content-type': contentType, [AI_MANAGEMENT_HEADER]: token },
3186
+ headers: {
3187
+ 'content-type': contentType,
3188
+ [AI_MANAGEMENT_HEADER]: token,
3189
+ },
3097
3190
  body,
3098
3191
  // A hung ai must not hold the apply open.
3099
3192
  signal: AbortSignal.timeout(30_000),
@@ -3130,7 +3223,11 @@ const PLATFORM_AI_MIGRATE = {
3130
3223
  target: 'migration',
3131
3224
  internalName: 'platform-ai-migrate',
3132
3225
  };
3133
- const PLATFORM_AI_RUNNER = { name: 'ai', target: 'runner', internalName: 'platform-ai' };
3226
+ const PLATFORM_AI_RUNNER = {
3227
+ name: 'ai',
3228
+ target: 'runner',
3229
+ internalName: 'platform-ai',
3230
+ };
3134
3231
  const PLATFORM_AI_WORKER = {
3135
3232
  name: 'ai-worker',
3136
3233
  target: 'worker',
@@ -3156,10 +3253,7 @@ const PLATFORM_AI_RUNNER_DEFAULTS = [
3156
3253
  { key: 'AI_FORGE_BINDINGS', value: '[]' },
3157
3254
  { key: 'AI_WORKER_TIMEOUT_MS', value: '60000' },
3158
3255
  ];
3159
- const PLATFORM_AI_WORKER_DEFAULTS = [
3160
- ...PLATFORM_AI_RUNNER_DEFAULTS,
3161
- { key: 'AI_WORKER_BATCH', value: '10' },
3162
- ];
3256
+ const PLATFORM_AI_WORKER_DEFAULTS = [...PLATFORM_AI_RUNNER_DEFAULTS, { key: 'AI_WORKER_BATCH', value: '10' }];
3163
3257
  /**
3164
3258
  * #172: makes `coolify:app` keyed {@link PLATFORM_AI_KEY} true -- the ai host
3165
3259
  * (https://github.com/wtfalch/ai) on the platform's Coolify, then foundry's
@@ -3712,9 +3806,14 @@ async function ensureAiRuntimeUrl(coolify, appIds, ownerUrl) {
3712
3806
  const password = randomBytes(24).toString('base64url');
3713
3807
  return `postgres://${database}_rt:${password}@${host}${pathname}${search}`;
3714
3808
  }
3715
- /** The entries of `defaults` whose key the app does not have yet. */
3809
+ /**
3810
+ * The entries of `defaults` whose key the app does not have yet. A key held
3811
+ * with a blank value counts as absent (#380), matching `filesRunnerEnvDrifted`,
3812
+ * so one apply clears the drift instead of replanning every round (#377's loop
3813
+ * class); a blank is never a valid setting, so the AI defaults share this.
3814
+ */
3716
3815
  async function missingEnvs(coolify, appId, defaults) {
3717
- const present = new Set((await coolify.listEnvs(appId)).filter((e) => !e.is_preview).map((e) => e.key));
3816
+ const present = new Set((await coolify.listEnvs(appId)).filter((e) => !e.is_preview && e.value).map((e) => e.key));
3718
3817
  return defaults.filter((d) => !present.has(d.key));
3719
3818
  }
3720
3819
  const defaultSleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
@@ -3754,7 +3853,9 @@ async function resolvePlatformServer(coolify) {
3754
3853
  * the app was already running or starting; the caller deploys it when not.
3755
3854
  */
3756
3855
  async function findOrCreatePlatformApp(coolify, name, environment, create) {
3757
- const existing = await coolify.findApplicationByName(name, { environmentIds: [environment.id] });
3856
+ const existing = await coolify.findApplicationByName(name, {
3857
+ environmentIds: [environment.id],
3858
+ });
3758
3859
  if (existing) {
3759
3860
  const live = await coolify.getApplication(existing.uuid);
3760
3861
  return {
@@ -3880,7 +3981,10 @@ export function brokerDeployNeeded(missingInputs, brokerAppId, authAppsRaw, reco
3880
3981
  * wrote there. Paired with {@link brokerDeployNeeded}, which reads it back.
3881
3982
  */
3882
3983
  export function brokerDeployRecordValue(brokerAppId, authAppsRaw) {
3883
- const record = { brokerAppId, authAppsHash: hashAuthApps(authAppsRaw) };
3984
+ const record = {
3985
+ brokerAppId,
3986
+ authAppsHash: hashAuthApps(authAppsRaw),
3987
+ };
3884
3988
  return JSON.stringify(record);
3885
3989
  }
3886
3990
  /** Whether any desired env is missing from the app, or set to another value. */
@@ -3982,6 +4086,20 @@ const FILES_DB = 'files';
3982
4086
  const FILES_RUNTIME_ROLE = `${FILES_DB}_rt`;
3983
4087
  const FILES_ZITADEL_PROJECT_NAME = 'files';
3984
4088
  const FILES_INTROSPECTION_APP_NAME = 'files-introspection';
4089
+ /**
4090
+ * #380: written only when absent, never overwritten, the same
4091
+ * {@link PLATFORM_AI_RUNNER_DEFAULTS} convention -- an operator's own limits
4092
+ * survive every apply. Values are files' own `.env.example`. The runner
4093
+ * needs all three: files' `ensureStarted()`
4094
+ * (`apps/host/src/lib/authz/files-host.ts`) requires each with
4095
+ * `required(...)`, and the health check fails without them -- the same gap
4096
+ * #375 fixed for the AI host's `AI_WORKER_TIMEOUT_MS`.
4097
+ */
4098
+ const PLATFORM_FILES_RUNNER_DEFAULTS = [
4099
+ { key: 'FILES_MAX_FILE_BYTES', value: '26214400' },
4100
+ { key: 'FILES_UPLOAD_TTL_SECONDS', value: '300' },
4101
+ { key: 'FILES_SCRATCH_TTL_DAYS', value: '1' },
4102
+ ];
3985
4103
  const PLATFORM_FILES_PROJECT_SCOPE_KEY = 'coolify_project_platform_files';
3986
4104
  const PLATFORM_FILES_DB_SCOPE_KEY = 'coolify_platform_files_database';
3987
4105
  const PLATFORM_FILES_MIGRATE_SCOPE_KEY = 'coolify_platform_files_migrate_app';
@@ -4033,12 +4151,17 @@ const PLATFORM_FILES_RUNNER_SCOPE_KEY = 'coolify_platform_files_runner_app';
4033
4151
  * field for it. Not yet checked against a live Coolify.
4034
4152
  * - The runner is created without `domains`, like `platform:auth`'s apps.
4035
4153
  * Whether Coolify adds a generated domain anyway is not yet checked.
4036
- * - Foundry sets only the env it owns: the two database URLs, `APP_URL`,
4037
- * `AUTH_ISSUER`, `AUTH_PERSON_TOKEN_*`, `MANAGEMENT_*` and the two `KEYS_*`
4038
- * values the host supplies through {@link PlatformFilesDeps.keys}, which
4039
- * files-migrate needs to become healthy. The rest of files' `.env.example`
4040
- * (`R2_*`, `KEYS_VERIFY_KEYS`, `JOBS_*`, `AUTHZ_BOOTSTRAP_OPERATOR`, ...)
4041
- * is set by a person on the runner.
4154
+ * - Foundry sets the env it owns: the two database URLs, `APP_URL`,
4155
+ * `AUTH_ISSUER`, `AUTH_PERSON_TOKEN_*`, `MANAGEMENT_*`, the `KEYS_*`
4156
+ * values the host supplies through {@link PlatformFilesDeps.keys} (#380
4157
+ * added `KEYS_VERIFY_KEYS`, required for the runner to become healthy,
4158
+ * and the optional `KEYS_AUDIT_TOKEN`), `R2_BUCKET`/`R2_ENDPOINT` from
4159
+ * {@link PlatformFilesDeps.r2} (#380, also required), the optional
4160
+ * `FILES_CLAMD_HOST`/`FILES_CLAMD_PORT`, `FILES_PUBLIC_HOSTS` and
4161
+ * `JOBS_SIGNING_SECRETS` the host may supply (#380), and the three
4162
+ * {@link PLATFORM_FILES_RUNNER_DEFAULTS} (#380, written only when absent).
4163
+ * `AUTHZ_BOOTSTRAP_OPERATOR` and the rest of files' `.env.example` are
4164
+ * still set by a person on the runner.
4042
4165
  * - The files project lives in the platform's `foundry` ZITADEL org. An
4043
4166
  * org's Agora requests it as an audience scope from its own ZITADEL org;
4044
4167
  * whether that needs a project grant per org is not decided here.
@@ -4055,7 +4178,15 @@ async function provisionPlatformFiles(context, deps) {
4055
4178
  const attempts = runtime.deliverAttempts ?? 3;
4056
4179
  const keys = filesKeys(deps);
4057
4180
  if (!keys) {
4058
- throw new Error('platform:files needs the keys worker URL and credential in its files dependencies (KEYS_WORKER_URL, KEYS_CREDENTIAL); files-migrate never becomes healthy without them');
4181
+ 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');
4182
+ }
4183
+ // #380: R2_BUCKET/R2_ENDPOINT, the same "block the whole step" treatment
4184
+ // as a missing `keys` above -- the runner's `ensureStarted()` requires
4185
+ // both, and only this function's caller (`blockedSteps`, `planFiles`) ever
4186
+ // has anything useful to report while they are unset.
4187
+ const r2 = filesR2(deps);
4188
+ if (!r2) {
4189
+ 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');
4059
4190
  }
4060
4191
  // Never plan-and-throw: the planner reports a lost secret and plans
4061
4192
  // nothing; this is the same rule for a caller that reaches the step anyway.
@@ -4149,14 +4280,16 @@ async function provisionPlatformFiles(context, deps) {
4149
4280
  const runtimeUrl = await ensureFilesRuntimeUrl(deps.coolify, [migrateApp.appId, runnerApp.appId], db.internal_db_url);
4150
4281
  // The keys worker the migrate app needs to become healthy (files' README:
4151
4282
  // `DATABASE_URL_OWNER`, `DATABASE_URL`, `KEYS_WORKER_URL`, `KEYS_CREDENTIAL`).
4152
- const keysEnvs = [
4283
+ // `files-migrate` never runs `ensureStarted()`, so it needs neither
4284
+ // `KEYS_VERIFY_KEYS` nor the `files` runner's R2/optional settings below.
4285
+ const migrateKeysEnvs = [
4153
4286
  { key: 'KEYS_WORKER_URL', value: keys.workerUrl },
4154
4287
  { key: 'KEYS_CREDENTIAL', value: keys.credential },
4155
4288
  ];
4156
4289
  await deps.coolify.bulkUpsertEnvs(migrateApp.appId, [
4157
4290
  { key: 'DATABASE_URL_OWNER', value: db.internal_db_url },
4158
4291
  { key: 'DATABASE_URL', value: runtimeUrl },
4159
- ...keysEnvs,
4292
+ ...migrateKeysEnvs,
4160
4293
  ]);
4161
4294
  // ---- the files ZITADEL project, in the platform's sign-in ----------------
4162
4295
  const issuer = `https://${platform.auth.hostname}`;
@@ -4164,6 +4297,41 @@ async function provisionPlatformFiles(context, deps) {
4164
4297
  const { org } = await zitadel.findOrCreateOrg(PLATFORM_AUTH_ORG_NAME);
4165
4298
  const { project } = await zitadel.findOrCreateProject(org.id, FILES_ZITADEL_PROJECT_NAME);
4166
4299
  await record(PLATFORM_FILES_ZITADEL_PROJECT_SCOPE_KEY, project.id);
4300
+ // #380: the runner's own required settings -- `KEYS_VERIFY_KEYS` and
4301
+ // `R2_BUCKET`/`R2_ENDPOINT` -- plus whatever optional ones the host
4302
+ // supplies. `files-migrate` needs none of these (see `migrateKeysEnvs`
4303
+ // above).
4304
+ const runnerKeysEnvs = [
4305
+ ...migrateKeysEnvs,
4306
+ { key: 'KEYS_VERIFY_KEYS', value: keys.verifyKeys },
4307
+ ];
4308
+ if (deps.keys?.auditToken)
4309
+ runnerKeysEnvs.push({
4310
+ key: 'KEYS_AUDIT_TOKEN',
4311
+ value: deps.keys.auditToken,
4312
+ });
4313
+ const runnerOptionalEnvs = [];
4314
+ if (deps.clamd?.host) {
4315
+ runnerOptionalEnvs.push({
4316
+ key: 'FILES_CLAMD_HOST',
4317
+ value: deps.clamd.host,
4318
+ });
4319
+ if (deps.clamd.port)
4320
+ runnerOptionalEnvs.push({
4321
+ key: 'FILES_CLAMD_PORT',
4322
+ value: deps.clamd.port,
4323
+ });
4324
+ }
4325
+ if (deps.publicHosts)
4326
+ runnerOptionalEnvs.push({
4327
+ key: 'FILES_PUBLIC_HOSTS',
4328
+ value: deps.publicHosts,
4329
+ });
4330
+ if (deps.jobsSigningSecrets)
4331
+ runnerOptionalEnvs.push({
4332
+ key: 'JOBS_SIGNING_SECRETS',
4333
+ value: deps.jobsSigningSecrets,
4334
+ });
4167
4335
  const runnerEnvs = [
4168
4336
  { key: 'DATABASE_URL', value: runtimeUrl },
4169
4337
  // The management verifier's audience, which must be an HTTPS origin. The
@@ -4171,7 +4339,14 @@ async function provisionPlatformFiles(context, deps) {
4171
4339
  { key: 'APP_URL', value: `https://${platform.files.hostname}` },
4172
4340
  { key: 'AUTH_ISSUER', value: issuer },
4173
4341
  { key: 'AUTH_PERSON_TOKEN_AUDIENCE', value: project.id },
4174
- ...keysEnvs,
4342
+ ...runnerKeysEnvs,
4343
+ { key: 'R2_BUCKET', value: r2.bucket },
4344
+ { key: 'R2_ENDPOINT', value: r2.endpoint },
4345
+ ...runnerOptionalEnvs,
4346
+ // #380: written only when absent, never overwriting an operator's own
4347
+ // limit (`missingEnvs`'s own "written only when absent" contract, the
4348
+ // same `provisionPlatformAi` uses for `PLATFORM_AI_RUNNER_DEFAULTS`).
4349
+ ...(await missingEnvs(deps.coolify, runnerApp.appId, PLATFORM_FILES_RUNNER_DEFAULTS)),
4175
4350
  ];
4176
4351
  // Base env first, so a secret's own write below cannot be the only one
4177
4352
  // that lands, and a re-run with a changed hostname updates `APP_URL`.
@@ -4199,8 +4374,14 @@ async function provisionPlatformFiles(context, deps) {
4199
4374
  await record(PLATFORM_FILES_INTROSPECTION_UNDELIVERED_SCOPE_KEY, client.appId);
4200
4375
  await deliverWithRetry(deps.deliverSecret, FILES_INTROSPECTION_SECRET_REF, client.clientSecret, attempts, sleep, `the introspection client secret (ZITADEL app ${client.appId})`);
4201
4376
  await deps.coolify.bulkUpsertEnvs(runnerApp.appId, [
4202
- { key: 'AUTH_PERSON_TOKEN_INTROSPECTION_CLIENT_ID', value: client.clientId },
4203
- { key: 'AUTH_PERSON_TOKEN_INTROSPECTION_CLIENT_SECRET', value: client.clientSecret },
4377
+ {
4378
+ key: 'AUTH_PERSON_TOKEN_INTROSPECTION_CLIENT_ID',
4379
+ value: client.clientId,
4380
+ },
4381
+ {
4382
+ key: 'AUTH_PERSON_TOKEN_INTROSPECTION_CLIENT_SECRET',
4383
+ value: client.clientSecret,
4384
+ },
4204
4385
  ]);
4205
4386
  await record(PLATFORM_FILES_INTROSPECTION_SCOPE_KEY, client.appId);
4206
4387
  }
@@ -4242,7 +4423,82 @@ function aiKeys(deps) {
4242
4423
  }
4243
4424
  function filesKeys(deps) {
4244
4425
  const keys = deps?.keys;
4245
- return keys?.workerUrl && keys.credential ? keys : null;
4426
+ return keys?.workerUrl && keys.credential && keys.verifyKeys
4427
+ ? {
4428
+ workerUrl: keys.workerUrl,
4429
+ credential: keys.credential,
4430
+ verifyKeys: keys.verifyKeys,
4431
+ }
4432
+ : null;
4433
+ }
4434
+ /** #380: `R2_BUCKET`/`R2_ENDPOINT`, both `required(...)` by the files runner's `ensureStarted()`. */
4435
+ function filesR2(deps) {
4436
+ const r2 = deps?.r2;
4437
+ return r2?.bucket && r2.endpoint ? r2 : null;
4438
+ }
4439
+ /**
4440
+ * #380: the files host's own checks on what the host supplies, mirrored from
4441
+ * `apps/host/src/lib/authz/files-host.ts` (`R2_ENDPOINT`: a bare https origin)
4442
+ * and `src/lib/keys/config.ts` (`KEYS_VERIFY_KEYS`: 1 or 2 comma-separated
4443
+ * base64url values, each exactly 32 bytes), so a bad value blocks the step
4444
+ * naming the field instead of deploying a host that never turns healthy.
4445
+ * Never echoes the value.
4446
+ */
4447
+ function filesDepsInvalid(deps) {
4448
+ const r2 = filesR2(deps);
4449
+ if (r2) {
4450
+ let ok = false;
4451
+ try {
4452
+ const u = new URL(r2.endpoint);
4453
+ ok =
4454
+ u.protocol === 'https:' &&
4455
+ !u.username &&
4456
+ !u.password &&
4457
+ u.pathname === '/' &&
4458
+ !u.search &&
4459
+ !u.hash;
4460
+ }
4461
+ catch {
4462
+ // Not a URL: ok stays false.
4463
+ }
4464
+ if (!ok)
4465
+ return 'R2_ENDPOINT (r2.endpoint) must be an HTTPS origin, with no path, query or fragment';
4466
+ }
4467
+ const verifyKeys = filesKeys(deps)?.verifyKeys;
4468
+ if (verifyKeys !== undefined) {
4469
+ const parts = verifyKeys.split(',');
4470
+ const valid = parts.length <= 2 &&
4471
+ parts.every((part) => /^[A-Za-z0-9_-]+$/.test(part) &&
4472
+ Buffer.from(part, 'base64url').length === 32 &&
4473
+ Buffer.from(part, 'base64url').toString('base64url') === part);
4474
+ if (!valid)
4475
+ return 'KEYS_VERIFY_KEYS (keys.verifyKeys) must be 1 or 2 comma-separated base64url values, each decoding to exactly 32 bytes';
4476
+ }
4477
+ return null;
4478
+ }
4479
+ /**
4480
+ * #380: whether the `files` runner's live env is missing a setting its own
4481
+ * `ensureStarted()` (`apps/host/src/lib/authz/files-host.ts`) requires --
4482
+ * every key `PLATFORM_FILES_RUNNER_DEFAULTS` writes, plus `R2_BUCKET`,
4483
+ * `R2_ENDPOINT` and `KEYS_VERIFY_KEYS`, none of which this step's
4484
+ * idempotency check (hostname/gitRef alone) could ever see. This is exactly
4485
+ * the gap that left the platform's own files host `running:unhealthy` from
4486
+ * the day it first deployed: the app was created and recorded applied, but
4487
+ * nothing ever re-checked whether its env could actually pass a health
4488
+ * check.
4489
+ */
4490
+ async function filesRunnerEnvDrifted(coolify, recordedIds) {
4491
+ const runnerAppId = recordedIds[`coolify:app:${PLATFORM_FILES_KEY}`];
4492
+ if (!runnerAppId)
4493
+ return false;
4494
+ const present = new Set((await coolify.listEnvs(runnerAppId)).filter((e) => !e.is_preview && e.value).map((e) => e.key));
4495
+ const required = [
4496
+ 'R2_BUCKET',
4497
+ 'R2_ENDPOINT',
4498
+ 'KEYS_VERIFY_KEYS',
4499
+ ...PLATFORM_FILES_RUNNER_DEFAULTS.map((d) => d.key),
4500
+ ];
4501
+ return required.some((key) => !present.has(key));
4246
4502
  }
4247
4503
  /** Stops the app when Coolify reports it running; a stop on an app that is not running can fail. */
4248
4504
  async function stopIfRunning(coolify, appId) {
@@ -4333,9 +4589,18 @@ const INTEGRATIONS_REPO = 'wtfalch/integrations';
4333
4589
  * The three Coolify apps `apps/host/README.md`'s "Deploy: migrate, web,
4334
4590
  * worker" names, each with the root Dockerfile target it builds.
4335
4591
  */
4336
- const INTEGRATIONS_MIGRATE_APP = { name: 'integrations-migrate', target: 'migration' };
4337
- const INTEGRATIONS_RUNNER_APP = { name: 'integrations', target: 'runner' };
4338
- const INTEGRATIONS_WORKER_APP = { name: 'integrations-worker', target: 'worker' };
4592
+ const INTEGRATIONS_MIGRATE_APP = {
4593
+ name: 'integrations-migrate',
4594
+ target: 'migration',
4595
+ };
4596
+ const INTEGRATIONS_RUNNER_APP = {
4597
+ name: 'integrations',
4598
+ target: 'runner',
4599
+ };
4600
+ const INTEGRATIONS_WORKER_APP = {
4601
+ name: 'integrations-worker',
4602
+ target: 'worker',
4603
+ };
4339
4604
  /** The runner's `EXPOSE 3000`; readiness is its own `HEALTHCHECK` on `/api/health`. */
4340
4605
  const INTEGRATIONS_RUNNER_PORT = '3000';
4341
4606
  const INTEGRATIONS_DB_NAME = 'integrations-db';
@@ -4535,13 +4800,19 @@ async function provisionPlatformIntegrations(action, context, deps) {
4535
4800
  const runnerEnvs = [
4536
4801
  ...sharedEnvs,
4537
4802
  ...(await envsIfAbsent(coolify, runnerApp.appId, [
4538
- { key: 'INTEGRATIONS_OPERATOR_TENANT_ID', value: INTEGRATIONS_OPERATOR_TENANT_ID },
4803
+ {
4804
+ key: 'INTEGRATIONS_OPERATOR_TENANT_ID',
4805
+ value: INTEGRATIONS_OPERATOR_TENANT_ID,
4806
+ },
4539
4807
  ])),
4540
4808
  ];
4541
4809
  const workerEnvs = [
4542
4810
  ...sharedEnvs,
4543
4811
  ...(await envsIfAbsent(coolify, workerApp.appId, [
4544
- { key: 'INTEGRATIONS_OPERATOR_TENANT_ID', value: INTEGRATIONS_OPERATOR_TENANT_ID },
4812
+ {
4813
+ key: 'INTEGRATIONS_OPERATOR_TENANT_ID',
4814
+ value: INTEGRATIONS_OPERATOR_TENANT_ID,
4815
+ },
4545
4816
  { key: 'INTEGRATIONS_WORKER_BATCH', value: INTEGRATIONS_WORKER_BATCH },
4546
4817
  ])),
4547
4818
  ];
@@ -5122,7 +5393,11 @@ export class ProvidersPlatformCloudflareReader {
5122
5393
  system: 'cloudflare',
5123
5394
  kind: 'zone',
5124
5395
  key: platform.apex,
5125
- config: { id: zone.id, status: zone.status, name_servers: zone.name_servers },
5396
+ config: {
5397
+ id: zone.id,
5398
+ status: zone.status,
5399
+ name_servers: zone.name_servers,
5400
+ },
5126
5401
  },
5127
5402
  ];
5128
5403
  }
@@ -31,6 +31,16 @@ export interface DeclaredPlatform {
31
31
  ownerOrgId: OrgId;
32
32
  /** The shared apex every service defaults its hostname under, e.g. `companyincluded.com`. */
33
33
  apex: string;
34
+ /**
35
+ * The Coolify server's public IPv4, for DNS A records (today `mx.<apex>`).
36
+ * Optional: Coolify's own localhost server reports `host.docker.internal`
37
+ * as its ip, which is not a public address and cannot be changed, so the
38
+ * operator declares the real one here, with the rest of the platform,
39
+ * instead of hand-setting it in Coolify. Platform-wide, not under `mail`,
40
+ * because it is a fact about the one shared server. Wins over the server's
41
+ * own reported ip when set.
42
+ */
43
+ serverIpv4?: string | null;
34
44
  auth: PlatformService;
35
45
  mail: PlatformService;
36
46
  sfu: PlatformService;
@@ -47,6 +57,7 @@ export declare function defaultPlatformHostname(service: PlatformServiceKey, ape
47
57
  * shape as `org.ts`'s `validateBuyerIdentity`.
48
58
  *
49
59
  * - `apex` is a valid domain (`org.ts`'s `validateDomain`).
60
+ * - `serverIpv4`, when declared, is a dotted-quad IPv4 address.
50
61
  * - Every service's `hostname` is the apex itself or a subdomain of it -- a
51
62
  * platform service does not live outside the platform's own apex.
52
63
  * - Every service's `gitRef` is non-empty once that service is `enabled` --