@wtfalch/foundry 0.17.0 → 0.18.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.
Files changed (38) hide show
  1. package/dist/bootstrap/declare.js +6 -4
  2. package/dist/index.d.ts +2 -2
  3. package/dist/index.js +1 -1
  4. package/dist/migrations/0043_platform_names.sql +25 -0
  5. package/dist/migrations/0044_platform_mail_lettermint_project.sql +11 -0
  6. package/dist/migrations/0045_names_to_edge.sql +107 -0
  7. package/dist/org.d.ts +74 -3
  8. package/dist/org.js +133 -0
  9. package/dist/plan/chat-matrix.d.ts +0 -1
  10. package/dist/plan/chat-matrix.js +4 -1
  11. package/dist/plan/diff.d.ts +18 -0
  12. package/dist/plan/diff.js +239 -26
  13. package/dist/plan/entry-edge-key.d.ts +79 -0
  14. package/dist/plan/entry-edge-key.js +260 -0
  15. package/dist/plan/executor.d.ts +246 -1
  16. package/dist/plan/executor.js +684 -32
  17. package/dist/plan/index.d.ts +2 -2
  18. package/dist/plan/index.js +1 -1
  19. package/dist/plan/live.d.ts +4 -2
  20. package/dist/plan/login-host.d.ts +12 -1
  21. package/dist/plan/login-host.js +20 -2
  22. package/dist/plan/plan-apply.d.ts +15 -0
  23. package/dist/plan/plan-apply.js +102 -9
  24. package/dist/plan/platform-diff.d.ts +92 -1
  25. package/dist/plan/platform-diff.js +166 -1
  26. package/dist/plan/platform-org.d.ts +29 -1
  27. package/dist/plan/platform-org.js +17 -2
  28. package/dist/plan/platform-providers.d.ts +45 -1
  29. package/dist/plan/platform-providers.js +514 -7
  30. package/dist/plan/step-system.js +1 -0
  31. package/dist/plan/worker.d.ts +134 -0
  32. package/dist/plan/worker.js +239 -0
  33. package/dist/platform.d.ts +22 -3
  34. package/dist/platform.js +6 -0
  35. package/dist/store/postgres.js +15 -1
  36. package/dist/store/schema.d.ts +70 -2
  37. package/dist/store/schema.js +6 -0
  38. package/package.json +1 -1
