@wtfalch/foundry 0.31.0 → 0.33.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -3,13 +3,17 @@ import { isIPv4 } from 'node:net';
3
3
  import { applyObjects, openInstance, platformMailSettingsObjects } from '@wtfalch/email/postmaster';
4
4
  import { VERSIONS, floatingTagBlocked } from '../bootstrap/versions.js';
5
5
  import { redactSecrets } from '../calls-sfu-deploy-support.js';
6
+ import { ownerMailboxes } from '../org.js';
7
+ import { BOULE_BRIDGE_SECRET_ENV, BOULE_PEER_PASSWORD_ENV, BOULE_PEER_ROLE, bouleHostnameFor, } from './boule.js';
6
8
  import { ensureCloudflaredConnector } from './cloudflared-connector.js';
7
9
  import { waitForDeploymentFinished, waitForHealthyVersion, waitForNewDeployment, } from './deploy-health.js';
8
- import { ACCESS_POLICY_NAME, getDatabaseStarted, isNotFoundError, isRunningOrStarting, } from './executor.js';
10
+ import { ACCESS_POLICY_NAME, coolifyProjectMark, getDatabaseStarted, isNotFoundError, isRunningOrStarting, } from './executor.js';
9
11
  import { ACCESS_PROVIDER_RECORD_KEY, GATE_CLAIM_NAME } from './gates.js';
10
12
  import { healthCheckSettings } from './health-check-settings.js';
11
13
  import { coolifyDomains, coolifyTunnelDomains, filesShareHostname } from './hostnames.js';
