@wtfalch/foundry 0.36.1 → 0.38.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.
@@ -17,10 +17,11 @@ import { LIMITS, limited } from './own-limits.js';
17
17
  export { FILES_MANAGEMENT_KEY_REF, JOBS_ADMIN_TOKEN_REF, PLATFORM_AI_MANAGEMENT_KEY_REF, } from './held-key-refs.js';
18
18
  import { coolifyDomains, coolifyTunnelDomains, filesShareHostname } from './hostnames.js';
19
19
  import { MAIL_RELAY_BLOCKED, provisionMailRelay } from './mail-relay.js';
20
+ import { MAIL_APP_SCOPE_KEY, MAIL_DATABASE_NAME, MAIL_DATABASE_PASSWORD_ENV, MAIL_DATABASE_SCOPE_KEY, MAIL_STORE_POSTGRES, MAIL_STORE_SCOPE_KEY, mailStoreIsLegacy, } from './mail-store.js';
20
21
  import { OIKOS_INGRESS_BLOCKED, PLATFORM_OIKOS_INGRESS_KEY, oikosIngressApplied, oikosIngressZoneBlocked, provisionOikosIngress, } from './oikos-ingress.js';
21
22
  import { recordOwnDnsHostname } from './own-dns.js';
22
23
  import { platformNetwork, platformTarget, scopeFromPlatform, } from './own-services.js';
