@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.
@@ -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
+ }
@@ -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, platformApex: string | null): boolean;
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.platformApex);
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
- plannedSteps.push(...planOwners(org, recordedIds, platformReadiness.authReady, platformReadiness.authIssuer));
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 steps = [];
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, platformApex) {
775
- return Boolean(org.domain && platformApex && org.domain.toLowerCase() === platformApex.toLowerCase());
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
- const toggled = planHostingToggle('coolify', 'app', entry !== undefined, 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);
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)
@@ -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
- }, urlTemplate?: string): Promise<{
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 `planOwner`: invites the org's owner into its ZITADEL
1769
- * org by email. Never logs, returns or throws the email itself -- only the
1770
- * invited user id, handed back as `resourceId` so `runSteps` records it
1771
- * under `zitadel:owner:<orgId>:<email>`, which is exactly what makes the next
1772
- * `plan()` call idempotent (`planOwner` plans nothing once that key is
1773
- * recorded).
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