Skip to content

Commit

Permalink
feat: access-api forwards store/ and upload/ invocations to upload-api (
Browse files Browse the repository at this point in the history
#334)

Motivation:
* #331
  • Loading branch information
gobengo authored Jan 14, 2023
1 parent 175bce5 commit 6be7217
Show file tree
Hide file tree
Showing 17 changed files with 722 additions and 12 deletions.
1 change: 1 addition & 0 deletions packages/access-api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
7 changes: 7 additions & 0 deletions packages/access-api/src/bindings.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -51,6 +54,10 @@ export interface RouteContext {
spaces: Spaces
validations: Validations
}
uploadApi: {
production?: URL
staging?: URL
}
}

export type Handler = _Handler<RouteContext>
Expand Down
22 changes: 21 additions & 1 deletion packages/access-api/src/config.js
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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,
}
}

Expand Down Expand Up @@ -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<ConfigDID,Alg>} verifier
* @returns {import('@ucanto/interface').Verifier<ConfigDID,Alg>}
*/
export function configureVerifier(config, verifier) {
if (config.DID) {
return verifier.withDID(DID.parse(config.DID).did())
}
return verifier
}
14 changes: 11 additions & 3 deletions packages/access-api/src/service/index.js
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -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),
Expand Down Expand Up @@ -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: {
Expand Down
133 changes: 133 additions & 0 deletions packages/access-api/src/service/upload-api-proxy.js
Original file line number Diff line number Diff line change
@@ -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<Omit<import('@web3-storage/capabilities/store'), 'store'>>} StoreServiceInferred
* @typedef {import('../ucanto/types.js').InferService<Omit<import('@web3-storage/capabilities/upload'), 'upload'>>} UploadServiceInferred
*/

/**
* @template {string|number|symbol} M
* @template {Ucanto.ConnectionView<any>} [Connection=Ucanto.ConnectionView<any>]
* @param {object} options
* @param {Ucanto.Signer} [options.signer]
* @param {Array<M>} options.methods
* @param {{ default: Connection } & Record<Ucanto.UCAN.DID, Connection>} 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<M, typeof handleInvocation>} */ ({}))
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<any>}
*/
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<any>} [Connection=Ucanto.ConnectionView<any>]
* @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<Ucanto.UCAN.DID, URL>} [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<any>} [Connection=Ucanto.ConnectionView<any>]
* @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<Ucanto.UCAN.DID, URL>} [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'],
})
}
45 changes: 45 additions & 0 deletions packages/access-api/src/ucanto/proxy.js
Original file line number Diff line number Diff line change
@@ -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<any>} [Connection=Ucanto.ConnectionView<any>]
* @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<Capability>} invocationIn
* @param {Ucanto.InvocationContext} context
* @returns {Promise<Ucanto.Result<any, { error: true }>>}
*/
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<any>} */ (connection)
)
return result
}
}
31 changes: 31 additions & 0 deletions packages/access-api/src/ucanto/types.ts
Original file line number Diff line number Diff line change
@@ -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<T, V> = {
[K in keyof T]-?: T[K] extends V ? K : never
}[keyof T]

/**
* Infer ucanto service object from a namespace object containing CapabilityParsers
*
* @example InferService<import('@web3-storage/capabilities/upload')>
*/
export type InferService<
S extends Record<string, unknown>,
CP extends TheCapabilityParser<Match<ParsedCapability>> = TheCapabilityParser<
Match<ParsedCapability>
>,
Success = any
> = {
[K in KeysWithValue<S, CP>]: ServiceMethod<
InferInvokedCapability<S[K] extends CP ? S[K] : never>,
Success,
{ error: true }
>
}
8 changes: 8 additions & 0 deletions packages/access-api/src/utils/context.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
},
}
}
9 changes: 7 additions & 2 deletions packages/access-api/test/helpers/context.js
Original file line number Diff line number Diff line change
Expand Up @@ -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))

Expand Down Expand Up @@ -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 || '',
}
}

Expand All @@ -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,
Expand All @@ -61,6 +65,7 @@ export async function context(options) {
bindings,
d1Persist: undefined,
buildCommand: undefined,
log: new Log(LogLevel.ERROR),
})

const binds = await mf.getBindings()
Expand All @@ -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'),
Expand Down
Loading

0 comments on commit 6be7217

Please sign in to comment.