Skip to content

Commit

Permalink
feat: add blob/get
Browse files Browse the repository at this point in the history
  • Loading branch information
joaosa committed May 30, 2024
1 parent 99937ba commit 4d9ecde
Show file tree
Hide file tree
Showing 12 changed files with 288 additions and 6 deletions.
35 changes: 35 additions & 0 deletions packages/capabilities/src/blob.js
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,41 @@ export const list = capability({
},
})

/**
* Capability can be used to get the stored Blob from the (memory)
* space identified by `with` field.
*/
export const get = capability({
can: 'space/blob/get',
/**
* DID of the (memory) space where Blob is stored.
*/
with: SpaceDID,
nb: Schema.struct({
/**
* A multihash digest of the blob payload bytes, uniquely identifying blob.
*/
digest: Schema.bytes(),
}),
derives: (claimed, delegated) => {
if (claimed.with !== delegated.with) {
return fail(
`Expected 'with: "${delegated.with}"' instead got '${claimed.with}'`
)
} else if (
delegated.nb.digest &&
!equals(delegated.nb.digest, claimed.nb.digest)
) {
return fail(
`Link ${
claimed.nb.digest ? `${claimed.nb.digest}` : ''
} violates imposed ${delegated.nb.digest} constraint.`
)
}
return ok({})
},
})

// ⚠️ We export imports here so they are not omitted in generated typedefs
// @see https://github.com/microsoft/TypeScript/issues/51548
export { Schema }
11 changes: 11 additions & 0 deletions packages/capabilities/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -502,6 +502,7 @@ export type Blob = InferInvokedCapability<typeof BlobCaps.blob>
export type BlobAdd = InferInvokedCapability<typeof BlobCaps.add>
export type BlobRemove = InferInvokedCapability<typeof BlobCaps.remove>
export type BlobList = InferInvokedCapability<typeof BlobCaps.list>
export type BlobGet = InferInvokedCapability<typeof BlobCaps.get>
export type ServiceBlob = InferInvokedCapability<typeof W3sBlobCaps.blob>
export type BlobAllocate = InferInvokedCapability<typeof W3sBlobCaps.allocate>
export type BlobAccept = InferInvokedCapability<typeof W3sBlobCaps.accept>
Expand Down Expand Up @@ -550,6 +551,15 @@ export interface BlobListSuccess extends ListResponse<BlobListItem> {}
// TODO: make types more specific
export type BlobListFailure = Ucanto.Failure

// Blob get
export interface BlobGetSuccess {
blob: { digest: Uint8Array; size: number }
cause: UnknownLink
}

// TODO: make types more specific
export type BlobGetFailure = Ucanto.Failure

