@wtfalch/foundry 0.29.0 → 0.31.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.
@@ -2,11 +2,11 @@ import { randomBytes } from 'node:crypto';
2
2
  import { URL } from 'node:url';
3
3
  import { resolveCallsSfuUrls } from '../calls-sfu.js';
4
4
  import { APP_ENTRY_KEY, appEntry, filesCollectionEnvName, ownerMailboxRecorded, ownerMailboxes, sharedSecretRef, } from '../org.js';
5
- import { AGORA_ACTIVATED_ENV_SCOPE_KEY, AGORA_APP_PORTS_EXPOSES, AGORA_CLIENT_ID_SCOPE_KEY, AGORA_DEPLOYED_SCOPE_KEY, APP_CLIENT_ID_SCOPE_KEY, CALLS_INTROSPECTION_SCOPE_KEY, CALLS_INTROSPECTION_UNDELIVERED_SCOPE_KEY, CALLS_SERVICE_DEPLOYED_SCOPE_KEY, CALLS_SERVICE_NAME_SCOPE_KEY, CALLS_SERVICE_PORT, CALLS_SERVICE_TOKEN_ENV_SCOPE_KEY, CHAT_INTROSPECTION_SCOPE_KEY, CHAT_INTROSPECTION_UNDELIVERED_SCOPE_KEY, CHAT_SERVICE_URL_SCOPE_KEY, CMS_CLIENT_ID_SCOPE_KEY, ENTRY_CLIENT_ID_SCOPE_PREFIX, ZITADEL_MESSAGING_PROJECT_SCOPE_KEY, ZITADEL_ORG_SCOPE_KEY, agoraEnvDrift, agoraEnvRevision, agoraHostnameFor, callsIntrospectionAppName, callsServiceHasName, callsServiceInternalName, callsTokenEnvMarker, callsTokenEnvMovedMessage, chatIntrospectionAppName, desiredAgoraEnv, entryClientIdScopeKey, } from './agora.js';
5
+ import { AGORA_ACTIVATED_ENV_SCOPE_KEY, AGORA_APP_PORTS_EXPOSES, AGORA_CLIENT_ID_SCOPE_KEY, AGORA_DEPLOYED_SCOPE_KEY, APP_CLIENT_ID_SCOPE_KEY, CALLS_INTROSPECTION_SCOPE_KEY, CALLS_INTROSPECTION_UNDELIVERED_SCOPE_KEY, CALLS_SERVICE_DEPLOYED_SCOPE_KEY, CALLS_SERVICE_NAME_SCOPE_KEY, CALLS_SERVICE_PORT, CALLS_SERVICE_TOKEN_ENV_SCOPE_KEY, CHAT_CALLS_APPLIED_SCOPE_KEY, CHAT_CALLS_TOKEN_SCOPE_KEY, CHAT_CALLS_TOKEN_UNDELIVERED_SCOPE_KEY, CHAT_INTROSPECTION_SCOPE_KEY, CHAT_INTROSPECTION_UNDELIVERED_SCOPE_KEY, CHAT_SERVICE_URL_SCOPE_KEY, CMS_CLIENT_ID_SCOPE_KEY, ENTRY_CLIENT_ID_SCOPE_PREFIX, ZITADEL_MESSAGING_PROJECT_SCOPE_KEY, ZITADEL_ORG_SCOPE_KEY, agoraEnvDrift, agoraEnvRevision, agoraHostnameFor, callsIntrospectionAppName, callsServiceHasName, callsServiceInternalName, callsTokenEnvMarker, callsTokenEnvMovedMessage, chatCallsApplied, chatHasUpstreams, chatIntrospectionAppName, desiredAgoraEnv, entryClientIdScopeKey, } from './agora.js';
6
6
  import { BOULE_BROKER_ENTRY_KEY, BOULE_CLIENT_ID_SCOPE_KEY, BOULE_DATABASE_SCOPE_KEY, BOULE_DEPLOYED_SCOPE_KEY, bouleEnvDrift, bouleHostnameFor, bouleMailWanted, desiredBouleEnv, wantedBouleBrokerEntry, } from './boule.js';
7
7
  import { brokerEntryAppliedScopeKey, brokerEntryWarningsScopeKey, entryAuthEnv, entryAuthEnvAppliedScopeKey, wantedBrokerEntry, writeBrokerEntry, } from './broker-entries.js';
8
8
  import { MAS_IMAGE, MAS_TAG, SYNAPSE_IMAGE, SYNAPSE_TAG, callsMasConfigYaml, callsServerName, callsSynapseHomeserverYaml, mintCallsMatrixSecrets, parseCallsDbUrl, } from './calls-matrix.js';
9
- import { CHAT_REPO, CHAT_SERVICE_DOCKERFILE, CHAT_SERVICE_PORT, CHAT_SYNAPSE_DOCKERFILE, CHAT_SYNAPSE_PORT, chatInternalNames, chatMasConfigYaml, chatServiceEnvs, chatSynapseHomeserverYaml, chatUpstreams, } from './chat-matrix.js';
9
+ import { CHAT_MAS_PORT, CHAT_REPO, CHAT_SERVICE_DOCKERFILE, CHAT_SERVICE_PORT, CHAT_SYNAPSE_DOCKERFILE, CHAT_SYNAPSE_PORT, assertChatServerName, chatInternalNames, chatMasConfigYaml, chatRekeyedInternalNames, chatServerName, chatServiceEnvs, chatSynapseHomeserverYaml, chatUpstreams, } from './chat-matrix.js';
10
10
  import { ensureCloudflaredConnector } from './cloudflared-connector.js';
