@wtfalch/foundry 0.29.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.
package/dist/plan/diff.js CHANGED
@@ -1,9 +1,9 @@
1
1
  import { VERSIONS, floatingTagBlocked } from '../bootstrap/versions.js';
2
- import { APP_ENTRY_KEY, ownerMailboxRecorded, ownerMailboxes } from '../org.js';
3
- import { AGORA_ACTIVATED_ENV_SCOPE_KEY, AGORA_APP_PORTS_EXPOSES, CALLS_INTROSPECTION_SCOPE_KEY, CALLS_INTROSPECTION_UNDELIVERED_SCOPE_KEY, CALLS_SERVICE_DEPLOYED_SCOPE_KEY, CHAT_INTROSPECTION_SCOPE_KEY, NO_AGORA_HOST_VALUES, ZITADEL_ORG_SCOPE_KEY, agoraEnvDrift, agoraEnvRevision, agoraHostMissing, agoraHostnameFor, callsServiceHasName, callsServiceHasTokenEnv, callsTokenEnvMovedMessage, callsTokenHostFrom, desiredAgoraEnv, withRecordedMessagingProject, } from './agora.js';
2
+ import { APP_ENTRY_KEY, ORG_POSTGRES_SERVER_SCOPE_KEY, ORG_SERVICE_SCHEMAS, ownerMailboxRecorded, ownerMailboxes, } from '../org.js';
3
+ import { AGORA_ACTIVATED_ENV_SCOPE_KEY, AGORA_APP_PORTS_EXPOSES, CALLS_INTROSPECTION_SCOPE_KEY, CALLS_INTROSPECTION_UNDELIVERED_SCOPE_KEY, CALLS_SERVICE_DEPLOYED_SCOPE_KEY, CHAT_CALLS_APPLIED_SCOPE_KEY, CHAT_CALLS_TOKEN_SCOPE_KEY, CHAT_CALLS_TOKEN_UNDELIVERED_SCOPE_KEY, CHAT_INTROSPECTION_SCOPE_KEY, NO_AGORA_HOST_VALUES, ZITADEL_ORG_SCOPE_KEY, agoraEnvDrift, agoraEnvRevision, agoraHostMissing, agoraHostnameFor, callsServiceHasName, callsServiceHasTokenEnv, callsTokenEnvMovedMessage, callsTokenHostFrom, chatCallsApplied, chatHasUpstreams, desiredAgoraEnv, withRecordedMessagingProject, } from './agora.js';
4
4
  import { BOULE_BROKER_ENTRY_KEY, BOULE_MAIL_TOKEN_ENV, bouleEnvDrift, bouleMailTokenDrift, bouleMailWanted, desiredBouleEnv, wantedBouleBrokerEntry, } from './boule.js';
5
5
  import { brokerEntryAppliedScopeKey, brokerEntryWarningsScopeKey, entryAuthEnv, entryAuthEnvAppliedScopeKey, wantedBrokerEntry, } from './broker-entries.js';
6
- import { chatInternalNames } from './chat-matrix.js';
6
+ import { chatInternalNames, chatUpstreams } from './chat-matrix.js';
7
7
  import { planEntryAiKeys } from './entry-ai-key.js';
8
8
  import { planEdgeReservedLabels, planEntryEdgeKeys } from './entry-edge-key.js';
9
9
  import { planFilesKeys } from './entry-files-key.js';
