1import * as zeroTrust from "@distilled.cloud/cloudflare/zero-trust";
2import * as Effect from "effect/Effect";
3import * as Schedule from "effect/Schedule";
4import * as Stream from "effect/Stream";
5
6import { Unowned } from "../../AdoptPolicy.ts";
7import { createPhysicalName } from "../../PhysicalName.ts";
8import * as Provider from "../../Provider.ts";
9import { isResourceOfType, Resource } from "../../Resource.ts";
10import { arrayEquals } from "../../Util/equal.ts";
11import { CloudflareEnvironment } from "../CloudflareEnvironment.ts";
12import type { Providers } from "../Providers.ts";
13import {
14 normalizePolicyRules,
15 type PolicyDecision,
16 type PolicyExcludeRule,
17 type PolicyExcludeRuleInput,
18 type PolicyRequireRule,
19 type PolicyRequireRuleInput,
20 type PolicyRule,
21 type PolicyRuleInput,
22} from "./Policy.ts";
23
24/**
25 * Application type literal — every value Cloudflare's Access service
26 * recognises. Stable across reconciles; changing it triggers a replacement.
27 */
28export type ApplicationType =
29 | "self_hosted"
30 | "saas"
31 | "ssh"
32 | "vnc"
33 | "bookmark"
34 | "warp"
35 | "infrastructure"
36 | "app_launcher"
37 | "biso"
38 | "dash_sso";
39
40/**
41 * A destination that this Access application protects.
42 *
43 * Cloudflare supports these destination flavours:
44 * - `public` — a public hostname/URI you own in Cloudflare (the legacy
45 * `domain` field on `ApplicationProps` covers the simple case).
46 * - `private` — a hostname or CIDR reachable through a Cloudflare Tunnel.
47 * Traffic from WARP-enrolled devices is intercepted and forwarded
48 * through the tunnel; identity is enforced before forwarding.
49 * - `via_mcp_server_portal` — routes via a managed MCP server portal.
50 * - `worker` / `preview_worker` — a specific Cloudflare Worker's production
51 * traffic (custom domains, routes, `workers.dev`) or its version preview
52 * URLs, keyed by the Worker's immutable ID (its `workerId` attribute).
53 * Usually you don't write these by hand — set the `access` prop on the
54 * `Cloudflare.Worker` instead, and the Worker enrolls itself into the
55 * application.
56 * - `all_workers` / `all_preview_workers` — every Worker on the account
57 * (including ones created later), production or preview traffic
58 * respectively. Hostname-level policies take precedence over Worker-level
59 * policies, which take precedence over these account-level policies.
60 */
61export type ApplicationDestination =
62 | { type: "public"; uri: string }
63 | {
64 type: "private";
65 hostname?: string;
66 cidr?: string;
67 l4Protocol?: "tcp" | "udp";
68 portRange?: string;
69 vnetId?: string;
70 }
71 | { type: "via_mcp_server_portal"; mcpServerId: string }
72 | { type: "worker"; workerId: string }
73 | { type: "preview_worker"; workerId: string }
74 | { type: "all_workers" }
75 | { type: "all_preview_workers" };
76
77/**
78 * Configuration for an OAuth authorization flow managed by Cloudflare Access.
79 */
80export interface OAuthConfiguration {
81 /** Whether Access acts as the OAuth authorization server. */
82 enabled?: boolean;
83 /** OAuth grant and token lifetimes. */
84 grant?: {
85 /** Lifetime of issued access tokens, as a Go-style duration. */
86 accessTokenLifetime?: string;
87 /** Lifetime of the authorization session, as a Go-style duration. */
88 sessionDuration?: string;
89 };
90 /** Settings for OAuth dynamic client registration. */
91 dynamicClientRegistration?: {
92 /** Whether dynamic client registration is enabled. */
93 enabled?: boolean;
94 /** Explicit redirect URIs that dynamically registered clients may use. */
95 allowedUris?: ReadonlyArray<string>;
96 /** Whether any localhost redirect URI is allowed. */
97 allowAnyOnLocalhost?: boolean;
98 /** Whether any loopback-address redirect URI is allowed. */
99 allowAnyOnLoopback?: boolean;
100 };
101}
102
103/**
104 * An Access policy defined inline on (and owned by) an application: created
105 * with the application, updated in place, and deleted with it. Uses the same
106 * rule model as the reusable `Cloudflare.Access.Policy` resource.
107 */
108export interface InlineApplicationPolicy {
109 /** The action Access takes when a user matches this policy. */
110 decision: PolicyDecision;
111 /** Rules evaluated with OR — matching any one grants the policy. */
112 include: ReadonlyArray<PolicyRuleInput>;
113 /** Rules evaluated with NOT — matching any one denies the policy. */
114 exclude?: ReadonlyArray<PolicyExcludeRuleInput>;
115 /** Rules evaluated with AND — all must match. */
116 require?: ReadonlyArray<PolicyRequireRuleInput>;
117 /** Display name. Cloudflare generates one when omitted. */
118 name?: string;
119 /** Execution order of this policy within the application. */
120 precedence?: number;
121 /** Session lifetime for this policy, e.g. `"24h"`, `"2h45m"`. */
122 sessionDuration?: string;
123 /** Require admin approval at the start of each session. */
124 approvalRequired?: boolean;
125 /** Administrators who can approve a temporary authentication request. */
126 approvalGroups?: ReadonlyArray<{
127 approvalsNeeded: number;
128 emailAddresses?: ReadonlyArray<string>;
129 emailListUuid?: string;
130 }>;
131 /** Serve the application in an isolated browser for matching users. */
132 isolationRequired?: boolean;
133 /** Require a login justification from matching users. */
134 purposeJustificationRequired?: boolean;
135 /** Custom message shown on the justification screen. */
136 purposeJustificationPrompt?: string;
137}
138
139export interface ApplicationProps {
140 /**
141 * The Access application type.
142 *
143 * Cloudflare requires a single global `warp` application per account; the
144 * provider's observe step special-cases this by scanning the account for an
145 * existing `warp` app when no `applicationId` is cached.
146 *
147 * Immutable — changing the type triggers a replace.
148 */
149 type: ApplicationType;
150 /**
151 * Human-readable display name. If omitted, a deterministic physical name
152 * is generated from the app/stage/logical-id.
153 */
154 name?: string;
155 /**
156 * Primary hostname and path secured by Access. Required for `self_hosted`
157 * apps; ignored on the request for `warp` (Cloudflare auto-fills it with
158 * `${authDomain}/warp`) and `saas` (Cloudflare uses the OIDC issuer).
159 */
160 domain?: string;
161 /**
162 * Destinations this application protects. Use for the modern multi-
163 * destination model — required for **Access for private apps** flows
164 * where traffic to a private hostname/CIDR is intercepted by WARP and
165 * routed through a Cloudflare Tunnel, with Access enforcing identity
166 * before the request reaches the upstream service.
167 *
168 * For simple public-hostname apps, set `domain` instead. The two are
169 * not mutually exclusive — Cloudflare treats `domain` as a shorthand
170 * for adding a single `{ type: "public" }` destination.
171 *
172 * @example
173 * ```ts
174 * destinations: [
175 * { type: "private", hostname: "admin.internal" },
176 * ]
177 * ```
178 */
179 destinations?: ReadonlyArray<ApplicationDestination>;
180 /**
181 * Optional OAuth authorization-server configuration managed by Access.
182 * Use this for non-browser clients such as MCP clients that authenticate
183 * through OAuth and may register redirect URIs dynamically.
184 */
185 oauthConfiguration?: OAuthConfiguration;
186 /**
187 * Token TTL for sessions issued by this application. Accepts Go-style
188 * duration strings, e.g. `"24h"`, `"720h"`, `"2h45m"`.
189 *
190 * @default "24h"
191 */
192 sessionDuration?: string;
193 /**
194 * Allowed identity-provider UUIDs. Defaults (on Cloudflare's side) to every
195 * IdP configured for the account.
196 */
197 allowedIdps?: string[];
198 /**
199 * Skip the IdP picker when only one IdP is allowed. Requires `allowedIdps`
200 * to contain exactly one entry.
201 *
202 * @default false
203 */
204 autoRedirectToIdentity?: boolean;
205 /**
206 * Whether the app should be visible in the App Launcher dashboard.
207 */
208 appLauncherVisible?: boolean;
209 /**
210 * Tags applied to this application for filtering in the App Launcher.
211 */
212 tags?: string[];
213 /**
214 * Access policies that gate access to this application, in ascending
215 * order of precedence.
216 *
217 * Each entry can be:
218 * - an **inline policy** owned by this application —
219 * `{ decision, include, ... }` with the same rule model as
220 * `Cloudflare.Access.Policy` (created, updated, and deleted with the
221 * application; no separate resource needed),
222 * - a deployed reusable `Cloudflare.Access.Policy`
223 * (`policies: [allowTeam]`),
224 * - a policy id (`string`),
225 * - `{ id, precedence? }`, or
226 * - the same with per-application overrides (`approvalRequired`,
227 * `isolationRequired`, `purposeJustificationRequired`,
228 * `purposeJustificationPrompt`, `sessionDuration`, `approvalGroups`).
229 *
230 * Cloudflare treats inline and reusable policies as mutually exclusive on
231 * one application — mixing the two forms fails validation.
232 *
233 * @example
234 * ```ts
235 * policies: [
236 * { decision: "allow", include: [{ emailDomain: "example.com" }] },
237 * ]
238 * ```
239 */
240 policies?: ReadonlyArray<
241 | string
242 | { policyId: string }
243 | InlineApplicationPolicy
244 | { id: string; precedence?: number }
245 | {
246 id: string;
247 precedence?: number;
248 approvalRequired?: boolean;
249 isolationRequired?: boolean;
250 purposeJustificationRequired?: boolean;
251 purposeJustificationPrompt?: string;
252 sessionDuration?: string;
253 approvalGroups?: ReadonlyArray<{
254 approvalsNeeded: number;
255 emailAddresses?: ReadonlyArray<string>;
256 emailListUuid?: string;
257 }>;
258 }
259 >;
260 /**
261 * Adopt an existing app that already lives in Cloudflare (matched by
262 * applicationId observation) instead of failing on conflict.
263 *
264 * @default false
265 */
266 adopt?: boolean;
267}
268
269/**
270 * Output attributes persisted between reconciles.
271 */
272export interface ApplicationAttributes {
273 /** Cloudflare-assigned application UUID. */
274 applicationId: string;
275 /** Audience tag used to verify JWTs issued for this application. */
276 aud: string;
277 /** Resolved domain. Cloudflare fills this in for `warp`/`saas` apps. */
278 domain: string;
279 /** Resolved destinations (echoed back by Cloudflare). */
280 destinations: ReadonlyArray<ApplicationDestination> | undefined;
281 /** Resolved managed OAuth configuration. */
282 oauthConfiguration: OAuthConfiguration | undefined;
283 /** Application type. */
284 type: ApplicationType;
285 /** Display name (resolved). */
286 name: string;
287 /** Account that owns this application. */
288 accountId: string;
289 /** ISO8601 creation timestamp (Cloudflare-supplied). */
290 createdAt: string | undefined;
291 /** ISO8601 last-update timestamp (Cloudflare-supplied). */
292 updatedAt: string | undefined;
293}
294
295/**
296 * Data other resources attach to an Access application via bindings.
297 * Workers enrolling themselves (the `access` prop on `Cloudflare.Worker`)
298 * push their `worker`/`preview_worker` destinations here; the application
299 * deploys with — and converges on — the union of its own `destinations`
300 * prop and every bound contribution.
301 */
302export interface ApplicationBinding {
303 destinations?: ApplicationDestination[];
304}
305
306export const isApplication = <T>(value: T): value is T & Application =>
307 isResourceOfType(value, "Cloudflare.Access.Application");
308
309export type Application = Resource<
310 "Cloudflare.Access.Application",
311 ApplicationProps,
312 ApplicationAttributes,
313 ApplicationBinding,
314 Providers
315>;
316
317/**
318 * A Cloudflare Zero Trust Access application.
319 *
320 * Replaces the curl-based `POST /accounts/{accountId}/access/apps` workflow
321 * with an Alchemy-managed resource. Supports every Cloudflare application
322 * type including `warp`, which Cloudflare requires for device enrolment via
323 * the WARP client.
324 *
325 * Access policies are authored as standalone {@link Policy} resources
326 * and referenced here by id — there is no inline-policy support.
327 * ### Creating an Application
328 * **Example:** Self-hosted application gated by a reusable Access policy
329 * ```typescript
330 * const allowMyOrg = yield* Cloudflare.Access.Policy("AllowMyOrg", {
331 * name: "Allow example.com via Google",
332 * decision: "allow",
333 * include: [{ emailDomain: { domain: "example.com" } }],
334 * });
335 *
336 * const app = yield* Cloudflare.Access.Application("InternalDashboard", {
337 * type: "self_hosted",
338 * domain: "dashboard.example.com",
339 * sessionDuration: "24h",
340 * policies: [allowMyOrg],
341 * });
342 * ```
343 *
344 * **Example:** Managed OAuth for an MCP server
345 * ```typescript
346 * const app = yield* Cloudflare.Access.Application("McpServer", {
347 * type: "self_hosted",
348 * domain: "mcp.example.com",
349 * oauthConfiguration: {
350 * enabled: true,
351 * grant: {
352 * sessionDuration: "24h",
353 * accessTokenLifetime: "15m",
354 * },
355 * dynamicClientRegistration: {
356 * enabled: true,
357 * allowAnyOnLocalhost: true,
358 * allowAnyOnLoopback: true,
359 * },
360 * },
361 * });
362 * ```
363 *
364 * ### Protecting Cloudflare Workers
365 * **Example:** Require Access on a specific Worker
366 * ```typescript
367 * // The application owns the policies (inline here — no separate Policy
368 * // resource needed); the Worker enrolls itself via its `access` prop,
369 * // covering its custom domains, routes, workers.dev URL, and version
370 * // preview URLs.
371 * const App = Cloudflare.Access.Application("TeamOnly", {
372 * type: "self_hosted",
373 * policies: [
374 * { decision: "allow", include: [{ emailDomain: "example.com" }] },
375 * ],
376 * });
377 *
378 * export default class Api extends Cloudflare.Worker<Api>()("Api", {
379 * main: import.meta.url,
380 * access: { application: App },
381 * }, /* ... *​/) {}
382 * ```
383 *
384 * **Example:** Require Access on every Worker in the account
385 * ```typescript
386 * // Covers all current AND future Workers. Hostname-level policies beat
387 * // Worker-level policies, which beat this account-level policy — so an
388 * // individual Worker can still be opened up with its own application.
389 * yield* Cloudflare.Access.Application("ProtectAllWorkers", {
390 * type: "self_hosted",
391 * destinations: [
392 * Cloudflare.Access.AllWorkers, // production traffic of every Worker
393 * Cloudflare.Access.AllWorkerPreviews, // every Worker's preview URLs
394 * ],
395 * policies: [
396 * { decision: "allow", include: [{ emailDomain: "example.com" }] },
397 * ],
398 * });
399 * ```
400 *
401 * ### Device-enrollment (warp)
402 * **Example:** WARP device-enrollment application
403 * ```typescript
404 * // There can only be ONE warp app per account; Cloudflare auto-derives the
405 * // domain (`${authDomain}/warp`) so do not pass `domain` for this type.
406 * const allowCorp = yield* Cloudflare.Access.Policy("AllowCorpUsers", {
407 * name: "Allow corp users",
408 * decision: "allow",
409 * include: [{ emailDomain: { domain: "example.com" } }],
410 * });
411 *
412 * const enroll = yield* Cloudflare.Access.Application("warp-login", {
413 * type: "warp",
414 * allowedIdps: [googleIdpId],
415 * autoRedirectToIdentity: true,
416 * sessionDuration: "720h",
417 * policies: [allowCorp],
418 * });
419 * ```
420 *
421 * ### Self-hosted with Google IdP
422 * **Example:** Self-hosted application restricted to a Google Workspace group
423 * ```typescript
424 * const admins = yield* Cloudflare.Access.Policy("AdminsOnly", {
425 * name: "Admins only",
426 * decision: "allow",
427 * include: [
428 * {
429 * gsuite: {
430 * email: "admins@example.com",
431 * identityProviderId: googleIdpUuid,
432 * },
433 * },
434 * ],
435 * });
436 *
437 * const app = yield* Cloudflare.Access.Application("AdminConsole", {
438 * type: "self_hosted",
439 * domain: "admin.example.com",
440 * allowedIdps: [googleIdpUuid],
441 * autoRedirectToIdentity: true,
442 * policies: [admins],
443 * });
444 * ```
445 *
446 * @resource
447 * @product Access
448 * @category Cloudflare One (Zero Trust)
449 */
450export const Application = Resource<Application>(
451 "Cloudflare.Access.Application",
452);
453
454// Ride out the two transient failure modes Cloudflare's Access endpoints
455// exhibit under load:
456//
457// - `AccessReferenceNotFound` (400 `policy <id> not found`): Access
458// validates referenced entities (e.g. the `policies` an application gates
459// on) synchronously, but a *freshly created* policy propagates
460// eventually-consistently — so a create/update referencing it, or a list
461// hydrating an app that references it, is briefly rejected. Distilled
462// types this 400 distinctly (vs. a generic `BadRequest`) so we retry only
463// this case and still fail fast on real bad requests.
464// - `Forbidden` (403): Cloudflare frequently returns 403 when throttling a
465// valid token rather than a dedicated rate-limit status, so a 403 here is
466// a transient back-pressure signal, not an auth failure.
467//
468// Capped exponential, bounded to ride out the window (~45s) then fail.
469const retryTransientAccessError = <A, E extends { _tag: string }, R>(
470 effect: Effect.Effect<A, E, R>,
471) =>
472 effect.pipe(
473 Effect.retry({
474 while: (e) =>
475 e._tag === "AccessReferenceNotFound" || e._tag === "Forbidden",
476 schedule: Schedule.max([
477 Schedule.min([
478 Schedule.exponential("1 second", 1.5),
479 Schedule.spaced("5 seconds"),
480 ]),
481 Schedule.recurs(12),
482 ]),
483 }),
484 );
485
486export const ApplicationProvider = () =>
487 Provider.succeed(Application, {
488 stables: ["applicationId", "aud", "type", "accountId"],
489
490 diff: Effect.fn(function* ({ olds = {}, news }) {
491 if ((olds as ApplicationProps).type !== undefined) {
492 if (
493 (olds as ApplicationProps).type !== (news as ApplicationProps).type
494 ) {
495 return { action: "replace" } as const;
496 }
497 }
498 }),
499
500 read: Effect.fn(function* ({ output, olds }) {
501 const { accountId } = yield* yield* CloudflareEnvironment;
502
503 // Prefer the persisted physical id. After state loss there is no
504 // applicationId to probe, so fall back to matching an existing app
505 // by domain — without this the engine plans a blind `create`, and
506 // Cloudflare happily creates a second application on the same
507 // domain with a fresh `aud`, silently breaking existing JWT
508 // validation. Warp apps are excluded from the fallback: they are a
509 // per-account singleton that `reconcile` already recovers.
510 let observed: ObservedApp | undefined;
511 if (output?.applicationId) {
512 observed = yield* observeById(accountId, output.applicationId);
513 } else if (olds?.type !== "warp" && typeof olds?.domain === "string") {
514 observed = yield* findByDomain(accountId, olds.domain);
515 } else {
516 return undefined;
517 }
518 if (!observed?.id || !observed.aud || !observed.type) {
519 return undefined;
520 }
521 const domain =
522 observed.domain ??
523 output?.domain ??
524 olds?.domain ??
525 // Worker-destination apps (`worker`/`all_workers`/...) have no
526 // hostname; Cloudflare omits `domain` for them entirely.
527 (observed.destinations !== undefined ? "" : undefined);
528 const name = observed.name ?? output?.name;
529 if (domain === undefined || name === undefined) {
530 return undefined;
531 }
532 const attrs = {
533 applicationId: observed.id,
534 aud: observed.aud,
535 domain,
536 destinations: observed.destinations ?? output?.destinations,
537 // Live cloud state is authoritative. In particular, do not resurrect
538 // a persisted configuration when Cloudflare explicitly returns null.
539 oauthConfiguration: observed.oauthConfiguration,
540 type: observed.type,
541 name,
542 accountId: output?.accountId ?? accountId,
543 createdAt: observed.createdAt ?? output?.createdAt,
544 updatedAt: observed.updatedAt ?? output?.updatedAt,
545 } satisfies ApplicationAttributes;
546 // Recovered by id → positively ours. Recovered by domain scan →
547 // existence is certain but ownership is not (Access applications
548 // carry no alchemy marker), so gate takeover behind `--adopt`.
549 return output?.applicationId ? attrs : Unowned(attrs);
550 }),
551
552 reconcile: Effect.fn(function* ({ id, news, output, bindings }) {
553 const { accountId } = yield* yield* CloudflareEnvironment;
554
555 const resolvedName = yield* resolveName(id, news.name);
556 const resolvedIdps = resolveAllowedIdps(news.allowedIdps);
557 const resolvedPolicies = resolvePolicies(news.policies);
558 if (
559 resolvedPolicies !== undefined &&
560 resolvedPolicies.some(
561 (p) => typeof p !== "string" && isInlinePolicy(p),
562 ) &&
563 resolvedPolicies.some(
564 (p) => typeof p === "string" || !isInlinePolicy(p),
565 )
566 ) {
567 return yield* Effect.fail(
568 new Error(
569 "Cloudflare Access applications cannot mix inline policies with " +
570 "reusable policy references — use one form for the whole " +
571 "`policies` list.",
572 ),
573 );
574 }
575 const body = buildMutableBody(
576 news,
577 resolvedName,
578 resolvedIdps,
579 resolvedPolicies,
580 );
581 // Destinations contributed through the binding contract (e.g. Workers
582 // enrolling via their `access` prop) extend the declared ones. The
583 // engine dedupes and sid-sorts bindings, so the merged order is
584 // stable across deploys.
585 const boundDestinations = (bindings ?? []).flatMap(
586 (b) => (b.data.destinations ?? []) as ApplicationDestination[],
587 );
588 if (boundDestinations.length > 0) {
589 body.destinations = [
590 ...(body.destinations ?? []),
591 ...boundDestinations,
592 ];
593 }
594
595 // 1. Observe
596 let observed: ObservedApp | undefined;
597 if (output?.applicationId) {
598 observed = yield* observeById(accountId, output.applicationId);
599 }
600 if (!observed && news.type === "warp") {
601 // Warp is a singleton per account — reuse any existing app.
602 observed = yield* findWarpApp(accountId);
603 }
604
605 // 2. Ensure
606 if (!observed) {
607 const created = yield* zeroTrust
608 .createAccessApplicationForAccount({
609 accountId,
610 domain: body.domain,
611 type: news.type,
612 name: resolvedName,
613 sessionDuration: body.sessionDuration,
614 allowedIdps:
615 body.allowedIdps === undefined
616 ? undefined
617 : Array.from(body.allowedIdps),
618 autoRedirectToIdentity: body.autoRedirectToIdentity,
619 appLauncherVisible: body.appLauncherVisible,
620 tags: body.tags === undefined ? undefined : Array.from(body.tags),
621 policies: toRequestPolicies(body.policies),
622 destinations:
623 body.destinations === undefined
624 ? undefined
625 : Array.from(body.destinations),
626 oauthConfiguration: toRequestOAuthConfiguration(
627 body.oauthConfiguration,
628 ),
629 })
630 .pipe(
631 // A referenced policy may be propagating, or the call may be
632 // throttled (403) — ride out both before falling through.
633 retryTransientAccessError,
634 // Distilled does not tag Conflict; surface any creation error
635 // through the warp-singleton recovery path before re-failing.
636 Effect.catch((err) =>
637 Effect.gen(function* () {
638 if (news.type === "warp") {
639 const existing = yield* findWarpApp(accountId);
640 if (existing) return existing;
641 }
642 return yield* Effect.fail(err);
643 }),
644 ),
645 );
646 observed = narrowApp(created as Parameters<typeof narrowApp>[0]);
647 }
648
649 // 3. Sync — Cloudflare's update endpoint is PUT-style; resend the
650 // full desired body whenever any mutable field differs.
651 if (!observed.id) {
652 return yield* Effect.fail(
653 new Error(
654 "Cloudflare did not return an application id for Access application",
655 ),
656 );
657 }
658 if (!bodyEqualsObserved(body, observed)) {
659 const updated = yield* zeroTrust
660 .updateAccessApplicationForAccount({
661 accountId,
662 appId: observed.id,
663 domain: body.domain ?? observed.domain,
664 type: news.type,
665 name: resolvedName,
666 sessionDuration: body.sessionDuration,
667 allowedIdps:
668 body.allowedIdps === undefined
669 ? undefined
670 : Array.from(body.allowedIdps),
671 autoRedirectToIdentity: body.autoRedirectToIdentity,
672 appLauncherVisible: body.appLauncherVisible,
673 tags: body.tags === undefined ? undefined : Array.from(body.tags),
674 policies: toRequestPolicies(
675 attachObservedPolicyIds(body.policies, observed.policies),
676 ),
677 destinations:
678 body.destinations === undefined
679 ? undefined
680 : Array.from(body.destinations),
681 // Preserve a live managed OAuth configuration when the caller
682 // does not manage it but another mutable field triggers this
683 // PUT-style update.
684 oauthConfiguration: toRequestOAuthConfiguration(
685 mergeOAuthConfiguration(
686 observed.oauthConfiguration,
687 body.oauthConfiguration,
688 ),
689 ),
690 })
691 // A just-added policy reference may still be propagating, or the
692 // call may be throttled (403) — ride out both.
693 .pipe(retryTransientAccessError);
694 observed = narrowApp(updated as Parameters<typeof narrowApp>[0]);
695 }
696
697 // 4. Return
698 if (!observed.id || !observed.aud || !observed.type) {
699 return yield* Effect.fail(
700 new Error(
701 "Cloudflare returned an Access application without id/aud/type",
702 ),
703 );
704 }
705 return {
706 applicationId: observed.id,
707 aud: observed.aud,
708 domain: observed.domain ?? body.domain ?? "",
709 destinations: observed.destinations ?? body.destinations,
710 // Keep the provider output cloud-authoritative. If Cloudflare rejects
711 // or clears the desired configuration, do not mask that drift with
712 // the request body.
713 oauthConfiguration: observed.oauthConfiguration,
714 type: observed.type,
715 name: observed.name ?? resolvedName,
716 accountId,
717 createdAt: observed.createdAt,
718 updatedAt: observed.updatedAt,
719 } satisfies ApplicationAttributes;
720 }),
721
722 // Account-scoped collection (pattern (b)): enumerate every Access
723 // application in the ambient account, exhaustively paginated, and hydrate
724 // each into the exact `read`/`reconcile` Attributes shape. Items missing
725 // the mandatory id/aud/type triplet are skipped (typed per-item drop).
726 list: Effect.fn(function* () {
727 const { accountId } = yield* yield* CloudflareEnvironment;
728 return yield* zeroTrust.listAccessApplicationsForAccount
729 .pages({ accountId })
730 .pipe(
731 Stream.runCollect,
732 // The list hydrates each app's `policies`; Cloudflare rejects the
733 // whole enumeration with the typed `AccessReferenceNotFound` (400
734 // "policy ... not found") while a sibling app references a policy
735 // that is still propagating or mid-deletion, and 403s the call when
736 // throttling. Ride out both.
737 retryTransientAccessError,
738 Effect.map((chunk) =>
739 Array.from(chunk).flatMap((page) =>
740 (page.result ?? []).flatMap((raw) => {
741 const app = narrowApp(raw as Parameters<typeof narrowApp>[0]);
742 if (!app.id || !app.aud || !app.type) return [];
743 return [
744 {
745 applicationId: app.id,
746 aud: app.aud,
747 domain: app.domain ?? "",
748 destinations: app.destinations,
749 oauthConfiguration: app.oauthConfiguration,
750 type: app.type,
751 name: app.name ?? "",
752 accountId,
753 createdAt: app.createdAt,
754 updatedAt: app.updatedAt,
755 } satisfies ApplicationAttributes,
756 ];
757 }),
758 ),
759 ),
760 );
761 }),
762
763 delete: Effect.fn(function* ({ output }) {
764 yield* zeroTrust
765 .deleteAccessApplicationForAccount({
766 accountId: output.accountId,
767 appId: output.applicationId,
768 })
769 .pipe(Effect.catch(() => Effect.void));
770 }),
771 });
772
773const resolveName = (id: string, name: string | undefined) =>
774 Effect.gen(function* () {
775 if (name) return name;
776 return yield* createPhysicalName({ id });
777 });
778
779const resolveAllowedIdps = (
780 idps: ApplicationProps["allowedIdps"],
781): ReadonlyArray<string> | undefined =>
782 idps === undefined
783 ? undefined
784 : // Inputs have already been resolved by the Plan layer by the time
785 // we run, so they're concrete strings here.
786 (idps as ReadonlyArray<string>);
787
788// Cloudflare allows only one `warp` app per account. When we have no
789// cached applicationId, scan the account and reuse it. Requirements
790// (Credentials | HttpClient) are inferred and provided by the Provider
791// runtime — matches the un-annotated Tunnel.findTunnelByName pattern.
792const findWarpApp = (accountId: string) =>
793 zeroTrust.listAccessApplicationsForAccount.items({ accountId }).pipe(
794 Stream.runCollect,
795 // A sibling app mid-teardown can transiently reject the whole
796 // enumeration (AccessReferenceNotFound), and Cloudflare 403s when
797 // throttling — same transient windows `list` rides out.
798 retryTransientAccessError,
799 Effect.map((chunk) =>
800 Array.from(chunk).find(
801 (a) => (a as { type?: string | null }).type === "warp",
802 ),
803 ),
804 Effect.map((found) =>
805 found === undefined
806 ? undefined
807 : narrowApp(found as Parameters<typeof narrowApp>[0]),
808 ),
809 );
810
811// Cold-recovery scan for `read` when no applicationId was persisted:
812// match an existing application by its domain (unique per account for
813// non-warp app types).
814const findByDomain = (accountId: string, domain: string) =>
815 zeroTrust.listAccessApplicationsForAccount.items({ accountId }).pipe(
816 Stream.runCollect,
817 // A sibling app mid-teardown can transiently reject the whole
818 // enumeration (AccessReferenceNotFound), and Cloudflare 403s when
819 // throttling. A missed scan here is worse than a slow one: the engine
820 // would plan a blind `create` and either duplicate the app or trip
821 // Cloudflare's `application_already_exists` Conflict.
822 retryTransientAccessError,
823 Effect.map((chunk) =>
824 Array.from(chunk).find(
825 (a) => (a as { domain?: string | null }).domain === domain,
826 ),
827 ),
828 Effect.map((found) =>
829 found === undefined
830 ? undefined
831 : narrowApp(found as Parameters<typeof narrowApp>[0]),
832 ),
833 );
834
835const observeById = (accountId: string, appId: string) =>
836 Effect.gen(function* () {
837 const r = yield* zeroTrust
838 .getAccessApplicationForAccount({ accountId, appId })
839 .pipe(
840 // A missing application is typed (404 → AccessApplicationNotFound):
841 // observe falls through to recreate. Transient 403 back-pressure is
842 // retried; anything else is a real failure and propagates.
843 retryTransientAccessError,
844 Effect.catchTag("AccessApplicationNotFound", () =>
845 Effect.succeed(undefined),
846 ),
847 );
848 if (r === undefined) return undefined;
849 return narrowApp(r as Parameters<typeof narrowApp>[0]);
850 });
851
852// ---------------------------------------------------------------------------
853// Observed-state types
854//
855// We only diff policy references by id (and order), so the observed policy
856// shape is narrowed to just the identifier here.
857// ---------------------------------------------------------------------------
858
859interface ObservedPolicy {
860 readonly id?: string;
861 readonly precedence?: number;
862 /** false for application-owned (inline) policies, true for reusable. */
863 readonly reusable?: boolean;
864 readonly decision?: string;
865 readonly name?: string;
866 readonly include?: ReadonlyArray<unknown>;
867 readonly exclude?: ReadonlyArray<unknown>;
868 readonly require?: ReadonlyArray<unknown>;
869 readonly sessionDuration?: string;
870 readonly approvalRequired?: boolean;
871 readonly isolationRequired?: boolean;
872 readonly purposeJustificationRequired?: boolean;
873 readonly purposeJustificationPrompt?: string;
874}
875
876interface ObservedApp {
877 readonly id?: string;
878 readonly aud?: string;
879 readonly name?: string;
880 readonly type?: ApplicationType;
881 readonly domain?: string;
882 readonly destinations?: ReadonlyArray<ApplicationDestination>;
883 readonly oauthConfiguration?: OAuthConfiguration;
884 readonly allowedIdps?: ReadonlyArray<string>;
885 readonly autoRedirectToIdentity?: boolean;
886 readonly appLauncherVisible?: boolean;
887 readonly sessionDuration?: string;
888 readonly tags?: ReadonlyArray<string>;
889 readonly policies?: ReadonlyArray<ObservedPolicy>;
890 readonly createdAt?: string;
891 readonly updatedAt?: string;
892}
893
894const undef = <T>(v: T | null | undefined): T | undefined =>
895 v == null ? undefined : v;
896
897const undefArr = <T>(
898 v: ReadonlyArray<T | null> | null | undefined,
899): ReadonlyArray<T> | undefined =>
900 v == null ? undefined : (v.filter((x) => x != null) as ReadonlyArray<T>);
901
902interface RawOAuthConfiguration {
903 readonly enabled?: boolean | null;
904 readonly grant?: {
905 readonly accessTokenLifetime?: string | null;
906 readonly sessionDuration?: string | null;
907 } | null;
908 readonly dynamicClientRegistration?: {
909 readonly enabled?: boolean | null;
910 readonly allowedUris?: ReadonlyArray<string | null> | null;
911 readonly allowAnyOnLocalhost?: boolean | null;
912 readonly allowAnyOnLoopback?: boolean | null;
913 } | null;
914}
915
916const narrowOAuthConfiguration = (
917 raw: RawOAuthConfiguration | null | undefined,
918): OAuthConfiguration | undefined =>
919 raw == null
920 ? undefined
921 : {
922 enabled: undef(raw.enabled),
923 grant:
924 raw.grant == null
925 ? undefined
926 : {
927 accessTokenLifetime: undef(raw.grant.accessTokenLifetime),
928 sessionDuration: undef(raw.grant.sessionDuration),
929 },
930 dynamicClientRegistration:
931 raw.dynamicClientRegistration == null
932 ? undefined
933 : {
934 enabled: undef(raw.dynamicClientRegistration.enabled),
935 allowedUris: undefArr(
936 raw.dynamicClientRegistration.allowedUris,
937 ) as string[] | undefined,
938 allowAnyOnLocalhost: undef(
939 raw.dynamicClientRegistration.allowAnyOnLocalhost,
940 ),
941 allowAnyOnLoopback: undef(
942 raw.dynamicClientRegistration.allowAnyOnLoopback,
943 ),
944 },
945 };
946
947const narrowApp = (raw: {
948 id?: string | null;
949 aud?: string | null;
950 name?: string | null;
951 type?: ApplicationType | null | string;
952 domain?: string | null;
953 destinations?: ReadonlyArray<unknown> | null;
954 oauthConfiguration?: RawOAuthConfiguration | null;
955 allowedIdps?: ReadonlyArray<string> | null;
956 autoRedirectToIdentity?: boolean | null;
957 appLauncherVisible?: boolean | null;
958 sessionDuration?: string | null;
959 tags?: ReadonlyArray<string> | null;
960 policies?: ReadonlyArray<unknown> | null;
961 createdAt?: string | null;
962 updatedAt?: string | null;
963}): ObservedApp => ({
964 id: undef(raw.id),
965 aud: undef(raw.aud),
966 name: undef(raw.name),
967 type: raw.type == null ? undefined : (raw.type as ApplicationType),
968 domain: undef(raw.domain),
969 destinations:
970 raw.destinations == null
971 ? undefined
972 : (raw.destinations as ReadonlyArray<ApplicationDestination>),
973 oauthConfiguration: narrowOAuthConfiguration(raw.oauthConfiguration),
974 allowedIdps: undefArr(raw.allowedIdps ?? undefined),
975 autoRedirectToIdentity: undef(raw.autoRedirectToIdentity),
976 appLauncherVisible: undef(raw.appLauncherVisible),
977 sessionDuration: undef(raw.sessionDuration),
978 tags: undefArr(raw.tags ?? undefined),
979 policies:
980 raw.policies == null
981 ? undefined
982 : (raw.policies as ReadonlyArray<ObservedPolicy>),
983 createdAt: undef(raw.createdAt),
984 updatedAt: undef(raw.updatedAt),
985});
986
987// ---------------------------------------------------------------------------
988// Body construction
989//
990// Inputs declared as `Input<string>` are concrete strings by the time the
991// reconciler runs (resolved by the Plan layer). We narrow them at the
992// resolution boundary, then build the request shape distilled already types
993// correctly — no cast-to-Parameters needed.
994// ---------------------------------------------------------------------------
995
996/**
997 * Reconciler-side inline policy: `InlineApplicationPolicy` plus the optional
998 * `id` of the observed application-owned policy it updates in place (see
999 * `attachObservedPolicyIds`).
1000 */
1001interface InlineResolvedPolicy extends InlineApplicationPolicy {
1002 id?: string;
1003}
1004
1005const isInlinePolicy = (p: ResolvedPolicy): p is InlineResolvedPolicy =>
1006 typeof p !== "string" && "decision" in p;
1007
1008type ResolvedPolicy =
1009 | string
1010 | InlineResolvedPolicy
1011 | { id: string; precedence?: number }
1012 | {
1013 id: string;
1014 precedence?: number;
1015 approvalRequired?: boolean;
1016 isolationRequired?: boolean;
1017 purposeJustificationRequired?: boolean;
1018 purposeJustificationPrompt?: string;
1019 sessionDuration?: string;
1020 approvalGroups?: ReadonlyArray<{
1021 approvalsNeeded: number;
1022 emailAddresses?: ReadonlyArray<string>;
1023 emailListUuid?: string;
1024 }>;
1025 };
1026
1027// The self-hosted policies item union: references (id string / link /
1028// per-app overrides) plus application-owned inline policies
1029// (`InlineAccessPolicy`, added to distilled's model — the API docs only
1030// carry the inline arm for infrastructure apps, but the live API accepts
1031// it for self_hosted).
1032type RequestPolicy =
1033 zeroTrust.AccessApplicationsCreateForAccountRequestPoliciesSelfHostedApplicationItem;
1034
1035interface AppMutableBody {
1036 domain?: string;
1037 destinations?: ReadonlyArray<ApplicationDestination>;
1038 oauthConfiguration?: OAuthConfiguration;
1039 type: ApplicationType;
1040 name?: string;
1041 sessionDuration?: string;
1042 allowedIdps?: ReadonlyArray<string>;
1043 autoRedirectToIdentity?: boolean;
1044 appLauncherVisible?: boolean;
1045 tags?: ReadonlyArray<string>;
1046 policies?: ReadonlyArray<ResolvedPolicy>;
1047}
1048
1049const policyIdOf = (p: ResolvedPolicy): string | undefined =>
1050 typeof p === "string" ? p : p.id;
1051
1052const toRequestPolicy = (p: ResolvedPolicy): RequestPolicy => {
1053 if (typeof p === "string") return p;
1054 if (isInlinePolicy(p)) {
1055 return {
1056 // `id` present when this inline body updates an observed
1057 // application-owned policy in place (attachObservedPolicyIds).
1058 id: p.id,
1059 decision: p.decision,
1060 include: normalizePolicyRules(
1061 p.include,
1062 ) as zeroTrust.InlineAccessPolicy["include"],
1063 exclude: normalizePolicyRules(
1064 p.exclude,
1065 ) as zeroTrust.InlineAccessPolicy["exclude"],
1066 require: normalizePolicyRules(
1067 p.require,
1068 ) as zeroTrust.InlineAccessPolicy["require"],
1069 name: p.name,
1070 precedence: p.precedence,
1071 sessionDuration: p.sessionDuration,
1072 approvalRequired: p.approvalRequired,
1073 approvalGroups:
1074 p.approvalGroups === undefined
1075 ? undefined
1076 : p.approvalGroups.map((g) => ({
1077 approvalsNeeded: g.approvalsNeeded,
1078 emailAddresses:
1079 g.emailAddresses === undefined
1080 ? undefined
1081 : Array.from(g.emailAddresses),
1082 emailListUuid: g.emailListUuid,
1083 })),
1084 isolationRequired: p.isolationRequired,
1085 purposeJustificationRequired: p.purposeJustificationRequired,
1086 purposeJustificationPrompt: p.purposeJustificationPrompt,
1087 } satisfies zeroTrust.InlineAccessPolicy;
1088 }
1089 // The simple `{ id, precedence? }` form lacks the per-app override fields;
1090 // narrow once to a permissive view so we can copy them through uniformly.
1091 const rich = p as {
1092 id: string;
1093 precedence?: number;
1094 approvalRequired?: boolean;
1095 isolationRequired?: boolean;
1096 purposeJustificationRequired?: boolean;
1097 purposeJustificationPrompt?: string;
1098 sessionDuration?: string;
1099 approvalGroups?: ReadonlyArray<{
1100 approvalsNeeded: number;
1101 emailAddresses?: ReadonlyArray<string>;
1102 emailListUuid?: string;
1103 }>;
1104 };
1105 return {
1106 id: rich.id,
1107 precedence: rich.precedence,
1108 approvalRequired: rich.approvalRequired,
1109 isolationRequired: rich.isolationRequired,
1110 purposeJustificationRequired: rich.purposeJustificationRequired,
1111 purposeJustificationPrompt: rich.purposeJustificationPrompt,
1112 sessionDuration: rich.sessionDuration,
1113 approvalGroups:
1114 rich.approvalGroups === undefined
1115 ? undefined
1116 : rich.approvalGroups.map((g) => ({
1117 approvalsNeeded: g.approvalsNeeded,
1118 emailAddresses:
1119 g.emailAddresses === undefined
1120 ? undefined
1121 : Array.from(g.emailAddresses),
1122 emailListUuid: g.emailListUuid,
1123 })),
1124 };
1125};
1126
1127const toRequestPolicies = (
1128 policies: ReadonlyArray<ResolvedPolicy> | undefined,
1129): Array<RequestPolicy> | undefined =>
1130 policies === undefined ? undefined : policies.map(toRequestPolicy);
1131
1132const mergeOAuthConfiguration = (
1133 observed: OAuthConfiguration | undefined,
1134 desired: OAuthConfiguration | undefined,
1135): OAuthConfiguration | undefined => {
1136 if (desired === undefined) return observed;
1137 return {
1138 enabled: desired.enabled ?? observed?.enabled,
1139 grant:
1140 desired.grant === undefined
1141 ? observed?.grant
1142 : {
1143 accessTokenLifetime:
1144 desired.grant.accessTokenLifetime ??
1145 observed?.grant?.accessTokenLifetime,
1146 sessionDuration:
1147 desired.grant.sessionDuration ?? observed?.grant?.sessionDuration,
1148 },
1149 dynamicClientRegistration:
1150 desired.dynamicClientRegistration === undefined
1151 ? observed?.dynamicClientRegistration
1152 : {
1153 enabled:
1154 desired.dynamicClientRegistration.enabled ??
1155 observed?.dynamicClientRegistration?.enabled,
1156 allowedUris:
1157 desired.dynamicClientRegistration.allowedUris ??
1158 observed?.dynamicClientRegistration?.allowedUris,
1159 allowAnyOnLocalhost:
1160 desired.dynamicClientRegistration.allowAnyOnLocalhost ??
1161 observed?.dynamicClientRegistration?.allowAnyOnLocalhost,
1162 allowAnyOnLoopback:
1163 desired.dynamicClientRegistration.allowAnyOnLoopback ??
1164 observed?.dynamicClientRegistration?.allowAnyOnLoopback,
1165 },
1166 };
1167};
1168
1169const toRequestOAuthConfiguration = (
1170 config: OAuthConfiguration | undefined,
1171): zeroTrust.CreateAccessApplicationForAccountRequest["oauthConfiguration"] =>
1172 config === undefined
1173 ? undefined
1174 : {
1175 enabled: config.enabled,
1176 grant: config.grant,
1177 dynamicClientRegistration:
1178 config.dynamicClientRegistration === undefined
1179 ? undefined
1180 : {
1181 ...config.dynamicClientRegistration,
1182 allowedUris:
1183 config.dynamicClientRegistration.allowedUris === undefined
1184 ? undefined
1185 : Array.from(config.dynamicClientRegistration.allowedUris),
1186 },
1187 };
1188
1189const resolvePolicies = (
1190 policies: ApplicationProps["policies"],
1191): ReadonlyArray<ResolvedPolicy> | undefined =>
1192 policies === undefined
1193 ? undefined
1194 : // Inputs are concrete values here — the Plan layer resolved them
1195 // before the reconciler ran. A whole `Access.Policy` resource resolves
1196 // to its Attributes; normalize it to the bare policy id so everything
1197 // downstream (diffing, request building) sees one shape.
1198 (policies as ReadonlyArray<ResolvedPolicy | { policyId: string }>).map(
1199 (p) => (typeof p !== "string" && "policyId" in p ? p.policyId : p),
1200 );
1201
1202const buildMutableBody = (
1203 news: ApplicationProps,
1204 resolvedName: string,
1205 resolvedAllowedIdps: ReadonlyArray<string> | undefined,
1206 resolvedPolicies: ReadonlyArray<ResolvedPolicy> | undefined,
1207): AppMutableBody => {
1208 const body: AppMutableBody = {
1209 type: news.type,
1210 name: resolvedName,
1211 };
1212 // Warp apps cannot accept a user-supplied domain — Cloudflare derives it.
1213 if (news.type !== "warp" && news.domain !== undefined) {
1214 body.domain = news.domain;
1215 }
1216 if (news.destinations !== undefined) {
1217 body.destinations = news.destinations;
1218 }
1219 if (news.oauthConfiguration !== undefined) {
1220 body.oauthConfiguration = news.oauthConfiguration;
1221 }
1222 if (news.sessionDuration !== undefined) {
1223 body.sessionDuration = news.sessionDuration;
1224 }
1225 if (resolvedAllowedIdps !== undefined) {
1226 body.allowedIdps = resolvedAllowedIdps;
1227 }
1228 if (news.autoRedirectToIdentity !== undefined) {
1229 body.autoRedirectToIdentity = news.autoRedirectToIdentity;
1230 }
1231 if (news.appLauncherVisible !== undefined) {
1232 body.appLauncherVisible = news.appLauncherVisible;
1233 }
1234 if (news.tags !== undefined) {
1235 body.tags = news.tags;
1236 }
1237 if (resolvedPolicies !== undefined) {
1238 body.policies = resolvedPolicies;
1239 }
1240 return body;
1241};
1242
1243// ---------------------------------------------------------------------------
1244// Drift detection
1245// ---------------------------------------------------------------------------
1246
1247const jsonEq = <T>(x: T, y: T): boolean =>
1248 JSON.stringify(x) === JSON.stringify(y);
1249
1250const oauthConfigurationEquals = (
1251 desired: OAuthConfiguration | undefined,
1252 observed: OAuthConfiguration | undefined,
1253): boolean => {
1254 if (desired === undefined) return true;
1255 if (observed === undefined) return false;
1256 if (desired.enabled !== undefined && desired.enabled !== observed.enabled) {
1257 return false;
1258 }
1259
1260 const desiredGrant = desired.grant;
1261 if (desiredGrant?.accessTokenLifetime !== undefined) {
1262 if (
1263 desiredGrant.accessTokenLifetime !== observed.grant?.accessTokenLifetime
1264 ) {
1265 return false;
1266 }
1267 }
1268 if (desiredGrant?.sessionDuration !== undefined) {
1269 if (desiredGrant.sessionDuration !== observed.grant?.sessionDuration) {
1270 return false;
1271 }
1272 }
1273
1274 const desiredRegistration = desired.dynamicClientRegistration;
1275 const observedRegistration = observed.dynamicClientRegistration;
1276 if (
1277 desiredRegistration?.enabled !== undefined &&
1278 desiredRegistration.enabled !== observedRegistration?.enabled
1279 ) {
1280 return false;
1281 }
1282 if (
1283 desiredRegistration?.allowAnyOnLocalhost !== undefined &&
1284 desiredRegistration.allowAnyOnLocalhost !==
1285 observedRegistration?.allowAnyOnLocalhost
1286 ) {
1287 return false;
1288 }
1289 if (
1290 desiredRegistration?.allowAnyOnLoopback !== undefined &&
1291 desiredRegistration.allowAnyOnLoopback !==
1292 observedRegistration?.allowAnyOnLoopback
1293 ) {
1294 return false;
1295 }
1296 if (
1297 desiredRegistration?.allowedUris !== undefined &&
1298 !arrayEquals(
1299 [...desiredRegistration.allowedUris].sort(),
1300 [...(observedRegistration?.allowedUris ?? [])].sort(),
1301 jsonEq,
1302 )
1303 ) {
1304 return false;
1305 }
1306 return true;
1307};
1308
1309const policiesEq = (
1310 desired: ReadonlyArray<ResolvedPolicy> | undefined,
1311 observed: ReadonlyArray<ObservedPolicy> | undefined,
1312): boolean => {
1313 if (desired === undefined && observed === undefined) return true;
1314 if (desired === undefined || observed === undefined) {
1315 // An explicit empty `[]` should be honoured; nothing observed and
1316 // nothing desired collapses to "in sync".
1317 return (desired ?? []).length === 0 && (observed ?? []).length === 0;
1318 }
1319 if (desired.length !== observed.length) return false;
1320 for (let i = 0; i < desired.length; i++) {
1321 const d = desired[i];
1322 const o = observed[i];
1323 if (typeof d !== "string" && isInlinePolicy(d)) {
1324 // Inline (application-owned) policy: compare the fields the caller
1325 // set against the observed policy body. Cloudflare echoes rule
1326 // objects structurally, so JSON equality is stable; getting this
1327 // wrong is costly — a false inequality re-PUTs the policy list and
1328 // id-less inline items would mint fresh policies every deploy.
1329 if (o.reusable === true) return false;
1330 if (d.decision !== o.decision) return false;
1331 // Compare in wire shape: the caller may have used the scalar
1332 // shorthand while Cloudflare echoes expanded rules.
1333 const include = normalizePolicyRules(d.include);
1334 if (!jsonEq(include, (o.include ?? []) as typeof include)) {
1335 return false;
1336 }
1337 const exclude = normalizePolicyRules(d.exclude);
1338 if (
1339 exclude !== undefined &&
1340 !jsonEq(exclude, (o.exclude ?? []) as typeof exclude)
1341 ) {
1342 return false;
1343 }
1344 const require = normalizePolicyRules(d.require);
1345 if (
1346 require !== undefined &&
1347 !jsonEq(require, (o.require ?? []) as typeof require)
1348 ) {
1349 return false;
1350 }
1351 if (d.name !== undefined && d.name !== o.name) return false;
1352 if (
1353 d.precedence !== undefined &&
1354 o.precedence !== undefined &&
1355 d.precedence !== o.precedence
1356 ) {
1357 return false;
1358 }
1359 if (
1360 d.sessionDuration !== undefined &&
1361 d.sessionDuration !== o.sessionDuration
1362 ) {
1363 return false;
1364 }
1365 if (
1366 d.approvalRequired !== undefined &&
1367 d.approvalRequired !== (o.approvalRequired ?? false)
1368 ) {
1369 return false;
1370 }
1371 if (
1372 d.isolationRequired !== undefined &&
1373 d.isolationRequired !== (o.isolationRequired ?? false)
1374 ) {
1375 return false;
1376 }
1377 if (
1378 d.purposeJustificationRequired !== undefined &&
1379 d.purposeJustificationRequired !==
1380 (o.purposeJustificationRequired ?? false)
1381 ) {
1382 return false;
1383 }
1384 continue;
1385 }
1386 // Reference forms: an observed application-owned policy can never
1387 // satisfy a reusable reference.
1388 if (o.reusable === false) return false;
1389 if (policyIdOf(d) !== o.id) return false;
1390 if (typeof d !== "string") {
1391 if (
1392 d.precedence !== undefined &&
1393 o.precedence !== undefined &&
1394 d.precedence !== o.precedence
1395 ) {
1396 return false;
1397 }
1398 }
1399 }
1400 return true;
1401};
1402
1403/**
1404 * Zip desired inline policies with the observed application-owned policies
1405 * so an update PUT carries their ids and updates them in place — an id-less
1406 * inline item in an update creates a brand-new policy (and drops the old
1407 * one), churning policy ids on every deploy that touches any app field.
1408 * Positional matching, guarded on `reusable === false` (never attach a
1409 * reusable policy's id to an inline body — that would mutate the shared
1410 * policy) and on a matching decision.
1411 */
1412const attachObservedPolicyIds = (
1413 desired: ReadonlyArray<ResolvedPolicy> | undefined,
1414 observed: ReadonlyArray<ObservedPolicy> | undefined,
1415): ReadonlyArray<ResolvedPolicy> | undefined =>
1416 desired === undefined || observed === undefined
1417 ? desired
1418 : desired.map((p, i) => {
1419 const o = observed[i];
1420 return typeof p !== "string" &&
1421 isInlinePolicy(p) &&
1422 p.id === undefined &&
1423 o?.id !== undefined &&
1424 o.reusable === false &&
1425 o.decision === p.decision
1426 ? { ...p, id: o.id }
1427 : p;
1428 });
1429
1430const bodyEqualsObserved = (
1431 desired: AppMutableBody,
1432 observed: ObservedApp,
1433): boolean => {
1434 if (desired.name !== undefined && desired.name !== observed.name) {
1435 return false;
1436 }
1437 // Only diff domain when caller actually set one (warp's auto-derived
1438 // domain must not trigger a perpetual update loop).
1439 if (desired.domain !== undefined && desired.domain !== observed.domain) {
1440 return false;
1441 }
1442 // Same rule for destinations — Cloudflare may echo back an enriched
1443 // shape (e.g. server-assigned `vnetId`); we only diff when the caller
1444 // explicitly set them.
1445 if (
1446 desired.destinations !== undefined &&
1447 JSON.stringify(desired.destinations) !==
1448 JSON.stringify(observed.destinations ?? [])
1449 ) {
1450 return false;
1451 }
1452 if (
1453 !oauthConfigurationEquals(
1454 desired.oauthConfiguration,
1455 observed.oauthConfiguration,
1456 )
1457 ) {
1458 return false;
1459 }
1460 if (
1461 desired.sessionDuration !== undefined &&
1462 desired.sessionDuration !== observed.sessionDuration
1463 ) {
1464 return false;
1465 }
1466 if (
1467 desired.autoRedirectToIdentity !== undefined &&
1468 desired.autoRedirectToIdentity !== observed.autoRedirectToIdentity
1469 ) {
1470 return false;
1471 }
1472 if (
1473 desired.appLauncherVisible !== undefined &&
1474 desired.appLauncherVisible !== observed.appLauncherVisible
1475 ) {
1476 return false;
1477 }
1478 if (
1479 desired.allowedIdps !== undefined &&
1480 !arrayEquals(desired.allowedIdps, observed.allowedIdps, jsonEq)
1481 ) {
1482 return false;
1483 }
1484 if (
1485 desired.tags !== undefined &&
1486 !arrayEquals(desired.tags, observed.tags, jsonEq)
1487 ) {
1488 return false;
1489 }
1490 if (!policiesEq(desired.policies, observed.policies)) {
1491 return false;
1492 }
1493 return true;
1494};