@wtfalch/foundry 0.16.16 → 0.17.1

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.
Files changed (67) hide show
  1. package/dist/apply.d.ts +22 -1
  2. package/dist/bootstrap/bootstrap.d.ts +6 -1
  3. package/dist/bootstrap/bootstrap.js +26 -3
  4. package/dist/bootstrap/declare.d.ts +11 -1
  5. package/dist/bootstrap/declare.js +18 -6
  6. package/dist/index.d.ts +11 -6
  7. package/dist/index.js +8 -1
  8. package/dist/migrations/0038_calls_version_pin.sql +8 -0
  9. package/dist/migrations/0039_destroy_record.sql +7 -0
  10. package/dist/migrations/0040_company_included_uuid.sql +72 -0
  11. package/dist/migrations/0041_company_included_uuid_keys.sql +132 -0
  12. package/dist/migrations/0042_mutation_leases.sql +12 -0
  13. package/dist/migrations/0043_platform_names.sql +25 -0
  14. package/dist/migrations/0044_platform_mail_lettermint_project.sql +11 -0
  15. package/dist/org.d.ts +128 -3
  16. package/dist/org.js +186 -1
  17. package/dist/plan/adopt.d.ts +76 -0
  18. package/dist/plan/adopt.js +184 -0
  19. package/dist/plan/agora.d.ts +21 -0
  20. package/dist/plan/agora.js +37 -0
  21. package/dist/plan/broker-entries.d.ts +9 -1
  22. package/dist/plan/broker-entries.js +13 -5
  23. package/dist/plan/chat-matrix.d.ts +1 -2
  24. package/dist/plan/chat-matrix.js +15 -4
  25. package/dist/plan/core-files.d.ts +25 -0
  26. package/dist/plan/core-files.js +25 -0
  27. package/dist/plan/diff.d.ts +27 -3
  28. package/dist/plan/diff.js +264 -86
  29. package/dist/plan/edge-worker.d.ts +114 -0
  30. package/dist/plan/edge-worker.js +201 -0
  31. package/dist/plan/entry-ai-key.d.ts +3 -3
  32. package/dist/plan/entry-files-key.d.ts +3 -3
  33. package/dist/plan/entry-names-key.d.ts +73 -0
  34. package/dist/plan/entry-names-key.js +236 -0
  35. package/dist/plan/executor.d.ts +292 -24
  36. package/dist/plan/executor.js +745 -77
  37. package/dist/plan/index.d.ts +6 -2
  38. package/dist/plan/index.js +3 -1
  39. package/dist/plan/live.d.ts +9 -2
  40. package/dist/plan/login-host.d.ts +13 -2
  41. package/dist/plan/login-host.js +26 -7
  42. package/dist/plan/plan-apply.d.ts +153 -6
  43. package/dist/plan/plan-apply.js +639 -78
  44. package/dist/plan/platform-diff.d.ts +106 -1
  45. package/dist/plan/platform-diff.js +182 -2
  46. package/dist/plan/platform-org.d.ts +45 -1
  47. package/dist/plan/platform-org.js +34 -3
  48. package/dist/plan/platform-providers.d.ts +45 -1
  49. package/dist/plan/platform-providers.js +585 -16
  50. package/dist/plan/providers-readers.d.ts +2 -0
  51. package/dist/plan/providers-readers.js +4 -0
  52. package/dist/plan/rollout.d.ts +52 -0
  53. package/dist/plan/rollout.js +37 -0
  54. package/dist/plan/step-system.d.ts +22 -0
  55. package/dist/plan/step-system.js +38 -0
  56. package/dist/plan.d.ts +3 -0
  57. package/dist/platform.d.ts +22 -3
  58. package/dist/platform.js +6 -0
  59. package/dist/state.d.ts +13 -0
  60. package/dist/store/audit.js +15 -1
  61. package/dist/store/postgres.d.ts +1 -1
  62. package/dist/store/postgres.js +142 -15
  63. package/dist/store/schema.d.ts +202 -5
  64. package/dist/store/schema.js +18 -1
  65. package/dist/store.d.ts +47 -4
  66. package/dist/store.js +9 -1
  67. package/package.json +1 -1
@@ -2,18 +2,22 @@ import { randomBytes } from 'node:crypto';
2
2
  import { URL } from 'node:url';
3
3
  import { CALLS_SFU_PRIVATE_URL, CALLS_SFU_PUBLIC_URL } from '../calls-sfu.js';
4
4
  import { APP_ENTRY_KEY, appEntry, filesCollectionEnvName, ownerMailboxRecorded, ownerMailboxes, sharedSecretRef, } from '../org.js';
5
- import { AGORA_CLIENT_ID_SCOPE_KEY, APP_CLIENT_ID_SCOPE_KEY, CALLS_INTROSPECTION_SCOPE_KEY, CALLS_INTROSPECTION_UNDELIVERED_SCOPE_KEY, CALLS_SERVICE_NAME_SCOPE_KEY, CALLS_SERVICE_PORT, CALLS_SERVICE_TOKEN_ENV_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, agoraHostnameFor, callsIntrospectionAppName, callsServiceHasName, callsServiceInternalName, callsTokenEnvMarker, callsTokenEnvMovedMessage, desiredAgoraEnv, entryClientIdScopeKey, } from './agora.js';
5
+ import { AGORA_ACTIVATED_ENV_SCOPE_KEY, AGORA_CLIENT_ID_SCOPE_KEY, APP_CLIENT_ID_SCOPE_KEY, CALLS_INTROSPECTION_SCOPE_KEY, CALLS_INTROSPECTION_UNDELIVERED_SCOPE_KEY, CALLS_SERVICE_NAME_SCOPE_KEY, CALLS_SERVICE_PORT, CALLS_SERVICE_TOKEN_ENV_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, desiredAgoraEnv, entryClientIdScopeKey, } from './agora.js';
6
6
  import { BOULE_BROKER_ENTRY_KEY, BOULE_CLIENT_ID_SCOPE_KEY, BOULE_DATABASE_SCOPE_KEY, bouleEnvDrift, bouleHostnameFor, 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
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';
10
10
  import { ensureCloudflaredConnector } from './cloudflared-connector.js';
11
+ import { CORE_FILES_COLLECTION_NAME, FILES_CORE_COLLECTION_SCOPE_KEY, ZITADEL_FILES_PROJECT_SCOPE_KEY, } from './core-files.js';
12
+ import { DEFAULT_AI_LIMIT_MICROS } from './diff.js';
13
+ import { entryEdgeAppliedScopeKey, entryEdgeRetiredScopeKey, entryEdgeRouteIdScopeKey, entryEdgeRoutePatternScopeKey, entryEdgeRoutePendingScopeKey, entryEdgeRouteZoneScopeKey, entryEdgeUploadedScopeKey, } from './edge-worker.js';
11
14
  import { AI_ENTRY_RENEW_WINDOW_DAYS, entryAiAppliedScopeKey, entryAiClampedScopeKey, entryAiExpiresScopeKey, entryAiPendingExpiresScopeKey, entryAiRetiredScopeKey, entryAiUndeliveredScopeKey, entryAiUnrevokedScopeKey, entryAiWebhookPendingScopeKey, entryAiWrittenScopeKey, parseIdList, } from './entry-ai-key.js';
12
15
  import { FILES_AGORA_KEY_SCOPE_KEY, FILES_AGORA_UNDELIVERED_SCOPE_KEY, FILES_KEY_DAYS, FILES_KEY_RENEW_WINDOW_DAYS, FILES_ORG_OWNER_SCOPE_KEY, FILES_ROOT_EXPIRES_SCOPE_KEY, FILES_ROOT_PREVIOUS_SCOPE_KEY, FILES_ROOT_UNDELIVERED_SCOPE_KEY, FILES_ROOT_UNREVOKED_SCOPE_KEY, entryFilesAppliedScopeKey, entryFilesExpiresScopeKey, entryFilesMinterScopeKey, entryFilesPendingExpiresScopeKey, entryFilesRetiredScopeKey, entryFilesUndeliveredScopeKey, entryFilesUnrevokedScopeKey, entryFilesWrittenScopeKey, filesOwnerUserId, parseIdList as parseFilesIdList, } from './entry-files-key.js';
16
+ import { ENTRY_NAMES_KEY_DELIVERY_ATTEMPTS, NAMES_ENTRY_KEY_DAYS, NAMES_RESERVED_LABELS_APPLIED_SCOPE_KEY, entryNamesMintedAtScopeKey, entryNamesRetiredScopeKey, entryNamesUndeliveredScopeKey, } from './entry-names-key.js';
13
17
  import { appHostname, coolifyDomains, entryHostnames, tunnelDnsHostnames, tunnelIngressHostnames, } from './hostnames.js';
14
18
  import { LOGIN_BINDING_SCOPE_KEY, LOGIN_HOST_PENDING_REMOVAL_SCOPE_KEY, loginBindingState, loginHostClientIds, pendingRemovalHosts, pendingRemovalState, } from './login-host.js';
15
19
  import { MAIL_COPY_COMPLETED_SCOPE_KEY, MAIL_COPY_DONE_SCOPE_KEY, parseMailCopyCompleted, runMailCopyStep, } from './mail-copy.js';
16
- 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, appDomainsAppliedScopeKey, appEnvAppliedScopeKey, appNameFor, appSettingsAppliedScopeKey, appSettingsOf, appStepKey, appliedAppDomains, appliedAppSettings, dbEnvAppliedScopeKey, resolveAppEnv, } from './platform-diff.js';
20
+ 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, appDomainsAppliedScopeKey, appEnvAppliedScopeKey, appNameFor, appSettingsAppliedScopeKey, appSettingsOf, appStepKey, appliedAppDomains, appliedAppSettings, dbEnvAppliedScopeKey, resolveAppEnv, serviceDbEnvAppliedScopeKey, } from './platform-diff.js';
17
21
  import { PLATFORM_AUTH_BROKER_MAIL_KEY_REF } from './platform-providers.js';
18
22
  import { appLabelsAppliedScopeKey, entryLabelHash, entryLabelSpec, entryProxyLabels, sameLabelSets, } from './proxy-labels.js';
19
23
  /**
@@ -210,14 +214,16 @@ const RATE_LIMIT_REQUESTS_PER_PERIOD = 10;
210
214
  const DEFAULT_APP_REPO = 'wtfalch/foundry';
211
215
  const DEFAULT_APP_DOCKERFILE = '/apps/hestia/Dockerfile';
212
216
  /**
213
- * The one repo `cmsSite` builds both of its apps from
214
- * (https://github.com/wtfalch/cms) -- the 2026-09-25 decision this campaign
215
- * implements (`.plans/2026-09-25-org-sites.md`, that repo): foundry never
216
- * calls a cms admin API for the org's site, it provisions two ordinary
217
- * Coolify applications from this one repo, told apart by
218
- * `dockerfile_location`. `CMS_APP_DOCKERFILE` is the repo's own root
219
- * `Dockerfile` (`apps/host`, single-org mode); `CMS_SITE_APP_DOCKERFILE`
220
- * is `apps/site`'s own Dockerfile (the reference renderer).
217
+ * The shared repo `cmsSite` builds its cms admin app from, always, and its
218
+ * site app from by default (https://github.com/wtfalch/cms) -- the
219
+ * 2026-09-25 decision this campaign implements
220
+ * (`.plans/2026-09-25-org-sites.md`, that repo): foundry never calls a cms
221
+ * admin API for the org's site, it provisions two ordinary Coolify
222
+ * applications, told apart by `dockerfile_location`. `CMS_APP_DOCKERFILE`
223
+ * is the repo's own root `Dockerfile` (`apps/host`, single-org mode);
224
+ * `CMS_SITE_APP_DOCKERFILE` is `apps/site`'s own Dockerfile (the reference
225
+ * renderer). An org with `site.repo` set (#245) builds its site app from
226
+ * that repo instead, the sample skin otherwise ships to every org.
221
227
  */
