@wtfalch/foundry 0.71.0 → 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 (36) 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/org.d.ts +18 -1
  5. package/dist/org.js +40 -0
  6. package/dist/plan/boule.d.ts +15 -0
  7. package/dist/plan/boule.js +13 -0
  8. package/dist/plan/broker-registrar-token.d.ts +69 -0
  9. package/dist/plan/broker-registrar-token.js +215 -0
  10. package/dist/plan/broker-registrar.d.ts +2 -0
  11. package/dist/plan/broker-registrar.js +5 -1
  12. package/dist/plan/diff.d.ts +13 -0
  13. package/dist/plan/diff.js +26 -5
  14. package/dist/plan/executor.d.ts +66 -0
  15. package/dist/plan/executor.js +136 -8
  16. package/dist/plan/health-check-settings.d.ts +13 -0
  17. package/dist/plan/health-check-settings.js +17 -0
  18. package/dist/plan/own-diff.d.ts +32 -0
  19. package/dist/plan/own-diff.js +67 -10
  20. package/dist/plan/own-hostnames.d.ts +8 -0
  21. package/dist/plan/own-hostnames.js +14 -0
  22. package/dist/plan/own-services.d.ts +7 -0
  23. package/dist/plan/own-services.js +2 -0
  24. package/dist/plan/plan-apply.d.ts +15 -0
  25. package/dist/plan/plan-apply.js +21 -0
  26. package/dist/plan/platform-diff.d.ts +9 -5
  27. package/dist/plan/platform-diff.js +16 -10
  28. package/dist/plan/platform-providers.d.ts +24 -2
  29. package/dist/plan/platform-providers.js +155 -12
  30. package/dist/plan/signin-mail.d.ts +18 -5
  31. package/dist/plan/signin-mail.js +14 -6
  32. package/dist/plan/zitadel-login-policy.d.ts +1 -1
  33. package/dist/plan/zitadel-login-policy.js +72 -46
  34. package/dist/plan/zitadel-machine-user.d.ts +14 -1
  35. package/dist/plan/zitadel-machine-user.js +96 -13
  36. package/package.json +3 -3
@@ -1,6 +1,7 @@
1
1
  import { randomBytes, randomUUID } from 'node:crypto';
2
2
  import { ZITADEL_ORG_SCOPE_KEY } from './agora.js';
3
3
  import { AUTH_REGISTRAR_KEY_ENV, AUTH_REGISTRAR_URL_ENV, customersOrgIdOf, registrarWanted, } from './boule.js';
4
+ import { REGISTRAR_TOKEN_ORGS_SCOPE_KEY, registrarOrgsMarker } from './broker-registrar-token.js';
4
5
  import { loginHostnameFor } from './login-host.js';
5
6
  import { brokerDeployRecordValue, missingBrokerInputs } from './platform-providers.js';
6
7
  import { testCustomersOrgKey } from './workspace-client.js';