23
- import { AUTH_ZITADEL_RUN_OPTIONS, JOBS_DEPLOY_PENDING_SCOPE_KEY, JOBS_INTERNAL_URL_SCOPE_KEY, JOBS_SCHEDULER_KEY, PLATFORM_ACCESS_PROVIDER_APPLIED_AUTH_DOMAIN_SCOPE_KEY, PLATFORM_ACCESS_PROVIDER_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_ACCESS_PROVIDER_UNDELIVERED_SCOPE_KEY, PLATFORM_ACCESS_ZITADEL_APP_SCOPE_KEY, PLATFORM_ACCESS_ZITADEL_PROJECT_SCOPE_KEY, PLATFORM_AI_APPLIED_GITREF_SCOPE_KEY, PLATFORM_AI_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_AI_CONNECTOR_APP_SCOPE_KEY, PLATFORM_AI_DEPLOY_PENDING_SCOPE_KEY, PLATFORM_AI_INTERNAL_URL_SCOPE_KEY, PLATFORM_AI_KEY, PLATFORM_AI_MANAGEMENT_KEY_CONFLICT_SCOPE_KEY, PLATFORM_AI_MANAGEMENT_KEY_SCOPE_KEY, PLATFORM_AI_MANAGEMENT_KEY_UNDELIVERED_SCOPE_KEY, PLATFORM_AI_MIGRATE_BLOCKED_SCOPE_KEY, PLATFORM_AI_ORGANISATION_SCOPE_KEY, PLATFORM_AI_SIGNING_KEY_SET_SCOPE_KEY, PLATFORM_AI_SIGNING_KEY_UNDELIVERED_SCOPE_KEY, PLATFORM_AI_TUNNEL_SCOPE_KEY, PLATFORM_ARCHON_ACCESS_PIN_SCOPE_KEY, PLATFORM_ARCHON_APPLIED_AUTH_HOSTNAME_SCOPE_KEY, PLATFORM_ARCHON_APPLIED_AUTH_ORG_SCOPE_KEY, PLATFORM_ARCHON_APPLIED_BROKER_SCOPE_KEY, PLATFORM_ARCHON_APPLIED_CI_ORG_SCOPE_KEY, PLATFORM_ARCHON_APPLIED_FILES_SCOPE_KEY, PLATFORM_ARCHON_APPLIED_FIRST_OWNER_LINK_SCOPE_KEY, PLATFORM_ARCHON_APPLIED_GIT_REF_SCOPE_KEY, PLATFORM_ARCHON_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_ARCHON_APPLIED_ROLLING_UPDATE_SCOPE_KEY, PLATFORM_ARCHON_APP_KEY_ORG_SCOPE_KEY, PLATFORM_ARCHON_CONNECTOR_APP_SCOPE_KEY, PLATFORM_ARCHON_COOLIFY_READ_TOKEN_REF, PLATFORM_ARCHON_DEPLOYMENT_SCOPE_KEY, PLATFORM_ARCHON_ENVS_DEPLOYED_SCOPE_KEY, PLATFORM_ARCHON_GATE_CLOSED_SCOPE_KEY, PLATFORM_ARCHON_ID_TOKEN_USERINFO_APPLIED_SCOPE_KEY, PLATFORM_ARCHON_INTEGRATIONS_KEY_SYNCED_SCOPE_KEY, PLATFORM_ARCHON_KEY, PLATFORM_ARCHON_MASTER_CLOUDFLARE_TOKEN_REF, PLATFORM_ARCHON_MASTER_COOLIFY_TOKEN_REF, PLATFORM_ARCHON_MASTER_COOLIFY_URL_REF, PLATFORM_ARCHON_ORG_DOMAIN_BLOCKED_SCOPE_KEY, PLATFORM_ARCHON_ORG_DOMAIN_SCOPE_KEY, PLATFORM_ARCHON_ORG_POLICY_SCOPE_KEY, PLATFORM_ARCHON_ORG_REGISTRATION_CLOSED_SCOPE_KEY, PLATFORM_ARCHON_ORG_SCOPE_KEY, PLATFORM_ARCHON_OWNER_INVITE_ORG_SCOPE_KEY, PLATFORM_ARCHON_OWNER_INVITE_SCOPE_KEY, PLATFORM_ARCHON_RETIRED_CLIENT_SCOPE_KEY, PLATFORM_ARCHON_UPTIME_TOKEN_SYNCED_SCOPE_KEY, PLATFORM_ARCHON_ZITADEL_APP_SCOPE_KEY, PLATFORM_ARCHON_ZITADEL_PROJECT_SCOPE_KEY, PLATFORM_AUTH_ADMIN_UNDELIVERED_SCOPE_KEY, PLATFORM_AUTH_ADMIN_USER_SCOPE_KEY, PLATFORM_AUTH_BROKER_DEPLOYED_SCOPE_KEY, PLATFORM_AUTH_BROKER_SCOPE_KEY, PLATFORM_AUTH_CONNECTOR_APP_SCOPE_KEY, PLATFORM_AUTH_INSTANCE_SCOPE_KEY, PLATFORM_AUTH_INSTANCE_UNDELIVERED_SCOPE_KEY, PLATFORM_AUTH_KEY, PLATFORM_AUTH_LOGIN_CLIENT_UNDELIVERED_SCOPE_KEY, PLATFORM_AUTH_LOGIN_CLIENT_USER_SCOPE_KEY, PLATFORM_AUTH_OWNER_INVITE_ORG_SCOPE_KEY, PLATFORM_AUTH_OWNER_INVITE_SCOPE_KEY, PLATFORM_AUTH_REGISTRATION_CLOSED_SCOPE_KEY, PLATFORM_AUTH_RESERVED_ADMIN_USERNAME_SCOPE_KEY, PLATFORM_AUTH_SMTP_SCOPE_KEY, PLATFORM_AUTH_ZITADEL_APPLIED_IMAGE_TAG_SCOPE_KEY, PLATFORM_AUTH_ZITADEL_RUN_OPTIONS_SCOPE_KEY, PLATFORM_BACKUP_DATABASE_SCOPE_KEYS, PLATFORM_BACKUP_SERVICE_KEYS, PLATFORM_EDGE_APPLIED_GIT_REF_SCOPE_KEY, PLATFORM_EDGE_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_EDGE_CLOUDFLARE_TOKEN_REF, PLATFORM_EDGE_INTERNAL_URL_SCOPE_KEY, PLATFORM_EDGE_KEY, PLATFORM_EDGE_KEY_REF, PLATFORM_EDGE_PLATFORM_KEY_SET_SCOPE_KEY, PLATFORM_EDGE_PLATFORM_KEY_UNDELIVERED_SCOPE_KEY, PLATFORM_FILES_APPLIED_GIT_REF_SCOPE_KEY, PLATFORM_FILES_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_FILES_INTERNAL_URL_SCOPE_KEY, PLATFORM_FILES_INTROSPECTION_SCOPE_KEY, PLATFORM_FILES_INTROSPECTION_UNDELIVERED_SCOPE_KEY, PLATFORM_FILES_KEY, PLATFORM_FILES_MANAGEMENT_KEY_SCOPE_KEY, PLATFORM_FILES_MANAGEMENT_UNDELIVERED_SCOPE_KEY, PLATFORM_FILES_ZITADEL_PROJECT_SCOPE_KEY, PLATFORM_HC_API_KEY_REF, PLATFORM_HC_APPLIED_GIT_REF_SCOPE_KEY, PLATFORM_HC_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_HC_KEY, PLATFORM_HEARTBEATS_APPLIED_SCOPE_KEY, PLATFORM_HEARTBEATS_HOSTED_API_KEY_REF, PLATFORM_INTEGRATIONS_APPLIED_GITREF_SCOPE_KEY, PLATFORM_INTEGRATIONS_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_INTEGRATIONS_KEY, PLATFORM_INTEGRATIONS_KEY_ID_SCOPE_KEY, PLATFORM_INTEGRATIONS_KEY_ROTATION_PREVIOUS_SCOPE_KEY, PLATFORM_INTEGRATIONS_KEY_ROTATION_REQUESTED_SCOPE_KEY, PLATFORM_INTEGRATIONS_KEY_ROTATION_UNDELIVERED_SCOPE_KEY, PLATFORM_INTEGRATIONS_KEY_SET_SCOPE_KEY, PLATFORM_INTEGRATIONS_KEY_UNDELIVERED_SCOPE_KEY, PLATFORM_INTEGRATIONS_RUNNER_KEYRING_DEPLOYED_SCOPE_KEY, PLATFORM_MAIL_ADMIN_KEY_SCOPE_KEY, PLATFORM_MAIL_ADMIN_UNDELIVERED_SCOPE_KEY, PLATFORM_MAIL_APPLIED_GIT_REF_SCOPE_KEY, PLATFORM_MAIL_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_MAIL_CONNECTOR_APP_SCOPE_KEY, PLATFORM_MAIL_HOST_PORTS_DIRECT, PLATFORM_MAIL_HOST_PORTS_SCOPE_KEY, PLATFORM_MAIL_INTERNAL_URL_SCOPE_KEY, PLATFORM_MAIL_KEY, PLATFORM_MAIL_RELAY_KEY, PLATFORM_MAIL_ROUTE_SCOPE_KEY, PLATFORM_MAIL_ROUTE_WANTED, PLATFORM_MAIL_ZITADEL_PROJECT_SCOPE_KEY, PLATFORM_OWNER_ORG_DOMAIN_BLOCKED_SCOPE_KEY, PLATFORM_OWNER_ORG_DOMAIN_SCOPE_KEY, PLATFORM_OWNER_ORG_SCOPE_KEY, PLATFORM_SFU_APPLIED_SCOPE_KEY, PLATFORM_UPTIME_ACCESS_CLIENT_ID_REF, PLATFORM_UPTIME_ACCESS_CLIENT_SECRET_REF, PLATFORM_UPTIME_ACCESS_TOKEN_ID_SCOPE_KEY, PLATFORM_UPTIME_ACCESS_TOKEN_NAME, PLATFORM_UPTIME_ACCESS_UNDELIVERED_SCOPE_KEY, PLATFORM_UPTIME_HEARTBEAT_CHECK_NAME, PLATFORM_UPTIME_KEY, PLATFORM_UPTIME_REMOVED_SCOPE_KEY, PLATFORM_UPTIME_SCRIPT_NAME, PLATFORM_UPTIME_TOKEN_ID_SCOPE_KEY, PLATFORM_UPTIME_TOKEN_REF, PLATFORM_ZITADEL_ORG_SCOPE_KEY, archonFilesMarker, archonFirstOwnerLink, archonOrgDomain, archonOrgName, desiredHeartbeatCheckNames, heartbeatCheckUrlRef, parseBrokerDeployRecord, parseLoginClientRecord, platformBackupHeartbeatCheckName, platformMailHostPorts, platformMailMxHost, uptimeAccessTokenConflict, uptimeRecorded, uptimeVarsDigest, } from './platform-diff.js';
24
+ import { AUTH_ZITADEL_RUN_OPTIONS, JOBS_DEPLOY_PENDING_SCOPE_KEY, JOBS_INTERNAL_URL_SCOPE_KEY, JOBS_SCHEDULER_KEY, PLATFORM_ACCESS_PROVIDER_APPLIED_AUTH_DOMAIN_SCOPE_KEY, PLATFORM_ACCESS_PROVIDER_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_ACCESS_PROVIDER_UNDELIVERED_SCOPE_KEY, PLATFORM_ACCESS_ZITADEL_APP_SCOPE_KEY, PLATFORM_ACCESS_ZITADEL_PROJECT_SCOPE_KEY, PLATFORM_AI_APPLIED_GITREF_SCOPE_KEY, PLATFORM_AI_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_AI_CONNECTOR_APP_SCOPE_KEY, PLATFORM_AI_DEPLOY_PENDING_SCOPE_KEY, PLATFORM_AI_INTERNAL_URL_SCOPE_KEY, PLATFORM_AI_KEY, PLATFORM_AI_MANAGEMENT_KEY_CONFLICT_SCOPE_KEY, PLATFORM_AI_MANAGEMENT_KEY_SCOPE_KEY, PLATFORM_AI_MANAGEMENT_KEY_UNDELIVERED_SCOPE_KEY, PLATFORM_AI_MIGRATE_BLOCKED_SCOPE_KEY, PLATFORM_AI_ORGANISATION_SCOPE_KEY, PLATFORM_AI_SIGNING_KEY_SET_SCOPE_KEY, PLATFORM_AI_SIGNING_KEY_UNDELIVERED_SCOPE_KEY, PLATFORM_AI_TUNNEL_SCOPE_KEY, PLATFORM_ARCHON_ACCESS_PIN_SCOPE_KEY, PLATFORM_ARCHON_APPLIED_AUTH_HOSTNAME_SCOPE_KEY, PLATFORM_ARCHON_APPLIED_AUTH_ORG_SCOPE_KEY, PLATFORM_ARCHON_APPLIED_BROKER_SCOPE_KEY, PLATFORM_ARCHON_APPLIED_CI_ORG_SCOPE_KEY, PLATFORM_ARCHON_APPLIED_FILES_SCOPE_KEY, PLATFORM_ARCHON_APPLIED_FIRST_OWNER_LINK_SCOPE_KEY, PLATFORM_ARCHON_APPLIED_GIT_REF_SCOPE_KEY, PLATFORM_ARCHON_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_ARCHON_APPLIED_ROLLING_UPDATE_SCOPE_KEY, PLATFORM_ARCHON_APP_KEY_ORG_SCOPE_KEY, PLATFORM_ARCHON_CONNECTOR_APP_SCOPE_KEY, PLATFORM_ARCHON_COOLIFY_READ_TOKEN_REF, PLATFORM_ARCHON_DEPLOYMENT_SCOPE_KEY, PLATFORM_ARCHON_ENVS_DEPLOYED_SCOPE_KEY, PLATFORM_ARCHON_GATE_CLOSED_SCOPE_KEY, PLATFORM_ARCHON_ID_TOKEN_USERINFO_APPLIED_SCOPE_KEY, PLATFORM_ARCHON_INTEGRATIONS_KEY_SYNCED_SCOPE_KEY, PLATFORM_ARCHON_KEY, PLATFORM_ARCHON_MASTER_CLOUDFLARE_TOKEN_REF, PLATFORM_ARCHON_MASTER_COOLIFY_TOKEN_REF, PLATFORM_ARCHON_MASTER_COOLIFY_URL_REF, PLATFORM_ARCHON_ORG_DOMAIN_BLOCKED_SCOPE_KEY, PLATFORM_ARCHON_ORG_DOMAIN_SCOPE_KEY, PLATFORM_ARCHON_ORG_POLICY_SCOPE_KEY, PLATFORM_ARCHON_ORG_REGISTRATION_CLOSED_SCOPE_KEY, PLATFORM_ARCHON_ORG_SCOPE_KEY, PLATFORM_ARCHON_OWNER_INVITE_ORG_SCOPE_KEY, PLATFORM_ARCHON_OWNER_INVITE_SCOPE_KEY, PLATFORM_ARCHON_RETIRED_CLIENT_SCOPE_KEY, PLATFORM_ARCHON_UPTIME_TOKEN_SYNCED_SCOPE_KEY, PLATFORM_ARCHON_ZITADEL_APP_SCOPE_KEY, PLATFORM_ARCHON_ZITADEL_PROJECT_SCOPE_KEY, PLATFORM_AUTH_ADMIN_UNDELIVERED_SCOPE_KEY, PLATFORM_AUTH_ADMIN_USER_SCOPE_KEY, PLATFORM_AUTH_BROKER_DEPLOYED_SCOPE_KEY, PLATFORM_AUTH_BROKER_SCOPE_KEY, PLATFORM_AUTH_CONNECTOR_APP_SCOPE_KEY, PLATFORM_AUTH_INSTANCE_SCOPE_KEY, PLATFORM_AUTH_INSTANCE_UNDELIVERED_SCOPE_KEY, PLATFORM_AUTH_KEY, PLATFORM_AUTH_LOGIN_CLIENT_UNDELIVERED_SCOPE_KEY, PLATFORM_AUTH_LOGIN_CLIENT_USER_SCOPE_KEY, PLATFORM_AUTH_OWNER_INVITE_ORG_SCOPE_KEY, PLATFORM_AUTH_OWNER_INVITE_SCOPE_KEY, PLATFORM_AUTH_REGISTRATION_CLOSED_SCOPE_KEY, PLATFORM_AUTH_RESERVED_ADMIN_USERNAME_SCOPE_KEY, PLATFORM_AUTH_SMTP_SCOPE_KEY, PLATFORM_AUTH_ZITADEL_APPLIED_IMAGE_TAG_SCOPE_KEY, PLATFORM_AUTH_ZITADEL_RUN_OPTIONS_SCOPE_KEY, PLATFORM_BACKUP_DATABASE_SCOPE_KEYS, PLATFORM_BACKUP_SERVICE_KEYS, PLATFORM_EDGE_APPLIED_GIT_REF_SCOPE_KEY, PLATFORM_EDGE_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_EDGE_CLOUDFLARE_TOKEN_REF, PLATFORM_EDGE_INTERNAL_URL_SCOPE_KEY, PLATFORM_EDGE_KEY, PLATFORM_EDGE_KEY_REF, PLATFORM_EDGE_PLATFORM_KEY_SET_SCOPE_KEY, PLATFORM_EDGE_PLATFORM_KEY_UNDELIVERED_SCOPE_KEY, PLATFORM_FILES_APPLIED_GIT_REF_SCOPE_KEY, PLATFORM_FILES_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_FILES_INTERNAL_URL_SCOPE_KEY, PLATFORM_FILES_INTROSPECTION_SCOPE_KEY, PLATFORM_FILES_INTROSPECTION_UNDELIVERED_SCOPE_KEY, PLATFORM_FILES_KEY, PLATFORM_FILES_MANAGEMENT_KEY_SCOPE_KEY, PLATFORM_FILES_MANAGEMENT_UNDELIVERED_SCOPE_KEY, PLATFORM_FILES_ZITADEL_PROJECT_SCOPE_KEY, PLATFORM_HC_API_KEY_REF, PLATFORM_HC_APPLIED_GIT_REF_SCOPE_KEY, PLATFORM_HC_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_HC_KEY, PLATFORM_HEARTBEATS_APPLIED_SCOPE_KEY, PLATFORM_HEARTBEATS_HOSTED_API_KEY_REF, PLATFORM_INTEGRATIONS_APPLIED_GITREF_SCOPE_KEY, PLATFORM_INTEGRATIONS_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_INTEGRATIONS_KEY, PLATFORM_INTEGRATIONS_KEY_ID_SCOPE_KEY, PLATFORM_INTEGRATIONS_KEY_ROTATION_PREVIOUS_SCOPE_KEY, PLATFORM_INTEGRATIONS_KEY_ROTATION_REQUESTED_SCOPE_KEY, PLATFORM_INTEGRATIONS_KEY_ROTATION_UNDELIVERED_SCOPE_KEY, PLATFORM_INTEGRATIONS_KEY_SET_SCOPE_KEY, PLATFORM_INTEGRATIONS_KEY_UNDELIVERED_SCOPE_KEY, PLATFORM_INTEGRATIONS_RUNNER_KEYRING_DEPLOYED_SCOPE_KEY, PLATFORM_MAIL_ADMIN_KEY_SCOPE_KEY, PLATFORM_MAIL_ADMIN_UNDELIVERED_SCOPE_KEY, PLATFORM_MAIL_APPLIED_GIT_REF_SCOPE_KEY, PLATFORM_MAIL_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_MAIL_CONNECTOR_APP_SCOPE_KEY, PLATFORM_MAIL_HOST_PORTS_DIRECT, PLATFORM_MAIL_HOST_PORTS_SCOPE_KEY, PLATFORM_MAIL_INTERNAL_URL_SCOPE_KEY, PLATFORM_MAIL_KEY, PLATFORM_MAIL_RELAY_KEY, PLATFORM_MAIL_ROUTE_SCOPE_KEY, PLATFORM_MAIL_ROUTE_WANTED, PLATFORM_MAIL_ZITADEL_PROJECT_SCOPE_KEY, PLATFORM_OWNER_ORG_DOMAIN_BLOCKED_SCOPE_KEY, PLATFORM_OWNER_ORG_DOMAIN_SCOPE_KEY, PLATFORM_OWNER_ORG_SCOPE_KEY, PLATFORM_SFU_APPLIED_SCOPE_KEY, PLATFORM_UPTIME_ACCESS_CLIENT_ID_REF, PLATFORM_UPTIME_ACCESS_CLIENT_SECRET_REF, PLATFORM_UPTIME_ACCESS_TOKEN_ID_SCOPE_KEY, PLATFORM_UPTIME_ACCESS_TOKEN_NAME, PLATFORM_UPTIME_ACCESS_UNDELIVERED_SCOPE_KEY, PLATFORM_UPTIME_CHAT_ROOM_ID_SCOPE_KEY, PLATFORM_UPTIME_HEARTBEAT_CHECK_NAME, PLATFORM_UPTIME_KEY, PLATFORM_UPTIME_REMOVED_SCOPE_KEY, PLATFORM_UPTIME_SCRIPT_NAME, PLATFORM_UPTIME_TOKEN_ID_SCOPE_KEY, PLATFORM_UPTIME_TOKEN_REF, PLATFORM_ZITADEL_ORG_SCOPE_KEY, archonFilesMarker, archonFirstOwnerLink, archonOrgDomain, archonOrgName, desiredHeartbeatCheckNames, heartbeatCheckUrlRef, parseBrokerDeployRecord, parseLoginClientRecord, platformBackupHeartbeatCheckName, platformMailHostPorts, platformMailMxHost, uptimeAccessTokenConflict, uptimeEffectiveVars, uptimeRecorded, uptimeVarsDigest, } from './platform-diff.js';
24
25
  import { ensureSignInOrg, inviteOwnerWithLink, signInOrgDomain } from './sign-in-org.js';
