@wtfalch/foundry 0.28.0 → 0.30.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.
@@ -1,6 +1,6 @@
1
1
  export type { LiveResource, ZitadelReader, CoolifyReader, CloudflareReader, FilesReader, AiReader, CmsReader, LiveReaders, PlatformCloudflareReader, PlatformCoolifyReader, PlatformLiveReaders, } from './live.js';
2
2
  export { readLiveResources, readLivePlatformResources } from './live.js';
3
- export type { StepAction, PlannedStep, PlanDetail } from './diff.js';
3
+ export type { StepAction, PlannedStep, PlanDetail, OrgPostgresPlan } from './diff.js';
4
4
  export { computePlanDetail, toPlan } from './diff.js';
5
5
  export type { StepSystem } from './step-system.js';
6
6
  export { stepSystemOf } from './step-system.js';
@@ -21,13 +21,14 @@ export { FILES_INTROSPECTION_SECRET_REF, FILES_MANAGEMENT_ISSUER, FILES_MANAGEME
21
21
  export { AI_MANAGEMENT_ISSUER, AI_MANAGEMENT_KEY_ID, AI_SIGNING_KEY_REF, AiManagementKeyExistsError, createAiManagementKeyMinter, PLATFORM_AI_MANAGEMENT_KEY_REF, PLATFORM_AI_MANAGEMENT_PERMISSIONS, } from './platform-providers.js';
22
22
  export type { AiManagementKeyMinter, PlatformAiDeps, PlatformAiRuntime, } from './platform-providers.js';
23
23
  export { createZitadelAdminMinter } from './zitadel-machine-user.js';
24
+ export { createZitadelLoginPolicy } from './zitadel-login-policy.js';
24
25
  export { createZitadelProjectRoles } from './zitadel-project-roles.js';
25
26
  export type { CallsSfuConnection, CallsSfuConnectionPort, CallsSfuKeysPort, CallsSfuSshConnector, CallsSfuPlatformDeps, } from './platform-sfu.js';
26
27
  export { MissingCallsSfuConnectionError, applyCallsSfuPlatformStep, callsSfuAppliedFingerprint, } from './platform-sfu.js';
27
28
  export type { MailCopyJob, MailCopyPort, MailCopyStepResult } from './mail-copy.js';
28
29
  export { MAIL_COPY_COMPLETED_SCOPE_KEY, MAIL_COPY_DONE_SCOPE_KEY, MissingMailCopyAccountsError, MissingMailCopySourceTokenError, MissingMailCopyStalwartTokenError, parseMailCopyCompleted, runMailCopyStep, } from './mail-copy.js';
29
30
  export type { ScopeResolver, RecordedIds, DnsRecordOwners } from './providers-readers.js';
30
- export { ProvidersAiReader, ProvidersCloudflareReader, ProvidersCmsReader, ProvidersCoolifyReader, ProvidersZitadelReader, storeScopeResolver, storeRecordedIds, } from './providers-readers.js';
31
+ export { ProvidersAiReader, ProvidersCloudflareReader, ProvidersCmsReader, ProvidersCoolifyReader, ProvidersZitadelReader, storeLoginHostResolver, storeScopeResolver, storeRecordedIds, } from './providers-readers.js';
31
32
  export type { AgoraHostPort, AgoraHostValues } from './agora.js';
32
33
  export type { BrokerPort } from './broker-entries.js';
33
34
  export { PlatformBrokerPort } from './broker-entries.js';
@@ -57,3 +58,5 @@ export type { EdgePlatformTunnels, TunnelIngress, TunnelPort, TunnelSummary, } f
57
58
  export type { ZitadelListAppsRawApp } from './executor.js';
58
59
  export { adaptZitadelListApps } from './executor.js';
59
60
  export { HttpJobsSchedulerClient } from './jobs-scheduler-http.js';
61
+ export type { OrgAppVersionRef, OrgAppVersionStatus, WtfalchPackageDrift, PlatformVersionStatus, ReadPlatformVersionStatusOptions, StatusCoolifyPort, StatusGithubPort, GhExec as StatusGhExec, } from './version-status.js';
62
+ export { readPlatformVersionStatus, wtfalchVersionsFromPnpmLockfile, providersStatusCoolifyPort, ghCliStatusGithubPort, } from './version-status.js';
@@ -11,10 +11,11 @@ export { FoundryPlatformStepExecutor, ProvidersPlatformCloudflareReader, Provide
11
11
  export { FILES_INTROSPECTION_SECRET_REF, FILES_MANAGEMENT_ISSUER, FILES_MANAGEMENT_KEY_ID, FILES_MANAGEMENT_KEY_REF, PLATFORM_AUTH_INSTANCE_BEARER_REF, } from './platform-providers.js';
12
12
  export { AI_MANAGEMENT_ISSUER, AI_MANAGEMENT_KEY_ID, AI_SIGNING_KEY_REF, AiManagementKeyExistsError, createAiManagementKeyMinter, PLATFORM_AI_MANAGEMENT_KEY_REF, PLATFORM_AI_MANAGEMENT_PERMISSIONS, } from './platform-providers.js';
13
13
  export { createZitadelAdminMinter } from './zitadel-machine-user.js';
14
+ export { createZitadelLoginPolicy } from './zitadel-login-policy.js';
14
15
  export { createZitadelProjectRoles } from './zitadel-project-roles.js';
15
16
  export { MissingCallsSfuConnectionError, applyCallsSfuPlatformStep, callsSfuAppliedFingerprint, } from './platform-sfu.js';
16
17
  export { MAIL_COPY_COMPLETED_SCOPE_KEY, MAIL_COPY_DONE_SCOPE_KEY, MissingMailCopyAccountsError, MissingMailCopySourceTokenError, MissingMailCopyStalwartTokenError, parseMailCopyCompleted, runMailCopyStep, } from './mail-copy.js';
17
- export { ProvidersAiReader, ProvidersCloudflareReader, ProvidersCmsReader, ProvidersCoolifyReader, ProvidersZitadelReader, storeScopeResolver, storeRecordedIds, } from './providers-readers.js';
18
+ export { ProvidersAiReader, ProvidersCloudflareReader, ProvidersCmsReader, ProvidersCoolifyReader, ProvidersZitadelReader, storeLoginHostResolver, storeScopeResolver, storeRecordedIds, } from './providers-readers.js';
18
19
  export { PlatformBrokerPort } from './broker-entries.js';
19
20
  export { loginHostnameFor } from './login-host.js';
20
21
  export { checkMailIdentityAudience } from './mail-identity.js';
@@ -29,3 +30,4 @@ export { createEdgeZones } from './edge-zones.js';
29
30
  export { createEdgeTunnels } from './edge-tunnels.js';
30
31
  export { adaptZitadelListApps } from './executor.js';
31
32
  export { HttpJobsSchedulerClient } from './jobs-scheduler-http.js';
33
+ export { readPlatformVersionStatus, wtfalchVersionsFromPnpmLockfile, providersStatusCoolifyPort, ghCliStatusGithubPort, } from './version-status.js';
@@ -1314,6 +1314,7 @@ const STEP_FIELDS = {
1314
1314
  'coolify:chat': 'chat.usesDomain',
1315
1315
  'coolify:calls': 'calls.enabled',
1316
1316
  'coolify:calls-service': 'calls.enabled',
1317
+ 'coolify:calls-chat': 'calls.enabled',
1317
1318
  'ai:budget': 'ai',
1318
1319
  'ai:key': 'ai',
1319
1320
  'cms:site': 'site',
@@ -207,6 +207,15 @@ export declare const PLATFORM_AUTH_SMTP_SCOPE_KEY = "zitadel_platform_smtp_provi
207
207
  * requested, so the invite goes out once. `none` when no owner is declared.
208
208
  */
209
209
  export declare const PLATFORM_AUTH_OWNER_INVITE_SCOPE_KEY = "zitadel_platform_owner_invite_user_id";
210
+ /**
211
+ * #394: the id of the role-less ZITADEL machine user that reserves the
212
+ * username `admin` in the platform's own org, so nobody can sign up under a
213
+ * name Stalwart maps onto its Admin account. `foundry-admin` is reserved by
214
+ * the admin machine user itself.
215
+ */
216
+ export declare const PLATFORM_AUTH_RESERVED_ADMIN_USERNAME_SCOPE_KEY = "zitadel_platform_reserved_admin_username";
217
+ /** #394: recorded once the platform's own ZITADEL org has self-registration forced off. */
218
+ export declare const PLATFORM_AUTH_REGISTRATION_CLOSED_SCOPE_KEY = "zitadel_platform_registration_closed";
210
219
  export declare const PLATFORM_AUTH_ADMIN_USER_SCOPE_KEY = "zitadel_platform_admin_user_id";
211
220
  export declare const PLATFORM_AUTH_ADMIN_UNDELIVERED_SCOPE_KEY = "zitadel_platform_admin_user_undelivered";
212
221
  /** #283: the created instance's id, recorded once its owner token is delivered. */
@@ -234,6 +234,15 @@ export const PLATFORM_AUTH_SMTP_SCOPE_KEY = 'zitadel_platform_smtp_provider_id';
234
234
  * requested, so the invite goes out once. `none` when no owner is declared.
235
235
  */
236
236
  export const PLATFORM_AUTH_OWNER_INVITE_SCOPE_KEY = 'zitadel_platform_owner_invite_user_id';
237
+ /**
238
+ * #394: the id of the role-less ZITADEL machine user that reserves the
239
+ * username `admin` in the platform's own org, so nobody can sign up under a
240
+ * name Stalwart maps onto its Admin account. `foundry-admin` is reserved by
241
+ * the admin machine user itself.
242
+ */
243
+ export const PLATFORM_AUTH_RESERVED_ADMIN_USERNAME_SCOPE_KEY = 'zitadel_platform_reserved_admin_username';
244
+ /** #394: recorded once the platform's own ZITADEL org has self-registration forced off. */
245
+ export const PLATFORM_AUTH_REGISTRATION_CLOSED_SCOPE_KEY = 'zitadel_platform_registration_closed';
237
246
  export const PLATFORM_AUTH_ADMIN_USER_SCOPE_KEY = 'zitadel_platform_admin_user_id';
238
247
  export const PLATFORM_AUTH_ADMIN_UNDELIVERED_SCOPE_KEY = 'zitadel_platform_admin_user_undelivered';
239
248
  /** #283: the created instance's id, recorded once its owner token is delivered. */
@@ -1708,7 +1717,10 @@ function planAuth(platform, recordedIds, executorBlocked, executorMissing, authD
1708
1717
  Boolean(recordedIds[PLATFORM_AUTH_OWNER_INVITE_SCOPE_KEY]) &&
1709
1718
  // The ZITADEL run options reach the container only through the auth
1710
1719
  // step's redeploy, so a platform recorded before them still plans it.
1711
- recordedIds[PLATFORM_AUTH_ZITADEL_RUN_OPTIONS_SCOPE_KEY] === AUTH_ZITADEL_RUN_OPTIONS) {
1720
+ recordedIds[PLATFORM_AUTH_ZITADEL_RUN_OPTIONS_SCOPE_KEY] === AUTH_ZITADEL_RUN_OPTIONS &&
1721
+ // #394: a platform applied before these existed plans the step again.
1722
+ Boolean(recordedIds[PLATFORM_AUTH_RESERVED_ADMIN_USERNAME_SCOPE_KEY]) &&
1723
+ Boolean(recordedIds[PLATFORM_AUTH_REGISTRATION_CLOSED_SCOPE_KEY])) {
1712
1724
  return { step: null, blocked: null };
1713
1725
  }
1714
1726
  // The admin key was minted but never reached the host. ZITADEL shows a
@@ -2284,7 +2296,7 @@ function planAccessProvider(platform, recordedIds, executorBlocked) {
2284
2296
  * auth/ZITADEL client: `hc` is an internal operational tool, not something
2285
2297
  * an organisation member signs into. Whether this stays the platform's
2286
2298
  * heartbeat server long-term, instead of or alongside the hand-set-up
2287
- * `hc.wtfalch.dev` instance, and what happens to that instance's own data,
2299
+ * heartbeat instance, and what happens to that instance's own data,
2288
2300
  * are both still open "Decide" items on issue #540's own thread -- not
2289
2301
  * settled by this code.
2290
2302
  *
@@ -2598,7 +2610,7 @@ function planAi(platform, recordedIds, executorBlocked) {
2598
2610
  /**
2599
2611
  * #175: Archon (https://github.com/wtfalch/archon), the one shared console,
2600
2612
  * built fresh at `platform.archon.hostname` (`archon.<apex>`) rather than the
2601
- * hand-deployed `operator.wtfalch.dev` it replaces (#165). One step,
2613
+ * hand-deployed `operator` app it replaces (#165). One step,
2602
2614
  * `coolify:app` keyed {@link PLATFORM_ARCHON_KEY}, covers the app, its
2603
2615
  * database, its Cloudflare tunnel, Access app and DNS record, and its sign-in
2604
2616
  * client in the platform's own ZITADEL -- the same bundling {@link planAuth}
@@ -86,6 +86,8 @@ export interface OrgPlatformReadiness {
86
86
  callsReady: boolean;
87
87
  /** The platform's own sign-in (#168), which every org's own ZITADEL org lives inside. */
88
88
  authReady: boolean;
89
+ /** `https://<platform.auth.hostname>`, the platform's own sign-in issuer; `null` until {@link authReady}. The owner invite reads its issuer here, never from Agora's host config. */
90
+ authIssuer: string | null;
89
91
  /** The Access provider id the platform recorded (`ACCESS_PROVIDER_RECORD_KEY`), which every gate requires. `null` or absent until the platform has recorded it. */
90
92
  accessProviderId?: string | null;
91
93
  /** The jobs scheduler (#173) is enabled, applied, has recorded its internal address, and its admin token is held. */
@@ -32,6 +32,7 @@ export const DEFAULT_ORG_PLATFORM_READINESS = {
32
32
  aiReady: false,
33
33
  callsReady: false,
34
34
  authReady: false,
35
+ authIssuer: null,
35
36
  jobsReady: false,
36
37
  edgeReady: false,
37
38
  missingKeys: { files: null, ai: null, jobs: null },
@@ -106,6 +107,7 @@ edgePortPresent = false) {
106
107
  const jobsApplied = platform.jobs.enabled && Boolean(platformRecordedIds[`coolify:jobs:${JOBS_SCHEDULER_KEY}`]);
107
108
  const jobsUrl = platformRecordedIds[JOBS_INTERNAL_URL_SCOPE_KEY];
108
109
  const edgeApplied = platform.edge.enabled && coolifyAppApplied(platformRecordedIds, PLATFORM_EDGE_KEY);
110
+ const authReady = platform.auth.enabled && coolifyAppApplied(platformRecordedIds, PLATFORM_AUTH_KEY);
109
111
  const missing = (applied, ref) => applied && heldRefs !== undefined && !heldRefs.has(ref) ? ref : null;
110
112
  const missingKeys = {
111
113
  files: missing(filesApplied, ORG_PLATFORM_KEY_REFS.files),
@@ -133,7 +135,8 @@ edgePortPresent = false) {
133
135
  ...(platformRecordedIds[ACCESS_PROVIDER_RECORD_KEY]
134
136
  ? { accessProviderId: platformRecordedIds[ACCESS_PROVIDER_RECORD_KEY] }
135
137
  : {}),
136
- authReady: platform.auth.enabled && coolifyAppApplied(platformRecordedIds, PLATFORM_AUTH_KEY),
138
+ authReady,
139
+ authIssuer: authReady ? `https://${platform.auth.hostname}` : null,
137
140
  jobsReady: jobsApplied && jobsUrl !== undefined && missingKeys.jobs === null,
138
141
  ...(jobsApplied && jobsUrl === undefined ? { jobsUrlMissing: true } : {}),
139
142
  edgeReady: edgeApplied && edgePortPresent,
@@ -90,7 +90,7 @@ export interface PlatformAuthDeps {
90
90
  tunnels: PlatformTunnelsFactory;
91
91
  cloudflare: Pick<CloudflarePort, 'findZoneByApex'>;
92
92
  /** Must target the platform's own new instance (its issuer), or `zitadelForInstance` must be set. */
93
- zitadel: Pick<ZitadelPort, 'findOrCreateOrg' | 'mintAdminMachineUser' | 'mintLoginClientMachineUser' | 'listLoginClientPats' | 'inviteOrgOwner'> & PlatformZitadelSmtp;
93
+ zitadel: Pick<ZitadelPort, 'findOrCreateOrg' | 'mintAdminMachineUser' | 'mintLoginClientMachineUser' | 'listLoginClientPats' | 'reserveUserName' | 'closeSelfRegistration' | 'inviteOrgOwner'> & PlatformZitadelSmtp;
94
94
  /**
95
95
  * Builds the ZITADEL port for the new instance from its issuer URL
96
96
  * (`https://<auth.hostname>`). When set it is used instead of
@@ -9,7 +9,7 @@ import { ACCESS_POLICY_NAME, getDatabaseStarted, isNotFoundError, isRunningOrSta
9
9
  import { ACCESS_PROVIDER_RECORD_KEY, GATE_CLAIM_NAME } from './gates.js';
10
10
  import { healthCheckSettings } from './health-check-settings.js';
11
11
  import { coolifyDomains, coolifyTunnelDomains, filesShareHostname } from './hostnames.js';
12
- import { AUTH_ZITADEL_RUN_OPTIONS, JOBS_DEPLOY_PENDING_SCOPE_KEY, JOBS_INTERNAL_URL_SCOPE_KEY, JOBS_SCHEDULER_KEY, PLATFORM_ACCESS_PROVIDER_APPLIED_AUTH_DOMAIN_SCOPE_KEY, PLATFORM_ACCESS_PROVIDER_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_ACCESS_PROVIDER_UNDELIVERED_SCOPE_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_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_APPLIED_OPERATOR_SCOPE_KEY, PLATFORM_ARCHON_APPLIED_ROLLING_UPDATE_SCOPE_KEY, PLATFORM_ARCHON_CONNECTOR_APP_SCOPE_KEY, PLATFORM_ARCHON_COOLIFY_READ_TOKEN_REF, PLATFORM_ARCHON_DEPLOYMENT_SCOPE_KEY, PLATFORM_ARCHON_ENVS_DEPLOYED_SCOPE_KEY, PLATFORM_ARCHON_GATE_CLOSED_SCOPE_KEY, PLATFORM_ARCHON_ID_TOKEN_USERINFO_APPLIED_SCOPE_KEY, PLATFORM_ARCHON_INTEGRATIONS_KEY_SYNCED_SCOPE_KEY, PLATFORM_ARCHON_KEY, PLATFORM_ARCHON_MASTER_CLOUDFLARE_TOKEN_REF, PLATFORM_ARCHON_MASTER_COOLIFY_TOKEN_REF, PLATFORM_ARCHON_MASTER_COOLIFY_URL_REF, PLATFORM_ARCHON_UPTIME_TOKEN_SYNCED_SCOPE_KEY, PLATFORM_ARCHON_ZITADEL_APP_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_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_EDGE_APPLIED_GIT_REF_SCOPE_KEY, PLATFORM_EDGE_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_EDGE_CLOUDFLARE_TOKEN_REF, PLATFORM_EDGE_INTERNAL_URL_SCOPE_KEY, PLATFORM_EDGE_KEY, PLATFORM_EDGE_KEY_REF, PLATFORM_EDGE_PLATFORM_KEY_SET_SCOPE_KEY, PLATFORM_EDGE_PLATFORM_KEY_UNDELIVERED_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_KEY, PLATFORM_FILES_MANAGEMENT_KEY_SCOPE_KEY, PLATFORM_FILES_MANAGEMENT_UNDELIVERED_SCOPE_KEY, PLATFORM_FILES_ZITADEL_PROJECT_SCOPE_KEY, PLATFORM_HC_API_KEY_REF, PLATFORM_HC_APPLIED_GIT_REF_SCOPE_KEY, PLATFORM_HC_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_HC_KEY, PLATFORM_HEARTBEATS_APPLIED_SCOPE_KEY, PLATFORM_INTEGRATIONS_APPLIED_GITREF_SCOPE_KEY, PLATFORM_INTEGRATIONS_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_INTEGRATIONS_KEY, PLATFORM_INTEGRATIONS_KEY_ID_SCOPE_KEY, PLATFORM_INTEGRATIONS_KEY_ROTATION_PREVIOUS_SCOPE_KEY, PLATFORM_INTEGRATIONS_KEY_ROTATION_REQUESTED_SCOPE_KEY, PLATFORM_INTEGRATIONS_KEY_ROTATION_UNDELIVERED_SCOPE_KEY, PLATFORM_INTEGRATIONS_KEY_SET_SCOPE_KEY, PLATFORM_INTEGRATIONS_KEY_UNDELIVERED_SCOPE_KEY, PLATFORM_INTEGRATIONS_RUNNER_KEYRING_DEPLOYED_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, PLATFORM_SFU_APPLIED_SCOPE_KEY, PLATFORM_UPTIME_ACCESS_CLIENT_ID_REF, PLATFORM_UPTIME_ACCESS_CLIENT_SECRET_REF, PLATFORM_UPTIME_ACCESS_TOKEN_ID_SCOPE_KEY, PLATFORM_UPTIME_ACCESS_TOKEN_NAME, PLATFORM_UPTIME_ACCESS_UNDELIVERED_SCOPE_KEY, PLATFORM_UPTIME_HEARTBEAT_CHECK_NAME, PLATFORM_UPTIME_KEY, PLATFORM_UPTIME_REMOVED_SCOPE_KEY, PLATFORM_UPTIME_SCRIPT_NAME, PLATFORM_UPTIME_TOKEN_ID_SCOPE_KEY, PLATFORM_UPTIME_TOKEN_REF, archonBootstrapOperator, desiredHeartbeatCheckNames, heartbeatCheckUrlRef, parseBrokerDeployRecord, parseLoginClientRecord, platformBackupHeartbeatCheckName, platformMailMxHost, uptimeAccessTokenConflict, uptimeRecorded, uptimeVarsDigest, } from './platform-diff.js';
12
+ import { AUTH_ZITADEL_RUN_OPTIONS, JOBS_DEPLOY_PENDING_SCOPE_KEY, JOBS_INTERNAL_URL_SCOPE_KEY, JOBS_SCHEDULER_KEY, PLATFORM_ACCESS_PROVIDER_APPLIED_AUTH_DOMAIN_SCOPE_KEY, PLATFORM_ACCESS_PROVIDER_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_ACCESS_PROVIDER_UNDELIVERED_SCOPE_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_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_APPLIED_OPERATOR_SCOPE_KEY, PLATFORM_ARCHON_APPLIED_ROLLING_UPDATE_SCOPE_KEY, PLATFORM_ARCHON_CONNECTOR_APP_SCOPE_KEY, PLATFORM_ARCHON_COOLIFY_READ_TOKEN_REF, PLATFORM_ARCHON_DEPLOYMENT_SCOPE_KEY, PLATFORM_ARCHON_ENVS_DEPLOYED_SCOPE_KEY, PLATFORM_ARCHON_GATE_CLOSED_SCOPE_KEY, PLATFORM_ARCHON_ID_TOKEN_USERINFO_APPLIED_SCOPE_KEY, PLATFORM_ARCHON_INTEGRATIONS_KEY_SYNCED_SCOPE_KEY, PLATFORM_ARCHON_KEY, PLATFORM_ARCHON_MASTER_CLOUDFLARE_TOKEN_REF, PLATFORM_ARCHON_MASTER_COOLIFY_TOKEN_REF, PLATFORM_ARCHON_MASTER_COOLIFY_URL_REF, PLATFORM_ARCHON_UPTIME_TOKEN_SYNCED_SCOPE_KEY, PLATFORM_ARCHON_ZITADEL_APP_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_KEY, PLATFORM_AUTH_LOGIN_CLIENT_UNDELIVERED_SCOPE_KEY, PLATFORM_AUTH_LOGIN_CLIENT_USER_SCOPE_KEY, PLATFORM_AUTH_OWNER_INVITE_SCOPE_KEY, PLATFORM_AUTH_REGISTRATION_CLOSED_SCOPE_KEY, PLATFORM_AUTH_RESERVED_ADMIN_USERNAME_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_EDGE_APPLIED_GIT_REF_SCOPE_KEY, PLATFORM_EDGE_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_EDGE_CLOUDFLARE_TOKEN_REF, PLATFORM_EDGE_INTERNAL_URL_SCOPE_KEY, PLATFORM_EDGE_KEY, PLATFORM_EDGE_KEY_REF, PLATFORM_EDGE_PLATFORM_KEY_SET_SCOPE_KEY, PLATFORM_EDGE_PLATFORM_KEY_UNDELIVERED_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_KEY, PLATFORM_FILES_MANAGEMENT_KEY_SCOPE_KEY, PLATFORM_FILES_MANAGEMENT_UNDELIVERED_SCOPE_KEY, PLATFORM_FILES_ZITADEL_PROJECT_SCOPE_KEY, PLATFORM_HC_API_KEY_REF, PLATFORM_HC_APPLIED_GIT_REF_SCOPE_KEY, PLATFORM_HC_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_HC_KEY, PLATFORM_HEARTBEATS_APPLIED_SCOPE_KEY, PLATFORM_INTEGRATIONS_APPLIED_GITREF_SCOPE_KEY, PLATFORM_INTEGRATIONS_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_INTEGRATIONS_KEY, PLATFORM_INTEGRATIONS_KEY_ID_SCOPE_KEY, PLATFORM_INTEGRATIONS_KEY_ROTATION_PREVIOUS_SCOPE_KEY, PLATFORM_INTEGRATIONS_KEY_ROTATION_REQUESTED_SCOPE_KEY, PLATFORM_INTEGRATIONS_KEY_ROTATION_UNDELIVERED_SCOPE_KEY, PLATFORM_INTEGRATIONS_KEY_SET_SCOPE_KEY, PLATFORM_INTEGRATIONS_KEY_UNDELIVERED_SCOPE_KEY, PLATFORM_INTEGRATIONS_RUNNER_KEYRING_DEPLOYED_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, PLATFORM_SFU_APPLIED_SCOPE_KEY, PLATFORM_UPTIME_ACCESS_CLIENT_ID_REF, PLATFORM_UPTIME_ACCESS_CLIENT_SECRET_REF, PLATFORM_UPTIME_ACCESS_TOKEN_ID_SCOPE_KEY, PLATFORM_UPTIME_ACCESS_TOKEN_NAME, PLATFORM_UPTIME_ACCESS_UNDELIVERED_SCOPE_KEY, PLATFORM_UPTIME_HEARTBEAT_CHECK_NAME, PLATFORM_UPTIME_KEY, PLATFORM_UPTIME_REMOVED_SCOPE_KEY, PLATFORM_UPTIME_SCRIPT_NAME, PLATFORM_UPTIME_TOKEN_ID_SCOPE_KEY, PLATFORM_UPTIME_TOKEN_REF, archonBootstrapOperator, desiredHeartbeatCheckNames, heartbeatCheckUrlRef, parseBrokerDeployRecord, parseLoginClientRecord, platformBackupHeartbeatCheckName, platformMailMxHost, uptimeAccessTokenConflict, uptimeRecorded, uptimeVarsDigest, } from './platform-diff.js';
13
13
  import { applyCallsSfuPlatformStep, callsSfuAppliedFingerprint, } from './platform-sfu.js';
14
14
  import { heldUptimeValues, provisionPlatformUptime, } from './uptime-worker.js';
15
15
  import { CreateInstanceError, createZitadelInstance, generateSystemApiKeypair, instanceWithoutBearerMessage, systemApiUsersEnv, } from './zitadel-system-api.js';
@@ -500,10 +500,13 @@ export class FoundryPlatformStepExecutor {
500
500
  if (platform.files.enabled && this.files && recordedIds[`coolify:app:${PLATFORM_FILES_KEY}`]) {
501
501
  // A Coolify read error must not fail the whole plan: treat it as not drifted.
502
502
  try {
503
- if (await filesRunnerEnvDrifted(this.files.coolify, recordedIds, {
503
+ const envDrifted = await filesRunnerEnvDrifted(this.files.coolify, recordedIds, {
504
504
  publicHosts: this.files.publicHosts,
505
505
  shareDomains: filesShareDomains(await this.declaredOrgStore.list()),
506
- }))
506
+ });
507
+ const bootstrapOperatorDrifted = !envDrifted &&
508
+ (await filesBootstrapOperatorDrifted(this.files.coolify, recordedIds[`coolify:app:${PLATFORM_FILES_KEY}`], recordedIds));
509
+ if (envDrifted || bootstrapOperatorDrifted)
507
510
  result.files = true;
508
511
  }
509
512
  catch {
@@ -903,6 +906,8 @@ const PLATFORM_AUTH_TUNNEL_SCOPE_KEY = 'cloudflare_platform_auth_tunnel';
903
906
  /** Recorded once the ZITADEL app has been deployed, so ZITADEL may have written to its database. */
904
907
  const PLATFORM_AUTH_ZITADEL_DEPLOYED_SCOPE_KEY = 'zitadel_platform_auth_deployed';
905
908
  const PLATFORM_AUTH_ADMIN_USER_NAME = 'foundry-admin';
909
+ /** #394: reserved so nobody signs up as the name Stalwart maps onto its Admin account. */
910
+ const PLATFORM_AUTH_RESERVED_USERNAME = 'admin';
906
911
  /** #216 (building on #209/#215): the narrower `IAM_LOGIN_CLIENT` machine user for the login app and the broker. */
907
912
  const PLATFORM_AUTH_LOGIN_CLIENT_USER_NAME = 'foundry-login-client';
908
913
  const PLATFORM_AUTH_ORG_NAME = 'foundry';
@@ -1125,7 +1130,7 @@ async function liveLoginClientTokenHash(coolify, appId) {
1125
1130
  * `platform.auth.hostname`, then (only the first time) foundry's own admin
1126
1131
  * machine user, handed to {@link PlatformAuthDeps.deliverSecret}. Env values
1127
1132
  * and instance settings are `wtfalch/auth`'s own `docker-compose.yml`
1128
- * (checked 2026-09-29), with `ZITADEL_DOMAIN`/`wtfalch.dev` replaced by this
1133
+ * (checked 2026-09-29), with `ZITADEL_DOMAIN`/`companyincluded.com` replaced by this
1129
1134
  * platform's own `apex`/`auth.hostname` wherever the compose named one --
1130
1135
  * every other setting it leaves open (login v2 required, external port 443,
1131
1136
  * TLS terminated at Cloudflare, `PASSWORDCHANGEREQUIRED` false) is kept
@@ -1815,6 +1820,28 @@ async function provisionPlatformAuth(context, deps, owners = []) {
1815
1820
  }
1816
1821
  }
1817
1822
  }
1823
+ // ---- reserved username and closed registration, once each (#394) ----------
1824
+ // Outside the blocks above so a platform applied before this existed gets
1825
+ // it on its next apply; `planAuth` plans the step until both keys exist.
1826
+ // Last, so a failure here holds up neither the SMTP provider nor the invite.
1827
+ const policyRecorded = context.recordedIds();
1828
+ if (!policyRecorded[PLATFORM_AUTH_RESERVED_ADMIN_USERNAME_SCOPE_KEY] ||
1829
+ !policyRecorded[PLATFORM_AUTH_REGISTRATION_CLOSED_SCOPE_KEY]) {
1830
+ const policyZitadel = deps.zitadelForInstance?.(`https://${hostname}`) ?? deps.zitadel;
1831
+ let policyOrgId = policyRecorded[PLATFORM_ZITADEL_ORG_SCOPE_KEY];
1832
+ if (!policyOrgId) {
1833
+ policyOrgId = (await policyZitadel.findOrCreateOrg(PLATFORM_AUTH_ORG_NAME)).org.id;
1834
+ await record(PLATFORM_ZITADEL_ORG_SCOPE_KEY, policyOrgId);
1835
+ }
1836
+ if (!policyRecorded[PLATFORM_AUTH_RESERVED_ADMIN_USERNAME_SCOPE_KEY]) {
1837
+ const { userId } = await policyZitadel.reserveUserName(policyOrgId, PLATFORM_AUTH_RESERVED_USERNAME);
1838
+ await record(PLATFORM_AUTH_RESERVED_ADMIN_USERNAME_SCOPE_KEY, userId);
1839
+ }
1840
+ if (!policyRecorded[PLATFORM_AUTH_REGISTRATION_CLOSED_SCOPE_KEY]) {
1841
+ await policyZitadel.closeSelfRegistration(policyOrgId);
1842
+ await record(PLATFORM_AUTH_REGISTRATION_CLOSED_SCOPE_KEY, 'closed');
1843
+ }
1844
+ }
1818
1845
  return { resourceId: zitadelAppId };
1819
1846
  }
1820
1847
  /** Lettermint's SMTP relay, as ZITADEL's SMTP provider wants it: `host:port`, STARTTLS. */
@@ -2034,7 +2061,7 @@ const PLATFORM_MAIL_ZITADEL_PROJECT_NAME = 'mail';
2034
2061
  *
2035
2062
  * **Why not `@wtfalch/email/postmaster`'s `mailObjects`/`applyObjects`**, the
2036
2063
  * instance-bootstrap helper `wtfalch-manage` already uses for the hand-built
2037
- * `mail.wtfalch.dev`: that plan also wires an `AcmeProvider` and `DnsServer`
2064
+ * `mail.companyincluded.com`: that plan also wires an `AcmeProvider` and `DnsServer`
2038
2065
  * for Stalwart's own DNS-01 ACME challenge, and an `MtaRoute` that relays
2039
2066
  * every outbound message through the earlier mail provider -- the estate moved transactional
2040
2067
  * mail off it onto Lettermint before this issue (`@wtfalch/mailer` ADR
@@ -2056,7 +2083,7 @@ const PLATFORM_MAIL_ZITADEL_PROJECT_NAME = 'mail';
2056
2083
  * `StepExecutionContext` to carry the platform, which `plan-apply.ts`'s
2057
2084
  * own doc comment already scoped out of #167 as more than a small change
2058
2085
  * -- a follow-up issue, not this one. (#205 removed the org step
2059
- * `coolify:mail`'s own fallback to the hand-built `wtfalch.dev`
2086
+ * `coolify:mail`'s own fallback to the hand-built mail
2060
2087
  * instance -- `diff.ts`'s `planMail` already blocks the step until the
2061
2088
  * platform's own mail is applied, so the fallback only ever served a
2062
2089
  * plan recorded before that, and now fails loudly instead.)
@@ -2809,7 +2836,7 @@ async function switchOnArchonIdTokenUserinfo(context, deps) {
2809
2836
  * - No API updates an app's Access policy, so a changed include list closes
2810
2837
  * the tunnel hostname, deletes the Access app and builds it again.
2811
2838
  * - #197: a `cloudflared` connector runs for this tunnel, as in `provisionPlatformAuth`.
2812
- * - Retiring `operator.wtfalch.dev` stays a human step.
2839
+ * - Retiring the hand-deployed `operator` app stays a human step.
2813
2840
  * - #475: deploys this app with a stable `custom_network_aliases` instead of a
2814
2841
  * fixed `custom_internal_name`, so Coolify runs its rolling update (start
2815
2842
  * the new container, wait for health, then stop the old one) instead of
@@ -3096,10 +3123,23 @@ async function provisionPlatformArchon(context, deps) {
3096
3123
  if (ownerInviteUserId && ownerInviteUserId !== 'none') {
3097
3124
  envs.push({ key: 'AUTHZ_BOOTSTRAP_OPERATOR', value: ownerInviteUserId });
3098
3125
  }
3126
+ // #501: a FILES_URL or FILES_AUDIENCE already on the app is kept (the #265
3127
+ // rule above), and hashed as the live value so it never reads as a changed
3128
+ // env.
3129
+ const keepLiveFilesEnv = new Set();
3130
+ for (const key of ['FILES_URL', 'FILES_AUDIENCE']) {
3131
+ const live = await currentEnvValue(deps.coolify, appId, key);
3132
+ if (live) {
3133
+ keepLiveFilesEnv.add(key);
3134
+ archonEnvHashOverrides.set(key, live);
3135
+ }
3136
+ }
3099
3137
  // A value with a line break (the PEM management keys) must be flagged:
3100
3138
  // unflagged, Coolify writes it into `.env` unquoted and the container never
3101
3139
  // starts.
3102
- envs.push(...inputs.envs.map((e) => (e.value.includes('\n') ? { ...e, multiline: true } : e)));
3140
+ envs.push(...inputs.envs
3141
+ .filter((e) => !keepLiveFilesEnv.has(e.key))
3142
+ .map((e) => (e.value.includes('\n') ? { ...e, multiline: true } : e)));
3103
3143
  // #436: /backups needs a Coolify token scoped to read only, never the
3104
3144
  // root token `putArchonMasterKeys` above already held. Nothing mints that
3105
3145
  // token over Coolify's API (this ref's own doc comment), so until a human
@@ -3196,7 +3236,7 @@ async function provisionPlatformArchon(context, deps) {
3196
3236
  clientIds: [clientId],
3197
3237
  origins: [`https://${hostname}`],
3198
3238
  // #211: `wtfalch/auth`'s broker (`broker/src/apps.ts`'s `parseAppFields`)
3199
- // falls back to `wtfalch <noreply@wtfalch.dev>` for an entry with no
3239
+ // falls back to its own default sender for an entry with no
3200
3240
  // `from`, and (`wtfalch/auth#179`) an entry with no `from` is dropped
3201
3241
  // entirely -- either way, unset here means Archon's mail comes from
3202
3242
  // the wrong platform or not at all.
@@ -3900,6 +3940,23 @@ async function archonBootstrapOperatorDrifted(coolify, appId, recordedIds) {
3900
3940
  return false;
3901
3941
  }
3902
3942
  }
3943
+ /**
3944
+ * #501: {@link archonBootstrapOperatorDrifted} for the files runner. The owner
3945
+ * invite can record after the files step's app id does, so an owner invited
3946
+ * later still gets `AUTHZ_BOOTSTRAP_OPERATOR`. A failed read is not drift.
3947
+ */
3948
+ async function filesBootstrapOperatorDrifted(coolify, appId, recordedIds) {
3949
+ const invitedUserId = recordedIds[PLATFORM_AUTH_OWNER_INVITE_SCOPE_KEY];
3950
+ if (!appId || !invitedUserId || invitedUserId === 'none')
3951
+ return false;
3952
+ try {
3953
+ const live = await currentEnvValue(coolify, appId, 'AUTHZ_BOOTSTRAP_OPERATOR');
3954
+ return live !== invitedUserId;
3955
+ }
3956
+ catch {
3957
+ return false;
3958
+ }
3959
+ }
3903
3960
  /**
3904
3961
  * #480: whether the live app disagrees with the patch
3905
3962
  * `provisionPlatformArchon` sends on every run -- `is_consistent_container_name_enabled`
@@ -4021,15 +4078,10 @@ async function collectArchonServiceInputs(platform, deps, recorded) {
4021
4078
  for (const service of services) {
4022
4079
  if (service === 'files') {
4023
4080
  envs.push(
4024
- // #436 round 2: not `PLATFORM_FILES_INTERNAL_URL_SCOPE_KEY`'s
4025
- // internal `http://<name>:3000` -- Archon's signed path needs an
4026
- // exact HTTPS origin (`filesOrigin()`/`filesBaseUrl()`,
4027
- // `wtfalch/archon`'s `src/lib/authz/files-management.ts` and
4028
- // `src/lib/files-admin/client.ts`), and that origin is also the
4029
- // signing audience, so it must equal Files' own `APP_URL` exactly
4030
- // (this file, the files runner env above: `https://${platform.
4031
- // files.hostname}`).
4032
- { key: 'FILES_URL', value: `https://${platform.files.hostname}` },
4081
+ // #501: Files is private and has no DNS, so FILES_URL is the recorded
4082
+ // internal address; FILES_AUDIENCE is the exact HTTPS origin the
4083
+ // signature is made for, equal to Files' own `APP_URL`.
4084
+ { key: 'FILES_URL', value: recorded[PLATFORM_FILES_INTERNAL_URL_SCOPE_KEY] }, { key: 'FILES_AUDIENCE', value: `https://${platform.files.hostname}` },
4033
4085
  // #436: Company Included's own Files organisation, provisioned by
4034
4086
  // the ordinary per-org `planFilesOrganisation` step under its own
4035
4087
  // org id -- `executor.ts`'s `filesOrganisation` calls
@@ -5357,10 +5409,10 @@ const PLATFORM_FILES_RUNNER_SCOPE_KEY = 'coolify_platform_files_runner_app';
5357
5409
  * and the optional `KEYS_AUDIT_TOKEN`), `R2_BUCKET`/`R2_ENDPOINT` from
5358
5410
  * {@link PlatformFilesDeps.r2} (#380, also required), the optional
5359
5411
  * `FILES_CLAMD_HOST`/`FILES_CLAMD_PORT`, `FILES_PUBLIC_HOSTS` and
5360
- * `JOBS_SIGNING_SECRETS` the host may supply (#380), and the three
5361
- * {@link PLATFORM_FILES_RUNNER_DEFAULTS} (#380, written only when absent).
5362
- * `AUTHZ_BOOTSTRAP_OPERATOR` and the rest of files' `.env.example` are
5363
- * still set by a person on the runner.
5412
+ * `JOBS_SIGNING_SECRETS` the host may supply (#380), the three
5413
+ * {@link PLATFORM_FILES_RUNNER_DEFAULTS} (#380, written only when absent),
5414
+ * and (#501) `AUTHZ_BOOTSTRAP_OPERATOR`, the same person `platform:archon`
5415
+ * names. The rest of files' `.env.example` is still set by a person.
5364
5416
  * - The files project lives in the platform's `foundry` ZITADEL org. An
5365
5417
  * org's Agora requests it as an audience scope from its own ZITADEL org;
5366
5418
  * whether that needs a project grant per org is not decided here.
@@ -5539,6 +5591,11 @@ shareDomains) {
5539
5591
  key: 'JOBS_SIGNING_SECRETS',
5540
5592
  value: deps.jobsSigningSecrets,
5541
5593
  });
5594
+ // #501: the same person platform:archon names; Files has no sign-in page.
5595
+ const ownerInviteUserId = recorded[PLATFORM_AUTH_OWNER_INVITE_SCOPE_KEY];
5596
+ if (ownerInviteUserId && ownerInviteUserId !== 'none') {
5597
+ runnerOptionalEnvs.push({ key: 'AUTHZ_BOOTSTRAP_OPERATOR', value: ownerInviteUserId });
5598
+ }
5542
5599
  const runnerEnvs = [
5543
5600
  { key: 'DATABASE_URL', value: runtimeUrl },
5544
5601
  // The management verifier's audience, which must be an HTTPS origin. The
@@ -1,7 +1,7 @@
1
1
  import type { CloudflareClient } from '@wtfalch/providers/cloudflare';
2
2
  import type { CoolifyClient } from '@wtfalch/providers/coolify';
3
3
  import { type ZitadelClient } from '@wtfalch/providers/zitadel';
4
- import type { OrgId } from '../org.js';
4
+ import { type OrgId } from '../org.js';
5
5
  import type { DeclaredOrgStore } from '../store.js';
6
6
  import type { AiReader, CloudflareReader, CmsReader, CoolifyReader, LiveResource, ZitadelReader } from './live.js';
7
7
  /** The `CoolifyClient` methods the readers call, so a host's own implementation needs no cast. */
@@ -68,6 +68,13 @@ export type ScopeResolver = (orgId: OrgId) => Promise<string | null>;
68
68
  * which `computePlanDetail` (`diff.ts`) already reads as "no live resource".
69
69
  */
70
70
  export declare function storeScopeResolver(store: DeclaredOrgStore, serviceKey: string): ScopeResolver;
71
+ /**
72
+ * #517: resolves the login host an org's sign-in is recorded on
73
+ * (`zitadel:login-host:<orgId>`, written by `zitadelLoginHost`), for
74
+ * {@link ProvidersZitadelReader}. The key holds the org id, so it cannot be a
75
+ * fixed {@link storeScopeResolver} `serviceKey`.
76
+ */
77
+ export declare function storeLoginHostResolver(store: DeclaredOrgStore): ScopeResolver;
71
78
  /**
72
79
  * Resolves an estate {@link OrgId} to the provider ids this org's applied
73
80
  * state has recorded for one reader's `system`, inverted so a reader can
@@ -131,12 +138,18 @@ export declare class ProvidersCloudflareReader implements CloudflareReader {
131
138
  * resource per project and per project grant in a ZITADEL org (v1 scope —
132
139
  * see that method's own doc comment), plus (#510) a synthesized `issuer`
133
140
  * resource once the org's own ZITADEL org is confirmed live.
141
+ *
142
+ * `resolveLoginHost` (#517) is optional; without it the issuer carries no
143
+ * host. A recorded host under `auth.` is the org's own domain, so the issuer
144
+ * then also says `usesDomain: true`; any other host (tier 1, shared) says
145
+ * nothing about `usesDomain`, which `planIssuer` reads as no move.
134
146
  */
135
147
  export declare class ProvidersZitadelReader implements ZitadelReader {
136
148
  private readonly client;
137
149
  private readonly resolveScope;
138
150
  private readonly resolveRecordedIds;
139
- constructor(client: ZitadelClient, resolveScope: ScopeResolver, resolveRecordedIds: RecordedIds);
151
+ private readonly resolveLoginHost;
152
+ constructor(client: ZitadelClient, resolveScope: ScopeResolver, resolveRecordedIds: RecordedIds, resolveLoginHost?: ScopeResolver);
140
153
  listResources(orgId: OrgId): Promise<LiveResource[]>;
141
154
  /**
142
155
  * #510: `client.listResources` reports only what is *inside* a ZITADEL org
@@ -1,5 +1,6 @@
1
1
  import { ZitadelError } from '@wtfalch/providers/zitadel';
2
- import { BOULE_MANAGED_ENV_KEYS } from './boule.js';
2
+ import { APP_ENTRY_KEY } from '../org.js';
3
+ import { BOULE_MAIL_TOKEN_ENV, BOULE_MANAGED_ENV_KEYS } from './boule.js';
3
4
  /**
4
5
  * A {@link ScopeResolver} backed by `AppliedState.serviceIds` (`../state.ts`)
5
6
  * — the one place foundry records a provider's own id for an org today,
@@ -30,6 +31,18 @@ export function storeScopeResolver(store, serviceKey) {
30
31
  return applied?.serviceIds[serviceKey] || null;
31
32
  };
32
33
  }
34
+ /**
35
+ * #517: resolves the login host an org's sign-in is recorded on
36
+ * (`zitadel:login-host:<orgId>`, written by `zitadelLoginHost`), for
37
+ * {@link ProvidersZitadelReader}. The key holds the org id, so it cannot be a
38
+ * fixed {@link storeScopeResolver} `serviceKey`.
39
+ */
40
+ export function storeLoginHostResolver(store) {
41
+ return async (orgId) => {
42
+ const applied = await store.getAppliedState(orgId);
43
+ return applied?.serviceIds[`zitadel:login-host:${orgId}`] || null;
44
+ };
45
+ }
33
46
  export function storeRecordedIds(store, system) {
34
47
  return async (orgId) => {
35
48
  const applied = await store.getAppliedState(orgId);
@@ -247,7 +260,7 @@ async function withAgoraEnvConfig(client, resource, recorded) {
247
260
  * `executor.ts` would write. Gated and failing exactly like
248
261
  * {@link withAgoraEnvConfig}.
249
262
  */
250
- async function withBouleEnvConfig(client, resource, recorded) {
263
+ async function withBouleEnvConfig(client, resource, recorded, liveKeys) {
251
264
  if (resource.kind !== 'application' || !recorded[resource.key]?.startsWith('boule:')) {
252
265
  return resource;
253
266
  }
@@ -257,7 +270,20 @@ async function withBouleEnvConfig(client, resource, recorded) {
257
270
  if (!e.is_preview && managed.includes(e.key))
258
271
  env[e.key] = e.value;
259
272
  }
260
- return { ...resource, config: { ...resource.config, env } };
273
+ // The hosted app's Lettermint token, as `config.appMailToken`: the one
274
+ // Boule must hold too. In memory only, compared by `planBoule` and never
275
+ // written to a step, description or log.
276
+ const orgId = recorded[resource.key]?.slice('boule:'.length);
277
+ const appUuid = Object.entries(recorded).find(([, value]) => value === `app:${orgId}:${APP_ENTRY_KEY}`)?.[0];
278
+ // A recorded app that is not in the live listing is gone: no app token, and
279
+ // no read of its envs. A listed app whose read fails fails like any read.
280
+ const appToken = appUuid && liveKeys.has(appUuid)
281
+ ? (await client.listEnvs(appUuid)).find((e) => !e.is_preview && e.key === BOULE_MAIL_TOKEN_ENV)?.value
282
+ : undefined;
283
+ return {
284
+ ...resource,
285
+ config: { ...resource.config, env, ...(appToken ? { appMailToken: appToken } : {}) },
286
+ };
261
287
  }
262
288
  /** `@wtfalch/providers`' own `LiveResource` (`resource.ts`) has no `system` field — foundry's adds it at this boundary. */
263
289
  function toFoundryResources(system, resources) {
@@ -290,7 +316,8 @@ export class ProvidersCoolifyReader {
290
316
  const withEnvNames = await Promise.all(withHealthCheck.map((r) => withEnvNamesConfig(this.client, r, recorded)));
291
317
  const withDbUrl = await Promise.all(withEnvNames.map((r) => withDatabaseUrlConfig(this.client, r, recorded)));
292
318
  const withAgoraConfig = await Promise.all(withDbUrl.map((r) => withAgoraEnvConfig(this.client, r, recorded)));
293
- const withBouleConfig = await Promise.all(withAgoraConfig.map((r) => withBouleEnvConfig(this.client, r, recorded)));
319
+ const liveKeys = new Set(withAgoraConfig.map((r) => r.key));
320
+ const withBouleConfig = await Promise.all(withAgoraConfig.map((r) => withBouleEnvConfig(this.client, r, recorded, liveKeys)));
294
321
  return applyRecordedIds(withBouleConfig, recorded);
295
322
  }
296
323
  /** The project's resources as Coolify reports them, unmapped: what adopt checks against a declaration. */
@@ -350,15 +377,22 @@ export class ProvidersCloudflareReader {
350
377
  * resource per project and per project grant in a ZITADEL org (v1 scope —
351
378
  * see that method's own doc comment), plus (#510) a synthesized `issuer`
352
379
  * resource once the org's own ZITADEL org is confirmed live.
380
+ *
381
+ * `resolveLoginHost` (#517) is optional; without it the issuer carries no
382
+ * host. A recorded host under `auth.` is the org's own domain, so the issuer
383
+ * then also says `usesDomain: true`; any other host (tier 1, shared) says
384
+ * nothing about `usesDomain`, which `planIssuer` reads as no move.
353
385
  */
354
386
  export class ProvidersZitadelReader {
355
387
  client;
356
388
  resolveScope;
357
389
  resolveRecordedIds;
358
- constructor(client, resolveScope, resolveRecordedIds) {
390
+ resolveLoginHost;
391
+ constructor(client, resolveScope, resolveRecordedIds, resolveLoginHost = async () => null) {
359
392
  this.client = client;
360
393
  this.resolveScope = resolveScope;
361
394
  this.resolveRecordedIds = resolveRecordedIds;
395
+ this.resolveLoginHost = resolveLoginHost;
362
396
  }
363
397
  async listResources(orgId) {
364
398
  const zitadelOrgId = await this.resolveScope(orgId);
@@ -402,7 +436,11 @@ export class ProvidersZitadelReader {
402
436
  return null;
403
437
  throw err;
404
438
  }
405
- return { system: 'zitadel', kind: 'issuer', key: orgId, config: {} };
439
+ const host = await this.resolveLoginHost(orgId);
440
+ if (!host)
441
+ return { system: 'zitadel', kind: 'issuer', key: orgId, config: {} };
442
+ const config = host.startsWith('auth.') ? { usesDomain: true, host } : { host };
443
+ return { system: 'zitadel', kind: 'issuer', key: orgId, config };
406
444
  }
407
445
  }
408
446
  /**
@@ -15,6 +15,7 @@ export function stepSystemOf(action) {
15
15
  case 'chat':
16
16
  case 'calls':
17
17
  case 'calls-service':
18
+ case 'calls-chat':
18
19
  return 'matrix';
19
20
  case 'database':
20
21
  case 'adopt-database':