11
11
  import { CORE_FILES_COLLECTION_NAME, FILES_CORE_COLLECTION_SCOPE_KEY, ZITADEL_FILES_PROJECT_SCOPE_KEY, } from './core-files.js';
12
12
  import { waitForNewDeployment, } from './deploy-health.js';
@@ -607,6 +607,39 @@ const CHAT_SERVICE_SCOPE_KEY = 'coolify_chat_service';
607
607
  const CHAT_SYNAPSE_DB_SCOPE_KEY = 'coolify_chat_synapse_database';
608
608
  const CHAT_MAS_DB_SCOPE_KEY = 'coolify_chat_mas_database';
609
609
  const CHAT_SERVICE_DB_SCOPE_KEY = 'coolify_chat_service_database';
610
+ /**
611
+ * #463: `coolifyChatRekey`'s own scope ids for the new, domain-named
612
+ * Synapse+MAS pair the re-key provisions beside the original. The chat
613
+ * service and its own database keep their original scope keys above --
614
+ * only the two Matrix apps and their databases are new resources; the
615
+ * service is repointed in place, never re-created.
616
+ */
617
+ const CHAT_REKEY_SYNAPSE_SCOPE_KEY = 'coolify_chat_rekey_synapse';
618
+ const CHAT_REKEY_MAS_SCOPE_KEY = 'coolify_chat_rekey_mas';
619
+ const CHAT_REKEY_SYNAPSE_DB_SCOPE_KEY = 'coolify_chat_rekey_synapse_database';
620
+ const CHAT_REKEY_MAS_DB_SCOPE_KEY = 'coolify_chat_rekey_mas_database';
621
+ /**
622
+ * Set once {@link ChatRekeyPort.copyHomeserver} returns, so a retried or
623
+ * repeated apply never copies the same history twice -- the copy itself is
624
+ * also idempotent by server name (that port's own doc comment), so this is
625
+ * belt and suspenders, not the only guard.
626
+ */
627
+ const CHAT_REKEY_COPIED_SCOPE_KEY = 'coolify_chat_rekey_copied';
628
+ /**
629
+ * A copy of the level-1 MAS app's id, taken the first time
630
+ * `coolifyChatRekey` runs for an org, before anything else happens. The
631
+ * generic `coolify:chat:<orgId>` scope id this step returns as its own
632
+ * `resourceId` is overwritten with the *new* MAS app's id once the re-key
633
+ * finishes, so a second run reading that generic id to decide what to park
634
+ * would stop the live, just-rekeyed MAS instead of the original one --
635
+ * this dedicated copy is what every run parks from, immune to that
636
+ * overwrite.
637
+ */
638
+ const CHAT_REKEY_ORIGINAL_MAS_SCOPE_KEY = 'coolify_chat_rekey_original_mas';
639
+ /** Set once the chat service runs healthy on the new pair; the original is parked only after it. */
640
+ const CHAT_REKEY_REPOINTED_SCOPE_KEY = 'coolify_chat_rekey_repointed';
641
+ /** The copy is bounded; an interrupted one is safe to run again because the port is idempotent. */
642
+ const CHAT_REKEY_COPY_TIMEOUT_MS = 60 * 60 * 1000;
610
643
  /** {@link CALLS_SECRET_ENV_KEYS}' counterpart on chat's own MAS app. */
