@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.
- package/dist/apply.d.ts +6 -1
- 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 +6 -4
- package/dist/index.js +2 -1
- package/dist/org.d.ts +12 -3
- package/dist/org.js +19 -3
- package/dist/plan/boule.d.ts +24 -1
- package/dist/plan/boule.js +29 -1
- package/dist/plan/confirm-upgrade.d.ts +17 -0
- package/dist/plan/confirm-upgrade.js +47 -0
- package/dist/plan/diff.d.ts +3 -1
- package/dist/plan/diff.js +174 -12
- package/dist/plan/executor.d.ts +65 -7
- package/dist/plan/executor.js +164 -45
- package/dist/plan/health-targets.js +1 -1
- package/dist/plan/index.d.ts +5 -2
- package/dist/plan/index.js +2 -1
- package/dist/plan/plan-apply.d.ts +40 -2
- package/dist/plan/plan-apply.js +43 -2
- package/dist/plan/platform-diff.d.ts +39 -10
- package/dist/plan/platform-diff.js +135 -33
- package/dist/plan/platform-org.d.ts +2 -0
- package/dist/plan/platform-org.js +2 -0
- package/dist/plan/platform-providers.d.ts +53 -7
- package/dist/plan/platform-providers.js +351 -99
- package/dist/plan/sign-in-org.d.ts +99 -0
- package/dist/plan/sign-in-org.js +133 -0
- package/dist/plan/version-status.d.ts +1 -2
- package/dist/plan.d.ts +30 -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);
|
|
@@ -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
|
|
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
|
-
|
|
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
|
-
!
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
|
1797
|
-
|
|
1798
|
-
|
|
1799
|
-
|
|
1800
|
-
|
|
1801
|
-
|
|
1802
|
-
|
|
1803
|
-
|
|
1804
|
-
|
|
1805
|
-
|
|
1806
|
-
|
|
1807
|
-
|
|
1808
|
-
|
|
1809
|
-
|
|
1810
|
-
|
|
1811
|
-
|
|
1812
|
-
|
|
1813
|
-
|
|
1814
|
-
|
|
1815
|
-
|
|
1816
|
-
|
|
1817
|
-
|
|
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
|
-
|
|
1824
|
-
|
|
1825
|
-
|
|
1826
|
-
|
|
1827
|
-
|
|
1828
|
-
|
|
1829
|
-
|
|
1830
|
-
|
|
1831
|
-
|
|
1832
|
-
|
|
1833
|
-
|
|
1834
|
-
|
|
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
|
-
|
|
1837
|
-
|
|
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
|
-
|
|
1841
|
-
|
|
1842
|
-
|
|
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 {
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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 ===
|
|
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:
|
|
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(
|
|
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
|
|
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
|
-
*
|
|
3924
|
-
*
|
|
3925
|
-
*
|
|
3926
|
-
*
|
|
3927
|
-
*
|
|
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
|
|
3937
|
-
const
|
|
3938
|
-
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)
|
|
3939
4151
|
return false;
|
|
3940
4152
|
try {
|
|
3941
|
-
const
|
|
3942
|
-
|
|
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:
|
|
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
|
*/
|