@wtfalch/foundry 0.17.1 → 0.19.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/dist/bootstrap/declare.d.ts +11 -2
  2. package/dist/bootstrap/declare.js +20 -11
  3. package/dist/index.d.ts +4 -2
  4. package/dist/index.js +2 -1
  5. package/dist/migrations/0045_names_to_edge.sql +107 -0
  6. package/dist/org.d.ts +17 -17
  7. package/dist/org.js +48 -58
  8. package/dist/plan/agora.d.ts +25 -0
  9. package/dist/plan/agora.js +25 -0
  10. package/dist/plan/boule.d.ts +8 -0
  11. package/dist/plan/boule.js +8 -0
  12. package/dist/plan/diff.d.ts +3 -1
  13. package/dist/plan/diff.js +115 -52
  14. package/dist/plan/entry-edge-key.d.ts +79 -0
  15. package/dist/plan/{entry-names-key.js → entry-edge-key.js} +90 -66
  16. package/dist/plan/executor.d.ts +87 -42
  17. package/dist/plan/executor.js +277 -120
  18. package/dist/plan/index.d.ts +2 -2
  19. package/dist/plan/index.js +1 -1
  20. package/dist/plan/live.d.ts +3 -3
  21. package/dist/plan/login-host.d.ts +15 -11
  22. package/dist/plan/login-host.js +16 -16
  23. package/dist/plan/plan-apply.d.ts +6 -5
  24. package/dist/plan/plan-apply.js +40 -38
  25. package/dist/plan/platform-diff.d.ts +22 -22
  26. package/dist/plan/platform-diff.js +45 -45
  27. package/dist/plan/platform-org-ports.d.ts +1 -0
  28. package/dist/plan/platform-org-ports.js +3 -0
  29. package/dist/plan/platform-org.d.ts +8 -8
  30. package/dist/plan/platform-org.js +7 -7
  31. package/dist/plan/platform-providers.d.ts +17 -16
  32. package/dist/plan/platform-providers.js +136 -118
  33. package/dist/plan/providers-readers.d.ts +34 -11
  34. package/dist/plan/providers-readers.js +72 -15
  35. package/dist/plan/step-system.js +1 -1
  36. package/dist/plan/{edge-worker.d.ts → worker.d.ts} +53 -33
  37. package/dist/plan/{edge-worker.js → worker.js} +92 -54
  38. package/dist/platform.d.ts +3 -3
  39. package/dist/platform.js +1 -1
  40. package/dist/store/postgres.js +7 -7
  41. package/dist/store/schema.d.ts +6 -6
  42. package/dist/store/schema.js +4 -4
  43. package/package.json +1 -1
  44. package/dist/plan/entry-names-key.d.ts +0 -73
@@ -2,24 +2,24 @@ import { randomBytes } from 'node:crypto';
2
2
  import { URL } from 'node:url';
3
3
  import { CALLS_SFU_PRIVATE_URL, CALLS_SFU_PUBLIC_URL } from '../calls-sfu.js';
4
4
  import { APP_ENTRY_KEY, appEntry, filesCollectionEnvName, ownerMailboxRecorded, ownerMailboxes, sharedSecretRef, } from '../org.js';
5
- import { AGORA_ACTIVATED_ENV_SCOPE_KEY, AGORA_CLIENT_ID_SCOPE_KEY, APP_CLIENT_ID_SCOPE_KEY, CALLS_INTROSPECTION_SCOPE_KEY, CALLS_INTROSPECTION_UNDELIVERED_SCOPE_KEY, CALLS_SERVICE_NAME_SCOPE_KEY, CALLS_SERVICE_PORT, CALLS_SERVICE_TOKEN_ENV_SCOPE_KEY, CHAT_SERVICE_URL_SCOPE_KEY, CMS_CLIENT_ID_SCOPE_KEY, ENTRY_CLIENT_ID_SCOPE_PREFIX, ZITADEL_MESSAGING_PROJECT_SCOPE_KEY, ZITADEL_ORG_SCOPE_KEY, agoraEnvDrift, agoraEnvRevision, agoraHostnameFor, callsIntrospectionAppName, callsServiceHasName, callsServiceInternalName, callsTokenEnvMarker, callsTokenEnvMovedMessage, desiredAgoraEnv, entryClientIdScopeKey, } from './agora.js';
6
- import { BOULE_BROKER_ENTRY_KEY, BOULE_CLIENT_ID_SCOPE_KEY, BOULE_DATABASE_SCOPE_KEY, bouleEnvDrift, bouleHostnameFor, desiredBouleEnv, wantedBouleBrokerEntry, } from './boule.js';
5
+ import { AGORA_ACTIVATED_ENV_SCOPE_KEY, AGORA_APP_PORTS_EXPOSES, AGORA_CLIENT_ID_SCOPE_KEY, AGORA_DEPLOYED_SCOPE_KEY, APP_CLIENT_ID_SCOPE_KEY, CALLS_INTROSPECTION_SCOPE_KEY, CALLS_INTROSPECTION_UNDELIVERED_SCOPE_KEY, CALLS_SERVICE_NAME_SCOPE_KEY, CALLS_SERVICE_PORT, CALLS_SERVICE_TOKEN_ENV_SCOPE_KEY, CHAT_SERVICE_URL_SCOPE_KEY, CMS_CLIENT_ID_SCOPE_KEY, ENTRY_CLIENT_ID_SCOPE_PREFIX, ZITADEL_MESSAGING_PROJECT_SCOPE_KEY, ZITADEL_ORG_SCOPE_KEY, agoraEnvDrift, agoraEnvRevision, agoraHostnameFor, callsIntrospectionAppName, callsServiceHasName, callsServiceInternalName, callsTokenEnvMarker, callsTokenEnvMovedMessage, desiredAgoraEnv, entryClientIdScopeKey, } from './agora.js';
6
+ import { BOULE_BROKER_ENTRY_KEY, BOULE_CLIENT_ID_SCOPE_KEY, BOULE_DATABASE_SCOPE_KEY, BOULE_DEPLOYED_SCOPE_KEY, bouleEnvDrift, bouleHostnameFor, desiredBouleEnv, wantedBouleBrokerEntry, } from './boule.js';
7
7
  import { brokerEntryAppliedScopeKey, brokerEntryWarningsScopeKey, entryAuthEnv, entryAuthEnvAppliedScopeKey, wantedBrokerEntry, writeBrokerEntry, } from './broker-entries.js';
