From 6be7217c677e347aca78f8c982a9b56703a15c08 Mon Sep 17 00:00:00 2001 From: Benjamin Goering <171782+gobengo@users.noreply.github.com> Date: Fri, 13 Jan 2023 18:03:09 -0800 Subject: [PATCH] feat: access-api forwards store/ and upload/ invocations to upload-api (#334) Motivation: * https://github.com/web3-storage/w3protocol/issues/331 --- packages/access-api/package.json | 1 + packages/access-api/src/bindings.d.ts | 7 + packages/access-api/src/config.js | 22 ++- packages/access-api/src/service/index.js | 14 +- .../src/service/upload-api-proxy.js | 133 +++++++++++++++ packages/access-api/src/ucanto/proxy.js | 45 +++++ packages/access-api/src/ucanto/types.ts | 31 ++++ packages/access-api/src/utils/context.js | 8 + packages/access-api/test/helpers/context.js | 9 +- .../access-api/test/helpers/upload-api.js | 69 ++++++++ packages/access-api/test/helpers/utils.js | 14 +- packages/access-api/test/store-list.js | 161 ++++++++++++++++++ packages/access-api/test/ucanto-proxy.test.js | 92 ++++++++++ .../access-api/test/upload-api-proxy.test.js | 115 +++++++++++++ packages/access-client/src/agent.js | 8 +- packages/capabilities/src/upload.js | 2 + pnpm-lock.yaml | 3 +- 17 files changed, 722 insertions(+), 12 deletions(-) create mode 100644 packages/access-api/src/service/upload-api-proxy.js create mode 100644 packages/access-api/src/ucanto/proxy.js create mode 100644 packages/access-api/test/helpers/upload-api.js create mode 100644 packages/access-api/test/store-list.js create mode 100644 packages/access-api/test/ucanto-proxy.test.js create mode 100644 packages/access-api/test/upload-api-proxy.test.js diff --git a/packages/access-api/package.json b/packages/access-api/package.json index 92167bf37..45e57ca88 100644 --- a/packages/access-api/package.json +++ b/packages/access-api/package.json @@ -43,6 +43,7 @@ "@types/mocha": "^10.0.1", "@types/node": "^18.11.14", "@types/qrcode": "^1.5.0", + "@ucanto/client": "^4.0.3", "better-sqlite3": "8.0.1", "buffer": "^6.0.3", "dotenv": "^16.0.3", diff --git a/packages/access-api/src/bindings.d.ts b/packages/access-api/src/bindings.d.ts index c154304fb..2b1027f70 100644 --- a/packages/access-api/src/bindings.d.ts +++ b/packages/access-api/src/bindings.d.ts @@ -28,6 +28,9 @@ export interface Env { * * this may be used to filter incoming ucanto invocations */ DID: string + // URLs to upload-api so we proxy invocations to it + UPLOAD_API_URL: string + UPLOAD_API_URL_STAGING: string // secrets PRIVATE_KEY: string SENTRY_DSN: string @@ -51,6 +54,10 @@ export interface RouteContext { spaces: Spaces validations: Validations } + uploadApi: { + production?: URL + staging?: URL + } } export type Handler = _Handler diff --git a/packages/access-api/src/config.js b/packages/access-api/src/config.js index 8539ce521..cbef84f2b 100644 --- a/packages/access-api/src/config.js +++ b/packages/access-api/src/config.js @@ -1,5 +1,7 @@ import { Signer } from '@ucanto/principal/ed25519' -import * as DID from '@ipld/dag-ucan/did' +// eslint-disable-next-line no-unused-vars +import * as UCAN from '@ucanto/interface' +import { DID } from '@ucanto/core' /** * Loads configuration variables from the global environment and returns a JS object @@ -64,6 +66,9 @@ export function loadConfig(env) { SPACES: env.SPACES, VALIDATIONS: env.VALIDATIONS, DB: /** @type {D1Database} */ (env.__D1_BETA__), + + UPLOAD_API_URL: env.UPLOAD_API_URL, + UPLOAD_API_URL_STAGING: env.UPLOAD_API_URL_STAGING, } } @@ -128,3 +133,18 @@ export function configureSigner(config) { } return signer } + +/** + * @template {UCAN.DID} ConfigDID + * @template {UCAN.SigAlg} [Alg=UCAN.SigAlg] + * @param {object} config + * @param {ConfigDID} [config.DID] - public DID for the service + * @param {import('@ucanto/interface').Verifier} verifier + * @returns {import('@ucanto/interface').Verifier} + */ +export function configureVerifier(config, verifier) { + if (config.DID) { + return verifier.withDID(DID.parse(config.DID).did()) + } + return verifier +} diff --git a/packages/access-api/src/service/index.js b/packages/access-api/src/service/index.js index f6d2d4c24..06ea54814 100644 --- a/packages/access-api/src/service/index.js +++ b/packages/access-api/src/service/index.js @@ -1,4 +1,4 @@ -import * as DID from '@ipld/dag-ucan/did' +import * as ucanto from '@ucanto/core' import * as Server from '@ucanto/server' import { Failure } from '@ucanto/server' import * as Space from '@web3-storage/capabilities/space' @@ -9,13 +9,21 @@ import { } from '@web3-storage/access/encoding' import { voucherClaimProvider } from './voucher-claim.js' import { voucherRedeemProvider } from './voucher-redeem.js' +import * as uploadApi from './upload-api-proxy.js' /** * @param {import('../bindings').RouteContext} ctx - * @returns {import('@web3-storage/access/types').Service} + * @returns { + * & import('@web3-storage/access/types').Service + * & { store: uploadApi.StoreServiceInferred } + * & { upload: uploadApi.UploadServiceInferred } + * } */ export function service(ctx) { return { + store: uploadApi.createStoreProxy(ctx), + upload: uploadApi.createUploadProxy(ctx), + voucher: { claim: voucherClaimProvider(ctx), redeem: voucherRedeemProvider(ctx), @@ -97,7 +105,7 @@ export function service(ctx) { const inv = await Space.recover .invoke({ issuer: ctx.signer, - audience: DID.parse(capability.with), + audience: ucanto.DID.parse(capability.with), with: ctx.signer.did(), lifetimeInSeconds: 60 * 10, nb: { diff --git a/packages/access-api/src/service/upload-api-proxy.js b/packages/access-api/src/service/upload-api-proxy.js new file mode 100644 index 000000000..90523f598 --- /dev/null +++ b/packages/access-api/src/service/upload-api-proxy.js @@ -0,0 +1,133 @@ +import * as Client from '@ucanto/client' +import * as CAR from '@ucanto/transport/car' +import * as CBOR from '@ucanto/transport/cbor' +import { DID } from '@ucanto/core' +import * as HTTP from '@ucanto/transport/http' +// eslint-disable-next-line no-unused-vars +import * as Ucanto from '@ucanto/interface' +import { createProxyHandler } from '../ucanto/proxy.js' + +/** + * @typedef {import('../ucanto/types.js').InferService>} StoreServiceInferred + * @typedef {import('../ucanto/types.js').InferService>} UploadServiceInferred + */ + +/** + * @template {string|number|symbol} M + * @template {Ucanto.ConnectionView} [Connection=Ucanto.ConnectionView] + * @param {object} options + * @param {Ucanto.Signer} [options.signer] + * @param {Array} options.methods + * @param {{ default: Connection } & Record} options.connections + */ +function createProxyService(options) { + const handleInvocation = createProxyHandler(options) + // eslint-disable-next-line unicorn/no-array-reduce + const service = options.methods.reduce((obj, method) => { + obj[method] = handleInvocation + return obj + }, /** @type {Record} */ ({})) + return service +} + +/** + * @typedef UcantoHttpConnectionOptions + * @property {Ucanto.UCAN.DID} audience + * @property {typeof globalThis.fetch} options.fetch + * @property {URL} options.url + */ + +/** + * @param {UcantoHttpConnectionOptions} options + * @returns {Ucanto.ConnectionView} + */ +function createUcantoHttpConnection(options) { + return Client.connect({ + id: DID.parse(options.audience), + encoder: CAR, + decoder: CBOR, + channel: HTTP.open({ + fetch: options.fetch, + url: options.url, + }), + }) +} + +const uploadApiEnvironments = { + production: { + audience: /** @type {const} */ ('did:web:web3.storage'), + url: new URL('https://up.web3.storage'), + }, + staging: { + audience: /** @type {const} */ ('did:web:staging.web3.storage'), + url: new URL('https://staging.up.web3.storage'), + }, +} + +/** + * @typedef {keyof typeof uploadApiEnvironments} UploadApiEnvironmentName + * @typedef {typeof uploadApiEnvironments[UploadApiEnvironmentName]['audience']} UploadApiAudience + */ + +/** + * @param {object} options + * @param {typeof globalThis.fetch} [options.fetch] + * @param {object} options.uploadApi + * @param {URL} [options.uploadApi.production] + * @param {URL} [options.uploadApi.staging] + */ +function getDefaultConnections(options) { + const { fetch = globalThis.fetch, uploadApi } = options + return { + default: createUcantoHttpConnection({ + ...uploadApiEnvironments.production, + ...(uploadApi.production && { url: uploadApi.production }), + fetch, + }), + ...(uploadApi.staging && { + [uploadApiEnvironments.staging.audience]: createUcantoHttpConnection({ + ...uploadApiEnvironments.staging, + url: uploadApi.staging, + fetch, + }), + }), + } +} + +/** + * @template {Ucanto.ConnectionView} [Connection=Ucanto.ConnectionView] + * @param {object} options + * @param {Ucanto.Signer} [options.signer] + * @param {typeof globalThis.fetch} [options.fetch] + * @param {{ default: Connection, [K: Ucanto.UCAN.DID]: Connection }} [options.connections] + * @param {Record} [options.audienceToUrl] + * @param {object} options.uploadApi + * @param {URL} [options.uploadApi.production] + * @param {URL} [options.uploadApi.staging] + */ +export function createUploadProxy(options) { + return createProxyService({ + ...options, + connections: options.connections || getDefaultConnections(options), + methods: ['list', 'add', 'remove', 'upload'], + }) +} + +/** + * @template {Ucanto.ConnectionView} [Connection=Ucanto.ConnectionView] + * @param {object} options + * @param {Ucanto.Signer} [options.signer] + * @param {typeof globalThis.fetch} [options.fetch] + * @param {{ default: Connection, [K: Ucanto.UCAN.DID]: Connection }} [options.connections] + * @param {Record} [options.audienceToUrl] + * @param {object} options.uploadApi + * @param {URL} [options.uploadApi.production] + * @param {URL} [options.uploadApi.staging] + */ +export function createStoreProxy(options) { + return createProxyService({ + ...options, + connections: options.connections || getDefaultConnections(options), + methods: ['list', 'add', 'remove', 'store'], + }) +} diff --git a/packages/access-api/src/ucanto/proxy.js b/packages/access-api/src/ucanto/proxy.js new file mode 100644 index 000000000..c62c39ac8 --- /dev/null +++ b/packages/access-api/src/ucanto/proxy.js @@ -0,0 +1,45 @@ +// eslint-disable-next-line no-unused-vars +import * as Ucanto from '@ucanto/interface' +import * as Client from '@ucanto/client' + +/** + * @template {Ucanto.ConnectionView} [Connection=Ucanto.ConnectionView] + * @param {object} options + * @param {{ default: Connection, [K: Ucanto.UCAN.DID]: Connection }} options.connections + * @param {Ucanto.Signer} [options.signer] + */ +export function createProxyHandler(options) { + /** + * @template {import('@ucanto/interface').Capability} Capability + * @param {Ucanto.Invocation} invocationIn + * @param {Ucanto.InvocationContext} context + * @returns {Promise>} + */ + return async function handleInvocation(invocationIn, context) { + const { connections, signer } = options + const { audience, capabilities } = invocationIn + const connection = connections[audience.did()] ?? connections.default + // eslint-disable-next-line unicorn/prefer-logical-operator-over-ternary, no-unneeded-ternary + const proxyInvocationIssuer = signer + ? // this results in a forwarded invocation, but the upstream will reject the signature + // created using options.signer unless options.signer signs w/ the same private key as the original issuer + // and it'd be nice to not even have to pass around `options.signer` + signer + : // this works, but involves lying about the issuer type (it wants a Signer but context.id is only a Verifier) + // @todo obviate this type override via https://github.com/web3-storage/ucanto/issues/195 + /** @type {Ucanto.Signer} */ (context.id) + + const [result] = await Client.execute( + [ + Client.invoke({ + issuer: proxyInvocationIssuer, + capability: capabilities[0], + audience, + proofs: [invocationIn], + }), + ], + /** @type {Client.ConnectionView} */ (connection) + ) + return result + } +} diff --git a/packages/access-api/src/ucanto/types.ts b/packages/access-api/src/ucanto/types.ts index a793db752..112c82c9b 100644 --- a/packages/access-api/src/ucanto/types.ts +++ b/packages/access-api/src/ucanto/types.ts @@ -1,10 +1,41 @@ import { + InferInvokedCapability, + Match, + ParsedCapability, RequestDecoder, RequestEncoder, ResponseDecoder, ResponseEncoder, + ServiceMethod, + TheCapabilityParser, } from '@ucanto/interface' export interface ClientCodec extends RequestEncoder, ResponseDecoder {} export interface ServerCodec extends RequestDecoder, ResponseEncoder {} + +/** + * Select from T the property names whose values are of type V + */ +export type KeysWithValue = { + [K in keyof T]-?: T[K] extends V ? K : never +}[keyof T] + +/** + * Infer ucanto service object from a namespace object containing CapabilityParsers + * + * @example InferService + */ +export type InferService< + S extends Record, + CP extends TheCapabilityParser> = TheCapabilityParser< + Match + >, + Success = any +> = { + [K in KeysWithValue]: ServiceMethod< + InferInvokedCapability, + Success, + { error: true } + > +} diff --git a/packages/access-api/src/utils/context.js b/packages/access-api/src/utils/context.js index bd926a78c..a807bcefd 100644 --- a/packages/access-api/src/utils/context.js +++ b/packages/access-api/src/utils/context.js @@ -50,5 +50,13 @@ export function getContext(request, env, ctx) { validations: new Validations(config.VALIDATIONS), }, email: new Email({ token: config.POSTMARK_TOKEN }), + uploadApi: { + production: config.UPLOAD_API_URL + ? new URL(config.UPLOAD_API_URL) + : undefined, + staging: config.UPLOAD_API_URL_STAGING + ? new URL(config.UPLOAD_API_URL_STAGING) + : undefined, + }, } } diff --git a/packages/access-api/test/helpers/context.js b/packages/access-api/test/helpers/context.js index c8695cbac..bda9f86fb 100644 --- a/packages/access-api/test/helpers/context.js +++ b/packages/access-api/test/helpers/context.js @@ -2,10 +2,11 @@ import { Signer } from '@ucanto/principal/ed25519' import { connection } from '@web3-storage/access' import dotenv from 'dotenv' -import { Miniflare } from 'miniflare' +import { Miniflare, Log, LogLevel } from 'miniflare' import path from 'path' import { fileURLToPath } from 'url' import { migrate } from '../../scripts/migrate.js' +import { configureSigner } from '../../src/config.js' const __dirname = path.dirname(fileURLToPath(import.meta.url)) @@ -33,6 +34,8 @@ function createBindings(env) { SENTRY_DSN: env.SENTRY_DSN || '', LOGTAIL_TOKEN: env.LOGTAIL_TOKEN || '', W3ACCESS_METRICS: createAnalyticsEngine(), + UPLOAD_API_URL: env.UPLOAD_API_URL || '', + UPLOAD_API_URL_STAGING: env.UPLOAD_API_URL_STAGING || '', } } @@ -53,6 +56,7 @@ export async function context(options) { const bindings = createBindings({ ...environment, }) + const servicePrincipal = configureSigner(bindings) const mf = new Miniflare({ packagePath: true, wranglerConfigPath: true, @@ -61,6 +65,7 @@ export async function context(options) { bindings, d1Persist: undefined, buildCommand: undefined, + log: new Log(LogLevel.ERROR), }) const binds = await mf.getBindings() @@ -70,7 +75,7 @@ export async function context(options) { return { mf, conn: connection({ - principal, + principal: servicePrincipal, // @ts-ignore fetch: mf.dispatchFetch.bind(mf), url: new URL('http://localhost:8787'), diff --git a/packages/access-api/test/helpers/upload-api.js b/packages/access-api/test/helpers/upload-api.js new file mode 100644 index 000000000..5079d5150 --- /dev/null +++ b/packages/access-api/test/helpers/upload-api.js @@ -0,0 +1,69 @@ +// eslint-disable-next-line no-unused-vars +import * as Ucanto from '@ucanto/interface' +import * as Server from '@ucanto/server' +import * as CAR from '@ucanto/transport/car' +import * as CBOR from '@ucanto/transport/cbor' + +/** + * @param {object} options + * @param {Ucanto.Verifier} options.id + * @returns {Ucanto.ServerView<{ + * store: import('../../src/service/upload-api-proxy.js').StoreServiceInferred + * upload: import('../../src/service/upload-api-proxy.js').UploadServiceInferred + * }>} + */ +export function createMockUploadApiServer({ id }) { + // eslint-disable-next-line unicorn/consistent-function-scoping + async function serviceMethod() { + return { mockUploadAPi: true } + } + const server = Server.create({ + id, + decoder: CAR, + encoder: CBOR, + service: { + store: { + list: serviceMethod, + add: serviceMethod, + remove: serviceMethod, + }, + upload: { + list: serviceMethod, + add: serviceMethod, + remove: serviceMethod, + }, + }, + }) + return server +} + +/** + * @template {Record} T + * @param {Ucanto.ServerView} ucantoServer + */ +export function ucantoServerNodeListener(ucantoServer) { + /** @type {import('node:http').RequestListener} */ + return async (request, response) => { + const chunks = [] + for await (const chunk of request) { + chunks.push(chunk) + } + const { headers, body } = await ucantoServer.request({ + headers: /** @type {Record} */ ({ ...request.headers }), + body: Buffer.concat(chunks), + }) + response.writeHead(200, headers) + response.write(body) + response.end() + } +} + +/** + * @param {import('node:net').AddressInfo|string|null} address - this is type from server.address() + * @returns {URL} + */ +export function serverLocalUrl(address) { + if (!address || typeof address !== 'object') + throw new Error(`cant determine local url from address`) + return new URL(`http://localhost:${address.port}`) +} diff --git a/packages/access-api/test/helpers/utils.js b/packages/access-api/test/helpers/utils.js index 1c2f62054..e8bee3b09 100644 --- a/packages/access-api/test/helpers/utils.js +++ b/packages/access-api/test/helpers/utils.js @@ -48,7 +48,7 @@ export async function createSpace(issuer, service, conn, email) { }) .execute(conn) if (!claim || claim.error) { - throw new Error('failed to create space') + throw new Error('failed to create space', { cause: claim }) } const delegation = stringToDelegation(claim) @@ -98,3 +98,15 @@ export async function createSpace(issuer, service, conn, email) { delegation: spaceDelegation, } } + +/** + * Return whether the provided stack trace string appears to be generated + * by a deployed upload-api. + * Heuristics: + * * stack trace files paths will start with `file:///var/task/upload-api` because of how the lambda environment is working + * + * @param {string} stack + */ +export function isUploadApiStack(stack) { + return stack.includes('file:///var/task/upload-api') +} diff --git a/packages/access-api/test/store-list.js b/packages/access-api/test/store-list.js new file mode 100644 index 000000000..327ecda79 --- /dev/null +++ b/packages/access-api/test/store-list.js @@ -0,0 +1,161 @@ +import assert from 'assert' +import { context } from './helpers/context.js' +import { createSpace } from './helpers/utils.js' +import * as Store from '@web3-storage/capabilities/store' +import * as ed25519 from '@ucanto/principal/ed25519' +import * as ucanto from '@ucanto/core' +import * as nodeHttp from 'node:http' +import { + createMockUploadApiServer, + serverLocalUrl, + ucantoServerNodeListener, +} from './helpers/upload-api.js' + +describe('proxy store/list invocations to upload-api', function () { + for (const web3storageDid of /** @type {const} */ ([ + 'did:web:web3.storage', + 'did:web:staging.web3.storage', + ])) { + it(`forwards invocations with aud=${web3storageDid}`, async function () { + const mockUpstream = createMockUploadApiServer({ + // eslint-disable-next-line unicorn/no-await-expression-member + id: (await ed25519.generate()).withDID( + ucanto.DID.parse(web3storageDid).did() + ), + }) + const mockUpstreamHttp = nodeHttp.createServer( + ucantoServerNodeListener(mockUpstream) + ) + await new Promise((resolve, reject) => + // eslint-disable-next-line unicorn/no-useless-undefined + mockUpstreamHttp.listen(0, () => resolve(undefined)) + ) + // now mockUpstreamHttp is listening on a port. If something goes wrong, we will close the server to have it stop litening + after(() => { + mockUpstreamHttp.close() + }) + // @ts-ignore (in practice address() is always an object, and will throw if not) + const mockUpstreamUrl = serverLocalUrl(mockUpstreamHttp.address()) + // if this is set, it's to inject in the actual private key used by web3StorageDid. + // and if it's present, the assertions will expect no error from the proxy or upstream + const privateKeyFromEnv = process.env.WEB3_STORAGE_PRIVATE_KEY + const { + issuer, + service: serviceSigner, + conn, + } = await context({ + environment: { + ...process.env, + // this emulates the configuration for deployed environments, + // which will allow the access-api ucanto server to accept + // invocations where aud=web3storageDid + DID: web3storageDid, + PRIVATE_KEY: privateKeyFromEnv ?? process.env.PRIVATE_KEY, + UPLOAD_API_URL: mockUpstreamUrl.toString(), + UPLOAD_API_URL_STAGING: mockUpstreamUrl.toString(), + }, + }) + const service = serviceSigner.withDID(web3storageDid) + const spaceCreation = await createSpace( + issuer, + service, + conn, + 'space-info@dag.house' + ) + const listInvocation = Store.list.invoke({ + issuer, + audience: service, + proofs: [spaceCreation.delegation], + with: spaceCreation.space.did(), + nb: {}, + }) + const result = await listInvocation.execute( + // cast to `any` only because this `conn` uses Service type from access-client. + /** @type {import('@ucanto/interface').ConnectionView} */ (conn) + ) + assert.ok(!result?.error, 'should not be an error') + }) + } + + it('errors when a bad delegation is given as proof', async () => { + const mockUpstream = createMockUploadApiServer({ + // eslint-disable-next-line unicorn/no-await-expression-member + id: await ed25519.generate(), + }) + const mockUpstreamHttp = nodeHttp.createServer( + ucantoServerNodeListener(mockUpstream) + ) + await new Promise((resolve, reject) => + // eslint-disable-next-line unicorn/no-useless-undefined + mockUpstreamHttp.listen(0, () => resolve(undefined)) + ) + // now mockUpstreamHttp is listening on a port. If something goes wrong, we will close the server to have it stop litening + after(() => { + mockUpstreamHttp.close() + }) + const mockUpstreamUrl = serverLocalUrl(mockUpstreamHttp.address()) + const [alice, bob, mallory] = await Promise.all( + Array.from({ length: 3 }).map(() => ed25519.Signer.generate()) + ) + const { service: serviceSigner, conn } = await context({ + environment: { + ...process.env, + UPLOAD_API_URL: mockUpstreamUrl.toString(), + }, + }) + const service = process.env.DID + ? serviceSigner.withDID(ucanto.DID.parse(process.env.DID).did()) + : serviceSigner + const spaceCreation = await createSpace( + alice, + service, + conn, + 'space-info@dag.house' + ) + /** + * @type {Array<{ + * invocation: import('@ucanto/interface').IssuedInvocationView + * resultAssertion: (r: import('@ucanto/interface').Result) => void + * }>} */ + const cases = [ + { + invocation: Store.list.invoke({ + issuer: mallory, + audience: service, + proofs: [ + // this shouldn't work because the audience is bob, + // but its a proof an an invocation issued by mallory + await Store.list.delegate({ + issuer: alice, + audience: bob, + with: spaceCreation.space.did(), + }), + ], + with: spaceCreation.space.did(), + nb: {}, + }), + resultAssertion(result) { + assert.ok(result.error, 'result is an error') + assert.ok('name' in result, 'result has a name') + assert.equal(result.name, 'InvalidAudience') + assert.ok( + 'stack' in result && typeof result.stack === 'string', + 'result has stack string' + ) + }, + }, + ] + for (const { invocation, resultAssertion } of cases) { + const result = await invocation.execute( + /** @type {import('@ucanto/interface').ConnectionView} */ (conn) + ) + try { + resultAssertion(result) + } catch (error) { + // eslint-disable-next-line no-console + console.warn('result failed assertion', result) + throw error + } + } + }) +}) diff --git a/packages/access-api/test/ucanto-proxy.test.js b/packages/access-api/test/ucanto-proxy.test.js new file mode 100644 index 000000000..3786304d3 --- /dev/null +++ b/packages/access-api/test/ucanto-proxy.test.js @@ -0,0 +1,92 @@ +import assert from 'assert' +import * as Server from '@ucanto/server' +import * as Client from '@ucanto/client' +import * as CAR from '@ucanto/transport/car' +import * as CBOR from '@ucanto/transport/cbor' +import * as ed25519 from '@ucanto/principal/ed25519' +import { createProxyHandler } from '../src/ucanto/proxy.js' +// eslint-disable-next-line no-unused-vars +import * as Ucanto from '@ucanto/interface' + +describe('ucanto-proxy', () => { + it('proxies invocations to another ucanto server', async () => { + // make a ucanto server that is the upstream + const upstreamPrincipal = await ed25519.generate() + /** @type {Array<[Ucanto.Invocation, unknown]>} */ + const testSucceedInvocations = [] + const testSucceedResponseFixture = { success: true } + const upstream = Server.create({ + id: upstreamPrincipal, + decoder: CAR, + encoder: CBOR, + service: { + test: { + /** + * @param {Ucanto.Invocation} invocation + * @param {Ucanto.InvocationContext} context + */ + succeed(invocation, context) { + testSucceedInvocations.push([invocation, context]) + return testSucceedResponseFixture + }, + }, + }, + }) + // make a ucanto server that is the proxy + const proxyPrincipal = upstreamPrincipal + // const proxyPrincipal = await ed25519.generate() + const proxy = Server.create({ + id: proxyPrincipal, + decoder: CAR, + encoder: CBOR, + service: { + test: { + succeed: createProxyHandler({ + connections: { + default: Client.connect({ + id: upstreamPrincipal, + encoder: CAR, + decoder: CBOR, + channel: upstream, + }), + }, + }), + }, + }, + }) + // create connection to proxy + const proxyConnection = Client.connect({ + id: proxyPrincipal, + encoder: CAR, + decoder: CBOR, + channel: proxy, + }) + // invoke proxy + const invoker = await ed25519.Signer.generate() + const invocationCapability = { + can: /** @type {const} */ ('test/succeed'), + with: /** @type {const} */ ('did:web:dag.house'), + nb: { foo: 'bar' }, + } + const [result] = await proxyConnection.execute( + Client.invoke({ + issuer: invoker, + audience: proxyPrincipal, + capability: invocationCapability, + }) + ) + assert.equal(result?.error, undefined, 'result has no error') + assert.equal(testSucceedInvocations.length, 1, 'upstream was invoked once') + assert.deepEqual( + testSucceedInvocations[0][0].capabilities[0], + invocationCapability, + 'upstream received same capability as was sent to proxy' + ) + assert.equal(result?.error, undefined, 'result has no error') + assert.deepEqual( + result, + testSucceedResponseFixture, + 'proxy result is same returned from upstream' + ) + }) +}) diff --git a/packages/access-api/test/upload-api-proxy.test.js b/packages/access-api/test/upload-api-proxy.test.js new file mode 100644 index 000000000..6074e4fdc --- /dev/null +++ b/packages/access-api/test/upload-api-proxy.test.js @@ -0,0 +1,115 @@ +import assert, { AssertionError } from 'assert' +import * as Store from '@web3-storage/capabilities/store' +import * as Upload from '@web3-storage/capabilities/upload' +import { context } from './helpers/context.js' +import * as ucanto from '@ucanto/core' +// eslint-disable-next-line no-unused-vars +import * as Ucanto from '@ucanto/interface' +import { isUploadApiStack } from './helpers/utils.js' +import * as ed25519 from '@ucanto/principal/ed25519' +import { + createMockUploadApiServer, + serverLocalUrl, + ucantoServerNodeListener, +} from './helpers/upload-api.js' +import * as nodeHttp from 'node:http' + +describe('Store.all', () => { + for (const can of parserAbilities(Store.all)) { + it(`proxies ${can} to upload-api`, testCanProxyInvocation(can)) + } +}) + +describe('Upload.all', () => { + for (const can of parserAbilities(Upload.all)) { + it(`proxies ${can} to upload-api`, testCanProxyInvocation(can)) + } +}) + +/** + * @param {Ucanto.Ability} can + */ +function testCanProxyInvocation(can) { + return async () => { + const upstreamPrincipal = await ed25519.generate() + const mockUpstream = createMockUploadApiServer({ + id: upstreamPrincipal, + }) + const mockUpstreamHttp = nodeHttp.createServer( + ucantoServerNodeListener(mockUpstream) + ) + await new Promise((resolve, reject) => + // eslint-disable-next-line unicorn/no-useless-undefined + mockUpstreamHttp.listen(0, () => resolve(undefined)) + ) + // now mockUpstreamHttp is listening on a port. If something goes wrong, we will close the server to have it stop litening + after(() => { + mockUpstreamHttp.close() + }) + const mockUpstreamUrl = serverLocalUrl(mockUpstreamHttp.address()) + const { issuer, conn } = await context({ + environment: { + ...process.env, + UPLOAD_API_URL: mockUpstreamUrl.toString(), + DID: upstreamPrincipal.did(), + }, + }) + /** @type {Ucanto.ConnectionView} */ + const connection = conn + const invocation = ucanto.invoke({ + issuer, + audience: upstreamPrincipal, + capability: { + can, + with: `https://dag.house`, + nb: {}, + }, + }) + const [result] = await connection.execute(invocation) + try { + if ('error' in result) { + assert.ok( + 'stack' in result && typeof result.stack === 'string', + 'error.stack is a string' + ) + assert.ok( + isUploadApiStack(result.stack), + 'error.stack appears to be from upload-api' + ) + } + } catch (error) { + if (error instanceof AssertionError) { + // eslint-disable-next-line no-console + console.warn(`unexpected result`, result) + } + throw error + } + } +} + +/** + * @param {Ucanto.CapabilityParser} cap + * @returns {Set} + */ +function parserAbilities(cap) { + const cans = new Set( + cap + .toString() + .split('|') + .map((s) => /** @type {unknown} */ (JSON.parse(s))) + .map((c) => { + assert.ok(c && typeof c === 'object', 'cap is an object') + assert.ok('can' in c && typeof c.can === 'string', 'c.can is a string') + const [ns, firstSegment, ...restSegments] = c.can.split('/') + assert.equal( + restSegments.length, + 0, + 'only two /-delimited segments in can' + ) + /** @type {Ucanto.Ability} */ + const can = `${ns}/${firstSegment}` + return can + }) + ) + return cans +} diff --git a/packages/access-client/src/agent.js b/packages/access-client/src/agent.js index 68d4da3c0..a80128b15 100644 --- a/packages/access-client/src/agent.js +++ b/packages/access-client/src/agent.js @@ -1,5 +1,4 @@ /* eslint-disable max-depth */ -import * as DID from '@ipld/dag-ucan/did' import * as Client from '@ucanto/client' // @ts-ignore // eslint-disable-next-line no-unused-vars @@ -15,7 +14,7 @@ import { stringToDelegation } from './encoding.js' import { Websocket, AbortError } from './utils/ws.js' import { Signer } from '@ucanto/principal/ed25519' import { Verifier } from '@ucanto/principal' -import { invoke, delegate } from '@ucanto/core' +import { invoke, delegate, DID } from '@ucanto/core' import { isExpired, isTooEarly, @@ -38,13 +37,14 @@ const PRINCIPAL = DID.parse('did:web:web3.storage') * import { connection } from '@web3-storage/access/agent' * ``` * + * @template {import('./types').Service} Service * @template {Ucanto.DID} T - DID method * @param {object} [options] * @param {Ucanto.Principal} [options.principal] - w3access API Principal * @param {URL} [options.url] - w3access API URL - * @param {Ucanto.Transport.Channel} [options.channel] - Ucanto channel to use + * @param {Ucanto.Transport.Channel} [options.channel] - Ucanto channel to use * @param {typeof fetch} [options.fetch] - Fetch implementation to use - * @returns {Ucanto.ConnectionView} + * @returns {Ucanto.ConnectionView} */ export function connection(options = {}) { return Client.connect({ diff --git a/packages/capabilities/src/upload.js b/packages/capabilities/src/upload.js index d2f72482f..a15f3152c 100644 --- a/packages/capabilities/src/upload.js +++ b/packages/capabilities/src/upload.js @@ -159,6 +159,8 @@ export const list = base.derive({ derives: equalWith, }) +export const all = add.or(remove).or(list) + // ⚠️ We export imports here so they are not omited in generated typedefs // @see https://github.com/microsoft/TypeScript/issues/51548 export { Link, Schema } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e2e5cdc3d..5f98345e5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -35,6 +35,7 @@ importers: '@types/mocha': ^10.0.1 '@types/node': ^18.11.14 '@types/qrcode': ^1.5.0 + '@ucanto/client': ^4.0.3 '@ucanto/core': ^4.0.3 '@ucanto/interface': ^4.0.3 '@ucanto/principal': ^4.0.3 @@ -92,6 +93,7 @@ importers: '@types/mocha': 10.0.1 '@types/node': 18.11.14 '@types/qrcode': 1.5.0 + '@ucanto/client': 4.0.3 better-sqlite3: 8.0.1 buffer: 6.0.3 dotenv: 16.0.3 @@ -1566,7 +1568,6 @@ packages: dependencies: '@ucanto/interface': 4.0.3 multiformats: 10.0.2 - dev: false /@ucanto/core/4.0.3: resolution: {integrity: sha512-5Uc6vdmKZzlA9NFvAN6BC1Tp1Npz0sepp2up1ZWU4BqArQ0w4U0YMtL9KPdBnL3TDAyDNgS9PgK+vHpjcSoeiQ==}