1import * as turnstile from "@distilled.cloud/cloudflare/turnstile";
2import * as Effect from "effect/Effect";
3import * as Predicate from "effect/Predicate";
4import * as Redacted from "effect/Redacted";
5import * as Stream from "effect/Stream";
6
7import { isResolved } from "../../Diff.ts";
8import { createPhysicalName } from "../../PhysicalName.ts";
9import * as Provider from "../../Provider.ts";
10import { Resource } from "../../Resource.ts";
11import { CloudflareEnvironment } from "../CloudflareEnvironment.ts";
12import type { Providers } from "../Providers.ts";
13
14const TypeId = "Cloudflare.Turnstile.Widget" as const;
15type TypeId = typeof TypeId;
16
17/**
18 * Rendering / interaction mode of a Turnstile widget.
19 */
20export type WidgetMode = "managed" | "non-interactive" | "invisible";
21
22/**
23 * Region a Turnstile widget can be served from. Cannot be changed after
24 * creation.
25 */
26export type WidgetRegion = "world" | "china";
27
28/**
29 * Clearance level granted when the widget is embedded on a Cloudflare zone.
30 */
31export type ClearanceLevel =
32 | "no_clearance"
33 | "jschallenge"
34 | "managed"
35 | "interactive";
36
37export type WidgetProps = {
38 /**
39 * Human readable widget name. Not unique. If omitted, a unique name is
40 * generated from the app, stage, and logical ID.
41 * @default ${app}-${stage}-${id}
42 */
43 name?: string;
44 /**
45 * Hostnames the widget is allowed to run on (e.g. `example.com`).
46 * Subdomains are covered automatically.
47 */
48 domains: string[];
49 /**
50 * Widget mode: `managed` (Cloudflare decides if an interaction is
51 * required), `non-interactive` (never requires interaction), or
52 * `invisible` (no visible widget).
53 */
54 mode: WidgetMode;
55 /**
56 * Region where this widget can be used. Cannot be changed after creation —
57 * updating this property triggers a replacement.
58 * @default "world"
59 */
60 region?: WidgetRegion;
61 /**
62 * If `true`, Cloudflare issues computationally expensive challenges in
63 * response to malicious bots (Enterprise only).
64 * @default false
65 */
66 botFightMode?: boolean;
67 /**
68 * If Turnstile is embedded on a Cloudflare site and the widget should
69 * grant challenge clearance, this setting determines the clearance level.
70 * @default "no_clearance"
71 */
72 clearanceLevel?: ClearanceLevel;
73 /**
74 * Return the Ephemeral ID in `/siteverify` responses (Enterprise only).
75 * @default false
76 */
77 ephemeralId?: boolean;
78 /**
79 * Do not show any Cloudflare branding on the widget (Enterprise only).
80 * @default false
81 */
82 offlabel?: boolean;
83};
84
85export type WidgetAttributes = {
86 /**
87 * Widget item identifier tag. This is the public sitekey embedded in HTML.
88 */
89 sitekey: string;
90 /**
91 * Secret key for this widget, used server-side with the
92 * `/turnstile/v0/siteverify` endpoint.
93 */
94 secret: Redacted.Redacted<string>;
95 /**
96 * The Cloudflare account the widget belongs to.
97 */
98 accountId: string;
99 /**
100 * Human readable widget name.
101 */
102 name: string;
103 /**
104 * Hostnames the widget is allowed to run on.
105 */
106 domains: string[];
107 /**
108 * Widget mode.
109 */
110 mode: WidgetMode;
111 /**
112 * Region where this widget can be used.
113 */
114 region: WidgetRegion;
115 /**
116 * Whether bot fight mode is enabled (Enterprise only).
117 */
118 botFightMode: boolean;
119 /**
120 * Clearance level granted on Cloudflare zones.
121 */
122 clearanceLevel: ClearanceLevel;
123 /**
124 * Whether the Ephemeral ID is returned in `/siteverify` (Enterprise only).
125 */
126 ephemeralId: boolean;
127 /**
128 * Whether Cloudflare branding is hidden (Enterprise only).
129 */
130 offlabel: boolean;
131 /**
132 * When the widget was created.
133 */
134 createdOn: string;
135 /**
136 * When the widget was last modified.
137 */
138 modifiedOn: string;
139};
140
141export type Widget = Resource<
142 TypeId,
143 WidgetProps,
144 WidgetAttributes,
145 never,
146 Providers
147>;
148
149/**
150 * A Cloudflare Turnstile widget — Cloudflare's CAPTCHA alternative.
151 *
152 * A widget is identified by its auto-assigned `sitekey` (the public key you
153 * embed in HTML) and produces a `secret` used server-side against the
154 * `/turnstile/v0/siteverify` endpoint. Name, domains, mode, and clearance
155 * settings are all mutable in place; only `region` forces a replacement.
156 * ### Creating a Widget
157 * **Example:** Managed widget
158 * ```typescript
159 * const widget = yield* Cloudflare.Turnstile.Widget("signup-form", {
160 * domains: ["example.com"],
161 * mode: "managed",
162 * });
163 * ```
164 *
165 * **Example:** Invisible widget with an explicit name
166 * ```typescript
167 * const widget = yield* Cloudflare.Turnstile.Widget("api-guard", {
168 * name: "api-guard",
169 * domains: ["example.com", "app.example.com"],
170 * mode: "invisible",
171 * });
172 * ```
173 *
174 * ### Using the keys
175 * **Example:** Embedding the sitekey and verifying tokens
176 * ```typescript
177 * // The sitekey is public — render it in your HTML:
178 * const sitekey = widget.sitekey;
179 *
180 * // The secret is redacted — pass it to your server-side verifier:
181 * const secret = widget.secret; // Redacted<string>
182 * ```
183 *
184 * @see https://developers.cloudflare.com/turnstile/
185 *
186 * @resource
187 * @product Turnstile
188 * @category Application Security
189 */
190export const Widget = Resource<Widget>(TypeId);
191
192/**
193 * Returns true if the given value is a Widget resource.
194 */
195export const isWidget = (value: unknown): value is Widget =>
196 Predicate.hasProperty(value, "Type") && value.Type === TypeId;
197
198export const WidgetProvider = () =>
199 Provider.succeed(Widget, {
200 stables: ["sitekey", "accountId", "region", "createdOn"],
201 diff: Effect.fn(function* ({ olds, news, output }) {
202 const { accountId } = yield* yield* CloudflareEnvironment;
203 if (!isResolved(news)) return undefined;
204 if ((output?.accountId ?? accountId) !== accountId) {
205 return { action: "replace" } as const;
206 }
207 // Region cannot be changed after creation.
208 const oldRegion = output?.region ?? olds?.region ?? "world";
209 if ((news.region ?? "world") !== oldRegion) {
210 return { action: "replace" } as const;
211 }
212 return undefined;
213 }),
214 read: Effect.fn(function* ({ id, output, olds }) {
215 const { accountId } = yield* yield* CloudflareEnvironment;
216 const acct = output?.accountId ?? accountId;
217
218 if (output?.sitekey) {
219 const observed = yield* getWidget(acct, output.sitekey);
220 return observed ? toAttributes(observed, acct) : undefined;
221 }
222 // Cold read — recover from lost state by matching the deterministic
223 // physical name. Names are not unique on Cloudflare's side; an exact
224 // match on our generated/explicit name is the best identity we have.
225 const name = yield* createWidgetName(id, olds?.name);
226 const match = yield* findByName(acct, name);
227 if (match) {
228 const observed = yield* getWidget(acct, match.sitekey);
229 return observed ? toAttributes(observed, acct) : undefined;
230 }
231 return undefined;
232 }),
233 reconcile: Effect.fn(function* ({ id, news, output }) {
234 const { accountId } = yield* yield* CloudflareEnvironment;
235 const name = yield* createWidgetName(id, news.name);
236
237 // Observe — the sitekey cached on `output` is a hint, not a
238 // guarantee: a 404 falls through to "missing" and we recreate.
239 const observed = output?.sitekey
240 ? yield* getWidget(output.accountId ?? accountId, output.sitekey)
241 : undefined;
242
243 if (!observed) {
244 // Ensure — greenfield (or out-of-band delete): create with the
245 // full desired body. Names are not unique so there is no
246 // AlreadyExists race to tolerate.
247 const created = yield* turnstile.createWidget({
248 accountId,
249 name,
250 domains: news.domains,
251 mode: news.mode,
252 region: news.region,
253 botFightMode: news.botFightMode,
254 clearanceLevel: news.clearanceLevel,
255 ephemeralId: news.ephemeralId,
256 offlabel: news.offlabel,
257 });
258 return toAttributes(created, accountId);
259 }
260
261 // Sync — diff observed cloud state against desired; the update API
262 // is a PUT that requires the full body, so send everything, but
263 // skip the call entirely on a no-op.
264 const desired = {
265 name,
266 domains: news.domains,
267 mode: news.mode,
268 botFightMode: news.botFightMode ?? observedBool(observed.botFightMode),
269 clearanceLevel:
270 news.clearanceLevel ?? (observed.clearanceLevel as ClearanceLevel),
271 ephemeralId: news.ephemeralId ?? observedBool(observed.ephemeralId),
272 offlabel: news.offlabel ?? observedBool(observed.offlabel),
273 };
274 const dirty =
275 observed.name !== desired.name ||
276 observed.mode !== desired.mode ||
277 !sameDomains(observed.domains, desired.domains) ||
278 (news.botFightMode !== undefined &&
279 observed.botFightMode !== news.botFightMode) ||
280 (news.clearanceLevel !== undefined &&
281 observed.clearanceLevel !== news.clearanceLevel) ||
282 (news.ephemeralId !== undefined &&
283 observed.ephemeralId !== news.ephemeralId) ||
284 (news.offlabel !== undefined && observed.offlabel !== news.offlabel);
285
286 if (!dirty) {
287 return toAttributes(observed, observed.accountId);
288 }
289
290 const updated = yield* turnstile.updateWidget({
291 accountId: observed.accountId,
292 sitekey: observed.sitekey,
293 ...desired,
294 });
295 return toAttributes(updated, observed.accountId);
296 }),
297 delete: Effect.fn(function* ({ output }) {
298 yield* turnstile
299 .deleteWidget({
300 accountId: output.accountId,
301 sitekey: output.sitekey,
302 })
303 .pipe(Effect.catchTag("WidgetNotFound", () => Effect.void));
304 }),
305 list: Effect.fn(function* () {
306 const { accountId } = yield* yield* CloudflareEnvironment;
307 // Enumerate every widget in the account, paginating exhaustively.
308 const pages = yield* turnstile.listWidgets
309 .pages({ accountId })
310 .pipe(Stream.runCollect);
311 const sitekeys = Array.from(pages).flatMap((page) =>
312 (page.result ?? []).map((w) => w.sitekey),
313 );
314 // The list payload omits the write-only `secret`, so hydrate each
315 // widget via `getWidget` to produce the exact `read` Attributes
316 // shape. A widget deleted between list and get surfaces as
317 // `WidgetNotFound` (mapped to `undefined` by `getWidget`).
318 const rows = yield* Effect.forEach(
319 sitekeys,
320 (sitekey) =>
321 getWidget(accountId, sitekey).pipe(
322 Effect.map((w) => (w ? toAttributes(w, accountId) : undefined)),
323 ),
324 { concurrency: 10 },
325 );
326 return rows.filter((row): row is WidgetAttributes => row !== undefined);
327 }),
328 });
329
330type ObservedWidget = turnstile.GetWidgetResponse & { accountId: string };
331
332/**
333 * Read a widget by sitekey, mapping "gone" (`WidgetNotFound`, Cloudflare
334 * error code 10404) to `undefined`.
335 */
336const getWidget = (accountId: string, sitekey: string) =>
337 turnstile.getWidget({ accountId, sitekey }).pipe(
338 Effect.map((w): ObservedWidget => ({ ...w, accountId })),
339 Effect.catchTag("WidgetNotFound", () => Effect.succeed(undefined)),
340 );
341
342/**
343 * Find a widget by exact name. Cloudflare's `filter` is a case-insensitive
344 * substring match, so re-check exactly client-side. If several widgets carry
345 * the same name, pick the oldest for determinism.
346 */
347const findByName = (accountId: string, name: string) =>
348 turnstile.listWidgets.items({ accountId, filter: `name:${name}` }).pipe(
349 Stream.filter((w) => w.name === name),
350 Stream.runCollect,
351 Effect.map((chunk) =>
352 Array.from(chunk)
353 .sort((a, b) => a.createdOn.localeCompare(b.createdOn))
354 .at(0),
355 ),
356 );
357
358const createWidgetName = (id: string, name: string | undefined) =>
359 Effect.gen(function* () {
360 return name ?? (yield* createPhysicalName({ id, lowercase: true }));
361 });
362
363const observedBool = (value: boolean | undefined) => value ?? false;
364
365const sameDomains = (observed: readonly string[], desired: readonly string[]) =>
366 observed.length === desired.length &&
367 [...observed].sort().join(",") === [...desired].sort().join(",");
368
369const toAttributes = (
370 widget:
371 | turnstile.GetWidgetResponse
372 | turnstile.CreateWidgetResponse
373 | turnstile.UpdateWidgetResponse,
374 accountId: string,
375): WidgetAttributes => ({
376 sitekey: widget.sitekey,
377 secret: Redacted.make(widget.secret),
378 accountId,
379 name: widget.name,
380 // Distilled widens generated string enums to open unions (`string & {}`).
381 domains: [...widget.domains],
382 mode: widget.mode as WidgetMode,
383 region: widget.region as WidgetRegion,
384 botFightMode: widget.botFightMode,
385 clearanceLevel: widget.clearanceLevel as ClearanceLevel,
386 ephemeralId: widget.ephemeralId,
387 offlabel: widget.offlabel,
388 createdOn: widget.createdOn,
389 modifiedOn: widget.modifiedOn,
390});