@wtfalch/foundry 0.71.0 → 0.72.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.
Files changed (38) hide show
  1. package/dist/index.d.ts +1 -1
  2. package/dist/modules/sign-in.js +81 -9
  3. package/dist/modules/types.d.ts +2 -0
  4. package/dist/native-build.d.ts +4 -1
  5. package/dist/native-build.js +8 -2
  6. package/dist/org.d.ts +18 -1
  7. package/dist/org.js +40 -0
  8. package/dist/plan/boule.d.ts +15 -0
  9. package/dist/plan/boule.js +13 -0
  10. package/dist/plan/broker-registrar-token.d.ts +69 -0
  11. package/dist/plan/broker-registrar-token.js +215 -0
  12. package/dist/plan/broker-registrar.d.ts +2 -0
  13. package/dist/plan/broker-registrar.js +5 -1
  14. package/dist/plan/diff.d.ts +13 -0
  15. package/dist/plan/diff.js +26 -5
  16. package/dist/plan/executor.d.ts +66 -0
  17. package/dist/plan/executor.js +136 -8
  18. package/dist/plan/health-check-settings.d.ts +13 -0
  19. package/dist/plan/health-check-settings.js +17 -0
  20. package/dist/plan/own-diff.d.ts +32 -0
  21. package/dist/plan/own-diff.js +67 -10
  22. package/dist/plan/own-hostnames.d.ts +8 -0
  23. package/dist/plan/own-hostnames.js +14 -0
  24. package/dist/plan/own-services.d.ts +7 -0
  25. package/dist/plan/own-services.js +2 -0
  26. package/dist/plan/plan-apply.d.ts +15 -0
  27. package/dist/plan/plan-apply.js +21 -0
  28. package/dist/plan/platform-diff.d.ts +9 -5
  29. package/dist/plan/platform-diff.js +16 -10
  30. package/dist/plan/platform-providers.d.ts +24 -2
  31. package/dist/plan/platform-providers.js +155 -12
  32. package/dist/plan/signin-mail.d.ts +18 -5
  33. package/dist/plan/signin-mail.js +14 -6
  34. package/dist/plan/zitadel-login-policy.d.ts +1 -1
  35. package/dist/plan/zitadel-login-policy.js +72 -46
  36. package/dist/plan/zitadel-machine-user.d.ts +14 -1
  37. package/dist/plan/zitadel-machine-user.js +96 -13
  38. package/package.json +3 -3
