@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
@@ -0,0 +1,215 @@
1
+ import { ZITADEL_ORG_SCOPE_KEY } from './agora.js';
2
+ import { customersOrgIdOf, registrarWanted } from './boule.js';
3
+ import { withTimeout } from './call-timeout.js';
4
+ import { testCustomersOrgKey } from './workspace-client.js';
5
+ /**
6
+ * The sign-in broker's ZITADEL credential for registering apps (auth ADR 0003, "Sign-in the broker makes"):
7
+ * `ZITADEL_REGISTRAR_TOKEN`, a machine user of its own, never the login-client PAT. Without it the broker
8
+ * logs "app sign-in off" and no Boule of that sign-in can register an app that signs people in.
9
+ *
10
+ * The user (`ZitadelPort.ensureRegistrarUser`) lives in the instance's first organisation and holds the
11
+ * project-creator role in the company's sign-in organisation and its customers organisations. Its token is
12
+ * minted once (platform-step rule 1), held at the target's `refs.registrarToken`, written to the broker's env
13
+ * and the broker deployed. A token already on the broker's env that foundry did not make is adopted: it is
14
+ * recorded as applied and left alone. Never a token in an error, a log or a recorded id.
15
+ */
16
+ /** The broker env that holds the token. */
17
+ export const REGISTRAR_TOKEN_ENV = 'ZITADEL_REGISTRAR_TOKEN';
18
+ /** The machine user's name, the same for every instance. */
19
+ export const REGISTRAR_TOKEN_USER_NAME = 'foundry-broker-registrar';
20
+ /** The user id of a mint whose token was not yet confirmed delivered (no `:`, so it is a rule 1 guard). Ignored once applied. */
21
+ export const REGISTRAR_TOKEN_UNDELIVERED_SCOPE_KEY = 'auth_registrar_token_undelivered';
22
+ /** `<user id>:<pat id>` once the token is held and on the broker; `adopted` for a value foundry did not make. Never the token. */
23
+ export const REGISTRAR_TOKEN_SCOPE_KEY = 'auth_registrar_token_applied';
24
+ /** {@link registrarOrgsMarker} of the organisations the user was last granted the role in. */
25
+ export const REGISTRAR_TOKEN_ORGS_SCOPE_KEY = 'auth_registrar_token_orgs';
26
+ /** The adopted marker's value. */
27
+ export const REGISTRAR_TOKEN_ADOPTED = 'adopted';
28
+ /** A stop whose text names ids only, never a cause, so a caller may log it as it is. */
29
+ export class RegistrarTokenError extends Error {
30
+ }
31
+ /** The held key store's read and write, and each ZITADEL call, stop at these: a hung call ends the step with a plain line. */
32
+ const HELD_KEY_TIMEOUT_MS = 15_000;
33
+ const ZITADEL_CALL_TIMEOUT_MS = 30_000;
34
+ const COOLIFY_CALL_TIMEOUT_MS = 60_000;
35
+ /** How many times the hold and the delivery are tried. */
36
+ const DELIVER_ATTEMPTS = 3;
37
+ /** The organisations an own company's registrar user covers besides the instance's first: its sign-in organisation and its customers organisations. */
38
+ export function registrarOrgIdsOf(org, recorded) {
39
+ const customers = org.customers?.enabled === true ? customersOrgIdOf(org, recorded) : undefined;
40
+ const test = org.customers?.enabled === true ? recorded[testCustomersOrgKey(org.id)] : undefined;
41
+ return [recorded[ZITADEL_ORG_SCOPE_KEY], customers, test].filter((id) => Boolean(id));
42
+ }
43
+ /** What the orgs marker holds: the ids, sorted, so a reorder is no change. */
44
+ export const registrarOrgsMarker = (orgIds) => [...new Set(orgIds)].sort().join(',');
45
+ /**
46
+ * Whether the company's broker still needs this step for its token: the token is wanted (customers or apps),
47
+ * and it is not applied or the organisations it covers are not the ones recorded now.
48
+ */
49
+ export function registrarTokenDue(org, recorded) {
50
+ if (!registrarWanted(org))
51
+ return false;
52
+ const applied = recorded[REGISTRAR_TOKEN_SCOPE_KEY];
53
+ if (!applied)
54
+ return true;
55
+ if (applied === REGISTRAR_TOKEN_ADOPTED)
56
+ return false;
57
+ return ((recorded[REGISTRAR_TOKEN_ORGS_SCOPE_KEY] ?? '') !==
58
+ registrarOrgsMarker(registrarOrgIdsOf(org, recorded)));
59
+ }
60
+ /** The same input with a timeout on every held key, ZITADEL and Coolify call. */
61
+ function timed(c) {
62
+ const { heldKeys, zitadel, coolify } = c;
63
+ const { ensureRegistrarUser, mintRegistrarToken } = zitadel;
64
+ return {
65
+ ...c,
66
+ coolify: {
67
+ listEnvs: (...args) => withTimeout('Coolify list env', COOLIFY_CALL_TIMEOUT_MS, () => coolify.listEnvs(...args)),
68
+ bulkUpsertEnvs: (...args) => withTimeout('Coolify write env', COOLIFY_CALL_TIMEOUT_MS, () => coolify.bulkUpsertEnvs(...args)),
69
+ deploy: (...args) => withTimeout('Coolify deploy', COOLIFY_CALL_TIMEOUT_MS, () => coolify.deploy(...args)),
70
+ },
71
+ heldKeys: heldKeys && {
72
+ resolve: (ref) => withTimeout('held key read', HELD_KEY_TIMEOUT_MS, () => heldKeys.resolve(ref)),
73
+ ...(heldKeys.put
74
+ ? {
75
+ put: (ref, value) => withTimeout('held key write', HELD_KEY_TIMEOUT_MS, () => heldKeys.put(ref, value)),
76
+ }
77
+ : {}),
78
+ },
79
+ zitadel: {
80
+ ...(ensureRegistrarUser
81
+ ? {
82
+ ensureRegistrarUser: (...args) => withTimeout('ZITADEL ensure registrar user', ZITADEL_CALL_TIMEOUT_MS, () => ensureRegistrarUser(...args)),
83
+ }
84
+ : {}),
85
+ ...(mintRegistrarToken
86
+ ? {
87
+ mintRegistrarToken: (...args) => withTimeout('ZITADEL mint registrar token', ZITADEL_CALL_TIMEOUT_MS, () => mintRegistrarToken(...args)),
88
+ }
89
+ : {}),
90
+ },
91
+ };
92
+ }
93
+ /** Runs one try at a time, up to {@link DELIVER_ATTEMPTS}; the cause of the last failure is dropped. */
94
+ async function retried(sleep, action) {
95
+ for (let attempt = 1; attempt <= DELIVER_ATTEMPTS; attempt += 1) {
96
+ try {
97
+ await action();
98
+ return true;
99
+ }
100
+ catch {
101
+ // The cause can hold the token (a Coolify validation error echoes the value), so it is dropped.
102
+ if (attempt < DELIVER_ATTEMPTS)
103
+ await sleep(1000 * attempt);
104
+ }
105
+ }
106
+ return false;
107
+ }
108
+ /**
109
+ * Makes the broker hold a registrar token that covers the company's organisations. Resumable: a token held
110
+ * but not delivered is delivered, never minted again; a token minted whose hold did not finish stops with
111
+ * the ids named and nothing minted (a person removes {@link REGISTRAR_TOKEN_UNDELIVERED_SCOPE_KEY} to make
112
+ * another). Returns what it did.
113
+ */
114
+ export async function ensureBrokerRegistrarToken(input) {
115
+ const c = timed(input);
116
+ const covered = [c.homeOrgId, ...c.orgIds];
117
+ const wanted = registrarOrgsMarker(c.orgIds);
118
+ const envRows = await c.coolify.listEnvs(c.brokerAppId);
119
+ const live = envRows.find((e) => e.key === REGISTRAR_TOKEN_ENV && !e.is_preview)?.value;
120
+ if (live)
121
+ c.secret(live);
122
+ const before = c.recorded();
123
+ const applied = before[REGISTRAR_TOKEN_SCOPE_KEY];
124
+ if (applied) {
125
+ if (!live) {
126
+ // The env was lost: the held token is delivered again; with none held, nothing is made over it.
127
+ const held = await c.heldKeys?.resolve(c.ref);
128
+ if (!held) {
129
+ throw new RegistrarTokenError(`${c.label}: the broker has no ${REGISTRAR_TOKEN_ENV} and the registrar token is not held (ref ${c.ref}); remove ${REGISTRAR_TOKEN_SCOPE_KEY} to make a new one`);
130
+ }
131
+ c.secret(held);
132
+ await deliver(c, held);
133
+ return 'delivered';
134
+ }
135
+ if (applied !== REGISTRAR_TOKEN_ADOPTED && before[REGISTRAR_TOKEN_ORGS_SCOPE_KEY] !== wanted) {
136
+ await grant(c, covered);
137
+ await c.record(REGISTRAR_TOKEN_ORGS_SCOPE_KEY, wanted);
138
+ await c.persist();
139
+ return 'grants';
140
+ }
141
+ return 'unchanged';
142
+ }
143
+ const undelivered = before[REGISTRAR_TOKEN_UNDELIVERED_SCOPE_KEY];
144
+ const held = await c.heldKeys?.resolve(c.ref);
145
+ if (live && !undelivered && held !== live) {
146
+ // A value somebody set by hand (Candor FX's): kept as it is. Its user is not known, so no grants.
147
+ c.log(`${c.label}: the broker already has a ${REGISTRAR_TOKEN_ENV} foundry did not make; adopted, not replaced`);
148
+ await c.record(REGISTRAR_TOKEN_SCOPE_KEY, REGISTRAR_TOKEN_ADOPTED);
149
+ await c.persist();
150
+ return 'adopted';
151
+ }
152
+ if (undelivered && !held) {
153
+ throw new RegistrarTokenError(`${c.label}: the registrar token of ZITADEL machine user ${undelivered} was minted but never confirmed held; remove the recorded key ${REGISTRAR_TOKEN_UNDELIVERED_SCOPE_KEY} to make a new one`);
154
+ }
155
+ if (held) {
156
+ // Made by a run that stopped after holding it: delivered now, nothing minted.
157
+ c.secret(held);
158
+ await grant(c, covered);
159
+ await c.record(REGISTRAR_TOKEN_ORGS_SCOPE_KEY, wanted);
160
+ await deliver(c, held);
161
+ await c.record(REGISTRAR_TOKEN_SCOPE_KEY, undelivered ? `${undelivered}:held` : 'held');
162
+ await c.persist();
163
+ return 'delivered';
164
+ }
165
+ if (typeof c.heldKeys?.put !== 'function') {
166
+ throw new RegistrarTokenError(`${c.label}: the held keys cannot hold the registrar token (ref ${c.ref})`);
167
+ }
168
+ if (!c.zitadel.ensureRegistrarUser || !c.zitadel.mintRegistrarToken) {
169
+ throw new RegistrarTokenError(`${c.label}: the host's ZITADEL port cannot make a registrar token`);
170
+ }
171
+ const { userId } = await c.zitadel.ensureRegistrarUser(covered, {
172
+ userName: REGISTRAR_TOKEN_USER_NAME,
173
+ name: 'Foundry (sign-in broker registrar)',
174
+ });
175
+ const { token, patId } = await c.zitadel.mintRegistrarToken(c.homeOrgId, userId);
176
+ c.secret(token);
177
+ // Recorded and persisted before the token goes anywhere: a crash from here on never mints a second one.
178
+ await c.record(REGISTRAR_TOKEN_UNDELIVERED_SCOPE_KEY, userId);
179
+ await c.persist();
180
+ const put = c.heldKeys.put.bind(c.heldKeys);
181
+ if (!(await retried(c.sleep, () => put(c.ref, token)))) {
182
+ throw new RegistrarTokenError(`${c.label}: the registrar token of ZITADEL machine user ${userId} was minted but could not be held after ${DELIVER_ATTEMPTS} tries (ref ${c.ref})`);
183
+ }
184
+ await deliver(c, token, userId);
185
+ await c.record(REGISTRAR_TOKEN_ORGS_SCOPE_KEY, wanted);
186
+ // Last: a run that failed above plans the step again.
187
+ await c.record(REGISTRAR_TOKEN_SCOPE_KEY, `${userId}:${patId}`);
188
+ await c.persist();
189
+ return 'minted';
190
+ }
191
+ async function grant(c, covered) {
192
+ if (!c.zitadel.ensureRegistrarUser) {
193
+ throw new RegistrarTokenError(`${c.label}: the host's ZITADEL port cannot grant the registrar user`);
194
+ }
195
+ try {
196
+ await c.zitadel.ensureRegistrarUser(covered, {
197
+ userName: REGISTRAR_TOKEN_USER_NAME,
198
+ name: 'Foundry (sign-in broker registrar)',
199
+ });
200
+ }
201
+ catch {
202
+ throw new RegistrarTokenError(`${c.label}: the registrar user could not be granted its organisations; apply again`);
203
+ }
204
+ }
205
+ /** Writes the token to the broker's env and deploys the broker, each retried; the cause is dropped. */
206
+ async function deliver(c, token, userId) {
207
+ const who = userId ? ` of ZITADEL machine user ${userId}` : '';
208
+ const wrote = await retried(c.sleep, () => c.coolify.bulkUpsertEnvs(c.brokerAppId, [{ key: REGISTRAR_TOKEN_ENV, value: token }]));
209
+ // Forced: an unforced deploy answers "skipped" when one for the same commit is queued, which would let
210
+ // this record delivery while the running broker never sees the token.
211
+ const deployed = wrote && (await retried(c.sleep, () => c.coolify.deploy(c.brokerAppId, true)));
212
+ if (!deployed) {
213
+ throw new RegistrarTokenError(`${c.label}: the registrar token${who} is held but could not be delivered to the broker after ${DELIVER_ATTEMPTS} tries; the next apply delivers it again`);
214
+ }
215
+ }
@@ -152,6 +152,8 @@ export declare function planBrokerRegistrar(org: DeclaredOrg, recorded: Readonly
152
152
  brokerWired: boolean;
153
153
  canHoldSecrets: boolean;
154
154
  issuerKnown: boolean;
155
+ /** A company on the shared sign-in whose registrar user the host can grant (`ZitadelPort.ensureRegistrarUser`). */
156
+ sharedGrants?: boolean;
155
157
  }): {
156
158
  deliver: boolean;
157
159
  /** Customers are off but the record cannot be taken away yet: Boule keeps its registrar env, in step with it. */
@@ -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,