Skip to content

Commit

Permalink
feat: HTTP to UCAN bridge (#325)
Browse files Browse the repository at this point in the history
To support users in languages that do not have existing UCAN invocation
implementations, we are going to launch a bridge that allows them to
make simple HTTP requests with JSON bodies that we transform into proper
UCAN invocations.

This follows the specification here:
storacha/specs#112

Values for authorization headers can be generated using the `bridge
generate-tokens` w3cli command proposed here:

storacha/w3cli#175

- [x] factor core bridge logic out to a separate library (filed as
#338)
- [x] factor HTTP input wrangling out to a separate function
- [x] rename `UPLOAD_API_DID` and `ACCESS_SERVICE_URL` environment
variables to `W3UP_SERVICE_DID` and `W3UP_SERVICE_URL` (filed as
#337)
- [x] add tests
- [x] expand and formalize bridge specification, move it to the specs
repo (done - storacha/specs#112)
- [x] document response format
  • Loading branch information
travis authored Mar 5, 2024
1 parent 3ad4717 commit f5a092a
Show file tree
Hide file tree
Showing 10 changed files with 886 additions and 133 deletions.
322 changes: 206 additions & 116 deletions package-lock.json

Large diffs are not rendered by default.

9 changes: 5 additions & 4 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -33,10 +33,10 @@
"@web-std/fetch": "^4.1.0",
"@web3-storage/data-segment": "5.0.0",
"@web3-storage/filecoin-client": "3.0.1",
"@web3-storage/w3up-client": "^9.2.2",
"@web3-storage/w3up-client": "^12.4.1",
"ava": "^4.3.3",
"constructs": "10.3.0",
"chalk": "4.1.2",
"constructs": "10.3.0",
"dotenv": "^16.0.3",
"git-rev-sync": "^3.0.2",
"hd-scripts": "^3.0.2",
Expand All @@ -48,8 +48,9 @@
},
"dependencies": {
"@ipld/dag-json": "^10.1.5",
"sst": "^2.40.3",
"aws-cdk-lib": "2.124.0"
"@web-std/stream": "^1.0.3",
"aws-cdk-lib": "2.124.0",
"sst": "^2.40.3"
},
"simple-git-hooks": {
"pre-commit": "npx lint-staged"
Expand Down
16 changes: 16 additions & 0 deletions stacks/config.js
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,22 @@ export function setupSentry (app, stack) {
})
}

/**
* @param {import('sst/constructs').Stack} stack
*/
export function getServiceURL (stack) {
// in production we use the top level subdomain
if (stack.stage === 'prod') {
return 'https://up.web3.storage'
// in staging and PR environments we use a sub-subdomain
} else if (stack.stage === 'staging' || stack.stage.startsWith('pr')) {
return `https://${stack.stage}.up.web3.storage`
// everywhere else we use something more estoteric - usually an AWS Lambda URL
} else {
return process.env.ACCESS_SERVICE_URL
}
}

/**
* Get Env validating it is set.
*/
Expand Down
5 changes: 3 additions & 2 deletions stacks/upload-api-stack.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import { CarparkStack } from './carpark-stack.js'
import { FilecoinStack } from './filecoin-stack.js'
import { UcanInvocationStack } from './ucan-invocation-stack.js'

import { getCustomDomain, getApiPackageJson, getGitInfo, setupSentry, getEnv, getEventSourceConfig } from './config.js'
import { getCustomDomain, getApiPackageJson, getGitInfo, setupSentry, getEnv, getEventSourceConfig, getServiceURL } from './config.js'

/**
* @param {import('sst/constructs').StackContext} properties
Expand Down Expand Up @@ -91,7 +91,7 @@ export function UploadApiStack({ stack, app }) {
VERSION: pkg.version,
COMMIT: git.commmit,
STAGE: stack.stage,
ACCESS_SERVICE_URL: process.env.ACCESS_SERVICE_URL ?? '',
ACCESS_SERVICE_URL: getServiceURL(stack) ?? '',
POSTMARK_TOKEN: process.env.POSTMARK_TOKEN ?? '',
PROVIDERS: process.env.PROVIDERS ?? '',
R2_ACCESS_KEY_ID: process.env.R2_ACCESS_KEY_ID ?? '',
Expand All @@ -118,6 +118,7 @@ export function UploadApiStack({ stack, app }) {
routes: {
'POST /': 'upload-api/functions/ucan-invocation-router.handler',
'POST /ucan': 'upload-api/functions/ucan.handler',
'POST /bridge': 'upload-api/functions/bridge.handler',
'GET /': 'upload-api/functions/get.home',
'GET /validate-email': 'upload-api/functions/validate-email.preValidateEmail',
'POST /validate-email': 'upload-api/functions/validate-email.validateEmail',
Expand Down
176 changes: 176 additions & 0 deletions test/bridge.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,176 @@
import { fetch } from '@web-std/fetch'
import { base64url } from 'multiformats/bases/base64'
import * as Signature from '@ipld/dag-ucan/signature'
import { ed25519 } from '@ucanto/principal'
import { CBOR } from '@ucanto/core'
import * as dagJSON from '@ipld/dag-json'
import pWaitFor from 'p-wait-for'
import { test } from './helpers/context.js'
import {
getApiEndpoint,
getDynamoDb
} from './helpers/deployment.js'
import { randomFile } from './helpers/random.js'
import { createMailSlurpInbox, setupNewClient } from './helpers/up-client.js'

test.before(t => {
t.context = {
apiEndpoint: getApiEndpoint(),
metricsDynamo: getDynamoDb('admin-metrics'),
spaceMetricsDynamo: getDynamoDb('space-metrics'),
rateLimitsDynamo: getDynamoDb('rate-limit')
}
})

/**
*
* @param {string} apiEndpoint
* @returns
*/
async function getServicePublicKey(apiEndpoint) {
const serviceInfoResponse = await fetch(`${apiEndpoint}/version`)
const { publicKey } = await serviceInfoResponse.json()
return publicKey
}

/**
*
* @param {import('@web3-storage/w3up-client').Client} client
* @param {[import('@ucanto/interface').Capability, ...import('@ucanto/interface').Capability[]]} capabilities
* @param {number} expiration
* @param {string | undefined} password
* @returns
*/
async function generateAuthHeaders(client, capabilities, expiration, password = 'i am the very model of a modern major general') {
const coupon = await client.coupon.issue({
capabilities,
expiration,
password
})

const { ok: bytes, error } = await coupon.archive()
if (!bytes) {
console.error(error)
throw new Error(error.message)
}
return {
'X-Auth-Secret': base64url.encode(new TextEncoder().encode(password)),
'Authorization': base64url.encode(bytes)
}
}

/**
*
* @param {import('./helpers/context.js').Context} context
* @param {import('@web3-storage/w3up-client').Client} client
* @param {[import('@ucanto/interface').Capability, ...import('@ucanto/interface').Capability[]]} capabilities
* @param {number} expiration
* @param {any} requestBody
*/
async function makeBridgeRequest(context, client, capabilities, expiration, requestBody) {
return fetch(`${context.apiEndpoint}/bridge`, {
method: 'POST',
headers: {
'content-type': 'application/json',
...await generateAuthHeaders(
client,
capabilities,
expiration
)
},
body: dagJSON.stringify(requestBody),
})
}

test('the bridge can make various types of requests', async t => {
const inbox = await createMailSlurpInbox()
const client = await setupNewClient(t.context.apiEndpoint, { inbox })
const spaceDID = client.currentSpace()?.did()
if (!spaceDID) {
t.fail('client was set up but does not have a currentSpace - this is weird!')
return
}

const response = await makeBridgeRequest(
t.context, client,
[{ can: 'upload/list', with: spaceDID }],
Date.now() + (1000 * 60),
{
tasks: [
['upload/list', spaceDID, {}]
]
}
)

t.deepEqual(response.status, 200)
const receipts = dagJSON.parse(await response.text())
t.deepEqual(receipts.length, 1)
t.assert(receipts[0].p.out.ok)
const result = receipts[0].p.out.ok
t.deepEqual(result.results, [])
t.deepEqual(result.size, 0)


// verify that uploading a file changes the upload/list response
// upload a file and wait for it to show up
const file = await randomFile(42)
const fileLink = await client.uploadFile(file)
let secondReceipts
await pWaitFor(async () => {
const secondResponse = await makeBridgeRequest(
t.context, client,
[{ can: 'upload/list', with: spaceDID }],
Date.now() + (1000 * 60),
{
tasks: [
['upload/list', spaceDID, {}]
]
}
)
const response = await secondResponse.text()
secondReceipts = dagJSON.parse(response)
const result = secondReceipts[0].p.out.ok.results[0]
return Boolean(result && result.root.equals(fileLink))
}, {
interval: 100,
})

t.assert(secondReceipts[0].p.out.ok)
t.deepEqual(secondReceipts[0].p.out.ok.results.length, 1)
// assert that the first item in the list is the item we just uploaded
t.deepEqual(secondReceipts[0].p.out.ok.results[0].root.toString(), fileLink.toString())


// verify expired requests fail
const expiredResponse = await makeBridgeRequest(
t.context, client,
[{ can: 'upload/list', with: spaceDID }],
0,
{
tasks: [
['upload/list', spaceDID, {}]
]
}
)
const expiredReceipts = dagJSON.parse(await expiredResponse.text())
t.assert(expiredReceipts[0].p.out.error)


// ensure response is verifiable
const payload = receipts[0].p
const signature = Signature.view(receipts[0].s)

// we need to get the service key out of band because the receipt
// has a did:web as it's `iss` field but local development environments
// use the `did:web:staging` DID backed by different keys and therefore aren't
// resolvable using the normal `did:web` resolution algorithm
const publicKey = await getServicePublicKey(t.context.apiEndpoint)
const verifier = ed25519.Verifier.parse(publicKey)
const verification = await signature.verify(verifier, CBOR.encode(payload))
if (verification.error) {
t.fail(verification.error.message)
console.error(verification.error)
}
t.assert(verification.ok)
})

15 changes: 7 additions & 8 deletions test/helpers/up-client.js
Original file line number Diff line number Diff line change
Expand Up @@ -19,11 +19,10 @@ function getAuthLinkFromEmail (email, accessServiceUrl) {
// forgive me for I have s̵i̵n̴n̴e̵d̴ ̸a̸n̵d̷ ̷p̶a̵r̵s̵e̸d̷ Ȟ̷̞T̷̢̈́M̸̼̿L̴̎ͅ ̵̗̍ẅ̵̝́ï̸ͅt̴̬̅ḫ̸̔ ̵͚̔ŗ̵͊e̸͍͐g̶̜͒ė̷͖x̴̱̌
// TODO we should update the email and add an ID to this element to make this more robust - tracked in https://github.com/web3-storage/w3infra/issues/208
const link = email.match(/<a href="([^"]*)".*Verify email address/)[1]
if (!link.includes(process.env.ACCESS_SERVICE_URL)){
throw new Error('Could not find expected access service verification URL - does the value of ACCESS_SERVICE_URL in your local environment match the deployment you are testing?')
if (!link){
throw new Error(`Could not find email verification link in ${email}`)
}
// test auth services always link to the staging URL but we want to hit the service we're testing
return link.replace(process.env.ACCESS_SERVICE_URL, accessServiceUrl)
return link
}

export async function createMailSlurpInbox() {
Expand Down Expand Up @@ -54,16 +53,16 @@ export async function setupNewClient (uploadServiceUrl, options = {}) {
const client = await createNewClient(uploadServiceUrl)

const timeoutMs = process.env.MAILSLURP_TIMEOUT ? parseInt(process.env.MAILSLURP_TIMEOUT) : 60_000
const authorizePromise = client.authorize(email)
const authorizePromise = client.login(email)
// click link in email
const latestEmail = await mailslurp.waitForLatestEmail(inboxId, timeoutMs)
const authLink = getAuthLinkFromEmail(latestEmail.body, uploadServiceUrl)
await fetch(authLink, { method: 'POST' })
await authorizePromise
const account = await authorizePromise
if (!client.currentSpace()) {
const space = await client.createSpace("test space")
await client.setCurrentSpace(space.did())
await client.registerSpace(email)
await account.provision(space.did())
await space.save()
}

return client
Expand Down
8 changes: 5 additions & 3 deletions test/integration.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import pWaitFor from 'p-wait-for'
import { HeadObjectCommand } from '@aws-sdk/client-s3'
import { PutItemCommand } from '@aws-sdk/client-dynamodb'
import { marshall } from '@aws-sdk/util-dynamodb'
import * as DidMailto from '@web3-storage/did-mailto'

import { METRICS_NAMES, SPACE_METRICS_NAMES } from '../upload-api/constants.js'

Expand Down Expand Up @@ -104,7 +105,7 @@ test('authorizations can be blocked by email or domain', async t => {

// it would be nice to use t.throwsAsync here, but that doesn't work with errors that aren't exceptions: https://github.com/avajs/ava/issues/2517
try {
await client.authorize('[email protected]')
await client.login('[email protected]')
t.fail('authorize should fail with a blocked domain')
} catch (e) {
t.is(e.name, 'AccountBlocked')
Expand All @@ -121,11 +122,12 @@ test('w3infra integration flow', async t => {
if (!spaceDid) {
throw new Error('Testing space DID must be set')
}
const account = client.accounts()[DidMailto.fromEmail(inbox.email)]

// it should be possible to create more than one space
const space = await client.createSpace("2nd space")
await client.setCurrentSpace(space.did())
await client.registerSpace(inbox.email)
await account.provision(space.did())
await space.save()

// Get space metrics before upload
const spaceBeforeUploadAddMetrics = await getSpaceMetrics(t, spaceDid, SPACE_METRICS_NAMES.UPLOAD_ADD_TOTAL)
Expand Down
56 changes: 56 additions & 0 deletions upload-api/bridge/streams.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import { ReadableStream } from "@web-std/stream"

/**
* Stream utilities adapted from https://stackoverflow.com/questions/40385133/retrieve-data-from-a-readablestream-object
*/

/**
*
* @param {Uint8Array[]} chunks
* @returns {Uint8Array}
*/
function concatArrayBuffers(chunks) {
const result = new Uint8Array(chunks.reduce((a, c) => a + c.length, 0))
let offset = 0
for (const chunk of chunks) {
result.set(chunk, offset)
offset += chunk.length
}
return result
}

/**
*
* @param {ReadableStream<Uint8Array>} stream
* @returns {Promise<Uint8Array>}
*/
export async function streamToArrayBuffer(stream) {
const chunks = []
const reader = stream.getReader()
while (true) {
const { done, value } = await reader.read()
if (done) {
break
} else {
chunks.push(value)
}
}
return concatArrayBuffers(chunks)
}

/**
*
* @param {string} str
* @returns {ReadableStream<Uint8Array>}
*/
export function stringToStream(str) {
const encoder = new TextEncoder()
const uint8Array = encoder.encode(str)

return new ReadableStream({
start(controller) {
controller.enqueue(uint8Array)
controller.close()
}
});
}
Loading

0 comments on commit f5a092a

Please sign in to comment.