diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 9bc70ac1..88af9bbd 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -80,6 +80,15 @@ jobs:
accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
preCommands: ../db/deploy-${{ matrix.environment }}.sh
environment: ${{ matrix.environment }}
+ - name: Deploy IPFS Retriever and Migrate Database
+ if: matrix.environment == 'calibration'
+ uses: cloudflare/wrangler-action@v4
+ with:
+ workingDirectory: ipfs-retriever
+ apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
+ accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
+ preCommands: ../db/deploy-${{ matrix.environment }}.sh
+ environment: ${{ matrix.environment }}
- name: Deploy Indexer
uses: cloudflare/wrangler-action@v4
with:
diff --git a/docs/data-layer.md b/docs/data-layer.md
new file mode 100644
index 00000000..5e5c8708
--- /dev/null
+++ b/docs/data-layer.md
@@ -0,0 +1,236 @@
+# FilBeam Data Layer
+
+All workers share a **single Cloudflare D1 database** (SQLite). Migrations live in `db/migrations/`.
+
+- **Tests**: migrations are applied automatically via `wrangler d1 migrations apply test-db --local` (see root `package.json` test script).
+- **Deploy**: worker deploys (`npm run deploy:calibration/mainnet`) do **not** apply migrations automatically. Run `db/deploy-calibration.sh` / `db/deploy-mainnet.sh` separately before deploying workers when migrations are pending.
+
+---
+
+## Tables
+
+| Table | Description |
+| ------------------------ | ------------------------------------------------------------------------------------------------------------------------- |
+| `service_providers` | SP registry: service URL and deletion status, keyed by on-chain provider ID |
+| `data_sets` | CDN deals: links an SP to a payer, tracks CDN/IPFS flags, egress usage, usage reporting watermarks, and termination state |
+| `pieces` | Pieces per data set: piece CID, IPFS root CID (from chain metadata), and deletion flag |
+| `data_set_egress_quotas` | Remaining byte budgets for CDN delivery and cache-miss charges; only exists for data sets that have been topped up |
+| `wallet_details` | Payer addresses with sanction status and last Chainalysis screen timestamp |
+| `retrieval_logs` | Per-request audit log: egress bytes, cache hit/miss, performance timings, country code, and bot flag |
+
+---
+
+## ER Diagram
+
+```mermaid
+erDiagram
+ service_providers {
+ TEXT id PK
+ TEXT service_url
+ INTEGER block_number
+ BOOLEAN is_deleted
+ }
+ data_sets {
+ TEXT id PK
+ TEXT service_provider_id
+ TEXT payer_address
+ BOOLEAN with_cdn
+ BOOLEAN with_ipfs_indexing
+ INTEGER total_egress_bytes_used
+ TIMESTAMP usage_reported_until
+ TEXT pending_usage_report_tx_hash
+ TIMESTAMP cdn_payments_settled_until
+ TEXT terminate_service_tx_hash
+ TIMESTAMP lockup_unlocks_at
+ }
+ pieces {
+ TEXT id PK
+ TEXT data_set_id PK
+ TEXT cid
+ TEXT ipfs_root_cid
+ TEXT x402_price
+ BOOLEAN is_deleted
+ }
+ data_set_egress_quotas {
+ TEXT data_set_id PK
+ INTEGER cdn_egress_quota
+ INTEGER cache_miss_egress_quota
+ }
+ wallet_details {
+ TEXT address PK
+ BOOLEAN is_sanctioned
+ TIMESTAMP last_screened_at
+ }
+ retrieval_logs {
+ INTEGER id PK
+ DATETIME timestamp
+ TEXT data_set_id
+ INTEGER response_status
+ INTEGER egress_bytes
+ INTEGER cache_miss_egress_bytes
+ BOOLEAN cache_miss
+ BOOLEAN cache_miss_response_valid
+ INTEGER fetch_ttfb
+ INTEGER fetch_ttlb
+ INTEGER worker_ttfb
+ TEXT request_country_code
+ TEXT bot_name
+ }
+
+ service_providers ||--o{ data_sets : "hosts"
+ data_sets ||--o{ pieces : "contains"
+ data_sets ||--o| data_set_egress_quotas : "has quota"
+ wallet_details ||--o{ data_sets : "pays for"
+ data_sets ||--o{ retrieval_logs : "logs"
+```
+
+> Relationships are logical — no foreign-key constraints are declared in the schema.
+
+---
+
+## How Tables Are Populated
+
+### `service_providers`
+
+Written by **indexer** in response to `ServiceProviderRegistry` on-chain events:
+
+| Event | Handler | Effect |
+| ------------------------------------ | -------------------------------------- | --------------------------------------------------------------------------------------------------------- |
+| `ProductAdded` / `ProductUpdated` | `handleProductAdded/Updated` | Upserts `id`, `service_url`, `block_number`; skips if stored `block_number` is newer (out-of-order guard) |
+| `ProductRemoved` / `ProviderRemoved` | `handleProductRemoved/ProviderRemoved` | Sets `is_deleted = true` |
+
+### `data_sets`
+
+Written by **indexer** in response to `FWSS` and `FilBeamOperator` events:
+
+| Event | Handler | Effect |
+| ------------------------- | ----------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| `DataSetCreated` | `handleFWSSDataSetCreated` | Upserts `id`, `service_provider_id`, `payer_address`, `with_cdn`, `with_ipfs_indexing`; also creates/updates `wallet_details` with sanction screen result **only when `withCDN` is set** |
+| `ServiceTerminated` | `handleFWSSServiceTerminated` | Sets `with_cdn = false`, calculates and sets `lockup_unlocks_at` |
+| `CDNPaymentRailsToppedUp` | `handleFWSSCDNPaymentRailsToppedUp` | Increments `data_set_egress_quotas` (idempotent via KV event dedup) |
+| `CDNPaymentSettled` | `handleCdnPaymentSettled` | Advances `cdn_payments_settled_until` to the block timestamp |
+
+Written by **usage-reporter** after confirmed on-chain usage report:
+
+- Sets `usage_reported_until` watermark and clears `pending_usage_report_tx_hash`
+
+Written by **terminator** after confirmed termination transaction:
+
+- Sets `terminate_service_tx_hash`
+
+### `pieces`
+
+Written by **indexer** in response to `PDPVerifier` on-chain events:
+
+| Event | Handler | Effect |
+| ------------------------- | --------------------- | --------------------------------------------------------------------------------------------------------------- |
+| `PieceAdded` / `addPiece` | `insertDataSetPiece` | Upserts `id`, `data_set_id`, `cid`, `ipfs_root_cid`, `x402_price`; `ipfs_root_cid` comes from on-chain metadata |
+| `PieceRemoved` | `removeDataSetPieces` | Sets `is_deleted = true` (batch, up to 50 per D1 statement) |
+
+### `data_set_egress_quotas`
+
+Written by **indexer** (`handleFWSSCDNPaymentRailsToppedUp`): converts top-up amounts to byte quotas using configured rates and increments both `cdn_egress_quota` and `cache_miss_egress_quota`.
+
+Decremented by **piece-retriever** / **ipfs-retriever** after each successful retrieval (only when `ENFORCE_EGRESS_QUOTA` is enabled): `cdn_egress_quota` is charged for all egress bytes served to the client; `cache_miss_egress_quota` is charged only on valid cache misses.
+
+### `wallet_details`
+
+Created/updated by **indexer** on `DataSetCreated`, but **only when `withCDN` is true** (Chainalysis API call per new payer).
+
+Re-screened periodically by **indexer** scheduled task (`screenWallets`): re-screens wallets not checked within the configured stale threshold, ordered oldest-first.
+
+### `retrieval_logs`
+
+Written by **piece-retriever** and **ipfs-retriever** after every request via `recordRetrieval` in `retrieval/lib/stats.js`. Written inside `ctx.waitUntil` so it does not block the response.
+
+---
+
+## Key Queries by Worker
+
+### Retrieval candidate lookup (piece-retriever, ipfs-retriever)
+
+The shared query in `retrieval/lib/access.js` (`buildRetrievalCandidateQuery`) JOINs five tables in one shot:
+
+```sql
+SELECT pieces.data_set_id, data_sets.service_provider_id, data_sets.payer_address,
+ data_sets.with_cdn, data_set_egress_quotas.cdn_egress_quota,
+ data_set_egress_quotas.cache_miss_egress_quota,
+ service_providers.service_url, service_providers.is_deleted AS service_provider_is_deleted,
+ wallet_details.is_sanctioned
+FROM pieces
+LEFT OUTER JOIN data_sets ON pieces.data_set_id = data_sets.id
+LEFT OUTER JOIN data_set_egress_quotas ON pieces.data_set_id = data_set_egress_quotas.data_set_id
+LEFT OUTER JOIN service_providers ON data_sets.service_provider_id = service_providers.id
+LEFT OUTER JOIN wallet_details ON data_sets.payer_address = wallet_details.address
+WHERE pieces.cid = ? -- piece-retriever
+-- or: pieces.ipfs_root_cid = ? -- ipfs-retriever
+ AND pieces.is_deleted IS FALSE
+```
+
+The result rows are then filtered by `filterAuthorizedRetrievalCandidates` (authorization cascade) — see `retrieval/lib/access.js`.
+
+### Slug resolution (ipfs-retriever, slug flow)
+
+Two sequential queries in `ipfs-retriever/lib/store.js`:
+
+1. Resolve `(pieceId, dataSetId)` → `ipfs_root_cid` + `payer_address`:
+
+```sql
+SELECT pieces.ipfs_root_cid, data_sets.payer_address
+FROM pieces LEFT OUTER JOIN data_sets ON pieces.data_set_id = data_sets.id
+WHERE pieces.id = ? AND pieces.data_set_id = ?
+```
+
+2. Then the retrieval candidate query above, keyed by `ipfs_root_cid`.
+
+### Usage aggregation (usage-reporter)
+
+Aggregates `retrieval_logs` per data set between the `usage_reported_until` watermark and a target timestamp, excluding bot traffic and data sets with a pending transaction:
+
+```sql
+SELECT rl.data_set_id,
+ SUM(rl.egress_bytes) AS cdn_bytes,
+ SUM(CASE WHEN rl.cache_miss = 1 AND rl.cache_miss_response_valid = 1
+ THEN COALESCE(rl.cache_miss_egress_bytes, rl.egress_bytes) ELSE 0 END) AS cache_miss_bytes
+FROM retrieval_logs rl
+INNER JOIN data_sets ds ON rl.data_set_id = ds.id
+WHERE rl.timestamp > datetime(ds.usage_reported_until)
+ AND rl.timestamp <= datetime(?)
+ AND rl.egress_bytes IS NOT NULL
+ AND rl.bot_name IS NULL
+ AND ds.pending_usage_report_tx_hash IS NULL
+GROUP BY rl.data_set_id
+HAVING (cdn_bytes > 0 OR cache_miss_bytes > 0)
+```
+
+### Terminator
+
+Finds active CDN data sets whose payer is sanctioned and have no pending termination:
+
+```sql
+SELECT DISTINCT data_sets.id
+FROM data_sets
+LEFT JOIN wallet_details ON data_sets.payer_address = wallet_details.address
+WHERE data_sets.with_cdn = 1
+ AND wallet_details.is_sanctioned = 1
+ AND data_sets.terminate_service_tx_hash IS NULL
+```
+
+### Payment settler
+
+Finds data sets that need CDN payment rail settlement (active, recently reporting, not sanctioned):
+
+```sql
+SELECT data_sets.id
+FROM data_sets
+LEFT JOIN wallet_details ON data_sets.payer_address = wallet_details.address
+WHERE (data_sets.with_cdn = 1 OR data_sets.lockup_unlocks_at >= datetime('now'))
+ AND data_sets.terminate_service_tx_hash IS NULL
+ AND data_sets.usage_reported_until >= datetime('now', '-30 days')
+ AND (wallet_details.is_sanctioned IS NULL OR wallet_details.is_sanctioned = 0)
+```
+
+### Stats API
+
+- **Per data set** (`stats-api`): reads `data_set_egress_quotas` by `data_set_id`
+- **Per payer** (`stats-api`): aggregates `data_set_egress_quotas` and `retrieval_logs` joined through `data_sets`, grouped by `payer_address`
diff --git a/docs/ipfs-retriever.md b/docs/ipfs-retriever.md
new file mode 100644
index 00000000..61c5e4f5
--- /dev/null
+++ b/docs/ipfs-retriever.md
@@ -0,0 +1,306 @@
+# ipfs-retriever
+
+## Overview
+
+`ipfs-retriever` is a Cloudflare Worker that serves IPFS content stored by Filecoin service providers (SPs). It receives a request identifying a specific dataset and piece, fetches the corresponding CAR archive from an SP, validates every block, and streams the raw file bytes to the client.
+
+It depends on the shared `@filbeam/retrieval` library for authorization, candidate selection, egress quota tracking, and the fetch lifecycle.
+
+---
+
+## URL formats
+
+There are two entry points:
+
+### Slug subdomain (primary)
+
+```
+https://1-{base32(dataSetId)}-{base32(pieceId)}.ipfs.calibration.filbeam.io/{subpath}
+```
+
+The slug encodes both the on-chain `dataSetId` and `pieceId` as base32 bigints, prefixed with a version (`1`). Parsed in `ipfs-retriever/lib/request.js`.
+
+Examples:
+
+- `https://1-abc123-def456.ipfs.calibration.filbeam.io/` — root of the piece
+- `https://1-abc123-def456.ipfs.calibration.filbeam.io/path/to/file.jpg` — specific file
+
+#### Why the IPFS CID is not in the slug
+
+The slug does not include the `ipfsRootCid`. The CID is looked up from D1 at request time: when an SP registers a piece on-chain via `addPiece`, it attaches `ipfsRootCID` as a metadata key. The indexer picks this up from the Goldsky webhook event and stores it in the `pieces` table keyed by `(dataSetId, pieceId)`. At retrieval time, the worker decodes the slug to get `dataSetId + pieceId`, queries D1 for the associated `ipfsRootCid`, and uses that CID to both find the right SP and construct the fetch URL. Encoding the CID in the slug itself would push the DNS label past the 63-character limit, and is unnecessary since the indexer already has it.
+
+ref.: [FIlBeam URL Format Doc](https://space-meridian.github.io/docs/Engineering/fefc0d538c414c8e96a56587e4ca75ce/FilBeam/FilBeam%20URL%20format%2027ecdd5cccdb806caeeaefad80cbf64d.html#27ecdd5c-ccdb-800e-ac46-de8a4a64f0a8)
+
+#### Why CID + wallet address can't both be in the subdomain
+
+The initial design proposed combining the IPFS CID and wallet address into a single subdomain component (e.g. `bafk123-0xabc.filbeam.io`). This was ruled out because a single DNS label is limited to 63 characters, too short to fit both a base58 CID and a 42-character Ethereum address together. (see [follow-up comment, issue #297](https://github.com/filbeam/worker/issues/297#issuecomment-3352714474))
+
+#### Why the wallet address is not in the slug at all
+
+The wallet address is not needed in the slug because `dataSetId` alone is sufficient to look it up in D1. Omitting it keeps the subdomain short enough to be valid. The worker resolves the wallet from the dataset record at query time. (see [review comment, PR #312](https://github.com/filbeam/worker/pull/312#issuecomment-4797500253))
+
+#### Why identifiers are in the subdomain
+
+Static websites served over IPFS often load sub-resources at absolute paths (e.g. ``). For these paths to resolve correctly, the dataset/piece identity must live in the subdomain, not the URL path, so that `/style.css` naturally maps to the right SP origin without any path rewriting. (see [original proposal comment, issue #297](https://github.com/filbeam/worker/issues/297#issuecomment-3346046091))
+
+### Bare domain redirect (convenience)
+
+```
+https://ipfs.calibration.filbeam.io/{walletAddress}/{ipfsRootCid}/{subpath}
+```
+
+Handled by `handleDnsRootRequest` in `ipfs-retriever/bin/ipfs-retriever.js`. Validates that `walletAddress` has an authorized deal for `ipfsRootCid` (running the full authorization cascade: payment rail, CDN flag, sanctions check), looks up the corresponding `dataSetId + pieceId` in D1, builds the slug, and issues a 302 redirect to the slug subdomain URL. If the wallet is not associated with that CID, the request is rejected with 402 before any redirect occurs. This validation is based entirely on indexed on-chain metadata — FilBeam does not verify that the `ipfsRootCid` actually corresponds to the piece data stored by the SP. Useful for constructing shareable links without knowing the on-chain IDs upfront.
+
+If the request is to the bare domain with no wallet or CID (i.e. `https://ipfs.calibration.filbeam.io/`), redirects to `https://filbeam.com`.
+
+### Query parameters
+
+- `?format=car` — serve the raw CAR archive to the client without conversion or block validation. The worker proxies the SP response as-is. Per the [IPFS Trustless Gateway spec](https://specs.ipfs.tech/http-gateways/trustless-gateway/), CAR is a client-validated transport, the caller is responsible for verifying block integrity.
+- `?format=raw` — not implemented (returns 400). The trustless gateway `raw` format returns only the single terminal block for a CID, not the full file content, which makes it useful for leaf-node lookups but complex for multi-block files or directories. According to @rvagg we should defer this until a concrete use case arises; tracked in [issue #295](https://github.com/filbeam/worker/issues/295)
+- No `?format` — default; converts CAR to raw file bytes. The worker validates every block before streaming. Each block's bytes are hashed and compared to its CID multihash via `validateBlock()`. The client receives only bytes that have passed verification.
+
+---
+
+## Request flow
+
+There are two entry points — the bare domain redirect and the slug flow — both handled by the same worker.
+
+```mermaid
+ sequenceDiagram
+ participant C as Client
+ participant W as ipfs-retriever
+ participant D1 as D1
+ participant KV as Bad Bits KV
+ participant CF as CF Edge Cache
+ participant SP as Service Provider
+
+ C->>W: GET /wallet/cid or slug.ipfs...filbeam.io/path
+
+ W->>W: handleFetchRequest (lifecycle, bot auth)
+
+ alt Bare domain redirect
+ W->>D1: getRetrievalCandidatesByWalletAndCid(wallet, cid)
+ D1-->>W: dataSetId + pieceId
+ W-->>C: 302 → slug subdomain
+ else Slug request
+ W->>W: parseRequest — decode slug → dataSetId + pieceId + subpath
+ W->>D1: Query 1 — resolve (dataSetId + pieceId)
+ D1-->>W: ipfsRootCid, payerAddress
+ W->>D1: Query 2 — find all SPs for ipfsRootCid (auth cascade)
+ D1-->>W: candidates[]
+ W->>KV: assertCidNotDenied(ipfsRootCid)
+ KV-->>W: OK or 410
+
+ loop Retry across candidates (random order)
+ W->>CF: fetch {spUrl}/ipfs/{cid}{subpath}?format=car
+ alt Cache HIT
+ CF-->>W: CAR stream
+ else Cache MISS
+ CF->>SP: GET /ipfs/{cid}{subpath}?format=car
+ SP-->>CF: CAR stream
+ CF-->>W: CAR stream
+ end
+ end
+
+ alt format=car
+ W-->>C: CAR stream (no validation)
+ else default
+ W->>W: CarBlockIterator + validateBlock + unixfs-exporter
+ W-->>C: Raw file bytes
+ end
+ Note over W,C: Cache-Control: public, max-age=31536000
+ end
+```
+
+### Step-by-step
+
+**① `handleFetchRequest` (shared lifecycle)**
+
+Sets up per-request context, registers an abort listener, rejects non-GET/HEAD with 405, redirects legacy `*.filcdn.io` domains to `*.filbeam.io` with 301, and runs `checkBotAuthorization` to validate the `Authorization` header against `BOT_TOKENS`. All errors thrown from here on are caught and converted to HTTP responses via `handleError`.
+
+**② Route detection**
+
+If the hostname matches the bare `DNS_ROOT` (e.g. `ipfs.calibration.filbeam.io`), the request is handled by `handleDnsRootRequest`: extracts `walletAddress` and `ipfsRootCid` from the URL path, runs the full authorization cascade to verify the wallet has a deal for that CID, looks up `dataSetId + pieceId` from D1, and issues a **302 redirect** to the slug subdomain. No content is served from this path.
+
+For slug requests, `parseRequest` strips the `DNS_ROOT` suffix from the hostname, splits the slug into `[version, encodedDataSetId, encodedPieceId]`, decodes each with `base32ToBigInt`, and extracts `ipfsSubpath` from `url.pathname` and `ipfsFormat` from `?format=`.
+
+**③ `getRetrievalCandidatesByDataSetAndPiece`**
+
+Two D1 queries:
+
+1. Resolve `(dataSetId + pieceId)` → `ipfsRootCid + payerAddress`. Throws 404 if the piece doesn't exist, has no payer, or has no `ipfsRootCid`.
+2. Query all rows matching `pieces.ipfs_root_cid = ?` joined across `data_sets`, `service_providers`, `data_set_egress_quotas`, and `wallet_details`. Runs the authorization cascade over the results:
+
+| Check | Error if all rows fail |
+| ----------------------------------- | ---------------------------- |
+| Any rows at all | 404 — not indexed |
+| SP exists and is not deleted | 404 — no SP |
+| `payer_address` matches wallet | 402 — no deal for this payer |
+| `with_cdn = 1` | 402 — CDN disabled |
+| `is_sanctioned` is false | 403 — payer is sanctioned |
+| `service_url` is set | 404 — SP not approved |
+| `with_ipfs_indexing = 1` | 402 — IPFS indexing disabled |
+| `ipfs_root_cid` is set | 404 — no CID on piece |
+| (if `enforceEgressQuota`) quota > 0 | 402 — quota exhausted |
+
+Returns one candidate per authorized SP: `{ serviceUrl, serviceProviderId, dataSetId, pieceId, ipfsRootCid }`. Multiple candidates exist when the same dataset is served by more than one SP — the worker retries across them if one fails.
+
+**④ `assertCidNotDenied`**
+
+Checks `ipfsRootCid` against the Bad Bits denylist stored in KV. Returns 410 if blocked.
+
+**⑤ `selectRetrievalCandidate`**
+
+Shuffles candidates randomly (no fixed priority) and tries them one by one. A candidate is skipped if its retrieval throws or returns a 5xx. If all candidates fail, logs the failure and returns 502 listing all attempted SPs.
+
+**⑥ `retrieveIpfsContent`**
+
+Fetches `{serviceUrl}/ipfs/{ipfsRootCid}{subpath}?format=car` with Cloudflare cache options (`cacheEverything: true`, TTL 86400 for 2xx, 0 for 4xx/5xx). Reads `CF-Cache-Status`: anything other than `HIT` is a cache miss and drives egress quota billing.
+
+**⑦ `processIpfsResponse`**
+
+- **`?format=car`**: body passed through as-is, no validation (client's responsibility per the Trustless Gateway spec).
+- **Default**: wraps the body in a counting generator (`countingBody`) to track SP egress bytes, then runs the full streaming pipeline — `CarBlockIterator`, per-block multihash check, `validateBlock`, and `ipfs-unixfs-exporter` traversal. A directory entry returns 404. A file or raw entry's `entry.content()` is converted to a `ReadableStream` and returned.
+
+**⑧ `serveRetrievalOutcome`**
+
+Pipes the response body through a `TransformStream` that counts `egressBytes` chunk by chunk, preserving backpressure — the SP is pulled only as fast as the client reads. Once the stream ends, `ctx.waitUntil` runs `recordRetrieval` to log the result to D1. Sets `Cache-Control: public, max-age=31536000` and `X-Data-Set-ID` on the response.
+
+---
+
+## CAR streaming pipeline
+
+The core of the worker is a fully lazy, end-to-end streaming pipeline. Nothing is buffered in memory. The only allocation at any point is one block at a time.
+
+```
+SP (frisbii/Curio)
+ └─ CAR stream (HTTP response body, streaming)
+ └─ countingBody — async generator counting SP egress bytes
+ └─ CarBlockIterator.fromIterable() — parses CAR header upfront, yields blocks lazily
+ └─ blockstore.get(cid) — called by the exporter per block
+ ├─ blocksReader.next() — pulls next block from the CAR stream
+ ├─ multihash comparison — verifies the block CID matches what the exporter asked for
+ └─ validateBlock() — hashes the bytes, confirms they match the multihash
+ └─ ipfs-unixfs-exporter (recursive())
+ └─ entry.content() — yields leaf block bytes
+ └─ ReadableStream → HTTP response to client
+```
+
+### Why `blockReadConcurrency: 1`
+
+The exporter is called with `{ blockReadConcurrency: 1 }`. This forces it to request blocks strictly one at a time in DFS traversal order. The blockstore's `get(cid)` does not look blocks up by CID, it calls `blocksReader.next()` and expects that the next block in the CAR is always the one being requested. This works because the SP guarantees DFS-ordered delivery (see [SP integration contract](#sp-integration-contract)). If `blockReadConcurrency` were greater than 1, the exporter would request blocks in parallel and the sequential CAR reader would return the wrong block for each.
+
+### `CarBlockIterator` vs `CarReader`
+
+`CarBlockIterator.fromIterable` reads only the CAR header (roots + version) upfront. Block data is pulled lazily as the iterator is consumed.
+
+### CAR-to-raw conversion
+
+`processIpfsResponse` uses the `recursive` export from `ipfs-unixfs-exporter` aliased as `exporter`. Despite the name, it is used here only to resolve the first entry (the requested path) and stream its bytes, the loop exits after the first iteration intentionally (`// eslint-disable-next-line no-unreachable-loop`). The actual `exporter()` function would be semantically cleaner for this use case.
+
+When converting CAR to raw for non-directories, the worker strips the upstream `content-type` and `x-content-type-options` headers and sets `content-disposition: inline`. The browser receives raw bytes and is responsible for inferring the content type. There are two cases:
+
+- **No subpath** (e.g. `/{ipfsRootCid}/`) — the root CID resolves directly to a file or raw block with no filename. The browser has only magic-byte sniffing to determine the content type. Modern browsers handle this well for common formats (images, video, HTML) but it may fail for less common types.
+- **With subpath** (e.g. `/{ipfsRootCid}/path/to/file.jpg`) — The terminal path segment carries a filename and extension. Modern browsers can use the extension, together with magic-byte sniffing, to infer the content type.
+ Currently, the worker does not set the Content-Type header based on the file extension, leaving that responsibility to the browser. Should we instead set a reasonable Content-Type ourselves? We should verify whether relying on the browser is sufficient for modern websites, including JavaScript, CSS, HTML, images, videos, and other assets. Is there any reason this approach could be risky or lead to compatibility or security issues?
+
+### Directory entries
+
+If the resolved path is a UnixFS directory, the worker returns **404 Not Found** (`retrieval.js:195`). Directory listing is not implemented. Since the SP returns a path-scoped CAR with `dag-scope=all`, the directory block and immediate child blocks are present in the CAR. The 404 is a choice, not an architectural limit. This is tracked in [issue #696](https://github.com/filbeam/worker/issues/696).
+
+**Potential improvement:** @rvagg suggested switching to `dag-scope=entity` on the SP fetch URL. For file paths, `entity` and `all` return identical blocks — the complete file DAG. For directory paths, `entity` returns only the directory block itself rather than all descendants recursively, which avoids fetching child blocks we immediately discard when returning 404. If directory listing is ever implemented (issue #696), `entity` would also be the right scope since listing only needs the directory block, not the full subtree.
+
+---
+
+## SP integration contract
+
+The pipeline makes specific assumptions about how the SP delivers the CAR. These are satisfied by **Curio**, which implements the [frisbii](https://github.com/ipld/frisbii) trustless HTTP gateway:
+
+1. **Path-scoped CAR** — the SP is called as `GET /ipfs/{rootCid}{subpath}?format=car`. It returns a CAR containing exactly the blocks needed to walk from `rootCid` to `subpath` and read the file. No more, no less. There are no wasted bytes for single-asset requests regardless of dataset size.
+
+2. **DFS-ordered blocks** — blocks are written to the CAR in the same order the DAG traversal engine requests them (depth-first). This is enforced in frisbii via `carPipe`, which hooks into the IPLD link system and writes each block to the CAR immediately as it is loaded during traversal. This is what makes the sequential `blocksReader.next()` blockstore safe.
+
+3. **Streaming-first, silent truncation on missing blocks** — frisbii prioritizes streaming over up-front validation. It is possible to begin receiving a valid CAR and then have it terminate prematurely if a block is missing from SP storage (e.g. the client did not upload the full DAG, or a disk holding part of the DAG was temporarily unavailable). There is no reliable in-band error signal for this — the CAR stream simply ends. The worker detects it by running the same DAG traversal: when `blocksReader.next()` returns `done` before the exporter has finished, it throws a `Block not found` error.
+
+4. **Single-SP DAG constraint** — the entire DAG must reside on the same SP, but it can span multiple Filecoin pieces on that SP. frisbii fetches blocks from whichever local piece holds them during traversal. Unlike generic IPFS gateways (Rainbow, Boxo) that use the DHT or IPNI to discover blocks scattered across the network, FilBeam has no cross-SP block discovery. A DAG sharded across two SPs cannot be served.
+
+**If a non-frisbii SP is ever onboarded**, guarantees 1 and 2 (path-scoped CAR and DFS ordering) must be verified before integration (tracked in [issue #692](https://github.com/filbeam/worker/issues/692)). The CID mismatch error (`Unexpected block CID`) is the failure mode if ordering is violated — it is not obvious without this context.
+
+### Trust model
+
+FilBeam trusts the `ipfsRootCid` submitted by the SP as on-chain metadata at `addPiece` time. It is indexed as-is without verifying it corresponds to the actual piece data stored on Filecoin. An SP could submit any CID as metadata.
+
+The only integrity guarantee FilBeam provides is at the block level: `validateBlock()` hashes each block's bytes and confirms they match the block's CID. This proves the CAR is self-consistent. It does not prove the CAR represents the content of the underlying Filecoin piece, since both the CID claim and the CAR bytes originate from the same SP.
+
+### Alternative: block-by-block retrieval (Discarded)
+
+An alternative design (used by IPFS Shipyard tooling like Boxo and verified-fetch) drives the exporter with a blockstore whose `get(cid)` makes individual HTTP requests: `GET /ipfs/{blockCid}?format=raw`. This eliminates the ordering dependency and enables per-block Cloudflare caching.
+
+The trade-offs cut both ways:
+
+- **CAR:** One round-trip per file, regardless of size. Fully streaming, but without per-block caching.
+- **Block-by-block:** Multiple round-trips per file (typically 20–80 for common content), but each block is independently cacheable. Ancestor directory blocks that are shared across many files can be served from cache on subsequent requests.
+
+For example, imagine a page at `/deep/path/content.html` that references 100 images. With the CAR approach, the ancestor directory blocks are fetched again for each image request. With block-by-block retrieval, those shared directory blocks would be cached after the first request and reused for the remaining images. In practice, however, ancestor directory blocks are typically very small, so the additional overhead of re-fetching them is likely to be minor.
+
+For FilBeam's narrow case — where the client deliberately stores the entire DAG on one SP and opts into this service — CAR retrieval is the better fit: one round-trip, fully streaming, no ordering complexity from the client side. The block-by-block design exists to solve the distributed-network problem of blocks scattered across unknown peers, which FilBeam does not have. That said, it is not a clear-cut choice.
+
+[Slack discussion Reference](https://filecoinproject.slack.com/archives/C08TVNKJV7C/p1779163683412569?thread_ts=1778671570.686079&cid=C08TVNKJV7C)
+
+---
+
+## Caching
+
+There are two independent cache layers.
+
+### Layer 1 — Cloudflare edge cache (origin fetch)
+
+Configured via the `cf` option on the SP `fetch` call (`retrieval/lib/origin-cache.js`):
+
+```js
+{
+ cacheEverything: true,
+ cacheTtlByStatus: { '200-299': ORIGIN_CACHE_TTL, 404: 0, '500-599': 0 }
+}
+```
+
+- `cacheEverything: true` — caches the SP response regardless of its `Cache-Control` header
+- 2xx responses are cached for `ORIGIN_CACHE_TTL` seconds (currently **86400, 1 day**)
+- 404 and 5xx responses are never cached
+
+**Cache key:** `{spBaseUrl}/ipfs/{ipfsRootCid}{ipfsSubpath}?format=car`
+
+CIDs are content-addressed and immutable, so in theory the same key always resolves to the same bytes and could be cached indefinitely. In practice two factors push against a very long TTL:
+
+- **Cache storage pressure** — large CARs cached across ~300 PoPs consume significant edge storage. A very large TTL risks filling PoP caches with content that is rarely re-requested.
+- **SP reliability** — a transient SP failure (bad disk, database issue, network blip) can produce a truncated CAR that still arrives with a 200 status. If Cloudflare caches that response, the broken content would be served for the full TTL. A shorter TTL limits the blast radius of such failures.
+
+The current 1-day value balances both concerns and should be revisited once cache hit rates and storage pressure are measurable.
+
+**Cache miss detection:** after the fetch, `CF-Cache-Status: HIT` means the edge served a cached copy; anything else is treated as a cache miss. Cache misses are what drive egress quota billing.
+
+### Layer 2 — Client/browser cache
+
+Set on every successful response to the client (`retrieval/lib/fetch-handler.js`):
+
+```
+Cache-Control: public, max-age=31536000
+```
+
+1 year. CID-addressed content is immutable, so this is correct in the happy path.
+
+**Open question:** if the stream terminates mid-way (truncated CAR, block validation error), will the browser reliably detect the response as incomplete and avoid caching a partial file for a year? This depends on HTTP version, whether a `Content-Length` was set, and browser-specific behavior — and needs further investigation before the 1-year TTL can be considered safe. @rvagg suggested asking `@lidel` as a subject-matter expert on this class of HTTP/IPFS gateway interaction.
+
+### Where cache data is stored
+
+Both layers use Cloudflare's edge network. Data is cached at the **PoP (Point of Presence)** that handled the request, whichever of Cloudflare's ~300 global data centers is geographically closest to the client. Each PoP maintains its own independent cache. A cache hit in Los Angeles does not warm the Frankfurt PoP. Enabling **Cloudflare Tiered Cache** at the account level would add a regional upper-tier cache, reducing cold-PoP fetches from Storage Providers for popular content if this becomes necessary in the future (it's not needed today).
+
+### Cache hit cost
+
+On a cache hit, the SP round-trip is eliminated but the worker still receives the full CAR body and runs the complete pipeline (CAR parsing, block validation, unixfs traversal). The cache saves network bytes from the SP but not CPU work in the worker.
+
+---
+
+## Related
+
+- `retrieval/` — shared library providing `handleFetchRequest`, `selectRetrievalCandidate`, `assertCidNotDenied`, egress quota tracking, and authorization
diff --git a/ipfs-retriever/.dev.vars.template b/ipfs-retriever/.dev.vars.template
new file mode 100644
index 00000000..ca1ba659
--- /dev/null
+++ b/ipfs-retriever/.dev.vars.template
@@ -0,0 +1 @@
+BOT_TOKENS={"secret":"dev"}
diff --git a/ipfs-retriever/bin/ipfs-retriever.js b/ipfs-retriever/bin/ipfs-retriever.js
new file mode 100644
index 00000000..c5b46eeb
--- /dev/null
+++ b/ipfs-retriever/bin/ipfs-retriever.js
@@ -0,0 +1,200 @@
+import {
+ isValidEthereumAddress,
+ httpAssert,
+ assertCidNotDenied,
+ handleFetchRequest,
+ selectRetrievalCandidate,
+} from '@filbeam/retrieval'
+
+import { parseRequest } from '../lib/request.js'
+import {
+ retrieveIpfsContent as defaultRetrieveIpfsContent,
+ processIpfsResponse,
+} from '../lib/retrieval.js'
+import {
+ getRetrievalCandidatesByDataSetAndPiece,
+ getSlugForWalletAndCid,
+} from '../lib/store.js'
+
+export default {
+ /**
+ * @param {Request} request
+ * @param {Env} env
+ * @param {ExecutionContext} ctx
+ * @param {object} options
+ * @param {typeof defaultRetrieveIpfsContent} [options.retrieveIpfsContent]
+ * @returns
+ */
+ async fetch(request, env, ctx, options) {
+ return handleFetchRequest(request, env, ctx, (context) =>
+ this._fetch(request, env, ctx, options, context),
+ )
+ },
+
+ /**
+ * @param {Request} request
+ * @param {Env} env
+ * @param {ExecutionContext} ctx
+ * @param {object} options
+ * @param {typeof defaultRetrieveIpfsContent} [options.retrieveIpfsContent]
+ * @param {import('@filbeam/retrieval').RequestContext} context
+ * @returns {Promise}
+ */
+ async _fetch(
+ request,
+ env,
+ ctx,
+ { retrieveIpfsContent = defaultRetrieveIpfsContent } = {},
+ context,
+ ) {
+ if (
+ URL.parse(request.url)?.hostname === env.DNS_ROOT.slice(1) ||
+ URL.parse(request.url)?.hostname === `link${env.DNS_ROOT}`
+ ) {
+ return handleDnsRootRequest(request, env)
+ }
+
+ const { dataSetId, pieceId, ipfsSubpath, ipfsFormat } = parseRequest(
+ request,
+ env,
+ )
+
+ return async () => {
+ // Timestamp to measure file retrieval performance (from cache and from SP)
+ const fetchStartedAt = performance.now()
+
+ const candidates = await getRetrievalCandidatesByDataSetAndPiece(
+ env,
+ dataSetId,
+ pieceId,
+ env.ENFORCE_EGRESS_QUOTA,
+ )
+ // Every candidate serves the same content, so they share the root CID.
+ const ipfsRootCid = candidates[0].ipfsRootCid
+
+ // Now check Bad Bits with the ipfsRootCid we got from the database
+ await assertCidNotDenied(env, ipfsRootCid)
+
+ const {
+ failureResponse,
+ candidate,
+ result: retrievalResult,
+ } = await selectRetrievalCandidate(
+ candidates,
+ (candidate) =>
+ retrieveIpfsContent(
+ candidate.serviceUrl,
+ ipfsRootCid,
+ ipfsSubpath,
+ env.ORIGIN_CACHE_TTL,
+ { signal: request.signal },
+ ),
+ {
+ env,
+ ctx,
+ requestCountryCode: context.requestCountryCode,
+ timestamp: context.requestTimestamp,
+ botName: context.botName,
+ },
+ )
+ if (failureResponse) return failureResponse
+ httpAssert(candidate && retrievalResult, 500, 'should never happen')
+
+ const originResponse = retrievalResult.response
+ const cacheMiss = retrievalResult.cacheMiss
+
+ const {
+ body: responseBody,
+ getOriginEgressBytes,
+ headers: responseHeaders,
+ } = await processIpfsResponse(originResponse, {
+ ipfsRootCid,
+ ipfsSubpath,
+ ipfsFormat,
+ signal: request.signal,
+ })
+
+ // When converting CAR to raw, the headers already carry the CAR-to-raw
+ // adjustments. A null body (e.g. a HEAD request) is served as-is.
+ const response = responseBody
+ ? new Response(responseBody, {
+ status: originResponse.status,
+ statusText: originResponse.statusText,
+ headers: responseHeaders,
+ })
+ : originResponse
+
+ return {
+ response,
+ cacheMiss,
+ dataSetId: candidate.dataSetId,
+ fetchStartedAt,
+ // The client is served the raw bytes. On a cache miss the worker
+ // fetched a (larger) CAR from the service provider, which the cache-miss
+ // quota is charged for; for a passed-through CAR (`?format=car`) the two
+ // are equal. Reaching here means the response streamed successfully (a
+ // converted CAR is validated during conversion), so charge every cache
+ // miss.
+ // The body has fully streamed by the time finalizeCacheMiss runs, so
+ // the lazily counted CAR byte total is final here.
+ finalizeCacheMiss: async (egressBytes) => ({
+ cacheMissEgressBytes: getOriginEgressBytes() ?? egressBytes,
+ cacheMissResponseValid: cacheMiss ? true : null,
+ }),
+ }
+ }
+ },
+}
+
+/**
+ * Handles requests to the bare DNS_ROOT domain (e.g., ipfs.filbeam.io).
+ *
+ * - If no path is provided, redirects to https://filbeam.com
+ * - If path is /wallet/cid or /wallet/cid/pathname, generates a slug and
+ * redirects to the subdomain-based URL
+ *
+ * @param {Request} request - The incoming request
+ * @param {Env} env - Worker environment
+ * @returns {Promise} Redirect response
+ */
+async function handleDnsRootRequest(request, env) {
+ // Parse the URL path to extract wallet, cid, and optional pathname
+ const parsedUrl = URL.parse(request.url)
+ const pathname = parsedUrl?.pathname || '/'
+
+ // If no path, redirect to filbeam.com
+ if (pathname === '/' || pathname === '') {
+ return Response.redirect('https://filbeam.com/', 302)
+ }
+
+ // Parse path as /wallet/cid/pathname
+ const pathParts = pathname.slice(1).split('/') // Remove leading slash and split
+
+ if (pathParts.length < 2) {
+ httpAssert(
+ false,
+ 404,
+ 'Invalid path format. Expected: /wallet/cid or /wallet/cid/pathname',
+ )
+ }
+
+ const wallet = pathParts[0].toLowerCase()
+ const cid = pathParts[1]
+ const subpath = pathParts.slice(2).join('/')
+
+ // Validate wallet address
+ httpAssert(
+ isValidEthereumAddress(wallet),
+ 404,
+ `Invalid wallet address: ${wallet}. Address must be a valid ethereum address.`,
+ )
+
+ // Get slug for the wallet and CID
+ const slug = await getSlugForWalletAndCid(env, wallet, cid)
+
+ // Build redirect URL
+ const redirectPath = subpath ? `/${subpath}` : ''
+ const redirectUrl = `https://${slug}${env.DNS_ROOT}${redirectPath}`
+
+ return Response.redirect(redirectUrl, 302)
+}
diff --git a/ipfs-retriever/lib/bigint-util.js b/ipfs-retriever/lib/bigint-util.js
new file mode 100644
index 00000000..e0e720d3
--- /dev/null
+++ b/ipfs-retriever/lib/bigint-util.js
@@ -0,0 +1,74 @@
+import { base32 } from 'multiformats/bases/base32'
+
+/**
+ * @param {BigInt} value
+ * @returns {Uint8Array}
+ */
+export function bigIntToUint8Array(value) {
+ if (typeof value !== 'bigint') {
+ throw new TypeError('Expected a BigInt value')
+ }
+ if (value < 0n) {
+ throw new Error('Cannot convert negative bigint to Uint8Array')
+ }
+ let hex = value.toString(16)
+ if (hex.length % 2) hex = '0' + hex
+ const bytes = hex.match(/.{2}/g)?.map((byte) => parseInt(byte, 16)) ?? []
+ return new Uint8Array(bytes)
+}
+
+/**
+ * @param {Uint8Array} value
+ * @returns {BigInt}
+ */
+export function uint8ArrayToBigInt(value) {
+ if (!(value instanceof Uint8Array)) {
+ throw new TypeError('Expected a Uint8Array value')
+ }
+ if (value.length === 0) {
+ return 0n
+ }
+ const hex = [...value].map((x) => x.toString(16).padStart(2, '0')).join('')
+ return BigInt('0x' + hex)
+}
+
+/**
+ * Converts a BigInt to a base32-encoded string
+ *
+ * @param {BigInt} value
+ * @returns {string}
+ */
+export function bigIntToBase32(value) {
+ if (typeof value !== 'bigint') {
+ throw new TypeError('Expected a BigInt value')
+ }
+ if (value < 0n) {
+ throw new Error('Cannot convert negative bigint to base32')
+ }
+ // Use "0" for zero value (0 is not a base32 character but is DNS-safe)
+ if (value === 0n) {
+ return '0'
+ }
+ const bytes = bigIntToUint8Array(value)
+ // Remove the 'b' prefix that multiformats adds
+ return base32.encode(bytes).slice(1)
+}
+
+/**
+ * Converts a base32-encoded string to a BigInt
+ *
+ * @param {string} value
+ * @returns {BigInt}
+ */
+export function base32ToBigInt(value) {
+ if (typeof value !== 'string') {
+ throw new TypeError('Expected a string value')
+ }
+ // Handle special case for zero
+ if (value === '0') {
+ return 0n
+ }
+ // Add back the 'b' prefix that multiformats expects
+ const bytes = base32.decode('b' + value)
+ return uint8ArrayToBigInt(bytes)
+}
diff --git a/ipfs-retriever/lib/request.js b/ipfs-retriever/lib/request.js
new file mode 100644
index 00000000..3dc7728f
--- /dev/null
+++ b/ipfs-retriever/lib/request.js
@@ -0,0 +1,77 @@
+import { httpAssert } from '@filbeam/retrieval'
+import { base32ToBigInt } from './bigint-util.js'
+
+/**
+ * Parse params found in path of the request URL
+ *
+ * @param {Request} request
+ * @param {object} options
+ * @param {string} options.DNS_ROOT
+ * @returns {{
+ * dataSetId: string
+ * pieceId: string
+ * ipfsSubpath: string
+ * ipfsFormat: string | null
+ * }}
+ */
+export function parseRequest(request, { DNS_ROOT }) {
+ const url = new URL(request.url)
+ console.log('retrieval request', { DNS_ROOT, url })
+
+ httpAssert(
+ url.hostname.endsWith(DNS_ROOT),
+ 400,
+ `Invalid hostname: ${url.hostname}. It must end with ${DNS_ROOT}.`,
+ )
+
+ const slug = url.hostname.slice(0, -DNS_ROOT.length)
+ const parts = slug.split('-')
+
+ httpAssert(
+ parts.length === 3,
+ 400,
+ `The hostname must be in the format: 1-{dataSetId}-{pieceId}${DNS_ROOT}`,
+ )
+
+ const [version, encodedDataSetId, encodedPieceId] = parts
+
+ httpAssert(
+ version === '1',
+ 400,
+ `Unsupported slug version: ${version}. Expected version 1.`,
+ )
+
+ httpAssert(
+ encodedDataSetId && encodedPieceId,
+ 400,
+ `The hostname must be in the format: 1-{dataSetId}-{pieceId}${DNS_ROOT}`,
+ )
+
+ let dataSetId
+ let pieceId
+
+ try {
+ dataSetId = base32ToBigInt(encodedDataSetId).toString()
+ } catch (error) {
+ httpAssert(
+ false,
+ 400,
+ `Invalid dataSetId encoding in slug: ${encodedDataSetId}. ${error instanceof Error ? error.message : String(error)}`,
+ )
+ }
+
+ try {
+ pieceId = base32ToBigInt(encodedPieceId).toString()
+ } catch (error) {
+ httpAssert(
+ false,
+ 400,
+ `Invalid pieceId encoding in slug: ${encodedPieceId}. ${error instanceof Error ? error.message : String(error)}`,
+ )
+ }
+
+ const ipfsSubpath = url.pathname || '/'
+ const ipfsFormat = url.searchParams.get('format')
+
+ return { dataSetId, pieceId, ipfsSubpath, ipfsFormat }
+}
diff --git a/ipfs-retriever/lib/retrieval.js b/ipfs-retriever/lib/retrieval.js
new file mode 100644
index 00000000..877933fe
--- /dev/null
+++ b/ipfs-retriever/lib/retrieval.js
@@ -0,0 +1,241 @@
+import { CarBlockIterator } from '@ipld/car'
+// @ts-ignore - Types exist but package.json exports configuration prevents resolution
+import * as carBlockValidator from '@web3-storage/car-block-validator'
+import { recursive as exporter } from 'ipfs-unixfs-exporter'
+import { httpAssert, originCacheOptions } from '@filbeam/retrieval'
+
+/** @import {UnixFSBasicEntry} from 'ipfs-unixfs-exporter' */
+/** @typedef {{ cid: import('multiformats').CID; bytes: Uint8Array }} Block */
+
+/** @type {(block: Block) => Promise | undefined} */
+const validateBlock = carBlockValidator.validateBlock
+
+/**
+ * Retrieves the IPFS content from the SP serving requests at the provided base
+ * URL.
+ *
+ * @param {string} baseUrl - The base URL of service provider.
+ * @param {string} ipfsRootCid - The IPFS Root CID to retrieve from.
+ * @param {string} ipfsSubpath - The subpath inside the UnixFS archive to
+ * retrieve, e.g. `/favicon.ico`.
+ * @param {number} [cacheTtl=86400] - Cache TTL in seconds (default: 86400).
+ * Default is `86400`
+ * @param {object} [options] - Optional parameters.
+ * @param {AbortSignal} [options.signal] - An optional AbortSignal to cancel the
+ * fetch request.
+ * @returns {Promise<{
+ * response: Response
+ * cacheMiss: boolean
+ * }>}
+ *
+ * - The response from the fetch request, the cache miss and the content length.
+ */
+export async function retrieveIpfsContent(
+ baseUrl,
+ ipfsRootCid,
+ ipfsSubpath,
+ cacheTtl = 86400,
+ { signal } = {},
+) {
+ // TODO: allow the caller to tweak Trustless GW parameters like `dag-scope` when requesting `format=car`.
+ // See https://specs.ipfs.tech/http-gateways/trustless-gateway/
+ // TODO: support `raw` format too, see https://github.com/filbeam/worker/issues/295
+ const url = getRetrievalUrl(baseUrl, ipfsRootCid, ipfsSubpath) + '?format=car'
+ console.log(`Fetching IPFS content from: ${url}`)
+ const response = await fetch(url, {
+ cf: originCacheOptions(cacheTtl),
+ signal,
+ })
+ const cacheStatus = response.headers.get('CF-Cache-Status')
+ if (!cacheStatus) {
+ console.log(`CF-Cache-Status was not provided for ${url}`)
+ }
+
+ const cacheMiss = cacheStatus !== 'HIT'
+
+ return { response, cacheMiss }
+}
+
+/**
+ * @param {string} serviceUrl
+ * @param {string} rootCid
+ * @param {string} subpath
+ * @returns {string}
+ */
+export function getRetrievalUrl(serviceUrl, rootCid, subpath) {
+ if (!serviceUrl.endsWith('/')) {
+ serviceUrl += '/'
+ }
+ let url = `${serviceUrl}ipfs/${rootCid}`
+ // Curio 404s with trailing slash
+ if (subpath !== '/') {
+ url += subpath
+ }
+ return url
+}
+
+/**
+ * @param {Response} response
+ * @param {object} options
+ * @param {string} options.ipfsRootCid
+ * @param {string} options.ipfsSubpath
+ * @param {string | null} options.ipfsFormat
+ * @param {AbortSignal} [options.signal]
+ * @returns {Promise<{
+ * body: ReadableStream | null
+ * getOriginEgressBytes: () => number | null
+ * headers: Headers
+ * }>}
+ * - `body` is the stream to serve to the client: raw bytes when converting from
+ * CAR, the original body when serving CAR or passing through.
+ * - `getOriginEgressBytes` returns the number of CAR bytes read from the service
+ * provider, or `null` when the body is passed through unchanged (in that
+ * case the bytes served equal the bytes fetched). Because the CAR is
+ * streamed lazily, the count is only final once `body` has been fully
+ * consumed, so call this after streaming the response.
+ * - `headers` are the response headers to serve, with the CAR-to-raw adjustments
+ * applied when converting.
+ */
+export async function processIpfsResponse(
+ response,
+ { ipfsRootCid, ipfsSubpath, ipfsFormat, signal },
+) {
+ const body = response.body
+ if (!response.ok || !body || ipfsFormat === 'car') {
+ return {
+ body,
+ getOriginEgressBytes: () => null,
+ headers: response.headers,
+ }
+ }
+
+ httpAssert(
+ ipfsFormat === null,
+ 400,
+ `Unsupported ?format value: "${ipfsFormat}"`,
+ )
+
+ // When converting from CAR to raw, set content-disposition to inline so
+ // browsers display the content instead of downloading it, and drop the
+ // upstream content type so the browser sniffs the raw bytes.
+ const headers = new Headers(response.headers)
+ headers.set('content-disposition', 'inline')
+ headers.delete('content-type')
+ headers.delete('x-content-type-options')
+
+ // Count the CAR bytes fetched from the service provider as they stream
+ // through. `CarBlockIterator` decodes only the CAR header up front and yields
+ // blocks lazily, so the whole archive is never held in memory. The byte count
+ // is therefore only final once the caller has fully consumed the returned
+ // body, so it is exposed via `getOriginEgressBytes` rather than as a value.
+ let originEgressBytes = 0
+ const countingBody = (async function* () {
+ for await (const chunk of body) {
+ originEgressBytes += chunk.length
+ yield chunk
+ }
+ })()
+
+ const blocks = await CarBlockIterator.fromIterable(countingBody)
+ const blocksReader = blocks[Symbol.asyncIterator]()
+
+ try {
+ const entries = exporter(
+ `${ipfsRootCid}${ipfsSubpath}`,
+ {
+ async get(blockCid) {
+ const res = await blocksReader.next()
+ if (res.done || !res.value) {
+ throw new Error(`Block ${blockCid} not found in CAR ${ipfsRootCid}`)
+ }
+ const block = res.value
+
+ // Compare only the multihashes, so a block stored under an equivalent
+ // CID with a different codec or CID version still matches. validateBlock
+ // below verifies the block bytes hash to this multihash.
+ const actualMultihash = block.cid.multihash.bytes
+ const expectedMultihash = blockCid.multihash.bytes
+ if (
+ actualMultihash.length !== expectedMultihash.length ||
+ !actualMultihash.every((byte, i) => byte === expectedMultihash[i])
+ ) {
+ throw new Error(
+ `Unexpected block CID ${block.cid}, expected ${blockCid}`,
+ )
+ }
+
+ try {
+ await validateBlock(block)
+ } catch (err) {
+ throw new Error(
+ `Invalid block ${blockCid} of root ${ipfsRootCid}`,
+ {
+ cause: err,
+ },
+ )
+ }
+
+ return block.bytes
+ },
+ },
+ { signal, blockReadConcurrency: 1 },
+ )
+
+ // eslint-disable-next-line no-unreachable-loop
+ for await (const entry of entries) {
+ signal?.throwIfAborted()
+ console.log(`Entry: ${entry.path} (${entry.type})`)
+
+ const expectedPath =
+ ipfsSubpath === '/' ? ipfsRootCid : `${ipfsRootCid}${ipfsSubpath}`
+ if (entry.path !== expectedPath) {
+ throw new Error(
+ `Unexpected entry - wrong path: ${describeEntry(entry)} (expected: ${expectedPath})`,
+ )
+ }
+
+ if (entry.type !== 'file' && entry.type !== 'raw') {
+ console.log(`Unexpected entry - wrong type: ${describeEntry(entry)}`)
+ httpAssert(false, 404, 'Not Found')
+ }
+
+ const entryContent = entry.content()
+
+ // Convert AsyncGenerator to ReadableStream for Response body
+ const rawDataStream = new ReadableStream({
+ async start(controller) {
+ try {
+ for await (const chunk of entryContent) {
+ signal?.throwIfAborted()
+ controller.enqueue(chunk)
+ }
+ controller.close()
+ } catch (error) {
+ controller.error(error)
+ }
+ },
+ })
+
+ return {
+ body: rawDataStream,
+ getOriginEgressBytes: () => originEgressBytes,
+ headers,
+ }
+ }
+
+ httpAssert(false, 404, 'Not Found')
+ } catch (err) {
+ // Release the SP connection immediately on error
+ body.cancel().catch(() => {})
+ throw err
+ }
+}
+
+/** @param {UnixFSBasicEntry} entry */
+export function describeEntry(entry) {
+ return JSON.stringify(
+ entry,
+ (_, v) => (typeof v === 'bigint' ? v.toString() : v),
+ 2,
+ )
+}
diff --git a/ipfs-retriever/lib/store.js b/ipfs-retriever/lib/store.js
new file mode 100644
index 00000000..9a974442
--- /dev/null
+++ b/ipfs-retriever/lib/store.js
@@ -0,0 +1,229 @@
+import { bigIntToBase32 } from './bigint-util.js'
+import {
+ httpAssert,
+ filterAuthorizedRetrievalCandidates,
+ buildRetrievalCandidateQuery,
+} from '@filbeam/retrieval'
+
+const SELECT_CANDIDATES_BY_CID = buildRetrievalCandidateQuery({
+ extraColumns: [
+ 'pieces.id AS piece_id',
+ 'pieces.ipfs_root_cid',
+ 'data_sets.with_ipfs_indexing',
+ ],
+ where: 'pieces.ipfs_root_cid = ?',
+})
+
+/**
+ * Validates query results and returns every approved retrieval candidate. This
+ * is a shared helper used by both getRetrievalCandidatesByWalletAndCid and
+ * getRetrievalCandidatesByDataSetAndPiece.
+ *
+ * @param {object} params
+ * @param {any[]} params.results - The query results to validate
+ * @param {string} params.payerAddress - The lower-cased address of the client
+ * paying for the request
+ * @param {string} params.lookupKey - Descriptive key for error messages (e.g.,
+ * "IPFS Root CID 'bafk...'")
+ * @param {boolean} [params.enforceEgressQuota] - Whether to require remaining
+ * egress quota
+ * @returns {{
+ * serviceProviderId: string
+ * serviceUrl: string
+ * dataSetId: string
+ * pieceId: string
+ * ipfsRootCid: string
+ * }[]}
+ */
+function validateQueryResultsAndGetCandidates(params) {
+ const {
+ results,
+ payerAddress,
+ lookupKey,
+ enforceEgressQuota = false,
+ } = params
+
+ const authorizedRetrievalCandidates = filterAuthorizedRetrievalCandidates(
+ results,
+ { payerAddress, enforceEgressQuota },
+ )
+
+ const withIpfsIndexing = authorizedRetrievalCandidates.filter(
+ (row) => row.with_ipfs_indexing === 1,
+ )
+ httpAssert(
+ withIpfsIndexing.length > 0,
+ 402,
+ `The Filecoin Warm Storage Service deal for payer '${payerAddress}' and ${lookupKey} has withIpfsIndexing=false.`,
+ )
+
+ const withIpfsRootCid = withIpfsIndexing.filter((row) => row.ipfs_root_cid)
+ httpAssert(
+ withIpfsRootCid.length > 0,
+ 404,
+ `${lookupKey} exists but has no associated IPFS Root CID.`,
+ )
+
+ const candidates = withIpfsRootCid.map((row) => ({
+ serviceProviderId: row.service_provider_id,
+ // We need this cast to suppress a TypeScript error. The compiler cannot
+ // infer that the filters above keep only rows with service_url defined.
+ serviceUrl: /** @type {string} */ (row.service_url),
+ dataSetId: row.data_set_id,
+ pieceId: row.piece_id,
+ ipfsRootCid: row.ipfs_root_cid,
+ }))
+
+ console.log(
+ `Validated ${candidates.length} retrieval candidate(s) for ${lookupKey} and payer '${payerAddress}'`,
+ )
+
+ return candidates
+}
+
+/**
+ * Retrieves every approved retrieval candidate (one per service provider) for a
+ * given root CID and payer.
+ *
+ * @param {Pick} env - Cloudflare Worker environment with D1 DB
+ * binding
+ * @param {string} payerAddress - The lower-cased address of the client paying
+ * for the request
+ * @param {string} ipfsRootCid - The IPFS Root CID to look up
+ * @returns {Promise<
+ * {
+ * serviceProviderId: string
+ * serviceUrl: string
+ * dataSetId: string
+ * pieceId: string
+ * ipfsRootCid: string
+ * }[]
+ * >}
+ */
+export async function getRetrievalCandidatesByWalletAndCid(
+ env,
+ payerAddress,
+ ipfsRootCid,
+) {
+ const results = /** @type {any[]} */ (
+ (await env.DB.prepare(SELECT_CANDIDATES_BY_CID).bind(ipfsRootCid).all())
+ .results
+ )
+
+ return validateQueryResultsAndGetCandidates({
+ results,
+ payerAddress,
+ lookupKey: `IPFS Root CID '${ipfsRootCid}'`,
+ })
+}
+
+/**
+ * Retrieves every approved retrieval candidate for the content addressed by a
+ * given data set ID and piece ID. The piece is resolved to its content CID and
+ * the data set's payer, then every service provider serving that content for
+ * that payer is returned so the worker can retry across them.
+ *
+ * @param {Pick} env - Cloudflare Worker environment with D1 DB
+ * binding
+ * @param {string} dataSetId - The data set ID
+ * @param {string} pieceId - The piece ID
+ * @param {boolean} [enforceEgressQuota=false] - Whether to require remaining
+ * egress quota. Default is `false`
+ * @returns {Promise<
+ * {
+ * serviceProviderId: string
+ * serviceUrl: string
+ * dataSetId: string
+ * pieceId: string
+ * ipfsRootCid: string
+ * }[]
+ * >}
+ */
+export async function getRetrievalCandidatesByDataSetAndPiece(
+ env,
+ dataSetId,
+ pieceId,
+ enforceEgressQuota = false,
+) {
+ const piece = /**
+ * @type {{
+ * ipfs_root_cid: string | null
+ * payer_address: string | null
+ * } | null}
+ */ (
+ await env.DB.prepare(
+ `
+ SELECT pieces.ipfs_root_cid, data_sets.payer_address
+ FROM pieces
+ LEFT OUTER JOIN data_sets ON pieces.data_set_id = data_sets.id
+ WHERE pieces.id = ? AND pieces.data_set_id = ?
+ `,
+ )
+ .bind(pieceId, dataSetId)
+ .first()
+ )
+
+ httpAssert(
+ piece,
+ 404,
+ `Piece ID '${pieceId}' does not exist in data set ID '${dataSetId}' or may not have been indexed yet.`,
+ )
+
+ const ipfsRootCid = piece.ipfs_root_cid
+ const payerAddress = piece.payer_address
+
+ httpAssert(
+ payerAddress,
+ 404,
+ `Data set ID '${dataSetId}' exists but has no associated payer address.`,
+ )
+ httpAssert(
+ ipfsRootCid,
+ 404,
+ `data set ID '${dataSetId}' and piece ID '${pieceId}' exists but has no associated IPFS Root CID.`,
+ )
+
+ const results = /** @type {any[]} */ (
+ (await env.DB.prepare(SELECT_CANDIDATES_BY_CID).bind(ipfsRootCid).all())
+ .results
+ )
+
+ return validateQueryResultsAndGetCandidates({
+ results,
+ payerAddress: payerAddress.toLowerCase(),
+ lookupKey: `data set ID '${dataSetId}' and piece ID '${pieceId}'`,
+ enforceEgressQuota,
+ })
+}
+
+/**
+ * Builds a slug from dataSetId and pieceId.
+ *
+ * @param {bigint} dataSetId - The data set ID as BigInt
+ * @param {bigint} pieceId - The piece ID as BigInt
+ * @returns {string} - The slug in format:
+ * 1-{base32(dataSetId)}-{base32(pieceId)}
+ */
+export function buildSlug(dataSetId, pieceId) {
+ return [
+ '1', // version
+ bigIntToBase32(dataSetId),
+ bigIntToBase32(pieceId),
+ ].join('-')
+}
+
+/**
+ * @param {Pick} env - Cloudflare Worker environment with D1 DB
+ * binding
+ * @param {string} payerAddress
+ * @param {string} ipfsRootCid
+ */
+export async function getSlugForWalletAndCid(env, payerAddress, ipfsRootCid) {
+ const [{ dataSetId, pieceId }] = await getRetrievalCandidatesByWalletAndCid(
+ env,
+ payerAddress,
+ ipfsRootCid,
+ )
+
+ return buildSlug(BigInt(dataSetId), BigInt(pieceId))
+}
diff --git a/ipfs-retriever/package.json b/ipfs-retriever/package.json
new file mode 100644
index 00000000..8e3c2158
--- /dev/null
+++ b/ipfs-retriever/package.json
@@ -0,0 +1,22 @@
+{
+ "name": "@filbeam/ipfs-retriever",
+ "version": "1.0.0",
+ "private": true,
+ "description": "FilBeam IPFS Retrieval Worker",
+ "author": "Space Meridian ",
+ "type": "module",
+ "main": "bin/ipfs-retriever.js",
+ "scripts": {
+ "build:types": "wrangler types",
+ "deploy:calibration": "wrangler deploy --env calibration",
+ "start": "wrangler d1 migrations apply dev-db --local --env dev --cwd ../db && wrangler dev --env dev",
+ "test": "wrangler d1 migrations apply test-db --local --cwd ../db && vitest run"
+ },
+ "dependencies": {
+ "@filbeam/retrieval": "^1.0.0",
+ "@ipld/car": "^5.4.2",
+ "@web3-storage/car-block-validator": "^1.2.2",
+ "ipfs-unixfs-exporter": "^13.7.3",
+ "multiformats": "^13.4.1"
+ }
+}
diff --git a/ipfs-retriever/test/apply-migrations.js b/ipfs-retriever/test/apply-migrations.js
new file mode 100644
index 00000000..3bad2028
--- /dev/null
+++ b/ipfs-retriever/test/apply-migrations.js
@@ -0,0 +1,3 @@
+import { applyD1Migrations, env } from 'cloudflare:test'
+
+await applyD1Migrations(env.DB, env.TEST_MIGRATIONS)
diff --git a/ipfs-retriever/test/bigint-util.test.js b/ipfs-retriever/test/bigint-util.test.js
new file mode 100644
index 00000000..74177c9b
--- /dev/null
+++ b/ipfs-retriever/test/bigint-util.test.js
@@ -0,0 +1,406 @@
+import { describe, it, expect } from 'vitest'
+import {
+ bigIntToUint8Array,
+ uint8ArrayToBigInt,
+ bigIntToBase32,
+ base32ToBigInt,
+} from '../lib/bigint-util.js'
+
+describe('bigint-util', () => {
+ describe('bigIntToUint8Array', () => {
+ it('converts zero correctly', () => {
+ const result = bigIntToUint8Array(0n)
+ expect(result).toEqual(new Uint8Array([0]))
+ })
+
+ it('converts small positive single-byte values', () => {
+ expect(bigIntToUint8Array(1n)).toEqual(new Uint8Array([1]))
+ expect(bigIntToUint8Array(255n)).toEqual(new Uint8Array([255]))
+ })
+
+ it('converts two-byte values', () => {
+ expect(bigIntToUint8Array(256n)).toEqual(new Uint8Array([1, 0]))
+ expect(bigIntToUint8Array(257n)).toEqual(new Uint8Array([1, 1]))
+ expect(bigIntToUint8Array(65535n)).toEqual(new Uint8Array([255, 255]))
+ })
+
+ it('converts medium values requiring multiple bytes', () => {
+ // 3 bytes
+ expect(bigIntToUint8Array(65536n)).toEqual(new Uint8Array([1, 0, 0]))
+ expect(bigIntToUint8Array(16777215n)).toEqual(
+ new Uint8Array([255, 255, 255]),
+ )
+
+ // 4 bytes
+ expect(bigIntToUint8Array(16777216n)).toEqual(
+ new Uint8Array([1, 0, 0, 0]),
+ )
+ })
+
+ it('converts large values requiring 8 bytes', () => {
+ const value = 2n ** 64n - 1n // Max 64-bit value
+ const result = bigIntToUint8Array(value)
+ expect(result.length).toBe(8)
+ expect(result).toEqual(
+ new Uint8Array([255, 255, 255, 255, 255, 255, 255, 255]),
+ )
+ })
+
+ it('converts very large values requiring 32+ bytes', () => {
+ const value = 2n ** 256n - 1n
+ const result = bigIntToUint8Array(value)
+ expect(result.length).toBe(32)
+ expect(result.every((byte) => byte === 255)).toBe(true)
+ })
+
+ it('converts values requiring 64+ bytes', () => {
+ const value = 2n ** 512n
+ const result = bigIntToUint8Array(value)
+ expect(result.length).toBe(65) // 512 bits = 64 bytes + 1 leading byte
+ expect(result[0]).toBe(1)
+ expect(result.slice(1).every((byte) => byte === 0)).toBe(true)
+ })
+
+ it('maintains big-endian byte order', () => {
+ // 0x0102 should be [1, 2], not [2, 1]
+ expect(bigIntToUint8Array(0x0102n)).toEqual(new Uint8Array([1, 2]))
+ expect(bigIntToUint8Array(0x123456n)).toEqual(
+ new Uint8Array([0x12, 0x34, 0x56]),
+ )
+ expect(bigIntToUint8Array(0xabcdefn)).toEqual(
+ new Uint8Array([0xab, 0xcd, 0xef]),
+ )
+ })
+
+ it('handles boundary values at byte transitions', () => {
+ // Test values at byte boundaries
+ expect(bigIntToUint8Array(254n)).toEqual(new Uint8Array([254]))
+ expect(bigIntToUint8Array(255n)).toEqual(new Uint8Array([255]))
+ expect(bigIntToUint8Array(256n)).toEqual(new Uint8Array([1, 0]))
+
+ expect(bigIntToUint8Array(65534n)).toEqual(new Uint8Array([255, 254]))
+ expect(bigIntToUint8Array(65535n)).toEqual(new Uint8Array([255, 255]))
+ expect(bigIntToUint8Array(65536n)).toEqual(new Uint8Array([1, 0, 0]))
+ })
+
+ it('handles powers of 2 correctly', () => {
+ expect(bigIntToUint8Array(2n ** 8n)).toEqual(new Uint8Array([1, 0]))
+ expect(bigIntToUint8Array(2n ** 16n)).toEqual(new Uint8Array([1, 0, 0]))
+ expect(bigIntToUint8Array(2n ** 24n)).toEqual(
+ new Uint8Array([1, 0, 0, 0]),
+ )
+ expect(bigIntToUint8Array(2n ** 32n)).toEqual(
+ new Uint8Array([1, 0, 0, 0, 0]),
+ )
+ })
+
+ it('throws error for non-bigint input', () => {
+ expect(() => bigIntToUint8Array(123)).toThrow()
+ expect(() => bigIntToUint8Array('123')).toThrow()
+ expect(() => bigIntToUint8Array(null)).toThrow()
+ expect(() => bigIntToUint8Array(undefined)).toThrow()
+ expect(() => bigIntToUint8Array({})).toThrow()
+ expect(() => bigIntToUint8Array([])).toThrow()
+ })
+ })
+
+ describe('uint8ArrayToBigInt', () => {
+ it('converts single byte correctly', () => {
+ expect(uint8ArrayToBigInt(new Uint8Array([0]))).toBe(0n)
+ expect(uint8ArrayToBigInt(new Uint8Array([1]))).toBe(1n)
+ expect(uint8ArrayToBigInt(new Uint8Array([255]))).toBe(255n)
+ })
+
+ it('converts multiple bytes correctly', () => {
+ expect(uint8ArrayToBigInt(new Uint8Array([1, 0]))).toBe(256n)
+ expect(uint8ArrayToBigInt(new Uint8Array([1, 1]))).toBe(257n)
+ expect(uint8ArrayToBigInt(new Uint8Array([255, 255]))).toBe(65535n)
+ })
+
+ it('converts large arrays correctly', () => {
+ // 4 bytes
+ expect(uint8ArrayToBigInt(new Uint8Array([1, 0, 0, 0]))).toBe(16777216n)
+
+ // 8 bytes (max 64-bit)
+ const maxUint64 = new Uint8Array([255, 255, 255, 255, 255, 255, 255, 255])
+ expect(uint8ArrayToBigInt(maxUint64)).toBe(2n ** 64n - 1n)
+ })
+
+ it('converts very large arrays (32+ bytes)', () => {
+ const thirtyTwoBytes = new Uint8Array(32).fill(255)
+ expect(uint8ArrayToBigInt(thirtyTwoBytes)).toBe(2n ** 256n - 1n)
+ })
+
+ it('handles empty array', () => {
+ expect(uint8ArrayToBigInt(new Uint8Array([]))).toBe(0n)
+ })
+
+ it('handles arrays with leading zeros', () => {
+ expect(uint8ArrayToBigInt(new Uint8Array([0, 0, 1]))).toBe(1n)
+ expect(uint8ArrayToBigInt(new Uint8Array([0, 1, 0]))).toBe(256n)
+ expect(uint8ArrayToBigInt(new Uint8Array([0, 0, 0, 255]))).toBe(255n)
+ })
+
+ it('handles all zeros', () => {
+ expect(uint8ArrayToBigInt(new Uint8Array([0]))).toBe(0n)
+ expect(uint8ArrayToBigInt(new Uint8Array([0, 0]))).toBe(0n)
+ expect(uint8ArrayToBigInt(new Uint8Array([0, 0, 0]))).toBe(0n)
+ })
+
+ it('interprets bytes as big-endian', () => {
+ // [1, 2] should be 0x0102 = 258, not 0x0201 = 513
+ expect(uint8ArrayToBigInt(new Uint8Array([1, 2]))).toBe(0x0102n)
+ expect(uint8ArrayToBigInt(new Uint8Array([0x12, 0x34, 0x56]))).toBe(
+ 0x123456n,
+ )
+ expect(uint8ArrayToBigInt(new Uint8Array([0xab, 0xcd, 0xef]))).toBe(
+ 0xabcdefn,
+ )
+ })
+
+ it('handles boundary values', () => {
+ expect(uint8ArrayToBigInt(new Uint8Array([254]))).toBe(254n)
+ expect(uint8ArrayToBigInt(new Uint8Array([255]))).toBe(255n)
+ expect(uint8ArrayToBigInt(new Uint8Array([255, 254]))).toBe(65534n)
+ expect(uint8ArrayToBigInt(new Uint8Array([255, 255]))).toBe(65535n)
+ })
+
+ it('works with Node.js Buffer', () => {
+ const buffer = Buffer.from([1, 2, 3])
+ expect(uint8ArrayToBigInt(buffer)).toBe(0x010203n)
+ })
+
+ it('throws error for non-Uint8Array input', () => {
+ expect(() => uint8ArrayToBigInt(123)).toThrow()
+ expect(() => uint8ArrayToBigInt('123')).toThrow()
+ expect(() => uint8ArrayToBigInt(null)).toThrow()
+ expect(() => uint8ArrayToBigInt(undefined)).toThrow()
+ expect(() => uint8ArrayToBigInt({})).toThrow()
+ })
+
+ it('throws error for regular arrays', () => {
+ expect(() => uint8ArrayToBigInt([1, 2, 3])).toThrow()
+ })
+ })
+
+ describe('Round-Trip Conversion', () => {
+ it('bigint -> array -> bigint preserves value for small numbers', () => {
+ const values = [0n, 1n, 127n, 128n, 255n, 256n, 65535n, 65536n]
+ values.forEach((value) => {
+ const array = bigIntToUint8Array(value)
+ const result = uint8ArrayToBigInt(array)
+ expect(result).toBe(value)
+ })
+ })
+
+ it('bigint -> array -> bigint preserves value for large numbers', () => {
+ const values = [2n ** 32n, 2n ** 64n, 2n ** 128n, 2n ** 256n, 2n ** 512n]
+ values.forEach((value) => {
+ const array = bigIntToUint8Array(value)
+ const result = uint8ArrayToBigInt(array)
+ expect(result).toBe(value)
+ })
+ })
+
+ it('bigint -> array -> bigint preserves value for powers of 2 minus 1', () => {
+ const values = [
+ 2n ** 8n - 1n,
+ 2n ** 16n - 1n,
+ 2n ** 32n - 1n,
+ 2n ** 64n - 1n,
+ 2n ** 128n - 1n,
+ ]
+ values.forEach((value) => {
+ const array = bigIntToUint8Array(value)
+ const result = uint8ArrayToBigInt(array)
+ expect(result).toBe(value)
+ })
+ })
+
+ it('array -> bigint -> array preserves array (without leading zeros)', () => {
+ const arrays = [
+ new Uint8Array([0]),
+ new Uint8Array([1]),
+ new Uint8Array([255]),
+ new Uint8Array([1, 0]),
+ new Uint8Array([255, 255]),
+ new Uint8Array([1, 2, 3, 4, 5]),
+ ]
+ arrays.forEach((array) => {
+ const bigint = uint8ArrayToBigInt(array)
+ const result = bigIntToUint8Array(bigint)
+ expect(result).toEqual(array)
+ })
+ })
+
+ it('array with leading zeros -> bigint -> array removes leading zeros', () => {
+ const arrayWithZeros = new Uint8Array([0, 0, 1, 2, 3])
+ const bigint = uint8ArrayToBigInt(arrayWithZeros)
+ const result = bigIntToUint8Array(bigint)
+ expect(result).toEqual(new Uint8Array([1, 2, 3]))
+ })
+
+ it('handles random large values correctly', () => {
+ // Generate some pseudo-random large bigints
+ const randomValues = [
+ 123456789012345678901234567890n,
+ 987654321098765432109876543210n,
+ 111111111111111111111111111111n,
+ ]
+ randomValues.forEach((value) => {
+ const array = bigIntToUint8Array(value)
+ const result = uint8ArrayToBigInt(array)
+ expect(result).toBe(value)
+ })
+ })
+ })
+
+ describe('bigIntToBase32', () => {
+ it('converts zero to the special character "0"', () => {
+ const result = bigIntToBase32(0n)
+ expect(result).toBe('0')
+ })
+
+ it('converts small positive values', () => {
+ expect(bigIntToBase32(1n)).toBe('ae')
+ })
+
+ it('converts single-byte values', () => {
+ expect(bigIntToBase32(255n)).toBe('74')
+ })
+
+ it('converts two-byte values', () => {
+ expect(bigIntToBase32(256n)).toBe('aeaa')
+ expect(bigIntToBase32(65535n)).toBe('777q')
+ })
+
+ it('converts large values', () => {
+ const large = 2n ** 64n - 1n
+ expect(bigIntToBase32(large)).toBe('7777777777776')
+ })
+
+ it('converts very large values (256-bit)', () => {
+ const veryLarge = 2n ** 256n - 1n
+ expect(bigIntToBase32(veryLarge)).toBe(
+ '777777777777777777777777777777777777777777777777777q',
+ )
+ })
+
+ it('handles powers of 2', () => {
+ expect(bigIntToBase32(2n ** 8n)).toBe('aeaa')
+ expect(bigIntToBase32(2n ** 16n)).toBe('aeaaa')
+ expect(bigIntToBase32(2n ** 32n)).toBe('aeaaaaaa')
+ expect(bigIntToBase32(2n ** 64n)).toBe('aeaaaaaaaaaaaaa')
+ })
+
+ it('throws error for negative values', () => {
+ expect(() => bigIntToBase32(-1n)).toThrow(
+ 'Cannot convert negative bigint to base32',
+ )
+ expect(() => bigIntToBase32(-100n)).toThrow()
+ })
+
+ it('throws error for non-bigint input', () => {
+ expect(() => bigIntToBase32(123)).toThrow(TypeError)
+ expect(() => bigIntToBase32('123')).toThrow(TypeError)
+ expect(() => bigIntToBase32(null)).toThrow(TypeError)
+ expect(() => bigIntToBase32(undefined)).toThrow(TypeError)
+ expect(() => bigIntToBase32({})).toThrow(TypeError)
+ expect(() => bigIntToBase32([])).toThrow(TypeError)
+ })
+ })
+
+ describe('base32ToBigInt', () => {
+ it('converts base32 strings to BigInt', () => {
+ const base32String = bigIntToBase32(12345n)
+ const result = base32ToBigInt(base32String)
+ expect(typeof result).toBe('bigint')
+ })
+
+ it('converts small values correctly', () => {
+ const original = 1n
+ const base32String = bigIntToBase32(original)
+ const result = base32ToBigInt(base32String)
+ expect(result).toBe(original)
+ })
+
+ it('converts medium values correctly', () => {
+ const original = 65535n
+ const base32String = bigIntToBase32(original)
+ const result = base32ToBigInt(base32String)
+ expect(result).toBe(original)
+ })
+
+ it('converts large values correctly', () => {
+ const original = 2n ** 64n - 1n
+ const base32String = bigIntToBase32(original)
+ const result = base32ToBigInt(base32String)
+ expect(result).toBe(original)
+ })
+
+ it('converts very large values correctly', () => {
+ const original = 2n ** 256n - 1n
+ const base32String = bigIntToBase32(original)
+ const result = base32ToBigInt(base32String)
+ expect(result).toBe(original)
+ })
+
+ it('handles zero value using special character "0"', () => {
+ const result = base32ToBigInt('0')
+ expect(result).toBe(0n)
+ })
+
+ it('throws error for non-string input', () => {
+ expect(() => base32ToBigInt(123)).toThrow(TypeError)
+ expect(() => base32ToBigInt(123n)).toThrow(TypeError)
+ expect(() => base32ToBigInt(null)).toThrow(TypeError)
+ expect(() => base32ToBigInt(undefined)).toThrow(TypeError)
+ expect(() => base32ToBigInt({})).toThrow(TypeError)
+ expect(() => base32ToBigInt([])).toThrow(TypeError)
+ })
+
+ it('throws error for invalid base32 strings', () => {
+ expect(() => base32ToBigInt('invalid!@#')).toThrow()
+ expect(() => base32ToBigInt('not-base32')).toThrow()
+ expect(() => base32ToBigInt('123')).toThrow() // numbers not in base32 alphabet
+ })
+ })
+
+ describe('Base32 Round-Trip Conversion', () => {
+ const TEST_CASES = [
+ // special case
+ 0n,
+ // small numbers
+ 1n,
+ 127n,
+ 128n,
+ 255n,
+ 256n,
+ 65535n,
+ 65536n,
+ // powers of 2
+ 2n ** 32n,
+ 2n ** 64n,
+ 2n ** 128n,
+ 2n ** 256n,
+ // powers of 2 minus 1
+ 2n ** 8n - 1n,
+ 2n ** 16n - 1n,
+ 2n ** 32n - 1n,
+ 2n ** 64n - 1n,
+ 2n ** 128n - 1n,
+ // random large values
+ 123456789012345678901234567890n,
+ 987654321098765432109876543210n,
+ 111111111111111111111111111111n,
+ ]
+
+ for (const tc of TEST_CASES) {
+ it(`preserves ${tc} during the round-trip`, () => {
+ const base32String = bigIntToBase32(tc)
+ const result = base32ToBigInt(base32String)
+ expect(result).toBe(tc)
+ })
+ }
+ })
+})
diff --git a/ipfs-retriever/test/request.test.js b/ipfs-retriever/test/request.test.js
new file mode 100644
index 00000000..ba472c7b
--- /dev/null
+++ b/ipfs-retriever/test/request.test.js
@@ -0,0 +1,286 @@
+import { describe, it, expect } from 'vitest'
+import { parseRequest } from '../lib/request.js'
+import { bigIntToBase32 } from '../lib/bigint-util.js'
+
+const DNS_ROOT = '.filbeam.io'
+const BOT_TOKENS = JSON.stringify({ secret: 'bot1' })
+
+describe('parseRequest', () => {
+ it('should parse dataSetId and pieceId from a slug URL', () => {
+ const dataSetId = '12345'
+ const pieceId = '67890'
+ const encodedDataSetId = bigIntToBase32(BigInt(dataSetId))
+ const encodedPieceId = bigIntToBase32(BigInt(pieceId))
+ const slug = `1-${encodedDataSetId}-${encodedPieceId}`
+
+ const request = new Request(`https://${slug}${DNS_ROOT}/`)
+ const result = parseRequest(request, { DNS_ROOT, BOT_TOKENS })
+
+ expect(result).toEqual({
+ dataSetId,
+ pieceId,
+ ipfsSubpath: '/',
+ ipfsFormat: null,
+ })
+ })
+
+ it('should parse subpath from URL pathname', () => {
+ const dataSetId = '100'
+ const pieceId = '200'
+ const encodedDataSetId = bigIntToBase32(BigInt(dataSetId))
+ const encodedPieceId = bigIntToBase32(BigInt(pieceId))
+ const slug = `1-${encodedDataSetId}-${encodedPieceId}`
+ const subpath = '/path/to/file.txt'
+
+ const request = new Request(`https://${slug}${DNS_ROOT}${subpath}`)
+ const result = parseRequest(request, { DNS_ROOT, BOT_TOKENS })
+
+ expect(result).toEqual({
+ dataSetId,
+ pieceId,
+ ipfsSubpath: subpath,
+ ipfsFormat: null,
+ })
+ })
+
+ it('should default to "/" for empty pathname', () => {
+ const dataSetId = '999'
+ const pieceId = '888'
+ const encodedDataSetId = bigIntToBase32(BigInt(dataSetId))
+ const encodedPieceId = bigIntToBase32(BigInt(pieceId))
+ const slug = `1-${encodedDataSetId}-${encodedPieceId}`
+
+ const request = new Request(`https://${slug}${DNS_ROOT}`)
+ const result = parseRequest(request, { DNS_ROOT, BOT_TOKENS })
+
+ expect(result).toEqual({
+ dataSetId,
+ pieceId,
+ ipfsSubpath: '/',
+ ipfsFormat: null,
+ })
+ })
+
+ it('should handle zero values for dataSetId and pieceId', () => {
+ const slug = '1-0-0'
+
+ const request = new Request(`https://${slug}${DNS_ROOT}/`)
+ const result = parseRequest(request, { DNS_ROOT, BOT_TOKENS })
+
+ expect(result).toEqual({
+ dataSetId: '0',
+ pieceId: '0',
+ ipfsSubpath: '/',
+ ipfsFormat: null,
+ })
+ })
+
+ it('should return descriptive error for invalid hostname format - missing parts', () => {
+ const request = new Request(`https://1-abc${DNS_ROOT}/`)
+ expect(() => parseRequest(request, { DNS_ROOT, BOT_TOKENS })).toThrowError(
+ `The hostname must be in the format: 1-{dataSetId}-{pieceId}${DNS_ROOT}`,
+ )
+ })
+
+ it('should return descriptive error for invalid hostname format - too many parts', () => {
+ const request = new Request(`https://1-abc-def-ghi${DNS_ROOT}/`)
+ expect(() => parseRequest(request, { DNS_ROOT, BOT_TOKENS })).toThrowError(
+ `The hostname must be in the format: 1-{dataSetId}-{pieceId}${DNS_ROOT}`,
+ )
+ })
+
+ it('should return descriptive error for invalid hostname format - no dashes', () => {
+ const request = new Request(`https://1abc${DNS_ROOT}/`)
+ expect(() => parseRequest(request, { DNS_ROOT, BOT_TOKENS })).toThrowError(
+ `The hostname must be in the format: 1-{dataSetId}-{pieceId}${DNS_ROOT}`,
+ )
+ })
+
+ it('should return descriptive error for missing dataSetId', () => {
+ const request = new Request(`https://1--abc${DNS_ROOT}/`)
+ expect(() => parseRequest(request, { DNS_ROOT, BOT_TOKENS })).toThrowError(
+ `The hostname must be in the format: 1-{dataSetId}-{pieceId}${DNS_ROOT}`,
+ )
+ })
+
+ it('should return descriptive error for missing pieceId', () => {
+ const request = new Request(`https://1-abc-${DNS_ROOT}/`)
+ expect(() => parseRequest(request, { DNS_ROOT, BOT_TOKENS })).toThrowError(
+ `The hostname must be in the format: 1-{dataSetId}-{pieceId}${DNS_ROOT}`,
+ )
+ })
+
+ it('should return descriptive error for unsupported version', () => {
+ const dataSetId = '12345'
+ const pieceId = '67890'
+ const encodedDataSetId = bigIntToBase32(BigInt(dataSetId))
+ const encodedPieceId = bigIntToBase32(BigInt(pieceId))
+ const slug = `2-${encodedDataSetId}-${encodedPieceId}`
+
+ const request = new Request(`https://${slug}${DNS_ROOT}/`)
+ expect(() => parseRequest(request, { DNS_ROOT, BOT_TOKENS })).toThrowError(
+ 'Unsupported slug version: 2. Expected version 1.',
+ )
+ })
+
+ it('should return descriptive error for invalid base32 dataSetId', () => {
+ const request = new Request(`https://1-invalid1-aeete${DNS_ROOT}/`)
+ expect(() => parseRequest(request, { DNS_ROOT, BOT_TOKENS })).toThrowError(
+ /Invalid dataSetId encoding in slug: invalid1/,
+ )
+ })
+
+ it('should return descriptive error for invalid base32 pieceId', () => {
+ const request = new Request(`https://1-ga4q-invalid1${DNS_ROOT}/`)
+ expect(() => parseRequest(request, { DNS_ROOT, BOT_TOKENS })).toThrowError(
+ /Invalid pieceId encoding in slug: invalid1/,
+ )
+ })
+
+ it('should return error for wrong DNS root', () => {
+ const dataSetId = '12345'
+ const pieceId = '67890'
+ const encodedDataSetId = bigIntToBase32(BigInt(dataSetId))
+ const encodedPieceId = bigIntToBase32(BigInt(pieceId))
+ const slug = `1-${encodedDataSetId}-${encodedPieceId}`
+
+ const request = new Request(`https://${slug}.wrong.io/`)
+ expect(() => parseRequest(request, { DNS_ROOT, BOT_TOKENS })).toThrowError(
+ `Invalid hostname: ${slug}.wrong.io. It must end with ${DNS_ROOT}.`,
+ )
+ })
+
+ it('should ignore query parameters', () => {
+ const dataSetId = '12345'
+ const pieceId = '67890'
+ const encodedDataSetId = bigIntToBase32(BigInt(dataSetId))
+ const encodedPieceId = bigIntToBase32(BigInt(pieceId))
+ const slug = `1-${encodedDataSetId}-${encodedPieceId}`
+ const subpath = '/file.txt'
+
+ const request = new Request(
+ `https://${slug}${DNS_ROOT}${subpath}?foo=bar&baz=qux`,
+ )
+ const result = parseRequest(request, { DNS_ROOT, BOT_TOKENS })
+
+ expect(result).toEqual({
+ dataSetId,
+ pieceId,
+ ipfsSubpath: subpath,
+ ipfsFormat: null,
+ })
+ })
+
+ it('should parse format=car from URL with subpath', () => {
+ const dataSetId = '100'
+ const pieceId = '200'
+ const encodedDataSetId = bigIntToBase32(BigInt(dataSetId))
+ const encodedPieceId = bigIntToBase32(BigInt(pieceId))
+ const slug = `1-${encodedDataSetId}-${encodedPieceId}`
+ const subpath = '/path/to/file.txt'
+
+ const request = new Request(
+ `https://${slug}${DNS_ROOT}${subpath}?format=car`,
+ )
+ const result = parseRequest(request, { DNS_ROOT, BOT_TOKENS })
+
+ expect(result).toEqual({
+ dataSetId,
+ pieceId,
+ ipfsSubpath: subpath,
+ ipfsFormat: 'car',
+ })
+ })
+
+ it('should parse any format value from URL', () => {
+ const dataSetId = '12345'
+ const pieceId = '67890'
+ const encodedDataSetId = bigIntToBase32(BigInt(dataSetId))
+ const encodedPieceId = bigIntToBase32(BigInt(pieceId))
+ const slug = `1-${encodedDataSetId}-${encodedPieceId}`
+
+ const request = new Request(`https://${slug}${DNS_ROOT}/?format=raw`)
+ const result = parseRequest(request, { DNS_ROOT, BOT_TOKENS })
+
+ expect(result).toEqual({
+ dataSetId,
+ pieceId,
+ ipfsSubpath: '/',
+ ipfsFormat: 'raw',
+ })
+ })
+
+ it('should return null for ipfsFormat when format parameter is not present', () => {
+ const dataSetId = '12345'
+ const pieceId = '67890'
+ const encodedDataSetId = bigIntToBase32(BigInt(dataSetId))
+ const encodedPieceId = bigIntToBase32(BigInt(pieceId))
+ const slug = `1-${encodedDataSetId}-${encodedPieceId}`
+
+ const request = new Request(`https://${slug}${DNS_ROOT}/file.txt`)
+ const result = parseRequest(request, { DNS_ROOT, BOT_TOKENS })
+
+ expect(result).toEqual({
+ dataSetId,
+ pieceId,
+ ipfsSubpath: '/file.txt',
+ ipfsFormat: null,
+ })
+ })
+
+ it('should preserve trailing slash in subpath', () => {
+ const dataSetId = '12345'
+ const pieceId = '67890'
+ const encodedDataSetId = bigIntToBase32(BigInt(dataSetId))
+ const encodedPieceId = bigIntToBase32(BigInt(pieceId))
+ const slug = `1-${encodedDataSetId}-${encodedPieceId}`
+ const subpath = '/directory/'
+
+ const request = new Request(`https://${slug}${DNS_ROOT}${subpath}`)
+ const result = parseRequest(request, { DNS_ROOT, BOT_TOKENS })
+
+ expect(result).toEqual({
+ dataSetId,
+ pieceId,
+ ipfsSubpath: subpath,
+ ipfsFormat: null,
+ })
+ })
+
+ it('should handle encoded characters in subpath', () => {
+ const dataSetId = '12345'
+ const pieceId = '67890'
+ const encodedDataSetId = bigIntToBase32(BigInt(dataSetId))
+ const encodedPieceId = bigIntToBase32(BigInt(pieceId))
+ const slug = `1-${encodedDataSetId}-${encodedPieceId}`
+ const subpath = '/file%20with%20spaces.txt'
+
+ const request = new Request(`https://${slug}${DNS_ROOT}${subpath}`)
+ const result = parseRequest(request, { DNS_ROOT, BOT_TOKENS })
+
+ expect(result).toEqual({
+ dataSetId,
+ pieceId,
+ ipfsSubpath: subpath,
+ ipfsFormat: null,
+ })
+ })
+
+ it('should handle large BigInt values', () => {
+ const dataSetId = '999999999999999999'
+ const pieceId = '888888888888888888'
+ const encodedDataSetId = bigIntToBase32(BigInt(dataSetId))
+ const encodedPieceId = bigIntToBase32(BigInt(pieceId))
+ const slug = `1-${encodedDataSetId}-${encodedPieceId}`
+
+ const request = new Request(`https://${slug}${DNS_ROOT}/`)
+ const result = parseRequest(request, { DNS_ROOT, BOT_TOKENS })
+
+ expect(result).toEqual({
+ dataSetId,
+ pieceId,
+ ipfsSubpath: '/',
+ ipfsFormat: null,
+ })
+ })
+})
diff --git a/ipfs-retriever/test/retrieval.test.js b/ipfs-retriever/test/retrieval.test.js
new file mode 100644
index 00000000..a47dfedd
--- /dev/null
+++ b/ipfs-retriever/test/retrieval.test.js
@@ -0,0 +1,239 @@
+import { describe, it, expect, vi, beforeEach } from 'vitest'
+import {
+ retrieveIpfsContent,
+ getRetrievalUrl,
+ processIpfsResponse,
+} from '../lib/retrieval.js'
+import { buildRawBlockCar } from './test-data-builders.js'
+
+describe('retrieveIpfsContent', () => {
+ const baseUrl = 'https://example.com'
+ const ipfsRootCid =
+ 'bafybeigdyrzt5sfp7udm7hu76uh7y26nf3efuylqabf3oclgtqy55fbzdi'
+ const defaultCacheTtl = 86400
+ let fetchMock
+
+ beforeEach(() => {
+ fetchMock = vi
+ .fn()
+ .mockResolvedValue({ ok: true, status: 200, headers: new Headers({}) })
+ global.fetch = fetchMock
+ })
+
+ it('constructs the correct URL with root path', async () => {
+ await retrieveIpfsContent(baseUrl, ipfsRootCid, '/')
+ expect(fetchMock).toHaveBeenCalledWith(
+ `${baseUrl}/ipfs/${ipfsRootCid}?format=car`,
+ expect.any(Object),
+ )
+ })
+
+ it('constructs the correct URL with subpath', async () => {
+ const subpath = '/path/to/file.txt'
+ await retrieveIpfsContent(baseUrl, ipfsRootCid, subpath)
+ expect(fetchMock).toHaveBeenCalledWith(
+ `${baseUrl}/ipfs/${ipfsRootCid}${subpath}?format=car`,
+ expect.any(Object),
+ )
+ })
+
+ it('constructs the correct URL with nested subpath', async () => {
+ const subpath = '/deep/nested/directory/file.json'
+ await retrieveIpfsContent(baseUrl, ipfsRootCid, subpath)
+ expect(fetchMock).toHaveBeenCalledWith(
+ `${baseUrl}/ipfs/${ipfsRootCid}${subpath}?format=car`,
+ expect.any(Object),
+ )
+ })
+
+ it('uses the default cacheTtl if not provided', async () => {
+ await retrieveIpfsContent(baseUrl, ipfsRootCid, '/')
+ const options = fetchMock.mock.calls[0][1]
+ expect(options.cf.cacheTtlByStatus['200-299']).toBe(defaultCacheTtl)
+ })
+
+ it('uses the provided cacheTtl', async () => {
+ await retrieveIpfsContent(baseUrl, ipfsRootCid, '/', 1234)
+ const options = fetchMock.mock.calls[0][1]
+ expect(options.cf.cacheTtlByStatus['200-299']).toBe(1234)
+ })
+
+ it('sets correct cacheTtlByStatus and cacheEverything', async () => {
+ await retrieveIpfsContent(baseUrl, ipfsRootCid, '/', 555)
+ const options = fetchMock.mock.calls[0][1]
+ expect(options.cf).toEqual({
+ cacheTtlByStatus: {
+ '200-299': 555,
+ 404: 0,
+ '500-599': 0,
+ },
+ cacheEverything: true,
+ })
+ })
+
+ it('passes the signal option correctly', async () => {
+ const signal = new AbortController().signal
+ await retrieveIpfsContent(baseUrl, ipfsRootCid, '/', 86400, { signal })
+ const options = fetchMock.mock.calls[0][1]
+ expect(options.signal).toBe(signal)
+ })
+
+ it('returns the fetch response and cache miss status', async () => {
+ const response = { ok: true, status: 200, headers: new Headers({}) }
+ fetchMock.mockResolvedValueOnce(response)
+ const result = await retrieveIpfsContent(baseUrl, ipfsRootCid, '/')
+ expect(result.response).toBe(response)
+ expect(result.cacheMiss).toBe(true) // No CF-Cache-Status header means cache miss
+ })
+
+ it('detects cache hit from CF-Cache-Status header', async () => {
+ const headers = new Headers({ 'CF-Cache-Status': 'HIT' })
+ const response = { ok: true, status: 200, headers }
+ fetchMock.mockResolvedValueOnce(response)
+ const result = await retrieveIpfsContent(baseUrl, ipfsRootCid, '/')
+ expect(result.cacheMiss).toBe(false)
+ })
+
+ it('detects cache miss from CF-Cache-Status header', async () => {
+ const headers = new Headers({ 'CF-Cache-Status': 'MISS' })
+ const response = { ok: true, status: 200, headers }
+ fetchMock.mockResolvedValueOnce(response)
+ const result = await retrieveIpfsContent(baseUrl, ipfsRootCid, '/')
+ expect(result.cacheMiss).toBe(true)
+ })
+
+ it('always appends format=car query parameter', async () => {
+ await retrieveIpfsContent(baseUrl, ipfsRootCid, '/file.txt')
+ expect(fetchMock).toHaveBeenCalledWith(
+ `${baseUrl}/ipfs/${ipfsRootCid}/file.txt?format=car`,
+ expect.any(Object),
+ )
+ })
+})
+
+describe('getRetrievalUrl', () => {
+ it('constructs URL with root path', () => {
+ const url = getRetrievalUrl('https://example.com', 'bafy123abc', '/')
+ expect(url).toBe('https://example.com/ipfs/bafy123abc')
+ })
+
+ it('constructs URL with subpath', () => {
+ const url = getRetrievalUrl(
+ 'https://example.com',
+ 'bafy123abc',
+ '/file.txt',
+ )
+ expect(url).toBe('https://example.com/ipfs/bafy123abc/file.txt')
+ })
+
+ it('constructs URL with nested subpath', () => {
+ const url = getRetrievalUrl(
+ 'https://example.com',
+ 'bafy123abc',
+ '/path/to/file.json',
+ )
+ expect(url).toBe('https://example.com/ipfs/bafy123abc/path/to/file.json')
+ })
+
+ it('avoids double slash in path when the base URL ends with a slash', () => {
+ const url = getRetrievalUrl(
+ 'https://example.com/',
+ 'bafy123abc',
+ '/file.txt',
+ )
+ expect(url).toBe('https://example.com/ipfs/bafy123abc/file.txt')
+ })
+
+ it('handles subpath with trailing slash', () => {
+ const url = getRetrievalUrl(
+ 'https://example.com',
+ 'bafy123abc',
+ '/directory/',
+ )
+ expect(url).toBe('https://example.com/ipfs/bafy123abc/directory/')
+ })
+
+ it('handles empty subpath correctly', () => {
+ const url = getRetrievalUrl('https://example.com', 'bafy123abc', '')
+ expect(url).toBe('https://example.com/ipfs/bafy123abc')
+ })
+
+ it('preserves special characters in subpath', () => {
+ const url = getRetrievalUrl(
+ 'https://example.com',
+ 'bafy123abc',
+ '/file%20with%20spaces.txt',
+ )
+ expect(url).toBe(
+ 'https://example.com/ipfs/bafy123abc/file%20with%20spaces.txt',
+ )
+ })
+})
+
+describe('processIpfsResponse', () => {
+ it('converts CAR to raw, reports the CAR size, and adjusts the headers for raw delivery', async () => {
+ const fileBytes = new Uint8Array(1000).fill(7)
+ const { carBytes, rootCid } = await buildRawBlockCar(fileBytes)
+ expect(carBytes.length).toBeGreaterThan(fileBytes.length)
+
+ const { body, getOriginEgressBytes, headers } = await processIpfsResponse(
+ new Response(carBytes, {
+ status: 200,
+ headers: {
+ 'content-type': 'application/vnd.ipld.car',
+ 'x-content-type-options': 'nosniff',
+ },
+ }),
+ { ipfsRootCid: rootCid, ipfsSubpath: '/', ipfsFormat: null },
+ )
+
+ const served = new Uint8Array(await new Response(body).arrayBuffer())
+ expect(served).toEqual(fileBytes)
+ // The CAR is streamed lazily, so the count is only final once the body has
+ // been consumed. It reports the full CAR fetched from the SP, not the raw
+ // bytes.
+ expect(getOriginEgressBytes()).toBe(carBytes.length)
+ // The browser should display the raw content and sniff its type.
+ expect(headers.get('content-disposition')).toBe('inline')
+ expect(headers.get('content-type')).toBe(null)
+ expect(headers.get('x-content-type-options')).toBe(null)
+ })
+
+ it('passes the body and headers through unchanged for ?format=car with null originEgressBytes', async () => {
+ const carBytes = new Uint8Array([1, 2, 3, 4])
+ const response = new Response(carBytes, {
+ status: 200,
+ headers: { 'content-type': 'application/vnd.ipld.car' },
+ })
+
+ const { body, getOriginEgressBytes, headers } = await processIpfsResponse(
+ response,
+ {
+ ipfsRootCid: 'bafyroot',
+ ipfsSubpath: '/',
+ ipfsFormat: 'car',
+ },
+ )
+
+ expect(getOriginEgressBytes()).toBe(null)
+ expect(new Uint8Array(await new Response(body).arrayBuffer())).toEqual(
+ carBytes,
+ )
+ // CAR is served as-is, so the upstream content type is preserved.
+ expect(headers.get('content-type')).toBe('application/vnd.ipld.car')
+ expect(headers.get('content-disposition')).toBe(null)
+ })
+
+ it('passes the body through unchanged for non-ok responses with null originEgressBytes', async () => {
+ const response = new Response('not found', { status: 404 })
+
+ const { body, getOriginEgressBytes } = await processIpfsResponse(response, {
+ ipfsRootCid: 'bafyroot',
+ ipfsSubpath: '/',
+ ipfsFormat: null,
+ })
+
+ expect(getOriginEgressBytes()).toBe(null)
+ expect(await new Response(body).text()).toBe('not found')
+ })
+})
diff --git a/ipfs-retriever/test/retriever.test.js b/ipfs-retriever/test/retriever.test.js
new file mode 100644
index 00000000..e20cc596
--- /dev/null
+++ b/ipfs-retriever/test/retriever.test.js
@@ -0,0 +1,1289 @@
+import { describe, it, expect, vi, beforeAll } from 'vitest'
+import worker from '../bin/ipfs-retriever.js'
+import { createHash } from 'node:crypto'
+import { retrieveIpfsContent } from '../lib/retrieval.js'
+import {
+ env,
+ createExecutionContext,
+ waitOnExecutionContext,
+} from 'cloudflare:test'
+import assert from 'node:assert/strict'
+import {
+ withDataSetPiece,
+ withApprovedProvider,
+ withBadBits,
+ withWalletDetails,
+ buildRawBlockCar,
+} from './test-data-builders.js'
+import { CONTENT_STORED_ON_CALIBRATION } from './test-data.js'
+import { buildSlug } from '../lib/store.js'
+
+function sleep(ms) {
+ return new Promise((resolve) => setTimeout(resolve, ms))
+}
+
+/**
+ * Calls the worker and drains the response body so the back-pressured egress
+ * measurement (which only advances as the client reads) completes before the
+ * test waits on the execution context. Returns a re-readable response with the
+ * same status, headers and bytes. A body-less response is returned unchanged.
+ *
+ * @param {Request} req
+ * @param {Env} env
+ * @param {ExecutionContext} ctx
+ * @param {object} [options]
+ */
+async function fetchAndRead(req, env, ctx, options = {}) {
+ const res = await worker.fetch(req, env, ctx, options)
+ if (!res.body) return res
+ return new Response(await res.arrayBuffer(), res)
+}
+
+const DNS_ROOT = '.ipfs.filbeam.io'
+env.DNS_ROOT = DNS_ROOT
+const botTokens = { secret: 'testbot' }
+env.BOT_TOKENS = JSON.stringify(botTokens)
+
+describe('retriever.fetch', () => {
+ const defaultPayerAddress = '0x1234567890abcdef1234567890abcdef12345678'
+ const {
+ ipfsRootCid: realIpfsRootCid,
+ dataSetId,
+ pieceId: realPieceId,
+ } = CONTENT_STORED_ON_CALIBRATION[0]
+ const realDataSetId = String(dataSetId)
+
+ beforeAll(async () => {
+ await env.DB.batch([
+ env.DB.prepare('DELETE FROM pieces'),
+ env.DB.prepare('DELETE FROM data_sets'),
+ env.DB.prepare('DELETE FROM wallet_details'),
+ ])
+
+ let cursor
+ while (true) {
+ const list = await env.BAD_BITS_KV.list({ cursor })
+ for (const key of list.keys) {
+ await env.BAD_BITS_KV.delete(key)
+ }
+ if (list.list_complete) break
+ cursor = list.cursor
+ }
+
+ for (const {
+ serviceProviderId,
+ serviceUrl,
+ pieceCid,
+ ipfsRootCid,
+ dataSetId,
+ pieceId,
+ } of CONTENT_STORED_ON_CALIBRATION) {
+ await withDataSetPiece(env, {
+ serviceProviderId,
+ pieceCid,
+ ipfsRootCid,
+ payerAddress: defaultPayerAddress,
+ withCDN: true,
+ withIpfsIndexing: true,
+ dataSetId: String(dataSetId),
+ pieceId,
+ })
+ await withApprovedProvider(env, {
+ id: serviceProviderId,
+ serviceUrl,
+ })
+ }
+ })
+
+ it('redirects to https://filbeam.com when no CID and no wallet address were provided', async () => {
+ const ctx = createExecutionContext()
+ const req = new Request(`https://${DNS_ROOT.slice(1)}/`)
+ const res = await fetchAndRead(req, env, ctx)
+ await waitOnExecutionContext(ctx)
+ expect(res.status).toBe(302)
+ expect(res.headers.get('Location')).toBe('https://filbeam.com/')
+ })
+
+ it('returns 404 for invalid path format on DNS_ROOT (missing CID)', async () => {
+ const ctx = createExecutionContext()
+ const req = new Request(
+ `https://${DNS_ROOT.slice(1)}/${defaultPayerAddress}`,
+ )
+ const res = await fetchAndRead(req, env, ctx)
+ await waitOnExecutionContext(ctx)
+ expect(res.status).toBe(404)
+ expect(await res.text()).toContain('Invalid path format')
+ })
+
+ it('returns 404 for invalid wallet address on DNS_ROOT path', async () => {
+ const ctx = createExecutionContext()
+ const invalidWallet = 'invalid-wallet'
+ const ipfsRootCid = 'bafk4testslug1'
+ const req = new Request(
+ `https://${DNS_ROOT.slice(1)}/${invalidWallet}/${ipfsRootCid}`,
+ )
+ const res = await fetchAndRead(req, env, ctx)
+ await waitOnExecutionContext(ctx)
+ expect(res.status).toBe(404)
+ expect(await res.text()).toContain('Invalid wallet address')
+ })
+
+ it('redirects to slug subdomain when valid wallet and CID are provided on DNS_ROOT path', async () => {
+ // Set up test data with numeric pieceId and dataSetId for slug generation
+ const testPayerAddress = '0xabcdef1234567890abcdef1234567890abcdef99'
+ const testIpfsRootCid = 'bafk4testslug2'
+ const testDataSetId = '12345'
+ const testPieceId = '67890'
+ const serviceProviderId = '100'
+
+ await withDataSetPiece(env, {
+ serviceProviderId,
+ payerAddress: testPayerAddress,
+ ipfsRootCid: testIpfsRootCid,
+ dataSetId: testDataSetId,
+ pieceId: testPieceId,
+ withCDN: true,
+ withIpfsIndexing: true,
+ })
+ await withApprovedProvider(env, {
+ id: serviceProviderId,
+ serviceUrl: 'https://test-provider.example.com',
+ })
+
+ const ctx = createExecutionContext()
+ const req = new Request(
+ `https://${DNS_ROOT.slice(1)}/${testPayerAddress}/${testIpfsRootCid}`,
+ )
+ const res = await fetchAndRead(req, env, ctx)
+ await waitOnExecutionContext(ctx)
+ expect(res.status).toBe(302)
+ const location = res.headers.get('Location')
+ // Expected slug: 1-ga4q-aeete (version-base32(12345)-base32(67890))
+ expect(location).toBe('https://1-ga4q-aeete.ipfs.filbeam.io/')
+ })
+
+ it('accepts mixed case addresses', async () => {
+ const testPayerAddress = '0xabcdef1234567890abcdef1234567890abcdef99'
+ const testIpfsRootCid = 'bafk4testslug2'
+ const testDataSetId = '12345'
+ const testPieceId = '67890'
+ const serviceProviderId = '100'
+
+ await withDataSetPiece(env, {
+ serviceProviderId,
+ payerAddress: testPayerAddress,
+ ipfsRootCid: testIpfsRootCid,
+ dataSetId: testDataSetId,
+ pieceId: testPieceId,
+ withCDN: true,
+ withIpfsIndexing: true,
+ })
+ await withApprovedProvider(env, {
+ id: serviceProviderId,
+ serviceUrl: 'https://test-provider.example.com',
+ })
+
+ const ctx = createExecutionContext()
+ const req = new Request(
+ `https://${DNS_ROOT.slice(1)}/${testPayerAddress.toUpperCase()}/${testIpfsRootCid}`,
+ )
+ const res = await fetchAndRead(req, env, ctx)
+ await waitOnExecutionContext(ctx)
+ expect(res.status).toBe(302)
+ const location = res.headers.get('Location')
+ expect(location).toBe('https://1-ga4q-aeete.ipfs.filbeam.io/')
+ })
+
+ it('redirects to slug subdomain with subpath when wallet, CID, and pathname are provided on DNS_ROOT path', async () => {
+ // Set up test data with numeric pieceId and dataSetId for slug generation
+ const testPayerAddress = '0xabcdef1234567890abcdef1234567890abcdef98'
+ const testIpfsRootCid = 'bafk4testslug3'
+ const testDataSetId = '54321'
+ const testPieceId = '98765'
+ const serviceProviderId = '101'
+
+ await withDataSetPiece(env, {
+ serviceProviderId,
+ payerAddress: testPayerAddress,
+ ipfsRootCid: testIpfsRootCid,
+ dataSetId: testDataSetId,
+ pieceId: testPieceId,
+ withCDN: true,
+ withIpfsIndexing: true,
+ })
+ await withApprovedProvider(env, {
+ id: serviceProviderId,
+ serviceUrl: 'https://test-provider2.example.com',
+ })
+
+ const ctx = createExecutionContext()
+ const subpath = 'path/to/file.txt'
+ const req = new Request(
+ `https://${DNS_ROOT.slice(1)}/${testPayerAddress}/${testIpfsRootCid}/${subpath}`,
+ )
+ const res = await fetchAndRead(req, env, ctx)
+ await waitOnExecutionContext(ctx)
+ expect(res.status).toBe(302)
+ const location = res.headers.get('Location')
+ // Expected slug: 1-2qyq-aga42 (version-base32(54321)-base32(98765))
+ expect(location).toBe(
+ 'https://1-2qyq-aga42.ipfs.filbeam.io/path/to/file.txt',
+ )
+ })
+
+ it('redirects to https://*.filcdn.io/* when old domain was used', async () => {
+ const ctx = createExecutionContext()
+ const req = new Request(`https://foo.filcdn.io/bar`)
+ const res = await fetchAndRead(req, env, ctx)
+ await waitOnExecutionContext(ctx)
+ expect(res.status).toBe(301)
+ expect(res.headers.get('Location')).toBe(`https://foo.filbeam.io/bar`)
+ })
+
+ it('returns 405 for unsupported request methods', async () => {
+ const ctx = createExecutionContext()
+ const req = withRequest('1', '1', 'POST')
+ const res = await fetchAndRead(req, env, ctx)
+ await waitOnExecutionContext(ctx)
+ expect(res.status).toBe(405)
+ expect(await res.text()).toBe('Method Not Allowed')
+ })
+
+ it('returns 400 if required fields are missing', async () => {
+ const ctx = createExecutionContext()
+ const mockRetrieveIpfsContent = vi.fn()
+ const req = new Request(
+ `http://${buildSlug(BigInt(realDataSetId), BigInt(realPieceId)).replace(/^(1-)/, '')}.${DNS_ROOT.slice(1)}`,
+ )
+ const res = await fetchAndRead(req, env, ctx, {
+ retrieveIpfsContent: mockRetrieveIpfsContent,
+ })
+ await waitOnExecutionContext(ctx)
+ expect(res.status).toBe(400)
+ })
+
+ it('returns 400 if slug has invalid base32 encoding', async () => {
+ const ctx = createExecutionContext()
+ const mockRetrieveIpfsContent = vi.fn()
+ const req = new Request(
+ `http://${buildSlug(BigInt(realDataSetId), BigInt(realPieceId))}1.${DNS_ROOT.slice(1)}`,
+ )
+ const res = await fetchAndRead(req, env, ctx, {
+ retrieveIpfsContent: mockRetrieveIpfsContent,
+ })
+ await waitOnExecutionContext(ctx)
+ expect(res.status).toBe(400)
+ expect(await res.text()).toContain('Invalid pieceId encoding in slug')
+ })
+
+ it('returns the response from retrieveIpfsContent', async () => {
+ const fakeResponse = new Response('hello', {
+ status: 201,
+ headers: { 'X-Test': 'yes' },
+ })
+ const mockRetrieveIpfsContent = vi.fn().mockResolvedValue({
+ response: fakeResponse,
+ cacheMiss: true,
+ })
+ const ctx = createExecutionContext()
+ const req = withRequest(realDataSetId, realPieceId)
+ const res = await fetchAndRead(req, env, ctx, {
+ retrieveIpfsContent: mockRetrieveIpfsContent,
+ })
+ await waitOnExecutionContext(ctx)
+ expect(await res.text()).toBe('hello')
+ expect(res.status).toBe(201)
+ expect(res.headers.get('X-Test')).toBe('yes')
+ })
+
+ it('sets Content-Control response header', async () => {
+ const originResponse = new Response('hello')
+ const mockRetrieveIpfsContent = vi.fn().mockResolvedValue({
+ response: originResponse,
+ cacheMiss: true,
+ })
+ const ctx = createExecutionContext()
+ const req = withRequest(realDataSetId, realPieceId)
+ const res = await fetchAndRead(req, env, ctx, {
+ retrieveIpfsContent: mockRetrieveIpfsContent,
+ })
+ await waitOnExecutionContext(ctx)
+ const cacheControlHeaders = res.headers.get('Cache-Control')
+ expect(cacheControlHeaders).toContain('public')
+ expect(cacheControlHeaders).toContain(`max-age=${env.CLIENT_CACHE_TTL}`)
+ })
+
+ it('sets Content-Control response on empty body', async () => {
+ const originResponse = new Response(null)
+ const mockRetrieveIpfsContent = vi.fn().mockResolvedValue({
+ response: originResponse,
+ cacheMiss: false,
+ })
+ const ctx = createExecutionContext()
+ const req = withRequest(realDataSetId, realPieceId)
+ const res = await fetchAndRead(req, env, ctx, {
+ retrieveIpfsContent: mockRetrieveIpfsContent,
+ })
+ await waitOnExecutionContext(ctx)
+ const cacheControlHeaders = res.headers.get('Cache-Control')
+ expect(cacheControlHeaders).toContain('public')
+ expect(cacheControlHeaders).toContain(`max-age=${env.CLIENT_CACHE_TTL}`)
+ })
+
+ it('sets Content-Security-Policy response header', async () => {
+ const originResponse = new Response('hello', {
+ headers: {
+ 'Content-Security-Policy': 'report-uri: https://endpoint.example.com',
+ },
+ })
+ const mockRetrieveIpfsContent = vi.fn().mockResolvedValue({
+ response: originResponse,
+ cacheMiss: true,
+ })
+ const ctx = createExecutionContext()
+ const req = withRequest(realDataSetId, realPieceId)
+ const res = await fetchAndRead(req, env, ctx, {
+ retrieveIpfsContent: mockRetrieveIpfsContent,
+ })
+ await waitOnExecutionContext(ctx)
+ const csp = res.headers.get('Content-Security-Policy')
+ expect(csp).toMatch(/^default-src 'self'/)
+ expect(csp).toContain('https://*.filbeam.io')
+ })
+
+ it('fetches the file from calibration service provider', async () => {
+ // The default request format is `car`, so the worker serves the CAR file
+ // unchanged. This is the sha256 of the CAR served for the dataset above.
+ const expectedHash =
+ 'd895b1ec0e1fbde5ba2ad3b927e4ea43dcd126e11ddfd9930027a0f594bbe002'
+ const ctx = createExecutionContext()
+ const req = withRequest(realDataSetId, realPieceId)
+ const res = await fetchAndRead(req, env, ctx, { retrieveIpfsContent })
+ await waitOnExecutionContext(ctx)
+ expect(res.status).toBe(200)
+ // get the sha256 hash of the content
+ const content = await res.bytes()
+ const hash = createHash('sha256').update(content).digest('hex')
+ expect(hash).toEqual(expectedHash)
+ })
+ it('stores retrieval results with cache miss and content length set in D1', async () => {
+ const body = 'file content'
+ const expectedEgressBytes = Buffer.byteLength(body, 'utf8')
+ const fakeResponse = new Response(body, {
+ status: 200,
+ headers: {
+ 'CF-Cache-Status': 'MISS',
+ },
+ })
+ const mockRetrieveIpfsContent = vi.fn().mockResolvedValue({
+ response: fakeResponse,
+ cacheMiss: true,
+ })
+ const ctx = createExecutionContext()
+ const req = withRequest(realDataSetId, realPieceId)
+ const res = await fetchAndRead(req, env, ctx, {
+ retrieveIpfsContent: mockRetrieveIpfsContent,
+ })
+ await waitOnExecutionContext(ctx)
+ assert.strictEqual(res.status, 200)
+ const readOutput = await env.DB.prepare(
+ `SELECT id, response_status, egress_bytes, cache_miss
+ FROM retrieval_logs
+ WHERE data_set_id = ?`,
+ )
+ .bind(String(realDataSetId))
+ .all()
+ const result = readOutput.results
+ assert.deepStrictEqual(result, [
+ {
+ id: 1, // Assuming this is the first log entry
+ response_status: 200,
+ egress_bytes: expectedEgressBytes,
+ cache_miss: 1, // 1 for true, 0 for false
+ },
+ ])
+ })
+ it('stores retrieval results with cache hit and content length set in D1', async () => {
+ const body = 'file content'
+ const expectedEgressBytes = Buffer.byteLength(body, 'utf8')
+ const fakeResponse = new Response(body, {
+ status: 200,
+ headers: {
+ 'CF-Cache-Status': 'HIT',
+ },
+ })
+ const mockRetrieveIpfsContent = vi.fn().mockResolvedValue({
+ response: fakeResponse,
+ cacheMiss: false,
+ })
+ const ctx = createExecutionContext()
+ const req = withRequest(realDataSetId, realPieceId)
+ const res = await fetchAndRead(req, env, ctx, {
+ retrieveIpfsContent: mockRetrieveIpfsContent,
+ })
+ await waitOnExecutionContext(ctx)
+ assert.strictEqual(res.status, 200)
+ const readOutput = await env.DB.prepare(
+ `SELECT id, response_status, egress_bytes, cache_miss
+ FROM retrieval_logs
+ WHERE data_set_id = ?`,
+ )
+ .bind(String(realDataSetId))
+ .all()
+ const result = readOutput.results
+ assert.deepStrictEqual(result, [
+ {
+ id: 1, // Assuming this is the first log entry
+ response_status: 200,
+ egress_bytes: expectedEgressBytes,
+ cache_miss: 0, // 1 for true, 0 for false
+ },
+ ])
+ })
+ it('stores retrieval performance stats in D1', async () => {
+ const body = 'file content'
+ const fakeResponse = new Response(body, {
+ status: 200,
+ headers: {
+ 'CF-Cache-Status': 'MISS',
+ },
+ })
+ const mockRetrieveIpfsContent = async () => {
+ await sleep(1) // Simulate a delay
+ return {
+ response: fakeResponse,
+ cacheMiss: true,
+ }
+ }
+ const ctx = createExecutionContext()
+ const req = withRequest(realDataSetId, realPieceId)
+ const res = await fetchAndRead(req, env, ctx, {
+ retrieveIpfsContent: mockRetrieveIpfsContent,
+ })
+ await waitOnExecutionContext(ctx)
+ assert.strictEqual(res.status, 200)
+ const readOutput = await env.DB.prepare(
+ `SELECT
+ response_status,
+ fetch_ttfb,
+ fetch_ttlb,
+ worker_ttfb
+ FROM retrieval_logs
+ WHERE data_set_id = ?`,
+ )
+ .bind(String(realDataSetId))
+ .all()
+ assert.strictEqual(readOutput.results.length, 1)
+ const result = readOutput.results[0]
+
+ assert.strictEqual(result.response_status, 200)
+ assert.strictEqual(typeof result.fetch_ttfb, 'number')
+ assert.strictEqual(typeof result.fetch_ttlb, 'number')
+ assert.strictEqual(typeof result.worker_ttfb, 'number')
+ })
+ it('stores request country code in D1', async () => {
+ const body = 'file content'
+ const mockRetrieveIpfsContent = async () => {
+ return {
+ response: new Response(body, {
+ status: 200,
+ }),
+ cacheMiss: true,
+ }
+ }
+ const ctx = createExecutionContext()
+ const req = withRequest(realDataSetId, realPieceId, 'GET', {
+ 'CF-IPCountry': 'US',
+ })
+ const res = await fetchAndRead(req, env, ctx, {
+ retrieveIpfsContent: mockRetrieveIpfsContent,
+ })
+ await waitOnExecutionContext(ctx)
+ assert.strictEqual(res.status, 200)
+ const { results } = await env.DB.prepare(
+ `SELECT request_country_code
+ FROM retrieval_logs
+ WHERE data_set_id = ?`,
+ )
+ .bind(String(realDataSetId))
+ .all()
+ assert.deepStrictEqual(results, [
+ {
+ request_country_code: 'US',
+ },
+ ])
+ })
+ it('logs 0 egress bytes for empty body', async () => {
+ const fakeResponse = new Response(null, {
+ status: 200,
+ headers: {
+ 'CF-Cache-Status': 'MISS',
+ },
+ })
+ const mockRetrieveIpfsContent = vi.fn().mockResolvedValue({
+ response: fakeResponse,
+ cacheMiss: true,
+ })
+ const ctx = createExecutionContext()
+ const req = withRequest(realDataSetId, realPieceId)
+ const res = await fetchAndRead(req, env, ctx, {
+ retrieveIpfsContent: mockRetrieveIpfsContent,
+ })
+ await waitOnExecutionContext(ctx)
+ assert.strictEqual(res.status, 200)
+ const readOutput = await env.DB.prepare(
+ 'SELECT egress_bytes FROM retrieval_logs WHERE data_set_id = ?',
+ )
+ .bind(String(realDataSetId))
+ .all()
+ assert.strictEqual(readOutput.results.length, 1)
+ assert.strictEqual(readOutput.results[0].egress_bytes, 0)
+ })
+
+ it('logs a 900 retrieval result when the response stream errors', async () => {
+ const erroringBody = new ReadableStream({
+ pull(controller) {
+ controller.enqueue(new Uint8Array([1, 2, 3]))
+ controller.error(new Error('stream boom'))
+ },
+ })
+ const mockRetrieveIpfsContent = vi.fn().mockResolvedValue({
+ response: new Response(erroringBody, { status: 200 }),
+ cacheMiss: true,
+ })
+
+ const ctx = createExecutionContext()
+ // `?format=car` passes the body through unchanged, so the erroring stream
+ // reaches the egress measurement.
+ const req = withRequest(
+ realDataSetId,
+ realPieceId,
+ 'GET',
+ {},
+ {
+ format: 'car',
+ },
+ )
+ // Not fetchAndRead: the body errors mid-stream, and the source error (not
+ // client consumption) drives the 900. Draining it here would throw.
+ const res = await worker.fetch(req, env, ctx, {
+ retrieveIpfsContent: mockRetrieveIpfsContent,
+ })
+ expect(res.status).toBe(200)
+ await waitOnExecutionContext(ctx)
+
+ const log = await env.DB.prepare(
+ 'SELECT response_status, egress_bytes FROM retrieval_logs WHERE data_set_id = ? AND response_status = 900',
+ )
+ .bind(String(realDataSetId))
+ .first()
+ // The client never reads, so back-pressure stops the chunk being counted
+ // before the source errors.
+ expect(log).toEqual({ response_status: 900, egress_bytes: 0 })
+ })
+
+ it(
+ 'measures egress correctly from real service provider',
+ { timeout: 10000 },
+ async () => {
+ const tasks = CONTENT_STORED_ON_CALIBRATION.map(
+ ({ dataSetId, pieceId, serviceProviderId }) => {
+ return (async () => {
+ try {
+ const ctx = createExecutionContext()
+ const req = withRequest(String(dataSetId), pieceId)
+ const res = await fetchAndRead(req, env, ctx, {
+ retrieveIpfsContent,
+ })
+ await waitOnExecutionContext(ctx)
+
+ assert.strictEqual(res.status, 200)
+
+ const content = await res.arrayBuffer()
+ const actualBytes = content.byteLength
+
+ const log = await env.DB.prepare(
+ 'SELECT egress_bytes FROM retrieval_logs WHERE data_set_id = ? ORDER BY id DESC LIMIT 1',
+ )
+ .bind(String(dataSetId))
+ .first()
+
+ assert.strictEqual(log.egress_bytes, actualBytes)
+
+ return { serviceProviderId, success: true }
+ } catch (err) {
+ console.warn(
+ `⚠️ Warning: Fetch or verification failed for serviceProvider ${serviceProviderId}:`,
+ err,
+ )
+ throw err
+ }
+ })()
+ },
+ )
+
+ try {
+ const res = await Promise.allSettled(tasks)
+ if (!res.some((r) => r.status === 'fulfilled')) {
+ throw new Error('All tasks failed')
+ }
+ } catch (err) {
+ const serviceProvidersChecked = CONTENT_STORED_ON_CALIBRATION.map(
+ (o) => o.serviceProviderId,
+ )
+ throw new Error(
+ `❌ All service providers failed to fetch. Service providers checked: ${serviceProvidersChecked.join(', ')}`,
+ )
+ }
+ },
+ )
+
+ it('charges bots for egress', async () => {
+ const botToken = Object.keys(botTokens)[0]
+ /** @type {string} */
+ const botName = env.BOT_TOKENS[botToken]
+ console.log({ botToken, botName })
+
+ const mockRetrieveIpfsContent = vi.fn().mockResolvedValue({
+ response: new Response('fake'),
+ cacheMiss: true,
+ })
+ const ctx = createExecutionContext()
+ const req = withRequest(realDataSetId, realPieceId, 'GET', {
+ authorization: `Bearer ${botToken}`,
+ })
+ const res = await fetchAndRead(req, env, ctx, {
+ retrieveIpfsContent: mockRetrieveIpfsContent,
+ })
+ await waitOnExecutionContext(ctx)
+ expect(res.status).toBe(200)
+ const readOutput = await env.DB.prepare(
+ 'SELECT egress_bytes FROM retrieval_logs WHERE data_set_id = ?',
+ )
+ .bind(String(realDataSetId))
+ .all()
+ expect(readOutput.results).toStrictEqual([
+ expect.objectContaining({
+ egress_bytes: 4,
+ }),
+ ])
+ })
+
+ it('logs the CAR size as cache-miss egress when converting CAR to raw', async () => {
+ const fileBytes = new Uint8Array(1000).fill(42)
+ const { carBytes, rootCid } = await buildRawBlockCar(fileBytes)
+ expect(carBytes.length).toBeGreaterThan(fileBytes.length)
+
+ const carDataSetId = '7777'
+ const carPieceId = '7777'
+ await withDataSetPiece(env, {
+ serviceProviderId: 'sp-car',
+ payerAddress: defaultPayerAddress,
+ pieceCid: 'bagacartest',
+ ipfsRootCid: rootCid,
+ dataSetId: carDataSetId,
+ pieceId: carPieceId,
+ })
+ await withApprovedProvider(env, {
+ id: 'sp-car',
+ serviceUrl: 'https://pdp.example/',
+ })
+
+ const mockRetrieveIpfsContent = vi.fn().mockResolvedValue({
+ response: new Response(carBytes, { status: 200 }),
+ cacheMiss: true,
+ })
+
+ const ctx = createExecutionContext()
+ const req = withRequest(
+ carDataSetId,
+ carPieceId,
+ 'GET',
+ {},
+ { format: null },
+ )
+ const res = await fetchAndRead(req, env, ctx, {
+ retrieveIpfsContent: mockRetrieveIpfsContent,
+ })
+ await waitOnExecutionContext(ctx)
+
+ expect(res.status).toBe(200)
+ expect(new Uint8Array(await res.arrayBuffer())).toEqual(fileBytes)
+ // Converting CAR to raw serves the content inline for browsers to sniff.
+ expect(res.headers.get('content-disposition')).toBe('inline')
+
+ const readOutput = await env.DB.prepare(
+ `SELECT egress_bytes, cache_miss_egress_bytes, cache_miss
+ FROM retrieval_logs
+ WHERE data_set_id = ?`,
+ )
+ .bind(carDataSetId)
+ .all()
+
+ // The client is charged the raw bytes served, the cache-miss quota the
+ // larger CAR fetched from the service provider.
+ expect(readOutput.results).toStrictEqual([
+ {
+ egress_bytes: fileBytes.length,
+ cache_miss_egress_bytes: carBytes.length,
+ cache_miss: 1,
+ },
+ ])
+ })
+
+ it('charges the cache-miss egress quota by the CAR size when enforcing', async () => {
+ const fileBytes = new Uint8Array(1000).fill(42)
+ const { carBytes, rootCid } = await buildRawBlockCar(fileBytes)
+
+ const dataSetId = '8888'
+ const pieceId = '8888'
+ await withDataSetPiece(env, {
+ serviceProviderId: 'sp-quota-car',
+ payerAddress: defaultPayerAddress,
+ pieceCid: 'bagacarquota',
+ ipfsRootCid: rootCid,
+ dataSetId,
+ pieceId,
+ })
+ await withApprovedProvider(env, {
+ id: 'sp-quota-car',
+ serviceUrl: 'https://pdp.example/',
+ })
+ await env.DB.prepare(
+ 'INSERT INTO data_set_egress_quotas (data_set_id, cdn_egress_quota, cache_miss_egress_quota) VALUES (?, ?, ?)',
+ )
+ .bind(dataSetId, 100000, 100000)
+ .run()
+
+ const mockRetrieveIpfsContent = vi.fn().mockResolvedValue({
+ response: new Response(carBytes, { status: 200 }),
+ cacheMiss: true,
+ })
+
+ const ctx = createExecutionContext()
+ const req = withRequest(dataSetId, pieceId, 'GET', {}, { format: null })
+ const res = await fetchAndRead(
+ req,
+ { ...env, ENFORCE_EGRESS_QUOTA: true },
+ ctx,
+ { retrieveIpfsContent: mockRetrieveIpfsContent },
+ )
+ await waitOnExecutionContext(ctx)
+
+ expect(res.status).toBe(200)
+ expect(new Uint8Array(await res.arrayBuffer())).toEqual(fileBytes)
+
+ // The CDN quota is charged the raw bytes served, the cache-miss quota the
+ // larger CAR fetched from the service provider.
+ const quota = await env.DB.prepare(
+ 'SELECT cdn_egress_quota, cache_miss_egress_quota FROM data_set_egress_quotas WHERE data_set_id = ?',
+ )
+ .bind(dataSetId)
+ .first()
+
+ expect(quota).toStrictEqual({
+ cdn_egress_quota: 100000 - fileBytes.length,
+ cache_miss_egress_quota: 100000 - carBytes.length,
+ })
+ })
+
+ it('charges the cache-miss egress quota for a ?format=car cache miss', async () => {
+ // `?format=car` passes the CAR through unchanged, so the bytes served equal
+ // the bytes fetched from the service provider.
+ const carBytes = new Uint8Array(500).fill(7)
+
+ const dataSetId = '9090'
+ const pieceId = '9090'
+ await withDataSetPiece(env, {
+ serviceProviderId: 'sp-car-passthrough',
+ payerAddress: defaultPayerAddress,
+ pieceCid: 'bagacarpassthrough',
+ ipfsRootCid: 'bafkcarpassthrough',
+ dataSetId,
+ pieceId,
+ })
+ await withApprovedProvider(env, {
+ id: 'sp-car-passthrough',
+ serviceUrl: 'https://pdp.example/',
+ })
+ await env.DB.prepare(
+ 'INSERT INTO data_set_egress_quotas (data_set_id, cdn_egress_quota, cache_miss_egress_quota) VALUES (?, ?, ?)',
+ )
+ .bind(dataSetId, 100000, 100000)
+ .run()
+
+ const mockRetrieveIpfsContent = vi.fn().mockResolvedValue({
+ response: new Response(carBytes, { status: 200 }),
+ cacheMiss: true,
+ })
+
+ const ctx = createExecutionContext()
+ const req = withRequest(dataSetId, pieceId, 'GET', {}, { format: 'car' })
+ const res = await fetchAndRead(
+ req,
+ { ...env, ENFORCE_EGRESS_QUOTA: true },
+ ctx,
+ { retrieveIpfsContent: mockRetrieveIpfsContent },
+ )
+ await waitOnExecutionContext(ctx)
+
+ expect(res.status).toBe(200)
+ expect(new Uint8Array(await res.arrayBuffer())).toEqual(carBytes)
+
+ const quota = await env.DB.prepare(
+ 'SELECT cdn_egress_quota, cache_miss_egress_quota FROM data_set_egress_quotas WHERE data_set_id = ?',
+ )
+ .bind(dataSetId)
+ .first()
+
+ expect(quota).toStrictEqual({
+ cdn_egress_quota: 100000 - carBytes.length,
+ cache_miss_egress_quota: 100000 - carBytes.length,
+ })
+ })
+
+ it('retries another service provider when the first one fails', async () => {
+ const sharedIpfsRootCid = 'bafkfallbackshared'
+ const badServiceUrl = 'https://bad-sp.example/'
+ const goodServiceUrl = 'https://good-sp.example/'
+ const goodDataSetId = '8801'
+
+ await withDataSetPiece(env, {
+ serviceProviderId: 'sp-fallback-bad',
+ payerAddress: defaultPayerAddress,
+ pieceCid: 'bagafallbackbad',
+ ipfsRootCid: sharedIpfsRootCid,
+ dataSetId: '8800',
+ pieceId: '8800',
+ })
+ await withApprovedProvider(env, {
+ id: 'sp-fallback-bad',
+ serviceUrl: badServiceUrl,
+ })
+ await withDataSetPiece(env, {
+ serviceProviderId: 'sp-fallback-good',
+ payerAddress: defaultPayerAddress,
+ pieceCid: 'bagafallbackgood',
+ ipfsRootCid: sharedIpfsRootCid,
+ dataSetId: goodDataSetId,
+ pieceId: '8801',
+ })
+ await withApprovedProvider(env, {
+ id: 'sp-fallback-good',
+ serviceUrl: goodServiceUrl,
+ })
+
+ const mockRetrieveIpfsContent = vi.fn(async (serviceUrl) => {
+ if (serviceUrl === goodServiceUrl) {
+ return {
+ response: new Response('fake', {
+ status: 200,
+ headers: { 'CF-Cache-Status': 'MISS' },
+ }),
+ cacheMiss: true,
+ }
+ }
+ return {
+ response: new Response('boom', { status: 500 }),
+ cacheMiss: true,
+ }
+ })
+
+ const ctx = createExecutionContext()
+ const req = withRequest('8800', '8800', 'GET', {}, { format: 'car' })
+ const res = await fetchAndRead(req, env, ctx, {
+ retrieveIpfsContent: mockRetrieveIpfsContent,
+ })
+ await waitOnExecutionContext(ctx)
+
+ expect(res.status).toBe(200)
+ expect(await res.text()).toBe('fake')
+
+ // The content is served, and the egress is charged to the data set whose
+ // service provider succeeded.
+ const readOutput = await env.DB.prepare(
+ `SELECT data_set_id, response_status
+ FROM retrieval_logs
+ WHERE data_set_id = ?`,
+ )
+ .bind(goodDataSetId)
+ .all()
+ expect(readOutput.results).toStrictEqual([
+ expect.objectContaining({
+ data_set_id: goodDataSetId,
+ response_status: 200,
+ }),
+ ])
+ })
+
+ it('requests payment if withCDN=false', async () => {
+ const dataSetId = '1004'
+ const pieceId = '2004'
+ const pieceCid =
+ 'baga6ea4seaqaleibb6ud4xeemuzzpsyhl6cxlsymsnfco4cdjka5uzajo2x4ipa'
+ const ipfsRootCid = 'bafk4test'
+ const serviceProviderId = 'service-provider'
+ const payerAddress = '0x1234567890abcdef1234567890abcdef12345678'
+
+ await withApprovedProvider(env, {
+ id: serviceProviderId,
+ serviceUrl: 'https://test-provider.xyz',
+ })
+
+ await withDataSetPiece(env, {
+ serviceProviderId,
+ pieceCid,
+ ipfsRootCid,
+ dataSetId,
+ withCDN: false,
+ pieceId,
+ payerAddress,
+ })
+
+ const ctx = createExecutionContext()
+ const req = withRequest(dataSetId, pieceId, 'GET')
+ const res = await fetchAndRead(req, env, ctx)
+ await waitOnExecutionContext(ctx)
+
+ assert.strictEqual(res.status, 402)
+ })
+ it('reads the provider URL from the database', async () => {
+ const serviceProviderId = 'service-provider-id'
+ const dataSetId = '1001'
+ const pieceId = '2001'
+ const payerAddress = '0x1234567890abcdef1234567890abcdef12345608'
+ const ipfsRootCid = 'bafk4test'
+ const body = 'file content'
+
+ await withDataSetPiece(env, {
+ serviceProviderId,
+ dataSetId,
+ pieceId,
+ ipfsRootCid,
+ payerAddress,
+ })
+
+ await withApprovedProvider(env, {
+ id: serviceProviderId,
+ serviceUrl: 'https://mock-pdp-url.com',
+ })
+
+ const mockRetrieveIpfsContent = async () => {
+ return {
+ response: new Response(body, {
+ status: 200,
+ }),
+ cacheMiss: true,
+ }
+ }
+
+ const ctx = createExecutionContext()
+ const req = withRequest(dataSetId, pieceId)
+ const res = await fetchAndRead(req, env, ctx, {
+ retrieveIpfsContent: mockRetrieveIpfsContent,
+ })
+ await waitOnExecutionContext(ctx)
+
+ // Check if the URL fetched is from the database
+ expect(await res.text()).toBe(body)
+ expect(res.status).toBe(200)
+ })
+
+ it('throws an error if the providerAddress is not found in the database', async () => {
+ const serviceProviderId = 'service-provider-id'
+ const dataSetId = '1002'
+ const pieceId = '2002'
+ const payerAddress = '0x2A06D234246eD18b6C91de8349fF34C22C7268e8'
+ const ipfsRootCid = 'bafk4test'
+
+ await withDataSetPiece(env, {
+ serviceProviderId,
+ dataSetId,
+ pieceId,
+ ipfsRootCid,
+ payerAddress,
+ })
+
+ const ctx = createExecutionContext()
+ const req = withRequest(dataSetId, pieceId)
+ const res = await fetchAndRead(req, env, ctx)
+ await waitOnExecutionContext(ctx)
+
+ // Expect an error because no URL was found
+ expect(res.status).toBe(404)
+ expect(await res.text()).toBe(
+ `No approved service provider found for payer '0x2a06d234246ed18b6c91de8349ff34c22c7268e8' and the requested content.`,
+ )
+ })
+
+ it('returns data set ID in the X-Data-Set-ID response header', async () => {
+ const { dataSetId } = CONTENT_STORED_ON_CALIBRATION[0]
+ const mockRetrieveIpfsContent = vi.fn().mockResolvedValue({
+ response: new Response('hello'),
+ cacheMiss: true,
+ })
+ const ctx = createExecutionContext()
+ const req = withRequest(realDataSetId, realPieceId)
+ const res = await fetchAndRead(req, env, ctx, {
+ retrieveIpfsContent: mockRetrieveIpfsContent,
+ })
+ await waitOnExecutionContext(ctx)
+ expect(await res.text()).toBe('hello')
+ expect(res.headers.get('X-Data-Set-ID')).toBe(String(dataSetId))
+ })
+
+ it('stores data set ID in retrieval logs', async () => {
+ const { dataSetId } = CONTENT_STORED_ON_CALIBRATION[0]
+ const mockRetrieveIpfsContent = vi.fn().mockResolvedValue({
+ response: new Response('hello'),
+ cacheMiss: true,
+ })
+ const ctx = createExecutionContext()
+ const req = withRequest(realDataSetId, realPieceId)
+ const res = await fetchAndRead(req, env, ctx, {
+ retrieveIpfsContent: mockRetrieveIpfsContent,
+ })
+ await waitOnExecutionContext(ctx)
+ expect(await res.text()).toBe('hello')
+
+ assert.strictEqual(res.status, 200)
+ const { results } = await env.DB.prepare(
+ `SELECT id, response_status, cache_miss
+ FROM retrieval_logs
+ WHERE data_set_id = ?`,
+ )
+ .bind(String(dataSetId))
+ .all()
+ assert.deepStrictEqual(results, [
+ {
+ id: 1, // Assuming this is the first log entry
+ response_status: 200,
+ cache_miss: 1, // 1 for true, 0 for false
+ },
+ ])
+ })
+
+ it('returns data set ID in the X-Data-Set-ID response header when the response body is empty', async () => {
+ const { dataSetId } = CONTENT_STORED_ON_CALIBRATION[0]
+ const mockRetrieveIpfsContent = vi.fn().mockResolvedValue({
+ response: new Response(null, { status: 404 }),
+ cacheMiss: true,
+ })
+ const ctx = createExecutionContext()
+ const req = withRequest(realDataSetId, realPieceId)
+ const res = await fetchAndRead(req, env, ctx, {
+ retrieveIpfsContent: mockRetrieveIpfsContent,
+ })
+ await waitOnExecutionContext(ctx)
+ expect(res.body).toBeNull()
+ expect(res.headers.get('X-Data-Set-ID')).toBe(String(dataSetId))
+ })
+
+ it('supports HEAD requests', async () => {
+ const fakeResponse = new Response('file content', {
+ status: 200,
+ })
+ const mockRetrieveIpfsContent = vi.fn().mockResolvedValue({
+ response: fakeResponse,
+ cacheMiss: true,
+ })
+ const ctx = createExecutionContext()
+ const req = withRequest(realDataSetId, realPieceId, 'HEAD')
+ const res = await fetchAndRead(req, env, ctx, {
+ retrieveIpfsContent: mockRetrieveIpfsContent,
+ })
+ await waitOnExecutionContext(ctx)
+ expect(res.status).toBe(200)
+ })
+
+ it('rejects retrieval requests for CIDs found in the Bad Bits denylist', async () => {
+ await withBadBits(env, realIpfsRootCid)
+
+ const fakeResponse = new Response('hello')
+ const mockRetrieveIpfsContent = vi.fn().mockResolvedValue({
+ response: fakeResponse,
+ cacheMiss: true,
+ })
+
+ const ctx = createExecutionContext()
+ const req = withRequest(realDataSetId, realPieceId)
+ const res = await fetchAndRead(req, env, ctx, {
+ retrieveIpfsContent: mockRetrieveIpfsContent,
+ })
+ await waitOnExecutionContext(ctx)
+ expect(res.status).toBe(404)
+ expect(await res.text()).toBe(
+ 'The requested CID was flagged by the Bad Bits Denylist at https://badbits.dwebops.pub',
+ )
+ })
+
+ it('reject retrieval request if payer is sanctioned', async () => {
+ const dataSetId = '1003'
+ const pieceId = '2003'
+ const pieceCid =
+ 'baga6ea4seaqaleibb6ud4xeemuzzpsyhl6cxlsymsnfco4cdjka5uzajo2x4ipa'
+ const ipfsRootCid = 'bafk4test'
+ const serviceProviderId = 'service-provider-id'
+ const payerAddress = '0x999999cf1046e68e36E1aA2E0E07105eDDD1f08E'
+
+ await withApprovedProvider(env, {
+ id: serviceProviderId,
+ serviceUrl: 'https://test-provider.xyz',
+ })
+
+ await withDataSetPiece(env, {
+ serviceProviderId,
+ payerAddress,
+ pieceCid,
+ ipfsRootCid,
+ dataSetId,
+ withCDN: true,
+ withIpfsIndexing: true,
+ pieceId,
+ })
+
+ await withWalletDetails(
+ env,
+ payerAddress,
+ true, // Sanctioned
+ )
+ const ctx = createExecutionContext()
+ const req = withRequest(dataSetId, pieceId)
+ const res = await fetchAndRead(req, env, ctx)
+ await waitOnExecutionContext(ctx)
+
+ assert.strictEqual(res.status, 403)
+ })
+ it('does not log to retrieval_logs on method not allowed (405)', async () => {
+ const ctx = createExecutionContext()
+ const req = withRequest(realDataSetId, realPieceId, 'POST')
+ const res = await fetchAndRead(req, env, ctx)
+ await waitOnExecutionContext(ctx)
+
+ expect(res.status).toBe(405)
+ expect(await res.text()).toBe('Method Not Allowed')
+
+ const result = await env.DB.prepare(
+ `SELECT response_status FROM retrieval_logs WHERE data_set_id = ? ORDER BY id DESC LIMIT 1`,
+ )
+ .bind(realDataSetId)
+ .first()
+ expect(result).toBeNull()
+ })
+
+ it('logs to retrieval_logs on unsupported service provider (404)', async () => {
+ const invalidPieceCid = 'bafiknvalidpieceid'
+ const pieceId = '9'
+ const invalidIpfsRootCid = 'bafkinvalidrootcid'
+ const dataSetId = '13'
+ const unsupportedServiceProviderId = 0
+
+ await env.DB.batch([
+ env.DB.prepare(
+ 'INSERT INTO data_sets (id, service_provider_id, payer_address, with_cdn, with_ipfs_indexing) VALUES (?, ?, ?, ?, ?)',
+ ).bind(
+ dataSetId,
+ unsupportedServiceProviderId,
+ defaultPayerAddress,
+ true,
+ true,
+ ),
+ env.DB.prepare(
+ 'INSERT INTO pieces (id, data_set_id, cid, ipfs_root_cid) VALUES (?, ?, ?, ?)',
+ ).bind(pieceId, dataSetId, invalidPieceCid, invalidIpfsRootCid),
+ ])
+
+ const ctx = createExecutionContext()
+ const req = withRequest(dataSetId, pieceId)
+ const res = await fetchAndRead(req, env, ctx)
+ await waitOnExecutionContext(ctx)
+
+ expect(res.status).toBe(404)
+ expect(await res.text()).toContain('No approved service provider found')
+
+ const result = await env.DB.prepare(
+ 'SELECT * FROM retrieval_logs WHERE data_set_id IS NULL AND response_status = 404 and CACHE_MISS IS NULL and egress_bytes IS NULL',
+ ).first()
+ expect(result).toBeTruthy()
+ })
+ it('does not log to retrieval_logs when slug encoding is invalid (400)', async () => {
+ const ctx = createExecutionContext()
+ const { count: countBefore } = await env.DB.prepare(
+ 'SELECT COUNT(*) AS count FROM retrieval_logs',
+ ).first()
+
+ const req = new Request(
+ `http://${buildSlug(BigInt(realDataSetId), BigInt(realPieceId))}1.${DNS_ROOT.slice(1)}`,
+ )
+ const res = await fetchAndRead(req, env, ctx)
+ await waitOnExecutionContext(ctx)
+
+ expect(res.status).toBe(400)
+ expect(await res.text()).toContain('Invalid pieceId encoding in slug')
+
+ const { count: countAfter } = await env.DB.prepare(
+ 'SELECT COUNT(*) AS count FROM retrieval_logs',
+ ).first()
+ expect(countAfter).toBe(countBefore)
+ })
+
+ it('converts CAR to RAW by default (no format parameter)', async () => {
+ // CONTENT_STORED_ON_CALIBRATION[1] is a single raw block holding a PNG.
+ const { dataSetId, pieceId } = CONTENT_STORED_ON_CALIBRATION[1]
+ const ctx = createExecutionContext()
+
+ const req = withRequest(
+ String(dataSetId),
+ pieceId,
+ 'GET',
+ {},
+ { format: null },
+ )
+
+ const res = await fetchAndRead(req, env, ctx, { retrieveIpfsContent })
+ await waitOnExecutionContext(ctx)
+
+ expect(res.status).toBe(200)
+
+ // Verify content-disposition is set to inline (not attachment)
+ expect(res.headers.get('content-disposition')).toBe('inline')
+
+ // Verify we got RAW PNG data, not a CAR file
+ const content = await res.bytes()
+ expect(content.length).toBeGreaterThan(0)
+
+ // PNG files start with the magic bytes: 89 50 4E 47 0D 0A 1A 0A
+ expect(content.slice(0, 4)).toEqual(
+ new Uint8Array([
+ 0x89,
+ 0x50, // 'P'
+ 0x4e, // 'N'
+ 0x47, // 'G'
+ ]),
+ )
+ })
+})
+
+/**
+ * @param {string} dataSetId
+ * @param {string} pieceId
+ * @param {string} method
+ * @param {Object} headers
+ * @param {Object} options
+ * @param {string} options.subpath
+ * @returns {Request}
+ */
+function withRequest(
+ dataSetId,
+ pieceId,
+ method = 'GET',
+ headers = {},
+ { subpath = '', format = 'car' } = {},
+) {
+ let url = `http://${buildSlug(BigInt(dataSetId), BigInt(pieceId))}.`
+ url += DNS_ROOT.slice(1) // remove the leading '.'
+ if (subpath) url += `${subpath}`
+ if (format) url += `?format=${format}`
+
+ return new Request(url, { method, headers })
+}
diff --git a/ipfs-retriever/test/store.test.js b/ipfs-retriever/test/store.test.js
new file mode 100644
index 00000000..ffb885c1
--- /dev/null
+++ b/ipfs-retriever/test/store.test.js
@@ -0,0 +1,725 @@
+import { describe, it, beforeAll } from 'vitest'
+import assert from 'node:assert/strict'
+import {
+ getRetrievalCandidatesByWalletAndCid,
+ getRetrievalCandidatesByDataSetAndPiece,
+ getSlugForWalletAndCid,
+} from '../lib/store.js'
+import { env } from 'cloudflare:test'
+import { withDataSetPiece, withApprovedProvider } from './test-data-builders.js'
+
+describe('getRetrievalCandidatesByWalletAndCid', () => {
+ const APPROVED_SERVICE_PROVIDER_ID = '20'
+ beforeAll(async () => {
+ await withApprovedProvider(env, {
+ id: APPROVED_SERVICE_PROVIDER_ID,
+ serviceUrl: 'https://approved-provider.xyz',
+ })
+ })
+
+ it('returns service provider for valid ipfsRootCid', async () => {
+ const dataSetId = 'test-set-1'
+ const ipfsRootCid = 'bafk4test'
+ const payerAddress = '0x1234567890abcdef1234567890abcdef12345678'
+
+ await env.DB.prepare(
+ 'INSERT INTO data_sets (id, service_provider_id, payer_address, with_cdn, with_ipfs_indexing) VALUES (?, ?, ?, ?, ?)',
+ )
+ .bind(dataSetId, APPROVED_SERVICE_PROVIDER_ID, payerAddress, true, true)
+ .run()
+ await env.DB.prepare(
+ 'INSERT INTO pieces (id, data_set_id, cid, ipfs_root_cid) VALUES (?, ?, ?, ?)',
+ )
+ .bind('piece-1', dataSetId, 'baga4piece', ipfsRootCid)
+ .run()
+
+ const result = await getRetrievalCandidatesByWalletAndCid(
+ env,
+ payerAddress,
+ ipfsRootCid,
+ )
+ assert.strictEqual(
+ result[0].serviceProviderId,
+ APPROVED_SERVICE_PROVIDER_ID,
+ )
+ })
+
+ it('throws error if ipfsRootCid not found', async () => {
+ const payerAddress = '0x1234567890abcdef1234567890abcdef12345678'
+ await assert.rejects(
+ async () =>
+ await getRetrievalCandidatesByWalletAndCid(
+ env,
+ payerAddress,
+ 'nonexistent-cid',
+ ),
+ /does not exist/,
+ )
+ })
+
+ it('excludes deleted pieces', async () => {
+ const dataSetId = 'test-set-deleted'
+ const ipfsRootCid = 'bafk4deleted'
+ const payerAddress = '0x1234567890abcdef1234567890abcdef12345678'
+
+ await env.DB.prepare(
+ 'INSERT INTO data_sets (id, service_provider_id, payer_address, with_cdn, with_ipfs_indexing) VALUES (?, ?, ?, ?, ?)',
+ )
+ .bind(dataSetId, APPROVED_SERVICE_PROVIDER_ID, payerAddress, true, true)
+ .run()
+ await env.DB.prepare(
+ 'INSERT INTO pieces (id, data_set_id, cid, ipfs_root_cid, is_deleted) VALUES (?, ?, ?, ?, ?)',
+ )
+ .bind('piece-deleted', dataSetId, 'baga4deleted', ipfsRootCid, true)
+ .run()
+
+ await assert.rejects(
+ async () =>
+ await getRetrievalCandidatesByWalletAndCid(
+ env,
+ payerAddress,
+ ipfsRootCid,
+ ),
+ /does not exist/,
+ )
+ })
+
+ it('throws error if data_set_id exists but has no associated service provider', async () => {
+ const cid = 'cid-no-owner'
+ const dataSetId = 'data-set-no-owner'
+ const payerAddress = '0x1234567890abcdef1234567890abcdef12345678'
+
+ await env.DB.prepare(
+ `
+ INSERT INTO pieces (id, data_set_id, cid, ipfs_root_cid)
+ VALUES (?, ?, ?, ?)
+ `,
+ )
+ .bind('piece-1', dataSetId, `bagatestpiece`, cid)
+ .run()
+
+ await assert.rejects(
+ async () =>
+ await getRetrievalCandidatesByWalletAndCid(env, payerAddress, cid),
+ /no associated service provider/,
+ )
+ })
+
+ it('returns error if no payment rail', async () => {
+ const cid = 'cid-unapproved'
+ const dataSetId = 'data-set-unapproved'
+ const serviceProviderId = APPROVED_SERVICE_PROVIDER_ID
+ const payerAddress = '0xabcdef1234567890abcdef1234567890abcdef12'
+
+ await env.DB.batch([
+ env.DB.prepare(
+ 'INSERT INTO data_sets (id, service_provider_id, payer_address, with_cdn) VALUES (?, ?, ?, ?)',
+ ).bind(
+ dataSetId,
+ serviceProviderId,
+ payerAddress.replace('a', 'b'),
+ true,
+ ),
+ env.DB.prepare(
+ 'INSERT INTO pieces (id, data_set_id, cid, ipfs_root_cid) VALUES (?, ?, ?, ?)',
+ ).bind('piece-2', dataSetId, 'bagatest', cid),
+ ])
+
+ await assert.rejects(
+ async () =>
+ await getRetrievalCandidatesByWalletAndCid(env, payerAddress, cid),
+ /There is no Filecoin Warm Storage Service deal for payer/,
+ )
+ })
+
+ it('returns error if withCDN=false', async () => {
+ const cid = 'cid-unapproved'
+ const dataSetId = 'data-set-unapproved'
+ const serviceProviderId = APPROVED_SERVICE_PROVIDER_ID
+ const payerAddress = '0xabcdef1234567890abcdef1234567890abcdef12'
+
+ await env.DB.batch([
+ env.DB.prepare(
+ 'INSERT INTO data_sets (id, service_provider_id, payer_address, with_cdn) VALUES (?, ?, ?, ?)',
+ ).bind(dataSetId, serviceProviderId, payerAddress, false),
+ env.DB.prepare(
+ 'INSERT INTO pieces (id, data_set_id, cid, ipfs_root_cid) VALUES (?, ?, ?, ?)',
+ ).bind('piece-2', dataSetId, 'bagatest', cid),
+ ])
+
+ await assert.rejects(
+ async () =>
+ await getRetrievalCandidatesByWalletAndCid(env, payerAddress, cid),
+ /withCDN=false/,
+ )
+ })
+
+ it('returns serviceProviderId for approved service provider', async () => {
+ const cid = 'cid-approved'
+ const dataSetId = 'data-set-approved'
+ const payerAddress = '0xabcdef1234567890abcdef1234567890abcdef12'
+
+ await env.DB.batch([
+ env.DB.prepare(
+ 'INSERT INTO data_sets (id, service_provider_id, payer_address, with_cdn, with_ipfs_indexing) VALUES (?, ?, ?, ?, ?)',
+ ).bind(dataSetId, APPROVED_SERVICE_PROVIDER_ID, payerAddress, true, true),
+ env.DB.prepare(
+ 'INSERT INTO pieces (id, data_set_id, cid, ipfs_root_cid) VALUES (?, ?, ?, ?)',
+ ).bind('piece-3', dataSetId, 'bagatest', cid),
+ ])
+
+ const result = await getRetrievalCandidatesByWalletAndCid(
+ env,
+ payerAddress,
+ cid,
+ )
+
+ assert.strictEqual(
+ result[0].serviceProviderId,
+ APPROVED_SERVICE_PROVIDER_ID,
+ )
+ })
+ it('returns the service provider first in the ordering when multiple service providers share the same ipfsRootCid', async () => {
+ const dataSetId1 = 'data-set-a'
+ const dataSetId2 = 'data-set-b'
+ const ipfsRootCid = 'shared-ipfs-cid'
+ const payerAddress = '0x1234567890abcdef1234567890abcdef12345678'
+ const serviceProviderId1 = 'service-provider-a'
+ const serviceProviderId2 = 'service-provicer-b'
+
+ await withApprovedProvider(env, {
+ id: serviceProviderId1,
+ })
+ await withApprovedProvider(env, {
+ id: serviceProviderId2,
+ })
+
+ // Insert both owners into separate sets with the same ipfsRootCid
+ await env.DB.prepare(
+ 'INSERT INTO data_sets (id, service_provider_id, payer_address, with_cdn, with_ipfs_indexing) VALUES (?, ?, ?, ?, ?)',
+ )
+ .bind(dataSetId1, serviceProviderId1, payerAddress, true, true)
+ .run()
+
+ await env.DB.prepare(
+ 'INSERT INTO data_sets (id, service_provider_id, payer_address, with_cdn) VALUES (?, ?, ?, ?)',
+ )
+ .bind(dataSetId2, serviceProviderId2, payerAddress, true)
+ .run()
+
+ // Insert same ipfsRootCid for both sets
+ await env.DB.prepare(
+ 'INSERT INTO pieces (id, data_set_id, cid, ipfs_root_cid) VALUES (?, ?, ?, ?)',
+ )
+ .bind('piece-a', dataSetId1, 'bagatest', ipfsRootCid)
+ .run()
+
+ await env.DB.prepare(
+ 'INSERT INTO pieces (id, data_set_id, cid, ipfs_root_cid) VALUES (?, ?, ?, ?)',
+ )
+ .bind('piece-b', dataSetId2, 'bagatest', ipfsRootCid)
+ .run()
+
+ // Should return only the serviceProviderId1 which is the first in the ordering
+ const result = await getRetrievalCandidatesByWalletAndCid(
+ env,
+ payerAddress,
+ ipfsRootCid,
+ )
+ assert.strictEqual(result[0].serviceProviderId, serviceProviderId1)
+ })
+
+ it('ignores owners that are not approved by Filecoin Warm Storage Service', async () => {
+ const dataSetId1 = '0'
+ const dataSetId2 = '1'
+ const ipfsRootCid = 'shared-piece-cid'
+ const payerAddress = '0x1234567890abcdef1234567890abcdef12345678'
+ const serviceProviderId1 = '0'
+ const serviceProviderId2 = '1'
+
+ await withApprovedProvider(env, {
+ id: serviceProviderId1,
+ serviceUrl: 'https://pdp-provider-1.xyz',
+ })
+
+ // NOTE: the second provider is not registered as an approved provider
+
+ // Important: we must insert the unapproved provider first!
+ await withDataSetPiece(env, {
+ payerAddress,
+ serviceProviderId: serviceProviderId2,
+ dataSetId: dataSetId2,
+ withCDN: true,
+ ipfsRootCid,
+ })
+
+ await withDataSetPiece(env, {
+ payerAddress,
+ serviceProviderId: serviceProviderId1,
+ dataSetId: dataSetId1,
+ withCDN: true,
+ ipfsRootCid,
+ })
+
+ // Should return service provider 1 because service provider 2 is not approved
+ const result = await getRetrievalCandidatesByWalletAndCid(
+ env,
+ payerAddress,
+ ipfsRootCid,
+ )
+ assert.deepStrictEqual(result[0], {
+ dataSetId: dataSetId1,
+ pieceId: '0',
+ serviceProviderId: serviceProviderId1.toLowerCase(),
+ serviceUrl: 'https://pdp-provider-1.xyz',
+ ipfsRootCid,
+ })
+ })
+})
+
+describe('getRetrievalCandidatesByDataSetAndPiece', () => {
+ const APPROVED_SERVICE_PROVIDER_ID = '25'
+ beforeAll(async () => {
+ await withApprovedProvider(env, {
+ id: APPROVED_SERVICE_PROVIDER_ID,
+ serviceUrl: 'https://approved-provider-byids.xyz',
+ })
+ })
+
+ it('returns service provider for valid dataSetId and pieceId', async () => {
+ const dataSetId = 'test-set-byids-1'
+ const pieceId = 'piece-byids-1'
+ const payerAddress = '0xabc123def456abc123def456abc123def456abc1'
+
+ await withDataSetPiece(env, {
+ payerAddress,
+ serviceProviderId: APPROVED_SERVICE_PROVIDER_ID,
+ dataSetId,
+ pieceId,
+ withCDN: true,
+ withIpfsIndexing: true,
+ ipfsRootCid: 'bafkbyids1',
+ })
+
+ const result = await getRetrievalCandidatesByDataSetAndPiece(
+ env,
+ dataSetId,
+ pieceId,
+ )
+
+ assert.strictEqual(
+ result[0].serviceProviderId,
+ APPROVED_SERVICE_PROVIDER_ID,
+ )
+ assert.strictEqual(
+ result[0].serviceUrl,
+ 'https://approved-provider-byids.xyz',
+ )
+ assert.strictEqual(result[0].dataSetId, dataSetId)
+ assert.strictEqual(result[0].pieceId, pieceId)
+ })
+
+ it('throws error if pieceId does not exist in the data set', async () => {
+ const dataSetId = 'test-set-byids-2'
+ const pieceId = 'nonexistent-piece'
+
+ await withDataSetPiece(env, {
+ payerAddress: '0xabc123def456abc123def456abc123def456abc2',
+ serviceProviderId: APPROVED_SERVICE_PROVIDER_ID,
+ dataSetId,
+ pieceId: 'existing-piece',
+ withCDN: true,
+ withIpfsIndexing: true,
+ ipfsRootCid: 'bafkbyids2',
+ })
+
+ await assert.rejects(
+ async () =>
+ await getRetrievalCandidatesByDataSetAndPiece(env, dataSetId, pieceId),
+ /does not exist in data set/,
+ )
+ })
+
+ it('throws error if pieceId exists but in different dataSetId', async () => {
+ const dataSetId1 = 'test-set-byids-3a'
+ const dataSetId2 = 'test-set-byids-3b'
+ const pieceId = 'piece-byids-3'
+ const payerAddress = '0xabc123def456abc123def456abc123def456abc3'
+
+ await withDataSetPiece(env, {
+ payerAddress,
+ serviceProviderId: APPROVED_SERVICE_PROVIDER_ID,
+ dataSetId: dataSetId1,
+ pieceId,
+ withCDN: true,
+ withIpfsIndexing: true,
+ ipfsRootCid: 'bafkbyids3a',
+ })
+
+ await withDataSetPiece(env, {
+ payerAddress,
+ serviceProviderId: APPROVED_SERVICE_PROVIDER_ID,
+ dataSetId: dataSetId2,
+ pieceId: 'different-piece',
+ withCDN: true,
+ withIpfsIndexing: true,
+ ipfsRootCid: 'bafkbyids3b',
+ })
+
+ await assert.rejects(
+ async () =>
+ await getRetrievalCandidatesByDataSetAndPiece(env, dataSetId2, pieceId),
+ /does not exist in data set/,
+ )
+ })
+
+ it('throws error if withCDN=false', async () => {
+ const dataSetId = 'test-set-byids-4'
+ const pieceId = 'piece-byids-4'
+
+ await withDataSetPiece(env, {
+ payerAddress: '0xabc123def456abc123def456abc123def456abc4',
+ serviceProviderId: APPROVED_SERVICE_PROVIDER_ID,
+ dataSetId,
+ pieceId,
+ withCDN: false,
+ withIpfsIndexing: true,
+ ipfsRootCid: 'bafkbyids4',
+ })
+
+ await assert.rejects(
+ async () =>
+ await getRetrievalCandidatesByDataSetAndPiece(env, dataSetId, pieceId),
+ /withCDN=false/,
+ )
+ })
+
+ it('throws error if withIpfsIndexing=false', async () => {
+ const dataSetId = 'test-set-byids-5'
+ const pieceId = 'piece-byids-5'
+
+ await withDataSetPiece(env, {
+ payerAddress: '0xabc123def456abc123def456abc123def456abc5',
+ serviceProviderId: APPROVED_SERVICE_PROVIDER_ID,
+ dataSetId,
+ pieceId,
+ withCDN: true,
+ withIpfsIndexing: false,
+ ipfsRootCid: 'bafkbyids5',
+ })
+
+ await assert.rejects(
+ async () =>
+ await getRetrievalCandidatesByDataSetAndPiece(env, dataSetId, pieceId),
+ /withIpfsIndexing=false/,
+ )
+ })
+
+ it('throws error if payer is sanctioned', async () => {
+ const dataSetId = 'test-set-byids-6'
+ const pieceId = 'piece-byids-6'
+ const payerAddress = '0xabc123def456abc123def456abc123def456abc6'
+
+ await env.DB.prepare(
+ 'INSERT INTO wallet_details (address, is_sanctioned) VALUES (?, ?)',
+ )
+ .bind(payerAddress, true)
+ .run()
+
+ await withDataSetPiece(env, {
+ payerAddress,
+ serviceProviderId: APPROVED_SERVICE_PROVIDER_ID,
+ dataSetId,
+ pieceId,
+ withCDN: true,
+ withIpfsIndexing: true,
+ ipfsRootCid: 'bafkbyids6',
+ })
+
+ await assert.rejects(
+ async () =>
+ await getRetrievalCandidatesByDataSetAndPiece(env, dataSetId, pieceId),
+ /is sanctioned/,
+ )
+ })
+
+ it('handles zero values for dataSetId and pieceId', async () => {
+ const dataSetId = '0'
+ const pieceId = '0'
+
+ await withDataSetPiece(env, {
+ payerAddress: '0xabc123def456abc123def456abc123def456abc7',
+ serviceProviderId: APPROVED_SERVICE_PROVIDER_ID,
+ dataSetId,
+ pieceId,
+ withCDN: true,
+ withIpfsIndexing: true,
+ ipfsRootCid: 'bafkbyids7',
+ })
+
+ const result = await getRetrievalCandidatesByDataSetAndPiece(
+ env,
+ dataSetId,
+ pieceId,
+ )
+
+ assert.strictEqual(result[0].dataSetId, '0')
+ assert.strictEqual(result[0].pieceId, '0')
+ assert.strictEqual(
+ result[0].serviceProviderId,
+ APPROVED_SERVICE_PROVIDER_ID,
+ )
+ })
+
+ it('returns every service provider serving the same content for the payer', async () => {
+ const payerAddress = '0xabc123def456abc123def456abc123def456abc8'
+ const ipfsRootCid = 'bafkbyids8shared'
+ const serviceProviderId1 = 'sp-byids-8a'
+ const serviceProviderId2 = 'sp-byids-8b'
+
+ await withApprovedProvider(env, {
+ id: serviceProviderId1,
+ serviceUrl: 'https://sp8a.xyz',
+ })
+ await withApprovedProvider(env, {
+ id: serviceProviderId2,
+ serviceUrl: 'https://sp8b.xyz',
+ })
+ await withDataSetPiece(env, {
+ payerAddress,
+ serviceProviderId: serviceProviderId1,
+ dataSetId: 'ds-8a',
+ pieceId: 'piece-8a',
+ withCDN: true,
+ withIpfsIndexing: true,
+ ipfsRootCid,
+ })
+ await withDataSetPiece(env, {
+ payerAddress,
+ serviceProviderId: serviceProviderId2,
+ dataSetId: 'ds-8b',
+ pieceId: 'piece-8b',
+ withCDN: true,
+ withIpfsIndexing: true,
+ ipfsRootCid,
+ })
+
+ // Looking up by one (data set, piece) returns the candidates for every
+ // service provider serving the same content for the payer.
+ const result = await getRetrievalCandidatesByDataSetAndPiece(
+ env,
+ 'ds-8a',
+ 'piece-8a',
+ )
+
+ assert.strictEqual(result.length, 2)
+ assert.deepStrictEqual(
+ result.map((c) => c.serviceProviderId).sort(),
+ [serviceProviderId1, serviceProviderId2].sort(),
+ )
+ })
+
+ it('excludes soft-deleted service providers', async () => {
+ const payerAddress = '0xabc123def456abc123def456abc123def456abc9'
+ const ipfsRootCid = 'bafkbyids9deleted'
+ const serviceProviderId = 'sp-byids-9-deleted'
+
+ await withApprovedProvider(env, {
+ id: serviceProviderId,
+ serviceUrl: 'https://sp9.xyz',
+ })
+ await env.DB.prepare(
+ 'UPDATE service_providers SET is_deleted = TRUE WHERE id = ?',
+ )
+ .bind(serviceProviderId)
+ .run()
+ await withDataSetPiece(env, {
+ payerAddress,
+ serviceProviderId,
+ dataSetId: 'ds-9',
+ pieceId: 'piece-9',
+ withCDN: true,
+ withIpfsIndexing: true,
+ ipfsRootCid,
+ })
+
+ await assert.rejects(
+ async () =>
+ await getRetrievalCandidatesByDataSetAndPiece(env, 'ds-9', 'piece-9'),
+ /has no associated service provider/,
+ )
+ })
+
+ it('throws 402 when the CDN egress quota is exhausted and enforcement is on', async () => {
+ await withApprovedProvider(env, {
+ id: 'sp-quota-cdn',
+ serviceUrl: 'https://qcdn.xyz',
+ })
+ await withDataSetPiece(env, {
+ payerAddress: '0xabc123def456abc123def456abc123def456abca',
+ serviceProviderId: 'sp-quota-cdn',
+ dataSetId: 'ds-quota-cdn',
+ pieceId: 'piece-quota-cdn',
+ withCDN: true,
+ withIpfsIndexing: true,
+ ipfsRootCid: 'bafkbyidsquotacdn',
+ })
+ await env.DB.prepare(
+ 'INSERT INTO data_set_egress_quotas (data_set_id, cdn_egress_quota, cache_miss_egress_quota) VALUES (?, ?, ?)',
+ )
+ .bind('ds-quota-cdn', 0, 100)
+ .run()
+
+ await assert.rejects(
+ async () =>
+ await getRetrievalCandidatesByDataSetAndPiece(
+ env,
+ 'ds-quota-cdn',
+ 'piece-quota-cdn',
+ true,
+ ),
+ /CDN egress quota exhausted/,
+ )
+ })
+
+ it('throws 402 when the cache-miss egress quota is exhausted and enforcement is on', async () => {
+ await withApprovedProvider(env, {
+ id: 'sp-quota-cm',
+ serviceUrl: 'https://qcm.xyz',
+ })
+ await withDataSetPiece(env, {
+ payerAddress: '0xabc123def456abc123def456abc123def456abcb',
+ serviceProviderId: 'sp-quota-cm',
+ dataSetId: 'ds-quota-cm',
+ pieceId: 'piece-quota-cm',
+ withCDN: true,
+ withIpfsIndexing: true,
+ ipfsRootCid: 'bafkbyidsquotacm',
+ })
+ await env.DB.prepare(
+ 'INSERT INTO data_set_egress_quotas (data_set_id, cdn_egress_quota, cache_miss_egress_quota) VALUES (?, ?, ?)',
+ )
+ .bind('ds-quota-cm', 100, 0)
+ .run()
+
+ await assert.rejects(
+ async () =>
+ await getRetrievalCandidatesByDataSetAndPiece(
+ env,
+ 'ds-quota-cm',
+ 'piece-quota-cm',
+ true,
+ ),
+ /Cache miss egress quota exhausted/,
+ )
+ })
+
+ it('returns candidates when enforcement is on and quota is sufficient', async () => {
+ await withApprovedProvider(env, {
+ id: 'sp-quota-ok',
+ serviceUrl: 'https://qok.xyz',
+ })
+ await withDataSetPiece(env, {
+ payerAddress: '0xabc123def456abc123def456abc123def456abcc',
+ serviceProviderId: 'sp-quota-ok',
+ dataSetId: 'ds-quota-ok',
+ pieceId: 'piece-quota-ok',
+ withCDN: true,
+ withIpfsIndexing: true,
+ ipfsRootCid: 'bafkbyidsquotaok',
+ })
+ await env.DB.prepare(
+ 'INSERT INTO data_set_egress_quotas (data_set_id, cdn_egress_quota, cache_miss_egress_quota) VALUES (?, ?, ?)',
+ )
+ .bind('ds-quota-ok', 100, 100)
+ .run()
+
+ const result = await getRetrievalCandidatesByDataSetAndPiece(
+ env,
+ 'ds-quota-ok',
+ 'piece-quota-ok',
+ true,
+ )
+
+ assert.strictEqual(result.length, 1)
+ assert.strictEqual(result[0].serviceProviderId, 'sp-quota-ok')
+ })
+})
+
+describe('getSlugForWalletAndCid', () => {
+ const APPROVED_SERVICE_PROVIDER_ID = '30'
+ beforeAll(async () => {
+ await withApprovedProvider(env, {
+ id: APPROVED_SERVICE_PROVIDER_ID,
+ serviceUrl: 'https://approved-provider-slug.xyz',
+ })
+ })
+
+ it('returns slug with version, dataSetId and pieceId encoded in base32', async () => {
+ const dataSetId = '12345'
+ const pieceId = '67890'
+ const ipfsRootCid = 'bafk4slugtest1'
+ const payerAddress = '0xabcdef1234567890abcdef1234567890abcdef34'
+
+ await withDataSetPiece(env, {
+ payerAddress,
+ serviceProviderId: APPROVED_SERVICE_PROVIDER_ID,
+ dataSetId,
+ pieceId,
+ withCDN: true,
+ withIpfsIndexing: true,
+ ipfsRootCid,
+ })
+
+ const result = await getSlugForWalletAndCid(env, payerAddress, ipfsRootCid)
+
+ // Slug format: version-base32(dataSetId)-base32(pieceId)
+ assert.strictEqual(result, '1-ga4q-aeete')
+ })
+
+ it('returns slug with zero-encoded values for dataSetId=0 and pieceId=0', async () => {
+ const dataSetId = '0'
+ const pieceId = '0'
+ const ipfsRootCid = 'bafk4slugtest2'
+ const payerAddress = '0xabcdef1234567890abcdef1234567890abcdef35'
+
+ await withDataSetPiece(env, {
+ payerAddress,
+ serviceProviderId: APPROVED_SERVICE_PROVIDER_ID,
+ dataSetId,
+ pieceId,
+ withCDN: true,
+ withIpfsIndexing: true,
+ ipfsRootCid,
+ })
+
+ const result = await getSlugForWalletAndCid(env, payerAddress, ipfsRootCid)
+
+ // For dataSetId=0 and pieceId=0, bigIntToBase32 returns '0'
+ assert.strictEqual(result, '1-0-0')
+ })
+
+ it('throws error for invalid payer address', async () => {
+ const dataSetId = '99999'
+ const pieceId = '88888'
+ const ipfsRootCid = 'bafk4slugtest3'
+ const validPayerAddress = '0xabcdef1234567890abcdef1234567890abcdef36'
+ const invalidPayerAddress = '0x0000000000000000000000000000000000000000'
+
+ await withDataSetPiece(env, {
+ payerAddress: validPayerAddress,
+ serviceProviderId: APPROVED_SERVICE_PROVIDER_ID,
+ dataSetId,
+ pieceId,
+ withCDN: true,
+ withIpfsIndexing: true,
+ ipfsRootCid,
+ })
+
+ await assert.rejects(
+ async () =>
+ await getSlugForWalletAndCid(env, invalidPayerAddress, ipfsRootCid),
+ /There is no Filecoin Warm Storage Service deal for payer/,
+ )
+ })
+})
diff --git a/ipfs-retriever/test/test-data-builders.js b/ipfs-retriever/test/test-data-builders.js
new file mode 100644
index 00000000..51eab806
--- /dev/null
+++ b/ipfs-retriever/test/test-data-builders.js
@@ -0,0 +1,133 @@
+import { getBadBitsEntry } from '@filbeam/retrieval'
+import { CarWriter } from '@ipld/car'
+import * as raw from 'multiformats/codecs/raw'
+import { sha256 } from 'multiformats/hashes/sha2'
+import { CID } from 'multiformats/cid'
+
+/**
+ * Builds an in-memory CAR holding a single raw block, so tests can exercise the
+ * CAR-to-raw conversion without a live service provider. The CAR is larger than
+ * the raw block it wraps (header + block framing).
+ *
+ * @param {Uint8Array} fileBytes - The raw content to wrap.
+ * @returns {Promise<{ carBytes: Uint8Array; rootCid: string }>}
+ */
+export async function buildRawBlockCar(fileBytes) {
+ const cid = CID.create(1, raw.code, await sha256.digest(fileBytes))
+ const { writer, out } = CarWriter.create([cid])
+
+ /** @type {Uint8Array[]} */
+ const chunks = []
+ const collecting = (async () => {
+ for await (const chunk of out) chunks.push(chunk)
+ })()
+ await writer.put({ cid, bytes: fileBytes })
+ await writer.close()
+ await collecting
+
+ const carBytes = new Uint8Array(chunks.reduce((sum, c) => sum + c.length, 0))
+ let offset = 0
+ for (const chunk of chunks) {
+ carBytes.set(chunk, offset)
+ offset += chunk.length
+ }
+
+ return { carBytes, rootCid: cid.toString() }
+}
+
+/**
+ * @param {Env} env
+ * @param {Object} options
+ * @param {number} options.serviceProviderId
+ * @param {string} options.pieceCid
+ * @param {number} options.dataSetId
+ * @param {boolean} options.withCDN
+ * @param {string} options.payerAddress
+ * @param {string} options.pieceId
+ */
+export async function withDataSetPiece(
+ env,
+ {
+ serviceProviderId = 0,
+ payerAddress = '0x1234567890abcdef1234567890abcdef12345608',
+ pieceCid = 'bagaTEST',
+ ipfsRootCid = 'bafk4test',
+ dataSetId = 0,
+ withCDN = true,
+ withIpfsIndexing = true,
+ pieceId = 0,
+ } = {},
+) {
+ await env.DB.batch([
+ env.DB.prepare(
+ `
+ INSERT INTO data_sets (id, service_provider_id, payer_address, with_cdn, with_ipfs_indexing)
+ VALUES (?, ?, ?, ?, ?)
+ `,
+ ).bind(
+ String(dataSetId),
+ String(serviceProviderId),
+ payerAddress.toLowerCase(),
+ withCDN,
+ withIpfsIndexing,
+ ),
+
+ env.DB.prepare(
+ `
+ INSERT INTO pieces (id, data_set_id, cid, ipfs_root_cid)
+ VALUES (?, ?, ?, ?)
+ `,
+ ).bind(String(pieceId), String(dataSetId), pieceCid, ipfsRootCid ?? null),
+ ])
+}
+
+/**
+ * @param {Env} env
+ * @param {Object} options
+ * @param {number} id
+ * @param {string} [options.serviceUrl]
+ */
+export async function withApprovedProvider(
+ env,
+ { id, serviceUrl = 'https://pdp.xyz/' } = {},
+) {
+ await env.DB.prepare(
+ `
+ INSERT INTO service_providers (id, service_url)
+ VALUES (?, ?)
+ `,
+ )
+ .bind(String(id), serviceUrl)
+ .run()
+}
+
+/**
+ * @param {Env} env
+ * @param {...string} cids
+ */
+export async function withBadBits(env, ...cids) {
+ await Promise.all(
+ cids.map(async (cid) =>
+ env.BAD_BITS_KV.put(`bad-bits:${await getBadBitsEntry(cid)}`, 'true'),
+ ),
+ )
+}
+
+/**
+ * Inserts an address into the database with an optional sanctioned flag.
+ *
+ * @param {Env} env
+ * @param {string} address
+ * @param {boolean} [isSanctioned=false] Default is `false`
+ * @returns {Promise}
+ */
+export async function withWalletDetails(env, address, isSanctioned = false) {
+ await env.DB.prepare(
+ `
+ INSERT INTO wallet_details (address, is_sanctioned)
+ VALUES (?, ?)
+ `,
+ )
+ .bind(address.toLowerCase(), isSanctioned ? 1 : 0)
+ .run()
+}
diff --git a/ipfs-retriever/test/test-data.js b/ipfs-retriever/test/test-data.js
new file mode 100644
index 00000000..548fb57f
--- /dev/null
+++ b/ipfs-retriever/test/test-data.js
@@ -0,0 +1,31 @@
+/**
+ * @type {{
+ * serviceProviderId: string
+ * serviceUrl: string
+ * pieceCid: string
+ * ipfsRootCid: string
+ * dataSetId: number
+ * pieceId: string
+ * }[]}
+ */
+export const CONTENT_STORED_ON_CALIBRATION = [
+ {
+ // This Piece must have IPFS RootCID set and IPFS Indexing enabled at the dataset level
+ serviceProviderId: '2',
+ serviceUrl: 'https://calib2.ezpdpz.net/',
+ pieceCid:
+ 'bafkzcibdzabqtx4ovk72zspicej5vmbjse2237cfzduljnevpmd4kfvccb5h44y4',
+ ipfsRootCid: 'bafkreiheygfzn22dfeos3xoay5cxnfb464znd2rszieyzcinlsgu2z7kau',
+ dataSetId: 14578,
+ pieceId: '0',
+ },
+ {
+ serviceProviderId: '4',
+ serviceUrl: 'https://caliberation-pdp.infrafolio.com/',
+ pieceCid:
+ 'bafkzcibd7r7avok5z3tdn4uq6shuqghxm75e5jgirvdzsdmrkly2u5dldodic4jb',
+ ipfsRootCid: 'bafkreigo55ody3xm4g6mbkitgdytcshanhluzywpi25f3qalfypc7bpna4',
+ dataSetId: 14577,
+ pieceId: '1',
+ },
+]
diff --git a/ipfs-retriever/tsconfig.json b/ipfs-retriever/tsconfig.json
new file mode 100644
index 00000000..e9257aa3
--- /dev/null
+++ b/ipfs-retriever/tsconfig.json
@@ -0,0 +1,15 @@
+{
+ "$schema": "https://json.schemastore.org/tsconfig",
+ "extends": "../tsconfig.base.json",
+ "compilerOptions": {
+ "composite": true,
+ "outDir": "dist"
+ },
+ "include": ["**/*.ts", "**/*.js", "src/**/*.json"],
+ "exclude": ["dist", "test"],
+ "references": [
+ {
+ "path": "../retrieval/tsconfig.json"
+ }
+ ]
+}
diff --git a/ipfs-retriever/vitest.config.js b/ipfs-retriever/vitest.config.js
new file mode 100644
index 00000000..4b7a065e
--- /dev/null
+++ b/ipfs-retriever/vitest.config.js
@@ -0,0 +1,30 @@
+import path from 'node:path'
+import {
+ defineWorkersProject,
+ readD1Migrations,
+} from '@cloudflare/vitest-pool-workers/config'
+
+export default defineWorkersProject(async () => {
+ // Read all migrations in the `migrations` directory
+ const migrationsPath = path.join(__dirname, '../db/migrations')
+ const migrations = await readD1Migrations(migrationsPath)
+ return {
+ test: {
+ setupFiles: ['./test/apply-migrations.js'],
+ poolOptions: {
+ workers: {
+ singleWorker: true,
+ wrangler: {
+ configPath: './wrangler.toml',
+ environment: 'dev',
+ },
+ miniflare: {
+ // Add a test-only binding for migrations, so we can apply them in a
+ // setup file
+ bindings: { TEST_MIGRATIONS: migrations },
+ },
+ },
+ },
+ },
+ }
+})
diff --git a/ipfs-retriever/worker-configuration.d.ts b/ipfs-retriever/worker-configuration.d.ts
new file mode 100644
index 00000000..fc0cfcc4
--- /dev/null
+++ b/ipfs-retriever/worker-configuration.d.ts
@@ -0,0 +1,10905 @@
+/* eslint-disable */
+// Generated by Wrangler by running `wrangler types` (hash: 4d117a9bf0aadb02f8235710f1b9e1a0)
+// Runtime types generated with workerd@1.20260124.0 2024-12-05 enable_request_signal,nodejs_compat
+declare namespace Cloudflare {
+ interface GlobalProps {
+ mainModule: typeof import("./bin/ipfs-retriever");
+ }
+ interface DevEnv {
+ BAD_BITS_KV: KVNamespace;
+ DB: D1Database;
+ ENVIRONMENT: "dev";
+ ORIGIN_CACHE_TTL: 86400;
+ CLIENT_CACHE_TTL: 31536000;
+ DNS_ROOT: ".localhost";
+ ENFORCE_EGRESS_QUOTA: false;
+ BOT_TOKENS: string;
+ }
+ interface CalibrationEnv {
+ BAD_BITS_KV: KVNamespace;
+ DB: D1Database;
+ ENVIRONMENT: "calibration";
+ ORIGIN_CACHE_TTL: 86400;
+ CLIENT_CACHE_TTL: 31536000;
+ DNS_ROOT: ".ipfs.calibration.filbeam.io";
+ ENFORCE_EGRESS_QUOTA: true;
+ BOT_TOKENS: string;
+ }
+ interface MainnetEnv {
+ BAD_BITS_KV: KVNamespace;
+ DB: D1Database;
+ ENVIRONMENT: "mainnet";
+ ORIGIN_CACHE_TTL: 86400;
+ CLIENT_CACHE_TTL: 31536000;
+ DNS_ROOT: ".ipfs.filbeam.io";
+ ENFORCE_EGRESS_QUOTA: true;
+ BOT_TOKENS: string;
+ }
+ interface Env {
+ BOT_TOKENS: string;
+ BAD_BITS_KV: KVNamespace;
+ DB: D1Database;
+ ENVIRONMENT?: "dev" | "calibration" | "mainnet";
+ ORIGIN_CACHE_TTL: 86400;
+ CLIENT_CACHE_TTL: 31536000;
+ DNS_ROOT: ".localhost" | ".ipfs.calibration.filbeam.io" | ".ipfs.filbeam.io";
+ ENFORCE_EGRESS_QUOTA: false | true;
+ }
+}
+interface Env extends Cloudflare.Env {}
+
+// Begin runtime types
+/*! *****************************************************************************
+Copyright (c) Cloudflare. All rights reserved.
+Copyright (c) Microsoft Corporation. All rights reserved.
+
+Licensed under the Apache License, Version 2.0 (the "License"); you may not use
+this file except in compliance with the License. You may obtain a copy of the
+License at http://www.apache.org/licenses/LICENSE-2.0
+THIS CODE IS PROVIDED ON AN *AS IS* BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+KIND, EITHER EXPRESS OR IMPLIED, INCLUDING WITHOUT LIMITATION ANY IMPLIED
+WARRANTIES OR CONDITIONS OF TITLE, FITNESS FOR A PARTICULAR PURPOSE,
+MERCHANTABLITY OR NON-INFRINGEMENT.
+See the Apache Version 2.0 License for specific language governing permissions
+and limitations under the License.
+***************************************************************************** */
+/* eslint-disable */
+// noinspection JSUnusedGlobalSymbols
+declare var onmessage: never;
+/**
+ * The **`DOMException`** interface represents an abnormal event (called an **exception**) that occurs as a result of calling a method or accessing a property of a web API.
+ *
+ * [MDN Reference](https://developer.mozilla.org/docs/Web/API/DOMException)
+ */
+declare class DOMException extends Error {
+ constructor(message?: string, name?: string);
+ /**
+ * The **`message`** read-only property of the a message or description associated with the given error name.
+ *
+ * [MDN Reference](https://developer.mozilla.org/docs/Web/API/DOMException/message)
+ */
+ readonly message: string;
+ /**
+ * The **`name`** read-only property of the one of the strings associated with an error name.
+ *
+ * [MDN Reference](https://developer.mozilla.org/docs/Web/API/DOMException/name)
+ */
+ readonly name: string;
+ /**
+ * The **`code`** read-only property of the DOMException interface returns one of the legacy error code constants, or `0` if none match.
+ * @deprecated
+ *
+ * [MDN Reference](https://developer.mozilla.org/docs/Web/API/DOMException/code)
+ */
+ readonly code: number;
+ static readonly INDEX_SIZE_ERR: number;
+ static readonly DOMSTRING_SIZE_ERR: number;
+ static readonly HIERARCHY_REQUEST_ERR: number;
+ static readonly WRONG_DOCUMENT_ERR: number;
+ static readonly INVALID_CHARACTER_ERR: number;
+ static readonly NO_DATA_ALLOWED_ERR: number;
+ static readonly NO_MODIFICATION_ALLOWED_ERR: number;
+ static readonly NOT_FOUND_ERR: number;
+ static readonly NOT_SUPPORTED_ERR: number;
+ static readonly INUSE_ATTRIBUTE_ERR: number;
+ static readonly INVALID_STATE_ERR: number;
+ static readonly SYNTAX_ERR: number;
+ static readonly INVALID_MODIFICATION_ERR: number;
+ static readonly NAMESPACE_ERR: number;
+ static readonly INVALID_ACCESS_ERR: number;
+ static readonly VALIDATION_ERR: number;
+ static readonly TYPE_MISMATCH_ERR: number;
+ static readonly SECURITY_ERR: number;
+ static readonly NETWORK_ERR: number;
+ static readonly ABORT_ERR: number;
+ static readonly URL_MISMATCH_ERR: number;
+ static readonly QUOTA_EXCEEDED_ERR: number;
+ static readonly TIMEOUT_ERR: number;
+ static readonly INVALID_NODE_TYPE_ERR: number;
+ static readonly DATA_CLONE_ERR: number;
+ get stack(): any;
+ set stack(value: any);
+}
+type WorkerGlobalScopeEventMap = {
+ fetch: FetchEvent;
+ scheduled: ScheduledEvent;
+ queue: QueueEvent;
+ unhandledrejection: PromiseRejectionEvent;
+ rejectionhandled: PromiseRejectionEvent;
+};
+declare abstract class WorkerGlobalScope extends EventTarget {
+ EventTarget: typeof EventTarget;
+}
+/* The **`console`** object provides access to the debugging console (e.g., the Web console in Firefox). *
+ * The **`console`** object provides access to the debugging console (e.g., the Web console in Firefox).
+ *
+ * [MDN Reference](https://developer.mozilla.org/docs/Web/API/console)
+ */
+interface Console {
+ "assert"(condition?: boolean, ...data: any[]): void;
+ /**
+ * The **`console.clear()`** static method clears the console if possible.
+ *
+ * [MDN Reference](https://developer.mozilla.org/docs/Web/API/console/clear_static)
+ */
+ clear(): void;
+ /**
+ * The **`console.count()`** static method logs the number of times that this particular call to `count()` has been called.
+ *
+ * [MDN Reference](https://developer.mozilla.org/docs/Web/API/console/count_static)
+ */
+ count(label?: string): void;
+ /**
+ * The **`console.countReset()`** static method resets counter used with console/count_static.
+ *
+ * [MDN Reference](https://developer.mozilla.org/docs/Web/API/console/countReset_static)
+ */
+ countReset(label?: string): void;
+ /**
+ * The **`console.debug()`** static method outputs a message to the console at the 'debug' log level.
+ *
+ * [MDN Reference](https://developer.mozilla.org/docs/Web/API/console/debug_static)
+ */
+ debug(...data: any[]): void;
+ /**
+ * The **`console.dir()`** static method displays a list of the properties of the specified JavaScript object.
+ *
+ * [MDN Reference](https://developer.mozilla.org/docs/Web/API/console/dir_static)
+ */
+ dir(item?: any, options?: any): void;
+ /**
+ * The **`console.dirxml()`** static method displays an interactive tree of the descendant elements of the specified XML/HTML element.
+ *
+ * [MDN Reference](https://developer.mozilla.org/docs/Web/API/console/dirxml_static)
+ */
+ dirxml(...data: any[]): void;
+ /**
+ * The **`console.error()`** static method outputs a message to the console at the 'error' log level.
+ *
+ * [MDN Reference](https://developer.mozilla.org/docs/Web/API/console/error_static)
+ */
+ error(...data: any[]): void;
+ /**
+ * The **`console.group()`** static method creates a new inline group in the Web console log, causing any subsequent console messages to be indented by an additional level, until console/groupEnd_static is called.
+ *
+ * [MDN Reference](https://developer.mozilla.org/docs/Web/API/console/group_static)
+ */
+ group(...data: any[]): void;
+ /**
+ * The **`console.groupCollapsed()`** static method creates a new inline group in the console.
+ *
+ * [MDN Reference](https://developer.mozilla.org/docs/Web/API/console/groupCollapsed_static)
+ */
+ groupCollapsed(...data: any[]): void;
+ /**
+ * The **`console.groupEnd()`** static method exits the current inline group in the console.
+ *
+ * [MDN Reference](https://developer.mozilla.org/docs/Web/API/console/groupEnd_static)
+ */
+ groupEnd(): void;
+ /**
+ * The **`console.info()`** static method outputs a message to the console at the 'info' log level.
+ *
+ * [MDN Reference](https://developer.mozilla.org/docs/Web/API/console/info_static)
+ */
+ info(...data: any[]): void;
+ /**
+ * The **`console.log()`** static method outputs a message to the console.
+ *
+ * [MDN Reference](https://developer.mozilla.org/docs/Web/API/console/log_static)
+ */
+ log(...data: any[]): void;
+ /**
+ * The **`console.table()`** static method displays tabular data as a table.
+ *
+ * [MDN Reference](https://developer.mozilla.org/docs/Web/API/console/table_static)
+ */
+ table(tabularData?: any, properties?: string[]): void;
+ /**
+ * The **`console.time()`** static method starts a timer you can use to track how long an operation takes.
+ *
+ * [MDN Reference](https://developer.mozilla.org/docs/Web/API/console/time_static)
+ */
+ time(label?: string): void;
+ /**
+ * The **`console.timeEnd()`** static method stops a timer that was previously started by calling console/time_static.
+ *
+ * [MDN Reference](https://developer.mozilla.org/docs/Web/API/console/timeEnd_static)
+ */
+ timeEnd(label?: string): void;
+ /**
+ * The **`console.timeLog()`** static method logs the current value of a timer that was previously started by calling console/time_static.
+ *
+ * [MDN Reference](https://developer.mozilla.org/docs/Web/API/console/timeLog_static)
+ */
+ timeLog(label?: string, ...data: any[]): void;
+ timeStamp(label?: string): void;
+ /**
+ * The **`console.trace()`** static method outputs a stack trace to the console.
+ *
+ * [MDN Reference](https://developer.mozilla.org/docs/Web/API/console/trace_static)
+ */
+ trace(...data: any[]): void;
+ /**
+ * The **`console.warn()`** static method outputs a warning message to the console at the 'warning' log level.
+ *
+ * [MDN Reference](https://developer.mozilla.org/docs/Web/API/console/warn_static)
+ */
+ warn(...data: any[]): void;
+}
+declare const console: Console;
+type BufferSource = ArrayBufferView | ArrayBuffer;
+type TypedArray = Int8Array | Uint8Array | Uint8ClampedArray | Int16Array | Uint16Array | Int32Array | Uint32Array | Float32Array | Float64Array | BigInt64Array | BigUint64Array;
+declare namespace WebAssembly {
+ class CompileError extends Error {
+ constructor(message?: string);
+ }
+ class RuntimeError extends Error {
+ constructor(message?: string);
+ }
+ type ValueType = "anyfunc" | "externref" | "f32" | "f64" | "i32" | "i64" | "v128";
+ interface GlobalDescriptor {
+ value: ValueType;
+ mutable?: boolean;
+ }
+ class Global {
+ constructor(descriptor: GlobalDescriptor, value?: any);
+ value: any;
+ valueOf(): any;
+ }
+ type ImportValue = ExportValue | number;
+ type ModuleImports = Record;
+ type Imports = Record;
+ type ExportValue = Function | Global | Memory | Table;
+ type Exports = Record;
+ class Instance {
+ constructor(module: Module, imports?: Imports);
+ readonly exports: Exports;
+ }
+ interface MemoryDescriptor {
+ initial: number;
+ maximum?: number;
+ shared?: boolean;
+ }
+ class Memory {
+ constructor(descriptor: MemoryDescriptor);
+ readonly buffer: ArrayBuffer;
+ grow(delta: number): number;
+ }
+ type ImportExportKind = "function" | "global" | "memory" | "table";
+ interface ModuleExportDescriptor {
+ kind: ImportExportKind;
+ name: string;
+ }
+ interface ModuleImportDescriptor {
+ kind: ImportExportKind;
+ module: string;
+ name: string;
+ }
+ abstract class Module {
+ static customSections(module: Module, sectionName: string): ArrayBuffer[];
+ static exports(module: Module): ModuleExportDescriptor[];
+ static imports(module: Module): ModuleImportDescriptor[];
+ }
+ type TableKind = "anyfunc" | "externref";
+ interface TableDescriptor {
+ element: TableKind;
+ initial: number;
+ maximum?: number;
+ }
+ class Table {
+ constructor(descriptor: TableDescriptor, value?: any);
+ readonly length: number;
+ get(index: number): any;
+ grow(delta: number, value?: any): number;
+ set(index: number, value?: any): void;
+ }
+ function instantiate(module: Module, imports?: Imports): Promise;
+ function validate(bytes: BufferSource): boolean;
+}
+/**
+ * The **`ServiceWorkerGlobalScope`** interface of the Service Worker API represents the global execution context of a service worker.
+ * Available only in secure contexts.
+ *
+ * [MDN Reference](https://developer.mozilla.org/docs/Web/API/ServiceWorkerGlobalScope)
+ */
+interface ServiceWorkerGlobalScope extends WorkerGlobalScope {
+ DOMException: typeof DOMException;
+ WorkerGlobalScope: typeof WorkerGlobalScope;
+ btoa(data: string): string;
+ atob(data: string): string;
+ setTimeout(callback: (...args: any[]) => void, msDelay?: number): number;
+ setTimeout(callback: (...args: Args) => void, msDelay?: number, ...args: Args): number;
+ clearTimeout(timeoutId: number | null): void;
+ setInterval(callback: (...args: any[]) => void, msDelay?: number): number;
+ setInterval(callback: (...args: Args) => void, msDelay?: number, ...args: Args): number;
+ clearInterval(timeoutId: number | null): void;
+ queueMicrotask(task: Function): void;
+ structuredClone(value: T, options?: StructuredSerializeOptions): T;
+ reportError(error: any): void;
+ fetch(input: RequestInfo | URL, init?: RequestInit): Promise;
+ self: ServiceWorkerGlobalScope;
+ crypto: Crypto;
+ caches: CacheStorage;
+ scheduler: Scheduler;
+ performance: Performance;
+ Cloudflare: Cloudflare;
+ readonly origin: string;
+ Event: typeof Event;
+ ExtendableEvent: typeof ExtendableEvent;
+ CustomEvent: typeof CustomEvent;
+ PromiseRejectionEvent: typeof PromiseRejectionEvent;
+ FetchEvent: typeof FetchEvent;
+ TailEvent: typeof TailEvent;
+ TraceEvent: typeof TailEvent;
+ ScheduledEvent: typeof ScheduledEvent;
+ MessageEvent: typeof MessageEvent;
+ CloseEvent: typeof CloseEvent;
+ ReadableStreamDefaultReader: typeof ReadableStreamDefaultReader;
+ ReadableStreamBYOBReader: typeof ReadableStreamBYOBReader;
+ ReadableStream: typeof ReadableStream;
+ WritableStream: typeof WritableStream;
+ WritableStreamDefaultWriter: typeof WritableStreamDefaultWriter;
+ TransformStream: typeof TransformStream;
+ ByteLengthQueuingStrategy: typeof ByteLengthQueuingStrategy;
+ CountQueuingStrategy: typeof CountQueuingStrategy;
+ ErrorEvent: typeof ErrorEvent;
+ EventSource: typeof EventSource;
+ ReadableStreamBYOBRequest: typeof ReadableStreamBYOBRequest;
+ ReadableStreamDefaultController: typeof ReadableStreamDefaultController;
+ ReadableByteStreamController: typeof ReadableByteStreamController;
+ WritableStreamDefaultController: typeof WritableStreamDefaultController;
+ TransformStreamDefaultController: typeof TransformStreamDefaultController;
+ CompressionStream: typeof CompressionStream;
+ DecompressionStream: typeof DecompressionStream;
+ TextEncoderStream: typeof TextEncoderStream;
+ TextDecoderStream: typeof TextDecoderStream;
+ Headers: typeof Headers;
+ Body: typeof Body;
+ Request: typeof Request;
+ Response: typeof Response;
+ WebSocket: typeof WebSocket;
+ WebSocketPair: typeof WebSocketPair;
+ WebSocketRequestResponsePair: typeof WebSocketRequestResponsePair;
+ AbortController: typeof AbortController;
+ AbortSignal: typeof AbortSignal;
+ TextDecoder: typeof TextDecoder;
+ TextEncoder: typeof TextEncoder;
+ navigator: Navigator;
+ Navigator: typeof Navigator;
+ URL: typeof URL;
+ URLSearchParams: typeof URLSearchParams;
+ URLPattern: typeof URLPattern;
+ Blob: typeof Blob;
+ File: typeof File;
+ FormData: typeof FormData;
+ Crypto: typeof Crypto;
+ SubtleCrypto: typeof SubtleCrypto;
+ CryptoKey: typeof CryptoKey;
+ CacheStorage: typeof CacheStorage;
+ Cache: typeof Cache;
+ FixedLengthStream: typeof FixedLengthStream;
+ IdentityTransformStream: typeof IdentityTransformStream;
+ HTMLRewriter: typeof HTMLRewriter;
+}
+declare function addEventListener(type: Type, handler: EventListenerOrEventListenerObject, options?: EventTargetAddEventListenerOptions | boolean): void;
+declare function removeEventListener(type: Type, handler: EventListenerOrEventListenerObject, options?: EventTargetEventListenerOptions | boolean): void;
+/**
+ * The **`dispatchEvent()`** method of the EventTarget sends an Event to the object, (synchronously) invoking the affected event listeners in the appropriate order.
+ *
+ * [MDN Reference](https://developer.mozilla.org/docs/Web/API/EventTarget/dispatchEvent)
+ */
+declare function dispatchEvent(event: WorkerGlobalScopeEventMap[keyof WorkerGlobalScopeEventMap]): boolean;
+/* [MDN Reference](https://developer.mozilla.org/docs/Web/API/Window/btoa) */
+declare function btoa(data: string): string;
+/* [MDN Reference](https://developer.mozilla.org/docs/Web/API/Window/atob) */
+declare function atob(data: string): string;
+/* [MDN Reference](https://developer.mozilla.org/docs/Web/API/Window/setTimeout) */
+declare function setTimeout(callback: (...args: any[]) => void, msDelay?: number): number;
+/* [MDN Reference](https://developer.mozilla.org/docs/Web/API/Window/setTimeout) */
+declare function setTimeout(callback: (...args: Args) => void, msDelay?: number, ...args: Args): number;
+/* [MDN Reference](https://developer.mozilla.org/docs/Web/API/Window/clearTimeout) */
+declare function clearTimeout(timeoutId: number | null): void;
+/* [MDN Reference](https://developer.mozilla.org/docs/Web/API/Window/setInterval) */
+declare function setInterval(callback: (...args: any[]) => void, msDelay?: number): number;
+/* [MDN Reference](https://developer.mozilla.org/docs/Web/API/Window/setInterval) */
+declare function setInterval(callback: (...args: Args) => void, msDelay?: number, ...args: Args): number;
+/* [MDN Reference](https://developer.mozilla.org/docs/Web/API/Window/clearInterval) */
+declare function clearInterval(timeoutId: number | null): void;
+/* [MDN Reference](https://developer.mozilla.org/docs/Web/API/Window/queueMicrotask) */
+declare function queueMicrotask(task: Function): void;
+/* [MDN Reference](https://developer.mozilla.org/docs/Web/API/Window/structuredClone) */
+declare function structuredClone(value: T, options?: StructuredSerializeOptions): T;
+/* [MDN Reference](https://developer.mozilla.org/docs/Web/API/Window/reportError) */
+declare function reportError(error: any): void;
+/* [MDN Reference](https://developer.mozilla.org/docs/Web/API/Window/fetch) */
+declare function fetch(input: RequestInfo | URL, init?: RequestInit): Promise;
+declare const self: ServiceWorkerGlobalScope;
+/**
+* The Web Crypto API provides a set of low-level functions for common cryptographic tasks.
+* The Workers runtime implements the full surface of this API, but with some differences in
+* the [supported algorithms](https://developers.cloudflare.com/workers/runtime-apis/web-crypto/#supported-algorithms)
+* compared to those implemented in most browsers.
+*
+* [Cloudflare Docs Reference](https://developers.cloudflare.com/workers/runtime-apis/web-crypto/)
+*/
+declare const crypto: Crypto;
+/**
+* The Cache API allows fine grained control of reading and writing from the Cloudflare global network cache.
+*
+* [Cloudflare Docs Reference](https://developers.cloudflare.com/workers/runtime-apis/cache/)
+*/
+declare const caches: CacheStorage;
+declare const scheduler: Scheduler;
+/**
+* The Workers runtime supports a subset of the Performance API, used to measure timing and performance,
+* as well as timing of subrequests and other operations.
+*
+* [Cloudflare Docs Reference](https://developers.cloudflare.com/workers/runtime-apis/performance/)
+*/
+declare const performance: Performance;
+declare const Cloudflare: Cloudflare;
+declare const origin: string;
+declare const navigator: Navigator;
+interface TestController {
+}
+interface ExecutionContext {
+ waitUntil(promise: Promise): void;
+ passThroughOnException(): void;
+ readonly props: Props;
+}
+type ExportedHandlerFetchHandler = (request: Request>, env: Env, ctx: ExecutionContext) => Response | Promise;
+type ExportedHandlerTailHandler = (events: TraceItem[], env: Env, ctx: ExecutionContext) => void | Promise;
+type ExportedHandlerTraceHandler = (traces: TraceItem[], env: Env, ctx: ExecutionContext) => void | Promise;
+type ExportedHandlerTailStreamHandler = (event: TailStream.TailEvent, env: Env, ctx: ExecutionContext) => TailStream.TailEventHandlerType | Promise;
+type ExportedHandlerScheduledHandler = (controller: ScheduledController, env: Env, ctx: ExecutionContext) => void | Promise;
+type ExportedHandlerQueueHandler = (batch: MessageBatch, env: Env, ctx: ExecutionContext) => void | Promise;
+type ExportedHandlerTestHandler = (controller: TestController, env: Env, ctx: ExecutionContext) => void | Promise;
+interface ExportedHandler {
+ fetch?: ExportedHandlerFetchHandler;
+ tail?: ExportedHandlerTailHandler;
+ trace?: ExportedHandlerTraceHandler;
+ tailStream?: ExportedHandlerTailStreamHandler;
+ scheduled?: ExportedHandlerScheduledHandler;
+ test?: ExportedHandlerTestHandler;
+ email?: EmailExportedHandler;
+ queue?: ExportedHandlerQueueHandler;
+}
+interface StructuredSerializeOptions {
+ transfer?: any[];
+}
+declare abstract class Navigator {
+ sendBeacon(url: string, body?: BodyInit): boolean;
+ readonly userAgent: string;
+ readonly hardwareConcurrency: number;
+}
+interface AlarmInvocationInfo {
+ readonly isRetry: boolean;
+ readonly retryCount: number;
+}
+interface Cloudflare {
+ readonly compatibilityFlags: Record;
+}
+interface DurableObject {
+ fetch(request: Request): Response | Promise;
+ alarm?(alarmInfo?: AlarmInvocationInfo): void | Promise;
+ webSocketMessage?(ws: WebSocket, message: string | ArrayBuffer): void | Promise;
+ webSocketClose?(ws: WebSocket, code: number, reason: string, wasClean: boolean): void | Promise;
+ webSocketError?(ws: WebSocket, error: unknown): void | Promise;
+}
+type DurableObjectStub = Fetcher & {
+ readonly id: DurableObjectId;
+ readonly name?: string;
+};
+interface DurableObjectId {
+ toString(): string;
+ equals(other: DurableObjectId): boolean;
+ readonly name?: string;
+}
+declare abstract class DurableObjectNamespace {
+ newUniqueId(options?: DurableObjectNamespaceNewUniqueIdOptions): DurableObjectId;
+ idFromName(name: string): DurableObjectId;
+ idFromString(id: string): DurableObjectId;
+ get(id: DurableObjectId, options?: DurableObjectNamespaceGetDurableObjectOptions): DurableObjectStub;
+ getByName(name: string, options?: DurableObjectNamespaceGetDurableObjectOptions): DurableObjectStub;
+ jurisdiction(jurisdiction: DurableObjectJurisdiction): DurableObjectNamespace;
+}
+type DurableObjectJurisdiction = "eu" | "fedramp" | "fedramp-high";
+interface DurableObjectNamespaceNewUniqueIdOptions {
+ jurisdiction?: DurableObjectJurisdiction;
+}
+type DurableObjectLocationHint = "wnam" | "enam" | "sam" | "weur" | "eeur" | "apac" | "oc" | "afr" | "me";
+type DurableObjectRoutingMode = "primary-only";
+interface DurableObjectNamespaceGetDurableObjectOptions {
+ locationHint?: DurableObjectLocationHint;
+ routingMode?: DurableObjectRoutingMode;
+}
+interface DurableObjectClass<_T extends Rpc.DurableObjectBranded | undefined = undefined> {
+}
+interface DurableObjectState {
+ waitUntil(promise: Promise): void;
+ readonly props: Props;
+ readonly id: DurableObjectId;
+ readonly storage: DurableObjectStorage;
+ container?: Container;
+ blockConcurrencyWhile(callback: () => Promise): Promise;
+ acceptWebSocket(ws: WebSocket, tags?: string[]): void;
+ getWebSockets(tag?: string): WebSocket[];
+ setWebSocketAutoResponse(maybeReqResp?: WebSocketRequestResponsePair): void;
+ getWebSocketAutoResponse(): WebSocketRequestResponsePair | null;
+ getWebSocketAutoResponseTimestamp(ws: WebSocket): Date | null;
+ setHibernatableWebSocketEventTimeout(timeoutMs?: number): void;
+ getHibernatableWebSocketEventTimeout(): number | null;
+ getTags(ws: WebSocket): string[];
+ abort(reason?: string): void;
+}
+interface DurableObjectTransaction {
+ get(key: string, options?: DurableObjectGetOptions): Promise;
+ get(keys: string[], options?: DurableObjectGetOptions): Promise