@wtfalch/foundry 0.70.1 → 0.72.0

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 (45) hide show
  1. package/dist/index.d.ts +3 -1
  2. package/dist/index.js +1 -0
  3. package/dist/modules/sign-in.js +81 -9
  4. package/dist/modules/types.d.ts +2 -0
  5. package/dist/org.d.ts +18 -1
  6. package/dist/org.js +40 -0
  7. package/dist/plan/boule.d.ts +15 -0
  8. package/dist/plan/boule.js +13 -0
  9. package/dist/plan/broker-registrar-token.d.ts +69 -0
  10. package/dist/plan/broker-registrar-token.js +215 -0
  11. package/dist/plan/broker-registrar.d.ts +2 -0
  12. package/dist/plan/broker-registrar.js +5 -1
  13. package/dist/plan/diff.d.ts +13 -0
  14. package/dist/plan/diff.js +26 -5
  15. package/dist/plan/executor.d.ts +82 -0
  16. package/dist/plan/executor.js +172 -10
  17. package/dist/plan/health-check-settings.d.ts +13 -0
  18. package/dist/plan/health-check-settings.js +17 -0
  19. package/dist/plan/own-diff.d.ts +32 -0
  20. package/dist/plan/own-diff.js +67 -10
  21. package/dist/plan/own-hostnames.d.ts +8 -0
  22. package/dist/plan/own-hostnames.js +14 -0
  23. package/dist/plan/own-services.d.ts +7 -0
  24. package/dist/plan/own-services.js +2 -0
  25. package/dist/plan/plan-apply.d.ts +36 -0
  26. package/dist/plan/plan-apply.js +91 -0
  27. package/dist/plan/platform-diff.d.ts +9 -5
  28. package/dist/plan/platform-diff.js +16 -10
  29. package/dist/plan/platform-providers.d.ts +24 -2
  30. package/dist/plan/platform-providers.js +155 -12
  31. package/dist/plan/signin-mail.d.ts +18 -5
  32. package/dist/plan/signin-mail.js +14 -6
  33. package/dist/plan/zitadel-login-policy.d.ts +1 -1
  34. package/dist/plan/zitadel-login-policy.js +72 -46
  35. package/dist/plan/zitadel-machine-user.d.ts +14 -1
  36. package/dist/plan/zitadel-machine-user.js +96 -13
  37. package/dist/platform.d.ts +3 -0
  38. package/dist/release/fanout.d.ts +77 -0
  39. package/dist/release/fanout.js +177 -0
  40. package/dist/release/run.js +2 -1
  41. package/dist/release/state.d.ts +3 -1
  42. package/dist/release/state.js +3 -0
  43. package/dist/release/types.d.ts +9 -1
  44. package/dist/store/schema.d.ts +4 -0
  45. package/package.json +3 -3
@@ -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
  }>;
@@ -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 const AUTH_SIGNIN_MAIL_VERSION = '2';
13
+ export const AUTH_SIGNIN_MAIL_VERSION = '3';
14
14
  /** `signinMailMarker` of the company name and version last written, recorded after both writes. */
15
15
  export 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`). */
@@ -37,6 +37,11 @@ export const SIGNIN_MAIL_LANGUAGE = 'en';
37
37
  * holds the code, split in the middle with Go's `slice` (`123-456`), because ZITADEL renders the
38
38
  * greeting as the large heading, so the code is the first thing the reader sees. No greeting by
39
39
  * name: the login fills names from the email address, so it would read "Wtfalch Wtfalch".
40
+ *
41
+ * Paragraphs: ZITADEL v4.17.3 puts `{{.Text}}` in one `<div>` of its built-in mail, where a line
42
+ * break collapses to a space. It escapes the field (`html/template`), then un-escapes the whole mail
43
+ * before sending it over SMTP (`internal/notification/types/user_email.go:43`,
44
+ * `html.UnescapeString`), so `<p>` tags in the text arrive as tags. There is no mail template API.
40
45
  */
41
46
  export function signinMailText(companyName) {
42
47
  const name = signinMailName(companyName);
@@ -45,7 +50,7 @@ export function signinMailText(companyName) {
45
50
  preHeader: `Your ${name} sign-in code`,
46
51
  subject: `Your ${name} sign-in code`,
47
52
  greeting: '{{slice .OTP 0 3}}-{{slice .OTP 3}}',
48
- text: 'Enter this code to sign in. It works for five minutes. Or use the button to sign in on this device.',
53
+ text: `<p style="margin:0 0 12px 0">Enter this code to sign in. It works for five minutes.</p><p style="margin:0">Or use the button to sign in on this device.</p>`,
49
54
  buttonText: 'Sign in',
50
55
  footerText: name,
51
56
  };
@@ -66,10 +71,13 @@ export const SIGNIN_MAIL_LABEL_POLICY = {
66
71
  warnColorDark: '#FF9C8C',
67
72
  disableWatermark: true,
68
73
  };
69
- /** Writes the text, the look and the code, each only when it differs. Throws the client's fixed-text error. */
70
- export async function applySigninMail(zitadel, companyName) {
74
+ /** 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. */
75
+ export async function applySigninMail(zitadel, companyName, defaultOrgId) {
76
+ const name = await zitadel.ensureOrgName(defaultOrgId, signinMailName(companyName));
71
77
  const text = await zitadel.ensureEmailOtpMessageText(SIGNIN_MAIL_LANGUAGE, signinMailText(companyName));
72
78
  const look = await zitadel.ensureLabelPolicy({ ...SIGNIN_MAIL_LABEL_POLICY });
73
79
  const code = await zitadel.ensureEmailOtpGenerator({ ...SIGNIN_MAIL_CODE });
74
- return { changed: text.changed || look.changed || code.changed };
80
+ return {
81
+ changed: name.changed || text.changed || look.changed || code.changed,
82
+ };
75
83
  }
@@ -20,4 +20,4 @@ export interface ZitadelLoginPolicyOptions {
20
20
  * policy (POST), so a later change to the default cannot turn registration
21
21
  * back on. A custom policy keeps its own factors and IdPs (PUT).
22
22
  */
23
- export declare function createZitadelLoginPolicy(options: ZitadelLoginPolicyOptions): Pick<ZitadelPort, 'closeSelfRegistration' | 'enforceSecondFactor'>;
23
+ export declare function createZitadelLoginPolicy(options: ZitadelLoginPolicyOptions): Required<Pick<ZitadelPort, 'closeSelfRegistration' | 'enforceSecondFactor' | 'setCustomersLoginPolicy'>>;