| 1 | import * as Cloudflare from "@/Cloudflare"; |
| 2 | import { CloudflareEnvironment } from "@/Cloudflare/CloudflareEnvironment"; |
| 3 | import { findZoneByName } from "@/Cloudflare/Zone/lookup"; |
| 4 | import * as Test from "@/Test/Alchemy"; |
| 5 | import * as web3 from "@distilled.cloud/cloudflare/web3"; |
| 6 | import * as zones from "@distilled.cloud/cloudflare/zones"; |
| 7 | import { expect } from "alchemy-test"; |
| 8 | import * as Effect from "effect/Effect"; |
| 9 | import { MinimumLogLevel } from "effect/References"; |
| 10 | import * as Schedule from "effect/Schedule"; |
| 11 | import * as Stream from "effect/Stream"; |
| 12 | |
| 13 | const { test } = Test.make({ providers: Cloudflare.providers() }); |
| 14 | |
| 15 | const logLevel = Effect.provideService( |
| 16 | MinimumLogLevel, |
| 17 | process.env.DEBUG ? "Debug" : "Info", |
| 18 | ); |
| 19 | |
| 20 | const 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. |
| 28 | const entitledZoneId = process.env.CLOUDFLARE_TEST_WEB3_ZONE_ID; |
| 29 | |
| 30 | const 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. |
| 43 | const forbiddenRetrySchedule = Schedule.exponential("500 millis"); |
| 44 | |
| 45 | const 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 | |
| 56 | const 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 | |
| 63 | const 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. |
| 73 | const 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. |
| 88 | const 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 | |
| 97 | test.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 | |
| 140 | test.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 | |
| 226 | test.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 | ); |