@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
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { VersionChangeItem } from '../plan.js';
|
|
2
|
+
export interface UnconfirmedUpgrade {
|
|
3
|
+
service: string;
|
|
4
|
+
from?: string;
|
|
5
|
+
to?: string;
|
|
6
|
+
}
|
|
7
|
+
/**
|
|
8
|
+
* Whether a change of `service` from `from` to `to` needs a confirmation:
|
|
9
|
+
* any change on a stateful service, otherwise a major or minor difference.
|
|
10
|
+
* A git ref or branch name has no major or minor, so it never counts as a jump.
|
|
11
|
+
*/
|
|
12
|
+
export declare function versionChangeNeedsConfirmation(service: string, from: string, to: string): boolean;
|
|
13
|
+
/**
|
|
14
|
+
* The lines that need a confirmation `confirmUpgrade` does not give. A name
|
|
15
|
+
* must match `VersionChangeItem.service` exactly; no value confirms all.
|
|
16
|
+
*/
|
|
17
|
+
export declare function unconfirmedUpgrades(versionChanges: readonly VersionChangeItem[] | undefined, confirmUpgrade: readonly string[] | undefined): UnconfirmedUpgrade[];
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `VersionChangeItem.service` names whose data the service migrates on
|
|
3
|
+
* first boot with a new image, so going back to the old image may not undo
|
|
4
|
+
* it. Stalwart is reported as `mail`, the name of its plan step. Postgres
|
|
5
|
+
* has no version-change line yet; its name is here for when it gets one.
|
|
6
|
+
*/
|
|
7
|
+
const STATEFUL_VERSION_CHANGE_SERVICES = new Set([
|
|
8
|
+
'zitadel',
|
|
9
|
+
'mail',
|
|
10
|
+
'synapse',
|
|
11
|
+
'mas',
|
|
12
|
+
'postgres',
|
|
13
|
+
]);
|
|
14
|
+
function majorMinor(version) {
|
|
15
|
+
const match = /^v?(\d+)\.(\d+)\.\d+/.exec(version.trim());
|
|
16
|
+
return match ? `${match[1]}.${match[2]}` : null;
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Whether a change of `service` from `from` to `to` needs a confirmation:
|
|
20
|
+
* any change on a stateful service, otherwise a major or minor difference.
|
|
21
|
+
* A git ref or branch name has no major or minor, so it never counts as a jump.
|
|
22
|
+
*/
|
|
23
|
+
export function versionChangeNeedsConfirmation(service, from, to) {
|
|
24
|
+
if (from === to)
|
|
25
|
+
return false;
|
|
26
|
+
if (STATEFUL_VERSION_CHANGE_SERVICES.has(service))
|
|
27
|
+
return true;
|
|
28
|
+
const a = majorMinor(from);
|
|
29
|
+
const b = majorMinor(to);
|
|
30
|
+
return a !== null && b !== null && a !== b;
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* The lines that need a confirmation `confirmUpgrade` does not give. A name
|
|
34
|
+
* must match `VersionChangeItem.service` exactly; no value confirms all.
|
|
35
|
+
*/
|
|
36
|
+
export function unconfirmedUpgrades(versionChanges, confirmUpgrade) {
|
|
37
|
+
const confirmed = new Set(confirmUpgrade ?? []);
|
|
38
|
+
const seen = new Set();
|
|
39
|
+
const result = [];
|
|
40
|
+
for (const line of versionChanges ?? []) {
|
|
41
|
+
if (!line.needsConfirmation || confirmed.has(line.service) || seen.has(line.service))
|
|
42
|
+
continue;
|
|
43
|
+
seen.add(line.service);
|
|
44
|
+
result.push({ service: line.service, from: line.from, to: line.to });
|
|
45
|
+
}
|
|
46
|
+
return result;
|
|
47
|
+
}
|
package/dist/plan/diff.d.ts
CHANGED
|
@@ -55,6 +55,8 @@ export interface PlanOrgOptions {
|
|
|
55
55
|
archonUrl?: string | null;
|
|
56
56
|
/** #318: the executor was built with a `WorkersPort`, read by `planWorkers`. */
|
|
57
57
|
edgeWorkers?: boolean;
|
|
58
|
+
/** The executor was built with an `ownerInviteMail` port; without it each owner's invite is blocked. */
|
|
59
|
+
ownerInviteMail?: boolean;
|
|
58
60
|
/** The platform's own service hostnames: `edgeReservedLabels` reserves them under the org's domain, and a wildcard Worker route (`planWorkers`) keeps them out. */
|
|
59
61
|
platformHostnames?: readonly string[];
|
|
60
62
|
}
|
|
@@ -134,7 +136,7 @@ export declare function computePlanDetail(org: DeclaredOrg, live: LiveResource[]
|
|
|
134
136
|
* computed `auth.<domain>` against, so the platform's own org never plans
|
|
135
137
|
* its login host equal to the shared ZITADEL instance's own.
|
|
136
138
|
*/
|
|
137
|
-
export declare function isPlatformOwnOrg(org: DeclaredOrg,
|
|
139
|
+
export declare function isPlatformOwnOrg(org: Pick<DeclaredOrg, 'id'>, platformOwnerOrgId: string | null): boolean;
|
|
138
140
|
/**
|
|
139
141
|
* What {@link planOrgPostgres} reports about the org's one shared Postgres
|
|
140
142
|
* server and its fixed schema topology (#444a, "manifest-and-plan-only"):
|
package/dist/plan/diff.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { VERSIONS, floatingTagBlocked } from '../bootstrap/versions.js';
|
|
2
2
|
import { APP_ENTRY_KEY, ORG_POSTGRES_SERVER_SCOPE_KEY, ORG_SERVICE_SCHEMAS, ownerMailboxRecorded, ownerMailboxes, } from '../org.js';
|
|
3
3
|
import { AGORA_ACTIVATED_ENV_SCOPE_KEY, AGORA_APP_PORTS_EXPOSES, CALLS_INTROSPECTION_SCOPE_KEY, CALLS_INTROSPECTION_UNDELIVERED_SCOPE_KEY, CALLS_SERVICE_DEPLOYED_SCOPE_KEY, CHAT_CALLS_APPLIED_SCOPE_KEY, CHAT_CALLS_TOKEN_SCOPE_KEY, CHAT_CALLS_TOKEN_UNDELIVERED_SCOPE_KEY, CHAT_INTROSPECTION_SCOPE_KEY, NO_AGORA_HOST_VALUES, ZITADEL_ORG_SCOPE_KEY, agoraEnvDrift, agoraEnvRevision, agoraHostMissing, agoraHostnameFor, callsServiceHasName, callsServiceHasTokenEnv, callsTokenEnvMovedMessage, callsTokenHostFrom, chatCallsApplied, chatHasUpstreams, desiredAgoraEnv, withRecordedMessagingProject, } from './agora.js';
|
|
4
|
-
import { BOULE_BROKER_ENTRY_KEY, BOULE_MAIL_TOKEN_ENV, bouleEnvDrift, bouleMailTokenDrift, bouleMailWanted, desiredBouleEnv, wantedBouleBrokerEntry, } from './boule.js';
|
|
4
|
+
import { BOULE_BRIDGE_SECRET_ENV, BOULE_BROKER_ENTRY_KEY, BOULE_MAIL_TOKEN_ENV, BOULE_PEER_PASSWORD_ENV, BOULE_SECRETS_RESTARTED_SCOPE_KEY, bouleEnvDrift, bouleMailTokenDrift, bouleMailWanted, desiredBouleEnv, wantedBouleBrokerEntry, } from './boule.js';
|
|
5
5
|
import { brokerEntryAppliedScopeKey, brokerEntryWarningsScopeKey, entryAuthEnv, entryAuthEnvAppliedScopeKey, wantedBrokerEntry, } from './broker-entries.js';
|
|
6
6
|
import { chatInternalNames, chatUpstreams } from './chat-matrix.js';
|
|
7
7
|
import { planEntryAiKeys } from './entry-ai-key.js';
|
|
@@ -16,6 +16,7 @@ import { CALLS_MAS_APPLIED_IMAGE_TAG_SCOPE_KEY, CALLS_SYNAPSE_APPLIED_IMAGE_TAG_
|
|
|
16
16
|
import { DEFAULT_ORG_PLATFORM_READINESS, } from './platform-org.js';
|
|
17
17
|
import { PLATFORM_AUTH_BROKER_MAIL_KEY_REF } from './platform-providers.js';
|
|
18
18
|
import { appLabelsAppliedScopeKey, entryLabelHash, entryLabelSpec } from './proxy-labels.js';
|
|
19
|
+
import { ZITADEL_ORG_DOMAIN_BLOCKED_SCOPE_KEY, ZITADEL_ORG_DOMAIN_SCOPE_KEY, ZITADEL_ORG_REGISTRATION_CLOSED_SCOPE_KEY, signInDomainBlockedText, signInOrgDomain, } from './sign-in-org.js';
|
|
19
20
|
import { withStepSystem } from './step-system.js';
|
|
20
21
|
import { planWorkers } from './worker.js';
|
|
21
22
|
/** The `Plan` (#19's public contract) a `PlanDetail` carries. */
|
|
@@ -165,11 +166,19 @@ export function computePlanDetail(org, live, recordedIds = {}, agoraHost = NO_AG
|
|
|
165
166
|
// The platform's own org: its domain is the platform apex (the same fact
|
|
166
167
|
// #491 reads for mail). It signs in on the shared instance's own host, so
|
|
167
168
|
// `issuer.usesDomain` never moves it.
|
|
168
|
-
const isPlatformOrg = isPlatformOwnOrg(org, platformReadiness.
|
|
169
|
+
const isPlatformOrg = isPlatformOwnOrg(org, platformReadiness.platformOwnerOrgId);
|
|
169
170
|
const issuer = planIssuer(org, byKey, claimed, platformReadiness.authReady, isPlatformOrg);
|
|
170
171
|
if (issuer.step)
|
|
171
172
|
plannedSteps.push(issuer.step);
|
|
172
|
-
|
|
173
|
+
// Before the domain and the owners: no invite runs on an org still open to registration.
|
|
174
|
+
const registrationClosed = planOrgRegistrationClosed(org, recordedIds, platformReadiness.authReady, issuer.step);
|
|
175
|
+
if (registrationClosed)
|
|
176
|
+
plannedSteps.push(registrationClosed);
|
|
177
|
+
const orgDomain = planOrgDomain(org, recordedIds, platformReadiness.authReady, issuer.step);
|
|
178
|
+
if (orgDomain)
|
|
179
|
+
plannedSteps.push(orgDomain);
|
|
180
|
+
const owners = planOwners(org, recordedIds, platformReadiness.authReady, platformReadiness.authIssuer, options.ownerInviteMail ?? false, registrationClosed !== null);
|
|
181
|
+
plannedSteps.push(...owners.steps);
|
|
173
182
|
// #267: after the ZITADEL org step, before any app's own issuer
|
|
174
183
|
// application. Apps created later in this run are not in the binding yet;
|
|
175
184
|
// the next run replans this step and repoints them.
|
|
@@ -506,6 +515,10 @@ export function computePlanDetail(org, live, recordedIds = {}, agoraHost = NO_AG
|
|
|
506
515
|
// budget, key) would otherwise also have nothing to act on.
|
|
507
516
|
if (issuer.blocked)
|
|
508
517
|
unmanaged.push({ description: issuer.blocked, key: 'zitadel:issuer' });
|
|
518
|
+
unmanaged.push(...owners.blocked);
|
|
519
|
+
const domainBlocked = orgDomainBlocked(org, recordedIds);
|
|
520
|
+
if (domainBlocked)
|
|
521
|
+
unmanaged.push(domainBlocked);
|
|
509
522
|
// #267 (rule 2): a login host with no ZITADEL org to trust it in is blocked, not planned to throw.
|
|
510
523
|
if (loginHost.blocked) {
|
|
511
524
|
unmanaged.push({ description: loginHost.blocked, key: 'zitadel:login-host' });
|
|
@@ -698,6 +711,70 @@ function planIssuer(org, byKey, claimed, authReady, isPlatformOrg = false) {
|
|
|
698
711
|
blocked: null,
|
|
699
712
|
};
|
|
700
713
|
}
|
|
714
|
+
/**
|
|
715
|
+
* Closes self-registration on a ZITADEL org that already exists and has no
|
|
716
|
+
* recorded close: one made before foundry closed it, or one whose close failed
|
|
717
|
+
* inside the issuer step. A fresh org is closed inside the issuer step, so
|
|
718
|
+
* nothing is planned beside that step. While this step is planned no owner is
|
|
719
|
+
* invited ({@link planOwners}).
|
|
720
|
+
*/
|
|
721
|
+
function planOrgRegistrationClosed(org, recordedIds, authReady, issuerStep) {
|
|
722
|
+
if (!authReady)
|
|
723
|
+
return null;
|
|
724
|
+
if (issuerStep?.action.desired.provision === true)
|
|
725
|
+
return null;
|
|
726
|
+
if (!recordedIds.zitadel_org)
|
|
727
|
+
return null;
|
|
728
|
+
if (recordedIds[ZITADEL_ORG_REGISTRATION_CLOSED_SCOPE_KEY] === recordedIds.zitadel_org) {
|
|
729
|
+
return null;
|
|
730
|
+
}
|
|
731
|
+
return {
|
|
732
|
+
action: { system: 'zitadel', kind: 'registration-closed', key: org.id, desired: {} },
|
|
733
|
+
step: {
|
|
734
|
+
description: `switch self-registration off on ${org.slug}'s sign-in org`,
|
|
735
|
+
destroys: false,
|
|
736
|
+
migrates: false,
|
|
737
|
+
},
|
|
738
|
+
};
|
|
739
|
+
}
|
|
740
|
+
/** The blocked item for an org sign-in domain the sign-in server refused to make primary. */
|
|
741
|
+
function orgDomainBlocked(org, recordedIds) {
|
|
742
|
+
const domain = signInOrgDomain(org);
|
|
743
|
+
if (recordedIds[ZITADEL_ORG_DOMAIN_BLOCKED_SCOPE_KEY]?.toLowerCase() !== domain)
|
|
744
|
+
return null;
|
|
745
|
+
if (recordedIds[ZITADEL_ORG_DOMAIN_SCOPE_KEY]?.toLowerCase() === domain)
|
|
746
|
+
return null;
|
|
747
|
+
return { description: signInDomainBlockedText(org.slug, domain), key: 'zitadel:domain' };
|
|
748
|
+
}
|
|
749
|
+
/**
|
|
750
|
+
* Registers the org's sign-in domain (its own, or `<slug>.oikos.host`) on its ZITADEL org
|
|
751
|
+
* and makes it primary, so an owner signs in as `name@<domain>`. Idempotent on the
|
|
752
|
+
* *recorded* `zitadel_org_domain`: a changed declared domain plans it again. A fresh
|
|
753
|
+
* org gets its domain inside the issuer step, so nothing is planned beside that step.
|
|
754
|
+
* Nothing is planned until the ZITADEL org exists.
|
|
755
|
+
*/
|
|
756
|
+
function planOrgDomain(org, recordedIds, authReady, issuerStep) {
|
|
757
|
+
if (!authReady)
|
|
758
|
+
return null;
|
|
759
|
+
if (issuerStep?.action.desired.provision === true)
|
|
760
|
+
return null;
|
|
761
|
+
if (!recordedIds.zitadel_org)
|
|
762
|
+
return null;
|
|
763
|
+
const domain = signInOrgDomain(org);
|
|
764
|
+
if (recordedIds[ZITADEL_ORG_DOMAIN_SCOPE_KEY]?.toLowerCase() === domain)
|
|
765
|
+
return null;
|
|
766
|
+
// Refused once: reported as blocked (see `orgDomainBlocked`), not planned again.
|
|
767
|
+
if (recordedIds[ZITADEL_ORG_DOMAIN_BLOCKED_SCOPE_KEY]?.toLowerCase() === domain)
|
|
768
|
+
return null;
|
|
769
|
+
return {
|
|
770
|
+
action: { system: 'zitadel', kind: 'domain', key: org.id, desired: { domain } },
|
|
771
|
+
step: {
|
|
772
|
+
description: `register ${domain} as ${org.slug}'s sign-in domain`,
|
|
773
|
+
destroys: false,
|
|
774
|
+
migrates: false,
|
|
775
|
+
},
|
|
776
|
+
};
|
|
777
|
+
}
|
|
701
778
|
/**
|
|
702
779
|
* The org's owners, one step each, each invited by ZITADEL email once the org's ZITADEL org
|
|
703
780
|
* exists (goal line: "the org's owner is invited by ZITADEL email and sets a
|
|
@@ -718,6 +795,11 @@ function planIssuer(org, byKey, claimed, authReady, isPlatformOrg = false) {
|
|
|
718
795
|
* issuer step immediately before it in the same run: `plan-apply.ts`'s
|
|
719
796
|
* `runSteps` awaits each planned step in array order, one at a time.
|
|
720
797
|
*
|
|
798
|
+
* An owner signs in as `name@<org domain>`, so each unrecorded owner needs a work
|
|
799
|
+
* address (`mailboxAddress`, or `mail.ownerMailboxAddress` for the first owner) and the
|
|
800
|
+
* host needs an `ownerInviteMail` port to send the first-sign-in link to the personal
|
|
801
|
+
* address. Without either, that owner is a blocked item named by position (rule 2).
|
|
802
|
+
*
|
|
721
803
|
* `desired` carries the owner's email so the executor can pass it to
|
|
722
804
|
* `ZitadelClient.inviteOrgOwner` — but the step's own `description` never
|
|
723
805
|
* does. `Plan` (the public contract `AuditLog.recordPlan`/`recordApply`
|
|
@@ -726,19 +808,40 @@ function planIssuer(org, byKey, claimed, authReady, isPlatformOrg = false) {
|
|
|
726
808
|
* nothing here puts it anywhere beyond what audit already records for the
|
|
727
809
|
* org.
|
|
728
810
|
*/
|
|
729
|
-
function planOwners(org, recordedIds, authReady, authIssuer) {
|
|
811
|
+
function planOwners(org, recordedIds, authReady, authIssuer, ownerInviteMail, registrationOpen = false) {
|
|
812
|
+
const steps = [];
|
|
813
|
+
const blocked = [];
|
|
814
|
+
// The close step is planned in this plan; the invites come with the next one, after it succeeded.
|
|
815
|
+
if (registrationOpen)
|
|
816
|
+
return { steps, blocked };
|
|
730
817
|
// #177: nothing to invite into yet -- `planIssuer` already reports the
|
|
731
818
|
// platform's sign-in as blocked, so these steps stay silent rather than
|
|
732
819
|
// repeating it.
|
|
733
820
|
if (!authReady)
|
|
734
|
-
return
|
|
735
|
-
const
|
|
821
|
+
return { steps, blocked };
|
|
822
|
+
const mailboxes = ownerMailboxes(org);
|
|
736
823
|
org.owners.forEach((owner, index) => {
|
|
737
824
|
// Every owner is keyed by its lowercased email (as `validateOwners` compares
|
|
738
825
|
// it), so reordering the list never moves a key onto another person.
|
|
739
826
|
const key = `${org.id}:${owner.email.toLowerCase()}`;
|
|
740
827
|
if (recordedIds[resourceKey('zitadel', 'owner', key)])
|
|
741
828
|
return; // already invited.
|
|
829
|
+
const position = index + 1;
|
|
830
|
+
const mailboxAddress = mailboxes[index]?.address;
|
|
831
|
+
if (!mailboxAddress) {
|
|
832
|
+
blocked.push({
|
|
833
|
+
description: `${org.slug}'s owner ${position} is blocked, not planned: no mailboxAddress is declared, and the owner signs in as name@<the org's domain>`,
|
|
834
|
+
key: `zitadel:owner:${position}`,
|
|
835
|
+
});
|
|
836
|
+
return;
|
|
837
|
+
}
|
|
838
|
+
if (!ownerInviteMail) {
|
|
839
|
+
blocked.push({
|
|
840
|
+
description: `${org.slug}'s owner ${position} is blocked, not planned: the host has no ownerInviteMail port, and the owner cannot open their work mailbox yet`,
|
|
841
|
+
key: `zitadel:owner:${position}`,
|
|
842
|
+
});
|
|
843
|
+
return;
|
|
844
|
+
}
|
|
742
845
|
steps.push({
|
|
743
846
|
action: {
|
|
744
847
|
system: 'zitadel',
|
|
@@ -746,6 +849,7 @@ function planOwners(org, recordedIds, authReady, authIssuer) {
|
|
|
746
849
|
key,
|
|
747
850
|
desired: {
|
|
748
851
|
email: owner.email,
|
|
852
|
+
mailboxAddress,
|
|
749
853
|
givenName: owner.givenName,
|
|
750
854
|
familyName: owner.familyName,
|
|
751
855
|
// The platform's login app, where the invite link must land (it serves `/verify`).
|
|
@@ -761,7 +865,7 @@ function planOwners(org, recordedIds, authReady, authIssuer) {
|
|
|
761
865
|
},
|
|
762
866
|
});
|
|
763
867
|
});
|
|
764
|
-
return steps;
|
|
868
|
+
return { steps, blocked };
|
|
765
869
|
}
|
|
766
870
|
/**
|
|
767
871
|
* #488: the bare hostname out of an issuer URL (`https://<hostname>`, the
|
|
@@ -771,8 +875,8 @@ function planOwners(org, recordedIds, authReady, authIssuer) {
|
|
|
771
875
|
* computed `auth.<domain>` against, so the platform's own org never plans
|
|
772
876
|
* its login host equal to the shared ZITADEL instance's own.
|
|
773
877
|
*/
|
|
774
|
-
export function isPlatformOwnOrg(org,
|
|
775
|
-
return Boolean(
|
|
878
|
+
export function isPlatformOwnOrg(org, platformOwnerOrgId) {
|
|
879
|
+
return Boolean(platformOwnerOrgId && org.id === platformOwnerOrgId);
|
|
776
880
|
}
|
|
777
881
|
function platformAuthHostnameFrom(issuer) {
|
|
778
882
|
if (!issuer)
|
|
@@ -2237,6 +2341,7 @@ function planBoule(org, byKey, claimed, recordedIds, issuer, otherSlugs, archonU
|
|
|
2237
2341
|
? `${org.slug}'s Boule mail is blocked, not planned: ${mailBlockedReason}`
|
|
2238
2342
|
: null;
|
|
2239
2343
|
const mailOn = mailWanted && !mailBlocked;
|
|
2344
|
+
const platformOwner = isPlatformOwnOrg(org, readiness.platformOwnerOrgId);
|
|
2240
2345
|
const action = {
|
|
2241
2346
|
system: 'coolify',
|
|
2242
2347
|
kind: 'boule',
|
|
@@ -2249,6 +2354,8 @@ function planBoule(org, byKey, claimed, recordedIds, issuer, otherSlugs, archonU
|
|
|
2249
2354
|
platformApex: readiness.platformApex,
|
|
2250
2355
|
platformLettermintProjectId: readiness.platformLettermintProjectId,
|
|
2251
2356
|
mailBlocked: mailBlocked !== null,
|
|
2357
|
+
// Only the platform's own org's Boule gets the peer password for Archon.
|
|
2358
|
+
platformOwner,
|
|
2252
2359
|
},
|
|
2253
2360
|
};
|
|
2254
2361
|
if (!live) {
|
|
@@ -2269,6 +2376,15 @@ function planBoule(org, byKey, claimed, recordedIds, issuer, otherSlugs, archonU
|
|
|
2269
2376
|
bouleMailTokenDrift(liveEnv[BOULE_MAIL_TOKEN_ENV], live.config.appMailToken)) {
|
|
2270
2377
|
drift.push(BOULE_MAIL_TOKEN_ENV);
|
|
2271
2378
|
}
|
|
2379
|
+
// The peer password is secret, so only its absence is compared.
|
|
2380
|
+
if (platformOwner && !liveEnv[BOULE_PEER_PASSWORD_ENV])
|
|
2381
|
+
drift.push(BOULE_PEER_PASSWORD_ENV);
|
|
2382
|
+
if (platformOwner && !liveEnv[BOULE_BRIDGE_SECRET_ENV])
|
|
2383
|
+
drift.push(BOULE_BRIDGE_SECRET_ENV);
|
|
2384
|
+
// Secrets written but Boule not restarted since: plan the step so it restarts, minting nothing.
|
|
2385
|
+
if (platformOwner && !recordedIds[BOULE_SECRETS_RESTARTED_SCOPE_KEY]) {
|
|
2386
|
+
drift.push('restart after the peer password and bridge secret');
|
|
2387
|
+
}
|
|
2272
2388
|
if (drift.length === 0)
|
|
2273
2389
|
return { step: null, blocked: null, mailBlocked };
|
|
2274
2390
|
return {
|
|
@@ -2323,10 +2439,17 @@ function planHostingToggle(system, kind, desiredEnabled, org, byKey, claimed, de
|
|
|
2323
2439
|
step: { description: describeProvision(), destroys: false, migrates: false },
|
|
2324
2440
|
};
|
|
2325
2441
|
}
|
|
2442
|
+
/**
|
|
2443
|
+
* The git source of the retired shared default app (`apps/hestia`, deleted in
|
|
2444
|
+
* #447). {@link planApp} uses it only to recognise a live app still built
|
|
2445
|
+
* from it, which it deletes instead of parking: nothing can redeploy it.
|
|
2446
|
+
*/
|
|
2447
|
+
const RETIRED_DEFAULT_APP_REPO = 'wtfalch/foundry';
|
|
2326
2448
|
/**
|
|
2327
2449
|
* The org's own app on Coolify (`org.ts`'s `HostingConfig.enabled`, #71), a
|
|
2328
2450
|
* per-org singleton resource modeled on {@link planSite}: park, not delete,
|
|
2329
|
-
* on the way off (#7 corrections 1)
|
|
2451
|
+
* on the way off (#7 corrections 1) -- except a live app built from
|
|
2452
|
+
* {@link RETIRED_DEFAULT_APP_REPO}, which is deleted (#447).
|
|
2330
2453
|
*
|
|
2331
2454
|
* #75: every app foundry deploys also carries a durable log directory (a
|
|
2332
2455
|
* persistent volume mounted at `/app/logs`, `LOG_DIR` in its env) so a
|
|
@@ -2411,10 +2534,49 @@ function planApp(org, entryKey, byKey, claimed, recordedIds, plannedSteps, authI
|
|
|
2411
2534
|
});
|
|
2412
2535
|
}
|
|
2413
2536
|
}
|
|
2414
|
-
|
|
2537
|
+
// #447: no shared default app any more. Migration 0029 wrote `repo: null`
|
|
2538
|
+
// for every org without a bespoke app and nothing rewrites that data, so
|
|
2539
|
+
// the entry stays declared but is blocked here and never provisioned.
|
|
2540
|
+
const noRepo = entry !== undefined && entry.repo === null;
|
|
2541
|
+
if (noRepo) {
|
|
2542
|
+
blocked.push({
|
|
2543
|
+
description: `${org.slug}'s ${label} repo is blocked, not planned: there is no shared default app any more (#447)`,
|
|
2544
|
+
key: blockedKey('repo'),
|
|
2545
|
+
});
|
|
2546
|
+
}
|
|
2547
|
+
const toggled = planHostingToggle('coolify', 'app', entry !== undefined && !noRepo, org, byKey, claimed, () => `provision ${org.slug}'s ${label}`, () => `park ${org.slug}'s ${label} (stops serving; app data is retained, not deleted -- #7 corrections 1)`, stepKey);
|
|
2548
|
+
// #447: only the live app's own `git_repository` decides, never the
|
|
2549
|
+
// declared entry (a `repo: null` entry can sit over a bespoke app, which is
|
|
2550
|
+
// parked as before). Only the `app` key ever held the default app, and an
|
|
2551
|
+
// entry that declares a repo is never a candidate. The reader does not
|
|
2552
|
+
// surface the Dockerfile location, so the repo is the whole match. Checked
|
|
2553
|
+
// whether or not the app is already parked, so a parked leftover is not
|
|
2554
|
+
// stranded.
|
|
2555
|
+
if (entryKey === APP_ENTRY_KEY && (entry === undefined || noRepo)) {
|
|
2556
|
+
const liveApp = byKey.get(resourceKey('coolify', 'app', stepKey));
|
|
2557
|
+
if (liveApp?.config.git_repository === RETIRED_DEFAULT_APP_REPO) {
|
|
2558
|
+
claimed.add(resourceKey('coolify', 'app', stepKey));
|
|
2559
|
+
return {
|
|
2560
|
+
step: {
|
|
2561
|
+
action: {
|
|
2562
|
+
system: 'coolify',
|
|
2563
|
+
kind: 'app',
|
|
2564
|
+
key: stepKey,
|
|
2565
|
+
desired: { enabled: false, delete: true },
|
|
2566
|
+
},
|
|
2567
|
+
step: {
|
|
2568
|
+
description: `delete ${org.slug}'s ${label}: it still builds from the retired shared default app, which no longer exists to redeploy from (#447)`,
|
|
2569
|
+
destroys: true,
|
|
2570
|
+
migrates: false,
|
|
2571
|
+
},
|
|
2572
|
+
},
|
|
2573
|
+
blocked,
|
|
2574
|
+
};
|
|
2575
|
+
}
|
|
2576
|
+
}
|
|
2415
2577
|
if (toggled)
|
|
2416
2578
|
return { step: toggled, blocked };
|
|
2417
|
-
if (!entry)
|
|
2579
|
+
if (!entry || noRepo)
|
|
2418
2580
|
return { step: null, blocked };
|
|
2419
2581
|
const live = byKey.get(resourceKey('coolify', 'app', stepKey));
|
|
2420
2582
|
if (!live)
|
package/dist/plan/executor.d.ts
CHANGED
|
@@ -14,6 +14,7 @@ import { type LoginHostBindingsPort } from './login-host.js';
|
|
|
14
14
|
import { type MailCopyPort } from './mail-copy.js';
|
|
15
15
|
import type { DestroyTarget, StepDestroyer, StepExecutionContext, StepExecutor } from './plan-apply.js';
|
|
16
16
|
import type { EdgePortName } from './platform-org.js';
|
|
17
|
+
export declare function coolifyProjectMark(org: Pick<DeclaredOrg, 'id'>): string;
|
|
17
18
|
/**
|
|
18
19
|
* The Coolify calls this executor makes, shaped to match
|
|
19
20
|
* `@wtfalch/providers/coolify`'s `CoolifyClient` method-for-method — a real
|
|
@@ -36,6 +37,12 @@ export interface CoolifyPort {
|
|
|
36
37
|
name: string;
|
|
37
38
|
description?: string | null;
|
|
38
39
|
} | null>;
|
|
40
|
+
/** A project's environments (`CoolifyClient.listEnvironments`), read-only. */
|
|
41
|
+
listEnvironments(projectUuid: string): Promise<Array<{
|
|
42
|
+
id: number;
|
|
43
|
+
uuid: string;
|
|
44
|
+
name: string;
|
|
45
|
+
}>>;
|
|
39
46
|
createProject(name: string, description?: string): Promise<{
|
|
40
47
|
uuid: string;
|
|
41
48
|
}>;
|
|
@@ -878,10 +885,22 @@ export interface ZitadelPort {
|
|
|
878
885
|
givenName: string;
|
|
879
886
|
familyName: string;
|
|
880
887
|
email: string;
|
|
881
|
-
|
|
888
|
+
username?: string;
|
|
889
|
+
}, urlTemplate?: string, returnCode?: boolean): Promise<{
|
|
882
890
|
userId: string;
|
|
883
891
|
created: boolean;
|
|
892
|
+
inviteCode?: string;
|
|
884
893
|
}>;
|
|
894
|
+
/** Adds a domain to the org. Idempotent: a domain already on the org is a no-op. */
|
|
895
|
+
addOrgDomain(orgId: string, domain: string): Promise<void>;
|
|
896
|
+
/** Makes a domain already on the org its primary one. Idempotent: no-op when it already is. */
|
|
897
|
+
setPrimaryOrgDomain(orgId: string, domain: string): Promise<void>;
|
|
898
|
+
setUserMetadata(orgId: string, userId: string, key: string, value: string): Promise<void>;
|
|
899
|
+
getUserMetadata(orgId: string, userId: string, key: string): Promise<string | null>;
|
|
900
|
+
findUserByEmail(orgId: string, email: string): Promise<{
|
|
901
|
+
userId: string;
|
|
902
|
+
state: string;
|
|
903
|
+
} | null>;
|
|
885
904
|
/**
|
|
886
905
|
* Adds a role to a project. Idempotent: a role that already exists is not
|
|
887
906
|
* an error. `@wtfalch/providers`' `ZitadelClient` has no project-role API,
|
|
@@ -1456,6 +1475,16 @@ export interface FoundryStepExecutorPorts {
|
|
|
1456
1475
|
* compilation over one dependency nothing else needs.
|
|
1457
1476
|
*/
|
|
1458
1477
|
mailCopy?: MailCopyPort;
|
|
1478
|
+
/**
|
|
1479
|
+
* Mails an owner's first-sign-in link to their personal address. The link
|
|
1480
|
+
* carries the one-time invite code `inviteOrgOwner` returned, so an error
|
|
1481
|
+
* from `send` must not repeat `link`. Optional: without it the planner
|
|
1482
|
+
* reports each owner's invite as blocked, because the owner's work address
|
|
1483
|
+
* is not a mailbox they can open yet.
|
|
1484
|
+
*/
|
|
1485
|
+
ownerInviteMail?: {
|
|
1486
|
+
send(to: string, link: string): Promise<void>;
|
|
1487
|
+
};
|
|
1459
1488
|
/**
|
|
1460
1489
|
* #463: the cross-homeserver copy chat's forward re-key needs -- see
|
|
1461
1490
|
* {@link ChatRekeyPort}'s own doc comment. Optional the same way
|
|
@@ -1661,7 +1690,10 @@ export declare class FoundryStepExecutor implements StepExecutor, StepDestroyer
|
|
|
1661
1690
|
private edgeRateLimits;
|
|
1662
1691
|
private edgeZones;
|
|
1663
1692
|
private edgeWriteSlot;
|
|
1693
|
+
/** Secrets this executor minted or was handed; see `StepExecutor.secretsHandled`. */
|
|
1694
|
+
private readonly handledSecrets;
|
|
1664
1695
|
constructor(ports: FoundryStepExecutorPorts);
|
|
1696
|
+
secretsHandled(): readonly string[];
|
|
1665
1697
|
/** #555: the edge-routed ports this executor was built with itself (they win over edge). */
|
|
1666
1698
|
ownEdgePorts(): Partial<Record<EdgePortName, boolean>>;
|
|
1667
1699
|
execute(action: StepAction, context: StepExecutionContext): Promise<{
|
|
@@ -1765,12 +1797,30 @@ export declare class FoundryStepExecutor implements StepExecutor, StepDestroyer
|
|
|
1765
1797
|
private teardownWorker;
|
|
1766
1798
|
private zitadelIssuer;
|
|
1767
1799
|
/**
|
|
1768
|
-
* `plan/diff.ts`'s `
|
|
1769
|
-
*
|
|
1770
|
-
*
|
|
1771
|
-
|
|
1772
|
-
|
|
1773
|
-
|
|
1800
|
+
* `plan/diff.ts`'s `planOrgRegistrationClosed`: closes self-registration on
|
|
1801
|
+
* the recorded ZITADEL org, for an org made before foundry did this or whose
|
|
1802
|
+
* close failed in the issuer step. Recorded only after the close returned.
|
|
1803
|
+
*/
|
|
1804
|
+
private zitadelRegistrationClosed;
|
|
1805
|
+
/**
|
|
1806
|
+
* `plan/diff.ts`'s `planOrgDomain`: registers the org's sign-in domain on
|
|
1807
|
+
* its recorded ZITADEL org and makes it primary, for an org whose ZITADEL
|
|
1808
|
+
* org was made before foundry did this. Records the domain only after both
|
|
1809
|
+
* calls succeeded.
|
|
1810
|
+
*/
|
|
1811
|
+
private zitadelDomain;
|
|
1812
|
+
/**
|
|
1813
|
+
* `plan/diff.ts`'s `planOwners`: makes the owner's account in the org's
|
|
1814
|
+
* ZITADEL org and mails the first-sign-in link to their personal address.
|
|
1815
|
+
* The account's username is the local part of `mailboxAddress` and its email
|
|
1816
|
+
* is `mailboxAddress`, so the owner signs in as `name@<org domain>`; the
|
|
1817
|
+
* personal address is kept as the `personal_email` metadata. The invite code
|
|
1818
|
+
* ZITADEL returns goes only into that mail. Nothing here logs, returns or
|
|
1819
|
+
* throws the personal address or the code -- only the invited user id, handed
|
|
1820
|
+
* back as `resourceId` so `runSteps` records it under
|
|
1821
|
+
* `zitadel:owner:<orgId>:<email>`, which is exactly what makes the next
|
|
1822
|
+
* `plan()` call idempotent. A failure after the account exists records
|
|
1823
|
+
* nothing: the next apply finds the account by username and mints a new code.
|
|
1774
1824
|
*/
|
|
1775
1825
|
private zitadelOwner;
|
|
1776
1826
|
/**
|
|
@@ -1992,6 +2042,14 @@ export declare class FoundryStepExecutor implements StepExecutor, StepDestroyer
|
|
|
1992
2042
|
private bouleBrokerEntry;
|
|
1993
2043
|
/** The shared tail of both broker-entry steps: read the app's key, write the entry, record what was written. */
|
|
1994
2044
|
private writeAppBrokerEntry;
|
|
2045
|
+
/**
|
|
2046
|
+
* `null` once the app already has `AUTHZ_PEER_PASSWORD_OPERATOR`: minted only
|
|
2047
|
+
* the first time and never rotated, like {@link ensureAuthCookieSecret}.
|
|
2048
|
+
*/
|
|
2049
|
+
private ensurePeerPassword;
|
|
2050
|
+
/** Same rule for the secret Archon's invite form sends to Boule. */
|
|
2051
|
+
private ensureBridgeSecret;
|
|
2052
|
+
private mintIfAbsent;
|
|
1995
2053
|
/**
|
|
1996
2054
|
* `null` once the app already has one -- `AUTH_COOKIE_SECRET` is generated
|
|
1997
2055
|
* only the first time (task brief), never regenerated or overwritten, so a
|