@wtfalch/foundry 0.19.1 → 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.
@@ -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
  /**
@@ -3934,6 +3946,87 @@ export class FoundryStepExecutor {
3934
3946
  throw new Error('ai: this executor was built without an ai port (#177)');
3935
3947
  return port;
3936
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
+ }
3937
4030
  async jobsPort(org) {
3938
4031
  const port = this.ports.jobsScheduler ?? (await this.ports.platformOrg?.jobs());
3939
4032
  if (!port) {
@@ -4002,7 +4095,7 @@ export class FoundryStepExecutor {
4002
4095
  }
4003
4096
  // Cleared, not deleted (#7 corrections 1): an ingress with only the
4004
4097
  // mandatory catch-all routes nothing.
4005
- await this.ports.cloudflare.putTunnelConfig(tunnelId, {
4098
+ await this.tunnelPort().putTunnelConfig(tunnelId, {
4006
4099
  ingress: [{ service: 'http_status:404' }],
4007
4100
  });
4008
4101
  // #235: park stops the connector too -- an ingress with nothing
@@ -4028,8 +4121,9 @@ export class FoundryStepExecutor {
4028
4121
  // domain declared still leaves `destroy` a way to recognise the old
4029
4122
  // id's CNAMEs as this tunnel's own rather than a foreign record.
4030
4123
  const recordedTunnelId = context.recordedIds()[`cloudflare:tunnel:${action.key}`];
4031
- const existing = await this.ports.cloudflare.findTunnelByName(tunnelName);
4032
- 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));
4033
4127
  const previousIds = parseTunnelPreviousIds(context.recordedIds());
4034
4128
  if (recordedTunnelId && recordedTunnelId !== tunnel.id) {
4035
4129
  previousIds.add(recordedTunnelId);
@@ -4056,7 +4150,7 @@ export class FoundryStepExecutor {
4056
4150
  hostname,
4057
4151
  service: COOLIFY_PROXY_ORIGIN,
4058
4152
  }));
4059
- await this.ports.cloudflare.putTunnelConfig(tunnel.id, {
4153
+ await this.tunnelPort().putTunnelConfig(tunnel.id, {
4060
4154
  ingress: [...ingress, { service: 'http_status:404' }],
4061
4155
  });
4062
4156
  // #235: a connector for this org's own tunnel, the same call every
@@ -4070,7 +4164,7 @@ export class FoundryStepExecutor {
4070
4164
  const { environment } = await this.ports.coolify.findOrCreateEnvironment(projectUuid, 'production');
4071
4165
  await ensureCloudflaredConnector({
4072
4166
  coolify: this.ports.coolify,
4073
- cloudflare: this.ports.cloudflare,
4167
+ tunnels: this.tunnelPort(),
4074
4168
  tunnelId: tunnel.id,
4075
4169
  appName: `${org.slug}-cloudflared`,
4076
4170
  projectUuid,
@@ -4201,7 +4295,7 @@ export class FoundryStepExecutor {
4201
4295
  * nothing of this domain's left in that set either way.
4202
4296
  */