@@ -1718,7 +1718,9 @@ export class FoundryPlanApply {
1718
1718
  oikosZoneReady: platformReadiness.oikosIngressApplied === true,
1719
1719
  ownMailKeyHeld: await this.ownMailKeyHeld(org),
1720
1720
  ownMailKeyMintable: this.executor.ownMailKeyMintable?.() ?? false,
1721
+ customersLoginPolicy: this.executor.customersLoginPolicyAvailable?.() ?? true,
1721
1722
  customersDirectory: this.executor.customersDirectoryAvailable?.() ?? false,
1723
+ customersDirectoryRoles: this.executor.customersDirectoryRolesAvailable?.() ?? false,
1722
1724
  bouleArchonCredential: this.executor.bouleArchonCredentialAvailable?.() ?? false,
1723
1725
  ...(blueGreenServers ? { blueGreenServers } : {}),
1724
1726
  ...(stageSinkDown ? { stageSinkDown } : {}),
@@ -1741,6 +1743,10 @@ export class FoundryPlanApply {
1741
1743
  ownSigninDbKeysPuttable: this.executor.ownSigninDbKeysPuttable?.() ?? true,
1742
1744
  ownSigninDbBackupConfigured: this.executor.ownSigninDbBackupConfigured?.() ?? true,
1743
1745
  ownSigninMailWritable: this.executor.ownSigninMailWritable?.() ?? true,
1746
+ ...(await this.ownAuthHeadFor(org, platformReadiness)),
1747
+ registrarGrants: this.executor.registrarGrantsAvailable?.() ?? false,
1748
+ ownRegistrarTokenWritable: this.executor.ownRegistrarTokenWritable?.(),
1749
+ ownRegistrarTokenHeld: await this.ownRegistrarTokenHeld(org),
1744
1750
  ...(this.backupGate
1745
1751
  ? {
1746
1752
  backupStorageUuid: (await this.platformDeps?.store.getServiceIds())?.[PLATFORM_BACKUP_S3_STORAGE_SCOPE_KEY] || null,
@@ -1964,6 +1970,14 @@ export class FoundryPlanApply {
1964
1970
  }
1965
1971
  return { store: Boolean(heldKeys), refs, differs, unreadable, invalid };
1966
1972
  }
1973
+ /** #886: the head of an own org's declared auth branch, or nothing when the org is not own or the executor has no read. */
1974
+ async ownAuthHeadFor(org, readiness) {
1975
+ const gitRef = readiness.serviceGitRefs?.auth;
1976
+ if (!ownsServices(org) || !gitRef || !this.executor.ownAuthHead)
1977
+ return {};
1978
+ const head = await this.executor.ownAuthHead(gitRef);
1979
+ return head ? { ownAuthHead: head } : {};
1980
+ }
1967
1981
  /** An own org's Lettermint key is held. A resolver that throws counts as not held. */
1968
1982
  async ownMailKeyHeld(org) {
1969
1983
  const held = this.platformDeps?.heldKeys;
@@ -1971,6 +1985,13 @@ export class FoundryPlanApply {
1971
1985
  return false;
1972
1986
  return (await heldOwnKeyRefs(held, org)).has(orgTarget(org).refs.brokerMailKey);
1973
1987
  }
1988
+ /** An own org's registrar token is held (a rerun after a crash delivers it instead of blocking). */
1989
+ async ownRegistrarTokenHeld(org) {
1990
+ const held = this.platformDeps?.heldKeys;
1991
+ if (!ownsServices(org) || !held)
1992
+ return false;
1993
+ return (await heldOwnKeyRefs(held, org)).has(orgTarget(org).refs.registrarToken);
1994
+ }
1974
1995
  /** Which of an own org's undelivered-secret refs hold a value now, so a rerun resumes instead of blocking. */
1975
1996
  async ownResumableRefs(org) {
1976
1997
  const held = this.platformDeps?.heldKeys;
@@ -70,16 +70,18 @@ export declare const AUTH_TUNNEL_ROUTES_VERSION = "2";
70
70
  export declare const PLATFORM_AUTH_TUNNEL_ROUTES_SCOPE_KEY = "auth_tunnel_routes_applied";
71
71
  /**
72
72
  * The version of the env `provisionAuthStack` writes to the login apps (`LOGIN_MAIL_FROM`,
73
- * `LETTERMINT_API_TOKEN`). Raise it when a key is added or its meaning changes, so an applied
73
+ * `LETTERMINT_API_TOKEN`, `LOGIN_PASSKEY_RP_ID`). Raise it when a key is added or its meaning changes, so an applied
74
74
  * sign-in plans its step once more and the apps get the env. Never holds a value.
75
75
  * 1: the login apps send their own invite and verification mail (auth 0.35.0, foundry 0.68.0).
76
+ * 2: a company's login apps get `LOGIN_PASSKEY_RP_ID` (foundry 0.70.2).
76
77
  */
77
- export declare const AUTH_LOGIN_ENV_VERSION = "1";
78
+ export declare const AUTH_LOGIN_ENV_VERSION = "2";
78
79
  /**
79
- * What the login apps' env is written for: the version and the sender address (`LOGIN_MAIL_FROM`).
80
- * The address is no secret; a new mail domain gives a new marker, so the step is planned again.
80
+ * What the login apps' env is written for: the version, the sender address (`LOGIN_MAIL_FROM`) and the
81
+ * passkey RP ID (`LOGIN_PASSKEY_RP_ID`, none for the platform and a company without its own domain).
82
+ * Neither is a secret; a new mail domain or a changed RP ID gives a new marker, so the step is planned again.
81
83
  */
82
- export declare function authLoginEnvMarker(smtpSender: string): string;
84
+ export declare function authLoginEnvMarker(smtpSender: string, passkeyRpId?: string): string;
83
85
  /** The {@link authLoginEnvMarker} the login apps' env was last written with, once live on them. */
84
86
  export declare const PLATFORM_AUTH_LOGIN_ENV_SCOPE_KEY = "auth_login_env_applied";
85
87
  /**
@@ -1327,6 +1329,8 @@ export interface PlanPlatformOptions {
1327
1329
  authMailKeyHeld?: boolean;
1328
1330
  /** C3: why the owner's invite cannot be made (no work address, no mail port). The rest of the step still plans. */
1329
1331
  authOwnerInvite?: string;
1332
+ /** The broker's registrar token was minted and never held: reported, nothing planned for it. */
1333
+ authRegistrar?: string;
1330
1334
  files?: string;
1331
1335
  integrations?: string;
1332
1336
  ai?: string;
@@ -77,17 +77,19 @@ export const AUTH_TUNNEL_ROUTES_VERSION = '2';
77
77
  export const PLATFORM_AUTH_TUNNEL_ROUTES_SCOPE_KEY = 'auth_tunnel_routes_applied';
78
78
  /**
79
79
  * The version of the env `provisionAuthStack` writes to the login apps (`LOGIN_MAIL_FROM`,
80
- * `LETTERMINT_API_TOKEN`). Raise it when a key is added or its meaning changes, so an applied
80
+ * `LETTERMINT_API_TOKEN`, `LOGIN_PASSKEY_RP_ID`). Raise it when a key is added or its meaning changes, so an applied
81
81
  * sign-in plans its step once more and the apps get the env. Never holds a value.
82
82
  * 1: the login apps send their own invite and verification mail (auth 0.35.0, foundry 0.68.0).
83
+ * 2: a company's login apps get `LOGIN_PASSKEY_RP_ID` (foundry 0.70.2).
83
84
  */
84
- export const AUTH_LOGIN_ENV_VERSION = '1';
85
+ export const AUTH_LOGIN_ENV_VERSION = '2';
85
86
  /**
86
- * What the login apps' env is written for: the version and the sender address (`LOGIN_MAIL_FROM`).
87
- * The address is no secret; a new mail domain gives a new marker, so the step is planned again.
87
+ * What the login apps' env is written for: the version, the sender address (`LOGIN_MAIL_FROM`) and the
88
+ * passkey RP ID (`LOGIN_PASSKEY_RP_ID`, none for the platform and a company without its own domain).
89
+ * Neither is a secret; a new mail domain or a changed RP ID gives a new marker, so the step is planned again.
88
90
  */
89
- export function authLoginEnvMarker(smtpSender) {
90
- return `${AUTH_LOGIN_ENV_VERSION}:${smtpSender}`;
91
+ export function authLoginEnvMarker(smtpSender, passkeyRpId) {
92
+ return `${AUTH_LOGIN_ENV_VERSION}:${smtpSender}${passkeyRpId ? `:${passkeyRpId}` : ''}`;
91
93
  }
92
94
  /** The {@link authLoginEnvMarker} the login apps' env was last written with, once live on them. */
93
95
  export const PLATFORM_AUTH_LOGIN_ENV_SCOPE_KEY = 'auth_login_env_applied';
@@ -1553,7 +1555,7 @@ export function planPlatform(platform, live, options = {}) {
1553
1555
  // reconcile below can be planned in the *same* round hc first comes up,
1554
1556
  // the same way files/mail/archon are planned in auth's own first round.
1555
1557
  const hcReady = hc.step !== null || Boolean(recordedIds[resourceKey('coolify', 'app', PLATFORM_HC_KEY)]);
1556
- const auth = planAuth(platform, recordedIds, blocked.auth ?? dnsBlocked?.('sign-in'), blocked.authExecutorMissing ?? false, drifted.auth ?? false, blocked.authOtherInputsMissing ?? [], blocked.authOwnerInvite, drifted.authApps ?? false, blocked.authMailKeyHeld ?? true);
1558
+ const auth = planAuth(platform, recordedIds, blocked.auth ?? dnsBlocked?.('sign-in'), blocked.authExecutorMissing ?? false, drifted.auth ?? false, blocked.authOtherInputsMissing ?? [], blocked.authOwnerInvite, drifted.authApps ?? false, blocked.authMailKeyHeld ?? true, blocked.authRegistrar);
1557
1559
  if (auth.step)
1558
1560
  plannedSteps.push(auth.step);
1559
1561
  // #174: the integrations host. Planned after auth; neither step reads the
@@ -2229,11 +2231,15 @@ function planSfu(platform, callsOrgSlugs, recordedIds) {
2229
2231
  * promising "this apply re-mints" while one of those is still missing would
2230
2232
  * be wrong the moment it is read.
2231
2233
  */
2232
- function planAuth(platform, recordedIds, executorBlocked, executorMissing, authDrifted, otherInputsMissing = [], ownerInviteBlocked, appsDown = false, mailKeyHeld = true) {
2234
+ function planAuth(platform, recordedIds, executorBlocked, executorMissing, authDrifted, otherInputsMissing = [], ownerInviteBlocked, appsDown = false, mailKeyHeld = true, registrarBlocked) {
2233
2235
  if (!platform.auth.enabled)
2234
2236
  return { step: null, blocked: null };
2235
- // C3/S2: standing notes. The invite and the domain wait on a person; they hold nothing else back.
2236
- const standing = [ownerInviteBlocked, ownerDomainBlockedText(recordedIds)].filter((t) => Boolean(t));
2237
+ // C3/S2: standing notes. The invite, the registrar token and the domain wait on a person; they hold nothing else back.
2238
+ const standing = [
2239
+ ownerInviteBlocked,
2240
+ registrarBlocked,
2241
+ ownerDomainBlockedText(recordedIds),
2242
+ ].filter((t) => Boolean(t));
2237
2243
  const standingNote = standing.length > 0 ? standing.join('; ') : null;
2238
2244
  const key = resourceKey('coolify', 'app', PLATFORM_AUTH_KEY);
2239
2245
  const connectorApplied = Boolean(recordedIds[PLATFORM_AUTH_CONNECTOR_APP_SCOPE_KEY]);
@@ -145,11 +145,16 @@ export interface PlatformAuthDeps {
145
145
  * and the planner reports it. The port may be a proxy that answers any method name, so the host says it.
146
146
  */
147
147
  signinMailWritable?: boolean;
148
+ /**
149
+ * The host's ZITADEL port has `ensureRegistrarUser` and `mintRegistrarToken` (the broker's registrar token,
150
+ * `plan/broker-registrar-token.ts`). Only `true` makes it; otherwise the planner reports it blocked.
151
+ */
152
+ registrarTokenWritable?: boolean;
148
153
  /** Overrides the Lettermint SMTP relay ZITADEL is given. Absent, Lettermint exactly as before. */
149
154
  smtp?: PlatformAuthSmtp;
150
155
  coolify: Pick<CoolifyPort, 'findProjectByName' | 'createProject' | 'findOrCreateEnvironment' | 'listServers' | 'findApplicationByName' | 'getApplication' | 'requireGithubApp' | 'createGithubAppApplication' | 'createDockerImageApplication' | 'createDockerfileApplication' | 'createPostgresDatabase' | 'getDatabase' | 'bulkUpsertEnvs' | 'listEnvs' | 'updateApplication' | 'deploy' | 'raw'> &
151
156
  /** An own org's sign-in database needs a volume and the CA file mount (#746); the platform's does not. */
152
- Partial<Pick<CoolifyPort, 'listStorages' | 'createPersistentStorage' | 'createFileStorage'>>;
157
+ Partial<Pick<CoolifyPort, 'listStorages' | 'createPersistentStorage' | 'createFileStorage' | 'deleteEnv'>>;
153
158
  /** F4.dns: every DNS record call, through edge at its recorded address (see {@link PlatformDnsFactory}). */
154
159
  dns: PlatformDnsFactory;
155
160
  /** F4.tunnels: every tunnel call, through edge at its recorded address (see {@link PlatformTunnelsFactory}). */
@@ -161,7 +166,12 @@ export interface PlatformAuthDeps {
161
166
  requestHeaders?: PlatformRequestHeadersFactory;
162
167
  cloudflare: Pick<CloudflarePort, 'findZoneByApex'>;
163
168
  /** Must target the platform's own new instance (its issuer), or `zitadelForInstance` must be set. */
164
- zitadel: Pick<ZitadelPort, 'findOrCreateOrg' | 'mintAdminMachineUser' | 'mintLoginClientMachineUser' | 'listLoginClientPats' | 'reserveUserName' | 'closeSelfRegistration' | 'enforceSecondFactor' | 'inviteOrgOwner' | 'ensureDomainPolicy' | 'addOrgDomain' | 'removeOrgDomain' | 'setPrimaryOrgDomain' | 'setUserMetadata' | 'getUserMetadata'> & PlatformZitadelSmtp & Partial<PlatformZitadelMailBranding>;
169
+ zitadel: Pick<ZitadelPort, 'findOrCreateOrg' | 'mintAdminMachineUser' | 'mintLoginClientMachineUser' | 'listLoginClientPats' | 'reserveUserName' | 'closeSelfRegistration' | 'enforceSecondFactor' | 'inviteOrgOwner' | 'ensureDomainPolicy' | 'addOrgDomain' | 'removeOrgDomain' | 'setPrimaryOrgDomain' | 'setUserMetadata' | 'getUserMetadata'> & PlatformZitadelSmtp & Partial<Pick<ZitadelPort, 'ensureRegistrarUser' | 'mintRegistrarToken'>> & Partial<PlatformZitadelMailBranding> & {
170
+ getOrg?: (id: string) => Promise<{
171
+ id: string;
172
+ name: string;
173
+ }>;
174
+ };
165
175
  /**
166
176
  * Mails the owner's first-sign-in link to their personal address (the same
167
177
  * port as `FoundryStepExecutorPorts.ownerInviteMail`). The link carries the
@@ -411,6 +421,7 @@ export declare class FoundryPlatformStepExecutor implements PlatformStepExecutor
411
421
  authOtherInputsMissing?: string[];
412
422
  authMailKeyHeld?: boolean;
413
423
  authOwnerInvite?: string;
424
+ authRegistrar?: string;
414
425
  files?: string;
415
426
  integrations?: string;
416
427
  ai?: string;
@@ -575,6 +586,7 @@ export declare const AUTH_BROKER_MCP_PATH_PATTERN = "^/(\\.well-known/oauth-auth
575
586
  export declare const PLATFORM_AUTH_DB_SCOPE_KEY = "coolify_platform_auth_database";
576
587
  export declare const PLATFORM_AUTH_ZITADEL_SCOPE_KEY = "coolify_platform_auth_zitadel_app";
577
588
  export declare const PLATFORM_AUTH_LOGIN_SCOPE_KEY = "coolify_platform_auth_login_app";
589
+ export declare const PLATFORM_AUTH_ORG_NAME = "foundry";
578
590
  /**
579
591
  * #283: the ref the new instance's IAM_OWNER machine token (a PAT) is
580
592
  * delivered under, once. A host reads it back to build `zitadelForInstance`
@@ -600,6 +612,11 @@ export interface AuthStackInput {
600
612
  network: ServiceNetwork;
601
613
  /** The ZITADEL SMTP sender address. */
602
614
  smtpSender: string;
615
+ /**
616
+ * `LOGIN_PASSKEY_RP_ID` for both login apps: a bare domain every host they serve sits under (the login
617
+ * refuses any other). Absent: unset, and each passkey belongs to the host it was made on.
618
+ */
619
+ passkeyRpId?: string;
603
620
  /** Where the login app sends a user with no sign-in request in flight, or null to leave it unset. */
604
621
  defaultRedirectUri: string | null;
605
622
  /** The company whose owner is invited into its own sign-in org; null for a company's own stack. */
@@ -620,6 +637,11 @@ export interface AuthStackInput {
620
637
  mailBranding?: {
621
638
  companyName: string;
622
639
  };
640
+ /**
641
+ * The broker gets a registrar token (`ZITADEL_REGISTRAR_TOKEN`, `plan/broker-registrar-token.ts`) covering the
642
+ * instance's first organisation and these (the company's sign-in and customers organisations). Absent: none.
643
+ */
644
+ registrarOrgIds?: readonly string[];
623
645
  }
624
646
  /**
625
647
  * #168: makes `coolify:app` keyed {@link PLATFORM_AUTH_KEY} true -- a Coolify
@@ -15,7 +15,7 @@ import { desktopCallbackUri, unionRedirectUris } from './desktop-callback.js';
15
15
  import { placedOnPlatform } from './destinations.js';
16
16
  import { ACCESS_POLICY_NAME, LOG_DIR, coolifyProjectMark, getDatabaseStarted, isNotFoundError, isRunningOrStarting, setOidcAppLoginBaseUri, } from './executor.js';
17
17
  import { ACCESS_PROVIDER_RECORD_KEY, GATE_CLAIM_NAME } from './gates.js';
18
- import { IMAGE_HEALTHCHECK_SETTINGS, commandHealthCheckSettings, healthCheckSettings, tcpProbe, } from './health-check-settings.js';
18
+ import { IMAGE_HEALTHCHECK_SETTINGS, commandHealthCheckSettings, healthCheckSettings, rollingUpdateSettings, tcpProbe, } from './health-check-settings.js';
19
19
  import { FILES_MANAGEMENT_KEY_REF, JOBS_ADMIN_TOKEN_REF, PLATFORM_AI_MANAGEMENT_KEY_REF, } from './held-key-refs.js';
20
20
  import { LIMITS, limited } from './own-limits.js';
21
21
  import { postLogoutAppliedValue, postLogoutUrisFor } from './post-logout-uris.js';
@@ -24,6 +24,7 @@ import { buildStageCopyHostPorts } from './stage-copy-host.js';
24
24
  import { stageGateKey, stageHostname, syncStageGateMembers, } from './stage-guards.js';
25
25
  export { FILES_MANAGEMENT_KEY_REF, JOBS_ADMIN_TOKEN_REF, PLATFORM_AI_MANAGEMENT_KEY_REF, } from './held-key-refs.js';
26
26
  import { BROKER_DB_NAME, BROKER_DB_USER, ensureBrokerMcpEnv } from './broker-mcp.js';
27
+ import { REGISTRAR_TOKEN_SCOPE_KEY, REGISTRAR_TOKEN_UNDELIVERED_SCOPE_KEY, RegistrarTokenError, ensureBrokerRegistrarToken, } from './broker-registrar-token.js';
27
28
  import { withTimeout } from './call-timeout.js';
28
29
  import { SIGN_IN_BIND_KEY_ENV, SIGN_IN_BIND_KEY_MIN_LENGTH, SIGN_IN_BIND_KEY_SCOPE_KEY, } from './cookie-domain.js';
29
30
  import { coolifyDomains, coolifyTunnelDomains, filesShareHostname } from './hostnames.js';
@@ -436,6 +437,9 @@ export class FoundryPlatformStepExecutor {
436
437
  const inviteBlocked = ownerInviteBlockedReason(ownerCompany, Boolean(this.auth.ownerInviteMail));
437
438
  if (inviteBlocked)
438
439
  blocked.authOwnerInvite = inviteBlocked;
440
+ const registrarBlocked = await registrarTokenBlockedReason(this.auth, recordedIds);
441
+ if (registrarBlocked)
442
+ blocked.authRegistrar = registrarBlocked;
439
443
  }
440
444
  if (platform.mail.enabled) {
441
445
  const mailBlocked = await mailBlockedReason(platform, this.mail, recordedIds);
@@ -911,13 +915,22 @@ export class FoundryPlatformStepExecutor {
911
915
  const loginClientDrifted = await authLoginClientDrifted(platform, this.auth, recordedIds);
912
916
  // The broker lacks the MCP database and secret a shared org's entry needs (auth ADR 0002).
913
917
  const mcpDrifted = recordedIds[PLATFORM_AUTH_MCP_SCOPE_KEY] !== '1' && (await this.mcpWanted());
918
+ // The broker has no registrar token yet (`ZITADEL_REGISTRAR_TOKEN`).
919
+ // A token minted and never held is blocked (`blockedSteps`), not drift: no apply could change it.
920
+ const registrarDrifted = this.auth.registrarTokenWritable === true &&
921
+ !recordedIds[REGISTRAR_TOKEN_SCOPE_KEY] &&
922
+ !(await registrarTokenBlockedReason(this.auth, recordedIds));
914
923
  if (loginClientDrifted ||
915
924
  mcpDrifted ||
925
+ registrarDrifted ||
916
926
  (await this.signInLoginDrifted(this.auth, recordedIds))) {
917
927
  result.auth = true;
918
928
  }
919
929
  if (await authAppsNotRunning(this.auth.coolify, recordedIds))
920
930
  result.authApps = true;
931
+ // An app made before the rolling update still has its fixed container name: the step clears it.
932
+ if (await authAppsKeepInternalName(this.auth.coolify, recordedIds))
933
+ result.auth = true;
921
934
  }
922
935
  // #380: once the runner app is recorded, re-check its live env against
923
936
  // every setting `ensureStarted()` requires -- the gap that left the
@@ -1561,7 +1574,25 @@ const PLATFORM_AUTH_ADMIN_USER_NAME = 'foundry-admin';
1561
1574
  const PLATFORM_AUTH_RESERVED_USERNAME = 'admin';
1562
1575
  /** #216 (building on #209/#215): the narrower `IAM_LOGIN_CLIENT` machine user for the login app and the broker. */
1563
1576
  const PLATFORM_AUTH_LOGIN_CLIENT_USER_NAME = 'foundry-login-client';
1564
- const PLATFORM_AUTH_ORG_NAME = 'foundry';
1577
+ export const PLATFORM_AUTH_ORG_NAME = 'foundry';
1578
+ /**
1579
+ * The instance's first organisation (the default one, whose name the login shows as its wordmark).
1580
+ * A company's own instance passes the recorded id (the platform passes none and keeps the name `foundry`);
1581
+ * found by that id when there is one, because a company's own instance has it renamed to the
1582
+ * company's name (`applySigninMail`), and a lookup by name would then make a second `foundry` org. The
1583
+ * name is the way in only before the id is recorded. A recorded id ZITADEL no longer has is a failure,
1584
+ * never a reason to make another org.
1585
+ */
1586
+ async function platformAuthFirstOrg(zitadel, recordedId) {
1587
+ if (recordedId) {
1588
+ // The name is no way back in once the id is recorded: the org was renamed, and a lookup by name would make another.
1589
+ if (typeof zitadel.getOrg !== 'function') {
1590
+ throw new Error("the instance's first organisation is recorded, but the host's ZITADEL port cannot read it by id; it is not looked up by name");
1591
+ }
1592
+ return await zitadel.getOrg(recordedId);
1593
+ }
1594
+ return (await zitadel.findOrCreateOrg(PLATFORM_AUTH_ORG_NAME)).org;
1595
+ }
1565
1596
  /**
1566
1597
  * #283: the ref the new instance's IAM_OWNER machine token (a PAT) is
1567
1598
  * delivered under, once. A host reads it back to build `zitadelForInstance`
@@ -1628,6 +1659,27 @@ function loginClientRecordValue(userId, patId, token) {
1628
1659
  };
1629
1660
  return JSON.stringify(record);
1630
1661
  }
1662
+ /**
1663
+ * The broker's registrar token was minted and never held: nothing can deliver it and a second mint is refused
1664
+ * (platform-step rule 1), so the plan reports it and plans nothing for it. Undefined when not in that state.
1665
+ */
1666
+ async function registrarTokenBlockedReason(auth, recordedIds) {
1667
+ const userId = recordedIds[REGISTRAR_TOKEN_UNDELIVERED_SCOPE_KEY];
1668
+ if (auth.registrarTokenWritable !== true || !userId || recordedIds[REGISTRAR_TOKEN_SCOPE_KEY]) {
1669
+ return undefined;
1670
+ }
1671
+ let held;
1672
+ try {
1673
+ held = await auth.heldKeys?.resolve(platformTarget().refs.registrarToken);
1674
+ }
1675
+ catch {
1676
+ // Not read: nothing is claimed, and the step itself reports the state.
1677
+ return undefined;
1678
+ }
1679
+ if (held)
1680
+ return undefined;
1681
+ return `the broker's registrar token of ZITADEL machine user ${userId} was minted but never confirmed held; remove the recorded key ${REGISTRAR_TOKEN_UNDELIVERED_SCOPE_KEY} to make a new one`;
1682
+ }
1631
1683
  /**
1632
1684
  * #216 round 4: whether the login-client PAT recorded for this platform
1633
1685
  * (under {@link PLATFORM_AUTH_LOGIN_CLIENT_USER_SCOPE_KEY}) is still, right
@@ -2037,7 +2089,7 @@ export async function provisionAuthStack(scope, deps, target, input) {
2037
2089
  const gitBranchPatchFor = (app) => authRefChanged && !app.justCreated ? { git_branch: gitRef } : {};
2038
2090
  // The Dockerfile's own HEALTHCHECK is the live check (`IMAGE_HEALTHCHECK_SETTINGS`).
2039
2091
  await deps.coolify.updateApplication(loginAppId, {
2040
- custom_internal_name: target.internal.authLogin,
2092
+ ...rollingUpdateSettings(target.internal.authLogin),
2041
2093
  ...IMAGE_HEALTHCHECK_SETTINGS,
2042
2094
  ...gitBranchPatchFor(loginApp),
2043
2095
  });
@@ -2071,7 +2123,7 @@ export async function provisionAuthStack(scope, deps, target, input) {
2071
2123
  : null;
2072
2124
  if (signInApp && signInNames) {
2073
2125
  await deps.coolify.updateApplication(signInApp.appId, {
2074
- custom_internal_name: signInNames.internal,
2126
+ ...rollingUpdateSettings(signInNames.internal),
2075
2127
  ...IMAGE_HEALTHCHECK_SETTINGS,
2076
2128
  ...gitBranchPatchFor(signInApp),
2077
2129
  });
@@ -2097,7 +2149,7 @@ export async function provisionAuthStack(scope, deps, target, input) {
2097
2149
  const brokerAppId = brokerApp.appId;
2098
2150
  await record(PLATFORM_AUTH_BROKER_SCOPE_KEY, brokerAppId);
2099
2151
  await deps.coolify.updateApplication(brokerAppId, {
2100
- custom_internal_name: target.internal.authBroker,
2152
+ ...rollingUpdateSettings(target.internal.authBroker),
2101
2153
  ...IMAGE_HEALTHCHECK_SETTINGS,
2102
2154
  ...gitBranchPatchFor(brokerApp),
2103
2155
  });
@@ -2201,6 +2253,8 @@ export async function provisionAuthStack(scope, deps, target, input) {
2201
2253
  loginEnvs.push({ key: 'LETTERMINT_API_TOKEN', value: heldMailKey });
2202
2254
  if (input.smtpSender)
2203
2255
  loginEnvs.push({ key: 'LOGIN_MAIL_FROM', value: input.smtpSender });
2256
+ if (input.passkeyRpId)
2257
+ loginEnvs.push({ key: LOGIN_PASSKEY_RP_ID_ENV, value: input.passkeyRpId });
2204
2258
  // `AUTH_APPS` and `ZITADEL_SERVICE_USER_TOKEN`/`_FILE` are not pushed here
2205
2259
  // -- this function's own doc comment, "Known gaps". `LETTERMINT_API_TOKEN`
2206
2260
  // is, once held (#210): pushing it costs nothing even while the broker
@@ -2242,6 +2296,12 @@ export async function provisionAuthStack(scope, deps, target, input) {
2242
2296
  await deps.coolify.bulkUpsertEnvs(zitadelAppId, zitadelEnvs);
2243
2297
  await deps.coolify.bulkUpsertEnvs(loginAppId, loginEnvs);
2244
2298
  await deps.coolify.bulkUpsertEnvs(brokerAppId, brokerEnvs);
2299
+ // A domain removed (or `usesDomain` off) leaves no RP ID to set: a value from an earlier apply is removed,
2300
+ // and the apps deploy again, so a passkey is never bound to a domain the company no longer has.
2301
+ const staleRpIdLogin = input.passkeyRpId
2302
+ ? false
2303
+ : await removeLoginEnv(deps.coolify, loginAppId, LOGIN_PASSKEY_RP_ID_ENV);
2304
+ let staleRpIdSignIn = false;
2245
2305
  // The in-app login: the login's env, the base path as a build argument, and the host table.
2246
2306
  let signInEnvsChanged = false;
2247
2307
  // The session bind key is minted once, on the in-app login app only: a key already live (32 characters or
@@ -2265,6 +2325,9 @@ export async function provisionAuthStack(scope, deps, target, input) {
2265
2325
  ];
2266
2326
  signInEnvsChanged = await envsDiffer(deps.coolify, signInApp.appId, signInEnvs);
2267
2327
  await deps.coolify.bulkUpsertEnvs(signInApp.appId, signInEnvs);
2328
+ staleRpIdSignIn = input.passkeyRpId
2329
+ ? false
2330
+ : await removeLoginEnv(deps.coolify, signInApp.appId, LOGIN_PASSKEY_RP_ID_ENV);
2268
2331
  }
2269
2332
  // Deploy an app that was just created, is not running (a throw after the
2270
2333
  // create left it never deployed, or it stopped), or whose envs changed.
@@ -2290,10 +2353,12 @@ export async function provisionAuthStack(scope, deps, target, input) {
2290
2353
  // died between the write and the deploy leaves the marker unset, and the next run sees no env diff.
2291
2354
  const loginEnvUnconfirmed = Boolean(heldMailKey &&
2292
2355
  input.smtpSender &&
2293
- recorded[PLATFORM_AUTH_LOGIN_ENV_SCOPE_KEY] !== authLoginEnvMarker(input.smtpSender));
2356
+ recorded[PLATFORM_AUTH_LOGIN_ENV_SCOPE_KEY] !==
2357
+ authLoginEnvMarker(input.smtpSender, input.passkeyRpId));
2294
2358
  if (loginApp.justCreated ||
2295
2359
  !loginApp.running ||
2296
2360
  loginEnvsChanged ||
2361
+ staleRpIdLogin ||
2297
2362
  loginEnvUnconfirmed ||
2298
2363
  authRefChanged) {
2299
2364
  await deps.coolify.deploy(loginAppId);
@@ -2305,6 +2370,7 @@ export async function provisionAuthStack(scope, deps, target, input) {
2305
2370
  (signInApp.justCreated ||
2306
2371
  !signInApp.running ||
2307
2372
  signInEnvsChanged ||
2373
+ staleRpIdSignIn ||
2308
2374
  bindKeyWritten ||
2309
2375
  bindKeyUnconfirmed ||
2310
2376
  loginEnvUnconfirmed ||
@@ -2316,7 +2382,7 @@ export async function provisionAuthStack(scope, deps, target, input) {
2316
2382
  // The mail env is live on the login apps: written above and deployed with them. Without the held
2317
2383
  // key the marker stays unset, so the step is planned again once the key is held.
2318
2384
  if (heldMailKey && input.smtpSender) {
2319
- await record(PLATFORM_AUTH_LOGIN_ENV_SCOPE_KEY, authLoginEnvMarker(input.smtpSender));
2385
+ await record(PLATFORM_AUTH_LOGIN_ENV_SCOPE_KEY, authLoginEnvMarker(input.smtpSender, input.passkeyRpId));
2320
2386
  }
2321
2387
  // #211: the broker's own `server.ts` refuses to start without
2322
2388
  // `ZITADEL_SERVICE_USER_TOKEN`/`_FILE` and `LETTERMINT_API_TOKEN` --
@@ -2501,7 +2567,7 @@ export async function provisionAuthStack(scope, deps, target, input) {
2501
2567
  // gets here right after the deploy.
2502
2568
  await waitForInstance(`https://${hostname}/.well-known/openid-configuration`, runtime, sleep, label);
2503
2569
  const zitadel = instanceZitadel();
2504
- const { org } = await zitadel.findOrCreateOrg(PLATFORM_AUTH_ORG_NAME);
2570
+ const org = await platformAuthFirstOrg(zitadel, target.kind === 'org' ? recorded[PLATFORM_ZITADEL_ORG_SCOPE_KEY] : undefined);
2505
2571
  // #216 round 5: recorded here too (mirroring `provisionPlatformArchon`),
2506
2572
  // so `authLoginClientDrifted` (below) never needs its own ZITADEL call
2507
2573
  // to resolve the org -- see that function's own doc comment.
@@ -2642,7 +2708,7 @@ export async function provisionAuthStack(scope, deps, target, input) {
2642
2708
  throw new Error(`${label}: the login-client token of ZITADEL machine user ${loginClientLost} was minted but never confirmed delivered; remove the recorded key ${PLATFORM_AUTH_LOGIN_CLIENT_UNDELIVERED_SCOPE_KEY} to mint a new one`);
2643
2709
  }
2644
2710
  const loginClientZitadel = instanceZitadel();
2645
- const { org: loginClientOrg } = await loginClientZitadel.findOrCreateOrg(PLATFORM_AUTH_ORG_NAME);
2711
+ const loginClientOrg = await platformAuthFirstOrg(loginClientZitadel, target.kind === 'org' ? freshRecordedIds[PLATFORM_ZITADEL_ORG_SCOPE_KEY] : undefined);
2646
2712
  // #216 round 5: backfills the org id for a platform whose admin block
2647
2713
  // (above) ran before this recording existed and has not run since --
2648
2714
  // see `authLoginClientDrifted`'s own doc comment.
@@ -2788,12 +2854,17 @@ export async function provisionAuthStack(scope, deps, target, input) {
2788
2854
  (deps.signinMailWritable !== true ||
2789
2855
  typeof mailZitadel.ensureEmailOtpMessageText !== 'function' ||
2790
2856
  typeof mailZitadel.ensureLabelPolicy !== 'function' ||
2857
+ typeof mailZitadel.ensureOrgName !== 'function' ||
2791
2858
  typeof mailZitadel.ensureEmailOtpGenerator !== 'function')) {
2792
2859
  console.info(`${label}: the host cannot write message texts, so the sign-in mail keeps ZITADEL's default text`);
2793
2860
  }
2794
2861
  else if (input.mailBranding) {
2862
+ const firstOrgId = scope.recordedIds()[PLATFORM_ZITADEL_ORG_SCOPE_KEY];
2863
+ if (!firstOrgId) {
2864
+ throw new Error(`${label}: the instance's first organisation is not recorded`);
2865
+ }
2795
2866
  try {
2796
- await applySigninMail(mailZitadel, input.mailBranding.companyName);
2867
+ await applySigninMail(mailZitadel, input.mailBranding.companyName, firstOrgId);
2797
2868
  }
2798
2869
  catch (error) {
2799
2870
  const cause = redactSecrets(error instanceof Error ? error.message : String(error), [
@@ -2854,6 +2925,41 @@ export async function provisionAuthStack(scope, deps, target, input) {
2854
2925
  }
2855
2926
  }
2856
2927
  }
2928
+ // The broker's registrar token: made once, held, written to the broker's env, adopted when set by hand.
2929
+ // Last, so a stop here never holds up the login env, the mail or the invite above. It is reported, not
2930
+ // thrown: the plan names the step again while the token is due, or blocked while it was minted and not held.
2931
+ if (input.registrarOrgIds) {
2932
+ try {
2933
+ const firstOrgId = scope.recordedIds()[PLATFORM_ZITADEL_ORG_SCOPE_KEY];
2934
+ if (!firstOrgId) {
2935
+ throw new RegistrarTokenError(`${label}: the instance's first organisation is not recorded`);
2936
+ }
2937
+ await ensureBrokerRegistrarToken({
2938
+ label,
2939
+ zitadel: instanceZitadel(),
2940
+ coolify: deps.coolify,
2941
+ brokerAppId,
2942
+ homeOrgId: firstOrgId,
2943
+ orgIds: input.registrarOrgIds,
2944
+ heldKeys: deps.heldKeys,
2945
+ ref: target.refs.registrarToken,
2946
+ recorded: () => scope.recordedIds(),
2947
+ record,
2948
+ persist: async () => {
2949
+ await scope.persist();
2950
+ },
2951
+ secret: (value) => runtime.onSecret?.(value),
2952
+ sleep,
2953
+ log: (line) => console.info(line),
2954
+ });
2955
+ }
2956
+ catch (error) {
2957
+ // Only our own text, which names ids; any other cause can carry a token.
2958
+ console.warn(error instanceof RegistrarTokenError
2959
+ ? error.message
2960
+ : `${label}: the broker's registrar token is not in place yet; the next apply tries again`);
2961
+ }
2962
+ }
2857
2963
  return { resourceId: zitadelAppId };
2858
2964
  }
2859
2965
  /** The platform's sign-in project: the shared `platform` project, its id recorded under the sign-in's own scope key. */
@@ -2886,6 +2992,8 @@ async function provisionPlatformAuth(context, deps, options = {}) {
2886
2992
  // In-app sign-in: the second login app copies the login's host table (the host's port writes that).
2887
2993
  ...(signInLogin ? { signInLogin: { bindings: null } } : {}),
2888
2994
  mcp,
2995
+ // The shared companies' organisations are granted by each company's registrar step.
2996
+ ...(deps.registrarTokenWritable === true ? { registrarOrgIds: [] } : {}),
2889
2997
  });
2890
2998
  }
2891
2999
  /**
@@ -6161,6 +6269,30 @@ async function authAppsNotRunning(coolify, recordedIds) {
6161
6269
  }
6162
6270
  return false;
6163
6271
  }
6272
+ /**
6273
+ * Whether the login app, the in-app login app or the broker still has a fixed container name
6274
+ * (`custom_internal_name`), which stops Coolify's rolling update. `rollingUpdateSettings` clears it on every
6275
+ * run of the step, so this plans the step again for an app made before. A Coolify read error counts as not drifted.
6276
+ */
6277
+ async function authAppsKeepInternalName(coolify, recordedIds) {
6278
+ for (const scopeKey of [
6279
+ PLATFORM_AUTH_LOGIN_SCOPE_KEY,
6280
+ SIGN_IN_LOGIN_APP_SCOPE_KEY,
6281
+ PLATFORM_AUTH_BROKER_SCOPE_KEY,
6282
+ ]) {
6283
+ const appId = recordedIds[scopeKey];
6284
+ if (!appId)
6285
+ continue;
6286
+ try {
6287
+ if ((await coolify.getApplication(appId)).custom_internal_name)
6288
+ return true;
6289
+ }
6290
+ catch {
6291
+ // Nothing logged: the error can carry Coolify's response.
6292
+ }
6293
+ }
6294
+ return false;
6295
+ }
6164
6296
  /**
6165
6297
  * Archon's live release webhook secret is missing, or is not the one its last deploy recorded
6166
6298
  * (`PLATFORM_ARCHON_WEBHOOK_SECRET_DEPLOYED_SCOPE_KEY`): the release hook step wrote it after Archon's
@@ -7516,6 +7648,17 @@ export function brokerDeployRecordValue(brokerAppId, authAppsRaw) {
7516
7648
  };
7517
7649
  return JSON.stringify(record);
7518
7650
  }
7651
+ const LOGIN_PASSKEY_RP_ID_ENV = 'LOGIN_PASSKEY_RP_ID';
7652
+ /** Removes one env from an app when it is live. True when it was removed; false with nothing live or no delete on the port. */
7653
+ async function removeLoginEnv(coolify, appId, key) {
7654
+ if (typeof coolify.deleteEnv !== 'function')
7655
+ return false;
7656
+ const row = (await coolify.listEnvs(appId)).find((e) => e.key === key && !e.is_preview);
7657
+ if (!row?.uuid)
7658
+ return false;
7659
+ await coolify.deleteEnv(appId, row.uuid);
7660
+ return true;
7661
+ }
7519
7662
  /** Whether any desired env is missing from the app, or set to another value. */
7520
7663
  export async function envsDiffer(coolify, appId, desired) {
7521
7664
  const live = new Map((await coolify.listEnvs(appId)).filter((e) => !e.is_preview).map((e) => [e.key, e.value]));
@@ -8069,7 +8212,7 @@ const EDGE_REPO = 'wtfalch/edge';
8069
8212
  const PLATFORM_EDGE_PROJECT = 'platform';
8070
8213
  const PLATFORM_EDGE_DB_NAME = 'platform-edge-db';
8071
8214
  const EDGE_APP_NAME = 'edge';
8072
- /** The app's fixed `custom_internal_name`, the way `platform:files`' runner names itself. */
8215
+ /** The app's network alias (#1054: not a `custom_internal_name`, which turns off rolling updates). */
8073
8216
  const EDGE_RUNNER_INTERNAL_NAME = 'platform-edge';
8074
8217
  /** The runner's port (its Dockerfile: `PORT=3000`, `EXPOSE 3000`). */
8075
8218
  const EDGE_PORT = '3000';
@@ -8274,7 +8417,7 @@ async function provisionPlatformEdge(context, deps) {
8274
8417
  // port the `waitForNewDeployment` call below already probes internally.
8275
8418
  await deps.coolify.updateApplication(appId, {
8276
8419
  dockerfile_target_build: 'runner',
8277
- custom_internal_name: EDGE_RUNNER_INTERNAL_NAME,
8420
+ ...rollingUpdateSettings(EDGE_RUNNER_INTERNAL_NAME),
8278
8421
  git_branch: gitRef,
8279
8422
  ...healthCheckSettings('/api/health', Number(EDGE_PORT)),
8280
8423
  });
@@ -2,7 +2,7 @@
2
2
  * The sign-in code mail of a company that runs its own sign-in: the company's name in the sender and
3
3
  * the text, the platform's design tokens for the look. ZITADEL's email-code message is built from the
4
4
  * instance's custom text and label policy; both are written through `PlatformZitadelMailBranding`
5
- * (`@wtfalch/providers` 0.14.0), each only when it differs. The code itself (six digits, five minutes)
5
+ * (`@wtfalch/providers`), each only when it differs. The code itself (six digits, five minutes)
6
6
  * is the instance's email-OTP generator.
7
7
  *
8
8
  * The look: no company declares a design foundry can read (an `OrgTheme` lives in cms and reaches
@@ -10,7 +10,7 @@
10
10
  * (`SIGNIN_MAIL_LABEL_POLICY`). No logo: ZITADEL takes it as a multipart upload the client does not make.
11
11
  */
12
12
  /** Raise it when the text or the look changes, so an applied company plans its sign-in step again. */
13
- export declare const AUTH_SIGNIN_MAIL_VERSION = "2";
13
+ export declare const AUTH_SIGNIN_MAIL_VERSION = "3";
14
14
  /** `signinMailMarker` of the company name and version last written, recorded after both writes. */
15
15
  export declare const PLATFORM_AUTH_SIGNIN_MAIL_SCOPE_KEY = "auth_signin_mail_applied";
16
16
  /** The code the mail carries: six digits, valid five minutes (`123-456` in the mail, see `signinMailText`). */
@@ -18,8 +18,16 @@ export declare const SIGNIN_MAIL_CODE: {
18
18
  readonly length: 6;
19
19
  readonly expirySeconds: 300;
20
20
  };
21
- /** The three ZITADEL calls the sign-in mail needs. `@wtfalch/providers` 0.14.0's `ZitadelClient` has all. */
21
+ /** The four ZITADEL calls the sign-in mail needs. */
22
22
  export interface PlatformZitadelMailBranding {
23
+ /**
24
+ * Renames an organisation, only when its name differs (`PUT /management/v1/orgs/me` as that
25
+ * organisation). The login shows the instance's default organisation's name as its wordmark when the
26
+ * host is bound to no company, so a company's own instance names it after the company.
27
+ */
28
+ ensureOrgName(orgId: string, name: string): Promise<{
29
+ changed: boolean;
30
+ }>;
23
31
  ensureEmailOtpMessageText(language: string, text: {
24
32
  title: string;
25
33
  preHeader: string;
@@ -65,6 +73,11 @@ export declare const SIGNIN_MAIL_LANGUAGE = "en";
65
73
  * holds the code, split in the middle with Go's `slice` (`123-456`), because ZITADEL renders the
66
74
  * greeting as the large heading, so the code is the first thing the reader sees. No greeting by
67
75
  * name: the login fills names from the email address, so it would read "Wtfalch Wtfalch".
76
+ *
77
+ * Paragraphs: ZITADEL v4.17.3 puts `{{.Text}}` in one `<div>` of its built-in mail, where a line
78
+ * break collapses to a space. It escapes the field (`html/template`), then un-escapes the whole mail
79
+ * before sending it over SMTP (`internal/notification/types/user_email.go:43`,
80
+ * `html.UnescapeString`), so `<p>` tags in the text arrive as tags. There is no mail template API.
68
81
  */
69
82
  export declare function signinMailText(companyName: string): {
70
83
  title: string;
@@ -91,7 +104,7 @@ export declare const SIGNIN_MAIL_LABEL_POLICY: {
91
104
  readonly warnColorDark: "#FF9C8C";
92
105
  readonly disableWatermark: true;
93
106
  };
94
- /** Writes the text, the look and the code, each only when it differs. Throws the client's fixed-text error. */
95
- export declare function applySigninMail(zitadel: PlatformZitadelMailBranding, companyName: string): Promise<{
107
+ /** Names the default organisation after the company, then writes the text, the look and the code, each only when it differs. Throws the client's fixed-text error. */
108
+ export declare function applySigninMail(zitadel: PlatformZitadelMailBranding, companyName: string, defaultOrgId: string): Promise<{
96
109
  changed: boolean;
97
110
  }>;