25
26
  export { PLATFORM_ZITADEL_ORG_SCOPE_KEY };
26
27
  import { applyCallsSfuPlatformStep, callsSfuAppliedFingerprint, } from './platform-sfu.js';
@@ -53,6 +54,23 @@ export async function platformTunnels(deps, recorded, step) {
53
54
  }
54
55
  return deps.tunnels(edgeUrl);
55
56
  }
57
+ /**
58
+ * Request headers a client can set that ZITADEL reads to pick or name an
59
+ * instance (auth#241, auth#252). Cloudflare removes them from every inbound
60
+ * request on a sign-in hostname, because the tunnel routes ZITADEL's paths
61
+ * straight to ZITADEL and no app code sees them first.
62
+ */
63
+ export const SIGNIN_STRIPPED_REQUEST_HEADERS = [
64
+ 'X-Zitadel-Instance-Host',
65
+ 'X-Zitadel-Public-Host',
66
+ 'X-Zitadel-Forward-Host',
67
+ 'X-Forwarded-Host',
68
+ ];
69
+ /** The request header port for a platform step; undefined when edge is not recorded or the host supplied no factory. */
70
+ export async function platformRequestHeaders(deps, recorded) {
71
+ const edgeUrl = recorded[PLATFORM_EDGE_INTERNAL_URL_SCOPE_KEY];
72
+ return edgeUrl && deps.requestHeaders ? deps.requestHeaders(edgeUrl) : undefined;
73
+ }
56
74
  /** The Access port for a platform step; same address and same reason as {@link platformDns}. */