611
644
  const CHAT_SECRET_ENV_KEYS = {
612
645
  masSynapseSharedSecret: 'CHAT_MATRIX_MAS_SYNAPSE_SHARED_SECRET',
@@ -784,6 +817,8 @@ export class FoundryStepExecutor {
784
817
  return this.coolifyCalls(action, org, context);
785
818
  case 'coolify:calls-service':
786
819
  return this.coolifyCallsService(action, org, context);
820
+ case 'coolify:calls-chat':
821
+ return this.coolifyCallsChat(action, org, context);
787
822
  case 'cloudflare:tunnel':
788
823
  return this.cloudflareTunnel(action, org, context);
789
824
  case 'cloudflare:zone':
@@ -1293,13 +1328,14 @@ export class FoundryStepExecutor {
1293
1328
  // ZITADEL's default invite link (`/ui/v2/login/verify`) 404s; the platform's login app, which
1294
1329
  // every org's users sign in through on this instance, serves `/verify` at the root.
1295
1330
  const authIssuer = action.desired.authIssuer;
1331
+ if (!authIssuer) {
1332
+ throw new Error(`zitadel:owner for ${org.slug}: no invite template issuer -- refusing to send ZITADEL's default invite link, which 404s on this platform`);
1333
+ }
1296
1334
  const { userId } = await this.ports.zitadel.inviteOrgOwner(zitadelOrgId, {
1297
1335
  givenName: action.desired.givenName,
1298
1336
  familyName: action.desired.familyName,
1299
1337
  email: action.desired.email,
1300
- }, authIssuer
1301
- ? `${authIssuer}/verify?userId={{.UserID}}&code={{.Code}}&organization={{.OrgID}}&invite=true`
1302
- : undefined);
1338
+ }, `${authIssuer}/verify?userId={{.UserID}}&code={{.Code}}&organization={{.OrgID}}&invite=true`);
1303
1339
  // An owner invited after chat was built never reaches `coolifyChat`
1304
1340
  // again, so the role is granted here once the messaging project exists.
1305
1341
  const messagingProjectId = context.recordedIds()[ZITADEL_MESSAGING_PROJECT_SCOPE_KEY];
@@ -3693,7 +3729,7 @@ export class FoundryStepExecutor {
3693
3729
  if (action.desired.usesDomain === false) {
3694
3730
  throw new NotBuiltError(`coolify:chat reverse re-key for ${org.slug}`, 'moving its chat back onto the original server_name is not built (#7 amendment 3)');
3695
3731
  }
3696
- throw new NotBuiltError(`coolify:chat re-key for ${org.slug}`, 'moving its chat onto the company domain is not built; chat stays on its level-1 server_name');
3732
+ return this.coolifyChatRekey(org, context, recorded);
3697
3733
  }
3698
3734
  const issuer = action.desired.issuer;
3699
3735
  if (typeof issuer !== 'string' || issuer === '') {
@@ -3878,6 +3914,224 @@ export class FoundryStepExecutor {
3878
3914
  context.recordScopeId(CHAT_SERVICE_URL_SCOPE_KEY, chatUpstreams(org.slug).chat);
3879
3915
  return { resourceId: mas.appId };
3880
3916
  }
3917
+ /**
3918
+ * `coolifyChat`'s forward re-key (#463, design note
3919
+ * `docs/chat-rekey-design.md` Option 1): a live chat recorded, and
3920
+ * `org.chat.usesDomain` flipped on. Provisions a second Synapse+MAS pair
3921
+ * named for the org's own domain (`chatRekeyedInternalNames`), copies
3922
+ * every room, member and message across through
3923
+ * {@link FoundryStepExecutorPorts.chatRekey}, repoints the existing chat
3924
+ * service at the new pair, and parks -- never deletes, so a stalled
3925
+ * migration can be rolled back by simply restarting them -- the original
3926
+ * Synapse and MAS apps. The chat service and its own database are never
3927
+ * re-created; only the Matrix half of the stack moves.
3928
+ *
3929
+ * Idempotent on recorded ids: new resources are found by name before they
3930
+ * are created, the new MAS's secrets are read back from its own envs, and
3931
+ * the copy, the repoint and the park each run once
3932
+ * ({@link CHAT_REKEY_COPIED_SCOPE_KEY}, {@link CHAT_REKEY_REPOINTED_SCOPE_KEY}).
3933
+ * The original pair keeps serving until the chat service is healthy on the
3934
+ * new one; a failure before that leaves it running.
3935
+ */
3936
+ async coolifyChatRekey(org, context, recorded) {
3937
+ if (!this.ports.chatRekey) {
3938
+ throw new NotBuiltError(`coolify:chat re-key for ${org.slug}`, 'construct FoundryStepExecutor with `chatRekey` -- the copy mechanism must be proven against a real Synapse and MAS pair first (#463 Human step)');
3939
+ }
3940
+ if (!org.domain) {
3941
+ throw new Error(`coolify:chat re-key for ${org.slug}: the org has no domain to re-key onto`);
3942
+ }
3943
+ try {
3944
+ assertChatServerName(org.domain);
3945
+ }
3946
+ catch {
3947
+ throw new Error(`coolify:chat re-key for ${org.slug}: the org's domain is not a valid server_name`);
3948
+ }
3949
+ const runtime = this.ports.runtime ?? {};
3950
+ const sleep = runtime.sleep ?? defaultSleep;
3951
+ const oldSynapseAppId = recorded[CHAT_SYNAPSE_SCOPE_KEY];
3952
+ const oldServiceAppId = recorded[CHAT_SERVICE_SCOPE_KEY];
3953
+ if (!oldSynapseAppId || !oldServiceAppId) {
3954
+ throw new Error(`coolify:chat re-key for ${org.slug}: its level-1 Synapse or chat service is not recorded yet`);
3955
+ }
3956
+ // See {@link CHAT_REKEY_ORIGINAL_MAS_SCOPE_KEY}'s own doc comment: taken
3957
+ // once, from the generic resourceId this step returns, before that id is
3958
+ // ever repointed at the new MAS.
3959
+ const oldMasAppId = recorded[CHAT_REKEY_ORIGINAL_MAS_SCOPE_KEY] ?? recorded[`coolify:chat:${org.id}`];
3960
+ if (!oldMasAppId) {
3961
+ throw new Error(`coolify:chat re-key for ${org.slug}: its level-1 MAS is not recorded yet`);
3962
+ }
3963
+ context.recordScopeId(CHAT_REKEY_ORIGINAL_MAS_SCOPE_KEY, oldMasAppId);
3964
+ const projectUuid = await this.ensureCoolifyProject(org, context);
3965
+ const { environment } = await this.ports.coolify.findOrCreateEnvironment(projectUuid, 'production');
3966
+ const server = await this.resolveServer(org, context);
3967
+ const names = chatRekeyedInternalNames(org.slug);
3968
+ const synapseDbId = await this.ensureCallsDatabase(org, context, projectUuid, environment, names.synapseDb, 'synapse', CHAT_REKEY_SYNAPSE_DB_SCOPE_KEY, CALLS_SYNAPSE_DB_INITDB_ARGS);
3969
+ context.recordScopeId(CHAT_REKEY_SYNAPSE_DB_SCOPE_KEY, synapseDbId);
3970
+ const masDbId = await this.ensureCallsDatabase(org, context, projectUuid, environment, names.masDb, 'mas', CHAT_REKEY_MAS_DB_SCOPE_KEY);
3971
+ context.recordScopeId(CHAT_REKEY_MAS_DB_SCOPE_KEY, masDbId);
3972
+ const [synapseDbRow, masDbRow] = await Promise.all([
3973
+ getDatabaseStarted(this.ports.coolify, synapseDbId),
3974
+ getDatabaseStarted(this.ports.coolify, masDbId),
3975
+ ]);
3976
+ const synapseDb = synapseDbRow.internal_db_url
3977
+ ? parseCallsDbUrl(synapseDbRow.internal_db_url)
3978
+ : null;
3979
+ const masDb = masDbRow.internal_db_url ? parseCallsDbUrl(masDbRow.internal_db_url) : null;
3980
+ if (!synapseDb || !masDb) {
3981
+ throw new Error(`coolify:chat re-key for ${org.slug}: its new database has no internal_db_url yet -- retry this apply once Coolify reports one`);
3982
+ }
3983
+ const githubApp = await this.ports.coolify.requireGithubApp();
3984
+ const synapse = await this.findOrCreateCallsApp(names.synapse, environment.id, () => this.ports.coolify.createGithubAppApplication({
3985
+ project_uuid: projectUuid,
3986
+ server_uuid: server.uuid,
3987
+ environment_name: environment.name,
3988
+ environment_uuid: environment.uuid,
3989
+ github_app_uuid: githubApp.uuid,
3990
+ git_repository: CHAT_REPO,
3991
+ git_branch: 'main',
3992
+ build_pack: 'dockerfile',
3993
+ name: names.synapse,
3994
+ dockerfile_location: CHAT_SYNAPSE_DOCKERFILE,
3995
+ ports_exposes: CHAT_SYNAPSE_PORT,
3996
+ autogenerate_domain: false,
3997
+ }), { startIfStopped: false });
3998
+ const mas = await this.findOrCreateCallsApp(names.mas, environment.id, () => this.ports.coolify.createDockerfileApplication({
3999
+ project_uuid: projectUuid,
4000
+ server_uuid: server.uuid,
4001
+ environment_name: environment.name,
4002
+ environment_uuid: environment.uuid,
4003
+ dockerfile: CALLS_MAS_DOCKERFILE,
4004
+ name: names.mas,
4005
+ description: `matrix-authentication-service for ${org.slug}'s re-keyed chat (#463)`,
4006
+ instant_deploy: false,
4007
+ }), { startIfStopped: false });
4008
+ await this.ports.coolify.updateApplication(synapse.appId, {
4009
+ custom_internal_name: names.synapse,
4010
+ ...(synapse.existed ? {} : { health_check_enabled: false }),
4011
+ });
4012
+ await this.ports.coolify.updateApplication(mas.appId, {
4013
+ custom_internal_name: names.mas,
4014
+ ...(mas.existed ? {} : { health_check_enabled: false }),
4015
+ });
4016
+ context.recordScopeId(CHAT_REKEY_SYNAPSE_SCOPE_KEY, synapse.appId);
4017
+ context.recordScopeId(CHAT_REKEY_MAS_SCOPE_KEY, mas.appId);
4018
+ // The new MAS holds the minted secrets, so its id is on record before they exist.
4019
+ await context.persistScopeIds();
4020
+ const synapseStorages = await this.ports.coolify.listStorages(synapse.appId);
4021
+ if (!(synapseStorages.persistent_storages ?? []).some((s) => s.mount_path === '/data')) {
4022
+ await this.ports.coolify.createPersistentStorage(synapse.appId, {
4023
+ name: `${names.synapse}-data`,
4024
+ mount_path: '/data',
4025
+ });
4026
+ }
4027
+ const secrets = await this.ensureCallsMatrixSecrets(mas.appId, CHAT_SECRET_ENV_KEYS);
4028
+ const agoraHost = agoraHostnameFor(org);
4029
+ await this.ensureCallsFileStorage(synapse.appId, '/data/homeserver.yaml', chatSynapseHomeserverYaml({
4030
+ orgSlug: org.slug,
4031
+ db: synapseDb,
4032
+ masHost: names.mas,
4033
+ masSharedSecret: secrets.value.masSynapseSharedSecret,
4034
+ serverName: org.domain,
4035
+ }));
4036
+ await this.ports.coolify.bulkUpsertEnvs(synapse.appId, [
4037
+ { key: 'SYNAPSE_CONFIG_PATH', value: '/data/homeserver.yaml' },
4038
+ { key: 'UID', value: '0' },
4039
+ { key: 'GID', value: '0' },
4040
+ ]);
4041
+ await this.ensureCallsFileStorage(mas.appId, '/config/config.yaml', chatMasConfigYaml({
4042
+ orgSlug: org.slug,
4043
+ publicHost: agoraHost,
4044
+ db: masDb,
4045
+ synapseHost: names.synapse,
4046
+ sharedSecret: secrets.value.masSynapseSharedSecret,
4047
+ clientId: secrets.value.masClientId,
4048
+ clientSecret: secrets.value.masClientSecret,
4049
+ encryptionKey: secrets.value.masEncryptionKey,
4050
+ rsaKeyPem: secrets.value.masRsaKeyPem,
4051
+ ecKeyPem: secrets.value.masEcKeyPem,
4052
+ serverName: org.domain,
4053
+ }));
4054
+ // MAS first: Synapse asks it for token introspection. Both are healthy
4055
+ // before the copy reads or writes either of them.
4056
+ for (const app of [mas, synapse]) {
4057
+ if (!app.existed ||
4058
+ secrets.changed ||
4059
+ !isRunningOrStarting((await this.ports.coolify.getApplication(app.appId)).status)) {
4060
+ await waitForNewDeployment(this.ports.coolify, app.appId, { kind: 'deployment-only' }, runtime, sleep, 'coolify:chat', `${org.slug}'s original chat pair stays live; the new ${app === mas ? 'MAS' : 'Synapse'} is not up`);
4061
+ }
4062
+ }
4063
+ if (!recorded[CHAT_REKEY_COPIED_SCOPE_KEY]) {
4064
+ const oldNames = chatInternalNames(org.slug);
4065
+ const abort = new AbortController();
4066
+ let timer;
4067
+ try {
4068
+ await Promise.race([
4069
+ this.ports.chatRekey.copyHomeserver({
4070
+ orgSlug: org.slug,
4071
+ fromServerName: chatServerName(org.slug),
4072
+ fromInternalHost: `http://${oldNames.synapse}:${CHAT_SYNAPSE_PORT}`,
4073
+ toServerName: org.domain,
4074
+ toInternalHost: `http://${names.synapse}:${CHAT_SYNAPSE_PORT}`,
4075
+ signal: abort.signal,
4076
+ }),
4077
+ new Promise((_, reject) => {
4078
+ timer = setTimeout(() => {
4079
+ abort.abort();
4080
+ reject(new Error('timed out'));
4081
+ }, CHAT_REKEY_COPY_TIMEOUT_MS);
4082
+ }),
4083
+ ]);
4084
+ }
4085
+ catch {
4086
+ throw new Error(`coolify:chat re-key for ${org.slug}: the copy did not finish; the original chat pair stays live. Apply again`);
4087
+ }
4088
+ finally {
4089
+ clearTimeout(timer);
4090
+ }
4091
+ context.recordScopeId(CHAT_REKEY_COPIED_SCOPE_KEY, '1');
4092
+ await context.persistScopeIds();
4093
+ }
4094
+ // The marker is set only after the service is healthy on the new pair; an
4095
+ // env write whose deploy failed must not let the original be parked.
4096
+ if (!recorded[CHAT_REKEY_REPOINTED_SCOPE_KEY]) {
4097
+ const envs = [
4098
+ { key: 'CHAT_SERVER_NAME', value: org.domain },
4099
+ {
4100
+ key: 'CHAT_INTERNAL_HOMESERVER_URL',
4101
+ value: `http://${names.synapse}:${CHAT_SYNAPSE_PORT}`,
4102
+ },
4103
+ { key: 'MAS_URL', value: `http://${names.mas}:${CHAT_MAS_PORT}` },
4104
+ { key: 'MAS_CLIENT_ID', value: secrets.value.masClientId },
4105
+ { key: 'MAS_CLIENT_SECRET', value: secrets.value.masClientSecret },
4106
+ ];
4107
+ let written = false;
4108
+ for (let attempt = 1; attempt <= JOBS_DELIVERY_ATTEMPTS && !written; attempt++) {
4109
+ try {
4110
+ await this.ports.coolify.bulkUpsertEnvs(oldServiceAppId, envs);
4111
+ written = true;
4112
+ }
4113
+ catch {
4114
+ // Retried; the error is dropped in case it echoes the client secret.
4115
+ }
4116
+ }
4117
+ if (!written) {
4118
+ throw new Error(`coolify:chat re-key for ${org.slug}: the new MAS client could not be written to the chat service (${oldServiceAppId}) after ${JOBS_DELIVERY_ATTEMPTS} attempts; the original chat pair stays live`);
4119
+ }
4120
+ await waitForNewDeployment(this.ports.coolify, oldServiceAppId, publicHealthCheck(org, context.recordedIds(), agoraHostnameFor(org), CHAT_SERVICE_HEALTH_CHECK_PATH, { viaAgora: true }), runtime, sleep, 'coolify:chat', `${org.slug}'s original chat pair stays live`);
4121
+ context.recordScopeId(CHAT_REKEY_REPOINTED_SCOPE_KEY, '1');
4122
+ await context.persistScopeIds();
4123
+ }
4124
+ // Park, never delete (#7 corrections 1's park-not-delete rule), the
4125
+ // original Synapse and MAS: cheap to roll back onto before members are
4126
+ // told to use the new id, the design note's own rollback story. Skipped
4127
+ // once already stopped, so a third run touches nothing here either.
4128
+ for (const appId of [oldSynapseAppId, oldMasAppId]) {
4129
+ if (isRunningOrStarting((await this.ports.coolify.getApplication(appId)).status)) {
4130
+ await this.ports.coolify.raw('POST', `/applications/${appId}/stop`);
4131
+ }
4132
+ }
4133
+ return { resourceId: mas.appId };
4134
+ }
3881
4135
  // ---- calls service (#145, part 2 of #128) -------------------------------
3882
4136
  /**
3883
4137
  * `plan/diff.ts`'s `planCallsService`: the org's own calls service --
@@ -3987,9 +4241,9 @@ export class FoundryStepExecutor {
3987
4241
  envs.push({ key: 'CALLS_BINDING_SECRET', value: bindingSecret });
3988
4242
  // The seven ZITADEL token vars, all or none (the service refuses to boot
3989
4243
  // on a partial set). Deliberately absent: `CALLS_CHAT_URL` and
3990
- // `CALLS_CHAT_TOKEN`. Foundry does not deploy chat
3991
- // (https://github.com/wtfalch/chat/issues/40), so with both unset the
3992
- // calls service admits only `event:` rooms.
4244
+ // `CALLS_CHAT_TOKEN`. `coolifyCallsChat` sets and removes the pair, after
4245
+ // this step and chat, so a chat-less org keeps event-only calls and this
4246
+ // write never leaves one of the two behind.
3993
4247
  const tokenHost = action.desired.host;
3994
4248
  const tokenMarker = callsTokenEnvMarker(tokenHost ?? null, context.recordedIds());
3995
4249
  const token = tokenHost && tokenMarker
@@ -4048,6 +4302,88 @@ export class FoundryStepExecutor {
4048
4302
  context.recordScopeId(dbMarkerKey, '1');
4049
4303
  return { resourceId: app.appId };
4050
4304
  }
4305
+ /**
4306
+ * `plan/diff.ts`'s `planCallsChat`: the one internal token that lets the
4307
+ * calls service ask chat who is in a room, and the chat address. The token
4308
+ * is `CHAT_INTERNAL_TOKEN` on chat-service and `CALLS_CHAT_TOKEN` on the
4309
+ * calls service, which gets `CALLS_CHAT_URL` in the same write: it refuses
4310
+ * to boot on one without the other. Chat-service's env is the only memory
4311
+ * of the token across a run, the way {@link ensureCallsMatrixSecrets} keeps
4312
+ * the MAS secrets on the MAS app, so it is generated only when that env has
4313
+ * none. Its id is recorded and persisted before either app holds it, and the
4314
+ * guard after both do (platform rule 1). Both apps are redeployed last; the
4315
+ * applied marker is recorded after that, so a run that fails in between is
4316
+ * planned and finished again. `remove` (chat is off) deletes both vars from
4317
+ * the calls service and restarts it.
4318
+ */
4319
+ async coolifyCallsChat(action, org, context) {
4320
+ const at = `coolify:calls-chat for ${org.slug}`;
4321
+ const recorded = context.recordedIds();
4322
+ const callsAppId = recorded[`coolify:calls-service:${action.key}`];
4323
+ const chatAppId = recorded[CHAT_SERVICE_SCOPE_KEY];
4324
+ const envOf = async (appId, key) => (await this.ports.coolify.listEnvs(appId)).find((e) => e.key === key && !e.is_preview);
4325
+ if (!callsAppId)
4326
+ throw new Error(`${at}: the calls service has not been provisioned yet`);
4327
+ if (action.desired.remove === true) {
4328
+ for (const key of ['CALLS_CHAT_URL', 'CALLS_CHAT_TOKEN']) {
4329
+ const env = await envOf(callsAppId, key);
4330
+ if (env?.uuid)
4331
+ await this.ports.coolify.deleteEnv(callsAppId, env.uuid);
4332
+ }
4333
+ if (isRunningOrStarting((await this.ports.coolify.getApplication(callsAppId)).status)) {
4334
+ await this.ports.coolify.restart(callsAppId);
4335
+ }
4336
+ context.recordScopeId(CHAT_CALLS_APPLIED_SCOPE_KEY, '');
4337
+ return {};
4338
+ }
4339
+ const chatUrl = chatUpstreams(org.slug).chat;
4340
+ if (!chatAppId || !chatHasUpstreams(org, recorded)) {
4341
+ throw new Error(`${at}: chat has not been provisioned yet`);
4342
+ }
4343
+ let tokenId = recorded[CHAT_CALLS_TOKEN_SCOPE_KEY];
4344
+ const undelivered = recorded[CHAT_CALLS_TOKEN_UNDELIVERED_SCOPE_KEY];
4345
+ if (undelivered && !tokenId) {
4346
+ throw new Error(`${at}: token ${undelivered} was minted but never written to both apps; remove the recorded key ${CHAT_CALLS_TOKEN_UNDELIVERED_SCOPE_KEY} to write it again`);
4347
+ }
4348
+ // Chat refuses a token under 32 characters; the calls service needs the same bytes.
4349
+ const held = (await envOf(chatAppId, 'CHAT_INTERNAL_TOKEN'))?.value;
4350
+ const usable = held !== undefined && /^\S{32,}$/.test(held) ? held : undefined;
4351
+ if (tokenId && !usable) {
4352
+ throw new Error(`${at}: token ${tokenId} is recorded but chat-service no longer holds a usable one; remove the recorded keys ${CHAT_CALLS_TOKEN_SCOPE_KEY} and ${CHAT_CALLS_TOKEN_UNDELIVERED_SCOPE_KEY} to mint a new one`);
4353
+ }
4354
+ const token = usable ?? randomBytes(32).toString('hex');
4355
+ if (!tokenId) {
4356
+ tokenId = randomBytes(8).toString('hex');
4357
+ context.recordScopeId(CHAT_CALLS_TOKEN_UNDELIVERED_SCOPE_KEY, tokenId);
4358
+ await context.persistScopeIds();
4359
+ }
4360
+ const id = tokenId;
4361
+ const write = async (appId, app, envs) => {
4362
+ for (let attempt = 1; attempt <= JOBS_DELIVERY_ATTEMPTS; attempt++) {
4363
+ try {
4364
+ await this.ports.coolify.bulkUpsertEnvs(appId, envs);
4365
+ return;
4366
+ }
4367
+ catch {
4368
+ // Retried; the error is dropped in case it echoes the token.
4369
+ }
4370
+ }
4371
+ throw new Error(`${at}: token ${id} could not be written to ${app} after ${JOBS_DELIVERY_ATTEMPTS} attempts`);
4372
+ };
4373
+ await write(chatAppId, 'chat-service', [{ key: 'CHAT_INTERNAL_TOKEN', value: token }]);
4374
+ await write(callsAppId, 'the calls service', [
4375
+ { key: 'CALLS_CHAT_URL', value: chatUrl },
4376
+ { key: 'CALLS_CHAT_TOKEN', value: token },
4377
+ ]);
4378
+ context.recordScopeId(CHAT_CALLS_TOKEN_SCOPE_KEY, id);
4379
+ // Chat first: the calls service's first question must find the route.
4380
+ const runtime = this.ports.runtime ?? {};
4381
+ const sleep = runtime.sleep ?? defaultSleep;
4382
+ await waitForNewDeployment(this.ports.coolify, chatAppId, publicHealthCheck(org, context.recordedIds(), agoraHostnameFor(org), CHAT_SERVICE_HEALTH_CHECK_PATH, { viaAgora: true }), runtime, sleep, 'coolify:calls-chat', `${org.slug}'s chat service stays at its previous deploy`);
4383
+ await waitForNewDeployment(this.ports.coolify, callsAppId, publicHealthCheck(org, context.recordedIds(), callsHostnameFor(org), CALLS_HEALTH_CHECK_PATH), runtime, sleep, 'coolify:calls-chat', `${org.slug}'s calls service stays at its previous deploy`);
4384
+ context.recordScopeId(CHAT_CALLS_APPLIED_SCOPE_KEY, chatCallsApplied(id, chatUrl));
4385
+ return {};
4386
+ }
4051
4387
  /**
4052
4388
  * The seven ZITADEL token env vars the calls service needs to accept
4053
4389
  * Agora's sign-in token (`calls/docs/member-auth.md`). Only called with all
@@ -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. */
@@ -394,6 +403,15 @@ export declare const PLATFORM_MAIL_ADMIN_UNDELIVERED_SCOPE_KEY = "stalwart_platf
394
403
  */
395
404
  export declare const PLATFORM_MAIL_APPLIED_HOSTNAME_SCOPE_KEY = "mail_applied_hostname";
396
405
  export declare const PLATFORM_MAIL_APPLIED_GIT_REF_SCOPE_KEY = "mail_applied_git_ref";
406
+ /**
407
+ * #661: recorded with {@link PLATFORM_MAIL_ROUTE_WANTED} once the mail
408
+ * server's settings write included the rule that delivers hosted recipients
409
+ * locally (`@wtfalch/email` 0.20.0). A platform applied before it lacks the id,
410
+ * so `planMail` plans the step once more; that run redeploys nothing, mints
411
+ * no key and creates nothing in the sign-in server.
412
+ */
413
+ export declare const PLATFORM_MAIL_ROUTE_SCOPE_KEY = "mail_route_applied";
414
+ export declare const PLATFORM_MAIL_ROUTE_WANTED = "local-first";
397
415
  /**
398
416
  * #177: where the mail server answers on the Coolify network
399
417
  * (`http://<custom_internal_name>:<port>`), recorded by the step the same way
@@ -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. */
@@ -432,6 +441,15 @@ export const PLATFORM_MAIL_ADMIN_UNDELIVERED_SCOPE_KEY = 'stalwart_platform_admi
432
441
  */
433
442
  export const PLATFORM_MAIL_APPLIED_HOSTNAME_SCOPE_KEY = 'mail_applied_hostname';
434
443
  export const PLATFORM_MAIL_APPLIED_GIT_REF_SCOPE_KEY = 'mail_applied_git_ref';
444
+ /**
445
+ * #661: recorded with {@link PLATFORM_MAIL_ROUTE_WANTED} once the mail
446
+ * server's settings write included the rule that delivers hosted recipients
447
+ * locally (`@wtfalch/email` 0.20.0). A platform applied before it lacks the id,
448
+ * so `planMail` plans the step once more; that run redeploys nothing, mints
449
+ * no key and creates nothing in the sign-in server.
450
+ */
451
+ export const PLATFORM_MAIL_ROUTE_SCOPE_KEY = 'mail_route_applied';
452
+ export const PLATFORM_MAIL_ROUTE_WANTED = 'local-first';
435
453
  /**
436
454
  * #177: where the mail server answers on the Coolify network
437
455
  * (`http://<custom_internal_name>:<port>`), recorded by the step the same way
@@ -1708,7 +1726,10 @@ function planAuth(platform, recordedIds, executorBlocked, executorMissing, authD
1708
1726
  Boolean(recordedIds[PLATFORM_AUTH_OWNER_INVITE_SCOPE_KEY]) &&
1709
1727
  // The ZITADEL run options reach the container only through the auth
1710
1728
  // 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) {
1729
+ recordedIds[PLATFORM_AUTH_ZITADEL_RUN_OPTIONS_SCOPE_KEY] === AUTH_ZITADEL_RUN_OPTIONS &&
1730
+ // #394: a platform applied before these existed plans the step again.
1731
+ Boolean(recordedIds[PLATFORM_AUTH_RESERVED_ADMIN_USERNAME_SCOPE_KEY]) &&
1732
+ Boolean(recordedIds[PLATFORM_AUTH_REGISTRATION_CLOSED_SCOPE_KEY])) {
1712
1733
  return { step: null, blocked: null };
1713
1734
  }
1714
1735
  // The admin key was minted but never reached the host. ZITADEL shows a
@@ -1844,9 +1865,16 @@ function planMail(platform, recordedIds, executorBlocked, authReady) {
1844
1865
  recordedIds[PLATFORM_MAIL_APPLIED_GIT_REF_SCOPE_KEY] === platform.mail.gitRef &&
1845
1866
  // #177: a mail step applied before this key existed never recorded it, and
1846
1867
  // org steps read it for Agora's mail upstream. Missing means unapplied.
1847
- recordedIds[PLATFORM_MAIL_INTERNAL_URL_SCOPE_KEY]) {
1868
+ recordedIds[PLATFORM_MAIL_INTERNAL_URL_SCOPE_KEY] &&
1869
+ recordedIds[PLATFORM_MAIL_ROUTE_SCOPE_KEY] === PLATFORM_MAIL_ROUTE_WANTED) {
1848
1870
  return { step: null, blocked: null };
1849
1871
  }
1872
+ // Only the route is missing: say so, the app is not redeployed.
1873
+ const onlyRouteMissing = recordedIds[key] &&
1874
+ recordedIds[PLATFORM_MAIL_CONNECTOR_APP_SCOPE_KEY] &&
1875
+ recordedIds[PLATFORM_MAIL_APPLIED_HOSTNAME_SCOPE_KEY] === platform.mail.hostname &&
1876
+ recordedIds[PLATFORM_MAIL_APPLIED_GIT_REF_SCOPE_KEY] === platform.mail.gitRef &&
1877
+ recordedIds[PLATFORM_MAIL_INTERNAL_URL_SCOPE_KEY];
1850
1878
  // Stalwart's `x:ApiKey/set` shows a secret once. An id was minted but never
1851
1879
  // reached the host: report it, plan nothing, the same rule {@link planAuth}
1852
1880
  // follows for ZITADEL's own admin key.
@@ -1874,7 +1902,9 @@ function planMail(platform, recordedIds, executorBlocked, authReady) {
1874
1902
  desired: { hostname: platform.mail.hostname, gitRef: platform.mail.gitRef },
1875
1903
  },
1876
1904
  step: {
1877
- description: `provision mail (Stalwart) at ${platform.mail.hostname}`,
1905
+ description: onlyRouteMissing
1906
+ ? `write the mail server's local-delivery route at ${platform.mail.hostname}`
1907
+ : `provision mail (Stalwart) at ${platform.mail.hostname}`,
1878
1908
  destroys: false,
1879
1909
  migrates: false,
1880
1910
  },
@@ -2284,7 +2314,7 @@ function planAccessProvider(platform, recordedIds, executorBlocked) {
2284
2314
  * auth/ZITADEL client: `hc` is an internal operational tool, not something
2285
2315
  * an organisation member signs into. Whether this stays the platform's
2286
2316
  * 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,
2317
+ * heartbeat instance, and what happens to that instance's own data,
2288
2318
  * are both still open "Decide" items on issue #540's own thread -- not
2289
2319
  * settled by this code.
2290
2320
  *
@@ -2598,7 +2628,7 @@ function planAi(platform, recordedIds, executorBlocked) {
2598
2628
  /**
2599
2629
  * #175: Archon (https://github.com/wtfalch/archon), the one shared console,
2600
2630
  * built fresh at `platform.archon.hostname` (`archon.<apex>`) rather than the
2601
- * hand-deployed `operator.wtfalch.dev` it replaces (#165). One step,
2631
+ * hand-deployed `operator` app it replaces (#165). One step,
2602
2632
  * `coolify:app` keyed {@link PLATFORM_ARCHON_KEY}, covers the app, its
2603
2633
  * database, its Cloudflare tunnel, Access app and DNS record, and its sign-in
2604
2634
  * 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,