@@ -403,7 +404,10 @@ export function planBrokerRegistrar(org, recorded, plannedSteps, host) {
403
404
  else {
404
405
  const mint = recorded[REGISTRAR_MINTED_SCOPE_KEY];
405
406
  if (mint &&
406
- recorded[REGISTRAR_APPLIED_SCOPE_KEY] === registrarAppliedValue(wanted.want, mint)) {
407
+ recorded[REGISTRAR_APPLIED_SCOPE_KEY] === registrarAppliedValue(wanted.want, mint) &&
408
+ !(host.sharedGrants &&
409
+ recorded[REGISTRAR_TOKEN_ORGS_SCOPE_KEY] !==
410
+ registrarOrgsMarker([wanted.want.organizationId, ...wanted.want.customers]))) {
407
411
  return { deliver, keepBouleEnv: false, step: null, blocked };
408
412
  }
409
413
  }
@@ -3,6 +3,7 @@ import type { Plan, PlanStep, UnmanagedItem, VersionChangeItem } from '../plan.j
3
3
  import { type AgoraHostReading } from './agora.js';
4
4
  import { type BlueGreenServer } from './blue-green.js';
5
5
  import { type LiveResource } from './live.js';
6
+ import { type OwnAuthHead } from './own-diff.js';
6
7
  import { type OrgPlatformReadiness } from './platform-org.js';
7
8
  import { type RemoteDatabaseFacts } from './remote-database.js';
8
9
  /**
@@ -101,8 +102,12 @@ export interface PlanOrgOptions {
101
102
  ownMailKeyHeld?: boolean;
102
103
  /** The executor can mint an own org's Lettermint key (`FoundryStepExecutor.ownMailKeyMintable`). */
103
104
  ownMailKeyMintable?: boolean;
105
+ /** The ZITADEL port can set the customers organisation's login policy. Only `false` plans none; unset plans it. */
106
+ customersLoginPolicy?: boolean;
104
107
  /** C3: the executor can mint a customers directory credential (`ZitadelPort.mintOrgDirectoryReader`). */
105
108
  customersDirectory?: boolean;
109
+ /** The ZITADEL port can grant the directory user its customer removal role (`ZitadelPort.ensureOrgDirectoryRoles`). */
110
+ customersDirectoryRoles?: boolean;
106
111
  /** C3: the executor can mint Boule's Archon credential (`FoundryStepExecutorPorts.mintBouleArchonCredential`). */
107
112
  bouleArchonCredential?: boolean;
108
113
  /** The executor can store a Worker app database's CA and certificate (`FoundryStepExecutor.workerAppDbKeysPuttable`). Absent: true. */
@@ -113,6 +118,14 @@ export interface PlanOrgOptions {
113
118
  ownSigninDbKeysPuttable?: boolean;
114
119
  /** The executor's company ZITADEL port can write the sign-in mail (`FoundryStepExecutor.ownSigninMailWritable`). Absent: true. */
115
120
  ownSigninMailWritable?: boolean;
121
+ /** #886: the head of the declared auth branch (`FoundryStepExecutor.ownAuthHead`). Absent: not read. */
122
+ ownAuthHead?: OwnAuthHead;
123
+ /** The executor's company ZITADEL port can make the broker's registrar token. Absent: not asked for. */
124
+ ownRegistrarTokenWritable?: boolean;
125
+ /** The executor can grant the shared sign-in's registrar user its organisations (`ZitadelPort.ensureRegistrarUser`). Absent: false. */
126
+ registrarGrants?: boolean;
127
+ /** An own org's registrar token is held. Absent: false. */
128
+ ownRegistrarTokenHeld?: boolean;
116
129
  /** The host supplies the backup bucket the sign-in database dumps into (`FoundryStepExecutor.ownSigninDbBackupConfigured`). Absent: true. */
117
130
  ownSigninDbBackupConfigured?: boolean;
118
131
  /** The own jobs admin token's held-key ref holds a value. */
package/dist/plan/diff.js CHANGED
@@ -9,7 +9,7 @@ import { planAppsDbRoute } from './apps-db-route.js';
9
9
  import { APPS_DB_BLOCKED_KEY, planAppsDb } from './apps-db.js';
10
10
  import { planAppsZones } from './apps-zones.js';
11
11
  import { bgKey, planBlueGreen, } from './blue-green.js';
12
- import { BOULE_ARCHON_CREDENTIAL_ENV, BOULE_BRIDGE_SECRET_ENV, BOULE_BROKER_ENTRY_KEY, BOULE_CUSTOMERS_CREDENTIAL_ENV, BOULE_MAIL_TOKEN_ENV, BOULE_OWNER_SEED_SECRET_ENV, BOULE_PEER_PASSWORD_ENV, BOULE_SECRETS_RESTARTED_SCOPE_KEY, CUSTOMERS_ROOM_SCOPE_KEY, CUSTOMERS_SIGN_IN_SCOPE_KEY, CUSTOMERS_SIGN_UP_SCOPE_KEY, archonCredentialRenewDue, bouleEnvDrift, bouleMailTokenDrift, bouleMailWanted, customersOrgIdOf, customersRoomValue, desiredBouleEnv, planBouleOwners, staleCustomersEnv, wantedBouleBrokerEntry, } from './boule.js';
12
+ import { BOULE_ARCHON_CREDENTIAL_ENV, BOULE_BRIDGE_SECRET_ENV, BOULE_BROKER_ENTRY_KEY, BOULE_CUSTOMERS_CREDENTIAL_ENV, BOULE_MAIL_TOKEN_ENV, BOULE_OWNER_SEED_SECRET_ENV, BOULE_PEER_PASSWORD_ENV, BOULE_SECRETS_RESTARTED_SCOPE_KEY, CUSTOMERS_DIRECTORY_ROLES_SCOPE_KEY, CUSTOMERS_DIRECTORY_ROLES_VERSION, CUSTOMERS_DIRECTORY_USER_SCOPE_KEY, CUSTOMERS_POLICY_SCOPE_KEY, CUSTOMERS_POLICY_VERSION, CUSTOMERS_ROOM_SCOPE_KEY, CUSTOMERS_SIGN_IN_SCOPE_KEY, CUSTOMERS_SIGN_UP_SCOPE_KEY, archonCredentialRenewDue, bouleEnvDrift, bouleMailTokenDrift, bouleMailWanted, customersOrgIdOf, customersRoomValue, desiredBouleEnv, planBouleOwners, staleCustomersEnv, wantedBouleBrokerEntry, } from './boule.js';
13
13
  import { brokerEntryAppliedScopeKey, brokerEntryWarningsScopeKey, entryAuthEnv, entryAuthEnvAppliedScopeKey, entryMcpProjectScopeKey, wantedBrokerEntry, } from './broker-entries.js';
14
14
  import { bouleRegistrarDrift, planBrokerRegistrar } from './broker-registrar.js';
15
15
  import { chatInternalNames, chatUpstreams } from './chat-matrix.js';
@@ -265,6 +265,9 @@ export function computePlanDetail(declaredOrg, live, recordedIds = {}, agoraHost
265
265
  signinDbKeysPuttable: options.ownSigninDbKeysPuttable ?? true,
266
266
  signinDbBackupConfigured: options.ownSigninDbBackupConfigured ?? true,
267
267
  signinMailWritable: options.ownSigninMailWritable ?? true,
268
+ ...(options.ownAuthHead ? { authHead: options.ownAuthHead } : {}),
269
+ registrarTokenWritable: options.ownRegistrarTokenWritable,
270
+ registrarTokenHeld: options.ownRegistrarTokenHeld ?? false,
268
271
  });
269
272
  if (ownAuth.step)
270
273
  plannedSteps.push(ownAuth.step);
@@ -470,7 +473,7 @@ export function computePlanDetail(declaredOrg, live, recordedIds = {}, agoraHost
470
473
  // this array order, one at a time.
471
474
  // Customers before the entries that sign them in: `pushAppEnv` and the
472
475
  // broker entry read the customers organisation's id.
473
- const customers = planCustomers(org, live, claimed, recordedIds, options.zitadelOrgMetadata ?? false);
476
+ const customers = planCustomers(org, live, claimed, recordedIds, options.zitadelOrgMetadata ?? false, options.customersLoginPolicy !== false);
474
477
  if (customers.step)
475
478
  plannedSteps.push(customers.step);
476
479
  // The workspaces' own customers, before the clients and the workspace steps that read the organisation's id.
@@ -702,6 +705,7 @@ export function computePlanDetail(declaredOrg, live, recordedIds = {}, agoraHost
702
705
  const bouleCredentials = planBouleCredentials(org, recordedIds, {
703
706
  archon: options.bouleArchonCredential === true,
704
707
  directory: options.customersDirectory === true,
708
+ directoryRoles: options.customersDirectoryRoles === true,
705
709
  }, platformReadiness.canHoldSecrets === true, (options.now ?? Date.now)());
706
710
  // F13: Boule's `CUSTOMER_GRANTS`, from the entries that declare `uses.customerGrants`. Every reason it
707
711
  // says nothing yet is a plan line, never silence.
@@ -723,6 +727,7 @@ export function computePlanDetail(declaredOrg, live, recordedIds = {}, agoraHost
723
727
  brokerWired: options.broker ?? false,
724
728
  canHoldSecrets: platformReadiness.canHoldSecrets === true,
725
729
  issuerKnown: Boolean(authIssuer),
730
+ sharedGrants: !ownsServices(org) && options.registrarGrants === true,
726
731
  });
727
732
  const boule = planBoule(org, byKey, claimed, recordedIds, authIssuer, options.otherSlugs ?? [], options.archonUrl ?? null, platformReadiness, {
728
733
  ...bouleCredentials.wants,
@@ -3361,6 +3366,10 @@ function planBouleCredentials(org, recordedIds, host, canHoldSecrets, nowMs) {
3361
3366
  wants: {
3362
3367
  archonCredential,
3363
3368
  customersDirectory,
3369
+ directoryRolesDue: customersDirectory &&
3370
+ host.directoryRoles === true &&
3371
+ Boolean(recordedIds[CUSTOMERS_DIRECTORY_USER_SCOPE_KEY]) &&
3372
+ recordedIds[CUSTOMERS_DIRECTORY_ROLES_SCOPE_KEY] !== CUSTOMERS_DIRECTORY_ROLES_VERSION,
3364
3373
  archonRenewDue: archonCredential && archonCredentialRenewDue(recordedIds, nowMs),
3365
3374
  },
3366
3375
  blocked,
@@ -3479,6 +3488,10 @@ function planBoule(org, byKey, claimed, recordedIds, issuer, otherSlugs, archonU
3479
3488
  !liveEnv[BOULE_CUSTOMERS_CREDENTIAL_ENV]) {
3480
3489
  drift.push(BOULE_CUSTOMERS_CREDENTIAL_ENV);
3481
3490
  }
3491
+ // The directory user that predates the customer removal role: the step grants it once.
3492
+ if (credentials.directoryRolesDue && !drift.includes(BOULE_CUSTOMERS_CREDENTIAL_ENV)) {
3493
+ drift.push(BOULE_CUSTOMERS_CREDENTIAL_ENV);
3494
+ }
3482
3495
  for (const key of staleCustomersEnv(org, liveEnv)) {
3483
3496
  if (!drift.includes(key))
3484
3497
  drift.push(key);
@@ -4721,7 +4734,7 @@ function planBouleBrokerEntry(org, recordedIds, plannedSteps, brokerWired) {
4721
4734
  * recorded, the live project grant to that org is claimed so it is not
4722
4735
  * listed as unmanaged.
4723
4736
  */
4724
- function planCustomers(org, live, claimed, recordedIds, metadataWired) {
4737
+ function planCustomers(org, live, claimed, recordedIds, metadataWired, policyAvailable = true) {
4725
4738
  const signUp = org.customers?.signUp === true;
4726
4739
  const wanted = org.customers?.enabled === true ||
4727
4740
  org.hosting.entries.some((entry) => entry.uses?.auth?.users === 'customers');
@@ -4776,8 +4789,14 @@ function planCustomers(org, live, claimed, recordedIds, metadataWired) {
4776
4789
  blocked.push(signInMetadataBlocked(org));
4777
4790
  const writeSignIn = signInDiffers && metadataWired;
4778
4791
  const writeRoom = roomDiffers && metadataWired;
4779
- if (applied === wantedSignUp && !writeSignIn && !writeRoom)
4792
+ // The login policy needs no metadata write, so it is never blocked: a stack plans it once. A host whose
4793
+ // port cannot write it plans none, and no marker is owed.
4794
+ const writePolicy = wanted &&
4795
+ policyAvailable &&
4796
+ recordedIds[CUSTOMERS_POLICY_SCOPE_KEY] !== CUSTOMERS_POLICY_VERSION;
4797
+ if (applied === wantedSignUp && !writeSignIn && !writeRoom && !writePolicy) {
4780
4798
  return { step: null, blocked };
4799
+ }
4781
4800
  return {
4782
4801
  step: {
4783
4802
  action: {
@@ -4794,7 +4813,9 @@ function planCustomers(org, live, claimed, recordedIds, metadataWired) {
4794
4813
  description: applied === wantedSignUp
4795
4814
  ? writeSignIn
4796
4815
  ? `${wantedSignIn ? 'set' : 'remove'} the emailed-code sign-in metadata of ${org.slug}'s customers`
4797
- : `${wantedRoom ? 'set' : 'remove'} the sign-in window text of ${org.slug}'s customers`
4816
+ : writeRoom
4817
+ ? `${wantedRoom ? 'set' : 'remove'} the sign-in window text of ${org.slug}'s customers`
4818
+ : `set the login policy of ${org.slug}'s customers: every sign-in method and second step an app can choose`
4798
4819
  : `turn self-registration ${wantedSignUp === '1' ? 'on' : 'off'} for ${org.slug}'s customers`,
4799
4820
  destroys: false,
4800
4821
  migrates: false,
@@ -17,6 +17,7 @@ import type { GithubDeployKeysPort } from './github-deploy-keys.js';
17
17
  import { type KeysRegistrarPort } from './keys-registrar.js';
18
18
  import { type LoginHostBindingsPort } from './login-host.js';
19
19
  import { type MailCopyPort } from './mail-copy.js';
20
+ import { type OwnAuthHead } from './own-diff.js';
20
21
  import { type OwnServicesPort } from './own-services.js';
21
22
  import type { DestroyTarget, StepDestroyer, StepExecutionContext, StepExecutor } from './plan-apply.js';
22
23
  import type { EdgePortName } from './platform-org.js';
@@ -124,6 +125,8 @@ export interface CoolifyPort {
124
125
  /** #788: where Coolify reports previews is not documented, so `previewsOn` reads both places. */
125
126
  is_preview_deployments_enabled?: boolean;
126
127
  custom_network_aliases?: string | null;
128
+ /** A fixed container name: while it is set Coolify runs no rolling update (`rollingUpdateSettings` clears it). */
129
+ custom_internal_name?: string | null;
127
130
  health_check_enabled?: boolean;
128
131
  ports_exposes?: string;
129
132
  /** Coolify's own record of the resource limits (`plan/own-limits.ts`). */
@@ -1029,6 +1032,17 @@ export interface ZitadelPort {
1029
1032
  enforceSecondFactor(orgId: string): Promise<{
1030
1033
  changed: boolean;
1031
1034
  }>;
1035
+ /**
1036
+ * Gives the customers org one fixed, permissive login policy that every customer app can narrow: username
1037
+ * and password allowed, passkeys allowed, no forced second factor, no external IdP, an authenticator app and
1038
+ * a security key as second factors, a user-verified passkey as the multi factor. `allowRegister` is left as
1039
+ * it is (`allowSelfRegistration` and `closeSelfRegistration` own it). An org on the instance default gets
1040
+ * its own policy. Idempotent: a policy already so set is no write. `zitadel-login-policy.ts` is the real
1041
+ * implementation. Optional: a host without it plans no policy (`customersLoginPolicyAvailable`).
1042
+ */
1043
+ setCustomersLoginPolicy?(orgId: string): Promise<{
1044
+ changed: boolean;
1045
+ }>;
1032
1046
  /** Grants another ZITADEL org access to a project (plan gap A9). Idempotent. */
1033
1047
  findOrCreateProjectGrant(orgId: string, projectId: string, grantedOrgId: string): Promise<{
1034
1048
  created: boolean;
@@ -1156,6 +1170,33 @@ export interface ZitadelPort {
1156
1170
  token: string;
1157
1171
  patId: string;
1158
1172
  }>;
1173
+ /**
1174
+ * The sign-in broker's registrar user (`ZITADEL_REGISTRAR_TOKEN`, auth ADR 0003): a machine user of its
1175
+ * own in `orgIds[0]` with the project-creator role in every org of `orgIds`. Idempotent, mints no token,
1176
+ * so it is repeated whenever an org joins. Optional: a host without it gets the registrar token reported
1177
+ * blocked. `zitadel-machine-user.ts` is the real implementation.
1178
+ */
1179
+ ensureRegistrarUser?(orgIds: readonly string[], input: {
1180
+ userName: string;
1181
+ name: string;
1182
+ }): Promise<{
1183
+ userId: string;
1184
+ }>;
1185
+ /**
1186
+ * A fresh token for the registrar user, made in its home org (`orgIds[0]` above); every token it holds is
1187
+ * revoked first. The caller holds the token and writes it to the broker's env; it never reaches an error,
1188
+ * a log or a recorded id.
1189
+ */
1190
+ mintRegistrarToken?(homeOrgId: string, userId: string): Promise<{
1191
+ token: string;
1192
+ patId: string;
1193
+ }>;
1194
+ /**
1195
+ * Grants the directory user the read role and the user-management role in that one organisation, replacing
1196
+ * the roles it holds there; a user that is not a member yet is added. Idempotent. Optional: a host without
1197
+ * it leaves the directory user read-only, and Boule cannot remove a customer.
1198
+ */
1199
+ ensureOrgDirectoryRoles?(orgId: string, userId: string): Promise<void>;
1159
1200
  /** C3: revokes every PAT of the directory reader, so a company that turns customers off leaves no live credential. A PAT already gone is not an error. */
1160
1201
  revokeOrgDirectoryReader?(orgId: string, userId: string): Promise<void>;
1161
1202
  /**
@@ -2092,8 +2133,16 @@ export declare class FoundryStepExecutor implements StepExecutor, StepDestroyer
2092
2133
  private executeScoped;
2093
2134
  /** Whether `ensureOwnAuthMailToken` can mint: the same two inputs it blocks on. */
2094
2135
  ownMailKeyMintable(): boolean;
2136
+ /** Whether the ZITADEL port can grant the shared sign-in's registrar user a company's organisations. */
2137
+ registrarGrantsAvailable(): boolean;
2138
+ /** Whether the host's company ZITADEL port can make the broker's registrar token (`provision.auth.registrarTokenWritable`). */
2139
+ ownRegistrarTokenWritable(): boolean;
2140
+ /** Whether the ZITADEL port can set the customers organisation's login policy. Without it no policy is planned. */
2141
+ customersLoginPolicyAvailable(): boolean;
2095
2142
  /** C3: whether the ZITADEL port can mint and revoke a customers directory credential. */
2096
2143
  customersDirectoryAvailable(): boolean;
2144
+ /** Whether the ZITADEL port can grant the directory user its customer removal role. */
2145
+ customersDirectoryRolesAvailable(): boolean;
2097
2146
  /** C3: whether the host supplied `mintBouleArchonCredential`. */
2098
2147
  bouleArchonCredentialAvailable(): boolean;
2099
2148
  /** Whether the held keys can store a new company's sign-in database CA and certificate (#746). */
@@ -2103,6 +2152,12 @@ export declare class FoundryStepExecutor implements StepExecutor, StepDestroyer
2103
2152
  * (`provision.auth.signinMailWritable`). The port is a lazy proxy that answers any name, so it cannot be probed.
2104
2153
  */
2105
2154
  ownSigninMailWritable(): boolean;
2155
+ /**
2156
+ * #886: the head of the declared auth branch, read through the host's `ownServices.readBranchHead` under a
2157
+ * timeout. `undefined` when there is nothing to read: the ref is a tag or a commit (it names one build), or the
2158
+ * host supplies no read. A failed or malformed read is a fixed reason, never the cause (it can echo a credential).
2159
+ */
2160
+ ownAuthHead(gitRef: string): Promise<OwnAuthHead | undefined>;
2106
2161
  /** Whether the held keys can store a Worker app database's CA and certificate. */
2107
2162
  workerAppDbKeysPuttable(): boolean;
2108
2163
  /** Whether the host supplies the backup bucket a Worker app database dumps into (the files host's bucket and key ports). */
@@ -3513,6 +3568,11 @@ export declare class FoundryStepExecutor implements StepExecutor, StepDestroyer
3513
3568
  * leaves one live token. `null` while the customers organisation is not recorded yet.
3514
3569
  */
3515
3570
  private ensureCustomersDirectoryCredential;
3571
+ /**
3572
+ * A directory user made before it could remove customers gets that role in the customers organisation
3573
+ * (and in no other), once: the grant is idempotent and the marker is recorded after it.
3574
+ */
3575
+ private ensureDirectoryRoles;
3516
3576
  /** The held keys with a timeout on every call: a hung store stops the step with a plain line. */
3517
3577
  private timedHeldKeys;
3518
3578
  /** T3: the company's registrar key, held at `foundry:registrar:<org id>`; either step may make it. */
@@ -3529,6 +3589,12 @@ export declare class FoundryStepExecutor implements StepExecutor, StepDestroyer
3529
3589
  * last and never holds the key; every error is redacted of it.
3530
3590
  */
3531
3591
  private brokerRegistrar;
3592
+ /**
3593
+ * A company on the shared sign-in: the platform broker's registrar user holds the project-creator role in
3594
+ * the company's sign-in and customers organisations, so the broker can register its apps there. Repeated
3595
+ * whenever the organisations change; no token is made here (the platform's sign-in step makes it).
3596
+ */
3597
+ private grantSharedRegistrar;
3532
3598
  private customerGrantsCredential;
3533
3599
  /**
3534
3600
  * F13, app side: an entry with `uses.customerGrants` gets `CUSTOMER_GRANTS_CREDENTIAL` (made and held first,
@@ -17,11 +17,12 @@ import { APPS_ZONES_APPLIED_SCOPE_KEY, applyAppsZones, retireAppsZones } from '.
17
17
  import { applyBlueGreenConnectors, applyBlueGreenServer, applyBlueGreenWeights, applyWorkspaceServer, } from './blue-green-apply.js';
18
18
  import { actionsKeyName, bgKey, blueGreenSubjects, colorAppName, healthTokenName, liveAppNameFor, mintActionsKey, mintPinKey, parseGuards, pinKeyName, splitServers, } from './blue-green.js';
19
19
  import { BouleOwnersError, httpBouleOwners } from './boule-owners-http.js';
20
- import { BOULE_ARCHON_CREDENTIAL_ENV, BOULE_ARCHON_PUSHED_AT_SCOPE_KEY, BOULE_BRIDGE_SECRET_ENV, BOULE_BROKER_ENTRY_KEY, BOULE_CLIENT_ID_SCOPE_KEY, BOULE_CUSTOMERS_CREDENTIAL_ENV, BOULE_CUSTOMERS_ORG_ENV, BOULE_DATABASE_SCOPE_KEY, BOULE_DEPLOYED_SCOPE_KEY, BOULE_OWNER_SEED_SECRET_ENV, BOULE_PEER_PASSWORD_ENV, BOULE_SECRETS_RESTARTED_SCOPE_KEY, BOULE_TEST_CUSTOMERS_ORG_ENV, CUSTOMERS_DIRECTORY_USER_SCOPE_KEY, CUSTOMERS_ROOM_METADATA_KEY, CUSTOMERS_ROOM_SCOPE_KEY, CUSTOMERS_SIGN_IN_METADATA_KEY, CUSTOMERS_SIGN_IN_SCOPE_KEY, CUSTOMERS_SIGN_UP_SCOPE_KEY, archonCredentialRenewDue, bouleArchonCredentialRef, bouleEnvDrift, bouleHostnameFor, bouleMailWanted, bouleOwnerRefusedScopeKey, bouleOwnerSeededScopeKey, bouleOwnerSeeds, customersDirectoryRef, customersOrgIdOf, desiredBouleEnv, wantedBouleBrokerEntry, } from './boule.js';
20
+ import { BOULE_ARCHON_CREDENTIAL_ENV, BOULE_ARCHON_PUSHED_AT_SCOPE_KEY, BOULE_BRIDGE_SECRET_ENV, BOULE_BROKER_ENTRY_KEY, BOULE_CLIENT_ID_SCOPE_KEY, BOULE_CUSTOMERS_CREDENTIAL_ENV, BOULE_CUSTOMERS_ORG_ENV, BOULE_DATABASE_SCOPE_KEY, BOULE_DEPLOYED_SCOPE_KEY, BOULE_OWNER_SEED_SECRET_ENV, BOULE_PEER_PASSWORD_ENV, BOULE_SECRETS_RESTARTED_SCOPE_KEY, BOULE_TEST_CUSTOMERS_ORG_ENV, CUSTOMERS_DIRECTORY_ROLES_SCOPE_KEY, CUSTOMERS_DIRECTORY_ROLES_VERSION, CUSTOMERS_DIRECTORY_USER_SCOPE_KEY, CUSTOMERS_POLICY_SCOPE_KEY, CUSTOMERS_POLICY_VERSION, CUSTOMERS_ROOM_METADATA_KEY, CUSTOMERS_ROOM_SCOPE_KEY, CUSTOMERS_SIGN_IN_METADATA_KEY, CUSTOMERS_SIGN_IN_SCOPE_KEY, CUSTOMERS_SIGN_UP_SCOPE_KEY, archonCredentialRenewDue, bouleArchonCredentialRef, bouleEnvDrift, bouleHostnameFor, bouleMailWanted, bouleOwnerRefusedScopeKey, bouleOwnerSeededScopeKey, bouleOwnerSeeds, customersDirectoryRef, customersOrgIdOf, desiredBouleEnv, wantedBouleBrokerEntry, } from './boule.js';
21
21
  import { brokerEntryAppliedScopeKey, brokerEntryWarningsScopeKey, entryAuthEnv, entryAuthEnvAppliedScopeKey, entryMcp, entryMcpProjectScopeKey, mcpCallbackUri, removeBrokerEntry, wantedBrokerEntry, writeBrokerEntry, } from './broker-entries.js';
22
+ import { REGISTRAR_TOKEN_ORGS_SCOPE_KEY, REGISTRAR_TOKEN_USER_NAME, registrarOrgIdsOf, registrarOrgsMarker, } from './broker-registrar-token.js';
22
23
  import { AUTH_REGISTRAR_KEY_ENV, AUTH_REGISTRAR_URL_ENV, REGISTRAR_APPLIED_SCOPE_KEY, REGISTRAR_DELIVERED_SCOPE_KEY, REGISTRAR_DEPLOY_WAITS_SCOPE_KEY, REGISTRAR_MINTED_SCOPE_KEY, REGISTRAR_REMOVAL_HELD_SCOPE_KEY, appliedRegistrarOrganization, ensureRegistrarKey, registrarAppliedValue, registrarRef, registrarWanted, wantedRegistrar, writeRegistrar, } from './broker-registrar.js';
23
24
  import { BROKER_WORKSPACES_DATABASE_SCOPE_KEY, canHoldWorkspacesDatabase, ensureBrokerWorkspacesDatabase, } from './broker-workspaces-database.js';
24
- import { recordBuiltCommit } from './build-record.js';
25
+ import { builtCommitScopeKey, recordBuiltCommit } from './build-record.js';
25
26
  import { CallError, withTimeout as withLabelledTimeout } from './call-timeout.js';
26
27
  import { MAS_TAG, SYNAPSE_IMAGE, SYNAPSE_TAG, callsMasConfigYaml, callsSynapseHomeserverYaml, masDockerfile, mintCallsMatrixSecrets, parseCallsDbUrl, } from './calls-matrix.js';
27
28
  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';
@@ -66,15 +67,15 @@ import { NETWORK_APPLIED_SCOPE_KEY } from './network-move.js';
66
67
  import { provisionOrgAppsAdmin, retireOrgAppsAdmin, } from './org-apps-admin.js';
67
68
  import { ORG_POSTGRES_MIGRATION_CONFIRMED_SCOPE_KEY, OrgPostgresMigrationNotConfirmedError, migrateHomeOf, migrateOrgPostgresHome, parseMigrationConfirmation, } from './org-postgres-migrate.js';
68
69
  import { orgPostgresSecretRef, provisionOrgPostgresDatabases, provisionOrgPostgresServer, serviceEndpoint, serviceOnServer, serviceUrls, } from './org-postgres.js';
69
- import { OWN_AUTH_APPLIED_GIT_REF_SCOPE_KEY, OWN_AUTH_APPLIED_HOSTNAME_SCOPE_KEY, } from './own-diff.js';
70
- import { NO_DOMAIN_ZONE, ownHostnames } from './own-hostnames.js';
70
+ import { OWN_AUTH_APPLIED_GIT_REF_SCOPE_KEY, OWN_AUTH_APPLIED_HOSTNAME_SCOPE_KEY, OWN_AUTH_REPOSITORY, isBranchRef, ownAuthBuildApps, } from './own-diff.js';
71
+ import { NO_DOMAIN_ZONE, ownHostnames, passkeyRpIdFor } from './own-hostnames.js';
71
72
  import { provisionJobsRoute } from './own-jobs-route.js';
72
73
  import { LIMITS, limited } from './own-limits.js';
73
74
  import { OWN_FILES_R2_SCOPE_KEY, OWN_FILES_R2_UNDELIVERED_SCOPE_KEY, OWN_SERVICES_APPLIED_SCOPE_KEY, OwnServicesBlockedError, orgNetwork, orgTarget, platformTarget, scopeFromOrg, } from './own-services.js';
74
75
  import { ownSfuUrls, provisionSfuApp } from './own-sfu.js';
75
76
  import { JOBS_INTERNAL_URL_SCOPE_KEY, PLATFORM_AUTH_BROKER_DEPLOYED_SCOPE_KEY, PLATFORM_AUTH_BROKER_SCOPE_KEY, } from './platform-diff.js';
76
77
  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, appImageAppliedScopeKey, appNameFor, appSettingsAppliedScopeKey, appSettingsOf, appStepKey, appliedAppDomains, appliedAppSettings, dbEnvAppliedScopeKey, entryImageFields, entryImageRef, orgDeclaresMcp, resolveAppEnv, serviceDbEnvAppliedScopeKey, zitadelAppIdTokenUserinfoAppliedScopeKey, } from './platform-diff.js';
77
- import { AUTH_LOGIN_PORT, PLATFORM_AUTH_BROKER_MAIL_KEY_REF, PLATFORM_AUTH_LOGIN_INTERNAL_NAME, PLATFORM_AUTH_LOGIN_SCOPE_KEY, PLATFORM_AUTH_PROJECT, PLATFORM_MAIL_SERVER_SCOPE_KEY, defaultStalwartForInstance, filesDepsInvalid, filesShareDomains, provisionAuthStack, provisionDatabaseBackup, provisionFilesHost, provisionJobsHost, provisionMailServer, resolveOwnFilesKeys, } from './platform-providers.js';
78
+ import { AUTH_LOGIN_PORT, PLATFORM_AUTH_BROKER_MAIL_KEY_REF, PLATFORM_AUTH_LOGIN_INTERNAL_NAME, PLATFORM_AUTH_LOGIN_SCOPE_KEY, PLATFORM_AUTH_ORG_NAME, PLATFORM_AUTH_PROJECT, PLATFORM_MAIL_SERVER_SCOPE_KEY, defaultStalwartForInstance, filesDepsInvalid, filesShareDomains, provisionAuthStack, provisionDatabaseBackup, provisionFilesHost, provisionJobsHost, provisionMailServer, resolveOwnFilesKeys, } from './platform-providers.js';
78
79
  import { companyAppPostLogoutUris, postLogoutAppliedValue, postLogoutUrisFor, zitadelAppPostLogoutAppliedScopeKey, } from './post-logout-uris.js';
79
80
  import { CLOUDFLARE_TUNNEL_APPS_HOSTNAMES_SCOPE_KEY, CLOUDFLARE_TUNNEL_ENTRY_ROUTES_SCOPE_KEY, CLOUDFLARE_TUNNEL_OIKOS_HOSTNAMES_SCOPE_KEY, ENTRY_CONTAINER_LABELS, appLabelsAppliedScopeKey, builtinRoutes, entryAppsHostnames, entryLabelHash, entryLabelSpec, entryOikosHostnames, entryProxyLabels, entryRoutes, entryRoutesMarker, ownsAppsZoneName, ownsOikosName, routesWithoutToken, sameLabelSets, } from './proxy-labels.js';
80
81
  import { STAGE_DEPLOY_ATTEMPTS, STAGE_DEPLOY_INTERVAL_MS, createReleaseHost, } from './release-host.js';
@@ -146,6 +147,8 @@ const BOULE_CREDENTIAL_TIMEOUT_MS = 60_000;
146
147
  /** One read of the broker's env. */
147
148
  const BROKER_ENV_READ_TIMEOUT_MS = 15_000;
148
149
  const HELD_KEY_READ_TIMEOUT_MS = 15_000;
150
+ /** One ZITADEL call that grants the shared registrar user its organisations. */
151
+ const REGISTRAR_CALL_TIMEOUT_MS = 30_000;
149
152
  const BOULE_CREDENTIAL_ATTEMPTS = 3;
150
153
  /** The ZITADEL machine user that reads one company's customers organisation. */
151
154
  const CUSTOMERS_DIRECTORY_USER_NAME = 'foundry-customers-directory';
@@ -922,6 +925,8 @@ async function withTimeout(work, ms) {
922
925
  clearTimeout(timer);
923
926
  }
924
927
  }
928
+ /** How long the host may take to read the head of the auth branch (#886). */
929
+ const AUTH_HEAD_READ_TIMEOUT_MS = 15_000;
925
930
  /** How long one call to the jobs scheduler or the held keys, made for an entry's project, may take. */
926
931
  const JOBS_PROJECT_CALL_TIMEOUT_MS = 30_000;
927
932
  /** How many times an entry's project credentials are tried against the held keys. */
@@ -1081,11 +1086,27 @@ export class FoundryStepExecutor {
1081
1086
  ownMailKeyMintable() {
1082
1087
  return Boolean(this.ports.lettermint && this.ports.ownServices?.provision.auth.heldKeys);
1083
1088
  }
1089
+ /** Whether the ZITADEL port can grant the shared sign-in's registrar user a company's organisations. */
1090
+ registrarGrantsAvailable() {
1091
+ return typeof this.ports.zitadel.ensureRegistrarUser === 'function';
1092
+ }
1093
+ /** Whether the host's company ZITADEL port can make the broker's registrar token (`provision.auth.registrarTokenWritable`). */
1094
+ ownRegistrarTokenWritable() {
1095
+ return this.ports.ownServices?.provision.auth.registrarTokenWritable === true;
1096
+ }
1097
+ /** Whether the ZITADEL port can set the customers organisation's login policy. Without it no policy is planned. */
1098
+ customersLoginPolicyAvailable() {
1099
+ return typeof this.ports.zitadel.setCustomersLoginPolicy === 'function';
1100
+ }
1084
1101
  /** C3: whether the ZITADEL port can mint and revoke a customers directory credential. */
1085
1102
  customersDirectoryAvailable() {
1086
1103
  return (typeof this.ports.zitadel.mintOrgDirectoryReader === 'function' &&
1087
1104
  typeof this.ports.zitadel.revokeOrgDirectoryReader === 'function');
1088
1105
  }
1106
+ /** Whether the ZITADEL port can grant the directory user its customer removal role. */
1107
+ customersDirectoryRolesAvailable() {
1108
+ return typeof this.ports.zitadel.ensureOrgDirectoryRoles === 'function';
1109
+ }
1089
1110
  /** C3: whether the host supplied `mintBouleArchonCredential`. */
1090
1111
  bouleArchonCredentialAvailable() {
1091
1112
  return typeof this.ports.mintBouleArchonCredential === 'function';
@@ -1101,6 +1122,30 @@ export class FoundryStepExecutor {
1101
1122
  ownSigninMailWritable() {
1102
1123
  return this.ports.ownServices?.provision.auth.signinMailWritable === true;
1103
1124
  }
1125
+ /**
1126
+ * #886: the head of the declared auth branch, read through the host's `ownServices.readBranchHead` under a
1127
+ * timeout. `undefined` when there is nothing to read: the ref is a tag or a commit (it names one build), or the
1128
+ * host supplies no read. A failed or malformed read is a fixed reason, never the cause (it can echo a credential).
1129
+ */
1130
+ async ownAuthHead(gitRef) {
1131
+ const read = this.ports.ownServices?.readBranchHead?.bind(this.ports.ownServices);
1132
+ if (!read || !isBranchRef(gitRef))
1133
+ return undefined;
1134
+ let commit;
1135
+ try {
1136
+ commit = await withLabelledTimeout('auth branch head', AUTH_HEAD_READ_TIMEOUT_MS, () => read(OWN_AUTH_REPOSITORY, gitRef));
1137
+ }
1138
+ catch (error) {
1139
+ return {
1140
+ failed: error instanceof CallError && error.message.includes('no answer')
1141
+ ? 'the auth branch head read timed out'
1142
+ : 'the auth branch head read failed',
1143
+ };
1144
+ }
1145
+ return /^[0-9a-f]{40}$/.test(commit)
1146
+ ? { commit }
1147
+ : { failed: 'the auth branch head was not a commit' };
1148
+ }
1104
1149
  /** Whether the held keys can store a Worker app database's CA and certificate. */
1105
1150
  workerAppDbKeysPuttable() {
1106
1151
  return typeof this.ports.heldKeys?.put === 'function';
@@ -2756,6 +2801,11 @@ export class FoundryStepExecutor {
2756
2801
  await this.ports.zitadel.allowSelfRegistration(customers.id);
2757
2802
  else
2758
2803
  await this.ports.zitadel.closeSelfRegistration(customers.id);
2804
+ // The live customers org only: the workspaces' test customers keep the instance policy. Read after the
2805
+ // sign-up call, so the policy keeps `allowRegister` as that call left it.
2806
+ const policyWritten = variant === LIVE_CUSTOMERS && this.customersLoginPolicyAvailable()
2807
+ ? await this.ports.zitadel.setCustomersLoginPolicy?.(customers.id)
2808
+ : undefined;
2759
2809
  await this.ports.zitadel.findOrCreateProjectGrant(zitadelOrgId, projectId, customers.id);
2760
2810
  // The sign-in and room metadata come before every marker: a failed write leaves both markers behind, so the next plan repeats the step.
2761
2811
  const signIn = await this.writeCustomersSignInMetadata(org, customers.id, action, context, variant);
@@ -2768,6 +2818,9 @@ export class FoundryStepExecutor {
2768
2818
  context.recordScopeId(variant.signInKey, signIn);
2769
2819
  if (room !== null)
2770
2820
  context.recordScopeId(CUSTOMERS_ROOM_SCOPE_KEY, room);
2821
+ if (policyWritten) {
2822
+ context.recordScopeId(CUSTOMERS_POLICY_SCOPE_KEY, CUSTOMERS_POLICY_VERSION);
2823
+ }
2771
2824
  return { resourceId: customers.id };
2772
2825
  }
2773
2826
  /**
@@ -8397,20 +8450,51 @@ export class FoundryStepExecutor {
8397
8450
  const put = heldKeys.put;
8398
8451
  const ref = customersDirectoryRef(org.id);
8399
8452
  const held = await withCallTimeout(BOULE_CREDENTIAL_TIMEOUT_MS, `${at}: read the held directory credential`, () => heldKeys.resolve(ref));
8400
- if (held)
8453
+ if (held) {
8454
+ await this.ensureDirectoryRoles(org, customersOrgId, context);
8401
8455
  return held;
8456
+ }
8402
8457
  const minted = await withCallTimeout(BOULE_CREDENTIAL_TIMEOUT_MS, `${at}: mint the directory credential`, () => zitadel.mintOrgDirectoryReader(customersOrgId, {
8403
8458
  userName: CUSTOMERS_DIRECTORY_USER_NAME,
8404
- name: 'Foundry customers directory (read-only)',
8459
+ name: 'Foundry customers directory',
8405
8460
  }));
8406
8461
  this.handledSecrets.add(minted.token);
8407
8462
  context.recordScopeId(CUSTOMERS_DIRECTORY_USER_SCOPE_KEY, minted.userId);
8463
+ // The mint grants the same roles, so a fresh user needs no second grant.
8464
+ if (zitadel.ensureOrgDirectoryRoles) {
8465
+ context.recordScopeId(CUSTOMERS_DIRECTORY_ROLES_SCOPE_KEY, CUSTOMERS_DIRECTORY_ROLES_VERSION);
8466
+ }
8408
8467
  await context.persistScopeIds();
8409
8468
  if (!(await this.holdWithRetry(ref, minted.token, put, `${at}: hold the directory credential`))) {
8410
8469
  throw new Error(`${at}: the directory credential of ZITADEL user ${minted.userId} could not be held after ${BOULE_CREDENTIAL_ATTEMPTS} attempts; the next apply mints it again and revokes this one`);
8411
8470
  }
8412
8471
  return minted.token;
8413
8472
  }
8473
+ /**
8474
+ * A directory user made before it could remove customers gets that role in the customers organisation
8475
+ * (and in no other), once: the grant is idempotent and the marker is recorded after it.
8476
+ */
8477
+ async ensureDirectoryRoles(org, customersOrgId, context) {
8478
+ const recorded = context.recordedIds();
8479
+ const userId = recorded[CUSTOMERS_DIRECTORY_USER_SCOPE_KEY];
8480
+ const grant = this.ports.zitadel.ensureOrgDirectoryRoles;
8481
+ if (!userId ||
8482
+ !grant ||
8483
+ recorded[CUSTOMERS_DIRECTORY_ROLES_SCOPE_KEY] === CUSTOMERS_DIRECTORY_ROLES_VERSION) {
8484
+ return;
8485
+ }
8486
+ const at = `coolify:boule for ${org.slug}`;
8487
+ try {
8488
+ await withCallTimeout(BOULE_CREDENTIAL_TIMEOUT_MS, `${at}: grant the directory user`, () => grant.call(this.ports.zitadel, customersOrgId, userId));
8489
+ }
8490
+ catch (error) {
8491
+ // The HTTP status says why (403 is a missing right); the body is dropped.
8492
+ const status = error?.status;
8493
+ throw new Error(`${at}: ZITADEL user ${userId} could not be granted its customer removal role${typeof status === 'number' ? ` (HTTP ${status})` : ''}; apply again`);
8494
+ }
8495
+ context.recordScopeId(CUSTOMERS_DIRECTORY_ROLES_SCOPE_KEY, CUSTOMERS_DIRECTORY_ROLES_VERSION);
8496
+ await context.persistScopeIds();
8497
+ }
8414
8498
  /** The held keys with a timeout on every call: a hung store stops the step with a plain line. */
8415
8499
  timedHeldKeys() {
8416
8500
  const held = this.ports.heldKeys;
@@ -8559,11 +8643,39 @@ export class FoundryStepExecutor {
8559
8643
  catch (error) {
8560
8644
  throw new Error(redactSecrets(error instanceof Error ? error.message : String(error), keys));
8561
8645
  }
8646
+ await this.grantSharedRegistrar(org, wanted.want, context);
8562
8647
  context.recordScopeId(REGISTRAR_DEPLOY_WAITS_SCOPE_KEY, result.waits.join(', '));
8563
8648
  // Last: a run that failed above plans the step again.
8564
8649
  context.recordScopeId(REGISTRAR_APPLIED_SCOPE_KEY, registrarAppliedValue(wanted.want, mint));
8565
8650
  return {};
8566
8651
  }
8652
+ /**
8653
+ * A company on the shared sign-in: the platform broker's registrar user holds the project-creator role in
8654
+ * the company's sign-in and customers organisations, so the broker can register its apps there. Repeated
8655
+ * whenever the organisations change; no token is made here (the platform's sign-in step makes it).
8656
+ */
8657
+ async grantSharedRegistrar(org, want, context) {
8658
+ const zitadel = this.ports.zitadel;
8659
+ if (ownsServices(org) || typeof zitadel.ensureRegistrarUser !== 'function')
8660
+ return;
8661
+ const covered = [want.organizationId, ...want.customers];
8662
+ const marker = registrarOrgsMarker(covered);
8663
+ if (context.recordedIds()[REGISTRAR_TOKEN_ORGS_SCOPE_KEY] === marker)
8664
+ return;
8665
+ const at = `coolify:broker-registrar for ${org.slug}`;
8666
+ try {
8667
+ const home = (await withCallTimeout(REGISTRAR_CALL_TIMEOUT_MS, `${at}: find the first organisation`, () => zitadel.findOrCreateOrg(PLATFORM_AUTH_ORG_NAME))).org.id;
8668
+ await withCallTimeout(REGISTRAR_CALL_TIMEOUT_MS, `${at}: grant the registrar user`, () => zitadel.ensureRegistrarUser([home, ...covered], {
8669
+ userName: REGISTRAR_TOKEN_USER_NAME,
8670
+ name: 'Foundry (sign-in broker registrar)',
8671
+ }));
8672
+ }
8673
+ catch {
8674
+ // The cause is dropped: nothing here is a secret, but ZITADEL's error can echo a request.
8675
+ throw new Error(`coolify:broker-registrar for ${org.slug}: the registrar user could not be granted the company's organisations; apply again`);
8676
+ }
8677
+ context.recordScopeId(REGISTRAR_TOKEN_ORGS_SCOPE_KEY, marker);
8678
+ }
8567
8679
  customerGrantsCredential(org, entryKey, context) {
8568
8680
  return ensureCustomerGrantsCredential({
8569
8681
  orgId: org.id,
@@ -9204,8 +9316,15 @@ export class FoundryStepExecutor {
9204
9316
  const before = scope.recordedIds();
9205
9317
  const refChanged = before[OWN_AUTH_APPLIED_GIT_REF_SCOPE_KEY] !== undefined &&
9206
9318
  before[OWN_AUTH_APPLIED_GIT_REF_SCOPE_KEY] !== gitRef;
9319
+ // #886: the branch head the plan read. Validated here: it decides which build is recorded.
9320
+ const head = typeof action.desired.head === 'string' && /^[0-9a-f]{40}$/.test(action.desired.head)
9321
+ ? action.desired.head
9322
+ : undefined;
9323
+ const headMoved = head !== undefined &&
9324
+ ownAuthBuildApps(org, before).some((app) => before[builtCommitScopeKey(app.name)] !== head);
9207
9325
  const gitApps = [
9208
9326
  before[PLATFORM_AUTH_LOGIN_SCOPE_KEY],
9327
+ before[SIGN_IN_LOGIN_APP_SCOPE_KEY],
9209
9328
  before[PLATFORM_AUTH_BROKER_SCOPE_KEY],
9210
9329
  ].filter((id) => Boolean(id));
9211
9330
  if (refChanged) {
@@ -9225,6 +9344,7 @@ export class FoundryStepExecutor {
9225
9344
  zones: this.zonePort(),
9226
9345
  }, scope, `${org.slug}:auth`),
9227
9346
  smtpSender: `noreply@${hn.mailDomain}`,
9347
+ ...(passkeyRpIdFor(org) ? { passkeyRpId: passkeyRpIdFor(org) } : {}),
9228
9348
  defaultRedirectUri: `https://${agoraHostnameFor(org)}`,
9229
9349
  ownerCompany: null,
9230
9350
  // In-app sign-in: the company's own stack gets the second login app. Its host table starts
@@ -9232,15 +9352,23 @@ export class FoundryStepExecutor {
9232
9352
  ...(inAppSignIn(org) ? { signInLogin: { bindings: null } } : {}),
9233
9353
  mcp: orgDeclaresMcp(org),
9234
9354
  mailBranding: { companyName: org.displayName },
9355
+ ...(registrarWanted(org) && this.ownRegistrarTokenWritable()
9356
+ ? { registrarOrgIds: registrarOrgIdsOf(org, scope.recordedIds()) }
9357
+ : {}),
9235
9358
  });
9236
- if (refChanged) {
9359
+ if (refChanged || headMoved) {
9237
9360
  const now = scope.recordedIds();
9238
9361
  const brokerDeployed = Boolean(now[PLATFORM_AUTH_BROKER_DEPLOYED_SCOPE_KEY]);
9362
+ const names = new Map(ownAuthBuildApps(org, now).map((app) => [app.id, app.name]));
9239
9363
  for (const id of gitApps) {
9240
9364
  // The broker crash-loops until its inputs exist; it was never deployed, so it is not now.
9241
9365
  if (id === now[PLATFORM_AUTH_BROKER_SCOPE_KEY] && !brokerDeployed)
9242
9366
  continue;
9243
9367
  await deps.coolify.deploy(id, true);
9368
+ // Recorded after the deploy was queued: a failed one leaves the old build, so the next plan repeats it.
9369
+ const name = names.get(id);
9370
+ if (head && name)
9371
+ scope.recordScopeId(builtCommitScopeKey(name), head);
9244
9372
  }
9245
9373
  }
9246
9374
  scope.recordScopeId(OWN_AUTH_APPLIED_HOSTNAME_SCOPE_KEY, hostname);
@@ -27,6 +27,19 @@
27
27
  * no live measurement to pin it to instead"). A caller whose app is known to
28
28
  * be slower or faster to boot passes its own.
29
29
  */
30
+ /**
31
+ * #1054: the two fields that let Coolify run a rolling update for an app other containers reach by a fixed name.
32
+ * Coolify starts the new container beside the old one, waits for its check, then stops the old one, only when the
33
+ * app has no fixed container name (`custom_internal_name`) and the consistent-name flag is off. A network alias
34
+ * answers on the Docker network under the same name throughout the swap, so callers send this instead of
35
+ * `custom_internal_name`. The app also needs a health check to wait on (`healthCheckSettings` or
36
+ * {@link IMAGE_HEALTHCHECK_SETTINGS}).
37
+ */
38
+ export declare function rollingUpdateSettings(alias: string): {
39
+ custom_network_aliases: string;
40
+ is_consistent_container_name_enabled: false;
41
+ custom_internal_name: null;
42
+ };
30
43
  /** The four Coolify `updateApplication` fields {@link healthCheckSettings} returns. */
31
44
  export interface HealthCheckPatch {
32
45
  health_check_enabled: true;
@@ -27,6 +27,23 @@
27
27
  * no live measurement to pin it to instead"). A caller whose app is known to
28
28
  * be slower or faster to boot passes its own.
29
29
  */
30
+ /**
31
+ * #1054: the two fields that let Coolify run a rolling update for an app other containers reach by a fixed name.
32
+ * Coolify starts the new container beside the old one, waits for its check, then stops the old one, only when the
33
+ * app has no fixed container name (`custom_internal_name`) and the consistent-name flag is off. A network alias
34
+ * answers on the Docker network under the same name throughout the swap, so callers send this instead of
35
+ * `custom_internal_name`. The app also needs a health check to wait on (`healthCheckSettings` or
36
+ * {@link IMAGE_HEALTHCHECK_SETTINGS}).
37
+ */
38
+ export function rollingUpdateSettings(alias) {
39
+ // `custom_internal_name: null` clears the fixed name an app made by an earlier foundry still has: with it set
40
+ // Coolify refuses the rolling update whatever else is patched.
41
+ return {
42
+ custom_network_aliases: alias,
43
+ is_consistent_container_name_enabled: false,
44
+ custom_internal_name: null,
45
+ };
46
+ }
30
47
  /** Archon's own number (see this module's doc comment) -- the default every other caller inherits unless it knows better. */
31
48
  export const DEFAULT_HEALTH_CHECK_START_PERIOD_SECONDS = 30;
32
49
  /**