@wtfalch/foundry 0.17.0 → 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.
@@ -9,12 +9,15 @@ 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';
13
+ import { entryEdgeAppliedScopeKey, entryEdgeRetiredScopeKey, entryEdgeRouteIdScopeKey, entryEdgeRoutePatternScopeKey, entryEdgeRoutePendingScopeKey, entryEdgeRouteZoneScopeKey, entryEdgeUploadedScopeKey, } from './edge-worker.js';
12
14
  import { AI_ENTRY_RENEW_WINDOW_DAYS, entryAiAppliedScopeKey, entryAiClampedScopeKey, entryAiExpiresScopeKey, entryAiPendingExpiresScopeKey, entryAiRetiredScopeKey, entryAiUndeliveredScopeKey, entryAiUnrevokedScopeKey, entryAiWebhookPendingScopeKey, entryAiWrittenScopeKey, parseIdList, } from './entry-ai-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';
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';
14
17
  import { appHostname, coolifyDomains, entryHostnames, tunnelDnsHostnames, tunnelIngressHostnames, } from './hostnames.js';
15
18
  import { LOGIN_BINDING_SCOPE_KEY, LOGIN_HOST_PENDING_REMOVAL_SCOPE_KEY, loginBindingState, loginHostClientIds, pendingRemovalHosts, pendingRemovalState, } from './login-host.js';
16
19
  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';
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';
18
21
  import { PLATFORM_AUTH_BROKER_MAIL_KEY_REF } from './platform-providers.js';
19
22
  import { appLabelsAppliedScopeKey, entryLabelHash, entryLabelSpec, entryProxyLabels, sameLabelSets, } from './proxy-labels.js';
20
23
  /**
@@ -657,6 +660,8 @@ export class FoundryStepExecutor {
657
660
  return this.cloudflareAccess(action, org);
658
661
  case 'cloudflare:ratelimit':
659
662
  return this.cloudflareRateLimit(org);
663
+ case 'cloudflare:worker':
664
+ return this.cloudflareWorker(action, org, context);
660
665
  case 'files:organisation':
661
666
  return this.filesOrganisation(action, org, context);
662
667
  case 'files:key':
@@ -673,6 +678,10 @@ export class FoundryStepExecutor {
673
678
  return action.key.includes(':')
674
679
  ? this.aiEntryKey(action, org, context)
675
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);
676
685
  case 'coolify:mail':
677
686
  return this.coolifyMail(action, org, context);
678
687
  case 'coolify:mail-copy':
@@ -840,6 +849,192 @@ export class FoundryStepExecutor {
840
849
  }
841
850
  return { resourceId: created.id };
842
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
+ }
843
1038
  // ---- zitadel --------------------------------------------------------
844
1039
  async zitadelIssuer(action, org, context) {
845
1040
  if (action.desired.provision !== true) {
@@ -1498,7 +1693,15 @@ export class FoundryStepExecutor {
1498
1693
  // One Lettermint project, `<slug>-app`, belongs to the `app` entry; a
1499
1694
  // second entry sharing the name would delete the first's live token.
1500
1695
  if (entry.key === APP_ENTRY_KEY) {
1501
- 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);
1502
1705
  }
1503
1706
  return { resourceId: appId };
1504
1707
  }
@@ -1937,12 +2140,73 @@ export class FoundryStepExecutor {
1937
2140
  * comment), never logged, never interpolated into a thrown error -- the
1938
2141
  * same discipline `pushDatabaseUrl`'s doc comment states for a database's
1939
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).
1940
2159
  */
