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";
12import type { DeviceDefaultProfile } from "./DefaultProfile.ts";
13
14const TypeId = "Cloudflare.Devices.CustomProfile" as const;
15type TypeId = typeof TypeId;
16
17/**
18 * Configuration for a WARP custom device profile.
19 *
20 * A custom profile applies its settings to the subset of devices matched
21 * by the `match` wirefilter expression, evaluated in ascending
22 * `precedence` order. Devices not matched by any custom profile fall back
23 * to the account's default profile.
24 */
25export interface DeviceCustomProfileProps {
26 /**
27 * Name of the device settings profile. If omitted, a unique name is
28 * generated from the app, stage, and logical ID.
29 * @default ${app}-${stage}-${id}
30 */
31 name?: string;
32 /**
33 * The wirefilter expression to match devices, e.g.
34 * `identity.email == "user@example.com"` or `os.name == "windows"`.
35 * Mutable — patched in place.
36 */
37 match: string;
38 /**
39 * The precedence of the profile. Lower values indicate higher
40 * precedence; profiles are evaluated in ascending order. Must be
41 * unique within the account.
42 */
43 precedence: number;
44 /**
45 * Whether the profile is applied to matching devices.
46 * @default true
47 */
48 enabled?: boolean;
49 /**
50 * A human-readable description of the profile.
51 */
52 description?: string;
53 /**
54 * Routes that WARP will tunnel for matched devices. Mutually exclusive
55 * with {@link exclude}. Pushed to the per-profile include list endpoint.
56 */
57 include?: DeviceDefaultProfile.SplitTunnelEntry[];
58 /**
59 * Routes that WARP will bypass for matched devices. Mutually exclusive
60 * with {@link include}. Pushed to the per-profile exclude list endpoint.
61 */
62 exclude?: DeviceDefaultProfile.SplitTunnelEntry[];
63 /**
64 * Local-fallback DNS suffixes for matched devices. For each suffix,
65 * WARP resolves matching names via the given DNS servers.
66 */
67 fallbackDomains?: DeviceDefaultProfile.FallbackDomain[];
68 /**
69 * Seconds to wait before activating the captive-portal bypass. A value
70 * of `0` disables the bypass.
71 */
72 captivePortal?: number;
73 /**
74 * Seconds to wait before WARP reconnects after the user disables it.
75 * `0` keeps WARP disabled until the user re-enables it.
76 */
77 autoConnect?: number;
78 /**
79 * Whether the user is allowed to remove the device from the team.
80 */
81 allowedToLeave?: boolean;
82 /**
83 * Whether the user is allowed to switch WARP between Gateway-with-WARP
84 * and other modes.
85 */
86 allowModeSwitch?: boolean;
87 /**
88 * Whether WARP should surface available client updates to the user.
89 */
90 allowUpdates?: boolean;
91 /**
92 * When `true`, fallback domains without an explicit `dnsServer` are NOT
93 * resolved via the system resolver — WARP refuses to resolve them.
94 */
95 disableAutoFallback?: boolean;
96 /**
97 * When `true`, Microsoft 365 service IP ranges are added to the
98 * split-tunnel exclude list automatically.
99 */
100 excludeOfficeIps?: boolean;
101 /**
102 * When `true`, the user cannot turn the WARP switch off.
103 */
104 switchLocked?: boolean;
105 /**
106 * Selects WARP's tunneling behavior: `"warp"` for the full WireGuard
107 * tunnel, `"proxy"` for a local SOCKS proxy on the chosen `port`.
108 */
109 serviceModeV2?: DeviceDefaultProfile.ServiceModeV2;
110 /**
111 * Minutes of LAN access permitted after a WARP reconnect. `0` means
112 * unrestricted until the next WARP cycle.
113 */
114 lanAllowMinutes?: number;
115 /**
116 * Prefix length of the LAN subnet that {@link lanAllowMinutes} applies
117 * to (e.g. `24` for a `/24`).
118 */
119 lanAllowSubnetSize?: number;
120 /**
121 * When `true`, the OS registers WARP's local interface IP with the
122 * on-premises DNS server.
123 */
124 registerInterfaceIpWithDns?: boolean;
125 /**
126 * When `true`, WARP signals SCCM that the device is inside a VPN
127 * boundary. Windows only.
128 */
129 sccmVpnBoundarySupport?: boolean;
130 /**
131 * URL launched when the user clicks "Send Feedback" in the client.
132 */
133 supportUrl?: string;
134 /**
135 * Which underlying tunnel protocol WARP should use (e.g. `"wireguard"`,
136 * `"masque"`).
137 */
138 tunnelProtocol?: string;
139}
140
141export type DeviceCustomProfileAttributes = {
142 /** API UUID of the profile. */
143 policyId: string;
144 /** Account that owns the profile. */
145 accountId: string;
146 /** Observed profile name. */
147 name: string;
148 /** Observed wirefilter match expression. */
149 match: string | undefined;
150 /** Observed precedence. */
151 precedence: number | undefined;
152 /** Whether the profile is applied to matching devices. */
153 enabled: boolean | undefined;
154 /** Observed description. */
155 description: string | undefined;
156 /** Always `false` — custom profiles are never the default profile. */
157 default: boolean;
158 /** Observed per-profile split-tunnel include list. */
159 include: DeviceDefaultProfile.SplitTunnelEntry[];
160 /** Observed per-profile split-tunnel exclude list. */
161 exclude: DeviceDefaultProfile.SplitTunnelEntry[];
162 /** Observed per-profile fallback domains. */
163 fallbackDomains: DeviceDefaultProfile.FallbackDomain[];
164 /** Observed captive-portal timeout (seconds). */
165 captivePortal: number | undefined;
166 /** Observed auto-connect timeout (seconds). */
167 autoConnect: number | undefined;
168 /** Whether devices are allowed to leave the organization. */
169 allowedToLeave: boolean | undefined;
170 /** Whether the user may switch WARP modes. */
171 allowModeSwitch: boolean | undefined;
172 /** Whether update notifications are shown. */
173 allowUpdates: boolean | undefined;
174 /** Whether auto-fallback to system DNS is disabled. */
175 disableAutoFallback: boolean | undefined;
176 /** Whether Microsoft IPs are auto-excluded. */
177 excludeOfficeIps: boolean | undefined;
178 /** Whether the WARP switch is locked on. */
179 switchLocked: boolean | undefined;
180 /** Tunneling mode configuration. */
181 serviceModeV2: DeviceDefaultProfile.ServiceModeV2 | undefined;
182 /** Whether the WARP IP is registered with the on-prem DNS server. */
183 registerInterfaceIpWithDns: boolean | undefined;
184 /** Whether SCCM VPN-boundary support is enabled. */
185 sccmVpnBoundarySupport: boolean | undefined;
186 /** Send-Feedback URL. */
187 supportUrl: string | undefined;
188 /** Tunnel protocol selection. */
189 tunnelProtocol: string | undefined;
190};
191
192export type DeviceCustomProfile = Resource<
193 TypeId,
194 DeviceCustomProfileProps,
195 DeviceCustomProfileAttributes,
196 never,
197 Providers
198>;
199
200/**
201 * A Cloudflare WARP **custom device profile** — a settings profile applied
202 * to the subset of devices matched by a wirefilter `match` expression at a
203 * given `precedence`.
204 *
205 * All properties are mutable in place: the profile itself is patched, and
206 * the per-profile split-tunnel include/exclude and fallback-domain lists
207 * are replaced via their dedicated endpoints. Deleting the resource
208 * deletes the profile; matched devices fall back to the account's default
209 * profile.
210 * ### Creating a profile
211 * **Example:** Profile for a user group
212 * ```typescript
213 * const profile = yield* Cloudflare.Devices.DeviceCustomProfile("Contractors", {
214 * match: 'identity.groups.name == "contractors"',
215 * precedence: 100,
216 * description: "Locked-down profile for contractors",
217 * switchLocked: true,
218 * });
219 * ```
220 *
221 * ### Split tunneling
222 * **Example:** Exclude internal ranges from the tunnel
223 * ```typescript
224 * yield* Cloudflare.Devices.DeviceCustomProfile("Engineering", {
225 * match: 'identity.groups.name == "engineering"',
226 * precedence: 50,
227 * exclude: [
228 * { address: "10.0.0.0/8", description: "RFC1918" },
229 * ],
230 * });
231 * ```
232 *
233 * ### Fallback domains
234 * **Example:** Resolve a private suffix via an on-prem DNS server
235 * ```typescript
236 * yield* Cloudflare.Devices.DeviceCustomProfile("CorpDns", {
237 * match: 'identity.email matches ".*@corp.example.com"',
238 * precedence: 10,
239 * fallbackDomains: [
240 * { suffix: "corp.example.com", dnsServer: ["10.0.0.53"] },
241 * ],
242 * });
243 * ```
244 *
245 * @see https://developers.cloudflare.com/cloudflare-one/connections/connect-devices/warp/configure-warp/device-profiles/
246 *
247 * @resource
248 * @product Devices
249 * @category Cloudflare One (Zero Trust)
250 */
251export const DeviceCustomProfile = Resource<DeviceCustomProfile>(TypeId);
252
253/**
254 * Returns true if the given value is a DeviceCustomProfile resource.
255 */
256export const isDeviceCustomProfile = (
257 value: unknown,
258): value is DeviceCustomProfile =>
259 Predicate.hasProperty(value, "Type") && value.Type === TypeId;
260
261export const DeviceCustomProfileProvider = () =>
262 Provider.succeed(DeviceCustomProfile, {
263 stables: ["policyId", "accountId", "default"],
264
265 read: Effect.fn(function* ({ id, output, olds }) {
266 const { accountId } = yield* yield* CloudflareEnvironment;
267 const acct = output?.accountId ?? accountId;
268
269 // Owned path: refresh by our persisted policy id.
270 if (output?.policyId) {
271 const observed = yield* observeProfile(acct, output.policyId);
272 return observed ? yield* buildAttrs(acct, observed) : undefined;
273 }
274
275 // Cold lookup: recover from lost state by exact name. Profile names
276 // carry no ownership markers, so brand the match `Unowned` and let
277 // the engine gate takeover behind the adopt policy.
278 const name = yield* createProfileName(id, olds?.name);
279 const match = yield* findByName(acct, name);
280 if (match?.policyId) {
281 const observed = yield* observeProfile(acct, match.policyId);
282 if (observed) return Unowned(yield* buildAttrs(acct, observed));
283 }
284 return undefined;
285 }),
286
287 reconcile: Effect.fn(function* ({ id, news, output }) {
288 const { accountId } = yield* yield* CloudflareEnvironment;
289 const name = yield* createProfileName(id, news.name);
290
291 // 1. Observe — the policy id cached on `output` is a hint, not a
292 // guarantee: a missing profile falls through to create.
293 let observed = output?.policyId
294 ? yield* observeProfile(accountId, output.policyId)
295 : undefined;
296
297 // 2. Ensure — create when missing. Names are not unique on
298 // Cloudflare's side, so there is no AlreadyExists race; a
299 // precedence collision is a real validation error and propagates.
300 if (!observed) {
301 const created = yield* zeroTrust.createDevicePolicyCustom({
302 accountId,
303 name,
304 match: news.match,
305 precedence: news.precedence,
306 enabled: news.enabled,
307 description: news.description,
308 include: news.include?.map(encodeSplit),
309 exclude: news.exclude?.map(encodeSplit),
310 captivePortal: news.captivePortal,
311 autoConnect: news.autoConnect,
312 allowedToLeave: news.allowedToLeave,
313 allowModeSwitch: news.allowModeSwitch,
314 allowUpdates: news.allowUpdates,
315 disableAutoFallback: news.disableAutoFallback,
316 excludeOfficeIps: news.excludeOfficeIps,
317 switchLocked: news.switchLocked,
318 serviceModeV2: news.serviceModeV2,
319 lanAllowMinutes: news.lanAllowMinutes,
320 lanAllowSubnetSize: news.lanAllowSubnetSize,
321 registerInterfaceIpWithDns: news.registerInterfaceIpWithDns,
322 sccmVpnBoundarySupport: news.sccmVpnBoundarySupport,
323 supportUrl: news.supportUrl,
324 tunnelProtocol: news.tunnelProtocol,
325 });
326 const policyId = created?.policyId;
327 if (!policyId) {
328 return yield* Effect.fail(
329 new Error(
330 "Cloudflare did not return a policy id for the created custom device profile",
331 ),
332 );
333 }
334 observed = yield* observeProfile(accountId, policyId);
335 if (!observed) {
336 return yield* Effect.fail(
337 new Error(
338 `custom device profile ${policyId} disappeared right after create`,
339 ),
340 );
341 }
342 }
343 const policyId = observed.policyId!;
344
345 // 3. Sync (a) — patch the profile body with only the changed fields.
346 const patch: zeroTrust.PatchDevicePolicyCustomRequest = {
347 accountId,
348 policyId,
349 };
350 let dirty = false;
351 const setIf = <
352 K extends Exclude<
353 keyof zeroTrust.PatchDevicePolicyCustomRequest,
354 "accountId" | "policyId"
355 >,
356 >(
357 key: K,
358 desired: zeroTrust.PatchDevicePolicyCustomRequest[K],
359 observedValue: unknown,
360 ) => {
361 if (desired === undefined) return;
362 if (!sameJSON(desired, observedValue)) {
363 patch[key] = desired;
364 dirty = true;
365 }
366 };
367 setIf("name", name, observed.name ?? undefined);
368 setIf("match", news.match, denull(observed.match));
369 setIf("precedence", news.precedence, denull(observed.precedence));
370 setIf("enabled", news.enabled, denull(observed.enabled));
371 setIf("description", news.description, denull(observed.description));
372 setIf(
373 "captivePortal",
374 news.captivePortal,
375 denull(observed.captivePortal),
376 );
377 setIf("autoConnect", news.autoConnect, denull(observed.autoConnect));
378 setIf(
379 "allowedToLeave",
380 news.allowedToLeave,
381 denull(observed.allowedToLeave),
382 );
383 setIf(
384 "allowModeSwitch",
385 news.allowModeSwitch,
386 denull(observed.allowModeSwitch),
387 );
388 setIf("allowUpdates", news.allowUpdates, denull(observed.allowUpdates));
389 setIf(
390 "disableAutoFallback",
391 news.disableAutoFallback,
392 denull(observed.disableAutoFallback),
393 );
394 setIf(
395 "excludeOfficeIps",
396 news.excludeOfficeIps,
397 denull(observed.excludeOfficeIps),
398 );
399 setIf("switchLocked", news.switchLocked, denull(observed.switchLocked));
400 setIf(
401 "registerInterfaceIpWithDns",
402 news.registerInterfaceIpWithDns,
403 denull(observed.registerInterfaceIpWithDns),
404 );
405 setIf(
406 "sccmVpnBoundarySupport",
407 news.sccmVpnBoundarySupport,
408 denull(observed.sccmVpnBoundarySupport),
409 );
410 setIf("supportUrl", news.supportUrl, denull(observed.supportUrl));
411 setIf(
412 "tunnelProtocol",
413 news.tunnelProtocol,
414 denull(observed.tunnelProtocol),
415 );
416 if (
417 news.serviceModeV2 !== undefined &&
418 !sameJSON(
419 news.serviceModeV2,
420 normalizeServiceMode(observed.serviceModeV2),
421 )
422 ) {
423 patch.serviceModeV2 = news.serviceModeV2;
424 dirty = true;
425 }
426 // lanAllow* are accepted by PATCH but not reliably echoed on GET,
427 // so push them whenever the user sets them — the API is idempotent.
428 if (news.lanAllowMinutes !== undefined) {
429 patch.lanAllowMinutes = news.lanAllowMinutes;
430 dirty = true;
431 }
432 if (news.lanAllowSubnetSize !== undefined) {
433 patch.lanAllowSubnetSize = news.lanAllowSubnetSize;
434 dirty = true;
435 }
436 if (dirty) {
437 yield* zeroTrust.patchDevicePolicyCustom(patch);
438 }
439
440 // 3. Sync (b) — replace the per-profile lists when they differ from
441 // the observed cloud state.
442 const lists = yield* observeLists(accountId, policyId);
443 if (
444 news.include !== undefined &&
445 !sameJSON(news.include, lists.include)
446 ) {
447 yield* zeroTrust.putDevicePolicyCustomInclude({
448 accountId,
449 policyId,
450 body: news.include.map(encodeSplit),
451 });
452 }
453 if (
454 news.exclude !== undefined &&
455 !sameJSON(news.exclude, lists.exclude)
456 ) {
457 yield* zeroTrust.putDevicePolicyCustomExclude({
458 accountId,
459 policyId,
460 body: news.exclude.map(encodeSplit),
461 });
462 }
463 if (
464 news.fallbackDomains !== undefined &&
465 !sameJSON(news.fallbackDomains, lists.fallbackDomains)
466 ) {
467 yield* zeroTrust.putDevicePolicyCustomFallbackDomain({
468 accountId,
469 policyId,
470 domains: news.fallbackDomains.map(encodeFallback),
471 });
472 }
473
474 // 4. Return — re-read so attrs reflect post-sync truth.
475 const final = yield* observeProfile(accountId, policyId);
476 if (!final) {
477 return yield* Effect.fail(
478 new Error(
479 `custom device profile ${policyId} disappeared during reconcile`,
480 ),
481 );
482 }
483 return yield* buildAttrs(accountId, final);
484 }),
485
486 delete: Effect.fn(function* ({ output }) {
487 yield* zeroTrust
488 .deleteDevicePolicyCustom({
489 accountId: output.accountId,
490 policyId: output.policyId,
491 })
492 .pipe(Effect.catchTag("DevicePolicyNotFound", () => Effect.void));
493 }),
494
495 // Account collection: enumerate every custom device profile in the
496 // ambient account, then hydrate each into the exact `read` Attributes
497 // shape (same `observeProfile` + `buildAttrs` path read uses, so the
498 // per-profile split-tunnel/fallback lists are fetched too). Bounded
499 // concurrency keeps the fan-out polite; a profile that vanishes
500 // mid-enumeration is dropped via the typed `DevicePolicyNotFound` map.
501 list: Effect.fn(function* () {
502 const { accountId } = yield* yield* CloudflareEnvironment;
503 const items = yield* zeroTrust.listDevicePolicyCustoms
504 .pages({ accountId })
505 .pipe(
506 Stream.runCollect,
507 Effect.map((chunk) =>
508 Array.from(chunk).flatMap((page) => page.result ?? []),
509 ),
510 );
511 const rows = yield* Effect.forEach(
512 items.filter((p) => p.policyId != null),
513 (p) =>
514 Effect.gen(function* () {
515 const observed = yield* observeProfile(accountId, p.policyId!);
516 return observed
517 ? yield* buildAttrs(accountId, observed)
518 : undefined;
519 }),
520 { concurrency: 10 },
521 );
522 return rows.filter(
523 (row): row is DeviceCustomProfileAttributes => row !== undefined,
524 );
525 }),
526 });
527
528type ObservedProfile = NonNullable<zeroTrust.GetDevicePolicyCustomResponse>;
529
530/**
531 * Read a custom profile by id, mapping "gone" (`DevicePolicyNotFound`,
532 * Cloudflare error code 2052) to `undefined`.
533 */
534const observeProfile = (accountId: string, policyId: string) =>
535 zeroTrust.getDevicePolicyCustom({ accountId, policyId }).pipe(
536 Effect.map((p): ObservedProfile | undefined => p ?? undefined),
537 Effect.catchTag("DevicePolicyNotFound", () => Effect.succeed(undefined)),
538 );
539
540/**
541 * Read the three per-profile list endpoints.
542 */
543const observeLists = Effect.fn(function* (accountId: string, policyId: string) {
544 const [include, exclude, fallback] = yield* Effect.all(
545 [
546 zeroTrust.getDevicePolicyCustomInclude({ accountId, policyId }),
547 zeroTrust.getDevicePolicyCustomExclude({ accountId, policyId }),
548 zeroTrust.getDevicePolicyCustomFallbackDomain({ accountId, policyId }),
549 ],
550 { concurrency: "unbounded" },
551 );
552 return {
553 include: (include.result ?? []).map(normalizeSplit),
554 exclude: (exclude.result ?? []).map(normalizeSplit),
555 fallbackDomains: (fallback.result ?? []).map(normalizeFallback),
556 };
557});
558
559/**
560 * Find a profile by exact name via the list endpoint (oldest-id-first for
561 * determinism when names collide).
562 */
563const findByName = (accountId: string, name: string) =>
564 zeroTrust.listDevicePolicyCustoms.items({ accountId }).pipe(
565 Stream.filter((p) => p.name === name && p.policyId != null),
566 Stream.runCollect,
567 Effect.map((chunk) =>
568 Array.from(chunk)
569 .sort((a, b) => (a.policyId ?? "").localeCompare(b.policyId ?? ""))
570 .at(0),
571 ),
572 );
573
574const createProfileName = (id: string, name: string | undefined) =>
575 Effect.gen(function* () {
576 return name ?? (yield* createPhysicalName({ id, lowercase: true }));
577 });
578
579const buildAttrs = Effect.fn(function* (
580 accountId: string,
581 profile: ObservedProfile,
582) {
583 const policyId = profile.policyId!;
584 const lists = yield* observeLists(accountId, policyId);
585 const attrs: DeviceCustomProfileAttributes = {
586 policyId,
587 accountId,
588 name: profile.name ?? "",
589 match: denull(profile.match),
590 precedence: denull(profile.precedence),
591 enabled: denull(profile.enabled),
592 description: denull(profile.description),
593 default: profile.default ?? false,
594 include: lists.include,
595 exclude: lists.exclude,
596 fallbackDomains: lists.fallbackDomains,
597 captivePortal: denull(profile.captivePortal),
598 autoConnect: denull(profile.autoConnect),
599 allowedToLeave: denull(profile.allowedToLeave),
600 allowModeSwitch: denull(profile.allowModeSwitch),
601 allowUpdates: denull(profile.allowUpdates),
602 disableAutoFallback: denull(profile.disableAutoFallback),
603 excludeOfficeIps: denull(profile.excludeOfficeIps),
604 switchLocked: denull(profile.switchLocked),
605 serviceModeV2: normalizeServiceMode(profile.serviceModeV2),
606 registerInterfaceIpWithDns: denull(profile.registerInterfaceIpWithDns),
607 sccmVpnBoundarySupport: denull(profile.sccmVpnBoundarySupport),
608 supportUrl: denull(profile.supportUrl),
609 tunnelProtocol: denull(profile.tunnelProtocol),
610 };
611 return attrs;
612});
613
614/**
615 * Strip Cloudflare's `null` echoes to `undefined` so structural equality
616 * (`JSON.stringify`) works.
617 */
618const denull = <T>(v: T | null | undefined): T | undefined =>
619 v == null ? undefined : v;
620
621const normalizeServiceMode = (
622 sm: { mode?: string | null; port?: number | null } | null | undefined,
623): DeviceDefaultProfile.ServiceModeV2 | undefined =>
624 sm && sm.mode != null
625 ? {
626 mode: (sm.mode === "proxy" ? "proxy" : "warp") as "warp" | "proxy",
627 port: denull(sm.port),
628 }
629 : undefined;
630
631const normalizeSplit = (
632 entry:
633 | { address: string; description?: string | null }
634 | { host: string; description?: string | null },
635): DeviceDefaultProfile.SplitTunnelEntry => ({
636 address: "address" in entry ? entry.address : "",
637 host: "host" in entry ? entry.host : undefined,
638 description: denull(entry.description),
639});
640
641const normalizeFallback = (entry: {
642 suffix: string;
643 description?: string | null;
644 dnsServer?: string[] | null;
645}): DeviceDefaultProfile.FallbackDomain => ({
646 suffix: entry.suffix,
647 description: denull(entry.description),
648 dnsServer: denull(entry.dnsServer),
649});
650
651/**
652 * Encode a SplitTunnelEntry for the API's union shape (`address` OR `host`).
653 * Prefers `host` when set since the address would otherwise be empty.
654 */
655const encodeSplit = (
656 e: DeviceDefaultProfile.SplitTunnelEntry,
657):
658 | { address: string; description?: string }
659 | { host: string; description?: string } => {
660 if (e.host && !e.address) {
661 return e.description !== undefined
662 ? { host: e.host, description: e.description }
663 : { host: e.host };
664 }
665 return e.description !== undefined
666 ? { address: e.address, description: e.description }
667 : { address: e.address };
668};
669
670const encodeFallback = (
671 d: DeviceDefaultProfile.FallbackDomain,
672): { suffix: string; description?: string; dnsServer?: string[] } => {
673 const out: { suffix: string; description?: string; dnsServer?: string[] } = {
674 suffix: d.suffix,
675 };
676 if (d.description !== undefined) out.description = d.description;
677 if (d.dnsServer !== undefined) out.dnsServer = d.dnsServer;
678 return out;
679};
680
681/** Structural deep-equality via canonical JSON. */
682const sameJSON = (a: unknown, b: unknown): boolean =>
683 JSON.stringify(a) === JSON.stringify(b);