@wtfalch/foundry 0.32.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, PLATFORM_ZITADEL_ORG_SCOPE_KEY, 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);
@@ -468,9 +478,18 @@ export class FoundryPlatformStepExecutor {
468
478
  ? recordedIds[PLATFORM_UPTIME_ACCESS_TOKEN_ID_SCOPE_KEY]
469
479
  : undefined);
470
480
  const brokerDrifted = !accessDrifted && (await archonBrokerRegistrationDrifted(this.archon.coolify, recordedIds));
471
- 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 &&
485
+ !brokerDrifted &&
486
+ !authOrgDrifted &&
487
+ (await archonAuthzDatabaseDrifted(this.archon.coolify, recordedIds[`coolify:app:${PLATFORM_ARCHON_KEY}`], await this.declaredOrgStore.get(platform.ownerOrgId)));
488
+ const bridgeDrifted = !accessDrifted &&
472
489
  !brokerDrifted &&
473
- (await archonBootstrapOperatorDrifted(this.archon.coolify, recordedIds[`coolify:app:${PLATFORM_ARCHON_KEY}`], recordedIds));
490
+ !authOrgDrifted &&
491
+ !authzDrifted &&
492
+ (await archonBridgeDrifted(this.archon.coolify, recordedIds[`coolify:app:${PLATFORM_ARCHON_KEY}`], await this.declaredOrgStore.get(platform.ownerOrgId)));
474
493
  // #480: re-reads the live app's rolling-update/health-check settings
475
494
  // even once `PLATFORM_ARCHON_APPLIED_ROLLING_UPDATE_SCOPE_KEY` is
476
495
  // recorded -- that marker only ever proves one past apply patched them,
