@wtfalch/foundry 0.16.12 → 0.16.14

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.
@@ -108,7 +108,7 @@ export function guardStepDeps(executor, redactor, deliverSecret, heldKeys) {
108
108
  // #201: ai's own default minter needs heldKeys too, to re-sign a mint
109
109
  // request against its signing key when an earlier run pinned it but
110
110
  // crashed before the management key itself was minted and delivered.
111
- if (key === 'archon' || key === 'ai' || 'heldKeys' in copy)
111
+ if (key === 'archon' || key === 'ai' || key === 'auth' || 'heldKeys' in copy)
112
112
  copy.heldKeys = heldKeys;
113
113
  out[key] = copy;
114
114
  }
@@ -125,6 +125,18 @@ export declare const PLATFORM_ARCHON_APPLIED_BROKER_SCOPE_KEY = "archon_applied_
125
125
  * between is repaired by the next one.
126
126
  */
127
127
  export declare const PLATFORM_ARCHON_GATE_CLOSED_SCOPE_KEY = "archon_gate_closed";
128
+ /**
129
+ * The ZITADEL SMTP provider (Lettermint) `provisionPlatformAuth` configured on
130
+ * the platform's instance, recorded as the provider id. Without it ZITADEL
131
+ * cannot send the owner's invite, so a platform applied before it existed
132
+ * plans the auth step again.
133
+ */
134
+ export declare const PLATFORM_AUTH_SMTP_SCOPE_KEY = "zitadel_platform_smtp_provider_id";
135
+ /**
136
+ * The platform owner's ZITADEL user id, recorded once the invite email was
137
+ * requested, so the invite goes out once. `none` when no owner is declared.
138
+ */
139
+ export declare const PLATFORM_AUTH_OWNER_INVITE_SCOPE_KEY = "zitadel_platform_owner_invite_user_id";
128
140
  export declare const PLATFORM_AUTH_ADMIN_USER_SCOPE_KEY = "zitadel_platform_admin_user_id";
129
141
  export declare const PLATFORM_AUTH_ADMIN_UNDELIVERED_SCOPE_KEY = "zitadel_platform_admin_user_undelivered";
130
142
  /** #283: the created instance's id, recorded once its owner token is delivered. */
@@ -145,6 +145,18 @@ export const PLATFORM_ARCHON_APPLIED_BROKER_SCOPE_KEY = 'archon_applied_broker_a
145
145
  * between is repaired by the next one.
146
146
  */
147
147
  export const PLATFORM_ARCHON_GATE_CLOSED_SCOPE_KEY = 'archon_gate_closed';
148
+ /**
149
+ * The ZITADEL SMTP provider (Lettermint) `provisionPlatformAuth` configured on
150
+ * the platform's instance, recorded as the provider id. Without it ZITADEL
151
+ * cannot send the owner's invite, so a platform applied before it existed
152
+ * plans the auth step again.
153
+ */
154
+ export const PLATFORM_AUTH_SMTP_SCOPE_KEY = 'zitadel_platform_smtp_provider_id';
155
+ /**
156
+ * The platform owner's ZITADEL user id, recorded once the invite email was
157
+ * requested, so the invite goes out once. `none` when no owner is declared.
158
+ */
159
+ export const PLATFORM_AUTH_OWNER_INVITE_SCOPE_KEY = 'zitadel_platform_owner_invite_user_id';
148
160
  export const PLATFORM_AUTH_ADMIN_USER_SCOPE_KEY = 'zitadel_platform_admin_user_id';
149
161
  export const PLATFORM_AUTH_ADMIN_UNDELIVERED_SCOPE_KEY = 'zitadel_platform_admin_user_undelivered';
150
162
  /** #283: the created instance's id, recorded once its owner token is delivered. */