12
- import { AUTH_ZITADEL_RUN_OPTIONS, JOBS_DEPLOY_PENDING_SCOPE_KEY, JOBS_INTERNAL_URL_SCOPE_KEY, JOBS_SCHEDULER_KEY, PLATFORM_ACCESS_PROVIDER_APPLIED_AUTH_DOMAIN_SCOPE_KEY, PLATFORM_ACCESS_PROVIDER_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_ACCESS_PROVIDER_UNDELIVERED_SCOPE_KEY, PLATFORM_AI_APPLIED_GITREF_SCOPE_KEY, PLATFORM_AI_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_AI_DEPLOY_PENDING_SCOPE_KEY, PLATFORM_AI_INTERNAL_URL_SCOPE_KEY, PLATFORM_AI_KEY, PLATFORM_AI_MANAGEMENT_KEY_CONFLICT_SCOPE_KEY, PLATFORM_AI_MANAGEMENT_KEY_SCOPE_KEY, PLATFORM_AI_MANAGEMENT_KEY_UNDELIVERED_SCOPE_KEY, PLATFORM_AI_MIGRATE_BLOCKED_SCOPE_KEY, PLATFORM_AI_ORGANISATION_SCOPE_KEY, PLATFORM_AI_SIGNING_KEY_SET_SCOPE_KEY, PLATFORM_AI_SIGNING_KEY_UNDELIVERED_SCOPE_KEY, PLATFORM_ARCHON_APPLIED_BROKER_SCOPE_KEY, PLATFORM_ARCHON_APPLIED_GIT_REF_SCOPE_KEY, PLATFORM_ARCHON_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_ARCHON_APPLIED_OPERATOR_SCOPE_KEY, PLATFORM_ARCHON_APPLIED_ROLLING_UPDATE_SCOPE_KEY, PLATFORM_ARCHON_CONNECTOR_APP_SCOPE_KEY, PLATFORM_ARCHON_COOLIFY_READ_TOKEN_REF, PLATFORM_ARCHON_DEPLOYMENT_SCOPE_KEY, PLATFORM_ARCHON_ENVS_DEPLOYED_SCOPE_KEY, PLATFORM_ARCHON_GATE_CLOSED_SCOPE_KEY, PLATFORM_ARCHON_ID_TOKEN_USERINFO_APPLIED_SCOPE_KEY, PLATFORM_ARCHON_INTEGRATIONS_KEY_SYNCED_SCOPE_KEY, PLATFORM_ARCHON_KEY, PLATFORM_ARCHON_MASTER_CLOUDFLARE_TOKEN_REF, PLATFORM_ARCHON_MASTER_COOLIFY_TOKEN_REF, PLATFORM_ARCHON_MASTER_COOLIFY_URL_REF, PLATFORM_ARCHON_UPTIME_TOKEN_SYNCED_SCOPE_KEY, PLATFORM_ARCHON_ZITADEL_APP_SCOPE_KEY, PLATFORM_AUTH_ADMIN_UNDELIVERED_SCOPE_KEY, PLATFORM_AUTH_ADMIN_USER_SCOPE_KEY, PLATFORM_AUTH_BROKER_DEPLOYED_SCOPE_KEY, PLATFORM_AUTH_BROKER_SCOPE_KEY, PLATFORM_AUTH_CONNECTOR_APP_SCOPE_KEY, PLATFORM_AUTH_INSTANCE_SCOPE_KEY, PLATFORM_AUTH_INSTANCE_UNDELIVERED_SCOPE_KEY, PLATFORM_AUTH_KEY, PLATFORM_AUTH_LOGIN_CLIENT_UNDELIVERED_SCOPE_KEY, PLATFORM_AUTH_LOGIN_CLIENT_USER_SCOPE_KEY, PLATFORM_AUTH_OWNER_INVITE_SCOPE_KEY, PLATFORM_AUTH_REGISTRATION_CLOSED_SCOPE_KEY, PLATFORM_AUTH_RESERVED_ADMIN_USERNAME_SCOPE_KEY, PLATFORM_AUTH_SMTP_SCOPE_KEY, PLATFORM_AUTH_ZITADEL_APPLIED_IMAGE_TAG_SCOPE_KEY, PLATFORM_AUTH_ZITADEL_RUN_OPTIONS_SCOPE_KEY, PLATFORM_BACKUP_DATABASE_SCOPE_KEYS, PLATFORM_BACKUP_SERVICE_KEYS, PLATFORM_EDGE_APPLIED_GIT_REF_SCOPE_KEY, PLATFORM_EDGE_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_EDGE_CLOUDFLARE_TOKEN_REF, PLATFORM_EDGE_INTERNAL_URL_SCOPE_KEY, PLATFORM_EDGE_KEY, PLATFORM_EDGE_KEY_REF, PLATFORM_EDGE_PLATFORM_KEY_SET_SCOPE_KEY, PLATFORM_EDGE_PLATFORM_KEY_UNDELIVERED_SCOPE_KEY, PLATFORM_FILES_APPLIED_GIT_REF_SCOPE_KEY, PLATFORM_FILES_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_FILES_INTERNAL_URL_SCOPE_KEY, PLATFORM_FILES_INTROSPECTION_SCOPE_KEY, PLATFORM_FILES_INTROSPECTION_UNDELIVERED_SCOPE_KEY, PLATFORM_FILES_KEY, PLATFORM_FILES_MANAGEMENT_KEY_SCOPE_KEY, PLATFORM_FILES_MANAGEMENT_UNDELIVERED_SCOPE_KEY, PLATFORM_FILES_ZITADEL_PROJECT_SCOPE_KEY, PLATFORM_HC_API_KEY_REF, PLATFORM_HC_APPLIED_GIT_REF_SCOPE_KEY, PLATFORM_HC_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_HC_KEY, PLATFORM_HEARTBEATS_APPLIED_SCOPE_KEY, PLATFORM_INTEGRATIONS_APPLIED_GITREF_SCOPE_KEY, PLATFORM_INTEGRATIONS_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_INTEGRATIONS_KEY, PLATFORM_INTEGRATIONS_KEY_ID_SCOPE_KEY, PLATFORM_INTEGRATIONS_KEY_ROTATION_PREVIOUS_SCOPE_KEY, PLATFORM_INTEGRATIONS_KEY_ROTATION_REQUESTED_SCOPE_KEY, PLATFORM_INTEGRATIONS_KEY_ROTATION_UNDELIVERED_SCOPE_KEY, PLATFORM_INTEGRATIONS_KEY_SET_SCOPE_KEY, PLATFORM_INTEGRATIONS_KEY_UNDELIVERED_SCOPE_KEY, PLATFORM_INTEGRATIONS_RUNNER_KEYRING_DEPLOYED_SCOPE_KEY, PLATFORM_MAIL_ADMIN_KEY_SCOPE_KEY, PLATFORM_MAIL_ADMIN_UNDELIVERED_SCOPE_KEY, PLATFORM_MAIL_APPLIED_GIT_REF_SCOPE_KEY, PLATFORM_MAIL_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_MAIL_CONNECTOR_APP_SCOPE_KEY, PLATFORM_MAIL_INTERNAL_URL_SCOPE_KEY, PLATFORM_MAIL_KEY, PLATFORM_MAIL_ROUTE_SCOPE_KEY, PLATFORM_MAIL_ROUTE_WANTED, PLATFORM_MAIL_ZITADEL_PROJECT_SCOPE_KEY, PLATFORM_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, archonBootstrapOperator, desiredHeartbeatCheckNames, heartbeatCheckUrlRef, parseBrokerDeployRecord, parseLoginClientRecord, platformBackupHeartbeatCheckName, platformMailMxHost, uptimeAccessTokenConflict, uptimeRecorded, uptimeVarsDigest, } from './platform-diff.js';
14
+ import { AUTH_ZITADEL_RUN_OPTIONS, JOBS_DEPLOY_PENDING_SCOPE_KEY, JOBS_INTERNAL_URL_SCOPE_KEY, JOBS_SCHEDULER_KEY, PLATFORM_ACCESS_PROVIDER_APPLIED_AUTH_DOMAIN_SCOPE_KEY, PLATFORM_ACCESS_PROVIDER_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_ACCESS_PROVIDER_UNDELIVERED_SCOPE_KEY, PLATFORM_AI_APPLIED_GITREF_SCOPE_KEY, PLATFORM_AI_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_AI_DEPLOY_PENDING_SCOPE_KEY, PLATFORM_AI_INTERNAL_URL_SCOPE_KEY, PLATFORM_AI_KEY, PLATFORM_AI_MANAGEMENT_KEY_CONFLICT_SCOPE_KEY, PLATFORM_AI_MANAGEMENT_KEY_SCOPE_KEY, PLATFORM_AI_MANAGEMENT_KEY_UNDELIVERED_SCOPE_KEY, PLATFORM_AI_MIGRATE_BLOCKED_SCOPE_KEY, PLATFORM_AI_ORGANISATION_SCOPE_KEY, PLATFORM_AI_SIGNING_KEY_SET_SCOPE_KEY, PLATFORM_AI_SIGNING_KEY_UNDELIVERED_SCOPE_KEY, PLATFORM_ARCHON_APPLIED_AUTH_ORG_SCOPE_KEY, PLATFORM_ARCHON_APPLIED_BROKER_SCOPE_KEY, PLATFORM_ARCHON_APPLIED_GIT_REF_SCOPE_KEY, PLATFORM_ARCHON_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_ARCHON_APPLIED_ROLLING_UPDATE_SCOPE_KEY, PLATFORM_ARCHON_CONNECTOR_APP_SCOPE_KEY, PLATFORM_ARCHON_COOLIFY_READ_TOKEN_REF, PLATFORM_ARCHON_DEPLOYMENT_SCOPE_KEY, PLATFORM_ARCHON_ENVS_DEPLOYED_SCOPE_KEY, PLATFORM_ARCHON_GATE_CLOSED_SCOPE_KEY, PLATFORM_ARCHON_ID_TOKEN_USERINFO_APPLIED_SCOPE_KEY, PLATFORM_ARCHON_INTEGRATIONS_KEY_SYNCED_SCOPE_KEY, PLATFORM_ARCHON_KEY, PLATFORM_ARCHON_MASTER_CLOUDFLARE_TOKEN_REF, PLATFORM_ARCHON_MASTER_COOLIFY_TOKEN_REF, PLATFORM_ARCHON_MASTER_COOLIFY_URL_REF, PLATFORM_ARCHON_UPTIME_TOKEN_SYNCED_SCOPE_KEY, PLATFORM_ARCHON_ZITADEL_APP_SCOPE_KEY, PLATFORM_AUTH_ADMIN_UNDELIVERED_SCOPE_KEY, PLATFORM_AUTH_ADMIN_USER_SCOPE_KEY, PLATFORM_AUTH_BROKER_DEPLOYED_SCOPE_KEY, PLATFORM_AUTH_BROKER_SCOPE_KEY, PLATFORM_AUTH_CONNECTOR_APP_SCOPE_KEY, PLATFORM_AUTH_INSTANCE_SCOPE_KEY, PLATFORM_AUTH_INSTANCE_UNDELIVERED_SCOPE_KEY, PLATFORM_AUTH_KEY, PLATFORM_AUTH_LOGIN_CLIENT_UNDELIVERED_SCOPE_KEY, PLATFORM_AUTH_LOGIN_CLIENT_USER_SCOPE_KEY, PLATFORM_AUTH_OWNER_INVITE_ORG_SCOPE_KEY, PLATFORM_AUTH_OWNER_INVITE_SCOPE_KEY, PLATFORM_AUTH_REGISTRATION_CLOSED_SCOPE_KEY, PLATFORM_AUTH_RESERVED_ADMIN_USERNAME_SCOPE_KEY, PLATFORM_AUTH_SMTP_SCOPE_KEY, PLATFORM_AUTH_ZITADEL_APPLIED_IMAGE_TAG_SCOPE_KEY, PLATFORM_AUTH_ZITADEL_RUN_OPTIONS_SCOPE_KEY, PLATFORM_BACKUP_DATABASE_SCOPE_KEYS, PLATFORM_BACKUP_SERVICE_KEYS, PLATFORM_EDGE_APPLIED_GIT_REF_SCOPE_KEY, PLATFORM_EDGE_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_EDGE_CLOUDFLARE_TOKEN_REF, PLATFORM_EDGE_INTERNAL_URL_SCOPE_KEY, PLATFORM_EDGE_KEY, PLATFORM_EDGE_KEY_REF, PLATFORM_EDGE_PLATFORM_KEY_SET_SCOPE_KEY, PLATFORM_EDGE_PLATFORM_KEY_UNDELIVERED_SCOPE_KEY, PLATFORM_FILES_APPLIED_GIT_REF_SCOPE_KEY, PLATFORM_FILES_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_FILES_INTERNAL_URL_SCOPE_KEY, PLATFORM_FILES_INTROSPECTION_SCOPE_KEY, PLATFORM_FILES_INTROSPECTION_UNDELIVERED_SCOPE_KEY, PLATFORM_FILES_KEY, PLATFORM_FILES_MANAGEMENT_KEY_SCOPE_KEY, PLATFORM_FILES_MANAGEMENT_UNDELIVERED_SCOPE_KEY, PLATFORM_FILES_ZITADEL_PROJECT_SCOPE_KEY, PLATFORM_HC_API_KEY_REF, PLATFORM_HC_APPLIED_GIT_REF_SCOPE_KEY, PLATFORM_HC_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_HC_KEY, PLATFORM_HEARTBEATS_APPLIED_SCOPE_KEY, PLATFORM_INTEGRATIONS_APPLIED_GITREF_SCOPE_KEY, PLATFORM_INTEGRATIONS_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_INTEGRATIONS_KEY, PLATFORM_INTEGRATIONS_KEY_ID_SCOPE_KEY, PLATFORM_INTEGRATIONS_KEY_ROTATION_PREVIOUS_SCOPE_KEY, PLATFORM_INTEGRATIONS_KEY_ROTATION_REQUESTED_SCOPE_KEY, PLATFORM_INTEGRATIONS_KEY_ROTATION_UNDELIVERED_SCOPE_KEY, PLATFORM_INTEGRATIONS_KEY_SET_SCOPE_KEY, PLATFORM_INTEGRATIONS_KEY_UNDELIVERED_SCOPE_KEY, PLATFORM_INTEGRATIONS_RUNNER_KEYRING_DEPLOYED_SCOPE_KEY, PLATFORM_MAIL_ADMIN_KEY_SCOPE_KEY, PLATFORM_MAIL_ADMIN_UNDELIVERED_SCOPE_KEY, PLATFORM_MAIL_APPLIED_GIT_REF_SCOPE_KEY, PLATFORM_MAIL_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_MAIL_CONNECTOR_APP_SCOPE_KEY, PLATFORM_MAIL_INTERNAL_URL_SCOPE_KEY, PLATFORM_MAIL_KEY, PLATFORM_MAIL_ROUTE_SCOPE_KEY, PLATFORM_MAIL_ROUTE_WANTED, PLATFORM_MAIL_ZITADEL_PROJECT_SCOPE_KEY, PLATFORM_OWNER_ORG_DOMAIN_BLOCKED_SCOPE_KEY, PLATFORM_OWNER_ORG_DOMAIN_SCOPE_KEY, PLATFORM_OWNER_ORG_SCOPE_KEY, PLATFORM_SFU_APPLIED_SCOPE_KEY, PLATFORM_UPTIME_ACCESS_CLIENT_ID_REF, PLATFORM_UPTIME_ACCESS_CLIENT_SECRET_REF, PLATFORM_UPTIME_ACCESS_TOKEN_ID_SCOPE_KEY, PLATFORM_UPTIME_ACCESS_TOKEN_NAME, PLATFORM_UPTIME_ACCESS_UNDELIVERED_SCOPE_KEY, PLATFORM_UPTIME_HEARTBEAT_CHECK_NAME, PLATFORM_UPTIME_KEY, PLATFORM_UPTIME_REMOVED_SCOPE_KEY, PLATFORM_UPTIME_SCRIPT_NAME, PLATFORM_UPTIME_TOKEN_ID_SCOPE_KEY, PLATFORM_UPTIME_TOKEN_REF, PLATFORM_ZITADEL_ORG_SCOPE_KEY, archonAuthOrganizationId, desiredHeartbeatCheckNames, heartbeatCheckUrlRef, parseBrokerDeployRecord, parseLoginClientRecord, platformBackupHeartbeatCheckName, platformMailMxHost, uptimeAccessTokenConflict, uptimeRecorded, uptimeVarsDigest, } from './platform-diff.js';
15
+ import { ensureSignInOrg, inviteOwnerWithLink, signInOrgDomain } from './sign-in-org.js';
16
+ export { PLATFORM_ZITADEL_ORG_SCOPE_KEY };
13
17
  import { applyCallsSfuPlatformStep, callsSfuAppliedFingerprint, } from './platform-sfu.js';
