@wtfalch/foundry 0.32.0 → 0.33.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/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 {
@@ -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
@@ -1,9 +1,10 @@
1
1
  import { randomBytes } from 'node:crypto';
2
2
  import { URL } from 'node:url';
3
+ import { redactSecrets } from '../calls-sfu-deploy-support.js';
3
4
  import { resolveCallsSfuUrls } from '../calls-sfu.js';
4
5
  import { APP_ENTRY_KEY, appEntry, filesCollectionEnvName, ownerMailboxRecorded, ownerMailboxes, sharedSecretRef, } from '../org.js';
5
6
  import { AGORA_ACTIVATED_ENV_SCOPE_KEY, AGORA_APP_PORTS_EXPOSES, AGORA_CLIENT_ID_SCOPE_KEY, AGORA_DEPLOYED_SCOPE_KEY, APP_CLIENT_ID_SCOPE_KEY, CALLS_INTROSPECTION_SCOPE_KEY, CALLS_INTROSPECTION_UNDELIVERED_SCOPE_KEY, CALLS_SERVICE_DEPLOYED_SCOPE_KEY, CALLS_SERVICE_NAME_SCOPE_KEY, CALLS_SERVICE_PORT, CALLS_SERVICE_TOKEN_ENV_SCOPE_KEY, CHAT_CALLS_APPLIED_SCOPE_KEY, CHAT_CALLS_TOKEN_SCOPE_KEY, CHAT_CALLS_TOKEN_UNDELIVERED_SCOPE_KEY, CHAT_INTROSPECTION_SCOPE_KEY, CHAT_INTROSPECTION_UNDELIVERED_SCOPE_KEY, CHAT_SERVICE_URL_SCOPE_KEY, CMS_CLIENT_ID_SCOPE_KEY, ENTRY_CLIENT_ID_SCOPE_PREFIX, ZITADEL_MESSAGING_PROJECT_SCOPE_KEY, ZITADEL_ORG_SCOPE_KEY, agoraEnvDrift, agoraEnvRevision, agoraHostnameFor, callsIntrospectionAppName, callsServiceHasName, callsServiceInternalName, callsTokenEnvMarker, callsTokenEnvMovedMessage, chatCallsApplied, chatHasUpstreams, chatIntrospectionAppName, desiredAgoraEnv, entryClientIdScopeKey, } from './agora.js';
6
- import { BOULE_BROKER_ENTRY_KEY, BOULE_CLIENT_ID_SCOPE_KEY, BOULE_DATABASE_SCOPE_KEY, BOULE_DEPLOYED_SCOPE_KEY, bouleEnvDrift, bouleHostnameFor, bouleMailWanted, desiredBouleEnv, wantedBouleBrokerEntry, } from './boule.js';
7
+ import { BOULE_BRIDGE_SECRET_ENV, BOULE_BROKER_ENTRY_KEY, BOULE_CLIENT_ID_SCOPE_KEY, BOULE_DATABASE_SCOPE_KEY, BOULE_DEPLOYED_SCOPE_KEY, BOULE_PEER_PASSWORD_ENV, BOULE_SECRETS_RESTARTED_SCOPE_KEY, bouleEnvDrift, bouleHostnameFor, bouleMailWanted, desiredBouleEnv, wantedBouleBrokerEntry, } from './boule.js';
7
8
  import { brokerEntryAppliedScopeKey, brokerEntryWarningsScopeKey, entryAuthEnv, entryAuthEnvAppliedScopeKey, wantedBrokerEntry, writeBrokerEntry, } from './broker-entries.js';
8
9
  import { MAS_IMAGE, MAS_TAG, SYNAPSE_IMAGE, SYNAPSE_TAG, callsMasConfigYaml, callsServerName, callsSynapseHomeserverYaml, mintCallsMatrixSecrets, parseCallsDbUrl, } from './calls-matrix.js';
9
10
  import { CHAT_MAS_PORT, CHAT_REPO, CHAT_SERVICE_DOCKERFILE, CHAT_SERVICE_PORT, CHAT_SYNAPSE_DOCKERFILE, CHAT_SYNAPSE_PORT, assertChatServerName, chatInternalNames, chatMasConfigYaml, chatRekeyedInternalNames, chatServerName, chatServiceEnvs, chatSynapseHomeserverYaml, chatUpstreams, } from './chat-matrix.js';
@@ -27,6 +28,7 @@ import { MAIL_COPY_COMPLETED_SCOPE_KEY, MAIL_COPY_DONE_SCOPE_KEY, parseMailCopyC
27
28
  import { CALLS_MAS_APPLIED_IMAGE_TAG_SCOPE_KEY, CALLS_SYNAPSE_APPLIED_IMAGE_TAG_SCOPE_KEY, ORG_MAIL_APPLIED_APEX_SCOPE_KEY, ORG_MAIL_APPLIED_HOST_SCOPE_KEY, ORG_MAIL_APPLIED_MODE_SCOPE_KEY, ORG_MAIL_APPLIED_USES_DOMAIN_SCOPE_KEY, appDomainsAppliedScopeKey, appEnvAppliedScopeKey, appFolderDiffers, appNameFor, appSettingsAppliedScopeKey, appSettingsOf, appStepKey, appliedAppDomains, appliedAppSettings, dbEnvAppliedScopeKey, resolveAppEnv, serviceDbEnvAppliedScopeKey, zitadelAppIdTokenUserinfoAppliedScopeKey, } from './platform-diff.js';
28
29
  import { AUTH_LOGIN_PORT, PLATFORM_AUTH_BROKER_MAIL_KEY_REF, PLATFORM_AUTH_LOGIN_INTERNAL_NAME, } from './platform-providers.js';
29
30
  import { appLabelsAppliedScopeKey, entryLabelHash, entryLabelSpec, entryProxyLabels, sameLabelSets, } from './proxy-labels.js';
31
+ import { ZITADEL_ORG_DOMAIN_SCOPE_KEY, ZITADEL_ORG_REGISTRATION_CLOSED_SCOPE_KEY, closeSignInOrgRegistration, ensureSignInOrg, inviteOwnerWithLink, registerSignInDomainOrBlock, signInOrgDomain, } from './sign-in-org.js';
30
32
  import { entryWorkerAppliedScopeKey, entryWorkerExclusionsScopeKey, entryWorkerRetiredScopeKey, entryWorkerRouteIdScopeKey, entryWorkerRoutePatternScopeKey, entryWorkerRoutePendingScopeKey, entryWorkerRouteZoneScopeKey, entryWorkerUploadedScopeKey, } from './worker.js';
31
33
  /**
32
34
  * The real {@link StepExecutor} (#20 "provision Company Factory end to
@@ -51,7 +53,7 @@ import { entryWorkerAppliedScopeKey, entryWorkerExclusionsScopeKey, entryWorkerR
51
53
  */
52
54
  /** #569: the description foundry gives the Coolify project it creates for an org. */
53
55
  const COOLIFY_PROJECT_MARK_PREFIX = 'foundry org ';
54
- function coolifyProjectMark(org) {
56
+ export function coolifyProjectMark(org) {
55
57
  return `${COOLIFY_PROJECT_MARK_PREFIX}${org.id}`;
56
58
  }
57
59
  /**
@@ -761,9 +763,14 @@ export class FoundryStepExecutor {
761
763
  edgeRateLimits;
762
764
  edgeZones;
763
765
  edgeWriteSlot;
766
+ /** Secrets this executor minted or was handed; see `StepExecutor.secretsHandled`. */
767
+ handledSecrets = new Set();
764
768
  constructor(ports) {
765
769
  this.ports = ports;
766
770
  }
771
+ secretsHandled() {
772
+ return [...this.handledSecrets];
773
+ }
767
774
  /** #555: the edge-routed ports this executor was built with itself (they win over edge). */
768
775
  ownEdgePorts() {
769
776
  return {
@@ -779,6 +786,10 @@ export class FoundryStepExecutor {
779
786
  switch (`${action.system}:${action.kind}`) {
780
787
  case 'zitadel:issuer':
781
788
  return this.zitadelIssuer(action, org, context);
789
+ case 'zitadel:registration-closed':
790
+ return this.zitadelRegistrationClosed(org, context);
791
+ case 'zitadel:domain':
792
+ return this.zitadelDomain(action, org, context);
782
793
  case 'zitadel:owner':
783
794
  return this.zitadelOwner(action, org, context);
784
795
  case 'zitadel:login-host':
@@ -1290,17 +1301,60 @@ export class FoundryStepExecutor {
1290
1301
  // their own design" -- not built in v1).
1291
1302
  throw new NotBuiltError(`zitadel:issuer move for ${org.slug}`, 'moving the issuer onto or off the org\'s own domain is not built in v1; see foundry\'s .claude/decisions-2026-09-24-executor.md, "Executor decisions"');
1292
1303
  }
1293
- const { org: zOrg } = await this.ports.zitadel.findOrCreateOrg(org.slug);
1294
- context.recordScopeId('zitadel_org', zOrg.id);
1295
- return { resourceId: zOrg.id };
1304
+ const { orgId } = await ensureSignInOrg({
1305
+ zitadel: this.ports.zitadel,
1306
+ name: org.slug,
1307
+ domain: signInOrgDomain(org),
1308
+ recordScopeId: (key, id) => context.recordScopeId(key, id),
1309
+ persistScopeIds: () => context.persistScopeIds(),
1310
+ registrationClosedScopeKey: ZITADEL_ORG_REGISTRATION_CLOSED_SCOPE_KEY,
1311
+ });
1312
+ return { resourceId: orgId };
1313
+ }
1314
+ /**
1315
+ * `plan/diff.ts`'s `planOrgRegistrationClosed`: closes self-registration on
1316
+ * the recorded ZITADEL org, for an org made before foundry did this or whose
1317
+ * close failed in the issuer step. Recorded only after the close returned.
1318
+ */
1319
+ async zitadelRegistrationClosed(org, context) {
1320
+ const zitadelOrgId = context.recordedIds().zitadel_org;
1321
+ if (!zitadelOrgId) {
1322
+ throw new Error(`zitadel:registration-closed for ${org.slug}: no ZITADEL org recorded yet -- the issuer must be provisioned first`);
1323
+ }
1324
+ await closeSignInOrgRegistration(this.ports.zitadel, zitadelOrgId, (key, id) => context.recordScopeId(key, id));
1325
+ return {};
1326
+ }
1327
+ /**
1328
+ * `plan/diff.ts`'s `planOrgDomain`: registers the org's sign-in domain on
1329
+ * its recorded ZITADEL org and makes it primary, for an org whose ZITADEL
1330
+ * org was made before foundry did this. Records the domain only after both
1331
+ * calls succeeded.
1332
+ */
1333
+ async zitadelDomain(action, org, context) {
1334
+ const zitadelOrgId = context.recordedIds().zitadel_org;
1335
+ if (!zitadelOrgId) {
1336
+ throw new Error(`zitadel:domain for ${org.slug}: no ZITADEL org recorded yet -- the issuer must be provisioned first`);
1337
+ }
1338
+ const domain = action.desired.domain;
1339
+ // A refusal (the sign-in server's domain policy wants proof) is recorded and reported as
1340
+ // blocked by the planner; it does not fail every apply.
1341
+ const registered = await registerSignInDomainOrBlock(this.ports.zitadel, zitadelOrgId, domain, (key, id) => context.recordScopeId(key, id));
1342
+ if (registered)
1343
+ context.recordScopeId(ZITADEL_ORG_DOMAIN_SCOPE_KEY, domain);
1344
+ return {};
1296
1345
  }
1297
1346
  /**
1298
- * `plan/diff.ts`'s `planOwner`: invites the org's owner into its ZITADEL
1299
- * org by email. Never logs, returns or throws the email itself -- only the
1300
- * invited user id, handed back as `resourceId` so `runSteps` records it
1301
- * under `zitadel:owner:<orgId>:<email>`, which is exactly what makes the next
1302
- * `plan()` call idempotent (`planOwner` plans nothing once that key is
1303
- * recorded).
1347
+ * `plan/diff.ts`'s `planOwners`: makes the owner's account in the org's
1348
+ * ZITADEL org and mails the first-sign-in link to their personal address.
1349
+ * The account's username is the local part of `mailboxAddress` and its email
1350
+ * is `mailboxAddress`, so the owner signs in as `name@<org domain>`; the
1351
+ * personal address is kept as the `personal_email` metadata. The invite code
1352
+ * ZITADEL returns goes only into that mail. Nothing here logs, returns or
1353
+ * throws the personal address or the code -- only the invited user id, handed
1354
+ * back as `resourceId` so `runSteps` records it under
1355
+ * `zitadel:owner:<orgId>:<email>`, which is exactly what makes the next
1356
+ * `plan()` call idempotent. A failure after the account exists records
1357
+ * nothing: the next apply finds the account by username and mints a new code.
1304
1358
  */
1305
1359
  async zitadelOwner(action, org, context) {
1306
1360
  const zitadelOrgId = context.recordedIds().zitadel_org;
@@ -1313,11 +1367,31 @@ export class FoundryStepExecutor {
1313
1367
  if (!authIssuer) {
1314
1368
  throw new Error(`zitadel:owner for ${org.slug}: no invite template issuer -- refusing to send ZITADEL's default invite link, which 404s on this platform`);
1315
1369
  }
1316
- const { userId } = await this.ports.zitadel.inviteOrgOwner(zitadelOrgId, {
1317
- givenName: action.desired.givenName,
1318
- familyName: action.desired.familyName,
1319
- email: action.desired.email,
1320
- }, `${authIssuer}/verify?userId={{.UserID}}&code={{.Code}}&organization={{.OrgID}}&invite=true`);
1370
+ const mailboxAddress = action.desired.mailboxAddress;
1371
+ if (!mailboxAddress) {
1372
+ throw new Error(`zitadel:owner for ${org.slug}: no mailboxAddress -- the planner reports this as blocked, so it should not reach the executor`);
1373
+ }
1374
+ const inviteMail = this.ports.ownerInviteMail;
1375
+ if (!inviteMail) {
1376
+ throw new Error(`zitadel:owner for ${org.slug}: no ownerInviteMail port -- the owner's work mailbox cannot be opened yet, so the invite is not sent there`);
1377
+ }
1378
+ // foundry's own record of this owner's account, so a returning owner is not mistaken for a stranger.
1379
+ const invitedBefore = context.recordedIds()[`zitadel:owner:${action.key}`];
1380
+ const { userId } = await inviteOwnerWithLink({
1381
+ zitadel: this.ports.zitadel,
1382
+ mail: inviteMail,
1383
+ orgId: zitadelOrgId,
1384
+ authIssuer,
1385
+ owner: {
1386
+ givenName: action.desired.givenName,
1387
+ familyName: action.desired.familyName,
1388
+ mailboxAddress,
1389
+ personalEmail: action.desired.email,
1390
+ },
1391
+ label: `zitadel:owner for ${org.slug}`,
1392
+ onSecret: (value) => this.handledSecrets.add(value),
1393
+ ...(invitedBefore ? { invitedUserId: invitedBefore } : {}),
1394
+ });
1321
1395
  // An owner invited after chat was built never reaches `coolifyChat`
1322
1396
  // again, so the role is granted here once the messaging project exists.
1323
1397
  const messagingProjectId = context.recordedIds()[ZITADEL_MESSAGING_PROJECT_SCOPE_KEY];
@@ -2493,6 +2567,23 @@ export class FoundryStepExecutor {
2493
2567
  // Last: a run that failed above plans the step again.
2494
2568
  context.recordScopeId(brokerEntryAppliedScopeKey(entryKey), JSON.stringify(want));
2495
2569
  }
2570
+ /**
2571
+ * `null` once the app already has `AUTHZ_PEER_PASSWORD_OPERATOR`: minted only
2572
+ * the first time and never rotated, like {@link ensureAuthCookieSecret}.
2573
+ */
2574
+ async ensurePeerPassword(appId) {
2575
+ return this.mintIfAbsent(appId, BOULE_PEER_PASSWORD_ENV, 24);
2576
+ }
2577
+ /** Same rule for the secret Archon's invite form sends to Boule. */
2578
+ async ensureBridgeSecret(appId) {
2579
+ return this.mintIfAbsent(appId, BOULE_BRIDGE_SECRET_ENV, 32);
2580
+ }
2581
+ async mintIfAbsent(appId, key, bytes) {
2582
+ const envs = await this.ports.coolify.listEnvs(appId);
2583
+ if (envs.some((e) => e.key === key && !e.is_preview))
2584
+ return null;
2585
+ return randomBytes(bytes).toString('base64url');
2586
+ }
2496
2587
  /**
2497
2588
  * `null` once the app already has one -- `AUTH_COOKIE_SECRET` is generated
2498
2589
  * only the first time (task brief), never regenerated or overwritten, so a
@@ -4707,7 +4798,29 @@ export class FoundryStepExecutor {
4707
4798
  const appKey = await this.ensureAuthAppKey(app.appId);
4708
4799
  if (appKey)
4709
4800
  envs.push({ key: 'AUTH_APP_KEY', value: appKey });
4710
- await this.ports.coolify.bulkUpsertEnvs(app.appId, envs);
4801
+ // Archon's way into this Boule's people store: only the platform's own
4802
+ // org's Boule gets it, minted once and never rotated (the role Boule's boot
4803
+ // creates from it would otherwise keep the old password).
4804
+ const peerPassword = action.desired.platformOwner === true ? await this.ensurePeerPassword(app.appId) : null;
4805
+ if (peerPassword) {
4806
+ this.handledSecrets.add(peerPassword);
4807
+ envs.push({ key: BOULE_PEER_PASSWORD_ENV, value: peerPassword });
4808
+ }
4809
+ // Archon's invite form calls Boule with this secret; same rule as the password.
4810
+ const bridgeSecret = action.desired.platformOwner === true ? await this.ensureBridgeSecret(app.appId) : null;
4811
+ if (bridgeSecret) {
4812
+ this.handledSecrets.add(bridgeSecret);
4813
+ envs.push({ key: BOULE_BRIDGE_SECRET_ENV, value: bridgeSecret });
4814
+ }
4815
+ try {
4816
+ await this.ports.coolify.bulkUpsertEnvs(app.appId, envs);
4817
+ }
4818
+ catch (error) {
4819
+ const minted = [peerPassword, bridgeSecret].filter((v) => v !== null);
4820
+ if (minted.length === 0)
4821
+ throw error;
4822
+ throw new Error(redactSecrets(error instanceof Error ? error.message : String(error), minted));
4823
+ }
4711
4824
  const dbPush = await this.pushDatabaseUrl(app.appId, dbId, dbName);
4712
4825
  if (!dbPush.pushed) {
4713
4826
  throw new Error(`Boule for ${org.slug}: its database has no internal_db_url yet -- retry this apply once Coolify reports one`);
@@ -4723,6 +4836,8 @@ export class FoundryStepExecutor {
4723
4836
  // that a previous apply created but crashed before deploying) sat built
4724
4837
  // but never started. Deployed once this executor has confirmed, never
4725
4838
  // again -- a live app is only ever restarted from here on.
4839
+ const secretsNotRestarted = action.desired.platformOwner === true &&
4840
+ !context.recordedIds()[BOULE_SECRETS_RESTARTED_SCOPE_KEY];
4726
4841
  if (!context.recordedIds()[BOULE_DEPLOYED_SCOPE_KEY]) {
4727
4842
  // #543: every deploy waits for health -- Boule's own public hostname.
4728
4843
  const runtime = this.ports.runtime ?? {};
@@ -4731,10 +4846,21 @@ export class FoundryStepExecutor {
4731
4846
  }
4732
4847
  else if (app.existed &&
4733
4848
  !mail.restarted &&
4734
- (envChanged || cookieSecret !== null || appKey !== null || dbPush.changed || mail.changed)) {
4849
+ (envChanged ||
4850
+ cookieSecret !== null ||
4851
+ appKey !== null ||
4852
+ peerPassword !== null ||
4853
+ bridgeSecret !== null ||
4854
+ secretsNotRestarted ||
4855
+ dbPush.changed ||
4856
+ mail.changed)) {
4735
4857
  // A restart inside the mail rule already came after every env write above.
4736
4858
  await this.ports.coolify.restart(app.appId);
4737
4859
  }
4860
+ // Last, after the restart or deploy: a run that failed before here repeats it, minting nothing.
4861
+ if (action.desired.platformOwner === true) {
4862
+ context.recordScopeId(BOULE_SECRETS_RESTARTED_SCOPE_KEY, '1');
4863
+ }
4738
4864
  return { resourceId: app.appId };
4739
4865
  }
4740
4866
  /**
@@ -28,7 +28,7 @@ const CALLS_HEALTH_PATH = '/healthz';
28
28
  function alwaysOnTargets(org, platform) {
29
29
  // The platform's own org signs in on the shared instance host, not its own
30
30
  // `loginHostnameFor` (#488; `login-host.ts`, `diff.ts`'s `planLoginHost`).
31
- const loginHost = platform && isPlatformOwnOrg(org, platform.apex)
31
+ const loginHost = platform && isPlatformOwnOrg(org, platform.ownerOrgId)
32
32
  ? platform.auth.hostname
33
33
  : loginHostnameFor(org);
34
34
  return [
@@ -41,6 +41,8 @@ export type { MailAllowlistApp } from './mail-identity-allowlist.js';
41
41
  export { checkMailAppsAllowlist } from './mail-identity-allowlist.js';
42
42
  export type { CoolifyPort, CloudflarePort, ZitadelPort, CallsSfuPort, JobsSchedulerPort, FilesOrganisationPort, AiOrganisationPort, FoundryStepExecutorPorts, PlatformOrgPortSource, } from './executor.js';
43
43
  export { FoundryStepExecutor, NotBuiltError, healthUrls } from './executor.js';
44
+ export type { EnsureSignInOrgOptions } from './sign-in-org.js';
45
+ export { ZITADEL_ORG_DOMAIN_SCOPE_KEY, ensureSignInOrg, registerSignInDomain, signInOrgDomain, } from './sign-in-org.js';
44
46
  export type { HealthTarget, HealthTargetDepth, HealthTargetExpect } from './health-targets.js';
45
47
  export { healthTargets, platformHealthTargets } from './health-targets.js';
46
48
  export { createEdgeDns, edgeDnsRecordOwners, EDGE_DNS_WRITE_CONCURRENCY } from './edge-dns.js';
@@ -21,6 +21,7 @@ export { loginHostnameFor } from './login-host.js';
21
21
  export { checkMailIdentityAudience } from './mail-identity.js';
22
22
  export { checkMailAppsAllowlist } from './mail-identity-allowlist.js';
23
23
  export { FoundryStepExecutor, NotBuiltError, healthUrls } from './executor.js';
24
+ export { ZITADEL_ORG_DOMAIN_SCOPE_KEY, ensureSignInOrg, registerSignInDomain, signInOrgDomain, } from './sign-in-org.js';
24
25
  export { healthTargets, platformHealthTargets } from './health-targets.js';
25
26
  export { createEdgeDns, edgeDnsRecordOwners, EDGE_DNS_WRITE_CONCURRENCY } from './edge-dns.js';
26
27
  export { createEdgeAccess } from './edge-access.js';