@wtfalch/foundry 0.39.1 → 0.39.2

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.
@@ -4431,18 +4431,27 @@ export class FoundryStepExecutor {
4431
4431
  ecKeyPem: secrets.value.masEcKeyPem,
4432
4432
  }));
4433
4433
  const introspection = await this.ensureChatIntrospection(org, zitadelOrgId, project.id, service.appId, context);
4434
- await this.ports.coolify.bulkUpsertEnvs(service.appId, chatServiceEnvs({
4435
- orgSlug: org.slug,
4436
- companyName: org.displayName,
4437
- issuer,
4438
- audience: project.id,
4439
- zitadelOrgId,
4440
- agoraHost,
4441
- masClientId: secrets.value.masClientId,
4442
- masClientSecret: secrets.value.masClientSecret,
4443
- introspectionClientId: introspection.clientId,
4444
- introspectionClientSecret: introspection.clientSecret,
4445
- }));
4434
+ // The internal token mounts chat-service's `/_chat/internal/*` routes, which the platform uptime
4435
+ // step calls whether or not calls are on. A usable one (also the one calls-chat shares) is kept;
4436
+ // otherwise one is made. Its value never reaches a log, a scope id or an error.
4437
+ const heldInternalToken = (await this.ports.coolify.listEnvs(service.appId)).find((e) => e.key === 'CHAT_INTERNAL_TOKEN' && !e.is_preview)?.value;
4438
+ const internalTokenUsable = typeof heldInternalToken === 'string' && /^\S{32,}$/.test(heldInternalToken);
4439
+ const internalToken = internalTokenUsable ? heldInternalToken : randomBytes(32).toString('hex');
4440
+ await this.ports.coolify.bulkUpsertEnvs(service.appId, [
4441
+ ...chatServiceEnvs({
4442
+ orgSlug: org.slug,
4443
+ companyName: org.displayName,
4444
+ issuer,
4445
+ audience: project.id,
4446
+ zitadelOrgId,
4447
+ agoraHost,
4448
+ masClientId: secrets.value.masClientId,
4449
+ masClientSecret: secrets.value.masClientSecret,
4450
+ introspectionClientId: introspection.clientId,
4451
+ introspectionClientSecret: introspection.clientSecret,
4452
+ }),
4453
+ { key: 'CHAT_INTERNAL_TOKEN', value: internalToken },
4454
+ ]);
4446
4455
  // Split the same way `coolifyApp`'s and `cmsSite`'s own databases are
4447
4456
  // (#239, #456): `DATABASE_URL_OWNER` is the superuser string,
4448
4457
  // `DATABASE_URL` is a minted `chat_rt` role -- `chat` is the literal