@@ -9,14 +9,17 @@ import { MAS_IMAGE, MAS_TAG, SYNAPSE_IMAGE, SYNAPSE_TAG, callsMasConfigYaml, cal
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
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';
12
13
  import { AI_ENTRY_RENEW_WINDOW_DAYS, entryAiAppliedScopeKey, entryAiClampedScopeKey, entryAiExpiresScopeKey, entryAiPendingExpiresScopeKey, entryAiRetiredScopeKey, entryAiUndeliveredScopeKey, entryAiUnrevokedScopeKey, entryAiWebhookPendingScopeKey, entryAiWrittenScopeKey, parseIdList, } from './entry-ai-key.js';
14
+ import { EDGE_ENTRY_KEY_DAYS, EDGE_RESERVED_LABELS_APPLIED_SCOPE_KEY, ENTRY_EDGE_KEY_DELIVERY_ATTEMPTS, entryEdgeMintedAtScopeKey, entryEdgeRetiredScopeKey, entryEdgeUndeliveredScopeKey, } from './entry-edge-key.js';
13
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';
14
16
  import { appHostname, coolifyDomains, entryHostnames, tunnelDnsHostnames, tunnelIngressHostnames, } from './hostnames.js';
15
17
  import { LOGIN_BINDING_SCOPE_KEY, LOGIN_HOST_PENDING_REMOVAL_SCOPE_KEY, loginBindingState, loginHostClientIds, pendingRemovalHosts, pendingRemovalState, } from './login-host.js';
16
18
  import { MAIL_COPY_COMPLETED_SCOPE_KEY, MAIL_COPY_DONE_SCOPE_KEY, parseMailCopyCompleted, runMailCopyStep, } from './mail-copy.js';
17
- 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';
19
+ 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';
18
20
  import { PLATFORM_AUTH_BROKER_MAIL_KEY_REF } from './platform-providers.js';
19
21
  import { appLabelsAppliedScopeKey, entryLabelHash, entryLabelSpec, entryProxyLabels, sameLabelSets, } from './proxy-labels.js';
22
+ import { entryWorkerAppliedScopeKey, entryWorkerExclusionsScopeKey, entryWorkerRetiredScopeKey, entryWorkerRouteIdScopeKey, entryWorkerRoutePatternScopeKey, entryWorkerRoutePendingScopeKey, entryWorkerRouteZoneScopeKey, entryWorkerUploadedScopeKey, } from './worker.js';
20
23
  /**
21
24
  * #274 fix round 4: bridges `@wtfalch/providers`' real `ZitadelClient.
22
25
  * listApps` -- a plain array, with no `loginBaseUri` or completeness signal
@@ -505,11 +508,21 @@ const CALLS_MAS_DB_SCOPE_KEY = 'coolify_calls_mas_database';
505
508
  * Synapse refuses to start against a database whose `LC_COLLATE`/`LC_CTYPE`
506
509
  * is not `C` (https://element-hq.github.io/synapse/latest/postgres.html);
507
510
  * Coolify's official postgres image otherwise creates one with `en_US.utf8`.
508
- * `postgres_initdb_args` is the only point this can be set -- it cannot be
509
- * migrated onto an existing database. MAS has no such requirement, so this
510
- * is passed for the Synapse database only. Not using Synapse's own
511
- * `allow_unsafe_locale` escape hatch: that trades a loud startup refusal for
512
- * silent index corruption after a future glibc upgrade.
511
+ * `createPostgresDatabase` forwards this to Coolify's own
512
+ * `postgres_initdb_args` field -- but Coolify stores that field and reads it
513
+ * back unchanged without ever handing it to `initdb`: its Postgres start
514
+ * action builds the container's env from `postgres_user`/`postgres_password`/
515
+ * `postgres_db` and runtime envs only
516
+ * (https://github.com/coollabsio/coolify/blob/main/app/Actions/Database/StartPostgresql.php).
517
+ * A database created this way still comes up `en_US.utf8` and crash-loops
518
+ * every Synapse app that reads it; live evidence and the real fix (not yet
519
+ * chosen) are tracked in #494. This constant and the field it feeds stay in
520
+ * place, inert, until that lands -- collation cannot be migrated onto an
521
+ * existing database under any of the candidate fixes either. MAS has no
522
+ * such requirement, so this is passed for the Synapse database only. Not
523
+ * using Synapse's own `allow_unsafe_locale` escape hatch regardless of
524
+ * which fix lands: that trades a loud startup refusal for silent index
525
+ * corruption after a future glibc upgrade.
513
526
  */
514
527
  const CALLS_SYNAPSE_DB_INITDB_ARGS = '--locale=C --encoding=UTF8';
515
528
  /**
@@ -657,6 +670,8 @@ export class FoundryStepExecutor {
657
670
  return this.cloudflareAccess(action, org);
658
671
  case 'cloudflare:ratelimit':
659
672
  return this.cloudflareRateLimit(org);
673
+ case 'cloudflare:worker':
674
+ return this.cloudflareWorker(action, org, context);
660
675
  case 'files:organisation':
661
676
  return this.filesOrganisation(action, org, context);
662
677
  case 'files:key':
@@ -673,6 +688,10 @@ export class FoundryStepExecutor {
673
688
  return action.key.includes(':')
674
689
  ? this.aiEntryKey(action, org, context)
675
690
  : this.aiKey(action, org, context);
691
+ case 'edge:key':
692
+ return this.edgeEntryKey(action, org, context);
693
+ case 'edge:reserved-labels':
694
+ return this.edgeReservedLabels(action, org, context);
676
695
  case 'coolify:mail':
677
696
  return this.coolifyMail(action, org, context);
678
697
  case 'coolify:mail-copy':
@@ -840,6 +859,250 @@ export class FoundryStepExecutor {
840
859
  }
841
860
  return { resourceId: created.id };
842
861
  }
862
+ /**
863
+ * `plan/diff.ts`'s `planWorkers` (#318, F8): deploys one entry's
864
+ * `worker` Worker and routes it on the org's own zone. Throws by name
865
+ * without a {@link WorkersPort}, the same `jobsRegistration` shape --
866
+ * `planWorkers` reports the step blocked instead while the host has
867
+ * none, so this only fires against a stale plan. The route is checked
868
+ * before upload: a foreign route already on the pattern is refused, never
869
+ * adopted or replaced (`cloudflareAccess`'s own precedent for a foreign
870
+ * resource on the same name), so a Worker is never deployed only to find
871
+ * nothing can route to it.
872
+ *
873
+ * Order on apply: record (and persist) the uploaded name and clear the
874
+ * retired marker, upload, record the pending route, create the route only
875
+ * if needed, delete any *previous* route this entry recorded on its own
876
+ * zone, then record the new route id/zone/pattern and the applied
877
+ * declaration last -- every crash point leaves a recorded trace that
878
+ * retire and destroy can clean up (#318 re-review), and per CLAUDE.md's
879
+ * "Declared changes apply" nothing is marked done until the
880
+ * live state actually matches it.
881
+ */
882
+ async cloudflareWorker(action, org, context) {
883
+ const entryKey = action.key.slice(org.id.length + 1);
884
+ const at = `cloudflare:worker for ${org.slug} entry '${entryKey}'`;
885
+ const workers = this.ports.workers;
886
+ if (!workers)
887
+ throw new Error(`${at}: this executor was built without a WorkersPort (#318)`);
888
+ const desired = action.desired;
889
+ if (desired.mode === 'retire') {
890
+ const recorded = context.recordedIds();
891
+ const mainId = recorded[`cloudflare:worker:${action.key}`];
892
+ const scriptName = mainId || recorded[entryWorkerUploadedScopeKey(entryKey)];
893
+ await this.teardownWorker(entryKey, scriptName, recorded, workers, at);
894
+ context.recordScopeId(entryWorkerRouteIdScopeKey(entryKey), '');
895
+ context.recordScopeId(entryWorkerRouteZoneScopeKey(entryKey), '');
896
+ context.recordScopeId(entryWorkerRoutePatternScopeKey(entryKey), '');
897
+ context.recordScopeId(entryWorkerRoutePendingScopeKey(entryKey), '');
898
+ context.recordScopeId(entryWorkerExclusionsScopeKey(entryKey), '');
899
+ context.recordScopeId(entryWorkerAppliedScopeKey(entryKey), '');
900
+ context.recordScopeId(entryWorkerUploadedScopeKey(entryKey), '');
901
+ // `runSteps` only ever writes the main recorded id from a truthy
902
+ // `resourceId` (`plan-apply.ts`); this step returns none, so without
903
+ // this marker the planner's "gone" scan would retire the same,
904
+ // never-cleared id forever.
905
+ if (mainId)
906
+ context.recordScopeId(entryWorkerRetiredScopeKey(entryKey), mainId);
907
+ return {};
908
+ }
909
+ if (!org.domain) {
910
+ throw new Error(`${at}: worker needs org.domain, and validateHosting already refuses it without one`);
911
+ }
912
+ const zone = await this.ports.cloudflare.findZoneByApex(org.domain);
913
+ if (!zone) {
914
+ throw new Error(`${at}: no Cloudflare zone found for ${org.domain} -- cloudflare:zone must provision it first`);
915
+ }
916
+ const name = desired.name;
917
+ const route = desired.route;
918
+ const bindings = (desired.bindings?.rateLimits ?? []).map((r) => ({
919
+ type: 'ratelimit',
920
+ name: r.name,
921
+ namespace_id: r.namespaceId,
922
+ simple: { limit: r.limit, period: r.period },
923
+ }));
924
+ const existingRoute = await workers.findRoute(zone.id, route);
925
+ if (existingRoute && existingRoute.script !== name) {
926
+ 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`);
927
+ }
928
+ // Checked only on an entry's first-ever apply -- a name foundry has
929
+ // already recorded for itself (even a retired one: redeclaring reuses
930
+ // the same deterministic name) is foundry's own script to overwrite,
931
+ // never a stranger's.
932
+ const recordedBeforeUpload = context.recordedIds();
933
+ const everUploadedByFoundry = Boolean(recordedBeforeUpload[entryWorkerAppliedScopeKey(entryKey)] ||
934
+ recordedBeforeUpload[entryWorkerUploadedScopeKey(entryKey)] ||
935
+ recordedBeforeUpload[`cloudflare:worker:${action.key}`]);
936
+ if (!everUploadedByFoundry && (await workers.scriptExists(name))) {
937
+ throw new Error(`${at}: a Worker script named '${name}' already exists and foundry never created it; refuses to overwrite it`);
938
+ }
939
+ // Recorded and persisted *before* the upload (#318 re-review): a crash
940
+ // mid-upload or a failed route create still leaves a trail to the
941
+ // script, and a retry reads it as foundry's own rather than tripping
942
+ // the `scriptExists` guard above. The retired marker clears here too,
943
+ // not only on success: once a redeploy has touched the script, a later
944
+ // undeclare must retire it even if this apply never finishes.
945
+ context.recordScopeId(entryWorkerUploadedScopeKey(entryKey), name);
946
+ context.recordScopeId(entryWorkerRetiredScopeKey(entryKey), '');
947
+ await context.persistScopeIds();
948
+ await workers.uploadScript({
949
+ name,
950
+ artifactUrl: desired.artifactUrl,
951
+ artifactSha256: desired.artifactSha256,
952
+ compatibilityDate: action.desired.compatibilityDate,
953
+ bindings: bindings.length > 0 ? bindings : undefined,
954
+ });
955
+ // Before the wildcard route exists, so a hostname foundry serves is never
956
+ // caught by the Worker, not even briefly.
957
+ await this.syncWorkerExclusions(entryKey, zone.id, org.domain, desired.exclusions ?? [], workers, context, at);
958
+ const recorded = context.recordedIds();
959
+ const previousRouteId = recorded[entryWorkerRouteIdScopeKey(entryKey)];
960
+ const previousPending = recorded[entryWorkerRoutePendingScopeKey(entryKey)];
961
+ const zoneRecord = { id: zone.id, apex: org.domain };
962
+ let routeId = existingRoute?.id;
963
+ if (!routeId) {
964
+ // Recorded before the create, so a route Cloudflare made but foundry
965
+ // never got to record is still found (`findRoute`) and deleted on
966
+ // retire or destroy (#318 re-review).
967
+ const pending = { ...zoneRecord, pattern: route, script: name };
968
+ context.recordScopeId(entryWorkerRoutePendingScopeKey(entryKey), JSON.stringify(pending));
969
+ await context.persistScopeIds();
970
+ const created = await workers.createRoute(zone.id, route, name);
971
+ routeId = created.id;
972
+ }
973
+ // Any route this entry recorded before that is not the one it now uses
974
+ // -- the declared pattern moved, or the domain did -- is deleted on its
975
+ // own zone rather than left live beside the new one. A pending route
976
+ // from an earlier, interrupted apply under a different pattern likewise.
977
+ if (previousRouteId && previousRouteId !== routeId) {
978
+ const previousZone = await this.liveWorkerZone(recorded[entryWorkerRouteZoneScopeKey(entryKey)], at);
979
+ if (previousZone)
980
+ await workers.deleteRoute(previousZone, previousRouteId); // Idempotent.
981
+ }
982
+ if (previousPending) {
983
+ await this.deletePendingWorkerRoute(previousPending, routeId, workers, at);
984
+ }
985
+ context.recordScopeId(entryWorkerRouteIdScopeKey(entryKey), routeId);
986
+ context.recordScopeId(entryWorkerRouteZoneScopeKey(entryKey), JSON.stringify(zoneRecord));
987
+ context.recordScopeId(entryWorkerRoutePatternScopeKey(entryKey), route);
988
+ context.recordScopeId(entryWorkerRoutePendingScopeKey(entryKey), '');
989
+ context.recordScopeId(entryWorkerAppliedScopeKey(entryKey), JSON.stringify({
990
+ name,
991
+ artifactUrl: desired.artifactUrl,
992
+ artifactSha256: desired.artifactSha256,
993
+ route,
994
+ bindings: desired.bindings,
995
+ exclusions: desired.exclusions,
996
+ }));
997
+ context.recordScopeId(entryWorkerUploadedScopeKey(entryKey), '');
998
+ // The retired marker was already cleared before the upload above: a
999
+ // redeclare reuses this entry's deterministic script name, so a marker
1000
+ // left equal to the (identical) main id would read as already retired.
1001
+ return { resourceId: name };
1002
+ }
1003
+ /**
1004
+ * Makes the no-Worker routes recorded for an entry match `patterns`: deletes
1005
+ * the ones no longer wanted (all of them if the zone changed), creates the
1006
+ * missing ones without a script. A route already on a pattern that is bound
1007
+ * to a Worker is foreign and refused; one with no script is adopted. Records
1008
+ * and persists after each change.
1009
+ */
1010
+ async syncWorkerExclusions(entryKey, zoneId, apex, patterns, workers, context, at) {
1011
+ const key = entryWorkerExclusionsScopeKey(entryKey);
1012
+ const raw = context.recordedIds()[key];
1013
+ let state = raw
1014
+ ? JSON.parse(raw)
1015
+ : { id: zoneId, apex, routes: {} };
1016
+ const save = async () => {
1017
+ context.recordScopeId(key, Object.keys(state.routes).length > 0 ? JSON.stringify(state) : '');
1018
+ await context.persistScopeIds();
1019
+ };
1020
+ if (state.id !== zoneId) {
1021
+ const oldZone = await this.liveWorkerZone(JSON.stringify({ id: state.id, apex: state.apex }), at);
1022
+ if (oldZone) {
1023
+ for (const id of Object.values(state.routes))
1024
+ await workers.deleteRoute(oldZone, id);
1025
+ }
1026
+ state = { id: zoneId, apex, routes: {} };
1027
+ await save();
1028
+ }
1029
+ for (const [pattern, id] of Object.entries(state.routes)) {
1030
+ if (patterns.includes(pattern))
1031
+ continue;
1032
+ await workers.deleteRoute(zoneId, id); // Idempotent.
1033
+ delete state.routes[pattern];
1034
+ await save();
1035
+ }
1036
+ for (const pattern of patterns) {
1037
+ if (state.routes[pattern])
1038
+ continue;
1039
+ const found = await workers.findRoute(zoneId, pattern);
1040
+ if (found?.script) {
1041
+ throw new Error(`${at}: route '${pattern}' is already bound to Worker '${found.script}'; foundry does not replace it`);
1042
+ }
1043
+ state.routes[pattern] = found ? found.id : (await workers.createRoute(zoneId, pattern)).id;
1044
+ await save();
1045
+ }
1046
+ }
1047
+ /**
1048
+ * The zone id a recorded route lives in, or `undefined` only when that
1049
+ * zone is *positively* gone -- its apex resolves to no zone, or to a
1050
+ * different zone than the one recorded (#318 re-review). Reads the zone
1051
+ * recorded beside the route, never `org.domain`: the route id and its
1052
+ * zone are always recorded together (the apply branch below writes both
1053
+ * in the same batch, and the pending-route marker carries its own zone
1054
+ * too), so a route id with no zone recorded cannot happen within this
1055
+ * feature -- and re-resolving from `org.domain` as a fallback would risk
1056
+ * looking up the *new* zone after a domain change and leaving the route
1057
+ * live on the old one, the exact bug a recorded-per-route zone exists to
1058
+ * avoid (#318 re-review).
1059
+ */
1060
+ async liveWorkerZone(recordedZone, at) {
1061
+ if (!recordedZone) {
1062
+ 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`);
1063
+ }
1064
+ const { id, apex } = JSON.parse(recordedZone);
1065
+ const zone = await this.ports.cloudflare.findZoneByApex(apex);
1066
+ return zone && zone.id === id ? id : undefined;
1067
+ }
1068
+ /** Deletes the route an interrupted apply was creating, if `findRoute` shows it exists, points at that apply's script, and is not `keepRouteId`. */
1069
+ async deletePendingWorkerRoute(pendingRaw, keepRouteId, workers, at) {
1070
+ const pending = JSON.parse(pendingRaw);
1071
+ const zoneId = await this.liveWorkerZone(JSON.stringify({ id: pending.id, apex: pending.apex }), at);
1072
+ if (!zoneId)
1073
+ return;
1074
+ const found = await workers.findRoute(zoneId, pending.pattern);
1075
+ if (found && found.script === pending.script && found.id !== keepRouteId) {
1076
+ await workers.deleteRoute(zoneId, found.id); // Idempotent.
1077
+ }
1078
+ }
1079
+ /**
1080
+ * Retire and destroy share this: delete the recorded route on its own
1081
+ * zone, then any route an interrupted apply created but never recorded,
1082
+ * then the script. Records nothing -- the caller does.
1083
+ */
1084
+ async teardownWorker(entryKey, scriptName, recorded, workers, at) {
1085
+ const routeId = recorded[entryWorkerRouteIdScopeKey(entryKey)];
1086
+ if (routeId) {
1087
+ const zoneId = await this.liveWorkerZone(recorded[entryWorkerRouteZoneScopeKey(entryKey)], at);
1088
+ if (zoneId)
1089
+ await workers.deleteRoute(zoneId, routeId); // Idempotent: a no-op if already gone.
1090
+ }
1091
+ const pending = recorded[entryWorkerRoutePendingScopeKey(entryKey)];
1092
+ if (pending)
1093
+ await this.deletePendingWorkerRoute(pending, undefined, workers, at);
1094
+ const exclusions = recorded[entryWorkerExclusionsScopeKey(entryKey)];
1095
+ if (exclusions) {
1096
+ const state = JSON.parse(exclusions);
1097
+ const zoneId = await this.liveWorkerZone(JSON.stringify({ id: state.id, apex: state.apex }), at);
1098
+ if (zoneId) {
1099
+ for (const id of Object.values(state.routes))
1100
+ await workers.deleteRoute(zoneId, id);
1101
+ }
1102
+ }
1103
+ if (scriptName)
1104
+ await workers.deleteScript(scriptName); // Idempotent: a no-op if already gone.
1105
+ }
843
1106
  // ---- zitadel --------------------------------------------------------
