@wtfalch/foundry 0.54.0 → 0.56.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.
@@ -101,6 +101,8 @@ export interface PlanOrgOptions {
101
101
  workerAppDbBackupConfigured?: boolean;
102
102
  /** The executor can store the sign-in database's CA and certificate (`FoundryStepExecutor.ownSigninDbKeysPuttable`). Absent: true. */
103
103
  ownSigninDbKeysPuttable?: boolean;
104
+ /** The executor's company ZITADEL port can write the sign-in mail (`FoundryStepExecutor.ownSigninMailWritable`). Absent: true. */
105
+ ownSigninMailWritable?: boolean;
104
106
  /** The host supplies the backup bucket the sign-in database dumps into (`FoundryStepExecutor.ownSigninDbBackupConfigured`). Absent: true. */
105
107
  ownSigninDbBackupConfigured?: boolean;
106
108
  /** The own jobs admin token's held-key ref holds a value. */
package/dist/plan/diff.js CHANGED
@@ -18,7 +18,7 @@ import { hasWildcardGate, planGates } from './gates.js';
18
18
  import { appHostname, tunnelDnsHostnames } from './hostnames.js';
19
19
  import { APP_GONE_KIND } from './live.js';
20
20
  import { lockedHostnames } from './locked-hostnames.js';
21
- import { LOGIN_BINDING_SCOPE_KEY, LOGIN_CLIENT_ORGS_SCOPE_KEY, agoraIssuerHostFor, clientOrganizationsState, companyLoginTunnelHost, customersClientOrganizations, inAppApps, loginBindingState, loginHostClientIds, loginHostServesAgora, loginHostnameFor, ownAuthApplied, pendingRemovalHosts, } from './login-host.js';
21
+ import { LOGIN_BINDING_SCOPE_KEY, LOGIN_CLIENT_ORGS_SCOPE_KEY, OWN_CUSTOMERS_BASE_URI_SCOPE_KEY, agoraIssuerHostFor, clientOrganizationsState, companyLoginTunnelHost, customersClientOrganizations, inAppApps, loginBindingState, loginHostClientIds, loginHostServesAgora, loginHostnameFor, ownAuthApplied, ownCustomersAppState, pendingRemovalHosts, } from './login-host.js';
22
22
  import { MAIL_COPY_COMPLETED_SCOPE_KEY, MAIL_COPY_DONE_SCOPE_KEY, parseMailCopyCompleted, } from './mail-copy.js';
23
23
  import { mailStoreSwitchChange } from './mail-store.js';
24
24
  import { planMcp } from './mcp.js';
@@ -214,6 +214,7 @@ export function computePlanDetail(org, live, recordedIds = {}, agoraHost = NO_AG
214
214
  now: (options.now ?? Date.now)(),
215
215
  signinDbKeysPuttable: options.ownSigninDbKeysPuttable ?? true,
216
216
  signinDbBackupConfigured: options.ownSigninDbBackupConfigured ?? true,
217
+ signinMailWritable: options.ownSigninMailWritable ?? true,
217
218
  });
218
219
  if (ownAuth.step)
219
220
  plannedSteps.push(ownAuth.step);
