1import * as spectrum from "@distilled.cloud/cloudflare/spectrum";
2import * as Effect from "effect/Effect";
3import * as Predicate from "effect/Predicate";
4import * as Stream from "effect/Stream";
5
6import { Unowned } from "../../AdoptPolicy.ts";
7import { isResolved } from "../../Diff.ts";
8import * as Provider from "../../Provider.ts";
9import { Resource } from "../../Resource.ts";
10import { CloudflareEnvironment } from "../CloudflareEnvironment.ts";
11import type { Providers } from "../Providers.ts";
12import { listAllZones } from "../Zone/lookup.ts";
13
14const TypeId = "Cloudflare.Spectrum.Application" as const;
15type TypeId = typeof TypeId;
16
17/**
18 * How data travels from Cloudflare's edge to the origin. `direct` sends
19 * traffic straight to the origin; `http`/`https` apply HTTP(S) processing
20 * (Enterprise only).
21 */
22export type TrafficType = "direct" | "http" | "https";
23
24/**
25 * The type of TLS termination at the edge for a Spectrum application.
26 */
27export type Tls = "off" | "flexible" | "full" | "strict";
28
29/**
30 * PROXY Protocol mode for origin connections.
31 */
32export type ProxyProtocol = "off" | "v1" | "v2" | "simple";
33
34/**
35 * The edge DNS record Spectrum creates for the application — the hostname
36 * clients connect to. Must live inside the application's zone.
37 */
38export interface Dns {
39 /**
40 * The fully-qualified edge hostname, e.g. `ssh.example.com`. Together
41 * with `protocol`, this is the user-visible identity of the application
42 * within a zone.
43 */
44 name: string;
45 /**
46 * The kind of edge DNS record Spectrum manages: `CNAME` (dynamic edge
47 * IPs) or `ADDRESS` (static BYOIP edge IPs, Enterprise only).
48 */
49 type: "CNAME" | "ADDRESS";
50}
51
52/**
53 * Origin lookup via DNS instead of fixed addresses. Requires `originPort`.
54 */
55export interface OriginDns {
56 /**
57 * The hostname of the origin server.
58 */
59 name?: string;
60 /**
61 * TTL of the origin DNS lookup, in seconds.
62 */
63 ttl?: number;
64 /**
65 * The DNS record type to resolve: `A`, `AAAA`, `SRV`, or `""` to let
66 * Cloudflare pick.
67 */
68 type?: "" | "A" | "AAAA" | "SRV";
69}
70
71/**
72 * The anycast edge IP configuration for the application's hostname.
73 */
74export interface EdgeIps {
75 /**
76 * `dynamic` (default) lets Cloudflare assign edge IPs; `static` pins the
77 * application to BYOIP addresses listed in `ips` (Enterprise only).
78 * @default "dynamic"
79 */
80 type?: "dynamic" | "static";
81 /**
82 * IP-version reachability of the dynamic edge IPs.
83 * @default "all"
84 */
85 connectivity?: "all" | "ipv4" | "ipv6";
86 /**
87 * Static BYOIP edge addresses (only with `type: "static"`).
88 */
89 ips?: string[];
90}
91
92export interface ApplicationProps {
93 /**
94 * Zone the application is created in. Stable — moving an application to
95 * a different zone triggers a replacement.
96 */
97 zoneId: string;
98 /**
99 * The edge DNS record (hostname + record type) for the application.
100 * Mutable — the PUT update accepts a new `dns`, but note `dns.name` +
101 * `protocol` is the identity used for cold-state recovery.
102 */
103 dns: Dns;
104 /**
105 * The edge port configuration, e.g. `"tcp/22"`, `"udp/53"`, or a range
106 * `"tcp/1000-2000"`. Arbitrary ports, ranges, and UDP require an
107 * Enterprise plan with Spectrum; Pro zones get `tcp/22` and `tcp/25565`.
108 */
109 protocol: string;
110 /**
111 * How traffic travels to the origin. `http`/`https` are Enterprise only.
112 * @default "direct"
113 */
114 trafficType?: TrafficType;
115 /**
116 * Fixed origin addresses, e.g. `["tcp://203.0.113.1:22"]`. Provide
117 * either `originDirect` or `originDns` + `originPort`.
118 */
119 originDirect?: string[];
120 /**
121 * Origin lookup via DNS. Must be combined with `originPort`.
122 */
123 originDns?: OriginDns;
124 /**
125 * The destination port at the origin (only with `originDns`). A range
126 * string (e.g. `"1000-2000"`) is Enterprise only.
127 */
128 originPort?: number | string;
129 /**
130 * TLS termination at the edge. Only meaningful for `http`/`https`
131 * traffic types — Cloudflare rejects the field on `direct` TCP apps, so
132 * leave it unset for those.
133 */
134 tls?: Tls;
135 /**
136 * Enables Argo Smart Routing (TCP + `direct` traffic type only).
137 * @default false
138 */
139 argoSmartRouting?: boolean;
140 /**
141 * Enables IP Access rules for this application (TCP only).
142 * @default false
143 */
144 ipFirewall?: boolean;
145 /**
146 * Enables PROXY Protocol to the origin.
147 * @default "off"
148 */
149 proxyProtocol?: ProxyProtocol;
150 /**
151 * The anycast edge IP configuration. Static BYOIP addresses are
152 * Enterprise only.
153 * @default { type: "dynamic", connectivity: "all" }
154 */
155 edgeIps?: EdgeIps;
156 /**
157 * UUID of a tunnel virtual network to route origin traffic through.
158 */
159 virtualNetworkId?: string;
160}
161
162export interface ApplicationAttributes {
163 /** Cloudflare-assigned identifier of the Spectrum application. */
164 appId: string;
165 /** Zone the application belongs to. */
166 zoneId: string;
167 /** The edge hostname clients connect to. */
168 dnsName: string;
169 /** The kind of edge DNS record (`CNAME` or `ADDRESS`). */
170 dnsType: "CNAME" | "ADDRESS";
171 /** The edge port configuration, e.g. `tcp/22`. */
172 protocol: string;
173 /** How traffic travels to the origin. */
174 trafficType: TrafficType;
175 /** Fixed origin addresses, if configured. */
176 originDirect: string[] | undefined;
177 /** Origin DNS lookup configuration, if configured. */
178 originDns: OriginDns | undefined;
179 /** The destination port at the origin, if configured. */
180 originPort: number | string | undefined;
181 /** TLS termination at the edge, if reported. */
182 tls: Tls | undefined;
183 /** Whether Argo Smart Routing is enabled. */
184 argoSmartRouting: boolean;
185 /** Whether IP Access rules are enabled. */
186 ipFirewall: boolean;
187 /** PROXY Protocol mode to the origin. */
188 proxyProtocol: ProxyProtocol;
189 /** ISO8601 creation timestamp. */
190 createdOn: string;
191 /** ISO8601 last-modified timestamp. */
192 modifiedOn: string;
193}
194
195export type Application = Resource<
196 TypeId,
197 ApplicationProps,
198 ApplicationAttributes,
199 never,
200 Providers
201>;
202
203/**
204 * A Cloudflare Spectrum application — DDoS protection and proxying for
205 * arbitrary TCP/UDP services (SSH, Minecraft, RDP, custom protocols), not
206 * just HTTP.
207 *
208 * An application is identified by its auto-assigned `appId`; its
209 * user-visible identity within a zone is the edge hostname (`dns.name`) +
210 * `protocol` pair. All configuration is mutable in place via PUT — only
211 * `zoneId` forces a replacement.
212 *
213 * Spectrum is plan-gated: Pro zones get SSH (`tcp/22`) and Minecraft
214 * (`tcp/25565`), Business adds RDP, and Enterprise unlocks arbitrary
215 * ports/ranges, UDP, static edge IPs, and PROXY Protocol. On unentitled
216 * zones every create fails with the typed `SpectrumProtocolNotAvailable`
217 * error.
218 *
219 * Safety: Spectrum applications carry no ownership markers. When there is
220 * no prior state, `read` scans the zone for an application with the same
221 * `dns.name` + `protocol` and reports it as `Unowned`, so the engine
222 * refuses to take it over unless `--adopt` (or `adopt(true)`) is set.
223 * ### Proxying SSH
224 * **Example:** SSH on a fixed origin address
225 * ```typescript
226 * const ssh = yield* Cloudflare.Spectrum.Application("Ssh", {
227 * zoneId: zone.zoneId,
228 * dns: { type: "CNAME", name: "ssh.example.com" },
229 * protocol: "tcp/22",
230 * originDirect: ["tcp://203.0.113.1:22"],
231 * });
232 * ```
233 *
234 * ### Origin via DNS
235 * **Example:** Resolve the origin by hostname
236 * ```typescript
237 * yield* Cloudflare.Spectrum.Application("Minecraft", {
238 * zoneId: zone.zoneId,
239 * dns: { type: "CNAME", name: "mc.example.com" },
240 * protocol: "tcp/25565",
241 * originDns: { name: "origin.example.com" },
242 * originPort: 25565,
243 * });
244 * ```
245 *
246 * ### Enterprise features
247 * **Example:** UDP with IP firewall and PROXY protocol
248 * ```typescript
249 * // Arbitrary ports/protocols, UDP, and proxyProtocol require an
250 * // Enterprise plan with Spectrum.
251 * yield* Cloudflare.Spectrum.Application("Dns", {
252 * zoneId: zone.zoneId,
253 * dns: { type: "CNAME", name: "dns.example.com" },
254 * protocol: "udp/53",
255 * originDirect: ["udp://203.0.113.1:53"],
256 * ipFirewall: true,
257 * proxyProtocol: "simple",
258 * });
259 * ```
260 *
261 * @see https://developers.cloudflare.com/spectrum/
262 *
263 * @resource
264 * @product Spectrum
265 * @category Network
266 */
267export const Application = Resource<Application>(TypeId);
268
269/**
270 * Returns true if the given value is a Application resource.
271 */
272export const isApplication = (value: unknown): value is Application =>
273 Predicate.hasProperty(value, "Type") && value.Type === TypeId;
274
275export const ApplicationProvider = () =>
276 Provider.succeed(Application, {
277 stables: ["appId", "zoneId", "createdOn"],
278
279 diff: Effect.fn(function* ({ olds, news }) {
280 // `news` may still contain unresolved plan-time expressions — defer
281 // to the engine's default update logic until everything is concrete.
282 if (!isResolved(news)) return undefined;
283 // Everything is mutable through the PUT update except the zone the
284 // application lives in (a path parameter).
285 if (
286 typeof olds?.zoneId === "string" &&
287 typeof news.zoneId === "string" &&
288 olds.zoneId !== news.zoneId
289 ) {
290 return { action: "replace" } as const;
291 }
292 return undefined;
293 }),
294
295 read: Effect.fn(function* ({ output, olds }) {
296 const zoneId =
297 output?.zoneId ??
298 (typeof olds?.zoneId === "string" ? olds.zoneId : undefined);
299 if (zoneId === undefined) return undefined;
300
301 // Owned path: refresh by our persisted application id.
302 if (output?.appId) {
303 const observed = yield* getApp(zoneId, output.appId);
304 return observed ? toAttributes(observed, zoneId) : undefined;
305 }
306
307 // Adoption path: an application with this hostname + protocol may
308 // already exist in the zone. Spectrum apps carry no ownership
309 // markers, so we cannot prove we created it — brand it `Unowned` so
310 // the engine refuses to take over unless `adopt` is set.
311 const dnsName = output?.dnsName ?? olds?.dns.name;
312 const protocol = output?.protocol ?? olds?.protocol;
313 if (dnsName !== undefined && protocol !== undefined) {
314 const observed = yield* findByIdentity(zoneId, dnsName, protocol);
315 if (observed) return Unowned(toAttributes(observed, zoneId));
316 }
317 return undefined;
318 }),
319
320 reconcile: Effect.fn(function* ({ news, output }) {
321 // Inputs have been resolved to concrete strings by Plan.
322 const zoneId = news.zoneId as string;
323
324 // 1. Observe — the app id cached on `output` is a hint, not a
325 // guarantee: a missing application falls through to the identity
326 // scan and then to create.
327 let observed = output?.appId
328 ? yield* getApp(zoneId, output.appId)
329 : undefined;
330
331 // 2. Fall back to scanning the zone for a hostname + protocol match.
332 // Ownership has already been verified upstream — `read` reports
333 // existing applications as `Unowned` and the engine gates
334 // takeover behind the adopt policy before reconcile ever runs.
335 if (!observed) {
336 observed = yield* findByIdentity(zoneId, news.dns.name, news.protocol);
337 }
338
339 // 3. Ensure — create when missing.
340 if (!observed) {
341 const created = yield* spectrum.createApp({
342 zoneId,
343 ...toRequestBody(news),
344 });
345 return toAttributes(created, zoneId);
346 }
347
348 // 4. Sync — diff observed cloud state against desired; the update
349 // API is a PUT that requires the full body, so send everything,
350 // but skip the call entirely on a no-op.
351 if (!isDirty(observed, news)) {
352 return toAttributes(observed, zoneId);
353 }
354 const updated = yield* spectrum.updateApp({
355 zoneId,
356 appId: observed.id,
357 ...toRequestBody(news),
358 });
359 return toAttributes(updated, zoneId);
360 }),
361
362 delete: Effect.fn(function* ({ output }) {
363 // Deleting the app also removes the Spectrum-managed edge DNS
364 // record. An already-gone app answers with the typed
365 // `SpectrumAppNotFound` (Cloudflare code 10006) — idempotent.
366 yield* spectrum
367 .deleteApp({ zoneId: output.zoneId, appId: output.appId })
368 .pipe(Effect.catchTag("SpectrumAppNotFound", () => Effect.void));
369 }),
370
371 // Zone-scoped collection: Spectrum applications live inside a zone and
372 // are listed per-zone (`/zones/{zone_id}/spectrum/apps`). Enumerate every
373 // zone, exhaustively paginate each zone's apps, and hydrate into the same
374 // `Attributes` shape `read` returns. Zones without Spectrum entitlement
375 // reject the route with the typed `Forbidden` tag — skip them.
376 list: Effect.fn(function* () {
377 const { accountId } = yield* yield* CloudflareEnvironment;
378 const zones = yield* listAllZones(accountId);
379 const rows = yield* Effect.forEach(
380 zones,
381 (zone) =>
382 spectrum.listApps.pages({ zoneId: zone.id }).pipe(
383 Stream.runCollect,
384 Effect.map((chunk) =>
385 Array.from(chunk).flatMap((page) =>
386 (page.result ?? []).map((app) => toAttributes(app, zone.id)),
387 ),
388 ),
389 Effect.catchTag("Forbidden", () =>
390 Effect.succeed([] as ApplicationAttributes[]),
391 ),
392 ),
393 { concurrency: 10 },
394 );
395 return rows.flat();
396 }),
397 });
398
399// ---------------------------------------------------------------------------
400// API helpers
401// ---------------------------------------------------------------------------
402
403type ObservedApp = spectrum.GetAppResponse;
404
405/**
406 * Distilled types `getApp`/`listApps` results as a union whose second
407 * variant omits the optional configuration fields — normalize to a single
408 * partial shape.
409 */
410type FullApp = Extract<ObservedApp, { trafficType: unknown }>;
411type AnyApp =
412 | ObservedApp
413 | spectrum.CreateAppResponse
414 | spectrum.UpdateAppResponse;
415const asFull = (app: AnyApp): Partial<FullApp> & AnyApp => app;
416
417/**
418 * Read an application by id, mapping "gone" (`SpectrumAppNotFound`,
419 * Cloudflare error code 10006) to `undefined`.
420 */
421const getApp = (zoneId: string, appId: string) =>
422 spectrum.getApp({ zoneId, appId }).pipe(
423 Effect.map((app): ObservedApp | undefined => app),
424 Effect.catchTag("SpectrumAppNotFound", () => Effect.succeed(undefined)),
425 );
426
427/**
428 * Find an application by exact `dns.name` + `protocol` within the zone —
429 * the pair is the application's user-visible identity.
430 */
431const findByIdentity = (zoneId: string, dnsName: string, protocol: string) =>
432 spectrum.listApps.items({ zoneId }).pipe(
433 Stream.filter(
434 (app) => app.dns.name === dnsName && app.protocol === protocol,
435 ),
436 Stream.take(1),
437 Stream.runCollect,
438 Effect.map((chunk): ObservedApp | undefined => Array.from(chunk)[0]),
439 );
440
441/**
442 * The full PUT/POST body derived from the desired props. Optional fields
443 * the user did not set are omitted so the API applies its defaults — in
444 * particular `tls`, which Cloudflare rejects on `direct` TCP apps.
445 */
446const toRequestBody = (news: ApplicationProps) => ({
447 dns: { name: news.dns.name, type: news.dns.type },
448 protocol: news.protocol,
449 trafficType: news.trafficType,
450 originDirect: news.originDirect,
451 originDns: news.originDns,
452 originPort: news.originPort,
453 tls: news.tls,
454 argoSmartRouting: news.argoSmartRouting,
455 ipFirewall: news.ipFirewall,
456 proxyProtocol: news.proxyProtocol,
457 edgeIps: news.edgeIps,
458 virtualNetworkId: news.virtualNetworkId as string | undefined,
459});
460
461/**
462 * Compare observed cloud state against the desired props. Fields the user
463 * left unset are only considered dirty when the observed value differs
464 * from the API default, so a no-op deploy skips the PUT entirely.
465 */
466const isDirty = (observed: ObservedApp, news: ApplicationProps): boolean => {
467 const o = asFull(observed);
468 return (
469 o.dns.name !== news.dns.name ||
470 o.dns.type !== news.dns.type ||
471 o.protocol !== news.protocol ||
472 (o.trafficType ?? "direct") !== (news.trafficType ?? "direct") ||
473 !sameList(o.originDirect ?? [], news.originDirect ?? []) ||
474 (o.originDns?.name ?? undefined) !== news.originDns?.name ||
475 (o.originPort ?? undefined) !== news.originPort ||
476 (news.tls !== undefined && o.tls !== news.tls) ||
477 (o.argoSmartRouting ?? false) !== (news.argoSmartRouting ?? false) ||
478 (o.ipFirewall ?? false) !== (news.ipFirewall ?? false) ||
479 (o.proxyProtocol ?? "off") !== (news.proxyProtocol ?? "off") ||
480 (news.edgeIps !== undefined && edgeIpsDirty(o, news.edgeIps)) ||
481 (news.virtualNetworkId !== undefined &&
482 (o.virtualNetworkId ?? undefined) !== news.virtualNetworkId)
483 );
484};
485
486const edgeIpsDirty = (o: Partial<FullApp>, desired: EdgeIps): boolean => {
487 const observed = o.edgeIps ?? {};
488 const observedIps =
489 "ips" in observed ? ((observed.ips ?? []) as readonly string[]) : [];
490 const observedConnectivity =
491 "connectivity" in observed
492 ? (observed.connectivity ?? "all")
493 : ("all" as const);
494 return (
495 (observed.type ?? "dynamic") !== (desired.type ?? "dynamic") ||
496 (desired.connectivity !== undefined &&
497 observedConnectivity !== desired.connectivity) ||
498 (desired.ips !== undefined && !sameList(observedIps, desired.ips))
499 );
500};
501
502const sameList = (observed: readonly string[], desired: readonly string[]) =>
503 observed.length === desired.length &&
504 [...observed].sort().join(",") === [...desired].sort().join(",");
505
506const toAttributes = (
507 app: ObservedApp | spectrum.CreateAppResponse | spectrum.UpdateAppResponse,
508 zoneId: string,
509): ApplicationAttributes => {
510 const a = asFull(app);
511 return {
512 appId: a.id,
513 zoneId,
514 // Cloudflare always echoes both dns fields for a persisted app —
515 // distilled just types them as optional/nullable.
516 dnsName: a.dns.name ?? "",
517 dnsType: (a.dns.type ?? "CNAME") as "CNAME" | "ADDRESS",
518 protocol: a.protocol,
519 trafficType: (a.trafficType ?? "direct") as TrafficType,
520 originDirect: a.originDirect ? [...a.originDirect] : undefined,
521 originDns: a.originDns
522 ? {
523 name: a.originDns.name ?? undefined,
524 ttl: a.originDns.ttl ?? undefined,
525 type: (a.originDns.type ?? undefined) as
526 | OriginDns["type"]
527 | undefined,
528 }
529 : undefined,
530 originPort: a.originPort ?? undefined,
531 tls: (a.tls ?? undefined) as Tls | undefined,
532 argoSmartRouting: a.argoSmartRouting ?? false,
533 ipFirewall: a.ipFirewall ?? false,
534 proxyProtocol: (a.proxyProtocol ?? "off") as ProxyProtocol,
535 createdOn: a.createdOn,
536 modifiedOn: a.modifiedOn,
537 };
538};