1import * as magicTransit from "@distilled.cloud/cloudflare/magic-transit";
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 * as Provider from "../../Provider.ts";
8import { Resource } from "../../Resource.ts";
9import { CloudflareEnvironment } from "../CloudflareEnvironment.ts";
10import type { Providers } from "../Providers.ts";
11
12const TypeId = "Cloudflare.MagicTransit.App" as const;
13type TypeId = typeof TypeId;
14
15export interface MagicAppProps {
16 /**
17 * Display name for the app.
18 */
19 name: string;
20 /**
21 * Category of the app, e.g. `"Collaboration"`.
22 */
23 type: string;
24 /**
25 * FQDNs to associate with traffic decisions.
26 */
27 hostnames?: string[];
28 /**
29 * IPv4 CIDRs to associate with traffic decisions. (IPv6 CIDRs are
30 * currently unsupported.)
31 */
32 ipSubnets?: string[];
33}
34
35export interface MagicAppAttributes {
36 /** Magic account app ID. */
37 appId: string;
38 /** The Cloudflare account the app belongs to. */
39 accountId: string;
40 /** Display name for the app. */
41 name: string;
42 /** Category of the app. */
43 type: string;
44 /** FQDNs associated with traffic decisions, if set. */
45 hostnames: string[] | undefined;
46 /** IPv4 CIDRs associated with traffic decisions, if set. */
47 ipSubnets: string[] | undefined;
48}
49
50export type MagicApp = Resource<
51 TypeId,
52 MagicAppProps,
53 MagicAppAttributes,
54 never,
55 Providers
56>;
57
58/**
59 * A custom Magic WAN app — a named set of hostnames and/or IP subnets used
60 * for traffic steering and policy decisions, complementing Cloudflare's
61 * managed app definitions.
62 *
63 * Requires a Magic WAN subscription — accounts without it receive a typed
64 * `MagicWanUnauthorized` error (Cloudflare code 1025).
65 *
66 * All properties are mutable in place via PATCH.
67 * ### Creating an app
68 * **Example:** App matching hostnames
69 * ```typescript
70 * const app = yield* Cloudflare.MagicTransit.MagicApp("crm", {
71 * name: "Internal CRM",
72 * type: "Business",
73 * hostnames: ["crm.example.com"],
74 * });
75 * ```
76 *
77 * **Example:** App matching IP subnets
78 * ```typescript
79 * const app = yield* Cloudflare.MagicTransit.MagicApp("voip", {
80 * name: "VoIP",
81 * type: "Communication",
82 * ipSubnets: ["192.0.2.0/24", "198.51.100.0/24"],
83 * });
84 * ```
85 *
86 * @see https://developers.cloudflare.com/magic-wan/configuration/apps/
87 *
88 * @resource
89 * @product Magic Transit
90 * @category Network
91 */
92export const MagicApp = Resource<MagicApp>(TypeId);
93
94/**
95 * Returns true if the given value is a MagicApp resource.
96 */
97export const isMagicApp = (value: unknown): value is MagicApp =>
98 Predicate.hasProperty(value, "Type") && value.Type === TypeId;
99
100export const MagicAppProvider = () =>
101 Provider.succeed(MagicApp, {
102 stables: ["appId", "accountId"],
103
104 read: Effect.fn(function* ({ output, olds }) {
105 const { accountId } = yield* yield* CloudflareEnvironment;
106 const acct = output?.accountId ?? accountId;
107
108 if (output?.appId) {
109 const observed = yield* getApp(acct, output.appId);
110 if (observed) return toAttributes(observed, acct);
111 }
112 // Cold read — match by name. Apps carry no ownership markers;
113 // report as Unowned so takeover is gated behind the adopt policy.
114 const name = output?.name ?? olds?.name;
115 if (name) {
116 const observed = yield* findByName(acct, name);
117 if (observed) return Unowned(toAttributes(observed, acct));
118 }
119 return undefined;
120 }),
121
122 reconcile: Effect.fn(function* ({ news, output }) {
123 const { accountId } = yield* yield* CloudflareEnvironment;
124
125 // Observe — there is no getApp endpoint; scan the list by cached id
126 // first, then by name.
127 let observed = output?.appId
128 ? yield* getApp(accountId, output.appId)
129 : undefined;
130 if (!observed) {
131 observed = yield* findByName(accountId, news.name);
132 }
133
134 // Ensure — create when missing.
135 if (!observed) {
136 const created = yield* magicTransit.createApp({
137 accountId,
138 name: news.name,
139 type: news.type,
140 hostnames: news.hostnames,
141 ipSubnets: news.ipSubnets,
142 });
143 return toAttributes(created, accountId);
144 }
145
146 // Sync — diff observed cloud state against desired; PATCH only on a
147 // real delta.
148 const dirty =
149 (observed.name ?? undefined) !== news.name ||
150 (observed.type ?? undefined) !== news.type ||
151 (news.hostnames !== undefined &&
152 !sameList(observed.hostnames, news.hostnames)) ||
153 (news.ipSubnets !== undefined &&
154 !sameList(observed.ipSubnets, news.ipSubnets));
155 if (dirty) {
156 const updated = yield* magicTransit.patchApp({
157 accountId,
158 accountAppId: observed.accountAppId,
159 name: news.name,
160 type: news.type,
161 hostnames: news.hostnames,
162 ipSubnets: news.ipSubnets,
163 });
164 observed = updated;
165 }
166
167 return toAttributes(observed, accountId);
168 }),
169
170 delete: Effect.fn(function* ({ output }) {
171 yield* magicTransit
172 .deleteApp({
173 accountId: output.accountId,
174 accountAppId: output.appId,
175 })
176 .pipe(Effect.catchTag("AppNotFound", () => Effect.void));
177 }),
178
179 // Account collection — exhaustively paginate the account-scoped apps list
180 // and hydrate each account app into the `read` Attributes shape. Magic WAN
181 // is entitlement-gated; unentitled accounts reject with a typed error, in
182 // which case there are no apps to enumerate → [].
183 list: Effect.fn(function* () {
184 const { accountId } = yield* yield* CloudflareEnvironment;
185 return yield* magicTransit.listApps.pages({ accountId }).pipe(
186 Stream.runCollect,
187 Effect.map((chunk) =>
188 Array.from(chunk).flatMap((page) =>
189 (page.result ?? [])
190 .filter(isAccountApp)
191 .map((app) => toAttributes(app, accountId)),
192 ),
193 ),
194 Effect.catchTag(["MagicWanUnauthorized", "Forbidden"], () =>
195 Effect.succeed([] as MagicAppAttributes[]),
196 ),
197 );
198 }),
199 });
200
201interface ObservedApp {
202 accountAppId: string;
203 name?: string | null;
204 type?: string | null;
205 hostnames?: string[] | null;
206 ipSubnets?: string[] | null;
207}
208
209const isAccountApp = (
210 app: magicTransit.ListAppsResponse["result"][number],
211): app is ObservedApp => "accountAppId" in app;
212
213/**
214 * Read an account app by id via the list endpoint (there is no getApp).
215 */
216const getApp = (accountId: string, appId: string) =>
217 magicTransit
218 .listApps({ accountId })
219 .pipe(
220 Effect.map((r): ObservedApp | undefined =>
221 r.result.filter(isAccountApp).find((app) => app.accountAppId === appId),
222 ),
223 );
224
225/**
226 * Find an account app by exact name. Names are not enforced unique; pick
227 * the first match deterministically by id.
228 */
229const findByName = (accountId: string, name: string) =>
230 magicTransit.listApps({ accountId }).pipe(
231 Effect.map((r): ObservedApp | undefined =>
232 r.result
233 .filter(isAccountApp)
234 .filter((app) => app.name === name)
235 .sort((a, b) => a.accountAppId.localeCompare(b.accountAppId))
236 .at(0),
237 ),
238 );
239
240const sameList = (
241 a: readonly string[] | null | undefined,
242 b: readonly string[] | undefined,
243): boolean =>
244 [...(a ?? [])].sort().join(",") === [...(b ?? [])].sort().join(",");
245
246const toAttributes = (
247 app: ObservedApp,
248 accountId: string,
249): MagicAppAttributes => ({
250 appId: app.accountAppId,
251 accountId,
252 name: app.name ?? "",
253 type: app.type ?? "",
254 hostnames: app.hostnames ?? undefined,
255 ipSubnets: app.ipSubnets ?? undefined,
256});