844
1107
  async zitadelIssuer(action, org, context) {
845
1108
  if (action.desired.provision !== true) {
@@ -1498,7 +1761,15 @@ export class FoundryStepExecutor {
1498
1761
  // One Lettermint project, `<slug>-app`, belongs to the `app` entry; a
1499
1762
  // second entry sharing the name would delete the first's live token.
1500
1763
  if (entry.key === APP_ENTRY_KEY) {
1501
- await this.ensureAppLettermintMail(appId, org, context, Boolean(existing));
1764
+ await this.ensureAppLettermintMail(appId, org, context, Boolean(existing),
1765
+ // #491 (review round 2, finding 3): `diff.ts`'s `planApps` always
1766
+ // sets `platformApex` on this step's `desired` once it's planned at
1767
+ // all -- `null` when no platform is declared, never omitted. The key
1768
+ // being absent outright (as opposed to present and `null`) means a
1769
+ // step planned before this field existed; `ensureAppLettermintMail`
1770
+ // refuses rather than silently falling back once it knows there is
1771
+ // a recorded mail domain to reason about.
1772
+ 'platformApex' in action.desired, action.desired.platformApex ?? null, action.desired.platformLettermintProjectId ?? null);
1502
1773
  }
1503
1774
  return { resourceId: appId };
1504
1775
  }
@@ -1937,12 +2208,73 @@ export class FoundryStepExecutor {
1937
2208
  * comment), never logged, never interpolated into a thrown error -- the
1938
2209
  * same discipline `pushDatabaseUrl`'s doc comment states for a database's
1939
2210
  * own connection string.
2211
+ *
2212
+ * #491: none of the above applies to the org whose mail domain is the
2213
+ * platform's own apex -- it shares the platform's existing Lettermint
2214
+ * project (`platformLettermintProjectId`, from `DeclaredPlatform.mail`)
2215
+ * instead of minting a `<slug>-app` one, and this method never creates or
2216
+ * deletes a project for it. `platformApex`/`platformLettermintProjectId`
2217
+ * come straight off this step's own `desired`, the same "the executor
2218
+ * writes exactly what the plan showed" rule `coolifyMail` already follows
2219
+ * for its own `apex`/`mailHost` -- the caller (`coolifyApp`) refuses a
2220
+ * `desired` missing the `platformApex` key outright as a stale plan --
2221
+ * checked here, after the no-op-until-a-mail-domain-exists return just
2222
+ * below, rather than unconditionally at the call site: every other
2223
+ * `coolify:app` concern (hostnames, ports, settings, the sign-in env...)
2224
+ * has nothing to do with Lettermint and must keep working the moment a
2225
+ * mail domain was never even recorded for this org, the same as before
2226
+ * this field existed (review round 2, finding 3).
1940
2227
  */
1941
- async ensureAppLettermintMail(appId, org, context, running) {
2228
+ async ensureAppLettermintMail(appId, org, context, running, hasPlatformApexField, platformApex, platformLettermintProjectId) {
1942
2229
  const mailDomain = context.recordedIds()[MAIL_DOMAIN_SCOPE_KEY];
1943
2230
  if (!mailDomain)
1944
2231
  return;
2232
+ if (!hasPlatformApexField) {
2233
+ throw new Error(`coolify:app for ${org.slug}: this step's plan carries no platformApex; plan this org's app again`);
2234
+ }
1945
2235
  const appName = `${org.slug}-app`;
2236
+ // #491: an org whose mail domain is the platform's own apex (Company
2237
+ // Included on companyincluded.com, today) shares the platform's existing
2238
+ // Lettermint project rather than minting a `<slug>-app` one of its own --
2239
+ // `diff.ts`'s `planApp` blocks this whole piece while
2240
+ // `platformLettermintProjectId` is unset, so a plan carrying neither is
2241
+ // stale; never fall back to creating, restricting or deleting anything
2242
+ // here until a fresh plan supplies it.
2243
+ const isPlatformApexOrg = Boolean(platformApex && mailDomain.toLowerCase() === platformApex.toLowerCase());
2244
+ if (isPlatformApexOrg) {
2245
+ if (!platformLettermintProjectId)
2246
+ return;
2247
+ const [projects, envs] = await Promise.all([
2248
+ this.ports.lettermint.listProjects(),
2249
+ this.ports.coolify.listEnvs(appId),
2250
+ ]);
2251
+ // Review round 2, finding 5: a missing held token would otherwise
2252
+ // leave the app with a stale or absent `LETTERMINT_API_TOKEN` and no
2253
+ // signal that anything is wrong -- refused instead, the same "fail
2254
+ // rather than silently point mail at nothing" rule `coolifyMail`
2255
+ // already follows for its own `apex`/`mailHost`.
2256
+ const heldToken = (await this.ports.heldKeys?.resolve(PLATFORM_AUTH_BROKER_MAIL_KEY_REF)) ?? null;
2257
+ if (!heldToken) {
2258
+ 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})`);
2259
+ }
2260
+ // Validates and restricts before any env is written -- `ensureLettermintDomainRestriction`'s
2261
+ // own doc comment (review round 2, finding 4) throws on a mistyped id,
2262
+ // and the app must never end up with a pushed token the restriction
2263
+ // step then fails on, the same "never half-ready" order
2264
+ // {@link ensureOrgLettermintRelay} already follows for its own token.
2265
+ await this.ensureLettermintDomainRestriction(org, mailDomain, projects, platformApex, platformLettermintProjectId);
2266
+ const currentToken = envs.find((e) => e.key === 'LETTERMINT_API_TOKEN' && !e.is_preview)?.value;
2267
+ const tokenChanged = currentToken !== heldToken;
2268
+ await this.ports.coolify.bulkUpsertEnvs(appId, [
2269
+ { key: 'MAIL_FROM', value: `${org.mail.noReplyLocalPart ?? 'no-reply'}@${mailDomain}` },
2270
+ { key: 'LETTERMINT_API_TOKEN', value: heldToken },
2271
+ ]);
2272
+ // Same restart rule as the non-apex path below: a running container
2273
+ // read its env at start and keeps a stale token until restarted.
2274
+ if (tokenChanged && running)
2275
+ await this.ports.coolify.restart(appId);
2276
+ return;
2277
+ }
1946
2278
  const [projects, envs] = await Promise.all([
1947
2279
  this.ports.lettermint.listProjects(),
1948
2280
  this.ports.coolify.listEnvs(appId),
@@ -1973,9 +2305,11 @@ export class FoundryStepExecutor {
1973
2305
  // old project is deleted below. A just-created app starts with the new env.
1974
2306
  if (!healthy && running)
1975
2307
  await this.ports.coolify.restart(appId);
1976
- await this.ensureLettermintDomainRestriction(org, mailDomain, allProjects);
2308
+ await this.ensureLettermintDomainRestriction(org, mailDomain, allProjects, platformApex, platformLettermintProjectId);
1977
2309
  for (const project of allProjects) {
1978
- if (project.name === appName && project.id !== keepProjectId) {
2310
+ if (project.name === appName &&
2311
+ project.id !== keepProjectId &&
2312
+ project.id !== platformLettermintProjectId) {
1979
2313
  await this.ports.lettermint.deleteProject(project.id);
1980
2314
  }
1981
2315
  }
@@ -2013,8 +2347,14 @@ export class FoundryStepExecutor {
2013
2347
  * it the same way the app step does, rather than assuming it already
2014
2348
  * exists -- the domain must never end up unrestricted, whichever step gets
2015
2349
  * there first.
2350
+ *
2351
+ * #491: this org's own `<slug>-relay` project is unaffected by whether it
2352
+ * is the platform's own org -- only the domain restriction
2353
+ * (`platformApex`/`platformLettermintProjectId`, forwarded to
2354
+ * {@link ensureLettermintDomainRestriction}) and the delete loop below
2355
+ * (which must never delete the platform's own project) change.
2016
2356
  */
2017
- async ensureOrgLettermintRelay(org, mailOrg, mailDomain, context) {
2357
+ async ensureOrgLettermintRelay(org, mailOrg, mailDomain, context, platformApex, platformLettermintProjectId) {
2018
2358
  const relayName = `${org.slug}-relay`;
2019
2359
  // #345: `mailOrg.domain` is absent for a level-1 org -- `mailDomain` is
2020
2360
  // `applyOrgMail`'s own resolved result, the one domain `MailPort`'s
@@ -2033,7 +2373,7 @@ export class FoundryStepExecutor {
2033
2373
  let keepProjectId;
2034
2374
  if (healthy && held) {
2035
2375
  keepProjectId = held.id;
2036
- await this.ensureLettermintDomainRestriction(org, mailDomain, allProjects);
2376
+ await this.ensureLettermintDomainRestriction(org, mailDomain, allProjects, platformApex, platformLettermintProjectId);
2037
2377
  }
2038
2378
  else {
2039
2379
  const created = await this.ports.lettermint.createProject(relayName);
@@ -2044,7 +2384,7 @@ export class FoundryStepExecutor {
2044
2384
  // reaches Stalwart: a failure here leaves the working route alone.
2045
2385
  // The restriction covers old and new projects, so the old one keeps
2046
2386
  // sending until the route switches.
2047
- await this.ensureLettermintDomainRestriction(org, mailDomain, allProjects);
2387
+ await this.ensureLettermintDomainRestriction(org, mailDomain, allProjects, platformApex, platformLettermintProjectId);
2048
2388
  await this.ports.mail.applyOrgRelay(relayOrg, created.token);
2049
2389
  }
2050
2390
  catch (err) {
@@ -2057,7 +2397,9 @@ export class FoundryStepExecutor {
2057
2397
  await context.persistScopeIds();
2058
2398
  }
2059
2399
  for (const project of allProjects) {
2060
- if (project.name === relayName && project.id !== keepProjectId) {
2400
+ if (project.name === relayName &&
2401
+ project.id !== keepProjectId &&
2402
+ project.id !== platformLettermintProjectId) {
2061
2403
  await this.ports.lettermint.deleteProject(project.id);
2062
2404
  }
2063
2405
  }
@@ -2089,10 +2431,31 @@ export class FoundryStepExecutor {
2089
2431
  * unrestricted domain cannot verify and cannot send, whichever project
2090
2432
  * owns it (post-#140 review finding, restated here rather than only at
2091
2433
  * the one call site it used to have).
2434
+ *
2435
+ * #491: `platformApex`/`platformLettermintProjectId` cover both callers at
2436
+ * once -- when `mailDomain` is the platform's own apex, the platform's
2437
+ * project id is always folded into the restriction, so a plan that shares
2438
+ * the platform's project (rather than minting a `<slug>-app` one) never
2439
+ * ends up restricting that org's domain to nothing. A no-op for every
2440
+ * other org's domain, since `mailDomain` never matches `platformApex` there.
2441
+ * Review round 2, finding 4: a declared id that is not among `allProjects`
2442
+ * (a mistype, or the project was deleted in Lettermint) is refused rather
2443
+ * than folded in anyway -- that would restrict the apex to whatever *is*
2444
+ * live (the relay project, say) plus an id naming nothing, which excludes
2445
+ * the real platform project from its own domain exactly as completely as
2446
+ * never restricting it at all. Centralised here rather than duplicated at
2447
+ * each caller, since both {@link ensureAppLettermintMail} and
2448
+ * {@link ensureOrgLettermintRelay} reach the apex through this one method.
2092
2449
  */
2093
- async ensureLettermintDomainRestriction(org, mailDomain, allProjects) {
2450
+ async ensureLettermintDomainRestriction(org, mailDomain, allProjects, platformApex, platformLettermintProjectId) {
2094
2451
  const appName = `${org.slug}-app`;
2095
2452
  const relayName = `${org.slug}-relay`;
2453
+ const isPlatformApexDomain = Boolean(platformApex && mailDomain.toLowerCase() === platformApex.toLowerCase());
2454
+ if (isPlatformApexDomain && platformLettermintProjectId) {
2455
+ if (!allProjects.some((p) => p.id === platformLettermintProjectId)) {
2456
+ throw new Error(`Lettermint for ${org.slug}: platform.mail.lettermintProjectId ${platformLettermintProjectId} is not a live Lettermint project`);
2457
+ }
2458
+ }
2096
2459
  const existing = await this.findExactLettermintDomain(mailDomain);
2097
2460
  const domain = existing
2098
2461
  ? await this.ports.lettermint.getDomain(existing.id)
@@ -2100,6 +2463,11 @@ export class FoundryStepExecutor {
2100
2463
  const scopedProjectIds = allProjects
2101
2464
  .filter((p) => p.name === appName || p.name === relayName)
2102
2465
  .map((p) => p.id);
2466
+ if (isPlatformApexDomain &&
2467
+ platformLettermintProjectId &&
2468
+ !scopedProjectIds.includes(platformLettermintProjectId)) {
2469
+ scopedProjectIds.push(platformLettermintProjectId);
2470
+ }
2103
2471
  await this.ports.lettermint.restrictDomainToProjects(domain.id, scopedProjectIds);
2104
2472
  const zone = await this.ports.cloudflare.findZoneByApex(mailDomain);
2105
2473
  if (!zone) {
@@ -2739,6 +3107,34 @@ export class FoundryStepExecutor {
2739
3107
  async coolifyChat(action, org, context) {
2740
3108
  const recorded = context.recordedIds();
2741
3109
  if (recorded[`coolify:chat:${action.key}`]) {
3110
+ // #456: `planChat` plans this distinct, non-re-key action for a live
3111
+ // chat app that predates the owner/runtime database split (#239) --
3112
+ // push only the split and redeploy if it changed, never the re-key
3113
+ // paths below, which throw on literally any other action here.
3114
+ if (action.desired.refreshDatabaseSplit === true) {
3115
+ const serviceAppId = recorded[CHAT_SERVICE_SCOPE_KEY];
3116
+ const serviceDbId = recorded[CHAT_SERVICE_DB_SCOPE_KEY];
3117
+ if (!serviceAppId || !serviceDbId) {
3118
+ throw new Error(`coolify:chat database split for ${org.slug}: the chat service app or its database is not recorded yet`);
3119
+ }
3120
+ const push = await this.pushDatabaseUrl(serviceAppId, serviceDbId, 'chat');
3121
+ const markerKey = serviceDbEnvAppliedScopeKey('chat');
3122
+ if (!push.pushed) {
3123
+ // Unlike a fresh provision, this chat was already fully up and
3124
+ // running -- its service database was already started (the main
3125
+ // path below never creates an app before confirming that). A push
3126
+ // refused here means the recorded database id no longer resolves
3127
+ // to a started database with an `internal_db_url`, which is not a
3128
+ // "retry once Coolify catches up" condition: fail loudly instead of
3129
+ // replanning this same step silently forever (#456 review).
3130
+ 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`);
3131
+ }
3132
+ if (push.changed || !recorded[markerKey]) {
3133
+ await this.ports.coolify.deploy(serviceAppId);
3134
+ }
3135
+ context.recordScopeId(markerKey, '1');
3136
+ return { resourceId: serviceAppId };
3137
+ }
2742
3138
  if (action.desired.usesDomain === false) {
2743
3139
  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)');
2744
3140
  }
