1import * as zeroTrust from "@distilled.cloud/cloudflare/zero-trust";
2import * as Effect from "effect/Effect";
3import * as Predicate from "effect/Predicate";
4
5import * as Provider from "../../Provider.ts";
6import { Resource } from "../../Resource.ts";
7import { CloudflareEnvironment } from "../CloudflareEnvironment.ts";
8import type { Providers } from "../Providers.ts";
9
10const TypeId = "Cloudflare.Devices.Settings" as const;
11type TypeId = typeof TypeId;
12
13/**
14 * A snapshot of the account's WARP device settings, as observed on
15 * Cloudflare. Captured before Alchemy first patches the singleton and
16 * restored on destroy.
17 */
18export interface DeviceSettingsSnapshot {
19 /** Time limit, in seconds, that a user can bypass WARP with an override code. */
20 disableForTime?: number;
21 /** Whether the external emergency disconnect feature is enabled. */
22 externalEmergencySignalEnabled?: boolean;
23 /** SHA-256 fingerprint pinning the emergency-signal server certificate. */
24 externalEmergencySignalFingerprint?: string;
25 /** Polling interval for the emergency disconnect signal (e.g. `"5m"`). */
26 externalEmergencySignalInterval?: string;
27 /** HTTPS URL the emergency disconnect signal is fetched from. */
28 externalEmergencySignalUrl?: string;
29 /** Whether Gateway proxy filtering on TCP is enabled. */
30 gatewayProxyEnabled?: boolean;
31 /** Whether Gateway proxy filtering on UDP is enabled. */
32 gatewayUdpProxyEnabled?: boolean;
33 /** Whether the Cloudflare-managed root certificate is installed on devices. */
34 rootCertificateInstallationEnabled?: boolean;
35 /** Whether CGNAT virtual IPv4 addressing is used. */
36 useZtVirtualIp?: boolean;
37}
38
39export interface DeviceSettingsProps {
40 /**
41 * Sets the time limit, in seconds, that a user can use an override code
42 * to bypass WARP. `0` disables override codes.
43 */
44 disableForTime?: number;
45 /**
46 * Controls whether the external emergency disconnect feature is enabled.
47 */
48 externalEmergencySignalEnabled?: boolean;
49 /**
50 * The SHA-256 fingerprint (64 hexadecimal characters) of the HTTPS
51 * server certificate for {@link externalEmergencySignalUrl}. When set,
52 * the WARP client uses it to verify the server's identity.
53 */
54 externalEmergencySignalFingerprint?: string;
55 /**
56 * The interval at which the WARP client fetches the emergency disconnect
57 * signal, as a duration string (e.g. `"5m"`, `"2m30s"`, `"1h"`).
58 * Minimum 30 seconds.
59 */
60 externalEmergencySignalInterval?: string;
61 /**
62 * The HTTPS URL from which to fetch the emergency disconnect signal.
63 * Must use HTTPS with an IPv4 or IPv6 address as the host.
64 */
65 externalEmergencySignalUrl?: string;
66 /**
67 * Enable Gateway proxy filtering on TCP.
68 */
69 gatewayProxyEnabled?: boolean;
70 /**
71 * Enable Gateway proxy filtering on UDP.
72 */
73 gatewayUdpProxyEnabled?: boolean;
74 /**
75 * Enable installation of the Cloudflare-managed root certificate on
76 * enrolled devices.
77 */
78 rootCertificateInstallationEnabled?: boolean;
79 /**
80 * Enable using CGNAT virtual IPv4 addressing.
81 */
82 useZtVirtualIp?: boolean;
83}
84
85export type DeviceSettingsAttributes = DeviceSettingsSnapshot & {
86 /** Account that owns the device settings singleton. */
87 accountId: string;
88 /**
89 * The settings the account had before Alchemy first patched them.
90 * Restored (via PUT, which resets unspecified fields) on destroy, so
91 * deleting the resource puts the account back the way it was found.
92 */
93 initialSettings: DeviceSettingsSnapshot;
94};
95
96export type DeviceSettings = Resource<
97 TypeId,
98 DeviceSettingsProps,
99 DeviceSettingsAttributes,
100 never,
101 Providers
102>;
103
104/**
105 * Manages the **singleton** Cloudflare Zero Trust **device settings** for
106 * an account (`/accounts/{accountId}/devices/settings`) — account-wide
107 * WARP toggles like the Gateway TCP/UDP proxy, managed root certificate
108 * installation, and CGNAT virtual IP.
109 *
110 * The singleton always exists, so reconcile patches only the declared
111 * fields in place. The pre-management snapshot is captured on first touch
112 * and restored on destroy (capture-and-restore), returning the account to
113 * the state Alchemy found it in.
114 * ### Managing device settings
115 * **Example:** Enable the Gateway proxy
116 * ```typescript
117 * yield* Cloudflare.Devices.DeviceSettings("Devices", {
118 * gatewayProxyEnabled: true,
119 * gatewayUdpProxyEnabled: true,
120 * });
121 * ```
122 *
123 * **Example:** Allow one-hour WARP override codes
124 * ```typescript
125 * yield* Cloudflare.Devices.DeviceSettings("Devices", {
126 * disableForTime: 3600,
127 * });
128 * ```
129 *
130 * @see https://developers.cloudflare.com/cloudflare-one/connections/connect-devices/warp/
131 *
132 * @resource
133 * @product Devices
134 * @category Cloudflare One (Zero Trust)
135 */
136export const DeviceSettings = Resource<DeviceSettings>(TypeId);
137
138/**
139 * Returns true if the given value is a DeviceSettings resource.
140 */
141export const isDeviceSettings = (value: unknown): value is DeviceSettings =>
142 Predicate.hasProperty(value, "Type") && value.Type === TypeId;
143
144export const DeviceSettingsProvider = () =>
145 Provider.succeed(DeviceSettings, {
146 nuke: { singleton: true },
147 stables: ["accountId", "initialSettings"],
148
149 // Account singleton: there is exactly one device-settings object per
150 // account and no enumeration API. Mirror `read` — observe the single
151 // singleton and return it as a one-element array. With no prior
152 // output, the observed snapshot is itself the restore target.
153 list: Effect.fn(function* () {
154 const { accountId } = yield* yield* CloudflareEnvironment;
155 const observed = yield* observeSettings(accountId);
156 return [toAttributes(accountId, observed, observed)];
157 }),
158
159 read: Effect.fn(function* ({ output }) {
160 const { accountId } = yield* yield* CloudflareEnvironment;
161 const acct = output?.accountId ?? accountId;
162 const observed = yield* observeSettings(acct);
163 // The singleton always exists with account defaults — there is
164 // nothing to "own", so a cold read adopts freely. The observed
165 // snapshot at adoption time becomes the restore target.
166 const initialSettings = output?.initialSettings ?? observed;
167 return toAttributes(acct, observed, initialSettings);
168 }),
169
170 reconcile: Effect.fn(function* ({ news, output }) {
171 const { accountId } = yield* yield* CloudflareEnvironment;
172
173 // 1. Observe — the singleton always exists; read its live state.
174 const observed = yield* observeSettings(accountId);
175
176 // 2. Capture — the pre-management snapshot, restored on destroy.
177 // `output` (including an adoption read) already carries it;
178 // otherwise this is our first touch.
179 const initialSettings = output?.initialSettings ?? observed;
180
181 // 3. Sync — patch only the declared fields that differ.
182 const changes: Partial<DeviceSettingsSnapshot> = {};
183 const assign = <K extends keyof DeviceSettingsSnapshot>(
184 key: K,
185 value: DeviceSettingsSnapshot[K],
186 ) => {
187 changes[key] = value;
188 };
189 let dirty = false;
190 for (const key of SETTING_KEYS) {
191 const desired = news[key];
192 if (desired === undefined) continue;
193 if (observed[key] !== desired) {
194 assign(key, desired);
195 dirty = true;
196 }
197 }
198 if (!dirty) {
199 return toAttributes(accountId, observed, initialSettings);
200 }
201 yield* zeroTrust.patchDeviceSetting({ accountId, ...changes });
202
203 // 4. Return — re-read so attrs reflect post-sync truth.
204 const final = yield* observeSettings(accountId);
205 return toAttributes(accountId, final, initialSettings);
206 }),
207
208 delete: Effect.fn(function* ({ output }) {
209 const { accountId, initialSettings } = output;
210 // Observe — skip the restore when the account already matches the
211 // captured snapshot (idempotent re-delete after a crashed run).
212 const observed = yield* observeSettings(accountId);
213 if (sameSnapshot(observed, initialSettings)) return;
214 // Restore via PUT: fields absent from the captured snapshot were
215 // unset before we managed the singleton, and PUT resets them.
216 yield* zeroTrust.putDeviceSetting({ accountId, ...initialSettings });
217 }),
218 });
219
220const SETTING_KEYS = [
221 "disableForTime",
222 "externalEmergencySignalEnabled",
223 "externalEmergencySignalFingerprint",
224 "externalEmergencySignalInterval",
225 "externalEmergencySignalUrl",
226 "gatewayProxyEnabled",
227 "gatewayUdpProxyEnabled",
228 "rootCertificateInstallationEnabled",
229 "useZtVirtualIp",
230] as const satisfies readonly (keyof DeviceSettingsSnapshot)[];
231
232/**
233 * Read the live settings, normalized to a `null`-free snapshot.
234 */
235const observeSettings = (accountId: string) =>
236 zeroTrust.getDeviceSetting({ accountId }).pipe(
237 Effect.map((s): DeviceSettingsSnapshot => {
238 const snapshot: DeviceSettingsSnapshot = {};
239 if (s.disableForTime != null) snapshot.disableForTime = s.disableForTime;
240 if (s.externalEmergencySignalEnabled != null) {
241 snapshot.externalEmergencySignalEnabled =
242 s.externalEmergencySignalEnabled;
243 }
244 if (s.externalEmergencySignalFingerprint != null) {
245 snapshot.externalEmergencySignalFingerprint =
246 s.externalEmergencySignalFingerprint;
247 }
248 if (s.externalEmergencySignalInterval != null) {
249 snapshot.externalEmergencySignalInterval =
250 s.externalEmergencySignalInterval;
251 }
252 if (s.externalEmergencySignalUrl != null) {
253 snapshot.externalEmergencySignalUrl = s.externalEmergencySignalUrl;
254 }
255 if (s.gatewayProxyEnabled != null) {
256 snapshot.gatewayProxyEnabled = s.gatewayProxyEnabled;
257 }
258 if (s.gatewayUdpProxyEnabled != null) {
259 snapshot.gatewayUdpProxyEnabled = s.gatewayUdpProxyEnabled;
260 }
261 if (s.rootCertificateInstallationEnabled != null) {
262 snapshot.rootCertificateInstallationEnabled =
263 s.rootCertificateInstallationEnabled;
264 }
265 if (s.useZtVirtualIp != null) snapshot.useZtVirtualIp = s.useZtVirtualIp;
266 return snapshot;
267 }),
268 );
269
270const toAttributes = (
271 accountId: string,
272 observed: DeviceSettingsSnapshot,
273 initialSettings: DeviceSettingsSnapshot,
274): DeviceSettingsAttributes => ({
275 ...observed,
276 accountId,
277 initialSettings,
278});
279
280const sameSnapshot = (
281 a: DeviceSettingsSnapshot,
282 b: DeviceSettingsSnapshot,
283): boolean => SETTING_KEYS.every((key) => a[key] === b[key]);