222
228
  const CMS_REPO = 'wtfalch/cms';
223
229
  const CMS_APP_DOCKERFILE = '/Dockerfile';
@@ -277,6 +283,8 @@ const LOG_DIR = '/app/logs';
277
283
  */
278
284
  const CMS_APP_SCOPE_KEY = 'coolify_cms_app';
279
285
  const SITE_APP_SCOPE_KEY = 'coolify_site_app';
286
+ /** #445: the one Coolify server every resource of this org lands on, chosen once and recorded. */
287
+ const COOLIFY_SERVER_SCOPE_KEY = 'coolify_server';
280
288
  /**
281
289
  * #117: the cms app's own Postgres database (`<org.slug>-cms-db`), found or
282
290
  * created inside `cmsSite` the same way `coolifyDatabase` does for the org's
@@ -652,6 +660,8 @@ export class FoundryStepExecutor {
652
660
  return this.cloudflareAccess(action, org);
653
661
  case 'cloudflare:ratelimit':
654
662
  return this.cloudflareRateLimit(org);
663
+ case 'cloudflare:worker':
664
+ return this.cloudflareWorker(action, org, context);
655
665
  case 'files:organisation':
656
666
  return this.filesOrganisation(action, org, context);
657
667
  case 'files:key':
@@ -659,8 +669,6 @@ export class FoundryStepExecutor {
659
669
  return action.key.includes(':')
660
670
  ? this.filesEntryKey(action, org, context)
661
671
  : this.filesRootKey(action, org, context);
662
- case 'files:storage':
663
- return this.filesStorage(action, org);
664
672
  case 'ai:organisation':
665
673
  return this.aiOrganisation(action, org);
666
674
  case 'ai:budget':
@@ -670,6 +678,10 @@ export class FoundryStepExecutor {
670
678
  return action.key.includes(':')
671
679
  ? this.aiEntryKey(action, org, context)
672
680
  : this.aiKey(action, org, context);
681
+ case 'names:key':
682
+ return this.namesEntryKey(action, org, context);
683
+ case 'names:reserved-labels':
684
+ return this.namesReservedLabels(action, org, context);
673
685
  case 'coolify:mail':
674
686
  return this.coolifyMail(action, org, context);
675
687
  case 'coolify:mail-copy':
@@ -837,6 +849,192 @@ export class FoundryStepExecutor {
837
849
  }
838
850
  return { resourceId: created.id };
839
851
  }
852
+ /**
853
+ * `plan/diff.ts`'s `planEdgeWorkers` (#318, F8): deploys one entry's
854
+ * `edge` Worker and routes it on the org's own zone. Throws by name
855
+ * without a {@link WorkersPort}, the same `jobsRegistration` shape --
856
+ * `planEdgeWorkers` reports the step blocked instead while the host has
857
+ * none, so this only fires against a stale plan. The route is checked
858
+ * before upload: a foreign route already on the pattern is refused, never
859
+ * adopted or replaced (`cloudflareAccess`'s own precedent for a foreign
860
+ * resource on the same name), so a Worker is never deployed only to find
861
+ * nothing can route to it.
862
+ *
863
+ * Order on apply: record (and persist) the uploaded name and clear the
864
+ * retired marker, upload, record the pending route, create the route only
865
+ * if needed, delete any *previous* route this entry recorded on its own
866
+ * zone, then record the new route id/zone/pattern and the applied
867
+ * declaration last -- every crash point leaves a recorded trace that
868
+ * retire and destroy can clean up (#318 re-review), and per CLAUDE.md's
869
+ * "Declared changes apply" nothing is marked done until the
870
+ * live state actually matches it.
871
+ */
872
+ async cloudflareWorker(action, org, context) {
873
+ const entryKey = action.key.slice(org.id.length + 1);
874
+ const at = `cloudflare:worker for ${org.slug} entry '${entryKey}'`;
875
+ const workers = this.ports.workers;
876
+ if (!workers)
877
+ throw new Error(`${at}: this executor was built without a WorkersPort (#318)`);
878
+ const desired = action.desired;
879
+ if (desired.mode === 'retire') {
880
+ const recorded = context.recordedIds();
881
+ const mainId = recorded[`cloudflare:worker:${action.key}`];
882
+ const scriptName = mainId || recorded[entryEdgeUploadedScopeKey(entryKey)];
883
+ await this.teardownEdgeWorker(entryKey, scriptName, recorded, workers, at);
884
+ context.recordScopeId(entryEdgeRouteIdScopeKey(entryKey), '');
885
+ context.recordScopeId(entryEdgeRouteZoneScopeKey(entryKey), '');
886
+ context.recordScopeId(entryEdgeRoutePatternScopeKey(entryKey), '');
887
+ context.recordScopeId(entryEdgeRoutePendingScopeKey(entryKey), '');
888
+ context.recordScopeId(entryEdgeAppliedScopeKey(entryKey), '');
889
+ context.recordScopeId(entryEdgeUploadedScopeKey(entryKey), '');
890
+ // `runSteps` only ever writes the main recorded id from a truthy
891
+ // `resourceId` (`plan-apply.ts`); this step returns none, so without
892
+ // this marker the planner's "gone" scan would retire the same,
893
+ // never-cleared id forever.
894
+ if (mainId)
895
+ context.recordScopeId(entryEdgeRetiredScopeKey(entryKey), mainId);
896
+ return {};
897
+ }
898
+ if (!org.domain) {
899
+ throw new Error(`${at}: edge needs org.domain, and validateHosting already refuses it without one`);
900
+ }
901
+ const zone = await this.ports.cloudflare.findZoneByApex(org.domain);
902
+ if (!zone) {
903
+ throw new Error(`${at}: no Cloudflare zone found for ${org.domain} -- cloudflare:zone must provision it first`);
904
+ }
905
+ const name = desired.name;
906
+ const route = desired.route;
907
+ const bindings = (desired.bindings?.rateLimits ?? []).map((r) => ({
908
+ type: 'ratelimit',
909
+ name: r.name,
910
+ namespace_id: r.namespaceId,
911
+ simple: { limit: r.limit, period: r.period },
912
+ }));
913
+ const existingRoute = await workers.findRoute(zone.id, route);
914
+ if (existingRoute && existingRoute.script !== name) {
915
+ throw new Error(`${at}: route '${route}' is already bound to Worker '${existingRoute.script}', not foundry's own '${name}'; foundry does not adopt or replace it`);
916
+ }
917
+ // Checked only on an entry's first-ever apply -- a name foundry has
918
+ // already recorded for itself (even a retired one: redeclaring reuses
919
+ // the same deterministic name) is foundry's own script to overwrite,
920
+ // never a stranger's.
921
+ const recordedBeforeUpload = context.recordedIds();
922
+ const everUploadedByFoundry = Boolean(recordedBeforeUpload[entryEdgeAppliedScopeKey(entryKey)] ||
923
+ recordedBeforeUpload[entryEdgeUploadedScopeKey(entryKey)] ||
924
+ recordedBeforeUpload[`cloudflare:worker:${action.key}`]);
925
+ if (!everUploadedByFoundry && (await workers.scriptExists(name))) {
926
+ throw new Error(`${at}: a Worker script named '${name}' already exists and foundry never created it; refuses to overwrite it`);
927
+ }
928
+ // Recorded and persisted *before* the upload (#318 re-review): a crash
929
+ // mid-upload or a failed route create still leaves a trail to the
930
+ // script, and a retry reads it as foundry's own rather than tripping
931
+ // the `scriptExists` guard above. The retired marker clears here too,
932
+ // not only on success: once a redeploy has touched the script, a later
933
+ // undeclare must retire it even if this apply never finishes.
934
+ context.recordScopeId(entryEdgeUploadedScopeKey(entryKey), name);
935
+ context.recordScopeId(entryEdgeRetiredScopeKey(entryKey), '');
936
+ await context.persistScopeIds();
937
+ await workers.uploadScript({
938
+ name,
939
+ artifactUrl: desired.artifactUrl,
940
+ artifactSha256: desired.artifactSha256,
941
+ compatibilityDate: action.desired.compatibilityDate,
942
+ bindings: bindings.length > 0 ? bindings : undefined,
943
+ });
944
+ const recorded = context.recordedIds();
945
+ const previousRouteId = recorded[entryEdgeRouteIdScopeKey(entryKey)];
946
+ const previousPending = recorded[entryEdgeRoutePendingScopeKey(entryKey)];
947
+ const zoneRecord = { id: zone.id, apex: org.domain };
948
+ let routeId = existingRoute?.id;
949
+ if (!routeId) {
950
+ // Recorded before the create, so a route Cloudflare made but foundry
951
+ // never got to record is still found (`findRoute`) and deleted on
952
+ // retire or destroy (#318 re-review).
953
+ const pending = { ...zoneRecord, pattern: route, script: name };
954
+ context.recordScopeId(entryEdgeRoutePendingScopeKey(entryKey), JSON.stringify(pending));
955
+ await context.persistScopeIds();
956
+ const created = await workers.createRoute(zone.id, route, name);
957
+ routeId = created.id;
958
+ }
959
+ // Any route this entry recorded before that is not the one it now uses
960
+ // -- the declared pattern moved, or the domain did -- is deleted on its
961
+ // own zone rather than left live beside the new one. A pending route
962
+ // from an earlier, interrupted apply under a different pattern likewise.
963
+ if (previousRouteId && previousRouteId !== routeId) {
964
+ const previousZone = await this.liveEdgeZone(recorded[entryEdgeRouteZoneScopeKey(entryKey)], at);
965
+ if (previousZone)
966
+ await workers.deleteRoute(previousZone, previousRouteId); // Idempotent.
967
+ }
968
+ if (previousPending) {
969
+ await this.deletePendingEdgeRoute(previousPending, routeId, workers, at);
970
+ }
971
+ context.recordScopeId(entryEdgeRouteIdScopeKey(entryKey), routeId);
972
+ context.recordScopeId(entryEdgeRouteZoneScopeKey(entryKey), JSON.stringify(zoneRecord));
973
+ context.recordScopeId(entryEdgeRoutePatternScopeKey(entryKey), route);
974
+ context.recordScopeId(entryEdgeRoutePendingScopeKey(entryKey), '');
975
+ context.recordScopeId(entryEdgeAppliedScopeKey(entryKey), JSON.stringify({
976
+ name,
977
+ artifactUrl: desired.artifactUrl,
978
+ artifactSha256: desired.artifactSha256,
979
+ route,
980
+ bindings: desired.bindings,
981
+ }));
982
+ context.recordScopeId(entryEdgeUploadedScopeKey(entryKey), '');
983
+ // The retired marker was already cleared before the upload above: a
984
+ // redeclare reuses this entry's deterministic script name, so a marker
985
+ // left equal to the (identical) main id would read as already retired.
986
+ return { resourceId: name };
987
+ }
988
+ /**
989
+ * The zone id a recorded route lives in, or `undefined` only when that
990
+ * zone is *positively* gone -- its apex resolves to no zone, or to a
991
+ * different zone than the one recorded (#318 re-review). Reads the zone
992
+ * recorded beside the route, never `org.domain`: the route id and its
993
+ * zone are always recorded together (the apply branch below writes both
994
+ * in the same batch, and the pending-route marker carries its own zone
995
+ * too), so a route id with no zone recorded cannot happen within this
996
+ * feature -- and re-resolving from `org.domain` as a fallback would risk
997
+ * looking up the *new* zone after a domain change and leaving the route
998
+ * live on the old one, the exact bug a recorded-per-route zone exists to
999
+ * avoid (#318 re-review).
1000
+ */
1001
+ async liveEdgeZone(recordedZone, at) {
1002
+ if (!recordedZone) {
1003
+ throw new Error(`${at}: a route is recorded but its zone is not, so foundry cannot tell which zone holds it; refuses to forget it`);
1004
+ }
1005
+ const { id, apex } = JSON.parse(recordedZone);
1006
+ const zone = await this.ports.cloudflare.findZoneByApex(apex);
1007
+ return zone && zone.id === id ? id : undefined;
1008
+ }
1009
+ /** Deletes the route an interrupted apply was creating, if `findRoute` shows it exists, points at that apply's script, and is not `keepRouteId`. */
1010
+ async deletePendingEdgeRoute(pendingRaw, keepRouteId, workers, at) {
1011
+ const pending = JSON.parse(pendingRaw);
1012
+ const zoneId = await this.liveEdgeZone(JSON.stringify({ id: pending.id, apex: pending.apex }), at);
1013
+ if (!zoneId)
1014
+ return;
1015
+ const found = await workers.findRoute(zoneId, pending.pattern);
1016
+ if (found && found.script === pending.script && found.id !== keepRouteId) {
1017
+ await workers.deleteRoute(zoneId, found.id); // Idempotent.
1018
+ }
1019
+ }
1020
+ /**
1021
+ * Retire and destroy share this: delete the recorded route on its own
1022
+ * zone, then any route an interrupted apply created but never recorded,
1023
+ * then the script. Records nothing -- the caller does.
1024
+ */
1025
+ async teardownEdgeWorker(entryKey, scriptName, recorded, workers, at) {
1026
+ const routeId = recorded[entryEdgeRouteIdScopeKey(entryKey)];
1027
+ if (routeId) {
1028
+ const zoneId = await this.liveEdgeZone(recorded[entryEdgeRouteZoneScopeKey(entryKey)], at);
1029
+ if (zoneId)
1030
+ await workers.deleteRoute(zoneId, routeId); // Idempotent: a no-op if already gone.
1031
+ }
1032
+ const pending = recorded[entryEdgeRoutePendingScopeKey(entryKey)];
1033
+ if (pending)
1034
+ await this.deletePendingEdgeRoute(pending, undefined, workers, at);
1035
+ if (scriptName)
1036
+ await workers.deleteScript(scriptName); // Idempotent: a no-op if already gone.
1037
+ }
840
1038
  // ---- zitadel --------------------------------------------------------
