1import * as Cloudflare from "@/Cloudflare";
2import { CloudflareEnvironment } from "@/Cloudflare/CloudflareEnvironment";
3import { findZoneByName } from "@/Cloudflare/Zone/lookup";
4import * as Test from "@/Test/Alchemy";
5import * as web3 from "@distilled.cloud/cloudflare/web3";
6import * as zones from "@distilled.cloud/cloudflare/zones";
7import { expect } from "alchemy-test";
8import * as Effect from "effect/Effect";
9import { MinimumLogLevel } from "effect/References";
10import * as Schedule from "effect/Schedule";
11import * as Stream from "effect/Stream";
12
13const { test } = Test.make({ providers: Cloudflare.providers() });
14
15const logLevel = Effect.provideService(
16 MinimumLogLevel,
17 process.env.DEBUG ? "Debug" : "Info",
18);
19
20const zoneName =
21 process.env.CLOUDFLARE_TEST_DNS_ZONE_NAME ?? "alchemy-test-2.us";
22
23// Cloudflare has restricted Web3 gateways for new customers — on the
24// standard testing account every create fails with "Account is not entitled
25// to create ipfs hostnames." (code 1010), surfaced as the typed
26// `Web3HostnameNotEntitled` error. The full lifecycle tests below are gated
27// behind an entitled zone id supplied via env.
28const entitledZoneId = process.env.CLOUDFLARE_TEST_WEB3_ZONE_ID;
29
30const resolveZoneId = Effect.gen(function* () {
31 const { accountId } = yield* yield* CloudflareEnvironment;
32 const zone = yield* findZoneByName({ accountId, name: zoneName });
33 if (!zone) {
34 return yield* Effect.die(
35 new Error(`zone "${zoneName}" not found in account`),
36 );
37 }
38 return zone.id;
39});
40
41// A freshly minted scoped token propagates eventually-consistently across
42// Cloudflare's edge — retry the typed `Forbidden` blips on out-of-band calls.
43const forbiddenRetrySchedule = Schedule.exponential("500 millis");
44
45const listHostnames = (zoneId: string) =>
46 web3.listHostnames.items({ zoneId }).pipe(
47 Stream.runCollect,
48 Effect.map((chunk) => Array.from(chunk)),
49 Effect.retry({
50 while: (e) => e._tag === "Forbidden",
51 schedule: forbiddenRetrySchedule,
52 times: 8,
53 }),
54 );
55
56const findHostname = (zoneId: string, name: string) =>
57 listHostnames(zoneId).pipe(
58 Effect.map((hostnames) =>
59 hostnames.find((h) => h.name === name && h.status !== "deleting"),
60 ),
61 );
62
63const getHostname = (zoneId: string, hostnameId: string) =>
64 web3.getHostname({ zoneId, identifier: hostnameId }).pipe(
65 Effect.retry({
66 while: (e) => e._tag === "Forbidden",
67 schedule: forbiddenRetrySchedule,
68 times: 8,
69 }),
70 );
71
72// Purge leftovers from interrupted runs so tests start from a clean slate.
73const purgeHostname = (zoneId: string, name: string) =>
74 listHostnames(zoneId).pipe(
75 Effect.flatMap(
76 Effect.forEach((h) =>
77 h.name === name && h.id
78 ? web3
79 .deleteHostname({ zoneId, identifier: h.id })
80 .pipe(Effect.catch(() => Effect.void))
81 : Effect.void,
82 ),
83 ),
84 );
85
86// Deletion is asynchronous — poll (bounded) until no live hostname with the
87// given name remains in the zone.
88const waitUntilGone = (zoneId: string, name: string) =>
89 findHostname(zoneId, name).pipe(
90 Effect.repeat({
91 schedule: Schedule.spaced("2 seconds"),
92 until: (h) => h === undefined,
93 times: 10,
94 }),
95 );
96
97test.provider(
98 "surfaces the typed Web3HostnameNotEntitled error on unentitled accounts",
99 (stack) =>
100 Effect.gen(function* () {
101 const zoneId = yield* resolveZoneId;
102
103 yield* stack.destroy();
104
105 // The testing account lacks the Web3 gateway entitlement — the
106 // distilled call must fail with the typed entitlement tag.
107 const error = yield* web3
108 .createHostname({
109 zoneId,
110 name: `alchemy-web3-entitlement.${zoneName}`,
111 target: "ipfs",
112 dnslink: "/ipns/onboarding.ipfs.cloudflare.com",
113 })
114 .pipe(
115 Effect.retry({
116 while: (e) => e._tag === "Forbidden",
117 schedule: forbiddenRetrySchedule,
118 times: 8,
119 }),
120 Effect.flip,
121 );
122 expect(error._tag).toEqual("Web3HostnameNotEntitled");
123
124 // The list endpoint is available regardless of entitlement.
125 const hostnames = yield* listHostnames(zoneId);
126 expect(Array.isArray(hostnames)).toBe(true);
127
128 yield* stack.destroy();
129 }).pipe(logLevel),
130 {
131 tags: [
132 "provider:cloudflare",
133 "provider:cloudflare:web3",
134 "provider:cloudflare:zone",
135 "live",
136 ],
137 },
138);
139
140test.provider.skipIf(!entitledZoneId)(
141 "create, update in place, replace on target change, destroy",
142 (stack) =>
143 Effect.gen(function* () {
144 const zoneId = entitledZoneId!;
145 const zone = yield* zones.getZone({ zoneId });
146 const name = `alchemy-web3-hostname.${zone.name}`;
147
148 yield* stack.destroy();
149 yield* purgeHostname(zoneId, name);
150
151 // Create an IPFS hostname pinned to a DNSLink.
152 const created = yield* stack.deploy(
153 Effect.gen(function* () {
154 return yield* Cloudflare.Web3.Hostname("Gateway", {
155 zoneId,
156 name,
157 target: "ipfs",
158 dnslink: "/ipns/onboarding.ipfs.cloudflare.com",
159 description: "v1",
160 });
161 }),
162 );
163
164 expect(created.hostnameId).toBeDefined();
165 expect(created.zoneId).toEqual(zoneId);
166 expect(created.name).toEqual(name);
167 expect(created.target).toEqual("ipfs");
168 expect(created.dnslink).toEqual("/ipns/onboarding.ipfs.cloudflare.com");
169 expect(created.description).toEqual("v1");
170 expect(["active", "pending"]).toContain(created.status);
171
172 // Out-of-band verification via the distilled API.
173 const live = yield* getHostname(zoneId, created.hostnameId);
174 expect(live.name).toEqual(name);
175 expect(live.target).toEqual("ipfs");
176
177 // Update mutable fields in place — same physical hostname.
178 const updated = yield* stack.deploy(
179 Effect.gen(function* () {
180 return yield* Cloudflare.Web3.Hostname("Gateway", {
181 zoneId,
182 name,
183 target: "ipfs",
184 dnslink: "/ipns/onboarding.ipfs.cloudflare.com",
185 description: "v2",
186 });
187 }),
188 );
189 expect(updated.hostnameId).toEqual(created.hostnameId);
190 expect(updated.description).toEqual("v2");
191
192 const patched = yield* getHostname(zoneId, updated.hostnameId);
193 expect(patched.description).toEqual("v2");
194
195 // Changing the target replaces the hostname.
196 const replaced = yield* stack.deploy(
197 Effect.gen(function* () {
198 return yield* Cloudflare.Web3.Hostname("Gateway", {
199 zoneId,
200 name,
201 target: "ethereum",
202 });
203 }),
204 );
205 expect(replaced.hostnameId).not.toEqual(created.hostnameId);
206 expect(replaced.target).toEqual("ethereum");
207
208 yield* stack.destroy();
209
210 // Deletion is asynchronous — wait (bounded) until the hostname is
211 // gone from the zone.
212 const gone = yield* waitUntilGone(zoneId, name);
213 expect(gone).toBeUndefined();
214 }).pipe(logLevel),
215 {
216 tags: [
217 "provider:cloudflare",
218 "provider:cloudflare:web3",
219 "provider:cloudflare:zone",
220 "live",
221 ],
222 timeout: 120_000,
223 },
224);
225
226test.provider.skipIf(!entitledZoneId)(
227 "content list — set entries, update declaratively, reset on destroy",
228 (stack) =>
229 Effect.gen(function* () {
230 const zoneId = entitledZoneId!;
231 const zone = yield* zones.getZone({ zoneId });
232 const name = `alchemy-web3-universal.${zone.name}`;
233 const cid = "QmXoypizjW3WknFiJnKLwHCnL72vedxjQkDDP1mXWo6uco";
234
235 yield* stack.destroy();
236 yield* purgeHostname(zoneId, name);
237
238 const program = (entries: Cloudflare.Web3.ContentListEntry[]) =>
239 Effect.gen(function* () {
240 const gateway = yield* Cloudflare.Web3.Hostname("Universal", {
241 zoneId,
242 name,
243 target: "ipfs_universal_path",
244 });
245 const list = yield* Cloudflare.Web3.HostnameContentList("Blocklist", {
246 zoneId,
247 hostnameId: gateway.hostnameId,
248 entries,
249 });
250 return { gateway, list };
251 });
252
253 // Create the universal-path gateway plus a one-entry blocklist.
254 const created = yield* stack.deploy(
255 program([{ type: "cid", content: cid, description: "blocked" }]),
256 );
257 expect(created.list.action).toEqual("block");
258 expect(created.list.entries).toHaveLength(1);
259 expect(created.list.entries[0]?.content).toEqual(cid);
260
261 // Out-of-band verification via the distilled API.
262 const liveEntries = yield* web3
263 .listHostnameIpfsUniversalPathContentListEntries({
264 zoneId,
265 identifier: created.gateway.hostnameId,
266 })
267 .pipe(
268 Effect.retry({
269 while: (e) => e._tag === "Forbidden",
270 schedule: forbiddenRetrySchedule,
271 times: 8,
272 }),
273 );
274 expect(liveEntries.entries ?? []).toHaveLength(1);
275 expect(liveEntries.entries?.[0]?.content).toEqual(cid);
276
277 // Declarative update — the bulk PUT replaces the whole list.
278 const updated = yield* stack.deploy(
279 program([{ type: "content_path", content: `/ipfs/${cid}/wiki` }]),
280 );
281 expect(updated.list.entries).toHaveLength(1);
282 expect(updated.list.entries[0]?.type).toEqual("content_path");
283
284 yield* stack.destroy();
285
286 const gone = yield* waitUntilGone(zoneId, name);
287 expect(gone).toBeUndefined();
288 }).pipe(logLevel),
289 {
290 tags: [
291 "provider:cloudflare",
292 "provider:cloudflare:web3",
293 "provider:cloudflare:zone",
294 "live",
295 ],
296 timeout: 120_000,
297 },
298);