1import * as addressing from "@distilled.cloud/cloudflare/addressing";
2import * as Effect from "effect/Effect";
3import * as Predicate from "effect/Predicate";
4import * as Stream from "effect/Stream";
5
6import { isResolved } from "../../Diff.ts";
7import * as Provider from "../../Provider.ts";
8import { Resource } from "../../Resource.ts";
9import { CloudflareEnvironment } from "../CloudflareEnvironment.ts";
10import type { Providers } from "../Providers.ts";
11
12const TypeId = "Cloudflare.Addressing.Prefix" as const;
13type TypeId = typeof TypeId;
14
15export interface PrefixProps {
16 /**
17 * IP Prefix in Classless Inter-Domain Routing format (e.g.
18 * `192.0.2.0/24`). Immutable — changing it forces a replacement.
19 */
20 cidr: string;
21 /**
22 * Autonomous System Number (ASN) the prefix will be advertised under.
23 * Immutable — changing it forces a replacement.
24 */
25 asn: number;
26 /**
27 * Description of the prefix. The only mutable field — patched in place
28 * via `patchPrefix`.
29 */
30 description?: string;
31 /**
32 * Identifier for an uploaded LOA (Letter of Authorization) document.
33 * Create-only — changing it forces a replacement.
34 */
35 loaDocumentId?: string;
36 /**
37 * Whether Cloudflare is allowed to generate the LOA document on behalf of
38 * the prefix owner. Create-only — changing it forces a replacement.
39 * @default false
40 */
41 delegateLoaCreation?: boolean;
42}
43
44export interface PrefixAttributes {
45 /** Cloudflare-assigned identifier of the IP Prefix. */
46 prefixId: string;
47 /** The Cloudflare account the prefix belongs to. */
48 accountId: string;
49 /** IP Prefix in CIDR format. */
50 cidr: string;
51 /** ASN the prefix is advertised under. */
52 asn: number;
53 /** Approval state of the prefix (`P` = pending, `V` = active). */
54 approved: string | undefined;
55 /** State of the ownership validation for the prefix. */
56 ownershipValidationState: string | undefined;
57 /** Token provided to demonstrate ownership of the prefix. */
58 ownershipValidationToken: string | undefined;
59 /** State of the IRR validation for the prefix. */
60 irrValidationState: string | undefined;
61 /** State of the RPKI validation for the prefix. */
62 rpkiValidationState: string | undefined;
63 /** Identifier of the uploaded LOA document, if any. */
64 loaDocumentId: string | undefined;
65 /** The prefix description, if set. */
66 description: string | undefined;
67 /** ISO8601 creation timestamp. */
68 createdAt: string | undefined;
69 /** ISO8601 last-modified timestamp. */
70 modifiedAt: string | undefined;
71}
72
73export type Prefix = Resource<
74 TypeId,
75 PrefixProps,
76 PrefixAttributes,
77 never,
78 Providers
79>;
80
81/**
82 * A BYOIP (Bring Your Own IP) prefix onboarded to Cloudflare's network.
83 *
84 * Requires the BYOIP enterprise add-on; onboarding a prefix is a contract
85 * process (LOA, IRR/RPKI validation, manual approval) — `approved` flips
86 * from `"P"` (pending) to `"V"` (active) on Cloudflare's side and is never
87 * waited on by this resource.
88 *
89 * Only `description` is mutable; `cidr`, `asn`, and the LOA settings force
90 * a replacement.
91 * ### Creating a Prefix
92 * **Example:** Onboard a prefix with a pre-uploaded LOA
93 * ```typescript
94 * const prefix = yield* Cloudflare.Addressing.Prefix("byoip", {
95 * cidr: "192.0.2.0/24",
96 * asn: 64496,
97 * description: "production ingress",
98 * loaDocumentId: loa.id,
99 * });
100 * ```
101 *
102 * **Example:** Delegate LOA creation to Cloudflare
103 * ```typescript
104 * const prefix = yield* Cloudflare.Addressing.Prefix("byoip", {
105 * cidr: "192.0.2.0/24",
106 * asn: 64496,
107 * delegateLoaCreation: true,
108 * });
109 * ```
110 *
111 * ### Advertising the Prefix
112 * **Example:** Advertise via a BGP prefix
113 * ```typescript
114 * const bgp = yield* Cloudflare.Addressing.BgpPrefix("advertise", {
115 * prefixId: prefix.prefixId,
116 * cidr: prefix.cidr,
117 * advertised: true,
118 * });
119 * ```
120 *
121 * @see https://developers.cloudflare.com/byoip/
122 *
123 * @resource
124 * @product Addressing
125 * @category Network
126 */
127export const Prefix = Resource<Prefix>(TypeId);
128
129/**
130 * Returns true if the given value is an Prefix resource.
131 */
132export const isPrefix = (value: unknown): value is Prefix =>
133 Predicate.hasProperty(value, "Type") && value.Type === TypeId;
134
135export const PrefixProvider = () =>
136 Provider.succeed(Prefix, {
137 stables: [
138 "prefixId",
139 "accountId",
140 "cidr",
141 "asn",
142 "ownershipValidationToken",
143 "createdAt",
144 ],
145
146 diff: Effect.fn(function* ({ olds, news, output }) {
147 if (olds === undefined) return undefined;
148 if (!isResolved(news) || !isResolved(olds)) return undefined;
149 // cidr / asn / LOA settings are create-only.
150 if (news.cidr !== (output?.cidr ?? olds.cidr)) {
151 return { action: "replace" } as const;
152 }
153 if (news.asn !== (output?.asn ?? olds.asn)) {
154 return { action: "replace" } as const;
155 }
156 if (
157 typeof news.loaDocumentId === "string" &&
158 typeof olds.loaDocumentId === "string" &&
159 news.loaDocumentId !== olds.loaDocumentId
160 ) {
161 return { action: "replace" } as const;
162 }
163 if (
164 (news.delegateLoaCreation ?? false) !==
165 (olds.delegateLoaCreation ?? false)
166 ) {
167 return { action: "replace" } as const;
168 }
169 return undefined;
170 }),
171
172 // BYOIP prefixes are account-scoped; the catalog list endpoint returns
173 // the full prefix object per item, so each row hydrates directly into the
174 // same `Attributes` shape `read` produces — no per-item fetch needed.
175 list: Effect.fn(function* () {
176 const { accountId } = yield* yield* CloudflareEnvironment;
177 return yield* addressing.listPrefixes.pages({ accountId }).pipe(
178 Stream.runCollect,
179 Effect.map((chunk) =>
180 Array.from(chunk).flatMap((page) =>
181 (page.result ?? []).map((p) => toAttributes(p, accountId)),
182 ),
183 ),
184 );
185 }),
186
187 read: Effect.fn(function* ({ output, olds }) {
188 const { accountId } = yield* yield* CloudflareEnvironment;
189 const acct = output?.accountId ?? accountId;
190
191 if (output?.prefixId) {
192 const observed = yield* getPrefix(acct, output.prefixId);
193 return observed ? toAttributes(observed, acct) : undefined;
194 }
195 // Cold read — a CIDR is unique per account, so match on it.
196 const cidr = output?.cidr ?? olds?.cidr;
197 if (typeof cidr !== "string") return undefined;
198 const match = yield* findByCidr(acct, cidr);
199 return match ? toAttributes(match, acct) : undefined;
200 }),
201
202 reconcile: Effect.fn(function* ({ news, output }) {
203 const { accountId } = yield* yield* CloudflareEnvironment;
204 const acct = output?.accountId ?? accountId;
205
206 // 1. Observe — the prefix id on `output` is a hint; a missing prefix
207 // falls through to the by-CIDR lookup and then to create.
208 let observed = output?.prefixId
209 ? yield* getPrefix(acct, output.prefixId)
210 : undefined;
211 if (!observed) {
212 observed = yield* findByCidr(acct, news.cidr);
213 }
214
215 // 2. Ensure — create the prefix if it is missing.
216 if (!observed) {
217 const created = yield* addressing.createPrefix({
218 accountId: acct,
219 cidr: news.cidr,
220 asn: news.asn,
221 description: news.description,
222 loaDocumentId: news.loaDocumentId as string | undefined,
223 delegateLoaCreation: news.delegateLoaCreation,
224 });
225 return toAttributes(created, acct);
226 }
227
228 // 3. Sync — `description` is the only mutable field; skip the PATCH
229 // entirely on a no-op.
230 const desiredDescription = news.description ?? "";
231 if ((observed.description ?? "") !== desiredDescription) {
232 const patched = yield* addressing.patchPrefix({
233 accountId: acct,
234 prefixId: observed.id ?? "",
235 description: desiredDescription,
236 });
237 return toAttributes(patched, acct);
238 }
239
240 return toAttributes(observed, acct);
241 }),
242
243 delete: Effect.fn(function* ({ output }) {
244 // Deleting an onboarded prefix can be rejected while service bindings
245 // or BGP advertisements still exist — children delete first; the
246 // engine orders dependencies, so a hard failure here is genuine.
247 yield* addressing
248 .deletePrefix({
249 accountId: output.accountId,
250 prefixId: output.prefixId,
251 })
252 .pipe(Effect.catchTag("PrefixNotFound", () => Effect.void));
253 }),
254 });
255
256/**
257 * Read a prefix by id, mapping "gone" (`PrefixNotFound`, Cloudflare error
258 * code 1000 `not_found`) to `undefined`.
259 */
260const getPrefix = (accountId: string, prefixId: string) =>
261 addressing
262 .getPrefix({ accountId, prefixId })
263 .pipe(Effect.catchTag("PrefixNotFound", () => Effect.succeed(undefined)));
264
265/**
266 * Find a prefix by exact CIDR — unique per account.
267 */
268const findByCidr = (accountId: string, cidr: string) =>
269 addressing.listPrefixes.items({ accountId }).pipe(
270 Stream.runCollect,
271 Effect.map((chunk) => Array.from(chunk).find((p) => p.cidr === cidr)),
272 );
273
274const toAttributes = (
275 prefix: addressing.GetPrefixResponse,
276 accountId: string,
277): PrefixAttributes => ({
278 prefixId: prefix.id ?? "",
279 accountId,
280 cidr: prefix.cidr ?? "",
281 asn: prefix.asn ?? 0,
282 approved: prefix.approved ?? undefined,
283 ownershipValidationState: prefix.ownershipValidationState ?? undefined,
284 ownershipValidationToken: prefix.ownershipValidationToken ?? undefined,
285 irrValidationState: prefix.irrValidationState ?? undefined,
286 rpkiValidationState: prefix.rpkiValidationState ?? undefined,
287 loaDocumentId: prefix.loaDocumentId ?? undefined,
288 description: prefix.description ?? undefined,
289 createdAt: prefix.createdAt ?? undefined,
290 modifiedAt: prefix.modifiedAt ?? undefined,
291});