14
18
  import { heldUptimeValues, provisionPlatformUptime, } from './uptime-worker.js';
15
19
  import { CreateInstanceError, createZitadelInstance, generateSystemApiKeypair, instanceWithoutBearerMessage, systemApiUsersEnv, } from './zitadel-system-api.js';
@@ -207,6 +211,12 @@ export class FoundryPlatformStepExecutor {
207
211
  // #216 round 5: see `PlanPlatformOptions.blocked.authOtherInputsMissing`.
208
212
  if (otherInputsMissing.length > 0)
209
213
  blocked.authOtherInputsMissing = otherInputsMissing;
214
+ // C3: the owner's invite needs a work address and the host's mail port. Without them it is
215
+ // a blocked item, never a throw on every apply.
216
+ const ownerCompany = await this.declaredOrgStore.get(platform.ownerOrgId);
217
+ const inviteBlocked = ownerInviteBlockedReason(ownerCompany, Boolean(this.auth.ownerInviteMail));
218
+ if (inviteBlocked)
219
+ blocked.authOwnerInvite = inviteBlocked;
210
220
  }
211
221
  if (platform.mail.enabled) {
212
222
  const mailBlocked = await mailBlockedReason(platform, this.mail, recordedIds);
@@ -365,6 +375,16 @@ export class FoundryPlatformStepExecutor {
365
375
  console.error(`platform:archon: could not reach the platform's ZITADEL client to plan the ID token repair: ${e instanceof Error ? e.message : String(e)}`);
366
376
  }
367
377
  }
378
+ // Not a block: without the auth dependencies and a ZITADEL client for the instance the sign-in policy step is not planned.
379
+ if (this.auth && platform.auth.enabled && recordedIds[PLATFORM_ZITADEL_ORG_SCOPE_KEY]) {
380
+ try {
381
+ this.auth.zitadelForInstance?.(`https://${platform.auth.hostname}`);
382
+ blocked.authSignInPolicy = true;
383
+ }
384
+ catch (e) {
385
+ console.error(`platform:auth: could not reach the platform's ZITADEL client to plan the sign-in policy: ${e instanceof Error ? e.message : String(e)}`);
386
+ }
387
+ }
368
388
  // Not a block: tells the planner to order Archon's step last (`PlanPlatformOptions.blocked.archonRunsInsideApp`).
