@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
@@ -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. */
@@ -264,6 +269,12 @@ export interface StepExecutor {
264
269
  ownSigninDbBackupConfigured?(): boolean;
265
270
  /** Whether the host's company ZITADEL port can write the sign-in mail text and look. Absent: true. */
266
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;
267
278
  /**
268
279
  * Whether the host's scoped ports for this own org include a files-organisation
269
280
  * port. The planner plans no `files:organisation` step for an own org while
@@ -1022,8 +1033,12 @@ export declare class FoundryPlanApply implements PlanApply {
1022
1033
  * only the ref.
1023
1034
  */
1024
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;
1025
1038
  /** An own org's Lettermint key is held. A resolver that throws counts as not held. */
1026
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;
1027
1042
  /** Which of an own org's undelivered-secret refs hold a value now, so a rerun resumes instead of blocking. */
1028
1043
  private ownResumableRefs;
1029
1044
  /**
@@ -1718,7 +1718,9 @@ export class FoundryPlanApply {
1718
1718
  oikosZoneReady: platformReadiness.oikosIngressApplied === true,
1719
1719
  ownMailKeyHeld: await this.ownMailKeyHeld(org),
1720
1720
  ownMailKeyMintable: this.executor.ownMailKeyMintable?.() ?? false,
1721
+ customersLoginPolicy: this.executor.customersLoginPolicyAvailable?.() ?? true,
1721
1722
  customersDirectory: this.executor.customersDirectoryAvailable?.() ?? false,
1723
+ customersDirectoryRoles: this.executor.customersDirectoryRolesAvailable?.() ?? false,
1722
1724
  bouleArchonCredential: this.executor.bouleArchonCredentialAvailable?.() ?? false,
1723
1725
  ...(blueGreenServers ? { blueGreenServers } : {}),
1724
1726
  ...(stageSinkDown ? { stageSinkDown } : {}),
@@ -1741,6 +1743,10 @@ export class FoundryPlanApply {
1741
1743
  ownSigninDbKeysPuttable: this.executor.ownSigninDbKeysPuttable?.() ?? true,
1742
1744
  ownSigninDbBackupConfigured: this.executor.ownSigninDbBackupConfigured?.() ?? true,
1743
1745
  ownSigninMailWritable: this.executor.ownSigninMailWritable?.() ?? true,
1746
+ ...(await this.ownAuthHeadFor(org, platformReadiness)),
1747
+ registrarGrants: this.executor.registrarGrantsAvailable?.() ?? false,
1748
+ ownRegistrarTokenWritable: this.executor.ownRegistrarTokenWritable?.(),
1749
+ ownRegistrarTokenHeld: await this.ownRegistrarTokenHeld(org),
1744
1750
  ...(this.backupGate
1745
1751
  ? {
1746
1752
  backupStorageUuid: (await this.platformDeps?.store.getServiceIds())?.[PLATFORM_BACKUP_S3_STORAGE_SCOPE_KEY] || null,
@@ -1964,6 +1970,14 @@ export class FoundryPlanApply {
1964
1970
  }
1965
1971
  return { store: Boolean(heldKeys), refs, differs, unreadable, invalid };
1966
1972
  }
1973
+ /** #886: the head of an own org's declared auth branch, or nothing when the org is not own or the executor has no read. */
1974
+ async ownAuthHeadFor(org, readiness) {
1975
+ const gitRef = readiness.serviceGitRefs?.auth;
1976
+ if (!ownsServices(org) || !gitRef || !this.executor.ownAuthHead)
1977
+ return {};
1978
+ const head = await this.executor.ownAuthHead(gitRef);
1979
+ return head ? { ownAuthHead: head } : {};
1980
+ }
1967
1981
  /** An own org's Lettermint key is held. A resolver that throws counts as not held. */
1968
1982
  async ownMailKeyHeld(org) {
1969
1983
  const held = this.platformDeps?.heldKeys;
@@ -1971,6 +1985,13 @@ export class FoundryPlanApply {
1971
1985
  return false;
1972
1986
  return (await heldOwnKeyRefs(held, org)).has(orgTarget(org).refs.brokerMailKey);
1973
1987
  }
1988
+ /** An own org's registrar token is held (a rerun after a crash delivers it instead of blocking). */
1989
+ async ownRegistrarTokenHeld(org) {
1990
+ const held = this.platformDeps?.heldKeys;
1991
+ if (!ownsServices(org) || !held)
1992
+ return false;
1993
+ return (await heldOwnKeyRefs(held, org)).has(orgTarget(org).refs.registrarToken);
1994
+ }
1974
1995
  /** Which of an own org's undelivered-secret refs hold a value now, so a rerun resumes instead of blocking. */
1975
1996
  async ownResumableRefs(org) {
1976
1997
  const held = this.platformDeps?.heldKeys;
@@ -70,16 +70,18 @@ export declare const AUTH_TUNNEL_ROUTES_VERSION = "2";
70
70
  export declare const PLATFORM_AUTH_TUNNEL_ROUTES_SCOPE_KEY = "auth_tunnel_routes_applied";
71
71
  /**
72
72
  * The version of the env `provisionAuthStack` writes to the login apps (`LOGIN_MAIL_FROM`,
73
- * `LETTERMINT_API_TOKEN`). Raise it when a key is added or its meaning changes, so an applied
73
+ * `LETTERMINT_API_TOKEN`, `LOGIN_PASSKEY_RP_ID`). Raise it when a key is added or its meaning changes, so an applied
74
74
  * sign-in plans its step once more and the apps get the env. Never holds a value.
75
75
  * 1: the login apps send their own invite and verification mail (auth 0.35.0, foundry 0.68.0).
76
+ * 2: a company's login apps get `LOGIN_PASSKEY_RP_ID` (foundry 0.70.2).
76
77
  */
77
- export declare const AUTH_LOGIN_ENV_VERSION = "1";
78
+ export declare const AUTH_LOGIN_ENV_VERSION = "2";
78
79
  /**
79
- * What the login apps' env is written for: the version and the sender address (`LOGIN_MAIL_FROM`).
80
- * The address is no secret; a new mail domain gives a new marker, so the step is planned again.
80
+ * What the login apps' env is written for: the version, the sender address (`LOGIN_MAIL_FROM`) and the
81
+ * passkey RP ID (`LOGIN_PASSKEY_RP_ID`, none for the platform and a company without its own domain).
82
+ * Neither is a secret; a new mail domain or a changed RP ID gives a new marker, so the step is planned again.
81
83
  */
82
- export declare function authLoginEnvMarker(smtpSender: string): string;
84
+ export declare function authLoginEnvMarker(smtpSender: string, passkeyRpId?: string): string;
83
85
  /** The {@link authLoginEnvMarker} the login apps' env was last written with, once live on them. */
84
86
  export declare const PLATFORM_AUTH_LOGIN_ENV_SCOPE_KEY = "auth_login_env_applied";
85
87
  /**
@@ -1327,6 +1329,8 @@ export interface PlanPlatformOptions {
1327
1329
  authMailKeyHeld?: boolean;
1328
1330
  /** C3: why the owner's invite cannot be made (no work address, no mail port). The rest of the step still plans. */
1329
1331
  authOwnerInvite?: string;
1332
+ /** The broker's registrar token was minted and never held: reported, nothing planned for it. */
1333
+ authRegistrar?: string;
1330
1334
  files?: string;
1331
1335
  integrations?: string;
1332
1336
  ai?: string;
@@ -77,17 +77,19 @@ export const AUTH_TUNNEL_ROUTES_VERSION = '2';
77
77
  export const PLATFORM_AUTH_TUNNEL_ROUTES_SCOPE_KEY = 'auth_tunnel_routes_applied';
78
78
  /**
79
79
  * The version of the env `provisionAuthStack` writes to the login apps (`LOGIN_MAIL_FROM`,
80
- * `LETTERMINT_API_TOKEN`). Raise it when a key is added or its meaning changes, so an applied
80
+ * `LETTERMINT_API_TOKEN`, `LOGIN_PASSKEY_RP_ID`). Raise it when a key is added or its meaning changes, so an applied
81
81
  * sign-in plans its step once more and the apps get the env. Never holds a value.
82
82
  * 1: the login apps send their own invite and verification mail (auth 0.35.0, foundry 0.68.0).
83
+ * 2: a company's login apps get `LOGIN_PASSKEY_RP_ID` (foundry 0.70.2).
83
84
  */
84
- export const AUTH_LOGIN_ENV_VERSION = '1';
85
+ export const AUTH_LOGIN_ENV_VERSION = '2';
85
86
  /**
86
- * What the login apps' env is written for: the version and the sender address (`LOGIN_MAIL_FROM`).
87
- * The address is no secret; a new mail domain gives a new marker, so the step is planned again.
87
+ * What the login apps' env is written for: the version, the sender address (`LOGIN_MAIL_FROM`) and the
88
+ * passkey RP ID (`LOGIN_PASSKEY_RP_ID`, none for the platform and a company without its own domain).
89
+ * Neither is a secret; a new mail domain or a changed RP ID gives a new marker, so the step is planned again.
88
90
  */
89
- export function authLoginEnvMarker(smtpSender) {
90
- return `${AUTH_LOGIN_ENV_VERSION}:${smtpSender}`;
91
+ export function authLoginEnvMarker(smtpSender, passkeyRpId) {
92
+ return `${AUTH_LOGIN_ENV_VERSION}:${smtpSender}${passkeyRpId ? `:${passkeyRpId}` : ''}`;
91
93
  }
92
94
  /** The {@link authLoginEnvMarker} the login apps' env was last written with, once live on them. */
93
95
  export const PLATFORM_AUTH_LOGIN_ENV_SCOPE_KEY = 'auth_login_env_applied';
@@ -1553,7 +1555,7 @@ export function planPlatform(platform, live, options = {}) {
1553
1555
  // reconcile below can be planned in the *same* round hc first comes up,
1554
1556
  // the same way files/mail/archon are planned in auth's own first round.
1555
1557
  const hcReady = hc.step !== null || Boolean(recordedIds[resourceKey('coolify', 'app', PLATFORM_HC_KEY)]);
1556
- const auth = planAuth(platform, recordedIds, blocked.auth ?? dnsBlocked?.('sign-in'), blocked.authExecutorMissing ?? false, drifted.auth ?? false, blocked.authOtherInputsMissing ?? [], blocked.authOwnerInvite, drifted.authApps ?? false, blocked.authMailKeyHeld ?? true);
1558
+ const auth = planAuth(platform, recordedIds, blocked.auth ?? dnsBlocked?.('sign-in'), blocked.authExecutorMissing ?? false, drifted.auth ?? false, blocked.authOtherInputsMissing ?? [], blocked.authOwnerInvite, drifted.authApps ?? false, blocked.authMailKeyHeld ?? true, blocked.authRegistrar);
1557
1559
  if (auth.step)
1558
1560
  plannedSteps.push(auth.step);
1559
1561
  // #174: the integrations host. Planned after auth; neither step reads the
@@ -2229,11 +2231,15 @@ function planSfu(platform, callsOrgSlugs, recordedIds) {
2229
2231
  * promising "this apply re-mints" while one of those is still missing would
2230
2232
  * be wrong the moment it is read.
2231
2233
  */
2232
- function planAuth(platform, recordedIds, executorBlocked, executorMissing, authDrifted, otherInputsMissing = [], ownerInviteBlocked, appsDown = false, mailKeyHeld = true) {
2234
+ function planAuth(platform, recordedIds, executorBlocked, executorMissing, authDrifted, otherInputsMissing = [], ownerInviteBlocked, appsDown = false, mailKeyHeld = true, registrarBlocked) {
2233
2235
  if (!platform.auth.enabled)
2234
2236
  return { step: null, blocked: null };
2235
- // C3/S2: standing notes. The invite and the domain wait on a person; they hold nothing else back.
2236
- const standing = [ownerInviteBlocked, ownerDomainBlockedText(recordedIds)].filter((t) => Boolean(t));
2237
+ // C3/S2: standing notes. The invite, the registrar token and the domain wait on a person; they hold nothing else back.
2238
+ const standing = [
2239
+ ownerInviteBlocked,
2240
+ registrarBlocked,
2241
+ ownerDomainBlockedText(recordedIds),
2242
+ ].filter((t) => Boolean(t));
2237
2243
  const standingNote = standing.length > 0 ? standing.join('; ') : null;
2238
2244
  const key = resourceKey('coolify', 'app', PLATFORM_AUTH_KEY);
2239
2245
  const connectorApplied = Boolean(recordedIds[PLATFORM_AUTH_CONNECTOR_APP_SCOPE_KEY]);
@@ -145,11 +145,16 @@ export interface PlatformAuthDeps {
145
145
  * and the planner reports it. The port may be a proxy that answers any method name, so the host says it.
146
146
  */
147
147
  signinMailWritable?: boolean;
148
+ /**
149
+ * The host's ZITADEL port has `ensureRegistrarUser` and `mintRegistrarToken` (the broker's registrar token,
150
+ * `plan/broker-registrar-token.ts`). Only `true` makes it; otherwise the planner reports it blocked.
151
+ */
152
+ registrarTokenWritable?: boolean;
148
153
  /** Overrides the Lettermint SMTP relay ZITADEL is given. Absent, Lettermint exactly as before. */
149
154
  smtp?: PlatformAuthSmtp;
150
155
  coolify: Pick<CoolifyPort, 'findProjectByName' | 'createProject' | 'findOrCreateEnvironment' | 'listServers' | 'findApplicationByName' | 'getApplication' | 'requireGithubApp' | 'createGithubAppApplication' | 'createDockerImageApplication' | 'createDockerfileApplication' | 'createPostgresDatabase' | 'getDatabase' | 'bulkUpsertEnvs' | 'listEnvs' | 'updateApplication' | 'deploy' | 'raw'> &
151
156
  /** An own org's sign-in database needs a volume and the CA file mount (#746); the platform's does not. */
152
- Partial<Pick<CoolifyPort, 'listStorages' | 'createPersistentStorage' | 'createFileStorage'>>;
157
+ Partial<Pick<CoolifyPort, 'listStorages' | 'createPersistentStorage' | 'createFileStorage' | 'deleteEnv'>>;
153
158
  /** F4.dns: every DNS record call, through edge at its recorded address (see {@link PlatformDnsFactory}). */
154
159
  dns: PlatformDnsFactory;
155
160
  /** F4.tunnels: every tunnel call, through edge at its recorded address (see {@link PlatformTunnelsFactory}). */
@@ -161,7 +166,12 @@ export interface PlatformAuthDeps {
161
166
  requestHeaders?: PlatformRequestHeadersFactory;
162
167
  cloudflare: Pick<CloudflarePort, 'findZoneByApex'>;
163
168
  /** Must target the platform's own new instance (its issuer), or `zitadelForInstance` must be set. */
164
- zitadel: Pick<ZitadelPort, 'findOrCreateOrg' | 'mintAdminMachineUser' | 'mintLoginClientMachineUser' | 'listLoginClientPats' | 'reserveUserName' | 'closeSelfRegistration' | 'enforceSecondFactor' | 'inviteOrgOwner' | 'ensureDomainPolicy' | 'addOrgDomain' | 'removeOrgDomain' | 'setPrimaryOrgDomain' | 'setUserMetadata' | 'getUserMetadata'> & PlatformZitadelSmtp & Partial<PlatformZitadelMailBranding>;
169
+ zitadel: Pick<ZitadelPort, 'findOrCreateOrg' | 'mintAdminMachineUser' | 'mintLoginClientMachineUser' | 'listLoginClientPats' | 'reserveUserName' | 'closeSelfRegistration' | 'enforceSecondFactor' | 'inviteOrgOwner' | 'ensureDomainPolicy' | 'addOrgDomain' | 'removeOrgDomain' | 'setPrimaryOrgDomain' | 'setUserMetadata' | 'getUserMetadata'> & PlatformZitadelSmtp & Partial<Pick<ZitadelPort, 'ensureRegistrarUser' | 'mintRegistrarToken'>> & Partial<PlatformZitadelMailBranding> & {
170
+ getOrg?: (id: string) => Promise<{
171
+ id: string;
172
+ name: string;
173
+ }>;
174
+ };
165
175
  /**
166
176
  * Mails the owner's first-sign-in link to their personal address (the same
167
177
  * port as `FoundryStepExecutorPorts.ownerInviteMail`). The link carries the
@@ -411,6 +421,7 @@ export declare class FoundryPlatformStepExecutor implements PlatformStepExecutor
411
421
  authOtherInputsMissing?: string[];
412
422
  authMailKeyHeld?: boolean;
413
423
  authOwnerInvite?: string;
424
+ authRegistrar?: string;
414
425
  files?: string;
415
426
  integrations?: string;
416
427
  ai?: string;
@@ -575,6 +586,7 @@ export declare const AUTH_BROKER_MCP_PATH_PATTERN = "^/(\\.well-known/oauth-auth
575
586
  export declare const PLATFORM_AUTH_DB_SCOPE_KEY = "coolify_platform_auth_database";
576
587
  export declare const PLATFORM_AUTH_ZITADEL_SCOPE_KEY = "coolify_platform_auth_zitadel_app";
577
588
  export declare const PLATFORM_AUTH_LOGIN_SCOPE_KEY = "coolify_platform_auth_login_app";
589
+ export declare const PLATFORM_AUTH_ORG_NAME = "foundry";
578
590
  /**
579
591
  * #283: the ref the new instance's IAM_OWNER machine token (a PAT) is
580
592
  * delivered under, once. A host reads it back to build `zitadelForInstance`
@@ -600,6 +612,11 @@ export interface AuthStackInput {
600
612
  network: ServiceNetwork;
601
613
  /** The ZITADEL SMTP sender address. */
602
614
  smtpSender: string;
615
+ /**
616
+ * `LOGIN_PASSKEY_RP_ID` for both login apps: a bare domain every host they serve sits under (the login
617
+ * refuses any other). Absent: unset, and each passkey belongs to the host it was made on.
618
+ */
619
+ passkeyRpId?: string;
603
620
  /** Where the login app sends a user with no sign-in request in flight, or null to leave it unset. */
604
621
  defaultRedirectUri: string | null;
605
622
  /** The company whose owner is invited into its own sign-in org; null for a company's own stack. */
@@ -620,6 +637,11 @@ export interface AuthStackInput {
620
637
  mailBranding?: {
621
638
  companyName: string;
622
639
  };
640
+ /**
641
+ * The broker gets a registrar token (`ZITADEL_REGISTRAR_TOKEN`, `plan/broker-registrar-token.ts`) covering the
642
+ * instance's first organisation and these (the company's sign-in and customers organisations). Absent: none.
643
+ */
644
+ registrarOrgIds?: readonly string[];
623
645
  }
624
646
  /**
625
647
  * #168: makes `coolify:app` keyed {@link PLATFORM_AUTH_KEY} true -- a Coolify