841
1039
  async zitadelIssuer(action, org, context) {
842
1040
  if (action.desired.provision !== true) {
@@ -983,6 +1181,7 @@ export class FoundryStepExecutor {
983
1181
  .map((key) => key.slice(ENTRY_CLIENT_ID_SCOPE_PREFIX.length)),
984
1182
  ...(recorded[CMS_CLIENT_ID_SCOPE_KEY] ? ['cms'] : []),
985
1183
  ...(recorded[AGORA_CLIENT_ID_SCOPE_KEY] ? ['agora'] : []),
1184
+ ...(recorded[BOULE_CLIENT_ID_SCOPE_KEY] ? ['boule'] : []),
986
1185
  ].map((name) => recorded[`zitadel:app:${name}`]));
987
1186
  const projectId = recorded.zitadel_project;
988
1187
  // No project yet means no apps yet: vacuously safe to decide from (fix
@@ -1256,11 +1455,37 @@ export class FoundryStepExecutor {
1256
1455
  context.recordScopeId('coolify_project', projectUuid);
1257
1456
  return projectUuid;
1258
1457
  }
1259
- async resolveServer() {
1458
+ /**
1459
+ * #445: one company lands on one Coolify server. The first call for an
1460
+ * org picks a usable, reachable server and records it
1461
+ * ({@link COOLIFY_SERVER_SCOPE_KEY}); every later call -- this apply, or a
1462
+ * later one -- reads that recording back and returns the same server,
1463
+ * regardless of what the pool's order or health looks like by then. A
1464
+ * recorded server that has gone unusable or unreachable is reported
1465
+ * blocked (never silently replaced with a second host, which would split
1466
+ * the org's resources across servers and break their internal container
1467
+ * URLs); a recorded server no longer registered at all is the same.
1468
+ * Deliberately no fallback to `servers[0]` when nothing is usable: an
1469
+ * unusable server picked blind is not a server anyone's resources could
1470
+ * actually be created on.
1471
+ */
1472
+ async resolveServer(org, context) {
1260
1473
  const servers = await this.ports.coolify.listServers();
1261
- const server = servers.find((s) => s.is_usable && s.is_reachable) ?? servers[0];
1474
+ const recorded = context.recordedIds()[COOLIFY_SERVER_SCOPE_KEY];
1475
+ if (recorded) {
1476
+ const server = servers.find((s) => s.uuid === recorded);
1477
+ if (!server) {
1478
+ throw new Error(`${org.slug}'s recorded Coolify server ${recorded} is no longer registered on this instance`);
1479
+ }
1480
+ if (!(server.is_usable && server.is_reachable)) {
1481
+ throw new Error(`${org.slug}'s recorded Coolify server ${recorded} is not usable/reachable right now; it is not moved to a second server`);
1482
+ }
1483
+ return server;
1484
+ }
1485
+ const server = servers.find((s) => s.is_usable && s.is_reachable);
1262
1486
  if (!server)
1263
1487
  throw new Error('no usable Coolify server is registered on this instance');
1488
+ context.recordScopeId(COOLIFY_SERVER_SCOPE_KEY, server.uuid);
1264
1489
  return server;
1265
1490
  }
1266
1491
  async coolifyApp(action, org, context) {
@@ -1326,7 +1551,7 @@ export class FoundryStepExecutor {
1326
1551
  haveNames = appliedAppDomains(org, context.recordedIds(), entry.key);
1327
1552
  }
1328
1553
  else {
1329
- const server = await this.resolveServer();
1554
+ const server = await this.resolveServer(org, context);
1330
1555
  const githubApp = await this.ports.coolify.requireGithubApp();
1331
1556
  const created = await this.ports.coolify.createGithubAppApplication({
1332
1557
  project_uuid: projectUuid,
@@ -1468,7 +1693,15 @@ export class FoundryStepExecutor {
1468
1693
  // One Lettermint project, `<slug>-app`, belongs to the `app` entry; a
1469
1694
  // second entry sharing the name would delete the first's live token.
1470
1695
  if (entry.key === APP_ENTRY_KEY) {
1471
- await this.ensureAppLettermintMail(appId, org, context, Boolean(existing));
1696
+ await this.ensureAppLettermintMail(appId, org, context, Boolean(existing),
1697
+ // #491 (review round 2, finding 3): `diff.ts`'s `planApps` always
1698
+ // sets `platformApex` on this step's `desired` once it's planned at
1699
+ // all -- `null` when no platform is declared, never omitted. The key
1700
+ // being absent outright (as opposed to present and `null`) means a
1701
+ // step planned before this field existed; `ensureAppLettermintMail`
1702
+ // refuses rather than silently falling back once it knows there is
1703
+ // a recorded mail domain to reason about.
1704
+ 'platformApex' in action.desired, action.desired.platformApex ?? null, action.desired.platformLettermintProjectId ?? null);
1472
1705
  }
1473
1706
  return { resourceId: appId };
1474
1707
  }
@@ -1529,7 +1762,7 @@ export class FoundryStepExecutor {
1529
1762
  dbId = found.uuid;
1530
1763
  }
1531
1764
  else {
1532
- const server = await this.resolveServer();
1765
+ const server = await this.resolveServer(org, context);
1533
1766
  const dbName = org.slug.replace(/-/g, '_');
1534
1767
  const created = await this.ports.coolify.createPostgresDatabase({
1535
1768
  project_uuid: projectUuid,
@@ -1907,12 +2140,73 @@ export class FoundryStepExecutor {
1907
2140
  * comment), never logged, never interpolated into a thrown error -- the
1908
2141
  * same discipline `pushDatabaseUrl`'s doc comment states for a database's
1909
2142
  * own connection string.
2143
+ *
2144
+ * #491: none of the above applies to the org whose mail domain is the
2145
+ * platform's own apex -- it shares the platform's existing Lettermint
2146
+ * project (`platformLettermintProjectId`, from `DeclaredPlatform.mail`)
2147
+ * instead of minting a `<slug>-app` one, and this method never creates or
2148
+ * deletes a project for it. `platformApex`/`platformLettermintProjectId`
2149
+ * come straight off this step's own `desired`, the same "the executor
2150
+ * writes exactly what the plan showed" rule `coolifyMail` already follows
2151
+ * for its own `apex`/`mailHost` -- the caller (`coolifyApp`) refuses a
2152
+ * `desired` missing the `platformApex` key outright as a stale plan --
2153
+ * checked here, after the no-op-until-a-mail-domain-exists return just
2154
+ * below, rather than unconditionally at the call site: every other
2155
+ * `coolify:app` concern (hostnames, ports, settings, the sign-in env...)
2156
+ * has nothing to do with Lettermint and must keep working the moment a
2157
+ * mail domain was never even recorded for this org, the same as before
2158
+ * this field existed (review round 2, finding 3).
1910
2159
  */
1911
- async ensureAppLettermintMail(appId, org, context, running) {
2160
+ async ensureAppLettermintMail(appId, org, context, running, hasPlatformApexField, platformApex, platformLettermintProjectId) {
1912
2161
  const mailDomain = context.recordedIds()[MAIL_DOMAIN_SCOPE_KEY];
1913
2162
  if (!mailDomain)
1914
2163
  return;
2164
+ if (!hasPlatformApexField) {
2165
+ throw new Error(`coolify:app for ${org.slug}: this step's plan carries no platformApex; plan this org's app again`);
2166
+ }
1915
2167
  const appName = `${org.slug}-app`;
2168
+ // #491: an org whose mail domain is the platform's own apex (Company
2169
+ // Included on companyincluded.com, today) shares the platform's existing
2170
+ // Lettermint project rather than minting a `<slug>-app` one of its own --
2171
+ // `diff.ts`'s `planApp` blocks this whole piece while
2172
+ // `platformLettermintProjectId` is unset, so a plan carrying neither is
2173
+ // stale; never fall back to creating, restricting or deleting anything
2174
+ // here until a fresh plan supplies it.
2175
+ const isPlatformApexOrg = Boolean(platformApex && mailDomain.toLowerCase() === platformApex.toLowerCase());
2176
+ if (isPlatformApexOrg) {
2177
+ if (!platformLettermintProjectId)
2178
+ return;
2179
+ const [projects, envs] = await Promise.all([
2180
+ this.ports.lettermint.listProjects(),
2181
+ this.ports.coolify.listEnvs(appId),
2182
+ ]);
2183
+ // Review round 2, finding 5: a missing held token would otherwise
2184
+ // leave the app with a stale or absent `LETTERMINT_API_TOKEN` and no
2185
+ // signal that anything is wrong -- refused instead, the same "fail
2186
+ // rather than silently point mail at nothing" rule `coolifyMail`
2187
+ // already follows for its own `apex`/`mailHost`.
2188
+ const heldToken = (await this.ports.heldKeys?.resolve(PLATFORM_AUTH_BROKER_MAIL_KEY_REF)) ?? null;
2189
+ if (!heldToken) {
2190
+ throw new Error(`coolify:app for ${org.slug}: the platform's own Lettermint token is not held yet (held-key ref ${PLATFORM_AUTH_BROKER_MAIL_KEY_REF})`);
2191
+ }
2192
+ // Validates and restricts before any env is written -- `ensureLettermintDomainRestriction`'s
2193
+ // own doc comment (review round 2, finding 4) throws on a mistyped id,
2194
+ // and the app must never end up with a pushed token the restriction
2195
+ // step then fails on, the same "never half-ready" order
2196
+ // {@link ensureOrgLettermintRelay} already follows for its own token.
2197
+ await this.ensureLettermintDomainRestriction(org, mailDomain, projects, platformApex, platformLettermintProjectId);
2198
+ const currentToken = envs.find((e) => e.key === 'LETTERMINT_API_TOKEN' && !e.is_preview)?.value;
2199
+ const tokenChanged = currentToken !== heldToken;
2200
+ await this.ports.coolify.bulkUpsertEnvs(appId, [
2201
+ { key: 'MAIL_FROM', value: `${org.mail.noReplyLocalPart ?? 'no-reply'}@${mailDomain}` },
2202
+ { key: 'LETTERMINT_API_TOKEN', value: heldToken },
2203
+ ]);
2204
+ // Same restart rule as the non-apex path below: a running container
2205
+ // read its env at start and keeps a stale token until restarted.
2206
+ if (tokenChanged && running)
2207
+ await this.ports.coolify.restart(appId);
2208
+ return;
2209
+ }
1916
2210
  const [projects, envs] = await Promise.all([
1917
2211
  this.ports.lettermint.listProjects(),
1918
2212
  this.ports.coolify.listEnvs(appId),
@@ -1943,9 +2237,11 @@ export class FoundryStepExecutor {
1943
2237
  // old project is deleted below. A just-created app starts with the new env.
1944
2238
  if (!healthy && running)
1945
2239
  await this.ports.coolify.restart(appId);
1946
- await this.ensureLettermintDomainRestriction(org, mailDomain, allProjects);
2240
+ await this.ensureLettermintDomainRestriction(org, mailDomain, allProjects, platformApex, platformLettermintProjectId);
1947
2241
  for (const project of allProjects) {
1948
- if (project.name === appName && project.id !== keepProjectId) {
2242
+ if (project.name === appName &&
2243
+ project.id !== keepProjectId &&
2244
+ project.id !== platformLettermintProjectId) {
1949
2245
  await this.ports.lettermint.deleteProject(project.id);
1950
2246
  }
1951
2247
  }
@@ -1983,8 +2279,14 @@ export class FoundryStepExecutor {
1983
2279
  * it the same way the app step does, rather than assuming it already
1984
2280
  * exists -- the domain must never end up unrestricted, whichever step gets
1985
2281
  * there first.
2282
+ *
2283
+ * #491: this org's own `<slug>-relay` project is unaffected by whether it
2284
+ * is the platform's own org -- only the domain restriction
2285
+ * (`platformApex`/`platformLettermintProjectId`, forwarded to
2286
+ * {@link ensureLettermintDomainRestriction}) and the delete loop below
2287
+ * (which must never delete the platform's own project) change.
1986
2288
  */
1987
- async ensureOrgLettermintRelay(org, mailOrg, mailDomain, context) {
2289
+ async ensureOrgLettermintRelay(org, mailOrg, mailDomain, context, platformApex, platformLettermintProjectId) {
1988
2290
  const relayName = `${org.slug}-relay`;
1989
2291
  // #345: `mailOrg.domain` is absent for a level-1 org -- `mailDomain` is
1990
2292
  // `applyOrgMail`'s own resolved result, the one domain `MailPort`'s
@@ -2003,7 +2305,7 @@ export class FoundryStepExecutor {
2003
2305
  let keepProjectId;
2004
2306
  if (healthy && held) {
2005
2307
  keepProjectId = held.id;
2006
- await this.ensureLettermintDomainRestriction(org, mailDomain, allProjects);
2308
+ await this.ensureLettermintDomainRestriction(org, mailDomain, allProjects, platformApex, platformLettermintProjectId);
2007
2309
  }
2008
2310
  else {
2009
2311
  const created = await this.ports.lettermint.createProject(relayName);
@@ -2014,7 +2316,7 @@ export class FoundryStepExecutor {
2014
2316
  // reaches Stalwart: a failure here leaves the working route alone.
2015
2317
  // The restriction covers old and new projects, so the old one keeps
2016
2318
  // sending until the route switches.
2017
- await this.ensureLettermintDomainRestriction(org, mailDomain, allProjects);
2319
+ await this.ensureLettermintDomainRestriction(org, mailDomain, allProjects, platformApex, platformLettermintProjectId);
2018
2320
  await this.ports.mail.applyOrgRelay(relayOrg, created.token);
2019
2321
  }
2020
2322
  catch (err) {
@@ -2027,7 +2329,9 @@ export class FoundryStepExecutor {
2027
2329
  await context.persistScopeIds();
2028
2330
  }
2029
2331
  for (const project of allProjects) {
2030
- if (project.name === relayName && project.id !== keepProjectId) {
2332
+ if (project.name === relayName &&
2333
+ project.id !== keepProjectId &&
2334
+ project.id !== platformLettermintProjectId) {
2031
2335
  await this.ports.lettermint.deleteProject(project.id);
2032
2336
  }
2033
2337
  }
@@ -2059,10 +2363,31 @@ export class FoundryStepExecutor {
2059
2363
  * unrestricted domain cannot verify and cannot send, whichever project
2060
2364
  * owns it (post-#140 review finding, restated here rather than only at
2061
2365
  * the one call site it used to have).
2366
+ *
2367
+ * #491: `platformApex`/`platformLettermintProjectId` cover both callers at
2368
+ * once -- when `mailDomain` is the platform's own apex, the platform's
2369
+ * project id is always folded into the restriction, so a plan that shares
2370
+ * the platform's project (rather than minting a `<slug>-app` one) never
2371
+ * ends up restricting that org's domain to nothing. A no-op for every
2372
+ * other org's domain, since `mailDomain` never matches `platformApex` there.
2373
+ * Review round 2, finding 4: a declared id that is not among `allProjects`
2374
+ * (a mistype, or the project was deleted in Lettermint) is refused rather
2375
+ * than folded in anyway -- that would restrict the apex to whatever *is*
2376
+ * live (the relay project, say) plus an id naming nothing, which excludes
2377
+ * the real platform project from its own domain exactly as completely as
2378
+ * never restricting it at all. Centralised here rather than duplicated at
2379
+ * each caller, since both {@link ensureAppLettermintMail} and
2380
+ * {@link ensureOrgLettermintRelay} reach the apex through this one method.
2062
2381
  */
2063
- async ensureLettermintDomainRestriction(org, mailDomain, allProjects) {
2382
+ async ensureLettermintDomainRestriction(org, mailDomain, allProjects, platformApex, platformLettermintProjectId) {
2064
2383
  const appName = `${org.slug}-app`;
2065
2384
  const relayName = `${org.slug}-relay`;
2385
+ const isPlatformApexDomain = Boolean(platformApex && mailDomain.toLowerCase() === platformApex.toLowerCase());
2386
+ if (isPlatformApexDomain && platformLettermintProjectId) {
2387
+ if (!allProjects.some((p) => p.id === platformLettermintProjectId)) {
2388
+ throw new Error(`Lettermint for ${org.slug}: platform.mail.lettermintProjectId ${platformLettermintProjectId} is not a live Lettermint project`);
2389
+ }
2390
+ }
2066
2391
  const existing = await this.findExactLettermintDomain(mailDomain);
2067
2392
  const domain = existing
2068
2393
  ? await this.ports.lettermint.getDomain(existing.id)
@@ -2070,6 +2395,11 @@ export class FoundryStepExecutor {
2070
2395
  const scopedProjectIds = allProjects
2071
2396
  .filter((p) => p.name === appName || p.name === relayName)
2072
2397
  .map((p) => p.id);
2398
+ if (isPlatformApexDomain &&
2399
+ platformLettermintProjectId &&
2400
+ !scopedProjectIds.includes(platformLettermintProjectId)) {
2401
+ scopedProjectIds.push(platformLettermintProjectId);
2402
+ }
2073
2403
  await this.ports.lettermint.restrictDomainToProjects(domain.id, scopedProjectIds);
2074
2404
  const zone = await this.ports.cloudflare.findZoneByApex(mailDomain);
2075
2405
  if (!zone) {
@@ -2161,16 +2491,22 @@ export class FoundryStepExecutor {
2161
2491
  const projectUuid = await this.ensureCoolifyProject(org, context);
2162
2492
  const { environment } = await this.ports.coolify.findOrCreateEnvironment(projectUuid, 'production');
2163
2493
  const site = await this.findOrCreateCmsApp({
2494
+ org,
2495
+ context,
2164
2496
  projectUuid,
2165
2497
  environment,
2166
2498
  name: `${org.slug}-site`,
2499
+ repo: org.site.repo ?? CMS_REPO,
2167
2500
  dockerfileLocation: CMS_SITE_APP_DOCKERFILE,
2168
2501
  domain,
2169
2502
  });
2170
2503
  const cms = await this.findOrCreateCmsApp({
2504
+ org,
2505
+ context,
2171
2506
  projectUuid,
2172
2507
  environment,
2173
2508
  name: `${org.slug}-cms`,
2509
+ repo: CMS_REPO,
2174
2510
  dockerfileLocation: CMS_APP_DOCKERFILE,
2175
2511
  domain: cmsHostnameFor(org),
2176
2512
  });
@@ -2281,7 +2617,7 @@ export class FoundryStepExecutor {
2281
2617
  const found = (await this.ports.coolify.listDatabases()).find((d) => d.name === name && d.environment_id === environment.id);
2282
2618
  if (found)
2283
2619
  return found.uuid;
2284
- const server = await this.resolveServer();
2620
+ const server = await this.resolveServer(org, context);
2285
2621
  const dbName = name.replace(/-/g, '_');
2286
2622
  const created = await this.ports.coolify.createPostgresDatabase({
2287
2623
  project_uuid: projectUuid,
@@ -2368,6 +2704,10 @@ export class FoundryStepExecutor {
2368
2704
  * `coolifyApp`'s own analogous gap (it never restarted a found-but-stopped
2369
2705
  * app either) has since closed the same way, inline in that method (#130);
2370
2706
  * this method is `cmsSite`'s only caller.
2707
+ *
2708
+ * `repo` (#245) lets the site app build from an org's own skin repo
2709
+ * (`org.site.repo`) instead of the shared `CMS_REPO`; `cmsSite` always
2710
+ * passes `CMS_REPO` for the cms admin app, which is never org-specific.
2371
2711
  */
2372
2712
  async findOrCreateCmsApp(input) {
2373
2713
  const existing = await this.ports.coolify.findApplicationByName(input.name, {
@@ -2380,7 +2720,7 @@ export class FoundryStepExecutor {
2380
2720
  }
2381
2721
  return { appId: existing.uuid, existed: true };
2382
2722
  }
2383
- const server = await this.resolveServer();
2723
+ const server = await this.resolveServer(input.org, input.context);
2384
2724
  const githubApp = await this.ports.coolify.requireGithubApp();
2385
2725
  const created = await this.ports.coolify.createGithubAppApplication({
2386
2726
  project_uuid: input.projectUuid,
@@ -2388,7 +2728,7 @@ export class FoundryStepExecutor {
2388
2728
  environment_name: input.environment.name,
2389
2729
  environment_uuid: input.environment.uuid,
2390
2730
  github_app_uuid: githubApp.uuid,
2391
- git_repository: CMS_REPO,
2731
+ git_repository: input.repo,
2392
2732
  git_branch: 'main',
2393
2733
  build_pack: 'dockerfile',
2394
2734
  name: input.name,
@@ -2426,16 +2766,16 @@ export class FoundryStepExecutor {
2426
2766
  }
2427
2767
  const projectUuid = await this.ensureCoolifyProject(org, context);
2428
2768
  const { environment } = await this.ports.coolify.findOrCreateEnvironment(projectUuid, 'production');
2429
- const server = await this.resolveServer();
2769
+ const server = await this.resolveServer(org, context);
2430
2770
  const names = {
2431
2771
  synapse: `${org.slug}-calls-synapse`,
2432
2772
  mas: `${org.slug}-calls-mas`,
2433
2773
  synapseDb: `${org.slug}-calls-synapse-db`,
2434
2774
  masDb: `${org.slug}-calls-mas-db`,
2435
2775
  };
2436
- const synapseDbId = await this.ensureCallsDatabase(context, projectUuid, environment, names.synapseDb, 'synapse', CALLS_SYNAPSE_DB_SCOPE_KEY, CALLS_SYNAPSE_DB_INITDB_ARGS);
2776
+ const synapseDbId = await this.ensureCallsDatabase(org, context, projectUuid, environment, names.synapseDb, 'synapse', CALLS_SYNAPSE_DB_SCOPE_KEY, CALLS_SYNAPSE_DB_INITDB_ARGS);
2437
2777
  context.recordScopeId(CALLS_SYNAPSE_DB_SCOPE_KEY, synapseDbId);
2438
- const masDbId = await this.ensureCallsDatabase(context, projectUuid, environment, names.masDb, 'mas', CALLS_MAS_DB_SCOPE_KEY);
2778
+ const masDbId = await this.ensureCallsDatabase(org, context, projectUuid, environment, names.masDb, 'mas', CALLS_MAS_DB_SCOPE_KEY);
2439
2779
  context.recordScopeId(CALLS_MAS_DB_SCOPE_KEY, masDbId);
2440
2780
  const [synapseDbRow, masDbRow] = await Promise.all([
2441
2781
  getDatabaseStarted(this.ports.coolify, synapseDbId),
@@ -2589,14 +2929,14 @@ export class FoundryStepExecutor {
2589
2929
  * database, whose {@link CALLS_SERVICE_DB_SCOPE_KEY} has no
2590
2930
  * `system:kind:key` slot of its own for the same reason.
2591
2931
  */
2592
- async ensureCallsDatabase(context, projectUuid, environment, name, postgresDb, scopeKey, postgresInitdbArgs) {
2932
+ async ensureCallsDatabase(org, context, projectUuid, environment, name, postgresDb, scopeKey, postgresInitdbArgs) {
2593
2933
  const existing = context.recordedIds()[scopeKey];
2594
2934
  if (existing)
2595
2935
  return existing;
2596
2936
  const found = (await this.ports.coolify.listDatabases()).find((d) => d.name === name && d.environment_id === environment.id);
2597
2937
  if (found)
2598
2938
  return found.uuid;
2599
- const server = await this.resolveServer();
2939
+ const server = await this.resolveServer(org, context);
2600
2940
  const created = await this.ports.coolify.createPostgresDatabase({
2601
2941
  project_uuid: projectUuid,
2602
2942
  server_uuid: server.uuid,
@@ -2699,6 +3039,34 @@ export class FoundryStepExecutor {
2699
3039
  async coolifyChat(action, org, context) {
2700
3040
  const recorded = context.recordedIds();
2701
3041
  if (recorded[`coolify:chat:${action.key}`]) {
3042
+ // #456: `planChat` plans this distinct, non-re-key action for a live
3043
+ // chat app that predates the owner/runtime database split (#239) --
3044
+ // push only the split and redeploy if it changed, never the re-key
3045
+ // paths below, which throw on literally any other action here.
3046
+ if (action.desired.refreshDatabaseSplit === true) {
3047
+ const serviceAppId = recorded[CHAT_SERVICE_SCOPE_KEY];
3048
+ const serviceDbId = recorded[CHAT_SERVICE_DB_SCOPE_KEY];
3049
+ if (!serviceAppId || !serviceDbId) {
3050
+ throw new Error(`coolify:chat database split for ${org.slug}: the chat service app or its database is not recorded yet`);
3051
+ }
3052
+ const push = await this.pushDatabaseUrl(serviceAppId, serviceDbId, 'chat');
3053
+ const markerKey = serviceDbEnvAppliedScopeKey('chat');
3054
+ if (!push.pushed) {
3055
+ // Unlike a fresh provision, this chat was already fully up and
3056
+ // running -- its service database was already started (the main
3057
+ // path below never creates an app before confirming that). A push
3058
+ // refused here means the recorded database id no longer resolves
3059
+ // to a started database with an `internal_db_url`, which is not a
3060
+ // "retry once Coolify catches up" condition: fail loudly instead of
3061
+ // replanning this same step silently forever (#456 review).
3062
+ throw new Error(`coolify:chat database split for ${org.slug}: the chat service's database (${serviceDbId}) has no internal_db_url -- it may have been deleted or is unreachable; this is not expected for an already-live chat`);
3063
+ }
3064
+ if (push.changed || !recorded[markerKey]) {
3065
+ await this.ports.coolify.deploy(serviceAppId);
3066
+ }
3067
+ context.recordScopeId(markerKey, '1');
3068
+ return { resourceId: serviceAppId };
3069
+ }
2702
3070
  if (action.desired.usesDomain === false) {
2703
3071
  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)');
2704
3072
  }
@@ -2722,13 +3090,13 @@ export class FoundryStepExecutor {
2722
3090
  await this.ensureChatAdmins(org, zitadelOrgId, project.id, recorded);
2723
3091
  const projectUuid = await this.ensureCoolifyProject(org, context);
2724
3092
  const { environment } = await this.ports.coolify.findOrCreateEnvironment(projectUuid, 'production');
2725
- const server = await this.resolveServer();
3093
+ const server = await this.resolveServer(org, context);
2726
3094
  const names = chatInternalNames(org.slug);
2727
- const synapseDbId = await this.ensureCallsDatabase(context, projectUuid, environment, names.synapseDb, 'synapse', CHAT_SYNAPSE_DB_SCOPE_KEY, CALLS_SYNAPSE_DB_INITDB_ARGS);
3095
+ const synapseDbId = await this.ensureCallsDatabase(org, context, projectUuid, environment, names.synapseDb, 'synapse', CHAT_SYNAPSE_DB_SCOPE_KEY, CALLS_SYNAPSE_DB_INITDB_ARGS);
2728
3096
  context.recordScopeId(CHAT_SYNAPSE_DB_SCOPE_KEY, synapseDbId);
2729
- const masDbId = await this.ensureCallsDatabase(context, projectUuid, environment, names.masDb, 'mas', CHAT_MAS_DB_SCOPE_KEY);
3097
+ const masDbId = await this.ensureCallsDatabase(org, context, projectUuid, environment, names.masDb, 'mas', CHAT_MAS_DB_SCOPE_KEY);
2730
3098
  context.recordScopeId(CHAT_MAS_DB_SCOPE_KEY, masDbId);
2731
- const serviceDbId = await this.ensureCallsDatabase(context, projectUuid, environment, names.serviceDb, 'chat', CHAT_SERVICE_DB_SCOPE_KEY);
3099
+ const serviceDbId = await this.ensureCallsDatabase(org, context, projectUuid, environment, names.serviceDb, 'chat', CHAT_SERVICE_DB_SCOPE_KEY);
2732
3100
  context.recordScopeId(CHAT_SERVICE_DB_SCOPE_KEY, serviceDbId);
2733
3101
  const [synapseDbRow, masDbRow, serviceDbRow] = await Promise.all([
2734
3102
  getDatabaseStarted(this.ports.coolify, synapseDbId),
@@ -2841,8 +3209,13 @@ export class FoundryStepExecutor {
2841
3209
  agoraHost,
2842
3210
  masClientId: secrets.value.masClientId,
2843
3211
  masClientSecret: secrets.value.masClientSecret,
2844
- databaseUrl: serviceDbRow.internal_db_url,
2845
3212
  }));
3213
+ // Split the same way `coolifyApp`'s and `cmsSite`'s own databases are
3214
+ // (#239, #456): `DATABASE_URL_OWNER` is the superuser string,
3215
+ // `DATABASE_URL` is a minted `chat_rt` role -- `chat` is the literal
3216
+ // `postgres_db`/`postgres_user` `ensureCallsDatabase` created the service
3217
+ // database with above, not the Coolify resource name `names.serviceDb`.
3218
+ const serviceDbPush = await this.pushDatabaseUrl(service.appId, serviceDbId, 'chat');
2846
3219
  // A file-storage write alone never reaches a running container. Deploy an
2847
3220
  // app that is new, whose config was freshly minted, or that is not
2848
3221
  // running now. The last test reads the live status, not what this run
@@ -2853,10 +3226,19 @@ export class FoundryStepExecutor {
2853
3226
  for (const app of [mas, synapse, service]) {
2854
3227
  if (!app.existed ||
2855
3228
  secrets.changed ||
3229
+ (app === service && serviceDbPush.changed) ||
2856
3230
  !isRunningOrStarting((await this.ports.coolify.getApplication(app.appId)).status)) {
2857
3231
  await this.ports.coolify.deploy(app.appId);
2858
3232
  }
2859
3233
  }
3234
+ // #456: recorded here too, not only on the `refreshDatabaseSplit` replan
3235
+ // path above -- without it a fresh provision's very first apply never
3236
+ // sets this marker, so `planChat` (`!recordedIds[serviceDbEnvAppliedScopeKey('chat')]`)
3237
+ // reads it as still missing and plans a pointless extra redeploy on the
3238
+ // very next apply for an org that was never behind.
3239
+ if (serviceDbPush.pushed) {
3240
+ context.recordScopeId(serviceDbEnvAppliedScopeKey('chat'), '1');
3241
+ }
2860
3242
  // Last: the marker tells `desiredAgoraEnv` the names and envs are in place.
2861
3243
  context.recordScopeId(CHAT_SERVICE_URL_SCOPE_KEY, chatUpstreams(org.slug).chat);
2862
3244
  return { resourceId: mas.appId };
@@ -2904,13 +3286,13 @@ export class FoundryStepExecutor {
2904
3286
  const synapseHost = `${org.slug}-calls-synapse`;
2905
3287
  const masHost = `${org.slug}-calls-mas`;
2906
3288
  const dbName = `${name}-db`;
2907
- const dbId = await this.ensureCallsDatabase(context, projectUuid, environment, dbName, dbName.replace(/-/g, '_'), CALLS_SERVICE_DB_SCOPE_KEY);
3289
+ const dbId = await this.ensureCallsDatabase(org, context, projectUuid, environment, dbName, dbName.replace(/-/g, '_'), CALLS_SERVICE_DB_SCOPE_KEY);
2908
3290
  context.recordScopeId(CALLS_SERVICE_DB_SCOPE_KEY, dbId);
2909
3291
  const db = await getDatabaseStarted(this.ports.coolify, dbId);
2910
3292
  if (!db.internal_db_url) {
2911
3293
  throw new Error(`coolify:calls-service for ${org.slug}: its database has no internal_db_url yet -- retry this apply once Coolify reports one`);
2912
3294
  }
2913
- const server = await this.resolveServer();
3295
+ const server = await this.resolveServer(org, context);
2914
3296
  const githubApp = await this.ports.coolify.requireGithubApp();
2915
3297
  const app = await this.findOrCreateCallsApp(name, environment.id, () => this.ports.coolify.createGithubAppApplication({
2916
3298
  project_uuid: projectUuid,
@@ -2936,7 +3318,6 @@ export class FoundryStepExecutor {
2936
3318
  await this.ensureLogVolume(app.appId, org, app.existed);
2937
3319
  const bindingSecret = await this.ensureCallsBindingSecret(app.appId);
2938
3320
  const envs = [
2939
- { key: 'DATABASE_URL', value: db.internal_db_url },
2940
3321
  { key: 'LIVEKIT_URL', value: CALLS_SFU_PRIVATE_URL },
2941
3322
  { key: 'LIVEKIT_PUBLIC_URL', value: CALLS_SFU_PUBLIC_URL },
2942
3323
  { key: 'LIVEKIT_API_KEY', value: sfu.apiKey },
@@ -2975,17 +3356,40 @@ export class FoundryStepExecutor {
2975
3356
  // unguarded and the planner reports the lost secret instead of minting again.
2976
3357
  if (token)
2977
3358
  context.recordScopeId(CALLS_INTROSPECTION_SCOPE_KEY, token.appId);
3359
+ // Split the same way `coolifyApp`'s and `cmsSite`'s own databases are
3360
+ // (#239, #456): `DATABASE_URL_OWNER` is the superuser string,
3361
+ // `DATABASE_URL` is a minted `<dbName>_rt` role -- the real Postgres
3362
+ // database name `ensureCallsDatabase` created this database with, not
3363
+ // the Coolify resource name.
3364
+ const dbPush = await this.pushDatabaseUrl(app.appId, dbId, dbName.replace(/-/g, '_'));
3365
+ // #456: a crash between the push above and the deploy/marker below
3366
+ // leaves the env right and no marker, so this replans and deploys once
3367
+ // more -- the same rule `coolifyApp`'s own `dbEnvAppliedScopeKey` marker
3368
+ // follows.
3369
+ const dbMarkerKey = serviceDbEnvAppliedScopeKey('calls-service');
3370
+ const dbSplitNeedsRefresh = dbPush.pushed && (dbPush.changed || !context.recordedIds()[dbMarkerKey]);
2978
3371
  // A container reads its name and env only at start, so a running app is
2979
3372
  // restarted once, when this run is the one that gave it its name or its
2980
3373
  // token env (the same rule `coolifyApp` follows when an existing app's
2981
- // env changed).
3374
+ // env changed). The database split is different: it is only safe once the
3375
+ // image actually contains the migrate step that creates the `_rt` role
3376
+ // (the sibling calls-service PR adding it), so that case redeploys --
3377
+ // rebuilding the image -- rather than restarting the one already running,
3378
+ // the same distinction `coolifyChat`'s own refresh path makes. A restart
3379
+ // here would bring back a container built before that migrate step
3380
+ // existed, still pointed at a role nothing has created.
2982
3381
  const givesTokenEnv = token !== null && context.recordedIds()[CALLS_SERVICE_TOKEN_ENV_SCOPE_KEY] !== tokenMarker;
2983
- if (app.existed && (!callsServiceHasName(org, context.recordedIds()) || givesTokenEnv)) {
3382
+ if (app.existed && dbSplitNeedsRefresh) {
3383
+ await this.ports.coolify.deploy(app.appId);
3384
+ }
3385
+ else if (app.existed && (!callsServiceHasName(org, context.recordedIds()) || givesTokenEnv)) {
2984
3386
  await this.ports.coolify.restart(app.appId);
2985
3387
  }
2986
3388
  context.recordScopeId(CALLS_SERVICE_NAME_SCOPE_KEY, name);
2987
3389
  if (token && tokenMarker)
2988
3390
  context.recordScopeId(CALLS_SERVICE_TOKEN_ENV_SCOPE_KEY, tokenMarker);
3391
+ if (dbPush.pushed)
3392
+ context.recordScopeId(dbMarkerKey, '1');
2989
3393
  return { resourceId: app.appId };
2990
3394
  }
2991
3395
  /**
@@ -3123,6 +3527,19 @@ export class FoundryStepExecutor {
3123
3527
  * pushed once calls are on and this org's calls service app carries its
3124
3528
  * internal name (`desiredAgoraEnv` reads that marker from the recorded ids,
3125
3529
  * not the host values).
3530
+ *
3531
+ * #442: a found-but-already-running app's container binds env only at
3532
+ * start, so writing it alone leaves that process on its old config --
3533
+ * restarted (never redeployed: no new image, so `restart` is the same
3534
+ * primitive `ensureLogVolume`'s own restart uses) whenever the live env
3535
+ * actually differs, or whenever the last restart this step confirmed
3536
+ * ({@link AGORA_ACTIVATED_ENV_SCOPE_KEY}) is not this exact env (an org
3537
+ * applied before this existed, whose live env already matched desired
3538
+ * with nothing ever confirmed running it). A brand new app has nothing
3539
+ * running yet to restart. The activation marker is recorded last, after
3540
+ * the restart (if any) returns without throwing, so a failed restart
3541
+ * leaves it stale and the next plan replans this step even though
3542
+ * Coolify's own env store already matches desired.
3126
3543
  */
3127
3544
  async coolifyAgora(action, org, context) {
3128
3545
  // The host values the plan read, carried in the step: never read again
@@ -3138,7 +3555,7 @@ export class FoundryStepExecutor {
3138
3555
  const projectUuid = await this.ensureCoolifyProject(org, context);
3139
3556
  const { environment } = await this.ports.coolify.findOrCreateEnvironment(projectUuid, 'production');
3140
3557
  const name = `${org.slug}-agora`;
3141
- const server = await this.resolveServer();
3558
+ const server = await this.resolveServer(org, context);
3142
3559
  const githubApp = await this.ports.coolify.requireGithubApp();
3143
3560
  const app = await this.findOrCreateCallsApp(name, environment.id, () => this.ports.coolify.createGithubAppApplication({
3144
3561
  project_uuid: projectUuid,
@@ -3155,7 +3572,20 @@ export class FoundryStepExecutor {
3155
3572
  }));
3156
3573
  // Every app foundry deploys keeps its own logs (#75), reused as-is here.
3157
3574
  await this.ensureLogVolume(app.appId, org, app.existed);
3575
+ // #442: read before overwriting -- `bulkUpsertEnvs` below always leaves
3576
+ // Coolify's own env store matching `env`, which is why that store alone
3577
+ // can no longer tell a changed value from one already live.
3578
+ const liveEnv = new Map((await this.ports.coolify.listEnvs(app.appId))
3579
+ .filter((e) => !e.is_preview)
3580
+ .map((e) => [e.key, e.value]));
3581
+ const envChanged = agoraEnvDrift(env, Object.fromEntries(liveEnv)).length > 0;
3158
3582
  await this.ports.coolify.bulkUpsertEnvs(app.appId, Object.entries(env).map(([key, value]) => ({ key, value })));
3583
+ const revision = agoraEnvRevision(env);
3584
+ const alreadyActivated = context.recordedIds()[AGORA_ACTIVATED_ENV_SCOPE_KEY] === revision;
3585
+ if (app.existed && (envChanged || !alreadyActivated)) {
3586
+ await this.ports.coolify.restart(app.appId);
3587
+ }
3588
+ context.recordScopeId(AGORA_ACTIVATED_ENV_SCOPE_KEY, revision);
3159
3589
  return { resourceId: app.appId };
3160
3590
  }
3161
3591
  // ---- boule (#161) --------------------------------------------------------
@@ -3195,9 +3625,9 @@ export class FoundryStepExecutor {
3195
3625
  const { environment } = await this.ports.coolify.findOrCreateEnvironment(projectUuid, 'production');
3196
3626
  const name = `${org.slug}-boule`;
3197
3627
  const dbName = `${org.slug}-boule-db`.replace(/-/g, '_');
3198
- const dbId = await this.ensureCallsDatabase(context, projectUuid, environment, `${org.slug}-boule-db`, dbName, BOULE_DATABASE_SCOPE_KEY);
3628
+ const dbId = await this.ensureCallsDatabase(org, context, projectUuid, environment, `${org.slug}-boule-db`, dbName, BOULE_DATABASE_SCOPE_KEY);
3199
3629
  context.recordScopeId(BOULE_DATABASE_SCOPE_KEY, dbId);
3200
- const server = await this.resolveServer();
3630
+ const server = await this.resolveServer(org, context);
3201
3631
  const githubApp = await this.ports.coolify.requireGithubApp();
3202
3632
  const app = await this.findOrCreateCallsApp(name, environment.id, () => this.ports.coolify.createGithubAppApplication({
3203
3633
  project_uuid: projectUuid,
@@ -3409,7 +3839,7 @@ export class FoundryStepExecutor {
3409
3839
  appName: `${org.slug}-cloudflared`,
3410
3840
  projectUuid,
3411
3841
  environment,
3412
- resolveServer: () => this.resolveServer(),
3842
+ resolveServer: () => this.resolveServer(org, context),
3413
3843
  recordedConnectorAppId: context.recordedIds()[CLOUDFLARE_TUNNEL_CONNECTOR_APP_SCOPE_KEY] ?? null,
3414
3844
  recordAppId: async (appId) => {
3415
3845
  context.recordScopeId(CLOUDFLARE_TUNNEL_CONNECTOR_APP_SCOPE_KEY, appId);
@@ -3783,22 +4213,26 @@ export class FoundryStepExecutor {
3783
4213
  throw new Error(`coolify:mail for ${org.slug}: this step's plan carries no platform mail ${!apex && !mailHost ? 'apex or host' : !apex ? 'apex' : 'host'}; plan this org's mail again`);
3784
4214
  }
3785
4215
  const options = { apex, mailHost };
4216
+ // #491: `null` unless `diff.ts`'s `planMail` carries them -- absent for
4217
+ // every org but the platform's own, and for a plan predating this field.
4218
+ const platformApex = action.desired.platformApex ?? null;
4219
+ const platformLettermintProjectId = action.desired.platformLettermintProjectId ?? null;
3786
4220
  if (mode === 'off') {
3787
4221
  const parkedMailOrg = mailOrgFor(org, usesDomain, 'hosted');
3788
4222
  await this.ports.mail.parkOrgMail(parkedMailOrg, options);
3789
- await this.parkLettermintMail(org, parkedMailOrg, context);
4223
+ await this.parkLettermintMail(org, parkedMailOrg, context, platformApex, platformLettermintProjectId);
3790
4224
  return undefined;
3791
4225
  }
3792
4226
  const mailOrg = mailOrgFor(org, usesDomain, mode);
3793
4227
  const result = await this.ports.mail.applyOrgMail(mailOrg, options);
3794
4228
  await this.publishMailDnsRecords(org, result);
3795
- await this.deleteStaleLettermintDomain(context, result.domain);
4229
+ await this.deleteStaleLettermintDomain(context, result.domain, platformApex);
3796
4230
  context.recordScopeId(MAIL_DOMAIN_SCOPE_KEY, result.domain);
3797
4231
  // #177: what this step wrote from the platform, so `planMail` can see a later change.
3798
4232
  context.recordScopeId(ORG_MAIL_APPLIED_APEX_SCOPE_KEY, options.apex);
3799
4233
  context.recordScopeId(ORG_MAIL_APPLIED_HOST_SCOPE_KEY, options.mailHost);
3800
4234
  await this.ensureOwnerMailbox(org, mailOrg, options, context);
3801
- await this.ensureOrgLettermintRelay(org, mailOrg, result.domain, context);
4235
+ await this.ensureOrgLettermintRelay(org, mailOrg, result.domain, context, platformApex, platformLettermintProjectId);
3802
4236
  await this.ensureRoleMailboxes(org, mailOrg, result.domain, options, context);
3803
4237
  return { resourceId: result.domain };
3804
4238
  }
@@ -3894,11 +4328,20 @@ export class FoundryStepExecutor {
3894
4328
  * domain recorded) and on a same-domain re-apply (compared
3895
4329
  * case-insensitively, the same rule {@link findExactLettermintDomain}
3896
4330
  * applies to the lookup itself).
4331
+ *
4332
+ * #491 review: also a no-op when the previous domain is the platform's own
4333
+ * apex -- an org that moves its mail *off* that domain (`usesDomain` flips,
4334
+ * or `org.domain` changes) must never delete it here, the same protection
4335
+ * {@link parkLettermintMail} already gives the apex on a move to `off`.
4336
+ * `planMail` only blocks a move *onto* the apex (no project id yet); a move
4337
+ * away from it reaches this method with nothing to block it otherwise.
3897
4338
  */
3898
- async deleteStaleLettermintDomain(context, newDomain) {
4339
+ async deleteStaleLettermintDomain(context, newDomain, platformApex) {
3899
4340
  const previous = context.recordedIds()[MAIL_DOMAIN_SCOPE_KEY];
3900
4341
  if (!previous || previous.toLowerCase() === newDomain.toLowerCase())
3901
4342
  return;
4343
+ if (platformApex && previous.toLowerCase() === platformApex.toLowerCase())
4344
+ return;
3902
4345
  const stale = await this.findExactLettermintDomain(previous);
3903
4346
  if (stale)
3904
4347
  await this.ports.lettermint.deleteDomain(stale.id);
@@ -3970,20 +4413,28 @@ export class FoundryStepExecutor {
3970
4413
  * A no-op until a previous apply recorded {@link MAIL_DOMAIN_SCOPE_KEY}:
3971
4414
  * nothing was ever provisioned for an org whose mail went straight from
3972
4415
  * never-applied to off.
4416
+ *
4417
+ * #491: when `mailDomain` is the platform's own apex, this must never
4418
+ * delete the platform's own project (the delete loop skips it by id) or
4419
+ * the platform's own domain (returns before the domain lookup/delete).
3973
4420
  */
3974
- async parkLettermintMail(org, mailOrg, context) {
4421
+ async parkLettermintMail(org, mailOrg, context, platformApex, platformLettermintProjectId) {
3975
4422
  const mailDomain = context.recordedIds()[MAIL_DOMAIN_SCOPE_KEY];
3976
4423
  if (!mailDomain)
3977
4424
  return;
4425
+ const isPlatformApexOrg = Boolean(platformApex && mailDomain.toLowerCase() === platformApex.toLowerCase());
3978
4426
  // #345: `mailOrg.domain` is absent for a level-1 org -- the previously
3979
4427
  // recorded mail domain is the one `MailPort.removeOrgRelay` needs.
3980
4428
  await this.ports.mail.removeOrgRelay({ ...mailOrg, domain: mailDomain });
3981
4429
  const names = new Set([`${org.slug}-app`, `${org.slug}-relay`]);
3982
4430
  const projects = await this.ports.lettermint.listProjects();
3983
4431
  for (const project of projects) {
3984
- if (names.has(project.name))
4432
+ if (names.has(project.name) && project.id !== platformLettermintProjectId) {
3985
4433
  await this.ports.lettermint.deleteProject(project.id);
4434
+ }
3986
4435
  }
4436
+ if (isPlatformApexOrg)
4437
+ return;
3987
4438
  const domain = await this.findExactLettermintDomain(mailDomain);
3988
4439
  if (domain)
3989
4440
  await this.ports.lettermint.deleteDomain(domain.id);
@@ -4029,11 +4480,11 @@ export class FoundryStepExecutor {
4029
4480
  }
4030
4481
  // ---- files / ai --------------------------------------------------------
4031
4482
  /**
4032
- * #80: provisions the org's own organisation on Files, before
4033
- * {@link filesStorage} can set its spend limit. `provisionOrganisation` is
4034
- * safely repeatable on its own (`FilesOrganisationPort`'s doc comment), so
4035
- * this is called every time regardless of whether it already ran --
4036
- * idempotent by find-or-create, not by checking `recordedIds` first.
4483
+ * #80: provisions the org's own organisation on Files.
4484
+ * `provisionOrganisation` is safely repeatable on its own
4485
+ * (`FilesOrganisationPort`'s doc comment), so this is called every time
4486
+ * regardless of whether it already ran -- idempotent by find-or-create,
4487
+ * not by checking `recordedIds` first.
4037
4488
  *
4038
4489
  * F6b: `action.desired.shareHost` (`diff.ts`'s `planFilesOrganisation`) is
4039
4490
  * passed through every time, even though `entry-files-key.ts` reports the
@@ -4046,15 +4497,23 @@ export class FoundryStepExecutor {
4046
4497
  * repeat-is-a-no-op shape as `provisionOrganisation` itself -- every org
4047
4498
  * gets an Agora (#164), so `diff.ts`'s `planFilesOrganisation` always sets
4048
4499
  * `desired.agoraOrigin`.
4500
+ *
4501
+ * #437: the owner member/name, the core ZITADEL files project and the core
4502
+ * collection (`core-files.ts`) are no longer conditioned on a hosting entry
4503
+ * declaring `uses.files` -- the core bundle (Agora's own uploads) needs
4504
+ * all three just as much as a bespoke entry does, and withholding them
4505
+ * until one existed is why a plain Agora/Boule org never got a usable
4506
+ * Files setup at all. Both the project and the collection are idempotent
4507
+ * by name, so a re-run that already created either finds it instead.
4049
4508
  */
4050
4509
  async filesOrganisation(action, org, context) {
4051
4510
  const files = await this.filesPort();
4052
- // F6b: an org whose entries use files needs an owner member in its files
4053
- // organisation, which its own key is minted below. Only a new
4054
- // organisation takes one, so it is passed when the owner is known now.
4055
- const owner = org.hosting.entries.some((e) => e.uses?.files)
4056
- ? filesOwnerUserId(org, context.recordedIds())
4057
- : undefined;
4511
+ const recorded = context.recordedIds();
4512
+ // F6b: an organisation takes an owner member and name only while it is
4513
+ // being created, so it is passed whenever the owner is known now --
4514
+ // every org, not only one with a hosting entry declaring `uses.files`
4515
+ // (#437).
4516
+ const owner = filesOwnerUserId(org, recorded);
4058
4517
  const provisioned = await files.provisionOrganisation({
4059
4518
  organisationId: org.id,
4060
4519
  ceilingBytes: action.desired.ceilingBytes ?? null,
@@ -4071,6 +4530,22 @@ export class FoundryStepExecutor {
4071
4530
  if (provisioned.agoraKey) {
4072
4531
  await this.deliverFilesAgoraKey(files, org, provisioned.agoraKey, context);
4073
4532
  }
4533
+ // #437: the audience Agora requests to read/browse Files
4534
+ // (`agoras.ts`'s `filesProjectId`), the same `urn:zitadel:iam:org:
4535
+ // project:id:<id>:aud` shape `messagingProjectId` already uses. Retried
4536
+ // on a later apply, same as the owner above, while the org's ZITADEL
4537
+ // org id is not recorded yet (`zitadel:issuer` has not run).
4538
+ const zitadelOrgId = recorded[ZITADEL_ORG_SCOPE_KEY];
4539
+ if (zitadelOrgId) {
4540
+ const { project } = await this.ports.zitadel.findOrCreateProject(zitadelOrgId, `${org.slug}-files`);
4541
+ context.recordScopeId(ZITADEL_FILES_PROJECT_SCOPE_KEY, project.id);
4542
+ }
4543
+ // #437: the one collection every file Agora itself uploads goes into
4544
+ // (`agoras.ts`'s `filesCollectionId`) -- Files has no route to list an
4545
+ // organisation's collections and an ordinary member cannot create one,
4546
+ // so it has to exist before Agora ever tries.
4547
+ const collection = await files.ensureCollection(org.id, CORE_FILES_COLLECTION_NAME);
4548
+ context.recordScopeId(FILES_CORE_COLLECTION_SCOPE_KEY, collection.id);
4074
4549
  return { resourceId: provisioned.organisationId };
4075
4550
  }
4076
4551
  /**
@@ -4107,12 +4582,6 @@ export class FoundryStepExecutor {
4107
4582
  context.recordScopeId(FILES_AGORA_UNDELIVERED_SCOPE_KEY, '');
4108
4583
  throw new Error(`${at}: Agora's key ${key.id} could not be delivered after ${AI_KEY_DELIVERY_ATTEMPTS} attempts (${cause}); ${revoked ? 'it was revoked again' : 'revoke it in files'}`);
4109
4584
  }
4110
- async filesStorage(action, org) {
4111
- const files = await this.filesPort();
4112
- await files.setQuota(org.id, action.desired.limitBytes ?? null);
4113
- await files.setBudget(org.id, action.desired.limitMicros ?? null);
4114
- return { resourceId: org.id };
4115
- }
4116
4585
  /**
4117
4586
  * #80: provisions the org's own organisation on AI and switches its AI use
4118
4587
  * on, before {@link aiBudget} can set its own spend limit. Both
@@ -4130,13 +4599,14 @@ export class FoundryStepExecutor {
4130
4599
  return { resourceId: provisioned.organisationId };
4131
4600
  }
4132
4601
  async aiBudget(action, org) {
4133
- const limitMicros = action.desired.limitMicros ?? null;
4134
- if (limitMicros === null) {
4135
- // ai/packages/sdk/src/types.ts:134-137's UpdateBudget.limitMicros is a
4136
- // required string -- there is no "uncapped" this API can express yet
4137
- // (Finding, this campaign's report).
4138
- throw new Error(`ai:budget for ${org.slug}: AI's updateBudget has no uncapped representation (UpdateBudget.limitMicros is a required string); declare a numeric limit until AI's API adds one`);
4139
- }
4602
+ // #484: ai/packages/sdk/src/types.ts:134-137's UpdateBudget.limitMicros
4603
+ // is a required string -- there is no "uncapped" this API can express
4604
+ // yet (Finding, this campaign's report) -- so a null `desired.limitMicros`
4605
+ // (an org that declares no limit) gets DEFAULT_AI_LIMIT_MICROS here too,
4606
+ // the same substitution diff.ts's planners already make when they fill
4607
+ // this field. Belt and braces: by the time a plan reaches here,
4608
+ // `action.desired.limitMicros` should already be non-null.
4609
+ const limitMicros = action.desired.limitMicros ?? DEFAULT_AI_LIMIT_MICROS;
4140
4610
  const ai = await this.aiPort();
4141
4611
  await ai.updateBudget(org.id, { keyId: null, limitMicros });
4142
4612
  return { resourceId: org.id };
@@ -4345,6 +4815,165 @@ export class FoundryStepExecutor {
4345
4815
  ids.push(keyId);
4346
4816
  context.recordScopeId(listKey, JSON.stringify(ids));
4347
4817
  }
4818
+ /**
4819
+ * F7 (#317): mints one entry's names key directly below the org (names
4820
+ * takes no separate minter key id, unlike ai/files), writes `NAMES_URL`
4821
+ * and `NAMES_API_KEY` to the entry's app env, then restarts it. Retires
4822
+ * the key when `uses.names` is removed. Follows the "one mint" rule
4823
+ * (CLAUDE.md "Platform steps"): the id is recorded as undelivered and
4824
+ * persisted before delivery is attempted, so a crash or a failed write
4825
+ * never leads to a second mint -- `planEntryNamesKeys` blocks instead.
4826
+ */
4827
+ async namesEntryKey(action, org, context) {
4828
+ const entryKey = action.key.slice(org.id.length + 1);
4829
+ const at = `names:key for ${org.slug} entry '${entryKey}'`;
4830
+ const names = this.ports.names;
4831
+ if (!names)
4832
+ throw new Error(`${at}: this executor was built without a NamesPort (#317)`);
4833
+ const desired = action.desired;
4834
+ if (desired.mode === 'retire') {
4835
+ const recorded = context.recordedIds();
4836
+ const mainId = recorded[`names:key:${action.key}`];
4837
+ // A key minted but never delivered never gets a main id (the mint
4838
+ // step throws before `runSteps` sees a `resourceId`), so it can
4839
+ // coexist with a separate, already-delivered id from an earlier mint
4840
+ // of the same entry: mint A, deliver, retire (main id A, retired
4841
+ // marker A); re-declare, mint B, fail delivery three times
4842
+ // (undelivered B, main id still A); undeclare again. The old `||`
4843
+ // here always picked A (truthy), leaving B live for up to 365 days
4844
+ // with nothing recorded (#317 re-review) -- both ids are revoked,
4845
+ // independently, whenever both are present.
4846
+ const undeliveredId = recorded[entryNamesUndeliveredScopeKey(entryKey)];
4847
+ const idsToRevoke = [...new Set([mainId, undeliveredId].filter((id) => !!id))];
4848
+ const keyId = mainId || undeliveredId;
4849
+ // Revoke first: the key going dead in names is what retire is for,
4850
+ // and the one thing that must still happen even when the entry's app
4851
+ // is itself already gone. Only once that has succeeded (or 404'd as
4852
+ // already gone) is the env cleanup attempted, itself tolerant of a
4853
+ // 404 on a listEnvs/deleteEnv against an app that no longer exists --
4854
+ // doing this the other way round meant an app already torn down (its
4855
+ // id still recorded) threw on `listEnvs` before `revokeKey` ever ran,
4856
+ // stranding a live key on every retry (#317 review).
4857
+ for (const id of idsToRevoke) {
4858
+ try {
4859
+ await names.revokeKey(org.id, id);
4860
+ }
4861
+ catch (error) {
4862
+ // A 404 means it is already gone -- recorded as retired below,
4863
+ // the same as a successful revoke. Anything else is a real
4864
+ // failure: thrown, not swallowed, so the key stays live and the
4865
+ // planner retires it again on the next apply instead of this
4866
+ // marking it done.
4867
+ if (!isNotFoundError(error))
4868
+ throw error;
4869
+ }
4870
+ }
4871
+ const appId = recorded[`coolify:app:${action.key}`];
4872
+ if (appId) {
4873
+ // A revoked key's env values are dead, not merely stale -- remove
4874
+ // them so nothing reads a credential names has already revoked. A
4875
+ // 404 on either call means that app or that env is already gone:
4876
+ // nothing to clean up there, and the key is already revoked above,
4877
+ // so it is not a reason to fail the step. Each delete tolerates its
4878
+ // own 404, so one env already gone does not leave the other behind.
4879
+ const tolerateNotFound = async (call, gone) => {
4880
+ try {
4881
+ return await call();
4882
+ }
4883
+ catch (error) {
4884
+ if (!isNotFoundError(error))
4885
+ throw error;
4886
+ return gone;
4887
+ }
4888
+ };
4889
+ const envs = await tolerateNotFound(() => this.ports.coolify.listEnvs(appId), []);
4890
+ for (const envKey of ['NAMES_URL', 'NAMES_API_KEY']) {
4891
+ const env = envs.find((e) => e.key === envKey && !e.is_preview);
4892
+ if (env?.uuid) {
4893
+ const uuid = env.uuid;
4894
+ await tolerateNotFound(() => this.ports.coolify.deleteEnv(appId, uuid), undefined);
4895
+ }
4896
+ }
4897
+ }
4898
+ context.recordScopeId(entryNamesUndeliveredScopeKey(entryKey), '');
4899
+ context.recordScopeId(entryNamesMintedAtScopeKey(entryKey), '');
4900
+ if (!keyId)
4901
+ return {};
4902
+ context.recordScopeId(entryNamesRetiredScopeKey(entryKey), keyId);
4903
+ return {};
4904
+ }
4905
+ const entry = org.hosting.entries.find((e) => e.key === entryKey && e.uses?.names);
4906
+ if (!entry)
4907
+ throw new Error(`${at}: the entry no longer declares uses.names`);
4908
+ const appId = context.recordedIds()[`coolify:app:${action.key}`];
4909
+ if (!appId)
4910
+ throw new Error(`${at}: the entry's app is not recorded yet`);
4911
+ if (!org.domain) {
4912
+ throw new Error(`${at}: names needs org.domain, and validateEntryNames already refuses it without one`);
4913
+ }
4914
+ // A key left over from a previous mint whose delivery never succeeded
4915
+ // is revoked first, not left blocking every later apply forever:
4916
+ // nothing here keeps its secret around to retry delivering it
4917
+ // (CLAUDE.md "No secret leaves"), so the only way to actually recover
4918
+ // is to revoke the stale id and mint a fresh one -- a 404 counts as
4919
+ // already revoked, same as any other retire (#317 re-review).
4920
+ const staleUndelivered = context.recordedIds()[entryNamesUndeliveredScopeKey(entryKey)];
4921
+ if (staleUndelivered) {
4922
+ try {
4923
+ await names.revokeKey(org.id, staleUndelivered);
4924
+ }
4925
+ catch (error) {
4926
+ if (!isNotFoundError(error))
4927
+ throw error;
4928
+ }
4929
+ context.recordScopeId(entryNamesUndeliveredScopeKey(entryKey), '');
4930
+ await context.persistScopeIds();
4931
+ }
4932
+ const minted = await names.mintKey({
4933
+ organisationId: org.id,
4934
+ domain: org.domain,
4935
+ name: desired.name,
4936
+ expiresDays: NAMES_ENTRY_KEY_DAYS,
4937
+ });
4938
+ context.recordScopeId(entryNamesUndeliveredScopeKey(entryKey), minted.id);
4939
+ await context.persistScopeIds();
4940
+ const envs = [
4941
+ { key: 'NAMES_URL', value: desired.namesUrl, literal: true },
4942
+ { key: 'NAMES_API_KEY', value: minted.secret, literal: true },
4943
+ ];
4944
+ let lastError;
4945
+ for (let attempt = 1; attempt <= ENTRY_NAMES_KEY_DELIVERY_ATTEMPTS; attempt++) {
4946
+ try {
4947
+ await this.ports.coolify.bulkUpsertEnvs(appId, envs);
4948
+ await this.ports.coolify.restart(appId);
4949
+ context.recordScopeId(entryNamesUndeliveredScopeKey(entryKey), '');
4950
+ // The only trace of this key's age, since nothing here renews it
4951
+ // (#317 review's warning-row ask reads this to flag an expiring key).
4952
+ context.recordScopeId(entryNamesMintedAtScopeKey(entryKey), new Date(context.now?.() ?? Date.now()).toISOString());
4953
+ return { resourceId: minted.id };
4954
+ }
4955
+ catch (e) {
4956
+ lastError = e;
4957
+ }
4958
+ }
4959
+ // Names the key id only; the underlying error is dropped in case it echoes the secret.
4960
+ throw new Error(`${at}: key ${minted.id} was minted but could not be written to the app's env after ${ENTRY_NAMES_KEY_DELIVERY_ATTEMPTS} attempts (${lastError instanceof Error ? lastError.name : 'error'}); the next apply revokes it and mints a fresh one automatically`);
4961
+ }
4962
+ /**
4963
+ * F7 (#317): `plan/entry-names-key.ts`'s `planNamesReservedLabels` --
4964
+ * tells the names service every label this org's own entries already
4965
+ * hold, so names refuses a customer claiming one of them.
4966
+ */
4967
+ async namesReservedLabels(action, org, context) {
4968
+ const names = this.ports.names;
4969
+ if (!names) {
4970
+ throw new Error(`names:reserved-labels for ${org.slug}: this executor was built without a NamesPort (#317)`);
4971
+ }
4972
+ const labels = action.desired.labels;
4973
+ await names.setReservedLabels(org.id, labels);
4974
+ context.recordScopeId(NAMES_RESERVED_LABELS_APPLIED_SCOPE_KEY, JSON.stringify(labels));
4975
+ return {};
4976
+ }
4348
4977
  /**
4349
4978
  * F6b: mints or renews the org's own files key, or revokes the ones a
4350
4979
  * renewal replaced. The key is minted below the files organisation's owner
@@ -4745,6 +5374,45 @@ export class FoundryStepExecutor {
4745
5374
  await files.revokeOrganisationKey(org.id, target.id);
4746
5375
  return;
4747
5376
  }
5377
+ case 'cloudflare:worker': {
5378
+ // #318: an entry's edge Worker (`<orgId>:<entry>`) and its route.
5379
+ // No port means no way to delete either -- thrown, not silently
5380
+ // skipped, so it is reported blocked rather than left live with
5381
+ // nothing recorded ids point at any more.
5382
+ const workers = this.ports.workers;
5383
+ if (!workers) {
5384
+ throw new Error(`cloudflare:worker '${target.key}' for ${org.slug}: this executor was built without a WorkersPort (#318); cannot delete it`);
5385
+ }
5386
+ // The route on its recorded zone (skipped only once that zone is
5387
+ // positively gone), any route an interrupted apply created but
5388
+ // never recorded, then the script -- the same teardown retire runs.
5389
+ // `target.id` is `''` for a route-only partial deploy.
5390
+ const entryKey = target.key.slice(org.id.length + 1);
5391
+ await this.teardownEdgeWorker(entryKey, target.id || undefined, recordedIds, workers, `cloudflare:worker '${target.key}' for ${org.slug}`);
5392
+ return;
5393
+ }
5394
+ case 'names:key': {
5395
+ // F7 (#317): an entry's key (`<orgId>:<entry>`); names has no org-root key.
5396
+ // No port means no way to revoke it -- thrown, not silently
5397
+ // skipped, so the key is reported blocked rather than left live
5398
+ // with nothing saying so.
5399
+ const names = this.ports.names;
5400
+ if (!names) {
5401
+ throw new Error(`names:key '${target.key}' for ${org.slug}: this executor was built without a NamesPort (#317); cannot revoke it`);
5402
+ }
5403
+ try {
5404
+ await names.revokeKey(org.id, target.id);
5405
+ }
5406
+ catch (error) {
5407
+ // Already gone -- the same already-retired key a retire step
5408
+ // handled before org destroy ever ran can still reach here
5409
+ // (#317 review); a 404 is success, not a reason to block
5410
+ // teardown forever.
5411
+ if (!isNotFoundError(error))
5412
+ throw error;
5413
+ }
5414
+ return;
5415
+ }
4748
5416
  case 'coolify:app':
4749
5417
  await this.ports.coolify.deleteApplication(target.id);
4750
5418
  return;