diff --git a/packages/capabilities/src/types.ts b/packages/capabilities/src/types.ts index f3d48c6af..354665924 100644 --- a/packages/capabilities/src/types.ts +++ b/packages/capabilities/src/types.ts @@ -131,16 +131,6 @@ export type UsageReport = InferInvokedCapability export type UsageReportSuccess = Record export type UsageReportFailure = Ucanto.Failure -export type EgressRecord = InferInvokedCapability -export type EgressRecordSuccess = { - space: SpaceDID - resource: UnknownLink - bytes: number - servedAt: ISO8601Date - cause: UnknownLink -} -export type EgressRecordFailure = ConsumerNotFound | Ucanto.Failure - export interface UsageData { /** Provider the report concerns, e.g. `did:web:web3.storage` */ provider: ProviderDID @@ -284,6 +274,18 @@ export type RateLimitListFailure = Ucanto.Failure // Space export type Space = InferInvokedCapability export type SpaceInfo = InferInvokedCapability +export type SpaceContentServe = InferInvokedCapability< + typeof SpaceCaps.contentServe +> +export type EgressRecord = InferInvokedCapability +export type EgressRecordSuccess = { + space: SpaceDID + resource: UnknownLink + bytes: number + servedAt: ISO8601Date + cause: UnknownLink +} +export type EgressRecordFailure = ConsumerNotFound | Ucanto.Failure // filecoin export interface DealMetadata { @@ -895,6 +897,8 @@ export type ServiceAbilityArray = [ ProviderAdd['can'], Space['can'], SpaceInfo['can'], + SpaceContentServe['can'], + EgressRecord['can'], Upload['can'], UploadAdd['can'], UploadGet['can'], diff --git a/packages/filecoin-api/src/aggregator/events.js b/packages/filecoin-api/src/aggregator/events.js index 11e054731..7c2f82a53 100644 --- a/packages/filecoin-api/src/aggregator/events.js +++ b/packages/filecoin-api/src/aggregator/events.js @@ -229,7 +229,10 @@ export const handleAggregateInsertToPieceAcceptQueue = async ( // TODO: Batch per a maximum to queue const results = await map( pieces, - /** @returns {Promise>} */ + /** + * @param piece + * @returns {Promise>} + */ async piece => { const inclusionProof = aggregateBuilder.resolveProof(piece.link) if (inclusionProof.error) return inclusionProof diff --git a/packages/upload-api/src/access/claim.js b/packages/upload-api/src/access/claim.js index 6f5e2d513..ebd936630 100644 --- a/packages/upload-api/src/access/claim.js +++ b/packages/upload-api/src/access/claim.js @@ -13,6 +13,7 @@ export const provide = (ctx) => /** * Checks if the given Principal is an Account. + * * @param {API.Principal} principal * @returns {principal is API.Principal>} */ @@ -20,6 +21,7 @@ const isAccount = (principal) => principal.did().startsWith('did:mailto:') /** * Returns true when the delegation has a `ucan:*` capability. + * * @param {API.Delegation} delegation * @returns boolean */ diff --git a/packages/upload-api/src/lib.js b/packages/upload-api/src/lib.js index 1d866b6ab..efebc517a 100644 --- a/packages/upload-api/src/lib.js +++ b/packages/upload-api/src/lib.js @@ -147,8 +147,8 @@ export const execute = async (agent, input) => { * a receipt it will return receipt without running invocation. * * @template {Record} S - * @param {Types.Invocation} invocation * @param {Agent} agent + * @param {Types.Invocation} invocation */ export const run = async (agent, invocation) => { const cached = await agent.context.agentStore.receipts.get(invocation.link()) diff --git a/packages/upload-api/test/helpers/utils.js b/packages/upload-api/test/helpers/utils.js index 91e6c766e..e95be29e6 100644 --- a/packages/upload-api/test/helpers/utils.js +++ b/packages/upload-api/test/helpers/utils.js @@ -37,11 +37,13 @@ export const mallory = ed25519.parse( 'MgCYtH0AvYxiQwBG6+ZXcwlXywq9tI50G2mCAUJbwrrahkO0B0elFYkl3Ulf3Q3A/EvcVY0utb4etiSE8e6pi4H0FEmU=' ) +/** did:key:z6MkrZ1r5XBFZjBU34qyD8fueMbMRkKw17BZaq2ivKFjnz2z */ export const w3Signer = ed25519.parse( 'MgCYKXoHVy7Vk4/QjcEGi+MCqjntUiasxXJ8uJKY0qh11e+0Bs8WsdqGK7xothgrDzzWD0ME7ynPjz2okXDh8537lId8=' ) export const w3 = w3Signer.withDID('did:web:test.web3.storage') +/** did:key:z6MkuKJgV8DKxiAF5oaUcT8ckg8kZUoBe6yavSLnHn5ZgyAP */ export const gatewaySigner = ed25519.parse( 'MgCaNpGXCEX0+BxxE4SjSStrxU9Ru/Im+HGNQ/JJx3lDoI+0B3NWjWW3G8OzjbazZjanjM3kgfcZbvpyxv20jHtmcTtg=' ) diff --git a/packages/w3up-client/src/client.js b/packages/w3up-client/src/client.js index aebdd9d23..096dcdbce 100644 --- a/packages/w3up-client/src/client.js +++ b/packages/w3up-client/src/client.js @@ -5,10 +5,12 @@ import { Receipt, } from '@web3-storage/upload-client' import { + Access as AccessCapabilities, Blob as BlobCapabilities, Index as IndexCapabilities, Upload as UploadCapabilities, Filecoin as FilecoinCapabilities, + Space as SpaceCapabilities, } from '@web3-storage/capabilities' import * as DIDMailto from '@web3-storage/did-mailto' import { Base } from './base.js' @@ -246,19 +248,27 @@ export class Client extends Base { } /** - * Create a new space with a given name. + * Creates a new space with a given name. * If an account is not provided, the space is created without any delegation and is not saved, hence it is a temporary space. * When an account is provided in the options argument, then it creates a delegated recovery account * by provisioning the space, saving it and then delegating access to the recovery account. + * 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`. * - * @typedef {object} CreateOptions - * @property {Account.Account} [account] + * @typedef {import('./types.js').ConnectionView} ConnectionView * - * @param {string} name - * @param {CreateOptions} options + * @typedef {object} SpaceCreateOptions + * @property {Account.Account} [account] - The account configured as the recovery account for the space. + * @property {Array} [authorizeGatewayServices] - The DID Key or DID Web of the Gateway to authorize to serve content from the created space. + * @property {boolean} [skipGatewayAuthorization] - Whether to skip the Gateway authorization. It means that the content of the space will not be served by any Gateway. + * + * @param {string} name - The name of the space to create. + * @param {SpaceCreateOptions} options - Options for the space creation. * @returns {Promise} The created space owned by the agent. */ - async createSpace(name, options = {}) { + async createSpace(name, options) { + // Save the space to authorize the client to use the space const space = await this._agent.createSpace(name) const account = options.account @@ -279,18 +289,35 @@ export class Client extends Base { const recovery = await space.createRecovery(account.did()) // Delegate space access to the recovery - const result = await this.capability.access.delegate({ + const delegationResult = await this.capability.access.delegate({ space: space.did(), delegations: [recovery], }) - if (result.error) { + if (delegationResult.error) { + throw new Error( + `failed to authorize recovery account: ${delegationResult.error.message}`, + { cause: delegationResult.error } + ) + } + } + + // 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 recovery account: ${result.error.message}`, - { cause: result.error } + 'failed to authorize Gateway Services: missing option' ) } + + for (const serviceConnection of options.authorizeGatewayServices) { + await authorizeContentServe(this, space, serviceConnection) + } } + return space } @@ -525,3 +552,76 @@ export class Client extends Base { await this.capability.upload.remove(contentCID) } } + +/** + * Authorizes an audience to serve content from the provided space and record egress events. + * It also publishes the delegation to the content serve service. + * Delegates the following capabilities to the audience: + * - `space/content/serve/*` + * + * @param {Client} client - The w3up client instance. + * @param {import('./types.js').OwnedSpace} space - The space to authorize the audience for. + * @param {import('./types.js').ConnectionView} connection - The connection to the Content Serve Service that will handle, validate, and store the access/delegate UCAN invocation. + * @param {object} [options] - Options for the content serve authorization invocation. + * @param {`did:${string}:${string}`} [options.audience] - The Web DID of the audience (gateway or peer) to authorize. + * @param {number} [options.expiration] - The time at which the delegation expires in seconds from unix epoch. + */ +export const authorizeContentServe = async ( + client, + space, + connection, + options = {} +) => { + const currentSpace = client.currentSpace() + try { + // Set the current space to the space we are authorizing the gateway for, otherwise the delegation will fail + await client.setCurrentSpace(space.did()) + + /** @type {import('@ucanto/client').Principal<`did:${string}:${string}`>} */ + const audience = { + did: () => options.audience ?? connection.id.did(), + } + + // Grant the audience the ability to serve content from the space, it includes existing proofs automatically + const delegation = await client.createDelegation( + audience, + [SpaceCapabilities.contentServe.can], + { + expiration: options.expiration ?? Infinity, + } + ) + + // Publish the delegation to the content serve service + const accessProofs = client.proofs([ + { can: AccessCapabilities.access.can, with: space.did() }, + ]) + const verificationResult = await AccessCapabilities.delegate + .invoke({ + issuer: client.agent.issuer, + audience, + with: space.did(), + proofs: [...accessProofs, delegation], + nb: { + delegations: { + [delegation.cid.toString()]: delegation.cid, + }, + }, + }) + .execute(connection) + + /* 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}`, + { + cause: verificationResult.out.error, + } + ) + } + return { ok: { ...verificationResult.out.ok, delegation } } + } finally { + if (currentSpace) { + await client.setCurrentSpace(currentSpace.did()) + } + } +} diff --git a/packages/w3up-client/src/index.js b/packages/w3up-client/src/index.js index 5d38b5363..da1bcbb14 100644 --- a/packages/w3up-client/src/index.js +++ b/packages/w3up-client/src/index.js @@ -12,6 +12,7 @@ import { Client } from './client.js' export * as Result from './result.js' export * as Account from './account.js' export * from './ability.js' +export { authorizeContentServe } from './client.js' /** * Create a new w3up client. diff --git a/packages/w3up-client/src/types.ts b/packages/w3up-client/src/types.ts index 553c55e93..64882a515 100644 --- a/packages/w3up-client/src/types.ts +++ b/packages/w3up-client/src/types.ts @@ -1,5 +1,8 @@ import { type Driver } from '@web3-storage/access/drivers/types' import { + AccessDelegate, + AccessDelegateFailure, + AccessDelegateSuccess, type Service as AccessService, type AgentDataExport, } from '@web3-storage/access/types' @@ -11,6 +14,7 @@ import type { Ability, Resource, Unit, + ServiceMethod, } from '@ucanto/interface' import { type Client } from './client.js' import { StorefrontService } from '@web3-storage/filecoin-client/storefront' @@ -36,6 +40,15 @@ export interface ServiceConf { filecoin: ConnectionView } +export interface ContentServeService { + access: { + delegate: ServiceMethod< + AccessDelegate, + AccessDelegateSuccess, + AccessDelegateFailure + > + } +} export interface ClientFactoryOptions { /** * A storage driver that persists exported agent data. diff --git a/packages/w3up-client/test/account.test.js b/packages/w3up-client/test/account.test.js index 573907b6c..154e43e5b 100644 --- a/packages/w3up-client/test/account.test.js +++ b/packages/w3up-client/test/account.test.js @@ -111,7 +111,9 @@ export const testAccount = Test.withContext({ assert, { client, mail, grantAccess } ) => { - const space = await client.createSpace('test') + const space = await client.createSpace('test', { + skipGatewayAuthorization: true, + }) const mnemonic = space.toMnemonic() const { signer } = await Space.fromMnemonic(mnemonic, { name: 'import' }) assert.deepEqual( @@ -147,7 +149,9 @@ export const testAccount = Test.withContext({ 'multi device workflow': async (asserts, { connect, mail, grantAccess }) => { const laptop = await connect() - const space = await laptop.createSpace('main') + const space = await laptop.createSpace('main', { + skipGatewayAuthorization: true, + }) // want to provision space ? const email = 'alice@web.mail' @@ -183,7 +187,9 @@ export const testAccount = Test.withContext({ asserts.deepEqual(result.did, space.did()) }, 'setup recovery': async (assert, { client, mail, grantAccess }) => { - const space = await client.createSpace('test') + const space = await client.createSpace('test', { + skipGatewayAuthorization: true, + }) const email = 'alice@web.mail' const login = Account.login(client, email) @@ -280,7 +286,9 @@ export const testAccount = Test.withContext({ assert, { client, mail, grantAccess } ) => { - const space = await client.createSpace('test') + const space = await client.createSpace('test', { + skipGatewayAuthorization: true, + }) const email = 'alice@web.mail' const login = Account.login(client, email) @@ -299,8 +307,10 @@ export const testAccount = Test.withContext({ assert.equal(typeof subs.results[0].subscription, 'string') }, - 'space.save': async (assert, { client, mail, grantAccess }) => { - const space = await client.createSpace('test') + 'space.save': async (assert, { client }) => { + const space = await client.createSpace('test', { + skipGatewayAuthorization: true, + }) assert.deepEqual(client.spaces(), []) const result = await space.save() diff --git a/packages/w3up-client/test/capability/access.test.js b/packages/w3up-client/test/capability/access.test.js index 61702a9ff..baf6add3d 100644 --- a/packages/w3up-client/test/capability/access.test.js +++ b/packages/w3up-client/test/capability/access.test.js @@ -20,7 +20,7 @@ export const AccessClient = Test.withContext({ }, 'should delegate and then claim': async ( assert, - { connection, provisionsStorage } + { id: w3, connection, provisionsStorage } ) => { const alice = new Client(await AgentData.create(), { // @ts-ignore @@ -29,7 +29,10 @@ export const AccessClient = Test.withContext({ upload: connection, }, }) - const space = await alice.createSpace('upload-test') + + const space = await alice.createSpace('upload-test', { + skipGatewayAuthorization: true, + }) const auth = await space.createAuthorization(alice) await alice.addSpace(auth) await alice.setCurrentSpace(space.did()) diff --git a/packages/w3up-client/test/capability/blob.test.js b/packages/w3up-client/test/capability/blob.test.js index 209d21307..6c0d21470 100644 --- a/packages/w3up-client/test/capability/blob.test.js +++ b/packages/w3up-client/test/capability/blob.test.js @@ -19,7 +19,9 @@ export const BlobClient = Test.withContext({ receiptsEndpoint: new URL(receiptsEndpoint), }) - const space = await alice.createSpace('test') + const space = await alice.createSpace('test', { + skipGatewayAuthorization: true, + }) const auth = await space.createAuthorization(alice) await alice.addSpace(auth) await alice.setCurrentSpace(space.did()) @@ -56,7 +58,9 @@ export const BlobClient = Test.withContext({ receiptsEndpoint: new URL(receiptsEndpoint), }) - const space = await alice.createSpace('test') + const space = await alice.createSpace('test', { + skipGatewayAuthorization: true, + }) const auth = await space.createAuthorization(alice) await alice.addSpace(auth) await alice.setCurrentSpace(space.did()) @@ -94,7 +98,9 @@ export const BlobClient = Test.withContext({ receiptsEndpoint: new URL(receiptsEndpoint), }) - const space = await alice.createSpace('test') + const space = await alice.createSpace('test', { + skipGatewayAuthorization: true, + }) const auth = await space.createAuthorization(alice) await alice.addSpace(auth) await alice.setCurrentSpace(space.did()) @@ -126,7 +132,9 @@ export const BlobClient = Test.withContext({ receiptsEndpoint: new URL(receiptsEndpoint), }) - const space = await alice.createSpace('test') + const space = await alice.createSpace('test', { + skipGatewayAuthorization: true, + }) const auth = await space.createAuthorization(alice) await alice.addSpace(auth) await alice.setCurrentSpace(space.did()) diff --git a/packages/w3up-client/test/capability/filecoin.test.js b/packages/w3up-client/test/capability/filecoin.test.js index f2640bece..7d51d4c78 100644 --- a/packages/w3up-client/test/capability/filecoin.test.js +++ b/packages/w3up-client/test/capability/filecoin.test.js @@ -5,7 +5,9 @@ import * as Test from '../test.js' export const FilecoinClient = Test.withContext({ offer: { 'should send an offer': async (assert, { client: alice }) => { - const space = await alice.createSpace('test') + const space = await alice.createSpace('test', { + skipGatewayAuthorization: true, + }) const auth = await space.createAuthorization(alice) await alice.addSpace(auth) await alice.setCurrentSpace(space.did()) @@ -36,7 +38,9 @@ export const FilecoinClient = Test.withContext({ throw new Error('could not compute proof') } - const space = await alice.createSpace('test') + const space = await alice.createSpace('test', { + skipGatewayAuthorization: true, + }) const auth = await space.createAuthorization(alice) await alice.addSpace(auth) await alice.setCurrentSpace(space.did()) diff --git a/packages/w3up-client/test/capability/index.test.js b/packages/w3up-client/test/capability/index.test.js index 40196272c..1e4bdc0a4 100644 --- a/packages/w3up-client/test/capability/index.test.js +++ b/packages/w3up-client/test/capability/index.test.js @@ -14,7 +14,9 @@ export const IndexClient = Test.withContext({ ) => { const car = await randomCAR(128) - const space = await alice.createSpace('test') + const space = await alice.createSpace('test', { + skipGatewayAuthorization: true, + }) const auth = await space.createAuthorization(alice) await alice.addSpace(auth) await alice.setCurrentSpace(space.did()) diff --git a/packages/w3up-client/test/capability/space.test.js b/packages/w3up-client/test/capability/space.test.js index d25f447db..3b71e3798 100644 --- a/packages/w3up-client/test/capability/space.test.js +++ b/packages/w3up-client/test/capability/space.test.js @@ -19,7 +19,9 @@ export const SpaceClient = Test.withContext({ }, }) - const space = await alice.createSpace('test') + const space = await alice.createSpace('test', { + skipGatewayAuthorization: true, + }) const auth = await space.createAuthorization(alice, { access: { 'space/info': {} }, expiration: Infinity, @@ -55,7 +57,9 @@ export const SpaceClient = Test.withContext({ upload: connection, }, }) - const space = await alice.createSpace('test') + const space = await alice.createSpace('test', { + skipGatewayAuthorization: true, + }) const auth = await alice.addSpace(await space.createAuthorization(alice)) assert.ok(auth) @@ -165,7 +169,9 @@ export const SpaceClient = Test.withContext({ upload: connection, }, }) - const space = await alice.createSpace('test') + const space = await alice.createSpace('test', { + skipGatewayAuthorization: true, + }) const auth = await alice.addSpace(await space.createAuthorization(alice)) assert.ok(auth) @@ -273,7 +279,9 @@ export const SpaceClient = Test.withContext({ upload: connection, }, }) - const space = await alice.createSpace('test') + const space = await alice.createSpace('test', { + skipGatewayAuthorization: true, + }) const auth = await alice.addSpace( await space.createAuthorization(alice) ) @@ -385,7 +393,9 @@ export const SpaceClient = Test.withContext({ upload: connection, }, }) - const space = await alice.createSpace('test') + const space = await alice.createSpace('test', { + skipGatewayAuthorization: true, + }) const auth = await alice.addSpace( await space.createAuthorization(alice) ) @@ -499,7 +509,9 @@ export const SpaceClient = Test.withContext({ upload: connection, }, }) - const space = await alice.createSpace('test') + const space = await alice.createSpace('test', { + skipGatewayAuthorization: true, + }) const auth = await alice.addSpace(await space.createAuthorization(alice)) assert.ok(auth) diff --git a/packages/w3up-client/test/capability/store.test.js b/packages/w3up-client/test/capability/store.test.js index 602a46602..a58ba0f9e 100644 --- a/packages/w3up-client/test/capability/store.test.js +++ b/packages/w3up-client/test/capability/store.test.js @@ -16,7 +16,9 @@ export const StoreClient = Test.withContext({ }, }) - const space = await alice.createSpace('test') + const space = await alice.createSpace('test', { + skipGatewayAuthorization: true, + }) const auth = await space.createAuthorization(alice) await alice.addSpace(auth) await alice.setCurrentSpace(space.did()) @@ -51,7 +53,9 @@ export const StoreClient = Test.withContext({ }, }) - const space = await alice.createSpace('test') + const space = await alice.createSpace('test', { + skipGatewayAuthorization: true, + }) const auth = await space.createAuthorization(alice) await alice.addSpace(auth) await alice.setCurrentSpace(space.did()) @@ -87,7 +91,9 @@ export const StoreClient = Test.withContext({ }, }) - const space = await alice.createSpace('test') + const space = await alice.createSpace('test', { + skipGatewayAuthorization: true, + }) const auth = await space.createAuthorization(alice) await alice.addSpace(auth) await alice.setCurrentSpace(space.did()) @@ -121,7 +127,9 @@ export const StoreClient = Test.withContext({ }, }) - const space = await alice.createSpace('test') + const space = await alice.createSpace('test', { + skipGatewayAuthorization: true, + }) const auth = await space.createAuthorization(alice) await alice.addSpace(auth) await alice.setCurrentSpace(space.did()) diff --git a/packages/w3up-client/test/capability/subscription.test.js b/packages/w3up-client/test/capability/subscription.test.js index c4d036712..82860a127 100644 --- a/packages/w3up-client/test/capability/subscription.test.js +++ b/packages/w3up-client/test/capability/subscription.test.js @@ -8,7 +8,9 @@ export const SubscriptionClient = Test.withContext({ assert, { client, connection, service, plansStorage, grantAccess, mail } ) => { - const space = await client.createSpace('test') + const space = await client.createSpace('test', { + skipGatewayAuthorization: true, + }) const email = 'alice@web.mail' const login = Account.login(client, email) const message = await mail.take() diff --git a/packages/w3up-client/test/capability/upload.test.js b/packages/w3up-client/test/capability/upload.test.js index 573221908..649d91ce0 100644 --- a/packages/w3up-client/test/capability/upload.test.js +++ b/packages/w3up-client/test/capability/upload.test.js @@ -9,7 +9,9 @@ export const UploadClient = Test.withContext({ ) => { const car = await randomCAR(128) - const space = await alice.createSpace('test') + const space = await alice.createSpace('test', { + skipGatewayAuthorization: true, + }) const auth = await space.createAuthorization(alice) await alice.addSpace(auth) await alice.setCurrentSpace(space.did()) @@ -38,7 +40,9 @@ export const UploadClient = Test.withContext({ ) => { const car = await randomCAR(128) - const space = await alice.createSpace('test') + const space = await alice.createSpace('test', { + skipGatewayAuthorization: true, + }) const auth = await space.createAuthorization(alice) await alice.addSpace(auth) await alice.setCurrentSpace(space.did()) @@ -77,7 +81,9 @@ export const UploadClient = Test.withContext({ ) => { const car = await randomCAR(128) - const space = await alice.createSpace('test') + const space = await alice.createSpace('test', { + skipGatewayAuthorization: true, + }) const auth = await space.createAuthorization(alice) await alice.addSpace(auth) await alice.setCurrentSpace(space.did()) @@ -109,7 +115,9 @@ export const UploadClient = Test.withContext({ ) => { const car = await randomCAR(128) - const space = await alice.createSpace('test') + const space = await alice.createSpace('test', { + skipGatewayAuthorization: true, + }) const auth = await space.createAuthorization(alice) await alice.addSpace(auth) await alice.setCurrentSpace(space.did()) diff --git a/packages/w3up-client/test/capability/usage.test.js b/packages/w3up-client/test/capability/usage.test.js index caadf5ede..45a2d9182 100644 --- a/packages/w3up-client/test/capability/usage.test.js +++ b/packages/w3up-client/test/capability/usage.test.js @@ -18,7 +18,9 @@ export const UsageClient = Test.withContext({ }, }) - const space = await alice.createSpace('test') + const space = await alice.createSpace('test', { + skipGatewayAuthorization: true, + }) const auth = await space.createAuthorization(alice) await alice.addSpace(auth) @@ -60,7 +62,9 @@ export const UsageClient = Test.withContext({ }, }) - const space = await alice.createSpace('test') + const space = await alice.createSpace('test', { + skipGatewayAuthorization: true, + }) const auth = await space.createAuthorization(alice) await alice.addSpace(auth) diff --git a/packages/w3up-client/test/client.test.js b/packages/w3up-client/test/client.test.js index 7869aa2f0..b64463bf4 100644 --- a/packages/w3up-client/test/client.test.js +++ b/packages/w3up-client/test/client.test.js @@ -1,5 +1,6 @@ import assert from 'assert' import { parseLink } from '@ucanto/server' +import * as Server from '@ucanto/server' import { Agent, AgentData, @@ -9,12 +10,18 @@ import { import { randomBytes, randomCAR } from './helpers/random.js' import { toCAR } from './helpers/car.js' import { File } from './helpers/shims.js' -import { Client } from '../src/client.js' +import { authorizeContentServe, Client } from '../src/client.js' import * as Test from './test.js' import { receiptsEndpoint } from './helpers/utils.js' import { Absentee } from '@ucanto/principal' import { DIDMailto } from '../src/capability/access.js' -import { confirmConfirmationUrl } from '../../upload-api/test/helpers/utils.js' +import { + alice, + confirmConfirmationUrl, + gateway, +} from '../../upload-api/test/helpers/utils.js' +import * as SpaceCapability from '@web3-storage/capabilities/space' +import { getConnection, getContentServeMockService } from './mocks/service.js' /** @type {Test.Suite} */ export const testClient = { @@ -38,7 +45,9 @@ export const testClient = { receiptsEndpoint: new URL(receiptsEndpoint), }) - const space = await alice.createSpace('upload-test') + const space = await alice.createSpace('upload-test', { + skipGatewayAuthorization: true, + }) const auth = await space.createAuthorization(alice) await alice.addSpace(auth) await alice.setCurrentSpace(space.did()) @@ -115,7 +124,9 @@ export const testClient = { receiptsEndpoint: new URL(receiptsEndpoint), }) - const space = await alice.createSpace('upload-dir-test') + const space = await alice.createSpace('upload-dir-test', { + skipGatewayAuthorization: true, + }) const auth = await space.createAuthorization(alice) await alice.addSpace(auth) @@ -160,7 +171,9 @@ export const testClient = { receiptsEndpoint: new URL(receiptsEndpoint), }) - const space = await alice.createSpace('car-space') + const space = await alice.createSpace('car-space', { + skipGatewayAuthorization: true, + }) await alice.addSpace(await space.createAuthorization(alice)) await alice.setCurrentSpace(space.did()) @@ -221,7 +234,9 @@ export const testClient = { const current0 = alice.currentSpace() assert.equal(current0, undefined) - const space = await alice.createSpace('new-space') + const space = await alice.createSpace('new-space', { + skipGatewayAuthorization: true, + }) await alice.addSpace(await space.createAuthorization(alice)) await alice.setCurrentSpace(space.did()) @@ -235,7 +250,9 @@ export const testClient = { const alice = new Client(await AgentData.create()) const name = `space-${Date.now()}` - const space = await alice.createSpace(name) + const space = await alice.createSpace(name, { + skipGatewayAuthorization: true, + }) const auth = await space.createAuthorization(alice) await alice.addSpace(auth) @@ -249,7 +266,9 @@ export const testClient = { const alice = new Client(await AgentData.create()) const bob = new Client(await AgentData.create()) - const space = await alice.createSpace('new-space') + const space = await alice.createSpace('new-space', { + skipGatewayAuthorization: true, + }) await alice.addSpace( await space.createAuthorization(alice, { access: { '*': {} }, @@ -284,6 +303,7 @@ export const testClient = { // Step 2: Alice creates a space with her account as the recovery account const space = await client.createSpace('recovery-space-test', { account: aliceAccount, // The account is the recovery account + skipGatewayAuthorization: true, }) assert.ok(space) @@ -315,7 +335,9 @@ export const testClient = { await aliceLogin // Step 2: Alice creates a space without providing a recovery account - const space = await client.createSpace('no-recovery-space-test') + const space = await client.createSpace('no-recovery-space-test', { + skipGatewayAuthorization: true, + }) assert.ok(space) // Step 3: Attempt to access the space from a new device @@ -418,6 +440,7 @@ export const testClient = { // Step 2: Alice creates a space const space = await aliceClient.createSpace('share-space-test', { account: aliceAccount, + skipGatewayAuthorization: true, }) assert.ok(space) @@ -470,6 +493,7 @@ export const testClient = { 'share-space-delegate-fail-test', { account: aliceAccount, + skipGatewayAuthorization: true, } ) assert.ok(space) @@ -506,12 +530,14 @@ export const testClient = { // Step 2: Alice creates a space const spaceA = await client.createSpace('test-space-a', { account: aliceAccount, + skipGatewayAuthorization: true, }) assert.ok(spaceA) // Step 3: Alice creates another space to share with a friend const spaceB = await client.createSpace('test-space-b', { account: aliceAccount, + skipGatewayAuthorization: true, }) assert.ok(spaceB) @@ -528,12 +554,207 @@ export const testClient = { ) }, }), + authorizeGateway: Test.withContext({ + 'should explicitly authorize a gateway to serve content from a space': + async (assert, { mail, grantAccess, connection }) => { + // Step 1: Create a client for Alice and login + const aliceClient = new Client( + await AgentData.create({ + principal: alice, + }), + { + // @ts-ignore + serviceConf: { + access: connection, + upload: connection, + }, + } + ) + + const aliceEmail = 'alice@web.mail' + const aliceLogin = aliceClient.login(aliceEmail) + const message = await mail.take() + assert.deepEqual(message.to, aliceEmail) + await grantAccess(message) + const aliceAccount = await aliceLogin + + // Step 2: Alice creates a space + const spaceA = await aliceClient.createSpace( + 'authorize-gateway-space', + { + account: aliceAccount, + skipGatewayAuthorization: true, + } + ) + assert.ok(spaceA) + + const gatewayService = getContentServeMockService() + const gatewayConnection = getConnection( + gateway, + gatewayService + ).connection + + // Step 3: Alice authorizes the gateway to serve content from the space + const delegationResult = await authorizeContentServe( + aliceClient, + spaceA, + gatewayConnection + ) + assert.ok(delegationResult.ok) + const { delegation } = delegationResult.ok + + // Step 4: Find the delegation for the default gateway + assert.equal(delegation.audience.did(), gateway.did()) + assert.ok( + delegation.capabilities.some( + (c) => + c.can === SpaceCapability.contentServe.can && + c.with === spaceA.did() + ) + ) + }, + 'should automatically authorize a gateway to serve content from a space when the space is created': + async (assert, { mail, grantAccess, connection }) => { + // Step 1: Create a client for Alice and login + const aliceClient = new Client( + await AgentData.create({ + principal: alice, + }), + { + // @ts-ignore + serviceConf: { + access: connection, + upload: connection, + }, + } + ) + + const aliceEmail = 'alice@web.mail' + const aliceLogin = aliceClient.login(aliceEmail) + const message = await mail.take() + assert.deepEqual(message.to, aliceEmail) + await grantAccess(message) + const aliceAccount = await aliceLogin + + // Step 2: Alice creates a space + const gatewayService = getContentServeMockService() + const gatewayConnection = getConnection( + gateway, + gatewayService + ).connection + + try { + const spaceA = await aliceClient.createSpace( + 'authorize-gateway-space', + { + account: aliceAccount, + authorizeGatewayServices: [gatewayConnection], + } + ) + assert.ok(spaceA, 'should create the space') + } catch (error) { + assert.fail(error, 'should not throw when creating the space') + } + }, + 'should throw when the content serve authorization fails due to missing service configuration': + async (assert, { mail, grantAccess, connection }) => { + // Step 1: Create a client for Alice and login + const aliceClient = new Client( + await AgentData.create({ + principal: alice, + }), + { + // @ts-ignore + serviceConf: { + access: connection, + upload: connection, + }, + } + ) + + const aliceEmail = 'alice@web.mail' + const aliceLogin = aliceClient.login(aliceEmail) + const message = await mail.take() + assert.deepEqual(message.to, aliceEmail) + 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' + ) + } + }, + 'should throw when content serve service can not process the invocation': + async (assert, { mail, grantAccess, connection }) => { + // Step 1: Create a client for Alice and login + const aliceClient = new Client( + await AgentData.create({ + principal: alice, + }), + { + // @ts-ignore + serviceConf: { + access: connection, + upload: connection, + }, + } + ) + + const aliceEmail = 'alice@web.mail' + const aliceLogin = aliceClient.login(aliceEmail) + const message = await mail.take() + assert.deepEqual(message.to, aliceEmail) + await grantAccess(message) + const aliceAccount = await aliceLogin + + // Step 2: Alice creates a space + const gatewayService = getContentServeMockService({ + error: Server.fail( + 'Content serve service can not process the invocation' + ).error, + }) + const gatewayConnection = getConnection( + gateway, + gatewayService + ).connection + + try { + await aliceClient.createSpace('authorize-gateway-space', { + account: aliceAccount, + authorizeGatewayServices: [gatewayConnection], + }) + assert.fail('should not create the space') + } catch (error) { + assert.match( + // @ts-expect-error + error.message, + /failed to publish delegation for audience/, + 'should throw when publishing the delegation' + ) + } + }, + }), proofs: { 'should get proofs': async (assert) => { const alice = new Client(await AgentData.create()) const bob = new Client(await AgentData.create()) - const space = await alice.createSpace('proof-space') + const space = await alice.createSpace('proof-space', { + skipGatewayAuthorization: true, + }) await alice.addSpace(await space.createAuthorization(alice)) await alice.setCurrentSpace(space.did()) @@ -551,7 +772,9 @@ export const testClient = { const alice = new Client(await AgentData.create()) const bob = new Client(await AgentData.create()) - const space = await alice.createSpace('test') + const space = await alice.createSpace('test', { + skipGatewayAuthorization: true, + }) await alice.addSpace(await space.createAuthorization(alice)) await alice.setCurrentSpace(space.did()) const name = `delegation-${Date.now()}` @@ -587,7 +810,9 @@ export const testClient = { }, }) - const space = await alice.createSpace('test') + const space = await alice.createSpace('test', { + skipGatewayAuthorization: true, + }) await alice.addSpace( await space.createAuthorization(alice, { access: { '*': {} }, @@ -609,7 +834,9 @@ export const testClient = { const alice = new Client(await AgentData.create()) const bob = new Client(await AgentData.create()) - const space = await alice.createSpace('test') + const space = await alice.createSpace('test', { + skipGatewayAuthorization: true, + }) await alice.addSpace(await space.createAuthorization(alice)) await alice.setCurrentSpace(space.did()) const name = `delegation-${Date.now()}` @@ -660,7 +887,9 @@ export const testClient = { }) // setup space - const space = await alice.createSpace('upload-test') + const space = await alice.createSpace('upload-test', { + skipGatewayAuthorization: true, + }) const auth = await space.createAuthorization(alice) await alice.addSpace(auth) await alice.setCurrentSpace(space.did()) @@ -709,7 +938,9 @@ export const testClient = { }) // setup space - const space = await alice.createSpace('upload-test') + const space = await alice.createSpace('upload-test', { + skipGatewayAuthorization: true, + }) const auth = await space.createAuthorization(alice) await alice.addSpace(auth) await alice.setCurrentSpace(space.did()) @@ -761,7 +992,9 @@ export const testClient = { }) // setup space - const space = await alice.createSpace('upload-test') + const space = await alice.createSpace('upload-test', { + skipGatewayAuthorization: true, + }) const auth = await space.createAuthorization(alice) await alice.addSpace(auth) await alice.setCurrentSpace(space.did()) @@ -785,7 +1018,9 @@ export const testClient = { }) // setup space - const space = await alice.createSpace('upload-test') + const space = await alice.createSpace('upload-test', { + skipGatewayAuthorization: true, + }) const auth = await space.createAuthorization(alice) await alice.addSpace(auth) await alice.setCurrentSpace(space.did()) diff --git a/packages/w3up-client/test/coupon.test.js b/packages/w3up-client/test/coupon.test.js index faee5f227..d75c9ee27 100644 --- a/packages/w3up-client/test/coupon.test.js +++ b/packages/w3up-client/test/coupon.test.js @@ -37,7 +37,9 @@ export const testCoupon = Test.withContext({ const access = await alice.coupon.redeem(archive) // creates a space and provision it with redeemed coupon - const space = await alice.createSpace('home') + const space = await alice.createSpace('home', { + skipGatewayAuthorization: true, + }) const result = await space.provision(access) await space.save() diff --git a/packages/w3up-client/test/mocks/service.js b/packages/w3up-client/test/mocks/service.js new file mode 100644 index 000000000..4b8805fde --- /dev/null +++ b/packages/w3up-client/test/mocks/service.js @@ -0,0 +1,43 @@ +import * as Client from '@ucanto/client' +import * as Server from '@ucanto/server' +import { HTTP } from '@ucanto/transport' +import * as CAR from '@ucanto/transport/car' +import * as AccessCaps from '@web3-storage/capabilities' + +/** + * Mocked Gateway/Content Serve service + * + * @param {{ ok: any } | { error: Server.API.Failure }} result + */ +export function getContentServeMockService(result = { ok: {} }) { + return { + access: { + delegate: Server.provide(AccessCaps.Access.delegate, async () => { + return result + }), + }, + } +} + +/** + * 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 = Server.create({ + id: id, + service, + codec: CAR.inbound, + validateAuthorization: () => ({ ok: {} }), + }) + const connection = Client.connect({ + id: id, + codec: CAR.outbound, + channel: url ? HTTP.open({ url: new URL(url) }) : server, + }) + + return { connection } +} diff --git a/packages/w3up-client/test/space.test.js b/packages/w3up-client/test/space.test.js index a6a0d1281..65c7f54b0 100644 --- a/packages/w3up-client/test/space.test.js +++ b/packages/w3up-client/test/space.test.js @@ -24,7 +24,9 @@ export const testSpace = Test.withContext({ }, 'should get usage': async (assert, { client, grantAccess, mail }) => { - const space = await client.createSpace('test') + const space = await client.createSpace('test', { + skipGatewayAuthorization: true, + }) const email = 'alice@web.mail' const login = Account.login(client, email)