@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.
@@ -29,6 +29,22 @@ export function chatUpstreams(orgSlug) {
29
29
  mas: `http://${names.mas}:${CHAT_MAS_PORT}`,
30
30
  };
31
31
  }
32
+ /**
33
+ * Internal names for the org's re-keyed homeserver pair (#463, design note
34
+ * `docs/chat-rekey-design.md` Option 1): a second Synapse and MAS,
35
+ * provisioned beside the original pair under `chatInternalNames` so both
36
+ * ends of the copy are live at once. The chat service and its own database
37
+ * are never re-created — same app, repointed once the copy is done and the
38
+ * original pair is parked.
39
+ */
40
+ export function chatRekeyedInternalNames(orgSlug) {
41
+ return {
42
+ synapse: `${orgSlug}-chat-synapse-2`,
43
+ mas: `${orgSlug}-chat-mas-2`,
44
+ synapseDb: `${orgSlug}-chat-synapse-2-db`,
45
+ masDb: `${orgSlug}-chat-mas-2-db`,
46
+ };
47
+ }
32
48
  /**
33
49
  * Never resolved by DNS and never federates; the identifier Synapse, MAS and
34
50
  * the chat service agree on. A level-1 org's chat lives on this name until a
@@ -38,18 +54,33 @@ export function chatUpstreams(orgSlug) {
38
54
  export function chatServerName(orgSlug) {
39
55
  return `chat-${orgSlug}.internal`;
40
56
  }
41
- /** The Matrix user the chat service acts as (`chat/apps/chat-service/src/workspace.ts`). */
42
- export function chatAdminUser(orgSlug) {
43
- return `@chat-admin:${chatServerName(orgSlug)}`;
57
+ /**
58
+ * The Matrix user the chat service acts as
59
+ * (`chat/apps/chat-service/src/workspace.ts`). `serverName` defaults to the
60
+ * org's level-1 `chatServerName`; a re-key (#463) passes the new one so the
61
+ * gate module's `admin_user` matches the homeserver it is loaded into.
62
+ */
63
+ export function chatAdminUser(orgSlug, serverName = chatServerName(orgSlug)) {
64
+ return `@chat-admin:${serverName}`;
44
65
  }
45
66
  const q = (value) => JSON.stringify(value);
67
+ const SERVER_NAME_PATTERN = /^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?(\.[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?)+$/;
68
+ /** A re-key's `server_name` is a lowercase DNS name with at least two labels; anything else would be written into both configs. */
69
+ export function assertChatServerName(serverName) {
70
+ if (serverName.length > 253 || !SERVER_NAME_PATTERN.test(serverName)) {
71
+ throw new Error('chat server_name must be a lowercase domain name');
72
+ }
73
+ }
46
74
  /** `homeserver.yaml`: federation off, presence on (stable Synapse features only -- no MSC3026 `busy`, foundry#41), MAS for auth, the chat gate module on. */
47
75
  export function chatSynapseHomeserverYaml(input) {
76
+ const serverName = input.serverName ?? chatServerName(input.orgSlug);
77
+ if (input.serverName !== undefined)
78
+ assertChatServerName(serverName);
48
79
  return [
49
80
  '# Generated by @wtfalch/foundry (executor.ts, coolify:chat). Do not edit',
50
81
  '# by hand; re-run apply instead -- it will not rotate secrets that exist.',
51
82
  '',
52
- `server_name: ${q(chatServerName(input.orgSlug))}`,
83
+ `server_name: ${q(serverName)}`,
53
84
  'pid_file: /data/homeserver.pid',
54
85
  'report_stats: false',
55
86
  '',
@@ -106,13 +137,16 @@ export function chatSynapseHomeserverYaml(input) {
106
137
  'modules:',
107
138
  ' - module: wtfalch_chat_gate.ChatGate',
108
139
  ' config:',
109
- ` admin_user: ${q(chatAdminUser(input.orgSlug))}`,
140
+ ` admin_user: ${q(chatAdminUser(input.orgSlug, serverName))}`,
110
141
  ' allow_encrypted_channels: false',
111
142
  '',
112
143
  ].join('\n');
113
144
  }
114
145
  /** MAS `config.yaml`: password login and registration off; the chat service is the one client, with admin. */
115
146
  export function chatMasConfigYaml(input) {
147
+ const serverName = input.serverName ?? chatServerName(input.orgSlug);
148
+ if (input.serverName !== undefined)
149
+ assertChatServerName(serverName);
116
150
  const indentKey = (pem, spaces) => pem
117
151
  .trimEnd()
118
152
  .split('\n')
@@ -150,7 +184,7 @@ export function chatMasConfigYaml(input) {
150
184
  '',
151
185
  'matrix:',
152
186
  ' kind: synapse',
153
- ` homeserver: ${q(chatServerName(input.orgSlug))}`,
187
+ ` homeserver: ${q(serverName)}`,
154
188
  ` endpoint: ${q(`http://${input.synapseHost}:${CHAT_SYNAPSE_PORT}`)}`,
155
189
  ` secret: ${q(input.sharedSecret)}`,
156
190
  '',
@@ -1,4 +1,4 @@
1
- import type { DeclaredOrg, OrgId } from '../org.js';
1
+ import type { DeclaredOrg, OrgId, OrgServiceSchema } from '../org.js';
2
2
  import type { Plan, PlanStep, UnmanagedItem, VersionChangeItem } from '../plan.js';
3
3
  import { type AgoraHostReading } from './agora.js';
4
4
  import type { LiveResource } from './live.js';
@@ -64,6 +64,8 @@ export interface PlanDetail {
64
64
  unmanaged: UnmanagedItem[];
65
65
  /** #251: see `VersionChangeItem` (`../plan.ts`). Never folded into `plannedSteps`. */
66
66
  versionChanges: VersionChangeItem[];
67
+ /** #444a: see {@link OrgPostgresPlan}. Never folded into `plannedSteps`, for the same reason `versionChanges` above is not. */
68
+ orgPostgres: OrgPostgresPlan;
67
69
  }
68
70
  /** The `Plan` (#19's public contract) a `PlanDetail` carries. */
69
71
  export declare function toPlan(detail: PlanDetail): Plan;
@@ -110,7 +112,12 @@ export declare function toPlan(detail: PlanDetail): Plan;
110
112
  * app-creating step below: {@link planLoginHost}, the org's own company
111
113
  * login host -- also idempotent on the *recorded* resource id, since
112
114
  * `live` has no notion of a ZITADEL trusted domain or a login binding
113
- * either. Everything
115
+ * either. #444a adds one more fact, in a different shape from every other
116
+ * addition here: {@link planOrgPostgres}, the org's one shared Postgres
117
+ * server plus whichever of `org.ts`'s `ORG_SERVICE_SCHEMAS` (nine today)
118
+ * this org actually runs and has not yet recorded -- idempotent on the
119
+ * *recorded* resource id like {@link planOwners}, but never folded into
120
+ * `plannedSteps`: its own {@link OrgPostgresPlan} field says why. Everything
114
121
  * else in `live` that none of these eighteen singletons and however many
115
122
  * declared issuer apps claim is unmanaged. The full field-by-field provisioning surface (SSO,
116
123
  * guests, calls on the org's own domain, adopted/bespoke apps, the actual
@@ -128,6 +135,42 @@ export declare function computePlanDetail(org: DeclaredOrg, live: LiveResource[]
128
135
  * its login host equal to the shared ZITADEL instance's own.
129
136
  */
130
137
  export declare function isPlatformOwnOrg(org: DeclaredOrg, platformApex: string | null): boolean;
138
+ /**
139
+ * What {@link planOrgPostgres} reports about the org's one shared Postgres
140
+ * server and its fixed schema topology (#444a, "manifest-and-plan-only"):
141
+ * missing only, nothing applied. Deliberately **not** folded into
142
+ * `plannedSteps` -- the same reason {@link VersionChangeItem} (`../plan.ts`)
143
+ * never is, restated in `computePlanDetail`'s own doc comment below, but
144
+ * sharper here: `plan-apply.ts`'s `runSteps` executes every
145
+ * `detail.plannedSteps` entry through `StepExecutor.execute`, which
146
+ * switches on `` `${system}:${kind}` `` and throws on anything it has no
147
+ * case for. `plan/executor.ts` is untouched this slice (444a's own
148
+ * acceptance line), so a `'coolify:postgres-server'`/`'coolify:postgres-
149
+ * schema'` entry in `plannedSteps` would not sit there harmlessly the way
150
+ * an unapplied field does -- the next real `apply()` would reach it and
151
+ * throw `FoundryStepExecutor has no dispatch for coolify:postgres-server`,
152
+ * for every org, immediately. This field is how 444a reports the same
153
+ * "missing" fact to a caller of `computePlanDetail` directly without that
154
+ * happening; wiring it into the public `Plan` (`toPlan` below) and into
155
+ * real `plannedSteps` entries is 444b's job, once `plan/executor.ts` has a
156
+ * dispatch for them.
157
+ */
158
+ export interface OrgPostgresPlan {
159
+ /** True until the org's one shared Postgres server is recorded under {@link ORG_POSTGRES_SERVER_SCOPE_KEY}. */
160
+ serverMissing: boolean;
161
+ /**
162
+ * Every `org.ts` {@link ORG_SERVICE_SCHEMAS} entry this org actually runs
163
+ * and has not yet recorded under its own `scopeKey`, in that constant's
164
+ * own order -- see {@link orgServiceSchemaEnabled} for which entries that
165
+ * is. Boule's is always included, the same way `ORG_SERVICE_SCHEMAS`
166
+ * itself always carries it; CMS's, chat's and calls' sit behind their own
167
+ * feature switch, and the app schema behind whether any hosting entry
168
+ * declares a database. A schema this org does not run yet is never
169
+ * reported missing, even though {@link ORG_SERVICE_SCHEMAS} still names
170
+ * it as part of the full topology.
171
+ */
172
+ missingSchemas: readonly OrgServiceSchema[];
173
+ }
131
174
  /** Restated from `executor.ts`, the same rule: the id of an ai key minted but not delivered. */
132
175
  export declare const AI_KEY_UNDELIVERED_SCOPE_KEY = "ai_key_undelivered";
133
176
  /** Restated from `executor.ts`, the same rule: the id of a jobs project registered but not delivered. */
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';
4
- import { BOULE_BROKER_ENTRY_KEY, bouleEnvDrift, desiredBouleEnv, wantedBouleBrokerEntry, } from './boule.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
+ 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
@@ -398,7 +412,7 @@ export function computePlanDetail(org, live, recordedIds = {}, agoraHost = NO_AG
398
412
  const bouleGuarded = guardApp(bouleClient);
399
413
  if (bouleGuarded)
400
414
  plannedSteps.push(bouleGuarded);
401
- const boule = planBoule(org, byKey, claimed, recordedIds, authIssuer, options.otherSlugs ?? [], options.archonUrl ?? null);
415
+ const boule = planBoule(org, byKey, claimed, recordedIds, authIssuer, options.otherSlugs ?? [], options.archonUrl ?? null, platformReadiness);
402
416
  if (boule.step)
403
417
  plannedSteps.push(boule.step);
404
418
  // After the apps and Boule -- an edge key needs its app recorded or planned,
@@ -468,6 +482,9 @@ export function computePlanDetail(org, live, recordedIds = {}, agoraHost = NO_AG
468
482
  unmanaged.push({ description: agora.blocked, key: 'coolify:agora' });
469
483
  if (boule.blocked)
470
484
  unmanaged.push({ description: boule.blocked, key: 'coolify:boule' });
485
+ if (boule.mailBlocked) {
486
+ unmanaged.push({ description: boule.mailBlocked, key: 'coolify:boule:mail' });
487
+ }
471
488
  // Same for an ai key minted but never delivered: planning a new mint would
472
489
  // orphan a second live key.
473
490
  if (aiKey.blocked)
@@ -504,6 +521,9 @@ export function computePlanDetail(org, live, recordedIds = {}, agoraHost = NO_AG
504
521
  unmanaged.push({ description: calls.blocked, key: 'coolify:calls' });
505
522
  if (chat.blocked)
506
523
  unmanaged.push({ description: chat.blocked, key: 'coolify:chat' });
524
+ if (callsChat.blocked) {
525
+ unmanaged.push({ description: callsChat.blocked, key: 'coolify:calls-chat' });
526
+ }
507
527
  if (callsService.blocked) {
508
528
  unmanaged.push({ description: callsService.blocked, key: 'coolify:calls-service' });
509
529
  }
@@ -538,6 +558,7 @@ export function computePlanDetail(org, live, recordedIds = {}, agoraHost = NO_AG
538
558
  plannedSteps: plannedSteps.map(withStepSystem),
539
559
  unmanaged,
540
560
  versionChanges,
561
+ orgPostgres,
541
562
  };
542
563
  }
543
564
  /**
@@ -638,12 +659,20 @@ function planIssuer(org, byKey, claimed, authReady, isPlatformOrg = false) {
638
659
  };
639
660
  }
640
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);
641
671
  // The platform's own org already signs in on the shared host: no move. A
642
- // live issuer that does not say (the reader never does: foundry v1 never
643
- // 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.
644
673
  if (isPlatformOrg ||
645
674
  live.config.usesDomain === undefined ||
646
- Boolean(live.config.usesDomain) === desiredUsesDomain) {
675
+ (Boolean(live.config.usesDomain) === desiredUsesDomain && !domainMoved)) {
647
676
  return { step: null, blocked: null };
648
677
  }
649
678
  const domain = org.domain ?? 'the org domain';
@@ -922,6 +951,50 @@ function loginHostWaiting(org, host, recordedIds, bindingsWired, issuerPlanned)
922
951
  }
923
952
  return null;
924
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
+ }
925
998
  /** #230: restated from `executor.ts`'s `MAIL_ROLE_MAILBOXES_SCOPE_KEY`, same rule: comma-joined local parts already created. */
926
999
  const MAIL_ROLE_MAILBOXES_SCOPE_KEY = 'mail_role_mailboxes';
927
1000
  /** Restated from `executor.ts`, the same rule: the app id of the org tunnel's cloudflared connector. */
@@ -1257,13 +1330,22 @@ function planChat(org, byKey, claimed, recordedIds, reading, issuerPlanned) {
1257
1330
  }
1258
1331
  return none;
1259
1332
  }
1260
- const domain = org.domain ?? 'its domain';
1261
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
+ }
1262
1344
  return {
1263
1345
  step: {
1264
1346
  action: { system: 'coolify', kind: 'chat', key: org.id, desired: { usesDomain: true } },
1265
1347
  step: {
1266
- 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`,
1267
1349
  destroys: true,
1268
1350
  migrates: true,
1269
1351
  },
@@ -1428,6 +1510,79 @@ function planCallsService(org, byKey, claimed, callsReady, recordedIds, callsBlo
1428
1510
  }
1429
1511
  return { step: null, blocked: null };
1430
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
+ }
1431
1586
  /**
1432
1587
  * Deduped `ai.*` permissions offered to every organisation by default
1433
1588
  * (Decision, this campaign's report): the minimum set that lets an org
@@ -2027,7 +2182,7 @@ function planBouleClient(org, byKey, claimed) {
2027
2182
  * address (the one Agora signs in at); without it the Boule is blocked, not
2028
2183
  * planned, so every other step still applies.
2029
2184
  */
2030
- function planBoule(org, byKey, claimed, recordedIds, issuer, otherSlugs, archonUrl) {
2185
+ function planBoule(org, byKey, claimed, recordedIds, issuer, otherSlugs, archonUrl, readiness) {
2031
2186
  const key = resourceKey('coolify', 'boule', org.id);
2032
2187
  const live = byKey.get(key);
2033
2188
  if (live)
@@ -2042,26 +2197,59 @@ function planBoule(org, byKey, claimed, recordedIds, issuer, otherSlugs, archonU
2042
2197
  if (tooLong.length > 0) {
2043
2198
  return {
2044
2199
  step: null,
2200
+ mailBlocked: null,
2045
2201
  blocked: `${org.slug}'s Boule is blocked, not planned: ${tooLong.map(([n, what]) => `${what} '${n}' is over 63 characters`).join(', ')}; shorten the slug`,
2046
2202
  };
2047
2203
  }
2048
2204
  if (otherSlugs.includes(`${org.slug}-boule`)) {
2049
2205
  return {
2050
2206
  step: null,
2207
+ mailBlocked: null,
2051
2208
  blocked: `${org.slug}'s Boule is blocked, not planned: another declared org has the slug '${org.slug}-boule', which is this Boule's hostname`,
2052
2209
  };
2053
2210
  }
2054
2211
  if (!issuer) {
2055
2212
  return {
2056
2213
  step: null,
2214
+ mailBlocked: null,
2057
2215
  blocked: `${org.slug}'s Boule is blocked, not planned: the host supplies no issuer`,
2058
2216
  };
2059
2217
  }
2218
+ // Mail env, only for a hosted-mail org whose domain is recorded. Blocked,
2219
+ // not failing: while the platform cannot yet supply the token, Boule is
2220
+ // planned without mail and keeps showing the invite link.
2221
+ const mailWanted = bouleMailWanted(org, recordedIds);
2222
+ const mailDomain = recordedIds.mail_domain?.toLowerCase();
2223
+ const isApexOrg = Boolean(mailDomain && readiness.platformApex && mailDomain === readiness.platformApex.toLowerCase());
2224
+ let mailBlockedReason = null;
2225
+ if (mailWanted && isApexOrg) {
2226
+ if (!readiness.platformLettermintProjectId) {
2227
+ mailBlockedReason = 'platform.mail.lettermintProjectId is not declared yet';
2228
+ }
2229
+ else if (readiness.mailKeyHeld !== true) {
2230
+ mailBlockedReason = `the platform's Lettermint key is not held yet (held-key ref ${PLATFORM_AUTH_BROKER_MAIL_KEY_REF})`;
2231
+ }
2232
+ }
2233
+ else if (mailWanted) {
2234
+ mailBlockedReason = edgeNeeded(readiness, ['zones', 'dns']);
2235
+ }
2236
+ const mailBlocked = mailBlockedReason
2237
+ ? `${org.slug}'s Boule mail is blocked, not planned: ${mailBlockedReason}`
2238
+ : null;
2239
+ const mailOn = mailWanted && !mailBlocked;
2060
2240
  const action = {
2061
2241
  system: 'coolify',
2062
2242
  kind: 'boule',
2063
2243
  key: org.id,
2064
- desired: { issuer, archonUrl },
2244
+ // The platform values go in like the app step's: the executor writes
2245
+ // what the plan showed and refuses a plan that carries no `platformApex`.
2246
+ desired: {
2247
+ issuer,
2248
+ archonUrl,
2249
+ platformApex: readiness.platformApex,
2250
+ platformLettermintProjectId: readiness.platformLettermintProjectId,
2251
+ mailBlocked: mailBlocked !== null,
2252
+ },
2065
2253
  };
2066
2254
  if (!live) {
2067
2255
  return {
@@ -2070,13 +2258,19 @@ function planBoule(org, byKey, claimed, recordedIds, issuer, otherSlugs, archonU
2070
2258
  step: { description: `provision ${org.slug}'s Boule`, destroys: false, migrates: false },
2071
2259
  },
2072
2260
  blocked: null,
2261
+ mailBlocked,
2073
2262
  };
2074
2263
  }
2075
2264
  const liveEnv = (live.config.env ?? {});
2076
- const { env } = desiredBouleEnv(org, recordedIds, issuer, archonUrl);
2265
+ const { env } = desiredBouleEnv(org, recordedIds, issuer, archonUrl, mailOn);
2077
2266
  const drift = bouleEnvDrift(env, liveEnv);
2267
+ // The token is compared, never planned with its value.
2268
+ if (mailOn &&
2269
+ bouleMailTokenDrift(liveEnv[BOULE_MAIL_TOKEN_ENV], live.config.appMailToken)) {
2270
+ drift.push(BOULE_MAIL_TOKEN_ENV);
2271
+ }
2078
2272
  if (drift.length === 0)
2079
- return { step: null, blocked: null };
2273
+ return { step: null, blocked: null, mailBlocked };
2080
2274
  return {
2081
2275
  step: {
2082
2276
  action,
@@ -2087,6 +2281,7 @@ function planBoule(org, byKey, claimed, recordedIds, issuer, otherSlugs, archonU
2087
2281
  },
2088
2282
  },
2089
2283
  blocked: null,
2284
+ mailBlocked,
2090
2285
  };
2091
2286
  }
2092
2287
  /**