// Blob allocate
export interface BlobAllocateSuccess {
size: number
Expand Down Expand Up @@ -902,6 +912,7 @@ export type ServiceAbilityArray = [
BlobAdd['can'],
BlobRemove['can'],
BlobList['can'],
BlobGet['can'],
ServiceBlob['can'],
BlobAllocate['can'],
BlobAccept['can'],
Expand Down
2 changes: 2 additions & 0 deletions packages/upload-api/src/blob.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { blobAddProvider } from './blob/add.js'
import { blobListProvider } from './blob/list.js'
import { blobRemoveProvider } from './blob/remove.js'
import { blobGetProvider } from './blob/get.js'
import * as API from './types.js'

export { BlobNotFound } from './blob/lib.js'
Expand All @@ -13,5 +14,6 @@ export function createService(context) {
add: blobAddProvider(context),
list: blobListProvider(context),
remove: blobRemoveProvider(context),
get: blobGetProvider(context),
}
}
24 changes: 24 additions & 0 deletions packages/upload-api/src/blob/get.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import * as Server from '@ucanto/server'
import * as Blob from '@web3-storage/capabilities/blob'
import * as API from '../types.js'
import { BlobNotFound } from './lib.js'
import { decode } from 'multiformats/hashes/digest'

/**
* @param {API.BlobServiceContext} context
* @returns {API.ServiceMethod<API.BlobGet, API.BlobGetSuccess, API.BlobGetFailure>}
*/
export function blobGetProvider(context) {
return Server.provide(Blob.get, async ({ capability }) => {
const { digest } = capability.nb
if (!digest) {
return Server.fail('nb.digest must be set')
}
const space = Server.DID.parse(capability.with).did()
const res = await context.allocationsStorage.get(space, digest)
if (res.error && res.error.name === 'RecordNotFound') {
return Server.error(new BlobNotFound(decode(digest)))
}
return res
})
}
4 changes: 4 additions & 0 deletions packages/upload-api/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,9 @@ import {
BlobRemove,
BlobRemoveSuccess,
BlobRemoveFailure,
BlobGet,
BlobGetSuccess,
BlobGetFailure,
BlobAllocate,
BlobAllocateSuccess,
BlobAllocateFailure,
Expand Down Expand Up @@ -322,6 +325,7 @@ export interface Service extends StorefrontService, W3sService {
add: ServiceMethod<BlobAdd, BlobAddSuccess, BlobAddFailure>
remove: ServiceMethod<BlobRemove, BlobRemoveSuccess, BlobRemoveFailure>
list: ServiceMethod<BlobList, BlobListSuccess, BlobListFailure>
get: ServiceMethod<BlobGet, BlobGetSuccess, BlobGetFailure>
}
}
plan: {
Expand Down
8 changes: 2 additions & 6 deletions packages/upload-api/src/types/blob.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
Multihash,
BlobListItem,
BlobRemoveSuccess,
BlobGetSuccess,
} from '@web3-storage/capabilities/types'

import { RecordKeyConflict, ListResponse } from '../types.js'
Expand All @@ -21,7 +22,7 @@ export interface AllocationsStorage {
get: (
space: DID,
blobMultihash: Multihash
) => Promise<Result<BlobGetOutput, Failure>>
) => Promise<Result<BlobGetSuccess, Failure>>
exists: (
space: DID,
blobMultihash: Multihash
Expand Down Expand Up @@ -59,11 +60,6 @@ export interface BlobAddInput {

export interface BlobAddOutput extends Omit<BlobAddInput, 'space' | 'cause'> {}

export interface BlobGetOutput {
blob: { digest: Uint8Array; size: number }
cause: UnknownLink
}

export interface BlobsStorage {
has: (content: Multihash) => Promise<Result<boolean, Failure>>
createUploadUrl: (
Expand Down
49 changes: 49 additions & 0 deletions packages/upload-client/src/blob.js
Original file line number Diff line number Diff line change
Expand Up @@ -403,3 +403,52 @@ export async function remove(

return result.out
}

/**
* Gets a stored Blob file by digest.
*
* @param {import('./types.js').InvocationConfig} conf Configuration
* for the UCAN invocation. An object with `issuer`, `with` and `proofs`.
*
* The `issuer` is the signing authority that is issuing the UCAN
* invocation(s). It is typically the user _agent_.
*
* The `with` is the resource the invocation applies to. It is typically the
* DID of a space.
*
* The `proofs` are a set of capability delegations that prove the issuer
* has the capability to perform the action.
*
* The issuer needs the `blob/get` delegated capability.
* @param {import('multiformats').MultihashDigest} multihash of the blob
* @param {import('./types.js').RequestOptions} [options]
*/
export async function get(
{ issuer, with: resource, proofs, audience },
multihash,
options = {}
) {
/* c8 ignore next */
const conn = options.connection ?? connection
const result = await BlobCapabilities.get
.invoke({
issuer,
/* c8 ignore next */
audience: audience ?? servicePrincipal,
with: SpaceDID.from(resource),
nb: {
digest: multihash.bytes,
},
proofs,
nonce: options.nonce,
})
.execute(conn)

if (!result.out.ok) {
throw new Error(`failed ${BlobCapabilities.get.can} invocation`, {
cause: result.out.error,
})
}

return result.out
}
7 changes: 7 additions & 0 deletions packages/upload-client/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,9 @@ import {
BlobList,
BlobListSuccess,
BlobListFailure,
BlobGet,
BlobGetSuccess,
BlobGetFailure,
IndexAdd,
IndexAddSuccess,
IndexAddFailure,
Expand Down Expand Up @@ -98,6 +101,9 @@ export type {
BlobList,
BlobListSuccess,
BlobListFailure,
BlobGet,
BlobGetSuccess,
BlobGetFailure,
IndexAdd,
IndexAddSuccess,
IndexAddFailure,
Expand Down Expand Up @@ -153,6 +159,7 @@ export interface Service extends StorefrontService {
add: ServiceMethod<BlobAdd, BlobAddSuccess, BlobAddFailure>
remove: ServiceMethod<BlobRemove, BlobRemoveSuccess, BlobRemoveFailure>
list: ServiceMethod<BlobList, BlobListSuccess, BlobListFailure>
get: ServiceMethod<BlobGet, BlobGetSuccess, BlobGetFailure>
}
index: {
add: ServiceMethod<IndexAdd, IndexAddSuccess, IndexAddFailure>
Expand Down
110 changes: 110 additions & 0 deletions packages/upload-client/test/blob.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -733,3 +733,113 @@ describe('Blob.remove', () => {
)
})
})

describe('Blob.get', () => {
it('get a stored CAR file', async () => {
const space = await Signer.generate()
const agent = await Signer.generate()
const bytes = await randomBytes(128)
const bytesHash = await sha256.digest(bytes)

const proofs = [
await BlobCapabilities.get.delegate({
issuer: space,
audience: agent,
with: space.did(),
expiration: Infinity,
}),
]

const service = mockService({
space: {
blob: {
// @ts-ignore Argument of type
get: provide(BlobCapabilities.get, ({ invocation }) => {
assert.equal(invocation.issuer.did(), agent.did())
assert.equal(invocation.capabilities.length, 1)
const invCap = invocation.capabilities[0]
assert.equal(invCap.can, BlobCapabilities.get.can)
assert.equal(invCap.with, space.did())
assert.equal(String(invCap.nb?.digest), bytesHash.bytes)
return {
ok: {
blob: { digest: bytesHash.bytes, size: bytes.length },
},
}
}),
},
},
})

const server = Server.create({
id: serviceSigner,
service,
codec: CAR.inbound,
validateAuthorization,
})
const connection = Client.connect({
id: serviceSigner,
codec: CAR.outbound,
channel: server,
})

const result = await Blob.get(
{ issuer: agent, with: space.did(), proofs, audience: serviceSigner },
bytesHash,
{ connection }
)

assert(service.space.blob.get.called)
assert.equal(service.space.blob.get.callCount, 1)

assert(result.ok)
assert.deepEqual(result.ok.blob.digest, bytesHash.bytes)
})

it('throws on service error', async () => {
const space = await Signer.generate()
const agent = await Signer.generate()
const bytes = await randomBytes(128)
const bytesHash = await sha256.digest(bytes)

const proofs = [
await BlobCapabilities.get.delegate({
issuer: space,
audience: agent,
with: space.did(),
expiration: Infinity,
}),
]

const service = mockService({
space: {
blob: {
get: provide(BlobCapabilities.get, () => {
throw new Server.Failure('boom')
}),
},
},
})

const server = Server.create({
id: serviceSigner,
service,
codec: CAR.inbound,
validateAuthorization,
})
const connection = Client.connect({
id: serviceSigner,
codec: CAR.outbound,
channel: server,
})

await assert.rejects(
Blob.get(
{ issuer: agent, with: space.did(), proofs, audience: serviceSigner },
bytesHash,
{ connection }
),
{ message: 'failed space/blob/get invocation' }
)
})
})
1 change: 1 addition & 0 deletions packages/upload-client/test/helpers/mocks.js
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ export function mockService(impl) {
add: withCallCount(impl.space?.blob?.add ?? notImplemented),
list: withCallCount(impl.space?.blob?.list ?? notImplemented),
remove: withCallCount(impl.space?.blob?.remove ?? notImplemented),
get: withCallCount(impl.space?.blob?.get ?? notImplemented),
},
index: {
add: withCallCount(impl.space?.index?.add ?? notImplemented),
Expand Down
12 changes: 12 additions & 0 deletions packages/w3up-client/src/capability/blob.js
Original file line number Diff line number Diff line change
Expand Up @@ -40,4 +40,16 @@ export class BlobClient extends Base {
options.connection = this._serviceConf.upload
return Blob.remove(conf, digest, options)
}

/**
* Gets a stored blob by multihash digest.
*
* @param {import('multiformats').MultihashDigest} digest - digest of blob to get.
* @param {import('../types.js').RequestOptions} [options]
*/
async get(digest, options = {}) {
const conf = await this._invocationConfig([BlobCapabilities.get.can])
options.connection = this._serviceConf.upload
return Blob.get(conf, digest, options)
}
}
Loading

0 comments on commit 4d9ecde

Please sign in to comment.