@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
@@ -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
  /**
@@ -52,7 +52,35 @@ export declare const OWN_AUTH_BLOCKED_KEY = "own-auth";
52
52
  export { OWN_AUTH_APPLIED_HOSTNAME_SCOPE_KEY };
53
53
  /** The auth git ref an own org's sign-in last deployed, so a changed declared ref plans the step again. */
54
54
  export declare const OWN_AUTH_APPLIED_GIT_REF_SCOPE_KEY = "own_auth_applied_git_ref";
55
+ /** The Git repository the company's login app, in-app login app and broker are built from. */
56
+ export declare const OWN_AUTH_REPOSITORY = "wtfalch/auth";
57
+ /** What the host read of the declared auth branch: its head commit, or why it could not be read (fixed text). */
58
+ export type OwnAuthHead = {
59
+ commit: string;
60
+ } | {
61
+ failed: string;
62
+ };
63
+ /**
64
+ * True for a declared auth ref whose head can move: not a commit and not a release tag (`v1.2.3`, `1.2`).
65
+ * A tag or a commit already names one build, so it is never read.
66
+ */
67
+ export declare function isBranchRef(ref: string): boolean;
68
+ /**
69
+ * The company's auth apps a moved branch head redeploys, with the name their `built_commit_<name>` is recorded
70
+ * under. The in-app login app is listed once it is recorded; the broker once it was ever deployed (before that it
71
+ * crash-loops on missing inputs and is not redeployed, so it never records a build).
72
+ */
73
+ export declare function ownAuthBuildApps(org: Pick<DeclaredOrg, 'slug'>, recordedIds: Readonly<Record<string, string>>): Array<{
74
+ id: string;
75
+ name: string;
76
+ }>;
55
77
  export interface PlanOwnAuthOptions {
78
+ /**
79
+ * #886: the head of the declared auth branch, read by the executor (`FoundryStepExecutor.ownAuthHead`).
80
+ * Absent: not read (a tag, a commit, or a host without the read), so only the declared ref decides.
81
+ * A failed read plans nothing from the head and is reported.
82
+ */
83
+ authHead?: OwnAuthHead;
56
84
  org: DeclaredOrg;
57
85
  recordedIds: Readonly<Record<string, string>>;
58
86
  readiness: OrgPlatformReadiness;
@@ -76,6 +104,10 @@ export interface PlanOwnAuthOptions {
76
104
  signinDbBackupConfigured?: boolean;
77
105
  /** The host's ZITADEL port can write the sign-in mail text and look. Default true. False: the marker is not required. */
78
106
  signinMailWritable?: boolean;
107
+ /** The host's ZITADEL port can make the broker's registrar token. True: it is asked for. False: reported blocked. Absent: neither. */
108
+ registrarTokenWritable?: boolean;
109
+ /** The registrar token's held-key ref holds a value, so a rerun after a crash delivers it. Default false. */
110
+ registrarTokenHeld?: boolean;
79
111
  }
80
112
  /**
81
113
  * An own org (`services.own`) runs its own sign-in: ZITADEL, the login app
@@ -3,18 +3,20 @@ import { sfuSlotPorts } from '../calls-sfu.js';
3
3
  import { SFU_SLOT_MAX, ownsServices } from '../org.js';
4
4
  import { ZITADEL_ORG_SCOPE_KEY } from './agora.js';
5
5
  import { brokerEntryAppliedScopeKey } from './broker-entries.js';
6
+ import { REGISTRAR_TOKEN_SCOPE_KEY, REGISTRAR_TOKEN_UNDELIVERED_SCOPE_KEY, registrarTokenDue, } from './broker-registrar-token.js';
7
+ import { builtCommitScopeKey } from './build-record.js';
6
8
  import { ZITADEL_FILES_PROJECT_SCOPE_KEY } from './core-files.js';
7
9
  import { OWN_AUTH_APPLIED_HOSTNAME_SCOPE_KEY, ownAuthApplied } from './login-host.js';
8
10
  import { OWN_MAIL_SMTP_INTERNAL_SCOPE_KEY, OWN_MAIL_SMTP_PORT_BASE, OWN_MAIL_SMTP_PORT_SCOPE_KEY, isOwnMailSmtpPort, } from './mail-relay.js';
9
11
  import { mailStoreIsLegacy, mailStoreSwitchDescription } from './mail-store.js';
10
- import { ownHostnames } from './own-hostnames.js';
12
+ import { ownHostnames, passkeyRpIdFor } from './own-hostnames.js';
11
13
  import { JOBS_PUBLIC_URL_SCOPE_KEY, OWN_JOBS_ROUTE_TUNNEL_SCOPE_KEY } from './own-jobs-route.js';
12
14
  import { OWN_FILES_R2_SCOPE_KEY, OWN_SERVICES_APPLIED_SCOPE_KEY, orgTarget, } from './own-services.js';
13
15
  import { OWN_SFU_APPLIED_HOSTNAME_SCOPE_KEY, OWN_SFU_APPLIED_NODE_IP_SCOPE_KEY, OWN_SFU_APPLIED_SLOT_SCOPE_KEY, OWN_SFU_APPLIED_VERSION_SCOPE_KEY, OWN_SFU_DEPLOY_PENDING_SCOPE_KEY, OWN_SFU_KEYS_UNDELIVERED_SCOPE_KEY, SFU_INTERNAL_URL_SCOPE_KEY, SFU_PUBLIC_URL_SCOPE_KEY, } from './own-sfu.js';
14
- import { AUTH_TUNNEL_ROUTES_VERSION, AUTH_ZITADEL_RUN_OPTIONS, JOBS_DEPLOY_PENDING_SCOPE_KEY, JOBS_INTERNAL_URL_SCOPE_KEY, PLATFORM_AUTH_ADMIN_UNDELIVERED_SCOPE_KEY, PLATFORM_AUTH_ADMIN_USER_SCOPE_KEY, PLATFORM_AUTH_BROKER_SCOPE_KEY, PLATFORM_AUTH_CONNECTOR_APP_SCOPE_KEY, PLATFORM_AUTH_INSTANCE_SCOPE_KEY, PLATFORM_AUTH_INSTANCE_UNDELIVERED_SCOPE_KEY, PLATFORM_AUTH_LOGIN_CLIENT_UNDELIVERED_SCOPE_KEY, PLATFORM_AUTH_LOGIN_CLIENT_USER_SCOPE_KEY, PLATFORM_AUTH_LOGIN_ENV_SCOPE_KEY, PLATFORM_AUTH_MCP_SCOPE_KEY, PLATFORM_AUTH_SMTP_SCOPE_KEY, PLATFORM_AUTH_TUNNEL_ROUTES_SCOPE_KEY, PLATFORM_AUTH_ZITADEL_RUN_OPTIONS_SCOPE_KEY, PLATFORM_FILES_APPLIED_GIT_REF_SCOPE_KEY, PLATFORM_FILES_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_FILES_INTERNAL_URL_SCOPE_KEY, PLATFORM_FILES_INTROSPECTION_SCOPE_KEY, PLATFORM_FILES_INTROSPECTION_UNDELIVERED_SCOPE_KEY, PLATFORM_FILES_MANAGEMENT_KEY_SCOPE_KEY, PLATFORM_FILES_MANAGEMENT_UNDELIVERED_SCOPE_KEY, PLATFORM_MAIL_ADMIN_KEY_SCOPE_KEY, PLATFORM_MAIL_ADMIN_UNDELIVERED_SCOPE_KEY, PLATFORM_MAIL_APPLIED_GIT_REF_SCOPE_KEY, PLATFORM_MAIL_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_MAIL_INTERNAL_URL_SCOPE_KEY, authLoginEnvMarker, orgDeclaresMcp, platformMailMxHost, } from './platform-diff.js';
16
+ import { AUTH_TUNNEL_ROUTES_VERSION, AUTH_ZITADEL_RUN_OPTIONS, JOBS_DEPLOY_PENDING_SCOPE_KEY, JOBS_INTERNAL_URL_SCOPE_KEY, PLATFORM_AUTH_ADMIN_UNDELIVERED_SCOPE_KEY, PLATFORM_AUTH_ADMIN_USER_SCOPE_KEY, PLATFORM_AUTH_BROKER_DEPLOYED_SCOPE_KEY, PLATFORM_AUTH_BROKER_SCOPE_KEY, PLATFORM_AUTH_CONNECTOR_APP_SCOPE_KEY, PLATFORM_AUTH_INSTANCE_SCOPE_KEY, PLATFORM_AUTH_INSTANCE_UNDELIVERED_SCOPE_KEY, PLATFORM_AUTH_LOGIN_CLIENT_UNDELIVERED_SCOPE_KEY, PLATFORM_AUTH_LOGIN_CLIENT_USER_SCOPE_KEY, PLATFORM_AUTH_LOGIN_ENV_SCOPE_KEY, PLATFORM_AUTH_MCP_SCOPE_KEY, PLATFORM_AUTH_SMTP_SCOPE_KEY, PLATFORM_AUTH_TUNNEL_ROUTES_SCOPE_KEY, PLATFORM_AUTH_ZITADEL_RUN_OPTIONS_SCOPE_KEY, PLATFORM_FILES_APPLIED_GIT_REF_SCOPE_KEY, PLATFORM_FILES_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_FILES_INTERNAL_URL_SCOPE_KEY, PLATFORM_FILES_INTROSPECTION_SCOPE_KEY, PLATFORM_FILES_INTROSPECTION_UNDELIVERED_SCOPE_KEY, PLATFORM_FILES_MANAGEMENT_KEY_SCOPE_KEY, PLATFORM_FILES_MANAGEMENT_UNDELIVERED_SCOPE_KEY, PLATFORM_MAIL_ADMIN_KEY_SCOPE_KEY, PLATFORM_MAIL_ADMIN_UNDELIVERED_SCOPE_KEY, PLATFORM_MAIL_APPLIED_GIT_REF_SCOPE_KEY, PLATFORM_MAIL_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_MAIL_INTERNAL_URL_SCOPE_KEY, authLoginEnvMarker, orgDeclaresMcp, platformMailMxHost, } from './platform-diff.js';
15
17
  import { OWN_FILES_RUNNER_SCOPE_KEY } from './platform-org.js';
16
- import { JOBS_ADMIN_TOKEN_UNDELIVERED_SCOPE_KEY, JOBS_APPLIED_GIT_REF_SCOPE_KEY, PLATFORM_AUTH_DB_SCOPE_KEY, PLATFORM_AUTH_ZITADEL_SCOPE_KEY, PLATFORM_FILES_SHARE_DOMAINS_SCOPE_KEY, filesShareDomains, } from './platform-providers.js';
17
- import { SIGN_IN_LOGIN_APP_SCOPE_KEY, inAppSignIn } from './sign-in.js';
18
+ import { JOBS_ADMIN_TOKEN_UNDELIVERED_SCOPE_KEY, JOBS_APPLIED_GIT_REF_SCOPE_KEY, PLATFORM_AUTH_DB_SCOPE_KEY, PLATFORM_AUTH_LOGIN_SCOPE_KEY, PLATFORM_AUTH_ZITADEL_SCOPE_KEY, PLATFORM_FILES_SHARE_DOMAINS_SCOPE_KEY, filesShareDomains, } from './platform-providers.js';
19
+ import { SIGN_IN_LOGIN_APP_SCOPE_KEY, inAppSignIn, signInLoginNames } from './sign-in.js';
18
20
  import { signinDbBackupMissing } from './signin-db-backup.js';
19
21
  import { SIGNIN_DB_ZITADEL_PENDING_SCOPE_KEY, signinDbCertDue } from './signin-db-tls.js';
20
22
  import { SIGNIN_DB_TLS_SCOPE_KEY } from './signin-db.js';
@@ -126,6 +128,39 @@ export const OWN_AUTH_BLOCKED_KEY = 'own-auth';
126
128
  export { OWN_AUTH_APPLIED_HOSTNAME_SCOPE_KEY };
127
129
  /** The auth git ref an own org's sign-in last deployed, so a changed declared ref plans the step again. */
128
130
  export const OWN_AUTH_APPLIED_GIT_REF_SCOPE_KEY = 'own_auth_applied_git_ref';
131
+ /** The Git repository the company's login app, in-app login app and broker are built from. */
132
+ export const OWN_AUTH_REPOSITORY = 'wtfalch/auth';
133
+ const FULL_SHA = /^[0-9a-f]{40}$/;
134
+ /**
135
+ * True for a declared auth ref whose head can move: not a commit and not a release tag (`v1.2.3`, `1.2`).
136
+ * A tag or a commit already names one build, so it is never read.
137
+ */
138
+ export function isBranchRef(ref) {
139
+ return !FULL_SHA.test(ref) && !/^v?\d+\.\d+/.test(ref);
140
+ }
141
+ /**
142
+ * The company's auth apps a moved branch head redeploys, with the name their `built_commit_<name>` is recorded
143
+ * under. The in-app login app is listed once it is recorded; the broker once it was ever deployed (before that it
144
+ * crash-loops on missing inputs and is not redeployed, so it never records a build).
145
+ */
146
+ export function ownAuthBuildApps(org, recordedIds) {
147
+ const target = orgTarget(org);
148
+ const apps = [
149
+ { id: recordedIds[PLATFORM_AUTH_LOGIN_SCOPE_KEY], name: target.names.authLogin },
150
+ { id: recordedIds[SIGN_IN_LOGIN_APP_SCOPE_KEY], name: signInLoginNames(target).name },
151
+ {
152
+ id: recordedIds[PLATFORM_AUTH_BROKER_DEPLOYED_SCOPE_KEY]
153
+ ? recordedIds[PLATFORM_AUTH_BROKER_SCOPE_KEY]
154
+ : undefined,
155
+ name: target.names.authBroker,
156
+ },
157
+ ];
158
+ return apps.filter((app) => Boolean(app.id));
159
+ }
160
+ /** True when the read head differs from the commit any of the company's auth apps was last built from. */
161
+ function authHeadMoved(org, recordedIds, head) {
162
+ return ownAuthBuildApps(org, recordedIds).some((app) => recordedIds[builtCommitScopeKey(app.name)] !== head);
163
+ }
129
164
  /**
130
165
  * An own org (`services.own`) runs its own sign-in: ZITADEL, the login app
131
166
  * and the broker in the org's own Coolify project, at `auth.<domain>`.
@@ -197,7 +232,21 @@ export function planOwnAuth(options) {
197
232
  return blocked('the host supplies no backup bucket for the sign-in database');
198
233
  }
199
234
  }
235
+ const registrarDue = options.registrarTokenWritable === true && registrarTokenDue(org, recordedIds);
236
+ // A token minted whose hold did not finish is never minted again: a person removes the marker.
237
+ if (registrarDue &&
238
+ recordedIds[REGISTRAR_TOKEN_UNDELIVERED_SCOPE_KEY] &&
239
+ !recordedIds[REGISTRAR_TOKEN_SCOPE_KEY] &&
240
+ !options.registrarTokenHeld) {
241
+ return blocked(`the broker's registrar token of ZITADEL machine user ${recordedIds[REGISTRAR_TOKEN_UNDELIVERED_SCOPE_KEY]} was minted but never confirmed held; remove the recorded key ${REGISTRAR_TOKEN_UNDELIVERED_SCOPE_KEY} to make a new one`);
242
+ }
200
243
  const hostname = names.auth;
244
+ // #886: a branch head that moved past what the auth apps were built from plans the step again. A failed read
245
+ // plans nothing from the head and is reported, so a broken read never loops.
246
+ const head = options.authHead;
247
+ const headCommit = head && 'commit' in head ? head.commit : undefined;
248
+ const headFailed = head && 'failed' in head ? head.failed : undefined;
249
+ const headMoved = headCommit !== undefined && authHeadMoved(org, recordedIds, headCommit);
201
250
  // The broker starts only on a valid `AUTH_APPS`, and the login client is minted only once it does:
202
251
  // the first broker entry is what lets this step finish, as the platform's first registration does.
203
252
  // Archon's registration is an entry too; the platform records it, not this org, so it comes in
@@ -219,7 +268,7 @@ export function planOwnAuth(options) {
219
268
  recordedIds[PLATFORM_AUTH_TUNNEL_ROUTES_SCOPE_KEY] === AUTH_TUNNEL_ROUTES_VERSION &&
220
269
  // The login apps' mail env (`LOGIN_MAIL_FROM`, the Lettermint key); a stack from before it is written again.
221
270
  recordedIds[PLATFORM_AUTH_LOGIN_ENV_SCOPE_KEY] ===
222
- authLoginEnvMarker(`noreply@${names.mailDomain}`) &&
271
+ authLoginEnvMarker(`noreply@${names.mailDomain}`, passkeyRpIdFor(org)) &&
223
272
  // The code mail carries the company's name and design; an older apply, or a renamed company, writes it again.
224
273
  (options.signinMailWritable === false ||
225
274
  recordedIds[PLATFORM_AUTH_SIGNIN_MAIL_SCOPE_KEY] === signinMailMarker(org.displayName)) &&
@@ -234,15 +283,23 @@ export function planOwnAuth(options) {
234
283
  !signinDbCertDue(recordedIds, options.now ?? Date.now()) &&
235
284
  !signinDbBackupMissing(recordedIds) &&
236
285
  // ZITADEL was started on the TLS database and never seen healthy there: the step starts it again.
237
- !recordedIds[SIGNIN_DB_ZITADEL_PENDING_SCOPE_KEY]) {
286
+ !recordedIds[SIGNIN_DB_ZITADEL_PENDING_SCOPE_KEY] &&
287
+ !headMoved &&
288
+ // The broker's registrar token (a company with customers or apps): made, or adopted, and covering the recorded organisations.
289
+ !registrarDue) {
238
290
  // Applied and otherwise done: say why the mail stays ZITADEL's stock text, once.
239
291
  const mailMissing = options.signinMailWritable === false &&
240
292
  recordedIds[PLATFORM_AUTH_SIGNIN_MAIL_SCOPE_KEY] !== signinMailMarker(org.displayName);
293
+ const registrarMissing = options.registrarTokenWritable === false && registrarTokenDue(org, recordedIds);
241
294
  return {
242
295
  step: null,
243
- blocked: mailMissing
244
- ? `${org.slug}'s sign-in mail keeps ZITADEL's default text: the host cannot write message texts`
245
- : null,
296
+ blocked: headFailed
297
+ ? `${org.slug}'s auth apps were not checked for a newer auth commit: ${headFailed}`
298
+ : registrarMissing
299
+ ? `${org.slug}'s sign-in broker has no registrar token: the host cannot make it, so its apps cannot register sign-in`
300
+ : mailMissing
301
+ ? `${org.slug}'s sign-in mail keeps ZITADEL's default text: the host cannot write message texts`
302
+ : null,
246
303
  };
247
304
  }
248
305
  return {
@@ -252,7 +309,7 @@ export function planOwnAuth(options) {
252
309
  system: 'coolify',
253
310
  kind: 'own-auth',
254
311
  key: org.id,
255
- desired: { hostname, gitRef },
312
+ desired: { hostname, gitRef, ...(headCommit ? { head: headCommit } : {}) },
256
313
  },
257
314
  step: {
258
315
  description: `provision ${org.slug}'s own sign-in at ${hostname}`,
@@ -27,3 +27,11 @@ export interface OwnHostnames {
27
27
  * {@link loginHostnameFor}, so the two cannot disagree.
28
28
  */
29
29
  export declare function ownHostnames(org: Pick<DeclaredOrg, 'slug' | 'domain' | 'issuer' | 'mail'>): OwnHostnames;
30
+ /**
31
+ * The WebAuthn RP ID for a company's login apps (`LOGIN_PASSKEY_RP_ID`): the company's own domain, which
32
+ * `auth.<domain>` and the in-app hosts (`agora.<domain>`, `boule.<domain>`) all sit under, so one passkey
33
+ * works on each. Undefined for a company without its own sign-in domain: its `<slug>-auth.oikos.host` shares
34
+ * `oikos.host` with every other company, so an RP ID there would span companies. The login app refuses a
35
+ * host outside the RP ID, so a company's hosts stay under its domain or the variable stays unset.
36
+ */
37
+ export declare function passkeyRpIdFor(org: Pick<DeclaredOrg, 'domain' | 'issuer'>): string | undefined;
@@ -23,3 +23,17 @@ export function ownHostnames(org) {
23
23
  mailDomain: mailOnDomain && domain ? domain : `${slug}.${NO_DOMAIN_ZONE}`,
24
24
  };
25
25
  }
