@wtfalch/foundry 0.11.0 → 0.13.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,12 +1,11 @@
1
1
  import { VERSIONS, floatingTagBlocked } from '../bootstrap/versions.js';
2
2
  import { APP_ENTRY_KEY } from '../org.js';
3
- import { adoptedConnectorKey, adoptedResources, adoptedTunnelKey, declaredAdoptUuids, isActiveSnapshot, protectsKey, } from './adopt.js';
4
- import { NO_AGORA_HOST_VALUES, ZITADEL_ORG_SCOPE_KEY, agoraEnvDrift, agoraHostMissing, desiredAgoraEnv, } from './agora.js';
3
+ import { NO_AGORA_HOST_VALUES, ZITADEL_ORG_SCOPE_KEY, agoraEnvDrift, agoraHostMissing, callsServiceHasName, desiredAgoraEnv, } from './agora.js';
5
4
  import { brokerEntryAppliedScopeKey, brokerEntryWarningsScopeKey, entryAuthEnv, entryAuthEnvAppliedScopeKey, wantedBrokerEntry, } from './broker-entries.js';
6
5
  import { appHostname, entryHostnames, tunnelDnsHostnames } from './hostnames.js';
7
6
  import { LOGIN_BINDING_SCOPE_KEY, loginBindingState, loginHostClientIds, loginHostnameFor, pendingRemovalHosts, } from './login-host.js';
8
7
  import { MAIL_COPY_COMPLETED_SCOPE_KEY, MAIL_COPY_DONE_SCOPE_KEY, parseMailCopyCompleted, } from './mail-copy.js';
9
- import { CALLS_MAS_APPLIED_IMAGE_TAG_SCOPE_KEY, CALLS_SYNAPSE_APPLIED_IMAGE_TAG_SCOPE_KEY, ORG_MAIL_APPLIED_APEX_SCOPE_KEY, ORG_MAIL_APPLIED_HOST_SCOPE_KEY, appDeployPendingScopeKey, appEnvAppliedScopeKey, appSettingsOf, appStepKey, appliedAppDomains, appliedAppSettings, dbEnvAppliedScopeKey, resolveAppEnv, versionNotAppliedLine, } from './platform-diff.js';
8
+ import { CALLS_MAS_APPLIED_IMAGE_TAG_SCOPE_KEY, CALLS_SYNAPSE_APPLIED_IMAGE_TAG_SCOPE_KEY, ORG_MAIL_APPLIED_APEX_SCOPE_KEY, ORG_MAIL_APPLIED_HOST_SCOPE_KEY, appEnvAppliedScopeKey, appSettingsOf, appStepKey, appliedAppDomains, appliedAppSettings, dbEnvAppliedScopeKey, resolveAppEnv, versionNotAppliedLine, } from './platform-diff.js';
10
9
  import { DEFAULT_ORG_PLATFORM_READINESS } from './platform-org.js';
11
10
  /** The `Plan` (#19's public contract) a `PlanDetail` carries. */
