1import * as zeroTrust from "@distilled.cloud/cloudflare/zero-trust";
2import * as Effect from "effect/Effect";
3import * as Option from "effect/Option";
4import * as Predicate from "effect/Predicate";
5import * as Redacted from "effect/Redacted";
6import * as Stream from "effect/Stream";
7
8import { Unowned } from "../../AdoptPolicy.ts";
9import { isResolved } from "../../Diff.ts";
10import { createPhysicalName } from "../../PhysicalName.ts";
11import * as Provider from "../../Provider.ts";
12import { Resource } from "../../Resource.ts";
13import { CloudflareEnvironment } from "../CloudflareEnvironment.ts";
14import type { Providers } from "../Providers.ts";
15
16const TypeId = "Cloudflare.Tunnel.WarpConnector" as const;
17type TypeId = typeof TypeId;
18
19export interface WarpConnectorProps {
20 /**
21 * User-friendly name for the WARP Connector tunnel. Tunnel names are
22 * unique per account, which makes the name the resource's identity
23 * during adoption and state recovery. Changing it replaces the tunnel.
24 *
25 * @default ${app}-${stage}-${id}
26 */
27 name?: string;
28}
29
30export interface WarpConnectorAttributes {
31 /** UUID of the WARP Connector tunnel, assigned by Cloudflare. */
32 tunnelId: string;
33 /** Cloudflare account that owns the tunnel. */
34 accountId: string;
35 /** User-friendly name of the tunnel. */
36 name: string;
37 /**
38 * Status of the tunnel: `inactive` (never run), `degraded`, `healthy`,
39 * or `down`.
40 */
41 status: string | undefined;
42 /** RFC 3339 timestamp of when the tunnel was created. */
43 createdAt: string | undefined;
44 /**
45 * Connector token used to run the WARP Connector on a host
46 * (`warp-cli connector new <token>`). Sensitive — stored redacted.
47 */
48 token: Redacted.Redacted<string>;
49}
50
51export type WarpConnector = Resource<
52 TypeId,
53 WarpConnectorProps,
54 WarpConnectorAttributes,
55 never,
56 Providers
57>;
58
59/**
60 * A Cloudflare WARP Connector tunnel — a software site-to-site connector
61 * that extends a private network into Cloudflare Zero Trust without
62 * running `cloudflared`.
63 *
64 * The resource manages the tunnel record itself (CRUD); a WARP Connector
65 * host joins it at runtime using the `token` attribute. Pair with
66 * {@link Route} to route private CIDRs through the connector and
67 * {@link VirtualNetwork} to isolate overlapping address space.
68 * ### Creating a WARP Connector
69 * **Example:** Basic WARP Connector tunnel
70 * ```typescript
71 * const connector = yield* Cloudflare.Tunnel.WarpConnector("SiteA", {
72 * name: "site-a-connector",
73 * });
74 * // Provision the host with: warp-cli connector new <Redacted.value(connector.token)>
75 * ```
76 *
77 * **Example:** Route a private network through the connector
78 * ```typescript
79 * yield* Cloudflare.Tunnel.Route("SiteANet", {
80 * tunnelId: connector.tunnelId,
81 * network: "10.8.0.0/16",
82 * });
83 * ```
84 *
85 * ### Renaming
86 * **Example:** Replace with a new name
87 * ```typescript
88 * // Renaming creates a new tunnel with a new tunnelId.
89 * const connector = yield* Cloudflare.Tunnel.WarpConnector("SiteA", {
90 * name: "site-a-connector-v2",
91 * });
92 * ```
93 *
94 * @see https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/private-net/warp-connector/
95 *
96 * @resource
97 * @product Tunnels
98 * @category Cloudflare One (Zero Trust)
99 */
100export const WarpConnector = Resource<WarpConnector>(TypeId);
101
102/**
103 * Returns true if the given value is a WarpConnector resource.
104 */
105export const isWarpConnector = (value: unknown): value is WarpConnector =>
106 Predicate.hasProperty(value, "Type") && value.Type === TypeId;
107
108export const WarpConnectorProvider = () =>
109 Provider.succeed(WarpConnector, {
110 stables: ["tunnelId", "accountId", "createdAt"],
111
112 diff: Effect.fn(function* ({ olds, news, output }) {
113 const { accountId } = yield* yield* CloudflareEnvironment;
114 if ((output?.accountId ?? accountId) !== accountId) {
115 return { action: "replace" } as const;
116 }
117 if (isResolved(news) && olds !== undefined && olds.name !== news.name) {
118 return { action: "replace" } as const;
119 }
120 return undefined;
121 }),
122
123 read: Effect.fn(function* ({ id, output, olds }) {
124 const { accountId } = yield* yield* CloudflareEnvironment;
125 const acct = output?.accountId ?? accountId;
126
127 // Owned path — refresh by the cached id.
128 if (output?.tunnelId) {
129 const observed = yield* getConnector(acct, output.tunnelId);
130 if (observed) return yield* toAttributes(observed, acct);
131 }
132
133 // Cold read — tunnel names are unique per account, so an exact name
134 // match is the resource's identity. WARP Connector tunnels carry no
135 // ownership markers, so gate adoption behind Unowned.
136 const name = yield* resolveName(id, olds?.name ?? output?.name);
137 const match = yield* findByName(acct, name);
138 if (match) return Unowned(yield* toAttributes(match, acct));
139 return undefined;
140 }),
141
142 // Account collection: the dedicated `/accounts/{id}/warp_connector`
143 // endpoint already scopes to warp_connector tunnels. Paginate
144 // exhaustively, drop soft-deleted tunnels (match `read`), then hydrate
145 // each live tunnel into the exact `read` Attributes shape (token
146 // included). A tunnel deleted out from under us between the list and the
147 // token fetch surfaces as a typed TunnelNotFound — skip it.
148 list: Effect.fn(function* () {
149 const { accountId } = yield* yield* CloudflareEnvironment;
150 const observed = yield* zeroTrust.listTunnelWarpConnectors
151 .pages({ accountId, isDeleted: false })
152 .pipe(
153 Stream.runCollect,
154 Effect.map((chunk) =>
155 Array.from(chunk).flatMap((page) =>
156 (page.result ?? []).filter(
157 (t): t is ObservedConnector & { id: string } =>
158 !t.deletedAt && typeof t.id === "string",
159 ),
160 ),
161 ),
162 );
163 const rows = yield* Effect.forEach(
164 observed,
165 (t) =>
166 toAttributes(t, accountId).pipe(
167 Effect.map(Option.some),
168 Effect.catchTag("TunnelNotFound", () =>
169 Effect.succeed(Option.none<WarpConnectorAttributes>()),
170 ),
171 ),
172 { concurrency: 10 },
173 );
174 return rows.flatMap((row) => (Option.isSome(row) ? [row.value] : []));
175 }),
176
177 reconcile: Effect.fn(function* ({ id, news, output }) {
178 const { accountId } = yield* yield* CloudflareEnvironment;
179 const name = yield* resolveName(id, news.name);
180
181 // 1. Observe — the cached id is a hint, not a guarantee.
182 let observed = output?.tunnelId
183 ? yield* getConnector(accountId, output.tunnelId)
184 : undefined;
185 if (!observed) {
186 observed = yield* findByName(accountId, name);
187 }
188
189 // 2. Ensure — create when missing. Tunnel names are unique per
190 // account, so a racing create surfaces as DuplicateTunnelName
191 // (code 1013): converge by re-reading the tunnel that won.
192 if (!observed) {
193 observed = yield* zeroTrust
194 .createTunnelWarpConnector({ accountId, name })
195 .pipe(
196 Effect.catchTag("DuplicateTunnelName", (error) =>
197 findByName(accountId, name).pipe(
198 Effect.flatMap((existing) =>
199 existing ? Effect.succeed(existing) : Effect.fail(error),
200 ),
201 ),
202 ),
203 );
204 }
205
206 // 3. Sync — rename via PATCH only when the observed name differs.
207 if ((observed.name ?? undefined) !== name) {
208 observed = yield* zeroTrust.patchTunnelWarpConnector({
209 accountId,
210 tunnelId: observed.id!,
211 name,
212 });
213 }
214
215 return yield* toAttributes(observed, accountId);
216 }),
217
218 delete: Effect.fn(function* ({ output }) {
219 // Deleting requires all connector sessions to be down — true for
220 // tunnels never joined by a host. A missing tunnel (TunnelNotFound,
221 // code 1002) means we're done.
222 yield* zeroTrust
223 .deleteTunnelWarpConnector({
224 accountId: output.accountId,
225 tunnelId: output.tunnelId,
226 })
227 .pipe(Effect.catchTag("TunnelNotFound", () => Effect.void));
228 }),
229 });
230
231interface ObservedConnector {
232 id?: string;
233 name?: string | null;
234 status?: "inactive" | "degraded" | "healthy" | "down" | null;
235 createdAt?: string | null;
236 deletedAt?: string | null;
237}
238
239/**
240 * Read a WARP Connector tunnel by id, mapping "gone" (`TunnelNotFound`,
241 * Cloudflare error code 1002) and soft-deleted tunnels to `undefined`.
242 */
243const getConnector = (accountId: string, tunnelId: string) =>
244 zeroTrust.getTunnelWarpConnector({ accountId, tunnelId }).pipe(
245 Effect.map((t): ObservedConnector | undefined =>
246 t.deletedAt ? undefined : t,
247 ),
248 Effect.catchTag("TunnelNotFound", () => Effect.succeed(undefined)),
249 );
250
251/**
252 * Find a live WARP Connector tunnel by exact name. Tunnel names are
253 * unique per account so at most one live tunnel can match.
254 */
255const findByName = (accountId: string, name: string) =>
256 zeroTrust.listTunnelWarpConnectors
257 .items({ accountId, name, isDeleted: false })
258 .pipe(
259 Stream.filter(
260 (t): t is ObservedConnector & { id: string } =>
261 t.name === name && !t.deletedAt && typeof t.id === "string",
262 ),
263 Stream.runHead,
264 Effect.map(Option.getOrUndefined),
265 );
266
267const resolveName = (id: string, name: string | undefined) =>
268 Effect.gen(function* () {
269 if (name) return name;
270 return yield* createPhysicalName({ id, lowercase: true });
271 });
272
273const toAttributes = Effect.fn(function* (
274 tunnel: ObservedConnector,
275 accountId: string,
276) {
277 const token = yield* zeroTrust.getTunnelWarpConnectorToken({
278 accountId,
279 tunnelId: tunnel.id!,
280 });
281 return {
282 tunnelId: tunnel.id!,
283 accountId,
284 name: tunnel.name ?? "",
285 status: tunnel.status ?? undefined,
286 createdAt: tunnel.createdAt ?? undefined,
287 token: Redacted.make(token),
288 } satisfies WarpConnectorAttributes;
289});