Skip to content

Commit

Permalink
feat: access-api serves access/claim invocations (#456)
Browse files Browse the repository at this point in the history
Motivation:
* #455
* this will definitely resolve this error
https://github.com/gobengo/w3protocol-test/actions/runs/4267476381/jobs/7429121484#step:6:66
* [ ] test that you can only claim delegations whose audience matches
access/claim invocation `with`
  • Loading branch information
gobengo authored Feb 28, 2023
1 parent 4f0bd1c commit baacf35
Show file tree
Hide file tree
Showing 5 changed files with 193 additions and 113 deletions.
11 changes: 11 additions & 0 deletions packages/access-api/src/service/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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') {
Expand Down
45 changes: 45 additions & 0 deletions packages/access-api/test/access-claim.test.js
Original file line number Diff line number Diff line change
@@ -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
}
130 changes: 17 additions & 113 deletions packages/access-api/test/access-delegate.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand All @@ -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<Ucanto.DID<'key'>>, service: Ucanto.Signer<Ucanto.DID>, conn: Ucanto.ConnectionView<Record<string, any>> }>} createContext
* @param {object} [options]
* @param {Iterable<Resolvable<Ucanto.Principal>>} 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<Capability>} 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<Resolvable<Ucanto.Principal>>} spaces
* @param {Ucanto.Signer<Ucanto.DID>} issuer
* @param {Ucanto.ConnectionView<Record<string, any>>} 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<Ucanto.DID>} 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
Expand Down Expand Up @@ -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.
*
Expand Down
116 changes: 116 additions & 0 deletions packages/access-api/test/helpers/ucanto-test-utils.js
Original file line number Diff line number Diff line change
@@ -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<Ucanto.DID<'key'>>, service: Ucanto.Signer<Ucanto.DID>, conn: Ucanto.ConnectionView<Record<string, any>> }>} createContext
* @param {object} [options]
* @param {Iterable<Promise<Ucanto.Principal>>} 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<Capability>} 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<T>} Resolvable
*/

/**
* given an iterable of spaces, register them against an access-api
* using a service-issued voucher/redeem invocation
*
* @param {Iterable<Resolvable<Ucanto.Principal>>} spaces
* @param {Ucanto.Signer<Ucanto.DID>} issuer
* @param {Ucanto.ConnectionView<Record<string, any>>} 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<Ucanto.DID>} 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)
}
}
4 changes: 4 additions & 0 deletions packages/access-client/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -93,6 +96,7 @@ export interface Service {
access: {
// returns a URL string for tests or nothing in other envs
authorize: ServiceMethod<AccessAuthorize, string | undefined, Failure>
claim: ServiceMethod<AccessClaim, AccessClaimSuccess, AccessClaimFailure>
delegate: ServiceMethod<
AccessDelegate,
AccessDelegateSuccess,
Expand Down

0 comments on commit baacf35

Please sign in to comment.