1941
- async ensureAppLettermintMail(appId, org, context, running) {
2160
+ async ensureAppLettermintMail(appId, org, context, running, hasPlatformApexField, platformApex, platformLettermintProjectId) {
1942
2161
  const mailDomain = context.recordedIds()[MAIL_DOMAIN_SCOPE_KEY];
1943
2162
  if (!mailDomain)
1944
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
+ }
1945
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
+ }
1946
2210
  const [projects, envs] = await Promise.all([
1947
2211
  this.ports.lettermint.listProjects(),
1948
2212
  this.ports.coolify.listEnvs(appId),
@@ -1973,9 +2237,11 @@ export class FoundryStepExecutor {
1973
2237
  // old project is deleted below. A just-created app starts with the new env.
1974
2238
  if (!healthy && running)
1975
2239
  await this.ports.coolify.restart(appId);
1976
- await this.ensureLettermintDomainRestriction(org, mailDomain, allProjects);
2240
+ await this.ensureLettermintDomainRestriction(org, mailDomain, allProjects, platformApex, platformLettermintProjectId);
1977
2241
  for (const project of allProjects) {
1978
- if (project.name === appName && project.id !== keepProjectId) {
2242
+ if (project.name === appName &&
2243
+ project.id !== keepProjectId &&
2244
+ project.id !== platformLettermintProjectId) {
1979
2245
  await this.ports.lettermint.deleteProject(project.id);
1980
2246
  }
1981
2247
  }
@@ -2013,8 +2279,14 @@ export class FoundryStepExecutor {
2013
2279
  * it the same way the app step does, rather than assuming it already
2014
2280
  * exists -- the domain must never end up unrestricted, whichever step gets
2015
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.
2016
2288
  */
2017
- async ensureOrgLettermintRelay(org, mailOrg, mailDomain, context) {
2289
+ async ensureOrgLettermintRelay(org, mailOrg, mailDomain, context, platformApex, platformLettermintProjectId) {
2018
2290
  const relayName = `${org.slug}-relay`;
2019
2291
  // #345: `mailOrg.domain` is absent for a level-1 org -- `mailDomain` is
2020
2292
  // `applyOrgMail`'s own resolved result, the one domain `MailPort`'s
@@ -2033,7 +2305,7 @@ export class FoundryStepExecutor {
2033
2305
  let keepProjectId;
2034
2306
  if (healthy && held) {
2035
2307
  keepProjectId = held.id;
2036
- await this.ensureLettermintDomainRestriction(org, mailDomain, allProjects);
2308
+ await this.ensureLettermintDomainRestriction(org, mailDomain, allProjects, platformApex, platformLettermintProjectId);
2037
2309
  }
2038
2310
  else {
2039
2311
  const created = await this.ports.lettermint.createProject(relayName);
@@ -2044,7 +2316,7 @@ export class FoundryStepExecutor {
2044
2316
  // reaches Stalwart: a failure here leaves the working route alone.
2045
2317
  // The restriction covers old and new projects, so the old one keeps
2046
2318
  // sending until the route switches.
2047
- await this.ensureLettermintDomainRestriction(org, mailDomain, allProjects);
2319
+ await this.ensureLettermintDomainRestriction(org, mailDomain, allProjects, platformApex, platformLettermintProjectId);
2048
2320
  await this.ports.mail.applyOrgRelay(relayOrg, created.token);
2049
2321
  }
2050
2322
  catch (err) {
@@ -2057,7 +2329,9 @@ export class FoundryStepExecutor {
2057
2329
  await context.persistScopeIds();
2058
2330
  }
2059
2331
  for (const project of allProjects) {
2060
- if (project.name === relayName && project.id !== keepProjectId) {
2332
+ if (project.name === relayName &&
2333
+ project.id !== keepProjectId &&
2334
+ project.id !== platformLettermintProjectId) {
2061
2335
  await this.ports.lettermint.deleteProject(project.id);
2062
2336
  }
2063
2337
  }
@@ -2089,10 +2363,31 @@ export class FoundryStepExecutor {
2089
2363
  * unrestricted domain cannot verify and cannot send, whichever project
2090
2364
  * owns it (post-#140 review finding, restated here rather than only at
2091
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.
2092
2381
  */
2093
- async ensureLettermintDomainRestriction(org, mailDomain, allProjects) {
2382
+ async ensureLettermintDomainRestriction(org, mailDomain, allProjects, platformApex, platformLettermintProjectId) {
2094
2383
  const appName = `${org.slug}-app`;
2095
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
+ }
2096
2391
  const existing = await this.findExactLettermintDomain(mailDomain);
2097
2392
  const domain = existing
2098
2393
  ? await this.ports.lettermint.getDomain(existing.id)
@@ -2100,6 +2395,11 @@ export class FoundryStepExecutor {
2100
2395
  const scopedProjectIds = allProjects
2101
2396
  .filter((p) => p.name === appName || p.name === relayName)
2102
2397
  .map((p) => p.id);
2398
+ if (isPlatformApexDomain &&
2399
+ platformLettermintProjectId &&
2400
+ !scopedProjectIds.includes(platformLettermintProjectId)) {
2401
+ scopedProjectIds.push(platformLettermintProjectId);
2402
+ }
2103
2403
  await this.ports.lettermint.restrictDomainToProjects(domain.id, scopedProjectIds);
2104
2404
  const zone = await this.ports.cloudflare.findZoneByApex(mailDomain);
2105
2405
  if (!zone) {
@@ -2739,6 +3039,34 @@ export class FoundryStepExecutor {
2739
3039
  async coolifyChat(action, org, context) {
2740
3040
  const recorded = context.recordedIds();
2741
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
+ }
2742
3070
  if (action.desired.usesDomain === false) {
2743
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)');
2744
3072
  }
@@ -2881,8 +3209,13 @@ export class FoundryStepExecutor {
2881
3209
  agoraHost,
2882
3210
  masClientId: secrets.value.masClientId,
2883
3211
  masClientSecret: secrets.value.masClientSecret,
2884
- databaseUrl: serviceDbRow.internal_db_url,
2885
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');
2886
3219
  // A file-storage write alone never reaches a running container. Deploy an
2887
3220
  // app that is new, whose config was freshly minted, or that is not
2888
3221
  // running now. The last test reads the live status, not what this run
@@ -2893,10 +3226,19 @@ export class FoundryStepExecutor {
2893
3226
  for (const app of [mas, synapse, service]) {
2894
3227
  if (!app.existed ||
2895
3228
  secrets.changed ||
3229
+ (app === service && serviceDbPush.changed) ||
2896
3230
  !isRunningOrStarting((await this.ports.coolify.getApplication(app.appId)).status)) {
2897
3231
  await this.ports.coolify.deploy(app.appId);
2898
3232
  }
2899
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
+ }
2900
3242
  // Last: the marker tells `desiredAgoraEnv` the names and envs are in place.
2901
3243
  context.recordScopeId(CHAT_SERVICE_URL_SCOPE_KEY, chatUpstreams(org.slug).chat);
2902
3244
  return { resourceId: mas.appId };
@@ -2976,7 +3318,6 @@ export class FoundryStepExecutor {
2976
3318
  await this.ensureLogVolume(app.appId, org, app.existed);
2977
3319
  const bindingSecret = await this.ensureCallsBindingSecret(app.appId);
2978
3320
  const envs = [
2979
- { key: 'DATABASE_URL', value: db.internal_db_url },
2980
3321
  { key: 'LIVEKIT_URL', value: CALLS_SFU_PRIVATE_URL },
2981
3322
  { key: 'LIVEKIT_PUBLIC_URL', value: CALLS_SFU_PUBLIC_URL },
2982
3323
  { key: 'LIVEKIT_API_KEY', value: sfu.apiKey },
@@ -3015,17 +3356,40 @@ export class FoundryStepExecutor {
3015
3356
  // unguarded and the planner reports the lost secret instead of minting again.
3016
3357
  if (token)
3017
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]);
3018
3371
  // A container reads its name and env only at start, so a running app is
3019
3372
  // restarted once, when this run is the one that gave it its name or its
3020
3373
  // token env (the same rule `coolifyApp` follows when an existing app's
3021
- // 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.
3022
3381
  const givesTokenEnv = token !== null && context.recordedIds()[CALLS_SERVICE_TOKEN_ENV_SCOPE_KEY] !== tokenMarker;
3023
- 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)) {
3024
3386
  await this.ports.coolify.restart(app.appId);
3025
3387
  }
3026
3388
  context.recordScopeId(CALLS_SERVICE_NAME_SCOPE_KEY, name);
3027
3389
  if (token && tokenMarker)
3028
3390
  context.recordScopeId(CALLS_SERVICE_TOKEN_ENV_SCOPE_KEY, tokenMarker);
3391
+ if (dbPush.pushed)
3392
+ context.recordScopeId(dbMarkerKey, '1');
3029
3393
  return { resourceId: app.appId };
3030
3394
  }
3031
3395
  /**
@@ -3849,22 +4213,26 @@ export class FoundryStepExecutor {
3849
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`);
3850
4214
  }
3851
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;
3852
4220
  if (mode === 'off') {
3853
4221
  const parkedMailOrg = mailOrgFor(org, usesDomain, 'hosted');
3854
4222
  await this.ports.mail.parkOrgMail(parkedMailOrg, options);
3855
- await this.parkLettermintMail(org, parkedMailOrg, context);
4223
+ await this.parkLettermintMail(org, parkedMailOrg, context, platformApex, platformLettermintProjectId);
3856
4224
  return undefined;
3857
4225
  }
3858
4226
  const mailOrg = mailOrgFor(org, usesDomain, mode);
3859
4227
  const result = await this.ports.mail.applyOrgMail(mailOrg, options);
3860
4228
  await this.publishMailDnsRecords(org, result);
3861
- await this.deleteStaleLettermintDomain(context, result.domain);
4229
+ await this.deleteStaleLettermintDomain(context, result.domain, platformApex);
3862
4230
  context.recordScopeId(MAIL_DOMAIN_SCOPE_KEY, result.domain);
3863
4231
  // #177: what this step wrote from the platform, so `planMail` can see a later change.
3864
4232
  context.recordScopeId(ORG_MAIL_APPLIED_APEX_SCOPE_KEY, options.apex);
3865
4233
  context.recordScopeId(ORG_MAIL_APPLIED_HOST_SCOPE_KEY, options.mailHost);
3866
4234
  await this.ensureOwnerMailbox(org, mailOrg, options, context);
3867
- await this.ensureOrgLettermintRelay(org, mailOrg, result.domain, context);
4235
+ await this.ensureOrgLettermintRelay(org, mailOrg, result.domain, context, platformApex, platformLettermintProjectId);
3868
4236
  await this.ensureRoleMailboxes(org, mailOrg, result.domain, options, context);
3869
4237
  return { resourceId: result.domain };
3870
4238
  }
@@ -3960,11 +4328,20 @@ export class FoundryStepExecutor {
3960
4328
  * domain recorded) and on a same-domain re-apply (compared
3961
4329
  * case-insensitively, the same rule {@link findExactLettermintDomain}
3962
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.
3963
4338
  */
3964
- async deleteStaleLettermintDomain(context, newDomain) {
4339
+ async deleteStaleLettermintDomain(context, newDomain, platformApex) {
3965
4340
  const previous = context.recordedIds()[MAIL_DOMAIN_SCOPE_KEY];
3966
4341
  if (!previous || previous.toLowerCase() === newDomain.toLowerCase())
3967
4342
  return;
4343
+ if (platformApex && previous.toLowerCase() === platformApex.toLowerCase())
4344
+ return;
3968
4345
  const stale = await this.findExactLettermintDomain(previous);
3969
4346
  if (stale)
3970
4347
  await this.ports.lettermint.deleteDomain(stale.id);
@@ -4036,20 +4413,28 @@ export class FoundryStepExecutor {
4036
4413
  * A no-op until a previous apply recorded {@link MAIL_DOMAIN_SCOPE_KEY}:
4037
4414
  * nothing was ever provisioned for an org whose mail went straight from
4038
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).
4039
4420
  */
4040
- async parkLettermintMail(org, mailOrg, context) {
4421
+ async parkLettermintMail(org, mailOrg, context, platformApex, platformLettermintProjectId) {
4041
4422
  const mailDomain = context.recordedIds()[MAIL_DOMAIN_SCOPE_KEY];
4042
4423
  if (!mailDomain)
4043
4424
  return;
4425
+ const isPlatformApexOrg = Boolean(platformApex && mailDomain.toLowerCase() === platformApex.toLowerCase());
4044
4426
  // #345: `mailOrg.domain` is absent for a level-1 org -- the previously
4045
4427
  // recorded mail domain is the one `MailPort.removeOrgRelay` needs.
4046
4428
  await this.ports.mail.removeOrgRelay({ ...mailOrg, domain: mailDomain });
4047
4429
  const names = new Set([`${org.slug}-app`, `${org.slug}-relay`]);
4048
4430
  const projects = await this.ports.lettermint.listProjects();
4049
4431
  for (const project of projects) {
4050
- if (names.has(project.name))
4432
+ if (names.has(project.name) && project.id !== platformLettermintProjectId) {
4051
4433
  await this.ports.lettermint.deleteProject(project.id);
4434
+ }
4052
4435
  }
4436
+ if (isPlatformApexOrg)
4437
+ return;
4053
4438
  const domain = await this.findExactLettermintDomain(mailDomain);
4054
4439
  if (domain)
4055
4440
  await this.ports.lettermint.deleteDomain(domain.id);
@@ -4214,13 +4599,14 @@ export class FoundryStepExecutor {
4214
4599
  return { resourceId: provisioned.organisationId };
4215
4600
  }
4216
4601
  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
- }
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;
4224
4610
  const ai = await this.aiPort();
4225
4611
  await ai.updateBudget(org.id, { keyId: null, limitMicros });
4226
4612
  return { resourceId: org.id };
@@ -4429,6 +4815,165 @@ export class FoundryStepExecutor {
4429
4815
  ids.push(keyId);
4430
4816
  context.recordScopeId(listKey, JSON.stringify(ids));
4431
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
+ }
4432
4977
  /**
4433
4978
  * F6b: mints or renews the org's own files key, or revokes the ones a
4434
4979
  * renewal replaced. The key is minted below the files organisation's owner
@@ -4829,6 +5374,45 @@ export class FoundryStepExecutor {
4829
5374
  await files.revokeOrganisationKey(org.id, target.id);
4830
5375
  return;
4831
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
+ }
4832
5416
  case 'coolify:app':
4833
5417
  await this.ports.coolify.deleteApplication(target.id);
4834
5418
  return;