@@ -1021,7 +1033,7 @@ function planSfu(platform) {
1021
1033
  * #211 round 2: a recorded broker id is not the same as a *deployed* broker
1022
1034
  * ({@link PLATFORM_AUTH_BROKER_DEPLOYED_SCOPE_KEY}) -- `provisionPlatformAuth`
1023
1035
  * creates and records the app before it has `ZITADEL_SERVICE_USER_TOKEN`/
1024
- * `RESEND_API_TOKEN` to deploy it with (#209, #210). So this still plans the
1036
+ * `LETTERMINT_API_TOKEN` to deploy it with (#209, #210). So this still plans the
1025
1037
  * step again while the broker sits undeployed: `executorBlocked` (from
1026
1038
  * `authBrokerBlockedReason`) reports it blocked, by name, for as long as the
1027
1039
  * inputs stay missing, and the moment they arrive the same replanned step
@@ -1131,6 +1143,10 @@ function planAuth(platform, recordedIds, executorBlocked, executorMissing, authD
1131
1143
  brokerApplied &&
1132
1144
  brokerDeployed &&
1133
1145
  loginClientApplied &&
1146
+ // Mail and the owner's invite: a platform applied before they existed
1147
+ // plans the step again, which only adds them.
1148
+ Boolean(recordedIds[PLATFORM_AUTH_SMTP_SCOPE_KEY]) &&
1149
+ Boolean(recordedIds[PLATFORM_AUTH_OWNER_INVITE_SCOPE_KEY]) &&
1134
1150
  // The ZITADEL run options reach the container only through the auth
1135
1151
  // step's redeploy, so a platform recorded before them still plans it.
1136
1152
  recordedIds[PLATFORM_AUTH_ZITADEL_RUN_OPTIONS_SCOPE_KEY] === AUTH_ZITADEL_RUN_OPTIONS) {
@@ -13,13 +13,32 @@ import { type CallsSfuPlatformDeps } from './platform-sfu.js';
13
13
  * positions, so every #167-era caller of {@link FoundryPlatformStepExecutor}
14
14
  * (zone only) keeps compiling with this third argument simply omitted.
15
15
  */
16
+ /**
17
+ * The SMTP call `provisionPlatformAuth` makes on the instance's ZITADEL.
18
+ * `@wtfalch/providers` gains it in 0.7.2; stated here so foundry compiles
19
+ * against 0.7.1 and tests use fakes.
20
+ */
21
+ export interface PlatformZitadelSmtp {
22
+ ensureSmtpProvider(config: {
23
+ host: string;
24
+ user: string;
25
+ password: string;
26
+ senderAddress: string;
27
+ senderName: string;
28
+ tls: boolean;
29
+ description: string;
30
+ }): Promise<{
31
+ id: string;
32
+ changed: boolean;
33
+ }>;
34
+ }
16
35
  export interface PlatformAuthDeps {
17
36
  coolify: Pick<CoolifyPort, 'findProjectByName' | 'createProject' | 'findOrCreateEnvironment' | 'listServers' | 'findApplicationByName' | 'getApplication' | 'requireGithubApp' | 'createGithubAppApplication' | 'createDockerImageApplication' | 'createDockerfileApplication' | 'createPostgresDatabase' | 'getDatabase' | 'bulkUpsertEnvs' | 'listEnvs' | 'updateApplication' | 'deploy' | 'raw'>;
18
37
  cloudflare: Pick<CloudflarePort, 'findZoneByApex' | 'findTunnelByName' | 'createTunnel' | 'putTunnelConfig' | 'findDnsRecord' | 'createDnsRecord' | 'updateDnsRecord'
19
38
  /** #197: fetches the tunnel's connector token for {@link ensureCloudflaredConnector}. */
20
39
  | 'getTunnelToken'>;
21
40
  /** Must target the platform's own new instance (its issuer), or `zitadelForInstance` must be set. */
22
- zitadel: Pick<ZitadelPort, 'findOrCreateOrg' | 'mintAdminMachineUser' | 'mintLoginClientMachineUser' | 'listLoginClientPats'>;
41
+ zitadel: Pick<ZitadelPort, 'findOrCreateOrg' | 'mintAdminMachineUser' | 'mintLoginClientMachineUser' | 'listLoginClientPats' | 'inviteOrgOwner'> & PlatformZitadelSmtp;
23
42
  /**
24
43
  * Builds the ZITADEL port for the new instance from its issuer URL
25
44
  * (`https://<auth.hostname>`). When set it is used instead of
@@ -28,6 +47,19 @@ export interface PlatformAuthDeps {
28
47
  */
29
48
  zitadelForInstance?: (issuerUrl: string) => PlatformAuthDeps['zitadel'];
30
49
  deliverSecret: FoundryStepExecutorPorts['deliverSecret'];
50
+ /**
51
+ * #210: reads back the broker's Lettermint mail key, held under
52
+ * {@link PLATFORM_AUTH_BROKER_MAIL_KEY_REF}. Never minted here -- unlike
53
+ * the login-client token above, foundry has no Lettermint account of its
54
+ * own to mint one from, so this only ever reads back a value a human (or
55
+ * Archon's own onboarding) put there directly once Lettermint issued it.
56
+ * Absent, or with nothing held under that ref yet, `LETTERMINT_API_TOKEN`
57
+ * stays a gap `missingBrokerInputs` reports by name, the same "blocked,
58
+ * not failing" every other unmet broker input already follows.
59
+ */
60
+ heldKeys?: {
61
+ resolve(ref: string): Promise<string | null>;
62
+ };
31
63
  /** Clock, sleep and fetch for the readiness wait and delivery retries; real ones by default. */
32
64
  runtime?: PlatformAuthRuntime;
33
65
  }
@@ -185,7 +217,7 @@ export declare class FoundryPlatformStepExecutor implements PlatformStepExecutor
185
217
  private readonly ai?;
186
218
  private readonly archon?;
187
219
  private readonly backup?;
188
- constructor(cloudflare: Pick<CloudflarePort, 'findZoneByApex' | 'createZone'>, declaredOrgStore: Pick<DeclaredOrgStore, 'findOrgIdForZone' | 'list'>, options?: FoundryPlatformStepExecutorOptions);
220
+ constructor(cloudflare: Pick<CloudflarePort, 'findZoneByApex' | 'createZone'>, declaredOrgStore: Pick<DeclaredOrgStore, 'findOrgIdForZone' | 'list' | 'get'>, options?: FoundryPlatformStepExecutorOptions);
189
221
  blockedSteps(platform: DeclaredPlatform, recordedIds?: Readonly<Record<string, string>>): Promise<{
190
222
  auth?: string;
191
223
  authExecutorMissing?: boolean;
@@ -234,6 +266,15 @@ export declare class FoundryPlatformStepExecutor implements PlatformStepExecutor
234
266
  * key delivered under `zitadel:platform:admin`.
235
267
  */
236
268
  export declare const PLATFORM_AUTH_INSTANCE_BEARER_REF = "zitadel:platform:instanceBearer";
269
+ /**
270
+ * #210: the ref the broker's Lettermint mail key is held under. Unlike every
271
+ * other `_REF` in this file, nothing here ever delivers a value under it --
272
+ * {@link PlatformAuthDeps.heldKeys} only reads it back, once a human (or
273
+ * Archon's own onboarding) puts it there directly with the key Lettermint
274
+ * issued, on one allowed to send from every app's domain
275
+ * (`wtfalch/auth/docs/deploy.md`).
276
+ */
277
+ export declare const PLATFORM_AUTH_BROKER_MAIL_KEY_REF = "lettermint:platform:apiToken";
237
278
  /**
238
279
  * The fixed description Stalwart's admin API key is minted under
239
280
  * (`StalwartClient.createApiKey`'s own signature returns only the secret,
@@ -591,13 +632,14 @@ export declare function createAiManagementKeyMinter(options: {
591
632
  }): AiManagementKeyMinter;
592
633
  /**
593
634
  * #211: names of the envs the platform's sign-in broker (`wtfalch/auth`'s
594
- * `broker/server.ts`) refuses to start without, that `provisionPlatformAuth`
595
- * itself has no way to supply yet -- `ZITADEL_SERVICE_USER_TOKEN`/`_FILE`
596
- * (#209 mints and pushes this once it lands) and `RESEND_API_TOKEN` (#210,
597
- * blocked on `wtfalch/auth#138`). Read from the broker's own live env, not a
598
- * recorded scope id: whichever of #209/#210 lands first (or a human, setting
599
- * either directly on the app) is enough to clear that one input, in either
600
- * order, with no coupling to this step's own execution.
635
+ * `broker/server.ts`) refuses to start without -- `ZITADEL_SERVICE_USER_TOKEN`/
636
+ * `_FILE` (#209 mints and pushes this once it lands) and `LETTERMINT_API_TOKEN`
637
+ * (#210: `wtfalch/auth#138` moved the broker's account mail onto
638
+ * `@wtfalch/mailer`'s Lettermint transport, so this is the env name the
639
+ * broker actually reads now, not `RESEND_API_TOKEN`). Read from the broker's
640
+ * own live env, not a recorded scope id: whichever of #209/#210 lands first
641
+ * (or a human, setting either directly on the app) is enough to clear that
642
+ * one input, in either order, with no coupling to this step's own execution.
601
643
  *
602
644
  * #211 round 3: `AUTH_APPS` is a third required input -- the broker's own
603
645
  * `parseApps` refuses to start with no `AUTH_APPS` set or an empty array
@@ -6,7 +6,7 @@ import { redactSecrets } from '../calls-sfu-deploy-support.js';
6
6
  import { ensureCloudflaredConnector } from './cloudflared-connector.js';
7
7
  import { ACCESS_POLICY_NAME, getDatabaseStarted, isNotFoundError, isRunningOrStarting, } from './executor.js';
8
8
  import { coolifyDomains } from './hostnames.js';
9
- import { AUTH_ZITADEL_RUN_OPTIONS, JOBS_SCHEDULER_KEY, PLATFORM_AI_APPLIED_GITREF_SCOPE_KEY, PLATFORM_AI_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_AI_DEPLOY_PENDING_SCOPE_KEY, PLATFORM_AI_INTERNAL_URL_SCOPE_KEY, PLATFORM_AI_KEY, PLATFORM_AI_MANAGEMENT_KEY_CONFLICT_SCOPE_KEY, PLATFORM_AI_MANAGEMENT_KEY_SCOPE_KEY, PLATFORM_AI_MANAGEMENT_KEY_UNDELIVERED_SCOPE_KEY, PLATFORM_AI_MIGRATE_BLOCKED_SCOPE_KEY, PLATFORM_AI_ORGANISATION_SCOPE_KEY, PLATFORM_AI_RUNNER_DEPLOYMENT_SCOPE_KEY, PLATFORM_AI_SIGNING_KEY_SET_SCOPE_KEY, PLATFORM_AI_SIGNING_KEY_UNDELIVERED_SCOPE_KEY, PLATFORM_ARCHON_APPLIED_BROKER_SCOPE_KEY, PLATFORM_ARCHON_APPLIED_GIT_REF_SCOPE_KEY, PLATFORM_ARCHON_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_ARCHON_CONNECTOR_APP_SCOPE_KEY, PLATFORM_ARCHON_GATE_CLOSED_SCOPE_KEY, PLATFORM_ARCHON_KEY, PLATFORM_ARCHON_MASTER_CLOUDFLARE_TOKEN_REF, PLATFORM_ARCHON_MASTER_COOLIFY_TOKEN_REF, PLATFORM_ARCHON_MASTER_COOLIFY_URL_REF, 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_KEY, PLATFORM_AUTH_LOGIN_CLIENT_UNDELIVERED_SCOPE_KEY, PLATFORM_AUTH_LOGIN_CLIENT_USER_SCOPE_KEY, PLATFORM_AUTH_ZITADEL_APPLIED_IMAGE_TAG_SCOPE_KEY, PLATFORM_AUTH_ZITADEL_RUN_OPTIONS_SCOPE_KEY, PLATFORM_BACKUP_DATABASE_SCOPE_KEYS, PLATFORM_BACKUP_SERVICE_KEYS, 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_KEY, PLATFORM_FILES_MANAGEMENT_KEY_SCOPE_KEY, PLATFORM_FILES_MANAGEMENT_UNDELIVERED_SCOPE_KEY, PLATFORM_FILES_ZITADEL_PROJECT_SCOPE_KEY, PLATFORM_INTEGRATIONS_APPLIED_GITREF_SCOPE_KEY, PLATFORM_INTEGRATIONS_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_INTEGRATIONS_KEY, PLATFORM_INTEGRATIONS_KEY_SET_SCOPE_KEY, PLATFORM_INTEGRATIONS_KEY_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_CONNECTOR_APP_SCOPE_KEY, PLATFORM_MAIL_INTERNAL_URL_SCOPE_KEY, PLATFORM_MAIL_KEY, PLATFORM_MAIL_ZITADEL_PROJECT_SCOPE_KEY, parseBrokerDeployRecord, parseLoginClientRecord, platformMailMxHost, } from './platform-diff.js';
9
+ import { AUTH_ZITADEL_RUN_OPTIONS, JOBS_SCHEDULER_KEY, PLATFORM_AI_APPLIED_GITREF_SCOPE_KEY, PLATFORM_AI_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_AI_DEPLOY_PENDING_SCOPE_KEY, PLATFORM_AI_INTERNAL_URL_SCOPE_KEY, PLATFORM_AI_KEY, PLATFORM_AI_MANAGEMENT_KEY_CONFLICT_SCOPE_KEY, PLATFORM_AI_MANAGEMENT_KEY_SCOPE_KEY, PLATFORM_AI_MANAGEMENT_KEY_UNDELIVERED_SCOPE_KEY, PLATFORM_AI_MIGRATE_BLOCKED_SCOPE_KEY, PLATFORM_AI_ORGANISATION_SCOPE_KEY, PLATFORM_AI_RUNNER_DEPLOYMENT_SCOPE_KEY, PLATFORM_AI_SIGNING_KEY_SET_SCOPE_KEY, PLATFORM_AI_SIGNING_KEY_UNDELIVERED_SCOPE_KEY, PLATFORM_ARCHON_APPLIED_BROKER_SCOPE_KEY, PLATFORM_ARCHON_APPLIED_GIT_REF_SCOPE_KEY, PLATFORM_ARCHON_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_ARCHON_CONNECTOR_APP_SCOPE_KEY, PLATFORM_ARCHON_GATE_CLOSED_SCOPE_KEY, PLATFORM_ARCHON_KEY, PLATFORM_ARCHON_MASTER_CLOUDFLARE_TOKEN_REF, PLATFORM_ARCHON_MASTER_COOLIFY_TOKEN_REF, PLATFORM_ARCHON_MASTER_COOLIFY_URL_REF, 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_KEY, PLATFORM_AUTH_LOGIN_CLIENT_UNDELIVERED_SCOPE_KEY, PLATFORM_AUTH_LOGIN_CLIENT_USER_SCOPE_KEY, PLATFORM_AUTH_OWNER_INVITE_SCOPE_KEY, PLATFORM_AUTH_SMTP_SCOPE_KEY, PLATFORM_AUTH_ZITADEL_APPLIED_IMAGE_TAG_SCOPE_KEY, PLATFORM_AUTH_ZITADEL_RUN_OPTIONS_SCOPE_KEY, PLATFORM_BACKUP_DATABASE_SCOPE_KEYS, PLATFORM_BACKUP_SERVICE_KEYS, 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_KEY, PLATFORM_FILES_MANAGEMENT_KEY_SCOPE_KEY, PLATFORM_FILES_MANAGEMENT_UNDELIVERED_SCOPE_KEY, PLATFORM_FILES_ZITADEL_PROJECT_SCOPE_KEY, PLATFORM_INTEGRATIONS_APPLIED_GITREF_SCOPE_KEY, PLATFORM_INTEGRATIONS_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_INTEGRATIONS_KEY, PLATFORM_INTEGRATIONS_KEY_SET_SCOPE_KEY, PLATFORM_INTEGRATIONS_KEY_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_CONNECTOR_APP_SCOPE_KEY, PLATFORM_MAIL_INTERNAL_URL_SCOPE_KEY, PLATFORM_MAIL_KEY, PLATFORM_MAIL_ZITADEL_PROJECT_SCOPE_KEY, parseBrokerDeployRecord, parseLoginClientRecord, platformMailMxHost, } from './platform-diff.js';
10
10
  import { applyCallsSfuPlatformStep } from './platform-sfu.js';
11
11
  import { CreateInstanceError, createZitadelInstance, generateSystemApiKeypair, instanceWithoutBearerMessage, systemApiUsersEnv, } from './zitadel-system-api.js';
12
12
  /** The key `runPlatformSteps` records the apex zone's id under (`system:kind:key`). */
@@ -302,7 +302,8 @@ export class FoundryPlatformStepExecutor {
302
302
  if (!this.auth) {
303
303
  throw new Error('platform:auth needs FoundryPlatformStepExecutor to be constructed with its auth dependencies (#168)');
304
304
  }
305
- return provisionPlatformAuth(context, this.auth);
305
+ const company = await this.declaredOrgStore.get(context.platform.ownerOrgId);
306
+ return provisionPlatformAuth(context, this.auth, company?.owners ?? []);
306
307
  }
307
308
  if (action.system === 'coolify' && action.kind === 'app' && action.key === PLATFORM_FILES_KEY) {
308
309
  if (!this.files) {
@@ -577,6 +578,15 @@ const PLATFORM_AUTH_ORG_NAME = 'foundry';
577
578
  */
578
579
  export const PLATFORM_AUTH_INSTANCE_BEARER_REF = 'zitadel:platform:instanceBearer';
579
580
  const PLATFORM_AUTH_BEARER_USER_NAME = 'foundry-instance-owner';
581
+ /**
582
+ * #210: the ref the broker's Lettermint mail key is held under. Unlike every
583
+ * other `_REF` in this file, nothing here ever delivers a value under it --
584
+ * {@link PlatformAuthDeps.heldKeys} only reads it back, once a human (or
585
+ * Archon's own onboarding) puts it there directly with the key Lettermint
586
+ * issued, on one allowed to send from every app's domain
587
+ * (`wtfalch/auth/docs/deploy.md`).
588
+ */
589
+ export const PLATFORM_AUTH_BROKER_MAIL_KEY_REF = 'lettermint:platform:apiToken';
580
590
  /**
581
591
  * Retries `action` up to `attempts` times, sleeping increasingly between
582
592
  * tries -- the same shape the admin machine user's own delivery retry (just
@@ -811,10 +821,15 @@ async function liveLoginClientTokenHash(coolify, appId) {
811
821
  * `IAM_LOGIN_CLIENT` machine user (never the admin one, which is
812
822
  * `IAM_OWNER`) and delivers its token to both apps, once the broker's
813
823
  * other required inputs are already met.
814
- * - The broker never receives `RESEND_API_TOKEN` either, so it will not start
815
- * on `AUTH_APPS` alone. `wtfalch/auth#138` moves the broker's account mail
816
- * onto held keys and `@wtfalch/mailer`; until that lands there is no held
817
- * key to read here, so this stays a plain gap rather than a wired value.
824
+ * - ~~The broker never receives `RESEND_API_TOKEN` either, so it will not
825
+ * start on `AUTH_APPS` alone.~~ Closed by #210, once `wtfalch/auth#138`
826
+ * moved the broker's account mail onto `@wtfalch/mailer`'s Lettermint
827
+ * transport: this function now reads the key back from
828
+ * {@link PlatformAuthDeps.heldKeys} under
829
+ * {@link PLATFORM_AUTH_BROKER_MAIL_KEY_REF} and pushes it as
830
+ * `LETTERMINT_API_TOKEN`, once a human (or Archon's own onboarding) has
831
+ * put one there. Still a gap until then, the same "blocked, not failing"
832
+ * way an unset `AUTH_APPS` already is.
818
833
  * - `AUTH_APPS` starts unset: the broker has nothing to serve until a
819
834
  * platform app registers with it ({@link provisionPlatformArchon} is the
820
835
  * first), so it will not start (`parseApps` throws on an empty list) until
@@ -842,7 +857,7 @@ async function liveLoginClientTokenHash(coolify, appId) {
842
857
  * #197: a `cloudflared` connector for this tunnel is run
843
858
  * ({@link ensureCloudflaredConnector}), so `hostname` answers.
844
859
  */
845
- async function provisionPlatformAuth(context, deps) {
860
+ async function provisionPlatformAuth(context, deps, owners = []) {
846
861
  const { platform } = context;
847
862
  const hostname = platform.auth.hostname;
848
863
  const gitRef = platform.auth.gitRef;
@@ -1043,14 +1058,24 @@ async function provisionPlatformAuth(context, deps) {
1043
1058
  { key: 'OTEL_NODE_RESOURCE_DETECTORS', value: 'none' },
1044
1059
  { key: 'API_CACHE_CONFIG', value: '{"defaultMinutes":2,"longMinutes":5}' },
1045
1060
  ];
1046
- // `AUTH_APPS`, `ZITADEL_SERVICE_USER_TOKEN`/`_FILE` and `RESEND_API_TOKEN`
1047
- // are not pushed here -- this function's own doc comment, "Known gaps".
1061
+ // #210: read back now, not inside the deploy-decision block below -- this
1062
+ // push has to land before `missingBrokerInputs` is read a few lines down,
1063
+ // the same way `envsDiffer`/`bulkUpsertEnvs` below already do for every
1064
+ // other broker env, so a single apply that finds the key already held can
1065
+ // also see `LETTERMINT_API_TOKEN` as met and deploy in that same run.
1066
+ const heldMailKey = await deps.heldKeys?.resolve(PLATFORM_AUTH_BROKER_MAIL_KEY_REF);
1067
+ // `AUTH_APPS` and `ZITADEL_SERVICE_USER_TOKEN`/`_FILE` are not pushed here
1068
+ // -- this function's own doc comment, "Known gaps". `LETTERMINT_API_TOKEN`
1069
+ // is, once held (#210): pushing it costs nothing even while the broker
1070
+ // still cannot start on the other two, and the deploy decision below stays
1071
+ // gated on `missingBrokerInputs` regardless.
1048
1072
  const brokerEnvs = [
1049
1073
  {
1050
1074
  key: 'ZITADEL_API_URL',
1051
1075
  value: `http://${PLATFORM_AUTH_ZITADEL_INTERNAL_NAME}:${AUTH_ZITADEL_PORT}`,
1052
1076
  },
1053
1077
  { key: 'ZITADEL_DOMAIN', value: hostname },
1078
+ ...(heldMailKey ? [{ key: 'LETTERMINT_API_TOKEN', value: heldMailKey }] : []),
1054
1079
  ];
1055
1080
  // Compared before the write: an env change only reaches a running
1056
1081
  // container on its next deploy (`diff.ts`'s `coolifyCalls` follows the
@@ -1074,11 +1099,11 @@ async function provisionPlatformAuth(context, deps) {
1074
1099
  await deps.coolify.deploy(loginAppId);
1075
1100
  }
1076
1101
  // #211: the broker's own `server.ts` refuses to start without
1077
- // `ZITADEL_SERVICE_USER_TOKEN`/`_FILE` and `RESEND_API_TOKEN` -- deploying
1078
- // it without them only produces a container that crash-loops behind
1079
- // `/api`. The app is still created and recorded above either way, so #209
1080
- // and #210 (or a human, directly) have something to push the env onto;
1081
- // only the deploy waits.
1102
+ // `ZITADEL_SERVICE_USER_TOKEN`/`_FILE` and `LETTERMINT_API_TOKEN` --
1103
+ // deploying it without them only produces a container that crash-loops
1104
+ // behind `/api`. The app is still created and recorded above either way,
1105
+ // so #209 and #210 (or a human, directly) have something to push the env
1106
+ // onto; only the deploy waits.
1082
1107
  // #211 round 5: `brokerDeployNeeded` is its own reason to deploy (not only
1083
1108
  // alongside justCreated/running/envsChanged, none of which this function's
1084
1109
  // own `brokerEnvs` write above ever touches `AUTH_APPS`): it is what
@@ -1262,7 +1287,7 @@ async function provisionPlatformAuth(context, deps) {
1262
1287
  // envs, then redeploys both.
1263
1288
  //
1264
1289
  // Deferred, not blocked, while the broker's other required inputs
1265
- // (`RESEND_API_TOKEN`, `AUTH_APPS`) are still missing (composing with
1290
+ // (`LETTERMINT_API_TOKEN`, `AUTH_APPS`) are still missing (composing with
1266
1291
  // #211's own gating): minting and delivering here would only redeploy a
1267
1292
  // broker that still refuses to start on the other missing input, spending
1268
1293
  // a mint and two deploys on nothing this apply can fix. `blockedSteps`'s
@@ -1379,8 +1404,62 @@ async function provisionPlatformAuth(context, deps) {
1379
1404
  // own doc comment, and `authLoginClientDrifted`'s, for why.
1380
1405
  await record(PLATFORM_AUTH_LOGIN_CLIENT_USER_SCOPE_KEY, loginClientRecordValue(loginClientUserId, loginClientPatId, loginClientToken));
1381
1406
  }
1407
+ // ---- ZITADEL mail and the owner's invite ----------------------------------
1408
+ // The token is the held key, else the broker's live env. Absent, nothing
1409
+ // here can run; `authBrokerBlockedReason` already reports it by name.
1410
+ const mailToken = heldMailKey || (await currentEnvValue(deps.coolify, brokerAppId, 'LETTERMINT_API_TOKEN'));
1411
+ if (mailToken) {
1412
+ const mailZitadel = deps.zitadelForInstance?.(`https://${hostname}`) ?? deps.zitadel;
1413
+ try {
1414
+ // Runs every time the step does (idempotent), so a rotated token lands.
1415
+ const smtp = await mailZitadel.ensureSmtpProvider({
1416
+ host: PLATFORM_AUTH_SMTP_HOST,
1417
+ user: PLATFORM_AUTH_SMTP_USER,
1418
+ password: mailToken,
1419
+ senderAddress: `noreply@${platform.apex}`,
1420
+ senderName: PLATFORM_AUTH_MAIL_SENDER_NAME,
1421
+ tls: true,
1422
+ description: PLATFORM_AUTH_SMTP_DESCRIPTION,
1423
+ });
1424
+ await record(PLATFORM_AUTH_SMTP_SCOPE_KEY, smtp.id);
1425
+ }
1426
+ catch {
1427
+ throw new Error(`platform:auth: could not configure ZITADEL's SMTP provider for ${hostname}; the cause is left out because it can carry the Lettermint key`);
1428
+ }
1429
+ if (!context.recordedIds()[PLATFORM_AUTH_OWNER_INVITE_SCOPE_KEY]) {
1430
+ const owner = owners[0];
1431
+ if (!owner) {
1432
+ // No owner declared: nothing to invite. Recorded so the step is not replanned for it.
1433
+ await record(PLATFORM_AUTH_OWNER_INVITE_SCOPE_KEY, 'none');
1434
+ }
1435
+ else {
1436
+ const ownerOrgId = context.recordedIds()[PLATFORM_ZITADEL_ORG_SCOPE_KEY] ??
1437
+ (await mailZitadel.findOrCreateOrg(PLATFORM_AUTH_ORG_NAME)).org.id;
1438
+ await record(PLATFORM_ZITADEL_ORG_SCOPE_KEY, ownerOrgId);
1439
+ let invitedUserId;
1440
+ try {
1441
+ ({ userId: invitedUserId } = await mailZitadel.inviteOrgOwner(ownerOrgId, {
1442
+ givenName: owner.givenName,
1443
+ familyName: owner.familyName,
1444
+ email: owner.email,
1445
+ }));
1446
+ }
1447
+ catch {
1448
+ throw new Error(`platform:auth: could not invite the platform owner into ZITADEL org ${ownerOrgId}; the cause is left out because it can carry the owner's email`);
1449
+ }
1450
+ await record(PLATFORM_AUTH_OWNER_INVITE_SCOPE_KEY, invitedUserId);
1451
+ }
1452
+ }
1453
+ }
1382
1454
  return { resourceId: zitadelAppId };
1383
1455
  }
1456
+ /** Lettermint's SMTP relay, as ZITADEL's SMTP provider wants it: `host:port`, STARTTLS. */
1457
+ const PLATFORM_AUTH_SMTP_HOST = 'smtp.lettermint.co:587';
1458
+ const PLATFORM_AUTH_SMTP_USER = 'lettermint';
1459
+ /** Marks the provider as foundry's, so `ensureSmtpProvider` finds it again. */
1460
+ const PLATFORM_AUTH_SMTP_DESCRIPTION = 'foundry:platform:lettermint';
1461
+ /** The same name the broker's `from` uses (`Company Included <noreply@apex>`). */
1462
+ const PLATFORM_AUTH_MAIL_SENDER_NAME = 'Company Included';
1384
1463
  // ---------------------------------------------------------------------------
1385
1464
  // #169: the platform's own mail server (Stalwart)
1386
1465
  // ---------------------------------------------------------------------------
@@ -1967,12 +2046,12 @@ async function mailBlockedReason(platform, mail, recordedIds) {
1967
2046
  }
1968
2047
  /**
1969
2048
  * #211: the broker cannot start without `ZITADEL_SERVICE_USER_TOKEN`/`_FILE`,
1970
- * `RESEND_API_TOKEN` and `AUTH_APPS`. `provisionPlatformAuth` already skips
1971
- * deploying the broker while any is missing ({@link missingBrokerInputs});
1972
- * this is the same check surfaced through `blockedSteps`, so a plan says
1973
- * why, by name, rather than the broker simply sitting undeployed with no
1974
- * explanation. `null` while there is no broker app id recorded yet (nothing
1975
- * to check) or every input is present.
2049
+ * `LETTERMINT_API_TOKEN` and `AUTH_APPS`. `provisionPlatformAuth` already
2050
+ * skips deploying the broker while any is missing
2051
+ * ({@link missingBrokerInputs}); this is the same check surfaced through
2052
+ * `blockedSteps`, so a plan says why, by name, rather than the broker simply
2053
+ * sitting undeployed with no explanation. `null` while there is no broker
2054
+ * app id recorded yet (nothing to check) or every input is present.
1976
2055
  *
1977
2056
  * #211 round 3: wraps the live read the way {@link mailBlockedReason} wraps
1978
2057
  * its own Coolify call -- a transient `listEnvs` failure must report a
@@ -1986,6 +2065,21 @@ async function mailBlockedReason(platform, mail, recordedIds) {
1986
2065
  * go fix an entry, not merely wait for one of #209/#210/Archon's own
1987
2066
  * registration to supply one), and reporting it identically hid which one
1988
2067
  * was true.
2068
+ *
2069
+ * #210: `LETTERMINT_API_TOKEN` joins `ZITADEL_SERVICE_USER_TOKEN` in being
2070
+ * excluded from `otherInputsMissing` -- but only while `auth.heldKeys`
2071
+ * actually resolves {@link PLATFORM_AUTH_BROKER_MAIL_KEY_REF} right now.
2072
+ * `planAuth`'s own "loop fix" stops planning this step the moment every
2073
+ * `otherInputsMissing` entry looks like something only another run can
2074
+ * supply; excluding `LETTERMINT_API_TOKEN` unconditionally (the way
2075
+ * `ZITADEL_SERVICE_USER_TOKEN` is, since foundry can always eventually mint
2076
+ * that one) would keep the step planned, and `provisionPlatformAuth`
2077
+ * running its full body, forever on a host that never wires `heldKeys` or
2078
+ * whose operator has not put a Lettermint key there yet -- exactly the
2079
+ * wasteful-replan bug #211 round 5 fixed for the broker deploy itself. Once
2080
+ * the key *is* held, excluding it lets this run's `provisionPlatformAuth`
2081
+ * push it (see that function's own `heldMailKey` read) and clears the gap
2082
+ * for good.
1989
2083
  */
1990
2084
  async function authBrokerBlockedReason(auth, recordedIds) {
1991
2085
  const brokerAppId = recordedIds[PLATFORM_AUTH_BROKER_SCOPE_KEY];
@@ -1993,9 +2087,11 @@ async function authBrokerBlockedReason(auth, recordedIds) {
1993
2087
  return { reason: null, otherInputsMissing: [] };
1994
2088
  let missing;
1995
2089
  let authAppsRaw;
2090
+ let heldMailKey;
1996
2091
  try {
1997
2092
  missing = await missingBrokerInputs(auth.coolify, brokerAppId);
1998
2093
  authAppsRaw = await currentEnvValue(auth.coolify, brokerAppId, 'AUTH_APPS');
2094
+ heldMailKey = (await auth.heldKeys?.resolve(PLATFORM_AUTH_BROKER_MAIL_KEY_REF)) ?? null;
1999
2095
  }
2000
2096
  catch {
2001
2097
  return {
@@ -2007,13 +2103,19 @@ async function authBrokerBlockedReason(auth, recordedIds) {
2007
2103
  }
2008
2104
  // #216 round 5: what `provisionPlatformAuth`'s own `loginClientOtherInputsMissing`
2009
2105
  // gate is -- see `planAuth`'s own doc comment for why it needs this too.
2010
- const otherInputsMissing = missing.filter((m) => m !== 'ZITADEL_SERVICE_USER_TOKEN');
2106
+ // #210: and `LETTERMINT_API_TOKEN`, once held -- see this function's own
2107
+ // doc comment.
2108
+ const otherInputsMissing = missing.filter((m) => m !== 'ZITADEL_SERVICE_USER_TOKEN' && !(m === 'LETTERMINT_API_TOKEN' && heldMailKey));
2011
2109
  if (missing.length === 0)
2012
2110
  return { reason: null, otherInputsMissing };
2111
+ // #210: say where the operator seals the Lettermint key, not only its env name.
2112
+ const named = (names) => names.map((m) => m === 'LETTERMINT_API_TOKEN'
2113
+ ? `LETTERMINT_API_TOKEN (seal it under the held-key ref ${PLATFORM_AUTH_BROKER_MAIL_KEY_REF})`
2114
+ : m);
2013
2115
  if (authAppsProblem(authAppsRaw, brokerAppId) === 'malformed') {
2014
2116
  const otherMissing = missing.filter((m) => m !== 'AUTH_APPS');
2015
2117
  const otherSuffix = otherMissing.length > 0
2016
- ? `, and it has no ${otherMissing.join(' and no ')} yet (#209, #210)`
2118
+ ? `, and it has no ${named(otherMissing).join(' and no ')} yet (#209, #210)`
2017
2119
  : '';
2018
2120
  return {
2019
2121
  reason: `the platform's sign-in broker ${brokerAppId} is blocked, not deployed: its AUTH_APPS is malformed${otherSuffix}`,
@@ -2024,7 +2126,7 @@ async function authBrokerBlockedReason(auth, recordedIds) {
2024
2126
  // those issues when one of them is actually among what's missing.
2025
2127
  const suffix = missing.some((m) => m !== 'AUTH_APPS') ? ' (#209, #210)' : '';
2026
2128
  return {
2027
- reason: `the platform's sign-in broker is created but not deployed, blocked: it has no ${missing.join(' and no ')} yet${suffix}`,
2129
+ reason: `the platform's sign-in broker is created but not deployed, blocked: it has no ${named(missing).join(' and no ')} yet${suffix}`,
2028
2130
  otherInputsMissing,
2029
2131
  };
2030
2132
  }
@@ -2197,8 +2299,8 @@ export const PLATFORM_ARCHON_CLIENT_ID_SCOPE_KEY = 'zitadel_platform_archon_clie
2197
2299
  * `provisionPlatformAuth` guarantees by the time this step is reachable (its
2198
2300
  * own resourceId records only once the broker app exists too). The broker
2199
2301
  * will not actually be able to serve a request with this key until
2200
- * `provisionPlatformAuth`'s own "Known gaps" (`ZITADEL_SERVICE_USER_TOKEN`,
2201
- * `RESEND_API_TOKEN`) are closed; wiring the key here is still correct and
2302
+ * `provisionPlatformAuth`'s own `ZITADEL_SERVICE_USER_TOKEN` is minted and
2303
+ * `LETTERMINT_API_TOKEN` is held; wiring the key here is still correct and
2202
2304
  * needed so the two apps agree the moment the broker can run.
2203
2305
  *
2204
2306
  * Known gaps, left for review:
@@ -2486,8 +2588,8 @@ async function provisionPlatformArchon(context, deps) {
2486
2588
  await deps.coolify.bulkUpsertEnvs(brokerAppId, desiredAuthApps);
2487
2589
  }
2488
2590
  // #211 round 2: deploying the broker before its own `server.ts` can start
2489
- // (missing `ZITADEL_SERVICE_USER_TOKEN`/`_FILE` or `RESEND_API_TOKEN`, #209,
2490
- // #210) only produces a container that crash-loops behind `/api` -- the
2591
+ // (missing `ZITADEL_SERVICE_USER_TOKEN`/`_FILE` or `LETTERMINT_API_TOKEN`,
2592
+ // #209, #210) only produces a container that crash-loops behind `/api` -- the
2491
2593
  // same rule `provisionPlatformAuth` follows for its own deploy of this
2492
2594
  // app. The env is still written either way, so the broker serves the new
2493
2595
  // registration the moment it is next deployed, by either step.
@@ -3987,13 +4089,14 @@ async function findOrCreatePlatformApp(coolify, name, environment, create) {
3987
4089
  }
3988
4090
  /**
3989
4091
  * #211: names of the envs the platform's sign-in broker (`wtfalch/auth`'s
3990
- * `broker/server.ts`) refuses to start without, that `provisionPlatformAuth`
3991
- * itself has no way to supply yet -- `ZITADEL_SERVICE_USER_TOKEN`/`_FILE`
3992
- * (#209 mints and pushes this once it lands) and `RESEND_API_TOKEN` (#210,
3993
- * blocked on `wtfalch/auth#138`). Read from the broker's own live env, not a
3994
- * recorded scope id: whichever of #209/#210 lands first (or a human, setting
3995
- * either directly on the app) is enough to clear that one input, in either
3996
- * order, with no coupling to this step's own execution.
4092
+ * `broker/server.ts`) refuses to start without -- `ZITADEL_SERVICE_USER_TOKEN`/
4093
+ * `_FILE` (#209 mints and pushes this once it lands) and `LETTERMINT_API_TOKEN`
4094
+ * (#210: `wtfalch/auth#138` moved the broker's account mail onto
4095
+ * `@wtfalch/mailer`'s Lettermint transport, so this is the env name the
4096
+ * broker actually reads now, not `RESEND_API_TOKEN`). Read from the broker's
4097
+ * own live env, not a recorded scope id: whichever of #209/#210 lands first
4098
+ * (or a human, setting either directly on the app) is enough to clear that
4099
+ * one input, in either order, with no coupling to this step's own execution.
3997
4100
  *
3998
4101
  * #211 round 3: `AUTH_APPS` is a third required input -- the broker's own
3999
4102
  * `parseApps` refuses to start with no `AUTH_APPS` set or an empty array
@@ -4011,8 +4114,8 @@ export async function missingBrokerInputs(coolify, brokerAppId) {
4011
4114
  if (!has('ZITADEL_SERVICE_USER_TOKEN') && !has('ZITADEL_SERVICE_USER_TOKEN_FILE')) {
4012
4115
  missing.push('ZITADEL_SERVICE_USER_TOKEN');
4013
4116
  }
4014
- if (!has('RESEND_API_TOKEN'))
4015
- missing.push('RESEND_API_TOKEN');
4117
+ if (!has('LETTERMINT_API_TOKEN'))
4118
+ missing.push('LETTERMINT_API_TOKEN');
4016
4119
  if (authAppsProblem(value('AUTH_APPS'), brokerAppId) !== null)
4017
4120
  missing.push('AUTH_APPS');
4018
4121
  return missing;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wtfalch/foundry",
3
- "version": "0.16.12",
3
+ "version": "0.16.14",
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",
@@ -43,7 +43,7 @@
43
43
  "@wtfalch/audit": "0.6.0",
44
44
  "@wtfalch/auth": "0.16.0",
45
45
  "@wtfalch/email": "^0.17.0",
46
- "@wtfalch/providers": "0.6.0",
46
+ "@wtfalch/providers": "0.7.2",
47
47
  "ssh2": "1.17.0",
48
48
  "zod": "4.6.5"
49
49
  },