26
+ const BARE_DOMAIN = /^(?=.{1,253}$)[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?(\.[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?)+$/;
27
+ /**
28
+ * The WebAuthn RP ID for a company's login apps (`LOGIN_PASSKEY_RP_ID`): the company's own domain, which
29
+ * `auth.<domain>` and the in-app hosts (`agora.<domain>`, `boule.<domain>`) all sit under, so one passkey
30
+ * works on each. Undefined for a company without its own sign-in domain: its `<slug>-auth.oikos.host` shares
31
+ * `oikos.host` with every other company, so an RP ID there would span companies. The login app refuses a
32
+ * host outside the RP ID, so a company's hosts stay under its domain or the variable stays unset.
33
+ */
34
+ export function passkeyRpIdFor(org) {
35
+ const domain = org.domain?.toLowerCase();
36
+ if (!domain || !org.issuer.usesDomain || !BARE_DOMAIN.test(domain))
37
+ return undefined;
38
+ return domain;
39
+ }
@@ -61,6 +61,8 @@ export interface ServiceTarget {
61
61
  };
62
62
  readonly refs: {
63
63
  instanceBearer: string;
64
+ /** The sign-in broker's ZITADEL registrar token (`ZITADEL_REGISTRAR_TOKEN`), held after its one mint. */
65
+ registrarToken: string;
64
66
  authAdmin: string;
65
67
  brokerMailKey: string;
66
68
  /** The connection string of the database for the broker's customer workspaces: foundry holds it after making the database, or the operator holds one first. */
@@ -209,4 +211,9 @@ export interface OwnServicesPort {
209
211
  /** Builds the ZITADEL port for one company instance. */
210
212
  zitadelFor(instance: OwnAuthInstance): ZitadelPort;
211
213
  provision: OwnServicesProvision;
214
+ /**
215
+ * #886: the head commit (40 hex characters) of `ref` in the Git repository `repository` (`owner/name`).
216
+ * Absent: a moved auth branch is not noticed. The executor puts a timeout on every call.
217
+ */
218
+ readBranchHead?(repository: string, ref: string): Promise<string>;
212
219
  }
@@ -46,6 +46,7 @@ export function platformTarget() {
46
46
  },
47
47
  refs: {
48
48
  instanceBearer: 'zitadel:platform:instanceBearer',
49
+ registrarToken: 'zitadel:platform:registrarToken',
49
50
  authAdmin: 'zitadel:platform:admin',
50
51
  brokerMailKey: 'lettermint:platform:apiToken',
51
52
  brokerWorkspacesDatabase: 'auth:platform:workspacesDatabaseUrl',
@@ -103,6 +104,7 @@ export function orgTarget(org) {
103
104
  },
104
105
  refs: {
105
106
  instanceBearer: `zitadel:org:${s}:instanceBearer`,
107
+ registrarToken: `zitadel:org:${s}:registrarToken`,
106
108
  authAdmin: `zitadel:org:${s}:admin`,
107
109
  brokerMailKey: `lettermint:org:${s}:apiToken`,
108
110
  brokerWorkspacesDatabase: `auth:org:${s}:workspacesDatabaseUrl`,
@@ -18,6 +18,7 @@ import type { GithubDeployKeysPort } from './github-deploy-keys.js';
18
18
  import { type LiveReaders, type LiveResource, type PlatformLiveReaders } from './live.js';
19
19
  import { type LoginHostDiscoveryPort } from './login-host.js';
20
20
  import { type OrgPostgresMigrationConfirmation } from './org-postgres-migrate.js';
21
+ import type { OwnAuthHead } from './own-diff.js';
21
22
  import { type EdgePortName, type HeldKeyResolver, type PlatformOwnerInput } from './platform-org.js';
22
23
  import type { CallsSfuConnectionPort } from './platform-sfu.js';
23
24
  import { type ReleaseContext, type ReleaseHost } from './release-host.js';
@@ -212,8 +213,12 @@ export interface StepExecutor {
212
213
  }>;
213
214
  /** Whether the executor can mint an own org's Lettermint key: a Lettermint port and a reader for the held ref. */
214
215
  ownMailKeyMintable?(): boolean;
216
+ /** Whether the ZITADEL port can set the customers organisation's login policy. Absent: planned, as before. */
217
+ customersLoginPolicyAvailable?(): boolean;
215
218
  /** C3: whether the ZITADEL port can mint and revoke a customers directory credential. */
216
219
  customersDirectoryAvailable?(): boolean;
220
+ /** Whether the ZITADEL port can grant the directory user its customer removal role. */
221
+ customersDirectoryRolesAvailable?(): boolean;
217
222
  /** C3: whether the host supplied `mintBouleArchonCredential`. */
218
223
  bouleArchonCredentialAvailable?(): boolean;
219
224
  /** Every Coolify server with whether it is usable and reachable; blue/green plans copies on each. Optional: an executor without it plans no blue/green. */
@@ -234,6 +239,21 @@ export interface StepExecutor {
234
239
  * copy is not recorded. Optional: an executor without it runs no release.
235
240
  */
236
241
  releaseHost?(org: DeclaredOrg, subject: BlueGreenSubject, context: ReleaseContext, renew: () => Promise<void>): ReleaseHost | null;
242
+ /**
243
+ * The built-in fan-out's in-place deploy of one org's Boule or Agora at `sha` (`release/fanout.ts`). Optional:
244
+ * an executor without it runs no fan-out.
245
+ */
246
+ deployBuiltinAt?(input: {
247
+ app: 'boule' | 'agora';
248
+ org: DeclaredOrg;
249
+ recordedIds: Readonly<Record<string, string>>;
250
+ sha: string;
251
+ }): Promise<{
252
+ ok: true;
253
+ } | {
254
+ ok: false;
255
+ reason: string;
256
+ }>;
237
257
  /**
238
258
  * One release tick for one subject: the recorded stage gate's list follows the people holding `releases:preview`,
239
259
  * owners staying. Never throws. Optional: an executor without it leaves every gate as it is.
@@ -249,6 +269,12 @@ export interface StepExecutor {
249
269
  ownSigninDbBackupConfigured?(): boolean;
250
270
  /** Whether the host's company ZITADEL port can write the sign-in mail text and look. Absent: true. */
251
271
  ownSigninMailWritable?(): boolean;
272
+ /** #886: reads the head of the declared auth branch for an own org; `undefined` when there is nothing to read. */
273
+ ownAuthHead?(gitRef: string): Promise<OwnAuthHead | undefined>;
274
+ /** Whether the host's ZITADEL port can grant the shared sign-in's registrar user a company's organisations. Absent: false. */
275
+ registrarGrantsAvailable?(): boolean;
276
+ /** Whether the host's company ZITADEL port can make the broker's registrar token. Absent: the planner neither asks for it nor reports it. */
277
+ ownRegistrarTokenWritable?(): boolean;
252
278
  /**
253
279
  * Whether the host's scoped ports for this own org include a files-organisation
254
280
  * port. The planner plans no `files:organisation` step for an own org while
@@ -935,6 +961,12 @@ export declare class FoundryPlanApply implements PlanApply {
935
961
  target: string;
936
962
  error?: string;
937
963
  }>>;
964
+ /**
965
+ * One fan-out pass for the owner org's built-in `app` (`release/fanout.ts`): the other orgs' copies are put on
966
+ * the commit the owner's copy runs, one after the other, in place, stopping at the first failure. Each state
967
+ * write takes the owner's lease on its own; no lease is held while a deploy waits.
968
+ */
969
+ private runBuiltinFanOut;
938
970
  /**
939
971
  * Phase 2 for one org: takes the copy lock of each stage copy request under the org's lease, then runs the
940
972
  * copies after the lease is released. While a copy runs, every other tick and apply for the org goes on, a
@@ -1001,8 +1033,12 @@ export declare class FoundryPlanApply implements PlanApply {
1001
1033
  * only the ref.
1002
1034
  */
1003
1035
  private heldEnvReadiness;
1036
+ /** #886: the head of an own org's declared auth branch, or nothing when the org is not own or the executor has no read. */
1037
+ private ownAuthHeadFor;
1004
1038
  /** An own org's Lettermint key is held. A resolver that throws counts as not held. */
1005
1039
  private ownMailKeyHeld;
1040
+ /** An own org's registrar token is held (a rerun after a crash delivers it instead of blocking). */
1041
+ private ownRegistrarTokenHeld;
1006
1042
  /** Which of an own org's undelivered-secret refs hold a value now, so a rerun resumes instead of blocking. */
1007
1043
  private ownResumableRefs;
1008
1044
  /**
@@ -4,6 +4,7 @@ import { VERSIONS, floatingTagBlocked } from '../bootstrap/versions.js';
4
4
  import { sfuSlotPorts } from '../calls-sfu.js';
5
5
  import { ORG_SERVICE_SCHEMAS, heldEnvRef, ownsServices, validateHosting } from '../org.js';
6
6
  import { PLATFORM_SERVICE_KEYS } from '../platform.js';
7
+ import { runBuiltinFanOut } from '../release/fanout.js';
7
8
  import { recordedFrom, releaseKeys } from '../release/state.js';
8
9
  import { AlreadyRunningError, StepFailedError } from '../runner/errors.js';
9
10
  import { StaleLeaseError, } from '../store.js';
@@ -1338,6 +1339,16 @@ export class FoundryPlanApply {
1338
1339
  settle('platform', e);
1339
1340
  }
1340
1341
  }
1342
+ // Between the phases: once the owner's Boule or Agora is fully live, copy it to every other org. Its errors
1343
+ // are reported by app, never as an org.
1344
+ for (const app of ['boule', 'agora']) {
1345
+ try {
1346
+ await this.runBuiltinFanOut(app);
1347
+ }
1348
+ catch (e) {
1349
+ settle(`fanout:${app}`, e);
1350
+ }
1351
+ }
1341
1352
  // Phase 2: the copies, one subject at a time. A copy takes its lock under the org's lease and runs without it.
1342
1353
  for (const { target, run } of copying) {
1343
1354
  try {
@@ -1350,6 +1361,65 @@ export class FoundryPlanApply {
1350
1361
  }
1351
1362
  return results;
1352
1363
  }
1364
+ /**
1365
+ * One fan-out pass for the owner org's built-in `app` (`release/fanout.ts`): the other orgs' copies are put on
1366
+ * the commit the owner's copy runs, one after the other, in place, stopping at the first failure. Each state
1367
+ * write takes the owner's lease on its own; no lease is held while a deploy waits.
1368
+ */
1369
+ async runBuiltinFanOut(app) {
1370
+ const store = this.releaseStore;
1371
+ const platform = await this.platformDeps?.store.get();
1372
+ if (!store || !platform || !this.executor.deployBuiltinAt)
1373
+ return;
1374
+ const ownerOrgId = platform.ownerOrgId;
1375
+ const owner = await this.store.get(ownerOrgId);
1376
+ if (!owner)
1377
+ return;
1378
+ const empty = { orgId: ownerOrgId, steps: [], unmanaged: [] };
1379
+ if ((await this.authz.canApplyPlatform?.(this.actor)) !== true) {
1380
+ await this.refusePlatform(empty, ownerOrgId, 'not authorized to run the release tick');
1381
+ throw new NotAuthorizedError(this.actor, ownerOrgId);
1382
+ }
1383
+ const deploy = this.executor.deployBuiltinAt.bind(this.executor);
1384
+ await runBuiltinFanOut({
1385
+ ownerOrgId,
1386
+ app,
1387
+ ownerDeclares: owner[app]?.blueGreen !== undefined,
1388
+ store,
1389
+ readIds: async () => ({
1390
+ ...((await this.store.getAppliedState(ownerOrgId))?.serviceIds ?? {}),
1391
+ }),
1392
+ write: (writes) => this.withMutationLease(companyScope(ownerOrgId), (reason) => this.refuse(empty, reason), async (fence) => {
1393
+ const previous = await this.store.getAppliedState(ownerOrgId);
1394
+ if (!previous)
1395
+ throw new Error('the owner org has no applied state');
1396
+ const serviceIds = { ...previous.serviceIds };
1397
+ for (const [key, value] of Object.entries(writes)) {
1398
+ if (value === null)
1399
+ delete serviceIds[key];
1400
+ else
1401
+ serviceIds[key] = value;
1402
+ }
1403
+ await this.store.putAppliedState({ ...previous, serviceIds }, fence);
1404
+ }),
1405
+ targets: async () => {
1406
+ const others = (await this.store.list()).filter((o) => o.id !== ownerOrgId && o[app] !== undefined);
1407
+ return others.map((o) => ({ orgId: o.id, slug: o.slug }));
1408
+ },
1409
+ // Re-reads the org fresh right before its own deploy, never from a list taken when the pass
1410
+ // started: a deploy can wait many minutes with no lease held on the target org (CLAUDE.md,
1411
+ // "Built-in fan-out"), so an ordinary `apply()` to a later-in-order org can turn its own
1412
+ // blue/green on in that window, and `deployBuiltinAt`'s guard must see that, not a stale copy.
1413
+ deploy: async (target, sha) => {
1414
+ const org = await this.store.get(target.orgId);
1415
+ if (!org || org[app] === undefined)
1416
+ return { ok: false, reason: 'org not found' };
1417
+ const recordedIds = (await this.store.getAppliedState(org.id))?.serviceIds ?? {};
1418
+ return deploy({ app, org, recordedIds, sha });
1419
+ },
1420
+ now: this.now,
1421
+ });
1422
+ }
1353
1423
  /**
1354
1424
  * Phase 2 for one org: takes the copy lock of each stage copy request under the org's lease, then runs the
1355
1425
  * copies after the lease is released. While a copy runs, every other tick and apply for the org goes on, a
@@ -1648,7 +1718,9 @@ export class FoundryPlanApply {
1648
1718
  oikosZoneReady: platformReadiness.oikosIngressApplied === true,
1649
1719
  ownMailKeyHeld: await this.ownMailKeyHeld(org),
1650
1720
  ownMailKeyMintable: this.executor.ownMailKeyMintable?.() ?? false,
1721
+ customersLoginPolicy: this.executor.customersLoginPolicyAvailable?.() ?? true,
1651
1722
  customersDirectory: this.executor.customersDirectoryAvailable?.() ?? false,
1723
+ customersDirectoryRoles: this.executor.customersDirectoryRolesAvailable?.() ?? false,
1652
1724
  bouleArchonCredential: this.executor.bouleArchonCredentialAvailable?.() ?? false,
1653
1725
  ...(blueGreenServers ? { blueGreenServers } : {}),
1654
1726
  ...(stageSinkDown ? { stageSinkDown } : {}),
@@ -1671,6 +1743,10 @@ export class FoundryPlanApply {
1671
1743
  ownSigninDbKeysPuttable: this.executor.ownSigninDbKeysPuttable?.() ?? true,
1672
1744
  ownSigninDbBackupConfigured: this.executor.ownSigninDbBackupConfigured?.() ?? true,
1673
1745
  ownSigninMailWritable: this.executor.ownSigninMailWritable?.() ?? true,
1746
+ ...(await this.ownAuthHeadFor(org, platformReadiness)),
1747
+ registrarGrants: this.executor.registrarGrantsAvailable?.() ?? false,
1748
+ ownRegistrarTokenWritable: this.executor.ownRegistrarTokenWritable?.(),
1749
+ ownRegistrarTokenHeld: await this.ownRegistrarTokenHeld(org),
1674
1750
  ...(this.backupGate
1675
1751
  ? {
1676
1752
  backupStorageUuid: (await this.platformDeps?.store.getServiceIds())?.[PLATFORM_BACKUP_S3_STORAGE_SCOPE_KEY] || null,
@@ -1894,6 +1970,14 @@ export class FoundryPlanApply {
1894
1970
  }
1895
1971
  return { store: Boolean(heldKeys), refs, differs, unreadable, invalid };
1896
1972
  }
1973
+ /** #886: the head of an own org's declared auth branch, or nothing when the org is not own or the executor has no read. */
1974
+ async ownAuthHeadFor(org, readiness) {
1975
+ const gitRef = readiness.serviceGitRefs?.auth;
1976
+ if (!ownsServices(org) || !gitRef || !this.executor.ownAuthHead)
1977
+ return {};
1978
+ const head = await this.executor.ownAuthHead(gitRef);
1979
+ return head ? { ownAuthHead: head } : {};
1980
+ }
1897
1981
  /** An own org's Lettermint key is held. A resolver that throws counts as not held. */
1898
1982
  async ownMailKeyHeld(org) {
1899
1983
  const held = this.platformDeps?.heldKeys;
@@ -1901,6 +1985,13 @@ export class FoundryPlanApply {
1901
1985
  return false;
1902
1986
  return (await heldOwnKeyRefs(held, org)).has(orgTarget(org).refs.brokerMailKey);
1903
1987
  }
1988
+ /** An own org's registrar token is held (a rerun after a crash delivers it instead of blocking). */
1989
+ async ownRegistrarTokenHeld(org) {
1990
+ const held = this.platformDeps?.heldKeys;
1991
+ if (!ownsServices(org) || !held)
1992
+ return false;
1993
+ return (await heldOwnKeyRefs(held, org)).has(orgTarget(org).refs.registrarToken);
1994
+ }
1904
1995
  /** Which of an own org's undelivered-secret refs hold a value now, so a rerun resumes instead of blocking. */
1905
1996
  async ownResumableRefs(org) {
1906
1997
  const held = this.platformDeps?.heldKeys;
@@ -70,16 +70,18 @@ export declare const AUTH_TUNNEL_ROUTES_VERSION = "2";
70
70
  export declare const PLATFORM_AUTH_TUNNEL_ROUTES_SCOPE_KEY = "auth_tunnel_routes_applied";
71
71
  /**
72
72
  * The version of the env `provisionAuthStack` writes to the login apps (`LOGIN_MAIL_FROM`,
73
- * `LETTERMINT_API_TOKEN`). Raise it when a key is added or its meaning changes, so an applied
73
+ * `LETTERMINT_API_TOKEN`, `LOGIN_PASSKEY_RP_ID`). Raise it when a key is added or its meaning changes, so an applied
74
74
  * sign-in plans its step once more and the apps get the env. Never holds a value.
75
75
  * 1: the login apps send their own invite and verification mail (auth 0.35.0, foundry 0.68.0).
76
+ * 2: a company's login apps get `LOGIN_PASSKEY_RP_ID` (foundry 0.70.2).
76
77
  */
77
- export declare const AUTH_LOGIN_ENV_VERSION = "1";
78
+ export declare const AUTH_LOGIN_ENV_VERSION = "2";
78
79
  /**
79
- * What the login apps' env is written for: the version and the sender address (`LOGIN_MAIL_FROM`).
80
- * The address is no secret; a new mail domain gives a new marker, so the step is planned again.
80
+ * What the login apps' env is written for: the version, the sender address (`LOGIN_MAIL_FROM`) and the
81
+ * passkey RP ID (`LOGIN_PASSKEY_RP_ID`, none for the platform and a company without its own domain).
82
+ * Neither is a secret; a new mail domain or a changed RP ID gives a new marker, so the step is planned again.
81
83
  */
82
- export declare function authLoginEnvMarker(smtpSender: string): string;
84
+ export declare function authLoginEnvMarker(smtpSender: string, passkeyRpId?: string): string;
83
85
  /** The {@link authLoginEnvMarker} the login apps' env was last written with, once live on them. */
84
86
  export declare const PLATFORM_AUTH_LOGIN_ENV_SCOPE_KEY = "auth_login_env_applied";
85
87
  /**
@@ -1327,6 +1329,8 @@ export interface PlanPlatformOptions {
1327
1329
  authMailKeyHeld?: boolean;
1328
1330
  /** C3: why the owner's invite cannot be made (no work address, no mail port). The rest of the step still plans. */
1329
1331
  authOwnerInvite?: string;
1332
+ /** The broker's registrar token was minted and never held: reported, nothing planned for it. */
1333
+ authRegistrar?: string;
1330
1334
  files?: string;
1331
1335
  integrations?: string;
1332
1336
  ai?: string;
@@ -77,17 +77,19 @@ export const AUTH_TUNNEL_ROUTES_VERSION = '2';
77
77
  export const PLATFORM_AUTH_TUNNEL_ROUTES_SCOPE_KEY = 'auth_tunnel_routes_applied';
78
78
  /**
79
79
  * The version of the env `provisionAuthStack` writes to the login apps (`LOGIN_MAIL_FROM`,
80
- * `LETTERMINT_API_TOKEN`). Raise it when a key is added or its meaning changes, so an applied
80
+ * `LETTERMINT_API_TOKEN`, `LOGIN_PASSKEY_RP_ID`). Raise it when a key is added or its meaning changes, so an applied
81
81
  * sign-in plans its step once more and the apps get the env. Never holds a value.
82
82
  * 1: the login apps send their own invite and verification mail (auth 0.35.0, foundry 0.68.0).
83
+ * 2: a company's login apps get `LOGIN_PASSKEY_RP_ID` (foundry 0.70.2).
83
84
  */
84
- export const AUTH_LOGIN_ENV_VERSION = '1';
85
+ export const AUTH_LOGIN_ENV_VERSION = '2';
85
86
  /**
86
- * What the login apps' env is written for: the version and the sender address (`LOGIN_MAIL_FROM`).
87
- * The address is no secret; a new mail domain gives a new marker, so the step is planned again.
87
+ * What the login apps' env is written for: the version, the sender address (`LOGIN_MAIL_FROM`) and the
88
+ * passkey RP ID (`LOGIN_PASSKEY_RP_ID`, none for the platform and a company without its own domain).
89
+ * Neither is a secret; a new mail domain or a changed RP ID gives a new marker, so the step is planned again.
88
90
  */
89
- export function authLoginEnvMarker(smtpSender) {
90
- return `${AUTH_LOGIN_ENV_VERSION}:${smtpSender}`;
91
+ export function authLoginEnvMarker(smtpSender, passkeyRpId) {
92
+ return `${AUTH_LOGIN_ENV_VERSION}:${smtpSender}${passkeyRpId ? `:${passkeyRpId}` : ''}`;
91
93
  }
92
94
  /** The {@link authLoginEnvMarker} the login apps' env was last written with, once live on them. */
93
95
  export const PLATFORM_AUTH_LOGIN_ENV_SCOPE_KEY = 'auth_login_env_applied';
@@ -1553,7 +1555,7 @@ export function planPlatform(platform, live, options = {}) {
1553
1555
  // reconcile below can be planned in the *same* round hc first comes up,
1554
1556
  // the same way files/mail/archon are planned in auth's own first round.
1555
1557
  const hcReady = hc.step !== null || Boolean(recordedIds[resourceKey('coolify', 'app', PLATFORM_HC_KEY)]);
1556
- const auth = planAuth(platform, recordedIds, blocked.auth ?? dnsBlocked?.('sign-in'), blocked.authExecutorMissing ?? false, drifted.auth ?? false, blocked.authOtherInputsMissing ?? [], blocked.authOwnerInvite, drifted.authApps ?? false, blocked.authMailKeyHeld ?? true);
1558
+ const auth = planAuth(platform, recordedIds, blocked.auth ?? dnsBlocked?.('sign-in'), blocked.authExecutorMissing ?? false, drifted.auth ?? false, blocked.authOtherInputsMissing ?? [], blocked.authOwnerInvite, drifted.authApps ?? false, blocked.authMailKeyHeld ?? true, blocked.authRegistrar);
1557
1559
  if (auth.step)
1558
1560
  plannedSteps.push(auth.step);
1559
1561
  // #174: the integrations host. Planned after auth; neither step reads the
@@ -2229,11 +2231,15 @@ function planSfu(platform, callsOrgSlugs, recordedIds) {
2229
2231
  * promising "this apply re-mints" while one of those is still missing would
2230
2232
  * be wrong the moment it is read.
2231
2233
  */
2232
- function planAuth(platform, recordedIds, executorBlocked, executorMissing, authDrifted, otherInputsMissing = [], ownerInviteBlocked, appsDown = false, mailKeyHeld = true) {
2234
+ function planAuth(platform, recordedIds, executorBlocked, executorMissing, authDrifted, otherInputsMissing = [], ownerInviteBlocked, appsDown = false, mailKeyHeld = true, registrarBlocked) {
2233
2235
  if (!platform.auth.enabled)
2234
2236
  return { step: null, blocked: null };
2235
- // C3/S2: standing notes. The invite and the domain wait on a person; they hold nothing else back.
2236
- const standing = [ownerInviteBlocked, ownerDomainBlockedText(recordedIds)].filter((t) => Boolean(t));
2237
+ // C3/S2: standing notes. The invite, the registrar token and the domain wait on a person; they hold nothing else back.
2238
+ const standing = [
2239
+ ownerInviteBlocked,
2240
+ registrarBlocked,
2241
+ ownerDomainBlockedText(recordedIds),
2242
+ ].filter((t) => Boolean(t));
2237
2243
  const standingNote = standing.length > 0 ? standing.join('; ') : null;
2238
2244
  const key = resourceKey('coolify', 'app', PLATFORM_AUTH_KEY);
2239
2245
  const connectorApplied = Boolean(recordedIds[PLATFORM_AUTH_CONNECTOR_APP_SCOPE_KEY]);