Skip to content

Commit

Permalink
feat: move validation flow to a Durable Object to make it ⏩ fast ⏩ fa…
Browse files Browse the repository at this point in the history
…st ⏩ fast ⏩ (#449)

Introduce a [Cloudflare Durable
Objects](https://developers.cloudflare.com/workers/runtime-apis/durable-objects/)-based
space verification workflow.

Rather than stashing a UCAN in KV and polling inside the Websocket
handler until eventual consistency makes it available to the
registration process in the CLI or w3console, we use a Durable Object to
create a direct connection between the Websocket and the HTTP POST that
provides the delegation.

It feels most natural to create a Durable Object per space and forward
the Websocket request on to the Durable Object and let it handle it
entirely. This necessitates adding the space DID information to the HTTP
path, which means adding a new endpoint and will require clients to be
updated in the wild before we get rid of the KV flow entirely. We should
remove KV-based verification once this has rolled out to all of our
users.

TODO
- [x] write tests
- [ ] make sure error handling makes sense

---------

Co-authored-by: Benjamin Goering <171782+gobengo@users.noreply.github.com>
  • Loading branch information
travis and gobengo authored Mar 14, 2023
1 parent a08b513 commit 3868d97
Show file tree
Hide file tree
Showing 13 changed files with 278 additions and 14 deletions.
2 changes: 1 addition & 1 deletion packages/access-api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@
"toucan-js": "^2.7.0"
},
"devDependencies": {
"@cloudflare/workers-types": "^3.18.0",
"@cloudflare/workers-types": "^3.19.0",
"@databases/split-sql-query": "^1.0.3",
"@databases/sql": "^3.2.0",
"@sentry/cli": "2.7.0",
Expand Down
8 changes: 8 additions & 0 deletions packages/access-api/src/bindings.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,12 @@ import { ProvisionsStorage } from './types/provisions.js'

export {}

export type {
DurableObjectNamespace,
DurableObjectState,
Response as WorkerResponse,
} from '@cloudflare/workers-types'

// CF Analytics Engine types not available yet
export interface AnalyticsEngine {
writeDataPoint: (event: AnalyticsEngineEvent) => void
Expand Down Expand Up @@ -53,6 +59,7 @@ export interface Env {
SPACES: KVNamespace
VALIDATIONS: KVNamespace
W3ACCESS_METRICS: AnalyticsEngine
SPACE_VERIFIERS: DurableObjectNamespace
// eslint-disable-next-line @typescript-eslint/naming-convention
__D1_BETA__: D1Database
}
Expand All @@ -71,6 +78,7 @@ export interface RouteContext {
validations: Validations
}
uploadApi: ConnectionView
spaceVerifiers: DurableObjectNamespace
}

export type Handler = _Handler<RouteContext>
Expand Down
131 changes: 131 additions & 0 deletions packages/access-api/src/durable-objects/space-verifier.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
import { stringToDelegation } from '@web3-storage/access/encoding'

/**
*
* @template {import('@ucanto/interface').Capabilities} [T=import('@ucanto/interface').Capabilities]
* @param {import('../bindings').DurableObjectNamespace} spaceVerifiers
* @param {string} space
* @param {import('@web3-storage/access/src/types').EncodedDelegation<T>} ucan
*/
export async function sendDelegationToSpaceVerifier(
spaceVerifiers,
space,
ucan
) {
const durableObjectID = spaceVerifiers.idFromName(space)
const durableObject = spaceVerifiers.get(durableObjectID)
// hostname is totally ignored by the durable object but must be set so set it to example.com
const response = await durableObject.fetch('https://example.com/delegation', {
method: 'PUT',
body: ucan,
})
if (response.status === 400) {
throw new Error(response.statusText)
}
}

/**
* @template {import('@ucanto/interface').Capabilities} [T=import('@ucanto/interface').Capabilities]
* @param {WebSocket} server
* @param {import('@web3-storage/access/src/types').EncodedDelegation<T>} ucan
*/
function sendDelegation(server, ucan) {
server.send(
JSON.stringify({
type: 'delegation',
delegation: ucan,
})
)
server.close()
}

/**
* SpaceVerifier
*/
export class SpaceVerifier {
/**
* @param {import('../bindings').DurableObjectState} state
*/
constructor(state) {
this.state = state
// `blockConcurrencyWhile()` ensures no requests are delivered until
// initialization completes.
this.state.blockConcurrencyWhile(async () => {
this.ucan = await this.state.storage.get('ucan')
})
}

cleanupServer() {
this.server = undefined
}

async cleanupUCAN() {
this.ucan = undefined
await this.state.storage.put('ucan', '')
}

/**
* @param {Request} req
*/
async fetch(req) {
const path = new URL(req.url).pathname
if (req.method === 'GET' && path.startsWith('/validate-ws/')) {
const upgradeHeader = req.headers.get('Upgrade')
if (!upgradeHeader || upgradeHeader !== 'websocket') {
return new Response('Expected Upgrade: websocket', { status: 426 })
}
if (this.server) {
return new Response('Websocket already connected for this space.', {
status: 409,
})
}
const [client, server] = Object.values(new WebSocketPair())
// @ts-ignore
server.accept()
// if the user has already verified and set this.ucan here, send them the delegation

if (this.ucan) {
sendDelegation(
server,
/** @type {import('@web3-storage/access/src/types').EncodedDelegation} */ (
this.ucan
)
)
await this.cleanupUCAN()
} else {
this.server = server
}
return new Response(undefined, {
status: 101,
webSocket: client,
})
} else if (req.method === 'PUT' && path === '/delegation') {
const ucan = await req.text()
const delegation = stringToDelegation(ucan)

// it's only important to check expiration here - if we successfully validate before expiration
// here and a user connects to the websocket later after expiration we should still send the delegation
if (Date.now() < delegation.expiration * 1000) {
if (this.server) {
sendDelegation(this.server, ucan)
this.cleanupServer()
} else {
await this.state.storage.put('ucan', ucan)
this.ucan = ucan
}
return new Response(undefined, {
status: 200,
})
} else {
this.server?.close()
return new Response('Delegation expired', {
status: 400,
})
}
} else {
return new Response("SpaceVerifier can't handle this request", {
status: 404,
})
}
}
}
4 changes: 4 additions & 0 deletions packages/access-api/src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { postRaw } from './routes/raw.js'
import { postRoot } from './routes/root.js'
import { preValidateEmail, validateEmail } from './routes/validate-email.js'
import { validateWS } from './routes/validate-ws.js'
import { validateWSDID } from './routes/validate-ws-did.js'
import { version } from './routes/version.js'
import { getContext } from './utils/context.js'

Expand All @@ -17,6 +18,7 @@ r.add('get', '/version', version)
r.add('get', '/validate-email', preValidateEmail)
r.add('post', '/validate-email', validateEmail)
r.add('get', '/validate-ws', validateWS)
r.add('get', '/validate-ws/:did', validateWSDID)
r.add('post', '/', postRoot)
r.add('post', '/raw', postRaw)

Expand All @@ -39,4 +41,6 @@ const worker = {
},
}

export { SpaceVerifier } from './durable-objects/space-verifier.js'

export default worker
17 changes: 16 additions & 1 deletion packages/access-api/src/models/validations.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { stringToDelegation } from '@web3-storage/access/encoding'
import { sendDelegationToSpaceVerifier } from '../durable-objects/space-verifier.js'

/**
* Validations
Expand All @@ -7,9 +8,11 @@ export class Validations {
/**
*
* @param {KVNamespace} kv
* @param {import('../bindings').DurableObjectNamespace | undefined} spaceVerifiers
*/
constructor(kv) {
constructor(kv, spaceVerifiers) {
this.kv = kv
this.spaceVerifiers = spaceVerifiers
}

/**
Expand All @@ -22,10 +25,22 @@ export class Validations {
stringToDelegation(ucan)
)

// TODO: remove this KV stuff once we have the durable objects stuff in production
await this.kv.put(delegation.audience.did(), ucan, {
expiration: delegation.expiration,
})

const cap =
/** @type import('@ucanto/interface').InferInvokedCapability<typeof import('@web3-storage/capabilities/voucher').redeem> */ (
delegation.capabilities[0]
)
if (this.spaceVerifiers && cap.nb?.space) {
await sendDelegationToSpaceVerifier(
this.spaceVerifiers,
cap.nb.space,
ucan
)
}
return delegation
}

Expand Down
17 changes: 17 additions & 0 deletions packages/access-api/src/routes/validate-ws-did.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
/**
* @param {import('@web3-storage/worker-utils/router').ParsedRequest} req
* @param {import('../bindings.js').RouteContext} env
*/
export async function validateWSDID(req, env) {
const durableObjectID = env.spaceVerifiers.idFromName(req.params.did)
const durableObject = env.spaceVerifiers.get(durableObjectID)
/** @type {import('../bindings.js').WorkerResponse} */
const response = await durableObject.fetch(req)
// wrap the response because it's not possible to set headers on the response we get back from the durable object
return new Response(response.body, {
status: response.status,
statusText: response.statusText,
headers: response.headers,
webSocket: response.webSocket,
})
}
10 changes: 5 additions & 5 deletions packages/access-api/src/service/voucher-claim.js
Original file line number Diff line number Diff line change
Expand Up @@ -35,16 +35,16 @@ export function voucherClaimProvider(ctx) {
.delegate()

const encoded = delegationToString(inv)
// For testing
if (ctx.config.ENV === 'test') {
return encoded
}

const url = `${ctx.url.protocol}//${ctx.url.host}/validate-email?ucan=${encoded}`

await ctx.email.sendValidation({
to: capability.nb.identity.replace('mailto:', ''),
url,
})

// For testing
if (ctx.config.ENV === 'test') {
return encoded
}
})
}
3 changes: 2 additions & 1 deletion packages/access-api/src/utils/context.js
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,7 @@ export function getContext(request, env, ctx) {
})
),
spaces: new Spaces(config.DB),
validations: new Validations(config.VALIDATIONS),
validations: new Validations(config.VALIDATIONS, env.SPACE_VERIFIERS),
accounts: new Accounts(config.DB),
provisions: new DbProvisions(signer.did(), createD1Database(config.DB)),
},
Expand All @@ -87,5 +87,6 @@ export function getContext(request, env, ctx) {
url: new URL(config.UPLOAD_API_URL),
fetch: globalThis.fetch.bind(globalThis),
}),
spaceVerifiers: env.SPACE_VERIFIERS,
}
}
2 changes: 1 addition & 1 deletion packages/access-api/test/helpers/context.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ dotenv.config({
})

/**
* @typedef {Omit<import('../../src/bindings').Env, 'SPACES'|'VALIDATIONS'|'__D1_BETA__'>} AccessApiBindings - bindings object expected by access-api workers
* @typedef {Omit<import('../../src/bindings').Env, 'SPACES'|'VALIDATIONS'|'__D1_BETA__'|'SPACE_VERIFIERS'>} AccessApiBindings - bindings object expected by access-api workers
*/

/**
Expand Down
Loading

0 comments on commit 3868d97

Please sign in to comment.