From e669b5596aa65ec0c93b1cfd199217d64771aba4 Mon Sep 17 00:00:00 2001 From: Felipe Forbeck Date: Thu, 19 Dec 2024 11:29:53 -0300 Subject: [PATCH] feat(w3up-client): add default gateway authorization (#1604) ### Context While updating the `web3-storage` docs, `w3cli`, and `@storacha/client`, I found it inconvenient to repeatedly define the gateway DID and URL every time I created a space. Also, if we don't provide the `skipGatewayAuthorization=true`, it will throw an error saying it is required to provide the gateway services to authorize. ### Changes Following @travis suggestion (see: https://github.com/storacha/docs/pull/21#discussion_r1883075639), I've updated the client to automatically authorize the Storacha Gateway (Production) by default if `skipGatewayAuthorization` is not set to `true` and `authorizeGatewayServices` is not provided or is empty. Once approved, this change will need to be ported to [upload-service/w3up-client](https://github.com/storacha/upload-service/tree/main/packages/w3up-client). And the PR https://github.com/storacha/freeway/pull/135 needs to be merged before this change gets released. --- packages/w3up-client/package.json | 1 + packages/w3up-client/src/account.js | 4 +- packages/w3up-client/src/client.js | 39 +++++++++--- packages/w3up-client/test/client.test.js | 30 ++++----- .../test/helpers/gateway-server.js | 61 +++++++++++++++++++ packages/w3up-client/test/mocks/service.js | 21 +++++-- 6 files changed, 122 insertions(+), 34 deletions(-) create mode 100644 packages/w3up-client/test/helpers/gateway-server.js diff --git a/packages/w3up-client/package.json b/packages/w3up-client/package.json index 0fb38e4a6..3fdafa870 100644 --- a/packages/w3up-client/package.json +++ b/packages/w3up-client/package.json @@ -140,6 +140,7 @@ "mock": "run-p mock:*", "mock:bucket-200": "PORT=8989 STATUS=200 node test/helpers/bucket-server.js", "mock:receipts-server": "PORT=9201 node test/helpers/receipts-server.js", + "mock:gateway-server": "PORT=5001 node test/helpers/gateway-server.js", "coverage": "c8 report -r html && open coverage/index.html", "rc": "npm version prerelease --preid rc", "docs": "npm run build && typedoc --out docs-generated" diff --git a/packages/w3up-client/src/account.js b/packages/w3up-client/src/account.js index c6e418202..997cdb942 100644 --- a/packages/w3up-client/src/account.js +++ b/packages/w3up-client/src/account.js @@ -242,8 +242,8 @@ export class AccountPlan { * or when the abort signal is aborted. * * @param {object} [options] - * @param {number} [options.interval=1000] - The polling interval in milliseconds (default is 1000ms). - * @param {number} [options.timeout=900000] - The maximum time to wait in milliseconds before throwing a timeout error (default is 15 minutes). + * @param {number} [options.interval] - The polling interval in milliseconds (default is 1000ms). + * @param {number} [options.timeout] - The maximum time to wait in milliseconds before throwing a timeout error (default is 15 minutes). * @param {AbortSignal} [options.signal] - An optional AbortSignal to cancel the waiting process. * @returns {Promise} - Resolves once a payment plan is selected within the timeout. * @throws {Error} - Throws an error if there is an issue retrieving the payment plan or if the timeout is exceeded. diff --git a/packages/w3up-client/src/client.js b/packages/w3up-client/src/client.js index b24c5ac2c..31874e64c 100644 --- a/packages/w3up-client/src/client.js +++ b/packages/w3up-client/src/client.js @@ -30,6 +30,9 @@ import { FilecoinClient } from './capability/filecoin.js' import { CouponAPI } from './coupon.js' export * as Access from './capability/access.js' import * as Result from './result.js' +import * as UcantoClient from '@ucanto/client' +import { HTTP } from '@ucanto/transport' +import * as CAR from '@ucanto/transport/car' export { AccessClient, @@ -255,6 +258,8 @@ export class Client extends Base { * In addition, it authorizes the listed Gateway Services to serve content from the created space. * It is done by delegating the `space/content/serve/*` capability to the Gateway Service. * User can skip the Gateway authorization by setting the `skipGatewayAuthorization` option to `true`. + * If no gateways are specified or the `skipGatewayAuthorization` flag is not set, the client will automatically grant access + * to the Storacha Gateway by default (https://freewaying.dag.haus/). * * @typedef {import('./types.js').ConnectionView} ConnectionView * @@ -304,16 +309,30 @@ export class Client extends Base { // Authorize the listed Gateway Services to serve content from the created space if (options.skipGatewayAuthorization !== true) { - if ( - !options.authorizeGatewayServices || - options.authorizeGatewayServices.length === 0 - ) { - throw new Error( - 'failed to authorize Gateway Services: missing option' - ) + let authorizeGatewayServices = options.authorizeGatewayServices + if (!authorizeGatewayServices || authorizeGatewayServices.length === 0) { + // If no Gateway Services are provided, authorize the Storacha Gateway Service + authorizeGatewayServices = [ + UcantoClient.connect({ + id: { + did: () => + /** @type {`did:${string}:${string}`} */ ( + /* c8 ignore next - default prod gateway id is not used in tests */ + process.env.DEFAULT_GATEWAY_ID ?? 'did:web:w3s.link' + ), + }, + codec: CAR.outbound, + channel: HTTP.open({ + url: new URL( + /* c8 ignore next - default prod gateway url is not used in tests */ + process.env.DEFAULT_GATEWAY_URL ?? 'https://freeway.dag.haus' + ), + }), + }), + ] } - for (const serviceConnection of options.authorizeGatewayServices) { + for (const serviceConnection of authorizeGatewayServices) { await authorizeContentServe(this, space, serviceConnection) } } @@ -609,7 +628,9 @@ export const authorizeContentServe = async ( /* c8 ignore next 8 - can't mock this error */ if (verificationResult.out.error) { throw new Error( - `failed to publish delegation for audience ${options.audience}: ${verificationResult.out.error.message}`, + `failed to publish delegation for audience ${audience.did()}: ${ + verificationResult.out.error.message + }`, { cause: verificationResult.out.error, } diff --git a/packages/w3up-client/test/client.test.js b/packages/w3up-client/test/client.test.js index b64463bf4..401267a18 100644 --- a/packages/w3up-client/test/client.test.js +++ b/packages/w3up-client/test/client.test.js @@ -656,7 +656,7 @@ export const testClient = { assert.fail(error, 'should not throw when creating the space') } }, - 'should throw when the content serve authorization fails due to missing service configuration': + 'should authorize the Storacha Gateway Service when no Gateway Services are provided': async (assert, { mail, grantAccess, connection }) => { // Step 1: Create a client for Alice and login const aliceClient = new Client( @@ -679,23 +679,17 @@ export const testClient = { await grantAccess(message) const aliceAccount = await aliceLogin - try { - const spaceA = await aliceClient.createSpace( - 'authorize-gateway-space', - { - account: aliceAccount, - authorizeGatewayServices: [], // No services to authorize - } - ) - assert.fail(spaceA, 'should not create the space') - } catch (error) { - assert.match( - // @ts-expect-error - error.message, - /missing option/, - 'should throw when creating the space' - ) - } + process.env.DEFAULT_GATEWAY_ID = gateway.did() + process.env.DEFAULT_GATEWAY_URL = 'http://localhost:5001' + + const spaceA = await aliceClient.createSpace( + 'authorize-gateway-space', + { + account: aliceAccount, + authorizeGatewayServices: [], // If no Gateway Services are provided, authorize the Storacha Gateway Service + } + ) + assert.ok(spaceA, 'should create the space') }, 'should throw when content serve service can not process the invocation': async (assert, { mail, grantAccess, connection }) => { diff --git a/packages/w3up-client/test/helpers/gateway-server.js b/packages/w3up-client/test/helpers/gateway-server.js new file mode 100644 index 000000000..34dd7c814 --- /dev/null +++ b/packages/w3up-client/test/helpers/gateway-server.js @@ -0,0 +1,61 @@ +import { createServer } from 'node:http' +import { + createUcantoServer, + getContentServeMockService, +} from '../mocks/service.js' +import { gateway } from '../../../upload-api/test/helpers/utils.js' + +const port = 5001 + +const server = createServer(async (req, res) => { + res.setHeader('Access-Control-Allow-Origin', '*') + res.setHeader('Access-Control-Allow-Methods', '*') + res.setHeader('Access-Control-Allow-Headers', '*') + if (req.method === 'OPTIONS') return res.end() + + if (req.method === 'POST') { + const service = getContentServeMockService() + const server = createUcantoServer(gateway, service) + + const bodyBuffer = Buffer.concat(await collect(req)) + + const reqHeaders = /** @type {Record} */ ( + Object.fromEntries(Object.entries(req.headers)) + ) + + const { headers, body, status } = await server.request({ + body: new Uint8Array( + bodyBuffer.buffer, + bodyBuffer.byteOffset, + bodyBuffer.byteLength + ), + headers: reqHeaders, + }) + + for (const [key, value] of Object.entries(headers)) { + res.setHeader(key, value) + } + res.writeHead(status ?? 200) + res.end(body) + } + res.end() +}) + +/** @param {import('node:stream').Readable} stream */ +const collect = (stream) => { + return /** @type {Promise} */ ( + new Promise((resolve, reject) => { + const chunks = /** @type {Buffer[]} */ ([]) + stream.on('data', (chunk) => chunks.push(Buffer.from(chunk))) + stream.on('error', (err) => reject(err)) + stream.on('end', () => resolve(chunks)) + }) + ) +} + +// eslint-disable-next-line no-console +server.listen(port, () => + console.log(`[Mock] Gateway Server Listening on :${port}`) +) + +process.on('SIGTERM', () => process.exit(0)) diff --git a/packages/w3up-client/test/mocks/service.js b/packages/w3up-client/test/mocks/service.js index 4b8805fde..58d8a2e9f 100644 --- a/packages/w3up-client/test/mocks/service.js +++ b/packages/w3up-client/test/mocks/service.js @@ -12,7 +12,8 @@ import * as AccessCaps from '@web3-storage/capabilities' export function getContentServeMockService(result = { ok: {} }) { return { access: { - delegate: Server.provide(AccessCaps.Access.delegate, async () => { + delegate: Server.provide(AccessCaps.Access.delegate, async (data) => { + // console.log('Access Caps Delegate', data) return result }), }, @@ -20,19 +21,29 @@ export function getContentServeMockService(result = { ok: {} }) { } /** - * Generic function to create connection to any type of mock service with any type of signer id. + * Creates a new Ucanto server with the given options. * * @param {any} id * @param {any} service - * @param {string | undefined} [url] */ -export function getConnection(id, service, url = undefined) { - const server = Server.create({ +export function createUcantoServer(id, service) { + return Server.create({ id: id, service, codec: CAR.inbound, validateAuthorization: () => ({ ok: {} }), }) +} + +/** + * Generic function to create connection to any type of mock service with any type of signer id. + * + * @param {any} id + * @param {any} service + * @param {string | undefined} [url] + */ +export function getConnection(id, service, url = undefined) { + const server = createUcantoServer(id, service) const connection = Client.connect({ id: id, codec: CAR.outbound,