@wtfalch/foundry 0.19.0 → 0.20.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.
@@ -2,7 +2,7 @@ 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_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';
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_INTROSPECTION_SCOPE_KEY, CHAT_INTROSPECTION_UNDELIVERED_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, chatIntrospectionAppName, desiredAgoraEnv, entryClientIdScopeKey, } from './agora.js';
6
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';
@@ -10,8 +10,13 @@ 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 { createEdgeAccess, } from './edge-access.js';
14
+ import { EDGE_DNS_WRITE_CONCURRENCY, createEdgeDns, limiter, } from './edge-dns.js';
15
+ import { createEdgeRateLimits, } from './edge-ratelimits.js';
16
+ import { createEdgeTunnels } from './edge-tunnels.js';
17
+ import { createEdgeZones } from './edge-zones.js';
13
18
  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';
19
+ import { BOULE_EDGE_ENTRY_KEY, EDGE_ENTRY_KEY_DAYS, EDGE_RESERVED_LABELS_APPLIED_SCOPE_KEY, ENTRY_EDGE_KEY_DELIVERY_ATTEMPTS, edgeKeyAppRecordKey, entryEdgeMintedAtScopeKey, entryEdgeRetiredScopeKey, entryEdgeUndeliveredScopeKey, } from './entry-edge-key.js';
15
20
  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
21
  import { appHostname, coolifyDomains, entryHostnames, tunnelDnsHostnames, tunnelIngressHostnames, } from './hostnames.js';
17
22
  import { LOGIN_BINDING_SCOPE_KEY, LOGIN_HOST_PENDING_REMOVAL_SCOPE_KEY, LOGIN_HOST_SHARED_SCOPE_KEY, loginBindingState, loginHostClientIds, pendingRemovalHosts, pendingRemovalState, } from './login-host.js';
@@ -479,6 +484,16 @@ const MAIL_ROLE_MAILBOXES_SCOPE_KEY = 'mail_role_mailboxes';
479
484
  const AI_KEY_UNDELIVERED_SCOPE_KEY = 'ai_key_undelivered';
480
485
  /** How many times `aiKey` tries `deliverSecret` before giving up. */
481
486
  const AI_KEY_DELIVERY_ATTEMPTS = 3;
487
+ /**
488
+ * #515: the limit `aiBudget` last applied, a flat scope id (no `:`) alongside
489
+ * `AI_KEY_UNDELIVERED_SCOPE_KEY`'s own. AI's `updateBudget` offers no read
490
+ * back, so nothing else can tell `diff.ts`'s `planAiBudget` the limit is
491
+ * already live -- without this, every plan for an applied org proposed
492
+ * setting it again. `providers-readers.ts`'s `ProvidersAiReader` keeps its
493
+ * own copy of this literal, the same "restated, not shared" choice this
494
+ * codebase already makes for `cloudflare_zone`/`zitadel_org`.
495
+ */
496
+ const AI_BUDGET_LIMIT_SCOPE_KEY = 'ai_budget_limit_micros';
482
497
  const DAY_MS = 24 * 60 * 60 * 1000;
483
498
  /** What a failed files call says about itself: its HTTP status, never its message, which can echo a value. */