@@ -145,7 +145,12 @@ function liveResourceName(r) {
145
145
  * app-creating step below: {@link planLoginHost}, the org's own company
146
146
  * login host -- also idempotent on the *recorded* resource id, since
147
147
  * `live` has no notion of a ZITADEL trusted domain or a login binding
148
- * either. Everything
148
+ * either. #444a adds one more fact, in a different shape from every other
149
+ * addition here: {@link planOrgPostgres}, the org's one shared Postgres
150
+ * server plus whichever of `org.ts`'s `ORG_SERVICE_SCHEMAS` (nine today)
151
+ * this org actually runs and has not yet recorded -- idempotent on the
152
+ * *recorded* resource id like {@link planOwners}, but never folded into
153
+ * `plannedSteps`: its own {@link OrgPostgresPlan} field says why. Everything
149
154
  * else in `live` that none of these eighteen singletons and however many
150
155
  * declared issuer apps claim is unmanaged. The full field-by-field provisioning surface (SSO,
151
156
  * guests, calls on the org's own domain, adopted/bespoke apps, the actual
@@ -164,7 +169,7 @@ export function computePlanDetail(org, live, recordedIds = {}, agoraHost = NO_AG
164
169
  const issuer = planIssuer(org, byKey, claimed, platformReadiness.authReady, isPlatformOrg);
165
170
  if (issuer.step)
166
171
  plannedSteps.push(issuer.step);
167
- plannedSteps.push(...planOwners(org, recordedIds, platformReadiness.authReady, 'error' in agoraHost ? null : agoraHost.issuer));
172
+ plannedSteps.push(...planOwners(org, recordedIds, platformReadiness.authReady, platformReadiness.authIssuer));
168
173
  // #267: after the ZITADEL org step, before any app's own issuer
169
174
  // application. Apps created later in this run are not in the binding yet;
170
175
  // the next run replans this step and repoints them.
@@ -176,6 +181,11 @@ export function computePlanDetail(org, live, recordedIds = {}, agoraHost = NO_AG
176
181
  platformAuthHostnameFrom('error' in agoraHost ? null : agoraHost.issuer), isPlatformOrg);
177
182
  if (loginHost.step)
178
183
  plannedSteps.push(loginHost.step);
184
+ // #444a: the org's one shared Postgres server and its fixed schema
185
+ // topology -- dependent on nothing else planned here, but its own
186
+ // `OrgPostgresPlan`, never `plannedSteps` ({@link OrgPostgresPlan}'s own
187
+ // doc comment says why).
188
+ const orgPostgres = planOrgPostgres(org, recordedIds);
179
189
  // Planned ahead of everything that needs the zone live (#238); its own step
180
190
  // is pushed just below, before mail.
181
191
  const zone = planZone(org, byKey, claimed);
@@ -230,6 +240,10 @@ export function computePlanDetail(org, live, recordedIds = {}, agoraHost = NO_AG
230
240
  : callsTokenHostFrom(withRecordedMessagingProject(agoraHost, recordedIds)));
231
241
  if (callsService.step)
232
242
  plannedSteps.push(callsService.step);
243
+ // After both apps it writes to: chat's token and the calls service's chat address.
244
+ const callsChat = planCallsChat(org, recordedIds, chat.step !== null || callsService.step !== null, chat.blocked !== null || calls.blocked !== null || callsService.blocked !== null);
245
+ if (callsChat.step)
246
+ plannedSteps.push(callsChat.step);
233
247
  // #251: same mechanism as the platform's own version-change lines -- see
234
248
  // `planPlatform`'s own block in `platform-diff.ts`. Synapse and MAS never
235
249
  // patch a running app's image tag once created, so this is
@@ -507,6 +521,9 @@ export function computePlanDetail(org, live, recordedIds = {}, agoraHost = NO_AG
507
521
  unmanaged.push({ description: calls.blocked, key: 'coolify:calls' });
508
522
  if (chat.blocked)
509
523
  unmanaged.push({ description: chat.blocked, key: 'coolify:chat' });
524
+ if (callsChat.blocked) {
525
+ unmanaged.push({ description: callsChat.blocked, key: 'coolify:calls-chat' });
526
+ }
510
527
  if (callsService.blocked) {
511
528
  unmanaged.push({ description: callsService.blocked, key: 'coolify:calls-service' });
512
529
  }
@@ -541,6 +558,7 @@ export function computePlanDetail(org, live, recordedIds = {}, agoraHost = NO_AG
541
558
  plannedSteps: plannedSteps.map(withStepSystem),
542
559
  unmanaged,
543
560
  versionChanges,
561
+ orgPostgres,
544
562
  };
545
563
  }
546
564
  /**
@@ -641,12 +659,20 @@ function planIssuer(org, byKey, claimed, authReady, isPlatformOrg = false) {
641
659
  };
642
660
  }
643
661
  claimed.add(key);
662
+ // #517: both sides on the domain says nothing about which domain. The live
663
+ // issuer's recorded host differing from the declared domain's login host is
664
+ // a domain change, and moves the issuer too.
665
+ const liveHost = typeof live.config.host === 'string' ? live.config.host : null;
666
+ const domainMoved = Boolean(live.config.usesDomain) &&
667
+ desiredUsesDomain &&
668
+ Boolean(org.domain) &&
669
+ liveHost !== null &&
670
+ liveHost !== loginHostnameFor(org);
644
671
  // The platform's own org already signs in on the shared host: no move. A
645
- // live issuer that does not say (the reader never does: foundry v1 never
646
- // moves one) is not read as a different one either.
672
+ // live issuer that does not say is not read as a different one either.
647
673
  if (isPlatformOrg ||
648
674
  live.config.usesDomain === undefined ||
649
- Boolean(live.config.usesDomain) === desiredUsesDomain) {
675
+ (Boolean(live.config.usesDomain) === desiredUsesDomain && !domainMoved)) {
650
676
  return { step: null, blocked: null };
651
677
  }
652
678
  const domain = org.domain ?? 'the org domain';
@@ -925,6 +951,50 @@ function loginHostWaiting(org, host, recordedIds, bindingsWired, issuerPlanned)
925
951
  }
926
952
  return null;
927
953
  }
954
+ /**
955
+ * Whether `org` runs the service {@link schema} belongs to, so
956
+ * {@link planOrgPostgres} reports a schema missing only once the org would
957
+ * actually need it -- CMS's, chat's and calls' entries follow their own
958
+ * on/off switch, the same ones this file already gates their own
959
+ * `coolify:*` steps on (`org.ts`'s `SiteConfig.enabled`, `ChatConfig.enabled`,
960
+ * `CallsConfig.enabled`); Boule's is unconditional, matching `org.ts`'s own
961
+ * "every org gets one" rule; the app schema follows whether any
962
+ * `hosting.entries` entry declares `database: true` (`coolifyDatabase`'s own
963
+ * gate) rather than `hosting.enabled` alone, since a hosting org with no
964
+ * database-bearing entry has nothing for this schema to hold.
965
+ */
966
+ function orgServiceSchemaEnabled(org, schema) {
967
+ if (schema.schema === 'cms')
968
+ return org.site.enabled;
969
+ if (schema.schema.startsWith('chat_'))
970
+ return org.chat.enabled;
971
+ if (schema.schema.startsWith('calls_'))
972
+ return org.calls.enabled;
973
+ if (schema.schema === 'app')
974
+ return org.hosting.entries.some((entry) => entry.database);
975
+ return true;
976
+ }
977
+ /**
978
+ * #444a: reports the org's one shared Postgres server as missing until
979
+ * `recordedIds` says otherwise, and its fixed schema topology as missing for
980
+ * whichever schemas this org actually runs and has not yet recorded (see
981
+ * {@link orgServiceSchemaEnabled}) -- see {@link OrgPostgresPlan}'s own doc
982
+ * comment for why this is a `PlanDetail` field rather than `plannedSteps`
983
+ * entries. Nothing here is ever blocked (rule 2 does not apply: unlike chat
984
+ * or calls, the server and its schemas need no issuer, no ZITADEL org and no
985
+ * platform readiness to exist), and the server being missing never gates a
986
+ * schema being reported missing too -- a fresh org with every feature on
987
+ * reports both the same way #444a's own acceptance line asks for ("a fresh
988
+ * org plans one server and N schemas"), since nothing here actually depends
989
+ * on the server existing yet; that ordering is 444b's concern, once
990
+ * `plan/executor.ts` has something to run.
991
+ */
992
+ function planOrgPostgres(org, recordedIds) {
993
+ return {
994
+ serverMissing: !recordedIds[ORG_POSTGRES_SERVER_SCOPE_KEY],
995
+ missingSchemas: ORG_SERVICE_SCHEMAS.filter((schema) => orgServiceSchemaEnabled(org, schema) && !recordedIds[schema.scopeKey]),
996
+ };
997
+ }
928
998
  /** #230: restated from `executor.ts`'s `MAIL_ROLE_MAILBOXES_SCOPE_KEY`, same rule: comma-joined local parts already created. */
929
999
  const MAIL_ROLE_MAILBOXES_SCOPE_KEY = 'mail_role_mailboxes';
930
1000
  /** Restated from `executor.ts`, the same rule: the app id of the org tunnel's cloudflared connector. */
@@ -1260,13 +1330,22 @@ function planChat(org, byKey, claimed, recordedIds, reading, issuerPlanned) {
1260
1330
  }
1261
1331
  return none;
1262
1332
  }
1263
- const domain = org.domain ?? 'its domain';
1264
1333
  if (!liveUsesDomain && desiredUsesDomain) {
1334
+ // #463: `usesDomain` is never fired by `domain` alone (#7 amendment 1),
1335
+ // but it can still be turned on with no domain recorded -- the step
1336
+ // itself has nowhere to re-key onto, so this is blocked rather than
1337
+ // planned (rule 2), not left for `coolifyChatRekey` to throw on.
1338
+ if (!org.domain) {
1339
+ return {
1340
+ step: null,
1341
+ blocked: `${org.slug}'s chat is blocked, not planned: usesDomain is on but the org has no domain to re-key onto`,
1342
+ };
1343
+ }
1265
1344
  return {
1266
1345
  step: {
1267
1346
  action: { system: 'coolify', kind: 'chat', key: org.id, desired: { usesDomain: true } },
1268
1347
  step: {
1269
- description: `re-key ${org.slug}'s chat onto ${domain}, copying rooms and messages so history reads continuous`,
1348
+ description: `re-key ${org.slug}'s chat onto ${org.domain}, copying rooms and messages so history reads continuous`,
1270
1349
  destroys: true,
1271
1350
  migrates: true,
1272
1351
  },
@@ -1431,6 +1510,79 @@ function planCallsService(org, byKey, claimed, callsReady, recordedIds, callsBlo
1431
1510
  }
1432
1511
  return { step: null, blocked: null };
1433
1512
  }
1513
+ /**
1514
+ * The token that lets the org's calls service ask its chat who is in a room
1515
+ * (`executor.ts`'s `coolifyCallsChat`). Chat off: nothing, unless the calls
1516
+ * service still carries the address, which is then removed so it does not
1517
+ * point at a chat that is gone. Both on: planned until the token is delivered
1518
+ * and both apps run with it, also for an org whose chat and calls service
1519
+ * already exist. An undelivered token is reported, never minted again (rule 1).
1520
+ * Silent while chat or the calls service is itself planned or blocked: they
1521
+ * report their own.
1522
+ */
1523
+ function planCallsChat(org, recordedIds, upstreamPlanned, upstreamBlocked) {
1524
+ const none = { step: null, blocked: null };
1525
+ if (!org.calls.enabled)
1526
+ return none;
1527
+ const action = (desired) => ({
1528
+ system: 'coolify',
1529
+ kind: 'calls-chat',
1530
+ key: org.id,
1531
+ desired,
1532
+ });
1533
+ const callsServiceId = recordedIds[`coolify:calls-service:${org.id}`];
1534
+ if (!org.chat.enabled) {
1535
+ if (!callsServiceId || !recordedIds[CHAT_CALLS_APPLIED_SCOPE_KEY])
1536
+ return none;
1537
+ return {
1538
+ step: {
1539
+ action: action({ remove: true }),
1540
+ step: {
1541
+ description: `remove the chat address and token from ${org.slug}'s calls service (chat is off) and restart it`,
1542
+ destroys: false,
1543
+ migrates: false,
1544
+ },
1545
+ },
1546
+ blocked: null,
1547
+ };
1548
+ }
1549
+ const tokenId = recordedIds[CHAT_CALLS_TOKEN_UNDELIVERED_SCOPE_KEY];
1550
+ if (tokenId && !recordedIds[CHAT_CALLS_TOKEN_SCOPE_KEY]) {
1551
+ return {
1552
+ step: null,
1553
+ blocked: `${org.slug}'s calls-to-chat token is blocked, not planned: token ${tokenId} was minted but never written to both apps; remove the recorded key ${CHAT_CALLS_TOKEN_UNDELIVERED_SCOPE_KEY} to write it again (chat-service's copy is reused, none is minted)`,
1554
+ };
1555
+ }
1556
+ const guard = recordedIds[CHAT_CALLS_TOKEN_SCOPE_KEY];
1557
+ if (guard &&
1558
+ recordedIds[CHAT_CALLS_APPLIED_SCOPE_KEY] ===
1559
+ chatCallsApplied(guard, chatUpstreams(org.slug).chat)) {
1560
+ return none;
1561
+ }
1562
+ const ready = chatHasUpstreams(org, recordedIds) &&
1563
+ callsServiceId &&
1564
+ callsServiceHasName(org, recordedIds) &&
1565
+ recordedIds[CALLS_SERVICE_DEPLOYED_SCOPE_KEY];
1566
+ if (!ready && upstreamBlocked)
1567
+ return none;
1568
+ if (!ready && !upstreamPlanned) {
1569
+ return {
1570
+ step: null,
1571
+ blocked: `${org.slug}'s calls-to-chat token is blocked, not planned: chat or the calls service is not provisioned yet (apply them first)`,
1572
+ };
1573
+ }
1574
+ return {
1575
+ step: {
1576
+ action: action({}),
1577
+ step: {
1578
+ description: `give ${org.slug}'s chat service and calls service one internal token, point the calls service at chat, and redeploy both`,
1579
+ destroys: false,
1580
+ migrates: false,
1581
+ },
1582
+ },
1583
+ blocked: null,
1584
+ };
1585
+ }
1434
1586
  /**
1435
1587
  * Deduped `ai.*` permissions offered to every organisation by default
1436
1588
  * (Decision, this campaign's report): the minimum set that lets an org
@@ -712,6 +712,39 @@ export interface CallsSfuPort {
712
712
  apiSecret: string;
713
713
  } | null>;
714
714
  }
715
+ /**
716
+ * #463: the cross-homeserver copy chat's forward re-key needs (design note,
717
+ * `docs/chat-rekey-design.md`, Option 1) -- copies every room, member and
718
+ * message from the org's platform-named homeserver onto its newly
719
+ * provisioned, domain-named one, inviting members under their new ids, so
720
+ * history reads continuous once the chat service is repointed
721
+ * (`coolifyChatRekey`).
722
+ *
723
+ * Optional on {@link FoundryStepExecutorPorts}, the same shape as
724
+ * `jobsScheduler`/`edge`/`mailCopy`: nothing in the estate has proven this
725
+ * mechanism against a real Synapse and MAS pair yet (issue #463's own Human
726
+ * step), so a host without one builds the executor without it and
727
+ * `coolify:chat`'s forward re-key throws `NotBuiltError` naming the gap,
728
+ * rather than running a copy nobody has watched work.
729
+ *
730
+ * Idempotent by `fromServerName`/`toServerName`: a room or message the
731
+ * implementation already copied for this pair is skipped, so a retried or
732
+ * resumed copy never duplicates history. Never returns or throws a secret,
733
+ * token or credential -- same rule as every other port here.
734
+ */
735
+ export interface ChatRekeyPort {
736
+ copyHomeserver(input: {
737
+ orgSlug: string;
738
+ fromServerName: string;
739
+ fromInternalHost: string;
740
+ toServerName: string;
741
+ toInternalHost: string;
742
+ /** Aborted when foundry stops waiting for the copy. */
743
+ signal: AbortSignal;
744
+ }): Promise<{
745
+ roomsCopied: number;
746
+ }>;
747
+ }
715
748
  /**
716
749
  * The ZITADEL calls this executor makes. `findOrCreateOrg`/`findOrCreateProject`
717
750
  * already exist on `@wtfalch/providers/zitadel`'s `ZitadelClient` (returning
@@ -871,6 +904,16 @@ export interface ZitadelPort {
871
904
  allowSelfRegistration(orgId: string): Promise<{
872
905
  changed: boolean;
873
906
  }>;
907
+ /**
908
+ * Forces the org's login policy to `allowRegister: false`, writing the org
909
+ * its own policy even when it only inherits the instance default.
910
+ * Idempotent: a custom policy that already forbids registration is no
911
+ * write. `@wtfalch/providers` can only turn registration on, so
912
+ * `zitadel-login-policy.ts` is the real implementation.
913
+ */
914
+ closeSelfRegistration(orgId: string): Promise<{
915
+ changed: boolean;
916
+ }>;
874
917
  /** Grants another ZITADEL org access to a project (plan gap A9). Idempotent. */
875
918
  findOrCreateProjectGrant(orgId: string, projectId: string, grantedOrgId: string): Promise<{
876
919
  created: boolean;
@@ -1010,6 +1053,14 @@ export interface ZitadelPort {
1010
1053
  * the binding holds; `zitadelApp` never sets it at creation.
1011
1054
  */
1012
1055
  updateOidcAppLoginBaseUri(orgId: string, projectId: string, appId: string, loginBaseUri: string): Promise<void>;
1056
+ /**
1057
+ * Occupies `userName` in `orgId` with a machine user that has no role and
1058
+ * no token, so nobody can register under that name. Idempotent: a user
1059
+ * already holding the name, however it was made, is returned unchanged.
1060
+ */
1061
+ reserveUserName(orgId: string, userName: string): Promise<{
1062
+ userId: string;
1063
+ }>;
1013
1064
  /**
1014
1065
  * Switches on "user info inside the ID token" for an already-created app,
1015
1066
  * read-modify-write (`@wtfalch/providers`' `ZitadelClient.
@@ -1405,6 +1456,13 @@ export interface FoundryStepExecutorPorts {
1405
1456
  * compilation over one dependency nothing else needs.
1406
1457
  */
1407
1458
  mailCopy?: MailCopyPort;
1459
+ /**
1460
+ * #463: the cross-homeserver copy chat's forward re-key needs -- see
1461
+ * {@link ChatRekeyPort}'s own doc comment. Optional the same way
1462
+ * `mailCopy` is: without it `coolify:chat`'s forward re-key throws
1463
+ * `NotBuiltError` naming the gap instead of running an unproven copy.
1464
+ */
1465
+ chatRekey?: ChatRekeyPort;
1408
1466
  /** Flat, not a factory: `organisationId` is an explicit argument on every method of both ports (see each port's own doc comment; #177 dropped the `filesLimitsFor`/`aiLimitsFor` factories this used to need). */
1409
1467
  filesOrganisation?: FilesOrganisationPort;
1410
1468
  aiOrganisation?: AiOrganisationPort;
@@ -2338,6 +2396,26 @@ export declare class FoundryStepExecutor implements StepExecutor, StepDestroyer
2338
2396
  * No secret reaches an error: errors name the org and the missing input only.
2339
2397
  */
2340
2398
  private coolifyChat;
2399
+ /**
2400
+ * `coolifyChat`'s forward re-key (#463, design note
2401
+ * `docs/chat-rekey-design.md` Option 1): a live chat recorded, and
2402
+ * `org.chat.usesDomain` flipped on. Provisions a second Synapse+MAS pair
2403
+ * named for the org's own domain (`chatRekeyedInternalNames`), copies
2404
+ * every room, member and message across through
2405
+ * {@link FoundryStepExecutorPorts.chatRekey}, repoints the existing chat
2406
+ * service at the new pair, and parks -- never deletes, so a stalled
2407
+ * migration can be rolled back by simply restarting them -- the original
2408
+ * Synapse and MAS apps. The chat service and its own database are never
2409
+ * re-created; only the Matrix half of the stack moves.
2410
+ *
2411
+ * Idempotent on recorded ids: new resources are found by name before they
2412
+ * are created, the new MAS's secrets are read back from its own envs, and
2413
+ * the copy, the repoint and the park each run once
2414
+ * ({@link CHAT_REKEY_COPIED_SCOPE_KEY}, {@link CHAT_REKEY_REPOINTED_SCOPE_KEY}).
2415
+ * The original pair keeps serving until the chat service is healthy on the
2416
+ * new one; a failure before that leaves it running.
2417
+ */
2418
+ private coolifyChatRekey;
2341
2419
  /**
2342
2420
  * `plan/diff.ts`'s `planCallsService`: the org's own calls service --
2343
2421
  * `wtfalch/calls`'s `packages/service`, wired to the calls Matrix stack
@@ -2352,6 +2430,21 @@ export declare class FoundryStepExecutor implements StepExecutor, StepDestroyer
2352
2430
  * 1); its own database is never touched from that branch.
2353
2431
  */
2354
2432
  private coolifyCallsService;
2433
+ /**
2434
+ * `plan/diff.ts`'s `planCallsChat`: the one internal token that lets the
2435
+ * calls service ask chat who is in a room, and the chat address. The token
2436
+ * is `CHAT_INTERNAL_TOKEN` on chat-service and `CALLS_CHAT_TOKEN` on the
2437
+ * calls service, which gets `CALLS_CHAT_URL` in the same write: it refuses
2438
+ * to boot on one without the other. Chat-service's env is the only memory
2439
+ * of the token across a run, the way {@link ensureCallsMatrixSecrets} keeps
2440
+ * the MAS secrets on the MAS app, so it is generated only when that env has
2441
+ * none. Its id is recorded and persisted before either app holds it, and the
2442
+ * guard after both do (platform rule 1). Both apps are redeployed last; the
2443
+ * applied marker is recorded after that, so a run that fails in between is
2444
+ * planned and finished again. `remove` (chat is off) deletes both vars from
2445
+ * the calls service and restarts it.
2446
+ */
2447
+ private coolifyCallsChat;
2355
2448
  /**
2356
2449
  * The seven ZITADEL token env vars the calls service needs to accept
2357
2450
  * Agora's sign-in token (`calls/docs/member-auth.md`). Only called with all