diff --git a/docs/ucan-handler.md b/docs/ucan-handler.md new file mode 100644 index 0000000..be94b89 --- /dev/null +++ b/docs/ucan-handler.md @@ -0,0 +1,50 @@ +### Gateway Content Serve Authorization Flow + +```mermaid +flowchart TD + subgraph Client Side + A[User] -->|Creates Space & Authorizes Gateway| B[w3up-client] + end + + subgraph Cloudflare Freeway Worker + C[Ucanto Server] + F[Content Server] + end + + subgraph Cloudflare KV Store + D[Content Serve Delegations] + end + + B -->|UCAN access/delegate| C + C -->E[UCAN Invocation Handler] + E -->|Stores Valid Delegation| D + F -->|Retrieves Delegation| D[Content Serve Delegations] +``` + +### Explanation +1. **User Interaction** + - The user interacts with the `w3up-client` to create a space and authorize the gateway to serve content. + +2. **UCAN Invocation** + - The `w3up-client` sends a UCAN invocation `access/delegate` to the Ucanto Server, providing the delegation details (`{ space, proofs }`). + - The request is processed by the UCAN Invocation Handler in the Cloudflare Freeway Worker. + +3. **Validation Steps** + - It validates that the delegation matches the expected capability (`space/content/serve/*`). + - It ensures the proof chain is valid. + +4. **Storing Delegation** + - After successful validation, the delegation is stored in the KV Store (`Content Serve Delegations Storage`) for further use. + +5. **Content Server Retrieval** + - The Freeway Worker retrieves the validated delegations from the KV Store to serve content for authorized spaces. + + +### Key Considerations +- **Mitigating DoS Attacks** + - By verifying that the space is provisioned before accepting the delegation, we can reduce the risk of abuse from unauthorized or irrelevant requests. + - We still need to implement this verification in another iteration. +- **Efficiency** + - This additional validation ensures only relevant delegations are processed and stored, minimizing resource waste. +- **Implementation** + - 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. diff --git a/scripts/delegate-serve.js b/scripts/delegate-serve.js index 76cbe13..956181f 100644 --- a/scripts/delegate-serve.js +++ b/scripts/delegate-serve.js @@ -2,7 +2,6 @@ import sade from 'sade' import { Schema } from '@ucanto/core' import { getClient } from '@storacha/cli/lib.js' import { Space } from '@web3-storage/capabilities' -import * as serve from '../src/capabilities/serve.js' const MailtoDID = /** @type {import('@ucanto/validator').StringSchema<`did:mailto:${string}:${string}`, unknown>} */ ( @@ -51,7 +50,7 @@ sade('delegate-serve.js [space]') if (proofs.length === 0) { throw new Error( - `No proofs found. Are you authorized to ${serve.star.can} ${space}?` + `No proofs found. Are you authorized to ${Space.contentServe.can} ${space}?` ) } diff --git a/src/capabilities/serve.js b/src/capabilities/serve.js index 4c63e3b..3187ce9 100644 --- a/src/capabilities/serve.js +++ b/src/capabilities/serve.js @@ -1,25 +1,5 @@ import { capability, Schema, DID, nullable, string } from '@ucanto/validator' -/** - * "Manage the serving of content owned by the subject Space." - * - * A Principal who may `space/content/serve/*` is permitted to perform all - * operations related to serving content owned by the Space, including actually - * serving it and recording egress charges. - */ -export const star = capability({ - can: 'space/content/serve/*', - /** - * The Space which contains the content. This Space will be charged egress - * fees if content is actually retrieved by way of this invocation. - */ - with: DID, - nb: Schema.struct({ - /** The authorization token, if any, used for this request. */ - token: nullable(string()) - }) -}) - /** * "Serve content owned by the subject Space over HTTP." * diff --git a/src/index.js b/src/index.js index 5fbfdfa..1f52d19 100644 --- a/src/index.js +++ b/src/index.js @@ -4,7 +4,7 @@ import { withCorsHeaders, withContentDispositionHeader, withErrorHandler, - createWithHttpMethod, + createWithHttpMethod as withHttpMethods, withCdnCache, withParsedIpfsUrl, withFixedLengthStream, @@ -26,6 +26,8 @@ import { withEgressClient, withAuthorizedSpace, withLocator, + withUcanInvocationHandler, + withDelegationsStorage, withDelegationStubs } from './middleware/index.js' import { instrument } from '@microlabs/otel-cf-workers' @@ -48,17 +50,26 @@ import { NoopSpanProcessor } from '@opentelemetry/sdk-trace-base' * The middleware stack */ const middleware = composeMiddleware( - // Prepare the Context + // Prepare the Context for all types of requests withCdnCache, withContext, withCorsHeaders, withVersionHeader, withErrorHandler, + withGatewayIdentity, + withDelegationsStorage, + + // Handle UCAN invocations (POST requests only) + withUcanInvocationHandler, + + // Handle Content Serve requests (GET and HEAD requests) + withHttpMethods('GET', 'HEAD'), + + // Prepare the Context for other types of requests withParsedIpfsUrl, - createWithHttpMethod('GET', 'HEAD'), withAuthToken, withLocator, - withGatewayIdentity, + // TODO: replace this with a handler to fetch the real delegations withDelegationStubs, diff --git a/src/middleware/index.js b/src/middleware/index.js index add1201..c6cb2b2 100644 --- a/src/middleware/index.js +++ b/src/middleware/index.js @@ -9,3 +9,5 @@ export { withEgressTracker } from './withEgressTracker.js' export { withEgressClient } from './withEgressClient.js' export { withDelegationStubs } from './withDelegationStubs.js' export { withGatewayIdentity } from './withGatewayIdentity.js' +export { withUcanInvocationHandler } from './withUcanInvocationHandler.js' +export { withDelegationsStorage } from './withDelegationsStorage.js' diff --git a/src/middleware/withAuthorizedSpace.js b/src/middleware/withAuthorizedSpace.js index 8e37cea..1e89534 100644 --- a/src/middleware/withAuthorizedSpace.js +++ b/src/middleware/withAuthorizedSpace.js @@ -2,14 +2,17 @@ import { Verifier } from '@ucanto/principal' import { ok, access, Unauthorized } from '@ucanto/validator' import { HttpError } from '@web3-storage/gateway-lib/util' import * as serve from '../capabilities/serve.js' +import { SpaceDID } from '@web3-storage/capabilities/utils' /** * @import * as Ucanto from '@ucanto/interface' * @import { IpfsUrlContext, Middleware } from '@web3-storage/gateway-lib' * @import { LocatorContext } from './withLocator.types.js' * @import { AuthTokenContext } from './withAuthToken.types.js' - * @import { SpaceContext, DelegationsStorageContext, DelegationProofsContext } from './withAuthorizedSpace.types.js' + * @import { SpaceContext } from './withAuthorizedSpace.types.js' + * @import { DelegationsStorageContext } from './withDelegationsStorage.types.js' * @import { GatewayIdentityContext } from './withGatewayIdentity.types.js' + * @import { DelegationProofsContext } from './withAuthorizedSpace.types.js' */ /** @@ -46,7 +49,7 @@ export function withAuthorizedSpace (handler) { ctx.authToken === null if (shouldServeLegacy) { - return handler(request, env, { ...ctx, space: null }) + return handler(request, env, ctx) } // These Spaces all have the content we're to serve, if we're allowed to. @@ -58,14 +61,14 @@ export function withAuthorizedSpace (handler) { // First space to successfully authorize is the one we'll use. const { space: selectedSpace, delegationProofs } = await Promise.any( spaces.map(async (space) => { - const result = await authorize(space, ctx) + const result = await authorize(SpaceDID.from(space), ctx) if (result.error) throw result.error return result.ok }) ) return handler(request, env, { ...ctx, - space: selectedSpace, + space: SpaceDID.from(selectedSpace), delegationProofs, locator: locator.scopeToSpaces([selectedSpace]) }) @@ -97,20 +100,15 @@ export function withAuthorizedSpace (handler) { * authorizing delegations in the * {@link DelegationsStorageContext.delegationsStorage}. * - * @param {Ucanto.DID} space + * @param {import('@web3-storage/capabilities/types').SpaceDID} space * @param {AuthTokenContext & DelegationsStorageContext & GatewayIdentityContext} ctx - * @returns {Promise>} + * @returns {Promise>} */ const authorize = async (space, ctx) => { // Look up delegations that might authorize us to serve the content. - const relevantDelegationsResult = await ctx.delegationsStorage.find({ - audience: ctx.gatewayIdentity.did(), - can: serve.transportHttp.can, - with: space - }) - + const relevantDelegationsResult = await ctx.delegationsStorage.find(space) if (relevantDelegationsResult.error) return relevantDelegationsResult - + const delegationProofs = relevantDelegationsResult.ok // Create an invocation of the serve capability. const invocation = await serve.transportHttp .invoke({ @@ -120,7 +118,7 @@ const authorize = async (space, ctx) => { nb: { token: ctx.authToken }, - proofs: relevantDelegationsResult.ok + proofs: delegationProofs }) .delegate() @@ -138,7 +136,7 @@ const authorize = async (space, ctx) => { return { ok: { space, - delegationProofs: relevantDelegationsResult.ok + delegationProofs } } } diff --git a/src/middleware/withAuthorizedSpace.types.ts b/src/middleware/withAuthorizedSpace.types.ts index 88c69ab..6a9166b 100644 --- a/src/middleware/withAuthorizedSpace.types.ts +++ b/src/middleware/withAuthorizedSpace.types.ts @@ -1,10 +1,6 @@ import * as Ucanto from '@ucanto/interface' import { Context as MiddlewareContext } from '@web3-storage/gateway-lib' - -export interface DelegationsStorageContext extends MiddlewareContext { - delegationsStorage: DelegationsStorage -} - +import { SpaceDID } from '@web3-storage/capabilities/types' export interface DelegationProofsContext extends MiddlewareContext { /** * The delegation proofs to use for the egress record @@ -12,33 +8,16 @@ export interface DelegationProofsContext extends MiddlewareContext { * must have delegated the right to the Gateway to serve content and record egress traffic. * The `space/content/serve/*` capability must be granted to the Gateway Web DID. */ - delegationProofs: Ucanto.Delegation[] + delegationProofs: Ucanto.Delegation[] } export interface SpaceContext extends MiddlewareContext { - space: Ucanto.DID | null -} - -// TEMP: https://github.com/storacha/blob-fetcher/pull/13/files -declare module '@web3-storage/blob-fetcher' { - interface Site { - space?: Ucanto.DID - } -} - -// TEMP - -export interface Query { - audience?: Ucanto.DID - can: string - with: Ucanto.Resource -} - -export interface DelegationsStorage { /** - * find all items that match the query + * The SpaceDID of the space that is authorized to serve the content from. + * If the space is not authorized, the request is considered a legacy request - which is served by default. + * The egress is not recorded for legacy requests because the space is unknown. + * Eventually, legacy requests will be aggressively throttled, forcing the users to migrate to authorized spaces. + * Then this field will become required and the legacy behavior will be removed. */ - find: ( - query: Query - ) => Promise> + space?: SpaceDID } diff --git a/src/middleware/withDelegationStubs.js b/src/middleware/withDelegationStubs.js index 1261c5e..0a97843 100644 --- a/src/middleware/withDelegationStubs.js +++ b/src/middleware/withDelegationStubs.js @@ -2,10 +2,11 @@ import { Delegation, Schema } from '@ucanto/core' /** * @import { +Environment, * Middleware, * Context as MiddlewareContext * } from '@web3-storage/gateway-lib' - * @import { DelegationsStorageContext } from './withAuthorizedSpace.types.js' + * @import { DelegationsStorageContext } from './withDelegationsStorage.types.js' * @import { LocatorContext } from './withLocator.types.js' * @import { GatewayIdentityContext } from './withGatewayIdentity.types.js' */ @@ -23,11 +24,18 @@ import { Delegation, Schema } from '@ucanto/core' * Middleware< * MiddlewareContext & LocatorContext & GatewayIdentityContext & DelegationsStorageContext, * MiddlewareContext & LocatorContext & GatewayIdentityContext, - * {} + * Environment & { FF_DELEGATIONS_STORAGE_ENABLED: string } * > * )} */ export const withDelegationStubs = (handler) => async (request, env, ctx) => { + if (env.FF_DELEGATIONS_STORAGE_ENABLED === 'true') { + // @ts-expect-error: If FF_DELEGATIONS_STORAGE_ENABLED is true, the context + // will have the delegationsStorage created by the withDelegationsStorage + // middleware. So we can skip the stubbing. + return handler(request, env, ctx) + } + const stubSpace = new URL(request.url).searchParams.get('stub_space') const stubDelegations = await Promise.all( new URL(request.url).searchParams @@ -48,7 +56,10 @@ export const withDelegationStubs = (handler) => async (request, env, ctx) => { return handler(request, env, { ...ctx, - delegationsStorage: { find: async () => ({ ok: stubDelegations }) }, + delegationsStorage: { + find: async () => ({ ok: stubDelegations }), + store: async () => ({ ok: {} }) + }, locator: stubSpace && Schema.did({ method: 'key' }).is(stubSpace) ? ctx.locator.scopeToSpaces([stubSpace]) diff --git a/src/middleware/withDelegationsStorage.js b/src/middleware/withDelegationsStorage.js new file mode 100644 index 0000000..c92bb9d --- /dev/null +++ b/src/middleware/withDelegationsStorage.js @@ -0,0 +1,142 @@ +import { Delegation } from '@ucanto/core' +import { ok, error, Failure } from '@ucanto/server' + +/** + * @import * as Ucanto from '@ucanto/interface' + * @import { Middleware } from '@web3-storage/gateway-lib' + * @typedef {import('./withDelegationsStorage.types.js').DelegationsStorageContext} DelegationsStorageContext + * @typedef {import('./withDelegationsStorage.types.js').DelegationsStorageEnvironment} DelegationsStorageEnvironment + */ + +/** + * Provides a delegations storage in the application context + * + * @type {( + * Middleware + * )} + */ +export const withDelegationsStorage = (handler) => async (request, env, ctx) => { + if (env.FF_DELEGATIONS_STORAGE_ENABLED !== 'true') { + return handler(request, env, ctx) + } + return handler(request, env, { + ...ctx, + delegationsStorage: createStorage(env) + }) +} + +/** + * @param {DelegationsStorageEnvironment} env + * @returns {import('./withDelegationsStorage.types.js').DelegationsStorage} + */ +function createStorage (env) { + return { + /** + * Finds the delegation proofs for the given space + * + * @param {import('@web3-storage/capabilities/types').SpaceDID} space + * @returns {Promise[], DelegationNotFound | Ucanto.Failure>>} + */ + find: async (space) => { + /** @type {Ucanto.Delegation[]} */ + const delegations = [] + const result = await env.CONTENT_SERVE_DELEGATIONS_STORE.list({ prefix: space }) + result.keys.forEach(async (key) => { + const delegation = await env.CONTENT_SERVE_DELEGATIONS_STORE.get(key.name, 'arrayBuffer') + if (delegation) { + const d = await Delegation.extract(new Uint8Array(delegation)) + if (d.ok) delegations.push(d.ok) + } + }) + return ok(delegations) + }, + + /** + * Stores the delegation proofs for the given space. + * If the delegation has an expiration, it will be stored with an expiration time in seconds since unix epoch. + * + * @param {import('@web3-storage/capabilities/types').SpaceDID} space + * @param {Ucanto.Delegation} delegation + * @returns {Promise>} + */ + store: async (space, delegation) => { + let options = {} + if (delegation.expiration && delegation.expiration > 0 && delegation.expiration !== Infinity) { + // expire the key-value pair when the delegation expires (seconds since epoch) + options = { expiration: delegation.expiration } + } + + const value = await delegation.archive() + if (value.error) { + console.error('error while archiving delegation', value.error) + return value + } + + try { + await env.CONTENT_SERVE_DELEGATIONS_STORE.put(`${space}:${delegation.cid.toString()}`, value.ok.buffer, options) + return ok({}) + } catch (/** @type {any} */ err) { + const message = `error while storing delegation for space ${space}` + console.error(message, err) + return error(new StoreOperationFailed(message)) + } + } + } +} + +export class InvalidDelegation extends Failure { + static name = /** @type {const} */ ('InvalidDelegation') + #reason + + /** @param {string} [reason] */ + constructor (reason) { + super() + this.#reason = reason + } + + get name () { + return InvalidDelegation.name + } + + describe () { + return this.#reason ?? 'Invalid delegation' + } +} + +export class DelegationNotFound extends Failure { + static name = /** @type {const} */ ('DelegationNotFound') + #reason + + /** @param {string} [reason] */ + constructor (reason) { + super() + this.#reason = reason + } + + get name () { + return DelegationNotFound.name + } + + describe () { + return this.#reason ?? 'Delegation not found' + } +} + +export class StoreOperationFailed extends Failure { + static name = /** @type {const} */ ('StoreOperationFailed') + #reason + + /** @param {string} [reason] */ + constructor (reason) { + super() + this.#reason = reason + } + + get name () { + return StoreOperationFailed.name + } + + describe () { + return this.#reason ?? 'Store operation failed' + } +} diff --git a/src/middleware/withDelegationsStorage.types.ts b/src/middleware/withDelegationsStorage.types.ts new file mode 100644 index 0000000..50a7563 --- /dev/null +++ b/src/middleware/withDelegationsStorage.types.ts @@ -0,0 +1,41 @@ +import * as Ucanto from '@ucanto/interface' +import { Environment as MiddlewareEnvironment, Context as MiddlewareContext } from '@web3-storage/gateway-lib' +import { KVNamespace } from '@cloudflare/workers-types' +import { GatewayIdentityContext } from './withGatewayIdentity.types.js' +import { StoreOperationFailed, DelegationNotFound } from './withDelegationsStorage.js' +import { SpaceDID } from '@web3-storage/capabilities/types' + +export interface DelegationsStorageEnvironment extends MiddlewareEnvironment { + CONTENT_SERVE_DELEGATIONS_STORE: KVNamespace + FF_DELEGATIONS_STORAGE_ENABLED: string +} + +export interface DelegationsStorageContext + extends MiddlewareContext, + GatewayIdentityContext { + delegationsStorage: DelegationsStorage +} + +export interface DelegationsStorage { + /** + * Finds the delegation proofs for the given space + * + * @param {import('@web3-storage/capabilities/types').SpaceDID} space + * @returns {Promise[], DelegationNotFound | Ucanto.Failure>>} + */ + find: ( + space: SpaceDID + ) => Promise[], DelegationNotFound | Ucanto.Failure>> + + /** + * Stores the delegation proofs for the given space + * + * @param {import('@web3-storage/capabilities/types').SpaceDID} space + * @param {Ucanto.Delegation} delegation + * @returns {Promise>} + */ + store: ( + space: SpaceDID, + delegation: Ucanto.Delegation + ) => Promise> +} diff --git a/src/middleware/withEgressClient.types.ts b/src/middleware/withEgressClient.types.ts index 2600594..cbb818a 100644 --- a/src/middleware/withEgressClient.types.ts +++ b/src/middleware/withEgressClient.types.ts @@ -1,7 +1,7 @@ import { Environment as MiddlewareEnvironment, Context as MiddlewareContext } from '@web3-storage/gateway-lib' import { DIDKey, UnknownLink } from '@ucanto/principal/ed25519' import { GatewayIdentityContext } from './withGatewayIdentity.types.js' -import { DelegationsStorageContext, DelegationProofsContext } from './withAuthorizedSpace.types.js' +import { DelegationProofsContext } from './withAuthorizedSpace.types.js' export interface Environment extends MiddlewareEnvironment { FF_EGRESS_TRACKER_ENABLED: string @@ -13,7 +13,6 @@ export interface Environment extends MiddlewareEnvironment { export interface EgressClientContext extends MiddlewareContext, GatewayIdentityContext, - DelegationsStorageContext, DelegationProofsContext { egressClient: EgressClient } diff --git a/src/middleware/withRateLimit.types.ts b/src/middleware/withRateLimit.types.ts index 284bfc8..909d6be 100644 --- a/src/middleware/withRateLimit.types.ts +++ b/src/middleware/withRateLimit.types.ts @@ -2,7 +2,6 @@ import { CID } from '@web3-storage/gateway-lib/handlers' import { IpfsUrlContext, Environment as MiddlewareEnvironment } from '@web3-storage/gateway-lib' import { KVNamespace, RateLimit } from '@cloudflare/workers-types' import { RATE_LIMIT_EXCEEDED } from '../constants.js' -import { EgressClient } from './withEgressClient.types.js' export interface Environment extends MiddlewareEnvironment { RATE_LIMITER: RateLimit diff --git a/src/middleware/withUcanInvocationHandler.js b/src/middleware/withUcanInvocationHandler.js new file mode 100644 index 0000000..0504bdd --- /dev/null +++ b/src/middleware/withUcanInvocationHandler.js @@ -0,0 +1,35 @@ +import { createServer } from '../server/index.js' +import { createService } from '../server/service.js' + +/** + * @import { Middleware } from '@web3-storage/gateway-lib' + * @import { + * Environment, + * Context, + * } from './withUcanInvocationHandler.types.js' + * @typedef {Context} UcanInvocationContext + */ + +/** + * The withUcanInvocationHandler middleware is used to handle UCAN invocation requests to the Freeway Gateway. + * It supports only POST requests to the root path. Any other requests are passed through. + * + * @type {Middleware} + */ +export function withUcanInvocationHandler (handler) { + return async (request, env, ctx) => { + if (request.method !== 'POST' || new URL(request.url).pathname !== '/') { + return handler(request, env, ctx) + } + + const service = ctx.service ?? createService(ctx) + const server = ctx.server ?? createServer(ctx, service) + + const { headers, body, status } = await server.request({ + body: new Uint8Array(await request.arrayBuffer()), + headers: Object.fromEntries(request.headers) + }) + + return new Response(body, { headers, status: status ?? 200 }) + } +} diff --git a/src/middleware/withUcanInvocationHandler.types.ts b/src/middleware/withUcanInvocationHandler.types.ts new file mode 100644 index 0000000..a313234 --- /dev/null +++ b/src/middleware/withUcanInvocationHandler.types.ts @@ -0,0 +1,28 @@ +import { Environment as MiddlewareEnvironment, Context as MiddlewareContext } from '@web3-storage/gateway-lib' +import { GatewayIdentityContext } from './withGatewayIdentity.types.js' +import { DelegationsStorageContext } from './withDelegationsStorage.types.js' +import { Service } from '../server/api.types.js' +import * as Server from '@ucanto/server' +export interface Environment extends MiddlewareEnvironment { +} + +export interface Context + extends MiddlewareContext, + GatewayIdentityContext, + DelegationsStorageContext { + /** + * This is optional because the handler is responsible for creating the service if it is not provided. + * + * @template T + * @type {Service} + */ + service?: Service + + /** + * This is optional because the handler is responsible for creating the server if it is not provided. + * + * @template U + * @type {Server.ServerView>} + */ + server?: Server.ServerView> +} diff --git a/src/server/api.types.ts b/src/server/api.types.ts new file mode 100644 index 0000000..b1553b9 --- /dev/null +++ b/src/server/api.types.ts @@ -0,0 +1,13 @@ +import { InferInvokedCapability, ServiceMethod, Unit } from '@ucanto/interface'; +import { Failure } from '@ucanto/interface'; +import { Access as AccessCapabilities } from '@web3-storage/capabilities'; + +export type AccessDelegation = InferInvokedCapability + +export interface Service { + access: ContentServeAuthService +} + +export interface ContentServeAuthService { + delegate: ServiceMethod +} diff --git a/src/server/index.js b/src/server/index.js new file mode 100644 index 0000000..7b1ecae --- /dev/null +++ b/src/server/index.js @@ -0,0 +1,20 @@ +import * as Server from '@ucanto/server' +import * as CAR from '@ucanto/transport/car' + +/** + * Creates a UCAN server. + * + * @template T + * @param {import('../middleware/withUcanInvocationHandler.types.js').Context} ctx + * @param {import('./api.types.js').Service} service + */ +export function createServer (ctx, service) { + return Server.create({ + id: ctx.gatewaySigner, + codec: CAR.inbound, + service, + catch: err => console.error(err), + // TODO: wire into revocations + validateAuthorization: () => ({ ok: {} }) + }) +} diff --git a/src/server/service.js b/src/server/service.js new file mode 100644 index 0000000..68b30cd --- /dev/null +++ b/src/server/service.js @@ -0,0 +1,59 @@ +import { + Access as AccessCapabilities, + Space as SpaceCapabilities +} from '@web3-storage/capabilities' +import { extractContentServeDelegations } from './utils.js' +import { claim, Schema } from '@ucanto/validator' +import * as UcantoServer from '@ucanto/server' +import { ok } from '@ucanto/client' + +/** + * @template T + * @param {import('../middleware/withUcanInvocationHandler.types.js').Context} ctx + * @returns {import('./api.types.js').Service} + */ +export function createService (ctx) { + return { + access: { + delegate: UcantoServer.provideAdvanced( + { + capability: AccessCapabilities.delegate, + audience: Schema.did({ method: 'web' }), + handler: async ({ capability, invocation, context }) => { + const result = extractContentServeDelegations(capability, invocation.proofs) + if (result.error) { + console.error('error while extracting delegation', result.error) + return result + } + + const delegations = result.ok + const validationResults = await Promise.all(delegations.map(async (delegation) => { + const validationResult = await claim( + SpaceCapabilities.contentServe, + [delegation], + { + ...context, + authority: ctx.gatewayIdentity + } + ) + if (validationResult.error) { + console.error('error while validating delegation', validationResult.error) + return validationResult + } + + const space = capability.with + return ctx.delegationsStorage.store(space, delegation) + })) + + const errorResult = validationResults.find(result => result.error) + if (errorResult) { + return errorResult + } + + return ok({}) + } + + }) + } + } +} diff --git a/src/server/utils.js b/src/server/utils.js new file mode 100644 index 0000000..7fa6ae4 --- /dev/null +++ b/src/server/utils.js @@ -0,0 +1,33 @@ +import { Space as SpaceCapabilities } from '@web3-storage/capabilities' +import { InvalidDelegation } from '../middleware/withDelegationsStorage.js' + +/** + * Checks if the space/content/serve/* delegation is for the gateway and it is not expired. + * + * @param {import('@ucanto/interface').InferInvokedCapability} capability - The capability to validate + * @param {import('@ucanto/interface').Proof[]} proofs - The proofs to validate + */ +export const extractContentServeDelegations = (capability, proofs) => { + const nbDelegations = new Set(Object.values(capability.nb.delegations)) + if (nbDelegations.size !== 1) { + return { error: new InvalidDelegation('nb.delegations has more than one delegation') } + } + const delegations = [] + for (const delegationLink of nbDelegations) { + const proofDelegations = proofs.flatMap((proof) => 'capabilities' in proof ? [proof] : []) + const delegationProof = proofDelegations.find((p) => delegationLink.equals(p.cid)) + if (!delegationProof) { + return { error: new InvalidDelegation(`delegation not found in proofs: ${delegationLink}`) } + } + + if (!delegationProof.capabilities.some((c) => c.can === SpaceCapabilities.contentServe.can)) { + return { + error: new InvalidDelegation( + `delegation does not contain ${SpaceCapabilities.contentServe.can} capability` + ) + } + } + delegations.push(delegationProof) + } + return { ok: delegations } +} diff --git a/test/unit/middleware/withAuthorizedSpace.spec.js b/test/unit/middleware/withAuthorizedSpace.spec.js index a49aa8e..8bcddcd 100644 --- a/test/unit/middleware/withAuthorizedSpace.spec.js +++ b/test/unit/middleware/withAuthorizedSpace.spec.js @@ -25,7 +25,9 @@ import * as serve from '../../../src/capabilities/serve.js' * Environment as MiddlewareEnvironment, * IpfsUrlContext * } from '@web3-storage/gateway-lib' - * @import { DelegationsStorage, SpaceContext } from '../../../src/middleware/withAuthorizedSpace.types.js' + * @import { SpaceContext } from '../../../src/middleware/withAuthorizedSpace.types.js' + * @import { DelegationsStorage, DelegationsStorageContext } from '../../../src/middleware/withDelegationsStorage.types.js' + * @import { DelegationProofsContext } from '../../../src/middleware/withAuthorizedSpace.types.js' * @import { LocatorContext } from '../../../src/middleware/withLocator.types.js' * @import { AuthTokenContext } from '../../../src/middleware/withAuthToken.types.js' */ @@ -54,7 +56,7 @@ const innerHandler = async (_req, _env, ctx) => { const request = new Request('http://example.com/') const context = { - waitUntil: () => {}, + waitUntil: () => { }, path: '', searchParams: new URLSearchParams() } @@ -118,18 +120,14 @@ const gatewayIdentity = gatewaySigner.withDID('did:web:test.w3s.link') * @returns {DelegationsStorage} * */ const createDelegationStorage = (delegations) => ({ - find: async (query) => ({ - ok: delegations.filter((delegation) => { - return ( - (!query.audience || delegation.audience.did() === query.audience) && - delegation.capabilities.some( - (cap) => - (!query.can || cap.can === query.can) && - (!query.with || cap.with === query.with) - ) + find: async (space) => { + return { + ok: delegations.filter((d) => + d.capabilities.some((cap) => cap.with === space) ) - }) - }) + } + }, + store: async (space, delegation) => ({ error: new Error('Not implemented') }) }) describe('withAuthorizedSpace', async () => { @@ -398,7 +396,6 @@ describe('withAuthorizedSpace', async () => { expect(await responseWithoutToken.json()).to.deep.equal({ CID: cid.toString(), - Space: null, Token: null, URLs: ['http://example.com/blob'] }) @@ -577,6 +574,9 @@ describe('withAuthorizedSpace', async () => { delegationsStorage: { find: async () => ({ error: { name: 'Weirdness', message: 'Something weird happened.' } + }), + store: async () => ({ + error: { name: 'Weirdness', message: 'Something weird happened.' } }) }, gatewayIdentity @@ -587,7 +587,12 @@ describe('withAuthorizedSpace', async () => { withAuthorizedSpace(ih)( request, {}, - { ...ctx, authToken: 'a1b2c3', delegationProofs: [], gatewaySigner } + { + ...ctx, + authToken: 'a1b2c3', + delegationProofs: [], + gatewaySigner + } ) ) diff --git a/test/unit/middleware/withDelegationsStorage.spec.js b/test/unit/middleware/withDelegationsStorage.spec.js new file mode 100644 index 0000000..93fd84c --- /dev/null +++ b/test/unit/middleware/withDelegationsStorage.spec.js @@ -0,0 +1,89 @@ +/* eslint-disable no-unused-expressions + --- + `no-unused-expressions` doesn't understand that several of Chai's assertions + are implemented as getters rather than explicit function calls; it thinks + the assertions are unused expressions. */ +import { describe, it, afterEach } from 'mocha' +import { expect } from 'chai' +import sinon from 'sinon' +import { ed25519 } from '@ucanto/principal' +import { withDelegationsStorage } from '../../../src/middleware/withDelegationsStorage.js' + +const kvStoreMock = { + get: sinon.stub(), + list: sinon.stub(), + put: sinon.stub(), + getWithMetadata: sinon.stub(), + delete: sinon.stub() +} + +/** + * @typedef {import('../../../src/middleware/withDelegationsStorage.types.js').DelegationsStorageEnvironment} DelegationsStorageEnvironment + * @typedef {import('../../../src/middleware/withDelegationsStorage.types.js').DelegationsStorageContext} DelegationsStorageContext + */ + +const gatewaySigner = (await ed25519.Signer.generate()).signer +const gatewayIdentity = gatewaySigner.withDID('did:web:test.w3s.link') + +const ctx = + /** @satisfies {DelegationsStorageContext} */ + ({ + gatewaySigner, + gatewayIdentity, + waitUntil: async (promise) => { + try { + await promise + } catch (error) { + // Ignore errors. + } + }, + delegationsStorage: { + find: sinon.stub(), + store: sinon.stub() + } + }) + +describe('withDelegationsStorage', async () => { + afterEach(() => { + kvStoreMock.get.resetHistory() + }) + + describe('-> Successful Requests', () => { + it('should set delegationsStorage in context when FF_DELEGATIONS_STORAGE_ENABLED is true', async () => { + const mockHandler = sinon.fake((request, env, ctx) => ctx) + const request = new Request('http://example.com/') + const env = { + FF_DELEGATIONS_STORAGE_ENABLED: 'true', + CONTENT_SERVE_DELEGATIONS_STORE: kvStoreMock + } + + await withDelegationsStorage(mockHandler)(request, env, { + ...ctx, + // @ts-expect-error - we are testing the case where delegationsStorage is set + delegationsStorage: undefined + }) + expect(mockHandler.calledOnce).to.be.true + expect(mockHandler.firstCall.args[2]).to.have.property('delegationsStorage') + expect(mockHandler.firstCall.args[2].delegationsStorage).to.be.an('object') + }) + }) + + it('should not set delegationsStorage in context when FF_DELEGATIONS_STORAGE_ENABLED is not true', async () => { + const mockHandler = sinon.fake((request, env, ctx) => ctx) + const request = new Request('http://example.com/') + const env = { + FF_DELEGATIONS_STORAGE_ENABLED: 'false', + CONTENT_SERVE_DELEGATIONS_STORE: kvStoreMock + } + + await withDelegationsStorage(mockHandler)(request, env, { + ...ctx, + // @ts-expect-error - we are testing the case where delegationsStorage is not set + delegationsStorage: undefined + }) + + expect(mockHandler.calledOnce).to.be.true + expect(mockHandler.firstCall.args[2]).to.have.property('delegationsStorage') + expect(mockHandler.firstCall.args[2].delegationsStorage).to.be.undefined + }) +}) diff --git a/test/unit/middleware/withEgressTracker.spec.js b/test/unit/middleware/withEgressTracker.spec.js index bb53caa..1db388a 100644 --- a/test/unit/middleware/withEgressTracker.spec.js +++ b/test/unit/middleware/withEgressTracker.spec.js @@ -44,8 +44,6 @@ const EgressClient = () => { } const gatewaySigner = (await ed25519.Signer.generate()).signer const gatewayIdentity = gatewaySigner.withDID('did:web:test.w3s.link') -/** @type {import('@ucanto/interface').Delegation[]} */ -const stubDelegations = [] const ctx = /** @satisfies {EgressTrackerContext} */ @@ -54,7 +52,6 @@ const ctx = dataCid: CID.parse('bafybeibv7vzycdcnydl5n5lbws6lul2omkm6a6b5wmqt77sicrwnhesy7y'), gatewaySigner, gatewayIdentity, - delegationsStorage: { find: async () => ({ ok: stubDelegations }) }, delegationProofs: [], waitUntil: async (promise) => { try { diff --git a/test/unit/middleware/withUcanInvocationHandler.spec.js b/test/unit/middleware/withUcanInvocationHandler.spec.js new file mode 100644 index 0000000..ba790be --- /dev/null +++ b/test/unit/middleware/withUcanInvocationHandler.spec.js @@ -0,0 +1,139 @@ +/* eslint-disable no-unused-expressions + --- + `no-unused-expressions` doesn't understand that several of Chai's assertions + are implemented as getters rather than explicit function calls; it thinks + the assertions are unused expressions. */ +import { describe, it, afterEach } from 'mocha' +import { expect } from 'chai' +import sinon from 'sinon' +import { ed25519 } from '@ucanto/principal' +import { withUcanInvocationHandler } from '../../../src/middleware/withUcanInvocationHandler.js' + +/** + * @typedef {import('../../../src/middleware/withUcanInvocationHandler.types.js').Environment} Environment + * @typedef {import('../../../src/middleware/withUcanInvocationHandler.types.js').Context} Context + */ + +const env = + /** @satisfies {Environment} */ + ({ + }) + +const gatewaySigner = (await ed25519.Signer.generate()).signer +const gatewayIdentity = gatewaySigner.withDID('did:web:test.w3s.link') +const serviceStub = { + access: { + delegate: sinon.stub().resolves({ ok: {} }) + } +} +const serverStub = { + request: sinon.stub().returns({ + headers: {}, + body: crypto.getRandomValues(new Uint8Array(10)), + status: 200 + }), + id: gatewayIdentity, + service: serviceStub, + codec: { accept: sinon.stub() }, + validateAuthorization: sinon.stub() +} + +const ctx = + /** @satisfies {Context} */ + ({ + gatewaySigner, + gatewayIdentity, + waitUntil: async (promise) => { + try { + await promise + } catch (error) { + // Ignore errors. + } + }, + delegationsStorage: { + find: sinon.stub(), + store: sinon.stub() + } + }) + +describe('withUcanInvocationHandler', () => { + afterEach(() => { + serviceStub.access.delegate.reset() + serverStub.request.reset() + }) + + it('should handle POST requests to the root path', async () => { + const mockHandler = sinon.stub().callsFake((request, env, ctx) => { + return { + headers: {}, + body: crypto.getRandomValues(new Uint8Array(10)), + status: 200 + } + }) + + const handler = withUcanInvocationHandler(mockHandler) + const request = new Request('http://example.com/', { method: 'POST' }) + const response = await handler(request, env, { + ...ctx, + // @ts-expect-error - TODO: fix the type + server: serverStub, + service: serviceStub + }) + + expect(response).to.be.an.instanceOf(Response) + expect(response.status).to.equal(200) + expect(serverStub.request.called).to.be.true + expect(mockHandler.calledOnceWith(request, env, ctx)).to.be.false + }) + + it('should pass through non-POST requests', async () => { + const content = crypto.getRandomValues(new Uint8Array(10)) + const mockHandler = sinon.stub().callsFake((request, env, ctx) => { + return { + headers: {}, + body: content, + status: 200 + } + }) + + const handler = withUcanInvocationHandler(mockHandler) + const request = new Request('http://example.com/', { method: 'GET' }) + const response = await handler(request, env, { + ...ctx, + // @ts-expect-error - TODO: fix the type + server: serverStub, + service: serviceStub + }) + + expect(response.status).to.equal(200) + expect(response.body).to.equal(content) + expect(mockHandler.called).to.be.true + expect(serverStub.request.called).to.be.false + }) + + it('should pass through POST requests to non-root paths', async () => { + const content = crypto.getRandomValues(new Uint8Array(10)) + const mockHandler = sinon.stub().callsFake((request, env, ctx) => { + return { + headers: {}, + body: content, + status: 200 + } + }) + + const path = 'other' + const handler = withUcanInvocationHandler(mockHandler) + const request = new Request(`http://example.com/${path}`, { method: 'POST' }) + const response = await handler(request, env, { + ...ctx, + // @ts-expect-error - TODO: fix the type + server: serverStub, + service: serviceStub + }) + + expect(response.status).to.equal(200) + expect(response.body).to.equal(content) + expect(mockHandler.called).to.be.true + expect(serverStub.request.called).to.be.false + }) +}) diff --git a/tsconfig.json b/tsconfig.json index ed30c85..7fa3406 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -14,7 +14,7 @@ "moduleResolution": "NodeNext", "skipLibCheck": true, "resolveJsonModule": true, - "lib": ["ES2022", "DOM"], + "lib": ["ES2022", "DOM", "DOM.Iterable"], "target": "ES2022", "sourceMap": true } diff --git a/wrangler.toml b/wrangler.toml index 3bafde8..938eeb5 100644 --- a/wrangler.toml +++ b/wrangler.toml @@ -40,6 +40,10 @@ route = { pattern = "https://freeway.dag.haus/*", zone_id = "f2f8a5b1c557202c6e3 r2_buckets = [ { binding = "CARPARK", bucket_name = "carpark-prod-0" } ] +kv_namespaces = [ + { binding = "AUTH_TOKEN_METADATA", id = "f848730e45d94f17bcaf3b6d0915da40" }, + { binding = "CONTENT_SERVE_DELEGATIONS_STORE", id = "b2984f16c21e4991a644683c00d80033" } +] [env.production.build] command = "npm run build" @@ -76,6 +80,10 @@ UPLOAD_SERVICE_DID = "did:web:staging.web3.storage" CONTENT_CLAIMS_SERVICE_URL = "https://staging.claims.web3.storage" UPLOAD_API_URL = "https://staging.up.web3.storage" CARPARK_PUBLIC_BUCKET_URL = "https://carpark-staging-0.r2.w3s.link" +kv_namespaces = [ + { binding = "AUTH_TOKEN_METADATA", id = "b618bb05deb8493f944ef4a0f538030c" }, + { binding = "CONTENT_SERVE_DELEGATIONS_STORE", id = "99ae45f8b5b3478a9df09302c27e81a3" } +] # Test! [env.test] @@ -116,9 +124,6 @@ UPLOAD_API_URL = "https://staging.up.web3.storage" name = "freeway-fforbeck" workers_dev = true upload_source_maps = true -# Personal Account -# account_id = "9e46c5ddfefedb9bae5d81a0dd911e5a" -# Company Account account_id = "fffa4b4363a7e5250af8357087263b3a" # r2_buckets = [ # { binding = "CARPARK", bucket_name = "carpark-fforbeck-0", preview_bucket_name = "carpark-fforbeck-preview-0" } @@ -126,12 +131,17 @@ account_id = "fffa4b4363a7e5250af8357087263b3a" r2_buckets = [ { binding = "CARPARK", bucket_name = "carpark-staging-0" } ] +kv_namespaces = [ + { binding = "AUTH_TOKEN_METADATA", id = "b618bb05deb8493f944ef4a0f538030c" }, + { binding = "CONTENT_SERVE_DELEGATIONS_STORE", id = "26cc47fec09749bb9ee42bc6407f9a9d" } +] [env.fforbeck.vars] DEBUG = "true" FF_RATE_LIMITER_ENABLED = "false" FF_EGRESS_TRACKER_ENABLED = "true" FF_TELEMETRY_ENABLED = "true" +FF_DELEGATIONS_STORAGE_ENABLED = "true" GATEWAY_SERVICE_DID = "did:web:staging.w3s.link" UPLOAD_SERVICE_DID = "did:web:staging.web3.storage" CONTENT_CLAIMS_SERVICE_URL = "https://staging.claims.web3.storage" @@ -144,11 +154,6 @@ type = "ratelimit" namespace_id = "0" simple = { limit = 5, period = 60 } -[[env.fforbeck.kv_namespaces]] -binding = "AUTH_TOKEN_METADATA" -id = "f848730e45d94f17bcaf3b6d0915da40" - - [env.hannahhoward] name = "freeway-hannahhoward" workers_dev = true