1import * as web3 from "@distilled.cloud/cloudflare/web3";
2import * as Effect from "effect/Effect";
3import * as Predicate from "effect/Predicate";
4import * as Stream from "effect/Stream";
5
6import * as Provider from "../../Provider.ts";
7import { Resource } from "../../Resource.ts";
8import { CloudflareEnvironment } from "../CloudflareEnvironment.ts";
9import type { Providers } from "../Providers.ts";
10import { listAllZones } from "../Zone/lookup.ts";
11
12const TypeId = "Cloudflare.Web3.HostnameContentList" as const;
13type TypeId = typeof TypeId;
14
15/**
16 * The kind of content a content-list entry blocks: a CID (`cid`) or a
17 * content path (`content_path`).
18 */
19export type ContentListEntryType = "cid" | "content_path";
20
21/**
22 * A single entry of an IPFS universal-path content list.
23 */
24export interface ContentListEntry {
25 /**
26 * The CID or content path of content to block, e.g.
27 * `QmXoypizjW3WknFiJnKLwHCnL72vedxjQkDDP1mXWo6uco` (`cid`) or
28 * `/ipfs/QmXoypizjW3WknFiJnKLwHCnL72vedxjQkDDP1mXWo6uco/wiki` (`content_path`).
29 */
30 content: string;
31 /**
32 * The type of content list entry to block.
33 */
34 type: ContentListEntryType;
35 /**
36 * An optional description of the content list entry.
37 */
38 description?: string;
39}
40
41export interface HostnameContentListProps {
42 /**
43 * The zone the hostname belongs to.
44 *
45 * Stable — moving to another zone triggers a replacement.
46 */
47 zoneId: string;
48 /**
49 * The Web3 hostname the content list belongs to. Must be an
50 * `ipfs_universal_path` hostname — other targets reject content-list
51 * operations.
52 *
53 * Stable — pointing at another hostname triggers a replacement.
54 */
55 hostnameId: string;
56 /**
57 * Behavior of the content list. Cloudflare currently only supports
58 * `block`.
59 * @default "block"
60 */
61 action?: "block";
62 /**
63 * The full desired set of content list entries. The list is replaced
64 * declaratively on every change (bulk PUT) — entries not present here
65 * are removed.
66 * @default []
67 */
68 entries?: ContentListEntry[];
69}
70
71export interface HostnameContentListAttributes {
72 /** The zone the hostname belongs to. */
73 zoneId: string;
74 /** The Web3 hostname the content list belongs to. */
75 hostnameId: string;
76 /** Behavior of the content list. */
77 action: "block";
78 /** The current content list entries. */
79 entries: ContentListEntry[];
80}
81
82export type HostnameContentList = Resource<
83 TypeId,
84 HostnameContentListProps,
85 HostnameContentListAttributes,
86 never,
87 Providers
88>;
89
90/**
91 * The IPFS content-blocking list of a Web3 universal-path gateway hostname —
92 * blocks specific CIDs or content paths from being served.
93 *
94 * The content list is a per-hostname singleton: it always exists for an
95 * `ipfs_universal_path` hostname (empty by default), so this resource never
96 * creates or deletes a physical object. Reconciliation replaces the whole
97 * list declaratively via the bulk PUT, and destroy resets the list to empty.
98 * ### Blocking content
99 * **Example:** Block a CID and a content path
100 * ```typescript
101 * const gateway = yield* Cloudflare.Web3.Hostname("UniversalGateway", {
102 * zoneId: zone.zoneId,
103 * name: "gateway.example.com",
104 * target: "ipfs_universal_path",
105 * });
106 *
107 * yield* Cloudflare.Web3.HostnameContentList("Blocklist", {
108 * zoneId: zone.zoneId,
109 * hostnameId: gateway.hostnameId,
110 * entries: [
111 * {
112 * type: "cid",
113 * content: "QmXoypizjW3WknFiJnKLwHCnL72vedxjQkDDP1mXWo6uco",
114 * description: "blocked CID",
115 * },
116 * {
117 * type: "content_path",
118 * content: "/ipfs/QmXoypizjW3WknFiJnKLwHCnL72vedxjQkDDP1mXWo6uco/wiki",
119 * },
120 * ],
121 * });
122 * ```
123 *
124 * **Example:** Clear the blocklist
125 * ```typescript
126 * // An empty entries array removes every block (also what destroy does).
127 * yield* Cloudflare.Web3.HostnameContentList("Blocklist", {
128 * zoneId: zone.zoneId,
129 * hostnameId: gateway.hostnameId,
130 * entries: [],
131 * });
132 * ```
133 *
134 * @see https://developers.cloudflare.com/web3/
135 *
136 * @resource
137 * @product Web3
138 * @category Domains & DNS
139 */
140export const HostnameContentList = Resource<HostnameContentList>(TypeId, {
141 aliases: ["Cloudflare.Web3HostnameContentList"],
142});
143
144/**
145 * Returns true if the given value is a HostnameContentList resource.
146 */
147export const isHostnameContentList = (
148 value: unknown,
149): value is HostnameContentList =>
150 Predicate.hasProperty(value, "Type") && value.Type === TypeId;
151
152export const HostnameContentListProvider = () =>
153 Provider.succeed(HostnameContentList, {
154 stables: ["zoneId", "hostnameId"],
155
156 list: Effect.fn(function* () {
157 const { accountId } = yield* yield* CloudflareEnvironment;
158 // The content list is a per-hostname singleton keyed by
159 // {zoneId, hostnameId}. Enumerate every zone, list its Web3
160 // hostnames, keep only universal-path gateways (other targets
161 // reject content-list ops), and read each one's list.
162 const zones = yield* listAllZones(accountId);
163 const rows = yield* Effect.forEach(
164 zones,
165 (zone) =>
166 web3.listHostnames.items({ zoneId: zone.id }).pipe(
167 Stream.runCollect,
168 Effect.map((chunk) =>
169 Array.from(chunk).filter(
170 (h): h is typeof h & { id: string } =>
171 h.id != null && h.target === "ipfs_universal_path",
172 ),
173 ),
174 Effect.flatMap((hostnames) =>
175 Effect.forEach(
176 hostnames,
177 (h) =>
178 observeContentList(zone.id, h.id).pipe(
179 Effect.catchTag("Forbidden", () =>
180 Effect.succeed(undefined),
181 ),
182 ),
183 { concurrency: 10 },
184 ),
185 ),
186 // Web3 is entitlement-gated and freshly minted tokens can
187 // briefly 403 — skip zones we can't enumerate.
188 Effect.catchTag("Forbidden", () => Effect.succeed([])),
189 ),
190 { concurrency: 10 },
191 );
192 return rows
193 .flat()
194 .filter(
195 (row): row is HostnameContentListAttributes => row !== undefined,
196 );
197 }),
198
199 diff: Effect.fn(function* ({ olds = {}, news, output }) {
200 const o = olds as Partial<HostnameContentListProps>;
201 const n = news as HostnameContentListProps;
202 // zoneId/hostnameId are Input<string>; compare only once both
203 // sides are concrete. The content list is a sub-singleton of the
204 // hostname — pointing elsewhere replaces it.
205 const oldZone = output?.zoneId ?? o.zoneId;
206 if (
207 typeof oldZone === "string" &&
208 typeof n.zoneId === "string" &&
209 oldZone !== n.zoneId
210 ) {
211 return { action: "replace" } as const;
212 }
213 const oldHostname = output?.hostnameId ?? o.hostnameId;
214 if (
215 typeof oldHostname === "string" &&
216 typeof n.hostnameId === "string" &&
217 oldHostname !== n.hostnameId
218 ) {
219 return { action: "replace" } as const;
220 }
221 return undefined;
222 }),
223
224 read: Effect.fn(function* ({ output, olds }) {
225 const zoneId =
226 output?.zoneId ??
227 (typeof olds?.zoneId === "string" ? olds.zoneId : undefined);
228 const hostnameId =
229 output?.hostnameId ??
230 (typeof olds?.hostnameId === "string" ? olds.hostnameId : undefined);
231 if (zoneId === undefined || hostnameId === undefined) return undefined;
232 // Settings-singleton semantics: the list always exists for a
233 // universal-path hostname (empty by default), so it is never
234 // `Unowned`. A deleted hostname reads as gone.
235 return yield* observeContentList(zoneId, hostnameId);
236 }),
237
238 reconcile: Effect.fn(function* ({ news }) {
239 // Inputs have been resolved to concrete strings by Plan.
240 const zoneId = news.zoneId as string;
241 const hostnameId = news.hostnameId as string;
242 const desired: HostnameContentListAttributes = {
243 zoneId,
244 hostnameId,
245 action: news.action ?? "block",
246 entries: news.entries ?? [],
247 };
248
249 // Observe — read the live list; the singleton always exists for a
250 // universal-path hostname, so "missing" only means the hostname
251 // itself is gone (which is a real failure for reconcile).
252 const observed = yield* observeContentList(zoneId, hostnameId);
253
254 // Sync — the bulk PUT replaces action + entries declaratively;
255 // skip the API call entirely on a no-op.
256 if (observed && sameContentList(observed, desired)) {
257 return observed;
258 }
259 yield* web3.putHostnameIpfsUniversalPathContentList({
260 zoneId,
261 identifier: hostnameId,
262 action: desired.action,
263 entries: desired.entries,
264 });
265 return desired;
266 }),
267
268 delete: Effect.fn(function* ({ output }) {
269 // There is no DELETE endpoint — destroying the resource resets the
270 // singleton to its empty default. If the hostname itself is already
271 // gone (or no longer a universal-path gateway), there is nothing
272 // left to reset.
273 yield* web3
274 .putHostnameIpfsUniversalPathContentList({
275 zoneId: output.zoneId,
276 identifier: output.hostnameId,
277 action: "block",
278 entries: [],
279 })
280 .pipe(
281 Effect.catchTag(
282 ["Web3HostnameNotFound", "InvalidWeb3HostnameTarget"],
283 () => Effect.void,
284 ),
285 );
286 }),
287 });
288
289/**
290 * Read the content list (action) plus its entries, mapping a gone hostname
291 * (`Web3HostnameNotFound`, code 1002) or a hostname that is not a
292 * universal-path gateway (`InvalidWeb3HostnameTarget`, code 1006) to
293 * `undefined`.
294 */
295const observeContentList = (zoneId: string, hostnameId: string) =>
296 Effect.gen(function* () {
297 const list = yield* web3.getHostnameIpfsUniversalPathContentList({
298 zoneId,
299 identifier: hostnameId,
300 });
301 const entries = yield* web3.listHostnameIpfsUniversalPathContentListEntries(
302 { zoneId, identifier: hostnameId },
303 );
304 return {
305 zoneId,
306 hostnameId,
307 action: (list.action ?? "block") as "block",
308 entries: (entries.entries ?? []).map((entry): ContentListEntry => ({
309 content: entry.content ?? "",
310 type: (entry.type ?? "cid") as ContentListEntryType,
311 ...(entry.description != null
312 ? { description: entry.description }
313 : {}),
314 })),
315 } satisfies HostnameContentListAttributes;
316 }).pipe(
317 Effect.catchTag(["Web3HostnameNotFound", "InvalidWeb3HostnameTarget"], () =>
318 Effect.succeed(undefined),
319 ),
320 );
321
322/**
323 * Order-insensitive comparison of two content lists by (content, type,
324 * description).
325 */
326const sameContentList = (
327 observed: HostnameContentListAttributes,
328 desired: HostnameContentListAttributes,
329) =>
330 observed.action === desired.action &&
331 observed.entries.length === desired.entries.length &&
332 serializeEntries(observed.entries) === serializeEntries(desired.entries);
333
334const serializeEntries = (entries: ContentListEntry[]) =>
335 entries
336 .map((entry) => `${entry.type}${entry.content}${entry.description ?? ""}`)
337 .sort()
338 .join("");