@@ -479,7 +498,9 @@ export class FoundryPlatformStepExecutor {
479
498
  // back off.
480
499
  const rollingUpdateDrifted = !accessDrifted &&
481
500
  !brokerDrifted &&
482
- !bootstrapOperatorDrifted &&
501
+ !authOrgDrifted &&
502
+ !authzDrifted &&
503
+ !bridgeDrifted &&
483
504
  (await archonRollingUpdateDrifted(this.archon.coolify, recordedIds[`coolify:app:${PLATFORM_ARCHON_KEY}`]));
484
505
  // The deployment the step triggered failed or Coolify does not know it:
485
506
  // plan the step again, which deploys again. A finished one is confirmed
@@ -490,7 +511,9 @@ export class FoundryPlatformStepExecutor {
490
511
  (await archonDeploymentState(this.archon.coolify, deployment)) === 'failed';
491
512
  if (accessDrifted ||
492
513
  brokerDrifted ||
493
- bootstrapOperatorDrifted ||
514
+ authOrgDrifted ||
515
+ authzDrifted ||
516
+ bridgeDrifted ||
494
517
  rollingUpdateDrifted ||
495
518
  redeployFailed)
496
519
  result.archon = true;
@@ -562,7 +585,7 @@ export class FoundryPlatformStepExecutor {
562
585
  throw new Error('platform:auth needs FoundryPlatformStepExecutor to be constructed with its auth dependencies (#168)');
563
586
  }
564
587
  const company = await this.declaredOrgStore.get(context.platform.ownerOrgId);
565
- return provisionPlatformAuth(context, this.auth, company?.owners ?? []);
588
+ return provisionPlatformAuth(context, this.auth, company);
566
589
  }
567
590
  if (action.system === 'coolify' && action.kind === 'app' && action.key === PLATFORM_FILES_KEY) {
568
591
  if (!this.files) {
@@ -596,7 +619,8 @@ export class FoundryPlatformStepExecutor {
596
619
  if (!this.archon) {
597
620
  throw new Error('platform:archon needs FoundryPlatformStepExecutor to be constructed with its archon dependencies (#175)');
598
621
  }
599
- return provisionPlatformArchon(context, this.archon);
622
+ const ownerCompany = await this.declaredOrgStore.get(context.platform.ownerOrgId);
623
+ return provisionPlatformArchon(context, this.archon, ownerCompany);
600
624
  }
601
625
  if (action.system === 'coolify' && action.kind === 'app' && action.key === PLATFORM_MAIL_KEY) {
602
626
  if (!this.mail) {
@@ -1216,8 +1240,9 @@ async function liveLoginClientTokenHash(coolify, appId) {
1216
1240
  * #197: a `cloudflared` connector for this tunnel is run
1217
1241
  * ({@link ensureCloudflaredConnector}), so `hostname` answers.
1218
1242
  */
1219
- async function provisionPlatformAuth(context, deps, owners = []) {
1243
+ async function provisionPlatformAuth(context, deps, ownerCompany = null) {
1220
1244
  const { platform } = context;
1245
+ const owners = ownerCompany?.owners ?? [];
1221
1246
  const hostname = platform.auth.hostname;
1222
1247
  const gitRef = platform.auth.gitRef;
1223
1248
  const recorded = context.recordedIds();
@@ -1774,6 +1799,28 @@ async function provisionPlatformAuth(context, deps, owners = []) {
1774
1799
  // own doc comment, and `authLoginClientDrifted`'s, for why.
1775
1800
  await record(PLATFORM_AUTH_LOGIN_CLIENT_USER_SCOPE_KEY, loginClientRecordValue(loginClientUserId, loginClientPatId, loginClientToken));
1776
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
+ }
1777
1824
  // ---- ZITADEL mail and the owner's invite ----------------------------------
1778
1825
  // The token is the held key, else the broker's live env. Absent, nothing
1779
1826
  // here can run; `authBrokerBlockedReason` already reports it by name.
@@ -1804,37 +1851,53 @@ async function provisionPlatformAuth(context, deps, owners = []) {
1804
1851
  ]);
1805
1852
  throw new Error(`platform:auth: could not configure ZITADEL's SMTP provider for ${hostname}: ${cause}`);
1806
1853
  }
1807
- 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)) {
1808
1858
  const owner = owners[0];
1809
- if (!owner) {
1859
+ if (!owner || !ownerCompany || !ownerSignInOrgId) {
1810
1860
  // No owner declared: nothing to invite. Recorded so the step is not replanned for it.
1811
1861
  await record(PLATFORM_AUTH_OWNER_INVITE_SCOPE_KEY, 'none');
1812
1862
  }
1813
1863
  else {
1814
- const ownerOrgId = context.recordedIds()[PLATFORM_ZITADEL_ORG_SCOPE_KEY] ??
1815
- (await mailZitadel.findOrCreateOrg(PLATFORM_AUTH_ORG_NAME)).org.id;
1816
- await record(PLATFORM_ZITADEL_ORG_SCOPE_KEY, ownerOrgId);
1817
- let invitedUserId;
1818
- try {
1819
- // ZITADEL's default invite link is `/ui/v2/login/verify`, which 404s on the login app;
1820
- // it serves `/verify` at the root.
1821
- ({ userId: invitedUserId } = await mailZitadel.inviteOrgOwner(ownerOrgId, {
1822
- givenName: owner.givenName,
1823
- familyName: owner.familyName,
1824
- email: owner.email,
1825
- }, `https://${hostname}/verify?userId={{.UserID}}&code={{.Code}}&organization={{.OrgID}}&invite=true`));
1826
- }
1827
- catch (error) {
1828
- // The cause, with the owner's email and the Lettermint key redacted, so a failure says why.
1829
- const cause = redactSecrets(error instanceof Error ? error.message : String(error), [
1830
- owner.email,
1831
- owner.email.toLowerCase(),
1832
- mailToken,
1833
- ...(smtpPassword ? [smtpPassword] : []),
1834
- ]);
1835
- 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);
1836
1900
  }
1837
- await record(PLATFORM_AUTH_OWNER_INVITE_SCOPE_KEY, invitedUserId);
1838
1901
  }
1839
1902
  }
1840
1903
  }
@@ -2578,6 +2641,18 @@ async function mailBlockedReason(platform, mail, recordedIds) {
2578
2641
  * push it (see that function's own `heldMailKey` read) and clears the gap
2579
2642
  * for good.
2580
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
+ }
2581
2656
  async function authBrokerBlockedReason(auth, recordedIds) {
2582
2657
  const brokerAppId = recordedIds[PLATFORM_AUTH_BROKER_SCOPE_KEY];
2583
2658
  if (!brokerAppId)
@@ -2760,7 +2835,6 @@ const PLATFORM_ARCHON_DB_SCOPE_KEY = 'coolify_platform_archon_database';
2760
2835
  const PLATFORM_ARCHON_TUNNEL_SCOPE_KEY = 'cloudflare_platform_archon_tunnel';
2761
2836
  const PLATFORM_ARCHON_ACCESS_SCOPE_KEY = 'cloudflare_platform_archon_access_app';
2762
2837
  const PLATFORM_ARCHON_ZITADEL_PROJECT_SCOPE_KEY = 'zitadel_platform_archon_project';
2763
- export { PLATFORM_ZITADEL_ORG_SCOPE_KEY };
2764
2838
  /** The hostname the sign-in client's redirect URIs were made for; a change makes a new client. */
2765
2839
  const PLATFORM_ARCHON_CLIENT_HOSTNAME_SCOPE_KEY = 'zitadel_platform_archon_client_hostname';
2766
2840
  /** Archon's OIDC client id: public, not a secret, so a plain scope id. */
@@ -2900,7 +2974,7 @@ async function switchOnArchonIdTokenUserinfo(context, deps) {
2900
2974
  * and `platform:integrations` are left for a further follow-up, since
2901
2975
  * #543 did not establish a reachable url for either.
2902
2976
  */
2903
- async function provisionPlatformArchon(context, deps) {
2977
+ async function provisionPlatformArchon(context, deps, ownerCompany = null) {
2904
2978
  const { platform } = context;
2905
2979
  const hostname = platform.archon.hostname;
2906
2980
  const gitRef = platform.archon.gitRef;
@@ -3081,10 +3155,13 @@ async function provisionPlatformArchon(context, deps) {
3081
3155
  clientId = created.clientId;
3082
3156
  }
3083
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;
3084
3161
  const envs = [
3085
3162
  { key: 'APP_URL', value: `https://${hostname}` },
3086
3163
  { key: 'AUTH_ISSUER', value: issuer },
3087
- { key: 'AUTH_ORGANIZATION_ID', value: org.id },
3164
+ { key: 'AUTH_ORGANIZATION_ID', value: authOrgId },
3088
3165
  { key: 'AUTH_CLIENT_ID', value: clientId },
3089
3166
  ];
3090
3167
  // #265: an owner URL or reporting site already on the app is kept as it is.
@@ -3108,6 +3185,24 @@ async function provisionPlatformArchon(context, deps) {
3108
3185
  const runtimeUrl = await ensureArchonRuntimeUrl(deps.coolify, appId, db.internal_db_url);
3109
3186
  if (runtimeUrl)
3110
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
+ }
3111
3206
  // #467: `envs` above only ever carries these four keys on the run that
3112
3207
  // generates or adopts them -- a later run that keeps them as they are
3113
3208
  // pushes none of it, so `envs` itself shrinks from one run to the next
@@ -3145,24 +3240,6 @@ async function provisionPlatformArchon(context, deps) {
3145
3240
  throw new Error(`platform:archon: app ${appId} lost its AUTH_APP_KEY between two reads`);
3146
3241
  }
3147
3242
  envs.push({ key: 'AUTH_APP_KEY', value: appKey });
3148
- // The owner `platform:auth` invited is Archon's first operator.
3149
- const bootstrapOperator = archonBootstrapOperator(recorded);
3150
- if (bootstrapOperator)
3151
- envs.push({ key: 'AUTHZ_BOOTSTRAP_OPERATOR', value: bootstrapOperator });
3152
- // #406: the invited platform owner's ZITADEL subject id, once
3153
- // `platform:auth` has recorded one -- the guard at the top of this
3154
- // function already requires `platform:auth` to have been applied before
3155
- // this step is reached, and `provisionPlatformAuth` records the owner
3156
- // invite (or, with no owner declared, the literal string 'none') as part
3157
- // of that same step, so by the time this runs the value here is whatever
3158
- // is actually true. `bootstrapOperator` (archon's `src/lib/authz/
3159
- // bootstrap.ts`) compares this verbatim against a signed-in user's
3160
- // subject id, so 'none' is skipped rather than written -- it would simply
3161
- // never match, but it is not a real id and should not look like one.
3162
- const ownerInviteUserId = recorded[PLATFORM_AUTH_OWNER_INVITE_SCOPE_KEY];
3163
- if (ownerInviteUserId && ownerInviteUserId !== 'none') {
3164
- envs.push({ key: 'AUTHZ_BOOTSTRAP_OPERATOR', value: ownerInviteUserId });
3165
- }
3166
3243
  // #501: a FILES_URL or FILES_AUDIENCE already on the app is kept (the #265
3167
3244
  // rule above), and hashed as the live value so it never reads as a changed
3168
3245
  // env.
@@ -3211,7 +3288,20 @@ async function provisionPlatformArchon(context, deps) {
3211
3288
  // deploy between this run's write and its confirmation).
3212
3289
  const envsHash = hashArchonEnvs(envs, archonEnvHashOverrides);
3213
3290
  const envsHashChanged = recorded[PLATFORM_ARCHON_ENVS_DEPLOYED_SCOPE_KEY] !== envsHash;
3214
- 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
+ }
3215
3305
  // #436 round 2: `bulkUpsertEnvs` only adds or updates, so the
3216
3306
  // `FILES_MANAGEMENT_KEY` this step used to write before the fix above
3217
3307
  // would otherwise survive on an Archon already deployed with it --
@@ -3233,7 +3323,9 @@ async function provisionPlatformArchon(context, deps) {
3233
3323
  for (const stale of await deps.coolify.listEnvs(appId)) {
3234
3324
  // foundry#542: a disabled Worker's token leaves Archon's env too.
3235
3325
  const staleUptime = !platform.uptime.enabled && stale.key === 'UPTIME_TOKEN';
3236
- 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) {
3237
3329
  await deps.coolify.deleteEnv(appId, stale.uuid);
3238
3330
  staleKeyDeleted = true;
3239
3331
  }
@@ -3264,13 +3356,16 @@ async function provisionPlatformArchon(context, deps) {
3264
3356
  // one. The first is kept (and updated below); the rest are dropped, so no
3265
3357
  // duplicate carries a stale key a broker client could still present.
3266
3358
  const archonMatchIndices = brokerApps
3267
- .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)
3268
3363
  .filter((i) => i >= 0);
3269
3364
  const archonEntryIndex = archonMatchIndices[0] ?? -1;
3270
3365
  const duplicateIndices = new Set(archonMatchIndices.slice(1));
3271
3366
  const archonEntry = {
3272
3367
  ...(archonEntryIndex >= 0 ? brokerApps[archonEntryIndex] : {}),
3273
- organizationId: org.id,
3368
+ organizationId: authOrgId,
3274
3369
  key: appKey,
3275
3370
  name: PLATFORM_ARCHON_ZITADEL_APP,
3276
3371
  clientIds: [clientId],
@@ -3501,7 +3596,7 @@ async function provisionPlatformArchon(context, deps) {
3501
3596
  // (whose AUTH_APPS never carried this registration) from one still holding
3502
3597
  // it.
3503
3598
  await record(PLATFORM_ARCHON_APPLIED_BROKER_SCOPE_KEY, brokerAppId);
3504
- await record(PLATFORM_ARCHON_APPLIED_OPERATOR_SCOPE_KEY, bootstrapOperator ?? 'none');
3599
+ await record(PLATFORM_ARCHON_APPLIED_AUTH_ORG_SCOPE_KEY, authOrgId);
3505
3600
  // The host runs inside this app: every marker is recorded above, so the
3506
3601
  // deploy goes last and is not waited for -- the old container stops during
3507
3602
  // it, and the response, the lease and the audit row must be done first.
@@ -3943,7 +4038,7 @@ async function archonAccessDrifted(deps, recorded, domain, emails, uptimeTokenId
3943
4038
  */
3944
4039
  async function archonBrokerRegistrationDrifted(coolify, recordedIds) {
3945
4040
  const brokerAppId = recordedIds[PLATFORM_AUTH_BROKER_SCOPE_KEY];
3946
- const orgId = recordedIds[PLATFORM_ZITADEL_ORG_SCOPE_KEY];
4041
+ const orgId = archonAuthOrganizationId(recordedIds);
3947
4042
  if (!brokerAppId || !orgId)
3948
4043
  return false;
3949
4044
  try {
@@ -3954,34 +4049,156 @@ async function archonBrokerRegistrationDrifted(coolify, recordedIds) {
3954
4049
  return false;
3955
4050
  }
3956
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
+ }
3957
4063
  /**
3958
- * #406: whether the platform owner invite recorded under
3959
- * {@link PLATFORM_AUTH_OWNER_INVITE_SCOPE_KEY} is a real user id (not
3960
- * absent, not the literal `'none'` {@link provisionPlatformAuth} records
3961
- * when no owner is declared) that Archon's live env does not yet carry as
3962
- * `AUTHZ_BOOTSTRAP_OPERATOR`. `provisionPlatformAuth`'s own invite can
3963
- * record after Archon's `coolify:app` id already has -- the owner invite
3964
- * happens inside `platform:auth` only once its Lettermint token is held,
3965
- * which Archon's own apply does not wait on -- so without this, an owner
3966
- * invited after Archon's first deploy would need a redeploy by hand, the
3967
- * way companyincluded.com's did. A failed read is not drift, the same rule
3968
- * {@link archonAccessDrifted} and {@link archonBrokerRegistrationDrifted}
3969
- * 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.
3970
4069
  */
3971
- async function archonBootstrapOperatorDrifted(coolify, appId, recordedIds) {
3972
- const invitedUserId = recordedIds[PLATFORM_AUTH_OWNER_INVITE_SCOPE_KEY];
3973
- 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)
3974
4151
  return false;
3975
4152
  try {
3976
- const live = await currentEnvValue(coolify, appId, 'AUTHZ_BOOTSTRAP_OPERATOR');
3977
- 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;
3978
4195
  }
3979
4196
  catch {
3980
4197
  return false;
3981
4198
  }
3982
4199
  }
3983
4200
  /**
3984
- * #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
3985
4202
  * invite can record after the files step's app id does, so an owner invited
3986
4203
  * later still gets `AUTHZ_BOOTSTRAP_OPERATOR`. A failed read is not drift.
3987
4204
  */