12
11
  export function toPlan(detail) {
@@ -118,7 +117,7 @@ export function computePlanDetail(org, live, recordedIds = {}, agoraHost = NO_AG
118
117
  // (`executor.ts`'s `coolifyCallsService`) reads the Synapse/MAS addresses
119
118
  // and MAS client credentials `coolifyCalls` just recorded, the same
120
119
  // ordering `planOwner` relies on for `zitadel:issuer`.
121
- const callsService = planCallsService(org, byKey, claimed, platformReadiness.callsReady);
120
+ const callsService = planCallsService(org, byKey, claimed, platformReadiness.callsReady, recordedIds, calls.blocked !== null);
122
121
  if (callsService.step)
123
122
  plannedSteps.push(callsService.step);
124
123
  // #251: same mechanism as the platform's own version-change lines -- see
@@ -174,10 +173,6 @@ export function computePlanDetail(org, live, recordedIds = {}, agoraHost = NO_AG
174
173
  plannedSteps.push(customers);
175
174
  plannedSteps.push(...planIssuerApps(org, byKey, claimed));
176
175
  plannedSteps.push(...planEntryClients(org, byKey, claimed));
177
- // #311: the snapshot of each adopted resource, before the database and app
178
- // steps that first write to them.
179
- const adoption = planAdoptions(org, options.adoption);
180
- plannedSteps.push(...adoption.steps);
181
176
  // The Access gate before the database, app, tunnel and zone (#84): on a
182
177
  // first apply the hostname is gated before anything answers on it.
183
178
  plannedSteps.push(...planAccess(org, recordedIds));
@@ -187,90 +182,16 @@ export function computePlanDetail(org, live, recordedIds = {}, agoraHost = NO_AG
187
182
  // string into the app's environment once the database exists -- it can
188
183
  // only do that if the database step already ran and recorded the
189
184
  // database's resource id.
190
- // A park of an adopted resource is reported, never planned (#311).
191
- const parks = [];
192
- const notAdoptedPark = (s) => {
193
- const held = adoptedPark(org, options.adoption?.adopted ?? [], recordedIds, s);
194
- if (held)
195
- parks.push(held);
196
- return !held;
197
- };
198
- // An adopt step plans its resource's step after it, whatever the markers
199
- // say: the adopted resource has not been configured by foundry yet.
200
- const adopting = new Set(adoption.steps.map((s) => s.action.key));
201
- const database = planDatabase(org, byKey, claimed) ??
202
- (adopting.has(`coolify:database:${org.id}`)
203
- ? {
204
- action: {
205
- system: 'coolify',
206
- kind: 'database',
207
- key: org.id,
208
- desired: { enabled: true },
209
- },
210
- step: {
211
- description: `record ${org.slug}'s adopted database`,
212
- destroys: false,
213
- migrates: false,
214
- },
215
- }
216
- : null);
217
- if (database &&
218
- notAdoptedPark(database) &&
219
- !adoption.withheld.has(`coolify:database:${org.id}`)) {
185
+ const database = planDatabase(org, byKey, claimed);
186
+ if (database)
220
187
  plannedSteps.push(database);
221
- }
222
188
  const authIssuer = 'error' in agoraHost ? null : agoraHost.issuer;
223
- const app = planApps(org, byKey, claimed, recordedIds, plannedSteps, adopting, authIssuer);
224
- plannedSteps.push(...app.steps.filter((s) => notAdoptedPark(s) && !adoption.withheld.has(`coolify:app:${s.action.key}`)));
189
+ const app = planApps(org, byKey, claimed, recordedIds, plannedSteps, authIssuer);
190
+ plannedSteps.push(...app.steps);
225
191
  // After the apps: the broker entry's key is minted into each app's env.
226
192
  const brokerEntries = planBrokerEntries(org, recordedIds, plannedSteps, options.broker ?? false);
227
193
  plannedSteps.push(...brokerEntries.steps);
228
- // An adopt step of the tunnel or its connector plans the tunnel step after
229
- // it, whatever the markers say: foundry has not merged its routes yet (#312).
230
- const tunnelKey = adoptedTunnelKey(org);
231
- const connectorKey = adoptedConnectorKey(org);
232
- const tunnelHeld = adoption.withheld.has(tunnelKey) || adoption.withheld.has(connectorKey);
233
- const planned = planTunnel(org, byKey, claimed, recordedIds);
234
- const merging = org.hosting.adopt?.tunnel !== undefined &&
235
- (planned !== null || adopting.has(tunnelKey) || adopting.has(connectorKey));
236
- const tunnelPlanned = merging && planned?.action.desired.enabled !== false
237
- ? {
238
- action: {
239
- system: 'cloudflare',
240
- kind: 'tunnel',
241
- key: org.id,
242
- desired: { enabled: true },
243
- },
244
- step: {
245
- description: `merge ${org.slug}'s routes into its adopted Cloudflare tunnel and keep the others`,
246
- destroys: false,
247
- migrates: false,
248
- },
249
- }
250
- : planned;
251
- // An adopted tunnel with no active snapshot and no adopt step in this plan
252
- // (hosting off plans none) cannot be written to: reported blocked, no tunnel
253
- // step, or the step would throw on every apply (rule 2, #312).
254
- const adoptedRows = options.adoption?.adopted ?? [];
255
- const activeRow = (key, uuid) => isActiveSnapshot(adoptedRows.find((r) => r.resourceKey === key), uuid ?? '');
256
- const adoptable = org.hosting.adopt?.tunnel !== undefined &&
257
- !tunnelHeld &&
258
- !adopting.has(tunnelKey) &&
259
- !adopting.has(connectorKey) &&
260
- (!org.hosting.enabled ||
261
- !(activeRow(tunnelKey, org.hosting.adopt.tunnel) &&
262
- activeRow(connectorKey, org.hosting.adopt.connectorApp)));
263
- if (adoptable) {
264
- adoption.blocked.push({
265
- description: `${org.slug}'s ${tunnelKey} is blocked, not planned: hosting is off, so its routes are not merged into the adopted tunnel`,
266
- key: `coolify:adopt:${tunnelKey}`,
267
- });
268
- }
269
- // Neither a snapshot-less adopted tunnel nor an undeclared one is written to
270
- // or parked (#312); a blocked adoption plans no tunnel step (rule 2).
271
- const tunnel = tunnelPlanned && notAdoptedPark(tunnelPlanned) && !tunnelHeld && !adoptable
272
- ? tunnelPlanned
273
- : null;
194
+ const tunnel = planTunnel(org, byKey, claimed, recordedIds);
274
195
  // #238: a tunnel that is being provisioned waits for an active zone; parking
275
196
  // one never does.
276
197
  if (tunnel && zoneWaiting && tunnel.action.desired.enabled !== false) {
@@ -371,7 +292,6 @@ export function computePlanDetail(org, live, recordedIds = {}, agoraHost = NO_AG
371
292
  unmanaged.push(...zoneBlocked);
372
293
  // #243: an appEnv source nothing will record is reported, not silently skipped.
373
294
  unmanaged.push(...app.blocked);
374
- unmanaged.push(...adoption.blocked, ...parks);
375
295
  unmanaged.push(...brokerEntries.blocked);
376
296
  return { orgId: org.id, plannedSteps, unmanaged, versionChanges };
377
297
  }
@@ -911,15 +831,41 @@ function planCalls(org, byKey, claimed, callsReady) {
911
831
  * stack it rides beside is itself blocked on `callsReady` -- {@link planCalls}
912
832
  * already reports the platform's shared SFU as missing once.
913
833
  */
914
- function planCallsService(org, byKey, claimed, callsReady) {
834
+ function planCallsService(org, byKey, claimed, callsReady, recordedIds, callsBlocked) {
915
835
  const key = resourceKey('coolify', 'calls-service', org.id);
916
836
  if (org.calls.enabled && !byKey.get(key) && !callsReady) {
917
837
  return { step: null, blocked: null };
918
838
  }
919
- return {
920
- step: planHostingToggle('coolify', 'calls-service', org.calls.enabled, org, byKey, claimed, () => `provision ${org.slug}'s calls service`, () => `park ${org.slug}'s calls service (stops serving; data is retained, not deleted -- #7 corrections 1)`),
921
- blocked: null,
922
- };
839
+ const toggle = planHostingToggle('coolify', 'calls-service', org.calls.enabled, org, byKey, claimed, () => `provision ${org.slug}'s calls service`, () => `park ${org.slug}'s calls service (stops serving; data is retained, not deleted -- #7 corrections 1)`);
840
+ if (toggle)
841
+ return { step: toggle, blocked: null };
842
+ // A live, enabled app made before the internal name existed (or under an
843
+ // old slug) has no step from the toggle, yet Agora's address needs the
844
+ // name. Run the step again until the executor has recorded the marker, but
845
+ // only while it can succeed: the SFU ready and the Matrix stack not blocked.
846
+ if (org.calls.enabled &&
847
+ callsReady &&
848
+ !callsBlocked &&
849
+ byKey.has(key) &&
850
+ !callsServiceHasName(org, recordedIds)) {
851
+ return {
852
+ step: {
853
+ action: {
854
+ system: 'coolify',
855
+ kind: 'calls-service',
856
+ key: org.id,
857
+ desired: { enabled: true },
858
+ },
859
+ step: {
860
+ description: `set the calls service's internal name for ${org.slug}`,
861
+ destroys: false,
862
+ migrates: false,
863
+ },
864
+ },
865
+ blocked: null,
866
+ };
867
+ }
868
+ return { step: null, blocked: null };
923
869
  }
924
870
  /**
925
871
  * Deduped `ai.*` permissions offered to every organisation by default
@@ -1411,117 +1357,6 @@ function planAgora(org, byKey, claimed, recordedIds, reading) {
1411
1357
  blocked: null,
1412
1358
  };
1413
1359
  }
1414
- /**
1415
- * #311: one `coolify:adopt` step per adopted resource that has no adoption row
1416
- * yet. It `migrates`, so apply needs the org's name typed. With no vault and
1417
- * store wired, each is reported blocked instead, and `withheld` names the
1418
- * resources whose own step must not plan either: it would write to a live
1419
- * resource nothing has snapshotted (rule 2).
1420
- */
1421
- function planAdoptions(org, adoption) {
1422
- const steps = [];
1423
- const blocked = [];
1424
- const withheld = new Set();
1425
- for (const resource of adoptedResources(org)) {
1426
- const what = resource.kind === 'app'
1427
- ? `${resource.entry.key} app`
1428
- : resource.kind === 'connector'
1429
- ? 'cloudflared connector'
1430
- : resource.kind;
1431
- if (!adoption?.wired) {
1432
- withheld.add(resource.resourceKey);
1433
- blocked.push({
1434
- description: `${org.slug}'s adoption of its ${what} is blocked, not planned: no adoption vault is wired`,
1435
- key: `coolify:adopt:${resource.resourceKey}`,
1436
- });
1437
- continue;
1438
- }
1439
- const row = adoption.adopted.find((r) => r.resourceKey === resource.resourceKey);
1440
- // A new snapshot of another uuid would record foundry's own earlier writes.
1441
- if (row?.state === 'adopted' && row.settings.uuid !== resource.uuid) {
1442
- withheld.add(resource.resourceKey);
1443
- blocked.push({
1444
- description: `${org.slug}'s ${resource.resourceKey} is adopted as ${String(row.settings.uuid)}, not ${resource.uuid}: restore the adopted ${resource.kind} first, not planned`,
1445
- key: `coolify:adopt:${resource.resourceKey}`,
1446
- });
1447
- continue;
1448
- }
1449
- if (isActiveSnapshot(row, resource.uuid))
1450
- continue;
1451
- // A snapshot of a uuid another key holds would record foundry's own writes.
1452
- const holder = adoption.adopted.find((r) => r.resourceKey !== resource.resourceKey &&
1453
- protectsKey(r) &&
1454
- r.settings.uuid === resource.uuid);
1455
- if (holder) {
1456
- withheld.add(resource.resourceKey);
1457
- blocked.push({
1458
- description: `${org.slug}'s ${resource.resourceKey} declares ${resource.uuid}, which ${holder.resourceKey} already adopted: declare it under one key, not planned`,
1459
- key: `coolify:adopt:${resource.resourceKey}`,
1460
- });
1461
- continue;
1462
- }
1463
- steps.push({
1464
- action: {
1465
- system: 'coolify',
1466
- kind: 'adopt',
1467
- key: resource.resourceKey,
1468
- desired: { uuid: resource.uuid },
1469
- },
1470
- step: {
1471
- description: `adopt ${org.slug}'s ${what} (Coolify ${resource.uuid}): snapshot it, then change only what the org declares`,
1472
- destroys: false,
1473
- migrates: true,
1474
- },
1475
- });
1476
- }
1477
- // An entry that was adopted and no longer declares `adopt` is not managed
1478
- // as foundry's own app or database (rule 2), restored or not.
1479
- const held = (key) => adoption?.adopted.some((r) => r.resourceKey === key && protectsKey(r));
1480
- const undeclared = [
1481
- ...(org.hosting.enabled
1482
- ? [
1483
- ...(org.hosting.adopt?.database ? [] : [`coolify:database:${org.id}`]),
1484
- ...org.hosting.entries
1485
- .filter((entry) => !entry.adopt)
1486
- .map((entry) => `coolify:app:${appStepKey(org, entry.key)}`),
1487
- ]
1488
- : []),
1489
- // The tunnel and its connector are held with hosting off too (#312).
1490
- ...(org.hosting.adopt?.tunnel ? [] : [adoptedTunnelKey(org)]),
1491
- ...(org.hosting.adopt?.connectorApp ? [] : [adoptedConnectorKey(org)]),
1492
- ];
1493
- for (const key of undeclared.filter(held)) {
1494
- withheld.add(key);
1495
- blocked.push({
1496
- description: `${org.slug}'s ${key} was adopted, so foundry does not manage it as its own: declare adopt again, or run restore to put it back as it was, not planned`,
1497
- key: `coolify:adopt:${key}`,
1498
- });
1499
- }
1500
- return { steps, blocked, withheld };
1501
- }
1502
- /**
1503
- * The blocked item for a park step on a resource foundry adopted: its key has
1504
- * a row that protects it (`adopted`, `confirmed` or `restored`), or its recorded id is a uuid the org
1505
- * declares as adopted. `null` for any other step (#311).
1506
- */
1507
- function adoptedPark(org, rows, recordedIds, { action }) {
1508
- if (action.desired.enabled !== false)
1509
- return null;
1510
- const parkable = action.system === 'coolify'
1511
- ? action.kind === 'app' || action.kind === 'database'
1512
- : action.system === 'cloudflare' && action.kind === 'tunnel';
1513
- if (!parkable)
1514
- return null;
1515
- const key = `${action.system}:${action.kind}:${action.key}`;
1516
- const id = recordedIds[key];
1517
- const held = rows.some((r) => r.resourceKey === key && protectsKey(r));
1518
- if (!held && !(id && declaredAdoptUuids(org).has(id)))
1519
- return null;
1520
- return {
1521
- description: `${org.slug}'s ${key} is adopted, not stopped: foundry never stops a resource it adopted`,
1522
- key: `coolify:adopt:${key}`,
1523
- };
1524
- }
1525
1360
  /**
1526
1361
  * The shared on/off shape behind {@link planApp}, {@link planDatabase} and
1527
1362
  * {@link planTunnel} (#71): a per-org singleton resource, keyed by the org
@@ -1626,24 +1461,7 @@ function planApp(org, entryKey, byKey, claimed, recordedIds, plannedSteps, authI
1626
1461
  key: stepKey,
1627
1462
  desired: { enabled: true },
1628
1463
  };
1629
- // An adopted app patched but not yet deployed: replanned whatever the
1630
- // markers say (#311).
1631
- if (entry.adopt && recordedIds[appDeployPendingScopeKey(entryKey)]) {
1632
- return {
1633
- step: {
1634
- action,
1635
- step: {
1636
- description: `deploy ${org.slug}'s ${label} settings to the adopted app`,
1637
- destroys: false,
1638
- migrates: false,
1639
- },
1640
- },
1641
- blocked,
1642
- };
1643
- }
1644
- // An adopted app gets no volume (`executor.ts`'s `coolifyApp`), so a missing
1645
- // one is not a drift (#311).
1646
- if (live.config.hasLogVolume === false && !entry.adopt) {
1464
+ if (live.config.hasLogVolume === false) {
1647
1465
  return {
1648
1466
  step: {
1649
1467
  action,
@@ -1662,11 +1480,7 @@ function planApp(org, entryKey, byKey, claimed, recordedIds, plannedSteps, authI
1662
1480
  // names (`envNames` absent) reads as compliant, like `hasLogVolume`.
1663
1481
  const envNames = live.config.envNames;
1664
1482
  const dbLive = byKey.get(resourceKey('coolify', 'database', org.id));
1665
- // An adopted app or database keeps the addresses it has (#311).
1666
- const dbReady = entry.database &&
1667
- !entry.adopt &&
1668
- !org.hosting.adopt?.database &&
1669
- Boolean(recordedIds[`coolify:database:${org.id}`]);
1483
+ const dbReady = entry.database && Boolean(recordedIds[`coolify:database:${org.id}`]);
1670
1484
  if (dbReady && dbLive?.config.hasInternalUrl === false) {
1671
1485
  // Rule 2: the push cannot succeed yet, so report it rather than replan
1672
1486
  // forever.
@@ -1776,7 +1590,7 @@ function planApp(org, entryKey, byKey, claimed, recordedIds, plannedSteps, authI
1776
1590
  * {@link planApp} once per hosting entry. A key with no declared entry still
1777
1591
  * runs for `app`, so an org that turns hosting off parks the app it has live.
1778
1592
  */
1779
- function planApps(org, byKey, claimed, recordedIds, plannedSteps, adopting, authIssuer) {
1593
+ function planApps(org, byKey, claimed, recordedIds, plannedSteps, authIssuer) {
1780
1594
  const steps = [];
1781
1595
  const blocked = [];
1782
1596
  // An entry removed from the list while its app is recorded is parked like a
@@ -1798,17 +1612,6 @@ function planApps(org, byKey, claimed, recordedIds, plannedSteps, adopting, auth
1798
1612
  continue;
1799
1613
  }
1800
1614
  const app = planApp(org, key, byKey, claimed, recordedIds, plannedSteps, authIssuer);
1801
- const stepKey = appStepKey(org, key);
1802
- if (!app.step && adopting.has(`coolify:app:${stepKey}`)) {
1803
- app.step = {
1804
- action: { system: 'coolify', kind: 'app', key: stepKey, desired: { enabled: true } },
1805
- step: {
1806
- description: `apply ${org.slug}'s ${key === APP_ENTRY_KEY ? 'app' : `${key} app`} settings, hostnames and env to the adopted app`,
1807
- destroys: false,
1808
- migrates: false,
1809
- },
1810
- };
1811
- }
1812
1615
  // The executor pushes the issuer the plan read, the way Agora gets its host values.
1813
1616
  if (app.step && app.step.action.desired.enabled !== false) {
1814
1617
  const signsIn = org.hosting.entries.some((e) => e.key === key && e.uses?.auth);
@@ -1,9 +1,6 @@
1
1
  import type { OrgMailOptions, OrgMailOrg, OrgMailResult } from '@wtfalch/email/postmaster';
2
2
  import type { DeclaredOrg, HostingBuildPack } from '../org.js';
3
- import type { UnmanagedItem } from '../plan.js';
4
- import type { Adoption, AdoptionStore, DeclaredOrgStore } from '../store.js';
5
- import { type IngressRoute } from './adopt.js';
6
- import type { AdoptionVault } from './adoption-vault.js';
3
+ import type { DeclaredOrgStore } from '../store.js';
7
4
  import { type BrokerPort } from './broker-entries.js';
8
5
  import type { StepAction } from './diff.js';
9
6
  import { type LoginHostBindingsPort } from './login-host.js';
@@ -184,13 +181,9 @@ export interface CoolifyPort {
184
181
  * `is_preview === false` the same way a deployed-config reader must.
185
182
  */
186
183
  listEnvs(applicationUuid: string): Promise<Array<{
187
- uuid?: string;
188
184
  key: string;
189
185
  value: string;
190
186
  is_preview: boolean;
191
- is_buildtime?: boolean;
192
- is_literal?: boolean;
193
- is_multiline?: boolean;
194
187
  }>>;
195
188
  /** Escape hatch (`CoolifyClient.raw`) — used for stop/start, which have no typed method yet. */
196
189
  raw(method: string, path: string, body?: unknown): Promise<unknown>;
@@ -387,18 +380,6 @@ export interface CloudflarePort {
387
380
  name: string;
388
381
  }>;
389
382
  putTunnelConfig(tunnelId: string, config: unknown): Promise<unknown>;
390
- /**
391
- * #312: the tunnel's live configuration, `config` null for a tunnel never
392
- * configured. Matches `@wtfalch/providers`' `CloudflareClient.getTunnelConfig`.
393
- * `cloudflareTunnel` reads it on every apply of an adopted tunnel, to merge
394
- * its routes into the live ingress list rather than overwrite it.
395
- */
396
- getTunnelConfig(tunnelId: string): Promise<{
397
- config: {
398
- ingress: IngressRoute[];
399
- [field: string]: unknown;
400
- } | null;
401
- }>;
402
383
  /**
403
384
  * The tunnel's connector token (#197) -- a `cloudflared` container reads
404
385
  * this at boot to dial into the tunnel. A live credential: matches
@@ -604,6 +585,18 @@ export interface LettermintPort {
604
585
  */
605
586
  restrictDomainToProjects(domainId: string, projectIds: readonly string[]): Promise<void>;
606
587
  }
588
+ /**
589
+ * `OrgMailOrg` with `domain` required (#345): a relay route is keyed to a
590
+ * domain, and `OrgMailOrg.domain` is absent for a level-1 org (its domain is
591
+ * `${slug}.${apex}`, resolved only once `applyOrgMail` returns
592
+ * `OrgMailResult.domain`) -- so a caller of `MailPort`'s three relay methods
593
+ * below must already hold that resolved domain and pass it explicitly,
594
+ * rather than the port guessing or refusing on a level-1 org's bare
595
+ * `mailOrgFor` result.
596
+ */
597
+ export type OrgRelayMailOrg = OrgMailOrg & {
598
+ readonly domain: string;
599
+ };
607
600
  /**
608
601
  * The Stalwart calls this executor makes for one org's mail, shaped to match
609
602
  * `@wtfalch/email/postmaster`'s `applyOrgMail`/`parkOrgMail` method-for-method
@@ -639,25 +632,29 @@ export interface MailPort {
639
632
  * minted (`ensureOrgLettermintRelay`'s own mint rule) -- it goes straight
640
633
  * here and nowhere else: never returned, never recorded, never logged, the
641
634
  * same discipline `ensureAppLettermintMail`'s own doc comment states for
642
- * `LETTERMINT_API_TOKEN`.
635
+ * `LETTERMINT_API_TOKEN`. `org.domain` is required (#345): a level-1 org
636
+ * has none of its own on `OrgMailOrg`, so the caller passes the domain
637
+ * `applyOrgMail` already resolved for it.
643
638
  */
644
- applyOrgRelay(org: OrgMailOrg, secret: string): Promise<void>;
639
+ applyOrgRelay(org: OrgRelayMailOrg, secret: string): Promise<void>;
645
640
  /**
646
641
  * Drops this org's relay route (#149), implemented with
647
642
  * `@wtfalch/email/postmaster`'s own `removeOrgRelay` -- a missing route is
648
643
  * a no-op, so `parkLettermintMail` can call this unconditionally before it
649
- * deletes the Lettermint project the route pointed at.
644
+ * deletes the Lettermint project the route pointed at. `org.domain` is
645
+ * required (#345), the same reason {@link MailPort.applyOrgRelay} needs it.
650
646
  */
651
- removeOrgRelay(org: OrgMailOrg): Promise<void>;
647
+ removeOrgRelay(org: OrgRelayMailOrg): Promise<void>;
652
648
  /**
653
649
  * True once this org's relay route exists on Stalwart with a secret set
654
650
  * (#149). `ensureOrgLettermintRelay`'s own mint rule reads this alongside
655
651
  * Lettermint's own project listing: a project without a live route (the
656
652
  * route recorded a token this executor never minted) is exactly as
657
653
  * unhealthy as a route with no project behind it, so a fresh mint fixes
658
- * either.
654
+ * either. `org.domain` is required (#345), the same reason
655
+ * {@link MailPort.applyOrgRelay} needs it.
659
656
  */
660
- hasOrgRelay(org: OrgMailOrg): Promise<boolean>;
657
+ hasOrgRelay(org: OrgRelayMailOrg): Promise<boolean>;
661
658
  }
662
659
  /**
663
660
  * The shared calls SFU's own per-org LiveKit API credentials (#122, #127,
@@ -1207,17 +1204,6 @@ export interface FoundryStepExecutorPorts {
1207
1204
  * passes the same store instance it constructs `FoundryPlanApply` with.
1208
1205
  */
1209
1206
  declaredOrgStore: Pick<DeclaredOrgStore, 'findOrgIdForZone'>;
1210
- /**
1211
- * #311: where an adoption keeps the env values of the live app it takes
1212
- * over. Optional with `adoptionStore`: without both, the planner reports
1213
- * adoption as blocked and plans no `coolify:adopt` step
1214
- * (`FoundryPlanApplyOptions`).
1215
- */
1216
- adoptionVault?: AdoptionVault;
1217
- /** #311: the rows that hold the non-secret part of each snapshot. */
1218
- adoptionStore?: AdoptionStore;
1219
- /** #311: the clock of a snapshot's `takenAt`; the host passes `FoundryPlanApply`'s own. Defaults to the real clock. */
1220
- now?: () => Date;
1221
1207
  }
1222
1208
  /**
1223
1209
  * Thrown by name for a step this executor deliberately does not implement:
@@ -1388,49 +1374,6 @@ export declare class FoundryStepExecutor implements StepExecutor, StepDestroyer
1388
1374
  private ensureZitadelProject;
1389
1375
  private ensureCoolifyProject;
1390
1376
  private resolveServer;
1391
- /** The live app Coolify holds under `uuid`. The error names the id only (rule 6). */
1392
- private readLiveApp;
1393
- /** The live ingress routes of the adopted tunnel `id`. The error names the id only (rule 6). */
1394
- private readAdoptedIngress;
1395
- /**
1396
- * #311: for each adopted resource, the lines its `coolify:adopt` step shows
1397
- * (a setting with old value beside new; an env var by name only) and what
1398
- * is blocked. Read from the live app on every plan. No value of an env var
1399
- * leaves this method (rule 6).
1400
- */
1401
- adoptionReport(org: DeclaredOrg, recorded: Readonly<Record<string, string>>): Promise<{
1402
- details: Record<string, string[]>;
1403
- blocked: UnmanagedItem[];
1404
- withheld: string[];
1405
- }>;
1406
- /**
1407
- * #311: puts each adopted app back as its snapshot recorded it, touching
1408
- * only what foundry writes: the settings it patches and the env names it
1409
- * owns (an edit a person made to anything else stays). Owned names the
1410
- * snapshot has are written back with its value and flags; owned names it
1411
- * lacks are deleted; preview rows are left alone. Every vault entry and
1412
- * live read happens first, so a missing entry or a row that cannot be
1413
- * deleted refuses before any Coolify write. Errors name ids only, and the
1414
- * vault's env values go to `bulkUpsertEnvs` and nowhere else (rule 6).
1415
- */
1416
- restoreAdoption(org: DeclaredOrg, recorded: Readonly<Record<string, string>>, rows: readonly Adoption[]): Promise<{
1417
- envDeleted: string[];
1418
- tokenLeft: string[];
1419
- }>;
1420
- /**
1421
- * #311: records what a live app or database looked like before foundry
1422
- * first writes to it, in this order: the env values to the vault, the
1423
- * non-secret settings to the row, then both read back and compared. The
1424
- * step writes nothing to Coolify, so a snapshot that fails stops the apply
1425
- * before any later step does. Every error names ids only (rule 6).
1426
- */
1427
- private coolifyAdopt;
1428
- /** #311: whether the resource was adopted: its row is `adopted` or `confirmed`, or `id` is a declared adopt uuid. */
1429
- private isAdopted;
1430
- /** #311: refuses to manage as foundry's own a resource whose row protects its key and whose declaration has no `adopt`. */
1431
- private refuseTakeover;
1432
- /** #311: refuses to write to a declared adopt uuid that has no active snapshot. */
1433
- private requireSnapshot;
1434
1377
  private coolifyApp;
1435
1378
  /**
1436
1379
  * #75: mounts a persistent volume at {@link LOG_DIR} on the app, unless one
@@ -1866,7 +1809,10 @@ export declare class FoundryStepExecutor implements StepExecutor, StepDestroyer
1866
1809
  * `agora` port overrides it) -- absent otherwise. The chat upstreams are
1867
1810
  * still never pushed: chat has no admin API yet
1868
1811
  * (https://github.com/wtfalch/chat/issues/40). Agora answers 502 on those
1869
- * routes rather than serving anything wrong.
1812
+ * routes rather than serving anything wrong. `AGORA_CALLS_UPSTREAM` is
1813
+ * pushed once calls are on and this org's calls service app carries its
1814
+ * internal name (`desiredAgoraEnv` reads that marker from the recorded ids,
1815
+ * not the host values).
1870
1816
  */
1871
1817
  private coolifyAgora;
1872
1818
  /**
@@ -1894,24 +1840,6 @@ export declare class FoundryStepExecutor implements StepExecutor, StepDestroyer
1894
1840
  /** `deliverSecret`, tried up to {@link JOBS_DELIVERY_ATTEMPTS} times; true once one attempt succeeds. */
1895
1841
  private deliverWithRetry;
1896
1842
  private cloudflareTunnel;
1897
- /**
1898
- * Runs `call`; for an adopted resource a failure that is not one of this
1899
- * file's own messages (which name hostnames and record types only) is
1900
- * replaced by `label`, because a client's error text carries response
1901
- * bodies (rule 6, #312).
1902
- */
1903
- private guarded;
1904
- /**
1905
- * #312: writes the org's routes into the adopted tunnel's LIVE ingress list
1906
- * (`mergeIngress`), not the snapshot, so a route a person added since is
1907
- * kept too. The declared names go into the tunnel row's `written` only after
1908
- * the write that carried them succeeded, so a crash in between leaves a
1909
- * route taken as foreign and kept. Writes nothing when the merged list is the
1910
- * live one. Errors name the tunnel id and the operation only.
1911
- */
1912
- private mergeAdoptedIngress;
1913
- /** #312: replaces the `settings` of an adoption row, read fresh so no other key is lost. The state and `takenAt` stay. */
1914
- private updateAdoptionSettings;
1915
1843
  /**
1916
1844
  * #236: when this tunnel's DNS was last published under a domain that
1917
1845
  * differs from the one `org` now declares, deletes every CNAME in the