1import * as zeroTrust from "@distilled.cloud/cloudflare/zero-trust";
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 { createPhysicalName } from "../../PhysicalName.ts";
8import * as Provider from "../../Provider.ts";
9import { Resource } from "../../Resource.ts";
10import { CloudflareEnvironment } from "../CloudflareEnvironment.ts";
11import type { Providers } from "../Providers.ts";
12
13const TypeId = "Cloudflare.Devices.DexTest" as const;
14type TypeId = typeof TypeId;
15
16/**
17 * What the WARP client probes when the test runs.
18 */
19export interface DeviceDexTestData {
20 /**
21 * The URL (for `http` tests) or hostname/IP (for `traceroute` tests)
22 * to probe.
23 */
24 host: string;
25 /** The kind of synthetic test the WARP client runs. */
26 kind: "http" | "traceroute";
27 /** The HTTP method to use — only `GET` is supported. */
28 method?: "GET" | (string & {});
29}
30
31/**
32 * A device-profile (DEX rule) targeted by the test.
33 */
34export interface DeviceDexTestTargetPolicy {
35 /** The id of the device settings profile. */
36 id: string;
37 /** Whether the profile is the account default. */
38 default?: boolean;
39 /** The name of the device settings profile. */
40 name?: string;
41}
42
43export interface DeviceDexTestProps {
44 /**
45 * Name of the DEX test. Must be unique within the account. If omitted,
46 * a unique name is generated from the app, stage, and logical ID.
47 * @default ${app}-${stage}-${id}
48 */
49 name?: string;
50 /**
51 * The synthetic probe the WARP client runs.
52 */
53 data: DeviceDexTestData;
54 /**
55 * How often the test runs, as a duration string (e.g. `0h30m0s`).
56 */
57 interval: string;
58 /**
59 * Whether the test is active.
60 * @default true
61 */
62 enabled?: boolean;
63 /**
64 * Additional details about the test.
65 */
66 description?: string;
67 /**
68 * Device settings profiles (DEX rules) targeted by this test.
69 */
70 targetPolicies?: DeviceDexTestTargetPolicy[];
71 /**
72 * Whether the test only runs for devices matching `targetPolicies`.
73 */
74 targeted?: boolean;
75}
76
77export type DeviceDexTestAttributes = {
78 /** API UUID of the DEX test. */
79 testId: string;
80 /** Account that owns the test. */
81 accountId: string;
82 /** Observed test name. */
83 name: string;
84 /** Observed probe configuration. */
85 data: DeviceDexTestData;
86 /** Observed run interval. */
87 interval: string;
88 /** Whether the test is active. */
89 enabled: boolean;
90 /** Observed description, if any. */
91 description: string | undefined;
92};
93
94export type DeviceDexTest = Resource<
95 TypeId,
96 DeviceDexTestProps,
97 DeviceDexTestAttributes,
98 never,
99 Providers
100>;
101
102/**
103 * A Cloudflare Zero Trust **DEX synthetic test** — an HTTP or traceroute
104 * probe that enrolled WARP devices run on a schedule so the Digital
105 * Experience Monitoring dashboard can chart reachability and latency to
106 * your critical applications.
107 *
108 * Requires the DEX entitlement on the account (the API rejects writes
109 * with `Forbidden` / `dex.api.entitlements.missing` otherwise).
110 * ### Creating a DEX test
111 * **Example:** HTTP probe every 30 minutes
112 * ```typescript
113 * const test = yield* Cloudflare.Devices.DeviceDexTest("AppHealth", {
114 * data: { host: "https://app.example.com/health", kind: "http", method: "GET" },
115 * interval: "0h30m0s",
116 * description: "Internal app reachability",
117 * });
118 * ```
119 *
120 * **Example:** Traceroute probe targeting specific device profiles
121 * ```typescript
122 * const trace = yield* Cloudflare.Devices.DeviceDexTest("OriginTrace", {
123 * data: { host: "203.0.113.10", kind: "traceroute" },
124 * interval: "0h30m0s",
125 * targeted: true,
126 * targetPolicies: [{ id: profile.policyId }],
127 * });
128 * ```
129 *
130 * @see https://developers.cloudflare.com/cloudflare-one/insights/dex/tests/
131 *
132 * @resource
133 * @product Devices
134 * @category Cloudflare One (Zero Trust)
135 */
136export const DeviceDexTest = Resource<DeviceDexTest>(TypeId);
137
138/**
139 * Returns true if the given value is a DeviceDexTest resource.
140 */
141export const isDeviceDexTest = (value: unknown): value is DeviceDexTest =>
142 Predicate.hasProperty(value, "Type") && value.Type === TypeId;
143
144export const DeviceDexTestProvider = () =>
145 Provider.succeed(DeviceDexTest, {
146 stables: ["testId", "accountId"],
147
148 // Account-scoped collection: paginate the DEX tests list and hydrate each
149 // row (which already carries the full read shape) into Attributes. DEX
150 // requires the Digital Experience Monitoring entitlement; on unentitled
151 // accounts the list rejects with the typed `Forbidden`
152 // (dex.api.entitlements.missing), so treat that as "nothing to enumerate".
153 list: Effect.fn(function* () {
154 const { accountId } = yield* yield* CloudflareEnvironment;
155 return yield* zeroTrust.listDeviceDexTests.pages({ accountId }).pipe(
156 Stream.runCollect,
157 Effect.map((chunk) =>
158 Array.from(chunk).flatMap((page) =>
159 (page.result ?? [])
160 .filter((t) => t.testId != null)
161 .map((t) => toAttributes(t, accountId)),
162 ),
163 ),
164 Effect.catchTag("Forbidden", () => Effect.succeed([])),
165 );
166 }),
167
168 read: Effect.fn(function* ({ id, output, olds }) {
169 const { accountId } = yield* yield* CloudflareEnvironment;
170 const acct = output?.accountId ?? accountId;
171
172 if (output?.testId) {
173 const observed = yield* observeTest(acct, output.testId);
174 return observed ? toAttributes(observed, acct) : undefined;
175 }
176
177 // Cold lookup: DEX test names are unique per account, but carry no
178 // ownership markers — brand the match `Unowned`.
179 const name = yield* createTestName(id, olds?.name);
180 const match = yield* findByName(acct, name);
181 if (match) return Unowned(toAttributes(match, acct));
182 return undefined;
183 }),
184
185 reconcile: Effect.fn(function* ({ id, news, output }) {
186 const { accountId } = yield* yield* CloudflareEnvironment;
187 const name = yield* createTestName(id, news.name);
188
189 // 1. Observe — cached id is a hint; fall back to a name scan.
190 let observed = output?.testId
191 ? yield* observeTest(accountId, output.testId)
192 : undefined;
193 if (!observed) {
194 observed = yield* findByName(accountId, name);
195 }
196
197 // 2. Ensure — create when missing.
198 if (!observed) {
199 const created = yield* zeroTrust.createDeviceDexTest({
200 accountId,
201 name,
202 enabled: news.enabled ?? true,
203 interval: news.interval,
204 data: encodeData(news.data),
205 ...(news.description !== undefined
206 ? { description: news.description }
207 : {}),
208 ...(news.targeted !== undefined ? { targeted: news.targeted } : {}),
209 ...(news.targetPolicies !== undefined
210 ? { targetPolicies: news.targetPolicies }
211 : {}),
212 });
213 return toAttributes(created, accountId);
214 }
215
216 // 3. Sync — PUT the full desired state when the observed state
217 // differs; skip the call on a no-op.
218 const dirty =
219 (observed.name ?? "") !== name ||
220 (observed.interval ?? "") !== news.interval ||
221 (observed.enabled ?? true) !== (news.enabled ?? true) ||
222 (observed.description ?? undefined) !== news.description ||
223 !sameData(observed.data, news.data);
224 if (!dirty) {
225 return toAttributes(observed, accountId);
226 }
227 const updated = yield* zeroTrust.updateDeviceDexTest({
228 accountId,
229 dexTestId: observed.testId!,
230 name,
231 enabled: news.enabled ?? true,
232 interval: news.interval,
233 data: encodeData(news.data),
234 ...(news.description !== undefined
235 ? { description: news.description }
236 : {}),
237 ...(news.targeted !== undefined ? { targeted: news.targeted } : {}),
238 ...(news.targetPolicies !== undefined
239 ? { targetPolicies: news.targetPolicies }
240 : {}),
241 });
242 return toAttributes(updated, accountId);
243 }),
244
245 delete: Effect.fn(function* ({ output }) {
246 yield* zeroTrust
247 .deleteDeviceDexTest({
248 accountId: output.accountId,
249 dexTestId: output.testId,
250 })
251 .pipe(Effect.catchTag("DexTestNotFound", () => Effect.void));
252 }),
253 });
254
255/**
256 * Structural shape shared by get/list/create/update responses.
257 */
258type ObservedDexTest = {
259 data: {
260 host: string;
261 kind: "http" | "traceroute" | (string & {});
262 method?: "GET" | (string & {}) | null;
263 };
264 enabled: boolean;
265 interval: string;
266 name: string;
267 description?: string | null;
268 testId?: string | null;
269};
270
271/**
272 * Read a DEX test by id, mapping "gone" to `undefined`.
273 */
274const observeTest = (accountId: string, dexTestId: string) =>
275 zeroTrust
276 .getDeviceDexTest({ accountId, dexTestId })
277 .pipe(Effect.catchTag("DexTestNotFound", () => Effect.succeed(undefined)));
278
279/**
280 * Find a DEX test by exact name (names are unique per account).
281 */
282const findByName = (accountId: string, name: string) =>
283 zeroTrust
284 .listDeviceDexTests({ accountId })
285 .pipe(
286 Effect.map((list) =>
287 (list.result ?? []).find((t) => t.name === name && t.testId != null),
288 ),
289 );
290
291const createTestName = (id: string, name: string | undefined) =>
292 Effect.gen(function* () {
293 return name ?? (yield* createPhysicalName({ id, lowercase: true }));
294 });
295
296const encodeData = (
297 data: DeviceDexTestData,
298): {
299 host: string;
300 kind: "http" | "traceroute";
301 method?: "GET" | (string & {});
302} => ({
303 host: data.host,
304 kind: data.kind,
305 ...(data.method !== undefined ? { method: data.method } : {}),
306});
307
308const sameData = (
309 observed: ObservedDexTest["data"],
310 desired: DeviceDexTestData,
311): boolean =>
312 observed.host === desired.host &&
313 observed.kind === desired.kind &&
314 (observed.method ?? undefined) === desired.method;
315
316const toAttributes = (
317 test: ObservedDexTest,
318 accountId: string,
319): DeviceDexTestAttributes => ({
320 testId: test.testId ?? "",
321 accountId,
322 name: test.name,
323 data: {
324 host: test.data.host,
325 kind: test.data.kind === "traceroute" ? "traceroute" : "http",
326 ...(test.data.method != null ? { method: test.data.method } : {}),
327 },
328 interval: test.interval,
329 enabled: test.enabled,
330 description: test.description ?? undefined,
331});