484
499
  function filesErrorCause(error) {
@@ -640,6 +655,12 @@ export const databaseStartWait = {
640
655
  // ---------------------------------------------------------------------------
641
656
  export class FoundryStepExecutor {
642
657
  ports;
658
+ edgeDns;
659
+ edgeTunnels;
660
+ edgeAccess;
661
+ edgeRateLimits;
662
+ edgeZones;
663
+ edgeWriteSlot;
643
664
  constructor(ports) {
644
665
  this.ports = ports;
645
666
  }
@@ -686,7 +707,7 @@ export class FoundryStepExecutor {
686
707
  case 'ai:organisation':
687
708
  return this.aiOrganisation(action, org);
688
709
  case 'ai:budget':
689
- return this.aiBudget(action, org);
710
+ return this.aiBudget(action, org, context);
690
711
  case 'ai:key':
691
712
  // The org's own key is keyed `<orgId>`, an entry's `<orgId>:<entry>` (F6a).
692
713
  return action.key.includes(':')
@@ -774,7 +795,7 @@ export class FoundryStepExecutor {
774
795
  // `everyone` is Cloudflare's own shape for "match every visitor".
775
796
  return { decision: 'bypass', include: [{ everyone: {} }] };
776
797
  case 'service-token': {
777
- const token = await this.ports.cloudflare.findServiceTokenByName(rule.serviceTokenName);
798
+ const token = await this.accessPort().findServiceTokenByName(rule.serviceTokenName);
778
799
  if (!token) {
779
800
  throw new Error(`cloudflare:access for ${org.slug}: no Cloudflare Access service token named '${rule.serviceTokenName}'`);
780
801
  }
@@ -788,7 +809,7 @@ export class FoundryStepExecutor {
788
809
  */
789
810
  async cloudflareAccessApp(org, opts) {
790
811
  const { appName, domain, policyName, policy } = opts;
791
- const cf = this.ports.cloudflare;
812
+ const cf = this.accessPort();
792
813
  const onDomain = (await cf.listAccessApps()).filter((a) => a.domain === domain);
793
814
  const foreign = onDomain.find((a) => a.name !== appName);
794
815
  if (foreign) {
@@ -812,12 +833,10 @@ export class FoundryStepExecutor {
812
833
  * gate), so `org.domain` is set and that zone is the org's alone, never
813
834
  * shared with another org the way `oikos.host` is.
814
835
  *
815
- * Rate limiting rules attach to a zone's `http_ratelimit` phase entry
816
- * point ruleset, one `GET`/`PUT` pair per zone (`@wtfalch/providers` 0.5.0
817
- * has no typed method for either -- `CloudflarePort.request`'s own doc
818
- * comment) -- so "find by name" here means reading that one ruleset's
819
- * `rules[]` and matching `RATE_LIMIT_RULE_DESCRIPTION`, the same shape
820
- * `cloudflareAccess` above finds its own Access app by name. The Free
836
+ * Rate limiting rules go through edge (`RateLimitPort`) -- so "find by
837
+ * name" here means listing the zone's rules and matching
838
+ * `RATE_LIMIT_RULE_DESCRIPTION`, the same shape `cloudflareAccess` above
839
+ * finds its own Access app by name. The Free
821
840
  * plan allows exactly one rule per zone, so a rule already there under a
822
841
  * different description is refused, never adopted or replaced, mirroring
823
842
  * `cloudflareAccess`'s own refusal when a foreign Access app already
@@ -827,40 +846,33 @@ export class FoundryStepExecutor {
827
846
  if (!org.domain) {
828
847
  throw new Error(`cloudflare:ratelimit for ${org.slug}: hosting.usesDomain is set but domain is null`);
829
848
  }
830
- const cf = this.ports.cloudflare;
849
+ const cf = this.zonePort();
831
850
  const zone = await cf.findZoneByApex(org.domain);
832
851
  if (!zone) {
833
852
  throw new Error(`cloudflare:ratelimit for ${org.slug}: no Cloudflare zone found for ${org.domain} -- cloudflare:zone must provision it first`);
834
853
  }
835
- const path = `/zones/${zone.id}/rulesets/phases/http_ratelimit/entrypoint`;
836
- const existing = await cf.request('GET', path, undefined, true);
837
- const rules = existing?.rules ?? [];
854
+ const rateLimits = this.rateLimitPort();
855
+ const rules = await rateLimits.listRateLimitRules(zone.id);
838
856
  const own = rules.find((r) => r.description === RATE_LIMIT_RULE_DESCRIPTION);
839
- if (own)
840
- return { resourceId: own.id };
841
- const foreign = rules.find((r) => r.description !== RATE_LIMIT_RULE_DESCRIPTION);
842
- if (foreign) {
843
- throw new Error(`cloudflare:ratelimit for ${org.slug}: zone ${org.domain} already has a rate limiting rule ('${foreign.description ?? foreign.id}') foundry did not create; the Free plan allows only one rule per zone, so foundry does not adopt or replace it`);
844
- }
845
- const updated = await cf.request('PUT', path, {
846
- rules: [
847
- {
848
- description: RATE_LIMIT_RULE_DESCRIPTION,
849
- expression: SIGN_IN_PATH_EXPRESSION,
850
- action: 'block',
851
- ratelimit: {
852
- characteristics: ['cf.colo.id', 'ip.src'],
853
- period: RATE_LIMIT_FREE_PLAN_PERIOD_SECONDS,
854
- requests_per_period: RATE_LIMIT_REQUESTS_PER_PERIOD,
855
- mitigation_timeout: RATE_LIMIT_FREE_PLAN_PERIOD_SECONDS,
856
- },
857
- },
858
- ],
859
- });
860
- const created = updated.rules?.[0];
861
- if (!created) {
862
- throw new Error(`cloudflare:ratelimit for ${org.slug}: PUT ${path} did not return the created rule`);
857
+ if (!own) {
858
+ const foreign = rules[0];
859
+ if (foreign) {
860
+ throw new Error(`cloudflare:ratelimit for ${org.slug}: zone ${org.domain} already has a rate limiting rule ('${foreign.description ?? foreign.id}') foundry did not create; the Free plan allows only one rule per zone, so foundry does not adopt or replace it`);
861
+ }
863
862
  }
863
+ // Edge adopts a live rule with no edge row (made before edge owned rate
864
+ // limits) and then makes the rule once: a repeat returns it unchanged.
865
+ const created = await rateLimits.createRateLimitRule(zone.id, {
866
+ description: RATE_LIMIT_RULE_DESCRIPTION,
867
+ expression: SIGN_IN_PATH_EXPRESSION,
868
+ action: 'block',
869
+ ratelimit: {
870
+ characteristics: ['cf.colo.id', 'ip.src'],
871
+ period: RATE_LIMIT_FREE_PLAN_PERIOD_SECONDS,
872
+ requests_per_period: RATE_LIMIT_REQUESTS_PER_PERIOD,
873
+ mitigation_timeout: RATE_LIMIT_FREE_PLAN_PERIOD_SECONDS,
874
+ },
875
+ });
864
876
  return { resourceId: created.id };
865
877
  }
866
878
  /**
@@ -913,7 +925,7 @@ export class FoundryStepExecutor {
913
925
  if (!org.domain) {
914
926
  throw new Error(`${at}: worker needs org.domain, and validateHosting already refuses it without one`);
915
927
  }
916
- const zone = await this.ports.cloudflare.findZoneByApex(org.domain);
928
+ const zone = await this.zonePort().findZoneByApex(org.domain);
917
929
  if (!zone) {
918
930
  throw new Error(`${at}: no Cloudflare zone found for ${org.domain} -- cloudflare:zone must provision it first`);
919
931
  }
@@ -1066,7 +1078,7 @@ export class FoundryStepExecutor {
1066
1078
  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`);
1067
1079
  }
1068
1080
  const { id, apex } = JSON.parse(recordedZone);
1069
- const zone = await this.ports.cloudflare.findZoneByApex(apex);
1081
+ const zone = await this.zonePort().findZoneByApex(apex);
1070
1082
  return zone && zone.id === id ? id : undefined;
1071
1083
  }
1072
1084
  /** Deletes the route an interrupted apply was creating, if `findRoute` shows it exists, points at that apply's script, and is not `keepRouteId`. */
@@ -2481,11 +2493,11 @@ export class FoundryStepExecutor {
2481
2493
  scopedProjectIds.push(platformLettermintProjectId);
2482
2494
  }
2483
2495
  await this.ports.lettermint.restrictDomainToProjects(domain.id, scopedProjectIds);
2484
- const zone = await this.ports.cloudflare.findZoneByApex(mailDomain);
2496
+ const zone = await this.zonePort().findZoneByApex(mailDomain);
2485
2497
  if (!zone) {
2486
2498
  throw new Error(`Lettermint for ${org.slug}: no Cloudflare zone found for ${mailDomain}`);
2487
2499
  }
2488
- await this.publishLettermintDnsRecords(zone.id, domain.dnsRecords);
2500
+ await this.publishLettermintDnsRecords(zone.name, domain.dnsRecords);
2489
2501
  await this.ports.lettermint.verifyDomain(domain.id);
2490
2502
  }
2491
2503
  /**
@@ -3079,6 +3091,74 @@ export class FoundryStepExecutor {
3079
3091
  }
3080
3092
  }
3081
3093
  // ---- chat -----------------------------------------------------------------
3094
+ /**
3095
+ * The chat service's introspection client: an API app in the org's
3096
+ * messaging project (the audience Agora's token carries), so ZITADEL's
3097
+ * introspection accepts it. The chat service authenticates as it with
3098
+ * `client_secret_basic`. Minted once, the way {@link callsTokenEnvs} does:
3099
+ * the id is recorded under an undelivered key and persisted before the
3100
+ * secret leaves, the secret goes to `deliverSecret` and to the caller's env
3101
+ * write, never to a log, error or return beyond that. A re-run reads the id
3102
+ * and secret back from the service's own env and mints nothing. `changed`
3103
+ * is true only for a fresh mint.
3104
+ */
3105
+ async ensureChatIntrospection(org, zitadelOrgId, projectId, serviceAppId, context) {
3106
+ const recorded = context.recordedIds();
3107
+ const done = recorded[CHAT_INTROSPECTION_SCOPE_KEY];
3108
+ const undelivered = recorded[CHAT_INTROSPECTION_UNDELIVERED_SCOPE_KEY];
3109
+ if (done) {
3110
+ const envs = await this.ports.coolify.listEnvs(serviceAppId);
3111
+ const read = (key) => envs.find((e) => e.key === key && !e.is_preview)?.value;
3112
+ const clientId = read('CHAT_INTROSPECTION_CLIENT_ID');
3113
+ const clientSecret = read('CHAT_INTROSPECTION_CLIENT_SECRET');
3114
+ if (!clientId || !clientSecret) {
3115
+ throw new Error(`coolify:chat for ${org.slug}: the introspection client (ZITADEL app ${done}) is recorded but its id or secret is no longer on the service; remove the recorded keys ${CHAT_INTROSPECTION_SCOPE_KEY} and ${CHAT_INTROSPECTION_UNDELIVERED_SCOPE_KEY}, and delete the ZITADEL client, to mint a new one`);
3116
+ }
3117
+ return { clientId, clientSecret, appId: done, changed: false };
3118
+ }
3119
+ if (undelivered) {
3120
+ // A minted client with no marker: a crash or failure after the mint.
3121
+ // Both values on the service mean the write landed and only the marker
3122
+ // was lost; otherwise nothing usable was delivered, and the app is
3123
+ // deleted below and made again.
3124
+ const envs = await this.ports.coolify.listEnvs(serviceAppId);
3125
+ const read = (key) => envs.find((e) => e.key === key && !e.is_preview)?.value;
3126
+ const clientId = read('CHAT_INTROSPECTION_CLIENT_ID');
3127
+ const clientSecret = read('CHAT_INTROSPECTION_CLIENT_SECRET');
3128
+ if (clientId && clientSecret) {
3129
+ return { clientId, clientSecret, appId: undelivered, changed: false };
3130
+ }
3131
+ }
3132
+ // An app by this name with no recorded secret is a crash between create
3133
+ // and record: nothing was delivered, so it is deleted and made again.
3134
+ const stale = (await this.ports.zitadel.listApps(zitadelOrgId, projectId)).apps.filter((app) => app.name === chatIntrospectionAppName(org) && app.state !== 'APP_STATE_REMOVED');
3135
+ const staleIds = new Set(stale.map((app) => app.id));
3136
+ if (undelivered)
3137
+ staleIds.add(undelivered);
3138
+ for (const id of staleIds) {
3139
+ try {
3140
+ await this.ports.zitadel.deleteApp(zitadelOrgId, projectId, id);
3141
+ }
3142
+ catch (error) {
3143
+ if (!isNotFoundError(error))
3144
+ throw error;
3145
+ }
3146
+ }
3147
+ const client = await this.ports.zitadel.createApiApp(zitadelOrgId, projectId, {
3148
+ name: chatIntrospectionAppName(org),
3149
+ });
3150
+ context.recordScopeId(CHAT_INTROSPECTION_UNDELIVERED_SCOPE_KEY, client.appId);
3151
+ await context.persistScopeIds();
3152
+ if (!(await this.deliverWithRetry(`zitadel:chat:${org.slug}:introspection`, client.clientSecret))) {
3153
+ throw new Error(`coolify:chat for ${org.slug}: the introspection client secret (ZITADEL app ${client.appId}) could not be delivered after ${JOBS_DELIVERY_ATTEMPTS} attempts`);
3154
+ }
3155
+ return {
3156
+ clientId: client.clientId,
3157
+ clientSecret: client.clientSecret,
3158
+ appId: client.appId,
3159
+ changed: true,
3160
+ };
3161
+ }
3082
3162
  /**
3083
3163
  * Creates the `chat.admin` role on the org's messaging project and grants
3084
3164
  * it to each owner whose invite is recorded (`zitadel:owner:<orgId>:<email>`,
@@ -3147,6 +3227,29 @@ export class FoundryStepExecutor {
3147
3227
  context.recordScopeId(markerKey, '1');
3148
3228
  return { resourceId: serviceAppId };
3149
3229
  }
3230
+ // `planChat` plans this for a live chat whose service has no
3231
+ // introspection client: mint it once, push only its two env vars and
3232
+ // redeploy. Never the re-key paths or a fresh provision.
3233
+ if (action.desired.refreshIntrospection === true) {
3234
+ const serviceAppId = recorded[CHAT_SERVICE_SCOPE_KEY];
3235
+ const zitadelOrgId = recorded[ZITADEL_ORG_SCOPE_KEY];
3236
+ const projectId = recorded[ZITADEL_MESSAGING_PROJECT_SCOPE_KEY];
3237
+ if (!serviceAppId || !zitadelOrgId || !projectId) {
3238
+ throw new Error(`coolify:chat introspection for ${org.slug}: the chat service app, the ZITADEL org or the messaging project is not recorded yet`);
3239
+ }
3240
+ const hadMarker = Boolean(recorded[CHAT_INTROSPECTION_SCOPE_KEY]);
3241
+ const client = await this.ensureChatIntrospection(org, zitadelOrgId, projectId, serviceAppId, context);
3242
+ await this.ports.coolify.bulkUpsertEnvs(serviceAppId, [
3243
+ { key: 'CHAT_INTROSPECTION_CLIENT_ID', value: client.clientId },
3244
+ { key: 'CHAT_INTROSPECTION_CLIENT_SECRET', value: client.clientSecret },
3245
+ ]);
3246
+ // Deploy first, marker last: a failed deploy leaves the marker unset,
3247
+ // so the next plan still carries this step and the container gets the env.
3248
+ if (client.changed || !hadMarker)
3249
+ await this.ports.coolify.deploy(serviceAppId);
3250
+ context.recordScopeId(CHAT_INTROSPECTION_SCOPE_KEY, client.appId);
3251
+ return { resourceId: serviceAppId };
3252
+ }
3150
3253
  if (action.desired.usesDomain === false) {
3151
3254
  throw new NotBuiltError(`coolify:chat reverse re-key for ${org.slug}`, 'moving its chat back onto the original server_name is not built (#7 amendment 3)');
3152
3255
  }
@@ -3280,6 +3383,7 @@ export class FoundryStepExecutor {
3280
3383
  rsaKeyPem: secrets.value.masRsaKeyPem,
3281
3384
  ecKeyPem: secrets.value.masEcKeyPem,
3282
3385
  }));
3386
+ const introspection = await this.ensureChatIntrospection(org, zitadelOrgId, project.id, service.appId, context);
3283
3387
  await this.ports.coolify.bulkUpsertEnvs(service.appId, chatServiceEnvs({
3284
3388
  orgSlug: org.slug,
3285
3389
  companyName: org.displayName,
@@ -3289,6 +3393,8 @@ export class FoundryStepExecutor {
3289
3393
  agoraHost,
3290
3394
  masClientId: secrets.value.masClientId,
3291
3395
  masClientSecret: secrets.value.masClientSecret,
3396
+ introspectionClientId: introspection.clientId,
3397
+ introspectionClientSecret: introspection.clientSecret,
3292
3398
  }));
3293
3399
  // Split the same way `coolifyApp`'s and `cmsSite`'s own databases are
3294
3400
  // (#239, #456): `DATABASE_URL_OWNER` is the superuser string,
@@ -3306,11 +3412,14 @@ export class FoundryStepExecutor {
3306
3412
  for (const app of [mas, synapse, service]) {
3307
3413
  if (!app.existed ||
3308
3414
  secrets.changed ||
3309
- (app === service && serviceDbPush.changed) ||
3415
+ (app === service && (serviceDbPush.changed || introspection.changed)) ||
3310
3416
  !isRunningOrStarting((await this.ports.coolify.getApplication(app.appId)).status)) {
3311
3417
  await this.ports.coolify.deploy(app.appId);
3312
3418
  }
3313
3419
  }
3420
+ // After the write and the deploys, so a failure before them leaves the
3421
+ // marker unset and the next apply repeats the step.
3422
+ context.recordScopeId(CHAT_INTROSPECTION_SCOPE_KEY, introspection.appId);
3314
3423
  // #456: recorded here too, not only on the `refreshDatabaseSplit` replan
3315
3424
  // path above -- without it a fresh provision's very first apply never
3316
3425
  // sets this marker, so `planChat` (`!recordedIds[serviceDbEnvAppliedScopeKey('chat')]`)
@@ -3837,6 +3946,87 @@ export class FoundryStepExecutor {
3837
3946
  throw new Error('ai: this executor was built without an ai port (#177)');
3838
3947
  return port;
3839
3948
  }
3949
+ /**
3950
+ * Every DNS record call runs through edge (`edge.dns`, the platform
3951
+ * credential), adopting live records before it first changes them. `dns` on
3952
+ * the ports is a seam for a host or test that supplies its own {@link DnsPort}.
3953
+ */
3954
+ dnsPort() {
3955
+ if (this.ports.dns)
3956
+ return this.ports.dns;
3957
+ if (!this.ports.edge) {
3958
+ throw new Error('dns: this executor was built without an EdgePort (F4.dns)');
3959
+ }
3960
+ this.edgeWriteSlot ??= limiter(EDGE_DNS_WRITE_CONCURRENCY);
3961
+ this.edgeDns ??= createEdgeDns(this.ports.edge.dns, { writeSlot: this.edgeWriteSlot });
3962
+ return this.edgeDns;
3963
+ }
3964
+ /**
3965
+ * Every tunnel call runs through edge (`edge.tunnels`, the platform
3966
+ * credential), adopting a live tunnel before it first configures, reads the
3967
+ * token of or deletes it. Shares edge's write slot with {@link dnsPort}.
3968
+ */
3969
+ tunnelPort() {
3970
+ if (this.ports.tunnels)
3971
+ return this.ports.tunnels;
3972
+ if (!this.ports.edge) {
3973
+ throw new Error('tunnels: this executor was built without an EdgePort (F4.tunnels)');
3974
+ }
3975
+ this.edgeWriteSlot ??= limiter(EDGE_DNS_WRITE_CONCURRENCY);
3976
+ this.edgeTunnels ??= createEdgeTunnels(this.ports.edge.tunnels, {
3977
+ writeSlot: this.edgeWriteSlot,
3978
+ });
3979
+ return this.edgeTunnels;
3980
+ }
3981
+ /**
3982
+ * Every Access call runs through edge (`edge.access`, the platform
3983
+ * credential), adopting a live app before it first adds a policy to or
3984
+ * deletes it. Shares edge's write slot with {@link dnsPort}.
3985
+ */
3986
+ accessPort() {
3987
+ if (this.ports.access)
3988
+ return this.ports.access;
3989
+ if (!this.ports.edge) {
3990
+ throw new Error('access: this executor was built without an EdgePort (F4.access)');
3991
+ }
3992
+ this.edgeWriteSlot ??= limiter(EDGE_DNS_WRITE_CONCURRENCY);
3993
+ this.edgeAccess ??= createEdgeAccess(this.ports.edge.access, {
3994
+ writeSlot: this.edgeWriteSlot,
3995
+ });
3996
+ return this.edgeAccess;
3997
+ }
3998
+ /**
3999
+ * Every rate limiting rule call runs through edge (`edge.ratelimits`, the
4000
+ * platform credential), adopting the live rule before it first creates it.
4001
+ * Shares edge's write slot with {@link dnsPort}.
4002
+ */
4003
+ rateLimitPort() {
4004
+ if (this.ports.ratelimits)
4005
+ return this.ports.ratelimits;
4006
+ if (!this.ports.edge) {
4007
+ throw new Error('ratelimits: this executor was built without an EdgePort (F4.ratelimits)');
4008
+ }
4009
+ this.edgeWriteSlot ??= limiter(EDGE_DNS_WRITE_CONCURRENCY);
4010
+ this.edgeRateLimits ??= createEdgeRateLimits(this.ports.edge.ratelimits, {
4011
+ writeSlot: this.edgeWriteSlot,
4012
+ });
4013
+ return this.edgeRateLimits;
4014
+ }
4015
+ /**
4016
+ * Every zone lookup and create runs through edge (`edge.zones`, the
4017
+ * platform credential), adopting a live zone before it first creates it.
4018
+ * Shares edge's write slot with {@link dnsPort}.
4019
+ */
4020
+ zonePort() {
4021
+ if (this.ports.zones)
4022
+ return this.ports.zones;
4023
+ if (!this.ports.edge) {
4024
+ throw new Error('zones: this executor was built without an EdgePort (F4.zones)');
4025
+ }
4026
+ this.edgeWriteSlot ??= limiter(EDGE_DNS_WRITE_CONCURRENCY);
4027
+ this.edgeZones ??= createEdgeZones(this.ports.edge.zones, { writeSlot: this.edgeWriteSlot });
4028
+ return this.edgeZones;
4029
+ }
3840
4030
  async jobsPort(org) {
3841
4031
  const port = this.ports.jobsScheduler ?? (await this.ports.platformOrg?.jobs());
3842
4032
  if (!port) {
@@ -3905,7 +4095,7 @@ export class FoundryStepExecutor {
3905
4095
  }
3906
4096
  // Cleared, not deleted (#7 corrections 1): an ingress with only the
3907
4097
  // mandatory catch-all routes nothing.
3908
- await this.ports.cloudflare.putTunnelConfig(tunnelId, {
4098
+ await this.tunnelPort().putTunnelConfig(tunnelId, {
3909
4099
  ingress: [{ service: 'http_status:404' }],
3910
4100
  });
3911
4101
  // #235: park stops the connector too -- an ingress with nothing
@@ -3931,8 +4121,9 @@ export class FoundryStepExecutor {
3931
4121
  // domain declared still leaves `destroy` a way to recognise the old
3932
4122
  // id's CNAMEs as this tunnel's own rather than a foreign record.
3933
4123
  const recordedTunnelId = context.recordedIds()[`cloudflare:tunnel:${action.key}`];
3934
- const existing = await this.ports.cloudflare.findTunnelByName(tunnelName);
3935
- const tunnel = existing ?? (await this.ports.cloudflare.createTunnel(tunnelName));
4124
+ const tunnels = this.tunnelPort();
4125
+ const existing = await tunnels.findTunnelByName(tunnelName);
4126
+ const tunnel = existing ?? (await tunnels.createTunnel(tunnelName));
3936
4127
  const previousIds = parseTunnelPreviousIds(context.recordedIds());
3937
4128
  if (recordedTunnelId && recordedTunnelId !== tunnel.id) {
3938
4129
  previousIds.add(recordedTunnelId);
@@ -3959,7 +4150,7 @@ export class FoundryStepExecutor {
3959
4150
  hostname,
3960
4151
  service: COOLIFY_PROXY_ORIGIN,
3961
4152
  }));
3962
- await this.ports.cloudflare.putTunnelConfig(tunnel.id, {
4153
+ await this.tunnelPort().putTunnelConfig(tunnel.id, {
3963
4154
  ingress: [...ingress, { service: 'http_status:404' }],
3964
4155
  });
3965
4156
  // #235: a connector for this org's own tunnel, the same call every
@@ -3973,7 +4164,7 @@ export class FoundryStepExecutor {
3973
4164
  const { environment } = await this.ports.coolify.findOrCreateEnvironment(projectUuid, 'production');
3974
4165
  await ensureCloudflaredConnector({
3975
4166
  coolify: this.ports.coolify,
3976
- cloudflare: this.ports.cloudflare,
4167
+ tunnels: this.tunnelPort(),
3977
4168
  tunnelId: tunnel.id,
3978
4169
  appName: `${org.slug}-cloudflared`,
3979
4170
  projectUuid,
@@ -4104,7 +4295,7 @@ export class FoundryStepExecutor {
4104
4295
  * nothing of this domain's left in that set either way.
4105
4296
  */
4106
4297
  async deleteStaleTunnelDnsRecords(tunnelId, previousIds, previousDomain, previousHostnames, context) {
4107
- const zone = await this.ports.cloudflare.findZoneByApex(previousDomain);
4298
+ const zone = await this.zonePort().findZoneByApex(previousDomain);
4108
4299
  if (!zone)
4109
4300
  return;
4110
4301
  const dnsTargets = new Set([`${tunnelId}.cfargotunnel.com`]);
@@ -4112,13 +4303,13 @@ export class FoundryStepExecutor {
4112
4303
  if (id !== tunnelId)
4113
4304
  dnsTargets.add(`${id}.cfargotunnel.com`);
4114
4305
  }
4115
- const cf = this.ports.cloudflare;
4306
+ const dns = this.dnsPort();
4116
4307
  const records = previousHostnames.length > 0
4117
- ? (await Promise.all(previousHostnames.map((hostname) => cf.findDnsRecord(zone.id, hostname, 'CNAME')))).flat()
4118
- : await cf.listDnsRecords(zone.id, { type: 'CNAME' });
4308
+ ? (await Promise.all(previousHostnames.map((hostname) => dns.findDnsRecord(zone.name, hostname, 'CNAME')))).flat()
4309
+ : await dns.listDnsRecords(zone.name, { type: 'CNAME' });
4119
4310
  for (const record of records) {
4120
4311
  if (dnsTargets.has(record.content)) {
4121
- await cf.deleteDnsRecord(zone.id, record.id);
4312
+ await dns.deleteDnsRecord(zone.name, record.id);
4122
4313
  }
4123
4314
  }
4124
4315
  if (previousHostnames.length > 0) {
@@ -4186,8 +4377,8 @@ export class FoundryStepExecutor {
4186
4377
  * both read this set.
4187
4378
  */
4188
4379
  async publishTunnelDnsRecords(org, domain, tunnelId, previousIds, previousHostnames, context) {
4189
- const cf = this.ports.cloudflare;
4190
- const zone = await cf.findZoneByApex(domain);
4380
+ const dns = this.dnsPort();
4381
+ const zone = await this.zonePort().findZoneByApex(domain);
4191
4382
  if (!zone) {
4192
4383
  throw new Error(`cloudflare:tunnel for ${org.slug}: no Cloudflare zone found for ${domain} to point its hostnames at -- cloudflare:zone must provision it first`);
4193
4384
  }
@@ -4199,14 +4390,14 @@ export class FoundryStepExecutor {
4199
4390
  const toCreate = [];
4200
4391
  const toRepoint = [];
4201
4392
  for (const hostname of hostnames) {
4202
- const [a] = await cf.findDnsRecord(zone.id, hostname, 'A');
4203
- const [aaaa] = await cf.findDnsRecord(zone.id, hostname, 'AAAA');
4393
+ const [a] = await dns.findDnsRecord(zone.name, hostname, 'A');
4394
+ const [aaaa] = await dns.findDnsRecord(zone.name, hostname, 'AAAA');
4204
4395
  const foreign = a ?? aaaa;
4205
4396
  if (foreign) {
4206
4397
  conflicts.push(`${hostname} already has a ${foreign.type} record`);
4207
4398
  continue;
4208
4399
  }
4209
- const [cname] = await cf.findDnsRecord(zone.id, hostname, 'CNAME');
4400
+ const [cname] = await dns.findDnsRecord(zone.name, hostname, 'CNAME');
4210
4401
  if (cname && cname.content !== dnsTarget) {
4211
4402
  if (previousTargets.has(cname.content)) {
4212
4403
  toRepoint.push({ hostname, recordId: cname.id });
@@ -4238,7 +4429,7 @@ export class FoundryStepExecutor {
4238
4429
  for (const hostname of settled)
4239
4430
  recordTracked(hostname);
4240
4431
  for (const hostname of toCreate) {
4241
- await cf.createDnsRecord(zone.id, {
4432
+ await dns.createDnsRecord(zone.name, {
4242
4433
  type: 'CNAME',
4243
4434
  name: hostname,
4244
4435
  content: dnsTarget,
@@ -4247,7 +4438,7 @@ export class FoundryStepExecutor {
4247
4438
  recordTracked(hostname);
4248
4439
  }
4249
4440
  for (const { hostname, recordId } of toRepoint) {
4250
- await cf.updateDnsRecord(zone.id, recordId, {
4441
+ await dns.updateDnsRecord(zone.name, recordId, {
4251
4442
  type: 'CNAME',
4252
4443
  name: hostname,
4253
4444
  content: dnsTarget,
@@ -4266,9 +4457,9 @@ export class FoundryStepExecutor {
4266
4457
  // tracking under `domain`, so it is dropped from the shared set either
4267
4458
  // way.
4268
4459
  for (const hostname of droppedHostnames) {
4269
- const [cname] = await cf.findDnsRecord(zone.id, hostname, 'CNAME');
4460
+ const [cname] = await dns.findDnsRecord(zone.name, hostname, 'CNAME');
4270
4461
  if (cname && (cname.content === dnsTarget || previousTargets.has(cname.content))) {
4271
- await cf.deleteDnsRecord(zone.id, cname.id);
4462
+ await dns.deleteDnsRecord(zone.name, cname.id);
4272
4463
  }
4273
4464
  tracked.delete(trackedTunnelDnsEntry(domain, hostname));
4274
4465
  context.recordScopeId(CLOUDFLARE_TUNNEL_DNS_TRACKED_SCOPE_KEY, trackedTunnelDnsState(tracked));
@@ -4302,7 +4493,7 @@ export class FoundryStepExecutor {
4302
4493
  */
4303
4494
  async cloudflareZone(action, org, context) {
4304
4495
  const domain = action.key;
4305
- const cf = this.ports.cloudflare;
4496
+ const cf = this.zonePort();
4306
4497
  const existing = await cf.findZoneByApex(domain);
4307
4498
  if (existing) {
4308
4499
  const owner = await this.ports.declaredOrgStore.findOrgIdForZone(existing.id);
@@ -4503,12 +4694,12 @@ export class FoundryStepExecutor {
4503
4694
  * publishes it.
4504
4695
  */
4505
4696
  async publishMailDnsRecords(org, result) {
4506
- const zone = await this.ports.cloudflare.findZoneByApex(result.domain);
4697
+ const zone = await this.zonePort().findZoneByApex(result.domain);
4507
4698
  if (!zone) {
4508
4699
  throw new Error(`coolify:mail for ${org.slug}: no Cloudflare zone found for ${result.domain}`);
4509
4700
  }
4510
4701
  for (const record of result.records) {
4511
- await this.upsertMailDnsRecord(zone.id, record);
4702
+ await this.upsertMailDnsRecord(zone.name, record);
4512
4703
  }
4513
4704
  }
4514
4705
  /**
@@ -4522,7 +4713,7 @@ export class FoundryStepExecutor {
4522
4713
  * a record someone proxied by hand is put back. An `MX` record carries the
4523
4714
  * priority it was given, which Cloudflare requires.
4524
4715
  *
4525
- * Found by an exact name+type match (`CloudflarePort.findDnsRecord`'s own
4716
+ * Found by an exact name+type match (`DnsPort.findDnsRecord`'s own
4526
4717
  * doc comment) so a stale record of a different type is never taken for a
4527
4718
  * match. A name can hold several `TXT` records (an apex often holds a site
4528
4719
  * verification beside SPF), so a `TXT` record only replaces one that
@@ -4531,8 +4722,8 @@ export class FoundryStepExecutor {
4531
4722
  * whatever it was.
4532
4723
  */
4533
4724
  async upsertMailDnsRecord(zoneId, record) {
4534
- const cf = this.ports.cloudflare;
4535
- const found = await cf.findDnsRecord(zoneId, record.name, record.type);
4725
+ const dns = this.dnsPort();
4726
+ const found = await dns.findDnsRecord(zoneId, record.name, record.type);
4536
4727
  const existing = record.type === 'TXT'
4537
4728
  ? found.find((r) => txtRecordTag(r.content) === txtRecordTag(record.value))
4538
4729
  : found[0];
@@ -4544,10 +4735,10 @@ export class FoundryStepExecutor {
4544
4735
  ...(record.priority === undefined ? {} : { priority: record.priority }),
4545
4736
  };
4546
4737
  if (existing) {
4547
- await cf.updateDnsRecord(zoneId, existing.id, rec);
4738
+ await dns.updateDnsRecord(zoneId, existing.id, rec);
4548
4739
  }
4549
4740
  else {
4550
- await cf.createDnsRecord(zoneId, rec);
4741
+ await dns.createDnsRecord(zoneId, rec);
4551
4742
  }
4552
4743
  }
4553
4744
  /**
@@ -4603,13 +4794,13 @@ export class FoundryStepExecutor {
4603
4794
  * `dkim_secondary`, none of them SPF -- so that merge has no case left to
4604
4795
  * handle here and is dropped rather than kept as dead code.
4605
4796
  */
4606
- async publishLettermintDnsRecords(zoneId, records) {
4797
+ async publishLettermintDnsRecords(zone, records) {
4607
4798
  for (const record of records) {
4608
4799
  if (record.type === 'TXT' && record.value.toLowerCase().startsWith('v=dmarc1')) {
4609
- await this.ensureLettermintDmarcRecord(this.ports.cloudflare, zoneId, record);
4800
+ await this.ensureLettermintDmarcRecord(this.dnsPort(), zone, record);
4610
4801
  continue;
4611
4802
  }
4612
- await this.upsertMailDnsRecord(zoneId, record);
4803
+ await this.upsertMailDnsRecord(zone, record);
4613
4804
  }
4614
4805
  }
4615
4806
  /**
@@ -4617,11 +4808,11 @@ export class FoundryStepExecutor {
4617
4808
  * already set (`p=quarantine`/`p=reject`) should not be relaxed back to
4618
4809
  * Lettermint's own `p=none` default.
4619
4810
  */
4620
- async ensureLettermintDmarcRecord(cf, zoneId, record) {
4621
- const hasDmarc = (await cf.findDnsRecord(zoneId, record.name, 'TXT')).some((r) => txtRecordTag(r.content) === 'v=dmarc1');
4811
+ async ensureLettermintDmarcRecord(dns, zoneId, record) {
4812
+ const hasDmarc = (await dns.findDnsRecord(zoneId, record.name, 'TXT')).some((r) => txtRecordTag(r.content) === 'v=dmarc1');
4622
4813
  if (hasDmarc)
4623
4814
  return;
4624
- await cf.createDnsRecord(zoneId, {
4815
+ await dns.createDnsRecord(zoneId, {
4625
4816
  type: 'TXT',
4626
4817
  name: record.name,
4627
4818
  content: record.value,
@@ -4748,7 +4939,7 @@ export class FoundryStepExecutor {
4748
4939
  await ai.setOrganisationOffers(org.id, action.desired.offers ?? []);
4749
4940
  return { resourceId: provisioned.organisationId };
4750
4941
  }
4751
- async aiBudget(action, org) {
4942
+ async aiBudget(action, org, context) {
4752
4943
  // #484: ai/packages/sdk/src/types.ts:134-137's UpdateBudget.limitMicros
4753
4944
  // is a required string -- there is no "uncapped" this API can express
4754
4945
  // yet (Finding, this campaign's report) -- so a null `desired.limitMicros`
@@ -4759,6 +4950,10 @@ export class FoundryStepExecutor {
4759
4950
  const limitMicros = action.desired.limitMicros ?? DEFAULT_AI_LIMIT_MICROS;
4760
4951
  const ai = await this.aiPort();
4761
4952
  await ai.updateBudget(org.id, { keyId: null, limitMicros });
4953
+ // #515: recorded only once `updateBudget` has actually succeeded, so a
4954
+ // crash or a thrown call before this line leaves nothing recorded and
4955
+ // the step replans, rather than recording a limit that was never applied.
4956
+ context.recordScopeId(AI_BUDGET_LIMIT_SCOPE_KEY, limitMicros);
4762
4957
  return { resourceId: org.id };
4763
4958
  }
4764
4959
  /**
@@ -5018,7 +5213,7 @@ export class FoundryStepExecutor {
5018
5213
  throw error;
5019
5214
  }
5020
5215
  }
5021
- const appId = recorded[`coolify:app:${action.key}`];
5216
+ const appId = recorded[edgeKeyAppRecordKey(org, entryKey)];
5022
5217
  if (appId) {
5023
5218
  // A revoked key's env values are dead, not merely stale -- remove
5024
5219
  // them so nothing reads a credential edge has already revoked. A
@@ -5052,10 +5247,14 @@ export class FoundryStepExecutor {
5052
5247
  context.recordScopeId(entryEdgeRetiredScopeKey(entryKey), keyId);
5053
5248
  return {};
5054
5249
  }
5055
- const entry = org.hosting.entries.find((e) => e.key === entryKey && e.uses?.edge);
5056
- if (!entry)
5250
+ if (entryKey === BOULE_EDGE_ENTRY_KEY) {
5251
+ if (!org.domain)
5252
+ throw new Error(`${at}: Boule's edge key needs org.domain`);
5253
+ }
5254
+ else if (!org.hosting.entries.some((e) => e.key === entryKey && e.uses?.edge)) {
5057
5255
  throw new Error(`${at}: the entry no longer declares uses.edge`);
5058
- const appId = context.recordedIds()[`coolify:app:${action.key}`];
5256
+ }
5257
+ const appId = context.recordedIds()[edgeKeyAppRecordKey(org, entryKey)];
5059
5258
  if (!appId)
5060
5259
  throw new Error(`${at}: the entry's app is not recorded yet`);
5061
5260
  if (!org.domain) {
@@ -5493,15 +5692,15 @@ export class FoundryStepExecutor {
5493
5692
  }
5494
5693
  for (const [dnsDomain, dnsHostnames] of hostnamesByDomain) {
5495
5694
  try {
5496
- const zone = await this.ports.cloudflare.findZoneByApex(dnsDomain);
5695
+ const zone = await this.zonePort().findZoneByApex(dnsDomain);
5497
5696
  if (zone) {
5498
- const cf = this.ports.cloudflare;
5697
+ const dns = this.dnsPort();
5499
5698
  const records = dnsHostnames.length > 0
5500
- ? (await Promise.all(dnsHostnames.map((hostname) => cf.findDnsRecord(zone.id, hostname, 'CNAME')))).flat()
5501
- : await cf.listDnsRecords(zone.id, { type: 'CNAME' });
5699
+ ? (await Promise.all(dnsHostnames.map((hostname) => dns.findDnsRecord(zone.name, hostname, 'CNAME')))).flat()
5700
+ : await dns.listDnsRecords(zone.name, { type: 'CNAME' });
5502
5701
  for (const record of records) {
5503
5702
  if (dnsTargets.has(record.content)) {
5504
- await cf.deleteDnsRecord(zone.id, record.id);
5703
+ await dns.deleteDnsRecord(zone.name, record.id);
5505
5704
  }
5506
5705
  }
5507
5706
  }
@@ -5511,11 +5710,11 @@ export class FoundryStepExecutor {
5511
5710
  throw error;
5512
5711
  }
5513
5712
  }
5514
- await this.ports.cloudflare.deleteTunnel(target.id);
5713
+ await this.tunnelPort().deleteTunnel(target.id);
5515
5714
  return;
5516
5715
  }
5517
5716
  case 'cloudflare:access':
5518
- await this.ports.cloudflare.deleteAccessApp(target.id);
5717
+ await this.accessPort().deleteAccessApp(target.id);
5519
5718
  return;
5520
5719
  case 'ai:key': {
5521
5720
  // F6a: an entry's key (`<orgId>:<entry>`). REVOKE SEAM (ai#157): a