diff --git a/packages/access-api/package.json b/packages/access-api/package.json index 15b303316..822c85a60 100644 --- a/packages/access-api/package.json +++ b/packages/access-api/package.json @@ -91,6 +91,7 @@ }, "rules": { "unicorn/prefer-number-properties": "off", + "@typescript-eslint/ban-types": "off", "jsdoc/no-undefined-types": [ "error", { diff --git a/packages/access-api/src/bindings.d.ts b/packages/access-api/src/bindings.d.ts index 2528ee98e..3245e7620 100644 --- a/packages/access-api/src/bindings.d.ts +++ b/packages/access-api/src/bindings.d.ts @@ -42,7 +42,8 @@ export interface Env { SENTRY_DSN: string POSTMARK_TOKEN: string POSTMARK_SENDER?: string - + /** CSV DIDs of services that can be used to provision spaces. */ + PROVIDERS?: string DEBUG_EMAIL?: string LOGTAIL_TOKEN: string // bindings diff --git a/packages/access-api/src/config.js b/packages/access-api/src/config.js index c6f1fa21c..86514dcf2 100644 --- a/packages/access-api/src/config.js +++ b/packages/access-api/src/config.js @@ -64,6 +64,11 @@ export function loadConfig(env) { PRIVATE_KEY: vars.PRIVATE_KEY, DID: DID.parse(vars.DID).did(), + /** DIDs of services that can be used to provision spaces. */ + PROVIDERS: env.PROVIDERS + ? env.PROVIDERS.split(',').map((id) => DID.parse(id).did()) + : [DID.parse(vars.DID).did()], + UPLOAD_API_URL: env.UPLOAD_API_URL || 'https://up.web3.storage/', // bindings METRICS: diff --git a/packages/access-api/src/models/provisions.js b/packages/access-api/src/models/provisions.js index 2322b458b..c531f4f75 100644 --- a/packages/access-api/src/models/provisions.js +++ b/packages/access-api/src/models/provisions.js @@ -1,4 +1,5 @@ /* eslint-disable no-void */ +import { Failure } from '@ucanto/server' /** * @template {import("@ucanto/interface").DID} ServiceId @@ -7,11 +8,11 @@ /** * @template {import("@ucanto/interface").DID} ServiceId - * @param {ServiceId} service + * @param {ServiceId[]} services * @param {Array>} storage * @returns {Provisions} */ -export function createProvisions(service, storage = []) { +export function createProvisions(services, storage = []) { /** @type {Provisions['hasStorageProvider']} */ const hasStorageProvider = async (consumerId) => { const hasRowWithSpace = storage.some(({ space }) => space === consumerId) @@ -20,13 +21,14 @@ export function createProvisions(service, storage = []) { /** @type {Provisions['put']} */ const put = async (item) => { storage.push(item) + return {} } /** @type {Provisions['count']} */ const count = async () => { return BigInt(storage.length) } return { - service, + services, count, put, hasStorageProvider, @@ -54,11 +56,11 @@ export class DbProvisions { #db /** - * @param {ServiceId} service + * @param {ServiceId[]} services * @param {ProvisionsDatabase} db */ - constructor(service, db) { - this.service = service + constructor(services, db) { + this.services = services this.#db = db this.tableNames = { provisions: /** @type {const} */ ('provisions'), @@ -75,6 +77,35 @@ export class DbProvisions { return BigInt(size) } + /** + * Selects all rows that match the query. + * + * @param {object} query + * @param {string} [query.space] + * @param {string} [query.provider] + * @param {string} [query.sponsor] + */ + async find(query = {}) { + const { provisions } = this.tableNames + let select = this.#db + .selectFrom(provisions) + .select(['cid', 'consumer', 'provider', 'sponsor']) + + if (query.space) { + select = select.where(`${provisions}.consumer`, '=', query.space) + } + + if (query.provider) { + select = select.where(`${provisions}.provider`, '=', query.provider) + } + + if (query.sponsor) { + select = select.where(`${provisions}.sponsor`, '=', query.sponsor) + } + + return await select.execute() + } + /** @type {Provisions['put']} */ async put(item) { /** @type {ProvisionsRow} */ @@ -84,6 +115,26 @@ export class DbProvisions { provider: item.provider, sponsor: item.account, } + + // We want to ensure that a space can not have provider of multiple types, + // e.g. a space can not have both a web3.storage and nft.storage providers + // otherwise it would be unclear where stored data should be added. + // Therefore we check look for any existing rows for this space, and if + // there is a row with a different provider, we error. + // Note that this does not give us transactional guarantees and in the face + // of concurrent requests, we may still end up with multiple providers + // however we soon intend to replace this table with one that has necessary + // constraints so we take this risk for now to avoid extra migration. + const matches = await this.find({ space: row.consumer }) + const conflict = matches.find((row) => row.provider !== item.provider) + if (conflict) { + return new ConflictError({ + message: `Space ${row.consumer} can not be provisioned with ${row.provider}, it already has a ${conflict.provider} provider`, + insertion: row, + existing: conflict, + }) + } + /** @type {Array} */ const rowColumns = ['cid', 'consumer', 'provider', 'sponsor'] const insert = this.#db @@ -97,13 +148,13 @@ export class DbProvisions { } catch (error) { primaryKeyError = getCidUniquenessError(error) if (!primaryKeyError) { - throw error + return new Failure(`Unexpected error inserting provision: ${error}`) } } if (!primaryKeyError) { // no error inserting, we're done with put - return + return {} } // there was already a row with this invocation cid @@ -113,24 +164,25 @@ export class DbProvisions { .selectFrom(this.tableNames.provisions) .select(rowColumns) .where('cid', '=', row.cid) - .executeTakeFirstOrThrow() - if (deepEqual(existing, row)) { + .executeTakeFirst() + + if (!existing) { + return new Failure(`Unexpected error inserting provision`) + } + + if (existing && deepEqual(existing, row)) { // the insert failed, but the existing row is identical to the row that failed to insert. // so the put is a no-op, and we can consider it a success despite encountering the primaryKeyError - return + return {} } // this is a sign of something very wrong. throw so error reporters can report on it // and determine what led to a put() with same invocation cid but new non-cid column values - throw Object.assign( - new Error( - `Provision with cid ${item.invocation.cid} already exists with different field values` - ), - { - insertion: row, - existing, - } - ) + return new ConflictError({ + message: `Provision with cid ${item.invocation.cid} already exists with different field values`, + insertion: row, + existing, + }) } /** @type {Provisions['hasStorageProvider']} */ @@ -191,6 +243,21 @@ function extractD1Error(error) { return { cause, code } } +class ConflictError extends Failure { + /** + * @param {object} input + * @param {string} input.message + * @param {unknown} input.insertion + * @param {unknown} input.existing + */ + constructor({ message, insertion, existing }) { + super(message) + this.name = 'ConflictError' + this.insertion = insertion + this.existing = existing + } +} + /** * return whether or not the provided parameter indicates an error * writing provision to kysely database because there is already an entry diff --git a/packages/access-api/src/routes/root.js b/packages/access-api/src/routes/root.js index 95a8deedc..9fe9ac410 100644 --- a/packages/access-api/src/routes/root.js +++ b/packages/access-api/src/routes/root.js @@ -22,5 +22,6 @@ export async function postRoot(request, env) { body: new Uint8Array(await request.arrayBuffer()), headers: Object.fromEntries(request.headers.entries()), }) + return new Response(rsp.body, { headers: rsp.headers }) } diff --git a/packages/access-api/src/service/provider-add.js b/packages/access-api/src/service/provider-add.js index 7253be3d9..62c0b325e 100644 --- a/packages/access-api/src/service/provider-add.js +++ b/packages/access-api/src/service/provider-add.js @@ -36,17 +36,22 @@ export function createProviderAddHandler(options) { message: 'Issuer must be a mailto DID', } } - if (provider !== options.provisions.service) { - throw new Error(`Provider must be ${options.provisions.service}`) + // @ts-expect-error provider might not be in service providers list - it ok! + if (!options.provisions.services.includes(provider)) { + return { + error: true, + name: 'InvalidProvider', + message: `Invalid provider: ${provider}`, + } } - await options.provisions.put({ + + return await options.provisions.put({ invocation, space: consumer, // eslint-disable-next-line object-shorthand provider: /** @type {ServiceId} */ (provider), account: accountDID, }) - return {} } } diff --git a/packages/access-api/src/types/provisions.ts b/packages/access-api/src/types/provisions.ts index f87620d4b..472954ff7 100644 --- a/packages/access-api/src/types/provisions.ts +++ b/packages/access-api/src/types/provisions.ts @@ -1,8 +1,6 @@ import * as Ucanto from '@ucanto/interface' import { ProviderAdd } from '@web3-storage/capabilities/src/types' -export type AlphaStorageProvider = 'did:web:web3.storage:providers:w3up-alpha' - /** * action which results in provisionment of a space consuming a storage provider */ @@ -10,21 +8,23 @@ export interface Provision> { invocation: Ucanto.Invocation space: Ucanto.DID<'key'> account: Ucanto.DID<'mailto'> - provider: AlphaStorageProvider | ServiceDID + provider: ServiceDID } /** * stores instances of a storage provider being consumed by a consumer */ export interface ProvisionsStorage> { - service: ServiceDID + services: ServiceDID[] hasStorageProvider: (consumer: Ucanto.DID<'key'>) => Promise /** * ensure item is stored * * @param item - provision to store */ - put: (item: Provision) => Promise + put: ( + item: Provision + ) => Promise> /** * get number of stored items diff --git a/packages/access-api/src/utils/context.js b/packages/access-api/src/utils/context.js index 82cd2c143..f24e47531 100644 --- a/packages/access-api/src/utils/context.js +++ b/packages/access-api/src/utils/context.js @@ -80,7 +80,10 @@ export function getContext(request, env, ctx) { spaces: new Spaces(config.DB), validations: new Validations(config.VALIDATIONS), accounts: new Accounts(config.DB), - provisions: new DbProvisions(signer.did(), createD1Database(config.DB)), + provisions: new DbProvisions( + config.PROVIDERS, + createD1Database(config.DB) + ), }, email, uploadApi: createUploadApiConnection({ diff --git a/packages/access-api/test/access-client-agent.test.js b/packages/access-api/test/access-client-agent.test.js index 1df61b5b5..0e2f13f32 100644 --- a/packages/access-api/test/access-client-agent.test.js +++ b/packages/access-api/test/access-client-agent.test.js @@ -536,7 +536,6 @@ function watchForEmail(emails, retryAfter, abort) { } /** - * @typedef {import('./provider-add.test.js').AccessAuthorize} AccessAuthorize * @typedef {import('@web3-storage/capabilities/src/types.js').AccessConfirm} AccessConfirm * @typedef {import('./helpers/ucanto-test-utils.js').AccessService} AccessService */ diff --git a/packages/access-api/test/helpers/context.js b/packages/access-api/test/helpers/context.js index 8db2762d8..c7663b36e 100644 --- a/packages/access-api/test/helpers/context.js +++ b/packages/access-api/test/helpers/context.js @@ -25,6 +25,7 @@ dotenv.config({ */ function createBindings(env) { return { + ...env, ENV: 'test', DEBUG: 'false', DID: env.DID || 'did:web:test.web3.storage', @@ -38,9 +39,11 @@ function createBindings(env) { } /** - * @param {object} options - * @param {Partial} [options.env] - environment variables to use when configuring access-api. Defaults to process.env. - * @param {unknown} [options.globals] - globals passed into miniflare + * @typedef {object} Options + * @property {Partial} [env] - environment variables to use when configuring access-api. Defaults to process.env. + * @property {Record} [globals] - globals passed into miniflare + * + * @param {Options} options */ export async function context({ env = {}, globals } = {}) { const bindings = createBindings({ diff --git a/packages/access-api/test/helpers/utils.js b/packages/access-api/test/helpers/utils.js index 494a9788e..0bed68877 100644 --- a/packages/access-api/test/helpers/utils.js +++ b/packages/access-api/test/helpers/utils.js @@ -4,7 +4,15 @@ import * as UCAN from '@ipld/dag-ucan' import * as Types from '@ucanto/interface' import * as Voucher from '@web3-storage/capabilities/voucher' import { stringToDelegation } from '@web3-storage/access/encoding' -import { Signer } from '@ucanto/principal/ed25519' +import { ed25519 } from '@ucanto/principal' +import * as Server from '@ucanto/server' +import * as Client from '@ucanto/client' +import * as CAR from '@ucanto/transport/car' +import * as CBOR from '@ucanto/transport/cbor' +import * as Context from './context.js' +import { Access } from '@web3-storage/capabilities' +// eslint-disable-next-line unicorn/prefer-export-from +export { Context } /** * @param {Types.UCAN.View} ucan @@ -26,7 +34,7 @@ export async function send(ucan, mf) { * @param {string} email */ export async function createSpace(issuer, service, conn, email) { - const space = await Signer.generate() + const space = await ed25519.generate() const spaceDelegation = await Voucher.top.delegate({ issuer: space, audience: issuer, @@ -133,3 +141,142 @@ export function createEmail(storage) { } return email } + +/** did:key:z6Mkk89bC3JrVqKie71YEcc5M1SMVxuCgNx6zLZ8SYJsxALi */ +export const alice = ed25519.parse( + 'MgCZT5vOnYZoVAeyjnzuJIVY9J4LNtJ+f8Js0cTPuKUpFne0BVEDJjEu6quFIU8yp91/TY/+MYK8GvlKoTDnqOCovCVM=' +) +/** did:key:z6MkffDZCkCTWreg8868fG1FGFogcJj5X6PY93pPcWDn9bob */ +export const bob = ed25519.parse( + 'MgCYbj5AJfVvdrjkjNCxB3iAUwx7RQHVQ7H1sKyHy46Iose0BEevXgL1V73PD9snOCIoONgb+yQ9sycYchQC8kygR4qY=' +) +/** did:key:z6MktafZTREjJkvV5mfJxcLpNBoVPwDLhTuMg9ng7dY4zMAL */ +export const mallory = ed25519.parse( + 'MgCYtH0AvYxiQwBG6+ZXcwlXywq9tI50G2mCAUJbwrrahkO0B0elFYkl3Ulf3Q3A/EvcVY0utb4etiSE8e6pi4H0FEmU=' +) + +export const w3 = ed25519 + .parse( + 'MgCYKXoHVy7Vk4/QjcEGi+MCqjntUiasxXJ8uJKY0qh11e+0Bs8WsdqGK7xothgrDzzWD0ME7ynPjz2okXDh8537lId8=' + ) + .withDID('did:web:test.web3.storage') + +/** + * Creates a server for the given service. + * + * @template {Record} Service + * @param {object} options + * @param {Service} options.service + * @param {Server.API.Signer>} [options.id] + * @param {Server.Transport.RequestDecoder} [options.decoder] + * @param {Server.Transport.ResponseEncoder} [options.encoder] + */ +export const createServer = ({ + id = w3, + service, + decoder = CAR, + encoder = CBOR, +}) => + Server.create({ + id, + encoder, + decoder, + service, + }) + +/** + * Creates a connection to the server over given channel. + * + * @param {object} options + * @param {Types.Principal} options.id + * @param {Types.Transport.Channel} options.channel + * @param {Types.Transport.RequestEncoder} [options.encoder] + * @param {Types.Transport.ResponseDecoder} [options.decoder] + */ +export const connect = ({ id, channel, encoder = CAR, decoder = CBOR }) => + Client.connect({ + id, + channel, + encoder, + decoder, + }) + +/** + * Creates a server for the given service and an in-process connection to + * it. You can pass optional parameters to configure identifier or transports + * used. + * + * @template {Record} Service + * @param {object} options + * @param {Service} options.service + * @param {Server.API.Signer>} options.id + * @param {object} [options.server] + * @param {Types.Transport.RequestDecoder} [options.server.decoder] + * @param {Types.Transport.ResponseEncoder} [options.server.encoder] + * @param {object} [options.client] + * @param {Types.Transport.RequestEncoder} [options.client.encoder] + * @param {Types.Transport.ResponseDecoder} [options.client.decoder] + */ +export const createChannel = ({ id = w3, service, ...etc }) => { + const server = createServer({ id, service, ...etc.server }) + const client = connect({ id, channel: server, ...etc.client }) + + return { server, client } +} + +/** + * @param {Context.Options} options + */ +export const createContextWithMailbox = async ({ env, globals } = {}) => { + /** @type {{to:string, url:string}[]} */ + const emails = [] + const email = createEmail(emails) + const context = await Context.context({ + env, + globals: { + email, + ...globals, + }, + }) + + return { ...context, emails } +} + +/** + * Utility function that creates a delegation from account to agent and an + * attestation from service to proof it. Proofs can be used to invoke any + * capability on behalf of the account. + * + * @param {object} input + * @param {Types.UCAN.Signer>} input.account + * @param {Types.Signer>} input.service + * @param {Types.Signer} input.agent + */ +export const createAuthorization = async ({ account, agent, service }) => { + // Issue authorization from account DID to agent DID + const authorization = await Server.delegate({ + issuer: account, + audience: agent, + capabilities: [ + { + with: 'ucan:*', + can: '*', + }, + ], + expiration: Infinity, + }) + + const attest = await Access.session + .invoke({ + issuer: service, + audience: agent, + with: service.did(), + nb: { + proof: authorization.cid, + }, + expiration: Infinity, + }) + .delegate() + + return [authorization, attest] +} diff --git a/packages/access-api/test/provider-add.test.js b/packages/access-api/test/provider-add.test.js index d215e5d9c..7be6fa401 100644 --- a/packages/access-api/test/provider-add.test.js +++ b/packages/access-api/test/provider-add.test.js @@ -1,282 +1,282 @@ import { assertNotError, - createTesterFromContext, - createTesterFromHandler, + registerSpaces, warnOnErrorResult, } from './helpers/ucanto-test-utils.js' import * as principal from '@ucanto/principal' -import * as provider from '@web3-storage/capabilities/provider' import * as assert from 'assert' -import { createProviderAddHandler } from '../src/service/provider-add.js' -import { context } from './helpers/context.js' import * as ucanto from '@ucanto/core' import * as Ucanto from '@ucanto/interface' import { Access, Provider } from '@web3-storage/capabilities' import * as delegationsResponse from '../src/utils/delegations-response.js' -import { createProvisions } from '../src/models/provisions.js' import { NON_STANDARD } from '@ipld/dag-ucan/signature' -import { createEmail } from './helpers/utils.js' - -for (const providerAddHandlerVariant of /** @type {const} */ ([ - { - name: 'handled by createProviderAddHandler', - ...(() => { - const spaceWithStorageProvider = principal.ed25519.generate() - const service = { - did: () => /** @type {const} */ ('did:web:web3.storage'), - } - const provisions = createProvisions(service.did()) - return { - spaceWithStorageProvider, - provisions, - ...createTesterFromHandler(() => - createProviderAddHandler({ - provisions, - }) - ), - } - })(), - }, -])) { - describe(`provider/add ${providerAddHandlerVariant.name}`, () => { - it(`can be invoked by did:key`, async () => { - const space = await principal.ed25519.generate() - const issuer = await providerAddHandlerVariant.issuer - const result = await providerAddHandlerVariant.invoke( - await provider.add - .invoke({ - issuer, - audience: await providerAddHandlerVariant.audience, - with: `did:mailto:example.com:foo`, - nb: { - consumer: space.did(), - provider: providerAddHandlerVariant.provisions.service, - }, - }) - .delegate() - ) - assertNotError(result) +import { + createContextWithMailbox, + createAuthorization, + Context, +} from './helpers/utils.js' + +describe(`provider/add`, () => { + it(`can invoke as did:mailto after authorize confirmation`, async () => { + const { space, agent, account, ...context } = await setup() + + await registerSpaces([space], { + ...context, + account, + agent, + }) + + await testAuthorizeClaimProviderAdd({ + deviceA: await principal.ed25519.generate(), + accountA: account, + space, + conn: context.conn, + service: context.service, + emails: context.emails, + mf: context.mf, }) }) -} -for (const accessApiVariant of /** @type {const} */ ([ - { - name: 'handled by access-api in miniflare', - ...(() => { - const spaceWithStorageProvider = principal.ed25519.generate() - /** @type {{to:string, url:string}[]} */ - const emails = [] - const email = createEmail(emails) - const features = new Set([ - 'provider/add', - 'access/delegate', - 'store/info', - ]) - return { - spaceWithStorageProvider, - emails, - features, - ...createTesterFromContext( - () => - context({ - globals: { - email, - }, - }), - { - registerSpaces: [spaceWithStorageProvider], - account: { did: () => /** @type {const} */ ('did:mailto:foo') }, - } - ), - } - })(), - }, -])) { - describe(`provider/add ${accessApiVariant.name}`, () => { - it(`can invoke as did:mailto after authorize confirmation`, async () => { - await testAuthorizeClaimProviderAdd({ - deviceA: await principal.ed25519.generate(), - accountA: { - did: () => /** @type {const} */ (`did:mailto:example.com:foo`), + it('provider/add allows for access/delegate', async () => { + const { space, agent, account, service, ...context } = await setup() + + const accountAuthorizesAgentClaim = await ucanto.delegate({ + issuer: account, + audience: agent, + capabilities: [ + { + with: 'ucan:*', + can: '*', }, - space: await principal.ed25519.generate(), - invoke: accessApiVariant.invoke, - service: await accessApiVariant.audience, - emails: accessApiVariant.emails, - miniflare: await accessApiVariant.miniflare, - }) + ], + }) + const serviceAttestsThatAccountAuthorizesAgent = await ucanto.delegate({ + issuer: service, + audience: agent, + capabilities: [ + { + with: service.did(), + can: 'ucan/attest', + nb: { proof: accountAuthorizesAgentClaim.cid }, + }, + ], }) + const sessionProofs = [ + accountAuthorizesAgentClaim, + serviceAttestsThatAccountAuthorizesAgent, + ] + const addStorageProviderResult = await ucanto + .invoke({ + issuer: agent, + audience: service, + capability: { + can: 'provider/add', + with: account.did(), + nb: { + provider: service.did(), + consumer: space.did(), + }, + }, + proofs: [...sessionProofs], + }) + .execute(context.conn) + + assertNotError(addStorageProviderResult) + + // storage provider added. So we should be able to delegate now + const accessDelegateResult = await ucanto + .invoke({ + issuer: agent, + audience: service, + capability: { + can: 'access/delegate', + with: space.did(), + nb: { + delegations: {}, + }, + }, + proofs: [ + // space says agent can access/delegate with space + await ucanto.delegate({ + issuer: space, + audience: agent, + capabilities: [ + { + can: 'access/delegate', + with: space.did(), + }, + ], + }), + ], + }) + .execute(context.conn) + assertNotError(accessDelegateResult) }) - if ( - ['provider/add', 'access/delegate'].every((f) => - accessApiVariant.features.has(f) + it('provider/add allows for store/info ', async () => { + const { space, agent, account, service, ...context } = await setup() + + const accountAuthorization = await createAccountAuthorization( + agent, + service, + principal.Absentee.from({ + id: account.did(), + }) ) - ) { - it('provider/add allows for access/delegate', 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 accountAuthorizesAgentClaim = await ucanto.delegate({ - issuer: principal.Absentee.from({ id: accountDid }), - audience: agent, - capabilities: [ - { - with: 'ucan:*', - can: '*', + const addStorageProviderResult = await ucanto + .invoke({ + issuer: agent, + audience: service, + capability: { + can: 'provider/add', + with: account.did(), + nb: { + provider: service.did(), + consumer: space.did(), }, - ], + }, + proofs: [...accountAuthorization], }) - const serviceAttestsThatAccountAuthorizesAgent = await ucanto.delegate({ - issuer: service, - audience: agent, - capabilities: [ - { - with: service.did(), - can: 'ucan/attest', - nb: { proof: accountAuthorizesAgentClaim.cid }, + .execute(context.conn) + + assertNotError(addStorageProviderResult) + + // storage provider added. So we should be able to space/info now + const spaceInfoResult = 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(), + }, + ], + }), ], }) - const sessionProofs = [ - accountAuthorizesAgentClaim, - serviceAttestsThatAccountAuthorizesAgent, - ] - const addStorageProvider = await ucanto - .invoke({ - issuer: agent, - audience: service, - capability: { - can: 'provider/add', - with: accountDid, - nb: { - provider: service.did(), - consumer: space.did(), - }, - }, - proofs: [...sessionProofs], - }) - .delegate() - const addStorageProviderResult = await accessApiVariant.invoke( - addStorageProvider - ) - assertNotError(addStorageProviderResult) - - // storage provider added. So we should be able to delegate now - const accessDelegate = await ucanto - .invoke({ - issuer: agent, - audience: service, - capability: { - can: 'access/delegate', - with: space.did(), - nb: { - delegations: {}, - }, - }, - proofs: [ - // space says agent can access/delegate with space - await ucanto.delegate({ - issuer: space, - audience: agent, - capabilities: [ - { - can: 'access/delegate', - with: space.did(), - }, - ], - }), - ], - }) - .delegate() - const accessDelegateResult = await accessApiVariant.invoke(accessDelegate) - assertNotError(accessDelegateResult) + .execute(context.conn) + assertNotError(spaceInfoResult) + assert.ok('did' in spaceInfoResult) + assert.deepEqual(spaceInfoResult.did, space.did()) + }) + + it('add providers set in env', async () => { + const { space, agent, account, service, ...context } = await setup({ + env: { + PROVIDERS: 'did:web:nft.storage,did:web:web3.storage', + }, }) - } + const proofs = await createAuthorization({ agent, service, account }) + const addNFTStorage = await Provider.add + .invoke({ + issuer: agent, + audience: service, + with: account.did(), + nb: { + provider: 'did:web:nft.storage', + consumer: space.did(), + }, + proofs, + }) + .execute(context.conn) - 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: service.did(), - 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()) + assertNotError(addNFTStorage) + + const w3space = await principal.ed25519.generate() + const addW3Storage = await Provider.add + .invoke({ + issuer: agent, + audience: service, + with: account.did(), + nb: { + provider: 'did:web:web3.storage', + consumer: w3space.did(), + }, + proofs, + }) + .execute(context.conn) + + assertNotError(addW3Storage) + }) + + it('provider/add can not add two diff providers to the same space', async () => { + const { space, agent, account, service, ...context } = await setup({ + env: { + PROVIDERS: 'did:web:nft.storage,did:web:web3.storage', + }, }) - } -} + + const proofs = await createAuthorization({ agent, service, account }) + const addNFTStorage = await Provider.add + .invoke({ + issuer: agent, + audience: service, + with: account.did(), + nb: { + provider: 'did:web:nft.storage', + consumer: space.did(), + }, + proofs, + }) + .execute(context.conn) + + assertNotError(addNFTStorage) + + const addW3Storage = await Provider.add + .invoke({ + issuer: agent, + audience: service, + with: account.did(), + nb: { + provider: 'did:web:web3.storage', + consumer: space.did(), + }, + proofs, + }) + .execute(context.conn) + + assert.equal( + addW3Storage.error, + true, + 'Provider already added to this space' + ) + + assert.match( + addW3Storage.error ? addW3Storage?.message : '', + /it already has a did:web:nft.storage provider/ + ) + }) +}) /** - * @typedef {import('../src/utils/email.js').ValidationEmailSend} ValidationEmailSend + * Sets up test context and creates various principals used in this test suite. + * + * @param {Context.Options} options */ +const setup = async (options = {}) => { + const context = await createContextWithMailbox(options) + const space = await principal.ed25519.generate() + const agent = await principal.ed25519.generate() + const account = principal.Absentee.from({ id: 'did:mailto:foo' }) + + return { ...context, space, agent, account } +} /** - * @typedef {import('@web3-storage/capabilities/types').AccessClaim} AccessClaim - * @typedef {import('@web3-storage/capabilities/types').AccessAuthorize} AccessAuthorize - * @typedef {import('@web3-storage/capabilities/types').ProviderAdd} ProviderAdd + * @typedef {import('../src/utils/email.js').ValidationEmailSend} ValidationEmailSend */ /** @@ -285,32 +285,32 @@ for (const accessApiVariant of /** @type {const} */ ([ * @param {Ucanto.Signer>} options.space * @param {Ucanto.Principal>} options.accountA * @param {Ucanto.Principal>} options.service - web3.storage service - * @param {import('miniflare').Miniflare} options.miniflare - * @param {import('../src/types/ucanto.js').ServiceInvoke} options.invoke + * @param {import('miniflare').Miniflare} options.mf + * @param {import('@ucanto/interface').ConnectionView} options.conn * @param {ValidationEmailSend[]} options.emails */ async function testAuthorizeClaimProviderAdd(options) { - const { accountA, deviceA, miniflare, service, space, emails } = options + const { accountA, conn, deviceA, mf, service, space, emails } = options // authorize - await options.invoke( - await Access.authorize - .invoke({ - issuer: deviceA, - audience: service, - with: deviceA.did(), - nb: { - att: [{ can: '*' }], - iss: accountA.did(), - }, - }) - .delegate() - ) + + await Access.authorize + .invoke({ + issuer: deviceA, + audience: service, + with: deviceA.did(), + nb: { + att: [{ can: '*' }], + iss: accountA.did(), + }, + }) + .execute(conn) + const validationEmail = emails.at(-1) assert.ok(validationEmail, 'has email after authorize') const confirmationUrl = validationEmail.url assert.ok(typeof confirmationUrl === 'string', 'confirmationUrl is string') - const confirmEmailPostResponse = await miniflare.dispatchFetch( + const confirmEmailPostResponse = await mf.dispatchFetch( new URL(confirmationUrl), { method: 'POST' } ) @@ -321,15 +321,14 @@ async function testAuthorizeClaimProviderAdd(options) { ) // claim as deviceA - const claimAsDeviceAResult = await options.invoke( - await Access.claim - .invoke({ - issuer: deviceA, - audience: service, - with: deviceA.did(), - }) - .delegate() - ) + const claimAsDeviceAResult = await Access.claim + .invoke({ + issuer: deviceA, + audience: service, + with: deviceA.did(), + }) + .execute(conn) + assert.ok( claimAsDeviceAResult && typeof claimAsDeviceAResult === 'object', `claimAsDeviceAResult is an object` @@ -361,20 +360,19 @@ async function testAuthorizeClaimProviderAdd(options) { ) // provider/add - const providerAddAsAccountResult = await options.invoke( - await Provider.add - .invoke({ - issuer: deviceA, - audience: service, - with: accountA.did(), - nb: { - provider: service.did(), - consumer: space.did(), - }, - proofs: claimedDelegations, - }) - .delegate() - ) + const providerAddAsAccountResult = await Provider.add + .invoke({ + issuer: deviceA, + audience: service, + with: accountA.did(), + nb: { + provider: service.did(), + consumer: space.did(), + }, + proofs: claimedDelegations, + }) + .execute(conn) + assert.ok( providerAddAsAccountResult && typeof providerAddAsAccountResult === 'object', @@ -382,19 +380,18 @@ async function testAuthorizeClaimProviderAdd(options) { ) assertNotError(providerAddAsAccountResult) - const spaceStorageResult = await options.invoke( + const spaceStorageResult = await ucanto + .invoke({ + issuer: space, + audience: service, + capability: { + can: 'testing/space-storage', + with: space.did(), + }, + }) // @ts-ignore - not in service type because only enabled while testing - await ucanto - .invoke({ - issuer: space, - audience: service, - capability: { - can: 'testing/space-storage', - with: space.did(), - }, - }) - .delegate() - ) + .execute(conn) + assert.ok( spaceStorageResult && typeof spaceStorageResult === 'object' && diff --git a/packages/access-api/test/provisions.test.js b/packages/access-api/test/provisions.test.js index c28b0142b..8fc8d6102 100644 --- a/packages/access-api/test/provisions.test.js +++ b/packages/access-api/test/provisions.test.js @@ -10,7 +10,7 @@ describe('DbProvisions', () => { it('should persist provisions', async () => { const { d1, service } = await context() const db = createD1Database(d1) - const storage = new DbProvisions(service.did(), db) + const storage = new DbProvisions([service.did()], db) const count = 2 + Math.round(Math.random() * 3) const spaceA = await principal.ed25519.generate() const [firstProvision, ...lastProvisions] = await Promise.all( @@ -67,16 +67,17 @@ describe('DbProvisions', () => { space: /** @type {const} */ ('did:key:foo'), account: /** @type {const} */ ('did:mailto:foo'), // note this type assertion is wrong, but useful to set up the test - provider: - /** @type {import('../src/types/provisions.js').AlphaStorageProvider} */ ( - 'did:provider:foo' - ), + provider: /** @type {import('@ucanto/interface').DID<'web'>} */ ( + 'did:provider:foo' + ), } - const putModifiedFirstProvision = () => storage.put(modifiedFirstProvision) - await assert.rejects( - putModifiedFirstProvision(), + const result = await storage.put(modifiedFirstProvision) + assert.equal( + result.error && result.name, + 'ConflictError', 'cannot put with same cid but different derived fields' ) + const provisionForFakeConsumer = await storage.findForConsumer( modifiedFirstProvision.space ) diff --git a/packages/access-api/wrangler.toml b/packages/access-api/wrangler.toml index fd22ec0b1..6b381ab02 100644 --- a/packages/access-api/wrangler.toml +++ b/packages/access-api/wrangler.toml @@ -64,7 +64,6 @@ unsafe = { bindings = [ [env.staging] name = "w3access-staging" workers_dev = true -vars = { ENV = "staging", DEBUG = "false", DID = "did:web:staging.web3.storage", UPLOAD_API_URL = "https://staging.up.web3.storage" } build = { command = "scripts/cli.js build --env staging", watch_dir = "src" } kv_namespaces = [ { binding = "SPACES", id = "b0e5ca990dda4e3784a1741dfa28a52e" }, @@ -76,6 +75,12 @@ d1_databases = [ unsafe = { bindings = [ { type = "analytics_engine", dataset = "W3ACCESS_METRICS", name = "W3ACCESS_METRICS" }, ] } +[env.staging.vars] +DEBUG = "false" +DID = "did:web:staging.web3.storage" +ENV = "staging" +UPLOAD_API_URL = "https://staging.up.web3.storage" +PROVIDERS = "did:web:staging.web3.storage,did:web:staging.nft.storage" [[env.staging.r2_buckets]] binding = "DELEGATIONS_BUCKET" bucket_name = "w3up-delegations-staging-0" @@ -104,3 +109,4 @@ DID = "did:web:web3.storage" ENV = "production" # production upload-api - bypass up.web3.storage due to cloudflare dns issue https://github.com/web3-storage/w3protocol/issues/363#issuecomment-1410887488 UPLOAD_API_URL = "https://3bd9h7xn3j.execute-api.us-west-2.amazonaws.com/" +PROVIDERS = "did:web:web3.storage,did:web:nft.storage" diff --git a/packages/capabilities/src/types.ts b/packages/capabilities/src/types.ts index 25442b907..415d40048 100644 --- a/packages/capabilities/src/types.ts +++ b/packages/capabilities/src/types.ts @@ -52,7 +52,11 @@ export type AccessConfirm = InferInvokedCapability export type ProviderAdd = InferInvokedCapability // eslint-disable-next-line @typescript-eslint/no-empty-interface export interface ProviderAddSuccess {} -export type ProviderAddFailure = Ucanto.Failure +export type ProviderAddFailure = InvalidProvider | Ucanto.Failure + +export interface InvalidProvider extends Ucanto.Failure { + name: 'InvalidProvider' +} // Space export type Space = InferInvokedCapability