diff --git a/cloudflare/README.md b/cloudflare/README.md index b135f9d..6356ae2 100644 --- a/cloudflare/README.md +++ b/cloudflare/README.md @@ -14,15 +14,27 @@ GET https://fxfiles.top/w//page.html -> 302 https://.ipfs.dweb.l An optional `?gw=` selects which gateway the redirect lands on: ``` -GET /w/?gw=dweb -> 302 https://.ipfs.dweb.link/ -GET /w/?gw=filebase -> 302 https://ipfs.filebase.io/ipfs/ +GET /w/?gw=filebase -> 302 https://ipfs.filebase.io/ipfs/ (default) +GET /w/?gw=fx -> 302 https://ipfs.cloud.fx.land/gateway/ ``` -It exists because **dweb.link returns HTTP 429 once a site sees real traffic** -(measured 2026-09-12: a freshly generated site 429'd on dweb.link and returned -200 from Filebase at the same moment). The app appends this automatically from -the gateway chosen in Settings, so a user who switches gets working links -without re-minting anything. +The app appends this automatically from the gateway chosen in Settings, so a +user who switches gets working links without re-minting anything. + +### dweb.link is retired — do not re-add it + +The IPFS Foundation **shut dweb.link down permanently on 2026-09-21** +(gatewaychanges.ipfs.io). The HTTP 429s seen beforehand, with a `Retry-After` of +around half an hour, were its announced escalating pauses — not load. + +`dweb` is therefore absent from `GATEWAYS`, and that is deliberate in a way +worth spelling out: links minted while dweb was the default carry an **explicit** +`?gw=dweb`, and an explicit key normally beats the default. But that "choice" +was manufactured by the default rather than made by anyone, so honouring it +would send those links to a dead host. Dropping the key makes them fall back to +the default instead. The app makes the matching move — `IpfsGatewayHelper` +lists the dweb template in `retiredTemplates`, which migrates any user still +holding it onto the current default at startup. `GATEWAYS` in the Worker is a **fixed allowlist**, and that is load-bearing: this is a link anyone can share, so accepting a caller-supplied destination @@ -93,9 +105,14 @@ If you deploy to a different host, set the secure-storage key - Redirect is **302** (never 301) with `Cache-Control: max-age=30`, so a regeneration propagates within ~30s while still allowing edge caching. -- The `dweb` gateway assumes CIDv1 (a CIDv0 `Qm…` cannot go in a subdomain — - hostnames are case-insensitive and base58 is not). `filebase` is path-style - and has no such constraint. The app mints CIDv1, so this is not a live issue. +- Both current gateways are **path-style**, so CID encoding is a non-issue. A + subdomain-style gateway would need the guard back: a CIDv0 (`Qm…`, base58 and + case-sensitive) or a CID over the 63-character DNS label limit silently + corrupts when used as a hostname, but is fine in a path. - The Worker rejects paths whose name isn't a plausible `k51…` IPNS name, and charset-checks the CID before interpolating it, so it can't be abused as an open redirector. +- When w3name is unreachable the Worker returns a plain **502**. It used to + redirect to `{name}.ipns.dweb.link`, which never resolved (w3name does not + publish to the DHT) and is now a dead host — redirecting there only turned our + error into a more confusing one. diff --git a/cloudflare/ipns-resolver-worker.js b/cloudflare/ipns-resolver-worker.js index add0e89..7368ffd 100644 --- a/cloudflare/ipns-resolver-worker.js +++ b/cloudflare/ipns-resolver-worker.js @@ -2,11 +2,11 @@ * FxFiles stable-link resolver — a STATELESS Cloudflare Worker that is the * fast, pretty front door over each website group's IPNS name. * - * GET https://fxfiles.top/w/[/][?gw=dweb|filebase] + * GET https://fxfiles.top/w/[/][?gw=filebase|fx] * -> resolve to its current CID via w3name's plain HTTP API * -> 302 to that gateway's URL for the CID, e.g. - * https://.ipfs.dweb.link/ (gw=dweb, default) - * https://ipfs.filebase.io/ipfs// (gw=filebase) + * https://ipfs.filebase.io/ipfs// (gw=filebase, default) + * https://ipfs.cloud.fx.land/gateway// (gw=fx) * * Why this design: * - The app never talks to Cloudflare and holds NO credential here. The IPNS @@ -14,15 +14,15 @@ * record and redirects. Anyone can redeploy it; losing it loses nothing. * - Resolving through w3name's HTTP API is fast (no DHT wait) and lands on the * immutable per-CID URL, which gateways cache aggressively. - * - If w3name is slow/unavailable, we fall back to the raw IPNS gateway URL. - * NOTE (measured 2026-05-30): that fallback only resolves if the record is - * ALSO published to the IPFS DHT — w3name does NOT do that, so today a bare - * {name}.ipns.dweb.link does NOT resolve on a plain gateway (it 500s). So - * name->CID resolution currently depends on this Worker reading w3name (both - * non-fx). The CONTENT (CID) IS fully public-reachable via IPFS gateways - * (verified 200). Net: the link survives fx being down, but not Cloudflare + - * w3name both being down. See README for the optional DHT-publish step that - * makes any gateway work. + * - When w3name is unreachable we return a plain 502 rather than redirecting + * anywhere. There used to be a fallback to {name}.ipns.dweb.link; it never + * actually resolved (w3name does not publish to the DHT, so a bare name 500s + * on a plain gateway — measured 2026-05-30) and that host is switched off for + * good on 2026-09-21 anyway. So name->CID resolution depends on this Worker + * reading w3name, both non-fx. The CONTENT (CID) stays fully public-reachable + * via any IPFS gateway. Net: a link survives fx being down, but not + * Cloudflare + w3name both being down. See README for the optional + * DHT-publish step that would make any gateway resolve the name directly. * * Abuse posture (it MUST stay publicly reachable so links + previews work): * - Not an open redirector: the destination host comes from a FIXED allowlist @@ -37,12 +37,11 @@ * (only active if `RW_LIMITER` is configured in wrangler.toml). Pair with a * dashboard WAF Rate Limiting Rule on `/w/*` for global enforcement. * - * Cloudflare's own IPFS gateway was decommissioned in Aug 2024 — irrelevant - * here; this Worker `fetch()`es the IPFS Foundation gateways (dweb.link/ipfs.io). + * Gateway churn is the norm, which is why the destination is a one-line change + * here rather than a property of published content: Cloudflare retired its IPFS + * gateway in Aug 2024, and the IPFS Foundation retires dweb.link on 2026-09-21. */ -const GATEWAY_HOST = 'ipfs.dweb.link'; -const IPNS_GATEWAY_HOST = 'ipns.dweb.link'; const W3NAME_ENDPOINT = 'https://name.web3.storage'; const REDIRECT_CACHE_SECONDS = 30; // keep short so regenerations propagate fast const MAX_NAME_LEN = 80; // base36 `k51…` libp2p-key names are ~62 chars @@ -52,20 +51,6 @@ const CID_RE = /^[A-Za-z0-9]{40,120}$/; // eslint-disable-next-line no-control-regex const CONTROL_CHARS = /[\u0000-\u001f\u007f]/; -/** - * Can this CID be a DNS label, i.e. is the subdomain gateway shape usable? - * - * Two ways it cannot, both of which silently corrupt the CID rather than - * failing loudly: - * - CIDv0 (`Qm…`) is base58 and CASE-SENSITIVE, but hostnames are not — the - * resolver lowercases the label and the gateway receives a different CID. - * - A DNS label caps at 63 characters (RFC 1035). CIDv1-base32 over SHA-256 - * is 59, but a larger hash function would overrun it. - * Either way the answer is the same: use the gateway's path form, which - * preserves case and has no length limit. - */ -const SUBDOMAIN_SAFE_CID = /^[a-z0-9]{1,63}$/; - /** * Gateways this Worker may redirect to, selected with `?gw=`. * @@ -75,23 +60,28 @@ const SUBDOMAIN_SAFE_CID = /^[a-z0-9]{1,63}$/; * open redirector, which is exactly the property the checks below exist to * protect. Unknown or missing `gw` falls back to the default. * - * `cid` builds the immutable per-CID URL in whichever shape the gateway wants: - * subdomain — https://.ipfs.dweb.link/ - * path — https://ipfs.filebase.io/ipfs// + * `dweb` IS DELIBERATELY ABSENT. The IPFS Foundation switched dweb.link off for + * good on 2026-09-21. Links minted while it was the default carry an explicit + * `?gw=dweb`, and an explicit key would normally beat the default — but that + * "choice" was manufactured by the default rather than made by anyone, so + * honouring it would send those links to a dead host. Dropping the key makes + * them fall back here instead, which is the whole point. + * + * Both remaining gateways are PATH-style (`https://host/ipfs//`), so + * there is no subdomain-safety problem to handle. A subdomain gateway would need + * that guard back: a case-sensitive CIDv0 (`Qm…`) or a CID over the 63-character + * DNS label limit silently corrupts as a hostname, but is fine in a path. */ const GATEWAYS = { - dweb: { - cid: (cid, path) => - SUBDOMAIN_SAFE_CID.test(cid) - ? `https://${cid}.${GATEWAY_HOST}${path}` - : `https://dweb.link/ipfs/${cid}${path}`, - }, filebase: { - // dweb.link starts returning 429 once a site sees real traffic; Filebase - // served the same CID fine at the same moment (measured 2026-09-12). - // Path-style, so it needs no subdomain-safety dance. + // Served the same CID fine at the moment dweb.link was 429ing it + // (measured 2026-09-12). cid: (cid, path) => `https://ipfs.filebase.io/ipfs/${cid}${path}`, }, + fx: { + // Ours. Verified 2026-09-12 to serve these CIDs with correct content types. + cid: (cid, path) => `https://ipfs.cloud.fx.land/gateway/${cid}${path}`, + }, }; /** @@ -111,7 +101,7 @@ function joinPath(inner, subpath) { } /** Default when `?gw=` is absent — keeps every already-shared link working. */ -const DEFAULT_GATEWAY = 'dweb'; +const DEFAULT_GATEWAY = 'filebase'; export default { async fetch(request, env) { @@ -167,26 +157,24 @@ export default { forwarded.delete('gw'); const query = forwarded.toString() ? `?${forwarded}` : ''; - // Unknown keys fall back rather than erroring: a link with a typo should - // still resolve, just on the default gateway. + // Unknown keys fall back rather than erroring: a link with a typo — or a + // retired key like `gw=dweb` — should still resolve, just on the default. // // hasOwn, NOT a bare `GATEWAYS[gwKey] ||` — gwKey is caller-controlled and // a plain object literal inherits from Object.prototype, so `?gw=toString` // and `?gw=__proto__` would hand back a TRUTHY inherited value whose `.cid` - // is undefined. That throws inside the try below and drops the request on - // the IPNS fallback, which (see the note at the top) does not resolve. The - // typo would break the link instead of quietly using the default. + // is undefined. That throws inside the try below and the request ends as a + // 502, so the typo would break the link instead of using the default. const gateway = Object.hasOwn(GATEWAYS, gwKey ?? '') ? GATEWAYS[gwKey] : GATEWAYS[DEFAULT_GATEWAY]; - // The happy path below never uses an IPNS gateway: w3name resolves the - // name here and we redirect to the immutable /ipfs/, which every - // gateway serves. This fallback only runs when w3name is unreachable, and - // per the note at the top it does not resolve anyway (the record is not on - // the DHT). Left on dweb because it is the only host that would even try. - const ipnsFallback = - `https://${name}.${IPNS_GATEWAY_HOST}${subpath}${query}`; + // NOTE: there is no IPNS-gateway fallback any more. It pointed at + // ipns.dweb.link, which (a) never resolved anyway — w3name does not publish + // to the DHT, so a bare name 500s on a plain gateway — and (b) is being + // switched off entirely on 2026-09-21. Redirecting a visitor to a host that + // is guaranteed to fail just turns our error into someone else's confusing + // one, so when w3name is unreachable we now say so plainly instead. try { const res = await fetch(`${W3NAME_ENDPOINT}/name/${name}`, { @@ -219,10 +207,24 @@ export default { } } } catch (_) { - // fall through to the IPNS gateway fallback + // fall through to the plain error below } - return redirect(ipnsFallback); + // Reached when w3name is unreachable, returns a non-OK, or hands back a + // value that is not a usable `/ipfs/`. 502, because the failure is + // upstream of us and the visitor's link is probably fine. + return new Response( + 'Could not resolve this FxFiles link right now. The IPNS name service ' + + 'is unreachable — please try again shortly.', + { + status: 502, + headers: { + 'content-type': 'text/plain; charset=utf-8', + 'Cache-Control': 'no-store', + 'Retry-After': '30', + }, + }, + ); }, }; diff --git a/cloudflare/ipns-resolver-worker.test.mjs b/cloudflare/ipns-resolver-worker.test.mjs index 2dfbcdd..88e0445 100644 Binary files a/cloudflare/ipns-resolver-worker.test.mjs and b/cloudflare/ipns-resolver-worker.test.mjs differ diff --git a/lib/core/services/ipfs_gateway_helper.dart b/lib/core/services/ipfs_gateway_helper.dart index f3cf7d4..60cd2d9 100644 --- a/lib/core/services/ipfs_gateway_helper.dart +++ b/lib/core/services/ipfs_gateway_helper.dart @@ -12,25 +12,41 @@ import 'package:fula_files/core/services/secure_storage_service.dart'; class IpfsGatewayHelper { IpfsGatewayHelper._(); - /// Subdomain-style dweb.link template, used as the app-wide default. - static const String defaultTemplate = 'https://{cid}.ipfs.dweb.link/'; + /// Subdomain-style dweb.link template. The app-wide default until + /// 2026-09-12, now RETIRED: the IPFS Foundation is shutting this gateway + /// down for good on 2026-09-21 (gatewaychanges.ipfs.io), and the HTTP 429s + /// seen beforehand are its announced escalating pauses, not load. Kept as a + /// constant ONLY so [init] can recognise and migrate anyone still on it. + static const String dwebTemplate = 'https://{cid}.ipfs.dweb.link/'; + + /// Path-style Filebase gateway, and the app-wide default since dweb's + /// retirement — measured 2026-09-12, a site that returned 429 from dweb.link + /// returned 200 from Filebase for the same CID at the same moment. + static const String filebaseTemplate = 'https://ipfs.filebase.io/ipfs/'; - /// Pre-v0.4 default. Anyone still on this exact value is upgraded to - /// [defaultTemplate] on the next [init]. - static const String legacyDefault = 'https://ipfs.cloud.fx.land/gateway/'; + /// fx's own gateway. This was the pre-v0.4 default, and [init] used to + /// migrate people AWAY from it and onto dweb — that migration is gone, + /// because the destination is now the thing that is dying. It is offered as + /// a first-class preset again: it serves these CIDs with correct content + /// types (verified 2026-09-12) and, unlike any third party, it is ours. + static const String fxTemplate = 'https://ipfs.cloud.fx.land/gateway/'; - /// Path-style Filebase gateway. Offered as a preset because dweb.link - /// rate-limits (HTTP 429) once a site gets any real traffic — measured - /// 2026-09-12, a freshly generated site returned 429 from dweb.link and - /// 200 from Filebase at the same moment. - static const String filebaseTemplate = 'https://ipfs.filebase.io/ipfs/'; + static const String defaultTemplate = filebaseTemplate; + + /// Templates that are dead or dying. A stored value matching one of these is + /// replaced with [defaultTemplate] on the next [init] — deliberately + /// overriding what looks like a user's choice, because for most people the + /// "choice" was just the old default, and leaving it would hand them a + /// broken site. Match-and-replace is idempotent, so no migration flag. + static const Set retiredTemplates = {dwebTemplate}; /// The presets the settings picker offers, in display order. Anything /// else the user types is "Custom" — [buildUrl] accepts any template in - /// either of the two supported shapes. + /// either of the two supported shapes. dweb is deliberately ABSENT: offering + /// a gateway that [init] would migrate away from on next launch is a trap. static const Map presets = { - 'dweb.link': defaultTemplate, 'Filebase': filebaseTemplate, + 'fx.land': fxTemplate, }; /// Preset label for [template], or null when it is a custom value. @@ -51,8 +67,8 @@ class IpfsGatewayHelper { /// asset URLs written into the site (client-side, no such risk), while the /// stable link falls back to the resolver's default. static const Map _frontDoorKeys = { - defaultTemplate: 'dweb', filebaseTemplate: 'filebase', + fxTemplate: 'fx', }; static String? frontDoorGatewayKey([String? template]) => @@ -67,12 +83,18 @@ class IpfsGatewayHelper { /// exact staleness that made the setting look inert for asset URLs. /// /// A preset emits its key even when it matches the resolver's own default, - /// rather than leaving the link bare. Omitting it would read as "no - /// opinion", and the resolver is then free to send the link somewhere else - /// if its default ever moves — but a user who picked dweb.link in Settings - /// HAS an opinion, and it should survive that. Bare links stay reserved for - /// callers that genuinely have none (custom gateways, which the resolver - /// cannot honour anyway). + /// rather than leaving the link bare, so that an explicit choice survives a + /// later change of that default. Bare links stay reserved for callers that + /// genuinely have no opinion (custom gateways, which the resolver cannot + /// honour anyway). + /// + /// CAVEAT, learned the hard way when dweb.link was retired: freezing the + /// gateway into a copied link cuts both ways. Every link copied while dweb + /// was the default carries `?gw=dweb`, and that key had to be dropped from + /// the resolver's allowlist so those links would fall back instead of + /// pointing at a dead host. Decorating is right while the set of live + /// gateways is stable; once a per-site preference exists, prefer bare links + /// so they keep following the owner's current choice. static String decorateFrontDoorUrl(String frontDoorUrl, {String? template}) { final key = frontDoorGatewayKey(template); if (key == null || frontDoorUrl.isEmpty) return frontDoorUrl; @@ -87,21 +109,37 @@ class IpfsGatewayHelper { static String get cachedTemplate => _cachedTemplate; /// Run after [SecureStorageService.init] and before any consumer reads - /// the gateway. Performs the one-time replacement of the legacy default - /// — match-and-replace is naturally idempotent, so no migration flag. + /// the gateway. + /// + /// Note this WRITES on first run, which is why retiring a default is not + /// just a matter of changing the constant: every user who has ever launched + /// the app has the then-current default persisted, so a new [defaultTemplate] + /// would reach new installs only. [retiredTemplates] is what actually moves + /// existing users off a dead gateway. static Future init() async { final stored = await SecureStorageService.instance .read(SecureStorageKeys.ipfsGatewayUrl); - if (stored == null || stored.isEmpty || stored == legacyDefault) { + final resolved = resolveStoredTemplate(stored); + if (resolved != stored) { await SecureStorageService.instance.write( SecureStorageKeys.ipfsGatewayUrl, - defaultTemplate, + resolved, ); - _cachedTemplate = defaultTemplate; - } else { - _cachedTemplate = stored; } + _cachedTemplate = resolved; + } + + /// The template [init] should end up with, given what is currently stored. + /// + /// Split out as a pure function so the migration is testable without a + /// storage backend — it is the part that decides whether a user keeps + /// working after a gateway is retired, which is worth covering directly. + static String resolveStoredTemplate(String? stored) { + if (stored == null || stored.trim().isEmpty) return defaultTemplate; + final trimmed = stored.trim(); + if (retiredTemplates.contains(trimmed)) return defaultTemplate; + return trimmed; } /// Refresh the in-memory cache after the user saves a new value in diff --git a/lib/main.dart b/lib/main.dart index 53f23db..ee0f3a2 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -185,8 +185,8 @@ Future _initializeApp() async { debugPrint('SecureStorageService initialization failed: $e'); } - // Migrate the legacy ipfs.cloud.fx.land gateway default to dweb.link and - // populate the sync cache that model getters/widgets read. + // Migrate anyone still on a retired gateway (dweb.link, switched off + // 2026-09-21) and populate the sync cache that model getters/widgets read. try { await IpfsGatewayHelper.init().timeout(const Duration(seconds: 3)); } catch (e) { diff --git a/lib/web/screens/web_api_config_screen.dart b/lib/web/screens/web_api_config_screen.dart index bc55b44..ae510ea 100644 --- a/lib/web/screens/web_api_config_screen.dart +++ b/lib/web/screens/web_api_config_screen.dart @@ -48,7 +48,7 @@ class _WebApiConfigScreenState extends State { 'https://ai.cloud.fx.land', 'https://ai.cloud.fx.land'), // NOTE: the IPFS gateway template is deliberately NOT here. It moved to // its own section in Settings, below Billing — it is a choice ordinary - // users make (dweb.link rate-limits, Filebase does not), not an endpoint + // users make (gateways get retired — dweb.link was), not an endpoint // override, and it needs a picker rather than a raw text field. Editing // it in two places would let this one store a near-miss of a preset that // silently degrades to "custom". diff --git a/lib/web/screens/web_settings_screen.dart b/lib/web/screens/web_settings_screen.dart index f6058ac..8b3a8d5 100644 --- a/lib/web/screens/web_settings_screen.dart +++ b/lib/web/screens/web_settings_screen.dart @@ -265,8 +265,8 @@ class _WebSettingsScreenState extends State { // Promoted OUT of More → API Configuration to the top level: this is the one // endpoint setting with a user-visible consequence — it decides which gateway // serves the images in a generated website and where a shared link resolves. - // dweb.link rate-limits (429) once a site gets traffic, so people need to - // reach this without hunting through an advanced editor. + // Gateways come and go — dweb.link was switched off entirely on 2026-09-21 — + // so people need to reach this without hunting through an advanced editor. // // This is now the ONLY editor for the key — the raw text field was removed // from More → API Configuration rather than left alongside, so a near-miss diff --git a/test/unit/core/services/ipfs_gateway_helper_test.dart b/test/unit/core/services/ipfs_gateway_helper_test.dart index 74df665..d7fc0f8 100644 --- a/test/unit/core/services/ipfs_gateway_helper_test.dart +++ b/test/unit/core/services/ipfs_gateway_helper_test.dart @@ -13,13 +13,22 @@ void main() { group('buildUrl', () { const cid = 'bafybeifx7yeb55armcsxwwitkymga5xf53dxiarykms3ygqic223w5sk3m'; + // The subdomain SHAPE is still supported for custom templates even though + // no preset uses it any more, so it stays covered. test('substitutes {cid} for subdomain-style templates', () { expect( - IpfsGatewayHelper.buildUrl(IpfsGatewayHelper.defaultTemplate, cid), + IpfsGatewayHelper.buildUrl(IpfsGatewayHelper.dwebTemplate, cid), 'https://$cid.ipfs.dweb.link/', ); }); + test('the default template is the path-style Filebase one', () { + expect( + IpfsGatewayHelper.buildUrl(IpfsGatewayHelper.defaultTemplate, cid), + 'https://ipfs.filebase.io/ipfs/$cid', + ); + }); + test('appends the cid for path-style templates', () { expect( IpfsGatewayHelper.buildUrl(IpfsGatewayHelper.filebaseTemplate, cid), @@ -55,14 +64,67 @@ void main() { }); }); - group('presetLabelFor', () { - test('names the two presets and nothing else', () { - expect(IpfsGatewayHelper.presetLabelFor(IpfsGatewayHelper.defaultTemplate), - 'dweb.link'); + // dweb.link is switched off for good on 2026-09-21, and `init` WRITES the + // default into storage on first run — so every existing user has the old + // default persisted and changing the constant alone would reach new installs + // only. This group covers the bit that actually moves people off it. + group('retirement migration', () { + test('the default is no longer dweb', () { + expect(IpfsGatewayHelper.defaultTemplate, + isNot(IpfsGatewayHelper.dwebTemplate)); + expect(IpfsGatewayHelper.defaultTemplate, + IpfsGatewayHelper.filebaseTemplate); + }); + + test('a stored dweb template is migrated to the default', () { expect( - IpfsGatewayHelper.presetLabelFor(IpfsGatewayHelper.filebaseTemplate), + IpfsGatewayHelper.resolveStoredTemplate(IpfsGatewayHelper.dwebTemplate), + IpfsGatewayHelper.defaultTemplate, + ); + }); + + test('nothing else is disturbed', () { + for (final keep in [ + IpfsGatewayHelper.filebaseTemplate, + IpfsGatewayHelper.fxTemplate, + 'https://my-host/ipfs/', + ]) { + expect(IpfsGatewayHelper.resolveStoredTemplate(keep), keep); + } + }); + + test('absent or blank falls back to the default', () { + expect(IpfsGatewayHelper.resolveStoredTemplate(null), + IpfsGatewayHelper.defaultTemplate); + expect(IpfsGatewayHelper.resolveStoredTemplate(''), + IpfsGatewayHelper.defaultTemplate); + expect(IpfsGatewayHelper.resolveStoredTemplate(' '), + IpfsGatewayHelper.defaultTemplate); + }); + + test('is idempotent — re-running never churns the value', () { + final once = + IpfsGatewayHelper.resolveStoredTemplate(IpfsGatewayHelper.dwebTemplate); + expect(IpfsGatewayHelper.resolveStoredTemplate(once), once); + }); + + test('no retired template is offered as a preset', () { + for (final template in IpfsGatewayHelper.presets.values) { + expect(IpfsGatewayHelper.retiredTemplates, isNot(contains(template)), + reason: 'offering a gateway that init() migrates away is a trap'); + } + }); + }); + + group('presetLabelFor', () { + test('names the presets and nothing else', () { + expect(IpfsGatewayHelper.presetLabelFor(IpfsGatewayHelper.filebaseTemplate), 'Filebase'); + expect(IpfsGatewayHelper.presetLabelFor(IpfsGatewayHelper.fxTemplate), + 'fx.land'); expect(IpfsGatewayHelper.presetLabelFor('https://my-host/ipfs/'), isNull); + expect(IpfsGatewayHelper.presetLabelFor(IpfsGatewayHelper.dwebTemplate), + isNull); }); test('tolerates surrounding whitespace', () { @@ -82,10 +144,16 @@ void main() { group('frontDoorGatewayKey', () { test('maps the presets to the resolver keys', () { - expect(IpfsGatewayHelper.frontDoorGatewayKey( - IpfsGatewayHelper.defaultTemplate), 'dweb'); expect(IpfsGatewayHelper.frontDoorGatewayKey( IpfsGatewayHelper.filebaseTemplate), 'filebase'); + expect(IpfsGatewayHelper.frontDoorGatewayKey( + IpfsGatewayHelper.fxTemplate), 'fx'); + }); + + test('the retired dweb template has no key', () { + expect( + IpfsGatewayHelper.frontDoorGatewayKey(IpfsGatewayHelper.dwebTemplate), + isNull); }); test('a custom gateway has no key — the resolver allowlists, by design', @@ -102,7 +170,7 @@ void main() { // The worker's allowlist is the other half of this contract: a key here // that it does not know would silently fall back to its default. test('only ever emits keys the worker allowlists', () { - const workerKeys = {'dweb', 'filebase'}; + const workerKeys = {'filebase', 'fx'}; for (final template in IpfsGatewayHelper.presets.values) { expect(workerKeys, contains( IpfsGatewayHelper.frontDoorGatewayKey(template))); @@ -118,9 +186,9 @@ void main() { expect(IpfsGatewayHelper.decorateFrontDoorUrl(link), '$link?gw=filebase'); }); - test('appends the dweb key explicitly, so the link is self-describing', + test('appends the default key explicitly, so the link is self-describing', () { - expect(IpfsGatewayHelper.decorateFrontDoorUrl(link), '$link?gw=dweb'); + expect(IpfsGatewayHelper.decorateFrontDoorUrl(link), '$link?gw=filebase'); }); test('uses & when the link already carries a query', () {