57
75
  async function platformAccess(deps, recorded, step) {
58
76
  const edgeUrl = recorded[PLATFORM_EDGE_INTERNAL_URL_SCOPE_KEY];
@@ -355,7 +373,7 @@ export class FoundryPlatformStepExecutor {
355
373
  if (!apiKey)
356
374
  blocked.heartbeatsApiKeyMissing = true;
357
375
  }
358
- await this.uptimeBlocked(platform, recordedIds, blocked);
376
+ await this.uptimeBlocked(platform, recordedIds, blocked, owner);
359
377
  // #604: only once edge and sign-in are applied (the planner reports
360
378
  // that case itself) and no provider is recorded yet.
361
379
  if (platform.edge.enabled &&
@@ -423,7 +441,7 @@ export class FoundryPlatformStepExecutor {
423
441
  * checks the pin and Archon first, so none of the reads here happens before
424
442
  * they hold.
425
443
  */
426
- async uptimeBlocked(platform, recordedIds, blocked) {
444
+ async uptimeBlocked(platform, recordedIds, blocked, owner = {}) {
427
445
  const deployed = Boolean(recordedIds[`cloudflare:worker:${PLATFORM_UPTIME_KEY}`]) &&
428
446
  !recordedIds[PLATFORM_UPTIME_REMOVED_SCOPE_KEY];
429
447
  if (!platform.uptime.enabled && !uptimeRecorded(recordedIds))
@@ -436,15 +454,41 @@ export class FoundryPlatformStepExecutor {
436
454
  }
437
455
  if (!platform.uptime.enabled)
438
456
  return;
439
- blocked.uptimeSecretsHeld = Object.keys(await heldUptimeValues(deps));
457
+ const heldValues = await heldUptimeValues(deps);
458
+ blocked.uptimeSecretsHeld = Object.keys(heldValues);
440
459
  const accessTokenId = recordedIds[PLATFORM_UPTIME_ACCESS_TOKEN_ID_SCOPE_KEY];
441
460
  const accessHeld = Boolean((await deps.heldKeys.resolve(PLATFORM_UPTIME_ACCESS_CLIENT_ID_REF)) &&
442
461
  (await deps.heldKeys.resolve(PLATFORM_UPTIME_ACCESS_CLIENT_SECRET_REF)));
443
462
  blocked.uptimeHeld = {
444
463
  token: Boolean(await deps.heldKeys.resolve(PLATFORM_UPTIME_TOKEN_REF)),
445
464
  access: accessHeld,
446
- vars: uptimeVarsDigest(deps.vars),
465
+ vars: uptimeVarsDigest(uptimeEffectiveVars(deps.vars, deps.chat && owner.chat && recordedIds[PLATFORM_UPTIME_CHAT_ROOM_ID_SCOPE_KEY]
466
+ ? {
467
+ homeserverUrl: `https://${owner.chat.agoraHost}`,
468
+ roomId: recordedIds[PLATFORM_UPTIME_CHAT_ROOM_ID_SCOPE_KEY],
469
+ }
470
+ : null)),
447
471
  };
472
+ if (deps.chat && !heldValues.CHAT_ACCESS_TOKEN) {
473
+ if (!owner.chat) {
474
+ blocked.uptimeChat = {
475
+ pending: false,
476
+ blocked: "the uptime Worker's chat alerts are blocked, not planned: the owner company's chat is not applied yet",
477
+ };
478
+ }
479
+ else if (owner.chat.subjects.length === 0) {
480
+ blocked.uptimeChat = {
481
+ pending: false,
482
+ blocked: "the uptime Worker's chat alerts are blocked, not planned: the owner company has no owner with a recorded sign-in user to join the alerts room",
483
+ };
484
+ }
485
+ else {
486
+ blocked.uptimeChat = { pending: true };
487
+ }
488
+ }
489
+ else if (deps.chat) {
490
+ blocked.uptimeChat = { pending: false };
491
+ }
448
492
  const edgeUrl = recordedIds[PLATFORM_EDGE_INTERNAL_URL_SCOPE_KEY];
449
493
  // A token of the name with no id recorded cannot be minted over, and
450
494
  // foundry does not remove what it did not make. With an id recorded the
@@ -650,7 +694,7 @@ export class FoundryPlatformStepExecutor {
650
694
  if (!this.mail) {
651
695
  throw new Error('platform:mail needs FoundryPlatformStepExecutor to be constructed with its mail dependencies (#169)');
652
696
  }
653
- return provisionPlatformMail(context, this.limitedOnPlatform(this.mail, LIMITS.platformMail));
697
+ return provisionPlatformMail(context, this.limitedOnPlatform(this.mail, LIMITS.platformMail), action.desired.switchMailStore === true);
654
698
  }
655
699
  if (action.system === 'coolify' &&
656
700
  action.kind === 'app' &&
@@ -1701,6 +1745,18 @@ export async function provisionAuthStack(scope, deps, target, input) {
1701
1745
  if (!zoneId) {
1702
1746
  throw new Error(`${label}: no Cloudflare zone for ${apex} to put ${hostname} in`);
1703
1747
  }
1748
+ // Before the DNS record, so the hostname is never published without the rule.
1749
+ const requestHeaders = await network.requestHeaders();
1750
+ if (requestHeaders) {
1751
+ await requestHeaders.ensureRequestHeaderStrip(zoneId, {
1752
+ description: `strip spoofable sign-in headers: ${hostname}`,
1753
+ expression: `(http.host eq "${hostname}")`,
1754
+ headers: SIGNIN_STRIPPED_REQUEST_HEADERS,
1755
+ });
1756
+ }
1757
+ else {
1758
+ console.error(`${label}: no request header port, so the sign-in hostname keeps X-Zitadel-* and X-Forwarded-Host from clients`);
1759
+ }
1704
1760
  const dnsTarget = `${tunnel.id}.cfargotunnel.com`;
1705
1761
  const dns = await network.dns();
1706
1762
  const [dnsRecord] = await dns.findDnsRecord(apex, hostname, 'CNAME');
@@ -2332,7 +2388,7 @@ const STALWART_READY_PATH = '/healthz/ready';
2332
2388
  /** Where the server the app was created on is recorded, so the MX record follows the app, not a later pick. */
2333
2389
  export const PLATFORM_MAIL_SERVER_SCOPE_KEY = 'coolify_platform_mail_server';
2334
2390
  const PLATFORM_MAIL_PROJECT_SCOPE_KEY = 'coolify_project_platform_mail';
2335
- const PLATFORM_MAIL_APP_SCOPE_KEY = 'coolify_platform_mail_app';
2391
+ const PLATFORM_MAIL_APP_SCOPE_KEY = MAIL_APP_SCOPE_KEY;
2336
2392
  const PLATFORM_MAIL_RELAY_APP_SCOPE_KEY = 'coolify_platform_mail_relay_app';
2337
2393
  const PLATFORM_MAIL_TUNNEL_SCOPE_KEY = 'cloudflare_platform_mail_tunnel';
2338
2394
  /**
@@ -2381,7 +2437,7 @@ export function defaultStalwartForInstance(input) {
2381
2437
  user: input.user,
2382
2438
  });
2383
2439
  return {
2384
- async applySettings({ mailHost, apex, oidc }) {
2440
+ async applySettings({ mailHost, apex, oidc, dataStore }) {
2385
2441
  // A fresh Stalwart (0.16) is in bootstrap mode and refuses every write
2386
2442
  // but the `Bootstrap` object, and loads that object only on its next
2387
2443
  // start, so write it and hand the restart to the caller. A server
@@ -2389,9 +2445,29 @@ export function defaultStalwartForInstance(input) {
2389
2445
  // carries the generated `admin@<apex>` secret: this step signs in with
2390
2446
  // the recovery admin and never needs it.
2391
2447
  if ((await instance.client.probe()) === 'bootstrap') {
2448
+ if (!dataStore.host ||
2449
+ !dataStore.database ||
2450
+ !dataStore.user ||
2451
+ !Number.isInteger(dataStore.port) ||
2452
+ dataStore.port < 1 ||
2453
+ dataStore.port > 65535) {
2454
+ throw new Error('platform:mail: the mail data store is not a usable Postgres address');
2455
+ }
2392
2456
  await instance.client.update('Bootstrap', 'singleton', {
2393
2457
  serverHostname: mailHost,
2394
2458
  defaultDomain: apex,
2459
+ // The password is read from the app's env, never written into Stalwart's settings.
2460
+ dataStore: {
2461
+ '@type': 'PostgreSql',
2462
+ host: dataStore.host,
2463
+ port: dataStore.port,
2464
+ database: dataStore.database,
2465
+ authUsername: dataStore.user,
2466
+ authSecret: {
2467
+ '@type': 'EnvironmentVariable',
2468
+ variableName: MAIL_DATABASE_PASSWORD_ENV,
2469
+ },
2470
+ },
2395
2471
  // The server sits behind a tunnel; no ACME challenge can reach it.
2396
2472
  requestTlsCertificate: false,
2397
2473
  });
@@ -2506,7 +2582,7 @@ export async function provisionMailServer(scope, deps, target, input) {
2506
2582
  const zone = input.zone ?? apex;
2507
2583
  const label = target.kind === 'org' ? `own step coolify:own-mail for ${target.id}` : 'platform:mail';
2508
2584
  const keyDescription = target.kind === 'org' ? `foundry:org:${target.id}:admin` : PLATFORM_MAIL_ADMIN_KEY_DESCRIPTION;
2509
- const recorded = scope.recordedIds();
2585
+ let recorded = scope.recordedIds();
2510
2586
  const runtime = deps.runtime ?? {};
2511
2587
  const sleep = runtime.sleep ?? defaultSleep;
2512
2588
  const record = async (key, id) => {
@@ -2523,6 +2599,15 @@ export async function provisionMailServer(scope, deps, target, input) {
2523
2599
  const imageTagBlocked = mailImageTagBlocked(imageTag);
2524
2600
  if (imageTagBlocked)
2525
2601
  throw new Error(imageTagBlocked);
2602
+ // A server whose data is still on its volume is never provisioned over: the plan's confirmed switch
2603
+ // deletes it first, and without that this step stops before any write.
2604
+ if (mailStoreIsLegacy(recorded)) {
2605
+ if (!input.switchStore) {
2606
+ throw new Error(`${label}: the mail server still stores its data on a volume; confirm the mail-store switch in the plan before applying`);
2607
+ }
2608
+ await switchMailStoreToPostgres(scope, deps.coolify, target, environment.id, label);
2609
+ recorded = scope.recordedIds();
2610
+ }
2526
2611
  // One server for both the app and the MX record: recorded the first time,
2527
2612
  // read back after, so a later run never points `mx.<apex>` somewhere the
2528
2613
  // app is not.
@@ -2533,6 +2618,32 @@ export async function provisionMailServer(scope, deps, target, input) {
2533
2618
  const serverIpBlocked = mxRecord ? mailServerIpBlocked(server.ip, mxRecord.serverIpv4) : null;
2534
2619
  if (serverIpBlocked)
2535
2620
  throw new Error(serverIpBlocked);
2621
+ // ---- the data store: a Postgres, created and recorded before the app ----------
2622
+ // A crash after the create resumes with the recorded id and never makes a second database. A crash
2623
+ // before the app exists leaves only this empty database behind; the next run uses it.
2624
+ let mailDbId = recorded[MAIL_DATABASE_SCOPE_KEY];
2625
+ if (!mailDbId) {
2626
+ const found = (await deps.coolify.listDatabases()).find((d) => d.name === target.names.mailDb && d.environment_id === environment.id);
2627
+ if (found) {
2628
+ mailDbId = found.uuid;
2629
+ }
2630
+ else {
2631
+ const created = await deps.coolify.createPostgresDatabase({
2632
+ project_uuid: projectUuid,
2633
+ server_uuid: server.uuid,
2634
+ environment_name: environment.name,
2635
+ environment_uuid: environment.uuid,
2636
+ name: target.names.mailDb,
2637
+ postgres_db: MAIL_DATABASE_NAME,
2638
+ postgres_user: MAIL_DATABASE_NAME,
2639
+ });
2640
+ mailDbId = created.uuid;
2641
+ }
2642
+ await record(MAIL_DATABASE_SCOPE_KEY, mailDbId);
2643
+ }
2644
+ const mailDb = await getDatabaseStarted(deps.coolify, mailDbId);
2645
+ const dataStore = mailDataStore(mailDb.internal_db_url, label);
2646
+ runtime.onSecret?.(dataStore.password);
2536
2647
  const app = await findOrCreatePlatformApp(deps.coolify, target.names.mailApp, environment, async () => {
2537
2648
  return deps.coolify.createDockerImageApplication({
2538
2649
  project_uuid: projectUuid,
@@ -2592,6 +2703,7 @@ export async function provisionMailServer(scope, deps, target, input) {
2592
2703
  const recoveryGenerated = await ensureGeneratedEnv(deps.coolify, appId, 'STALWART_RECOVERY_ADMIN', () => `${STALWART_RECOVERY_USER}:${randomBytes(18).toString('base64')}`);
2593
2704
  const envs = [
2594
2705
  { key: 'STALWART_PUBLIC_URL', value: `https://${hostname}` },
2706
+ { key: MAIL_DATABASE_PASSWORD_ENV, value: dataStore.password },
2595
2707
  ];
2596
2708
  if (recoveryGenerated)
2597
2709
  envs.push({ key: 'STALWART_RECOVERY_ADMIN', value: recoveryGenerated });
@@ -2781,13 +2893,15 @@ export async function provisionMailServer(scope, deps, target, input) {
2781
2893
  mailHost: hostname,
2782
2894
  apex,
2783
2895
  oidc: { issuerUrl: issuer, audience: project.id },
2896
+ dataStore: dataStore.address,
2784
2897
  },
2785
2898
  });
2899
+ await record(MAIL_STORE_SCOPE_KEY, MAIL_STORE_POSTGRES);
2786
2900
  await record(PLATFORM_MAIL_ZITADEL_PROJECT_SCOPE_KEY, project.id);
2787
2901
  await record(PLATFORM_MAIL_ROUTE_SCOPE_KEY, PLATFORM_MAIL_ROUTE_WANTED);
2788
2902
  }
2789
2903
  catch (error) {
2790
- throw redactedMailError(error, [password]);
2904
+ throw redactedMailError(error, [password, dataStore.password]);
2791
2905
  }
2792
2906
  }
2793
2907
  else if (!recordedProjectId) {
@@ -2822,8 +2936,10 @@ export async function provisionMailServer(scope, deps, target, input) {
2822
2936
  mailHost: hostname,
2823
2937
  apex,
2824
2938
  oidc: { issuerUrl: issuer, audience: project.id },
2939
+ dataStore: dataStore.address,
2825
2940
  },
2826
2941
  });
2942
+ await record(MAIL_STORE_SCOPE_KEY, MAIL_STORE_POSTGRES);
2827
2943
  await record(PLATFORM_MAIL_ROUTE_SCOPE_KEY, PLATFORM_MAIL_ROUTE_WANTED);
2828
2944
  // Recorded and persisted before the secret leaves this function: if
2829
2945
  // delivery fails for good, the next plan reports the lost key rather
@@ -2846,7 +2962,7 @@ export async function provisionMailServer(scope, deps, target, input) {
2846
2962
  await record(PLATFORM_MAIL_ADMIN_KEY_SCOPE_KEY, appId);
2847
2963
  }
2848
2964
  catch (error) {
2849
- throw redactedMailError(error, [password]);
2965
+ throw redactedMailError(error, [password, dataStore.password]);
2850
2966
  }
2851
2967
  }
2852
2968
  // Last, after every write the applied values depend on, so a run that
@@ -2855,6 +2971,79 @@ export async function provisionMailServer(scope, deps, target, input) {
2855
2971
  await record(PLATFORM_MAIL_APPLIED_GIT_REF_SCOPE_KEY, imageTag);
2856
2972
  return { resourceId: appId };
2857
2973
  }
2974
+ /**
2975
+ * Where Stalwart reaches its database, and the password it reads from its env. Parsed from Coolify's
2976
+ * `internal_db_url` inside a try: Node's parse error carries the whole input, credential included, so it
2977
+ * is replaced with a fixed message.
2978
+ */
2979
+ function mailDataStore(internalDbUrl, label) {
2980
+ if (!internalDbUrl) {
2981
+ throw new Error(`${label}: the mail database has no internal_db_url yet -- retry this apply once Coolify reports one`);
2982
+ }
2983
+ try {
2984
+ const url = new URL(internalDbUrl);
2985
+ const address = {
2986
+ host: url.hostname,
2987
+ port: url.port ? Number(url.port) : 5432,
2988
+ database: decodeURIComponent(url.pathname.replace(/^\//, '')),
2989
+ user: decodeURIComponent(url.username),
2990
+ };
2991
+ const password = decodeURIComponent(url.password);
2992
+ if (!address.host ||
2993
+ !address.database ||
2994
+ !address.user ||
2995
+ !password ||
2996
+ !Number.isInteger(address.port)) {
2997
+ throw new Error('incomplete');
2998
+ }
2999
+ return { address, password };
3000
+ }
3001
+ catch {
3002
+ throw new Error(`${label}: the mail database's internal_db_url could not be parsed`);
3003
+ }
3004
+ }
3005
+ /**
3006
+ * The recorded ids a recreated Stalwart makes stale: the app and what was deployed or applied to it, and the
3007
+ * admin key minted in it (the new server has none, so it is minted and delivered again). Everything else
3008
+ * stays: the project, the server, the tunnel and its connector, the DNS names, the internal address and the
3009
+ * published ports all describe things the new app reuses.
3010
+ */
3011
+ // A function, not a constant: `platform-diff.ts` and this file import each other, so its keys are not
3012
+ // all initialised yet when this module is evaluated.
3013
+ const mailStoreSwitchClearedKeys = () => [
3014
+ PLATFORM_MAIL_APP_SCOPE_KEY,
3015
+ PLATFORM_MAIL_DEPLOYED_SCOPE_KEY,
3016
+ PLATFORM_MAIL_DEPLOYED_HOSTNAME_SCOPE_KEY,
3017
+ PLATFORM_MAIL_DEPLOYED_GIT_REF_SCOPE_KEY,
3018
+ PLATFORM_MAIL_APPLIED_HOSTNAME_SCOPE_KEY,
3019
+ PLATFORM_MAIL_APPLIED_GIT_REF_SCOPE_KEY,
3020
+ PLATFORM_MAIL_ADMIN_KEY_SCOPE_KEY,
3021
+ PLATFORM_MAIL_ADMIN_UNDELIVERED_SCOPE_KEY,
3022
+ PLATFORM_MAIL_ROUTE_SCOPE_KEY,
3023
+ ];
3024
+ /**
3025
+ * Deletes the volume-backed Stalwart app (Coolify's delete removes its volumes) and clears the ids above.
3026
+ * The delete comes first: an app left behind after the ids are cleared would be found by name and reused,
3027
+ * still on its volume. A run that dies between the two finds no app by that name on the next run and only
3028
+ * clears. An app of that name whose uuid is not the recorded one is not deleted.
3029
+ */
3030
+ async function switchMailStoreToPostgres(scope, coolify, target, environmentId, label) {
3031
+ const recorded = scope.recordedIds();
3032
+ const live = await coolify.findApplicationByName(target.names.mailApp, {
3033
+ environmentIds: [environmentId],
3034
+ });
3035
+ if (live) {
3036
+ if (live.uuid !== recorded[PLATFORM_MAIL_APP_SCOPE_KEY]) {
3037
+ throw new Error(`${label}: the app named ${target.names.mailApp} is not the recorded mail server; not deleting it`);
3038
+ }
3039
+ await coolify.deleteApplication(live.uuid);
3040
+ }
3041
+ for (const key of mailStoreSwitchClearedKeys()) {
3042
+ if (recorded[key])
3043
+ scope.recordScopeId(key, '');
3044
+ }
3045
+ await scope.persist();
3046
+ }
2858
3047
  /** The platform's mail project: the shared `platform` project, its id recorded by the mail step under its own key. */
2859
3048
  const platformMailProject = {
2860
3049
  async ensure(coolify) {
@@ -2863,7 +3052,7 @@ const platformMailProject = {
2863
3052
  },
2864
3053
  };
2865
3054
  /** The platform's mail server: {@link provisionMailServer} with {@link platformTarget} and the platform's own inputs. */
2866
- async function provisionPlatformMail(context, deps) {
3055
+ async function provisionPlatformMail(context, deps, switchStore = false) {
2867
3056
  const { platform } = context;
2868
3057
  const scope = scopeFromPlatform(context);
2869
3058
  return provisionMailServer(scope, deps, platformTarget(), {
@@ -2871,6 +3060,7 @@ async function provisionPlatformMail(context, deps) {
2871
3060
  apex: platform.apex,
2872
3061
  mailHost: platformMailMxHost(platform.apex),
2873
3062
  imageTag: platform.mail.gitRef,
3063
+ switchStore,
2874
3064
  hostPorts: platformMailHostPorts(platform),
2875
3065
  issuerUrl: `https://${platform.auth.hostname}`,
2876
3066
  mxRecord: { serverIpv4: platform.serverIpv4 },
@@ -8148,11 +8338,12 @@ const PLATFORM_BACKUP_DEFAULT_FREQUENCY = 'daily';
8148
8338
  * The database a service's schedule must name. A schedule that names none
8149
8339
  * dumps the Coolify resource's first database; Archon's is named here anyway,
8150
8340
  * so a resource whose first database is not {@link ARCHON_POSTGRES_DB} is still
8151
- * dumped right. Every other service runs on its resource's first database and
8152
- * needs no entry.
8341
+ * dumped right. Mail names its database the same way. Every other service runs on its
8342
+ * resource's first database and needs no entry.
8153
8343
  */
8154
8344
  const PLATFORM_BACKUP_DATABASE_NAMES = {
8155
8345
  archon: ARCHON_POSTGRES_DB,
8346
+ mail: MAIL_DATABASE_NAME,
8156
8347
  };
8157
8348
  /**
8158
8349
  * The one schedule both the reader and {@link provisionPlatformBackup} treat
@@ -1,5 +1,6 @@
1
1
  import type { AccessPort } from './edge-access.js';
2
2
  import type { PlatformWorkersPort } from './edge-workers.js';
3
+ import type { CoolifyPort } from './executor.js';
3
4
  import type { PlatformStepExecutionContext } from './plan-apply.js';
4
5
  /** The optional plain text variables the Worker reads, supplied by the host (not secrets). */
5
6
  export declare const UPTIME_OPTIONAL_VARS: readonly ["CHAT_HOMESERVER_URL", "CHAT_ROOM_ID", "ALERT_EMAIL_FROM", "ALERT_EMAIL_TO"];
@@ -9,6 +10,19 @@ export type PlatformWorkersFactory = (edgeUrl: string) => PlatformWorkersPort |
9
10
  export type PlatformUptimeAccessPort = Pick<AccessPort, 'createServiceToken' | 'removeServiceToken' | 'findServiceTokenByName'>;
10
11
  /** Builds the Access port at edge's recorded address, the {@link PlatformWorkersFactory} way. */
11
12
  export type PlatformUptimeAccessFactory = (edgeUrl: string) => PlatformUptimeAccessPort | Promise<PlatformUptimeAccessPort>;
13
+ /**
14
+ * What the uptime step needs to make its chat bot through chat-service. The bearer for
15
+ * `/_chat/internal/*` is `CHAT_INTERNAL_TOKEN` on Company Included's chat-service app, read here and
16
+ * never held, logged or put in an error.
17
+ */
18
+ export interface PlatformUptimeChatDeps {
19
+ /** Reads that app's env (`OwnerChat.appId`). */
20
+ coolify: Pick<CoolifyPort, 'listEnvs'>;
21
+ /** The HTTP client; the global `fetch` by default. */
22
+ fetch?: typeof fetch;
23
+ /** Milliseconds one call may take (default 10 seconds). */
24
+ timeoutMs?: number;
25
+ }
12
26
  /**
13
27
  * Dependencies of the uptime Worker step. `deliverSecret` and `heldKeys` are
14
28
  * the host's: the minted token goes only to `deliverSecret`, and every secret
@@ -26,8 +40,13 @@ export interface PlatformUptimeDeps {
26
40
  heldKeys: {
27
41
  resolve(ref: string): Promise<string | null>;
28
42
  };
29
- /** Host config for the Worker's optional plain text variables; an absent one is left unset. */
43
+ /**
44
+ * Host config for the Worker's optional plain text variables; an absent one is left unset. A set
45
+ * `CHAT_HOMESERVER_URL` or `CHAT_ROOM_ID` overrides the one foundry made.
46
+ */
30
47
  vars?: Partial<Record<(typeof UPTIME_OPTIONAL_VARS)[number], string>>;
48
+ /** Makes the Worker's chat bot and its token. Absent: the chat credential is held by hand. */
49
+ chat?: PlatformUptimeChatDeps;
31
50
  }
32
51
  /**
33
52
  * What is held now: the optional secrets, and `UPTIME_HEARTBEAT_URL` (a ping
@@ -1,7 +1,7 @@
1
1
  import { randomBytes, randomUUID } from 'node:crypto';
2
2
  import { EdgeApiError } from '@wtfalch/edge';
3
3
  import { uptimeArtifactUrl } from '../platform.js';
4
- import { PLATFORM_EDGE_INTERNAL_URL_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_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_UPTIME_APPLIED_SECRETS_SCOPE_KEY, PLATFORM_UPTIME_APPLIED_SHA256_SCOPE_KEY, PLATFORM_UPTIME_APPLIED_TAG_SCOPE_KEY, PLATFORM_UPTIME_APPLIED_VARS_SCOPE_KEY, PLATFORM_UPTIME_HEARTBEAT_URL_REF, PLATFORM_UPTIME_KEY, PLATFORM_UPTIME_KV_TITLE, PLATFORM_UPTIME_OPTIONAL_SECRETS, PLATFORM_UPTIME_REMOVED_SCOPE_KEY, PLATFORM_UPTIME_SCRIPT_NAME, PLATFORM_UPTIME_TOKEN_ID_SCOPE_KEY, PLATFORM_UPTIME_TOKEN_REF, PLATFORM_UPTIME_TOKEN_UNDELIVERED_SCOPE_KEY, uptimeAccessTokenConflict, uptimeSecretNames, uptimeVarsDigest, } from './platform-diff.js';
4
+ import { PLATFORM_EDGE_INTERNAL_URL_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_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_UPTIME_APPLIED_SECRETS_SCOPE_KEY, PLATFORM_UPTIME_APPLIED_SHA256_SCOPE_KEY, PLATFORM_UPTIME_APPLIED_TAG_SCOPE_KEY, PLATFORM_UPTIME_APPLIED_VARS_SCOPE_KEY, PLATFORM_UPTIME_CHAT_ROOM_ID_SCOPE_KEY, PLATFORM_UPTIME_CHAT_TOKEN_REF, PLATFORM_UPTIME_CHAT_UNDELIVERED_SCOPE_KEY, PLATFORM_UPTIME_HEARTBEAT_URL_REF, PLATFORM_UPTIME_KEY, PLATFORM_UPTIME_KV_TITLE, PLATFORM_UPTIME_OPTIONAL_SECRETS, PLATFORM_UPTIME_REMOVED_SCOPE_KEY, PLATFORM_UPTIME_SCRIPT_NAME, PLATFORM_UPTIME_TOKEN_ID_SCOPE_KEY, PLATFORM_UPTIME_TOKEN_REF, PLATFORM_UPTIME_TOKEN_UNDELIVERED_SCOPE_KEY, uptimeAccessTokenConflict, uptimeEffectiveVars, uptimeSecretNames, uptimeVarsDigest, } from './platform-diff.js';
5
5
  /** The Worker's cron trigger, from Archon's `workers/uptime/manifest.json`. */
6
6
  const UPTIME_CRONS = ['* * * * *'];
7
7
  /** The date Archon's own Worker config used. */
@@ -15,6 +15,84 @@ export const UPTIME_OPTIONAL_VARS = [
15
15
  'ALERT_EMAIL_FROM',
16
16
  'ALERT_EMAIL_TO',
17
17
  ];
18
+ /** How long one call to read chat-service's token or to ask it for the bot may take. */
19
+ const CHAT_CALL_TIMEOUT_MS = 10_000;
20
+ /** The bot's localpart in chat-service (`@uptime-bot:<server>`). */
21
+ const CHAT_BOT_LOCALPART = 'uptime-bot';
22
+ /** Runs `work` and gives up after `ms`; the error names `what` only. */
23
+ async function withinMs(ms, what, work) {
24
+ let timer;
25
+ try {
26
+ return await Promise.race([
27
+ work,
28
+ new Promise((_, reject) => {
29
+ timer = setTimeout(() => reject(new Error(`platform:uptime: ${what} did not answer within ${ms / 1000}s`)), ms);
30
+ }),
31
+ ]);
32
+ }
33
+ finally {
34
+ clearTimeout(timer);
35
+ }
36
+ }
37
+ /**
38
+ * `PUT /_chat/internal/bots/uptime-bot` on the company's Agora host. Idempotent on the chat-service
39
+ * side: a repeat returns the room and no token unless `rotate` is set, so calling with `rotate` again
40
+ * revokes the previous token. Every message here is fixed text: the response body, the bearer and the
41
+ * token never reach an error.
42
+ */
43
+ async function callChatBot(chat, owner, rotate) {
44
+ const timeoutMs = chat.timeoutMs ?? CHAT_CALL_TIMEOUT_MS;
45
+ let envs;
46
+ try {
47
+ envs = await withinMs(timeoutMs, 'reading the chat-service app', chat.coolify.listEnvs(owner.appId));
48
+ }
49
+ catch {
50
+ // The cause can carry a response body.
51
+ throw new Error('platform:uptime: the chat-service app could not be read');
52
+ }
53
+ const bearer = envs.find((e) => e.key === 'CHAT_INTERNAL_TOKEN' && !e.is_preview)?.value;
54
+ if (typeof bearer !== 'string' || !/^\S{32,}$/.test(bearer)) {
55
+ throw new Error('platform:uptime: chat-service holds no usable CHAT_INTERNAL_TOKEN');
56
+ }
57
+ const controller = new AbortController();
58
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
59
+ try {
60
+ const response = await (chat.fetch ?? fetch)(`https://${owner.agoraHost}/_chat/internal/bots/${CHAT_BOT_LOCALPART}`, {
61
+ method: 'PUT',
62
+ headers: { authorization: `Bearer ${bearer}`, 'content-type': 'application/json' },
63
+ body: JSON.stringify({
64
+ displayName: 'Platform alerts',
65
+ room: { name: 'platform-alerts' },
66
+ members: [...owner.subjects],
67
+ ...(rotate ? { rotate: true } : {}),
68
+ }),
69
+ redirect: 'error',
70
+ signal: controller.signal,
71
+ });
72
+ if (response.status !== 200) {
73
+ throw new Error(`platform:uptime: chat-service refused the bot request (HTTP ${response.status})`);
74
+ }
75
+ const body = (await response.json());
76
+ const { userId, roomId, accessToken } = body ?? {};
77
+ if (typeof userId !== 'string' ||
78
+ !userId ||
79
+ typeof roomId !== 'string' ||
80
+ !roomId ||
81
+ (accessToken !== undefined && (typeof accessToken !== 'string' || !accessToken))) {
82
+ throw new Error('platform:uptime: chat-service answered the bot request with an unusable body');
83
+ }
84
+ return { userId, roomId, ...(accessToken ? { accessToken } : {}) };
85
+ }
86
+ catch (error) {
87
+ // Our own messages pass; anything else (a network error, a bad JSON body) is replaced.
88
+ if (error instanceof Error && error.message.startsWith('platform:uptime: '))
89
+ throw error;
90
+ throw new Error('platform:uptime: chat-service did not answer the bot request');
91
+ }
92
+ finally {
93
+ clearTimeout(timer);
94
+ }
95
+ }
18
96
  /**
19
97
  * What is held now: the optional secrets, and `UPTIME_HEARTBEAT_URL` (a ping
20
98
  * URL that works as a capability, so a secret too), name to value. Callers
@@ -108,11 +186,51 @@ export async function provisionPlatformUptime(context, deps) {
108
186
  }
109
187
  const hostname = platform.archon.hostname;
110
188
  const held = await heldUptimeValues(deps);
189
+ // The chat bot comes before the upload: its room is one of the Worker's variables. A call that fails
190
+ // stops the step here, and the next apply makes it again (the marker below says whether a token may
191
+ // already have been minted).
192
+ const ownerChat = context.owner?.chat ?? null;
193
+ let chatRoomId = recorded[PLATFORM_UPTIME_CHAT_ROOM_ID_SCOPE_KEY] || '';
194
+ if (deps.chat && ownerChat && ownerChat.subjects.length > 0) {
195
+ const lost = recorded[PLATFORM_UPTIME_CHAT_UNDELIVERED_SCOPE_KEY];
196
+ if (held.CHAT_ACCESS_TOKEN) {
197
+ if (lost) {
198
+ context.recordScopeId(PLATFORM_UPTIME_CHAT_UNDELIVERED_SCOPE_KEY, '');
199
+ await context.persist?.();
200
+ }
201
+ }
202
+ else {
203
+ // Recorded and persisted before the route is called, because the call is what mints the token.
204
+ if (!lost) {
205
+ context.recordScopeId(PLATFORM_UPTIME_CHAT_UNDELIVERED_SCOPE_KEY, randomUUID());
206
+ await context.persist?.();
207
+ }
208
+ // A marker, or a room already made, means the bot exists and its token is not held: rotate.
209
+ const rotate = Boolean(lost) || Boolean(chatRoomId);
210
+ let bot = await callChatBot(deps.chat, ownerChat, rotate);
211
+ if (!bot.accessToken && !rotate)
212
+ bot = await callChatBot(deps.chat, ownerChat, true);
213
+ const chatToken = bot.accessToken;
214
+ if (!chatToken) {
215
+ throw new Error('platform:uptime: chat-service returned no token for the bot, even when asked to rotate it');
216
+ }
217
+ chatRoomId = bot.roomId;
218
+ context.recordScopeId(PLATFORM_UPTIME_CHAT_ROOM_ID_SCOPE_KEY, chatRoomId);
219
+ await context.persist?.();
220
+ await attempt('holding the chat bot token', () => deps.deliverSecret(PLATFORM_UPTIME_CHAT_TOKEN_REF, chatToken));
221
+ context.recordScopeId(PLATFORM_UPTIME_CHAT_UNDELIVERED_SCOPE_KEY, '');
222
+ await context.persist?.();
223
+ held.CHAT_ACCESS_TOKEN = chatToken;
224
+ }
225
+ }
226
+ const workerVars = uptimeEffectiveVars(deps.vars, deps.chat && ownerChat && chatRoomId
227
+ ? { homeserverUrl: `https://${ownerChat.agoraHost}`, roomId: chatRoomId }
228
+ : null);
111
229
  const bindings = [
112
230
  { type: 'kv_namespace', name: 'UPTIME_KV', title: PLATFORM_UPTIME_KV_TITLE },
113
231
  { type: 'plain_text', name: 'ARCHON_BASE_URL', text: `https://${hostname}` },
114
232
  ...UPTIME_OPTIONAL_VARS.flatMap((name) => {
115
- const text = deps.vars?.[name];
233
+ const text = workerVars[name];
116
234
  return text ? [{ type: 'plain_text', name, text }] : [];
117
235
  }),
118
236
  ];
@@ -223,7 +341,7 @@ export async function provisionPlatformUptime(context, deps) {
223
341
  context.recordScopeId(PLATFORM_UPTIME_APPLIED_TAG_SCOPE_KEY, uptime.tag);
224
342
  context.recordScopeId(PLATFORM_UPTIME_APPLIED_SHA256_SCOPE_KEY, uptime.sha256);
225
343
  context.recordScopeId(PLATFORM_UPTIME_APPLIED_HOSTNAME_SCOPE_KEY, hostname);
226
- context.recordScopeId(PLATFORM_UPTIME_APPLIED_VARS_SCOPE_KEY, uptimeVarsDigest(deps.vars));
344
+ context.recordScopeId(PLATFORM_UPTIME_APPLIED_VARS_SCOPE_KEY, uptimeVarsDigest(workerVars));
227
345
  context.recordScopeId(PLATFORM_UPTIME_APPLIED_SECRETS_SCOPE_KEY, uptimeSecretNames(Object.keys(held)));
228
346
  return { resourceId: PLATFORM_UPTIME_SCRIPT_NAME };
229
347
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wtfalch/foundry",
3
- "version": "0.36.1",
3
+ "version": "0.38.0",
4
4
  "description": "Foundry's SDK: the declared org's types, the plan and apply contracts, and the state store's interface.",
5
5
  "repository": {
6
6
  "type": "git",