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 { isResolved } from "../../Diff.ts";
8import { createPhysicalName } from "../../PhysicalName.ts";
9import * as Provider from "../../Provider.ts";
10import { Resource } from "../../Resource.ts";
11import { arrayEqualsUnordered } from "../../Util/equal.ts";
12import { CloudflareEnvironment } from "../CloudflareEnvironment.ts";
13import type { Providers } from "../Providers.ts";
14
15const TypeId = "Cloudflare.Gateway.ProxyEndpoint" as const;
16type TypeId = typeof TypeId;
17
18/**
19 * The kind of proxy endpoint. `ip` endpoints admit traffic from a source
20 * CIDR allowlist (Enterprise only); `identity` endpoints authenticate
21 * per-user. Immutable — changing the kind triggers a replacement.
22 */
23export type ProxyEndpointKind = "ip" | "identity";
24
25export interface ProxyEndpointProps {
26 /**
27 * Display name for the proxy endpoint. Used as a stable identifier so
28 * the provider can locate it by name during adoption / state recovery.
29 * If omitted, a unique name is generated from the app, stage, and
30 * logical ID.
31 *
32 * @default ${app}-${stage}-${id}
33 */
34 name?: string;
35 /**
36 * The proxy endpoint kind. `ip` endpoints (source-CIDR allowlist)
37 * require an Enterprise plan; `identity` endpoints work on all Zero
38 * Trust plans. Immutable — changing the kind triggers a replacement.
39 *
40 * @default "ip"
41 */
42 kind?: ProxyEndpointKind;
43 /**
44 * Source CIDRs allowed to connect through the endpoint (e.g.
45 * `203.0.113.1/32`). Required for (and only meaningful on) `ip`-kind
46 * endpoints. Mutable — patched in place.
47 */
48 ips?: string[];
49}
50
51export interface ProxyEndpointAttributes {
52 /** UUID of the proxy endpoint, assigned by Cloudflare. */
53 proxyEndpointId: string;
54 /** Cloudflare account that owns the proxy endpoint. */
55 accountId: string;
56 /** Display name of the proxy endpoint. */
57 name: string;
58 /** The proxy endpoint kind. */
59 kind: ProxyEndpointKind;
60 /** Source CIDR allowlist (empty for identity-kind endpoints). */
61 ips: string[];
62 /**
63 * Server-assigned subdomain. The PAC-file proxy hostname is
64 * `<subdomain>.proxy.cloudflare-gateway.com`.
65 */
66 subdomain: string | undefined;
67 /** ISO8601 creation timestamp. */
68 createdAt: string | undefined;
69 /** ISO8601 last-update timestamp. */
70 updatedAt: string | undefined;
71}
72
73export type ProxyEndpoint = Resource<
74 TypeId,
75 ProxyEndpointProps,
76 ProxyEndpointAttributes,
77 never,
78 Providers
79>;
80
81/**
82 * A Cloudflare Zero Trust Gateway proxy endpoint — an agentless HTTP
83 * proxy for forwarding traffic to Gateway without installing the WARP
84 * client, typically wired up via a PAC file pointing at the endpoint's
85 * server-assigned `subdomain`.
86 *
87 * `ip`-kind endpoints admit traffic from a source-CIDR allowlist and
88 * require an Enterprise plan (Cloudflare error code 2009 otherwise);
89 * `identity`-kind endpoints authenticate individual users and work on all
90 * Zero Trust plans. The kind is immutable; name and `ips` converge in
91 * place. Accounts are limited to a small number of proxy endpoints, so
92 * prefer reusing one per account.
93 * ### Creating a Proxy Endpoint
94 * **Example:** Identity-based endpoint (all plans)
95 * ```typescript
96 * const proxy = yield* Cloudflare.Gateway.ProxyEndpoint("UserProxy", {
97 * kind: "identity",
98 * });
99 * // PAC file target:
100 * const host = `${proxy.subdomain}.proxy.cloudflare-gateway.com`;
101 * ```
102 *
103 * **Example:** IP allowlist endpoint (Enterprise)
104 * ```typescript
105 * const proxy = yield* Cloudflare.Gateway.ProxyEndpoint("OfficeProxy", {
106 * kind: "ip",
107 * ips: ["203.0.113.1/32"],
108 * });
109 * ```
110 *
111 * @see https://developers.cloudflare.com/cloudflare-one/connections/connect-devices/agentless/pac-files/
112 *
113 * @resource
114 * @product Gateway
115 * @category Cloudflare One (Zero Trust)
116 */
117export const ProxyEndpoint = Resource<ProxyEndpoint>(TypeId);
118
119/**
120 * Returns true if the given value is a ProxyEndpoint resource.
121 */
122export const isProxyEndpoint = (value: unknown): value is ProxyEndpoint =>
123 Predicate.hasProperty(value, "Type") && value.Type === TypeId;
124
125export const ProxyEndpointProvider = () =>
126 Provider.succeed(ProxyEndpoint, {
127 stables: ["proxyEndpointId", "accountId", "kind", "subdomain", "createdAt"],
128
129 diff: Effect.fn(function* ({ olds = {}, news, output }) {
130 const { accountId } = yield* yield* CloudflareEnvironment;
131 if (!isResolved(news)) return undefined;
132 if ((output?.accountId ?? accountId) !== accountId) {
133 return { action: "replace" } as const;
134 }
135 // The endpoint kind is immutable on Cloudflare's side.
136 const oldKind =
137 output?.kind ?? (olds as ProxyEndpointProps).kind ?? undefined;
138 if (oldKind !== undefined && oldKind !== (news.kind ?? "ip")) {
139 return { action: "replace" } as const;
140 }
141 return undefined;
142 }),
143
144 read: Effect.fn(function* ({ id, output, olds }) {
145 const { accountId } = yield* yield* CloudflareEnvironment;
146 const acct = output?.accountId ?? accountId;
147
148 // Owned path — refresh by the cached endpoint id.
149 if (output?.proxyEndpointId) {
150 const observed = yield* getEndpoint(acct, output.proxyEndpointId);
151 if (observed) return toAttributes(observed, acct);
152 }
153
154 // Cold read — locate by deterministic name. Proxy endpoints carry no
155 // ownership markers, so report the match as Unowned to gate adoption.
156 const name = yield* resolveName(id, olds?.name ?? output?.name);
157 const match = yield* findByName(acct, name);
158 if (match) return Unowned(toAttributes(match, acct));
159 return undefined;
160 }),
161
162 reconcile: Effect.fn(function* ({ id, news, output }) {
163 const { accountId } = yield* yield* CloudflareEnvironment;
164 const name = yield* resolveName(id, news.name);
165 const kind = news.kind ?? "ip";
166
167 // 1. Observe — the cached id is a hint; fall back to a name scan.
168 // Accounts are limited to very few proxy endpoints, so converging
169 // onto an existing same-named endpoint matters more than usual.
170 let observed = output?.proxyEndpointId
171 ? yield* getEndpoint(accountId, output.proxyEndpointId)
172 : undefined;
173 if (!observed) {
174 observed = yield* findByName(accountId, name);
175 }
176
177 // 2. Ensure — create with the full desired body when missing.
178 // `ip`-kind endpoints on a non-Enterprise account fail with the
179 // typed entitlement error IpProxyEndpointsRequireEnterprise
180 // (Cloudflare code 2009), which propagates.
181 if (!observed) {
182 observed = yield* zeroTrust.createGatewayProxyEndpoint({
183 accountId,
184 name,
185 kind,
186 ...(kind === "ip" ? { ips: news.ips ?? [] } : {}),
187 });
188 }
189
190 // 3. Sync — diff observed name/ips against desired and PATCH only
191 // the delta; skip the call entirely on a no-op. `ips` compares as
192 // an unordered set and only applies to ip-kind endpoints.
193 const observedIps = observedIpsOf(observed);
194 const dirty =
195 observed.name !== name ||
196 (kind === "ip" &&
197 news.ips !== undefined &&
198 !arrayEqualsUnordered(observedIps, news.ips));
199 if (dirty) {
200 observed = yield* zeroTrust.patchGatewayProxyEndpoint({
201 accountId,
202 proxyEndpointId: observed.id ?? "",
203 name,
204 ...(kind === "ip" && news.ips !== undefined ? { ips: news.ips } : {}),
205 });
206 }
207
208 return toAttributes(observed, accountId);
209 }),
210
211 delete: Effect.fn(function* ({ output }) {
212 // A missing endpoint (ProxyEndpointNotFound, code 2002) means we're
213 // done.
214 yield* zeroTrust
215 .deleteGatewayProxyEndpoint({
216 accountId: output.accountId,
217 proxyEndpointId: output.proxyEndpointId,
218 })
219 .pipe(Effect.catchTag("ProxyEndpointNotFound", () => Effect.void));
220 }),
221
222 // Account-scoped collection: the proxy-endpoints list op returns each
223 // endpoint's full body, so no per-item hydration is needed. Exhaustively
224 // paginate (`.items` flattens `result` across pages) and map straight into
225 // the `read` Attributes shape.
226 list: Effect.fn(function* () {
227 const { accountId } = yield* yield* CloudflareEnvironment;
228 return yield* zeroTrust.listGatewayProxyEndpoints
229 .items({ accountId })
230 .pipe(
231 Stream.map((e) => toAttributes(e as ObservedEndpoint, accountId)),
232 Stream.runCollect,
233 Effect.map((chunk) => Array.from(chunk)),
234 );
235 }),
236 });
237
238type ObservedEndpoint = zeroTrust.GetGatewayProxyEndpointResponse;
239
240/**
241 * Read a proxy endpoint by id, mapping "gone" (`ProxyEndpointNotFound`,
242 * Cloudflare error code 2002) to `undefined`.
243 */
244const getEndpoint = (accountId: string, proxyEndpointId: string) =>
245 zeroTrust.getGatewayProxyEndpoint({ accountId, proxyEndpointId }).pipe(
246 Effect.map((e): ObservedEndpoint | undefined => e),
247 Effect.catchTag("ProxyEndpointNotFound", () => Effect.succeed(undefined)),
248 );
249
250/**
251 * Find a proxy endpoint by exact name. Pick the oldest match for
252 * determinism.
253 */
254const findByName = (accountId: string, name: string) =>
255 zeroTrust.listGatewayProxyEndpoints.items({ accountId }).pipe(
256 Stream.filter((e) => e.name === name),
257 Stream.runCollect,
258 Effect.map(
259 (chunk): ObservedEndpoint | undefined =>
260 Array.from(chunk)
261 .sort((a, b) => (a.createdAt ?? "").localeCompare(b.createdAt ?? ""))
262 .at(0) as ObservedEndpoint | undefined,
263 ),
264 );
265
266const resolveName = (id: string, name: string | undefined) =>
267 Effect.gen(function* () {
268 if (name) return name;
269 return yield* createPhysicalName({ id, lowercase: true });
270 });
271
272/**
273 * Identity-kind endpoints report `ips: null` on the wire (previously `[]`),
274 * so a key-presence check is not enough — only a real array counts.
275 */
276const observedIpsOf = (endpoint: ObservedEndpoint): string[] =>
277 "ips" in endpoint && Array.isArray(endpoint.ips) ? [...endpoint.ips] : [];
278
279const toAttributes = (
280 endpoint: ObservedEndpoint,
281 accountId: string,
282): ProxyEndpointAttributes => ({
283 proxyEndpointId: endpoint.id ?? "",
284 accountId,
285 name: endpoint.name,
286 kind: (endpoint.kind ?? "ip") as ProxyEndpointKind,
287 ips: observedIpsOf(endpoint),
288 subdomain: endpoint.subdomain ?? undefined,
289 createdAt: endpoint.createdAt ?? undefined,
290 updatedAt: endpoint.updatedAt ?? undefined,
291});