@wtfalch/foundry 0.17.1 → 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.
@@ -10,16 +10,16 @@ import { CHAT_REPO, CHAT_SERVICE_DOCKERFILE, CHAT_SERVICE_PORT, CHAT_SYNAPSE_DOC
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
12
  import { DEFAULT_AI_LIMIT_MICROS } from './diff.js';
13
- import { entryEdgeAppliedScopeKey, entryEdgeRetiredScopeKey, entryEdgeRouteIdScopeKey, entryEdgeRoutePatternScopeKey, entryEdgeRoutePendingScopeKey, entryEdgeRouteZoneScopeKey, entryEdgeUploadedScopeKey, } from './edge-worker.js';
14
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';
15
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';
17
16
  import { appHostname, coolifyDomains, entryHostnames, tunnelDnsHostnames, tunnelIngressHostnames, } from './hostnames.js';
18
17
  import { LOGIN_BINDING_SCOPE_KEY, LOGIN_HOST_PENDING_REMOVAL_SCOPE_KEY, loginBindingState, loginHostClientIds, pendingRemovalHosts, pendingRemovalState, } from './login-host.js';
19
18
  import { MAIL_COPY_COMPLETED_SCOPE_KEY, MAIL_COPY_DONE_SCOPE_KEY, parseMailCopyCompleted, runMailCopyStep, } from './mail-copy.js';
20
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';
21
20
  import { PLATFORM_AUTH_BROKER_MAIL_KEY_REF } from './platform-providers.js';
22
21
  import { appLabelsAppliedScopeKey, entryLabelHash, entryLabelSpec, entryProxyLabels, sameLabelSets, } from './proxy-labels.js';
22
+ import { entryWorkerAppliedScopeKey, entryWorkerExclusionsScopeKey, entryWorkerRetiredScopeKey, entryWorkerRouteIdScopeKey, entryWorkerRoutePatternScopeKey, entryWorkerRoutePendingScopeKey, entryWorkerRouteZoneScopeKey, entryWorkerUploadedScopeKey, } from './worker.js';
23
23
  /**
24
24
  * #274 fix round 4: bridges `@wtfalch/providers`' real `ZitadelClient.
25
25
  * listApps` -- a plain array, with no `loginBaseUri` or completeness signal
@@ -508,11 +508,21 @@ const CALLS_MAS_DB_SCOPE_KEY = 'coolify_calls_mas_database';
508
508
  * Synapse refuses to start against a database whose `LC_COLLATE`/`LC_CTYPE`
509
509
  * is not `C` (https://element-hq.github.io/synapse/latest/postgres.html);
510
510
  * Coolify's official postgres image otherwise creates one with `en_US.utf8`.
511
- * `postgres_initdb_args` is the only point this can be set -- it cannot be
512
- * migrated onto an existing database. MAS has no such requirement, so this
513
- * is passed for the Synapse database only. Not using Synapse's own
514
- * `allow_unsafe_locale` escape hatch: that trades a loud startup refusal for
515
- * 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.
516
526
  */
517
527
  const CALLS_SYNAPSE_DB_INITDB_ARGS = '--locale=C --encoding=UTF8';
518
528
  /**
@@ -678,10 +688,10 @@ export class FoundryStepExecutor {
678
688
  return action.key.includes(':')
679
689
  ? this.aiEntryKey(action, org, context)
680
690
  : 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);
691
+ case 'edge:key':
692
+ return this.edgeEntryKey(action, org, context);
693
+ case 'edge:reserved-labels':
694
+ return this.edgeReservedLabels(action, org, context);
685
695
  case 'coolify:mail':
686
696
  return this.coolifyMail(action, org, context);
687
697
  case 'coolify:mail-copy':
@@ -850,10 +860,10 @@ export class FoundryStepExecutor {
850
860
  return { resourceId: created.id };
851
861
  }
852
862
  /**
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
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
855
865
  * without a {@link WorkersPort}, the same `jobsRegistration` shape --
856
- * `planEdgeWorkers` reports the step blocked instead while the host has
866
+ * `planWorkers` reports the step blocked instead while the host has
857
867
  * none, so this only fires against a stale plan. The route is checked
858
868
  * before upload: a foreign route already on the pattern is refused, never
859
869
  * adopted or replaced (`cloudflareAccess`'s own precedent for a foreign
@@ -879,24 +889,25 @@ export class FoundryStepExecutor {
879
889
  if (desired.mode === 'retire') {
880
890
  const recorded = context.recordedIds();
881
891
  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), '');
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), '');
890
901
  // `runSteps` only ever writes the main recorded id from a truthy
891
902
  // `resourceId` (`plan-apply.ts`); this step returns none, so without
892
903
  // this marker the planner's "gone" scan would retire the same,
893
904
  // never-cleared id forever.
894
905
  if (mainId)
895
- context.recordScopeId(entryEdgeRetiredScopeKey(entryKey), mainId);
906
+ context.recordScopeId(entryWorkerRetiredScopeKey(entryKey), mainId);
896
907
  return {};
897
908
  }
898
909
  if (!org.domain) {
899
- throw new Error(`${at}: edge needs org.domain, and validateHosting already refuses it without one`);
910
+ throw new Error(`${at}: worker needs org.domain, and validateHosting already refuses it without one`);
900
911
  }
901
912
  const zone = await this.ports.cloudflare.findZoneByApex(org.domain);
902
913
  if (!zone) {
@@ -919,8 +930,8 @@ export class FoundryStepExecutor {
919
930
  // the same deterministic name) is foundry's own script to overwrite,
920
931
  // never a stranger's.
921
932
  const recordedBeforeUpload = context.recordedIds();
922
- const everUploadedByFoundry = Boolean(recordedBeforeUpload[entryEdgeAppliedScopeKey(entryKey)] ||
923
- recordedBeforeUpload[entryEdgeUploadedScopeKey(entryKey)] ||
933
+ const everUploadedByFoundry = Boolean(recordedBeforeUpload[entryWorkerAppliedScopeKey(entryKey)] ||
934
+ recordedBeforeUpload[entryWorkerUploadedScopeKey(entryKey)] ||
924
935
  recordedBeforeUpload[`cloudflare:worker:${action.key}`]);
925
936
  if (!everUploadedByFoundry && (await workers.scriptExists(name))) {
926
937
  throw new Error(`${at}: a Worker script named '${name}' already exists and foundry never created it; refuses to overwrite it`);
@@ -931,8 +942,8 @@ export class FoundryStepExecutor {
931
942
  // the `scriptExists` guard above. The retired marker clears here too,
932
943
  // not only on success: once a redeploy has touched the script, a later
933
944
  // undeclare must retire it even if this apply never finishes.
934
- context.recordScopeId(entryEdgeUploadedScopeKey(entryKey), name);
935
- context.recordScopeId(entryEdgeRetiredScopeKey(entryKey), '');
945
+ context.recordScopeId(entryWorkerUploadedScopeKey(entryKey), name);
946
+ context.recordScopeId(entryWorkerRetiredScopeKey(entryKey), '');
936
947
  await context.persistScopeIds();
937
948
  await workers.uploadScript({
938
949
  name,
@@ -941,9 +952,12 @@ export class FoundryStepExecutor {
941
952
  compatibilityDate: action.desired.compatibilityDate,
942
953
  bindings: bindings.length > 0 ? bindings : undefined,
943
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);
944
958
  const recorded = context.recordedIds();
945
- const previousRouteId = recorded[entryEdgeRouteIdScopeKey(entryKey)];
946
- const previousPending = recorded[entryEdgeRoutePendingScopeKey(entryKey)];
959
+ const previousRouteId = recorded[entryWorkerRouteIdScopeKey(entryKey)];
960
+ const previousPending = recorded[entryWorkerRoutePendingScopeKey(entryKey)];
947
961
  const zoneRecord = { id: zone.id, apex: org.domain };
948
962
  let routeId = existingRoute?.id;
949
963
  if (!routeId) {
@@ -951,7 +965,7 @@ export class FoundryStepExecutor {
951
965
  // never got to record is still found (`findRoute`) and deleted on
952
966
  // retire or destroy (#318 re-review).
953
967
  const pending = { ...zoneRecord, pattern: route, script: name };
954
- context.recordScopeId(entryEdgeRoutePendingScopeKey(entryKey), JSON.stringify(pending));
968
+ context.recordScopeId(entryWorkerRoutePendingScopeKey(entryKey), JSON.stringify(pending));
955
969
  await context.persistScopeIds();
956
970
  const created = await workers.createRoute(zone.id, route, name);
957
971
  routeId = created.id;
@@ -961,30 +975,75 @@ export class FoundryStepExecutor {
961
975
  // own zone rather than left live beside the new one. A pending route
962
976
  // from an earlier, interrupted apply under a different pattern likewise.
963
977
  if (previousRouteId && previousRouteId !== routeId) {
964
- const previousZone = await this.liveEdgeZone(recorded[entryEdgeRouteZoneScopeKey(entryKey)], at);
978
+ const previousZone = await this.liveWorkerZone(recorded[entryWorkerRouteZoneScopeKey(entryKey)], at);
965
979
  if (previousZone)
966
980
  await workers.deleteRoute(previousZone, previousRouteId); // Idempotent.
967
981
  }
968
982
  if (previousPending) {
969
- await this.deletePendingEdgeRoute(previousPending, routeId, workers, at);
983
+ await this.deletePendingWorkerRoute(previousPending, routeId, workers, at);
970
984
  }
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({
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({
976
990
  name,
977
991
  artifactUrl: desired.artifactUrl,
978
992
  artifactSha256: desired.artifactSha256,
979
993
  route,
980
994
  bindings: desired.bindings,
995
+ exclusions: desired.exclusions,
981
996
  }));
982
- context.recordScopeId(entryEdgeUploadedScopeKey(entryKey), '');
997
+ context.recordScopeId(entryWorkerUploadedScopeKey(entryKey), '');
983
998
  // The retired marker was already cleared before the upload above: a
984
999
  // redeclare reuses this entry's deterministic script name, so a marker
985
1000
  // left equal to the (identical) main id would read as already retired.
986
1001
  return { resourceId: name };
987
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
+ }
988
1047
  /**
989
1048
  * The zone id a recorded route lives in, or `undefined` only when that
990
1049
  * zone is *positively* gone -- its apex resolves to no zone, or to a
@@ -998,7 +1057,7 @@ export class FoundryStepExecutor {
998
1057
  * live on the old one, the exact bug a recorded-per-route zone exists to
999
1058
  * avoid (#318 re-review).
1000
1059
  */
1001
- async liveEdgeZone(recordedZone, at) {
1060
+ async liveWorkerZone(recordedZone, at) {
1002
1061
  if (!recordedZone) {
1003
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`);
1004
1063
  }
@@ -1007,9 +1066,9 @@ export class FoundryStepExecutor {
1007
1066
  return zone && zone.id === id ? id : undefined;
1008
1067
  }
1009
1068
  /** 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) {
1069
+ async deletePendingWorkerRoute(pendingRaw, keepRouteId, workers, at) {
1011
1070
  const pending = JSON.parse(pendingRaw);
1012
- const zoneId = await this.liveEdgeZone(JSON.stringify({ id: pending.id, apex: pending.apex }), at);
1071
+ const zoneId = await this.liveWorkerZone(JSON.stringify({ id: pending.id, apex: pending.apex }), at);
1013
1072
  if (!zoneId)
1014
1073
  return;
1015
1074
  const found = await workers.findRoute(zoneId, pending.pattern);
@@ -1022,16 +1081,25 @@ export class FoundryStepExecutor {
1022
1081
  * zone, then any route an interrupted apply created but never recorded,
1023
1082
  * then the script. Records nothing -- the caller does.
1024
1083
  */
1025
- async teardownEdgeWorker(entryKey, scriptName, recorded, workers, at) {
1026
- const routeId = recorded[entryEdgeRouteIdScopeKey(entryKey)];
1084
+ async teardownWorker(entryKey, scriptName, recorded, workers, at) {
1085
+ const routeId = recorded[entryWorkerRouteIdScopeKey(entryKey)];
1027
1086
  if (routeId) {
1028
- const zoneId = await this.liveEdgeZone(recorded[entryEdgeRouteZoneScopeKey(entryKey)], at);
1087
+ const zoneId = await this.liveWorkerZone(recorded[entryWorkerRouteZoneScopeKey(entryKey)], at);
1029
1088
  if (zoneId)
1030
1089
  await workers.deleteRoute(zoneId, routeId); // Idempotent: a no-op if already gone.
1031
1090
  }
1032
- const pending = recorded[entryEdgeRoutePendingScopeKey(entryKey)];
1091
+ const pending = recorded[entryWorkerRoutePendingScopeKey(entryKey)];
1033
1092
  if (pending)
1034
- await this.deletePendingEdgeRoute(pending, undefined, workers, at);
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
+ }
1035
1103
  if (scriptName)
1036
1104
  await workers.deleteScript(scriptName); // Idempotent: a no-op if already gone.
1037
1105
  }
@@ -4816,24 +4884,24 @@ export class FoundryStepExecutor {
4816
4884
  context.recordScopeId(listKey, JSON.stringify(ids));
4817
4885
  }
4818
4886
  /**
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
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
4823
4891
  * (CLAUDE.md "Platform steps"): the id is recorded as undelivered and
4824
4892
  * persisted before delivery is attempted, so a crash or a failed write
4825
- * never leads to a second mint -- `planEntryNamesKeys` blocks instead.
4893
+ * never leads to a second mint -- `planEntryEdgeKeys` blocks instead.
4826
4894
  */
4827
- async namesEntryKey(action, org, context) {
4895
+ async edgeEntryKey(action, org, context) {
4828
4896
  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)`);
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)`);
4833
4901
  const desired = action.desired;
4834
4902
  if (desired.mode === 'retire') {
4835
4903
  const recorded = context.recordedIds();
4836
- const mainId = recorded[`names:key:${action.key}`];
4904
+ const mainId = recorded[`edge:key:${action.key}`];
4837
4905
  // A key minted but never delivered never gets a main id (the mint
4838
4906
  // step throws before `runSteps` sees a `resourceId`), so it can
4839
4907
  // coexist with a separate, already-delivered id from an earlier mint
@@ -4843,10 +4911,10 @@ export class FoundryStepExecutor {
4843
4911
  // here always picked A (truthy), leaving B live for up to 365 days
4844
4912
  // with nothing recorded (#317 re-review) -- both ids are revoked,
4845
4913
  // independently, whenever both are present.
4846
- const undeliveredId = recorded[entryNamesUndeliveredScopeKey(entryKey)];
4914
+ const undeliveredId = recorded[entryEdgeUndeliveredScopeKey(entryKey)];
4847
4915
  const idsToRevoke = [...new Set([mainId, undeliveredId].filter((id) => !!id))];
4848
4916
  const keyId = mainId || undeliveredId;
4849
- // Revoke first: the key going dead in names is what retire is for,
4917
+ // Revoke first: the key going dead in edge is what retire is for,
4850
4918
  // and the one thing that must still happen even when the entry's app
4851
4919
  // is itself already gone. Only once that has succeeded (or 404'd as
4852
4920
  // already gone) is the env cleanup attempted, itself tolerant of a
@@ -4856,7 +4924,7 @@ export class FoundryStepExecutor {
4856
4924
  // stranding a live key on every retry (#317 review).
4857
4925
  for (const id of idsToRevoke) {
4858
4926
  try {
4859
- await names.revokeKey(org.id, id);
4927
+ await edge.revokeKey(org.id, id);
4860
4928
  }
4861
4929
  catch (error) {
4862
4930
  // A 404 means it is already gone -- recorded as retired below,
@@ -4871,7 +4939,7 @@ export class FoundryStepExecutor {
4871
4939
  const appId = recorded[`coolify:app:${action.key}`];
4872
4940
  if (appId) {
4873
4941
  // A revoked key's env values are dead, not merely stale -- remove
4874
- // them so nothing reads a credential names has already revoked. A
4942
+ // them so nothing reads a credential edge has already revoked. A
4875
4943
  // 404 on either call means that app or that env is already gone:
4876
4944
  // nothing to clean up there, and the key is already revoked above,
4877
4945
  // so it is not a reason to fail the step. Each delete tolerates its
@@ -4887,7 +4955,7 @@ export class FoundryStepExecutor {
4887
4955
  }
4888
4956
  };
4889
4957
  const envs = await tolerateNotFound(() => this.ports.coolify.listEnvs(appId), []);
4890
- for (const envKey of ['NAMES_URL', 'NAMES_API_KEY']) {
4958
+ for (const envKey of ['EDGE_URL', 'EDGE_API_KEY']) {
4891
4959
  const env = envs.find((e) => e.key === envKey && !e.is_preview);
4892
4960
  if (env?.uuid) {
4893
4961
  const uuid = env.uuid;
@@ -4895,21 +4963,21 @@ export class FoundryStepExecutor {
4895
4963
  }
4896
4964
  }
4897
4965
  }
4898
- context.recordScopeId(entryNamesUndeliveredScopeKey(entryKey), '');
4899
- context.recordScopeId(entryNamesMintedAtScopeKey(entryKey), '');
4966
+ context.recordScopeId(entryEdgeUndeliveredScopeKey(entryKey), '');
4967
+ context.recordScopeId(entryEdgeMintedAtScopeKey(entryKey), '');
4900
4968
  if (!keyId)
4901
4969
  return {};
4902
- context.recordScopeId(entryNamesRetiredScopeKey(entryKey), keyId);
4970
+ context.recordScopeId(entryEdgeRetiredScopeKey(entryKey), keyId);
4903
4971
  return {};
4904
4972
  }
4905
- const entry = org.hosting.entries.find((e) => e.key === entryKey && e.uses?.names);
4973
+ const entry = org.hosting.entries.find((e) => e.key === entryKey && e.uses?.edge);
4906
4974
  if (!entry)
4907
- throw new Error(`${at}: the entry no longer declares uses.names`);
4975
+ throw new Error(`${at}: the entry no longer declares uses.edge`);
4908
4976
  const appId = context.recordedIds()[`coolify:app:${action.key}`];
4909
4977
  if (!appId)
4910
4978
  throw new Error(`${at}: the entry's app is not recorded yet`);
4911
4979
  if (!org.domain) {
4912
- throw new Error(`${at}: names needs org.domain, and validateEntryNames already refuses it without one`);
4980
+ throw new Error(`${at}: edge needs org.domain, and validateEntryEdge already refuses it without one`);
4913
4981
  }
4914
4982
  // A key left over from a previous mint whose delivery never succeeded
4915
4983
  // is revoked first, not left blocking every later apply forever:
@@ -4917,39 +4985,39 @@ export class FoundryStepExecutor {
4917
4985
  // (CLAUDE.md "No secret leaves"), so the only way to actually recover
4918
4986
  // is to revoke the stale id and mint a fresh one -- a 404 counts as
4919
4987
  // already revoked, same as any other retire (#317 re-review).
4920
- const staleUndelivered = context.recordedIds()[entryNamesUndeliveredScopeKey(entryKey)];
4988
+ const staleUndelivered = context.recordedIds()[entryEdgeUndeliveredScopeKey(entryKey)];
4921
4989
  if (staleUndelivered) {
4922
4990
  try {
4923
- await names.revokeKey(org.id, staleUndelivered);
4991
+ await edge.revokeKey(org.id, staleUndelivered);
4924
4992
  }
4925
4993
  catch (error) {
4926
4994
  if (!isNotFoundError(error))
4927
4995
  throw error;
4928
4996
  }
4929
- context.recordScopeId(entryNamesUndeliveredScopeKey(entryKey), '');
4997
+ context.recordScopeId(entryEdgeUndeliveredScopeKey(entryKey), '');
4930
4998
  await context.persistScopeIds();
4931
4999
  }
4932
- const minted = await names.mintKey({
5000
+ const minted = await edge.mintKey({
4933
5001
  organisationId: org.id,
4934
5002
  domain: org.domain,
4935
5003
  name: desired.name,
4936
- expiresDays: NAMES_ENTRY_KEY_DAYS,
5004
+ expiresDays: EDGE_ENTRY_KEY_DAYS,
4937
5005
  });
4938
- context.recordScopeId(entryNamesUndeliveredScopeKey(entryKey), minted.id);
5006
+ context.recordScopeId(entryEdgeUndeliveredScopeKey(entryKey), minted.id);
4939
5007
  await context.persistScopeIds();
4940
5008
  const envs = [
4941
- { key: 'NAMES_URL', value: desired.namesUrl, literal: true },
4942
- { key: 'NAMES_API_KEY', value: minted.secret, literal: true },
5009
+ { key: 'EDGE_URL', value: desired.edgeUrl, literal: true },
5010
+ { key: 'EDGE_API_KEY', value: minted.secret, literal: true },
4943
5011
  ];
4944
5012
  let lastError;
4945
- for (let attempt = 1; attempt <= ENTRY_NAMES_KEY_DELIVERY_ATTEMPTS; attempt++) {
5013
+ for (let attempt = 1; attempt <= ENTRY_EDGE_KEY_DELIVERY_ATTEMPTS; attempt++) {
4946
5014
  try {
4947
5015
  await this.ports.coolify.bulkUpsertEnvs(appId, envs);
4948
5016
  await this.ports.coolify.restart(appId);
4949
- context.recordScopeId(entryNamesUndeliveredScopeKey(entryKey), '');
5017
+ context.recordScopeId(entryEdgeUndeliveredScopeKey(entryKey), '');
4950
5018
  // The only trace of this key's age, since nothing here renews it
4951
5019
  // (#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());
5020
+ context.recordScopeId(entryEdgeMintedAtScopeKey(entryKey), new Date(context.now?.() ?? Date.now()).toISOString());
4953
5021
  return { resourceId: minted.id };
4954
5022
  }
4955
5023
  catch (e) {
@@ -4957,21 +5025,21 @@ export class FoundryStepExecutor {
4957
5025
  }
4958
5026
  }
4959
5027
  // 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`);
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`);
4961
5029
  }
4962
5030
  /**
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.
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.
4966
5034
  */
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)`);
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)`);
4971
5039
  }
4972
5040
  const labels = action.desired.labels;
4973
- await names.setReservedLabels(org.id, labels);
4974
- context.recordScopeId(NAMES_RESERVED_LABELS_APPLIED_SCOPE_KEY, JSON.stringify(labels));
5041
+ await edge.setReservedLabels(org.id, labels);
5042
+ context.recordScopeId(EDGE_RESERVED_LABELS_APPLIED_SCOPE_KEY, JSON.stringify(labels));
4975
5043
  return {};
4976
5044
  }
4977
5045
  /**
@@ -5375,7 +5443,7 @@ export class FoundryStepExecutor {
5375
5443
  return;
5376
5444
  }
5377
5445
  case 'cloudflare:worker': {
5378
- // #318: an entry's edge Worker (`<orgId>:<entry>`) and its route.
5446
+ // #318: an entry's Worker (`<orgId>:<entry>`) and its route.
5379
5447
  // No port means no way to delete either -- thrown, not silently
5380
5448
  // skipped, so it is reported blocked rather than left live with
5381
5449
  // nothing recorded ids point at any more.
@@ -5388,20 +5456,20 @@ export class FoundryStepExecutor {
5388
5456
  // never recorded, then the script -- the same teardown retire runs.
5389
5457
  // `target.id` is `''` for a route-only partial deploy.
5390
5458
  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}`);
5459
+ await this.teardownWorker(entryKey, target.id || undefined, recordedIds, workers, `cloudflare:worker '${target.key}' for ${org.slug}`);
5392
5460
  return;
5393
5461
  }
5394
- case 'names:key': {
5395
- // F7 (#317): an entry's key (`<orgId>:<entry>`); names has no org-root key.
5462
+ case 'edge:key': {
5463
+ // F7 (#317): an entry's key (`<orgId>:<entry>`); edge has no org-root key.
5396
5464
  // No port means no way to revoke it -- thrown, not silently
5397
5465
  // skipped, so the key is reported blocked rather than left live
5398
5466
  // 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`);
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`);
5402
5470
  }
5403
5471
  try {
5404
- await names.revokeKey(org.id, target.id);
5472
+ await edge.revokeKey(org.id, target.id);
5405
5473
  }
5406
5474
  catch (error) {
5407
5475
  // Already gone -- the same already-retired key a retire step
@@ -5,7 +5,7 @@ export { computePlanDetail, toPlan } from './diff.js';
5
5
  export type { StepSystem } from './step-system.js';
6
6
  export { stepSystemOf } from './step-system.js';
7
7
  export type { PlatformPlanDetail } from './platform-diff.js';
8
- export { planPlatform, toPlatformPlan, JOBS_SCHEDULER_KEY, PLATFORM_AI_KEY, PLATFORM_AI_INTERNAL_URL_SCOPE_KEY, PLATFORM_AI_MANAGEMENT_KEY_SCOPE_KEY, PLATFORM_AI_ORGANISATION_SCOPE_KEY, PLATFORM_AUTH_KEY, PLATFORM_FILES_KEY, PLATFORM_FILES_INTERNAL_URL_SCOPE_KEY, PLATFORM_FILES_ZITADEL_PROJECT_SCOPE_KEY, PLATFORM_MAIL_KEY, PLATFORM_MAIL_INTERNAL_URL_SCOPE_KEY, PLATFORM_MAIL_ZITADEL_PROJECT_SCOPE_KEY, PLATFORM_NAMES_KEY, PLATFORM_NAMES_KEY_REF, PLATFORM_NAMES_CLOUDFLARE_TOKEN_REF, } from './platform-diff.js';
8
+ export { planPlatform, toPlatformPlan, JOBS_SCHEDULER_KEY, PLATFORM_AI_KEY, PLATFORM_AI_INTERNAL_URL_SCOPE_KEY, PLATFORM_AI_MANAGEMENT_KEY_SCOPE_KEY, PLATFORM_AI_ORGANISATION_SCOPE_KEY, PLATFORM_AUTH_KEY, PLATFORM_FILES_KEY, PLATFORM_FILES_INTERNAL_URL_SCOPE_KEY, PLATFORM_FILES_ZITADEL_PROJECT_SCOPE_KEY, PLATFORM_MAIL_KEY, PLATFORM_MAIL_INTERNAL_URL_SCOPE_KEY, PLATFORM_MAIL_ZITADEL_PROJECT_SCOPE_KEY, PLATFORM_EDGE_KEY, PLATFORM_EDGE_KEY_REF, PLATFORM_EDGE_CLOUDFLARE_TOKEN_REF, } from './platform-diff.js';
9
9
  export type { HeldKeyResolver, OrgPlatformReadiness } from './platform-org.js';
10
10
  export { DEFAULT_ORG_PLATFORM_READINESS, ORG_PLATFORM_KEY_REFS, agoraHostValuesFromPlatform, heldPlatformKeyRefs, orgPlatformReadinessFrom, } from './platform-org.js';
11
11
  export type { PlatformOrgClientFactories } from './platform-org-ports.js';
@@ -15,7 +15,7 @@ export { AdoptRefusedError } from './adopt.js';
15
15
  export type { AuditActor, AuditLog, AuthzGate, StepExecutor, StepDestroyer, DestroyTarget, StepExecutionContext, PlatformStepExecutor, PlatformStepExecutionContext, FoundryPlanApplyOptions, } from './plan-apply.js';
16
16
  export { FoundryPlanApply, DeclaredOrgNotFoundError, StalePlanError, ConfirmationRequiredError, NotAuthorizedError, NotATestOrgError, DeclaredPlatformNotFoundError, PlatformStalePlanError, PlatformConfirmationRequiredError, planNeedsConfirmation, } from './plan-apply.js';
17
17
  export { FoundryPlatformStepExecutor, ProvidersPlatformCloudflareReader, ProvidersPlatformCoolifyReader, PLATFORM_MAIL_ADMIN_KEY_DESCRIPTION, filesPublicHostsValue, filesShareDomains, } from './platform-providers.js';
18
- export type { PlatformAuthDeps, PlatformAuthRuntime, PlatformFilesDeps, PlatformFilesRuntime, PlatformNamesDeps, PlatformNamesRuntime, } from './platform-providers.js';
18
+ export type { PlatformAuthDeps, PlatformAuthRuntime, PlatformFilesDeps, PlatformFilesRuntime, PlatformEdgeDeps, PlatformEdgeRuntime, } from './platform-providers.js';
19
19
  export type { PlatformMailDeps, PlatformMailAdminPort, PlatformMailRuntime, } from './platform-providers.js';
20
20
  export { FILES_INTROSPECTION_SECRET_REF, FILES_MANAGEMENT_ISSUER, FILES_MANAGEMENT_KEY_ID, FILES_MANAGEMENT_KEY_REF, PLATFORM_AUTH_INSTANCE_BEARER_REF, } from './platform-providers.js';
21
21
  export { AI_MANAGEMENT_ISSUER, AI_MANAGEMENT_KEY_ID, AI_SIGNING_KEY_REF, AiManagementKeyExistsError, createAiManagementKeyMinter, PLATFORM_AI_MANAGEMENT_KEY_REF, PLATFORM_AI_MANAGEMENT_PERMISSIONS, } from './platform-providers.js';
@@ -1,7 +1,7 @@
1
1
  export { readLiveResources, readLivePlatformResources } from './live.js';
2
2
  export { computePlanDetail, toPlan } from './diff.js';
3
3
  export { stepSystemOf } from './step-system.js';
4
- export { planPlatform, toPlatformPlan, JOBS_SCHEDULER_KEY, PLATFORM_AI_KEY, PLATFORM_AI_INTERNAL_URL_SCOPE_KEY, PLATFORM_AI_MANAGEMENT_KEY_SCOPE_KEY, PLATFORM_AI_ORGANISATION_SCOPE_KEY, PLATFORM_AUTH_KEY, PLATFORM_FILES_KEY, PLATFORM_FILES_INTERNAL_URL_SCOPE_KEY, PLATFORM_FILES_ZITADEL_PROJECT_SCOPE_KEY, PLATFORM_MAIL_KEY, PLATFORM_MAIL_INTERNAL_URL_SCOPE_KEY, PLATFORM_MAIL_ZITADEL_PROJECT_SCOPE_KEY, PLATFORM_NAMES_KEY, PLATFORM_NAMES_KEY_REF, PLATFORM_NAMES_CLOUDFLARE_TOKEN_REF, } from './platform-diff.js';
4
+ export { planPlatform, toPlatformPlan, JOBS_SCHEDULER_KEY, PLATFORM_AI_KEY, PLATFORM_AI_INTERNAL_URL_SCOPE_KEY, PLATFORM_AI_MANAGEMENT_KEY_SCOPE_KEY, PLATFORM_AI_ORGANISATION_SCOPE_KEY, PLATFORM_AUTH_KEY, PLATFORM_FILES_KEY, PLATFORM_FILES_INTERNAL_URL_SCOPE_KEY, PLATFORM_FILES_ZITADEL_PROJECT_SCOPE_KEY, PLATFORM_MAIL_KEY, PLATFORM_MAIL_INTERNAL_URL_SCOPE_KEY, PLATFORM_MAIL_ZITADEL_PROJECT_SCOPE_KEY, PLATFORM_EDGE_KEY, PLATFORM_EDGE_KEY_REF, PLATFORM_EDGE_CLOUDFLARE_TOKEN_REF, } from './platform-diff.js';
5
5
  export { DEFAULT_ORG_PLATFORM_READINESS, ORG_PLATFORM_KEY_REFS, agoraHostValuesFromPlatform, heldPlatformKeyRefs, orgPlatformReadinessFrom, } from './platform-org.js';
6
6
  export { PlatformOrgBlockedError, PlatformOrgPorts } from './platform-org-ports.js';
7
7
  export { AdoptRefusedError } from './adopt.js';
@@ -26,11 +26,11 @@ export interface LiveResource {
26
26
  * `platform-diff.ts`'s `planSfu` always plans its step when
27
27
  * `DeclaredPlatform.sfu.enabled` is true, rather than diffing against a
28
28
  * live resource the way every other system here does. It is still part of
29
- * this union so `StepAction.system` (`diff.ts`) can name it. `'names'`
30
- * (#317) is the same: a names key has no live lookup of its own, the same
29
+ * this union so `StepAction.system` (`diff.ts`) can name it. `'edge'`
30
+ * (#317) is the same: a edge key has no live lookup of its own, the same
31
31
  * shape `ai`/`files` entry keys already follow.
32
32
  */
33
- system: 'zitadel' | 'coolify' | 'cloudflare' | 'files' | 'ai' | 'cms' | 'jobs' | 'sfu' | 'names';
33
+ system: 'zitadel' | 'coolify' | 'cloudflare' | 'files' | 'ai' | 'cms' | 'jobs' | 'sfu' | 'edge';
34
34
  kind: string;
35
35
  key: string;
36
36
  config: Record<string, unknown>;
@@ -335,7 +335,7 @@ export interface FoundryPlanApplyOptions {
335
335
  broker?: boolean;
336
336
  /**
337
337
  * #318: true when the host's executor was built with a `WorkersPort`.
338
- * Optional, and false without it: each entry's `edge` Worker is then
338
+ * Optional, and false without it: each entry's `worker` Worker is then
339
339
  * reported blocked instead of planned.
340
340
  */
341
341
  edgeWorkers?: boolean;
@@ -347,11 +347,11 @@ export interface FoundryPlanApplyOptions {
347
347
  filesOrganisation?: boolean;
348
348
  aiOrganisation?: boolean;
349
349
  /**
350
- * #317: true when the host's executor was built with a `NamesPort`.
351
- * Optional, and false without it: each entry's `uses.names` step is then
350
+ * #317: true when the host's executor was built with a `EdgePort`.
351
+ * Optional, and false without it: each entry's `uses.edge` step is then
352
352
  * reported blocked instead of planned to fail.
353
353
  */
354
- names?: boolean;
354
+ edge?: boolean;
355
355
  /** Overridable for tests; defaults to `() => new Date()`. */
356
356
  now?: () => Date;
357
357
  /** #441: how long a mutation lease lasts before another caller may take it over. Defaults to 30 minutes. */
@@ -431,7 +431,7 @@ export declare class FoundryPlanApply implements PlanApply {
431
431
  private readonly edgeWorkers;
432
432
  private readonly hostFiles;
433
433
  private readonly hostAi;
434
- private readonly hostNames;
434
+ private readonly hostEdge;
435
435
  private readonly now;
436
436
  private readonly leaseTtlMs;
437
437
  private readonly appliedVersions;
@@ -580,6 +580,7 @@ export declare class FoundryPlanApply implements PlanApply {
580
580
  * which only the plan can read.
581
581
  */
582
582
  private invalidHosting;
583
+ private platformServiceHostnames;
583
584
  /**
584
585
  * #177: an org's plan reads the shared platform's own addresses and
585
586
  * readiness through the very `platform` deps `planPlatform`/`applyPlatform`