Skip to content
New issue

Have a question about this project? # for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “#”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? # to your account

feat: access-api serves access/claim invocations #456

Merged
merged 2 commits into from
Feb 28, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: I think it would be a lot simpler to follow if it just returned one promise with all the things as opposed to trying to have three different promises which in all need to await on registration anyway.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

one of the reasons it's so gnarly is to afford for cases where a promise of a ucanto signer is created, and then that's used to build several testers, and all in a way that I can avoid any await before calling mocha it to have many separate it calls like you like (and I like too now).

I'm down to refactor things in there, but I may need a pair programming session with you to do it, so that's why I'm not doing now.

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