1import * as apiGateway from "@distilled.cloud/cloudflare/api-gateway";
2import * as Effect from "effect/Effect";
3import * as Predicate from "effect/Predicate";
4import * as Schedule from "effect/Schedule";
5import * as Stream from "effect/Stream";
6
7import { Unowned } from "../../AdoptPolicy.ts";
8import * as Provider from "../../Provider.ts";
9import { Resource } from "../../Resource.ts";
10import { CloudflareEnvironment } from "../CloudflareEnvironment.ts";
11import type { Providers } from "../Providers.ts";
12import { listAllZones } from "../Zone/lookup.ts";
13
14const TypeId = "Cloudflare.ApiShield.Operation" as const;
15type TypeId = typeof TypeId;
16
17/**
18 * HTTP method of an API Shield operation.
19 */
20export type OperationMethod =
21 | "GET"
22 | "POST"
23 | "HEAD"
24 | "OPTIONS"
25 | "PUT"
26 | "DELETE"
27 | "CONNECT"
28 | "PATCH"
29 | "TRACE";
30
31export interface OperationProps {
32 /**
33 * Zone the operation is registered on.
34 *
35 * Immutable — moving an operation between zones triggers a replacement.
36 */
37 zoneId: string;
38 /**
39 * The HTTP method used to access the endpoint.
40 *
41 * Immutable — an operation is the `(method, host, endpoint)` tuple, so
42 * changing the method triggers a replacement.
43 */
44 method: OperationMethod;
45 /**
46 * RFC3986-compliant host the endpoint lives on (e.g. `api.example.com`).
47 * Must belong to the zone.
48 *
49 * Immutable — changing the host triggers a replacement.
50 */
51 host: string;
52 /**
53 * The endpoint path, which may contain path-parameter templates in curly
54 * braces (e.g. `/api/v1/users/{id}`). Cloudflare normalizes variable
55 * names left-to-right to `{var1}`, `{var2}`, … on insertion.
56 *
57 * Immutable — changing the (normalized) endpoint triggers a replacement.
58 */
59 endpoint: string;
60}
61
62export interface OperationAttributes {
63 /** Cloudflare-assigned UUID of the operation. */
64 operationId: string;
65 /** Zone the operation is registered on. */
66 zoneId: string;
67 /** The HTTP method used to access the endpoint. */
68 method: OperationMethod;
69 /** RFC3986-compliant host the endpoint lives on. */
70 host: string;
71 /**
72 * The endpoint path as stored by Cloudflare — variable names are
73 * normalized left-to-right to `{var1}`, `{var2}`, …
74 */
75 endpoint: string;
76 /** ISO8601 timestamp of the last update. */
77 lastUpdated: string;
78}
79
80export type Operation = Resource<
81 TypeId,
82 OperationProps,
83 OperationAttributes,
84 never,
85 Providers
86>;
87
88/**
89 * A Cloudflare API Shield operation — a registered API endpoint on a zone,
90 * identified by the `(method, host, endpoint)` tuple. Registered operations
91 * are the unit other API Shield features (schema validation, rate limiting
92 * recommendations, API Discovery) attach to.
93 *
94 * An operation is pure identity: there is no update API, so changing any
95 * property triggers a replacement. Cloudflare upserts by identity — creating
96 * an already-registered tuple returns the existing operation — which makes
97 * reconciliation race-free.
98 *
99 * Endpoint paths may contain `{placeholder}` templates; Cloudflare
100 * normalizes the variable names left-to-right to `{var1}`, `{var2}`, … and
101 * the normalized form is what is stored and diffed.
102 * ### Registering an Operation
103 * **Example:** Register a GET endpoint
104 * ```typescript
105 * const op = yield* Cloudflare.ApiShield.Operation("GetUser", {
106 * zoneId: zone.zoneId,
107 * method: "GET",
108 * host: "api.example.com",
109 * endpoint: "/api/v1/users/{id}",
110 * });
111 * // op.endpoint === "/api/v1/users/{var1}"
112 * ```
113 *
114 * **Example:** Register a POST endpoint
115 * ```typescript
116 * yield* Cloudflare.ApiShield.Operation("CreateUser", {
117 * zoneId: zone.zoneId,
118 * method: "POST",
119 * host: "api.example.com",
120 * endpoint: "/api/v1/users",
121 * });
122 * ```
123 *
124 * @see https://developers.cloudflare.com/api-shield/management-and-monitoring/endpoint-management/
125 *
126 * @resource
127 * @product API Shield
128 * @category Application Security
129 */
130export const Operation = Resource<Operation>(TypeId);
131
132/**
133 * Returns true if the given value is an Operation resource.
134 */
135export const isOperation = (value: unknown): value is Operation =>
136 Predicate.hasProperty(value, "Type") && value.Type === TypeId;
137
138export const OperationProvider = () =>
139 Provider.succeed(Operation, {
140 // An operation has no mutable aspect — every attribute except the
141 // last-updated timestamp survives any non-replacing deploy.
142 stables: ["operationId", "zoneId", "method", "host", "endpoint"],
143
144 list: Effect.fn(function* () {
145 const { accountId } = yield* yield* CloudflareEnvironment;
146 // Operations live inside a zone (`/zones/{id}/api_gateway/operations`)
147 // with no account-wide enumeration API — fan out over every zone and
148 // exhaustively paginate each zone's operations.
149 const zones = yield* listAllZones(accountId);
150 const rows = yield* Effect.forEach(
151 zones,
152 (zone) =>
153 apiGateway.listOperations.pages({ zoneId: zone.id }).pipe(
154 Stream.runCollect,
155 Effect.map((chunk) =>
156 Array.from(chunk).flatMap((page) =>
157 (page.result ?? []).map((op) => toAttributes(op, zone.id)),
158 ),
159 ),
160 // A freshly-minted scoped token can transiently 403; ride out
161 // the blip, then skip zones the token genuinely can't read.
162 Effect.retry({
163 while: (e) => e._tag === "Forbidden",
164 schedule: Schedule.exponential("500 millis"),
165 times: 5,
166 }),
167 Effect.catchTag("Forbidden", () =>
168 Effect.succeed([] as OperationAttributes[]),
169 ),
170 ),
171 { concurrency: 10 },
172 );
173 return rows.flat();
174 }),
175
176 diff: Effect.fn(function* ({ olds, news }) {
177 const o = olds as OperationProps | undefined;
178 const n = news as OperationProps;
179 if (o?.endpoint === undefined) return undefined;
180 // The tuple is the operation's identity — any change replaces.
181 if (o.method !== n.method || o.host !== n.host) {
182 return { action: "replace" } as const;
183 }
184 // Cloudflare normalizes `{name}` templates to `{varN}`; compare
185 // normalized forms so `/users/{id}` -> `/users/{userId}` is a no-op.
186 if (normalizeEndpoint(o.endpoint) !== normalizeEndpoint(n.endpoint)) {
187 return { action: "replace" } as const;
188 }
189 // zoneId is Input<string>; compare only once both are concrete.
190 if (
191 typeof o.zoneId === "string" &&
192 typeof n.zoneId === "string" &&
193 o.zoneId !== n.zoneId
194 ) {
195 return { action: "replace" } as const;
196 }
197 return undefined;
198 }),
199
200 read: Effect.fn(function* ({ output, olds }) {
201 const zoneId = output?.zoneId ?? (olds?.zoneId as string | undefined);
202 if (zoneId === undefined) return undefined;
203
204 // Owned path: refresh by our persisted operation id.
205 if (output?.operationId) {
206 const observed = yield* getOperation(zoneId, output.operationId);
207 if (observed) return toAttributes(observed, zoneId);
208 }
209
210 // Cold path: an operation with this tuple may already exist on the
211 // zone (state was lost, or it was registered out-of-band). The tuple
212 // is pure identity and carries no ownership markers, so report it as
213 // `Unowned` and let the adopt policy gate the takeover.
214 const tuple = output ?? (olds as OperationProps | undefined);
215 if (tuple?.endpoint !== undefined && typeof tuple.host === "string") {
216 const observed = yield* findByTuple(zoneId, {
217 method: tuple.method,
218 host: tuple.host,
219 endpoint: tuple.endpoint,
220 });
221 if (observed) return Unowned(toAttributes(observed, zoneId));
222 }
223 return undefined;
224 }),
225
226 reconcile: Effect.fn(function* ({ news, output }) {
227 // Inputs have been resolved to concrete strings by Plan.
228 const zoneId = news.zoneId as string;
229
230 // 1. Observe — the operation id cached on `output` is a hint, not a
231 // guarantee: a missing operation falls through to ensure.
232 const observed = output?.operationId
233 ? yield* getOperation(zoneId, output.operationId)
234 : undefined;
235 if (observed) return toAttributes(observed, zoneId);
236
237 // 2. Ensure — Cloudflare upserts by identity: registering an existing
238 // `(method, host, endpoint)` tuple returns the existing operation,
239 // so there is no AlreadyExists race to tolerate.
240 const created = yield* apiGateway.createOperation({
241 zoneId,
242 method: news.method,
243 host: news.host,
244 endpoint: news.endpoint,
245 });
246 return toAttributes(created, zoneId);
247 // 3. Sync — nothing to do: an operation is existence-only.
248 }),
249
250 delete: Effect.fn(function* ({ output }) {
251 yield* apiGateway
252 .deleteOperation({
253 zoneId: output.zoneId,
254 operationId: output.operationId,
255 })
256 .pipe(Effect.catchTag("OperationNotFound", () => Effect.void));
257 }),
258 });
259
260type ObservedOperation = Pick<
261 apiGateway.GetOperationResponse,
262 "operationId" | "method" | "host" | "endpoint" | "lastUpdated"
263>;
264
265/**
266 * Normalize an endpoint path the way Cloudflare does on insertion: each
267 * `{placeholder}` is replaced left-to-right with `{var1}`, `{var2}`, …
268 */
269export const normalizeEndpoint = (endpoint: string): string => {
270 let n = 0;
271 return endpoint.replace(/\{[^}]*\}/g, () => {
272 n += 1;
273 return `{var${n}}`;
274 });
275};
276
277/**
278 * Read an operation by id, mapping "gone" (`OperationNotFound`, Cloudflare
279 * error code 10404) to `undefined`.
280 */
281const getOperation = (zoneId: string, operationId: string) =>
282 apiGateway.getOperation({ zoneId, operationId }).pipe(
283 Effect.map((op): ObservedOperation | undefined => op),
284 Effect.catchTag("OperationNotFound", () => Effect.succeed(undefined)),
285 );
286
287/**
288 * Find an operation by its exact `(method, host, endpoint)` tuple. The
289 * tuple is the operation's identity, so at most one can match. Endpoint
290 * comparison uses Cloudflare's normalized form.
291 */
292const findByTuple = (
293 zoneId: string,
294 tuple: { method: string; host: string; endpoint: string },
295) =>
296 apiGateway.listOperations
297 .items({ zoneId, host: [tuple.host], method: [tuple.method] })
298 .pipe(
299 Stream.runCollect,
300 Effect.map((chunk) =>
301 Array.from(chunk).find(
302 (op): op is ObservedOperation & typeof op =>
303 op.method === tuple.method &&
304 op.host === tuple.host &&
305 op.endpoint === normalizeEndpoint(tuple.endpoint),
306 ),
307 ),
308 );
309
310const toAttributes = (
311 op: ObservedOperation,
312 zoneId: string,
313): OperationAttributes => ({
314 operationId: op.operationId,
315 zoneId,
316 // Distilled widens generated string enums to open unions (`string & {}`).
317 method: op.method as OperationMethod,
318 host: op.host,
319 endpoint: op.endpoint,
320 lastUpdated: op.lastUpdated,
321});