@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.
- package/dist/bootstrap/bootstrap.d.ts +18 -1
- package/dist/bootstrap/bootstrap.js +167 -28
- package/dist/bootstrap/declare.d.ts +5 -1
- package/dist/bootstrap/declare.js +3 -1
- package/dist/bootstrap/index.d.ts +1 -1
- package/dist/index.d.ts +4 -2
- package/dist/index.js +1 -0
- package/dist/org.d.ts +6 -2
- package/dist/org.js +16 -0
- package/dist/plan/boule.d.ts +24 -1
- package/dist/plan/boule.js +29 -1
- package/dist/plan/diff.d.ts +3 -1
- package/dist/plan/diff.js +125 -9
- package/dist/plan/executor.d.ts +65 -7
- package/dist/plan/executor.js +144 -18
- package/dist/plan/health-targets.js +1 -1
- package/dist/plan/index.d.ts +2 -0
- package/dist/plan/index.js +1 -0
- package/dist/plan/plan-apply.d.ts +23 -0
- package/dist/plan/plan-apply.js +11 -0
- package/dist/plan/platform-diff.d.ts +33 -12
- package/dist/plan/platform-diff.js +95 -31
- package/dist/plan/platform-org.d.ts +2 -0
- package/dist/plan/platform-org.js +2 -0
- package/dist/plan/platform-providers.d.ts +51 -4
- package/dist/plan/platform-providers.js +296 -79
- package/dist/plan/sign-in-org.d.ts +99 -0
- package/dist/plan/sign-in-org.js +133 -0
- package/dist/plan.d.ts +18 -0
- package/package.json +1 -1
|
@@ -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,
|
|
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
|
|
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
|
-
|
|
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
|
-
!
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
|
1815
|
-
|
|
1816
|
-
|
|
1817
|
-
|
|
1818
|
-
|
|
1819
|
-
|
|
1820
|
-
|
|
1821
|
-
|
|
1822
|
-
|
|
1823
|
-
|
|
1824
|
-
|
|
1825
|
-
|
|
1826
|
-
|
|
1827
|
-
|
|
1828
|
-
|
|
1829
|
-
|
|
1830
|
-
|
|
1831
|
-
|
|
1832
|
-
|
|
1833
|
-
|
|
1834
|
-
|
|
1835
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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 ===
|
|
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:
|
|
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(
|
|
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
|
|
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
|
-
*
|
|
3959
|
-
*
|
|
3960
|
-
*
|
|
3961
|
-
*
|
|
3962
|
-
*
|
|
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
|
|
3972
|
-
const
|
|
3973
|
-
if (!
|
|
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
|
|
3977
|
-
|
|
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:
|
|
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
|
*/
|