diff --git a/packages/capabilities/src/admin.js b/packages/capabilities/src/admin.js new file mode 100644 index 000000000..8dbb707f9 --- /dev/null +++ b/packages/capabilities/src/admin.js @@ -0,0 +1,48 @@ +import { capability, struct, ok, Link } from '@ucanto/validator' +import { equalWith, and, equal, ProviderDID } from './utils.js' + +export const admin = capability({ + can: 'admin/*', + with: ProviderDID, + derives: equalWith, +}) + +export const upload = { + /** + * Capability can be invoked by a provider to get information about a content CID. + */ + inspect: capability({ + can: 'admin/upload/inspect', + with: ProviderDID, + nb: struct({ + root: Link, + }), + derives: (child, parent) => { + return ( + and(equalWith(child, parent)) || + and(equal(child.nb.root, parent.nb.root, 'root')) || + ok({}) + ) + }, + }), +} + +export const store = { + /** + * Capability can be invoked by a provider to get information an upload shard CID. + */ + inspect: capability({ + can: 'admin/store/inspect', + with: ProviderDID, + nb: struct({ + link: Link, + }), + derives: (child, parent) => { + return ( + and(equalWith(child, parent)) || + and(equal(child.nb.link, parent.nb.link, 'link')) || + ok({}) + ) + }, + }), +} diff --git a/packages/capabilities/src/index.js b/packages/capabilities/src/index.js index eb59739d4..455f0e114 100644 --- a/packages/capabilities/src/index.js +++ b/packages/capabilities/src/index.js @@ -9,6 +9,7 @@ import * as Consumer from './consumer.js' import * as Customer from './customer.js' import * as Console from './console.js' import * as RateLimit from './rate-limit.js' +import * as Admin from './admin.js' import * as Subscription from './subscription.js' import * as Filecoin from './filecoin.js' @@ -26,6 +27,7 @@ export { RateLimit, Subscription, Filecoin, + Admin, } /** @type {import('./types.js').AbilitiesArray} */ @@ -59,4 +61,7 @@ export const abilitiesAsStrings = [ Filecoin.dealQueue.can, Filecoin.dealAdd.can, Filecoin.chainTrackerInfo.can, + Admin.admin.can, + Admin.upload.inspect.can, + Admin.store.inspect.can, ] diff --git a/packages/capabilities/src/types.ts b/packages/capabilities/src/types.ts index f6e90a62a..9897b4cc3 100644 --- a/packages/capabilities/src/types.ts +++ b/packages/capabilities/src/types.ts @@ -14,6 +14,7 @@ import * as ConsumerCaps from './consumer.js' import * as SubscriptionCaps from './subscription.js' import * as RateLimitCaps from './rate-limit.js' import * as FilecoinCaps from './filecoin.js' +import * as AdminCaps from './admin.js' export type { Unit } @@ -211,6 +212,26 @@ export type Store = InferInvokedCapability export type StoreAdd = InferInvokedCapability export type StoreRemove = InferInvokedCapability export type StoreList = InferInvokedCapability +// Admin +export type Admin = InferInvokedCapability +export type AdminUploadInspect = InferInvokedCapability< + typeof AdminCaps.upload.inspect +> +export type AdminStoreInspect = InferInvokedCapability< + typeof AdminCaps.store.inspect +> +export interface SpaceAdmin { + did: DID + insertedAt: string +} +export interface AdminUploadInspectSuccess { + spaces: SpaceAdmin[] +} +export type AdminUploadInspectFailure = Ucanto.Failure +export interface AdminStoreInspectSuccess { + spaces: SpaceAdmin[] +} +export type AdminStoreInspectFailure = Ucanto.Failure // Filecoin export type FilecoinQueue = InferInvokedCapability< typeof FilecoinCaps.filecoinQueue @@ -263,5 +284,8 @@ export type AbilitiesArray = [ AggregateAdd['can'], DealQueue['can'], DealAdd['can'], - ChainTrackerInfo['can'] + ChainTrackerInfo['can'], + Admin['can'], + AdminUploadInspect['can'], + AdminStoreInspect['can'] ] diff --git a/packages/capabilities/src/utils.js b/packages/capabilities/src/utils.js index 62655b39d..01eb02c86 100644 --- a/packages/capabilities/src/utils.js +++ b/packages/capabilities/src/utils.js @@ -1,7 +1,10 @@ -import { fail, ok } from '@ucanto/validator' +import { DID, fail, ok } from '@ucanto/validator' // eslint-disable-next-line no-unused-vars import * as Types from '@ucanto/interface' +// e.g. did:web:web3.storage or did:web:staging.web3.storage +export const ProviderDID = DID.match({ method: 'web' }) + /** * Check URI can be delegated * diff --git a/packages/capabilities/test/capabilities/admin.test.js b/packages/capabilities/test/capabilities/admin.test.js new file mode 100644 index 000000000..6a5a2b7be --- /dev/null +++ b/packages/capabilities/test/capabilities/admin.test.js @@ -0,0 +1,194 @@ +import assert from 'assert' +import { access } from '@ucanto/validator' +import { delegate } from '@ucanto/core' +import { Verifier } from '@ucanto/principal/ed25519' +import * as Admin from '../../src/admin.js' +import { service, alice, readmeCID } from '../helpers/fixtures.js' + +describe('admin/upload/inspect', async function () { + const agent = alice + it('can be invoked by the service on the service', async function () { + const invocation = Admin.upload.inspect.invoke({ + issuer: service, + audience: service, + with: service.did(), + nb: { + root: readmeCID, + }, + }) + const result = await access(await invocation.delegate(), { + capability: Admin.upload.inspect, + principal: Verifier, + authority: service, + }) + if (result.error) { + assert.fail('error in self issue') + } else { + assert.deepEqual(result.ok.audience.did(), service.did()) + assert.equal(result.ok.capability.can, 'admin/upload/inspect') + assert.deepEqual(result.ok.capability.nb, { + root: readmeCID, + }) + } + }) + + it('can be invoked by an agent delegated permissions by the service', async function () { + const auth = Admin.upload.inspect.invoke({ + issuer: agent, + audience: service, + with: service.did(), + nb: { + root: readmeCID, + }, + proofs: [ + await delegate({ + issuer: service, + audience: agent, + capabilities: [{ with: service.did(), can: 'admin/upload/inspect' }], + }), + ], + }) + const result = await access(await auth.delegate(), { + capability: Admin.upload.inspect, + principal: Verifier, + authority: service, + }) + if (result.error) { + assert.fail( + `error in self issue: ${JSON.stringify(result.error.message)}` + ) + } else { + assert.deepEqual(result.ok.audience.did(), service.did()) + assert.equal(result.ok.capability.can, 'admin/upload/inspect') + assert.deepEqual(result.ok.capability.nb, { + root: readmeCID, + }) + } + }) + + it('fails without a delegation from the service delegation', async function () { + const agent = alice + const auth = Admin.upload.inspect.invoke({ + issuer: agent, + audience: service, + with: service.did(), + nb: { + root: readmeCID, + }, + }) + + const result = await access(await auth.delegate(), { + capability: Admin.upload.inspect, + principal: Verifier, + authority: service, + }) + + assert.equal(result.error?.message.includes('not authorized'), true) + }) + + it('requires nb.root', async function () { + assert.throws(() => { + Admin.upload.inspect.invoke({ + issuer: alice, + audience: service, + with: service.did(), + // @ts-ignore + nb: {}, + }) + }, /Error: Invalid 'nb' - Object contains invalid field "root"/) + }) +}) + +describe('admin/store/inspect', function () { + const agent = alice + it('can be invoked by the service on the service', async function () { + const invocation = Admin.store.inspect.invoke({ + issuer: service, + audience: service, + with: service.did(), + nb: { + link: readmeCID, + }, + }) + const result = await access(await invocation.delegate(), { + capability: Admin.store.inspect, + principal: Verifier, + authority: service, + }) + if (result.error) { + assert.fail('error in self issue') + } else { + assert.deepEqual(result.ok.audience.did(), service.did()) + assert.equal(result.ok.capability.can, 'admin/store/inspect') + assert.deepEqual(result.ok.capability.nb, { + link: readmeCID, + }) + } + }) + + it('can be invoked by an agent delegated permissions by the service', async function () { + const auth = Admin.store.inspect.invoke({ + issuer: agent, + audience: service, + with: service.did(), + nb: { + link: readmeCID, + }, + proofs: [ + await delegate({ + issuer: service, + audience: agent, + capabilities: [{ with: service.did(), can: 'admin/store/inspect' }], + }), + ], + }) + const result = await access(await auth.delegate(), { + capability: Admin.store.inspect, + principal: Verifier, + authority: service, + }) + if (result.error) { + assert.fail( + `error in self issue: ${JSON.stringify(result.error.message)}` + ) + } else { + assert.deepEqual(result.ok.audience.did(), service.did()) + assert.equal(result.ok.capability.can, 'admin/store/inspect') + assert.deepEqual(result.ok.capability.nb, { + link: readmeCID, + }) + } + }) + + it('fails without a delegation from the service delegation', async function () { + const agent = alice + const auth = Admin.store.inspect.invoke({ + issuer: agent, + audience: service, + with: service.did(), + nb: { + link: readmeCID, + }, + }) + + const result = await access(await auth.delegate(), { + capability: Admin.store.inspect, + principal: Verifier, + authority: service, + }) + + assert.equal(result.error?.message.includes('not authorized'), true) + }) + + it('requires nb.shard', async function () { + assert.throws(() => { + Admin.store.inspect.invoke({ + issuer: alice, + audience: service, + with: service.did(), + // @ts-ignore + nb: {}, + }) + }, /Error: Invalid 'nb' - Object contains invalid field "link"/) + }) +}) diff --git a/packages/capabilities/test/helpers/fixtures.js b/packages/capabilities/test/helpers/fixtures.js index a78c70b0d..a4ae2d1b4 100644 --- a/packages/capabilities/test/helpers/fixtures.js +++ b/packages/capabilities/test/helpers/fixtures.js @@ -1,3 +1,4 @@ +import { parseLink } from '@ucanto/core' import { Absentee } from '@ucanto/principal' import { Signer } from '@ucanto/principal/ed25519' @@ -31,3 +32,7 @@ export const malloryAccount = Absentee.from({ export const service = Signer.parse( 'MgCYKXoHVy7Vk4/QjcEGi+MCqjntUiasxXJ8uJKY0qh11e+0Bs8WsdqGK7xothgrDzzWD0ME7ynPjz2okXDh8537lId8=' ).withDID('did:web:test.web3.storage') + +export const readmeCID = parseLink( + 'bafybeihqfdg2ereoijjoyrqzr2x2wsasqm2udurforw7pa3tvbnxhojao4' +) diff --git a/packages/upload-api/src/admin.js b/packages/upload-api/src/admin.js new file mode 100644 index 000000000..d43255d00 --- /dev/null +++ b/packages/upload-api/src/admin.js @@ -0,0 +1,16 @@ +import * as Types from './types.js' +import * as StoreInspect from './admin/store/inspect.js' +import * as UploadInspect from './admin/upload/inspect.js' + +/** + * @param {Types.AdminServiceContext} context + */ +export const createService = (context) => ({ + store: { + inspect: StoreInspect.provide(context), + }, + + upload: { + inspect: UploadInspect.provide(context), + }, +}) diff --git a/packages/upload-api/src/admin/store/inspect.js b/packages/upload-api/src/admin/store/inspect.js new file mode 100644 index 000000000..ae1cf6c4e --- /dev/null +++ b/packages/upload-api/src/admin/store/inspect.js @@ -0,0 +1,45 @@ +import * as API from '../../types.js' +import * as Provider from '@ucanto/server' +import { Admin } from '@web3-storage/capabilities' + +/** + * @param {API.AdminServiceContext} context + */ +export const provide = (context) => + Provider.provide(Admin.store.inspect, (input) => inspect(input, context)) + +/** + * @param {API.Input} input + * @param {API.AdminServiceContext} context + * @returns {Promise} + */ +const inspect = async ({ capability }, context) => { + /** + * Ensure that resource is the service DID, which implies it's either + * invoked by service itself or an authorized delegate (like admin). + * In other words no user will be able to invoke this unless service + * explicitly delegated capability to them to do so. + */ + if (capability.with !== context.signer.did()) { + return { error: new UnknownProvider(capability.with) } + } + + return { + ok: await context.storeTable.getCID(capability.nb.link), + } +} + +class UnknownProvider extends Provider.Failure { + /** + * @param {API.DID} did + */ + constructor(did) { + super() + this.did = did + this.name = /** @type {const} */ ('UnknownProvider') + } + + describe() { + return `Provider ${this.did} not found` + } +} diff --git a/packages/upload-api/src/admin/upload/inspect.js b/packages/upload-api/src/admin/upload/inspect.js new file mode 100644 index 000000000..bb5f2e081 --- /dev/null +++ b/packages/upload-api/src/admin/upload/inspect.js @@ -0,0 +1,45 @@ +import * as API from '../../types.js' +import * as Provider from '@ucanto/server' +import { Admin } from '@web3-storage/capabilities' + +/** + * @param {API.AdminServiceContext} context + */ +export const provide = (context) => + Provider.provide(Admin.upload.inspect, (input) => inspect(input, context)) + +/** + * @param {API.Input} input + * @param {API.AdminServiceContext} context + * @returns {Promise} + */ +const inspect = async ({ capability }, context) => { + /** + * Ensure that resource is the service DID, which implies it's either + * invoked by service itself or an authorized delegate (like admin). + * In other words no user will be able to invoke this unless service + * explicitly delegated capability to them to do so. + */ + if (capability.with !== context.signer.did()) { + return { error: new UnknownProvider(capability.with) } + } + + return { + ok: await context.uploadTable.getCID(capability.nb.root), + } +} + +class UnknownProvider extends Provider.Failure { + /** + * @param {API.DID} did + */ + constructor(did) { + super() + this.did = did + this.name = /** @type {const} */ ('UnknownProvider') + } + + describe() { + return `Provider ${this.did} not found` + } +} diff --git a/packages/upload-api/src/lib.js b/packages/upload-api/src/lib.js index b6b1d3048..7e518aff2 100644 --- a/packages/upload-api/src/lib.js +++ b/packages/upload-api/src/lib.js @@ -12,6 +12,7 @@ import { createService as createCustomerService } from './customer.js' import { createService as createSpaceService } from './space.js' import { createService as createProviderService } from './provider.js' import { createService as createSubscriptionService } from './subscription.js' +import { createService as createAdminService } from './admin.js' import { createService as createRateLimitService } from './rate-limit.js' export * from './types.js' @@ -37,11 +38,12 @@ export const createService = (context) => ({ consumer: createConsumerService(context), customer: createCustomerService(context), provider: createProviderService(context), + 'rate-limit': createRateLimitService(context), + admin: createAdminService(context), space: createSpaceService(context), store: createStoreService(context), - upload: createUploadService(context), subscription: createSubscriptionService(context), - 'rate-limit': createRateLimitService(context), + upload: createUploadService(context), }) /** diff --git a/packages/upload-api/src/types.ts b/packages/upload-api/src/types.ts index 5f915dff5..2216f85c2 100644 --- a/packages/upload-api/src/types.ts +++ b/packages/upload-api/src/types.ts @@ -88,6 +88,12 @@ import { RateLimitList, RateLimitListSuccess, RateLimitListFailure, + AdminStoreInspect, + AdminStoreInspectSuccess, + AdminStoreInspectFailure, + AdminUploadInspect, + AdminUploadInspectSuccess, + AdminUploadInspectFailure, ProviderAdd, ProviderAddSuccess, ProviderAddFailure, @@ -170,6 +176,22 @@ export interface Service { RateLimitListFailure > } + admin: { + store: { + inspect: ServiceMethod< + AdminStoreInspect, + AdminStoreInspectSuccess, + AdminStoreInspectFailure + > + } + upload: { + inspect: ServiceMethod< + AdminUploadInspect, + AdminUploadInspectSuccess, + AdminUploadInspectFailure + > + } + } provider: { add: ServiceMethod } @@ -214,6 +236,12 @@ export interface CustomerServiceContext { provisionsStorage: Provisions } +export interface AdminServiceContext { + signer: EdSigner.Signer + uploadTable: UploadTable + storeTable: StoreTable +} + export interface ConsoleServiceContext {} export interface SpaceServiceContext { @@ -306,6 +334,7 @@ export interface DudewhereBucket { } export interface StoreTable { + getCID: (link: UnknownLink) => Promise exists: (space: DID, link: UnknownLink) => Promise insert: (item: StoreAddInput) => Promise remove: (space: DID, link: UnknownLink) => Promise @@ -323,6 +352,7 @@ export interface TestStoreTable { } export interface UploadTable { + getCID: (link: UnknownLink) => Promise exists: (space: DID, root: UnknownLink) => Promise insert: (item: UploadAddInput) => Promise remove: (space: DID, root: UnknownLink) => Promise @@ -346,6 +376,14 @@ export type SubscriptionGetResult = Result< SubscriptionGetSuccess, SubscriptionGetFailure > +export type AdminStoreInspectResult = Result< + AdminStoreInspectSuccess, + AdminStoreInspectFailure +> +export type AdminUploadInspectResult = Result< + AdminUploadInspectSuccess, + AdminUploadInspectFailure +> export interface StoreAddInput { space: DID @@ -359,6 +397,10 @@ export interface StoreAddInput { export interface StoreAddOutput extends Omit {} +export interface StoreGetOk { + spaces: Array<{ did: DID; insertedAt: string }> +} + export interface StoreListItem extends StoreAddOutput { insertedAt: string } @@ -401,6 +443,10 @@ export interface UploadDidNotRemove { shards?: undefined } +export interface UploadGetOk { + spaces: Array<{ did: DID; insertedAt: string }> +} + export interface UploadListItem extends UploadAddOk { insertedAt: string updatedAt: string diff --git a/packages/upload-api/test/admin/store/inspect.js b/packages/upload-api/test/admin/store/inspect.js new file mode 100644 index 000000000..7ac4a0fca --- /dev/null +++ b/packages/upload-api/test/admin/store/inspect.js @@ -0,0 +1,63 @@ +import * as API from '../../types.js' +import { alice, registerSpace } from '../../util.js' +import { createServer, connect } from '../../../src/lib.js' + +import { delegate } from '@ucanto/core' +import * as CAR from '@ucanto/transport/car' +import { Admin, Store } from '@web3-storage/capabilities' + +/** + * @type {API.Tests} + */ +export const test = { + 'admin/store/inspect returns information about an uploaded CID': async ( + assert, + context + ) => { + const { proof, spaceDid } = await registerSpace(alice, context) + const connection = connect({ + id: context.id, + channel: createServer(context), + }) + + const data = new Uint8Array([11, 22, 34, 44, 55]) + const link = await CAR.codec.link(data) + const size = data.byteLength + + const storeAdd = await Store.add + .invoke({ + issuer: alice, + audience: context.id, + with: spaceDid, + nb: { link, size }, + proofs: [proof], + }) + .execute(connection) + + assert.ok(storeAdd.out.ok) + + const service = context.service + const adminStoreInspect = await Admin.store.inspect + .invoke({ + issuer: alice, + audience: connection.id, + with: service.did(), + nb: { link }, + proofs: [ + await delegate({ + issuer: service, + audience: alice, + capabilities: [{ with: service.did(), can: 'admin/store/inspect' }], + }), + ], + }) + .execute(connection) + + assert.ok( + adminStoreInspect.out.ok, + `failed to get shard: ${adminStoreInspect.out.error?.message}` + ) + assert.equal(adminStoreInspect.out.ok?.spaces[0].did, spaceDid) + assert.equal(typeof adminStoreInspect.out.ok?.spaces[0].insertedAt, 'string') + }, +} diff --git a/packages/upload-api/test/admin/store/inspect.spec.js b/packages/upload-api/test/admin/store/inspect.spec.js new file mode 100644 index 000000000..22f39eec1 --- /dev/null +++ b/packages/upload-api/test/admin/store/inspect.spec.js @@ -0,0 +1,31 @@ +/* eslint-disable no-nested-ternary */ +/* eslint-disable no-only-tests/no-only-tests */ +import * as Suite from './inspect.js' +import * as assert from 'assert' +import { cleanupContext, createContext } from '../../helpers/context.js' + +describe('admin/store/inspect', () => { + for (const [name, test] of Object.entries(Suite.test)) { + const define = name.startsWith('only! ') + ? it.only + : name.startsWith('skip! ') + ? it.skip + : it + + define(name, async () => { + const context = await createContext() + try { + await test( + { + equal: assert.strictEqual, + deepEqual: assert.deepStrictEqual, + ok: assert.ok, + }, + context + ) + } finally { + cleanupContext(context) + } + }) + } +}) diff --git a/packages/upload-api/test/admin/upload/inspect.js b/packages/upload-api/test/admin/upload/inspect.js new file mode 100644 index 000000000..0292e2dac --- /dev/null +++ b/packages/upload-api/test/admin/upload/inspect.js @@ -0,0 +1,68 @@ +import * as API from '../../types.js' +import { alice, registerSpace, randomCAR } from '../../util.js' +import { createServer, connect } from '../../../src/lib.js' + +import { delegate } from '@ucanto/core' +import { Admin, Upload } from '@web3-storage/capabilities' + +/** + * @type {API.Tests} + */ +export const test = { + 'admin/upload/inspect returns information about an uploaded CID': async ( + assert, + context + ) => { + const { proof, spaceDid } = await registerSpace(alice, context) + const connection = connect({ + id: context.id, + channel: createServer(context), + }) + + const car = await randomCAR(128) + const otherCar = await randomCAR(40) + + // invoke a upload/add with proof + const [root] = car.roots + const shards = [car.cid, otherCar.cid].sort() + + const uploadAdd = await Upload.add + .invoke({ + issuer: alice, + audience: connection.id, + with: spaceDid, + nb: { root, shards }, + proofs: [proof], + }) + .execute(connection) + + assert.ok(uploadAdd.out.ok) + + const service = context.service + const adminUploadInspect = await Admin.upload.inspect + .invoke({ + issuer: alice, + audience: connection.id, + with: service.did(), + nb: { root: car.roots[0] }, + proofs: [ + await delegate({ + issuer: service, + audience: alice, + capabilities: [ + { with: service.did(), can: 'admin/upload/inspect' }, + ], + }), + ], + }) + .execute(connection) + + assert.ok( + adminUploadInspect.out.ok, + `failed to get root: ${adminUploadInspect.out.error?.message}` + ) + assert.equal(adminUploadInspect.out.ok?.spaces[0].did, spaceDid) + assert.equal(typeof adminUploadInspect.out.ok?.spaces[0].insertedAt, 'string') + + }, +} diff --git a/packages/upload-api/test/admin/upload/inspect.spec.js b/packages/upload-api/test/admin/upload/inspect.spec.js new file mode 100644 index 000000000..965d1f0ec --- /dev/null +++ b/packages/upload-api/test/admin/upload/inspect.spec.js @@ -0,0 +1,31 @@ +/* eslint-disable no-nested-ternary */ +/* eslint-disable no-only-tests/no-only-tests */ +import * as Suite from './inspect.js' +import * as assert from 'assert' +import { cleanupContext, createContext } from '../../helpers/context.js' + +describe('admin/upload/inspect', () => { + for (const [name, test] of Object.entries(Suite.test)) { + const define = name.startsWith('only! ') + ? it.only + : name.startsWith('skip! ') + ? it.skip + : it + + define(name, async () => { + const context = await createContext() + try { + await test( + { + equal: assert.strictEqual, + deepEqual: assert.deepStrictEqual, + ok: assert.ok, + }, + context + ) + } finally { + cleanupContext(context) + } + }) + } +}) diff --git a/packages/upload-api/test/store-table.js b/packages/upload-api/test/store-table.js index 3ebf92b3f..5e3d44671 100644 --- a/packages/upload-api/test/store-table.js +++ b/packages/upload-api/test/store-table.js @@ -25,6 +25,23 @@ export class StoreTable { return output } + /** + * + * @param {API.UnknownLink} link + * @returns {Promise} + */ + async getCID(link) { + const items = + this.items?.filter((item) => item.link.toString() === link.toString()) || + [] + return { + spaces: items.map((item) => ({ + did: item.space, + insertedAt: item.insertedAt, + })), + } + } + /** * * @param {API.DID} space diff --git a/packages/upload-api/test/store.js b/packages/upload-api/test/store.js index 2b3d5d85e..9f3bfff17 100644 --- a/packages/upload-api/test/store.js +++ b/packages/upload-api/test/store.js @@ -1,12 +1,11 @@ import { createServer, connect } from '../src/lib.js' import * as API from '../src/types.js' -import * as Signer from '@ucanto/principal/ed25519' import { CID } from 'multiformats' import * as CAR from '@ucanto/transport/car' import { base64pad } from 'multiformats/bases/base64' import * as Link from '@ucanto/core/link' import * as StoreCapabilities from '@web3-storage/capabilities/store' -import { createSpace, registerSpace } from './util.js' +import { alice, bob, createSpace, registerSpace } from './util.js' import { Absentee } from '@ucanto/principal' import { provisionProvider } from './helpers/utils.js' @@ -15,7 +14,6 @@ import { provisionProvider } from './helpers/utils.js' */ export const test = { 'store/add returns signed url for uploading': async (assert, context) => { - const alice = await Signer.generate() const { proof, spaceDid } = await registerSpace(alice, context) const connection = connect({ id: context.id, @@ -101,11 +99,67 @@ export const test = { Date.now() - new Date(item?.insertedAt).getTime() < 60_000, true ) + + const { spaces } = await context.storeTable.getCID(link) + assert.equal(spaces.length, 1) + assert.equal(spaces[0].did, spaceDid) }, + 'store/add should allow add the same content to be stored in multiple spaces': + async (assert, context) => { + const { proof: aliceProof, spaceDid: aliceSpaceDid } = + await registerSpace(alice, context) + const { proof: bobProof, spaceDid: bobSpaceDid } = await registerSpace( + bob, + context, + 'bob' + ) + + const connection = connect({ + id: context.id, + channel: createServer(context), + }) + + const data = new Uint8Array([11, 22, 34, 44, 55]) + const link = await CAR.codec.link(data) + const size = data.byteLength + + const aliceStoreAdd = await StoreCapabilities.add + .invoke({ + issuer: alice, + audience: context.id, + with: aliceSpaceDid, + nb: { link, size }, + proofs: [aliceProof], + }) + .execute(connection) + + assert.ok( + aliceStoreAdd.out.ok, + `Alice failed to store ${link.toString()}` + ) + + const bobStoreAdd = await StoreCapabilities.add + .invoke({ + issuer: bob, + audience: context.id, + with: bobSpaceDid, + nb: { link, size }, + proofs: [bobProof], + }) + .execute(connection) + + assert.ok(bobStoreAdd.out.ok, `Bob failed to store ${link.toString()}`) + + const { spaces } = await context.storeTable.getCID(link) + assert.equal(spaces.length, 2) + const spaceDids = spaces.map((space) => space.did) + assert.ok(spaceDids.includes(aliceSpaceDid)) + assert.ok(spaceDids.includes(bobSpaceDid)) + }, + 'store/add should create a presigned url that can only PUT a payload with the right length': async (assert, context) => { - const alice = await Signer.generate() const { proof, spaceDid } = await registerSpace(alice, context) const connection = connect({ id: context.id, @@ -156,7 +210,6 @@ export const test = { 'store/add should create a presigned url that can only PUT the exact bytes we signed for': async (assert, context) => { - const alice = await Signer.generate() const { proof, spaceDid } = await registerSpace(alice, context) const connection = connect({ id: context.id, @@ -203,7 +256,6 @@ export const test = { }, 'store/add returns done if already uploaded': async (assert, context) => { - const alice = await Signer.generate() const { proof, spaceDid } = await registerSpace(alice, context) const connection = connect({ id: context.id, @@ -278,7 +330,6 @@ export const test = { assert, context ) => { - const alice = await Signer.generate() const { proof, space, spaceDid } = await createSpace(alice) const connection = connect({ id: context.id, @@ -327,7 +378,6 @@ export const test = { }, 'store/add fails when size too large to PUT': async (assert, context) => { - const alice = await Signer.generate() const { proof, spaceDid } = await registerSpace(alice, context) const connection = connect({ id: context.id, @@ -358,7 +408,6 @@ export const test = { assert, context ) => { - const alice = await Signer.generate() const { proof, spaceDid } = await registerSpace(alice, context) const connection = connect({ id: context.id, @@ -396,7 +445,6 @@ export const test = { }, 'store/list does not fail for empty list': async (assert, context) => { - const alice = await Signer.generate() const { proof, spaceDid } = await registerSpace(alice, context) const connection = connect({ id: context.id, @@ -420,7 +468,6 @@ export const test = { assert, context ) => { - const alice = await Signer.generate() const { proof, spaceDid } = await registerSpace(alice, context) const connection = connect({ id: context.id, @@ -475,7 +522,6 @@ export const test = { }, 'store/list can be paginated with custom size': async (assert, context) => { - const alice = await Signer.generate() const { proof, spaceDid } = await registerSpace(alice, context) const connection = connect({ id: context.id, @@ -555,7 +601,6 @@ export const test = { }, 'store/list can page backwards': async (assert, context) => { - const alice = await Signer.generate() const { proof, spaceDid } = await registerSpace(alice, context) const connection = connect({ id: context.id, diff --git a/packages/upload-api/test/upload-table.js b/packages/upload-api/test/upload-table.js index 89e00bba8..e2689a021 100644 --- a/packages/upload-api/test/upload-table.js +++ b/packages/upload-api/test/upload-table.js @@ -10,6 +10,23 @@ export class UploadTable { this.items = [] } + /** + * + * @param {API.UnknownLink} link + * @returns {Promise} + */ + async getCID(link) { + const items = + this.items?.filter((item) => item.root.toString() === link.toString()) || + [] + return { + spaces: items.map((item) => ({ + did: item.space, + insertedAt: item.insertedAt, + })), + } + } + /** * @param {API.UploadAddInput} input * @returns diff --git a/packages/upload-api/test/upload.js b/packages/upload-api/test/upload.js index 389e1b2e6..ff1352725 100644 --- a/packages/upload-api/test/upload.js +++ b/packages/upload-api/test/upload.js @@ -1,6 +1,7 @@ import * as API from '../src/types.js' import { alice, + bob, registerSpace, randomCAR, createSpace, @@ -48,7 +49,10 @@ export const test = { } assert.equal(uploadAdd.out.ok.root.toString(), root.toString()) - assert.deepEqual(uploadAdd.out.ok.shards?.map(String).sort(), shards.map(String).sort()) + assert.deepEqual( + uploadAdd.out.ok.shards?.map(String).sort(), + shards.map(String).sort() + ) const { results } = await context.uploadTable.list(spaceDid) assert.deepEqual(results.length, 1) @@ -60,8 +64,66 @@ export const test = { const msAgo = Date.now() - new Date(item.insertedAt).getTime() assert.equal(msAgo < 60_000, true) assert.equal(msAgo >= 0, true) + + const { spaces } = await context.uploadTable.getCID(root) + assert.equal(spaces.length, 1) + assert.equal(spaces[0].did, spaceDid) }, + 'upload/add should allow the same content to be uploaded to multiple spaces': + async (assert, context) => { + const { proof: aliceProof, spaceDid: aliceSpaceDid } = + await registerSpace(alice, context) + const { proof: bobProof, spaceDid: bobSpaceDid } = await registerSpace( + bob, + context, + 'bob' + ) + + const connection = connect({ + id: context.id, + channel: createServer(context), + }) + + const car = await randomCAR(128) + const otherCar = await randomCAR(40) + + // invoke a upload/add with proof + const [root] = car.roots + const shards = [car.cid, otherCar.cid].sort() + + const aliceUploadAdd = await Upload.add + .invoke({ + issuer: alice, + audience: connection.id, + with: aliceSpaceDid, + nb: { root, shards }, + proofs: [aliceProof], + }) + .execute(connection) + assert.ok( + aliceUploadAdd.out.ok, + `Alice failed to upload ${root.toString()}` + ) + + const bobUploadAdd = await Upload.add + .invoke({ + issuer: bob, + audience: connection.id, + with: bobSpaceDid, + nb: { root, shards }, + proofs: [bobProof], + }) + .execute(connection) + assert.ok(bobUploadAdd.out.ok, `Bob failed to upload ${root.toString()}`) + + const { spaces } = await context.uploadTable.getCID(root) + assert.equal(spaces.length, 2) + const spaceDids = spaces.map((space) => space.did) + assert.ok(spaceDids.includes(aliceSpaceDid)) + assert.ok(spaceDids.includes(bobSpaceDid)) + }, + 'upload/add does not fail with no shards provided': async ( assert, context @@ -149,13 +211,19 @@ export const test = { throw new Error('invocation failed', { cause: uploadAdd2 }) } - assert.deepEqual(uploadAdd2.out.ok.shards?.map(String).sort(), shards.map(String).sort()) + assert.deepEqual( + uploadAdd2.out.ok.shards?.map(String).sort(), + shards.map(String).sort() + ) const { results } = await context.uploadTable.list(spaceDid) assert.equal(results.length, 1) const [upload] = results assert.equal(upload.root.toString(), root.toString()) - assert.deepEqual(upload.shards?.map(String).sort(), shards.map(String).sort()) + assert.deepEqual( + upload.shards?.map(String).sort(), + shards.map(String).sort() + ) }, 'upload/add merges shards to an existing item with shards': async ( diff --git a/packages/upload-api/test/util.js b/packages/upload-api/test/util.js index 4510e6446..c37035e56 100644 --- a/packages/upload-api/test/util.js +++ b/packages/upload-api/test/util.js @@ -54,13 +54,15 @@ export async function createSpace(audience) { * @param {API.Principal & API.Signer} audience * @param {import('./types.js').UcantoServerTestContext} context */ -export const registerSpace = async (audience, context) => { +export const registerSpace = async (audience, context, username = 'alice') => { const { proof, space, spaceDid } = await createSpace(audience) const connection = connect({ id: context.id, channel: createServer(context), }) - const account = Absentee.from({ id: 'did:mailto:test.web3.storage:alice' }) + const account = Absentee.from({ + id: `did:mailto:test.web3.storage:${username}`, + }) const provisionResult = await provisionProvider({ service: /** @type {API.Signer>} */ (context.id),