8
8
  import { MAS_IMAGE, MAS_TAG, SYNAPSE_IMAGE, SYNAPSE_TAG, callsMasConfigYaml, callsServerName, callsSynapseHomeserverYaml, mintCallsMatrixSecrets, parseCallsDbUrl, } from './calls-matrix.js';
9
9
  import { CHAT_REPO, CHAT_SERVICE_DOCKERFILE, CHAT_SERVICE_PORT, CHAT_SYNAPSE_DOCKERFILE, CHAT_SYNAPSE_PORT, chatInternalNames, chatMasConfigYaml, chatServiceEnvs, chatSynapseHomeserverYaml, chatUpstreams, } from './chat-matrix.js';
10
10
  import { ensureCloudflaredConnector } from './cloudflared-connector.js';
11
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
- import { LOGIN_BINDING_SCOPE_KEY, LOGIN_HOST_PENDING_REMOVAL_SCOPE_KEY, loginBindingState, loginHostClientIds, pendingRemovalHosts, pendingRemovalState, } from './login-host.js';
17
+ import { LOGIN_BINDING_SCOPE_KEY, LOGIN_HOST_PENDING_REMOVAL_SCOPE_KEY, LOGIN_HOST_SHARED_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
@@ -240,10 +240,14 @@ const CMS_SITE_APP_DOCKERFILE = '/apps/site/Dockerfile';
240
240
  * followed rather than reopened (#164 "Decide" 1). The agora repo's own
241
241
  * Dockerfile (`apps/agora/Dockerfile`) already `EXPOSE`s 8080 and serves
242
242
  * from a build built at the repository root, the same one-repo shape
243
- * `CMS_REPO`'s own two apps use -- no `ports_exposes`/`PORT` env is needed
244
- * the way `CALLS_SERVICE_PORT` is for the calls service's own Node process,
245
- * since Coolify's github-app build already reads the exposed port the same
246
- * way it does for `cmsSite`'s two apps above.
243
+ * `CMS_REPO`'s own two apps use. This comment used to claim that needed no
244
+ * `ports_exposes` of its own, on the theory that Coolify's github-app build
245
+ * reads a Dockerfile's `EXPOSE` the same way it does for `cmsSite`'s two
246
+ * apps above -- wrong (#506): Coolify never reads `EXPOSE` for a build it
247
+ * runs itself, so without an explicit `agora.ts`'s `AGORA_APP_PORTS_EXPOSES`,
248
+ * Traefik is left routing to a port nothing listens on, and a company's
249
+ * Agora answers 502 behind the tunnel. Seen live on Company Included,
250
+ * 2026-10-05.
247
251
  */
248
252
  const AGORA_REPO = 'wtfalch/agora';
249
253
  const AGORA_APP_DOCKERFILE = '/apps/agora/Dockerfile';
@@ -508,11 +512,21 @@ const CALLS_MAS_DB_SCOPE_KEY = 'coolify_calls_mas_database';
508
512
  * Synapse refuses to start against a database whose `LC_COLLATE`/`LC_CTYPE`
509
513
  * is not `C` (https://element-hq.github.io/synapse/latest/postgres.html);
510
514
  * 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.
515
+ * `createPostgresDatabase` forwards this to Coolify's own
516
+ * `postgres_initdb_args` field -- but Coolify stores that field and reads it
517
+ * back unchanged without ever handing it to `initdb`: its Postgres start
518
+ * action builds the container's env from `postgres_user`/`postgres_password`/
519
+ * `postgres_db` and runtime envs only
520
+ * (https://github.com/coollabsio/coolify/blob/main/app/Actions/Database/StartPostgresql.php).
521
+ * A database created this way still comes up `en_US.utf8` and crash-loops
522
+ * every Synapse app that reads it; live evidence and the real fix (not yet
523
+ * chosen) are tracked in #494. This constant and the field it feeds stay in
524
+ * place, inert, until that lands -- collation cannot be migrated onto an
525
+ * existing database under any of the candidate fixes either. MAS has no
526
+ * such requirement, so this is passed for the Synapse database only. Not
527
+ * using Synapse's own `allow_unsafe_locale` escape hatch regardless of
528
+ * which fix lands: that trades a loud startup refusal for silent index
529
+ * corruption after a future glibc upgrade.
516
530
  */
517
531
  const CALLS_SYNAPSE_DB_INITDB_ARGS = '--locale=C --encoding=UTF8';
518
532
  /**
@@ -655,7 +669,7 @@ export class FoundryStepExecutor {
655
669
  case 'cloudflare:tunnel':
656
670
  return this.cloudflareTunnel(action, org, context);
657
671
  case 'cloudflare:zone':
658
- return this.cloudflareZone(action, org);
672
+ return this.cloudflareZone(action, org, context);
659
673
  case 'cloudflare:access':
660
674
  return this.cloudflareAccess(action, org);
661
675
  case 'cloudflare:ratelimit':
@@ -678,10 +692,10 @@ export class FoundryStepExecutor {
678
692
  return action.key.includes(':')
679
693
  ? this.aiEntryKey(action, org, context)
680
694
  : 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);
695
+ case 'edge:key':
696
+ return this.edgeEntryKey(action, org, context);
697
+ case 'edge:reserved-labels':
698
+ return this.edgeReservedLabels(action, org, context);
685
699
  case 'coolify:mail':
686
700
  return this.coolifyMail(action, org, context);
687
701
  case 'coolify:mail-copy':
@@ -850,10 +864,10 @@ export class FoundryStepExecutor {
850
864
  return { resourceId: created.id };
851
865
  }
852
866
  /**
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
867
+ * `plan/diff.ts`'s `planWorkers` (#318, F8): deploys one entry's
868
+ * `worker` Worker and routes it on the org's own zone. Throws by name
855
869
  * without a {@link WorkersPort}, the same `jobsRegistration` shape --
856
- * `planEdgeWorkers` reports the step blocked instead while the host has
870
+ * `planWorkers` reports the step blocked instead while the host has
857
871
  * none, so this only fires against a stale plan. The route is checked
858
872
  * before upload: a foreign route already on the pattern is refused, never
859
873
  * adopted or replaced (`cloudflareAccess`'s own precedent for a foreign
@@ -879,24 +893,25 @@ export class FoundryStepExecutor {
879
893
  if (desired.mode === 'retire') {
880
894
  const recorded = context.recordedIds();
881
895
  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), '');
896
+ const scriptName = mainId || recorded[entryWorkerUploadedScopeKey(entryKey)];
897
+ await this.teardownWorker(entryKey, scriptName, recorded, workers, at);
898
+ context.recordScopeId(entryWorkerRouteIdScopeKey(entryKey), '');
899
+ context.recordScopeId(entryWorkerRouteZoneScopeKey(entryKey), '');
900
+ context.recordScopeId(entryWorkerRoutePatternScopeKey(entryKey), '');
901
+ context.recordScopeId(entryWorkerRoutePendingScopeKey(entryKey), '');
902
+ context.recordScopeId(entryWorkerExclusionsScopeKey(entryKey), '');
903
+ context.recordScopeId(entryWorkerAppliedScopeKey(entryKey), '');
904
+ context.recordScopeId(entryWorkerUploadedScopeKey(entryKey), '');
890
905
  // `runSteps` only ever writes the main recorded id from a truthy
891
906
  // `resourceId` (`plan-apply.ts`); this step returns none, so without
892
907
  // this marker the planner's "gone" scan would retire the same,
893
908
  // never-cleared id forever.
894
909
  if (mainId)
895
- context.recordScopeId(entryEdgeRetiredScopeKey(entryKey), mainId);
910
+ context.recordScopeId(entryWorkerRetiredScopeKey(entryKey), mainId);
896
911
  return {};
897
912
  }
898
913
  if (!org.domain) {
899
- throw new Error(`${at}: edge needs org.domain, and validateHosting already refuses it without one`);
914
+ throw new Error(`${at}: worker needs org.domain, and validateHosting already refuses it without one`);
900
915
  }
901
916
  const zone = await this.ports.cloudflare.findZoneByApex(org.domain);
902
917
  if (!zone) {
@@ -919,8 +934,8 @@ export class FoundryStepExecutor {
919
934
  // the same deterministic name) is foundry's own script to overwrite,
920
935
  // never a stranger's.
921
936
  const recordedBeforeUpload = context.recordedIds();
922
- const everUploadedByFoundry = Boolean(recordedBeforeUpload[entryEdgeAppliedScopeKey(entryKey)] ||
923
- recordedBeforeUpload[entryEdgeUploadedScopeKey(entryKey)] ||
937
+ const everUploadedByFoundry = Boolean(recordedBeforeUpload[entryWorkerAppliedScopeKey(entryKey)] ||
938
+ recordedBeforeUpload[entryWorkerUploadedScopeKey(entryKey)] ||
924
939
  recordedBeforeUpload[`cloudflare:worker:${action.key}`]);
925
940
  if (!everUploadedByFoundry && (await workers.scriptExists(name))) {
926
941
  throw new Error(`${at}: a Worker script named '${name}' already exists and foundry never created it; refuses to overwrite it`);
@@ -931,8 +946,8 @@ export class FoundryStepExecutor {
931
946
  // the `scriptExists` guard above. The retired marker clears here too,
932
947
  // not only on success: once a redeploy has touched the script, a later
933
948
  // undeclare must retire it even if this apply never finishes.
934
- context.recordScopeId(entryEdgeUploadedScopeKey(entryKey), name);
935
- context.recordScopeId(entryEdgeRetiredScopeKey(entryKey), '');
949
+ context.recordScopeId(entryWorkerUploadedScopeKey(entryKey), name);
950
+ context.recordScopeId(entryWorkerRetiredScopeKey(entryKey), '');
936
951
  await context.persistScopeIds();
937
952
  await workers.uploadScript({
938
953
  name,
@@ -941,9 +956,12 @@ export class FoundryStepExecutor {
941
956
  compatibilityDate: action.desired.compatibilityDate,
942
957
  bindings: bindings.length > 0 ? bindings : undefined,
943
958
  });
959
+ // Before the wildcard route exists, so a hostname foundry serves is never
960
+ // caught by the Worker, not even briefly.
961
+ await this.syncWorkerExclusions(entryKey, zone.id, org.domain, desired.exclusions ?? [], workers, context, at);
944
962
  const recorded = context.recordedIds();
945
- const previousRouteId = recorded[entryEdgeRouteIdScopeKey(entryKey)];
946
- const previousPending = recorded[entryEdgeRoutePendingScopeKey(entryKey)];
963
+ const previousRouteId = recorded[entryWorkerRouteIdScopeKey(entryKey)];
964
+ const previousPending = recorded[entryWorkerRoutePendingScopeKey(entryKey)];
947
965
  const zoneRecord = { id: zone.id, apex: org.domain };
948
966
  let routeId = existingRoute?.id;
949
967
  if (!routeId) {
@@ -951,7 +969,7 @@ export class FoundryStepExecutor {
951
969
  // never got to record is still found (`findRoute`) and deleted on
952
970
  // retire or destroy (#318 re-review).
953
971
  const pending = { ...zoneRecord, pattern: route, script: name };
954
- context.recordScopeId(entryEdgeRoutePendingScopeKey(entryKey), JSON.stringify(pending));
972
+ context.recordScopeId(entryWorkerRoutePendingScopeKey(entryKey), JSON.stringify(pending));
955
973
  await context.persistScopeIds();
956
974
  const created = await workers.createRoute(zone.id, route, name);
957
975
  routeId = created.id;
@@ -961,30 +979,75 @@ export class FoundryStepExecutor {
961
979
  // own zone rather than left live beside the new one. A pending route
962
980
  // from an earlier, interrupted apply under a different pattern likewise.
963
981
  if (previousRouteId && previousRouteId !== routeId) {
964
- const previousZone = await this.liveEdgeZone(recorded[entryEdgeRouteZoneScopeKey(entryKey)], at);
982
+ const previousZone = await this.liveWorkerZone(recorded[entryWorkerRouteZoneScopeKey(entryKey)], at);
965
983
  if (previousZone)
966
984
  await workers.deleteRoute(previousZone, previousRouteId); // Idempotent.
967
985
  }
968
986
  if (previousPending) {
969
- await this.deletePendingEdgeRoute(previousPending, routeId, workers, at);
987
+ await this.deletePendingWorkerRoute(previousPending, routeId, workers, at);
970
988
  }
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({
989
+ context.recordScopeId(entryWorkerRouteIdScopeKey(entryKey), routeId);
990
+ context.recordScopeId(entryWorkerRouteZoneScopeKey(entryKey), JSON.stringify(zoneRecord));
991
+ context.recordScopeId(entryWorkerRoutePatternScopeKey(entryKey), route);
992
+ context.recordScopeId(entryWorkerRoutePendingScopeKey(entryKey), '');
993
+ context.recordScopeId(entryWorkerAppliedScopeKey(entryKey), JSON.stringify({
976
994
  name,
977
995
  artifactUrl: desired.artifactUrl,
978
996
  artifactSha256: desired.artifactSha256,
979
997
  route,
980
998
  bindings: desired.bindings,
999
+ exclusions: desired.exclusions,
981
1000
  }));
982
- context.recordScopeId(entryEdgeUploadedScopeKey(entryKey), '');
1001
+ context.recordScopeId(entryWorkerUploadedScopeKey(entryKey), '');
983
1002
  // The retired marker was already cleared before the upload above: a
984
1003
  // redeclare reuses this entry's deterministic script name, so a marker
985
1004
  // left equal to the (identical) main id would read as already retired.
986
1005
  return { resourceId: name };
987
1006
  }
1007
+ /**
1008
+ * Makes the no-Worker routes recorded for an entry match `patterns`: deletes
1009
+ * the ones no longer wanted (all of them if the zone changed), creates the
1010
+ * missing ones without a script. A route already on a pattern that is bound
1011
+ * to a Worker is foreign and refused; one with no script is adopted. Records
1012
+ * and persists after each change.
1013
+ */
1014
+ async syncWorkerExclusions(entryKey, zoneId, apex, patterns, workers, context, at) {
1015
+ const key = entryWorkerExclusionsScopeKey(entryKey);
1016
+ const raw = context.recordedIds()[key];
1017
+ let state = raw
1018
+ ? JSON.parse(raw)
1019
+ : { id: zoneId, apex, routes: {} };
1020
+ const save = async () => {
1021
+ context.recordScopeId(key, Object.keys(state.routes).length > 0 ? JSON.stringify(state) : '');
1022
+ await context.persistScopeIds();
1023
+ };
1024
+ if (state.id !== zoneId) {
1025
+ const oldZone = await this.liveWorkerZone(JSON.stringify({ id: state.id, apex: state.apex }), at);
1026
+ if (oldZone) {
1027
+ for (const id of Object.values(state.routes))
1028
+ await workers.deleteRoute(oldZone, id);
1029
+ }
1030
+ state = { id: zoneId, apex, routes: {} };
1031
+ await save();
1032
+ }
1033
+ for (const [pattern, id] of Object.entries(state.routes)) {
1034
+ if (patterns.includes(pattern))
1035
+ continue;
1036
+ await workers.deleteRoute(zoneId, id); // Idempotent.
1037
+ delete state.routes[pattern];
1038
+ await save();
1039
+ }
1040
+ for (const pattern of patterns) {
1041
+ if (state.routes[pattern])
1042
+ continue;
1043
+ const found = await workers.findRoute(zoneId, pattern);
1044
+ if (found?.script) {
1045
+ throw new Error(`${at}: route '${pattern}' is already bound to Worker '${found.script}'; foundry does not replace it`);
1046
+ }
1047
+ state.routes[pattern] = found ? found.id : (await workers.createRoute(zoneId, pattern)).id;
1048
+ await save();
1049
+ }
1050
+ }
988
1051
  /**
989
1052
  * The zone id a recorded route lives in, or `undefined` only when that
990
1053
  * zone is *positively* gone -- its apex resolves to no zone, or to a
@@ -998,7 +1061,7 @@ export class FoundryStepExecutor {
998
1061
  * live on the old one, the exact bug a recorded-per-route zone exists to
999
1062
  * avoid (#318 re-review).
1000
1063
  */
1001
- async liveEdgeZone(recordedZone, at) {
1064
+ async liveWorkerZone(recordedZone, at) {
1002
1065
  if (!recordedZone) {
1003
1066
  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
1067
  }
@@ -1007,9 +1070,9 @@ export class FoundryStepExecutor {
1007
1070
  return zone && zone.id === id ? id : undefined;
1008
1071
  }
1009
1072
  /** 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) {
1073
+ async deletePendingWorkerRoute(pendingRaw, keepRouteId, workers, at) {
1011
1074
  const pending = JSON.parse(pendingRaw);
1012
- const zoneId = await this.liveEdgeZone(JSON.stringify({ id: pending.id, apex: pending.apex }), at);
1075
+ const zoneId = await this.liveWorkerZone(JSON.stringify({ id: pending.id, apex: pending.apex }), at);
1013
1076
  if (!zoneId)
1014
1077
  return;
1015
1078
  const found = await workers.findRoute(zoneId, pending.pattern);
@@ -1022,16 +1085,25 @@ export class FoundryStepExecutor {
1022
1085
  * zone, then any route an interrupted apply created but never recorded,
1023
1086
  * then the script. Records nothing -- the caller does.
1024
1087
  */
1025
- async teardownEdgeWorker(entryKey, scriptName, recorded, workers, at) {
1026
- const routeId = recorded[entryEdgeRouteIdScopeKey(entryKey)];
1088
+ async teardownWorker(entryKey, scriptName, recorded, workers, at) {
1089
+ const routeId = recorded[entryWorkerRouteIdScopeKey(entryKey)];
1027
1090
  if (routeId) {
1028
- const zoneId = await this.liveEdgeZone(recorded[entryEdgeRouteZoneScopeKey(entryKey)], at);
1091
+ const zoneId = await this.liveWorkerZone(recorded[entryWorkerRouteZoneScopeKey(entryKey)], at);
1029
1092
  if (zoneId)
1030
1093
  await workers.deleteRoute(zoneId, routeId); // Idempotent: a no-op if already gone.
1031
1094
  }
1032
- const pending = recorded[entryEdgeRoutePendingScopeKey(entryKey)];
1095
+ const pending = recorded[entryWorkerRoutePendingScopeKey(entryKey)];
1033
1096
  if (pending)
1034
- await this.deletePendingEdgeRoute(pending, undefined, workers, at);
1097
+ await this.deletePendingWorkerRoute(pending, undefined, workers, at);
1098
+ const exclusions = recorded[entryWorkerExclusionsScopeKey(entryKey)];
1099
+ if (exclusions) {
1100
+ const state = JSON.parse(exclusions);
1101
+ const zoneId = await this.liveWorkerZone(JSON.stringify({ id: state.id, apex: state.apex }), at);
1102
+ if (zoneId) {
1103
+ for (const id of Object.values(state.routes))
1104
+ await workers.deleteRoute(zoneId, id);
1105
+ }
1106
+ }
1035
1107
  if (scriptName)
1036
1108
  await workers.deleteScript(scriptName); // Idempotent: a no-op if already gone.
1037
1109
  }
@@ -1164,11 +1236,16 @@ export class FoundryStepExecutor {
1164
1236
  throw new Error(`zitadel:login-host for ${org.slug}: the loginHostBindings port is not wired`);
1165
1237
  }
1166
1238
  const host = action.desired.host;
1167
- await this.ports.zitadel.ensureTrustedDomain(host);
1168
- // zitadel-login's own routing table, written before any app is repointed
1169
- // so no app ever points at a host with no binding.
1239
+ // The platform's own shared sign-in host: already trusted and routed by
1240
+ // the shared instance, so it gets no trusted domain or binding here.
1241
+ const shared = action.desired.shared === true;
1170
1242
  const clientIds = loginHostClientIds(recorded);
1171
- await bindings.upsertBinding({ host, organizationId: zitadelOrgId, clientIds });
1243
+ if (!shared) {
1244
+ await this.ports.zitadel.ensureTrustedDomain(host);
1245
+ // zitadel-login's own routing table, written before any app is repointed
1246
+ // so no app ever points at a host with no binding.
1247
+ await bindings.upsertBinding({ host, organizationId: zitadelOrgId, clientIds });
1248
+ }
1172
1249
  // Only apps whose client id the binding now holds: any other app in the
1173
1250
  // project would lose sign-in. The rest move on a later run, once the
1174
1251
  // recorded client ids differ from the binding and replan this step.
@@ -1269,7 +1346,10 @@ export class FoundryStepExecutor {
1269
1346
  // the binding and replans the step next run; a nonempty pending list
1270
1347
  // does too (`planLoginHost`), independent of the host comparison that
1271
1348
  // normally alone decides it.
1272
- context.recordScopeId(LOGIN_BINDING_SCOPE_KEY, loginBindingState(host, clientIds));
1349
+ if (shared)
1350
+ context.recordScopeId(LOGIN_HOST_SHARED_SCOPE_KEY, '1');
1351
+ else
1352
+ context.recordScopeId(LOGIN_BINDING_SCOPE_KEY, loginBindingState(host, clientIds));
1273
1353
  context.recordScopeId(LOGIN_HOST_PENDING_REMOVAL_SCOPE_KEY, pendingRemovalState(stillPending));
1274
1354
  return { resourceId: host };
1275
1355
  }
@@ -3535,11 +3615,25 @@ export class FoundryStepExecutor {
3535
3615
  * actually differs, or whenever the last restart this step confirmed
3536
3616
  * ({@link AGORA_ACTIVATED_ENV_SCOPE_KEY}) is not this exact env (an org
3537
3617
  * applied before this existed, whose live env already matched desired
3538
- * with nothing ever confirmed running it). A brand new app has nothing
3539
- * running yet to restart. The activation marker is recorded last, after
3540
- * the restart (if any) returns without throwing, so a failed restart
3541
- * leaves it stale and the next plan replans this step even though
3542
- * Coolify's own env store already matches desired.
3618
+ * with nothing ever confirmed running it). The activation marker is
3619
+ * recorded last, after the restart (if any) returns without throwing, so a
3620
+ * failed restart leaves it stale and the next plan replans this step even
3621
+ * though Coolify's own env store already matches desired.
3622
+ *
3623
+ * #504: a brand new app is never restarted -- but it is deployed, once
3624
+ * ({@link AGORA_DEPLOYED_SCOPE_KEY}). `createGithubAppApplication` never
3625
+ * sets `instant_deploy`, and Coolify defaults it to false, so without an
3626
+ * explicit deploy the app sat created but never built or started.
3627
+ *
3628
+ * #506: `ports_exposes: AGORA_APP_PORTS_EXPOSES` goes on the create call
3629
+ * itself, so Traefik has a port to route to from the moment the app
3630
+ * exists. An app `findOrCreateCallsApp` instead *found* may predate this
3631
+ * fix and carry no `ports_exposes` at all (or the wrong one), so its live
3632
+ * value is read and, when it differs, patched -- and, since the container
3633
+ * listens on `ports_exposes` only at its next deploy (the same rule
3634
+ * `coolifyApp`'s own `settingsNeedDeploy` follows for a port change
3635
+ * there), redeployed even on a run that already has its one #504 deploy
3636
+ * behind it.
3543
3637
  */
3544
3638
  async coolifyAgora(action, org, context) {
3545
3639
  // The host values the plan read, carried in the step: never read again
@@ -3569,9 +3663,22 @@ export class FoundryStepExecutor {
3569
3663
  name,
3570
3664
  domains: coolifyDomains([agoraHostnameFor(org)]),
3571
3665
  dockerfile_location: AGORA_APP_DOCKERFILE,
3666
+ ports_exposes: AGORA_APP_PORTS_EXPOSES,
3572
3667
  }));
3573
3668
  // Every app foundry deploys keeps its own logs (#75), reused as-is here.
3574
3669
  await this.ensureLogVolume(app.appId, org, app.existed);
3670
+ // #506: an app found rather than just created may predate passing
3671
+ // `ports_exposes` at create -- Traefik then routes to a port nothing
3672
+ // listens on and Agora answers 502 behind the tunnel. A freshly created
3673
+ // app already got the right port above, so only a found one is checked.
3674
+ const portDiffers = app.existed &&
3675
+ (await this.ports.coolify.getApplication(app.appId)).ports_exposes !==
3676
+ AGORA_APP_PORTS_EXPOSES;
3677
+ if (portDiffers) {
3678
+ await this.ports.coolify.updateApplication(app.appId, {
3679
+ ports_exposes: AGORA_APP_PORTS_EXPOSES,
3680
+ });
3681
+ }
3575
3682
  // #442: read before overwriting -- `bulkUpsertEnvs` below always leaves
3576
3683
  // Coolify's own env store matching `env`, which is why that store alone
3577
3684
  // can no longer tell a changed value from one already live.
@@ -3582,7 +3689,25 @@ export class FoundryStepExecutor {
3582
3689
  await this.ports.coolify.bulkUpsertEnvs(app.appId, Object.entries(env).map(([key, value]) => ({ key, value })));
3583
3690
  const revision = agoraEnvRevision(env);
3584
3691
  const alreadyActivated = context.recordedIds()[AGORA_ACTIVATED_ENV_SCOPE_KEY] === revision;
3585
- if (app.existed && (envChanged || !alreadyActivated)) {
3692
+ // #504: `createGithubAppApplication` never sets `instant_deploy`, and
3693
+ // Coolify defaults it to false -- a brand new app, and one found by name
3694
+ // that a previous apply created but crashed before deploying, both sat
3695
+ // built but never started. `restart` cannot fix either: it starts a
3696
+ // container that was never built. Deployed once this executor has
3697
+ // confirmed (the marker never changes value once set), never again -- a
3698
+ // live app is only ever restarted from here on, unless #506's port fix
3699
+ // below needs its own deploy.
3700
+ if (!context.recordedIds()[AGORA_DEPLOYED_SCOPE_KEY]) {
3701
+ await this.ports.coolify.deploy(app.appId);
3702
+ context.recordScopeId(AGORA_DEPLOYED_SCOPE_KEY, '1');
3703
+ }
3704
+ else if (portDiffers) {
3705
+ // #506: a port fix needs its own full deploy -- env drift alone only
3706
+ // needs the cheaper restart, and this deploy already carries this
3707
+ // run's env (pushed above), so the two never both run.
3708
+ await this.ports.coolify.deploy(app.appId);
3709
+ }
3710
+ else if (app.existed && (envChanged || !alreadyActivated)) {
3586
3711
  await this.ports.coolify.restart(app.appId);
3587
3712
  }
3588
3713
  context.recordScopeId(AGORA_ACTIVATED_ENV_SCOPE_KEY, revision);
@@ -3611,6 +3736,10 @@ export class FoundryStepExecutor {
3611
3736
  *
3612
3737
  * `AUTH_APP_KEY` is minted once here; its broker entry is the next step
3613
3738
  * (`bouleBrokerEntry`).
3739
+ *
3740
+ * #504: a brand new app is never restarted -- but it is deployed, once
3741
+ * ({@link BOULE_DEPLOYED_SCOPE_KEY}), the same reason and the same marker
3742
+ * shape {@link coolifyAgora}'s own doc comment explains.
3614
3743
  */
3615
3744
  async coolifyBoule(action, org, context) {
3616
3745
  // The issuer and Archon URL the plan read, carried in the step: the env
@@ -3662,7 +3791,17 @@ export class FoundryStepExecutor {
3662
3791
  if (!dbPush.pushed) {
3663
3792
  throw new Error(`Boule for ${org.slug}: its database has no internal_db_url yet -- retry this apply once Coolify reports one`);
3664
3793
  }
3665
- if (app.existed && (envChanged || cookieSecret !== null || appKey !== null || dbPush.changed)) {
3794
+ // #504: see `coolifyAgora`'s own comment just above -- `createGithubAppApplication`
3795
+ // never sets `instant_deploy`, so a brand new app (or one found by name
3796
+ // that a previous apply created but crashed before deploying) sat built
3797
+ // but never started. Deployed once this executor has confirmed, never
3798
+ // again -- a live app is only ever restarted from here on.
3799
+ if (!context.recordedIds()[BOULE_DEPLOYED_SCOPE_KEY]) {
3800
+ await this.ports.coolify.deploy(app.appId);
3801
+ context.recordScopeId(BOULE_DEPLOYED_SCOPE_KEY, '1');
3802
+ }
3803
+ else if (app.existed &&
3804
+ (envChanged || cookieSecret !== null || appKey !== null || dbPush.changed)) {
3666
3805
  await this.ports.coolify.restart(app.appId);
3667
3806
  }
3668
3807
  return { resourceId: app.appId };
@@ -4151,8 +4290,17 @@ export class FoundryStepExecutor {
4151
4290
  * shared-hostname collision this ticket exists to refuse. An org re-running
4152
4291
  * its own already-recorded zone is unaffected: the store then names this
4153
4292
  * same org as the owner.
4293
+ *
4294
+ * #510: also records the zone's own id under the flat `cloudflare_zone`
4295
+ * scope key, alongside the `cloudflare:zone:<domain>` resource id `runSteps`
4296
+ * already records from this method's return value. Without it, nothing
4297
+ * ever gives `ProvidersCloudflareReader` a zone id to resolve this org's
4298
+ * scope with (`providers-readers.ts`'s `storeScopeResolver` doc comment),
4299
+ * so that reader's `listResources` always returned `[]` and `diff.ts`'s
4300
+ * `planZone` re-proposed "provision a Cloudflare zone" on every apply, even
4301
+ * once the zone was live.
4154
4302
  */
4155
- async cloudflareZone(action, org) {
4303
+ async cloudflareZone(action, org, context) {
4156
4304
  const domain = action.key;
4157
4305
  const cf = this.ports.cloudflare;
4158
4306
  const existing = await cf.findZoneByApex(domain);
@@ -4161,9 +4309,11 @@ export class FoundryStepExecutor {
4161
4309
  if (owner !== null && owner !== org.id) {
4162
4310
  throw new Error(`cloudflare:zone for ${org.slug}: zone ${existing.id} (${domain}) is already recorded under org ${owner}; foundry does not adopt it`);
4163
4311
  }
4312
+ context.recordScopeId('cloudflare_zone', existing.id);
4164
4313
  return { resourceId: existing.id };
4165
4314
  }
4166
4315
  const created = await cf.createZone(domain);
4316
+ context.recordScopeId('cloudflare_zone', created.id);
4167
4317
  return { resourceId: created.id };
4168
4318
  }
4169
4319
  // ---- mail -----------------------------------------------------------
@@ -4816,24 +4966,24 @@ export class FoundryStepExecutor {
4816
4966
  context.recordScopeId(listKey, JSON.stringify(ids));
4817
4967
  }
4818
4968
  /**
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
4969
+ * F7 (#317): mints one entry's edge key directly below the org (edge
4970
+ * takes no separate minter key id, unlike ai/files), writes `EDGE_URL`
4971
+ * and `EDGE_API_KEY` to the entry's app env, then restarts it. Retires
4972
+ * the key when `uses.edge` is removed. Follows the "one mint" rule
4823
4973
  * (CLAUDE.md "Platform steps"): the id is recorded as undelivered and
4824
4974
  * persisted before delivery is attempted, so a crash or a failed write
4825
- * never leads to a second mint -- `planEntryNamesKeys` blocks instead.
4975
+ * never leads to a second mint -- `planEntryEdgeKeys` blocks instead.
4826
4976
  */
4827
- async namesEntryKey(action, org, context) {
4977
+ async edgeEntryKey(action, org, context) {
4828
4978
  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)`);
4979
+ const at = `edge:key for ${org.slug} entry '${entryKey}'`;
4980
+ const edge = this.ports.edge;
4981
+ if (!edge)
4982
+ throw new Error(`${at}: this executor was built without an EdgePort (#317)`);
4833
4983
  const desired = action.desired;
4834
4984
  if (desired.mode === 'retire') {
4835
4985
  const recorded = context.recordedIds();
4836
- const mainId = recorded[`names:key:${action.key}`];
4986
+ const mainId = recorded[`edge:key:${action.key}`];
4837
4987
  // A key minted but never delivered never gets a main id (the mint
4838
4988
  // step throws before `runSteps` sees a `resourceId`), so it can
4839
4989
  // coexist with a separate, already-delivered id from an earlier mint
@@ -4843,10 +4993,10 @@ export class FoundryStepExecutor {
4843
4993
  // here always picked A (truthy), leaving B live for up to 365 days
4844
4994
  // with nothing recorded (#317 re-review) -- both ids are revoked,
4845
4995
  // independently, whenever both are present.
4846
- const undeliveredId = recorded[entryNamesUndeliveredScopeKey(entryKey)];
4996
+ const undeliveredId = recorded[entryEdgeUndeliveredScopeKey(entryKey)];
4847
4997
  const idsToRevoke = [...new Set([mainId, undeliveredId].filter((id) => !!id))];
4848
4998
  const keyId = mainId || undeliveredId;
4849
- // Revoke first: the key going dead in names is what retire is for,
4999
+ // Revoke first: the key going dead in edge is what retire is for,
4850
5000
  // and the one thing that must still happen even when the entry's app
4851
5001
  // is itself already gone. Only once that has succeeded (or 404'd as
4852
5002
  // already gone) is the env cleanup attempted, itself tolerant of a
@@ -4856,7 +5006,7 @@ export class FoundryStepExecutor {
4856
5006
  // stranding a live key on every retry (#317 review).
4857
5007
  for (const id of idsToRevoke) {
4858
5008
  try {
4859
- await names.revokeKey(org.id, id);
5009
+ await edge.revokeKey(org.id, id);
4860
5010
  }
4861
5011
  catch (error) {
4862
5012
  // A 404 means it is already gone -- recorded as retired below,
@@ -4871,7 +5021,7 @@ export class FoundryStepExecutor {
4871
5021
  const appId = recorded[`coolify:app:${action.key}`];
4872
5022
  if (appId) {
4873
5023
  // A revoked key's env values are dead, not merely stale -- remove
4874
- // them so nothing reads a credential names has already revoked. A
5024
+ // them so nothing reads a credential edge has already revoked. A
4875
5025
  // 404 on either call means that app or that env is already gone:
4876
5026
  // nothing to clean up there, and the key is already revoked above,
4877
5027
  // so it is not a reason to fail the step. Each delete tolerates its
@@ -4887,7 +5037,7 @@ export class FoundryStepExecutor {
4887
5037
  }
4888
5038
  };
4889
5039
  const envs = await tolerateNotFound(() => this.ports.coolify.listEnvs(appId), []);
4890
- for (const envKey of ['NAMES_URL', 'NAMES_API_KEY']) {
5040
+ for (const envKey of ['EDGE_URL', 'EDGE_API_KEY']) {
4891
5041
  const env = envs.find((e) => e.key === envKey && !e.is_preview);
4892
5042
  if (env?.uuid) {
4893
5043
  const uuid = env.uuid;
@@ -4895,21 +5045,21 @@ export class FoundryStepExecutor {
4895
5045
  }
4896
5046
  }
4897
5047
  }
4898
- context.recordScopeId(entryNamesUndeliveredScopeKey(entryKey), '');
4899
- context.recordScopeId(entryNamesMintedAtScopeKey(entryKey), '');
5048
+ context.recordScopeId(entryEdgeUndeliveredScopeKey(entryKey), '');
5049
+ context.recordScopeId(entryEdgeMintedAtScopeKey(entryKey), '');
4900
5050
  if (!keyId)
4901
5051
  return {};
4902
- context.recordScopeId(entryNamesRetiredScopeKey(entryKey), keyId);
5052
+ context.recordScopeId(entryEdgeRetiredScopeKey(entryKey), keyId);
4903
5053
  return {};
4904
5054
  }
4905
- const entry = org.hosting.entries.find((e) => e.key === entryKey && e.uses?.names);
5055
+ const entry = org.hosting.entries.find((e) => e.key === entryKey && e.uses?.edge);
4906
5056
  if (!entry)
4907
- throw new Error(`${at}: the entry no longer declares uses.names`);
5057
+ throw new Error(`${at}: the entry no longer declares uses.edge`);
4908
5058
  const appId = context.recordedIds()[`coolify:app:${action.key}`];
4909
5059
  if (!appId)
4910
5060
  throw new Error(`${at}: the entry's app is not recorded yet`);
4911
5061
  if (!org.domain) {
4912
- throw new Error(`${at}: names needs org.domain, and validateEntryNames already refuses it without one`);
5062
+ throw new Error(`${at}: edge needs org.domain, and validateEntryEdge already refuses it without one`);
4913
5063
  }
4914
5064
  // A key left over from a previous mint whose delivery never succeeded
4915
5065
  // is revoked first, not left blocking every later apply forever:
@@ -4917,39 +5067,39 @@ export class FoundryStepExecutor {
4917
5067
  // (CLAUDE.md "No secret leaves"), so the only way to actually recover
4918
5068
  // is to revoke the stale id and mint a fresh one -- a 404 counts as
4919
5069
  // already revoked, same as any other retire (#317 re-review).
4920
- const staleUndelivered = context.recordedIds()[entryNamesUndeliveredScopeKey(entryKey)];
5070
+ const staleUndelivered = context.recordedIds()[entryEdgeUndeliveredScopeKey(entryKey)];
4921
5071
  if (staleUndelivered) {
4922
5072
  try {
4923
- await names.revokeKey(org.id, staleUndelivered);
5073
+ await edge.revokeKey(org.id, staleUndelivered);
4924
5074
  }
4925
5075
  catch (error) {
4926
5076
  if (!isNotFoundError(error))
4927
5077
  throw error;
4928
5078
  }
4929
- context.recordScopeId(entryNamesUndeliveredScopeKey(entryKey), '');
5079
+ context.recordScopeId(entryEdgeUndeliveredScopeKey(entryKey), '');
4930
5080
  await context.persistScopeIds();
4931
5081
  }
4932
- const minted = await names.mintKey({
5082
+ const minted = await edge.mintKey({
4933
5083
  organisationId: org.id,
4934
5084
  domain: org.domain,
4935
5085
  name: desired.name,
4936
- expiresDays: NAMES_ENTRY_KEY_DAYS,
5086
+ expiresDays: EDGE_ENTRY_KEY_DAYS,
4937
5087
  });
4938
- context.recordScopeId(entryNamesUndeliveredScopeKey(entryKey), minted.id);
5088
+ context.recordScopeId(entryEdgeUndeliveredScopeKey(entryKey), minted.id);
4939
5089
  await context.persistScopeIds();
4940
5090
  const envs = [
4941
- { key: 'NAMES_URL', value: desired.namesUrl, literal: true },
4942
- { key: 'NAMES_API_KEY', value: minted.secret, literal: true },
5091
+ { key: 'EDGE_URL', value: desired.edgeUrl, literal: true },
5092
+ { key: 'EDGE_API_KEY', value: minted.secret, literal: true },
4943
5093
  ];
4944
5094
  let lastError;
4945
- for (let attempt = 1; attempt <= ENTRY_NAMES_KEY_DELIVERY_ATTEMPTS; attempt++) {
5095
+ for (let attempt = 1; attempt <= ENTRY_EDGE_KEY_DELIVERY_ATTEMPTS; attempt++) {
4946
5096
  try {
4947
5097
  await this.ports.coolify.bulkUpsertEnvs(appId, envs);
4948
5098
  await this.ports.coolify.restart(appId);
4949
- context.recordScopeId(entryNamesUndeliveredScopeKey(entryKey), '');
5099
+ context.recordScopeId(entryEdgeUndeliveredScopeKey(entryKey), '');
4950
5100
  // The only trace of this key's age, since nothing here renews it
4951
5101
  // (#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());
5102
+ context.recordScopeId(entryEdgeMintedAtScopeKey(entryKey), new Date(context.now?.() ?? Date.now()).toISOString());
4953
5103
  return { resourceId: minted.id };
4954
5104
  }
4955
5105
  catch (e) {
@@ -4957,21 +5107,21 @@ export class FoundryStepExecutor {
4957
5107
  }
4958
5108
  }
4959
5109
  // 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`);
5110
+ 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
5111
  }
4962
5112
  /**
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.
5113
+ * F7 (#317): `plan/entry-edge-key.ts`'s `planEdgeReservedLabels` --
5114
+ * tells the edge service every label this org's own entries already
5115
+ * hold, so edge refuses a customer claiming one of them.
4966
5116
  */
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)`);
5117
+ async edgeReservedLabels(action, org, context) {
5118
+ const edge = this.ports.edge;
5119
+ if (!edge) {
5120
+ throw new Error(`edge:reserved-labels for ${org.slug}: this executor was built without an EdgePort (#317)`);
4971
5121
  }
4972
5122
  const labels = action.desired.labels;
4973
- await names.setReservedLabels(org.id, labels);
4974
- context.recordScopeId(NAMES_RESERVED_LABELS_APPLIED_SCOPE_KEY, JSON.stringify(labels));
5123
+ await edge.setReservedLabels(org.id, labels);
5124
+ context.recordScopeId(EDGE_RESERVED_LABELS_APPLIED_SCOPE_KEY, JSON.stringify(labels));
4975
5125
  return {};
4976
5126
  }
4977
5127
  /**
@@ -5250,7 +5400,14 @@ export class FoundryStepExecutor {
5250
5400
  // `LOGIN_HOST_PENDING_REMOVAL_SCOPE_KEY` -- nothing else ever runs
5251
5401
  // for this org again to finish removing it once destroy deletes
5252
5402
  // the ZITADEL project and org below, so it has to go here too.
5253
- for (const host of [target.id, ...pendingRemovalHosts(recordedIds)]) {
5403
+ // A shared instance host's trusted domain is not this org's to remove.
5404
+ // The flag may be missing, so the recorded host is also compared
5405
+ // with the platform's shared auth host.
5406
+ const sharedHost = await this.ports.platformOrg?.authHostname?.();
5407
+ const isShared = recordedIds[LOGIN_HOST_SHARED_SCOPE_KEY] === '1' ||
5408
+ (sharedHost != null && target.id === sharedHost);
5409
+ const own = isShared ? [] : [target.id];
5410
+ for (const host of [...own, ...pendingRemovalHosts(recordedIds)]) {
5254
5411
  try {
5255
5412
  await this.ports.zitadel.deleteTrustedDomain(host);
5256
5413
  }
@@ -5375,7 +5532,7 @@ export class FoundryStepExecutor {
5375
5532
  return;
5376
5533
  }
5377
5534
  case 'cloudflare:worker': {
5378
- // #318: an entry's edge Worker (`<orgId>:<entry>`) and its route.
5535
+ // #318: an entry's Worker (`<orgId>:<entry>`) and its route.
5379
5536
  // No port means no way to delete either -- thrown, not silently
5380
5537
  // skipped, so it is reported blocked rather than left live with
5381
5538
  // nothing recorded ids point at any more.
@@ -5388,20 +5545,20 @@ export class FoundryStepExecutor {
5388
5545
  // never recorded, then the script -- the same teardown retire runs.
5389
5546
  // `target.id` is `''` for a route-only partial deploy.
5390
5547
  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}`);
5548
+ await this.teardownWorker(entryKey, target.id || undefined, recordedIds, workers, `cloudflare:worker '${target.key}' for ${org.slug}`);
5392
5549
  return;
5393
5550
  }
5394
- case 'names:key': {
5395
- // F7 (#317): an entry's key (`<orgId>:<entry>`); names has no org-root key.
5551
+ case 'edge:key': {
5552
+ // F7 (#317): an entry's key (`<orgId>:<entry>`); edge has no org-root key.
5396
5553
  // No port means no way to revoke it -- thrown, not silently
5397
5554
  // skipped, so the key is reported blocked rather than left live
5398
5555
  // 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`);
5556
+ const edge = this.ports.edge;
5557
+ if (!edge) {
5558
+ throw new Error(`edge:key '${target.key}' for ${org.slug}: this executor was built without an EdgePort (#317); cannot revoke it`);
5402
5559
  }
5403
5560
  try {
5404
- await names.revokeKey(org.id, target.id);
5561
+ await edge.revokeKey(org.id, target.id);
5405
5562
  }
5406
5563
  catch (error) {
5407
5564
  // Already gone -- the same already-retired key a retire step