1import * as apiGateway from "@distilled.cloud/cloudflare/api-gateway";
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 { createPhysicalName } from "../../PhysicalName.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.Label" as const;
15type TypeId = typeof TypeId;
16
17export interface LabelProps {
18 /**
19 * Zone the label is defined on.
20 *
21 * Immutable — moving a label between zones triggers a replacement.
22 */
23 zoneId: string;
24 /**
25 * Name of the label. Must be 1–24 characters. If omitted, a unique
26 * lowercase name is generated from the app, stage, and logical ID.
27 *
28 * Immutable — the name is the label's identity, so changing it triggers
29 * a replacement.
30 * @default ${app}-${id}-${stage}-${suffix} (truncated to 24 characters)
31 */
32 name?: string;
33 /**
34 * Human-readable description of the label. Mutable — patched in place.
35 * @default ""
36 */
37 description?: string;
38}
39
40export interface LabelAttributes {
41 /** Zone the label is defined on. */
42 zoneId: string;
43 /** Name of the label (its identity within the zone). */
44 name: string;
45 /** Human-readable description of the label. */
46 description: string;
47 /**
48 * Who owns the label: `user` for labels we manage, `managed` for
49 * Cloudflare-curated labels.
50 */
51 source: string;
52 /** ISO8601 creation timestamp. */
53 createdAt: string;
54 /** ISO8601 timestamp of the last update. */
55 lastUpdated: string;
56}
57
58/**
59 * Returns true if the given value is an Label resource.
60 */
61export const isLabel = (value: unknown): value is Label =>
62 Predicate.hasProperty(value, "Type") && value.Type === TypeId;
63
64export type Label = Resource<
65 TypeId,
66 LabelProps,
67 LabelAttributes,
68 never,
69 Providers
70>;
71
72/**
73 * A Cloudflare API Shield user label — a zone-scoped tag that can be
74 * attached to registered API operations to organize and filter them
75 * (e.g. by team, service, or sensitivity).
76 *
77 * The label's `name` is its identity (and Cloudflare limits it to 24
78 * characters), so renaming triggers a replacement; only the `description`
79 * is mutable in place. Deleting a label detaches it from any operations
80 * server-side.
81 * ### Creating a Label
82 * **Example:** Label with a generated name
83 * ```typescript
84 * const label = yield* Cloudflare.ApiShield.Label("TeamPayments", {
85 * zoneId: zone.zoneId,
86 * description: "endpoints owned by the payments team",
87 * });
88 * ```
89 *
90 * **Example:** Label with an explicit name
91 * ```typescript
92 * yield* Cloudflare.ApiShield.Label("Pii", {
93 * zoneId: zone.zoneId,
94 * name: "pii",
95 * description: "endpoints that return personal data",
96 * });
97 * ```
98 *
99 * @see https://developers.cloudflare.com/api-shield/management-and-monitoring/endpoint-labels/
100 *
101 * @resource
102 * @product API Shield
103 * @category Application Security
104 */
105export const Label = Resource<Label>(TypeId);
106
107export const LabelProvider = () =>
108 Provider.succeed(Label, {
109 stables: ["zoneId", "name", "source", "createdAt"],
110
111 list: Effect.fn(function* () {
112 const { accountId } = yield* yield* CloudflareEnvironment;
113 // Labels are zone-scoped — fan out over every zone and list its
114 // user labels. Only `user`-sourced labels are enumerated;
115 // `managed` labels are Cloudflare-curated and not ours to delete.
116 const zones = yield* listAllZones(accountId);
117 const rows = yield* Effect.forEach(
118 zones,
119 (zone) =>
120 apiGateway.listLabels.pages({ zoneId: zone.id, source: "user" }).pipe(
121 Stream.runCollect,
122 Effect.map((chunk) =>
123 Array.from(chunk).flatMap((page) =>
124 (page.result ?? []).map((label) =>
125 toAttributes(label, zone.id),
126 ),
127 ),
128 ),
129 // A zone may genuinely vanish mid-enumeration: a concurrent
130 // delete purged it (ZonePurged, Cloudflare code 10410), it lacks
131 // the route (InvalidRoute), or it's already gone (NotFound). Skip
132 // that zone rather than fail the whole enumeration. (Transient
133 // code-10000 "Authentication error" blips under concurrency are
134 // retried globally by the Cloudflare retry policy, so they never
135 // reach here as a real failure.)
136 Effect.catchTag(["ZonePurged", "InvalidRoute", "NotFound"], () =>
137 Effect.succeed([]),
138 ),
139 ),
140 { concurrency: 10 },
141 );
142 return rows.flat();
143 }),
144
145 diff: Effect.fn(function* ({ id, olds, news, output }) {
146 const o = olds as LabelProps | undefined;
147 const n = news as LabelProps;
148 if (o === undefined) return undefined;
149 // The name is the label's identity — compare the resolved physical
150 // names (an omitted name resolves deterministically from the id).
151 const oldName = output?.name ?? (yield* createLabelName(id, o.name));
152 // Auto-generated names are engine-owned: the deployed name stays
153 // authoritative even if the generator would name this id differently
154 // today. Only an explicit user-provided name can force a replace.
155 const newName = n.name ?? oldName;
156 if (oldName !== newName) {
157 return { action: "replace" } as const;
158 }
159 // zoneId is Input<string>; compare only once both are concrete.
160 if (
161 typeof o.zoneId === "string" &&
162 typeof n.zoneId === "string" &&
163 o.zoneId !== n.zoneId
164 ) {
165 return { action: "replace" } as const;
166 }
167 return undefined;
168 }),
169
170 read: Effect.fn(function* ({ id, output, olds }) {
171 const zoneId = output?.zoneId ?? (olds?.zoneId as string | undefined);
172 if (zoneId === undefined) return undefined;
173
174 // Owned path: refresh by the persisted name.
175 if (output?.name) {
176 const observed = yield* getLabel(zoneId, output.name);
177 return observed ? toAttributes(observed, zoneId) : undefined;
178 }
179
180 // Cold path: recover from lost state via the deterministic name.
181 // Labels carry no ownership markers, so an existing label is
182 // reported as `Unowned` and adoption is gated by the adopt policy.
183 const name = yield* createLabelName(id, olds?.name);
184 const observed = yield* getLabel(zoneId, name);
185 return observed ? Unowned(toAttributes(observed, zoneId)) : undefined;
186 }),
187
188 reconcile: Effect.fn(function* ({ id, news, output }) {
189 // Inputs have been resolved to concrete strings by Plan.
190 const zoneId = news.zoneId as string;
191 const name = output?.name ?? (yield* createLabelName(id, news.name));
192
193 // 1. Observe — read the live label; the attributes cached on
194 // `output` are a hint, not a guarantee.
195 let observed = yield* getLabel(zoneId, name);
196
197 // 2. Ensure — create when missing. A concurrent create surfaces as
198 // `LabelAlreadyExists` (Cloudflare code 70009): converge by
199 // re-reading the label that won the race.
200 if (!observed) {
201 observed = yield* apiGateway
202 .bulkCreateLabelUsers({
203 zoneId,
204 body: [{ name, description: news.description }],
205 })
206 .pipe(
207 Effect.map((response) => response.result[0]),
208 Effect.catchTag("LabelAlreadyExists", (error) =>
209 getLabel(zoneId, name).pipe(
210 Effect.flatMap((existing) =>
211 existing ? Effect.succeed(existing) : Effect.fail(error),
212 ),
213 ),
214 ),
215 );
216 }
217
218 // 3. Sync — diff the observed description against desired; skip the
219 // patch call entirely on a no-op.
220 const desired = news.description ?? "";
221 if (observed.description !== desired) {
222 observed = yield* apiGateway.patchLabelUser({
223 zoneId,
224 name,
225 description: desired,
226 });
227 }
228
229 return toAttributes(observed, zoneId);
230 }),
231
232 delete: Effect.fn(function* ({ output }) {
233 yield* apiGateway
234 .deleteLabelUser({ zoneId: output.zoneId, name: output.name })
235 .pipe(Effect.catchTag("LabelNotFound", () => Effect.void));
236 }),
237 });
238
239type ObservedLabel = Pick<
240 apiGateway.GetLabelUserResponse,
241 "name" | "description" | "source" | "createdAt" | "lastUpdated"
242>;
243
244/**
245 * Read a label by name, mapping "gone" (`LabelNotFound`, Cloudflare error
246 * code 70014) to `undefined`.
247 */
248const getLabel = (zoneId: string, name: string) =>
249 apiGateway.getLabelUser({ zoneId, name }).pipe(
250 Effect.map((label): ObservedLabel | undefined => label),
251 Effect.catchTag("LabelNotFound", () => Effect.succeed(undefined)),
252 );
253
254/**
255 * Resolve the label's physical name: the explicit prop wins; otherwise a
256 * deterministic name is generated from the app, stage, and logical ID,
257 * truncated to Cloudflare's 24-character limit.
258 */
259const createLabelName = (id: string, name: string | undefined) =>
260 Effect.gen(function* () {
261 return (
262 name ??
263 (yield* createPhysicalName({
264 id,
265 lowercase: true,
266 maxLength: 24,
267 suffixLength: 8,
268 }))
269 );
270 });
271
272const toAttributes = (
273 label: ObservedLabel,
274 zoneId: string,
275): LabelAttributes => ({
276 zoneId,
277 name: label.name,
278 description: label.description,
279 source: label.source,
280 createdAt: label.createdAt,
281 lastUpdated: label.lastUpdated,
282});