@@ -1394,8 +1395,13 @@ function planOwnLoginHost(org, recordedIds, gates) {
1394
1395
  const none = { step: null, blocked: null };
1395
1396
  const mapOwed = clientOrganizationsOwed(org, recordedIds, true);
1396
1397
  // Nothing wanted and nothing owed.
1397
- if (!inAppSignIn(org) && !recordedIds[SIGN_IN_IN_APP_SCOPE_KEY] && !mapOwed)
1398
+ // A customers entry's app owes its base URI (the company's login host) until the marker names it.
1399
+ const customersOwed = Boolean(recordedIds[SIGN_IN_LOGIN_APP_SCOPE_KEY]) &&
1400
+ ownCustomersAppState(org, recordedIds) !==
1401
+ (recordedIds[OWN_CUSTOMERS_BASE_URI_SCOPE_KEY] ?? '');
1402
+ if (!inAppSignIn(org) && !recordedIds[SIGN_IN_IN_APP_SCOPE_KEY] && !mapOwed && !customersOwed) {
1398
1403
  return none;
1404
+ }
1399
1405
  // The company's own sign-in is reported on its own line (`coolify:own-auth`).
1400
1406
  if (!ownAuthApplied(org, recordedIds))
1401
1407
  return none;
@@ -1411,6 +1417,7 @@ function planOwnLoginHost(org, recordedIds, gates) {
1411
1417
  if ((recordedIds[SIGN_IN_TRUSTED_SCOPE_KEY] ?? '') === targets.trusted &&
1412
1418
  (recordedIds[SIGN_IN_IN_APP_SCOPE_KEY] ?? '') === targets.active &&
1413
1419
  !mapOwed &&
1420
+ !customersOwed &&
1414
1421
  pendingRemovalHosts(recordedIds).length === 0) {
1415
1422
  return { step: null, blocked: waiting };
1416
1423
  }
@@ -2021,6 +2021,11 @@ export declare class FoundryStepExecutor implements StepExecutor, StepDestroyer
2021
2021
  bouleArchonCredentialAvailable(): boolean;
2022
2022
  /** Whether the held keys can store a new company's sign-in database CA and certificate (#746). */
2023
2023
  ownSigninDbKeysPuttable(): boolean;
2024
+ /**
2025
+ * Whether the host says its company ZITADEL port has `ensureEmailOtpMessageText` and `ensureLabelPolicy`
2026
+ * (`provision.auth.signinMailWritable`). The port is a lazy proxy that answers any name, so it cannot be probed.
2027
+ */
2028
+ ownSigninMailWritable(): boolean;
2024
2029
  /** Whether the held keys can store a Worker app database's CA and certificate. */
2025
2030
  workerAppDbKeysPuttable(): boolean;
2026
2031
  /** Whether the host supplies the backup bucket a Worker app database dumps into (the files host's bucket and key ports). */
@@ -37,7 +37,7 @@ import { GATE_CLAIM_NAME, gateHostnamesScopeKey, gateIdRecordKey, isBehindGate }
37
37
  import { IMAGE_HEALTHCHECK_SETTINGS, commandHealthCheckSettings, healthCheckSettings, wgetProbe, } from './health-check-settings.js';
38
38
  import { PROXY_LABEL_TOKEN_REF } from './held-key-refs.js';
39
39
  import { appHostname, cmsStudioHostnameFor, coolifyTunnelDomains, entryHostnames, siteHostnameFor, tunnelDnsHostnames, tunnelIngressHostnames, } from './hostnames.js';
40
- import { LOGIN_BINDING_SCOPE_KEY, LOGIN_CLIENT_ORGS_SCOPE_KEY, LOGIN_HOST_PENDING_REMOVAL_SCOPE_KEY, LOGIN_HOST_SHARED_SCOPE_KEY, clientOrganizationIds, clientOrganizationsState, customersClientOrganizations, customersEntryKeys, loginBindingState, loginHostClientIds, ownAuthApplied, pendingRemovalHosts, pendingRemovalState, } from './login-host.js';
40
+ import { LOGIN_BINDING_SCOPE_KEY, LOGIN_CLIENT_ORGS_SCOPE_KEY, LOGIN_HOST_PENDING_REMOVAL_SCOPE_KEY, LOGIN_HOST_SHARED_SCOPE_KEY, OWN_CUSTOMERS_BASE_URI_SCOPE_KEY, clientOrganizationIds, clientOrganizationsState, customersClientOrganizations, customersEntryKeys, loginBindingState, loginHostClientIds, ownAuthApplied, pendingRemovalHosts, pendingRemovalState, } from './login-host.js';
41
41
  import { MAIL_COPY_COMPLETED_SCOPE_KEY, MAIL_COPY_DONE_SCOPE_KEY, parseMailCopyCompleted, runMailCopyStep, } from './mail-copy.js';
42
42
  import { OWN_MAIL_SMTP_INTERNAL_SCOPE_KEY, OWN_MAIL_SMTP_PORT_SCOPE_KEY, isOwnMailSmtpPort, normalizeMailTarget, } from './mail-relay.js';
43
43
  import { applyAccessTokenLifetime, applyMcpAccess, applyMcpProject } from './mcp.js';
@@ -1013,6 +1013,13 @@ export class FoundryStepExecutor {
1013
1013
  ownSigninDbKeysPuttable() {
1014
1014
  return Boolean(this.ports.ownServices?.provision.auth.heldKeys?.put);
1015
1015
  }
1016
+ /**
1017
+ * Whether the host says its company ZITADEL port has `ensureEmailOtpMessageText` and `ensureLabelPolicy`
1018
+ * (`provision.auth.signinMailWritable`). The port is a lazy proxy that answers any name, so it cannot be probed.
1019
+ */
1020
+ ownSigninMailWritable() {
1021
+ return this.ports.ownServices?.provision.auth.signinMailWritable === true;
1022
+ }
1016
1023
  /** Whether the held keys can store a Worker app database's CA and certificate. */
1017
1024
  workerAppDbKeysPuttable() {
1018
1025
  return typeof this.ports.heldKeys?.put === 'function';
@@ -2268,6 +2275,17 @@ export class FoundryStepExecutor {
2268
2275
  customersAppIds.add(appId);
2269
2276
  }
2270
2277
  }
2278
+ // A company's own instance: a customers entry's app signs in on the company's login host. Without a base
2279
+ // URI of its own it falls back to the v1 login once in-app sign-in turns `loginV2.required` off.
2280
+ const ownCustomersAppIds = new Set();
2281
+ if (own) {
2282
+ for (const key of customersKeys) {
2283
+ const appId = recorded[`zitadel:app:${key}`];
2284
+ if (appId)
2285
+ ownCustomersAppIds.add(appId);
2286
+ }
2287
+ }
2288
+ const ownCustomersSet = [];
2271
2289
  if (projectId) {
2272
2290
  const listed = await this.ports.zitadel.listApps(zitadelOrgId, projectId);
2273
2291
  listingComplete = listed.complete;
@@ -2277,6 +2295,11 @@ export class FoundryStepExecutor {
2277
2295
  await setOidcAppLoginBaseUri(this.ports.zitadel, zitadelOrgId, projectId, app.id, sharedBaseUri);
2278
2296
  continue;
2279
2297
  }
2298
+ if (ownCustomersAppIds.has(app.id)) {
2299
+ await setOidcAppLoginBaseUri(this.ports.zitadel, zitadelOrgId, projectId, app.id, `https://${host}/`);
2300
+ ownCustomersSet.push(app.id);
2301
+ continue;
2302
+ }
2280
2303
  if (!boundAppIds.has(app.id))
2281
2304
  continue;
2282
2305
  // A company's own instance has the login host as its default; only the in-app apps move.
@@ -2317,9 +2340,11 @@ export class FoundryStepExecutor {
2317
2340
  for (const app of liveApps) {
2318
2341
  const uri = customersAppIds.has(app.id) && sharedBaseUri
2319
2342
  ? sharedBaseUri
2320
- : boundAppIds.has(app.id)
2321
- ? baseUriOf(app.id)
2322
- : app.loginBaseUri;
2343
+ : ownCustomersAppIds.has(app.id)
2344
+ ? `https://${host}/`
2345
+ : boundAppIds.has(app.id)
2346
+ ? baseUriOf(app.id)
2347
+ : app.loginBaseUri;
2323
2348
  if (typeof uri === 'string')
2324
2349
  currentLoginBaseUris.add(normalizedLoginBaseUri(uri));
2325
2350
  }
@@ -2413,6 +2438,10 @@ export class FoundryStepExecutor {
2413
2438
  if (orgsWritable) {
2414
2439
  context.recordScopeId(LOGIN_CLIENT_ORGS_SCOPE_KEY, clientOrganizationsState(wantedOrgs));
2415
2440
  }
2441
+ if (own) {
2442
+ // Written last: a failed base URI write leaves it behind and the step is planned again.
2443
+ context.recordScopeId(OWN_CUSTOMERS_BASE_URI_SCOPE_KEY, ownCustomersSet.sort().join(','));
2444
+ }
2416
2445
  context.recordScopeId(LOGIN_HOST_PENDING_REMOVAL_SCOPE_KEY, pendingRemovalState(stillPending));
2417
2446
  return { resourceId: host };
2418
2447
  }
@@ -7320,6 +7349,7 @@ export class FoundryStepExecutor {
7320
7349
  // empty; the login-host step writes it once the apps' clients exist.
7321
7350
  ...(inAppSignIn(org) ? { signInLogin: { bindings: null } } : {}),
7322
7351
  mcp: orgDeclaresMcp(org),
7352
+ mailBranding: { companyName: org.displayName },
7323
7353
  });
7324
7354
  if (refChanged) {
7325
7355
  const now = scope.recordedIds();
@@ -184,6 +184,14 @@ export declare const LOGIN_CLIENT_ORGS_SCOPE_KEY = "login_client_orgs";
184
184
  * recorded. Staff clients are not here.
185
185
  */
186
186
  export declare function customersClientOrganizations(org: DeclaredOrg, recorded: Readonly<Record<string, string>>): Record<string, string>;
187
+ /**
188
+ * Scope id holding the ZITADEL app ids of an own org's customers entries whose Login V2 base URI was set to the
189
+ * company's login host (sorted, joined by `,`). On the company's own instance an app with no base URI of its own
190
+ * falls back to the v1 login once in-app sign-in turns `loginV2.required` off. Absent or empty means none.
191
+ */
192
+ export declare const OWN_CUSTOMERS_BASE_URI_SCOPE_KEY = "own_customers_login_base_uri";
193
+ /** The recorded ZITADEL app ids of the org's customers entries, sorted and joined by `,` ('' when none). */
194
+ export declare function ownCustomersAppState(org: DeclaredOrg, recorded: Readonly<Record<string, string>>): string;
187
195
  /** {@link LOGIN_CLIENT_ORGS_SCOPE_KEY}'s value for a map. */
188
196
  export declare function clientOrganizationsState(map: Readonly<Record<string, string>>): string;
189
197
  /** The client ids a recorded {@link LOGIN_CLIENT_ORGS_SCOPE_KEY} value holds. */
@@ -211,6 +211,20 @@ export function customersClientOrganizations(org, recorded) {
211
211
  }
212
212
  return map;
213
213
  }
214
+ /**
215
+ * Scope id holding the ZITADEL app ids of an own org's customers entries whose Login V2 base URI was set to the
216
+ * company's login host (sorted, joined by `,`). On the company's own instance an app with no base URI of its own
217
+ * falls back to the v1 login once in-app sign-in turns `loginV2.required` off. Absent or empty means none.
218
+ */
219
+ export const OWN_CUSTOMERS_BASE_URI_SCOPE_KEY = 'own_customers_login_base_uri';
220
+ /** The recorded ZITADEL app ids of the org's customers entries, sorted and joined by `,` ('' when none). */
221
+ export function ownCustomersAppState(org, recorded) {
222
+ return [...customersEntryKeys(org)]
223
+ .map((key) => recorded[`zitadel:app:${key}`])
224
+ .filter((id) => Boolean(id))
225
+ .sort()
226
+ .join(',');
227
+ }
214
228
  /** {@link LOGIN_CLIENT_ORGS_SCOPE_KEY}'s value for a map. */
215
229
  export function clientOrganizationsState(map) {
216
230
  return Object.entries(map)
@@ -51,6 +51,8 @@ export interface PlanOwnAuthOptions {
51
51
  signinDbKeysPuttable?: boolean;
52
52
  /** The host supplies the backup bucket the sign-in database dumps into. Default true. */
53
53
  signinDbBackupConfigured?: boolean;
54
+ /** The host's ZITADEL port can write the sign-in mail text and look. Default true. False: the marker is not required. */
55
+ signinMailWritable?: boolean;
54
56
  }
55
57
  /**
56
58
  * An own org (`services.own`) runs its own sign-in: ZITADEL, the login app
@@ -17,6 +17,7 @@ import { SIGN_IN_LOGIN_APP_SCOPE_KEY, inAppSignIn } from './sign-in.js';
17
17
  import { signinDbBackupMissing } from './signin-db-backup.js';
18
18
  import { SIGNIN_DB_ZITADEL_PENDING_SCOPE_KEY, signinDbCertDue } from './signin-db-tls.js';
19
19
  import { SIGNIN_DB_TLS_SCOPE_KEY } from './signin-db.js';
20
+ import { PLATFORM_AUTH_SIGNIN_MAIL_SCOPE_KEY, signinMailMarker } from './signin-mail.js';
20
21
  /** `unmanaged` key of the one line `planOwnJobs` reports. */
21
22
  export const OWN_JOBS_BLOCKED_KEY = 'own-jobs';
22
23
  /**
@@ -163,6 +164,9 @@ export function planOwnAuth(options) {
163
164
  recordedIds[PLATFORM_AUTH_SMTP_SCOPE_KEY] &&
164
165
  recordedIds[PLATFORM_AUTH_ZITADEL_RUN_OPTIONS_SCOPE_KEY] === AUTH_ZITADEL_RUN_OPTIONS &&
165
166
  recordedIds[PLATFORM_AUTH_TUNNEL_ROUTES_SCOPE_KEY] === AUTH_TUNNEL_ROUTES_VERSION &&
167
+ // The code mail carries the company's name and design; an older apply, or a renamed company, writes it again.
168
+ (options.signinMailWritable === false ||
169
+ recordedIds[PLATFORM_AUTH_SIGNIN_MAIL_SCOPE_KEY] === signinMailMarker(org.displayName)) &&
166
170
  // An entry with `mcp` needs the broker's database and secret (auth ADR 0002).
167
171
  (!orgDeclaresMcp(org) || recordedIds[PLATFORM_AUTH_MCP_SCOPE_KEY] === '1') &&
168
172
  (Boolean(recordedIds[PLATFORM_AUTH_LOGIN_CLIENT_USER_SCOPE_KEY]) || !brokerHoldsAnEntry) &&
@@ -175,7 +179,15 @@ export function planOwnAuth(options) {
175
179
  !signinDbBackupMissing(recordedIds) &&
176
180
  // ZITADEL was started on the TLS database and never seen healthy there: the step starts it again.
177
181
  !recordedIds[SIGNIN_DB_ZITADEL_PENDING_SCOPE_KEY]) {
178
- return { step: null, blocked: null };
182
+ // Applied and otherwise done: say why the mail stays ZITADEL's stock text, once.
183
+ const mailMissing = options.signinMailWritable === false &&
184
+ recordedIds[PLATFORM_AUTH_SIGNIN_MAIL_SCOPE_KEY] !== signinMailMarker(org.displayName);
185
+ return {
186
+ step: null,
187
+ blocked: mailMissing
188
+ ? `${org.slug}'s sign-in mail keeps ZITADEL's default text: the host cannot write message texts`
189
+ : null,
190
+ };
179
191
  }
180
192
  return {
181
193
  blocked: null,
@@ -234,6 +234,8 @@ export interface StepExecutor {
234
234
  ownSigninDbKeysPuttable?(): boolean;
235
235
  /** Whether the host supplies the backup bucket the sign-in database dumps into (#746). Absent: true. */
236
236
  ownSigninDbBackupConfigured?(): boolean;
237
+ /** Whether the host's company ZITADEL port can write the sign-in mail text and look. Absent: true. */
238
+ ownSigninMailWritable?(): boolean;
237
239
  /**
238
240
  * Whether the host's scoped ports for this own org include a files-organisation
239
241
  * port. The planner plans no `files:organisation` step for an own org while
@@ -1363,6 +1363,7 @@ export class FoundryPlanApply {
1363
1363
  workerAppDbBackupConfigured: this.executor.workerAppDbBackupConfigured?.() ?? true,
1364
1364
  ownSigninDbKeysPuttable: this.executor.ownSigninDbKeysPuttable?.() ?? true,
1365
1365
  ownSigninDbBackupConfigured: this.executor.ownSigninDbBackupConfigured?.() ?? true,
1366
+ ownSigninMailWritable: this.executor.ownSigninMailWritable?.() ?? true,
1366
1367
  ...(this.backupGate
1367
1368
  ? {
1368
1369
  backupStorageUuid: (await this.platformDeps?.store.getServiceIds())?.[PLATFORM_BACKUP_S3_STORAGE_SCOPE_KEY] || null,
@@ -20,6 +20,7 @@ import type { PlatformDriftedSteps, PlatformStepExecutionContext, PlatformStepEx
20
20
  import { type BrokerDeployRecord, PLATFORM_ZITADEL_ORG_SCOPE_KEY } from './platform-diff.js';
21
21
  import type { PlatformOwnerInput } from './platform-org.js';
22
22
  import type { SigninDbBackupPort } from './signin-db-backup.js';
23
+ import { type PlatformZitadelMailBranding } from './signin-mail.js';
23
24
  export { PLATFORM_ZITADEL_ORG_SCOPE_KEY };
24
25
  import { type PlatformGithubReleaseHookDeps } from './github-release-hook.js';
25
26
  import { type CallsSfuPlatformDeps } from './platform-sfu.js';
@@ -134,6 +135,12 @@ export interface PlatformAuthSmtp {
134
135
  password?: () => Promise<string>;
135
136
  }
136
137
  export interface PlatformAuthDeps {
138
+ /**
139
+ * The host's ZITADEL port can write the sign-in mail text and look (`PlatformZitadelMailBranding`,
140
+ * `@wtfalch/providers` 0.13.0). Only `true` writes it; otherwise a company keeps ZITADEL's stock text
141
+ * and the planner reports it. The port may be a proxy that answers any method name, so the host says it.
142
+ */
143
+ signinMailWritable?: boolean;
137
144
  /** Overrides the Lettermint SMTP relay ZITADEL is given. Absent, Lettermint exactly as before. */
138
145
  smtp?: PlatformAuthSmtp;
139
146
  coolify: Pick<CoolifyPort, 'findProjectByName' | 'createProject' | 'findOrCreateEnvironment' | 'listServers' | 'findApplicationByName' | 'getApplication' | 'requireGithubApp' | 'createGithubAppApplication' | 'createDockerImageApplication' | 'createDockerfileApplication' | 'createPostgresDatabase' | 'getDatabase' | 'bulkUpsertEnvs' | 'listEnvs' | 'updateApplication' | 'deploy' | 'raw'> &
@@ -150,7 +157,7 @@ export interface PlatformAuthDeps {
150
157
  requestHeaders?: PlatformRequestHeadersFactory;
151
158
  cloudflare: Pick<CloudflarePort, 'findZoneByApex'>;
152
159
  /** Must target the platform's own new instance (its issuer), or `zitadelForInstance` must be set. */
153
- zitadel: Pick<ZitadelPort, 'findOrCreateOrg' | 'mintAdminMachineUser' | 'mintLoginClientMachineUser' | 'listLoginClientPats' | 'reserveUserName' | 'closeSelfRegistration' | 'enforceSecondFactor' | 'inviteOrgOwner' | 'ensureDomainPolicy' | 'addOrgDomain' | 'removeOrgDomain' | 'setPrimaryOrgDomain' | 'setUserMetadata' | 'getUserMetadata'> & PlatformZitadelSmtp;
160
+ zitadel: Pick<ZitadelPort, 'findOrCreateOrg' | 'mintAdminMachineUser' | 'mintLoginClientMachineUser' | 'listLoginClientPats' | 'reserveUserName' | 'closeSelfRegistration' | 'enforceSecondFactor' | 'inviteOrgOwner' | 'ensureDomainPolicy' | 'addOrgDomain' | 'removeOrgDomain' | 'setPrimaryOrgDomain' | 'setUserMetadata' | 'getUserMetadata'> & PlatformZitadelSmtp & Partial<PlatformZitadelMailBranding>;
154
161
  /**
155
162
  * Mails the owner's first-sign-in link to their personal address (the same
156
163
  * port as `FoundryStepExecutorPorts.ownerInviteMail`). The link carries the
@@ -564,6 +571,10 @@ export interface AuthStackInput {
564
571
  * database on the sign-in Postgres and `MCP_OAUTH_SECRET` before that entry reaches it. Absent: neither.
565
572
  */
566
573
  mcp?: boolean;
574
+ /** A company's own sign-in: the code mail names this company (sender, text) and uses the platform's design. Absent: ZITADEL's stock mail. */
575
+ mailBranding?: {
576
+ companyName: string;
577
+ };
567
578
  }
568
579
  /**
569
580
  * #168: makes `coolify:app` keyed {@link PLATFORM_AUTH_KEY} true -- a Coolify
@@ -36,6 +36,7 @@ import { ensureSignInOrg, inviteOwnerWithLink, signInOrgDomain } from './sign-in
36
36
  import { SIGN_IN_BASE_PATH, SIGN_IN_BUILD_ARG_ENV, SIGN_IN_LOGIN_APP_SCOPE_KEY, SIGN_IN_UPSTREAM_ENV, inAppOnSharedLogin, signInLoginNames, signInUpstream, } from './sign-in.js';
37
37
  import { SIGNIN_DB_ZITADEL_PENDING_SCOPE_KEY } from './signin-db-tls.js';
38
38
  import { SIGNIN_DB_APP_SCOPE_KEY, SIGNIN_DB_TLS_SCOPE_KEY, dockerfileAppCreator, ensureSigninDbCaFile, provisionSigninDb, signinDbDsn, waitHealthy, withCallTimeout, } from './signin-db.js';
39
+ import { PLATFORM_AUTH_SIGNIN_MAIL_SCOPE_KEY, applySigninMail, signinMailMarker, signinMailName, } from './signin-mail.js';
39
40
  export { PLATFORM_ZITADEL_ORG_SCOPE_KEY };
40
41
  import { githubReleaseHookPlanInputs, provisionGithubReleaseHook, } from './github-release-hook.js';
41
42
  import { applyCallsSfuPlatformStep, callsSfuAppliedFingerprint, } from './platform-sfu.js';
@@ -2531,7 +2532,9 @@ export async function provisionAuthStack(scope, deps, target, input) {
2531
2532
  user: smtpTarget.user,
2532
2533
  password: smtpPassword,
2533
2534
  senderAddress: input.smtpSender,
2534
- senderName: PLATFORM_AUTH_MAIL_SENDER_NAME,
2535
+ senderName: input.mailBranding
2536
+ ? signinMailName(input.mailBranding.companyName)
2537
+ : PLATFORM_AUTH_MAIL_SENDER_NAME,
2535
2538
  tls: smtpTarget.tls,
2536
2539
  description: target.kind === 'platform'
2537
2540
  ? PLATFORM_AUTH_SMTP_DESCRIPTION
@@ -2547,6 +2550,27 @@ export async function provisionAuthStack(scope, deps, target, input) {
2547
2550
  ]);
2548
2551
  throw new Error(`${label}: could not configure ZITADEL's SMTP provider for ${hostname}: ${cause}`);
2549
2552
  }
2553
+ // A company's own sign-in: its name and design in the code mail. Idempotent; the marker is
2554
+ // recorded after both writes, so a failure halfway is repeated.
2555
+ if (input.mailBranding &&
2556
+ (deps.signinMailWritable !== true ||
2557
+ typeof mailZitadel.ensureEmailOtpMessageText !== 'function' ||
2558
+ typeof mailZitadel.ensureLabelPolicy !== 'function')) {
2559
+ console.info(`${label}: the host cannot write message texts, so the sign-in mail keeps ZITADEL's default text`);
2560
+ }
2561
+ else if (input.mailBranding) {
2562
+ try {
2563
+ await applySigninMail(mailZitadel, input.mailBranding.companyName);
2564
+ }
2565
+ catch (error) {
2566
+ const cause = redactSecrets(error instanceof Error ? error.message : String(error), [
2567
+ mailToken,
2568
+ ...(smtpPassword ? [smtpPassword] : []),
2569
+ ]);
2570
+ throw new Error(`${label}: could not set the sign-in mail text and look for ${hostname}: ${cause}`);
2571
+ }
2572
+ await record(PLATFORM_AUTH_SIGNIN_MAIL_SCOPE_KEY, signinMailMarker(input.mailBranding.companyName));
2573
+ }
2550
2574
  const inviteRecorded = scope.recordedIds()[PLATFORM_AUTH_OWNER_INVITE_SCOPE_KEY];
2551
2575
  const inviteOrgRecorded = scope.recordedIds()[PLATFORM_AUTH_OWNER_INVITE_ORG_SCOPE_KEY];
2552
2576
  // An invite recorded for another org (the `foundry` org, before the owner moved) is made again.
@@ -0,0 +1,83 @@
1
+ /**
2
+ * The sign-in code mail of a company that runs its own sign-in: the company's name in the sender and
3
+ * the text, the platform's design tokens for the look. ZITADEL's email-code message is built from the
4
+ * instance's custom text and label policy; both are written through `PlatformZitadelMailBranding`
5
+ * (`@wtfalch/providers` 0.13.0), each only when it differs.
6
+ *
7
+ * The look: no company declares a design foundry can read (an `OrgTheme` lives in cms and reaches
8
+ * Agora, Boule and the site as data), so every company gets the login's house colours
9
+ * (`SIGNIN_MAIL_LABEL_POLICY`). No logo: ZITADEL takes it as a multipart upload the client does not make.
10
+ */
11
+ /** Raise it when the text or the look changes, so an applied company plans its sign-in step again. */
12
+ export declare const AUTH_SIGNIN_MAIL_VERSION = "1";
13
+ /** `signinMailMarker` of the company name and version last written, recorded after both writes. */
14
+ export declare const PLATFORM_AUTH_SIGNIN_MAIL_SCOPE_KEY = "auth_signin_mail_applied";
15
+ /** The two ZITADEL calls the sign-in mail needs. `@wtfalch/providers` 0.13.0's `ZitadelClient` has both. */
16
+ export interface PlatformZitadelMailBranding {
17
+ ensureEmailOtpMessageText(language: string, text: {
18
+ title: string;
19
+ preHeader: string;
20
+ subject: string;
21
+ greeting: string;
22
+ text: string;
23
+ buttonText: string;
24
+ footerText: string;
25
+ }): Promise<{
26
+ changed: boolean;
27
+ }>;
28
+ ensureLabelPolicy(policy: {
29
+ primaryColor: string;
30
+ backgroundColor: string;
31
+ fontColor: string;
32
+ warnColor: string;
33
+ primaryColorDark: string;
34
+ backgroundColorDark: string;
35
+ fontColorDark: string;
36
+ warnColorDark: string;
37
+ disableWatermark: boolean;
38
+ }): Promise<{
39
+ changed: boolean;
40
+ }>;
41
+ }
42
+ /** What the marker holds: the version and the company name, so a rename writes the mail again. */
43
+ export declare function signinMailMarker(companyName: string): string;
44
+ /**
45
+ * The name as it goes into the sender and the text: one line, and no `{{`, which ZITADEL would run
46
+ * as a template action.
47
+ */
48
+ export declare function signinMailName(companyName: string): string;
49
+ export declare const SIGNIN_MAIL_LANGUAGE = "en";
50
+ /**
51
+ * English text. `{{.OTP}}` is the code variable of this message (ZITADEL's `otp.go` sets it; the link
52
+ * behind the button carries the code as `{{.Code}}` from the login's `urlTemplate`). No greeting by
53
+ * name: the login fills names from the email address, so it would read "Wtfalch Wtfalch".
54
+ */
55
+ export declare function signinMailText(companyName: string): {
56
+ title: string;
57
+ preHeader: string;
58
+ subject: string;
59
+ greeting: string;
60
+ text: string;
61
+ buttonText: string;
62
+ footerText: string;
63
+ };
64
+ /**
65
+ * The login's house colours as ZITADEL's label policy. The login reads the instance label policy for
66
+ * its own page colours and falls back to these when a value is empty (`login/src/helpers/colors.ts`
67
+ * in wtfalch/auth), so writing them keeps the sign-in window as it is and makes the mail match it.
68
+ */
69
+ export declare const SIGNIN_MAIL_LABEL_POLICY: {
70
+ readonly primaryColor: "#B23A0C";
71
+ readonly backgroundColor: "#F6F3EC";
72
+ readonly fontColor: "#1D1B16";
73
+ readonly warnColor: "#B3261E";
74
+ readonly primaryColorDark: "#FF8A4A";
75
+ readonly backgroundColorDark: "#17130E";
76
+ readonly fontColorDark: "#EDE8DD";
77
+ readonly warnColorDark: "#FF9C8C";
78
+ readonly disableWatermark: true;
79
+ };
80
+ /** Writes the text and the look, each only when it differs. Throws the client's fixed-text error. */
81
+ export declare function applySigninMail(zitadel: PlatformZitadelMailBranding, companyName: string): Promise<{
82
+ changed: boolean;
83
+ }>;
@@ -0,0 +1,69 @@
1
+ /**
2
+ * The sign-in code mail of a company that runs its own sign-in: the company's name in the sender and
3
+ * the text, the platform's design tokens for the look. ZITADEL's email-code message is built from the
4
+ * instance's custom text and label policy; both are written through `PlatformZitadelMailBranding`
5
+ * (`@wtfalch/providers` 0.13.0), each only when it differs.
6
+ *
7
+ * The look: no company declares a design foundry can read (an `OrgTheme` lives in cms and reaches
8
+ * Agora, Boule and the site as data), so every company gets the login's house colours
9
+ * (`SIGNIN_MAIL_LABEL_POLICY`). No logo: ZITADEL takes it as a multipart upload the client does not make.
10
+ */
11
+ /** Raise it when the text or the look changes, so an applied company plans its sign-in step again. */
12
+ export const AUTH_SIGNIN_MAIL_VERSION = '1';
13
+ /** `signinMailMarker` of the company name and version last written, recorded after both writes. */
14
+ export const PLATFORM_AUTH_SIGNIN_MAIL_SCOPE_KEY = 'auth_signin_mail_applied';
15
+ /** What the marker holds: the version and the company name, so a rename writes the mail again. */
16
+ export function signinMailMarker(companyName) {
17
+ return `${AUTH_SIGNIN_MAIL_VERSION}:${signinMailName(companyName)}`;
18
+ }
19
+ /**
20
+ * The name as it goes into the sender and the text: one line, and no `{{`, which ZITADEL would run
21
+ * as a template action.
22
+ */
23
+ export function signinMailName(companyName) {
24
+ return companyName
25
+ .replace(/[\p{Cc}\s]+/gu, ' ')
26
+ .replaceAll('{{', '{ {')
27
+ .replaceAll('}}', '} }')
28
+ .trim();
29
+ }
30
+ export const SIGNIN_MAIL_LANGUAGE = 'en';
31
+ /**
32
+ * English text. `{{.OTP}}` is the code variable of this message (ZITADEL's `otp.go` sets it; the link
33
+ * behind the button carries the code as `{{.Code}}` from the login's `urlTemplate`). No greeting by
34
+ * name: the login fills names from the email address, so it would read "Wtfalch Wtfalch".
35
+ */
36
+ export function signinMailText(companyName) {
37
+ const name = signinMailName(companyName);
38
+ return {
39
+ title: 'Your sign-in code',
40
+ preHeader: `Your ${name} sign-in code`,
41
+ subject: `Your ${name} sign-in code`,
42
+ greeting: 'Hello,',
43
+ text: 'Your code is {{.OTP}}. It works for five minutes. Or use the button to sign in on this device.',
44
+ buttonText: 'Sign in',
45
+ footerText: name,
46
+ };
47
+ }
48
+ /**
49
+ * The login's house colours as ZITADEL's label policy. The login reads the instance label policy for
50
+ * its own page colours and falls back to these when a value is empty (`login/src/helpers/colors.ts`
51
+ * in wtfalch/auth), so writing them keeps the sign-in window as it is and makes the mail match it.
52
+ */
53
+ export const SIGNIN_MAIL_LABEL_POLICY = {
54
+ primaryColor: '#B23A0C',
55
+ backgroundColor: '#F6F3EC',
56
+ fontColor: '#1D1B16',
57
+ warnColor: '#B3261E',
58
+ primaryColorDark: '#FF8A4A',
59
+ backgroundColorDark: '#17130E',
60
+ fontColorDark: '#EDE8DD',
61
+ warnColorDark: '#FF9C8C',
62
+ disableWatermark: true,
63
+ };
64
+ /** Writes the text and the look, each only when it differs. Throws the client's fixed-text error. */
65
+ export async function applySigninMail(zitadel, companyName) {
66
+ const text = await zitadel.ensureEmailOtpMessageText(SIGNIN_MAIL_LANGUAGE, signinMailText(companyName));
67
+ const look = await zitadel.ensureLabelPolicy({ ...SIGNIN_MAIL_LABEL_POLICY });
68
+ return { changed: text.changed || look.changed };
69
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wtfalch/foundry",
3
- "version": "0.54.0",
3
+ "version": "0.56.0",
4
4
  "description": "Foundry's SDK: the declared org's types, the plan and apply contracts, and the state store's interface.",
5
5
  "repository": {
6
6
  "type": "git",