4203
4297
  async deleteStaleTunnelDnsRecords(tunnelId, previousIds, previousDomain, previousHostnames, context) {
4204
- const zone = await this.ports.cloudflare.findZoneByApex(previousDomain);
4298
+ const zone = await this.zonePort().findZoneByApex(previousDomain);
4205
4299
  if (!zone)
4206
4300
  return;
4207
4301
  const dnsTargets = new Set([`${tunnelId}.cfargotunnel.com`]);
@@ -4209,13 +4303,13 @@ export class FoundryStepExecutor {
4209
4303
  if (id !== tunnelId)
4210
4304
  dnsTargets.add(`${id}.cfargotunnel.com`);
4211
4305
  }
4212
- const cf = this.ports.cloudflare;
4306
+ const dns = this.dnsPort();
4213
4307
  const records = previousHostnames.length > 0
4214
- ? (await Promise.all(previousHostnames.map((hostname) => cf.findDnsRecord(zone.id, hostname, 'CNAME')))).flat()
4215
- : 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' });
4216
4310
  for (const record of records) {
4217
4311
  if (dnsTargets.has(record.content)) {
4218
- await cf.deleteDnsRecord(zone.id, record.id);
4312
+ await dns.deleteDnsRecord(zone.name, record.id);
4219
4313
  }
4220
4314
  }
4221
4315
  if (previousHostnames.length > 0) {
@@ -4283,8 +4377,8 @@ export class FoundryStepExecutor {
4283
4377
  * both read this set.
4284
4378
  */
4285
4379
  async publishTunnelDnsRecords(org, domain, tunnelId, previousIds, previousHostnames, context) {
4286
- const cf = this.ports.cloudflare;
4287
- const zone = await cf.findZoneByApex(domain);
4380
+ const dns = this.dnsPort();
4381
+ const zone = await this.zonePort().findZoneByApex(domain);
4288
4382
  if (!zone) {
4289
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`);
4290
4384
  }
@@ -4296,14 +4390,14 @@ export class FoundryStepExecutor {
4296
4390
  const toCreate = [];
4297
4391
  const toRepoint = [];
4298
4392
  for (const hostname of hostnames) {
4299
- const [a] = await cf.findDnsRecord(zone.id, hostname, 'A');
4300
- 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');
4301
4395
  const foreign = a ?? aaaa;
4302
4396
  if (foreign) {
4303
4397
  conflicts.push(`${hostname} already has a ${foreign.type} record`);
4304
4398
  continue;
4305
4399
  }
4306
- const [cname] = await cf.findDnsRecord(zone.id, hostname, 'CNAME');
4400
+ const [cname] = await dns.findDnsRecord(zone.name, hostname, 'CNAME');
4307
4401
  if (cname && cname.content !== dnsTarget) {
4308
4402
  if (previousTargets.has(cname.content)) {
4309
4403
  toRepoint.push({ hostname, recordId: cname.id });
@@ -4335,7 +4429,7 @@ export class FoundryStepExecutor {
4335
4429
  for (const hostname of settled)
4336
4430
  recordTracked(hostname);
4337
4431
  for (const hostname of toCreate) {
4338
- await cf.createDnsRecord(zone.id, {
4432
+ await dns.createDnsRecord(zone.name, {
4339
4433
  type: 'CNAME',
4340
4434
  name: hostname,
4341
4435
  content: dnsTarget,
@@ -4344,7 +4438,7 @@ export class FoundryStepExecutor {
4344
4438
  recordTracked(hostname);
4345
4439
  }
4346
4440
  for (const { hostname, recordId } of toRepoint) {
4347
- await cf.updateDnsRecord(zone.id, recordId, {
4441
+ await dns.updateDnsRecord(zone.name, recordId, {
4348
4442
  type: 'CNAME',
4349
4443
  name: hostname,
4350
4444
  content: dnsTarget,
@@ -4363,9 +4457,9 @@ export class FoundryStepExecutor {
4363
4457
  // tracking under `domain`, so it is dropped from the shared set either
4364
4458
  // way.
4365
4459
  for (const hostname of droppedHostnames) {
4366
- const [cname] = await cf.findDnsRecord(zone.id, hostname, 'CNAME');
4460
+ const [cname] = await dns.findDnsRecord(zone.name, hostname, 'CNAME');
4367
4461
  if (cname && (cname.content === dnsTarget || previousTargets.has(cname.content))) {
4368
- await cf.deleteDnsRecord(zone.id, cname.id);
4462
+ await dns.deleteDnsRecord(zone.name, cname.id);
4369
4463
  }
4370
4464
  tracked.delete(trackedTunnelDnsEntry(domain, hostname));
4371
4465
  context.recordScopeId(CLOUDFLARE_TUNNEL_DNS_TRACKED_SCOPE_KEY, trackedTunnelDnsState(tracked));
@@ -4399,7 +4493,7 @@ export class FoundryStepExecutor {
4399
4493
  */
4400
4494
  async cloudflareZone(action, org, context) {
4401
4495
  const domain = action.key;
4402
- const cf = this.ports.cloudflare;
4496
+ const cf = this.zonePort();
4403
4497
  const existing = await cf.findZoneByApex(domain);
4404
4498
  if (existing) {
4405
4499
  const owner = await this.ports.declaredOrgStore.findOrgIdForZone(existing.id);
@@ -4600,12 +4694,12 @@ export class FoundryStepExecutor {
4600
4694
  * publishes it.
4601
4695
  */
4602
4696
  async publishMailDnsRecords(org, result) {
4603
- const zone = await this.ports.cloudflare.findZoneByApex(result.domain);
4697
+ const zone = await this.zonePort().findZoneByApex(result.domain);
4604
4698
  if (!zone) {
4605
4699
  throw new Error(`coolify:mail for ${org.slug}: no Cloudflare zone found for ${result.domain}`);
4606
4700
  }
4607
4701
  for (const record of result.records) {
4608
- await this.upsertMailDnsRecord(zone.id, record);
4702
+ await this.upsertMailDnsRecord(zone.name, record);
4609
4703
  }
4610
4704
  }
4611
4705
  /**
@@ -4619,7 +4713,7 @@ export class FoundryStepExecutor {
4619
4713
  * a record someone proxied by hand is put back. An `MX` record carries the
4620
4714
  * priority it was given, which Cloudflare requires.
4621
4715
  *
4622
- * Found by an exact name+type match (`CloudflarePort.findDnsRecord`'s own
4716
+ * Found by an exact name+type match (`DnsPort.findDnsRecord`'s own
4623
4717
  * doc comment) so a stale record of a different type is never taken for a
4624
4718
  * match. A name can hold several `TXT` records (an apex often holds a site
4625
4719
  * verification beside SPF), so a `TXT` record only replaces one that
@@ -4628,8 +4722,8 @@ export class FoundryStepExecutor {
4628
4722
  * whatever it was.
4629
4723
  */
4630
4724
  async upsertMailDnsRecord(zoneId, record) {
4631
- const cf = this.ports.cloudflare;
4632
- 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);
4633
4727
  const existing = record.type === 'TXT'
4634
4728
  ? found.find((r) => txtRecordTag(r.content) === txtRecordTag(record.value))
4635
4729
  : found[0];
@@ -4641,10 +4735,10 @@ export class FoundryStepExecutor {
4641
4735
  ...(record.priority === undefined ? {} : { priority: record.priority }),
4642
4736
  };
4643
4737
  if (existing) {
4644
- await cf.updateDnsRecord(zoneId, existing.id, rec);
4738
+ await dns.updateDnsRecord(zoneId, existing.id, rec);
4645
4739
  }
4646
4740
  else {
4647
- await cf.createDnsRecord(zoneId, rec);
4741
+ await dns.createDnsRecord(zoneId, rec);
4648
4742
  }
4649
4743
  }
4650
4744
  /**
@@ -4700,13 +4794,13 @@ export class FoundryStepExecutor {
4700
4794
  * `dkim_secondary`, none of them SPF -- so that merge has no case left to
4701
4795
  * handle here and is dropped rather than kept as dead code.
4702
4796
  */
4703
- async publishLettermintDnsRecords(zoneId, records) {
4797
+ async publishLettermintDnsRecords(zone, records) {
4704
4798
  for (const record of records) {
4705
4799
  if (record.type === 'TXT' && record.value.toLowerCase().startsWith('v=dmarc1')) {
4706
- await this.ensureLettermintDmarcRecord(this.ports.cloudflare, zoneId, record);
4800
+ await this.ensureLettermintDmarcRecord(this.dnsPort(), zone, record);
4707
4801
  continue;
4708
4802
  }
4709
- await this.upsertMailDnsRecord(zoneId, record);
4803
+ await this.upsertMailDnsRecord(zone, record);
4710
4804
  }
4711
4805
  }
4712
4806
  /**
@@ -4714,11 +4808,11 @@ export class FoundryStepExecutor {
4714
4808
  * already set (`p=quarantine`/`p=reject`) should not be relaxed back to
4715
4809
  * Lettermint's own `p=none` default.
4716
4810
  */
4717
- async ensureLettermintDmarcRecord(cf, zoneId, record) {
4718
- 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');
4719
4813
  if (hasDmarc)
4720
4814
  return;
4721
- await cf.createDnsRecord(zoneId, {
4815
+ await dns.createDnsRecord(zoneId, {
4722
4816
  type: 'TXT',
4723
4817
  name: record.name,
4724
4818
  content: record.value,
@@ -4845,7 +4939,7 @@ export class FoundryStepExecutor {
4845
4939
  await ai.setOrganisationOffers(org.id, action.desired.offers ?? []);
4846
4940
  return { resourceId: provisioned.organisationId };
4847
4941
  }
4848
- async aiBudget(action, org) {
4942
+ async aiBudget(action, org, context) {
4849
4943
  // #484: ai/packages/sdk/src/types.ts:134-137's UpdateBudget.limitMicros
4850
4944
  // is a required string -- there is no "uncapped" this API can express
4851
4945
  // yet (Finding, this campaign's report) -- so a null `desired.limitMicros`
@@ -4856,6 +4950,10 @@ export class FoundryStepExecutor {
4856
4950
  const limitMicros = action.desired.limitMicros ?? DEFAULT_AI_LIMIT_MICROS;
4857
4951
  const ai = await this.aiPort();
4858
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);
4859
4957
  return { resourceId: org.id };
4860
4958
  }
4861
4959
  /**
@@ -5115,7 +5213,7 @@ export class FoundryStepExecutor {
5115
5213
  throw error;
5116
5214
  }
5117
5215
  }
5118
- const appId = recorded[`coolify:app:${action.key}`];
5216
+ const appId = recorded[edgeKeyAppRecordKey(org, entryKey)];
5119
5217
  if (appId) {
5120
5218
  // A revoked key's env values are dead, not merely stale -- remove
5121
5219
  // them so nothing reads a credential edge has already revoked. A
@@ -5149,10 +5247,14 @@ export class FoundryStepExecutor {
5149
5247
  context.recordScopeId(entryEdgeRetiredScopeKey(entryKey), keyId);
5150
5248
  return {};
5151
5249
  }
5152
- const entry = org.hosting.entries.find((e) => e.key === entryKey && e.uses?.edge);
5153
- 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)) {
5154
5255
  throw new Error(`${at}: the entry no longer declares uses.edge`);
5155
- const appId = context.recordedIds()[`coolify:app:${action.key}`];
5256
+ }
5257
+ const appId = context.recordedIds()[edgeKeyAppRecordKey(org, entryKey)];
5156
5258
  if (!appId)
5157
5259
  throw new Error(`${at}: the entry's app is not recorded yet`);
5158
5260
  if (!org.domain) {
@@ -5590,15 +5692,15 @@ export class FoundryStepExecutor {
5590
5692
  }
5591
5693
  for (const [dnsDomain, dnsHostnames] of hostnamesByDomain) {
5592
5694
  try {
5593
- const zone = await this.ports.cloudflare.findZoneByApex(dnsDomain);
5695
+ const zone = await this.zonePort().findZoneByApex(dnsDomain);
5594
5696
  if (zone) {
5595
- const cf = this.ports.cloudflare;
5697
+ const dns = this.dnsPort();
5596
5698
  const records = dnsHostnames.length > 0
5597
- ? (await Promise.all(dnsHostnames.map((hostname) => cf.findDnsRecord(zone.id, hostname, 'CNAME')))).flat()
5598
- : 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' });
5599
5701
  for (const record of records) {
5600
5702
  if (dnsTargets.has(record.content)) {
5601
- await cf.deleteDnsRecord(zone.id, record.id);
5703
+ await dns.deleteDnsRecord(zone.name, record.id);
5602
5704
  }
5603
5705
  }
5604
5706
  }
@@ -5608,11 +5710,11 @@ export class FoundryStepExecutor {
5608
5710
  throw error;
5609
5711
  }
5610
5712
  }
5611
- await this.ports.cloudflare.deleteTunnel(target.id);
5713
+ await this.tunnelPort().deleteTunnel(target.id);
5612
5714
  return;
5613
5715
  }
5614
5716
  case 'cloudflare:access':
5615
- await this.ports.cloudflare.deleteAccessApp(target.id);
5717
+ await this.accessPort().deleteAccessApp(target.id);
5616
5718
  return;
5617
5719
  case 'ai:key': {
5618
5720
  // F6a: an entry's key (`<orgId>:<entry>`). REVOKE SEAM (ai#157): a
@@ -5,7 +5,7 @@ export { computePlanDetail, toPlan } from './diff.js';
5
5
  export type { StepSystem } from './step-system.js';
6
6
  export { stepSystemOf } from './step-system.js';
7
7
  export type { PlatformPlanDetail } from './platform-diff.js';
8
- export { planPlatform, toPlatformPlan, JOBS_SCHEDULER_KEY, PLATFORM_AI_KEY, PLATFORM_AI_INTERNAL_URL_SCOPE_KEY, PLATFORM_AI_MANAGEMENT_KEY_SCOPE_KEY, PLATFORM_AI_ORGANISATION_SCOPE_KEY, PLATFORM_AUTH_KEY, PLATFORM_FILES_KEY, PLATFORM_FILES_INTERNAL_URL_SCOPE_KEY, PLATFORM_FILES_ZITADEL_PROJECT_SCOPE_KEY, PLATFORM_MAIL_KEY, PLATFORM_MAIL_INTERNAL_URL_SCOPE_KEY, PLATFORM_MAIL_ZITADEL_PROJECT_SCOPE_KEY, PLATFORM_EDGE_KEY, PLATFORM_EDGE_KEY_REF, PLATFORM_EDGE_CLOUDFLARE_TOKEN_REF, } from './platform-diff.js';
8
+ export { planPlatform, toPlatformPlan, JOBS_SCHEDULER_KEY, PLATFORM_AI_KEY, PLATFORM_AI_INTERNAL_URL_SCOPE_KEY, PLATFORM_AI_MANAGEMENT_KEY_SCOPE_KEY, PLATFORM_AI_ORGANISATION_SCOPE_KEY, PLATFORM_AUTH_KEY, PLATFORM_FILES_KEY, PLATFORM_FILES_INTERNAL_URL_SCOPE_KEY, PLATFORM_FILES_ZITADEL_PROJECT_SCOPE_KEY, PLATFORM_MAIL_KEY, PLATFORM_MAIL_INTERNAL_URL_SCOPE_KEY, PLATFORM_MAIL_ZITADEL_PROJECT_SCOPE_KEY, PLATFORM_EDGE_KEY, PLATFORM_EDGE_INTERNAL_URL_SCOPE_KEY, PLATFORM_EDGE_KEY_REF, PLATFORM_EDGE_CLOUDFLARE_TOKEN_REF, } from './platform-diff.js';
9
9
  export type { HeldKeyResolver, OrgPlatformReadiness } from './platform-org.js';
10
10
  export { DEFAULT_ORG_PLATFORM_READINESS, ORG_PLATFORM_KEY_REFS, agoraHostValuesFromPlatform, heldPlatformKeyRefs, orgPlatformReadinessFrom, } from './platform-org.js';
11
11
  export type { PlatformOrgClientFactories } from './platform-org-ports.js';
@@ -15,7 +15,7 @@ export { AdoptRefusedError } from './adopt.js';
15
15
  export type { AuditActor, AuditLog, AuthzGate, StepExecutor, StepDestroyer, DestroyTarget, StepExecutionContext, PlatformStepExecutor, PlatformStepExecutionContext, FoundryPlanApplyOptions, } from './plan-apply.js';
16
16
  export { FoundryPlanApply, DeclaredOrgNotFoundError, StalePlanError, ConfirmationRequiredError, NotAuthorizedError, NotATestOrgError, DeclaredPlatformNotFoundError, PlatformStalePlanError, PlatformConfirmationRequiredError, planNeedsConfirmation, } from './plan-apply.js';
17
17
  export { FoundryPlatformStepExecutor, ProvidersPlatformCloudflareReader, ProvidersPlatformCoolifyReader, PLATFORM_MAIL_ADMIN_KEY_DESCRIPTION, filesPublicHostsValue, filesShareDomains, } from './platform-providers.js';
18
- export type { PlatformAuthDeps, PlatformAuthRuntime, PlatformFilesDeps, PlatformFilesRuntime, PlatformEdgeDeps, PlatformEdgeRuntime, } from './platform-providers.js';
18
+ export type { PlatformAuthDeps, PlatformAuthRuntime, PlatformFilesDeps, PlatformFilesRuntime, PlatformEdgeDeps, PlatformDnsFactory, PlatformAccessFactory, PlatformAccessPort, PlatformTunnelsFactory, PlatformEdgeRuntime, } from './platform-providers.js';
19
19
  export type { PlatformMailDeps, PlatformMailAdminPort, PlatformMailRuntime, } from './platform-providers.js';
20
20
  export { FILES_INTROSPECTION_SECRET_REF, FILES_MANAGEMENT_ISSUER, FILES_MANAGEMENT_KEY_ID, FILES_MANAGEMENT_KEY_REF, PLATFORM_AUTH_INSTANCE_BEARER_REF, } from './platform-providers.js';
21
21
  export { AI_MANAGEMENT_ISSUER, AI_MANAGEMENT_KEY_ID, AI_SIGNING_KEY_REF, AiManagementKeyExistsError, createAiManagementKeyMinter, PLATFORM_AI_MANAGEMENT_KEY_REF, PLATFORM_AI_MANAGEMENT_PERMISSIONS, } from './platform-providers.js';
@@ -27,7 +27,7 @@ export { MissingCallsSfuConnectionError, applyCallsSfuPlatformStep } from './pla
27
27
  export type { MailCopyJob, MailCopyPort, MailCopyStepResult } from './mail-copy.js';
28
28
  export { MAIL_COPY_COMPLETED_SCOPE_KEY, MAIL_COPY_DONE_SCOPE_KEY, MissingMailCopyAccountsError, MissingMailCopySourceTokenError, MissingMailCopyStalwartTokenError, parseMailCopyCompleted, runMailCopyStep, } from './mail-copy.js';
29
29
  export type { ScopeResolver, RecordedIds } from './providers-readers.js';
30
- export { ProvidersCloudflareReader, ProvidersCmsReader, ProvidersCoolifyReader, ProvidersZitadelReader, storeScopeResolver, storeRecordedIds, } from './providers-readers.js';
30
+ export { ProvidersAiReader, ProvidersCloudflareReader, ProvidersCmsReader, ProvidersCoolifyReader, ProvidersZitadelReader, storeScopeResolver, storeRecordedIds, } from './providers-readers.js';
31
31
  export type { AgoraHostPort, AgoraHostValues } from './agora.js';
32
32
  export type { BrokerPort } from './broker-entries.js';
33
33
  export { PlatformBrokerPort } from './broker-entries.js';
@@ -39,6 +39,18 @@ export type { MailAllowlistApp } from './mail-identity-allowlist.js';
39
39
  export { checkMailAppsAllowlist } from './mail-identity-allowlist.js';
40
40
  export type { CoolifyPort, CloudflarePort, ZitadelPort, CallsSfuPort, JobsSchedulerPort, FilesOrganisationPort, AiOrganisationPort, FoundryStepExecutorPorts, PlatformOrgPortSource, } from './executor.js';
41
41
  export { FoundryStepExecutor, NotBuiltError, healthUrls } from './executor.js';
42
+ export { createEdgeDns, EDGE_DNS_WRITE_CONCURRENCY } from './edge-dns.js';
43
+ export type { DnsPort, DnsRecordInput, DnsRecordSummary, EdgePlatformDns } from './edge-dns.js';
44
+ export { createEdgeAccess } from './edge-access.js';
45
+ export type { AccessAppSummary, AccessDecision, AccessPolicyRule, AccessPort, EdgePlatformAccess, } from './edge-access.js';
46
+ export { createEdgeWorkers } from './edge-workers.js';
47
+ export type { EdgePlatformWorkers, EdgeWorkersOptions } from './edge-workers.js';
48
+ export { createEdgeRateLimits } from './edge-ratelimits.js';
49
+ export type { EdgePlatformRateLimits, EdgeRateLimitsOptions, RateLimitPort, RateLimitRuleInput, } from './edge-ratelimits.js';
50
+ export { createEdgeZones } from './edge-zones.js';
51
+ export type { EdgePlatformZones, EdgeZonesOptions, ZonePort } from './edge-zones.js';
52
+ export { createEdgeTunnels } from './edge-tunnels.js';
53
+ export type { EdgePlatformTunnels, TunnelIngress, TunnelPort, TunnelSummary, } from './edge-tunnels.js';
42
54
  export type { ZitadelListAppsRawApp } from './executor.js';
43
55
  export { adaptZitadelListApps } from './executor.js';
44
56
  export { HttpJobsSchedulerClient } from './jobs-scheduler-http.js';
@@ -1,7 +1,7 @@
1
1
  export { readLiveResources, readLivePlatformResources } from './live.js';
2
2
  export { computePlanDetail, toPlan } from './diff.js';
3
3
  export { stepSystemOf } from './step-system.js';
4
- export { planPlatform, toPlatformPlan, JOBS_SCHEDULER_KEY, PLATFORM_AI_KEY, PLATFORM_AI_INTERNAL_URL_SCOPE_KEY, PLATFORM_AI_MANAGEMENT_KEY_SCOPE_KEY, PLATFORM_AI_ORGANISATION_SCOPE_KEY, PLATFORM_AUTH_KEY, PLATFORM_FILES_KEY, PLATFORM_FILES_INTERNAL_URL_SCOPE_KEY, PLATFORM_FILES_ZITADEL_PROJECT_SCOPE_KEY, PLATFORM_MAIL_KEY, PLATFORM_MAIL_INTERNAL_URL_SCOPE_KEY, PLATFORM_MAIL_ZITADEL_PROJECT_SCOPE_KEY, PLATFORM_EDGE_KEY, PLATFORM_EDGE_KEY_REF, PLATFORM_EDGE_CLOUDFLARE_TOKEN_REF, } from './platform-diff.js';
4
+ export { planPlatform, toPlatformPlan, JOBS_SCHEDULER_KEY, PLATFORM_AI_KEY, PLATFORM_AI_INTERNAL_URL_SCOPE_KEY, PLATFORM_AI_MANAGEMENT_KEY_SCOPE_KEY, PLATFORM_AI_ORGANISATION_SCOPE_KEY, PLATFORM_AUTH_KEY, PLATFORM_FILES_KEY, PLATFORM_FILES_INTERNAL_URL_SCOPE_KEY, PLATFORM_FILES_ZITADEL_PROJECT_SCOPE_KEY, PLATFORM_MAIL_KEY, PLATFORM_MAIL_INTERNAL_URL_SCOPE_KEY, PLATFORM_MAIL_ZITADEL_PROJECT_SCOPE_KEY, PLATFORM_EDGE_KEY, PLATFORM_EDGE_INTERNAL_URL_SCOPE_KEY, PLATFORM_EDGE_KEY_REF, PLATFORM_EDGE_CLOUDFLARE_TOKEN_REF, } from './platform-diff.js';
5
5
  export { DEFAULT_ORG_PLATFORM_READINESS, ORG_PLATFORM_KEY_REFS, agoraHostValuesFromPlatform, heldPlatformKeyRefs, orgPlatformReadinessFrom, } from './platform-org.js';
6
6
  export { PlatformOrgBlockedError, PlatformOrgPorts } from './platform-org-ports.js';
7
7
  export { AdoptRefusedError } from './adopt.js';
@@ -14,11 +14,17 @@ export { createZitadelAdminMinter } from './zitadel-machine-user.js';
14
14
  export { createZitadelProjectRoles } from './zitadel-project-roles.js';
15
15
  export { MissingCallsSfuConnectionError, applyCallsSfuPlatformStep } from './platform-sfu.js';
16
16
  export { MAIL_COPY_COMPLETED_SCOPE_KEY, MAIL_COPY_DONE_SCOPE_KEY, MissingMailCopyAccountsError, MissingMailCopySourceTokenError, MissingMailCopyStalwartTokenError, parseMailCopyCompleted, runMailCopyStep, } from './mail-copy.js';
17
- export { ProvidersCloudflareReader, ProvidersCmsReader, ProvidersCoolifyReader, ProvidersZitadelReader, storeScopeResolver, storeRecordedIds, } from './providers-readers.js';
17
+ export { ProvidersAiReader, ProvidersCloudflareReader, ProvidersCmsReader, ProvidersCoolifyReader, ProvidersZitadelReader, storeScopeResolver, storeRecordedIds, } from './providers-readers.js';
18
18
  export { PlatformBrokerPort } from './broker-entries.js';
19
19
  export { loginHostnameFor } from './login-host.js';
20
20
  export { checkMailIdentityAudience } from './mail-identity.js';
21
21
  export { checkMailAppsAllowlist } from './mail-identity-allowlist.js';
22
22
  export { FoundryStepExecutor, NotBuiltError, healthUrls } from './executor.js';
23
+ export { createEdgeDns, EDGE_DNS_WRITE_CONCURRENCY } from './edge-dns.js';
24
+ export { createEdgeAccess } from './edge-access.js';
25
+ export { createEdgeWorkers } from './edge-workers.js';
26
+ export { createEdgeRateLimits } from './edge-ratelimits.js';
27
+ export { createEdgeZones } from './edge-zones.js';
28
+ export { createEdgeTunnels } from './edge-tunnels.js';
23
29
  export { adaptZitadelListApps } from './executor.js';
24
30
  export { HttpJobsSchedulerClient } from './jobs-scheduler-http.js';