@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.
@@ -23,12 +23,13 @@ export const DEFAULT_GIT_REF = 'main';
23
23
  */
24
24
  export const DEFAULT_MAIL_IMAGE_TAG = VERSIONS.stalwart;
25
25
  /**
26
- * Every platform service except `sfu` and `edge` is enabled, like the other
27
- * steps: a service whose dependencies the host did not supply is reported
28
- * blocked, not planned. `sfu` stays off because it needs a held SSH key and
29
- * host fingerprint, a separate human step (issue 170). `edge` stays off
30
- * because the edge service (N1) does not exist yet -- a human deploys it
31
- * and sets its hostname before enabling it (#317's own "Human steps").
26
+ * Every platform service except `sfu` is enabled, like the other steps: a
27
+ * service whose dependencies the host did not supply is reported blocked,
28
+ * not planned. `sfu` stays off because it needs a held SSH key and host
29
+ * fingerprint, a separate human step (issue 170). `edge` is on because every
30
+ * platform DNS record is written through it (F4.dns); its own Cloudflare API
31
+ * token is a held key (`cloudflare:edge:apiToken`), and until a human holds
32
+ * it the planner reports edge blocked, and auth, mail and archon with it.
32
33
  */
33
34
  const SERVICES_ENABLED_BY_DEFAULT = new Set([
34
35
  'auth',
@@ -38,6 +39,7 @@ const SERVICES_ENABLED_BY_DEFAULT = new Set([
38
39
  'jobs',
39
40
  'integrations',
40
41
  'archon',
42
+ 'edge',
41
43
  ]);
42
44
  /**
43
45
  * The owner as `OwnerIdentity`, trimmed. Throws a plain error naming the
package/dist/index.d.ts CHANGED
@@ -20,7 +20,9 @@ export type { FoundryStoreDb } from './store/postgres.js';
20
20
  export { declaredOrgs, foundryAppliedState, foundryAppliedVersions, foundryAuditEvents, foundryDeclaredPlatform, foundryRequestedChanges, type AuditEventRow, type DeclaredOrgRow, type NewDeclaredOrgRow, type AppliedStateRow, type NewAppliedStateRow, type AppliedVersionRow, type NewAppliedVersionRow, type DeclaredPlatformRow, type NewDeclaredPlatformRow, type RequestedChangeRow, type NewRequestedChangeRow, } from './store/schema.js';
21
21
  export type { LiveResource, ZitadelReader, CoolifyReader, CloudflareReader, FilesReader, AiReader, CmsReader, LiveReaders, PlatformCloudflareReader, PlatformCoolifyReader, PlatformLiveReaders, StepAction, PlannedStep, StepSystem, PlanDetail, PlatformPlanDetail, AdoptCandidate, AdoptInput, AdoptPlan, AdoptSnapshot, AuditActor, AuditLog, AuthzGate, StepExecutor, StepDestroyer, DestroyTarget, StepExecutionContext, PlatformStepExecutor, PlatformStepExecutionContext, FoundryPlanApplyOptions, } from './plan/index.js';
22
22
  export { AdoptRefusedError } from './plan/index.js';
23
- export { readLiveResources, readLivePlatformResources, computePlanDetail, toPlan, 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_FILES_KEY, PLATFORM_FILES_INTERNAL_URL_SCOPE_KEY, PLATFORM_FILES_ZITADEL_PROJECT_SCOPE_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, FoundryPlanApply, DeclaredOrgNotFoundError, StalePlanError, ConfirmationRequiredError, NotAuthorizedError, NotATestOrgError, DeclaredPlatformNotFoundError, PlatformStalePlanError, PlatformConfirmationRequiredError, planNeedsConfirmation, } from './plan/index.js';
23
+ export { createEdgeAccess, createEdgeDns, createEdgeRateLimits, createEdgeTunnels, createEdgeWorkers, createEdgeZones, } from './plan/index.js';
24
+ export type { AccessAppSummary, AccessDecision, AccessPolicyRule, AccessPort, DnsPort, EdgePlatformAccess, EdgePlatformDns, EdgePlatformRateLimits, EdgePlatformWorkers, EdgePlatformZones, EdgeZonesOptions, ZonePort, EdgeRateLimitsOptions, RateLimitPort, RateLimitRuleInput, EdgeWorkersOptions, EdgePlatformTunnels, TunnelPort, } from './plan/index.js';
25
+ export { readLiveResources, readLivePlatformResources, computePlanDetail, toPlan, 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_FILES_KEY, PLATFORM_FILES_INTERNAL_URL_SCOPE_KEY, PLATFORM_FILES_ZITADEL_PROJECT_SCOPE_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, FoundryPlanApply, DeclaredOrgNotFoundError, StalePlanError, ConfirmationRequiredError, NotAuthorizedError, NotATestOrgError, DeclaredPlatformNotFoundError, PlatformStalePlanError, PlatformConfirmationRequiredError, planNeedsConfirmation, } from './plan/index.js';
24
26
  export type { HeldKeyResolver, OrgPlatformReadiness } from './plan/index.js';
25
27
  export { DEFAULT_ORG_PLATFORM_READINESS, ORG_PLATFORM_KEY_REFS, agoraHostValuesFromPlatform, heldPlatformKeyRefs, orgPlatformReadinessFrom, } from './plan/index.js';
26
28
  export type { PlatformOrgClientFactories } from './plan/index.js';
@@ -29,7 +31,7 @@ export type { CoolifyPort, CloudflarePort, ZitadelPort, CallsSfuPort, JobsSchedu
29
31
  export { FoundryStepExecutor, NotBuiltError, healthUrls, loginHostnameFor } from './plan/index.js';
30
32
  export { HttpJobsSchedulerClient } from './plan/index.js';
31
33
  export type { ScopeResolver, RecordedIds } from './plan/index.js';
32
- export { ProvidersCloudflareReader, ProvidersCmsReader, ProvidersCoolifyReader, ProvidersZitadelReader, storeScopeResolver, storeRecordedIds, } from './plan/index.js';
34
+ export { ProvidersAiReader, ProvidersCloudflareReader, ProvidersCmsReader, ProvidersCoolifyReader, ProvidersZitadelReader, storeScopeResolver, storeRecordedIds, } from './plan/index.js';
33
35
  export type { ZitadelListAppsRawApp } from './plan/index.js';
34
36
  export { adaptZitadelListApps } from './plan/index.js';
35
37
  export type { MailIdentityApp, MailIdentityAudienceCheck } from './plan/index.js';
@@ -37,7 +39,7 @@ export { checkMailIdentityAudience } from './plan/index.js';
37
39
  export type { MailAllowlistApp } from './plan/index.js';
38
40
  export { checkMailAppsAllowlist } from './plan/index.js';
39
41
  export { FoundryPlatformStepExecutor, ProvidersPlatformCloudflareReader, ProvidersPlatformCoolifyReader, createZitadelAdminMinter, createZitadelProjectRoles, PLATFORM_AUTH_KEY, PLATFORM_MAIL_KEY, PLATFORM_MAIL_ADMIN_KEY_DESCRIPTION, } from './plan/index.js';
40
- export type { PlatformAuthDeps, PlatformAuthRuntime, PlatformFilesDeps, PlatformFilesRuntime, PlatformEdgeDeps, PlatformEdgeRuntime, } from './plan/index.js';
42
+ export type { PlatformAuthDeps, PlatformAuthRuntime, PlatformFilesDeps, PlatformFilesRuntime, PlatformEdgeDeps, PlatformDnsFactory, PlatformAccessFactory, PlatformAccessPort, PlatformTunnelsFactory, PlatformEdgeRuntime, } from './plan/index.js';
41
43
  export type { PlatformMailDeps, PlatformMailAdminPort, PlatformMailRuntime, } from './plan/index.js';
42
44
  export { FILES_INTROSPECTION_SECRET_REF, FILES_MANAGEMENT_ISSUER, FILES_MANAGEMENT_KEY_ID, FILES_MANAGEMENT_KEY_REF, PLATFORM_AUTH_INSTANCE_BEARER_REF, } from './plan/index.js';
43
45
  export { AI_MANAGEMENT_ISSUER, AI_MANAGEMENT_KEY_ID, AI_SIGNING_KEY_REF, AiManagementKeyExistsError, createAiManagementKeyMinter, PLATFORM_AI_MANAGEMENT_KEY_REF, PLATFORM_AI_MANAGEMENT_PERMISSIONS, } from './plan/index.js';
package/dist/index.js CHANGED
@@ -12,12 +12,13 @@ export { createPostgresAppliedVersionsStore, createPostgresDeclaredOrgStore, cre
12
12
  export { createPostgresAuditLog } from './store/audit.js';
13
13
  export { declaredOrgs, foundryAppliedState, foundryAppliedVersions, foundryAuditEvents, foundryDeclaredPlatform, foundryRequestedChanges, } from './store/schema.js';
14
14
  export { AdoptRefusedError } from './plan/index.js';
15
- export { readLiveResources, readLivePlatformResources, computePlanDetail, toPlan, 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_FILES_KEY, PLATFORM_FILES_INTERNAL_URL_SCOPE_KEY, PLATFORM_FILES_ZITADEL_PROJECT_SCOPE_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, FoundryPlanApply, DeclaredOrgNotFoundError, StalePlanError, ConfirmationRequiredError, NotAuthorizedError, NotATestOrgError, DeclaredPlatformNotFoundError, PlatformStalePlanError, PlatformConfirmationRequiredError, planNeedsConfirmation, } from './plan/index.js';
15
+ export { createEdgeAccess, createEdgeDns, createEdgeRateLimits, createEdgeTunnels, createEdgeWorkers, createEdgeZones, } from './plan/index.js';
16
+ export { readLiveResources, readLivePlatformResources, computePlanDetail, toPlan, 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_FILES_KEY, PLATFORM_FILES_INTERNAL_URL_SCOPE_KEY, PLATFORM_FILES_ZITADEL_PROJECT_SCOPE_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, FoundryPlanApply, DeclaredOrgNotFoundError, StalePlanError, ConfirmationRequiredError, NotAuthorizedError, NotATestOrgError, DeclaredPlatformNotFoundError, PlatformStalePlanError, PlatformConfirmationRequiredError, planNeedsConfirmation, } from './plan/index.js';
16
17
  export { DEFAULT_ORG_PLATFORM_READINESS, ORG_PLATFORM_KEY_REFS, agoraHostValuesFromPlatform, heldPlatformKeyRefs, orgPlatformReadinessFrom, } from './plan/index.js';
17
18
  export { PlatformOrgBlockedError, PlatformOrgPorts } from './plan/index.js';
18
19
  export { FoundryStepExecutor, NotBuiltError, healthUrls, loginHostnameFor } from './plan/index.js';
19
20
  export { HttpJobsSchedulerClient } from './plan/index.js';
20
- export { ProvidersCloudflareReader, ProvidersCmsReader, ProvidersCoolifyReader, ProvidersZitadelReader, storeScopeResolver, storeRecordedIds, } from './plan/index.js';
21
+ export { ProvidersAiReader, ProvidersCloudflareReader, ProvidersCmsReader, ProvidersCoolifyReader, ProvidersZitadelReader, storeScopeResolver, storeRecordedIds, } from './plan/index.js';
21
22
  export { adaptZitadelListApps } from './plan/index.js';
22
23
  export { checkMailIdentityAudience } from './plan/index.js';
23
24
  export { checkMailAppsAllowlist } from './plan/index.js';
@@ -1,4 +1,5 @@
1
- import type { CloudflarePort, CoolifyPort } from './executor.js';
1
+ import type { TunnelPort } from './edge-tunnels.js';
2
+ import type { CoolifyPort } from './executor.js';
2
3
  /**
3
4
  * #197: the pinned `cloudflared` image, the same release tag as
4
5
  * `scripts/shared-ingress.mjs`'s and `calls-sfu.ts`'s own
@@ -24,7 +25,7 @@ export declare function cloudflaredDockerfile(): string;
24
25
  */
25
26
  export interface EnsureCloudflaredConnectorInput {
26
27
  coolify: Pick<CoolifyPort, 'findApplicationByName' | 'getApplication' | 'createDockerfileApplication' | 'bulkUpsertEnvs' | 'listEnvs' | 'deploy'>;
27
- cloudflare: Pick<CloudflarePort, 'getTunnelToken'>;
28
+ tunnels: Pick<TunnelPort, 'getTunnelToken'>;
28
29
  /** The Cloudflare tunnel this connector dials into (already created and configured by the caller). */
29
30
  tunnelId: string;
30
31
  /** Fixed name, found before created -- so a second apply reuses this app rather than making a second one. */
@@ -32,7 +32,7 @@ export function cloudflaredDockerfile() {
32
32
  * with no cause, because a Coolify error can echo the request body.
33
33
  */
34
34
  export async function ensureCloudflaredConnector(input) {
35
- const { coolify, cloudflare } = input;
35
+ const { coolify, tunnels } = input;
36
36
  const liveStatus = async (uuid) => {
37
37
  try {
38
38
  return { found: true, status: (await coolify.getApplication(uuid)).status };
@@ -82,7 +82,7 @@ export async function ensureCloudflaredConnector(input) {
82
82
  }
83
83
  if (appId !== input.recordedConnectorAppId)
84
84
  await input.recordAppId(appId);
85
- const token = await cloudflare.getTunnelToken(input.tunnelId);
85
+ const token = await tunnels.getTunnelToken(input.tunnelId);
86
86
  const tokenChanged = justCreated || (await tunnelTokenDiffers(coolify, appId, token));
87
87
  if (tokenChanged) {
88
88
  try {
package/dist/plan/diff.js CHANGED
@@ -214,10 +214,6 @@ export function computePlanDetail(org, live, recordedIds = {}, agoraHost = NO_AG
214
214
  const edgeReservedLabels = planEdgeReservedLabels(org, recordedIds, platformReadiness, options.platformHostnames ?? []);
215
215
  if (edgeReservedLabels.step)
216
216
  plannedSteps.push(edgeReservedLabels.step);
217
- // After the apps -- a edge key needs the entry's app recorded, the same
218
- // dependency `planEntryAiKeys`/`planFilesKeys` have.
219
- const entryEdgeKeys = planEntryEdgeKeys(org, recordedIds, plannedSteps, platformReadiness, (options.now ?? Date.now)());
220
- plannedSteps.push(...entryEdgeKeys.steps);
221
217
  const tunnel = planTunnel(org, byKey, claimed, recordedIds);
222
218
  // #238: a tunnel that is being provisioned waits for an active zone; parking
223
219
  // one never does.
@@ -264,6 +260,10 @@ export function computePlanDetail(org, live, recordedIds = {}, agoraHost = NO_AG
264
260
  const boule = planBoule(org, byKey, claimed, recordedIds, authIssuer, options.otherSlugs ?? [], options.archonUrl ?? null);
265
261
  if (boule.step)
266
262
  plannedSteps.push(boule.step);
263
+ // After the apps and Boule -- an edge key needs its app recorded or planned,
264
+ // the same dependency `planEntryAiKeys`/`planFilesKeys` have.
265
+ const entryEdgeKeys = planEntryEdgeKeys(org, recordedIds, plannedSteps, platformReadiness, (options.now ?? Date.now)());
266
+ plannedSteps.push(...entryEdgeKeys.steps);
267
267
  // Boule's own broker entry, after its app: the entry needs the AUTH_APP_KEY
268
268
  // that step puts in the app's env.
269
269
  const bouleBroker = planBouleBrokerEntry(org, recordedIds, plannedSteps, options.broker ?? false);
@@ -0,0 +1,68 @@
1
+ import type { AccessDecision, createEdgeClient } from '@wtfalch/edge';
2
+ import { type WriteSlot } from './edge-dns.js';
3
+ /** The edge SDK's platform Access client: `createEdgeClient({ ... }).platform.access`. */
4
+ export type EdgePlatformAccess = ReturnType<typeof createEdgeClient>['platform']['access'];
5
+ export type { AccessDecision };
6
+ /** One Access rule in Cloudflare's own shape: `{ email: { email } }`, `{ everyone: {} }`, `{ service_token: { token_id } }`. */
7
+ export type AccessPolicyRule = Record<string, unknown>;
8
+ export interface AccessAppSummary {
9
+ /** Cloudflare's app id: what every edge Access call takes. */
10
+ id: string;
11
+ name: string;
12
+ domain: string;
13
+ }
14
+ /**
15
+ * Foundry's Cloudflare Access calls. Every Access call in foundry goes through
16
+ * this; the one implementation is {@link createEdgeAccess}, which speaks to the
17
+ * edge service with the platform credential.
18
+ */
19
+ export interface AccessPort {
20
+ /** Every live Access app foundry may use: the platform's own, and ones nobody adopted yet. */
21
+ listAccessApps(): Promise<AccessAppSummary[]>;
22
+ createAccessApp(app: {
23
+ name: string;
24
+ domain: string;
25
+ type: 'self_hosted';
26
+ }): Promise<{
27
+ id: string;
28
+ }>;
29
+ listAccessPolicies(appId: string): Promise<Array<{
30
+ id: string;
31
+ name: string;
32
+ include?: AccessPolicyRule[];
33
+ }>>;
34
+ createAccessPolicy(appId: string, policy: {
35
+ name: string;
36
+ decision: AccessDecision;
37
+ include: AccessPolicyRule[];
38
+ }): Promise<{
39
+ id: string;
40
+ }>;
41
+ /**
42
+ * A service token's id by exact name -- the token itself is a live
43
+ * Cloudflare resource foundry never mints and whose secret it never reads.
44
+ * `undefined` when no token has that name; throws when more than one does.
45
+ */
46
+ findServiceTokenByName(name: string): Promise<{
47
+ id: string;
48
+ } | undefined>;
49
+ deleteAccessApp(appId: string): Promise<void>;
50
+ }
51
+ /**
52
+ * Edge answers a policy, delete or create call for a live Access app with no
53
+ * edge row `not_found` or 409 `exists` ("create or adopt it first"). So the
54
+ * adapter adopts an app the moment it is about to be used: an app it saw in a
55
+ * list (or that a create refused with `exists`) whose owner edge reported as
56
+ * `null` is remembered, and adopted just before the first call on it. An app
57
+ * foundry recorded in an earlier apply and now names by id only is looked up
58
+ * on `not_found`, adopted if unowned, and the call retried once, so a destroy
59
+ * deletes it instead of finding "nothing there". Adopt is idempotent and the
60
+ * list reports the owner afterwards, so nothing is recorded in the plan. An
61
+ * app with an owner is never adopted or changed. An adopt that edge skips
62
+ * (`taken`, `unsupported_type`) is an error. A `rate_limited` answer is
63
+ * retried with edge's hint, up to 5 tries.
64
+ */
65
+ export declare function createEdgeAccess(client: EdgePlatformAccess, options?: {
66
+ sleep?: (ms: number) => Promise<void>;
67
+ writeSlot?: WriteSlot;
68
+ }): AccessPort;
@@ -0,0 +1,123 @@
1
+ import { EdgeApiError } from '@wtfalch/edge';
2
+ import { EDGE_DNS_WRITE_CONCURRENCY, limiter, rateLimitRetrier, } from './edge-dns.js';
3
+ const isNotFound = (error) => error instanceof EdgeApiError && error.code === 'not_found';
4
+ /** Edge's 409 for a live app on the domain with no edge row. */
5
+ const isExists = (error) => error instanceof EdgeApiError && error.code === 'conflict' && error.reason === 'exists';
6
+ /**
7
+ * Edge answers a policy, delete or create call for a live Access app with no
8
+ * edge row `not_found` or 409 `exists` ("create or adopt it first"). So the
9
+ * adapter adopts an app the moment it is about to be used: an app it saw in a
10
+ * list (or that a create refused with `exists`) whose owner edge reported as
11
+ * `null` is remembered, and adopted just before the first call on it. An app
12
+ * foundry recorded in an earlier apply and now names by id only is looked up
13
+ * on `not_found`, adopted if unowned, and the call retried once, so a destroy
14
+ * deletes it instead of finding "nothing there". Adopt is idempotent and the
15
+ * list reports the owner afterwards, so nothing is recorded in the plan. An
16
+ * app with an owner is never adopted or changed. An adopt that edge skips
17
+ * (`taken`, `unsupported_type`) is an error. A `rate_limited` answer is
18
+ * retried with edge's hint, up to 5 tries.
19
+ */
20
+ export function createEdgeAccess(client, options = {}) {
21
+ const retrying = rateLimitRetrier(options.sleep);
22
+ const access = {
23
+ list: (filter) => retrying(() => client.list(filter)),
24
+ create: (input) => retrying(() => client.create(input)),
25
+ remove: (id) => retrying(() => client.remove(id)),
26
+ listPolicies: (appId) => retrying(() => client.listPolicies(appId)),
27
+ createPolicy: (appId, input) => retrying(() => client.createPolicy(appId, input)),
28
+ findServiceToken: (name) => retrying(() => client.findServiceToken(name)),
29
+ adopt: (input) => retrying(() => client.adopt(input)),
30
+ };
31
+ const writeSlot = options.writeSlot ?? limiter(EDGE_DNS_WRITE_CONCURRENCY);
32
+ /** app id -> a seen live app with no edge row yet. */
33
+ const unadopted = new Map();
34
+ const remember = (views) => {
35
+ for (const view of views) {
36
+ if (view.owner === null)
37
+ unadopted.set(view.id, view);
38
+ else
39
+ unadopted.delete(view.id);
40
+ }
41
+ };
42
+ const adopt = async (view) => {
43
+ const result = await writeSlot(() => access.adopt({ apps: [{ name: view.name, domain: view.domain, id: view.id }] }));
44
+ const skipped = result.skipped[0];
45
+ if (skipped) {
46
+ throw new Error(`edge access: ${view.name} (${view.domain}) cannot be adopted (${skipped.reason}); remove or fix it by hand`);
47
+ }
48
+ if (result.notFound.length > 0) {
49
+ throw new EdgeApiError(404, 'not_found', `edge access: ${view.name} is no longer live`);
50
+ }
51
+ unadopted.delete(view.id);
52
+ };
53
+ /** Runs a call on an app; adopts it first when seen unowned, and once more on `not_found`. */
54
+ const onApp = async (appId, call) => {
55
+ const seen = unadopted.get(appId);
56
+ if (seen)
57
+ await adopt(seen);
58
+ try {
59
+ return await call();
60
+ }
61
+ catch (error) {
62
+ if (!isNotFound(error))
63
+ throw error;
64
+ const live = (await access.list()).apps;
65
+ remember(live);
66
+ const view = live.find((a) => a.id === appId);
67
+ if (!view || view.owner !== null)
68
+ throw error;
69
+ await adopt(view);
70
+ return call();
71
+ }
72
+ };
73
+ const listAccessApps = async () => {
74
+ const { apps } = await access.list();
75
+ remember(apps);
76
+ return apps.map((a) => ({ id: a.id, name: a.name, domain: a.domain }));
77
+ };
78
+ return {
79
+ listAccessApps,
80
+ async createAccessApp(app) {
81
+ try {
82
+ const created = await writeSlot(() => access.create({ name: app.name, domain: app.domain, type: app.type }));
83
+ return { id: created.id };
84
+ }
85
+ catch (error) {
86
+ // A live app holds the domain with no edge row. The one of this name
87
+ // and domain is foundry's own from before edge owned Access: use it,
88
+ // adopting it before its first policy call. Any other app is refused.
89
+ if (!isExists(error))
90
+ throw error;
91
+ const { apps } = await access.list({ domain: app.domain });
92
+ remember(apps);
93
+ const same = apps.find((a) => a.name === app.name && a.owner === null);
94
+ if (!same)
95
+ throw error;
96
+ return { id: same.id };
97
+ }
98
+ },
99
+ async listAccessPolicies(appId) {
100
+ const { policies } = await onApp(appId, () => access.listPolicies(appId));
101
+ return policies.map((p) => ({ id: p.id, name: p.name, include: p.include }));
102
+ },
103
+ async createAccessPolicy(appId, policy) {
104
+ const created = await onApp(appId, () => writeSlot(() => access.createPolicy(appId, policy)));
105
+ return { id: created.id };
106
+ },
107
+ async findServiceTokenByName(name) {
108
+ try {
109
+ const { token } = await access.findServiceToken(name);
110
+ return token ? { id: token.id } : undefined;
111
+ }
112
+ catch (error) {
113
+ if (error instanceof EdgeApiError && error.code === 'conflict') {
114
+ throw new Error(`edge access: more than one Cloudflare Access service token is named '${name}'; rename or remove the extras`);
115
+ }
116
+ throw error;
117
+ }
118
+ },
119
+ async deleteAccessApp(appId) {
120
+ await onApp(appId, () => writeSlot(() => access.remove(appId)));
121
+ },
122
+ };
123
+ }
@@ -0,0 +1,89 @@
1
+ import type { createEdgeClient } from '@wtfalch/edge';
2
+ /** The edge SDK's platform DNS client: `createEdgeClient({ ... }).platform.dns`. */
3
+ export type EdgePlatformDns = ReturnType<typeof createEdgeClient>['platform']['dns'];
4
+ export interface DnsRecordSummary {
5
+ id: string;
6
+ type: string;
7
+ content: string;
8
+ name: string;
9
+ }
10
+ export interface DnsRecordInput {
11
+ type: string;
12
+ name: string;
13
+ content: string;
14
+ proxied?: boolean;
15
+ ttl?: number;
16
+ /** Required for an `MX` record. */
17
+ priority?: number;
18
+ }
19
+ /**
20
+ * Foundry's DNS calls. Every record call in foundry goes through this; the one
21
+ * implementation is {@link createEdgeDns}, which speaks to the edge service
22
+ * with the platform credential. `zone` is the zone's own domain (what
23
+ * `ZonePort.findZoneByApex` returns as `name`), not a Cloudflare zone
24
+ * id: edge addresses a zone by its domain.
25
+ */
26
+ export interface DnsPort {
27
+ /** An exact name + type match, so a stale record of a different type is never mistaken for a match. */
28
+ findDnsRecord(zone: string, name: string, type?: string): Promise<DnsRecordSummary[]>;
29
+ /**
30
+ * Every record of a type in the zone, for a domain recorded before
31
+ * `CLOUDFLARE_TUNNEL_DNS_HOSTNAMES_SCOPE_KEY` existed and so with no
32
+ * hostname list to look up instead.
33
+ */
34
+ listDnsRecords(zone: string, opts?: {
35
+ type?: string;
36
+ name?: string;
37
+ }): Promise<DnsRecordSummary[]>;
38
+ createDnsRecord(zone: string, rec: DnsRecordInput): Promise<{
39
+ id: string;
40
+ name: string;
41
+ content: string;
42
+ }>;
43
+ updateDnsRecord(zone: string, recordId: string, rec: DnsRecordInput): Promise<{
44
+ id: string;
45
+ name: string;
46
+ content: string;
47
+ }>;
48
+ deleteDnsRecord(zone: string, recordId: string): Promise<void>;
49
+ }
50
+ /** Edge answers its platform writes from one shared slot of 4 (company-less writes). */
51
+ export declare const EDGE_DNS_WRITE_CONCURRENCY = 4;
52
+ /**
53
+ * Edge lists a TXT record raw, quoted (`"v=spf1 ..."`, long values in several
54
+ * quoted chunks), but returns it unquoted from a create or update. Everything
55
+ * foundry compares sees the unquoted form.
56
+ */
57
+ export declare function unquoteTxt(content: string): string;
58
+ export type WriteSlot = <T>(task: () => Promise<T>) => Promise<T>;
59
+ export declare function limiter(max: number): WriteSlot;
60
+ /**
61
+ * Edge's company-less platform calls share one per-org slot of 4 and answer a
62
+ * full slot with `rate_limited` and a retry hint, without queueing. The SDK
63
+ * does not retry, so every call here waits the hint out, up to this many tries.
64
+ */
65
+ export declare const EDGE_DNS_RATE_LIMIT_ATTEMPTS = 5;
66
+ /**
67
+ * Retries a call edge answered `rate_limited`, waiting out its hint (capped at
68
+ * 30 s), up to {@link EDGE_DNS_RATE_LIMIT_ATTEMPTS} tries. Shared by every
69
+ * edge adapter, since all platform calls draw on the one slot.
70
+ */
71
+ export declare function rateLimitRetrier(sleepOption?: (ms: number) => Promise<void>): <T>(task: () => Promise<T>) => Promise<T>;
72
+ /**
73
+ * Edge answers an update or delete of a live record with no edge row
74
+ * `not_found` ("adopt it first"). So the adapter adopts a record the moment it
75
+ * is about to be changed or removed: every record it has returned that the
76
+ * list reported with `owner: null` is remembered, and adopted just before the
77
+ * first write that names it. Adopt is idempotent and the list reports the
78
+ * owner once adopted, so nothing is recorded in the plan and a later apply
79
+ * adopts nothing. A record foundry only reads (a foreign record it refuses to
80
+ * overwrite) is never adopted. An `unsupported_type`, `taken` or `organisation`
81
+ * skip is an error; an `unclaimed` record is left alone. A company's own record
82
+ * (edge answers `forbidden`) is never changed: the error names zone, name and
83
+ * type. A `rate_limited` answer is retried with edge's hint, up to 5 tries.
84
+ */
85
+ export declare function createEdgeDns(client: EdgePlatformDns, options?: {
86
+ sleep?: (ms: number) => Promise<void>;
87
+ /** Share one slot of {@link EDGE_DNS_WRITE_CONCURRENCY} with the tunnel adapter: edge counts both. */
88
+ writeSlot?: WriteSlot;
89
+ }): DnsPort;
@@ -0,0 +1,227 @@
1
+ import { EdgeApiError } from '@wtfalch/edge';
2
+ /** Edge answers its platform writes from one shared slot of 4 (company-less writes). */
3
+ export const EDGE_DNS_WRITE_CONCURRENCY = 4;
4
+ /**
5
+ * Edge lists a TXT record raw, quoted (`"v=spf1 ..."`, long values in several
6
+ * quoted chunks), but returns it unquoted from a create or update. Everything
7
+ * foundry compares sees the unquoted form.
8
+ */
9
+ export function unquoteTxt(content) {
10
+ const trimmed = content.trim();
11
+ if (!trimmed.startsWith('"'))
12
+ return content;
13
+ const chunks = [...trimmed.matchAll(/"((?:[^"\\]|\\.)*)"/g)].map((m) => (m[1] ?? '').replace(/\\(.)/g, '$1'));
14
+ return chunks.length > 0 ? chunks.join('') : content;
15
+ }
16
+ function summary(record) {
17
+ return {
18
+ id: record.id,
19
+ type: record.type,
20
+ name: record.name,
21
+ content: record.type === 'TXT' ? unquoteTxt(record.content) : record.content,
22
+ };
23
+ }
24
+ export function limiter(max) {
25
+ let active = 0;
26
+ const waiting = [];
27
+ return async (task) => {
28
+ while (active >= max)
29
+ await new Promise((resolve) => waiting.push(resolve));
30
+ active++;
31
+ try {
32
+ return await task();
33
+ }
34
+ finally {
35
+ active--;
36
+ waiting.shift()?.();
37
+ }
38
+ };
39
+ }
40
+ const isEdgeNotFound = (error) => error instanceof EdgeApiError && error.code === 'not_found';
41
+ const isEdgeForbidden = (error) => error instanceof EdgeApiError && error.code === 'forbidden';
42
+ /**
43
+ * Edge's company-less platform calls share one per-org slot of 4 and answer a
44
+ * full slot with `rate_limited` and a retry hint, without queueing. The SDK
45
+ * does not retry, so every call here waits the hint out, up to this many tries.
46
+ */
47
+ export const EDGE_DNS_RATE_LIMIT_ATTEMPTS = 5;
48
+ const DEFAULT_RETRY_AFTER_SECONDS = 2;
49
+ const MAX_RETRY_AFTER_SECONDS = 30;
50
+ /**
51
+ * Retries a call edge answered `rate_limited`, waiting out its hint (capped at
52
+ * 30 s), up to {@link EDGE_DNS_RATE_LIMIT_ATTEMPTS} tries. Shared by every
53
+ * edge adapter, since all platform calls draw on the one slot.
54
+ */
55
+ export function rateLimitRetrier(sleepOption) {
56
+ const sleep = sleepOption ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
57
+ return async (task) => {
58
+ for (let attempt = 1;; attempt++) {
59
+ try {
60
+ return await task();
61
+ }
62
+ catch (error) {
63
+ if (!(error instanceof EdgeApiError) ||
64
+ error.code !== 'rate_limited' ||
65
+ attempt >= EDGE_DNS_RATE_LIMIT_ATTEMPTS) {
66
+ throw error;
67
+ }
68
+ await sleep(Math.min(error.retryAfterSeconds ?? DEFAULT_RETRY_AFTER_SECONDS, MAX_RETRY_AFTER_SECONDS) * 1000);
69
+ }
70
+ }
71
+ };
72
+ }
73
+ const OWNED_BY_COMPANY = 'owned by the company; foundry will not change it';
74
+ /**
75
+ * Edge answers an update or delete of a live record with no edge row
76
+ * `not_found` ("adopt it first"). So the adapter adopts a record the moment it
77
+ * is about to be changed or removed: every record it has returned that the
78
+ * list reported with `owner: null` is remembered, and adopted just before the
79
+ * first write that names it. Adopt is idempotent and the list reports the
80
+ * owner once adopted, so nothing is recorded in the plan and a later apply
81
+ * adopts nothing. A record foundry only reads (a foreign record it refuses to
82
+ * overwrite) is never adopted. An `unsupported_type`, `taken` or `organisation`
83
+ * skip is an error; an `unclaimed` record is left alone. A company's own record
84
+ * (edge answers `forbidden`) is never changed: the error names zone, name and
85
+ * type. A `rate_limited` answer is retried with edge's hint, up to 5 tries.
86
+ */
87
+ export function createEdgeDns(client, options = {}) {
88
+ const retrying = rateLimitRetrier(options.sleep);
89
+ /** The SDK client with every call retried on `rate_limited`. */
90
+ const dns = {
91
+ list: (input) => retrying(() => client.list(input)),
92
+ create: (input) => retrying(() => client.create(input)),
93
+ update: (zone, id, patch) => retrying(() => client.update(zone, id, patch)),
94
+ remove: (zone, id) => retrying(() => client.remove(zone, id)),
95
+ adopt: (input) => retrying(() => client.adopt(input)),
96
+ };
97
+ const writeSlot = options.writeSlot ?? limiter(EDGE_DNS_WRITE_CONCURRENCY);
98
+ /** zone -> record id -> a seen live record with no edge row yet. */
99
+ const unadopted = new Map();
100
+ /** zone -> record id -> every record seen, to name one in an error. */
101
+ const seen = new Map();
102
+ const describe = (zone, recordId) => {
103
+ const record = seen.get(zone)?.get(recordId);
104
+ return record ? `${record.type} ${record.name} in ${zone}` : `record ${recordId} in ${zone}`;
105
+ };
106
+ const remember = (zone, records) => {
107
+ const all = seen.get(zone) ?? new Map();
108
+ for (const record of records)
109
+ all.set(record.id, record);
110
+ seen.set(zone, all);
111
+ const pending = unadopted.get(zone) ?? new Map();
112
+ for (const record of records) {
113
+ if (record.owner === null)
114
+ pending.set(record.id, record);
115
+ else
116
+ pending.delete(record.id);
117
+ }
118
+ unadopted.set(zone, pending);
119
+ };
120
+ const adopt = async (zone, record) => {
121
+ const result = await writeSlot(() => dns.adopt({
122
+ zone,
123
+ records: [
124
+ {
125
+ name: record.name,
126
+ type: record.type,
127
+ id: record.id,
128
+ content: record.content,
129
+ },
130
+ ],
131
+ }));
132
+ const skipped = result.skipped[0];
133
+ if (skipped) {
134
+ throw new Error(skipped.reason === 'organisation'
135
+ ? `edge dns: ${record.type} ${record.name} in ${zone} is ${OWNED_BY_COMPANY}`
136
+ : `edge dns: ${record.type} ${record.name} in ${zone} cannot be adopted (${skipped.reason}); remove or fix it by hand`);
137
+ }
138
+ unadopted.get(zone)?.delete(record.id);
139
+ };
140
+ /** Adopts `recordId` when it was seen without an edge row. */
141
+ const ensureAdopted = async (zone, recordId) => {
142
+ const record = unadopted.get(zone)?.get(recordId);
143
+ if (record)
144
+ await adopt(zone, record);
145
+ };
146
+ /** Runs a write; on `not_found` adopts the live record once and retries. */
147
+ const writeExisting = async (zone, recordId, write) => {
148
+ await ensureAdopted(zone, recordId);
149
+ // `forbidden` also answers a revoked or under-scoped platform key, so the
150
+ // record is listed again and the company message is used only when its
151
+ // owner really is an organisation; anything else is the original error.
152
+ const refuseCompanyRecord = async (error) => {
153
+ if (!isEdgeForbidden(error))
154
+ throw error;
155
+ const known = seen.get(zone)?.get(recordId);
156
+ let owner;
157
+ try {
158
+ const { records } = await dns.list({
159
+ zone,
160
+ ...(known ? { name: known.name, type: known.type } : {}),
161
+ });
162
+ remember(zone, records);
163
+ owner = records.find((r) => r.id === recordId)?.owner;
164
+ }
165
+ catch {
166
+ throw error;
167
+ }
168
+ if (owner !== 'organisation')
169
+ throw error;
170
+ throw new Error(`edge dns: ${describe(zone, recordId)} is ${OWNED_BY_COMPANY}`);
171
+ };
172
+ try {
173
+ return await writeSlot(write);
174
+ }
175
+ catch (error) {
176
+ if (!isEdgeNotFound(error))
177
+ return refuseCompanyRecord(error);
178
+ const live = (await dns.list({ zone })).records;
179
+ remember(zone, live);
180
+ const record = live.find((r) => r.id === recordId);
181
+ if (!record || record.owner !== null)
182
+ throw error;
183
+ await adopt(zone, record);
184
+ try {
185
+ return await writeSlot(write);
186
+ }
187
+ catch (retryError) {
188
+ return refuseCompanyRecord(retryError);
189
+ }
190
+ }
191
+ };
192
+ const body = (rec) => ({
193
+ content: rec.content,
194
+ ...(rec.proxied === undefined ? {} : { proxied: rec.proxied }),
195
+ ...(rec.ttl === undefined ? {} : { ttl: rec.ttl }),
196
+ ...(rec.priority === undefined ? {} : { priority: rec.priority }),
197
+ });
198
+ const list = async (zone, opts) => {
199
+ const { records } = await dns.list({
200
+ zone,
201
+ ...(opts?.name ? { name: opts.name } : {}),
202
+ ...(opts?.type ? { type: opts.type } : {}),
203
+ });
204
+ remember(zone, records);
205
+ return records.map(summary);
206
+ };
207
+ return {
208
+ findDnsRecord: (zone, name, type) => list(zone, { name, type }),
209
+ listDnsRecords: list,
210
+ async createDnsRecord(zone, rec) {
211
+ const created = await writeSlot(() => dns.create({
212
+ zone,
213
+ name: rec.name,
214
+ type: rec.type,
215
+ ...body(rec),
216
+ }));
217
+ return { id: created.id, name: created.name, content: summary(created).content };
218
+ },
219
+ async updateDnsRecord(zone, recordId, rec) {
220
+ const updated = await writeExisting(zone, recordId, () => dns.update(zone, recordId, body(rec)));
221
+ return { id: updated.id, name: updated.name, content: summary(updated).content };
222
+ },
223
+ async deleteDnsRecord(zone, recordId) {
224
+ await writeExisting(zone, recordId, () => dns.remove(zone, recordId));
225
+ },
226
+ };
227
+ }