diff --git a/packages/access-api/migrations/0005_drop_delegations_audience_to_accounts_did_fk.sql b/packages/access-api/migrations/0005_drop_delegations_audience_to_accounts_did_fk.sql new file mode 100644 index 000000000..3c75fd12c --- /dev/null +++ b/packages/access-api/migrations/0005_drop_delegations_audience_to_accounts_did_fk.sql @@ -0,0 +1,30 @@ +-- Migration number: 0005 2023-02-09T23:48:40.469Z + +/* +goal: remove the foreign key constraint on delegations.audience -> accounts.did. +We want to be able to store delegations whose audience is not an account did. + +sqlite doesn't support `alter table drop constraint`. +So here we will: +* create delegations_new table without the constraint +* insert all from delegations -> delegations_new +* rename delegations_new -> delegations +*/ + +CREATE TABLE + IF NOT EXISTS delegations_new ( + cid TEXT NOT NULL PRIMARY KEY, + bytes BLOB NOT NULL, + audience TEXT NOT NULL, + issuer TEXT NOT NULL, + expiration TEXT, + inserted_at TEXT NOT NULL DEFAULT (strftime ('%Y-%m-%dT%H:%M:%fZ', 'now')), + updated_at TEXT NOT NULL DEFAULT (strftime ('%Y-%m-%dT%H:%M:%fZ', 'now')), + UNIQUE (cid) + ); + +INSERT INTO delegations_new (cid, bytes, audience, issuer, expiration, inserted_at, updated_at) +SELECT cid, bytes, audience, issuer, expiration, inserted_at, updated_at FROM delegations; + +DROP TABLE delegations; +ALTER TABLE delegations_new RENAME TO delegations; diff --git a/packages/access-api/package.json b/packages/access-api/package.json index 26c5ae5cc..798e5e4e3 100644 --- a/packages/access-api/package.json +++ b/packages/access-api/package.json @@ -22,6 +22,7 @@ "@ucanto/principal": "^4.2.3", "@ucanto/server": "^4.2.3", "@ucanto/transport": "^4.2.3", + "@ucanto/validator": "^4.2.3", "@web3-storage/access": "workspace:^", "@web3-storage/capabilities": "workspace:^", "@web3-storage/worker-utils": "0.4.3-dev", @@ -31,6 +32,7 @@ "preact": "^10.11.3", "preact-render-to-string": "^5.2.6", "qrcode": "^1.5.1", + "streaming-iterables": "^7.1.0", "toucan-js": "^2.7.0" }, "devDependencies": { @@ -85,7 +87,23 @@ "WebSocketPair": "readonly" }, "rules": { - "unicorn/prefer-number-properties": "off" + "unicorn/prefer-number-properties": "off", + "jsdoc/no-undefined-types": [ + "error", + { + "definedTypes": [ + "AsyncIterableIterator", + "Awaited", + "D1Database", + "FetchEvent", + "Iterable", + "IterableIterator", + "KVNamespace", + "PromiseLike", + "ResponseInit" + ] + } + ] } }, "eslintIgnore": [ diff --git a/packages/access-api/src/bindings.d.ts b/packages/access-api/src/bindings.d.ts index 632b24f7c..5b2274928 100644 --- a/packages/access-api/src/bindings.d.ts +++ b/packages/access-api/src/bindings.d.ts @@ -11,6 +11,7 @@ import { Validations } from './models/validations.js' import { loadConfig } from './config.js' import { ConnectionView, Signer as EdSigner } from '@ucanto/principal/ed25519' import { Accounts } from './models/accounts.js' +import { DelegationsStorage as Delegations } from './types/delegations.js' export {} @@ -59,6 +60,7 @@ export interface RouteContext { spaces: Spaces validations: Validations accounts: Accounts + delegations: Delegations } uploadApi: ConnectionView } diff --git a/packages/access-api/src/models/delegations.js b/packages/access-api/src/models/delegations.js new file mode 100644 index 000000000..bc693028f --- /dev/null +++ b/packages/access-api/src/models/delegations.js @@ -0,0 +1,135 @@ +import * as Ucanto from '@ucanto/interface' +import { + delegationsToBytes, + bytesToDelegations, +} from '@web3-storage/access/encoding' + +/** + * @typedef {import('@web3-storage/access/src/types').DelegationTable} DelegationRow + * @typedef {Omit} DelegationRowUpdate + */ + +/** + * @typedef Tables + * @property {DelegationRow} delegations + */ + +/** + * @typedef {import("../types/database").Database} DelegationsDatabase + */ + +/** + * DelegationsStorage that persists using SQL. + * * should work with cloudflare D1 + */ +export class DbDelegationsStorage { + /** @type {DelegationsDatabase} */ + #db + + /** + * @param {DelegationsDatabase} db + */ + constructor(db) { + this.#db = db + // eslint-disable-next-line no-void + void ( + /** @type {import('../types/delegations').DelegationsStorage} */ (this) + ) + } + + async count() { + const { size } = await this.#db + .selectFrom('delegations') + .select((e) => e.fn.count('cid').as('size')) + .executeTakeFirstOrThrow() + return BigInt(size) + } + + /** + * @param {import('../types/delegations').Query} query + */ + async *find(query) { + for await (const row of await selectByAudience(this.#db, query.audience)) { + yield rowToDelegation(row) + } + } + + /** + * store items + * + * @param {Array} delegations + * @returns {Promise} + */ + async putMany(...delegations) { + if (delegations.length === 0) { + return + } + const values = delegations.map((d) => createDelegationRowUpdate(d)) + await this.#db + .insertInto('delegations') + .values(values) + .onConflict((oc) => oc.column('cid').doNothing()) + .executeTakeFirst() + } + + /** + * iterate through all stored items + * + * @returns {AsyncIterableIterator} + */ + async *[Symbol.asyncIterator]() { + if (!this.#db.canStream) { + throw Object.assign( + new Error( + `cannot create asyncIterator because the underlying database does not support streaming` + ), + { name: 'NotImplementedError' } + ) + } + for await (const row of this.#db + .selectFrom('delegations') + .select(['bytes']) + .stream()) { + yield rowToDelegation(row) + } + } +} + +/** + * @param {Pick} row + * @returns {Ucanto.Delegation} + */ +function rowToDelegation(row) { + const delegations = bytesToDelegations(row.bytes) + if (delegations.length !== 1) { + throw new Error( + `unexpected number of delegations from bytes: ${delegations.length}` + ) + } + return delegations[0] +} + +/** + * @param {Ucanto.Delegation} d + * @returns {DelegationRowUpdate} + */ +function createDelegationRowUpdate(d) { + return { + cid: d.cid.toV1().toString(), + audience: d.audience.did(), + issuer: d.issuer.did(), + bytes: delegationsToBytes([d]), + } +} + +/** + * @param {DelegationsDatabase} db + * @param {Ucanto.DID<'key'>} audience + */ +async function selectByAudience(db, audience) { + return await db + .selectFrom('delegations') + .selectAll() + .where('delegations.audience', '=', audience) + .execute() +} diff --git a/packages/access-api/src/models/spaces.js b/packages/access-api/src/models/spaces.js index 3a862053b..e273271b4 100644 --- a/packages/access-api/src/models/spaces.js +++ b/packages/access-api/src/models/spaces.js @@ -21,7 +21,14 @@ export class Spaces { constructor(d1) { /** @type {GenericPlugin} */ const objectPlugin = new GenericPlugin({ - metadata: (v) => JSON.parse(v), + metadata: (v) => { + // this will be `EMPTY` because it's the default value in the sql schema + // https://github.com/web3-storage/w3protocol/issues/447 + if (v === 'EMPTY') { + return + } + return JSON.parse(v) + }, inserted_at: (v) => new Date(v), updated_at: (v) => new Date(v), }) diff --git a/packages/access-api/src/service/access-claim.js b/packages/access-api/src/service/access-claim.js new file mode 100644 index 000000000..5bfd6ec85 --- /dev/null +++ b/packages/access-api/src/service/access-claim.js @@ -0,0 +1,54 @@ +import * as Server from '@ucanto/server' +import { claim } from '@web3-storage/capabilities/access' +import * as Ucanto from '@ucanto/interface' +import * as validator from '@ucanto/validator' +import * as delegationsResponse from '../utils/delegations-response.js' +import { collect } from 'streaming-iterables' + +/** + * @typedef {import('@web3-storage/capabilities/types').AccessClaimSuccess} AccessClaimSuccess + * @typedef {import('@web3-storage/capabilities/types').AccessClaimFailure} AccessClaimFailure + */ + +/** + * @callback AccessClaimHandler + * @param {Ucanto.Invocation} invocation + * @returns {Promise>} + */ + +/** + * @param {object} ctx + * @param {import('../types/delegations').DelegationsStorage} ctx.delegations + * @param {Pick} ctx.config + */ +export function accessClaimProvider(ctx) { + const handleClaimInvocation = createAccessClaimHandler(ctx) + return Server.provide(claim, async ({ invocation }) => { + // disable until hardened in test/staging + if (ctx.config.ENV === 'production') { + throw new Error(`acccess/claim invocation handling is not enabled`) + } + return handleClaimInvocation(invocation) + }) +} + +/** + * @param {object} options + * @param {import('../types/delegations').DelegationsStorage} options.delegations + * @returns {AccessClaimHandler} + */ +export function createAccessClaimHandler({ delegations }) { + /** @type {AccessClaimHandler} */ + return async (invocation) => { + const claimedAudience = invocation.capabilities[0].with + if (validator.DID.match({ method: 'mailto' }).is(claimedAudience)) { + throw new Error(`did:mailto not supported`) + } + const claimed = await collect( + delegations.find({ audience: claimedAudience }) + ) + return { + delegations: delegationsResponse.encode(claimed), + } + } +} diff --git a/packages/access-api/src/service/access-delegate.js b/packages/access-api/src/service/access-delegate.js new file mode 100644 index 000000000..45ddcf328 --- /dev/null +++ b/packages/access-api/src/service/access-delegate.js @@ -0,0 +1,107 @@ +import * as Server from '@ucanto/server' +import { delegate } from '@web3-storage/capabilities/access' +import * as Ucanto from '@ucanto/interface' +import { createDelegationsStorage } from './delegations.js' + +/** + * access/delegate failure due to the 'with' resource not having + * enough storage capacity to store the delegation. + * https://github.com/web3-storage/specs/blob/7e662a2d9ada4e3fc22a7a68f84871bff0a5380c/w3-access.md?plain=1#L94 + * + * Semantics inspired by https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/507 + * + * @typedef {import('@web3-storage/capabilities/types').InsufficientStorage} InsufficientStorage + */ + +/** + * @typedef {import('@web3-storage/capabilities/types').AccessDelegateSuccess} AccessDelegateSuccess + * @typedef {import('@web3-storage/capabilities/types').AccessDelegateFailure} AccessDelegateFailure + * @typedef {Ucanto.Result} AccessDelegateResult + */ + +/** + * @param {object} ctx + * @param {import('../types/delegations').DelegationsStorage} ctx.delegations + * @param {HasStorageProvider} ctx.hasStorageProvider + */ +export function accessDelegateProvider(ctx) { + const handleInvocation = createAccessDelegateHandler(ctx) + return Server.provide(delegate, async ({ capability, invocation }) => { + return handleInvocation( + /** @type {Ucanto.Invocation} */ ( + invocation + ) + ) + }) +} + +/** + * @callback AccessDelegateHandler + * @param {Ucanto.Invocation} invocation + * @returns {Promise} + */ + +/** + * @callback HasStorageProvider + * @param {Ucanto.DID<'key'>} did + * @returns {Promise} whether the given resource has a storage provider + */ + +/** + * @param {object} options + * @param {import('../types/delegations').DelegationsStorage} [options.delegations] + * @param {HasStorageProvider} [options.hasStorageProvider] + * @param {boolean} [options.allowServiceWithoutStorageProvider] - whether to allow service if the capability resource does not have a storage provider + * @returns {AccessDelegateHandler} + */ +export function createAccessDelegateHandler({ + delegations = createDelegationsStorage(), + hasStorageProvider = async () => false, + allowServiceWithoutStorageProvider = false, +} = {}) { + return async (invocation) => { + const capabability = invocation.capabilities[0] + if ( + !allowServiceWithoutStorageProvider && + !(await hasStorageProvider(capabability.with)) + ) { + return { + name: 'InsufficientStorage', + message: `${capabability.with} has no storage provider`, + error: true, + } + } + const delegated = extractProvenDelegations(invocation) + await delegations.putMany(...delegated) + return {} + } +} + +/** + * @param {Ucanto.Invocation} invocation + * @returns {Iterable>} + */ +function* extractProvenDelegations({ proofs, capabilities }) { + const nbDelegations = new Set(Object.values(capabilities[0].nb.delegations)) + const proofDelegations = proofs.flatMap((proof) => + 'capabilities' in proof ? [proof] : [] + ) + if (nbDelegations.size > proofDelegations.length) { + throw new Error( + `UnknownDelegation: nb.delegations has more delegations than proofs` + ) + } + for (const delegationLink of nbDelegations) { + // @todo avoid O(m*n) check here, but not obvious how while also using full Link#equals logic + // (could be O(minimum(m,n)) if comparing CID as strings, but that might ignore same link diff multibase) + const delegationProof = proofDelegations.find((p) => + delegationLink.equals(p.cid) + ) + if (!delegationProof) { + throw new Error( + `UnknownDelegation: missing proof for delegation cid ${delegationLink}` + ) + } + yield delegationProof + } +} diff --git a/packages/access-api/src/service/delegations.js b/packages/access-api/src/service/delegations.js new file mode 100644 index 000000000..0e99544ba --- /dev/null +++ b/packages/access-api/src/service/delegations.js @@ -0,0 +1,56 @@ +import * as Ucanto from '@ucanto/interface' + +// without this alias, eslint jsdoc/check-types will complain below +/* eslint-disable jsdoc/check-types */ +/** + * @typedef {typeof Symbol.iterator} SymbolIterator + */ +/* eslint-enable jsdoc/check-types */ + +/** + * DelegationsStorage that stores in-memory. + * + * @param {Pick, 'length' | 'push' | SymbolIterator>} storage + * @returns {import("../types/delegations").DelegationsStorage} + */ +export function createDelegationsStorage(storage = []) { + /** @type {import("../types/delegations").DelegationsStorage[typeof Symbol.asyncIterator]} */ + async function* asyncIterator() { + for (const delegation of storage) { + yield delegation + } + } + /** @type {import("../types/delegations").DelegationsStorage['count']} */ + async function count() { + return BigInt(storage.length) + } + /** @type {import("../types/delegations").DelegationsStorage['find']} */ + async function* find(query) { + for (const d of storage) { + if (d.audience.did() === query.audience) { + yield d + } + } + } + /** @type {import("../types/delegations").DelegationsStorage['putMany']} */ + async function putMany(...args) { + return storage.push(...args) + } + /** @type {import('../types/delegations').DelegationsStorage} */ + const delegations = { + [Symbol.asyncIterator]: asyncIterator, + count, + find, + putMany, + } + return delegations +} + +/** + * Given array of delegations, return a valid value for access/delegate nb.delegations + * + * @param {Array} delegations + */ +export function toDelegationsDict(delegations) { + return Object.fromEntries(delegations.map((d) => [d.cid.toString(), d.cid])) +} diff --git a/packages/access-api/src/service/index.js b/packages/access-api/src/service/index.js index 8bef92ff6..3f1f71c1a 100644 --- a/packages/access-api/src/service/index.js +++ b/packages/access-api/src/service/index.js @@ -11,6 +11,7 @@ import { voucherClaimProvider } from './voucher-claim.js' import { voucherRedeemProvider } from './voucher-redeem.js' import * as uploadApi from './upload-api-proxy.js' import { accessAuthorizeProvider } from './access-authorize.js' +import { accessDelegateProvider } from './access-delegate.js' /** * @param {import('../bindings').RouteContext} ctx @@ -26,6 +27,18 @@ export function service(ctx) { access: { authorize: accessAuthorizeProvider(ctx), + delegate: (...args) => { + // disable until hardened in test/staging + if (ctx.config.ENV === 'production') { + throw new Error(`acccess/delegate invocation handling is not enabled`) + } + return accessDelegateProvider({ + delegations: ctx.models.delegations, + hasStorageProvider: async (uri) => { + return Boolean(await ctx.models.spaces.get(uri)) + }, + })(...args) + }, }, voucher: { claim: voucherClaimProvider(ctx), diff --git a/packages/access-api/src/service/upload-api-proxy.js b/packages/access-api/src/service/upload-api-proxy.js index 484457ff8..d807ad71c 100644 --- a/packages/access-api/src/service/upload-api-proxy.js +++ b/packages/access-api/src/service/upload-api-proxy.js @@ -25,7 +25,7 @@ function createProxyService(options) { const service = options.methods.reduce((obj, method) => { obj[method] = handleInvocation return obj - }, /** @type {Record} */ ({})) + }, /** @type {Record>} */ ({})) return service } diff --git a/packages/access-api/src/types/database.ts b/packages/access-api/src/types/database.ts new file mode 100644 index 000000000..bdaa04929 --- /dev/null +++ b/packages/access-api/src/types/database.ts @@ -0,0 +1,9 @@ +import { Kysely } from 'kysely' + +export type Database = Kysely & { + /** + * whether or not this Databse supports Kysely stream() asyncIterator + * (kysely-d1 dialect does not) + */ + canStream: boolean +} diff --git a/packages/access-api/src/types/delegations.ts b/packages/access-api/src/types/delegations.ts new file mode 100644 index 000000000..eaf6bbc19 --- /dev/null +++ b/packages/access-api/src/types/delegations.ts @@ -0,0 +1,36 @@ +import * as Ucanto from '@ucanto/interface' + +interface ByAudience { + audience: Ucanto.DID<'key'> +} +export type Query = ByAudience + +export interface DelegationsStorage< + Cap extends Ucanto.Capability = Ucanto.Capability +> { + /** + * write several items into storage + * + * @param delegations - delegations to store + */ + putMany: ( + ...delegations: Array>> + ) => Promise + + /** + * get number of stored items + */ + count: () => Promise + + /** + * iterate through all stored items + */ + [Symbol.asyncIterator]: () => AsyncIterableIterator< + Ucanto.Delegation> + > + + /** + * find all items that match the query + */ + find: (query: Query) => AsyncIterable>> +} diff --git a/packages/access-api/src/utils/context.js b/packages/access-api/src/utils/context.js index 5e99285df..79984a9ce 100644 --- a/packages/access-api/src/utils/context.js +++ b/packages/access-api/src/utils/context.js @@ -9,6 +9,8 @@ import { Validations } from '../models/validations.js' import { Email } from './email.js' import { createUploadApiConnection } from '../service/upload-api-proxy.js' import { DID } from '@ucanto/core' +import { DbDelegationsStorage } from '../models/delegations.js' +import { createD1Database } from './d1.js' /** * Obtains a route context object. @@ -54,6 +56,7 @@ export function getContext(request, env, ctx) { config, url, models: { + delegations: new DbDelegationsStorage(createD1Database(config.DB)), spaces: new Spaces(config.DB), validations: new Validations(config.VALIDATIONS), accounts: new Accounts(config.DB), diff --git a/packages/access-api/src/utils/d1.js b/packages/access-api/src/utils/d1.js index 82731a918..b4b615fb6 100644 --- a/packages/access-api/src/utils/d1.js +++ b/packages/access-api/src/utils/d1.js @@ -1,7 +1,8 @@ // @ts-ignore // eslint-disable-next-line no-unused-vars import * as Ucanto from '@ucanto/interface' -import { OperationNodeTransformer } from 'kysely' +import { Kysely, OperationNodeTransformer } from 'kysely' +import { D1Dialect } from 'kysely-d1' import { isPlainObject, isDate, isBuffer } from './common.js' /** @@ -137,3 +138,27 @@ export class D1Error extends Error { this.code = error.cause.code } } + +/** + * @template S + * @param {D1Database} d1 + * @returns {import('../types/database.js').Database} + */ +export function createD1Database(d1) { + /** @type {Kysely} */ + const kdb = new Kysely({ + dialect: new D1Dialect({ database: d1 }), + plugins: [ + new GenericPlugin({ + // eslint-disable-next-line unicorn/no-null + expires_at: (v) => (typeof v === 'string' ? new Date(v) : null), + inserted_at: (v) => new Date(v), + updated_at: (v) => new Date(v), + }), + ], + }) + const db = Object.assign(kdb, { + canStream: false, + }) + return db +} diff --git a/packages/access-api/src/utils/delegations-response.js b/packages/access-api/src/utils/delegations-response.js new file mode 100644 index 000000000..03214b6ec --- /dev/null +++ b/packages/access-api/src/utils/delegations-response.js @@ -0,0 +1,46 @@ +/** + * test tools for encoding UCAN.Delegation + * into ucanto responses. + */ + +import * as Ucanto from '@ucanto/interface' +import { + bytesToDelegations, + delegationsToBytes, +} from '@web3-storage/access/encoding' + +/** + * @template D + * @typedef {Record>} DictCidToCarBytes + */ + +/** + * encode a set of delegations into a format suitable for a ucanto response. + * the ucanto response is likely going to be encoded to CBOR. + * encode the set to a dict, where keys are a CIDs of the delegation, and + * + * @template {Ucanto.Capabilities} Capabilities + * @param {Iterable>} delegations + * @returns {DictCidToCarBytes>} + */ +export function encode(delegations) { + const entries = [...delegations].map((d) => { + return /** @type {const} */ ([d.cid.toString(), delegationsToBytes([d])]) + }) + return Object.fromEntries(entries) +} + +/** + * @param {DictCidToCarBytes} encoded + * @returns {Iterable} + */ +export function* decode(encoded) { + for (const carBytes of Object.values(encoded)) { + const delegations = bytesToDelegations( + /** @type {import('@web3-storage/access/src/types.js').BytesDelegation} */ ( + carBytes + ) + ) + yield* delegations + } +} diff --git a/packages/access-api/src/utils/ucan.js b/packages/access-api/src/utils/ucan.js new file mode 100644 index 000000000..b415f61e0 --- /dev/null +++ b/packages/access-api/src/utils/ucan.js @@ -0,0 +1,29 @@ +import * as principal from '@ucanto/principal' +import * as Ucanto from '@ucanto/interface' +import * as ucanto from '@ucanto/core' + +/** + * @param {object} options + * @param {PromiseLike} [options.audience] + * @param {PromiseLike} [options.issuer] + * @param {Ucanto.URI} [options.with] + * @param {Ucanto.Ability} [options.can] + */ +export async function createSampleDelegation(options = {}) { + const { + issuer = Promise.resolve(principal.ed25519.generate()), + audience = Promise.resolve(principal.ed25519.generate()), + can, + } = options + const delegation = await ucanto.delegate({ + issuer: await issuer, + audience: await audience, + capabilities: [ + { + with: options.with || 'urn:', + can: can || 'test/*', + }, + ], + }) + return delegation +} diff --git a/packages/access-api/test/access-delegate.test.js b/packages/access-api/test/access-delegate.test.js new file mode 100644 index 000000000..55b224440 --- /dev/null +++ b/packages/access-api/test/access-delegate.test.js @@ -0,0 +1,639 @@ +import { context } from './helpers/context.js' +import * as Access from '@web3-storage/capabilities/access' +import * as assert from 'node:assert' +// eslint-disable-next-line no-unused-vars +import * as Ucanto from '@ucanto/interface' +import * as ucanto from '@ucanto/core' +import * as principal from '@ucanto/principal' +import { createAccessDelegateHandler } from '../src/service/access-delegate.js' +import { createAccessClaimHandler } from '../src/service/access-claim.js' +import { + createDelegationsStorage, + toDelegationsDict, +} from '../src/service/delegations.js' +import { createD1Database } from '../src/utils/d1.js' +import { DbDelegationsStorage } from '../src/models/delegations.js' +import { Voucher } from '@web3-storage/capabilities' +import * as delegationsResponse from '../src/utils/delegations-response.js' + +/** + * Run the same tests against several variants of access/delegate handlers. + */ +for (const handlerVariant of /** @type {const} */ ([ + { + name: 'handled by access-api in miniflare', + ...(() => { + const spaceWithStorageProvider = principal.ed25519.generate() + return { + spaceWithStorageProvider, + ...createTesterFromContext(() => context(), { + registerSpaces: [spaceWithStorageProvider], + }), + } + })(), + }, + { + name: 'handled by access-delegate-handler', + ...(() => { + const spaceWithStorageProvider = principal.ed25519.generate() + return { + spaceWithStorageProvider, + ...createTesterFromHandler(() => + createAccessDelegateHandler({ + hasStorageProvider: async (uri) => { + return ( + uri === (await spaceWithStorageProvider.then((s) => s.did())) + ) + }, + }) + ), + } + })(), + }, +])) { + describe(`access/delegate ${handlerVariant.name}`, () => { + // test common variants of access/delegate invocation + for (const [variantName, createTest] of Object.entries( + namedDelegateVariants({ + spaceWithStorageProvider: handlerVariant.spaceWithStorageProvider, + }) + )) { + it(`handles variant ${variantName}`, async () => { + const { issuer, audience, invoke } = handlerVariant + const { invocation, check } = await createTest({ issuer, audience }) + /** @type {Ucanto.Result} */ + const result = await invoke(invocation) + if (typeof check === 'function') { + await check(result) + } + assertNotError(result) + }) + } + + it(`InsufficientStorage if DID in the with field has no storage provider`, async () => { + await testInsufficientStorageIfNoStorageProvider(handlerVariant) + }) + }) +} + +/** + * Run the same tests against several variants of ( access/delegate | access/claim ) handlers. + */ +for (const variant of /** @type {const} */ ([ + { + name: 'handled by createAccessHandler using array createDelegationsStorage', + ...(() => { + const spaceWithStorageProvider = principal.ed25519.generate() + return { + spaceWithStorageProvider, + ...createTesterFromHandler( + (() => { + const delegations = createDelegationsStorage() + return () => { + return createAccessHandler( + createAccessDelegateHandler({ + delegations, + hasStorageProvider: async (uri) => { + return ( + uri === + (await spaceWithStorageProvider.then((s) => s.did())) + ) + }, + }), + createAccessClaimHandler({ delegations }) + ) + } + })() + ), + } + })(), + }, + { + name: 'handled by createAccessHandler using DbDelegationsStorage', + ...(() => { + const spaceWithStorageProvider = principal.ed25519.generate() + const d1 = context().then((ctx) => ctx.d1) + const database = d1.then((d1) => createD1Database(d1)) + const delegations = database.then((db) => new DbDelegationsStorage(db)) + return { + spaceWithStorageProvider, + ...createTesterFromHandler( + (() => { + return () => { + /** + * @type {InvocationHandler} + */ + return async (invocation) => { + const handle = createAccessHandler( + createAccessDelegateHandler({ + delegations: await delegations, + hasStorageProvider: async (uri) => { + return ( + uri === + (await spaceWithStorageProvider.then((s) => s.did())) + ) + }, + }), + createAccessClaimHandler({ delegations: await delegations }) + ) + return handle(invocation) + } + } + })() + ), + } + })(), + }, + /* + @todo: uncomment this testing against access-api + miniflare + * after + * more tests on createAccessClaimHandler alone + * ensure you can only claim things that are delegated to you, etc. + * use createAccessClaimHandler inside of access-api ucanto service/server + */ + // { + // name: 'handled by access-api in miniflare', + // ...createTesterFromContext(() => context()), + // }, +])) { + describe(`access/delegate ${variant.name}`, () => { + // test delegate, then claim + it('can delegate, then claim', async () => { + await testCanDelegateThenClaim( + variant.invoke, + await variant.spaceWithStorageProvider, + await variant.audience + ) + }) + }) +} + +/** + * Tests using context from "./helpers/context.js", which sets up a testable access-api inside miniflare. + * + * @param {() => Promise<{ issuer: Ucanto.Signer>, service: Ucanto.Signer, conn: Ucanto.ConnectionView> }>} createContext + * @param {object} [options] + * @param {Iterable>} options.registerSpaces - spaces to register in access-api. Some access-api functionality on a space requires it to be registered. + */ +function createTesterFromContext(createContext, options) { + const context = createContext().then(async (ctx) => { + await registerSpaces(options?.registerSpaces ?? [], ctx.service, ctx.conn) + return ctx + }) + const issuer = context.then(({ issuer }) => issuer) + const audience = context.then(({ service }) => service) + /** + * @template {Ucanto.Capability} Capability + * @param {Ucanto.Invocation} invocation + */ + const invoke = async (invocation) => { + const { conn } = await context + const [result] = await conn.execute(invocation) + return result + } + return { issuer, audience, invoke } +} + +/** + * given an iterable of spaces, register them against an access-api + * using a service-issued voucher/redeem invocation + * + * @param {Iterable>} spaces + * @param {Ucanto.Signer} issuer + * @param {Ucanto.ConnectionView>} conn + */ +async function registerSpaces(spaces, issuer, conn) { + for (const spacePromise of spaces) { + const space = await spacePromise + const redeem = await spaceRegistrationInvocation(issuer, space.did()) + const results = await conn.execute(redeem) + assert.deepEqual( + results.length, + 1, + 'registration invocation should have 1 result' + ) + const [result] = results + assertNotError(result) + } +} + +/** + * get an access-api invocation that will register a space. + * This is useful e.g. because some functionality (e.g. access/delegate) + * will fail unless the space is registered. + * + * @param {Ucanto.Signer} issuer - issues voucher/redeem. e.g. could be the same signer as access-api env.PRIVATE_KEY + * @param {Ucanto.DID} space + * @param {Ucanto.Principal} audience - audience of the invocation. often is same as issuer + */ +async function spaceRegistrationInvocation(issuer, space, audience = issuer) { + const redeem = await Voucher.redeem + .invoke({ + issuer, + audience, + with: issuer.did(), + nb: { + product: 'product:free', + space, + identity: 'mailto:someone', + }, + }) + .delegate() + return redeem +} + +/** + * @template {Ucanto.Capability} Capability + * @template Result + * @typedef {object} InvokeTester + * @property {(invocation: Ucanto.Invocation) => Promise} invoke + * @property {Resolvable>>} issuer + * @property {Resolvable>} audience + */ + +/** + * Tests using simple function invocation -> result + * + * @template {Ucanto.Capability} Capability + * @template Result + * @param {() => (invocation: Ucanto.Invocation) => Promise} createHandler + * @returns {InvokeTester} + */ +function createTesterFromHandler(createHandler) { + const issuer = principal.ed25519.generate() + const audience = principal.ed25519.generate() + /** + * @param {Ucanto.Invocation} invocation + */ + const invoke = async (invocation) => { + const handle = createHandler() + const result = await handle(invocation) + return result + } + return { issuer, audience, invoke } +} + +/** + * a value that can be passed to Promise.resolve() to get Promise + * + * @template T + * @typedef {T | Promise} Resolvable + */ + +/** + * @typedef InvocationContext + * @property {Resolvable>>} issuer + * @property {Resolvable} audience + */ + +/** + * @template {Ucanto.CapabilityParser>} CapabilityParser + * @template [Success=unknown] + * @typedef InvocationTest + * @property {Ucanto.Invocation>} invocation + * @property {(result: Ucanto.Result) => Resolvable} [check] - check the result of the invocation. throw if not valid + */ + +/** + * @template {Ucanto.CapabilityParser>} CapabilityParser + * @template [Success=unknown] + * @typedef {(options: InvocationContext) => Promise>} InvocationTestCreator + */ + +/** + * @param {object} options + * @param {Promise>>} options.spaceWithStorageProvider + * @returns {InvocationTestCreator} + */ +function createTestWithSpaceAndEmptyDelegationSet(options) { + /** + * create valid delegate invocation with an empty delegation set + * + * @type {InvocationTestCreator} + */ + return async function (invocationOptions) { + const issuer = await invocationOptions.issuer + const audience = await invocationOptions.audience + const spaceWithStorageProvider = await options.spaceWithStorageProvider + const authorizationToDelegate = await Access.delegate.delegate({ + issuer: spaceWithStorageProvider, + audience: issuer, + with: spaceWithStorageProvider.did(), + }) + const invocation = await Access.delegate + .invoke({ + issuer, + audience, + with: spaceWithStorageProvider.did(), + nb: { + delegations: {}, + }, + proofs: [authorizationToDelegate], + }) + .delegate() + return { invocation } + } +} + +/** + * @param {object} options + * @param {Promise>>} options.spaceWithStorageProvider + * @param {(options: { issuer: Ucanto.Signer> }) => Resolvable>} options.delegations - delegations to delegate vi access/delegate .nb.delegations + * @returns {InvocationTestCreator} + */ +function createTestWithSpace(options) { + return async function (invocationOptions) { + const issuer = await invocationOptions.issuer + const audience = await invocationOptions.audience + const spaceWithStorageProvider = await options.spaceWithStorageProvider + const authorizationToDelegate = await Access.top.delegate({ + issuer: spaceWithStorageProvider, + audience: issuer, + with: spaceWithStorageProvider.did(), + }) + const delegations = [...(await options.delegations({ issuer }))] + const invocation = await Access.delegate + .invoke({ + issuer, + audience, + with: spaceWithStorageProvider.did(), + nb: { + delegations: toDelegationsDict(delegations), + }, + proofs: [authorizationToDelegate, ...delegations], + }) + .delegate() + return { invocation } + } +} + +/** + * @param {object} options + * @param {Promise>>} options.spaceWithStorageProvider + * @returns {Record>} + */ +function namedDelegateVariants({ spaceWithStorageProvider }) { + return { + withSpaceAndEmptyDelegationSet: createTestWithSpaceAndEmptyDelegationSet({ + spaceWithStorageProvider, + }), + withSpaceAndSingleDelegation: createTestWithSpace({ + spaceWithStorageProvider, + delegations: async ({ issuer }) => { + return [ + await ucanto.delegate({ + issuer, + audience: issuer, + capabilities: [{ can: '*', with: 'did:web:example.com' }], + }), + ] + }, + }), + } +} + +describe('access-delegate-handler', () => { + it('UnknownDelegation when invoked with nb.delegations not included in proofs', async () => { + const alice = await principal.ed25519.generate() + const bob = await principal.ed25519.generate() + const delegated = await ucanto.delegate({ + issuer: alice, + audience: alice, + capabilities: [{ can: '*', with: 'urn:foo' }], + }) + const invocation = await Access.delegate + .invoke({ + issuer: alice, + audience: bob, + with: alice.did(), + nb: { + delegations: { + notACid: delegated.cid, + }, + }, + // note: empty! + proofs: [], + }) + .delegate() + const delegations = createDelegationsStorage() + const handleAccessDelegate = createAccessDelegateHandler({ + delegations, + hasStorageProvider: async (uri) => { + return uri === alice.did() + }, + }) + await assert.rejects(handleAccessDelegate(invocation), 'UnknownDelegation') + assert.deepEqual(await delegations.count(), 0, '0 delegations were stored') + }) + it('stores delegations', async () => { + const alice = await principal.ed25519.generate() + const bob = await principal.ed25519.generate() + const delegated = await ucanto.delegate({ + issuer: alice, + audience: alice, + capabilities: [{ can: '*', with: 'urn:foo' }], + }) + const invocation = await Access.delegate + .invoke({ + issuer: alice, + audience: bob, + with: alice.did(), + nb: { + delegations: { + notACid: delegated.cid, + }, + }, + proofs: [delegated], + }) + .delegate() + const delegations = createDelegationsStorage() + const handleAccessDelegate = createAccessDelegateHandler({ + delegations, + hasStorageProvider: async (uri) => uri === alice.did(), + }) + const result = await handleAccessDelegate(invocation) + assertNotError(result, 'invocation result is not an error') + assert.deepEqual(await delegations.count(), 1, '1 delegation was stored') + }) + + // "Provider SHOULD deny service if DID in the `with` field has no storage provider." + // https://github.com/web3-storage/specs/blob/7e662a2d9ada4e3fc22a7a68f84871bff0a5380c/w3-access.md?plain=1#L94 + it('InsufficientStorage if DID in the `with` field has no storage provider', async () => { + await testInsufficientStorageIfNoStorageProvider({ + audience: await principal.ed25519.generate(), + invoke: createAccessDelegateHandler({ + delegations: createDelegationsStorage(), + // note: always returns false + hasStorageProvider: async () => false, + }), + }) + }) +}) + +/** + * @param {object} options + * @param {Resolvable} options.audience + * @param {(inv: Ucanto.Invocation) => Promise>} options.invoke + */ +async function testInsufficientStorageIfNoStorageProvider(options) { + const alice = await principal.ed25519.generate() + const invocation = await Access.delegate + .invoke({ + issuer: alice, + audience: await options.audience, + with: alice.did(), + nb: { + delegations: {}, + }, + }) + .delegate() + const result = await options.invoke(invocation) + assert.ok(result.error, 'invocation result.error is truthy') + assert.ok('name' in result, 'result has a .name property') + assert.deepEqual(result.name, 'InsufficientStorage') + assert.ok( + result.message.includes('has no storage provider'), + 'InsufficientStorage message indicates that it is because there is no storage provider' + ) +} + +/** + * @template {Ucanto.Capability} Capability + * @template [Success=unknown] + * @template {{ error: true }} [Failure=Ucanto.Failure] + * @typedef {(invocation: Ucanto.Invocation) => Promise>} InvocationHandler + */ + +/** + * @param {InvocationHandler} invoke + * @param {Ucanto.Signer>} issuer + * @param {Ucanto.Verifier} audience + */ +async function testCanDelegateThenClaim(invoke, issuer, audience) { + const setup = await setupDelegateThenClaim(issuer, audience) + const { delegate } = setup + const delegateResult = await invoke(delegate) + warnOnErrorResult(delegateResult) + assert.notDeepEqual( + delegateResult.error, + true, + 'result of access/delegate is not an error' + ) + + // delegate succeeded, now try to claim it + const { claim } = setup + const claimResult = await invoke(claim) + assertNotError(claimResult) + const claimedDelegations = [ + ...delegationsResponse.decode( + /** @type {import('../src/service/access-claim.js').AccessClaimSuccess} */ ( + claimResult + ).delegations + ), + ] + const { delegations } = setup + assert.deepEqual( + claimedDelegations, + delegations, + 'claimed all delegated delegations' + ) +} + +/** + * @param {{ error?: unknown }|null} result + * @param {string} assertionMessage + */ +function assertNotError(result, assertionMessage = 'result is not an error') { + warnOnErrorResult(result) + if (result && 'error' in result) { + assert.notDeepEqual(result.error, true, assertionMessage) + } +} + +/** + * @param {{ error?: unknown }|null} result + * @param {string} [message] + * @param {(...loggables: any[]) => void} warn + */ +function warnOnErrorResult( + result, + message = 'unexpected error result', + // eslint-disable-next-line no-console + warn = console.warn.bind(console) +) { + if (result && 'error' in result && result.error) { + warn(message, result) + } +} + +/** + * setup test scenario testing that an access/delegate can be followed up by access/claim. + * + * @param {Ucanto.Signer>} invoker + * @param {Ucanto.Verifier} audience + */ +async function setupDelegateThenClaim(invoker, audience) { + const alice = await principal.ed25519.generate() + const aliceSaysInvokerCanStoreAllWithAlice = await ucanto.delegate({ + issuer: alice, + audience: invoker, + capabilities: [{ can: 'store/*', with: alice.did() }], + }) + const delegations = [aliceSaysInvokerCanStoreAllWithAlice] + // invocation of access/delegate + const delegate = await Access.delegate + .invoke({ + issuer: invoker, + audience, + with: invoker.did(), + nb: { + delegations: toDelegationsDict(delegations), + }, + proofs: delegations, + }) + .delegate() + // invocation of access/claim that should claim the delegations + // claim as invoker, since invoker is the audience of `aliceSaysInvokerCanStoreAllWithAlice` + const claim = await Access.claim + .invoke({ + issuer: invoker, + audience, + with: invoker.did(), + proofs: [], + }) + .delegate() + return { delegate, claim, delegations } +} + +/** + * @typedef {Ucanto.InferInvokedCapability} AccessClaim + * @typedef {Ucanto.InferInvokedCapability} AccessDelegate + */ + +/** + * @param {import('../src/service/access-delegate.js').AccessDelegateHandler} handleDelegate + * @param {InvocationHandler} handleClaim + * @returns {InvocationHandler} + */ +function createAccessHandler(handleDelegate, handleClaim) { + return async (invocation) => { + const can = invocation.capabilities[0].can + switch (can) { + case 'access/claim': { + return handleClaim( + /** @type {Ucanto.Invocation} */ (invocation) + ) + } + case 'access/delegate': { + return handleDelegate( + /** @type {Ucanto.Invocation} */ (invocation) + ) + } + default: { + // eslint-disable-next-line no-void + void (/** @type {never} */ (can)) + } + } + throw new Error(`unexpected can=${can}`) + } +} diff --git a/packages/access-api/test/delegations-response.test.js b/packages/access-api/test/delegations-response.test.js new file mode 100644 index 000000000..a79fe74b9 --- /dev/null +++ b/packages/access-api/test/delegations-response.test.js @@ -0,0 +1,96 @@ +/** + * test tools for encoding UCAN.Delegation + * into ucanto responses. + */ + +import * as Ucanto from '@ucanto/interface' +import { createSampleDelegation } from '../src/utils/ucan.js' +import * as assert from 'node:assert' +import { + bytesToDelegations, + delegationsToBytes, +} from '@web3-storage/access/encoding' + +/** + * @template D + * @typedef {Record>} DictCidToCarBytes + */ + +/** + * encode a set of delegations into a format suitable for a ucanto response. + * the ucanto response is likely going to be encoded to CBOR. + * encode the set to a dict, where keys are a CIDs of the delegation, and + * + * @template {Ucanto.Capabilities} Capabilities + * @param {Iterable>} delegations + * @returns {DictCidToCarBytes>} + */ +function encode(delegations) { + const entries = [...delegations].map((d) => { + return /** @type {const} */ ([d.cid.toString(), delegationsToBytes([d])]) + }) + return Object.fromEntries(entries) +} + +/** + * @param {DictCidToCarBytes} encoded + * @returns {Iterable} + */ +function* decode(encoded) { + for (const carBytes of Object.values(encoded)) { + const delegations = bytesToDelegations( + /** @type {import('@web3-storage/access/src/types.js').BytesDelegation} */ ( + carBytes + ) + ) + yield* delegations + } +} + +it('can encode delegations set to dict', async () => { + const delegations = [ + ...(await createSampleDelegations(Math.ceil(3 * Math.random()))), + ] + const delegationsCidStrings = new Set(delegations.map((d) => String(d.cid))) + const encoded = await encode(delegations) + assert.deepEqual( + Object.entries(encoded).length, + delegations.length, + 'encoded has one entry for each delegation' + ) + for (const value of Object.values(encoded)) { + const decodedDelegations = bytesToDelegations( + /** @type {import('@web3-storage/access/src/types.js').BytesDelegation} */ ( + value + ) + ) + assert.deepEqual(decodedDelegations.length, 1, 'decodedValue has one entry') + const [delegation] = decodedDelegations + assert.ok( + delegationsCidStrings.has(delegation.cid.toString()), + 'decoded delegations entry has same cid as original' + ) + } +}) + +it('can decode delegations dict back to set', async () => { + const delegations = [ + ...(await createSampleDelegations(Math.ceil(3 * Math.random()))), + ] + const delegationsCidStrings = new Set(delegations.map((d) => String(d.cid))) + const encoded = await encode(delegations) + const decoded = [...decode(encoded)] + const decodedCidStrings = new Set(decoded.map((d) => String(d.cid))) + assert.deepEqual( + [...delegationsCidStrings], + [...decodedCidStrings], + 'decoded has same cids as original' + ) +}) + +async function createSampleDelegations(length = 3) { + const delegations = await Promise.all( + Array.from({ length }).map(() => createSampleDelegation()) + ) + return delegations +} diff --git a/packages/access-api/test/delegations-storage.test.js b/packages/access-api/test/delegations-storage.test.js new file mode 100644 index 000000000..51d9d90b5 --- /dev/null +++ b/packages/access-api/test/delegations-storage.test.js @@ -0,0 +1,93 @@ +import { context } from './helpers/context.js' +import { DbDelegationsStorage } from '../src/models/delegations.js' +import { createD1Database } from '../src/utils/d1.js' +import * as assert from 'node:assert' +import { createSampleDelegation } from '../src/utils/ucan.js' +import * as principal from '@ucanto/principal' +import * as Ucanto from '@ucanto/interface' +import * as ucanto from '@ucanto/core' +import { collect } from 'streaming-iterables' + +describe('DbDelegationsStorage', () => { + it('should persist delegations', async () => { + const { d1 } = await context() + const storage = new DbDelegationsStorage(createD1Database(d1)) + const count = Math.round(Math.random() * 10) + const delegations = await Promise.all( + Array.from({ length: count }).map(() => createSampleDelegation()) + ) + await storage.putMany(...delegations) + assert.deepEqual(await storage.count(), delegations.length) + }) + + it('can retrieve delegations by audience', async () => { + const { issuer, d1 } = await context() + const delegations = new DbDelegationsStorage(createD1Database(d1)) + + const alice = await principal.ed25519.generate() + const delegationsForAlice = await Promise.all( + Array.from({ length: 1 }).map(() => + createDelegation({ issuer, audience: alice }) + ) + ) + + const bob = await principal.ed25519.generate() + const delegationsForBob = await Promise.all( + Array.from({ length: 2 }).map((e, i) => + createDelegation({ + issuer, + audience: bob, + capabilities: [ + { + can: `test/${i}`, + with: alice.did(), + }, + ], + }) + ) + ) + + await delegations.putMany(...delegationsForAlice, ...delegationsForBob) + + const aliceDelegations = await collect( + delegations.find({ audience: alice.did() }) + ) + assert.deepEqual(aliceDelegations.length, delegationsForAlice.length) + + const bobDelegations = await collect( + delegations.find({ audience: bob.did() }) + ) + assert.deepEqual(bobDelegations.length, delegationsForBob.length) + + const carol = await principal.ed25519.generate() + const carolDelegations = await collect( + delegations.find({ audience: carol.did() }) + ) + assert.deepEqual(carolDelegations.length, 0) + }) +}) + +/** + * @param {object} [opts] + * @param {Ucanto.Signer} [opts.issuer] + * @param {Ucanto.Principal} [opts.audience] + * @param {Ucanto.Capabilities} [opts.capabilities] + * @returns {Promise} + */ +async function createDelegation(opts = {}) { + const { + issuer = await principal.ed25519.generate(), + audience = issuer, + capabilities = [ + { + can: 'test/*', + with: issuer.did(), + }, + ], + } = opts + return await ucanto.delegate({ + issuer, + audience, + capabilities, + }) +} diff --git a/packages/access-client/src/types.ts b/packages/access-client/src/types.ts index 097bc68d2..9ac06e689 100644 --- a/packages/access-client/src/types.ts +++ b/packages/access-client/src/types.ts @@ -32,6 +32,9 @@ import type { VoucherRedeem, Top, AccessAuthorize, + AccessDelegate, + AccessDelegateFailure, + AccessDelegateSuccess, } from '@web3-storage/capabilities/types' import type { SetRequired } from 'type-fest' import { Driver } from './drivers/types.js' @@ -90,6 +93,11 @@ export interface Service { access: { // returns a URL string for tests or nothing in other envs authorize: ServiceMethod + delegate: ServiceMethod< + AccessDelegate, + AccessDelegateSuccess, + AccessDelegateFailure + > } voucher: { claim: ServiceMethod< diff --git a/packages/capabilities/src/types.ts b/packages/capabilities/src/types.ts index 933ee2246..0d2d30757 100644 --- a/packages/capabilities/src/types.ts +++ b/packages/capabilities/src/types.ts @@ -1,4 +1,5 @@ import type { TupleToUnion } from 'type-fest' +import * as Ucanto from '@ucanto/interface' import { InferInvokedCapability } from '@ucanto/interface' import { space, info, recover, recoverValidation } from './space.js' import { top } from './top.js' @@ -7,11 +8,32 @@ import * as UploadCaps from './upload.js' import { claim, redeem } from './voucher.js' import * as AccessCaps from './access.js' +/** + * failure due to a resource not having enough storage capacity. + */ +export interface InsufficientStorage { + error: true + name: 'InsufficientStorage' + message: string +} + // Access export type Access = InferInvokedCapability export type AccessAuthorize = InferInvokedCapability< typeof AccessCaps.authorize > +export type AccessClaim = InferInvokedCapability +export interface AccessClaimSuccess { + delegations: Record> +} +export interface AccessClaimFailure { + error: true +} + +export type AccessDelegate = InferInvokedCapability +export type AccessDelegateSuccess = unknown +export type AccessDelegateFailure = { error: true } | InsufficientStorage + export type AccessSession = InferInvokedCapability // Space diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5622b7997..d23fbf316 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -47,6 +47,7 @@ importers: '@ucanto/principal': ^4.2.3 '@ucanto/server': ^4.2.3 '@ucanto/transport': ^4.2.3 + '@ucanto/validator': ^4.2.3 '@web3-storage/access': workspace:^ '@web3-storage/capabilities': workspace:^ '@web3-storage/worker-utils': 0.4.3-dev @@ -69,6 +70,7 @@ importers: qrcode: ^1.5.1 readable-stream: ^4.2.0 sade: ^1.8.1 + streaming-iterables: ^7.1.0 toucan-js: ^2.7.0 typescript: 4.9.4 wrangler: ^2.8.0 @@ -79,6 +81,7 @@ importers: '@ucanto/principal': 4.2.3 '@ucanto/server': 4.2.3 '@ucanto/transport': 4.2.3 + '@ucanto/validator': 4.2.3 '@web3-storage/access': link:../access-client '@web3-storage/capabilities': link:../capabilities '@web3-storage/worker-utils': 0.4.3-dev @@ -88,6 +91,7 @@ importers: preact: 10.11.3 preact-render-to-string: 5.2.6_preact@10.11.3 qrcode: 1.5.1 + streaming-iterables: 7.1.0 toucan-js: 2.7.0 devDependencies: '@cloudflare/workers-types': 3.19.0 @@ -11492,6 +11496,11 @@ packages: readable-stream: 3.6.0 dev: true + /streaming-iterables/7.1.0: + resolution: {integrity: sha512-t2KmiLVhqafTRqGefD98s5XAMskfkfprr/BTzPIZz0kWB23iyR7XUkY03yjUf4aZpAuuV2/2SUOVri3LgKuOKw==} + engines: {node: '>=14'} + dev: false + /streamsearch/1.1.0: resolution: {integrity: sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==} engines: {node: '>=10.0.0'}