Skip to content
New issue

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

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

Already on GitHub? Sign in to your account

feat: blob capability implementation #1340

Closed
wants to merge 1 commit into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion packages/capabilities/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,8 @@
"@ucanto/principal": "^9.0.0",
"@ucanto/transport": "^9.1.0",
"@ucanto/validator": "^9.0.1",
"@web3-storage/data-segment": "^3.2.0"
"@web3-storage/data-segment": "^3.2.0",
"uint8arrays": "^5.0.3"
},
"devDependencies": {
"@web3-storage/eslint-config-w3up": "workspace:^",
Expand Down
213 changes: 213 additions & 0 deletions packages/capabilities/src/blob.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,213 @@
/**
* Blob Capabilities.
*
* Blob is a fixed size byte array addressed by the multihash.
* Usually blobs are used to represent set of IPLD blocks at different byte ranges.
*
* These can be imported directly with:
* ```js
* import * as Blob from '@web3-storage/capabilities/blob'
* ```
*
* @module
*/
import { capability, Link, Schema, ok, fail } from '@ucanto/validator'
import { equal, equalContent, equalWith, checkLink, SpaceDID, and } from './utils.js'

/**
* Agent capabilities for Blob protocol
*/

/**
* Capability can only be delegated (but not invoked) allowing audience to
* derived any `blob/` prefixed capability for the (memory) space identified
* by DID in the `with` field.
*/
export const blob = capability({
can: 'blob/*',
/**
* DID of the (memory) space where Blob is intended to
* be stored.
*/
with: SpaceDID,
derives: equalWith,
})

/**
* `blob/add` capability allows agent to store a Blob into a (memory) space
* identified by did:key in the `with` field. Agent must precompute Blob locally
* and provide it's multihash and size using `nb.content` and `nb.size` fields, allowing
* a service to provision a write location for the agent to PUT or POST desired
* Blob into.
*/
export const add = capability({
can: 'blob/add',
/**
* DID of the (memory) space where Blob is intended to
* be stored.
*/
with: SpaceDID,
nb: Schema.struct({
/**
* A multihash digest of the blob payload bytes, uniquely identifying blob.
*/
content: Schema.bytes(),
/**
* Size of the Blob file to be stored. Service will provision write target
* for this exact size. Attempt to write a larger Blob file will fail.
*/
size: Schema.integer(),
}),
derives: (claim, from) => {
const result = equalContent(claim, from)
if (result.error) {
return result
} else if (claim.nb.size !== undefined && from.nb.size !== undefined) {
return claim.nb.size > from.nb.size
? fail(`Size constraint violation: ${claim.nb.size} > ${from.nb.size}`)
: ok({})
} else {
return ok({})
}
},
})

/**
* `blob/remove` capability can be used to remove the stored Blob from the (memory)
* space identified by `with` field.
*/
export const remove = capability({
can: 'blob/remove',
/**
* DID of the (memory) space where Blob is intended to
* be stored.
*/
with: SpaceDID,
nb: Schema.struct({
/**
* A multihash digest of the blob payload bytes, uniquely identifying blob.
*/
content: Schema.bytes(),
}),
derives: equalContent,
})

/**
* `blob/list` capability can be invoked to request a list of stored Blobs in the
* (memory) space identified by `with` field.
*/
export const list = capability({
can: 'blob/list',
/**
* DID of the (memory) space where Blob is intended to
* be stored.
*/
with: SpaceDID,
nb: Schema.struct({
/**
* A pointer that can be moved back and forth on the list.
* It can be used to paginate a list for instance.
*/
cursor: Schema.string().optional(),
/**
* Maximum number of items per page.
*/
size: Schema.integer().optional(),
/**
* If true, return page of results preceding cursor. Defaults to false.
*/
pre: Schema.boolean().optional(),
}),
derives: (claimed, delegated) => {
if (claimed.with !== delegated.with) {
return fail(
`Expected 'with: "${delegated.with}"' instead got '${claimed.with}'`
)
}
return ok({})
},
})

/**
* Service capabilities for Blob protocol
*/

// TODO: should we preffix these with some tmp service namespace that eases delegation of blob?
// OR
// export const blob = add.or(remove).or(list)

/**
* `blob/allocate` capability can be invoked to create a memory
* address where blob content can be written via HTTP PUT request.
*/
export const allocate = capability({
can: 'blob/allocate',
/**
* DID of the (memory) space where Blob is intended to
* be stored.
*/
with: SpaceDID,
nb: Schema.struct({
/**
* A multihash digest of the blob payload bytes, uniquely identifying blob.
*/
content: Schema.bytes(),
/**
* The Link for an Add Blob task, that caused an allocation
*/
cause: Link,
/**
* DID of the user space where allocation takes place
*/
space: SpaceDID
}),
derives: (claim, from) => {
return (
and(equalWith(claim, from)) ||
and(equalContent(claim, from)) ||
and(checkLink(claim.nb.cause, from.nb.cause, 'cause')) ||
and(equal(claim.nb.space, from.nb.space, 'space')) ||
ok({})
)
},
})

/**
* `blob/accept` capability invocation should either succeed when content is
* delivered on allocated address or fail if no content is allocation expires
* without content being delivered.
*/
export const accept = capability({
can: 'blob/accept',
/**
* DID of the (memory) space where Blob is intended to
* be stored.
*/
with: SpaceDID,
nb: Schema.struct({
/**
* A multihash digest of the blob payload bytes, uniquely identifying blob.
*/
content: Schema.bytes(),
/**
* Expiration..
*/
exp: Schema.integer(),
}),
derives: (claim, from) => {
const result = equalContent(claim, from)
if (result.error) {
return result
} else if (claim.nb.exp !== undefined && from.nb.exp !== undefined) {
return claim.nb.exp > from.nb.exp
? fail(`exp constraint violation: ${claim.nb.exp} > ${from.nb.exp}`)
: ok({})
} else {
return ok({})
}
},
})

// ⚠️ We export imports here so they are not omitted in generated typedes
// @see https://github.com/microsoft/TypeScript/issues/51548
export { Schema, Link }
7 changes: 7 additions & 0 deletions packages/capabilities/src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import * as DealTracker from './filecoin/deal-tracker.js'
import * as UCAN from './ucan.js'
import * as Plan from './plan.js'
import * as Usage from './usage.js'
import * as Blob from './blob.js'

export {
Access,
Expand Down Expand Up @@ -86,4 +87,10 @@ export const abilitiesAsStrings = [
Plan.get.can,
Usage.usage.can,
Usage.report.can,
Blob.blob.can,
Blob.add.can,
Blob.remove.can,
Blob.list.can,
Blob.allocate.can,
Blob.accept.can,
]
74 changes: 73 additions & 1 deletion packages/capabilities/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import {
import { space, info } from './space.js'
import * as provider from './provider.js'
import { top } from './top.js'
import * as BlobCaps from './blob.js'
import * as StoreCaps from './store.js'
import * as UploadCaps from './upload.js'
import * as AccessCaps from './access.js'
Expand Down Expand Up @@ -439,6 +440,71 @@ export interface UploadNotFound extends Ucanto.Failure {

export type UploadGetFailure = UploadNotFound | Ucanto.Failure

// Blob
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 BlobAllocate = InferInvokedCapability<typeof BlobCaps.allocate>
export type BlobAccept = InferInvokedCapability<typeof BlobCaps.accept>

// Blob add
export interface BlobAddSuccess {
claim: {
'await/ok': Link
}
}
export type BlobAddFailure = Ucanto.Failure

// Blob remove
export interface BlobRemoveSuccess {
size: number
}

export interface BlobItemNotFound extends Ucanto.Failure {
name: 'BlobItemNotFound'
}

export type BlobRemoveFailure = BlobItemNotFound | Ucanto.Failure

// Blob list
export interface BlobListSuccess extends ListResponse<BlobListItem> {}
export interface BlobListItem {
link: UnknownLink
size: number
insertedAt: ISO8601Date
}

export type BlobListFailure = Ucanto.Failure

// Blob allocate
export interface BlobAllocateSuccess {
size: Number
address?: BlobAddress
}

export interface BlobAddress {
url: ToString<URL>
headers: Record<string, string>
}

export interface BlobItemNotFound extends Ucanto.Failure {
name: 'BlobItemNotFound'
}

export interface BlobNotAllocableToSpace extends Ucanto.Failure {
name: 'BlobNotAllocableToSpace'
}

export type BlobAllocateFailure = BlobItemNotFound | BlobNotAllocableToSpace | Ucanto.Failure

// Blob accept
export interface BlobAcceptSuccess {
claim: Link
}

export type BlobAcceptFailure = BlobItemNotFound | Ucanto.Failure

// Store
export type Store = InferInvokedCapability<typeof StoreCaps.store>
export type StoreAdd = InferInvokedCapability<typeof StoreCaps.add>
Expand Down Expand Up @@ -708,7 +774,13 @@ export type ServiceAbilityArray = [
AdminStoreInspect['can'],
PlanGet['can'],
Usage['can'],
UsageReport['can']
UsageReport['can'],
Blob['can'],
BlobAdd['can'],
BlobRemove['can'],
BlobList['can'],
BlobAllocate['can'],
BlobAccept['can'],
]

/**
Expand Down
27 changes: 27 additions & 0 deletions packages/capabilities/src/utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ import { DID, fail, ok } from '@ucanto/validator'
// eslint-disable-next-line no-unused-vars
import * as Types from '@ucanto/interface'

import { equals } from 'uint8arrays/equals'

// e.g. did:web:web3.storage or did:web:staging.web3.storage
export const ProviderDID = DID.match({ method: 'web' })

Expand Down Expand Up @@ -85,6 +87,31 @@ export const equalLink = (claimed, delegated) => {
}
}

/**
* @template {Types.ParsedCapability<"blob/add"|"blob/remove"|"blob/allocate"|"blob/accept", Types.URI<'did:'>, {content: Uint8Array}>} T
* @param {T} claimed
* @param {T} delegated
* @returns {Types.Result<{}, Types.Failure>}
*/
export const equalContent = (claimed, delegated) => {
if (claimed.with !== delegated.with) {
return fail(
`Expected 'with: "${delegated.with}"' instead got '${claimed.with}'`
)
} else if (
delegated.nb.content &&
!equals(delegated.nb.content, claimed.nb.content)
) {
return fail(
`Link ${claimed.nb.content ? `${claimed.nb.content}` : ''} violates imposed ${
delegated.nb.content
} constraint.`
)
} else {
return ok({})
}
}

/**
* Checks that `claimed` {@link Types.Link} meets an `imposed` constraint.
*
Expand Down
19 changes: 19 additions & 0 deletions packages/upload-api/src/blob/accept.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import * as Server from '@ucanto/server'
import * as Blob from '@web3-storage/capabilities/blob'
import * as API from '../types.js'
import { BlobItemNotFound } from './lib.js'

/**
* @param {API.BlobServiceContext} context
* @returns {API.ServiceMethod<API.BlobAccept, API.BlobAcceptSuccess, API.BlobAcceptFailure>}
*/
export function blobAcceptProvider(context) {
return Server.provide(Blob.accept, async ({ capability }) => {

return {
ok: {
claim: 'locationClaimLink'
}
}
})
}
Loading
Loading