369
389
  if (this.archon?.runsInsideApp) {
370
390
  blocked.archonRunsInsideApp = true;
@@ -458,9 +478,18 @@ export class FoundryPlatformStepExecutor {
458
478
  ? recordedIds[PLATFORM_UPTIME_ACCESS_TOKEN_ID_SCOPE_KEY]
459
479
  : undefined);
460
480
  const brokerDrifted = !accessDrifted && (await archonBrokerRegistrationDrifted(this.archon.coolify, recordedIds));
461
- const bootstrapOperatorDrifted = !accessDrifted &&
481
+ const authOrgDrifted = !accessDrifted &&
482
+ !brokerDrifted &&
483
+ (await archonAuthOrgDrifted(this.archon.coolify, recordedIds[`coolify:app:${PLATFORM_ARCHON_KEY}`], recordedIds));
484
+ const authzDrifted = !accessDrifted &&
462
485
  !brokerDrifted &&
463
- (await archonBootstrapOperatorDrifted(this.archon.coolify, recordedIds[`coolify:app:${PLATFORM_ARCHON_KEY}`], recordedIds));
486
+ !authOrgDrifted &&
487
+ (await archonAuthzDatabaseDrifted(this.archon.coolify, recordedIds[`coolify:app:${PLATFORM_ARCHON_KEY}`], await this.declaredOrgStore.get(platform.ownerOrgId)));
488
+ const bridgeDrifted = !accessDrifted &&
489
+ !brokerDrifted &&
490
+ !authOrgDrifted &&
491
+ !authzDrifted &&
492
+ (await archonBridgeDrifted(this.archon.coolify, recordedIds[`coolify:app:${PLATFORM_ARCHON_KEY}`], await this.declaredOrgStore.get(platform.ownerOrgId)));
464
493
  // #480: re-reads the live app's rolling-update/health-check settings
465
494
  // even once `PLATFORM_ARCHON_APPLIED_ROLLING_UPDATE_SCOPE_KEY` is
466
495
  // recorded -- that marker only ever proves one past apply patched them,
@@ -469,7 +498,9 @@ export class FoundryPlatformStepExecutor {
469
498
  // back off.
470
499
  const rollingUpdateDrifted = !accessDrifted &&
471
500
  !brokerDrifted &&
472
- !bootstrapOperatorDrifted &&
501
+ !authOrgDrifted &&
502
+ !authzDrifted &&
503
+ !bridgeDrifted &&
473
504
  (await archonRollingUpdateDrifted(this.archon.coolify, recordedIds[`coolify:app:${PLATFORM_ARCHON_KEY}`]));
474
505
  // The deployment the step triggered failed or Coolify does not know it:
475
506
  // plan the step again, which deploys again. A finished one is confirmed
@@ -480,7 +511,9 @@ export class FoundryPlatformStepExecutor {
480
511
  (await archonDeploymentState(this.archon.coolify, deployment)) === 'failed';
481
512
  if (accessDrifted ||
482
513
  brokerDrifted ||
483
- bootstrapOperatorDrifted ||
514
+ authOrgDrifted ||
515
+ authzDrifted ||
516
+ bridgeDrifted ||
484
517
  rollingUpdateDrifted ||
485
518
  redeployFailed)
486
519
  result.archon = true;
@@ -552,7 +585,7 @@ export class FoundryPlatformStepExecutor {
552
585
  throw new Error('platform:auth needs FoundryPlatformStepExecutor to be constructed with its auth dependencies (#168)');
553
586
  }
554
587
  const company = await this.declaredOrgStore.get(context.platform.ownerOrgId);
555
- return provisionPlatformAuth(context, this.auth, company?.owners ?? []);
588
+ return provisionPlatformAuth(context, this.auth, company);
556
589
  }
557
590
  if (action.system === 'coolify' && action.kind === 'app' && action.key === PLATFORM_FILES_KEY) {
558
591
  if (!this.files) {
@@ -586,7 +619,8 @@ export class FoundryPlatformStepExecutor {
586
619
  if (!this.archon) {
587
620
  throw new Error('platform:archon needs FoundryPlatformStepExecutor to be constructed with its archon dependencies (#175)');
588
621
  }
589
- return provisionPlatformArchon(context, this.archon);
622
+ const ownerCompany = await this.declaredOrgStore.get(context.platform.ownerOrgId);
623
+ return provisionPlatformArchon(context, this.archon, ownerCompany);
590
624
  }
591
625
  if (action.system === 'coolify' && action.kind === 'app' && action.key === PLATFORM_MAIL_KEY) {
592
626
  if (!this.mail) {
@@ -608,6 +642,14 @@ export class FoundryPlatformStepExecutor {
608
642
  }
609
643
  return switchOnArchonIdTokenUserinfo(context, this.archon);
610
644
  }
645
+ if (action.system === 'zitadel' &&
646
+ action.kind === 'sign-in-policy' &&
647
+ action.key === PLATFORM_AUTH_KEY) {
648
+ if (!this.auth) {
649
+ throw new Error('platform:auth needs FoundryPlatformStepExecutor to be constructed with its auth dependencies (#168)');
650
+ }
651
+ return applyPlatformSignInPolicy(context, this.auth);
652
+ }
611
653
  if (action.system === 'cloudflare' && action.kind === 'access-provider') {
612
654
  if (!this.accessProvider) {
613
655
  throw new Error('cloudflare:access-provider needs FoundryPlatformStepExecutor to be constructed with its Access provider dependencies (#604)');
@@ -1198,8 +1240,9 @@ async function liveLoginClientTokenHash(coolify, appId) {
1198
1240
  * #197: a `cloudflared` connector for this tunnel is run
1199
1241
  * ({@link ensureCloudflaredConnector}), so `hostname` answers.
1200
1242
  */
1201
- async function provisionPlatformAuth(context, deps, owners = []) {
1243
+ async function provisionPlatformAuth(context, deps, ownerCompany = null) {
1202
1244
  const { platform } = context;
1245
+ const owners = ownerCompany?.owners ?? [];
1203
1246
  const hostname = platform.auth.hostname;
1204
1247
  const gitRef = platform.auth.gitRef;
1205
1248
  const recorded = context.recordedIds();
@@ -1756,6 +1799,28 @@ async function provisionPlatformAuth(context, deps, owners = []) {
1756
1799
  // own doc comment, and `authLoginClientDrifted`'s, for why.
1757
1800
  await record(PLATFORM_AUTH_LOGIN_CLIENT_USER_SCOPE_KEY, loginClientRecordValue(loginClientUserId, loginClientPatId, loginClientToken));
1758
1801
  }
1802
+ // ---- the owner company's own sign-in org ----------------------------------
1803
+ // Archon signs people in from this org; the `foundry` org holds none. The
1804
+ // company executor finds the same org by name (its slug) and makes no second.
1805
+ const ownerOrgZitadel = deps.zitadelForInstance?.(`https://${hostname}`) ?? deps.zitadel;
1806
+ let ownerSignInOrgId;
1807
+ if (ownerCompany) {
1808
+ ({ orgId: ownerSignInOrgId } = await ensureSignInOrg({
1809
+ domainBlockedScopeKey: PLATFORM_OWNER_ORG_DOMAIN_BLOCKED_SCOPE_KEY,
1810
+ zitadel: ownerOrgZitadel,
1811
+ name: ownerCompany.slug,
1812
+ domain: signInOrgDomain(ownerCompany),
1813
+ recordScopeId: (key, id) => context.recordScopeId(key, id),
1814
+ persistScopeIds: async () => {
1815
+ await context.persist?.();
1816
+ },
1817
+ orgScopeKey: PLATFORM_OWNER_ORG_SCOPE_KEY,
1818
+ domainScopeKey: PLATFORM_OWNER_ORG_DOMAIN_SCOPE_KEY,
1819
+ }));
1820
+ }
1821
+ else {
1822
+ await record(PLATFORM_OWNER_ORG_SCOPE_KEY, 'none');
1823
+ }
1759
1824
  // ---- ZITADEL mail and the owner's invite ----------------------------------
1760
1825
  // The token is the held key, else the broker's live env. Absent, nothing
1761
1826
  // here can run; `authBrokerBlockedReason` already reports it by name.
@@ -1786,63 +1851,97 @@ async function provisionPlatformAuth(context, deps, owners = []) {
1786
1851
  ]);
1787
1852
  throw new Error(`platform:auth: could not configure ZITADEL's SMTP provider for ${hostname}: ${cause}`);
1788
1853
  }
1789
- if (!context.recordedIds()[PLATFORM_AUTH_OWNER_INVITE_SCOPE_KEY]) {
1854
+ const inviteRecorded = context.recordedIds()[PLATFORM_AUTH_OWNER_INVITE_SCOPE_KEY];
1855
+ const inviteOrgRecorded = context.recordedIds()[PLATFORM_AUTH_OWNER_INVITE_ORG_SCOPE_KEY];
1856
+ // An invite recorded for another org (the `foundry` org, before the owner moved) is made again.
1857
+ if (!inviteRecorded || (inviteRecorded !== 'none' && inviteOrgRecorded !== ownerSignInOrgId)) {
1790
1858
  const owner = owners[0];
1791
- if (!owner) {
1859
+ if (!owner || !ownerCompany || !ownerSignInOrgId) {
1792
1860
  // No owner declared: nothing to invite. Recorded so the step is not replanned for it.
1793
1861
  await record(PLATFORM_AUTH_OWNER_INVITE_SCOPE_KEY, 'none');
1794
1862
  }
1795
1863
  else {
1796
- const ownerOrgId = context.recordedIds()[PLATFORM_ZITADEL_ORG_SCOPE_KEY] ??
1797
- (await mailZitadel.findOrCreateOrg(PLATFORM_AUTH_ORG_NAME)).org.id;
1798
- await record(PLATFORM_ZITADEL_ORG_SCOPE_KEY, ownerOrgId);
1799
- let invitedUserId;
1800
- try {
1801
- // ZITADEL's default invite link is `/ui/v2/login/verify`, which 404s on the login app;
1802
- // it serves `/verify` at the root.
1803
- ({ userId: invitedUserId } = await mailZitadel.inviteOrgOwner(ownerOrgId, {
1804
- givenName: owner.givenName,
1805
- familyName: owner.familyName,
1806
- email: owner.email,
1807
- }, `https://${hostname}/verify?userId={{.UserID}}&code={{.Code}}&organization={{.OrgID}}&invite=true`));
1808
- }
1809
- catch (error) {
1810
- // The cause, with the owner's email and the Lettermint key redacted, so a failure says why.
1811
- const cause = redactSecrets(error instanceof Error ? error.message : String(error), [
1812
- owner.email,
1813
- owner.email.toLowerCase(),
1814
- mailToken,
1815
- ...(smtpPassword ? [smtpPassword] : []),
1816
- ]);
1817
- throw new Error(`platform:auth: could not invite the platform owner into ZITADEL org ${ownerOrgId}: ${cause}`);
1864
+ const mailboxAddress = ownerMailboxes(ownerCompany)[0]?.address;
1865
+ // C3: `ownerInviteBlockedReason` reports these two as blocked; the step goes on without the invite.
1866
+ if (mailboxAddress && deps.ownerInviteMail) {
1867
+ let invitedUserId;
1868
+ try {
1869
+ ({ userId: invitedUserId } = await inviteOwnerWithLink({
1870
+ zitadel: mailZitadel,
1871
+ mail: deps.ownerInviteMail,
1872
+ orgId: ownerSignInOrgId,
1873
+ authIssuer: `https://${hostname}`,
1874
+ owner: {
1875
+ givenName: owner.givenName,
1876
+ familyName: owner.familyName,
1877
+ mailboxAddress,
1878
+ personalEmail: owner.email,
1879
+ },
1880
+ label: 'platform:auth',
1881
+ ...(inviteRecorded && inviteRecorded !== 'none'
1882
+ ? { invitedUserId: inviteRecorded }
1883
+ : {}),
1884
+ }));
1885
+ }
1886
+ catch (error) {
1887
+ // The cause, with both addresses and the Lettermint key redacted, so a failure says why.
1888
+ const cause = redactSecrets(error instanceof Error ? error.message : String(error), [
1889
+ owner.email,
1890
+ owner.email.toLowerCase(),
1891
+ mailboxAddress,
1892
+ mailToken,
1893
+ ...(smtpPassword ? [smtpPassword] : []),
1894
+ ]);
1895
+ throw new Error(`platform:auth: could not invite the platform owner into ZITADEL org ${ownerSignInOrgId}: ${cause}`);
1896
+ }
1897
+ // The user id first: a crash between the two replans the invite, never skips it.
1898
+ await record(PLATFORM_AUTH_OWNER_INVITE_SCOPE_KEY, invitedUserId);
1899
+ await record(PLATFORM_AUTH_OWNER_INVITE_ORG_SCOPE_KEY, ownerSignInOrgId);
1818
1900
  }
1819
- await record(PLATFORM_AUTH_OWNER_INVITE_SCOPE_KEY, invitedUserId);
1820
1901
  }
1821
1902
  }
1822
1903
  }
1823
- // ---- reserved username and closed registration, once each (#394) ----------
1824
- // Outside the blocks above so a platform applied before this existed gets
1825
- // it on its next apply; `planAuth` plans the step until both keys exist.
1826
- // Last, so a failure here holds up neither the SMTP provider nor the invite.
1827
- const policyRecorded = context.recordedIds();
1828
- if (!policyRecorded[PLATFORM_AUTH_RESERVED_ADMIN_USERNAME_SCOPE_KEY] ||
1829
- !policyRecorded[PLATFORM_AUTH_REGISTRATION_CLOSED_SCOPE_KEY]) {
1830
- const policyZitadel = deps.zitadelForInstance?.(`https://${hostname}`) ?? deps.zitadel;
1831
- let policyOrgId = policyRecorded[PLATFORM_ZITADEL_ORG_SCOPE_KEY];
1832
- if (!policyOrgId) {
1833
- policyOrgId = (await policyZitadel.findOrCreateOrg(PLATFORM_AUTH_ORG_NAME)).org.id;
1834
- await record(PLATFORM_ZITADEL_ORG_SCOPE_KEY, policyOrgId);
1904
+ return { resourceId: zitadelAppId };
1905
+ }
1906
+ /**
1907
+ * Reserves the username `admin` and switches self-registration off in the
1908
+ * platform's sign-in org (`zitadel:sign-in-policy` keyed {@link PLATFORM_AUTH_KEY}).
1909
+ * Each call records its own key right after it succeeds, so a failure in the
1910
+ * second leaves the first recorded and throws; the next plan names the step
1911
+ * again. The error names the org only, never the cause.
1912
+ */
1913
+ async function applyPlatformSignInPolicy(context, deps) {
1914
+ const { platform } = context;
1915
+ const recorded = context.recordedIds();
1916
+ const orgId = recorded[PLATFORM_ZITADEL_ORG_SCOPE_KEY];
1917
+ if (!orgId) {
1918
+ throw new Error("platform:auth: the sign-in's ZITADEL org is not recorded yet -- cannot reserve the admin username or close registration");
1919
+ }
1920
+ const zitadel = deps.zitadelForInstance?.(`https://${platform.auth.hostname}`) ?? deps.zitadel;
1921
+ const record = async (key, value) => {
1922
+ context.recordScopeId(key, value);
1923
+ await context.persist?.();
1924
+ };
1925
+ if (!recorded[PLATFORM_AUTH_RESERVED_ADMIN_USERNAME_SCOPE_KEY]) {
1926
+ let userId;
1927
+ try {
1928
+ ({ userId } = await zitadel.reserveUserName(orgId, PLATFORM_AUTH_RESERVED_USERNAME));
1835
1929
  }
1836
- if (!policyRecorded[PLATFORM_AUTH_RESERVED_ADMIN_USERNAME_SCOPE_KEY]) {
1837
- const { userId } = await policyZitadel.reserveUserName(policyOrgId, PLATFORM_AUTH_RESERVED_USERNAME);
1838
- await record(PLATFORM_AUTH_RESERVED_ADMIN_USERNAME_SCOPE_KEY, userId);
1930
+ catch {
1931
+ throw new Error(`platform:auth: reserving the username '${PLATFORM_AUTH_RESERVED_USERNAME}' failed in ZITADEL org ${orgId}`);
1839
1932
  }
1840
- if (!policyRecorded[PLATFORM_AUTH_REGISTRATION_CLOSED_SCOPE_KEY]) {
1841
- await policyZitadel.closeSelfRegistration(policyOrgId);
1842
- await record(PLATFORM_AUTH_REGISTRATION_CLOSED_SCOPE_KEY, 'closed');
1933
+ await record(PLATFORM_AUTH_RESERVED_ADMIN_USERNAME_SCOPE_KEY, userId);
1934
+ }
1935
+ if (!recorded[PLATFORM_AUTH_REGISTRATION_CLOSED_SCOPE_KEY]) {
1936
+ try {
1937
+ await zitadel.closeSelfRegistration(orgId);
1843
1938
  }
1939
+ catch {
1940
+ throw new Error(`platform:auth: closing self-registration failed in ZITADEL org ${orgId}`);
1941
+ }
1942
+ await record(PLATFORM_AUTH_REGISTRATION_CLOSED_SCOPE_KEY, 'closed');
1844
1943
  }
1845
- return { resourceId: zitadelAppId };
1944
+ return {};
1846
1945
  }
1847
1946
  /** Lettermint's SMTP relay, as ZITADEL's SMTP provider wants it: `host:port`, STARTTLS. */
1848
1947
  const PLATFORM_AUTH_SMTP_HOST = 'smtp.lettermint.co:587';
@@ -2542,6 +2641,18 @@ async function mailBlockedReason(platform, mail, recordedIds) {
2542
2641
  * push it (see that function's own `heldMailKey` read) and clears the gap
2543
2642
  * for good.
2544
2643
  */
2644
+ /** Why the platform owner cannot be invited yet, or `null`. Fixed text: names no address. */
2645
+ export function ownerInviteBlockedReason(ownerCompany, hasInviteMail) {
2646
+ if (!ownerCompany || ownerCompany.owners.length === 0)
2647
+ return null;
2648
+ if (!ownerMailboxes(ownerCompany)[0]?.address) {
2649
+ return "the platform owner's invite is blocked, not planned: the owner has no work address (mailboxAddress), and the owner signs in as name@<the org's domain>";
2650
+ }
2651
+ if (!hasInviteMail) {
2652
+ return "the platform owner's invite is blocked, not planned: the host built the platform's auth dependencies without ownerInviteMail, so the first-sign-in link cannot be sent";
2653
+ }
2654
+ return null;
2655
+ }
2545
2656
  async function authBrokerBlockedReason(auth, recordedIds) {
2546
2657
  const brokerAppId = recordedIds[PLATFORM_AUTH_BROKER_SCOPE_KEY];
2547
2658
  if (!brokerAppId)
@@ -2723,8 +2834,6 @@ const PLATFORM_ARCHON_PROJECT_SCOPE_KEY = 'coolify_project_platform_archon';
2723
2834
  const PLATFORM_ARCHON_DB_SCOPE_KEY = 'coolify_platform_archon_database';
2724
2835
  const PLATFORM_ARCHON_TUNNEL_SCOPE_KEY = 'cloudflare_platform_archon_tunnel';
2725
2836
  const PLATFORM_ARCHON_ACCESS_SCOPE_KEY = 'cloudflare_platform_archon_access_app';
2726
- /** The platform's own ZITADEL org (`foundry`, the one `provisionPlatformAuth` mints its admin user in). */
2727
- export const PLATFORM_ZITADEL_ORG_SCOPE_KEY = 'zitadel_platform_org';
2728
2837
  const PLATFORM_ARCHON_ZITADEL_PROJECT_SCOPE_KEY = 'zitadel_platform_archon_project';
2729
2838
  /** The hostname the sign-in client's redirect URIs were made for; a change makes a new client. */
2730
2839
  const PLATFORM_ARCHON_CLIENT_HOSTNAME_SCOPE_KEY = 'zitadel_platform_archon_client_hostname';
@@ -2865,7 +2974,7 @@ async function switchOnArchonIdTokenUserinfo(context, deps) {
2865
2974
  * and `platform:integrations` are left for a further follow-up, since
2866
2975
  * #543 did not establish a reachable url for either.
2867
2976
  */
2868
- async function provisionPlatformArchon(context, deps) {
2977
+ async function provisionPlatformArchon(context, deps, ownerCompany = null) {
2869
2978
  const { platform } = context;
2870
2979
  const hostname = platform.archon.hostname;
2871
2980
  const gitRef = platform.archon.gitRef;
@@ -3046,10 +3155,13 @@ async function provisionPlatformArchon(context, deps) {
3046
3155
  clientId = created.clientId;
3047
3156
  }
3048
3157
  // ---- env ------------------------------------------------------------------
3158
+ // People sign in from the owner company's sign-in org. The project, client
3159
+ // and Access app above stay in the `foundry` org, which holds no people.
3160
+ const authOrgId = archonAuthOrganizationId(context.recordedIds()) ?? org.id;
3049
3161
  const envs = [
3050
3162
  { key: 'APP_URL', value: `https://${hostname}` },
3051
3163
  { key: 'AUTH_ISSUER', value: issuer },
3052
- { key: 'AUTH_ORGANIZATION_ID', value: org.id },
3164
+ { key: 'AUTH_ORGANIZATION_ID', value: authOrgId },
3053
3165
  { key: 'AUTH_CLIENT_ID', value: clientId },
3054
3166
  ];
3055
3167
  // #265: an owner URL or reporting site already on the app is kept as it is.
@@ -3073,6 +3185,24 @@ async function provisionPlatformArchon(context, deps) {
3073
3185
  const runtimeUrl = await ensureArchonRuntimeUrl(deps.coolify, appId, db.internal_db_url);
3074
3186
  if (runtimeUrl)
3075
3187
  envs.push({ key: 'DATABASE_URL', value: runtimeUrl });
3188
+ // Archon's way into the owner company's people store. Not there until that
3189
+ // company's Boule holds the peer password; `driftedSteps` then plans this
3190
+ // step again. Its host is the company's database, not Archon's, so
3191
+ // `assertSameDatabaseHost` is not applied to this key.
3192
+ const authzDatabaseUrl = ownerCompany
3193
+ ? await ownerAuthzDatabaseUrl(deps.coolify, ownerCompany, 'platform:archon')
3194
+ : null;
3195
+ if (authzDatabaseUrl)
3196
+ envs.push({ key: ARCHON_AUTHZ_DATABASE_URL_ENV, value: authzDatabaseUrl });
3197
+ // The invite form's secret and Boule's address, written together and only
3198
+ // once Boule holds the secret: the secret is Boule's, copied here.
3199
+ const bridge = ownerCompany
3200
+ ? await ownerBridge(deps.coolify, ownerCompany, 'platform:archon')
3201
+ : null;
3202
+ if (bridge) {
3203
+ envs.push({ key: BOULE_BRIDGE_SECRET_ENV, value: bridge.secret });
3204
+ envs.push({ key: ARCHON_BOULE_URL_ENV, value: bridge.url });
3205
+ }
3076
3206
  // #467: `envs` above only ever carries these four keys on the run that
3077
3207
  // generates or adopts them -- a later run that keeps them as they are
3078
3208
  // pushes none of it, so `envs` itself shrinks from one run to the next
@@ -3110,24 +3240,6 @@ async function provisionPlatformArchon(context, deps) {
3110
3240
  throw new Error(`platform:archon: app ${appId} lost its AUTH_APP_KEY between two reads`);
3111
3241
  }
3112
3242
  envs.push({ key: 'AUTH_APP_KEY', value: appKey });
3113
- // The owner `platform:auth` invited is Archon's first operator.
3114
- const bootstrapOperator = archonBootstrapOperator(recorded);
3115
- if (bootstrapOperator)
3116
- envs.push({ key: 'AUTHZ_BOOTSTRAP_OPERATOR', value: bootstrapOperator });
3117
- // #406: the invited platform owner's ZITADEL subject id, once
3118
- // `platform:auth` has recorded one -- the guard at the top of this
3119
- // function already requires `platform:auth` to have been applied before
3120
- // this step is reached, and `provisionPlatformAuth` records the owner
3121
- // invite (or, with no owner declared, the literal string 'none') as part
3122
- // of that same step, so by the time this runs the value here is whatever
3123
- // is actually true. `bootstrapOperator` (archon's `src/lib/authz/
3124
- // bootstrap.ts`) compares this verbatim against a signed-in user's
3125
- // subject id, so 'none' is skipped rather than written -- it would simply
3126
- // never match, but it is not a real id and should not look like one.
3127
- const ownerInviteUserId = recorded[PLATFORM_AUTH_OWNER_INVITE_SCOPE_KEY];
3128
- if (ownerInviteUserId && ownerInviteUserId !== 'none') {
3129
- envs.push({ key: 'AUTHZ_BOOTSTRAP_OPERATOR', value: ownerInviteUserId });
3130
- }
3131
3243
  // #501: a FILES_URL or FILES_AUDIENCE already on the app is kept (the #265
3132
3244
  // rule above), and hashed as the live value so it never reads as a changed
3133
3245
  // env.
@@ -3176,7 +3288,20 @@ async function provisionPlatformArchon(context, deps) {
3176
3288
  // deploy between this run's write and its confirmation).
3177
3289
  const envsHash = hashArchonEnvs(envs, archonEnvHashOverrides);
3178
3290
  const envsHashChanged = recorded[PLATFORM_ARCHON_ENVS_DEPLOYED_SCOPE_KEY] !== envsHash;
3179
- await deps.coolify.bulkUpsertEnvs(appId, envs);
3291
+ try {
3292
+ await deps.coolify.bulkUpsertEnvs(appId, envs);
3293
+ }
3294
+ catch (error) {
3295
+ // Every value this write carries that came from the owner company's Boule.
3296
+ const carried = [
3297
+ bridge?.secret,
3298
+ authzDatabaseUrl,
3299
+ authzDatabaseUrl ? passwordOf(authzDatabaseUrl) : undefined,
3300
+ ].filter((v) => Boolean(v));
3301
+ if (carried.length === 0)
3302
+ throw error;
3303
+ throw new Error(redactSecrets(error instanceof Error ? error.message : String(error), carried));
3304
+ }
3180
3305
  // #436 round 2: `bulkUpsertEnvs` only adds or updates, so the
3181
3306
  // `FILES_MANAGEMENT_KEY` this step used to write before the fix above
3182
3307
  // would otherwise survive on an Archon already deployed with it --
@@ -3198,7 +3323,9 @@ async function provisionPlatformArchon(context, deps) {
3198
3323
  for (const stale of await deps.coolify.listEnvs(appId)) {
3199
3324
  // foundry#542: a disabled Worker's token leaves Archon's env too.
3200
3325
  const staleUptime = !platform.uptime.enabled && stale.key === 'UPTIME_TOKEN';
3201
- if ((stale.key === 'FILES_MANAGEMENT_KEY' || staleUptime) && stale.uuid) {
3326
+ // People now come from the owner company's store, so a first operator set by an older apply goes.
3327
+ const staleOperator = stale.key === 'AUTHZ_BOOTSTRAP_OPERATOR';
3328
+ if ((stale.key === 'FILES_MANAGEMENT_KEY' || staleUptime || staleOperator) && stale.uuid) {
3202
3329
  await deps.coolify.deleteEnv(appId, stale.uuid);
3203
3330
  staleKeyDeleted = true;
3204
3331
  }
@@ -3229,13 +3356,16 @@ async function provisionPlatformArchon(context, deps) {
3229
3356
  // one. The first is kept (and updated below); the rest are dropped, so no
3230
3357
  // duplicate carries a stale key a broker client could still present.
3231
3358
  const archonMatchIndices = brokerApps
3232
- .map((a, i) => (a.organizationId === org.id && a.name === PLATFORM_ARCHON_ZITADEL_APP ? i : -1))
3359
+ .map((a, i) => (a.organizationId === authOrgId || a.organizationId === org.id) &&
3360
+ a.name === PLATFORM_ARCHON_ZITADEL_APP
3361
+ ? i
3362
+ : -1)
3233
3363
  .filter((i) => i >= 0);
3234
3364
  const archonEntryIndex = archonMatchIndices[0] ?? -1;
3235
3365
  const duplicateIndices = new Set(archonMatchIndices.slice(1));
3236
3366
  const archonEntry = {
3237
3367
  ...(archonEntryIndex >= 0 ? brokerApps[archonEntryIndex] : {}),
3238
- organizationId: org.id,
3368
+ organizationId: authOrgId,
3239
3369
  key: appKey,
3240
3370
  name: PLATFORM_ARCHON_ZITADEL_APP,
3241
3371
  clientIds: [clientId],
@@ -3466,7 +3596,7 @@ async function provisionPlatformArchon(context, deps) {
3466
3596
  // (whose AUTH_APPS never carried this registration) from one still holding
3467
3597
  // it.
3468
3598
  await record(PLATFORM_ARCHON_APPLIED_BROKER_SCOPE_KEY, brokerAppId);
3469
- await record(PLATFORM_ARCHON_APPLIED_OPERATOR_SCOPE_KEY, bootstrapOperator ?? 'none');
3599
+ await record(PLATFORM_ARCHON_APPLIED_AUTH_ORG_SCOPE_KEY, authOrgId);
3470
3600
  // The host runs inside this app: every marker is recorded above, so the
3471
3601
  // deploy goes last and is not waited for -- the old container stops during
3472
3602
  // it, and the response, the lease and the audit row must be done first.
@@ -3908,7 +4038,7 @@ async function archonAccessDrifted(deps, recorded, domain, emails, uptimeTokenId
3908
4038
  */
3909
4039
  async function archonBrokerRegistrationDrifted(coolify, recordedIds) {
3910
4040
  const brokerAppId = recordedIds[PLATFORM_AUTH_BROKER_SCOPE_KEY];
3911
- const orgId = recordedIds[PLATFORM_ZITADEL_ORG_SCOPE_KEY];
4041
+ const orgId = archonAuthOrganizationId(recordedIds);
3912
4042
  if (!brokerAppId || !orgId)
3913
4043
  return false;
3914
4044
  try {
@@ -3919,34 +4049,156 @@ async function archonBrokerRegistrationDrifted(coolify, recordedIds) {
3919
4049
  return false;
3920
4050
  }
3921
4051
  }
4052
+ /** The decoded password of a connection URL, or `undefined` when it has none or cannot be parsed. */
4053
+ function passwordOf(url) {
4054
+ try {
4055
+ const password = new URL(url).password;
4056
+ return password ? decodeURIComponent(password) : undefined;
4057
+ }
4058
+ catch (_error) {
4059
+ // Not a parseable URL: there is no password to mask beyond the whole string.
4060
+ return undefined;
4061
+ }
4062
+ }
3922
4063
  /**
3923
- * #406: whether the platform owner invite recorded under
3924
- * {@link PLATFORM_AUTH_OWNER_INVITE_SCOPE_KEY} is a real user id (not
3925
- * absent, not the literal `'none'` {@link provisionPlatformAuth} records
3926
- * when no owner is declared) that Archon's live env does not yet carry as
3927
- * `AUTHZ_BOOTSTRAP_OPERATOR`. `provisionPlatformAuth`'s own invite can
3928
- * record after Archon's `coolify:app` id already has -- the owner invite
3929
- * happens inside `platform:auth` only once its Lettermint token is held,
3930
- * which Archon's own apply does not wait on -- so without this, an owner
3931
- * invited after Archon's first deploy would need a redeploy by hand, the
3932
- * way companyincluded.com's did. A failed read is not drift, the same rule
3933
- * {@link archonAccessDrifted} and {@link archonBrokerRegistrationDrifted}
3934
- * follow: the next plan reads again.
4064
+ * The owner company's own Boule: the app named `<slug>-boule` in the
4065
+ * `production` environment of the Coolify project foundry made for that org
4066
+ * (marked with the org id). Never the first app of that name across Coolify,
4067
+ * which another org's hosting entry can also produce. `null` when the project
4068
+ * is missing or not foundry's for this org, or holds no such app.
3935
4069
  */
3936
- async function archonBootstrapOperatorDrifted(coolify, appId, recordedIds) {
3937
- const invitedUserId = recordedIds[PLATFORM_AUTH_OWNER_INVITE_SCOPE_KEY];
3938
- if (!appId || !invitedUserId || invitedUserId === 'none')
4070
+ async function findOwnerBoule(coolify, owner) {
4071
+ const project = await coolify.findProjectByName(owner.slug);
4072
+ if (!project || project.description !== coolifyProjectMark(owner))
4073
+ return null;
4074
+ const environment = (await coolify.listEnvironments(project.uuid)).find((e) => e.name === 'production');
4075
+ if (!environment)
4076
+ return null;
4077
+ return coolify.findApplicationByName(`${owner.slug}-boule`, { environmentIds: [environment.id] });
4078
+ }
4079
+ /** Archon's connection to the owner company's people store (role {@link BOULE_PEER_ROLE}). */
4080
+ const ARCHON_AUTHZ_DATABASE_URL_ENV = 'AUTHZ_DATABASE_URL';
4081
+ /**
4082
+ * Archon's `AUTHZ_DATABASE_URL`: the owner company's Boule database, as the
4083
+ * peer role with the password Boule's env holds. The host and database come
4084
+ * from that Boule's own `DATABASE_URL` (built from the database's internal
4085
+ * URL); only they are read from it, never its credentials. `null` while the
4086
+ * company's Boule, its password or its database URL does not exist yet. A
4087
+ * failed read throws a fixed message with the error's name, never its text,
4088
+ * which can carry Coolify's response.
4089
+ */
4090
+ async function ownerAuthzDatabaseUrl(coolify, owner, label) {
4091
+ let password;
4092
+ let databaseUrl;
4093
+ try {
4094
+ const boule = await findOwnerBoule(coolify, owner);
4095
+ if (!boule)
4096
+ return null;
4097
+ password = await currentEnvValue(coolify, boule.uuid, BOULE_PEER_PASSWORD_ENV);
4098
+ databaseUrl = await currentEnvValue(coolify, boule.uuid, 'DATABASE_URL');
4099
+ }
4100
+ catch (error) {
4101
+ throw new Error(`${label}: could not read the owner company's Boule env (${error instanceof Error ? error.name : 'error'})`);
4102
+ }
4103
+ if (!password || !databaseUrl)
4104
+ return null;
4105
+ let host;
4106
+ let pathname;
4107
+ let search;
4108
+ try {
4109
+ ({ host, pathname, search } = new URL(databaseUrl));
4110
+ }
4111
+ catch (error) {
4112
+ // Node's `ERR_INVALID_URL` carries the whole input, credential included.
4113
+ throw new Error(`${label}: the owner company's Boule DATABASE_URL could not be parsed (${error instanceof Error ? error.name : 'error'})`);
4114
+ }
4115
+ return `postgres://${BOULE_PEER_ROLE}:${encodeURIComponent(password)}@${host}${pathname}${search}`;
4116
+ }
4117
+ /** Where Archon's invite form calls Boule. */
4118
+ const ARCHON_BOULE_URL_ENV = 'BOULE_URL';
4119
+ /**
4120
+ * The invite form's secret (read from the owner company's Boule env, never
4121
+ * minted here) and the origin Archon calls Boule on. The origin is Boule's
4122
+ * public one, `https://<bouleHostnameFor>`: it is the `APP_URL` foundry already
4123
+ * writes to that Boule, derived from the declared org alone. Boule's internal
4124
+ * address would need its Coolify uuid and port, which foundry does not record,
4125
+ * and goes stale when the app is recreated. `null` while the company's Boule
4126
+ * does not hold the secret. A failed read throws a fixed message with the
4127
+ * error's name only.
4128
+ */
4129
+ async function ownerBridge(coolify, owner, label) {
4130
+ let secret;
4131
+ try {
4132
+ const boule = await findOwnerBoule(coolify, owner);
4133
+ if (!boule)
4134
+ return null;
4135
+ secret = await currentEnvValue(coolify, boule.uuid, BOULE_BRIDGE_SECRET_ENV);
4136
+ }
4137
+ catch (error) {
4138
+ throw new Error(`${label}: could not read the owner company's Boule env (${error instanceof Error ? error.name : 'error'})`);
4139
+ }
4140
+ if (!secret)
4141
+ return null;
4142
+ return { secret, url: `https://${bouleHostnameFor(owner)}` };
4143
+ }
4144
+ /**
4145
+ * Whether Archon's live `ARCHON_BRIDGE_SECRET` or `BOULE_URL` is not what the
4146
+ * owner company's Boule now implies. Nothing to compare while that Boule holds
4147
+ * no secret. A failed read is not drift: the next plan reads again.
4148
+ */
4149
+ async function archonBridgeDrifted(coolify, appId, owner) {
4150
+ if (!appId || !owner)
3939
4151
  return false;
3940
4152
  try {
3941
- const live = await currentEnvValue(coolify, appId, 'AUTHZ_BOOTSTRAP_OPERATOR');
3942
- return live !== invitedUserId;
4153
+ const wanted = await ownerBridge(coolify, owner, 'platform:archon');
4154
+ if (!wanted)
4155
+ return false;
4156
+ return ((await currentEnvValue(coolify, appId, BOULE_BRIDGE_SECRET_ENV)) !== wanted.secret ||
4157
+ (await currentEnvValue(coolify, appId, ARCHON_BOULE_URL_ENV)) !== wanted.url);
4158
+ }
4159
+ catch (_error) {
4160
+ // Nothing logged: the error can carry Coolify's response, and the values read are credentials.
4161
+ return false;
4162
+ }
4163
+ }
4164
+ /**
4165
+ * Whether Archon's live `AUTHZ_DATABASE_URL` is not the one the owner
4166
+ * company's Boule now allows. Nothing to compare while that Boule holds no
4167
+ * password. A failed read is not drift: the next plan reads again.
4168
+ */
4169
+ async function archonAuthzDatabaseDrifted(coolify, appId, owner) {
4170
+ if (!appId || !owner)
4171
+ return false;
4172
+ try {
4173
+ const wanted = await ownerAuthzDatabaseUrl(coolify, owner, 'platform:archon');
4174
+ if (!wanted)
4175
+ return false;
4176
+ return (await currentEnvValue(coolify, appId, ARCHON_AUTHZ_DATABASE_URL_ENV)) !== wanted;
4177
+ }
4178
+ catch (_error) {
4179
+ // Nothing logged: the error can carry Coolify's response, and the values read are credentials.
4180
+ return false;
4181
+ }
4182
+ }
4183
+ /**
4184
+ * Whether Archon's live `AUTH_ORGANIZATION_ID` is not the sign-in org it
4185
+ * should accept ({@link archonAuthOrganizationId}). Without this nothing
4186
+ * replans Archon when the owner company's org changes, since the step plans
4187
+ * only on hostname, `gitRef` and the Access list. A failed read is not drift.
4188
+ */
4189
+ async function archonAuthOrgDrifted(coolify, appId, recordedIds) {
4190
+ const expected = archonAuthOrganizationId(recordedIds);
4191
+ if (!appId || !expected)
4192
+ return false;
4193
+ try {
4194
+ return (await currentEnvValue(coolify, appId, 'AUTH_ORGANIZATION_ID')) !== expected;
3943
4195
  }
3944
4196
  catch {
3945
4197
  return false;
3946
4198
  }
3947
4199
  }
3948
4200
  /**
3949
- * #501: {@link archonBootstrapOperatorDrifted} for the files runner. The owner
4201
+ * #501: The files runner's check that it carries the owner's `AUTHZ_BOOTSTRAP_OPERATOR`. The owner
3950
4202
  * invite can record after the files step's app id does, so an owner invited
3951
4203
  * later still gets `AUTHZ_BOOTSTRAP_OPERATOR`. A failed read is not drift.
3952
4204
  */