├── .npmignore ├── .gitignore ├── docs ├── freeway.png ├── ucan-handler.md └── freeway.tldr ├── src ├── constants.js ├── middleware │ ├── withCarBlockHandler.types.ts │ ├── withAuthToken.types.ts │ ├── withCarParkFetch.types.ts │ ├── withVersionHeader.js │ ├── withGatewayIdentity.types.ts │ ├── withLocator.types.ts │ ├── withEgressTracker.types.ts │ ├── withOptionsRequest.js │ ├── withEgressClient.types.ts │ ├── withFormatCarHandler.js │ ├── withContentClaimsDagula.types.ts │ ├── withFormatRawHandler.js │ ├── withRateLimit.types.ts │ ├── withGatewayIdentity.js │ ├── withUcanInvocationHandler.types.ts │ ├── index.js │ ├── withAuthorizedSpace.types.ts │ ├── withAuthToken.js │ ├── withDidDocumentHandler.js │ ├── withUcanInvocationHandler.js │ ├── withDelegationsStorage.types.ts │ ├── withDelegationStubs.js │ ├── withCarParkFetch.js │ ├── withLocator.js │ ├── withEgressClient.js │ ├── withDelegationsStorage.js │ ├── withRateLimit.js │ ├── withContentClaimsDagula.js │ ├── withEgressTracker.js │ ├── withCarBlockHandler.js │ └── withAuthorizedSpace.js ├── server │ ├── api.types.ts │ ├── utils.js │ ├── service.js │ └── index.js ├── capabilities │ └── serve.js ├── bindings.d.ts └── index.js ├── scripts ├── delegate-serve.d.ts ├── debug │ ├── README.md │ ├── wrangler.toml │ ├── package.json │ └── src │ │ └── index.js ├── r2-put.js ├── mk-validator-proof.js └── delegate-serve.js ├── test ├── unit │ └── middleware │ │ ├── util │ │ ├── rejection.js │ │ ├── createTestCID.js │ │ ├── expectToBeInstanceOf.js │ │ └── strictStub.js │ │ ├── withAuthToken.spec.js │ │ ├── withUcanInvocationHandler.spec.js │ │ ├── withDelegationsStorage.spec.js │ │ ├── withRateLimit.spec.js │ │ └── withAuthorizedSpace.spec.js ├── helpers │ ├── bucket.js │ ├── builder.js │ ├── run-wrangler.js │ └── content-claims.js ├── integration │ └── rate-limit.spec.js └── fixtures │ └── worker-fixture.js ├── .github ├── workflows │ ├── test.yml │ └── release.yml └── actions │ └── test │ └── action.yml ├── tsconfig.json ├── package.json ├── README.md ├── LICENSE.md └── wrangler.toml /.npmignore: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist 3 | .mf 4 | .env 5 | .dev.vars* 6 | .wrangler 7 | -------------------------------------------------------------------------------- /docs/freeway.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/storacha/freeway/HEAD/docs/freeway.png -------------------------------------------------------------------------------- /src/constants.js: -------------------------------------------------------------------------------- 1 | export const CAR_CODE = 0x0202 2 | 3 | export const RATE_LIMIT_EXCEEDED = { 4 | NO: 0, 5 | YES: 1, 6 | MAYBE: 2 7 | } 8 | -------------------------------------------------------------------------------- /scripts/delegate-serve.d.ts: -------------------------------------------------------------------------------- 1 | declare module '@storacha/cli/lib.js' { 2 | import { Client } from '@storacha/client' 3 | export declare function getClient(): Promise 4 | } 5 | -------------------------------------------------------------------------------- /scripts/debug/README.md: -------------------------------------------------------------------------------- 1 | # freeway-debug 2 | 3 | A stripped down freeway for debugging. Just run `npx wrangler dev` from this directory to start a freeway server that points to production on `http://localhost:8787`. 4 | -------------------------------------------------------------------------------- /src/middleware/withCarBlockHandler.types.ts: -------------------------------------------------------------------------------- 1 | import { Environment as MiddlewareEnvironment } from '@web3-storage/gateway-lib' 2 | 3 | export interface Environment extends MiddlewareEnvironment { 4 | CARPARK: R2Bucket 5 | } -------------------------------------------------------------------------------- /scripts/debug/wrangler.toml: -------------------------------------------------------------------------------- 1 | name = "freeway-debug" 2 | main = "./dist/worker.mjs" 3 | compatibility_flags = [ "nodejs_compat" ] 4 | compatibility_date = "2025-09-01" 5 | 6 | [vars] 7 | FF_RAMP_UP_PROBABILITY = "100" 8 | 9 | [build] 10 | command = "npm run build" 11 | -------------------------------------------------------------------------------- /test/unit/middleware/util/rejection.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Resolves to the reason for the rejection of a promise, or `undefined` if the 3 | * promise resolves. 4 | * @param {Promise} promise 5 | * @returns {Promise} 6 | */ 7 | export const rejection = (promise) => promise.then(() => { }).catch((err) => err) 8 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Test 2 | on: 3 | push: 4 | branches: 5 | - main 6 | pull_request: 7 | branches: 8 | - main 9 | 10 | jobs: 11 | test: 12 | name: Test 13 | runs-on: ubuntu-latest 14 | steps: 15 | - uses: actions/checkout@v3 16 | - uses: ./.github/actions/test 17 | -------------------------------------------------------------------------------- /src/middleware/withAuthToken.types.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Environment as MiddlewareEnvironment, 3 | Context as MiddlewareContext, 4 | } from '@web3-storage/gateway-lib' 5 | 6 | export interface Environment extends MiddlewareEnvironment {} 7 | 8 | export interface AuthTokenContextIn extends MiddlewareContext {} 9 | 10 | export interface AuthTokenContext extends MiddlewareContext { 11 | authToken: string | null 12 | } 13 | -------------------------------------------------------------------------------- /src/middleware/withCarParkFetch.types.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Environment as MiddlewareEnvironment, 3 | Context as MiddlewareContext, 4 | } from '@web3-storage/gateway-lib' 5 | 6 | export interface CarParkFetchEnvironment extends MiddlewareEnvironment { 7 | CARPARK: R2Bucket 8 | CARPARK_PUBLIC_BUCKET_URL?: string 9 | } 10 | 11 | export interface CarParkFetchContext extends MiddlewareContext { 12 | fetch: typeof globalThis.fetch 13 | } 14 | -------------------------------------------------------------------------------- /src/middleware/withVersionHeader.js: -------------------------------------------------------------------------------- 1 | import conf from '../../package.json' with { type: 'json' } 2 | 3 | /** 4 | * @import { Middleware, Context } from '@web3-storage/gateway-lib' 5 | */ 6 | 7 | /** 8 | * @type {Middleware} 9 | */ 10 | export function withVersionHeader (handler) { 11 | return async (request, env, ctx) => { 12 | const response = await handler(request, env, ctx) 13 | response.headers.set('x-freeway-version', conf.version) 14 | return response 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /test/unit/middleware/util/createTestCID.js: -------------------------------------------------------------------------------- 1 | import { CID } from 'multiformats' 2 | import * as raw from 'multiformats/codecs/raw' 3 | import { identity } from 'multiformats/hashes/identity' 4 | 5 | /** 6 | * Creates a CID from an integer. The CID should be both unique and consistent 7 | * for any value of {@link n}, making it useful for testing. 8 | * 9 | * @param {number} n 10 | * @returns {CID} 11 | */ 12 | export const createTestCID = (n) => { 13 | return CID.createV1(raw.code, identity.digest(Uint8Array.of(n))) 14 | } 15 | -------------------------------------------------------------------------------- /src/server/api.types.ts: -------------------------------------------------------------------------------- 1 | import { InferInvokedCapability, ServiceMethod, Unit } from '@ucanto/interface'; 2 | import { Failure } from '@ucanto/interface'; 3 | import { Access as AccessCapabilities } from '@storacha/capabilities'; 4 | 5 | export type AccessDelegation = InferInvokedCapability 6 | 7 | export interface Service { 8 | access: ContentServeAuthService 9 | } 10 | 11 | export interface ContentServeAuthService { 12 | delegate: ServiceMethod 13 | } 14 | -------------------------------------------------------------------------------- /src/middleware/withGatewayIdentity.types.ts: -------------------------------------------------------------------------------- 1 | import { Environment as MiddlewareEnvironment, Context as MiddlewareContext } from '@web3-storage/gateway-lib' 2 | import * as Ucanto from '@ucanto/interface' 3 | import { EdSigner } from '@ucanto/principal/ed25519' 4 | 5 | export interface Environment extends MiddlewareEnvironment { 6 | GATEWAY_PRINCIPAL_KEY: string 7 | GATEWAY_SERVICE_DID: string 8 | } 9 | 10 | export interface GatewayIdentityContext extends MiddlewareContext { 11 | gatewaySigner: Ucanto.Signer 12 | gatewayIdentity: Ucanto.Signer 13 | } 14 | -------------------------------------------------------------------------------- /test/unit/middleware/util/expectToBeInstanceOf.js: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai' 2 | 3 | /** 4 | * Asserts that a value is an instance of a class, in a way that TypeScript can 5 | * understand too. Just a simple wrapper around Chai's `instanceOf`, typed as an 6 | * assertion function. 7 | * 8 | * @template {Function} Class 9 | * @param {unknown} value 10 | * @param {Class} aClass 11 | * @returns {asserts value is InstanceType} 12 | */ 13 | export const expectToBeInstanceOf = (value, aClass) => { 14 | expect(value).to.be.instanceOf(aClass) 15 | } 16 | -------------------------------------------------------------------------------- /scripts/debug/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "freeway-debug", 3 | "version": "0.0.0", 4 | "description": "An IPFS gateway for accessing UnixFS data via CAR CIDs", 5 | "license": "Apache-2.0 OR MIT", 6 | "author": "Alan Shaw", 7 | "type": "module", 8 | "exports": { 9 | ".": { 10 | "import": "./src/index.js", 11 | "types": "./dist/src/index.d.ts" 12 | } 13 | }, 14 | "main": "src/index.js", 15 | "scripts": { 16 | "build": "esbuild --bundle src/index.js --format=esm --outfile=dist/worker.mjs", 17 | "build:tsc": "tsc --build" 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/middleware/withLocator.types.ts: -------------------------------------------------------------------------------- 1 | import { Locator } from '@web3-storage/blob-fetcher' 2 | import { 3 | Environment as MiddlewareEnvironment, 4 | Context as MiddlewareContext, 5 | IpfsUrlContext, 6 | } from '@web3-storage/gateway-lib' 7 | 8 | export interface LocatorEnvironment extends MiddlewareEnvironment { 9 | CONTENT_CLAIMS_SERVICE_URL?: string 10 | CARPARK: R2Bucket 11 | CARPARK_PUBLIC_BUCKET_URL?: string 12 | INDEXING_SERVICE_URL?: string 13 | FF_RAMP_UP_PROBABILITY?: string 14 | } 15 | 16 | export interface LocatorContext extends IpfsUrlContext { 17 | locator: Locator 18 | } 19 | -------------------------------------------------------------------------------- /src/middleware/withEgressTracker.types.ts: -------------------------------------------------------------------------------- 1 | import { IpfsUrlContext, Environment as MiddlewareEnvironment } from '@web3-storage/gateway-lib' 2 | import { SpaceContext, DelegationProofsContext } from './withAuthorizedSpace.types.js' 3 | import { GatewayIdentityContext } from './withGatewayIdentity.types.js' 4 | 5 | export interface Environment extends MiddlewareEnvironment { 6 | FF_EGRESS_TRACKER_ENABLED: string 7 | FF_EGRESS_TRACKER_ROLLOUT_PERCENTAGE?: string 8 | EGRESS_QUEUE: Queue 9 | UPLOAD_SERVICE_DID: string 10 | } 11 | 12 | export interface Context extends IpfsUrlContext, SpaceContext, GatewayIdentityContext, DelegationProofsContext { 13 | } 14 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "include": ["src", "test", "scripts"], 3 | "exclude": ["scripts/r2-put.js"], 4 | "compilerOptions": { 5 | "declaration": true, 6 | "declarationMap": true, 7 | "emitDeclarationOnly": true, 8 | "outDir": "dist", 9 | "allowJs": true, 10 | "checkJs": true, 11 | "strict": true, 12 | "esModuleInterop": true, 13 | "module": "node18", 14 | "moduleResolution": "node16", 15 | "skipLibCheck": true, 16 | "resolveJsonModule": true, 17 | "lib": ["ES2022", "DOM", "DOM.Iterable"], 18 | "target": "ES2022", 19 | "sourceMap": true, 20 | "types": ["./worker-configuration.d.ts", "node"] 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/middleware/withOptionsRequest.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @import { Middleware, Context } from '@web3-storage/gateway-lib' 3 | */ 4 | 5 | /** 6 | * Handles OPTIONS requests for CORS preflight. 7 | * @type {Middleware} 8 | */ 9 | export function withOptionsRequest (handler) { 10 | return async (request, env, ctx) => { 11 | if (request.method === 'OPTIONS') { 12 | const headers = new Headers() 13 | headers.set('Access-Control-Allow-Origin', '*') 14 | headers.set('Access-Control-Allow-Methods', 'GET, HEAD, POST, OPTIONS') 15 | headers.set('Access-Control-Allow-Headers', 'Content-Type, Authorization') 16 | return new Response(null, { headers, status: 204 }) 17 | } 18 | return handler(request, env, ctx) 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /test/helpers/bucket.js: -------------------------------------------------------------------------------- 1 | import * as PublicBucket from '@web3-storage/public-bucket/server/node' 2 | 3 | /** 4 | * @typedef {import('@web3-storage/public-bucket').Bucket} Bucket 5 | * @typedef {{ 6 | * getCallCount: () => number 7 | * resetCallCount: () => void 8 | * }} MockBucketService 9 | */ 10 | 11 | /** 12 | * @param {Bucket} bucket 13 | * @param {import('node:http').Server} server 14 | * @returns {Promise} 15 | */ 16 | export const mockBucketService = async (bucket, server) => { 17 | let callCount = 0 18 | const getCallCount = () => callCount 19 | const resetCallCount = () => { callCount = 0 } 20 | 21 | const handler = PublicBucket.createHandler({ bucket }) 22 | server.on('request', (request, response) => { 23 | callCount++ 24 | handler(request, response) 25 | }) 26 | return { getCallCount, resetCallCount } 27 | } 28 | -------------------------------------------------------------------------------- /src/middleware/withEgressClient.types.ts: -------------------------------------------------------------------------------- 1 | import { Environment as MiddlewareEnvironment, Context as MiddlewareContext } from '@web3-storage/gateway-lib' 2 | import { DIDKey, UnknownLink } from '@ucanto/principal/ed25519' 3 | import { GatewayIdentityContext } from './withGatewayIdentity.types.js' 4 | import { DelegationProofsContext } from './withAuthorizedSpace.types.js' 5 | 6 | export interface Environment extends MiddlewareEnvironment { 7 | FF_EGRESS_TRACKER_ENABLED: string 8 | GATEWAY_PRINCIPAL_KEY: string 9 | UPLOAD_API_URL: string 10 | UPLOAD_SERVICE_DID: string 11 | } 12 | 13 | export interface EgressClientContext 14 | extends MiddlewareContext, 15 | GatewayIdentityContext, 16 | DelegationProofsContext { 17 | egressClient: EgressClient 18 | } 19 | 20 | export interface EgressClient { 21 | record: (space: DIDKey, resource: UnknownLink, bytes: number, servedAt: Date) => Promise 22 | } 23 | -------------------------------------------------------------------------------- /src/middleware/withFormatCarHandler.js: -------------------------------------------------------------------------------- 1 | import { handleCar } from '@web3-storage/gateway-lib/handlers' 2 | 3 | /** 4 | * @import { Middleware, IpfsUrlContext, DagContext } from '@web3-storage/gateway-lib' 5 | * @import { Environment } from '../bindings.js' 6 | */ 7 | 8 | /** 9 | * @type {Middleware} 10 | */ 11 | export function withFormatCarHandler (handler) { 12 | return async (request, env, ctx) => { 13 | const { headers } = request 14 | const { searchParams } = ctx 15 | if (!searchParams) throw new Error('missing URL search params') 16 | if ( 17 | searchParams.get('format') === 'car' || 18 | headers.get('Accept')?.includes('application/vnd.ipld.car') 19 | ) { 20 | return await handleCar(request, env, ctx) 21 | } 22 | return handler(request, env, ctx) // pass to other handlers 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/middleware/withContentClaimsDagula.types.ts: -------------------------------------------------------------------------------- 1 | import { Environment as MiddlewareEnvironment } from '@web3-storage/gateway-lib' 2 | 3 | export interface Environment extends MiddlewareEnvironment { 4 | CONTENT_CLAIMS_SERVICE_URL?: string 5 | /** 6 | * The KV namespace that stores the DAGPB content cache. 7 | */ 8 | DAGPB_CONTENT_CACHE: KVNamespace 9 | /** 10 | * The number that represents when to expire the key-value pair in seconds from now. 11 | * The minimum value is 60 seconds. Any value less than 60MB will not be used. 12 | */ 13 | FF_DAGPB_CONTENT_CACHE_TTL_SECONDS?: string 14 | /** 15 | * The maximum size of the key-value pair in MB. 16 | * The minimum value is 1 MB. Any value less than 1MB will not be used. 17 | */ 18 | FF_DAGPB_CONTENT_CACHE_MAX_SIZE_MB?: string 19 | /** 20 | * The flag that enables the DAGPB content cache. 21 | */ 22 | FF_DAGPB_CONTENT_CACHE_ENABLED: string 23 | } -------------------------------------------------------------------------------- /src/middleware/withFormatRawHandler.js: -------------------------------------------------------------------------------- 1 | import { handleBlock } from '@web3-storage/gateway-lib/handlers' 2 | 3 | /** 4 | * @import { Middleware, IpfsUrlContext, BlockContext, UnixfsContext } from '@web3-storage/gateway-lib' 5 | * @import { Environment } from '../bindings.js' 6 | */ 7 | 8 | /** 9 | * @type {Middleware} 10 | */ 11 | export function withFormatRawHandler (handler) { 12 | return async (request, env, ctx) => { 13 | const { headers } = request 14 | const { searchParams } = ctx 15 | if (!searchParams) throw new Error('missing URL search params') 16 | if ( 17 | searchParams.get('format') === 'raw' || 18 | headers.get('Accept')?.includes('application/vnd.ipld.raw') 19 | ) { 20 | return await handleBlock(request, env, ctx) 21 | } 22 | return handler(request, env, ctx) // pass to other handlers 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/middleware/withRateLimit.types.ts: -------------------------------------------------------------------------------- 1 | import { CID } from '@web3-storage/gateway-lib/handlers' 2 | import { IpfsUrlContext, Environment as MiddlewareEnvironment } from '@web3-storage/gateway-lib' 3 | import { RATE_LIMIT_EXCEEDED } from '../constants.js' 4 | 5 | export interface Environment extends MiddlewareEnvironment { 6 | RATE_LIMITER: RateLimit 7 | AUTH_TOKEN_METADATA: KVNamespace 8 | FF_RATE_LIMITER_ENABLED: string 9 | GATEWAY_VALIDATOR_PROOF?: string 10 | } 11 | 12 | export interface TokenMetadata { 13 | locationClaim?: unknown // TODO: figure out the right type to use for this - we probably need it for the private data case to verify auth 14 | invalid?: boolean 15 | } 16 | 17 | export interface Context extends IpfsUrlContext { 18 | authToken: string | null 19 | } 20 | 21 | export type RateLimitExceeded = typeof RATE_LIMIT_EXCEEDED[keyof typeof RATE_LIMIT_EXCEEDED] 22 | 23 | export interface RateLimitService { 24 | check: (cid: CID, req: Request) => Promise 25 | } 26 | -------------------------------------------------------------------------------- /src/middleware/withGatewayIdentity.js: -------------------------------------------------------------------------------- 1 | import { Schema } from '@ucanto/core' 2 | import { ed25519 } from '@ucanto/principal' 3 | 4 | /** 5 | * @typedef {import('./withGatewayIdentity.types.js').GatewayIdentityContext} GatewayIdentityContext 6 | * @typedef {import('./withGatewayIdentity.types.js').Environment} Environment 7 | */ 8 | 9 | /** 10 | * The GatewayIdentity handler adds the gateway identity to the context. 11 | * 12 | * @type {import('@web3-storage/gateway-lib').Middleware} 13 | */ 14 | export function withGatewayIdentity (handler) { 15 | return async (req, env, ctx) => { 16 | const gatewaySigner = env.GATEWAY_PRINCIPAL_KEY 17 | ? ed25519.Signer.parse(env.GATEWAY_PRINCIPAL_KEY) 18 | : await ed25519.Signer.generate() 19 | 20 | const gatewayIdentity = gatewaySigner.withDID( 21 | Schema.DID.from(env.GATEWAY_SERVICE_DID) 22 | ) 23 | return handler(req, env, { 24 | ...ctx, 25 | gatewaySigner, 26 | gatewayIdentity 27 | }) 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /.github/actions/test/action.yml: -------------------------------------------------------------------------------- 1 | name: Test 2 | description: 'Setup and test' 3 | 4 | runs: 5 | using: "composite" 6 | steps: 7 | - uses: actions/setup-node@v3 8 | with: 9 | registry-url: 'https://registry.npmjs.org' 10 | node-version: 20 11 | cache: 'npm' 12 | - run: npm ci 13 | shell: bash 14 | - run: npm run lint 15 | shell: bash 16 | - run: npm run test:unit 17 | name: Unit Tests 18 | shell: bash 19 | - run: npm run test:integration 20 | name: Integration Tests 21 | shell: bash 22 | - run: | 23 | # If the Miniflare tests fail we need to capture the output and check for the error, 24 | # because the error is not bubbled up from miniflare and the tests will pass. 25 | npm run test:miniflare | tee test-output.log 26 | 27 | # Check for the error in the output to fail the test action 28 | if grep -q 'ERR_TEST_FAILURE' test-output.log; then 29 | echo "Test failure detected: ERR_TEST_FAILURE" 30 | exit 1 31 | fi 32 | name: Miniflare Tests 33 | shell: bash 34 | -------------------------------------------------------------------------------- /src/middleware/withUcanInvocationHandler.types.ts: -------------------------------------------------------------------------------- 1 | import { Environment as MiddlewareEnvironment, Context as MiddlewareContext } from '@web3-storage/gateway-lib' 2 | import { GatewayIdentityContext } from './withGatewayIdentity.types.js' 3 | import { DelegationsStorageContext } from './withDelegationsStorage.types.js' 4 | import { Service } from '../server/api.types.js' 5 | import * as Server from '@ucanto/server' 6 | export interface Environment extends MiddlewareEnvironment { 7 | GATEWAY_VALIDATOR_PROOF?: string 8 | } 9 | 10 | export interface Context 11 | extends MiddlewareContext, 12 | GatewayIdentityContext, 13 | DelegationsStorageContext { 14 | /** 15 | * This is optional because the handler is responsible for creating the service if it is not provided. 16 | * 17 | * @template T 18 | * @type {Service} 19 | */ 20 | service?: Service 21 | 22 | /** 23 | * This is optional because the handler is responsible for creating the server if it is not provided. 24 | * 25 | * @template U 26 | * @type {Server.ServerView>} 27 | */ 28 | server?: Server.ServerView> 29 | } 30 | -------------------------------------------------------------------------------- /src/middleware/index.js: -------------------------------------------------------------------------------- 1 | export { withAuthToken } from './withAuthToken.js' 2 | export { withCarBlockHandler } from './withCarBlockHandler.js' 3 | export { withContentClaimsDagula } from './withContentClaimsDagula.js' 4 | export { withRateLimit } from './withRateLimit.js' 5 | export { withVersionHeader } from './withVersionHeader.js' 6 | export { withAuthorizedSpace } from './withAuthorizedSpace.js' 7 | export { withLocator } from './withLocator.js' 8 | export { withCarParkFetch } from './withCarParkFetch.js' 9 | export { withEgressClient } from './withEgressClient.js' 10 | export { withEgressTracker } from './withEgressTracker.js' 11 | export { withFormatCarHandler } from './withFormatCarHandler.js' 12 | export { withFormatRawHandler } from './withFormatRawHandler.js' 13 | export { withDelegationStubs } from './withDelegationStubs.js' 14 | export { withDidDocumentHandler } from './withDidDocumentHandler.js' 15 | export { withGatewayIdentity } from './withGatewayIdentity.js' 16 | export { withUcanInvocationHandler } from './withUcanInvocationHandler.js' 17 | export { withDelegationsStorage } from './withDelegationsStorage.js' 18 | export { withOptionsRequest } from './withOptionsRequest.js' 19 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | on: 2 | push: 3 | branches: 4 | - main 5 | name: Release 6 | jobs: 7 | release-staging: 8 | runs-on: ubuntu-latest 9 | permissions: 10 | contents: write 11 | pull-requests: write 12 | steps: 13 | - uses: actions/checkout@v3 14 | - uses: ./.github/actions/test 15 | - name: Deploy to Staging 16 | uses: cloudflare/wrangler-action@v3 17 | with: 18 | apiToken: ${{secrets.CF_TOKEN }} 19 | environment: 'staging' 20 | release-production: 21 | runs-on: ubuntu-latest 22 | permissions: 23 | contents: write 24 | pull-requests: write 25 | steps: 26 | - uses: google-github-actions/release-please-action@v3 27 | id: release 28 | with: 29 | release-type: node 30 | package-name: freeway 31 | - uses: actions/checkout@v3 32 | - uses: ./.github/actions/test 33 | - name: Deploy to Production 34 | uses: cloudflare/wrangler-action@v3 35 | with: 36 | apiToken: ${{ secrets.CF_TOKEN }} 37 | environment: 'production' 38 | if: ${{ steps.release.outputs.release_created }} 39 | 40 | -------------------------------------------------------------------------------- /test/integration/rate-limit.spec.js: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai' 2 | import { describe, it } from 'mocha' 3 | import fetch from 'node-fetch' 4 | import { getWorkerInfo } from '../fixtures/worker-fixture.js' 5 | 6 | describe('Rate Limit Handler', () => { 7 | /** 8 | * This is a test CID that is known to be stored in the staging environment 9 | * See https://bafybeibv7vzycdcnydl5n5lbws6lul2omkm6a6b5wmqt77sicrwnhesy7y.ipfs.w3s.link 10 | */ 11 | const cid = 'bafybeibv7vzycdcnydl5n5lbws6lul2omkm6a6b5wmqt77sicrwnhesy7y' 12 | 13 | it('should enforce rate limits', async () => { 14 | const { ip, port } = getWorkerInfo() 15 | const maxRequests = 130 16 | let successCount = 0 17 | let rateLimitCount = 0 18 | 19 | const requests = Array.from({ length: maxRequests }, async () => { 20 | const response = await fetch(`http://${ip}:${port}/ipfs/${cid}`) 21 | if (response.status === 200) { 22 | successCount++ 23 | } else if (response.status === 429) { 24 | rateLimitCount++ 25 | } 26 | }) 27 | 28 | await Promise.all(requests) 29 | 30 | expect(successCount).to.be.lessThan(maxRequests) 31 | expect(rateLimitCount).to.be.greaterThan(0) 32 | }).timeout(30_000) 33 | }) 34 | -------------------------------------------------------------------------------- /src/middleware/withAuthorizedSpace.types.ts: -------------------------------------------------------------------------------- 1 | import * as Ucanto from '@ucanto/interface' 2 | import { Context as MiddlewareContext } from '@web3-storage/gateway-lib' 3 | import { SpaceDID } from '@storacha/capabilities/types' 4 | export interface DelegationProofsContext extends MiddlewareContext { 5 | /** 6 | * The delegation proofs to use for the egress record 7 | * The proofs must be valid for the space and the owner of the space 8 | * must have delegated the right to the Gateway to serve content and record egress traffic. 9 | * The `space/content/serve/*` capability must be granted to the Gateway Web DID. 10 | */ 11 | delegationProofs: Ucanto.Delegation[] 12 | } 13 | 14 | export interface SpaceContext extends MiddlewareContext { 15 | /** 16 | * The SpaceDID of the space that is authorized to serve the content from. 17 | * If the space is not authorized, the request is considered a legacy request - which is served by default. 18 | * The egress is not recorded for legacy requests because the space is unknown. 19 | * Eventually, legacy requests will be aggressively throttled, forcing the users to migrate to authorized spaces. 20 | * Then this field will become required and the legacy behavior will be removed. 21 | */ 22 | space?: SpaceDID 23 | } 24 | -------------------------------------------------------------------------------- /scripts/r2-put.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Puts data to the local persisted Miniflare R2 buckets. 3 | * 4 | * Usage: 5 | * node scripts/r2-put.js test.jpg --no-wrap --no-rollup 6 | */ 7 | import { R2Bucket } from '@miniflare/r2' 8 | import { FileStorage } from '@miniflare/storage-file' 9 | import { getFilesFromPath } from 'files-from-path' 10 | import { Builder } from '../test/helpers/builder.js' 11 | 12 | const bucketNames = ['CARPARK', 'SATNAV', 'DUDEWHERE'] 13 | const buckets = bucketNames.map(b => new R2Bucket(new FileStorage(`./.mf/r2/${b}`))) 14 | // @ts-expect-error import('@miniflare/r2').R2Bucket does not satisfy import('@cloudflare/workers-types').R2Bucket interface 🙈 15 | const builder = new Builder(buckets[0], buckets[1], buckets[2]) 16 | 17 | const paths = process.argv.slice(2).filter(p => p !== '--no-wrap') 18 | const files = await getFilesFromPath(paths) 19 | const input = files.map(f => ({ path: f.name, content: f.stream() })) 20 | const wrapWithDirectory = process.argv.every(p => p !== '--no-wrap') 21 | 22 | const { dataCid, carCids } = await builder.add(input, { wrapWithDirectory }) 23 | 24 | const rollup = process.argv.every(p => p !== '--no-rollup') 25 | if (rollup) await builder.rollup(dataCid, carCids) 26 | 27 | console.log(`Data CID:\n${dataCid}`) 28 | console.log(`CAR CIDs:\n${carCids.join('\n')}`) 29 | -------------------------------------------------------------------------------- /src/middleware/withAuthToken.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @import { 3 | * Middleware, 4 | * Context as MiddlewareContext, 5 | * } from '@web3-storage/gateway-lib' 6 | * @import { 7 | * Environment, 8 | * AuthTokenContext, 9 | * } from './withAuthToken.types.js' 10 | */ 11 | 12 | /** 13 | * Finds an authentication token in the URL query parameters or the 14 | * `Authorization` header and adds it to the context as `authToken`. 15 | * 16 | * @type {Middleware} 17 | */ 18 | export function withAuthToken (handler) { 19 | return async (req, env, ctx) => { 20 | return handler(req, env, { 21 | ...ctx, 22 | authToken: 23 | getAuthTokenFromQueryParams(req) || getAuthTokenFromHeaders(req) 24 | }) 25 | } 26 | } 27 | 28 | /** 29 | * @param {Request} request 30 | * @returns {string | null} 31 | */ 32 | function getAuthTokenFromQueryParams (request) { 33 | return new URL(request.url).searchParams.get('authToken') 34 | } 35 | 36 | const BEARER_PREFIX = 'Bearer ' 37 | 38 | /** 39 | * @param {Request} request 40 | * @returns {string | null} 41 | */ 42 | function getAuthTokenFromHeaders (request) { 43 | const authHeader = request.headers.get('Authorization') 44 | if (authHeader && authHeader.startsWith(BEARER_PREFIX)) { 45 | return authHeader.substring(BEARER_PREFIX.length) 46 | } 47 | return null 48 | } 49 | -------------------------------------------------------------------------------- /src/capabilities/serve.js: -------------------------------------------------------------------------------- 1 | import { capability, Schema, DID, nullable, string } from '@ucanto/validator' 2 | 3 | /** 4 | * "Serve content owned by the subject Space over HTTP." 5 | * 6 | * A Principal who may `space/content/serve/transport/http` is permitted to 7 | * serve any content owned by the Space, in the manner of an [IPFS Gateway]. The 8 | * content may be a Blob stored by a Storage Node, or indexed content stored 9 | * within such Blobs (ie, Shards). 10 | * 11 | * Note that the args do not currently specify *what* content should be served. 12 | * Invoking this command does not currently *serve* the content in any way, but 13 | * merely validates the authority to do so. Currently, the entirety of a Space 14 | * must use the same authorization, thus the content does not need to be 15 | * identified. In the future, this command may refer directly to a piece of 16 | * content by CID. 17 | * 18 | * [IPFS Gateway]: https://specs.ipfs.tech/http-gateways/path-gateway/ 19 | */ 20 | export const transportHttp = capability({ 21 | can: 'space/content/serve/transport/http', 22 | /** 23 | * The Space which contains the content. This Space will be charged egress 24 | * fees if content is actually retrieved by way of this invocation. 25 | */ 26 | with: DID, 27 | nb: Schema.struct({ 28 | /** The authorization token, if any, used for this request. */ 29 | token: nullable(string()) 30 | }) 31 | }) 32 | -------------------------------------------------------------------------------- /src/middleware/withDidDocumentHandler.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @import { Middleware, Environment } from '@web3-storage/gateway-lib' 3 | * @import { GatewayIdentityContext } from './withGatewayIdentity.types.js' 4 | */ 5 | 6 | /** 7 | * Handles a GET request for `/.well-known/did.json` and passes on all other 8 | * requests to the next handler in the chain. 9 | * 10 | * @type {Middleware} 11 | */ 12 | export function withDidDocumentHandler (handler) { 13 | return async (request, env, ctx) => { 14 | if (request.method !== 'GET' || new URL(request.url).pathname !== '/.well-known/did.json') { 15 | return handler(request, env, ctx) 16 | } 17 | 18 | const webKey = ctx.gatewayIdentity.did() 19 | const publicKeyMultibase = ctx.gatewaySigner.did().replace('did:key:', '') 20 | const verificationMethods = [ 21 | { 22 | id: `${webKey}#owner`, 23 | type: 'Ed25519VerificationKey2020', 24 | controller: webKey, 25 | publicKeyMultibase 26 | } 27 | ] 28 | 29 | const headers = { 'Content-Type': 'application/json' } 30 | const body = JSON.stringify({ 31 | '@context': ['https://w3id.org/did/v1'], 32 | id: webKey, 33 | verificationMethod: verificationMethods, 34 | assertionMethod: verificationMethods.map(k => k.id), 35 | authentication: verificationMethods.map(k => k.id) 36 | }, null, 2) 37 | 38 | return new Response(body, { headers }) 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/middleware/withUcanInvocationHandler.js: -------------------------------------------------------------------------------- 1 | import { createServer } from '../server/index.js' 2 | import { createService } from '../server/service.js' 3 | 4 | /** 5 | * @import { Middleware } from '@web3-storage/gateway-lib' 6 | * @import { 7 | * Environment, 8 | * Context, 9 | * } from './withUcanInvocationHandler.types.js' 10 | * @typedef {Context} UcanInvocationContext 11 | */ 12 | 13 | /** 14 | * The withUcanInvocationHandler middleware is used to handle UCAN invocation requests to the Freeway Gateway. 15 | * It supports only POST requests to the root path. Any other requests are passed through. 16 | * 17 | * @type {Middleware} 18 | */ 19 | export function withUcanInvocationHandler (handler) { 20 | return async (request, env, ctx) => { 21 | if (request.method !== 'POST' || new URL(request.url).pathname !== '/') { 22 | return handler(request, env, ctx) 23 | } 24 | 25 | const service = 26 | ctx.service ?? (await createService(ctx, env)) 27 | const server = ctx.server ?? (await createServer(ctx, service, env)) 28 | 29 | const { headers, body, status } = await server.request({ 30 | body: new Uint8Array(await request.arrayBuffer()), 31 | headers: Object.fromEntries(request.headers) 32 | }) 33 | 34 | // @ts-expect-error - ByteView is compatible with BodyInit but TypeScript doesn't recognize it 35 | return new Response(body, { headers, status: status ?? 200 }) 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/bindings.d.ts: -------------------------------------------------------------------------------- 1 | import { CID } from '@web3-storage/gateway-lib/handlers' 2 | import { Environment as RateLimiterEnvironment } from './middleware/withRateLimit.types.ts' 3 | import { Environment as CarParkFetchEnvironment } from './middleware/withCarParkFetch.types.ts' 4 | import { Environment as CarBlockEnvironment } from './middleware/withCarBlockHandler.types.ts' 5 | import { Environment as ContentClaimsDagulaEnvironment } from './middleware/withContentClaimsDagula.types.ts' 6 | import { Environment as EgressTrackerEnvironment } from './middleware/withEgressTracker.types.ts' 7 | import { Environment as EgressClientEnvironment } from './middleware/withEgressClient.types.ts' 8 | import { Environment as GatewayIdentityEnvironment } from './middleware/withGatewayIdentity.types.ts' 9 | import { Environment as DelegationsStorageEnvironment } from './middleware/withDelegationsStorage.types.ts' 10 | import { Environment as LocatorEnvironment } from './middleware/withLocator.types.ts' 11 | import { UnknownLink } from 'multiformats' 12 | import { DIDKey } from '@ucanto/principal/ed25519' 13 | 14 | export interface Environment 15 | extends RateLimiterEnvironment, 16 | CarBlockEnvironment, 17 | CarParkFetchEnvironment, 18 | ContentClaimsDagulaEnvironment, 19 | EgressClientEnvironment, 20 | EgressTrackerEnvironment, 21 | GatewayIdentityEnvironment, 22 | DelegationsStorageEnvironment, 23 | LocatorEnvironment { 24 | VERSION: string 25 | CONTENT_CLAIMS_SERVICE_URL?: string 26 | HONEYCOMB_API_KEY: string 27 | FF_TELEMETRY_ENABLED: string 28 | TELEMETRY_RATIO: string 29 | GATEWAY_VALIDATOR_PROOF?: string 30 | } 31 | -------------------------------------------------------------------------------- /test/unit/middleware/withAuthToken.spec.js: -------------------------------------------------------------------------------- 1 | import { describe, it } from 'mocha' 2 | import { expect } from 'chai' 3 | import { withAuthToken } from '../../../src/middleware/withAuthToken.js' 4 | 5 | /** 6 | * @import { AuthTokenContext } from '../../../src/middleware/withAuthToken.types.js' 7 | * @import { 8 | * Handler, 9 | * Environment as MiddlewareEnvironment, 10 | * } from '@web3-storage/gateway-lib' 11 | */ 12 | 13 | /** @type {Handler} */ 14 | const innerHandler = async (_req, _env, ctx) => 15 | new Response( 16 | ctx.authToken === null ? 'No token given' : `Got token: ${ctx.authToken}` 17 | ) 18 | 19 | const ctx = { 20 | waitUntil: () => {} 21 | } 22 | 23 | describe('withAuthToken', async () => { 24 | it('should find a token in the URL', async () => { 25 | const request = new Request('http://example.com/?authToken=1234abcd') 26 | const response = await withAuthToken(innerHandler)(request, {}, ctx) 27 | expect(await response.text()).to.equal('Got token: 1234abcd') 28 | }) 29 | 30 | it('should find a token in the `Authorization` header', async () => { 31 | const request = new Request('http://example.com/', { 32 | headers: { Authorization: 'Bearer 1234abcd' } 33 | }) 34 | const response = await withAuthToken(innerHandler)(request, {}, ctx) 35 | expect(await response.text()).to.equal('Got token: 1234abcd') 36 | }) 37 | 38 | it('should provide `authToken: null` when there is not auth token', async () => { 39 | const request = new Request('http://example.com/') 40 | const response = await withAuthToken(innerHandler)(request, {}, ctx) 41 | expect(await response.text()).to.equal('No token given') 42 | }) 43 | }) 44 | -------------------------------------------------------------------------------- /src/middleware/withDelegationsStorage.types.ts: -------------------------------------------------------------------------------- 1 | import * as Ucanto from '@ucanto/interface' 2 | import { Environment as MiddlewareEnvironment, Context as MiddlewareContext } from '@web3-storage/gateway-lib' 3 | import { GatewayIdentityContext } from './withGatewayIdentity.types.js' 4 | import { StoreOperationFailed, DelegationNotFound } from './withDelegationsStorage.js' 5 | import { SpaceDID } from '@storacha/capabilities/types' 6 | 7 | export interface DelegationsStorageEnvironment extends MiddlewareEnvironment { 8 | CONTENT_SERVE_DELEGATIONS_STORE: KVNamespace 9 | FF_DELEGATIONS_STORAGE_ENABLED: string 10 | GATEWAY_VALIDATOR_PROOF?: string 11 | } 12 | 13 | export interface DelegationsStorageContext 14 | extends MiddlewareContext, 15 | GatewayIdentityContext { 16 | delegationsStorage: DelegationsStorage 17 | } 18 | 19 | export interface DelegationsStorage { 20 | /** 21 | * Finds the delegation proofs for the given space 22 | * 23 | * @param {import('@storacha/capabilities/types').SpaceDID} space 24 | * @returns {Promise[], DelegationNotFound | Ucanto.Failure>>} 25 | */ 26 | find: ( 27 | space: SpaceDID 28 | ) => Promise[], DelegationNotFound | Ucanto.Failure>> 29 | 30 | /** 31 | * Stores the delegation proofs for the given space 32 | * 33 | * @param {import('@storacha/capabilities/types').SpaceDID} space 34 | * @param {Ucanto.Delegation} delegation 35 | * @returns {Promise>} 36 | */ 37 | store: ( 38 | space: SpaceDID, 39 | delegation: Ucanto.Delegation, 40 | ) => Promise> 41 | } 42 | -------------------------------------------------------------------------------- /scripts/debug/src/index.js: -------------------------------------------------------------------------------- 1 | /* eslint-env worker */ 2 | 3 | // Stripped down freeway for debugging. It points at the production network. 4 | 5 | import { 6 | withCdnCache, 7 | withContext, 8 | withCorsHeaders, 9 | withContentDispositionHeader, 10 | withErrorHandler, 11 | createWithHttpMethod as withHttpMethods, 12 | withParsedIpfsUrl, 13 | composeMiddleware 14 | } from '@web3-storage/gateway-lib/middleware' 15 | import { handleUnixfs } from '@web3-storage/gateway-lib/handlers' 16 | import { 17 | withContentClaimsDagula, 18 | withVersionHeader, 19 | withCarBlockHandler, 20 | withLocator, 21 | withOptionsRequest, 22 | withFormatRawHandler, 23 | withFormatCarHandler 24 | } from '../../../src/middleware/index.js' 25 | 26 | /** 27 | * @import { Handler, Context } from '@web3-storage/gateway-lib' 28 | * @import { Environment } from '../../../src/bindings.js' 29 | */ 30 | 31 | const middleware = composeMiddleware( 32 | withCdnCache, 33 | withContext, 34 | withOptionsRequest, 35 | withCorsHeaders, 36 | withVersionHeader, 37 | withErrorHandler, 38 | withHttpMethods('GET', 'HEAD'), 39 | withParsedIpfsUrl, 40 | withLocator, 41 | withCarBlockHandler, 42 | withContentClaimsDagula, 43 | withFormatRawHandler, 44 | withFormatCarHandler, 45 | withContentDispositionHeader 46 | ) 47 | 48 | const handler = { 49 | /** @type {Handler} */ 50 | async fetch (request, env, ctx) { 51 | let status = 500 52 | let headers = new Headers() 53 | try { 54 | const handler = middleware(handleUnixfs) 55 | const response = await handler(request, env, ctx) 56 | status = response.status 57 | headers = response.headers 58 | return response 59 | } catch (/** @type {any} */ err) { 60 | console.error(err) 61 | return new Response(err.stack, { status }) 62 | } finally { 63 | console.log(request.method, request.url, '→', status) 64 | for (const [k, v] of headers) { 65 | console.log(`\t${k}:`, v) 66 | } 67 | } 68 | } 69 | } 70 | 71 | export default handler 72 | -------------------------------------------------------------------------------- /docs/ucan-handler.md: -------------------------------------------------------------------------------- 1 | ### Gateway Content Serve Authorization Flow 2 | 3 | ```mermaid 4 | flowchart TD 5 | subgraph Client Side 6 | A[User] -->|Creates Space & Authorizes Gateway| B[w3up-client] 7 | end 8 | 9 | subgraph Cloudflare Freeway Worker 10 | C[Ucanto Server] 11 | F[Content Server] 12 | end 13 | 14 | subgraph Cloudflare KV Store 15 | D[Content Serve Delegations] 16 | end 17 | 18 | B -->|UCAN access/delegate| C 19 | C -->E[UCAN Invocation Handler] 20 | E -->|Stores Valid Delegation| D 21 | F -->|Retrieves Delegation| D[Content Serve Delegations] 22 | ``` 23 | 24 | ### Explanation 25 | 1. **User Interaction** 26 | - The user interacts with the `w3up-client` to create a space and authorize the gateway to serve content. 27 | 28 | 2. **UCAN Invocation** 29 | - The `w3up-client` sends a UCAN invocation `access/delegate` to the Ucanto Server, providing the delegation details (`{ space, proofs }`). 30 | - The request is processed by the UCAN Invocation Handler in the Cloudflare Freeway Worker. 31 | 32 | 3. **Validation Steps** 33 | - It validates that the delegation matches the expected capability (`space/content/serve/*`). 34 | - It ensures the proof chain is valid. 35 | 36 | 4. **Storing Delegation** 37 | - After successful validation, the delegation is stored in the KV Store (`Content Serve Delegations Storage`) for further use. 38 | 39 | 5. **Content Server Retrieval** 40 | - The Freeway Worker retrieves the validated delegations from the KV Store to serve content for authorized spaces. 41 | 42 | 43 | ### Key Considerations 44 | - **Mitigating DoS Attacks** 45 | - By verifying that the space is provisioned before accepting the delegation, we can reduce the risk of abuse from unauthorized or irrelevant requests. 46 | - We still need to implement this verification in another iteration. 47 | - **Efficiency** 48 | - This additional validation ensures only relevant delegations are processed and stored, minimizing resource waste. 49 | - **Implementation** 50 | - Adding a check against the space provisioning status in the `Ucanto Server` can be done efficiently by querying the space registry or relevant provisioning database. 51 | -------------------------------------------------------------------------------- /test/fixtures/worker-fixture.js: -------------------------------------------------------------------------------- 1 | import path, { resolve } from 'node:path' 2 | import { fileURLToPath } from 'node:url' 3 | import { runWranglerDev } from '../helpers/run-wrangler.js' 4 | 5 | // Get __dirname equivalent in ES module 6 | const __filename = fileURLToPath(import.meta.url) 7 | const __dirname = path.dirname(__filename) 8 | 9 | /** 10 | * The wrangler environment to use for the test worker. 11 | * @type {string} 12 | */ 13 | const wranglerEnv = process.env.WRANGLER_ENV || 'integration' 14 | 15 | const DEBUG = process.env.DEBUG === 'true' 16 | 17 | /** 18 | * Worker information object 19 | * @typedef {Object} WorkerInfo 20 | * @property {string | undefined} ip - The IP address of the test worker. 21 | * @property {number | undefined} port - The port of the test worker. 22 | * @property {() => Promise | undefined} stop - Function to stop the test worker. 23 | * @property {() => string | undefined} getOutput - Function to get the output of the test worker. 24 | */ 25 | 26 | /** 27 | * Worker information object 28 | * @type {WorkerInfo | undefined} 29 | */ 30 | let workerInfo 31 | 32 | /** 33 | * Sets up the test worker. 34 | * @returns {Promise} 35 | */ 36 | export const mochaGlobalSetup = async () => { 37 | try { 38 | workerInfo = await runWranglerDev( 39 | resolve(__dirname, '../../'), // The directory of the worker with the wrangler.toml 40 | ['--local'], 41 | process.env, 42 | wranglerEnv 43 | ) 44 | console.log(`Output: ${await workerInfo.getOutput()}`) 45 | console.log('WorkerInfo:', workerInfo) 46 | console.log(`Test worker started! ENV: ${wranglerEnv}, DEBUG: ${DEBUG}`) 47 | } catch (error) { 48 | console.error('Failed to start test worker:', error) 49 | throw error 50 | } 51 | } 52 | 53 | /** 54 | * Tears down the test worker. 55 | * @returns {Promise} 56 | */ 57 | export const mochaGlobalTeardown = async () => { 58 | // If the worker is not running, nothing to do. 59 | if (!workerInfo) return 60 | 61 | try { 62 | const { stop } = workerInfo 63 | await stop?.() 64 | if (DEBUG) { 65 | console.log('getOutput', await workerInfo.getOutput()) 66 | } 67 | console.log('Test worker stopped!') 68 | } catch (error) { 69 | console.error('Failed to stop test worker:', error) 70 | throw error 71 | } 72 | } 73 | 74 | /** 75 | * Gets the worker info. 76 | * @returns {WorkerInfo} 77 | */ 78 | export function getWorkerInfo () { 79 | if (!workerInfo) throw new Error('Worker not running.') 80 | return workerInfo 81 | } 82 | -------------------------------------------------------------------------------- /test/unit/middleware/util/strictStub.js: -------------------------------------------------------------------------------- 1 | import { inspect } from 'util' 2 | 3 | /** @import * as sinon from 'sinon' */ 4 | 5 | /** 6 | * Equivalent to {@link Overloaded} when {@link Fn} is overloaded, and to 7 | * {@link NotOverloaded} when {@link Fn} is not overloaded. 8 | * 9 | * @template {(...args: any) => any} Fn 10 | * @template Overloaded 11 | * @template NotOverloaded 12 | * @typedef {Fn extends { 13 | * (a: infer Arg): infer Ret; 14 | * (a: infer Arg2): infer Ret2; 15 | * } 16 | * ? [[Arg, Ret], [Arg2, Ret2]] extends [[Arg2, Ret2], [Arg, Ret]] 17 | * ? NotOverloaded 18 | * : Overloaded 19 | * : never 20 | * } IfOverloaded 21 | */ 22 | 23 | /** 24 | * Makes concessions for overloaded functions. TypeScript cannot properly infer 25 | * from the type of overloaded functions, and instead infers from the last 26 | * overload. This can cause surprising results. {@link SinonStubOf} for an 27 | * overloaded function returns a stub typed with `unknown` arg and return types, 28 | * but also typed as the original function, with all its overloads intact. Sinon 29 | * calls will lack type information, but regular use of the function will be 30 | * properly typed. 31 | * 32 | * @template {(...args: any) => any} Fn 33 | * @typedef {IfOverloaded, sinon.SinonStub, ReturnType>>} SinonStubOf 34 | */ 35 | 36 | /** 37 | * Creates a Sinon stub which has no default behavior and throws an error if 38 | * called without a specific behavior being set. 39 | * 40 | * @example 41 | * const toWord = stub('toWord') 42 | * toWord.withArgs(1).returns('one') 43 | * toWord.withArgs(2).returns('two') 44 | * 45 | * toWord(1) // => 'one' 46 | * toWord(2) // => 'two' 47 | * toWord(3) // => Error: Unexpected call to toWord with args: 3 48 | * 49 | * @template {(...args: any[]) => any} Fn 50 | * @param {sinon.SinonSandbox} sandbox 51 | * @param {string} name 52 | * @returns {Fn & SinonStubOf} 53 | */ 54 | export const strictStub = (sandbox, name) => { 55 | const createdStub = /** @type {Fn & SinonStubOf} */ (sandbox.stub()) 56 | /** @type {(...args: unknown[]) => never} */ 57 | const fakeImpl = (...args) => { 58 | throw new Error(`Unexpected call to ${name} with args: ${inspect(args)}`) 59 | } 60 | createdStub.callsFake(fakeImpl) 61 | 62 | // When the sandbox resets this stub, set it up again. 63 | const existingResetBehavior = createdStub.resetBehavior 64 | createdStub.resetBehavior = () => { 65 | existingResetBehavior.call(createdStub) 66 | createdStub.callsFake(fakeImpl) 67 | } 68 | return createdStub 69 | } 70 | -------------------------------------------------------------------------------- /test/helpers/builder.js: -------------------------------------------------------------------------------- 1 | /* global TransformStream */ 2 | import * as Link from 'multiformats/link' 3 | import { sha256 } from 'multiformats/hashes/sha2' 4 | import { base58btc } from 'multiformats/bases/base58' 5 | import { UnixFS, ShardingStream } from '@storacha/upload-client' 6 | import { CAR_CODE } from '../../src/constants.js' 7 | 8 | export class Builder { 9 | #bucket 10 | 11 | /** @param {{ put: (key: string, value: Uint8Array) => Promise }} bucket */ 12 | constructor (bucket) { 13 | this.#bucket = bucket 14 | } 15 | 16 | /** 17 | * @param {import('@storacha/upload-client/types').BlobLike|import('@storacha/upload-client/types').FileLike[]} input 18 | * @param {import('@storacha/upload-client/types').ShardingOptions} [options] 19 | * @returns {Promise<{ root: import('multiformats').UnknownLink, shards: import('multiformats').Link[]}>} 20 | */ 21 | async add (input, options = {}) { 22 | if (process.env.DEBUG) { 23 | console.log( 24 | 'Adding ' + 25 | (Array.isArray(input) 26 | ? `${input.length} file${input.length === 1 ? '' : 's'}` 27 | : '1 blob') + 28 | '...' 29 | ) 30 | } 31 | const unixFsEncoder = Array.isArray(input) 32 | ? UnixFS.createDirectoryEncoderStream(input) 33 | : UnixFS.createFileEncoderStream(input) 34 | 35 | /** @type {import('multiformats').UnknownLink?} */ 36 | let root = null 37 | /** @type {import('multiformats').Link[]} */ 38 | const shards = [] 39 | await unixFsEncoder 40 | .pipeThrough( 41 | new TransformStream({ 42 | transform (block, controller) { 43 | root = block.cid 44 | controller.enqueue(block) 45 | } 46 | }) 47 | ) 48 | .pipeThrough(new ShardingStream(options)) 49 | .pipeTo( 50 | new WritableStream({ 51 | write: async (car) => { 52 | const bytes = new Uint8Array(await car.arrayBuffer()) 53 | const digest = await sha256.digest(bytes) 54 | await this.#bucket.put(toBlobKey(digest), bytes) 55 | shards.push(Link.create(CAR_CODE, digest)) 56 | } 57 | }) 58 | ) 59 | 60 | if (!root) throw new Error('no blocks generated') 61 | return { root, shards } 62 | } 63 | } 64 | 65 | /** @param {import('multiformats').Link} cid */ 66 | export const toCarKey = (cid) => `${cid}/${cid}.car` 67 | 68 | /** @param {import('multiformats').MultihashDigest} digest */ 69 | export const toBlobKey = (digest) => { 70 | const digestString = base58btc.encode(digest.bytes) 71 | return `${digestString}/${digestString}.blob` 72 | } 73 | -------------------------------------------------------------------------------- /src/server/utils.js: -------------------------------------------------------------------------------- 1 | import { Space as SpaceCapabilities } from '@storacha/capabilities' 2 | import { InvalidDelegation } from '../middleware/withDelegationsStorage.js' 3 | import { Delegation } from '@ucanto/core' 4 | 5 | /** 6 | * Checks if the space/content/serve/* delegation is for the gateway and it is not expired. 7 | * 8 | * @param {import('@ucanto/interface').InferInvokedCapability} capability - The capability to validate 9 | * @param {import('@ucanto/interface').Invocation} invocation - The invocation containing attached blocks 10 | */ 11 | export const extractContentServeDelegations = async ( 12 | capability, 13 | invocation 14 | ) => { 15 | /** @type {Map} */ 16 | const blocks = new Map() 17 | for (const block of invocation.iterateIPLDBlocks()) { 18 | blocks.set(block.cid.toString(), block) 19 | } 20 | 21 | const delegations = await Promise.all( 22 | Object.values(capability.nb.delegations).map(async (cid) => { 23 | const block = blocks.get(cid.toString()) 24 | if (!block) { 25 | throw new Error(`Block not found for delegation CID: ${cid}`) 26 | } 27 | 28 | // Try to extract delegation from CAR archive 29 | try { 30 | const delegation = await Delegation.extract(block.bytes) 31 | if (delegation.error) { 32 | throw delegation.error 33 | } 34 | return delegation.ok 35 | } catch (error) { 36 | // Fallback: treat as raw delegation block 37 | const link = cid.link() 38 | const cidv1 = link.version === 0 ? link.toV1() : link 39 | return Delegation.create({ 40 | root: { 41 | cid: /** @type {import('@ucanto/interface').UCANLink} */ (cidv1), 42 | bytes: block.bytes 43 | }, 44 | blocks 45 | }) 46 | } 47 | }) 48 | ) 49 | 50 | const validDelegations = delegations 51 | .map((delegation) => { 52 | // check for each capability if it is a content serve capability 53 | if (delegation.capabilities.length === 0) { 54 | return false 55 | } 56 | const result = delegation.capabilities.some((c) => { 57 | return SpaceCapabilities.contentServe.match({ 58 | capability: /** @type {any} */ (c), 59 | delegation 60 | }).ok 61 | }) 62 | return result 63 | }) 64 | .filter((r) => r) 65 | 66 | if (validDelegations.length === 0) { 67 | return { 68 | error: new InvalidDelegation('no valid delegations found') 69 | } 70 | } 71 | 72 | return { ok: delegations } 73 | } 74 | -------------------------------------------------------------------------------- /src/server/service.js: -------------------------------------------------------------------------------- 1 | import { 2 | Access as AccessCapabilities, 3 | Space as SpaceCapabilities 4 | } from '@storacha/capabilities' 5 | import { extractContentServeDelegations } from './utils.js' 6 | import { claim, Schema } from '@ucanto/validator' 7 | import * as UcantoServer from '@ucanto/server' 8 | import { ok } from '@ucanto/client' 9 | import { getValidatorProofs } from './index.js' 10 | 11 | /** 12 | * @template T 13 | * @param {import('../middleware/withUcanInvocationHandler.types.js').Context} ctx 14 | * @param {import('../middleware/withUcanInvocationHandler.types.js').Environment} env 15 | * @returns {import('./api.types.js').Service} 16 | */ 17 | export function createService (ctx, env) { 18 | return { 19 | access: { 20 | delegate: UcantoServer.provideAdvanced({ 21 | capability: AccessCapabilities.delegate, 22 | audience: Schema.did({ method: 'web' }).or( 23 | Schema.did({ method: 'key' }) 24 | ), 25 | handler: async ({ capability, invocation, context }) => { 26 | console.log('extracting delegations: ', capability) 27 | const result = await extractContentServeDelegations( 28 | capability, 29 | invocation 30 | ) 31 | if (result.error) { 32 | console.error('error while extracting delegation', result.error) 33 | return result 34 | } 35 | 36 | const delegations = result.ok 37 | console.log('executing claim for delegations: ', delegations) 38 | const validatorProofs = await getValidatorProofs(env) 39 | console.log('validatorProofs: ', validatorProofs) 40 | const validationResult = await claim( 41 | SpaceCapabilities.contentServe, 42 | delegations, 43 | { 44 | ...context, 45 | authority: ctx.gatewayIdentity, 46 | proofs: [...validatorProofs] 47 | } 48 | ) 49 | if (validationResult.error) { 50 | console.error( 51 | 'error while validating delegation', 52 | validationResult.error 53 | ) 54 | return validationResult 55 | } 56 | 57 | const space = capability.with 58 | 59 | const validationResults = await Promise.all( 60 | delegations.map((delegation) => { 61 | return ctx.delegationsStorage.store(space, delegation) 62 | }) 63 | ) 64 | 65 | const errorResult = validationResults.find((result) => result.error) 66 | if (errorResult) { 67 | return errorResult 68 | } 69 | 70 | return ok({}) 71 | } 72 | }) 73 | } 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /src/middleware/withDelegationStubs.js: -------------------------------------------------------------------------------- 1 | import { Delegation, Schema } from '@ucanto/core' 2 | 3 | /** 4 | * @import { 5 | Environment, 6 | * Middleware, 7 | * Context as MiddlewareContext 8 | * } from '@web3-storage/gateway-lib' 9 | * @import { DelegationsStorageContext } from './withDelegationsStorage.types.js' 10 | * @import { LocatorContext } from './withLocator.types.js' 11 | * @import { GatewayIdentityContext } from './withGatewayIdentity.types.js' 12 | */ 13 | 14 | /** 15 | * Stubs some of the context to make UCAN-authorized requests testable while the 16 | * feature is being built. 17 | * 18 | * NOTE!: This must not persist once the feature is released, without additional 19 | * safeguards. This currently allows anyone to bypass data privacy, which is 20 | * fine while that privacy is not yet a released and used feature, but becomes a 21 | * hole once actual users expect privacy to work. 22 | * 23 | * @type {( 24 | * Middleware< 25 | * MiddlewareContext & LocatorContext & GatewayIdentityContext & DelegationsStorageContext, 26 | * MiddlewareContext & LocatorContext & GatewayIdentityContext, 27 | * Environment & { FF_DELEGATIONS_STORAGE_ENABLED: string } 28 | * > 29 | * )} 30 | */ 31 | export const withDelegationStubs = (handler) => async (request, env, ctx) => { 32 | if (env.FF_DELEGATIONS_STORAGE_ENABLED === 'true') { 33 | // @ts-expect-error: If FF_DELEGATIONS_STORAGE_ENABLED is true, the context 34 | // will have the delegationsStorage created by the withDelegationsStorage 35 | // middleware. So we can skip the stubbing. 36 | return handler(request, env, ctx) 37 | } 38 | 39 | const stubSpace = new URL(request.url).searchParams.get('stub_space') 40 | const stubDelegations = await Promise.all( 41 | new URL(request.url).searchParams 42 | .getAll('stub_delegation') 43 | .map(async (delegationBase64url) => { 44 | // atob() only supports base64, not base64url. Buffer supports 45 | // base64url, but isn't available in the worker. 46 | const delegationBase64 = delegationBase64url 47 | .replaceAll('-', '+') 48 | .replaceAll('_', '/') 49 | const res = await Delegation.extract( 50 | Uint8Array.from(atob(delegationBase64), (c) => c.charCodeAt(0)) 51 | ) 52 | if (res.error) throw res.error 53 | return res.ok 54 | }) 55 | ) 56 | 57 | return handler(request, env, { 58 | ...ctx, 59 | delegationsStorage: { 60 | find: async () => ({ ok: stubDelegations }), 61 | store: async () => ({ ok: {} }) 62 | }, 63 | locator: 64 | stubSpace && Schema.did({ method: 'key' }).is(stubSpace) 65 | ? ctx.locator.scopeToSpaces([stubSpace]) 66 | : ctx.locator 67 | }) 68 | } 69 | -------------------------------------------------------------------------------- /src/middleware/withCarParkFetch.js: -------------------------------------------------------------------------------- 1 | import { withSimpleSpan } from '@web3-storage/blob-fetcher/tracing/tracing' 2 | import { createHandler } from '@web3-storage/public-bucket/server' 3 | // eslint-disable-next-line 4 | import * as BucketAPI from '@web3-storage/public-bucket' 5 | 6 | /** @implements {BucketAPI.Bucket} */ 7 | export class TraceBucket { 8 | #bucket 9 | 10 | /** 11 | * 12 | * @param {BucketAPI.Bucket} bucket 13 | */ 14 | constructor (bucket) { 15 | this.#bucket = bucket 16 | } 17 | 18 | /** @param {string} key */ 19 | head (key) { 20 | return withSimpleSpan('bucket.head', this.#bucket.head, this.#bucket)(key) 21 | } 22 | 23 | /** 24 | * @param {string} key 25 | * @param {BucketAPI.GetOptions} [options] 26 | */ 27 | get (key, options) { 28 | return withSimpleSpan('bucket.get', this.#bucket.get, this.#bucket)(key, options) 29 | } 30 | } 31 | 32 | /** 33 | * @import { 34 | * Middleware, 35 | * Context as MiddlewareContext 36 | * } from '@web3-storage/gateway-lib' 37 | * @import { 38 | * CarParkFetchContext, 39 | * CarParkFetchEnvironment 40 | * } from './withCarParkFetch.types.js' 41 | */ 42 | 43 | /** 44 | * 20MiB should allow the worker to process ~4-5 concurrent requests that 45 | * require a batch at the maximum size. 46 | */ 47 | const MAX_BATCH_SIZE = 20 * 1024 * 1024 48 | 49 | /** 50 | * Adds {@link CarParkFetchContext.fetch} to the context. This version of fetch 51 | * will pull directly from R2 CARPARK when present 52 | * 53 | * @type {Middleware} 54 | */ 55 | export function withCarParkFetch (handler) { 56 | return async (request, env, ctx) => { 57 | const url = new URL(request.url) 58 | const legacyRequest = url.searchParams.get('legacyReq') === 'true' 59 | if (!env.CARPARK_PUBLIC_BUCKET_URL || legacyRequest) { 60 | return handler(request, env, { ...ctx, fetch: globalThis.fetch }) 61 | } 62 | 63 | const bucket = new TraceBucket(/** @type {import('@web3-storage/public-bucket').Bucket} */ (env.CARPARK)) 64 | const bucketHandler = createHandler({ bucket, maxBatchSize: MAX_BATCH_SIZE }) 65 | 66 | /** 67 | * 68 | * @param {globalThis.RequestInfo | URL} input 69 | * @param {globalThis.RequestInit} [init] 70 | * @returns {Promise} 71 | */ 72 | const fetch = async (input, init) => { 73 | const request = input instanceof Request ? input : new Request(input, init) 74 | // check whether request is going to CARPARK 75 | if (env.CARPARK_PUBLIC_BUCKET_URL && request.url.startsWith(env.CARPARK_PUBLIC_BUCKET_URL)) { 76 | return bucketHandler(request) 77 | } 78 | return globalThis.fetch(input, init) 79 | } 80 | return handler(request, env, { ...ctx, fetch }) 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /src/middleware/withLocator.js: -------------------------------------------------------------------------------- 1 | import { ContentClaimsClient } from '@web3-storage/blob-fetcher/locator/content-claims-client' 2 | import * as Locator from '@web3-storage/blob-fetcher/locator' 3 | import { Client } from '@storacha/indexing-service-client' 4 | import { trace } from '@opentelemetry/api' 5 | 6 | /** 7 | * @import { 8 | * IpfsUrlContext, 9 | * Middleware, 10 | * } from '@web3-storage/gateway-lib' 11 | * @import { 12 | * LocatorContext, 13 | * LocatorEnvironment 14 | * } from './withLocator.types.js' 15 | */ 16 | 17 | /** 18 | * Adds {@link LocatorContext.locator} to the context which connects to the 19 | * {@link LocatorEnvironment.INDEXING_SERVICE_URL}. 20 | * 21 | * @type {Middleware} 22 | */ 23 | export function withLocator (handler) { 24 | return async (request, env, ctx) => { 25 | const url = new URL(request.url) 26 | const legacyRequest = url.searchParams.get('legacyReq') === 'true' 27 | const useIndexingService = isIndexingServiceEnabled(request, env) 28 | const span = trace.getActiveSpan() 29 | if (span) { 30 | span.setAttribute('useIndexingService', useIndexingService) 31 | span.setAttribute('legacyRequest', legacyRequest) 32 | } 33 | 34 | const client = 35 | !legacyRequest && useIndexingService 36 | ? new Client({ 37 | serviceURL: env.INDEXING_SERVICE_URL 38 | ? new URL(env.INDEXING_SERVICE_URL) 39 | : undefined 40 | }) 41 | : new ContentClaimsClient({ 42 | serviceURL: env.CONTENT_CLAIMS_SERVICE_URL 43 | ? new URL(env.CONTENT_CLAIMS_SERVICE_URL) 44 | : undefined, 45 | // @ts-expect-error not yet sure how to square with types in libraries 46 | carpark: !legacyRequest ? env.CARPARK : undefined, 47 | carparkPublicBucketURL: 48 | !legacyRequest && env.CARPARK_PUBLIC_BUCKET_URL 49 | ? new URL(env.CARPARK_PUBLIC_BUCKET_URL) 50 | : undefined 51 | }) 52 | 53 | const { headers } = request 54 | const { searchParams } = ctx 55 | if (!searchParams) throw new Error('missing URL search params') 56 | const compressed = 57 | searchParams.get('format') === 'raw' || 58 | headers.get('Accept')?.includes('application/vnd.ipld.raw') 59 | const locator = Locator.create({ client, compressed }) 60 | return handler(request, env, { ...ctx, locator }) 61 | } 62 | } 63 | 64 | /** 65 | * Determines if the indexing service is enabled. It is enabled if the request 66 | * contains the `ff=indexing-service` query parameter or if a random chance 67 | * falls within the ramp-up probability. If `FF_RAMP_UP_PROBABILITY` is not set, 68 | * it defaults to 0%. 69 | * 70 | * @param {Request} request 71 | * @param {LocatorEnvironment} env 72 | * @returns {boolean} 73 | */ 74 | function isIndexingServiceEnabled (request, env) { 75 | const withIndexingServicesArg = new URL(request.url).searchParams 76 | .getAll('ff') 77 | .includes('indexing-service') 78 | const probability = env.FF_RAMP_UP_PROBABILITY 79 | ? Number(env.FF_RAMP_UP_PROBABILITY) 80 | : 0 81 | const withIndexerEnabled = Math.random() * 100 <= probability 82 | return withIndexingServicesArg || withIndexerEnabled 83 | } 84 | -------------------------------------------------------------------------------- /scripts/mk-validator-proof.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Create a "ucan/attest" delegation allowing the gateway to validate 3 | * attestations issued by the upload-service. 4 | * 5 | * This generates the GATEWAY_VALIDATOR_PROOF environment variable value. 6 | * 7 | * Usage: node scripts/mk-validator-proof.js 8 | * 9 | * Example (staging): 10 | * node scripts/mk-validator-proof.js \ 11 | * did:web:staging.up.storacha.network \ 12 | * MgCZT5J+...your-key-here... \ 13 | * did:web:staging.w3s.link 14 | * 15 | * Example (production): 16 | * node scripts/mk-validator-proof.js \ 17 | * did:web:up.storacha.network \ 18 | * MgCZT5J+...your-key-here... \ 19 | * did:web:w3s.link 20 | */ 21 | import * as DID from '@ipld/dag-ucan/did' 22 | import { CAR, delegate } from '@ucanto/core' 23 | import * as ed25519 from '@ucanto/principal/ed25519' 24 | import { base64 } from 'multiformats/bases/base64' 25 | import { identity } from 'multiformats/hashes/identity' 26 | import * as Link from 'multiformats/link' 27 | 28 | // CORRECT DIRECTION (staging): 29 | // - issuer should be did:web:staging.up.storacha.network (upload-service) 30 | // - audience should be did:web:staging.w3s.link (gateway) 31 | // - can should be 'ucan/attest' 32 | // - with should be issuer.did() (i.e. did:web:staging.up.storacha.network) 33 | // The private key must be the upload-service private key. This makes the 34 | // gateway trust attestations issued by the upload-service. 35 | 36 | const uploadServiceDIDWeb = process.argv[2] 37 | const uploadServicePrivateKey = process.argv[3] 38 | const gatewayDIDWeb = process.argv[4] 39 | 40 | if (!uploadServiceDIDWeb || !uploadServicePrivateKey || !gatewayDIDWeb) { 41 | console.error('Error: Missing required arguments') 42 | console.error('Usage: node scripts/mk-validator-proof.js ') 43 | console.error('') 44 | console.error('Example (staging):') 45 | console.error(' node scripts/mk-validator-proof.js \\') 46 | console.error(' did:web:staging.up.storacha.network \\') 47 | console.error(' MgCZT5J+...your-key-here... \\') 48 | console.error(' did:web:staging.w3s.link') 49 | process.exit(1) 50 | } 51 | 52 | console.log(`Upload Service DID: ${uploadServiceDIDWeb}`) 53 | console.log(`Upload Service Private Key: ${uploadServicePrivateKey.slice(0, 7)}...${uploadServicePrivateKey.slice(-7)}`) 54 | console.log(`Gateway DID: ${gatewayDIDWeb}`) 55 | console.log('') 56 | 57 | const issuer = ed25519 58 | .parse(uploadServicePrivateKey) 59 | .withDID(DID.parse(uploadServiceDIDWeb).did()) 60 | const audience = DID.parse(gatewayDIDWeb) 61 | 62 | // Note: variable names are confusing - "uploadService" is actually the issuer (gateway in our case) 63 | // and "gateway" is actually the audience (upload service in our case) 64 | // The 'with' should be the issuer's DID per colleague's instructions 65 | const delegation = await delegate({ 66 | issuer, 67 | audience, 68 | capabilities: [{ can: 'ucan/attest', with: issuer.did() }], 69 | expiration: Infinity 70 | }) 71 | 72 | console.log('✅ Delegation created:') 73 | console.log(` Issuer: ${issuer.did()}`) 74 | console.log(` Audience: ${audience.did()}`) 75 | console.log(` Capability: ucan/attest with ${issuer.did()}`) 76 | console.log('') 77 | 78 | const res = await delegation.archive() 79 | if (res.error) { 80 | console.error('❌ Error archiving delegation:', res.error) 81 | throw res.error 82 | } 83 | 84 | const proof = Link.create(CAR.code, identity.digest(res.ok)).toString(base64) 85 | 86 | console.log('✅ Validator proof generated successfully!') 87 | console.log('') 88 | console.log('Add this to your environment variables:') 89 | console.log('') 90 | console.log('GATEWAY_VALIDATOR_PROOF=' + proof) 91 | console.log('') 92 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "freeway", 3 | "version": "2.36.1", 4 | "description": "An IPFS gateway for accessing UnixFS data via CAR CIDs", 5 | "keywords": [ 6 | "IPFS", 7 | "gateway", 8 | "CAR", 9 | "CID", 10 | "IPLD", 11 | "UnixFS" 12 | ], 13 | "license": "Apache-2.0 OR MIT", 14 | "author": "Alan Shaw", 15 | "type": "module", 16 | "exports": { 17 | ".": { 18 | "import": "./src/index.js", 19 | "types": "./dist/src/index.d.ts" 20 | } 21 | }, 22 | "main": "src/index.js", 23 | "types": "dist/src/index.d.ts", 24 | "scripts": { 25 | "build": "esbuild --bundle src/index.js --format=esm --external:node:buffer --external:node:events --external:node:async_hooks --external:cloudflare:workers --sourcemap --minify --outfile=dist/worker.mjs && npm run build:tsc", 26 | "build:debug": "esbuild --bundle src/index.js --format=esm --external:node:buffer --external:node:events --external:node:async_hooks --external:cloudflare:workers --outfile=dist/worker.mjs", 27 | "build:tsc": "tsc --build", 28 | "dev": "npm run build:debug && miniflare dist/worker.mjs --watch --debug -m --r2-persist --global-async-io --global-timers", 29 | "lint": "standard", 30 | "lint:fix": "standard --fix", 31 | "prepare": "npm run build", 32 | "start": "npm run dev", 33 | "test:integration": "npm run build:debug && mocha --experimental-vm-modules --recursive test/integration/**/*.spec.js --require test/fixtures/worker-fixture.js", 34 | "test:miniflare": "npm run build:debug && mocha --experimental-vm-modules --recursive test/miniflare/**/*.spec.js", 35 | "test:unit": "npm run build:debug && mocha --experimental-vm-modules --recursive test/unit/**/*.spec.js", 36 | "test:unit:only": "npm run build:debug && mocha --experimental-vm-modules" 37 | }, 38 | "dependencies": { 39 | "@ipld/dag-json": "^10.2.5", 40 | "@ipld/dag-pb": "^4.1.5", 41 | "@microlabs/otel-cf-workers": "^1.0.0-rc.48", 42 | "@opentelemetry/api": "^1.9.0", 43 | "@opentelemetry/sdk-trace-base": "^1.27.0", 44 | "@storacha/capabilities": "^1.10.0", 45 | "@storacha/indexing-service-client": "^2.6.9", 46 | "@types/node": "^24.10.1", 47 | "@ucanto/client": "^9.0.2", 48 | "@ucanto/core": "^10.4.5", 49 | "@ucanto/interface": "^11.0.1", 50 | "@ucanto/principal": "^9.0.3", 51 | "@ucanto/server": "^11.0.3", 52 | "@ucanto/transport": "^9.2.1", 53 | "@ucanto/validator": "^10.0.1", 54 | "@web3-storage/blob-fetcher": "^4.2.6", 55 | "@web3-storage/gateway-lib": "^5.2.2", 56 | "dagula": "^8.0.0", 57 | "http-range-parse": "^1.0.0", 58 | "lnmap": "^2.0.0", 59 | "multiformats": "^13.0.1" 60 | }, 61 | "devDependencies": { 62 | "@ipld/dag-ucan": "^3.4.5", 63 | "@storacha/blob-index": "^1.2.4", 64 | "@storacha/cli": "^1.6.2", 65 | "@storacha/client": "^1.8.2", 66 | "@storacha/upload-client": "^1.3.6", 67 | "@types/chai": "^5.0.0", 68 | "@types/mocha": "^10.0.9", 69 | "@types/node": "^24.10.1", 70 | "@types/node-fetch": "^2.6.11", 71 | "@types/sinon": "^17.0.3", 72 | "@web3-storage/content-claims": "^5.2.4", 73 | "@web3-storage/public-bucket": "^1.4.0", 74 | "carstream": "^2.2.0", 75 | "chai": "^5.1.1", 76 | "esbuild": "^0.24.0", 77 | "files-from-path": "^0.2.6", 78 | "miniflare": "^4.20251011.1", 79 | "mocha": "^10.7.3", 80 | "multipart-byte-range": "^3.0.1", 81 | "sade": "^1.8.1", 82 | "sinon": "^19.0.2", 83 | "standard": "^17.1.2", 84 | "tree-kill": "^1.2.2", 85 | "typescript": "^5.8.3", 86 | "wrangler": "^4.45.0" 87 | }, 88 | "standard": { 89 | "ignore": [ 90 | "*.ts", 91 | "src/middleware/withVersionHeader.js" 92 | ] 93 | }, 94 | "packageManager": "pnpm@10.14.0+sha512.ad27a79641b49c3e481a16a805baa71817a04bbe06a38d17e60e2eaee83f6a146c6a688125f5792e48dd5ba30e7da52a5cda4c3992b9ccf333f9ce223af84748" 95 | } 96 | -------------------------------------------------------------------------------- /src/server/index.js: -------------------------------------------------------------------------------- 1 | import * as Server from '@ucanto/server' 2 | import * as CAR from '@ucanto/transport/car' 3 | import { DIDResolutionError } from '@ucanto/validator' 4 | import * as Proof from '@storacha/client/proof' 5 | 6 | /** 7 | * Known did:web to did:key mappings for signature verification 8 | * @type {Record<`did:${string}:${string}`, `did:key:${string}`>} 9 | */ 10 | const knownWebDIDs = { 11 | // Production 12 | 'did:web:up.storacha.network': 13 | 'did:key:z6MkqdncRZ1wj8zxCTDUQ8CRT8NQWd63T7mZRvZUX8B7XDFi', 14 | 'did:web:web3.storage': 15 | 'did:key:z6MkqdncRZ1wj8zxCTDUQ8CRT8NQWd63T7mZRvZUX8B7XDFi', 16 | 'did:web:w3s.link': 17 | 'did:key:z6Mkha3NLZ38QiZXsUHKRHecoumtha3LnbYEL21kXYBFXvo5', 18 | 19 | // Staging 20 | 'did:web:staging.up.storacha.network': 21 | 'did:key:z6MkhcbEpJpEvNVDd3n5RurquVdqs5dPU16JDU5VZTDtFgnn', 22 | 'did:web:staging.web3.storage': 23 | 'did:key:z6MkhcbEpJpEvNVDd3n5RurquVdqs5dPU16JDU5VZTDtFgnn', 24 | 'did:web:staging.w3s.link': 25 | 'did:key:z6MkqK1d4thaCEXSGZ6EchJw3tDPhQriwynWDuR55ayATMNf' 26 | } 27 | 28 | /** 29 | * Resolves did:web DIDs to their corresponding did:key DIDs 30 | * @param {import('@ucanto/interface').DID} did 31 | */ 32 | export const resolveDIDKey = async (did) => { 33 | if (knownWebDIDs[did]) { 34 | const didKey = /** @type {`did:key:${string}`} */ (knownWebDIDs[did]) 35 | return Server.ok([didKey]) // Return array of did:keys 36 | } 37 | return Server.error(new DIDResolutionError(did)) 38 | } 39 | 40 | /** 41 | * @type {import('@ucanto/interface').Delegation[]} 42 | */ 43 | let cachedValidatorProofs 44 | 45 | /** 46 | * Loads validator proofs from environment variable. 47 | * These proofs allow the gateway to validate ucan/attest delegations. 48 | * 49 | * @param {{ GATEWAY_VALIDATOR_PROOF?: string }} env 50 | * @returns {Promise} 51 | */ 52 | export const getValidatorProofs = async (env) => { 53 | if (cachedValidatorProofs) { 54 | return cachedValidatorProofs 55 | } 56 | cachedValidatorProofs = [] 57 | if (env.GATEWAY_VALIDATOR_PROOF) { 58 | try { 59 | const proof = await Proof.parse(env.GATEWAY_VALIDATOR_PROOF) 60 | const delegation = /** @type {import('@ucanto/interface').Delegation} */ ( 61 | proof 62 | ) 63 | console.log( 64 | `Gateway validator proof loaded: [issuer: ${delegation.issuer.did()}, audience: ${delegation.audience.did()}]` 65 | ) 66 | cachedValidatorProofs = [delegation] 67 | } catch (error) { 68 | console.error('Failed to parse GATEWAY_VALIDATOR_PROOF:', error) 69 | } 70 | } 71 | return cachedValidatorProofs 72 | } 73 | 74 | /** 75 | * Creates a UCAN server. 76 | * 77 | * @template T 78 | * @param {import('../middleware/withUcanInvocationHandler.types.js').Context} ctx 79 | * @param {import('./api.types.js').Service} service 80 | * @param {{ GATEWAY_VALIDATOR_PROOF?: string }} env 81 | */ 82 | export async function createServer (ctx, service, env) { 83 | const proofs = await getValidatorProofs(env) 84 | console.log('Server validator proofs loaded:', proofs.length) 85 | if (proofs.length > 0) { 86 | console.log('First proof details:', { 87 | issuer: proofs[0].issuer.did(), 88 | audience: proofs[0].audience.did(), 89 | capabilities: proofs[0].capabilities.map((c) => ({ 90 | can: c.can, 91 | with: c.with 92 | })), 93 | cid: proofs[0].cid.toString() 94 | }) 95 | } 96 | return Server.create({ 97 | id: ctx.gatewaySigner.withDID(ctx.gatewayIdentity.did()), 98 | codec: CAR.inbound, 99 | service, 100 | catch: (err) => console.error(err), 101 | // TODO: wire into revocations 102 | validateAuthorization: () => ({ ok: {} }), 103 | resolveDIDKey, 104 | proofs 105 | }) 106 | } 107 | -------------------------------------------------------------------------------- /test/helpers/run-wrangler.js: -------------------------------------------------------------------------------- 1 | import assert from 'node:assert' 2 | import { fork } from 'node:child_process' 3 | import path from 'node:path' 4 | import treeKill from 'tree-kill' 5 | import { fileURLToPath } from 'node:url' 6 | 7 | // Get __dirname equivalent in ES module 8 | const __filename = fileURLToPath(import.meta.url) 9 | const __dirname = path.dirname(__filename) 10 | 11 | export const wranglerEntryPath = path.resolve( 12 | __dirname, 13 | '../../node_modules/wrangler/bin/wrangler.js' 14 | ) 15 | 16 | /** 17 | * @typedef {Object} WranglerProcessInfo 18 | * @property {string} ip - The IP address of the test worker. 19 | * @property {number} port - The port of the test worker. 20 | * @property {() => Promise} stop - Function to stop the test worker. 21 | * @property {() => string} getOutput - Function to get the output of the test worker. 22 | * @property {() => void} clearOutput - Function to clear the output buffer. 23 | */ 24 | 25 | /** 26 | * Runs the command `wrangler dev` in a child process. 27 | * 28 | * Returns an object that gives you access to: 29 | * 30 | * - `ip` and `port` of the http-server hosting the pages project 31 | * - `stop()` function that will close down the server. 32 | * 33 | * @param {string} cwd - The current working directory. 34 | * @param {string[]} options - The options to pass to the wrangler command. 35 | * @param {NodeJS.ProcessEnv} [env] - The environment variables. 36 | * @param {string} [wranglerEnv] - The wrangler environment to use. 37 | * @returns {Promise} 38 | */ 39 | export async function runWranglerDev ( 40 | cwd, 41 | options, 42 | env, 43 | wranglerEnv 44 | ) { 45 | return runLongLivedWrangler( 46 | ['dev', `--env=${wranglerEnv}`, ...options], 47 | cwd, 48 | env 49 | ) 50 | } 51 | 52 | /** 53 | * Runs a long-lived wrangler command in a child process. 54 | * 55 | * @param {string[]} command - The wrangler command to run. 56 | * @param {string} cwd - The current working directory. 57 | * @param {NodeJS.ProcessEnv} [env] - The environment variables. 58 | * @returns {Promise} 59 | */ 60 | async function runLongLivedWrangler ( 61 | command, 62 | cwd, 63 | env 64 | ) { 65 | return new Promise((resolve, reject) => { 66 | const wranglerProcess = fork(wranglerEntryPath, command, { 67 | stdio: ['ignore', /* stdout */ 'pipe', /* stderr */ 'pipe', 'ipc'], 68 | cwd, 69 | env: { ...process.env, ...env, PWD: cwd } 70 | }).on('message', (messageJSON) => { 71 | clearTimeout(timeoutHandle) 72 | /** @type {{ ip: string, port: number }} */ 73 | const message = JSON.parse(messageJSON.toString()) 74 | resolve({ ...message, stop, getOutput, clearOutput }) 75 | }) 76 | 77 | /** @type {Buffer[]} */ 78 | const chunks = [] 79 | wranglerProcess.stdout?.on('data', (chunk) => { 80 | chunks.push(chunk) 81 | }) 82 | wranglerProcess.stderr?.on('data', (chunk) => { 83 | chunks.push(chunk) 84 | }) 85 | const getOutput = () => Buffer.concat(chunks).toString() 86 | const clearOutput = () => (chunks.length = 0) 87 | 88 | const timeoutHandle = setTimeout(() => { 89 | const separator = '='.repeat(80) 90 | const message = [ 91 | 'Timed out starting long-lived Wrangler:', 92 | separator, 93 | getOutput(), 94 | separator 95 | ].join('\n') 96 | reject(new Error(message)) 97 | }, 50_000) 98 | 99 | /** @type {WranglerProcessInfo['stop']} */ 100 | async function stop () { 101 | return new Promise((resolve) => { 102 | assert( 103 | wranglerProcess.pid, 104 | `Command "${command.join(' ')}" had no process id` 105 | ) 106 | treeKill(wranglerProcess.pid, 'SIGKILL', (e) => { 107 | if (e) { 108 | console.error( 109 | 'Failed to kill command: ' + command.join(' '), 110 | wranglerProcess.pid, 111 | e 112 | ) 113 | } 114 | resolve() 115 | }) 116 | }) 117 | } 118 | }) 119 | } 120 | -------------------------------------------------------------------------------- /src/middleware/withEgressClient.js: -------------------------------------------------------------------------------- 1 | import * as UCantoClient from '@ucanto/client' 2 | import * as CAR from '@ucanto/transport/car' 3 | import { SpaceDID } from '@storacha/capabilities/utils' 4 | import { HTTP } from '@ucanto/transport' 5 | import { Space } from '@storacha/capabilities' 6 | import { DID } from '@ucanto/core' 7 | 8 | /** 9 | * @import { Middleware } from '@web3-storage/gateway-lib' 10 | * @typedef {import('./withEgressClient.types.js').EgressClientContext} EgressClientContext 11 | * @typedef {import('./withEgressClient.types.js').Environment} Environment 12 | */ 13 | 14 | /** 15 | * The EgressClient handler exposes the methods to invoke capabilities on the Upload API. 16 | * 17 | * @type {Middleware} 18 | */ 19 | export function withEgressClient (handler) { 20 | return async (req, env, ctx) => { 21 | if (env.FF_EGRESS_TRACKER_ENABLED !== 'true') { 22 | return handler(req, env, ctx) 23 | } 24 | const egressClient = await create(env, ctx) 25 | return handler(req, env, { ...ctx, egressClient }) 26 | } 27 | } 28 | 29 | /** 30 | * Creates a EgressClient instance with the given environment and establishes a connection to the UCanto Server. 31 | * 32 | * @param {Environment} env 33 | * @param {import('./withEgressClient.types.js').EgressClientContext} ctx 34 | * @returns {Promise} 35 | */ 36 | async function create (env, ctx) { 37 | return { 38 | /** 39 | * Records the egress bytes for the given resource. 40 | * 41 | * @param {import('@ucanto/principal/ed25519').DIDKey} space - The Space DID where the content was served 42 | * @param {import('@ucanto/principal/ed25519').UnknownLink} resource - The link to the resource that was served 43 | * @param {number} bytes - The number of bytes served 44 | * @param {Date} servedAt - The timestamp of when the content was served 45 | * @returns {Promise} 46 | */ 47 | record: async (space, resource, bytes, servedAt) => 48 | record(space, resource, bytes, servedAt, env, ctx) 49 | } 50 | } 51 | 52 | /** 53 | * Creates a connection with the UCanto Server at the provided server URL. 54 | * 55 | * @param {string} serverUrl 56 | * @param {import('@ucanto/client').Principal<`did:${string}:${string}`>} principal 57 | * 58 | */ 59 | async function connect (serverUrl, principal) { 60 | const connection = await UCantoClient.connect({ 61 | id: principal, 62 | codec: CAR.outbound, 63 | channel: HTTP.open({ url: new URL(serverUrl) }) 64 | }) 65 | 66 | return connection 67 | } 68 | 69 | /** 70 | * Records the egress bytes in the UCanto Server by invoking the `Space.egressRecord` capability. 71 | * 72 | * @param {import('@ucanto/principal/ed25519').DIDKey} space - The Space DID where the content was served 73 | * @param {import('@ucanto/principal/ed25519').UnknownLink} resource - The link to the resource that was served 74 | * @param {number} bytes - The number of bytes served 75 | * @param {Date} servedAt - The timestamp of when the content was served 76 | * @param {import('./withEgressClient.types.js').Environment} env - The environment 77 | * @param {import('./withEgressClient.types.js').EgressClientContext} ctx - The egress client context 78 | * @returns {Promise} 79 | */ 80 | async function record (space, resource, bytes, servedAt, env, ctx) { 81 | const uploadServicePrincipal = DID.parse(env.UPLOAD_SERVICE_DID) 82 | const connection = await connect(env.UPLOAD_API_URL, uploadServicePrincipal) 83 | 84 | const invocation = Space.egressRecord.invoke({ 85 | issuer: ctx.gatewayIdentity, 86 | audience: uploadServicePrincipal, 87 | with: SpaceDID.from(space), 88 | nb: { 89 | resource, 90 | bytes, 91 | servedAt: servedAt.getTime() 92 | }, 93 | expiration: Infinity, // Don't expire the invocation, so we can record egress any time 94 | nonce: process.hrtime().toString(), 95 | proofs: ctx.delegationProofs 96 | }) 97 | const res = await invocation.execute(connection) 98 | if (res.out.error) { 99 | console.error(`Failed to record egress for space ${space}`, res.out.error) 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # freeway 2 | 3 |

