@web3-storage/content-claims
Version:
Implementation of the Content Claims Protocol.
139 lines (125 loc) • 4.36 kB
JavaScript
/* eslint-env browser */
import { extract as extractDelegation } from '@ucanto/core/delegation'
import { connect, invoke, delegate } from '@ucanto/client'
import { CAR, HTTP } from '@ucanto/transport'
import { sha256 } from 'multiformats/hashes/sha2'
import { equals } from 'multiformats/bytes'
import { base58btc } from 'multiformats/bases/base58'
import { CARReaderStream } from 'carstream/reader'
import * as Assert from '../capability/assert.js'
import { decode as decodeDigest } from 'multiformats/hashes/digest'
export const serviceURL = new URL('https://claims.web3.storage')
/** @type {import('@ucanto/interface').Principal} */
export const servicePrincipal = { did: () => 'did:web:claims.web3.storage' }
/** @type {import('@ucanto/interface').ConnectionView<import('../server/service/api.js').Service>} */
export const connection = connect({
id: servicePrincipal,
codec: CAR.outbound,
channel: HTTP.open({ url: serviceURL, method: 'POST' })
})
export { connect, invoke, delegate, CAR, HTTP }
const assertCapMap = {
[Assert.location.can]: Assert.location,
[Assert.partition.can]: Assert.partition,
[Assert.inclusion.can]: Assert.inclusion,
[Assert.index.can]: Assert.index,
[Assert.relation.can]: Assert.relation,
[Assert.equals.can]: Assert.equals
}
/**
* @param {import('@ucanto/interface').Capability} cap
* @returns {cap is import('../server/api.js').AnyAssertCap}
*/
const isAssertCap = cap =>
Object.keys(assertCapMap).includes(cap.can) &&
'nb' in cap &&
typeof cap.nb === 'object' &&
'content' in cap.nb
/**
* @param {Uint8Array} bytes
* @returns {Promise<import('./api.js').Claim>}
*/
export const decode = async bytes => {
const delegation = await extractDelegation(bytes)
if (delegation.error) {
throw new Error('failed to decode claim', { cause: delegation.error })
}
return decodeDelegation(delegation.ok)
}
/**
* @param {import('@ucanto/interface').Delegation} delegation
* @returns {Promise<import('./api.js').Claim>}
*/
export const decodeDelegation = async delegation => {
const cap = delegation.capabilities[0]
if (!isAssertCap(cap)) {
throw new Error('invalid claim')
}
// @ts-expect-error
const parsedCap = assertCapMap[cap.can].create({ with: cap.with, nb: cap.nb })
// @ts-expect-error
return {
...parsedCap.nb,
type: parsedCap.can,
delegation: () => delegation
}
}
/**
* Fetch a CAR archive of claims from the service. Note: no verification is
* performed on the response data.
*
* @typedef {{
* walk?: Array<'parts'|'includes'|'children'>
* serviceURL?: URL
* }} FetchOptions
* @param {import('multiformats').MultihashDigest} content
* @param {FetchOptions} [options]
*/
export const fetch = async (content, options) => {
const path = `/claims/multihash/${base58btc.encode(content.bytes)}`
const url = new URL(path, options?.serviceURL ?? serviceURL)
if (options?.walk) url.searchParams.set('walk', options.walk.join(','))
return globalThis.fetch(url)
}
/**
* Read content claims from the service for the given content CID.
*
* @param {import('multiformats').MultihashDigest} content
* @param {FetchOptions} [options]
* @returns {Promise<import('./api.js').Claim[]>}
*/
export const read = async (content, options) => {
const res = await fetch(content, options)
if (!res.ok) throw new Error(`unexpected service status: ${res.status}`, { cause: await res.text() })
if (!res.body) throw new Error('missing response body')
/** @type {import('./api.js').Claim[]} */
const claims = []
try {
await res.body
.pipeThrough(new CARReaderStream())
.pipeTo(new WritableStream({
async write (block) {
const digest = await sha256.digest(block.bytes)
if (!equals(block.cid.multihash.bytes, digest.bytes)) {
throw new Error(`hash verification failed: ${block.cid}`)
}
const claim = await decode(block.bytes)
claims.push(claim)
}
}))
} catch (/** @type {any} */ err) {
if (!claims.length && err.message === 'unexpected end of data') {
return claims
}
throw err
}
return claims
}
/**
*
* @param {import('./api.js').Claim} claim
* @returns
*/
export const contentMultihash = (claim) => {
return 'digest' in claim.content ? decodeDigest(claim.content.digest) : claim.content.multihash
}