@@ -2881,8 +3277,13 @@ export class FoundryStepExecutor {
2881
3277
  agoraHost,
2882
3278
  masClientId: secrets.value.masClientId,
2883
3279
  masClientSecret: secrets.value.masClientSecret,
2884
- databaseUrl: serviceDbRow.internal_db_url,
2885
3280
  }));
3281
+ // Split the same way `coolifyApp`'s and `cmsSite`'s own databases are
3282
+ // (#239, #456): `DATABASE_URL_OWNER` is the superuser string,
3283
+ // `DATABASE_URL` is a minted `chat_rt` role -- `chat` is the literal
3284
+ // `postgres_db`/`postgres_user` `ensureCallsDatabase` created the service
3285
+ // database with above, not the Coolify resource name `names.serviceDb`.
3286
+ const serviceDbPush = await this.pushDatabaseUrl(service.appId, serviceDbId, 'chat');
2886
3287
  // A file-storage write alone never reaches a running container. Deploy an
2887
3288
  // app that is new, whose config was freshly minted, or that is not
2888
3289
  // running now. The last test reads the live status, not what this run
@@ -2893,10 +3294,19 @@ export class FoundryStepExecutor {
2893
3294
  for (const app of [mas, synapse, service]) {
2894
3295
  if (!app.existed ||
2895
3296
  secrets.changed ||
3297
+ (app === service && serviceDbPush.changed) ||
2896
3298
  !isRunningOrStarting((await this.ports.coolify.getApplication(app.appId)).status)) {
2897
3299
  await this.ports.coolify.deploy(app.appId);
2898
3300
  }
2899
3301
  }
3302
+ // #456: recorded here too, not only on the `refreshDatabaseSplit` replan
3303
+ // path above -- without it a fresh provision's very first apply never
3304
+ // sets this marker, so `planChat` (`!recordedIds[serviceDbEnvAppliedScopeKey('chat')]`)
3305
+ // reads it as still missing and plans a pointless extra redeploy on the
3306
+ // very next apply for an org that was never behind.
3307
+ if (serviceDbPush.pushed) {
3308
+ context.recordScopeId(serviceDbEnvAppliedScopeKey('chat'), '1');
3309
+ }
2900
3310
  // Last: the marker tells `desiredAgoraEnv` the names and envs are in place.
2901
3311
  context.recordScopeId(CHAT_SERVICE_URL_SCOPE_KEY, chatUpstreams(org.slug).chat);
2902
3312
  return { resourceId: mas.appId };
@@ -2976,7 +3386,6 @@ export class FoundryStepExecutor {
2976
3386
  await this.ensureLogVolume(app.appId, org, app.existed);
2977
3387
  const bindingSecret = await this.ensureCallsBindingSecret(app.appId);
2978
3388
  const envs = [
2979
- { key: 'DATABASE_URL', value: db.internal_db_url },
2980
3389
  { key: 'LIVEKIT_URL', value: CALLS_SFU_PRIVATE_URL },
2981
3390
  { key: 'LIVEKIT_PUBLIC_URL', value: CALLS_SFU_PUBLIC_URL },
2982
3391
  { key: 'LIVEKIT_API_KEY', value: sfu.apiKey },
@@ -3015,17 +3424,40 @@ export class FoundryStepExecutor {
3015
3424
  // unguarded and the planner reports the lost secret instead of minting again.
3016
3425
  if (token)
3017
3426
  context.recordScopeId(CALLS_INTROSPECTION_SCOPE_KEY, token.appId);
3427
+ // Split the same way `coolifyApp`'s and `cmsSite`'s own databases are
3428
+ // (#239, #456): `DATABASE_URL_OWNER` is the superuser string,
3429
+ // `DATABASE_URL` is a minted `<dbName>_rt` role -- the real Postgres
3430
+ // database name `ensureCallsDatabase` created this database with, not
3431
+ // the Coolify resource name.
3432
+ const dbPush = await this.pushDatabaseUrl(app.appId, dbId, dbName.replace(/-/g, '_'));
3433
+ // #456: a crash between the push above and the deploy/marker below
3434
+ // leaves the env right and no marker, so this replans and deploys once
3435
+ // more -- the same rule `coolifyApp`'s own `dbEnvAppliedScopeKey` marker
3436
+ // follows.
3437
+ const dbMarkerKey = serviceDbEnvAppliedScopeKey('calls-service');
3438
+ const dbSplitNeedsRefresh = dbPush.pushed && (dbPush.changed || !context.recordedIds()[dbMarkerKey]);
3018
3439
  // A container reads its name and env only at start, so a running app is
3019
3440
  // restarted once, when this run is the one that gave it its name or its
3020
3441
  // token env (the same rule `coolifyApp` follows when an existing app's
3021
- // env changed).
3442
+ // env changed). The database split is different: it is only safe once the
3443
+ // image actually contains the migrate step that creates the `_rt` role
3444
+ // (the sibling calls-service PR adding it), so that case redeploys --
3445
+ // rebuilding the image -- rather than restarting the one already running,
3446
+ // the same distinction `coolifyChat`'s own refresh path makes. A restart
3447
+ // here would bring back a container built before that migrate step
3448
+ // existed, still pointed at a role nothing has created.
3022
3449
  const givesTokenEnv = token !== null && context.recordedIds()[CALLS_SERVICE_TOKEN_ENV_SCOPE_KEY] !== tokenMarker;
3023
- if (app.existed && (!callsServiceHasName(org, context.recordedIds()) || givesTokenEnv)) {
3450
+ if (app.existed && dbSplitNeedsRefresh) {
3451
+ await this.ports.coolify.deploy(app.appId);
3452
+ }
3453
+ else if (app.existed && (!callsServiceHasName(org, context.recordedIds()) || givesTokenEnv)) {
3024
3454
  await this.ports.coolify.restart(app.appId);
3025
3455
  }
3026
3456
  context.recordScopeId(CALLS_SERVICE_NAME_SCOPE_KEY, name);
3027
3457
  if (token && tokenMarker)
3028
3458
  context.recordScopeId(CALLS_SERVICE_TOKEN_ENV_SCOPE_KEY, tokenMarker);
3459
+ if (dbPush.pushed)
3460
+ context.recordScopeId(dbMarkerKey, '1');
3029
3461
  return { resourceId: app.appId };
3030
3462
  }
3031
3463
  /**
@@ -3849,22 +4281,26 @@ export class FoundryStepExecutor {
3849
4281
  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`);
3850
4282
  }
3851
4283
  const options = { apex, mailHost };
4284
+ // #491: `null` unless `diff.ts`'s `planMail` carries them -- absent for
4285
+ // every org but the platform's own, and for a plan predating this field.
4286
+ const platformApex = action.desired.platformApex ?? null;
4287
+ const platformLettermintProjectId = action.desired.platformLettermintProjectId ?? null;
3852
4288
  if (mode === 'off') {
3853
4289
  const parkedMailOrg = mailOrgFor(org, usesDomain, 'hosted');
3854
4290
  await this.ports.mail.parkOrgMail(parkedMailOrg, options);
3855
- await this.parkLettermintMail(org, parkedMailOrg, context);
4291
+ await this.parkLettermintMail(org, parkedMailOrg, context, platformApex, platformLettermintProjectId);
3856
4292
  return undefined;
3857
4293
  }
3858
4294
  const mailOrg = mailOrgFor(org, usesDomain, mode);
3859
4295
  const result = await this.ports.mail.applyOrgMail(mailOrg, options);
3860
4296
  await this.publishMailDnsRecords(org, result);
3861
- await this.deleteStaleLettermintDomain(context, result.domain);
4297
+ await this.deleteStaleLettermintDomain(context, result.domain, platformApex);
3862
4298
  context.recordScopeId(MAIL_DOMAIN_SCOPE_KEY, result.domain);
3863
4299
  // #177: what this step wrote from the platform, so `planMail` can see a later change.
3864
4300
  context.recordScopeId(ORG_MAIL_APPLIED_APEX_SCOPE_KEY, options.apex);
3865
4301
  context.recordScopeId(ORG_MAIL_APPLIED_HOST_SCOPE_KEY, options.mailHost);
3866
4302
  await this.ensureOwnerMailbox(org, mailOrg, options, context);
3867
- await this.ensureOrgLettermintRelay(org, mailOrg, result.domain, context);
4303
+ await this.ensureOrgLettermintRelay(org, mailOrg, result.domain, context, platformApex, platformLettermintProjectId);
3868
4304
  await this.ensureRoleMailboxes(org, mailOrg, result.domain, options, context);
3869
4305
  return { resourceId: result.domain };
3870
4306
  }
@@ -3960,11 +4396,20 @@ export class FoundryStepExecutor {
3960
4396
  * domain recorded) and on a same-domain re-apply (compared
3961
4397
  * case-insensitively, the same rule {@link findExactLettermintDomain}
3962
4398
  * applies to the lookup itself).
4399
+ *
4400
+ * #491 review: also a no-op when the previous domain is the platform's own
4401
+ * apex -- an org that moves its mail *off* that domain (`usesDomain` flips,
4402
+ * or `org.domain` changes) must never delete it here, the same protection
4403
+ * {@link parkLettermintMail} already gives the apex on a move to `off`.
4404
+ * `planMail` only blocks a move *onto* the apex (no project id yet); a move
4405
+ * away from it reaches this method with nothing to block it otherwise.
3963
4406
  */
3964
- async deleteStaleLettermintDomain(context, newDomain) {
4407
+ async deleteStaleLettermintDomain(context, newDomain, platformApex) {
3965
4408
  const previous = context.recordedIds()[MAIL_DOMAIN_SCOPE_KEY];
3966
4409
  if (!previous || previous.toLowerCase() === newDomain.toLowerCase())
3967
4410
  return;
4411
+ if (platformApex && previous.toLowerCase() === platformApex.toLowerCase())
4412
+ return;
3968
4413
  const stale = await this.findExactLettermintDomain(previous);
3969
4414
  if (stale)
3970
4415
  await this.ports.lettermint.deleteDomain(stale.id);
@@ -4036,20 +4481,28 @@ export class FoundryStepExecutor {
4036
4481
  * A no-op until a previous apply recorded {@link MAIL_DOMAIN_SCOPE_KEY}:
4037
4482
  * nothing was ever provisioned for an org whose mail went straight from
4038
4483
  * never-applied to off.
4484
+ *
4485
+ * #491: when `mailDomain` is the platform's own apex, this must never
4486
+ * delete the platform's own project (the delete loop skips it by id) or
4487
+ * the platform's own domain (returns before the domain lookup/delete).
4039
4488
  */
4040
- async parkLettermintMail(org, mailOrg, context) {
4489
+ async parkLettermintMail(org, mailOrg, context, platformApex, platformLettermintProjectId) {
4041
4490
  const mailDomain = context.recordedIds()[MAIL_DOMAIN_SCOPE_KEY];
4042
4491
  if (!mailDomain)
4043
4492
  return;
4493
+ const isPlatformApexOrg = Boolean(platformApex && mailDomain.toLowerCase() === platformApex.toLowerCase());
4044
4494
  // #345: `mailOrg.domain` is absent for a level-1 org -- the previously
4045
4495
  // recorded mail domain is the one `MailPort.removeOrgRelay` needs.
4046
4496
  await this.ports.mail.removeOrgRelay({ ...mailOrg, domain: mailDomain });
4047
4497
  const names = new Set([`${org.slug}-app`, `${org.slug}-relay`]);
4048
4498
  const projects = await this.ports.lettermint.listProjects();
4049
4499
  for (const project of projects) {
4050
- if (names.has(project.name))
4500
+ if (names.has(project.name) && project.id !== platformLettermintProjectId) {
4051
4501
  await this.ports.lettermint.deleteProject(project.id);
4502
+ }
4052
4503
  }
4504
+ if (isPlatformApexOrg)
4505
+ return;
4053
4506
  const domain = await this.findExactLettermintDomain(mailDomain);
4054
4507
  if (domain)
4055
4508
  await this.ports.lettermint.deleteDomain(domain.id);
@@ -4214,13 +4667,14 @@ export class FoundryStepExecutor {
4214
4667
  return { resourceId: provisioned.organisationId };
4215
4668
  }
4216
4669
  async aiBudget(action, org) {
4217
- const limitMicros = action.desired.limitMicros ?? null;
4218
- if (limitMicros === null) {
4219
- // ai/packages/sdk/src/types.ts:134-137's UpdateBudget.limitMicros is a
4220
- // required string -- there is no "uncapped" this API can express yet
4221
- // (Finding, this campaign's report).
4222
- 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`);
4223
- }
4670
+ // #484: ai/packages/sdk/src/types.ts:134-137's UpdateBudget.limitMicros
4671
+ // is a required string -- there is no "uncapped" this API can express
4672
+ // yet (Finding, this campaign's report) -- so a null `desired.limitMicros`
4673
+ // (an org that declares no limit) gets DEFAULT_AI_LIMIT_MICROS here too,
4674
+ // the same substitution diff.ts's planners already make when they fill
4675
+ // this field. Belt and braces: by the time a plan reaches here,
4676
+ // `action.desired.limitMicros` should already be non-null.
4677
+ const limitMicros = action.desired.limitMicros ?? DEFAULT_AI_LIMIT_MICROS;
4224
4678
  const ai = await this.aiPort();
4225
4679
  await ai.updateBudget(org.id, { keyId: null, limitMicros });
4226
4680
  return { resourceId: org.id };
@@ -4429,6 +4883,165 @@ export class FoundryStepExecutor {
4429
4883
  ids.push(keyId);
4430
4884
  context.recordScopeId(listKey, JSON.stringify(ids));
4431
4885
  }
4886
+ /**
4887
+ * F7 (#317): mints one entry's edge key directly below the org (edge
4888
+ * takes no separate minter key id, unlike ai/files), writes `EDGE_URL`
4889
+ * and `EDGE_API_KEY` to the entry's app env, then restarts it. Retires
4890
+ * the key when `uses.edge` is removed. Follows the "one mint" rule
4891
+ * (CLAUDE.md "Platform steps"): the id is recorded as undelivered and
4892
+ * persisted before delivery is attempted, so a crash or a failed write
4893
+ * never leads to a second mint -- `planEntryEdgeKeys` blocks instead.
4894
+ */
4895
+ async edgeEntryKey(action, org, context) {
4896
+ const entryKey = action.key.slice(org.id.length + 1);
4897
+ const at = `edge:key for ${org.slug} entry '${entryKey}'`;
4898
+ const edge = this.ports.edge;
4899
+ if (!edge)
4900
+ throw new Error(`${at}: this executor was built without an EdgePort (#317)`);
4901
+ const desired = action.desired;
4902
+ if (desired.mode === 'retire') {
4903
+ const recorded = context.recordedIds();
4904
+ const mainId = recorded[`edge:key:${action.key}`];
4905
+ // A key minted but never delivered never gets a main id (the mint
4906
+ // step throws before `runSteps` sees a `resourceId`), so it can
4907
+ // coexist with a separate, already-delivered id from an earlier mint
4908
+ // of the same entry: mint A, deliver, retire (main id A, retired
4909
+ // marker A); re-declare, mint B, fail delivery three times
4910
+ // (undelivered B, main id still A); undeclare again. The old `||`
4911
+ // here always picked A (truthy), leaving B live for up to 365 days
4912
+ // with nothing recorded (#317 re-review) -- both ids are revoked,
4913
+ // independently, whenever both are present.
4914
+ const undeliveredId = recorded[entryEdgeUndeliveredScopeKey(entryKey)];
4915
+ const idsToRevoke = [...new Set([mainId, undeliveredId].filter((id) => !!id))];
4916
+ const keyId = mainId || undeliveredId;
4917
+ // Revoke first: the key going dead in edge is what retire is for,
4918
+ // and the one thing that must still happen even when the entry's app
4919
+ // is itself already gone. Only once that has succeeded (or 404'd as
4920
+ // already gone) is the env cleanup attempted, itself tolerant of a
4921
+ // 404 on a listEnvs/deleteEnv against an app that no longer exists --
4922
+ // doing this the other way round meant an app already torn down (its
4923
+ // id still recorded) threw on `listEnvs` before `revokeKey` ever ran,
4924
+ // stranding a live key on every retry (#317 review).
4925
+ for (const id of idsToRevoke) {
4926
+ try {
4927
+ await edge.revokeKey(org.id, id);
4928
+ }
4929
+ catch (error) {
4930
+ // A 404 means it is already gone -- recorded as retired below,
4931
+ // the same as a successful revoke. Anything else is a real
4932
+ // failure: thrown, not swallowed, so the key stays live and the
4933
+ // planner retires it again on the next apply instead of this
4934
+ // marking it done.
4935
+ if (!isNotFoundError(error))
4936
+ throw error;
4937
+ }
4938
+ }
4939
+ const appId = recorded[`coolify:app:${action.key}`];
4940
+ if (appId) {
4941
+ // A revoked key's env values are dead, not merely stale -- remove
4942
+ // them so nothing reads a credential edge has already revoked. A
4943
+ // 404 on either call means that app or that env is already gone:
4944
+ // nothing to clean up there, and the key is already revoked above,
4945
+ // so it is not a reason to fail the step. Each delete tolerates its
4946
+ // own 404, so one env already gone does not leave the other behind.
4947
+ const tolerateNotFound = async (call, gone) => {
4948
+ try {
4949
+ return await call();
4950
+ }
4951
+ catch (error) {
4952
+ if (!isNotFoundError(error))
4953
+ throw error;
4954
+ return gone;
4955
+ }
4956
+ };
4957
+ const envs = await tolerateNotFound(() => this.ports.coolify.listEnvs(appId), []);
4958
+ for (const envKey of ['EDGE_URL', 'EDGE_API_KEY']) {
4959
+ const env = envs.find((e) => e.key === envKey && !e.is_preview);
4960
+ if (env?.uuid) {
4961
+ const uuid = env.uuid;
4962
+ await tolerateNotFound(() => this.ports.coolify.deleteEnv(appId, uuid), undefined);
4963
+ }
4964
+ }
4965
+ }
4966
+ context.recordScopeId(entryEdgeUndeliveredScopeKey(entryKey), '');
4967
+ context.recordScopeId(entryEdgeMintedAtScopeKey(entryKey), '');
4968
+ if (!keyId)
4969
+ return {};
4970
+ context.recordScopeId(entryEdgeRetiredScopeKey(entryKey), keyId);
4971
+ return {};
4972
+ }
4973
+ const entry = org.hosting.entries.find((e) => e.key === entryKey && e.uses?.edge);
4974
+ if (!entry)
4975
+ throw new Error(`${at}: the entry no longer declares uses.edge`);
4976
+ const appId = context.recordedIds()[`coolify:app:${action.key}`];
4977
+ if (!appId)
4978
+ throw new Error(`${at}: the entry's app is not recorded yet`);
4979
+ if (!org.domain) {
4980
+ throw new Error(`${at}: edge needs org.domain, and validateEntryEdge already refuses it without one`);
4981
+ }
4982
+ // A key left over from a previous mint whose delivery never succeeded
4983
+ // is revoked first, not left blocking every later apply forever:
4984
+ // nothing here keeps its secret around to retry delivering it
4985
+ // (CLAUDE.md "No secret leaves"), so the only way to actually recover
4986
+ // is to revoke the stale id and mint a fresh one -- a 404 counts as
4987
+ // already revoked, same as any other retire (#317 re-review).
4988
+ const staleUndelivered = context.recordedIds()[entryEdgeUndeliveredScopeKey(entryKey)];
4989
+ if (staleUndelivered) {
4990
+ try {
4991
+ await edge.revokeKey(org.id, staleUndelivered);
4992
+ }
4993
+ catch (error) {
4994
+ if (!isNotFoundError(error))
4995
+ throw error;
4996
+ }
4997
+ context.recordScopeId(entryEdgeUndeliveredScopeKey(entryKey), '');
4998
+ await context.persistScopeIds();
4999
+ }
5000
+ const minted = await edge.mintKey({
5001
+ organisationId: org.id,
5002
+ domain: org.domain,
5003
+ name: desired.name,
5004
+ expiresDays: EDGE_ENTRY_KEY_DAYS,
5005
+ });
5006
+ context.recordScopeId(entryEdgeUndeliveredScopeKey(entryKey), minted.id);
5007
+ await context.persistScopeIds();
5008
+ const envs = [
5009
+ { key: 'EDGE_URL', value: desired.edgeUrl, literal: true },
5010
+ { key: 'EDGE_API_KEY', value: minted.secret, literal: true },
5011
+ ];
5012
+ let lastError;
5013
+ for (let attempt = 1; attempt <= ENTRY_EDGE_KEY_DELIVERY_ATTEMPTS; attempt++) {
5014
+ try {
5015
+ await this.ports.coolify.bulkUpsertEnvs(appId, envs);
5016
+ await this.ports.coolify.restart(appId);
5017
+ context.recordScopeId(entryEdgeUndeliveredScopeKey(entryKey), '');
5018
+ // The only trace of this key's age, since nothing here renews it
5019
+ // (#317 review's warning-row ask reads this to flag an expiring key).
5020
+ context.recordScopeId(entryEdgeMintedAtScopeKey(entryKey), new Date(context.now?.() ?? Date.now()).toISOString());
5021
+ return { resourceId: minted.id };
5022
+ }
5023
+ catch (e) {
5024
+ lastError = e;
5025
+ }
5026
+ }
5027
+ // Names the key id only; the underlying error is dropped in case it echoes the secret.
5028
+ throw new Error(`${at}: key ${minted.id} was minted but could not be written to the app's env after ${ENTRY_EDGE_KEY_DELIVERY_ATTEMPTS} attempts (${lastError instanceof Error ? lastError.name : 'error'}); the next apply revokes it and mints a fresh one automatically`);
5029
+ }
5030
+ /**
5031
+ * F7 (#317): `plan/entry-edge-key.ts`'s `planEdgeReservedLabels` --
5032
+ * tells the edge service every label this org's own entries already
5033
+ * hold, so edge refuses a customer claiming one of them.
5034
+ */
5035
+ async edgeReservedLabels(action, org, context) {
5036
+ const edge = this.ports.edge;
5037
+ if (!edge) {
5038
+ throw new Error(`edge:reserved-labels for ${org.slug}: this executor was built without an EdgePort (#317)`);
5039
+ }
5040
+ const labels = action.desired.labels;
5041
+ await edge.setReservedLabels(org.id, labels);
5042
+ context.recordScopeId(EDGE_RESERVED_LABELS_APPLIED_SCOPE_KEY, JSON.stringify(labels));
5043
+ return {};
5044
+ }
4432
5045
  /**
4433
5046
  * F6b: mints or renews the org's own files key, or revokes the ones a
4434
5047
  * renewal replaced. The key is minted below the files organisation's owner
@@ -4829,6 +5442,45 @@ export class FoundryStepExecutor {
4829
5442
  await files.revokeOrganisationKey(org.id, target.id);
4830
5443
  return;
4831
5444
  }
5445
+ case 'cloudflare:worker': {
5446
+ // #318: an entry's Worker (`<orgId>:<entry>`) and its route.
5447
+ // No port means no way to delete either -- thrown, not silently
5448
+ // skipped, so it is reported blocked rather than left live with
5449
+ // nothing recorded ids point at any more.
5450
+ const workers = this.ports.workers;
5451
+ if (!workers) {
5452
+ throw new Error(`cloudflare:worker '${target.key}' for ${org.slug}: this executor was built without a WorkersPort (#318); cannot delete it`);
5453
+ }
5454
+ // The route on its recorded zone (skipped only once that zone is
5455
+ // positively gone), any route an interrupted apply created but
5456
+ // never recorded, then the script -- the same teardown retire runs.
5457
+ // `target.id` is `''` for a route-only partial deploy.
5458
+ const entryKey = target.key.slice(org.id.length + 1);
5459
+ await this.teardownWorker(entryKey, target.id || undefined, recordedIds, workers, `cloudflare:worker '${target.key}' for ${org.slug}`);
5460
+ return;
5461
+ }
5462
+ case 'edge:key': {
5463
+ // F7 (#317): an entry's key (`<orgId>:<entry>`); edge has no org-root key.
5464
+ // No port means no way to revoke it -- thrown, not silently
5465
+ // skipped, so the key is reported blocked rather than left live
5466
+ // with nothing saying so.
5467
+ const edge = this.ports.edge;
5468
+ if (!edge) {
5469
+ throw new Error(`edge:key '${target.key}' for ${org.slug}: this executor was built without an EdgePort (#317); cannot revoke it`);
5470
+ }
5471
+ try {
5472
+ await edge.revokeKey(org.id, target.id);
5473
+ }
5474
+ catch (error) {
5475
+ // Already gone -- the same already-retired key a retire step
5476
+ // handled before org destroy ever ran can still reach here
5477
+ // (#317 review); a 404 is success, not a reason to block
5478
+ // teardown forever.
5479
+ if (!isNotFoundError(error))
5480
+ throw error;
5481
+ }
5482
+ return;
5483
+ }
4832
5484
  case 'coolify:app':
4833
5485
  await this.ports.coolify.deleteApplication(target.id);
4834
5486
  return;