@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.
@@ -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';
6
- import { BOULE_BROKER_ENTRY_KEY, BOULE_CLIENT_ID_SCOPE_KEY, BOULE_DATABASE_SCOPE_KEY, BOULE_DEPLOYED_SCOPE_KEY, bouleEnvDrift, bouleHostnameFor, desiredBouleEnv, wantedBouleBrokerEntry, } from './boule.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
+ 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];
@@ -2493,6 +2529,10 @@ export class FoundryStepExecutor {
2493
2529
  * domain restricted to them before any old project is deleted, so the
2494
2530
  * restriction list never becomes empty.
2495
2531
  *
2532
+ * #654: the non-platform-apex part now lives in {@link ensureOrgAppMailToken},
2533
+ * shared with Boule, which holds the same token; the rule below is its rule
2534
+ * with the consumers read from the hosted app and Boule.
2535
+ *
2496
2536
  * Mint rule ("Credential model"): the project is healthy when the app env
2497
2537
  * holds `LETTERMINT_API_TOKEN` and Lettermint lists exactly one project
2498
2538
  * named `<slug>-app` -- unlike the earlier provider's key, a Lettermint project's name
@@ -2545,7 +2585,6 @@ export class FoundryStepExecutor {
2545
2585
  if (!hasPlatformApexField) {
2546
2586
  throw new Error(`coolify:app for ${org.slug}: this step's plan carries no platformApex; plan this org's app again`);
2547
2587
  }
2548
- const appName = `${org.slug}-app`;
2549
2588
  // #491: an org whose mail domain is the platform's own apex (Company
2550
2589
  // Included on companyincluded.com, today) shares the platform's existing
2551
2590
  // Lettermint project rather than minting a `<slug>-app` one of its own --
@@ -2588,44 +2627,129 @@ export class FoundryStepExecutor {
2588
2627
  await this.ports.coolify.restart(appId);
2589
2628
  return;
2590
2629
  }
2591
- const [projects, envs] = await Promise.all([
2630
+ await this.ensureOrgAppMailToken({
2631
+ org,
2632
+ context,
2633
+ mailDomain,
2634
+ self: { role: 'app', id: appId, restart: running },
2635
+ mailFrom: `${org.mail.noReplyLocalPart ?? 'no-reply'}@${mailDomain}`,
2636
+ platformApex,
2637
+ platformLettermintProjectId,
2638
+ });
2639
+ }
2640
+ /**
2641
+ * The one mint rule for the org's `<slug>-app` Lettermint token, shared by
2642
+ * the hosted app (`ensureAppLettermintMail`) and Boule
2643
+ * (`ensureBouleMail`), which hold the same token. Lettermint shows a
2644
+ * token once, so the token lives only in those apps' envs: the rule reads
2645
+ * every consumer that is recorded (the hosted app first, Boule second).
2646
+ * The project is healthy when Lettermint lists exactly one `<slug>-app`
2647
+ * project and some consumer holds a token. A healthy rule gives `self` that
2648
+ * token (the app's wins) when it lacks it or holds another; a peer gets it
2649
+ * in its own step. An
2650
+ * unhealthy one mints a fresh project and writes its token to every
2651
+ * consumer, restarting those that run, before any other `<slug>-app`
2652
+ * project is deleted. Either role may run first: an app added after Boule
2653
+ * adopts Boule's token instead of deleting its project.
2654
+ *
2655
+ * The token goes straight to `bulkUpsertEnvs` and nowhere else: never
2656
+ * returned, recorded, logged or interpolated into an error. Returns
2657
+ * whether `self`'s own token changed and whether it was restarted here.
2658
+ *
2659
+ * A peer recorded but gone from Coolify (404) is no consumer. A healthy
2660
+ * company's step writes only its own env; a re-mint writes every live
2661
+ * consumer, restarts the running ones, restricts the domain, then deletes
2662
+ * the old projects.
2663
+ */
2664
+ async ensureOrgAppMailToken(args) {
2665
+ const { org, context, mailDomain, self, mailFrom } = args;
2666
+ const appName = `${org.slug}-app`;
2667
+ const recorded = context.recordedIds();
2668
+ const appId = self.role === 'app' ? self.id : recorded[`coolify:app:${appStepKey(org)}`];
2669
+ const bouleId = self.role === 'boule' ? self.id : recorded[`coolify:boule:${org.id}`];
2670
+ const peerListEnvs = (id) => !id || id === self.id
2671
+ ? Promise.resolve(null)
2672
+ : // A recorded peer that Coolify no longer has is not a consumer: not
2673
+ // read, written or restarted (same 404 rule as the retire step).
2674
+ this.ports.coolify
2675
+ .listEnvs(id)
2676
+ .catch((error) => {
2677
+ if (!isNotFoundError(error))
2678
+ throw error;
2679
+ return null;
2680
+ });
2681
+ const [projects, selfEnvs, appEnvs, bouleEnvs] = await Promise.all([
2592
2682
  this.ports.lettermint.listProjects(),
2593
- this.ports.coolify.listEnvs(appId),
2683
+ this.ports.coolify.listEnvs(self.id),
2684
+ self.role === 'app' ? Promise.resolve(null) : peerListEnvs(appId),
2685
+ self.role === 'boule' ? Promise.resolve(null) : peerListEnvs(bouleId),
2594
2686
  ]);
2595
- const envValue = (key) => envs.find((e) => e.key === key && !e.is_preview)?.value;
2687
+ const tokenOf = (envs) => envs.find((e) => e.key === 'LETTERMINT_API_TOKEN' && !e.is_preview)?.value || undefined;
2688
+ // The hosted app first, Boule second: the app's token wins a disagreement.
2689
+ const consumers = [];
2690
+ const appSlot = self.role === 'app' ? selfEnvs : appEnvs;
2691
+ const bouleSlot = self.role === 'boule' ? selfEnvs : bouleEnvs;
2692
+ if (appSlot && appId)
2693
+ consumers.push({ id: appId, token: tokenOf(appSlot) });
2694
+ if (bouleSlot && bouleId)
2695
+ consumers.push({ id: bouleId, token: tokenOf(bouleSlot) });
2696
+ const existingToken = consumers.find((c) => c.token)?.token;
2596
2697
  const ours = projects.filter((p) => p.name === appName);
2597
2698
  const held = ours.length === 1 ? ours[0] : undefined;
2598
- const healthy = held !== undefined && Boolean(envValue('LETTERMINT_API_TOKEN'));
2699
+ const healthy = held !== undefined && existingToken !== undefined;
2599
2700
  let allProjects = projects;
2600
2701
  let keepProjectId;
2601
- const envUpdates = [];
2602
- if (healthy && held) {
2702
+ let token;
2703
+ if (healthy && held && existingToken) {
2603
2704
  keepProjectId = held.id;
2705
+ token = existingToken;
2604
2706
  }
2605
2707
  else {
2606
2708
  const created = await this.ports.lettermint.createProject(appName);
2607
2709
  keepProjectId = created.project.id;
2608
2710
  allProjects = [...projects, created.project];
2609
- envUpdates.push({ key: 'LETTERMINT_API_TOKEN', value: created.token });
2711
+ token = created.token;
2712
+ }
2713
+ // A healthy company: this step writes only its own env; a peer that lacks
2714
+ // the token gets it in its own step. A re-mint kills the old token, so it
2715
+ // reaches every live consumer here.
2716
+ const targets = consumers.filter((c) => c.token !== token && (!healthy || c.id === self.id));
2717
+ const selfChanged = targets.some((c) => c.id === self.id);
2718
+ for (const consumer of consumers) {
2719
+ const updates = [];
2720
+ if (targets.includes(consumer))
2721
+ updates.push({ key: 'LETTERMINT_API_TOKEN', value: token });
2722
+ if (consumer.id === self.id && mailFrom)
2723
+ updates.push({ key: 'MAIL_FROM', value: mailFrom });
2724
+ if (updates.length > 0)
2725
+ await this.ports.coolify.bulkUpsertEnvs(consumer.id, updates);
2610
2726
  }
2611
- envUpdates.push({
2612
- key: 'MAIL_FROM',
2613
- value: `${org.mail.noReplyLocalPart ?? 'no-reply'}@${mailDomain}`,
2614
- });
2615
- await this.ports.coolify.bulkUpsertEnvs(appId, envUpdates);
2616
2727
  // A running container read its env at start and still holds the old
2617
- // token, so a fresh one only reaches it through a restart -- before the
2618
- // old project is deleted below. A just-created app is deployed first (#566).
2619
- if (!healthy && running)
2620
- await this.ports.coolify.restart(appId);
2621
- await this.ensureLettermintDomainRestriction(org, mailDomain, allProjects, platformApex, platformLettermintProjectId);
2728
+ // token, so a fresh one only reaches it through a restart -- after every
2729
+ // write and before the old project is deleted below, so a failure in
2730
+ // between leaves the old project for the next apply to re-mint. A just-created
2731
+ // app is deployed first (#566).
2732
+ let selfRestarted = false;
2733
+ for (const consumer of targets) {
2734
+ const isSelf = consumer.id === self.id;
2735
+ const running = isSelf
2736
+ ? self.restart
2737
+ : isRunningOrStarting((await this.ports.coolify.getApplication(consumer.id)).status);
2738
+ if (!running)
2739
+ continue;
2740
+ await this.ports.coolify.restart(consumer.id);
2741
+ if (isSelf)
2742
+ selfRestarted = true;
2743
+ }
2744
+ await this.ensureLettermintDomainRestriction(org, mailDomain, allProjects, args.platformApex, args.platformLettermintProjectId);
2622
2745
  for (const project of allProjects) {
2623
2746
  if (project.name === appName &&
2624
2747
  project.id !== keepProjectId &&
2625
- project.id !== platformLettermintProjectId) {
2748
+ project.id !== args.platformLettermintProjectId) {
2626
2749
  await this.ports.lettermint.deleteProject(project.id);
2627
2750
  }
2628
2751
  }
2752
+ return { selfChanged, selfRestarted };
2629
2753
  }
2630
2754
  /**
2631
2755
  * #149: mints this org's own Lettermint relay credential and writes it to
@@ -3605,7 +3729,7 @@ export class FoundryStepExecutor {
3605
3729
  if (action.desired.usesDomain === false) {
3606
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)');
3607
3731
  }
3608
- 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);
3609
3733
  }
3610
3734
  const issuer = action.desired.issuer;
3611
3735
  if (typeof issuer !== 'string' || issuer === '') {
@@ -3790,6 +3914,224 @@ export class FoundryStepExecutor {
3790
3914
  context.recordScopeId(CHAT_SERVICE_URL_SCOPE_KEY, chatUpstreams(org.slug).chat);
3791
3915
  return { resourceId: mas.appId };
3792
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
+ }
3793
4135
  // ---- calls service (#145, part 2 of #128) -------------------------------
3794
4136
  /**
3795
4137
  * `plan/diff.ts`'s `planCallsService`: the org's own calls service --
@@ -3899,9 +4241,9 @@ export class FoundryStepExecutor {
3899
4241
  envs.push({ key: 'CALLS_BINDING_SECRET', value: bindingSecret });
3900
4242
  // The seven ZITADEL token vars, all or none (the service refuses to boot
3901
4243
  // on a partial set). Deliberately absent: `CALLS_CHAT_URL` and
3902
- // `CALLS_CHAT_TOKEN`. Foundry does not deploy chat
3903
- // (https://github.com/wtfalch/chat/issues/40), so with both unset the
3904
- // 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.
3905
4247
  const tokenHost = action.desired.host;
3906
4248
  const tokenMarker = callsTokenEnvMarker(tokenHost ?? null, context.recordedIds());
3907
4249
  const token = tokenHost && tokenMarker
@@ -3960,6 +4302,88 @@ export class FoundryStepExecutor {
3960
4302
  context.recordScopeId(dbMarkerKey, '1');
3961
4303
  return { resourceId: app.appId };
3962
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
+ }
3963
4387
  /**
3964
4388
  * The seven ZITADEL token env vars the calls service needs to accept
3965
4389
  * Agora's sign-in token (`calls/docs/member-auth.md`). Only called with all
@@ -4237,7 +4661,14 @@ export class FoundryStepExecutor {
4237
4661
  // written is the env the plan showed.
4238
4662
  const issuer = action.desired.issuer ?? null;
4239
4663
  const archonUrl = action.desired.archonUrl ?? null;
4240
- const { env, missing } = desiredBouleEnv(org, context.recordedIds(), issuer, archonUrl);
4664
+ // The plan decides blocked mail (`mailBlocked`); a plan with no
4665
+ // `platformApex` key predates this field and is refused before any mail
4666
+ // env is written.
4667
+ const mailWanted = bouleMailWanted(org, context.recordedIds()) && action.desired.mailBlocked !== true;
4668
+ if (mailWanted && !('platformApex' in action.desired)) {
4669
+ throw new Error(`coolify:boule for ${org.slug}: this step's plan carries no platformApex; plan this org's Boule again`);
4670
+ }
4671
+ const { env, missing } = desiredBouleEnv(org, context.recordedIds(), issuer, archonUrl, mailWanted);
4241
4672
  if (missing.length > 0) {
4242
4673
  throw new Error(`Boule for ${org.slug} cannot be configured: missing ${missing.join(', ')}. Nothing was written.`);
4243
4674
  }
@@ -4288,6 +4719,12 @@ export class FoundryStepExecutor {
4288
4719
  if (!dbPush.pushed) {
4289
4720
  throw new Error(`Boule for ${org.slug}: its database has no internal_db_url yet -- retry this apply once Coolify reports one`);
4290
4721
  }
4722
+ // The org's Lettermint token, the same one its hosted app holds. A no-op
4723
+ // for an org with mail off or no mail domain recorded: Boule then shows
4724
+ // the invite link.
4725
+ const mail = mailWanted
4726
+ ? await this.ensureBouleMail(app.appId, org, context, action, app.existed && Boolean(context.recordedIds()[BOULE_DEPLOYED_SCOPE_KEY]))
4727
+ : { changed: false, restarted: false };
4291
4728
  // #504: see `coolifyAgora`'s own comment just above -- `createGithubAppApplication`
4292
4729
  // never sets `instant_deploy`, so a brand new app (or one found by name
4293
4730
  // that a previous apply created but crashed before deploying) sat built
@@ -4300,11 +4737,53 @@ export class FoundryStepExecutor {
4300
4737
  context.recordScopeId(BOULE_DEPLOYED_SCOPE_KEY, '1');
4301
4738
  }
4302
4739
  else if (app.existed &&
4303
- (envChanged || cookieSecret !== null || appKey !== null || dbPush.changed)) {
4740
+ !mail.restarted &&
4741
+ (envChanged || cookieSecret !== null || appKey !== null || dbPush.changed || mail.changed)) {
4742
+ // A restart inside the mail rule already came after every env write above.
4304
4743
  await this.ports.coolify.restart(app.appId);
4305
4744
  }
4306
4745
  return { resourceId: app.appId };
4307
4746
  }
4747
+ /**
4748
+ * Gives Boule `LETTERMINT_API_TOKEN`: the platform's held key for an org on
4749
+ * the platform's own apex, else the org's `<slug>-app` token through the
4750
+ * shared mint rule ({@link ensureOrgAppMailToken}), which mints that project
4751
+ * when no hosted app made it yet. `MAIL_FROM` is written with the rest of
4752
+ * Boule's env. Returns whether Boule's token changed, so the caller
4753
+ * restarts a running Boule, unless `restarted`: the shared rule then already
4754
+ * restarted it (`running`), before it deleted the old project.
4755
+ */
4756
+ async ensureBouleMail(appId, org, context, action, running) {
4757
+ const none = { changed: false, restarted: false };
4758
+ const mailDomain = context.recordedIds()[MAIL_DOMAIN_SCOPE_KEY];
4759
+ if (!mailDomain)
4760
+ return none;
4761
+ const platformApex = action.desired.platformApex ?? null;
4762
+ const platformLettermintProjectId = action.desired.platformLettermintProjectId ?? null;
4763
+ if (platformApex && mailDomain.toLowerCase() === platformApex.toLowerCase()) {
4764
+ const heldToken = (await this.ports.heldKeys?.resolve(PLATFORM_AUTH_BROKER_MAIL_KEY_REF)) ?? null;
4765
+ if (!heldToken) {
4766
+ throw new Error(`coolify:boule for ${org.slug}: the platform's own Lettermint token is not held yet (held-key ref ${PLATFORM_AUTH_BROKER_MAIL_KEY_REF})`);
4767
+ }
4768
+ const current = (await this.ports.coolify.listEnvs(appId)).find((e) => e.key === 'LETTERMINT_API_TOKEN' && !e.is_preview)?.value;
4769
+ if (current === heldToken)
4770
+ return none;
4771
+ await this.ports.coolify.bulkUpsertEnvs(appId, [
4772
+ { key: 'LETTERMINT_API_TOKEN', value: heldToken },
4773
+ ]);
4774
+ return { changed: true, restarted: false };
4775
+ }
4776
+ const { selfChanged, selfRestarted } = await this.ensureOrgAppMailToken({
4777
+ org,
4778
+ context,
4779
+ mailDomain,
4780
+ self: { role: 'boule', id: appId, restart: running },
4781
+ mailFrom: null,
4782
+ platformApex,
4783
+ platformLettermintProjectId,
4784
+ });
4785
+ return { changed: selfChanged, restarted: selfRestarted };
4786
+ }
4308
4787
  // ---- jobs (#173) ---------------------------------------------------------
4309
4788
  /**
4310
4789
  * `plan/diff.ts`'s `planJobsRegistration`: registers the org's own app with