diff --git a/packages/access-api/src/models/spaces.js b/packages/access-api/src/models/spaces.js index e273271b4..f136710b1 100644 --- a/packages/access-api/src/models/spaces.js +++ b/packages/access-api/src/models/spaces.js @@ -7,7 +7,7 @@ import { D1Dialect } from 'kysely-d1' import { D1Error, GenericPlugin } from '../utils/d1.js' /** - * @typedef {import('@web3-storage/access/src/types.js').SpaceRecord} SpaceRecord + * @typedef {import('@web3-storage/access/src/types.js').SpaceTable} SpaceTable */ /** @@ -19,7 +19,7 @@ export class Spaces { * @param {D1Database} d1 */ constructor(d1) { - /** @type {GenericPlugin} */ + /** @type {GenericPlugin>} */ const objectPlugin = new GenericPlugin({ metadata: (v) => { // this will be `EMPTY` because it's the default value in the sql schema diff --git a/packages/access-api/src/service/index.js b/packages/access-api/src/service/index.js index debf9e5e1..db5e469c7 100644 --- a/packages/access-api/src/service/index.js +++ b/packages/access-api/src/service/index.js @@ -1,6 +1,7 @@ import * as ucanto from '@ucanto/core' import * as Ucanto from '@ucanto/interface' import * as Server from '@ucanto/server' +import * as validator from '@ucanto/validator' import { Failure } from '@ucanto/server' import * as Space from '@web3-storage/capabilities/space' import { top } from '@web3-storage/capabilities/top' @@ -15,6 +16,7 @@ import { accessAuthorizeProvider } from './access-authorize.js' import { accessDelegateProvider } from './access-delegate.js' import { accessClaimProvider } from './access-claim.js' import { providerAddProvider } from './provider-add.js' +import { Spaces } from '../models/spaces.js' /** * @param {import('../bindings').RouteContext} ctx @@ -24,6 +26,9 @@ import { providerAddProvider } from './provider-add.js' * } */ export function service(ctx) { + /** @param {Ucanto.DID<'key'>} space */ + const hasStorageProvider = async (space) => + spaceHasStorageProvider(space, ctx.models.spaces, ctx.models.provisions) return { store: uploadApi.createStoreProxy(ctx), upload: uploadApi.createUploadProxy(ctx), @@ -47,19 +52,7 @@ export function service(ctx) { } return accessDelegateProvider({ delegations: ctx.models.delegations, - hasStorageProvider: async (space) => { - /** @type {import('./access-delegate.js').HasStorageProvider} */ - const registeredViaVoucherRedeem = async (space) => - Boolean(await ctx.models.spaces.get(space)) - /** @type {import('./access-delegate.js').HasStorageProvider} */ - // eslint-disable-next-line unicorn/consistent-function-scoping - const registeredViaProviderAdd = (space) => - ctx.models.provisions.hasStorageProvider(space) - return Boolean( - (await registeredViaProviderAdd(space)) || - (await registeredViaVoucherRedeem(space)) - ) - }, + hasStorageProvider, })(...args) }, }, @@ -81,8 +74,27 @@ export function service(ctx) { space: { info: Server.provide(Space.info, async ({ capability, invocation }) => { - const results = await ctx.models.spaces.get(capability.with) - if (!results) { + const spaceDid = capability.with + if (!validator.DID.match({ method: 'key' }).is(spaceDid)) { + /** @type {import('@web3-storage/access/types').SpaceUnknown} */ + const unexpectedSpaceDidFailure = { + error: true, + name: 'SpaceUnknown', + message: `can only get info for did:key spaces`, + } + return unexpectedSpaceDidFailure + } + if ( + await spaceHasStorageProviderFromProviderAdd( + spaceDid, + ctx.models.provisions + ) + ) { + return { did: spaceDid } + } + // this only exists if the space was registered via voucher/redeem + const space = await ctx.models.spaces.get(capability.with) + if (!space) { /** @type {import('@web3-storage/access/types').SpaceUnknown} */ const spaceUnknownFailure = { error: true, @@ -91,7 +103,8 @@ export function service(ctx) { } return spaceUnknownFailure } - return results + /** @type {import('@web3-storage/access/types').SpaceRecord} */ + return space }), recover: Server.provide( Space.recover, @@ -219,3 +232,36 @@ export function service(ctx) { }, } } + +/** + * @param {Ucanto.DID<'key'>} space + * @param {Spaces} spaces + * @param {import('../types/provisions.js').ProvisionsStorage} provisions + * @returns {Promise} + */ +async function spaceHasStorageProvider(space, spaces, provisions) { + return ( + (await spaceHasStorageProviderFromProviderAdd(space, provisions)) || + (await spaceHasStorageProviderFromVoucherRedeem(space, spaces)) + ) +} + +/** + * @param {Ucanto.DID<'key'>} space + * @param {Spaces} spaces + * @returns {Promise} + */ +async function spaceHasStorageProviderFromVoucherRedeem(space, spaces) { + const registered = Boolean(await spaces.get(space)) + return registered +} + +/** + * @param {Ucanto.DID<'key'>} space + * @param {import('../types/provisions.js').ProvisionsStorage} provisions + * @returns {Promise} + */ +async function spaceHasStorageProviderFromProviderAdd(space, provisions) { + const registeredViaProviderAdd = await provisions.hasStorageProvider(space) + return registeredViaProviderAdd +} diff --git a/packages/access-api/test/provider-add.test.js b/packages/access-api/test/provider-add.test.js index aa25064b3..b3071dcc9 100644 --- a/packages/access-api/test/provider-add.test.js +++ b/packages/access-api/test/provider-add.test.js @@ -15,6 +15,7 @@ import { Access, Provider } from '@web3-storage/capabilities' import * as delegationsResponse from '../src/utils/delegations-response.js' import { createProvisions } from '../src/models/provisions.js' import { Email } from '../src/utils/email.js' +import { NON_STANDARD } from '@ipld/dag-ucan/signature' for (const providerAddHandlerVariant of /** @type {const} */ ([ { @@ -64,7 +65,11 @@ for (const accessApiVariant of /** @type {const} */ ([ /** @type {{to:string, url:string}[]} */ const emails = [] const email = createEmail(emails) - const features = new Set(['provider/add', 'access/delegate']) + const features = new Set([ + 'provider/add', + 'access/delegate', + 'store/info', + ]) return { spaceWithStorageProvider, emails, @@ -187,6 +192,77 @@ for (const accessApiVariant of /** @type {const} */ ([ assertNotError(accessDelegateResult) }) } + + if ( + ['provider/add', 'store/info'].every((f) => + accessApiVariant.features.has(f) + ) + ) { + it('provider/add allows for store/info ', async () => { + const space = await principal.ed25519.generate() + const agent = await accessApiVariant.issuer + const service = await accessApiVariant.audience + const accountDid = /** @type {const} */ ('did:mailto:example.com:foo') + const accountAuthorization = await createAccountAuthorization( + agent, + service, + principal.Absentee.from({ + id: /** @type {Ucanto.DID<'mailto'>} */ (accountDid), + }) + ) + const addStorageProvider = await ucanto + .invoke({ + issuer: agent, + audience: service, + capability: { + can: 'provider/add', + with: accountDid, + nb: { + provider: 'did:web:web3.storage:providers:w3up-alpha', + consumer: space.did(), + }, + }, + proofs: [...accountAuthorization], + }) + .delegate() + const addStorageProviderResult = await accessApiVariant.invoke( + addStorageProvider + ) + assertNotError(addStorageProviderResult) + + // storage provider added. So we should be able to space/info now + const spaceInfo = await ucanto + .invoke({ + issuer: agent, + audience: service, + capability: { + can: 'space/info', + with: space.did(), + nb: { + delegations: {}, + }, + }, + proofs: [ + // space says agent can store/info with space + await ucanto.delegate({ + issuer: space, + audience: agent, + capabilities: [ + { + can: 'space/info', + with: space.did(), + }, + ], + }), + ], + }) + .delegate() + const spaceInfoResult = await accessApiVariant.invoke(spaceInfo) + assertNotError(spaceInfoResult) + assert.ok('did' in spaceInfoResult) + assert.deepEqual(spaceInfoResult.did, space.did()) + }) + } } /** @@ -337,3 +413,47 @@ async function testAuthorizeClaimProviderAdd(options) { `testing/space-storage.hasStorageProvider is true` ) } + +/** + * Create some proofs that delegate capabilities to agent to invoke on behalf of account. + * This is supposed to emulate what gets created by `access/authorize` confirmation email link click. + * + * @param {Ucanto.Principal>} agent - device agent that will be authorized + * @param {Ucanto.Signer} service + * @param {Ucanto.UCAN.Signer, NON_STANDARD>} account + * @param {Ucanto.Capabilities} capabilities + * @returns + */ +async function createAccountAuthorization( + agent, + service, + account, + capabilities = [ + { + with: 'ucan:*', + can: '*', + }, + ] +) { + const accountAuthorizesAgentClaim = await ucanto.delegate({ + issuer: account, + audience: agent, + capabilities, + }) + const serviceAttestsThatAccountAuthorizesAgent = await ucanto.delegate({ + issuer: service, + audience: agent, + capabilities: [ + { + with: service.did(), + can: 'ucan/attest', + nb: { proof: accountAuthorizesAgentClaim.cid }, + }, + ], + }) + const proofs = [ + accountAuthorizesAgentClaim, + serviceAttestsThatAccountAuthorizesAgent, + ] + return proofs +} diff --git a/packages/access-client/src/types.ts b/packages/access-client/src/types.ts index d5240e95e..f02d85a4b 100644 --- a/packages/access-client/src/types.ts +++ b/packages/access-client/src/types.ts @@ -70,6 +70,16 @@ export interface SpaceTable { } export type SpaceRecord = Selectable +export type SpaceInfoResult = + // w3up spaces registered via provider/add will have this + | { + // space did + did: DID<'key'> + } + // deprecated and may be removed if voucher/redeem is removed + /** @deprecated */ + | SpaceRecord + export interface AccountTable { did: URI<'did:'> inserted_at: Generated @@ -119,7 +129,7 @@ export interface Service { redeem: ServiceMethod } space: { - info: ServiceMethod + info: ServiceMethod 'recover-validation': ServiceMethod< SpaceRecoverValidation, EncodedDelegation<[SpaceRecover]> | undefined,