diff --git a/packages/access-api/src/service/index.js b/packages/access-api/src/service/index.js index 3f1f71c1a..3ab9813b1 100644 --- a/packages/access-api/src/service/index.js +++ b/packages/access-api/src/service/index.js @@ -12,6 +12,7 @@ import { voucherRedeemProvider } from './voucher-redeem.js' import * as uploadApi from './upload-api-proxy.js' import { accessAuthorizeProvider } from './access-authorize.js' import { accessDelegateProvider } from './access-delegate.js' +import { accessClaimProvider } from './access-claim.js' /** * @param {import('../bindings').RouteContext} ctx @@ -27,6 +28,16 @@ export function service(ctx) { access: { authorize: accessAuthorizeProvider(ctx), + claim: (...args) => { + // disable until hardened in test/staging + if (ctx.config.ENV === 'production') { + throw new Error(`acccess/claim invocation handling is not enabled`) + } + return accessClaimProvider({ + delegations: ctx.models.delegations, + config: ctx.config, + })(...args) + }, delegate: (...args) => { // disable until hardened in test/staging if (ctx.config.ENV === 'production') { diff --git a/packages/access-api/test/access-claim.test.js b/packages/access-api/test/access-claim.test.js new file mode 100644 index 000000000..6d5122234 --- /dev/null +++ b/packages/access-api/test/access-claim.test.js @@ -0,0 +1,45 @@ +import { context } from './helpers/context.js' +import { createTesterFromContext } from './helpers/ucanto-test-utils.js' +import { ed25519 } from '@ucanto/principal' +import { claim } from '@web3-storage/capabilities/access' +import * as assert from 'assert' + +/** + * Run the same tests against several variants of access/delegate handlers. + */ +for (const handlerVariant of /** @type {const} */ ([ + { + name: 'handled by access-api in miniflare', + ...(() => { + const spaceWithStorageProvider = ed25519.generate() + return { + spaceWithStorageProvider, + ...createTesterFromContext(() => context(), { + registerSpaces: [spaceWithStorageProvider], + }), + } + })(), + }, +])) { + describe(`access-claim ${handlerVariant.name}`, () => { + it(`can be invoked`, async () => { + const issuer = await handlerVariant.issuer + const result = await handlerVariant.invoke( + await claim + .invoke({ + issuer, + audience: await handlerVariant.audience, + with: issuer.did(), + }) + .delegate() + ) + assert.deepEqual( + 'delegations' in result, + true, + 'result contains delegations set' + ) + }) + }) + + // there are more tests about `testDelegateThenClaim` in ./access-delegate.test.js +} diff --git a/packages/access-api/test/access-delegate.test.js b/packages/access-api/test/access-delegate.test.js index 55b224440..7a60bb77f 100644 --- a/packages/access-api/test/access-delegate.test.js +++ b/packages/access-api/test/access-delegate.test.js @@ -13,8 +13,12 @@ import { } from '../src/service/delegations.js' import { createD1Database } from '../src/utils/d1.js' import { DbDelegationsStorage } from '../src/models/delegations.js' -import { Voucher } from '@web3-storage/capabilities' import * as delegationsResponse from '../src/utils/delegations-response.js' +import { + assertNotError, + createTesterFromContext, + warnOnErrorResult, +} from './helpers/ucanto-test-utils.js' /** * Run the same tests against several variants of access/delegate handlers. @@ -144,17 +148,18 @@ for (const variant of /** @type {const} */ ([ } })(), }, - /* - @todo: uncomment this testing against access-api + miniflare - * after - * more tests on createAccessClaimHandler alone - * ensure you can only claim things that are delegated to you, etc. - * use createAccessClaimHandler inside of access-api ucanto service/server - */ - // { - // name: 'handled by access-api in miniflare', - // ...createTesterFromContext(() => context()), - // }, + { + name: 'handled by access-api in miniflare', + ...(() => { + const spaceWithStorageProvider = principal.ed25519.generate() + return { + spaceWithStorageProvider, + ...createTesterFromContext(() => context(), { + registerSpaces: [spaceWithStorageProvider], + }), + } + })(), + }, ])) { describe(`access/delegate ${variant.name}`, () => { // test delegate, then claim @@ -168,80 +173,6 @@ for (const variant of /** @type {const} */ ([ }) } -/** - * Tests using context from "./helpers/context.js", which sets up a testable access-api inside miniflare. - * - * @param {() => Promise<{ issuer: Ucanto.Signer>, service: Ucanto.Signer, conn: Ucanto.ConnectionView> }>} createContext - * @param {object} [options] - * @param {Iterable>} options.registerSpaces - spaces to register in access-api. Some access-api functionality on a space requires it to be registered. - */ -function createTesterFromContext(createContext, options) { - const context = createContext().then(async (ctx) => { - await registerSpaces(options?.registerSpaces ?? [], ctx.service, ctx.conn) - return ctx - }) - const issuer = context.then(({ issuer }) => issuer) - const audience = context.then(({ service }) => service) - /** - * @template {Ucanto.Capability} Capability - * @param {Ucanto.Invocation} invocation - */ - const invoke = async (invocation) => { - const { conn } = await context - const [result] = await conn.execute(invocation) - return result - } - return { issuer, audience, invoke } -} - -/** - * given an iterable of spaces, register them against an access-api - * using a service-issued voucher/redeem invocation - * - * @param {Iterable>} spaces - * @param {Ucanto.Signer} issuer - * @param {Ucanto.ConnectionView>} conn - */ -async function registerSpaces(spaces, issuer, conn) { - for (const spacePromise of spaces) { - const space = await spacePromise - const redeem = await spaceRegistrationInvocation(issuer, space.did()) - const results = await conn.execute(redeem) - assert.deepEqual( - results.length, - 1, - 'registration invocation should have 1 result' - ) - const [result] = results - assertNotError(result) - } -} - -/** - * get an access-api invocation that will register a space. - * This is useful e.g. because some functionality (e.g. access/delegate) - * will fail unless the space is registered. - * - * @param {Ucanto.Signer} issuer - issues voucher/redeem. e.g. could be the same signer as access-api env.PRIVATE_KEY - * @param {Ucanto.DID} space - * @param {Ucanto.Principal} audience - audience of the invocation. often is same as issuer - */ -async function spaceRegistrationInvocation(issuer, space, audience = issuer) { - const redeem = await Voucher.redeem - .invoke({ - issuer, - audience, - with: issuer.did(), - nb: { - product: 'product:free', - space, - identity: 'mailto:someone', - }, - }) - .delegate() - return redeem -} - /** * @template {Ucanto.Capability} Capability * @template Result @@ -539,33 +470,6 @@ async function testCanDelegateThenClaim(invoke, issuer, audience) { ) } -/** - * @param {{ error?: unknown }|null} result - * @param {string} assertionMessage - */ -function assertNotError(result, assertionMessage = 'result is not an error') { - warnOnErrorResult(result) - if (result && 'error' in result) { - assert.notDeepEqual(result.error, true, assertionMessage) - } -} - -/** - * @param {{ error?: unknown }|null} result - * @param {string} [message] - * @param {(...loggables: any[]) => void} warn - */ -function warnOnErrorResult( - result, - message = 'unexpected error result', - // eslint-disable-next-line no-console - warn = console.warn.bind(console) -) { - if (result && 'error' in result && result.error) { - warn(message, result) - } -} - /** * setup test scenario testing that an access/delegate can be followed up by access/claim. * diff --git a/packages/access-api/test/helpers/ucanto-test-utils.js b/packages/access-api/test/helpers/ucanto-test-utils.js new file mode 100644 index 000000000..9fa716bda --- /dev/null +++ b/packages/access-api/test/helpers/ucanto-test-utils.js @@ -0,0 +1,116 @@ +import * as Ucanto from '@ucanto/interface' +import { Voucher } from '@web3-storage/capabilities' +import * as assert from 'assert' + +/** + * Tests using context from "./helpers/context.js", which sets up a testable access-api inside miniflare. + * + * @param {() => Promise<{ issuer: Ucanto.Signer>, service: Ucanto.Signer, conn: Ucanto.ConnectionView> }>} createContext + * @param {object} [options] + * @param {Iterable>} options.registerSpaces - spaces to register in access-api. Some access-api functionality on a space requires it to be registered. + */ +export function createTesterFromContext(createContext, options) { + const context = createContext().then(async (ctx) => { + await registerSpaces(options?.registerSpaces ?? [], ctx.service, ctx.conn) + return ctx + }) + const issuer = context.then(({ issuer }) => issuer) + const audience = context.then(({ service }) => service) + /** + * @template {Ucanto.Capability} Capability + * @param {Ucanto.Invocation} invocation + */ + const invoke = async (invocation) => { + const { conn } = await context + const [result] = await conn.execute(invocation) + return result + } + return { issuer, audience, invoke } +} + +/** + * @template T + * @typedef {import('../access-delegate.test').Resolvable} Resolvable + */ + +/** + * given an iterable of spaces, register them against an access-api + * using a service-issued voucher/redeem invocation + * + * @param {Iterable>} spaces + * @param {Ucanto.Signer} issuer + * @param {Ucanto.ConnectionView>} conn + */ +export async function registerSpaces(spaces, issuer, conn) { + for (const spacePromise of spaces) { + const space = await spacePromise + const redeem = await spaceRegistrationInvocation(issuer, space.did()) + const results = await conn.execute(redeem) + assert.deepEqual( + results.length, + 1, + 'registration invocation should have 1 result' + ) + const [result] = results + assertNotError(result) + } +} + +/** + * get an access-api invocation that will register a space. + * This is useful e.g. because some functionality (e.g. access/delegate) + * will fail unless the space is registered. + * + * @param {Ucanto.Signer} issuer - issues voucher/redeem. e.g. could be the same signer as access-api env.PRIVATE_KEY + * @param {Ucanto.DID} space + * @param {Ucanto.Principal} audience - audience of the invocation. often is same as issuer + */ +export async function spaceRegistrationInvocation( + issuer, + space, + audience = issuer +) { + const redeem = await Voucher.redeem + .invoke({ + issuer, + audience, + with: issuer.did(), + nb: { + product: 'product:free', + space, + identity: 'mailto:someone', + }, + }) + .delegate() + return redeem +} + +/** + * @param {{ error?: unknown }|null} result + * @param {string} assertionMessage + */ +export function assertNotError( + result, + assertionMessage = 'result is not an error' +) { + warnOnErrorResult(result) + if (result && 'error' in result) { + assert.notDeepEqual(result.error, true, assertionMessage) + } +} + +/** + * @param {{ error?: unknown }|null} result + * @param {string} [message] + * @param {(...loggables: any[]) => void} warn + */ +export function warnOnErrorResult( + result, + message = 'unexpected error result', + // eslint-disable-next-line no-console + warn = console.warn.bind(console) +) { + if (result && 'error' in result && result.error) { + warn(message, result) + } +} diff --git a/packages/access-client/src/types.ts b/packages/access-client/src/types.ts index 9ac06e689..69c35fd8e 100644 --- a/packages/access-client/src/types.ts +++ b/packages/access-client/src/types.ts @@ -35,6 +35,9 @@ import type { AccessDelegate, AccessDelegateFailure, AccessDelegateSuccess, + AccessClaim, + AccessClaimSuccess, + AccessClaimFailure, } from '@web3-storage/capabilities/types' import type { SetRequired } from 'type-fest' import { Driver } from './drivers/types.js' @@ -93,6 +96,7 @@ export interface Service { access: { // returns a URL string for tests or nothing in other envs authorize: ServiceMethod + claim: ServiceMethod delegate: ServiceMethod< AccessDelegate, AccessDelegateSuccess,