@@ -4459,7 +4468,8 @@ export class FoundryStepExecutor {
4459
4468
  for (const app of [mas, synapse, service]) {
4460
4469
  if (!app.existed ||
4461
4470
  secrets.changed ||
4462
- (app === service && (serviceDbPush.changed || introspection.changed)) ||
4471
+ (app === service &&
4472
+ (serviceDbPush.changed || introspection.changed || !internalTokenUsable)) ||
4463
4473
  !isRunningOrStarting((await this.ports.coolify.getApplication(app.appId)).status)) {
4464
4474
  // #543: `'ready'` for the service (it has a real route, probed
4465
4475
  // through Agora); `'deployment-only'` for Synapse/MAS, same as this
@@ -884,6 +884,8 @@ export declare const PLATFORM_HC_KEY = "platform:hc";
884
884
  * read back, never regenerated -- see `ensureGeneratedEnv`).
885
885
  */
886
886
  export declare const PLATFORM_HC_APPLIED_HOSTNAME_SCOPE_KEY = "hc_applied_hostname";
887
+ /** The id of the Cloudflare Access app in front of hc. Unrecorded (or empty) plans the hc step, so an already-applied hc gets the gate. */
888
+ export declare const PLATFORM_HC_ACCESS_SCOPE_KEY = "cloudflare_platform_hc_access_app";
887
889
  export declare const PLATFORM_HC_APPLIED_GIT_REF_SCOPE_KEY = "hc_applied_git_ref";
888
890
  /**
889
891
  * #540 round 2: the held-key ref a human puts hc's first project API key
@@ -987,6 +987,8 @@ export const PLATFORM_HC_KEY = 'platform:hc';
987
987
  * read back, never regenerated -- see `ensureGeneratedEnv`).
988
988
  */
989
989
  export const PLATFORM_HC_APPLIED_HOSTNAME_SCOPE_KEY = 'hc_applied_hostname';
990
+ /** The id of the Cloudflare Access app in front of hc. Unrecorded (or empty) plans the hc step, so an already-applied hc gets the gate. */
991
+ export const PLATFORM_HC_ACCESS_SCOPE_KEY = 'cloudflare_platform_hc_access_app';
990
992
  export const PLATFORM_HC_APPLIED_GIT_REF_SCOPE_KEY = 'hc_applied_git_ref';
991
993
  /**
992
994
  * #540 round 2: the held-key ref a human puts hc's first project API key
@@ -2595,7 +2597,8 @@ function planHc(platform, recordedIds, executorBlocked) {
2595
2597
  const { hostname, gitRef } = platform.hc;
2596
2598
  const applied = Boolean(recordedIds[resourceKey('coolify', 'app', PLATFORM_HC_KEY)]) &&
2597
2599
  recordedIds[PLATFORM_HC_APPLIED_HOSTNAME_SCOPE_KEY] === hostname &&
2598
- recordedIds[PLATFORM_HC_APPLIED_GIT_REF_SCOPE_KEY] === gitRef;
2600
+ recordedIds[PLATFORM_HC_APPLIED_GIT_REF_SCOPE_KEY] === gitRef &&
2601
+ Boolean(recordedIds[PLATFORM_HC_ACCESS_SCOPE_KEY]);
2599
2602
  if (applied)
2600
2603
  return { step: null, blocked: null };
2601
2604
  if (executorBlocked)
@@ -1501,6 +1501,12 @@ export interface PlatformAccessProviderDeps {
1501
1501
  deliverAttempts?: number;
1502
1502
  };
1503
1503
  }
1504
+ /**
1505
+ * Healthchecks' own status route (`hc/api/urls.py`, `views.status`): 200 `OK` once
1506
+ * the database answers. `/` redirects an anonymous visitor to the login page (302),
1507
+ * which Coolify's health check, wanting 200, counts as unhealthy.
1508
+ */
1509
+ export declare const HC_HEALTH_CHECK_PATH = "/api/v3/status/";
1504
1510
  /**
1505
1511
  * The only paths the AI host's public route serves; everything else answers 404 at the tunnel. A company entry's key
1506
1512
  * holds `ai.runs:create` and `ai.offerings:invoke` (`AI_ENTRY_PERMISSIONS`), and `@wtfalch/ai`'s client reaches them
@@ -1530,9 +1536,15 @@ export interface PlatformHcDeps {
1530
1536
  dns: PlatformDnsFactory;
1531
1537
  /** F4.tunnels: every tunnel call, through edge at its recorded address (see {@link PlatformTunnelsFactory}). */
1532
1538
  tunnels: PlatformTunnelsFactory;
1539
+ /** F4.access: every Access app call, through edge at its recorded address (see {@link PlatformAccessFactory}). */
1540
+ access: PlatformAccessFactory;
1541
+ /** The emails the Access app in front of the web UI lets through ({@link PlatformArchonDeps.accessEmails}). */
1542
+ accessEmails: readonly string[];
1533
1543
  /** Clock, sleep and fetch for the readiness wait; real ones by default. */
1534
1544
  runtime?: PlatformHcRuntime;
1535
1545
  }
1546
+ export declare const PLATFORM_HC_ACCESS_APP_NAME = "foundry:hc";
1547
+ export declare const hcPublicAccessScopeKey: (name: string) => string;
1536
1548
  export declare function filesKeys(deps: PlatformFilesDeps | undefined): {
1537
1549
  workerUrl: string;
1538
1550
  credential: string;
@@ -21,7 +21,7 @@ import { MAIL_APP_SCOPE_KEY, MAIL_DATABASE_NAME, MAIL_DATABASE_PASSWORD_ENV, MAI
21
21
  import { OIKOS_INGRESS_BLOCKED, PLATFORM_OIKOS_INGRESS_KEY, oikosIngressApplied, oikosIngressZoneBlocked, provisionOikosIngress, } from './oikos-ingress.js';
22
22
  import { recordOwnDnsHostname } from './own-dns.js';
23
23
  import { platformNetwork, platformTarget, scopeFromPlatform, } from './own-services.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
+ 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_ACCESS_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';
25
25
  import { ensureSignInOrg, inviteOwnerWithLink, signInOrgDomain } from './sign-in-org.js';
26
26
  import { SIGNIN_DB_ZITADEL_PENDING_SCOPE_KEY } from './signin-db-tls.js';
27
27
  import { SIGNIN_DB_APP_SCOPE_KEY, SIGNIN_DB_TLS_SCOPE_KEY, dockerfileAppCreator, ensureSigninDbCaFile, provisionSigninDb, waitHealthy, withCallTimeout, } from './signin-db.js';
@@ -347,6 +347,10 @@ export class FoundryPlatformStepExecutor {
347
347
  const hcTagBlocked = hcImageTagBlocked(platform.hc.gitRef);
348
348
  if (hcTagBlocked)
349
349
  blocked.hc = hcTagBlocked;
350
+ else if (this.hc.accessEmails.length === 0) {
351
+ blocked.hc =
352
+ 'the heartbeat server is blocked, not planned: the host names no email the Cloudflare Access app in front of it may let through';
353
+ }
350
354
  }
351
355
  // foundry#544: `'hosted'` needs none of `hc`'s own applied state -- only
352
356
  // the self-hosted provider's standing note depends on `hc` being
@@ -710,7 +714,8 @@ export class FoundryPlatformStepExecutor {
710
714
  if (!this.hc) {
711
715
  throw new Error('platform:hc needs FoundryPlatformStepExecutor to be constructed with its hc dependencies (#540)');
712
716
  }
713
- return provisionPlatformHc(context, this.limitedOnPlatform(this.hc, LIMITS.platformHc));
717
+ const ownerCompany = await this.declaredOrgStore.get(context.platform.ownerOrgId);
718
+ return provisionPlatformHc(context, this.limitedOnPlatform(this.hc, LIMITS.platformHc), ownerCompany);
714
719
  }
715
720
  if (action.system === 'zitadel' &&
716
721
  action.kind === 'archon-org' &&
@@ -7393,12 +7398,11 @@ const HC_INTERNAL_NAME = 'platform-hc';
7393
7398
  /** Healthchecks' own default uWSGI/gunicorn port inside the container. */
7394
7399
  const HC_PORT = '8000';
7395
7400
  /**
7396
- * Healthchecks ships no dedicated readiness route (confirmed against its
7397
- * `hc/front/urls.py`: the closest is `checks/<uuid>/status/`, which needs a
7398
- * check's own id) -- `/` (`views.index`) is the documented fallback, a
7399
- * public page for an anonymous visitor.
7401
+ * Healthchecks' own status route (`hc/api/urls.py`, `views.status`): 200 `OK` once
7402
+ * the database answers. `/` redirects an anonymous visitor to the login page (302),
7403
+ * which Coolify's health check, wanting 200, counts as unhealthy.
7400
7404
  */
7401
- const HC_HEALTH_CHECK_PATH = '/';
7405
+ export const HC_HEALTH_CHECK_PATH = '/api/v3/status/';
7402
7406
  const PLATFORM_HC_DB = 'hc';
7403
7407
  const PLATFORM_HC_DB_USER = 'hc';
7404
7408
  /** The tunnel that carries the AI host's public hostname, and the app that runs its connector. */
@@ -7450,13 +7454,17 @@ function hcImageTagBlocked(tag) {
7450
7454
  * (`platform-diff.ts`'s `planHc`) is what stops this running on every
7451
7455
  * apply, not a live comparison in here.
7452
7456
  */
7453
- async function provisionPlatformHc(context, deps) {
7457
+ async function provisionPlatformHc(context, deps, ownerCompany = null) {
7454
7458
  const { platform } = context;
7455
7459
  const hostname = platform.hc.hostname;
7456
7460
  const gitRef = platform.hc.gitRef;
7457
7461
  const tagBlocked = hcImageTagBlocked(gitRef);
7458
7462
  if (tagBlocked)
7459
7463
  throw new Error(tagBlocked);
7464
+ if (deps.accessEmails.length === 0) {
7465
+ throw new Error('platform:hc: no Access emails given; refusing to put the heartbeat server behind an Access app nobody can pass');
7466
+ }
7467
+ const accessEmails = archonAccessEmails(deps.accessEmails, ownerCompany);
7460
7468
  const recorded = context.recordedIds();
7461
7469
  const runtime = deps.runtime ?? {};
7462
7470
  const sleep = runtime.sleep ?? defaultSleep;
@@ -7557,6 +7565,8 @@ async function provisionPlatformHc(context, deps) {
7557
7565
  // comment. `hostname` is public and Cloudflare-proxied, so sign-up stays
7558
7566
  // closed until a real invite mechanism exists.
7559
7567
  { key: 'REGISTRATION_OPEN', value: 'False' },
7568
+ // Healthchecks turns Django's debug mode on unless told otherwise: error pages would show settings.
7569
+ { key: 'DEBUG', value: 'False' },
7560
7570
  ];
7561
7571
  if (secretKey)
7562
7572
  envs.push({ key: 'SECRET_KEY', value: secretKey });
@@ -7569,8 +7579,22 @@ async function provisionPlatformHc(context, deps) {
7569
7579
  await waitForNewDeployment(deps.coolify, appId, { kind: 'ready', url: `${internalUrl}${HC_HEALTH_CHECK_PATH}` }, runtime, sleep, 'platform:hc', `hc stays at ${recorded[PLATFORM_HC_APPLIED_GIT_REF_SCOPE_KEY] ?? 'no recorded ref'}`);
7570
7580
  }
7571
7581
  await record(PLATFORM_HC_INTERNAL_URL_SCOPE_KEY, internalUrl);
7572
- // ---- tunnel and DNS ---------------------------------------------------------
7582
+ // ---- Cloudflare Access, before the hostname resolves ------------------------
7573
7583
  const tunnels = await platformTunnels(deps, context.recordedIds(), 'platform:hc');
7584
+ const access = await platformAccess(deps, context.recordedIds(), 'platform:hc');
7585
+ const accessAppId = await ensureHcAccess(access, hostname, accessEmails, async () => {
7586
+ // The app is about to be rebuilt: the step is planned again until it is recorded
7587
+ // again, and the hostname answers 404 meanwhile, so it is never served without the gate.
7588
+ await record(PLATFORM_HC_ACCESS_SCOPE_KEY, '');
7589
+ const open = await tunnels.findTunnelByName(HC_TUNNEL_NAME);
7590
+ if (open)
7591
+ await tunnels.putTunnelConfig(open.id, { ingress: [{ service: 'http_status:404' }] });
7592
+ });
7593
+ await ensureHcPublicAccess(access, hostname, record);
7594
+ if (recorded[PLATFORM_HC_ACCESS_SCOPE_KEY] !== accessAppId) {
7595
+ await record(PLATFORM_HC_ACCESS_SCOPE_KEY, accessAppId);
7596
+ }
7597
+ // ---- tunnel and DNS ---------------------------------------------------------
7574
7598
  const existingTunnel = await tunnels.findTunnelByName(HC_TUNNEL_NAME);
7575
7599
  const tunnel = existingTunnel ?? (await tunnels.createTunnel(HC_TUNNEL_NAME));
7576
7600
  await record(PLATFORM_HC_TUNNEL_SCOPE_KEY, tunnel.id);
@@ -7613,6 +7637,82 @@ async function provisionPlatformHc(context, deps) {
7613
7637
  await record(PLATFORM_HC_APPLIED_GIT_REF_SCOPE_KEY, gitRef);
7614
7638
  return { resourceId: appId };
7615
7639
  }
7640
+ /**
7641
+ * Paths a machine calls on hc, each its own Access app with one bypass policy:
7642
+ * `/api` (Archon's heartbeats client, behind Healthchecks' `X-Api-Key`) and
7643
+ * `/ping` (the uptime Worker and the backups, by the check's secret ping URL).
7644
+ */
7645
+ const PLATFORM_HC_PUBLIC_PATHS = [
7646
+ { name: 'api', path: '/api' },
7647
+ { name: 'ping', path: '/ping' },
7648
+ ];
7649
+ export const PLATFORM_HC_ACCESS_APP_NAME = 'foundry:hc';
7650
+ const hcPublicAppName = (name) => `${PLATFORM_HC_ACCESS_APP_NAME}:public:${name}`;
7651
+ export const hcPublicAccessScopeKey = (name) => `cloudflare_platform_hc_access_public_${name}`;
7652
+ /**
7653
+ * The Access app in front of hc's web UI: the one-time PIN to the declared
7654
+ * emails only. Adopts only an app of its own name on the hostname; one with
7655
+ * other policies, another provider or other emails is rebuilt after
7656
+ * `closeGate`.
7657
+ */
7658
+ async function ensureHcAccess(cf, domain, emails, closeGate) {
7659
+ const { id: pinId } = await cf.ensureOneTimePinProvider();
7660
+ const onDomain = (await cf.listAccessApps()).filter((a) => a.domain === domain);
7661
+ const foreign = onDomain.find((a) => a.name !== PLATFORM_HC_ACCESS_APP_NAME);
7662
+ if (foreign) {
7663
+ throw new Error(`platform:hc: Access app '${foreign.name}' already holds ${domain}; foundry does not adopt it`);
7664
+ }
7665
+ const create = async () => {
7666
+ const created = await cf.createAccessApp({
7667
+ name: PLATFORM_HC_ACCESS_APP_NAME,
7668
+ domain,
7669
+ type: 'self_hosted',
7670
+ allowedIdps: [pinId],
7671
+ autoRedirectToIdentity: true,
7672
+ });
7673
+ await cf.createAccessPolicy(created.id, archonAccessPolicy(emails));
7674
+ return created.id;
7675
+ };
7676
+ const existing = onDomain[0];
7677
+ if (!existing)
7678
+ return create();
7679
+ const policies = await cf.listAccessPolicies(existing.id);
7680
+ if (policies.length === 1 &&
7681
+ policies[0]?.name === ACCESS_POLICY_NAME &&
7682
+ sameEmails(policies[0].include, emails) &&
7683
+ accessAppPinned(existing, pinId)) {
7684
+ return existing.id;
7685
+ }
7686
+ await closeGate();
7687
+ await cf.deleteAccessApp(existing.id);
7688
+ return create();
7689
+ }
7690
+ /** {@link ensureArchonPublicAccess} for hc's {@link PLATFORM_HC_PUBLIC_PATHS}. */
7691
+ async function ensureHcPublicAccess(cf, hostname, record) {
7692
+ for (const { name, path } of PLATFORM_HC_PUBLIC_PATHS) {
7693
+ const domain = `${hostname}${path}`;
7694
+ const appName = hcPublicAppName(name);
7695
+ const onDomain = (await cf.listAccessApps()).filter((a) => a.domain === domain);
7696
+ const foreign = onDomain.find((a) => a.name !== appName);
7697
+ if (foreign) {
7698
+ throw new Error(`platform:hc: Access app '${foreign.name}' already holds ${domain}; foundry does not adopt it`);
7699
+ }
7700
+ let id = onDomain[0]?.id;
7701
+ if (id && !onlyBypassEveryone(await cf.listAccessPolicies(id))) {
7702
+ await cf.deleteAccessApp(id);
7703
+ id = undefined;
7704
+ }
7705
+ if (!id) {
7706
+ id = (await cf.createAccessApp({ name: appName, domain, type: 'self_hosted' })).id;
7707
+ await cf.createAccessPolicy(id, {
7708
+ name: PLATFORM_ARCHON_PUBLIC_POLICY_NAME,
7709
+ decision: 'bypass',
7710
+ include: [{ everyone: {} }],
7711
+ });
7712
+ }
7713
+ await record(hcPublicAccessScopeKey(name), id);
7714
+ }
7715
+ }
7616
7716
  /**
7617
7717
  * The runtime `DATABASE_URL` the names app gets: the value already set on
7618
7718
  * it, else a new `names_rt` credential on the database's host, the
@@ -187,8 +187,8 @@ export async function provisionPlatformUptime(context, deps) {
187
187
  const hostname = platform.archon.hostname;
188
188
  const held = await heldUptimeValues(deps);
189
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).
190
+ // is logged and the Worker deploys without it; the next apply makes the call again (the marker below
191
+ // says whether a token may already have been minted).
192
192
  const ownerChat = context.owner?.chat ?? null;
193
193
  let chatRoomId = recorded[PLATFORM_UPTIME_CHAT_ROOM_ID_SCOPE_KEY] || '';
194
194
  if (deps.chat && ownerChat && ownerChat.subjects.length > 0) {
@@ -207,20 +207,32 @@ export async function provisionPlatformUptime(context, deps) {
207
207
  }
208
208
  // A marker, or a room already made, means the bot exists and its token is not held: rotate.
209
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');
210
+ // A chat call that fails never stops the Worker: it deploys without the chat variables, the
211
+ // marker above stays, and the next apply makes the call again (foundry#748).
212
+ let chatToken;
213
+ try {
214
+ let bot = await callChatBot(deps.chat, ownerChat, rotate);
215
+ if (!bot.accessToken && !rotate)
216
+ bot = await callChatBot(deps.chat, ownerChat, true);
217
+ if (!bot.accessToken) {
218
+ throw new Error('chat-service returned no token for the bot');
219
+ }
220
+ chatToken = bot.accessToken;
221
+ chatRoomId = bot.roomId;
222
+ }
223
+ catch {
224
+ // The cause is dropped: it can carry a response body. One fixed line says what happened.
225
+ console.error('platform:uptime: the chat bot could not be made; the Worker deploys without chat alerts and the next apply tries again');
226
+ chatRoomId = '';
227
+ }
228
+ if (chatToken) {
229
+ context.recordScopeId(PLATFORM_UPTIME_CHAT_ROOM_ID_SCOPE_KEY, chatRoomId);
230
+ await context.persist?.();
231
+ await attempt('holding the chat bot token', () => deps.deliverSecret(PLATFORM_UPTIME_CHAT_TOKEN_REF, chatToken));
232
+ context.recordScopeId(PLATFORM_UPTIME_CHAT_UNDELIVERED_SCOPE_KEY, '');
233
+ await context.persist?.();
234
+ held.CHAT_ACCESS_TOKEN = chatToken;
216
235
  }
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
236
  }
225
237
  }
226
238
  const workerVars = uptimeEffectiveVars(deps.vars, deps.chat && ownerChat && chatRoomId
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wtfalch/foundry",
3
- "version": "0.39.1",
3
+ "version": "0.39.2",
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",