4 | GitHub Workflow Status 5 | 6 | License: Apache-2.0 OR MIT 7 |

8 | 9 | 🧪 Experimental IPFS HTTP gateway providing access to UnixFS data via CAR CIDs. 10 | 11 | ![freeway overview diagram](./docs/freeway.png) 12 | 13 | ## Running Locally 14 | 15 | 1. Install the project 16 | ```sh 17 | npm install 18 | ``` 19 | 20 | 2. CloudFlare Authentication 21 | ```sh 22 | npx wrangler login 23 | ``` 24 | 25 | 3. Get Your Account Id 26 | ```sh 27 | npx wrangler whoami 28 | ``` 29 | 30 | 4. Add your configs to `wrangler.toml` 31 | ```sh 32 | [env.YOUR_USERNAME] 33 | # Custom name for your testing/dev worker 34 | name = "YOUR_USERNAME-freeway" 35 | workers_dev = true 36 | # Account Id from step 3 37 | account_id = "YOUR_ACCOUNT_ID" 38 | # See step 4.2 to create the R2 Bucket 39 | r2_buckets = [ 40 | { 41 | binding = "CARPARK", bucket_name = "carpark-YOUR_USERNAME-0", preview_bucket_name = "carpark-YOUR_USERNAME-preview-0" 42 | } 43 | ] 44 | 45 | [env.YOUR_USERNAME.vars] 46 | DEBUG = "true" 47 | FF_RATE_LIMITER_ENABLED = "false" 48 | CONTENT_CLAIMS_SERVICE_URL = "https://dev.claims.web3.storage" 49 | ``` 50 | 51 | If you want to enable the Rate Limiter and KV add the following too: 52 | ```sh 53 | [[env.YOUR_USERNAME.unsafe.bindings]] 54 | name = "RATE_LIMITER" 55 | type = "ratelimit" 56 | namespace_id = "0" 57 | simple = { limit = 100, period = 60 } 58 | 59 | [[env.YOUR_USERNAME.kv_namespaces]] 60 | binding = "AUTH_TOKEN_METADATA" 61 | # See step 4.1 to create the KV store 62 | id = "KV_ID" 63 | ``` 64 | 65 | 4.1 66 | In order to get the KV ID you need to create a KV with the following command: 67 | ```sh 68 | npx wrangler kv namespace create YOUR_USERNAME-AUTH_TOKEN_METADATA 69 | ``` 70 | Copy the `id` from the output and add it to your `env.YOUR_USERNAME.kv_namespaces`. 71 | 72 | 4.2 73 | You will also need to create the R2 Bucket: 74 | ```sh 75 | npx wrangler r2 bucket create carpark-YOUR_USERNAME-0 76 | ``` 77 | 78 | 5. Start local server 79 | ```sh 80 | npx wrangler dev -e YOUR_USERNAME 81 | ``` 82 | 83 | ## Testing 84 | 85 | Freeway is using miniflare v3 for testing which allows you to define the testing configurations in the JavaScript code (see `src/test/index.spec.js`). 86 | 87 | Note: 88 | - Miniflare v3 doesn't support the Rate Limiting binding for now, so we need to mock the rate limiting API to be able to use it in tests and in local development. 89 | 90 | In order to run the existing tests you can execute the following commands: 91 | 92 | **Miniflare Tests** 93 | ```sh 94 | npm run test:miniflare 95 | ``` 96 | 97 | **Unit Tests** 98 | ```sh 99 | npm run test:unit 100 | ``` 101 | 102 | **Integration Tests** 103 | ```sh 104 | npm run test:integration 105 | ``` 106 | 107 | ## Deployment 108 | 109 | Deploy the worker to Cloudflare using your environment configuration: 110 | ```sh 111 | npx wrangler deploy -e YOUR_USERNAME 112 | ``` 113 | 114 | In order to remove the worker after your testing is done, you can execute: 115 | ```sh 116 | npx wrangler delete YOUR_WORKER_NAME -e YOUR_USERNAME 117 | ``` 118 | If you are connected with the Cloudflare Company account, please make sure you are not deleting the `freeway-worker` - which is the Production worker. 119 | 120 | ## Contributing 121 | 122 | Feel free to join in. All welcome. Please read our [contributing guidelines](https://github.com/web3-storage/freeway/blob/main/CONTRIBUTING.md) and/or [open an issue](https://github.com/web3-storage/freeway/issues)! 123 | 124 | ## License 125 | 126 | Dual-licensed under [MIT + Apache 2.0](https://github.com/web3-storage/freeway/blob/main/LICENSE.md) 127 | 128 | -------------------------------------------------------------------------------- /test/unit/middleware/withUcanInvocationHandler.spec.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-unused-expressions 2 | --- 3 | `no-unused-expressions` doesn't understand that several of Chai's assertions 4 | are implemented as getters rather than explicit function calls; it thinks 5 | the assertions are unused expressions. */ 6 | import { describe, it, afterEach } from 'mocha' 7 | import { expect } from 'chai' 8 | import sinon from 'sinon' 9 | import { ed25519 } from '@ucanto/principal' 10 | import { withUcanInvocationHandler } from '../../../src/middleware/withUcanInvocationHandler.js' 11 | 12 | /** 13 | * @typedef {import('../../../src/middleware/withUcanInvocationHandler.types.js').Environment} Environment 14 | * @typedef {import('../../../src/middleware/withUcanInvocationHandler.types.js').Context} Context 15 | */ 16 | 17 | const env = 18 | /** @satisfies {Environment} */ 19 | ({ 20 | }) 21 | 22 | const gatewaySigner = (await ed25519.Signer.generate()).signer 23 | const gatewayIdentity = gatewaySigner.withDID('did:web:test.w3s.link') 24 | const serviceStub = { 25 | access: { 26 | delegate: sinon.stub().resolves({ ok: {} }) 27 | } 28 | } 29 | const serverStub = { 30 | request: sinon.stub().returns({ 31 | headers: {}, 32 | body: crypto.getRandomValues(new Uint8Array(10)), 33 | status: 200 34 | }), 35 | id: gatewayIdentity, 36 | service: serviceStub, 37 | codec: { accept: sinon.stub() }, 38 | validateAuthorization: sinon.stub() 39 | } 40 | 41 | const ctx = 42 | /** @satisfies {Context} */ 43 | ({ 44 | gatewaySigner, 45 | gatewayIdentity, 46 | waitUntil: async (promise) => { 47 | try { 48 | await promise 49 | } catch (error) { 50 | // Ignore errors. 51 | } 52 | }, 53 | delegationsStorage: { 54 | find: sinon.stub(), 55 | store: sinon.stub() 56 | } 57 | }) 58 | 59 | describe('withUcanInvocationHandler', () => { 60 | afterEach(() => { 61 | serviceStub.access.delegate.reset() 62 | serverStub.request.reset() 63 | }) 64 | 65 | it('should handle POST requests to the root path', async () => { 66 | const mockHandler = sinon.stub().callsFake((request, env, ctx) => { 67 | return { 68 | headers: {}, 69 | body: crypto.getRandomValues(new Uint8Array(10)), 70 | status: 200 71 | } 72 | }) 73 | 74 | const handler = withUcanInvocationHandler(mockHandler) 75 | const request = new Request('http://example.com/', { method: 'POST' }) 76 | const response = await handler(request, env, { 77 | ...ctx, 78 | // @ts-expect-error - TODO: fix the type 79 | server: serverStub, 80 | service: serviceStub 81 | }) 82 | 83 | expect(response).to.be.an.instanceOf(Response) 84 | expect(response.status).to.equal(200) 85 | expect(serverStub.request.called).to.be.true 86 | expect(mockHandler.calledOnceWith(request, env, ctx)).to.be.false 87 | }) 88 | 89 | it('should pass through non-POST requests', async () => { 90 | const content = crypto.getRandomValues(new Uint8Array(10)) 91 | const mockHandler = sinon.stub().callsFake((request, env, ctx) => { 92 | return { 93 | headers: {}, 94 | body: content, 95 | status: 200 96 | } 97 | }) 98 | 99 | const handler = withUcanInvocationHandler(mockHandler) 100 | const request = new Request('http://example.com/', { method: 'GET' }) 101 | const response = await handler(request, env, { 102 | ...ctx, 103 | // @ts-expect-error - TODO: fix the type 104 | server: serverStub, 105 | service: serviceStub 106 | }) 107 | 108 | expect(response.status).to.equal(200) 109 | expect(response.body).to.equal(content) 110 | expect(mockHandler.called).to.be.true 111 | expect(serverStub.request.called).to.be.false 112 | }) 113 | 114 | it('should pass through POST requests to non-root paths', async () => { 115 | const content = crypto.getRandomValues(new Uint8Array(10)) 116 | const mockHandler = sinon.stub().callsFake((request, env, ctx) => { 117 | return { 118 | headers: {}, 119 | body: content, 120 | status: 200 121 | } 122 | }) 123 | 124 | const path = 'other' 125 | const handler = withUcanInvocationHandler(mockHandler) 126 | const request = new Request(`http://example.com/${path}`, { method: 'POST' }) 127 | const response = await handler(request, env, { 128 | ...ctx, 129 | // @ts-expect-error - TODO: fix the type 130 | server: serverStub, 131 | service: serviceStub 132 | }) 133 | 134 | expect(response.status).to.equal(200) 135 | expect(response.body).to.equal(content) 136 | expect(mockHandler.called).to.be.true 137 | expect(serverStub.request.called).to.be.false 138 | }) 139 | }) 140 | -------------------------------------------------------------------------------- /scripts/delegate-serve.js: -------------------------------------------------------------------------------- 1 | import sade from 'sade' 2 | import { Schema } from '@ucanto/core' 3 | import { getClient } from '@storacha/cli/lib.js' 4 | import { Space } from '@storacha/capabilities' 5 | 6 | const MailtoDID = 7 | /** @type {import('@ucanto/validator').StringSchema<`did:mailto:${string}:${string}`, unknown>} */ ( 8 | Schema.text({ pattern: /^did:mailto:.*:.*$/ }) 9 | ) 10 | 11 | sade('delegate-serve.js [space]') 12 | .option( 13 | '--token', 14 | 'The auth token to use. If not provided, the delegation will not be authenticated.' 15 | ) 16 | .option('--accountDID', 'The account DID to use when creating a new space.') 17 | .option( 18 | '--gatewayDID', 19 | 'The gateway DID to use when delegating the space/content/serve capability. Defaults to did:web:staging.w3s.link.' 20 | ) 21 | .describe( 22 | `Delegates ${Space.contentServe.can} to the Gateway for a test space generated by the script, with an optional auth token. Outputs a base64url string suitable for the stub_delegation query parameter.` 23 | ) 24 | .action( 25 | /** 26 | * @param {string} [space] 27 | * @param {object} [options] 28 | * @param {string} [options.token] 29 | * @param {string} [options.accountDID] 30 | * @param {string} [options.gatewayDID] 31 | */ 32 | async ( 33 | space, 34 | { token, accountDID, gatewayDID = 'did:web:staging.w3s.link' } = {} 35 | ) => { 36 | const client = await getClient() 37 | 38 | space ??= await createSpace(client, accountDID) 39 | 40 | if (!Schema.did({}).is(space)) { 41 | throw new Error(`Invalid space DID: ${space}`) 42 | } 43 | 44 | const proofs = client.proofs([ 45 | { 46 | can: Space.contentServe.can, 47 | with: space 48 | } 49 | ]) 50 | 51 | if (proofs.length === 0) { 52 | throw new Error( 53 | `No proofs found. Are you authorized to ${Space.contentServe.can} ${space}?` 54 | ) 55 | } 56 | 57 | if (!Schema.did({}).is(gatewayDID)) { 58 | throw new Error(`Invalid gateway DID: ${gatewayDID}`) 59 | } 60 | 61 | const gatewayIdentity = { 62 | did: () => gatewayDID 63 | } 64 | 65 | // NOTE: This type assertion is wrong. It's a hack to let us use this 66 | // ability. `client.createDelegation` currently only accepts abilities it 67 | // knows about. That should probably be expanded, but this little script 68 | // isn't going to be the reason to go change that, as it involves updating 69 | // multiple packages. 70 | const ability = /** @type {"*"} */ (Space.contentServe.can) 71 | 72 | client.setCurrentSpace(space) 73 | const delegation = await client.createDelegation( 74 | gatewayIdentity, 75 | [ability], 76 | { 77 | expiration: Infinity, 78 | proofs 79 | } 80 | ) 81 | 82 | await client.capability.access.delegate({ 83 | delegations: [delegation] 84 | }) 85 | 86 | const carResult = await delegation.archive() 87 | if (carResult.error) throw carResult.error 88 | const base64Url = Buffer.from(carResult.ok).toString('base64url') 89 | process.stdout.write( 90 | `Agent Proofs: ${proofs 91 | .flatMap((p) => p.capabilities) 92 | .map((c) => `${c.can} with ${c.with}`) 93 | .join('\n')}\n` 94 | ) 95 | process.stdout.write(`Issuer: ${client.agent.issuer.did()}\n`) 96 | process.stdout.write(`Audience: ${gatewayIdentity.did()}\n`) 97 | process.stdout.write(`Space: ${space}\n`) 98 | process.stdout.write(`Token: ${token ?? 'none'}\n`) 99 | process.stdout.write( 100 | `Delegation: ${delegation.capabilities 101 | .map((c) => `${c.can} with ${c.with}`) 102 | .join('\n')}\n` 103 | ) 104 | process.stdout.write( 105 | `Stubs: stub_space=${space}&stub_delegation=${base64Url}&authToken=${ 106 | token ?? '' 107 | }\n` 108 | ) 109 | } 110 | ) 111 | .parse(process.argv) 112 | 113 | /** 114 | * @param {import('@storacha/client').Client} client 115 | * @param {string} [accountDID] 116 | */ 117 | async function createSpace (client, accountDID) { 118 | const provider = client.defaultProvider() 119 | if (!Schema.did({ method: 'web' }).is(provider)) { 120 | throw new Error(`Invalid provider DID: ${provider}`) 121 | } 122 | if (!accountDID) { 123 | throw new Error('Must provide an account DID to create a space') 124 | } 125 | 126 | if (!MailtoDID.is(accountDID)) { 127 | throw new Error(`Invalid account DID: ${accountDID}`) 128 | } 129 | const account = client.accounts()[accountDID] 130 | const newSpace = await client.agent.createSpace('test') 131 | const provision = await account.provision(newSpace.did(), { provider }) 132 | if (provision.error) throw provision.error 133 | await newSpace.save() 134 | await newSpace.createAuthorization(client.agent) 135 | return newSpace.did() 136 | } 137 | -------------------------------------------------------------------------------- /src/middleware/withDelegationsStorage.js: -------------------------------------------------------------------------------- 1 | import { Delegation } from '@ucanto/core' 2 | import { ok, error, Failure } from '@ucanto/server' 3 | 4 | /** 5 | * @import * as Ucanto from '@ucanto/interface' 6 | * @import { Middleware } from '@web3-storage/gateway-lib' 7 | * @typedef {import('./withDelegationsStorage.types.js').DelegationsStorageContext} DelegationsStorageContext 8 | * @typedef {import('./withDelegationsStorage.types.js').DelegationsStorageEnvironment} DelegationsStorageEnvironment 9 | */ 10 | 11 | /** 12 | * Provides a delegations storage in the application context 13 | * 14 | * @type {( 15 | * Middleware 16 | * )} 17 | */ 18 | export const withDelegationsStorage = 19 | (handler) => async (request, env, ctx) => { 20 | if (env.FF_DELEGATIONS_STORAGE_ENABLED !== 'true') { 21 | return handler(request, env, ctx) 22 | } 23 | return handler(request, env, { 24 | ...ctx, 25 | delegationsStorage: createStorage(env) 26 | }) 27 | } 28 | 29 | /** 30 | * @param {DelegationsStorageEnvironment} env 31 | * @returns {import('./withDelegationsStorage.types.js').DelegationsStorage} 32 | */ 33 | function createStorage (env) { 34 | return { 35 | /** 36 | * Finds the delegation proofs for the given space 37 | * 38 | * @param {import('@storacha/capabilities/types').SpaceDID} space 39 | * @returns {Promise[], DelegationNotFound | Ucanto.Failure>>} 40 | */ 41 | find: async (space) => { 42 | /** @type {Ucanto.Delegation[]} */ 43 | const delegations = [] 44 | const result = await env.CONTENT_SERVE_DELEGATIONS_STORE.list({ 45 | prefix: space 46 | }) 47 | await Promise.all( 48 | result.keys.map(async (key) => { 49 | const delegation = await env.CONTENT_SERVE_DELEGATIONS_STORE.get( 50 | key.name, 51 | 'arrayBuffer' 52 | ) 53 | if (delegation) { 54 | const d = await Delegation.extract(new Uint8Array(delegation)) 55 | if (d.ok) delegations.push(d.ok) 56 | else console.error('error while extracting delegation', d.error) 57 | } 58 | }) 59 | ) 60 | return ok(delegations) 61 | }, 62 | 63 | /** 64 | * Stores the delegation proofs for the given space. 65 | * If the delegation has an expiration, it will be stored with an expiration time in seconds since unix epoch. 66 | * 67 | * @param {import('@storacha/capabilities/types').SpaceDID} space 68 | * @param {Ucanto.Delegation} delegation 69 | * @returns {Promise>} 70 | */ 71 | store: async (space, delegation) => { 72 | let options = {} 73 | if ( 74 | delegation.expiration && 75 | delegation.expiration > 0 && 76 | delegation.expiration !== Infinity 77 | ) { 78 | // expire the key-value pair when the delegation expires (seconds since epoch) 79 | options = { expiration: delegation.expiration } 80 | } 81 | 82 | const value = await delegation.archive() 83 | if (value.error) { 84 | console.error('error while archiving delegation', value.error) 85 | return value 86 | } 87 | 88 | try { 89 | await env.CONTENT_SERVE_DELEGATIONS_STORE.put( 90 | `${space}:${delegation.cid.toString()}`, 91 | /** @type {ArrayBuffer} */ (value.ok.buffer), 92 | options 93 | ) 94 | return ok({}) 95 | } catch (/** @type {any} */ err) { 96 | const message = `error while storing delegation for space ${space}` 97 | console.error(message, err) 98 | return error(new StoreOperationFailed(message)) 99 | } 100 | } 101 | } 102 | } 103 | 104 | export class InvalidDelegation extends Failure { 105 | static name = /** @type {const} */ ('InvalidDelegation') 106 | #reason 107 | 108 | /** @param {string} [reason] */ 109 | constructor (reason) { 110 | super() 111 | this.#reason = reason 112 | } 113 | 114 | get name () { 115 | return InvalidDelegation.name 116 | } 117 | 118 | describe () { 119 | return this.#reason ?? 'Invalid delegation' 120 | } 121 | } 122 | 123 | export class DelegationNotFound extends Failure { 124 | static name = /** @type {const} */ ('DelegationNotFound') 125 | #reason 126 | 127 | /** @param {string} [reason] */ 128 | constructor (reason) { 129 | super() 130 | this.#reason = reason 131 | } 132 | 133 | get name () { 134 | return DelegationNotFound.name 135 | } 136 | 137 | describe () { 138 | return this.#reason ?? 'Delegation not found' 139 | } 140 | } 141 | 142 | export class StoreOperationFailed extends Failure { 143 | static name = /** @type {const} */ ('StoreOperationFailed') 144 | #reason 145 | 146 | /** @param {string} [reason] */ 147 | constructor (reason) { 148 | super() 149 | this.#reason = reason 150 | } 151 | 152 | get name () { 153 | return StoreOperationFailed.name 154 | } 155 | 156 | describe () { 157 | return this.#reason ?? 'Store operation failed' 158 | } 159 | } 160 | -------------------------------------------------------------------------------- /test/helpers/content-claims.js: -------------------------------------------------------------------------------- 1 | /* global ReadableStream, WritableStream */ 2 | import http from 'node:http' 3 | import { Writable } from 'node:stream' 4 | import { CARReaderStream, CARWriterStream } from 'carstream' 5 | import { sha256 } from 'multiformats/hashes/sha2' 6 | import { base58btc } from 'multiformats/bases/base58' 7 | import * as Digest from 'multiformats/hashes/digest' 8 | import * as Link from 'multiformats/link' 9 | import * as raw from 'multiformats/codecs/raw' 10 | import { Map as LinkMap } from 'lnmap' 11 | import { Assert } from '@web3-storage/content-claims/capability' 12 | import * as ed25519 from '@ucanto/principal/ed25519' 13 | import { CAR_CODE } from '../../src/constants.js' 14 | 15 | /** 16 | * @typedef {import('carstream/api').Block & { children: import('multiformats').UnknownLink[] }} RelationIndexData 17 | * @typedef {Map} Claims 18 | * @typedef {{ 19 | * url: URL 20 | * close: () => void 21 | * signer: import('@ucanto/interface').Signer 22 | * addClaims: (c: Claims) => void 23 | * resetClaims: () => void 24 | * getCallCount: () => number 25 | * resetCallCount: () => void 26 | * }} MockClaimsService 27 | */ 28 | 29 | /** 30 | * @param {import('@ucanto/interface').Signer} signer 31 | * @param {import('multiformats').Link} shard 32 | * @param {ReadableStream} carStream CAR file data 33 | * @param {URL} location 34 | */ 35 | export const generateBlockLocationClaims = async (signer, shard, carStream, location) => { 36 | /** @type {Claims} */ 37 | const claims = new LinkMap() 38 | 39 | await carStream 40 | .pipeThrough(new CARReaderStream()) 41 | .pipeTo(new WritableStream({ 42 | async write ({ cid, blockOffset, blockLength }) { 43 | const blocks = claims.get(cid) ?? [] 44 | blocks.push(await generateLocationClaim(signer, cid, location, blockOffset, blockLength)) 45 | claims.set(cid, blocks) 46 | } 47 | })) 48 | 49 | return claims 50 | } 51 | 52 | /** 53 | * @param {import('@ucanto/interface').Signer} signer 54 | * @param {import('multiformats').UnknownLink} content 55 | * @param {URL} location 56 | * @param {number} offset 57 | * @param {number} length 58 | */ 59 | export const generateLocationClaim = async (signer, content, location, offset, length) => { 60 | const invocation = Assert.location.invoke({ 61 | issuer: signer, 62 | audience: signer, 63 | with: signer.did(), 64 | nb: { 65 | content, 66 | location: [ 67 | /** @type {import('@ucanto/interface').URI} */ 68 | (location.toString()) 69 | ], 70 | range: { offset, length } 71 | } 72 | }) 73 | return await encode(invocation) 74 | } 75 | 76 | /** 77 | * @param {import('@ucanto/interface').Signer} signer 78 | * @param {import('multiformats').UnknownLink} content 79 | * @param {import('multiformats').Link} index 80 | */ 81 | export const generateIndexClaim = async (signer, content, index) => { 82 | const invocation = Assert.index.invoke({ 83 | issuer: signer, 84 | audience: signer, 85 | with: signer.did(), 86 | nb: { 87 | content, 88 | index 89 | } 90 | }) 91 | return await encode(invocation) 92 | } 93 | 94 | /** 95 | * Encode a claim to a block. 96 | * @param {import('@ucanto/interface').IPLDViewBuilder} invocation 97 | */ 98 | const encode = async invocation => { 99 | const view = await invocation.buildIPLDView() 100 | const bytes = await view.archive() 101 | if (bytes.error) throw new Error('failed to archive') 102 | return { cid: Link.create(CAR_CODE, await sha256.digest(bytes.ok)), bytes: bytes.ok } 103 | } 104 | 105 | /** @returns {Promise} */ 106 | export const mockClaimsService = async () => { 107 | let callCount = 0 108 | /** @type {Claims} */ 109 | const claims = new LinkMap() 110 | /** @param {Claims} s */ 111 | const addClaims = s => { 112 | for (const [k, v] of s) { 113 | const blocks = claims.get(k) ?? [] 114 | blocks.push(...v) 115 | claims.set(k, blocks) 116 | } 117 | } 118 | const resetClaims = () => claims.clear() 119 | const getCallCount = () => callCount 120 | const resetCallCount = () => { callCount = 0 } 121 | 122 | const server = http.createServer(async (req, res) => { 123 | callCount++ 124 | const content = Link.create(raw.code, Digest.decode(base58btc.decode(String(req.url?.split('/')[3])))) 125 | const blocks = [...claims.get(content) ?? []] 126 | const readable = new ReadableStream({ 127 | pull (controller) { 128 | const block = blocks.shift() 129 | if (!block) return controller.close() 130 | controller.enqueue(block) 131 | } 132 | }) 133 | await readable 134 | .pipeThrough(new CARWriterStream()) 135 | .pipeTo(Writable.toWeb(res)) 136 | }) 137 | await new Promise(resolve => server.listen(resolve)) 138 | const close = () => { 139 | server.closeAllConnections() 140 | server.close() 141 | } 142 | // @ts-expect-error 143 | const { port } = server.address() 144 | const url = new URL(`http://127.0.0.1:${port}`) 145 | return { addClaims, resetClaims, close, url, signer: await ed25519.generate(), getCallCount, resetCallCount } 146 | } 147 | -------------------------------------------------------------------------------- /src/middleware/withRateLimit.js: -------------------------------------------------------------------------------- 1 | import { HttpError } from '@web3-storage/gateway-lib/util' 2 | import { RATE_LIMIT_EXCEEDED } from '../constants.js' 3 | 4 | /** 5 | * @import { Middleware } from '@web3-storage/gateway-lib' 6 | * @import { 7 | * Environment, 8 | * Context, 9 | * RateLimitService, 10 | * RateLimitExceeded 11 | * } from './withRateLimit.types.js' 12 | * @typedef {Context} RateLimiterContext 13 | */ 14 | 15 | /** 16 | * The rate limiting handler must be applied after the withParsedIpfsUrl handler, 17 | * which parses the CID from the URL. It uses the CID to check the rate limit, and 18 | * it can be enabled or disabled using the FF_RATE_LIMITER_ENABLED flag. 19 | * Every successful request is recorded in the accounting service. 20 | * 21 | * @type {Middleware} 22 | */ 23 | export function withRateLimit (handler) { 24 | return async (req, env, ctx) => { 25 | if (env.FF_RATE_LIMITER_ENABLED !== 'true') { 26 | return handler(req, env, ctx) 27 | } 28 | 29 | const { dataCid } = ctx 30 | const rateLimitService = create(env, ctx) 31 | const isRateLimitExceeded = await rateLimitService.check(dataCid, req) 32 | if (isRateLimitExceeded === RATE_LIMIT_EXCEEDED.YES) { 33 | throw new HttpError('Too Many Requests', { status: 429 }) 34 | } 35 | return handler(req, env, ctx) 36 | } 37 | } 38 | 39 | /** 40 | * @param {Environment} env 41 | * @param {RateLimiterContext} ctx 42 | * @returns {RateLimitService} 43 | */ 44 | function create (env, ctx) { 45 | return { 46 | /** 47 | * @param {import('multiformats/cid').CID} cid 48 | * @param {Request} request 49 | * @returns {Promise} 50 | */ 51 | check: async (cid, request) => { 52 | const authToken = ctx.authToken 53 | if (!authToken) { 54 | // no token, use normal rate limit 55 | return isRateLimited(env.RATE_LIMITER, cid) 56 | } 57 | 58 | const tokenMetadata = await getTokenMetadata(env, authToken, ctx) 59 | if (!tokenMetadata) { 60 | // we didn't get any metadata - for now just use the top level rate limit 61 | // this means token based requests will be subject to normal rate limits until the data propagates 62 | return isRateLimited(env.RATE_LIMITER, cid) 63 | } 64 | 65 | if (tokenMetadata.invalid) { 66 | // this means we know about the token and we know it's invalid, 67 | // so we should just use the CID rate limit 68 | return isRateLimited(env.RATE_LIMITER, cid) 69 | } 70 | 71 | // TODO at some point we should enforce user configurable rate limits 72 | // and origin matching but for now we just serve all valid token requests 73 | return RATE_LIMIT_EXCEEDED.NO 74 | } 75 | } 76 | } 77 | 78 | /** 79 | * @param {RateLimit} rateLimitAPI 80 | * @param {import('multiformats/cid').CID} cid 81 | * @returns {Promise} 82 | * @throws {Error} if no rate limit API is found 83 | */ 84 | async function isRateLimited (rateLimitAPI, cid) { 85 | if (!rateLimitAPI) { 86 | throw new Error('no rate limit API found') 87 | } 88 | const rateLimitResponse = await rateLimitAPI.limit({ key: cid.toString() }) 89 | if (rateLimitResponse.success) { 90 | return RATE_LIMIT_EXCEEDED.NO 91 | } else { 92 | console.log(`rate limit exceeded: ${cid}`) 93 | return RATE_LIMIT_EXCEEDED.YES 94 | } 95 | } 96 | 97 | /** 98 | * @param {Environment} env 99 | * @param {string} authToken 100 | * @param {RateLimiterContext} ctx 101 | * @returns {Promise} 102 | */ 103 | async function getTokenMetadata (env, authToken, ctx) { 104 | const cachedValue = await env.AUTH_TOKEN_METADATA.get(authToken) 105 | // TODO: we should implement an SWR pattern here - record an expiry in the metadata and if the expiry has passed, re-validate the cache after 106 | // returning the value 107 | if (cachedValue) { 108 | return decode(cachedValue) 109 | } 110 | 111 | const tokenMetadata = await locateTokenMetadata(authToken) 112 | if (tokenMetadata) { 113 | // NOTE: non-blocking call to the auth token metadata cache 114 | ctx.waitUntil( 115 | env.AUTH_TOKEN_METADATA.put(authToken, encode(tokenMetadata)) 116 | ) 117 | return tokenMetadata 118 | } 119 | 120 | return null 121 | } 122 | 123 | /** 124 | * @param {string} s 125 | * @returns {import('./withRateLimit.types.js').TokenMetadata} 126 | */ 127 | function decode (s) { 128 | // TODO should this be dag-json? 129 | return JSON.parse(s) 130 | } 131 | 132 | /** 133 | * @param {import('./withRateLimit.types.js').TokenMetadata} m 134 | * @returns {string} 135 | */ 136 | function encode (m) { 137 | // TODO should this be dag-json? 138 | return JSON.stringify(m) 139 | } 140 | 141 | /** 142 | * TODO: implement this function 143 | * 144 | * @param {string} authToken 145 | * @returns {Promise} 146 | */ 147 | async function locateTokenMetadata (authToken) { 148 | // TODO I think this needs to check the content claims service (?) for any claims relevant to this token 149 | // TODO do we have a plan for this? need to ask Hannah if the indexing service covers this? 150 | return undefined 151 | } 152 | -------------------------------------------------------------------------------- /src/middleware/withContentClaimsDagula.js: -------------------------------------------------------------------------------- 1 | import { Dagula } from 'dagula' 2 | import { base58btc } from 'multiformats/bases/base58' 3 | import * as BatchingFetcher from '@web3-storage/blob-fetcher/fetcher/batching' 4 | import * as dagPb from '@ipld/dag-pb' 5 | 6 | /** 7 | * @import { 8 | * IpfsUrlContext, 9 | * BlockContext, 10 | * DagContext, 11 | * UnixfsContext, 12 | * Middleware, 13 | * } from '@web3-storage/gateway-lib' 14 | * @import { LocatorContext } from './withLocator.types.js' 15 | * @import { CarParkFetchContext } from './withCarParkFetch.types.js' 16 | * @import { Environment } from './withContentClaimsDagula.types.js' 17 | */ 18 | 19 | /** 20 | * Creates a dagula instance backed by content claims. 21 | * Get operations for DAG Protobuf content are cached if the DAGPB_CONTENT_CACHE is enabled. 22 | * 23 | * @type {( 24 | * Middleware< 25 | * BlockContext & DagContext & UnixfsContext & IpfsUrlContext & LocatorContext & CarParkFetchContext, 26 | * IpfsUrlContext & LocatorContext & CarParkFetchContext, 27 | * Environment 28 | * > 29 | * )} 30 | */ 31 | export function withContentClaimsDagula (handler) { 32 | return async (request, env, ctx) => { 33 | const { locator } = ctx 34 | const fetcher = BatchingFetcher.create(locator, ctx.fetch) 35 | const dagula = new Dagula({ 36 | async get (cid) { 37 | const dagPbContent = await getDagPbContent(env, fetcher, cid, ctx) 38 | if (dagPbContent) { 39 | return dagPbContent 40 | } 41 | const res = await fetcher.fetch(cid.multihash) 42 | return res.ok ? { cid, bytes: await res.ok.bytes() } : undefined 43 | }, 44 | async stream (cid, options) { 45 | const res = await fetcher.fetch(cid.multihash, options) 46 | return res.ok ? res.ok.stream() : undefined 47 | }, 48 | async stat (cid) { 49 | const res = await locator.locate(cid.multihash) 50 | return res.ok ? { size: res.ok.site[0].range.length } : undefined 51 | } 52 | }) 53 | return handler(request, env, { ...ctx, blocks: dagula, dag: dagula, unixfs: dagula }) 54 | } 55 | } 56 | 57 | /** 58 | * Returns the cached DAG Protobuf bytes if they exist, otherwise fetches the DAG Protobuf bytes 59 | * from the fetcher and caches them in the KV store. 60 | * 61 | * @param {Environment} env 62 | * @param {import('@web3-storage/blob-fetcher').Fetcher} fetcher 63 | * @param {import('multiformats').UnknownLink} cid 64 | * @param {import('@web3-storage/gateway-lib').Context} ctx 65 | * @returns {Promise<{ cid: import('multiformats').UnknownLink, bytes: Uint8Array } | undefined>} 66 | */ 67 | async function getDagPbContent (env, fetcher, cid, ctx) { 68 | if (env.FF_DAGPB_CONTENT_CACHE_ENABLED === 'true' && cid.code === dagPb.code) { 69 | const cachedBytes = await getCachedDagPbBytes(env, cid) 70 | if (cachedBytes) { 71 | return { cid, bytes: cachedBytes } 72 | } 73 | 74 | const res = await fetcher.fetch(cid.multihash) 75 | if (res.ok) { 76 | const bytes = await res.ok.bytes() 77 | const dagPbNode = dagPb.decode(bytes) 78 | if (dagPbNode.Links && dagPbNode.Links.length === 0) { 79 | // Old DAG PB nodes have no links ("raw" blocks as leaves), so we don't want to cache them 80 | return { cid, bytes } 81 | } 82 | ctx.waitUntil(cacheDagPbBytes(env, cid, bytes)) 83 | return { cid, bytes } 84 | } 85 | } 86 | return undefined 87 | } 88 | 89 | /** 90 | * Caches the DAG Protobuf content into the KV store if the content size is less than or equal to the max size. 91 | * The content is cached for the duration of the TTL (seconds), if the TTL is not set, the content is cached indefinitely. 92 | * 93 | * @param {Environment} env 94 | * @param {import('multiformats').UnknownLink} cid 95 | * @param {Uint8Array} bytes 96 | * @returns {Promise} 97 | */ 98 | async function cacheDagPbBytes (env, cid, bytes) { 99 | const maxSize = env.FF_DAGPB_CONTENT_CACHE_MAX_SIZE_MB ? parseInt(env.FF_DAGPB_CONTENT_CACHE_MAX_SIZE_MB) * 1024 * 1024 : undefined 100 | if (maxSize && bytes.length <= maxSize) { 101 | try { 102 | const ttlSeconds = env.FF_DAGPB_CONTENT_CACHE_TTL_SECONDS ? parseInt(env.FF_DAGPB_CONTENT_CACHE_TTL_SECONDS) : 0 103 | const key = getDagPbKey(cid) 104 | await env.DAGPB_CONTENT_CACHE.put(key, bytes, { 105 | expirationTtl: ttlSeconds > 60 ? ttlSeconds : undefined 106 | }) 107 | } catch (/** @type {any} */ error) { 108 | console.error(error) 109 | } 110 | } 111 | } 112 | 113 | /** 114 | * Returns the cached DAG Protobuf bytes if they exist, otherwise returns null. 115 | * 116 | * @param {Environment} env 117 | * @param {import('multiformats').UnknownLink} cid 118 | * @returns {Promise} 119 | */ 120 | async function getCachedDagPbBytes (env, cid) { 121 | const key = getDagPbKey(cid) 122 | const dagPbBytes = await env.DAGPB_CONTENT_CACHE.get(key, 'arrayBuffer') 123 | if (dagPbBytes) { 124 | return new Uint8Array(dagPbBytes) 125 | } 126 | return null 127 | } 128 | 129 | /** 130 | * Returns the base58btc encoded key for the DAG Protobuf content in the KV store. 131 | * 132 | * @param {import('multiformats').UnknownLink} cid 133 | * @returns {string} 134 | */ 135 | function getDagPbKey (cid) { 136 | return base58btc.encode(cid.multihash.bytes) 137 | } 138 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | /* eslint-env worker */ 2 | import { 3 | withContext, 4 | withCorsHeaders, 5 | withContentDispositionHeader, 6 | withErrorHandler, 7 | createWithHttpMethod as withHttpMethods, 8 | withCdnCache, 9 | withParsedIpfsUrl, 10 | withFixedLengthStream, 11 | composeMiddleware 12 | } from '@web3-storage/gateway-lib/middleware' 13 | import { handleUnixfs } from '@web3-storage/gateway-lib/handlers' 14 | import { 15 | withContentClaimsDagula, 16 | withVersionHeader, 17 | withAuthToken, 18 | withCarBlockHandler, 19 | withGatewayIdentity, 20 | withRateLimit, 21 | withEgressTracker, 22 | withEgressClient, 23 | withAuthorizedSpace, 24 | withLocator, 25 | withUcanInvocationHandler, 26 | withDelegationsStorage, 27 | withDelegationStubs, 28 | withOptionsRequest, 29 | withCarParkFetch, 30 | withDidDocumentHandler, 31 | withFormatRawHandler, 32 | withFormatCarHandler 33 | } from './middleware/index.js' 34 | import { instrument } from '@microlabs/otel-cf-workers' 35 | import { NoopSpanProcessor, TraceIdRatioBasedSampler } from '@opentelemetry/sdk-trace-base' 36 | 37 | /** 38 | * @import { Handler, Context } from '@web3-storage/gateway-lib' 39 | * @import { Environment } from './bindings.js' 40 | */ 41 | 42 | /** 43 | * The middleware stack 44 | */ 45 | const middleware = composeMiddleware( 46 | // Prepare the Context for all types of requests 47 | withCdnCache, 48 | withContext, 49 | withOptionsRequest, 50 | withCorsHeaders, 51 | withVersionHeader, 52 | withErrorHandler, 53 | withGatewayIdentity, 54 | withDidDocumentHandler, 55 | withDelegationsStorage, 56 | 57 | // Handle UCAN invocations (POST requests only) 58 | withUcanInvocationHandler, 59 | 60 | // Handle Content Serve requests (GET and HEAD requests) 61 | withHttpMethods('GET', 'HEAD'), 62 | 63 | // Prepare the Context for other types of requests 64 | withParsedIpfsUrl, 65 | withAuthToken, 66 | withLocator, 67 | withCarParkFetch, 68 | 69 | // TODO: replace this with a handler to fetch the real delegations 70 | withDelegationStubs, 71 | 72 | // Rate-limit requests 73 | withRateLimit, 74 | 75 | // Fetch CAR data - Double-check why this can't be placed after the authorized space middleware 76 | withCarBlockHandler, 77 | 78 | // Authorize requests 79 | withAuthorizedSpace, 80 | 81 | // Track Egress 82 | withEgressClient, 83 | withEgressTracker, 84 | 85 | // Fetch data 86 | withContentClaimsDagula, 87 | withFormatRawHandler, 88 | withFormatCarHandler, 89 | 90 | // Prepare the Response 91 | withContentDispositionHeader, 92 | withFixedLengthStream 93 | ) 94 | 95 | /** 96 | * Configure the OpenTelemetry exporter based on the environment 97 | * 98 | * @param {Environment} env 99 | * @param {*} _trigger 100 | * @returns {import('@microlabs/otel-cf-workers').TraceConfig} 101 | */ 102 | function config (env, _trigger) { 103 | if (env.HONEYCOMB_API_KEY) { 104 | return { 105 | exporter: { 106 | url: 'https://api.honeycomb.io/v1/traces', 107 | headers: { 'x-honeycomb-team': env.HONEYCOMB_API_KEY } 108 | }, 109 | service: { name: 'freeway' }, 110 | ...(env.TELEMETRY_RATIO 111 | ? { 112 | sampling: { 113 | headSampler: new TraceIdRatioBasedSampler(parseFloat(env.TELEMETRY_RATIO)) 114 | } 115 | } 116 | : {}) 117 | } 118 | } 119 | return { 120 | // @ts-expect-error - NoopSpanProcessor extends SpanProcessor, but ts doesn't recognize it 121 | spanProcessors: new NoopSpanProcessor(), 122 | service: { name: 'freeway' } 123 | } 124 | } 125 | 126 | /** 127 | * The promise to the pre-configured handler 128 | * 129 | * @type {Promise> | null} 130 | */ 131 | let handlerPromise = null 132 | 133 | /** 134 | * Pre-configure the handler based on the environment. 135 | * 136 | * @param {Environment} env 137 | * @returns {Promise>} 138 | */ 139 | async function initializeHandler (env) { 140 | const baseHandler = middleware(handleUnixfs) 141 | if (env.FF_TELEMETRY_ENABLED === 'true') { 142 | globalThis.fetch = globalThis.fetch.bind(globalThis) 143 | } 144 | const finalHandler = env.FF_TELEMETRY_ENABLED === 'true' 145 | ? /** @type {Handler} */(instrument({ fetch: baseHandler }, config).fetch) 146 | : baseHandler 147 | return async (request, env, ctx) => { 148 | const response = await finalHandler(request, env, ctx) 149 | const cacheControl = response.headers.get('Cache-Control') ?? '' 150 | const newHeaders = new Headers(response.headers) 151 | newHeaders.set('Cache-Control', cacheControl ? `${cacheControl}, no-transform` : 'no-transform') 152 | return new Response(response.body, { 153 | status: response.status, 154 | statusText: response.statusText, 155 | headers: newHeaders 156 | }) 157 | } 158 | } 159 | 160 | const handler = { 161 | /** @type {Handler} */ 162 | async fetch (request, env, ctx) { 163 | console.log(request.method, request.url) 164 | // Initialize the handler only once and reuse the promise 165 | if (!handlerPromise) { 166 | handlerPromise = initializeHandler(env) 167 | } 168 | const handler = await handlerPromise 169 | return handler(request, env, ctx) 170 | } 171 | } 172 | 173 | export default handler 174 | -------------------------------------------------------------------------------- /src/middleware/withEgressTracker.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @import { Middleware } from '@web3-storage/gateway-lib' 3 | * @import { Environment } from './withEgressTracker.types.js' 4 | * @typedef {import('./withEgressTracker.types.js').Context} EgressTrackerContext 5 | */ 6 | 7 | import { Space } from '@storacha/capabilities' 8 | import { SpaceDID } from '@storacha/capabilities/utils' 9 | import { DID } from '@ucanto/core' 10 | import * as dagJSON from '@ipld/dag-json' 11 | 12 | /** 13 | * The egress tracking handler must be enabled after the rate limiting, authorized space, 14 | * and egress client handlers, and before any handler that serves the response body. 15 | * It uses the Space & Data CID of the served content to record the egress in the egress client, 16 | * and it counts the bytes served with a TransformStream to determine the egress amount. 17 | * 18 | * @type {Middleware} 19 | */ 20 | export function withEgressTracker (handler) { 21 | return async (req, env, ctx) => { 22 | if (env.FF_EGRESS_TRACKER_ENABLED !== 'true') { 23 | return handler(req, env, ctx) 24 | } 25 | 26 | // Check rollout percentage for gradual deployment 27 | const rolloutPercentage = parseInt(env.FF_EGRESS_TRACKER_ROLLOUT_PERCENTAGE || '100') 28 | const shouldTrack = Math.random() * 100 < rolloutPercentage 29 | if (!shouldTrack) { 30 | return handler(req, env, ctx) 31 | } 32 | 33 | // If the space is not defined, it is a legacy request and we can't track egress 34 | const space = ctx.space 35 | if (!space) { 36 | console.log('Egress tracking skipped: no space context available (legacy request)') 37 | return handler(req, env, ctx) 38 | } 39 | console.log('Egress tracking enabled for space:', space) 40 | 41 | // Check if Cloudflare Queue is available for egress tracking 42 | if (!env.EGRESS_QUEUE) { 43 | console.error('EGRESS_QUEUE is not defined') 44 | return handler(req, env, ctx) 45 | } 46 | 47 | if (!env.UPLOAD_SERVICE_DID) { 48 | console.error('UPLOAD_SERVICE_DID is not defined') 49 | return handler(req, env, ctx) 50 | } 51 | 52 | const response = await handler(req, env, ctx) 53 | if (!response.ok || !response.body) { 54 | return response 55 | } 56 | 57 | const responseBody = response.body.pipeThrough( 58 | createByteCountStream(async (totalBytesServed) => { 59 | if (totalBytesServed > 0) { 60 | try { 61 | // Create UCAN invocation for egress record 62 | const invocation = Space.egressRecord.invoke({ 63 | issuer: ctx.gatewayIdentity, 64 | audience: DID.parse(env.UPLOAD_SERVICE_DID), 65 | with: SpaceDID.from(space), 66 | nb: { 67 | resource: ctx.dataCid, 68 | bytes: totalBytesServed, 69 | servedAt: new Date().getTime() 70 | }, 71 | expiration: Infinity, 72 | nonce: Date.now().toString(), 73 | proofs: ctx.delegationProofs 74 | }) 75 | 76 | // Serialize and send to Cloudflare Queue 77 | const delegation = await invocation.delegate() 78 | const archiveResult = await delegation.archive() 79 | if (archiveResult.error) { 80 | console.error('Failed to serialize egress invocation:', archiveResult.error) 81 | return 82 | } 83 | const serializedInvocation = archiveResult.ok 84 | 85 | // Non-blocking call to queue the invocation 86 | ctx.waitUntil( 87 | env.EGRESS_QUEUE.send(dagJSON.encode({ 88 | messageId: delegation.cid, 89 | invocation: serializedInvocation, 90 | timestamp: Date.now() 91 | })) 92 | ) 93 | } catch (error) { 94 | console.error('Failed to create or queue egress invocation:', error) 95 | } 96 | } 97 | }) 98 | ) 99 | 100 | return new Response(responseBody, { 101 | status: response.status, 102 | statusText: response.statusText, 103 | headers: response.headers 104 | }) 105 | } 106 | } 107 | 108 | /** 109 | * Creates a TransformStream to count bytes in the response body. 110 | * 111 | * @param {(totalBytes: number) => void} onClose 112 | * @template {Uint8Array} T 113 | * @returns {TransformStream} - The created TransformStream. 114 | */ 115 | function createByteCountStream (onClose) { 116 | let totalBytes = 0 117 | 118 | return new TransformStream({ 119 | /** 120 | * The transform function is called for each chunk of the response body. 121 | * It enqueues the chunk and updates the total bytes served. 122 | * If an error occurs, it signals an error to the controller and logs it. 123 | * The bytes are not counted in case of enqueuing an error. 124 | */ 125 | async transform (chunk, controller) { 126 | try { 127 | controller.enqueue(chunk) 128 | totalBytes += chunk.byteLength 129 | } catch (error) { 130 | console.error('Error while counting bytes:', error) 131 | controller.error(error) 132 | } 133 | }, 134 | 135 | /** 136 | * The flush function is called when the stream is being finalized, 137 | * which is when the response is being sent to the client. 138 | * So before the response is sent, we record the egress using the callback. 139 | * If an error occurs, the egress is not recorded. 140 | * NOTE: The flush function is NOT called in case of a stream error. 141 | */ 142 | async flush () { 143 | onClose(totalBytes) 144 | } 145 | }) 146 | } 147 | -------------------------------------------------------------------------------- /src/middleware/withCarBlockHandler.js: -------------------------------------------------------------------------------- 1 | /* eslint-env browser */ 2 | /* global FixedLengthStream */ 3 | 4 | import { CAR_CODE } from '../constants.js' 5 | import { HttpError } from '@web3-storage/gateway-lib/util' 6 | // @ts-expect-error no types 7 | import httpRangeParse from 'http-range-parse' 8 | import { base58btc } from 'multiformats/bases/base58' 9 | 10 | /** 11 | * @import { 12 | * IpfsUrlContext, 13 | * Middleware, 14 | * Context, 15 | * IpfsUrlContext as CarBlockHandlerContext, 16 | * Handler 17 | * } from '@web3-storage/gateway-lib' 18 | * @import { Environment } from './withCarBlockHandler.types.js' 19 | */ 20 | 21 | /** @typedef {{ offset: number, length?: number } | { offset?: number, length: number } | { suffix: number }} Range */ 22 | 23 | /** 24 | * Middleware that will serve CAR files if a CAR codec is found in the path 25 | * CID. If the CID is not a CAR CID it delegates to the next middleware. 26 | * 27 | * @type {Middleware} 28 | */ 29 | 30 | export function withCarBlockHandler (handler) { 31 | return async (request, env, ctx) => { 32 | const { dataCid, searchParams } = ctx 33 | if (!dataCid) throw new Error('missing data CID') 34 | 35 | // if not CAR codec, or if trusted gateway format has been requested... 36 | const formatParam = searchParams.get('format') 37 | const acceptHeader = request.headers.get('Accept') 38 | if ( 39 | dataCid.code !== CAR_CODE || 40 | formatParam === 'car' || 41 | acceptHeader === 'application/vnd.ipld.car' || 42 | formatParam === 'raw' || 43 | acceptHeader === 'application/vnd.ipld.raw' 44 | ) { 45 | return handler(request, env, ctx) // pass to other handlers 46 | } 47 | 48 | try { 49 | return await handleCarBlock(request, env, ctx) 50 | } catch (err) { 51 | if (err instanceof HttpError && err.status === 404) { 52 | return handler(request, env, ctx) // use content claims to resolve 53 | } 54 | throw err 55 | } 56 | } 57 | } 58 | 59 | /** 60 | * Handler that serves CAR files directly from R2. 61 | * 62 | * @type {Handler} 63 | */ 64 | export async function handleCarBlock (request, env, ctx) { 65 | const { searchParams, dataCid } = ctx 66 | if (!dataCid) throw new Error('missing data CID') 67 | if (!searchParams) throw new Error('missing URL search params') 68 | 69 | if (request.method !== 'HEAD' && request.method !== 'GET') { 70 | throw new HttpError('method not allowed', { status: 405 }) 71 | } 72 | if (dataCid.code !== CAR_CODE) { 73 | throw new HttpError('not a CAR CID', { status: 400 }) 74 | } 75 | 76 | const etag = `"${dataCid}"` 77 | if (request.headers.get('If-None-Match') === etag) { 78 | return new Response(null, { status: 304 }) 79 | } 80 | 81 | if (request.method === 'HEAD') { 82 | let obj = await env.CARPARK.head(toBlobKey(dataCid.multihash)) 83 | if (!obj) { 84 | obj = await env.CARPARK.head(toCARKey(dataCid)) 85 | } 86 | if (!obj) throw new HttpError('CAR not found', { status: 404 }) 87 | return new Response(undefined, { 88 | headers: { 89 | 'Accept-Ranges': 'bytes', 90 | 'Content-Length': obj.size.toString(), 91 | Etag: etag 92 | } 93 | }) 94 | } 95 | 96 | /** @type {Range|undefined} */ 97 | let range 98 | if (request.headers.has('range')) { 99 | try { 100 | range = parseRange(request.headers.get('range') ?? '') 101 | } catch (err) { 102 | throw new HttpError('invalid range', { status: 400, cause: err }) 103 | } 104 | } 105 | 106 | let obj = await env.CARPARK.get(toBlobKey(dataCid.multihash), { range }) 107 | if (!obj) { 108 | obj = await env.CARPARK.get(toCARKey(dataCid), { range }) 109 | } 110 | if (!obj) throw new HttpError('CAR not found', { status: 404 }) 111 | 112 | const status = range ? 206 : 200 113 | const headers = new Headers({ 114 | 'Content-Type': 'application/vnd.ipld.car; version=1;', 115 | 'X-Content-Type-Options': 'nosniff', 116 | 'Cache-Control': 'public, max-age=29030400, immutable', 117 | 'Content-Disposition': `attachment; filename="${dataCid}.car"`, 118 | Etag: etag 119 | }) 120 | 121 | let contentLength = obj.size 122 | if (range) { 123 | let first, last 124 | if ('suffix' in range) { 125 | first = obj.size - range.suffix 126 | last = obj.size - 1 127 | } else { 128 | first = range.offset || 0 129 | last = range.length != null ? first + range.length - 1 : obj.size - 1 130 | } 131 | headers.set('Content-Range', `bytes ${first}-${last}/${obj.size}`) 132 | contentLength = last - first + 1 133 | } 134 | headers.set('Content-Length', contentLength.toString()) 135 | 136 | return new Response( 137 | obj.body.pipeThrough(new FixedLengthStream(contentLength)), 138 | { status, headers } 139 | ) 140 | } 141 | 142 | /** @param {import('multiformats').UnknownLink} cid */ 143 | const toCARKey = (cid) => `${cid}/${cid}.car` 144 | 145 | /** @param {import('multiformats').MultihashDigest} digest */ 146 | const toBlobKey = (digest) => { 147 | const mhStr = base58btc.encode(digest.bytes) 148 | return `${mhStr}/${mhStr}.blob` 149 | } 150 | 151 | /** 152 | * Convert a HTTP Range header to a range object. 153 | * @param {string} value 154 | * @returns {Range} 155 | */ 156 | function parseRange (value) { 157 | const result = httpRangeParse(value) 158 | if (result.ranges) throw new Error('Multipart ranges not supported') 159 | const { unit, first, last, suffix } = result 160 | if (unit !== 'bytes') throw new Error(`Unsupported range unit: ${unit}`) 161 | return suffix != null 162 | ? { suffix } 163 | : { offset: first, length: last != null ? last - first + 1 : undefined } 164 | } 165 | -------------------------------------------------------------------------------- /test/unit/middleware/withDelegationsStorage.spec.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-unused-expressions 2 | --- 3 | `no-unused-expressions` doesn't understand that several of Chai's assertions 4 | are implemented as getters rather than explicit function calls; it thinks 5 | the assertions are unused expressions. */ 6 | import { describe, it, afterEach } from 'mocha' 7 | import { expect } from 'chai' 8 | import sinon from 'sinon' 9 | import { ed25519 } from '@ucanto/principal' 10 | import * as raw from 'multiformats/codecs/raw' 11 | import { withDelegationsStorage } from '../../../src/middleware/withDelegationsStorage.js' 12 | import { contentServe } from '@storacha/capabilities/space' 13 | import { sha256 } from 'multiformats/hashes/sha2' 14 | import { Link } from '@storacha/capabilities/store' 15 | import { randomBytes } from 'node:crypto' 16 | 17 | const kvStoreMock = { 18 | get: sinon.stub(), 19 | list: sinon.stub(), 20 | put: sinon.stub(), 21 | getWithMetadata: sinon.stub(), 22 | delete: sinon.stub() 23 | } 24 | 25 | /** 26 | * @typedef {import('../../../src/middleware/withDelegationsStorage.types.js').DelegationsStorageEnvironment} DelegationsStorageEnvironment 27 | * @typedef {import('../../../src/middleware/withDelegationsStorage.types.js').DelegationsStorageContext} DelegationsStorageContext 28 | */ 29 | 30 | const gatewaySigner = (await ed25519.Signer.generate()).signer 31 | const gatewayIdentity = gatewaySigner.withDID('did:web:test.w3s.link') 32 | 33 | const randomCid = async () => { 34 | const input = new Uint8Array(randomBytes(138)) 35 | const cid = Link.create(raw.code, await sha256.digest(input)) 36 | return cid 37 | } 38 | 39 | const ctx = 40 | /** @satisfies {DelegationsStorageContext} */ 41 | ({ 42 | gatewaySigner, 43 | gatewayIdentity, 44 | waitUntil: async (promise) => { 45 | try { 46 | await promise 47 | } catch (error) { 48 | // Ignore errors. 49 | } 50 | }, 51 | delegationsStorage: { 52 | find: sinon.stub(), 53 | store: sinon.stub() 54 | } 55 | }) 56 | 57 | describe('withDelegationsStorage', async () => { 58 | afterEach(() => { 59 | kvStoreMock.get.resetHistory() 60 | }) 61 | 62 | describe('-> Successful Requests', () => { 63 | it('should set delegationsStorage in context when FF_DELEGATIONS_STORAGE_ENABLED is true', async () => { 64 | const mockHandler = sinon.fake((request, env, ctx) => ctx) 65 | const request = new Request('http://example.com/') 66 | const env = { 67 | FF_DELEGATIONS_STORAGE_ENABLED: 'true', 68 | CONTENT_SERVE_DELEGATIONS_STORE: kvStoreMock 69 | } 70 | 71 | await withDelegationsStorage(mockHandler)(request, env, { 72 | ...ctx, 73 | // @ts-expect-error - we are testing the case where delegationsStorage is set 74 | delegationsStorage: undefined 75 | }) 76 | expect(mockHandler.calledOnce).to.be.true 77 | expect(mockHandler.firstCall.args[2]).to.have.property( 78 | 'delegationsStorage' 79 | ) 80 | expect(mockHandler.firstCall.args[2].delegationsStorage).to.be.an( 81 | 'object' 82 | ) 83 | }) 84 | 85 | it('should call the find method of the delegationsStorage and return the delegation', async () => { 86 | // @ts-expect-error - Dummy handler that uses delegationsStorage 87 | const mockHandler = async (request, env, ctx) => { 88 | const result = await ctx.delegationsStorage.find(space) 89 | return new Response(JSON.stringify(result)) 90 | } 91 | 92 | const request = new Request('http://example.com/') 93 | 94 | /** @type {import('@storacha/capabilities/types').SpaceDID} */ 95 | const space = 'did:key:z6MkeTvzPkRVhu4HcGu95ZCP23pMdtk3p144umfsPE68tZ4a' 96 | const alice = await ed25519.Signer.generate() 97 | 98 | // Create a sample delegation to be returned by the List and Find functions 99 | const delegation = await contentServe.delegate({ 100 | issuer: alice, 101 | audience: gatewayIdentity, 102 | with: space, 103 | expiration: 1000 104 | }) 105 | const { ok: bytes } = await delegation.archive() 106 | const delegations = [ 107 | { 108 | id: `${space}:${await randomCid()}`, 109 | expires: 1000, 110 | content: bytes 111 | }, 112 | { 113 | id: `${space}:${await randomCid()}`, 114 | expires: 1000, 115 | content: bytes 116 | }, 117 | { 118 | id: `${space}:${await randomCid()}`, 119 | expires: 1000, 120 | content: bytes 121 | } 122 | ] 123 | 124 | // Simulate external request to the KV store 125 | kvStoreMock.list.callsFake(async () => { 126 | // Simulate network delay 127 | await new Promise((resolve) => 128 | setTimeout(resolve, Math.random() * 100) 129 | ) 130 | return { 131 | keys: delegations.map((d) => ({ name: d.id })) 132 | } 133 | }) 134 | kvStoreMock.get.onCall(0).resolves(delegations[0].content) 135 | kvStoreMock.get.onCall(1).resolves(delegations[1].content) 136 | kvStoreMock.get.onCall(2).resolves(delegations[2].content) 137 | 138 | const env = { 139 | FF_DELEGATIONS_STORAGE_ENABLED: 'true', 140 | CONTENT_SERVE_DELEGATIONS_STORE: kvStoreMock // simulate results 141 | } 142 | 143 | const response = await withDelegationsStorage(mockHandler)( 144 | request, 145 | env, 146 | ctx 147 | ) 148 | const result = await response.json() 149 | const delegationsFound = result.ok 150 | // Assert results 151 | expect(delegationsFound).to.be.an('array') 152 | expect(delegationsFound.length).to.equal(3) 153 | 154 | // Assert KV calls 155 | expect(kvStoreMock.list.firstCall.calledWith({ prefix: space })).to.be 156 | .true 157 | expect(kvStoreMock.get.firstCall.calledWith(delegations[0].id)).to.be 158 | .true 159 | expect(kvStoreMock.get.secondCall.calledWith(delegations[1].id)).to.be 160 | .true 161 | expect(kvStoreMock.get.thirdCall.calledWith(delegations[2].id)).to.be 162 | .true 163 | }) 164 | }) 165 | 166 | it('should not set delegationsStorage in context when FF_DELEGATIONS_STORAGE_ENABLED is not true', async () => { 167 | const mockHandler = sinon.fake((request, env, ctx) => ctx) 168 | const request = new Request('http://example.com/') 169 | const env = { 170 | FF_DELEGATIONS_STORAGE_ENABLED: 'false', 171 | CONTENT_SERVE_DELEGATIONS_STORE: kvStoreMock 172 | } 173 | 174 | await withDelegationsStorage(mockHandler)(request, env, { 175 | ...ctx, 176 | // @ts-expect-error - we are testing the case where delegationsStorage is not set 177 | delegationsStorage: undefined 178 | }) 179 | 180 | expect(mockHandler.calledOnce).to.be.true 181 | expect(mockHandler.firstCall.args[2]).to.have.property( 182 | 'delegationsStorage' 183 | ) 184 | expect(mockHandler.firstCall.args[2].delegationsStorage).to.be.undefined 185 | }) 186 | }) 187 | -------------------------------------------------------------------------------- /test/unit/middleware/withRateLimit.spec.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-unused-expressions 2 | --- 3 | `no-unused-expressions` doesn't understand that several of Chai's assertions 4 | are implemented as getters rather than explicit function calls; it thinks 5 | the assertions are unused expressions. */ 6 | import { describe, it, afterEach } from 'mocha' 7 | import { expect } from 'chai' 8 | import sinon from 'sinon' 9 | import { withRateLimit } from '../../../src/middleware/withRateLimit.js' 10 | import { HttpError } from '@web3-storage/gateway-lib/util' 11 | import { CID } from 'multiformats' 12 | import { identity } from 'multiformats/hashes/identity' 13 | import * as raw from 'multiformats/codecs/raw' 14 | import { strictStub } from './util/strictStub.js' 15 | import { expectToBeInstanceOf } from './util/expectToBeInstanceOf.js' 16 | import { rejection } from './util/rejection.js' 17 | 18 | /** 19 | * @import { SinonStub } from 'sinon' 20 | * @import { 21 | * Environment, 22 | * Context 23 | * } from '../../../src/middleware/withRateLimit.types.js' 24 | * @import { 25 | * Handler, 26 | * Context as MiddlewareContext, 27 | * Environment as MiddlewareEnvironment, 28 | * } from '@web3-storage/gateway-lib' 29 | */ 30 | 31 | const sandbox = sinon.createSandbox() 32 | 33 | /** @typedef {Handler} RequestHandler */ 34 | /** @type {SinonStub, ReturnType>} */ 35 | const innerHandler = strictStub(sandbox, 'nextHandler') 36 | 37 | const request = new Request('http://example.com/') 38 | 39 | const env = 40 | /** @satisfies {Environment} */ 41 | ({ 42 | DEBUG: 'false', 43 | RATE_LIMITER: { 44 | limit: strictStub(sandbox, 'limit') 45 | }, 46 | FF_RATE_LIMITER_ENABLED: 'true', 47 | AUTH_TOKEN_METADATA: { 48 | get: strictStub(sandbox, 'get'), 49 | getWithMetadata: strictStub(sandbox, 'getWithMetadata'), 50 | put: strictStub(sandbox, 'put'), 51 | list: strictStub(sandbox, 'list'), 52 | delete: strictStub(sandbox, 'delete') 53 | } 54 | }) 55 | 56 | /** 57 | * Creates a request with an optional authorization token. 58 | * 59 | * @param {Object} [options] 60 | * @param {string|null} [options.authToken] The value for the `authToken` key 61 | * @returns {Promise} 62 | */ 63 | const createContext = async ({ authToken } = {}) => ({ 64 | // Doesn't matter what the CID is, as long as it's consistent. 65 | dataCid: CID.createV1(raw.code, identity.digest(Uint8Array.of(1))), 66 | waitUntil: strictStub(sandbox, 'waitUntil').returns(undefined), 67 | path: '', 68 | searchParams: new URLSearchParams(), 69 | authToken: authToken ?? null 70 | }) 71 | 72 | describe('withRateLimits', async () => { 73 | afterEach(() => { 74 | sandbox.reset() 75 | }) 76 | 77 | it('should call next if no auth token and rate limit is not exceeded', async () => { 78 | const ctx = await createContext() 79 | 80 | env.RATE_LIMITER.limit 81 | .withArgs({ key: ctx.dataCid.toString() }) 82 | .resolves({ success: true }) 83 | 84 | const innerResponse = new Response() 85 | innerHandler 86 | .withArgs(sinon.match.same(request), env, ctx) 87 | .resolves(innerResponse) 88 | 89 | const wrappedHandler = withRateLimit(innerHandler) 90 | const response = await wrappedHandler(request, env, ctx) 91 | 92 | expect(innerHandler.calledOnce).to.be.true 93 | expect(response).to.equal(innerResponse) 94 | }) 95 | 96 | it('should throw an error if no auth token and rate limit is exceeded', async () => { 97 | const ctx = await createContext() 98 | 99 | env.RATE_LIMITER.limit 100 | .withArgs({ key: ctx.dataCid.toString() }) 101 | .resolves({ success: false }) 102 | 103 | const wrappedHandler = withRateLimit(innerHandler) 104 | const error = await rejection(wrappedHandler(request, env, ctx)) 105 | 106 | expect(innerHandler.notCalled).to.be.true 107 | expectToBeInstanceOf(error, HttpError) 108 | expect(error.status).to.equal(429) 109 | expect(error.message).to.equal('Too Many Requests') 110 | }) 111 | 112 | it('should call next if auth token is present but no token metadata and rate limit is not exceeded', async () => { 113 | const ctx = await createContext({ 114 | authToken: 'test-token' 115 | }) 116 | 117 | const innerResponse = new Response() 118 | innerHandler 119 | .withArgs(sinon.match.same(request), env, ctx) 120 | .resolves(innerResponse) 121 | 122 | env.RATE_LIMITER.limit 123 | .withArgs({ key: ctx.dataCid.toString() }) 124 | .resolves({ success: true }) 125 | env.AUTH_TOKEN_METADATA.get.withArgs('test-token').resolves(null) 126 | 127 | const wrappedHandler = withRateLimit(innerHandler) 128 | const response = await wrappedHandler(request, env, ctx) 129 | 130 | expect(response).to.equal(innerResponse) 131 | }) 132 | 133 | it('should throw an error if auth token is present but no token metadata and rate limit is exceeded', async () => { 134 | const ctx = await createContext({ 135 | authToken: 'test-token' 136 | }) 137 | 138 | env.RATE_LIMITER.limit 139 | .withArgs({ key: ctx.dataCid.toString() }) 140 | .resolves({ success: false }) 141 | env.AUTH_TOKEN_METADATA.get.withArgs('test-token').resolves(null) 142 | 143 | const wrappedHandler = withRateLimit(innerHandler) 144 | 145 | const error = await rejection(wrappedHandler(request, env, ctx)) 146 | 147 | expect(innerHandler.notCalled).to.be.true 148 | expectToBeInstanceOf(error, HttpError) 149 | expect(error.status).to.equal(429) 150 | expect(error.message).to.equal('Too Many Requests') 151 | }) 152 | 153 | it('should call next if auth token is present and token metadata is invalid but rate limit is not exceeded', async () => { 154 | const ctx = await createContext({ 155 | authToken: 'test-token' 156 | }) 157 | 158 | const innerResponse = new Response() 159 | innerHandler 160 | .withArgs(sinon.match.same(request), env, ctx) 161 | .resolves(innerResponse) 162 | 163 | env.RATE_LIMITER.limit 164 | .withArgs({ key: ctx.dataCid.toString() }) 165 | .resolves({ success: true }) 166 | env.AUTH_TOKEN_METADATA.get 167 | .withArgs('test-token') 168 | .resolves(JSON.stringify({ invalid: true })) 169 | 170 | const wrappedHandler = withRateLimit(innerHandler) 171 | 172 | const response = await wrappedHandler(request, env, ctx) 173 | 174 | expect(response).to.equal(innerResponse) 175 | }) 176 | 177 | it('should throw an error if auth token is present and token metadata is invalid and rate limit is exceeded', async () => { 178 | const ctx = await createContext({ 179 | authToken: 'test-token' 180 | }) 181 | 182 | env.RATE_LIMITER.limit 183 | .withArgs({ key: ctx.dataCid.toString() }) 184 | .resolves({ success: false }) 185 | env.AUTH_TOKEN_METADATA.get 186 | .withArgs('test-token') 187 | .resolves(JSON.stringify({ invalid: true })) 188 | 189 | const wrappedHandler = withRateLimit(innerHandler) 190 | 191 | const error = await rejection(wrappedHandler(request, env, ctx)) 192 | 193 | expect(innerHandler.notCalled).to.be.true 194 | expectToBeInstanceOf(error, HttpError) 195 | expect(error.status).to.equal(429) 196 | expect(error.message).to.equal('Too Many Requests') 197 | }) 198 | 199 | it('should call next if auth token is present and token metadata is valid', async () => { 200 | const ctx = await createContext({ 201 | authToken: 'test-token' 202 | }) 203 | 204 | const innerResponse = new Response() 205 | innerHandler 206 | .withArgs(sinon.match.same(request), env, ctx) 207 | .resolves(innerResponse) 208 | 209 | env.AUTH_TOKEN_METADATA.get 210 | .withArgs('test-token') 211 | .resolves(JSON.stringify({ invalid: false })) 212 | 213 | const wrappedHandler = withRateLimit(innerHandler) 214 | 215 | const response = await wrappedHandler(request, env, ctx) 216 | 217 | expect(response).to.equal(innerResponse) 218 | }) 219 | }) 220 | -------------------------------------------------------------------------------- /src/middleware/withAuthorizedSpace.js: -------------------------------------------------------------------------------- 1 | import { Verifier } from '@ucanto/principal' 2 | import { ok, access, fail, Unauthorized } from '@ucanto/validator' 3 | import { resolveDIDKey, getValidatorProofs } from '../server/index.js' 4 | import { HttpError } from '@web3-storage/gateway-lib/util' 5 | import * as serve from '../capabilities/serve.js' 6 | import { SpaceDID } from '@storacha/capabilities/utils' 7 | 8 | /** 9 | * Extracts a SpaceDID string from various space object formats. 10 | * Handles string DIDs, objects with .did() method, and Uint8Arrays. 11 | * 12 | * @param {any} space - The space object to extract DID from 13 | * @returns {import('@storacha/capabilities/types').SpaceDID | undefined} 14 | */ 15 | function extractSpaceDID (space) { 16 | if (!space) return undefined 17 | 18 | try { 19 | // Already a string DID 20 | if (typeof space === 'string' && space.startsWith('did:')) { 21 | return /** @type {import('@storacha/capabilities/types').SpaceDID} */ (space) 22 | } 23 | 24 | // Object with .did() method (most common case from indexing service) 25 | if (typeof space === 'object' && typeof /** @type {any} */ (space).did === 'function') { 26 | return /** @type {import('@storacha/capabilities/types').SpaceDID} */ (/** @type {any} */ (space).did()) 27 | } 28 | 29 | // Uint8Array (fallback case) 30 | if (ArrayBuffer.isView(space)) { 31 | const spaceDID = SpaceDID.from(space) 32 | return /** @type {import('@storacha/capabilities/types').SpaceDID} */ (spaceDID.toString()) 33 | } 34 | 35 | // Last resort: try String() conversion 36 | const spaceStr = String(space) 37 | if (spaceStr.startsWith('did:')) { 38 | return /** @type {import('@storacha/capabilities/types').SpaceDID} */ (spaceStr) 39 | } 40 | 41 | return undefined 42 | } catch (error) { 43 | // Log error in debug mode only 44 | if (process.env.DEBUG) { 45 | console.warn('Failed to extract space DID:', error, 'Raw space:', space) 46 | } 47 | return undefined 48 | } 49 | } 50 | 51 | /** 52 | * @import * as Ucanto from '@ucanto/interface' 53 | * @import { IpfsUrlContext, Middleware } from '@web3-storage/gateway-lib' 54 | * @import { LocatorContext } from './withLocator.types.js' 55 | * @import { AuthTokenContext, Environment } from './withAuthToken.types.js' 56 | * @import { SpaceContext } from './withAuthorizedSpace.types.js' 57 | * @import { DelegationsStorageContext, DelegationsStorageEnvironment } from './withDelegationsStorage.types.js' 58 | * @import { GatewayIdentityContext } from './withGatewayIdentity.types.js' 59 | * @import { DelegationProofsContext } from './withAuthorizedSpace.types.js' 60 | */ 61 | 62 | /** 63 | * Attempts to locate the {@link IpfsUrlContext.dataCid}. If it's able to, 64 | * attempts to authorize the request to access the data. 65 | * 66 | * @throws {HttpError} (404) If the locator tells us the data is not found, or 67 | * if no located space is one the request is authorized to access. 68 | * @throws {Error} If the locator fails in any other way. 69 | * @type {( 70 | * Middleware< 71 | * LocatorContext & IpfsUrlContext & AuthTokenContext & GatewayIdentityContext & DelegationProofsContext & DelegationsStorageContext & SpaceContext, 72 | * LocatorContext & IpfsUrlContext & AuthTokenContext & GatewayIdentityContext & DelegationProofsContext & DelegationsStorageContext 73 | * > 74 | * )} 75 | */ 76 | export function withAuthorizedSpace (handler) { 77 | return async (request, env, ctx) => { 78 | const { locator, dataCid } = ctx 79 | const locRes = await locator.locate(dataCid.multihash) 80 | if (locRes.error) { 81 | if (locRes.error.name === 'NotFound') { 82 | throw new HttpError('Not Found', { status: 404, cause: locRes.error }) 83 | } 84 | throw new Error(`failed to locate: ${dataCid}`, { cause: locRes.error }) 85 | } 86 | 87 | // Legacy behavior: Site results which have no Space attached are from 88 | // before we started authorizing serving content explicitly. For these, we 89 | // always serve the content, but only if the request has no authorization 90 | // token AND there are no sites with space information available. 91 | const sitesWithSpace = locRes.ok.site.filter((site) => site.space !== undefined) 92 | const sitesWithoutSpace = locRes.ok.site.filter((site) => site.space === undefined) 93 | const shouldServeLegacy = 94 | sitesWithSpace.length === 0 && 95 | sitesWithoutSpace.length > 0 && 96 | ctx.authToken === null 97 | 98 | if (shouldServeLegacy) { 99 | console.log('[withAuthorizedSpace] Using legacy path (no space)') 100 | return handler(request, env, ctx) 101 | } 102 | 103 | // These Spaces all have the content we're to serve, if we're allowed to. 104 | // Extract space DIDs from sites with space information 105 | const spaces = sitesWithSpace 106 | .map((site) => extractSpaceDID(site.space)) 107 | .filter((space) => space !== undefined) 108 | 109 | // If content is found in multiple DIFFERENT spaces, skip egress tracking 110 | // by not setting ctx.space (security/billing concern - ambiguous ownership) 111 | const uniqueSpaces = [...new Set(spaces.map(s => s.toString()))] 112 | const skipEgressTracking = uniqueSpaces.length > 1 113 | if (skipEgressTracking && env.DEBUG === 'true') { 114 | console.log(`Content found in ${uniqueSpaces.length} different spaces - egress tracking will be skipped`) 115 | console.log(`Spaces: ${uniqueSpaces.join(', ')}`) 116 | } 117 | 118 | try { 119 | // First space to successfully authorize is the one we'll use. 120 | const { space: selectedSpace, delegationProofs } = await Promise.any( 121 | spaces.map(async (space) => { 122 | // @ts-ignore 123 | const result = await authorize(SpaceDID.from(space), ctx, env) 124 | if (result.error) { 125 | throw result.error 126 | } 127 | return result.ok 128 | }) 129 | ) 130 | return handler(request, env, { 131 | ...ctx, 132 | // Only set space if we're not skipping egress tracking 133 | space: skipEgressTracking ? undefined : SpaceDID.from(selectedSpace.toString()), 134 | delegationProofs, 135 | locator: locator.scopeToSpaces([selectedSpace]) 136 | }) 137 | } catch (error) { 138 | // If all Spaces failed to authorize, return 404 (security through obscurity) 139 | if (error instanceof AggregateError) { 140 | // Check if all errors are authorization failures (not storage errors) 141 | const isAuthFailure = error.errors.every((e) => 142 | e instanceof Unauthorized || 143 | (e.message && e.message.includes('not authorized to serve')) 144 | ) 145 | 146 | if (isAuthFailure) { 147 | if (env.DEBUG === 'true') { 148 | console.log( 149 | [ 150 | 'Authorization Failures:', 151 | ...error.errors.map((e) => e.message) 152 | ].join('\n\n') 153 | ) 154 | } 155 | // Don't reveal whether content exists in unauthorized spaces 156 | throw new HttpError('Not Found', { status: 404, cause: error }) 157 | } 158 | // For storage or other errors, throw the AggregateError as-is 159 | throw error 160 | } else { 161 | throw error 162 | } 163 | } 164 | } 165 | } 166 | 167 | /** 168 | * Authorizes the request to serve content from the given Space. Looks for 169 | * authorizing delegations in the 170 | * {@link DelegationsStorageContext.delegationsStorage}. 171 | * 172 | * @param {import('@storacha/capabilities/types').SpaceDID} space 173 | * @param {AuthTokenContext & DelegationsStorageContext & GatewayIdentityContext} ctx 174 | * @param {import('./withRateLimit.types.js').Environment} env 175 | * @returns {Promise>} 176 | */ 177 | const authorize = async (space, ctx, env) => { 178 | // Look up delegations that might authorize us to serve the content. 179 | const relevantDelegationsResult = await ctx.delegationsStorage.find(space) 180 | if (relevantDelegationsResult.error) { 181 | return relevantDelegationsResult 182 | } 183 | 184 | const delegationProofs = relevantDelegationsResult.ok 185 | 186 | // If no delegations found, the server is not authorized to serve this content 187 | if (!delegationProofs || delegationProofs.length === 0) { 188 | return fail('The gateway is not authorized to serve this content.') 189 | } 190 | 191 | // Create an invocation of the serve capability. 192 | const invocation = await serve.transportHttp 193 | .invoke({ 194 | issuer: ctx.gatewayIdentity, 195 | audience: ctx.gatewayIdentity, 196 | with: space, 197 | nb: { 198 | token: ctx.authToken 199 | }, 200 | proofs: delegationProofs 201 | }) 202 | .delegate() 203 | 204 | // Load validator proofs and validate the invocation 205 | const validatorProofs = await getValidatorProofs(env) 206 | const accessResult = await access(invocation, { 207 | capability: serve.transportHttp, 208 | authority: ctx.gatewayIdentity, 209 | principal: Verifier, 210 | proofs: validatorProofs, 211 | resolveDIDKey, 212 | validateAuthorization: () => ok({}) 213 | }) 214 | 215 | if (accessResult.error) { 216 | return accessResult 217 | } 218 | 219 | return { 220 | ok: { 221 | space, 222 | delegationProofs 223 | } 224 | } 225 | } 226 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | The contents of this repository are Copyright (c) corresponding authors and 2 | contributors, licensed under the `Permissive License Stack` meaning either of: 3 | 4 | - Apache-2.0 Software License: https://www.apache.org/licenses/LICENSE-2.0 5 | ([...4tr2kfsq](https://w3s.link/ipfs/bafkreiankqxazcae4onkp436wag2lj3ccso4nawxqkkfckd6cg4tr2kfsq)) 6 | 7 | - MIT Software License: https://opensource.org/licenses/MIT 8 | ([...vljevcba](https://w3s.link/ipfs/bafkreiepofszg4gfe2gzuhojmksgemsub2h4uy2gewdnr35kswvljevcba)) 9 | 10 | You may not use the contents of this repository except in compliance 11 | with one of the listed Licenses. For an extended clarification of the 12 | intent behind the choice of Licensing please refer to 13 | https://protocol.ai/blog/announcing-the-permissive-license-stack/ 14 | 15 | Unless required by applicable law or agreed to in writing, software 16 | distributed under the terms listed in this notice is distributed on 17 | an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, 18 | either express or implied. See each License for the specific language 19 | governing permissions and limitations under that License. 20 | 21 | 22 | 23 | `SPDX-License-Identifier: Apache-2.0 OR MIT` 24 | 25 | Verbatim copies of both licenses are included below: 26 | 27 |
Apache-2.0 Software License 28 | 29 | ``` 30 | Apache License 31 | Version 2.0, January 2004 32 | http://www.apache.org/licenses/ 33 | 34 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 35 | 36 | 1. Definitions. 37 | 38 | "License" shall mean the terms and conditions for use, reproduction, 39 | and distribution as defined by Sections 1 through 9 of this document. 40 | 41 | "Licensor" shall mean the copyright owner or entity authorized by 42 | the copyright owner that is granting the License. 43 | 44 | "Legal Entity" shall mean the union of the acting entity and all 45 | other entities that control, are controlled by, or are under common 46 | control with that entity. For the purposes of this definition, 47 | "control" means (i) the power, direct or indirect, to cause the 48 | direction or management of such entity, whether by contract or 49 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 50 | outstanding shares, or (iii) beneficial ownership of such entity. 51 | 52 | "You" (or "Your") shall mean an individual or Legal Entity 53 | exercising permissions granted by this License. 54 | 55 | "Source" form shall mean the preferred form for making modifications, 56 | including but not limited to software source code, documentation 57 | source, and configuration files. 58 | 59 | "Object" form shall mean any form resulting from mechanical 60 | transformation or translation of a Source form, including but 61 | not limited to compiled object code, generated documentation, 62 | and conversions to other media types. 63 | 64 | "Work" shall mean the work of authorship, whether in Source or 65 | Object form, made available under the License, as indicated by a 66 | copyright notice that is included in or attached to the work 67 | (an example is provided in the Appendix below). 68 | 69 | "Derivative Works" shall mean any work, whether in Source or Object 70 | form, that is based on (or derived from) the Work and for which the 71 | editorial revisions, annotations, elaborations, or other modifications 72 | represent, as a whole, an original work of authorship. For the purposes 73 | of this License, Derivative Works shall not include works that remain 74 | separable from, or merely link (or bind by name) to the interfaces of, 75 | the Work and Derivative Works thereof. 76 | 77 | "Contribution" shall mean any work of authorship, including 78 | the original version of the Work and any modifications or additions 79 | to that Work or Derivative Works thereof, that is intentionally 80 | submitted to Licensor for inclusion in the Work by the copyright owner 81 | or by an individual or Legal Entity authorized to submit on behalf of 82 | the copyright owner. For the purposes of this definition, "submitted" 83 | means any form of electronic, verbal, or written communication sent 84 | to the Licensor or its representatives, including but not limited to 85 | communication on electronic mailing lists, source code control systems, 86 | and issue tracking systems that are managed by, or on behalf of, the 87 | Licensor for the purpose of discussing and improving the Work, but 88 | excluding communication that is conspicuously marked or otherwise 89 | designated in writing by the copyright owner as "Not a Contribution." 90 | 91 | "Contributor" shall mean Licensor and any individual or Legal Entity 92 | on behalf of whom a Contribution has been received by Licensor and 93 | subsequently incorporated within the Work. 94 | 95 | 2. Grant of Copyright License. Subject to the terms and conditions of 96 | this License, each Contributor hereby grants to You a perpetual, 97 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 98 | copyright license to reproduce, prepare Derivative Works of, 99 | publicly display, publicly perform, sublicense, and distribute the 100 | Work and such Derivative Works in Source or Object form. 101 | 102 | 3. Grant of Patent License. Subject to the terms and conditions of 103 | this License, each Contributor hereby grants to You a perpetual, 104 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 105 | (except as stated in this section) patent license to make, have made, 106 | use, offer to sell, sell, import, and otherwise transfer the Work, 107 | where such license applies only to those patent claims licensable 108 | by such Contributor that are necessarily infringed by their 109 | Contribution(s) alone or by combination of their Contribution(s) 110 | with the Work to which such Contribution(s) was submitted. If You 111 | institute patent litigation against any entity (including a 112 | cross-claim or counterclaim in a lawsuit) alleging that the Work 113 | or a Contribution incorporated within the Work constitutes direct 114 | or contributory patent infringement, then any patent licenses 115 | granted to You under this License for that Work shall terminate 116 | as of the date such litigation is filed. 117 | 118 | 4. Redistribution. You may reproduce and distribute copies of the 119 | Work or Derivative Works thereof in any medium, with or without 120 | modifications, and in Source or Object form, provided that You 121 | meet the following conditions: 122 | 123 | (a) You must give any other recipients of the Work or 124 | Derivative Works a copy of this License; and 125 | 126 | (b) You must cause any modified files to carry prominent notices 127 | stating that You changed the files; and 128 | 129 | (c) You must retain, in the Source form of any Derivative Works 130 | that You distribute, all copyright, patent, trademark, and 131 | attribution notices from the Source form of the Work, 132 | excluding those notices that do not pertain to any part of 133 | the Derivative Works; and 134 | 135 | (d) If the Work includes a "NOTICE" text file as part of its 136 | distribution, then any Derivative Works that You distribute must 137 | include a readable copy of the attribution notices contained 138 | within such NOTICE file, excluding those notices that do not 139 | pertain to any part of the Derivative Works, in at least one 140 | of the following places: within a NOTICE text file distributed 141 | as part of the Derivative Works; within the Source form or 142 | documentation, if provided along with the Derivative Works; or, 143 | within a display generated by the Derivative Works, if and 144 | wherever such third-party notices normally appear. The contents 145 | of the NOTICE file are for informational purposes only and 146 | do not modify the License. You may add Your own attribution 147 | notices within Derivative Works that You distribute, alongside 148 | or as an addendum to the NOTICE text from the Work, provided 149 | that such additional attribution notices cannot be construed 150 | as modifying the License. 151 | 152 | You may add Your own copyright statement to Your modifications and 153 | may provide additional or different license terms and conditions 154 | for use, reproduction, or distribution of Your modifications, or 155 | for any such Derivative Works as a whole, provided Your use, 156 | reproduction, and distribution of the Work otherwise complies with 157 | the conditions stated in this License. 158 | 159 | 5. Submission of Contributions. Unless You explicitly state otherwise, 160 | any Contribution intentionally submitted for inclusion in the Work 161 | by You to the Licensor shall be under the terms and conditions of 162 | this License, without any additional terms or conditions. 163 | Notwithstanding the above, nothing herein shall supersede or modify 164 | the terms of any separate license agreement you may have executed 165 | with Licensor regarding such Contributions. 166 | 167 | 6. Trademarks. This License does not grant permission to use the trade 168 | names, trademarks, service marks, or product names of the Licensor, 169 | except as required for reasonable and customary use in describing the 170 | origin of the Work and reproducing the content of the NOTICE file. 171 | 172 | 7. Disclaimer of Warranty. Unless required by applicable law or 173 | agreed to in writing, Licensor provides the Work (and each 174 | Contributor provides its Contributions) on an "AS IS" BASIS, 175 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 176 | implied, including, without limitation, any warranties or conditions 177 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 178 | PARTICULAR PURPOSE. You are solely responsible for determining the 179 | appropriateness of using or redistributing the Work and assume any 180 | risks associated with Your exercise of permissions under this License. 181 | 182 | 8. Limitation of Liability. In no event and under no legal theory, 183 | whether in tort (including negligence), contract, or otherwise, 184 | unless required by applicable law (such as deliberate and grossly 185 | negligent acts) or agreed to in writing, shall any Contributor be 186 | liable to You for damages, including any direct, indirect, special, 187 | incidental, or consequential damages of any character arising as a 188 | result of this License or out of the use or inability to use the 189 | Work (including but not limited to damages for loss of goodwill, 190 | work stoppage, computer failure or malfunction, or any and all 191 | other commercial damages or losses), even if such Contributor 192 | has been advised of the possibility of such damages. 193 | 194 | 9. Accepting Warranty or Additional Liability. While redistributing 195 | the Work or Derivative Works thereof, You may choose to offer, 196 | and charge a fee for, acceptance of support, warranty, indemnity, 197 | or other liability obligations and/or rights consistent with this 198 | License. However, in accepting such obligations, You may act only 199 | on Your own behalf and on Your sole responsibility, not on behalf 200 | of any other Contributor, and only if You agree to indemnify, 201 | defend, and hold each Contributor harmless for any liability 202 | incurred by, or claims asserted against, such Contributor by reason 203 | of your accepting any such warranty or additional liability. 204 | 205 | END OF TERMS AND CONDITIONS 206 | ``` 207 | 208 |
209 | 210 |
MIT Software License 211 | 212 | ``` 213 | Permission is hereby granted, free of charge, to any person obtaining a copy 214 | of this software and associated documentation files (the "Software"), to deal 215 | in the Software without restriction, including without limitation the rights 216 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 217 | copies of the Software, and to permit persons to whom the Software is 218 | furnished to do so, subject to the following conditions: 219 | 220 | The above copyright notice and this permission notice shall be included in 221 | all copies or substantial portions of the Software. 222 | 223 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 224 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 225 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 226 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 227 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 228 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 229 | THE SOFTWARE. 230 | ``` 231 | 232 |
233 | -------------------------------------------------------------------------------- /wrangler.toml: -------------------------------------------------------------------------------- 1 | name = "freeway" 2 | main = "./dist/worker.mjs" 3 | compatibility_flags = [ "nodejs_compat" ] 4 | compatibility_date = "2025-09-01" 5 | r2_buckets = [ 6 | { binding = "CARPARK", bucket_name = "carpark-dev-0" } 7 | ] 8 | 9 | [vars] 10 | CONTENT_CLAIMS_SERVICE_URL = "https://dev.claims.web3.storage" 11 | GATEWAY_SERVICE_DID = "did:web:staging.w3s.link" 12 | UPLOAD_SERVICE_DID = "did:web:staging.web3.storage" 13 | UPLOAD_API_URL = "https://staging.up.web3.storage" 14 | 15 | [build] 16 | command = "npm run build:debug" 17 | 18 | [[unsafe.bindings]] 19 | # TODO BEFORE MERGE - update this to work in all environments - useful to do it like this for now 20 | name = "RATE_LIMITER" 21 | type = "ratelimit" 22 | # An identifier you define, that is unique to your Cloudflare account. 23 | # Must be an integer. 24 | namespace_id = "0" 25 | 26 | # Limit: the number of tokens allowed within a given period in a single 27 | # Cloudflare location 28 | # Period: the duration of the period, in seconds. Must be either 10 or 60 29 | simple = { limit = 100, period = 60 } 30 | 31 | [[kv_namespaces]] 32 | # TODO BEFORE MERGE - update this to work in all environments - useful to do it like this for now 33 | binding = "AUTH_TOKEN_METADATA" 34 | id = "f848730e45d94f17bcaf3b6d0915da40" 35 | 36 | # PROD! 37 | [env.production] 38 | account_id = "fffa4b4363a7e5250af8357087263b3a" 39 | route = { pattern = "https://freeway.dag.haus/*", zone_id = "f2f8a5b1c557202c6e3d0ce0e98e4c8e" } 40 | r2_buckets = [ 41 | { binding = "CARPARK", bucket_name = "carpark-prod-0" } 42 | ] 43 | kv_namespaces = [ 44 | { binding = "AUTH_TOKEN_METADATA", id = "f848730e45d94f17bcaf3b6d0915da40" }, 45 | { binding = "CONTENT_SERVE_DELEGATIONS_STORE", id = "b2984f16c21e4991a644683c00d80033" }, 46 | { binding = "DAGPB_CONTENT_CACHE", id = "e1339664614940a0804a3020ce3dae12" } 47 | ] 48 | 49 | [[env.production.queues.producers]] 50 | queue = "egress-tracking-queue-production" 51 | binding = "EGRESS_QUEUE" 52 | 53 | [env.production.build] 54 | command = "npm run build" 55 | 56 | [env.production.vars] 57 | MAX_SHARDS = "825" 58 | FF_RATE_LIMITER_ENABLED = "false" 59 | FF_EGRESS_TRACKER_ENABLED = "true" 60 | FF_EGRESS_TRACKER_ROLLOUT_PERCENTAGE = "100" # 100% - 10M events/day, ~1200 req/s avg (queue limit 5K req/sec) 61 | FF_TELEMETRY_ENABLED = "false" # FIXME: telemetry is causing directory listings to fail. 62 | FF_DELEGATIONS_STORAGE_ENABLED = "true" 63 | FF_RAMP_UP_PROBABILITY = "100" 64 | # Cache for 30 days by default 65 | FF_DAGPB_CONTENT_CACHE_TTL_SECONDS = 2_592_000 66 | FF_DAGPB_CONTENT_CACHE_MAX_SIZE_MB = 2 67 | FF_DAGPB_CONTENT_CACHE_ENABLED = "true" 68 | TELEMETRY_RATIO = 0.0005 69 | GATEWAY_SERVICE_DID = "did:web:w3s.link" 70 | UPLOAD_SERVICE_DID = "did:web:web3.storage" 71 | CONTENT_CLAIMS_SERVICE_URL = "https://claims.web3.storage" 72 | CARPARK_PUBLIC_BUCKET_URL = "https://carpark-prod-0.r2.w3s.link" 73 | UPLOAD_API_URL = "https://up.web3.storage" 74 | INDEXING_SERVICE_URL = "https://indexer.storacha.network/" 75 | GATEWAY_VALIDATOR_PROOF = "mAYIEAPQCOqJlcm9vdHOB2CpYJQABcRIgYOVdOrpLxbw0mELO22krjEVICp5cju4nVChUCL8+i/1ndmVyc2lvbgHdAQFxEiCUjD+aZlOcJxbn3UlM1dqeSYs09dyPrMvVKPmC+TSaoadhc1hE7aEDQPhDJ+yO05lTqYJndtPY9d2WXGtyj90eoL0MBeKylYKMQDnSMBkMAv8XMX+OEZd7QrhvzAktsXMEKY+wPQAbpQRhdmUwLjkuMWNhdHSBomNjYW5rdWNhbi9hdHRlc3Rkd2l0aHBkaWQ6d2ViOnczcy5saW5rY2F1ZFgZnRp3ZWI6dXAuc3RvcmFjaGEubmV0d29ya2NleHD2Y2lzc06dGndlYjp3M3MubGlua2NwcmaAWQFxEiBg5V06ukvFvDSYQs7baSuMRUgKnlyO7idUKFQIvz6L/aFqdWNhbkAwLjkuMdgqWCUAAXESIJSMP5pmU5wnFufdSUzV2p5JizT13I+sy9Uo+YL5NJqh" 76 | 77 | [env.production.observability] 78 | enabled = true 79 | head_sampling_rate = 0.01 80 | 81 | # Staging! 82 | [env.staging] 83 | account_id = "fffa4b4363a7e5250af8357087263b3a" 84 | route = { pattern = "https://freeway-staging.dag.haus/*", zone_id = "f2f8a5b1c557202c6e3d0ce0e98e4c8e" } 85 | r2_buckets = [ 86 | { binding = "CARPARK", bucket_name = "carpark-staging-0" } 87 | ] 88 | kv_namespaces = [ 89 | { binding = "AUTH_TOKEN_METADATA", id = "b618bb05deb8493f944ef4a0f538030c" }, 90 | { binding = "CONTENT_SERVE_DELEGATIONS_STORE", id = "99ae45f8b5b3478a9df09302c27e81a3" }, 91 | { binding = "DAGPB_CONTENT_CACHE", id = "c70a74363e7a4f06ad39fa3022aab7c7" } 92 | ] 93 | 94 | [[env.staging.queues.producers]] 95 | queue = "egress-tracking-queue-staging" 96 | binding = "EGRESS_QUEUE" 97 | 98 | [env.staging.build] 99 | command = "npm run build" 100 | 101 | [env.staging.vars] 102 | MAX_SHARDS = "825" 103 | FF_RATE_LIMITER_ENABLED = "false" 104 | FF_EGRESS_TRACKER_ENABLED = "true" 105 | FF_EGRESS_TRACKER_ROLLOUT_PERCENTAGE = "100" 106 | FF_TELEMETRY_ENABLED = "true" 107 | FF_DELEGATIONS_STORAGE_ENABLED = "true" 108 | FF_RAMP_UP_PROBABILITY = "100" 109 | FF_DAGPB_CONTENT_CACHE_TTL_SECONDS = 300 110 | FF_DAGPB_CONTENT_CACHE_MAX_SIZE_MB = 2 111 | FF_DAGPB_CONTENT_CACHE_ENABLED = "true" 112 | TELEMETRY_RATIO = 1.0 113 | GATEWAY_SERVICE_DID = "did:web:staging.w3s.link" 114 | UPLOAD_SERVICE_DID = "did:web:staging.up.storacha.network" 115 | CONTENT_CLAIMS_SERVICE_URL = "https://staging.claims.web3.storage" 116 | UPLOAD_API_URL = "https://staging.up.web3.storage" 117 | CARPARK_PUBLIC_BUCKET_URL = "https://carpark-staging-0.r2.w3s.link" 118 | INDEXING_SERVICE_URL = "https://staging.indexer.storacha.network/" 119 | GATEWAY_VALIDATOR_PROOF = "mAYIEAI0DOqJlcm9vdHOB2CpYJQABcRIg5a/pcyEeJn5snQZHm+wVBzeFbY8RTlfwETn+nMMiCqlndmVyc2lvbgH2AQFxEiD8LRCZwMMfTVlGtNX85n0aVxjp54oE0/G/WTG/dsOOA6dhc1hE7aEDQCEy4qAv4dybXYh/iipwdOLf4DlYjZGFopGvvfDZbVq6Q4MIY5PY9XXHP9D6ELs6qjcvSLfP/nbew/IWbG55RAJhdmUwLjkuMWNhdHSBomNjYW5rdWNhbi9hdHRlc3Rkd2l0aHgYZGlkOndlYjpzdGFnaW5nLnczcy5saW5rY2F1ZFghnRp3ZWI6c3RhZ2luZy51cC5zdG9yYWNoYS5uZXR3b3JrY2V4cPZjaXNzVp0ad2ViOnN0YWdpbmcudzNzLmxpbmtjcHJmgFkBcRIg5a/pcyEeJn5snQZHm+wVBzeFbY8RTlfwETn+nMMiCqmhanVjYW5AMC45LjHYKlglAAFxEiD8LRCZwMMfTVlGtNX85n0aVxjp54oE0/G/WTG/dsOOAw" 120 | 121 | # Test! 122 | [env.test] 123 | workers_dev = true 124 | r2_buckets = [ 125 | { binding = "CARPARK", bucket_name = "carpark-test-0" } 126 | ] 127 | 128 | [env.test.vars] 129 | DEBUG = "true" 130 | FF_RATE_LIMITER_ENABLED = "false" 131 | FF_EGRESS_TRACKER_ENABLED = "false" 132 | FF_DAGPB_CONTENT_CACHE_ENABLED = "true" 133 | FF_DAGPB_CONTENT_CACHE_MAX_SIZE_MB = 2 134 | FF_DAGPB_CONTENT_CACHE_TTL_SECONDS = 300 135 | FF_TELEMETRY_ENABLED = "true" 136 | TELEMETRY_RATIO = 1.0 137 | FF_RAMP_UP_PROBABILITY = "100" 138 | MAX_SHARDS = "120" 139 | GATEWAY_SERVICE_DID = "did:web:staging.w3s.link" 140 | UPLOAD_SERVICE_DID = "did:web:staging.web3.storage" 141 | CONTENT_CLAIMS_SERVICE_URL = "https://test.claims.web3.storage" 142 | UPLOAD_API_URL = "https://staging.up.web3.storage" 143 | 144 | [env.alanshaw] 145 | workers_dev = true 146 | account_id = "4fe12d085474d33bdcfd8e9bed4d8f95" 147 | r2_buckets = [ 148 | { binding = "CARPARK", bucket_name = "carpark-alanshaw-0", preview_bucket_name = "carpark-alanshaw-preview-0" } 149 | ] 150 | 151 | [env.alanshaw.vars] 152 | DEBUG = "true" 153 | FF_RATE_LIMITER_ENABLED = "false" 154 | FF_EGRESS_TRACKER_ENABLED = "false" 155 | FF_TELEMETRY_ENABLED = "true" 156 | TELEMETRY_RATIO = 1.0 157 | GATEWAY_SERVICE_DID = "did:web:staging.w3s.link" 158 | UPLOAD_SERVICE_DID = "did:web:staging.web3.storage" 159 | CONTENT_CLAIMS_SERVICE_URL = "https://dev.claims.web3.storage" 160 | UPLOAD_API_URL = "https://staging.up.web3.storage" 161 | 162 | [env.fforbeck] 163 | name = "freeway-fforbeck" 164 | workers_dev = true 165 | upload_source_maps = true 166 | account_id = "fffa4b4363a7e5250af8357087263b3a" 167 | # r2_buckets = [ 168 | # { binding = "CARPARK", bucket_name = "carpark-fforbeck-0", preview_bucket_name = "carpark-fforbeck-preview-0" } 169 | # ] 170 | #r2_buckets = [ 171 | # { binding = "CARPARK", bucket_name = "carpark-prod-0", preview_bucket_name = "carpark-prod-0" } 172 | #] 173 | #kv_namespaces = [ 174 | # { binding = "AUTH_TOKEN_METADATA", id = "b618bb05deb8493f944ef4a0f538030c" }, 175 | # { binding = "CONTENT_SERVE_DELEGATIONS_STORE", id = "26cc47fec09749bb9ee42bc6407f9a9d" }, 176 | # { binding = "DAGPB_CONTENT_CACHE", id = "3f0c253b90fc48c1b384f1563ede54f9" } 177 | #] 178 | # Copied from STAGING env 179 | r2_buckets = [ 180 | { binding = "CARPARK", bucket_name = "carpark-staging-0", preview_bucket_name = "carpark-staging-0" } 181 | ] 182 | kv_namespaces = [ 183 | { binding = "AUTH_TOKEN_METADATA", id = "b618bb05deb8493f944ef4a0f538030c", preview_id = "6a546b5fc21a423eb4ea07db2d611a91" }, 184 | { binding = "CONTENT_SERVE_DELEGATIONS_STORE", id = "99ae45f8b5b3478a9df09302c27e81a3", preview_id = "ec5c429f8b1849a68d73dee7447b4e30" }, 185 | { binding = "DAGPB_CONTENT_CACHE", id = "c70a74363e7a4f06ad39fa3022aab7c7", preview_id = "fcea72d8d8694b5c831736e1317e9208" } 186 | ] 187 | 188 | [env.fforbeck.vars] 189 | DEBUG = "true" 190 | FF_RATE_LIMITER_ENABLED = "false" 191 | FF_EGRESS_TRACKER_ENABLED = "true" 192 | FF_EGRESS_TRACKER_ROLLOUT_PERCENTAGE = "100" 193 | FF_TELEMETRY_ENABLED = "true" 194 | FF_DELEGATIONS_STORAGE_ENABLED = "true" 195 | FF_RAMP_UP_PROBABILITY = "100" 196 | FF_DAGPB_CONTENT_CACHE_TTL_SECONDS = 0 197 | FF_DAGPB_CONTENT_CACHE_MAX_SIZE_MB = 2 198 | FF_DAGPB_CONTENT_CACHE_ENABLED = "false" 199 | TELEMETRY_RATIO = 1.0 200 | CONTENT_CLAIMS_SERVICE_URL = "https://staging.claims.web3.storage" 201 | CARPARK_PUBLIC_BUCKET_URL = "https://carpark-staging-0.r2.w3s.link" 202 | GATEWAY_SERVICE_DID = "did:web:staging.w3s.link" 203 | UPLOAD_SERVICE_DID = "did:web:staging.up.storacha.network" 204 | UPLOAD_API_URL = "https://staging.up.storacha.network" 205 | INDEXING_SERVICE_URL = "https://staging.indexer.storacha.network/" 206 | MAX_SHARDS = "825" 207 | GATEWAY_VALIDATOR_PROOF = "mAYIEAI0DOqJlcm9vdHOB2CpYJQABcRIg5a/pcyEeJn5snQZHm+wVBzeFbY8RTlfwETn+nMMiCqlndmVyc2lvbgH2AQFxEiD8LRCZwMMfTVlGtNX85n0aVxjp54oE0/G/WTG/dsOOA6dhc1hE7aEDQCEy4qAv4dybXYh/iipwdOLf4DlYjZGFopGvvfDZbVq6Q4MIY5PY9XXHP9D6ELs6qjcvSLfP/nbew/IWbG55RAJhdmUwLjkuMWNhdHSBomNjYW5rdWNhbi9hdHRlc3Rkd2l0aHgYZGlkOndlYjpzdGFnaW5nLnczcy5saW5rY2F1ZFghnRp3ZWI6c3RhZ2luZy51cC5zdG9yYWNoYS5uZXR3b3JrY2V4cPZjaXNzVp0ad2ViOnN0YWdpbmcudzNzLmxpbmtjcHJmgFkBcRIg5a/pcyEeJn5snQZHm+wVBzeFbY8RTlfwETn+nMMiCqmhanVjYW5AMC45LjHYKlglAAFxEiD8LRCZwMMfTVlGtNX85n0aVxjp54oE0/G/WTG/dsOOAw" 208 | 209 | [[env.fforbeck.unsafe.bindings]] 210 | name = "RATE_LIMITER" 211 | type = "ratelimit" 212 | namespace_id = "0" 213 | simple = { limit = 5, period = 60 } 214 | 215 | [[env.fforbeck.queues.producers]] 216 | queue = "egress-tracking-queue-staging" 217 | binding = "EGRESS_QUEUE" 218 | 219 | [env.fforbeck.observability] 220 | [env.fforbeck.observability.logs] 221 | enabled = true 222 | head_sampling_rate = 1 223 | invocation_logs = true 224 | persist = false 225 | 226 | [env.hannahhoward] 227 | name = "freeway-hannahhoward" 228 | workers_dev = true 229 | account_id = "fffa4b4363a7e5250af8357087263b3a" 230 | r2_buckets = [ 231 | { binding = "CARPARK", bucket_name = "carpark-prod-0", preview_bucket_name = "carpark-prod-0" } 232 | ] 233 | kv_namespaces = [ 234 | { binding = "AUTH_TOKEN_METADATA", id = "b618bb05deb8493f944ef4a0f538030c", preview_id = "6a546b5fc21a423eb4ea07db2d611a91" }, 235 | { binding = "CONTENT_SERVE_DELEGATIONS_STORE", id = "26cc47fec09749bb9ee42bc6407f9a9d", preview_id = "ec5c429f8b1849a68d73dee7447b4e30" }, 236 | { binding = "DAGPB_CONTENT_CACHE", id = "3f0c253b90fc48c1b384f1563ede54f9", preview_id = "fcea72d8d8694b5c831736e1317e9208" } 237 | ] 238 | 239 | [env.hannahhoward.vars] 240 | DEBUG = "true" 241 | FF_RATE_LIMITER_ENABLED = "false" 242 | FF_EGRESS_TRACKER_ENABLED = "false" 243 | FF_TELEMETRY_ENABLED = "true" 244 | FF_DELEGATIONS_STORAGE_ENABLED = "true" 245 | FF_RAMP_UP_PROBABILITY = "100" 246 | FF_DAGPB_CONTENT_CACHE_TTL_SECONDS = 300 247 | FF_DAGPB_CONTENT_CACHE_MAX_SIZE_MB = 2 248 | FF_DAGPB_CONTENT_CACHE_ENABLED = "true" 249 | TELEMETRY_RATIO = 1.0 250 | CONTENT_CLAIMS_SERVICE_URL = "https://claims.web3.storage" 251 | CARPARK_PUBLIC_BUCKET_URL = "https://carpark-prod-0.r2.w3s.link" 252 | INDEXING_SERVICE_URL = "https://indexer.storacha.network/" 253 | GATEWAY_SERVICE_DID = "did:web:w3s.link" 254 | UPLOAD_SERVICE_DID = "did:web:web3.storage" 255 | UPLOAD_API_URL = "https://up.web3.storage" 256 | 257 | [[env.hannahhoward.unsafe.bindings]] 258 | name = "RATE_LIMITER" 259 | type = "ratelimit" 260 | namespace_id = "0" 261 | simple = { limit = 5, period = 60 } 262 | 263 | [env.peeja] 264 | # Custom name for your testing/dev worker 265 | name = "peeja-freeway" 266 | workers_dev = true 267 | # Account Id from step 3 268 | account_id = "5ad700ac7e44a8bdebb6c5626bc15ecb" 269 | # See step 4.2 to create the R2 Bucket 270 | r2_buckets = [ 271 | { binding = "CARPARK", bucket_name = "carpark-peeja-0", preview_bucket_name = "carpark-peeja-preview-0" }, 272 | ] 273 | compatibility_flags = ["nodejs_compat"] 274 | compatibility_date = "2024-11-26" 275 | 276 | [env.peeja.vars] 277 | DEBUG = "true" 278 | FF_RATE_LIMITER_ENABLED = "false" 279 | FF_EGRESS_TRACKER_ENABLED = "true" 280 | FF_TELEMETRY_ENABLED = "false" 281 | TELEMETRY_RATIO = 1.0 282 | GATEWAY_SERVICE_DID = "did:web:staging.w3s.link" 283 | UPLOAD_SERVICE_DID = "did:web:staging.web3.storage" 284 | CONTENT_CLAIMS_SERVICE_URL = "https://staging.claims.web3.storage" 285 | UPLOAD_API_URL = "https://staging.up.web3.storage" 286 | INDEXING_SERVICE_URL = "https://staging.indexer.storacha.network/" 287 | 288 | [[env.peeja.unsafe.bindings]] 289 | name = "RATE_LIMITER" 290 | type = "ratelimit" 291 | namespace_id = "0" 292 | simple = { limit = 100, period = 60 } 293 | 294 | [[env.peeja.kv_namespaces]] 295 | binding = "AUTH_TOKEN_METADATA" 296 | # See step 4.1 to create the KV store 297 | id = "0b44d436a3df48bfbdcfa3eb8b5207ca" 298 | 299 | 300 | ### Integration Tests Configuration 301 | [env.integration] 302 | name = "freeway-integration-test" 303 | workers_dev = true 304 | account_id = "fffa4b4363a7e5250af8357087263b3a" 305 | r2_buckets = [ 306 | { binding = "CARPARK", bucket_name = "carpark-integration-0", preview_bucket_name = "carpark-integration-preview-0" } 307 | ] 308 | 309 | [env.integration.vars] 310 | DEBUG = "true" 311 | FF_RATE_LIMITER_ENABLED = "true" 312 | FF_EGRESS_TRACKER_ENABLED = "true" 313 | FF_TELEMETRY_ENABLED = "true" 314 | TELEMETRY_RATIO = 1.0 315 | GATEWAY_SERVICE_DID = "did:web:staging.w3s.link" 316 | UPLOAD_SERVICE_DID = "did:web:staging.web3.storage" 317 | CONTENT_CLAIMS_SERVICE_URL = "https://staging.claims.web3.storage" 318 | UPLOAD_API_URL = "https://staging.up.web3.storage" 319 | INDEXING_SERVICE_URL = "https://staging.indexer.storacha.network/" 320 | 321 | [[env.integration.unsafe.bindings]] 322 | name = "RATE_LIMITER" 323 | type = "ratelimit" 324 | namespace_id = "0" 325 | simple = { limit = 100, period = 60 } 326 | 327 | [[env.integration.kv_namespaces]] 328 | binding = "AUTH_TOKEN_METADATA" 329 | id = "a355501ee4f242b1affa32c1b335db2b" 330 | -------------------------------------------------------------------------------- /docs/freeway.tldr: -------------------------------------------------------------------------------- 1 | { 2 | "tldrawFileFormatVersion": 1, 3 | "schema": { 4 | "schemaVersion": 2, 5 | "sequences": { 6 | "com.tldraw.store": 4, 7 | "com.tldraw.asset": 1, 8 | "com.tldraw.camera": 1, 9 | "com.tldraw.document": 2, 10 | "com.tldraw.instance": 24, 11 | "com.tldraw.instance_page_state": 5, 12 | "com.tldraw.page": 1, 13 | "com.tldraw.instance_presence": 5, 14 | "com.tldraw.pointer": 1, 15 | "com.tldraw.shape": 4, 16 | "com.tldraw.asset.bookmark": 1, 17 | "com.tldraw.asset.image": 3, 18 | "com.tldraw.asset.video": 3, 19 | "com.tldraw.shape.group": 0, 20 | "com.tldraw.shape.text": 2, 21 | "com.tldraw.shape.bookmark": 2, 22 | "com.tldraw.shape.draw": 1, 23 | "com.tldraw.shape.geo": 8, 24 | "com.tldraw.shape.note": 6, 25 | "com.tldraw.shape.line": 4, 26 | "com.tldraw.shape.frame": 0, 27 | "com.tldraw.shape.arrow": 3, 28 | "com.tldraw.shape.highlight": 0, 29 | "com.tldraw.shape.embed": 4, 30 | "com.tldraw.shape.image": 3, 31 | "com.tldraw.shape.video": 2 32 | } 33 | }, 34 | "records": [ 35 | { 36 | "gridSize": 10, 37 | "name": "", 38 | "meta": {}, 39 | "id": "document:document", 40 | "typeName": "document" 41 | }, 42 | { 43 | "id": "pointer:pointer", 44 | "typeName": "pointer", 45 | "x": 393.60541247841013, 46 | "y": 573.3743105658386, 47 | "lastActivityTimestamp": 1716900205095, 48 | "meta": {} 49 | }, 50 | { 51 | "meta": {}, 52 | "id": "page:page", 53 | "name": "Page 1", 54 | "index": "a1", 55 | "typeName": "page" 56 | }, 57 | { 58 | "x": 149.31867578562074, 59 | "y": 141.72106754981675, 60 | "z": 0.6963450474426978, 61 | "meta": {}, 62 | "id": "camera:page:page", 63 | "typeName": "camera" 64 | }, 65 | { 66 | "editingShapeId": null, 67 | "croppingShapeId": null, 68 | "selectedShapeIds": [ 69 | "shape:8_Cz6TN1N68JOhkpUIla0" 70 | ], 71 | "hoveredShapeId": null, 72 | "erasingShapeIds": [], 73 | "hintingShapeIds": [], 74 | "focusedGroupId": null, 75 | "meta": {}, 76 | "id": "instance_page_state:page:page", 77 | "pageId": "page:page", 78 | "typeName": "instance_page_state" 79 | }, 80 | { 81 | "followingUserId": null, 82 | "opacityForNextShape": 1, 83 | "stylesForNextShape": { 84 | "tldraw:fill": "solid", 85 | "tldraw:geo": "rectangle", 86 | "tldraw:color": "violet", 87 | "tldraw:size": "m", 88 | "tldraw:dash": "dotted", 89 | "tldraw:arrowheadStart": "arrow", 90 | "tldraw:verticalAlign": "middle", 91 | "tldraw:horizontalAlign": "middle" 92 | }, 93 | "brush": null, 94 | "scribbles": [], 95 | "cursor": { 96 | "type": "move", 97 | "rotation": 0 98 | }, 99 | "isFocusMode": false, 100 | "exportBackground": true, 101 | "isDebugMode": false, 102 | "isToolLocked": false, 103 | "screenBounds": { 104 | "x": 0, 105 | "y": 0, 106 | "w": 1604, 107 | "h": 676 108 | }, 109 | "zoomBrush": null, 110 | "isGridMode": false, 111 | "isPenMode": false, 112 | "chatMessage": "", 113 | "isChatting": false, 114 | "highlightedUserIds": [], 115 | "canMoveCamera": true, 116 | "isFocused": true, 117 | "devicePixelRatio": 2, 118 | "isCoarsePointer": false, 119 | "isHoveringCanvas": true, 120 | "openMenus": [], 121 | "isChangingStyle": false, 122 | "isReadonly": false, 123 | "meta": {}, 124 | "id": "instance:instance", 125 | "currentPageId": "page:page", 126 | "typeName": "instance", 127 | "insets": [ 128 | false, 129 | false, 130 | true, 131 | false 132 | ], 133 | "duplicateProps": null 134 | }, 135 | { 136 | "x": 957.477047778804, 137 | "y": 233.98588080011538, 138 | "rotation": 0, 139 | "isLocked": false, 140 | "opacity": 1, 141 | "meta": {}, 142 | "type": "geo", 143 | "props": { 144 | "w": 144.87856708952017, 145 | "h": 150.0670579257756, 146 | "geo": "triangle", 147 | "color": "light-green", 148 | "labelColor": "black", 149 | "fill": "solid", 150 | "dash": "draw", 151 | "size": "m", 152 | "font": "draw", 153 | "text": "claims", 154 | "align": "middle", 155 | "verticalAlign": "end", 156 | "growY": 0, 157 | "url": "" 158 | }, 159 | "parentId": "page:page", 160 | "index": "a8", 161 | "id": "shape:h6icsoW_1CHz6qrV0suAC", 162 | "typeName": "shape" 163 | }, 164 | { 165 | "x": 568.015625, 166 | "y": 239.4453125, 167 | "rotation": 0, 168 | "isLocked": false, 169 | "opacity": 1, 170 | "meta": {}, 171 | "type": "arrow", 172 | "parentId": "page:page", 173 | "index": "aKV", 174 | "props": { 175 | "dash": "draw", 176 | "size": "m", 177 | "fill": "pattern", 178 | "color": "light-green", 179 | "labelColor": "black", 180 | "bend": 0, 181 | "start": { 182 | "type": "binding", 183 | "boundShapeId": "shape:1f6_uId-mbYXlI3TNpa8Y", 184 | "normalizedAnchor": { 185 | "x": 0.5, 186 | "y": 0.5 187 | }, 188 | "isPrecise": true, 189 | "isExact": false 190 | }, 191 | "end": { 192 | "type": "binding", 193 | "boundShapeId": "shape:1WDpKEa5X6TRDU6YicTE0", 194 | "normalizedAnchor": { 195 | "x": 0.5, 196 | "y": 0.5 197 | }, 198 | "isPrecise": true, 199 | "isExact": false 200 | }, 201 | "arrowheadStart": "arrow", 202 | "arrowheadEnd": "arrow", 203 | "text": "WHERE\n[CID]?", 204 | "font": "draw", 205 | "labelPosition": 0.5 206 | }, 207 | "id": "shape:b_z3nn_Ox0o7wifnEGs7T", 208 | "typeName": "shape" 209 | }, 210 | { 211 | "x": 636.203125, 212 | "y": 326.75, 213 | "rotation": 0, 214 | "isLocked": false, 215 | "opacity": 1, 216 | "meta": {}, 217 | "type": "arrow", 218 | "parentId": "page:page", 219 | "index": "aKG", 220 | "props": { 221 | "dash": "draw", 222 | "size": "m", 223 | "fill": "pattern", 224 | "color": "black", 225 | "labelColor": "black", 226 | "bend": 85.279065067975, 227 | "start": { 228 | "type": "binding", 229 | "boundShapeId": "shape:1f6_uId-mbYXlI3TNpa8Y", 230 | "normalizedAnchor": { 231 | "x": 0.5, 232 | "y": 0.5 233 | }, 234 | "isPrecise": true, 235 | "isExact": false 236 | }, 237 | "end": { 238 | "type": "binding", 239 | "boundShapeId": "shape:S_XO2kLHaBjqWinUQgEyY", 240 | "normalizedAnchor": { 241 | "x": 0.5, 242 | "y": 0.5 243 | }, 244 | "isPrecise": true, 245 | "isExact": false 246 | }, 247 | "arrowheadStart": "arrow", 248 | "arrowheadEnd": "arrow", 249 | "text": "GET Range: bytes=x-y", 250 | "font": "draw", 251 | "labelPosition": 0.447452814015783 252 | }, 253 | "id": "shape:Lq1ERRAfmv_Wis3iQxU0Y", 254 | "typeName": "shape" 255 | }, 256 | { 257 | "x": 1019.9461990093237, 258 | "y": 385.5849338268566, 259 | "rotation": 3.1241393610698496, 260 | "isLocked": false, 261 | "opacity": 1, 262 | "meta": {}, 263 | "id": "shape:1WDpKEa5X6TRDU6YicTE0", 264 | "type": "geo", 265 | "props": { 266 | "w": 144.87856708952017, 267 | "h": 150.0670579257756, 268 | "geo": "triangle", 269 | "color": "light-green", 270 | "labelColor": "black", 271 | "fill": "solid", 272 | "dash": "draw", 273 | "size": "m", 274 | "font": "draw", 275 | "text": "", 276 | "align": "middle", 277 | "verticalAlign": "end", 278 | "growY": 0, 279 | "url": "" 280 | }, 281 | "parentId": "page:page", 282 | "index": "aG", 283 | "typeName": "shape" 284 | }, 285 | { 286 | "x": 897.098631608211, 287 | "y": 243.98416741186475, 288 | "rotation": 0, 289 | "isLocked": false, 290 | "opacity": 1, 291 | "meta": {}, 292 | "id": "shape:nh0lQ4O76zMpOCRP0vhGg", 293 | "type": "text", 294 | "props": { 295 | "color": "black", 296 | "size": "m", 297 | "w": 95.734375, 298 | "text": "content", 299 | "font": "draw", 300 | "textAlign": "start", 301 | "autoSize": true, 302 | "scale": 1 303 | }, 304 | "parentId": "page:page", 305 | "index": "aH", 306 | "typeName": "shape" 307 | }, 308 | { 309 | "x": 884.6508418353444, 310 | "y": 475.0891179851932, 311 | "rotation": 0, 312 | "isLocked": false, 313 | "opacity": 1, 314 | "meta": {}, 315 | "id": "shape:S_XO2kLHaBjqWinUQgEyY", 316 | "type": "geo", 317 | "props": { 318 | "w": 143, 319 | "h": 127.22914692566225, 320 | "geo": "cloud", 321 | "color": "black", 322 | "labelColor": "black", 323 | "fill": "solid", 324 | "dash": "solid", 325 | "size": "m", 326 | "font": "draw", 327 | "text": "public bucket", 328 | "align": "middle", 329 | "verticalAlign": "middle", 330 | "growY": 0, 331 | "url": "" 332 | }, 333 | "parentId": "page:page", 334 | "index": "aI", 335 | "typeName": "shape" 336 | }, 337 | { 338 | "x": 504.9344695094065, 339 | "y": 235.71795070055006, 340 | "rotation": 0, 341 | "isLocked": false, 342 | "opacity": 1, 343 | "meta": {}, 344 | "id": "shape:1f6_uId-mbYXlI3TNpa8Y", 345 | "type": "geo", 346 | "props": { 347 | "w": 229.22701085647526, 348 | "h": 167.3199041729866, 349 | "geo": "cloud", 350 | "color": "orange", 351 | "labelColor": "black", 352 | "fill": "solid", 353 | "dash": "solid", 354 | "size": "m", 355 | "font": "draw", 356 | "text": "freeway\nw3s.link", 357 | "align": "middle", 358 | "verticalAlign": "middle", 359 | "growY": 0, 360 | "url": "" 361 | }, 362 | "parentId": "page:page", 363 | "index": "aK", 364 | "typeName": "shape" 365 | }, 366 | { 367 | "x": 257.31660516542416, 368 | "y": 127.8032181401372, 369 | "rotation": 0, 370 | "isLocked": false, 371 | "opacity": 1, 372 | "meta": {}, 373 | "id": "shape:MkEHLMcemuEJl7hwfZd5t", 374 | "type": "geo", 375 | "props": { 376 | "w": 150.90436827898415, 377 | "h": 61.6953125, 378 | "geo": "rectangle", 379 | "color": "violet", 380 | "labelColor": "black", 381 | "fill": "solid", 382 | "dash": "solid", 383 | "size": "m", 384 | "font": "draw", 385 | "text": "browser", 386 | "align": "middle", 387 | "verticalAlign": "middle", 388 | "growY": 0, 389 | "url": "" 390 | }, 391 | "parentId": "page:page", 392 | "index": "aM", 393 | "typeName": "shape" 394 | }, 395 | { 396 | "x": 488.79869416996246, 397 | "y": 66.71977049272618, 398 | "rotation": 0, 399 | "isLocked": false, 400 | "opacity": 1, 401 | "meta": {}, 402 | "id": "shape:9RN2B3NU2QQhZa68LZjvy", 403 | "type": "geo", 404 | "props": { 405 | "w": 150.90436827898415, 406 | "h": 61.6953125, 407 | "geo": "rectangle", 408 | "color": "violet", 409 | "labelColor": "black", 410 | "fill": "solid", 411 | "dash": "solid", 412 | "size": "m", 413 | "font": "draw", 414 | "text": "browser", 415 | "align": "middle", 416 | "verticalAlign": "middle", 417 | "growY": 0, 418 | "url": "" 419 | }, 420 | "parentId": "page:page", 421 | "index": "aN", 422 | "typeName": "shape" 423 | }, 424 | { 425 | "x": 217.20201850671947, 426 | "y": 261.6942759460338, 427 | "rotation": 0, 428 | "isLocked": false, 429 | "opacity": 1, 430 | "meta": {}, 431 | "id": "shape:WVUyz4RqB-zwwzmFLML1j", 432 | "type": "geo", 433 | "props": { 434 | "w": 150.90436827898415, 435 | "h": 61.6953125, 436 | "geo": "rectangle", 437 | "color": "violet", 438 | "labelColor": "black", 439 | "fill": "solid", 440 | "dash": "solid", 441 | "size": "m", 442 | "font": "draw", 443 | "text": "curl", 444 | "align": "middle", 445 | "verticalAlign": "middle", 446 | "growY": 0, 447 | "url": "" 448 | }, 449 | "parentId": "page:page", 450 | "index": "aO", 451 | "typeName": "shape" 452 | }, 453 | { 454 | "x": 312.48430526661735, 455 | "y": 145.28530879185757, 456 | "rotation": 0, 457 | "isLocked": false, 458 | "opacity": 1, 459 | "meta": {}, 460 | "id": "shape:2uTQDMMyWpFZcV_wXCbwI", 461 | "type": "arrow", 462 | "parentId": "page:page", 463 | "index": "aNV", 464 | "props": { 465 | "dash": "dotted", 466 | "size": "m", 467 | "fill": "solid", 468 | "color": "violet", 469 | "labelColor": "black", 470 | "bend": 0, 471 | "start": { 472 | "type": "binding", 473 | "boundShapeId": "shape:9RN2B3NU2QQhZa68LZjvy", 474 | "normalizedAnchor": { 475 | "x": 0.6172452942479272, 476 | "y": 0.6741574416328375 477 | }, 478 | "isPrecise": false, 479 | "isExact": false 480 | }, 481 | "end": { 482 | "type": "binding", 483 | "boundShapeId": "shape:1f6_uId-mbYXlI3TNpa8Y", 484 | "normalizedAnchor": { 485 | "x": 0.535064965079527, 486 | "y": 0.11207672990942905 487 | }, 488 | "isPrecise": true, 489 | "isExact": false 490 | }, 491 | "arrowheadStart": "arrow", 492 | "arrowheadEnd": "arrow", 493 | "text": "", 494 | "labelPosition": 0.5, 495 | "font": "draw" 496 | }, 497 | "typeName": "shape" 498 | }, 499 | { 500 | "x": 312.48430526661735, 501 | "y": 145.28530879185757, 502 | "rotation": 0, 503 | "isLocked": false, 504 | "opacity": 1, 505 | "meta": {}, 506 | "id": "shape:1vZgTQo9bAwcvLLMsN9c-", 507 | "type": "arrow", 508 | "parentId": "page:page", 509 | "index": "aMV", 510 | "props": { 511 | "dash": "dotted", 512 | "size": "m", 513 | "fill": "solid", 514 | "color": "violet", 515 | "labelColor": "black", 516 | "bend": 0, 517 | "start": { 518 | "type": "binding", 519 | "boundShapeId": "shape:MkEHLMcemuEJl7hwfZd5t", 520 | "normalizedAnchor": { 521 | "x": 0.5, 522 | "y": 0.5 523 | }, 524 | "isPrecise": true, 525 | "isExact": false 526 | }, 527 | "end": { 528 | "type": "binding", 529 | "boundShapeId": "shape:1f6_uId-mbYXlI3TNpa8Y", 530 | "normalizedAnchor": { 531 | "x": 0.28607471676737384, 532 | "y": 0.2549151098251597 533 | }, 534 | "isPrecise": true, 535 | "isExact": false 536 | }, 537 | "arrowheadStart": "arrow", 538 | "arrowheadEnd": "arrow", 539 | "text": "", 540 | "labelPosition": 0.5, 541 | "font": "draw" 542 | }, 543 | "typeName": "shape" 544 | }, 545 | { 546 | "x": 312.48430526661735, 547 | "y": 145.28530879185757, 548 | "rotation": 0, 549 | "isLocked": false, 550 | "opacity": 1, 551 | "meta": {}, 552 | "id": "shape:6Ub-c1VaBLD7gBl8s0t0D", 553 | "type": "arrow", 554 | "parentId": "page:page", 555 | "index": "aP", 556 | "props": { 557 | "dash": "dotted", 558 | "size": "m", 559 | "fill": "solid", 560 | "color": "violet", 561 | "labelColor": "black", 562 | "bend": 0, 563 | "start": { 564 | "type": "binding", 565 | "boundShapeId": "shape:WVUyz4RqB-zwwzmFLML1j", 566 | "normalizedAnchor": { 567 | "x": 0.5, 568 | "y": 0.5 569 | }, 570 | "isPrecise": true, 571 | "isExact": false 572 | }, 573 | "end": { 574 | "type": "binding", 575 | "boundShapeId": "shape:1f6_uId-mbYXlI3TNpa8Y", 576 | "normalizedAnchor": { 577 | "x": 0.5, 578 | "y": 0.5 579 | }, 580 | "isPrecise": true, 581 | "isExact": false 582 | }, 583 | "arrowheadStart": "arrow", 584 | "arrowheadEnd": "arrow", 585 | "text": "", 586 | "labelPosition": 0.5, 587 | "font": "draw" 588 | }, 589 | "typeName": "shape" 590 | }, 591 | { 592 | "x": 304.44503871552365, 593 | "y": 486.66182002363644, 594 | "rotation": 0, 595 | "isLocked": false, 596 | "opacity": 1, 597 | "meta": {}, 598 | "id": "shape:8_Cz6TN1N68JOhkpUIla0", 599 | "type": "geo", 600 | "props": { 601 | "w": 143, 602 | "h": 127.22914692566225, 603 | "geo": "cloud", 604 | "color": "black", 605 | "labelColor": "black", 606 | "fill": "solid", 607 | "dash": "solid", 608 | "size": "m", 609 | "font": "draw", 610 | "text": "public bucket", 611 | "align": "middle", 612 | "verticalAlign": "middle", 613 | "growY": 0, 614 | "url": "" 615 | }, 616 | "parentId": "page:page", 617 | "index": "aQ", 618 | "typeName": "shape" 619 | }, 620 | { 621 | "x": 636.203125, 622 | "y": 326.75, 623 | "rotation": 0, 624 | "isLocked": false, 625 | "opacity": 1, 626 | "meta": {}, 627 | "id": "shape:b2f9v3ZfTTHabCPfUohCM", 628 | "type": "arrow", 629 | "parentId": "page:page", 630 | "index": "aR", 631 | "props": { 632 | "dash": "draw", 633 | "size": "m", 634 | "fill": "pattern", 635 | "color": "black", 636 | "labelColor": "black", 637 | "bend": -48.33739334531044, 638 | "start": { 639 | "type": "binding", 640 | "boundShapeId": "shape:1f6_uId-mbYXlI3TNpa8Y", 641 | "normalizedAnchor": { 642 | "x": 0.4060664895832534, 643 | "y": 0.7212185908645974 644 | }, 645 | "isPrecise": true, 646 | "isExact": false 647 | }, 648 | "end": { 649 | "type": "binding", 650 | "boundShapeId": "shape:8_Cz6TN1N68JOhkpUIla0", 651 | "normalizedAnchor": { 652 | "x": 0.5, 653 | "y": 0.5 654 | }, 655 | "isPrecise": true, 656 | "isExact": false 657 | }, 658 | "arrowheadStart": "arrow", 659 | "arrowheadEnd": "arrow", 660 | "text": "GET Range: bytes=x-y", 661 | "labelPosition": 0.4490074170144837, 662 | "font": "draw" 663 | }, 664 | "typeName": "shape" 665 | } 666 | ] 667 | } -------------------------------------------------------------------------------- /test/unit/middleware/withAuthorizedSpace.spec.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-unused-expressions 2 | --- 3 | `no-unused-expressions` doesn't understand that several of Chai's assertions 4 | are implemented as getters rather than explicit function calls; it thinks 5 | the assertions are unused expressions. */ 6 | import { describe, it } from 'mocha' 7 | import { expect } from 'chai' 8 | import sinon from 'sinon' 9 | import * as ed25519 from '@ucanto/principal/ed25519' 10 | import { withAuthorizedSpace } from '../../../src/middleware/withAuthorizedSpace.js' 11 | import * as Digest from 'multiformats/hashes/digest' 12 | import { base64 } from 'multiformats/bases/base64' 13 | import { rejection } from './util/rejection.js' 14 | import { expectToBeInstanceOf } from './util/expectToBeInstanceOf.js' 15 | import { HttpError } from '@web3-storage/gateway-lib/util' 16 | import { createTestCID } from './util/createTestCID.js' 17 | import * as serve from '../../../src/capabilities/serve.js' 18 | 19 | /** 20 | * @import { MultihashDigest } from 'multiformats' 21 | * @import * as Ucanto from '@ucanto/interface' 22 | * @import { Locator } from '@web3-storage/blob-fetcher' 23 | * @import { 24 | * Handler, 25 | * Environment as MiddlewareEnvironment, 26 | * IpfsUrlContext 27 | * } from '@web3-storage/gateway-lib' 28 | * @import { SpaceContext } from '../../../src/middleware/withAuthorizedSpace.types.js' 29 | * @import { DelegationsStorage, DelegationsStorageContext } from '../../../src/middleware/withDelegationsStorage.types.js' 30 | * @import { DelegationProofsContext } from '../../../src/middleware/withAuthorizedSpace.types.js' 31 | * @import { LocatorContext } from '../../../src/middleware/withLocator.types.js' 32 | * @import { AuthTokenContext } from '../../../src/middleware/withAuthToken.types.js' 33 | */ 34 | 35 | /** @type {Handler} */ 36 | const innerHandler = async (_req, _env, ctx) => { 37 | const locateResult = await ctx.locator.locate(ctx.dataCid.multihash) 38 | if (locateResult.error) { 39 | throw new Error(`Failed to locate: ${ctx.dataCid}`, { 40 | cause: locateResult.error 41 | }) 42 | } 43 | 44 | const blobLocations = locateResult.ok.site.flatMap((site) => site.location) 45 | 46 | return new Response( 47 | JSON.stringify({ 48 | CID: ctx.dataCid.toString(), 49 | Space: ctx.space, 50 | Token: ctx.authToken, 51 | URLs: blobLocations 52 | }) 53 | ) 54 | } 55 | 56 | const request = new Request('http://example.com/') 57 | 58 | const context = { 59 | waitUntil: () => { }, 60 | path: '', 61 | searchParams: new URLSearchParams() 62 | } 63 | 64 | /** 65 | * Returns a {@link Locator} which locates content only from a specific Space, 66 | * by simply filtering the results of another {@link Locator}. 67 | * 68 | * @param {Locator} locator 69 | * @param {Ucanto.DID[]} spaces 70 | * @returns {Locator} 71 | */ 72 | export const spaceScopedLocator = (locator, spaces) => ({ 73 | locate: async (digest) => { 74 | const locateResult = await locator.locate(digest) 75 | if (locateResult.error) { 76 | return locateResult 77 | } else { 78 | return { 79 | ok: { 80 | ...locateResult.ok, 81 | site: locateResult.ok.site.filter( 82 | (site) => site.space && spaces.includes(site.space) 83 | ) 84 | } 85 | } 86 | } 87 | }, 88 | scopeToSpaces (newSpaces) { 89 | return spaceScopedLocator(locator, newSpaces) 90 | } 91 | }) 92 | 93 | /** 94 | * @param {MultihashDigest} expectedDigest 95 | * @param {Awaited>} locateResponse 96 | * @returns {Locator} 97 | * */ 98 | const createLocator = (expectedDigest, locateResponse) => ({ 99 | async locate (digest) { 100 | if (Digest.equals(digest, expectedDigest)) { 101 | return locateResponse 102 | } else { 103 | expect.fail( 104 | `Got unexpected digest in test locator: ${base64.baseEncode( 105 | digest.bytes 106 | )}` 107 | ) 108 | } 109 | }, 110 | scopeToSpaces (spaces) { 111 | return spaceScopedLocator(this, spaces) 112 | } 113 | }) 114 | 115 | const gatewaySigner = (await ed25519.Signer.generate()).signer 116 | const gatewayIdentity = gatewaySigner.withDID('did:web:test.w3s.link') 117 | 118 | /** 119 | * @param {Ucanto.Delegation[]} delegations 120 | * @returns {DelegationsStorage} 121 | * */ 122 | const createDelegationStorage = (delegations) => ({ 123 | find: async (space) => { 124 | return { 125 | ok: delegations.filter((d) => 126 | d.capabilities.some((cap) => cap.with === space) 127 | ) 128 | } 129 | }, 130 | store: async (space, delegation) => ({ error: new Error('Not implemented') }) 131 | }) 132 | 133 | describe('withAuthorizedSpace', async () => { 134 | it('should serve a found CID from a Space authorized using a token', async () => { 135 | const cid = createTestCID(0) 136 | const space = await ed25519.Signer.generate() 137 | 138 | const response = await withAuthorizedSpace(innerHandler)( 139 | request, 140 | {}, 141 | { 142 | ...context, 143 | dataCid: cid, 144 | authToken: 'a1b2c3', 145 | locator: createLocator(cid.multihash, { 146 | ok: { 147 | digest: cid.multihash, 148 | site: [ 149 | { 150 | location: [new URL('http://example.com/blob')], 151 | range: { offset: 0, length: 100 }, 152 | space: space.did() 153 | } 154 | ] 155 | }, 156 | error: undefined 157 | }), 158 | delegationsStorage: createDelegationStorage([ 159 | await serve.transportHttp.delegate({ 160 | issuer: space, 161 | audience: gatewayIdentity, 162 | with: space.did(), 163 | nb: { token: 'a1b2c3' } 164 | }) 165 | ]), 166 | delegationProofs: [], 167 | gatewaySigner, 168 | gatewayIdentity 169 | } 170 | ) 171 | 172 | expect(await response.json()).to.deep.equal({ 173 | CID: cid.toString(), 174 | Space: space.did(), 175 | Token: 'a1b2c3', 176 | URLs: ['http://example.com/blob'] 177 | }) 178 | }) 179 | 180 | it('should serve a found CID from a Space authorized using no token', async () => { 181 | const cid = createTestCID(0) 182 | const space = await ed25519.Signer.generate() 183 | 184 | const response = await withAuthorizedSpace(innerHandler)( 185 | request, 186 | {}, 187 | { 188 | ...context, 189 | dataCid: cid, 190 | authToken: null, 191 | locator: createLocator(cid.multihash, { 192 | ok: { 193 | digest: cid.multihash, 194 | site: [ 195 | { 196 | location: [new URL('http://example.com/blob')], 197 | range: { offset: 0, length: 100 }, 198 | space: space.did() 199 | } 200 | ] 201 | }, 202 | error: undefined 203 | }), 204 | delegationsStorage: createDelegationStorage([ 205 | await serve.transportHttp.delegate({ 206 | issuer: space, 207 | audience: gatewayIdentity, 208 | with: space.did(), 209 | nb: { token: null } 210 | }) 211 | ]), 212 | delegationProofs: [], 213 | gatewaySigner, 214 | gatewayIdentity 215 | } 216 | ) 217 | 218 | expect(await response.json()).to.deep.equal({ 219 | CID: cid.toString(), 220 | Space: space.did(), 221 | Token: null, 222 | URLs: ['http://example.com/blob'] 223 | }) 224 | }) 225 | 226 | it('should not serve a found CID using a token authorizing no Space', async () => { 227 | const handler = sinon.fake(innerHandler) 228 | const cid = createTestCID(0) 229 | const space = await ed25519.Signer.generate() 230 | 231 | const error = await rejection( 232 | withAuthorizedSpace(innerHandler)( 233 | request, 234 | {}, 235 | { 236 | ...context, 237 | dataCid: cid, 238 | authToken: 'not-valid-token', 239 | locator: createLocator(cid.multihash, { 240 | ok: { 241 | digest: cid.multihash, 242 | site: [ 243 | { 244 | location: [new URL('http://example.com/blob')], 245 | range: { offset: 0, length: 100 }, 246 | space: space.did() 247 | } 248 | ] 249 | }, 250 | error: undefined 251 | }), 252 | delegationsStorage: createDelegationStorage([ 253 | await serve.transportHttp.delegate({ 254 | issuer: space, 255 | audience: gatewayIdentity, 256 | with: space.did(), 257 | nb: { token: 'a1b2c3' } 258 | }) 259 | ]), 260 | delegationProofs: [], 261 | gatewaySigner, 262 | gatewayIdentity 263 | } 264 | ) 265 | ) 266 | 267 | expect(handler.notCalled).to.be.true 268 | expectToBeInstanceOf(error, HttpError) 269 | expect(error.status).to.equal(404) 270 | expect(error.message).to.equal('Not Found') 271 | }) 272 | 273 | it('should serve a found CID when stored in multiple Spaces', async () => { 274 | const cid = createTestCID(0) 275 | const space1 = await ed25519.Signer.generate() 276 | const space2 = await ed25519.Signer.generate() 277 | const space3 = await ed25519.Signer.generate() 278 | 279 | const ctx = { 280 | ...context, 281 | dataCid: cid, 282 | locator: createLocator(cid.multihash, { 283 | ok: { 284 | digest: cid.multihash, 285 | site: [ 286 | { 287 | location: [new URL('http://example.com/1/blob')], 288 | range: { offset: 0, length: 100 }, 289 | space: space1.did() 290 | }, 291 | { 292 | location: [new URL('http://example.com/2/blob')], 293 | range: { offset: 0, length: 100 }, 294 | space: space2.did() 295 | }, 296 | { 297 | location: [new URL('http://example.com/3/blob')], 298 | range: { offset: 0, length: 100 }, 299 | space: space3.did() 300 | } 301 | ] 302 | }, 303 | error: undefined 304 | }), 305 | delegationsStorage: createDelegationStorage([ 306 | await serve.transportHttp.delegate({ 307 | issuer: space1, 308 | audience: gatewayIdentity, 309 | with: space1.did(), 310 | nb: { token: 'space1-token' } 311 | }), 312 | // No authorization for space2 313 | await serve.transportHttp.delegate({ 314 | issuer: space3, 315 | audience: gatewayIdentity, 316 | with: space3.did(), 317 | nb: { token: 'space3-token' } 318 | }) 319 | ]), 320 | gatewayIdentity 321 | } 322 | 323 | const response1 = await withAuthorizedSpace(innerHandler)( 324 | request, 325 | {}, 326 | { ...ctx, authToken: 'space1-token', delegationProofs: [], gatewaySigner } 327 | ) 328 | 329 | // When content is in multiple spaces, ctx.space is undefined (egress tracking skipped) 330 | // Note: JSON.stringify removes undefined values, so Space won't be in the response 331 | const result1 = await response1.json() 332 | expect(result1.CID).to.equal(cid.toString()) 333 | expect(result1.Space).to.be.undefined 334 | expect(result1.Token).to.equal('space1-token') 335 | expect(result1.URLs).to.deep.equal(['http://example.com/1/blob']) 336 | 337 | const error2 = await rejection( 338 | withAuthorizedSpace(sinon.fake(innerHandler))( 339 | request, 340 | {}, 341 | { 342 | ...ctx, 343 | authToken: 'space2-token', 344 | delegationProofs: [], 345 | gatewaySigner 346 | } 347 | ) 348 | ) 349 | 350 | expect(sinon.fake(innerHandler).notCalled).to.be.true 351 | expectToBeInstanceOf(error2, HttpError) 352 | expect(error2.status).to.equal(404) 353 | expect(error2.message).to.equal('Not Found') 354 | 355 | const response3 = await withAuthorizedSpace(innerHandler)( 356 | request, 357 | {}, 358 | { ...ctx, authToken: 'space3-token', delegationProofs: [], gatewaySigner } 359 | ) 360 | 361 | // When content is in multiple spaces, ctx.space is undefined (egress tracking skipped) 362 | // Note: JSON.stringify removes undefined values, so Space won't be in the response 363 | const result3 = await response3.json() 364 | expect(result3.CID).to.equal(cid.toString()) 365 | expect(result3.Space).to.be.undefined 366 | expect(result3.Token).to.equal('space3-token') 367 | expect(result3.URLs).to.deep.equal(['http://example.com/3/blob']) 368 | }) 369 | 370 | it('should NOT skip egress tracking when content is in a single space', async () => { 371 | const cid = createTestCID(0) 372 | const space1 = await ed25519.Signer.generate() 373 | 374 | const ctx = { 375 | ...context, 376 | dataCid: cid, 377 | locator: createLocator(cid.multihash, { 378 | ok: { 379 | digest: cid.multihash, 380 | site: [ 381 | { 382 | location: [new URL('http://example.com/1/blob')], 383 | range: { offset: 0, length: 100 }, 384 | space: space1.did() 385 | }, 386 | { 387 | location: [new URL('http://example.com/2/blob')], 388 | range: { offset: 0, length: 100 }, 389 | space: space1.did() // Same space, different location 390 | } 391 | ] 392 | }, 393 | error: undefined 394 | }), 395 | delegationsStorage: createDelegationStorage([ 396 | await serve.transportHttp.delegate({ 397 | issuer: space1, 398 | audience: gatewayIdentity, 399 | with: space1.did(), 400 | nb: { token: 'space1-token' } 401 | }) 402 | ]), 403 | gatewayIdentity 404 | } 405 | 406 | const response = await withAuthorizedSpace(innerHandler)( 407 | request, 408 | {}, 409 | { ...ctx, authToken: 'space1-token', delegationProofs: [], gatewaySigner } 410 | ) 411 | 412 | // When content is in a single space (even with multiple sites), ctx.space should be set 413 | const result = await response.json() 414 | expect(result).to.deep.equal({ 415 | CID: cid.toString(), 416 | Space: space1.did(), 417 | Token: 'space1-token', 418 | URLs: ['http://example.com/1/blob', 'http://example.com/2/blob'] 419 | }) 420 | }) 421 | 422 | it('should serve a found legacy CID only using no token', async () => { 423 | const cid = createTestCID(0) 424 | 425 | const ctx = { 426 | ...context, 427 | dataCid: cid, 428 | locator: createLocator(cid.multihash, { 429 | ok: { 430 | digest: cid.multihash, 431 | site: [ 432 | { 433 | location: [new URL('http://example.com/blob')], 434 | range: { offset: 0, length: 100 } 435 | // No `space` value means it's legacy 436 | } 437 | ] 438 | }, 439 | error: undefined 440 | }), 441 | delegationsStorage: createDelegationStorage([]), 442 | gatewayIdentity 443 | } 444 | 445 | const responseWithoutToken = await withAuthorizedSpace(innerHandler)( 446 | request, 447 | {}, 448 | { ...ctx, authToken: null, delegationProofs: [], gatewaySigner } 449 | ) 450 | 451 | expect(await responseWithoutToken.json()).to.deep.equal({ 452 | CID: cid.toString(), 453 | Token: null, 454 | URLs: ['http://example.com/blob'] 455 | }) 456 | 457 | const ih = sinon.fake(innerHandler) 458 | const errorWithToken = await rejection( 459 | withAuthorizedSpace(ih)( 460 | request, 461 | {}, 462 | { ...ctx, authToken: 'a1b2c3', delegationProofs: [], gatewaySigner } 463 | ) 464 | ) 465 | 466 | expect(ih.notCalled).to.be.true 467 | expectToBeInstanceOf(errorWithToken, HttpError) 468 | expect(errorWithToken.status).to.equal(404) 469 | expect(errorWithToken.message).to.equal('Not Found') 470 | }) 471 | 472 | it('should throw a 404 error if the content is not found', async () => { 473 | const handler = sinon.fake(innerHandler) 474 | const cid = createTestCID(0) 475 | const space = await ed25519.Signer.generate() 476 | 477 | const error = await rejection( 478 | withAuthorizedSpace(innerHandler)( 479 | request, 480 | {}, 481 | { 482 | ...context, 483 | dataCid: cid, 484 | authToken: 'a1b2c3', 485 | locator: createLocator(cid.multihash, { 486 | error: { 487 | name: 'NotFound', 488 | message: 'Not found', 489 | digest: cid.multihash.bytes 490 | } 491 | }), 492 | delegationsStorage: createDelegationStorage([ 493 | await serve.transportHttp.delegate({ 494 | issuer: space, 495 | audience: gatewayIdentity, 496 | with: space.did(), 497 | nb: { token: 'a1b2c3' } 498 | }) 499 | ]), 500 | delegationProofs: [], 501 | gatewaySigner, 502 | gatewayIdentity 503 | } 504 | ) 505 | ) 506 | 507 | expect(handler.notCalled).to.be.true 508 | expectToBeInstanceOf(error, HttpError) 509 | expect(error.status).to.equal(404) 510 | expect(error.message).to.equal('Not Found') 511 | expect(error.cause).to.deep.equal({ 512 | name: 'NotFound', 513 | message: 'Not found', 514 | digest: cid.multihash.bytes 515 | }) 516 | }) 517 | 518 | it('should throw an error if the locator aborts', async () => { 519 | const handler = sinon.fake(innerHandler) 520 | const cid = createTestCID(0) 521 | const space = await ed25519.Signer.generate() 522 | 523 | const error = await rejection( 524 | withAuthorizedSpace(innerHandler)( 525 | request, 526 | {}, 527 | { 528 | ...context, 529 | dataCid: cid, 530 | authToken: 'a1b2c3', 531 | locator: createLocator(cid.multihash, { 532 | error: { 533 | name: 'Aborted', 534 | message: 'Aborted', 535 | digest: cid.multihash.bytes 536 | } 537 | }), 538 | delegationsStorage: createDelegationStorage([ 539 | await serve.transportHttp.delegate({ 540 | issuer: space, 541 | audience: gatewayIdentity, 542 | with: space.did(), 543 | nb: { token: 'a1b2c3' } 544 | }) 545 | ]), 546 | delegationProofs: [], 547 | gatewaySigner, 548 | gatewayIdentity 549 | } 550 | ) 551 | ) 552 | 553 | expect(handler.notCalled).to.be.true 554 | expectToBeInstanceOf(error, Error) 555 | expect(error.message).to.equal('failed to locate: bafkqaaia') 556 | expect(error.cause).to.deep.equal({ 557 | name: 'Aborted', 558 | message: 'Aborted', 559 | digest: cid.multihash.bytes 560 | }) 561 | }) 562 | 563 | it('should throw an error if the locator has a network error', async () => { 564 | const handler = sinon.fake(innerHandler) 565 | const cid = createTestCID(0) 566 | const space = await ed25519.Signer.generate() 567 | 568 | const error = await rejection( 569 | withAuthorizedSpace(innerHandler)( 570 | request, 571 | {}, 572 | { 573 | ...context, 574 | dataCid: cid, 575 | authToken: 'a1b2c3', 576 | locator: createLocator(cid.multihash, { 577 | error: { 578 | name: 'NetworkError', 579 | message: 'Network error', 580 | url: 'http://example.com/blob' 581 | } 582 | }), 583 | delegationsStorage: createDelegationStorage([ 584 | await serve.transportHttp.delegate({ 585 | issuer: space, 586 | audience: gatewayIdentity, 587 | with: space.did(), 588 | nb: { token: 'a1b2c3' } 589 | }) 590 | ]), 591 | delegationProofs: [], 592 | gatewaySigner, 593 | gatewayIdentity 594 | } 595 | ) 596 | ) 597 | 598 | expect(handler.notCalled).to.be.true 599 | expectToBeInstanceOf(error, Error) 600 | expect(error.message).to.equal('failed to locate: bafkqaaia') 601 | expect(error.cause).to.deep.equal({ 602 | name: 'NetworkError', 603 | message: 'Network error', 604 | url: 'http://example.com/blob' 605 | }) 606 | }) 607 | 608 | it('should throw errors encountered by `delegationsStorage`', async () => { 609 | const cid = createTestCID(0) 610 | const space = await ed25519.Signer.generate() 611 | 612 | const ctx = { 613 | ...context, 614 | dataCid: cid, 615 | locator: createLocator(cid.multihash, { 616 | ok: { 617 | digest: cid.multihash, 618 | site: [ 619 | { 620 | location: [new URL('http://example.com/1/blob')], 621 | range: { offset: 0, length: 100 }, 622 | space: space.did() 623 | } 624 | ] 625 | }, 626 | error: undefined 627 | }), 628 | delegationsStorage: { 629 | find: async () => ({ 630 | error: { name: 'Weirdness', message: 'Something weird happened.' } 631 | }), 632 | store: async () => ({ 633 | error: { name: 'Weirdness', message: 'Something weird happened.' } 634 | }) 635 | }, 636 | gatewayIdentity 637 | } 638 | 639 | const ih = sinon.fake(innerHandler) 640 | const error = await rejection( 641 | withAuthorizedSpace(ih)( 642 | request, 643 | {}, 644 | { 645 | ...ctx, 646 | authToken: 'a1b2c3', 647 | delegationProofs: [], 648 | gatewaySigner 649 | } 650 | ) 651 | ) 652 | 653 | expect(ih.notCalled).to.be.true 654 | expectToBeInstanceOf(error, AggregateError) 655 | expect(error.errors.map((e) => e.name)).to.deep.equal(['Weirdness']) 656 | }) 657 | }) 658 | --------------------------------------------------------------------------------